mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-29 22:56:45 +08:00
refactor(events): 将设备状态广播降级为快照同步并按需订阅 WebSocket 事件,顺带修复相关测试
This commit is contained in:
@@ -93,11 +93,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_discover_devices() {
|
fn test_discover_devices() {
|
||||||
let devices = discover_devices();
|
let _devices = discover_devices();
|
||||||
// Just verify the function runs without error
|
|
||||||
assert!(devices.gpio_chips.len() >= 0);
|
|
||||||
assert!(devices.usb_relays.len() >= 0);
|
|
||||||
assert!(devices.serial_ports.len() >= 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use super::encoder::{OpusConfig, OpusFrame};
|
|||||||
use super::monitor::{AudioHealthMonitor, AudioHealthStatus};
|
use super::monitor::{AudioHealthMonitor, AudioHealthStatus};
|
||||||
use super::streamer::{AudioStreamer, AudioStreamerConfig};
|
use super::streamer::{AudioStreamer, AudioStreamerConfig};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::events::{EventBus, SystemEvent};
|
use crate::events::EventBus;
|
||||||
|
|
||||||
/// Audio quality presets
|
/// Audio quality presets
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
@@ -139,15 +139,15 @@ impl AudioController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set event bus for publishing audio events
|
/// Set event bus for internal state notifications.
|
||||||
pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) {
|
pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) {
|
||||||
*self.event_bus.write().await = Some(event_bus);
|
*self.event_bus.write().await = Some(event_bus);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish an event to the event bus
|
/// Mark the device-info snapshot as stale.
|
||||||
async fn publish_event(&self, event: SystemEvent) {
|
async fn mark_device_info_dirty(&self) {
|
||||||
if let Some(ref bus) = *self.event_bus.read().await {
|
if let Some(ref bus) = *self.event_bus.read().await {
|
||||||
bus.publish(event);
|
bus.mark_device_info_dirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,11 +276,7 @@ impl AudioController {
|
|||||||
.report_error(Some(&config.device), &error_msg, "start_failed")
|
.report_error(Some(&config.device), &error_msg, "start_failed")
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
self.publish_event(SystemEvent::AudioStateChanged {
|
self.mark_device_info_dirty().await;
|
||||||
streaming: false,
|
|
||||||
device: None,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
return Err(AppError::AudioError(error_msg));
|
return Err(AppError::AudioError(error_msg));
|
||||||
}
|
}
|
||||||
@@ -292,12 +288,7 @@ impl AudioController {
|
|||||||
self.monitor.report_recovered(Some(&config.device)).await;
|
self.monitor.report_recovered(Some(&config.device)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish event
|
self.mark_device_info_dirty().await;
|
||||||
self.publish_event(SystemEvent::AudioStateChanged {
|
|
||||||
streaming: true,
|
|
||||||
device: Some(config.device),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
info!("Audio streaming started");
|
info!("Audio streaming started");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -309,12 +300,7 @@ impl AudioController {
|
|||||||
streamer.stop().await?;
|
streamer.stop().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish event
|
self.mark_device_info_dirty().await;
|
||||||
self.publish_event(SystemEvent::AudioStateChanged {
|
|
||||||
streaming: false,
|
|
||||||
device: None,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
info!("Audio streaming stopped");
|
info!("Audio streaming stopped");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -15,6 +15,39 @@ use tokio::sync::broadcast;
|
|||||||
/// Event channel capacity (ring buffer size)
|
/// Event channel capacity (ring buffer size)
|
||||||
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||||
|
|
||||||
|
const EXACT_TOPICS: &[&str] = &[
|
||||||
|
"stream.mode_switching",
|
||||||
|
"stream.state_changed",
|
||||||
|
"stream.config_changing",
|
||||||
|
"stream.config_applied",
|
||||||
|
"stream.device_lost",
|
||||||
|
"stream.reconnecting",
|
||||||
|
"stream.recovered",
|
||||||
|
"stream.webrtc_ready",
|
||||||
|
"stream.stats_update",
|
||||||
|
"stream.mode_changed",
|
||||||
|
"stream.mode_ready",
|
||||||
|
"webrtc.ice_candidate",
|
||||||
|
"webrtc.ice_complete",
|
||||||
|
"msd.upload_progress",
|
||||||
|
"msd.download_progress",
|
||||||
|
"system.device_info",
|
||||||
|
"error",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PREFIX_TOPICS: &[&str] = &["stream.*", "webrtc.*", "msd.*", "system.*"];
|
||||||
|
|
||||||
|
fn make_sender() -> broadcast::Sender<SystemEvent> {
|
||||||
|
let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||||
|
tx
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic_prefix(event_name: &str) -> Option<String> {
|
||||||
|
event_name
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(prefix, _)| format!("{}.*", prefix))
|
||||||
|
}
|
||||||
|
|
||||||
/// Global event bus for broadcasting system events
|
/// Global event bus for broadcasting system events
|
||||||
///
|
///
|
||||||
/// The event bus uses tokio's broadcast channel to distribute events
|
/// The event bus uses tokio's broadcast channel to distribute events
|
||||||
@@ -43,13 +76,31 @@ const EVENT_CHANNEL_CAPACITY: usize = 256;
|
|||||||
/// ```
|
/// ```
|
||||||
pub struct EventBus {
|
pub struct EventBus {
|
||||||
tx: broadcast::Sender<SystemEvent>,
|
tx: broadcast::Sender<SystemEvent>,
|
||||||
|
exact_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
|
||||||
|
prefix_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
|
||||||
|
device_info_dirty_tx: broadcast::Sender<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventBus {
|
impl EventBus {
|
||||||
/// Create a new event bus
|
/// Create a new event bus
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
let tx = make_sender();
|
||||||
Self { tx }
|
let exact_topics = EXACT_TOPICS
|
||||||
|
.iter()
|
||||||
|
.map(|topic| (*topic, make_sender()))
|
||||||
|
.collect();
|
||||||
|
let prefix_topics = PREFIX_TOPICS
|
||||||
|
.iter()
|
||||||
|
.map(|topic| (*topic, make_sender()))
|
||||||
|
.collect();
|
||||||
|
let (device_info_dirty_tx, _dirty_rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
tx,
|
||||||
|
exact_topics,
|
||||||
|
prefix_topics,
|
||||||
|
device_info_dirty_tx,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish an event to all subscribers
|
/// Publish an event to all subscribers
|
||||||
@@ -57,6 +108,18 @@ impl EventBus {
|
|||||||
/// If there are no active subscribers, the event is silently dropped.
|
/// If there are no active subscribers, the event is silently dropped.
|
||||||
/// This is by design - events are fire-and-forget notifications.
|
/// This is by design - events are fire-and-forget notifications.
|
||||||
pub fn publish(&self, event: SystemEvent) {
|
pub fn publish(&self, event: SystemEvent) {
|
||||||
|
let event_name = event.event_name();
|
||||||
|
|
||||||
|
if let Some(tx) = self.exact_topics.get(event_name) {
|
||||||
|
let _ = tx.send(event.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(prefix) = topic_prefix(event_name) {
|
||||||
|
if let Some(tx) = self.prefix_topics.get(prefix.as_str()) {
|
||||||
|
let _ = tx.send(event.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If no subscribers, send returns Err which is normal
|
// If no subscribers, send returns Err which is normal
|
||||||
let _ = self.tx.send(event);
|
let _ = self.tx.send(event);
|
||||||
}
|
}
|
||||||
@@ -70,6 +133,35 @@ impl EventBus {
|
|||||||
self.tx.subscribe()
|
self.tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribe to a specific topic.
|
||||||
|
///
|
||||||
|
/// Supports exact event names, namespace wildcards like `stream.*`, and
|
||||||
|
/// `*` for the full event stream.
|
||||||
|
pub fn subscribe_topic(&self, topic: &str) -> Option<broadcast::Receiver<SystemEvent>> {
|
||||||
|
if topic == "*" {
|
||||||
|
return Some(self.tx.subscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
if topic.ends_with(".*") {
|
||||||
|
return self.prefix_topics.get(topic).map(|tx| tx.subscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.exact_topics.get(topic).map(|tx| tx.subscribe())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the device-info snapshot as stale.
|
||||||
|
///
|
||||||
|
/// This is an internal trigger used to refresh the latest `system.device_info`
|
||||||
|
/// snapshot without exposing another public WebSocket event.
|
||||||
|
pub fn mark_device_info_dirty(&self) {
|
||||||
|
let _ = self.device_info_dirty_tx.send(());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to internal device-info refresh triggers.
|
||||||
|
pub fn subscribe_device_info_dirty(&self) -> broadcast::Receiver<()> {
|
||||||
|
self.device_info_dirty_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the current number of active subscribers
|
/// Get the current number of active subscribers
|
||||||
///
|
///
|
||||||
/// Useful for monitoring and debugging.
|
/// Useful for monitoring and debugging.
|
||||||
@@ -122,6 +214,40 @@ mod tests {
|
|||||||
assert!(matches!(event2, SystemEvent::StreamStateChanged { .. }));
|
assert!(matches!(event2, SystemEvent::StreamStateChanged { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_subscribe_topic_exact() {
|
||||||
|
let bus = EventBus::new();
|
||||||
|
let mut rx = bus.subscribe_topic("stream.state_changed").unwrap();
|
||||||
|
|
||||||
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
|
state: "ready".to_string(),
|
||||||
|
device: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let event = rx.recv().await.unwrap();
|
||||||
|
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_subscribe_topic_prefix() {
|
||||||
|
let bus = EventBus::new();
|
||||||
|
let mut rx = bus.subscribe_topic("stream.*").unwrap();
|
||||||
|
|
||||||
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
|
state: "ready".to_string(),
|
||||||
|
device: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let event = rx.recv().await.unwrap();
|
||||||
|
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_subscribe_topic_unknown() {
|
||||||
|
let bus = EventBus::new();
|
||||||
|
assert!(bus.subscribe_topic("unknown.topic").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_no_subscribers() {
|
fn test_no_subscribers() {
|
||||||
let bus = EventBus::new();
|
let bus = EventBus::new();
|
||||||
|
|||||||
@@ -5,9 +5,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::atx::PowerStatus;
|
|
||||||
use crate::msd::MsdMode;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Device Info Structures (for system.device_info event)
|
// Device Info Structures (for system.device_info event)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -278,36 +275,9 @@ pub enum SystemEvent {
|
|||||||
mode: String,
|
mode: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HID Events
|
|
||||||
// ============================================================================
|
|
||||||
/// HID backend state changed
|
|
||||||
#[serde(rename = "hid.state_changed")]
|
|
||||||
HidStateChanged {
|
|
||||||
/// Backend type: "otg", "ch9329", "none"
|
|
||||||
backend: String,
|
|
||||||
/// Whether backend is initialized and ready
|
|
||||||
initialized: bool,
|
|
||||||
/// Whether backend is currently online
|
|
||||||
online: bool,
|
|
||||||
/// Error message if any, None if OK
|
|
||||||
error: Option<String>,
|
|
||||||
/// Error code for programmatic handling: "epipe", "eagain", "port_not_found", etc.
|
|
||||||
error_code: Option<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MSD (Mass Storage Device) Events
|
// MSD (Mass Storage Device) Events
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
/// MSD state changed
|
|
||||||
#[serde(rename = "msd.state_changed")]
|
|
||||||
MsdStateChanged {
|
|
||||||
/// Operating mode
|
|
||||||
mode: MsdMode,
|
|
||||||
/// Whether storage is connected to target
|
|
||||||
connected: bool,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// File upload progress (for large file uploads)
|
/// File upload progress (for large file uploads)
|
||||||
#[serde(rename = "msd.upload_progress")]
|
#[serde(rename = "msd.upload_progress")]
|
||||||
MsdUploadProgress {
|
MsdUploadProgress {
|
||||||
@@ -342,28 +312,6 @@ pub enum SystemEvent {
|
|||||||
status: String,
|
status: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ATX (Power Control) Events
|
|
||||||
// ============================================================================
|
|
||||||
/// ATX power state changed
|
|
||||||
#[serde(rename = "atx.state_changed")]
|
|
||||||
AtxStateChanged {
|
|
||||||
/// Power status
|
|
||||||
power_status: PowerStatus,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Audio Events
|
|
||||||
// ============================================================================
|
|
||||||
/// Audio state changed (streaming started/stopped)
|
|
||||||
#[serde(rename = "audio.state_changed")]
|
|
||||||
AudioStateChanged {
|
|
||||||
/// Whether audio is currently streaming
|
|
||||||
streaming: bool,
|
|
||||||
/// Current device (None if stopped)
|
|
||||||
device: Option<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Complete device information (sent on WebSocket connect and state changes)
|
/// Complete device information (sent on WebSocket connect and state changes)
|
||||||
#[serde(rename = "system.device_info")]
|
#[serde(rename = "system.device_info")]
|
||||||
DeviceInfo {
|
DeviceInfo {
|
||||||
@@ -404,12 +352,8 @@ impl SystemEvent {
|
|||||||
Self::StreamModeReady { .. } => "stream.mode_ready",
|
Self::StreamModeReady { .. } => "stream.mode_ready",
|
||||||
Self::WebRTCIceCandidate { .. } => "webrtc.ice_candidate",
|
Self::WebRTCIceCandidate { .. } => "webrtc.ice_candidate",
|
||||||
Self::WebRTCIceComplete { .. } => "webrtc.ice_complete",
|
Self::WebRTCIceComplete { .. } => "webrtc.ice_complete",
|
||||||
Self::HidStateChanged { .. } => "hid.state_changed",
|
|
||||||
Self::MsdStateChanged { .. } => "msd.state_changed",
|
|
||||||
Self::MsdUploadProgress { .. } => "msd.upload_progress",
|
Self::MsdUploadProgress { .. } => "msd.upload_progress",
|
||||||
Self::MsdDownloadProgress { .. } => "msd.download_progress",
|
Self::MsdDownloadProgress { .. } => "msd.download_progress",
|
||||||
Self::AtxStateChanged { .. } => "atx.state_changed",
|
|
||||||
Self::AudioStateChanged { .. } => "audio.state_changed",
|
|
||||||
Self::DeviceInfo { .. } => "system.device_info",
|
Self::DeviceInfo { .. } => "system.device_info",
|
||||||
Self::Error { .. } => "error",
|
Self::Error { .. } => "error",
|
||||||
}
|
}
|
||||||
@@ -448,12 +392,6 @@ mod tests {
|
|||||||
device: Some("/dev/video0".to_string()),
|
device: Some("/dev/video0".to_string()),
|
||||||
};
|
};
|
||||||
assert_eq!(event.event_name(), "stream.state_changed");
|
assert_eq!(event.event_name(), "stream.state_changed");
|
||||||
|
|
||||||
let event = SystemEvent::MsdStateChanged {
|
|
||||||
mode: MsdMode::Image,
|
|
||||||
connected: true,
|
|
||||||
};
|
|
||||||
assert_eq!(event.event_name(), "msd.state_changed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -567,8 +567,9 @@ impl Ch9329Backend {
|
|||||||
data: &[u8],
|
data: &[u8],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let packet = Self::build_packet(address, cmd, data);
|
let packet = Self::build_packet(address, cmd, data);
|
||||||
port.write_all(&packet)
|
port.write_all(&packet).map_err(|e| {
|
||||||
.map_err(|e| Self::backend_error(format!("Failed to write to CH9329: {}", e), "write_failed"))?;
|
Self::backend_error(format!("Failed to write to CH9329: {}", e), "write_failed")
|
||||||
|
})?;
|
||||||
trace!("CH9329 TX [cmd=0x{:02X}]: {:02X?}", cmd, packet);
|
trace!("CH9329 TX [cmd=0x{:02X}]: {:02X?}", cmd, packet);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -599,7 +600,11 @@ impl Ch9329Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn expected_response_cmd(cmd: u8, is_error: bool) -> u8 {
|
fn expected_response_cmd(cmd: u8, is_error: bool) -> u8 {
|
||||||
cmd | if is_error { RESPONSE_ERROR_MASK } else { RESPONSE_SUCCESS_MASK }
|
cmd | if is_error {
|
||||||
|
RESPONSE_ERROR_MASK
|
||||||
|
} else {
|
||||||
|
RESPONSE_SUCCESS_MASK
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn xfer_packet(
|
fn xfer_packet(
|
||||||
@@ -700,9 +705,9 @@ impl Ch9329Backend {
|
|||||||
|
|
||||||
fn enqueue_command(&self, command: WorkerCommand) -> Result<()> {
|
fn enqueue_command(&self, command: WorkerCommand) -> Result<()> {
|
||||||
let guard = self.worker_tx.lock();
|
let guard = self.worker_tx.lock();
|
||||||
let sender = guard.as_ref().ok_or_else(|| {
|
let sender = guard
|
||||||
Self::backend_error("CH9329 worker is not running", "worker_stopped")
|
.as_ref()
|
||||||
})?;
|
.ok_or_else(|| Self::backend_error("CH9329 worker is not running", "worker_stopped"))?;
|
||||||
sender
|
sender
|
||||||
.send(command)
|
.send(command)
|
||||||
.map_err(|_| Self::backend_error("CH9329 worker stopped", "worker_stopped"))
|
.map_err(|_| Self::backend_error("CH9329 worker stopped", "worker_stopped"))
|
||||||
@@ -765,9 +770,7 @@ impl Ch9329Backend {
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let AppError::HidError {
|
if let AppError::HidError {
|
||||||
reason,
|
reason, error_code, ..
|
||||||
error_code,
|
|
||||||
..
|
|
||||||
} = err
|
} = err
|
||||||
{
|
{
|
||||||
runtime.set_error(reason, error_code);
|
runtime.set_error(reason, error_code);
|
||||||
@@ -894,9 +897,7 @@ impl Ch9329Backend {
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let AppError::HidError {
|
if let AppError::HidError {
|
||||||
reason,
|
reason, error_code, ..
|
||||||
error_code,
|
|
||||||
..
|
|
||||||
} = &err
|
} = &err
|
||||||
{
|
{
|
||||||
runtime.set_error(reason.clone(), error_code.clone());
|
runtime.set_error(reason.clone(), error_code.clone());
|
||||||
@@ -912,9 +913,7 @@ impl Ch9329Backend {
|
|||||||
Ok(WorkerCommand::Packet { cmd, data }) => {
|
Ok(WorkerCommand::Packet { cmd, data }) => {
|
||||||
if let Err(err) = Self::xfer_packet(port.as_mut(), address, cmd, &data) {
|
if let Err(err) = Self::xfer_packet(port.as_mut(), address, cmd, &data) {
|
||||||
if let AppError::HidError {
|
if let AppError::HidError {
|
||||||
reason,
|
reason, error_code, ..
|
||||||
error_code,
|
|
||||||
..
|
|
||||||
} = err
|
} = err
|
||||||
{
|
{
|
||||||
runtime.set_error(reason, error_code);
|
runtime.set_error(reason, error_code);
|
||||||
@@ -949,9 +948,7 @@ impl Ch9329Backend {
|
|||||||
for (cmd, data) in reset_sequence {
|
for (cmd, data) in reset_sequence {
|
||||||
if let Err(err) = Self::xfer_packet(port.as_mut(), address, cmd, &data) {
|
if let Err(err) = Self::xfer_packet(port.as_mut(), address, cmd, &data) {
|
||||||
if let AppError::HidError {
|
if let AppError::HidError {
|
||||||
reason,
|
reason, error_code, ..
|
||||||
error_code,
|
|
||||||
..
|
|
||||||
} = err
|
} = err
|
||||||
{
|
{
|
||||||
runtime.set_error(reason, error_code);
|
runtime.set_error(reason, error_code);
|
||||||
@@ -988,9 +985,7 @@ impl Ch9329Backend {
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let AppError::HidError {
|
if let AppError::HidError {
|
||||||
reason,
|
reason, error_code, ..
|
||||||
error_code,
|
|
||||||
..
|
|
||||||
} = err
|
} = err
|
||||||
{
|
{
|
||||||
runtime.set_error(reason, error_code);
|
runtime.set_error(reason, error_code);
|
||||||
@@ -1050,14 +1045,7 @@ impl HidBackend for Ch9329Backend {
|
|||||||
.name("ch9329-worker".to_string())
|
.name("ch9329-worker".to_string())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
Self::worker_loop(
|
Self::worker_loop(
|
||||||
port_path,
|
port_path, baud_rate, address, rx, chip_info, led_status, runtime, init_tx,
|
||||||
baud_rate,
|
|
||||||
address,
|
|
||||||
rx,
|
|
||||||
chip_info,
|
|
||||||
led_status,
|
|
||||||
runtime,
|
|
||||||
init_tx,
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to spawn CH9329 worker: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("Failed to spawn CH9329 worker: {}", e)))?;
|
||||||
@@ -1084,7 +1072,10 @@ impl HidBackend for Ch9329Backend {
|
|||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
let _ = handle.join();
|
let _ = handle.join();
|
||||||
self.record_error(
|
self.record_error(
|
||||||
format!("CH9329 not responding on {} @ {} baud: {}", self.port_path, self.baud_rate, err),
|
format!(
|
||||||
|
"CH9329 not responding on {} @ {} baud: {}",
|
||||||
|
self.port_path, self.baud_rate, err
|
||||||
|
),
|
||||||
"init_failed",
|
"init_failed",
|
||||||
);
|
);
|
||||||
Err(AppError::Internal(format!(
|
Err(AppError::Internal(format!(
|
||||||
@@ -1398,15 +1389,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_packet_building() {
|
fn test_packet_building() {
|
||||||
let backend = Ch9329Backend::new("/dev/null").unwrap();
|
|
||||||
|
|
||||||
// Test GET_INFO packet (no data)
|
// Test GET_INFO packet (no data)
|
||||||
let packet = backend.build_packet(cmd::GET_INFO, &[]);
|
let packet = Ch9329Backend::build_packet(DEFAULT_ADDR, cmd::GET_INFO, &[]);
|
||||||
assert_eq!(packet, vec![0x57, 0xAB, 0x00, 0x01, 0x00, 0x03]);
|
assert_eq!(packet, vec![0x57, 0xAB, 0x00, 0x01, 0x00, 0x03]);
|
||||||
|
|
||||||
// Test keyboard packet (8 bytes data)
|
// Test keyboard packet (8 bytes data)
|
||||||
let data = [0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]; // 'A' key
|
let data = [0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]; // 'A' key
|
||||||
let packet = backend.build_packet(cmd::SEND_KB_GENERAL_DATA, &data);
|
let packet =
|
||||||
|
Ch9329Backend::build_packet(DEFAULT_ADDR, cmd::SEND_KB_GENERAL_DATA, &data);
|
||||||
|
|
||||||
assert_eq!(packet[0], 0x57); // Header
|
assert_eq!(packet[0], 0x57); // Header
|
||||||
assert_eq!(packet[1], 0xAB); // Header
|
assert_eq!(packet[1], 0xAB); // Header
|
||||||
@@ -1415,17 +1405,17 @@ mod tests {
|
|||||||
assert_eq!(packet[4], 8); // Length (8 data bytes)
|
assert_eq!(packet[4], 8); // Length (8 data bytes)
|
||||||
assert_eq!(&packet[5..13], &data); // Data
|
assert_eq!(&packet[5..13], &data); // Data
|
||||||
// Checksum = 0x57 + 0xAB + 0x00 + 0x02 + 0x08 + 0x00 + 0x00 + 0x04 + ... = 0x10
|
// Checksum = 0x57 + 0xAB + 0x00 + 0x02 + 0x08 + 0x00 + 0x00 + 0x04 + ... = 0x10
|
||||||
let expected_checksum: u8 = packet[..13].iter().fold(0u8, |acc, &x| acc.wrapping_add(x));
|
let expected_checksum: u8 = packet[..13]
|
||||||
|
.iter()
|
||||||
|
.fold(0u8, |acc: u8, &x| acc.wrapping_add(x));
|
||||||
assert_eq!(packet[13], expected_checksum);
|
assert_eq!(packet[13], expected_checksum);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_relative_mouse_packet() {
|
fn test_relative_mouse_packet() {
|
||||||
let backend = Ch9329Backend::new("/dev/null").unwrap();
|
|
||||||
|
|
||||||
// Test relative mouse: move right 50 pixels
|
// Test relative mouse: move right 50 pixels
|
||||||
let data = [0x01, 0x00, 50u8, 0x00, 0x00];
|
let data = [0x01, 0x00, 50u8, 0x00, 0x00];
|
||||||
let packet = backend.build_packet(cmd::SEND_MS_REL_DATA, &data);
|
let packet = Ch9329Backend::build_packet(DEFAULT_ADDR, cmd::SEND_MS_REL_DATA, &data);
|
||||||
|
|
||||||
assert_eq!(packet[0], 0x57);
|
assert_eq!(packet[0], 0x57);
|
||||||
assert_eq!(packet[1], 0xAB);
|
assert_eq!(packet[1], 0xAB);
|
||||||
|
|||||||
@@ -113,11 +113,11 @@ impl HidRuntimeState {
|
|||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{info, warn};
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::events::{EventBus, SystemEvent};
|
use crate::events::EventBus;
|
||||||
use crate::otg::OtgService;
|
use crate::otg::OtgService;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
@@ -360,18 +360,6 @@ impl HidController {
|
|||||||
self.runtime_state.read().await.clone()
|
self.runtime_state.read().await.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current state as SystemEvent
|
|
||||||
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
|
|
||||||
let state = self.snapshot().await;
|
|
||||||
SystemEvent::HidStateChanged {
|
|
||||||
backend: state.backend,
|
|
||||||
initialized: state.initialized,
|
|
||||||
online: state.online,
|
|
||||||
error: state.error,
|
|
||||||
error_code: state.error_code,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reload the HID backend with new type
|
/// Reload the HID backend with new type
|
||||||
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
|
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
|
||||||
info!("Reloading HID backend: {:?}", new_backend_type);
|
info!("Reloading HID backend: {:?}", new_backend_type);
|
||||||
@@ -707,12 +695,6 @@ async fn apply_runtime_state(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(events) = events.read().await.as_ref() {
|
if let Some(events) = events.read().await.as_ref() {
|
||||||
events.publish(SystemEvent::HidStateChanged {
|
events.mark_device_info_dirty();
|
||||||
backend: next.backend,
|
|
||||||
initialized: next.initialized,
|
|
||||||
online: next.online,
|
|
||||||
error: next.error,
|
|
||||||
error_code: next.error_code,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/main.rs
114
src/main.rs
@@ -7,7 +7,7 @@ use axum_server::tls_rustls::RustlsConfig;
|
|||||||
use clap::{Parser, ValueEnum};
|
use clap::{Parser, ValueEnum};
|
||||||
use futures::{stream::FuturesUnordered, StreamExt};
|
use futures::{stream::FuturesUnordered, StreamExt};
|
||||||
use rustls::crypto::{ring, CryptoProvider};
|
use rustls::crypto::{ring, CryptoProvider};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::{broadcast, mpsc};
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use one_kvm::atx::AtxController;
|
use one_kvm::atx::AtxController;
|
||||||
@@ -646,6 +646,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::info!("Extension health check task started");
|
tracing::info!("Extension health check task started");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.publish_device_info().await;
|
||||||
|
|
||||||
// Start device info broadcast task
|
// Start device info broadcast task
|
||||||
// This monitors state change events and broadcasts DeviceInfo to all clients
|
// This monitors state change events and broadcasts DeviceInfo to all clients
|
||||||
spawn_device_info_broadcaster(state.clone(), events);
|
spawn_device_info_broadcaster(state.clone(), events);
|
||||||
@@ -854,12 +856,86 @@ fn generate_self_signed_cert() -> anyhow::Result<rcgen::CertifiedKey<rcgen::KeyP
|
|||||||
/// Spawn a background task that monitors state change events
|
/// Spawn a background task that monitors state change events
|
||||||
/// and broadcasts DeviceInfo to all WebSocket clients with debouncing
|
/// and broadcasts DeviceInfo to all WebSocket clients with debouncing
|
||||||
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
||||||
use one_kvm::events::SystemEvent;
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
let mut rx = events.subscribe();
|
enum DeviceInfoTrigger {
|
||||||
|
Event,
|
||||||
|
Lagged { topic: &'static str, count: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_INFO_TOPICS: &[&str] = &[
|
||||||
|
"stream.state_changed",
|
||||||
|
"stream.config_applied",
|
||||||
|
"stream.mode_ready",
|
||||||
|
];
|
||||||
const DEBOUNCE_MS: u64 = 100;
|
const DEBOUNCE_MS: u64 = 100;
|
||||||
|
|
||||||
|
let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
for topic in DEVICE_INFO_TOPICS {
|
||||||
|
let Some(mut rx) = events.subscribe_topic(topic) else {
|
||||||
|
tracing::warn!(
|
||||||
|
"DeviceInfo broadcaster missing topic subscription: {}",
|
||||||
|
topic
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let trigger_tx = trigger_tx.clone();
|
||||||
|
let topic_name = *topic;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(_) => {
|
||||||
|
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
if trigger_tx
|
||||||
|
.send(DeviceInfoTrigger::Lagged {
|
||||||
|
topic: topic_name,
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut dirty_rx = events.subscribe_device_info_dirty();
|
||||||
|
let trigger_tx = trigger_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match dirty_rx.recv().await {
|
||||||
|
Ok(()) => {
|
||||||
|
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
if trigger_tx
|
||||||
|
.send(DeviceInfoTrigger::Lagged {
|
||||||
|
topic: "device_info_dirty",
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS);
|
let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS);
|
||||||
let mut pending_broadcast = false;
|
let mut pending_broadcast = false;
|
||||||
@@ -869,32 +945,24 @@ fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
|||||||
let recv_result = if pending_broadcast {
|
let recv_result = if pending_broadcast {
|
||||||
let remaining =
|
let remaining =
|
||||||
DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
|
DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
|
||||||
tokio::time::timeout(Duration::from_millis(remaining), rx.recv()).await
|
tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await
|
||||||
} else {
|
} else {
|
||||||
Ok(rx.recv().await)
|
Ok(trigger_rx.recv().await)
|
||||||
};
|
};
|
||||||
|
|
||||||
match recv_result {
|
match recv_result {
|
||||||
Ok(Ok(event)) => {
|
Ok(Some(DeviceInfoTrigger::Event)) => {
|
||||||
let should_broadcast = matches!(
|
|
||||||
event,
|
|
||||||
SystemEvent::StreamStateChanged { .. }
|
|
||||||
| SystemEvent::StreamConfigApplied { .. }
|
|
||||||
| SystemEvent::StreamModeReady { .. }
|
|
||||||
| SystemEvent::HidStateChanged { .. }
|
|
||||||
| SystemEvent::MsdStateChanged { .. }
|
|
||||||
| SystemEvent::AtxStateChanged { .. }
|
|
||||||
| SystemEvent::AudioStateChanged { .. }
|
|
||||||
);
|
|
||||||
if should_broadcast {
|
|
||||||
pending_broadcast = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(n))) => {
|
|
||||||
tracing::warn!("DeviceInfo broadcaster lagged by {} events", n);
|
|
||||||
pending_broadcast = true;
|
pending_broadcast = true;
|
||||||
}
|
}
|
||||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
|
Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"DeviceInfo broadcaster lagged by {} events on topic {}",
|
||||||
|
count,
|
||||||
|
topic
|
||||||
|
);
|
||||||
|
pending_broadcast = true;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
tracing::info!("Event bus closed, stopping DeviceInfo broadcaster");
|
tracing::info!("Event bus closed, stopping DeviceInfo broadcaster");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,15 +115,6 @@ impl MsdController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current state as SystemEvent
|
|
||||||
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
|
|
||||||
let state = self.state.read().await;
|
|
||||||
crate::events::SystemEvent::MsdStateChanged {
|
|
||||||
mode: state.mode.clone(),
|
|
||||||
connected: state.connected,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current MSD state
|
/// Get current MSD state
|
||||||
pub async fn state(&self) -> MsdState {
|
pub async fn state(&self) -> MsdState {
|
||||||
self.state.read().await.clone()
|
self.state.read().await.clone()
|
||||||
@@ -141,6 +132,12 @@ impl MsdController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mark_device_info_dirty(&self) {
|
||||||
|
if let Some(ref bus) = *self.events.read().await {
|
||||||
|
bus.mark_device_info_dirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if MSD is available
|
/// Check if MSD is available
|
||||||
pub async fn is_available(&self) -> bool {
|
pub async fn is_available(&self) -> bool {
|
||||||
self.state.read().await.available
|
self.state.read().await.available
|
||||||
@@ -228,11 +225,7 @@ impl MsdController {
|
|||||||
self.monitor.report_recovered().await;
|
self.monitor.report_recovered().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
|
self.mark_device_info_dirty().await;
|
||||||
mode: MsdMode::Image,
|
|
||||||
connected: true,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -303,12 +296,7 @@ impl MsdController {
|
|||||||
self.monitor.report_recovered().await;
|
self.monitor.report_recovered().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish event
|
self.mark_device_info_dirty().await;
|
||||||
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
|
|
||||||
mode: MsdMode::Drive,
|
|
||||||
connected: true,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -340,11 +328,7 @@ impl MsdController {
|
|||||||
drop(state);
|
drop(state);
|
||||||
drop(_op_guard);
|
drop(_op_guard);
|
||||||
|
|
||||||
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
|
self.mark_device_info_dirty().await;
|
||||||
mode: MsdMode::None,
|
|
||||||
connected: false,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/state.rs
14
src/state.rs
@@ -1,5 +1,5 @@
|
|||||||
use std::{collections::VecDeque, sync::Arc};
|
use std::{collections::VecDeque, sync::Arc};
|
||||||
use tokio::sync::{broadcast, RwLock};
|
use tokio::sync::{broadcast, watch, RwLock};
|
||||||
|
|
||||||
use crate::atx::AtxController;
|
use crate::atx::AtxController;
|
||||||
use crate::audio::AudioController;
|
use crate::audio::AudioController;
|
||||||
@@ -58,6 +58,8 @@ pub struct AppState {
|
|||||||
pub extensions: Arc<ExtensionManager>,
|
pub extensions: Arc<ExtensionManager>,
|
||||||
/// Event bus for real-time notifications
|
/// Event bus for real-time notifications
|
||||||
pub events: Arc<EventBus>,
|
pub events: Arc<EventBus>,
|
||||||
|
/// Latest device info snapshot for WebSocket clients
|
||||||
|
device_info_tx: watch::Sender<Option<SystemEvent>>,
|
||||||
/// Online update service
|
/// Online update service
|
||||||
pub update: Arc<UpdateService>,
|
pub update: Arc<UpdateService>,
|
||||||
/// Shutdown signal sender
|
/// Shutdown signal sender
|
||||||
@@ -89,6 +91,8 @@ impl AppState {
|
|||||||
shutdown_tx: broadcast::Sender<()>,
|
shutdown_tx: broadcast::Sender<()>,
|
||||||
data_dir: std::path::PathBuf,
|
data_dir: std::path::PathBuf,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
|
let (device_info_tx, _device_info_rx) = watch::channel(None);
|
||||||
|
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
config,
|
config,
|
||||||
sessions,
|
sessions,
|
||||||
@@ -103,6 +107,7 @@ impl AppState {
|
|||||||
rtsp: Arc::new(RwLock::new(rtsp)),
|
rtsp: Arc::new(RwLock::new(rtsp)),
|
||||||
extensions,
|
extensions,
|
||||||
events,
|
events,
|
||||||
|
device_info_tx,
|
||||||
update,
|
update,
|
||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
||||||
@@ -120,6 +125,11 @@ impl AppState {
|
|||||||
self.shutdown_tx.subscribe()
|
self.shutdown_tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribe to the latest device info snapshot.
|
||||||
|
pub fn subscribe_device_info(&self) -> watch::Receiver<Option<SystemEvent>> {
|
||||||
|
self.device_info_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
/// Record revoked session IDs (bounded queue)
|
/// Record revoked session IDs (bounded queue)
|
||||||
pub async fn remember_revoked_sessions(&self, session_ids: Vec<String>) {
|
pub async fn remember_revoked_sessions(&self, session_ids: Vec<String>) {
|
||||||
if session_ids.is_empty() {
|
if session_ids.is_empty() {
|
||||||
@@ -167,7 +177,7 @@ impl AppState {
|
|||||||
/// Publish DeviceInfo event to all connected WebSocket clients
|
/// Publish DeviceInfo event to all connected WebSocket clients
|
||||||
pub async fn publish_device_info(&self) {
|
pub async fn publish_device_info(&self) {
|
||||||
let device_info = self.get_device_info().await;
|
let device_info = self.get_device_info().await;
|
||||||
self.events.publish(device_info);
|
let _ = self.device_info_tx.send(Some(device_info));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collect video device information
|
/// Collect video device information
|
||||||
|
|||||||
@@ -532,17 +532,30 @@ impl VideoStreamManager {
|
|||||||
device_path, format, resolution.width, resolution.height, fps, mode
|
device_path, format, resolution.width, resolution.height, fps, mode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if mode == StreamMode::WebRTC {
|
||||||
|
// Stop the shared pipeline before replacing the capture source so WebRTC
|
||||||
|
// sessions do not stay attached to a stale frame source.
|
||||||
|
self.webrtc_streamer
|
||||||
|
.update_video_config(resolution, format, fps)
|
||||||
|
.await;
|
||||||
|
info!("WebRTC streamer config updated (pipeline stopped, sessions closed)");
|
||||||
|
}
|
||||||
|
|
||||||
// Apply to streamer (handles video capture)
|
// Apply to streamer (handles video capture)
|
||||||
self.streamer
|
self.streamer
|
||||||
.apply_video_config(device_path, format, resolution, fps)
|
.apply_video_config(device_path, format, resolution, fps)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if mode != StreamMode::WebRTC {
|
||||||
|
if let Err(e) = self.start().await {
|
||||||
|
error!("Failed to start streamer after config change: {}", e);
|
||||||
|
} else {
|
||||||
|
info!("Streamer started after config change");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update WebRTC config if in WebRTC mode
|
// Update WebRTC config if in WebRTC mode
|
||||||
if mode == StreamMode::WebRTC {
|
if mode == StreamMode::WebRTC {
|
||||||
self.webrtc_streamer
|
|
||||||
.update_video_config(resolution, format, fps)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) =
|
let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) =
|
||||||
self.streamer.current_capture_config().await;
|
self.streamer.current_capture_config().await;
|
||||||
if actual_format != format || actual_resolution != resolution || actual_fps != fps {
|
if actual_format != format || actual_resolution != resolution || actual_fps != fps {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use crate::config::*;
|
use crate::config::*;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::events::SystemEvent;
|
|
||||||
use crate::rtsp::RtspService;
|
use crate::rtsp::RtspService;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::video::codec_constraints::{
|
use crate::video::codec_constraints::{
|
||||||
@@ -45,73 +44,11 @@ pub async fn apply_video_config(
|
|||||||
|
|
||||||
let resolution = crate::video::format::Resolution::new(new_config.width, new_config.height);
|
let resolution = crate::video::format::Resolution::new(new_config.width, new_config.height);
|
||||||
|
|
||||||
// Step 1: 更新 WebRTC streamer 配置(停止现有 pipeline 和 sessions)
|
|
||||||
state
|
state
|
||||||
.stream_manager
|
.stream_manager
|
||||||
.webrtc_streamer()
|
|
||||||
.update_video_config(resolution, format, new_config.fps)
|
|
||||||
.await;
|
|
||||||
tracing::info!("WebRTC streamer config updated");
|
|
||||||
|
|
||||||
// Step 2: 应用视频配置到 streamer(重新创建 capturer)
|
|
||||||
state
|
|
||||||
.stream_manager
|
|
||||||
.streamer()
|
|
||||||
.apply_video_config(&device, format, resolution, new_config.fps)
|
.apply_video_config(&device, format, resolution, new_config.fps)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to apply video config: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("Failed to apply video config: {}", e)))?;
|
||||||
tracing::info!("Video config applied to streamer");
|
|
||||||
|
|
||||||
// Step 3: 重启 streamer(仅 MJPEG 模式)
|
|
||||||
if !state.stream_manager.is_webrtc_enabled().await {
|
|
||||||
if let Err(e) = state.stream_manager.start().await {
|
|
||||||
tracing::error!("Failed to start streamer after config change: {}", e);
|
|
||||||
} else {
|
|
||||||
tracing::info!("Streamer started after config change");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 配置 WebRTC direct capture(所有模式统一配置)
|
|
||||||
let (device_path, _resolution, _format, _fps, jpeg_quality) = state
|
|
||||||
.stream_manager
|
|
||||||
.streamer()
|
|
||||||
.current_capture_config()
|
|
||||||
.await;
|
|
||||||
if let Some(device_path) = device_path {
|
|
||||||
state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.set_capture_device(device_path, jpeg_quality)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
tracing::warn!("No capture device configured for WebRTC");
|
|
||||||
}
|
|
||||||
|
|
||||||
if state.stream_manager.is_webrtc_enabled().await {
|
|
||||||
use crate::video::encoder::VideoCodecType;
|
|
||||||
let codec = state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.current_video_codec()
|
|
||||||
.await;
|
|
||||||
let codec_str = match codec {
|
|
||||||
VideoCodecType::H264 => "h264",
|
|
||||||
VideoCodecType::H265 => "h265",
|
|
||||||
VideoCodecType::VP8 => "vp8",
|
|
||||||
VideoCodecType::VP9 => "vp9",
|
|
||||||
}
|
|
||||||
.to_string();
|
|
||||||
let is_hardware = state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.is_hardware_encoding()
|
|
||||||
.await;
|
|
||||||
state.events.publish(SystemEvent::WebRTCReady {
|
|
||||||
transition_id: None,
|
|
||||||
codec: codec_str,
|
|
||||||
hardware: is_hardware,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("Video config applied successfully");
|
tracing::info!("Video config applied successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use tracing::{info, warn};
|
|||||||
use crate::auth::{Session, SESSION_COOKIE};
|
use crate::auth::{Session, SESSION_COOKIE};
|
||||||
use crate::config::{AppConfig, StreamMode};
|
use crate::config::{AppConfig, StreamMode};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::events::SystemEvent;
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest};
|
use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest};
|
||||||
use crate::video::codec_constraints::codec_to_id;
|
use crate::video::codec_constraints::codec_to_id;
|
||||||
@@ -936,20 +935,8 @@ pub async fn update_config(
|
|||||||
let resolution =
|
let resolution =
|
||||||
crate::video::format::Resolution::new(new_config.video.width, new_config.video.height);
|
crate::video::format::Resolution::new(new_config.video.width, new_config.video.height);
|
||||||
|
|
||||||
// Step 1: Update WebRTC streamer config FIRST
|
|
||||||
// This stops the shared pipeline and closes existing sessions BEFORE capturer is recreated
|
|
||||||
// This ensures the pipeline won't be subscribed to a stale frame source
|
|
||||||
state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.update_video_config(resolution, format, new_config.video.fps)
|
|
||||||
.await;
|
|
||||||
tracing::info!("WebRTC streamer config updated (pipeline stopped, sessions closed)");
|
|
||||||
|
|
||||||
// Step 2: Apply video config to streamer (recreates capturer)
|
|
||||||
if let Err(e) = state
|
if let Err(e) = state
|
||||||
.stream_manager
|
.stream_manager
|
||||||
.streamer()
|
|
||||||
.apply_video_config(&device, format, resolution, new_config.video.fps)
|
.apply_video_config(&device, format, resolution, new_config.video.fps)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -962,59 +949,6 @@ pub async fn update_config(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
tracing::info!("Video config applied successfully");
|
tracing::info!("Video config applied successfully");
|
||||||
|
|
||||||
// Step 3: Start the streamer to begin capturing frames (MJPEG mode only)
|
|
||||||
if !state.stream_manager.is_webrtc_enabled().await {
|
|
||||||
// This is necessary because apply_video_config only creates the capturer but doesn't start it
|
|
||||||
if let Err(e) = state.stream_manager.start().await {
|
|
||||||
tracing::error!("Failed to start streamer after config change: {}", e);
|
|
||||||
// Don't fail the request - the stream might start later when client connects
|
|
||||||
} else {
|
|
||||||
tracing::info!("Streamer started after config change");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure WebRTC direct capture (all modes)
|
|
||||||
let (device_path, _resolution, _format, _fps, jpeg_quality) = state
|
|
||||||
.stream_manager
|
|
||||||
.streamer()
|
|
||||||
.current_capture_config()
|
|
||||||
.await;
|
|
||||||
if let Some(device_path) = device_path {
|
|
||||||
state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.set_capture_device(device_path, jpeg_quality)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
tracing::warn!("No capture device configured for WebRTC");
|
|
||||||
}
|
|
||||||
|
|
||||||
if state.stream_manager.is_webrtc_enabled().await {
|
|
||||||
use crate::video::encoder::VideoCodecType;
|
|
||||||
let codec = state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.current_video_codec()
|
|
||||||
.await;
|
|
||||||
let codec_str = match codec {
|
|
||||||
VideoCodecType::H264 => "h264",
|
|
||||||
VideoCodecType::H265 => "h265",
|
|
||||||
VideoCodecType::VP8 => "vp8",
|
|
||||||
VideoCodecType::VP9 => "vp9",
|
|
||||||
}
|
|
||||||
.to_string();
|
|
||||||
let is_hardware = state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.is_hardware_encoding()
|
|
||||||
.await;
|
|
||||||
state.events.publish(SystemEvent::WebRTCReady {
|
|
||||||
transition_id: None,
|
|
||||||
codec: codec_str,
|
|
||||||
hardware: is_hardware,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream config processing (encoder backend, bitrate, etc.)
|
// Stream config processing (encoder backend, bitrate, etc.)
|
||||||
|
|||||||
247
src/web/ws.rs
247
src/web/ws.rs
@@ -16,12 +16,122 @@ use axum::{
|
|||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::broadcast;
|
use tokio::{sync::mpsc, task::JoinHandle};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::events::SystemEvent;
|
use crate::events::SystemEvent;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
enum BusMessage {
|
||||||
|
Event(SystemEvent),
|
||||||
|
Lagged { topic: String, count: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_topics(topics: &[String]) -> Vec<String> {
|
||||||
|
let mut normalized = topics.to_vec();
|
||||||
|
normalized.sort();
|
||||||
|
normalized.dedup();
|
||||||
|
|
||||||
|
if normalized.iter().any(|topic| topic == "*") {
|
||||||
|
return vec!["*".to_string()];
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized
|
||||||
|
.into_iter()
|
||||||
|
.filter(|topic| {
|
||||||
|
if topic.ends_with(".*") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((prefix, _)) = topic.split_once('.') else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let wildcard = format!("{}.*", prefix);
|
||||||
|
!topics.iter().any(|candidate| candidate == &wildcard)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_device_info_topic(topic: &str) -> bool {
|
||||||
|
matches!(topic, "*" | "system.*" | "system.device_info")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_event_tasks(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
topics: &[String],
|
||||||
|
event_tx: &mpsc::UnboundedSender<BusMessage>,
|
||||||
|
event_tasks: &mut Vec<JoinHandle<()>>,
|
||||||
|
) {
|
||||||
|
for task in event_tasks.drain(..) {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
let topics = normalize_topics(topics);
|
||||||
|
let mut device_info_task_added = false;
|
||||||
|
for topic in topics {
|
||||||
|
if is_device_info_topic(&topic) && !device_info_task_added {
|
||||||
|
let mut rx = state.subscribe_device_info();
|
||||||
|
let event_tx = event_tx.clone();
|
||||||
|
event_tasks.push(tokio::spawn(async move {
|
||||||
|
if let Some(snapshot) = rx.borrow().clone() {
|
||||||
|
if event_tx.send(BusMessage::Event(snapshot)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if rx.changed().await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(snapshot) = rx.borrow().clone() {
|
||||||
|
if event_tx.send(BusMessage::Event(snapshot)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
device_info_task_added = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_device_info_topic(&topic) && topic != "*" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(mut rx) = state.events.subscribe_topic(&topic) else {
|
||||||
|
warn!("Client subscribed to unknown topic: {}", topic);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let event_tx = event_tx.clone();
|
||||||
|
let topic_name = topic.clone();
|
||||||
|
event_tasks.push(tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(event) => {
|
||||||
|
if event_tx.send(BusMessage::Event(event)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
if event_tx
|
||||||
|
.send(BusMessage::Lagged {
|
||||||
|
topic: topic_name.clone(),
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Client-to-server message
|
/// Client-to-server message
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(tag = "type", content = "payload")]
|
#[serde(tag = "type", content = "payload")]
|
||||||
@@ -50,16 +160,12 @@ pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>
|
|||||||
/// Handle WebSocket connection
|
/// Handle WebSocket connection
|
||||||
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||||
let (mut sender, mut receiver) = socket.split();
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
|
||||||
// Subscribe to event bus
|
let mut event_tasks: Vec<JoinHandle<()>> = Vec::new();
|
||||||
let mut event_rx = state.events.subscribe();
|
|
||||||
|
|
||||||
// Track subscribed topics (default: none until client subscribes)
|
// Track subscribed topics (default: none until client subscribes)
|
||||||
let mut subscribed_topics: Vec<String> = vec![];
|
let mut subscribed_topics: Vec<String> = vec![];
|
||||||
|
|
||||||
// Flag to send device info after first subscribe
|
|
||||||
let mut device_info_sent = false;
|
|
||||||
|
|
||||||
info!("WebSocket client connected");
|
info!("WebSocket client connected");
|
||||||
|
|
||||||
// Heartbeat interval (30 seconds)
|
// Heartbeat interval (30 seconds)
|
||||||
@@ -73,18 +179,13 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
|||||||
Some(Ok(Message::Text(text))) => {
|
Some(Ok(Message::Text(text))) => {
|
||||||
if let Err(e) = handle_client_message(&text, &mut subscribed_topics).await {
|
if let Err(e) = handle_client_message(&text, &mut subscribed_topics).await {
|
||||||
warn!("Failed to handle client message: {}", e);
|
warn!("Failed to handle client message: {}", e);
|
||||||
}
|
} else {
|
||||||
|
rebuild_event_tasks(
|
||||||
// Send device info after first subscribe
|
&state,
|
||||||
if !device_info_sent && !subscribed_topics.is_empty() {
|
&subscribed_topics,
|
||||||
let device_info = state.get_device_info().await;
|
&event_tx,
|
||||||
if let Ok(json) = serialize_event(&device_info) {
|
&mut event_tasks,
|
||||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
);
|
||||||
warn!("Failed to send device info to client");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
device_info_sent = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Ping(_))) => {
|
Some(Ok(Message::Ping(_))) => {
|
||||||
@@ -109,28 +210,29 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
|||||||
// Receive event from event bus
|
// Receive event from event bus
|
||||||
event = event_rx.recv() => {
|
event = event_rx.recv() => {
|
||||||
match event {
|
match event {
|
||||||
Ok(event) => {
|
Some(BusMessage::Event(event)) => {
|
||||||
// Filter event based on subscribed topics
|
// Filter event based on subscribed topics
|
||||||
if should_send_event(&event, &subscribed_topics) {
|
if let Ok(json) = serialize_event(&event) {
|
||||||
if let Ok(json) = serialize_event(&event) {
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
warn!("Failed to send event to client, disconnecting");
|
||||||
warn!("Failed to send event to client, disconnecting");
|
break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
Some(BusMessage::Lagged { topic, count }) => {
|
||||||
warn!("WebSocket client lagged by {} events", n);
|
warn!(
|
||||||
|
"WebSocket client lagged by {} events on topic {}",
|
||||||
|
count, topic
|
||||||
|
);
|
||||||
// Send error notification to client using SystemEvent::Error
|
// Send error notification to client using SystemEvent::Error
|
||||||
let error_event = SystemEvent::Error {
|
let error_event = SystemEvent::Error {
|
||||||
message: format!("Lagged by {} events", n),
|
message: format!("Lagged by {} events", count),
|
||||||
};
|
};
|
||||||
if let Ok(json) = serialize_event(&error_event) {
|
if let Ok(json) = serialize_event(&error_event) {
|
||||||
let _ = sender.send(Message::Text(json.into())).await;
|
let _ = sender.send(Message::Text(json.into())).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
None => {
|
||||||
warn!("Event bus closed");
|
warn!("Event bus closed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -147,6 +249,10 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for task in event_tasks {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
info!("WebSocket handler exiting");
|
info!("WebSocket handler exiting");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,21 +282,6 @@ async fn handle_client_message(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an event should be sent based on subscribed topics
|
|
||||||
fn should_send_event(event: &SystemEvent, topics: &[String]) -> bool {
|
|
||||||
if topics.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fast path: check for wildcard subscription (avoid String allocation)
|
|
||||||
if topics.iter().any(|t| t == "*") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if event matches any subscribed topic
|
|
||||||
topics.iter().any(|topic| event.matches_topic(topic))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize event to JSON string
|
/// Serialize event to JSON string
|
||||||
fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
|
fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
|
||||||
serde_json::to_string(event)
|
serde_json::to_string(event)
|
||||||
@@ -199,53 +290,49 @@ fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::events::SystemEvent;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_send_event_wildcard() {
|
fn test_normalize_topics_dedupes_and_sorts() {
|
||||||
let event = SystemEvent::StreamStateChanged {
|
let topics = vec![
|
||||||
state: "streaming".to_string(),
|
"stream.state_changed".to_string(),
|
||||||
device: None,
|
"stream.state_changed".to_string(),
|
||||||
};
|
"system.device_info".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
assert!(should_send_event(&event, &["*".to_string()]));
|
assert_eq!(
|
||||||
|
normalize_topics(&topics),
|
||||||
|
vec![
|
||||||
|
"stream.state_changed".to_string(),
|
||||||
|
"system.device_info".to_string()
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_send_event_prefix() {
|
fn test_normalize_topics_wildcard_wins() {
|
||||||
let event = SystemEvent::StreamStateChanged {
|
let topics = vec!["*".to_string(), "stream.state_changed".to_string()];
|
||||||
state: "streaming".to_string(),
|
assert_eq!(normalize_topics(&topics), vec!["*".to_string()]);
|
||||||
device: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(should_send_event(&event, &["stream.*".to_string()]));
|
|
||||||
assert!(!should_send_event(&event, &["msd.*".to_string()]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_send_event_exact() {
|
fn test_normalize_topics_drops_exact_when_prefix_exists() {
|
||||||
let event = SystemEvent::StreamStateChanged {
|
let topics = vec![
|
||||||
state: "streaming".to_string(),
|
"stream.*".to_string(),
|
||||||
device: None,
|
"stream.state_changed".to_string(),
|
||||||
};
|
"system.device_info".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
assert!(should_send_event(
|
assert_eq!(
|
||||||
&event,
|
normalize_topics(&topics),
|
||||||
&["stream.state_changed".to_string()]
|
vec!["stream.*".to_string(), "system.device_info".to_string()]
|
||||||
));
|
);
|
||||||
assert!(!should_send_event(
|
|
||||||
&event,
|
|
||||||
&["stream.config_changed".to_string()]
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_send_event_empty_topics() {
|
fn test_is_device_info_topic_matches_expected_topics() {
|
||||||
let event = SystemEvent::StreamStateChanged {
|
assert!(is_device_info_topic("system.device_info"));
|
||||||
state: "streaming".to_string(),
|
assert!(is_device_info_topic("system.*"));
|
||||||
device: None,
|
assert!(is_device_info_topic("*"));
|
||||||
};
|
assert!(!is_device_info_topic("stream.*"));
|
||||||
|
|
||||||
assert!(!should_send_event(&event, &[]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,40 @@ type EventHandler = (data: any) => void
|
|||||||
|
|
||||||
let wsInstance: WebSocket | null = null
|
let wsInstance: WebSocket | null = null
|
||||||
let handlers = new Map<string, EventHandler[]>()
|
let handlers = new Map<string, EventHandler[]>()
|
||||||
|
let subscribedTopics: string[] = []
|
||||||
const connected = ref(false)
|
const connected = ref(false)
|
||||||
const reconnectAttempts = ref(0)
|
const reconnectAttempts = ref(0)
|
||||||
const networkError = ref(false)
|
const networkError = ref(false)
|
||||||
const networkErrorMessage = ref<string | null>(null)
|
const networkErrorMessage = ref<string | null>(null)
|
||||||
|
|
||||||
|
function getSubscribedTopics(): string[] {
|
||||||
|
return Array.from(handlers.entries())
|
||||||
|
.filter(([, eventHandlers]) => eventHandlers.length > 0)
|
||||||
|
.map(([event]) => event)
|
||||||
|
.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function arraysEqual(a: string[], b: string[]): boolean {
|
||||||
|
return a.length === b.length && a.every((value, index) => value === b[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSubscriptions() {
|
||||||
|
const topics = getSubscribedTopics()
|
||||||
|
|
||||||
|
if (arraysEqual(topics, subscribedTopics)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribedTopics = topics
|
||||||
|
|
||||||
|
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
|
||||||
|
subscribe(topics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
|
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
|
||||||
|
syncSubscriptions()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +64,7 @@ function connect() {
|
|||||||
networkErrorMessage.value = null
|
networkErrorMessage.value = null
|
||||||
reconnectAttempts.value = 0
|
reconnectAttempts.value = 0
|
||||||
|
|
||||||
// Subscribe to all events by default
|
syncSubscriptions()
|
||||||
subscribe(['*'])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wsInstance.onmessage = (e) => {
|
wsInstance.onmessage = (e) => {
|
||||||
@@ -78,6 +104,7 @@ function disconnect() {
|
|||||||
wsInstance.close()
|
wsInstance.close()
|
||||||
wsInstance = null
|
wsInstance = null
|
||||||
}
|
}
|
||||||
|
subscribedTopics = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribe(topics: string[]) {
|
function subscribe(topics: string[]) {
|
||||||
@@ -94,6 +121,7 @@ function on(event: string, handler: EventHandler) {
|
|||||||
handlers.set(event, [])
|
handlers.set(event, [])
|
||||||
}
|
}
|
||||||
handlers.get(event)!.push(handler)
|
handlers.get(event)!.push(handler)
|
||||||
|
syncSubscriptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
function off(event: string, handler: EventHandler) {
|
function off(event: string, handler: EventHandler) {
|
||||||
@@ -103,7 +131,11 @@ function off(event: string, handler: EventHandler) {
|
|||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
eventHandlers.splice(index, 1)
|
eventHandlers.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
if (eventHandlers.length === 0) {
|
||||||
|
handlers.delete(event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
syncSubscriptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEvent(payload: WsEvent) {
|
function handleEvent(payload: WsEvent) {
|
||||||
|
|||||||
Reference in New Issue
Block a user