Compare commits

...

97 Commits

Author SHA1 Message Date
4f898ddee7 client: headless mode and kmssink guard 2026-02-02 15:15:45 -03:00
57adce2696 camera: add HDMI output path 2026-01-28 17:52:00 -03:00
dd69d7e378 uvc: log configfs snapshot 2026-01-27 04:45:14 -03:00
1dc3bab3aa kernel: guard missing uvc ep desc 2026-01-27 01:13:09 -03:00
c7b06c4456 kernel: log uvc prep ep state 2026-01-26 22:32:54 -03:00
2fa996ed61 kernel: log uvc ep sizing 2026-01-26 20:58:48 -03:00
eb40374389 kernel: add uvc prep/queue logs 2026-01-26 00:51:11 -03:00
df2ff4cc5c kernel: regenerate uvc debug patch 2026-01-23 16:44:45 -03:00
fca161865b kernel: fix uvc debug patch format 2026-01-23 16:39:48 -03:00
dc971175d9 kernel: guard uvc queue readiness 2026-01-23 16:32:47 -03:00
213673274d kernel: guard vb2 queue before stream 2026-01-23 16:25:39 -03:00
ffc79982fc kernel: log uvc prep request sizing 2026-01-23 03:04:34 -03:00
d8a7dd5a98 kernel: fix uvc debug patch 2026-01-22 15:30:42 -03:00
48dfe4bae7 kernel: add uvc debug patch 2026-01-22 15:23:25 -03:00
8855f9ab78 uvc: parse debugfs params safely 2026-01-10 13:31:04 -03:00
cfe2430521 uvc: clamp to configfs maxpacket 2026-01-10 13:26:54 -03:00
298aeabc10 core: fix debugfs fifo parsing 2026-01-10 13:22:07 -03:00
11c615d0b7 uvc: read fifo caps from debugfs 2026-01-10 13:08:38 -03:00
b6479acf11 uvc: cap payload to fifo and fix SET_CUR 2026-01-10 05:27:28 -03:00
c7ec6caf4d server: gate UVC SET_CUR ack on env 2026-01-10 00:39:01 -03:00
06bc0cd98d server: safer gadget bring-up + kernel upgrade hook 2026-01-09 22:50:09 -03:00
a939dfcc77 uvc: ack commit after data 2026-01-09 18:16:54 -03:00
2bb9b2f196 uvc: ack SET_CUR after data 2026-01-09 18:14:03 -03:00
94cfc2d422 uvc: restore SET_CUR setup response 2026-01-09 18:04:00 -03:00
129b508123 uvc: ack SET_CUR after data 2026-01-09 17:55:36 -03:00
efa261ba48 core: skip reset when gadget bound 2026-01-09 17:55:25 -03:00
67cddf6a99 server: add watchdog fail-safes 2026-01-09 17:23:27 -03:00
4e96c271ed server: guard gadget cycling 2026-01-08 23:58:19 -03:00
0da8fbb5f7 uvc: avoid extra response after data 2026-01-08 22:19:07 -03:00
1738cf9ed0 uvc: refuse manual stop in unit 2026-01-08 21:37:30 -03:00
6f440154b7 uvc: prevent unsafe stops 2026-01-08 21:29:50 -03:00
dcdfb5cb2a uvc: log control responses 2026-01-08 21:18:54 -03:00
b173e94f7a server: avoid killing uvc helper 2026-01-08 01:53:22 -03:00
83b34a1b92 server: split uvc helper service 2026-01-08 00:59:14 -03:00
7d35115d1a server: fix unit stop hook 2026-01-08 00:42:32 -03:00
16c74879f2 core: make detach non-destructive 2026-01-08 00:20:12 -03:00
617a1d844f server: detach gadget on stop 2026-01-07 04:47:39 -03:00
86656054c5 uvc: ack SET_CUR then send status 2026-01-07 03:24:17 -03:00
1b9a0f7ee2 uvc: send SET_CUR response after data 2026-01-07 02:56:27 -03:00
cb024953f1 uvc: fix interface mapping 2026-01-07 01:38:02 -03:00
cc4173d503 server: constrain MJPEG caps for g_uvc 2026-01-07 01:02:32 -03:00
ba4b9837e7 server: default UVC ctrl len to 34 2026-01-06 22:59:49 -03:00
003bb44d55 server: drop jpegparse for MJPEG UVC 2026-01-06 22:21:11 -03:00
cb624c8249 server: log UVC appsrc push errors 2026-01-06 22:08:46 -03:00
3611db4d8c server: make UVC appsrc non-blocking by default 2026-01-06 22:02:30 -03:00
baf8374905 server: skip read-only MJPEG frame index 2026-01-06 21:45:25 -03:00
dcb6ccd157 server: avoid read-only MJPEG attrs in gadget setup 2026-01-06 21:43:50 -03:00
9ae39bc0f4 server: keep gadget bring-up alive if udev settles slowly 2026-01-06 21:41:32 -03:00
aba2a689b4 client: add MJPEG quality control 2026-01-06 21:36:13 -03:00
6295720b96 uvc: add MJPEG webcam path 2026-01-06 21:06:20 -03:00
dccc6601b1 server: default UVC control to nonblocking 2026-01-06 20:33:04 -03:00
d8b0d739a5 client: add MJPG camera input 2026-01-06 16:19:55 -03:00
6a76be3c38 server: adapt UVC ctrl length from host 2026-01-06 15:24:39 -03:00
83f2aac696 server: accept UVC probe on mismatched iface 2026-01-06 14:51:52 -03:00
f043634ce2 server: fix UVC control length 2026-01-06 14:28:00 -03:00
7ea2f83002 server: read UVC interface numbers 2026-01-06 13:22:06 -03:00
d9d2bd6c73 chore: untrack AGENTS.md 2026-01-06 12:53:04 -03:00
1bec61006d server: stabilize uvc device selection 2026-01-06 12:11:48 -03:00
3c1f647b04 server: supervise uvc helper 2026-01-06 11:48:36 -03:00
f1aaa1743d uvc: default to 25fps 2026-01-06 10:05:21 -03:00
49fdd9c3de client: allow webcam resolution overrides 2026-01-06 09:47:41 -03:00
368319af63 uvc: default to 720p gadget settings 2026-01-06 09:36:55 -03:00
54c2367c8c uvc: log probe data and accept host interval 2026-01-06 05:43:56 -03:00
41cf36a812 uvc: queue buffers for SET_CUR 2026-01-06 05:39:03 -03:00
04afb7603f uvc: log response ioctls failures 2026-01-06 05:32:55 -03:00
fb8573d806 uvc: use blocking event reads by default 2026-01-06 05:27:27 -03:00
e35af4a46d uvc: log setup/data events 2026-01-06 05:01:52 -03:00
2570ea69db uvc: clamp payload and accept host probe 2026-01-06 04:58:04 -03:00
a9f3195952 uvc: treat ENOENT as idle 2026-01-06 04:52:07 -03:00
bc14f42b8d server: keep UVC enabled in core service 2026-01-06 04:47:17 -03:00
30eab21166 server: add UVC control helper 2026-01-06 04:38:41 -03:00
27a69a8bb6 core: pin gadget to high-speed 2026-01-05 21:37:33 -03:00
da6aa6c425 core: fallback when UVC fails 2026-01-05 14:09:52 -03:00
953f29dec7 nextcloud: integration with mailu 2025-12-14 14:20:56 -03:00
2e5f162d2c install mic deps 2025-12-01 16:21:39 -03:00
103220a05a server: mic dependency updates in install script 2025-12-01 15:54:03 -03:00
3458b42a11 client: mic selection defaults improved 2025-12-01 11:38:51 -03:00
c274e8ce18 client: debugging web cam 2025-12-01 03:34:01 -03:00
5be0837f45 client: debugging lines added for hang 2025-12-01 01:29:41 -03:00
8e4b0eabeb client: debugging lines added for hang 2025-12-01 01:24:12 -03:00
d5435666f4 client: debugging lines added for hang 2025-12-01 01:21:27 -03:00
76770b9c41 client: moved handshake earlier to avoid input traps 2025-12-01 01:13:34 -03:00
35196ee8f3 client: addressed unsafe block 2025-12-01 00:14:47 -03:00
6194848b0e client: addressed warning in build 2025-12-01 00:11:23 -03:00
3aef06001b client: screen relay switched to ximagesink 2025-12-01 00:07:09 -03:00
39e4458967 client: webcam fixes on missing glimagesink property 2025-12-01 00:00:28 -03:00
1ece995ec1 client: switched to VideoOverlayExt 2025-11-30 23:52:07 -03:00
d1e86f36f1 client: switched to VideoOverlayExt 2025-11-30 23:50:09 -03:00
7585dd9430 client: switched to VideoOverlayExt 2025-11-30 23:47:41 -03:00
5d68b87dfb client: forgot some imports 2025-11-30 23:44:26 -03:00
b56789d081 client: webcam fixes on v41 constants 2025-11-30 23:41:29 -03:00
3cdf5c0718 core: permissions change, client: weyland window support 2025-11-30 23:07:31 -03:00
4f69556235 core: ignore UVC header strings errors 2025-11-30 18:28:07 -03:00
29e86791ed core: tolerate uvc control class failures 2025-11-30 18:22:48 -03:00
e5e5cd2630 core: tolerate missing uvc control speeds 2025-11-30 18:09:52 -03:00
e223d90db4 install updates - path errors fixed 2025-11-30 18:01:54 -03:00
d2677afc46 install updates - relay service update 2025-11-30 16:16:03 -03:00
45 changed files with 4626 additions and 866 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ override.toml
**/*~
*.swp
*.swo
*.md

View File

@ -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"

View File

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

View File

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

View File

@ -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(),
})
}
/// Fuzzymatch devices under `/dev/v4l/by-id`
/// Fuzzymatch 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 webcam 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"),
}
}
}

View File

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

View File

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

View File

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

View File

@ -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=<pulsedevicename>
// 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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) // 48kHz
.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) // 48kHz
.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()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 10s)
@ -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×48kHz 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" # 16bit
# 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×48kHz 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" # 16bit
# 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 (usbvideo) ------------------
mkdir -p "$G/functions/uvc.usb0"
F="$G/functions/uvc.usb0"
if [[ -z $DISABLE_UVC ]]; then
# ----------------------- UVC function (usbvideo) ------------------
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, 16bpp) ──────────────
mkdir -p "$F/streaming/uncompressed/u"
# GUID = {59555932-0000-0010-8000-00aa00389b71} (“YUY2”) littleendian
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 (index1 @30fps) ────────────────────────
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" # 30fps
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 (absolutepaths, 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.5s 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"
# perspeed 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. VideoControl 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 perspeed 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 "[lesavkacore] ❌ $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

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

View 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

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

View 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

View 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

View File

@ -0,0 +1,11 @@
[Unit]
Description=Lesavka reboot watchdog timer
[Timer]
OnBootSec=15min
OnUnitActiveSec=15min
AccuracySec=30s
Persistent=true
[Install]
WantedBy=timers.target

View File

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

View File

@ -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..."

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

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

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

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

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

View File

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

View File

@ -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)?;
// -------- cliptap (minute dumps) ------------
tap.lock().unwrap().feed(map.as_slice());
// -------- cliptap (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 nonblocking; 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 nonblocking; 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> {
// ────────────────────── minuteclip 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() {

File diff suppressed because it is too large Load Diff

260
server/src/camera.rs Normal file
View 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())
}

View File

@ -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 6s - 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 6s - 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)
})
}
}

View File

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

View File

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

View File

@ -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 { // ≈10s
for attempt in 1..=200 {
// ≈10s
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 outputcapable 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(())
}

View File

@ -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 AnnexB 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,
])?;
}
}
// Upcast 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 multicam)
sink: CameraSink,
id: u32, // gRPC “id” (for future multicam)
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);
}
}

View File

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