Add support for PiKVM Switch and related features

This commit introduces several new components and improvements:
- Added Switch module with firmware update and configuration support
- Implemented new media streaming capabilities
- Updated various UI elements and CSS styles
- Enhanced keyboard and mouse event handling
- Added new validators and configuration options
- Updated Python version support to 3.13
- Improved error handling and logging
This commit is contained in:
mofeng-git
2025-02-01 01:08:36 +00:00
parent 5db37797ea
commit 7b3335ea94
117 changed files with 5342 additions and 479 deletions

View File

@@ -28,3 +28,7 @@ div#msd-menu div.msd-message,
div#msd-menu input.msd-message {
display: none;
}
div#msd-menu select#msd-image-selector {
width: 100%;
}

View File

@@ -85,7 +85,8 @@ div.stream-box-mouse-none {
}
img#stream-image,
video#stream-video {
video#stream-video,
canvas#stream-canvas {
width: 100%;
height: 100%;
object-fit: contain;

View File

@@ -41,6 +41,13 @@
--led-spin-slow: spin 6s linear infinite;
--led-spin-medium: spin 3s linear infinite;
--led-spin-fast: spin 2s linear infinite;
/* Additional colors for GPIO */
--led-filter-blue: invert(0.5) sepia(1) saturate(5) hue-rotate(170deg);
--led-filter-cyan: invert(0.5) sepia(1) saturate(5) hue-rotate(130deg);
--led-filter-magenta: invert(0.5) sepia(1) saturate(5) hue-rotate(200deg);
--led-filter-pink: invert(0.5) sepia(1) saturate(5) hue-rotate(300deg);
--led-filter-white: invert(1) sepia(1);
}
img.led-gray {
@@ -48,19 +55,16 @@ img.led-gray {
-webkit-filter: var(--led-filter-gray);
filter: var(--led-filter-gray);
}
img.led-green {
-webkit-transform: translateZ(0);
-webkit-filter: var(--led-filter-green);
filter: var(--led-filter-green);
}
img.led-red {
-webkit-transform: translateZ(0);
-webkit-filter: var(--led-filter-red);
filter: var(--led-filter-red);
}
img.led-yellow {
-webkit-transform: translateZ(0);
-webkit-filter: var(--led-filter-yellow);
@@ -73,10 +77,36 @@ img.led-red-rotating-fast {
-webkit-animation: var(--led-spin-fast);
animation: var(--led-spin-fast);
}
img.led-yellow-rotating-fast {
-webkit-filter: var(--led-filter-yellow);
filter: var(--led-filter-yellow);
-webkit-animation: var(--led-spin-fast);
animation: var(--led-spin-fast);
}
/* Additional colors for GPIO */
img.led-blue {
-webkit-transform: translateZ(0);
-webkit-filter: var(--led-filter-blue);
filter: var(--led-filter-blue);
}
img.led-cyan {
-webkit-transform: translateZ(0);
-webkit-filter: var(--led-filter-cyan);
filter: var(--led-filter-cyan);
}
img.led-magenta {
-webkit-transform: translateZ(0);
-webkit-filter: var(--led-filter-magenta);
filter: var(--led-filter-magenta);
}
img.led-pink {
-webkit-transform: translateZ(0);
-webkit-filter: var(--led-filter-pink);
filter: var(--led-filter-pink);
}
img.led-white {
-webkit-transform: translateZ(0);
-webkit-filter: var(--led-filter-white);
filter: var(--led-filter-white);
}

View File

@@ -88,12 +88,17 @@ img.svg-gray {
}
img.inline-lamp {
vertical-align: middle;
height: 1em;
margin-left: 2px;
margin-right: 2px;
}
img.inline-lamp-small {
vertical-align: middle;
height: 8px;
margin-left: 2px;
margin-right: 2px;
}
img.inline-lamp-big {
vertical-align: middle;
height: 20px;
@@ -104,7 +109,8 @@ img.inline-lamp-big {
button,
select,
input[type=file]::-webkit-file-selector-button,
input[type=file]::file-selector-button {
input[type=file]::file-selector-button,
input[type=color] {
border: none;
border-radius: 4px;
color: var(--cs-control-default-fg);
@@ -117,11 +123,9 @@ input[type=file]::file-selector-button {
}
button {
display: block;
width: 100%;
}
select {
display: block;
width: 100%;
padding-left: 5px;
}
select[size] {
@@ -194,6 +198,7 @@ select:not([size]) option.comment {
input[type=text], input[type=password] {
overflow-x: auto;
font-family: monospace;
box-sizing: border-box;
border-radius: 4px;
border: var(--border-default-thin);
color: var(--cs-code-default-fg);
@@ -223,42 +228,35 @@ textarea::-webkit-input-placeholder {
}
div.buttons-row {
display: flex;
margin: 0;
padding: 0;
font-size: 0;
}
.row50 {
display: inline-block;
width: 50%;
}
.row33 {
display: inline-block;
width: 33.33%;
}
.row25 {
display: inline-block;
width: 25%;
}
.row16 {
display: inline-block;
width: 16.66%;
}
.row50:not(:first-child),
.row33:not(:first-child),
.row25:not(:first-child),
.row16:not(:first-child) {
div.buttons-row button:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: var(--border-control-thin) !important;
}
.row50:not(:last-child),
.row33:not(:last-child),
.row25:not(:last-child),
.row16:not(:last-child) {
div.buttons-row button:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
button.row100 {
width: 100% !important;
}
button.row50 {
width: 50% !important;
}
button.row33 {
width: 33.33% !important;
}
button.row25 {
width: 25% !important;
}
button.row16 {
width: 16.66% !important;
}
table.kv {
border-spacing: 5px;

View File

@@ -63,9 +63,11 @@ div.modal div.modal-window div.modal-content {
div.modal div.modal-window div.modal-buttons {
border-top: var(--border-control-thin);
display: flex;
margin: 0;
padding: 0;
font-size: 0;
width: 100%;
}
div.modal div.modal-window div.modal-buttons button {

View File

@@ -172,6 +172,7 @@ ul#navbar li div.menu div.buttons select {
border-radius: 0;
text-align: left;
padding: 0 16px;
width: 100%;
}
ul#navbar li div.menu input[type=text] {

View File

@@ -21,7 +21,7 @@
@supports (-webkit-appearance:none) {
input[type=range].slider {
input[type=range] {
cursor: pointer;
outline: none;
width: 100%;
@@ -33,7 +33,7 @@
}
}
@supports not (-webkit-appearance:none) {
input[type=range].slider {
input[type=range] {
cursor: pointer;
outline: none;
width: 100%;
@@ -42,20 +42,20 @@
margin-right: 0;
}
}
input[type=range].slider:disabled {
input[type=range]:disabled {
cursor: default;
}
input[type=range].slider::-webkit-slider-runnable-track {
input[type=range]::-webkit-slider-runnable-track {
height: 5px;
background: var(--cs-control-default-bg);
border-radius: 3px;
}
input[type=range].slider:disabled::-webkit-slider-runnable-track {
input[type=range]:disabled::-webkit-slider-runnable-track {
cursor: default;
}
input[type=range].slider::-webkit-slider-thumb {
input[type=range]::-webkit-slider-thumb {
border: var(--border-intensive-2px);
height: 18px;
width: 18px;
@@ -64,29 +64,29 @@ input[type=range].slider::-webkit-slider-thumb {
-webkit-appearance: none;
margin-top: -7px;
}
input[type=range].slider:disabled::-webkit-slider-thumb {
input[type=range]:disabled::-webkit-slider-thumb {
cursor: default;
border: var(--border-default-2px);
background: var(--cs-thumb-disabled-bg);
}
input[type=range].slider::-moz-range-track {
input[type=range]::-moz-range-track {
height: 5px;
background: var(--cs-control-default-bg);
border-radius: 3px;
}
input[type=range].slider:disabled::-moz-range-track {
input[type=range]:disabled::-moz-range-track {
cursor: default;
}
input[type=range].slider::-moz-range-thumb {
input[type=range]::-moz-range-thumb {
border: var(--border-intensive-2px);
height: 18px;
width: 18px;
border-radius: 25px;
background: var(--cs-thumb-default-bg);
}
input[type=range].slider:disabled::-moz-range-thumb {
input[type=range]:disabled::-moz-range-thumb {
cursor: default;
border: var(--border-default-2px);
background: var(--cs-thumb-disabled-bg);

View File

@@ -25,7 +25,8 @@
button:enabled:hover,
select:not([size]):enabled:hover,
input[type=file]:enabled:hover::-webkit-file-selector-button,
input[type=file]:enabled:hover::file-selector-button {
input[type=file]:enabled:hover::file-selector-button,
input[type=color]:enabled:hover {
color: var(--cs-control-hovered-fg);
background-color: var(--cs-control-hovered-bg);
}
@@ -33,7 +34,8 @@ input[type=file]:enabled:hover::file-selector-button {
button:active,
select:not([size]):active,
input[type=file]:active::-webkit-file-selector-button,
input[type=file]:active::file-selector-button {
input[type=file]:active::file-selector-button,
input[type=color]:active {
color: var(--cs-control-pressed-fg) !important;
background-color: var(--cs-control-pressed-bg) !important;
}
@@ -60,12 +62,12 @@ div.radio-box input[type=radio]:not(:checked):not(:disabled) + label:hover {
/* ===== slider.css ===== */
/*div.switch-box label span.switch-inner:not(:disabled):hover::before {*/
input[type=range].slider:not(:disabled):hover::-webkit-slider-runnable-track {
input[type=range]:not(:disabled):hover::-webkit-slider-runnable-track {
background-color: var(--cs-control-hovered-bg);
}
/*div.switch-box label span.switch-inner:not(:disabled):hover::before {*/
input[type=range].slider:not(:disabled):hover::-moz-range-track {
input[type=range]:not(:disabled):hover::-moz-range-track {
background-color: var(--cs-control-hovered-bg);
}

View File

@@ -92,7 +92,7 @@ ul#navbar li a.menu-button:hover:not(.active) {
/*@media only screen and (orientation: portrait) {
@supports (-webkit-appearance: none) {
input[type=range].slider {
input[type=range] {
margin: 20px 0 20px 0 !important;
}
}

View File

@@ -32,6 +32,7 @@ export function Atx(__recorder) {
/************************************************************************/
var __has_switch = null; // Or true/false
var __state = null;
var __init__ = function() {
@@ -54,12 +55,12 @@ export function Atx(__recorder) {
}
if (state.enabled !== undefined) {
__state.enabled = state.enabled;
tools.feature.setEnabled($("atx-dropdown"), __state.enabled);
tools.feature.setEnabled($("atx-dropdown"), (__state.enabled && !__has_switch));
}
if (__state.enabled !== undefined) {
if (state.busy !== undefined) {
__updateButtons(!state.busy);
__state.busy = state.busy;
__updateButtons(!__state.busy);
}
if (state.leds !== undefined) {
__state.leds = state.leds;
@@ -75,6 +76,11 @@ export function Atx(__recorder) {
}
};
self.setHasSwitch = function(has_switch) {
__has_switch = has_switch;
self.setState(__state);
};
var __updateLeds = function(power, hdd, busy) {
$("atx-power-led").className = (busy ? "led-yellow" : (power ? "led-green" : "led-gray"));
$("atx-hdd-led").className = (hdd ? "led-red" : "led-gray");
@@ -101,7 +107,7 @@ export function Atx(__recorder) {
if ($("atx-ask-switch").checked) {
wm.confirm(`
Are you sure you want to press the <b>${button}</b> button?<br>
Warning! This could case data loss on the server.
Warning! This could cause data loss on the server.
`).then(function(ok) {
if (ok) {
click_button();

View File

@@ -52,6 +52,7 @@ export function Keyboard(__recordWsEvent) {
window.addEventListener("focusin", __updateOnlineLeds);
window.addEventListener("focusout", __updateOnlineLeds);
tools.storage.bindSimpleSwitch($("hid-keyboard-bad-link-switch"), "hid.keyboard.bad_link", false);
tools.storage.bindSimpleSwitch($("hid-keyboard-swap-cc-switch"), "hid.keyboard.swap_cc", false);
};
@@ -140,11 +141,16 @@ export function Keyboard(__recordWsEvent) {
}
let event = {
"event_type": "key",
"event": {"key": code, "state": state},
"event": {
"key": code,
"state": state,
"finish": $("hid-keyboard-bad-link-switch").checked,
},
};
if (__ws && !$("hid-mute-switch").checked) {
__ws.sendHidEvent(event);
}
delete event.event.finish;
__recordWsEvent(event);
};

View File

@@ -127,7 +127,7 @@ export function Msd() {
tools.hidden.setVisible($("msd-message-offline"), (state && !state.online));
tools.hidden.setVisible($("msd-message-image-broken"), (o && d.image && !d.image.complete && !s.uploading));
tools.hidden.setVisible($("msd-message-too-big-for-cdrom"), (o && d.cdrom && d.image && d.image.size >= 2359296000));
tools.hidden.setVisible($("msd-message-too-big-for-dvd"), (o && d.cdrom && d.image && d.image.size >= 33957083136));
tools.hidden.setVisible($("msd-message-out-of-storage"), (o && d.image && !d.image.in_storage));
tools.hidden.setVisible($("msd-message-rw-enabled"), (o && d.rw));
tools.hidden.setVisible($("msd-message-another-user-uploads"), (o && s.uploading && !__http));

View File

@@ -184,7 +184,7 @@ export function Ocr(__getGeometry) {
"ocr_left": __sel.left,
"ocr_top": __sel.top,
"ocr_right": __sel.right,
"orc_bottom": __sel.bottom,
"ocr_bottom": __sel.bottom,
};
tools.httpGet("/api/streamer/snapshot", params, function(http) {
if (http.status === 200) {

View File

@@ -34,15 +34,10 @@ export function Paste(__recorder) {
var __init__ = function() {
tools.storage.bindSimpleSwitch($("hid-pak-ask-switch"), "hid.pak.ask", true);
tools.storage.bindSimpleSwitch($("hid-pak-slow-switch"), "hid.pak.slow", false);
tools.storage.bindSimpleSwitch($("hid-pak-secure-switch"), "hid.pak.secure", false, function(value) {
$("hid-pak-text").style.setProperty("-webkit-text-security", (value ? "disc" : "none"));
});
tools.feature.setEnabled($("hid-pak-secure"), (
tools.browser.is_chrome
|| tools.browser.is_safari
|| tools.browser.is_opera
));
$("hid-pak-keymap-selector").addEventListener("change", function() {
tools.storage.set("hid.pak.keymap", $("hid-pak-keymap-selector").value);
});
@@ -73,10 +68,11 @@ export function Paste(__recorder) {
tools.el.setEnabled($("hid-pak-keymap-selector"), false);
let keymap = $("hid-pak-keymap-selector").value;
let slow = $("hid-pak-slow-switch").checked;
tools.debug(`HID: paste-as-keys ${keymap}: ${text}`);
tools.httpPost("/api/hid/print", {"limit": 0, "keymap": keymap}, function(http) {
tools.httpPost("/api/hid/print", {"limit": 0, "keymap": keymap, "slow": slow}, function(http) {
tools.el.setEnabled($("hid-pak-text"), true);
tools.el.setEnabled($("hid-pak-button"), true);
tools.el.setEnabled($("hid-pak-keymap-selector"), true);
@@ -86,7 +82,7 @@ export function Paste(__recorder) {
} else if (http.status !== 200) {
wm.error("HID paste error", http.responseText);
} else if (http.status === 200) {
__recorder.recordPrintEvent(text, keymap);
__recorder.recordPrintEvent(text, keymap, slow);
}
}, text, "text/plain");
};

View File

@@ -67,8 +67,8 @@ export function Recorder() {
__recordEvent(event);
};
self.recordPrintEvent = function(text, keymap) {
__recordEvent({"event_type": "print", "event": {"text": text, "keymap": keymap}});
self.recordPrintEvent = function(text, keymap, slow) {
__recordEvent({"event_type": "print", "event": {"text": text, "keymap": keymap, "slow": slow}});
};
self.recordAtxButtonEvent = function(button) {
@@ -159,9 +159,12 @@ export function Recorder() {
} else if (event.event_type === "print") {
__checkType(event.event.text, "string", "Non-string print text");
if (event.event.keymap) {
if (event.event.keymap !== undefined) {
__checkType(event.event.keymap, "string", "Non-string keymap");
}
if (event.event.slow !== undefined) {
__checkType(event.event.slow, "boolean", "Non-bool slow");
}
} else if (event.event_type === "key") {
__checkType(event.event.key, "string", "Non-string key code");
@@ -284,9 +287,12 @@ export function Recorder() {
} else if (event.event_type === "print") {
let params = {"limit": 0};
if (event.event.keymap) {
if (event.event.keymap !== undefined) {
params["keymap"] = event.event.keymap;
}
if (event.event.slow !== undefined) {
params["slow"] = event.event.slow;
}
tools.httpPost("/api/hid/print", params, function(http) {
if (http.status === 413) {
wm.error("Too many text for paste!");
@@ -330,7 +336,11 @@ export function Recorder() {
});
return;
} else if (["key", "mouse_button", "mouse_move", "mouse_wheel", "mouse_relative"].includes(event.event_type)) {
} else if (event.event_type === "key") {
event.event.finish = $("hid-keyboard-bad-link-switch").checked;
__ws.sendHidEvent(event);
} else if (["mouse_button", "mouse_move", "mouse_wheel", "mouse_relative"].includes(event.event_type)) {
__ws.sendHidEvent(event);
} else if (event.event_type === "mouse_move_random") {

View File

@@ -34,6 +34,7 @@ import {Msd} from "./msd.js";
import {Streamer} from "./stream.js";
import {Gpio} from "./gpio.js";
import {Ocr} from "./ocr.js";
import {Switch} from "./switch.js";
export function Session() {
@@ -54,6 +55,7 @@ export function Session() {
var __msd = new Msd();
var __gpio = new Gpio(__recorder);
var __ocr = new Ocr(__streamer.getGeometry);
var __switch = new Switch();
var __info_hw_state = null;
var __info_fan_state = null;
@@ -291,7 +293,7 @@ export function Session() {
tools.httpGet("/api/auth/check", null, function(http) {
if (http.status === 200) {
__ws = new WebSocket(`${tools.is_https ? "wss" : "ws"}://${location.host}/api/ws?legacy=0`);
__ws = new WebSocket(`${tools.is_https ? "wss" : "ws"}://${location.host}/api/ws`);
__ws.sendHidEvent = (event) => __sendHidEvent(__ws, event.event_type, event.event);
__ws.onopen = __wsOpenHandler;
__ws.onmessage = __wsMessageHandler;
@@ -314,6 +316,9 @@ export function Session() {
if (event_type == "key") {
let data = __ascii_encoder.encode("\x01\x00" + event.key);
data[1] = (event.state ? 1 : 0);
if (event.finish === true) { // Optional
data[1] |= 0x02;
}
ws.send(data);
} else if (event_type == "mouse_button") {
@@ -363,14 +368,29 @@ export function Session() {
let data = JSON.parse(event.data);
switch (data.event_type) {
case "pong": __missed_heartbeats = 0; break;
case "info_state": __setInfoState(data.event); break;
case "gpio_state": __gpio.setState(data.event); break;
case "hid_state": __hid.setState(data.event); break;
case "hid_keymaps_state": __paste.setState(data.event); break;
case "atx_state": __atx.setState(data.event); break;
case "msd_state": __msd.setState(data.event); break;
case "streamer_state": __streamer.setState(data.event); break;
case "ocr_state": __ocr.setState(data.event); break;
case "info": __setInfoState(data.event); break;
case "gpio": __gpio.setState(data.event); break;
case "hid": __hid.setState(data.event); break;
case "hid_keymaps": __paste.setState(data.event); break;
case "atx": __atx.setState(data.event); break;
case "streamer": __streamer.setState(data.event); break;
case "ocr": __ocr.setState(data.event); break;
case "msd":
if (data.event.online === false) {
__switch.setMsdConnected(false);
} else if (data.event.drive !== undefined) {
__switch.setMsdConnected(data.event.drive.connected);
}
__msd.setState(data.event);
break;
case "switch":
if (data.event.model) {
__atx.setHasSwitch(data.event.model.ports.length > 0);
}
__switch.setState(data.event);
break;
}
};
@@ -401,6 +421,7 @@ export function Session() {
__streamer.setState(null);
__ocr.setState(null);
__recorder.setSocket(null);
__switch.setState(null);
__ws = null;
setTimeout(function() {

View File

@@ -27,6 +27,7 @@ import {tools, $} from "../tools.js";
import {wm} from "../wm.js";
import {JanusStreamer} from "./stream_janus.js";
import {MediaStreamer} from "./stream_media.js";
import {MjpegStreamer} from "./stream_mjpeg.js";
@@ -93,6 +94,15 @@ export function Streamer() {
__resetStream();
}
}
tools.el.setEnabled($("stream-mic-switch"), !!value);
});
tools.storage.bindSimpleSwitch($("stream-mic-switch"), "stream.mic", false, function(allow_mic) {
if (__streamer.getMode() === "janus") {
if (__streamer.isMicAllowed() !== allow_mic) {
__resetStream();
}
}
});
tools.el.setOnClick($("stream-screenshot-button"), __clickScreenshotButton);
@@ -174,17 +184,20 @@ export function Streamer() {
if (state.features) {
let f = state.features;
let l = state.limits;
let has_webrtc = JanusStreamer.is_webrtc_available();
let has_h264 = JanusStreamer.is_h264_available();
let has_janus = (__janus_imported && f.h264 && has_webrtc); // Don't check has_h264 for sure
let sup_h264 = $("stream-video").canPlayType("video/mp4; codecs=\"avc1.42E01F\"");
let sup_vd = MediaStreamer.is_videodecoder_available();
let sup_webrtc = JanusStreamer.is_webrtc_available();
let has_media = (f.h264 && sup_vd); // Don't check sup_h264 for sure
let has_janus = (__janus_imported && f.h264 && sup_webrtc); // Same
tools.info(
`Stream: Janus WebRTC state: features.h264=${f.h264},`
+ ` webrtc=${has_webrtc}, h264=${has_h264}, janus_imported=${__janus_imported}`
+ ` webrtc=${sup_webrtc}, h264=${sup_h264}, janus_imported=${__janus_imported}`
);
tools.hidden.setVisible($("stream-message-no-webrtc"), __janus_imported && f.h264 && !has_webrtc);
tools.hidden.setVisible($("stream-message-no-h264"), __janus_imported && f.h264 && !has_h264);
tools.hidden.setVisible($("stream-message-no-webrtc"), __janus_imported && f.h264 && !sup_webrtc);
tools.hidden.setVisible($("stream-message-no-vd"), f.h264 && !sup_vd);
tools.hidden.setVisible($("stream-message-no-h264"), __janus_imported && f.h264 && !sup_h264);
tools.slider.setRange($("stream-desired-fps-slider"), l.desired_fps.min, l.desired_fps.max);
if (f.resolution) {
@@ -196,21 +209,28 @@ export function Streamer() {
} else {
$("stream-resolution-selector").options.length = 0;
}
if (has_janus) {
if (f.h264) {
tools.slider.setRange($("stream-h264-bitrate-slider"), l.h264_bitrate.min, l.h264_bitrate.max);
tools.slider.setRange($("stream-h264-gop-slider"), l.h264_gop.min, l.h264_gop.max);
}
// tools.feature.setEnabled($("stream-quality"), f.quality); // Only on s.encoder.quality
tools.feature.setEnabled($("stream-resolution"), f.resolution);
tools.feature.setEnabled($("stream-h264-bitrate"), has_janus);
tools.feature.setEnabled($("stream-h264-gop"), has_janus);
tools.feature.setEnabled($("stream-mode"), has_janus);
if (!has_janus) {
tools.feature.setEnabled($("stream-h264-bitrate"), f.h264);
tools.feature.setEnabled($("stream-h264-gop"), f.h264);
tools.feature.setEnabled($("stream-mode"), f.h264);
if (!f.h264) {
tools.feature.setEnabled($("stream-audio"), false);
tools.feature.setEnabled($("stream-mic"), false);
}
let mode = (has_janus ? tools.storage.get("stream.mode", "janus") : "mjpeg");
let mode = tools.storage.get("stream.mode", "janus");
if (mode === "janus" && !has_janus) {
mode = "media";
}
if (mode === "media" && !has_media) {
mode = "mjpeg";
}
tools.radio.clickValue("stream-mode-radio", mode);
}
@@ -287,14 +307,19 @@ export function Streamer() {
__streamer.stopStream();
if (mode === "janus") {
__streamer = new JanusStreamer(__setActive, __setInactive, __setInfo,
tools.storage.getInt("stream.orient", 0), !$("stream-video").muted);
tools.storage.getInt("stream.orient", 0), !$("stream-video").muted, $("stream-mic-switch").checked);
// Firefox doesn't support RTP orientation:
// - https://bugzilla.mozilla.org/show_bug.cgi?id=1316448
tools.feature.setEnabled($("stream-orient"), !tools.browser.is_firefox);
} else { // mjpeg
__streamer = new MjpegStreamer(__setActive, __setInactive, __setInfo);
} else {
if (mode === "media") {
__streamer = new MediaStreamer(__setActive, __setInactive, __setInfo);
} else { // mjpeg
__streamer = new MjpegStreamer(__setActive, __setInactive, __setInfo);
}
tools.feature.setEnabled($("stream-orient"), false);
tools.feature.setEnabled($("stream-audio"), false); // Enabling in stream_janus.js
tools.feature.setEnabled($("stream-mic"), false); // Ditto
}
if (wm.isWindowVisible($("stream-window"))) {
__streamer.ensureStream((__state && __state.streamer !== undefined) ? __state.streamer : null);
@@ -305,7 +330,8 @@ export function Streamer() {
let mode = tools.radio.getValue("stream-mode-radio");
tools.storage.set("stream.mode", mode);
if (mode !== __streamer.getMode()) {
tools.hidden.setVisible($("stream-image"), (mode !== "janus"));
tools.hidden.setVisible($("stream-canvas"), (mode === "media"));
tools.hidden.setVisible($("stream-image"), (mode === "mjpeg"));
tools.hidden.setVisible($("stream-video"), (mode === "janus"));
__resetStream(mode);
}

View File

@@ -29,9 +29,13 @@ import {tools, $} from "../tools.js";
var _Janus = null;
export function JanusStreamer(__setActive, __setInactive, __setInfo, __orient, __allow_audio) {
export function JanusStreamer(__setActive, __setInactive, __setInfo, __orient, __allow_audio, __allow_mic) {
var self = this;
/************************************************************************/
__allow_mic = (__allow_audio && __allow_mic); // XXX: Mic only with audio
var __stop = false;
var __ensuring = false;
@@ -45,10 +49,22 @@ export function JanusStreamer(__setActive, __setInactive, __setInfo, __orient, _
var __state = null;
var __frames = 0;
/************************************************************************/
self.getOrientation = () => __orient;
self.isAudioAllowed = () => __allow_audio;
self.isMicAllowed = () => __allow_mic;
self.getName = () => (__allow_audio ? "H.264 + Audio" : "H.264");
self.getName = function() {
let name = "WebRTC H.264";
if (__allow_audio) {
name += " + Audio";
if (__allow_mic) {
name += " + Mic";
}
}
return name;
};
self.getMode = () => "janus";
self.getResolution = function() {
@@ -75,9 +91,9 @@ export function JanusStreamer(__setActive, __setInactive, __setInfo, __orient, _
var __ensureJanus = function(internal) {
if (__janus === null && !__stop && (!__ensuring || internal)) {
__ensuring = true;
__setInactive();
__setInfo(false, false, "");
__ensuring = true;
__logInfo("Starting Janus ...");
__janus = new _Janus({
"server": `${tools.is_https ? "wss" : "ws"}://${location.host}/janus/ws`,
@@ -148,6 +164,16 @@ export function JanusStreamer(__setActive, __setInactive, __setInfo, __orient, _
el.srcObject = new MediaStream();
}
el.srcObject.addTrack(track);
// FIXME: Задержка уменьшается, но начинаются заикания на кейфреймах.
// XXX: Этот пример переехал из януса 0.x, перед использованием адаптировать к 1.x.
// - https://github.com/Glimesh/janus-ftl-plugin/issues/101
/*if (__handle && __handle.webrtcStuff && __handle.webrtcStuff.pc) {
for (let receiver of __handle.webrtcStuff.pc.getReceivers()) {
if (receiver.track && receiver.track.kind === "video" && receiver.playoutDelayHint !== undefined) {
receiver.playoutDelayHint = 0;
}
}
}*/
};
var __removeTrack = function(track) {
@@ -215,6 +241,7 @@ export function JanusStreamer(__setActive, __setInactive, __setInfo, __orient, _
__setInfo(false, false, "");
} else if (msg.result.status === "features") {
tools.feature.setEnabled($("stream-audio"), msg.result.features.audio);
tools.feature.setEnabled($("stream-mic"), msg.result.features.mic);
}
} else if (msg.error_code || msg.error) {
__logError("Got uStreamer error message:", msg.error_code, "-", msg.error);
@@ -237,17 +264,13 @@ export function JanusStreamer(__setActive, __setInactive, __setInfo, __orient, _
__logInfo("Handling SDP:", jsep);
let tracks = [{"type": "video", "capture": false, "recv": true, "add": true}];
if (__allow_audio) {
tracks.push({"type": "audio", "capture": false, "recv": true, "add": true});
tracks.push({"type": "audio", "capture": __allow_mic, "recv": true, "add": true});
}
__handle.createAnswer({
"jsep": jsep,
// Janus 1.x
"tracks": tracks,
// Janus 0.x
"media": {"audioSend": false, "videoSend": false, "data": false},
// Chrome is playing OPUS as mono without this hack
// - https://issues.webrtc.org/issues/41481053 - IT'S NOT FIXED!
// - https://github.com/ossrs/srs/pull/2683/files
@@ -288,50 +311,6 @@ export function JanusStreamer(__setActive, __setInactive, __setInfo, __orient, _
}
},
// Janus 0.x
"onremotestream": function(stream) {
if (stream === null) {
// https://github.com/pikvm/pikvm/issues/1084
// Этого вообще не должно происходить, но почему-то янусу в unmute
// может прилететь null-эвент. Костыляем, наблюдаем.
__logError("Got invalid onremotestream(null). Restarting Janus...");
__destroyJanus();
return;
}
let tracks = stream.getTracks();
__logInfo("Got a remote stream changes:", stream, tracks);
let has_video = false;
for (let track of tracks) {
if (track.kind == "video") {
has_video = true;
break;
}
}
if (!has_video && __isOnline()) {
// Chrome sends `muted` notifiation for tracks in `disconnected` ICE state
// and Janus.js just removes muted track from list of available tracks.
// But track still exists actually so it's safe to just ignore that case.
return;
}
_Janus.attachMediaStream($("stream-video"), stream);
__sendKeyRequired();
__startInfoInterval();
// FIXME: Задержка уменьшается, но начинаются заикания на кейфреймах.
// - https://github.com/Glimesh/janus-ftl-plugin/issues/101
/*if (__handle && __handle.webrtcStuff && __handle.webrtcStuff.pc) {
for (let receiver of __handle.webrtcStuff.pc.getReceivers()) {
if (receiver.track && receiver.track.kind === "video" && receiver.playoutDelayHint !== undefined) {
receiver.playoutDelayHint = 0;
}
}
}*/
},
"oncleanup": function() {
__logInfo("Got a cleanup notification");
__stopInfoInterval();
@@ -388,11 +367,12 @@ export function JanusStreamer(__setActive, __setInactive, __setInfo, __orient, _
var __sendWatch = function() {
if (__handle) {
__logInfo(`Sending WATCH(orient=${__orient}, audio=${__allow_audio}) + FEATURES ...`);
__logInfo(`Sending WATCH(orient=${__orient}, audio=${__allow_audio}, mic=${__allow_mic}) + FEATURES ...`);
__handle.send({"message": {"request": "features"}});
__handle.send({"message": {"request": "watch", "params": {
"orientation": __orient,
"audio": __allow_audio,
"mic": __allow_mic,
}}});
}
};
@@ -447,11 +427,3 @@ JanusStreamer.ensure_janus = function(callback) {
JanusStreamer.is_webrtc_available = function() {
return !!window.RTCPeerConnection;
};
JanusStreamer.is_h264_available = function() {
let ok = true;
if ($("stream-video").canPlayType) {
ok = $("stream-video").canPlayType("video/mp4; codecs=\"avc1.42E01F\"");
}
return ok;
};

View File

@@ -0,0 +1,241 @@
/*****************************************************************************
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
*****************************************************************************/
"use strict";
import {tools, $} from "../tools.js";
export function MediaStreamer(__setActive, __setInactive, __setInfo) {
var self = this;
/************************************************************************/
var __stop = false;
var __ensuring = false;
var __ws = null;
var __ping_timer = null;
var __missed_heartbeats = 0;
var __decoder = null;
var __codec = "";
var __canvas = $("stream-canvas");
var __ctx = __canvas.getContext("2d");
var __state = null;
var __frames = 0;
/************************************************************************/
self.getName = () => "HTTP H.264";
self.getMode = () => "media";
self.getResolution = function() {
return {
// Разрешение видео или элемента
"real_width": (__canvas.width || __canvas.offsetWidth),
"real_height": (__canvas.height || __canvas.offsetHeight),
"view_width": __canvas.offsetWidth,
"view_height": __canvas.offsetHeight,
};
};
self.ensureStream = function(state) {
__state = state;
__stop = false;
__ensureMedia(false);
};
self.stopStream = function() {
__stop = true;
__ensuring = false;
__wsForceClose();
__setInfo(false, false, "");
};
var __ensureMedia = function(internal) {
if (__ws === null && !__stop && (!__ensuring || internal)) {
__ensuring = true;
__setInactive();
__setInfo(false, false, "");
__logInfo("Starting Media ...");
__ws = new WebSocket(`${tools.is_https ? "wss" : "ws"}://${location.host}/api/media/ws`);
__ws.binaryType = "arraybuffer";
__ws.onopen = __wsOpenHandler;
__ws.onerror = __wsErrorHandler;
__ws.onclose = __wsCloseHandler;
__ws.onmessage = async (event) => {
if (typeof event.data === "string") {
__wsJsonHandler(JSON.parse(event.data));
} else { // Binary
await __wsBinHandler(event.data);
}
};
}
};
var __wsOpenHandler = function(event) {
__logInfo("Socket opened:", event);
__missed_heartbeats = 0;
__ping_timer = setInterval(__ping, 1000);
};
var __ping = function() {
try {
__missed_heartbeats += 1;
if (__missed_heartbeats >= 5) {
throw new Error("Too many missed heartbeats");
}
__ws.send(new Uint8Array([0]));
if (__decoder && __decoder.state === "configured") {
let online = !!(__state && __state.source.online);
let info = `${__frames} fps dynamic`;
__frames = 0;
__setInfo(true, online, info);
}
} catch (ex) {
__wsErrorHandler(ex.message);
}
};
var __wsForceClose = function() {
if (__ws) {
__ws.onclose = null;
__ws.close();
}
__wsCloseHandler(null);
__setInactive();
};
var __wsErrorHandler = function(event) {
__logInfo("Socket error:", event);
__setInfo(false, false, event);
__wsForceClose();
};
var __wsCloseHandler = function(event) {
__logInfo("Socket closed:", event);
if (__ping_timer) {
clearInterval(__ping_timer);
__ping_timer = null;
}
if (__decoder) {
__decoder.close();
__decoder = null;
}
__missed_heartbeats = 0;
__frames = 0;
__ws = null;
if (!__stop) {
setTimeout(() => __ensureMedia(true), 1000);
}
};
var __wsJsonHandler = function(event) {
if (event.event_type === "media") {
__decoderCreate(event.event.video);
}
};
var __wsBinHandler = async (data) => {
let header = new Uint8Array(data.slice(0, 2));
if (header[0] === 255) { // Pong
__missed_heartbeats = 0;
} else if (header[0] === 1 && __decoder !== null) { // Video frame
let key = !!header[1];
if (__decoder.state !== "configured") {
if (!key) {
return;
}
await __decoder.configure({"codec": __codec, "optimizeForLatency": true});
__setActive();
}
let chunk = new EncodedVideoChunk({ // eslint-disable-line no-undef
"timestamp": (performance.now() + performance.timeOrigin) * 1000,
"type": (key ? "key" : "delta"),
"data": data.slice(2),
});
await __decoder.decode(chunk);
}
};
var __decoderCreate = function(formats) {
__decoderDestroy();
if (formats.h264 === undefined) {
let msg = "No H.264 stream available on PiKVM";
__setInfo(false, false, msg);
__logInfo(msg);
return;
}
if (!window.VideoDecoder) {
let msg = "This browser can't handle direct H.264 stream";
if (!tools.is_https) {
msg = "Direct H.264 requires HTTPS";
}
__setInfo(false, false, msg);
__logInfo(msg);
return;
}
__decoder = new VideoDecoder({ // eslint-disable-line no-undef
"output": (frame) => {
try {
if (__canvas.width !== frame.displayWidth || __canvas.height !== frame.displayHeight) {
__canvas.width = frame.displayWidth;
__canvas.height = frame.displayHeight;
}
__ctx.drawImage(frame, 0, 0);
__frames += 1;
} finally {
frame.close();
}
},
"error": (err) => __logInfo(err.message),
});
__codec = `avc1.${formats.h264.profile_level_id}`;
__ws.send(JSON.stringify({
"event_type": "start",
"event": {"type": "video", "format": "h264"},
}));
};
var __decoderDestroy = function() {
if (__decoder !== null) {
__decoder.close();
__decoder = null;
__codec = "";
}
};
var __logInfo = (...args) => tools.info("Stream [Media]:", ...args);
}
MediaStreamer.is_videodecoder_available = function() {
return !!window.VideoDecoder;
};

View File

@@ -41,7 +41,7 @@ export function MjpegStreamer(__setActive, __setInactive, __setInfo) {
/************************************************************************/
self.getName = () => "MJPEG";
self.getName = () => "HTTP MJPEG";
self.getMode = () => "mjpeg";
self.getResolution = function() {

610
web/share/js/kvm/switch.js Normal file
View File

@@ -0,0 +1,610 @@
/*****************************************************************************
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
*****************************************************************************/
"use strict";
import {tools, $} from "../tools.js";
import {wm} from "../wm.js";
export function Switch() {
var self = this;
/************************************************************************/
var __state = null;
var __msd_connected = false;
var __init__ = function() {
tools.selector.addOption($("switch-edid-selector"), "Default", "default");
$("switch-edid-selector").onchange = __selectEdid;
tools.el.setOnClick($("switch-edid-add-button"), __clickAddEdidButton);
tools.el.setOnClick($("switch-edid-remove-button"), __clickRemoveEdidButton);
tools.el.setOnClick($("switch-edid-copy-data-button"), __clickCopyEdidDataButton);
tools.storage.bindSimpleSwitch($("switch-atx-ask-switch"), "switch.atx.ask", true);
for (let role of ["inactive", "active", "flashing", "beacon", "bootloader"]) {
let el_brightness = $(`switch-color-${role}-brightness-slider`);
tools.slider.setParams(el_brightness, 0, 255, 1, 0);
el_brightness.onchange = $(`switch-color-${role}-input`).onchange = tools.partial(__selectColor, role);
tools.el.setOnClick($(`switch-color-${role}-default-button`), tools.partial(__clickSetDefaultColorButton, role));
}
};
/************************************************************************/
self.setMsdConnected = function(connected) {
__msd_connected = connected;
};
self.setState = function(state) {
if (state) {
if (!__state) {
__state = {};
}
if (state.model) {
__state = {};
__applyModel(state.model);
}
if (__state.model) {
if (state.summary) {
__applySummary(state.summary);
}
if (state.beacons) {
__applyBeacons(state.beacons);
}
if (state.usb) {
__applyUsb(state.usb);
}
if (state.video) {
__applyVideo(state.video);
}
if (state.atx) {
__applyAtx(state.atx);
}
if (state.edids) {
__applyEdids(state.edids);
}
if (state.colors) {
__applyColors(state.colors);
}
}
} else {
tools.feature.setEnabled($("switch-dropdown"), false);
$("switch-chain").innerText = "";
$("switch-active-port").innerText = "N/A";
__setPowerLedState($("switch-atx-power-led"), false, false);
__setLedState($("switch-atx-hdd-led"), "red", false);
__state = null;
}
};
var __applyColors = function(colors) {
for (let role in colors) {
let color = colors[role];
$(`switch-color-${role}-input`).value = (
"#"
+ color.red.toString(16).padStart(2, "0")
+ color.green.toString(16).padStart(2, "0")
+ color.blue.toString(16).padStart(2, "0")
);
$(`switch-color-${role}-brightness-slider`).value = color.brightness;
}
__state.colors = colors;
};
var __selectColor = function(role) {
let el_color = $(`switch-color-${role}-input`);
let el_brightness = $(`switch-color-${role}-brightness-slider`);
let color = __state.colors[role];
let brightness = parseInt(el_brightness.value);
let rgbx = (
el_color.value.slice(1)
+ ":" + brightness.toString(16).padStart(2, "0")
+ ":" + color.blink_ms.toString(16).padStart(4, "0")
);
__sendPost("/api/switch/set_colors", {[role]: rgbx}, function() {
el_color.value = (
"#"
+ color.red.toString(16).padStart(2, "0")
+ color.green.toString(16).padStart(2, "0")
+ color.blue.toString(16).padStart(2, "0")
);
el_brightness.value = color.brightness;
});
};
var __clickSetDefaultColorButton = function(role) {
__sendPost("/api/switch/set_colors", {[role]: "default"});
};
var __applyEdids = function(edids) {
let el = $("switch-edid-selector");
let old_edid_id = el.value;
el.options.length = 1;
for (let kv of Object.entries(edids.all)) {
if (kv[0] !== "default") {
tools.selector.addOption(el, kv[1].name, kv[0]);
}
}
el.value = (old_edid_id in edids.all ? old_edid_id : "default");
for (let port in __state.model.ports) {
let custom = (edids.used[port] !== "default");
$(`__switch-custom-edid-p${port}`).style.visibility = (custom ? "unset" : "hidden");
}
__state.edids = edids;
__selectEdid();
};
var __selectEdid = function() {
let edid_id = $("switch-edid-selector").value;
let edid = null;
try { edid = __state.edids.all[edid_id]; } catch { edid_id = ""; }
let parsed = (edid ? edid.parsed : null);
let na = "<i>&lt;Not Available&gt;</i>";
$("switch-edid-info-mfc-id").innerHTML = (parsed ? tools.escape(parsed.mfc_id) : na);
$("switch-edid-info-product-id").innerHTML = (parsed ? tools.escape(`0x${parsed.product_id.toString(16).toUpperCase()}`) : na);
$("switch-edid-info-serial").innerHTML = (parsed ? tools.escape(`0x${parsed.serial.toString(16).toUpperCase()}`) : na);
$("switch-edid-info-monitor-name").innerHTML = ((parsed && parsed.monitor_name) ? tools.escape(parsed.monitor_name) : na);
$("switch-edid-info-monitor-serial").innerHTML = ((parsed && parsed.monitor_serial) ? tools.escape(parsed.monitor_serial) : na);
$("switch-edid-info-audio").innerHTML = (parsed ? (parsed.audio ? "Yes" : "No") : na);
tools.el.setEnabled($("switch-edid-remove-button"), (edid_id && (edid_id !== "default")));
tools.el.setEnabled($("switch-edid-copy-data-button"), !!edid_id);
};
var __clickAddEdidButton = function() {
let create_content = function(el_parent, el_ok_button) {
tools.el.setEnabled(el_ok_button, false);
el_parent.innerHTML = `
<table>
<tr>
<td>Name:</td>
<td><input
type="text" autocomplete="off" id="__switch-edid-new-name-input"
placeholder="Enter some meaningful name"
style="width:100%"
/></td>
</tr>
<tr><td colspan="2">HEX data:</td></tr>
<tr>
<td colspan="2"><textarea
id="__switch-edid-new-data-text" placeholder="Like 0123ABCD..."
style="min-width:350px"
></textarea><td>
</table>
`;
let el_name = $("__switch-edid-new-name-input");
let el_data = $("__switch-edid-new-data-text");
el_name.oninput = el_data.oninput = function() {
let name = el_name.value.replace(/\s+/g, "");
let data = el_data.value.replace(/\s+/g, "");
tools.el.setEnabled(el_ok_button, ((name.length > 0) && /[0-9a-fA-F]{512}/.test(data)));
};
};
wm.modal("Add new EDID", create_content, true, true).then(function(ok) {
if (ok) {
let name = $("__switch-edid-new-name-input").value;
let data = $("__switch-edid-new-data-text").value;
__sendPost("/api/switch/edids/create", {"name": name, "data": data});
}
});
};
var __clickRemoveEdidButton = function() {
let edid_id = $("switch-edid-selector").value;
if (edid_id && __state && __state.edids) {
let name = __state.edids.all[edid_id].name;
let html = "Are you sure to remove this EDID?<br>Ports that used it will change it to the default.";
wm.confirm(html, name).then(function(ok) {
if (ok) {
__sendPost("/api/switch/edids/remove", {"id": edid_id});
}
});
}
};
var __clickCopyEdidDataButton = function() {
let edid_id = $("switch-edid-selector").value;
if (edid_id && __state && __state.edids) {
let data = __state.edids.all[edid_id].data;
data = data.replace(/(.{32})/g, "$1\n");
wm.copyTextToClipboard(data);
}
};
var __applyUsb = function(usb) {
for (let port = 0; port < __state.model.ports.length; ++port) {
if (!__state.usb || __state.usb.links[port] !== usb.links[port]) {
__setLedState($(`__switch-usb-led-p${port}`), "green", usb.links[port]);
}
}
__state.usb = usb;
};
var __applyVideo = function(video) {
for (let port = 0; port < __state.model.ports.length; ++port) {
if (!__state.video || __state.video.links[port] !== video.links[port]) {
__setLedState($(`__switch-video-led-p${port}`), "green", video.links[port]);
}
}
__state.video = video;
};
var __applyAtx = function(atx) {
for (let port = 0; port < __state.model.ports.length; ++port) {
let busy = atx.busy[port];
if (!__state.atx || __state.atx.leds.power[port] !== atx.leds.power[port] || __state.atx.busy[port] !== busy) {
let power = atx.leds.power[port];
__setPowerLedState($(`__switch-atx-power-led-p${port}`), power, busy);
if (port === __state.summary.active_port) {
// summary есть всегда, если есть model, и atx обновляется последним в setState()
__setPowerLedState($("switch-atx-power-led"), power, busy);
}
}
if (!__state.atx || __state.atx.leds.hdd[port] !== atx.leds.hdd[port]) {
let hdd = atx.leds.hdd[port];
__setLedState($(`__switch-atx-hdd-led-p${port}`), "red", hdd);
if (port === __state.summary.active_port) {
__setLedState($("switch-atx-hdd-led"), "red", hdd);
}
}
if (!__state.atx || __state.atx.busy[port] !== busy) {
tools.el.setEnabled($(`__switch-atx-power-button-p${port}`), !busy);
tools.el.setEnabled($(`__switch-atx-power-long-button-p${port}`), !busy);
tools.el.setEnabled($(`__switch-atx-reset-button-p${port}`), !busy);
}
}
__state.atx = atx;
};
var __applyBeacons = function(beacons) {
for (let unit = 0; unit < __state.model.units.length; ++unit) {
if (!__state.beacons || __state.beacons.uplinks[unit] !== beacons.uplinks[unit]) {
__setLedState($(`__switch-beacon-led-u${unit}`), "green", beacons.uplinks[unit]);
}
if (!__state.beacons || __state.beacons.downlinks[unit] !== beacons.downlinks[unit]) {
__setLedState($(`__switch-beacon-led-d${unit}`), "green", beacons.downlinks[unit]);
}
}
for (let port = 0; port < __state.model.ports.length; ++port) {
if (!__state.beacons || __state.beacons.ports[port] !== beacons.ports[port]) {
__setLedState($(`__switch-beacon-led-p${port}`), "green", beacons.ports[port]);
}
}
__state.beacons = beacons;
};
var __applySummary = function(summary) {
let active = summary.active_port;
if (!__state.summary || __state.summary.active_port !== active) {
if (active < 0 || active >= __state.model.ports.length) {
$("switch-active-port").innerText = "N/A";
} else {
$("switch-active-port").innerText = "p" + __formatPort(__state.model, active);
}
for (let port = 0; port < __state.model.ports.length; ++port) {
__setLedState($(`__switch-port-led-p${port}`), "green", (port === active));
}
}
if (__state.atx) {
// Синхронизация светодиодов ATX при смене порта
let power = false;
let busy = false;
let hdd = false;
if (active >= 0 && active < __state.model.ports.length) {
power = __state.atx.leds.power[active];
hdd = __state.atx.leds.hdd[active];
busy = __state.atx.busy[active];
}
__setPowerLedState($("switch-atx-power-led"), power, busy);
__setLedState($("switch-atx-hdd-led"), "red", hdd);
}
__state.summary = summary;
};
var __applyModel = function(model) {
tools.feature.setEnabled($("switch-dropdown"), model.ports.length);
let content = "";
let unit = -1;
for (let port = 0; port < model.ports.length; ++port) {
let pa = model.ports[port]; // pa == port attrs
if (unit !== pa.unit) {
unit = pa.unit;
content += `${unit > 0 ? "<tr><td colspan=100><hr></td></tr>" : ""}
<tr>
<td></td><td></td><td></td>
<td class="value">Unit: ${unit + 1}</td>
<td></td>
<td colspan=100>
<div class="buttons-row">
<button id="__switch-beacon-button-u${unit}" class="small" title="Toggle uplink Beacon Led">
<img id="__switch-beacon-led-u${unit}" class="inline-lamp led-gray" src="/share/svg/led-beacon.svg"/>
Uplink
</button>
<button id="__switch-beacon-button-d${unit}" class="small" title="Toggle downlink Beacon Led">
<img id="__switch-beacon-led-d${unit}" class="inline-lamp led-gray" src="/share/svg/led-beacon.svg"/>
Downlink
</button>
</div>
</td>
</tr>
<tr><td colspan=100><hr></td></tr>
`;
}
content += `
<tr>
<td>Port:</td>
<td class="value">${__formatPort(model, port)}</td>
<td>&nbsp;&nbsp;</td>
<td>
<div class="buttons-row">
<button id="__switch-port-button-p${port}" title="Activate this port">
<img id="__switch-port-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-circle.svg"/>
</button>
<button id="__switch-params-button-p${port}" title="Configure this port">
<img id="__switch-params-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-gear.svg"/>
</button>
</div>
</td>
<td>
<span
id="__switch-custom-edid-p${port}" style="visibility:hidden"
title="A non-default EDID is used on this port"
>
&#9913;
</span>
&nbsp;&nbsp;&nbsp;&nbsp;
${pa.name.length > 0 ? tools.escape(pa.name) : ("Host " + (port + 1))}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</td>
<td style="font-size:1em">
<button id="__switch-beacon-button-p${port}" class="small" title="Toggle Beacon Led on this port">
<img id="__switch-beacon-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-beacon.svg"/>
</button>
</td>
<td>
<img id="__switch-video-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-video.svg" title="Video Link"/>
<img id="__switch-usb-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-usb.svg" title="USB Link"/>
<img id="__switch-atx-power-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-atx-power.svg" title="Power Led"/>
<img id="__switch-atx-hdd-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-atx-hdd.svg" title="HDD Led"/>
</td>
<td>
<div class="buttons-row">
<button id="__switch-atx-power-button-p${port}" class="small">Power <sup><i>short</i></sup></button>
<button id="__switch-atx-power-long-button-p${port}" class="small"><sup><i>long</i></sup></button>
<button id="__switch-atx-reset-button-p${port}" class="small">Reset</button>
</div>
</td>
</tr>
`;
}
$("switch-chain").innerHTML = content;
if (model.units.length > 0) {
tools.hidden.setVisible($("switch-message-update"), (model.firmware.version > model.units[0].firmware.version));
}
for (let unit = 0; unit < model.units.length; ++unit) {
tools.el.setOnClick($(`__switch-beacon-button-u${unit}`), tools.partial(__switchUplinkBeacon, unit));
tools.el.setOnClick($(`__switch-beacon-button-d${unit}`), tools.partial(__switchDownlinkBeacon, unit));
}
for (let port = 0; port < model.ports.length; ++port) {
tools.el.setOnClick($(`__switch-port-button-p${port}`), tools.partial(__switchActivePort, port));
tools.el.setOnClick($(`__switch-params-button-p${port}`), tools.partial(__showParamsDialog, port));
tools.el.setOnClick($(`__switch-beacon-button-p${port}`), tools.partial(__switchPortBeacon, port));
tools.el.setOnClick($(`__switch-atx-power-button-p${port}`), tools.partial(__atxClick, port, "power"));
tools.el.setOnClick($(`__switch-atx-power-long-button-p${port}`), tools.partial(__atxClick, port, "power_long"));
tools.el.setOnClick($(`__switch-atx-reset-button-p${port}`), tools.partial(__atxClick, port, "reset"));
}
__setPowerLedState($("switch-atx-power-led"), false, false);
__setLedState($("switch-atx-hdd-led"), "red", false);
__state.model = model;
};
var __showParamsDialog = function(port) {
if (!__state || !__state.model || !__state.edids) {
return;
}
let model = __state.model;
let edids = __state.edids;
let atx_actions = {
"power": "ATX power click",
"power_long": "Power long",
"reset": "Reset click",
};
let add_edid_option = function(el, attrs, id) {
tools.selector.addOption(el, attrs.name, id, (edids.used[port] === id));
if (attrs.parsed !== null) {
let parsed = attrs.parsed;
let text = "\xA0\xA0\xA0\xA0\xA0\u2570 ";
text += (parsed.monitor_name !== null ? parsed.monitor_name : parsed.mfc_id);
text += (parsed.audio ? "; +Audio" : "; -Audio");
tools.selector.addComment(el, text);
}
};
let create_content = function(el_parent) {
let html = `
<table>
<tr>
<td>Port name:</td>
<td><input
type="text" autocomplete="off" id="__switch-port-name-input"
value="${tools.escape(model.ports[port].name)}" placeholder="Host ${port + 1}"
style="width:100%"
/></td>
</tr>
<tr>
<td>EDID:</td>
<td><select id="__switch-port-edid-selector" style="width: 100%"></select></td>
</tr>
</table>
<hr>
<table>
`;
for (let kv of Object.entries(atx_actions)) {
html += `
<tr>
<td style="white-space: nowrap">${tools.escape(kv[1])}:</td>
<td style="width: 100%"><input type="range" id="__switch-port-atx-click-${kv[0]}-delay-slider"/></td>
<td id="__switch-port-atx-click-${kv[0]}-delay-value"></td>
<td>&nbsp;&nbsp;&nbsp;</td>
<td><button
id="__switch-port-atx-click-${kv[0]}-delay-default-button"
class="small" title="Reset default"
>&#8635;</button></td>
</tr>
`;
}
html += "</table>";
el_parent.innerHTML = html;
let el_selector = $("__switch-port-edid-selector");
add_edid_option(el_selector, edids.all["default"], "default");
for (let kv of Object.entries(edids.all)) {
if (kv[0] !== "default") {
tools.selector.addSeparator(el_selector, 20);
add_edid_option(el_selector, kv[1], kv[0]);
}
}
for (let action of Object.keys(atx_actions)) {
let limits = model.limits.atx.click_delays[action];
let el_slider = $(`__switch-port-atx-click-${action}-delay-slider`);
let display_value = tools.partial(function(action, value) {
$(`__switch-port-atx-click-${action}-delay-value`).innerText = `${value.toFixed(1)}`;
}, action);
let reset_default = tools.partial(function(el_slider, limits) {
tools.slider.setValue(el_slider, limits["default"]);
}, el_slider, limits);
tools.slider.setParams(el_slider, limits.min, limits.max, 0.5, model.ports[port].atx.click_delays[action], display_value);
tools.el.setOnClick($(`__switch-port-atx-click-${action}-delay-default-button`), reset_default);
}
};
wm.modal(`Port ${__formatPort(__state.model, port)} settings`, create_content, true, true).then(function(ok) {
if (ok) {
let params = {
"port": port,
"edid_id": $("__switch-port-edid-selector").value,
"name": $("__switch-port-name-input").value,
};
for (let action of Object.keys(atx_actions)) {
params[`atx_click_${action}_delay`] = tools.slider.getValue($(`__switch-port-atx-click-${action}-delay-slider`));
};
__sendPost("/api/switch/set_port_params", params);
}
});
};
var __formatPort = function(model, port) {
if (model.units.length > 1) {
return `${model.ports[port].unit + 1}.${model.ports[port].channel + 1}`;
} else {
return `${port + 1}`;
}
};
var __setLedState = function(el, color, on) {
el.classList.toggle(`led-${color}`, on);
el.classList.toggle("led-gray", !on);
};
var __setPowerLedState = function(el, power, busy) {
el.classList.toggle("led-green", (power && !busy));
el.classList.toggle("led-yellow", busy);
el.classList.toggle("led-gray", !(power || busy));
};
var __switchActivePort = function(port) {
if (__msd_connected) {
wm.error(`
Oops! Before port switching, please disconnect an active Mass Storage Drive image first.
Otherwise, it will break a current USB operation (OS installation, Live CD, or whatever).
`);
} else {
__sendPost("/api/switch/set_active", {"port": port});
}
};
var __switchUplinkBeacon = function(unit) {
let state = false;
try { state = !__state.beacons.uplinks[unit]; } catch {}; // eslint-disable-line no-empty
__sendPost("/api/switch/set_beacon", {"uplink": unit, "state": state});
};
var __switchDownlinkBeacon = function(unit) {
let state = false;
try { state = !__state.beacons.downlinks[unit]; } catch {}; // eslint-disable-line no-empty
__sendPost("/api/switch/set_beacon", {"downlink": unit, "state": state});
};
var __switchPortBeacon = function(port) {
let state = false;
try { state = !__state.beacons.ports[port]; } catch {}; // eslint-disable-line no-empty
__sendPost("/api/switch/set_beacon", {"port": port, "state": state});
};
var __atxClick = function(port, button) {
let click_button = function() {
__sendPost("/api/switch/atx/click", {"port": port, "button": button});
};
if ($("switch-atx-ask-switch").checked) {
wm.confirm(`
Are you sure you want to press the <b>${button}</b> button?<br>
Warning! This could cause data loss on the server.
`).then(function(ok) {
if (ok) {
click_button();
}
});
} else {
click_button();
}
};
var __sendPost = function(url, params, error_callback=null) {
tools.httpPost(url, params, function(http) {
if (http.status !== 200) {
if (error_callback) {
error_callback();
}
wm.error("Switch error", http.responseText);
}
});
};
__init__();
}

View File

@@ -78,7 +78,7 @@ export var tools = new function() {
};
self.partial = function(func, ...args) {
return () => func(...args);
return (...rest) => func(...args, ...rest);
};
self.upperFirst = function(text) {
@@ -104,10 +104,6 @@ export var tools = new function() {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
self.formatHex = function(value) {
return `0x${value.toString(16).toUpperCase()}`;
};
self.formatSize = function(size) {
if (size > 0) {
let index = Math.floor( Math.log(size) / Math.log(1024) );

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.51472 0.514648C1.34424 2.68513 0 5.6865 0 8.99993C0 12.3134 1.34424 15.3147 3.51472 17.4852L4.92893 16.071C3.11819 14.2603 2 11.7616 2 8.99993C2 6.23823 3.11819 3.7396 4.92893 1.92886L3.51472 0.514648ZM6.34315 3.34308C4.89653 4.7897 4 6.79107 4 8.99993C4 11.2088 4.89653 13.2102 6.34315 14.6568L7.75736 13.2426C6.67048 12.1557 6 10.6571 6 8.99993C6 7.3428 6.67048 5.84417 7.75736 4.75729L6.34315 3.34308ZM12 4.99995C9.79086 4.99995 8 6.79081 8 8.99995C8 10.8638 9.27477 12.4299 11 12.8739V23H13V12.8739C14.7252 12.4299 16 10.8638 16 8.99995C16 6.79081 14.2091 4.99995 12 4.99995ZM10 8.99995C10 7.89538 10.8954 6.99995 12 6.99995C13.1046 6.99995 14 7.89538 14 8.99995C14 10.1045 13.1046 11 12 11C10.8954 11 10 10.1045 10 8.99995ZM17.6568 3.34308C19.1034 4.7897 20 6.79107 20 8.99993C20 11.2088 19.1034 13.2102 17.6568 14.6568L16.2426 13.2426C17.3295 12.1557 18 10.6571 18 8.99993C18 7.3428 17.3295 5.84417 16.2426 4.75729L17.6568 3.34308ZM20.4852 0.514648C22.6557 2.68513 23.9999 5.6865 23.9999 8.99993C23.9999 12.3134 22.6557 15.3147 20.4852 17.4852L19.071 16.071C20.8817 14.2603 21.9999 11.7616 21.9999 8.99993C21.9999 6.23823 20.8817 3.7396 19.071 1.92886L20.4852 0.514648Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

22
web/share/svg/led-usb.svg Normal file
View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<g>
<g>
<g>
<rect x="191.996" y="68.27" width="17.067" height="17.067"/>
<rect x="123.729" y="68.27" width="17.067" height="17.067"/>
<path d="M448,0h-34.133c-4.719,0-8.533,3.814-8.533,8.533V409.6c0,28.237-22.972,51.2-51.2,51.2H243.2
c-28.237,0-51.2-22.963-51.2-51.2v-17.067c9.412,0,17.067-7.654,17.067-17.067v-34.133h51.2c9.412,0,17.067-7.654,17.067-17.067
V153.6c0-9.412-7.654-17.067-17.067-17.067v-128c0-4.719-3.823-8.533-8.533-8.533H81.067c-4.719,0-8.533,3.814-8.533,8.533v128
c-9.421,0-17.067,7.654-17.067,17.067v170.667c0,9.412,7.646,17.067,17.067,17.067h51.2v34.133
c0,9.412,7.646,17.067,17.067,17.067V409.6c0,56.465,45.935,102.4,102.4,102.4h110.933c56.457,0,102.4-45.935,102.4-102.4V8.533
C456.533,3.814,452.71,0,448,0z M174.933,59.733c0-4.719,3.814-8.533,8.533-8.533H217.6c4.71,0,8.533,3.814,8.533,8.533v34.133
c0,4.719-3.823,8.533-8.533,8.533h-34.133c-4.719,0-8.533-3.814-8.533-8.533V59.733z M115.2,102.4
c-4.719,0-8.533-3.814-8.533-8.533V59.733c0-4.719,3.814-8.533,8.533-8.533h34.133c4.71,0,8.533,3.814,8.533,8.533v34.133
c0,4.719-3.823,8.533-8.533,8.533H115.2z M149.333,375.467H140.8v-34.133H192l0.009,34.133h-8.542H149.333z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB