fix: 修复无信号状态同步与采集管线阻塞

- 使用非阻塞 V4L2 句柄并类型化采集错误
- 完善视频管线生命周期,避免复用停止中的管线
- 区分音视频事件并支持 REST/WebSocket 状态快照
- 修复信号丢失后保留最后一帧及刷新后黑屏问题
- 优化无信号提示、状态展示优先级及相关界面细节
- 降低重复 OTG 错误日志级别
This commit is contained in:
mofeng-git
2026-07-30 22:43:59 +08:00
parent ce1712ff2e
commit 6fdcf5c7c9
24 changed files with 406 additions and 165 deletions

View File

@@ -6,9 +6,10 @@ use self::types::EXACT_EVENT_TOPICS;
pub use types::{
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo,
MsdDeviceMediaInfo, StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
MsdDeviceMediaInfo, StreamKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
};
use std::sync::RwLock;
use tokio::sync::broadcast;
const EVENT_CHANNEL_CAPACITY: usize = 256;
@@ -40,6 +41,7 @@ pub struct EventBus {
exact_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
prefix_topics: std::collections::HashMap<String, broadcast::Sender<SystemEvent>>,
device_info_dirty_tx: broadcast::Sender<()>,
latest_video_stream_state: RwLock<Option<SystemEvent>>,
}
impl EventBus {
@@ -60,12 +62,26 @@ impl EventBus {
exact_topics,
prefix_topics,
device_info_dirty_tx,
latest_video_stream_state: RwLock::new(None),
}
}
pub fn publish(&self, event: SystemEvent) {
let event_name = event.event_name();
if matches!(
event,
SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
..
}
) {
*self
.latest_video_stream_state
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(event.clone());
}
if let Some(tx) = self.exact_topics.get(event_name) {
let _ = tx.send(event.clone());
}
@@ -103,6 +119,15 @@ impl EventBus {
self.device_info_dirty_tx.subscribe()
}
/// Stateful video status topics replay this value to new WebSocket
/// subscribers so a page refresh cannot miss an earlier signal-loss edge.
pub fn latest_video_stream_state(&self) -> Option<SystemEvent> {
self.latest_video_stream_state
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
pub fn subscriber_count(&self) -> usize {
self.tx.receiver_count()
}
@@ -124,6 +149,7 @@ mod tests {
let mut rx = bus.subscribe();
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "streaming".to_string(),
device: Some("/dev/video0".to_string()),
reason: None,
@@ -132,6 +158,10 @@ mod tests {
let event = rx.recv().await.unwrap();
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
assert!(matches!(
bus.latest_video_stream_state(),
Some(SystemEvent::StreamStateChanged { state, .. }) if state == "streaming"
));
}
#[tokio::test]
@@ -143,6 +173,7 @@ mod tests {
assert_eq!(bus.subscriber_count(), 2);
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "ready".to_string(),
device: Some("/dev/video0".to_string()),
reason: None,
@@ -162,6 +193,7 @@ mod tests {
let mut rx = bus.subscribe_topic("stream.state_changed").unwrap();
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "ready".to_string(),
device: None,
reason: None,
@@ -178,6 +210,7 @@ mod tests {
let mut rx = bus.subscribe_topic("stream.*").unwrap();
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "ready".to_string(),
device: None,
reason: None,
@@ -200,10 +233,36 @@ mod tests {
assert_eq!(bus.subscriber_count(), 0);
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "ready".to_string(),
device: None,
reason: None,
next_retry_ms: None,
});
}
#[test]
fn audio_state_does_not_replace_latest_video_state() {
let bus = EventBus::new();
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "no_signal".to_string(),
device: Some("/dev/video0".to_string()),
reason: Some("no_sync".to_string()),
next_retry_ms: Some(500),
});
bus.publish(SystemEvent::StreamStateChanged {
kind: StreamKind::Audio,
state: "streaming".to_string(),
device: Some("hw:0,0".to_string()),
reason: None,
next_retry_ms: None,
});
assert!(matches!(
bus.latest_video_stream_state(),
Some(SystemEvent::StreamStateChanged { state, reason, .. })
if state == "no_signal" && reason.as_deref() == Some("no_sync")
));
}
}

View File

@@ -92,10 +92,10 @@ pub struct ClientStats {
pub connected_secs: u64,
}
/// Video vs audio source for [`SystemEvent::StreamDeviceLost`] (WebSocket `stream.device_lost`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
/// Media subsystem that owns a stream state or device event.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamDeviceLostKind {
pub enum StreamKind {
Video,
Audio,
}
@@ -114,6 +114,7 @@ pub enum SystemEvent {
#[serde(rename = "stream.state_changed")]
StreamStateChanged {
kind: StreamKind,
state: String,
device: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -141,7 +142,7 @@ pub enum SystemEvent {
#[serde(rename = "stream.device_lost")]
StreamDeviceLost {
kind: StreamDeviceLostKind,
kind: StreamKind,
device: String,
reason: String,
},
@@ -274,6 +275,7 @@ mod tests {
#[test]
fn test_event_name() {
let event = SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: "streaming".to_string(),
device: Some("/dev/video0".to_string()),
reason: None,
@@ -285,7 +287,7 @@ mod tests {
#[test]
fn stream_device_lost_json_snake_case_kind() {
let event = SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Audio,
kind: StreamKind::Audio,
device: "hw:0,0".to_string(),
reason: "test".to_string(),
};
@@ -306,6 +308,7 @@ mod tests {
from_mode: String::new(),
},
SystemEvent::StreamStateChanged {
kind: StreamKind::Video,
state: String::new(),
device: None,
reason: None,
@@ -323,7 +326,7 @@ mod tests {
fps: 0,
},
SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Video,
kind: StreamKind::Video,
device: String::new(),
reason: String::new(),
},