Compare commits
23 Commits
master
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
|
|||||||
38
AGENTS.md
Normal file
38
AGENTS.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
Rust is split into three crates: `server/` (gRPC relay and device control with async tests in `server/tests`), `client/` (GTK + GStreamer desktop agent; `src/input/` and `src/output/` hold HID and media pipelines), and `common/` (shared protobuf schema in `common/proto/lesavka.proto`, compiled via `build.rs`). `scripts/install/*.sh` provision Arch-based hosts, `scripts/manual/*.sh` run hardware diagnostics, and `scripts/daemon/lesavka-core.sh` handles USB gadget bring-up.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- `cargo fmt --all` — run rustfmt across every crate before committing.
|
||||||
|
- `cargo clippy --workspace --all-targets -D warnings` — enforce lint cleanliness against desktop, server, and shared code.
|
||||||
|
- `cargo build --workspace --all-targets` — compile every binary/lib in debug mode; prefer `--release` for deployment artifacts referenced by install scripts.
|
||||||
|
- `cargo test --workspace` — executes unit tests plus async scenarios such as `server/tests/hid.rs`.
|
||||||
|
- `cargo run -p lesavka_server` / `cargo run -p lesavka_client` — start each side locally; set `LESAVKA_SERVER_ADDR` in the environment when pointing the client at a remote relay.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
Follow Rust 2024 idioms: four-space indentation, `snake_case` for modules/functions, and `CamelCase` for types and protobuf messages. Keep gRPC traits in `common` authoritative and prefer `tonic` streams over manual channels. Never hand-edit generated files in `target/` or the `OUT_DIR`; update `.proto` files and rebuild instead. Document hardware constants inline.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
Async tests should use `#[tokio::test(flavor = "multi_thread")]` when they spawn background servers, mirroring `hid_roundtrip`. Give tests descriptive names and keep them near the code they exercise (`client/src/tests/`, `server/tests/`). Cover new RPCs end-to-end by instantiating the tonic server in-process and asserting on gRPC clients.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
Recent history favors terse, imperative summaries (“usb fix”), so keep titles under ~50 characters and describe the subsystem. Commits should stay focused (proto update plus regeneration in one commit, client UI tweaks in another). Pull requests must include context, manual verification steps (`cargo test --workspace`, notable hardware checks), relevant logs or screenshots, and linked issues. Call out script or deployment changes so operators know when to re-run `scripts/install/server.sh` or update systemd units.
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
For reproducible installs, prefer `scripts/install/server.sh --ref <branch>` to provision capture nodes (udev rules, GStreamer stack) and drop `lesavka-core.service`, while `scripts/install/client.sh` handles desktop prerequisites and systemd activation. When editing these scripts, test on an Arch VM. Keep secret material (VPN credentials, Tailscale auth) out of git and load them via environment variables or systemd drop-ins.
|
||||||
|
|
||||||
|
Install scripts must stay idempotent and self-contained: add any new runtime dependencies (e.g., audio/ALSA tools needed for mic bring-up) to the respective install script so a rerun on an arbitrary server/client brings the box to a ready state.
|
||||||
|
|
||||||
|
## Current Setup
|
||||||
|
- Server runs on Raspberry Pi 5 host `titan-jh` (ssh alias configured) and is already provisioned; client setup happens on this machine.
|
||||||
|
- HDMI capture uses two USB AVerMedia/GC311 devices with power/data split; AC relays on GPIO keep them off unless needed (control scripts already on the Pi).
|
||||||
|
- USB gadget exposes HID/UAC/UVC; webcam feed is expected from the client into the gadget UVC node (default `LESAVKA_UVC_DEV=/dev/video4`).
|
||||||
|
- Client ↔ server address: `http://38.28.125.112:50051` (was `64.25.10.31`).
|
||||||
|
- Webcam uplink is the remaining piece to confirm end-to-end after client install; server-side audio/video capture is in place.
|
||||||
|
|
||||||
|
## Session Notes (Dec 1, 2025)
|
||||||
|
- Server change pending deployment: `server/src/main.rs` now auto-picks UVC sink via `LESAVKA_UVC_DEV` or first `:video_output:` node (titan-jh gadget is `/dev/video0`); errors if none. `stream_microphone` honors `LESAVKA_UAC_DEV`.
|
||||||
|
- Client change: mic capture falls back to first non-`.monitor` Pulse source if `LESAVKA_MIC_SOURCE` missing/not found.
|
||||||
|
- Action: rerun `scripts/install/server.sh --ref feature/webcam-caps` on titan-jh (optionally set `LESAVKA_UVC_DEV=/dev/video0`, `LESAVKA_UAC_DEV=plughw:UAC2Gadget,0`), then restart service. Check `/tmp/lesavka-server.log` for “stream_camera using UVC sink”.
|
||||||
|
- Tethys display: SDDM running but greeter not; capture shows black+cursor. Need greeter/desktop on HDMI head (GC311 input) once display/cable is correct; investigate sddm greeter crash after reboot.
|
||||||
@ -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,30 +1,28 @@
|
|||||||
#![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::{
|
||||||
input::inputs::InputAggregator,
|
handshake, input::camera::CameraCapture, input::inputs::InputAggregator,
|
||||||
input::microphone::MicrophoneCapture,
|
input::microphone::MicrophoneCapture, output::audio::AudioOut, output::video::MonitorWindow,
|
||||||
input::camera::CameraCapture,
|
};
|
||||||
output::video::MonitorWindow,
|
|
||||||
output::audio::AudioOut};
|
|
||||||
|
|
||||||
pub struct LesavkaClientApp {
|
pub struct LesavkaClientApp {
|
||||||
aggregator: Option<InputAggregator>,
|
aggregator: Option<InputAggregator>,
|
||||||
@ -45,14 +43,20 @@ 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 = InputAggregator::new(dev_mode, kbd_tx.clone(), mou_tx.clone());
|
||||||
agg.init()?; // grab devices immediately
|
|
||||||
|
|
||||||
Ok(Self { aggregator: Some(agg), server_addr, dev_mode, kbd_tx, mou_tx })
|
Ok(Self {
|
||||||
|
aggregator: Some(agg),
|
||||||
|
server_addr,
|
||||||
|
dev_mode,
|
||||||
|
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);
|
||||||
|
|
||||||
@ -64,13 +68,15 @@ impl LesavkaClientApp {
|
|||||||
.connect_lazy();
|
.connect_lazy();
|
||||||
|
|
||||||
let vid_ep = Channel::from_shared(self.server_addr.clone())?
|
let vid_ep = Channel::from_shared(self.server_addr.clone())?
|
||||||
.initial_connection_window_size(4<<20)
|
.initial_connection_window_size(4 << 20)
|
||||||
.initial_stream_window_size(4<<20)
|
.initial_stream_window_size(4 << 20)
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.connect_lazy();
|
.connect_lazy();
|
||||||
|
|
||||||
/*────────── input aggregator task ─────────────*/
|
/*────────── input aggregator task (grab after handshake) ─────────────*/
|
||||||
let aggregator = self.aggregator.take().expect("InputAggregator present");
|
let mut aggregator = self.aggregator.take().expect("InputAggregator present");
|
||||||
|
info!("⌛ grabbing input devices…");
|
||||||
|
aggregator.init()?; // grab devices now that handshake succeeded
|
||||||
let agg_task = tokio::spawn(async move {
|
let agg_task = tokio::spawn(async move {
|
||||||
let mut a = aggregator;
|
let mut a = aggregator;
|
||||||
a.run().await
|
a.run().await
|
||||||
@ -96,19 +102,27 @@ impl LesavkaClientApp {
|
|||||||
|
|
||||||
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 {
|
||||||
@ -137,7 +151,7 @@ impl LesavkaClientApp {
|
|||||||
/*────────── 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() {
|
||||||
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(),
|
||||||
)?);
|
)?);
|
||||||
tokio::spawn(Self::cam_loop(vid_ep.clone(), cam));
|
tokio::spawn(Self::cam_loop(vid_ep.clone(), cam));
|
||||||
}
|
}
|
||||||
@ -173,13 +187,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 +210,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 +228,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 +274,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 +317,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 +352,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,7 +2,10 @@
|
|||||||
#![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, Copy, Debug)]
|
||||||
pub struct PeerCaps {
|
pub struct PeerCaps {
|
||||||
@ -11,19 +14,43 @@ pub struct PeerCaps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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")
|
||||||
|
.tcp_nodelay(true)
|
||||||
|
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||||
|
.connect_timeout(Duration::from_secs(5));
|
||||||
|
|
||||||
|
let channel = timeout(Duration::from_secs(8), ep.connect())
|
||||||
|
.await
|
||||||
|
.expect("handshake connect timeout")
|
||||||
|
.expect("handshake connect failed");
|
||||||
|
|
||||||
|
info!("🤝 handshake channel connected");
|
||||||
|
let mut cli = HandshakeClient::new(channel);
|
||||||
|
info!("🤝 fetching capabilities…");
|
||||||
|
|
||||||
|
match timeout(Duration::from_secs(5), cli.get_capabilities(pb::Empty {})).await {
|
||||||
|
Ok(Ok(rsp)) => {
|
||||||
|
let caps = PeerCaps {
|
||||||
camera: rsp.get_ref().camera,
|
camera: rsp.get_ref().camera,
|
||||||
microphone: rsp.get_ref().microphone,
|
microphone: rsp.get_ref().microphone,
|
||||||
},
|
};
|
||||||
Err(e) if e.code() == Code::Unimplemented => {
|
info!(?caps, "🤝 handshake ok");
|
||||||
// ↺ old server – pretend it supports nothing special.
|
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,14 +1,14 @@
|
|||||||
// 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;
|
||||||
|
|
||||||
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,
|
||||||
}
|
}
|
||||||
@ -17,10 +17,12 @@ impl CameraCapture {
|
|||||||
pub fn new(device_fragment: Option<&str>) -> anyhow::Result<Self> {
|
pub fn new(device_fragment: Option<&str>) -> 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 (enc, raw_caps) = Self::pick_encoder();
|
||||||
// (NVIDIA → VA-API → software x264).
|
// (NVIDIA → VA-API → software x264).
|
||||||
@ -88,23 +90,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();
|
||||||
@ -120,7 +157,7 @@ impl CameraCapture {
|
|||||||
fn pick_encoder() -> (&'static str, &'static str) {
|
fn pick_encoder() -> (&'static str, &'static str) {
|
||||||
let encoders = &[
|
let encoders = &[
|
||||||
("nvh264enc", "video/x-raw(memory:NVMM),format=NV12"),
|
("nvh264enc", "video/x-raw(memory:NVMM),format=NV12"),
|
||||||
("vaapih264enc","video/x-raw,format=NV12"),
|
("vaapih264enc", "video/x-raw,format=NV12"),
|
||||||
("v4l2h264enc", "video/x-raw"), // RPi, Jetson, etc.
|
("v4l2h264enc", "video/x-raw"), // RPi, Jetson, etc.
|
||||||
("x264enc", "video/x-raw"), // software
|
("x264enc", "video/x-raw"), // software
|
||||||
];
|
];
|
||||||
@ -135,12 +172,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,13 +105,15 @@ 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 = [
|
||||||
self.buttons,
|
self.buttons,
|
||||||
self.dx.clamp(-127,127) as u8,
|
self.dx.clamp(-127, 127) as u8,
|
||||||
self.dy.clamp(-127,127) as u8,
|
self.dy.clamp(-127, 127) as u8,
|
||||||
self.wheel as u8,
|
self.wheel as u8,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -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;
|
||||||
|
|||||||
@ -28,11 +28,14 @@ async fn main() -> Result<()> {
|
|||||||
/*------------- 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 +72,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,21 +4,34 @@ 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
|
||||||
1 => {
|
1 => {
|
||||||
// One monitor: side-by-side layout
|
// One monitor: side-by-side layout
|
||||||
let m = &monitors[0].geometry;
|
let m = &monitors[0].geometry;
|
||||||
let total_native_width: i32 = streams.iter().map(|(_,w,_)| *w).sum();
|
let total_native_width: i32 = streams.iter().map(|(_, w, _)| *w).sum();
|
||||||
let scale = f64::min(
|
let scale = f64::min(
|
||||||
m.width() as f64 / total_native_width as f64,
|
m.width() as f64 / total_native_width as f64,
|
||||||
m.height() as f64 / streams[0].2 as f64,
|
m.height() as f64 / streams[0].2 as f64,
|
||||||
@ -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()
|
||||||
|
|||||||
@ -161,44 +161,45 @@ for s in fs hs ss; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# ── 4. Video‑Control interface ─────────────────────────────────────
|
# ── 4. Video‑Control interface ─────────────────────────────────────
|
||||||
|
set +e # relax errors for configfs quirks
|
||||||
mkdir -p "$F/control/header/h" # real dir – mandatory
|
mkdir -p "$F/control/header/h" # real dir – mandatory
|
||||||
mkdir -p "$F/control/class" # parent once
|
mkdir -p "$F/control/class" # parent once
|
||||||
|
mkdir -p "$F/control/class/fs" "$F/control/class/hs" "$F/control/class/ss" 2>/dev/null || true
|
||||||
|
|
||||||
echo "[lesavka-core] ★ directory tree just before links:"
|
echo "[lesavka-core] ★ directory tree just before links:"
|
||||||
tree -L 3 "$F/control" | sed 's/^/[lesavka-core] /'
|
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)
|
# best-effort: some UDCs reject certain speeds; skip on failure
|
||||||
mkdir -p "$F/control/class/$s" # harmless if already there
|
if mkdir -p "$F/control/class/$s" 2>/dev/null; then
|
||||||
|
ln -snf "$F/control/header/h" "$F/control/class/$s/h" 2>/dev/null || \
|
||||||
# create the mandatory *symlink inside* that directory:
|
log "⚠️ control/class/$s/h link missing (continuing)"
|
||||||
ln -snf ../../header/h "$F/control/class/$s/h"
|
else
|
||||||
|
log "⚠️ skipping control/class/$s (mkdir failed)"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
for s in fs hs ss; do
|
for s in fs hs ss; do
|
||||||
[ -L "$F/control/class/$s/h" ] || {
|
[ -L "$F/control/class/$s/h" ] || log "⚠️ $s/h link missing (continuing)"
|
||||||
echo "[lesavka‑core] ❌ $s/h link missing, aborting" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "[lesavka-core] ★ directory tree just before bind:"
|
echo "[lesavka-core] ★ directory tree just before bind:"
|
||||||
tree -L 3 "$F/control" | sed 's/^/[lesavka-core] /'
|
tree -L 3 "$F/control" | sed 's/^/[lesavka-core] /'
|
||||||
|
|
||||||
for s in fs hs ss; do
|
for s in fs hs ss; do
|
||||||
[ -L "$F/control/class/$s" ] || {
|
[ -L "$F/control/class/$s" ] || log "⚠️ $s link missing (continuing)"
|
||||||
echo "[lesavka-core] ❌ $s link missing, gadget aborting" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
done
|
done
|
||||||
|
set -e # back to strict mode
|
||||||
|
|
||||||
# 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"
|
set +e
|
||||||
echo "Lesavka UVC" >"$F/control/header/strings/0x409/label"
|
mkdir -p "$F/control/header/strings/0x409" 2>/dev/null || log "⚠️ skipping control/header strings (mkdir failed)"
|
||||||
|
echo "Lesavka UVC" >"$F/control/header/strings/0x409/label" 2>/dev/null || log "⚠️ unable to set UVC label (continuing)"
|
||||||
|
set -e
|
||||||
|
|
||||||
# ----------------------- configuration -----------------------------
|
# ----------------------- configuration -----------------------------
|
||||||
mkdir -p "$G/configs/c.1/strings/0x409"
|
mkdir -p "$G/configs/c.1/strings/0x409"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -20,10 +20,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 +44,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 +113,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
|
||||||
@ -124,7 +148,8 @@ 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
|
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_SYS_MODULE
|
||||||
|
AmbientCapabilities=CAP_SYS_MODULE
|
||||||
MountFlags=slave
|
MountFlags=slave
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
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"))?;
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
// 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::{
|
||||||
|
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 +52,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 +69,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 +91,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 +112,9 @@ 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")))?;
|
})?;
|
||||||
|
|
||||||
/* 1 - detach gadget */
|
/* 1 - detach gadget */
|
||||||
info!("🔌 detaching gadget from {ctrl}");
|
info!("🔌 detaching gadget from {ctrl}");
|
||||||
@ -112,12 +126,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 +174,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 +198,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 +211,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 +225,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"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,9 @@ impl Handshake for HandshakeSvc {
|
|||||||
|
|
||||||
impl HandshakeSvc {
|
impl HandshakeSvc {
|
||||||
pub fn server() -> HandshakeServer<Self> {
|
pub fn server() -> HandshakeServer<Self> {
|
||||||
HandshakeServer::new(Self { camera: true, microphone: true })
|
HandshakeServer::new(Self {
|
||||||
|
camera: true,
|
||||||
|
microphone: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// server/src/lib.rs
|
// server/src/lib.rs
|
||||||
|
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod video;
|
|
||||||
pub mod gadget;
|
pub mod gadget;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
|
pub mod video;
|
||||||
|
|||||||
@ -2,33 +2,27 @@
|
|||||||
// 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::sync::atomic::AtomicBool;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc};
|
||||||
|
use tokio::{fs::OpenOptions, io::AsyncWriteExt, 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, gadget::UsbGadget, handshake::HandshakeSvc, video};
|
||||||
|
|
||||||
/*──────────────── constants ────────────────*/
|
/*──────────────── constants ────────────────*/
|
||||||
/// **false** = never reset automatically.
|
/// **false** = never reset automatically.
|
||||||
@ -39,22 +33,19 @@ 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 +60,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,13 +83,43 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// walk /dev/video* via udev and look for an output‑capable node (gadget exposes one)
|
||||||
|
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:") {
|
||||||
|
if let Some(node) = dev.devnode() {
|
||||||
|
return Ok(node.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"no video_output v4l2 node found; set LESAVKA_UVC_DEV"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/*──────────────── Handler ───────────────────*/
|
/*──────────────── Handler ───────────────────*/
|
||||||
struct Handler {
|
struct Handler {
|
||||||
kb: Arc<Mutex<tokio::fs::File>>,
|
kb: Arc<Mutex<tokio::fs::File>>,
|
||||||
@ -140,8 +165,8 @@ impl Relay for Handler {
|
|||||||
/* existing streams ─ unchanged, except: no more auto-reset */
|
/* existing streams ─ unchanged, except: no more auto-reset */
|
||||||
type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>;
|
type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>;
|
||||||
type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>;
|
type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>;
|
||||||
type CaptureVideoStream = Pin<Box<dyn Stream<Item=Result<VideoPacket,Status>> + Send>>;
|
type CaptureVideoStream = Pin<Box<dyn Stream<Item = Result<VideoPacket, Status>> + Send>>;
|
||||||
type CaptureAudioStream = Pin<Box<dyn Stream<Item=Result<AudioPacket,Status>> + Send>>;
|
type CaptureAudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, Status>> + Send>>;
|
||||||
type StreamMicrophoneStream = ReceiverStream<Result<Empty, Status>>;
|
type StreamMicrophoneStream = ReceiverStream<Result<Empty, Status>>;
|
||||||
type StreamCameraStream = ReceiverStream<Result<Empty, Status>>;
|
type StreamCameraStream = ReceiverStream<Result<Empty, Status>>;
|
||||||
|
|
||||||
@ -191,9 +216,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 +229,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);
|
||||||
@ -225,12 +251,12 @@ impl Relay for Handler {
|
|||||||
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
|
// map gRPC camera id → UVC device
|
||||||
let uvc = std::env::var("LESAVKA_UVC_DEV")
|
let uvc = pick_uvc_device().map_err(|e| Status::internal(format!("{e:#}")))?;
|
||||||
.unwrap_or_else(|_| "/dev/video4".into());
|
info!(%uvc, "🎥 stream_camera using UVC sink");
|
||||||
|
|
||||||
// build once
|
// build once
|
||||||
let relay = video::CameraRelay::new(0, &uvc)
|
let relay =
|
||||||
.map_err(|e| Status::internal(format!("{e:#}")))?;
|
video::CameraRelay::new(0, &uvc).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 +297,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 +307,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(_) => {
|
||||||
@ -320,11 +342,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
info!("🌐 lesavka-server listening on 0.0.0.0:50051");
|
info!("🌐 lesavka-server listening on 0.0.0.0:50051");
|
||||||
Server::builder()
|
Server::builder()
|
||||||
.tcp_nodelay(true)
|
.tcp_nodelay(true)
|
||||||
.max_frame_size(Some(2*1024*1024))
|
.max_frame_size(Some(2 * 1024 * 1024))
|
||||||
.add_service(RelayServer::new(handler))
|
.add_service(RelayServer::new(handler))
|
||||||
.add_service(HandshakeSvc::server())
|
.add_service(HandshakeSvc::server())
|
||||||
.add_service(ReflBuilder::configure().build_v1().unwrap())
|
.add_service(ReflBuilder::configure().build_v1().unwrap())
|
||||||
.serve(([0,0,0,0], 50051).into())
|
.serve(([0, 0, 0, 0], 50051).into())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,52 @@
|
|||||||
// server/src/video.rs
|
// server/src/video.rs
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use futures_util::Stream;
|
||||||
|
use gst::prelude::*;
|
||||||
|
use gst::MessageView::*;
|
||||||
|
use gst::MessageView;
|
||||||
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 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;
|
|
||||||
|
|
||||||
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 dev_mode_enabled() -> bool {
|
||||||
|
*DEV_MODE
|
||||||
|
.get_or_init(|| std::env::var("LESAVKA_DEV_MODE").is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
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,11 +70,7 @@ 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")?;
|
||||||
|
|
||||||
@ -79,8 +108,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() {
|
||||||
@ -139,10 +170,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 +187,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -175,7 +207,11 @@ pub async fn eye_ball(
|
|||||||
/* -------- 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 +238,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 {
|
||||||
@ -225,16 +270,36 @@ impl WebcamSink {
|
|||||||
gst::init()?;
|
gst::init()?;
|
||||||
|
|
||||||
let pipeline = gst::Pipeline::new();
|
let pipeline = gst::Pipeline::new();
|
||||||
|
|
||||||
|
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", 1280i32)
|
||||||
|
.field("height", 720i32)
|
||||||
|
.field("framerate", gst::Fraction::new(30, 1))
|
||||||
|
.build();
|
||||||
|
|
||||||
let src = gst::ElementFactory::make("appsrc")
|
let src = gst::ElementFactory::make("appsrc")
|
||||||
.build()?
|
.build()?
|
||||||
.downcast::<gst_app::AppSrc>()
|
.downcast::<gst_app::AppSrc>()
|
||||||
.expect("appsrc");
|
.expect("appsrc");
|
||||||
src.set_is_live(true);
|
src.set_is_live(true);
|
||||||
src.set_format(gst::Format::Time);
|
src.set_format(gst::Format::Time);
|
||||||
|
src.set_caps(Some(&caps_h264));
|
||||||
|
src.set_property("block", &true);
|
||||||
|
|
||||||
let h264parse = gst::ElementFactory::make("h264parse").build()?;
|
let h264parse = gst::ElementFactory::make("h264parse").build()?;
|
||||||
let decoder = gst::ElementFactory::make("v4l2h264dec").build()?;
|
let decoder_name = Self::pick_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 convert = gst::ElementFactory::make("videoconvert").build()?;
|
||||||
|
let caps = gst::ElementFactory::make("capsfilter")
|
||||||
|
.property("caps", &raw_caps)
|
||||||
|
.build()?;
|
||||||
let sink = gst::ElementFactory::make("v4l2sink")
|
let sink = gst::ElementFactory::make("v4l2sink")
|
||||||
.property("device", &uvc_dev)
|
.property("device", &uvc_dev)
|
||||||
.property("sync", &false)
|
.property("sync", &false)
|
||||||
@ -242,22 +307,48 @@ impl WebcamSink {
|
|||||||
|
|
||||||
// Up‑cast to &gst::Element for the collection macros
|
// Up‑cast to &gst::Element for the collection macros
|
||||||
pipeline.add_many(&[
|
pipeline.add_many(&[
|
||||||
src.upcast_ref(), &h264parse, &decoder, &convert, &sink
|
src.upcast_ref(),
|
||||||
|
&h264parse,
|
||||||
|
&decoder,
|
||||||
|
&convert,
|
||||||
|
&caps,
|
||||||
|
&sink,
|
||||||
])?;
|
])?;
|
||||||
gst::Element::link_many(&[
|
gst::Element::link_many(&[
|
||||||
src.upcast_ref(), &h264parse, &decoder, &convert, &sink
|
src.upcast_ref(),
|
||||||
|
&h264parse,
|
||||||
|
&decoder,
|
||||||
|
&convert,
|
||||||
|
&caps,
|
||||||
|
&sink,
|
||||||
])?;
|
])?;
|
||||||
pipeline.set_state(gst::State::Playing)?;
|
pipeline.set_state(gst::State::Playing)?;
|
||||||
|
|
||||||
Ok(Self { appsrc: src, _pipe: pipeline })
|
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);
|
let _ = self.appsrc.push_buffer(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pick_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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*─────────────────────────────────*/
|
/*─────────────────────────────────*/
|
||||||
@ -281,7 +372,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 +389,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 +400,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