Initial commit: COPILOT D6 Flutter keyboard controller

Flutter web app replacing legacy WPF CCTV surveillance keyboard controller.
Includes wall overview, section view with monitor grid, camera input,
PTZ control, alarm/lock/sequence BLoCs, and legacy-matching UI styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
klas
2026-02-12 14:57:38 +01:00
commit 40143734fc
125 changed files with 65073 additions and 0 deletions

42
.gitignore vendored Normal file
View 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*

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
}
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
Error: not an OLE2 structured storage file

View 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\`

View 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

View 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)

View 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

View 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

View 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
```

View 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 │ │
│ └────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
```

View 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&#xa;(Serial Port)&#xa;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&#xa;(USB HID)&#xa;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&#xa;(Serial Port)&#xa;±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&#xa;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&#xa;(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)&#xa;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&#xa;Controller&#xa;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&#xa;Lock&#xa;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&#xa;Alarm&#xa;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&#xa;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&#xa;Buttons&#xa;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&#xa;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&#xa;(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&#xa;(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&#xa;(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&#xa;192.168.102.186&#xa;(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&#xa;192.168.102.20&#xa;(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&#xa;(ASP.NET Core)&#xa;HTTPS :443 + SignalR Hub&#xa;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&#xa;(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&#xa;(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&#xa;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>

View 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

View 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.

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

View 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

View File

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

View 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.

View 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`. |

View 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** | |

View 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": {}
}

View 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
```

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

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

View 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

View 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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View 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
}
}

View 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"
}
}

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

View 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.

View 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

View 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
}
}

View 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),
),
),
);
}
}

View 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;
}
}

View 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)';
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View File

@@ -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);
}
}

View 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();
}
}

View 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,
];
}

View 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? ??
'',
);
}
}

View File

@@ -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;
}
}
}

View 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,
];
}

View 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(),
);
}
}

View 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,
];
}

View 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];
}

View 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();
}

View 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());
}

View File

@@ -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();
}
}

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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));
}
}

View File

@@ -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();
}

View File

@@ -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];
}

View File

@@ -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();
}
}

View File

@@ -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];
}

View File

@@ -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];
}

View 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();
}
}

View 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);
}

View 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,
});
}

View File

@@ -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();
}
}

View File

@@ -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];
}

View File

@@ -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];
}

View 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();
}
}

View 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];
}

View 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];
}

View File

@@ -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));
}
}
}

View File

@@ -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);
}

View File

@@ -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),
);
}
}

View 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));
}
}

View 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];
}

View 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,
];
}

View 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);
}
}

View 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;
}
}
}

View 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')}';
}
}

View 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),
),
),
);
}
}

View File

@@ -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),
),
],
],
),
);
},
);
}
}

View 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),
),
),
],
),
),
),
);
}
}

View File

@@ -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,
),
),
);
}),
),
);
}),
),
);
}
}

View File

@@ -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),
),
),
);
}
}

View 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),
),
),
);
}
}

View File

@@ -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,
),
),
),
),
);
}
}

View 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';
}
}

Some files were not shown because too many files have changed in this diff Show More