Compare commits

..

17 Commits

Author SHA1 Message Date
fe5e766dc6 doc: add README with build, config, deployment, and OSCAR hotkey guide
All checks were successful
Publish / frontend (push) Has been skipped
Publish / backend (push) Has been skipped
CI / fmt (push) Successful in 30s
CI / clippy (push) Successful in 1m32s
CI / check (push) Successful in 1m35s
Covers architecture, building, configuration, deployment, port
switching with Avocent OSCAR (tested hotkey syntax with recommended
pause settings), full hotkey syntax reference, and protocol heritage
naming the Peppercon e-RIC/LARA lineage.

Completes success criterion 5: source on git.lair.cafe with a README
that names the Peppercon e-RIC heritage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:51:37 +03:00
8c2ea95723 fix: account for proxy pipeline latency in switch overlay duration
All checks were successful
Publish / backend (push) Has been skipped
CI / fmt (push) Successful in 31s
Publish / frontend (push) Successful in 45s
CI / check (push) Successful in 1m29s
CI / clippy (push) Successful in 1m27s
Add 4s for the WS-to-Belkin-to-Avocent pipeline delay before the key
sequence starts executing, plus 1s settle time after for the video
stream to stabilize. Total overlay = 4s + (pauses * pause_duration) + 1s.

[deploy: frontend]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:39:40 +03:00
a57247cb46 fix: switch overlay dismiss — use display:none and named callback
All checks were successful
Publish / backend (push) Has been skipped
CI / fmt (push) Successful in 35s
Publish / frontend (push) Successful in 55s
CI / clippy (push) Successful in 1m23s
CI / check (push) Successful in 1m27s
[deploy: frontend]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:33:35 +03:00
ef48bd40cd ci: verify publish gating (no deploy directive)
All checks were successful
CI / fmt (push) Successful in 29s
CI / check (push) Successful in 1m19s
Publish / frontend (push) Has been skipped
Publish / backend (push) Has been skipped
CI / clippy (push) Successful in 1m17s
2026-05-07 13:28:32 +03:00
d4bbe6450f ci: use bracketed deploy directives to avoid false matches
Some checks are pending
CI / fmt (push) Waiting to run
CI / clippy (push) Waiting to run
Publish / frontend (push) Waiting to run
Publish / backend (push) Waiting to run
CI / check (push) Successful in 1m14s
Changed from "deploy: frontend" to "[deploy: frontend]" to prevent
substring matches against prose in the commit message body.

Directives: [deploy: frontend], [deploy: backend],
[deploy: frontend, backend]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:26:43 +03:00
f62084eac7 ci: gate publish jobs on commit message deploy directives
Some checks are pending
Publish / backend (push) Waiting to run
CI / fmt (push) Successful in 33s
Publish / frontend (push) Successful in 45s
CI / clippy (push) Successful in 1m43s
CI / check (push) Successful in 1m44s
Publish jobs now only run when the commit message contains:
- "deploy: frontend" — deploys frontend only
- "deploy: backend" — deploys backend only
- "deploy: backend, frontend" or "deploy: frontend, backend" — both

Case-insensitive matching. Commits without a deploy directive
will only run CI (check/fmt/clippy), not publish.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:24:44 +03:00
edb6853e3a fix: switch overlay dismissal and port dropdown selection persistence
All checks were successful
CI / fmt (push) Successful in 34s
CI / check (push) Successful in 1m14s
CI / clippy (push) Successful in 1m19s
Publish / frontend (push) Successful in 52s
Publish / backend (push) Successful in 2m36s
- Overlay now reliably dismissed: use window.setTimeout and inline
  overlay hide instead of separate function that could lose DOM ref
- Minimum 1s duration to prevent flash for short hotkey sequences
- Port dropdown preserves user's selection across page refreshes
  by tracking selectedPort locally instead of always using device's
  active_port
- Prevent duplicate change listener on port select across remounts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:20:30 +03:00
2e6f80f9ac feat: suspend input during port switch with overlay and countdown
All checks were successful
CI / fmt (push) Successful in 34s
Publish / frontend (push) Successful in 55s
CI / clippy (push) Successful in 1m21s
CI / check (push) Successful in 1m37s
Publish / backend (push) Successful in 2m51s
When switching ports via the console dropdown:
- Input (keyboard, mouse, wheel) is suspended immediately to prevent
  stray events from interfering with the OSCAR hotkey sequence
- A semi-transparent overlay with spinner and countdown timer appears
  over the console canvas
- Duration is calculated from the actual key_pause_duration setting
  multiplied by the number of * pause tokens in the port's hotkey,
  plus a 500ms buffer
- Input resumes and overlay disappears when the timer expires

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 13:11:47 +03:00
7406b4ac02 feat: auto-resume session on page refresh
All checks were successful
CI / fmt (push) Successful in 35s
Publish / frontend (push) Successful in 48s
CI / check (push) Successful in 1m28s
CI / clippy (push) Successful in 1m25s
Publish / backend (push) Successful in 2m42s
On load, check sessionStorage for stored credentials (saved during
login). If present, auto-authenticate and skip the login form. Falls
back to showing login if the stored credentials fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 12:19:03 +03:00
35db634317 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 &gt; 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>
2026-05-07 12:13:57 +03:00
9bd215356b fix: add __templates__ and image button coords to KVM form submissions
All checks were successful
CI / fmt (push) Successful in 34s
Publish / frontend (push) Successful in 46s
CI / clippy (push) Successful in 1m21s
CI / check (push) Successful in 1m32s
Publish / backend (push) Successful in 2m44s
The Belkin firmware requires:
1. A hidden __templates__ field in all form POSTs
2. Image button actions submitted as name.x=0&name.y=0 (not name=value)
3. ECG_kvm_powerport_cnt hidden field

Without these, the device returns "Permission denied" on port config
saves. Also fixed the switch endpoint with the same pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 11:50:38 +03:00
d503742542 fix: port count change immediately updates table rows
Some checks failed
CI / fmt (push) Failing after 39s
Publish / frontend (push) Successful in 47s
CI / clippy (push) Successful in 1m44s
CI / check (push) Successful in 1m47s
Publish / backend (push) Successful in 2m50s
When the port count dropdown changes, the table now re-renders with
the new number of rows, preserving existing values for ports that
were already visible. This allows configuring ports > 16 after
switching to a higher port count.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 11:38:52 +03:00
63aa9a400f feat: Send Key dropdown for browser-intercepted keys
Some checks failed
CI / fmt (push) Failing after 40s
Publish / frontend (push) Successful in 44s
CI / check (push) Successful in 1m21s
CI / clippy (push) Successful in 1m45s
Publish / backend (push) Successful in 2m51s
Adds a "Send Key" dropdown menu to the console toolbar for keys that
browsers intercept before JavaScript can capture them (Print Screen,
Scroll Lock, Pause/Break). Also includes Escape, Tab, Caps Lock,
Num Lock, and Ctrl+Alt+Del.

Each menu item sends a press+release scancode pair directly over the
WebSocket, bypassing browser key capture entirely. This enables
activating downstream KVM switch menus (e.g., Avocent OSCAR via
Print Screen) from the blekin interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 11:32:10 +03:00
ea18d97aa6 feat: KVM port management — configuration, switching, and navigation shell
Some checks failed
CI / fmt (push) Failing after 43s
Publish / frontend (push) Successful in 44s
CI / check (push) Successful in 1m20s
CI / clippy (push) Successful in 1m41s
Publish / backend (push) Successful in 2m49s
Backend (crates/ericrfb-proxy):
- Session cookie now persisted in AppState for device API calls
- New kvm.rs with three REST endpoints:
  GET /api/kvm/ports — scrapes kvm.asp, returns port config as JSON
  PUT /api/kvm/ports — saves port names, hotkeys, visibility, count
  POST /api/kvm/switch — switches active KVM port via home2.asp
- HTML scraping extracts form values from predictable firmware HTML

Frontend (crates/ericrfb-frontend):
- New shell.ts: sidebar navigation with page routing pattern
  (Console, Ports — extensible for Virtual Media, Users, etc.)
- Console refactored into pages/console.ts with mount/unmount lifecycle
  - Port switcher dropdown in toolbar (fetches port list, switches on change)
  - WebSocket auto-reconnects after port switch
- New pages/ports.ts: editable port configuration table
  - Port count, key pause duration, per-port name/hotkey/show-in-console
  - Save, reload, and per-port switch buttons
  - Active port highlighted
- Dark theme sidebar with active state indicators

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 11:18:36 +03:00
dd029c7f93 doc: port mapping implementation planning 2026-05-07 11:13:36 +03:00
75a51def79 fix: correct keyboard scancode mapping from KeyTranslator.java
All checks were successful
CI / fmt (push) Successful in 39s
Publish / frontend (push) Successful in 45s
CI / check (push) Successful in 1m22s
CI / clippy (push) Successful in 1m38s
Publish / backend (push) Successful in 2m48s
The mapping was built assuming keynr followed physical keyboard order
with Escape=0. In reality, KeyTranslator.java maps Java VK_* codes to
keynr values with a different layout:

