Compare commits
97 Commits
master
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f898ddee7 | |||
| 57adce2696 | |||
| dd69d7e378 | |||
| 1dc3bab3aa | |||
| c7b06c4456 | |||
| 2fa996ed61 | |||
| eb40374389 | |||
| df2ff4cc5c | |||
| fca161865b | |||
| dc971175d9 | |||
| 213673274d | |||
| ffc79982fc | |||
| d8a7dd5a98 | |||
| 48dfe4bae7 | |||
| 8855f9ab78 | |||
| cfe2430521 | |||
| 298aeabc10 | |||
| 11c615d0b7 | |||
| b6479acf11 | |||
| c7ec6caf4d | |||
| 06bc0cd98d | |||
| a939dfcc77 | |||
| 2bb9b2f196 | |||
| 94cfc2d422 | |||
| 129b508123 | |||
| efa261ba48 | |||
| 67cddf6a99 | |||
| 4e96c271ed | |||
| 0da8fbb5f7 | |||
| 1738cf9ed0 | |||
| 6f440154b7 | |||
| dcdfb5cb2a | |||
| b173e94f7a | |||
| 83b34a1b92 | |||
| 7d35115d1a | |||
| 16c74879f2 | |||
| 617a1d844f | |||
| 86656054c5 | |||
| 1b9a0f7ee2 | |||
| cb024953f1 | |||
| cc4173d503 | |||
| ba4b9837e7 | |||
| 003bb44d55 | |||
| cb624c8249 | |||
| 3611db4d8c | |||
| baf8374905 | |||
| dcb6ccd157 | |||
| 9ae39bc0f4 | |||
| aba2a689b4 | |||
| 6295720b96 | |||
| dccc6601b1 | |||
| d8b0d739a5 | |||
| 6a76be3c38 | |||
| 83f2aac696 | |||
| f043634ce2 | |||
| 7ea2f83002 | |||
| d9d2bd6c73 | |||
| 1bec61006d | |||
| 3c1f647b04 | |||
| f1aaa1743d | |||
| 49fdd9c3de | |||
| 368319af63 | |||
| 54c2367c8c | |||
| 41cf36a812 | |||
| 04afb7603f | |||
| fb8573d806 | |||
| e35af4a46d | |||
| 2570ea69db | |||
| a9f3195952 | |||
| bc14f42b8d | |||
| 30eab21166 | |||
| 27a69a8bb6 | |||
| da6aa6c425 | |||
| 953f29dec7 | |||
| 2e5f162d2c | |||
| 103220a05a | |||
| 3458b42a11 | |||
| c274e8ce18 | |||
| 5be0837f45 | |||
| 8e4b0eabeb | |||
| d5435666f4 | |||
| 76770b9c41 | |||
| 35196ee8f3 | |||
| 6194848b0e | |||
| 3aef06001b | |||
| 39e4458967 | |||
| 1ece995ec1 | |||
| d1e86f36f1 | |||
| 7585dd9430 | |||
| 5d68b87dfb | |||
| b56789d081 | |||
| 3cdf5c0718 | |||
| 4f69556235 | |||
| 29e86791ed | |||
| e5e5cd2630 | |||
| e223d90db4 | |||
| d2677afc46 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,3 +9,4 @@ override.toml
|
||||
**/*~
|
||||
*.swp
|
||||
*.swo
|
||||
*.md
|
||||
|
||||
@ -29,6 +29,7 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
async-stream = "0.3"
|
||||
shell-escape = "0.1"
|
||||
v4l = "0.14"
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
|
||||
@ -1,42 +1,46 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use anyhow::Result;
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_stream::{wrappers::BroadcastStream, StreamExt};
|
||||
use tonic::{transport::Channel, Request};
|
||||
use tracing::{error, trace, debug, info, warn};
|
||||
use tokio_stream::{StreamExt, wrappers::BroadcastStream};
|
||||
use tonic::{Request, transport::Channel};
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
use winit::{
|
||||
event::Event,
|
||||
event_loop::{EventLoopBuilder, ControlFlow},
|
||||
event_loop::{ControlFlow, EventLoopBuilder},
|
||||
platform::wayland::EventLoopBuilderExtWayland,
|
||||
};
|
||||
|
||||
use lesavka_common::lesavka::{
|
||||
relay_client::RelayClient, KeyboardReport,
|
||||
MonitorRequest, MouseReport, VideoPacket, AudioPacket
|
||||
AudioPacket, KeyboardReport, MonitorRequest, MouseReport, VideoPacket,
|
||||
relay_client::RelayClient,
|
||||
};
|
||||
|
||||
use crate::{handshake,
|
||||
input::inputs::InputAggregator,
|
||||
input::microphone::MicrophoneCapture,
|
||||
input::camera::CameraCapture,
|
||||
output::video::MonitorWindow,
|
||||
output::audio::AudioOut};
|
||||
use crate::{
|
||||
handshake,
|
||||
input::camera::{CameraCapture, CameraCodec, CameraConfig},
|
||||
input::inputs::InputAggregator,
|
||||
input::microphone::MicrophoneCapture,
|
||||
output::audio::AudioOut,
|
||||
output::video::MonitorWindow,
|
||||
};
|
||||
|
||||
pub struct LesavkaClientApp {
|
||||
aggregator: Option<InputAggregator>,
|
||||
server_addr: String,
|
||||
dev_mode: bool,
|
||||
headless: bool,
|
||||
kbd_tx: broadcast::Sender<KeyboardReport>,
|
||||
mou_tx: broadcast::Sender<MouseReport>,
|
||||
}
|
||||
|
||||
impl LesavkaClientApp {
|
||||
pub fn new() -> Result<Self> {
|
||||
let dev_mode = std::env::var("LESAVKA_DEV_MODE").is_ok();
|
||||
let dev_mode = std::env::var("LESAVKA_DEV_MODE").is_ok();
|
||||
let headless = std::env::var("LESAVKA_HEADLESS").is_ok();
|
||||
let server_addr = std::env::args()
|
||||
.nth(1)
|
||||
.or_else(|| std::env::var("LESAVKA_SERVER_ADDR").ok())
|
||||
@ -45,16 +49,48 @@ impl LesavkaClientApp {
|
||||
let (kbd_tx, _) = broadcast::channel(1024);
|
||||
let (mou_tx, _) = broadcast::channel(4096);
|
||||
|
||||
let mut agg = InputAggregator::new(dev_mode, kbd_tx.clone(), mou_tx.clone());
|
||||
agg.init()?; // grab devices immediately
|
||||
let agg = if headless {
|
||||
None
|
||||
} else {
|
||||
Some(InputAggregator::new(dev_mode, kbd_tx.clone(), mou_tx.clone()))
|
||||
};
|
||||
|
||||
Ok(Self { aggregator: Some(agg), server_addr, dev_mode, kbd_tx, mou_tx })
|
||||
Ok(Self {
|
||||
aggregator: agg,
|
||||
server_addr,
|
||||
dev_mode,
|
||||
headless,
|
||||
kbd_tx,
|
||||
mou_tx,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) -> Result<()> {
|
||||
/*────────── handshake / feature-negotiation ───────────────*/
|
||||
info!(server = %self.server_addr, "🚦 starting handshake");
|
||||
let caps = handshake::negotiate(&self.server_addr).await;
|
||||
tracing::info!("🤝 server capabilities = {:?}", caps);
|
||||
let camera_cfg = match (
|
||||
caps.camera_codec.as_deref(),
|
||||
caps.camera_width,
|
||||
caps.camera_height,
|
||||
caps.camera_fps,
|
||||
) {
|
||||
(Some(codec), Some(width), Some(height), Some(fps)) => {
|
||||
let codec = match codec.to_ascii_lowercase().as_str() {
|
||||
"mjpeg" | "mjpg" | "jpeg" => CameraCodec::Mjpeg,
|
||||
"h264" => CameraCodec::H264,
|
||||
_ => CameraCodec::H264,
|
||||
};
|
||||
Some(CameraConfig {
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
/*────────── persistent gRPC channels ──────────*/
|
||||
let hid_ep = Channel::from_shared(self.server_addr.clone())?
|
||||
@ -64,21 +100,30 @@ impl LesavkaClientApp {
|
||||
.connect_lazy();
|
||||
|
||||
let vid_ep = Channel::from_shared(self.server_addr.clone())?
|
||||
.initial_connection_window_size(4<<20)
|
||||
.initial_stream_window_size(4<<20)
|
||||
.initial_connection_window_size(4 << 20)
|
||||
.initial_stream_window_size(4 << 20)
|
||||
.tcp_nodelay(true)
|
||||
.connect_lazy();
|
||||
|
||||
/*────────── input aggregator task ─────────────*/
|
||||
let aggregator = self.aggregator.take().expect("InputAggregator present");
|
||||
let agg_task = tokio::spawn(async move {
|
||||
let mut a = aggregator;
|
||||
a.run().await
|
||||
});
|
||||
let mut agg_task = None;
|
||||
let mut kbd_loop = None;
|
||||
let mut mou_loop = None;
|
||||
if !self.headless {
|
||||
/*────────── input aggregator task (grab after handshake) ─────────────*/
|
||||
let mut aggregator = self.aggregator.take().expect("InputAggregator present");
|
||||
info!("⌛ grabbing input devices…");
|
||||
aggregator.init()?; // grab devices now that handshake succeeded
|
||||
agg_task = Some(tokio::spawn(async move {
|
||||
let mut a = aggregator;
|
||||
a.run().await
|
||||
}));
|
||||
|
||||
/*────────── HID streams (never return) ────────*/
|
||||
let kbd_loop = self.stream_loop_keyboard(hid_ep.clone());
|
||||
let mou_loop = self.stream_loop_mouse(hid_ep.clone());
|
||||
/*────────── HID streams (never return) ────────*/
|
||||
kbd_loop = Some(self.stream_loop_keyboard(hid_ep.clone()));
|
||||
mou_loop = Some(self.stream_loop_mouse(hid_ep.clone()));
|
||||
} else {
|
||||
info!("🧪 headless mode: skipping HID input capture");
|
||||
}
|
||||
|
||||
/*───────── optional 300 s auto-exit in dev mode */
|
||||
let suicide = async {
|
||||
@ -91,73 +136,104 @@ impl LesavkaClientApp {
|
||||
}
|
||||
};
|
||||
|
||||
/*────────── video rendering thread (winit) ────*/
|
||||
let (video_tx, mut video_rx) = tokio::sync::mpsc::unbounded_channel::<VideoPacket>();
|
||||
if !self.headless {
|
||||
/*────────── video rendering thread (winit) ────*/
|
||||
let (video_tx, mut video_rx) = tokio::sync::mpsc::unbounded_channel::<VideoPacket>();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
gtk::init().expect("GTK initialisation failed");
|
||||
let el = EventLoopBuilder::<()>::new().with_any_thread(true).build().unwrap();
|
||||
let win0 = MonitorWindow::new(0).expect("win0");
|
||||
let win1 = MonitorWindow::new(1).expect("win1");
|
||||
std::thread::spawn(move || {
|
||||
gtk::init().expect("GTK initialisation failed");
|
||||
let el = EventLoopBuilder::<()>::new()
|
||||
.with_any_thread(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
let win0 = MonitorWindow::new(0).expect("win0");
|
||||
let win1 = MonitorWindow::new(1).expect("win1");
|
||||
|
||||
let _ = el.run(move |_: Event<()>, _elwt| {
|
||||
_elwt.set_control_flow(ControlFlow::WaitUntil(
|
||||
std::time::Instant::now() + std::time::Duration::from_millis(16)));
|
||||
static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
static DUMP_CNT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
while let Ok(pkt) = video_rx.try_recv() {
|
||||
CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if CNT.load(std::sync::atomic::Ordering::Relaxed) % 300 == 0 {
|
||||
debug!("🎥 received {} video packets", CNT.load(std::sync::atomic::Ordering::Relaxed));
|
||||
let _ = el.run(move |_: Event<()>, _elwt| {
|
||||
_elwt.set_control_flow(ControlFlow::WaitUntil(
|
||||
std::time::Instant::now() + std::time::Duration::from_millis(16),
|
||||
));
|
||||
static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
static DUMP_CNT: std::sync::atomic::AtomicU32 =
|
||||
std::sync::atomic::AtomicU32::new(0);
|
||||
while let Ok(pkt) = video_rx.try_recv() {
|
||||
CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if CNT.load(std::sync::atomic::Ordering::Relaxed) % 300 == 0 {
|
||||
debug!(
|
||||
"🎥 received {} video packets",
|
||||
CNT.load(std::sync::atomic::Ordering::Relaxed)
|
||||
);
|
||||
}
|
||||
let n = DUMP_CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if n % 120 == 0 {
|
||||
let eye = if pkt.id == 0 { "l" } else { "r" };
|
||||
let path = format!("/tmp/eye{eye}-cli-{n:05}.h264");
|
||||
std::fs::write(&path, &pkt.data).ok();
|
||||
}
|
||||
match pkt.id {
|
||||
0 => win0.push_packet(pkt),
|
||||
1 => win1.push_packet(pkt),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let n = DUMP_CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if n % 120 == 0 {
|
||||
let eye = if pkt.id == 0 { "l" } else { "r" };
|
||||
let path = format!("/tmp/eye{eye}-cli-{n:05}.h264");
|
||||
std::fs::write(&path, &pkt.data).ok();
|
||||
}
|
||||
match pkt.id {
|
||||
0 => win0.push_packet(pkt),
|
||||
1 => win1.push_packet(pkt),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*────────── start video gRPC pullers ──────────*/
|
||||
let ep_video = vid_ep.clone();
|
||||
tokio::spawn(Self::video_loop(ep_video, video_tx));
|
||||
/*────────── start video gRPC pullers ──────────*/
|
||||
let ep_video = vid_ep.clone();
|
||||
tokio::spawn(Self::video_loop(ep_video, video_tx));
|
||||
|
||||
/*────────── audio renderer & puller ───────────*/
|
||||
let audio_out = AudioOut::new()?;
|
||||
let ep_audio = vid_ep.clone();
|
||||
/*────────── audio renderer & puller ───────────*/
|
||||
let audio_out = AudioOut::new()?;
|
||||
let ep_audio = vid_ep.clone();
|
||||
|
||||
tokio::spawn(Self::audio_loop(ep_audio, audio_out));
|
||||
tokio::spawn(Self::audio_loop(ep_audio, audio_out));
|
||||
} else {
|
||||
info!("🧪 headless mode: skipping video/audio renderers");
|
||||
}
|
||||
/*────────── camera & mic tasks (gated by caps) ───────────*/
|
||||
if caps.camera && std::env::var("LESAVKA_CAM_DISABLE").is_err() {
|
||||
if let Some(cfg) = camera_cfg {
|
||||
info!(
|
||||
codec = ?cfg.codec,
|
||||
width = cfg.width,
|
||||
height = cfg.height,
|
||||
fps = cfg.fps,
|
||||
"📸 using camera settings from server"
|
||||
);
|
||||
}
|
||||
let cam = Arc::new(CameraCapture::new(
|
||||
std::env::var("LESAVKA_CAM_SOURCE").ok().as_deref()
|
||||
std::env::var("LESAVKA_CAM_SOURCE").ok().as_deref(),
|
||||
camera_cfg,
|
||||
)?);
|
||||
tokio::spawn(Self::cam_loop(vid_ep.clone(), cam));
|
||||
}
|
||||
if caps.microphone {
|
||||
if caps.microphone && std::env::var("LESAVKA_MIC_DISABLE").is_err() {
|
||||
let mic = Arc::new(MicrophoneCapture::new()?);
|
||||
tokio::spawn(Self::voice_loop(vid_ep.clone(), mic)); // renamed
|
||||
tokio::spawn(Self::voice_loop(vid_ep.clone(), mic)); // renamed
|
||||
}
|
||||
|
||||
/*────────── central reactor ───────────────────*/
|
||||
tokio::select! {
|
||||
_ = kbd_loop => { warn!("⚠️⌨️ keyboard stream finished"); },
|
||||
_ = mou_loop => { warn!("⚠️🖱️ mouse stream finished"); },
|
||||
_ = suicide => { /* handled above */ },
|
||||
r = agg_task => {
|
||||
match r {
|
||||
Ok(Ok(())) => warn!("input aggregator terminated cleanly"),
|
||||
Ok(Err(e)) => error!("input aggregator error: {e:?}"),
|
||||
Err(join_err) => error!("aggregator task panicked: {join_err:?}"),
|
||||
if self.headless {
|
||||
tokio::select! {
|
||||
_ = suicide => { /* handled above */ },
|
||||
}
|
||||
} else {
|
||||
let kbd_loop = kbd_loop.expect("kbd_loop");
|
||||
let mou_loop = mou_loop.expect("mou_loop");
|
||||
let agg_task = agg_task.expect("agg_task");
|
||||
tokio::select! {
|
||||
_ = kbd_loop => { warn!("⚠️⌨️ keyboard stream finished"); },
|
||||
_ = mou_loop => { warn!("⚠️🖱️ mouse stream finished"); },
|
||||
_ = suicide => { /* handled above */ },
|
||||
r = agg_task => {
|
||||
match r {
|
||||
Ok(Ok(())) => warn!("input aggregator terminated cleanly"),
|
||||
Ok(Err(e)) => error!("input aggregator error: {e:?}"),
|
||||
Err(join_err) => error!("aggregator task panicked: {join_err:?}"),
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -172,19 +248,21 @@ impl LesavkaClientApp {
|
||||
loop {
|
||||
info!("⌨️🤙 Keyboard dial {}", self.server_addr);
|
||||
let mut cli = RelayClient::new(ep.clone());
|
||||
|
||||
let outbound = BroadcastStream::new(self.kbd_tx.subscribe())
|
||||
.filter_map(|r| r.ok());
|
||||
|
||||
|
||||
let outbound = BroadcastStream::new(self.kbd_tx.subscribe()).filter_map(|r| r.ok());
|
||||
|
||||
match cli.stream_keyboard(Request::new(outbound)).await {
|
||||
Ok(mut resp) => {
|
||||
while let Some(msg) = resp.get_mut().message().await.transpose() {
|
||||
if let Err(e) = msg { warn!("⌨️ server err: {e}"); break; }
|
||||
if let Err(e) = msg {
|
||||
warn!("⌨️ server err: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("❌⌨️ connect failed: {e}"),
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await; // retry
|
||||
tokio::time::sleep(Duration::from_secs(1)).await; // retry
|
||||
}
|
||||
}
|
||||
|
||||
@ -193,14 +271,16 @@ impl LesavkaClientApp {
|
||||
loop {
|
||||
info!("🖱️🤙 Mouse dial {}", self.server_addr);
|
||||
let mut cli = RelayClient::new(ep.clone());
|
||||
|
||||
let outbound = BroadcastStream::new(self.mou_tx.subscribe())
|
||||
.filter_map(|r| r.ok());
|
||||
|
||||
|
||||
let outbound = BroadcastStream::new(self.mou_tx.subscribe()).filter_map(|r| r.ok());
|
||||
|
||||
match cli.stream_mouse(Request::new(outbound)).await {
|
||||
Ok(mut resp) => {
|
||||
while let Some(msg) = resp.get_mut().message().await.transpose() {
|
||||
if let Err(e) = msg { warn!("🖱️ server err: {e}"); break; }
|
||||
if let Err(e) = msg {
|
||||
warn!("🖱️ server err: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("❌🖱️ connect failed: {e}"),
|
||||
@ -210,24 +290,27 @@ impl LesavkaClientApp {
|
||||
}
|
||||
|
||||
/*──────────────── monitor stream ────────────────*/
|
||||
async fn video_loop(
|
||||
ep: Channel,
|
||||
tx: tokio::sync::mpsc::UnboundedSender<VideoPacket>,
|
||||
) {
|
||||
async fn video_loop(ep: Channel, tx: tokio::sync::mpsc::UnboundedSender<VideoPacket>) {
|
||||
for monitor_id in 0..=1 {
|
||||
let ep = ep.clone();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let mut cli = RelayClient::new(ep.clone());
|
||||
let req = MonitorRequest { id: monitor_id, max_bitrate: 6_000 };
|
||||
let req = MonitorRequest {
|
||||
id: monitor_id,
|
||||
max_bitrate: 6_000,
|
||||
};
|
||||
match cli.capture_video(Request::new(req)).await {
|
||||
Ok(mut stream) => {
|
||||
debug!("🎥🏁 cli video{monitor_id}: stream opened");
|
||||
while let Some(res) = stream.get_mut().message().await.transpose() {
|
||||
match res {
|
||||
Ok(pkt) => {
|
||||
trace!("🎥📥 cli video{monitor_id}: got {} bytes", pkt.data.len());
|
||||
trace!(
|
||||
"🎥📥 cli video{monitor_id}: got {} bytes",
|
||||
pkt.data.len()
|
||||
);
|
||||
if tx.send(pkt).is_err() {
|
||||
warn!("⚠️🎥 cli video{monitor_id}: GUI thread gone");
|
||||
break;
|
||||
@ -253,11 +336,16 @@ impl LesavkaClientApp {
|
||||
async fn audio_loop(ep: Channel, out: AudioOut) {
|
||||
loop {
|
||||
let mut cli = RelayClient::new(ep.clone());
|
||||
let req = MonitorRequest { id: 0, max_bitrate: 0 };
|
||||
let req = MonitorRequest {
|
||||
id: 0,
|
||||
max_bitrate: 0,
|
||||
};
|
||||
match cli.capture_audio(Request::new(req)).await {
|
||||
Ok(mut stream) => {
|
||||
while let Some(res) = stream.get_mut().message().await.transpose() {
|
||||
if let Ok(pkt) = res { out.push(pkt); }
|
||||
if let Ok(pkt) = res {
|
||||
out.push(pkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("❌🔊 audio stream err: {e}"),
|
||||
@ -272,11 +360,11 @@ impl LesavkaClientApp {
|
||||
static FAIL_CNT: AtomicUsize = AtomicUsize::new(0);
|
||||
loop {
|
||||
let mut cli = RelayClient::new(ep.clone());
|
||||
|
||||
|
||||
// 1. create a Tokio MPSC channel
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<AudioPacket>(256);
|
||||
let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
|
||||
|
||||
|
||||
// 2. spawn a real thread that does the blocking `pull()`
|
||||
let mic_clone = mic.clone();
|
||||
std::thread::spawn(move || {
|
||||
@ -287,14 +375,12 @@ impl LesavkaClientApp {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 3. turn `rx` into an async stream for gRPC
|
||||
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
match cli.stream_microphone(Request::new(outbound)).await {
|
||||
Ok(mut resp) => {
|
||||
while resp.get_mut().message().await.transpose().is_some() {}
|
||||
}
|
||||
Err(e) => {
|
||||
match cli.stream_microphone(Request::new(outbound)).await {
|
||||
Ok(mut resp) => while resp.get_mut().message().await.transpose().is_some() {},
|
||||
Err(e) => {
|
||||
// first failure → warn, subsequent ones → debug
|
||||
if FAIL_CNT.fetch_add(1, Ordering::Relaxed) == 0 {
|
||||
warn!("❌🎤 connect failed: {e}");
|
||||
@ -328,8 +414,7 @@ impl LesavkaClientApp {
|
||||
if n < 10 || n % 120 == 0 {
|
||||
tracing::trace!("📸 cli frame#{n} {} B", pkt.data.len());
|
||||
}
|
||||
tracing::trace!("📸⬆️ sent webcam AU pts={} {} B",
|
||||
pkt.pts, pkt.data.len());
|
||||
tracing::trace!("📸⬆️ sent webcam AU pts={} {} B", pkt.pts, pkt.data.len());
|
||||
let _ = tx.try_send(pkt);
|
||||
}
|
||||
}
|
||||
@ -337,14 +422,14 @@ impl LesavkaClientApp {
|
||||
|
||||
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
match cli.stream_camera(Request::new(outbound)).await {
|
||||
Ok(_) => delay = Duration::from_secs(1), // got a stream → reset
|
||||
Ok(_) => delay = Duration::from_secs(1), // got a stream → reset
|
||||
Err(e) if e.code() == tonic::Code::Unimplemented => {
|
||||
tracing::warn!("📸 server does not support StreamCamera – giving up");
|
||||
return; // stop the task completely (#3)
|
||||
return; // stop the task completely (#3)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("❌📸 connect failed: {e:?}");
|
||||
delay = next_delay(delay); // back-off (#2)
|
||||
delay = next_delay(delay); // back-off (#2)
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
@ -355,6 +440,6 @@ impl LesavkaClientApp {
|
||||
fn next_delay(cur: std::time::Duration) -> std::time::Duration {
|
||||
match cur.as_secs() {
|
||||
1..=15 => cur * 2,
|
||||
_ => std::time::Duration::from_secs(30),
|
||||
_ => std::time::Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,28 +2,86 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use lesavka_common::lesavka::{self as pb, handshake_client::HandshakeClient};
|
||||
use tonic::Code;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use tonic::{Code, transport::Endpoint};
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug)]
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct PeerCaps {
|
||||
pub camera: bool,
|
||||
pub microphone: bool,
|
||||
pub camera: bool,
|
||||
pub microphone: bool,
|
||||
pub camera_output: Option<String>,
|
||||
pub camera_codec: Option<String>,
|
||||
pub camera_width: Option<u32>,
|
||||
pub camera_height: Option<u32>,
|
||||
pub camera_fps: Option<u32>,
|
||||
}
|
||||
|
||||
pub async fn negotiate(uri: &str) -> PeerCaps {
|
||||
let mut cli = HandshakeClient::connect(uri.to_owned())
|
||||
.await
|
||||
.expect("\"dial handshake\"");
|
||||
info!(%uri, "🤝 dial handshake");
|
||||
|
||||
match cli.get_capabilities(pb::Empty {}).await {
|
||||
Ok(rsp) => PeerCaps {
|
||||
camera: rsp.get_ref().camera,
|
||||
microphone: rsp.get_ref().microphone,
|
||||
},
|
||||
Err(e) if e.code() == Code::Unimplemented => {
|
||||
// ↺ old server – pretend it supports nothing special.
|
||||
let ep = Endpoint::from_shared(uri.to_owned())
|
||||
.expect("handshake endpoint")
|
||||
.tcp_nodelay(true)
|
||||
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||
.connect_timeout(Duration::from_secs(5));
|
||||
|
||||
let channel = timeout(Duration::from_secs(8), ep.connect())
|
||||
.await
|
||||
.expect("handshake connect timeout")
|
||||
.expect("handshake connect failed");
|
||||
|
||||
info!("🤝 handshake channel connected");
|
||||
let mut cli = HandshakeClient::new(channel);
|
||||
info!("🤝 fetching capabilities…");
|
||||
|
||||
match timeout(Duration::from_secs(5), cli.get_capabilities(pb::Empty {})).await {
|
||||
Ok(Ok(rsp)) => {
|
||||
let rsp = rsp.get_ref();
|
||||
let caps = PeerCaps {
|
||||
camera: rsp.camera,
|
||||
microphone: rsp.microphone,
|
||||
camera_output: if rsp.camera_output.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(rsp.camera_output.clone())
|
||||
},
|
||||
camera_codec: if rsp.camera_codec.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(rsp.camera_codec.clone())
|
||||
},
|
||||
camera_width: if rsp.camera_width == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(rsp.camera_width)
|
||||
},
|
||||
camera_height: if rsp.camera_height == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(rsp.camera_height)
|
||||
},
|
||||
camera_fps: if rsp.camera_fps == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(rsp.camera_fps)
|
||||
},
|
||||
};
|
||||
info!(?caps, "🤝 handshake ok");
|
||||
caps
|
||||
}
|
||||
Ok(Err(e)) if e.code() == Code::Unimplemented => {
|
||||
warn!("🤝 handshake not implemented on server – assuming defaults");
|
||||
PeerCaps::default()
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!("🤝 handshake failed: {e} – assuming defaults");
|
||||
PeerCaps::default()
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("🤝 handshake timed out – assuming defaults");
|
||||
PeerCaps::default()
|
||||
}
|
||||
Err(e) => panic!("\"handshake failed: {e}\""),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,49 +1,107 @@
|
||||
// client/src/input/camera.rs
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::Context;
|
||||
use gst::prelude::*;
|
||||
use gstreamer as gst;
|
||||
use gstreamer_app as gst_app;
|
||||
use gst::prelude::*;
|
||||
use lesavka_common::lesavka::VideoPacket;
|
||||
|
||||
fn env_u32(name: &str, default: u32) -> u32 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum CameraCodec {
|
||||
H264,
|
||||
Mjpeg,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CameraConfig {
|
||||
pub codec: CameraCodec,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
}
|
||||
|
||||
pub struct CameraCapture {
|
||||
#[allow(dead_code)] // kept alive to hold PLAYING state
|
||||
pipeline: gst::Pipeline,
|
||||
sink: gst_app::AppSink,
|
||||
sink: gst_app::AppSink,
|
||||
}
|
||||
|
||||
impl CameraCapture {
|
||||
pub fn new(device_fragment: Option<&str>) -> anyhow::Result<Self> {
|
||||
pub fn new(
|
||||
device_fragment: Option<&str>,
|
||||
cfg: Option<CameraConfig>,
|
||||
) -> anyhow::Result<Self> {
|
||||
gst::init().ok();
|
||||
|
||||
// Pick device
|
||||
let dev = device_fragment
|
||||
.and_then(Self::find_device)
|
||||
.unwrap_or_else(|| "/dev/video0".into());
|
||||
// Pick device (prefers V4L2 nodes with capture capability)
|
||||
let dev = match device_fragment {
|
||||
Some(path) if path.starts_with("/dev/") => path.to_string(),
|
||||
Some(fragment) => Self::find_device(fragment).unwrap_or_else(|| "/dev/video0".into()),
|
||||
None => "/dev/video0".into(),
|
||||
};
|
||||
|
||||
// let (enc, raw_caps) = Self::pick_encoder();
|
||||
// (NVIDIA → VA-API → software x264).
|
||||
let (enc, kf_prop, kf_val) = Self::choose_encoder();
|
||||
tracing::info!("📸 using encoder element: {enc}");
|
||||
let use_mjpg_source = std::env::var("LESAVKA_CAM_MJPG").is_ok()
|
||||
|| std::env::var("LESAVKA_CAM_FORMAT")
|
||||
.ok()
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "mjpg" | "mjpeg" | "jpeg"))
|
||||
.unwrap_or(false);
|
||||
let output_mjpeg = cfg
|
||||
.map(|cfg| matches!(cfg.codec, CameraCodec::Mjpeg))
|
||||
.unwrap_or_else(|| {
|
||||
std::env::var("LESAVKA_CAM_CODEC")
|
||||
.ok()
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "mjpeg" | "mjpg" | "jpeg"))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100);
|
||||
let (enc, kf_prop, kf_val) = if use_mjpg_source && !output_mjpeg {
|
||||
("x264enc", "key-int-max", "30")
|
||||
} else {
|
||||
Self::choose_encoder()
|
||||
};
|
||||
if use_mjpg_source && !output_mjpeg {
|
||||
tracing::info!("📸 using MJPG source with software encode");
|
||||
}
|
||||
if output_mjpeg {
|
||||
tracing::info!(
|
||||
"📸 outputting MJPEG frames for UVC (quality={jpeg_quality})"
|
||||
);
|
||||
} else {
|
||||
tracing::info!("📸 using encoder element: {enc}");
|
||||
}
|
||||
let width = cfg.map(|cfg| cfg.width).unwrap_or_else(|| env_u32("LESAVKA_CAM_WIDTH", 1280));
|
||||
let height = cfg.map(|cfg| cfg.height).unwrap_or_else(|| env_u32("LESAVKA_CAM_HEIGHT", 720));
|
||||
let fps = cfg.map(|cfg| cfg.fps).unwrap_or_else(|| env_u32("LESAVKA_CAM_FPS", 25)).max(1);
|
||||
let have_nvvidconv = gst::ElementFactory::find("nvvidconv").is_some();
|
||||
let (src_caps, preenc) = match enc {
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Jetson (has nvvidconv) Desktop (falls back to videoconvert)
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
"nvh264enc" if have_nvvidconv =>
|
||||
("video/x-raw(memory:NVMM),format=NV12,width=1280,height=720",
|
||||
"nvvidconv !"),
|
||||
(format!(
|
||||
"video/x-raw(memory:NVMM),format=NV12,width={width},height={height},framerate={fps}/1"
|
||||
), "nvvidconv !"),
|
||||
"nvh264enc" /* else */ =>
|
||||
("video/x-raw,format=NV12,width=1280,height=720",
|
||||
"videoconvert !"),
|
||||
(format!(
|
||||
"video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1"
|
||||
), "videoconvert !"),
|
||||
"vaapih264enc" =>
|
||||
("video/x-raw,format=NV12,width=1280,height=720",
|
||||
"videoconvert !"),
|
||||
(format!(
|
||||
"video/x-raw,format=NV12,width={width},height={height},framerate={fps}/1"
|
||||
), "videoconvert !"),
|
||||
_ =>
|
||||
("video/x-raw,width=1280,height=720",
|
||||
"videoconvert !"),
|
||||
};
|
||||
(format!(
|
||||
"video/x-raw,width={width},height={height},framerate={fps}/1"
|
||||
), "videoconvert !"),
|
||||
};
|
||||
|
||||
// let desc = format!(
|
||||
// "v4l2src device={dev} do-timestamp=true ! {raw_caps},width=1280,height=720 ! \
|
||||
@ -56,14 +114,43 @@ impl CameraCapture {
|
||||
// * nvh264enc needs NVMM memory caps;
|
||||
// * vaapih264enc wants system-memory caps;
|
||||
// * x264enc needs the usual raw caps.
|
||||
let desc = format!(
|
||||
"v4l2src device={dev} do-timestamp=true ! {src_caps} ! \
|
||||
{preenc} {enc} {kf_prop}={kf_val} ! \
|
||||
h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \
|
||||
queue max-size-buffers=30 leaky=downstream ! \
|
||||
appsink name=asink emit-signals=true max-buffers=60 drop=true"
|
||||
);
|
||||
tracing::info!(%enc, ?desc, "📸 using encoder element");
|
||||
let desc = if output_mjpeg {
|
||||
if use_mjpg_source {
|
||||
format!(
|
||||
"v4l2src device={dev} do-timestamp=true ! \
|
||||
image/jpeg,width={width},height={height},framerate={fps}/1 ! \
|
||||
queue max-size-buffers=30 leaky=downstream ! \
|
||||
appsink name=asink emit-signals=true max-buffers=60 drop=true"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"v4l2src device={dev} do-timestamp=true ! \
|
||||
video/x-raw,width={width},height={height},framerate={fps}/1 ! \
|
||||
videoconvert ! jpegenc quality={jpeg_quality} ! \
|
||||
queue max-size-buffers=30 leaky=downstream ! \
|
||||
appsink name=asink emit-signals=true max-buffers=60 drop=true"
|
||||
)
|
||||
}
|
||||
} else if use_mjpg_source {
|
||||
format!(
|
||||
"v4l2src device={dev} do-timestamp=true ! \
|
||||
image/jpeg,width={width},height={height} ! \
|
||||
jpegdec ! videorate ! video/x-raw,framerate={fps}/1 ! \
|
||||
videoconvert ! {enc} {kf_prop}={kf_val} ! \
|
||||
h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \
|
||||
queue max-size-buffers=30 leaky=downstream ! \
|
||||
appsink name=asink emit-signals=true max-buffers=60 drop=true"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"v4l2src device={dev} do-timestamp=true ! {src_caps} ! \
|
||||
{preenc} {enc} {kf_prop}={kf_val} ! \
|
||||
h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! \
|
||||
queue max-size-buffers=30 leaky=downstream ! \
|
||||
appsink name=asink emit-signals=true max-buffers=60 drop=true"
|
||||
)
|
||||
};
|
||||
tracing::info!(%enc, width, height, fps, ?desc, "📸 using encoder element");
|
||||
|
||||
let pipeline: gst::Pipeline = gst::parse::launch(&desc)
|
||||
.context("gst parse_launch(cam)")?
|
||||
@ -85,26 +172,61 @@ impl CameraCapture {
|
||||
|
||||
pub fn pull(&self) -> Option<VideoPacket> {
|
||||
let sample = self.sink.pull_sample().ok()?;
|
||||
let buf = sample.buffer()?;
|
||||
let map = buf.map_readable().ok()?;
|
||||
let buf = sample.buffer()?;
|
||||
let map = buf.map_readable().ok()?;
|
||||
let pts = buf.pts().unwrap_or(gst::ClockTime::ZERO).nseconds() / 1_000;
|
||||
Some(VideoPacket { id: 2, pts, data: map.as_slice().to_vec() })
|
||||
Some(VideoPacket {
|
||||
id: 2,
|
||||
pts,
|
||||
data: map.as_slice().to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fuzzy‑match devices under `/dev/v4l/by-id`
|
||||
/// Fuzzy‑match devices under `/dev/v4l/by-id`, preferring capture nodes
|
||||
fn find_device(substr: &str) -> Option<String> {
|
||||
let dir = std::fs::read_dir("/dev/v4l/by-id").ok()?;
|
||||
for e in dir.flatten() {
|
||||
let p = e.path();
|
||||
if p.file_name()?.to_string_lossy().contains(substr) {
|
||||
if let Ok(target) = std::fs::read_link(&p) {
|
||||
return Some(format!("/dev/{}", target.file_name()?.to_string_lossy()));
|
||||
let wanted = substr.to_ascii_lowercase();
|
||||
let mut matches: Vec<_> = std::fs::read_dir("/dev/v4l/by-id")
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let p = e.path();
|
||||
let name = p.file_name()?.to_string_lossy().to_ascii_lowercase();
|
||||
if name.contains(&wanted) {
|
||||
Some(p)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// deterministic order
|
||||
matches.sort();
|
||||
|
||||
for p in matches {
|
||||
if let Ok(target) = std::fs::read_link(&p) {
|
||||
let dev = format!("/dev/{}", target.file_name()?.to_string_lossy());
|
||||
if Self::is_capture(&dev) {
|
||||
return Some(dev);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_capture(dev: &str) -> bool {
|
||||
const V4L2_CAP_VIDEO_CAPTURE: u32 = 0x0000_0001;
|
||||
const V4L2_CAP_VIDEO_CAPTURE_MPLANE: u32 = 0x0000_1000;
|
||||
|
||||
v4l::Device::with_path(dev)
|
||||
.ok()
|
||||
.and_then(|d| d.query_caps().ok())
|
||||
.map(|caps| {
|
||||
let bits = caps.capabilities.bits();
|
||||
(bits & V4L2_CAP_VIDEO_CAPTURE != 0) || (bits & V4L2_CAP_VIDEO_CAPTURE_MPLANE != 0)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cheap stub used when the web‑cam is disabled
|
||||
pub fn new_stub() -> Self {
|
||||
let pipeline = gst::Pipeline::new();
|
||||
@ -116,13 +238,13 @@ impl CameraCapture {
|
||||
Self { pipeline, sink }
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // helper kept for future heuristics
|
||||
#[allow(dead_code)] // helper kept for future heuristics
|
||||
fn pick_encoder() -> (&'static str, &'static str) {
|
||||
let encoders = &[
|
||||
("nvh264enc", "video/x-raw(memory:NVMM),format=NV12"),
|
||||
("vaapih264enc","video/x-raw,format=NV12"),
|
||||
("v4l2h264enc", "video/x-raw"), // RPi, Jetson, etc.
|
||||
("x264enc", "video/x-raw"), // software
|
||||
("nvh264enc", "video/x-raw(memory:NVMM),format=NV12"),
|
||||
("vaapih264enc", "video/x-raw,format=NV12"),
|
||||
("v4l2h264enc", "video/x-raw"), // RPi, Jetson, etc.
|
||||
("x264enc", "video/x-raw"), // software
|
||||
];
|
||||
for (name, caps) in encoders {
|
||||
if gst::ElementFactory::find(name).is_some() {
|
||||
@ -135,13 +257,16 @@ impl CameraCapture {
|
||||
|
||||
fn choose_encoder() -> (&'static str, &'static str, &'static str) {
|
||||
match () {
|
||||
_ if gst::ElementFactory::find("nvh264enc").is_some() =>
|
||||
("nvh264enc", "gop-size", "30"),
|
||||
_ if gst::ElementFactory::find("vaapih264enc").is_some() =>
|
||||
("vaapih264enc","keyframe-period","30"),
|
||||
_ if gst::ElementFactory::find("v4l2h264enc").is_some() =>
|
||||
("v4l2h264enc","idrcount", "30"),
|
||||
_ => ("x264enc", "key-int-max", "30"),
|
||||
_ if gst::ElementFactory::find("nvh264enc").is_some() => {
|
||||
("nvh264enc", "gop-size", "30")
|
||||
}
|
||||
_ if gst::ElementFactory::find("vaapih264enc").is_some() => {
|
||||
("vaapih264enc", "keyframe-period", "30")
|
||||
}
|
||||
_ if gst::ElementFactory::find("v4l2h264enc").is_some() => {
|
||||
("v4l2h264enc", "idrcount", "30")
|
||||
}
|
||||
_ => ("x264enc", "key-int-max", "30"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
// client/src/input/inputs.rs
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use evdev::{Device, EventType, KeyCode, RelativeAxisCode};
|
||||
use tokio::{sync::broadcast::Sender, time::{interval, Duration}};
|
||||
use tracing::{debug, info, warn, trace};
|
||||
use tokio::{
|
||||
sync::broadcast::Sender,
|
||||
time::{Duration, interval},
|
||||
};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use lesavka_common::lesavka::{KeyboardReport, MouseReport};
|
||||
|
||||
@ -21,19 +24,26 @@ pub struct InputAggregator {
|
||||
}
|
||||
|
||||
impl InputAggregator {
|
||||
pub fn new(dev_mode: bool,
|
||||
kbd_tx: Sender<KeyboardReport>,
|
||||
mou_tx: Sender<MouseReport>) -> Self {
|
||||
Self { kbd_tx, mou_tx, dev_mode, released: false, magic_active: false,
|
||||
keyboards: Vec::new(), mice: Vec::new()
|
||||
}
|
||||
pub fn new(
|
||||
dev_mode: bool,
|
||||
kbd_tx: Sender<KeyboardReport>,
|
||||
mou_tx: Sender<MouseReport>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kbd_tx,
|
||||
mou_tx,
|
||||
dev_mode,
|
||||
released: false,
|
||||
magic_active: false,
|
||||
keyboards: Vec::new(),
|
||||
mice: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Called once at startup: enumerates input devices,
|
||||
/// classifies them, and constructs a aggregator struct per type.
|
||||
pub fn init(&mut self) -> Result<()> {
|
||||
let paths = std::fs::read_dir("/dev/input")
|
||||
.context("Failed to read /dev/input")?;
|
||||
let paths = std::fs::read_dir("/dev/input").context("Failed to read /dev/input")?;
|
||||
|
||||
let mut found_any = false;
|
||||
|
||||
@ -42,7 +52,10 @@ impl InputAggregator {
|
||||
let path = entry.path();
|
||||
|
||||
// skip anything that isn't "event*"
|
||||
if !path.file_name().map_or(false, |f| f.to_string_lossy().starts_with("event")) {
|
||||
if !path
|
||||
.file_name()
|
||||
.map_or(false, |f| f.to_string_lossy().starts_with("event"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -56,12 +69,17 @@ impl InputAggregator {
|
||||
};
|
||||
|
||||
// non-blocking so fetch_events never stalls the whole loop
|
||||
dev.set_nonblocking(true).with_context(|| format!("set_non_blocking {:?}", path))?;
|
||||
dev.set_nonblocking(true)
|
||||
.with_context(|| format!("set_non_blocking {:?}", path))?;
|
||||
|
||||
match classify_device(&dev) {
|
||||
DeviceKind::Keyboard => {
|
||||
dev.grab().with_context(|| format!("grabbing keyboard {path:?}"))?;
|
||||
info!("🤏🖱️ Grabbed keyboard {:?}", dev.name().unwrap_or("UNKNOWN"));
|
||||
dev.grab()
|
||||
.with_context(|| format!("grabbing keyboard {path:?}"))?;
|
||||
info!(
|
||||
"🤏🖱️ Grabbed keyboard {:?}",
|
||||
dev.name().unwrap_or("UNKNOWN")
|
||||
);
|
||||
|
||||
// pass dev_mode to aggregator
|
||||
// let kbd_agg = KeyboardAggregator::new(dev, self.dev_mode);
|
||||
@ -71,7 +89,8 @@ impl InputAggregator {
|
||||
continue;
|
||||
}
|
||||
DeviceKind::Mouse => {
|
||||
dev.grab().with_context(|| format!("grabbing mouse {path:?}"))?;
|
||||
dev.grab()
|
||||
.with_context(|| format!("grabbing mouse {path:?}"))?;
|
||||
info!("🤏⌨️ Grabbed mouse {:?}", dev.name().unwrap_or("UNKNOWN"));
|
||||
|
||||
// let mouse_agg = MouseAggregator::new(dev);
|
||||
@ -81,7 +100,10 @@ impl InputAggregator {
|
||||
continue;
|
||||
}
|
||||
DeviceKind::Other => {
|
||||
debug!("Skipping non-kbd/mouse device: {:?}", dev.name().unwrap_or("UNKNOWN"));
|
||||
debug!(
|
||||
"Skipping non-kbd/mouse device: {:?}",
|
||||
dev.name().unwrap_or("UNKNOWN")
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@ -104,24 +126,26 @@ impl InputAggregator {
|
||||
let magic_now = self.keyboards.iter().any(|k| k.magic_grab());
|
||||
let magic_left = self.keyboards.iter().any(|k| k.magic_left());
|
||||
let magic_right = self.keyboards.iter().any(|k| k.magic_right());
|
||||
let mut want_kill = false;
|
||||
let mut want_kill = false;
|
||||
for kbd in &mut self.keyboards {
|
||||
kbd.process_events();
|
||||
want_kill |= kbd.magic_kill();
|
||||
want_kill |= kbd.magic_kill();
|
||||
}
|
||||
|
||||
if magic_now && !self.magic_active { self.toggle_grab(); }
|
||||
if magic_now && !self.magic_active {
|
||||
self.toggle_grab();
|
||||
}
|
||||
if (magic_left || magic_right) && self.magic_active {
|
||||
current = match current {
|
||||
Layout::SideBySide => Layout::FullLeft,
|
||||
Layout::FullLeft => Layout::FullRight,
|
||||
Layout::FullRight => Layout::SideBySide,
|
||||
Layout::FullLeft => Layout::FullRight,
|
||||
Layout::FullRight => Layout::SideBySide,
|
||||
};
|
||||
apply_layout(current);
|
||||
}
|
||||
if want_kill {
|
||||
if want_kill {
|
||||
warn!("🧙 magic chord - killing 🪄 AVADA KEDAVRA!!! 💥💀⚰️");
|
||||
std::process::exit(0);
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
for mouse in &mut self.mice {
|
||||
@ -139,18 +163,18 @@ impl InputAggregator {
|
||||
} else {
|
||||
tracing::info!("🧙 magic chord - freeing devices 🪄 EXPELLIARMUS!!! 🔓🕊️");
|
||||
}
|
||||
for k in &mut self.keyboards { k.set_grab(self.released); k.set_send(self.released); }
|
||||
for m in &mut self.mice { m.set_grab(self.released); m.set_send(self.released); }
|
||||
for k in &mut self.keyboards {
|
||||
k.set_grab(self.released);
|
||||
k.set_send(self.released);
|
||||
}
|
||||
for m in &mut self.mice {
|
||||
m.set_grab(self.released);
|
||||
m.set_send(self.released);
|
||||
}
|
||||
self.released = !self.released;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Classification {
|
||||
keyboard: Option<()>,
|
||||
mouse: Option<()>,
|
||||
}
|
||||
|
||||
/// The classification function
|
||||
fn classify_device(dev: &Device) -> DeviceKind {
|
||||
let evbits = dev.supported_events();
|
||||
@ -166,13 +190,10 @@ fn classify_device(dev: &Device) -> DeviceKind {
|
||||
|
||||
// Mouse logic
|
||||
if evbits.contains(EventType::RELATIVE) {
|
||||
if let (Some(rel), Some(keys)) =
|
||||
(dev.supported_relative_axes(), dev.supported_keys())
|
||||
{
|
||||
let has_xy = rel.contains(RelativeAxisCode::REL_X)
|
||||
&& rel.contains(RelativeAxisCode::REL_Y);
|
||||
let has_btn = keys.contains(KeyCode::BTN_LEFT)
|
||||
|| keys.contains(KeyCode::BTN_RIGHT);
|
||||
if let (Some(rel), Some(keys)) = (dev.supported_relative_axes(), dev.supported_keys()) {
|
||||
let has_xy =
|
||||
rel.contains(RelativeAxisCode::REL_X) && rel.contains(RelativeAxisCode::REL_Y);
|
||||
let has_btn = keys.contains(KeyCode::BTN_LEFT) || keys.contains(KeyCode::BTN_RIGHT);
|
||||
if has_xy && has_btn {
|
||||
return DeviceKind::Mouse;
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
// client/src/input/keyboard.rs
|
||||
|
||||
use std::{collections::HashSet, sync::atomic::{AtomicU32, Ordering}};
|
||||
use evdev::{Device, EventType, InputEvent, KeyCode};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
};
|
||||
use tokio::sync::broadcast::Sender;
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
@ -11,7 +14,7 @@ use super::keymap::{is_modifier, keycode_to_usage};
|
||||
|
||||
pub struct KeyboardAggregator {
|
||||
dev: Device,
|
||||
tx: Sender<KeyboardReport>,
|
||||
tx: Sender<KeyboardReport>,
|
||||
dev_mode: bool,
|
||||
sending_disabled: bool,
|
||||
pressed_keys: HashSet<KeyCode>,
|
||||
@ -24,11 +27,21 @@ static SEQ: AtomicU32 = AtomicU32::new(0);
|
||||
impl KeyboardAggregator {
|
||||
pub fn new(dev: Device, dev_mode: bool, tx: Sender<KeyboardReport>) -> Self {
|
||||
let _ = dev.set_nonblocking(true);
|
||||
Self { dev, tx, dev_mode, sending_disabled: false, pressed_keys: HashSet::new()}
|
||||
Self {
|
||||
dev,
|
||||
tx,
|
||||
dev_mode,
|
||||
sending_disabled: false,
|
||||
pressed_keys: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_grab(&mut self, grab: bool) {
|
||||
let _ = if grab { self.dev.grab() } else { self.dev.ungrab() };
|
||||
let _ = if grab {
|
||||
self.dev.grab()
|
||||
} else {
|
||||
self.dev.ungrab()
|
||||
};
|
||||
}
|
||||
|
||||
pub fn set_send(&mut self, send: bool) {
|
||||
@ -40,28 +53,47 @@ impl KeyboardAggregator {
|
||||
let events: Vec<InputEvent> = match self.dev.fetch_events() {
|
||||
Ok(it) => it.collect(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return,
|
||||
Err(e) => { if self.dev_mode { error!("⌨️❌ read error: {e}"); } return }
|
||||
Err(e) => {
|
||||
if self.dev_mode {
|
||||
error!("⌨️❌ read error: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if self.dev_mode && !events.is_empty() {
|
||||
trace!("⌨️ {} kbd evts from {}", events.len(), self.dev.name().unwrap_or("?"));
|
||||
trace!(
|
||||
"⌨️ {} kbd evts from {}",
|
||||
events.len(),
|
||||
self.dev.name().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
|
||||
for ev in events {
|
||||
if ev.event_type() != EventType::KEY { continue }
|
||||
if ev.event_type() != EventType::KEY {
|
||||
continue;
|
||||
}
|
||||
let code = KeyCode::new(ev.code());
|
||||
match ev.value() {
|
||||
1 => { self.pressed_keys.insert(code); } // press
|
||||
0 => { self.pressed_keys.remove(&code); } // release
|
||||
1 => {
|
||||
self.pressed_keys.insert(code);
|
||||
} // press
|
||||
0 => {
|
||||
self.pressed_keys.remove(&code);
|
||||
} // release
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let report = self.build_report();
|
||||
// Generate a local sequence number for debugging/log-merge only.
|
||||
let id = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
if self.dev_mode { debug!(seq = id, ?report, "kbd"); }
|
||||
if self.dev_mode {
|
||||
debug!(seq = id, ?report, "kbd");
|
||||
}
|
||||
if !self.sending_disabled {
|
||||
let _ = self.tx.send(KeyboardReport { data: report.to_vec() });
|
||||
let _ = self.tx.send(KeyboardReport {
|
||||
data: report.to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -72,16 +104,23 @@ impl KeyboardAggregator {
|
||||
let mut keys = Vec::new();
|
||||
|
||||
for &kc in &self.pressed_keys {
|
||||
if let Some(m) = is_modifier(kc) { mods |= m }
|
||||
else if let Some(u) = keycode_to_usage(kc) { keys.push(u) }
|
||||
if let Some(m) = is_modifier(kc) {
|
||||
mods |= m
|
||||
} else if let Some(u) = keycode_to_usage(kc) {
|
||||
keys.push(u)
|
||||
}
|
||||
}
|
||||
|
||||
out[0] = mods;
|
||||
for (i, k) in keys.into_iter().take(6).enumerate() { out[2+i] = k }
|
||||
for (i, k) in keys.into_iter().take(6).enumerate() {
|
||||
out[2 + i] = k
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn has_key(&self, kc: KeyCode) -> bool { self.pressed_keys.contains(&kc) }
|
||||
pub fn has_key(&self, kc: KeyCode) -> bool {
|
||||
self.pressed_keys.contains(&kc)
|
||||
}
|
||||
|
||||
pub fn magic_grab(&self) -> bool {
|
||||
self.has_key(KeyCode::KEY_LEFTCTRL)
|
||||
@ -102,8 +141,7 @@ impl KeyboardAggregator {
|
||||
}
|
||||
|
||||
pub fn magic_kill(&self) -> bool {
|
||||
self.has_key(KeyCode::KEY_LEFTCTRL)
|
||||
&& self.has_key(KeyCode::KEY_ESC)
|
||||
self.has_key(KeyCode::KEY_LEFTCTRL) && self.has_key(KeyCode::KEY_ESC)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -77,46 +77,46 @@ pub fn keycode_to_usage(key: KeyCode) -> Option<u8> {
|
||||
KeyCode::KEY_F10 => Some(0x43),
|
||||
KeyCode::KEY_F11 => Some(0x44),
|
||||
KeyCode::KEY_F12 => Some(0x45),
|
||||
|
||||
|
||||
// --- Navigation / editing cluster ---------------------------------
|
||||
KeyCode::KEY_SYSRQ => Some(0x46), // Print-Screen
|
||||
KeyCode::KEY_SCROLLLOCK => Some(0x47),
|
||||
KeyCode::KEY_PAUSE => Some(0x48),
|
||||
KeyCode::KEY_INSERT => Some(0x49),
|
||||
KeyCode::KEY_HOME => Some(0x4A),
|
||||
KeyCode::KEY_PAGEUP => Some(0x4B),
|
||||
KeyCode::KEY_DELETE => Some(0x4C),
|
||||
KeyCode::KEY_END => Some(0x4D),
|
||||
KeyCode::KEY_PAGEDOWN => Some(0x4E),
|
||||
KeyCode::KEY_RIGHT => Some(0x4F),
|
||||
KeyCode::KEY_LEFT => Some(0x50),
|
||||
KeyCode::KEY_DOWN => Some(0x51),
|
||||
KeyCode::KEY_UP => Some(0x52),
|
||||
KeyCode::KEY_SYSRQ => Some(0x46), // Print-Screen
|
||||
KeyCode::KEY_SCROLLLOCK => Some(0x47),
|
||||
KeyCode::KEY_PAUSE => Some(0x48),
|
||||
KeyCode::KEY_INSERT => Some(0x49),
|
||||
KeyCode::KEY_HOME => Some(0x4A),
|
||||
KeyCode::KEY_PAGEUP => Some(0x4B),
|
||||
KeyCode::KEY_DELETE => Some(0x4C),
|
||||
KeyCode::KEY_END => Some(0x4D),
|
||||
KeyCode::KEY_PAGEDOWN => Some(0x4E),
|
||||
KeyCode::KEY_RIGHT => Some(0x4F),
|
||||
KeyCode::KEY_LEFT => Some(0x50),
|
||||
KeyCode::KEY_DOWN => Some(0x51),
|
||||
KeyCode::KEY_UP => Some(0x52),
|
||||
|
||||
// --- Keypad / Num-lock block --------------------------------------
|
||||
KeyCode::KEY_NUMLOCK => Some(0x53),
|
||||
KeyCode::KEY_KPSLASH => Some(0x54),
|
||||
KeyCode::KEY_KPASTERISK => Some(0x55),
|
||||
KeyCode::KEY_KPMINUS => Some(0x56),
|
||||
KeyCode::KEY_KPPLUS => Some(0x57),
|
||||
KeyCode::KEY_KPENTER => Some(0x58),
|
||||
KeyCode::KEY_KP1 => Some(0x59),
|
||||
KeyCode::KEY_KP2 => Some(0x5A),
|
||||
KeyCode::KEY_KP3 => Some(0x5B),
|
||||
KeyCode::KEY_KP4 => Some(0x5C),
|
||||
KeyCode::KEY_KP5 => Some(0x5D),
|
||||
KeyCode::KEY_KP6 => Some(0x5E),
|
||||
KeyCode::KEY_KP7 => Some(0x5F),
|
||||
KeyCode::KEY_KP8 => Some(0x60),
|
||||
KeyCode::KEY_KP9 => Some(0x61),
|
||||
KeyCode::KEY_KP0 => Some(0x62),
|
||||
KeyCode::KEY_KPDOT => Some(0x63),
|
||||
KeyCode::KEY_KPEQUAL => Some(0x67),
|
||||
KeyCode::KEY_NUMLOCK => Some(0x53),
|
||||
KeyCode::KEY_KPSLASH => Some(0x54),
|
||||
KeyCode::KEY_KPASTERISK => Some(0x55),
|
||||
KeyCode::KEY_KPMINUS => Some(0x56),
|
||||
KeyCode::KEY_KPPLUS => Some(0x57),
|
||||
KeyCode::KEY_KPENTER => Some(0x58),
|
||||
KeyCode::KEY_KP1 => Some(0x59),
|
||||
KeyCode::KEY_KP2 => Some(0x5A),
|
||||
KeyCode::KEY_KP3 => Some(0x5B),
|
||||
KeyCode::KEY_KP4 => Some(0x5C),
|
||||
KeyCode::KEY_KP5 => Some(0x5D),
|
||||
KeyCode::KEY_KP6 => Some(0x5E),
|
||||
KeyCode::KEY_KP7 => Some(0x5F),
|
||||
KeyCode::KEY_KP8 => Some(0x60),
|
||||
KeyCode::KEY_KP9 => Some(0x61),
|
||||
KeyCode::KEY_KP0 => Some(0x62),
|
||||
KeyCode::KEY_KPDOT => Some(0x63),
|
||||
KeyCode::KEY_KPEQUAL => Some(0x67),
|
||||
|
||||
// --- Misc ---------------------------------------------------------
|
||||
KeyCode::KEY_102ND => Some(0x64), // “<>” on ISO boards
|
||||
KeyCode::KEY_MENU => Some(0x65), // Application / Compose
|
||||
|
||||
KeyCode::KEY_102ND => Some(0x64), // “<>” on ISO boards
|
||||
KeyCode::KEY_MENU => Some(0x65), // Application / Compose
|
||||
|
||||
// We'll handle modifiers (ctrl, shift, alt, meta) in `is_modifier()`
|
||||
_ => None,
|
||||
}
|
||||
@ -125,13 +125,13 @@ pub fn keycode_to_usage(key: KeyCode) -> Option<u8> {
|
||||
/// If a key is a modifier, return the bit(s) to set in HID byte[0].
|
||||
pub fn is_modifier(key: KeyCode) -> Option<u8> {
|
||||
match key {
|
||||
KeyCode::KEY_LEFTCTRL => Some(0x01),
|
||||
KeyCode::KEY_LEFTCTRL => Some(0x01),
|
||||
KeyCode::KEY_LEFTSHIFT => Some(0x02),
|
||||
KeyCode::KEY_LEFTALT => Some(0x04),
|
||||
KeyCode::KEY_LEFTMETA => Some(0x08),
|
||||
KeyCode::KEY_LEFTALT => Some(0x04),
|
||||
KeyCode::KEY_LEFTMETA => Some(0x08),
|
||||
KeyCode::KEY_RIGHTCTRL => Some(0x10),
|
||||
KeyCode::KEY_RIGHTSHIFT => Some(0x20),
|
||||
KeyCode::KEY_RIGHTALT => Some(0x40),
|
||||
KeyCode::KEY_RIGHTALT => Some(0x40),
|
||||
KeyCode::KEY_RIGHTMETA => Some(0x80),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@ -3,31 +3,36 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gst::prelude::*;
|
||||
use gstreamer as gst;
|
||||
use gstreamer_app as gst_app;
|
||||
use gst::prelude::*;
|
||||
use lesavka_common::lesavka::AudioPacket;
|
||||
use tracing::{debug, error, info, warn, trace};
|
||||
use shell_escape::unix::escape;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
|
||||
pub struct MicrophoneCapture {
|
||||
#[allow(dead_code)] // kept alive to hold PLAYING state
|
||||
pipeline: gst::Pipeline,
|
||||
sink: gst_app::AppSink,
|
||||
sink: gst_app::AppSink,
|
||||
}
|
||||
|
||||
impl MicrophoneCapture {
|
||||
pub fn new() -> Result<Self> {
|
||||
gst::init().ok(); // idempotent
|
||||
gst::init().ok(); // idempotent
|
||||
|
||||
/* pulsesrc (default mic) → AAC/ADTS → appsink -------------------*/
|
||||
// Optional override: LESAVKA_MIC_SOURCE=<pulse‑device‑name>
|
||||
// If not provided or not found, fall back to first non-monitor source.
|
||||
let device_arg = match std::env::var("LESAVKA_MIC_SOURCE") {
|
||||
Ok(s) if !s.is_empty() => {
|
||||
let full = Self::pulse_source_by_substr(&s).unwrap_or(s);
|
||||
format!("device={}", escape(full.into()))
|
||||
}
|
||||
_ => String::new(),
|
||||
Ok(s) if !s.is_empty() => match Self::pulse_source_by_substr(&s) {
|
||||
Some(full) => format!("device={}", escape(full.into())),
|
||||
None => {
|
||||
warn!("🎤 requested mic '{s}' not found; using default");
|
||||
Self::default_source_arg()
|
||||
}
|
||||
},
|
||||
_ => Self::default_source_arg(),
|
||||
};
|
||||
debug!("🎤 device: {device_arg}");
|
||||
let aac = ["avenc_aac", "fdkaacenc", "faac", "opusenc"]
|
||||
@ -50,14 +55,8 @@ impl MicrophoneCapture {
|
||||
appsink name=asink emit-signals=true max-buffers=50 drop=true"
|
||||
);
|
||||
|
||||
let pipeline: gst::Pipeline = gst::parse::launch(&desc)?
|
||||
.downcast()
|
||||
.expect("pipeline");
|
||||
let sink: gst_app::AppSink = pipeline
|
||||
.by_name("asink")
|
||||
.unwrap()
|
||||
.downcast()
|
||||
.unwrap();
|
||||
let pipeline: gst::Pipeline = gst::parse::launch(&desc)?.downcast().expect("pipeline");
|
||||
let sink: gst_app::AppSink = pipeline.by_name("asink").unwrap().downcast().unwrap();
|
||||
|
||||
/* ─── bus for diagnostics ───────────────────────────────────────*/
|
||||
{
|
||||
@ -66,20 +65,30 @@ impl MicrophoneCapture {
|
||||
use gst::MessageView::*;
|
||||
for msg in bus.iter_timed(gst::ClockTime::NONE) {
|
||||
match msg.view() {
|
||||
StateChanged(s) if s.current() == gst::State::Playing
|
||||
&& msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) =>
|
||||
info!("🎤 mic pipeline ▶️ (source=pulsesrc)"),
|
||||
Error(e) =>
|
||||
error!("🎤💥 mic: {} ({})", e.error(), e.debug().unwrap_or_default()),
|
||||
Warning(w) =>
|
||||
warn!("🎤⚠️ mic: {} ({})", w.error(), w.debug().unwrap_or_default()),
|
||||
StateChanged(s)
|
||||
if s.current() == gst::State::Playing
|
||||
&& msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) =>
|
||||
{
|
||||
info!("🎤 mic pipeline ▶️ (source=pulsesrc)")
|
||||
}
|
||||
Error(e) => error!(
|
||||
"🎤💥 mic: {} ({})",
|
||||
e.error(),
|
||||
e.debug().unwrap_or_default()
|
||||
),
|
||||
Warning(w) => warn!(
|
||||
"🎤⚠️ mic: {} ({})",
|
||||
w.error(),
|
||||
w.debug().unwrap_or_default()
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pipeline.set_state(gst::State::Playing)
|
||||
pipeline
|
||||
.set_state(gst::State::Playing)
|
||||
.context("start mic pipeline")?;
|
||||
|
||||
Ok(Self { pipeline, sink })
|
||||
@ -97,8 +106,11 @@ impl MicrophoneCapture {
|
||||
if n < 10 || n % 300 == 0 {
|
||||
trace!("🎤⇧ cli pkt#{n} {} bytes", map.len());
|
||||
}
|
||||
Some(AudioPacket { id: 0, pts, data: map.as_slice().to_vec() })
|
||||
|
||||
Some(AudioPacket {
|
||||
id: 0,
|
||||
pts,
|
||||
data: map.as_slice().to_vec(),
|
||||
})
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
@ -106,15 +118,39 @@ impl MicrophoneCapture {
|
||||
|
||||
fn pulse_source_by_substr(fragment: &str) -> Option<String> {
|
||||
use std::process::Command;
|
||||
let out = Command::new("pactl").args(["list", "short", "sources"])
|
||||
.output().ok()?;
|
||||
let out = Command::new("pactl")
|
||||
.args(["list", "short", "sources"])
|
||||
.output()
|
||||
.ok()?;
|
||||
let list = String::from_utf8_lossy(&out.stdout);
|
||||
list.lines()
|
||||
.find_map(|ln| {
|
||||
let mut cols = ln.split_whitespace();
|
||||
let _id = cols.next()?;
|
||||
let name = cols.next()?; // column #1
|
||||
if name.contains(fragment) { Some(name.to_owned()) } else { None }
|
||||
})
|
||||
list.lines().find_map(|ln| {
|
||||
let mut cols = ln.split_whitespace();
|
||||
let _id = cols.next()?;
|
||||
let name = cols.next()?; // column #1
|
||||
if name.contains(fragment) {
|
||||
Some(name.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Pick the first non-monitor Pulse source if available; otherwise empty.
|
||||
fn default_source_arg() -> String {
|
||||
use std::process::Command;
|
||||
let out = Command::new("pactl")
|
||||
.args(["list", "short", "sources"])
|
||||
.output();
|
||||
if let Ok(out) = out {
|
||||
let list = String::from_utf8_lossy(&out.stdout);
|
||||
if let Some(name) = list
|
||||
.lines()
|
||||
.filter_map(|ln| ln.split_whitespace().nth(1))
|
||||
.find(|name| !name.ends_with(".monitor"))
|
||||
{
|
||||
return format!("device={}", escape(name.into()));
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
// client/src/input/mod.rs
|
||||
|
||||
pub mod inputs; // the aggregator that scans /dev/input and spawns sub-aggregators
|
||||
pub mod keyboard; // existing keyboard aggregator logic (minus scanning)
|
||||
pub mod mouse; // a stub aggregator for mice
|
||||
pub mod camera; // stub for camera
|
||||
pub mod microphone; // stub for mic
|
||||
pub mod keymap; // keyboard keymap logic
|
||||
pub mod camera; // stub for camera
|
||||
pub mod inputs; // the aggregator that scans /dev/input and spawns sub-aggregators
|
||||
pub mod keyboard; // existing keyboard aggregator logic (minus scanning)
|
||||
pub mod keymap;
|
||||
pub mod microphone; // stub for mic
|
||||
pub mod mouse; // a stub aggregator for mice // keyboard keymap logic
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
// client/src/input/mouse.rs
|
||||
|
||||
use evdev::{Device, EventType, InputEvent, KeyCode, RelativeAxisCode};
|
||||
use tokio::sync::broadcast::{self, Sender};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, error, warn, trace};
|
||||
use tokio::sync::broadcast::{self, Sender};
|
||||
use tracing::{debug, error, trace, warn};
|
||||
|
||||
use lesavka_common::lesavka::MouseReport;
|
||||
|
||||
@ -11,58 +11,91 @@ const SEND_INTERVAL: Duration = Duration::from_millis(1);
|
||||
|
||||
pub struct MouseAggregator {
|
||||
dev: Device,
|
||||
tx: Sender<MouseReport>,
|
||||
tx: Sender<MouseReport>,
|
||||
dev_mode: bool,
|
||||
sending_disabled: bool,
|
||||
next_send: Instant,
|
||||
|
||||
buttons: u8,
|
||||
last_buttons: u8,
|
||||
dx: i8,
|
||||
dy: i8,
|
||||
dx: i8,
|
||||
dy: i8,
|
||||
wheel: i8,
|
||||
}
|
||||
|
||||
impl MouseAggregator {
|
||||
pub fn new(dev: Device, dev_mode: bool, tx: Sender<MouseReport>) -> Self {
|
||||
Self { dev, tx, dev_mode, sending_disabled: false, next_send: Instant::now(), buttons:0, last_buttons:0, dx:0, dy:0, wheel:0 }
|
||||
Self {
|
||||
dev,
|
||||
tx,
|
||||
dev_mode,
|
||||
sending_disabled: false,
|
||||
next_send: Instant::now(),
|
||||
buttons: 0,
|
||||
last_buttons: 0,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
wheel: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline] fn slog(&self, f: impl FnOnce()) { if self.dev_mode { f() } }
|
||||
#[inline]
|
||||
#[allow(dead_code)]
|
||||
fn slog(&self, f: impl FnOnce()) {
|
||||
if self.dev_mode {
|
||||
f()
|
||||
}
|
||||
}
|
||||
pub fn set_grab(&mut self, grab: bool) {
|
||||
let _ = if grab { self.dev.grab() } else { self.dev.ungrab() };
|
||||
let _ = if grab {
|
||||
self.dev.grab()
|
||||
} else {
|
||||
self.dev.ungrab()
|
||||
};
|
||||
}
|
||||
|
||||
pub fn set_send(&mut self, send: bool) {
|
||||
self.sending_disabled = !send;
|
||||
}
|
||||
|
||||
|
||||
pub fn process_events(&mut self) {
|
||||
let evts: Vec<InputEvent> = match self.dev.fetch_events() {
|
||||
Ok(it) => it.collect(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return,
|
||||
Err(e) => { if self.dev_mode { error!("🖱️❌ mouse read err: {e}"); } return }
|
||||
Err(e) => {
|
||||
if self.dev_mode {
|
||||
error!("🖱️❌ mouse read err: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if self.dev_mode && !evts.is_empty() {
|
||||
trace!("🖱️ {} evts from {}", evts.len(), self.dev.name().unwrap_or("?"));
|
||||
trace!(
|
||||
"🖱️ {} evts from {}",
|
||||
evts.len(),
|
||||
self.dev.name().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
|
||||
for e in evts {
|
||||
match e.event_type() {
|
||||
EventType::KEY => match e.code() {
|
||||
c if c == KeyCode::BTN_LEFT.0 => self.set_btn(0, e.value()),
|
||||
c if c == KeyCode::BTN_RIGHT.0 => self.set_btn(1, e.value()),
|
||||
c if c == KeyCode::BTN_LEFT.0 => self.set_btn(0, e.value()),
|
||||
c if c == KeyCode::BTN_RIGHT.0 => self.set_btn(1, e.value()),
|
||||
c if c == KeyCode::BTN_MIDDLE.0 => self.set_btn(2, e.value()),
|
||||
_ => {}
|
||||
},
|
||||
EventType::RELATIVE => match e.code() {
|
||||
c if c == RelativeAxisCode::REL_X.0 =>
|
||||
self.dx = self.dx.saturating_add(e.value().clamp(-127,127) as i8),
|
||||
c if c == RelativeAxisCode::REL_Y.0 =>
|
||||
self.dy = self.dy.saturating_add(e.value().clamp(-127,127) as i8),
|
||||
c if c == RelativeAxisCode::REL_WHEEL.0 =>
|
||||
self.wheel = self.wheel.saturating_add(e.value().clamp(-1,1) as i8),
|
||||
c if c == RelativeAxisCode::REL_X.0 => {
|
||||
self.dx = self.dx.saturating_add(e.value().clamp(-127, 127) as i8)
|
||||
}
|
||||
c if c == RelativeAxisCode::REL_Y.0 => {
|
||||
self.dy = self.dy.saturating_add(e.value().clamp(-127, 127) as i8)
|
||||
}
|
||||
c if c == RelativeAxisCode::REL_WHEEL.0 => {
|
||||
self.wheel = self.wheel.saturating_add(e.value().clamp(-1, 1) as i8)
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
EventType::SYNCHRONIZATION => self.flush(),
|
||||
@ -72,13 +105,15 @@ impl MouseAggregator {
|
||||
}
|
||||
|
||||
fn flush(&mut self) {
|
||||
if self.buttons == self.last_buttons && Instant::now() < self.next_send { return; }
|
||||
if self.buttons == self.last_buttons && Instant::now() < self.next_send {
|
||||
return;
|
||||
}
|
||||
self.next_send = Instant::now() + SEND_INTERVAL;
|
||||
|
||||
let pkt = [
|
||||
self.buttons,
|
||||
self.dx.clamp(-127,127) as u8,
|
||||
self.dy.clamp(-127,127) as u8,
|
||||
self.dx.clamp(-127, 127) as u8,
|
||||
self.dy.clamp(-127, 127) as u8,
|
||||
self.wheel as u8,
|
||||
];
|
||||
|
||||
@ -86,17 +121,27 @@ impl MouseAggregator {
|
||||
if let Err(broadcast::error::SendError(_)) =
|
||||
self.tx.send(MouseReport { data: pkt.to_vec() })
|
||||
{
|
||||
if self.dev_mode { warn!("❌🖱️ no HID receiver (mouse)"); }
|
||||
if self.dev_mode {
|
||||
warn!("❌🖱️ no HID receiver (mouse)");
|
||||
}
|
||||
} else if self.dev_mode {
|
||||
debug!("📤🖱️ mouse {:?}", pkt);
|
||||
}
|
||||
}
|
||||
|
||||
self.dx=0; self.dy=0; self.wheel=0; self.last_buttons=self.buttons;
|
||||
self.dx = 0;
|
||||
self.dy = 0;
|
||||
self.wheel = 0;
|
||||
self.last_buttons = self.buttons;
|
||||
}
|
||||
|
||||
#[inline] fn set_btn(&mut self, bit: u8, val: i32) {
|
||||
if val!=0 { self.buttons |= 1<<bit } else { self.buttons &= !(1<<bit) }
|
||||
#[inline]
|
||||
fn set_btn(&mut self, bit: u8, val: i32) {
|
||||
if val != 0 {
|
||||
self.buttons |= 1 << bit
|
||||
} else {
|
||||
self.buttons &= !(1 << bit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,4 +153,3 @@ impl Drop for MouseAggregator {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,29 +1,27 @@
|
||||
// client/src/layout.rs - Wayland-only window placement utilities
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use serde_json::Value;
|
||||
use std::process::Command;
|
||||
use tracing::{info, warn};
|
||||
use serde_json::Value;
|
||||
|
||||
/// The three layouts we cycle through.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Layout {
|
||||
SideBySide, // two halves on the current monitor
|
||||
FullLeft, // left eye full-screen, right hidden
|
||||
FullRight, // right eye full-screen, left hidden
|
||||
SideBySide, // two halves on the current monitor
|
||||
FullLeft, // left eye full-screen, right hidden
|
||||
FullRight, // right eye full-screen, left hidden
|
||||
}
|
||||
|
||||
/// Move/resize a window titled “Lesavka-eye-{eye}” using `swaymsg`.
|
||||
fn place_window(eye: u32, x: i32, y: i32, w: i32, h: i32) {
|
||||
let title = format!("Lesavka-eye-{eye}");
|
||||
let cmd = format!(
|
||||
r#"[title="^{title}$"] resize set {w} {h}; move position {x} {y}"#
|
||||
);
|
||||
let cmd = format!(r#"[title="^{title}$"] resize set {w} {h}; move position {x} {y}"#);
|
||||
|
||||
match Command::new("swaymsg").arg(cmd).status() {
|
||||
Ok(st) if st.success() => info!("✅ placed eye{eye} {w}×{h}@{x},{y}"),
|
||||
Ok(st) => warn!("⚠️ swaymsg exited with {st}"),
|
||||
Err(e) => warn!("⚠️ swaymsg failed: {e}"),
|
||||
Ok(st) => warn!("⚠️ swaymsg exited with {st}"),
|
||||
Err(e) => warn!("⚠️ swaymsg failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,15 +33,23 @@ pub fn apply(layout: Layout) {
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o.stdout,
|
||||
Err(e) => { warn!("get_outputs failed: {e}"); return; }
|
||||
Err(e) => {
|
||||
warn!("get_outputs failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(Value::Array(outputs)) = serde_json::from_slice::<Value>(&out) else {
|
||||
warn!("unexpected JSON from swaymsg"); return;
|
||||
warn!("unexpected JSON from swaymsg");
|
||||
return;
|
||||
};
|
||||
let Some(rect) = outputs.iter()
|
||||
let Some(rect) = outputs
|
||||
.iter()
|
||||
.find(|o| o.get("focused").and_then(Value::as_bool) == Some(true))
|
||||
.and_then(|o| o.get("rect")) else { return; };
|
||||
.and_then(|o| o.get("rect"))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// helper to read an i64 → i32 with defaults
|
||||
let g = |k: &str| rect.get(k).and_then(Value::as_i64).unwrap_or(0) as i32;
|
||||
@ -52,8 +58,8 @@ pub fn apply(layout: Layout) {
|
||||
match layout {
|
||||
Layout::SideBySide => {
|
||||
let w_half = w / 2;
|
||||
place_window(0, x, y, w_half, h);
|
||||
place_window(1, x + w_half, y, w_half, h);
|
||||
place_window(0, x, y, w_half, h);
|
||||
place_window(1, x + w_half, y, w_half, h);
|
||||
}
|
||||
Layout::FullLeft => {
|
||||
place_window(0, x, y, w, h);
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod app;
|
||||
pub mod input;
|
||||
pub mod output;
|
||||
pub mod layout;
|
||||
pub mod handshake;
|
||||
pub mod input;
|
||||
pub mod layout;
|
||||
pub mod output;
|
||||
|
||||
pub use app::LesavkaClientApp;
|
||||
|
||||
@ -23,16 +23,22 @@ fn ensure_runtime_dir() {
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> Result<()> {
|
||||
ensure_runtime_dir();
|
||||
let headless = env::var("LESAVKA_HEADLESS").is_ok();
|
||||
if !headless {
|
||||
ensure_runtime_dir();
|
||||
}
|
||||
|
||||
/*------------- common filter & stderr layer ------------------------*/
|
||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
EnvFilter::new(
|
||||
"lesavka_client=trace,\
|
||||
lesavka_server=trace,\
|
||||
tonic=debug,\
|
||||
h2=debug,\
|
||||
tower=debug",
|
||||
"warn,\
|
||||
lesavka_client::app=info,\
|
||||
lesavka_client::input::camera=debug,\
|
||||
lesavka_client::output::video=info,\
|
||||
lesavka_client::output::audio=info,\
|
||||
tonic=warn,\
|
||||
h2=warn,\
|
||||
tower=warn",
|
||||
)
|
||||
});
|
||||
|
||||
@ -42,7 +48,7 @@ async fn main() -> Result<()> {
|
||||
.with_file(true);
|
||||
|
||||
let dev_mode = env::var("LESAVKA_DEV_MODE").is_ok();
|
||||
let mut _guard: Option<WorkerGuard> = None; // keep guard alive
|
||||
let mut _guard: Option<WorkerGuard> = None; // keep guard alive
|
||||
|
||||
/*------------- subscriber setup -----------------------------------*/
|
||||
if dev_mode {
|
||||
@ -69,7 +75,10 @@ async fn main() -> Result<()> {
|
||||
.with(file_layer)
|
||||
.init();
|
||||
|
||||
tracing::info!("📜 lesavka-client running in DEV mode → {}", log_path.display());
|
||||
tracing::info!(
|
||||
"📜 lesavka-client running in DEV mode → {}",
|
||||
log_path.display()
|
||||
);
|
||||
} else {
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
// client/src/output/audio.rs
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gst::MessageView::*;
|
||||
use gst::prelude::*;
|
||||
use gstreamer as gst;
|
||||
use gstreamer_app as gst_app;
|
||||
use gst::prelude::*;
|
||||
use gst::MessageView::*;
|
||||
use tracing::{error, info, warn, debug};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use lesavka_common::lesavka::AudioPacket;
|
||||
|
||||
@ -57,13 +57,14 @@ impl AudioOut {
|
||||
.expect("no src element")
|
||||
.downcast::<gst_app::AppSrc>()
|
||||
.expect("src not an AppSrc");
|
||||
|
||||
src.set_caps(Some(&gst::Caps::builder("audio/mpeg")
|
||||
.field("mpegversion", &4i32) // AAC
|
||||
.field("stream-format", &"adts") // ADTS frames
|
||||
.field("rate", &48_000i32) // 48 kHz
|
||||
.field("channels", &2i32) // stereo
|
||||
.build()
|
||||
|
||||
src.set_caps(Some(
|
||||
&gst::Caps::builder("audio/mpeg")
|
||||
.field("mpegversion", &4i32) // AAC
|
||||
.field("stream-format", &"adts") // ADTS frames
|
||||
.field("rate", &48_000i32) // 48 kHz
|
||||
.field("channels", &2i32) // stereo
|
||||
.build(),
|
||||
));
|
||||
src.set_format(gst::Format::Time);
|
||||
|
||||
@ -72,34 +73,40 @@ impl AudioOut {
|
||||
std::thread::spawn(move || {
|
||||
for msg in bus.iter_timed(gst::ClockTime::NONE) {
|
||||
match msg.view() {
|
||||
Error(e) => error!("💥 gst error from {:?}: {} ({})",
|
||||
msg.src().map(|s| s.path_string()),
|
||||
e.error(), e.debug().unwrap_or_default()),
|
||||
Warning(w) => warn!("⚠️ gst warning from {:?}: {} ({})",
|
||||
msg.src().map(|s| s.path_string()),
|
||||
w.error(), w.debug().unwrap_or_default()),
|
||||
Element(e) => debug!("🔎 gst element message: {}", e
|
||||
.structure()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()),
|
||||
Error(e) => error!(
|
||||
"💥 gst error from {:?}: {} ({})",
|
||||
msg.src().map(|s| s.path_string()),
|
||||
e.error(),
|
||||
e.debug().unwrap_or_default()
|
||||
),
|
||||
Warning(w) => warn!(
|
||||
"⚠️ gst warning from {:?}: {} ({})",
|
||||
msg.src().map(|s| s.path_string()),
|
||||
w.error(),
|
||||
w.debug().unwrap_or_default()
|
||||
),
|
||||
Element(e) => debug!(
|
||||
"🔎 gst element message: {}",
|
||||
e.structure().map(|s| s.to_string()).unwrap_or_default()
|
||||
),
|
||||
StateChanged(s) if s.current() == gst::State::Playing => {
|
||||
if msg
|
||||
.src()
|
||||
.map(|s| s.is::<gst::Pipeline>())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) {
|
||||
info!("🔊 audio pipeline ▶️ (sink='{}')", sink);
|
||||
} else {
|
||||
debug!("🔊 element {} now ▶️",
|
||||
msg.src().map(|s| s.name()).unwrap_or_default());
|
||||
debug!(
|
||||
"🔊 element {} now ▶️",
|
||||
msg.src().map(|s| s.name()).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pipeline.set_state(gst::State::Playing).context("starting audio pipeline")?;
|
||||
pipeline
|
||||
.set_state(gst::State::Playing)
|
||||
.context("starting audio pipeline")?;
|
||||
|
||||
Ok(Self { pipeline, src })
|
||||
}
|
||||
@ -131,7 +138,7 @@ fn pick_sink_element() -> Result<String> {
|
||||
}
|
||||
|
||||
// 2. Query PipeWire for default & running sinks
|
||||
let sinks = list_pw_sinks(); // Vec<(name,state)>
|
||||
let sinks = list_pw_sinks(); // Vec<(name,state)>
|
||||
for (n, st) in &sinks {
|
||||
if *st == "RUNNING" {
|
||||
info!("🔈 using default RUNNING sink '{}'", n);
|
||||
@ -157,20 +164,16 @@ fn pick_sink_element() -> Result<String> {
|
||||
}
|
||||
|
||||
fn list_pw_sinks() -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
if out.is_empty() {
|
||||
// ── PulseAudio / pactl fallback ────────────────────────────────
|
||||
if let Ok(info) = std::process::Command::new("pactl")
|
||||
.args(["info"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
{
|
||||
if let Some(line) = info.lines().find(|l| l.starts_with("Default Sink:")) {
|
||||
let def = line["Default Sink:".len()..].trim();
|
||||
return vec![(def.to_string(), "UNKNOWN".to_string())];
|
||||
}
|
||||
// ── PulseAudio / pactl fallback ────────────────────────────────
|
||||
if let Ok(info) = std::process::Command::new("pactl")
|
||||
.args(["info"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
{
|
||||
if let Some(line) = info.lines().find(|l| l.starts_with("Default Sink:")) {
|
||||
let def = line["Default Sink:".len()..].trim();
|
||||
return vec![(def.to_string(), "UNKNOWN".to_string())];
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
// client/src/output/display.rs
|
||||
|
||||
use gtk::gdk;
|
||||
use gtk::prelude::ListModelExt;
|
||||
use gtk::prelude::*;
|
||||
use gtk::gdk;
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MonitorInfo {
|
||||
pub geometry: gdk::Rectangle,
|
||||
pub geometry: gdk::Rectangle,
|
||||
pub scale_factor: i32,
|
||||
pub is_internal: bool,
|
||||
pub is_internal: bool,
|
||||
}
|
||||
|
||||
/// Enumerate monitors sorted by our desired priority.
|
||||
@ -22,28 +22,35 @@ pub fn enumerate_monitors() -> Vec<MonitorInfo> {
|
||||
is_internal: false,
|
||||
}];
|
||||
};
|
||||
let model = display.monitors(); // gio::ListModel
|
||||
let model = display.monitors(); // gio::ListModel
|
||||
|
||||
let mut list: Vec<_> = (0..model.n_items())
|
||||
.filter_map(|i| model.item(i))
|
||||
.filter_map(|obj| obj.downcast::<gdk::Monitor>().ok())
|
||||
.map(|m| {
|
||||
// -------- internal vs external ----------------------------------
|
||||
let connector = m.connector().unwrap_or_default(); // e.g. "eDP-1"
|
||||
let connector = m.connector().unwrap_or_default(); // e.g. "eDP-1"
|
||||
let is_internal = connector.starts_with("eDP")
|
||||
|| connector.starts_with("LVDS")
|
||||
|| connector.starts_with("DSI")
|
||||
|| connector.to_ascii_lowercase().contains("internal");
|
||||
|| connector.starts_with("LVDS")
|
||||
|| connector.starts_with("DSI")
|
||||
|| connector.to_ascii_lowercase().contains("internal");
|
||||
|
||||
// -------- geometry / scale --------------------------------------
|
||||
let geometry = m.geometry();
|
||||
let geometry = m.geometry();
|
||||
let scale_factor = m.scale_factor();
|
||||
|
||||
debug!(
|
||||
"🖥️ monitor: {:?}, connector={:?}, geom={:?}, scale={}",
|
||||
m.model(), connector, geometry, scale_factor
|
||||
m.model(),
|
||||
connector,
|
||||
geometry,
|
||||
scale_factor
|
||||
);
|
||||
MonitorInfo { geometry, scale_factor, is_internal }
|
||||
MonitorInfo {
|
||||
geometry,
|
||||
scale_factor,
|
||||
is_internal,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@ -4,23 +4,36 @@ use super::display::MonitorInfo;
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Rect { pub x: i32, pub y: i32, pub w: i32, pub h: i32 }
|
||||
pub struct Rect {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
}
|
||||
|
||||
/// Compute rectangles for N video streams (all 16:9 here).
|
||||
pub fn assign_rectangles(
|
||||
monitors: &[MonitorInfo],
|
||||
streams: &[(&str, i32, i32)], // (name, w, h)
|
||||
streams: &[(&str, i32, i32)], // (name, w, h)
|
||||
) -> Vec<Rect> {
|
||||
let mut rects = vec![Rect { x:0, y:0, w:0, h:0 }; streams.len()];
|
||||
let mut rects = vec![
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 0,
|
||||
h: 0
|
||||
};
|
||||
streams.len()
|
||||
];
|
||||
|
||||
match monitors.len() {
|
||||
0 => return rects, // impossible, but keep compiler happy
|
||||
0 => return rects, // impossible, but keep compiler happy
|
||||
1 => {
|
||||
// One monitor: side-by-side layout
|
||||
let m = &monitors[0].geometry;
|
||||
let total_native_width: i32 = streams.iter().map(|(_,w,_)| *w).sum();
|
||||
let total_native_width: i32 = streams.iter().map(|(_, w, _)| *w).sum();
|
||||
let scale = f64::min(
|
||||
m.width() as f64 / total_native_width as f64,
|
||||
m.width() as f64 / total_native_width as f64,
|
||||
m.height() as f64 / streams[0].2 as f64,
|
||||
);
|
||||
debug!("one-monitor scale = {}", scale);
|
||||
@ -29,30 +42,42 @@ pub fn assign_rectangles(
|
||||
for (idx, &(_, w, h)) in streams.iter().enumerate() {
|
||||
let ww = (w as f64 * scale).round() as i32;
|
||||
let hh = (h as f64 * scale).round() as i32;
|
||||
rects[idx] = Rect { x, y: m.y(), w: ww, h: hh };
|
||||
rects[idx] = Rect {
|
||||
x,
|
||||
y: m.y(),
|
||||
w: ww,
|
||||
h: hh,
|
||||
};
|
||||
x += ww;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// ≥2 monitors: map 1-to-1 until we run out
|
||||
for (idx, stream) in streams.iter().enumerate() {
|
||||
if idx >= monitors.len() { break; }
|
||||
|
||||
let m = &monitors[idx];
|
||||
let geom = m.geometry;
|
||||
if idx >= monitors.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let m = &monitors[idx];
|
||||
let geom = m.geometry;
|
||||
let (w, h) = (stream.1, stream.2);
|
||||
|
||||
|
||||
let scale = f64::min(
|
||||
geom.width() as f64 / w as f64,
|
||||
geom.width() as f64 / w as f64,
|
||||
geom.height() as f64 / h as f64,
|
||||
);
|
||||
debug!("monitor#{idx} scale = {scale}");
|
||||
|
||||
|
||||
let ww = (w as f64 * scale).round() as i32;
|
||||
let hh = (h as f64 * scale).round() as i32;
|
||||
let xx = geom.x() + (geom.width() - ww) / 2;
|
||||
let xx = geom.x() + (geom.width() - ww) / 2;
|
||||
let yy = geom.y() + (geom.height() - hh) / 2;
|
||||
rects[idx] = Rect { x: xx, y: yy, w: ww, h: hh };
|
||||
rects[idx] = Rect {
|
||||
x: xx,
|
||||
y: yy,
|
||||
w: ww,
|
||||
h: hh,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// client/src/output/mod.rs
|
||||
|
||||
pub mod audio;
|
||||
pub mod video;
|
||||
pub mod layout;
|
||||
pub mod display;
|
||||
pub mod layout;
|
||||
pub mod video;
|
||||
|
||||
@ -1,36 +1,20 @@
|
||||
// client/src/output/video.rs
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use std::thread;
|
||||
use anyhow::Context;
|
||||
use gstreamer as gst;
|
||||
use gstreamer::prelude::{Cast, ElementExt, GstBinExt, ObjectExt};
|
||||
use gstreamer_app as gst_app;
|
||||
use gstreamer_video::{prelude::*, VideoOverlay};
|
||||
use gst::prelude::*;
|
||||
use gstreamer_video::VideoOverlay;
|
||||
use gstreamer_video::prelude::VideoOverlayExt;
|
||||
use lesavka_common::lesavka::VideoPacket;
|
||||
use tracing::{error, info, warn, debug};
|
||||
use gstreamer_video as gst_video;
|
||||
use gstreamer_video::glib::Type;
|
||||
use std::process::Command;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::output::{display, layout};
|
||||
|
||||
/* ---------- pipeline ----------------------------------------------------
|
||||
* ┌────────────┐ H.264/AU ┌─────────┐ Decoded ┌─────────────┐
|
||||
* │ AppSrc │────────────► decodebin ├──────────► glimagesink │
|
||||
* └────────────┘ (autoplug) (overlay) |
|
||||
* ----------------------------------------------------------------------*/
|
||||
const PIPELINE_DESC: &str = concat!(
|
||||
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! ",
|
||||
"queue leaky=downstream ! ",
|
||||
"capsfilter caps=video/x-h264,stream-format=byte-stream,alignment=au ! ",
|
||||
"h264parse disable-passthrough=true ! decodebin ! videoconvert ! ",
|
||||
"glimagesink name=sink sync=false"
|
||||
);
|
||||
|
||||
pub struct MonitorWindow {
|
||||
_pipeline: gst::Pipeline,
|
||||
src: gst_app::AppSrc,
|
||||
src: gst_app::AppSrc,
|
||||
}
|
||||
|
||||
impl MonitorWindow {
|
||||
@ -38,14 +22,30 @@ impl MonitorWindow {
|
||||
gst::init().context("initialising GStreamer")?;
|
||||
|
||||
// --- Build pipeline ---------------------------------------------------
|
||||
let pipeline: gst::Pipeline = gst::parse::launch(PIPELINE_DESC)?
|
||||
let sink = if std::env::var("GDK_BACKEND")
|
||||
.map(|v| v.contains("x11"))
|
||||
.unwrap_or_else(|_| std::env::var_os("DISPLAY").is_some())
|
||||
{
|
||||
"ximagesink name=sink sync=false"
|
||||
} else {
|
||||
"glimagesink name=sink sync=false"
|
||||
};
|
||||
|
||||
let desc = format!(
|
||||
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! \
|
||||
queue leaky=downstream ! \
|
||||
capsfilter caps=video/x-h264,stream-format=byte-stream,alignment=au ! \
|
||||
h264parse disable-passthrough=true ! decodebin ! videoconvert ! {sink}"
|
||||
);
|
||||
|
||||
let pipeline: gst::Pipeline = gst::parse::launch(&desc)?
|
||||
.downcast::<gst::Pipeline>()
|
||||
.expect("not a pipeline");
|
||||
|
||||
/* -------- placement maths -------------------------------------- */
|
||||
let monitors = display::enumerate_monitors();
|
||||
let monitors = display::enumerate_monitors();
|
||||
let stream_defs = &[("eye-0", 1920, 1080), ("eye-1", 1920, 1080)];
|
||||
let rects = layout::assign_rectangles(&monitors, stream_defs);
|
||||
let rects = layout::assign_rectangles(&monitors, stream_defs);
|
||||
|
||||
// --- AppSrc------------------------------------------------------------
|
||||
let src: gst_app::AppSrc = pipeline
|
||||
@ -54,38 +54,46 @@ impl MonitorWindow {
|
||||
.downcast::<gst_app::AppSrc>()
|
||||
.unwrap();
|
||||
|
||||
src.set_caps(Some(&gst::Caps::builder("video/x-h264")
|
||||
.field("stream-format", &"byte-stream")
|
||||
.field("alignment", &"au")
|
||||
.build()));
|
||||
src.set_caps(Some(
|
||||
&gst::Caps::builder("video/x-h264")
|
||||
.field("stream-format", &"byte-stream")
|
||||
.field("alignment", &"au")
|
||||
.build(),
|
||||
));
|
||||
src.set_format(gst::Format::Time);
|
||||
|
||||
/* -------- move/resize overlay ---------------------------------- */
|
||||
if let Some(sink_elem) = pipeline.by_name("sink") {
|
||||
if sink_elem.find_property("window-title").is_some() {
|
||||
let _ = sink_elem.set_property("window-title", &format!("Lesavka-eye-{id}"));
|
||||
}
|
||||
if let Ok(overlay) = sink_elem.dynamic_cast::<VideoOverlay>() {
|
||||
if let Some(r) = rects.get(id as usize) {
|
||||
// 1. Tell glimagesink how to crop the texture in its own window
|
||||
overlay.set_render_rectangle(r.x, r.y, r.w, r.h);
|
||||
let _ = overlay.set_render_rectangle(r.x, r.y, r.w, r.h);
|
||||
debug!(
|
||||
"🔲 eye-{id} → render_rectangle({}, {}, {}, {})",
|
||||
r.x, r.y, r.w, r.h
|
||||
);
|
||||
|
||||
|
||||
// 2. **Compositor-level** placement (Wayland only)
|
||||
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||
use std::process::{Command, ExitStatus};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
|
||||
// A small helper struct so the two branches return the same type
|
||||
struct Placer {
|
||||
name: &'static str,
|
||||
run: Arc<dyn Fn(&str) -> std::io::Result<ExitStatus> + Send + Sync>,
|
||||
run: Arc<dyn Fn(&str) -> std::io::Result<ExitStatus> + Send + Sync>,
|
||||
}
|
||||
|
||||
let placer = if Command::new("swaymsg").arg("-t").arg("get_tree")
|
||||
.output().is_ok()
|
||||
|
||||
let placer = if Command::new("swaymsg")
|
||||
.arg("-t")
|
||||
.arg("get_tree")
|
||||
.output()
|
||||
.is_ok()
|
||||
{
|
||||
Placer {
|
||||
name: "swaymsg",
|
||||
@ -111,26 +119,29 @@ impl MonitorWindow {
|
||||
}),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if placer.name != "noop" {
|
||||
// Criteria string that works for i3-/sway-compatible IPC
|
||||
let criteria = format!(
|
||||
r#"[title="^Lesavka-eye-{id}$"] \
|
||||
resize set {w} {h}; \
|
||||
move absolute position {x} {y}"#,
|
||||
w = r.w,
|
||||
h = r.h,
|
||||
x = r.x,
|
||||
y = r.y,
|
||||
);
|
||||
|
||||
let cmd = match placer.name {
|
||||
// Criteria string that works for i3-/sway-compatible IPC
|
||||
"swaymsg" | "hyprctl" => format!(
|
||||
r#"[title="^Lesavka-eye-{id}$"] \
|
||||
resize set {w} {h}; \
|
||||
move absolute position {x} {y}"#,
|
||||
w = r.w,
|
||||
h = r.h,
|
||||
x = r.x,
|
||||
y = r.y,
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
// Retry in a detached thread - avoids blocking GStreamer
|
||||
let placename = placer.name;
|
||||
let runner = placer.run.clone();
|
||||
let runner = placer.run.clone();
|
||||
thread::spawn(move || {
|
||||
for attempt in 1..=10 {
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
match runner(&criteria) {
|
||||
match runner(&cmd) {
|
||||
Ok(st) if st.success() => {
|
||||
tracing::info!(
|
||||
"✅ {placename}: placed eye-{id} (attempt {attempt})"
|
||||
@ -145,26 +156,47 @@ impl MonitorWindow {
|
||||
});
|
||||
}
|
||||
}
|
||||
// 3. X11 / Xwayland placement via wmctrl
|
||||
else if std::env::var_os("DISPLAY").is_some() {
|
||||
let title = format!("Lesavka-eye-{id}");
|
||||
let w = r.w;
|
||||
let h = r.h;
|
||||
let x = r.x;
|
||||
let y = r.y;
|
||||
std::thread::spawn(move || {
|
||||
for attempt in 1..=10 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
let status = Command::new("wmctrl")
|
||||
.args(["-r", &title, "-e", &format!("0,{x},{y},{w},{h}")])
|
||||
.status();
|
||||
match status {
|
||||
Ok(st) if st.success() => {
|
||||
tracing::info!(
|
||||
"✅ wmctrl placed eye-{id} (attempt {attempt})"
|
||||
);
|
||||
break;
|
||||
}
|
||||
_ => tracing::debug!(
|
||||
"⌛ wmctrl: eye-{id} not mapped yet (attempt {attempt})"
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
let id = id; // move into thread
|
||||
let id = id; // move into thread
|
||||
let bus = pipeline.bus().expect("no bus");
|
||||
std::thread::spawn(move || {
|
||||
use gst::MessageView::*;
|
||||
for msg in bus.iter_timed(gst::ClockTime::NONE) {
|
||||
match msg.view() {
|
||||
StateChanged(s) if s.current() == gst::State::Playing => {
|
||||
if msg
|
||||
.src()
|
||||
.map(|s| s.is::<gst::Pipeline>())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
info!(
|
||||
"🎞️ video{id} pipeline ▶️ (sink='glimagesink')"
|
||||
);
|
||||
if msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) {
|
||||
info!("🎞️ video{id} pipeline ▶️ (sink='glimagesink')");
|
||||
}
|
||||
}
|
||||
Error(e) => error!(
|
||||
@ -185,16 +217,23 @@ impl MonitorWindow {
|
||||
|
||||
pipeline.set_state(gst::State::Playing)?;
|
||||
|
||||
Ok(Self { _pipeline: pipeline, src })
|
||||
Ok(Self {
|
||||
_pipeline: pipeline,
|
||||
src,
|
||||
})
|
||||
}
|
||||
|
||||
/// Feed one access-unit to the decoder.
|
||||
pub fn push_packet(&self, pkt: VideoPacket) {
|
||||
static CNT : std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if n % 150 == 0 || n < 10 {
|
||||
debug!(eye = pkt.id, bytes = pkt.data.len(), pts = pkt.pts, "⬇️ received video AU");
|
||||
debug!(
|
||||
eye = pkt.id,
|
||||
bytes = pkt.data.len(),
|
||||
pts = pkt.pts,
|
||||
"⬇️ received video AU"
|
||||
);
|
||||
}
|
||||
let mut buf = gst::Buffer::from_slice(pkt.data);
|
||||
buf.get_mut()
|
||||
|
||||
@ -11,7 +11,15 @@ message AudioPacket { uint32 id = 1; uint64 pts = 2; bytes data = 3; }
|
||||
|
||||
message ResetUsbReply { bool ok = 1; } // true = success
|
||||
|
||||
message HandshakeSet { bool camera = 1; bool microphone = 2; }
|
||||
message HandshakeSet {
|
||||
bool camera = 1;
|
||||
bool microphone = 2;
|
||||
string camera_output = 3;
|
||||
string camera_codec = 4;
|
||||
uint32 camera_width = 5;
|
||||
uint32 camera_height = 6;
|
||||
uint32 camera_fps = 7;
|
||||
}
|
||||
|
||||
message Empty {}
|
||||
|
||||
|
||||
@ -6,7 +6,258 @@
|
||||
set -euo pipefail
|
||||
|
||||
log() { printf '[lesavka-core] %s\n' "$*"; }
|
||||
cleanup() { echo "" >"$G/UDC" 2>/dev/null || true; }
|
||||
|
||||
G=/sys/kernel/config/usb_gadget/lesavka
|
||||
|
||||
if [[ -r /etc/lesavka/uvc.env ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source /etc/lesavka/uvc.env
|
||||
fi
|
||||
|
||||
find_udc() {
|
||||
ls /sys/class/udc 2>/dev/null | head -n1 || true
|
||||
}
|
||||
|
||||
udc_state() {
|
||||
local udc="$1"
|
||||
if [[ -z $udc ]]; then
|
||||
echo "unknown"
|
||||
return 0
|
||||
fi
|
||||
cat "/sys/class/udc/$udc/state" 2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
is_attached_state() {
|
||||
case "$1" in
|
||||
configured|addressed|default|suspended)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
detach_gadget() {
|
||||
local udc=""
|
||||
udc="$(find_udc)"
|
||||
local state
|
||||
state="$(udc_state "$udc")"
|
||||
case "$state" in
|
||||
configured|addressed|default|suspended)
|
||||
log "detach skipped (state=$state)"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
if [[ -n $udc && -w /sys/class/udc/$udc/soft_connect ]]; then
|
||||
echo 0 >"/sys/class/udc/$udc/soft_connect" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n ${LESAVKA_DETACH_CLEAR_UDC:-} && -e $G/UDC ]]; then
|
||||
echo "" >"$G/UDC" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n $udc ]]; then
|
||||
log "detached (state=${state:-unknown})"
|
||||
else
|
||||
log "detached (no UDC)"
|
||||
fi
|
||||
}
|
||||
|
||||
attach_gadget() {
|
||||
if [[ ! -d $G ]]; then
|
||||
log "gadget path missing; need full setup"
|
||||
return 1
|
||||
fi
|
||||
local udc=""
|
||||
udc="$(find_udc)"
|
||||
if [[ -z $udc ]]; then
|
||||
log "UDC not found; need full setup"
|
||||
return 1
|
||||
fi
|
||||
if [[ -n $udc && -w /sys/class/udc/$udc/soft_connect ]]; then
|
||||
echo 1 >"/sys/class/udc/$udc/soft_connect" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n ${LESAVKA_ATTACH_WRITE_UDC:-} && -e $G/UDC ]]; then
|
||||
echo "$udc" >"$G/UDC" 2>/dev/null || true
|
||||
fi
|
||||
log "attached to $udc"
|
||||
return 0
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--detach)
|
||||
detach_gadget
|
||||
exit 0
|
||||
;;
|
||||
--detach-hard)
|
||||
LESAVKA_DETACH_CLEAR_UDC=1 detach_gadget
|
||||
exit 0
|
||||
;;
|
||||
--attach)
|
||||
if attach_gadget; then
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--attach|--detach|--detach-hard]"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
cleanup() {
|
||||
if [[ -n ${LESAVKA_ALLOW_GADGET_RESET:-} ]]; then
|
||||
LESAVKA_DETACH_CLEAR_UDC=1 detach_gadget
|
||||
else
|
||||
detach_gadget
|
||||
fi
|
||||
}
|
||||
|
||||
DISABLE_UAC=${LESAVKA_DISABLE_UAC:-}
|
||||
DISABLE_UVC=${LESAVKA_DISABLE_UVC:-}
|
||||
ALLOW_RESET=${LESAVKA_ALLOW_GADGET_RESET:-}
|
||||
UVC_FALLBACK=${LESAVKA_UVC_FALLBACK:-1}
|
||||
UVC_STREAMING_INTERVAL=${LESAVKA_UVC_STREAMING_INTERVAL:-1}
|
||||
UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
|
||||
UVC_MAXBURST=${LESAVKA_UVC_MAXBURST:-1}
|
||||
UVC_INTERVAL=${LESAVKA_UVC_INTERVAL:-}
|
||||
UVC_WIDTH=${LESAVKA_UVC_WIDTH:-1280}
|
||||
UVC_HEIGHT=${LESAVKA_UVC_HEIGHT:-720}
|
||||
UVC_FPS=${LESAVKA_UVC_FPS:-25}
|
||||
UVC_DISABLE_IRQ=${LESAVKA_UVC_DISABLE_IRQ:-}
|
||||
UVC_BULK=${LESAVKA_UVC_BULK:-}
|
||||
UVC_CODEC=${LESAVKA_UVC_CODEC:-yuyv}
|
||||
|
||||
uvc_fifo_min() {
|
||||
local path="$1"
|
||||
local raw=""
|
||||
raw="$(cat "$path" 2>/dev/null || true)"
|
||||
if [[ -z $raw ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo "$raw" | tr ', ' '\n' | awk 'NF{print $1}' | awk '
|
||||
$1 > 0 { if (min == "" || $1 < min) min = $1 }
|
||||
END { if (min != "") print min }'
|
||||
}
|
||||
|
||||
uvc_fifo_min_debugfs() {
|
||||
local path="$1"
|
||||
awk -F': ' '/^g_tx_fifo_size\[/{print $2}' "$path" 2>/dev/null | awk '
|
||||
$1 > 0 { if (min == "" || $1 < min) min = $1 }
|
||||
END { if (min != "") print min }' || true
|
||||
}
|
||||
|
||||
uvc_fifo_np_debugfs() {
|
||||
local path="$1"
|
||||
awk -F': ' '/^g_np_tx_fifo_size/{print $2; exit}' "$path" 2>/dev/null || true
|
||||
}
|
||||
|
||||
compute_uvc_payload_cap() {
|
||||
UVC_PAYLOAD_CAP=""
|
||||
UVC_PAYLOAD_SRC=""
|
||||
UVC_PAYLOAD_PCT=""
|
||||
UVC_FIFO_PERIODIC="$(uvc_fifo_min /sys/module/dwc2/parameters/g_tx_fifo_size)"
|
||||
UVC_FIFO_NP="$(uvc_fifo_min /sys/module/dwc2/parameters/g_np_tx_fifo_size)"
|
||||
UVC_FIFO_SRC_PERIODIC=""
|
||||
UVC_FIFO_SRC_NP=""
|
||||
if [[ -n $UVC_FIFO_PERIODIC ]]; then
|
||||
UVC_FIFO_SRC_PERIODIC="dwc2.params"
|
||||
fi
|
||||
if [[ -n $UVC_FIFO_NP ]]; then
|
||||
UVC_FIFO_SRC_NP="dwc2.params"
|
||||
fi
|
||||
UVC_UDC="$(ls /sys/class/udc 2>/dev/null | head -n1 || true)"
|
||||
if [[ -n $UVC_UDC ]]; then
|
||||
local params="/sys/kernel/debug/usb/$UVC_UDC/params"
|
||||
if [[ -r $params ]]; then
|
||||
if [[ -z $UVC_FIFO_PERIODIC ]]; then
|
||||
UVC_FIFO_PERIODIC="$(uvc_fifo_min_debugfs "$params")"
|
||||
if [[ -n $UVC_FIFO_PERIODIC ]]; then
|
||||
UVC_FIFO_SRC_PERIODIC="debugfs.params"
|
||||
fi
|
||||
fi
|
||||
if [[ -z $UVC_FIFO_NP ]]; then
|
||||
UVC_FIFO_NP="$(uvc_fifo_np_debugfs "$params")"
|
||||
if [[ -n $UVC_FIFO_NP ]]; then
|
||||
UVC_FIFO_SRC_NP="debugfs.params"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n ${LESAVKA_UVC_MAXPAYLOAD_LIMIT:-} ]]; then
|
||||
UVC_PAYLOAD_CAP="${LESAVKA_UVC_MAXPAYLOAD_LIMIT}"
|
||||
UVC_PAYLOAD_SRC="env"
|
||||
UVC_PAYLOAD_PCT=100
|
||||
return
|
||||
fi
|
||||
|
||||
local chosen=""
|
||||
if [[ -n $UVC_BULK ]]; then
|
||||
if [[ -n $UVC_FIFO_NP ]]; then
|
||||
chosen="$UVC_FIFO_NP"
|
||||
UVC_PAYLOAD_SRC="${UVC_FIFO_SRC_NP:-dwc2.g_np_tx_fifo_size}"
|
||||
elif [[ -n $UVC_FIFO_PERIODIC ]]; then
|
||||
chosen="$UVC_FIFO_PERIODIC"
|
||||
UVC_PAYLOAD_SRC="${UVC_FIFO_SRC_PERIODIC:-dwc2.g_tx_fifo_size}"
|
||||
fi
|
||||
else
|
||||
if [[ -n $UVC_FIFO_PERIODIC ]]; then
|
||||
chosen="$UVC_FIFO_PERIODIC"
|
||||
UVC_PAYLOAD_SRC="${UVC_FIFO_SRC_PERIODIC:-dwc2.g_tx_fifo_size}"
|
||||
elif [[ -n $UVC_FIFO_NP ]]; then
|
||||
chosen="$UVC_FIFO_NP"
|
||||
UVC_PAYLOAD_SRC="${UVC_FIFO_SRC_NP:-dwc2.g_np_tx_fifo_size}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z $chosen ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local pct=${LESAVKA_UVC_LIMIT_PCT:-95}
|
||||
if ((pct < 1)); then
|
||||
pct=1
|
||||
elif ((pct > 100)); then
|
||||
pct=100
|
||||
fi
|
||||
UVC_PAYLOAD_PCT=$pct
|
||||
local bytes=$((chosen * 4))
|
||||
UVC_PAYLOAD_CAP=$((bytes * pct / 100))
|
||||
}
|
||||
|
||||
compute_uvc_payload_cap
|
||||
if [[ -n $UVC_PAYLOAD_CAP && $UVC_PAYLOAD_CAP -gt 0 ]]; then
|
||||
log "UVC fifo periodic=${UVC_FIFO_PERIODIC:-?} np=${UVC_FIFO_NP:-?} cap=${UVC_PAYLOAD_CAP}B pct=${UVC_PAYLOAD_PCT:-?} src=${UVC_PAYLOAD_SRC:-?} udc=${UVC_UDC:-?}"
|
||||
if ((UVC_MAXPACKET > UVC_PAYLOAD_CAP)); then
|
||||
log "clamping UVC maxpacket $UVC_MAXPACKET -> $UVC_PAYLOAD_CAP"
|
||||
UVC_MAXPACKET=$UVC_PAYLOAD_CAP
|
||||
fi
|
||||
fi
|
||||
if [[ -n $UVC_BULK && $UVC_MAXPACKET -gt 512 ]]; then
|
||||
log "clamping UVC maxpacket $UVC_MAXPACKET -> 512 (bulk)"
|
||||
UVC_MAXPACKET=512
|
||||
fi
|
||||
if [[ -n ${LESAVKA_UVC_MJPEG:-} ]]; then
|
||||
UVC_CODEC=mjpeg
|
||||
fi
|
||||
MAX_SPEED=${LESAVKA_MAX_SPEED:-high-speed}
|
||||
|
||||
if [[ -z $UVC_INTERVAL ]]; then
|
||||
UVC_INTERVAL=$((10000000 / UVC_FPS))
|
||||
fi
|
||||
UVC_FRAME_SIZE=${LESAVKA_UVC_FRAME_SIZE:-$((UVC_WIDTH * UVC_HEIGHT * 2))}
|
||||
|
||||
wait_for_enum() {
|
||||
local tries=${1:-50} # 50 x 100ms = 5s
|
||||
UDC_STATE="unknown"
|
||||
UDC_SPEED="unknown"
|
||||
for ((i=0; i<tries; i++)); do
|
||||
UDC_STATE=$(cat "/sys/class/udc/$UDC/state" 2>/dev/null || echo "unknown")
|
||||
UDC_SPEED=$(cat "/sys/class/udc/$UDC/current_speed" 2>/dev/null || echo "unknown")
|
||||
if [[ "$UDC_STATE" != "not attached" && "$UDC_STATE" != "unknown" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
exec 2> >(tee -a /tmp/lesavka-core.debug.$(date +%s).log)
|
||||
set -x
|
||||
@ -21,12 +272,14 @@ grep -q 'dtoverlay=dwc2,dr_mode=peripheral' "$CFG" || echo 'dtoverlay=dwc2,dr_mo
|
||||
modprobe dwc2 || { echo "dwc2 not in kernel; abort" >&2; exit 1; }
|
||||
modprobe libcomposite || { echo "libcomposite not in kernel; abort" >&2; exit 1; }
|
||||
|
||||
modprobe -r uvcvideo 2>/dev/null || true
|
||||
if [[ -n ${LESAVKA_RELOAD_UVCVIDEO:-} ]]; then
|
||||
modprobe -r uvcvideo 2>/dev/null || true
|
||||
fi
|
||||
modprobe uvcvideo || { echo "uvcvideo not in kernel; abort" >&2; exit 1; }
|
||||
|
||||
udevadm control --reload
|
||||
udevadm trigger --subsystem-match=video4linux
|
||||
udevadm settle
|
||||
udevadm settle --timeout=5 || log "⚠️ udevadm settle timed out"
|
||||
|
||||
#──────────────────────────────────────────────────
|
||||
# 2. Wait for UDC device to appear (max 10 s)
|
||||
@ -58,23 +311,56 @@ fi
|
||||
[[ -n $UDC ]] || { log "❌ UDC not present after manual bind"; exit 1; }
|
||||
log "✅ UDC detected: $UDC"
|
||||
|
||||
# If a gadget is already configured, avoid tearing it down unless forced.
|
||||
if [[ -d $G && -z $ALLOW_RESET ]]; then
|
||||
if [[ -s $G/UDC || -d $G/configs/c.1 ]]; then
|
||||
log "🔒 gadget already configured; skipping reset."
|
||||
log " Set LESAVKA_ALLOW_GADGET_RESET=1 to force rebuild."
|
||||
attach_gadget || true
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Guard against lockups: if the gadget is already bound, don't reset unless forced.
|
||||
BOUND_UDC=""
|
||||
if [[ -r $G/UDC ]]; then
|
||||
BOUND_UDC=$(cat "$G/UDC" 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -n $BOUND_UDC && -z $ALLOW_RESET ]]; then
|
||||
log "🔒 gadget already bound to '$BOUND_UDC' - refusing reset."
|
||||
log " Set LESAVKA_ALLOW_GADGET_RESET=1 to force."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Guard against lockups: don't reset gadget while host is attached unless forced.
|
||||
UDC_STATE="$(udc_state "$UDC")"
|
||||
if [[ -z $ALLOW_RESET ]] && is_attached_state "$UDC_STATE"; then
|
||||
log "🔒 UDC state is '$UDC_STATE' - refusing gadget reset while host attached."
|
||||
log " Set LESAVKA_ALLOW_GADGET_RESET=1 to force."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
#──────────────────────────────────────────────────
|
||||
# 3. (Re‑)create gadget
|
||||
#──────────────────────────────────────────────────
|
||||
mountpoint -q /sys/kernel/config || mount -t configfs none /sys/kernel/config
|
||||
G=/sys/kernel/config/usb_gadget/lesavka
|
||||
|
||||
if [[ -d $G ]]; then
|
||||
echo '' >"$G/UDC" 2>/dev/null || true
|
||||
sleep 0.2
|
||||
find "$G/configs" -type l -delete 2>/dev/null || true
|
||||
rm -rf "$G" 2>/dev/null || true
|
||||
# configfs doesn't allow unlinking attribute files; remove links then rmdir.
|
||||
find "$G" -type l -delete 2>/dev/null || true
|
||||
for dir in "$G/functions" "$G/configs" "$G/strings" "$G/os_desc" "$G/webusb"; do
|
||||
[[ -d $dir ]] || continue
|
||||
find "$dir" -mindepth 1 -depth -type d -exec rmdir {} \; 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
mkdir -p "$G"
|
||||
echo 0x1d6b >"$G/idVendor" # Linux Foundation
|
||||
echo 0x0104 >"$G/idProduct" # Multifunction Composite Gadget
|
||||
echo 0x0200 >"$G/bcdUSB"
|
||||
echo "$MAX_SPEED" >"$G/max_speed"
|
||||
|
||||
mkdir -p "$G/strings/0x409"
|
||||
echo "$(cat /proc/sys/kernel/random/uuid)" >"$G/strings/0x409/serialnumber"
|
||||
@ -103,113 +389,134 @@ printf '\x05\x01\x09\x02\xa1\x01\x09\x01\xa1\x00'\
|
||||
'\x05\x01\x09\x30\x09\x31\x09\x38\x15\x81\x25\x7f\x75\x08\x95\x03\x81\x06'\
|
||||
'\xc0\xc0' >"$G/functions/hid.usb1/report_desc"
|
||||
|
||||
# ---------- UAC2 function - speaker + mic, 2×48 kHz stereo ---------
|
||||
mkdir -p "$G/functions/uac2.usb0"
|
||||
U="$G/functions/uac2.usb0"
|
||||
# Playback (speaker)
|
||||
echo 0x3 >"$U/p_chmask" # L+R
|
||||
echo 48000 >"$U/p_srate"
|
||||
echo 2 >"$U/p_ssize" # 16 bit
|
||||
# Capture (microphone)
|
||||
echo 0x3 >"$U/c_chmask"
|
||||
echo 48000 >"$U/c_srate"
|
||||
echo 2 >"$U/c_ssize"
|
||||
# Optional: allocate a few extra request buffers
|
||||
echo 32 >"$U/req_number" 2>/dev/null || true
|
||||
if [[ -z $DISABLE_UAC ]]; then
|
||||
# ---------- UAC2 function - speaker + mic, 2×48 kHz stereo ---------
|
||||
mkdir -p "$G/functions/uac2.usb0"
|
||||
U="$G/functions/uac2.usb0"
|
||||
# Playback (speaker)
|
||||
echo 0x3 >"$U/p_chmask" # L+R
|
||||
echo 48000 >"$U/p_srate"
|
||||
echo 2 >"$U/p_ssize" # 16 bit
|
||||
# Capture (microphone)
|
||||
echo 0x3 >"$U/c_chmask"
|
||||
echo 48000 >"$U/c_srate"
|
||||
echo 2 >"$U/c_ssize"
|
||||
# Optional: allocate a few extra request buffers
|
||||
echo 32 >"$U/req_number" 2>/dev/null || true
|
||||
else
|
||||
log "🔇 UAC2 disabled (LESAVKA_DISABLE_UAC set)"
|
||||
fi
|
||||
|
||||
# ----------------------- UVC function (usb‑video) ------------------
|
||||
mkdir -p "$G/functions/uvc.usb0"
|
||||
F="$G/functions/uvc.usb0"
|
||||
if [[ -z $DISABLE_UVC ]]; then
|
||||
# ----------------------- UVC function (usb‑video) ------------------
|
||||
mkdir -p "$G/functions/uvc.usb0"
|
||||
F="$G/functions/uvc.usb0"
|
||||
echo "$UVC_STREAMING_INTERVAL" >"$F/streaming_interval"
|
||||
echo "$UVC_MAXPACKET" >"$F/streaming_maxpacket"
|
||||
echo "$UVC_MAXBURST" >"$F/streaming_maxburst"
|
||||
if [[ -n $UVC_BULK ]]; then
|
||||
echo 1 >"$F/streaming_bulk" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── 1. FORMAT DESCRIPTOR (uncompressed YUY2, 16 bpp) ──────────────
|
||||
mkdir -p "$F/streaming/uncompressed/u"
|
||||
# GUID = {59555932-0000-0010-8000-00aa00389b71} (“YUY2”) little‑endian
|
||||
printf '\x59\x55\x59\x32\x00\x00\x10\x00\x80\x00\x00\xaa\x00\x38\x9b\x71' \
|
||||
>"$F/streaming/uncompressed/u/guidFormat"
|
||||
echo 16 >"$F/streaming/uncompressed/u/bBitsPerPixel"
|
||||
# ── 1. FORMAT DESCRIPTOR ──────────────────────────────────────────
|
||||
if [[ "$UVC_CODEC" == "mjpeg" ]]; then
|
||||
mkdir -p "$F/streaming/mjpeg/m"
|
||||
echo 1 >"$F/streaming/mjpeg/m/bDefaultFrameIndex" 2>/dev/null || true
|
||||
echo 0 >"$F/streaming/mjpeg/m/bmaControls" 2>/dev/null || true
|
||||
|
||||
# ── 2. FRAME DESCRIPTOR (index 1 @ 30 fps) ────────────────────────
|
||||
mkdir -p "$F/streaming/uncompressed/u/f1"
|
||||
echo 1280 >"$F/streaming/uncompressed/u/f1/wWidth"
|
||||
echo 720 >"$F/streaming/uncompressed/u/f1/wHeight"
|
||||
echo 1843200 >"$F/streaming/uncompressed/u/f1/dwMaxVideoFrameBufferSize"
|
||||
echo 333333 >"$F/streaming/uncompressed/u/f1/dwDefaultFrameInterval" # 30 fps
|
||||
echo 333333 >"$F/streaming/uncompressed/u/f1/dwFrameInterval"
|
||||
mkdir -p "$F/streaming/mjpeg/m/720p"
|
||||
echo 0 >"$F/streaming/mjpeg/m/720p/bmCapabilities"
|
||||
echo "$UVC_WIDTH" >"$F/streaming/mjpeg/m/720p/wWidth"
|
||||
echo "$UVC_HEIGHT" >"$F/streaming/mjpeg/m/720p/wHeight"
|
||||
echo "$UVC_FRAME_SIZE" >"$F/streaming/mjpeg/m/720p/dwMaxVideoFrameBufferSize"
|
||||
echo "$UVC_INTERVAL" >"$F/streaming/mjpeg/m/720p/dwDefaultFrameInterval"
|
||||
cat <<EOF >"$F/streaming/mjpeg/m/720p/dwFrameInterval"
|
||||
${UVC_INTERVAL}
|
||||
$((UVC_INTERVAL * 2))
|
||||
EOF
|
||||
else
|
||||
# uncompressed YUY2, 16 bpp
|
||||
mkdir -p "$F/streaming/uncompressed/yuyv"
|
||||
# GUID = {59555932-0000-0010-8000-00aa00389b71} (“YUY2”) little-endian
|
||||
printf '\x59\x55\x59\x32\x00\x00\x10\x00\x80\x00\x00\xaa\x00\x38\x9b\x71' \
|
||||
>"$F/streaming/uncompressed/yuyv/guidFormat"
|
||||
echo 16 >"$F/streaming/uncompressed/yuyv/bBitsPerPixel"
|
||||
|
||||
# ── 3. REQUIRED HEADER LINKS (absolute‑paths, no “1”) ──────────────
|
||||
header_h="$F/streaming/header/h" # convenience variables
|
||||
fmt_dir="$F/streaming/uncompressed/u"
|
||||
mkdir -p "$F/streaming/uncompressed/yuyv/480p"
|
||||
echo "$UVC_WIDTH" >"$F/streaming/uncompressed/yuyv/480p/wWidth"
|
||||
echo "$UVC_HEIGHT" >"$F/streaming/uncompressed/yuyv/480p/wHeight"
|
||||
echo "$UVC_FRAME_SIZE" >"$F/streaming/uncompressed/yuyv/480p/dwMaxVideoFrameBufferSize"
|
||||
echo "$UVC_INTERVAL" >"$F/streaming/uncompressed/yuyv/480p/dwDefaultFrameInterval"
|
||||
cat <<EOF >"$F/streaming/uncompressed/yuyv/480p/dwFrameInterval"
|
||||
${UVC_INTERVAL}
|
||||
$((UVC_INTERVAL * 2))
|
||||
EOF
|
||||
fi
|
||||
|
||||
mkdir -p "$header_h"
|
||||
# ── 3. REQUIRED HEADER LINKS (per UVC gadget docs) ────────────────
|
||||
mkdir -p "$F/streaming/header/h"
|
||||
pushd "$F/streaming/header/h" >/dev/null
|
||||
if [[ "$UVC_CODEC" == "mjpeg" ]]; then
|
||||
ln -s ../../mjpeg/m mjpeg
|
||||
else
|
||||
ln -s ../../uncompressed/yuyv yuyv
|
||||
fi
|
||||
popd >/dev/null
|
||||
|
||||
# wait until the kernel has rebuilt the directory and added its attribute
|
||||
# files (bmInfo is always created by the driver)
|
||||
for _ in {1..50}; do
|
||||
[ -e "$header_h/bmInfo" ] && break
|
||||
sleep 0.010 # max 0.5 s total
|
||||
done
|
||||
|
||||
# ABSOLUTE symlink → no relative elements, name is “fmt” (not “1”)
|
||||
ln -sf "$fmt_dir" "$header_h/fmt"
|
||||
# echo 1 >"$header_h/bNumFormats"
|
||||
# echo 0 >"$header_h/bmInfo"
|
||||
|
||||
# per‑speed class directories (absolute links)
|
||||
for s in fs hs ss; do
|
||||
mkdir -p "$F/streaming/class/$s"
|
||||
ln -sf "$header_h" "$F/streaming/class/$s/h"
|
||||
done
|
||||
for s in fs hs ss; do
|
||||
mkdir -p "$F/streaming/class/$s"
|
||||
pushd "$F/streaming/class/$s" >/dev/null
|
||||
ln -s ../../header/h h
|
||||
popd >/dev/null
|
||||
done
|
||||
|
||||
# ── 4. Video‑Control interface ─────────────────────────────────────
|
||||
mkdir -p "$F/control/header/h" # real dir – mandatory
|
||||
mkdir -p "$F/control/class" # parent once
|
||||
mkdir -p "$F/control/header/h"
|
||||
set +e
|
||||
for s in fs hs ss; do
|
||||
mkdir -p "$F/control/class/$s" 2>/dev/null || continue
|
||||
pushd "$F/control/class/$s" >/dev/null
|
||||
ln -s ../../header/h h 2>/dev/null || true
|
||||
popd >/dev/null
|
||||
done
|
||||
set -e
|
||||
|
||||
echo "[lesavka-core] ★ directory tree just before links:"
|
||||
tree -L 3 "$F/control" | sed 's/^/[lesavka-core] /'
|
||||
|
||||
for s in fs hs ss; do
|
||||
# ensure the per‑speed dir exists (created by kernel)
|
||||
mkdir -p "$F/control/class/$s" # harmless if already there
|
||||
|
||||
# create the mandatory *symlink inside* that directory:
|
||||
ln -snf ../../header/h "$F/control/class/$s/h"
|
||||
done
|
||||
|
||||
for s in fs hs ss; do
|
||||
[ -L "$F/control/class/$s/h" ] || {
|
||||
echo "[lesavka‑core] ❌ $s/h link missing, aborting" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
echo "[lesavka-core] ★ directory tree just before bind:"
|
||||
tree -L 3 "$F/control" | sed 's/^/[lesavka-core] /'
|
||||
|
||||
for s in fs hs ss; do
|
||||
[ -L "$F/control/class/$s" ] || {
|
||||
echo "[lesavka-core] ❌ $s link missing, gadget aborting" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
if [[ -n $UVC_DISABLE_IRQ ]]; then
|
||||
echo 0 >"$F/control/enable_interrupt_ep" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# optional: hide unsupported controls
|
||||
echo 0 >"$F/control/terminal/camera/default/bmControls" 2>/dev/null || true
|
||||
echo 0 >"$F/control/processing/default/bmControls" 2>/dev/null || true
|
||||
echo 0 >"$F/control/terminal/camera/default/bmControls" 2>/dev/null || true
|
||||
echo 0 >"$F/control/processing/default/bmControls" 2>/dev/null || true
|
||||
|
||||
# friendly label
|
||||
mkdir -p "$F/control/header/strings/0x409"
|
||||
echo "Lesavka UVC" >"$F/control/header/strings/0x409/label"
|
||||
mkdir -p "$F/control/header/h/strings/0x409" 2>/dev/null || true
|
||||
echo "Lesavka UVC" >"$F/control/header/h/strings/0x409/label" 2>/dev/null || true
|
||||
else
|
||||
log "📷 UVC disabled (LESAVKA_DISABLE_UVC set)"
|
||||
fi
|
||||
|
||||
# ----------------------- configuration -----------------------------
|
||||
mkdir -p "$G/configs/c.1/strings/0x409"
|
||||
echo 500 > "$G/configs/c.1/MaxPower"
|
||||
# echo "Config 1" > "$G/configs/c.1/strings/0x409/configuration"
|
||||
echo "Config 1: HID + UAC2" >"$G/configs/c.1/strings/0x409/configuration"
|
||||
config_label="Config 1: HID"
|
||||
if [[ -z $DISABLE_UAC ]]; then
|
||||
config_label+=" + UAC2"
|
||||
fi
|
||||
if [[ -z $DISABLE_UVC ]]; then
|
||||
config_label+=" + UVC"
|
||||
fi
|
||||
echo "$config_label" >"$G/configs/c.1/strings/0x409/configuration"
|
||||
|
||||
ln -s $G/functions/hid.usb0 $G/configs/c.1/
|
||||
ln -s $G/functions/hid.usb1 $G/configs/c.1/
|
||||
ln -s $U $G/configs/c.1/
|
||||
ln -s $G/functions/uvc.usb0 $G/configs/c.1/
|
||||
if [[ -z $DISABLE_UAC ]]; then
|
||||
ln -s $U $G/configs/c.1/
|
||||
fi
|
||||
if [[ -z $DISABLE_UVC ]]; then
|
||||
ln -s $G/functions/uvc.usb0 $G/configs/c.1/
|
||||
fi
|
||||
|
||||
# mkdir -p $G/functions/hid.usb0/os_desc
|
||||
# mkdir -p $G/functions/hid.usb1/os_desc
|
||||
@ -232,6 +539,19 @@ ln -s $G/functions/uvc.usb0 $G/configs/c.1/
|
||||
# 4. Bind gadget
|
||||
#──────────────────────────────────────────────────
|
||||
echo "$UDC" >"$G/UDC"
|
||||
log "🎉 gadget bound on $UDC (hidg0, hidg1, UAC2 L+R, UVC)"
|
||||
parts="hidg0,hidg1"
|
||||
[[ -z $DISABLE_UAC ]] && parts+=",UAC2"
|
||||
[[ -z $DISABLE_UVC ]] && parts+=",UVC"
|
||||
log "🎉 gadget bound on $UDC ($parts)"
|
||||
|
||||
if wait_for_enum 50; then
|
||||
log "✅ UDC state is '$UDC_STATE' (speed=$UDC_SPEED)"
|
||||
else
|
||||
log "⚠️ UDC state is '$UDC_STATE' (speed=$UDC_SPEED). Host not enumerated."
|
||||
if [[ -z $DISABLE_UVC && "$UVC_FALLBACK" != "0" ]]; then
|
||||
log "♻️ retrying without UVC (LESAVKA_UVC_FALLBACK=0 to disable)"
|
||||
exec env LESAVKA_DISABLE_UVC=1 LESAVKA_UVC_FALLBACK=0 "$0"
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
31
scripts/daemon/lesavka-hw-watchdog.py
Normal file
31
scripts/daemon/lesavka-hw-watchdog.py
Normal file
@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
|
||||
path = os.environ.get("LESAVKA_WATCHDOG_DEV", "/dev/watchdog")
|
||||
interval = float(os.environ.get("LESAVKA_WATCHDOG_INTERVAL", "5"))
|
||||
|
||||
fd = os.open(path, os.O_WRONLY)
|
||||
|
||||
running = True
|
||||
|
||||
|
||||
def handle(_sig, _frame):
|
||||
global running
|
||||
running = False
|
||||
|
||||
|
||||
signal.signal(signal.SIGTERM, handle)
|
||||
signal.signal(signal.SIGINT, handle)
|
||||
|
||||
try:
|
||||
while running:
|
||||
os.write(fd, b"\0")
|
||||
time.sleep(interval)
|
||||
finally:
|
||||
try:
|
||||
os.write(fd, b"V")
|
||||
except Exception:
|
||||
pass
|
||||
os.close(fd)
|
||||
13
scripts/daemon/lesavka-hw-watchdog.service
Normal file
13
scripts/daemon/lesavka-hw-watchdog.service
Normal file
@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Lesavka hardware watchdog
|
||||
ConditionPathExists=/dev/watchdog
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/lesavka-hw-watchdog.py
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
KillMode=process
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
10
scripts/daemon/lesavka-uvc.sh
Normal file
10
scripts/daemon/lesavka-uvc.sh
Normal file
@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/daemon/lesavka-uvc.sh - launch UVC control helper as a standalone service
|
||||
set -euo pipefail
|
||||
|
||||
DEV=${LESAVKA_UVC_DEV:-/dev/v4l/by-path/platform-1000480000.usb-video-index0}
|
||||
if [[ ! -e "$DEV" ]]; then
|
||||
DEV=/dev/video0
|
||||
fi
|
||||
|
||||
exec /usr/local/bin/lesavka-uvc --device "$DEV"
|
||||
10
scripts/daemon/lesavka-watchdog.service
Normal file
10
scripts/daemon/lesavka-watchdog.service
Normal file
@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Lesavka reboot watchdog
|
||||
ConditionPathExists=!/etc/lesavka/no-reboot
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/lesavka-watchdog.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
23
scripts/daemon/lesavka-watchdog.sh
Normal file
23
scripts/daemon/lesavka-watchdog.sh
Normal file
@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HEARTBEAT=/etc/lesavka/watchdog.touch
|
||||
MAX_AGE_SEC=${LESAVKA_WATCHDOG_MAX_AGE:-840}
|
||||
|
||||
if [[ -f /etc/lesavka/no-reboot ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
now=$(date +%s)
|
||||
if [[ ! -f "$HEARTBEAT" ]]; then
|
||||
logger -t lesavka-watchdog "no heartbeat file; rebooting"
|
||||
systemctl --no-wall reboot
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mtime=$(stat -c %Y "$HEARTBEAT" 2>/dev/null || echo 0)
|
||||
age=$((now - mtime))
|
||||
if (( age > MAX_AGE_SEC )); then
|
||||
logger -t lesavka-watchdog "heartbeat stale (${age}s); rebooting"
|
||||
systemctl --no-wall reboot
|
||||
fi
|
||||
11
scripts/daemon/lesavka-watchdog.timer
Normal file
11
scripts/daemon/lesavka-watchdog.timer
Normal file
@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Lesavka reboot watchdog timer
|
||||
|
||||
[Timer]
|
||||
OnBootSec=15min
|
||||
OnUnitActiveSec=15min
|
||||
AccuracySec=30s
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@ -5,8 +5,20 @@ set -euo pipefail
|
||||
ORIG_USER=${SUDO_USER:-$(id -un)}
|
||||
|
||||
# 1. packages (Arch)
|
||||
sudo pacman -Syq --needed --noconfirm git rustup protobuf gcc evtest gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav pipewire pipewire-pulse
|
||||
yay -S --noconfirm grpcurl-bin
|
||||
sudo pacman -Syq --needed --noconfirm \
|
||||
git rustup protobuf gcc clang evtest base-devel \
|
||||
gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \
|
||||
pipewire pipewire-pulse \
|
||||
wmctrl qt6-tools
|
||||
|
||||
# 1b. yay for AUR bits (run as the invoking user, never root)
|
||||
if ! command -v yay >/dev/null 2>&1; then
|
||||
sudo -u "$ORIG_USER" bash -c 'cd /tmp && git clone --depth 1 https://aur.archlinux.org/yay.git &&
|
||||
cd yay && makepkg -si --noconfirm'
|
||||
fi
|
||||
sudo -u "$ORIG_USER" yay -S --needed --noconfirm grpcurl-bin
|
||||
|
||||
# 1c. input access
|
||||
sudo usermod -aG input "$ORIG_USER"
|
||||
|
||||
# 2. Rust tool-chain for both root & user
|
||||
@ -14,7 +26,9 @@ sudo rustup default stable
|
||||
sudo -u "$ORIG_USER" rustup default stable
|
||||
|
||||
# 3. clone / update into a user-writable dir
|
||||
SRC="$HOME/.local/src/lesavka"
|
||||
USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6)
|
||||
SRC="$USER_HOME/.local/src/lesavka"
|
||||
sudo -u "$ORIG_USER" mkdir -p "$(dirname "$SRC")"
|
||||
if [[ -d $SRC/.git ]]; then
|
||||
sudo -u "$ORIG_USER" git -C "$SRC" pull --ff-only
|
||||
else
|
||||
@ -41,7 +55,7 @@ Group=root
|
||||
|
||||
Environment=RUST_LOG=debug
|
||||
Environment=LESAVKA_DEV_MODE=1
|
||||
Environment=LESAVKA_SERVER_ADDR=http://64.25.10.31:50051
|
||||
Environment=LESAVKA_SERVER_ADDR=http://38.28.125.112:50051
|
||||
|
||||
ExecStart=/usr/local/bin/lesavka-client
|
||||
Restart=no
|
||||
|
||||
@ -5,6 +5,25 @@ ORIG_USER=${SUDO_USER:-$(id -un)}
|
||||
|
||||
REF=${LESAVKA_REF:-master} # fallback
|
||||
|
||||
udc_state() {
|
||||
local udc=""
|
||||
udc=$(ls /sys/class/udc 2>/dev/null | head -n1 || true)
|
||||
if [[ -z $udc ]]; then
|
||||
echo "unknown"
|
||||
return 0
|
||||
fi
|
||||
cat "/sys/class/udc/$udc/state" 2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
is_attached_state() {
|
||||
case "$1" in
|
||||
configured|addressed|default|suspended|unknown)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-r|--ref) REF="$2"; shift 2 ;;
|
||||
@ -20,10 +39,12 @@ sudo pacman -Syq --needed --noconfirm git \
|
||||
rustup \
|
||||
protobuf \
|
||||
gcc \
|
||||
alsa-utils \
|
||||
pipewire \
|
||||
pipewire-pulse \
|
||||
tailscale \
|
||||
base-devel \
|
||||
v4l-utils \
|
||||
gstreamer \
|
||||
gst-plugins-base \
|
||||
gst-plugins-base-libs \
|
||||
@ -42,18 +63,40 @@ if ! command -v yay >/dev/null 2>&1; then
|
||||
fi
|
||||
# yay -S --noconfirm grpcurl-bin
|
||||
|
||||
echo "==> 1c. GPIO permissions for relay"
|
||||
echo 'z /dev/gpiochip* 0660 root gpio -' | sudo tee /etc/tmpfiles.d/gpiochip.conf >/dev/null
|
||||
sudo systemd-tmpfiles --create /etc/tmpfiles.d/gpiochip.conf || true
|
||||
|
||||
echo "==> 2a. Kernel-driver tweaks"
|
||||
cat <<'EOF' | sudo tee /etc/modprobe.d/gc311-stream.conf >/dev/null
|
||||
options uvcvideo quirks=0x200 timeout=10000
|
||||
EOF
|
||||
|
||||
echo "==> 2b. Predictable /dev names for each capture card"
|
||||
# probe all v4l2 devices, keep only the two GC311 capture cards
|
||||
# ensure relay (GPIO power) is on if present
|
||||
if systemctl list-unit-files | grep -q '^relay.service'; then
|
||||
sudo systemctl enable --now relay.service
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# probe v4l2 devices for GC311s (07ca:3311)
|
||||
mapfile -t GC_VIDEOS < <(
|
||||
sudo v4l2-ctl --list-devices |
|
||||
sudo v4l2-ctl --list-devices 2>/dev/null |
|
||||
awk '/Live Gamer MINI/{getline; print $1}'
|
||||
)
|
||||
|
||||
# fallback via udev if v4l2-ctl output is empty/partial
|
||||
if [ "${#GC_VIDEOS[@]}" -ne 2 ]; then
|
||||
mapfile -t GC_VIDEOS < <(
|
||||
for dev in /dev/video*; do
|
||||
props=$(sudo udevadm info -q property -n "$dev" 2>/dev/null || true)
|
||||
if echo "$props" | grep -q 'ID_VENDOR_ID=07ca' && echo "$props" | grep -q 'ID_MODEL_ID=3311'; then
|
||||
echo "$dev"
|
||||
fi
|
||||
done | sort -u
|
||||
)
|
||||
fi
|
||||
|
||||
if [ "${#GC_VIDEOS[@]}" -ne 2 ]; then
|
||||
echo "❌ Exactly two GC311 capture cards (index0) must be attached!" >&2
|
||||
printf ' Detected: %s\n' "${GC_VIDEOS[@]}"
|
||||
@ -89,7 +132,7 @@ sudo -u "$ORIG_USER" rustup default stable
|
||||
|
||||
echo "==> 4a. Source checkout"
|
||||
SRC_DIR=/var/src/lesavka
|
||||
REPO_URL=ssh://git@scm.bstein.dev:2242/brad_stein/lesavka.git
|
||||
REPO_URL=ssh://git@scm.bstein.dev:2242/bstein/lesavka.git
|
||||
if [[ ! -d $SRC_DIR ]]; then
|
||||
sudo mkdir -p /var/src
|
||||
sudo chown "$ORIG_USER":"$ORIG_USER" /var/src
|
||||
@ -106,12 +149,23 @@ else
|
||||
sudo -u "$ORIG_USER" git -C "$SRC_DIR" checkout --force "$REF"
|
||||
fi
|
||||
|
||||
echo "==> 4b. Source build"
|
||||
sudo -u "$ORIG_USER" bash -c "cd '$SRC_DIR/server' && cargo clean && cargo build --release"
|
||||
echo "==> 4b. Kernel upgrade (optional)"
|
||||
if [[ "${LESAVKA_KERNEL_UPDATE:-1}" != "0" ]]; then
|
||||
sudo LESAVKA_KERNEL_BUILD_USER="$ORIG_USER" "$SRC_DIR/scripts/kernel/build-linux-rpi.sh"
|
||||
else
|
||||
echo "⚠️ skipping kernel upgrade (LESAVKA_KERNEL_UPDATE=0)"
|
||||
fi
|
||||
|
||||
echo "==> 4c. Source build"
|
||||
sudo -u "$ORIG_USER" bash -c "cd '$SRC_DIR/server' && cargo clean && cargo build --release --bins"
|
||||
|
||||
echo "==> 5. Install binaries"
|
||||
sudo install -Dm755 "$SRC_DIR/server/target/release/lesavka-server" /usr/local/bin/lesavka-server
|
||||
sudo install -Dm755 "$SRC_DIR/server/target/release/lesavka-uvc" /usr/local/bin/lesavka-uvc
|
||||
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh
|
||||
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh
|
||||
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-watchdog.sh" /usr/local/bin/lesavka-watchdog.sh
|
||||
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-hw-watchdog.py" /usr/local/bin/lesavka-hw-watchdog.py
|
||||
|
||||
echo "==> 6a. Systemd units - lesavka-core"
|
||||
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-core.service >/dev/null
|
||||
@ -124,7 +178,10 @@ Requires=sys-kernel-config.mount
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/lesavka-core.sh
|
||||
RemainAfterExit=yes
|
||||
CapabilityBoundingSet=CAP_SYS_ADMIN
|
||||
Environment=LESAVKA_UVC_FALLBACK=0
|
||||
Environment=LESAVKA_UVC_CODEC=mjpeg
|
||||
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_SYS_MODULE
|
||||
AmbientCapabilities=CAP_SYS_MODULE
|
||||
MountFlags=slave
|
||||
|
||||
[Install]
|
||||
@ -136,18 +193,24 @@ cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-server.service >/dev/null
|
||||
[Unit]
|
||||
Description=lesavka gRPC relay
|
||||
After=network.target lesavka-core.service
|
||||
StartLimitIntervalSec=30
|
||||
StartLimitBurst=10
|
||||
|
||||
[Service]
|
||||
ExecStartPre=/usr/local/bin/lesavka-core.sh --attach
|
||||
ExecStart=/usr/local/bin/lesavka-server
|
||||
TimeoutStopSec=10
|
||||
KillSignal=SIGTERM
|
||||
KillMode=process
|
||||
Restart=always
|
||||
Environment=RUST_LOG=lesavka_server=info,lesavka_server::audio=info,lesavka_server::video=debug,lesavka_server::gadget=info
|
||||
Environment=RUST_BACKTRACE=1
|
||||
Environment=GST_DEBUG="*:2,alsasink:6,alsasrc:6"
|
||||
Environment=LESAVKA_UVC_CODEC=mjpeg
|
||||
Environment=LESAVKA_UVC_EXTERNAL=1
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardError=append:/tmp/lesavka-server.stderr
|
||||
StartLimitIntervalSec=30
|
||||
StartLimitBurst=10
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
@ -157,10 +220,95 @@ UNIT
|
||||
echo "==> 6c. Systemd units - initialization"
|
||||
sudo truncate -s 0 /tmp/lesavka-server.log
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now lesavka-core
|
||||
sudo systemctl restart lesavka-core
|
||||
echo "✅ lesavka-core installed and restarted..."
|
||||
sudo systemctl enable lesavka-core lesavka-uvc lesavka-server
|
||||
|
||||
UDC_STATE=$(udc_state)
|
||||
if [[ -n ${LESAVKA_ALLOW_GADGET_RESET:-} ]] || ! is_attached_state "$UDC_STATE"; then
|
||||
sudo systemctl restart lesavka-core
|
||||
echo "✅ lesavka-core installed and restarted..."
|
||||
else
|
||||
echo "⚠️ UDC state is '$UDC_STATE' - skipping lesavka-core restart."
|
||||
echo " Set LESAVKA_ALLOW_GADGET_RESET=1 to force."
|
||||
fi
|
||||
|
||||
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-uvc.service >/dev/null
|
||||
[Unit]
|
||||
Description=lesavka UVC control helper
|
||||
After=lesavka-core.service
|
||||
Requires=lesavka-core.service
|
||||
RefuseManualStop=yes
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/lesavka-uvc.sh
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
KillSignal=SIGTERM
|
||||
KillMode=process
|
||||
TimeoutStopSec=10
|
||||
StandardError=append:/tmp/lesavka-uvc.stderr
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
echo "==> 6d. Systemd units - watchdogs"
|
||||
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-watchdog.service >/dev/null
|
||||
[Unit]
|
||||
Description=Lesavka reboot watchdog
|
||||
ConditionPathExists=!/etc/lesavka/no-reboot
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/lesavka-watchdog.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-watchdog.timer >/dev/null
|
||||
[Unit]
|
||||
Description=Lesavka reboot watchdog timer
|
||||
|
||||
[Timer]
|
||||
OnBootSec=15min
|
||||
OnUnitActiveSec=15min
|
||||
AccuracySec=30s
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
UNIT
|
||||
|
||||
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-hw-watchdog.service >/dev/null
|
||||
[Unit]
|
||||
Description=Lesavka hardware watchdog
|
||||
ConditionPathExists=/dev/watchdog
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/lesavka-hw-watchdog.py
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
KillMode=process
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
sudo install -d /etc/lesavka
|
||||
sudo touch /etc/lesavka/watchdog.touch
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now lesavka-hw-watchdog
|
||||
sudo systemctl enable --now lesavka-watchdog.timer
|
||||
|
||||
if [[ -n ${LESAVKA_ALLOW_GADGET_RESET:-} ]] || ! is_attached_state "$UDC_STATE"; then
|
||||
sudo systemctl restart lesavka-uvc
|
||||
echo "✅ lesavka-uvc installed and restarted..."
|
||||
else
|
||||
echo "⚠️ UDC state is '$UDC_STATE' - skipping lesavka-uvc restart."
|
||||
fi
|
||||
|
||||
sudo systemctl enable --now lesavka-server
|
||||
sudo systemctl restart lesavka-server
|
||||
echo "✅ lesavka-server installed and restarted..."
|
||||
|
||||
174
scripts/kernel/build-linux-rpi.sh
Normal file
174
scripts/kernel/build-linux-rpi.sh
Normal file
@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-linux-rpi.sh - build/install a newer linux-rpi from rpi-6.18.y
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ${EUID:-0} -ne 0 ]]; then
|
||||
echo "run as root (sudo) to install build deps and kernel packages" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILD_USER=${LESAVKA_KERNEL_BUILD_USER:-${SUDO_USER:-$(id -un)}}
|
||||
if [[ -z $BUILD_USER || $BUILD_USER == root ]]; then
|
||||
echo "missing non-root build user; set LESAVKA_KERNEL_BUILD_USER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KERNEL_REPO=${LESAVKA_KERNEL_REPO:-https://github.com/raspberrypi/linux.git}
|
||||
KERNEL_BRANCH=${LESAVKA_KERNEL_BRANCH:-rpi-6.18.y}
|
||||
KERNEL_COMMIT=${LESAVKA_KERNEL_COMMIT:-}
|
||||
PKGBUILD_REPO=${LESAVKA_KERNEL_PKG_REPO:-https://github.com/archlinuxarm/PKGBUILDs.git}
|
||||
BUILD_ROOT=${LESAVKA_KERNEL_BUILD_ROOT:-/var/tmp/lesavka-linux-rpi}
|
||||
PKGREL=${LESAVKA_KERNEL_PKGREL:-2}
|
||||
JOBS=${LESAVKA_KERNEL_JOBS:-2}
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
PATCH_DIR=${LESAVKA_KERNEL_PATCH_DIR:-$SCRIPT_DIR}
|
||||
PATCH_DWC2_FIFO=${LESAVKA_KERNEL_PATCH_DWC2_FIFO:-}
|
||||
PATCH_UVC_BULK=${LESAVKA_KERNEL_PATCH_UVC_BULK:-}
|
||||
PATCH_UVC_DEBUG=${LESAVKA_KERNEL_PATCH_UVC_DEBUG:-}
|
||||
|
||||
HEARTBEAT=/etc/lesavka/watchdog.touch
|
||||
if [[ -w $HEARTBEAT && -z ${LESAVKA_DISABLE_KEEPALIVE:-} ]]; then
|
||||
(while true; do touch "$HEARTBEAT"; sleep 600; done) &
|
||||
KEEPALIVE_PID=$!
|
||||
trap 'kill $KEEPALIVE_PID' EXIT
|
||||
fi
|
||||
|
||||
if [[ -z $KERNEL_COMMIT ]]; then
|
||||
KERNEL_COMMIT=$(git ls-remote "$KERNEL_REPO" "refs/heads/$KERNEL_BRANCH" | awk '{print $1}')
|
||||
fi
|
||||
if [[ -z $KERNEL_COMMIT ]]; then
|
||||
echo "failed to resolve kernel commit for $KERNEL_BRANCH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KERNEL_VERSION=$(
|
||||
curl -fsSL "https://raw.githubusercontent.com/raspberrypi/linux/$KERNEL_COMMIT/Makefile" |
|
||||
awk -F' = ' '
|
||||
/^VERSION =/ {v=$2}
|
||||
/^PATCHLEVEL =/ {p=$2}
|
||||
/^SUBLEVEL =/ {s=$2}
|
||||
END { if (v && p && s) print v "." p "." s }'
|
||||
)
|
||||
if [[ -z $KERNEL_VERSION ]]; then
|
||||
echo "failed to determine kernel version from $KERNEL_COMMIT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_VERSION="${KERNEL_VERSION}-${PKGREL}"
|
||||
INSTALLED_VERSION=$(pacman -Qi linux-rpi 2>/dev/null | awk -F': ' '/Version/{print $2}')
|
||||
if [[ -n $INSTALLED_VERSION && $INSTALLED_VERSION == "$TARGET_VERSION"* ]]; then
|
||||
echo "linux-rpi already at $TARGET_VERSION"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "==> Building linux-rpi $KERNEL_VERSION ($KERNEL_COMMIT) pkgrel=$PKGREL"
|
||||
|
||||
pacman -Sy --needed --noconfirm git bc kmod inetutils base-devel
|
||||
|
||||
rm -rf "$BUILD_ROOT"
|
||||
mkdir -p "$BUILD_ROOT"
|
||||
chown "$BUILD_USER":"$BUILD_USER" "$BUILD_ROOT"
|
||||
|
||||
sudo -u "$BUILD_USER" git clone --depth 1 "$PKGBUILD_REPO" "$BUILD_ROOT/PKGBUILDs"
|
||||
cp -a "$BUILD_ROOT/PKGBUILDs/core/linux-rpi" "$BUILD_ROOT/linux-rpi"
|
||||
chown -R "$BUILD_USER":"$BUILD_USER" "$BUILD_ROOT/linux-rpi"
|
||||
|
||||
sudo -u "$BUILD_USER" bash -c "
|
||||
set -euo pipefail
|
||||
cd '$BUILD_ROOT/linux-rpi'
|
||||
sed -i 's/^pkgver=.*/pkgver=$KERNEL_VERSION/' PKGBUILD
|
||||
sed -i 's/^pkgrel=.*/pkgrel=$PKGREL/' PKGBUILD
|
||||
sed -i 's/^_commit=.*/_commit=$KERNEL_COMMIT/' PKGBUILD
|
||||
makepkg -g > /tmp/lesavka-kernel.sums
|
||||
"
|
||||
|
||||
sudo -u "$BUILD_USER" BUILD_ROOT="$BUILD_ROOT" python - <<'PY'
|
||||
import re
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
root = Path(os.environ["BUILD_ROOT"])
|
||||
pkgbuild = root / "linux-rpi" / "PKGBUILD"
|
||||
sums = Path("/tmp/lesavka-kernel.sums").read_text().splitlines()
|
||||
text = pkgbuild.read_text()
|
||||
for line in sums:
|
||||
if not line.startswith("sha256sums"):
|
||||
continue
|
||||
key = line.split("=", 1)[0]
|
||||
text = re.sub(rf"^{re.escape(key)}=.*$", line, text, flags=re.M)
|
||||
pkgbuild.write_text(text)
|
||||
PY
|
||||
|
||||
MAKEPKG_EXTRA=""
|
||||
KERNEL_SRC="$BUILD_ROOT/linux-rpi/src/linux-$KERNEL_COMMIT"
|
||||
PATCHES=()
|
||||
if [[ -n $PATCH_DWC2_FIFO ]]; then
|
||||
PATCHES+=("dwc2-fifo.patch")
|
||||
fi
|
||||
if [[ -n $PATCH_UVC_BULK ]]; then
|
||||
PATCHES+=("uvc-bulk.patch")
|
||||
fi
|
||||
if [[ -n $PATCH_UVC_DEBUG ]]; then
|
||||
PATCHES+=("uvc-debug.patch")
|
||||
fi
|
||||
if [[ ${#PATCHES[@]} -gt 0 ]]; then
|
||||
sudo -u "$BUILD_USER" bash -c "
|
||||
set -euo pipefail
|
||||
cd '$BUILD_ROOT/linux-rpi'
|
||||
MAKEFLAGS='-j$JOBS' makepkg -o --noconfirm
|
||||
"
|
||||
sudo -u "$BUILD_USER" bash -c "
|
||||
set -euo pipefail
|
||||
src='$KERNEL_SRC'
|
||||
if [[ ! -d \$src ]]; then
|
||||
echo \"missing kernel source \$src\" >&2
|
||||
exit 1
|
||||
fi
|
||||
cd \"\$src\"
|
||||
for patch in ${PATCHES[*]}; do
|
||||
patch_file='$PATCH_DIR/'\"\$patch\"
|
||||
if [[ ! -f \$patch_file ]]; then
|
||||
echo \"missing patch \$patch_file\" >&2
|
||||
exit 1
|
||||
fi
|
||||
case \"\$patch\" in
|
||||
dwc2-fifo.patch)
|
||||
if grep -q 'dwc2_check_param_fifo_total' \"drivers/usb/dwc2/params.c\"; then
|
||||
echo \"dwc2-fifo patch already applied\"
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
uvc-bulk.patch)
|
||||
if grep -q 'streaming_bulk' \"drivers/usb/gadget/function/uvc_configfs.c\"; then
|
||||
echo \"uvc-bulk patch already applied\"
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
uvc-debug.patch)
|
||||
if grep -q 'uvcg_video_enable: missing uvc/func/config' \
|
||||
\"drivers/usb/gadget/function/uvc_video.c\"; then
|
||||
echo \"uvc-debug patch already applied\"
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
patch -p1 < \"\$patch_file\"
|
||||
done
|
||||
"
|
||||
MAKEPKG_EXTRA="-e"
|
||||
fi
|
||||
|
||||
sudo -u "$BUILD_USER" bash -c "
|
||||
set -euo pipefail
|
||||
cd '$BUILD_ROOT/linux-rpi'
|
||||
MAKEFLAGS='-j$JOBS' makepkg -s --noconfirm $MAKEPKG_EXTRA
|
||||
"
|
||||
|
||||
mapfile -t PKGS < <(ls "$BUILD_ROOT/linux-rpi"/*.pkg.tar.* 2>/dev/null)
|
||||
if [[ ${#PKGS[@]} -eq 0 ]]; then
|
||||
echo "no kernel packages built" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pacman -U --noconfirm "${PKGS[@]}"
|
||||
echo "✅ linux-rpi upgraded to $TARGET_VERSION (reboot required)"
|
||||
68
scripts/kernel/dwc2-fifo.patch
Normal file
68
scripts/kernel/dwc2-fifo.patch
Normal file
@ -0,0 +1,68 @@
|
||||
diff --git a/drivers/usb/dwc2/gadget.c b/drivers/usb/dwc2/gadget.c
|
||||
index 0a10aa2f8f5c..d7c5a3d1c4de 100644
|
||||
--- a/drivers/usb/dwc2/gadget.c
|
||||
+++ b/drivers/usb/dwc2/gadget.c
|
||||
@@ -226,10 +226,7 @@ int dwc2_hsotg_tx_fifo_total_depth(struct dwc2_hsotg *hsotg)
|
||||
{
|
||||
int addr;
|
||||
int tx_addr_max;
|
||||
- u32 np_tx_fifo_size;
|
||||
-
|
||||
- np_tx_fifo_size = min_t(u32, hsotg->hw_params.dev_nperio_tx_fifo_size,
|
||||
- hsotg->params.g_np_tx_fifo_size);
|
||||
+ u32 np_tx_fifo_size = hsotg->params.g_np_tx_fifo_size;
|
||||
|
||||
/* Get Endpoint Info Control block size in DWORDs. */
|
||||
tx_addr_max = hsotg->hw_params.total_fifo_size;
|
||||
diff --git a/drivers/usb/dwc2/params.c b/drivers/usb/dwc2/params.c
|
||||
index 5d29c2157fbe..b25f7399b41c 100644
|
||||
--- a/drivers/usb/dwc2/params.c
|
||||
+++ b/drivers/usb/dwc2/params.c
|
||||
@@ -767,6 +767,35 @@ static void dwc2_check_param_tx_fifo_sizes(struct dwc2_hsotg *hsotg)
|
||||
}
|
||||
}
|
||||
|
||||
+static void dwc2_check_param_fifo_total(struct dwc2_hsotg *hsotg)
|
||||
+{
|
||||
+ struct dwc2_core_params *p = &hsotg->params;
|
||||
+ u32 total_tx = 0;
|
||||
+ u32 max_np;
|
||||
+ int fifo_count;
|
||||
+ int fifo;
|
||||
+
|
||||
+ fifo_count = dwc2_hsotg_tx_fifo_count(hsotg);
|
||||
+ for (fifo = 1; fifo <= fifo_count; fifo++)
|
||||
+ total_tx += p->g_tx_fifo_size[fifo];
|
||||
+
|
||||
+ if (p->g_rx_fifo_size + p->g_np_tx_fifo_size + total_tx <=
|
||||
+ hsotg->hw_params.total_fifo_size)
|
||||
+ return;
|
||||
+
|
||||
+ max_np = 16;
|
||||
+ if (hsotg->hw_params.total_fifo_size > p->g_rx_fifo_size + total_tx)
|
||||
+ max_np = hsotg->hw_params.total_fifo_size -
|
||||
+ p->g_rx_fifo_size - total_tx;
|
||||
+ if (max_np < 16)
|
||||
+ max_np = 16;
|
||||
+
|
||||
+ dev_warn(hsotg->dev,
|
||||
+ "%s: FIFO sizes exceed total, clamping g_np_tx_fifo_size to %u\n",
|
||||
+ __func__, max_np);
|
||||
+ p->g_np_tx_fifo_size = max_np;
|
||||
+}
|
||||
+
|
||||
static void dwc2_check_param_eusb2_disc(struct dwc2_hsotg *hsotg)
|
||||
{
|
||||
u32 gsnpsid;
|
||||
@@ -879,9 +879,10 @@ static void dwc2_check_params(struct dwc2_hsotg *hsotg)
|
||||
CHECK_RANGE(g_rx_fifo_size,
|
||||
16, hw->rx_fifo_size,
|
||||
hw->rx_fifo_size);
|
||||
CHECK_RANGE(g_np_tx_fifo_size,
|
||||
- 16, hw->dev_nperio_tx_fifo_size,
|
||||
+ 16, hw->total_fifo_size,
|
||||
hw->dev_nperio_tx_fifo_size);
|
||||
dwc2_check_param_tx_fifo_sizes(hsotg);
|
||||
+ dwc2_check_param_fifo_total(hsotg);
|
||||
}
|
||||
}
|
||||
173
scripts/kernel/uvc-bulk.patch
Normal file
173
scripts/kernel/uvc-bulk.patch
Normal file
@ -0,0 +1,173 @@
|
||||
diff --git a/drivers/usb/gadget/function/f_uvc.c b/drivers/usb/gadget/function/f_uvc.c
|
||||
index aa6ab666741a..7fb30b09e980 100644
|
||||
--- a/drivers/usb/gadget/function/f_uvc.c
|
||||
+++ b/drivers/usb/gadget/function/f_uvc.c
|
||||
@@ -652,22 +652,32 @@ uvc_function_bind(struct usb_configuration *c, struct usb_function *f)
|
||||
unsigned int max_packet_size;
|
||||
struct usb_ep *ep;
|
||||
struct f_uvc_opts *opts;
|
||||
+ bool bulk;
|
||||
int ret = -EINVAL;
|
||||
|
||||
uvcg_info(f, "%s()\n", __func__);
|
||||
|
||||
opts = fi_to_f_uvc_opts(f->fi);
|
||||
+ bulk = opts->streaming_bulk;
|
||||
/* Sanity check the streaming endpoint module parameters. */
|
||||
opts->streaming_interval = clamp(opts->streaming_interval, 1U, 16U);
|
||||
- opts->streaming_maxpacket = clamp(opts->streaming_maxpacket, 1U, 3072U);
|
||||
opts->streaming_maxburst = min(opts->streaming_maxburst, 15U);
|
||||
|
||||
- /* For SS, wMaxPacketSize has to be 1024 if bMaxBurst is not 0 */
|
||||
- if (opts->streaming_maxburst &&
|
||||
- (opts->streaming_maxpacket % 1024) != 0) {
|
||||
- opts->streaming_maxpacket = roundup(opts->streaming_maxpacket, 1024);
|
||||
- uvcg_info(f, "overriding streaming_maxpacket to %d\n",
|
||||
- opts->streaming_maxpacket);
|
||||
+ if (bulk) {
|
||||
+ opts->streaming_maxpacket =
|
||||
+ clamp(opts->streaming_maxpacket, 1U, 1024U);
|
||||
+ } else {
|
||||
+ opts->streaming_maxpacket =
|
||||
+ clamp(opts->streaming_maxpacket, 1U, 3072U);
|
||||
+ /* For SS, wMaxPacketSize has to be 1024 if bMaxBurst is not 0 */
|
||||
+ if (opts->streaming_maxburst &&
|
||||
+ (opts->streaming_maxpacket % 1024) != 0) {
|
||||
+ opts->streaming_maxpacket =
|
||||
+ roundup(opts->streaming_maxpacket, 1024);
|
||||
+ uvcg_info(f,
|
||||
+ "overriding streaming_maxpacket to %d\n",
|
||||
+ opts->streaming_maxpacket);
|
||||
+ }
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -677,37 +687,70 @@ uvc_function_bind(struct usb_configuration *c, struct usb_function *f)
|
||||
* NOTE: We assume that the user knows what they are doing and won't
|
||||
* give parameters that their UDC doesn't support.
|
||||
*/
|
||||
- if (opts->streaming_maxpacket <= 1024) {
|
||||
+ if (bulk) {
|
||||
max_packet_mult = 1;
|
||||
max_packet_size = opts->streaming_maxpacket;
|
||||
- } else if (opts->streaming_maxpacket <= 2048) {
|
||||
- max_packet_mult = 2;
|
||||
- max_packet_size = opts->streaming_maxpacket / 2;
|
||||
- } else {
|
||||
- max_packet_mult = 3;
|
||||
- max_packet_size = opts->streaming_maxpacket / 3;
|
||||
- }
|
||||
|
||||
- uvc_fs_streaming_ep.wMaxPacketSize =
|
||||
- cpu_to_le16(min(opts->streaming_maxpacket, 1023U));
|
||||
- uvc_fs_streaming_ep.bInterval = opts->streaming_interval;
|
||||
+ uvc_fs_streaming_ep.bmAttributes = USB_ENDPOINT_XFER_BULK;
|
||||
+ uvc_hs_streaming_ep.bmAttributes = USB_ENDPOINT_XFER_BULK;
|
||||
+ uvc_ss_streaming_ep.bmAttributes = USB_ENDPOINT_XFER_BULK;
|
||||
|
||||
- uvc_hs_streaming_ep.wMaxPacketSize =
|
||||
- cpu_to_le16(max_packet_size | ((max_packet_mult - 1) << 11));
|
||||
+ uvc_fs_streaming_ep.wMaxPacketSize =
|
||||
+ cpu_to_le16(min(opts->streaming_maxpacket, 64U));
|
||||
+ uvc_fs_streaming_ep.bInterval = 0;
|
||||
|
||||
- /* A high-bandwidth endpoint must specify a bInterval value of 1 */
|
||||
- if (max_packet_mult > 1)
|
||||
- uvc_hs_streaming_ep.bInterval = 1;
|
||||
- else
|
||||
- uvc_hs_streaming_ep.bInterval = opts->streaming_interval;
|
||||
-
|
||||
- uvc_ss_streaming_ep.wMaxPacketSize = cpu_to_le16(max_packet_size);
|
||||
- uvc_ss_streaming_ep.bInterval = opts->streaming_interval;
|
||||
- uvc_ss_streaming_comp.bmAttributes = max_packet_mult - 1;
|
||||
- uvc_ss_streaming_comp.bMaxBurst = opts->streaming_maxburst;
|
||||
- uvc_ss_streaming_comp.wBytesPerInterval =
|
||||
- cpu_to_le16(max_packet_size * max_packet_mult *
|
||||
- (opts->streaming_maxburst + 1));
|
||||
+ uvc_hs_streaming_ep.wMaxPacketSize =
|
||||
+ cpu_to_le16(min(opts->streaming_maxpacket, 512U));
|
||||
+ uvc_hs_streaming_ep.bInterval = 0;
|
||||
+
|
||||
+ uvc_ss_streaming_ep.wMaxPacketSize =
|
||||
+ cpu_to_le16(min(opts->streaming_maxpacket, 1024U));
|
||||
+ uvc_ss_streaming_ep.bInterval = 0;
|
||||
+ uvc_ss_streaming_comp.bmAttributes = 0;
|
||||
+ uvc_ss_streaming_comp.bMaxBurst = opts->streaming_maxburst;
|
||||
+ uvc_ss_streaming_comp.wBytesPerInterval = cpu_to_le16(0);
|
||||
+ } else {
|
||||
+ if (opts->streaming_maxpacket <= 1024) {
|
||||
+ max_packet_mult = 1;
|
||||
+ max_packet_size = opts->streaming_maxpacket;
|
||||
+ } else if (opts->streaming_maxpacket <= 2048) {
|
||||
+ max_packet_mult = 2;
|
||||
+ max_packet_size = opts->streaming_maxpacket / 2;
|
||||
+ } else {
|
||||
+ max_packet_mult = 3;
|
||||
+ max_packet_size = opts->streaming_maxpacket / 3;
|
||||
+ }
|
||||
+
|
||||
+ uvc_fs_streaming_ep.bmAttributes =
|
||||
+ USB_ENDPOINT_SYNC_ASYNC | USB_ENDPOINT_XFER_ISOC;
|
||||
+ uvc_hs_streaming_ep.bmAttributes =
|
||||
+ USB_ENDPOINT_SYNC_ASYNC | USB_ENDPOINT_XFER_ISOC;
|
||||
+ uvc_ss_streaming_ep.bmAttributes =
|
||||
+ USB_ENDPOINT_SYNC_ASYNC | USB_ENDPOINT_XFER_ISOC;
|
||||
+
|
||||
+ uvc_fs_streaming_ep.wMaxPacketSize =
|
||||
+ cpu_to_le16(min(opts->streaming_maxpacket, 1023U));
|
||||
+ uvc_fs_streaming_ep.bInterval = opts->streaming_interval;
|
||||
+
|
||||
+ uvc_hs_streaming_ep.wMaxPacketSize =
|
||||
+ cpu_to_le16(max_packet_size |
|
||||
+ ((max_packet_mult - 1) << 11));
|
||||
+
|
||||
+ /* A high-bandwidth endpoint must specify a bInterval value of 1 */
|
||||
+ if (max_packet_mult > 1)
|
||||
+ uvc_hs_streaming_ep.bInterval = 1;
|
||||
+ else
|
||||
+ uvc_hs_streaming_ep.bInterval = opts->streaming_interval;
|
||||
+
|
||||
+ uvc_ss_streaming_ep.wMaxPacketSize =
|
||||
+ cpu_to_le16(max_packet_size);
|
||||
+ uvc_ss_streaming_ep.bInterval = opts->streaming_interval;
|
||||
+ uvc_ss_streaming_comp.bmAttributes = max_packet_mult - 1;
|
||||
+ uvc_ss_streaming_comp.bMaxBurst = opts->streaming_maxburst;
|
||||
+ uvc_ss_streaming_comp.wBytesPerInterval =
|
||||
+ cpu_to_le16(max_packet_size * max_packet_mult *
|
||||
+ (opts->streaming_maxburst + 1));
|
||||
+ }
|
||||
|
||||
/* Allocate endpoints. */
|
||||
if (opts->enable_interrupt_ep) {
|
||||
diff --git a/drivers/usb/gadget/function/u_uvc.h b/drivers/usb/gadget/function/u_uvc.h
|
||||
index 3ac392cbb779..b57e2d10ddcd 100644
|
||||
--- a/drivers/usb/gadget/function/u_uvc.h
|
||||
+++ b/drivers/usb/gadget/function/u_uvc.h
|
||||
@@ -24,6 +24,7 @@ struct f_uvc_opts {
|
||||
unsigned int streaming_interval;
|
||||
unsigned int streaming_maxpacket;
|
||||
unsigned int streaming_maxburst;
|
||||
+ unsigned int streaming_bulk;
|
||||
|
||||
unsigned int control_interface;
|
||||
unsigned int streaming_interface;
|
||||
diff --git a/drivers/usb/gadget/function/uvc_configfs.c b/drivers/usb/gadget/function/uvc_configfs.c
|
||||
index a4a2d3dcb0d6..80817ff1b6fe 100644
|
||||
--- a/drivers/usb/gadget/function/uvc_configfs.c
|
||||
+++ b/drivers/usb/gadget/function/uvc_configfs.c
|
||||
@@ -3751,6 +3751,7 @@ UVC_ATTR(f_uvc_opts_, cname, cname)
|
||||
UVCG_OPTS_ATTR(streaming_interval, streaming_interval, 16);
|
||||
UVCG_OPTS_ATTR(streaming_maxpacket, streaming_maxpacket, 3072);
|
||||
UVCG_OPTS_ATTR(streaming_maxburst, streaming_maxburst, 15);
|
||||
+UVCG_OPTS_ATTR(streaming_bulk, streaming_bulk, 1);
|
||||
|
||||
#undef UVCG_OPTS_ATTR
|
||||
|
||||
@@ -3800,6 +3801,7 @@ static struct configfs_attribute *uvc_attrs[] = {
|
||||
&f_uvc_opts_attr_streaming_interval,
|
||||
&f_uvc_opts_attr_streaming_maxpacket,
|
||||
&f_uvc_opts_attr_streaming_maxburst,
|
||||
+ &f_uvc_opts_attr_streaming_bulk,
|
||||
&f_uvc_opts_string_attr_function_name,
|
||||
NULL,
|
||||
};
|
||||
185
scripts/kernel/uvc-debug.patch
Normal file
185
scripts/kernel/uvc-debug.patch
Normal file
@ -0,0 +1,185 @@
|
||||
--- a/drivers/usb/gadget/function/uvc_video.c
|
||||
+++ b/drivers/usb/gadget/function/uvc_video.c
|
||||
@@ -503,9 +503,22 @@
|
||||
unsigned int max_req_size, req_size, header_size;
|
||||
unsigned int nreq;
|
||||
|
||||
+ pr_info_once("uvc: Video prep enter: video=%p ep=%p max_req=%u max_payload=%u imagesize=%u interval=%u\n",
|
||||
+ video, video->ep, video->max_req_size, video->max_payload_size,
|
||||
+ video->imagesize, video->interval);
|
||||
+
|
||||
max_req_size = video->max_req_size;
|
||||
|
||||
+ uvcg_info(&uvc->func,
|
||||
+ "Video prep ep: ep=%p desc=%p maxpacket=%u maxburst=%u mult=%u\n",
|
||||
+ video->ep, video->ep->desc, video->ep->maxpacket,
|
||||
+ video->ep->maxburst, video->ep->mult);
|
||||
+
|
||||
if (!usb_endpoint_xfer_isoc(video->ep->desc)) {
|
||||
+ uvcg_info(&uvc->func,
|
||||
+ "Video prep non-isoc: attrs=0x%x max_req=%u imagesize=%u interval=%u\n",
|
||||
+ video->ep->desc->bmAttributes, max_req_size,
|
||||
+ video->imagesize, video->interval);
|
||||
video->req_size = max_req_size;
|
||||
video->reqs_per_frame = video->uvc_num_requests =
|
||||
DIV_ROUND_UP(video->imagesize, max_req_size);
|
||||
@@ -534,6 +547,16 @@
|
||||
}
|
||||
video->req_size = req_size;
|
||||
|
||||
+ if (!req_size || !video->max_payload_size || !video->imagesize || !nreq) {
|
||||
+ uvcg_info(&uvc->func,
|
||||
+ "Video prep requests: imagesize=%u interval=%u interval_dur=%u nreq=%u header=%u req_size=%u max_req=%u max_payload=%u ep_maxpacket=%u maxburst=%u mult=%u bInterval=%u speed=%u\n",
|
||||
+ video->imagesize, video->interval, interval_duration, nreq,
|
||||
+ header_size, req_size, max_req_size,
|
||||
+ video->max_payload_size, video->ep->maxpacket,
|
||||
+ video->ep->maxburst, video->ep->mult,
|
||||
+ video->ep->desc->bInterval, cdev->gadget->speed);
|
||||
+ }
|
||||
+
|
||||
/* We need to compensate the amount of requests to be
|
||||
* allocated with the maximum amount of zero length requests.
|
||||
* Since it is possible that hw_submit will initially
|
||||
@@ -558,6 +581,11 @@
|
||||
*/
|
||||
uvc_video_prep_requests(video);
|
||||
|
||||
+ uvcg_info(&video->uvc->func,
|
||||
+ "Video alloc: req_size=%u max_req=%u max_payload=%u uvc_num_requests=%u reqs_per_frame=%u\n",
|
||||
+ video->req_size, video->max_req_size, video->max_payload_size,
|
||||
+ video->uvc_num_requests, video->reqs_per_frame);
|
||||
+
|
||||
for (i = 0; i < video->uvc_num_requests; i++) {
|
||||
ureq = kzalloc(sizeof(struct uvc_request), GFP_KERNEL);
|
||||
if (ureq == NULL)
|
||||
@@ -764,20 +792,78 @@
|
||||
{
|
||||
int ret;
|
||||
|
||||
+ if (!video) {
|
||||
+ pr_err("uvcg_video_enable: missing video\n");
|
||||
+ return -EINVAL;
|
||||
+ }
|
||||
+
|
||||
+ if (!video->uvc || !video->uvc->func.config ||
|
||||
+ !video->uvc->func.config->cdev) {
|
||||
+ pr_err("uvcg_video_enable: missing uvc/func/config video=%p uvc=%p\n",
|
||||
+ video, video->uvc);
|
||||
+ return -ENODEV;
|
||||
+ }
|
||||
+
|
||||
if (video->ep == NULL) {
|
||||
uvcg_info(&video->uvc->func,
|
||||
"Video enable failed, device is uninitialized.\n");
|
||||
return -ENODEV;
|
||||
}
|
||||
|
||||
+ if (!video->ep->desc) {
|
||||
+ uvcg_err(&video->uvc->func,
|
||||
+ "Video enable failed, missing ep desc ep=%p maxpacket=%u maxburst=%u mult=%u\n",
|
||||
+ video->ep, video->ep->maxpacket,
|
||||
+ video->ep->maxburst, video->ep->mult);
|
||||
+ return -EINVAL;
|
||||
+ }
|
||||
+
|
||||
+ if (!video->kworker || !video->async_wq) {
|
||||
+ uvcg_err(&video->uvc->func,
|
||||
+ "Video enable failed, missing worker(s) kworker=%p async_wq=%p\n",
|
||||
+ video->kworker, video->async_wq);
|
||||
+ return -EINVAL;
|
||||
+ }
|
||||
+
|
||||
+ if (!video->queue.queue.dev) {
|
||||
+ uvcg_err(&video->uvc->func,
|
||||
+ "Video enable failed, missing queue device\n");
|
||||
+ return -EINVAL;
|
||||
+ }
|
||||
+
|
||||
/*
|
||||
* Safe to access request related fields without req_lock because
|
||||
* this is the only thread currently active, and no other
|
||||
* request handling thread will become active until this function
|
||||
* returns.
|
||||
*/
|
||||
+ uvcg_info(&video->uvc->func,
|
||||
+ "Video enable start: video=%p ep=%p kworker=%p async_wq=%p req_size=%u max_payload=%u requests=%u reqs_per_frame=%u use_sg=%u flags=0x%x\n",
|
||||
+ video, video->ep, video->kworker, video->async_wq,
|
||||
+ video->req_size, video->max_payload_size,
|
||||
+ video->uvc_num_requests, video->reqs_per_frame,
|
||||
+ video->queue.use_sg, video->queue.flags);
|
||||
+
|
||||
video->is_enabled = true;
|
||||
|
||||
+ if (!video->queue.queue.lock || !video->queue.queue.ops ||
|
||||
+ !video->queue.queue.mem_ops) {
|
||||
+ uvcg_err(&video->uvc->func,
|
||||
+ "Video queue not ready: dev=%p lock=%p ops=%p mem_ops=%p buf_ops=%p drv_priv=%p type=%u io_modes=0x%x\n",
|
||||
+ video->queue.queue.dev, video->queue.queue.lock,
|
||||
+ video->queue.queue.ops, video->queue.queue.mem_ops,
|
||||
+ video->queue.queue.buf_ops, video->queue.queue.drv_priv,
|
||||
+ video->queue.queue.type, video->queue.queue.io_modes);
|
||||
+ return -EINVAL;
|
||||
+ }
|
||||
+
|
||||
+ uvcg_info(&video->uvc->func,
|
||||
+ "Video queue enable: dev=%p lock=%p ops=%p mem_ops=%p buf_ops=%p drv_priv=%p type=%u io_modes=0x%x\n",
|
||||
+ video->queue.queue.dev, video->queue.queue.lock,
|
||||
+ video->queue.queue.ops, video->queue.queue.mem_ops,
|
||||
+ video->queue.queue.buf_ops, video->queue.queue.drv_priv,
|
||||
+ video->queue.queue.type, video->queue.queue.io_modes);
|
||||
+
|
||||
if ((ret = uvcg_queue_enable(&video->queue, 1)) < 0)
|
||||
return ret;
|
||||
|
||||
--- a/drivers/usb/gadget/function/f_uvc.c
|
||||
+++ b/drivers/usb/gadget/function/f_uvc.c
|
||||
@@ -298,6 +298,14 @@
|
||||
int ret;
|
||||
|
||||
uvcg_info(f, "%s(%u, %u)\n", __func__, interface, alt);
|
||||
+ uvcg_info(f,
|
||||
+ "UVC set_alt: intf=%u alt=%u state=%u speed=%u ep=%p desc=%p maxpacket=%u maxburst=%u mult=%u\n",
|
||||
+ interface, alt, uvc->state, cdev->gadget->speed,
|
||||
+ uvc->video.ep,
|
||||
+ uvc->video.ep ? uvc->video.ep->desc : NULL,
|
||||
+ uvc->video.ep ? uvc->video.ep->maxpacket : 0,
|
||||
+ uvc->video.ep ? uvc->video.ep->maxburst : 0,
|
||||
+ uvc->video.ep ? uvc->video.ep->mult : 0);
|
||||
|
||||
if (interface == uvc->control_intf) {
|
||||
if (alt)
|
||||
@@ -362,10 +370,34 @@
|
||||
return ret;
|
||||
usb_ep_enable(uvc->video.ep);
|
||||
|
||||
+ if (uvc->video.ep->desc)
|
||||
+ uvcg_info(f,
|
||||
+ "UVC ep config: speed=%u addr=0x%02x attrs=0x%02x maxpacket=%u maxburst=%u mult=%u wMaxPacket=0x%04x interval=%u\n",
|
||||
+ f->config->cdev->gadget->speed,
|
||||
+ uvc->video.ep->address,
|
||||
+ uvc->video.ep->desc->bmAttributes,
|
||||
+ uvc->video.ep->maxpacket, uvc->video.ep->maxburst,
|
||||
+ uvc->video.ep->mult,
|
||||
+ le16_to_cpu(uvc->video.ep->desc->wMaxPacketSize),
|
||||
+ uvc->video.ep->desc->bInterval);
|
||||
+ else
|
||||
+ uvcg_err(f,
|
||||
+ "UVC ep config: missing desc speed=%u maxpacket=%u maxburst=%u mult=%u\n",
|
||||
+ f->config->cdev->gadget->speed,
|
||||
+ uvc->video.ep->maxpacket, uvc->video.ep->maxburst,
|
||||
+ uvc->video.ep->mult);
|
||||
+
|
||||
uvc->video.max_req_size = uvc->video.ep->maxpacket
|
||||
* max_t(unsigned int, uvc->video.ep->maxburst, 1)
|
||||
* (uvc->video.ep->mult);
|
||||
|
||||
+ uvcg_info(f,
|
||||
+ "UVC max_req_size=%u (maxpacket=%u maxburst=%u mult=%u)\n",
|
||||
+ uvc->video.max_req_size,
|
||||
+ uvc->video.ep->maxpacket,
|
||||
+ uvc->video.ep->maxburst,
|
||||
+ uvc->video.ep->mult);
|
||||
+
|
||||
memset(&v4l2_event, 0, sizeof(v4l2_event));
|
||||
v4l2_event.type = UVC_EVENT_STREAMON;
|
||||
v4l2_event_queue(&uvc->vdev, &v4l2_event);
|
||||
107
scripts/manual/eval_lesavka.sh
Executable file
107
scripts/manual/eval_lesavka.sh
Executable file
@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/manual/eval_lesavka.sh - iterative health check for lesavka client/server/gadget
|
||||
# - Locally: probes TCP + gRPC handshake on LESAVKA_SERVER_ADDR
|
||||
# - Optional: if TETHYS_HOST is set, ssh to run lsusb + dmesg tail (enumeration check)
|
||||
# - Optional: if THEIA_HOST is set, ssh to show core/server status + hidg/uvc presence
|
||||
#
|
||||
# Env:
|
||||
# LESAVKA_SERVER_ADDR (default http://38.28.125.112:50051)
|
||||
# ITER=0 (loop forever) or number of iterations
|
||||
# SLEEP=10 (seconds between iterations)
|
||||
# TETHYS_HOST=host (ssh target for target machine; requires key auth)
|
||||
# THEIA_HOST=host (ssh target for server/gadget Pi)
|
||||
# SSH_OPTS="-o ConnectTimeout=5" (optional extra ssh flags)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SERVER=${LESAVKA_SERVER_ADDR:-http://38.28.125.112:50051}
|
||||
# default to a few iterations instead of infinite to avoid unintentional long runs
|
||||
ITER=${ITER:-5}
|
||||
SLEEP=${SLEEP:-10}
|
||||
SSH_OPTS=${SSH_OPTS:-"-o ConnectTimeout=5 -o BatchMode=yes"}
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
PROTO_DIR="${PROTO_DIR:-${SCRIPT_DIR}/../../common/proto}"
|
||||
|
||||
hostport=${SERVER#http://}
|
||||
hostport=${hostport#https://}
|
||||
host=${hostport%%:*}
|
||||
port=${hostport##*:}
|
||||
|
||||
has_nc() { command -v nc >/dev/null 2>&1; }
|
||||
has_grpc() { command -v grpcurl >/dev/null 2>&1; }
|
||||
|
||||
probe_server() {
|
||||
echo "==> [local] $(date -Is) probing $SERVER"
|
||||
if has_nc; then
|
||||
if nc -zw3 "$host" "$port"; then
|
||||
echo " tcp: OK (port reachable)"
|
||||
else
|
||||
echo " tcp: FAIL (port unreachable)"
|
||||
fi
|
||||
else
|
||||
echo " tcp: skipped (nc not present)"
|
||||
fi
|
||||
|
||||
if has_grpc; then
|
||||
if [[ -f "${PROTO_DIR}/lesavka.proto" ]]; then
|
||||
if out=$(grpcurl -plaintext -max-time 5 \
|
||||
-import-path "${PROTO_DIR}" -proto lesavka.proto \
|
||||
"$host:$port" lesavka.Handshake/GetCapabilities 2>&1); then
|
||||
echo " gRPC Handshake (proto): OK → $out"
|
||||
else
|
||||
echo " gRPC Handshake (proto): FAIL → $out"
|
||||
fi
|
||||
else
|
||||
if out=$(grpcurl -plaintext -max-time 5 "$host:$port" list 2>&1); then
|
||||
echo " gRPC list (reflection): $out"
|
||||
else
|
||||
echo " gRPC list (reflection) FAIL → $out"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " gRPC: skipped (grpcurl not present)"
|
||||
fi
|
||||
}
|
||||
|
||||
probe_tethys() {
|
||||
[[ -z "${TETHYS_HOST:-}" ]] && return
|
||||
echo "==> [tethys] $(date -Is) checking lsusb + dmesg tail on $TETHYS_HOST"
|
||||
ssh $SSH_OPTS "$TETHYS_HOST" '
|
||||
lsusb;
|
||||
echo "--- /dev/hidraw* ---";
|
||||
ls /dev/hidraw* 2>/dev/null || true;
|
||||
echo "--- dmesg (USB tail) ---";
|
||||
dmesg | tail -n 20
|
||||
' || echo " ssh to $TETHYS_HOST failed"
|
||||
}
|
||||
|
||||
probe_theia() {
|
||||
[[ -z "${THEIA_HOST:-}" ]] && return
|
||||
echo "==> [theia] $(date -Is) checking services/device nodes on $THEIA_HOST"
|
||||
ssh $SSH_OPTS "$THEIA_HOST" '
|
||||
systemctl --no-pager --quiet is-active lesavka-core && echo "lesavka-core: active" || echo "lesavka-core: INACTIVE";
|
||||
systemctl --no-pager --quiet is-active lesavka-server && echo "lesavka-server: active" || echo "lesavka-server: INACTIVE";
|
||||
echo "--- hidg nodes ---";
|
||||
ls -l /dev/hidg0 /dev/hidg1 2>/dev/null || true;
|
||||
echo "--- video nodes ---";
|
||||
ls -l /dev/video* 2>/dev/null | head;
|
||||
echo "--- recent server log ---";
|
||||
journalctl -u lesavka-server -n 20 --no-pager
|
||||
' || echo " ssh to $THEIA_HOST failed"
|
||||
}
|
||||
|
||||
count=0
|
||||
while :; do
|
||||
probe_server
|
||||
probe_theia
|
||||
probe_tethys
|
||||
|
||||
count=$((count + 1))
|
||||
if [[ "$ITER" -gt 0 && "$count" -ge "$ITER" ]]; then
|
||||
break
|
||||
fi
|
||||
echo "==> sleeping ${SLEEP}s (iteration $count complete)"
|
||||
sleep "$SLEEP"
|
||||
done
|
||||
|
||||
echo "Done."
|
||||
56
scripts/manual/kde-start-tethys.sh
Executable file
56
scripts/manual/kde-start-tethys.sh
Executable file
@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/manual/kde-start-tethys.sh
|
||||
#
|
||||
# Start/restart SDDM on tethys and set display geometry over :0.
|
||||
# Intended for remote use after SSH-ing into tethys.
|
||||
#
|
||||
# Env overrides:
|
||||
# MODE=1920x1080 (preferred mode)
|
||||
# RATE=60 (refresh rate)
|
||||
# OUTPUTS="HDMI-1 DP-1" (space-separated outputs to try)
|
||||
# DISPLAY=:0 (X display; default :0)
|
||||
# XAUTHORITY=... (override cookie; otherwise auto-detected from SDDM)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODE=${MODE:-1920x1080}
|
||||
RATE=${RATE:-60}
|
||||
OUTPUTS=${OUTPUTS:-"HDMI-1 DP-1"}
|
||||
DISPLAY=${DISPLAY:-:0}
|
||||
|
||||
log() { printf "[kde-start] %s\n" "$*"; }
|
||||
|
||||
log "restarting sddm.service"
|
||||
sudo systemctl restart sddm
|
||||
sleep 2
|
||||
|
||||
# find SDDM Xauthority if not provided
|
||||
if [[ -z "${XAUTHORITY:-}" ]]; then
|
||||
XAUTHORITY=$(ls /var/run/sddm/*/xauth_* 2>/dev/null | head -n1 || true)
|
||||
fi
|
||||
|
||||
if [[ -z "${XAUTHORITY:-}" ]]; then
|
||||
log "warning: no XAUTHORITY found; xrandr may fail"
|
||||
fi
|
||||
|
||||
# wait for X to come up
|
||||
for attempt in {1..15}; do
|
||||
if DISPLAY=$DISPLAY XAUTHORITY=${XAUTHORITY:-} xrandr --query >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log "setting mode ${MODE}@${RATE} on outputs: ${OUTPUTS}"
|
||||
for out in $OUTPUTS; do
|
||||
if DISPLAY=$DISPLAY XAUTHORITY=${XAUTHORITY:-} xrandr --output "$out" --mode "$MODE" --rate "$RATE" --primary >/dev/null 2>&1; then
|
||||
log "set $out to ${MODE}@${RATE}"
|
||||
else
|
||||
log "skip $out (xrandr failed)"
|
||||
fi
|
||||
done
|
||||
|
||||
log "current xrandr:"
|
||||
DISPLAY=$DISPLAY XAUTHORITY=${XAUTHORITY:-} xrandr --query || true
|
||||
|
||||
log "done."
|
||||
@ -1,10 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/manual/usb-reset.sh
|
||||
# scripts/manual/usb-reset.sh - trigger USB reset RPC on the server
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
PROTO_DIR="${SCRIPT_DIR}/../../common/proto"
|
||||
|
||||
grpcurl \
|
||||
-plaintext \
|
||||
-import-path ./../../common/proto \
|
||||
-import-path "${PROTO_DIR}" \
|
||||
-proto lesavka.proto \
|
||||
-d '{}' \
|
||||
64.25.10.31:50051 \
|
||||
38.28.125.112:50051 \
|
||||
lesavka.Relay/ResetUsb
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
// server/src/audio.rs
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use anyhow::{Context, anyhow};
|
||||
use chrono::Local;
|
||||
use futures_util::Stream;
|
||||
use gstreamer as gst;
|
||||
use gstreamer_app as gst_app;
|
||||
use gst::prelude::*;
|
||||
use gst::ElementFactory;
|
||||
use gst::MessageView::*;
|
||||
use gst::prelude::*;
|
||||
use gstreamer as gst;
|
||||
use gstreamer_app as gst_app;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::Status;
|
||||
use tracing::{debug, error, warn};
|
||||
use std::time::{Instant, Duration, SystemTime, UNIX_EPOCH};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lesavka_common::lesavka::AudioPacket;
|
||||
|
||||
@ -21,7 +21,7 @@ use lesavka_common::lesavka::AudioPacket;
|
||||
/// endpoint) **towards** the client.
|
||||
pub struct AudioStream {
|
||||
_pipeline: gst::Pipeline,
|
||||
inner: ReceiverStream<Result<AudioPacket, Status>>,
|
||||
inner: ReceiverStream<Result<AudioPacket, Status>>,
|
||||
}
|
||||
|
||||
impl Stream for AudioStream {
|
||||
@ -58,17 +58,18 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result<AudioStream> {
|
||||
*/
|
||||
let desc = build_pipeline_desc(alsa_dev)?;
|
||||
|
||||
let pipeline: gst::Pipeline = gst::parse::launch(&desc)?
|
||||
.downcast()
|
||||
.expect("pipeline");
|
||||
let pipeline: gst::Pipeline = gst::parse::launch(&desc)?.downcast().expect("pipeline");
|
||||
|
||||
let sink: gst_app::AppSink = pipeline
|
||||
.by_name("asink")
|
||||
.expect("asink")
|
||||
.downcast()
|
||||
.expect("appsink");
|
||||
|
||||
let tap = Arc::new(Mutex::new(ClipTap::new("🎧 - ear", Duration::from_secs(60))));
|
||||
|
||||
let tap = Arc::new(Mutex::new(ClipTap::new(
|
||||
"🎧 - ear",
|
||||
Duration::from_secs(60),
|
||||
)));
|
||||
// sink.connect("underrun", false, |_| {
|
||||
// tracing::warn!("⚠️ USB playback underrun – host muted or not reading");
|
||||
// None
|
||||
@ -80,12 +81,19 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result<AudioStream> {
|
||||
std::thread::spawn(move || {
|
||||
for msg in bus.iter_timed(gst::ClockTime::NONE) {
|
||||
match msg.view() {
|
||||
Error(e) => error!("💥 audio pipeline: {} ({})",
|
||||
e.error(), e.debug().unwrap_or_default()),
|
||||
Warning(w) => warn!("⚠️ audio pipeline: {} ({})",
|
||||
w.error(), w.debug().unwrap_or_default()),
|
||||
StateChanged(s) if s.current() == gst::State::Playing =>
|
||||
debug!("🎶 audio pipeline PLAYING"),
|
||||
Error(e) => error!(
|
||||
"💥 audio pipeline: {} ({})",
|
||||
e.error(),
|
||||
e.debug().unwrap_or_default()
|
||||
),
|
||||
Warning(w) => warn!(
|
||||
"⚠️ audio pipeline: {} ({})",
|
||||
w.error(),
|
||||
w.debug().unwrap_or_default()
|
||||
),
|
||||
StateChanged(s) if s.current() == gst::State::Playing => {
|
||||
debug!("🎶 audio pipeline PLAYING")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@ -94,52 +102,53 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result<AudioStream> {
|
||||
/*──────────── callbacks ────────────*/
|
||||
sink.set_callbacks(
|
||||
gst_app::AppSinkCallbacks::builder()
|
||||
.new_sample({
|
||||
let tap = tap.clone();
|
||||
move |s| {
|
||||
let sample = s.pull_sample().map_err(|_| gst::FlowError::Eos)?;
|
||||
let buffer = sample.buffer().ok_or(gst::FlowError::Error)?;
|
||||
let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?;
|
||||
.new_sample({
|
||||
let tap = tap.clone();
|
||||
move |s| {
|
||||
let sample = s.pull_sample().map_err(|_| gst::FlowError::Eos)?;
|
||||
let buffer = sample.buffer().ok_or(gst::FlowError::Error)?;
|
||||
let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?;
|
||||
|
||||
// -------- clip‑tap (minute dumps) ------------
|
||||
tap.lock().unwrap().feed(map.as_slice());
|
||||
// -------- clip‑tap (minute dumps) ------------
|
||||
tap.lock().unwrap().feed(map.as_slice());
|
||||
|
||||
static CNT: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if n < 10 || n % 300 == 0 {
|
||||
debug!("🎧 ear #{n}: {} bytes", map.len());
|
||||
}
|
||||
|
||||
let pts_us = buffer
|
||||
.pts()
|
||||
.unwrap_or(gst::ClockTime::ZERO)
|
||||
.nseconds() / 1_000;
|
||||
|
||||
// push non‑blocking; drop oldest on overflow
|
||||
if tx.try_send(Ok(AudioPacket {
|
||||
id,
|
||||
pts: pts_us,
|
||||
data: map.as_slice().to_vec(),
|
||||
})).is_err() {
|
||||
static DROPS: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
let d = DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if d % 300 == 0 {
|
||||
warn!("🎧💔 dropped {d} audio AUs (client too slow)");
|
||||
static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if n < 10 || n % 300 == 0 {
|
||||
debug!("🎧 ear #{n}: {} bytes", map.len());
|
||||
}
|
||||
|
||||
let pts_us = buffer.pts().unwrap_or(gst::ClockTime::ZERO).nseconds() / 1_000;
|
||||
|
||||
// push non‑blocking; drop oldest on overflow
|
||||
if tx
|
||||
.try_send(Ok(AudioPacket {
|
||||
id,
|
||||
pts: pts_us,
|
||||
data: map.as_slice().to_vec(),
|
||||
}))
|
||||
.is_err()
|
||||
{
|
||||
static DROPS: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
let d = DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if d % 300 == 0 {
|
||||
warn!("🎧💔 dropped {d} audio AUs (client too slow)");
|
||||
}
|
||||
}
|
||||
Ok(gst::FlowSuccess::Ok)
|
||||
}
|
||||
Ok(gst::FlowSuccess::Ok)
|
||||
}
|
||||
}).build(),
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
pipeline.set_state(gst::State::Playing)
|
||||
pipeline
|
||||
.set_state(gst::State::Playing)
|
||||
.context("starting audio pipeline")?;
|
||||
|
||||
Ok(AudioStream {
|
||||
_pipeline: pipeline,
|
||||
inner: ReceiverStream::new(rx),
|
||||
inner: ReceiverStream::new(rx),
|
||||
})
|
||||
}
|
||||
|
||||
@ -152,9 +161,7 @@ fn build_pipeline_desc(dev: &str) -> anyhow::Result<String> {
|
||||
.into_iter()
|
||||
.find(|&e| {
|
||||
reg.find_plugin(e).is_some()
|
||||
|| reg
|
||||
.find_feature(e, ElementFactory::static_type())
|
||||
.is_some()
|
||||
|| reg.find_feature(e, ElementFactory::static_type()).is_some()
|
||||
})
|
||||
.ok_or_else(|| anyhow!("no AAC encoder plugin available"))?;
|
||||
|
||||
@ -176,8 +183,8 @@ fn build_pipeline_desc(dev: &str) -> anyhow::Result<String> {
|
||||
|
||||
// ────────────────────── minute‑clip helper ───────────────────────────────
|
||||
pub struct ClipTap {
|
||||
buf: Vec<u8>,
|
||||
tag: &'static str,
|
||||
buf: Vec<u8>,
|
||||
tag: &'static str,
|
||||
next_dump: Instant,
|
||||
period: Duration,
|
||||
}
|
||||
@ -222,8 +229,8 @@ impl Drop for ClipTap {
|
||||
// ────────────────────── microphone sink ────────────────────────────────
|
||||
pub struct Voice {
|
||||
appsrc: gst_app::AppSrc,
|
||||
_pipe: gst::Pipeline, // keep pipeline alive
|
||||
tap: ClipTap,
|
||||
_pipe: gst::Pipeline, // keep pipeline alive
|
||||
tap: ClipTap,
|
||||
}
|
||||
|
||||
impl Voice {
|
||||
@ -236,7 +243,7 @@ impl Voice {
|
||||
let pipeline = gst::Pipeline::new();
|
||||
|
||||
// elements
|
||||
let appsrc = gst::ElementFactory::make("appsrc")
|
||||
let appsrc = gst::ElementFactory::make("appsrc")
|
||||
.build()
|
||||
.context("make appsrc")?
|
||||
.downcast::<gst_app::AppSrc>()
|
||||
@ -246,10 +253,10 @@ impl Voice {
|
||||
appsrc.set_format(gst::Format::Time);
|
||||
appsrc.set_is_live(true);
|
||||
|
||||
let decodebin = gst::ElementFactory::make("decodebin")
|
||||
let decodebin = gst::ElementFactory::make("decodebin")
|
||||
.build()
|
||||
.context("make decodebin")?;
|
||||
let alsa_sink = gst::ElementFactory::make("alsasink")
|
||||
let alsa_sink = gst::ElementFactory::make("alsasink")
|
||||
.build()
|
||||
.context("make alsasink")?;
|
||||
|
||||
@ -259,7 +266,7 @@ impl Voice {
|
||||
appsrc.link(&decodebin)?;
|
||||
|
||||
/*------------ decodebin autolink ----------------*/
|
||||
let sink_clone = alsa_sink.clone(); // keep original for later
|
||||
let sink_clone = alsa_sink.clone(); // keep original for later
|
||||
decodebin.connect_pad_added(move |_db, pad| {
|
||||
let sink_pad = sink_clone.static_pad("sink").unwrap();
|
||||
if !sink_pad.is_linked() {
|
||||
|
||||
1031
server/src/bin/lesavka-uvc.rs
Normal file
1031
server/src/bin/lesavka-uvc.rs
Normal file
File diff suppressed because it is too large
Load Diff
260
server/src/camera.rs
Normal file
260
server/src/camera.rs
Normal file
@ -0,0 +1,260 @@
|
||||
// server/src/camera.rs
|
||||
|
||||
use gstreamer as gst;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CameraOutput {
|
||||
Uvc,
|
||||
Hdmi,
|
||||
}
|
||||
|
||||
impl CameraOutput {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
CameraOutput::Uvc => "uvc",
|
||||
CameraOutput::Hdmi => "hdmi",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CameraCodec {
|
||||
H264,
|
||||
Mjpeg,
|
||||
}
|
||||
|
||||
impl CameraCodec {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
CameraCodec::H264 => "h264",
|
||||
CameraCodec::Mjpeg => "mjpeg",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HdmiConnector {
|
||||
pub name: String,
|
||||
pub id: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CameraConfig {
|
||||
pub output: CameraOutput,
|
||||
pub codec: CameraCodec,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
pub hdmi: Option<HdmiConnector>,
|
||||
}
|
||||
|
||||
static LAST_CONFIG: OnceLock<RwLock<CameraConfig>> = OnceLock::new();
|
||||
|
||||
pub fn update_camera_config() -> CameraConfig {
|
||||
let cfg = select_camera_config();
|
||||
let lock = LAST_CONFIG.get_or_init(|| RwLock::new(cfg.clone()));
|
||||
*lock.write().unwrap() = cfg.clone();
|
||||
cfg
|
||||
}
|
||||
|
||||
pub fn current_camera_config() -> CameraConfig {
|
||||
if let Some(lock) = LAST_CONFIG.get() {
|
||||
return lock.read().unwrap().clone();
|
||||
}
|
||||
update_camera_config()
|
||||
}
|
||||
|
||||
fn select_camera_config() -> CameraConfig {
|
||||
let output_env = std::env::var("LESAVKA_CAM_OUTPUT").ok();
|
||||
let output_override = output_env
|
||||
.as_deref()
|
||||
.and_then(parse_camera_output);
|
||||
|
||||
let require_connected = output_override != Some(CameraOutput::Hdmi);
|
||||
let hdmi = detect_hdmi_connector(require_connected);
|
||||
if output_override == Some(CameraOutput::Hdmi) && hdmi.is_none() {
|
||||
warn!("📷 HDMI output forced but no connector detected");
|
||||
}
|
||||
let output = match output_override {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
if hdmi.is_some() {
|
||||
CameraOutput::Hdmi
|
||||
} else {
|
||||
CameraOutput::Uvc
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let cfg = match output {
|
||||
CameraOutput::Hdmi => select_hdmi_config(hdmi),
|
||||
CameraOutput::Uvc => select_uvc_config(),
|
||||
};
|
||||
|
||||
info!(
|
||||
output = cfg.output.as_str(),
|
||||
codec = cfg.codec.as_str(),
|
||||
width = cfg.width,
|
||||
height = cfg.height,
|
||||
fps = cfg.fps,
|
||||
hdmi = cfg.hdmi.as_ref().map(|h| h.name.as_str()).unwrap_or("none"),
|
||||
"📷 camera output selected"
|
||||
);
|
||||
|
||||
cfg
|
||||
}
|
||||
|
||||
fn parse_camera_output(raw: &str) -> Option<CameraOutput> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"uvc" => Some(CameraOutput::Uvc),
|
||||
"hdmi" => Some(CameraOutput::Hdmi),
|
||||
"auto" | "" => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn select_hdmi_config(hdmi: Option<HdmiConnector>) -> CameraConfig {
|
||||
let hw_decode = has_hw_h264_decode();
|
||||
let (width, height) = if hw_decode { (1920, 1080) } else { (1280, 720) };
|
||||
let fps = 30;
|
||||
if !hw_decode {
|
||||
warn!("📷 HDMI output: hardware H264 decoder not detected; using 720p30");
|
||||
}
|
||||
CameraConfig {
|
||||
output: CameraOutput::Hdmi,
|
||||
codec: CameraCodec::H264,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
hdmi,
|
||||
}
|
||||
}
|
||||
|
||||
fn select_uvc_config() -> CameraConfig {
|
||||
let mut uvc_env = HashMap::new();
|
||||
if let Ok(text) = fs::read_to_string("/etc/lesavka/uvc.env") {
|
||||
uvc_env = parse_env_file(&text);
|
||||
}
|
||||
|
||||
let width = read_u32_from_env("LESAVKA_UVC_WIDTH")
|
||||
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_WIDTH"))
|
||||
.unwrap_or(1280);
|
||||
let height = read_u32_from_env("LESAVKA_UVC_HEIGHT")
|
||||
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_HEIGHT"))
|
||||
.unwrap_or(720);
|
||||
let fps = read_u32_from_env("LESAVKA_UVC_FPS")
|
||||
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_FPS"))
|
||||
.or_else(|| {
|
||||
read_u32_from_env("LESAVKA_UVC_INTERVAL")
|
||||
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_INTERVAL"))
|
||||
.and_then(|interval| {
|
||||
if interval == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(10_000_000 / interval)
|
||||
}
|
||||
})
|
||||
})
|
||||
.unwrap_or(25);
|
||||
|
||||
CameraConfig {
|
||||
output: CameraOutput::Uvc,
|
||||
codec: CameraCodec::Mjpeg,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
hdmi: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_hw_h264_decode() -> bool {
|
||||
if gst::init().is_err() {
|
||||
return false;
|
||||
}
|
||||
for name in ["v4l2h264dec", "v4l2slh264dec", "omxh264dec"] {
|
||||
if gst::ElementFactory::find(name).is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn detect_hdmi_connector(require_connected: bool) -> Option<HdmiConnector> {
|
||||
let preferred = std::env::var("LESAVKA_HDMI_CONNECTOR").ok();
|
||||
let entries = fs::read_dir("/sys/class/drm").ok()?;
|
||||
let mut connectors = Vec::new();
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !name.contains("HDMI-A-") {
|
||||
continue;
|
||||
}
|
||||
let status_path = entry.path().join("status");
|
||||
let status = fs::read_to_string(&status_path)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
let id = fs::read_to_string(entry.path().join("connector_id"))
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u32>().ok());
|
||||
connectors.push((name, status, id));
|
||||
}
|
||||
|
||||
let matches_preferred = |name: &str, preferred: &str| {
|
||||
name == preferred || name.ends_with(preferred)
|
||||
};
|
||||
|
||||
if let Some(pref) = preferred.as_deref() {
|
||||
for (name, status, id) in &connectors {
|
||||
if matches_preferred(name, pref)
|
||||
&& (!require_connected || status == "connected")
|
||||
{
|
||||
return Some(HdmiConnector {
|
||||
name: name.clone(),
|
||||
id: *id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (name, status, id) in connectors {
|
||||
if !require_connected || status == "connected" {
|
||||
return Some(HdmiConnector { name, id });
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_env_file(text: &str) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let mut parts = line.splitn(2, '=');
|
||||
let key = match parts.next() {
|
||||
Some(v) => v.trim(),
|
||||
None => continue,
|
||||
};
|
||||
let val = match parts.next() {
|
||||
Some(v) => v.trim(),
|
||||
None => continue,
|
||||
};
|
||||
out.insert(key.to_string(), val.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn read_u32_from_env(key: &str) -> Option<u32> {
|
||||
std::env::var(key).ok().and_then(|v| v.parse::<u32>().ok())
|
||||
}
|
||||
|
||||
fn read_u32_from_map(map: &HashMap<String, String>, key: &str) -> Option<u32> {
|
||||
map.get(key).and_then(|v| v.parse::<u32>().ok())
|
||||
}
|
||||
@ -1,7 +1,14 @@
|
||||
// server/src/gadget.rs
|
||||
use std::{fs::{self, OpenOptions}, io::Write, path::Path, thread, time::Duration};
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{info, warn, trace};
|
||||
use std::{
|
||||
env,
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
path::Path,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use tracing::{info, trace, warn};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UsbGadget {
|
||||
@ -46,8 +53,10 @@ impl UsbGadget {
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(anyhow::anyhow!("UDC never reached '{wanted}' (last = {:?})",
|
||||
fs::read_to_string(&path).unwrap_or_default()))
|
||||
Err(anyhow::anyhow!(
|
||||
"UDC never reached '{wanted}' (last = {:?})",
|
||||
fs::read_to_string(&path).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn wait_state_any(ctrl: &str, limit_ms: u64) -> anyhow::Result<String> {
|
||||
@ -61,7 +70,9 @@ impl UsbGadget {
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
Err(anyhow::anyhow!("UDC state did not settle within {limit_ms} ms"))
|
||||
Err(anyhow::anyhow!(
|
||||
"UDC state did not settle within {limit_ms} ms"
|
||||
))
|
||||
}
|
||||
|
||||
/// Write `value` (plus “\n”) into a sysfs attribute
|
||||
@ -81,14 +92,18 @@ impl UsbGadget {
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(anyhow::anyhow!("⚠️ UDC {ctrl} did not re-appear within {limit_ms} ms"))
|
||||
Err(anyhow::anyhow!(
|
||||
"⚠️ UDC {ctrl} did not re-appear within {limit_ms} ms"
|
||||
))
|
||||
}
|
||||
|
||||
/// Scan platform devices when /sys/class/udc is empty
|
||||
fn probe_platform_udc() -> Result<Option<String>> {
|
||||
for entry in fs::read_dir("/sys/bus/platform/devices")? {
|
||||
let p = entry?.file_name().into_string().unwrap();
|
||||
if p.ends_with(".usb") { return Ok(Some(p)); }
|
||||
if p.ends_with(".usb") {
|
||||
return Ok(Some(p));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
@ -98,26 +113,54 @@ impl UsbGadget {
|
||||
/// Hard-reset the gadget → identical to a physical cable re-plug
|
||||
pub fn cycle(&self) -> Result<()> {
|
||||
/* 0 - ensure we *know* the controller even after a previous crash */
|
||||
let ctrl = Self::find_controller()
|
||||
.or_else(|_| Self::probe_platform_udc()?
|
||||
.ok_or_else(|| anyhow::anyhow!("no UDC present")))?;
|
||||
let ctrl = Self::find_controller().or_else(|_| {
|
||||
Self::probe_platform_udc()?.ok_or_else(|| anyhow::anyhow!("no UDC present"))
|
||||
})?;
|
||||
let force_cycle = env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok();
|
||||
match Self::state(&ctrl) {
|
||||
Ok(state)
|
||||
if !force_cycle
|
||||
&& matches!(state.as_str(), "configured" | "addressed" | "default" | "suspended") =>
|
||||
{
|
||||
warn!(
|
||||
"🔒 refusing gadget cycle while host attached (state={state}); set LESAVKA_GADGET_FORCE_CYCLE=1 to override"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Ok(state) if !force_cycle && state == "unknown" => {
|
||||
warn!(
|
||||
"🔒 refusing gadget cycle with unknown UDC state; set LESAVKA_GADGET_FORCE_CYCLE=1 to override"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) if !force_cycle => {
|
||||
warn!(
|
||||
"🔒 refusing gadget cycle without UDC state; set LESAVKA_GADGET_FORCE_CYCLE=1 to override"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
/* 1 - detach gadget */
|
||||
info!("🔌 detaching gadget from {ctrl}");
|
||||
// a) drop pull-ups (if the controller offers the switch)
|
||||
let sc = format!("/sys/class/udc/{ctrl}/soft_connect");
|
||||
let _ = Self::write_attr(&sc, "0"); // ignore errors - not all HW has it
|
||||
let _ = Self::write_attr(&sc, "0"); // ignore errors - not all HW has it
|
||||
|
||||
// b) clear the UDC attribute; the kernel may transiently answer EBUSY
|
||||
for attempt in 1..=10 {
|
||||
match Self::write_attr(self.udc_file, "") {
|
||||
Ok(_) => break,
|
||||
Err(err) if {
|
||||
// only swallow EBUSY
|
||||
err.downcast_ref::<std::io::Error>()
|
||||
.and_then(|io| io.raw_os_error())
|
||||
== Some(libc::EBUSY) && attempt < 10
|
||||
} => {
|
||||
Err(err)
|
||||
if {
|
||||
// only swallow EBUSY
|
||||
err.downcast_ref::<std::io::Error>()
|
||||
.and_then(|io| io.raw_os_error())
|
||||
== Some(libc::EBUSY)
|
||||
&& attempt < 10
|
||||
} =>
|
||||
{
|
||||
trace!("⏳ UDC busy (attempt {attempt}/10) - retrying…");
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
@ -146,10 +189,10 @@ impl UsbGadget {
|
||||
Some(libc::EINVAL) | Some(libc::EPERM) | Some(libc::ENOENT) => {
|
||||
warn!("⚠️ soft_connect unsupported ({io}); continuing");
|
||||
}
|
||||
_ => return Err(err), // propagate all other errors
|
||||
_ => return Err(err), // propagate all other errors
|
||||
}
|
||||
} else {
|
||||
return Err(err); // non-IO errors: propagate
|
||||
return Err(err); // non-IO errors: propagate
|
||||
}
|
||||
}
|
||||
Ok(_) => { /* success */ }
|
||||
@ -157,21 +200,20 @@ impl UsbGadget {
|
||||
}
|
||||
|
||||
/* 5 - wait for host (but tolerate sleep) */
|
||||
Self::wait_state(&ctrl, "configured", 6_000)
|
||||
.or_else(|e| {
|
||||
// If the host is physically absent (sleep / KVM paused)
|
||||
// we allow 'not attached' and continue - we can still
|
||||
// accept keyboard/mouse data and the host will enumerate
|
||||
// later without another reset.
|
||||
let last = fs::read_to_string(format!("/sys/class/udc/{ctrl}/state"))
|
||||
.unwrap_or_default();
|
||||
if last.trim() == "not attached" {
|
||||
warn!("⚠️ host did not enumerate within 6 s - continuing (state = {last:?})");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
})?;
|
||||
Self::wait_state(&ctrl, "configured", 6_000).or_else(|e| {
|
||||
// If the host is physically absent (sleep / KVM paused)
|
||||
// we allow 'not attached' and continue - we can still
|
||||
// accept keyboard/mouse data and the host will enumerate
|
||||
// later without another reset.
|
||||
let last =
|
||||
fs::read_to_string(format!("/sys/class/udc/{ctrl}/state")).unwrap_or_default();
|
||||
if last.trim() == "not attached" {
|
||||
warn!("⚠️ host did not enumerate within 6 s - continuing (state = {last:?})");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
info!("✅ USB-gadget cycle complete");
|
||||
Ok(())
|
||||
@ -182,8 +224,10 @@ impl UsbGadget {
|
||||
let cand = ["dwc2", "dwc3"];
|
||||
for drv in cand {
|
||||
let root = format!("/sys/bus/platform/drivers/{drv}");
|
||||
if !Path::new(&root).exists() { continue }
|
||||
|
||||
if !Path::new(&root).exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
/*----------- unbind ------------------------------------------------*/
|
||||
info!("🔧 unbinding UDC driver ({drv})");
|
||||
for attempt in 1..=20 {
|
||||
@ -193,34 +237,32 @@ impl UsbGadget {
|
||||
trace!("unbind in-progress (#{attempt}) - waiting…");
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(err) => return Err(err)
|
||||
.context("UDC unbind failed irrecoverably"),
|
||||
Err(err) => return Err(err).context("UDC unbind failed irrecoverably"),
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(150)); // let the core quiesce
|
||||
thread::sleep(Duration::from_millis(150)); // let the core quiesce
|
||||
|
||||
/*----------- bind --------------------------------------------------*/
|
||||
info!("🔧 binding UDC driver ({drv})");
|
||||
for attempt in 1..=20 {
|
||||
match Self::write_attr(format!("{root}/bind"), ctrl) {
|
||||
Ok(_) => return Ok(()), // success 🎉
|
||||
Ok(_) => return Ok(()), // success 🎉
|
||||
Err(err) if attempt < 20 && Self::is_still_detaching(&err) => {
|
||||
trace!("bind busy (#{attempt}) - retrying…");
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(err) => return Err(err)
|
||||
.context("UDC bind failed irrecoverably"),
|
||||
Err(err) => return Err(err).context("UDC bind failed irrecoverably"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!("no dwc2/dwc3 driver nodes found"))
|
||||
}
|
||||
|
||||
|
||||
fn is_still_detaching(err: &anyhow::Error) -> bool {
|
||||
err.downcast_ref::<std::io::Error>()
|
||||
.and_then(|io| io.raw_os_error())
|
||||
.map_or(false, |code| {
|
||||
matches!(code, libc::EBUSY | libc::ENOENT | libc::ENODEV)
|
||||
})
|
||||
.and_then(|io| io.raw_os_error())
|
||||
.map_or(false, |code| {
|
||||
matches!(code, libc::EBUSY | libc::ENOENT | libc::ENODEV)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,11 +5,9 @@ use lesavka_common::lesavka::{
|
||||
Empty, HandshakeSet,
|
||||
handshake_server::{Handshake, HandshakeServer},
|
||||
};
|
||||
use crate::camera;
|
||||
|
||||
pub struct HandshakeSvc {
|
||||
pub camera: bool,
|
||||
pub microphone: bool,
|
||||
}
|
||||
pub struct HandshakeSvc;
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl Handshake for HandshakeSvc {
|
||||
@ -17,15 +15,26 @@ impl Handshake for HandshakeSvc {
|
||||
&self,
|
||||
_req: Request<Empty>,
|
||||
) -> Result<Response<HandshakeSet>, Status> {
|
||||
let cfg = camera::update_camera_config();
|
||||
let camera_enabled = match cfg.output {
|
||||
camera::CameraOutput::Uvc => std::env::var("LESAVKA_DISABLE_UVC").is_err(),
|
||||
camera::CameraOutput::Hdmi => true,
|
||||
};
|
||||
let microphone = std::env::var("LESAVKA_DISABLE_UAC").is_err();
|
||||
Ok(Response::new(HandshakeSet {
|
||||
camera: self.camera,
|
||||
microphone: self.microphone,
|
||||
camera: camera_enabled,
|
||||
microphone,
|
||||
camera_output: cfg.output.as_str().to_string(),
|
||||
camera_codec: cfg.codec.as_str().to_string(),
|
||||
camera_width: cfg.width,
|
||||
camera_height: cfg.height,
|
||||
camera_fps: cfg.fps,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl HandshakeSvc {
|
||||
pub fn server() -> HandshakeServer<Self> {
|
||||
HandshakeServer::new(Self { camera: true, microphone: true })
|
||||
HandshakeServer::new(Self)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// server/src/lib.rs
|
||||
|
||||
pub mod audio;
|
||||
pub mod video;
|
||||
pub mod camera;
|
||||
pub mod gadget;
|
||||
pub mod handshake;
|
||||
pub mod video;
|
||||
|
||||
@ -1,60 +1,50 @@
|
||||
//! lesavka-server - **auto-cycle disabled**
|
||||
//! lesavka-server - gadget cycle guarded by env
|
||||
// server/src/main.rs
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::{panic, backtrace::Backtrace, pin::Pin, sync::Arc};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use anyhow::Context as _;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use tokio::{
|
||||
fs::{OpenOptions},
|
||||
io::AsyncWriteExt,
|
||||
sync::Mutex,
|
||||
};
|
||||
use gstreamer as gst;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc};
|
||||
use tokio::{fs::OpenOptions, io::AsyncWriteExt, process::Command, sync::Mutex};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::{Request, Response, Status};
|
||||
use tonic::transport::Server;
|
||||
use tonic_reflection::server::{Builder as ReflBuilder};
|
||||
use tracing::{info, warn, error, trace, debug};
|
||||
use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*};
|
||||
use tonic::{Request, Response, Status};
|
||||
use tonic_reflection::server::Builder as ReflBuilder;
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*};
|
||||
|
||||
use lesavka_common::lesavka::{
|
||||
Empty, ResetUsbReply,
|
||||
AudioPacket, Empty, KeyboardReport, MonitorRequest, MouseReport, ResetUsbReply, VideoPacket,
|
||||
relay_server::{Relay, RelayServer},
|
||||
KeyboardReport, MouseReport,
|
||||
MonitorRequest, VideoPacket, AudioPacket
|
||||
};
|
||||
|
||||
use lesavka_server::{gadget::UsbGadget, video, audio, handshake::HandshakeSvc};
|
||||
use lesavka_server::{audio, camera, gadget::UsbGadget, handshake::HandshakeSvc, video};
|
||||
|
||||
/*──────────────── constants ────────────────*/
|
||||
/// **false** = never reset automatically.
|
||||
const AUTO_CYCLE: bool = false;
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
|
||||
|
||||
/*──────────────── logging ───────────────────*/
|
||||
fn init_tracing() -> anyhow::Result<WorkerGuard> {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true).truncate(true).write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open("/tmp/lesavka-server.log")?;
|
||||
let (file_writer, guard) = tracing_appender::non_blocking(file);
|
||||
|
||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
EnvFilter::new("lesavka_server=info,lesavka_server::video=warn")
|
||||
});
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("lesavka_server=info,lesavka_server::video=warn"));
|
||||
let filter_str = env_filter.to_string();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_target(true)
|
||||
.with_thread_ids(true),
|
||||
)
|
||||
.with(fmt::layer().with_target(true).with_thread_ids(true))
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(file_writer)
|
||||
@ -69,9 +59,13 @@ fn init_tracing() -> anyhow::Result<WorkerGuard> {
|
||||
|
||||
/*──────────────── helpers ───────────────────*/
|
||||
async fn open_with_retry(path: &str) -> anyhow::Result<tokio::fs::File> {
|
||||
for attempt in 1..=200 { // ≈10 s
|
||||
for attempt in 1..=200 {
|
||||
// ≈10 s
|
||||
match OpenOptions::new()
|
||||
.write(true).custom_flags(libc::O_NONBLOCK).open(path).await
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(path)
|
||||
.await
|
||||
{
|
||||
Ok(f) => {
|
||||
info!("✅ {path} opened on attempt #{attempt}");
|
||||
@ -88,28 +82,201 @@ async fn open_with_retry(path: &str) -> anyhow::Result<tokio::fs::File> {
|
||||
}
|
||||
|
||||
fn next_minute() -> SystemTime {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH).unwrap();
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
|
||||
let secs = now.as_secs();
|
||||
let next = (secs / 60 + 1) * 60;
|
||||
UNIX_EPOCH + Duration::from_secs(next)
|
||||
}
|
||||
|
||||
fn allow_gadget_cycle() -> bool {
|
||||
std::env::var("LESAVKA_ALLOW_GADGET_CYCLE").is_ok()
|
||||
}
|
||||
|
||||
async fn recover_hid_if_needed(
|
||||
err: &std::io::Error,
|
||||
gadget: UsbGadget,
|
||||
kb: Arc<Mutex<tokio::fs::File>>,
|
||||
ms: Arc<Mutex<tokio::fs::File>>,
|
||||
did_cycle: Arc<AtomicBool>,
|
||||
) {
|
||||
let code = err.raw_os_error();
|
||||
let should_recover = matches!(
|
||||
code,
|
||||
Some(libc::ENOTCONN) | Some(libc::ESHUTDOWN) | Some(libc::EPIPE)
|
||||
);
|
||||
if !should_recover {
|
||||
return;
|
||||
}
|
||||
if did_cycle
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let allow_cycle = allow_gadget_cycle();
|
||||
tokio::spawn(async move {
|
||||
if allow_cycle {
|
||||
warn!("🔁 HID transport down (errno={code:?}) - cycling gadget");
|
||||
match tokio::task::spawn_blocking(move || gadget.cycle()).await {
|
||||
Ok(Ok(())) => info!("✅ USB gadget cycle complete (auto-recover)"),
|
||||
Ok(Err(e)) => error!("💥 USB gadget cycle failed: {e:#}"),
|
||||
Err(e) => error!("💥 USB gadget cycle task panicked: {e:#}"),
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"🔒 HID transport down (errno={code:?}) - gadget cycle disabled; set LESAVKA_ALLOW_GADGET_CYCLE=1 to enable"
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = async {
|
||||
let kb_new = open_with_retry("/dev/hidg0").await?;
|
||||
let ms_new = open_with_retry("/dev/hidg1").await?;
|
||||
*kb.lock().await = kb_new;
|
||||
*ms.lock().await = ms_new;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}
|
||||
.await
|
||||
{
|
||||
error!("💥 HID reopen failed: {e:#}");
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
did_cycle.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
/// Pick the UVC gadget video node.
|
||||
/// Priority: 1) `LESAVKA_UVC_DEV` override; 2) first `video_output` node.
|
||||
/// Returns an error when nothing matches instead of guessing a capture card.
|
||||
fn pick_uvc_device() -> anyhow::Result<String> {
|
||||
if let Ok(path) = std::env::var("LESAVKA_UVC_DEV") {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let ctrl = UsbGadget::find_controller().ok();
|
||||
if let Some(ctrl) = ctrl.as_deref() {
|
||||
let by_path = format!("/dev/v4l/by-path/platform-{ctrl}-video-index0");
|
||||
if Path::new(&by_path).exists() {
|
||||
return Ok(by_path);
|
||||
}
|
||||
}
|
||||
|
||||
// walk /dev/video* via udev and look for an output‑capable node (gadget exposes one)
|
||||
let mut fallback: Option<String> = None;
|
||||
if let Ok(mut en) = udev::Enumerator::new() {
|
||||
let _ = en.match_subsystem("video4linux");
|
||||
if let Ok(devs) = en.scan_devices() {
|
||||
for dev in devs {
|
||||
let caps = dev
|
||||
.property_value("ID_V4L_CAPABILITIES")
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or_default();
|
||||
if !caps.contains(":video_output:") {
|
||||
continue;
|
||||
}
|
||||
let Some(node) = dev.devnode() else { continue };
|
||||
let node = node.to_string_lossy().into_owned();
|
||||
let product = dev
|
||||
.property_value("ID_V4L_PRODUCT")
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or_default();
|
||||
let path = dev
|
||||
.property_value("ID_PATH")
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or_default();
|
||||
if let Some(ctrl) = ctrl.as_deref() {
|
||||
if product == ctrl || path.contains(ctrl) {
|
||||
return Ok(node);
|
||||
}
|
||||
}
|
||||
if fallback.is_none() {
|
||||
fallback = Some(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(node) = fallback {
|
||||
return Ok(node);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"no video_output v4l2 node found; set LESAVKA_UVC_DEV"
|
||||
))
|
||||
}
|
||||
|
||||
fn uvc_ctrl_bin() -> String {
|
||||
std::env::var("LESAVKA_UVC_CTRL_BIN")
|
||||
.unwrap_or_else(|_| "/usr/local/bin/lesavka-uvc".to_string())
|
||||
}
|
||||
|
||||
fn spawn_uvc_control(bin: &str, uvc_dev: &str) -> anyhow::Result<tokio::process::Child> {
|
||||
Command::new(bin)
|
||||
.arg("--device")
|
||||
.arg(uvc_dev)
|
||||
.spawn()
|
||||
.context("spawning lesavka-uvc")
|
||||
}
|
||||
|
||||
async fn supervise_uvc_control(bin: String) {
|
||||
let mut waiting_logged = false;
|
||||
loop {
|
||||
let uvc_dev = match pick_uvc_device() {
|
||||
Ok(dev) => {
|
||||
if waiting_logged {
|
||||
info!(%dev, "📷 UVC device discovered");
|
||||
waiting_logged = false;
|
||||
}
|
||||
dev
|
||||
}
|
||||
Err(e) => {
|
||||
if !waiting_logged {
|
||||
warn!("⚠️ UVC device not ready: {e:#}");
|
||||
waiting_logged = true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match spawn_uvc_control(&bin, &uvc_dev) {
|
||||
Ok(mut child) => {
|
||||
info!(%uvc_dev, "📷 UVC control helper started");
|
||||
match child.wait().await {
|
||||
Ok(status) => {
|
||||
warn!(%uvc_dev, "⚠️ lesavka-uvc exited: {status}");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(%uvc_dev, "⚠️ lesavka-uvc wait failed: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(%uvc_dev, "⚠️ failed to start lesavka-uvc: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/*──────────────── Handler ───────────────────*/
|
||||
struct Handler {
|
||||
kb: Arc<Mutex<tokio::fs::File>>,
|
||||
ms: Arc<Mutex<tokio::fs::File>>,
|
||||
gadget: UsbGadget,
|
||||
did_cycle: AtomicBool,
|
||||
did_cycle: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
async fn new(gadget: UsbGadget) -> anyhow::Result<Self> {
|
||||
if AUTO_CYCLE {
|
||||
if allow_gadget_cycle() {
|
||||
info!("🛠️ Initial USB reset…");
|
||||
let _ = gadget.cycle(); // ignore failure - may boot without host
|
||||
let _ = gadget.cycle(); // ignore failure - may boot without host
|
||||
} else {
|
||||
info!("🛠️ AUTO_CYCLE disabled - no initial reset");
|
||||
info!(
|
||||
"🔒 gadget cycle disabled at startup (set LESAVKA_ALLOW_GADGET_CYCLE=1 to enable)"
|
||||
);
|
||||
}
|
||||
|
||||
info!("🛠️ opening HID endpoints …");
|
||||
@ -121,7 +288,7 @@ impl Handler {
|
||||
kb: Arc::new(Mutex::new(kb)),
|
||||
ms: Arc::new(Mutex::new(ms)),
|
||||
gadget,
|
||||
did_cycle: AtomicBool::new(false),
|
||||
did_cycle: Arc::new(AtomicBool::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
@ -138,10 +305,10 @@ impl Handler {
|
||||
#[tonic::async_trait]
|
||||
impl Relay for Handler {
|
||||
/* existing streams ─ unchanged, except: no more auto-reset */
|
||||
type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>;
|
||||
type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>;
|
||||
type CaptureVideoStream = Pin<Box<dyn Stream<Item=Result<VideoPacket,Status>> + Send>>;
|
||||
type CaptureAudioStream = Pin<Box<dyn Stream<Item=Result<AudioPacket,Status>> + Send>>;
|
||||
type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>;
|
||||
type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>;
|
||||
type CaptureVideoStream = Pin<Box<dyn Stream<Item = Result<VideoPacket, Status>> + Send>>;
|
||||
type CaptureAudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, Status>> + Send>>;
|
||||
type StreamMicrophoneStream = ReceiverStream<Result<Empty, Status>>;
|
||||
type StreamCameraStream = ReceiverStream<Result<Empty, Status>>;
|
||||
|
||||
@ -151,12 +318,23 @@ impl Relay for Handler {
|
||||
) -> Result<Response<Self::StreamKeyboardStream>, Status> {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
||||
let kb = self.kb.clone();
|
||||
let ms = self.ms.clone();
|
||||
let gadget = self.gadget.clone();
|
||||
let did_cycle = self.did_cycle.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut s = req.into_inner();
|
||||
while let Some(pkt) = s.next().await.transpose()? {
|
||||
if let Err(e) = kb.lock().await.write_all(&pkt.data).await {
|
||||
warn!("⌨️ write failed: {e} (dropped)");
|
||||
recover_hid_if_needed(
|
||||
&e,
|
||||
gadget.clone(),
|
||||
kb.clone(),
|
||||
ms.clone(),
|
||||
did_cycle.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
tx.send(Ok(pkt)).await.ok();
|
||||
}
|
||||
@ -172,12 +350,23 @@ impl Relay for Handler {
|
||||
) -> Result<Response<Self::StreamMouseStream>, Status> {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(1024);
|
||||
let ms = self.ms.clone();
|
||||
let kb = self.kb.clone();
|
||||
let gadget = self.gadget.clone();
|
||||
let did_cycle = self.did_cycle.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut s = req.into_inner();
|
||||
while let Some(pkt) = s.next().await.transpose()? {
|
||||
if let Err(e) = ms.lock().await.write_all(&pkt.data).await {
|
||||
warn!("🖱️ write failed: {e} (dropped)");
|
||||
recover_hid_if_needed(
|
||||
&e,
|
||||
gadget.clone(),
|
||||
kb.clone(),
|
||||
ms.clone(),
|
||||
did_cycle.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
tx.send(Ok(pkt)).await.ok();
|
||||
}
|
||||
@ -191,9 +380,11 @@ impl Relay for Handler {
|
||||
&self,
|
||||
req: Request<tonic::Streaming<AudioPacket>>,
|
||||
) -> Result<Response<Self::StreamMicrophoneStream>, Status> {
|
||||
|
||||
// 1 ─ build once, early
|
||||
let mut sink = audio::Voice::new("hw:UAC2Gadget,0").await
|
||||
let uac_dev = std::env::var("LESAVKA_UAC_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into());
|
||||
info!(%uac_dev, "🎤 stream_microphone using UAC sink");
|
||||
let mut sink = audio::Voice::new(&uac_dev)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("{e:#}")))?;
|
||||
|
||||
// 2 ─ dummy outbound stream (same trick as before)
|
||||
@ -202,8 +393,7 @@ impl Relay for Handler {
|
||||
// 3 ─ drive the sink in a background task
|
||||
tokio::spawn(async move {
|
||||
let mut inbound = req.into_inner();
|
||||
static CNT: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
static CNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
while let Some(pkt) = inbound.next().await.transpose()? {
|
||||
let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
@ -212,7 +402,7 @@ impl Relay for Handler {
|
||||
}
|
||||
sink.push(&pkt);
|
||||
}
|
||||
sink.finish(); // flush on EOS
|
||||
sink.finish(); // flush on EOS
|
||||
let _ = tx.send(Ok(Empty {})).await;
|
||||
Ok::<(), Status>(())
|
||||
});
|
||||
@ -224,26 +414,45 @@ impl Relay for Handler {
|
||||
&self,
|
||||
req: Request<tonic::Streaming<VideoPacket>>,
|
||||
) -> Result<Response<Self::StreamCameraStream>, Status> {
|
||||
// map gRPC camera id → UVC device
|
||||
let uvc = std::env::var("LESAVKA_UVC_DEV")
|
||||
.unwrap_or_else(|_| "/dev/video4".into());
|
||||
|
||||
// build once
|
||||
let relay = video::CameraRelay::new(0, &uvc)
|
||||
.map_err(|e| Status::internal(format!("{e:#}")))?;
|
||||
|
||||
let cfg = camera::current_camera_config();
|
||||
info!(
|
||||
output = cfg.output.as_str(),
|
||||
codec = cfg.codec.as_str(),
|
||||
width = cfg.width,
|
||||
height = cfg.height,
|
||||
fps = cfg.fps,
|
||||
hdmi = cfg.hdmi.as_ref().map(|h| h.name.as_str()).unwrap_or("none"),
|
||||
"🎥 stream_camera output selected"
|
||||
);
|
||||
|
||||
let relay = match cfg.output {
|
||||
camera::CameraOutput::Uvc => {
|
||||
if std::env::var("LESAVKA_DISABLE_UVC").is_ok() {
|
||||
return Err(Status::failed_precondition(
|
||||
"UVC output disabled (LESAVKA_DISABLE_UVC set)",
|
||||
));
|
||||
}
|
||||
let uvc = pick_uvc_device().map_err(|e| Status::internal(format!("{e:#}")))?;
|
||||
info!(%uvc, "🎥 stream_camera using UVC sink");
|
||||
video::CameraRelay::new_uvc(0, &uvc, &cfg)
|
||||
.map_err(|e| Status::internal(format!("{e:#}")))?
|
||||
}
|
||||
camera::CameraOutput::Hdmi => video::CameraRelay::new_hdmi(0, &cfg)
|
||||
.map_err(|e| Status::internal(format!("{e:#}")))?,
|
||||
};
|
||||
|
||||
// dummy outbound (same pattern as other streams)
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(1);
|
||||
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut s = req.into_inner();
|
||||
while let Some(pkt) = s.next().await.transpose()? {
|
||||
relay.feed(pkt); // ← all logging inside video.rs
|
||||
relay.feed(pkt); // ← all logging inside video.rs
|
||||
}
|
||||
tx.send(Ok(Empty {})).await.ok();
|
||||
Ok::<(), Status>(())
|
||||
});
|
||||
|
||||
|
||||
Ok(Response::new(ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
@ -269,10 +478,9 @@ impl Relay for Handler {
|
||||
req: Request<MonitorRequest>,
|
||||
) -> Result<Response<Self::CaptureAudioStream>, Status> {
|
||||
// Only one speaker stream for now; both 0/1 → same ALSA dev.
|
||||
let _id = req.into_inner().id;
|
||||
let _id = req.into_inner().id;
|
||||
// Allow override (`LESAVKA_ALSA_DEV=hw:2,0` for debugging).
|
||||
let dev = std::env::var("LESAVKA_ALSA_DEV")
|
||||
.unwrap_or_else(|_| "hw:UAC2Gadget,0".into());
|
||||
let dev = std::env::var("LESAVKA_ALSA_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into());
|
||||
|
||||
let s = audio::ear(&dev, 0)
|
||||
.await
|
||||
@ -282,10 +490,7 @@ impl Relay for Handler {
|
||||
}
|
||||
|
||||
/*────────────── USB-reset RPC ────────────*/
|
||||
async fn reset_usb(
|
||||
&self,
|
||||
_req: Request<Empty>,
|
||||
) -> Result<Response<ResetUsbReply>, Status> {
|
||||
async fn reset_usb(&self, _req: Request<Empty>) -> Result<Response<ResetUsbReply>, Status> {
|
||||
info!("🔴 explicit ResetUsb() called");
|
||||
match self.gadget.cycle() {
|
||||
Ok(_) => {
|
||||
@ -314,17 +519,27 @@ async fn main() -> anyhow::Result<()> {
|
||||
error!("💥 panic: {p}\n{bt}");
|
||||
}));
|
||||
|
||||
let gadget = UsbGadget::new("lesavka");
|
||||
let gadget = UsbGadget::new("lesavka");
|
||||
if std::env::var("LESAVKA_DISABLE_UVC").is_err() {
|
||||
if std::env::var("LESAVKA_UVC_EXTERNAL").is_ok() {
|
||||
info!("📷 UVC control helper external; not spawning");
|
||||
} else {
|
||||
let bin = uvc_ctrl_bin();
|
||||
tokio::spawn(supervise_uvc_control(bin));
|
||||
}
|
||||
} else {
|
||||
info!("📷 UVC disabled (LESAVKA_DISABLE_UVC set)");
|
||||
}
|
||||
let handler = Handler::new(gadget.clone()).await?;
|
||||
|
||||
info!("🌐 lesavka-server listening on 0.0.0.0:50051");
|
||||
Server::builder()
|
||||
.tcp_nodelay(true)
|
||||
.max_frame_size(Some(2*1024*1024))
|
||||
.max_frame_size(Some(2 * 1024 * 1024))
|
||||
.add_service(RelayServer::new(handler))
|
||||
.add_service(HandshakeSvc::server())
|
||||
.add_service(ReflBuilder::configure().build_v1().unwrap())
|
||||
.serve(([0,0,0,0], 50051).into())
|
||||
.serve(([0, 0, 0, 0], 50051).into())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -1,19 +1,74 @@
|
||||
// server/src/video.rs
|
||||
|
||||
use anyhow::Context;
|
||||
use futures_util::Stream;
|
||||
use gst::MessageView;
|
||||
use gst::MessageView::*;
|
||||
use gst::prelude::*;
|
||||
use gstreamer as gst;
|
||||
use gstreamer_app as gst_app;
|
||||
use gst::prelude::*;
|
||||
use gst::{log, MessageView};
|
||||
use gst::MessageView::*;
|
||||
use lesavka_common::lesavka::VideoPacket;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::Status;
|
||||
use tracing::{debug, warn, error, info, enabled, trace, Level};
|
||||
use futures_util::Stream;
|
||||
use tracing::{Level, debug, enabled, error, info, trace, warn};
|
||||
|
||||
use crate::camera::{CameraCodec, CameraConfig};
|
||||
|
||||
const EYE_ID: [&str; 2] = ["l", "r"];
|
||||
static START: std::sync::OnceLock<gst::ClockTime> = std::sync::OnceLock::new();
|
||||
static DEV_MODE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
|
||||
fn env_u32(name: &str, default: u32) -> u32 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn dev_mode_enabled() -> bool {
|
||||
*DEV_MODE.get_or_init(|| std::env::var("LESAVKA_DEV_MODE").is_ok())
|
||||
}
|
||||
|
||||
fn pick_h264_decoder() -> &'static str {
|
||||
if gst::ElementFactory::find("v4l2h264dec").is_some() {
|
||||
"v4l2h264dec"
|
||||
} else if gst::ElementFactory::find("v4l2slh264dec").is_some() {
|
||||
"v4l2slh264dec"
|
||||
} else if gst::ElementFactory::find("omxh264dec").is_some() {
|
||||
"omxh264dec"
|
||||
} else {
|
||||
"avdec_h264"
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_idr(h264: &[u8]) -> bool {
|
||||
// naive Annex‑B scan for H.264 IDR (NAL type 5)
|
||||
let mut i = 0;
|
||||
while i + 4 < h264.len() {
|
||||
// find start code 0x000001 or 0x00000001
|
||||
if h264[i] == 0 && h264[i + 1] == 0 {
|
||||
let offset = if h264[i + 2] == 1 {
|
||||
3
|
||||
} else if h264[i + 2] == 0 && h264[i + 3] == 1 {
|
||||
4
|
||||
} else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let nal_idx = i + offset;
|
||||
if nal_idx < h264.len() {
|
||||
let nal = h264[nal_idx] & 0x1F;
|
||||
if nal == 5 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub struct VideoStream {
|
||||
_pipeline: gst::Pipeline,
|
||||
@ -37,14 +92,18 @@ impl Drop for VideoStream {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn eye_ball(
|
||||
dev: &str,
|
||||
id: u32,
|
||||
_max_bitrate_kbit: u32,
|
||||
) -> anyhow::Result<VideoStream> {
|
||||
pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Result<VideoStream> {
|
||||
let eye = EYE_ID[id as usize];
|
||||
gst::init().context("gst init")?;
|
||||
|
||||
let target_fps = env_u32("LESAVKA_EYE_FPS", 25);
|
||||
let frame_interval_us = if target_fps == 0 {
|
||||
0
|
||||
} else {
|
||||
(1_000_000 / target_fps) as u64
|
||||
};
|
||||
let last_sent = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let desc = format!(
|
||||
"v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \
|
||||
queue ! \
|
||||
@ -79,8 +138,10 @@ pub async fn eye_ball(
|
||||
|
||||
/* ----- BUS WATCH: show errors & warnings immediately --------------- */
|
||||
let bus = pipeline.bus().expect("bus");
|
||||
if let Some(src_pad) = pipeline.by_name(&format!("cam_{eye}"))
|
||||
.and_then(|e| e.static_pad("src")) {
|
||||
if let Some(src_pad) = pipeline
|
||||
.by_name(&format!("cam_{eye}"))
|
||||
.and_then(|e| e.static_pad("src"))
|
||||
{
|
||||
src_pad.add_probe(gst::PadProbeType::EVENT_DOWNSTREAM, |pad, info| {
|
||||
if let Some(gst::PadProbeData::Event(ref ev)) = info.data {
|
||||
if let gst::EventView::Caps(c) = ev.view() {
|
||||
@ -128,6 +189,7 @@ pub async fn eye_ball(
|
||||
}
|
||||
});
|
||||
|
||||
let last_sent_cloned = last_sent.clone();
|
||||
sink.set_callbacks(
|
||||
gst_app::AppSinkCallbacks::builder()
|
||||
.new_sample(move |sink| {
|
||||
@ -139,10 +201,9 @@ pub async fn eye_ball(
|
||||
let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?;
|
||||
|
||||
/* -------- basic counters ------ */
|
||||
static FRAME: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
static FRAME: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = FRAME.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if n % 120 == 0 {
|
||||
if n % 120 == 0 && contains_idr(map.as_slice()) {
|
||||
trace!(target: "lesavka_server::video", "eye-{eye}: delivered {n} frames");
|
||||
if enabled!(Level::TRACE) {
|
||||
let path = format!("/tmp/eye-{eye}-srv-{:05}.h264", n);
|
||||
@ -157,7 +218,9 @@ pub async fn eye_ball(
|
||||
/* -------- detect SPS / IDR ---- */
|
||||
if enabled!(Level::DEBUG) {
|
||||
if let Some(&nal) = map.as_slice().get(4) {
|
||||
if (nal & 0x1F) == 0x05 /* IDR */ {
|
||||
if (nal & 0x1F) == 0x05
|
||||
/* IDR */
|
||||
{
|
||||
debug!("eye-{eye}: IDR");
|
||||
}
|
||||
}
|
||||
@ -172,10 +235,22 @@ pub async fn eye_ball(
|
||||
.nseconds()
|
||||
/ 1_000;
|
||||
|
||||
if frame_interval_us > 0 {
|
||||
let last = last_sent_cloned.load(Ordering::Relaxed);
|
||||
if last != 0 && pts_us.saturating_sub(last) < frame_interval_us {
|
||||
return Ok(gst::FlowSuccess::Ok);
|
||||
}
|
||||
last_sent_cloned.store(pts_us, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/* -------- ship over gRPC ----- */
|
||||
let data = map.as_slice().to_vec();
|
||||
let size = data.len();
|
||||
let pkt = VideoPacket { id, pts: pts_us, data };
|
||||
let pkt = VideoPacket {
|
||||
id,
|
||||
pts: pts_us,
|
||||
data,
|
||||
};
|
||||
match tx.try_send(Ok(pkt)) {
|
||||
Ok(_) => {
|
||||
trace!(target:"lesavka_server::video",
|
||||
@ -202,29 +277,159 @@ pub async fn eye_ball(
|
||||
.build(),
|
||||
);
|
||||
|
||||
pipeline.set_state(gst::State::Playing).context("🎥 starting video pipeline eye-{eye}")?;
|
||||
pipeline
|
||||
.set_state(gst::State::Playing)
|
||||
.context("🎥 starting video pipeline eye-{eye}")?;
|
||||
let bus = pipeline.bus().unwrap();
|
||||
loop {
|
||||
match bus.timed_pop(gst::ClockTime::NONE) {
|
||||
Some(msg) if matches!(msg.view(), MessageView::StateChanged(s)
|
||||
if s.current() == gst::State::Playing) => break,
|
||||
Some(msg)
|
||||
if matches!(msg.view(), MessageView::StateChanged(s)
|
||||
if s.current() == gst::State::Playing) =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
Ok(VideoStream { _pipeline: pipeline, inner: ReceiverStream::new(rx) })
|
||||
Ok(VideoStream {
|
||||
_pipeline: pipeline,
|
||||
inner: ReceiverStream::new(rx),
|
||||
})
|
||||
}
|
||||
|
||||
pub struct WebcamSink {
|
||||
appsrc: gst_app::AppSrc,
|
||||
_pipe: gst::Pipeline,
|
||||
_pipe: gst::Pipeline,
|
||||
}
|
||||
|
||||
impl WebcamSink {
|
||||
pub fn new(uvc_dev: &str) -> anyhow::Result<Self> {
|
||||
pub fn new(uvc_dev: &str, cfg: &CameraConfig) -> anyhow::Result<Self> {
|
||||
gst::init()?;
|
||||
|
||||
let pipeline = gst::Pipeline::new();
|
||||
|
||||
let width = cfg.width as i32;
|
||||
let height = cfg.height as i32;
|
||||
let fps = cfg.fps.max(1) as i32;
|
||||
let use_mjpeg = matches!(cfg.codec, CameraCodec::Mjpeg);
|
||||
|
||||
let src = gst::ElementFactory::make("appsrc")
|
||||
.build()?
|
||||
.downcast::<gst_app::AppSrc>()
|
||||
.expect("appsrc");
|
||||
src.set_is_live(true);
|
||||
src.set_format(gst::Format::Time);
|
||||
let block = std::env::var("LESAVKA_UVC_APP_BLOCK")
|
||||
.ok()
|
||||
.map(|v| v != "0")
|
||||
.unwrap_or(false);
|
||||
src.set_property("block", &block);
|
||||
|
||||
if use_mjpeg {
|
||||
let caps_mjpeg = gst::Caps::builder("image/jpeg")
|
||||
.field("parsed", true)
|
||||
.field("width", width)
|
||||
.field("height", height)
|
||||
.field("framerate", gst::Fraction::new(fps, 1))
|
||||
.field("pixel-aspect-ratio", gst::Fraction::new(1, 1))
|
||||
.field("colorimetry", "2:4:7:1")
|
||||
.build();
|
||||
src.set_caps(Some(&caps_mjpeg));
|
||||
|
||||
let queue = gst::ElementFactory::make("queue").build()?;
|
||||
let capsfilter = gst::ElementFactory::make("capsfilter")
|
||||
.property("caps", &caps_mjpeg)
|
||||
.build()?;
|
||||
let sink = gst::ElementFactory::make("v4l2sink")
|
||||
.property("device", &uvc_dev)
|
||||
.property("sync", &false)
|
||||
.build()?;
|
||||
|
||||
pipeline.add_many(&[src.upcast_ref(), &queue, &capsfilter, &sink])?;
|
||||
gst::Element::link_many(&[src.upcast_ref(), &queue, &capsfilter, &sink])?;
|
||||
} else {
|
||||
let caps_h264 = gst::Caps::builder("video/x-h264")
|
||||
.field("stream-format", "byte-stream")
|
||||
.field("alignment", "au")
|
||||
.build();
|
||||
let raw_caps = gst::Caps::builder("video/x-raw")
|
||||
.field("format", "YUY2")
|
||||
.field("width", width)
|
||||
.field("height", height)
|
||||
.field("framerate", gst::Fraction::new(fps, 1))
|
||||
.build();
|
||||
src.set_caps(Some(&caps_h264));
|
||||
|
||||
let h264parse = gst::ElementFactory::make("h264parse").build()?;
|
||||
let decoder_name = pick_h264_decoder();
|
||||
let decoder = gst::ElementFactory::make(decoder_name)
|
||||
.build()
|
||||
.with_context(|| format!("building decoder element {decoder_name}"))?;
|
||||
let convert = gst::ElementFactory::make("videoconvert").build()?;
|
||||
let scale = gst::ElementFactory::make("videoscale").build()?;
|
||||
let caps = gst::ElementFactory::make("capsfilter")
|
||||
.property("caps", &raw_caps)
|
||||
.build()?;
|
||||
let sink = gst::ElementFactory::make("v4l2sink")
|
||||
.property("device", &uvc_dev)
|
||||
.property("sync", &false)
|
||||
.build()?;
|
||||
|
||||
pipeline.add_many(&[
|
||||
src.upcast_ref(),
|
||||
&h264parse,
|
||||
&decoder,
|
||||
&convert,
|
||||
&scale,
|
||||
&caps,
|
||||
&sink,
|
||||
])?;
|
||||
gst::Element::link_many(&[
|
||||
src.upcast_ref(),
|
||||
&h264parse,
|
||||
&decoder,
|
||||
&convert,
|
||||
&scale,
|
||||
&caps,
|
||||
&sink,
|
||||
])?;
|
||||
}
|
||||
pipeline.set_state(gst::State::Playing)?;
|
||||
|
||||
Ok(Self {
|
||||
appsrc: src,
|
||||
_pipe: pipeline,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn push(&self, pkt: VideoPacket) {
|
||||
let mut buf = gst::Buffer::from_slice(pkt.data);
|
||||
buf.get_mut()
|
||||
.unwrap()
|
||||
.set_pts(Some(gst::ClockTime::from_useconds(pkt.pts)));
|
||||
if let Err(err) = self.appsrc.push_buffer(buf) {
|
||||
tracing::warn!(target:"lesavka_server::video", %err, "📸⚠️ appsrc push failed");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pub struct HdmiSink {
|
||||
appsrc: gst_app::AppSrc,
|
||||
_pipe: gst::Pipeline,
|
||||
}
|
||||
|
||||
impl HdmiSink {
|
||||
pub fn new(cfg: &CameraConfig) -> anyhow::Result<Self> {
|
||||
gst::init()?;
|
||||
|
||||
let pipeline = gst::Pipeline::new();
|
||||
let width = cfg.width as i32;
|
||||
let height = cfg.height as i32;
|
||||
let fps = cfg.fps.max(1) as i32;
|
||||
|
||||
let src = gst::ElementFactory::make("appsrc")
|
||||
.build()?
|
||||
.downcast::<gst_app::AppSrc>()
|
||||
@ -232,48 +437,172 @@ impl WebcamSink {
|
||||
src.set_is_live(true);
|
||||
src.set_format(gst::Format::Time);
|
||||
|
||||
let h264parse = gst::ElementFactory::make("h264parse").build()?;
|
||||
let decoder = gst::ElementFactory::make("v4l2h264dec").build()?;
|
||||
let convert = gst::ElementFactory::make("videoconvert").build()?;
|
||||
let sink = gst::ElementFactory::make("v4l2sink")
|
||||
.property("device", &uvc_dev)
|
||||
.property("sync", &false)
|
||||
.build()?;
|
||||
let raw_caps = gst::Caps::builder("video/x-raw")
|
||||
.field("width", width)
|
||||
.field("height", height)
|
||||
.field("framerate", gst::Fraction::new(fps, 1))
|
||||
.build();
|
||||
let capsfilter = gst::ElementFactory::make("capsfilter")
|
||||
.property("caps", &raw_caps)
|
||||
.build()?;
|
||||
|
||||
let queue = gst::ElementFactory::make("queue")
|
||||
.property("max-size-buffers", 4u32)
|
||||
.build()?;
|
||||
let convert = gst::ElementFactory::make("videoconvert").build()?;
|
||||
let scale = gst::ElementFactory::make("videoscale").build()?;
|
||||
let sink = build_hdmi_sink(cfg)?;
|
||||
|
||||
match cfg.codec {
|
||||
CameraCodec::H264 => {
|
||||
let caps_h264 = gst::Caps::builder("video/x-h264")
|
||||
.field("stream-format", "byte-stream")
|
||||
.field("alignment", "au")
|
||||
.build();
|
||||
src.set_caps(Some(&caps_h264));
|
||||
let h264parse = gst::ElementFactory::make("h264parse").build()?;
|
||||
let decoder_name = pick_h264_decoder();
|
||||
let decoder = gst::ElementFactory::make(decoder_name)
|
||||
.build()
|
||||
.with_context(|| format!("building decoder element {decoder_name}"))?;
|
||||
|
||||
pipeline.add_many(&[
|
||||
src.upcast_ref(),
|
||||
&queue,
|
||||
&h264parse,
|
||||
&decoder,
|
||||
&convert,
|
||||
&scale,
|
||||
&capsfilter,
|
||||
&sink,
|
||||
])?;
|
||||
gst::Element::link_many(&[
|
||||
src.upcast_ref(),
|
||||
&queue,
|
||||
&h264parse,
|
||||
&decoder,
|
||||
&convert,
|
||||
&scale,
|
||||
&capsfilter,
|
||||
&sink,
|
||||
])?;
|
||||
}
|
||||
CameraCodec::Mjpeg => {
|
||||
let caps_mjpeg = gst::Caps::builder("image/jpeg")
|
||||
.field("parsed", true)
|
||||
.field("width", width)
|
||||
.field("height", height)
|
||||
.field("framerate", gst::Fraction::new(fps, 1))
|
||||
.build();
|
||||
src.set_caps(Some(&caps_mjpeg));
|
||||
let jpegdec = gst::ElementFactory::make("jpegdec").build()?;
|
||||
|
||||
pipeline.add_many(&[
|
||||
src.upcast_ref(),
|
||||
&queue,
|
||||
&jpegdec,
|
||||
&convert,
|
||||
&scale,
|
||||
&capsfilter,
|
||||
&sink,
|
||||
])?;
|
||||
gst::Element::link_many(&[
|
||||
src.upcast_ref(),
|
||||
&queue,
|
||||
&jpegdec,
|
||||
&convert,
|
||||
&scale,
|
||||
&capsfilter,
|
||||
&sink,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
|
||||
// Up‑cast to &gst::Element for the collection macros
|
||||
pipeline.add_many(&[
|
||||
src.upcast_ref(), &h264parse, &decoder, &convert, &sink
|
||||
])?;
|
||||
gst::Element::link_many(&[
|
||||
src.upcast_ref(), &h264parse, &decoder, &convert, &sink
|
||||
])?;
|
||||
pipeline.set_state(gst::State::Playing)?;
|
||||
|
||||
Ok(Self { appsrc: src, _pipe: pipeline })
|
||||
Ok(Self {
|
||||
appsrc: src,
|
||||
_pipe: pipeline,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn push(&self, pkt: VideoPacket) {
|
||||
let mut buf = gst::Buffer::from_slice(pkt.data);
|
||||
buf.get_mut().unwrap()
|
||||
.set_pts(Some(gst::ClockTime::from_useconds(pkt.pts)));
|
||||
let _ = self.appsrc.push_buffer(buf);
|
||||
buf.get_mut()
|
||||
.unwrap()
|
||||
.set_pts(Some(gst::ClockTime::from_useconds(pkt.pts)));
|
||||
if let Err(err) = self.appsrc.push_buffer(buf) {
|
||||
tracing::warn!(target:"lesavka_server::video", %err, "📺⚠️ HDMI appsrc push failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result<gst::Element> {
|
||||
if let Ok(name) = std::env::var("LESAVKA_HDMI_SINK") {
|
||||
return gst::ElementFactory::make(&name)
|
||||
.build()
|
||||
.context("building HDMI sink");
|
||||
}
|
||||
|
||||
if gst::ElementFactory::find("kmssink").is_some() {
|
||||
let sink = gst::ElementFactory::make("kmssink").build()?;
|
||||
if let Some(connector) = cfg.hdmi.as_ref().and_then(|h| h.id) {
|
||||
if sink.has_property("connector-id", None) {
|
||||
sink.set_property("connector-id", &(connector as i32));
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target: "lesavka_server::video",
|
||||
%connector,
|
||||
"kmssink does not expose connector-id property; using default connector"
|
||||
);
|
||||
}
|
||||
}
|
||||
sink.set_property("sync", &false);
|
||||
return Ok(sink);
|
||||
}
|
||||
|
||||
let sink = gst::ElementFactory::make("autovideosink")
|
||||
.build()
|
||||
.context("building HDMI sink")?;
|
||||
let _ = sink.set_property("sync", &false);
|
||||
Ok(sink)
|
||||
}
|
||||
|
||||
/*─────────────────────────────────*/
|
||||
/* gRPC → WebcamSink relay */
|
||||
/* gRPC → CameraSink relay */
|
||||
/*─────────────────────────────────*/
|
||||
|
||||
enum CameraSink {
|
||||
Uvc(WebcamSink),
|
||||
Hdmi(HdmiSink),
|
||||
}
|
||||
|
||||
impl CameraSink {
|
||||
fn push(&self, pkt: VideoPacket) {
|
||||
match self {
|
||||
CameraSink::Uvc(sink) => sink.push(pkt),
|
||||
CameraSink::Hdmi(sink) => sink.push(pkt),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CameraRelay {
|
||||
sink: WebcamSink, // the v4l2sink pipeline (or stub)
|
||||
id: u32, // gRPC “id” (for future multi‑cam)
|
||||
sink: CameraSink,
|
||||
id: u32, // gRPC “id” (for future multi‑cam)
|
||||
frames: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl CameraRelay {
|
||||
pub fn new(id: u32, uvc_dev: &str) -> anyhow::Result<Self> {
|
||||
pub fn new_uvc(id: u32, uvc_dev: &str, cfg: &CameraConfig) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
sink: WebcamSink::new(uvc_dev)?,
|
||||
sink: CameraSink::Uvc(WebcamSink::new(uvc_dev, cfg)?),
|
||||
id,
|
||||
frames: std::sync::atomic::AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_hdmi(id: u32, cfg: &CameraConfig) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
sink: CameraSink::Hdmi(HdmiSink::new(cfg)?),
|
||||
id,
|
||||
frames: std::sync::atomic::AtomicU64::new(0),
|
||||
})
|
||||
@ -281,7 +610,9 @@ impl CameraRelay {
|
||||
|
||||
/// Push one VideoPacket coming from the client
|
||||
pub fn feed(&self, pkt: VideoPacket) {
|
||||
let n = self.frames.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let n = self
|
||||
.frames
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if n < 10 || n % 60 == 0 {
|
||||
tracing::debug!(target:"lesavka_server::video",
|
||||
cam_id = self.id,
|
||||
@ -296,14 +627,15 @@ impl CameraRelay {
|
||||
"📸📥 srv pkt");
|
||||
}
|
||||
|
||||
if cfg!(debug_assertions) || tracing::enabled!(tracing::Level::TRACE) {
|
||||
if n % 120 == 0 {
|
||||
let path = format!("/tmp/eye3-cli-{n:05}.h264");
|
||||
if let Err(e) = std::fs::write(&path, &pkt.data) {
|
||||
tracing::warn!("📸💾 dump failed: {e}");
|
||||
} else {
|
||||
tracing::debug!("📸💾 wrote {}", path);
|
||||
}
|
||||
if dev_mode_enabled()
|
||||
&& (cfg!(debug_assertions) || tracing::enabled!(tracing::Level::TRACE))
|
||||
&& contains_idr(&pkt.data)
|
||||
{
|
||||
let path = format!("/tmp/eye3-cli-{n:05}.h264");
|
||||
if let Err(e) = std::fs::write(&path, &pkt.data) {
|
||||
tracing::warn!("📸💾 dump failed: {e}");
|
||||
} else {
|
||||
tracing::debug!("📸💾 wrote {}", path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,14 +1,24 @@
|
||||
#[tokio::test]
|
||||
async fn hid_roundtrip() {
|
||||
use lesavka_common::lesavka::*;
|
||||
use lesavka_server::RelaySvc; // export the struct in lib.rs
|
||||
use lesavka_server::RelaySvc; // export the struct in lib.rs
|
||||
let svc = RelaySvc::default();
|
||||
let (mut cli, srv) = tonic::transport::Channel::balance_channel(1);
|
||||
tokio::spawn(tonic::transport::server::Server::builder()
|
||||
.add_service(relay_server::RelayServer::new(svc))
|
||||
.serve_with_incoming(srv));
|
||||
tokio::spawn(
|
||||
tonic::transport::server::Server::builder()
|
||||
.add_service(relay_server::RelayServer::new(svc))
|
||||
.serve_with_incoming(srv),
|
||||
);
|
||||
|
||||
let (mut tx, mut rx) = relay_client::RelayClient::new(cli).stream().await.unwrap().into_inner();
|
||||
tx.send(HidReport { data: vec![0,0,4,0,0,0,0,0] }).await.unwrap();
|
||||
assert!(rx.message().await.unwrap().is_none()); // nothing echoed yet
|
||||
let (mut tx, mut rx) = relay_client::RelayClient::new(cli)
|
||||
.stream()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
tx.send(HidReport {
|
||||
data: vec![0, 0, 4, 0, 0, 0, 0, 0],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(rx.message().await.unwrap().is_none()); // nothing echoed yet
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user