feat: vmcec.sh tests HDMI-CEC against a fake TV; vmwake.sh wakes the VM over QMP

run.sh opens a QMP socket; the skill documents the CEC test and its gotchas.
This commit is contained in:
theupriser committed 2026-09-25 11:14:00 +02:00
1 parent e04db1ec5f
commit 04204f1b9d
4 files changed
+277 -21

No files matched your search

+35 -21
View File
@@ -274,33 +274,47 @@ the environment through). fwupd correctly refuses the firmware in the VM
A successful dry run counts as staged, so `[m]`/`[r]` and the restart question
on `q` show up too; in dry-run mode "restart" only prints.
## Fake HDMI-CEC (vivid)
## Fake HDMI-CEC (vivid): `scripts/vmcec.sh`
The VM has no CEC hardware; the kernel's `vivid` test driver emulates it: a
virtual HDMI input (`/dev/cec0`, plays the TV) and output (`/dev/cec1`, the
PC). Needs the HDMI-CEC item on (cecd installed); `cec-ctl` is in v4l-utils.
virtual HDMI input (`/dev/cec0`, plays the TV via `cec-ctl`/`cec-follower`)
and output (`/dev/cec1`, the PC, where cecd runs). Needs the HDMI-CEC item on;
the Steam-settings checks need Steam Machine support (steamos-manager).
```bash
ssh -p 2222 -o BatchMode=yes theupriser@localhost bash -s << 'EOF'
export XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
sudo modprobe vivid n_devs=1 num_inputs=1 input_types=0x3 num_outputs=1 output_types=0x1
cec-ctl -d /dev/cec0 --tv --phys-addr 0.0.0.0 --osd-name "Sony TV" >/dev/null
# plug the output into the input: the PC side gets 1.0.0.0
v4l2-ctl -d /dev/video0 --set-ctrl hdmi_000_0_is_connected_to=2
# the cecd service (-e) grabs every CEC device, the TV too: run it on the output only
systemctl --user stop cecd; systemd-run --user -q --unit=cecd-test /usr/bin/cecd -d /dev/cec1
sleep 4; cec-ctl -d /dev/cec1 | grep -E "Physical Address|Logical Address " # 1.0.0.0, e.g. 8
EOF
scripts/vmcec.sh # all checks + a real suspend/resume (~5 min)
scripts/vmcec.sh --no-sleep # without suspending the VM
scripts/vmcec.sh --sleep-only # only the suspend/resume check
scripts/vmwake.sh # wake a sleeping VM (QMP socket from run.sh)
```
Then, with `T="cec-ctl -d /dev/cec0 -t 8"` (the PC's logical address):
`$T --give-osd-name` (the Steam Machine answers "Steam Machine"), remote keys
`$T --user-control-pressed ui-cmd=down` + `$T --user-control-released`
(arrive as KEY_DOWN on the `cecd vivid-000-vid-out0` input device; there's no
evtest, read `/dev/input/eventN` with a small python struct reader), and PC to
TV: `sudo cec-ctl -d /dev/cec0 -M` (monitor needs root) while running
`cectool -d /dev/cec1 set-active|volume-up|standby`. Afterwards
`systemctl --user stop cecd-test; sudo modprobe -r vivid; systemctl --user start cecd`.
It prints PASS/FAIL/SKIP for: identity (address 1.0.0.0, name "Steam
Machine", Valve vendor ID), every TV remote key and the key it becomes, the
Steam settings over D-Bus (remote control, WakeTv, SuspendTv, SuspendDevice
and cecd's config), PC to TV (make active, wake, volume, audio status,
standby), TV standby putting the PC to sleep (blocked by an inhibitor), and
sleep/wake turning the TV off and on.
Gotchas it handles, learned the hard way:
- The cecd service (`-e`) grabs every CEC device, the fake TV too: the test
uses a temporary drop-in (`~/.config/systemd/user/cecd.service.d/vmcec.conf`)
with `-d /dev/cec1`, removed at the end.
- The TV's power button (KEY_POWER) and TV standby with SuspendDevice on
really suspend the VM (Steam Machine support makes the power button sleep):
block with `sudo systemd-inhibit --what=sleep:handle-power-key` (as user it's
refused). If the VM sleeps anyway: `scripts/vmwake.sh` (the QEMU window's
keyboard doesn't wake it; a VM started before run.sh had `-qmp` needs a hard restart).
- `rtcwake` skips logind, so cecd never sees the sleep: suspend with
`systemctl suspend` and wake over QMP.
- `steamosctl get/set-hdmi-cec-suspend-tv|suspend-device` don't work
(steamos-manager 26.4.1); Steam uses the D-Bus properties of
`com.steampowered.SteamOSManager1.HdmiCec2`, and so does the test.
- The kernel answers "give OSD name" itself, even without cecd: it's no proof
cecd runs. cecd logs "Putting TV in standby" only at `RUST_LOG=debug`.
- CecDevice1 methods take arguments (`VolumeUp y 0`, Daemon1 `Standby b true`).
- After the test (or a hard restart) CachyOS may put autologin back on
gamescope: set the session to plasma and restart the display manager.
The menu item is 7 (8 = Steam Machine support, 9 = kernel pin, 10 = BIOS with `--fremont`).
## Steamify shortcut
+1
View File
@@ -49,4 +49,5 @@ exec qemu-system-x86_64 \
-nic user,model=virtio-net-pci,hostfwd=tcp::2222-:22 \
-virtfs local,path="$repo",mount_tag=repo,security_model=mapped-xattr \
-virtfs local,path="$PWD/share",mount_tag=vmtools,security_model=mapped-xattr,readonly=on \
-qmp unix:"$PWD/qmp.sock",server=on,wait=off \
"${smbios[@]}" "${cdrom[@]}"
+230
View File
@@ -0,0 +1,230 @@
#!/bin/bash
# Test HDMI-CEC in the guest against a fake TV: the kernel's vivid driver
# gives a virtual HDMI input (/dev/cec0, plays the TV, driven with cec-ctl)
# and output (/dev/cec1, the PC, where cecd runs). Needs the wizard's HDMI-CEC
# item on (cecd, cec-ctl from v4l-utils); steamos-manager (Steam Machine
# support) for the Steam settings tests. Prints PASS/FAIL/SKIP per check.
#
# scripts/vmcec.sh everything, including a real suspend/resume
# (needs the QMP socket from run.sh to wake up)
# scripts/vmcec.sh --no-sleep everything except suspending the VM
# scripts/vmcec.sh --sleep-only only the real sleep/wake check (after a full run)
#
# Afterwards the normal cecd service runs again; vivid stays loaded.
# Env: VM_USER (theupriser), VM_PORT (2222), VM_HOST (localhost).
set -euo pipefail
. "$(dirname "$0")/common.sh"
SLEEP_TEST=true SLEEP_ONLY=false
[[ "${1:-}" == --no-sleep ]] && SLEEP_TEST=false
[[ "${1:-}" == --sleep-only ]] && SLEEP_ONLY=true
# --- in the guest: helpers, setup and all checks except the real sleep ---
$SLEEP_ONLY || vm_ssh "SLEEP_TEST=$SLEEP_TEST bash -s" << 'REMOTE'
export XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
W=~/.cache/vmcec; mkdir -p "$W"
pass=0 fail=0 skip=0
PASS() { echo " PASS $*"; pass=$((pass + 1)); }
FAIL() { echo " FAIL $*"; fail=$((fail + 1)); }
SKIP() { echo " SKIP $*"; skip=$((skip + 1)); }
check() { local d="$1"; shift; if "$@" >/dev/null 2>&1; then PASS "$d"; else FAIL "$d"; fi; }
TV() { cec-ctl -d /dev/cec0 "$@"; }
BUS=com.steampowered.CecDaemon1
has_smgr() { steamosctl get-hdmi-cec-state >/dev/null 2>&1; }
# Key reader for the cecd input device: prints key names while it runs.
cat > "$W/keys.py" << 'PY'
import re, select, struct, sys, time
names = {}
for line in open('/usr/include/linux/input-event-codes.h'):
m = re.match(r'#define\s+(KEY_\w+|BTN_\w+)\s+(0x[0-9a-fA-F]+|\d+)\b', line)
if m: names.setdefault(int(m.group(2), 0), m.group(1))
f = open(sys.argv[1], 'rb'); end = time.time() + float(sys.argv[2])
while time.time() < end:
if select.select([f], [], [], 0.1)[0]:
_, _, t, c, v = struct.unpack('llHHi', f.read(24))
if t == 1 and v == 1: print(names.get(c, c), flush=True)
PY
# Monitor the TV side's CEC bus into $W/mon.txt (needs root).
mon_start() { sudo pkill -f 'cec-ctl -d /dev/cec0 -M' 2>/dev/null; : > "$W/mon.txt"
sudo sh -c "exec cec-ctl -d /dev/cec0 -M > $W/mon.txt 2>&1" & sleep 1; }
mon_stop() { sleep 1; sudo pkill -f 'cec-ctl -d /dev/cec0 -M' 2>/dev/null; wait 2>/dev/null; }
seen() { grep -q "Received from.*): $1" "$W/mon.txt"; }
echo "== Setup: fake TV (vivid)"
lsmod | grep -q '^vivid' || sudo modprobe vivid n_devs=1 num_inputs=1 input_types=0x3 num_outputs=1 output_types=0x1
sleep 2
TV --tv --osd-name "Fake TV" >/dev/null 2>&1
v4l2-ctl -d /dev/video0 --set-ctrl hdmi_000_0_is_connected_to=2
# The cecd service (-e) takes every CEC device, the TV too: a temporary
# drop-in limits it to the PC side, so steamos-manager still controls it.
mkdir -p ~/.config/systemd/user/cecd.service.d
printf '[Service]\nExecStart=\nExecStart=/usr/bin/cecd -d /dev/cec1\n' > ~/.config/systemd/user/cecd.service.d/vmcec.conf
systemctl --user daemon-reload; systemctl --user restart cecd.service
sleep 4
systemctl --user is-active -q steamos-manager.service && { systemctl --user restart steamos-manager.service; sleep 3; }
LA=$(cec-ctl -d /dev/cec1 | awk '/Logical Address /{print $4; exit}')
PA=$(cec-ctl -d /dev/cec1 | awk '/Physical Address/{print $4; exit}')
EV=$(grep -A4 'N: Name="cecd' /proc/bus/input/devices | grep -oE 'event[0-9]+' | head -1)
echo " PC side: physical $PA, logical $LA, input /dev/input/${EV:-none}"
echo "== 1. Identity (TV asks the PC)"
[[ "$PA" == 1.0.0.0 ]] && PASS "physical address 1.0.0.0" || FAIL "physical address is $PA"
[[ "$LA" =~ ^(4|8|9|11)$ ]] && PASS "logical address $LA (playback device)" || FAIL "logical address is '$LA'"
name=$(TV -t "$LA" --give-osd-name 2>&1 | awk -F': ' '/name:/{print $2; exit}')
[[ "$name" == "Steam Machine" ]] && PASS "OSD name 'Steam Machine'" || FAIL "OSD name is '$name' (steamos-manager's config not applied?)"
vendor=$(TV -t "$LA" --give-device-vendor-id 2>&1 | awk '/vendor-id:/{print $2; exit}')
[[ "$vendor" == 0xe0319e* ]] && PASS "vendor ID Valve (0xe0319e)" || FAIL "vendor ID is '$vendor'"
check "answers a poll" TV -t "$LA" --poll
echo "== 2. TV remote -> PC (key presses)"
press() { TV -t "$LA" --user-control-pressed ui-cmd="$1" >/dev/null; sleep 0.15; TV -t "$LA" --user-control-released >/dev/null; sleep 0.25; }
keys_for() { # keys_for <seconds> <ui-cmd>...: keys arriving while pressing
EV=$(grep -A4 'N: Name="cecd' /proc/bus/input/devices | grep -oE 'event[0-9]+' | head -1)
: > "$W/keys.txt"; local r=
[[ -n "$EV" ]] && { sudo timeout "$1" python3 "$W/keys.py" "/dev/input/$EV" "$1" > "$W/keys.txt" & r=$!; }
sleep 1; shift
local k; for k in "$@"; do press "$k"; done; [[ -n "$r" ]] && wait "$r" 2>/dev/null; cat "$W/keys.txt"; }
if [[ -z "$EV" ]]; then FAIL "no cecd input device"
else
for k in up down left right select exit back root-menu setup-menu contents-menu \
play pause stop rewind fast-forward forward backward \
volume-up volume-down mute number-entry-mode number-0-or-number-10 number-5 \
channel-up channel-down display-information electronic-program-guide \
f1-blue f2-red f3-green f4-yellow; do
got=$(keys_for 2 "$k" | tr '\n' ' ')
if [[ -n "$got" ]]; then PASS "$(printf '%-28s -> %s' "$k" "$got")"
else SKIP "$(printf '%-28s -> nothing (not mapped by cecd)' "$k")"; fi
done
# The TV's power button arrives as KEY_POWER, and Steam Machine support
# makes the power button sleep: with sleep blocked, check the key only.
sudo systemd-inhibit --what=sleep:handle-power-key --mode=block --who=vmcec --why=test sleep 6 & inh=$!; sleep 1
got=$(keys_for 2 power-toggle-function | tr '\n' ' ')
wait $inh 2>/dev/null
[[ "$got" == *KEY_POWER* ]] && PASS "$(printf '%-28s -> %s' power-toggle-function "$got") (sleeps the PC)" ||
FAIL "power-toggle-function -> '$got'"
fi
echo "== 3. Steam settings (steamos-manager, over D-Bus like Steam)"
# steamosctl's suspend-tv/suspend-device getters and setters don't work
# (steamos-manager 26.4.1); Steam uses these D-Bus properties.
SM=com.steampowered.SteamOSManager1; SMP=/com/steampowered/SteamOSManager1; CEC2=$SM.HdmiCec2
prop() { busctl --user get-property $SM $SMP $CEC2 "$1" 2>/dev/null | awk '{print $2}'; }
setp() { busctl --user set-property $SM $SMP $CEC2 "$1" b "$2" 2>/dev/null; sleep 2; }
cfg() { grep -h "^$1 *=" ~/.config/cecd/config.d/*.toml 2>/dev/null | tail -1 | awk -F'= *' '{print $2}'; }
if ! has_smgr; then SKIP "steamos-manager has no HDMI-CEC interface (no Steam Machine support?)"
else
echo " now: $(steamosctl get-hdmi-cec-state 2>&1); EnableControl=$(prop EnableControl) WakeTv=$(prop WakeTv) SuspendTv=$(prop SuspendTv) SuspendDevice=$(prop SuspendDevice) WakeDeviceSupported=$(prop WakeDeviceSupported)"
setp EnableControl false
got=$(keys_for 2 down | tr '\n' ' ')
[[ -z "$got" && "$(cfg uinput)" == false ]] && PASS "remote control off: keys are ignored (uinput = false)" || FAIL "remote control off: keys '$got', uinput = $(cfg uinput)"
setp EnableControl true
got=$(keys_for 2 down | tr '\n' ' ')
[[ -n "$got" ]] && PASS "remote control on again: $got" || FAIL "remote control on, but no keys"
for pr in WakeTv:wake_tv SuspendTv:suspend_tv SuspendDevice:allow_standby; do
for v in false true; do
setp "${pr%%:*}" $v
[[ "$(prop "${pr%%:*}")" == "$v" && "$(cfg "${pr#*:}")" == "$v" ]] &&
PASS "${pr%%:*} $v (cecd config ${pr#*:} = $v)" || FAIL "${pr%%:*} $v: property $(prop "${pr%%:*}"), config ${pr#*:} = $(cfg "${pr#*:}")"
done
done
[[ "$(prop WakeDeviceSupported)" == true ]] && PASS "waking the PC with the TV is supported" ||
SKIP "waking the PC with the TV: not supported here (WakeDeviceSupported = false)"
steamosctl set-hdmi-cec-state control-and-wake >/dev/null 2>&1; sleep 3
fi
systemctl --user is-active -q cecd.service && PASS "cecd still running" || FAIL "cecd stopped"
echo "== 4. PC -> TV"
# A plain TV (no soundbar): volume goes to the TV itself (address 0).
TV --tv --osd-name "Fake TV" >/dev/null 2>&1; sleep 2
echo " fake TV: $(TV | awk '/Logical Address /{printf "%s%s ", $4, $5}')"
DEV=$(busctl --user tree $BUS 2>/dev/null | grep -oE '/com/steampowered/CecDaemon1/Devices/Cec[0-9]+' | head -1)
mon_start
if has_smgr; then steamosctl hdmi-cec-make-active >/dev/null 2>&1; sleep 1; else busctl --user call $BUS "$DEV" $BUS.CecDevice1 SetActiveSource >/dev/null 2>&1; sleep 1; fi
busctl --user call $BUS /com/steampowered/CecDaemon1/Daemon $BUS.Daemon1 Wake >/dev/null 2>&1; sleep 1
mon_stop
{ seen ACTIVE_SOURCE || seen IMAGE_VIEW_ON || seen TEXT_VIEW_ON; } && PASS "make active: TV switches to the PC ($(grep -oE '\): [A-Z_]+' "$W/mon.txt" | sort -u | tr -d '):' | tr '\n' ' '))" || FAIL "make active: TV got nothing"
seen IMAGE_VIEW_ON || seen TEXT_VIEW_ON && PASS "wake: TV is turned on (IMAGE_VIEW_ON)" || FAIL "wake: no IMAGE_VIEW_ON"
mon_start
for m in VolumeUp VolumeDown Mute; do busctl --user call $BUS "$DEV" $BUS.CecDevice1 $m y 0 >/dev/null 2>&1; sleep 0.5; done
echo " audio status: $(busctl --user --timeout=3 call $BUS "$DEV" $BUS.CecDevice1 GetAudioStatus y 0 2>&1)"; sleep 1
mon_stop
n=$(grep -cE 'USER_CONTROL_PRESSED' "$W/mon.txt"); ops=$(grep -A1 USER_CONTROL_PRESSED "$W/mon.txt" | grep -oiE 'volume-up|volume-down|mute' | sort -u | tr '\n' ' ')
[[ $n -ge 3 ]] && PASS "volume up/down/mute sent to the TV ($ops)" || FAIL "volume: $n key presses reached the TV ($ops)"
seen GIVE_AUDIO_STATUS && PASS "asks the TV's audio status" || FAIL "no GIVE_AUDIO_STATUS"
mon_start
busctl --user call $BUS /com/steampowered/CecDaemon1/Daemon $BUS.Daemon1 Standby b true >/dev/null 2>&1; sleep 1
mon_stop
seen STANDBY && PASS "standby: TV is turned off (STANDBY)" || FAIL "standby: no STANDBY"
systemctl --user is-active -q cec-audio-control.socket && PASS "cec-audio-control socket listening" || FAIL "cec-audio-control socket not active"
echo "== 5. TV off -> PC to sleep (sleep blocked, so the VM stays up)"
if ! has_smgr; then SKIP "needs steamos-manager"
else
TV --tv >/dev/null 2>&1; sleep 2
for want in true false; do
setp SuspendDevice $want; sleep 1
since=$(date '+%Y-%m-%d %H:%M:%S')
sudo systemd-inhibit --what=sleep --mode=block --who=vmcec --why=test sleep 7 & inh=$!; sleep 1
TV -t 15 --standby >/dev/null 2>&1; sleep 4
wait $inh 2>/dev/null
tried=$(journalctl --user -u cecd --since "$since" --no-pager | grep -c 'Failed to standby')
if [[ $want == true ]]; then
[[ $tried -gt 0 ]] && PASS "SuspendDevice on: TV standby makes the PC go to sleep (blocked here)" || FAIL "SuspendDevice on, but no sleep attempt"
else
[[ $tried -eq 0 ]] && PASS "SuspendDevice off: TV standby doesn't touch the PC" || FAIL "SuspendDevice off, but the PC tried to sleep"
fi
done
setp SuspendDevice true
fi
if ! $SLEEP_TEST; then rm -f ~/.config/systemd/user/cecd.service.d/vmcec.conf; systemctl --user daemon-reload; systemctl --user restart cecd.service; fi
echo "== Result: $pass passed, $fail failed, $skip skipped (before the sleep test)"
echo "$LA" > "$W/la"
REMOTE
$SLEEP_TEST || { echo "(sleep test skipped)"; exit 0; }
# --- real suspend/resume: the TV should go off, and on again at wake ---
# Through logind (systemctl suspend), like Steam and the power button: cecd
# acts on logind's PrepareForSleep, which rtcwake skips. cec-follower plays a
# TV that answers power status; QMP (vmwake.sh) wakes the VM.
echo "== 6. Real sleep and wake (systemctl suspend, woken over QMP)"
vm_ssh bash -s << 'REMOTE'
export XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
W=~/.cache/vmcec
# cecd on the PC side only, with debug logging (it logs what it does at sleep).
mkdir -p ~/.config/systemd/user/cecd.service.d
printf '[Service]\nExecStart=\nExecStart=/usr/bin/cecd -d /dev/cec1\nEnvironment=RUST_LOG=debug\n' > ~/.config/systemd/user/cecd.service.d/vmcec.conf
systemctl --user daemon-reload; systemctl --user restart cecd.service; sleep 4
SM=com.steampowered.SteamOSManager1; SMP=/com/steampowered/SteamOSManager1
busctl --user set-property $SM $SMP $SM.HdmiCec2 SuspendTv b true 2>/dev/null
busctl --user set-property $SM $SMP $SM.HdmiCec2 WakeTv b true 2>/dev/null
sudo systemctl stop vmcec-tv vmcec-mon 2>/dev/null; sudo systemctl reset-failed vmcec-tv vmcec-mon vmcec-sleep 2>/dev/null
cec-ctl -d /dev/cec0 --tv >/dev/null; sleep 1
sudo systemd-run -q --unit=vmcec-tv sh -c 'exec cec-follower -d /dev/cec0 > /dev/null 2>&1'; sleep 2
steamosctl hdmi-cec-make-active >/dev/null 2>&1; sleep 3
sudo systemd-run -q --unit=vmcec-mon sh -c "exec cec-ctl -d /dev/cec0 -M > $W/sleep-mon.txt 2>&1"; sleep 1
sudo systemd-run -q --unit=vmcec-sleep sh -c 'sleep 2; systemctl suspend'
echo " suspending..."
REMOTE
sleep 15
"$(dirname "$0")/vmwake.sh" || true
until vm_ssh -o ConnectTimeout=4 true 2>/dev/null; do sleep 3; done
sleep 8
vm_ssh bash -s << 'REMOTE'
export XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
W=~/.cache/vmcec
sudo systemctl stop vmcec-mon vmcec-tv 2>/dev/null
t=$(journalctl -b --no-pager -o short-iso | grep "PM: suspend entry" | tail -1 | awk '{print $1}')
[[ -n "$t" ]] && echo " PASS the VM really slept and woke (suspend at $t)" || echo " FAIL no suspend in the kernel log"
log=$(journalctl --user -u cecd --since "$(date -d "${t:-now} -10 sec" '+%F %T')" --no-pager)
grep -q 'Putting TV in standby' <<< "$log" && echo " PASS cecd: 'Putting TV in standby'" || echo " FAIL cecd didn't act on going to sleep"
grep -q 'Waking TV' <<< "$log" && echo " PASS cecd: 'Woke from standby. Waking TV.'" || echo " FAIL cecd didn't act on waking up"
grep -q 'Received from.*: STANDBY' "$W/sleep-mon.txt" && echo " PASS going to sleep: TV turned off (STANDBY)" || echo " FAIL going to sleep: no STANDBY to the TV"
grep -qE 'Received from.*: (IMAGE_VIEW_ON|TEXT_VIEW_ON)' "$W/sleep-mon.txt" && echo " PASS waking up: TV turned on (IMAGE_VIEW_ON)" || echo " FAIL waking up: no IMAGE_VIEW_ON"
grep -oE '^(Received|Transmitted)[^:]*: [A-Z_]+' "$W/sleep-mon.txt" | sed 's/^/ /'
# Back to the normal service.
rm -f ~/.config/systemd/user/cecd.service.d/vmcec.conf; systemctl --user daemon-reload; systemctl --user restart cecd.service
REMOTE
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# Wake the VM from sleep (suspend to RAM), e.g. after the TV's power button
# in the HDMI-CEC test. Uses the QMP socket run.sh opens in the VM directory.
# Env: VM_DIR (the repo if it holds disk.qcow2, else ~/vms/cachyos-test).
set -euo pipefail
here="$(cd "$(dirname "$0")/.." && pwd)"
VM_DIR="${VM_DIR:-$([[ -f "$here/disk.qcow2" ]] && echo "$here" || echo "$HOME/vms/cachyos-test")}"
sock="$VM_DIR/qmp.sock"
[[ -S "$sock" ]] || { echo "No QMP socket at $sock (VM not running, or started before run.sh had -qmp)." >&2; exit 1; }
printf '%s\n' '{"execute":"qmp_capabilities"}' '{"execute":"system_wakeup"}' |
socat -t 2 - UNIX-CONNECT:"$sock" 2>/dev/null | grep -c '"return"' >/dev/null && echo "Wake-up sent."