- keynr 0 = Backquote (not Escape)
- keynr 59 = Escape
- keynr 27 = Enter (not 40)
- keynr 40 = Backslash (not 42)

This caused the number row and QWERTY row to be off by 1 (Escape
was inserted at position 0, pushing everything). The home row
(CapsLock=28, A=29...L=37) happened to align by coincidence, which
is why 'g'(33) worked but 'r'(should be 18, was 19=T) didn't.

Also fixes: F1-F12 off by 1, PrintScreen/ScrollLock/Pause off by 1,
numpad operator keys swapped with numpad 7/8/9.

Corrected both Rust (input.rs) and TypeScript (input.ts) mappings
to match the authoritative KeyTranslator.java VK→keynr table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 10:42:02 +03:00
4f7d69c75a fix: Tight palette selector 0 reads two separate color bytes
All checks were successful
CI / fmt (push) Successful in 46s
Publish / frontend (push) Successful in 51s
CI / check (push) Successful in 1m28s
CI / clippy (push) Successful in 1m35s
Publish / backend (push) Successful in 2m45s
When pal_selector=0 (no predefined palette), the Java code reads two
separate full bytes for the 2-color palette (line 421-422 of
ByteColorRFBRenderer.java). We were reading one packed byte and
splitting into nibbles, consuming 1 byte too few and misaligning all
subsequent reads including the zlib compressed data.

This caused "deflate decompression error" on stream 1 because the
zlib header (78 da) was offset by 1 byte.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 09:55:21 +03:00
19 changed files with 1647 additions and 338 deletions

View File

@@ -10,6 +10,10 @@ env:
jobs:
frontend:
runs-on: rust
if: >-
contains(github.event.head_commit.message, '[deploy: frontend]')
|| contains(github.event.head_commit.message, '[deploy: backend, frontend]')
|| contains(github.event.head_commit.message, '[deploy: frontend, backend]')
steps:
- uses: actions/checkout@v4
@@ -37,6 +41,10 @@ jobs:
backend:
runs-on: rust
if: >-
contains(github.event.head_commit.message, '[deploy: backend]')
|| contains(github.event.head_commit.message, '[deploy: backend, frontend]')
|| contains(github.event.head_commit.message, '[deploy: frontend, backend]')
steps:
- uses: actions/checkout@v4

1
Cargo.lock generated
View File

