From 69168ff062b475155a6bf7b16e9b83fbc63649ee Mon Sep 17 00:00:00 2001 From: Brandon Tuttle <11356668+tutman96@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:00:50 -0500 Subject: [PATCH 01/14] Fix fullscreen video relative mouse movements (#85) --- ui/package.json | 1 + ui/src/components/ActionBar.tsx | 3 +++ ui/src/components/WebRTCVideo.tsx | 41 +++++++++++++++++++++++++------ ui/vite.config.ts | 24 +++++++++++++++--- 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/ui/package.json b/ui/package.json index 592a300..9a7fae5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,6 +10,7 @@ "dev": "vite dev --mode=development", "build": "npm run build:prod", "build:device": "tsc && vite build --mode=device --emptyOutDir", + "dev:device": "vite dev --mode=device", "build:prod": "tsc && vite build --mode=production", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index cd5432c..13ab896 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -4,6 +4,7 @@ import { useMountMediaStore, useUiStore, useSettingsStore, + useVideoStore, } from "@/hooks/stores"; import { MdOutlineContentPasteGo } from "react-icons/md"; import Container from "@components/Container"; @@ -33,6 +34,7 @@ export default function Actionbar({ state => state.remoteVirtualMediaState, ); const developerMode = useSettingsStore(state => state.developerMode); + const hdmiState = useVideoStore(state => state.hdmiState); // This is the only way to get a reliable state change for the popover // at time of writing this there is no mount, or unmount event for the popover @@ -247,6 +249,7 @@ export default function Actionbar({ size="XS" theme="light" text="Fullscreen" + disabled={hdmiState !== 'ready'} LeadingIcon={LuMaximize} onClick={() => requestFullscreen()} /> diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 7603369..1e5699c 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -30,6 +30,8 @@ export default function WebRTCVideo() { const { setClientSize: setVideoClientSize, setSize: setVideoSize, + width: videoWidth, + height: videoHeight, clientWidth: videoClientWidth, clientHeight: videoClientHeight, } = useVideoStore(); @@ -102,20 +104,43 @@ export default function WebRTCVideo() { const mouseMoveHandler = useCallback( (e: MouseEvent) => { if (!videoClientWidth || !videoClientHeight) return; - const { buttons } = e; + // Get the aspect ratios of the video element and the video stream + const videoElementAspectRatio = videoClientWidth / videoClientHeight; + const videoStreamAspectRatio = videoWidth / videoHeight; - // Clamp mouse position within the video boundaries - const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth); - const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight); + // Calculate the effective video display area + let effectiveWidth = videoClientWidth; + let effectiveHeight = videoClientHeight; + let offsetX = 0; + let offsetY = 0; - // Normalize mouse position to 0-32767 range (HID absolute coordinate system) - const x = Math.round((currMouseX / videoClientWidth) * 32767); - const y = Math.round((currMouseY / videoClientHeight) * 32767); + if (videoElementAspectRatio > videoStreamAspectRatio) { + // Pillarboxing: black bars on the left and right + effectiveWidth = videoClientHeight * videoStreamAspectRatio; + offsetX = (videoClientWidth - effectiveWidth) / 2; + } else if (videoElementAspectRatio < videoStreamAspectRatio) { + // Letterboxing: black bars on the top and bottom + effectiveHeight = videoClientWidth / videoStreamAspectRatio; + offsetY = (videoClientHeight - effectiveHeight) / 2; + } + + // Clamp mouse position within the effective video boundaries + const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth); + const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight); + + // Map clamped mouse position to the video stream's coordinate system + const relativeX = (clampedX - offsetX) / effectiveWidth; + const relativeY = (clampedY - offsetY) / effectiveHeight; + + // Convert to HID absolute coordinate system (0-32767 range) + const x = Math.round(relativeX * 32767); + const y = Math.round(relativeY * 32767); // Send mouse movement + const { buttons } = e; sendMouseMovement(x, y, buttons); }, - [sendMouseMovement, videoClientHeight, videoClientWidth], + [sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight], ); const mouseWheelHandler = useCallback( diff --git a/ui/vite.config.ts b/ui/vite.config.ts index e9c7fe5..b6d26f6 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -2,13 +2,31 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react-swc"; import tsconfigPaths from "vite-tsconfig-paths"; -export default defineConfig(({ mode }) => { +declare const process: { + env: { + JETKVM_PROXY_URL: string; + }; +}; + +export default defineConfig(({ mode, command }) => { const isCloud = mode === "production"; const onDevice = mode === "device"; + const { JETKVM_PROXY_URL } = process.env; + return { plugins: [tsconfigPaths(), react()], build: { outDir: isCloud ? "dist" : "../static" }, - server: { host: "0.0.0.0" }, - base: onDevice ? "/static" : "/", + server: { + host: "0.0.0.0", + proxy: JETKVM_PROXY_URL ? { + '/me': JETKVM_PROXY_URL, + '/device': JETKVM_PROXY_URL, + '/webrtc': JETKVM_PROXY_URL, + '/auth': JETKVM_PROXY_URL, + '/storage': JETKVM_PROXY_URL, + '/cloud': JETKVM_PROXY_URL, + } : undefined + }, + base: onDevice && command === 'build' ? "/static" : "/", }; }); From 63b3ef015188501ffcc3d9f63a5376d07b018c6a Mon Sep 17 00:00:00 2001 From: Andrew Nicholson Date: Wed, 12 Feb 2025 14:08:03 +0000 Subject: [PATCH 02/14] Enable "Boot Interface Subclass" for keyboard and mouse. (#113) This is often required for the keyboard/mouse to be recognized in BIOS/UEFI firmware. --- usb.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/usb.go b/usb.go index 075409a..e302815 100644 --- a/usb.go +++ b/usb.go @@ -132,7 +132,7 @@ func writeGadgetConfig() error { } err = writeGadgetAttrs(hid0Path, [][]string{ {"protocol", "1"}, - {"subclass", "0"}, + {"subclass", "1"}, {"report_length", "8"}, }) if err != nil { @@ -152,7 +152,7 @@ func writeGadgetConfig() error { } err = writeGadgetAttrs(hid1Path, [][]string{ {"protocol", "2"}, - {"subclass", "0"}, + {"subclass", "1"}, {"report_length", "6"}, }) if err != nil { From aa0f38bc0bbcb8c581a91edda8bd4c3772a3293b Mon Sep 17 00:00:00 2001 From: Dominik Heidler Date: Thu, 13 Feb 2025 14:05:07 +0100 Subject: [PATCH 03/14] Add openSUSE ISOs (#151) --- ui/src/components/MountMediaDialog.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ui/src/components/MountMediaDialog.tsx b/ui/src/components/MountMediaDialog.tsx index 4aca608..c3ef59d 100644 --- a/ui/src/components/MountMediaDialog.tsx +++ b/ui/src/components/MountMediaDialog.tsx @@ -26,6 +26,7 @@ import { InputFieldWithLabel } from "./InputField"; import DebianIcon from "@/assets/debian-icon.png"; import UbuntuIcon from "@/assets/ubuntu-icon.png"; import FedoraIcon from "@/assets/fedora-icon.png"; +import OpenSUSEIcon from "@/assets/opensuse-icon.png"; import ArchIcon from "@/assets/arch-icon.png"; import NetBootIcon from "@/assets/netboot-icon.svg"; import { TrashIcon } from "@heroicons/react/16/solid"; @@ -542,6 +543,16 @@ function UrlView({ url: "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-41-1.4.iso", icon: FedoraIcon, }, + { + name: "openSUSE Leap 15.6", + url: "https://download.opensuse.org/distribution/leap/15.6/iso/openSUSE-Leap-15.6-NET-x86_64-Media.iso", + icon: OpenSUSEIcon, + }, + { + name: "openSUSE Tumbleweed", + url: "https://download.opensuse.org/tumbleweed/iso/openSUSE-Tumbleweed-NET-x86_64-Current.iso", + icon: OpenSUSEIcon, + }, { name: "Arch Linux", url: "https://archlinux.doridian.net/iso/2025.02.01/archlinux-2025.02.01-x86_64.iso", From d07bedb323f8cdfaa5ec29a978b17bbd878b3743 Mon Sep 17 00:00:00 2001 From: Scai <59282365+alexevladgabriel@users.noreply.github.com> Date: Thu, 13 Feb 2025 15:33:03 +0200 Subject: [PATCH 04/14] Invert colors on Icons (#123) * feat(ui): invert colors on icons * feat(ui): fix tailwindcss class for invert --- ui/src/components/sidebar/settings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index ec606a6..db43d75 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -466,7 +466,7 @@ export default function SettingsSidebar() {
Finger touching a screen @@ -490,7 +490,7 @@ export default function SettingsSidebar() { >
- Mouse icon + Mouse icon

From 0b5033f798e6179775a02eb50bba837b7f6fa2e8 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Thu, 13 Feb 2025 13:42:07 +0000 Subject: [PATCH 05/14] feat: restore EDID on reboot (#34) This commit adds the config entry "EdidString" and saves the EDID string when it's modified via the RPC. The EDID is restored when the jetkvm_native control socket connects (usually at boot) Signed-off-by: Cameron Fleming --- config.go | 1 + jsonrpc.go | 6 ++++++ native.go | 16 ++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/config.go b/config.go index 1636434..ceacfe5 100644 --- a/config.go +++ b/config.go @@ -22,6 +22,7 @@ type Config struct { LocalAuthToken string `json:"local_auth_token"` LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` + EdidString string `json:"hdmi_edid_string"` } const configPath = "/userdata/kvm_config.json" diff --git a/jsonrpc.go b/jsonrpc.go index 2ce5f18..4f6519b 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -183,6 +183,12 @@ func rpcSetEDID(edid string) error { if err != nil { return err } + + // Save EDID to config, allowing it to be restored on reboot. + LoadConfig() + config.EdidString = edid + SaveConfig() + return nil } diff --git a/native.go b/native.go index d34ab07..1bd8429 100644 --- a/native.go +++ b/native.go @@ -152,6 +152,9 @@ func handleCtrlClient(conn net.Conn) { ctrlSocketConn = conn + // Restore HDMI EDID if applicable + go restoreHdmiEdid() + readBuf := make([]byte, 4096) for { n, err := conn.Read(readBuf) @@ -304,3 +307,16 @@ func ensureBinaryUpdated(destPath string) error { return nil } + +// Restore the HDMI EDID value from the config. +// Called after successful connection to jetkvm_native. +func restoreHdmiEdid() { + LoadConfig() + if config.EdidString != "" { + logger.Infof("Restoring HDMI EDID to %v", config.EdidString) + _, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString}) + if err != nil { + logger.Errorf("Failed to restore HDMI EDID: %v", err) + } + } +} From 2a99c2db9d23640e254e67bdfb9957db3d2634aa Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Thu, 13 Feb 2025 14:41:10 +0000 Subject: [PATCH 06/14] fix(net): stop dhcp client and release all v4 addr on linkdown (#16) This commit fixes jetkvm/kvm#12 by disabling the udhcpc client when the link goes down, it then removes all the active IPv4 addresses from the deivce. Once the link comes back up, it re-activates the udhcpc client so it can fetch a new IPv4 address for the device. This doesn't make any changes to the IPv6 side of things yet. --- network.go | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/network.go b/network.go index f461e45..ee88d05 100644 --- a/network.go +++ b/network.go @@ -6,6 +6,7 @@ import ( "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" "net" + "os/exec" "time" "github.com/vishvananda/netlink" @@ -25,6 +26,23 @@ type LocalIpInfo struct { MAC string } +// setDhcpClientState sends signals to udhcpc to change it's current mode +// of operation. Setting active to true will force udhcpc to renew the DHCP lease. +// Setting active to false will put udhcpc into idle mode. +func setDhcpClientState(active bool) { + var signal string; + if active { + signal = "-SIGUSR1" + } else { + signal = "-SIGUSR2" + } + + cmd := exec.Command("/usr/bin/killall", signal, "udhcpc"); + if err := cmd.Run(); err != nil { + fmt.Printf("network: setDhcpClientState: failed to change udhcpc state: %s\n", err) + } +} + func checkNetworkState() { iface, err := netlink.LinkByName("eth0") if err != nil { @@ -47,9 +65,26 @@ func checkNetworkState() { fmt.Printf("failed to get addresses for eth0: %v\n", err) } + // If the link is going down, put udhcpc into idle mode. + // If the link is coming back up, activate udhcpc and force it to renew the lease. + if newState.Up != networkState.Up { + setDhcpClientState(newState.Up) + } + for _, addr := range addrs { if addr.IP.To4() != nil { - newState.IPv4 = addr.IP.String() + if !newState.Up && networkState.Up { + // If the network is going down, remove all IPv4 addresses from the interface. + fmt.Printf("network: state transitioned to down, removing IPv4 address %s\n", addr.IP.String()) + err := netlink.AddrDel(iface, &addr) + if err != nil { + fmt.Printf("network: failed to delete %s", addr.IP.String()) + } + + newState.IPv4 = "..." + } else { + newState.IPv4 = addr.IP.String() + } } else if addr.IP.To16() != nil && newState.IPv6 == "" { newState.IPv6 = addr.IP.String() } From 951173ba19a59f2deab31cf728c77444a053f1ea Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 13 Feb 2025 12:10:47 -0500 Subject: [PATCH 07/14] Restart mDNS every time the connection information changes (#155) --- network.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/network.go b/network.go index ee88d05..c9ef919 100644 --- a/network.go +++ b/network.go @@ -13,6 +13,8 @@ import ( "github.com/vishvananda/netlink/nl" ) +var mDNSConn *mdns.Conn + var networkState struct { Up bool IPv4 string @@ -91,13 +93,26 @@ func checkNetworkState() { } if newState != networkState { - networkState = newState fmt.Println("network state changed") + //restart MDNS + startMDNS() + networkState = newState requestDisplayUpdate() } } func startMDNS() error { + //If server was previously running, stop it + if mDNSConn != nil { + fmt.Printf("Stopping mDNS server\n") + err := mDNSConn.Close() + if err != nil { + fmt.Printf("failed to stop mDNS server: %v\n", err) + } + } + + //Start a new server + fmt.Printf("Starting mDNS server on jetkvm.local\n") addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4) if err != nil { return err @@ -118,10 +133,11 @@ func startMDNS() error { return err } - _, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{ + mDNSConn, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{ LocalNames: []string{"jetkvm.local"}, //TODO: make it configurable }) if err != nil { + mDNSConn = nil return err } //defer server.Close() @@ -157,7 +173,6 @@ func init() { } } }() - fmt.Println("Starting mDNS server") err := startMDNS() if err != nil { fmt.Println("failed to run mDNS: %v", err) From 5217377175a51a1e6524b3a48be69e48a61d98d0 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Mon, 17 Feb 2025 10:00:28 +0000 Subject: [PATCH 08/14] feat(display.go): Add display brightness settings (#17) * feat(display.go): impl setDisplayBrightness() Implements setDisplayBrightness(brightness int) which allows setting the backlight brightness on JetKVM's hardware. Needs to be implemented into the RPC, config and frontend. * feat(config): add backlight control settings * feat(display): add automatic dimming & switch off to display WIP, dims the display to 50% of the BacklightMaxBrightness after BacklightDimAfterMS expires. Turns the display off after BacklightOffAfterMS * feat(rpc): add methods to get and set BacklightSettings * WIP: feat(settings): add Max backlight setting * chore: use constant for backlight control file * fix: only attempt to wake the display if it's off * feat(display): wake on touch * fix: re-use buffer between reads * fix: wakeDisplay() on start to fix warm start issue If the application had turned off the display before exiting, it wouldn't be brought on when the application restarted without a device reboot. * chore: various comment & string updates * fix: newline on set brightness log Noticed by @eric https://github.com/jetkvm/kvm/pull/17#discussion_r1903338705 * fix: set default value for display Set the DisplayMaxBrightness to the default brightness used out-of-the-box by JetKVM. Also sets the dim/timeout to 2 minutes and 30 mintes respectively. * feat(display.go): use tickers to countdown to dim/off As suggested by tutman in https://github.com/jetkvm/kvm/pull/17, use tickers set to the duration of dim/off to avoid a loop running every second. The tickers are reset to the dim/off times whenever wakeDisplay() is called. * chore: update config Changed Dim & Off values to seconds instead of milliseconds, there's no need for it to be that precise. * feat(display.go): wakeDisplay() force Adds the force boolean to wakedisplay() which allows skipping the backlightState == 0 check, this means you can force a ticker reset, even if the display is currently in the "full bright" state * feat(display.go): move tickers into their own method This allows them to only be started if necessary. If the user has chosen to keep the display on and not-dimmed all the time, the tickers can't start as their tick value must be a positive integer. * feat(display.go): stop tickers when auto-dim/auto-off is disabled * feat(rpc): implement display backlight control methods * feat(ui): implement display backlight control * chore: update variable names As part of @joshuasing's review on #17, updated variables & constants to match the Go best practices. Signed-off-by: Cameron Fleming * fix(display): move backlightTicker setup into screen setup goroutine Signed-off-by: Cameron Fleming * chore: fix some start-up timing issues * fix(display): Don't attempt to start the tickers if the display is disabled If max_brightness is zero, then there's no point in trying to dim it (or turn it off...) * fix: wakeDisplay() doesn't need to stop the tickers The tickers only need to be reset, if they're disabled, they won't have been started. * fix: Don't wake up the display if it's turned off --------- Signed-off-by: Cameron Fleming --- config.go | 30 +++-- display.go | 169 +++++++++++++++++++++++++ jsonrpc.go | 60 ++++++++- ui/src/components/sidebar/settings.tsx | 109 ++++++++++++++++ ui/src/hooks/stores.ts | 16 +++ 5 files changed, 371 insertions(+), 13 deletions(-) diff --git a/config.go b/config.go index ceacfe5..435b87e 100644 --- a/config.go +++ b/config.go @@ -12,24 +12,30 @@ type WakeOnLanDevice struct { } type Config struct { - CloudURL string `json:"cloud_url"` - CloudToken string `json:"cloud_token"` - GoogleIdentity string `json:"google_identity"` - JigglerEnabled bool `json:"jiggler_enabled"` - AutoUpdateEnabled bool `json:"auto_update_enabled"` - IncludePreRelease bool `json:"include_pre_release"` - HashedPassword string `json:"hashed_password"` - LocalAuthToken string `json:"local_auth_token"` - LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration - WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` + CloudURL string `json:"cloud_url"` + CloudToken string `json:"cloud_token"` + GoogleIdentity string `json:"google_identity"` + JigglerEnabled bool `json:"jiggler_enabled"` + AutoUpdateEnabled bool `json:"auto_update_enabled"` + IncludePreRelease bool `json:"include_pre_release"` + HashedPassword string `json:"hashed_password"` + LocalAuthToken string `json:"local_auth_token"` + LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration + WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` EdidString string `json:"hdmi_edid_string"` + DisplayMaxBrightness int `json:"display_max_brightness"` + DisplayDimAfterSec int `json:"display_dim_after_sec"` + DisplayOffAfterSec int `json:"display_off_after_sec"` } const configPath = "/userdata/kvm_config.json" var defaultConfig = &Config{ - CloudURL: "https://api.jetkvm.com", - AutoUpdateEnabled: true, // Set a default value + CloudURL: "https://api.jetkvm.com", + AutoUpdateEnabled: true, // Set a default value + DisplayMaxBrightness: 64, + DisplayDimAfterSec: 120, // 2 minutes + DisplayOffAfterSec: 1800, // 30 minutes } var config *Config diff --git a/display.go b/display.go index f312eb6..416401b 100644 --- a/display.go +++ b/display.go @@ -1,12 +1,26 @@ package kvm import ( + "errors" "fmt" "log" + "os" + "strconv" "time" ) var currentScreen = "ui_Boot_Screen" +var backlightState = 0 // 0 - NORMAL, 1 - DIMMED, 2 - OFF + +var ( + dimTicker *time.Ticker + offTicker *time.Ticker +) + +const ( + touchscreenDevice string = "/dev/input/event1" + backlightControlClass string = "/sys/class/backlight/backlight/brightness" +) func switchToScreen(screen string) { _, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen}) @@ -65,6 +79,7 @@ func requestDisplayUpdate() { return } go func() { + wakeDisplay(false) fmt.Println("display updating........................") //TODO: only run once regardless how many pending updates updateDisplay() @@ -83,6 +98,156 @@ func updateStaticContents() { updateLabelIfChanged("ui_Status_Content_Device_Id_Content_Label", GetDeviceID()) } +// setDisplayBrightness sets /sys/class/backlight/backlight/brightness to alter +// the backlight brightness of the JetKVM hardware's display. +func setDisplayBrightness(brightness int) error { + // NOTE: The actual maximum value for this is 255, but out-of-the-box, the value is set to 64. + // The maximum set here is set to 100 to reduce the risk of drawing too much power (and besides, 255 is very bright!). + if brightness > 100 || brightness < 0 { + return errors.New("brightness value out of bounds, must be between 0 and 100") + } + + // Check the display backlight class is available + if _, err := os.Stat(backlightControlClass); errors.Is(err, os.ErrNotExist) { + return errors.New("brightness value cannot be set, possibly not running on JetKVM hardware") + } + + // Set the value + bs := []byte(strconv.Itoa(brightness)) + err := os.WriteFile(backlightControlClass, bs, 0644) + if err != nil { + return err + } + + fmt.Printf("display: set brightness to %v\n", brightness) + return nil +} + +// tick_displayDim() is called when when dim ticker expires, it simply reduces the brightness +// of the display by half of the max brightness. +func tick_displayDim() { + err := setDisplayBrightness(config.DisplayMaxBrightness / 2) + if err != nil { + fmt.Printf("display: failed to dim display: %s\n", err) + } + + dimTicker.Stop() + + backlightState = 1 +} + +// tick_displayOff() is called when the off ticker expires, it turns off the display +// by setting the brightness to zero. +func tick_displayOff() { + err := setDisplayBrightness(0) + if err != nil { + fmt.Printf("display: failed to turn off display: %s\n", err) + } + + offTicker.Stop() + + backlightState = 2 +} + +// wakeDisplay sets the display brightness back to config.DisplayMaxBrightness and stores the time the display +// last woke, ready for displayTimeoutTick to put the display back in the dim/off states. +// Set force to true to skip the backlight state check, this should be done if altering the tickers. +func wakeDisplay(force bool) { + if backlightState == 0 && !force { + return + } + + // Don't try to wake up if the display is turned off. + if config.DisplayMaxBrightness == 0 { + return + } + + err := setDisplayBrightness(config.DisplayMaxBrightness) + if err != nil { + fmt.Printf("display wake failed, %s\n", err) + } + + if config.DisplayDimAfterSec != 0 { + dimTicker.Reset(time.Duration(config.DisplayDimAfterSec) * time.Second) + } + + if config.DisplayOffAfterSec != 0 { + offTicker.Reset(time.Duration(config.DisplayOffAfterSec) * time.Second) + } + backlightState = 0 +} + +// watchTsEvents monitors the touchscreen for events and simply calls wakeDisplay() to ensure the +// touchscreen interface still works even with LCD dimming/off. +// TODO: This is quite a hack, really we should be getting an event from jetkvm_native, or the whole display backlight +// control should be hoisted up to jetkvm_native. +func watchTsEvents() { + ts, err := os.OpenFile(touchscreenDevice, os.O_RDONLY, 0666) + if err != nil { + fmt.Printf("display: failed to open touchscreen device: %s\n", err) + return + } + + defer ts.Close() + + // This buffer is set to 24 bytes as that's the normal size of events on /dev/input + // Reference: https://www.kernel.org/doc/Documentation/input/input.txt + // This could potentially be set higher, to require multiple events to wake the display. + buf := make([]byte, 24) + for { + _, err := ts.Read(buf) + if err != nil { + fmt.Printf("display: failed to read from touchscreen device: %s\n", err) + return + } + + wakeDisplay(false) + } +} + +// startBacklightTickers starts the two tickers for dimming and switching off the display +// if they're not already set. This is done separately to the init routine as the "never dim" +// option has the value set to zero, but time.NewTicker only accept positive values. +func startBacklightTickers() { + LoadConfig() + // Don't start the tickers if the display is switched off. + // Set the display to off if that's the case. + if config.DisplayMaxBrightness == 0 { + setDisplayBrightness(0) + return + } + + if dimTicker == nil && config.DisplayDimAfterSec != 0 { + fmt.Printf("display: dim_ticker has started\n") + dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second) + defer dimTicker.Stop() + + go func() { + for { + select { + case <-dimTicker.C: + tick_displayDim() + } + } + }() + } + + if offTicker == nil && config.DisplayOffAfterSec != 0 { + fmt.Printf("display: off_ticker has started\n") + offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second) + defer offTicker.Stop() + + go func() { + for { + select { + case <-offTicker.C: + tick_displayOff() + } + } + }() + } +} + func init() { go func() { waitCtrlClientConnected() @@ -91,6 +256,10 @@ func init() { updateStaticContents() displayInited = true fmt.Println("display inited") + startBacklightTickers() + wakeDisplay(true) requestDisplayUpdate() }() + + go watchTsEvents() } diff --git a/jsonrpc.go b/jsonrpc.go index 4f6519b..45ed56e 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -34,6 +34,12 @@ type JSONRPCEvent struct { Params interface{} `json:"params,omitempty"` } +type BacklightSettings struct { + MaxBrightness int `json:"max_brightness"` + DimAfter int `json:"dim_after"` + OffAfter int `json:"off_after"` +} + func writeJSONRPCResponse(response JSONRPCResponse, session *Session) { responseBytes, err := json.Marshal(response) if err != nil { @@ -225,6 +231,56 @@ func rpcTryUpdate() error { return nil } +func rpcSetBacklightSettings(params BacklightSettings) error { + LoadConfig() + + blConfig := params + + // NOTE: by default, the frontend limits the brightness to 64, as that's what the device originally shipped with. + if blConfig.MaxBrightness > 255 || blConfig.MaxBrightness < 0 { + return fmt.Errorf("maxBrightness must be between 0 and 255") + } + + if blConfig.DimAfter < 0 { + return fmt.Errorf("dimAfter must be a positive integer") + } + + if blConfig.OffAfter < 0 { + return fmt.Errorf("offAfter must be a positive integer") + } + + config.DisplayMaxBrightness = blConfig.MaxBrightness + config.DisplayDimAfterSec = blConfig.DimAfter + config.DisplayOffAfterSec = blConfig.OffAfter + + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + log.Printf("rpc: display: settings applied, max_brightness: %d, dim after: %ds, off after: %ds", config.DisplayMaxBrightness, config.DisplayDimAfterSec, config.DisplayOffAfterSec) + + // If the device started up with auto-dim and/or auto-off set to zero, the display init + // method will not have started the tickers. So in case that has changed, attempt to start the tickers now. + startBacklightTickers() + + // Wake the display after the settings are altered, this ensures the tickers + // are reset to the new settings, and will bring the display up to maxBrightness. + // Calling with force set to true, to ignore the current state of the display, and force + // it to reset the tickers. + wakeDisplay(true) + return nil +} + +func rpcGetBacklightSettings() (*BacklightSettings, error) { + LoadConfig() + + return &BacklightSettings{ + MaxBrightness: config.DisplayMaxBrightness, + DimAfter: int(config.DisplayDimAfterSec), + OffAfter: int(config.DisplayOffAfterSec), + }, nil +} + const ( devModeFile = "/userdata/jetkvm/devmode.enable" sshKeyDir = "/userdata/dropbear/.ssh" @@ -385,7 +441,7 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interfac } args[i] = reflect.ValueOf(newStruct).Elem() } else { - return nil, fmt.Errorf("invalid parameter type for: %s", paramName) + return nil, fmt.Errorf("invalid parameter type for: %s, type: %s", paramName, paramType.Kind()) } } else { args[i] = convertedValue.Convert(paramType) @@ -560,4 +616,6 @@ var rpcHandlers = map[string]RPCHandler{ "getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices}, "setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}}, "resetConfig": {Func: rpcResetConfig}, + "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}}, + "getBacklightSettings": {Func: rpcGetBacklightSettings}, } diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index db43d75..28691d8 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -1,5 +1,6 @@ import SidebarHeader from "@components/SidebarHeader"; import { + BacklightSettings, useLocalAuthModalStore, useSettingsStore, useUiStore, @@ -95,6 +96,7 @@ export default function SettingsSidebar() { const hideCursor = useSettingsStore(state => state.isCursorHidden); const setHideCursor = useSettingsStore(state => state.setCursorVisibility); const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode); + const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings); const [currentVersions, setCurrentVersions] = useState<{ appVersion: string; @@ -228,6 +230,28 @@ export default function SettingsSidebar() { [send, setDeveloperMode], ); + const handleBacklightSettingsChange = (settings: BacklightSettings) => { + // If the user has set the display to dim after it turns off, set the dim_after + // value to never. + if (settings.dim_after > settings.off_after && settings.off_after != 0) { + settings.dim_after = 0; + } + + setBacklightSettings(settings); + } + + const handleBacklightSettingsSave = () => { + send("setBacklightSettings", { params: settings.backlightSettings }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to set backlight settings: ${resp.error.data || "Unknown error"}`, + ); + return; + } + notifications.success("Backlight settings updated successfully"); + }); + }; + const handleUpdateSSHKey = useCallback(() => { send("setSSHKeyState", { sshKey }, resp => { if ("error" in resp) { @@ -302,6 +326,17 @@ export default function SettingsSidebar() { } }); + send("getBacklightSettings", {}, resp => { + if ("error" in resp) { + notifications.error( + `Failed to get backlight settings: ${resp.error.data || "Unknown error"}`, + ); + return; + } + const result = resp.result as BacklightSettings; + setBacklightSettings(result); + }) + send("getDevModeState", {}, resp => { if ("error" in resp) return; const result = resp.result as { enabled: boolean }; @@ -797,6 +832,80 @@ export default function SettingsSidebar() { />
+
+ +
+ + { + settings.backlightSettings.max_brightness = parseInt(e.target.value) + handleBacklightSettingsChange(settings.backlightSettings); + }} + /> + + {settings.backlightSettings.max_brightness != 0 && ( + <> + + { + settings.backlightSettings.dim_after = parseInt(e.target.value) + handleBacklightSettingsChange(settings.backlightSettings); + }} + /> + + + { + settings.backlightSettings.off_after = parseInt(e.target.value) + handleBacklightSettingsChange(settings.backlightSettings); + }} + /> + + + )} +

+ The display will wake up when the connection state changes, or when touched. +

+