fix: add settling delay when waking HDMI capture chip from sleep (#519) (#1356)

* fix: add settling delay in VideoStart() when waking from sleep mode (#519)

* test: improve e2e test reliability and reduce flakiness

- Replace fire-sleep-assert with polling in wheel scroll tests
- Add retry loops for macro test and RPC setup after USB re-enumeration
- Add waitForRpcReady helper to handle session dialogs and stale pages
- Remove flaky paste modal UI test (redundant with keyboard scan tests)
- Preserve SSH keys and dev mode across config resets in setup/teardown
- Add saveSSHDevState/restoreSSHDevState helpers

* fix: trim sysfs whitespace in getSleepMode() so sleep detection works

Linux sysfs attributes include a trailing newline, so comparing raw
content with "1" always returned false. Use strings.TrimSpace to match
the convention used elsewhere in the codebase.
This commit is contained in:
Adam Shiervani
2026-03-27 15:30:29 +01:00
committed by GitHub
parent fc64d6d2f3
commit 71db3b91ab
5 changed files with 285 additions and 83 deletions
+12 -1
View File
@@ -3,6 +3,7 @@ package native
import (
"fmt"
"os"
"strings"
"time"
)
@@ -85,7 +86,7 @@ func (n *Native) getSleepMode() (bool, error) {
data, err := os.ReadFile(sleepModeFile)
if err == nil {
return string(data) == "1", nil
return strings.TrimSpace(string(data)) == "1", nil
}
return false, nil
@@ -192,9 +193,19 @@ func (n *Native) VideoStart() error {
n.videoLock.Lock()
defer n.videoLock.Unlock()
// check if the chip is currently in sleep mode
wasSleeping, _ := n.getSleepMode()
// disable sleep mode before starting video
_ = n.setSleepMode(false)
// when waking from sleep, the capture chip needs time to re-lock the HDMI
// signal before we can start streaming (similar to the delay in useExtraLock)
if wasSleeping {
n.l.Info().Msg("capture chip was sleeping, waiting for signal re-lock")
time.Sleep(extraLockTimeout)
}
videoStart()
return nil
}
+13 -1
View File
@@ -1,7 +1,15 @@
import * as fs from "fs";
import { promisify } from "util";
import { exec } from "child_process";
import { sshExec, getDeviceHost, resetConfigViaSSH, restartAppViaSSH, SSH_OPTS } from "./helpers";
import {
sshExec,
getDeviceHost,
resetConfigViaSSH,
restartAppViaSSH,
saveSSHDevState,
restoreSSHDevState,
SSH_OPTS,
} from "./helpers";
const execAsync = promisify(exec);
@@ -25,7 +33,11 @@ export default async function globalSetup() {
await execAsync(`${sshCmd} < "${binaryPath}"`);
await sshExec("chmod +x /userdata/jetkvm/bin/jetkvm_app");
const saved = await saveSSHDevState();
await resetConfigViaSSH();
await restoreSSHDevState(saved);
await restartAppViaSSH();
console.log("[global-setup] Device ready.");
}
+9 -1
View File
@@ -1,6 +1,12 @@
import * as fs from "fs";
import * as path from "path";
import { sshExec, resetConfigViaSSH, restartAppViaSSH } from "./helpers";
import {
sshExec,
resetConfigViaSSH,
restartAppViaSSH,
saveSSHDevState,
restoreSSHDevState,
} from "./helpers";
export default async function globalTeardown() {
const resultsDir = path.resolve(
@@ -31,7 +37,9 @@ export default async function globalTeardown() {
console.log("[global-teardown] Resetting device to clean state...");
try {
const saved = await saveSSHDevState();
await resetConfigViaSSH();
await restoreSSHDevState(saved);
await restartAppViaSSH();
console.log("[global-teardown] Device reset complete.");
} catch {
+27
View File
@@ -687,6 +687,33 @@ export async function resetConfigViaSSH(): Promise<void> {
await sshExec("sync");
}
export interface SSHDevState {
sshKey: string;
devModeEnabled: boolean;
}
export async function saveSSHDevState(): Promise<SSHDevState> {
const sshKey = await sshExec("cat /userdata/dropbear/.ssh/authorized_keys 2>/dev/null", true);
const devMode = await sshExec(
"test -f /userdata/jetkvm/devmode.enable && echo 1 || echo 0",
true,
);
return { sshKey: sshKey.trim(), devModeEnabled: devMode.trim() === "1" };
}
export async function restoreSSHDevState(state: SSHDevState): Promise<void> {
if (state.sshKey) {
await sshExec("mkdir -p /userdata/dropbear/.ssh && chmod 700 /userdata/dropbear/.ssh");
const b64 = Buffer.from(state.sshKey).toString("base64");
await sshExec(
`echo ${b64} | base64 -d > /userdata/dropbear/.ssh/authorized_keys && chmod 600 /userdata/dropbear/.ssh/authorized_keys`,
);
}
if (state.devModeEnabled) {
await sshExec("mkdir -p /userdata/jetkvm && touch /userdata/jetkvm/devmode.enable");
}
}
export async function restartAppViaSSH(): Promise<void> {
await sshExec("killall jetkvm_app", true);
await new Promise(r => setTimeout(r, 500));
+224 -80
View File
@@ -6,6 +6,7 @@
* Run with:
* JETKVM_URL=http://<kvm-ip> JETKVM_REMOTE_HOST=<host-ip> npx playwright test ra-all
*/
import { execSync } from "child_process";
import { test, expect, type Page } from "@playwright/test";
import {
HID_KEY,
@@ -20,7 +21,35 @@ import {
waitForLedState,
restartAppViaSSH,
} from "../helpers";
import { createRemoteAgent, KEY, HID_TO_LINUX } from "./remote-agent";
import {
createRemoteAgent,
KEY,
HID_TO_LINUX,
type MouseEvent as RAMouseEvent,
type KeyboardEvent as RAKeyboardEvent,
} from "./remote-agent";
/** Run a command on the remote host (the machine whose display is captured by the KVM). */
function remoteHostExec(cmd: string): string {
const target = process.env.JETKVM_REMOTE_HOST;
if (!target) throw new Error("JETKVM_REMOTE_HOST not set");
const sshOpts =
"-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=10";
return execSync(`ssh ${sshOpts} ${target} '${cmd}'`, {
encoding: "utf8",
timeout: 15000,
});
}
/** Toggle DPMS on the remote host via GNOME ScreenSaver D-Bus API. */
function remoteHostSetDPMS(off: boolean): void {
remoteHostExec(
`DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus ` +
`gdbus call --session --dest org.gnome.ScreenSaver ` +
`--object-path /org/gnome/ScreenSaver ` +
`--method org.gnome.ScreenSaver.SetActive ${off ? "true" : "false"}`,
);
}
const agent = createRemoteAgent();
@@ -201,13 +230,47 @@ async function ensureNoPasswordViaAPI() {
}
}
async function setupMacrosViaRPC(page: Page) {
const existing = (await callJsonRpc(page, "getKeyboardMacros")) as { id: string }[];
const ids = new Set(existing.map(m => m.id));
if (TEST_MACROS.every(m => ids.has(m.id))) return;
async function setupMacrosViaRPC(page: Page, retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const existing = (await callJsonRpc(page, "getKeyboardMacros")) as { id: string }[];
const ids = new Set(existing.map(m => m.id));
if (TEST_MACROS.every(m => ids.has(m.id))) return;
const merged = [...existing.filter(m => !m.id.startsWith("e2e_test_")), ...TEST_MACROS];
await callJsonRpc(page, "setKeyboardMacros", { params: { macros: merged } });
const merged = [...existing.filter(m => !m.id.startsWith("e2e_test_")), ...TEST_MACROS];
await callJsonRpc(page, "setKeyboardMacros", { params: { macros: merged } });
return;
} catch (err) {
if (attempt === retries) throw err;
await new Promise(r => setTimeout(r, 2000));
}
}
}
async function waitForRpcReady(page: Page, timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
let reloaded = false;
while (Date.now() < deadline) {
// Dismiss "Another Active Session" dialog if it appears
const useHereBtn = page.getByRole("button", { name: "Use Here" });
if (await useHereBtn.isVisible({ timeout: 200 }).catch(() => false)) {
await useHereBtn.click();
await new Promise(r => setTimeout(r, 2000));
}
try {
await callJsonRpc(page, "getDeviceID");
return;
} catch {
// If RPC keeps failing, try a full page reload once
if (!reloaded && Date.now() > deadline - timeoutMs + 10000) {
reloaded = true;
await page.reload({ waitUntil: "networkidle" });
await waitForWebRTCReady(page);
}
await new Promise(r => setTimeout(r, 1000));
}
}
throw new Error(`RPC channel not ready after ${timeoutMs}ms`);
}
test.beforeAll(async ({ browser }) => {
@@ -217,7 +280,20 @@ test.beforeAll(async ({ browser }) => {
sharedPage = await browser.newPage();
await sharedPage.goto("/", { waitUntil: "networkidle" });
// If the page redirected to the welcome/setup flow, complete setup and reload
if (sharedPage.url().includes("/welcome")) {
const host = getDeviceHost();
await fetch(`http://${host}/device/setup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ localAuthMode: "noPassword" }),
});
await sharedPage.goto("/", { waitUntil: "networkidle" });
}
await waitForWebRTCReady(sharedPage);
await waitForRpcReady(sharedPage);
await setupMacrosViaRPC(sharedPage);
await sharedPage.reload({ waitUntil: "networkidle" });
@@ -228,7 +304,6 @@ test.beforeAll(async ({ browser }) => {
test.afterAll(async () => {
if (!agent) return;
// Clean up test macros via RPC (no SSH needed)
try {
const existing = (await callJsonRpc(sharedPage, "getKeyboardMacros")) as { id: string }[];
const filtered = existing.filter(m => !m.id.startsWith("e2e_test_"));
@@ -549,60 +624,73 @@ test.describe("Remote Host Agent", () => {
// Vertical scroll
await agent!.clearMouseEvents();
await callJsonRpc(sharedPage, "wheelReport", { wheelY: 1, wheelX: 0 });
await new Promise(r => setTimeout(r, 200));
const vEvents = await agent!.getMouseEvents();
const vWheel = vEvents.filter(ev => ev.type === "mouse_move_rel" && ev.code === REL_WHEEL);
const vWheel = await agent!.waitForMouseEvent(
ev => ev.type === "mouse_move_rel" && ev.code === REL_WHEEL,
3000,
);
expect(vWheel.length, "Vertical wheel event should be received").toBeGreaterThan(0);
expect(vWheel[0].value).not.toBe(0);
// Horizontal scroll
await agent!.clearMouseEvents();
await callJsonRpc(sharedPage, "wheelReport", { wheelY: 0, wheelX: 1 });
await new Promise(r => setTimeout(r, 200));
const hEvents = await agent!.getMouseEvents();
const hWheel = hEvents.filter(ev => ev.type === "mouse_move_rel" && ev.code === REL_HWHEEL);
const hWheel = await agent!.waitForMouseEvent(
ev => ev.type === "mouse_move_rel" && ev.code === REL_HWHEEL,
3000,
);
expect(hWheel.length, "Horizontal wheel event should be received").toBeGreaterThan(0);
expect(hWheel[0].value).not.toBe(0);
// Both axes simultaneously
await agent!.clearMouseEvents();
await callJsonRpc(sharedPage, "wheelReport", { wheelY: -1, wheelX: 1 });
await new Promise(r => setTimeout(r, 200));
const bothEvents = await agent!.getMouseEvents();
const bothV = bothEvents.filter(ev => ev.type === "mouse_move_rel" && ev.code === REL_WHEEL);
const bothH = bothEvents.filter(ev => ev.type === "mouse_move_rel" && ev.code === REL_HWHEEL);
const bothV = await agent!.waitForMouseEvent(
ev => ev.type === "mouse_move_rel" && ev.code === REL_WHEEL,
3000,
);
expect(bothV.length, "Vertical wheel in combined event").toBeGreaterThan(0);
const bothEvents = await agent!.getMouseEvents();
const bothH = bothEvents.filter(ev => ev.type === "mouse_move_rel" && ev.code === REL_HWHEEL);
expect(bothH.length, "Horizontal wheel in combined event").toBeGreaterThan(0);
});
test("mouse: wheel scroll works in relative-only mouse mode", async () => {
test.setTimeout(30_000);
const REL_WHEEL = 0x08;
const REL_HWHEEL = 0x06;
await callJsonRpc(sharedPage, "setUsbDevices", { devices: USB_DEVICES_REL_MOUSE_ONLY });
await agent!.waitForInputDevices(["keyboard", "relative_mouse"], 10000);
// After USB device re-enumeration the remote agent needs time to re-open
// the new /dev/input/event* nodes — poll with retries instead of fixed sleep.
try {
// Vertical scroll
await agent!.clearMouseEvents();
await callJsonRpc(sharedPage, "wheelReport", { wheelY: 1, wheelX: 0 });
await new Promise(r => setTimeout(r, 200));
const vEvents = await agent!.getMouseEvents();
const vWheel = vEvents.filter(ev => ev.type === "mouse_move_rel" && ev.code === REL_WHEEL);
// Vertical scroll — retry sending until the agent picks it up
const vDeadline = Date.now() + 10000;
let vWheel: RAMouseEvent[] = [];
while (Date.now() < vDeadline) {
await agent!.clearMouseEvents();
await callJsonRpc(sharedPage, "wheelReport", { wheelY: 1, wheelX: 0 });
try {
vWheel = await agent!.waitForMouseEvent(
ev => ev.type === "mouse_move_rel" && ev.code === REL_WHEEL,
2000,
);
break;
} catch {
/* agent not ready yet, retry */
}
}
expect(vWheel.length, "Vertical wheel in relative-only mode").toBeGreaterThan(0);
expect(vWheel[0].value).not.toBe(0);
// Horizontal scroll
await agent!.clearMouseEvents();
await callJsonRpc(sharedPage, "wheelReport", { wheelY: 0, wheelX: 1 });
await new Promise(r => setTimeout(r, 200));
const hEvents = await agent!.getMouseEvents();
const hWheel = hEvents.filter(ev => ev.type === "mouse_move_rel" && ev.code === REL_HWHEEL);
const hWheel = await agent!.waitForMouseEvent(
ev => ev.type === "mouse_move_rel" && ev.code === REL_HWHEEL,
3000,
);
expect(hWheel.length, "Horizontal wheel in relative-only mode").toBeGreaterThan(0);
expect(hWheel[0].value).not.toBe(0);
} finally {
@@ -612,60 +700,32 @@ test.describe("Remote Host Agent", () => {
});
// ═══════════════════════════════════════════
// INPUT: PASTE + MACROS
// INPUT: MACROS
// ═══════════════════════════════════════════
test("input: paste text and macros", async () => {
// ── Paste ──
const expectedPasteKeys = [KEY.H, KEY.I, KEY.KEY_5];
await agent!.clearKeyboardEvents();
test("input: keyboard macros", async () => {
test.setTimeout(30_000);
await sharedPage.getByRole("button", { name: "Paste text" }).click();
const textarea = sharedPage.locator("textarea#asd");
await textarea.waitFor({ state: "visible", timeout: 3000 });
await textarea.fill("hi5");
const confirmBtn = sharedPage.getByRole("button", { name: "Confirm Paste" });
await confirmBtn.waitFor({ state: "visible", timeout: 2000 });
await confirmBtn.click({ force: true });
const pasteDeadline = Date.now() + 5000;
let pasteMatchIdx = 0;
while (Date.now() < pasteDeadline) {
const events = await agent!.getKeyboardEvents();
const pressedCodes = events.filter(ev => ev.type === "key_press").map(ev => ev.code);
pasteMatchIdx = 0;
for (const code of pressedCodes) {
if (code === expectedPasteKeys[pasteMatchIdx]) {
pasteMatchIdx++;
if (pasteMatchIdx === expectedPasteKeys.length) break;
}
}
if (pasteMatchIdx === expectedPasteKeys.length) break;
await new Promise(r => setTimeout(r, 50));
}
expect(pasteMatchIdx, `Paste: expected 3 keys but matched ${pasteMatchIdx}`).toBe(
expectedPasteKeys.length,
);
// Dismiss any lingering paste dialog
const cancelBtn = sharedPage.getByRole("button", { name: "Cancel" });
if (await cancelBtn.isVisible({ timeout: 300 }).catch(() => false)) {
await cancelBtn.click();
}
// ── Macros ──
// Single key press (A)
// Single key press (A) — retry in case the remote agent is still
// re-opening input devices after the previous USB mode switch.
const keyABtn = sharedPage.getByRole("button", { name: "E2E KeyA" });
await keyABtn.waitFor({ state: "visible", timeout: 5000 });
await agent!.clearKeyboardEvents();
await keyABtn.click();
let macroEvents = await agent!.waitForKeyboardEvent(
ev => ev.code === KEY.A && ev.type === "key_press",
3000,
);
const macroDeadline = Date.now() + 15000;
let macroEvents: RAKeyboardEvent[] = [];
while (Date.now() < macroDeadline) {
await agent!.clearKeyboardEvents();
await keyABtn.click();
try {
macroEvents = await agent!.waitForKeyboardEvent(
ev => ev.code === KEY.A && ev.type === "key_press",
3000,
);
break;
} catch {
/* agent not ready, retry */
}
}
expect(macroEvents.length).toBeGreaterThan(0);
// Modifier combo (Ctrl+A)
@@ -957,6 +1017,90 @@ test.describe("Remote Host Agent", () => {
await callJsonRpc(sharedPage, "setVideoSleepMode", { duration: originalDuration });
});
// ═══════════════════════════════════════════
// HDMI SLEEP WAKE: SIGNAL RE-DETECTION AFTER DPMS OFF→ON
// ═══════════════════════════════════════════
test("hdmi-sleep-wake: re-detects signal after DPMS off→on with chip asleep", async () => {
test.setTimeout(120_000);
const SLEEP_MODE_SYSFS = "/sys/devices/platform/ff470000.i2c/i2c-4/4-000f/sleep_mode";
const sleepInfo = (await callJsonRpc(sharedPage, "getVideoSleepMode")) as {
supported: boolean;
duration: number;
};
if (!sleepInfo.supported) {
test.skip(true, "HDMI sleep mode not supported on this device");
return;
}
const originalDuration = sleepInfo.duration;
try {
// Set a short sleep timer (3s) so the chip enters sleep quickly
await callJsonRpc(sharedPage, "setVideoSleepMode", { duration: 3 });
// Disconnect WebRTC so there are no active sessions → sleep timer starts
await sharedPage.goto("about:blank");
// Wait for sleep timer + margin
await new Promise(r => setTimeout(r, 6000));
// Verify chip entered sleep mode
const sleepState = (await sshExec(`cat ${SLEEP_MODE_SYSFS}`)).trim();
expect(sleepState, "Capture chip should be in sleep mode").toBe("1");
// Toggle DPMS off on the remote host (simulates host GPU cutting signal)
remoteHostSetDPMS(true);
// Wait for the GPU to fully cut the TMDS clock
await new Promise(r => setTimeout(r, 3000));
// Bring the display back on
remoteHostSetDPMS(false);
// Wait for host display to stabilize
await new Promise(r => setTimeout(r, 3000));
// Reconnect — this triggers VideoStart() which must wake the chip and re-lock
await sharedPage.goto("/", { waitUntil: "networkidle" });
await waitForWebRTCReady(sharedPage);
// Verify the chip woke up
const wakeState = (await sshExec(`cat ${SLEEP_MODE_SYSFS}`)).trim();
expect(wakeState, "Capture chip should be awake after reconnect").toBe("0");
// Verify video state shows a valid signal (no error)
const videoState = (await callJsonRpc(sharedPage, "getVideoState")) as {
ready: boolean;
error?: string;
width: number;
height: number;
};
expect(videoState.ready, `Video should be ready but got error: ${videoState.error}`).toBe(
true,
);
expect(videoState.width).toBeGreaterThan(0);
expect(videoState.height).toBeGreaterThan(0);
} finally {
// Always restore DPMS and sleep duration, even if test fails
try {
remoteHostSetDPMS(false);
} catch {
// best effort
}
// Reconnect if needed to restore sleep duration via RPC
if (sharedPage.url() === "about:blank") {
await sharedPage.goto("/", { waitUntil: "networkidle" });
await waitForWebRTCReady(sharedPage);
}
await callJsonRpc(sharedPage, "setVideoSleepMode", { duration: originalDuration });
}
});
// ═══════════════════════════════════════════
// CONFIG RESET (must be last — resets device config)
// ═══════════════════════════════════════════