@@ -267,6 +267,7 @@ dependencies = [
"futures-util",
"reqwest",
"serde",
"serde_json",
"tokio",
"toml",
"tower-http 0.5.2",

142
README.md Normal file
View File

@@ -0,0 +1,142 @@
# blekin
A Rust proxy that translates the Belkin OmniView Remote IP Manager's proprietary
e-RIC RFB protocol (Peppercon LARA, originally served via a Java applet) into a
modern HTML5 KVM console. No Java anywhere in the stack.
## What it does
```
Browser (Vite + TypeScript)
│ WebSocket (binary, RGBA blits + input events)
blekin proxy (Rust, tokio + axum)
│ HTTP session (cookie + APPLET_ID extraction)
│ TCP to OmniView:443 (e-RIC RFB)
Belkin OmniView Remote IP Manager → downstream KVM switch → servers
```
The proxy authenticates with the OmniView's web interface, establishes an e-RIC
RFB session over TCP, decodes the proprietary 8bpp protocol to RGBA, and bridges
video frames and input events to a browser-based console over WebSocket.
## Features
- Full KVM console: keyboard, mouse, scroll wheel
- Encodings: Raw, CopyRect, Hextile, Tight (with zlib), IIP tile cache, Raw-tile
- Send Key menu for browser-intercepted keys (Print Screen, Scroll Lock, etc.)
- Port switching with downstream KVM support (Avocent OSCAR tested)
- Port configuration: naming, hotkeys, visibility
- Auto-reconnect with exponential backoff
- Session persistence across page refreshes
- Dark theme, fullscreen mode
## Building
```sh
# Backend
cargo build --release -p ericrfb-proxy
# Frontend
cd crates/ericrfb-frontend
npm ci
npm run build # outputs to ../../dist/
```
## Configuration
The proxy reads `config.toml` or falls back to the `BLEKIN_HOST` environment variable:
```toml
bind = "0.0.0.0:3000"
static_dir = "dist"
[omniview]
host = "10.3.0.130"
http_port = 80
rfb_port = 443
```
## Deployment
The systemd unit runs the proxy as a service. Nginx serves the static frontend
and reverse-proxies `/api/` to the proxy:
```sh
# Backend
sudo cp target/release/ericrfb-proxy /usr/local/bin/
sudo cp asset/systemd/blekin.service /etc/systemd/system/
sudo systemctl enable --now blekin.service
# Frontend
sudo cp -r dist/* /var/www/blekin.example.com/
sudo cp asset/nginx/blekin.example.conf /etc/nginx/sites-available/
sudo ln -sf /etc/nginx/sites-available/blekin.example.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
## Port switching with downstream KVM
The Belkin OmniView can control a downstream KVM switch (e.g., Avocent AutoView)
by sending configurable hotkey sequences when switching ports. Configure these
in the Ports page of the blekin interface.
### Avocent OSCAR setup
The Avocent OSCAR menu is invoked with Print Screen, then expects the port's EID
(Electronic ID) typed as digits, followed by Enter. Each phase needs a pause to
let the OSCAR process the input.
**Recommended settings:**
- **Key pause:** `1000` ms (in the Ports page global settings)
- **Hotkey format:** `PRINTSCREEN-***<EID digits>-***ENTER`
The `*` character inserts a pause of the configured duration. Three `***` = 3
seconds, which gives the OSCAR reliable processing time.
**Examples** (with key pause = 1000ms):
| Server | Avocent EID | Hotkey |
|--------|-------------|--------|
| server-a | 01 | `PRINTSCREEN-***1-***ENTER` |
| server-b | 05 | `PRINTSCREEN-***5-***ENTER` |
| server-c | 17 | `PRINTSCREEN-***1-7-***ENTER` |
| server-d | 22 | `PRINTSCREEN-***2-2-***ENTER` |
| server-e | 31 | `PRINTSCREEN-***3-1-***ENTER` |
Note: don't type the leading zero in EIDs (per Avocent OSCAR convention).
### Hotkey syntax reference
The Belkin hotkey syntax uses key names connected by operators:
| Operator | Meaning |
|----------|---------|
| `+` | Press additionally (hold previous, press next) |
| `-` | Release all pressed keys |
| `>` | Release most recently pressed key only |
| `*` | Pause for the configured key pause duration |
Key names: `A`-`Z`, `0`-`9`, `F1`-`F12`, `PRINTSCREEN`, `ENTER`, `ESCAPE`,
`LCTRL`/`CTRL`, `LALT`/`ALT`, `LSHIFT`/`SHIFT`, `SPACE`, `TAB`, `DELETE`,
`INSERT`, `HOME`, `END`, `PAGE_UP`, `PAGE_DOWN`, `UP`, `DOWN`, `LEFT`, `RIGHT`,
`BACK_SPACE`, `NUM_LOCK`, `NUMPAD0`-`NUMPAD9`, `NUMPADPLUS`, `NUMPADMINUS`,
`NUMPADMUL`, `NUMPAD/`, `NUMPADENTER`, `SCROLL_LOCK`, `BREAK`, `CAPS_LOCK`.
## Protocol heritage
The e-RIC RFB protocol is a proprietary variant of VNC/RFB developed by
Peppercon (later acquired by Raritan) under the name "LARA" (LAN Attached
Remote Access). It was used in several KVM-over-IP products including the Belkin
OmniView Remote IP Manager (F1DE101H). The protocol shares structural
similarities with standard RFB but uses its own handshake, pixel format
negotiation, and encoding extensions.
This implementation was reverse-engineered from the CFR-decompiled `rc.jar` Java
applet that originally provided the browser-based console.
## License
MIT

View File

@@ -1,218 +0,0 @@
import { codeToScancode } from './input'
import {
TAG_BLIT, TAG_RESIZE,
makeKeyPress, makeKeyRelease, makePointer, makeCtrlAltDel,
} from './protocol'
import { showLogin } from './login'
export function startConsole(
app: HTMLElement,
appletId: string,
port: number,
boardName: string,
) {
app.innerHTML = `
<div class="toolbar">
<span>${boardName}</span>
<button id="btn-cad">Ctrl+Alt+Del</button>
<button id="btn-fs">Fullscreen</button>
<button id="btn-disconnect">Disconnect</button>
<span class="status" id="status">connecting...</span>
</div>
<div class="console-wrap">
<canvas id="canvas" tabindex="0"></canvas>
</div>
`
const canvas = document.getElementById('canvas') as HTMLCanvasElement
const ctx = canvas.getContext('2d')!
const statusEl = document.getElementById('status')!
function setStatus(text: string, connected: boolean) {
statusEl.textContent = text
statusEl.classList.toggle('connected', connected)
}
// WebSocket with reconnect
let ws: WebSocket | null = null
let reconnectTimer: number | undefined
let reconnectDelay = 1000
function connect() {
if (reconnectTimer) clearTimeout(reconnectTimer)
setStatus('connecting...', false)
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProto}//${location.host}/api/ws?applet_id=${encodeURIComponent(appletId)}&port=${port}`
ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
setStatus('connected', true)
reconnectDelay = 1000
canvas.focus()
}
ws.onclose = () => {
setStatus(`disconnected — reconnecting in ${reconnectDelay / 1000}s...`, false)
scheduleReconnect()
}
ws.onerror = () => {
setStatus('connection error', false)
}
ws.onmessage = handleMessage
}
function scheduleReconnect() {
reconnectTimer = window.setTimeout(() => {
// Re-login to get a fresh APPLET_ID, then reconnect
relogin()
}, reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, 30000)
}
async function relogin() {
setStatus('re-authenticating...', false)
try {
const resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: sessionStorage.getItem('blekin_user') || 'administrator',
password: sessionStorage.getItem('blekin_pass') || '',
}),
})
if (!resp.ok) throw new Error('re-auth failed')
const data = await resp.json()
appletId = data.applet_id
port = data.port
connect()
} catch {
setStatus(`re-auth failed — retry in ${reconnectDelay / 1000}s...`, false)
scheduleReconnect()
}
}
function handleMessage(ev: MessageEvent) {
if (!(ev.data instanceof ArrayBuffer)) return
const view = new DataView(ev.data)
const tag = view.getUint8(0)
switch (tag) {
case TAG_RESIZE: {
const w = view.getUint16(1)
const h = view.getUint16(3)
canvas.width = w
canvas.height = h
break
}
case TAG_BLIT: {
const x = view.getUint16(1)
const y = view.getUint16(3)
const w = view.getUint16(5)
const h = view.getUint16(7)
const rgba = new Uint8ClampedArray(ev.data, 9)
const img = new ImageData(rgba, w, h)
ctx.putImageData(img, x, y)
break
}
}
}
// Keyboard input
let buttonMask = 0
canvas.addEventListener('keydown', (e) => {
e.preventDefault()
const sc = codeToScancode(e.code)
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) {
ws.send(makeKeyPress(sc))
}
})
canvas.addEventListener('keyup', (e) => {
e.preventDefault()
const sc = codeToScancode(e.code)
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) {
ws.send(makeKeyRelease(sc))
}
})
// Mouse input
function sendPointer(e: MouseEvent) {
if (ws?.readyState !== WebSocket.OPEN) return
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
const x = Math.round((e.clientX - rect.left) * scaleX)
const y = Math.round((e.clientY - rect.top) * scaleY)
ws.send(makePointer(
Math.max(0, Math.min(x, canvas.width - 1)),
Math.max(0, Math.min(y, canvas.height - 1)),
buttonMask,
))
}
canvas.addEventListener('mousemove', sendPointer)
canvas.addEventListener('mousedown', (e) => {
e.preventDefault()
canvas.focus()
if (e.button === 0) buttonMask |= 1
else if (e.button === 1) buttonMask |= 2
else if (e.button === 2) buttonMask |= 4
sendPointer(e)
})
canvas.addEventListener('mouseup', (e) => {
e.preventDefault()
if (e.button === 0) buttonMask &= ~1
else if (e.button === 1) buttonMask &= ~2
else if (e.button === 2) buttonMask &= ~4
sendPointer(e)
})
canvas.addEventListener('contextmenu', (e) => e.preventDefault())
canvas.addEventListener('wheel', (e) => {
e.preventDefault()
if (ws?.readyState !== WebSocket.OPEN) return
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
const x = Math.round((e.clientX - rect.left) * scaleX)
const y = Math.round((e.clientY - rect.top) * scaleY)
const scrollMask = e.deltaY < 0 ? 8 : 16
ws.send(makePointer(x, y, buttonMask | scrollMask))
ws.send(makePointer(x, y, buttonMask))
})
// Toolbar
document.getElementById('btn-cad')!.addEventListener('click', () => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(makeCtrlAltDel())
}
canvas.focus()
})
document.getElementById('btn-fs')!.addEventListener('click', () => {
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
app.requestFullscreen()
}
canvas.focus()
})
document.getElementById('btn-disconnect')!.addEventListener('click', () => {
if (reconnectTimer) clearTimeout(reconnectTimer)
ws?.close()
ws = null
showLogin(app)
})
// Start connection
connect()
}

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,47 +1,59 @@
// JavaScript KeyboardEvent.code → e-RIC scancode (KbdLayout_104pc)
// Derived from KeyTranslator.java line 14 (Java VK_* → keynr table).
// Must match crates/ericrfb/src/input.rs js_code_to_scancode()
const KEY_MAP: Record<string, number> = {
Escape: 0,
F1: 59, F2: 60, F3: 61, F4: 62, F5: 63, F6: 64,
F7: 65, F8: 66, F9: 67, F10: 68, F11: 69, F12: 70,
// Number row (keynr 0-13)
Backquote: 0,
Digit1: 1, Digit2: 2, Digit3: 3, Digit4: 4, Digit5: 5,
Digit6: 6, Digit7: 7, Digit8: 8, Digit9: 9, Digit0: 10,
Minus: 11, Equal: 12, Backspace: 13,
Backquote: 1,
Digit1: 2, Digit2: 3, Digit3: 4, Digit4: 5, Digit5: 6,
Digit6: 7, Digit7: 8, Digit8: 9, Digit9: 10, Digit0: 11,
Minus: 12, Equal: 13, Backspace: 14,
Tab: 15,
KeyQ: 16, KeyW: 17, KeyE: 18, KeyR: 19, KeyT: 20,
KeyY: 21, KeyU: 22, KeyI: 23, KeyO: 24, KeyP: 25,
BracketLeft: 26, BracketRight: 27,
// QWERTY row (keynr 14-27)
Tab: 14,
KeyQ: 15, KeyW: 16, KeyE: 17, KeyR: 18, KeyT: 19,
KeyY: 20, KeyU: 21, KeyI: 22, KeyO: 23, KeyP: 24,
BracketLeft: 25, BracketRight: 26, Enter: 27,
// Home row (keynr 28-40)
CapsLock: 28,
KeyA: 29, KeyS: 30, KeyD: 31, KeyF: 32, KeyG: 33,
KeyH: 34, KeyJ: 35, KeyK: 36, KeyL: 37,
Semicolon: 38, Quote: 39, Enter: 40,
Semicolon: 38, Quote: 39, Backslash: 40,
ShiftLeft: 41, Backslash: 42,
// Bottom row (keynr 41-53)
ShiftLeft: 41,
KeyZ: 43, KeyX: 44, KeyC: 45, KeyV: 46, KeyB: 47,
KeyN: 48, KeyM: 49, Comma: 50, Period: 51, Slash: 52,
ShiftRight: 53,
ControlLeft: 54, MetaLeft: 105, AltLeft: 55,
Space: 56,
AltRight: 57, MetaRight: 106, ControlRight: 58,
// Modifiers (keynr 54-58)
ControlLeft: 54, AltLeft: 55, Space: 56,
AltRight: 57, ControlRight: 58,
PrintScreen: 71, ScrollLock: 72, Pause: 73,
// Escape + Function keys (keynr 59-71)
Escape: 59,
F1: 60, F2: 61, F3: 62, F4: 63, F5: 64, F6: 65,
F7: 66, F8: 67, F9: 68, F10: 69, F11: 70, F12: 71,
// Navigation cluster (keynr 72-84)
PrintScreen: 72, ScrollLock: 73, Pause: 74,
Insert: 75, Home: 76, PageUp: 77,
Delete: 78, End: 79, PageDown: 80,
ArrowUp: 81, ArrowLeft: 82, ArrowDown: 83, ArrowRight: 84,
NumLock: 85, NumpadDivide: 86, NumpadMultiply: 87, NumpadSubtract: 88,
NumpadAdd: 89, NumpadEnter: 98,
Numpad7: 90, Numpad8: 94, Numpad9: 99,
// Numpad (keynr 85-101)
NumLock: 85,
Numpad7: 86, Numpad8: 87, Numpad9: 88,
NumpadAdd: 89, NumpadDivide: 90,
Numpad4: 91, Numpad5: 92, Numpad6: 93,
NumpadMultiply: 94,
Numpad1: 95, Numpad2: 96, Numpad3: 97,
NumpadEnter: 98, NumpadSubtract: 99,
Numpad0: 100, NumpadDecimal: 101,
// Windows/Meta keys (keynr 105-106)
MetaLeft: 105, MetaRight: 106,
}
export function codeToScancode(code: string): number | undefined {

View File

@@ -1,4 +1,4 @@
import { startConsole } from './console'
import { mountShell } from './shell'
interface LoginResponse {
applet_id: string
@@ -46,10 +46,13 @@ export function showLogin(app: HTMLElement) {
}
const data: LoginResponse = await resp.json()
// Store credentials for automatic reconnect
sessionStorage.setItem('blekin_user', username)
sessionStorage.setItem('blekin_pass', password)
startConsole(app, data.applet_id, data.port, data.board_name)
mountShell(app, {
appletId: data.applet_id,
port: data.port,
boardName: data.board_name,
})
} catch (err) {
errorDiv.textContent = (err as Error).message
errorDiv.hidden = false

View File

@@ -1,5 +1,29 @@
import './style.css'
import { showLogin } from './login'
import { mountShell } from './shell'
const app = document.getElementById('app')!
showLogin(app)
// Try to resume a session from stored credentials
const user = sessionStorage.getItem('blekin_user')
const pass = sessionStorage.getItem('blekin_pass')
if (user && pass) {
app.innerHTML = '<div class="login"><div class="login-form"><h1>Reconnecting...</h1></div></div>'
fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass }),
})
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
mountShell(app, { appletId: data.applet_id, port: data.port, boardName: data.board_name })
})
.catch(() => {
sessionStorage.removeItem('blekin_user')
sessionStorage.removeItem('blekin_pass')
showLogin(app)
})
} else {
showLogin(app)
}

