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>
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# Legacy app binaries and decompiled sources
|
||||
App/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Playwright
|
||||
.playwright-mcp/
|
||||
test-results/
|
||||
|
||||
# Flutter build outputs
|
||||
copilot_keyboard/build/
|
||||
copilot_keyboard/.dart_tool/
|
||||
copilot_keyboard/.idea/
|
||||
copilot_keyboard/.flutter-plugins
|
||||
copilot_keyboard/.flutter-plugins-dependencies
|
||||
copilot_keyboard/.metadata
|
||||
copilot_keyboard/windows/
|
||||
|
||||
# Temporary screenshots
|
||||
flutter_screenshot.png
|
||||
flutter_section_screenshot.png
|
||||
flutter_selected_screenshot.png
|
||||
flutter_fullscreen.png
|
||||
|
||||
# Temp/utility scripts
|
||||
screenshot.js
|
||||
screenshot_fullscreen.js
|
||||
screenshot.mjs
|
||||
activate_and_screenshot.ps1
|
||||
maximize_and_screenshot.ps1
|
||||
take_screenshot.ps1
|
||||
convert_doc.ps1
|
||||
extract_doc.py
|
||||
extract_html.py
|
||||
test_coordinator.ps1
|
||||
package.json
|
||||
nul
|
||||
|
||||
# Decompiled text dumps
|
||||
C*DEVCOPILOT_D6*
|
||||
11864
Docs/Aplikační_server-Business_analýza.doc
Normal file
4257
Docs/Aplikační_server-Use_Cases.doc
Normal file
224
Docs/IMPLEMENTATION_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# COPILOT Implementation Quick Reference
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ KEYBOARD (LattePanda Sigma) │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Flutter App (UI + PRIMARY Logic if elected) │ │
|
||||
│ │ • Camera/Monitor selection │ │
|
||||
│ │ • PTZ controls │ │
|
||||
│ │ • Alarm display │ │
|
||||
│ │ • Sequence management (PRIMARY only) │ │
|
||||
│ └─────────────────────────┬──────────────────────────────────┘ │
|
||||
│ │ localhost HTTP │
|
||||
│ ┌─────────────────────────┼──────────────────────────────────┐ │
|
||||
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
|
||||
│ │ │GeViScope│ │ G-Core │ │GeViSrvr │ C# Bridges (.NET 8) │ │
|
||||
│ │ │ :7720 │ │ :7721 │ │ :7710 │ │ │
|
||||
│ │ └────┬────┘ └────┬────┘ └────┬────┘ │ │
|
||||
│ └───────┼───────────┼───────────┼────────────────────────────┘ │
|
||||
└──────────┼───────────┼───────────┼──────────────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
GeViScope G-Core GeViServer
|
||||
Servers Servers (PTZ only)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Commands
|
||||
|
||||
### ViewerConnectLive (Switch Camera to Monitor)
|
||||
```http
|
||||
POST http://localhost:7720/api/crossswitch
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"camera_id": 101,
|
||||
"monitor_id": 5,
|
||||
"mode": 0
|
||||
}
|
||||
```
|
||||
|
||||
### PTZ Control
|
||||
```http
|
||||
POST http://localhost:7720/api/ptz/move
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"camera_id": 101,
|
||||
"pan": 50,
|
||||
"tilt": 30,
|
||||
"zoom": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Query Active Alarms
|
||||
```http
|
||||
GET http://localhost:7720/api/alarms/active
|
||||
```
|
||||
|
||||
### Query Monitor State
|
||||
```http
|
||||
GET http://localhost:7720/api/monitors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Queries (SDK)
|
||||
|
||||
| Query | Purpose | Answer Type |
|
||||
|-------|---------|-------------|
|
||||
| `GeViSQ_GetFirstAlarm(activeOnly, enabledOnly)` | First active alarm | `GeViSA_AlarmInfo` |
|
||||
| `GeViSQ_GetNextAlarm(...)` | Next alarm in list | `GeViSA_AlarmInfo` |
|
||||
| `GeViSQ_GetFirstVideoOutput(activeOnly, enabledOnly)` | First monitor | `GeViSA_VideoOutputInfo` |
|
||||
| `GeViSQ_GetNextVideoOutput(...)` | Next monitor | `GeViSA_VideoOutputInfo` |
|
||||
| `GeViSQ_GetFirstVideoInput(activeOnly, enabledOnly)` | First camera | `GeViSA_VideoInputInfo` |
|
||||
| `GeViSQ_GetNextVideoInput(...)` | Next camera | `GeViSA_VideoInputInfo` |
|
||||
|
||||
---
|
||||
|
||||
## Event Notifications (Subscribe via PLC)
|
||||
|
||||
| Event | When Fired | Key Fields |
|
||||
|-------|------------|------------|
|
||||
| `EventStarted` | Alarm triggered | EventID, TypeID, ForeignKey |
|
||||
| `EventStopped` | Alarm cleared | EventID, TypeID |
|
||||
| `ViewerConnected` | Camera switched to monitor | Viewer, Channel, PlayMode |
|
||||
| `ViewerCleared` | Monitor cleared | Viewer |
|
||||
| `ViewerSelectionChanged` | Monitor content changed | Viewer, Channel, PlayMode |
|
||||
| `VCAlarmQueueNotification` | Alarm queue change | Viewer, Notification, AlarmID |
|
||||
| `DigitalInput` | External contact change | Contact, State |
|
||||
|
||||
---
|
||||
|
||||
## Alarm States (PlcViewerAlarmState)
|
||||
|
||||
| Value | Name | Description | Monitor Blocked? |
|
||||
|-------|------|-------------|------------------|
|
||||
| 0 | `vasNewAlarm` | New alarm added | YES |
|
||||
| 1 | `vasPresented` | Currently displayed | YES |
|
||||
| 2 | `vasStacked` | In queue, not displayed | Depends |
|
||||
| 3 | `vasConfirmed` | Acknowledged | NO |
|
||||
|
||||
---
|
||||
|
||||
## PTZ Lock Flow
|
||||
|
||||
```
|
||||
1. Keyboard → PRIMARY: RequestLock(cameraId, priority)
|
||||
2. PRIMARY checks:
|
||||
- Camera locked? → Compare priority
|
||||
- High > Low priority wins
|
||||
- Same priority → First wins
|
||||
3. PRIMARY → Keyboard: LockGranted/LockDenied
|
||||
4. If granted: Keyboard sends PTZ directly to server
|
||||
5. Lock expires after 5 minutes
|
||||
6. Warning sent at 4 minutes (1 min before expiry)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Failover Timeline
|
||||
|
||||
```
|
||||
T+0s PRIMARY stops sending heartbeats
|
||||
T+2s STANDBY misses 1st heartbeat
|
||||
T+4s STANDBY misses 2nd heartbeat
|
||||
T+6s STANDBY misses 3rd heartbeat → Declares itself PRIMARY
|
||||
T+6s New PRIMARY broadcasts role change
|
||||
T+6s Keyboards reconnect to new PRIMARY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Degraded Mode (No PRIMARY/STANDBY)
|
||||
|
||||
| Feature | Works? | Notes |
|
||||
|---------|--------|-------|
|
||||
| ViewerConnectLive | ✅ | Direct to server |
|
||||
| PTZ Control | ✅ | Direct to server |
|
||||
| PTZ Locking | ❌ | No coordinator |
|
||||
| Sequences | ❌ | Runs on PRIMARY |
|
||||
| State Sync | ❌ | No broadcaster |
|
||||
| Alarms | ⚠️ | Local only per keyboard |
|
||||
|
||||
---
|
||||
|
||||
## Playback Commands
|
||||
|
||||
```
|
||||
// Seek to timestamp
|
||||
ViewerPlayFromTime(viewer, channel, "play forward",
|
||||
"2024/01/15 14:30:00,000 GMT+01:00")
|
||||
|
||||
// Set playback speed (2x)
|
||||
ViewerSetPlayMode(viewer, "play forward", 2.0)
|
||||
|
||||
// Jump back 60 seconds
|
||||
ViewerJumpByTime(viewer, channel, "play forward", -60)
|
||||
|
||||
// Export snapshot
|
||||
ViewerExportPicture(viewer, "C:\\Snapshots\\frame.bmp")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Port Reference
|
||||
|
||||
| Port | Service |
|
||||
|------|---------|
|
||||
| 7700-7703 | GeViServer (native) |
|
||||
| 7710 | GeViServer Bridge (REST) |
|
||||
| 7720 | GeViScope Bridge (REST) |
|
||||
| 7721 | G-Core Bridge (REST) |
|
||||
| 8090 | PRIMARY WebSocket |
|
||||
| 50051 | gRPC (if used) |
|
||||
|
||||
---
|
||||
|
||||
## Startup Sequence
|
||||
|
||||
```
|
||||
1. Start bridges (GeViScope, G-Core, GeViServer)
|
||||
2. Wait for bridge health checks to pass
|
||||
3. Query current alarm state from all servers
|
||||
4. Query current monitor state from all servers
|
||||
5. Subscribe to event notifications
|
||||
6. Connect to PRIMARY (or start election if none)
|
||||
7. Sync shared state from PRIMARY
|
||||
8. Start periodic alarm sync (every 30s)
|
||||
9. Ready to accept user commands
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Action |
|
||||
|-------|--------|
|
||||
| Bridge unreachable | Retry 3x, then show offline status |
|
||||
| Command timeout | Retry 1x, then report failure |
|
||||
| PRIMARY unreachable | Continue in degraded mode |
|
||||
| Alarm query fails | Use cached state, retry on next sync |
|
||||
| Lock request timeout | Assume denied, inform user |
|
||||
|
||||
---
|
||||
|
||||
## Logging Format
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-15T14:30:00.123Z",
|
||||
"level": "INFO",
|
||||
"keyboard_id": "KB-001",
|
||||
"user": "operator1",
|
||||
"event": "command_executed",
|
||||
"command": "ViewerConnectLive",
|
||||
"params": { "camera_id": 101, "monitor_id": 5 },
|
||||
"duration_ms": 45,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
15715
Docs/Klavesnice_Business_Analyza.txt
Normal file
1286
Docs/Klavesnice_Business_Analyza_clean.txt
Normal file
15715
Docs/Klávesnice_COPILOT-Business_Analýza.doc
Normal file
1
Docs/Klávesnice_COPILOT-Business_Analýza.txt
Normal file
@@ -0,0 +1 @@
|
||||
Error: not an OLE2 structured storage file
|
||||
559
Docs/NEW_SYSTEM_DESIGN_SUMMARY.md
Normal file
@@ -0,0 +1,559 @@
|
||||
# COPILOT New System Design Summary
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Date:** 2026-02-03
|
||||
**Status:** Design Complete - Ready for Implementation
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document describes the architecture for rewriting the COPILOT CCTV keyboard controller system with a new master/slave architecture. The new system eliminates the need for dedicated server hardware by enabling any keyboard to act as the coordination PRIMARY, while maintaining critical functionality (ViewerConnectLive) even when the coordination layer fails.
|
||||
|
||||
### Key Goals
|
||||
- **Zero extra hardware** - PRIMARY runs on a keyboard (LattePanda Sigma)
|
||||
- **Direct command path** - Commands go directly to servers, not through a coordinator
|
||||
- **High availability** - Automatic failover with degraded mode support
|
||||
- **Cross-platform** - Flutter-based keyboards (web/desktop)
|
||||
- **State consistency** - Query-based alarm state, not just event subscription
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
### 1.1 Current System (COPILOT_D6)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ COPILOT_D6 ARCHITECTURE │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Keyboards (WPF) ──────► AppServer (Central) ──────► GeViSoft │
|
||||
│ - SignalR Hub │
|
||||
│ - Lock Management │
|
||||
│ - Separate Hardware │
|
||||
│ │
|
||||
│ Problems: │
|
||||
│ • Central AppServer = single point of failure │
|
||||
│ • Extra hardware required │
|
||||
│ • All commands routed through central point │
|
||||
│ • WPF = Windows-only │
|
||||
│ • Event-only alarm tracking (misses alarms if offline) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 New System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ NEW ARCHITECTURE │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Keyboard 1 │ │ Keyboard 2 │ │ Keyboard N │ │
|
||||
│ │ (PRIMARY) │ │ (STANDBY) │ │ (REGULAR) │ │
|
||||
│ │ Flutter │ │ Flutter │ │ Flutter │ │
|
||||
│ │ + Bridges │ │ + Bridges │ │ + Bridges │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ WebSocket (coordination only) │ │
|
||||
│ ├──────────────────┼──────────────────┤ │
|
||||
│ │ │ │ │
|
||||
│ │ Direct HTTP (ViewerConnectLive, PTZ) │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ GeViScope │ │ G-Core │ │ GeViServer │ │
|
||||
│ │ Servers │ │ Servers │ │ (PTZ only) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ Benefits: │
|
||||
│ • No extra hardware (PRIMARY runs on keyboard) │
|
||||
│ • Direct commands = low latency (<100ms) │
|
||||
│ • Automatic failover to STANDBY │
|
||||
│ • ViewerConnectLive works even if PRIMARY dead │
|
||||
│ • Query-based alarm state (never miss alarms) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Hardware Platform
|
||||
|
||||
### 2.1 LattePanda Sigma SBC (Inside Each Keyboard)
|
||||
|
||||
| Component | Specification |
|
||||
|-----------|---------------|
|
||||
| **CPU** | Intel Core i5-1340P (13th Gen), 12 cores/16 threads, up to 4.6 GHz |
|
||||
| **RAM** | 16GB or 32GB LPDDR5-6400 (102.4 GB/s bandwidth) |
|
||||
| **GPU** | Intel Iris Xe Graphics (80 EUs), up to 4x 4K displays |
|
||||
| **Network** | Dual 2.5 GbE ports |
|
||||
| **TDP** | Sustained 44W with proper cooling |
|
||||
| **Size** | 102mm × 146mm |
|
||||
|
||||
### 2.2 Resource Usage (Per Keyboard)
|
||||
|
||||
| Process | RAM Usage | CPU Usage |
|
||||
|---------|-----------|-----------|
|
||||
| Flutter Keyboard App | ~200-400 MB | <5% |
|
||||
| GeViScope Bridge (.NET 8) | ~100-150 MB | <2% |
|
||||
| G-Core Bridge (.NET 8) | ~100-150 MB | <2% |
|
||||
| GeViServer Bridge (.NET 8) | ~100-150 MB | <2% |
|
||||
| OS + Background | ~2-3 GB | Variable |
|
||||
| **Total** | **~3-4 GB** | **<20% typical** |
|
||||
|
||||
**Verdict:** LattePanda Sigma with 16GB RAM is highly suitable. 32GB provides future margin.
|
||||
|
||||
---
|
||||
|
||||
## 3. Component Responsibilities
|
||||
|
||||
### 3.1 PRIMARY Keyboard
|
||||
|
||||
The PRIMARY is elected from available keyboards based on priority. It handles:
|
||||
|
||||
| Responsibility | Description |
|
||||
|----------------|-------------|
|
||||
| **PTZ Lock Management** | Grant/revoke locks, enforce priority, handle timeouts |
|
||||
| **Shared State** | Current video wall state (monitor → camera mappings) |
|
||||
| **Sequence Engine** | Camera rotation sequences on monitors |
|
||||
| **Alarm Coordination** | Aggregate alarm state, broadcast to keyboards |
|
||||
| **Keyboard Registry** | Track which keyboards are online |
|
||||
| **WebSocket Hub** | Broadcast state changes to all keyboards |
|
||||
|
||||
**What PRIMARY Does NOT Do:**
|
||||
- Route ViewerConnectLive commands (keyboards send directly)
|
||||
- Route PTZ commands (keyboards send directly)
|
||||
- Act as single point of failure for critical operations
|
||||
|
||||
### 3.2 STANDBY Keyboard
|
||||
|
||||
- Receives all state updates from PRIMARY
|
||||
- Maintains synchronized copy of shared state
|
||||
- Monitors PRIMARY via heartbeat (every 2 seconds)
|
||||
- Promotes to PRIMARY if heartbeat fails (6 seconds timeout)
|
||||
|
||||
### 3.3 REGULAR Keyboards
|
||||
|
||||
- Send commands directly to servers
|
||||
- Request PTZ locks from PRIMARY
|
||||
- Receive state broadcasts from PRIMARY
|
||||
- Can operate in degraded mode without PRIMARY
|
||||
|
||||
### 3.4 C# Bridges (Per Keyboard)
|
||||
|
||||
| Bridge | Port | SDK | Purpose |
|
||||
|--------|------|-----|---------|
|
||||
| GeViScope Bridge | 7720 | GeViScope SDK (32-bit) | ViewerConnectLive, PTZ, alarms |
|
||||
| G-Core Bridge | 7721 | G-Core SDK | ViewerConnectLive, PTZ, alarms |
|
||||
| GeViServer Bridge | 7710 | GeViSoft SDK | PTZ fallback, state queries |
|
||||
|
||||
---
|
||||
|
||||
## 4. Command Flow
|
||||
|
||||
### 4.1 ViewerConnectLive (Camera → Monitor)
|
||||
|
||||
```
|
||||
Keyboard Server
|
||||
│ │
|
||||
│ 1. User selects camera + monitor │
|
||||
│ │
|
||||
│ 2. Route to correct server │
|
||||
│ (based on camera ID mapping) │
|
||||
│ │
|
||||
│ 3. HTTP POST to local bridge │
|
||||
│ POST localhost:7720/crossswitch│
|
||||
│ ─────────────────────────► │
|
||||
│ │
|
||||
│ 4. Bridge calls SDK │
|
||||
│ ViewerConnectLive(cam, mon) │
|
||||
│ ─────────────────────────► │ GeViScope/G-Core
|
||||
│ │
|
||||
│ 5. Server executes, fires event │
|
||||
│ ViewerConnected notification │
|
||||
│ ◄───────────────────────────── │
|
||||
│ │
|
||||
│ 6. Update local state │
|
||||
│ 7. Notify PRIMARY (if online) │
|
||||
│ │
|
||||
```
|
||||
|
||||
**Key Point:** Commands go DIRECTLY to servers. PRIMARY is only notified for state tracking.
|
||||
|
||||
### 4.2 PTZ Control with Locking
|
||||
|
||||
```
|
||||
Keyboard PRIMARY Server
|
||||
│ │ │
|
||||
│ 1. Request PTZ lock │ │
|
||||
│ ────────────────────────►│ │
|
||||
│ │ │
|
||||
│ 2. PRIMARY checks: │ │
|
||||
│ - Is camera locked? │ │
|
||||
│ - Priority comparison │ │
|
||||
│ │ │
|
||||
│ 3. Lock granted/denied │ │
|
||||
│ ◄────────────────────────│ │
|
||||
│ │ │
|
||||
│ 4. If granted, send PTZ directly │
|
||||
│ ─────────────────────────────────────────────────► │
|
||||
│ │ │
|
||||
│ 5. Lock timeout (5 min) │ │
|
||||
│ Warning at 4 min │ │
|
||||
│ ◄────────────────────────│ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
### 4.3 PTZ Lock Configuration
|
||||
|
||||
| Setting | Value | Source |
|
||||
|---------|-------|--------|
|
||||
| Lock Timeout | 5 minutes | COPILOT_D6 LockExpirationConfig |
|
||||
| Warning Before Expiry | 1 minute | COPILOT_D6 NotificationBeforeExpiration |
|
||||
| Priority Levels | High, Low | COPILOT_D6 CameraLockOptions |
|
||||
|
||||
---
|
||||
|
||||
## 5. State Management
|
||||
|
||||
### 5.1 Video Wall State Verification
|
||||
|
||||
**Problem:** No direct "query current state" API in GeViScope/G-Core SDKs.
|
||||
|
||||
**Solution:** Event-based state tracking with startup query.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ STATE VERIFICATION APPROACH │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. STARTUP: Query current state │
|
||||
│ - GetFirstVideoOutput / GetNextVideoOutput │
|
||||
│ - sActualCamera field = currently displayed camera │
|
||||
│ │
|
||||
│ 2. REAL-TIME: Subscribe to notifications │
|
||||
│ - ViewerConnected(Viewer, Channel, PlayMode) │
|
||||
│ - ViewerCleared(Viewer) │
|
||||
│ - ViewerSelectionChanged(Viewer, Channel, ...) │
|
||||
│ │
|
||||
│ 3. VERIFICATION: After sending command │
|
||||
│ - Wait for ViewerConnected notification (500ms timeout) │
|
||||
│ - If received: confirm state │
|
||||
│ - If timeout: flag potential failure │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 Alarm State Management
|
||||
|
||||
**Problem (D6):** Event-only tracking misses alarms if AppServer offline.
|
||||
|
||||
**Solution:** Query + Subscribe + Periodic Sync.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ALARM STATE APPROACH │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. STARTUP: Query ALL active alarms │
|
||||
│ - GeViSQ_GetFirstAlarm(activeOnly=true) │
|
||||
│ - GeViSQ_GetNextAlarm() until no more │
|
||||
│ - Populate local alarm state BEFORE accepting commands │
|
||||
│ │
|
||||
│ 2. REAL-TIME: Subscribe to events │
|
||||
│ - EventStarted(EventID, TypeID) → Add alarm │
|
||||
│ - EventStopped(EventID) → Remove alarm │
|
||||
│ - VCAlarmQueueNotification → Update monitor reservation │
|
||||
│ │
|
||||
│ 3. PERIODIC SYNC: Every 30 seconds │
|
||||
│ - Re-query all active alarms │
|
||||
│ - Compare with local state │
|
||||
│ - Add missing, remove stale │
|
||||
│ - Log discrepancies │
|
||||
│ │
|
||||
│ RESULT: Never miss an alarm, even after restart/reconnect │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.3 Alarm → Monitor Blocking
|
||||
|
||||
| Alarm State | Value | Monitor Effect |
|
||||
|-------------|-------|----------------|
|
||||
| `vasNewAlarm` | 0 | Monitor reserved, cannot switch |
|
||||
| `vasPresented` | 1 | Alarm displayed, cannot switch |
|
||||
| `vasStacked` | 2 | Alarm in queue, monitor may be switchable |
|
||||
| `vasConfirmed` | 3 | Acknowledged, normal operation resumes |
|
||||
|
||||
---
|
||||
|
||||
## 6. Failover and Degraded Mode
|
||||
|
||||
### 6.1 Failover Sequence
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FAILOVER SEQUENCE │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ NORMAL OPERATION: │
|
||||
│ • PRIMARY sends heartbeat every 2 seconds │
|
||||
│ • STANDBY receives and acknowledges │
|
||||
│ │
|
||||
│ PRIMARY FAILURE DETECTED (3 missed heartbeats = 6 seconds): │
|
||||
│ 1. STANDBY declares itself PRIMARY │
|
||||
│ 2. STANDBY broadcasts: "I am now PRIMARY" │
|
||||
│ 3. STANDBY starts accepting lock requests │
|
||||
│ 4. STANDBY starts sending heartbeats │
|
||||
│ 5. Next highest priority keyboard becomes STANDBY │
|
||||
│ │
|
||||
│ SPLIT-BRAIN PREVENTION: │
|
||||
│ • Only one PRIMARY at a time (priority-based election) │
|
||||
│ • If old PRIMARY comes back, it defers to new PRIMARY │
|
||||
│ • Epoch/generation counter prevents stale PRIMARY takeover │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 Degraded Mode
|
||||
|
||||
When BOTH PRIMARY and STANDBY are unavailable:
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| **ViewerConnectLive** | ✅ Works | Direct to server |
|
||||
| **PTZ Control** | ✅ Works | Direct to server |
|
||||
| **PTZ Locking** | ❌ Unavailable | No coordinator |
|
||||
| **Sequences** | ❌ Stopped | Runs on PRIMARY |
|
||||
| **State Sync** | ❌ Unavailable | No broadcaster |
|
||||
| **Alarm State** | ⚠️ Local only | Each keyboard tracks independently |
|
||||
|
||||
**Critical Path Guarantee:** Operators can always switch cameras and control PTZ, even in degraded mode.
|
||||
|
||||
---
|
||||
|
||||
## 7. Recorded Video Access
|
||||
|
||||
### 7.1 Playback Actions (Direct to GeViScope/G-Core)
|
||||
|
||||
| Action | Purpose | Parameters |
|
||||
|--------|---------|------------|
|
||||
| `ViewerConnect` | Play recorded video | Viewer, Channel, PlayMode |
|
||||
| `ViewerPlayFromTime` | Seek to timestamp | Viewer, Channel, PlayMode, Time |
|
||||
| `ViewerSetPlayMode` | Control speed/direction | Viewer, PlayMode, PlaySpeed |
|
||||
| `ViewerJumpByTime` | Relative seek | Viewer, Channel, PlayMode, TimeInSec |
|
||||
| `ViewerExportPicture` | Snapshot export | Viewer, FilePath |
|
||||
|
||||
### 7.2 Play Modes
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `play forward` | Normal speed forward |
|
||||
| `play backward` | Normal speed backward |
|
||||
| `fast forward` / `fast backward` | High speed |
|
||||
| `step forward` / `step backward` | Frame by frame |
|
||||
| `play stop` | Pause |
|
||||
| `play BOD` / `play EOD` | Beginning/End of database |
|
||||
| `next detected motion` | Skip to motion event |
|
||||
|
||||
---
|
||||
|
||||
## 8. External Systems Integration
|
||||
|
||||
### 8.1 Alarm Sources (Digital Inputs)
|
||||
|
||||
```
|
||||
External System → LAN I/O Device → DigitalInput Action → Event → Alarm
|
||||
(MIO, IOI43) Contact + State
|
||||
```
|
||||
|
||||
**DigitalInputState Values:**
|
||||
- `disLow` (0) - Contact open/inactive
|
||||
- `disMiddle` (1) - Terminated (tamper)
|
||||
- `disHigh` (2) - Contact closed/active
|
||||
|
||||
### 8.2 Logging (ELK Stack)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ LOGGING ARCHITECTURE │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Each Keyboard: │
|
||||
│ Flutter App ──► /var/log/copilot/keyboard.jsonl │
|
||||
│ Bridges ──────► /var/log/copilot/bridge-*.jsonl │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Filebeat (local) ──► Logstash ──► Elasticsearch ──► Kibana │
|
||||
│ │
|
||||
│ Log Format: JSON Lines (structured) │
|
||||
│ Retention: 30 days local, configurable in ELK │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.3 User Management (IAM Ready)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ AUTHENTICATION ABSTRACTION │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ interface IAuthProvider { │
|
||||
│ authenticate(credentials) → AuthResult │
|
||||
│ getUser(userId) → User │
|
||||
│ getPermissions(userId) → List<Permission> │
|
||||
│ logout(sessionId) │
|
||||
│ } │
|
||||
│ │
|
||||
│ Implementations: │
|
||||
│ Phase 1: LocalAuthProvider (JSON/SQLite) │
|
||||
│ Phase 2: LdapAuthProvider (Active Directory) │
|
||||
│ Phase 3: OidcAuthProvider (Keycloak, Azure AD, Okta) │
|
||||
│ │
|
||||
│ Permission Model: │
|
||||
│ viewLive, viewRecorded, ptzControl, ptzControlHighPriority, │
|
||||
│ switchMonitor, switchAlarmMonitor, manageSequences, │
|
||||
│ manageUsers, viewAuditLog, systemConfig │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration Path
|
||||
|
||||
### Phase 0: Infrastructure (Week 1-2)
|
||||
- Finalize C# bridges (GeViScope, G-Core, GeViServer)
|
||||
- Add PLC notification subscriptions
|
||||
- Test direct commands without GeViSoft routing
|
||||
- Document server configurations
|
||||
|
||||
### Phase 1: Flutter Keyboard Core (Week 3-5)
|
||||
- Keyboard layout UI
|
||||
- Direct command execution (ViewerConnectLive, PTZ)
|
||||
- Server routing logic
|
||||
- State notification subscription
|
||||
- Basic error handling
|
||||
|
||||
### Phase 2: Coordination Layer (Week 6-8)
|
||||
- PRIMARY election mechanism
|
||||
- PTZ lock management
|
||||
- Shared state (video wall, locks, keyboards)
|
||||
- WebSocket hub
|
||||
- State sync from server notifications
|
||||
|
||||
### Phase 3: Advanced Features (Week 9-11)
|
||||
- Sequence engine
|
||||
- CrossSwitch rules
|
||||
- Alarm handling
|
||||
- User authentication
|
||||
|
||||
### Phase 4: Testing & Cutover (Week 12-14)
|
||||
- Parallel operation testing
|
||||
- Failover testing
|
||||
- Load testing
|
||||
- Operator training
|
||||
- Production cutover
|
||||
|
||||
---
|
||||
|
||||
## 10. Key Design Decisions Summary
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Central Coordinator | PRIMARY keyboard | Zero extra hardware |
|
||||
| Command Path | Direct to servers | Low latency, no bottleneck |
|
||||
| State Verification | Event-based + startup query | GeViScope/G-Core have no query API |
|
||||
| GeViSoft Usage | Minimal (PTZ only if needed) | User requirement |
|
||||
| Failover | Priority-based STANDBY promotion | Automatic recovery |
|
||||
| Degraded Mode | ViewerConnectLive works | Critical path must always work |
|
||||
| PTZ Locks | 5-min timeout, priority levels | Match D6 behavior |
|
||||
| Alarm State | Query + Subscribe + Sync | Never miss alarms |
|
||||
| UI Technology | Flutter | Cross-platform |
|
||||
| Hardware | LattePanda Sigma 16GB | Sufficient for bridges + app |
|
||||
| Logging | Local JSON + Filebeat → ELK | Central Kibana dashboards |
|
||||
| Auth | Abstracted, IAM-ready | Future AD/OIDC integration |
|
||||
|
||||
---
|
||||
|
||||
## 11. Port Assignments
|
||||
|
||||
| Service | Port | Protocol |
|
||||
|---------|------|----------|
|
||||
| GeViServer | 7700-7703 | Native SDK |
|
||||
| GeViServer Bridge | 7710 | HTTP REST |
|
||||
| GeViScope Bridge | 7720 | HTTP REST |
|
||||
| G-Core Bridge | 7721 | HTTP REST |
|
||||
| Python API (if used) | 8000 | HTTP REST |
|
||||
| Flutter Web | 8081 | HTTP |
|
||||
| PRIMARY WebSocket | 8090 | WebSocket |
|
||||
| gRPC (SDK Bridge) | 50051 | gRPC |
|
||||
|
||||
---
|
||||
|
||||
## 12. Configuration Files
|
||||
|
||||
| File | Purpose | Location |
|
||||
|------|---------|----------|
|
||||
| `servers.json` | Bridge endpoints per keyboard | Per keyboard |
|
||||
| `keyboards.json` | Keyboard priorities, roles | Shared |
|
||||
| `sequences.json` | Camera rotation sequences | PRIMARY |
|
||||
| `crossswitch-rules.json` | Logical → physical routing | Shared |
|
||||
| `video-wall.json` | Monitor layout configuration | Shared |
|
||||
| `auth.yaml` | Authentication provider config | Per keyboard |
|
||||
|
||||
---
|
||||
|
||||
## 13. Open Questions for Implementation
|
||||
|
||||
1. **Bridge Deployment:** Bridges run locally on each keyboard (recommended) vs. centralized
|
||||
2. **PTZ via GeViServer:** Any PTZ that MUST go through GeViServer?
|
||||
3. **Alarm Source:** External system integration details
|
||||
4. **Sequence Persistence:** Where to store sequence state on PRIMARY failover?
|
||||
5. **Log Retention:** Local retention period before ELK shipping
|
||||
|
||||
---
|
||||
|
||||
## 14. Risk Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| PRIMARY failure | Automatic STANDBY promotion |
|
||||
| Network partition | Degraded mode maintains critical functions |
|
||||
| Missed alarms | Startup query + periodic sync |
|
||||
| State desync | Event verification + periodic full sync |
|
||||
| SDK incompatibility | C# bridges abstract SDK differences |
|
||||
| Performance | LattePanda Sigma has significant headroom |
|
||||
|
||||
---
|
||||
|
||||
## 15. Success Criteria
|
||||
|
||||
- [ ] ViewerConnectLive latency <100ms (direct path)
|
||||
- [ ] PTZ lock acquisition <50ms
|
||||
- [ ] Failover time <10 seconds
|
||||
- [ ] Zero missed alarms after keyboard restart
|
||||
- [ ] Degraded mode maintains camera switching
|
||||
- [ ] Support 10+ concurrent keyboards
|
||||
- [ ] State consistency >99.9%
|
||||
|
||||
---
|
||||
|
||||
## Document History
|
||||
|
||||
| Version | Date | Author | Changes |
|
||||
|---------|------|--------|---------|
|
||||
| 1.0 | 2026-02-03 | Claude (AI Assistant) | Initial design document |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- COPILOT_D6 Source: `C:\DEV\COPILOT_D6\App\`
|
||||
- GeViScope SDK Docs: `C:\DEV\COPILOT\GeViScope_SDK_Docs\`
|
||||
- G-Core SDK Docs: `C:\DEV\COPILOT\G-Core_SDK_Docs\`
|
||||
- Existing Bridges: `C:\DEV\COPILOT\geviscope-bridge\`, `C:\DEV\COPILOT\gcore-bridge\`
|
||||
- API Implementation: `C:\DEV\COPILOT\geutebruck-api\`
|
||||
126
Docs/legacy-architecture/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
title: "COPILOT D6 Legacy WPF Application"
|
||||
description: "Complete reverse-engineered architecture documentation of the original COPILOT D6 CCTV keyboard controller system"
|
||||
---
|
||||
|
||||
# COPILOT D6 Legacy Architecture
|
||||
|
||||
> Reverse-engineered from compiled .NET 7 assemblies (build 1.0.705, December 2023) using ILSpy decompilation. This documentation covers the complete architecture of the original WPF-based CCTV keyboard controller system.
|
||||
|
||||
## System Overview
|
||||
|
||||
The COPILOT D6 system is a **CCTV surveillance keyboard controller** used to manage video feeds from multiple camera servers (Geutebruck GeViScope, G-Core, GeViSoft) across a wall of physical monitors. Operators use a custom hardware keyboard with joystick to:
|
||||
|
||||
- Switch cameras to monitors (CrossSwitch)
|
||||
- Control PTZ cameras (Pan/Tilt/Zoom) via joystick
|
||||
- Manage camera prepositions (saved positions)
|
||||
- Run camera sequences (automated cycling)
|
||||
- View and manage alarms
|
||||
- Playback recorded video
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```
|
||||
COPILOT SYSTEM ARCHITECTURE
|
||||
============================
|
||||
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ COPILOT KEYBOARD (Hardware) │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
|
||||
│ │ Numpad + │ │ 3-Axis │ │ Jog/Shuttle │ │
|
||||
│ │ Function │ │ Joystick │ │ Wheel │ │
|
||||
│ │ Keys │ │ (HID USB) │ │ │ │
|
||||
│ │ [Serial] │ │ X/Y/Z Axes │ │ [Serial] │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └───────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┼───────────────────┘ │
|
||||
│ Arduino Leonardo │
|
||||
│ (USB Composite Device) │
|
||||
└────────────────────────────┬──────────────────────────────┘
|
||||
│ USB (Serial + HID)
|
||||
┌────────────────────────────┼──────────────────────────────┐
|
||||
│ LattePanda Sigma SBC │
|
||||
│ ┌─────────────────────────┴──────────────────────────┐ │
|
||||
│ │ Copilot.App.exe (WPF .NET 7) │ │
|
||||
│ │ ┌──────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Copilot.Device Layer │ │ │
|
||||
│ │ │ Serial Port ←→ Keyboard Keys/Jog/Shuttle │ │ │
|
||||
│ │ │ HID Device ←→ Joystick X/Y/Z │ │ │
|
||||
│ │ └──────────────────────┬───────────────────────┘ │ │
|
||||
│ │ │ Events │ │
|
||||
│ │ ┌──────────────────────┴───────────────────────┐ │ │
|
||||
│ │ │ MainWindow (WPF) │ │ │
|
||||
│ │ │ Routes input → current ViewModel │ │ │
|
||||
│ │ └──────────────────────┬───────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌──────────────────────┴───────────────────────┐ │ │
|
||||
│ │ │ SegmentViewModel (main screen) │ │ │
|
||||
│ │ │ • PTZ control via joystick │ │ │
|
||||
│ │ │ • Camera number entry │ │ │
|
||||
│ │ │ • CrossSwitch execution │ │ │
|
||||
│ │ │ • Camera lock management │ │ │
|
||||
│ │ │ • Playback control (jog/shuttle) │ │ │
|
||||
│ │ └──┬────────────────────────────────────┬──────┘ │ │
|
||||
│ │ │ Direct SDK calls │ SignalR │ │
|
||||
│ │ ┌──┴──────────────────────┐ ┌─────────┴──────┐ │ │
|
||||
│ │ │ Camera Server │ │ AppServer │ │ │
|
||||
│ │ │ Drivers │ │ Client │ │ │
|
||||
│ │ │ • GeViScope SDK │ │ (SignalR Hub) │ │ │
|
||||
│ │ │ • G-Core SDK │ │ │ │ │
|
||||
│ │ │ • GeViSoft SDK │ │ │ │ │
|
||||
│ │ └──┬──────────────────────┘ └────────┬───────┘ │ │
|
||||
│ └─────┼──────────────────────────────────┼───────────┘ │
|
||||
└────────┼──────────────────────────────────┼───────────────┘
|
||||
│ Native SDK (TCP) │ HTTPS/WSS
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌────────────────────────┐
|
||||
│ Camera Servers │ │ Copilot AppServer │
|
||||
│ • GeViScope │ │ (ASP.NET Core) │
|
||||
│ 192.168.102.186 │ │ copilot.test.d6... │
|
||||
│ • G-Core │ │ ┌──────────────────┐ │
|
||||
│ 192.168.102.20 │ │ │ SignalR Hub │ │
|
||||
│ │ │ │ • Camera Locks │ │
|
||||
│ Each server has: │ │ │ • Sequences │ │
|
||||
│ • Cameras │ │ │ • Config Sync │ │
|
||||
│ • Monitors/Viewers │ │ │ • Viewer State │ │
|
||||
│ • PTZ controllers │ │ │ • Alarm History │ │
|
||||
│ • Alarm events │ │ └──────────────────┘ │
|
||||
│ │ │ ┌──────────────────┐ │
|
||||
│ │ │ │ SQLite Database │ │
|
||||
│ │ │ │ • Lock state │ │
|
||||
│ │ │ │ • Alarm history │ │
|
||||
│ │ │ └──────────────────┘ │
|
||||
│ │ │ ┌──────────────────┐ │
|
||||
│ │ │ │ REST API │ │
|
||||
│ │ │ │ • Auto-updates │ │
|
||||
│ │ │ │ • Configuration │ │
|
||||
│ │ │ │ • Blazor Admin UI │ │
|
||||
│ │ │ └──────────────────┘ │
|
||||
└─────────────────────┘ └────────────────────────┘
|
||||
```
|
||||
|
||||
## Assembly Map
|
||||
|
||||
| Assembly | Type | Purpose |
|
||||
|----------|------|---------|
|
||||
| `Copilot.App.dll` | WPF Client | Main application - UI, ViewModels, navigation, input handling |
|
||||
| `Copilot.Device.dll` | Client Library | Hardware abstraction - serial port, HID joystick, key mapping |
|
||||
| `Copilot.Common.dll` | Shared Library | Configuration models, data protection, providers, hub interfaces |
|
||||
| `Copilot.Common.Services.dll` | Shared Library | Driver providers, media channel service, viewer state management |
|
||||
| `Copilot.Drivers.Common.dll` | Shared Library | Driver interfaces (`IMovementController`, `ICameraServerDriver`, etc.) |
|
||||
| `Copilot.Drivers.GeViScope.dll` | Driver | GeViScope SDK wrapper - PLC actions for cameras/PTZ |
|
||||
| `Copilot.Drivers.GCore.dll` | Driver | G-Core SDK wrapper - binary protocol for cameras/PTZ |
|
||||
| `Copilot.Drivers.GeviSoft.dll` | Driver | GeViSoft SDK wrapper |
|
||||
| `Copilot.AppServer.Client.dll` | Client Library | SignalR hub client, availability monitoring |
|
||||
| `Copilot.AppServer.dll` | Server | ASP.NET Core server - API, SignalR hub, Blazor admin |
|
||||
| `Copilot.AppServer.Database.dll` | Server Library | Entity Framework - SQLite models and repositories |
|
||||
| `Copilot.Camea.Client.dll` | Server Library | Camea API integration (external alarm system) |
|
||||
|
||||
## Documentation Pages
|
||||
|
||||
- **[System Architecture](./architecture.md)** - Component diagrams, dependency graph, deployment model
|
||||
- **[Hardware & Input](./hardware-input.md)** - Joystick, keyboard, serial/HID protocols
|
||||
- **[PTZ Control Flow](./ptz-control.md)** - Joystick → Pan/Tilt/Zoom → SDK command pipeline
|
||||
- **[Data Flows](./data-flows.md)** - CrossSwitch, Alarms, Sequences, Playback, Camera Lock
|
||||
- **[Configuration](./configuration.md)** - JSON config files, monitor wall topology, function buttons
|
||||
- **[AppServer](./appserver.md)** - SignalR hub, REST API, database, admin UI
|
||||
21
Docs/legacy-architecture/_sidebar.md
Normal file
@@ -0,0 +1,21 @@
|
||||
- **Overview**
|
||||
- [Home](/)
|
||||
|
||||
- **Legacy Architecture**
|
||||
- [System Architecture](architecture.md)
|
||||
- [Hardware & Input](hardware-input.md)
|
||||
- [PTZ Control Flow](ptz-control.md)
|
||||
|
||||
- **Legacy Workflows**
|
||||
- [Data Flows](data-flows.md)
|
||||
- [Configuration](configuration.md)
|
||||
- [AppServer](appserver.md)
|
||||
|
||||
- **Architecture Review**
|
||||
- [Critical Infrastructure Review](architecture-review.md)
|
||||
|
||||
- **Migration (WPF → Flutter)**
|
||||
- [Migration Overview](migration-comparison.md)
|
||||
- [Migration Guide](migration-guide.md)
|
||||
- [Business Rules Reference](migration-business-rules.md)
|
||||
- [Implementation Guide](migration-implementation.md)
|
||||
190
Docs/legacy-architecture/appserver.md
Normal file
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: "AppServer"
|
||||
description: "ASP.NET Core coordination server - SignalR hub, REST API, database, admin UI"
|
||||
---
|
||||
|
||||
# AppServer (Copilot.AppServer)
|
||||
|
||||
The AppServer is a centralized coordination service that runs as an ASP.NET Core application (Windows Service capable). It does NOT handle video — only coordination between keyboards.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Copilot.AppServer.exe"
|
||||
subgraph "SignalR Hub"
|
||||
CamLock["Camera Lock<br/>Management"]
|
||||
SeqMgr["Sequence<br/>Scheduler"]
|
||||
CfgSync["Configuration<br/>Sync"]
|
||||
ViewState["Viewer State<br/>Broadcasting"]
|
||||
AlarmQ["Alarm Query<br/>Proxy"]
|
||||
end
|
||||
|
||||
subgraph "REST API (v1)"
|
||||
Updates["GET /api/v1/updates/{name}<br/>Auto-update manifest"]
|
||||
CfgAPI["Configuration endpoints"]
|
||||
end
|
||||
|
||||
subgraph "Blazor Admin UI"
|
||||
AdminWWW["Web-based admin panel<br/>Configuration management"]
|
||||
end
|
||||
|
||||
subgraph "Database"
|
||||
SQLite["SQLite (copilot.db)<br/>• Camera locks<br/>• Lock history<br/>• Alarm cache"]
|
||||
end
|
||||
|
||||
subgraph "External Integrations"
|
||||
CameaClient["Camea API Client<br/>http://localhost:8081<br/>Alarm data source"]
|
||||
end
|
||||
end
|
||||
|
||||
K1["Keyboard 1"] -->|WSS| CamLock
|
||||
K2["Keyboard 2"] -->|WSS| SeqMgr
|
||||
K3["Keyboard 3"] -->|WSS| CfgSync
|
||||
CamLock --> SQLite
|
||||
AlarmQ --> CameaClient
|
||||
AdminWWW --> CfgSync
|
||||
```
|
||||
|
||||
## SignalR Hub Interface
|
||||
|
||||
The hub exposes methods grouped by function:
|
||||
|
||||
### Camera Locks
|
||||
```
|
||||
TryLockCamera(cameraId, copilotName, priority) → CameraLockResult
|
||||
UnlockCamera(cameraId, copilotName)
|
||||
RequestCameraLock(cameraId, copilotName, priority)
|
||||
CameraLockConfirmTakeOver(cameraId, copilotName, confirm)
|
||||
ResetCameraLockExpiration(cameraId, copilotName)
|
||||
GetLockedCameraIds(copilotName) → IEnumerable<int>
|
||||
```
|
||||
|
||||
### Camera Lock Notifications (Server → Client)
|
||||
```
|
||||
CameraLockNotify(notification) where notification has:
|
||||
- NotificationType: Acquired | TakenOver | ConfirmTakeOver | Confirmed | Rejected | ExpireSoon | Unlocked
|
||||
- CameraId: int
|
||||
- CopilotName: string
|
||||
```
|
||||
|
||||
### Sequences
|
||||
```
|
||||
Start(viewerId, sequenceId)
|
||||
Stop(viewerId)
|
||||
GetSequences(categoryId) → IEnumerable<SequenceMessage>
|
||||
GetSequenceCategories() → IEnumerable<SequenceCategoryMessage>
|
||||
GetRunningSequences() → IEnumerable<ViewerSequenceState>
|
||||
```
|
||||
|
||||
### Sequence Notifications (Server → Client)
|
||||
```
|
||||
ViewerSequenceStateChanged(ViewerSequenceState)
|
||||
```
|
||||
|
||||
### Configuration
|
||||
```
|
||||
GetConfigurationFile(filename) → ConfigurationFile
|
||||
```
|
||||
|
||||
### Configuration Notifications (Server → Client)
|
||||
```
|
||||
ConfigurationFileChanged(ConfigurationFile)
|
||||
```
|
||||
|
||||
### Alarms
|
||||
```
|
||||
GetCamerasWithAlarms() → HashSet<int>
|
||||
GetAlarmsForCamera(cameraId, from, to) → IReadOnlyList<CameraAlarm>
|
||||
```
|
||||
|
||||
## Database Schema (SQLite)
|
||||
|
||||
Managed via Entity Framework Core with code-first migrations:
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
CameraLock {
|
||||
int CameraId PK
|
||||
string OwnerName
|
||||
string Priority
|
||||
datetime ExpiresAt
|
||||
datetime CreatedAt
|
||||
}
|
||||
|
||||
CameraLockHistory {
|
||||
int Id PK
|
||||
int CameraId
|
||||
string OwnerName
|
||||
string Action
|
||||
datetime Timestamp
|
||||
}
|
||||
|
||||
AlarmCache {
|
||||
int Id PK
|
||||
int CameraId
|
||||
int AlarmTypeId
|
||||
string Name
|
||||
datetime StartTime
|
||||
datetime EndTime
|
||||
}
|
||||
```
|
||||
|
||||
## Lock Expiration System
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Lock["Lock Created<br/>(ExpiresAt = now + 5min)"] --> Timer["Expiration Timer"]
|
||||
Timer -->|"T+4min<br/>(1min before expiry)"| Warn["Send ExpireSoon<br/>notification"]
|
||||
Warn --> Reset{"PTZ Action?"}
|
||||
Reset -->|Yes| ExtendLock["Reset ExpiresAt = now + 5min"]
|
||||
ExtendLock --> Timer
|
||||
Reset -->|No timeout| Expire["Send Unlocked<br/>notification"]
|
||||
Expire --> Remove["Remove lock from DB"]
|
||||
```
|
||||
|
||||
## Auto-Update System
|
||||
|
||||
The AppServer serves firmware and application updates:
|
||||
|
||||
```
|
||||
GET /api/v1/updates/{copilotName}
|
||||
→ Returns JSON array of available updates:
|
||||
[
|
||||
{
|
||||
"Version": "1.0.706",
|
||||
"Url": "https://copilot.test.d6.colsys.cz/updates/Copilot-1.0.706.zip",
|
||||
"Changelog": "https://...",
|
||||
"Mandatory": { "Value": true, "MinVersion": "1.0.700" },
|
||||
"CheckSum": { "Value": "abc123...", "HashingAlgorithm": "SHA256" }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The WPF app uses `AutoUpdater.NET` to check on startup and apply updates.
|
||||
|
||||
## Deployment
|
||||
|
||||
```
|
||||
Copilot.AppServer.exe
|
||||
├── appsettings.json (main config)
|
||||
├── configs/
|
||||
│ ├── appsettings-copilot.json
|
||||
│ ├── appsettings-camera-servers.json
|
||||
│ ├── appsettings-monitor-wall.json
|
||||
│ ├── appsettings-function-buttons.json
|
||||
│ ├── appsettings-prepositions.json
|
||||
│ ├── appsettings-sequences.json
|
||||
│ └── appsettings-sequence-categories.json
|
||||
├── copilot.db (SQLite database)
|
||||
├── wwwroot/ (Blazor admin UI assets)
|
||||
├── logs/
|
||||
│ ├── copilot-appserver-YYYYMMDD.log
|
||||
│ └── buffer/ (Elasticsearch buffer)
|
||||
└── web.config (IIS hosting, if used)
|
||||
```
|
||||
|
||||
Runs as:
|
||||
- Windows Service (`Microsoft.Extensions.Hosting.WindowsServices`)
|
||||
- Or standalone Kestrel server on HTTPS port 443
|
||||
- Uses machine certificate store for TLS
|
||||
399
Docs/legacy-architecture/architecture-review.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# Architecture Review: Legacy vs New — Critical Infrastructure Improvements
|
||||
|
||||
> Pre-implementation review. This system controls traffic/tunnel cameras in critical infrastructure. Every failure mode must be addressed. The system may run on Windows, Linux, or Android tablets in the future.
|
||||
|
||||
## 1. Side-by-Side Failure Mode Comparison
|
||||
|
||||
### 1.1 Camera Server Unreachable
|
||||
|
||||
| Aspect | Legacy (WPF) | New (Flutter) | Verdict |
|
||||
|--------|-------------|---------------|---------|
|
||||
| Detection | Driver `IsConnected` check every 2 seconds | HTTP timeout (5s) | Legacy better — faster detection |
|
||||
| Recovery | `CameraServerDriverReconnectService` retries every 2s | **None** — user must click retry button | **Critical gap** |
|
||||
| Partial failure | Skips disconnected drivers, other servers still work | Each bridge is independent — OK | Equal |
|
||||
| State on reconnect | Reloads media channels, fires `DriverConnected` event | No state resync after reconnect | **Gap** |
|
||||
|
||||
### 1.2 Coordination Layer Down (AppServer / PRIMARY)
|
||||
|
||||
| Aspect | Legacy (WPF) | New (Flutter) | Verdict |
|
||||
|--------|-------------|---------------|---------|
|
||||
| Detection | SignalR built-in disconnect detection | Not implemented yet | Equal (both need this) |
|
||||
| Recovery | SignalR auto-reconnect: 0s, 5s, 10s, 15s fixed delays | Not implemented yet | To be built |
|
||||
| Degraded mode | CrossSwitch/PTZ work, locks/sequences don't | Same design — correct | Equal |
|
||||
| State on reconnect | Hub client calls `GetLockedCameraIds()`, `GetRunningSequences()` | Not implemented yet | Must match |
|
||||
|
||||
### 1.3 Network Failure
|
||||
|
||||
| Aspect | Legacy (WPF) | New (Flutter) | Verdict |
|
||||
|--------|-------------|---------------|---------|
|
||||
| Detection | `NetworkAvailabilityWorker` polls every 5s (checks NIC status) | **None** — no network detection | **Critical gap** |
|
||||
| UI feedback | `NetworkAvailabilityState` updates UI commands | Connection status bar (manual) | **Gap** |
|
||||
| Recovery | Automatic — reconnect services activate when NIC comes back | **Manual only** — user clicks retry | **Critical gap** |
|
||||
|
||||
### 1.4 Bridge Process Crash
|
||||
|
||||
| Aspect | Legacy (WPF) | New (Flutter) | Verdict |
|
||||
|--------|-------------|---------------|---------|
|
||||
| Detection | N/A (SDK was in-process) | HTTP timeout → connection status false | OK |
|
||||
| Recovery | N/A (app restarts) | **None** — bridge stays dead | **Critical gap** |
|
||||
| Prevention | N/A | Process supervision needed | Must add |
|
||||
|
||||
### 1.5 Flutter App Crash
|
||||
|
||||
| Aspect | Legacy (WPF) | New (Flutter) | Verdict |
|
||||
|--------|-------------|---------------|---------|
|
||||
| Recovery | App restarts, reconnects in ~5s | App restarts, must reinitialize | Equal |
|
||||
| State recovery | Queries AppServer for locks, sequences, viewer states | Queries bridges for monitor states, alarms | Equal |
|
||||
| Lock state | Restored via `GetLockedCameraIds()` | Restored from coordination service | Equal |
|
||||
|
||||
## 2. Critical Improvements Required
|
||||
|
||||
### 2.1 Automatic Reconnection (MUST HAVE)
|
||||
|
||||
The legacy system reconnects automatically at every level. Our Flutter app does not. For tunnel/traffic camera control, an operator cannot be expected to click a retry button during an emergency.
|
||||
|
||||
**Required reconnection layers:**
|
||||
|
||||
```
|
||||
Layer 1: Bridge Health Polling
|
||||
Flutter → periodic GET /health to each bridge
|
||||
If bridge was down and comes back → auto-reconnect WebSocket + resync state
|
||||
|
||||
Layer 2: WebSocket Auto-Reconnect
|
||||
On disconnect → exponential backoff retry (1s, 2s, 4s, 8s, max 30s)
|
||||
On reconnect → resync state from bridge
|
||||
|
||||
Layer 3: Coordination Auto-Reconnect
|
||||
On PRIMARY disconnect → retry connection with backoff
|
||||
After 6s → STANDBY promotion (if configured)
|
||||
On reconnect to (new) PRIMARY → resync lock/sequence state
|
||||
|
||||
Layer 4: Network Change Detection
|
||||
Monitor network interface status
|
||||
On network restored → trigger reconnection at all layers
|
||||
```
|
||||
|
||||
**Legacy equivalent:**
|
||||
- Camera drivers: 2-second reconnect loop (`CameraServerDriverReconnectService`)
|
||||
- SignalR: built-in auto-reconnect with `HubRetryPolicy` (0s, 5s, 10s, 15s)
|
||||
- Network: 5-second NIC polling (`NetworkAvailabilityWorker`)
|
||||
|
||||
### 2.2 Process Supervision (MUST HAVE)
|
||||
|
||||
Every .NET process (bridges + coordination service) must auto-restart on crash. An operator should never have to SSH into a machine to restart a bridge.
|
||||
|
||||
| Platform | Supervision Method |
|
||||
|----------|--------------------|
|
||||
| Windows | Windows Service (via `Microsoft.Extensions.Hosting.WindowsServices`) or NSSM |
|
||||
| Linux | systemd units with `Restart=always` |
|
||||
| Docker | `restart: always` policy |
|
||||
| Android tablet | Bridges run on server, not locally |
|
||||
|
||||
**Proposed process tree:**
|
||||
```
|
||||
LattePanda Sigma (per keyboard)
|
||||
├── copilot-geviscope-bridge.service (auto-restart)
|
||||
├── copilot-gcore-bridge.service (auto-restart)
|
||||
├── copilot-geviserver-bridge.service (auto-restart)
|
||||
├── copilot-coordinator.service (auto-restart, PRIMARY only)
|
||||
└── copilot-keyboard.service (auto-restart, Flutter desktop)
|
||||
or browser tab (Flutter web)
|
||||
```
|
||||
|
||||
### 2.3 Health Monitoring Dashboard (SHOULD HAVE)
|
||||
|
||||
The operator must see at a glance what's working and what's not.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ System Status │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │
|
||||
│ │ GeViScope │ │ G-Core │ │ Coordination │ │
|
||||
│ │ ● Online │ │ ● Online │ │ ● PRIMARY active │ │
|
||||
│ │ 12 cams │ │ 8 cams │ │ 2 keyboards │ │
|
||||
│ │ 6 viewers │ │ 4 viewers │ │ 1 lock active │ │
|
||||
│ └────────────┘ └────────────┘ └────────────────────┘ │
|
||||
│ │
|
||||
│ ⚠ G-Core bridge reconnecting (attempt 3/∞) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.4 Command Retry with Idempotency (SHOULD HAVE)
|
||||
|
||||
Critical commands (CrossSwitch) should retry on transient failure:
|
||||
|
||||
```dart
|
||||
Future<bool> viewerConnectLive(int viewer, int channel) async {
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
final response = await _client.post('/viewer/connect-live', ...);
|
||||
if (response.statusCode == 200) return true;
|
||||
} catch (e) {
|
||||
if (attempt == 3) rethrow;
|
||||
await Future.delayed(Duration(milliseconds: 200 * attempt));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
PTZ commands should NOT retry (they're continuous — a stale retry would cause unexpected movement).
|
||||
|
||||
### 2.5 State Verification After Reconnection (MUST HAVE)
|
||||
|
||||
After any reconnection event, the app must not trust its cached state:
|
||||
|
||||
```
|
||||
On bridge reconnect:
|
||||
1. Query GET /monitors → rebuild monitor state
|
||||
2. Query GET /alarms/active → rebuild alarm state
|
||||
3. Re-subscribe WebSocket events
|
||||
|
||||
On coordination reconnect:
|
||||
1. Query locks → rebuild lock state
|
||||
2. Query running sequences → update sequence state
|
||||
3. Re-subscribe lock/sequence change events
|
||||
```
|
||||
|
||||
Legacy does this: `ViewerStatesInitWorker` rebuilds viewer state on startup/reconnect. `ConfigurationService.OnChangeAvailability` resyncs config when AppServer comes back.
|
||||
|
||||
## 3. Platform Independence Analysis
|
||||
|
||||
### 3.1 Current Platform Assumptions
|
||||
|
||||
| Component | Current Assumption | Future Need |
|
||||
|-----------|-------------------|-------------|
|
||||
| C# Bridges | Run locally on Windows (LattePanda) | Linux, Docker, remote server |
|
||||
| Flutter App | Windows desktop or browser | Linux, Android tablet, browser |
|
||||
| Coordination | Runs on PRIMARY keyboard (Windows) | Linux, Docker, any host |
|
||||
| Hardware I/O | USB Serial + HID on local machine | Remote keyboard via network, or Bluetooth |
|
||||
| Bridge URLs | `http://localhost:7720` | `http://192.168.x.y:7720` (already configurable) |
|
||||
|
||||
### 3.2 Architecture for Platform Independence
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Deployment A: LattePanda (Current)"
|
||||
LP_App["Flutter Desktop"]
|
||||
LP_Bridge1["GeViScope Bridge"]
|
||||
LP_Bridge2["G-Core Bridge"]
|
||||
LP_Coord["Coordinator"]
|
||||
LP_Serial["USB Serial/HID"]
|
||||
LP_App --> LP_Bridge1
|
||||
LP_App --> LP_Bridge2
|
||||
LP_App --> LP_Coord
|
||||
LP_Serial --> LP_App
|
||||
end
|
||||
|
||||
subgraph "Deployment B: Android Tablet (Future)"
|
||||
AT_App["Flutter Android"]
|
||||
AT_BT["Bluetooth Keyboard"]
|
||||
AT_App -->|"HTTP over WiFi"| Remote_Bridge1["Bridge on Server"]
|
||||
AT_App -->|"HTTP over WiFi"| Remote_Bridge2["Bridge on Server"]
|
||||
AT_App -->|"WebSocket"| Remote_Coord["Coordinator on Server"]
|
||||
AT_BT --> AT_App
|
||||
end
|
||||
|
||||
subgraph "Deployment C: Linux Kiosk (Future)"
|
||||
LX_App["Flutter Linux"]
|
||||
LX_Bridge1["GeViScope Bridge"]
|
||||
LX_Bridge2["G-Core Bridge"]
|
||||
LX_Coord["Coordinator"]
|
||||
LX_Serial["USB Serial/HID"]
|
||||
LX_App --> LX_Bridge1
|
||||
LX_App --> LX_Bridge2
|
||||
LX_App --> LX_Coord
|
||||
LX_Serial --> LX_App
|
||||
end
|
||||
|
||||
Remote_Bridge1 --> CS1["Camera Server 1"]
|
||||
Remote_Bridge2 --> CS2["Camera Server 2"]
|
||||
LP_Bridge1 --> CS1
|
||||
LP_Bridge2 --> CS2
|
||||
LX_Bridge1 --> CS1
|
||||
LX_Bridge2 --> CS2
|
||||
```
|
||||
|
||||
### 3.3 Key Design Rules for Platform Independence
|
||||
|
||||
1. **Flutter app never assumes bridges are on localhost.** Bridge URLs come from `servers.json`. Already the case.
|
||||
|
||||
2. **Bridges are deployable anywhere .NET 8 runs.** Currently Windows x86/x64. Must also build for Linux x64 and linux-arm64.
|
||||
|
||||
3. **Coordination service is just another network service.** Flutter app connects to it like a bridge — via configured URL.
|
||||
|
||||
4. **Hardware I/O is abstracted behind a service interface.** `KeyboardService` interface has platform-specific implementations:
|
||||
- `NativeSerialKeyboardService` (desktop with USB)
|
||||
- `WebSerialKeyboardService` (browser with Web Serial API)
|
||||
- `BluetoothKeyboardService` (tablet with BT keyboard, future)
|
||||
- `EmulatedKeyboardService` (development/testing)
|
||||
|
||||
5. **No platform-specific code in business logic.** All platform differences are in the service layer, injected via DI.
|
||||
|
||||
## 4. Coordination Service Design (Option B)
|
||||
|
||||
### 4.1 Service Overview
|
||||
|
||||
A minimal .NET 8 ASP.NET Core application (~400 lines) running on the PRIMARY keyboard:
|
||||
|
||||
```
|
||||
copilot-coordinator/
|
||||
├── Program.cs # Minimal API setup, WebSocket, endpoints
|
||||
├── Services/
|
||||
│ ├── LockManager.cs # Camera lock state (ported from legacy CameraLocksService)
|
||||
│ ├── SequenceRunner.cs # Sequence execution (ported from legacy SequenceService)
|
||||
│ └── KeyboardRegistry.cs # Track connected keyboards
|
||||
├── Models/
|
||||
│ ├── CameraLock.cs # Lock state model
|
||||
│ ├── SequenceState.cs # Running sequence model
|
||||
│ └── Messages.cs # WebSocket message types
|
||||
└── appsettings.json # Lock timeout, heartbeat interval config
|
||||
```
|
||||
|
||||
### 4.2 REST API
|
||||
|
||||
```
|
||||
GET /health → Service health
|
||||
GET /status → Connected keyboards, active locks, sequences
|
||||
|
||||
POST /locks/try {cameraId, keyboardId, priority} → Acquire lock
|
||||
POST /locks/release {cameraId, keyboardId} → Release lock
|
||||
POST /locks/takeover {cameraId, keyboardId, priority} → Request takeover
|
||||
POST /locks/confirm {cameraId, keyboardId, confirm} → Confirm/reject takeover
|
||||
POST /locks/reset {cameraId, keyboardId} → Reset expiration
|
||||
GET /locks → All active locks
|
||||
GET /locks/{keyboardId} → Locks held by keyboard
|
||||
|
||||
POST /sequences/start {viewerId, sequenceId} → Start sequence
|
||||
POST /sequences/stop {viewerId} → Stop sequence
|
||||
GET /sequences/running → Active sequences
|
||||
|
||||
WS /ws → Real-time events
|
||||
```
|
||||
|
||||
### 4.3 WebSocket Events (broadcast to all connected keyboards)
|
||||
|
||||
```json
|
||||
{"type": "lock_acquired", "cameraId": 5, "keyboardId": "KB1", "expiresAt": "..."}
|
||||
{"type": "lock_released", "cameraId": 5}
|
||||
{"type": "lock_expiring", "cameraId": 5, "keyboardId": "KB1", "expiresIn": 60}
|
||||
{"type": "lock_takeover", "cameraId": 5, "from": "KB1", "to": "KB2"}
|
||||
{"type": "sequence_started", "viewerId": 1001, "sequenceId": 3}
|
||||
{"type": "sequence_stopped", "viewerId": 1001}
|
||||
{"type": "keyboard_online", "keyboardId": "KB2"}
|
||||
{"type": "keyboard_offline", "keyboardId": "KB2"}
|
||||
{"type": "heartbeat"}
|
||||
```
|
||||
|
||||
### 4.4 Failover (Configured STANDBY)
|
||||
|
||||
```
|
||||
keyboards.json:
|
||||
{
|
||||
"keyboards": [
|
||||
{"id": "KB1", "role": "PRIMARY", "coordinatorPort": 8090},
|
||||
{"id": "KB2", "role": "STANDBY", "coordinatorPort": 8090}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- PRIMARY starts coordinator service on `:8090`
|
||||
- STANDBY monitors PRIMARY's `/health` endpoint
|
||||
- If PRIMARY unreachable for 6 seconds → STANDBY starts its own coordinator
|
||||
- When old PRIMARY recovers → checks if another coordinator is running → defers (becomes STANDBY)
|
||||
- Lock state after failover: **empty** (locks expire naturally in ≤5 minutes, same as legacy AppServer restart behavior)
|
||||
|
||||
## 5. Improvement Summary: Legacy vs New
|
||||
|
||||
### What the New System Does BETTER
|
||||
|
||||
| Improvement | Detail |
|
||||
|-------------|--------|
|
||||
| No central server hardware | Coordinator runs on keyboard, not separate machine |
|
||||
| Alarm reliability | Query + Subscribe + Periodic sync (legacy had event-only + hourly refresh) |
|
||||
| Direct command path | CrossSwitch/PTZ bypass coordinator entirely (legacy routed some through AppServer) |
|
||||
| Multiplatform | Flutter + .NET 8 run on Windows, Linux, Android. Legacy was Windows-only WPF |
|
||||
| No SDK dependency in UI | Bridges abstract SDKs behind REST. UI never touches native code |
|
||||
| Independent operation | Each keyboard works standalone for critical ops. Legacy needed AppServer for several features |
|
||||
| Deployable anywhere | Bridges + coordinator can run on any server, not just the keyboard |
|
||||
|
||||
### What the New System Must MATCH (Currently Missing)
|
||||
|
||||
| Legacy Feature | Legacy Implementation | New Implementation Needed |
|
||||
|---------------|----------------------|---------------------------|
|
||||
| Auto-reconnect to camera servers | 2-second periodic retry service | Bridge health polling + WebSocket auto-reconnect |
|
||||
| Auto-reconnect to AppServer | SignalR built-in (0s, 5s, 10s, 15s) | Coordinator WebSocket auto-reconnect with backoff |
|
||||
| Network detection | 5-second NIC polling worker | `connectivity_plus` package or periodic health checks |
|
||||
| State resync on reconnect | `ViewerStatesInitWorker`, config resync on availability change | Query bridges + coordinator on any reconnect event |
|
||||
| Graceful partial failure | `Parallel.ForEach` with per-driver try-catch | Already OK (each bridge independent) |
|
||||
| Process watchdog | Windows Service | systemd / Windows Service / Docker restart policy |
|
||||
| Media channel refresh | 10-minute periodic refresh | Periodic bridge status query |
|
||||
|
||||
### What the New System Should Do BETTER THAN Legacy
|
||||
|
||||
| Improvement | Legacy Gap | New Approach |
|
||||
|-------------|-----------|--------------|
|
||||
| Exponential backoff | Fixed delays (0, 5, 10, 15s) — no backoff | Exponential: 1s, 2s, 4s, 8s, max 30s with jitter |
|
||||
| Circuit breaker | None — retries forever even if server is gone | After N failures, back off to slow polling (60s) |
|
||||
| Command retry | None — single attempt | Retry critical commands (CrossSwitch) 3x with 200ms delay |
|
||||
| Health visibility | Hidden in logs | Operator-facing status dashboard in UI |
|
||||
| Structured logging | Basic ILogger | JSON structured logging → ELK (already in design) |
|
||||
| Graceful degradation UI | Commands silently disabled | Clear visual indicator: "Degraded mode — locks unavailable" |
|
||||
|
||||
## 6. Proposed Resilience Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Flutter App"
|
||||
UI["UI Layer"]
|
||||
BLoCs["BLoC Layer"]
|
||||
RS["ReconnectionService"]
|
||||
HS["HealthService"]
|
||||
BS["BridgeService"]
|
||||
CS["CoordinationClient"]
|
||||
KS["KeyboardService"]
|
||||
end
|
||||
|
||||
subgraph "Health & Reconnection"
|
||||
RS -->|"periodic /health"| Bridge1["GeViScope Bridge"]
|
||||
RS -->|"periodic /health"| Bridge2["G-Core Bridge"]
|
||||
RS -->|"periodic /health"| Coord["Coordinator"]
|
||||
RS -->|"on failure"| BS
|
||||
RS -->|"on failure"| CS
|
||||
HS -->|"status stream"| BLoCs
|
||||
end
|
||||
|
||||
subgraph "Normal Operation"
|
||||
BS -->|"REST commands"| Bridge1
|
||||
BS -->|"REST commands"| Bridge2
|
||||
BS -->|"WebSocket events"| Bridge1
|
||||
BS -->|"WebSocket events"| Bridge2
|
||||
CS -->|"REST + WebSocket"| Coord
|
||||
end
|
||||
|
||||
BLoCs --> UI
|
||||
KS -->|"Serial/HID"| BLoCs
|
||||
```
|
||||
|
||||
**New services needed in Flutter app:**
|
||||
|
||||
| Service | Responsibility |
|
||||
|---------|---------------|
|
||||
| `ReconnectionService` | Polls bridge `/health` endpoints, auto-reconnects WebSocket, triggers state resync |
|
||||
| `HealthService` | Aggregates health of all bridges + coordinator, exposes stream to UI |
|
||||
| `CoordinationClient` | REST + WebSocket client to coordinator (locks, sequences, heartbeat) |
|
||||
|
||||
## 7. Action Items Before Implementation
|
||||
|
||||
- [ ] **Create coordination service** (.NET 8 minimal API, ~400 lines)
|
||||
- [ ] **Add `ReconnectionService`** to Flutter app (exponential backoff, health polling)
|
||||
- [ ] **Add `HealthService`** to Flutter app (status aggregation for UI)
|
||||
- [ ] **Add `CoordinationClient`** to Flutter app (locks, sequences)
|
||||
- [ ] **Fix WebSocket auto-reconnect** in `BridgeService`
|
||||
- [ ] **Add command retry** for CrossSwitch (3x with backoff)
|
||||
- [ ] **Add bridge process supervision** (systemd/Windows Service configs)
|
||||
- [ ] **Add state resync** on every reconnect event
|
||||
- [ ] **Build health status UI** component
|
||||
- [ ] **Update `servers.json`** schema to include coordinator URL
|
||||
- [ ] **Build for Linux** — verify .NET 8 bridges compile for linux-x64
|
||||
- [ ] **Abstract keyboard input** behind `KeyboardService` interface with platform impls
|
||||
268
Docs/legacy-architecture/architecture.md
Normal file
@@ -0,0 +1,268 @@
|
||||
---
|
||||
title: "System Architecture"
|
||||
description: "Component diagrams, dependency graph, and deployment model"
|
||||
---
|
||||
|
||||
# System Architecture
|
||||
|
||||
## Component Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Copilot.App.exe (WPF Client)"
|
||||
App["App.cs<br/>.NET Generic Host"]
|
||||
MW["MainWindow<br/>Input Router"]
|
||||
SVM["SegmentViewModel<br/>Main Screen Logic"]
|
||||
PVM["PlaybackViewModel"]
|
||||
CSVM["CameraSearchViewModel"]
|
||||
PreVM["PrepositionsViewModel"]
|
||||
SeqVM["SequenceCategoriesViewModel<br/>SequencesViewModel"]
|
||||
SvcVM["ServiceMenuViewModel"]
|
||||
|
||||
subgraph "App Services"
|
||||
CCS["CameraControllerService"]
|
||||
CLS["CameraLockService"]
|
||||
CAS["CameraAlarmService"]
|
||||
SeqS["SequenceService"]
|
||||
FBS["FunctionButtonsService"]
|
||||
PBS["PlaybackStateService"]
|
||||
CfgS["ConfigurationService"]
|
||||
NAS["NetworkAvailabilityState"]
|
||||
end
|
||||
|
||||
subgraph "Navigation"
|
||||
NS["NavigationService"]
|
||||
NStore["NavigationStore"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph "Copilot.Device.dll"
|
||||
CD["CopilotDevice"]
|
||||
SP["SerialPortDataProvider"]
|
||||
HID["JoystickHidDataProvider"]
|
||||
end
|
||||
|
||||
subgraph "Copilot.Common.dll"
|
||||
Config["Configuration System"]
|
||||
Hub["ICopilotHub<br/>ICopilotHubEvents"]
|
||||
Prov["ICopilotInfoProvider"]
|
||||
end
|
||||
|
||||
subgraph "Copilot.Common.Services.dll"
|
||||
MCS["MediaChannelService"]
|
||||
VSS["ViewerStateService"]
|
||||
CSDP["CameraServerDriverProvider"]
|
||||
CenDP["CentralServerDriverProvider"]
|
||||
end
|
||||
|
||||
subgraph "Copilot.Drivers.Common.dll"
|
||||
IMC["IMovementController"]
|
||||
ICSD["ICameraServerDriver"]
|
||||
ICenD["ICentralServerDriver"]
|
||||
IVC["IViewerController"]
|
||||
end
|
||||
|
||||
subgraph "Copilot.Drivers.GeViScope.dll"
|
||||
GscD["GeViScopeDriver"]
|
||||
GscMC["GeViScopeMovementController"]
|
||||
GscVC["GeviScopeViewerController"]
|
||||
end
|
||||
|
||||
subgraph "Copilot.Drivers.GCore.dll"
|
||||
GcD["GCoreDriver"]
|
||||
GcMC["GCoreMovementController"]
|
||||
GcVC["GCoreViewerController"]
|
||||
end
|
||||
|
||||
subgraph "Copilot.AppServer.Client.dll"
|
||||
ASC["AppServerClient<br/>(SignalR)"]
|
||||
Avail["AvailabilityState"]
|
||||
end
|
||||
|
||||
App --> MW
|
||||
MW --> CD
|
||||
MW --> SVM
|
||||
SVM --> CCS
|
||||
SVM --> CLS
|
||||
SVM --> FBS
|
||||
SVM --> CAS
|
||||
CLS --> Hub
|
||||
CAS --> Hub
|
||||
SeqS --> Hub
|
||||
CfgS --> Hub
|
||||
CCS --> CSDP
|
||||
CCS --> MCS
|
||||
CSDP --> GscD
|
||||
CSDP --> GcD
|
||||
CenDP --> GscD
|
||||
GscD --> GscMC
|
||||
GscD --> GscVC
|
||||
GcD --> GcMC
|
||||
GcD --> GcVC
|
||||
GscMC -.->|implements| IMC
|
||||
GcMC -.->|implements| IMC
|
||||
GscD -.->|implements| ICSD
|
||||
GcD -.->|implements| ICSD
|
||||
Hub --> ASC
|
||||
```
|
||||
|
||||
## Service Lifetime & Registration
|
||||
|
||||
All services are registered via `Microsoft.Extensions.DependencyInjection` in `App.cs`:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Singletons (one instance)"
|
||||
MainWindow
|
||||
SegmentViewModel
|
||||
NavigationStore
|
||||
NavigationService
|
||||
CameraLockService
|
||||
PlaybackStateService
|
||||
ConfigurationService
|
||||
CameraAlarmService
|
||||
CameraControllerService
|
||||
PrepositionService
|
||||
SequenceService
|
||||
NetworkAvailabilityState
|
||||
end
|
||||
|
||||
subgraph "Transient (new per request)"
|
||||
CameraSearchViewModel
|
||||
PlaybackViewModel
|
||||
PrepositionsViewModel
|
||||
SequenceCategoriesViewModel
|
||||
SequencesViewModel
|
||||
ServiceMenuViewModel
|
||||
ErrorViewModel
|
||||
CameraAlarmHistoryViewModel
|
||||
end
|
||||
|
||||
subgraph "Hosted Services (background workers)"
|
||||
StartupConfigurationCheckWorker
|
||||
ViewerStatesInitWorker
|
||||
CameraAlarmsUpdateWorker
|
||||
NetworkAvailabilityWorker
|
||||
SequenceService_Hosted["SequenceService<br/>(also IHostedService)"]
|
||||
ProcessMonitorServiceHost
|
||||
end
|
||||
```
|
||||
|
||||
## Startup Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as App.cs
|
||||
participant Host as .NET Host
|
||||
participant MW as MainWindow
|
||||
participant Nav as NavigationService
|
||||
participant Device as CopilotDevice
|
||||
participant Hub as SignalR Hub
|
||||
participant Config as ConfigurationService
|
||||
|
||||
App->>Host: Build & ConfigureServices
|
||||
Host->>Host: Register all DI services
|
||||
App->>Host: StartAsync()
|
||||
Host->>Host: Start background workers
|
||||
|
||||
Note over Host: StartupConfigurationCheckWorker starts
|
||||
Note over Host: ViewerStatesInitWorker starts
|
||||
Note over Host: CameraAlarmsUpdateWorker starts
|
||||
Note over Host: NetworkAvailabilityWorker starts
|
||||
|
||||
App->>Nav: Navigate<SegmentsPage>(wallId)
|
||||
App->>MW: Show()
|
||||
MW->>Device: Subscribe JoystickMoved
|
||||
MW->>Device: Subscribe VirtualKeyDown/Up
|
||||
|
||||
alt Firmware outdated
|
||||
MW->>Nav: Navigate<UpdateFirmwarePage>
|
||||
end
|
||||
|
||||
App->>Config: UpdateAllConfigurationsIfChanged()
|
||||
Config->>Hub: GetConfigurationFile() for each manager
|
||||
Config->>Config: Write updated configs to disk
|
||||
|
||||
Note over App: Application Ready
|
||||
```
|
||||
|
||||
## Navigation System
|
||||
|
||||
The app uses a custom stack-based navigation system (not WPF Navigation):
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> SegmentsPage: Initial (select segment)
|
||||
SegmentsPage --> SegmentPage: Select segment
|
||||
|
||||
SegmentPage --> CameraSearchPage: Search button
|
||||
SegmentPage --> PrepositionsPage: Prepositions button
|
||||
SegmentPage --> PlaybackPage: Playback button
|
||||
SegmentPage --> SequenceCategoriesPage: Sequence button
|
||||
SegmentPage --> CameraAlarmHistoryPage: Alarm history
|
||||
SegmentPage --> ServiceMenuPage: Hold Backspace 3s
|
||||
SegmentPage --> CameraLockExpirationView: Lock expiring
|
||||
|
||||
CameraSearchPage --> SegmentPage: Back
|
||||
PrepositionsPage --> PrepositionAddPage: Add preset
|
||||
PrepositionAddPage --> PrepositionsPage: Back
|
||||
PrepositionsPage --> SegmentPage: Back
|
||||
PlaybackPage --> SegmentPage: Back
|
||||
SequenceCategoriesPage --> SequencesPage: Select category
|
||||
SequencesPage --> SegmentPage: Start sequence
|
||||
|
||||
ServiceMenuPage --> UpdateFirmwarePage: Update firmware
|
||||
```
|
||||
|
||||
## Two Communication Paths
|
||||
|
||||
The app communicates with two distinct backends simultaneously:
|
||||
|
||||
### Path 1: Direct SDK (Camera Operations)
|
||||
```
|
||||
App → CameraServerDriverProvider → GeViScope/GCore Driver → Native SDK → Camera Server
|
||||
```
|
||||
- **Used for:** CrossSwitch, PTZ, Playback, Viewer control
|
||||
- **Latency:** < 50ms (direct TCP connection to camera server)
|
||||
- **Reconnection:** Automatic, 2-second retry interval
|
||||
|
||||
### Path 2: SignalR Hub (Coordination)
|
||||
```
|
||||
App → CopilotHub (SignalR) → AppServer → Database/Logic → Response
|
||||
```
|
||||
- **Used for:** Camera locks, sequences, config sync, alarm history, viewer state
|
||||
- **Latency:** ~100-200ms (HTTPS + server processing)
|
||||
- **Reconnection:** Automatic via SignalR reconnection policy
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "App"
|
||||
SVM["SegmentViewModel"]
|
||||
end
|
||||
|
||||
subgraph "Path 1: Direct (low latency)"
|
||||
Driver["GeViScope/GCore Driver"]
|
||||
SDK["Native SDK (TCP)"]
|
||||
end
|
||||
|
||||
subgraph "Path 2: Coordinated"
|
||||
HubC["SignalR Client"]
|
||||
HTTPS["HTTPS/WSS"]
|
||||
end
|
||||
|
||||
subgraph "Camera Server"
|
||||
CS["GeViScope / G-Core"]
|
||||
end
|
||||
|
||||
subgraph "AppServer"
|
||||
HubS["SignalR Hub"]
|
||||
DB["SQLite DB"]
|
||||
end
|
||||
|
||||
SVM -->|"CrossSwitch, PTZ,<br/>Playback"| Driver
|
||||
Driver --> SDK --> CS
|
||||
|
||||
SVM -->|"Locks, Sequences,<br/>Config, Alarms"| HubC
|
||||
HubC --> HTTPS --> HubS
|
||||
HubS --> DB
|
||||
```
|
||||
253
Docs/legacy-architecture/configuration.md
Normal file
@@ -0,0 +1,253 @@
|
||||
---
|
||||
title: "Configuration"
|
||||
description: "JSON configuration files, monitor wall topology, and function button mapping"
|
||||
---
|
||||
|
||||
# Configuration System
|
||||
|
||||
## Configuration Files
|
||||
|
||||
All configuration is stored as JSON files, managed centrally on the AppServer and synced to each keyboard.
|
||||
|
||||
### appsettings.json (Main)
|
||||
|
||||
```json
|
||||
{
|
||||
"UseSoftwareRendering": true,
|
||||
"AppServerConfiguration": {
|
||||
"Uri": "https://copilot.test.d6.colsys.cz",
|
||||
"Timeout": "00:00:01.5"
|
||||
},
|
||||
"ReconnectionConfiguration": {
|
||||
"ReconnectionPeriod": "00:00:02"
|
||||
},
|
||||
"ViewerStates": {
|
||||
"AppServerConnectionTimeout": "00:00:05"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Value | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `UseSoftwareRendering` | `true` | Forces WPF software rendering (no GPU) for LattePanda |
|
||||
| `AppServerConfiguration.Uri` | HTTPS URL | Central AppServer for coordination |
|
||||
| `AppServerConfiguration.Timeout` | 1.5s | HTTP request timeout |
|
||||
| `ReconnectionPeriod` | 2s | Auto-reconnect interval to camera servers |
|
||||
| `AppServerConnectionTimeout` | 5s | SignalR connection timeout |
|
||||
|
||||
### appsettings-copilot.json (Per-Keyboard)
|
||||
|
||||
```json
|
||||
{
|
||||
"CopilotConfig": {
|
||||
"WallId": 2,
|
||||
"CameraLockOptions": {
|
||||
"Priority": "Low",
|
||||
"Timeout": "00:05:00"
|
||||
},
|
||||
"CameraNumberOptions": {
|
||||
"MaxLength": 6,
|
||||
"Prefixes": ["500", "501", "502"],
|
||||
"CancelEditTimeout": "00:05"
|
||||
},
|
||||
"ViewerStateThrottleInterval": "00:00:00.150",
|
||||
"CentralServer": {
|
||||
"IpAddress": "192.168.102.186",
|
||||
"UserName": "sysadmin",
|
||||
"Password": "masterkey"
|
||||
},
|
||||
"AllowPrepositionsInterval": {
|
||||
"Min": 10,
|
||||
"Max": 99
|
||||
},
|
||||
"PlaybackOptions": {
|
||||
"Allowed": true,
|
||||
"Speeds": [1, 2, 5, 15, 30, 100, 250]
|
||||
},
|
||||
"AlarmHistoryOptions": {
|
||||
"CameraIdExtractionRegex": "^\\D*(?<CameraId>\\d{6})\\D*$",
|
||||
"DefaultSearchInterval": "30.00:00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Purpose |
|
||||
|---------|---------|
|
||||
| `WallId` | Which monitor wall this keyboard controls |
|
||||
| `CameraLockOptions.Priority` | PTZ lock priority (`Low` / `High`) |
|
||||
| `CameraLockOptions.Timeout` | Lock auto-expiration (5 min) |
|
||||
| `CameraNumberOptions.MaxLength` | Max digits for camera number (6) |
|
||||
| `CameraNumberOptions.Prefixes` | Auto-prefixes for camera numbers |
|
||||
| `CancelEditTimeout` | Auto-cancel camera number entry (5 sec) |
|
||||
| `ViewerStateThrottleInterval` | Debounce viewer state updates (150ms) |
|
||||
| `PlaybackOptions.Speeds` | Shuttle playback speeds (1x to 250x) |
|
||||
| `AllowPrepositionsInterval` | Valid preposition range (10-99) |
|
||||
|
||||
### appsettings-camera-servers.json
|
||||
|
||||
```json
|
||||
{
|
||||
"CameraServerConfig": {
|
||||
"CameraServers": [
|
||||
{
|
||||
"IpAddress": "192.168.102.20",
|
||||
"UserName": "sysadmin",
|
||||
"Password": "masterkey",
|
||||
"CameraServerType": "GCore"
|
||||
},
|
||||
{
|
||||
"IpAddress": "192.168.102.186",
|
||||
"UserName": "sysadmin",
|
||||
"Password": "masterkey",
|
||||
"CameraServerType": "GeviScope"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported server types: `GeViScope`, `GCore`, `GeViSoft`
|
||||
|
||||
### appsettings-monitor-wall.json (Topology)
|
||||
|
||||
Defines the physical monitor wall layout:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Wall (WallId: 1, Name: HDŘÚ TSK/DIC)"
|
||||
subgraph "Segment 1 (4 rows × 8 cols)"
|
||||
M1["Monitor #01<br/>2×2 span<br/>VM: 151,152,153,154"]
|
||||
M2["Monitor #02<br/>2×2 span<br/>VM: 155,156,157,158"]
|
||||
M3["Monitor #03"]
|
||||
M4["Monitor #04"]
|
||||
M5["Monitor #05<br/>...etc"]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Hierarchy:
|
||||
```
|
||||
Wall
|
||||
└── Segment (grid: RowCount × ColumnCount)
|
||||
└── Monitor (physical screen, positioned at Row/Col with RowSpan/ColSpan)
|
||||
└── VirtualMonitor (individual video feed, positioned within monitor grid)
|
||||
```
|
||||
|
||||
A single physical monitor can display multiple virtual monitors (e.g., 2×2 quad view). Each VirtualMonitor has a unique `VirtualMonitorId` that maps to a `ViewerId` in the camera server.
|
||||
|
||||
### appsettings-function-buttons.json
|
||||
|
||||
Maps F1-F7 and Home keys to actions per wall:
|
||||
|
||||
```json
|
||||
{
|
||||
"FunctionButtonsConfig": {
|
||||
"FunctionButtons": [
|
||||
{
|
||||
"WallId": 1,
|
||||
"Buttons": [
|
||||
{
|
||||
"Button": "F1",
|
||||
"Actions": [
|
||||
{ "ViewerId": 1001, "ActionType": "CrossSwitch", "SourceId": 501111 },
|
||||
{ "ViewerId": 1002, "ActionType": "CrossSwitch", "SourceId": 502345 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"Button": "F4",
|
||||
"Actions": [
|
||||
{ "ViewerId": 1001, "ActionType": "SequenceStart", "SourceId": 258 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Action types: `CrossSwitch` (switch camera to viewer), `SequenceStart` (start camera cycling)
|
||||
|
||||
### appsettings-sequences.json
|
||||
|
||||
```json
|
||||
{
|
||||
"SequencesConfig": {
|
||||
"Sequences": [
|
||||
{
|
||||
"Id": 1,
|
||||
"CategoryId": 1,
|
||||
"Name": "Test-A",
|
||||
"Interval": 1,
|
||||
"MediaChannels": [500100, 500101, 500102, 500103]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## AppServer Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"SqliteConnection": "Data Source=copilot.db"
|
||||
},
|
||||
"CameaApiClient": {
|
||||
"Uri": "http://localhost:8081",
|
||||
"Timeout": "00:00:01.5",
|
||||
"TimeZone": "Central Europe Standard Time"
|
||||
},
|
||||
"LockExpirationConfig": {
|
||||
"LockExpirationTimeout": "00:05:00",
|
||||
"NotificationBeforeExpiration": "00:01:00"
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Https": {
|
||||
"Url": "https://*:443",
|
||||
"Certificate": {
|
||||
"Subject": "copilot.test.d6.colsys.cz",
|
||||
"Store": "Root",
|
||||
"Location": "LocalMachine"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Network Topology
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ 192.168.102.0/24 Network │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ GeViScope Server │ │ G-Core Server │ │
|
||||
│ │ 192.168.102.186 │ │ 192.168.102.20 │ │
|
||||
│ │ sysadmin/master │ │ sysadmin/master │ │
|
||||
│ └────────┬─────────┘ └────────┬────────┘ │
|
||||
│ │ TCP (native SDK) │ TCP (native SDK) │
|
||||
│ │ │ │
|
||||
│ ┌────────┴──────────────────────┴────────┐ │
|
||||
│ │ Keyboard SBC │ │
|
||||
│ │ (LattePanda Sigma) │ │
|
||||
│ │ Copilot.App.exe (WPF) │ │
|
||||
│ └────────┬───────────────────────────────┘ │
|
||||
│ │ HTTPS/WSS (SignalR) │
|
||||
│ │ │
|
||||
│ ┌────────┴───────────────────────────────┐ │
|
||||
│ │ AppServer │ │
|
||||
│ │ copilot.test.d6.colsys.cz:443 │ │
|
||||
│ │ Copilot.AppServer.exe │ │
|
||||
│ │ + SQLite DB + Camea API (:8081) │ │
|
||||
│ └────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────┐ │
|
||||
│ │ Elasticsearch (logging) │ │
|
||||
│ │ localhost:9200 │ │
|
||||
│ └────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
139
Docs/legacy-architecture/copilot-architecture.drawio
Normal file
@@ -0,0 +1,139 @@
|
||||
<mxfile host="app.diagrams.net" modified="2026-02-10T00:00:00.000Z" agent="Claude" version="24.0.0">
|
||||
<diagram id="copilot-architecture" name="System Architecture">
|
||||
<mxGraphModel dx="1422" dy="794" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1600" pageHeight="1200" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
|
||||
<!-- Title -->
|
||||
<mxCell id="title" value="COPILOT D6 Legacy System Architecture" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="400" y="20" width="500" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Hardware Keyboard -->
|
||||
<mxCell id="hw-group" value="COPILOT Keyboard Hardware" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontColor=#333333;verticalAlign=top;fontStyle=1;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="80" width="540" height="120" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="hw-keys" value="Button Panel
(Serial Port)
Numpad + F1-F7" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="110" width="140" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="hw-joy" value="3-Axis Joystick
(USB HID)
X/Y/Z ±255" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="260" y="110" width="140" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="hw-shuttle" value="Jog/Shuttle
(Serial Port)
±7 positions" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="110" width="140" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="hw-arduino" value="Arduino Leonardo (USB Composite)" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;fontColor=#999999;" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="185" width="260" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- USB Arrow -->
|
||||
<mxCell id="usb-arrow" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;containSize=1;strokeWidth=2;" edge="1" parent="1" source="hw-group" target="device-layer">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="usb-label" value="USB (Serial + HID)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=10;" vertex="1" connectable="0" parent="usb-arrow">
|
||||
<mxGeometry x="-0.2" relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry>
|
||||
</mxCell>
|
||||
|
||||
<!-- WPF App -->
|
||||
<mxCell id="app-group" value="Copilot.App.exe (WPF .NET 7)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;verticalAlign=top;fontStyle=1;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="240" width="620" height="440" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Device Layer -->
|
||||
<mxCell id="device-layer" value="Copilot.Device
SerialPortDataProvider + JoystickHidDataProvider" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="120" y="270" width="460" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- MainWindow -->
|
||||
<mxCell id="mainwindow" value="MainWindow
(Input Router → Current ViewModel)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="120" y="330" width="460" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- SegmentViewModel -->
|
||||
<mxCell id="segmentvm" value="SegmentViewModel (Main Screen)
PTZ Control | CrossSwitch | Camera Lock | Playback | Sequences" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="80" y="390" width="540" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Services Row -->
|
||||
<mxCell id="svc-camera" value="Camera
Controller
Service" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="460" width="90" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="svc-lock" value="Camera
Lock
Service" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="160" y="460" width="90" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="svc-alarm" value="Camera
Alarm
Service" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="260" y="460" width="90" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="svc-seq" value="Sequence
Service" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="360" y="460" width="90" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="svc-func" value="Function
Buttons
Service" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="460" y="460" width="90" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="svc-config" value="Config
Service" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="560" y="460" width="80" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Drivers Row -->
|
||||
<mxCell id="drv-geviscope" value="GeViScope Driver
(GscPLCWrapper)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="540" width="130" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="drv-gcore" value="G-Core Driver
(GngPLCWrapper)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="540" width="130" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="drv-gevisoft" value="GeViSoft Driver" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="340" y="540" width="100" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="hub-client" value="SignalR Hub Client
(WSS)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="470" y="540" width="140" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- App Arrows -->
|
||||
<mxCell id="a1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;" edge="1" parent="1" source="device-layer" target="mainwindow"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="a2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;" edge="1" parent="1" source="mainwindow" target="segmentvm"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
|
||||
<!-- Camera Servers -->
|
||||
<mxCell id="srv-geviscope" value="GeViScope Server
192.168.102.186
(Central + Camera)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="740" width="180" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="srv-gcore" value="G-Core Server
192.168.102.20
(Camera)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="240" y="740" width="160" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- AppServer -->
|
||||
<mxCell id="appserver" value="Copilot.AppServer.exe
(ASP.NET Core)
HTTPS :443 + SignalR Hub
SQLite DB + Camea API + Blazor Admin" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="440" y="730" width="240" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- SDK Arrows -->
|
||||
<mxCell id="sdk1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeWidth=2;strokeColor=#9673a6;" edge="1" parent="1" source="drv-geviscope" target="srv-geviscope"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="sdk1-label" value="Native SDK
(TCP)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=9;" vertex="1" connectable="0" parent="sdk1"><mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry></mxCell>
|
||||
|
||||
<mxCell id="sdk2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeWidth=2;strokeColor=#9673a6;" edge="1" parent="1" source="drv-gcore" target="srv-gcore"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="sdk2-label" value="Native SDK
(TCP)" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=9;" vertex="1" connectable="0" parent="sdk2"><mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry></mxCell>
|
||||
|
||||
<mxCell id="hub-arrow" style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeWidth=2;strokeColor=#b85450;dashed=1;" edge="1" parent="1" source="hub-client" target="appserver"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="hub-label" value="HTTPS/WSS
SignalR" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=9;" vertex="1" connectable="0" parent="hub-arrow"><mxGeometry relative="1" as="geometry"><mxPoint as="offset" /></mxGeometry></mxCell>
|
||||
|
||||
<!-- Legend -->
|
||||
<mxCell id="legend" value="Legend" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="720" y="80" width="200" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="leg1" value="Hardware" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=9;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="105" width="80" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="leg2" value="UI Layer" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=9;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="135" width="80" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="leg3" value="Services" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=9;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="165" width="80" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="leg4" value="Drivers/SDK" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=9;" vertex="1" parent="1">
|
||||
<mxGeometry x="730" y="195" width="80" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
287
Docs/legacy-architecture/data-flows.md
Normal file
@@ -0,0 +1,287 @@
|
||||
---
|
||||
title: "Data Flows"
|
||||
description: "CrossSwitch, Alarms, Sequences, Playback, and Camera Lock workflows"
|
||||
---
|
||||
|
||||
# Data Flows
|
||||
|
||||
## 1. CrossSwitch (Switch Camera to Monitor)
|
||||
|
||||
The primary operation — routing a camera feed to a physical monitor output.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Op as Operator
|
||||
participant VM as SegmentViewModel
|
||||
participant CenD as CentralServerDriver
|
||||
participant Srv as Camera Server
|
||||
|
||||
Op->>VM: Enter camera number (digits + Enter)
|
||||
VM->>VM: Validate camera exists via MediaChannelService
|
||||
|
||||
alt Camera exists
|
||||
alt Playback active on this viewer
|
||||
VM->>VM: playbackStateService.SetPlaybackState(false)
|
||||
end
|
||||
alt Sequence active on this viewer
|
||||
VM->>VM: hub.Stop(viewerId)
|
||||
end
|
||||
|
||||
VM->>CenD: CrossSwitch(viewerId, cameraNumber)
|
||||
CenD->>Srv: SDK CrossSwitch command
|
||||
|
||||
Note over Srv: Server switches video output<br/>and fires ViewerConnectionEvent
|
||||
|
||||
else Camera not found
|
||||
VM->>VM: ShowError("MediaChannel does not exist", 2s)
|
||||
end
|
||||
```
|
||||
|
||||
### CrossSwitch Triggers
|
||||
1. **Numeric entry** — Type camera number + Enter
|
||||
2. **Plus/Minus keys** — Cycle to next/previous camera in sorted order
|
||||
3. **Function buttons** — F1-F7 mapped to preconfigured CrossSwitch actions
|
||||
4. **Home key** — When not in PTZ mode or playback, executes function button "Home"
|
||||
|
||||
### Camera Number Editing
|
||||
|
||||
```
|
||||
State: Not Editing
|
||||
│
|
||||
├─ Press digit → BeginEdit(), append digit, start cancel timer
|
||||
│
|
||||
▼
|
||||
State: Editing (showing entered digits)
|
||||
│
|
||||
├─ Press digit → append digit, restart cancel timer
|
||||
├─ Press Backspace → remove last digit, restart cancel timer
|
||||
├─ Press Enter → validate, CrossSwitch if valid, EndEdit()
|
||||
└─ Cancel timer fires → CancelEdit(), revert to original camera
|
||||
```
|
||||
|
||||
Camera number can be up to 6 digits. Configurable prefixes (e.g., "500", "501", "502") are prepended automatically based on the selected prefix.
|
||||
|
||||
## 2. Camera Lock Workflow
|
||||
|
||||
Camera locks coordinate PTZ access across multiple keyboards via the AppServer SignalR hub.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant K1 as Keyboard 1 (Low priority)
|
||||
participant Hub as AppServer Hub
|
||||
participant DB as SQLite DB
|
||||
participant K2 as Keyboard 2 (High priority)
|
||||
|
||||
K1->>Hub: TryLockCamera(camId, "K1", Low)
|
||||
Hub->>DB: Check existing lock
|
||||
DB-->>Hub: No lock exists
|
||||
Hub->>DB: Create lock(camId, "K1", Low, expires=5min)
|
||||
Hub-->>K1: LockResult(Acquired=true)
|
||||
K1->>K1: IsCameraTelemetryActive = true
|
||||
|
||||
Note over K1: Operator controls PTZ...<br/>Each action calls ResetCameraLockExpiration()
|
||||
|
||||
K2->>Hub: TryLockCamera(camId, "K2", High)
|
||||
Hub->>DB: Check existing lock → held by K1
|
||||
Hub-->>K2: LockResult(Acquired=false, Owner="K1")
|
||||
|
||||
K2->>Hub: RequestCameraLock(camId, "K2", High)
|
||||
Hub->>K1: CameraLockNotify(ConfirmTakeOver)
|
||||
K1->>K1: Show dialog "K2 requests lock"
|
||||
|
||||
alt K1 confirms takeover
|
||||
K1->>Hub: CameraLockConfirmTakeOver(confirm=true)
|
||||
Hub->>K1: CameraLockNotify(Unlocked)
|
||||
Hub->>K2: CameraLockNotify(Confirmed)
|
||||
K2->>Hub: TryLockCamera(camId, "K2", High)
|
||||
Hub-->>K2: LockResult(Acquired=true)
|
||||
else K1 denies
|
||||
K1->>Hub: CameraLockConfirmTakeOver(confirm=false)
|
||||
Hub->>K2: CameraLockNotify(Rejected)
|
||||
end
|
||||
```
|
||||
|
||||
### Lock Priority Levels
|
||||
Configured per keyboard in `appsettings-copilot.json`:
|
||||
- `Low` — Standard operator
|
||||
- `High` — Supervisor (can request takeover)
|
||||
|
||||
### Lock Expiration
|
||||
- **Timeout:** 5 minutes (`LockExpirationConfig.LockExpirationTimeout`)
|
||||
- **Warning:** 1 minute before expiration (`NotificationBeforeExpiration`)
|
||||
- **Reset:** Every PTZ action or explicit reset call extends the timer
|
||||
|
||||
## 3. Alarm System
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Worker as CameraAlarmsUpdateWorker
|
||||
participant CAS as CameraAlarmService
|
||||
participant Hub as CopilotHub
|
||||
participant AS as AppServer
|
||||
participant Camea as Camea API
|
||||
|
||||
Note over Worker: Background worker (periodic)
|
||||
|
||||
Worker->>CAS: UpdateCameraToAlarmsMapping()
|
||||
CAS->>Hub: GetCamerasWithAlarms()
|
||||
Hub->>AS: Query
|
||||
AS->>Camea: GET /api/alarms
|
||||
Camea-->>AS: List of cameras with active alarms
|
||||
AS-->>Hub: HashSet<int> cameraIds
|
||||
Hub-->>CAS: Store in camerasWithAlarms
|
||||
|
||||
Note over CAS: UI can now check HasCameraAlarms(cameraId)
|
||||
|
||||
Note over Worker: Also on SegmentViewModel...
|
||||
participant VM as SegmentViewModel
|
||||
VM->>CAS: GetAlarmForCamera(camId, from, to)
|
||||
CAS->>Hub: GetAlarmsForCamera(camId, from, to)
|
||||
Hub->>AS: Query alarm history
|
||||
AS-->>Hub: List<CameraAlarm>
|
||||
Hub-->>CAS: Alarms (UTC → LocalTime)
|
||||
CAS-->>VM: Sorted by StartTime desc
|
||||
```
|
||||
|
||||
### Alarm-Related Behavior
|
||||
- Screens with active alarms **block** camera number editing (CrossSwitch disabled)
|
||||
- Alarm history page uses configurable regex to extract camera ID from alarm names: `^\D*(?<CameraId>\d{6})\D*$`
|
||||
- Default search interval: 30 days
|
||||
|
||||
## 4. Sequence Management
|
||||
|
||||
Sequences automatically cycle cameras on a monitor at configurable intervals.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Op as Operator
|
||||
participant VM as SegmentViewModel
|
||||
participant SeqS as SequenceService
|
||||
participant Hub as SignalR Hub
|
||||
participant AS as AppServer
|
||||
|
||||
Op->>VM: Press Sequence key
|
||||
VM->>VM: Navigate to SequenceCategoriesPage
|
||||
Op->>VM: Select category
|
||||
VM->>SeqS: GetSequences(categoryId)
|
||||
SeqS->>Hub: GetSequences(categoryId)
|
||||
Hub-->>SeqS: List<SequenceMessage>
|
||||
Op->>VM: Select sequence
|
||||
VM->>SeqS: Start(viewerId, sequenceId)
|
||||
SeqS->>Hub: Start(viewerId, sequenceId)
|
||||
|
||||
Note over AS: AppServer runs sequence timer
|
||||
AS->>Hub: ViewerSequenceStateChanged
|
||||
Hub->>SeqS: OnChanged(viewerSequenceState)
|
||||
SeqS->>VM: Update UI (sequence indicator)
|
||||
|
||||
Note over AS: Timer fires every N seconds
|
||||
AS->>AS: CrossSwitch next camera in list
|
||||
```
|
||||
|
||||
### Sequence Configuration
|
||||
```json
|
||||
{
|
||||
"Id": 1,
|
||||
"CategoryId": 1,
|
||||
"Name": "Test-A",
|
||||
"Interval": 1, // seconds between switches
|
||||
"MediaChannels": [500100, 500101, 500102, 500103]
|
||||
}
|
||||
```
|
||||
|
||||
Sequences are **run on the AppServer**, not on the keyboard. The keyboard only starts/stops them.
|
||||
|
||||
## 5. Playback Control
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Op as Operator
|
||||
participant VM as SegmentViewModel
|
||||
participant VC as IViewerController
|
||||
participant Srv as Camera Server
|
||||
|
||||
Note over Op: Playback enters via Jog/Shuttle<br/>or Playback button
|
||||
|
||||
Op->>VM: Jog right (arrow key)
|
||||
VM->>VM: IsPlaybackActive = true
|
||||
VM->>VC: StepForwardAndStop()
|
||||
VC->>Srv: Step forward one frame
|
||||
|
||||
Op->>VM: Shuttle right position 3
|
||||
VM->>VM: TryGetShuttleSpeed(speeds, out speed, out forward)
|
||||
Note over VM: position 3 → speeds[2] = 5
|
||||
VM->>VC: FastForward(5)
|
||||
VC->>Srv: Play at 5x speed
|
||||
|
||||
Op->>VM: Shuttle center
|
||||
VM->>VC: Stop()
|
||||
|
||||
Op->>VM: Press Home key
|
||||
VM->>VC: PlayLive()
|
||||
VM->>VM: IsPlaybackActive = false
|
||||
```
|
||||
|
||||
### Playback Controls
|
||||
| Input | Action | SDK Method |
|
||||
|-------|--------|------------|
|
||||
| Jog Left | Step back 1 frame | `StepBackwardAndStop()` |
|
||||
| Jog Right | Step forward 1 frame | `StepForwardAndStop()` |
|
||||
| Shuttle Left 1-7 | Rewind at speed | `FastBackward(speed)` |
|
||||
| Shuttle Right 1-7 | Fast forward at speed | `FastForward(speed)` |
|
||||
| Shuttle Center | Pause | `Stop()` |
|
||||
| Home | Return to live | `PlayLive()` |
|
||||
|
||||
## 6. Function Buttons
|
||||
|
||||
F1-F7 and Home keys execute preconfigured action lists per wall:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Key["F1-F7 / Home Key Press"] --> FBS["FunctionButtonsService"]
|
||||
FBS --> Config["Load from appsettings-function-buttons.json"]
|
||||
Config --> Actions["Get actions for wallId + buttonKey"]
|
||||
Actions --> ForEach["For each action:"]
|
||||
|
||||
ForEach -->|CrossSwitch| CS["CentralServerDriver.CrossSwitch(viewerId, sourceId)<br/>+ Minimize viewer"]
|
||||
ForEach -->|SequenceStart| SS["SequenceService.Start(viewerId, sourceId)"]
|
||||
```
|
||||
|
||||
Example: Pressing F1 on Wall 2 executes:
|
||||
```json
|
||||
{ "ViewerId": 12322, "ActionType": "CrossSwitch", "SourceId": 500123 }
|
||||
```
|
||||
This switches camera 500123 to viewer 12322.
|
||||
|
||||
## 7. Configuration Sync
|
||||
|
||||
Configuration files are managed centrally on the AppServer and synced to keyboards:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant AS as AppServer
|
||||
participant Hub as SignalR Hub
|
||||
participant CfgS as ConfigurationService
|
||||
participant Disk as Local JSON Files
|
||||
|
||||
Note over CfgS: On startup
|
||||
CfgS->>Hub: GetConfigurationFile(filename) for each manager
|
||||
Hub-->>CfgS: ConfigurationFile (content + hash)
|
||||
CfgS->>Disk: Compare hash, write if changed
|
||||
|
||||
Note over AS: Admin changes config via Blazor UI
|
||||
AS->>Hub: ConfigurationFileChanged event
|
||||
Hub->>CfgS: OnConfigurationFileChanged(file)
|
||||
CfgS->>Disk: Write updated content
|
||||
|
||||
Note over CfgS: Also triggered when AppServer<br/>becomes available after disconnect
|
||||
```
|
||||
|
||||
Configuration files synced:
|
||||
- `appsettings-copilot.json` — Keyboard-specific settings
|
||||
- `appsettings-camera-servers.json` — Camera server connections
|
||||
- `appsettings-monitor-wall.json` — Monitor wall topology
|
||||
- `appsettings-function-buttons.json` — Function button actions
|
||||
- `appsettings-prepositions.json` — Camera prepositions
|
||||
- `appsettings-sequences.json` — Sequence definitions
|
||||
- `appsettings-sequence-categories.json` — Sequence categories
|
||||
146
Docs/legacy-architecture/hardware-input.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: "Hardware & Input System"
|
||||
description: "COPILOT keyboard hardware, joystick, serial/HID protocols, and key mapping"
|
||||
---
|
||||
|
||||
# Hardware & Input System
|
||||
|
||||
## Physical Hardware
|
||||
|
||||
The COPILOT keyboard is a custom-built control panel with three input subsystems:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ COPILOT KEYBOARD │
|
||||
│ │
|
||||
│ ┌────────────────────────┐ ┌───────────────┐ ┌────────────┐ │
|
||||
│ │ BUTTON PANEL │ │ JOYSTICK │ │ JOG/ │ │
|
||||
│ │ │ │ │ │ SHUTTLE │ │
|
||||
│ │ [Home] [F1-F7] │ │ ╱───╲ │ │ │ │
|
||||
│ │ [0-9] [.] [*] │ │ │ ● │ │ │ ◄──●──► │ │
|
||||
│ │ [+] [-] [(] [)] │ │ ╲───╱ │ │ │ │
|
||||
│ │ [< >] │ │ 3-axis │ │ 7 speeds │ │
|
||||
│ │ │ │ X/Y/Z │ │ per dir │ │
|
||||
│ │ Serial (COM port) │ │ USB HID │ │ Serial │ │
|
||||
│ └────────────────────────┘ └───────────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ Arduino Leonardo (ATmega32U4) │
|
||||
│ USB Composite: Serial + HID │
|
||||
└───────────────────────────────┬──────────────────────────────────┘
|
||||
│ USB
|
||||
LattePanda SBC
|
||||
```
|
||||
|
||||
## Communication Protocol
|
||||
|
||||
### Serial Port (Buttons + Jog/Shuttle)
|
||||
|
||||
Messages are newline-terminated strings with a single-character prefix:
|
||||
|
||||
| Prefix | Type | Example | Meaning |
|
||||
|--------|------|---------|---------|
|
||||
| `p` | Key Pressed | `pH\r\n` | Home key pressed |
|
||||
| `r` | Key Released | `rH\r\n` | Home key released |
|
||||
| `h` | Heartbeat | `h\r\n` | Keepalive (ignored) |
|
||||
| `j` | Jog | `j+1\r\n` / `j-1\r\n` | Jog wheel step right/left |
|
||||
| `s` | Shuttle | `s3\r\n` / `s-5\r\n` | Shuttle position (-7 to +7) |
|
||||
| `v` | Version | `v2.1\r\n` | Firmware version response |
|
||||
|
||||
Startup: App sends byte `0xDC` to request firmware version.
|
||||
|
||||
### HID Device (Joystick)
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Vendor ID | `10959` (0x2ACF) |
|
||||
| Product ID | `257` (0x0101) |
|
||||
| Axes | X (pan), Y (tilt), Z (zoom) |
|
||||
| Raw range | Scaled to **-255 to +255** |
|
||||
| Buttons | Button1, Button2, Button3 |
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "HID Input Processing"
|
||||
Raw["Raw HID Report"] --> Parser["DeviceItemInputParser"]
|
||||
Parser --> Changed{"HasChanged?"}
|
||||
Changed -->|Yes| Usage{"Usage Type"}
|
||||
Usage -->|GenericDesktopX| PanEvt["JoystickX event<br/>(-255 to +255)"]
|
||||
Usage -->|GenericDesktopY| TiltEvt["JoystickY event<br/>(-255 to +255)"]
|
||||
Usage -->|GenericDesktopZ| ZoomEvt["JoystickZ event<br/>(-255 to +255)"]
|
||||
Usage -->|Button1| B1["JoystickButton1<br/>Pressed/Released"]
|
||||
Usage -->|Button2| B2["JoystickButton2<br/>Pressed/Released"]
|
||||
Usage -->|Button3| Ignore["Ignored"]
|
||||
end
|
||||
```
|
||||
|
||||
## Key Mapping
|
||||
|
||||
### Hardware Keys → Virtual Keys
|
||||
|
||||
The serial protocol uses ASCII character codes:
|
||||
|
||||
| Serial Code | Char | Virtual Key | UI Function |
|
||||
|-------------|------|-------------|-------------|
|
||||
| 72 | `H` | Home | Home position / Jump to live |
|
||||
| 65 | `A` | F1 | Function button 1 |
|
||||
| 66 | `B` | F2 | Function button 2 |
|
||||
| 67 | `C` | F3 | Function button 3 |
|
||||
| 68 | `D` | F4 | Function button 4 |
|
||||
| 69 | `E` | F5 | Function button 5 |
|
||||
| 70 | `F` | F6 | Function button 6 |
|
||||
| 71 | `G` | F7 | Function button 7 |
|
||||
| 46 | `.` | Prefix | Camera number prefix cycle |
|
||||
| 42 | `*` | FullScreen | Toggle monitor maximize |
|
||||
| 41 | `)` | Sequence | Open sequence menu |
|
||||
| 40 | `(` | Backspace | Delete last digit |
|
||||
| 48 | `0` | D0 | Digit 0 |
|
||||
| 55-51 | `7-3` | D1-D5 | Digits 1-5 (note: remapped!) |
|
||||
| 49-51 | `1-3` | D7-D9 | Digits 7-9 (note: remapped!) |
|
||||
| 52-54 | `4-6` | D4-D6 | Digits 4-6 |
|
||||
| 60 | `<` | Minus | Previous camera / Focus far |
|
||||
| 62 | `>` | Plus | Next camera / Focus near |
|
||||
| 43 | `+` | Lock | Toggle camera PTZ lock |
|
||||
| 45 | `-` | Enter | Confirm camera number |
|
||||
|
||||
**Important:** The digit key wiring is non-standard — D1 maps to ASCII 55 (`7`), not 49 (`1`). This is a hardware layout choice.
|
||||
|
||||
### Joystick Buttons
|
||||
| Button | Virtual Key | Function |
|
||||
|--------|-------------|----------|
|
||||
| Button1 | JoystickButton1 | (Context-dependent) |
|
||||
| Button2 | JoystickButton2 | Toggle camera PTZ lock |
|
||||
|
||||
### Development Keyboard Emulation
|
||||
|
||||
When no serial port is detected, the app falls back to standard keyboard input via `VirtualKeyboard.Emulate()`:
|
||||
|
||||
| PC Key | Virtual Key |
|
||||
|--------|-------------|
|
||||
| Home | Home |
|
||||
| F1-F7 | F1-F7 |
|
||||
| F10 | Prefix |
|
||||
| F11 | FullScreen |
|
||||
| F12 | Sequence |
|
||||
| 0-9 / Numpad | D0-D9 |
|
||||
| Backspace | Backspace |
|
||||
| Enter | Enter |
|
||||
| +/- | Plus/Minus |
|
||||
| Left/Right arrow | JogLeft/JogRight |
|
||||
| Shift+Left/Right | Shuttle (incremental) |
|
||||
| Shift+Up | Shuttle center (reset) |
|
||||
|
||||
## Shuttle Speed Mapping
|
||||
|
||||
The shuttle wheel has 7 positions per direction. Speeds are configurable per deployment:
|
||||
|
||||
```
|
||||
Position: -7 -6 -5 -4 -3 -2 -1 0 +1 +2 +3 +4 +5 +6 +7
|
||||
◄── backward ──────────── center ──────────── forward ──►
|
||||
Config: [250,100, 30, 15, 5, 2, 1] ● [ 1, 2, 5, 15, 30,100,250]
|
||||
```
|
||||
|
||||
Default playback speeds (from config): `[1, 2, 5, 15, 30, 100, 250]`
|
||||
|
||||
## Service Key (Hidden Menu)
|
||||
|
||||
Holding the **Backspace** key for 3 seconds opens the Service Menu page. This provides access to firmware update and diagnostic functions.
|
||||
99
Docs/legacy-architecture/index.html
Normal file
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>COPILOT D6 Legacy Architecture</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docsify-themeable@0/dist/css/theme-simple-dark.css">
|
||||
<style>
|
||||
:root {
|
||||
--base-font-size: 15px;
|
||||
--theme-color: #3b82f6;
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-background: #1e1e2e;
|
||||
--sidebar-nav-link-color--active: #3b82f6;
|
||||
}
|
||||
.markdown-section pre {
|
||||
background-color: #1e1e2e;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.markdown-section table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
.markdown-section h1 {
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.mermaid svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
.mermaid {
|
||||
text-align: center;
|
||||
margin: 1em 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">Loading...</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
<script>
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'dark',
|
||||
securityLevel: 'loose',
|
||||
flowchart: { useMaxWidth: true, htmlLabels: true },
|
||||
sequence: { useMaxWidth: true }
|
||||
});
|
||||
|
||||
window.$docsify = {
|
||||
name: 'COPILOT D6',
|
||||
repo: '',
|
||||
loadSidebar: '_sidebar.md',
|
||||
subMaxLevel: 3,
|
||||
auto2top: true,
|
||||
search: {
|
||||
placeholder: 'Search docs...',
|
||||
noData: 'No results.',
|
||||
depth: 3
|
||||
},
|
||||
// Intercept mermaid code blocks at the markdown parser level
|
||||
markdown: {
|
||||
renderer: {
|
||||
code: function(code, lang) {
|
||||
if (lang && lang.toLowerCase() === 'mermaid') {
|
||||
return '<div class="mermaid">' + code + '</div>';
|
||||
}
|
||||
return this.origin.code.apply(this, arguments);
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
function(hook) {
|
||||
// Strip YAML frontmatter before markdown parsing
|
||||
hook.beforeEach(function(content) {
|
||||
return content.replace(/^---[\s\S]*?---\n*/m, '');
|
||||
});
|
||||
|
||||
// Render mermaid diagrams after DOM update
|
||||
hook.doneEach(function() {
|
||||
var els = document.querySelectorAll('.mermaid');
|
||||
if (els.length > 0) {
|
||||
// Reset any previously processed elements
|
||||
els.forEach(function(el) {
|
||||
el.removeAttribute('data-processed');
|
||||
});
|
||||
mermaid.run({ nodes: Array.from(els) }).catch(function(e) {
|
||||
console.error('Mermaid render error:', e);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/docsify@4/lib/docsify.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/docsify@4/lib/plugins/search.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
126
Docs/legacy-architecture/index.md
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
title: "COPILOT D6 Legacy WPF Application"
|
||||
description: "Complete reverse-engineered architecture documentation of the original COPILOT D6 CCTV keyboard controller system"
|
||||
---
|
||||
|
||||
# COPILOT D6 Legacy Architecture
|
||||
|
||||
> Reverse-engineered from compiled .NET 7 assemblies (build 1.0.705, December 2023) using ILSpy decompilation. This documentation covers the complete architecture of the original WPF-based CCTV keyboard controller system.
|
||||
|
||||
## System Overview
|
||||
|
||||
The COPILOT D6 system is a **CCTV surveillance keyboard controller** used to manage video feeds from multiple camera servers (Geutebruck GeViScope, G-Core, GeViSoft) across a wall of physical monitors. Operators use a custom hardware keyboard with joystick to:
|
||||
|
||||
- Switch cameras to monitors (CrossSwitch)
|
||||
- Control PTZ cameras (Pan/Tilt/Zoom) via joystick
|
||||
- Manage camera prepositions (saved positions)
|
||||
- Run camera sequences (automated cycling)
|
||||
- View and manage alarms
|
||||
- Playback recorded video
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```
|
||||
COPILOT SYSTEM ARCHITECTURE
|
||||
============================
|
||||
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ COPILOT KEYBOARD (Hardware) │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
|
||||
│ │ Numpad + │ │ 3-Axis │ │ Jog/Shuttle │ │
|
||||
│ │ Function │ │ Joystick │ │ Wheel │ │
|
||||
│ │ Keys │ │ (HID USB) │ │ │ │
|
||||
│ │ [Serial] │ │ X/Y/Z Axes │ │ [Serial] │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └───────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┼───────────────────┘ │
|
||||
│ Arduino Leonardo │
|
||||
│ (USB Composite Device) │
|
||||
└────────────────────────────┬──────────────────────────────┘
|
||||
│ USB (Serial + HID)
|
||||
┌────────────────────────────┼──────────────────────────────┐
|
||||
│ LattePanda Sigma SBC │
|
||||
│ ┌─────────────────────────┴──────────────────────────┐ │
|
||||
│ │ Copilot.App.exe (WPF .NET 7) │ │
|
||||
│ │ ┌──────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Copilot.Device Layer │ │ │
|
||||
│ │ │ Serial Port ←→ Keyboard Keys/Jog/Shuttle │ │ │
|
||||
│ │ │ HID Device ←→ Joystick X/Y/Z │ │ │
|
||||
│ │ └──────────────────────┬───────────────────────┘ │ │
|
||||
│ │ │ Events │ │
|
||||
│ │ ┌──────────────────────┴───────────────────────┐ │ │
|
||||
│ │ │ MainWindow (WPF) │ │ │
|
||||
│ │ │ Routes input → current ViewModel │ │ │
|
||||
│ │ └──────────────────────┬───────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌──────────────────────┴───────────────────────┐ │ │
|
||||
│ │ │ SegmentViewModel (main screen) │ │ │
|
||||
│ │ │ • PTZ control via joystick │ │ │
|
||||
│ │ │ • Camera number entry │ │ │
|
||||
│ │ │ • CrossSwitch execution │ │ │
|
||||
│ │ │ • Camera lock management │ │ │
|
||||
│ │ │ • Playback control (jog/shuttle) │ │ │
|
||||
│ │ └──┬────────────────────────────────────┬──────┘ │ │
|
||||
│ │ │ Direct SDK calls │ SignalR │ │
|
||||
│ │ ┌──┴──────────────────────┐ ┌─────────┴──────┐ │ │
|
||||
│ │ │ Camera Server │ │ AppServer │ │ │
|
||||
│ │ │ Drivers │ │ Client │ │ │
|
||||
│ │ │ • GeViScope SDK │ │ (SignalR Hub) │ │ │
|
||||
│ │ │ • G-Core SDK │ │ │ │ │
|
||||
│ │ │ • GeViSoft SDK │ │ │ │ │
|
||||
│ │ └──┬──────────────────────┘ └────────┬───────┘ │ │
|
||||
│ └─────┼──────────────────────────────────┼───────────┘ │
|
||||
└────────┼──────────────────────────────────┼───────────────┘
|
||||
│ Native SDK (TCP) │ HTTPS/WSS
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌────────────────────────┐
|
||||
│ Camera Servers │ │ Copilot AppServer │
|
||||
│ • GeViScope │ │ (ASP.NET Core) │
|
||||
│ 192.168.102.186 │ │ copilot.test.d6... │
|
||||
│ • G-Core │ │ ┌──────────────────┐ │
|
||||
│ 192.168.102.20 │ │ │ SignalR Hub │ │
|
||||
│ │ │ │ • Camera Locks │ │
|
||||
│ Each server has: │ │ │ • Sequences │ │
|
||||
│ • Cameras │ │ │ • Config Sync │ │
|
||||
│ • Monitors/Viewers │ │ │ • Viewer State │ │
|
||||
│ • PTZ controllers │ │ │ • Alarm History │ │
|
||||
│ • Alarm events │ │ └──────────────────┘ │
|
||||
│ │ │ ┌──────────────────┐ │
|
||||
│ │ │ │ SQLite Database │ │
|
||||
│ │ │ │ • Lock state │ │
|
||||
│ │ │ │ • Alarm history │ │
|
||||
│ │ │ └──────────────────┘ │
|
||||
│ │ │ ┌──────────────────┐ │
|
||||
│ │ │ │ REST API │ │
|
||||
│ │ │ │ • Auto-updates │ │
|
||||
│ │ │ │ • Configuration │ │
|
||||
│ │ │ │ • Blazor Admin UI │ │
|
||||
│ │ │ └──────────────────┘ │
|
||||
└─────────────────────┘ └────────────────────────┘
|
||||
```
|
||||
|
||||
## Assembly Map
|
||||
|
||||
| Assembly | Type | Purpose |
|
||||
|----------|------|---------|
|
||||
| `Copilot.App.dll` | WPF Client | Main application - UI, ViewModels, navigation, input handling |
|
||||
| `Copilot.Device.dll` | Client Library | Hardware abstraction - serial port, HID joystick, key mapping |
|
||||
| `Copilot.Common.dll` | Shared Library | Configuration models, data protection, providers, hub interfaces |
|
||||
| `Copilot.Common.Services.dll` | Shared Library | Driver providers, media channel service, viewer state management |
|
||||
| `Copilot.Drivers.Common.dll` | Shared Library | Driver interfaces (`IMovementController`, `ICameraServerDriver`, etc.) |
|
||||
| `Copilot.Drivers.GeViScope.dll` | Driver | GeViScope SDK wrapper - PLC actions for cameras/PTZ |
|
||||
| `Copilot.Drivers.GCore.dll` | Driver | G-Core SDK wrapper - binary protocol for cameras/PTZ |
|
||||
| `Copilot.Drivers.GeviSoft.dll` | Driver | GeViSoft SDK wrapper |
|
||||
| `Copilot.AppServer.Client.dll` | Client Library | SignalR hub client, availability monitoring |
|
||||
| `Copilot.AppServer.dll` | Server | ASP.NET Core server - API, SignalR hub, Blazor admin |
|
||||
| `Copilot.AppServer.Database.dll` | Server Library | Entity Framework - SQLite models and repositories |
|
||||
| `Copilot.Camea.Client.dll` | Server Library | Camea API integration (external alarm system) |
|
||||
|
||||
## Documentation Pages
|
||||
|
||||
- **[System Architecture](./architecture.md)** - Component diagrams, dependency graph, deployment model
|
||||
- **[Hardware & Input](./hardware-input.md)** - Joystick, keyboard, serial/HID protocols
|
||||
- **[PTZ Control Flow](./ptz-control.md)** - Joystick → Pan/Tilt/Zoom → SDK command pipeline
|
||||
- **[Data Flows](./data-flows.md)** - CrossSwitch, Alarms, Sequences, Playback, Camera Lock
|
||||
- **[Configuration](./configuration.md)** - JSON config files, monitor wall topology, function buttons
|
||||
- **[AppServer](./appserver.md)** - SignalR hub, REST API, database, admin UI
|
||||
393
Docs/legacy-architecture/migration-business-rules.md
Normal 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
|
||||
120
Docs/legacy-architecture/migration-comparison.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: "Legacy → Flutter Migration Comparison"
|
||||
description: "Side-by-side comparison of legacy WPF architecture vs new Flutter system"
|
||||
---
|
||||
|
||||
# Legacy → Flutter Migration Comparison
|
||||
|
||||
## Architecture Comparison
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Legacy (WPF)"
|
||||
L_HW["Hardware Keyboard<br/>Serial + HID"] --> L_App["Copilot.App.exe<br/>(WPF .NET 7)"]
|
||||
L_App -->|"Native SDK<br/>(TCP direct)"| L_Srv["Camera Servers<br/>GeViScope / G-Core"]
|
||||
L_App -->|"SignalR<br/>(HTTPS/WSS)"| L_AS["AppServer<br/>(ASP.NET Core)"]
|
||||
L_AS --> L_DB["SQLite"]
|
||||
end
|
||||
|
||||
subgraph "New (Flutter)"
|
||||
N_HW["Hardware Keyboard<br/>Serial + HID"] --> N_App["Flutter App<br/>(Web/Desktop)"]
|
||||
N_App -->|"REST HTTP<br/>(localhost)"| N_Bridge["C# Bridges<br/>(.NET 8)"]
|
||||
N_Bridge -->|"Native SDK<br/>(TCP)"| N_Srv["Camera Servers<br/>GeViScope / G-Core"]
|
||||
N_App -->|"WebSocket<br/>(PRIMARY)"| N_Coord["PRIMARY Keyboard<br/>(Coordination)"]
|
||||
end
|
||||
```
|
||||
|
||||
## Component Mapping
|
||||
|
||||
| Legacy (WPF) | New (Flutter) | Notes |
|
||||
|---------------|---------------|-------|
|
||||
| `Copilot.App.exe` | `copilot_keyboard` Flutter app | Full rewrite |
|
||||
| `Copilot.Device.dll` | Web HID / Serial API | Browser APIs or native plugins |
|
||||
| `Copilot.Drivers.GeViScope.dll` | GeViScope Bridge (:7720) | SDK stays in C#, exposed via REST |
|
||||
| `Copilot.Drivers.GCore.dll` | G-Core Bridge (:7721) | SDK stays in C#, exposed via REST |
|
||||
| `Copilot.Drivers.GeviSoft.dll` | GeViServer Bridge (:7710) | SDK stays in C#, exposed via REST |
|
||||
| `Copilot.Drivers.Common.dll` | Bridge REST API contracts | Interfaces become HTTP endpoints |
|
||||
| `Copilot.AppServer.exe` | PRIMARY keyboard + WebSocket hub | **No separate server** — runs on keyboard |
|
||||
| `Copilot.AppServer.Database.dll` | In-memory state on PRIMARY | No SQLite needed |
|
||||
| `Copilot.Common.Services.dll` | `BridgeService` + `StateService` | Dart services |
|
||||
| SignalR Hub | WebSocket hub on PRIMARY | Simpler protocol |
|
||||
| `IMovementController` | `POST /api/ptz/{action}` on bridge | REST instead of direct SDK |
|
||||
| `ICameraServerDriver` | Bridge handles connection | App doesn't touch SDK |
|
||||
| `ICentralServerDriver.CrossSwitch` | `POST /api/viewer/connect-live` | Via bridge REST |
|
||||
|
||||
## Key Architectural Differences
|
||||
|
||||
### 1. SDK Access Path
|
||||
|
||||
**Legacy:** App → Native SDK DLL → Camera Server (direct TCP)
|
||||
```
|
||||
Copilot.App → GeViScopeMovementController → GscPLCWrapper → TCP → Server
|
||||
```
|
||||
|
||||
**New:** App → HTTP → C# Bridge → Native SDK → Camera Server
|
||||
```
|
||||
Flutter App → HTTP POST /api/ptz/pan → Bridge (.NET 8) → SDK → Server
|
||||
```
|
||||
|
||||
**Why:** Flutter (especially web) cannot load native .NET DLLs. The C# bridges wrap the same SDKs behind a REST API.
|
||||
|
||||
### 2. Coordination Model
|
||||
|
||||
**Legacy:** Centralized AppServer (single point of coordination)
|
||||
- All keyboards connect to one AppServer via SignalR
|
||||
- AppServer manages locks, sequences, config, alarms
|
||||
- AppServer failure = loss of coordination features
|
||||
|
||||
**New:** Distributed PRIMARY/STANDBY model
|
||||
- Any keyboard can be PRIMARY (runs coordination logic)
|
||||
- STANDBY monitors PRIMARY via heartbeat, auto-promotes after 6s
|
||||
- No separate server hardware needed
|
||||
- Critical operations (CrossSwitch, PTZ) work without PRIMARY
|
||||
|
||||
### 3. Configuration Management
|
||||
|
||||
**Legacy:** AppServer stores config → syncs to keyboards via SignalR
|
||||
**New:** `servers.json` + `keyboards.json` + `crossswitch-rules.json` loaded from local files + PRIMARY sync
|
||||
|
||||
### 4. Alarm System
|
||||
|
||||
**Legacy:** AppServer → Camea API → SignalR → Keyboards
|
||||
**New:** Each bridge can query alarms directly + periodic sync via PRIMARY
|
||||
|
||||
## Feature Parity Matrix
|
||||
|
||||
| Feature | Legacy Status | New Status | Priority |
|
||||
|---------|--------------|------------|----------|
|
||||
| CrossSwitch (camera → monitor) | Complete | Phase 1 | Critical |
|
||||
| PTZ via joystick (Pan/Tilt/Zoom) | Complete | Phase 1 | Critical |
|
||||
| Camera number entry (numpad) | Complete | Phase 1 | Critical |
|
||||
| Camera lock (PTZ coordination) | Complete | Phase 2 | High |
|
||||
| Prepositions (saved positions) | Complete | Phase 1 | High |
|
||||
| Sequences (camera cycling) | Complete | Phase 3 | Medium |
|
||||
| Function buttons (F1-F7) | Complete | Phase 1 | High |
|
||||
| Playback (jog/shuttle) | Complete | Phase 3 | Medium |
|
||||
| Alarm display | Complete | Phase 3 | Medium |
|
||||
| Alarm history | Complete | Phase 3 | Low |
|
||||
| Monitor wall segments | Complete | Phase 1 | High |
|
||||
| Config sync from server | Complete | Phase 2 | Medium |
|
||||
| Auto-update (firmware + app) | Complete | Phase 4 | Low |
|
||||
| Service menu | Complete | Phase 4 | Low |
|
||||
| Keyboard emulation (dev mode) | Complete | N/A (browser) | N/A |
|
||||
|
||||
## PTZ Speed Values: Compatible
|
||||
|
||||
The legacy app sends speed values **0-255** to the SDK. The new bridges should use the **same range** to maintain identical PTZ behavior. The zoom-proportional speed feature is provided by the camera/server infrastructure, not the app.
|
||||
|
||||
```
|
||||
Legacy: Joystick HID (-255..+255) → PanRight(speed) → SDK → Server
|
||||
New: Joystick HID (-255..+255) → POST /api/ptz/pan {speed} → Bridge → SDK → Server
|
||||
```
|
||||
|
||||
Same speed values = same camera behavior.
|
||||
|
||||
## Risk Areas
|
||||
|
||||
1. **Joystick latency** — Legacy sends joystick events directly via in-process SDK call (~1ms). New path adds HTTP overhead (~5-20ms). Monitor for responsiveness.
|
||||
2. **Lock coordination** — Legacy uses SignalR (battle-tested). New uses custom WebSocket protocol. Needs thorough testing.
|
||||
3. **Sequence execution** — Legacy runs on AppServer (always-on). New runs on PRIMARY keyboard (could failover mid-sequence).
|
||||
4. **Alarm reliability** — Legacy has Camea API integration on AppServer. New needs bridge-level alarm subscription.
|
||||
220
Docs/legacy-architecture/migration-guide.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Migration Guide: WPF → Flutter (No AppServer)
|
||||
|
||||
> This guide maps every component of the legacy WPF COPILOT D6 system to its Flutter equivalent. The key architectural change: **there is no centralized AppServer** — the PRIMARY keyboard handles coordination.
|
||||
|
||||
## Architecture Transformation
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "LEGACY Architecture"
|
||||
direction TB
|
||||
L_HW["Hardware Keyboard"] --> L_App["Copilot.App.exe<br/>(WPF .NET 7)"]
|
||||
L_App -->|"Native SDK DLL<br/>(in-process)"| L_Cam["Camera Servers"]
|
||||
L_App -->|"SignalR<br/>(HTTPS/WSS)"| L_AS["AppServer<br/>(separate machine)"]
|
||||
L_AS --> L_DB["SQLite DB"]
|
||||
end
|
||||
|
||||
subgraph "NEW Architecture"
|
||||
direction TB
|
||||
N_HW["Hardware Keyboard"] --> N_App["Flutter App<br/>(Web/Desktop)"]
|
||||
N_App -->|"REST HTTP<br/>(localhost bridges)"| N_Bridge["C# Bridges<br/>(.NET 8)"]
|
||||
N_Bridge -->|"Native SDK"| N_Cam["Camera Servers"]
|
||||
N_App -->|"WebSocket<br/>(:8090)"| N_Primary["PRIMARY Keyboard<br/>(coordination)"]
|
||||
end
|
||||
```
|
||||
|
||||
## What Replaces the AppServer?
|
||||
|
||||
The legacy AppServer was a centralized ASP.NET Core server providing:
|
||||
|
||||
| AppServer Feature | New Approach | Where It Runs |
|
||||
|-------------------|-------------|---------------|
|
||||
| Camera locks (SignalR hub) | WebSocket hub + in-memory state | PRIMARY keyboard |
|
||||
| Sequence execution | Sequence runner service | PRIMARY keyboard |
|
||||
| Config file sync | Local JSON files + PRIMARY broadcast | Each keyboard |
|
||||
| Alarm history (Camea API) | Bridge alarm endpoints + periodic sync | Each keyboard queries bridges |
|
||||
| Viewer state tracking | Bridge WebSocket events | Each keyboard tracks locally |
|
||||
| Auto-update | Out of scope (Phase 4+) | — |
|
||||
| Admin UI (Blazor) | Not needed initially | — |
|
||||
|
||||
### PRIMARY/STANDBY Model
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant K1 as Keyboard 1 (PRIMARY)
|
||||
participant K2 as Keyboard 2 (STANDBY)
|
||||
participant B as C# Bridges
|
||||
participant C as Camera Servers
|
||||
|
||||
Note over K1,K2: Normal operation
|
||||
K1->>B: Direct commands (CrossSwitch, PTZ)
|
||||
K2->>B: Direct commands (CrossSwitch, PTZ)
|
||||
K1->>K2: Heartbeat every 2s via WebSocket
|
||||
K1->>K2: State sync (locks, sequences)
|
||||
|
||||
Note over K1,K2: PRIMARY failure
|
||||
K2->>K2: No heartbeat for 6s
|
||||
K2->>K2: Self-promote to PRIMARY
|
||||
K2->>B: Continue direct commands
|
||||
Note over K2: Lock/sequence state rebuilt from bridges
|
||||
```
|
||||
|
||||
**Key principle:** Direct commands (CrossSwitch, PTZ) **always work** — they go straight to bridges. Only coordination features (locks, sequences) need the PRIMARY.
|
||||
|
||||
## Component Mapping: Legacy → Flutter
|
||||
|
||||
### Assembly-Level Mapping
|
||||
|
||||
| Legacy Assembly | Flutter Equivalent | Status |
|
||||
|----------------|-------------------|--------|
|
||||
| `Copilot.App.dll` (WPF UI + ViewModels) | `copilot_keyboard/` Flutter app | Partially built |
|
||||
| `Copilot.Device.dll` (Serial + HID) | Web Serial API / `flutter_libserialport` | Not started |
|
||||
| `Copilot.Drivers.GeViScope.dll` | GeViScope Bridge (:7720) REST API | **Complete** |
|
||||
| `Copilot.Drivers.GCore.dll` | G-Core Bridge (:7721) REST API | **Complete** |
|
||||
| `Copilot.Drivers.GeviSoft.dll` | GeViServer Bridge (:7710) REST API | Minimal |
|
||||
| `Copilot.Drivers.Common.dll` | Bridge REST API contracts | **Complete** |
|
||||
| `Copilot.Common.dll` | `config/`, `domain/entities/` | Partially built |
|
||||
| `Copilot.Common.Services.dll` | `data/services/` (BridgeService, StateService) | Partially built |
|
||||
| `Copilot.AppServer.Client.dll` | `CoordinationService` (WebSocket to PRIMARY) | Not started |
|
||||
| `Copilot.AppServer.dll` | PRIMARY keyboard coordination logic | Not started |
|
||||
| `Copilot.AppServer.Database.dll` | In-memory state on PRIMARY | Not started |
|
||||
|
||||
### Class-Level Mapping
|
||||
|
||||
| Legacy Class | Flutter Class | BLoC | Notes |
|
||||
|-------------|--------------|------|-------|
|
||||
| `SegmentViewModel` (1323 lines) | Split into multiple BLoCs | `WallBloc` + `PtzBloc` + `CameraBloc` | Core logic, needs decomposition |
|
||||
| `MainWindow` (217 lines) | `MainScreen` + `KeyboardService` | — | Input routing |
|
||||
| `CameraControllerService` (65 lines) | `BridgeService` | — | Already mapped to REST |
|
||||
| `CameraLockService` (61 lines) | `LockService` (new) | `LockBloc` (new) | Via PRIMARY WebSocket |
|
||||
| `SequenceService` (77 lines) | `SequenceService` (new) | `SequenceBloc` (new) | Via PRIMARY WebSocket |
|
||||
| `ConfigurationService` (100 lines) | `AppConfig` + JSON files | — | Local files, no sync |
|
||||
| `FunctionButtonsService` (78 lines) | `FunctionButtonService` (new) | `WallBloc` | Config-driven |
|
||||
| `PrepositionService` (114 lines) | `PrepositionService` (new) | `PrepositionBloc` (new) | Via bridge REST |
|
||||
| `CameraAlarmService` (47 lines) | `AlarmService` (exists) | `AlarmBloc` (exists) | Already implemented |
|
||||
| `PlaybackStateService` (37 lines) | `PlaybackService` (new) | `PlaybackBloc` (new) | Via bridge REST |
|
||||
| `CopilotDevice` | `KeyboardService` (new) | — | Web Serial + HID APIs |
|
||||
| `NavigationService` | `GoRouter` | — | Already in pubspec |
|
||||
|
||||
## What's Already Built vs. What's Missing
|
||||
|
||||
### Already Built in Flutter
|
||||
|
||||
| Feature | Files | Status |
|
||||
|---------|-------|--------|
|
||||
| CrossSwitch (camera → monitor) | `WallBloc`, `BridgeService` | Working |
|
||||
| PTZ control (Pan/Tilt/Zoom) | `PtzBloc`, `BridgeService` | Working |
|
||||
| Camera number input (numpad) | `WallBloc`, `MainScreen` | Working |
|
||||
| Camera prefix selection (500/501/502) | `WallBloc` | Working |
|
||||
| Monitor state tracking | `MonitorBloc`, `StateService` | Working |
|
||||
| Alarm monitoring | `AlarmBloc`, `AlarmService` | Working |
|
||||
| Connection management | `ConnectionBloc`, `BridgeService` | Working |
|
||||
| Wall grid UI (5 sections) | `WallOverview`, `SectionView` | Working |
|
||||
| WebSocket event streaming | `BridgeService` | Working |
|
||||
| Bridge health checks | `BridgeService` | Working |
|
||||
| DI container (GetIt) | `injection_container.dart` | Working |
|
||||
|
||||
### Missing — Must Be Built
|
||||
|
||||
| Feature | Priority | Complexity | Legacy Reference |
|
||||
|---------|----------|-----------|------------------|
|
||||
| Camera lock system | **Critical** | High | `CameraLockService`, `SegmentViewModel:631-676` |
|
||||
| PRIMARY/STANDBY coordination | **Critical** | High | Replaces `AppServer` |
|
||||
| Hardware keyboard (Serial) | **Critical** | Medium | `CopilotDevice`, `SerialPortDataProvider` |
|
||||
| Hardware joystick (HID) | **Critical** | Medium | `JoystickHidDataProvider` |
|
||||
| Function buttons (F1-F7) | High | Low | `FunctionButtonsService` |
|
||||
| Prepositions (saved positions) | High | Medium | `PrepositionService` |
|
||||
| Camera number edit timeout | High | Low | `SegmentViewModel:829-847` |
|
||||
| Playback mode (jog/shuttle) | Medium | Medium | `PlaybackStateService`, viewer controller |
|
||||
| Sequences (camera cycling) | Medium | High | `SequenceService` |
|
||||
| Keyboard emulation (dev mode) | Medium | Low | `MainWindow:60-104` |
|
||||
| Service menu | Low | Low | Long-press Backspace 3s |
|
||||
| Config hot-reload | Low | Medium | `ConfigurationService` |
|
||||
| CrossSwitch rules engine | Medium | Medium | `crossswitch-rules.json` |
|
||||
| Alarm history view | Low | Low | `CameraAlarmService.GetAlarmForCamera` |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 2A: Camera Lock System (Critical)
|
||||
|
||||
The lock system is the most complex missing piece. In the legacy app:
|
||||
|
||||
1. User selects a PTZ camera → triggers lock attempt
|
||||
2. If lock acquired → PTZ telemetry enabled
|
||||
3. If locked by another keyboard → dialog: "Request takeover?"
|
||||
4. Lock expires after 5 minutes unless reset by PTZ movement
|
||||
5. 1-minute warning before expiration
|
||||
6. Priority levels: Low (default), High (override)
|
||||
|
||||
**New implementation:**
|
||||
- PRIMARY keyboard manages lock state in memory
|
||||
- Keyboards send lock requests via WebSocket to PRIMARY
|
||||
- PRIMARY broadcasts lock state changes to all keyboards
|
||||
- In degraded mode (no PRIMARY): local-only locks (no coordination)
|
||||
|
||||
### Phase 2B: PRIMARY/STANDBY Coordination
|
||||
|
||||
```
|
||||
WebSocket Hub on PRIMARY (:8090)
|
||||
├── /ws/heartbeat → 2-second heartbeat
|
||||
├── /ws/locks → Lock state sync
|
||||
├── /ws/sequences → Sequence state sync
|
||||
└── /ws/state → General state broadcast
|
||||
```
|
||||
|
||||
### Phase 3: Hardware Input
|
||||
|
||||
Serial port protocol (from `CopilotDevice`):
|
||||
- Prefix `p` = key press, `r` = key release
|
||||
- Prefix `j` = jog value, `s` = shuttle value
|
||||
- Prefix `v` = version, `h` = heartbeat
|
||||
|
||||
HID joystick:
|
||||
- VendorId: 10959 (0x2ACF), ProductId: 257 (0x0101)
|
||||
- 3 axes: X (pan), Y (tilt), Z (zoom)
|
||||
- Value range: -255 to +255
|
||||
|
||||
### Phase 4: Sequences & Playback
|
||||
|
||||
- Sequences run on PRIMARY keyboard (not AppServer)
|
||||
- Playback via bridge viewer control endpoints
|
||||
- Jog/shuttle wheel maps to playback speed (-7 to +7)
|
||||
|
||||
## Bridge API Reference (Quick)
|
||||
|
||||
Both bridges expose identical endpoints:
|
||||
|
||||
```
|
||||
POST /viewer/connect-live {Viewer, Channel} → CrossSwitch
|
||||
POST /viewer/clear {Viewer} → Clear monitor
|
||||
POST /camera/pan {Camera, Direction, Speed} → PTZ pan
|
||||
POST /camera/tilt {Camera, Direction, Speed} → PTZ tilt
|
||||
POST /camera/zoom {Camera, Direction, Speed} → PTZ zoom
|
||||
POST /camera/stop {Camera} → Stop PTZ
|
||||
POST /camera/preset {Camera, Preset} → Go to preset
|
||||
GET /monitors → All monitor states
|
||||
GET /alarms/active → Active alarms
|
||||
GET /health → Bridge health
|
||||
WS /ws/events → Real-time events
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
| File | Purpose | Legacy Equivalent |
|
||||
|------|---------|-------------------|
|
||||
| `servers.json` | Server connections, bridge URLs, ID ranges | `CameraServersConfiguration` |
|
||||
| `keyboards.json` | Keyboard identity, PRIMARY/STANDBY role | `CopilotAppConfiguration` |
|
||||
| `wall-config.json` | Monitor wall layout (sections, monitors, viewers) | `MonitorWallConfiguration` |
|
||||
| `function-buttons.json` | F1-F7 actions per wall | `FunctionButtonsConfiguration` |
|
||||
| `prepositions.json` | Camera preset names | `PrepositionsConfiguration` |
|
||||
| `crossswitch-rules.json` | Routing rules for CrossSwitch | New (was in AppServer logic) |
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| Joystick latency via HTTP bridge | Measure. Legacy: ~1ms (in-process). New: ~5-20ms (HTTP). Acceptable for PTZ. |
|
||||
| Lock coordination without AppServer | PRIMARY WebSocket hub. Degraded mode = local-only locks. |
|
||||
| Sequence failover mid-execution | PRIMARY tracks sequence state. STANDBY can resume from last known position. |
|
||||
| Config drift between keyboards | PRIMARY broadcasts config changes. Startup sync on connect. |
|
||||
| Browser HID/Serial support | Use Web HID API + Web Serial API. Desktop fallback via `flutter_libserialport`. |
|
||||
701
Docs/legacy-architecture/migration-implementation.md
Normal file
@@ -0,0 +1,701 @@
|
||||
# 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<Object?> 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<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`:
|
||||
```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<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
|
||||
|
||||
```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<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
|
||||
|
||||
```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<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`:
|
||||
```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<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
|
||||
|
||||
```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<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
|
||||
|
||||
```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<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)
|
||||
|
||||
```dart
|
||||
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
|
||||
|
||||
```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** | |
|
||||
43
Docs/legacy-architecture/mint.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/schema.json",
|
||||
"name": "COPILOT D6 Legacy Architecture",
|
||||
"logo": {
|
||||
"light": "/logo/light.svg",
|
||||
"dark": "/logo/dark.svg"
|
||||
},
|
||||
"favicon": "/favicon.png",
|
||||
"colors": {
|
||||
"primary": "#2563EB",
|
||||
"light": "#60A5FA",
|
||||
"dark": "#1D4ED8"
|
||||
},
|
||||
"navigation": [
|
||||
{
|
||||
"group": "Overview",
|
||||
"pages": ["index"]
|
||||
},
|
||||
{
|
||||
"group": "Architecture",
|
||||
"pages": [
|
||||
"architecture",
|
||||
"hardware-input",
|
||||
"ptz-control"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Workflows",
|
||||
"pages": [
|
||||
"data-flows",
|
||||
"configuration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Infrastructure",
|
||||
"pages": [
|
||||
"appserver",
|
||||
"migration-comparison"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mermaid": {}
|
||||
}
|
||||
172
Docs/legacy-architecture/ptz-control.md
Normal file
@@ -0,0 +1,172 @@
|
||||
---
|
||||
title: "PTZ Control Flow"
|
||||
description: "Complete joystick → Pan/Tilt/Zoom → SDK command pipeline with speed analysis"
|
||||
---
|
||||
|
||||
# PTZ Control Flow
|
||||
|
||||
## End-to-End Pipeline
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant HW as Joystick (HID)
|
||||
participant Dev as CopilotDevice
|
||||
participant MW as MainWindow
|
||||
participant VM as SegmentViewModel
|
||||
participant CCS as CameraControllerService
|
||||
participant Drv as GeViScope/GCore Driver
|
||||
participant SDK as Native SDK (PLC)
|
||||
participant Srv as Camera Server
|
||||
|
||||
HW->>Dev: HID report (raw axis data)
|
||||
Dev->>Dev: Scale to -255..+255
|
||||
Dev->>MW: JoystickMoved(x, y, z)
|
||||
MW->>MW: Dispatcher.Invoke (UI thread)
|
||||
MW->>VM: IJoystickHandler.OnJoystickMoved(x, y, z)
|
||||
|
||||
alt IsCameraTelemetryActive (PTZ locked)
|
||||
VM->>VM: DoPtzAction(x, Pan, controller.Pan)
|
||||
VM->>VM: DoPtzAction(y, Tilt, controller.Tilt)
|
||||
VM->>VM: DoPtzAction(z, Zoom, controller.Zoom)
|
||||
|
||||
Note over VM: DoPtzAction only sends if speed changed<br/>(deduplication via ptzSpeeds dictionary)
|
||||
|
||||
VM->>CCS: GetMovementController(cameraNumber)
|
||||
CCS->>Drv: GetMovementControllerForChannel(id)
|
||||
Drv-->>VM: IMovementController
|
||||
|
||||
alt x changed (Pan)
|
||||
alt x > 0
|
||||
VM->>Drv: PanRight(x)
|
||||
else x < 0
|
||||
VM->>Drv: PanLeft(abs(x))
|
||||
else x == 0
|
||||
VM->>Drv: PanStop()
|
||||
end
|
||||
end
|
||||
|
||||
Drv->>SDK: GscAct_PanRight(channelId, speed)
|
||||
SDK->>Srv: TCP PLC command
|
||||
|
||||
VM->>VM: ResetCameraLockExpiration()
|
||||
else Not locked
|
||||
Note over VM: Joystick events ignored
|
||||
end
|
||||
```
|
||||
|
||||
## Speed Value Processing
|
||||
|
||||
### Layer 1: HID → Raw Value
|
||||
```
|
||||
Joystick physical position → HID report → GetScaledValue(-255.0, 255.0) → integer
|
||||
```
|
||||
|
||||
The joystick reports continuously while being held. Each axis change fires independently.
|
||||
|
||||
### Layer 2: Deduplication (DoPtzAction)
|
||||
```csharp
|
||||
private void DoPtzAction(int? value, PtzAction ptzActionFlag, Action<int> ptzAction)
|
||||
{
|
||||
if (value.HasValue)
|
||||
{
|
||||
int speed = value.GetValueOrDefault();
|
||||
// Only send if speed actually changed
|
||||
if (!ptzSpeeds.TryGetValue(ptzActionFlag, out var prevSpeed) || speed != prevSpeed)
|
||||
{
|
||||
ptzSpeeds[ptzActionFlag] = speed;
|
||||
ptzAction(speed); // Call Pan/Tilt/Zoom with raw value
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key insight:** The `ptzSpeeds` dictionary prevents flooding the SDK with duplicate commands. Only changed values are forwarded.
|
||||
|
||||
### Layer 3: Direction Resolution (IMovementControllerExtensions)
|
||||
```csharp
|
||||
public static void Pan(this IMovementController controller, int speed)
|
||||
{
|
||||
if (speed > 0) controller.PanRight(speed);
|
||||
if (speed < 0) controller.PanLeft(Math.Abs(speed));
|
||||
if (speed == 0) controller.PanStop();
|
||||
}
|
||||
// Same pattern for Tilt and Zoom
|
||||
```
|
||||
|
||||
### Layer 4: SDK Command
|
||||
```csharp
|
||||
// GeViScope example
|
||||
public void PanRight(int speed)
|
||||
{
|
||||
using var channelId = new GscMediaChannelID(mediaChannelId);
|
||||
using var action = new GscAct_PanRight(channelId, speed);
|
||||
plcWrapper()?.SendAction(action);
|
||||
}
|
||||
```
|
||||
|
||||
The speed value (0-255) is passed directly to the native SDK with no transformation.
|
||||
|
||||
## Speed Range Summary
|
||||
|
||||
```
|
||||
Joystick Position → Speed Value → SDK Parameter
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Full Left/Up → -255 → PanLeft(255) / TiltUp(255)
|
||||
Half Left/Up → ~-128 → PanLeft(128) / TiltUp(128)
|
||||
Dead Center → 0 → PanStop() / TiltStop()
|
||||
Half Right/Down → ~+128 → PanRight(128) / TiltDown(128)
|
||||
Full Right/Down → +255 → PanRight(255) / TiltDown(255)
|
||||
|
||||
Zoom twist out → -255 → ZoomOut(255)
|
||||
Zoom center → 0 → ZoomStop()
|
||||
Zoom twist in → +255 → ZoomIn(255)
|
||||
```
|
||||
|
||||
## Zoom-Proportional Speed: NOT in App Code
|
||||
|
||||
**Critical finding:** The COPILOT WPF application does NOT implement zoom-proportional pan/tilt speed adjustment. The raw joystick value (-255 to +255) passes through all layers unmodified.
|
||||
|
||||
The "slower pan/tilt when zoomed in" behavior that operators observe is provided by one or more of:
|
||||
|
||||
1. **Camera hardware** — Most PTZ cameras (Axis, Bosch, Pelco) have built-in "proportional PTZ" that adjusts mechanical speed based on current zoom level
|
||||
2. **GeViScope/G-Core server** — The video management software may apply speed scaling before sending commands to the physical camera
|
||||
3. **PTZ protocol** — Some protocols (ONVIF, Pelco-D) support proportional speed as a protocol-level feature
|
||||
|
||||
**Implication for Flutter rewrite:** The new system will get this behavior "for free" as long as it sends the same speed values (0-255) to the same server/camera infrastructure.
|
||||
|
||||
## Focus Control
|
||||
|
||||
Focus is controlled via the `+` and `-` hardware keys (not the joystick):
|
||||
|
||||
```
|
||||
Key Down (+) → FocusNear(128) (fixed speed)
|
||||
Key Down (-) → FocusFar(128) (fixed speed)
|
||||
Key Up → FocusStop()
|
||||
```
|
||||
|
||||
The `DefaultFocusSpeed` constant is **128** (half of maximum).
|
||||
|
||||
## PTZ Lock Requirement
|
||||
|
||||
PTZ commands are only sent when `IsCameraTelemetryActive == true`, which requires:
|
||||
|
||||
1. The selected camera must be `Movable` (from MediaChannel metadata)
|
||||
2. The operator must acquire a camera lock via the AppServer
|
||||
3. The lock has a 5-minute timeout with a 1-minute warning
|
||||
4. Each PTZ action resets the lock expiration timer
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Unlocked
|
||||
Unlocked --> LockRequested: Press Lock/Button2
|
||||
LockRequested --> Locked: Lock acquired
|
||||
LockRequested --> ConfirmDialog: Lock held by other operator
|
||||
ConfirmDialog --> TakeOverRequested: Confirm takeover
|
||||
TakeOverRequested --> Locked: Takeover granted
|
||||
TakeOverRequested --> Unlocked: Takeover denied
|
||||
Locked --> ExpirationWarning: 4 minutes elapsed
|
||||
ExpirationWarning --> Locked: Any PTZ action (resets timer)
|
||||
ExpirationWarning --> Unlocked: 5 minutes elapsed (auto-unlock)
|
||||
Locked --> Unlocked: Press Lock/Button2 (manual unlock)
|
||||
Locked --> Unlocked: TakenOver by higher priority
|
||||
```
|
||||
34
Docs/legacy-architecture/test-mermaid.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Mermaid Test</title>
|
||||
<style>body { background: #1e1e2e; color: #cdd6f4; font-family: sans-serif; padding: 2em; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Mermaid Rendering Test</h1>
|
||||
<p>If you see a diagram below, mermaid is working:</p>
|
||||
|
||||
<pre class="mermaid">
|
||||
graph LR
|
||||
A[Flutter App] -->|REST| B[C# Bridge]
|
||||
B -->|SDK| C[Camera Server]
|
||||
</pre>
|
||||
|
||||
<p>Sequence diagram test:</p>
|
||||
<pre class="mermaid">
|
||||
sequenceDiagram
|
||||
participant App
|
||||
participant Bridge
|
||||
participant Camera
|
||||
App->>Bridge: POST /api/ptz/pan
|
||||
Bridge->>Camera: SDK PanRight(speed)
|
||||
Camera-->>Bridge: OK
|
||||
Bridge-->>App: 200 OK
|
||||
</pre>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
<script>
|
||||
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
394
Docs/plans/2026-02-03-flutter-ui-design.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# Flutter COPILOT Keyboard UI Design
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the Flutter UI implementation based on the existing D6 application and the Klavesnice Business Analysis specification.
|
||||
|
||||
## Screen Structure
|
||||
|
||||
### 1. Main Screen (Basic View)
|
||||
|
||||
The main screen consists of three primary areas:
|
||||
|
||||
```
|
||||
+--------------------------------------------------+
|
||||
| Connection Status Bar |
|
||||
+--------------------------------------------------+
|
||||
| |
|
||||
| Video Wall Grid |
|
||||
| (Physical Monitors with Viewers) |
|
||||
| |
|
||||
+--------------------------------------------------+
|
||||
| Bottom Toolbar |
|
||||
+--------------------------------------------------+
|
||||
```
|
||||
|
||||
### 2. Video Wall Grid
|
||||
|
||||
Based on D6 screenshots, the wall is divided into **5 sections**:
|
||||
- Vrchní část (Top section) - monitors 210-234
|
||||
- Pravá část (Right section)
|
||||
- Levá část (Left section)
|
||||
- Dolní část (Bottom section)
|
||||
- Střední část (Middle section)
|
||||
|
||||
Each section contains:
|
||||
- Section header with name
|
||||
- Grid of physical monitors
|
||||
- Each physical monitor can contain 1-4 viewers (quad view)
|
||||
|
||||
#### Monitor Display States
|
||||
|
||||
| State | Visual |
|
||||
|-------|--------|
|
||||
| Normal | Dark background, white text |
|
||||
| Selected (touched) | Cyan/blue border (thick) |
|
||||
| Active Alarm | Red background |
|
||||
| Alarm + Selected | Red background + cyan border |
|
||||
| Locked by current user | Lock icon visible |
|
||||
| Locked by other user | Lock icon + disabled |
|
||||
|
||||
#### Viewer Number Display
|
||||
|
||||
- Each viewer shows its viewer number (e.g., 210, 211, 212, 213)
|
||||
- Quad view: 4 viewers in one physical monitor with visible border around physical monitor
|
||||
- Single view: One viewer fills the physical monitor
|
||||
|
||||
### 3. Bottom Toolbar (Button Strip)
|
||||
|
||||
Dynamic button strip that changes based on context:
|
||||
|
||||
#### Default State (No Selection)
|
||||
```
|
||||
[Search] [500] [501] [502] [HOME] [F1] [F2] [F3] [F4] [F5] [F6] [F7]
|
||||
```
|
||||
|
||||
#### Monitor Selected (Camera View)
|
||||
```
|
||||
[←] [PREPOS] [PvZ] [ALARM] [LOCK/UNLOCK] [SEARCH]
|
||||
```
|
||||
|
||||
Where:
|
||||
- **←** Back to default
|
||||
- **PREPOS** Open preposition list (only for PTZ cameras when unlocked)
|
||||
- **PvZ** Enter playback mode (only if keyboard has permission)
|
||||
- **ALARM** Open alarm history list
|
||||
- **LOCK/UNLOCK** Toggle PTZ lock
|
||||
- **SEARCH** Open camera search dialog
|
||||
|
||||
### 4. Camera Prefix Selection
|
||||
|
||||
Three prefix buttons for camera number input:
|
||||
- **500** - GeViScope cameras (500001-500999)
|
||||
- **501** - G-CORE cameras (501001-501999)
|
||||
- **502** - GeViServer cameras (502001-502999)
|
||||
|
||||
Selected prefix is highlighted. User then types 3-digit camera number.
|
||||
|
||||
### 5. Camera Number Input
|
||||
|
||||
```
|
||||
+----------------------------------+
|
||||
| Input Field: [500] + [ ] |
|
||||
| Current: 500201 |
|
||||
+----------------------------------+
|
||||
| [1] [2] [3] |
|
||||
| [4] [5] [6] |
|
||||
| [7] [8] [9] |
|
||||
| [C] [0] [OK] |
|
||||
+----------------------------------+
|
||||
```
|
||||
|
||||
- Number input via touchscreen or physical USB keyboard
|
||||
- C = Clear, OK = Confirm CrossSwitch
|
||||
- ESC/Back = Cancel
|
||||
|
||||
---
|
||||
|
||||
## Secondary Screens
|
||||
|
||||
### 6. Search Screen
|
||||
|
||||
Opened via Search button when monitor is selected:
|
||||
|
||||
```
|
||||
+--------------------------------------------------+
|
||||
| Search Camera [X] |
|
||||
+--------------------------------------------------+
|
||||
| Camera Number: [________] |
|
||||
| |
|
||||
| [Keyboard toggle] |
|
||||
+--------------------------------------------------+
|
||||
| Search Results: |
|
||||
| [500001 - Jindřišská, tramvaj] |
|
||||
| [500002 - Václavské náměstí] |
|
||||
| ... |
|
||||
+--------------------------------------------------+
|
||||
| [←] [OK] |
|
||||
+--------------------------------------------------+
|
||||
```
|
||||
|
||||
### 7. Preposition List Screen
|
||||
|
||||
```
|
||||
+--------------------------------------------------+
|
||||
| PREPOZICE - Kamera 500005 [X] |
|
||||
+--------------------------------------------------+
|
||||
| [2] Jindřišská, tramvajový ostrůvek |
|
||||
| [10] Jindřišská, křižovatka [●] |
|
||||
| [15] U Bulhara směr centrum |
|
||||
| ... |
|
||||
+--------------------------------------------------+
|
||||
| [←] [+] [🗑] [✓] |
|
||||
+--------------------------------------------------+
|
||||
```
|
||||
|
||||
- Blue highlight on selected preposition
|
||||
- [+] Add new preposition (disabled if AppServer unavailable)
|
||||
- [🗑] Delete preposition (disabled if editable=0 or positions 1-9)
|
||||
- [✓] Confirm/go to selected preposition
|
||||
|
||||
### 8. Add Preposition Screen
|
||||
|
||||
```
|
||||
+--------------------------------------------------+
|
||||
| Nová prepozice - Kamera 500005 [X] |
|
||||
+--------------------------------------------------+
|
||||
| Číslo prepozice: [__] (10-99 only) |
|
||||
| |
|
||||
| Název prepozice: [________________] |
|
||||
| |
|
||||
| [Keyboard] |
|
||||
+--------------------------------------------------+
|
||||
| [←] [ULOŽIT] |
|
||||
+--------------------------------------------------+
|
||||
```
|
||||
|
||||
Save button disabled until both fields filled.
|
||||
|
||||
### 9. Playback Mode (PvZ) Screen
|
||||
|
||||
Overlay controls on main view:
|
||||
|
||||
```
|
||||
+--------------------------------------------------+
|
||||
| PvZ: Kamera 500005 |
|
||||
| Čas: 2026-02-03 14:35:22 |
|
||||
+--------------------------------------------------+
|
||||
| [|◄] [◄◄] [◄] [⏸] [►] [►►] [►|] [LIVE] |
|
||||
+--------------------------------------------------+
|
||||
| Speed: [-7 ... -1] [0] [+1 ... +7] |
|
||||
+--------------------------------------------------+
|
||||
```
|
||||
|
||||
Jog-shuttle speed table:
|
||||
- Position -7 to -1: Reverse (slow to fast)
|
||||
- Position 0: Pause
|
||||
- Position +1 to +7: Forward (slow to fast)
|
||||
|
||||
### 10. Alarm List Screen
|
||||
|
||||
```
|
||||
+--------------------------------------------------+
|
||||
| Alarmy - Kamera 500005 [X] |
|
||||
+--------------------------------------------------+
|
||||
| Od: [2026-02-01] [📅] Do: [2026-02-03] [📅] |
|
||||
+--------------------------------------------------+
|
||||
| Začátek | Konec |
|
||||
| 02-03 14:30:15 | 02-03 14:32:45 [●] |
|
||||
| 02-03 12:15:30 | 02-03 12:18:22 |
|
||||
| 02-02 23:45:00 | 02-02 23:47:15 |
|
||||
+--------------------------------------------------+
|
||||
| [←] [LIVE] [⏩] [▶⏩] [◄◄] [⏸] |
|
||||
+--------------------------------------------------+
|
||||
```
|
||||
|
||||
- [⏩] Jump to timestamp (paused)
|
||||
- [▶⏩] Jump to timestamp and play
|
||||
- [◄◄] Reverse playback
|
||||
- [⏸] Stop playback
|
||||
- [LIVE] Return to live stream
|
||||
|
||||
### 11. Function Button Config (F1-F7, HOME)
|
||||
|
||||
Each function button triggers predefined wall configuration:
|
||||
- Stored on Application Server
|
||||
- Can set camera or sequence per monitor
|
||||
- Before CrossSwitch, check if sequence is running and stop it
|
||||
|
||||
### 12. Service Menu
|
||||
|
||||
Activated by holding Backspace for 3 seconds:
|
||||
|
||||
```
|
||||
+----------------------------------+
|
||||
| Servisní Menu [X] |
|
||||
+----------------------------------+
|
||||
| (1) Restartovat aplikaci |
|
||||
| (2) Restartovat klávesnici |
|
||||
| (3) Vypnout klávesnici |
|
||||
+----------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Specifications
|
||||
|
||||
### Color Palette
|
||||
|
||||
| Element | Color | Hex |
|
||||
|---------|-------|-----|
|
||||
| Background | Dark blue-gray | #1a2332 |
|
||||
| Monitor normal | Dark gray | #2d3748 |
|
||||
| Monitor selected | Cyan border | #00d4ff |
|
||||
| Monitor alarm | Red | #ff4444 |
|
||||
| Button active | Blue | #3182ce |
|
||||
| Button disabled | Gray | #4a5568 |
|
||||
| Text primary | White | #ffffff |
|
||||
| Text secondary | Gray | #a0aec0 |
|
||||
| Preposition highlight | Blue | #2b6cb0 |
|
||||
|
||||
### Typography
|
||||
|
||||
- Monitor numbers: Monospace, bold, 16-20px
|
||||
- Section headers: Sans-serif, semibold, 14px
|
||||
- Button labels: Sans-serif, medium, 12-14px
|
||||
- Input fields: Monospace, regular, 16px
|
||||
|
||||
### Touch Targets
|
||||
|
||||
- Minimum touch target: 44x44 pixels
|
||||
- Monitor tiles: Variable (based on grid)
|
||||
- Toolbar buttons: 48px height
|
||||
- List items: 48px height minimum
|
||||
|
||||
---
|
||||
|
||||
## State Management (BLoC)
|
||||
|
||||
### Required BLoCs
|
||||
|
||||
1. **ConnectionBloc** - Server connection states
|
||||
2. **WallBloc** - Video wall state, monitor selection
|
||||
3. **AlarmBloc** - Active alarms, alarm history
|
||||
4. **CameraBloc** - Camera input, CrossSwitch operations
|
||||
5. **PTZBloc** - Lock state, telemetry controls
|
||||
6. **PlaybackBloc** - PvZ mode, jog-shuttle
|
||||
7. **PrepositionBloc** - Preposition list, add/delete
|
||||
8. **FunctionButtonBloc** - Function button configurations
|
||||
9. **SequenceBloc** - Sequence state per monitor
|
||||
|
||||
### Events & States Example (WallBloc)
|
||||
|
||||
```dart
|
||||
// Events
|
||||
abstract class WallEvent {}
|
||||
class LoadWallConfig extends WallEvent {}
|
||||
class SelectMonitor extends WallEvent { final int viewerId; }
|
||||
class DeselectMonitor extends WallEvent {}
|
||||
class CrossSwitchCamera extends WallEvent { final int cameraId; final int viewerId; }
|
||||
|
||||
// States
|
||||
abstract class WallState {}
|
||||
class WallLoading extends WallState {}
|
||||
class WallLoaded extends WallState {
|
||||
final List<WallSection> sections;
|
||||
final int? selectedViewerId;
|
||||
final int? selectedPhysicalMonitorId;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Core UI (MVP)
|
||||
1. Main screen layout with wall grid
|
||||
2. Monitor selection (touch)
|
||||
3. Camera number input (prefix + digits)
|
||||
4. CrossSwitch command
|
||||
5. Connection status bar
|
||||
|
||||
### Phase 2: Alarms & Status
|
||||
1. Alarm state display (red monitors)
|
||||
2. Alarm blocking (prevent CrossSwitch on active alarm)
|
||||
3. Monitor lock indicators
|
||||
|
||||
### Phase 3: PTZ & Playback
|
||||
1. PTZ lock/unlock
|
||||
2. Telemetry controls (pan/tilt/zoom)
|
||||
3. Playback mode (PvZ)
|
||||
4. Jog-shuttle controls
|
||||
|
||||
### Phase 4: Advanced Features
|
||||
1. Preposition management
|
||||
2. Alarm history list
|
||||
3. Function buttons (F1-F7, HOME)
|
||||
4. Sequences
|
||||
5. Search functionality
|
||||
|
||||
### Phase 5: Polish
|
||||
1. Service menu
|
||||
2. CAMEA integration
|
||||
3. Autonomous mode fallbacks
|
||||
4. Error handling dialogs
|
||||
|
||||
---
|
||||
|
||||
## Autonomous Mode Behavior
|
||||
|
||||
When Application Server is unavailable:
|
||||
|
||||
| Feature | Available | Notes |
|
||||
|---------|-----------|-------|
|
||||
| CrossSwitch | ✓ | Direct to bridge |
|
||||
| PTZ Lock | ✓ | Local lock only |
|
||||
| CAMEA Reserve | ✓ | Direct to CAMEA |
|
||||
| Preposition List | ✓ | Cached config |
|
||||
| Add Preposition | ✗ | Requires AppServer |
|
||||
| Delete Preposition | ✗ | Requires AppServer |
|
||||
| Sequences | ✗ | Run by AppServer |
|
||||
| Function Buttons | ✓ | Cached config |
|
||||
| Alarm Management | ✗ | Run by GeViSoft |
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── presentation/
|
||||
│ ├── screens/
|
||||
│ │ ├── main_screen.dart
|
||||
│ │ ├── search_screen.dart
|
||||
│ │ ├── preposition_screen.dart
|
||||
│ │ ├── alarm_list_screen.dart
|
||||
│ │ ├── playback_overlay.dart
|
||||
│ │ └── service_menu_dialog.dart
|
||||
│ ├── widgets/
|
||||
│ │ ├── wall_grid/
|
||||
│ │ │ ├── wall_grid.dart
|
||||
│ │ │ ├── wall_section.dart
|
||||
│ │ │ ├── physical_monitor.dart
|
||||
│ │ │ └── viewer_tile.dart
|
||||
│ │ ├── toolbar/
|
||||
│ │ │ ├── bottom_toolbar.dart
|
||||
│ │ │ ├── prefix_buttons.dart
|
||||
│ │ │ ├── function_buttons.dart
|
||||
│ │ │ └── action_buttons.dart
|
||||
│ │ ├── input/
|
||||
│ │ │ ├── camera_input.dart
|
||||
│ │ │ ├── numeric_keypad.dart
|
||||
│ │ │ └── datetime_picker.dart
|
||||
│ │ └── common/
|
||||
│ │ ├── connection_status_bar.dart
|
||||
│ │ └── confirmation_dialog.dart
|
||||
│ └── blocs/
|
||||
│ ├── wall/
|
||||
│ ├── alarm/
|
||||
│ ├── camera/
|
||||
│ ├── ptz/
|
||||
│ ├── playback/
|
||||
│ ├── preposition/
|
||||
│ └── function_button/
|
||||
```
|
||||
235
Docs/plans/PHASE_0_INFRASTRUCTURE.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Phase 0: Infrastructure Implementation Plan
|
||||
|
||||
**Status:** In Progress
|
||||
**Duration:** Week 1-2
|
||||
**Goal:** Finalize bridges, add event subscriptions, test direct commands, document configurations
|
||||
|
||||
---
|
||||
|
||||
## Current State Assessment
|
||||
|
||||
### Bridge Readiness
|
||||
|
||||
| Bridge | Port | Status | PLC Events | ViewerConnectLive | PTZ | Playback |
|
||||
|--------|------|--------|------------|-------------------|-----|----------|
|
||||
| GeViScope | 7720 | ✅ Ready | ✅ | ✅ | ✅ | ✅ |
|
||||
| G-Core | 7721 | ✅ Ready | ✅ | ✅ | ✅ | ✅ |
|
||||
| GeViServer | 7710 | ⚠️ Minimal | ⚠️ Events only | ❌ | ❌ | ❌ |
|
||||
|
||||
**Decision:** GeViServer Bridge remains minimal per design ("Minimal GeViSoft usage").
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 Tasks
|
||||
|
||||
### Task 1: Event Notification Forwarding ✅ COMPLETED
|
||||
**Priority:** HIGH
|
||||
**Effort:** 4-6 hours
|
||||
**Status:** Done (2026-02-03)
|
||||
|
||||
The bridges have PLC subscriptions but notifications are only logged. For the new architecture, events must be forwarded to the Flutter app.
|
||||
|
||||
**Sub-tasks:**
|
||||
1. [x] Add WebSocket endpoint to GeViScope Bridge for event streaming (`ws://localhost:7720/ws/events`)
|
||||
2. [x] Add WebSocket endpoint to G-Core Bridge for event streaming (`ws://localhost:7721/ws/events`)
|
||||
3. [x] Define event message format (JSON)
|
||||
4. [x] Forward these events:
|
||||
- `ViewerConnected(Viewer, Channel, PlayMode)`
|
||||
- `ViewerCleared(Viewer)`
|
||||
- `ViewerSelectionChanged(Viewer, Channel, PlayMode)`
|
||||
- `EventStarted(EventID, TypeID, ForeignKey)`
|
||||
- `EventStopped(EventID, TypeID)`
|
||||
- `VCAlarmQueueNotification(Viewer, Notification, AlarmID, TypeID)`
|
||||
- `DigitalInput(Contact, State)`
|
||||
- `ConnectionLost` (bonus)
|
||||
|
||||
**Event Message Format:**
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-02-03T10:30:00.123Z",
|
||||
"server": "GeViScope-01",
|
||||
"action": "ViewerConnected",
|
||||
"params": {
|
||||
"Viewer": 5,
|
||||
"Channel": 101,
|
||||
"PlayMode": 11
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Alarm Query Endpoints ✅ COMPLETED (Event-Based)
|
||||
**Priority:** HIGH
|
||||
**Effort:** 3-4 hours
|
||||
**Status:** Done (2026-02-03)
|
||||
|
||||
Add alarm query capability to bridges (critical for "never miss alarms" requirement).
|
||||
|
||||
**Finding:** GeViScope/G-Core SDKs don't have direct alarm query APIs like GeViSoft. They use event-based tracking.
|
||||
|
||||
**Implementation:**
|
||||
- [x] Research GeViScope SDK - uses EventStarted/EventStopped notifications (no query API)
|
||||
- [x] Research G-Core SDK - uses EventStarted/EventStopped notifications (no query API)
|
||||
- [x] Implement `GET /alarms/active` - returns alarms tracked from events
|
||||
- [x] Implement `GET /alarms` - returns all tracked alarms (active + stopped)
|
||||
- [x] Track alarm state from EventStarted/EventStopped notifications
|
||||
|
||||
**Important:** For complete alarm state on startup (before any events received), the Flutter app should query GeViServer bridge which has `GeViSQ_GetFirstAlarm/GetNextAlarm` methods.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Monitor State Query ✅ COMPLETED (Event-Based)
|
||||
**Priority:** MEDIUM
|
||||
**Effort:** 2-3 hours
|
||||
**Status:** Done (2026-02-03)
|
||||
|
||||
Add ability to query current monitor state on startup.
|
||||
|
||||
**Finding:** GeViScope/G-Core SDKs use ViewerConnected/ViewerCleared events for state changes. GetFirstVideoOutput is GeViSoft SDK only.
|
||||
|
||||
**Implementation:**
|
||||
- [x] Research GeViScope SDK - uses ViewerConnected/ViewerCleared/ViewerSelectionChanged events
|
||||
- [x] Research G-Core SDK - same event-based approach
|
||||
- [x] Implement `GET /monitors` - returns all monitored states tracked from events
|
||||
- [x] Implement `GET /monitors/{viewerId}` - returns specific monitor state
|
||||
- [x] Track monitor state from ViewerConnected/ViewerCleared events
|
||||
|
||||
**Important:** For complete monitor state on startup (before any events received), the Flutter app should either:
|
||||
1. Trigger a ViewerConnectLive to each known monitor (will fire ViewerConnected event)
|
||||
2. Query GeViServer bridge if GeViSoft integration is acceptable
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Server Configuration Documentation ✅ COMPLETED
|
||||
**Priority:** MEDIUM
|
||||
**Effort:** 2-3 hours
|
||||
**Status:** Done (2026-02-03)
|
||||
|
||||
Document server configurations for deployment.
|
||||
|
||||
**Sub-tasks:**
|
||||
1. [x] Create `servers.json` template with all server definitions
|
||||
2. [x] Document camera ID ranges per server
|
||||
3. [x] Document monitor ID mappings
|
||||
4. [x] Create `crossswitch-rules.json` template
|
||||
5. [x] Create `keyboards.json` template
|
||||
|
||||
**Files Created:**
|
||||
- `C:\DEV\COPILOT_D6\config\servers.json.template`
|
||||
- `C:\DEV\COPILOT_D6\config\crossswitch-rules.json.template`
|
||||
- `C:\DEV\COPILOT_D6\config\keyboards.json.template`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Bridge Health Checks ✅ COMPLETED
|
||||
**Priority:** MEDIUM
|
||||
**Effort:** 1-2 hours
|
||||
**Status:** Done (2026-02-03)
|
||||
|
||||
Enhance health check endpoints for production monitoring.
|
||||
|
||||
**Sub-tasks:**
|
||||
1. [x] Add detailed status to `/status` endpoint:
|
||||
- `is_connected` (bool)
|
||||
- `address` (server address)
|
||||
- `connection_duration_sec`
|
||||
- `event_count` (since connection)
|
||||
- `websocket_clients` (connected WS clients)
|
||||
- `plc_active` (bool)
|
||||
2. [x] Add `/health` endpoint for load balancer probes
|
||||
3. [ ] Add connection auto-reconnect logic (deferred to Phase 1)
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Integration Testing ⬜ IN PROGRESS
|
||||
**Priority:** HIGH
|
||||
**Effort:** 4-6 hours
|
||||
|
||||
Test bridges against actual servers.
|
||||
|
||||
**Sub-tasks:**
|
||||
1. [ ] Test GeViScope Bridge against real GeViScope server
|
||||
- [ ] Connection/disconnection
|
||||
- [ ] ViewerConnectLive
|
||||
- [ ] PTZ control
|
||||
- [ ] Event reception via WebSocket
|
||||
2. [ ] Test G-Core Bridge against real G-Core server
|
||||
- [ ] Same tests as above
|
||||
3. [x] Create test scripts (HTTP files)
|
||||
- `C:\DEV\COPILOT_D6\tests\test-geviscope.http`
|
||||
- `C:\DEV\COPILOT_D6\tests\test-gcore.http`
|
||||
4. [ ] Document any SDK issues found
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Logging Standardization ⬜
|
||||
**Priority:** LOW
|
||||
**Effort:** 2 hours
|
||||
|
||||
Standardize logging format for Kibana integration.
|
||||
|
||||
**Sub-tasks:**
|
||||
1. [ ] Configure Serilog with JSON formatter
|
||||
2. [ ] Add structured logging fields:
|
||||
- keyboard_id
|
||||
- server_id
|
||||
- command
|
||||
- duration_ms
|
||||
- success
|
||||
3. [ ] Configure log rotation
|
||||
4. [ ] Test log output format
|
||||
|
||||
---
|
||||
|
||||
## File Structure After Phase 0
|
||||
|
||||
```
|
||||
C:\DEV\COPILOT_D6\
|
||||
├── docs\
|
||||
│ ├── NEW_SYSTEM_DESIGN_SUMMARY.md
|
||||
│ ├── IMPLEMENTATION_QUICK_REFERENCE.md
|
||||
│ └── plans\
|
||||
│ └── PHASE_0_INFRASTRUCTURE.md
|
||||
├── config\
|
||||
│ ├── servers.json.template ✅ Created
|
||||
│ ├── crossswitch-rules.json.template ✅ Created
|
||||
│ └── keyboards.json.template ✅ Created
|
||||
└── tests\
|
||||
├── test-geviscope.http ✅ Created
|
||||
└── test-gcore.http ✅ Created
|
||||
|
||||
Bridges (existing, enhanced):
|
||||
├── C:\DEV\COPILOT\geviscope-bridge\ ✅ WebSocket added
|
||||
└── C:\DEV\COPILOT\gcore-bridge\ ✅ WebSocket added
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] Events are streamed via WebSocket from both bridges
|
||||
- [x] Alarm state can be queried on demand (event-based tracking + `/alarms/active` endpoint)
|
||||
- [x] Monitor state can be queried on demand (event-based tracking + `/monitors` endpoint)
|
||||
- [x] Configuration templates are documented
|
||||
- [ ] All bridge endpoints tested against real servers
|
||||
- [ ] Logging format matches ELK requirements (Task 7 - pending, low priority)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Access to GeViScope server for testing
|
||||
- Access to G-Core server for testing
|
||||
- GeViScope SDK documentation (chunk files available)
|
||||
- G-Core SDK documentation (chunk files available)
|
||||
|
||||
---
|
||||
|
||||
## Next Phase
|
||||
|
||||
After Phase 0, proceed to **Phase 1: Flutter Keyboard Core**
|
||||
- Keyboard layout UI
|
||||
- Direct command execution
|
||||
- Server routing logic
|
||||
- State notification subscription
|
||||
382
Docs/plans/PHASE_1_FLUTTER_KEYBOARD.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# Phase 1: Flutter Keyboard Core Implementation Plan
|
||||
|
||||
**Status:** In Progress
|
||||
**Duration:** Week 3-5
|
||||
**Goal:** Build the core Flutter keyboard app with direct command execution and state tracking
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Flutter Keyboard App │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ UI Layer │ │ BLoC Layer │ │ Data Layer │ │
|
||||
│ │ (Screens) │◄─┤ (State) │◄─┤ (Services) │ │
|
||||
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────────────────────────┼─────────────────┐ │
|
||||
│ │ Service Layer │ │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌───┴───────┐ │ │
|
||||
│ │ │BridgeService│ │ StateService│ │AlarmService│ │ │
|
||||
│ │ │ (HTTP+WS) │ │ (Monitor+ │ │(Query+Track)│ │ │
|
||||
│ │ └──────┬──────┘ │ Alarm) │ └─────┬─────┘ │ │
|
||||
│ └─────────┼─────────┴─────────────┴────────┼──────────────┘ │
|
||||
└────────────┼────────────────────────────────┼──────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌────────────────┐
|
||||
│ GeViScope/GCore│ │ GeViServer │
|
||||
│ Bridges │ │ Bridge │
|
||||
│ (7720/7721) │ │ (7710) │
|
||||
└────────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 Tasks
|
||||
|
||||
### Task 1.1: Project Setup ✅ COMPLETED
|
||||
**Priority:** HIGH
|
||||
|
||||
Create new Flutter project with proper structure.
|
||||
|
||||
**Sub-tasks:**
|
||||
- [x] Create Flutter project: `copilot_keyboard`
|
||||
- [x] Set up directory structure (clean architecture)
|
||||
- [x] Add dependencies (BLoC, dio, web_socket_channel, etc.)
|
||||
- [x] Configure for Windows desktop target
|
||||
- [x] Create base config loading from `servers.json`
|
||||
|
||||
**Directory Structure:**
|
||||
```
|
||||
copilot_keyboard/
|
||||
├── lib/
|
||||
│ ├── main.dart
|
||||
│ ├── app.dart
|
||||
│ ├── config/
|
||||
│ │ ├── app_config.dart
|
||||
│ │ └── server_config.dart
|
||||
│ ├── core/
|
||||
│ │ ├── constants/
|
||||
│ │ ├── errors/
|
||||
│ │ └── utils/
|
||||
│ ├── data/
|
||||
│ │ ├── models/
|
||||
│ │ ├── repositories/
|
||||
│ │ └── services/
|
||||
│ ├── domain/
|
||||
│ │ ├── entities/
|
||||
│ │ ├── repositories/
|
||||
│ │ └── usecases/
|
||||
│ └── presentation/
|
||||
│ ├── blocs/
|
||||
│ ├── screens/
|
||||
│ └── widgets/
|
||||
├── assets/
|
||||
│ └── config/
|
||||
├── test/
|
||||
└── pubspec.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.2: Bridge Service ✅ COMPLETED
|
||||
**Priority:** HIGH
|
||||
|
||||
Create service to communicate with all bridges.
|
||||
|
||||
**Sub-tasks:**
|
||||
- [ ] Create `BridgeService` class
|
||||
- [ ] Implement HTTP client for REST calls
|
||||
- [ ] Implement WebSocket client for event streaming
|
||||
- [ ] Add connection management (connect/disconnect/reconnect)
|
||||
- [ ] Route commands to correct bridge based on camera/monitor ID
|
||||
|
||||
**Key Methods:**
|
||||
```dart
|
||||
class BridgeService {
|
||||
// Connection
|
||||
Future<void> connect(ServerConfig server);
|
||||
Future<void> disconnect(String serverId);
|
||||
|
||||
// Commands (routed to correct bridge)
|
||||
Future<void> viewerConnectLive(int viewer, int channel);
|
||||
Future<void> viewerClear(int viewer);
|
||||
Future<void> ptzPan(int camera, String direction, int speed);
|
||||
Future<void> ptzTilt(int camera, String direction, int speed);
|
||||
Future<void> ptzZoom(int camera, String direction, int speed);
|
||||
Future<void> ptzStop(int camera);
|
||||
Future<void> ptzPreset(int camera, int preset);
|
||||
|
||||
// Event stream
|
||||
Stream<BridgeEvent> get eventStream;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.3: State Service ✅ COMPLETED
|
||||
**Priority:** HIGH
|
||||
|
||||
Track monitor and alarm state from events.
|
||||
|
||||
**Sub-tasks:**
|
||||
- [ ] Create `StateService` class
|
||||
- [ ] Subscribe to bridge WebSocket events
|
||||
- [ ] Track monitor states (viewer → camera mapping)
|
||||
- [ ] Track alarm states (active alarms)
|
||||
- [ ] Provide state streams for UI
|
||||
|
||||
**Key Methods:**
|
||||
```dart
|
||||
class StateService {
|
||||
// Monitor state
|
||||
Stream<Map<int, MonitorState>> get monitorStates;
|
||||
MonitorState? getMonitorState(int viewerId);
|
||||
|
||||
// Alarm state
|
||||
Stream<List<AlarmState>> get activeAlarms;
|
||||
bool isMonitorBlocked(int viewerId);
|
||||
|
||||
// Sync
|
||||
Future<void> syncFromBridges();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.4: Alarm Service (GeViServer Query) ✅ COMPLETED
|
||||
**Priority:** HIGH
|
||||
|
||||
Query initial alarm state from GeViServer on startup.
|
||||
|
||||
**Sub-tasks:**
|
||||
- [ ] Create `AlarmService` class
|
||||
- [ ] Implement GeViServer bridge connection
|
||||
- [ ] Query active alarms on startup using GetFirstAlarm/GetNextAlarm pattern
|
||||
- [ ] Merge with event-based alarm tracking
|
||||
- [ ] Periodic sync (every 30 seconds)
|
||||
|
||||
**Key Methods:**
|
||||
```dart
|
||||
class AlarmService {
|
||||
Future<List<AlarmInfo>> queryAllAlarms();
|
||||
Future<void> startPeriodicSync(Duration interval);
|
||||
void stopPeriodicSync();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5: Keyboard Layout UI ✅ COMPLETED
|
||||
**Priority:** HIGH
|
||||
|
||||
Build the main keyboard interface.
|
||||
|
||||
**Sub-tasks:**
|
||||
- [ ] Create main keyboard screen layout
|
||||
- [ ] Camera selection grid (numbered buttons)
|
||||
- [ ] Monitor selection grid (numbered buttons)
|
||||
- [ ] PTZ control panel (joystick or directional buttons)
|
||||
- [ ] Preset buttons
|
||||
- [ ] Status display (current camera on selected monitor)
|
||||
- [ ] Alarm indicator panel
|
||||
|
||||
**UI Components:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ COPILOT Keyboard [Status: Online] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ CAMERAS │ │ MONITORS │ │
|
||||
│ │ [1] [2] [3] [4] [5] │ │ [1] [2] [3] [4] │ │
|
||||
│ │ [6] [7] [8] [9] [10] │ │ [5] [6] [7] [8] │ │
|
||||
│ │ ... │ │ [9!][10][11][12] │ │
|
||||
│ └─────────────────────────┘ └─────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ PTZ CONTROL │ │ PRESETS │ │
|
||||
│ │ [▲] │ │ [1] [2] [3] [4] │ │
|
||||
│ │ [◄][●][►] │ │ [5] [6] [7] [8] │ │
|
||||
│ │ [▼] │ │ │ │
|
||||
│ │ [Z-] [Z+] │ │ │ │
|
||||
│ └─────────────────────────┘ └─────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ ACTIVE ALARMS │ │
|
||||
│ │ [!] Camera 5 - Motion Detected (10:30:15) │ │
|
||||
│ │ [!] Camera 12 - Door Contact (10:28:42) │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.6: BLoC Implementation ✅ COMPLETED
|
||||
**Priority:** HIGH
|
||||
|
||||
Implement state management with BLoC pattern.
|
||||
|
||||
**BLoCs to Create:**
|
||||
- [x] `ConnectionBloc` - Bridge connection state
|
||||
- [x] `CameraBloc` - Camera selection and routing
|
||||
- [x] `MonitorBloc` - Monitor state and selection
|
||||
- [x] `PtzBloc` - PTZ control state
|
||||
- [x] `AlarmBloc` - Alarm state and display
|
||||
|
||||
---
|
||||
|
||||
### Task 1.7: Server Routing Logic ✅ COMPLETED
|
||||
**Priority:** HIGH
|
||||
|
||||
Route commands to correct bridge based on camera/monitor ranges.
|
||||
|
||||
**Sub-tasks:**
|
||||
- [ ] Load server config from `servers.json`
|
||||
- [ ] Implement camera-to-server mapping
|
||||
- [ ] Implement monitor-to-server mapping
|
||||
- [ ] Handle cross-server scenarios (camera on server A → monitor on server B)
|
||||
|
||||
**Routing Rules:**
|
||||
```dart
|
||||
class ServerRouter {
|
||||
// Find which server owns a camera
|
||||
ServerConfig? getServerForCamera(int cameraId);
|
||||
|
||||
// Find which server owns a monitor
|
||||
ServerConfig? getServerForMonitor(int monitorId);
|
||||
|
||||
// Get bridge URL for a server
|
||||
String getBridgeUrl(String serverId);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.8: Error Handling ✅ COMPLETED
|
||||
**Priority:** MEDIUM
|
||||
|
||||
Implement basic error handling and recovery.
|
||||
|
||||
**Sub-tasks:**
|
||||
- [ ] Connection error handling with retry
|
||||
- [ ] Command timeout handling
|
||||
- [ ] Offline/degraded mode detection
|
||||
- [ ] User-friendly error messages
|
||||
- [ ] Logging for debugging
|
||||
|
||||
---
|
||||
|
||||
## Dependencies (pubspec.yaml)
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# State Management
|
||||
flutter_bloc: ^8.1.6
|
||||
equatable: ^2.0.5
|
||||
|
||||
# Networking
|
||||
dio: ^5.7.0
|
||||
web_socket_channel: ^3.0.1
|
||||
|
||||
# Local Storage
|
||||
shared_preferences: ^2.3.3
|
||||
|
||||
# Routing
|
||||
go_router: ^14.6.2
|
||||
|
||||
# Dependency Injection
|
||||
get_it: ^8.0.2
|
||||
|
||||
# Utilities
|
||||
json_annotation: ^4.9.0
|
||||
rxdart: ^0.28.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
build_runner: ^2.4.13
|
||||
json_serializable: ^6.8.0
|
||||
bloc_test: ^9.1.7
|
||||
mocktail: ^1.0.4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] App connects to all configured bridges on startup
|
||||
- [ ] Camera button switches camera to selected monitor
|
||||
- [ ] PTZ controls move the selected camera
|
||||
- [ ] Monitor states update in real-time from WebSocket events
|
||||
- [ ] Alarm states query from GeViServer on startup
|
||||
- [ ] Alarms update in real-time from events
|
||||
- [ ] Monitors with active alarms show visual indicator
|
||||
- [ ] App works in degraded mode if bridges unavailable
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
```
|
||||
copilot_keyboard/
|
||||
├── lib/
|
||||
│ ├── main.dart
|
||||
│ ├── app.dart
|
||||
│ ├── injection_container.dart
|
||||
│ ├── config/
|
||||
│ │ ├── app_config.dart
|
||||
│ │ └── server_config.dart
|
||||
│ ├── core/
|
||||
│ │ ├── constants/
|
||||
│ │ │ └── api_constants.dart
|
||||
│ │ ├── errors/
|
||||
│ │ │ └── failures.dart
|
||||
│ │ └── utils/
|
||||
│ │ └── logger.dart
|
||||
│ ├── data/
|
||||
│ │ ├── models/
|
||||
│ │ │ ├── monitor_state_model.dart
|
||||
│ │ │ ├── alarm_state_model.dart
|
||||
│ │ │ └── bridge_event_model.dart
|
||||
│ │ └── services/
|
||||
│ │ ├── bridge_service.dart
|
||||
│ │ ├── state_service.dart
|
||||
│ │ └── alarm_service.dart
|
||||
│ ├── domain/
|
||||
│ │ └── entities/
|
||||
│ │ ├── monitor_state.dart
|
||||
│ │ ├── alarm_state.dart
|
||||
│ │ └── server_config.dart
|
||||
│ └── presentation/
|
||||
│ ├── blocs/
|
||||
│ │ ├── connection/
|
||||
│ │ ├── camera/
|
||||
│ │ ├── monitor/
|
||||
│ │ ├── ptz/
|
||||
│ │ └── alarm/
|
||||
│ ├── screens/
|
||||
│ │ └── keyboard_screen.dart
|
||||
│ └── widgets/
|
||||
│ ├── camera_grid.dart
|
||||
│ ├── monitor_grid.dart
|
||||
│ ├── ptz_control.dart
|
||||
│ ├── preset_buttons.dart
|
||||
│ └── alarm_panel.dart
|
||||
└── pubspec.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Phase Dependencies
|
||||
|
||||
Phase 1 creates the foundation for:
|
||||
- **Phase 2:** Coordination layer (PRIMARY election, PTZ locks)
|
||||
- **Phase 3:** Advanced features (sequences, CrossSwitch rules)
|
||||
BIN
Docs/screenshots/d6_app_20260203_133448.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
Docs/screenshots/d6_app_20260203_133612.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
Docs/screenshots/d6_app_20260203_134150.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
Docs/screenshots/d6_app_max_20260203_133713.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
Docs/screenshots/d6_app_max_20260203_134226.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
Docs/screenshots/d6_app_max_20260203_134918.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
Docs/screenshots/d6_app_max_20260203_135619.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
Docs/screenshots/d6_app_max_20260203_140211.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
Docs/screenshots/flutter_overview_final.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
Docs/screenshots/flutter_section_final.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
Docs/screenshots/flutter_selected_final.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
68
config/crossswitch-rules.json.template
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"$schema": "./crossswitch-rules.schema.json",
|
||||
"version": "1.0",
|
||||
"description": "CrossSwitch rules for logical camera/monitor mapping",
|
||||
|
||||
"rules": [
|
||||
{
|
||||
"id": "rule-001",
|
||||
"name": "Direct Camera to Monitor",
|
||||
"description": "Default rule - map camera directly to monitor on same server",
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"conditions": {
|
||||
"cameraRange": [1, 200],
|
||||
"monitorRange": [1, 32]
|
||||
},
|
||||
"action": {
|
||||
"type": "viewerConnectLive",
|
||||
"routing": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rule-002",
|
||||
"name": "Alarm Monitor Override",
|
||||
"description": "Prevent switching on monitors with active alarms",
|
||||
"enabled": true,
|
||||
"priority": 10,
|
||||
"conditions": {
|
||||
"monitorHasAlarm": true,
|
||||
"alarmStates": ["vasNewAlarm", "vasPresented"]
|
||||
},
|
||||
"action": {
|
||||
"type": "deny",
|
||||
"reason": "Monitor has active alarm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rule-003",
|
||||
"name": "Cross-Server Camera to Monitor",
|
||||
"description": "Allow camera from one server to display on monitor of another",
|
||||
"enabled": true,
|
||||
"priority": 50,
|
||||
"conditions": {
|
||||
"crossServer": true
|
||||
},
|
||||
"action": {
|
||||
"type": "viewerConnectLive",
|
||||
"routing": "monitorServer",
|
||||
"note": "Command goes to monitor's server, not camera's server"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
"defaults": {
|
||||
"playMode": "live",
|
||||
"switchTimeout": 5000,
|
||||
"verifySwitch": true,
|
||||
"verifyTimeout": 500
|
||||
},
|
||||
|
||||
"alarmMonitorReservation": {
|
||||
"enabled": true,
|
||||
"description": "When alarm fires, reserve monitor and switch to alarm camera",
|
||||
"reservationStates": ["vasNewAlarm", "vasPresented"],
|
||||
"autoRelease": false,
|
||||
"releaseOnConfirm": true
|
||||
}
|
||||
}
|
||||
69
config/keyboards.json.template
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"$schema": "./keyboards.schema.json",
|
||||
"version": "1.0",
|
||||
"description": "Keyboard configuration for PRIMARY/STANDBY election",
|
||||
|
||||
"keyboards": [
|
||||
{
|
||||
"id": "KB-001",
|
||||
"name": "Control Room 1",
|
||||
"location": "Main Control Room",
|
||||
"priority": 100,
|
||||
"canBePrimary": true,
|
||||
"canBeStandby": true,
|
||||
"network": {
|
||||
"address": "192.168.1.101",
|
||||
"coordinationPort": 8090
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "KB-002",
|
||||
"name": "Control Room 2",
|
||||
"location": "Main Control Room",
|
||||
"priority": 90,
|
||||
"canBePrimary": true,
|
||||
"canBeStandby": true,
|
||||
"network": {
|
||||
"address": "192.168.1.102",
|
||||
"coordinationPort": 8090
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "KB-003",
|
||||
"name": "Security Station",
|
||||
"location": "Security Office",
|
||||
"priority": 50,
|
||||
"canBePrimary": false,
|
||||
"canBeStandby": true,
|
||||
"network": {
|
||||
"address": "192.168.1.103",
|
||||
"coordinationPort": 8090
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
"election": {
|
||||
"heartbeatIntervalMs": 2000,
|
||||
"heartbeatTimeoutMs": 6000,
|
||||
"electionTimeoutMs": 10000,
|
||||
"minKeyboardsForQuorum": 1
|
||||
},
|
||||
|
||||
"coordination": {
|
||||
"primaryWebSocketPort": 8090,
|
||||
"stateSync": {
|
||||
"enabled": true,
|
||||
"intervalMs": 1000
|
||||
}
|
||||
},
|
||||
|
||||
"degradedMode": {
|
||||
"description": "Features available when no PRIMARY/STANDBY is online",
|
||||
"viewerConnectLive": true,
|
||||
"ptzControl": true,
|
||||
"ptzLocking": false,
|
||||
"sequences": false,
|
||||
"sharedState": false,
|
||||
"alarmDisplay": "local"
|
||||
}
|
||||
}
|
||||
104
config/servers.json.template
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"$schema": "./servers.schema.json",
|
||||
"version": "1.0",
|
||||
"description": "Server configuration for COPILOT keyboard system",
|
||||
|
||||
"servers": [
|
||||
{
|
||||
"id": "geviscope-01",
|
||||
"name": "GeViScope Server 1",
|
||||
"type": "geviscope",
|
||||
"enabled": true,
|
||||
"connection": {
|
||||
"address": "192.168.1.10",
|
||||
"port": 7700,
|
||||
"username": "operator",
|
||||
"password": "${GEVISCOPE_01_PASSWORD}"
|
||||
},
|
||||
"bridge": {
|
||||
"url": "http://localhost:7720",
|
||||
"websocket": "ws://localhost:7720/ws/events"
|
||||
},
|
||||
"resources": {
|
||||
"cameraRange": { "start": 1, "end": 100 },
|
||||
"monitorRange": { "start": 1, "end": 16 }
|
||||
},
|
||||
"features": {
|
||||
"ptz": true,
|
||||
"playback": true,
|
||||
"alarms": true,
|
||||
"digitalIO": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gcore-01",
|
||||
"name": "G-Core Server 1",
|
||||
"type": "gcore",
|
||||
"enabled": true,
|
||||
"connection": {
|
||||
"address": "192.168.1.20",
|
||||
"port": 7700,
|
||||
"username": "operator",
|
||||
"password": "${GCORE_01_PASSWORD}"
|
||||
},
|
||||
"bridge": {
|
||||
"url": "http://localhost:7721",
|
||||
"websocket": "ws://localhost:7721/ws/events"
|
||||
},
|
||||
"resources": {
|
||||
"cameraRange": { "start": 101, "end": 200 },
|
||||
"monitorRange": { "start": 17, "end": 32 }
|
||||
},
|
||||
"features": {
|
||||
"ptz": true,
|
||||
"playback": true,
|
||||
"alarms": true,
|
||||
"digitalIO": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "geviserver-01",
|
||||
"name": "GeViServer (PTZ Fallback)",
|
||||
"type": "geviserver",
|
||||
"enabled": false,
|
||||
"connection": {
|
||||
"address": "192.168.1.5",
|
||||
"port": 7700,
|
||||
"username": "operator",
|
||||
"password": "${GEVISERVER_01_PASSWORD}"
|
||||
},
|
||||
"bridge": {
|
||||
"url": "http://localhost:7710"
|
||||
},
|
||||
"resources": {
|
||||
"cameraRange": { "start": 1, "end": 200 },
|
||||
"monitorRange": { "start": 1, "end": 32 }
|
||||
},
|
||||
"features": {
|
||||
"ptz": true,
|
||||
"playback": false,
|
||||
"alarms": false,
|
||||
"digitalIO": false
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
"routing": {
|
||||
"description": "Rules for routing commands to correct server based on camera/monitor ID",
|
||||
"cameraToServer": [
|
||||
{ "range": [1, 100], "serverId": "geviscope-01" },
|
||||
{ "range": [101, 200], "serverId": "gcore-01" }
|
||||
],
|
||||
"monitorToServer": [
|
||||
{ "range": [1, 16], "serverId": "geviscope-01" },
|
||||
{ "range": [17, 32], "serverId": "gcore-01" }
|
||||
]
|
||||
},
|
||||
|
||||
"healthCheck": {
|
||||
"enabled": true,
|
||||
"intervalMs": 30000,
|
||||
"timeoutMs": 5000,
|
||||
"unhealthyThreshold": 3
|
||||
}
|
||||
}
|
||||
45
copilot_keyboard/.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
16
copilot_keyboard/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# copilot_keyboard
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
28
copilot_keyboard/analysis_options.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
66
copilot_keyboard/assets/config/servers.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"servers": [
|
||||
{
|
||||
"id": "geviscope1",
|
||||
"name": "GeViScope Local",
|
||||
"type": "geviscope",
|
||||
"enabled": true,
|
||||
"connection": {
|
||||
"address": "localhost:12003",
|
||||
"port": 12003,
|
||||
"username": "sysadmin"
|
||||
},
|
||||
"bridge": {
|
||||
"url": "http://localhost:7720",
|
||||
"websocket": "ws://localhost:7720/ws/events"
|
||||
},
|
||||
"resources": {
|
||||
"cameraRange": {"start": 1, "end": 999},
|
||||
"monitorRange": {"start": 1, "end": 9999}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gcore1",
|
||||
"name": "G-Core Server 1",
|
||||
"type": "gcore",
|
||||
"enabled": false,
|
||||
"connection": {
|
||||
"address": "192.168.1.20",
|
||||
"port": 7700,
|
||||
"username": "admin"
|
||||
},
|
||||
"bridge": {
|
||||
"url": "http://localhost:7721",
|
||||
"websocket": "ws://localhost:7721/ws/events"
|
||||
},
|
||||
"resources": {
|
||||
"cameraRange": {"start": 1000, "end": 1999},
|
||||
"monitorRange": {"start": 1000, "end": 1999}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "geviserver1",
|
||||
"name": "GeViServer",
|
||||
"type": "geviserver",
|
||||
"enabled": false,
|
||||
"connection": {
|
||||
"address": "192.168.1.30",
|
||||
"port": 7700,
|
||||
"username": "admin"
|
||||
},
|
||||
"bridge": {
|
||||
"url": "http://localhost:7710",
|
||||
"websocket": "ws://localhost:7710/ws"
|
||||
},
|
||||
"resources": {
|
||||
"cameraRange": {"start": 0, "end": 0},
|
||||
"monitorRange": {"start": 0, "end": 0}
|
||||
}
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"alarmSyncIntervalSeconds": 30,
|
||||
"connectionRetrySeconds": 5,
|
||||
"commandTimeoutSeconds": 10
|
||||
}
|
||||
}
|
||||
50
copilot_keyboard/lib/app.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'injection_container.dart';
|
||||
import 'presentation/blocs/wall/wall_bloc.dart';
|
||||
import 'presentation/blocs/wall/wall_event.dart';
|
||||
import 'presentation/screens/main_screen.dart';
|
||||
|
||||
class CopilotKeyboardApp extends StatelessWidget {
|
||||
const CopilotKeyboardApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<WallBloc>(
|
||||
create: (_) => sl<WallBloc>()..add(const LoadWallConfig()),
|
||||
child: MaterialApp(
|
||||
title: 'COPILOT Keyboard',
|
||||
theme: _buildDarkTheme(),
|
||||
darkTheme: _buildDarkTheme(),
|
||||
themeMode: ThemeMode.dark,
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: const MainScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData _buildDarkTheme() {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF00D4FF),
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF0A0E14),
|
||||
cardTheme: CardTheme(
|
||||
elevation: 0,
|
||||
color: const Color(0xFF151A22),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(36, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
179
copilot_keyboard/lib/config/app_config.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import '../domain/entities/function_button_config.dart';
|
||||
import '../domain/entities/server_config.dart';
|
||||
|
||||
/// Application configuration loaded from servers.json
|
||||
class AppConfig {
|
||||
final List<ServerConfig> servers;
|
||||
final String coordinatorUrl;
|
||||
final String keyboardId;
|
||||
final FunctionButtonConfig functionButtons;
|
||||
final int alarmSyncIntervalSeconds;
|
||||
final int connectionRetrySeconds;
|
||||
final int commandTimeoutSeconds;
|
||||
|
||||
const AppConfig({
|
||||
required this.servers,
|
||||
this.coordinatorUrl = 'http://localhost:8090',
|
||||
this.keyboardId = 'keyboard-1',
|
||||
this.functionButtons = const FunctionButtonConfig(),
|
||||
this.alarmSyncIntervalSeconds = 30,
|
||||
this.connectionRetrySeconds = 5,
|
||||
this.commandTimeoutSeconds = 10,
|
||||
});
|
||||
|
||||
/// Load configuration from file or assets
|
||||
static Future<AppConfig> load({String? configPath}) async {
|
||||
final logger = Logger();
|
||||
Map<String, dynamic> configJson;
|
||||
|
||||
// Try loading from file path first
|
||||
if (configPath != null) {
|
||||
try {
|
||||
final file = File(configPath);
|
||||
if (await file.exists()) {
|
||||
final contents = await file.readAsString();
|
||||
configJson = jsonDecode(contents) as Map<String, dynamic>;
|
||||
logger.i('Loaded config from: $configPath');
|
||||
return _parseConfig(configJson);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('Failed to load config from file: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Try common file locations
|
||||
final commonPaths = [
|
||||
'servers.json',
|
||||
'../servers.json',
|
||||
'config/servers.json',
|
||||
r'C:\DEV\COPILOT_D6\servers.json',
|
||||
];
|
||||
|
||||
for (final path in commonPaths) {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
final contents = await file.readAsString();
|
||||
configJson = jsonDecode(contents) as Map<String, dynamic>;
|
||||
logger.i('Loaded config from: $path');
|
||||
return _parseConfig(configJson);
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to next path
|
||||
}
|
||||
}
|
||||
|
||||
// Try loading from assets
|
||||
try {
|
||||
final contents = await rootBundle.loadString('assets/config/servers.json');
|
||||
configJson = jsonDecode(contents) as Map<String, dynamic>;
|
||||
logger.i('Loaded config from assets');
|
||||
return _parseConfig(configJson);
|
||||
} catch (e) {
|
||||
logger.w('Failed to load config from assets: $e');
|
||||
}
|
||||
|
||||
// Return default empty config
|
||||
logger.w('No config file found, using defaults');
|
||||
return const AppConfig(servers: []);
|
||||
}
|
||||
|
||||
static AppConfig _parseConfig(Map<String, dynamic> json) {
|
||||
final serversJson = json['servers'] as List<dynamic>? ?? [];
|
||||
final servers = serversJson
|
||||
.map((s) => ServerConfig.fromJson(s as Map<String, dynamic>))
|
||||
.where((s) => s.enabled)
|
||||
.toList();
|
||||
|
||||
final settings = json['settings'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
// Parse function button config
|
||||
final fbJson = json['functionButtons'] as Map<String, dynamic>?;
|
||||
final functionButtons = fbJson != null
|
||||
? FunctionButtonConfig.fromJson(fbJson)
|
||||
: const FunctionButtonConfig();
|
||||
|
||||
return AppConfig(
|
||||
servers: servers,
|
||||
coordinatorUrl: settings['coordinatorUrl'] as String? ?? 'http://localhost:8090',
|
||||
keyboardId: settings['keyboardId'] as String? ?? 'keyboard-1',
|
||||
functionButtons: functionButtons,
|
||||
alarmSyncIntervalSeconds: settings['alarmSyncIntervalSeconds'] as int? ?? 30,
|
||||
connectionRetrySeconds: settings['connectionRetrySeconds'] as int? ?? 5,
|
||||
commandTimeoutSeconds: settings['commandTimeoutSeconds'] as int? ?? 10,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get servers by type
|
||||
List<ServerConfig> getServersByType(ServerType type) {
|
||||
return servers.where((s) => s.type == type).toList();
|
||||
}
|
||||
|
||||
/// Get server that owns a camera ID
|
||||
ServerConfig? getServerForCamera(int cameraId) {
|
||||
for (final server in servers) {
|
||||
if (server.ownsCamera(cameraId)) {
|
||||
return server;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get server that owns a monitor ID
|
||||
ServerConfig? getServerForMonitor(int monitorId) {
|
||||
for (final server in servers) {
|
||||
if (server.ownsMonitor(monitorId)) {
|
||||
return server;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get total camera count across all servers
|
||||
int get totalCameras {
|
||||
int count = 0;
|
||||
for (final server in servers) {
|
||||
count += (server.cameraRangeEnd - server.cameraRangeStart + 1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Get total monitor count across all servers
|
||||
int get totalMonitors {
|
||||
int count = 0;
|
||||
for (final server in servers) {
|
||||
count += (server.monitorRangeEnd - server.monitorRangeStart + 1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Get all camera IDs
|
||||
List<int> get allCameraIds {
|
||||
final ids = <int>[];
|
||||
for (final server in servers) {
|
||||
for (int i = server.cameraRangeStart; i <= server.cameraRangeEnd; i++) {
|
||||
ids.add(i);
|
||||
}
|
||||
}
|
||||
ids.sort();
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Get all monitor IDs
|
||||
List<int> get allMonitorIds {
|
||||
final ids = <int>[];
|
||||
for (final server in servers) {
|
||||
for (int i = server.monitorRangeStart; i <= server.monitorRangeEnd; i++) {
|
||||
ids.add(i);
|
||||
}
|
||||
}
|
||||
ids.sort();
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
64
copilot_keyboard/lib/data/models/bridge_event.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
/// Event received from bridge WebSocket
|
||||
class BridgeEvent {
|
||||
final String serverId;
|
||||
final DateTime timestamp;
|
||||
final String action;
|
||||
final Map<String, dynamic> params;
|
||||
|
||||
BridgeEvent({
|
||||
required this.serverId,
|
||||
required this.timestamp,
|
||||
required this.action,
|
||||
required this.params,
|
||||
});
|
||||
|
||||
factory BridgeEvent.fromJson(Map<String, dynamic> json, String serverId) {
|
||||
return BridgeEvent(
|
||||
serverId: serverId,
|
||||
timestamp: json['timestamp'] != null
|
||||
? DateTime.parse(json['timestamp'] as String)
|
||||
: DateTime.now(),
|
||||
action: json['action'] as String? ?? '',
|
||||
params: json['params'] as Map<String, dynamic>? ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
// Specific event type checks
|
||||
bool get isViewerConnected => action == 'ViewerConnected';
|
||||
bool get isViewerCleared => action == 'ViewerCleared';
|
||||
bool get isViewerSelectionChanged => action == 'ViewerSelectionChanged';
|
||||
bool get isEventStarted => action == 'EventStarted';
|
||||
bool get isEventStopped => action == 'EventStopped';
|
||||
bool get isDigitalInput => action == 'DigitalInput';
|
||||
bool get isAlarmQueueNotification => action == 'VCAlarmQueueNotification';
|
||||
bool get isConnectionLost => action == 'ConnectionLost';
|
||||
|
||||
// Viewer events
|
||||
int? get viewer => params['Viewer'] as int?;
|
||||
int? get channel => params['Channel'] as int?;
|
||||
int? get playMode => params['PlayMode'] as int?;
|
||||
|
||||
// Alarm events
|
||||
int? get eventId => params['EventID'] as int?;
|
||||
String? get eventName => params['EventName'] as String?;
|
||||
int? get typeId => params['TypeID'] as int?;
|
||||
int? get foreignKey {
|
||||
final fk = params['ForeignKey'];
|
||||
if (fk is int) return fk;
|
||||
if (fk is String) return int.tryParse(fk);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Digital input
|
||||
int? get contact => params['Contact'] as int?;
|
||||
int? get state => params['State'] as int?;
|
||||
|
||||
// Alarm queue notification
|
||||
int? get notification => params['Notification'] as int?;
|
||||
int? get alarmId => params['AlarmID'] as int?;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeEvent($action from $serverId at $timestamp)';
|
||||
}
|
||||
}
|
||||
265
copilot_keyboard/lib/data/services/alarm_service.dart
Normal file
@@ -0,0 +1,265 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import '../../domain/entities/alarm_state.dart';
|
||||
import '../../domain/entities/server_config.dart';
|
||||
import '../models/bridge_event.dart';
|
||||
|
||||
/// Service for querying and tracking alarm state from GeViServer
|
||||
class AlarmService {
|
||||
final Logger _logger = Logger();
|
||||
final Map<String, Dio> _httpClients = {};
|
||||
final Map<String, ServerConfig> _servers = {};
|
||||
|
||||
// Alarm state
|
||||
final _alarmsController = BehaviorSubject<List<AlarmState>>.seeded([]);
|
||||
Timer? _syncTimer;
|
||||
|
||||
/// Stream of active alarms
|
||||
Stream<List<AlarmState>> get alarms => _alarmsController.stream;
|
||||
|
||||
/// Current active alarms
|
||||
List<AlarmState> get currentAlarms => _alarmsController.value;
|
||||
|
||||
/// Initialize with GeViServer configurations
|
||||
Future<void> initialize(List<ServerConfig> servers) async {
|
||||
// Only use GeViServer type servers for alarm queries
|
||||
final geviServers =
|
||||
servers.where((s) => s.type == ServerType.geviserver && s.enabled);
|
||||
|
||||
for (final server in geviServers) {
|
||||
_servers[server.id] = server;
|
||||
_httpClients[server.id] = Dio(BaseOptions(
|
||||
baseUrl: server.bridgeUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
}
|
||||
|
||||
_logger.i('AlarmService initialized with ${_servers.length} GeViServers');
|
||||
}
|
||||
|
||||
/// Query all active alarms from all GeViServers
|
||||
Future<List<AlarmState>> queryAllAlarms() async {
|
||||
final allAlarms = <AlarmState>[];
|
||||
|
||||
for (final entry in _servers.entries) {
|
||||
final serverId = entry.key;
|
||||
try {
|
||||
final alarms = await _queryAlarmsFromServer(serverId);
|
||||
allAlarms.addAll(alarms);
|
||||
_logger.i('Queried ${alarms.length} alarms from $serverId');
|
||||
} catch (e) {
|
||||
_logger.e('Failed to query alarms from $serverId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_alarmsController.add(allAlarms);
|
||||
return allAlarms;
|
||||
}
|
||||
|
||||
/// Query alarms from a specific server using GetFirstAlarm/GetNextAlarm pattern
|
||||
Future<List<AlarmState>> _queryAlarmsFromServer(String serverId) async {
|
||||
final client = _httpClients[serverId];
|
||||
if (client == null) return [];
|
||||
|
||||
final alarms = <AlarmState>[];
|
||||
|
||||
try {
|
||||
// Try the bulk endpoint first (preferred)
|
||||
final response = await client.get('/alarms/active');
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final alarmList = data['alarms'] as List<dynamic>? ?? [];
|
||||
|
||||
for (final alarmJson in alarmList) {
|
||||
final alarm = _parseAlarmFromJson(
|
||||
alarmJson as Map<String, dynamic>,
|
||||
serverId,
|
||||
);
|
||||
if (alarm != null) {
|
||||
alarms.add(alarm);
|
||||
}
|
||||
}
|
||||
return alarms;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.d('Bulk alarm query failed, trying iteration: $e');
|
||||
}
|
||||
|
||||
// Fallback to GetFirstAlarm/GetNextAlarm iteration
|
||||
try {
|
||||
// Get first alarm
|
||||
var response = await client.get('/alarms/first');
|
||||
if (response.statusCode != 200) return alarms;
|
||||
|
||||
var data = response.data as Map<String, dynamic>;
|
||||
if (data['alarm'] == null) return alarms; // No alarms
|
||||
|
||||
var alarm = _parseAlarmFromJson(
|
||||
data['alarm'] as Map<String, dynamic>,
|
||||
serverId,
|
||||
);
|
||||
if (alarm != null) alarms.add(alarm);
|
||||
|
||||
// Iterate through remaining alarms
|
||||
while (true) {
|
||||
response = await client.get('/alarms/next');
|
||||
if (response.statusCode != 200) break;
|
||||
|
||||
data = response.data as Map<String, dynamic>;
|
||||
if (data['alarm'] == null) break; // No more alarms
|
||||
|
||||
alarm = _parseAlarmFromJson(
|
||||
data['alarm'] as Map<String, dynamic>,
|
||||
serverId,
|
||||
);
|
||||
if (alarm != null) alarms.add(alarm);
|
||||
|
||||
// Safety limit
|
||||
if (alarms.length > 1000) {
|
||||
_logger.w('Alarm query hit safety limit of 1000');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Alarm iteration failed: $e');
|
||||
}
|
||||
|
||||
return alarms;
|
||||
}
|
||||
|
||||
/// Parse alarm from JSON response
|
||||
AlarmState? _parseAlarmFromJson(Map<String, dynamic> json, String serverId) {
|
||||
try {
|
||||
return AlarmState(
|
||||
eventId: json['EventID'] as int? ?? json['event_id'] as int? ?? 0,
|
||||
eventName:
|
||||
json['EventName'] as String? ?? json['event_name'] as String? ?? '',
|
||||
typeId: json['TypeID'] as int? ?? json['type_id'] as int? ?? 0,
|
||||
foreignKey: _parseForeignKey(json['ForeignKey'] ?? json['foreign_key']),
|
||||
serverId: serverId,
|
||||
startedAt: _parseDateTime(json['StartedAt'] ?? json['started_at']),
|
||||
stoppedAt: _parseNullableDateTime(json['StoppedAt'] ?? json['stopped_at']),
|
||||
isActive: json['IsActive'] as bool? ?? json['is_active'] as bool? ?? true,
|
||||
status: AlarmStatus.fromValue(
|
||||
json['Status'] as int? ?? json['status'] as int? ?? 0,
|
||||
),
|
||||
associatedMonitor: json['AssociatedMonitor'] as int? ??
|
||||
json['associated_monitor'] as int?,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Failed to parse alarm: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int _parseForeignKey(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
DateTime _parseDateTime(dynamic value) {
|
||||
if (value is DateTime) return value;
|
||||
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
DateTime? _parseNullableDateTime(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is DateTime) return value;
|
||||
if (value is String) return DateTime.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Start periodic alarm sync
|
||||
void startPeriodicSync(Duration interval) {
|
||||
stopPeriodicSync();
|
||||
_syncTimer = Timer.periodic(interval, (_) => queryAllAlarms());
|
||||
_logger.i('Started periodic alarm sync every ${interval.inSeconds}s');
|
||||
}
|
||||
|
||||
/// Stop periodic alarm sync
|
||||
void stopPeriodicSync() {
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = null;
|
||||
}
|
||||
|
||||
/// Handle alarm event from WebSocket
|
||||
void handleAlarmEvent(BridgeEvent event) {
|
||||
final currentAlarms = List<AlarmState>.from(_alarmsController.value);
|
||||
|
||||
if (event.isEventStarted) {
|
||||
// New alarm started
|
||||
final newAlarm = AlarmState(
|
||||
eventId: event.eventId ?? 0,
|
||||
eventName: event.eventName ?? '',
|
||||
typeId: event.typeId ?? 0,
|
||||
foreignKey: event.foreignKey ?? 0,
|
||||
serverId: event.serverId,
|
||||
startedAt: event.timestamp,
|
||||
isActive: true,
|
||||
status: AlarmStatus.newAlarm,
|
||||
);
|
||||
|
||||
// Remove existing alarm with same ID if any
|
||||
currentAlarms.removeWhere((a) =>
|
||||
a.eventId == newAlarm.eventId && a.serverId == newAlarm.serverId);
|
||||
currentAlarms.add(newAlarm);
|
||||
|
||||
_logger.d('Alarm started: ${newAlarm.eventName} (ID: ${newAlarm.eventId})');
|
||||
} else if (event.isEventStopped) {
|
||||
// Alarm stopped
|
||||
final index = currentAlarms.indexWhere((a) =>
|
||||
a.eventId == event.eventId && a.serverId == event.serverId);
|
||||
|
||||
if (index >= 0) {
|
||||
currentAlarms[index] = currentAlarms[index].stopped();
|
||||
_logger.d('Alarm stopped: ${currentAlarms[index].eventName}');
|
||||
}
|
||||
} else if (event.isAlarmQueueNotification) {
|
||||
// Alarm status changed (presented, confirmed, etc.)
|
||||
final alarmId = event.alarmId;
|
||||
final notification = event.notification;
|
||||
|
||||
if (alarmId != null && notification != null) {
|
||||
final index = currentAlarms.indexWhere((a) =>
|
||||
a.eventId == alarmId && a.serverId == event.serverId);
|
||||
|
||||
if (index >= 0) {
|
||||
final newStatus = AlarmStatus.fromValue(notification);
|
||||
currentAlarms[index] = currentAlarms[index].withStatus(newStatus);
|
||||
_logger.d(
|
||||
'Alarm status changed: ${currentAlarms[index].eventName} -> ${newStatus.name}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_alarmsController.add(currentAlarms);
|
||||
}
|
||||
|
||||
/// Get alarms blocking a specific monitor
|
||||
List<AlarmState> getAlarmsForMonitor(int monitorId) {
|
||||
return _alarmsController.value
|
||||
.where((a) => a.associatedMonitor == monitorId && a.blocksMonitor)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Check if any alarm blocks a monitor
|
||||
bool isMonitorBlocked(int monitorId) {
|
||||
return _alarmsController.value
|
||||
.any((a) => a.associatedMonitor == monitorId && a.blocksMonitor);
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
stopPeriodicSync();
|
||||
_alarmsController.close();
|
||||
_httpClients.clear();
|
||||
_servers.clear();
|
||||
}
|
||||
}
|
||||
487
copilot_keyboard/lib/data/services/bridge_service.dart
Normal file
@@ -0,0 +1,487 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../../domain/entities/server_config.dart';
|
||||
import '../models/bridge_event.dart';
|
||||
|
||||
/// Service for communicating with all bridges.
|
||||
/// Includes auto-reconnection with exponential backoff and health polling.
|
||||
class BridgeService {
|
||||
final Logger _logger = Logger();
|
||||
final Map<String, Dio> _httpClients = {};
|
||||
final Map<String, WebSocketChannel> _wsChannels = {};
|
||||
final Map<String, StreamSubscription> _wsSubscriptions = {};
|
||||
final Map<String, ServerConfig> _servers = {};
|
||||
final Map<String, int> _reconnectAttempts = {};
|
||||
final Map<String, Timer> _reconnectTimers = {};
|
||||
Timer? _healthCheckTimer;
|
||||
bool _disposed = false;
|
||||
|
||||
// Reconnection config
|
||||
static const _initialReconnectDelay = Duration(seconds: 1);
|
||||
static const _maxReconnectDelay = Duration(seconds: 30);
|
||||
static const _healthCheckInterval = Duration(seconds: 10);
|
||||
static const _commandRetryCount = 3;
|
||||
static const _commandRetryDelay = Duration(milliseconds: 200);
|
||||
|
||||
// Event streams
|
||||
final _eventController = BehaviorSubject<BridgeEvent>();
|
||||
final _connectionStatusController =
|
||||
BehaviorSubject<Map<String, bool>>.seeded({});
|
||||
|
||||
// Callback for state resync after reconnection
|
||||
void Function(String serverId)? onReconnected;
|
||||
|
||||
/// Stream of all events from all bridges
|
||||
Stream<BridgeEvent> get eventStream => _eventController.stream;
|
||||
|
||||
/// Stream of connection status per server
|
||||
Stream<Map<String, bool>> get connectionStatus =>
|
||||
_connectionStatusController.stream;
|
||||
|
||||
/// Get current connection status
|
||||
Map<String, bool> get currentConnectionStatus =>
|
||||
_connectionStatusController.value;
|
||||
|
||||
/// Initialize the service with server configurations
|
||||
Future<void> initialize(List<ServerConfig> servers) async {
|
||||
_logger.i('Initializing BridgeService with ${servers.length} servers');
|
||||
|
||||
for (final server in servers) {
|
||||
if (!server.enabled) {
|
||||
_logger.d('Skipping disabled server: ${server.id}');
|
||||
continue;
|
||||
}
|
||||
|
||||
_servers[server.id] = server;
|
||||
|
||||
// Create HTTP client
|
||||
_httpClients[server.id] = Dio(BaseOptions(
|
||||
baseUrl: server.bridgeUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
|
||||
_updateConnectionStatus(server.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a specific server's bridge
|
||||
Future<bool> connect(String serverId) async {
|
||||
final server = _servers[serverId];
|
||||
if (server == null) {
|
||||
_logger.w('Unknown server: $serverId');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check bridge health
|
||||
final response =
|
||||
await _httpClients[serverId]!.get('/health');
|
||||
if (response.statusCode == 200) {
|
||||
_updateConnectionStatus(serverId, true);
|
||||
_logger.i('Connected to bridge: $serverId');
|
||||
|
||||
// Connect WebSocket for events if available
|
||||
if (server.websocketUrl != null) {
|
||||
await _connectWebSocket(serverId, server.websocketUrl!);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Failed to connect to $serverId: $e');
|
||||
}
|
||||
|
||||
_updateConnectionStatus(serverId, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Connect to all configured servers and start health monitoring
|
||||
Future<void> connectAll() async {
|
||||
for (final serverId in _servers.keys) {
|
||||
await connect(serverId);
|
||||
}
|
||||
_startHealthChecks();
|
||||
}
|
||||
|
||||
/// Disconnect from a specific server
|
||||
Future<void> disconnect(String serverId) async {
|
||||
await _disconnectWebSocket(serverId);
|
||||
_updateConnectionStatus(serverId, false);
|
||||
_logger.i('Disconnected from bridge: $serverId');
|
||||
}
|
||||
|
||||
/// Disconnect from all servers
|
||||
Future<void> disconnectAll() async {
|
||||
for (final serverId in _servers.keys) {
|
||||
await disconnect(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Command Methods
|
||||
// ============================================================
|
||||
|
||||
/// Switch camera to monitor (ViewerConnectLive).
|
||||
/// Critical command — retries up to 3 times with backoff.
|
||||
Future<bool> viewerConnectLive(int viewer, int channel) async {
|
||||
final serverId = _findServerForMonitor(viewer);
|
||||
if (serverId == null) {
|
||||
_logger.w('No server found for monitor $viewer');
|
||||
return false;
|
||||
}
|
||||
|
||||
return _retryCommand('viewerConnectLive', () async {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/viewer/connect-live',
|
||||
data: {'Viewer': viewer, 'Channel': channel},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear monitor (ViewerClear)
|
||||
Future<bool> viewerClear(int viewer) async {
|
||||
final serverId = _findServerForMonitor(viewer);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/viewer/clear',
|
||||
data: {'Viewer': viewer},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('viewerClear failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Pan control
|
||||
Future<bool> ptzPan(int camera, String direction, int speed) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/pan',
|
||||
data: {'Camera': camera, 'Direction': direction, 'Speed': speed},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzPan failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Tilt control
|
||||
Future<bool> ptzTilt(int camera, String direction, int speed) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/tilt',
|
||||
data: {'Camera': camera, 'Direction': direction, 'Speed': speed},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzTilt failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Zoom control
|
||||
Future<bool> ptzZoom(int camera, String direction, int speed) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/zoom',
|
||||
data: {'Camera': camera, 'Direction': direction, 'Speed': speed},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzZoom failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Stop all movement
|
||||
Future<bool> ptzStop(int camera) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/stop',
|
||||
data: {'Camera': camera},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzStop failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Go to preset
|
||||
Future<bool> ptzPreset(int camera, int preset) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/preset',
|
||||
data: {'Camera': camera, 'Preset': preset},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzPreset failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Query Methods
|
||||
// ============================================================
|
||||
|
||||
/// Get current monitor states from a bridge
|
||||
Future<List<Map<String, dynamic>>> getMonitorStates(String serverId) async {
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/monitors');
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final monitors = data['monitors'] as List<dynamic>? ?? [];
|
||||
return monitors.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getMonitorStates failed for $serverId: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get active alarms from a bridge
|
||||
Future<List<Map<String, dynamic>>> getActiveAlarms(String serverId) async {
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/alarms/active');
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final alarms = data['alarms'] as List<dynamic>? ?? [];
|
||||
return alarms.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getActiveAlarms failed for $serverId: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get bridge status
|
||||
Future<Map<String, dynamic>?> getBridgeStatus(String serverId) async {
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/status');
|
||||
if (response.statusCode == 200) {
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getBridgeStatus failed for $serverId: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private Methods
|
||||
// ============================================================
|
||||
|
||||
String? _findServerForCamera(int cameraId) {
|
||||
for (final entry in _servers.entries) {
|
||||
if (entry.value.ownsCamera(cameraId)) {
|
||||
return entry.key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _findServerForMonitor(int monitorId) {
|
||||
for (final entry in _servers.entries) {
|
||||
if (entry.value.ownsMonitor(monitorId)) {
|
||||
return entry.key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _updateConnectionStatus(String serverId, bool connected) {
|
||||
final current = Map<String, bool>.from(_connectionStatusController.value);
|
||||
current[serverId] = connected;
|
||||
_connectionStatusController.add(current);
|
||||
}
|
||||
|
||||
Future<void> _connectWebSocket(String serverId, String url) async {
|
||||
try {
|
||||
await _disconnectWebSocket(serverId);
|
||||
|
||||
_logger.d('Connecting WebSocket to $url');
|
||||
final channel = WebSocketChannel.connect(Uri.parse(url));
|
||||
_wsChannels[serverId] = channel;
|
||||
_reconnectAttempts[serverId] = 0; // Reset on successful connection
|
||||
|
||||
_wsSubscriptions[serverId] = channel.stream.listen(
|
||||
(message) {
|
||||
try {
|
||||
final json = jsonDecode(message as String) as Map<String, dynamic>;
|
||||
if (json['type'] == 'connected') {
|
||||
_logger.d('WebSocket connected to $serverId');
|
||||
return;
|
||||
}
|
||||
final event = BridgeEvent.fromJson(json, serverId);
|
||||
_eventController.add(event);
|
||||
} catch (e) {
|
||||
_logger.e('Failed to parse WebSocket message: $e');
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.e('WebSocket error for $serverId: $error');
|
||||
_updateConnectionStatus(serverId, false);
|
||||
_scheduleReconnect(serverId);
|
||||
},
|
||||
onDone: () {
|
||||
_logger.w('WebSocket closed for $serverId');
|
||||
_updateConnectionStatus(serverId, false);
|
||||
_scheduleReconnect(serverId);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Failed to connect WebSocket to $serverId: $e');
|
||||
_scheduleReconnect(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnectWebSocket(String serverId) async {
|
||||
await _wsSubscriptions[serverId]?.cancel();
|
||||
_wsSubscriptions.remove(serverId);
|
||||
|
||||
await _wsChannels[serverId]?.sink.close();
|
||||
_wsChannels.remove(serverId);
|
||||
}
|
||||
|
||||
/// Retry a critical command with exponential backoff.
|
||||
/// Used for CrossSwitch and other critical operations.
|
||||
Future<bool> _retryCommand(String name, Future<bool> Function() command) async {
|
||||
for (int attempt = 1; attempt <= _commandRetryCount; attempt++) {
|
||||
try {
|
||||
final result = await command();
|
||||
if (result) return true;
|
||||
} catch (e) {
|
||||
if (attempt == _commandRetryCount) {
|
||||
_logger.e('$name failed after $attempt attempts: $e');
|
||||
return false;
|
||||
}
|
||||
_logger.w('$name attempt $attempt failed, retrying: $e');
|
||||
await Future.delayed(_commandRetryDelay * attempt);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Schedule WebSocket reconnection with exponential backoff.
|
||||
void _scheduleReconnect(String serverId) {
|
||||
if (_disposed) return;
|
||||
_reconnectTimers[serverId]?.cancel();
|
||||
|
||||
final attempts = _reconnectAttempts[serverId] ?? 0;
|
||||
final delay = Duration(
|
||||
milliseconds: (_initialReconnectDelay.inMilliseconds *
|
||||
(1 << attempts.clamp(0, 5))) // 1s, 2s, 4s, 8s, 16s, 32s
|
||||
.clamp(0, _maxReconnectDelay.inMilliseconds),
|
||||
);
|
||||
|
||||
_logger.i('Scheduling reconnect for $serverId in ${delay.inSeconds}s (attempt ${attempts + 1})');
|
||||
|
||||
_reconnectTimers[serverId] = Timer(delay, () async {
|
||||
if (_disposed) return;
|
||||
_reconnectAttempts[serverId] = attempts + 1;
|
||||
|
||||
final server = _servers[serverId];
|
||||
if (server == null) return;
|
||||
|
||||
// Check if bridge is healthy before reconnecting WebSocket
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/health');
|
||||
if (response.statusCode == 200) {
|
||||
_updateConnectionStatus(serverId, true);
|
||||
if (server.websocketUrl != null) {
|
||||
await _connectWebSocket(serverId, server.websocketUrl!);
|
||||
}
|
||||
_logger.i('Reconnected to $serverId');
|
||||
onReconnected?.call(serverId);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.d('Reconnect health check failed for $serverId: $e');
|
||||
_scheduleReconnect(serverId); // Try again
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start periodic health checks for all bridges.
|
||||
/// Detects when a bridge comes back online after failure.
|
||||
void _startHealthChecks() {
|
||||
_healthCheckTimer?.cancel();
|
||||
_healthCheckTimer = Timer.periodic(_healthCheckInterval, (_) async {
|
||||
if (_disposed) return;
|
||||
for (final serverId in _servers.keys) {
|
||||
final isConnected = currentConnectionStatus[serverId] ?? false;
|
||||
if (!isConnected) {
|
||||
// Bridge is down — try to reconnect
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/health');
|
||||
if (response.statusCode == 200) {
|
||||
_logger.i('Bridge $serverId is back online');
|
||||
_updateConnectionStatus(serverId, true);
|
||||
_reconnectAttempts[serverId] = 0;
|
||||
|
||||
final server = _servers[serverId];
|
||||
if (server?.websocketUrl != null) {
|
||||
await _connectWebSocket(serverId, server!.websocketUrl!);
|
||||
}
|
||||
onReconnected?.call(serverId);
|
||||
}
|
||||
} catch (_) {
|
||||
// Still down, will check again next cycle
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Dispose of all resources
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_healthCheckTimer?.cancel();
|
||||
for (final timer in _reconnectTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_reconnectTimers.clear();
|
||||
_eventController.close();
|
||||
_connectionStatusController.close();
|
||||
|
||||
for (final sub in _wsSubscriptions.values) {
|
||||
sub.cancel();
|
||||
}
|
||||
_wsSubscriptions.clear();
|
||||
|
||||
for (final channel in _wsChannels.values) {
|
||||
channel.sink.close();
|
||||
}
|
||||
_wsChannels.clear();
|
||||
|
||||
_httpClients.clear();
|
||||
_servers.clear();
|
||||
}
|
||||
}
|
||||
464
copilot_keyboard/lib/data/services/coordination_service.dart
Normal file
@@ -0,0 +1,464 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../../domain/entities/camera_lock.dart';
|
||||
|
||||
/// Client for the COPILOT Coordinator service (:8090).
|
||||
/// Handles camera locks, sequences, and keyboard coordination.
|
||||
class CoordinationService {
|
||||
final Logger _logger = Logger();
|
||||
late final Dio _http;
|
||||
WebSocketChannel? _ws;
|
||||
StreamSubscription? _wsSubscription;
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _lockResetTimer;
|
||||
bool _disposed = false;
|
||||
|
||||
String _coordinatorUrl = '';
|
||||
String _keyboardId = '';
|
||||
|
||||
// Reconnection config
|
||||
static const _initialReconnectDelay = Duration(seconds: 1);
|
||||
static const _maxReconnectDelay = Duration(seconds: 30);
|
||||
int _reconnectAttempts = 0;
|
||||
|
||||
// Connection status
|
||||
final _connectedController = BehaviorSubject<bool>.seeded(false);
|
||||
|
||||
// Lock state
|
||||
final _locksController = BehaviorSubject<Map<int, CameraLock>>.seeded({});
|
||||
final _notificationController =
|
||||
BehaviorSubject<CameraLockNotification?>.seeded(null);
|
||||
|
||||
/// Whether connected to the coordinator
|
||||
Stream<bool> get connected => _connectedController.stream;
|
||||
bool get isConnected => _connectedController.value;
|
||||
|
||||
/// Current camera locks (all keyboards)
|
||||
Stream<Map<int, CameraLock>> get locks => _locksController.stream;
|
||||
Map<int, CameraLock> get currentLocks => _locksController.value;
|
||||
|
||||
/// Lock notifications targeted at this keyboard
|
||||
Stream<CameraLockNotification?> get notifications =>
|
||||
_notificationController.stream;
|
||||
|
||||
/// Initialize with coordinator URL and keyboard identity
|
||||
Future<void> initialize(String coordinatorUrl, String keyboardId) async {
|
||||
_coordinatorUrl = coordinatorUrl;
|
||||
_keyboardId = keyboardId;
|
||||
|
||||
_http = Dio(BaseOptions(
|
||||
baseUrl: coordinatorUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
|
||||
_logger.i(
|
||||
'CoordinationService initialized: $coordinatorUrl (keyboard: $keyboardId)');
|
||||
}
|
||||
|
||||
/// Connect to the coordinator (HTTP health check + WebSocket)
|
||||
Future<bool> connect() async {
|
||||
try {
|
||||
final response = await _http.get('/health');
|
||||
if (response.statusCode == 200) {
|
||||
_connectedController.add(true);
|
||||
_reconnectAttempts = 0;
|
||||
await _connectWebSocket();
|
||||
|
||||
// Sync current lock state
|
||||
await syncLocks();
|
||||
_logger.i('Connected to coordinator');
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Failed to connect to coordinator: $e');
|
||||
}
|
||||
|
||||
_connectedController.add(false);
|
||||
_scheduleReconnect();
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Disconnect from the coordinator
|
||||
Future<void> disconnect() async {
|
||||
_reconnectTimer?.cancel();
|
||||
_lockResetTimer?.cancel();
|
||||
await _disconnectWebSocket();
|
||||
_connectedController.add(false);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Lock Operations
|
||||
// ============================================================
|
||||
|
||||
/// Try to acquire a lock on a camera
|
||||
Future<({bool acquired, CameraLock? lock})> tryLock(
|
||||
int cameraId, {
|
||||
CameraLockPriority priority = CameraLockPriority.low,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/try', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
'Priority': priority.name,
|
||||
});
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final acquired = data['acquired'] as bool? ?? false;
|
||||
final lockJson = data['lock'] as Map<String, dynamic>?;
|
||||
final lock = lockJson != null ? CameraLock.fromJson(lockJson) : null;
|
||||
|
||||
if (acquired && lock != null) {
|
||||
_updateLockState(lock);
|
||||
_startLockResetTimer(cameraId);
|
||||
}
|
||||
|
||||
return (acquired: acquired, lock: lock);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('tryLock failed for camera $cameraId: $e');
|
||||
}
|
||||
return (acquired: false, lock: null);
|
||||
}
|
||||
|
||||
/// Release a camera lock
|
||||
Future<bool> releaseLock(int cameraId) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/release', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
});
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
_removeLockState(cameraId);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('releaseLock failed for camera $cameraId: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Request takeover of a camera locked by another keyboard
|
||||
Future<bool> requestTakeover(
|
||||
int cameraId, {
|
||||
CameraLockPriority priority = CameraLockPriority.low,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/takeover', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
'Priority': priority.name,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('requestTakeover failed for camera $cameraId: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm or reject a takeover request
|
||||
Future<bool> confirmTakeover(int cameraId, bool confirm) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/confirm', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
'Confirm': confirm,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('confirmTakeover failed for camera $cameraId: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset lock expiration timer (called periodically while PTZ is active)
|
||||
Future<bool> resetExpiration(int cameraId) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/reset', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('resetExpiration failed for camera $cameraId: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all current locks from the coordinator
|
||||
Future<void> syncLocks() async {
|
||||
try {
|
||||
final response = await _http.get('/locks');
|
||||
if (response.statusCode == 200) {
|
||||
final lockList = response.data as List<dynamic>;
|
||||
final locks = <int, CameraLock>{};
|
||||
for (final lockJson in lockList) {
|
||||
final lock = CameraLock.fromJson(lockJson as Map<String, dynamic>);
|
||||
locks[lock.cameraId] = lock;
|
||||
}
|
||||
_locksController.add(locks);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('syncLocks failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get camera IDs locked by this keyboard
|
||||
Future<List<int>> getMyLockedCameras() async {
|
||||
try {
|
||||
final response = await _http.get('/locks/$_keyboardId');
|
||||
if (response.statusCode == 200) {
|
||||
final ids = response.data as List<dynamic>;
|
||||
return ids.cast<int>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getMyLockedCameras failed: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Check if a camera is locked by this keyboard
|
||||
bool isCameraLockedByMe(int cameraId) {
|
||||
final lock = currentLocks[cameraId];
|
||||
return lock != null &&
|
||||
lock.ownerName.toLowerCase() == _keyboardId.toLowerCase();
|
||||
}
|
||||
|
||||
/// Check if a camera is locked by another keyboard
|
||||
bool isCameraLockedByOther(int cameraId) {
|
||||
final lock = currentLocks[cameraId];
|
||||
return lock != null &&
|
||||
lock.ownerName.toLowerCase() != _keyboardId.toLowerCase();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sequence Operations
|
||||
// ============================================================
|
||||
|
||||
/// Start a sequence on a viewer
|
||||
Future<Map<String, dynamic>?> startSequence(
|
||||
int viewerId, int sequenceId) async {
|
||||
try {
|
||||
final response = await _http.post('/sequences/start', data: {
|
||||
'ViewerId': viewerId,
|
||||
'SequenceId': sequenceId,
|
||||
});
|
||||
if (response.statusCode == 200) {
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('startSequence failed: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Stop a sequence on a viewer
|
||||
Future<bool> stopSequence(int viewerId) async {
|
||||
try {
|
||||
final response = await _http.post('/sequences/stop', data: {
|
||||
'ViewerId': viewerId,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('stopSequence failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get running sequences
|
||||
Future<List<Map<String, dynamic>>> getRunningSequences() async {
|
||||
try {
|
||||
final response = await _http.get('/sequences/running');
|
||||
if (response.statusCode == 200) {
|
||||
final list = response.data as List<dynamic>;
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getRunningSequences failed: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get available sequences, optionally filtered by category
|
||||
Future<List<Map<String, dynamic>>> getSequences({int? categoryId}) async {
|
||||
try {
|
||||
final queryParams = categoryId != null ? {'categoryId': categoryId} : null;
|
||||
final response =
|
||||
await _http.get('/sequences', queryParameters: queryParams);
|
||||
if (response.statusCode == 200) {
|
||||
final list = response.data as List<dynamic>;
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getSequences failed: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get sequence categories
|
||||
Future<List<Map<String, dynamic>>> getSequenceCategories() async {
|
||||
try {
|
||||
final response = await _http.get('/sequences/categories');
|
||||
if (response.statusCode == 200) {
|
||||
final list = response.data as List<dynamic>;
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getSequenceCategories failed: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private Methods
|
||||
// ============================================================
|
||||
|
||||
Future<void> _connectWebSocket() async {
|
||||
await _disconnectWebSocket();
|
||||
|
||||
try {
|
||||
final wsUrl =
|
||||
'${_coordinatorUrl.replaceFirst('http', 'ws')}/ws?keyboard=$_keyboardId';
|
||||
_logger.d('Connecting coordinator WebSocket: $wsUrl');
|
||||
|
||||
_ws = WebSocketChannel.connect(Uri.parse(wsUrl));
|
||||
|
||||
_wsSubscription = _ws!.stream.listen(
|
||||
(message) => _handleWsMessage(message as String),
|
||||
onError: (error) {
|
||||
_logger.e('Coordinator WebSocket error: $error');
|
||||
_connectedController.add(false);
|
||||
_scheduleReconnect();
|
||||
},
|
||||
onDone: () {
|
||||
_logger.w('Coordinator WebSocket closed');
|
||||
_connectedController.add(false);
|
||||
_scheduleReconnect();
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Failed to connect coordinator WebSocket: $e');
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnectWebSocket() async {
|
||||
await _wsSubscription?.cancel();
|
||||
_wsSubscription = null;
|
||||
await _ws?.sink.close();
|
||||
_ws = null;
|
||||
}
|
||||
|
||||
void _handleWsMessage(String message) {
|
||||
try {
|
||||
final json = jsonDecode(message) as Map<String, dynamic>;
|
||||
final type = json['type'] as String?;
|
||||
final data = json['data'];
|
||||
|
||||
switch (type) {
|
||||
case 'lock_acquired':
|
||||
if (data is Map<String, dynamic>) {
|
||||
final lock = CameraLock.fromJson(data);
|
||||
_updateLockState(lock);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'lock_released':
|
||||
if (data is Map<String, dynamic>) {
|
||||
final cameraId =
|
||||
data['cameraId'] as int? ?? data['CameraId'] as int? ?? 0;
|
||||
_removeLockState(cameraId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'lock_notification':
|
||||
if (data is Map<String, dynamic>) {
|
||||
final notification = CameraLockNotification.fromJson(data);
|
||||
_notificationController.add(notification);
|
||||
_logger.i(
|
||||
'Lock notification: ${notification.type.name} camera ${notification.cameraId}');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sequence_started':
|
||||
case 'sequence_stopped':
|
||||
_logger.d('Sequence event: $type');
|
||||
break;
|
||||
|
||||
case 'keyboard_online':
|
||||
case 'keyboard_offline':
|
||||
_logger.d('Keyboard event: $type');
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.d('Unknown coordinator event: $type');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Failed to parse coordinator message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _updateLockState(CameraLock lock) {
|
||||
final locks = Map<int, CameraLock>.from(_locksController.value);
|
||||
locks[lock.cameraId] = lock;
|
||||
_locksController.add(locks);
|
||||
}
|
||||
|
||||
void _removeLockState(int cameraId) {
|
||||
final locks = Map<int, CameraLock>.from(_locksController.value);
|
||||
locks.remove(cameraId);
|
||||
_locksController.add(locks);
|
||||
}
|
||||
|
||||
/// Auto-reset lock expiration every 2 minutes while a lock is held
|
||||
void _startLockResetTimer(int cameraId) {
|
||||
_lockResetTimer?.cancel();
|
||||
_lockResetTimer = Timer.periodic(const Duration(minutes: 2), (_) {
|
||||
if (isCameraLockedByMe(cameraId)) {
|
||||
resetExpiration(cameraId);
|
||||
} else {
|
||||
_lockResetTimer?.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_disposed) return;
|
||||
_reconnectTimer?.cancel();
|
||||
|
||||
final delay = Duration(
|
||||
milliseconds: (_initialReconnectDelay.inMilliseconds *
|
||||
(1 << _reconnectAttempts.clamp(0, 5)))
|
||||
.clamp(0, _maxReconnectDelay.inMilliseconds),
|
||||
);
|
||||
|
||||
_logger.i(
|
||||
'Scheduling coordinator reconnect in ${delay.inSeconds}s (attempt ${_reconnectAttempts + 1})');
|
||||
|
||||
_reconnectTimer = Timer(delay, () async {
|
||||
if (_disposed) return;
|
||||
_reconnectAttempts++;
|
||||
await connect();
|
||||
});
|
||||
}
|
||||
|
||||
/// Dispose all resources
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_lockResetTimer?.cancel();
|
||||
_wsSubscription?.cancel();
|
||||
_ws?.sink.close();
|
||||
_connectedController.close();
|
||||
_locksController.close();
|
||||
_notificationController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import '../../domain/entities/function_button_config.dart';
|
||||
import 'bridge_service.dart';
|
||||
import 'coordination_service.dart';
|
||||
|
||||
/// Executes function button actions (F1-F7).
|
||||
/// Ported from legacy FunctionButtonsService.cs.
|
||||
class FunctionButtonService {
|
||||
final BridgeService _bridgeService;
|
||||
final CoordinationService _coordinationService;
|
||||
final Logger _logger = Logger();
|
||||
|
||||
FunctionButtonConfig _config = const FunctionButtonConfig();
|
||||
|
||||
FunctionButtonService({
|
||||
required BridgeService bridgeService,
|
||||
required CoordinationService coordinationService,
|
||||
}) : _bridgeService = bridgeService,
|
||||
_coordinationService = coordinationService;
|
||||
|
||||
/// Load function button configuration.
|
||||
void loadConfig(FunctionButtonConfig config) {
|
||||
_config = config;
|
||||
_logger.i('Loaded function button config: ${config.walls.length} walls');
|
||||
}
|
||||
|
||||
/// Execute all actions for a button on a specific wall.
|
||||
/// Actions are executed sequentially (like legacy).
|
||||
Future<bool> execute(String wallId, String buttonKey) async {
|
||||
final actions = _config.getActions(wallId, buttonKey);
|
||||
if (actions.isEmpty) {
|
||||
_logger.d('No actions for $buttonKey on wall $wallId');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.i('Executing $buttonKey on wall $wallId (${actions.length} actions)');
|
||||
|
||||
for (final action in actions) {
|
||||
try {
|
||||
switch (action.type) {
|
||||
case FunctionButtonActionType.crossSwitch:
|
||||
await _bridgeService.viewerConnectLive(
|
||||
action.viewerId, action.sourceId);
|
||||
_logger.d(
|
||||
'CrossSwitch: viewer ${action.viewerId} -> camera ${action.sourceId}');
|
||||
|
||||
case FunctionButtonActionType.sequenceStart:
|
||||
await _coordinationService.startSequence(
|
||||
action.viewerId, action.sourceId);
|
||||
_logger.d(
|
||||
'SequenceStart: viewer ${action.viewerId} -> sequence ${action.sourceId}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e(
|
||||
'Function button action failed: ${action.type.name} - $e');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Check if a button has actions for a given wall.
|
||||
bool hasActions(String wallId, String buttonKey) {
|
||||
return _config.hasActions(wallId, buttonKey);
|
||||
}
|
||||
}
|
||||
189
copilot_keyboard/lib/data/services/state_service.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import '../../domain/entities/monitor_state.dart';
|
||||
import '../../domain/entities/alarm_state.dart';
|
||||
import '../models/bridge_event.dart';
|
||||
import 'bridge_service.dart';
|
||||
import 'alarm_service.dart';
|
||||
|
||||
/// Service for tracking overall system state (monitors + alarms)
|
||||
class StateService {
|
||||
final BridgeService _bridgeService;
|
||||
final AlarmService _alarmService;
|
||||
final Logger _logger = Logger();
|
||||
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
// Monitor state
|
||||
final _monitorStatesController =
|
||||
BehaviorSubject<Map<int, MonitorState>>.seeded({});
|
||||
|
||||
// Combined state stream (monitors with alarm flags)
|
||||
final _combinedMonitorStatesController =
|
||||
BehaviorSubject<Map<int, MonitorState>>.seeded({});
|
||||
|
||||
StateService({
|
||||
required BridgeService bridgeService,
|
||||
required AlarmService alarmService,
|
||||
}) : _bridgeService = bridgeService,
|
||||
_alarmService = alarmService;
|
||||
|
||||
/// Stream of monitor states (without alarm info)
|
||||
Stream<Map<int, MonitorState>> get monitorStates =>
|
||||
_monitorStatesController.stream;
|
||||
|
||||
/// Stream of monitor states with alarm flags
|
||||
Stream<Map<int, MonitorState>> get combinedMonitorStates =>
|
||||
_combinedMonitorStatesController.stream;
|
||||
|
||||
/// Stream of active alarms (delegated to AlarmService)
|
||||
Stream<List<AlarmState>> get activeAlarms => _alarmService.alarms;
|
||||
|
||||
/// Current monitor states
|
||||
Map<int, MonitorState> get currentMonitorStates =>
|
||||
_monitorStatesController.value;
|
||||
|
||||
/// Get state for a specific monitor
|
||||
MonitorState? getMonitorState(int viewerId) {
|
||||
return _combinedMonitorStatesController.value[viewerId];
|
||||
}
|
||||
|
||||
/// Initialize state tracking
|
||||
Future<void> initialize() async {
|
||||
// Subscribe to bridge events
|
||||
_eventSubscription = _bridgeService.eventStream.listen(_handleBridgeEvent);
|
||||
|
||||
// Subscribe to alarm changes to update monitor flags
|
||||
_alarmService.alarms.listen((_) => _updateCombinedStates());
|
||||
|
||||
_logger.i('StateService initialized');
|
||||
}
|
||||
|
||||
/// Sync initial state from all bridges
|
||||
Future<void> syncFromBridges() async {
|
||||
_logger.i('Syncing state from bridges...');
|
||||
|
||||
final connectionStatus = _bridgeService.currentConnectionStatus;
|
||||
final monitors = <int, MonitorState>{};
|
||||
|
||||
for (final entry in connectionStatus.entries) {
|
||||
if (!entry.value) continue; // Skip disconnected servers
|
||||
|
||||
try {
|
||||
final serverMonitors =
|
||||
await _bridgeService.getMonitorStates(entry.key);
|
||||
|
||||
for (final monitorJson in serverMonitors) {
|
||||
final state = MonitorState.fromJson(monitorJson);
|
||||
monitors[state.viewerId] = state;
|
||||
}
|
||||
|
||||
_logger.d(
|
||||
'Synced ${serverMonitors.length} monitors from ${entry.key}');
|
||||
} catch (e) {
|
||||
_logger.e('Failed to sync monitors from ${entry.key}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_monitorStatesController.add(monitors);
|
||||
_updateCombinedStates();
|
||||
|
||||
// Also sync alarms
|
||||
await _alarmService.queryAllAlarms();
|
||||
|
||||
_logger.i('State sync complete: ${monitors.length} monitors');
|
||||
}
|
||||
|
||||
/// Handle incoming bridge event
|
||||
void _handleBridgeEvent(BridgeEvent event) {
|
||||
if (event.isViewerConnected || event.isViewerSelectionChanged) {
|
||||
_handleViewerConnected(event);
|
||||
} else if (event.isViewerCleared) {
|
||||
_handleViewerCleared(event);
|
||||
} else if (event.isEventStarted ||
|
||||
event.isEventStopped ||
|
||||
event.isAlarmQueueNotification) {
|
||||
// Delegate alarm events to AlarmService
|
||||
_alarmService.handleAlarmEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle viewer connected event
|
||||
void _handleViewerConnected(BridgeEvent event) {
|
||||
final viewer = event.viewer;
|
||||
final channel = event.channel;
|
||||
final playMode = event.playMode;
|
||||
|
||||
if (viewer == null) return;
|
||||
|
||||
final monitors = Map<int, MonitorState>.from(_monitorStatesController.value);
|
||||
final existing = monitors[viewer];
|
||||
|
||||
monitors[viewer] = MonitorState(
|
||||
viewerId: viewer,
|
||||
currentChannel: channel ?? existing?.currentChannel ?? 0,
|
||||
playMode: PlayMode.fromValue(playMode ?? existing?.playMode.value ?? 0),
|
||||
serverId: event.serverId,
|
||||
lastUpdated: event.timestamp,
|
||||
);
|
||||
|
||||
_monitorStatesController.add(monitors);
|
||||
_updateCombinedStates();
|
||||
|
||||
_logger.d('Monitor $viewer connected to channel $channel');
|
||||
}
|
||||
|
||||
/// Handle viewer cleared event
|
||||
void _handleViewerCleared(BridgeEvent event) {
|
||||
final viewer = event.viewer;
|
||||
if (viewer == null) return;
|
||||
|
||||
final monitors = Map<int, MonitorState>.from(_monitorStatesController.value);
|
||||
final existing = monitors[viewer];
|
||||
|
||||
if (existing != null) {
|
||||
monitors[viewer] = existing.cleared();
|
||||
} else {
|
||||
monitors[viewer] = MonitorState(
|
||||
viewerId: viewer,
|
||||
currentChannel: 0,
|
||||
playMode: PlayMode.unknown,
|
||||
serverId: event.serverId,
|
||||
lastUpdated: event.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
_monitorStatesController.add(monitors);
|
||||
_updateCombinedStates();
|
||||
|
||||
_logger.d('Monitor $viewer cleared');
|
||||
}
|
||||
|
||||
/// Update combined states with alarm flags
|
||||
void _updateCombinedStates() {
|
||||
final monitors = _monitorStatesController.value;
|
||||
final combined = <int, MonitorState>{};
|
||||
|
||||
for (final entry in monitors.entries) {
|
||||
final hasAlarm = _alarmService.isMonitorBlocked(entry.key);
|
||||
combined[entry.key] = entry.value.withAlarm(hasAlarm);
|
||||
}
|
||||
|
||||
_combinedMonitorStatesController.add(combined);
|
||||
}
|
||||
|
||||
/// Check if a monitor is blocked by an alarm
|
||||
bool isMonitorBlocked(int viewerId) {
|
||||
return _alarmService.isMonitorBlocked(viewerId);
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_eventSubscription?.cancel();
|
||||
_monitorStatesController.close();
|
||||
_combinedMonitorStatesController.close();
|
||||
}
|
||||
}
|
||||
137
copilot_keyboard/lib/domain/entities/alarm_state.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Alarm state enumeration matching SDK PlcViewerAlarmState
|
||||
enum AlarmStatus {
|
||||
newAlarm(0, 'vasNewAlarm'),
|
||||
presented(1, 'vasPresented'),
|
||||
stacked(2, 'vasStacked'),
|
||||
confirmed(3, 'vasConfirmed'),
|
||||
removed(4, 'vasRemoved'),
|
||||
lastConfirmed(5, 'vasLastConfirmed'),
|
||||
lastRemoved(6, 'vasLastRemoved');
|
||||
|
||||
final int value;
|
||||
final String name;
|
||||
const AlarmStatus(this.value, this.name);
|
||||
|
||||
static AlarmStatus fromValue(int value) {
|
||||
return AlarmStatus.values.firstWhere(
|
||||
(s) => s.value == value,
|
||||
orElse: () => AlarmStatus.newAlarm,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this status blocks the monitor
|
||||
bool get blocksMonitor =>
|
||||
this == AlarmStatus.newAlarm || this == AlarmStatus.presented;
|
||||
}
|
||||
|
||||
/// State of a single alarm/event
|
||||
class AlarmState extends Equatable {
|
||||
final int eventId;
|
||||
final String eventName;
|
||||
final int typeId;
|
||||
final int foreignKey; // Camera or contact ID
|
||||
final String? serverId;
|
||||
final DateTime startedAt;
|
||||
final DateTime? stoppedAt;
|
||||
final bool isActive;
|
||||
final AlarmStatus status;
|
||||
final int? associatedMonitor;
|
||||
|
||||
const AlarmState({
|
||||
required this.eventId,
|
||||
required this.eventName,
|
||||
required this.typeId,
|
||||
required this.foreignKey,
|
||||
this.serverId,
|
||||
required this.startedAt,
|
||||
this.stoppedAt,
|
||||
required this.isActive,
|
||||
this.status = AlarmStatus.newAlarm,
|
||||
this.associatedMonitor,
|
||||
});
|
||||
|
||||
/// Check if this alarm blocks a monitor
|
||||
bool get blocksMonitor => isActive && status.blocksMonitor;
|
||||
|
||||
/// Create a stopped alarm
|
||||
AlarmState stopped() {
|
||||
return AlarmState(
|
||||
eventId: eventId,
|
||||
eventName: eventName,
|
||||
typeId: typeId,
|
||||
foreignKey: foreignKey,
|
||||
serverId: serverId,
|
||||
startedAt: startedAt,
|
||||
stoppedAt: DateTime.now(),
|
||||
isActive: false,
|
||||
status: AlarmStatus.removed,
|
||||
associatedMonitor: associatedMonitor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create alarm with updated status
|
||||
AlarmState withStatus(AlarmStatus newStatus) {
|
||||
return AlarmState(
|
||||
eventId: eventId,
|
||||
eventName: eventName,
|
||||
typeId: typeId,
|
||||
foreignKey: foreignKey,
|
||||
serverId: serverId,
|
||||
startedAt: startedAt,
|
||||
stoppedAt: stoppedAt,
|
||||
isActive: isActive,
|
||||
status: newStatus,
|
||||
associatedMonitor: associatedMonitor,
|
||||
);
|
||||
}
|
||||
|
||||
factory AlarmState.fromJson(Map<String, dynamic> json) {
|
||||
return AlarmState(
|
||||
eventId: json['event_id'] as int? ?? 0,
|
||||
eventName: json['event_name'] as String? ?? '',
|
||||
typeId: json['type_id'] as int? ?? 0,
|
||||
foreignKey: json['foreign_key'] as int? ?? 0,
|
||||
serverId: json['server_id'] as String?,
|
||||
startedAt: json['started_at'] != null
|
||||
? DateTime.parse(json['started_at'] as String)
|
||||
: DateTime.now(),
|
||||
stoppedAt: json['stopped_at'] != null
|
||||
? DateTime.parse(json['stopped_at'] as String)
|
||||
: null,
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
status: AlarmStatus.fromValue(json['status'] as int? ?? 0),
|
||||
associatedMonitor: json['associated_monitor'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'event_id': eventId,
|
||||
'event_name': eventName,
|
||||
'type_id': typeId,
|
||||
'foreign_key': foreignKey,
|
||||
'server_id': serverId,
|
||||
'started_at': startedAt.toIso8601String(),
|
||||
'stopped_at': stoppedAt?.toIso8601String(),
|
||||
'is_active': isActive,
|
||||
'status': status.value,
|
||||
'associated_monitor': associatedMonitor,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
eventId,
|
||||
eventName,
|
||||
typeId,
|
||||
foreignKey,
|
||||
serverId,
|
||||
startedAt,
|
||||
stoppedAt,
|
||||
isActive,
|
||||
status,
|
||||
associatedMonitor,
|
||||
];
|
||||
}
|
||||
123
copilot_keyboard/lib/domain/entities/camera_lock.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
/// Camera lock entity matching the coordinator's CameraLock model.
|
||||
class CameraLock {
|
||||
final int cameraId;
|
||||
final CameraLockPriority priority;
|
||||
final String ownerName;
|
||||
final DateTime ownedSince;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const CameraLock({
|
||||
required this.cameraId,
|
||||
required this.priority,
|
||||
required this.ownerName,
|
||||
required this.ownedSince,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
factory CameraLock.fromJson(Map<String, dynamic> json) {
|
||||
return CameraLock(
|
||||
cameraId: json['cameraId'] as int? ?? json['CameraId'] as int? ?? 0,
|
||||
priority: CameraLockPriority.fromString(
|
||||
json['priority'] as String? ?? json['Priority'] as String? ?? 'Low'),
|
||||
ownerName:
|
||||
json['ownerName'] as String? ?? json['OwnerName'] as String? ?? '',
|
||||
ownedSince: DateTime.parse(
|
||||
json['ownedSince'] as String? ?? json['OwnedSince'] as String? ?? ''),
|
||||
expiresAt: DateTime.parse(
|
||||
json['expiresAt'] as String? ?? json['ExpiresAt'] as String? ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
Duration get timeRemaining {
|
||||
final remaining = expiresAt.difference(DateTime.now().toUtc());
|
||||
return remaining.isNegative ? Duration.zero : remaining;
|
||||
}
|
||||
|
||||
bool get isExpiringSoon => timeRemaining.inSeconds <= 60;
|
||||
bool get isExpired => timeRemaining == Duration.zero;
|
||||
bool isOwnedBy(String keyboardId) =>
|
||||
ownerName.toLowerCase() == keyboardId.toLowerCase();
|
||||
}
|
||||
|
||||
enum CameraLockPriority {
|
||||
none,
|
||||
high,
|
||||
low;
|
||||
|
||||
static CameraLockPriority fromString(String value) {
|
||||
switch (value.toLowerCase()) {
|
||||
case 'high':
|
||||
return CameraLockPriority.high;
|
||||
case 'low':
|
||||
return CameraLockPriority.low;
|
||||
default:
|
||||
return CameraLockPriority.none;
|
||||
}
|
||||
}
|
||||
|
||||
String get name {
|
||||
switch (this) {
|
||||
case CameraLockPriority.high:
|
||||
return 'High';
|
||||
case CameraLockPriority.low:
|
||||
return 'Low';
|
||||
case CameraLockPriority.none:
|
||||
return 'None';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CameraLockNotificationType {
|
||||
acquired,
|
||||
takenOver,
|
||||
confirmTakeOver,
|
||||
confirmed,
|
||||
rejected,
|
||||
expireSoon,
|
||||
unlocked;
|
||||
|
||||
static CameraLockNotificationType fromString(String value) {
|
||||
switch (value) {
|
||||
case 'Acquired':
|
||||
return CameraLockNotificationType.acquired;
|
||||
case 'TakenOver':
|
||||
return CameraLockNotificationType.takenOver;
|
||||
case 'ConfirmTakeOver':
|
||||
return CameraLockNotificationType.confirmTakeOver;
|
||||
case 'Confirmed':
|
||||
return CameraLockNotificationType.confirmed;
|
||||
case 'Rejected':
|
||||
return CameraLockNotificationType.rejected;
|
||||
case 'ExpireSoon':
|
||||
return CameraLockNotificationType.expireSoon;
|
||||
case 'Unlocked':
|
||||
return CameraLockNotificationType.unlocked;
|
||||
default:
|
||||
return CameraLockNotificationType.acquired;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock notification from the coordinator (sent via WebSocket)
|
||||
class CameraLockNotification {
|
||||
final CameraLockNotificationType type;
|
||||
final int cameraId;
|
||||
final String copilotName;
|
||||
|
||||
const CameraLockNotification({
|
||||
required this.type,
|
||||
required this.cameraId,
|
||||
required this.copilotName,
|
||||
});
|
||||
|
||||
factory CameraLockNotification.fromJson(Map<String, dynamic> json) {
|
||||
return CameraLockNotification(
|
||||
type: CameraLockNotificationType.fromString(
|
||||
json['type'] as String? ?? json['Type'] as String? ?? ''),
|
||||
cameraId: json['cameraId'] as int? ?? json['CameraId'] as int? ?? 0,
|
||||
copilotName: json['copilotName'] as String? ??
|
||||
json['CopilotName'] as String? ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/// Configuration for function buttons (F1-F7) per wall.
|
||||
/// Loaded from the "functionButtons" section of the config file.
|
||||
class FunctionButtonConfig {
|
||||
final Map<String, Map<String, List<FunctionButtonAction>>> walls;
|
||||
|
||||
const FunctionButtonConfig({this.walls = const {}});
|
||||
|
||||
/// Get actions for a specific wall and button.
|
||||
List<FunctionButtonAction> getActions(String wallId, String buttonKey) {
|
||||
return walls[wallId]?[buttonKey] ?? [];
|
||||
}
|
||||
|
||||
/// Check if a button has any actions configured for this wall.
|
||||
bool hasActions(String wallId, String buttonKey) {
|
||||
return getActions(wallId, buttonKey).isNotEmpty;
|
||||
}
|
||||
|
||||
factory FunctionButtonConfig.fromJson(Map<String, dynamic> json) {
|
||||
final wallsJson = json['walls'] as Map<String, dynamic>? ?? {};
|
||||
final walls = <String, Map<String, List<FunctionButtonAction>>>{};
|
||||
|
||||
for (final wallEntry in wallsJson.entries) {
|
||||
final buttonsJson = wallEntry.value as Map<String, dynamic>? ?? {};
|
||||
final buttons = <String, List<FunctionButtonAction>>{};
|
||||
|
||||
for (final buttonEntry in buttonsJson.entries) {
|
||||
final actionsJson = buttonEntry.value as List<dynamic>? ?? [];
|
||||
buttons[buttonEntry.key] = actionsJson
|
||||
.map((a) =>
|
||||
FunctionButtonAction.fromJson(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
walls[wallEntry.key] = buttons;
|
||||
}
|
||||
|
||||
return FunctionButtonConfig(walls: walls);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single action triggered by a function button press.
|
||||
class FunctionButtonAction {
|
||||
final FunctionButtonActionType type;
|
||||
final int viewerId;
|
||||
final int sourceId;
|
||||
|
||||
const FunctionButtonAction({
|
||||
required this.type,
|
||||
required this.viewerId,
|
||||
required this.sourceId,
|
||||
});
|
||||
|
||||
factory FunctionButtonAction.fromJson(Map<String, dynamic> json) {
|
||||
return FunctionButtonAction(
|
||||
type: FunctionButtonActionType.fromString(
|
||||
json['actionType'] as String? ?? ''),
|
||||
viewerId: json['viewerId'] as int? ?? 0,
|
||||
sourceId: json['sourceId'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum FunctionButtonActionType {
|
||||
crossSwitch,
|
||||
sequenceStart;
|
||||
|
||||
static FunctionButtonActionType fromString(String value) {
|
||||
switch (value.toLowerCase()) {
|
||||
case 'crossswitch':
|
||||
return FunctionButtonActionType.crossSwitch;
|
||||
case 'sequencestart':
|
||||
return FunctionButtonActionType.sequenceStart;
|
||||
default:
|
||||
return FunctionButtonActionType.crossSwitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
127
copilot_keyboard/lib/domain/entities/monitor_state.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Play mode enumeration matching SDK values
|
||||
enum PlayMode {
|
||||
unknown(0),
|
||||
playStop(1),
|
||||
playForward(2),
|
||||
playBackward(3),
|
||||
fastForward(4),
|
||||
fastBackward(5),
|
||||
stepForward(6),
|
||||
stepBackward(7),
|
||||
playBOD(8),
|
||||
playEOD(9),
|
||||
quasiLive(10),
|
||||
live(11),
|
||||
nextEvent(12),
|
||||
prevEvent(13),
|
||||
peekLivePicture(14),
|
||||
nextDetectedMotion(17),
|
||||
prevDetectedMotion(18);
|
||||
|
||||
final int value;
|
||||
const PlayMode(this.value);
|
||||
|
||||
static PlayMode fromValue(int value) {
|
||||
return PlayMode.values.firstWhere(
|
||||
(m) => m.value == value,
|
||||
orElse: () => PlayMode.unknown,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// State of a single monitor/viewer
|
||||
class MonitorState extends Equatable {
|
||||
final int viewerId;
|
||||
final int currentChannel;
|
||||
final PlayMode playMode;
|
||||
final String? serverId;
|
||||
final DateTime lastUpdated;
|
||||
final bool hasAlarm;
|
||||
|
||||
const MonitorState({
|
||||
required this.viewerId,
|
||||
required this.currentChannel,
|
||||
required this.playMode,
|
||||
this.serverId,
|
||||
required this.lastUpdated,
|
||||
this.hasAlarm = false,
|
||||
});
|
||||
|
||||
/// Check if monitor is currently displaying a camera
|
||||
bool get isActive => currentChannel > 0;
|
||||
|
||||
/// Check if monitor is in live mode
|
||||
bool get isLive => playMode == PlayMode.live || playMode == PlayMode.quasiLive;
|
||||
|
||||
/// Create a cleared state
|
||||
MonitorState cleared() {
|
||||
return MonitorState(
|
||||
viewerId: viewerId,
|
||||
currentChannel: 0,
|
||||
playMode: PlayMode.unknown,
|
||||
serverId: serverId,
|
||||
lastUpdated: DateTime.now(),
|
||||
hasAlarm: hasAlarm,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create updated state with new camera
|
||||
MonitorState withCamera(int channel, PlayMode mode) {
|
||||
return MonitorState(
|
||||
viewerId: viewerId,
|
||||
currentChannel: channel,
|
||||
playMode: mode,
|
||||
serverId: serverId,
|
||||
lastUpdated: DateTime.now(),
|
||||
hasAlarm: hasAlarm,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create state with alarm flag updated
|
||||
MonitorState withAlarm(bool alarm) {
|
||||
return MonitorState(
|
||||
viewerId: viewerId,
|
||||
currentChannel: currentChannel,
|
||||
playMode: playMode,
|
||||
serverId: serverId,
|
||||
lastUpdated: lastUpdated,
|
||||
hasAlarm: alarm,
|
||||
);
|
||||
}
|
||||
|
||||
factory MonitorState.fromJson(Map<String, dynamic> json) {
|
||||
return MonitorState(
|
||||
viewerId: json['viewer_id'] as int? ?? 0,
|
||||
currentChannel: json['current_channel'] as int? ?? 0,
|
||||
playMode: PlayMode.fromValue(json['play_mode'] as int? ?? 0),
|
||||
serverId: json['server_id'] as String?,
|
||||
lastUpdated: json['last_updated'] != null
|
||||
? DateTime.parse(json['last_updated'] as String)
|
||||
: DateTime.now(),
|
||||
hasAlarm: json['has_alarm'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'viewer_id': viewerId,
|
||||
'current_channel': currentChannel,
|
||||
'play_mode': playMode.value,
|
||||
'server_id': serverId,
|
||||
'last_updated': lastUpdated.toIso8601String(),
|
||||
'has_alarm': hasAlarm,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
viewerId,
|
||||
currentChannel,
|
||||
playMode,
|
||||
serverId,
|
||||
lastUpdated,
|
||||
hasAlarm,
|
||||
];
|
||||
}
|
||||
72
copilot_keyboard/lib/domain/entities/sequence.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
/// Sequence definition loaded from the coordinator.
|
||||
class SequenceDefinition {
|
||||
final int id;
|
||||
final String name;
|
||||
final int categoryId;
|
||||
final List<int> cameras;
|
||||
final int intervalSeconds;
|
||||
|
||||
const SequenceDefinition({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.categoryId,
|
||||
required this.cameras,
|
||||
required this.intervalSeconds,
|
||||
});
|
||||
|
||||
factory SequenceDefinition.fromJson(Map<String, dynamic> json) {
|
||||
return SequenceDefinition(
|
||||
id: json['id'] as int? ?? json['Id'] as int? ?? 0,
|
||||
name: json['name'] as String? ?? json['Name'] as String? ?? '',
|
||||
categoryId:
|
||||
json['categoryId'] as int? ?? json['CategoryId'] as int? ?? 0,
|
||||
cameras: (json['cameras'] as List<dynamic>? ??
|
||||
json['Cameras'] as List<dynamic>? ??
|
||||
[])
|
||||
.cast<int>(),
|
||||
intervalSeconds: json['intervalSeconds'] as int? ??
|
||||
json['IntervalSeconds'] as int? ??
|
||||
5,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequence category for grouping sequences.
|
||||
class SequenceCategory {
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
const SequenceCategory({required this.id, required this.name});
|
||||
|
||||
factory SequenceCategory.fromJson(Map<String, dynamic> json) {
|
||||
return SequenceCategory(
|
||||
id: json['id'] as int? ?? json['Id'] as int? ?? 0,
|
||||
name: json['name'] as String? ?? json['Name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A sequence currently running on a viewer.
|
||||
class RunningSequence {
|
||||
final int viewerId;
|
||||
final int sequenceId;
|
||||
final DateTime startedAt;
|
||||
|
||||
const RunningSequence({
|
||||
required this.viewerId,
|
||||
required this.sequenceId,
|
||||
required this.startedAt,
|
||||
});
|
||||
|
||||
factory RunningSequence.fromJson(Map<String, dynamic> json) {
|
||||
return RunningSequence(
|
||||
viewerId: json['viewerId'] as int? ?? json['ViewerId'] as int? ?? 0,
|
||||
sequenceId:
|
||||
json['sequenceId'] as int? ?? json['SequenceId'] as int? ?? 0,
|
||||
startedAt: DateTime.tryParse(json['startedAt'] as String? ??
|
||||
json['StartedAt'] as String? ??
|
||||
'') ??
|
||||
DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
94
copilot_keyboard/lib/domain/entities/server_config.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Server type enumeration
|
||||
enum ServerType { geviscope, gcore, geviserver }
|
||||
|
||||
/// Configuration for a single recording server
|
||||
class ServerConfig extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final ServerType type;
|
||||
final bool enabled;
|
||||
final String address;
|
||||
final int port;
|
||||
final String username;
|
||||
final String bridgeUrl;
|
||||
final String? websocketUrl;
|
||||
final int cameraRangeStart;
|
||||
final int cameraRangeEnd;
|
||||
final int monitorRangeStart;
|
||||
final int monitorRangeEnd;
|
||||
|
||||
const ServerConfig({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.enabled,
|
||||
required this.address,
|
||||
required this.port,
|
||||
required this.username,
|
||||
required this.bridgeUrl,
|
||||
this.websocketUrl,
|
||||
required this.cameraRangeStart,
|
||||
required this.cameraRangeEnd,
|
||||
required this.monitorRangeStart,
|
||||
required this.monitorRangeEnd,
|
||||
});
|
||||
|
||||
/// Check if this server owns a camera ID
|
||||
bool ownsCamera(int cameraId) {
|
||||
return cameraId >= cameraRangeStart && cameraId <= cameraRangeEnd;
|
||||
}
|
||||
|
||||
/// Check if this server owns a monitor ID
|
||||
bool ownsMonitor(int monitorId) {
|
||||
return monitorId >= monitorRangeStart && monitorId <= monitorRangeEnd;
|
||||
}
|
||||
|
||||
factory ServerConfig.fromJson(Map<String, dynamic> json) {
|
||||
final typeStr = json['type'] as String;
|
||||
final type = ServerType.values.firstWhere(
|
||||
(t) => t.name == typeStr,
|
||||
orElse: () => ServerType.geviscope,
|
||||
);
|
||||
|
||||
final connection = json['connection'] as Map<String, dynamic>? ?? {};
|
||||
final bridge = json['bridge'] as Map<String, dynamic>? ?? {};
|
||||
final resources = json['resources'] as Map<String, dynamic>? ?? {};
|
||||
final cameraRange = resources['cameraRange'] as Map<String, dynamic>? ?? {};
|
||||
final monitorRange = resources['monitorRange'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return ServerConfig(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String? ?? json['id'] as String,
|
||||
type: type,
|
||||
enabled: json['enabled'] as bool? ?? true,
|
||||
address: connection['address'] as String? ?? '',
|
||||
port: connection['port'] as int? ?? 7700,
|
||||
username: connection['username'] as String? ?? '',
|
||||
bridgeUrl: bridge['url'] as String? ?? '',
|
||||
websocketUrl: bridge['websocket'] as String?,
|
||||
cameraRangeStart: cameraRange['start'] as int? ?? 0,
|
||||
cameraRangeEnd: cameraRange['end'] as int? ?? 0,
|
||||
monitorRangeStart: monitorRange['start'] as int? ?? 0,
|
||||
monitorRangeEnd: monitorRange['end'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
enabled,
|
||||
address,
|
||||
port,
|
||||
username,
|
||||
bridgeUrl,
|
||||
websocketUrl,
|
||||
cameraRangeStart,
|
||||
cameraRangeEnd,
|
||||
monitorRangeStart,
|
||||
monitorRangeEnd,
|
||||
];
|
||||
}
|
||||
439
copilot_keyboard/lib/domain/entities/wall_config.dart
Normal file
@@ -0,0 +1,439 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Configuration for a physical monitor that can display 1-4 viewers
|
||||
class PhysicalMonitor extends Equatable {
|
||||
final int id;
|
||||
final String? name;
|
||||
final List<int> viewerIds; // 1-4 viewer IDs in this physical monitor
|
||||
final bool isQuadView;
|
||||
final int row; // Grid row position (1-based)
|
||||
final int col; // Grid column position (1-based)
|
||||
final int rowSpan; // How many rows this monitor spans
|
||||
final int colSpan; // How many columns this monitor spans
|
||||
|
||||
const PhysicalMonitor({
|
||||
required this.id,
|
||||
this.name,
|
||||
required this.viewerIds,
|
||||
this.isQuadView = false,
|
||||
this.row = 1,
|
||||
this.col = 1,
|
||||
this.rowSpan = 1,
|
||||
this.colSpan = 1,
|
||||
});
|
||||
|
||||
/// Whether this monitor has multiple viewers (quad view)
|
||||
bool get hasMultipleViewers => viewerIds.length > 1;
|
||||
|
||||
/// Get the primary viewer ID (first one)
|
||||
int get primaryViewerId => viewerIds.isNotEmpty ? viewerIds.first : 0;
|
||||
|
||||
factory PhysicalMonitor.fromJson(Map<String, dynamic> json) {
|
||||
final viewers = json['viewer_ids'] as List<dynamic>?;
|
||||
return PhysicalMonitor(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String?,
|
||||
viewerIds: viewers?.map((v) => v as int).toList() ?? [],
|
||||
isQuadView: json['is_quad_view'] as bool? ?? false,
|
||||
row: json['row'] as int? ?? 1,
|
||||
col: json['col'] as int? ?? 1,
|
||||
rowSpan: json['row_span'] as int? ?? 1,
|
||||
colSpan: json['col_span'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'viewer_ids': viewerIds,
|
||||
'is_quad_view': isQuadView,
|
||||
'row': row,
|
||||
'col': col,
|
||||
'row_span': rowSpan,
|
||||
'col_span': colSpan,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, viewerIds, isQuadView, row, col, rowSpan, colSpan];
|
||||
}
|
||||
|
||||
/// A section of the video wall (e.g., "Vrchní část", "Pravá část")
|
||||
class WallSection extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final List<PhysicalMonitor> monitors;
|
||||
final int columns; // Grid layout columns for this section
|
||||
final int rows; // Grid layout rows for this section
|
||||
|
||||
const WallSection({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.monitors,
|
||||
this.columns = 8,
|
||||
this.rows = 4,
|
||||
});
|
||||
|
||||
factory WallSection.fromJson(Map<String, dynamic> json) {
|
||||
final monitors = json['monitors'] as List<dynamic>?;
|
||||
return WallSection(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
monitors: monitors
|
||||
?.map((m) => PhysicalMonitor.fromJson(m as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
columns: json['columns'] as int? ?? 8,
|
||||
rows: json['rows'] as int? ?? 4,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'monitors': monitors.map((m) => m.toJson()).toList(),
|
||||
'columns': columns,
|
||||
'rows': rows,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, monitors, columns, rows];
|
||||
}
|
||||
|
||||
/// Complete wall configuration with all sections
|
||||
class WallConfig extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final List<WallSection> sections;
|
||||
final List<int> alarmMonitorIds; // Monitor IDs designated for alarms
|
||||
|
||||
const WallConfig({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.sections,
|
||||
this.alarmMonitorIds = const [],
|
||||
});
|
||||
|
||||
/// Get all viewer IDs across all sections
|
||||
List<int> get allViewerIds {
|
||||
final ids = <int>[];
|
||||
for (final section in sections) {
|
||||
for (final monitor in section.monitors) {
|
||||
ids.addAll(monitor.viewerIds);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Get all physical monitors across all sections
|
||||
List<PhysicalMonitor> get allMonitors {
|
||||
final monitors = <PhysicalMonitor>[];
|
||||
for (final section in sections) {
|
||||
monitors.addAll(section.monitors);
|
||||
}
|
||||
return monitors;
|
||||
}
|
||||
|
||||
/// Find physical monitor containing a viewer ID
|
||||
PhysicalMonitor? findMonitorByViewerId(int viewerId) {
|
||||
for (final section in sections) {
|
||||
for (final monitor in section.monitors) {
|
||||
if (monitor.viewerIds.contains(viewerId)) {
|
||||
return monitor;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Find section containing a viewer ID
|
||||
WallSection? findSectionByViewerId(int viewerId) {
|
||||
for (final section in sections) {
|
||||
for (final monitor in section.monitors) {
|
||||
if (monitor.viewerIds.contains(viewerId)) {
|
||||
return section;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
factory WallConfig.fromJson(Map<String, dynamic> json) {
|
||||
final sections = json['sections'] as List<dynamic>?;
|
||||
final alarmIds = json['alarm_monitor_ids'] as List<dynamic>?;
|
||||
return WallConfig(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
sections: sections
|
||||
?.map((s) => WallSection.fromJson(s as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
alarmMonitorIds: alarmIds?.map((i) => i as int).toList() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'sections': sections.map((s) => s.toJson()).toList(),
|
||||
'alarm_monitor_ids': alarmMonitorIds,
|
||||
};
|
||||
|
||||
/// Create sample wall config matching D6 app structure
|
||||
factory WallConfig.sample() {
|
||||
return WallConfig(
|
||||
id: 'wall_1',
|
||||
name: 'Hlavní videostěna',
|
||||
sections: [
|
||||
// Vrchní část - 8 columns x 4 rows irregular grid
|
||||
WallSection(
|
||||
id: 'top',
|
||||
name: 'Vrchní část',
|
||||
columns: 8,
|
||||
rows: 4,
|
||||
monitors: [
|
||||
// Row 1-2: Three quad monitors
|
||||
PhysicalMonitor(
|
||||
id: 1,
|
||||
viewerIds: [210, 211, 212, 213],
|
||||
isQuadView: true,
|
||||
row: 1, col: 1, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 2,
|
||||
viewerIds: [214, 215, 216, 217],
|
||||
isQuadView: true,
|
||||
row: 1, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 3,
|
||||
viewerIds: [1001, 1002, 1003, 1004],
|
||||
isQuadView: true,
|
||||
row: 1, col: 5, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
// Row 4: Single monitor
|
||||
PhysicalMonitor(
|
||||
id: 4,
|
||||
viewerIds: [222],
|
||||
row: 4, col: 2, rowSpan: 1, colSpan: 1,
|
||||
),
|
||||
// Row 3-4: Three quad monitors
|
||||
PhysicalMonitor(
|
||||
id: 5,
|
||||
viewerIds: [223, 224, 225, 226],
|
||||
isQuadView: true,
|
||||
row: 3, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 6,
|
||||
viewerIds: [227, 228, 229, 230],
|
||||
isQuadView: true,
|
||||
row: 3, col: 5, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 7,
|
||||
viewerIds: [231, 232, 233, 234],
|
||||
isQuadView: true,
|
||||
row: 3, col: 7, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Levá část - 7 columns x 6 rows
|
||||
WallSection(
|
||||
id: 'left',
|
||||
name: 'Levá část',
|
||||
columns: 7,
|
||||
rows: 6,
|
||||
monitors: [
|
||||
// Row 1-2: 3x2 monitor + two 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 8,
|
||||
viewerIds: [88, 89, 90, 91, 92, 93],
|
||||
row: 1, col: 1, rowSpan: 2, colSpan: 3,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 9,
|
||||
viewerIds: [40, 41, 42, 43],
|
||||
isQuadView: true,
|
||||
row: 1, col: 4, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 10,
|
||||
viewerIds: [44, 45, 46, 47],
|
||||
isQuadView: true,
|
||||
row: 1, col: 6, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
// Row 3-4: Two 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 11,
|
||||
viewerIds: [48, 49, 50, 51],
|
||||
isQuadView: true,
|
||||
row: 3, col: 4, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 12,
|
||||
viewerIds: [52, 53, 54, 55],
|
||||
isQuadView: true,
|
||||
row: 3, col: 6, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
// Row 5-6: Two 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 13,
|
||||
viewerIds: [56, 57, 58, 59],
|
||||
isQuadView: true,
|
||||
row: 5, col: 4, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 14,
|
||||
viewerIds: [60, 61, 62, 63],
|
||||
isQuadView: true,
|
||||
row: 5, col: 6, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Střed stěny - 8 columns x 4 rows
|
||||
WallSection(
|
||||
id: 'center',
|
||||
name: 'Střed stěny',
|
||||
columns: 8,
|
||||
rows: 4,
|
||||
monitors: [
|
||||
// Row 1-2: Quad + 4 tall single monitors
|
||||
PhysicalMonitor(
|
||||
id: 15,
|
||||
viewerIds: [14, 15, 16, 17],
|
||||
isQuadView: true,
|
||||
row: 1, col: 2, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(id: 16, viewerIds: [18], row: 1, col: 4, rowSpan: 2, colSpan: 1),
|
||||
PhysicalMonitor(id: 17, viewerIds: [19], row: 1, col: 5, rowSpan: 2, colSpan: 1),
|
||||
PhysicalMonitor(id: 18, viewerIds: [20], row: 1, col: 6, rowSpan: 2, colSpan: 1),
|
||||
PhysicalMonitor(id: 19, viewerIds: [21], row: 1, col: 7, rowSpan: 2, colSpan: 1),
|
||||
// Row 3-4: Four 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 20,
|
||||
viewerIds: [24, 25, 32, 33],
|
||||
isQuadView: true,
|
||||
row: 3, col: 1, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 21,
|
||||
viewerIds: [26, 27, 34, 35],
|
||||
isQuadView: true,
|
||||
row: 3, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 22,
|
||||
viewerIds: [28, 29, 36, 37],
|
||||
isQuadView: true,
|
||||
row: 3, col: 5, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 23,
|
||||
viewerIds: [30, 31, 38, 39],
|
||||
isQuadView: true,
|
||||
row: 3, col: 7, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Pravá část - 7 columns x 6 rows
|
||||
WallSection(
|
||||
id: 'right',
|
||||
name: 'Pravá část',
|
||||
columns: 7,
|
||||
rows: 6,
|
||||
monitors: [
|
||||
// Row 1-2: Two 2x2 quads + 3x2 monitor
|
||||
PhysicalMonitor(
|
||||
id: 24,
|
||||
viewerIds: [64, 65, 66, 67],
|
||||
isQuadView: true,
|
||||
row: 1, col: 1, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 25,
|
||||
viewerIds: [68, 69, 70, 71],
|
||||
isQuadView: true,
|
||||
row: 1, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 26,
|
||||
viewerIds: [94, 95, 96, 97, 98, 99],
|
||||
row: 1, col: 5, rowSpan: 2, colSpan: 3,
|
||||
),
|
||||
// Row 3-4: Two 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 27,
|
||||
viewerIds: [72, 73, 74, 75],
|
||||
isQuadView: true,
|
||||
row: 3, col: 1, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 28,
|
||||
viewerIds: [76, 77, 78, 79],
|
||||
isQuadView: true,
|
||||
row: 3, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
// Row 5-6: Two 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 29,
|
||||
viewerIds: [80, 81, 82, 83],
|
||||
isQuadView: true,
|
||||
row: 5, col: 1, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 30,
|
||||
viewerIds: [84, 85, 86, 87],
|
||||
isQuadView: true,
|
||||
row: 5, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Stupínek - 8 columns x 4 rows
|
||||
WallSection(
|
||||
id: 'bottom',
|
||||
name: 'Stupínek',
|
||||
columns: 8,
|
||||
rows: 4,
|
||||
monitors: [
|
||||
// Row 1-2: Three 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 31,
|
||||
viewerIds: [183, 184, 185, 186],
|
||||
isQuadView: true,
|
||||
row: 1, col: 1, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 32,
|
||||
viewerIds: [187, 188, 189, 190],
|
||||
isQuadView: true,
|
||||
row: 1, col: 3, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 33,
|
||||
viewerIds: [191, 192, 193, 194],
|
||||
isQuadView: true,
|
||||
row: 1, col: 5, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
// Row 3-4: Two 2x2 quads
|
||||
PhysicalMonitor(
|
||||
id: 34,
|
||||
viewerIds: [195, 196, 197, 198],
|
||||
isQuadView: true,
|
||||
row: 3, col: 5, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
PhysicalMonitor(
|
||||
id: 35,
|
||||
viewerIds: [199, 200, 201, 202],
|
||||
isQuadView: true,
|
||||
row: 3, col: 7, rowSpan: 2, colSpan: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
alarmMonitorIds: [222, 223, 224], // Example alarm monitors
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, sections, alarmMonitorIds];
|
||||
}
|
||||
123
copilot_keyboard/lib/injection_container.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import 'config/app_config.dart';
|
||||
import 'data/services/bridge_service.dart';
|
||||
import 'data/services/alarm_service.dart';
|
||||
import 'data/services/coordination_service.dart';
|
||||
import 'data/services/function_button_service.dart';
|
||||
import 'data/services/state_service.dart';
|
||||
import 'presentation/blocs/connection/connection_bloc.dart';
|
||||
import 'presentation/blocs/camera/camera_bloc.dart';
|
||||
import 'presentation/blocs/monitor/monitor_bloc.dart';
|
||||
import 'presentation/blocs/ptz/ptz_bloc.dart';
|
||||
import 'presentation/blocs/alarm/alarm_bloc.dart';
|
||||
import 'presentation/blocs/lock/lock_bloc.dart';
|
||||
import 'presentation/blocs/sequence/sequence_bloc.dart';
|
||||
import 'presentation/blocs/wall/wall_bloc.dart';
|
||||
|
||||
final sl = GetIt.instance;
|
||||
|
||||
/// Initialize all dependencies
|
||||
Future<void> initializeDependencies() async {
|
||||
// Config
|
||||
final config = await AppConfig.load();
|
||||
sl.registerSingleton<AppConfig>(config);
|
||||
|
||||
// Services
|
||||
sl.registerLazySingleton<BridgeService>(() => BridgeService());
|
||||
sl.registerLazySingleton<AlarmService>(() => AlarmService());
|
||||
sl.registerLazySingleton<StateService>(() => StateService(
|
||||
bridgeService: sl<BridgeService>(),
|
||||
alarmService: sl<AlarmService>(),
|
||||
));
|
||||
sl.registerLazySingleton<CoordinationService>(() => CoordinationService());
|
||||
sl.registerLazySingleton<FunctionButtonService>(() => FunctionButtonService(
|
||||
bridgeService: sl<BridgeService>(),
|
||||
coordinationService: sl<CoordinationService>(),
|
||||
));
|
||||
|
||||
// BLoCs
|
||||
sl.registerFactory<ConnectionBloc>(() => ConnectionBloc(
|
||||
bridgeService: sl<BridgeService>(),
|
||||
config: sl<AppConfig>(),
|
||||
));
|
||||
|
||||
sl.registerFactory<CameraBloc>(() => CameraBloc(
|
||||
bridgeService: sl<BridgeService>(),
|
||||
config: sl<AppConfig>(),
|
||||
));
|
||||
|
||||
sl.registerFactory<MonitorBloc>(() => MonitorBloc(
|
||||
stateService: sl<StateService>(),
|
||||
config: sl<AppConfig>(),
|
||||
));
|
||||
|
||||
sl.registerFactory<PtzBloc>(() => PtzBloc(
|
||||
bridgeService: sl<BridgeService>(),
|
||||
coordinationService: sl<CoordinationService>(),
|
||||
));
|
||||
|
||||
sl.registerFactory<AlarmBloc>(() => AlarmBloc(
|
||||
alarmService: sl<AlarmService>(),
|
||||
stateService: sl<StateService>(),
|
||||
));
|
||||
|
||||
sl.registerFactory<LockBloc>(() => LockBloc(
|
||||
coordinationService: sl<CoordinationService>(),
|
||||
keyboardId: sl<AppConfig>().keyboardId,
|
||||
));
|
||||
|
||||
sl.registerFactory<SequenceBloc>(() => SequenceBloc(
|
||||
coordinationService: sl<CoordinationService>(),
|
||||
));
|
||||
|
||||
sl.registerFactory<WallBloc>(() => WallBloc(
|
||||
bridgeService: sl<BridgeService>(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Initialize services (call after dependencies are set up)
|
||||
Future<void> initializeServices() async {
|
||||
final config = sl<AppConfig>();
|
||||
final bridgeService = sl<BridgeService>();
|
||||
final alarmService = sl<AlarmService>();
|
||||
final stateService = sl<StateService>();
|
||||
|
||||
final coordinationService = sl<CoordinationService>();
|
||||
|
||||
// Initialize services with config
|
||||
await bridgeService.initialize(config.servers);
|
||||
await alarmService.initialize(config.servers);
|
||||
await stateService.initialize();
|
||||
await coordinationService.initialize(config.coordinatorUrl, config.keyboardId);
|
||||
|
||||
// Load function button config
|
||||
sl<FunctionButtonService>().loadConfig(config.functionButtons);
|
||||
|
||||
// Wire reconnection callback: resync state when a bridge comes back online
|
||||
bridgeService.onReconnected = (serverId) {
|
||||
stateService.syncFromBridges();
|
||||
};
|
||||
|
||||
// Connect to all bridges
|
||||
await bridgeService.connectAll();
|
||||
|
||||
// Sync initial state
|
||||
await stateService.syncFromBridges();
|
||||
|
||||
// Start periodic alarm sync
|
||||
alarmService.startPeriodicSync(
|
||||
Duration(seconds: config.alarmSyncIntervalSeconds),
|
||||
);
|
||||
|
||||
// Connect to coordinator (non-blocking, auto-reconnects)
|
||||
coordinationService.connect();
|
||||
}
|
||||
|
||||
/// Dispose all services
|
||||
void disposeServices() {
|
||||
sl<CoordinationService>().dispose();
|
||||
sl<AlarmService>().dispose();
|
||||
sl<StateService>().dispose();
|
||||
sl<BridgeService>().dispose();
|
||||
}
|
||||
28
copilot_keyboard/lib/main.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'injection_container.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final logger = Logger();
|
||||
|
||||
try {
|
||||
// Initialize dependencies
|
||||
logger.i('Initializing dependencies...');
|
||||
await initializeDependencies();
|
||||
|
||||
// Initialize services and connect to bridges
|
||||
logger.i('Initializing services...');
|
||||
await initializeServices();
|
||||
|
||||
logger.i('Starting COPILOT Keyboard app...');
|
||||
} catch (e, stackTrace) {
|
||||
logger.e('Initialization failed', error: e, stackTrace: stackTrace);
|
||||
// Continue anyway - app will show disconnected state
|
||||
}
|
||||
|
||||
runApp(const CopilotKeyboardApp());
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/services/alarm_service.dart';
|
||||
import '../../../data/services/state_service.dart';
|
||||
import 'alarm_event.dart';
|
||||
import 'alarm_state.dart';
|
||||
|
||||
class AlarmBloc extends Bloc<AlarmEvent, AlarmBlocState> {
|
||||
final AlarmService _alarmService;
|
||||
final StateService _stateService;
|
||||
StreamSubscription? _alarmSubscription;
|
||||
|
||||
AlarmBloc({
|
||||
required AlarmService alarmService,
|
||||
required StateService stateService,
|
||||
}) : _alarmService = alarmService,
|
||||
_stateService = stateService,
|
||||
super(const AlarmBlocState()) {
|
||||
on<RefreshAlarms>(_onRefreshAlarms);
|
||||
on<AlarmsUpdated>(_onAlarmsUpdated);
|
||||
on<AcknowledgeAlarm>(_onAcknowledgeAlarm);
|
||||
|
||||
// Subscribe to alarm changes
|
||||
_alarmSubscription = _alarmService.alarms.listen((alarms) {
|
||||
add(AlarmsUpdated(alarms));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onRefreshAlarms(
|
||||
RefreshAlarms event,
|
||||
Emitter<AlarmBlocState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true, error: null));
|
||||
|
||||
try {
|
||||
await _alarmService.queryAllAlarms();
|
||||
emit(state.copyWith(isLoading: false, lastSync: DateTime.now()));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to refresh alarms: $e',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onAlarmsUpdated(
|
||||
AlarmsUpdated event,
|
||||
Emitter<AlarmBlocState> emit,
|
||||
) {
|
||||
// Filter to only active alarms for display
|
||||
final activeAlarms = event.alarms.where((a) => a.isActive).toList();
|
||||
|
||||
// Sort by start time (newest first)
|
||||
activeAlarms.sort((a, b) => b.startedAt.compareTo(a.startedAt));
|
||||
|
||||
emit(state.copyWith(
|
||||
activeAlarms: activeAlarms,
|
||||
lastSync: DateTime.now(),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _onAcknowledgeAlarm(
|
||||
AcknowledgeAlarm event,
|
||||
Emitter<AlarmBlocState> emit,
|
||||
) async {
|
||||
// Alarm acknowledgment would be implemented here
|
||||
// This would call the bridge to acknowledge the alarm
|
||||
// For now, just log that we received the event
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_alarmSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../domain/entities/alarm_state.dart';
|
||||
|
||||
abstract class AlarmEvent extends Equatable {
|
||||
const AlarmEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Refresh alarms from server
|
||||
class RefreshAlarms extends AlarmEvent {
|
||||
const RefreshAlarms();
|
||||
}
|
||||
|
||||
/// Alarms updated (internal)
|
||||
class AlarmsUpdated extends AlarmEvent {
|
||||
final List<AlarmState> alarms;
|
||||
|
||||
const AlarmsUpdated(this.alarms);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [alarms];
|
||||
}
|
||||
|
||||
/// Acknowledge an alarm
|
||||
class AcknowledgeAlarm extends AlarmEvent {
|
||||
final int alarmId;
|
||||
final String serverId;
|
||||
|
||||
const AcknowledgeAlarm({
|
||||
required this.alarmId,
|
||||
required this.serverId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [alarmId, serverId];
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../domain/entities/alarm_state.dart' as domain;
|
||||
|
||||
class AlarmBlocState extends Equatable {
|
||||
final List<domain.AlarmState> activeAlarms;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final DateTime? lastSync;
|
||||
|
||||
const AlarmBlocState({
|
||||
this.activeAlarms = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.lastSync,
|
||||
});
|
||||
|
||||
/// Get count of active blocking alarms
|
||||
int get blockingAlarmCount =>
|
||||
activeAlarms.where((a) => a.blocksMonitor).length;
|
||||
|
||||
/// Get alarms for a specific monitor
|
||||
List<domain.AlarmState> alarmsForMonitor(int monitorId) {
|
||||
return activeAlarms
|
||||
.where((a) => a.associatedMonitor == monitorId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Check if monitor has blocking alarm
|
||||
bool monitorHasBlockingAlarm(int monitorId) {
|
||||
return activeAlarms.any(
|
||||
(a) => a.associatedMonitor == monitorId && a.blocksMonitor);
|
||||
}
|
||||
|
||||
AlarmBlocState copyWith({
|
||||
List<domain.AlarmState>? activeAlarms,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
DateTime? lastSync,
|
||||
}) {
|
||||
return AlarmBlocState(
|
||||
activeAlarms: activeAlarms ?? this.activeAlarms,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
lastSync: lastSync ?? this.lastSync,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [activeAlarms, isLoading, error, lastSync];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../data/services/bridge_service.dart';
|
||||
import 'camera_event.dart';
|
||||
import 'camera_state.dart';
|
||||
|
||||
class CameraBloc extends Bloc<CameraEvent, CameraState> {
|
||||
final BridgeService _bridgeService;
|
||||
final AppConfig _config;
|
||||
|
||||
CameraBloc({
|
||||
required BridgeService bridgeService,
|
||||
required AppConfig config,
|
||||
}) : _bridgeService = bridgeService,
|
||||
_config = config,
|
||||
super(CameraState(availableCameras: config.allCameraIds)) {
|
||||
on<SelectCamera>(_onSelectCamera);
|
||||
on<ConnectCameraToMonitor>(_onConnectCameraToMonitor);
|
||||
on<ClearCameraSelection>(_onClearCameraSelection);
|
||||
}
|
||||
|
||||
void _onSelectCamera(
|
||||
SelectCamera event,
|
||||
Emitter<CameraState> emit,
|
||||
) {
|
||||
emit(state.copyWith(selectedCameraId: event.cameraId, error: null));
|
||||
}
|
||||
|
||||
Future<void> _onConnectCameraToMonitor(
|
||||
ConnectCameraToMonitor event,
|
||||
Emitter<CameraState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isConnecting: true, error: null));
|
||||
|
||||
try {
|
||||
final success = await _bridgeService.viewerConnectLive(
|
||||
event.monitorId,
|
||||
event.cameraId,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
emit(state.copyWith(isConnecting: false));
|
||||
} else {
|
||||
emit(state.copyWith(
|
||||
isConnecting: false,
|
||||
error: 'Failed to connect camera ${event.cameraId} to monitor ${event.monitorId}',
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isConnecting: false,
|
||||
error: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onClearCameraSelection(
|
||||
ClearCameraSelection event,
|
||||
Emitter<CameraState> emit,
|
||||
) {
|
||||
emit(state.copyWith(clearSelection: true, error: null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class CameraEvent extends Equatable {
|
||||
const CameraEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Select a camera for viewing/control
|
||||
class SelectCamera extends CameraEvent {
|
||||
final int cameraId;
|
||||
|
||||
const SelectCamera(this.cameraId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId];
|
||||
}
|
||||
|
||||
/// Connect selected camera to a monitor
|
||||
class ConnectCameraToMonitor extends CameraEvent {
|
||||
final int cameraId;
|
||||
final int monitorId;
|
||||
|
||||
const ConnectCameraToMonitor({
|
||||
required this.cameraId,
|
||||
required this.monitorId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId, monitorId];
|
||||
}
|
||||
|
||||
/// Clear selection
|
||||
class ClearCameraSelection extends CameraEvent {
|
||||
const ClearCameraSelection();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class CameraState extends Equatable {
|
||||
final int? selectedCameraId;
|
||||
final bool isConnecting;
|
||||
final String? error;
|
||||
final List<int> availableCameras;
|
||||
|
||||
const CameraState({
|
||||
this.selectedCameraId,
|
||||
this.isConnecting = false,
|
||||
this.error,
|
||||
this.availableCameras = const [],
|
||||
});
|
||||
|
||||
bool get hasSelection => selectedCameraId != null;
|
||||
|
||||
CameraState copyWith({
|
||||
int? selectedCameraId,
|
||||
bool? isConnecting,
|
||||
String? error,
|
||||
List<int>? availableCameras,
|
||||
bool clearSelection = false,
|
||||
}) {
|
||||
return CameraState(
|
||||
selectedCameraId:
|
||||
clearSelection ? null : (selectedCameraId ?? this.selectedCameraId),
|
||||
isConnecting: isConnecting ?? this.isConnecting,
|
||||
error: error,
|
||||
availableCameras: availableCameras ?? this.availableCameras,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[selectedCameraId, isConnecting, error, availableCameras];
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../data/services/bridge_service.dart';
|
||||
import 'connection_event.dart';
|
||||
import 'connection_state.dart';
|
||||
|
||||
class ConnectionBloc extends Bloc<ConnectionEvent, ConnectionState> {
|
||||
final BridgeService _bridgeService;
|
||||
final AppConfig _config;
|
||||
StreamSubscription? _statusSubscription;
|
||||
|
||||
ConnectionBloc({
|
||||
required BridgeService bridgeService,
|
||||
required AppConfig config,
|
||||
}) : _bridgeService = bridgeService,
|
||||
_config = config,
|
||||
super(const ConnectionState()) {
|
||||
on<ConnectAll>(_onConnectAll);
|
||||
on<ConnectServer>(_onConnectServer);
|
||||
on<DisconnectServer>(_onDisconnectServer);
|
||||
on<DisconnectAll>(_onDisconnectAll);
|
||||
on<RetryConnections>(_onRetryConnections);
|
||||
on<ConnectionStatusUpdated>(_onConnectionStatusUpdated);
|
||||
|
||||
// Subscribe to connection status changes
|
||||
_statusSubscription = _bridgeService.connectionStatus.listen((status) {
|
||||
add(ConnectionStatusUpdated(status));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onConnectAll(
|
||||
ConnectAll event,
|
||||
Emitter<ConnectionState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(overallStatus: ConnectionOverallStatus.connecting));
|
||||
|
||||
try {
|
||||
await _bridgeService.connectAll();
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
overallStatus: ConnectionOverallStatus.disconnected,
|
||||
error: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onConnectServer(
|
||||
ConnectServer event,
|
||||
Emitter<ConnectionState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _bridgeService.connect(event.serverId);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: 'Failed to connect to ${event.serverId}: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDisconnectServer(
|
||||
DisconnectServer event,
|
||||
Emitter<ConnectionState> emit,
|
||||
) async {
|
||||
await _bridgeService.disconnect(event.serverId);
|
||||
}
|
||||
|
||||
Future<void> _onDisconnectAll(
|
||||
DisconnectAll event,
|
||||
Emitter<ConnectionState> emit,
|
||||
) async {
|
||||
await _bridgeService.disconnectAll();
|
||||
emit(state.copyWith(overallStatus: ConnectionOverallStatus.disconnected));
|
||||
}
|
||||
|
||||
Future<void> _onRetryConnections(
|
||||
RetryConnections event,
|
||||
Emitter<ConnectionState> emit,
|
||||
) async {
|
||||
// Retry only disconnected servers
|
||||
final disconnected = state.serverStatus.entries
|
||||
.where((e) => !e.value)
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
|
||||
for (final serverId in disconnected) {
|
||||
await _bridgeService.connect(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
void _onConnectionStatusUpdated(
|
||||
ConnectionStatusUpdated event,
|
||||
Emitter<ConnectionState> emit,
|
||||
) {
|
||||
final status = event.status;
|
||||
ConnectionOverallStatus overall;
|
||||
|
||||
if (status.isEmpty) {
|
||||
overall = ConnectionOverallStatus.disconnected;
|
||||
} else if (status.values.every((v) => v)) {
|
||||
overall = ConnectionOverallStatus.connected;
|
||||
} else if (status.values.any((v) => v)) {
|
||||
overall = ConnectionOverallStatus.partial;
|
||||
} else {
|
||||
overall = ConnectionOverallStatus.disconnected;
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
overallStatus: overall,
|
||||
serverStatus: status,
|
||||
error: null,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_statusSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class ConnectionEvent extends Equatable {
|
||||
const ConnectionEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Connect to all servers
|
||||
class ConnectAll extends ConnectionEvent {
|
||||
const ConnectAll();
|
||||
}
|
||||
|
||||
/// Connect to a specific server
|
||||
class ConnectServer extends ConnectionEvent {
|
||||
final String serverId;
|
||||
|
||||
const ConnectServer(this.serverId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [serverId];
|
||||
}
|
||||
|
||||
/// Disconnect from a specific server
|
||||
class DisconnectServer extends ConnectionEvent {
|
||||
final String serverId;
|
||||
|
||||
const DisconnectServer(this.serverId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [serverId];
|
||||
}
|
||||
|
||||
/// Disconnect from all servers
|
||||
class DisconnectAll extends ConnectionEvent {
|
||||
const DisconnectAll();
|
||||
}
|
||||
|
||||
/// Retry failed connections
|
||||
class RetryConnections extends ConnectionEvent {
|
||||
const RetryConnections();
|
||||
}
|
||||
|
||||
/// Connection status updated (internal)
|
||||
class ConnectionStatusUpdated extends ConnectionEvent {
|
||||
final Map<String, bool> status;
|
||||
|
||||
const ConnectionStatusUpdated(this.status);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
enum ConnectionOverallStatus { disconnected, connecting, connected, partial }
|
||||
|
||||
class ConnectionState extends Equatable {
|
||||
final ConnectionOverallStatus overallStatus;
|
||||
final Map<String, bool> serverStatus;
|
||||
final String? error;
|
||||
|
||||
const ConnectionState({
|
||||
this.overallStatus = ConnectionOverallStatus.disconnected,
|
||||
this.serverStatus = const {},
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// Check if all servers are connected
|
||||
bool get allConnected =>
|
||||
serverStatus.isNotEmpty && serverStatus.values.every((v) => v);
|
||||
|
||||
/// Check if any server is connected
|
||||
bool get anyConnected => serverStatus.values.any((v) => v);
|
||||
|
||||
/// Get count of connected servers
|
||||
int get connectedCount => serverStatus.values.where((v) => v).length;
|
||||
|
||||
/// Get count of total servers
|
||||
int get totalCount => serverStatus.length;
|
||||
|
||||
ConnectionState copyWith({
|
||||
ConnectionOverallStatus? overallStatus,
|
||||
Map<String, bool>? serverStatus,
|
||||
String? error,
|
||||
}) {
|
||||
return ConnectionState(
|
||||
overallStatus: overallStatus ?? this.overallStatus,
|
||||
serverStatus: serverStatus ?? this.serverStatus,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [overallStatus, serverStatus, error];
|
||||
}
|
||||
168
copilot_keyboard/lib/presentation/blocs/lock/lock_bloc.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/services/coordination_service.dart';
|
||||
import '../../../domain/entities/camera_lock.dart';
|
||||
import 'lock_event.dart';
|
||||
import 'lock_state.dart';
|
||||
|
||||
class LockBloc extends Bloc<LockEvent, LockState> {
|
||||
final CoordinationService _coordinationService;
|
||||
|
||||
StreamSubscription? _locksSub;
|
||||
StreamSubscription? _notifSub;
|
||||
StreamSubscription? _connSub;
|
||||
|
||||
LockBloc({
|
||||
required CoordinationService coordinationService,
|
||||
required String keyboardId,
|
||||
}) : _coordinationService = coordinationService,
|
||||
super(const LockState()) {
|
||||
on<TryLock>(_onTryLock);
|
||||
on<ReleaseLock>(_onReleaseLock);
|
||||
on<ReleaseAllLocks>(_onReleaseAllLocks);
|
||||
on<RequestTakeover>(_onRequestTakeover);
|
||||
on<ConfirmTakeover>(_onConfirmTakeover);
|
||||
on<ResetLockExpiration>(_onResetLockExpiration);
|
||||
on<LocksUpdated>(_onLocksUpdated);
|
||||
on<LockNotificationReceived>(_onLockNotificationReceived);
|
||||
on<CoordinatorConnectionChanged>(_onCoordinatorConnectionChanged);
|
||||
|
||||
// Subscribe to coordinator streams
|
||||
_locksSub = _coordinationService.locks.listen((locks) {
|
||||
add(LocksUpdated(locks));
|
||||
});
|
||||
|
||||
_notifSub = _coordinationService.notifications.listen((notification) {
|
||||
if (notification != null) {
|
||||
add(LockNotificationReceived(notification));
|
||||
}
|
||||
});
|
||||
|
||||
_connSub = _coordinationService.connected.listen((connected) {
|
||||
add(CoordinatorConnectionChanged(connected));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onTryLock(TryLock event, Emitter<LockState> emit) async {
|
||||
final result = await _coordinationService.tryLock(
|
||||
event.cameraId,
|
||||
priority: event.priority,
|
||||
);
|
||||
|
||||
if (!result.acquired) {
|
||||
final lock = result.lock;
|
||||
final owner = lock?.ownerName ?? 'unknown';
|
||||
emit(state.copyWith(
|
||||
lastNotification: 'Camera ${event.cameraId} locked by $owner',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onReleaseLock(
|
||||
ReleaseLock event, Emitter<LockState> emit) async {
|
||||
await _coordinationService.releaseLock(event.cameraId);
|
||||
}
|
||||
|
||||
Future<void> _onReleaseAllLocks(
|
||||
ReleaseAllLocks event, Emitter<LockState> emit) async {
|
||||
final myLocks = await _coordinationService.getMyLockedCameras();
|
||||
for (final cameraId in myLocks) {
|
||||
await _coordinationService.releaseLock(cameraId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRequestTakeover(
|
||||
RequestTakeover event, Emitter<LockState> emit) async {
|
||||
final success = await _coordinationService.requestTakeover(
|
||||
event.cameraId,
|
||||
priority: event.priority,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
emit(state.copyWith(
|
||||
lastNotification: 'Takeover requested for camera ${event.cameraId}',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onConfirmTakeover(
|
||||
ConfirmTakeover event, Emitter<LockState> emit) async {
|
||||
await _coordinationService.confirmTakeover(
|
||||
event.cameraId, event.confirm);
|
||||
emit(state.copyWith(clearPendingTakeover: true));
|
||||
}
|
||||
|
||||
Future<void> _onResetLockExpiration(
|
||||
ResetLockExpiration event, Emitter<LockState> emit) async {
|
||||
await _coordinationService.resetExpiration(event.cameraId);
|
||||
}
|
||||
|
||||
void _onLocksUpdated(LocksUpdated event, Emitter<LockState> emit) {
|
||||
emit(state.copyWith(locks: event.locks));
|
||||
}
|
||||
|
||||
void _onLockNotificationReceived(
|
||||
LockNotificationReceived event, Emitter<LockState> emit) {
|
||||
final notification = event.notification;
|
||||
|
||||
switch (notification.type) {
|
||||
case CameraLockNotificationType.confirmTakeOver:
|
||||
// Show takeover confirmation dialog
|
||||
emit(state.copyWith(
|
||||
pendingTakeover: TakeoverRequest(
|
||||
cameraId: notification.cameraId,
|
||||
requestingKeyboard: notification.copilotName,
|
||||
),
|
||||
));
|
||||
break;
|
||||
|
||||
case CameraLockNotificationType.takenOver:
|
||||
emit(state.copyWith(
|
||||
lastNotification:
|
||||
'Camera ${notification.cameraId} taken over by ${notification.copilotName}',
|
||||
));
|
||||
break;
|
||||
|
||||
case CameraLockNotificationType.expireSoon:
|
||||
emit(state.copyWith(
|
||||
lastNotification:
|
||||
'Lock on camera ${notification.cameraId} expiring soon',
|
||||
));
|
||||
break;
|
||||
|
||||
case CameraLockNotificationType.confirmed:
|
||||
emit(state.copyWith(
|
||||
lastNotification:
|
||||
'Takeover confirmed for camera ${notification.cameraId}',
|
||||
));
|
||||
break;
|
||||
|
||||
case CameraLockNotificationType.rejected:
|
||||
emit(state.copyWith(
|
||||
lastNotification:
|
||||
'Takeover rejected for camera ${notification.cameraId}',
|
||||
));
|
||||
break;
|
||||
|
||||
case CameraLockNotificationType.unlocked:
|
||||
case CameraLockNotificationType.acquired:
|
||||
// Handled by lock state updates
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onCoordinatorConnectionChanged(
|
||||
CoordinatorConnectionChanged event, Emitter<LockState> emit) {
|
||||
emit(state.copyWith(coordinatorConnected: event.connected));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_locksSub?.cancel();
|
||||
_notifSub?.cancel();
|
||||
_connSub?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
60
copilot_keyboard/lib/presentation/blocs/lock/lock_event.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import '../../../domain/entities/camera_lock.dart';
|
||||
|
||||
abstract class LockEvent {}
|
||||
|
||||
/// Try to acquire a lock on a camera
|
||||
class TryLock extends LockEvent {
|
||||
final int cameraId;
|
||||
final CameraLockPriority priority;
|
||||
|
||||
TryLock(this.cameraId, {this.priority = CameraLockPriority.low});
|
||||
}
|
||||
|
||||
/// Release a camera lock
|
||||
class ReleaseLock extends LockEvent {
|
||||
final int cameraId;
|
||||
ReleaseLock(this.cameraId);
|
||||
}
|
||||
|
||||
/// Release all locks held by this keyboard
|
||||
class ReleaseAllLocks extends LockEvent {}
|
||||
|
||||
/// Request takeover of a camera locked by another keyboard
|
||||
class RequestTakeover extends LockEvent {
|
||||
final int cameraId;
|
||||
final CameraLockPriority priority;
|
||||
|
||||
RequestTakeover(this.cameraId, {this.priority = CameraLockPriority.low});
|
||||
}
|
||||
|
||||
/// Confirm or reject a takeover request from another keyboard
|
||||
class ConfirmTakeover extends LockEvent {
|
||||
final int cameraId;
|
||||
final bool confirm;
|
||||
|
||||
ConfirmTakeover(this.cameraId, {required this.confirm});
|
||||
}
|
||||
|
||||
/// Reset lock expiration (keep-alive during PTZ)
|
||||
class ResetLockExpiration extends LockEvent {
|
||||
final int cameraId;
|
||||
ResetLockExpiration(this.cameraId);
|
||||
}
|
||||
|
||||
/// Internal: lock state updated from coordinator WebSocket
|
||||
class LocksUpdated extends LockEvent {
|
||||
final Map<int, CameraLock> locks;
|
||||
LocksUpdated(this.locks);
|
||||
}
|
||||
|
||||
/// Internal: lock notification received from coordinator
|
||||
class LockNotificationReceived extends LockEvent {
|
||||
final CameraLockNotification notification;
|
||||
LockNotificationReceived(this.notification);
|
||||
}
|
||||
|
||||
/// Internal: coordinator connection status changed
|
||||
class CoordinatorConnectionChanged extends LockEvent {
|
||||
final bool connected;
|
||||
CoordinatorConnectionChanged(this.connected);
|
||||
}
|
||||
75
copilot_keyboard/lib/presentation/blocs/lock/lock_state.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import '../../../domain/entities/camera_lock.dart';
|
||||
|
||||
class LockState {
|
||||
/// All known camera locks
|
||||
final Map<int, CameraLock> locks;
|
||||
|
||||
/// Whether the coordinator is connected
|
||||
final bool coordinatorConnected;
|
||||
|
||||
/// Pending takeover confirmation request (show dialog to user)
|
||||
final TakeoverRequest? pendingTakeover;
|
||||
|
||||
/// Last notification message (for snackbar/toast)
|
||||
final String? lastNotification;
|
||||
|
||||
/// Error message
|
||||
final String? error;
|
||||
|
||||
const LockState({
|
||||
this.locks = const {},
|
||||
this.coordinatorConnected = false,
|
||||
this.pendingTakeover,
|
||||
this.lastNotification,
|
||||
this.error,
|
||||
});
|
||||
|
||||
LockState copyWith({
|
||||
Map<int, CameraLock>? locks,
|
||||
bool? coordinatorConnected,
|
||||
TakeoverRequest? pendingTakeover,
|
||||
bool clearPendingTakeover = false,
|
||||
String? lastNotification,
|
||||
bool clearNotification = false,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return LockState(
|
||||
locks: locks ?? this.locks,
|
||||
coordinatorConnected: coordinatorConnected ?? this.coordinatorConnected,
|
||||
pendingTakeover:
|
||||
clearPendingTakeover ? null : (pendingTakeover ?? this.pendingTakeover),
|
||||
lastNotification:
|
||||
clearNotification ? null : (lastNotification ?? this.lastNotification),
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if a camera is locked by this keyboard
|
||||
bool isCameraLockedByMe(int cameraId, String keyboardId) {
|
||||
final lock = locks[cameraId];
|
||||
return lock != null &&
|
||||
lock.ownerName.toLowerCase() == keyboardId.toLowerCase();
|
||||
}
|
||||
|
||||
/// Check if a camera is locked by another keyboard
|
||||
bool isCameraLockedByOther(int cameraId, String keyboardId) {
|
||||
final lock = locks[cameraId];
|
||||
return lock != null &&
|
||||
lock.ownerName.toLowerCase() != keyboardId.toLowerCase();
|
||||
}
|
||||
|
||||
/// Get the lock for a camera, if any
|
||||
CameraLock? getLock(int cameraId) => locks[cameraId];
|
||||
}
|
||||
|
||||
/// Pending takeover request shown as a dialog
|
||||
class TakeoverRequest {
|
||||
final int cameraId;
|
||||
final String requestingKeyboard;
|
||||
|
||||
const TakeoverRequest({
|
||||
required this.cameraId,
|
||||
required this.requestingKeyboard,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../data/services/state_service.dart';
|
||||
import '../../../data/services/bridge_service.dart';
|
||||
import '../../../injection_container.dart';
|
||||
import 'monitor_event.dart';
|
||||
import 'monitor_state.dart';
|
||||
|
||||
class MonitorBloc extends Bloc<MonitorEvent, MonitorBlocState> {
|
||||
final StateService _stateService;
|
||||
final AppConfig _config;
|
||||
StreamSubscription? _stateSubscription;
|
||||
|
||||
MonitorBloc({
|
||||
required StateService stateService,
|
||||
required AppConfig config,
|
||||
}) : _stateService = stateService,
|
||||
_config = config,
|
||||
super(MonitorBlocState(availableMonitors: config.allMonitorIds)) {
|
||||
on<SelectMonitor>(_onSelectMonitor);
|
||||
on<ClearMonitor>(_onClearMonitor);
|
||||
on<ClearMonitorSelection>(_onClearMonitorSelection);
|
||||
on<MonitorStatesUpdated>(_onMonitorStatesUpdated);
|
||||
|
||||
// Subscribe to monitor state changes
|
||||
_stateSubscription = _stateService.combinedMonitorStates.listen((states) {
|
||||
add(MonitorStatesUpdated(states));
|
||||
});
|
||||
}
|
||||
|
||||
void _onSelectMonitor(
|
||||
SelectMonitor event,
|
||||
Emitter<MonitorBlocState> emit,
|
||||
) {
|
||||
emit(state.copyWith(selectedMonitorId: event.monitorId, error: null));
|
||||
}
|
||||
|
||||
Future<void> _onClearMonitor(
|
||||
ClearMonitor event,
|
||||
Emitter<MonitorBlocState> emit,
|
||||
) async {
|
||||
try {
|
||||
final bridgeService = sl<BridgeService>();
|
||||
await bridgeService.viewerClear(event.monitorId);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: 'Failed to clear monitor: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
void _onClearMonitorSelection(
|
||||
ClearMonitorSelection event,
|
||||
Emitter<MonitorBlocState> emit,
|
||||
) {
|
||||
emit(state.copyWith(clearSelection: true, error: null));
|
||||
}
|
||||
|
||||
void _onMonitorStatesUpdated(
|
||||
MonitorStatesUpdated event,
|
||||
Emitter<MonitorBlocState> emit,
|
||||
) {
|
||||
emit(state.copyWith(monitorStates: event.states));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_stateSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../domain/entities/monitor_state.dart';
|
||||
|
||||
abstract class MonitorEvent extends Equatable {
|
||||
const MonitorEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Select a monitor for camera switching
|
||||
class SelectMonitor extends MonitorEvent {
|
||||
final int monitorId;
|
||||
|
||||
const SelectMonitor(this.monitorId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [monitorId];
|
||||
}
|
||||
|
||||
/// Clear the selected monitor
|
||||
class ClearMonitor extends MonitorEvent {
|
||||
final int monitorId;
|
||||
|
||||
const ClearMonitor(this.monitorId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [monitorId];
|
||||
}
|
||||
|
||||
/// Clear selection
|
||||
class ClearMonitorSelection extends MonitorEvent {
|
||||
const ClearMonitorSelection();
|
||||
}
|
||||
|
||||
/// Monitor states updated (internal)
|
||||
class MonitorStatesUpdated extends MonitorEvent {
|
||||
final Map<int, MonitorState> states;
|
||||
|
||||
const MonitorStatesUpdated(this.states);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [states];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../domain/entities/monitor_state.dart' as domain;
|
||||
|
||||
class MonitorBlocState extends Equatable {
|
||||
final int? selectedMonitorId;
|
||||
final Map<int, domain.MonitorState> monitorStates;
|
||||
final List<int> availableMonitors;
|
||||
final String? error;
|
||||
|
||||
const MonitorBlocState({
|
||||
this.selectedMonitorId,
|
||||
this.monitorStates = const {},
|
||||
this.availableMonitors = const [],
|
||||
this.error,
|
||||
});
|
||||
|
||||
bool get hasSelection => selectedMonitorId != null;
|
||||
|
||||
/// Get the currently selected monitor's state
|
||||
domain.MonitorState? get selectedMonitorState {
|
||||
if (selectedMonitorId == null) return null;
|
||||
return monitorStates[selectedMonitorId];
|
||||
}
|
||||
|
||||
/// Get the camera currently on the selected monitor
|
||||
int? get selectedMonitorCamera {
|
||||
return selectedMonitorState?.currentChannel;
|
||||
}
|
||||
|
||||
MonitorBlocState copyWith({
|
||||
int? selectedMonitorId,
|
||||
Map<int, domain.MonitorState>? monitorStates,
|
||||
List<int>? availableMonitors,
|
||||
String? error,
|
||||
bool clearSelection = false,
|
||||
}) {
|
||||
return MonitorBlocState(
|
||||
selectedMonitorId:
|
||||
clearSelection ? null : (selectedMonitorId ?? this.selectedMonitorId),
|
||||
monitorStates: monitorStates ?? this.monitorStates,
|
||||
availableMonitors: availableMonitors ?? this.availableMonitors,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[selectedMonitorId, monitorStates, availableMonitors, error];
|
||||
}
|
||||
189
copilot_keyboard/lib/presentation/blocs/ptz/ptz_bloc.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/services/bridge_service.dart';
|
||||
import '../../../data/services/coordination_service.dart';
|
||||
import 'ptz_event.dart';
|
||||
import 'ptz_state.dart';
|
||||
|
||||
class PtzBloc extends Bloc<PtzEvent, PtzState> {
|
||||
final BridgeService _bridgeService;
|
||||
final CoordinationService _coordinationService;
|
||||
|
||||
Timer? _lockResetTimer;
|
||||
|
||||
PtzBloc({
|
||||
required BridgeService bridgeService,
|
||||
required CoordinationService coordinationService,
|
||||
}) : _bridgeService = bridgeService,
|
||||
_coordinationService = coordinationService,
|
||||
super(const PtzState()) {
|
||||
on<PtzPanStart>(_onPanStart);
|
||||
on<PtzTiltStart>(_onTiltStart);
|
||||
on<PtzZoomStart>(_onZoomStart);
|
||||
on<PtzStop>(_onStop);
|
||||
on<PtzGoToPreset>(_onGoToPreset);
|
||||
on<PtzSetCamera>(_onSetCamera);
|
||||
}
|
||||
|
||||
/// Ensure we have a lock on the camera before PTZ movement.
|
||||
/// Returns true if lock was acquired or already held.
|
||||
Future<bool> _ensureLock(int cameraId, Emitter<PtzState> emit) async {
|
||||
// Already locked by us
|
||||
if (_coordinationService.isCameraLockedByMe(cameraId)) {
|
||||
emit(state.copyWith(lockStatus: PtzLockStatus.locked));
|
||||
return true;
|
||||
}
|
||||
|
||||
emit(state.copyWith(lockStatus: PtzLockStatus.acquiring));
|
||||
|
||||
final result = await _coordinationService.tryLock(cameraId);
|
||||
if (result.acquired) {
|
||||
emit(state.copyWith(lockStatus: PtzLockStatus.locked));
|
||||
_startLockResetTimer(cameraId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Lock denied — someone else has it
|
||||
emit(state.copyWith(
|
||||
lockStatus: PtzLockStatus.denied,
|
||||
lockedBy: result.lock?.ownerName,
|
||||
error: 'Camera locked by ${result.lock?.ownerName ?? "another keyboard"}',
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
void _startLockResetTimer(int cameraId) {
|
||||
_lockResetTimer?.cancel();
|
||||
_lockResetTimer = Timer.periodic(const Duration(minutes: 2), (_) {
|
||||
if (_coordinationService.isCameraLockedByMe(cameraId)) {
|
||||
_coordinationService.resetExpiration(cameraId);
|
||||
} else {
|
||||
_lockResetTimer?.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onPanStart(
|
||||
PtzPanStart event,
|
||||
Emitter<PtzState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(activeCameraId: event.cameraId));
|
||||
|
||||
if (!await _ensureLock(event.cameraId, emit)) return;
|
||||
|
||||
final direction =
|
||||
event.direction == 'left' ? PtzDirection.left : PtzDirection.right;
|
||||
emit(state.copyWith(
|
||||
currentDirection: direction,
|
||||
isMoving: true,
|
||||
error: null,
|
||||
));
|
||||
|
||||
try {
|
||||
await _bridgeService.ptzPan(event.cameraId, event.direction, event.speed);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString(), isMoving: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onTiltStart(
|
||||
PtzTiltStart event,
|
||||
Emitter<PtzState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(activeCameraId: event.cameraId));
|
||||
|
||||
if (!await _ensureLock(event.cameraId, emit)) return;
|
||||
|
||||
final direction =
|
||||
event.direction == 'up' ? PtzDirection.up : PtzDirection.down;
|
||||
emit(state.copyWith(
|
||||
currentDirection: direction,
|
||||
isMoving: true,
|
||||
error: null,
|
||||
));
|
||||
|
||||
try {
|
||||
await _bridgeService.ptzTilt(
|
||||
event.cameraId, event.direction, event.speed);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString(), isMoving: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onZoomStart(
|
||||
PtzZoomStart event,
|
||||
Emitter<PtzState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(activeCameraId: event.cameraId));
|
||||
|
||||
if (!await _ensureLock(event.cameraId, emit)) return;
|
||||
|
||||
final direction =
|
||||
event.direction == 'in' ? PtzDirection.zoomIn : PtzDirection.zoomOut;
|
||||
emit(state.copyWith(
|
||||
currentDirection: direction,
|
||||
isMoving: true,
|
||||
error: null,
|
||||
));
|
||||
|
||||
try {
|
||||
await _bridgeService.ptzZoom(
|
||||
event.cameraId, event.direction, event.speed);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString(), isMoving: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onStop(
|
||||
PtzStop event,
|
||||
Emitter<PtzState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(
|
||||
currentDirection: PtzDirection.none,
|
||||
isMoving: false,
|
||||
error: null,
|
||||
));
|
||||
|
||||
try {
|
||||
await _bridgeService.ptzStop(event.cameraId);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onGoToPreset(
|
||||
PtzGoToPreset event,
|
||||
Emitter<PtzState> emit,
|
||||
) async {
|
||||
if (!await _ensureLock(event.cameraId, emit)) return;
|
||||
|
||||
emit(state.copyWith(error: null));
|
||||
|
||||
try {
|
||||
await _bridgeService.ptzPreset(event.cameraId, event.preset);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _onSetCamera(
|
||||
PtzSetCamera event,
|
||||
Emitter<PtzState> emit,
|
||||
) {
|
||||
if (event.cameraId == null) {
|
||||
_lockResetTimer?.cancel();
|
||||
emit(state.copyWith(
|
||||
clearCamera: true, lockStatus: PtzLockStatus.none));
|
||||
} else {
|
||||
emit(state.copyWith(activeCameraId: event.cameraId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_lockResetTimer?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
90
copilot_keyboard/lib/presentation/blocs/ptz/ptz_event.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class PtzEvent extends Equatable {
|
||||
const PtzEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Start panning
|
||||
class PtzPanStart extends PtzEvent {
|
||||
final int cameraId;
|
||||
final String direction; // 'left' or 'right'
|
||||
final int speed;
|
||||
|
||||
const PtzPanStart({
|
||||
required this.cameraId,
|
||||
required this.direction,
|
||||
this.speed = 50,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId, direction, speed];
|
||||
}
|
||||
|
||||
/// Start tilting
|
||||
class PtzTiltStart extends PtzEvent {
|
||||
final int cameraId;
|
||||
final String direction; // 'up' or 'down'
|
||||
final int speed;
|
||||
|
||||
const PtzTiltStart({
|
||||
required this.cameraId,
|
||||
required this.direction,
|
||||
this.speed = 50,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId, direction, speed];
|
||||
}
|
||||
|
||||
/// Start zooming
|
||||
class PtzZoomStart extends PtzEvent {
|
||||
final int cameraId;
|
||||
final String direction; // 'in' or 'out'
|
||||
final int speed;
|
||||
|
||||
const PtzZoomStart({
|
||||
required this.cameraId,
|
||||
required this.direction,
|
||||
this.speed = 50,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId, direction, speed];
|
||||
}
|
||||
|
||||
/// Stop all PTZ movement
|
||||
class PtzStop extends PtzEvent {
|
||||
final int cameraId;
|
||||
|
||||
const PtzStop(this.cameraId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId];
|
||||
}
|
||||
|
||||
/// Go to preset
|
||||
class PtzGoToPreset extends PtzEvent {
|
||||
final int cameraId;
|
||||
final int preset;
|
||||
|
||||
const PtzGoToPreset({
|
||||
required this.cameraId,
|
||||
required this.preset,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId, preset];
|
||||
}
|
||||
|
||||
/// Set camera for PTZ control
|
||||
class PtzSetCamera extends PtzEvent {
|
||||
final int? cameraId;
|
||||
|
||||
const PtzSetCamera(this.cameraId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId];
|
||||
}
|
||||
50
copilot_keyboard/lib/presentation/blocs/ptz/ptz_state.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
enum PtzDirection { none, left, right, up, down, zoomIn, zoomOut }
|
||||
|
||||
enum PtzLockStatus { none, acquiring, locked, denied }
|
||||
|
||||
class PtzState extends Equatable {
|
||||
final int? activeCameraId;
|
||||
final PtzDirection currentDirection;
|
||||
final bool isMoving;
|
||||
final PtzLockStatus lockStatus;
|
||||
final String? lockedBy;
|
||||
final String? error;
|
||||
|
||||
const PtzState({
|
||||
this.activeCameraId,
|
||||
this.currentDirection = PtzDirection.none,
|
||||
this.isMoving = false,
|
||||
this.lockStatus = PtzLockStatus.none,
|
||||
this.lockedBy,
|
||||
this.error,
|
||||
});
|
||||
|
||||
bool get hasActiveCamera => activeCameraId != null;
|
||||
bool get hasLock => lockStatus == PtzLockStatus.locked;
|
||||
|
||||
PtzState copyWith({
|
||||
int? activeCameraId,
|
||||
PtzDirection? currentDirection,
|
||||
bool? isMoving,
|
||||
PtzLockStatus? lockStatus,
|
||||
String? lockedBy,
|
||||
String? error,
|
||||
bool clearCamera = false,
|
||||
}) {
|
||||
return PtzState(
|
||||
activeCameraId:
|
||||
clearCamera ? null : (activeCameraId ?? this.activeCameraId),
|
||||
currentDirection: currentDirection ?? this.currentDirection,
|
||||
isMoving: isMoving ?? this.isMoving,
|
||||
lockStatus: lockStatus ?? this.lockStatus,
|
||||
lockedBy: lockedBy ?? this.lockedBy,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[activeCameraId, currentDirection, isMoving, lockStatus, lockedBy, error];
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/services/coordination_service.dart';
|
||||
import '../../../domain/entities/sequence.dart';
|
||||
import 'sequence_event.dart';
|
||||
import 'sequence_state.dart';
|
||||
|
||||
class SequenceBloc extends Bloc<SequenceEvent, SequenceState> {
|
||||
final CoordinationService _coordinationService;
|
||||
|
||||
SequenceBloc({required CoordinationService coordinationService})
|
||||
: _coordinationService = coordinationService,
|
||||
super(const SequenceState()) {
|
||||
on<LoadSequences>(_onLoadSequences);
|
||||
on<StartSequence>(_onStartSequence);
|
||||
on<StopSequence>(_onStopSequence);
|
||||
on<SelectCategory>(_onSelectCategory);
|
||||
}
|
||||
|
||||
Future<void> _onLoadSequences(
|
||||
LoadSequences event, Emitter<SequenceState> emit) async {
|
||||
emit(state.copyWith(isLoading: true, clearError: true));
|
||||
|
||||
try {
|
||||
final sequencesJson = await _coordinationService.getSequences();
|
||||
final categoriesJson = await _coordinationService.getSequenceCategories();
|
||||
final runningJson = await _coordinationService.getRunningSequences();
|
||||
|
||||
final sequences = sequencesJson
|
||||
.map((j) => SequenceDefinition.fromJson(j))
|
||||
.toList();
|
||||
final categories =
|
||||
categoriesJson.map((j) => SequenceCategory.fromJson(j)).toList();
|
||||
|
||||
final running = <int, RunningSequence>{};
|
||||
for (final j in runningJson) {
|
||||
final rs = RunningSequence.fromJson(j);
|
||||
running[rs.viewerId] = rs;
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
sequences: sequences,
|
||||
categories: categories,
|
||||
running: running,
|
||||
isLoading: false,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onStartSequence(
|
||||
StartSequence event, Emitter<SequenceState> emit) async {
|
||||
try {
|
||||
final result = await _coordinationService.startSequence(
|
||||
event.viewerId, event.sequenceId);
|
||||
|
||||
if (result != null) {
|
||||
final rs = RunningSequence.fromJson(result);
|
||||
final running = Map<int, RunningSequence>.from(state.running);
|
||||
running[rs.viewerId] = rs;
|
||||
emit(state.copyWith(running: running));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onStopSequence(
|
||||
StopSequence event, Emitter<SequenceState> emit) async {
|
||||
try {
|
||||
await _coordinationService.stopSequence(event.viewerId);
|
||||
|
||||
final running = Map<int, RunningSequence>.from(state.running);
|
||||
running.remove(event.viewerId);
|
||||
emit(state.copyWith(running: running));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelectCategory(
|
||||
SelectCategory event, Emitter<SequenceState> emit) {
|
||||
if (event.categoryId == null) {
|
||||
emit(state.copyWith(clearCategory: true));
|
||||
} else {
|
||||
emit(state.copyWith(selectedCategoryId: event.categoryId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
abstract class SequenceEvent {}
|
||||
|
||||
/// Load available sequences and categories from coordinator
|
||||
class LoadSequences extends SequenceEvent {}
|
||||
|
||||
/// Start a sequence on a viewer
|
||||
class StartSequence extends SequenceEvent {
|
||||
final int viewerId;
|
||||
final int sequenceId;
|
||||
|
||||
StartSequence({required this.viewerId, required this.sequenceId});
|
||||
}
|
||||
|
||||
/// Stop a sequence on a viewer
|
||||
class StopSequence extends SequenceEvent {
|
||||
final int viewerId;
|
||||
|
||||
StopSequence(this.viewerId);
|
||||
}
|
||||
|
||||
/// Filter sequences by category
|
||||
class SelectCategory extends SequenceEvent {
|
||||
final int? categoryId;
|
||||
|
||||
SelectCategory(this.categoryId);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import '../../../domain/entities/sequence.dart';
|
||||
|
||||
class SequenceState {
|
||||
final List<SequenceDefinition> sequences;
|
||||
final List<SequenceCategory> categories;
|
||||
final Map<int, RunningSequence> running; // viewerId -> RunningSequence
|
||||
final int? selectedCategoryId;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const SequenceState({
|
||||
this.sequences = const [],
|
||||
this.categories = const [],
|
||||
this.running = const {},
|
||||
this.selectedCategoryId,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// Sequences filtered by selected category
|
||||
List<SequenceDefinition> get filteredSequences {
|
||||
if (selectedCategoryId == null) return sequences;
|
||||
return sequences
|
||||
.where((s) => s.categoryId == selectedCategoryId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Check if a sequence is running on a viewer
|
||||
bool isRunningOnViewer(int viewerId) => running.containsKey(viewerId);
|
||||
|
||||
/// Get running sequence for a viewer
|
||||
RunningSequence? getRunning(int viewerId) => running[viewerId];
|
||||
|
||||
SequenceState copyWith({
|
||||
List<SequenceDefinition>? sequences,
|
||||
List<SequenceCategory>? categories,
|
||||
Map<int, RunningSequence>? running,
|
||||
int? selectedCategoryId,
|
||||
bool clearCategory = false,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return SequenceState(
|
||||
sequences: sequences ?? this.sequences,
|
||||
categories: categories ?? this.categories,
|
||||
running: running ?? this.running,
|
||||
selectedCategoryId: clearCategory
|
||||
? null
|
||||
: (selectedCategoryId ?? this.selectedCategoryId),
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
);
|
||||
}
|
||||
}
|
||||
237
copilot_keyboard/lib/presentation/blocs/wall/wall_bloc.dart
Normal file
@@ -0,0 +1,237 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/services/bridge_service.dart';
|
||||
import '../../../domain/entities/wall_config.dart';
|
||||
import 'wall_event.dart';
|
||||
import 'wall_state.dart';
|
||||
|
||||
class WallBloc extends Bloc<WallEvent, WallState> {
|
||||
final BridgeService _bridgeService;
|
||||
Timer? _editTimeoutTimer;
|
||||
|
||||
/// Legacy cancel timeout: 5 seconds of inactivity cancels edit mode.
|
||||
static const _editTimeout = Duration(seconds: 5);
|
||||
|
||||
WallBloc({required BridgeService bridgeService})
|
||||
: _bridgeService = bridgeService,
|
||||
super(const WallState()) {
|
||||
on<LoadWallConfig>(_onLoadWallConfig);
|
||||
on<SelectViewer>(_onSelectViewer);
|
||||
on<DeselectViewer>(_onDeselectViewer);
|
||||
on<SetCameraPrefix>(_onSetCameraPrefix);
|
||||
on<AddCameraDigit>(_onAddCameraDigit);
|
||||
on<BackspaceCameraDigit>(_onBackspaceCameraDigit);
|
||||
on<CancelCameraEdit>(_onCancelCameraEdit);
|
||||
on<CycleCameraPrefix>(_onCycleCameraPrefix);
|
||||
on<ExecuteCrossSwitch>(_onExecuteCrossSwitch);
|
||||
on<UpdateViewerCamera>(_onUpdateViewerCamera);
|
||||
on<SetViewerAlarm>(_onSetViewerAlarm);
|
||||
on<SetViewerLock>(_onSetViewerLock);
|
||||
on<ToggleSectionExpanded>(_onToggleSectionExpanded);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_editTimeoutTimer?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
void _restartEditTimeout() {
|
||||
_editTimeoutTimer?.cancel();
|
||||
_editTimeoutTimer = Timer(_editTimeout, () {
|
||||
add(const CancelCameraEdit());
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelEditTimeout() {
|
||||
_editTimeoutTimer?.cancel();
|
||||
}
|
||||
|
||||
void _onLoadWallConfig(LoadWallConfig event, Emitter<WallState> emit) {
|
||||
emit(state.copyWith(isLoading: true, clearError: true));
|
||||
|
||||
try {
|
||||
// Use provided config or load sample
|
||||
final config = event.config ?? WallConfig.sample();
|
||||
|
||||
// Initialize viewer states for all viewers
|
||||
final viewerStates = <int, ViewerState>{};
|
||||
for (final viewerId in config.allViewerIds) {
|
||||
viewerStates[viewerId] = ViewerState(viewerId: viewerId);
|
||||
}
|
||||
|
||||
// Expand all sections by default
|
||||
final expandedSections = config.sections.map((s) => s.id).toSet();
|
||||
|
||||
emit(state.copyWith(
|
||||
config: config,
|
||||
isLoading: false,
|
||||
viewerStates: viewerStates,
|
||||
expandedSections: expandedSections,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to load wall config: $e',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelectViewer(SelectViewer event, Emitter<WallState> emit) {
|
||||
if (state.config == null) return;
|
||||
|
||||
// Find the physical monitor containing this viewer
|
||||
final monitor = state.config!.findMonitorByViewerId(event.viewerId);
|
||||
|
||||
_cancelEditTimeout();
|
||||
emit(state.copyWith(
|
||||
selectedViewerId: event.viewerId,
|
||||
selectedPhysicalMonitorId: monitor?.id,
|
||||
cameraNumberInput: '',
|
||||
isEditing: false,
|
||||
));
|
||||
}
|
||||
|
||||
void _onDeselectViewer(DeselectViewer event, Emitter<WallState> emit) {
|
||||
_cancelEditTimeout();
|
||||
emit(state.copyWith(
|
||||
clearSelection: true,
|
||||
cameraNumberInput: '',
|
||||
isEditing: false,
|
||||
));
|
||||
}
|
||||
|
||||
void _onSetCameraPrefix(SetCameraPrefix event, Emitter<WallState> emit) {
|
||||
if (event.prefix != 500 && event.prefix != 501 && event.prefix != 502) {
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(cameraPrefix: event.prefix));
|
||||
}
|
||||
|
||||
void _onAddCameraDigit(AddCameraDigit event, Emitter<WallState> emit) {
|
||||
if (event.digit < 0 || event.digit > 9) return;
|
||||
if (state.selectedViewerId == null) return;
|
||||
if (state.cameraNumberInput.length >= 6) return; // Max 6 digits (legacy)
|
||||
|
||||
_restartEditTimeout();
|
||||
emit(state.copyWith(
|
||||
cameraNumberInput: state.cameraNumberInput + event.digit.toString(),
|
||||
isEditing: true,
|
||||
));
|
||||
}
|
||||
|
||||
void _onBackspaceCameraDigit(
|
||||
BackspaceCameraDigit event, Emitter<WallState> emit) {
|
||||
if (state.cameraNumberInput.isEmpty) return;
|
||||
|
||||
final newInput = state.cameraNumberInput
|
||||
.substring(0, state.cameraNumberInput.length - 1);
|
||||
if (newInput.isEmpty) {
|
||||
_cancelEditTimeout();
|
||||
emit(state.copyWith(cameraNumberInput: '', isEditing: false));
|
||||
} else {
|
||||
_restartEditTimeout();
|
||||
emit(state.copyWith(cameraNumberInput: newInput));
|
||||
}
|
||||
}
|
||||
|
||||
void _onCancelCameraEdit(CancelCameraEdit event, Emitter<WallState> emit) {
|
||||
_cancelEditTimeout();
|
||||
emit(state.copyWith(cameraNumberInput: '', isEditing: false));
|
||||
}
|
||||
|
||||
void _onCycleCameraPrefix(
|
||||
CycleCameraPrefix event, Emitter<WallState> emit) {
|
||||
const prefixes = [500, 501, 502];
|
||||
final idx = prefixes.indexOf(state.cameraPrefix);
|
||||
final next = prefixes[(idx + 1) % prefixes.length];
|
||||
emit(state.copyWith(cameraPrefix: next));
|
||||
}
|
||||
|
||||
Future<void> _onExecuteCrossSwitch(
|
||||
ExecuteCrossSwitch event, Emitter<WallState> emit) async {
|
||||
print('CrossSwitch: canExecute=${state.canExecuteCrossSwitch}, selectedViewer=${state.selectedViewerId}, cameraInput=${state.cameraNumberInput}, fullCamera=${state.fullCameraNumber}');
|
||||
if (!state.canExecuteCrossSwitch) {
|
||||
print('CrossSwitch: Cannot execute - returning early');
|
||||
return;
|
||||
}
|
||||
|
||||
final viewerId = state.selectedViewerId!;
|
||||
final cameraId = state.fullCameraNumber!;
|
||||
|
||||
try {
|
||||
print('CrossSwitch: Calling viewerConnectLive(viewer=$viewerId, camera=$cameraId)');
|
||||
// Execute CrossSwitch via bridge service
|
||||
final result = await _bridgeService.viewerConnectLive(viewerId, cameraId);
|
||||
print('CrossSwitch: Result = $result');
|
||||
|
||||
// Update local state
|
||||
final viewerStates = Map<int, ViewerState>.from(state.viewerStates);
|
||||
viewerStates[viewerId] = state.getViewerState(viewerId).copyWith(
|
||||
currentCameraId: cameraId,
|
||||
isLive: true,
|
||||
);
|
||||
|
||||
_cancelEditTimeout();
|
||||
emit(state.copyWith(
|
||||
viewerStates: viewerStates,
|
||||
cameraNumberInput: '',
|
||||
isEditing: false,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: 'CrossSwitch failed: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
void _onUpdateViewerCamera(
|
||||
UpdateViewerCamera event, Emitter<WallState> emit) {
|
||||
final viewerStates = Map<int, ViewerState>.from(state.viewerStates);
|
||||
|
||||
viewerStates[event.viewerId] =
|
||||
(viewerStates[event.viewerId] ?? ViewerState(viewerId: event.viewerId))
|
||||
.copyWith(
|
||||
currentCameraId: event.cameraId,
|
||||
isLive: event.isLive,
|
||||
);
|
||||
|
||||
emit(state.copyWith(viewerStates: viewerStates));
|
||||
}
|
||||
|
||||
void _onSetViewerAlarm(SetViewerAlarm event, Emitter<WallState> emit) {
|
||||
final viewerStates = Map<int, ViewerState>.from(state.viewerStates);
|
||||
|
||||
viewerStates[event.viewerId] =
|
||||
(viewerStates[event.viewerId] ?? ViewerState(viewerId: event.viewerId))
|
||||
.copyWith(hasAlarm: event.hasAlarm);
|
||||
|
||||
emit(state.copyWith(viewerStates: viewerStates));
|
||||
}
|
||||
|
||||
void _onSetViewerLock(SetViewerLock event, Emitter<WallState> emit) {
|
||||
final viewerStates = Map<int, ViewerState>.from(state.viewerStates);
|
||||
|
||||
viewerStates[event.viewerId] =
|
||||
(viewerStates[event.viewerId] ?? ViewerState(viewerId: event.viewerId))
|
||||
.copyWith(
|
||||
isLocked: event.isLocked,
|
||||
lockedBy: event.lockedBy,
|
||||
);
|
||||
|
||||
emit(state.copyWith(viewerStates: viewerStates));
|
||||
}
|
||||
|
||||
void _onToggleSectionExpanded(
|
||||
ToggleSectionExpanded event, Emitter<WallState> emit) {
|
||||
final expandedSections = Set<String>.from(state.expandedSections);
|
||||
|
||||
if (expandedSections.contains(event.sectionId)) {
|
||||
expandedSections.remove(event.sectionId);
|
||||
} else {
|
||||
expandedSections.add(event.sectionId);
|
||||
}
|
||||
|
||||
emit(state.copyWith(expandedSections: expandedSections));
|
||||
}
|
||||
}
|
||||
131
copilot_keyboard/lib/presentation/blocs/wall/wall_event.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../domain/entities/wall_config.dart';
|
||||
|
||||
abstract class WallEvent extends Equatable {
|
||||
const WallEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Load wall configuration
|
||||
class LoadWallConfig extends WallEvent {
|
||||
final WallConfig? config;
|
||||
|
||||
const LoadWallConfig([this.config]);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [config];
|
||||
}
|
||||
|
||||
/// Select a viewer by tapping on it
|
||||
class SelectViewer extends WallEvent {
|
||||
final int viewerId;
|
||||
|
||||
const SelectViewer(this.viewerId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [viewerId];
|
||||
}
|
||||
|
||||
/// Deselect current viewer
|
||||
class DeselectViewer extends WallEvent {
|
||||
const DeselectViewer();
|
||||
}
|
||||
|
||||
/// Update camera prefix for input
|
||||
class SetCameraPrefix extends WallEvent {
|
||||
final int prefix; // 500, 501, 502
|
||||
|
||||
const SetCameraPrefix(this.prefix);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [prefix];
|
||||
}
|
||||
|
||||
/// Add digit to camera number input
|
||||
class AddCameraDigit extends WallEvent {
|
||||
final int digit;
|
||||
|
||||
const AddCameraDigit(this.digit);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [digit];
|
||||
}
|
||||
|
||||
/// Remove last digit from camera input (Backspace)
|
||||
class BackspaceCameraDigit extends WallEvent {
|
||||
const BackspaceCameraDigit();
|
||||
}
|
||||
|
||||
/// Cancel camera edit (Escape or timeout)
|
||||
class CancelCameraEdit extends WallEvent {
|
||||
const CancelCameraEdit();
|
||||
}
|
||||
|
||||
/// Cycle to next camera prefix (Prefix key)
|
||||
class CycleCameraPrefix extends WallEvent {
|
||||
const CycleCameraPrefix();
|
||||
}
|
||||
|
||||
/// Execute CrossSwitch with current camera input
|
||||
class ExecuteCrossSwitch extends WallEvent {
|
||||
const ExecuteCrossSwitch();
|
||||
}
|
||||
|
||||
/// Update viewer state (from WebSocket events)
|
||||
class UpdateViewerCamera extends WallEvent {
|
||||
final int viewerId;
|
||||
final int cameraId;
|
||||
final bool isLive;
|
||||
|
||||
const UpdateViewerCamera({
|
||||
required this.viewerId,
|
||||
required this.cameraId,
|
||||
this.isLive = true,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [viewerId, cameraId, isLive];
|
||||
}
|
||||
|
||||
/// Set alarm state on a viewer
|
||||
class SetViewerAlarm extends WallEvent {
|
||||
final int viewerId;
|
||||
final bool hasAlarm;
|
||||
|
||||
const SetViewerAlarm({
|
||||
required this.viewerId,
|
||||
required this.hasAlarm,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [viewerId, hasAlarm];
|
||||
}
|
||||
|
||||
/// Set lock state on a viewer's camera
|
||||
class SetViewerLock extends WallEvent {
|
||||
final int viewerId;
|
||||
final bool isLocked;
|
||||
final String? lockedBy;
|
||||
|
||||
const SetViewerLock({
|
||||
required this.viewerId,
|
||||
required this.isLocked,
|
||||
this.lockedBy,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [viewerId, isLocked, lockedBy];
|
||||
}
|
||||
|
||||
/// Toggle expanded section
|
||||
class ToggleSectionExpanded extends WallEvent {
|
||||
final String sectionId;
|
||||
|
||||
const ToggleSectionExpanded(this.sectionId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [sectionId];
|
||||
}
|
||||
180
copilot_keyboard/lib/presentation/blocs/wall/wall_state.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../domain/entities/wall_config.dart';
|
||||
|
||||
/// State for a single viewer
|
||||
class ViewerState extends Equatable {
|
||||
final int viewerId;
|
||||
final int currentCameraId;
|
||||
final bool isLive;
|
||||
final bool hasAlarm;
|
||||
final bool isLocked;
|
||||
final String? lockedBy;
|
||||
|
||||
const ViewerState({
|
||||
required this.viewerId,
|
||||
this.currentCameraId = 0,
|
||||
this.isLive = true,
|
||||
this.hasAlarm = false,
|
||||
this.isLocked = false,
|
||||
this.lockedBy,
|
||||
});
|
||||
|
||||
bool get hasCamera => currentCameraId > 0;
|
||||
bool get isLockedByOther => isLocked && lockedBy != null;
|
||||
|
||||
ViewerState copyWith({
|
||||
int? currentCameraId,
|
||||
bool? isLive,
|
||||
bool? hasAlarm,
|
||||
bool? isLocked,
|
||||
String? lockedBy,
|
||||
}) {
|
||||
return ViewerState(
|
||||
viewerId: viewerId,
|
||||
currentCameraId: currentCameraId ?? this.currentCameraId,
|
||||
isLive: isLive ?? this.isLive,
|
||||
hasAlarm: hasAlarm ?? this.hasAlarm,
|
||||
isLocked: isLocked ?? this.isLocked,
|
||||
lockedBy: lockedBy ?? this.lockedBy,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[viewerId, currentCameraId, isLive, hasAlarm, isLocked, lockedBy];
|
||||
}
|
||||
|
||||
/// Main wall bloc state
|
||||
class WallState extends Equatable {
|
||||
final WallConfig? config;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
// Selection state
|
||||
final int? selectedViewerId;
|
||||
final int? selectedPhysicalMonitorId;
|
||||
|
||||
// Camera input state
|
||||
final int cameraPrefix; // 500, 501, 502
|
||||
final String cameraNumberInput; // Up to 6 digits typed by user
|
||||
final bool isEditing; // Whether camera input is active
|
||||
|
||||
// Viewer states (keyed by viewer ID)
|
||||
final Map<int, ViewerState> viewerStates;
|
||||
|
||||
// Expanded sections
|
||||
final Set<String> expandedSections;
|
||||
|
||||
const WallState({
|
||||
this.config,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.selectedViewerId,
|
||||
this.selectedPhysicalMonitorId,
|
||||
this.cameraPrefix = 500,
|
||||
this.cameraNumberInput = '',
|
||||
this.isEditing = false,
|
||||
this.viewerStates = const {},
|
||||
this.expandedSections = const {},
|
||||
});
|
||||
|
||||
static const int _maxLength = 6;
|
||||
static const List<int> _prefixes = [500, 501, 502];
|
||||
|
||||
/// Compose camera number with prefix (legacy CameraNumber.GetCameraNumberWithPrefix).
|
||||
/// If digits > prefix length: use digits as-is, right-pad with zeros.
|
||||
/// If digits <= prefix length: prefix + left-padded digits.
|
||||
int? get fullCameraNumber {
|
||||
if (cameraNumberInput.isEmpty) return null;
|
||||
|
||||
final prefix = cameraPrefix.toString();
|
||||
final String composed;
|
||||
|
||||
if (cameraNumberInput.length > prefix.length) {
|
||||
composed = cameraNumberInput.padRight(_maxLength, '0');
|
||||
} else {
|
||||
composed = prefix +
|
||||
cameraNumberInput.padLeft(_maxLength - prefix.length, '0');
|
||||
}
|
||||
|
||||
return int.tryParse(composed);
|
||||
}
|
||||
|
||||
/// Display string: typed digits only (no prefix shown in field).
|
||||
String get cameraInputDisplay {
|
||||
if (!isEditing || cameraNumberInput.isEmpty) return '';
|
||||
return cameraNumberInput;
|
||||
}
|
||||
|
||||
/// Check if a viewer is selected
|
||||
bool isViewerSelected(int viewerId) => selectedViewerId == viewerId;
|
||||
|
||||
/// Check if a physical monitor is selected (any of its viewers)
|
||||
bool isPhysicalMonitorSelected(PhysicalMonitor monitor) =>
|
||||
selectedPhysicalMonitorId == monitor.id;
|
||||
|
||||
/// Get viewer state
|
||||
ViewerState getViewerState(int viewerId) {
|
||||
return viewerStates[viewerId] ?? ViewerState(viewerId: viewerId);
|
||||
}
|
||||
|
||||
/// Check if section is expanded
|
||||
bool isSectionExpanded(String sectionId) =>
|
||||
expandedSections.contains(sectionId);
|
||||
|
||||
/// Check if CrossSwitch can be executed
|
||||
bool get canExecuteCrossSwitch {
|
||||
if (selectedViewerId == null) return false;
|
||||
if (!isEditing || cameraNumberInput.isEmpty) return false;
|
||||
if (fullCameraNumber == null) return false;
|
||||
final viewerState = getViewerState(selectedViewerId!);
|
||||
if (viewerState.hasAlarm) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
WallState copyWith({
|
||||
WallConfig? config,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
int? selectedViewerId,
|
||||
int? selectedPhysicalMonitorId,
|
||||
int? cameraPrefix,
|
||||
String? cameraNumberInput,
|
||||
bool? isEditing,
|
||||
Map<int, ViewerState>? viewerStates,
|
||||
Set<String>? expandedSections,
|
||||
bool clearSelection = false,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return WallState(
|
||||
config: config ?? this.config,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
selectedViewerId:
|
||||
clearSelection ? null : (selectedViewerId ?? this.selectedViewerId),
|
||||
selectedPhysicalMonitorId: clearSelection
|
||||
? null
|
||||
: (selectedPhysicalMonitorId ?? this.selectedPhysicalMonitorId),
|
||||
cameraPrefix: cameraPrefix ?? this.cameraPrefix,
|
||||
cameraNumberInput: cameraNumberInput ?? this.cameraNumberInput,
|
||||
isEditing: isEditing ?? this.isEditing,
|
||||
viewerStates: viewerStates ?? this.viewerStates,
|
||||
expandedSections: expandedSections ?? this.expandedSections,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
config,
|
||||
isLoading,
|
||||
error,
|
||||
selectedViewerId,
|
||||
selectedPhysicalMonitorId,
|
||||
cameraPrefix,
|
||||
cameraNumberInput,
|
||||
isEditing,
|
||||
viewerStates,
|
||||
expandedSections,
|
||||
];
|
||||
}
|
||||
349
copilot_keyboard/lib/presentation/screens/keyboard_screen.dart
Normal file
@@ -0,0 +1,349 @@
|
||||
import 'package:flutter/material.dart' hide LockState;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import '../../data/services/function_button_service.dart';
|
||||
import '../../injection_container.dart';
|
||||
import '../blocs/connection/connection_bloc.dart';
|
||||
import '../blocs/camera/camera_bloc.dart';
|
||||
import '../blocs/monitor/monitor_bloc.dart';
|
||||
import '../blocs/ptz/ptz_bloc.dart';
|
||||
import '../blocs/alarm/alarm_bloc.dart';
|
||||
import '../blocs/lock/lock_bloc.dart';
|
||||
import '../blocs/lock/lock_event.dart';
|
||||
import '../blocs/lock/lock_state.dart';
|
||||
import '../blocs/sequence/sequence_bloc.dart';
|
||||
import '../blocs/sequence/sequence_event.dart';
|
||||
import '../blocs/wall/wall_bloc.dart';
|
||||
import '../blocs/wall/wall_event.dart';
|
||||
import '../widgets/wall_grid/wall_grid.dart';
|
||||
import '../widgets/toolbar/bottom_toolbar.dart';
|
||||
import '../widgets/connection_status_bar.dart';
|
||||
import '../widgets/ptz_control.dart';
|
||||
import '../widgets/sequence_panel.dart';
|
||||
import '../widgets/takeover_dialog.dart';
|
||||
|
||||
class KeyboardScreen extends StatelessWidget {
|
||||
const KeyboardScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<ConnectionBloc>(create: (_) => sl<ConnectionBloc>()),
|
||||
BlocProvider<CameraBloc>(create: (_) => sl<CameraBloc>()),
|
||||
BlocProvider<MonitorBloc>(create: (_) => sl<MonitorBloc>()),
|
||||
BlocProvider<PtzBloc>(create: (_) => sl<PtzBloc>()),
|
||||
BlocProvider<AlarmBloc>(create: (_) => sl<AlarmBloc>()),
|
||||
BlocProvider<LockBloc>(create: (_) => sl<LockBloc>()),
|
||||
BlocProvider<SequenceBloc>(
|
||||
create: (_) => sl<SequenceBloc>()..add(LoadSequences()),
|
||||
),
|
||||
BlocProvider<WallBloc>(
|
||||
create: (_) => sl<WallBloc>()..add(const LoadWallConfig()),
|
||||
),
|
||||
],
|
||||
child: BlocListener<LockBloc, LockState>(
|
||||
listenWhen: (prev, curr) =>
|
||||
prev.pendingTakeover != curr.pendingTakeover &&
|
||||
curr.pendingTakeover != null,
|
||||
listener: (context, state) {
|
||||
if (state.pendingTakeover != null) {
|
||||
showTakeoverDialog(context, state.pendingTakeover!);
|
||||
}
|
||||
},
|
||||
child: const _KeyboardScreenContent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeyboardScreenContent extends StatelessWidget {
|
||||
const _KeyboardScreenContent();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0D1117),
|
||||
body: Column(
|
||||
children: [
|
||||
// Top bar with connection status
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF161B22),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFF30363D), width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'COPILOT Keyboard',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'v0.3.0-build5',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF7F7F7F),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const ConnectionStatusBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Main content area
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Wide layout with PTZ on the side
|
||||
if (constraints.maxWidth > 1200) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Wall grid
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const WallGrid(),
|
||||
),
|
||||
),
|
||||
// PTZ controls sidebar
|
||||
Container(
|
||||
width: 220,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF161B22),
|
||||
border: Border(
|
||||
left: BorderSide(color: Color(0xFF30363D), width: 1),
|
||||
),
|
||||
),
|
||||
child: const SingleChildScrollView(
|
||||
child: PtzControl(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
// Narrow layout - PTZ in bottom sheet or collapsed
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const WallGrid(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Bottom toolbar
|
||||
BottomToolbar(
|
||||
onSearchPressed: () => _showSearchDialog(context),
|
||||
onPrepositionPressed: () => _showPrepositionDialog(context),
|
||||
onPlaybackPressed: () => _showPlaybackOverlay(context),
|
||||
onAlarmListPressed: () => _showAlarmListDialog(context),
|
||||
onSequencePressed: () => _showSequenceDialog(context),
|
||||
onLockPressed: () => _toggleLock(context),
|
||||
onFunctionButtonPressed: (buttonId) =>
|
||||
_executeFunctionButton(context, buttonId),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSearchDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A202C),
|
||||
title: const Text(
|
||||
'Hledat kameru',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Číslo nebo název kamery',
|
||||
hintStyle: TextStyle(color: Colors.white54),
|
||||
prefixIcon: Icon(Icons.search, color: Colors.white54),
|
||||
border: OutlineInputBorder(),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.white24),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Color(0xFF00D4FF)),
|
||||
),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Funkce bude implementována v další fázi.',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Zavřít'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPrepositionDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A202C),
|
||||
title: const Text(
|
||||
'Prepozice',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
content: const SizedBox(
|
||||
width: 400,
|
||||
height: 300,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Seznam prepozic bude implementován v další fázi.',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Zavřít'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlaybackOverlay(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Režim přehrávání (PvZ) bude implementován v další fázi.'),
|
||||
backgroundColor: Color(0xFF2D3748),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSequenceDialog(BuildContext context) {
|
||||
final wallState = context.read<WallBloc>().state;
|
||||
final viewerId = wallState.selectedViewerId;
|
||||
if (viewerId == null) return;
|
||||
|
||||
// Refresh sequence list
|
||||
context.read<SequenceBloc>().add(LoadSequences());
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => BlocProvider.value(
|
||||
value: context.read<SequenceBloc>(),
|
||||
child: AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A202C),
|
||||
title: Text(
|
||||
'Sekvence — Monitor $viewerId',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 500,
|
||||
height: 400,
|
||||
child: SingleChildScrollView(
|
||||
child: SequencePanel(viewerId: viewerId),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Zavřít'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlarmListDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A202C),
|
||||
title: const Text(
|
||||
'Historie alarmů',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
content: const SizedBox(
|
||||
width: 500,
|
||||
height: 400,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Seznam alarmů bude implementován v další fázi.',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Zavřít'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleLock(BuildContext context) {
|
||||
final wallState = context.read<WallBloc>().state;
|
||||
final viewerId = wallState.selectedViewerId;
|
||||
if (viewerId == null) return;
|
||||
|
||||
final viewerState = wallState.getViewerState(viewerId);
|
||||
final cameraId = viewerState.currentCameraId;
|
||||
if (cameraId <= 0) return;
|
||||
|
||||
final lockBloc = context.read<LockBloc>();
|
||||
final lockState = lockBloc.state;
|
||||
|
||||
if (lockState.isCameraLockedByMe(
|
||||
cameraId, sl<AppConfig>().keyboardId)) {
|
||||
lockBloc.add(ReleaseLock(cameraId));
|
||||
} else {
|
||||
lockBloc.add(TryLock(cameraId));
|
||||
}
|
||||
}
|
||||
|
||||
void _executeFunctionButton(BuildContext context, String buttonId) {
|
||||
final wallBloc = context.read<WallBloc>();
|
||||
final wallId = wallBloc.state.config?.id ?? '1';
|
||||
final service = sl<FunctionButtonService>();
|
||||
|
||||
if (!service.hasActions(wallId, buttonId)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$buttonId — žádná akce pro tuto stěnu'),
|
||||
backgroundColor: const Color(0xFF2D3748),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
service.execute(wallId, buttonId);
|
||||
}
|
||||
}
|
||||
168
copilot_keyboard/lib/presentation/screens/main_screen.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../blocs/wall/wall_bloc.dart';
|
||||
import '../blocs/wall/wall_event.dart';
|
||||
import '../blocs/wall/wall_state.dart';
|
||||
import '../widgets/overview/wall_overview.dart';
|
||||
import '../widgets/section/section_view.dart';
|
||||
|
||||
class MainScreen extends StatefulWidget {
|
||||
const MainScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MainScreen> createState() => _MainScreenState();
|
||||
}
|
||||
|
||||
class _MainScreenState extends State<MainScreen> {
|
||||
String? _selectedSectionId;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
WallBloc? _wallBloc;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<WallBloc, WallState>(
|
||||
builder: (blocContext, state) {
|
||||
// Store bloc reference for keyboard handler
|
||||
_wallBloc = BlocProvider.of<WallBloc>(blocContext, listen: false);
|
||||
|
||||
return KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFF0A0E14),
|
||||
body: _buildBody(blocContext, state),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context, WallState state) {
|
||||
if (state.isLoading || state.config == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF00D4FF),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Show section view or wall overview
|
||||
if (_selectedSectionId != null) {
|
||||
final section = state.config!.sections.firstWhere(
|
||||
(s) => s.id == _selectedSectionId,
|
||||
orElse: () => state.config!.sections.first,
|
||||
);
|
||||
return SectionView(
|
||||
section: section,
|
||||
wallState: state,
|
||||
onBack: () => setState(() => _selectedSectionId = null),
|
||||
onViewerTap: (viewerId) {
|
||||
context.read<WallBloc>().add(SelectViewer(viewerId));
|
||||
// Re-request focus for keyboard input after tile tap
|
||||
_focusNode.requestFocus();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return WallOverview(
|
||||
config: state.config!,
|
||||
wallState: state,
|
||||
onSectionTap: (sectionId) {
|
||||
setState(() => _selectedSectionId = sectionId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleKeyEvent(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
|
||||
final bloc = _wallBloc;
|
||||
if (bloc == null) return;
|
||||
|
||||
final state = bloc.state;
|
||||
final key = event.logicalKey;
|
||||
|
||||
// Escape - go back or deselect
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
if (state.selectedViewerId != null) {
|
||||
bloc.add(const DeselectViewer());
|
||||
} else if (_selectedSectionId != null) {
|
||||
setState(() => _selectedSectionId = null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle camera input when a viewer is selected
|
||||
if (state.selectedViewerId == null) return;
|
||||
|
||||
// Digit keys 0-9
|
||||
if (key.keyId >= LogicalKeyboardKey.digit0.keyId &&
|
||||
key.keyId <= LogicalKeyboardKey.digit9.keyId) {
|
||||
final digit = key.keyId - LogicalKeyboardKey.digit0.keyId;
|
||||
bloc.add(AddCameraDigit(digit));
|
||||
return;
|
||||
}
|
||||
|
||||
// Numpad digits
|
||||
if (key.keyId >= LogicalKeyboardKey.numpad0.keyId &&
|
||||
key.keyId <= LogicalKeyboardKey.numpad9.keyId) {
|
||||
final digit = key.keyId - LogicalKeyboardKey.numpad0.keyId;
|
||||
bloc.add(AddCameraDigit(digit));
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter - execute CrossSwitch
|
||||
if (key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.numpadEnter) {
|
||||
if (state.canExecuteCrossSwitch) {
|
||||
bloc.add(const ExecuteCrossSwitch());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace - remove last digit
|
||||
if (key == LogicalKeyboardKey.backspace) {
|
||||
bloc.add(const BackspaceCameraDigit());
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete - cancel edit
|
||||
if (key == LogicalKeyboardKey.delete) {
|
||||
bloc.add(const CancelCameraEdit());
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape - cancel edit or deselect
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
if (state.isEditing) {
|
||||
bloc.add(const CancelCameraEdit());
|
||||
} else {
|
||||
bloc.add(const DeselectViewer());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// F1-F3 for prefix selection
|
||||
if (key == LogicalKeyboardKey.f1) {
|
||||
bloc.add(const SetCameraPrefix(500));
|
||||
return;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.f2) {
|
||||
bloc.add(const SetCameraPrefix(501));
|
||||
return;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.f3) {
|
||||
bloc.add(const SetCameraPrefix(502));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
253
copilot_keyboard/lib/presentation/widgets/alarm_panel.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../domain/entities/alarm_state.dart';
|
||||
import '../blocs/alarm/alarm_bloc.dart';
|
||||
import '../blocs/alarm/alarm_event.dart';
|
||||
import '../blocs/alarm/alarm_state.dart';
|
||||
|
||||
class AlarmPanel extends StatelessWidget {
|
||||
final int maxDisplayed;
|
||||
|
||||
const AlarmPanel({super.key, this.maxDisplayed = 5});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AlarmBloc, AlarmBlocState>(
|
||||
builder: (context, state) {
|
||||
final alarms = state.activeAlarms.take(maxDisplayed).toList();
|
||||
final hasMore = state.activeAlarms.length > maxDisplayed;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'ACTIVE ALARMS',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (state.blockingAlarmCount > 0)
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${state.blockingAlarmCount}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (state.isLoading)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: () =>
|
||||
context.read<AlarmBloc>().add(const RefreshAlarms()),
|
||||
tooltip: 'Refresh alarms',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (alarms.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.green.shade600,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'No active alarms',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.red.shade200,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
...alarms.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final alarm = entry.value;
|
||||
return _AlarmTile(
|
||||
alarm: alarm,
|
||||
isLast: index == alarms.length - 1 && !hasMore,
|
||||
);
|
||||
}),
|
||||
if (hasMore)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(7),
|
||||
bottomRight: Radius.circular(7),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'+${state.activeAlarms.length - maxDisplayed} more alarms',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
state.error!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.lastSync != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Last sync: ${_formatTime(state.lastSync!)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:'
|
||||
'${time.minute.toString().padLeft(2, '0')}:'
|
||||
'${time.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
class _AlarmTile extends StatelessWidget {
|
||||
final AlarmState alarm;
|
||||
final bool isLast;
|
||||
|
||||
const _AlarmTile({
|
||||
required this.alarm,
|
||||
required this.isLast,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBlocking = alarm.blocksMonitor;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isBlocking ? Colors.red.shade50 : null,
|
||||
border: isLast
|
||||
? null
|
||||
: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.red.shade200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isBlocking ? Icons.warning : Icons.info_outline,
|
||||
color: isBlocking ? Colors.red : Colors.orange,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
alarm.eventName.isNotEmpty
|
||||
? alarm.eventName
|
||||
: 'Event ${alarm.eventId}',
|
||||
style: TextStyle(
|
||||
fontWeight: isBlocking ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
if (alarm.foreignKey > 0)
|
||||
Text(
|
||||
'Camera/Contact: ${alarm.foreignKey}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatTime(alarm.startedAt),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (alarm.associatedMonitor != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'M${alarm.associatedMonitor}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:'
|
||||
'${time.minute.toString().padLeft(2, '0')}:'
|
||||
'${time.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
129
copilot_keyboard/lib/presentation/widgets/camera_grid.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../blocs/camera/camera_bloc.dart';
|
||||
import '../blocs/camera/camera_event.dart';
|
||||
import '../blocs/camera/camera_state.dart';
|
||||
import '../blocs/monitor/monitor_bloc.dart';
|
||||
import '../blocs/monitor/monitor_state.dart';
|
||||
|
||||
class CameraGrid extends StatelessWidget {
|
||||
final int columns;
|
||||
|
||||
const CameraGrid({super.key, this.columns = 8});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CameraBloc, CameraState>(
|
||||
builder: (context, cameraState) {
|
||||
return BlocBuilder<MonitorBloc, MonitorBlocState>(
|
||||
builder: (context, monitorState) {
|
||||
final cameras = cameraState.availableCameras;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'CAMERAS',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: cameras.map((cameraId) {
|
||||
final isSelected =
|
||||
cameraState.selectedCameraId == cameraId;
|
||||
final isOnSelectedMonitor =
|
||||
monitorState.selectedMonitorCamera == cameraId;
|
||||
|
||||
return _CameraButton(
|
||||
cameraId: cameraId,
|
||||
isSelected: isSelected,
|
||||
isOnSelectedMonitor: isOnSelectedMonitor,
|
||||
onPressed: () => _onCameraPressed(
|
||||
context,
|
||||
cameraId,
|
||||
monitorState.selectedMonitorId,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onCameraPressed(BuildContext context, int cameraId, int? monitorId) {
|
||||
final cameraBloc = context.read<CameraBloc>();
|
||||
|
||||
if (monitorId != null) {
|
||||
// Monitor is selected, connect camera to it
|
||||
cameraBloc.add(ConnectCameraToMonitor(
|
||||
cameraId: cameraId,
|
||||
monitorId: monitorId,
|
||||
));
|
||||
} else {
|
||||
// Just select the camera
|
||||
cameraBloc.add(SelectCamera(cameraId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CameraButton extends StatelessWidget {
|
||||
final int cameraId;
|
||||
final bool isSelected;
|
||||
final bool isOnSelectedMonitor;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _CameraButton({
|
||||
required this.cameraId,
|
||||
required this.isSelected,
|
||||
required this.isOnSelectedMonitor,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color backgroundColor;
|
||||
Color foregroundColor;
|
||||
|
||||
if (isSelected) {
|
||||
backgroundColor = Theme.of(context).colorScheme.primary;
|
||||
foregroundColor = Theme.of(context).colorScheme.onPrimary;
|
||||
} else if (isOnSelectedMonitor) {
|
||||
backgroundColor = Theme.of(context).colorScheme.secondary;
|
||||
foregroundColor = Theme.of(context).colorScheme.onSecondary;
|
||||
} else {
|
||||
backgroundColor = Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
foregroundColor = Theme.of(context).colorScheme.onSurface;
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 40,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'$cameraId',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../blocs/connection/connection_bloc.dart';
|
||||
import '../blocs/connection/connection_event.dart';
|
||||
import '../blocs/connection/connection_state.dart' as conn;
|
||||
|
||||
class ConnectionStatusBar extends StatelessWidget {
|
||||
const ConnectionStatusBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ConnectionBloc, conn.ConnectionState>(
|
||||
builder: (context, state) {
|
||||
Color backgroundColor;
|
||||
Color textColor;
|
||||
String statusText;
|
||||
IconData statusIcon;
|
||||
|
||||
switch (state.overallStatus) {
|
||||
case conn.ConnectionOverallStatus.connected:
|
||||
backgroundColor = Colors.green.shade100;
|
||||
textColor = Colors.green.shade800;
|
||||
statusText = 'Connected (${state.connectedCount}/${state.totalCount})';
|
||||
statusIcon = Icons.cloud_done;
|
||||
case conn.ConnectionOverallStatus.partial:
|
||||
backgroundColor = Colors.orange.shade100;
|
||||
textColor = Colors.orange.shade800;
|
||||
statusText = 'Partial (${state.connectedCount}/${state.totalCount})';
|
||||
statusIcon = Icons.cloud_off;
|
||||
case conn.ConnectionOverallStatus.connecting:
|
||||
backgroundColor = Colors.blue.shade100;
|
||||
textColor = Colors.blue.shade800;
|
||||
statusText = 'Connecting...';
|
||||
statusIcon = Icons.cloud_sync;
|
||||
case conn.ConnectionOverallStatus.disconnected:
|
||||
backgroundColor = Colors.red.shade100;
|
||||
textColor = Colors.red.shade800;
|
||||
statusText = 'Disconnected';
|
||||
statusIcon = Icons.cloud_off;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(statusIcon, color: textColor, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (state.overallStatus == conn.ConnectionOverallStatus.disconnected ||
|
||||
state.overallStatus == conn.ConnectionOverallStatus.partial) ...[
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () => context
|
||||
.read<ConnectionBloc>()
|
||||
.add(const RetryConnections()),
|
||||
child: Icon(Icons.refresh, color: textColor, size: 18),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
150
copilot_keyboard/lib/presentation/widgets/monitor_grid.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../domain/entities/monitor_state.dart';
|
||||
import '../blocs/monitor/monitor_bloc.dart';
|
||||
import '../blocs/monitor/monitor_event.dart';
|
||||
import '../blocs/monitor/monitor_state.dart';
|
||||
|
||||
class MonitorGrid extends StatelessWidget {
|
||||
final int columns;
|
||||
|
||||
const MonitorGrid({super.key, this.columns = 4});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<MonitorBloc, MonitorBlocState>(
|
||||
builder: (context, state) {
|
||||
final monitors = state.availableMonitors;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'MONITORS',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: monitors.map((monitorId) {
|
||||
final isSelected = state.selectedMonitorId == monitorId;
|
||||
final monitorState = state.monitorStates[monitorId];
|
||||
|
||||
return _MonitorButton(
|
||||
monitorId: monitorId,
|
||||
isSelected: isSelected,
|
||||
monitorState: monitorState,
|
||||
onPressed: () => _onMonitorPressed(context, monitorId),
|
||||
onLongPress: () => _onMonitorLongPress(context, monitorId),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onMonitorPressed(BuildContext context, int monitorId) {
|
||||
context.read<MonitorBloc>().add(SelectMonitor(monitorId));
|
||||
}
|
||||
|
||||
void _onMonitorLongPress(BuildContext context, int monitorId) {
|
||||
context.read<MonitorBloc>().add(ClearMonitor(monitorId));
|
||||
}
|
||||
}
|
||||
|
||||
class _MonitorButton extends StatelessWidget {
|
||||
final int monitorId;
|
||||
final bool isSelected;
|
||||
final MonitorState? monitorState;
|
||||
final VoidCallback onPressed;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
const _MonitorButton({
|
||||
required this.monitorId,
|
||||
required this.isSelected,
|
||||
this.monitorState,
|
||||
required this.onPressed,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasAlarm = monitorState?.hasAlarm ?? false;
|
||||
final currentCamera = monitorState?.currentChannel ?? 0;
|
||||
final isActive = currentCamera > 0;
|
||||
|
||||
Color backgroundColor;
|
||||
Color foregroundColor;
|
||||
Color? borderColor;
|
||||
|
||||
if (hasAlarm) {
|
||||
backgroundColor = Colors.red.shade700;
|
||||
foregroundColor = Colors.white;
|
||||
borderColor = Colors.red.shade900;
|
||||
} else if (isSelected) {
|
||||
backgroundColor = Theme.of(context).colorScheme.primary;
|
||||
foregroundColor = Theme.of(context).colorScheme.onPrimary;
|
||||
} else if (isActive) {
|
||||
backgroundColor = Theme.of(context).colorScheme.primaryContainer;
|
||||
foregroundColor = Theme.of(context).colorScheme.onPrimaryContainer;
|
||||
} else {
|
||||
backgroundColor = Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
foregroundColor = Theme.of(context).colorScheme.onSurface;
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: 64,
|
||||
height: 48,
|
||||
child: GestureDetector(
|
||||
onLongPress: onLongPress,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
padding: const EdgeInsets.all(4),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
side: borderColor != null
|
||||
? BorderSide(color: borderColor, width: 2)
|
||||
: BorderSide.none,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (hasAlarm)
|
||||
const Icon(Icons.warning, size: 12, color: Colors.yellow),
|
||||
Text(
|
||||
'$monitorId',
|
||||
style: const TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isActive)
|
||||
Text(
|
||||
'C$currentCamera',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: foregroundColor.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../domain/entities/wall_config.dart';
|
||||
import '../../blocs/wall/wall_state.dart';
|
||||
|
||||
/// Overview screen showing all wall sections in spatial layout (matching D6)
|
||||
class WallOverview extends StatelessWidget {
|
||||
final WallConfig config;
|
||||
final WallState wallState;
|
||||
final Function(String sectionId) onSectionTap;
|
||||
|
||||
const WallOverview({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.wallState,
|
||||
required this.onSectionTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: const Color(0xFF5A5A5A), // D6 background color
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
children: _buildSpatialLayout(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildSpatialLayout(BoxConstraints constraints) {
|
||||
final widgets = <Widget>[];
|
||||
final width = constraints.maxWidth;
|
||||
final height = constraints.maxHeight;
|
||||
|
||||
// Position sections based on D6 layout
|
||||
// The D6 app shows:
|
||||
// - "4. Vrchní část" (top) at the top center
|
||||
// - "1. Levá část" (left), "2. Střed stěny" (center), "3. Pravá část" (right) in middle row
|
||||
// - "5. Stupínek" (bottom) at the bottom center
|
||||
|
||||
for (final section in config.sections) {
|
||||
final position = _getSectionPosition(section.id, width, height);
|
||||
final size = _getSectionSize(section.id, width, height);
|
||||
|
||||
widgets.add(
|
||||
Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: _SectionTile(
|
||||
section: section,
|
||||
wallState: wallState,
|
||||
onTap: () => onSectionTap(section.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
Offset _getSectionPosition(String sectionId, double width, double height) {
|
||||
// Layout matching D6 screenshot
|
||||
switch (sectionId) {
|
||||
case 'top': // 4. Vrchní část - top center
|
||||
return Offset(width * 0.35, height * 0.05);
|
||||
case 'left': // 1. Levá část - middle left
|
||||
return Offset(width * 0.08, height * 0.35);
|
||||
case 'center': // 2. Střed stěny - middle center
|
||||
return Offset(width * 0.33, height * 0.35);
|
||||
case 'right': // 3. Pravá část - middle right
|
||||
return Offset(width * 0.58, height * 0.35);
|
||||
case 'bottom': // 5. Stupínek - bottom center
|
||||
return Offset(width * 0.33, height * 0.68);
|
||||
default:
|
||||
return Offset.zero;
|
||||
}
|
||||
}
|
||||
|
||||
Size _getSectionSize(String sectionId, double width, double height) {
|
||||
// Sizes proportional to D6 layout
|
||||
switch (sectionId) {
|
||||
case 'top':
|
||||
return Size(width * 0.30, height * 0.22);
|
||||
case 'left':
|
||||
return Size(width * 0.22, height * 0.25);
|
||||
case 'center':
|
||||
return Size(width * 0.22, height * 0.25);
|
||||
case 'right':
|
||||
return Size(width * 0.22, height * 0.25);
|
||||
case 'bottom':
|
||||
return Size(width * 0.30, height * 0.25);
|
||||
default:
|
||||
return Size(width * 0.2, height * 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTile extends StatelessWidget {
|
||||
final WallSection section;
|
||||
final WallState wallState;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SectionTile({
|
||||
required this.section,
|
||||
required this.wallState,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section label
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'${_getSectionNumber(section.id)}. ${section.name}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Mini monitors grid
|
||||
Expanded(
|
||||
child: _MiniMonitorsGrid(
|
||||
section: section,
|
||||
wallState: wallState,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _getSectionNumber(String id) {
|
||||
switch (id) {
|
||||
case 'left': return 1;
|
||||
case 'center': return 2;
|
||||
case 'right': return 3;
|
||||
case 'top': return 4;
|
||||
case 'bottom': return 5;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniMonitorsGrid extends StatelessWidget {
|
||||
final WallSection section;
|
||||
final WallState wallState;
|
||||
|
||||
const _MiniMonitorsGrid({
|
||||
required this.section,
|
||||
required this.wallState,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final monitors = section.monitors;
|
||||
final gridCols = section.columns;
|
||||
final gridRows = section.rows;
|
||||
|
||||
// Calculate cell dimensions based on grid size
|
||||
final cellWidth = constraints.maxWidth / gridCols;
|
||||
final cellHeight = constraints.maxHeight / gridRows;
|
||||
|
||||
// Position monitors using their explicit row/col values (matching detail view)
|
||||
return Stack(
|
||||
children: monitors.map((monitor) {
|
||||
// Convert 1-based row/col to 0-based for positioning
|
||||
final row = monitor.row - 1;
|
||||
final col = monitor.col - 1;
|
||||
|
||||
return Positioned(
|
||||
left: col * cellWidth,
|
||||
top: row * cellHeight,
|
||||
width: monitor.colSpan * cellWidth,
|
||||
height: monitor.rowSpan * cellHeight,
|
||||
child: _MiniPhysicalMonitor(
|
||||
monitor: monitor,
|
||||
wallState: wallState,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniPhysicalMonitor extends StatelessWidget {
|
||||
final PhysicalMonitor monitor;
|
||||
final WallState wallState;
|
||||
|
||||
const _MiniPhysicalMonitor({
|
||||
required this.monitor,
|
||||
required this.wallState,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewers = monitor.viewerIds;
|
||||
final gridCols = monitor.colSpan;
|
||||
final gridRows = monitor.rowSpan;
|
||||
|
||||
// Overview: no cyan borders, just dark grid lines between viewers
|
||||
return Container(
|
||||
color: const Color(0xFF4A4A4A), // Dark background shows as grid lines
|
||||
child: Column(
|
||||
children: List.generate(gridRows, (row) {
|
||||
return Expanded(
|
||||
child: Row(
|
||||
children: List.generate(gridCols, (col) {
|
||||
final index = row * gridCols + col;
|
||||
if (index >= viewers.length) {
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(0.5),
|
||||
child: Container(
|
||||
color: const Color(0xFF6A6A6A),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final viewerId = viewers[index];
|
||||
final viewerState = wallState.getViewerState(viewerId);
|
||||
|
||||
Color tileColor;
|
||||
if (viewerState.hasAlarm) {
|
||||
tileColor = const Color(0xFFDC2626);
|
||||
} else {
|
||||
tileColor = const Color(0xFF6A6A6A);
|
||||
}
|
||||
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(0.5),
|
||||
child: Container(
|
||||
color: tileColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../blocs/ptz/ptz_bloc.dart';
|
||||
import '../blocs/ptz/ptz_event.dart';
|
||||
import '../blocs/monitor/monitor_bloc.dart';
|
||||
import '../blocs/monitor/monitor_state.dart';
|
||||
|
||||
class PresetButtons extends StatelessWidget {
|
||||
final int presetCount;
|
||||
|
||||
const PresetButtons({super.key, this.presetCount = 8});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<MonitorBloc, MonitorBlocState>(
|
||||
builder: (context, monitorState) {
|
||||
final cameraId = monitorState.selectedMonitorCamera;
|
||||
final isEnabled = cameraId != null && cameraId > 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'PRESETS',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: List.generate(presetCount, (index) {
|
||||
final presetId = index + 1;
|
||||
return _PresetButton(
|
||||
presetId: presetId,
|
||||
isEnabled: isEnabled,
|
||||
onPressed: isEnabled
|
||||
? () => context.read<PtzBloc>().add(
|
||||
PtzGoToPreset(cameraId: cameraId, preset: presetId),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PresetButton extends StatelessWidget {
|
||||
final int presetId;
|
||||
final bool isEnabled;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _PresetButton({
|
||||
required this.presetId,
|
||||
required this.isEnabled,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 44,
|
||||
height: 36,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isEnabled
|
||||
? Theme.of(context).colorScheme.secondaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: isEnabled
|
||||
? Theme.of(context).colorScheme.onSecondaryContainer
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'$presetId',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
231
copilot_keyboard/lib/presentation/widgets/ptz_control.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../blocs/ptz/ptz_bloc.dart';
|
||||
import '../blocs/ptz/ptz_event.dart';
|
||||
import '../blocs/ptz/ptz_state.dart';
|
||||
import '../blocs/monitor/monitor_bloc.dart';
|
||||
import '../blocs/monitor/monitor_state.dart';
|
||||
|
||||
class PtzControl extends StatelessWidget {
|
||||
const PtzControl({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<MonitorBloc, MonitorBlocState>(
|
||||
builder: (context, monitorState) {
|
||||
return BlocBuilder<PtzBloc, PtzState>(
|
||||
builder: (context, ptzState) {
|
||||
final cameraId = monitorState.selectedMonitorCamera;
|
||||
final isEnabled = cameraId != null && cameraId > 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'PTZ CONTROL',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (cameraId != null && cameraId > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'Camera $cameraId',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildPtzPad(context, cameraId, isEnabled),
|
||||
const SizedBox(height: 8),
|
||||
_buildZoomControls(context, cameraId, isEnabled),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPtzPad(BuildContext context, int? cameraId, bool isEnabled) {
|
||||
return Column(
|
||||
children: [
|
||||
// Up
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_PtzButton(
|
||||
icon: Icons.arrow_upward,
|
||||
onPressStart: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(
|
||||
PtzTiltStart(cameraId: cameraId, direction: 'up'),
|
||||
)
|
||||
: null,
|
||||
onPressEnd: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Left, Stop, Right
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_PtzButton(
|
||||
icon: Icons.arrow_back,
|
||||
onPressStart: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(
|
||||
PtzPanStart(cameraId: cameraId, direction: 'left'),
|
||||
)
|
||||
: null,
|
||||
onPressEnd: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_PtzButton(
|
||||
icon: Icons.stop,
|
||||
isStop: true,
|
||||
onPressStart: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_PtzButton(
|
||||
icon: Icons.arrow_forward,
|
||||
onPressStart: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(
|
||||
PtzPanStart(cameraId: cameraId, direction: 'right'),
|
||||
)
|
||||
: null,
|
||||
onPressEnd: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Down
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_PtzButton(
|
||||
icon: Icons.arrow_downward,
|
||||
onPressStart: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(
|
||||
PtzTiltStart(cameraId: cameraId, direction: 'down'),
|
||||
)
|
||||
: null,
|
||||
onPressEnd: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildZoomControls(
|
||||
BuildContext context, int? cameraId, bool isEnabled) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_PtzButton(
|
||||
icon: Icons.zoom_out,
|
||||
onPressStart: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(
|
||||
PtzZoomStart(cameraId: cameraId, direction: 'out'),
|
||||
)
|
||||
: null,
|
||||
onPressEnd: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_PtzButton(
|
||||
icon: Icons.zoom_in,
|
||||
onPressStart: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(
|
||||
PtzZoomStart(cameraId: cameraId, direction: 'in'),
|
||||
)
|
||||
: null,
|
||||
onPressEnd: isEnabled && cameraId != null
|
||||
? () => context.read<PtzBloc>().add(PtzStop(cameraId))
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PtzButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressStart;
|
||||
final VoidCallback? onPressEnd;
|
||||
final bool isStop;
|
||||
|
||||
const _PtzButton({
|
||||
required this.icon,
|
||||
this.onPressStart,
|
||||
this.onPressEnd,
|
||||
this.isStop = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isEnabled = onPressStart != null;
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: isEnabled ? (_) => onPressStart?.call() : null,
|
||||
onTapUp: isEnabled ? (_) => onPressEnd?.call() : null,
|
||||
onTapCancel: isEnabled ? () => onPressEnd?.call() : null,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: isEnabled
|
||||
? (isStop
|
||||
? Theme.of(context).colorScheme.errorContainer
|
||||
: Theme.of(context).colorScheme.primaryContainer)
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isEnabled
|
||||
? (isStop
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(context).colorScheme.primary)
|
||||
: Theme.of(context).colorScheme.outline,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isEnabled
|
||||
? (isStop
|
||||
? Theme.of(context).colorScheme.onErrorContainer
|
||||
: Theme.of(context).colorScheme.onPrimaryContainer)
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../domain/entities/wall_config.dart';
|
||||
import '../../blocs/wall/wall_bloc.dart';
|
||||
import '../../blocs/wall/wall_event.dart';
|
||||
import '../../blocs/wall/wall_state.dart';
|
||||
|
||||
/// Full section view matching D6 app design
|
||||
class SectionView extends StatelessWidget {
|
||||
final WallSection section;
|
||||
final WallState wallState;
|
||||
final VoidCallback onBack;
|
||||
final Function(int viewerId) onViewerTap;
|
||||
|
||||
const SectionView({
|
||||
super.key,
|
||||
required this.section,
|
||||
required this.wallState,
|
||||
required this.onBack,
|
||||
required this.onViewerTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: const Color(0xFF555555), // D6 background color
|
||||
child: Column(
|
||||
children: [
|
||||
// Header bar
|
||||
_HeaderBar(
|
||||
section: section,
|
||||
wallState: wallState,
|
||||
),
|
||||
// Monitors grid - takes all available space
|
||||
Expanded(
|
||||
child: _MonitorsGrid(
|
||||
section: section,
|
||||
wallState: wallState,
|
||||
onViewerTap: onViewerTap,
|
||||
),
|
||||
),
|
||||
// Bottom toolbar with circular icons
|
||||
_BottomIconBar(
|
||||
wallState: wallState,
|
||||
onSegmentsTap: onBack,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderBar extends StatelessWidget {
|
||||
final WallSection section;
|
||||
final WallState wallState;
|
||||
|
||||
const _HeaderBar({
|
||||
required this.section,
|
||||
required this.wallState,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Get section number from id
|
||||
final sectionNum = _getSectionNumber(section.id);
|
||||
|
||||
return Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
color: const Color(0xFF555555),
|
||||
child: Row(
|
||||
children: [
|
||||
// Video camera icon (matching D6 app)
|
||||
const Icon(
|
||||
Icons.videocam,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Section name
|
||||
Text(
|
||||
'$sectionNum | ${section.name}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Status message (red when server unavailable)
|
||||
const Text(
|
||||
'Aplikační server není dostupný, některé funkce nejsou k dispozici',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFFF4444),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Status icons
|
||||
const Icon(Icons.dns, color: Color(0xFFFF4444), size: 28),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.lan, color: Color(0xFF24FF00), size: 28),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _getSectionNumber(String id) {
|
||||
switch (id) {
|
||||
case 'left': return 1;
|
||||
case 'center': return 2;
|
||||
case 'right': return 3;
|
||||
case 'top': return 4;
|
||||
case 'bottom': return 5;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MonitorsGrid extends StatelessWidget {
|
||||
final WallSection section;
|
||||
final WallState wallState;
|
||||
final Function(int viewerId) onViewerTap;
|
||||
|
||||
const _MonitorsGrid({
|
||||
required this.section,
|
||||
required this.wallState,
|
||||
required this.onViewerTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final monitors = section.monitors;
|
||||
final gridCols = section.columns;
|
||||
final gridRows = section.rows;
|
||||
|
||||
// Calculate cell dimensions based on grid size
|
||||
final cellWidth = constraints.maxWidth / gridCols;
|
||||
final cellHeight = constraints.maxHeight / gridRows;
|
||||
|
||||
// Build a map of which physical monitor each grid cell belongs to
|
||||
final cellToMonitor = <String, PhysicalMonitor>{};
|
||||
for (final monitor in monitors) {
|
||||
for (int r = 0; r < monitor.rowSpan; r++) {
|
||||
for (int c = 0; c < monitor.colSpan; c++) {
|
||||
final gridRow = monitor.row - 1 + r;
|
||||
final gridCol = monitor.col - 1 + c;
|
||||
cellToMonitor['$gridRow,$gridCol'] = monitor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Position monitors using their explicit row/col values
|
||||
return Stack(
|
||||
children: [
|
||||
// First layer: monitor content without borders
|
||||
...monitors.map((monitor) {
|
||||
final row = monitor.row - 1;
|
||||
final col = monitor.col - 1;
|
||||
|
||||
return Positioned(
|
||||
left: col * cellWidth,
|
||||
top: row * cellHeight,
|
||||
width: monitor.colSpan * cellWidth,
|
||||
height: monitor.rowSpan * cellHeight,
|
||||
child: _PhysicalMonitorContent(
|
||||
monitor: monitor,
|
||||
wallState: wallState,
|
||||
onViewerTap: onViewerTap,
|
||||
),
|
||||
);
|
||||
}),
|
||||
// Second layer: border overlay
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _GridBorderPainter(
|
||||
monitors: monitors,
|
||||
gridCols: gridCols,
|
||||
gridRows: gridRows,
|
||||
cellToMonitor: cellToMonitor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Custom painter that draws all grid borders with correct colors
|
||||
class _GridBorderPainter extends CustomPainter {
|
||||
final List<PhysicalMonitor> monitors;
|
||||
final int gridCols;
|
||||
final int gridRows;
|
||||
final Map<String, PhysicalMonitor> cellToMonitor;
|
||||
|
||||
static const _borderWidth = 2.0;
|
||||
static const _cyanColor = Color(0xFF00BFFF);
|
||||
static const _darkColor = Color(0xFF4A4A4A);
|
||||
|
||||
_GridBorderPainter({
|
||||
required this.monitors,
|
||||
required this.gridCols,
|
||||
required this.gridRows,
|
||||
required this.cellToMonitor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final cellWidth = size.width / gridCols;
|
||||
final cellHeight = size.height / gridRows;
|
||||
|
||||
final cyanPaint = Paint()
|
||||
..color = _cyanColor
|
||||
..strokeWidth = _borderWidth
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final darkPaint = Paint()
|
||||
..color = _darkColor
|
||||
..strokeWidth = _borderWidth
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
// Collect all border segments, draw dark first then cyan on top
|
||||
final darkLines = <_LineSegment>[];
|
||||
final cyanLines = <_LineSegment>[];
|
||||
|
||||
// Collect horizontal lines
|
||||
for (int row = 0; row <= gridRows; row++) {
|
||||
for (int col = 0; col < gridCols; col++) {
|
||||
final x1 = col * cellWidth;
|
||||
final x2 = (col + 1) * cellWidth;
|
||||
final y = row * cellHeight;
|
||||
|
||||
final cellAbove = row > 0 ? cellToMonitor['${row - 1},$col'] : null;
|
||||
final cellBelow = row < gridRows ? cellToMonitor['$row,$col'] : null;
|
||||
|
||||
// Only draw border if at least one side has a physical monitor
|
||||
if (cellAbove == null && cellBelow == null) continue;
|
||||
|
||||
// Cyan if: edge of physical monitor (one side empty or different monitor)
|
||||
final isCyan = cellAbove == null || cellBelow == null ||
|
||||
cellAbove.id != cellBelow.id;
|
||||
|
||||
// Skip internal borders for single-viewer monitors
|
||||
if (!isCyan && cellAbove != null && cellAbove.viewerIds.length == 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final segment = _LineSegment(Offset(x1, y), Offset(x2, y));
|
||||
if (isCyan) {
|
||||
cyanLines.add(segment);
|
||||
} else {
|
||||
darkLines.add(segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect vertical lines
|
||||
for (int col = 0; col <= gridCols; col++) {
|
||||
for (int row = 0; row < gridRows; row++) {
|
||||
final x = col * cellWidth;
|
||||
final y1 = row * cellHeight;
|
||||
final y2 = (row + 1) * cellHeight;
|
||||
|
||||
final cellLeft = col > 0 ? cellToMonitor['$row,${col - 1}'] : null;
|
||||
final cellRight = col < gridCols ? cellToMonitor['$row,$col'] : null;
|
||||
|
||||
// Only draw border if at least one side has a physical monitor
|
||||
if (cellLeft == null && cellRight == null) continue;
|
||||
|
||||
// Cyan if: edge of physical monitor (one side empty or different monitor)
|
||||
final isCyan = cellLeft == null || cellRight == null ||
|
||||
cellLeft.id != cellRight.id;
|
||||
|
||||
// Skip internal borders for single-viewer monitors
|
||||
if (!isCyan && cellLeft != null && cellLeft.viewerIds.length == 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final segment = _LineSegment(Offset(x, y1), Offset(x, y2));
|
||||
if (isCyan) {
|
||||
cyanLines.add(segment);
|
||||
} else {
|
||||
darkLines.add(segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw dark borders first (behind)
|
||||
for (final line in darkLines) {
|
||||
canvas.drawLine(line.start, line.end, darkPaint);
|
||||
}
|
||||
|
||||
// Draw cyan borders on top (in front)
|
||||
for (final line in cyanLines) {
|
||||
canvas.drawLine(line.start, line.end, cyanPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _LineSegment {
|
||||
final Offset start;
|
||||
final Offset end;
|
||||
_LineSegment(this.start, this.end);
|
||||
}
|
||||
|
||||
/// Physical monitor content without borders (borders drawn by overlay)
|
||||
class _PhysicalMonitorContent extends StatelessWidget {
|
||||
final PhysicalMonitor monitor;
|
||||
final WallState wallState;
|
||||
final Function(int viewerId) onViewerTap;
|
||||
|
||||
const _PhysicalMonitorContent({
|
||||
required this.monitor,
|
||||
required this.wallState,
|
||||
required this.onViewerTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewers = monitor.viewerIds;
|
||||
|
||||
// Single viewer fills entire monitor space
|
||||
if (viewers.length == 1) {
|
||||
final viewerId = viewers.first;
|
||||
final isSelected = wallState.isViewerSelected(viewerId);
|
||||
return _ViewerTile(
|
||||
viewerId: viewerId,
|
||||
viewerState: wallState.getViewerState(viewerId),
|
||||
isSelected: isSelected,
|
||||
cameraInputDisplay: isSelected ? wallState.cameraInputDisplay : null,
|
||||
onTap: () => onViewerTap(viewerId),
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple viewers: determine grid based on viewer count and monitor shape
|
||||
final gridCols = monitor.colSpan;
|
||||
final gridRows = monitor.rowSpan;
|
||||
|
||||
return Column(
|
||||
children: List.generate(gridRows, (row) {
|
||||
return Expanded(
|
||||
child: Row(
|
||||
children: List.generate(gridCols, (col) {
|
||||
final index = row * gridCols + col;
|
||||
|
||||
if (index >= viewers.length) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
color: const Color(0xFF6A6A6A),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final viewerId = viewers[index];
|
||||
final isSelected = wallState.isViewerSelected(viewerId);
|
||||
return Expanded(
|
||||
child: _ViewerTile(
|
||||
viewerId: viewerId,
|
||||
viewerState: wallState.getViewerState(viewerId),
|
||||
isSelected: isSelected,
|
||||
cameraInputDisplay: isSelected ? wallState.cameraInputDisplay : null,
|
||||
onTap: () => onViewerTap(viewerId),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ViewerTile extends StatelessWidget {
|
||||
final int viewerId;
|
||||
final ViewerState viewerState;
|
||||
final bool isSelected;
|
||||
final String? cameraInputDisplay;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ViewerTile({
|
||||
required this.viewerId,
|
||||
required this.viewerState,
|
||||
required this.isSelected,
|
||||
this.cameraInputDisplay,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// D6 style: selected = cyan fill, alarm = red, normal = gray
|
||||
Color bgColor;
|
||||
if (isSelected) {
|
||||
bgColor = const Color(0xFF00D4FF); // Cyan fill for selected
|
||||
} else if (viewerState.hasAlarm) {
|
||||
bgColor = const Color(0xFFDC2626); // Red for alarm
|
||||
} else {
|
||||
bgColor = const Color(0xFF6A6A6A); // Gray for normal
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
color: bgColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Viewer ID at top (smaller)
|
||||
Text(
|
||||
'$viewerId',
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
// Camera number - large white text, matching D6 style
|
||||
if (isSelected && cameraInputDisplay != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
cameraInputDisplay!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ViewerTileWithBorder extends StatelessWidget {
|
||||
final int viewerId;
|
||||
final ViewerState viewerState;
|
||||
final bool isSelected;
|
||||
final String? cameraInputDisplay;
|
||||
final VoidCallback onTap;
|
||||
final Border border;
|
||||
|
||||
const _ViewerTileWithBorder({
|
||||
required this.viewerId,
|
||||
required this.viewerState,
|
||||
required this.isSelected,
|
||||
this.cameraInputDisplay,
|
||||
required this.onTap,
|
||||
required this.border,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// D6 style: selected = cyan fill, alarm = red, normal = gray
|
||||
Color bgColor;
|
||||
if (isSelected) {
|
||||
bgColor = const Color(0xFF00D4FF); // Cyan fill for selected
|
||||
} else if (viewerState.hasAlarm) {
|
||||
bgColor = const Color(0xFFDC2626); // Red for alarm
|
||||
} else {
|
||||
bgColor = const Color(0xFF6A6A6A); // Gray for normal
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: border,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Viewer ID at top (smaller)
|
||||
Text(
|
||||
'$viewerId',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
// Camera number - large white text, matching D6 style
|
||||
if (isSelected && cameraInputDisplay != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
cameraInputDisplay!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomIconBar extends StatelessWidget {
|
||||
final WallState wallState;
|
||||
final VoidCallback onSegmentsTap;
|
||||
|
||||
const _BottomIconBar({
|
||||
required this.wallState,
|
||||
required this.onSegmentsTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasSelection = wallState.selectedViewerId != null;
|
||||
|
||||
return Container(
|
||||
height: 86,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: const Color(0xFF555555),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
// Search
|
||||
_CircleIconButton(
|
||||
icon: Icons.search,
|
||||
isActive: hasSelection,
|
||||
onTap: () {},
|
||||
),
|
||||
// Lock
|
||||
_CircleIconButton(
|
||||
icon: Icons.lock_outline,
|
||||
onTap: () {},
|
||||
),
|
||||
// Quad view
|
||||
_CircleIconButton(
|
||||
icon: Icons.grid_view,
|
||||
onTap: () {},
|
||||
),
|
||||
// Segments - navigate to overview
|
||||
_CircleIconButton(
|
||||
icon: Icons.apps,
|
||||
isActive: true,
|
||||
onTap: onSegmentsTap,
|
||||
),
|
||||
// Image/Camera
|
||||
_CircleIconButton(
|
||||
icon: Icons.image_outlined,
|
||||
onTap: () {},
|
||||
),
|
||||
// Alarm (red)
|
||||
_CircleIconButton(
|
||||
icon: Icons.notification_important,
|
||||
iconColor: const Color(0xFFCC4444),
|
||||
onTap: () {},
|
||||
),
|
||||
// History
|
||||
_CircleIconButton(
|
||||
icon: Icons.history,
|
||||
onTap: () {},
|
||||
),
|
||||
// Monitor
|
||||
_CircleIconButton(
|
||||
icon: Icons.tv,
|
||||
onTap: () {},
|
||||
),
|
||||
// Prefix selector
|
||||
_PrefixButton(
|
||||
prefix: wallState.cameraPrefix,
|
||||
onTap: () {
|
||||
// Cycle through prefixes
|
||||
final nextPrefix = wallState.cameraPrefix == 500 ? 501
|
||||
: wallState.cameraPrefix == 501 ? 502 : 500;
|
||||
context.read<WallBloc>().add(SetCameraPrefix(nextPrefix));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CircleIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final bool isActive;
|
||||
final Color? iconColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CircleIconButton({
|
||||
required this.icon,
|
||||
this.isActive = false,
|
||||
this.iconColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isActive ? Colors.white : Colors.white38,
|
||||
width: 2,
|
||||
),
|
||||
color: isActive ? const Color(0xFF333333) : Colors.transparent,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: iconColor ?? (isActive ? Colors.white : Colors.white60),
|
||||
size: 38,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrefixButton extends StatelessWidget {
|
||||
final int prefix;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _PrefixButton({
|
||||
required this.prefix,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$prefix',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
182
copilot_keyboard/lib/presentation/widgets/sequence_panel.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../blocs/sequence/sequence_bloc.dart';
|
||||
import '../blocs/sequence/sequence_event.dart';
|
||||
import '../blocs/sequence/sequence_state.dart';
|
||||
|
||||
/// Panel for starting/stopping camera rotation sequences.
|
||||
/// Shown as a dialog from the toolbar.
|
||||
class SequencePanel extends StatelessWidget {
|
||||
final int viewerId;
|
||||
|
||||
const SequencePanel({super.key, required this.viewerId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<SequenceBloc, SequenceState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final isRunning = state.isRunningOnViewer(viewerId);
|
||||
final runningSeq = state.getRunning(viewerId);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Running sequence indicator
|
||||
if (isRunning && runningSeq != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF38A169).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: const Color(0xFF38A169)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.play_circle, color: Color(0xFF38A169)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_getSequenceName(state, runningSeq.sequenceId),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Běží na monitoru $viewerId',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
context
|
||||
.read<SequenceBloc>()
|
||||
.add(StopSequence(viewerId));
|
||||
},
|
||||
icon: const Icon(Icons.stop, size: 16),
|
||||
label: const Text('Zastavit'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFDC2626),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// Category filter chips
|
||||
if (state.categories.isNotEmpty) ...[
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('Vše'),
|
||||
selected: state.selectedCategoryId == null,
|
||||
onSelected: (_) =>
|
||||
context.read<SequenceBloc>().add(SelectCategory(null)),
|
||||
selectedColor: const Color(0xFF2B6CB0),
|
||||
labelStyle: TextStyle(
|
||||
color: state.selectedCategoryId == null
|
||||
? Colors.white
|
||||
: Colors.white70,
|
||||
),
|
||||
backgroundColor: const Color(0xFF2D3748),
|
||||
),
|
||||
...state.categories.map((cat) => FilterChip(
|
||||
label: Text(cat.name),
|
||||
selected: state.selectedCategoryId == cat.id,
|
||||
onSelected: (_) => context
|
||||
.read<SequenceBloc>()
|
||||
.add(SelectCategory(cat.id)),
|
||||
selectedColor: const Color(0xFF2B6CB0),
|
||||
labelStyle: TextStyle(
|
||||
color: state.selectedCategoryId == cat.id
|
||||
? Colors.white
|
||||
: Colors.white70,
|
||||
),
|
||||
backgroundColor: const Color(0xFF2D3748),
|
||||
)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// Sequence list
|
||||
if (state.filteredSequences.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Žádné sekvence k dispozici.',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...state.filteredSequences.map((seq) {
|
||||
final isThisRunning =
|
||||
runningSeq?.sequenceId == seq.id && isRunning;
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
isThisRunning ? Icons.play_circle : Icons.loop,
|
||||
color: isThisRunning
|
||||
? const Color(0xFF38A169)
|
||||
: const Color(0xFF718096),
|
||||
),
|
||||
title: Text(
|
||||
seq.name,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${seq.cameras.length} kamer, ${seq.intervalSeconds}s interval',
|
||||
style:
|
||||
const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
trailing: isThisRunning
|
||||
? TextButton(
|
||||
onPressed: () => context
|
||||
.read<SequenceBloc>()
|
||||
.add(StopSequence(viewerId)),
|
||||
child: const Text('Zastavit',
|
||||
style: TextStyle(color: Color(0xFFDC2626))),
|
||||
)
|
||||
: TextButton(
|
||||
onPressed: () => context
|
||||
.read<SequenceBloc>()
|
||||
.add(StartSequence(
|
||||
viewerId: viewerId, sequenceId: seq.id)),
|
||||
child: const Text('Spustit'),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _getSequenceName(SequenceState state, int sequenceId) {
|
||||
final seq =
|
||||
state.sequences.where((s) => s.id == sequenceId).firstOrNull;
|
||||
return seq?.name ?? 'Sekvence #$sequenceId';
|
||||
}
|
||||
}
|
||||