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