View File

@@ -0,0 +1,370 @@
import { codeToScancode } from '../input'
import { parseHotkey } from '../hotkey'
import {
TAG_BLIT, TAG_RESIZE,
makeKeyPress, makeKeyRelease, makePointer, makeCtrlAltDel,
} from '../protocol'
import type { SessionInfo } from '../shell'
let ws: WebSocket | null = null
let reconnectTimer: number | undefined
let reconnectDelay = 1000
let containerEl: HTMLElement | null = null
let session: SessionInfo
let buttonMask = 0
export function mountConsole(el: HTMLElement, s: SessionInfo) {
containerEl = el
session = s
el.innerHTML = `
<div class="toolbar">
<span>${s.boardName}</span>
<select id="port-select" title="Switch KVM port"></select>
<div class="sendkey-wrap">
<button id="btn-sendkey">Send Key &#x25BE;</button>
<div class="sendkey-menu" id="sendkey-menu" hidden>
<button data-sc="72">Print Screen</button>
<button data-sc="73">Scroll Lock</button>
<button data-sc="74">Pause / Break</button>
<hr />
<button data-sc="59">Escape</button>
<button data-sc="14">Tab</button>
<button data-sc="28">Caps Lock</button>
<button data-sc="85">Num Lock</button>
<hr />
<button data-cad="1">Ctrl + Alt + Del</button>
</div>
</div>
<button id="btn-fs">Fullscreen</button>
<span class="status" id="status">connecting...</span>
</div>
<div class="console-wrap">
<canvas id="canvas" tabindex="0"></canvas>
</div>
`
loadPortList()
connect()
wireInputHandlers()
wireToolbar()
}
export function unmountConsole() {
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = undefined
ws?.close()
ws = null
buttonMask = 0
inputSuspended = false
portSelectWired = false
if (containerEl) containerEl.innerHTML = ''
containerEl = null
}
// ---------------------------------------------------------------------------
// WebSocket
// ---------------------------------------------------------------------------
function setStatus(text: string, connected: boolean) {
const el = document.getElementById('status')
if (el) {
el.textContent = text
el.classList.toggle('connected', connected)
}
}
function connect() {
if (reconnectTimer) clearTimeout(reconnectTimer)
setStatus('connecting...', false)
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProto}//${location.host}/api/ws?applet_id=${encodeURIComponent(session.appletId)}&port=${session.port}`
ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
setStatus('connected', true)
reconnectDelay = 1000
document.getElementById('canvas')?.focus()
}
ws.onclose = () => {
if (!containerEl) return // unmounted
setStatus(`disconnected — reconnecting in ${reconnectDelay / 1000}s...`, false)
scheduleReconnect()
}
ws.onerror = () => setStatus('connection error', false)
ws.onmessage = handleMessage
}
function scheduleReconnect() {
reconnectTimer = window.setTimeout(relogin, reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, 30000)
}
async function relogin() {
if (!containerEl) return
setStatus('re-authenticating...', false)
try {
const resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: sessionStorage.getItem('blekin_user') || 'administrator',
password: sessionStorage.getItem('blekin_pass') || '',
}),
})
if (!resp.ok) throw new Error('re-auth failed')
const data = await resp.json()
session = { ...session, appletId: data.applet_id, port: data.port }
connect()
} catch {
setStatus(`re-auth failed — retry in ${reconnectDelay / 1000}s...`, false)
scheduleReconnect()
}
}
function handleMessage(ev: MessageEvent) {
if (!(ev.data instanceof ArrayBuffer)) return
const view = new DataView(ev.data)
const tag = view.getUint8(0)
const canvas = document.getElementById('canvas') as HTMLCanvasElement | null
if (!canvas) return
const ctx = canvas.getContext('2d')!
switch (tag) {
case TAG_RESIZE: {
canvas.width = view.getUint16(1)
canvas.height = view.getUint16(3)
break
}
case TAG_BLIT: {
const x = view.getUint16(1)
const y = view.getUint16(3)
const w = view.getUint16(5)
const h = view.getUint16(7)
const rgba = new Uint8ClampedArray(ev.data, 9)
ctx.putImageData(new ImageData(rgba, w, h), x, y)
break
}
}
}
// ---------------------------------------------------------------------------
// Port switcher
// ---------------------------------------------------------------------------
let portHotkeys: Record<number, string> = {}
let keyPauseDuration = 2000
let inputSuspended = false
let selectedPort = -1
let portSelectWired = false
async function loadPortList() {
try {
const resp = await fetch('/api/kvm/ports')
if (!resp.ok) return
const data = await resp.json()
keyPauseDuration = data.key_pause_duration || 2000
const select = document.getElementById('port-select') as HTMLSelectElement
if (!select) return
select.innerHTML = ''
portHotkeys = {}
// Use locally tracked selection if we've switched, otherwise use device's active port
const activePort = selectedPort >= 0 ? selectedPort : data.active_port
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 === activePort
select.appendChild(opt)
if (p.hotkey) portHotkeys[p.index] = p.hotkey
}
if (!portSelectWired) {
select.addEventListener('change', () => {
const port = parseInt(select.value)
switchToPort(port)
})
portSelectWired = true
}
} catch { /* port list optional */ }
}
let switchTimerId = 0
function switchToPort(port: number) {
selectedPort = port
const hotkey = portHotkeys[port]
if (!hotkey || ws?.readyState !== WebSocket.OPEN) return
// Total duration: proxy round-trip + key sequence execution + settle time
const pauseCount = (hotkey.match(/\*/g) || []).length
const proxyLatency = 4000 // WS → proxy → Belkin → Avocent pipeline delay
const settleTime = 1000 // wait for video stream to stabilize after switch
const duration = proxyLatency + (pauseCount * keyPauseDuration) + settleTime
// Suspend input and show overlay
inputSuspended = true
if (switchTimerId) window.clearTimeout(switchTimerId)
showSwitchOverlay(duration)
// Send the hotkey sequence
const messages = parseHotkey(hotkey)
for (const msg of messages) {
ws.send(msg)
}
// Update Belkin's active port tracking
fetch('/api/kvm/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port }),
}).catch(() => {})
// Resume input after the switch completes
switchTimerId = window.setTimeout(dismissOverlay, duration)
}
function dismissOverlay() {
switchTimerId = 0
inputSuspended = false
const overlay = document.getElementById('switch-overlay')
if (overlay) overlay.style.display = 'none'
document.getElementById('canvas')?.focus()
}
function showSwitchOverlay(duration: number) {
let overlay = document.getElementById('switch-overlay')
if (!overlay) {
overlay = document.createElement('div')
overlay.id = 'switch-overlay'
overlay.className = 'switch-overlay'
document.querySelector('.console-wrap')?.appendChild(overlay)
}
overlay.style.display = ''
overlay.innerHTML = `
<div class="switch-overlay-content">
<div class="switch-spinner"></div>
<div>Switching port...</div>
<div class="switch-timer" id="switch-timer"></div>
</div>
`
// Countdown timer
const endTime = Date.now() + duration
const tick = () => {
const timerEl = document.getElementById('switch-timer')
if (!timerEl) return
const remaining = Math.max(0, endTime - Date.now())
timerEl.textContent = `${(remaining / 1000).toFixed(1)}s`
if (remaining > 0) requestAnimationFrame(tick)
}
tick()
}
// ---------------------------------------------------------------------------
// Input handlers
// ---------------------------------------------------------------------------
function wireInputHandlers() {
const canvas = document.getElementById('canvas')! as HTMLCanvasElement
canvas.addEventListener('keydown', (e) => {
e.preventDefault()
if (inputSuspended) return
const sc = codeToScancode(e.code)
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) ws.send(makeKeyPress(sc))
})
canvas.addEventListener('keyup', (e) => {
e.preventDefault()
if (inputSuspended) return
const sc = codeToScancode(e.code)
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) ws.send(makeKeyRelease(sc))
})
function sendPointer(e: MouseEvent) {
if (inputSuspended || ws?.readyState !== WebSocket.OPEN) return
const rect = canvas.getBoundingClientRect()
const x = Math.round((e.clientX - rect.left) * canvas.width / rect.width)
const y = Math.round((e.clientY - rect.top) * canvas.height / rect.height)
ws.send(makePointer(
Math.max(0, Math.min(x, canvas.width - 1)),
Math.max(0, Math.min(y, canvas.height - 1)),
buttonMask,
))
}
canvas.addEventListener('mousemove', sendPointer)
canvas.addEventListener('mousedown', (e) => {
e.preventDefault()
canvas.focus()
if (e.button === 0) buttonMask |= 1
else if (e.button === 1) buttonMask |= 2
else if (e.button === 2) buttonMask |= 4
sendPointer(e)
})
canvas.addEventListener('mouseup', (e) => {
e.preventDefault()
if (e.button === 0) buttonMask &= ~1
else if (e.button === 1) buttonMask &= ~2
else if (e.button === 2) buttonMask &= ~4
sendPointer(e)
})
canvas.addEventListener('contextmenu', (e) => e.preventDefault())
canvas.addEventListener('wheel', (e) => {
e.preventDefault()
if (inputSuspended || ws?.readyState !== WebSocket.OPEN) return
const rect = canvas.getBoundingClientRect()
const x = Math.round((e.clientX - rect.left) * canvas.width / rect.width)
const y = Math.round((e.clientY - rect.top) * canvas.height / rect.height)
const scrollMask = e.deltaY < 0 ? 8 : 16
ws.send(makePointer(x, y, buttonMask | scrollMask))
ws.send(makePointer(x, y, buttonMask))
})
}
function wireToolbar() {
// Send Key dropdown
const sendKeyBtn = document.getElementById('btn-sendkey')!
const sendKeyMenu = document.getElementById('sendkey-menu')!
sendKeyBtn.addEventListener('click', (e) => {
e.stopPropagation()
sendKeyMenu.hidden = !sendKeyMenu.hidden
})
// Close menu on outside click
document.addEventListener('click', () => { sendKeyMenu.hidden = true })
sendKeyMenu.addEventListener('click', (e) => e.stopPropagation())
sendKeyMenu.querySelectorAll('button[data-sc]').forEach(btn => {
btn.addEventListener('click', () => {
const sc = parseInt((btn as HTMLElement).dataset.sc!)
if (ws?.readyState === WebSocket.OPEN) {
ws.send(makeKeyPress(sc))
ws.send(makeKeyRelease(sc))
}
sendKeyMenu.hidden = true
document.getElementById('canvas')?.focus()
})
})
sendKeyMenu.querySelectorAll('button[data-cad]').forEach(btn => {
btn.addEventListener('click', () => {
if (ws?.readyState === WebSocket.OPEN) ws.send(makeCtrlAltDel())
sendKeyMenu.hidden = true
document.getElementById('canvas')?.focus()
})
})
// Fullscreen
document.getElementById('btn-fs')?.addEventListener('click', () => {
const shell = document.querySelector('.shell')
if (document.fullscreenElement) document.exitFullscreen()
else shell?.requestFullscreen()
document.getElementById('canvas')?.focus()
})
}

