From 5217377175a51a1e6524b3a48be69e48a61d98d0 Mon Sep 17 00:00:00 2001
From: Cameron Fleming <cameron@nevexo.space>
Date: Mon, 17 Feb 2025 10:00:28 +0000
Subject: [PATCH] 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 <cameron@nevexo.space>

* fix(display): move backlightTicker setup into screen setup goroutine

Signed-off-by: Cameron Fleming <cameron@nevexo.space>

* 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 <cameron@nevexo.space>
---
 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() {
           />
         </SettingsItem>
         <div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
+        <div className="pb-2 space-y-4">
+          <SectionHeader
+            title="Hardware"
+            description="Configure the JetKVM Hardware"
+          />
+        </div>
+        <SettingsItem title="Display Brightness" description="Set the brightness of the display">
+          <SelectMenuBasic
+            size="SM"
+            label=""
+            value={settings.backlightSettings.max_brightness.toString()}
+            options={[
+              { value: "0", label: "Off" },
+              { value: "10", label: "Low" },
+              { value: "35", label: "Medium" },
+              { value: "64", label: "High" },
+            ]}
+            onChange={e => {
+              settings.backlightSettings.max_brightness = parseInt(e.target.value)
+              handleBacklightSettingsChange(settings.backlightSettings);
+            }}
+          />
+        </SettingsItem>
+        {settings.backlightSettings.max_brightness != 0 && (
+          <>
+          <SettingsItem title="Dim Display After" description="Set how long to wait before dimming the display">
+            <SelectMenuBasic
+              size="SM"
+              label=""
+              value={settings.backlightSettings.dim_after.toString()}
+              options={[
+                { value: "0", label: "Never" },
+                { value: "60", label: "1 Minute" },
+                { value: "300", label: "5 Minutes" },
+                { value: "600", label: "10 Minutes" },
+                { value: "1800", label: "30 Minutes" },
+                { value: "3600", label: "1 Hour" },
+              ]}
+              onChange={e => {
+                settings.backlightSettings.dim_after = parseInt(e.target.value)
+                handleBacklightSettingsChange(settings.backlightSettings);
+              }}
+            />
+          </SettingsItem>
+          <SettingsItem title="Turn off Display After" description="Set how long to wait before turning off the display">
+            <SelectMenuBasic
+              size="SM"
+              label=""
+              value={settings.backlightSettings.off_after.toString()}
+              options={[
+                { value: "0", label: "Never" },
+                { value: "300", label: "5 Minutes" },
+                { value: "600", label: "10 Minutes" },
+                { value: "1800", label: "30 Minutes" },
+                { value: "3600", label: "1 Hour" },
+              ]}
+              onChange={e => {
+                settings.backlightSettings.off_after = parseInt(e.target.value)
+                handleBacklightSettingsChange(settings.backlightSettings);
+              }}
+            />
+          </SettingsItem>
+          </>
+        )}
+        <p className="text-xs text-slate-600 dark:text-slate-400">
+          The display will wake up when the connection state changes, or when touched.
+        </p>
+        <Button
+          size="SM"
+          theme="primary"
+          text="Save Display Settings"
+          onClick={handleBacklightSettingsSave}
+        />
+        <div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
         <div className="pb-2 space-y-4">
           <SectionHeader
             title="Advanced"
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts
index b4cfbec..7a0268b 100644
--- a/ui/src/hooks/stores.ts
+++ b/ui/src/hooks/stores.ts
@@ -229,6 +229,12 @@ export interface VideoState {
   }) => void;
 }
 
+export interface BacklightSettings {
+  max_brightness: number;
+  dim_after: number;
+  off_after: number;
+}
+
 export const useVideoStore = create<VideoState>(set => ({
   width: 0,
   height: 0,
@@ -270,6 +276,9 @@ interface SettingsState {
   // Add new developer mode state
   developerMode: boolean;
   setDeveloperMode: (enabled: boolean) => void;
+
+  backlightSettings: BacklightSettings;
+  setBacklightSettings: (settings: BacklightSettings) => void;
 }
 
 export const useSettingsStore = create(
@@ -287,6 +296,13 @@ export const useSettingsStore = create(
       // Add developer mode with default value
       developerMode: false,
       setDeveloperMode: enabled => set({ developerMode: enabled }),
+
+      backlightSettings: {
+        max_brightness: 100,
+        dim_after: 10000,
+        off_after: 50000,
+      },
+      setBacklightSettings: (settings: BacklightSettings) => set({ backlightSettings: settings }),
     }),
     {
       name: "settings",