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() { />
++ The display will wake up when the connection state changes, or when touched. +
+ +