View File

@@ -0,0 +1,195 @@
import type { SessionInfo } from '../shell'
let containerEl: HTMLElement | null = null
interface PortInfo {
index: number
name: string
hotkey: string
show_in_rc: boolean
}
interface PortsData {
port_count: number
key_pause_duration: number
active_port: number
ports: PortInfo[]
}
export function mountPorts(el: HTMLElement, _session: SessionInfo) {
containerEl = el
el.innerHTML = `
<div class="ports-page">
<div class="ports-header">
<h2>KVM Port Configuration</h2>
<div class="ports-actions">
<button id="btn-reload" class="btn">Reload</button>
<button id="btn-save" class="btn btn-primary">Save</button>
</div>
</div>
<div class="ports-toast" id="toast" hidden></div>
<div class="ports-global">
<label>
Port count
<select id="port-count">
${[1, 2, 4, 8, 12, 16, 24, 32, 48, 64].map(n => `<option value="${n}">${n}</option>`).join('')}
</select>
</label>
<label>
Key pause
<input type="number" id="key-pause" min="0" max="9999" value="100" />
<span>ms</span>
</label>
</div>
<table class="ports-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Hotkey</th>
<th>Show</th>
<th></th>
</tr>
</thead>
<tbody id="ports-body"></tbody>
</table>
</div>
`
document.getElementById('btn-reload')?.addEventListener('click', loadPorts)
document.getElementById('btn-save')?.addEventListener('click', savePorts)
document.getElementById('port-count')?.addEventListener('change', onPortCountChange)
loadPorts()
}
export function unmountPorts() {
if (containerEl) containerEl.innerHTML = ''
containerEl = null
}
function showToast(msg: string, success: boolean) {
const el = document.getElementById('toast')
if (!el) return
el.textContent = msg
el.className = `ports-toast ${success ? 'toast-ok' : 'toast-err'}`
el.hidden = false
setTimeout(() => { el.hidden = true }, 3000)
}
async function loadPorts() {
try {
const resp = await fetch('/api/kvm/ports')
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data: PortsData = await resp.json()
renderPorts(data)
} catch (e) {
showToast(`Failed to load: ${e}`, false)
}
}
let currentActivePort = 0
function renderPorts(data: PortsData) {
const countEl = document.getElementById('port-count') as HTMLSelectElement
const pauseEl = document.getElementById('key-pause') as HTMLInputElement
if (countEl) countEl.value = String(data.port_count)
if (pauseEl) pauseEl.value = String(data.key_pause_duration)
currentActivePort = data.active_port
renderPortRows(data.ports, data.active_port)
}
function renderPortRows(ports: PortInfo[], activePort: number) {
const tbody = document.getElementById('ports-body')!
tbody.innerHTML = ''
for (const p of ports) {
const tr = document.createElement('tr')
tr.className = p.index === activePort ? 'active-port' : ''
tr.innerHTML = `
<td class="port-num">${p.index + 1}</td>
<td><input type="text" class="port-name" data-idx="${p.index}" value="${esc(p.name)}" maxlength="20" /></td>
<td><input type="text" class="port-hotkey" data-idx="${p.index}" value="${esc(p.hotkey)}" maxlength="64" /></td>
<td><input type="checkbox" class="port-show" data-idx="${p.index}" ${p.show_in_rc ? 'checked' : ''} /></td>
<td><button class="btn btn-sm btn-switch" data-idx="${p.index}">Switch</button></td>
`
tbody.appendChild(tr)
}
// Wire switch buttons
tbody.querySelectorAll('.btn-switch').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt((btn as HTMLElement).dataset.idx!)
try {
await fetch('/api/kvm/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port: idx }),
})
showToast(`Switched to port ${idx + 1}`, true)
loadPorts()
} catch (e) {
showToast(`Switch failed: ${e}`, false)
}
})
})
}
function onPortCountChange() {
const countEl = document.getElementById('port-count') as HTMLSelectElement
const newCount = parseInt(countEl?.value || '16')
// Read current values from the existing rows
const ports: PortInfo[] = []
for (let i = 0; i < newCount; i++) {
const nameEl = document.querySelector(`.port-name[data-idx="${i}"]`) as HTMLInputElement | null
const hotkeyEl = document.querySelector(`.port-hotkey[data-idx="${i}"]`) as HTMLInputElement | null
const showEl = document.querySelector(`.port-show[data-idx="${i}"]`) as HTMLInputElement | null
ports.push({
index: i,
name: nameEl?.value || '',
hotkey: hotkeyEl?.value || '',
show_in_rc: showEl?.checked || false,
})
}
renderPortRows(ports, currentActivePort)
}
async function savePorts() {
const countEl = document.getElementById('port-count') as HTMLSelectElement
const pauseEl = document.getElementById('key-pause') as HTMLInputElement
const portCount = parseInt(countEl?.value || '16')
const keyPause = parseInt(pauseEl?.value || '100')
const ports: PortInfo[] = []
for (let i = 0; i < portCount; i++) {
const nameEl = document.querySelector(`.port-name[data-idx="${i}"]`) as HTMLInputElement
const hotkeyEl = document.querySelector(`.port-hotkey[data-idx="${i}"]`) as HTMLInputElement
const showEl = document.querySelector(`.port-show[data-idx="${i}"]`) as HTMLInputElement
ports.push({
index: i,
name: nameEl?.value || '',
hotkey: hotkeyEl?.value || '',
show_in_rc: showEl?.checked || false,
})
}
try {
const resp = await fetch('/api/kvm/ports', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port_count: portCount, key_pause_duration: keyPause, ports }),
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
showToast('Configuration saved', true)
loadPorts()
} catch (e) {
showToast(`Save failed: ${e}`, false)
}
}
function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}

View File

@@ -0,0 +1,60 @@
import { mountConsole, unmountConsole } from './pages/console'
import { mountPorts, unmountPorts } from './pages/ports'
export interface SessionInfo {
appletId: string
port: number
boardName: string
}
type PageId = 'console' | 'ports'
let currentPage: PageId | null = null
let contentEl: HTMLElement
let session: SessionInfo
const pages: Record<PageId, { mount: (el: HTMLElement, s: SessionInfo) => void; unmount: () => void }> = {
console: { mount: mountConsole, unmount: unmountConsole },
ports: { mount: mountPorts, unmount: unmountPorts },
}
function navigate(page: PageId) {
if (currentPage === page) return
if (currentPage) pages[currentPage].unmount()
// Update nav links
document.querySelectorAll('.nav-link').forEach(el => {
el.classList.toggle('active', el.getAttribute('data-page') === page)
})
currentPage = page
pages[page].mount(contentEl, session)
}
export function mountShell(app: HTMLElement, s: SessionInfo) {
session = s
app.innerHTML = `
<div class="shell">
<nav class="sidebar">
<div class="sidebar-brand">blekin</div>
<a href="#" data-page="console" class="nav-link active">Console</a>
<a href="#" data-page="ports" class="nav-link">Ports</a>
</nav>
<main class="content" id="page-content"></main>
</div>
`
contentEl = document.getElementById('page-content')!
// Wire up navigation
document.querySelectorAll('.nav-link').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault()
const page = el.getAttribute('data-page') as PageId
if (page) navigate(page)
})
})
// Default to console
navigate('console')
}

