fix: hotkey-based port switching and HTML entity unescaping
All checks were successful
CI / fmt (push) Successful in 36s
Publish / frontend (push) Successful in 44s
CI / check (push) Successful in 1m23s
CI / clippy (push) Successful in 1m36s
Publish / backend (push) Successful in 2m45s

Three fixes for KVM port management:

1. HTML unescape scraped values — the > character in hotkey strings
   (e.g., PrintScreen>0>1) gets entity-encoded to > in the device's
   HTML. Added html_unescape() to the scraper so hotkeys round-trip
   correctly.

2. Send hotkeys over WebSocket — port switching via the Belkin web form
   only changes the active port number, it doesn't send the hotkey
   sequence to the downstream KVM. Now when switching ports from the
   console dropdown, blekin parses the Belkin hotkey syntax and sends
   the key press/release sequence over the existing WebSocket connection.

3. New hotkey.ts parser — converts Belkin hotkey syntax to scancode
   sequences:
   - > and -> = sequential (tap each key)
   - + = simultaneous (hold all, release in reverse)
   - Supports all key names: PrintScreen, Ctrl, Alt, Shift, F1-F12,
     letters, digits, navigation keys, numpad

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 12:13:57 +03:00
parent 9bd215356b
commit 35db634317
3 changed files with 124 additions and 9 deletions

View File

@@ -0,0 +1,92 @@
// Parse Belkin hotkey syntax and convert to scancode sequences.
//
// Syntax: [confirm] <keyname>((+|->|>)<keyname>)*
// + = press simultaneously (hold first, press second)
// > = press sequentially (release first, then press second)
// -> = same as >
//
// Examples:
// PrintScreen>0>1 → tap PrintScreen, tap 0, tap 1
// Ctrl+Alt+Del → hold Ctrl+Alt, tap Del, release all
// PrintScreen>1>7 → tap PrintScreen, tap 1, tap 7
import { makeKeyPress, makeKeyRelease } from './protocol'
// Belkin key names → e-RIC scancodes (from KeyTranslator.java)
const KEY_NAMES: Record<string, number> = {
// Letters
A: 29, B: 47, C: 45, D: 31, E: 17, F: 32, G: 33, H: 34, I: 22,
J: 35, K: 36, L: 37, M: 49, N: 48, O: 23, P: 24, Q: 15, R: 18,
S: 30, T: 19, U: 21, V: 46, W: 16, X: 44, Y: 20, Z: 43,
// Digits
'0': 10, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
// Modifiers
Ctrl: 54, Alt: 55, Shift: 41, Win: 105,
CtrlLeft: 54, CtrlRight: 58, AltLeft: 55, AltRight: 57,
ShiftLeft: 41, ShiftRight: 53,
// Special keys
Esc: 59, Escape: 59, Tab: 14, CapsLock: 28, Space: 56,
Enter: 27, Return: 27, Backspace: 13, Del: 78, Delete: 78,
Insert: 75, Home: 76, End: 79, PageUp: 77, PageDown: 80,
PrintScreen: 72, Print: 72, SysRq: 72,
ScrollLock: 73, Pause: 74, Break: 74,
NumLock: 85,
// Arrow keys
Up: 81, Down: 83, Left: 82, Right: 84,
// F-keys
F1: 60, F2: 61, F3: 62, F4: 63, F5: 64, F6: 65,
F7: 66, F8: 67, F9: 68, F10: 69, F11: 70, F12: 71,
// Numpad
NumPlus: 89, 'Num+': 89, NumMinus: 99, 'Num-': 99,
NumMultiply: 94, 'Num*': 94, NumDivide: 90, 'Num/': 90,
NumEnter: 98,
}
/**
* Parse a Belkin hotkey string into a sequence of WS messages to send.
* Returns an array of ArrayBuffer messages (press/release pairs).
*/
export function parseHotkey(hotkey: string): ArrayBuffer[] {
if (!hotkey.trim()) return []
const messages: ArrayBuffer[] = []
// Split on > (sequential) first, preserving + groups
const groups = hotkey.split(/->|>/).map(g => g.trim()).filter(Boolean)
for (const group of groups) {
// Each group may contain + (simultaneous keys)
const keys = group.split('+').map(k => k.trim()).filter(Boolean)
const scancodes: number[] = []
for (const key of keys) {
const sc = KEY_NAMES[key]
if (sc !== undefined) {
scancodes.push(sc)
}
}
if (scancodes.length === 0) continue
if (scancodes.length === 1) {
// Single key: press then release
messages.push(makeKeyPress(scancodes[0]))
messages.push(makeKeyRelease(scancodes[0]))
} else {
// Combo: press all in order, release in reverse
for (const sc of scancodes) {
messages.push(makeKeyPress(sc))
}
for (const sc of scancodes.reverse()) {
messages.push(makeKeyRelease(sc))
}
}
}
return messages
}

View File

@@ -1,4 +1,5 @@
import { codeToScancode } from '../input'
import { parseHotkey } from '../hotkey'
import {
TAG_BLIT, TAG_RESIZE,
makeKeyPress, makeKeyRelease, makePointer, makeCtrlAltDel,
@@ -153,6 +154,8 @@ function handleMessage(ev: MessageEvent) {
// Port switcher
// ---------------------------------------------------------------------------
let portHotkeys: Record<number, string> = {}
async function loadPortList() {
try {
const resp = await fetch('/api/kvm/ports')
@@ -161,24 +164,37 @@ async function loadPortList() {
const select = document.getElementById('port-select') as HTMLSelectElement
if (!select) return
select.innerHTML = ''
portHotkeys = {}
for (const p of data.ports) {
const opt = document.createElement('option')
opt.value = String(p.index)
opt.textContent = p.name || `Port ${p.index + 1}`
opt.selected = p.index === data.active_port
select.appendChild(opt)
if (p.hotkey) portHotkeys[p.index] = p.hotkey
}
select.addEventListener('change', async () => {
select.addEventListener('change', () => {
const port = parseInt(select.value)
await fetch('/api/kvm/switch', {
switchToPort(port)
})
} catch { /* port list optional */ }
}
function switchToPort(port: number) {
const hotkey = portHotkeys[port]
if (hotkey && ws?.readyState === WebSocket.OPEN) {
// Send the hotkey sequence as key events over WS to the KVM
const messages = parseHotkey(hotkey)
for (const msg of messages) {
ws.send(msg)
}
}
// Also update the Belkin's active port tracking
fetch('/api/kvm/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port }),
})
// Reconnect to pick up new video stream
ws?.close()
})
} catch { /* port list optional */ }
}).catch(() => {})
}
// ---------------------------------------------------------------------------

View File

@@ -83,7 +83,14 @@ fn extract_input_value(html: &str, name: &str) -> Option<String> {
let val_needle = "value=\"";
let val_pos = after.find(val_needle)? + val_needle.len();
let end = after[val_pos..].find('"')? + val_pos;
Some(after[val_pos..end].to_string())
Some(html_unescape(&after[val_pos..end]))
}
fn html_unescape(s: &str) -> String {
s.replace("&gt;", ">")
.replace("&lt;", "<")
.replace("&amp;", "&")
.replace("&quot;", "\"")
}
fn extract_selected_option(html: &str, name: &str) -> Option<String> {