2025-06-01 13:31:22 -05:00
#!/usr/bin/env bash
2025-06-27 22:51:50 -05:00
# scripts/install/server.sh - install and setup all server related apps and environments
2025-06-01 13:31:22 -05:00
set -euo pipefail
2025-06-01 21:30:55 -05:00
ORIG_USER = ${ SUDO_USER :- $( id -un) }
2026-04-16 14:29:16 -03:00
SCRIPT_DIR = $( cd -- " $( dirname -- " ${ BASH_SOURCE [0] } " ) " && pwd )
SCRIPT_REPO_ROOT = $( cd -- " $SCRIPT_DIR /../.. " && pwd )
DEFAULT_REPO_URL = ssh://git@scm.bstein.dev:2242/bstein/lesavka.git
2026-04-16 13:54:25 -03:00
export TMPDIR = ${ TMPDIR :- /var/tmp }
2025-06-01 21:30:55 -05:00
2025-07-04 18:24:48 -05:00
REF = ${ LESAVKA_REF :- master } # fallback
2026-04-16 14:29:16 -03:00
REPO_URL = ${ LESAVKA_REPO_URL :- }
USER_HOME = $( getent passwd " $ORIG_USER " | cut -d: -f6)
2026-04-27 20:36:15 -03:00
INSTALL_UVC_CODEC = ${ LESAVKA_INSTALL_UVC_CODEC :- mjpeg }
2026-04-28 02:29:32 -03:00
INSTALL_SERVER_BIND_ADDR = ${ LESAVKA_INSTALL_SERVER_BIND_ADDR :- 0 .0.0.0 : 50051 }
2026-04-30 08:16:57 -03:00
LESAVKA_TLS_DIR = ${ LESAVKA_TLS_DIR :- /etc/lesavka/pki }
LESAVKA_CLIENT_BUNDLE = ${ LESAVKA_CLIENT_BUNDLE :- /etc/lesavka/lesavka-client-pki.tar.gz }
2026-05-01 13:52:36 -03:00
DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US = 720000
LEGACY_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US = -45000
resolve_upstream_audio_playout_offset_us( ) {
if [ [ -n ${ LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US :- } ] ] ; then
printf '%s\n' " $LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US "
return 0
fi
if [ [ ${ LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US :- } = = " $LEGACY_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US " ] ] ; then
echo "⚠️ migrating legacy upstream audio playout offset -45ms to +720ms for MJPEG/UVC." >& 2
echo " Use LESAVKA_INSTALL_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=-45000 only if you intentionally need the old value." >& 2
printf '%s\n' " $DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US "
return 0
fi
if [ [ -n ${ LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US :- } ] ] ; then
printf '%s\n' " $LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US "
return 0
fi
printf '%s\n' " $DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US "
}
2025-07-04 18:24:48 -05:00
2026-04-26 00:27:43 -03:00
manifest_package_version( ) {
local manifest = $1
[ [ -f $manifest ] ] || return 1
awk -F'"' '
$0 ~ /^\[ package\] / { in_package = 1; next }
in_package && $0 ~ /^\[ / { exit }
in_package && $0 ~ /^[ [ :space:] ] *version[ [ :space:] ] *= / { print $2 ; exit }
' " $manifest "
}
2026-04-27 15:50:29 -03:00
render_uvc_env_file( ) {
cat <<EOF
# generated by lesavka/scripts/install/server.sh
# Edit only for local UVC hardware overrides; rerunning the installer refreshes defaults.
LESAVKA_UVC_DEBUG = ${ LESAVKA_UVC_DEBUG :- 1 }
2026-04-27 21:36:30 -03:00
LESAVKA_UVC_MAXPACKET = ${ LESAVKA_UVC_MAXPACKET :- 1024 }
2026-04-27 15:50:29 -03:00
LESAVKA_UVC_LIMIT_PCT = ${ LESAVKA_UVC_LIMIT_PCT :- 100 }
LESAVKA_UVC_FPS = ${ LESAVKA_UVC_FPS :- 20 }
LESAVKA_UVC_INTERVAL = ${ LESAVKA_UVC_INTERVAL :- 500000 }
LESAVKA_UVC_WIDTH = ${ LESAVKA_UVC_WIDTH :- 640 }
LESAVKA_UVC_HEIGHT = ${ LESAVKA_UVC_HEIGHT :- 480 }
2026-04-27 20:36:15 -03:00
LESAVKA_UVC_CODEC = ${ INSTALL_UVC_CODEC }
2026-04-27 15:50:29 -03:00
LESAVKA_UVC_BLOCKING = ${ LESAVKA_UVC_BLOCKING :- 1 }
2026-04-28 21:39:46 -03:00
LESAVKA_UVC_CONTROL_READ_ONLY = ${ LESAVKA_UVC_CONTROL_READ_ONLY :- 0 }
2026-04-27 15:50:29 -03:00
LESAVKA_UVC_MAXBURST = ${ LESAVKA_UVC_MAXBURST :- 0 }
EOF
}
2026-04-30 08:16:57 -03:00
append_san_entry( ) {
local value = $1
[ [ -n $value ] ] || return 0
case " $value " in
IP:*)
TLS_SAN_IPS += ( " ${ value #IP : } " )
; ;
DNS:*)
TLS_SAN_DNS += ( " ${ value #DNS : } " )
; ;
*[ !0-9.] *)
TLS_SAN_DNS += ( " $value " )
; ;
*)
TLS_SAN_IPS += ( " $value " )
; ;
esac
}
render_server_cert_ext( ) {
local ext_file = $1
local dns_index = 1
local ip_index = 1
{
echo "basicConstraints = CA:FALSE"
echo "keyUsage = digitalSignature,keyEncipherment"
echo "extendedKeyUsage = serverAuth"
echo "subjectAltName = @alt_names"
echo "[alt_names]"
local value
for value in " ${ TLS_SAN_DNS [@] } " ; do
[ [ -n $value ] ] || continue
printf 'DNS.%d = %s\n' " $dns_index " " $value "
dns_index = $(( dns_index + 1 ))
done
for value in " ${ TLS_SAN_IPS [@] } " ; do
[ [ -n $value ] ] || continue
printf 'IP.%d = %s\n' " $ip_index " " $value "
ip_index = $(( ip_index + 1 ))
done
} >" $ext_file "
}
ensure_server_tls_pki( ) {
echo "==> 5c. TLS/mTLS identity"
sudo install -d -m 0750 " $LESAVKA_TLS_DIR "
if ! sudo test -s " $LESAVKA_TLS_DIR /ca.key " || ! sudo test -s " $LESAVKA_TLS_DIR /ca.crt " ; then
echo " ↪ generating Lesavka local CA"
sudo openssl genrsa -out " $LESAVKA_TLS_DIR /ca.key " 4096 >/dev/null 2>& 1
sudo openssl req -x509 -new -nodes \
-key " $LESAVKA_TLS_DIR /ca.key " \
-sha256 -days " ${ LESAVKA_TLS_CA_DAYS :- 3650 } " \
-subj "/CN=Lesavka Local Relay CA" \
-out " $LESAVKA_TLS_DIR /ca.crt " >/dev/null 2>& 1
fi
TLS_SAN_DNS = ( lesavka-server " $( hostname -s 2>/dev/null || true ) " " $( hostname -f 2>/dev/null || true ) " )
TLS_SAN_IPS = ( 127.0.0.1 38.28.125.112)
local extra_san
IFS = ',' read -r -a extra_san <<< " ${ LESAVKA_TLS_SAN :- } "
local san
for san in " ${ extra_san [@] } " ; do
append_san_entry " ${ san //[[ : space : ]]/ } "
done
local ext_file client_ext_file
ext_file = $( mktemp)
client_ext_file = $( mktemp)
render_server_cert_ext " $ext_file "
{
echo "basicConstraints = CA:FALSE"
echo "keyUsage = digitalSignature,keyEncipherment"
echo "extendedKeyUsage = clientAuth"
} >" $client_ext_file "
if ! sudo test -s " $LESAVKA_TLS_DIR /server.key " || ! sudo test -s " $LESAVKA_TLS_DIR /server.crt " ; then
echo " ↪ generating server certificate"
sudo openssl genrsa -out " $LESAVKA_TLS_DIR /server.key " 2048 >/dev/null 2>& 1
sudo openssl req -new \
-key " $LESAVKA_TLS_DIR /server.key " \
-subj "/CN=lesavka-server" \
-out " $LESAVKA_TLS_DIR /server.csr " >/dev/null 2>& 1
sudo openssl x509 -req \
-in " $LESAVKA_TLS_DIR /server.csr " \
-CA " $LESAVKA_TLS_DIR /ca.crt " \
-CAkey " $LESAVKA_TLS_DIR /ca.key " \
-CAcreateserial \
-out " $LESAVKA_TLS_DIR /server.crt " \
-days " ${ LESAVKA_TLS_CERT_DAYS :- 825 } " \
-sha256 \
-extfile " $ext_file " >/dev/null 2>& 1
sudo rm -f " $LESAVKA_TLS_DIR /server.csr "
fi
if ! sudo test -s " $LESAVKA_TLS_DIR /client.key " || ! sudo test -s " $LESAVKA_TLS_DIR /client.crt " ; then
echo " ↪ generating default client certificate"
sudo openssl genrsa -out " $LESAVKA_TLS_DIR /client.key " 2048 >/dev/null 2>& 1
sudo openssl req -new \
-key " $LESAVKA_TLS_DIR /client.key " \
-subj "/CN=lesavka-client" \
-out " $LESAVKA_TLS_DIR /client.csr " >/dev/null 2>& 1
sudo openssl x509 -req \
-in " $LESAVKA_TLS_DIR /client.csr " \
-CA " $LESAVKA_TLS_DIR /ca.crt " \
-CAkey " $LESAVKA_TLS_DIR /ca.key " \
-CAcreateserial \
-out " $LESAVKA_TLS_DIR /client.crt " \
-days " ${ LESAVKA_TLS_CERT_DAYS :- 825 } " \
-sha256 \
-extfile " $client_ext_file " >/dev/null 2>& 1
sudo rm -f " $LESAVKA_TLS_DIR /client.csr "
fi
sudo chmod 0600 " $LESAVKA_TLS_DIR / " *.key
sudo chmod 0644 " $LESAVKA_TLS_DIR / " *.crt
rm -f " $ext_file " " $client_ext_file "
local bundle_tmp
bundle_tmp = $( mktemp -d)
sudo cp " $LESAVKA_TLS_DIR /ca.crt " " $bundle_tmp /ca.crt "
sudo cp " $LESAVKA_TLS_DIR /client.crt " " $bundle_tmp /client.crt "
sudo cp " $LESAVKA_TLS_DIR /client.key " " $bundle_tmp /client.key "
sudo tar -C " $bundle_tmp " -czf " $LESAVKA_CLIENT_BUNDLE " ca.crt client.crt client.key
2026-04-30 11:38:16 -03:00
sudo chown " $ORIG_USER " :" $ORIG_USER " " $LESAVKA_CLIENT_BUNDLE "
sudo chmod 0600 " $LESAVKA_CLIENT_BUNDLE "
2026-04-30 08:16:57 -03:00
sudo rm -rf " $bundle_tmp "
echo " ↪ client enrollment bundle: $LESAVKA_CLIENT_BUNDLE "
}
2026-04-27 16:05:42 -03:00
find_uvc_output_node( ) {
local by_path_root = /dev/v4l/by-path
local ctrl = ""
ctrl = $( ls /sys/class/udc 2>/dev/null | head -n1 || true )
if [ [ -n $ctrl && -e " $by_path_root /platform- $ctrl -video-index0 " ] ] ; then
printf '%s\n' " $by_path_root /platform- $ctrl -video-index0 "
return 0
fi
local candidate
shopt -s nullglob
for candidate in " $by_path_root " /platform-*-video-index0; do
printf '%s\n' " $candidate "
shopt -u nullglob
return 0
done
shopt -u nullglob
return 1
}
wait_for_uvc_output_node( ) {
local node = ""
for _ in { 1..50} ; do
if node = $( find_uvc_output_node) ; then
printf '%s\n' " $node "
return 0
fi
sleep 0.1
done
return 1
}
validate_uvc_gadget_ready( ) {
if [ [ -n ${ LESAVKA_DISABLE_UVC :- } ] ] ; then
return 0
fi
if [ [ ! -d /sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0 ] ] ; then
echo "❌ UVC gadget function is missing after rebuild; refusing to continue with a half-applied install." >& 2
return 1
fi
local node = ""
if ! node = $( wait_for_uvc_output_node) ; then
echo "❌ UVC gadget video-output node did not appear after rebuild; refusing to continue." >& 2
return 1
fi
echo " ✅ UVC gadget output ready at ${ node } "
}
2026-04-27 21:02:59 -03:00
uvc_gadget_present( ) {
[ [ -d /sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0 ] ]
}
2026-01-08 23:58:19 -03:00
udc_state( ) {
local udc = ""
udc = $( ls /sys/class/udc 2>/dev/null | head -n1 || true )
if [ [ -z $udc ] ] ; then
echo "unknown"
return 0
fi
cat " /sys/class/udc/ $udc /state " 2>/dev/null || echo "unknown"
}
is_attached_state( ) {
case " $1 " in
configured| addressed| default| suspended| unknown)
return 0
; ;
esac
return 1
}
2026-04-27 22:34:46 -03:00
server_bind_addr( ) {
2026-04-28 02:29:32 -03:00
printf '%s\n' " ${ INSTALL_SERVER_BIND_ADDR } "
2026-04-27 22:34:46 -03:00
}
server_bind_port( ) {
local bind_addr port
bind_addr = $( server_bind_addr)
port = ${ bind_addr ##* : }
[ [ $port = ~ ^[ 0-9] +$ ] ] || return 1
printf '%s\n' " $port "
}
list_server_listener_pids( ) {
local port
port = $( server_bind_port) || return 1
2026-04-28 01:33:17 -03:00
{
sudo lsof -tiTCP:" $port " -sTCP:LISTEN 2>/dev/null || true
list_server_listener_pids_proc " $port "
} | sed '/^$/d' | sort -u
}
2026-04-28 02:04:29 -03:00
list_server_bound_inactive_lines( ) {
local port = $1
sudo ss -H -B -tn " sport = : $port " 2>/dev/null | sed '/^$/d' || true
}
2026-04-28 01:33:17 -03:00
list_server_listener_inodes_proc( ) {
local port = $1 hex_port
hex_port = $( printf '%04X' " $port " )
sudo awk -v target = " $hex_port " '
FNR = = 1 { next }
$4 = = "0A" {
split( $2 , local, ":" )
if ( toupper( local[ 2] ) = = target) print $10
}
' /proc/net/tcp /proc/net/tcp6 2>/dev/null | sed ' /^$/d' | sort -u
}
list_server_listener_pids_proc( ) {
local port = $1
local -a inodes = ( )
mapfile -t inodes < <( list_server_listener_inodes_proc " $port " )
if ( ( ${# inodes [@] } = = 0 ) ) ; then
return 0
fi
sudo bash -s -- " ${ inodes [@] } " <<'EOF'
set -euo pipefail
inodes = ( " $@ " )
shopt -s nullglob
for fd in /proc/[ 0-9] */fd/*; do
target = $( readlink " $fd " 2>/dev/null || true )
for inode in " ${ inodes [@] } " ; do
if [ [ " $target " = = " socket:[ $inode ] " ] ] ; then
pid = ${ fd #/proc/ }
pid = ${ pid %%/* }
printf '%s\n' " $pid "
break
fi
done
done | sort -u
EOF
}
server_listener_presence_detail( ) {
local port = $1
local -a inodes = ( )
mapfile -t inodes < <( list_server_listener_inodes_proc " $port " )
if ( ( ${# inodes [@] } = = 0 ) ) ; then
printf 'no /proc listener entries for :%s' " $port "
return 0
fi
printf 'listener inodes on :%s => %s' " $port " " ${ inodes [*] } "
2026-04-27 22:34:46 -03:00
}
2026-04-28 02:04:29 -03:00
server_bound_inactive_detail( ) {
local port = $1
local detail
detail = $( list_server_bound_inactive_lines " $port " | paste -sd ';' -)
if [ [ -z $detail ] ] ; then
printf 'no bound-inactive tcp sockets for :%s' " $port "
return 0
fi
printf 'bound-inactive tcp sockets on :%s => %s' " $port " " $detail "
}
destroy_hidden_server_listener( ) {
local port = $1
echo " ⚠️ TCP : $port is listening without a visible owning PID; asking the kernel to drop the stale socket state. "
sudo ss -K state connected " sport = : $port or dport = : $port " >/dev/null 2>& 1 || true
sudo ss -K state listening " sport = : $port " >/dev/null 2>& 1 || true
for _ in { 1..30} ; do
if ! list_server_listener_inodes_proc " $port " | grep -q .; then
if list_server_bound_inactive_lines " $port " | grep -q .; then
echo " ❌ TCP : $port stopped listening but remains bound after kernel cleanup; $( server_bound_inactive_detail " $port " ) . " >& 2
return 1
fi
echo " ✅ kernel dropped the hidden TCP : $port socket state. "
return 0
fi
sleep 0.1
done
echo " ❌ TCP : $port still appears to be listening after kernel socket cleanup; $( server_listener_presence_detail " $port " ) . " >& 2
return 1
}
2026-04-27 22:34:46 -03:00
clear_stale_server_listener( ) {
local port pid cmdline found = 0 unexpected = 0
port = $( server_bind_port) || {
echo " ⚠️ could not parse LESAVKA_SERVER_BIND_ADDR=' $( server_bind_addr) '; skipping stale-listener cleanup. "
return 0
}
while read -r pid; do
[ [ -n $pid ] ] || continue
found = 1
cmdline = $( sudo ps -o args = -p " $pid " 2>/dev/null || true )
if [ [ $cmdline = = *"/usr/local/bin/lesavka-server" * ] ] || [ [ $cmdline = = *"lesavka-server" * ] ] ; then
echo " ⚠️ clearing stale lesavka-server listener on : $port (pid $pid ). "
sudo kill -TERM " $pid " 2>/dev/null || true
else
echo " ❌ TCP : $port is already owned by an unexpected process (pid $pid ): ${ cmdline :- <unknown> } . " >& 2
unexpected = 1
fi
done < <( list_server_listener_pids || true )
if [ [ " $unexpected " != "0" ] ] ; then
return 1
fi
if [ [ " $found " = = "0" ] ] ; then
2026-04-28 01:33:17 -03:00
if list_server_listener_inodes_proc " $port " | grep -q .; then
2026-04-28 02:04:29 -03:00
if destroy_hidden_server_listener " $port " ; then
return 0
fi
2026-04-28 01:33:17 -03:00
echo " ❌ TCP : $port is listening but no owning PID could be identified; $( server_listener_presence_detail " $port " ) . " >& 2
return 1
fi
2026-04-28 02:04:29 -03:00
if list_server_bound_inactive_lines " $port " | grep -q .; then
echo " ❌ TCP : $port is not listening but remains bound; $( server_bound_inactive_detail " $port " ) . " >& 2
return 1
fi
2026-04-27 22:34:46 -03:00
return 0
fi
for _ in { 1..30} ; do
if ! list_server_listener_pids | grep -q .; then
echo " ✅ cleared stale lesavka-server listeners on : $port . "
return 0
fi
sleep 0.1
done
while read -r pid; do
[ [ -n $pid ] ] || continue
cmdline = $( sudo ps -o args = -p " $pid " 2>/dev/null || true )
if [ [ $cmdline = = *"/usr/local/bin/lesavka-server" * ] ] || [ [ $cmdline = = *"lesavka-server" * ] ] ; then
echo " ⚠️ stale lesavka-server listener on : $port survived SIGTERM; sending SIGKILL to pid $pid . "
sudo kill -KILL " $pid " 2>/dev/null || true
else
echo " ❌ TCP : $port remained busy after cleanup and is owned by an unexpected process (pid $pid ): ${ cmdline :- <unknown> } . " >& 2
return 1
fi
done < <( list_server_listener_pids || true )
for _ in { 1..30} ; do
if ! list_server_listener_pids | grep -q .; then
echo " ✅ cleared stale lesavka-server listeners on : $port . "
return 0
fi
sleep 0.1
done
2026-04-28 01:33:17 -03:00
if list_server_listener_inodes_proc " $port " | grep -q .; then
echo " ❌ TCP : $port still appears to be listening after cleanup; $( server_listener_presence_detail " $port " ) . " >& 2
return 1
fi
2026-04-28 02:04:29 -03:00
if list_server_bound_inactive_lines " $port " | grep -q .; then
echo " ❌ TCP : $port stopped listening but remains bound after cleanup; $( server_bound_inactive_detail " $port " ) . " >& 2
return 1
fi
2026-04-28 01:33:17 -03:00
2026-04-27 22:34:46 -03:00
echo " ❌ lesavka-server listener on : $port survived cleanup; refusing to start a duplicate server. " >& 2
return 1
}
wait_for_unit_running( ) {
local unit = $1
for _ in { 1..50} ; do
if systemctl is-active --quiet " $unit " ; then
if [ [ $( systemctl show " $unit " -p SubState --value 2>/dev/null || true ) = = "running" ] ] ; then
return 0
fi
fi
sleep 0.2
done
return 1
}
validate_server_ready( ) {
2026-04-28 20:28:36 -03:00
local bind_addr port
2026-04-27 22:34:46 -03:00
bind_addr = $( server_bind_addr)
2026-04-28 20:28:36 -03:00
port = $( server_bind_port) || {
echo " ❌ could not parse LESAVKA_SERVER_BIND_ADDR=' ${ bind_addr } ' while validating server readiness. " >& 2
return 1
}
2026-04-27 22:34:46 -03:00
if wait_for_unit_running lesavka-server; then
2026-04-28 20:28:36 -03:00
for _ in { 1..50} ; do
if list_server_listener_inodes_proc " $port " | grep -q .; then
echo " ✅ lesavka-server is active and listening on ${ bind_addr } . "
return 0
fi
sleep 0.2
done
echo " ❌ lesavka-server reached active/running state but did not open TCP : ${ port } . " >& 2
sudo journalctl -b -u lesavka-server -n 80 --no-pager >& 2 || true
return 1
2026-04-27 22:34:46 -03:00
fi
echo " ❌ lesavka-server failed to reach active/running state on ${ bind_addr } . " >& 2
sudo systemctl status lesavka-server --no-pager >& 2 || true
if [ [ -s /tmp/lesavka-server.stderr ] ] ; then
echo "---- /tmp/lesavka-server.stderr (tail) ----" >& 2
sudo tail -n 40 /tmp/lesavka-server.stderr >& 2 || true
echo "------------------------------------------" >& 2
fi
return 1
}
2026-04-22 15:09:00 -03:00
normalize_hdmi_connector( ) {
local name = " $1 "
if [ [ $name = ~ ( HDMI-A-[ 0-9] +) $ ] ] ; then
printf '%s\n' " ${ BASH_REMATCH [1] } "
else
printf '%s\n' " $name "
fi
}
read_existing_hdmi_connector( ) {
local line value
line = $( grep -E '^LESAVKA_HDMI_CONNECTOR=' /etc/lesavka/server.env 2>/dev/null | tail -n1 || true )
[ [ -n $line ] ] || return 0
value = ${ line #*= }
value = ${ value % \" }
value = ${ value # \" }
value = ${ value % \' }
value = ${ value # \' }
normalize_hdmi_connector " $value "
}
2026-04-22 15:24:17 -03:00
boot_config_path( ) {
for path in /boot/config.txt /boot/firmware/config.txt; do
if [ [ -e $path ] ] ; then
printf '%s\n' " $path "
return 0
fi
done
return 0
}
boot_cmdline_path( ) {
for path in /boot/cmdline.txt /boot/firmware/cmdline.txt; do
if [ [ -e $path ] ] ; then
printf '%s\n' " $path "
return 0
fi
done
return 0
}
runtime_forced_hdmi_connectors( ) {
[ [ -r /proc/cmdline ] ] || return 0
tr ' ' '\n' </proc/cmdline |
sed -n 's/^video=\(HDMI-A-[0-9]\+\):.*e$/\1/p' |
sort -u
}
remove_lesavka_hdmi_config_force( ) {
local cfg tmp
cfg = $( boot_config_path)
[ [ -n $cfg ] ] || return 0
tmp = $( mktemp)
awk '
/^# lesavka: force HDMI/ { next}
/^hdmi_force_hotplug:[ 0-9] += 1$/ { next}
/^hdmi_group:[ 0-9] += 2$/ { next}
/^hdmi_mode:[ 0-9] += 82$/ { next}
{ print}
' " $cfg " >" $tmp "
sudo install -m 0644 " $tmp " " $cfg "
rm -f " $tmp "
}
write_hdmi_cmdline_force( ) {
local connector = " ${ 1 :- } " cmd token tmp line
cmd = $( boot_cmdline_path)
[ [ -n $cmd ] ] || return 0
tmp = $( mktemp)
line = $( cat " $cmd " )
for token in $line ; do
case " $token " in
video = HDMI-A-*:1920x1080@60e)
continue
; ;
esac
printf '%s ' " $token " >>" $tmp "
done
if [ [ -n $connector ] ] ; then
printf 'video=%s:1920x1080@60e ' " $connector " >>" $tmp "
fi
sed -i 's/[[:space:]]*$//' " $tmp "
printf '\n' >>" $tmp "
sudo install -m 0644 " $tmp " " $cmd "
rm -f " $tmp "
}
clear_lesavka_hdmi_boot_force( ) {
remove_lesavka_hdmi_config_force
write_hdmi_cmdline_force ""
}
write_hdmi_boot_force( ) {
local connector = " $1 " idx cfg
connector = $( normalize_hdmi_connector " $connector " )
if [ [ ! $connector = ~ ^HDMI-A-( [ 0-9] +) $ ] ] ; then
echo " ⚠️ cannot write HDMI boot force for unexpected connector ' $connector '. " >& 2
return 0
fi
idx = $(( BASH_REMATCH[ 1 ] - 1 ))
cfg = $( boot_config_path)
remove_lesavka_hdmi_config_force
if [ [ -n $cfg ] ] ; then
{
echo
echo " # lesavka: force HDMI mode for capture dongle ( $connector ) "
printf 'hdmi_force_hotplug:%s=1\n' " $idx "
printf 'hdmi_group:%s=2\n' " $idx "
printf 'hdmi_mode:%s=82\n' " $idx "
} | sudo tee -a " $cfg " >/dev/null
fi
write_hdmi_cmdline_force " $connector "
}
prepare_hdmi_detection_boot_state( ) {
local -a forced = ( )
if [ [ -n ${ LESAVKA_HDMI_CONNECTOR :- } ] ] ; then
return 0
fi
mapfile -t forced < <( runtime_forced_hdmi_connectors)
if ( ( ${# forced [@] } <= 1 ) ) ; then
return 0
fi
echo " ⚠️ current boot forces multiple HDMI connectors: ${ forced [*] } " >& 2
echo " Linux will report every forced connector as connected, so auto-detection cannot be trusted yet." >& 2
clear_lesavka_hdmi_boot_force
echo "✅ removed Lesavka-managed dual-HDMI force settings from boot config." >& 2
echo " Reboot Theia, then rerun this installer so it can see the real connected HDMI port." >& 2
echo " If both ports are physically connected after reboot, rerun once with LESAVKA_HDMI_CONNECTOR=HDMI-A-N." >& 2
exit 1
}
2026-04-22 15:09:00 -03:00
describe_hdmi_connectors( ) {
2026-04-30 15:25:56 -03:00
local dev name status id edid_bytes
2026-04-22 15:09:00 -03:00
for dev in /sys/class/drm/card*-HDMI-A-*; do
[ [ -e $dev /status ] ] || continue
name = $( basename " $dev " )
status = $( cat " $dev /status " 2>/dev/null || true )
id = $( cat " $dev /connector_id " 2>/dev/null || true )
2026-04-30 15:25:56 -03:00
edid_bytes = $( wc -c <" $dev /edid " 2>/dev/null || echo 0)
printf ' %s (%s): %s, edid=%sB\n' " $( normalize_hdmi_connector " $name " ) " " ${ id :- no -id } " " ${ status :- unknown } " " ${ edid_bytes :- 0 } " >& 2
2026-04-22 15:09:00 -03:00
done
}
2026-04-22 13:40:25 -03:00
detect_connected_hdmi_connector( ) {
2026-04-30 15:25:56 -03:00
local dev name stable_name status score existing increment edid_bytes
2026-04-22 15:09:00 -03:00
local best = "" best_score = 0 best_count = 0
2026-04-22 14:42:22 -03:00
declare -A scores = ( )
2026-04-22 15:09:00 -03:00
# HDMI status can briefly flap during gadget/display bring-up, so sample a
# few times. Store the logical connector suffix, not the DRM card prefix, so
# this stays valid if Linux renumbers cardN across boots.
2026-04-22 14:42:22 -03:00
for _ in 1 2 3 4 5; do
for dev in /sys/class/drm/card*-HDMI-A-*; do
[ [ -e $dev /status ] ] || continue
name = $( basename " $dev " )
2026-04-22 15:09:00 -03:00
stable_name = $( normalize_hdmi_connector " $name " )
2026-04-22 14:42:22 -03:00
status = $( cat " $dev /status " 2>/dev/null || true )
if [ [ $status = = connected ] ] ; then
2026-04-30 15:25:56 -03:00
increment = 1
# A boot-forced HDMI connector also reports "connected" on Raspberry Pi,
# even when no sink is present. Real EDID is stronger evidence than the
# status bit, so prefer it before falling back to older install state.
edid_bytes = $( wc -c <" $dev /edid " 2>/dev/null || echo 0)
if [ [ ${ edid_bytes :- 0 } = ~ ^[ 0-9] +$ ] ] && ( ( edid_bytes > 0 ) ) ; then
increment = 20
fi
scores[ $stable_name ] = $(( ${ scores [ $stable_name ] :- 0 } + increment ))
2026-04-22 14:42:22 -03:00
fi
done
sleep 0.2
done
for name in " ${ !scores[@] } " ; do
score = ${ scores [ $name ] }
2026-04-22 15:09:00 -03:00
if ( ( score > best_score ) ) ; then
2026-04-22 14:42:22 -03:00
best = $name
best_score = $score
2026-04-22 13:40:25 -03:00
fi
done
2026-04-22 14:42:22 -03:00
2026-04-22 15:09:00 -03:00
( ( best_score > 0 ) ) || return 0
for name in " ${ !scores[@] } " ; do
if ( ( scores[ $name ] = = best_score ) ) ; then
best_count = $(( best_count + 1 ))
fi
done
if ( ( best_count = = 1 ) ) ; then
printf '%s\n' " $best "
return 0
fi
2026-04-30 15:25:56 -03:00
existing = $( read_existing_hdmi_connector)
if [ [ -n $existing && ${ scores [ $existing ] :- 0 } -eq $best_score ] ] ; then
echo " ⚠️ multiple HDMI connectors are equally connected; preserving existing LESAVKA_HDMI_CONNECTOR= $existing . " >& 2
describe_hdmi_connectors
printf '%s\n' " $existing "
return 0
fi
2026-04-22 15:09:00 -03:00
echo "❌ multiple HDMI connectors are equally connected; refusing to guess." >& 2
describe_hdmi_connectors
echo " Disconnect the non-target HDMI output or run once with LESAVKA_HDMI_CONNECTOR=HDMI-A-N." >& 2
return 1
}
resolve_hdmi_connector( ) {
local detected existing
if [ [ -n ${ LESAVKA_HDMI_CONNECTOR :- } ] ] ; then
normalize_hdmi_connector " $LESAVKA_HDMI_CONNECTOR "
return 0
fi
if ! detected = $( detect_connected_hdmi_connector) ; then
return 1
fi
if [ [ -n $detected ] ] ; then
printf '%s\n' " $detected "
return 0
fi
existing = $( read_existing_hdmi_connector)
if [ [ -n $existing ] ] ; then
echo " ⚠️ no connected HDMI connector detected; preserving existing LESAVKA_HDMI_CONNECTOR= $existing . " >& 2
printf '%s\n' " $existing "
fi
2026-04-22 13:40:25 -03:00
}
2026-04-16 14:29:16 -03:00
run_as_user( ) {
sudo -u " $ORIG_USER " env HOME = " $USER_HOME " SSH_AUTH_SOCK = " ${ SSH_AUTH_SOCK :- } " " $@ "
}
2026-04-25 11:14:44 -03:00
CAPTURE_DISCOVERY_RELAY_PRESENT = 0
CAPTURE_DISCOVERY_RELAY_WAS_ACTIVE = 0
CAPTURE_DISCOVERY_POWER_BORROWED = 0
prepare_capture_power_for_discovery( ) {
if ! systemctl list-unit-files | grep -q '^relay.service' ; then
return 0
fi
CAPTURE_DISCOVERY_RELAY_PRESENT = 1
if systemctl is-active --quiet relay.service; then
CAPTURE_DISCOVERY_RELAY_WAS_ACTIVE = 1
return 0
fi
echo " ↪ borrowing relay GPIO power for capture discovery"
sudo systemctl start relay.service
CAPTURE_DISCOVERY_POWER_BORROWED = 1
sudo udevadm settle --timeout= 10 || true
sleep 2
}
restore_capture_power_after_discovery( ) {
if [ " ${ CAPTURE_DISCOVERY_RELAY_PRESENT :- 0 } " -eq 0 ] ; then
return 0
fi
if [ " ${ CAPTURE_DISCOVERY_POWER_BORROWED :- 0 } " -eq 0 ] ; then
return 0
fi
if [ " ${ CAPTURE_DISCOVERY_RELAY_WAS_ACTIVE :- 0 } " -ne 0 ] ; then
return 0
fi
echo " ↪ returning relay GPIO power control to Lesavka auto mode"
sudo systemctl stop relay.service || true
CAPTURE_DISCOVERY_POWER_BORROWED = 0
}
2025-07-04 18:24:48 -05:00
while [ [ $# -gt 0 ] ] ; do
case $1 in
-r| --ref) REF = " $2 " ; shift 2 ; ;
-h| --help)
echo " Usage: $0 [--ref <branch|commit>] " ; exit 0 ; ;
*) echo " Unknown option: $1 " ; exit 1 ; ;
esac
done
echo " ==> Using git ref: $REF "
2026-04-16 13:54:25 -03:00
mkdir -p " $TMPDIR "
2025-07-04 18:24:48 -05:00
2026-04-16 14:29:16 -03:00
if [ [ -z $REPO_URL ] ] && [ [ -d $SCRIPT_REPO_ROOT /.git ] ] ; then
REPO_URL = $( git -C " $SCRIPT_REPO_ROOT " config --get remote.origin.url || true )
fi
REPO_URL = ${ REPO_URL :- $DEFAULT_REPO_URL }
2025-06-23 00:26:02 -05:00
echo "==> 1a. Base packages"
2026-04-16 13:54:25 -03:00
sudo pacman -Sq --needed --noconfirm git \
2025-06-29 22:57:54 -05:00
rustup \
protobuf \
2026-04-16 14:29:16 -03:00
abseil-cpp \
2025-06-29 22:57:54 -05:00
gcc \
2025-12-01 15:54:03 -03:00
alsa-utils \
2025-06-29 22:57:54 -05:00
pipewire \
pipewire-pulse \
tailscale \
base-devel \
2025-11-30 16:16:03 -03:00
v4l-utils \
2025-06-29 22:57:54 -05:00
gstreamer \
gst-plugins-base \
2025-06-30 02:03:12 -05:00
gst-plugins-base-libs \
2025-06-29 22:57:54 -05:00
gst-plugins-good \
gst-plugins-bad \
2025-06-30 02:03:12 -05:00
gst-plugins-bad-libs \
2025-06-29 22:57:54 -05:00
gst-plugins-ugly \
2025-06-30 22:27:49 -05:00
gst-libav \
2025-07-01 10:23:51 -05:00
tcpdump \
2026-04-30 08:16:57 -03:00
lsof \
openssl
2025-06-01 21:30:55 -05:00
if ! command -v yay >/dev/null 2>& 1; then
2025-06-23 00:26:02 -05:00
echo "==> 1b. installing yay from AUR ..."
2026-04-16 14:29:16 -03:00
run_as_user env TMPDIR = " $TMPDIR " bash -c '
2026-04-16 13:54:25 -03:00
rm -rf " $TMPDIR /yay " &&
cd " $TMPDIR " && git clone --depth 1 https://aur.archlinux.org/yay.git &&
2025-06-01 21:30:55 -05:00
cd yay && makepkg -si --noconfirm'
2025-06-01 16:04:00 -05:00
fi
2025-06-28 00:05:13 -05:00
# yay -S --noconfirm grpcurl-bin
2025-06-01 14:18:42 -05:00
2025-11-30 18:01:54 -03:00
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
2026-04-24 22:43:25 -03:00
echo "==> 1d. Audio permissions for diagnostics"
if getent group audio >/dev/null 2>& 1 && [ -n " ${ SUDO_USER :- } " ] && [ " ${ SUDO_USER } " != "root" ] ; then
sudo usermod -aG audio " ${ SUDO_USER } " || true
fi
2025-06-28 15:45:35 -05:00
echo "==> 2a. Kernel-driver tweaks"
2025-06-23 00:26:02 -05:00
cat <<'EOF' | sudo tee /etc/modprobe.d/gc311-stream.conf >/dev/null
options uvcvideo quirks = 0x200 timeout = 10000
EOF
2026-04-22 15:24:17 -03:00
prepare_hdmi_detection_boot_state
2025-06-23 00:26:02 -05:00
echo "==> 2b. Predictable /dev names for each capture card"
2026-04-25 11:14:44 -03:00
prepare_capture_power_for_discovery
trap restore_capture_power_after_discovery EXIT
2025-11-30 16:16:03 -03:00
2026-04-25 06:18:27 -03:00
discover_gc_capture_pairs( ) {
2026-04-25 05:57:29 -03:00
for dev in /dev/video*; do
props = $( sudo udevadm info -q property -n " $dev " 2>/dev/null || true )
index = $( cat " /sys/class/video4linux/ $( basename " $dev " ) /index " 2>/dev/null || true )
if echo " $props " | grep -q 'ID_VENDOR_ID=07ca' \
&& echo " $props " | grep -q 'ID_MODEL_ID=3311' \
&& [ " $index " = "0" ] ; then
tag = $( printf '%s\n' " $props " | awk -F= '/^ID_PATH_TAG=/{print $2}' )
if [ -n " $tag " ] ; then
printf '%s %s\n' " $tag " " $dev "
2025-11-30 16:16:03 -03:00
fi
2026-04-25 05:57:29 -03:00
fi
done | sort -u
2026-04-25 06:18:27 -03:00
}
mapfile -t GC_CAPTURE_PAIRS < <( discover_gc_capture_pairs)
if [ " ${# GC_CAPTURE_PAIRS [@] } " -ne 2 ] ; then
for _ in { 1..20} ; do
sleep 0.5
mapfile -t GC_CAPTURE_PAIRS < <( discover_gc_capture_pairs)
[ " ${# GC_CAPTURE_PAIRS [@] } " -eq 2 ] && break
done
fi
2025-11-30 16:16:03 -03:00
2026-04-25 05:57:29 -03:00
if [ " ${# GC_CAPTURE_PAIRS [@] } " -ne 2 ] ; then
2026-04-16 16:25:01 -03:00
echo "⚠️ GC311 capture cards not fully present; skipping udev eye-link refresh." >& 2
2026-04-25 05:57:29 -03:00
if [ " ${# GC_CAPTURE_PAIRS [@] } " -eq 0 ] ; then
2026-04-16 16:25:01 -03:00
echo " Detected: none" >& 2
else
2026-04-25 05:57:29 -03:00
printf ' Detected: %s\n' " ${ GC_CAPTURE_PAIRS [@] } " >& 2
2026-04-16 16:25:01 -03:00
fi
echo " The server install will continue, and existing /dev/lesavka_* links stay untouched." >& 2
else
2026-04-25 05:57:29 -03:00
LEFT_TAG = ${ GC_CAPTURE_PAIRS [0]%% * }
LEFT_DEV = ${ GC_CAPTURE_PAIRS [0]#* }
RIGHT_TAG = ${ GC_CAPTURE_PAIRS [1]%% * }
RIGHT_DEV = ${ GC_CAPTURE_PAIRS [1]#* }
if [ -z " $LEFT_TAG " ] || [ -z " $RIGHT_TAG " ] || [ " $LEFT_TAG " = " $RIGHT_TAG " ] ; then
echo "⚠️ GC311 cards were detected, but the capture path tags are incomplete or duplicated." >& 2
printf ' Left candidate: %s\n' " ${ GC_CAPTURE_PAIRS [0] :- missing } " >& 2
printf ' Right candidate: %s\n' " ${ GC_CAPTURE_PAIRS [1] :- missing } " >& 2
2026-04-16 16:25:01 -03:00
echo " Skipping udev eye-link refresh and preserving any existing /dev/lesavka_* links." >& 2
else
2026-04-25 05:57:29 -03:00
printf ' ↪ Left card: %s (%s)\n' " $LEFT_DEV " " $LEFT_TAG "
printf ' ↪ Right card: %s (%s)\n' " $RIGHT_DEV " " $RIGHT_TAG "
2025-06-25 07:46:50 -05:00
2026-04-16 16:25:01 -03:00
sudo tee /etc/udev/rules.d/85-gc311.rules >/dev/null <<EOF
2025-06-27 22:51:50 -05:00
# auto-generated by lesavka/scripts/daemon/install-server.sh - DO NOT EDIT
2025-06-28 00:24:23 -05:00
SUBSYSTEM = = "video4linux" , ATTR{ index} = = "0" , ENV{ ID_PATH_TAG} = = " $LEFT_TAG " , SYMLINK += "lesavka_l_eye"
SUBSYSTEM = = "video4linux" , ATTR{ index} = = "0" , ENV{ ID_PATH_TAG} = = " $RIGHT_TAG " , SYMLINK += "lesavka_r_eye"
2025-06-25 07:46:50 -05:00
EOF
2025-06-25 08:25:13 -05:00
2026-04-16 16:25:01 -03:00
sudo udevadm control --reload
sudo udevadm trigger --subsystem-match= video4linux
sudo udevadm settle
fi
fi
2026-04-25 11:14:44 -03:00
restore_capture_power_after_discovery
trap - EXIT
2025-06-23 00:26:02 -05:00
echo "==> 3. Rust toolchain"
2025-06-01 21:30:55 -05:00
sudo rustup default stable
2026-04-16 14:29:16 -03:00
run_as_user rustup default stable
2025-06-01 13:31:22 -05:00
2025-06-23 00:26:02 -05:00
echo "==> 4a. Source checkout"
SRC_DIR = /var/src/lesavka
2025-06-01 21:30:55 -05:00
if [ [ ! -d $SRC_DIR ] ] ; then
sudo mkdir -p /var/src
sudo chown " $ORIG_USER " :" $ORIG_USER " /var/src
2025-06-01 14:18:42 -05:00
fi
2025-06-01 21:30:55 -05:00
if [ [ -d $SRC_DIR /.git ] ] ; then
2026-04-16 14:29:16 -03:00
run_as_user git -C " $SRC_DIR " fetch --all --tags --prune
2025-06-01 14:18:42 -05:00
else
2026-04-16 14:29:16 -03:00
run_as_user git clone " $REPO_URL " " $SRC_DIR "
2025-07-04 18:30:28 -05:00
fi
2025-07-04 18:24:48 -05:00
2026-04-16 14:29:16 -03:00
if run_as_user git -C " $SRC_DIR " rev-parse --verify --quiet " origin/ $REF " >/dev/null; then
run_as_user git -C " $SRC_DIR " checkout -B " $REF " " origin/ $REF "
2025-07-04 18:24:48 -05:00
else
2026-04-16 14:29:16 -03:00
run_as_user git -C " $SRC_DIR " checkout --force " $REF "
2025-06-01 14:18:42 -05:00
fi
2025-06-01 13:31:22 -05:00
2026-01-09 22:50:09 -03:00
echo "==> 4b. Kernel upgrade (optional)"
2026-04-16 13:54:25 -03:00
if [ [ " ${ LESAVKA_KERNEL_UPDATE :- 0 } " != "0" ] ] ; then
2026-04-08 22:23:40 -03:00
sudo LESAVKA_KERNEL_BUILD_USER = " $ORIG_USER " bash " $SRC_DIR /scripts/kernel/build-linux-rpi.sh "
2026-01-09 22:50:09 -03:00
else
echo "⚠️ skipping kernel upgrade (LESAVKA_KERNEL_UPDATE=0)"
fi
echo "==> 4c. Source build"
2026-04-16 14:29:16 -03:00
run_as_user env TMPDIR = " $TMPDIR " bash -c " cd ' $SRC_DIR /server' && cargo clean && cargo build --release --bins "
2025-06-01 13:31:22 -05:00
2025-06-23 00:26:02 -05:00
echo "==> 5. Install binaries"
2026-04-16 12:58:05 -03:00
sudo install -Dm755 " $SRC_DIR /target/release/lesavka-server " /usr/local/bin/lesavka-server
sudo install -Dm755 " $SRC_DIR /target/release/lesavka-uvc " /usr/local/bin/lesavka-uvc
2025-06-27 22:51:50 -05:00
sudo install -Dm755 " $SRC_DIR /scripts/daemon/lesavka-core.sh " /usr/local/bin/lesavka-core.sh
2026-01-08 00:59:14 -03:00
sudo install -Dm755 " $SRC_DIR /scripts/daemon/lesavka-uvc.sh " /usr/local/bin/lesavka-uvc.sh
2026-04-24 21:27:19 -03:00
sudo install -Dm755 " $SRC_DIR /scripts/manual/run_uac_output_sanity.sh " /usr/local/bin/lesavka-uac-sanity
2025-06-01 14:18:42 -05:00
2026-04-22 13:40:25 -03:00
echo "==> 5b. Runtime environment defaults"
sudo install -d -m 0755 /etc/lesavka
2026-04-30 08:16:57 -03:00
ensure_server_tls_pki
2026-04-22 15:09:00 -03:00
HDMI_CONNECTOR = $( resolve_hdmi_connector)
2026-04-22 13:40:25 -03:00
if [ [ -n $HDMI_CONNECTOR ] ] ; then
echo " ↪ HDMI connector: $HDMI_CONNECTOR "
2026-04-22 15:24:17 -03:00
write_hdmi_boot_force " $HDMI_CONNECTOR "
2026-04-22 13:40:25 -03:00
else
echo "⚠️ no connected HDMI connector detected; leaving LESAVKA_HDMI_CONNECTOR unset." >& 2
fi
{
echo "# generated by lesavka/scripts/install/server.sh"
echo "# Edit only for local hardware overrides; rerunning the installer refreshes defaults."
if [ [ -n $HDMI_CONNECTOR ] ] ; then
2026-04-22 14:42:22 -03:00
printf 'LESAVKA_HDMI_CONNECTOR=%s\n' " $HDMI_CONNECTOR "
2026-04-22 13:40:25 -03:00
fi
2026-04-27 16:42:02 -03:00
printf 'LESAVKA_CAM_OUTPUT=%s\n' " ${ LESAVKA_INSTALL_CAM_OUTPUT :- uvc } "
2026-04-22 22:10:39 -03:00
printf 'LESAVKA_CAM_WIDTH=%s\n' " ${ LESAVKA_CAM_WIDTH :- 1920 } "
printf 'LESAVKA_CAM_HEIGHT=%s\n' " ${ LESAVKA_CAM_HEIGHT :- 1080 } "
printf 'LESAVKA_CAM_FPS=%s\n' " ${ LESAVKA_CAM_FPS :- 30 } "
printf 'LESAVKA_HDMI_WIDTH=%s\n' " ${ LESAVKA_HDMI_WIDTH :- 1920 } "
printf 'LESAVKA_HDMI_HEIGHT=%s\n' " ${ LESAVKA_HDMI_HEIGHT :- 1080 } "
printf 'LESAVKA_HDMI_SINK=%s\n' " ${ LESAVKA_HDMI_SINK :- fbdevsink } "
printf 'LESAVKA_HDMI_FBDEV=%s\n' " ${ LESAVKA_HDMI_FBDEV :- /dev/fb0 } "
2026-04-22 14:42:22 -03:00
printf 'LESAVKA_HDMI_DRIVER=%s\n' " ${ LESAVKA_HDMI_DRIVER :- vc4 } "
2026-04-24 17:44:11 -03:00
printf 'LESAVKA_HDMI_PRESENTATION_DELAY_US=%s\n' " ${ LESAVKA_HDMI_PRESENTATION_DELAY_US :- 180000 } "
2026-04-22 14:42:22 -03:00
printf 'LESAVKA_UAC_DEV=%s\n' " ${ LESAVKA_UAC_DEV :- hw : UAC2Gadget ,0 } "
printf 'LESAVKA_ALSA_DEV=%s\n' " ${ LESAVKA_ALSA_DEV :- hw : UAC2Gadget ,0 } "
2026-04-27 02:56:07 -03:00
printf 'LESAVKA_UAC_HDMI_COMPENSATION_US=%s\n' " ${ LESAVKA_UAC_HDMI_COMPENSATION_US :- 205000 } "
2026-04-25 04:26:51 -03:00
printf 'LESAVKA_UAC_SESSION_CLOCK_ALIGN=%s\n' " ${ LESAVKA_UAC_SESSION_CLOCK_ALIGN :- 0 } "
2026-04-25 16:48:20 -03:00
printf 'LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS=%s\n' " ${ LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS :- 1000 } "
2026-05-01 13:52:36 -03:00
printf 'LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=%s\n' " $( resolve_upstream_audio_playout_offset_us) "
2026-04-25 16:48:20 -03:00
printf 'LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=%s\n' " ${ LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US :- 0 } "
2026-04-28 22:03:51 -03:00
printf 'LESAVKA_UPSTREAM_PAIR_SLACK_US=%s\n' " ${ LESAVKA_UPSTREAM_PAIR_SLACK_US :- 80000 } "
2026-04-25 22:25:24 -03:00
printf 'LESAVKA_UPSTREAM_STALE_DROP_MS=%s\n' " ${ LESAVKA_UPSTREAM_STALE_DROP_MS :- 80 } "
2026-04-28 02:29:32 -03:00
printf 'LESAVKA_SERVER_BIND_ADDR=%s\n' " ${ INSTALL_SERVER_BIND_ADDR } "
2026-04-27 20:36:15 -03:00
printf 'LESAVKA_UVC_CODEC=%s\n' " ${ INSTALL_UVC_CODEC } "
2026-04-30 08:16:57 -03:00
printf 'LESAVKA_REQUIRE_TLS=%s\n' " ${ LESAVKA_REQUIRE_TLS :- 1 } "
printf 'LESAVKA_TLS_CERT=%s\n' " ${ LESAVKA_TLS_CERT :- $LESAVKA_TLS_DIR /server.crt } "
printf 'LESAVKA_TLS_KEY=%s\n' " ${ LESAVKA_TLS_KEY :- $LESAVKA_TLS_DIR /server.key } "
printf 'LESAVKA_TLS_CLIENT_CA=%s\n' " ${ LESAVKA_TLS_CLIENT_CA :- $LESAVKA_TLS_DIR /ca.crt } "
2026-04-22 13:40:25 -03:00
} | sudo tee /etc/lesavka/server.env >/dev/null
2026-04-27 15:50:29 -03:00
UVC_ENV_TMP = $( mktemp)
render_uvc_env_file >" $UVC_ENV_TMP "
UVC_ENV_CHANGED = 1
if sudo test -f /etc/lesavka/uvc.env && sudo cmp -s " $UVC_ENV_TMP " /etc/lesavka/uvc.env; then
UVC_ENV_CHANGED = 0
fi
sudo install -m 0644 " $UVC_ENV_TMP " /etc/lesavka/uvc.env
rm -f " $UVC_ENV_TMP "
2026-04-27 14:34:50 -03:00
2025-06-23 00:26:02 -05:00
echo "==> 6a. Systemd units - lesavka-core"
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-core.service >/dev/null
2025-06-01 13:31:22 -05:00
[ Unit]
2025-06-23 00:26:02 -05:00
Description = lesavka USB gadget bring-up
2025-06-06 00:04:55 -05:00
After = sys-kernel-config.mount
Requires = sys-kernel-config.mount
2025-06-23 00:26:02 -05:00
2025-06-01 13:31:22 -05:00
[ Service]
2025-06-01 14:18:42 -05:00
Type = oneshot
2025-06-23 00:26:02 -05:00
ExecStart = /usr/local/bin/lesavka-core.sh
2025-06-01 14:18:42 -05:00
RemainAfterExit = yes
2026-01-06 04:47:17 -03:00
Environment = LESAVKA_UVC_FALLBACK = 0
2026-04-27 13:50:48 -03:00
EnvironmentFile = -/etc/lesavka/server.env
2025-11-30 23:07:31 -03:00
CapabilityBoundingSet = CAP_SYS_ADMIN CAP_SYS_MODULE
AmbientCapabilities = CAP_SYS_MODULE
2025-06-06 00:41:32 -05:00
MountFlags = slave
2025-06-23 00:26:02 -05:00
2025-06-01 13:31:22 -05:00
[ Install]
WantedBy = multi-user.target
2025-06-01 21:30:55 -05:00
UNIT
2025-06-01 13:31:22 -05:00
2025-06-23 00:26:02 -05:00
echo "==> 6b. Systemd units - lesavka-server"
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-server.service >/dev/null
2025-06-01 13:31:22 -05:00
[ Unit]
2025-06-23 00:26:02 -05:00
Description = lesavka gRPC relay
2026-04-28 14:23:59 -03:00
After = network.target lesavka-core.service lesavka-uvc.service
Wants = lesavka-uvc.service
2026-01-08 00:42:32 -03:00
StartLimitIntervalSec = 30
StartLimitBurst = 10
2025-06-23 00:26:02 -05:00
2025-06-01 13:31:22 -05:00
[ Service]
2026-01-07 04:47:39 -03:00
ExecStartPre = /usr/local/bin/lesavka-core.sh --attach
2025-06-23 00:26:02 -05:00
ExecStart = /usr/local/bin/lesavka-server
2026-01-07 04:47:39 -03:00
TimeoutStopSec = 10
KillSignal = SIGTERM
2026-04-27 22:17:34 -03:00
KillMode = control-group
2025-06-01 14:18:42 -05:00
Restart = always
2025-07-04 18:00:49 -05:00
Environment = RUST_LOG = lesavka_server = info,lesavka_server::audio= info,lesavka_server::video= debug,lesavka_server::gadget= info
2025-06-25 20:00:34 -05:00
Environment = RUST_BACKTRACE = 1
2025-07-01 10:23:51 -05:00
Environment = GST_DEBUG = "*:2,alsasink:6,alsasrc:6"
2026-01-08 00:59:14 -03:00
Environment = LESAVKA_UVC_EXTERNAL = 1
2026-04-08 22:23:40 -03:00
Environment = LESAVKA_EYE_ADAPTIVE = 1
Environment = LESAVKA_EYE_MIN_FPS = 12
2026-04-08 23:01:31 -03:00
Environment = LESAVKA_EYE_FPS = 20
2026-04-08 22:23:40 -03:00
Environment = LESAVKA_MIC_INIT_ATTEMPTS = 5
Environment = LESAVKA_MIC_INIT_DELAY_MS = 250
2026-04-28 14:23:59 -03:00
Environment = LESAVKA_SERVER_LOG_PATH = /var/log/lesavka/server.log
2026-04-27 15:50:29 -03:00
EnvironmentFile = -/etc/lesavka/uvc.env
2026-04-22 13:40:25 -03:00
EnvironmentFile = -/etc/lesavka/server.env
2025-06-25 20:00:34 -05:00
Restart = always
2025-06-25 22:24:58 -05:00
RestartSec = 5
2026-04-28 14:23:59 -03:00
StandardError = append:/var/log/lesavka/server.stderr
2025-06-01 14:18:42 -05:00
User = root
2025-06-23 00:26:02 -05:00
2025-06-01 13:31:22 -05:00
[ Install]
WantedBy = multi-user.target
2025-06-01 21:30:55 -05:00
UNIT
2025-06-01 13:31:22 -05:00
2025-06-23 00:26:02 -05:00
echo "==> 6c. Systemd units - initialization"
2026-04-28 14:23:59 -03:00
sudo install -d -m 0755 /var/log/lesavka
sudo rm -f /tmp/lesavka-server.log
sudo truncate -s 0 /var/log/lesavka/server.log
2025-06-01 13:31:22 -05:00
sudo systemctl daemon-reload
2026-04-16 13:54:25 -03:00
sudo systemctl enable lesavka-core lesavka-server
2026-01-08 23:58:19 -03:00
UDC_STATE = $( udc_state)
2026-04-27 15:50:29 -03:00
FORCE_GADGET_REBUILD = 0
2026-04-30 15:47:07 -03:00
GADGET_REBUILD_REASON = ""
2026-04-30 16:13:42 -03:00
EXPLICIT_GADGET_REBUILD = 0
2026-04-27 21:02:59 -03:00
if [ [ -z ${ LESAVKA_DISABLE_UVC :- } ] ] && ! uvc_gadget_present; then
FORCE_GADGET_REBUILD = 1
2026-04-30 15:47:07 -03:00
GADGET_REBUILD_REASON = "UVC function is missing from the live gadget"
2026-04-27 21:02:59 -03:00
echo "⚠️ UVC function is missing from the live gadget; forcing a rebuild before server start."
fi
2026-04-27 15:50:29 -03:00
if [ [ " $UVC_ENV_CHANGED " = = "1" ] ] && is_attached_state " $UDC_STATE " ; then
FORCE_GADGET_REBUILD = 1
2026-04-30 15:47:07 -03:00
GADGET_REBUILD_REASON = "UVC runtime settings changed while the host is attached"
2026-04-27 15:50:29 -03:00
echo "⚠️ UVC runtime settings changed while the host is attached; forcing a gadget rebuild so the new descriptors take effect."
fi
2026-04-30 15:47:07 -03:00
if [ [ -n ${ LESAVKA_FORCE_GADGET_REBUILD :- } ] ] ; then
FORCE_GADGET_REBUILD = 1
2026-04-30 16:13:42 -03:00
EXPLICIT_GADGET_REBUILD = 1
2026-04-30 15:47:07 -03:00
GADGET_REBUILD_REASON = "explicit LESAVKA_FORCE_GADGET_REBUILD request"
echo "⚠️ explicit LESAVKA_FORCE_GADGET_REBUILD request; forcing a gadget rebuild before server start."
fi
2026-04-30 16:13:42 -03:00
if [ [ " $FORCE_GADGET_REBUILD " = = "1" ] ] && is_attached_state " $UDC_STATE " \
&& { [ [ " $EXPLICIT_GADGET_REBUILD " != "1" ] ] || [ [ -z ${ LESAVKA_ALLOW_GADGET_RESET :- } ] ] ; } ; then
echo " ⚠️ ${ GADGET_REBUILD_REASON :- Gadget state } requires a rebuild, but UDC state is ' $UDC_STATE ' and attached-host hard reset was not explicitly allowed. " >& 2
2026-04-30 15:47:07 -03:00
echo " Preserving the attached gadget to avoid wedging the Pi USB controller." >& 2
echo " Run during a maintenance window with LESAVKA_ALLOW_GADGET_RESET=1 LESAVKA_FORCE_GADGET_REBUILD=1 if a hard rebuild is required." >& 2
FORCE_GADGET_REBUILD = 0
fi
if [ [ " $FORCE_GADGET_REBUILD " = = "1" ] ] || ! is_attached_state " $UDC_STATE " ; then
2026-04-21 19:09:52 -03:00
echo " ⚠️ UDC state is ' $UDC_STATE ' - forcing a Lesavka gadget rebuild before server start. "
2026-04-28 21:49:58 -03:00
sudo systemctl stop lesavka-server >/dev/null 2>& 1 || true
2026-04-28 14:23:59 -03:00
sudo systemctl stop lesavka-uvc >/dev/null 2>& 1 || true
sudo systemctl reset-failed lesavka-uvc >/dev/null 2>& 1 || true
2026-04-30 15:47:07 -03:00
CORE_REBUILD_ENV = (
"LESAVKA_ALLOW_GADGET_RESET=1"
"LESAVKA_ATTACH_WRITE_UDC=1"
"LESAVKA_DETACH_CLEAR_UDC=1"
"LESAVKA_UVC_FALLBACK=0"
" LESAVKA_UVC_CODEC= ${ INSTALL_UVC_CODEC } "
)
if [ [ -n ${ LESAVKA_RELOAD_UVCVIDEO :- } ] ] ; then
CORE_REBUILD_ENV += ( " LESAVKA_RELOAD_UVCVIDEO= ${ LESAVKA_RELOAD_UVCVIDEO } " )
fi
sudo env " ${ CORE_REBUILD_ENV [@] } " /usr/local/bin/lesavka-core.sh
2026-04-27 23:04:18 -03:00
sudo systemctl reset-failed lesavka-core >/dev/null 2>& 1 || true
echo "✅ lesavka-core gadget rebuilt directly."
2026-04-30 15:47:07 -03:00
elif [ [ -n ${ LESAVKA_ALLOW_GADGET_RESET :- } ] ] ; then
echo " ✅ UDC state is ' $UDC_STATE '; LESAVKA_ALLOW_GADGET_RESET permits recovery, but no hard gadget rebuild is needed. "
2026-01-08 23:58:19 -03:00
else
echo " ⚠️ UDC state is ' $UDC_STATE ' - skipping lesavka-core restart. "
2026-04-30 15:47:07 -03:00
echo " Set LESAVKA_ALLOW_GADGET_RESET=1 LESAVKA_FORCE_GADGET_REBUILD=1 to force during a maintenance window."
2026-01-08 23:58:19 -03:00
fi
2025-06-06 20:45:09 -05:00
2026-01-08 00:59:14 -03:00
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-uvc.service >/dev/null
[ Unit]
Description = lesavka UVC control helper
After = lesavka-core.service
Requires = lesavka-core.service
[ Service]
ExecStart = /usr/local/bin/lesavka-uvc.sh
Restart = always
RestartSec = 2
KillSignal = SIGTERM
2026-04-27 21:02:59 -03:00
KillMode = control-group
2026-01-08 00:59:14 -03:00
TimeoutStopSec = 10
2026-04-28 14:23:59 -03:00
StandardError = append:/var/log/lesavka/uvc.stderr
2026-01-08 00:59:14 -03:00
User = root
2026-04-08 20:00:14 -03:00
EnvironmentFile = -/etc/lesavka/uvc.env
2026-01-08 00:59:14 -03:00
[ Install]
WantedBy = multi-user.target
UNIT
2026-04-16 13:54:25 -03:00
sudo systemctl daemon-reload
sudo systemctl enable lesavka-uvc
2026-04-09 12:32:37 -03:00
echo "==> 6d. Systemd units - remove legacy reboot watchdog"
sudo systemctl stop lesavka-watchdog.timer lesavka-watchdog.service >/dev/null 2>& 1 || true
sudo systemctl disable lesavka-watchdog.timer lesavka-watchdog.service >/dev/null 2>& 1 || true
sudo systemctl unmask lesavka-watchdog.timer lesavka-watchdog.service >/dev/null 2>& 1 || true
sudo rm -f /etc/systemd/system/lesavka-watchdog.timer \
/etc/systemd/system/lesavka-watchdog.service \
/usr/local/bin/lesavka-watchdog.sh \
/etc/lesavka/watchdog.touch
2026-01-09 17:23:27 -03:00
sudo systemctl daemon-reload
2026-04-27 16:12:51 -03:00
if [ [ " $UVC_ENV_CHANGED " = = "1" ] ] && systemctl is-active --quiet lesavka-uvc; then
2026-04-27 15:50:29 -03:00
sudo systemctl restart lesavka-uvc
echo "✅ lesavka-uvc restarted with the refreshed UVC runtime settings."
2026-04-27 16:12:51 -03:00
elif systemctl is-active --quiet lesavka-uvc; then
echo "✅ lesavka-uvc already active; runtime settings unchanged."
2026-01-08 23:58:19 -03:00
else
2026-04-28 14:23:59 -03:00
sudo truncate -s 0 /var/log/lesavka/uvc.stderr
sudo systemctl start lesavka-uvc
echo "✅ lesavka-uvc started to attach the UVC gadget to the host."
2026-01-08 23:58:19 -03:00
fi
2026-01-08 00:59:14 -03:00
2026-04-27 16:05:42 -03:00
validate_uvc_gadget_ready
2026-04-28 14:23:59 -03:00
sudo truncate -s 0 /var/log/lesavka/server.stderr
2026-04-27 22:34:46 -03:00
sudo systemctl stop lesavka-server >/dev/null 2>& 1 || true
clear_stale_server_listener
sudo systemctl reset-failed lesavka-server >/dev/null 2>& 1 || true
2025-06-23 00:26:02 -05:00
sudo systemctl restart lesavka-server
2026-04-27 22:34:46 -03:00
validate_server_ready
2026-04-26 00:27:43 -03:00
INSTALLED_VERSION = $( manifest_package_version " $SRC_DIR /server/Cargo.toml " 2>/dev/null || true )
2026-04-25 16:48:20 -03:00
INSTALLED_SHA = $( git -C " $SCRIPT_REPO_ROOT " rev-parse --short HEAD 2>/dev/null || true )
2026-04-27 20:39:31 -03:00
PERSISTED_CAM_OUTPUT = $( grep '^LESAVKA_CAM_OUTPUT=' /etc/lesavka/server.env 2>/dev/null | tail -n1 | cut -d= -f2- || true )
2026-04-28 02:29:32 -03:00
PERSISTED_SERVER_BIND_ADDR = $( grep '^LESAVKA_SERVER_BIND_ADDR=' /etc/lesavka/server.env 2>/dev/null | tail -n1 | cut -d= -f2- || true )
2026-04-27 20:39:31 -03:00
PERSISTED_UVC_CODEC = $( grep '^LESAVKA_UVC_CODEC=' /etc/lesavka/uvc.env 2>/dev/null | tail -n1 | cut -d= -f2- || true )
2025-06-23 00:26:02 -05:00
echo "✅ lesavka-server installed and restarted..."
2026-04-25 16:48:20 -03:00
if [ [ -n $INSTALLED_VERSION || -n $INSTALLED_SHA ] ] ; then
2026-04-26 00:27:43 -03:00
echo " ➡️ Installed: lesavka-server ${ INSTALLED_VERSION :- unknown } ${ INSTALLED_SHA : + ( $INSTALLED_SHA ) } "
2026-04-25 16:48:20 -03:00
fi
2026-04-28 02:29:32 -03:00
if [ [ -n $PERSISTED_SERVER_BIND_ADDR ] ] ; then
echo " ➡️ Server bind: ${ PERSISTED_SERVER_BIND_ADDR } "
fi
2026-04-27 20:39:31 -03:00
if [ [ -n $PERSISTED_CAM_OUTPUT ] ] ; then
echo " ➡️ Camera output: ${ PERSISTED_CAM_OUTPUT } "
fi
if [ [ -n $PERSISTED_UVC_CODEC ] ] ; then
echo " ➡️ UVC codec: ${ PERSISTED_UVC_CODEC } "
fi
2026-04-30 11:38:16 -03:00
echo " ➡️ Client TLS bundle: ${ LESAVKA_CLIENT_BUNDLE } "
echo " ➡️ Client install can use: sudo env LESAVKA_CLIENT_PKI_BUNDLE= ${ LESAVKA_CLIENT_BUNDLE } ./scripts/install/client.sh "
2026-04-08 22:23:40 -03:00
echo "➡️ Status: sudo systemctl status lesavka-server --no-pager"
echo "➡️ Logs: sudo journalctl -u lesavka-server -f --no-pager"
2026-04-26 00:27:43 -03:00
echo " ✅ Installed version: lesavka-server ${ INSTALLED_VERSION :- unknown } ${ INSTALLED_SHA : + ( $INSTALLED_SHA ) } "