View File

@@ -32,10 +32,7 @@ body {
min-width: 320px;
}
.login-form h1 {
font-size: 1.25rem;
text-align: center;
}
.login-form h1 { font-size: 1.25rem; text-align: center; }
.login-form input {
padding: 0.5rem;
@@ -58,26 +55,65 @@ body {
.login-form button:hover { background: #5a9c69; }
.login-form button:disabled { opacity: 0.5; cursor: not-allowed; }
.login-error { color: #e74c3c; font-size: 0.85rem; text-align: center; }
.login-error {
color: #e74c3c;
font-size: 0.85rem;
text-align: center;
/* Shell layout */
.shell {
display: flex;
height: 100vh;
}
/* Toolbar */
.sidebar {
width: 160px;
min-width: 160px;
background: #222;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
padding: 0.5rem 0;
}
.sidebar-brand {
padding: 0.75rem 1rem;
font-size: 1.1rem;
font-weight: 600;
color: #4a7c59;
border-bottom: 1px solid #333;
margin-bottom: 0.25rem;
}
.nav-link {
display: block;
padding: 0.5rem 1rem;
color: #999;
text-decoration: none;
font-size: 0.85rem;
border-left: 3px solid transparent;
}
.nav-link:hover { color: #ccc; background: #2a2a2a; }
.nav-link.active { color: #e0e0e0; border-left-color: #4a7c59; background: #2a2a2a; }
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Console toolbar */
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: #222;
background: #1e1e1e;
border-bottom: 1px solid #333;
font-size: 0.8rem;
}
.toolbar button {
padding: 0.25rem 0.75rem;
.toolbar button, .toolbar select {
padding: 0.25rem 0.5rem;
border: 1px solid #444;
border-radius: 3px;
background: #333;
@@ -86,16 +122,49 @@ body {
font-size: 0.8rem;
}
.toolbar button:hover { background: #444; }
.toolbar .status {
margin-left: auto;
color: #888;
}
.toolbar button:hover, .toolbar select:hover { background: #444; }
.toolbar .status { margin-left: auto; color: #888; }
.toolbar .status.connected { color: #4a7c59; }
/* Console */
/* Send Key dropdown */
.sendkey-wrap {
position: relative;
}
.sendkey-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 100;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
padding: 0.25rem 0;
min-width: 160px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.sendkey-menu button {
display: block;
width: 100%;
padding: 0.35rem 0.75rem;
border: none;
background: none;
color: #ccc;
text-align: left;
cursor: pointer;
font-size: 0.8rem;
}
.sendkey-menu button:hover { background: #383838; }
.sendkey-menu hr {
border: none;
border-top: 1px solid #3a3a3a;
margin: 0.2rem 0;
}
/* Console canvas */
.console-wrap {
flex: 1;
display: flex;
@@ -109,3 +178,149 @@ body {
image-rendering: pixelated;
cursor: none;
}
/* Port switch overlay */
.switch-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.switch-overlay-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
color: #ccc;
font-size: 0.9rem;
}
.switch-spinner {
width: 32px;
height: 32px;
border: 3px solid #444;
border-top-color: #4a7c59;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.switch-timer {
font-variant-numeric: tabular-nums;
color: #888;
font-size: 0.8rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Console wrap needs position for overlay */
.console-wrap {
position: relative;
}
/* Ports config page */
.ports-page {
padding: 1.5rem;
overflow-y: auto;
max-height: 100vh;
}
.ports-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.ports-header h2 { font-size: 1.1rem; font-weight: 500; }
.ports-actions { display: flex; gap: 0.5rem; }
.ports-toast {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.85rem;
margin-bottom: 1rem;
}
.toast-ok { background: #1a3a1a; color: #6ece6e; }
.toast-err { background: #3a1a1a; color: #e74c3c; }
.ports-global {
display: flex;
gap: 1.5rem;
align-items: center;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.ports-global label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ports-global input, .ports-global select {
padding: 0.3rem 0.5rem;
border: 1px solid #444;
border-radius: 3px;
background: #2a2a2a;
color: #e0e0e0;
font-size: 0.85rem;
width: 80px;
}
.ports-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.ports-table th {
text-align: left;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid #444;
color: #999;
font-weight: 500;
}
.ports-table td {
padding: 0.3rem 0.5rem;
border-bottom: 1px solid #2a2a2a;
}
.ports-table .port-num { color: #666; width: 2rem; text-align: center; }
.ports-table input[type="text"] {
padding: 0.25rem 0.4rem;
border: 1px solid #444;
border-radius: 3px;
background: #2a2a2a;
color: #e0e0e0;
font-size: 0.85rem;
width: 100%;
}
.ports-table input[type="checkbox"] { cursor: pointer; }
.active-port { background: #1a2a1a; }
.active-port .port-num { color: #4a7c59; font-weight: 600; }
/* Buttons */
.btn {
padding: 0.35rem 0.75rem;
border: 1px solid #444;
border-radius: 4px;
background: #333;
color: #ccc;
cursor: pointer;
font-size: 0.8rem;
}
.btn:hover { background: #444; }
.btn-primary { background: #4a7c59; border-color: #4a7c59; color: white; }
.btn-primary:hover { background: #5a9c69; }
.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.75rem; }

View File

@@ -10,6 +10,7 @@ tokio.workspace = true
axum.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json = "1"
toml.workspace = true
tower-http.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,266 @@
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use crate::AppState;
use crate::login::ErrorResponse;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
#[derive(Serialize)]
pub struct PortsResponse {
pub port_count: u16,
pub key_pause_duration: u16,
pub active_port: u16,
pub ports: Vec<PortInfo>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct PortInfo {
pub index: u16,
pub name: String,
pub hotkey: String,
pub show_in_rc: bool,
}
#[derive(Deserialize)]
pub struct SavePortsRequest {
pub port_count: u16,
pub key_pause_duration: u16,
pub ports: Vec<PortInfo>,
}
#[derive(Deserialize)]
pub struct SwitchRequest {
pub port: u16,
}
#[derive(Serialize)]
pub struct SwitchResponse {
pub active_port: u16,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async fn get_cookie(state: &AppState) -> Result<String, (StatusCode, Json<ErrorResponse>)> {
state.session_cookie.read().await.clone().ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "not logged in".into(),
}),
)
})
}
fn device_url(state: &AppState, path: &str) -> String {
format!(
"http://{}:{}{path}",
state.config.omniview.host, state.config.omniview.http_port
)
}
fn api_err(msg: impl Into<String>) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse { error: msg.into() }),
)
}
// ---------------------------------------------------------------------------
// HTML scraping helpers
// ---------------------------------------------------------------------------
fn extract_input_value(html: &str, name: &str) -> Option<String> {
let needle = format!("name=\"{name}\"");
let pos = html.find(&needle)?;
let after = &html[pos..];
let val_needle = "value=\"";
let val_pos = after.find(val_needle)? + val_needle.len();
let end = after[val_pos..].find('"')? + val_pos;
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> {
let needle = format!("name=\"{name}\"");
let pos = html.find(&needle)?;
let after = &html[pos..];
// Find "selected>" then the text until newline or '<'
let sel_pos = after.find("selected>")?;
let text_start = sel_pos + "selected>".len();
let text_end = after[text_start..].find(['<', '\n']).unwrap_or(0) + text_start;
Some(after[text_start..text_end].trim().to_string())
}
fn has_checked(html: &str, name: &str) -> bool {
let needle = format!("name=\"{name}\"");
if let Some(pos) = html.find(&needle) {
// Check if "checked" appears nearby before the next input/tag
let region = &html[pos.saturating_sub(80)..html.len().min(pos + 120)];
region.contains("checked")
} else {
false
}
}
// ---------------------------------------------------------------------------
// GET /api/kvm/ports
// ---------------------------------------------------------------------------
pub async fn get_ports(
State(state): State<AppState>,
) -> Result<Json<PortsResponse>, (StatusCode, Json<ErrorResponse>)> {
let cookie = get_cookie(&state).await?;
let html = state
.http_client
.get(device_url(&state, "/kvm.asp"))
.header("cookie", &cookie)
.send()
.await
.map_err(|e| api_err(format!("fetch kvm.asp: {e}")))?
.text()
.await
.map_err(|e| api_err(format!("read kvm.asp: {e}")))?;
let port_count: u16 = extract_selected_option(&html, "ECG_kvm_nr_ports")
.and_then(|s| s.parse().ok())
.unwrap_or(16);
let key_pause_duration: u16 = extract_input_value(&html, "ECG_key_pause_duration")
.and_then(|s| s.parse().ok())
.unwrap_or(100);
let active_port: u16 = extract_selected_option(&html, "kvm_active_port_0")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let mut ports = Vec::with_capacity(port_count as usize);
for i in 0..port_count {
let name = extract_input_value(&html, &format!("ECG_kvm_portname_{i}")).unwrap_or_default();
let hotkey = extract_input_value(&html, &format!("ECG_kvm_hotkey_{i}")).unwrap_or_default();
let show_in_rc = has_checked(&html, &format!("ECG_kvm_show_in_rc_{i}"));
ports.push(PortInfo {
index: i,
name,
hotkey,
show_in_rc,
});
}
Ok(Json(PortsResponse {
port_count,
key_pause_duration,
active_port,
ports,
}))
}
// ---------------------------------------------------------------------------
// PUT /api/kvm/ports
// ---------------------------------------------------------------------------
pub async fn save_ports(
State(state): State<AppState>,
Json(req): Json<SavePortsRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let cookie = get_cookie(&state).await?;
let mut form: Vec<(String, String)> = vec![
// Hidden template field required by the firmware
("__templates__".into(), " kvm_port_list kvm".into()),
("ECG_kvm_nr_ports".into(), req.port_count.to_string()),
(
"ECG_key_pause_duration".into(),
req.key_pause_duration.to_string(),
),
("ECG_kvm_portname_cnt".into(), req.port_count.to_string()),
("ECG_kvm_hotkey_cnt".into(), req.port_count.to_string()),
("ECG_kvm_powerport_cnt".into(), req.port_count.to_string()),
("ECG_kvm_show_in_rc_cnt".into(), req.port_count.to_string()),
];
for port in &req.ports {
form.push((
format!("ECG_kvm_portname_{}", port.index),
port.name.clone(),
));
form.push((
format!("ECG_kvm_hotkey_{}", port.index),
port.hotkey.clone(),
));
if port.show_in_rc {
form.push((format!("ECG_kvm_show_in_rc_{}", port.index), "yes".into()));
}
}
// Image button submits with .x/.y coordinates
form.push(("action_apply.x".into(), "0".into()));
form.push(("action_apply.y".into(), "0".into()));
let resp = state
.http_client
.post(device_url(&state, "/kvm.asp"))
.header("cookie", &cookie)
.form(&form)
.send()
.await
.map_err(|e| api_err(format!("post kvm.asp: {e}")))?;
let body = resp
.text()
.await
.map_err(|e| api_err(format!("read response: {e}")))?;
if body.contains("ERIC_RESPONSE_OK") {
Ok(Json(serde_json::json!({"ok": true})))
} else {
Err(api_err("device rejected configuration update"))
}
}
// ---------------------------------------------------------------------------
// POST /api/kvm/switch
// ---------------------------------------------------------------------------
pub async fn switch_port(
State(state): State<AppState>,
Json(req): Json<SwitchRequest>,
) -> Result<Json<SwitchResponse>, (StatusCode, Json<ErrorResponse>)> {
let cookie = get_cookie(&state).await?;
let form = [
(
"__templates__",
" kvm_port_list rc_preview power_control_ipmi power_control_intern power_control_direct".to_string(),
),
("kvm_active_port_0", req.port.to_string()),
("action_switch_0.x", "0".into()),
("action_switch_0.y", "0".into()),
];
state
.http_client
.post(device_url(&state, "/home2.asp"))
.header("cookie", &cookie)
.form(&form)
.send()
.await
.map_err(|e| api_err(format!("post home2.asp: {e}")))?;
Ok(Json(SwitchResponse {
active_port: req.port,
}))
}

View File

@@ -88,6 +88,9 @@ pub async fn handle_login(
let board_name =
extract_param(&html, "BOARD_NAME").unwrap_or_else(|| "Remote IP Manager".into());
// Persist session cookie for KVM API calls
*state.session_cookie.write().await = Some(cookie);
tracing::info!(
"login successful: board={board_name}, applet_id={}...",
&applet_id[..applet_id.len().min(16)]

View File

@@ -1,4 +1,5 @@
mod config;
mod kvm;
mod login;
mod ws;
@@ -7,6 +8,7 @@ use std::sync::Arc;
use axum::Router;
use axum::routing::{get, post};
use tokio::net::TcpListener;
use tokio::sync::RwLock;
use tower_http::services::ServeDir;
use tracing_subscriber::EnvFilter;
@@ -14,6 +16,7 @@ use tracing_subscriber::EnvFilter;
pub struct AppState {
pub config: Arc<config::ProxyConfig>,
pub http_client: reqwest::Client,
pub session_cookie: Arc<RwLock<Option<String>>>,
}
#[tokio::main]
@@ -36,11 +39,14 @@ async fn main() -> anyhow::Result<()> {
.danger_accept_invalid_certs(true)
.redirect(reqwest::redirect::Policy::none())
.build()?,
session_cookie: Arc::new(RwLock::new(None)),
};
let app = Router::new()
.route("/api/login", post(login::handle_login))
.route("/api/ws", get(ws::handle_ws))
.route("/api/kvm/ports", get(kvm::get_ports).put(kvm::save_ports))
.route("/api/kvm/switch", post(kvm::switch_port))
.fallback_service(ServeDir::new(&cfg.static_dir))
.with_state(state);

View File

@@ -171,8 +171,8 @@ pub fn decode_tight(
)));
}
let packed_colors = read_u8(r)?;
palette_2 = Some(if let Some(pal) = palette_for_selector(pal_selector) {
let packed_colors = read_u8(r)?;
match pal_selector {
1 => [
pal[(packed_colors >> 1) as usize],
@@ -189,13 +189,10 @@ pub fn decode_tight(
_ => [packed_colors >> 4, packed_colors & 0x0F],
}
} else {
// Default: read 2 separate color bytes
// Actually for selector 0 the Java code reads 2 bytes:
// nArray[0] = this.K[aw2.w.read()]; nArray[1] = this.K[aw2.w.read()];
// But packed_colors was already read as 1 byte. The protocol
// for selector 0 reads colors differently. Since this path is rare
// and we've already read the packed byte, treat high/low nibbles.
[packed_colors >> 4, packed_colors & 0x0F]
// Selector 0: two separate RGB332 color bytes (line 421-422)
let c0 = read_u8(r)?;
let c1 = read_u8(r)?;
[c0, c1]
});
row_bytes = (rw as usize).div_ceil(8);

View File

@@ -49,54 +49,41 @@ pub const HOTKEY_CTRL_ALT_DEL: &str = "36 f0 37 f0 4e";
/// Map a JavaScript `KeyboardEvent.code` string to an e-RIC scancode.
/// Returns `None` for unmapped keys.
pub fn js_code_to_scancode(code: &str) -> Option<u8> {
// Mapping derived from KeyTranslator.java line 14 (Java VK_* → keynr table).
Some(match code {
// Function row
"Escape" => 0,
"F1" => 59,
"F2" => 60,
"F3" => 61,
"F4" => 62,
"F5" => 63,
"F6" => 64,
"F7" => 65,
"F8" => 66,
"F9" => 67,
"F10" => 68,
"F11" => 69,
"F12" => 70,
// Number row (keynr 0-13)
"Backquote" => 0,
"Digit1" => 1,
"Digit2" => 2,
"Digit3" => 3,
"Digit4" => 4,
"Digit5" => 5,
"Digit6" => 6,
"Digit7" => 7,
"Digit8" => 8,
"Digit9" => 9,
"Digit0" => 10,
"Minus" => 11,
"Equal" => 12,
"Backspace" => 13,
// Number row
"Backquote" => 1,
"Digit1" => 2,
"Digit2" => 3,
"Digit3" => 4,
"Digit4" => 5,
"Digit5" => 6,
"Digit6" => 7,
"Digit7" => 8,
"Digit8" => 9,
"Digit9" => 10,
"Digit0" => 11,
"Minus" => 12,
"Equal" => 13,
"Backspace" => 14,
// QWERTY row (keynr 14-27)
"Tab" => 14,
"KeyQ" => 15,
"KeyW" => 16,
"KeyE" => 17,
"KeyR" => 18,
"KeyT" => 19,
"KeyY" => 20,
"KeyU" => 21,
"KeyI" => 22,
"KeyO" => 23,
"KeyP" => 24,
"BracketLeft" => 25,
"BracketRight" => 26,
"Enter" => 27,
// QWERTY row
"Tab" => 15,
"KeyQ" => 16,
"KeyW" => 17,
"KeyE" => 18,
"KeyR" => 19,
"KeyT" => 20,
"KeyY" => 21,
"KeyU" => 22,
"KeyI" => 23,
"KeyO" => 24,
"KeyP" => 25,
"BracketLeft" => 26,
"BracketRight" => 27,
// Home row
// Home row (keynr 28-40)
"CapsLock" => 28,
"KeyA" => 29,
"KeyS" => 30,
@@ -109,11 +96,10 @@ pub fn js_code_to_scancode(code: &str) -> Option<u8> {
"KeyL" => 37,
"Semicolon" => 38,
"Quote" => 39,
"Enter" => 40,
"Backslash" => 40,
// Bottom row
// Bottom row (keynr 41-53)
"ShiftLeft" => 41,
"Backslash" => 42,
"KeyZ" => 43,
"KeyX" => 44,
"KeyC" => 45,
@@ -126,51 +112,66 @@ pub fn js_code_to_scancode(code: &str) -> Option<u8> {
"Slash" => 52,
"ShiftRight" => 53,
// Modifier / bottom row
// Modifiers (keynr 54-58)
"ControlLeft" => 54,
"MetaLeft" => 105,
"AltLeft" => 55,
"Space" => 56,
"AltRight" => 57,
"MetaRight" => 106,
"ControlRight" => 58,
// Navigation cluster
"PrintScreen" => 71,
"ScrollLock" => 72,
"Pause" => 73,
// Escape + Function keys (keynr 59-71)
"Escape" => 59,
"F1" => 60,
"F2" => 61,
"F3" => 62,
"F4" => 63,
"F5" => 64,
"F6" => 65,
"F7" => 66,
"F8" => 67,
"F9" => 68,
"F10" => 69,
"F11" => 70,
"F12" => 71,
// Navigation cluster (keynr 72-84)
"PrintScreen" => 72,
"ScrollLock" => 73,
"Pause" => 74,
"Insert" => 75,
"Home" => 76,
"PageUp" => 77,
"Delete" => 78,
"End" => 79,
"PageDown" => 80,
// Arrow keys
"ArrowUp" => 81,
"ArrowLeft" => 82,
"ArrowDown" => 83,
"ArrowRight" => 84,
// Numpad
// Numpad (keynr 85-101)
"NumLock" => 85,
"NumpadDivide" => 86,
"NumpadMultiply" => 87,
"NumpadSubtract" => 88,
"Numpad7" => 86,
"Numpad8" => 87,
"Numpad9" => 88,
"NumpadAdd" => 89,
"NumpadEnter" => 98,
"Numpad7" => 90,
"Numpad8" => 94,
"Numpad9" => 99,
"NumpadDivide" => 90,
"Numpad4" => 91,
"Numpad5" => 92,
"Numpad6" => 93,
"NumpadMultiply" => 94,
"Numpad1" => 95,
"Numpad2" => 96,
"Numpad3" => 97,
"NumpadEnter" => 98,
"NumpadSubtract" => 99,
"Numpad0" => 100,
"NumpadDecimal" => 101,
// Windows/Meta keys (keynr 105-106)
"MetaLeft" => 105,
"MetaRight" => 106,
_ => return None,
})
}
@@ -214,10 +215,20 @@ mod tests {
#[test]
fn test_js_code_mapping() {
// From KeyTranslator.java: VK_A(65)→29, VK_ESCAPE(27)→59, etc.
assert_eq!(js_code_to_scancode("KeyA"), Some(29));
assert_eq!(js_code_to_scancode("Escape"), Some(0));
assert_eq!(js_code_to_scancode("KeyR"), Some(18));
assert_eq!(js_code_to_scancode("KeyG"), Some(33));
assert_eq!(js_code_to_scancode("Escape"), Some(59));
assert_eq!(js_code_to_scancode("Backspace"), Some(13));
assert_eq!(js_code_to_scancode("Enter"), Some(27));
assert_eq!(js_code_to_scancode("Backslash"), Some(40));
assert_eq!(js_code_to_scancode("Backquote"), Some(0));
assert_eq!(js_code_to_scancode("F1"), Some(60));
assert_eq!(js_code_to_scancode("ControlLeft"), Some(54));
assert_eq!(js_code_to_scancode("Delete"), Some(78));
assert_eq!(js_code_to_scancode("Numpad7"), Some(86));
assert_eq!(js_code_to_scancode("NumpadMultiply"), Some(94));
assert_eq!(js_code_to_scancode("Unknown"), None);
}
}

View File

@@ -0,0 +1,121 @@
# KVM port configuration and switching
## Context
The Belkin OmniView fronts an Avocent KVM switch with up to 16 ports. The legacy web interface at `http://10.3.0.130` allows port naming, hotkey assignment, visibility toggling, and active port switching. Users currently need to visit that interface separately. We want blekin to fully replace it — starting with port management, with a navigation structure that supports adding more settings pages later.
## Belkin device API (from HTML form inspection)
**GET /kvm.asp** — returns HTML form with current port config:
- `ECG_kvm_nr_ports`: port count (1,2,4,8,12,16,24,32,48,64)
- `ECG_key_pause_duration`: ms (default 100)
- Per port N (0-indexed): `ECG_kvm_portname_N`, `ECG_kvm_hotkey_N`, `ECG_kvm_show_in_rc_N`
- `kvm_active_port_0`: currently selected port
**POST /kvm.asp** — save port config (same fields + `action_apply=Apply`)
**POST /home2.asp** — switch active port: `kvm_active_port_0=<0-15>&action_switch_0=Switch`
All requests require `pp_session_id` cookie.
## Changes
### Backend: persist session cookie
Modify `AppState` in `main.rs` to hold the device session cookie:
```rust
pub session_cookie: Arc<tokio::sync::RwLock<Option<String>>>,
```
Update `login.rs` to store the cookie into shared state after successful auth.
### Backend: new REST endpoints (`crates/ericrfb-proxy/src/kvm.rs`)
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/kvm/ports` | GET | Fetch port config (scrape kvm.asp, return JSON) |
| `/api/kvm/ports` | PUT | Save port config (build form, POST to kvm.asp) |
| `/api/kvm/switch` | POST | Switch active port (POST to home2.asp) |
**GET /api/kvm/ports response:**
```json
{
"port_count": 16,
"key_pause_duration": 100,
"active_port": 0,
"ports": [
{ "index": 0, "name": "oolon", "hotkey": "", "show_in_rc": true },
...
]
}
```
HTML scraping: simple string/regex matching on the predictable firmware HTML (extract `value="..."` from named inputs, `selected` from dropdowns, `checked` from checkboxes).
### Frontend: app shell with navigation (`crates/ericrfb-frontend/src/shell.ts`)
After login, render a shell layout:
```
+----------+----------------------------------+
| sidebar | content area |
| | |
| Console | (active page rendered here) |
| Ports | |
| | |
+----------+----------------------------------+
```
Each page module exports `mount(container)` and `unmount()`. The shell swaps them on nav clicks. This pattern supports adding Virtual Media, Users, Device Settings etc. later without restructuring.
### Frontend: refactor console into page module
Move `console.ts` logic into a `pages/console.ts` with mount/unmount pattern:
- `mount(el, session)`: creates toolbar + canvas, connects WebSocket
- `unmount()`: tears down WebSocket, removes event listeners
Add port switcher `<select>` to the console toolbar (fetches port list from `/api/kvm/ports`, calls `/api/kvm/switch` on change, triggers reconnect).
### Frontend: port config page (`crates/ericrfb-frontend/src/pages/ports.ts`)
Editable table of ports with name, hotkey, show-in-console fields. Port count dropdown and key-pause input at the top. Save button PUTs to `/api/kvm/ports`. Each row has a Switch button that POSTs to `/api/kvm/switch`. Active port highlighted.
## Files
### New
| File | Purpose |
|------|---------|
| `crates/ericrfb-proxy/src/kvm.rs` | GET/PUT ports, POST switch, HTML scraping |
| `crates/ericrfb-frontend/src/shell.ts` | App shell, sidebar nav, page routing |
| `crates/ericrfb-frontend/src/pages/console.ts` | Console page (refactored from console.ts) |
| `crates/ericrfb-frontend/src/pages/ports.ts` | Port configuration page |
### Modified
| File | Changes |
|------|---------|
| `crates/ericrfb-proxy/src/main.rs` | session_cookie in AppState, new routes, `mod kvm` |
| `crates/ericrfb-proxy/src/login.rs` | Store cookie in shared state after auth |
| `crates/ericrfb-proxy/Cargo.toml` | Add `regex = "1"` |
| `crates/ericrfb-frontend/src/main.ts` | After login → mountShell() instead of startConsole() |
| `crates/ericrfb-frontend/src/login.ts` | Success callback → shell mount |
| `crates/ericrfb-frontend/src/style.css` | Shell layout, sidebar, ports page styles |
| `crates/ericrfb-frontend/src/console.ts` | Delete or re-export from pages/console.ts |
## Implementation order
1. Backend: session cookie persistence in AppState + login.rs
2. Backend: kvm.rs with three endpoints, test with curl
3. Frontend: shell.ts with sidebar navigation
4. Frontend: refactor console.ts → pages/console.ts with mount/unmount
5. Frontend: pages/ports.ts wired to API
6. Frontend: port switcher dropdown in console toolbar
## Verification
- `cargo test && cargo clippy` pass
- `npx tsc --noEmit` passes
- `curl /api/kvm/ports` returns correct JSON matching device state
- `curl -X POST /api/kvm/switch -d '{"port":0}'` switches the active port
- `curl -X PUT /api/kvm/ports` with config JSON updates port names on device
- Frontend: navigate between Console and Ports pages without breaking WebSocket
- Frontend: switch port from console toolbar, console reconnects to new port
- Frontend: edit port names in Ports page, save, verify on device