-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsvx
More file actions
executable file
·858 lines (767 loc) · 31.1 KB
/
Copy pathsvx
File metadata and controls
executable file
·858 lines (767 loc) · 31.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
#!/usr/bin/env bash
set -uo pipefail
# --- Constants ---
readonly VERSION="2.1.10"
readonly SCRIPT_NAME="Svx Admin"
readonly REPO="audric/svxlink-cmd"
readonly INSTALL_URL="https://raw.githubusercontent.com/${REPO}/master/install.sh"
# Absolute path to this running script, so the self-update can re-exec the new
# copy the installer writes over it (otherwise we'd keep running the old one).
readonly SELF="$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || printf '%s' "$0")"
# --- Root check ---
if [[ $EUID -ne 0 ]]; then
echo "Error: This script must be run as root (sudo ./svx.sh)" >&2
exit 1
fi
# --- Dependency check ---
missing_pkgs=()
if ! command -v dialog &>/dev/null && ! command -v whiptail &>/dev/null; then
missing_pkgs+=("dialog")
fi
# alsa-utils provides amixer/alsamixer (audio panel) and alsactl + the
# alsa-restore service (so stored volumes persist across reboot).
if ! command -v amixer &>/dev/null || ! command -v alsamixer &>/dev/null \
|| ! command -v alsactl &>/dev/null; then
missing_pkgs+=("alsa-utils")
fi
if [[ ${#missing_pkgs[@]} -gt 0 ]]; then
echo "Missing packages: ${missing_pkgs[*]}"
read -rp "Install them now? [Y/n] " answer
case "${answer:-y}" in
[Yy]*)
apt-get update -qq && apt-get install -y "${missing_pkgs[@]}"
;;
*)
echo "Cannot continue without: ${missing_pkgs[*]}" >&2
exit 1
;;
esac
fi
# --- Dialog detection ---
DIALOG=""
if command -v dialog &>/dev/null; then
DIALOG="dialog"
elif command -v whiptail &>/dev/null; then
DIALOG="whiptail"
else
echo "Error: dialog installation failed." >&2
exit 1
fi
# --- Temp file for dialog output ---
DIALOG_OUT=$(mktemp)
# --- Custom dialog theme (dark, no blue) ---
DIALOGRC_FILE=$(mktemp)
trap 'rm -f "$DIALOG_OUT" "$DIALOGRC_FILE"' EXIT
if [[ "$DIALOG" == "dialog" ]]; then
cat > "$DIALOGRC_FILE" <<'THEME'
use_shadow = OFF
use_colors = ON
screen_color = (WHITE,BLACK,ON)
shadow_color = (BLACK,BLACK,ON)
dialog_color = (WHITE,BLACK,OFF)
title_color = (GREEN,BLACK,ON)
border_color = (WHITE,BLACK,ON)
button_active_color = (WHITE,BLUE,ON)
button_inactive_color = (WHITE,BLACK,OFF)
button_key_active_color = (WHITE,BLUE,ON)
button_key_inactive_color = (WHITE,BLACK,ON)
button_label_active_color = (WHITE,BLUE,ON)
button_label_inactive_color = (WHITE,BLACK,ON)
menubox_color = (WHITE,BLACK,OFF)
menubox_border_color = (WHITE,BLACK,OFF)
menubox_border2_color = (WHITE,BLACK,OFF)
item_color = (WHITE,BLACK,OFF)
item_selected_color = (BLACK,WHITE,OFF)
tag_color = (GREEN,BLACK,ON)
tag_selected_color = (BLACK,WHITE,ON)
tag_key_color = (GREEN,BLACK,ON)
tag_key_selected_color = (BLACK,WHITE,ON)
check_color = (WHITE,BLACK,OFF)
check_selected_color = (BLACK,GREEN,ON)
uarrow_color = (GREEN,BLACK,ON)
darrow_color = (GREEN,BLACK,ON)
inputbox_color = (WHITE,BLACK,OFF)
inputbox_border_color = (WHITE,BLACK,ON)
form_active_text_color = (BLACK,GREEN,ON)
form_text_color = (WHITE,BLACK,OFF)
form_item_readonly_color = (WHITE,BLACK,ON)
gauge_color = (GREEN,BLACK,ON)
border2_color = (WHITE,BLACK,OFF)
searchbox_color = (WHITE,BLACK,OFF)
searchbox_title_color = (GREEN,BLACK,ON)
searchbox_border_color = (WHITE,BLACK,ON)
position_indicator_color = (GREEN,BLACK,ON)
THEME
export DIALOGRC="$DIALOGRC_FILE"
fi
# --- Editor detection ---
EDITOR="${EDITOR:-$(command -v nano || command -v vi || echo "vi")}"
# --- Services ---
readonly SERVICES=("svxlink" "remotetrx" "svxreflector")
declare -A SERVICE_LABELS=(
[svxlink]="SvxLink"
[remotetrx]="RemoteTRX"
[svxreflector]="SvxReflector"
)
declare -A CONFIG_FILES=(
[svxlink]="/etc/svxlink/svxlink.conf"
[remotetrx]="/etc/svxlink/remotetrx.conf"
[svxreflector]="/etc/svxlink/svxreflector.conf"
)
declare -A ENV_FILES=(
[svxlink]="/etc/default/svxlink"
[remotetrx]="/etc/default/remotetrx"
[svxreflector]="/etc/default/svxreflector"
)
declare -A LOG_FILES=(
[svxlink]="/var/log/svxlink"
[remotetrx]="/var/log/remotetrx"
[svxreflector]="/var/log/svxreflector"
)
# Resolve the actual log file path for a service. SvxLink's log location
# varies between installs: a custom systemd unit may pass --logfile=PATH,
# the Debian package sets LOGFILE= in /etc/default/<svc>, and the path may
# or may not carry a .log extension. Probe those sources in order, then
# fall back to the conventional /var/log/<svc>.
resolve_logfile() {
local svc="$1" lf="" env_lf=""
# Read LOGFILE= from the service's /etc/default file (Debian package style)
# up front: the stock systemd unit references it as ${LOGFILE} rather than
# spelling out a literal path.
if [[ -f "${ENV_FILES[$svc]}" ]]; then
env_lf=$(grep -E '^[[:space:]]*LOGFILE=' "${ENV_FILES[$svc]}" \
| tail -n1 | sed 's/^[^=]*=//; s/^["'\'']//; s/["'\'']$//')
fi
# 1. --logfile=PATH (or --logfile PATH) in the systemd unit's ExecStart
lf=$(systemctl cat "${svc}.service" 2>/dev/null \
| sed -n 's/.*--logfile[ =]\([^ ]*\).*/\1/p' | head -n1)
# The stock unit passes --logfile=${LOGFILE} (an unexpanded shell variable,
# not a real path); substitute the env file's value rather than testing for
# a file literally named "${LOGFILE}".
[[ "$lf" == *'$'* ]] && lf="$env_lf"
# 2. Fall back to the env file's LOGFILE
[[ -z "$lf" ]] && lf="$env_lf"
# 3. Conventional default
[[ -z "$lf" ]] && lf="${LOG_FILES[$svc]}"
printf '%s' "$lf"
}
readonly GPIO_CONF="/etc/svxlink/gpio.conf"
# DTMF command written to the logic's DTMF_CTRL_PTY to make the repeater
# transmit (default '*#' = ~16 s voice status/ident readback).
readonly IDENT_CMD="*#"
# DTMF prefix for reflector talkgroup selection. The full sequence sent is
# <prefix><tg># — e.g. selecting TG 91 sends '9191#'.
readonly TG_SELECT_PREFIX="91"
# Get service status as a word: active, inactive, failed, unknown
get_service_status() {
local st
st=$(systemctl is-active "${1}.service" 2>/dev/null) || true
echo "${st:-unknown}"
}
# Build a status summary string for the menu title
build_status_title() {
local title=""
for svc in "${SERVICES[@]}"; do
local status
status=$(get_service_status "$svc")
local label="${SERVICE_LABELS[$svc]}"
if [[ "$DIALOG" == "dialog" ]]; then
case "$status" in
active) title+="\\Z2● ${label}\\Zn " ;;
failed) title+="\\Z1● ${label}\\Zn " ;;
*) title+="\\Z3○ ${label}\\Zn " ;;
esac
else
case "$status" in
active) title+="[UP] ${label} " ;;
failed) title+="[FAIL] ${label} " ;;
*) title+="[DOWN] ${label} " ;;
esac
fi
done
echo "$title"
}
# Build system info string for the title bar
build_sysinfo() {
local info=""
# CPU temperature
local temp_file="/sys/class/thermal/thermal_zone0/temp"
if [[ -r "$temp_file" ]]; then
local raw
raw=$(<"$temp_file")
info+="${temp_file:+$((raw / 1000))°C}"
fi
# Uptime
local up
up=$(uptime -p 2>/dev/null | sed 's/up //;s/ days\?/d/;s/ hours\?/h/;s/ minutes\?/m/;s/,//g') || true
[[ -n "$up" ]] && info+=" Up ${up}"
# Last boot
local last_boot
last_boot=$(uptime -s 2>/dev/null) || true
[[ -n "$last_boot" ]] && info+=" Since ${last_boot%:*}"
echo "$info"
}
# =============================================================================
# Service Picker
# =============================================================================
# Sets PICKED_SERVICE to the chosen service name, or returns 1 on cancel.
PICKED_SERVICE=""
pick_service() {
PICKED_SERVICE=""
local prompt="${1:-Select a service:}"
local items=()
local i=1
for svc in "${SERVICES[@]}"; do
local status
status=$(get_service_status "$svc")
items+=("$i" "$(printf "%-14s(%s)" "${SERVICE_LABELS[$svc]}" "$status")")
((i++))
done
if [[ "$DIALOG" == "dialog" ]]; then
dialog --colors --no-shadow --title " Select Service " \
--menu "\n$prompt\n" 14 0 3 \
"${items[@]}" 2>"$DIALOG_OUT" || return 1
else
whiptail --title " Select Service " \
--menu "\n$prompt\n" 14 0 3 \
"${items[@]}" 2>"$DIALOG_OUT" || return 1
fi
local choice
choice=$(<"$DIALOG_OUT")
PICKED_SERVICE="${SERVICES[$((choice - 1))]}"
[[ -n "$PICKED_SERVICE" ]] || return 1
}
# =============================================================================
# Result Display
# =============================================================================
show_result() {
local title="$1"
local msg="$2"
if [[ "$DIALOG" == "dialog" ]]; then
dialog --colors --no-shadow --title "$title" --msgbox "$msg" 15 60
else
whiptail --title "$title" --msgbox "$msg" 15 60
fi
}
# Yes/No prompt. Returns 0 on Yes, non-zero otherwise. A third arg of
# "defaultno" pre-selects No (use it for actions that interrupt service).
confirm_yesno() {
local title="$1"
local msg="$2"
local extra=()
[[ "${3:-}" == "defaultno" ]] && extra=(--defaultno)
if [[ "$DIALOG" == "dialog" ]]; then
dialog --colors --no-shadow --title "$title" "${extra[@]}" --yesno "$msg" 12 60
else
whiptail --title "$title" "${extra[@]}" --yesno "$msg" 12 60
fi
}
# =============================================================================
# Service Control Actions
# =============================================================================
do_service_action() {
local action="$1"
pick_service "Select service to ${action}:" || return
local svc="$PICKED_SERVICE"
local output
output=$(systemctl "$action" "${svc}.service" 2>&1) && \
show_result "Success" "${SERVICE_LABELS[$svc]}: ${action} completed.\n\n${output}" || \
show_result "Error" "${SERVICE_LABELS[$svc]}: ${action} failed.\n\n${output}"
}
action_start() { do_service_action "start"; }
action_stop() { do_service_action "stop"; }
action_restart() { do_service_action "restart"; }
action_reload() { do_service_action "reload"; }
action_enable() { do_service_action "enable"; }
action_disable() { do_service_action "disable"; }
# =============================================================================
# Monitoring Actions
# =============================================================================
action_status() {
pick_service "Show status for:" || return
local svc="$PICKED_SERVICE"
local output
output=$(systemctl status "${svc}.service" 2>&1 || true)
show_result "${SERVICE_LABELS[$svc]} Status" "$output"
}
action_logs() {
pick_service "Follow logs for:" || return
local svc="$PICKED_SERVICE"
local logfile; logfile=$(resolve_logfile "$svc")
local log_items=("journald" "$(printf "%-12s%s" "journalctl" "-u ${svc}.service")")
if [[ -f "$logfile" ]]; then
log_items+=("file" "$(printf "%-12s%s" "tail -f" "${logfile}")")
fi
if [[ "$DIALOG" == "dialog" ]]; then
dialog --colors --no-shadow --title "Log Source" \
--menu "\nChoose log source for ${SERVICE_LABELS[$svc]}:\n" 12 55 2 \
"${log_items[@]}" 2>"$DIALOG_OUT" || return
else
whiptail --title "Log Source" \
--menu "\nChoose log source for ${SERVICE_LABELS[$svc]}:\n" 12 55 2 \
"${log_items[@]}" 2>"$DIALOG_OUT" || return
fi
local source
source=$(<"$DIALOG_OUT")
clear
echo "=== Following logs for ${SERVICE_LABELS[$svc]} (Ctrl+C to return) ==="
echo ""
(
trap 'exit 0' INT
if [[ "$source" == "journald" ]]; then
journalctl -u "${svc}.service" -f
else
tail -f "$logfile"
fi
)
}
# =============================================================================
# Configuration Actions
# =============================================================================
action_edit_config() {
pick_service "Edit config for:" || return
local svc="$PICKED_SERVICE"
local conf="${CONFIG_FILES[$svc]}"
if [[ ! -f "$conf" ]]; then
show_result "Error" "Config file not found:\n${conf}"
return
fi
$EDITOR "$conf"
}
action_edit_gpio() {
if [[ ! -f "$GPIO_CONF" ]]; then
show_result "Error" "GPIO config not found:\n${GPIO_CONF}"
return
fi
$EDITOR "$GPIO_CONF"
}
action_edit_env() {
pick_service "Edit environment for:" || return
local svc="$PICKED_SERVICE"
local envf="${ENV_FILES[$svc]}"
if [[ ! -f "$envf" ]]; then
show_result "Error" "Environment file not found:\n${envf}"
return
fi
$EDITOR "$envf"
}
# =============================================================================
# GPIO Action
# =============================================================================
# Ensure mixer settings will be reloaded at boot.
# alsactl writes the saved state; the alsa-restore service reloads it on boot.
# Sets REPLY to a human-readable reason and returns 1 if persistence is not
# guaranteed (so stored volumes would silently revert after a reboot).
ensure_alsa_restore() {
REPLY=""
if ! command -v alsactl &>/dev/null; then
REPLY="alsactl missing — install the 'alsa-utils' package."
return 1
fi
local unit status
for unit in alsa-restore.service alsa-state.service; do
status=$(systemctl is-enabled "$unit" 2>/dev/null) || status=""
case "$status" in
enabled|enabled-runtime|static|indirect)
return 0 ;; # will run at boot
disabled)
systemctl enable "$unit" &>/dev/null && return 0
REPLY="Could not enable ${unit}."
return 1 ;;
masked)
REPLY="${unit} is masked — run: systemctl unmask ${unit}"
return 1 ;;
esac
done
REPLY="No alsa-restore service found — saved volumes may not reload at boot."
return 1
}
# Save current mixer levels (optionally for one card) so they survive a reboot,
# and make sure the alsa-restore service will reload them at boot. amixer changes
# are otherwise in-memory only and lost on the next boot.
persist_alsa_volumes() {
local card="${1:-}"
if command -v alsactl &>/dev/null; then
if [[ -n "$card" ]]; then
alsactl store "$card" 2>/dev/null || true
else
alsactl store 2>/dev/null || true
fi
fi
if ! ensure_alsa_restore; then
show_result "Audio" "Volumes saved, but may not persist across reboot:\n\n${REPLY}"
fi
}
action_alsamixer() {
local usb_card=""
if [[ -f /proc/asound/cards ]]; then
usb_card=$(awk '/USB-Audio/{print $1; exit}' /proc/asound/cards)
fi
if [[ -n "$usb_card" ]]; then
alsamixer -c "$usb_card" || true
else
alsamixer || true
fi
persist_alsa_volumes "$usb_card"
}
# The ALSA capture device SvxLink's receiver actually uses, read from its config
# (the first [Rx*] section's AUDIO_DEV, minus the "alsa:" prefix). This is the
# authoritative source — card *indices* can shuffle across reboots, so a USB
# heuristic can point at the wrong card; SvxLink's own setting cannot.
rx_audio_dev() {
awk '
/^\[Rx[0-9]*\]/ { inrx = 1; next }
/^\[/ { inrx = 0 }
inrx && /^[[:space:]]*AUDIO_DEV[[:space:]]*=/ {
sub(/^[[:space:]]*AUDIO_DEV[[:space:]]*=[[:space:]]*/, "")
sub(/[[:space:]]*#.*/, ""); sub(/^alsa:/, ""); sub(/[[:space:]]+$/, "")
print; exit
}' "${CONFIG_FILES[svxlink]}" 2>/dev/null
}
# Extract the card token (index or CARD=name) from an ALSA device string,
# e.g. plughw:1,0 -> 1 plughw:CARD=Device,0 -> Device (for amixer/alsactl -c).
dev_card() { printf '%s' "$1" | sed -E 's/^(plug)?hw://; s/,.*//; s/^CARD=//'; }
# Live RX input-level meter (external svx-vumeter binary). It frees the capture
# device by stopping svxlink for the session and restarts it again on exit, then
# saves the levels so they persist across reboots.
action_rx_meter() {
local bin
bin=$(command -v svx-vumeter) || bin="/usr/local/bin/svx-vumeter"
if [[ ! -x "$bin" ]]; then
show_result "RX level meter" \
"svx-vumeter is not installed.\n\nInstall it (and refresh svx) with:\n\n curl -fsSL https://raw.githubusercontent.com/audric/svxlink-cmd/master/install.sh | sudo sh"
return
fi
confirm_yesno "RX level meter" \
"This STOPS SvxLink for the session — the repeater goes off the air — and restarts it when you quit the meter (q).\n\nProceed?" \
defaultno || return
# Use exactly the device SvxLink receives on; fall back to the first USB card.
local dev card args=(-stop svxlink)
dev=$(rx_audio_dev)
if [[ -z "$dev" && -f /proc/asound/cards ]]; then
local usb; usb=$(awk '/USB-Audio/{print $1; exit}' /proc/asound/cards)
[[ -n "$usb" ]] && dev="plughw:${usb},0"
fi
if [[ -n "$dev" ]]; then
card=$(dev_card "$dev")
args+=(-D "$dev" -c "$card")
fi
clear
"$bin" "${args[@]}" || true
# amixer changes are in-memory only — save them so a reboot keeps them.
persist_alsa_volumes "$card"
}
# Discover every DTMF_CTRL_PTY path configured for SvxLink. The PTY name is
# per-logic and differs between systems, and logics may be split across
# included *.conf files — so never hardcode it; scan the config(s).
discover_ctrl_ptys() {
local conf="${CONFIG_FILES[svxlink]}"
local dir; dir=$(dirname "$conf")
local files=() f
[[ -f "$conf" ]] && files+=("$conf")
for f in "$dir"/*.conf "$dir"/*.d/*.conf; do
[[ -f "$f" && "$f" != "$conf" ]] && files+=("$f")
done
(( ${#files[@]} )) || return 0
# Pull the value, strip inline comments/whitespace, de-duplicate.
sed -n 's/^[[:space:]]*DTMF_CTRL_PTY[[:space:]]*=[[:space:]]*\([^[:space:]#]*\).*/\1/p' \
"${files[@]}" 2>/dev/null | awk 'NF && !seen[$0]++'
}
# Pick a present DTMF_CTRL_PTY, prompting the user when several logics expose
# one. Echoes the chosen path on stdout; returns non-zero (after showing an
# explanatory dialog) when none is configured/present or the user cancels.
# $1 is the dialog title to use for any messages.
pick_ctrl_pty() {
local title="$1"
local all=() present=() p
mapfile -t all < <(discover_ctrl_ptys)
if (( ${#all[@]} == 0 )); then
show_result "$title" \
"No DTMF_CTRL_PTY configured for svxlink.\n\nAdd e.g.\n DTMF_CTRL_PTY=/tmp/RepeaterLogic.pty\nto your logic section and restart svxlink."
return 1
fi
# Note: -L (lstat) as well as -e, because the PTY is usually a symlink owned
# by the svxlink user in /tmp; with fs.protected_symlinks set, root cannot
# follow it, so a plain -e would wrongly report it missing.
for p in "${all[@]}"; do [[ -e "$p" || -L "$p" ]] && present+=("$p"); done
if (( ${#present[@]} == 0 )); then
show_result "$title" \
"DTMF_CTRL_PTY is configured but not present:\n${all[*]}\n\nIs svxlink running with the logic loaded?"
return 1
fi
if (( ${#present[@]} == 1 )); then
printf '%s' "${present[0]}"
return 0
fi
# Several logics expose a PTY — let the user choose which to use.
local items=() i
for i in "${!present[@]}"; do items+=("$i" "${present[$i]}"); done
if [[ "$DIALOG" == "dialog" ]]; then
dialog --colors --no-shadow --title " $title " \
--menu "\nChoose which logic's control PTY:\n" 15 60 6 "${items[@]}" 2>"$DIALOG_OUT" || return 1
else
whiptail --title " $title " \
--menu "\nChoose which logic's control PTY:\n" 15 60 6 "${items[@]}" 2>"$DIALOG_OUT" || return 1
fi
local pty="${present[$(cat "$DIALOG_OUT")]}"
[[ -n "$pty" ]] || return 1
printf '%s' "$pty"
}
# Write a DTMF command string ($1) to a control PTY ($2). Tries a direct write
# first — that succeeds when we already own the PTY (e.g. a root-owned PTY while
# running as root). Only if that's denied fall back to writing as the PTY's
# owner, since the pts is mode 600 and root cannot always write to a slave owned
# by another user (e.g. svxlink running under its own service account).
# 2>/dev/null comes first so a failed-redirection error (root blocked from
# following the svxlink-owned symlink) is swallowed, not printed over the TUI.
deliver_to_pty() {
local cmd="$1" pty="$2" owner
owner=$(stat -c %U "$pty" 2>/dev/null)
printf %s "$cmd" 2>/dev/null > "$pty" \
|| { [[ -n "$owner" ]] && su -s /bin/sh "$owner" -c "printf %s '$cmd' > '$pty'" 2>/dev/null; }
}
# Make the repeater transmit by writing IDENT_CMD to a logic's DTMF_CTRL_PTY.
action_send_ident() {
local pty owner
pty=$(pick_ctrl_pty "Send ident") || return
confirm_yesno "Send ident" \
"Send '$IDENT_CMD' to:\n$pty\n\nThis keys the transmitter ON the air." || return
if deliver_to_pty "$IDENT_CMD" "$pty"; then
show_result "Send ident" "Sent '$IDENT_CMD' — the repeater should transmit its readback now."
else
owner=$(stat -c %U "$pty" 2>/dev/null)
show_result "Send ident" "Failed to write '$IDENT_CMD' to:\n$pty\n(owner '${owner:-unknown}'). Try running svx as root."
fi
}
# Select a reflector talkgroup by writing the TgSelect DTMF sequence
# (<TG_SELECT_PREFIX><tg>#) to a logic's DTMF_CTRL_PTY.
action_select_tg() {
local pty owner tg cmd
pty=$(pick_ctrl_pty "Select talkgroup") || return
if [[ "$DIALOG" == "dialog" ]]; then
dialog --colors --no-shadow --title " Select talkgroup " \
--inputbox "\nEnter the talkgroup number to select:\n" 10 50 2>"$DIALOG_OUT" || return
else
whiptail --title " Select talkgroup " \
--inputbox "\nEnter the talkgroup number to select:\n" 10 50 2>"$DIALOG_OUT" || return
fi
tg=$(cat "$DIALOG_OUT")
if [[ ! "$tg" =~ ^[0-9]+$ ]]; then
show_result "Select talkgroup" "Invalid talkgroup '$tg'.\n\nEnter digits only."
return
fi
cmd="${TG_SELECT_PREFIX}${tg}#"
if deliver_to_pty "$cmd" "$pty"; then
show_result "Select talkgroup" "Sent '$cmd' — talkgroup $tg selected."
else
owner=$(stat -c %U "$pty" 2>/dev/null)
show_result "Select talkgroup" "Failed to write '$cmd' to:\n$pty\n(owner '${owner:-unknown}'). Try running svx as root."
fi
}
# Disconnect from the reflector talkgroup by selecting TG 0 — the standard
# SvxLink "leave talkgroup" action (sends <TG_SELECT_PREFIX>0#).
action_disconnect_reflector() {
local pty owner cmd
pty=$(pick_ctrl_pty "Disconnect reflector") || return
cmd="${TG_SELECT_PREFIX}0#"
confirm_yesno "Disconnect reflector" \
"Send '$cmd' to:\n$pty\n\nThis leaves the current talkgroup (selects TG 0)." || return
if deliver_to_pty "$cmd" "$pty"; then
show_result "Disconnect reflector" "Sent '$cmd' — disconnected from the talkgroup."
else
owner=$(stat -c %U "$pty" 2>/dev/null)
show_result "Disconnect reflector" "Failed to write '$cmd' to:\n$pty\n(owner '${owner:-unknown}'). Try running svx as root."
fi
}
readonly LOGROTATE_CONF="/etc/logrotate.d/svxlink"
readonly LOGROTATE_OPTS="{
weekly
rotate 4
compress
delaycompress
missingok
notifempty
copytruncate
}"
action_check_logrotate() {
# Collect the log files actually in use (paths vary by install)
local -a logs=()
local svc lf
for svc in "${SERVICES[@]}"; do
lf=$(resolve_logfile "$svc")
[[ -f "$lf" ]] && logs+=("$lf")
done
if [[ ${#logs[@]} -eq 0 ]]; then
show_result "No File Logs" "No SvxLink file-based logs found.\n\nServices are logging to journald only.\nNo log rotation needed."
return
fi
# File logs exist — check logrotate
if ! command -v logrotate &>/dev/null; then
show_result "Warning" "SvxLink is writing to log files but\nlogrotate is not installed.\n\nInstall it with:\n apt install logrotate\n\nThen run this check again."
return
fi
if [[ -f "$LOGROTATE_CONF" ]]; then
local content
content=$(<"$LOGROTATE_CONF")
show_result "Log Rotation OK" "Config: ${LOGROTATE_CONF}\n\n${content}"
else
if [[ "$DIALOG" == "dialog" ]]; then
dialog --colors --no-shadow --title " Log Rotation Missing " \
--yesno "\nSvxLink is writing to log files but no\nlogrotate config was found.\n\nCreate ${LOGROTATE_CONF}?\n\nWeekly rotation, 4 copies, compressed.\n" 15 55 2>"$DIALOG_OUT"
else
whiptail --title " Log Rotation Missing " \
--yesno "\nSvxLink is writing to log files but no\nlogrotate config was found.\n\nCreate ${LOGROTATE_CONF}?\n\nWeekly rotation, 4 copies, compressed.\n" 15 55 2>"$DIALOG_OUT"
fi
if [[ $? -eq 0 ]]; then
{ printf '%s\n' "${logs[@]}"; printf '%s\n' "$LOGROTATE_OPTS"; } > "$LOGROTATE_CONF"
chmod 644 "$LOGROTATE_CONF"
show_result "Success" "Created ${LOGROTATE_CONF}\n\nLog rotation is now active."
fi
fi
}
action_health_check() {
local report=""
# Disk usage
report+="DISK USAGE\n"
report+="$(df -h --output=target,size,used,avail,pcent / /boot 2>/dev/null | head -5)\n"
report+="\n"
# Top 10 CPU consuming processes
report+="TOP 10 PROCESSES (CPU)\n"
report+="$(ps -eo pid,user,%cpu,%mem,comm --sort=-%cpu | head -11)\n"
if [[ "$DIALOG" == "dialog" ]]; then
dialog --colors --no-shadow --title " System Health " \
--msgbox "$report" 28 70
else
whiptail --title " System Health " \
--msgbox "$report" 28 70
fi
}
action_gpio_restart() {
local output
output=$(systemctl restart svxlink_gpio_setup.service 2>&1) && \
show_result "Success" "GPIO setup restarted.\n\n${output}" || \
show_result "Error" "GPIO setup restart failed.\n\n${output}"
}
# Update svx (and svx-vumeter) to the latest release by re-running the published
# installer. We're already root, so it can write to BINDIR. On success it offers
# to re-exec the freshly installed script so the new version takes effect now.
action_update() {
local avail output rc tmp
if ! command -v curl &>/dev/null; then
show_result "Update" "curl is required to update but was not found.\n\nInstall it with:\n apt install curl"
return
fi
# Best-effort: read the VERSION line from the remote svx so we can show what
# the update would install. Empty if offline — we still offer to proceed.
avail=$(curl -fsSL "https://raw.githubusercontent.com/${REPO}/master/svx" 2>/dev/null \
| sed -n 's/^readonly VERSION="\(.*\)"/\1/p' | head -1)
confirm_yesno "Update" \
"Current version: $VERSION\nAvailable: ${avail:-unknown}\n\nDownload and install the latest svx and svx-vumeter from GitHub?" \
|| return
# Fetch the installer to a file first — piping curl straight into sh would
# feed curl's own error text to the shell if the download failed.
tmp=$(mktemp)
if ! curl -fsSL "$INSTALL_URL" -o "$tmp" 2>/dev/null; then
rm -f "$tmp"
show_result "Update" "Could not download the installer from:\n$INSTALL_URL\n\nCheck network connectivity."
return
fi
output=$(sh "$tmp" 2>&1); rc=$?
rm -f "$tmp"
if [[ $rc -eq 0 ]]; then
# The installer overwrote this script on disk, but we're still running the
# old copy in memory. Re-exec the new one so it takes effect immediately;
# if the user declines, the change applies on the next manual launch.
if confirm_yesno "Update" "Update finished.\n\n${output}\n\nRestart now to load the new version?"; then
clear
exec "$SELF"
fi
show_result "Update" "Update installed.\n\nIt will take effect next time you start svx."
else
show_result "Update" "Update failed.\n\n${output}"
fi
}
# =============================================================================
# Main Menu
# =============================================================================
main_menu() {
local status_title sysinfo
status_title=$(build_status_title)
sysinfo=$(build_sysinfo)
local W=30
local menu_items=(
"1" "$(printf "%-${W}s" "Start service")"
"2" "$(printf "%-${W}s" "Stop service")"
"3" "$(printf "%-${W}s" "Restart service")"
"4" "$(printf "%-${W}s" "Reload config (SIGHUP)")"
"─1" "$(printf "%-${W}s" "─── Monitoring ─────────")"
"5" "$(printf "%-${W}s" "Show detailed status")"
"6" "$(printf "%-${W}s" "Follow live logs")"
"7" "$(printf "%-${W}s" "System health check")"
"─2" "$(printf "%-${W}s" "─── Configuration ──────")"
"8" "$(printf "%-${W}s" "Edit config file")"
"9" "$(printf "%-${W}s" "Edit GPIO config")"
"10" "$(printf "%-${W}s" "Edit environment defaults")"
"─3" "$(printf "%-${W}s" "─── Audio ──────────────")"
"11" "$(printf "%-${W}s" "Alsamixer")"
"12" "$(printf "%-${W}s" "RX level meter")"
"13" "$(printf "%-${W}s" "Send ident (TX readback)")"
"─6" "$(printf "%-${W}s" "─── Reflector ──────────")"
"14" "$(printf "%-${W}s" "Select talkgroup")"
"15" "$(printf "%-${W}s" "Disconnect reflector")"
"─4" "$(printf "%-${W}s" "─── Maintenance ────────")"
"16" "$(printf "%-${W}s" "Check log rotation")"
"17" "$(printf "%-${W}s" "Update svxadmin to latest")"
"─5" "$(printf "%-${W}s" "─── Boot & GPIO ────────")"
"18" "$(printf "%-${W}s" "Enable service at boot")"
"19" "$(printf "%-${W}s" "Disable service at boot")"
"20" "$(printf "%-${W}s" "Restart GPIO setup")"
)
if [[ "$DIALOG" == "dialog" ]]; then
dialog --colors --no-shadow \
--title " $SCRIPT_NAME v$VERSION " \
--backtitle "$status_title | $sysinfo" \
--cancel-label "Quit" \
--menu "\nChoose an action:\n" 0 50 0 \
"${menu_items[@]}" 2>"$DIALOG_OUT"
else
whiptail \
--title " $SCRIPT_NAME v$VERSION " \
--backtitle "$status_title | $sysinfo" \
--cancel-button "Quit" \
--menu "\nChoose an action:\n" 0 50 0 \
"${menu_items[@]}" 2>"$DIALOG_OUT"
fi
}
# =============================================================================
# Main Loop
# =============================================================================
while true; do
main_menu || break
choice=$(cat "$DIALOG_OUT")
case "$choice" in
1) action_start ;;
2) action_stop ;;
3) action_restart ;;
4) action_reload ;;
5) action_status ;;
6) action_logs ;;
7) action_health_check ;;
8) action_edit_config ;;
9) action_edit_gpio ;;
10) action_edit_env ;;
11) action_alsamixer ;;
12) action_rx_meter ;;
13) action_send_ident ;;
14) action_select_tg ;;
15) action_disconnect_reflector ;;
16) action_check_logrotate ;;
17) action_update ;;
18) action_enable ;;
19) action_disable ;;
20) action_gpio_restart ;;
*) ;; # separator selected, ignore
esac
done
clear
echo "Goodbye!"