diff --git a/Makefile b/Makefile index eea9730..f9e4426 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -VERSION_DEV := 0.3.6-dev$(shell date +%Y%m%d%H%M) -VERSION := 0.3.5 +VERSION_DEV := 0.3.7-dev$(shell date +%Y%m%d%H%M) +VERSION := 0.3.6 hash_resource: @shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256 diff --git a/config.go b/config.go index 1636434..3818b7b 100644 --- a/config.go +++ b/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "sync" ) type WakeOnLanDevice struct { @@ -12,44 +13,56 @@ 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"` + ActiveExtension string `json:"active_extension"` + 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 + ActiveExtension: "", + DisplayMaxBrightness: 64, + DisplayDimAfterSec: 120, // 2 minutes + DisplayOffAfterSec: 1800, // 30 minutes } -var config *Config +var ( + config *Config + configLock = &sync.Mutex{} +) func LoadConfig() { if config != nil { + logger.Info("config already loaded, skipping") return } file, err := os.Open(configPath) if err != nil { logger.Debug("default config file doesn't exist, using default") - config = defaultConfig return } defer file.Close() - var loadedConfig Config + // load and merge the default config with the user config + loadedConfig := *defaultConfig if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { logger.Errorf("config file JSON parsing failed, %v", err) - config = defaultConfig return } @@ -57,6 +70,9 @@ func LoadConfig() { } func SaveConfig() error { + configLock.Lock() + defer configLock.Unlock() + file, err := os.Create(configPath) if err != nil { return fmt.Errorf("failed to create config file: %w", err) diff --git a/display.go b/display.go index f312eb6..9d22c26 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,155 @@ 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() { + // 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 +255,10 @@ func init() { updateStaticContents() displayInited = true fmt.Println("display inited") + startBacklightTickers() + wakeDisplay(true) requestDisplayUpdate() }() + + go watchTsEvents() } diff --git a/go.mod b/go.mod index 5ddcfb6..adc054a 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf github.com/hanwen/go-fuse/v2 v2.5.1 + github.com/hashicorp/go-envparse v0.1.0 github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965 github.com/pion/logging v0.2.2 github.com/pion/mdns/v2 v2.0.7 @@ -21,6 +22,7 @@ require ( github.com/pojntfx/go-nbd v0.3.2 github.com/psanford/httpreadat v0.1.0 github.com/vishvananda/netlink v1.3.0 + go.bug.st/serial v1.6.2 golang.org/x/crypto v0.28.0 golang.org/x/net v0.30.0 ) @@ -32,6 +34,7 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/creack/goselect v0.1.2 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-jose/go-jose/v4 v4.0.2 // indirect @@ -68,7 +71,7 @@ require ( github.com/wlynxg/anet v0.0.5 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sys v0.26.0 // indirect + golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.19.0 // indirect google.golang.org/protobuf v1.34.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum index be21917..b7b8756 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= +github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -49,6 +51,8 @@ github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g= github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ= github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs= +github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY= +github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -144,6 +148,8 @@ github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1Y github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= +go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= @@ -159,8 +165,8 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= diff --git a/jsonrpc.go b/jsonrpc.go index 2ce5f18..619e561 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -10,8 +10,11 @@ import ( "os/exec" "path/filepath" "reflect" + "strconv" + "time" "github.com/pion/webrtc/v4" + "go.bug.st/serial" ) type JSONRPCRequest struct { @@ -34,6 +37,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 { @@ -183,6 +192,11 @@ func rpcSetEDID(edid string) error { if err != nil { return err } + + // Save EDID to config, allowing it to be restored on reboot. + config.EdidString = edid + SaveConfig() + return nil } @@ -219,6 +233,52 @@ func rpcTryUpdate() error { return nil } +func rpcSetBacklightSettings(params BacklightSettings) error { + 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) { + return &BacklightSettings{ + MaxBrightness: config.DisplayMaxBrightness, + DimAfter: int(config.DisplayDimAfterSec), + OffAfter: int(config.DisplayOffAfterSec), + }, nil +} + const ( devModeFile = "/userdata/jetkvm/devmode.enable" sshKeyDir = "/userdata/dropbear/.ssh" @@ -379,7 +439,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) @@ -479,7 +539,6 @@ func rpcSetUsbEmulationState(enabled bool) error { } func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) { - LoadConfig() if config.WakeOnLanDevices == nil { return []WakeOnLanDevice{}, nil } @@ -491,13 +550,11 @@ type SetWakeOnLanDevicesParams struct { } func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error { - LoadConfig() config.WakeOnLanDevices = params.Devices return SaveConfig() } func rpcResetConfig() error { - LoadConfig() config = defaultConfig if err := SaveConfig(); err != nil { return fmt.Errorf("failed to reset config: %w", err) @@ -507,7 +564,172 @@ func rpcResetConfig() error { return nil } -// TODO: replace this crap with code generator +type DCPowerState struct { + IsOn bool `json:"isOn"` + Voltage float64 `json:"voltage"` + Current float64 `json:"current"` + Power float64 `json:"power"` +} + +func rpcGetDCPowerState() (DCPowerState, error) { + return dcState, nil +} + +func rpcSetDCPowerState(enabled bool) error { + log.Printf("[jsonrpc.go:rpcSetDCPowerState] Setting DC power state to: %v", enabled) + err := setDCPowerState(enabled) + if err != nil { + return fmt.Errorf("failed to set DC power state: %w", err) + } + return nil +} + +func rpcGetActiveExtension() (string, error) { + return config.ActiveExtension, nil +} + +func rpcSetActiveExtension(extensionId string) error { + if config.ActiveExtension == extensionId { + return nil + } + if config.ActiveExtension == "atx-power" { + unmountATXControl() + } else if config.ActiveExtension == "dc-power" { + unmountDCControl() + } + config.ActiveExtension = extensionId + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + if extensionId == "atx-power" { + mountATXControl() + } else if extensionId == "dc-power" { + mountDCControl() + } + return nil +} + +func rpcSetATXPowerAction(action string) error { + logger.Debugf("[jsonrpc.go:rpcSetATXPowerAction] Executing ATX power action: %s", action) + switch action { + case "power-short": + logger.Debug("[jsonrpc.go:rpcSetATXPowerAction] Simulating short power button press") + return pressATXPowerButton(200 * time.Millisecond) + case "power-long": + logger.Debug("[jsonrpc.go:rpcSetATXPowerAction] Simulating long power button press") + return pressATXPowerButton(5 * time.Second) + case "reset": + logger.Debug("[jsonrpc.go:rpcSetATXPowerAction] Simulating reset button press") + return pressATXResetButton(200 * time.Millisecond) + default: + return fmt.Errorf("invalid action: %s", action) + } +} + +type ATXState struct { + Power bool `json:"power"` + HDD bool `json:"hdd"` +} + +func rpcGetATXState() (ATXState, error) { + state := ATXState{ + Power: ledPWRState, + HDD: ledHDDState, + } + return state, nil +} + +type SerialSettings struct { + BaudRate string `json:"baudRate"` + DataBits string `json:"dataBits"` + StopBits string `json:"stopBits"` + Parity string `json:"parity"` +} + +func rpcGetSerialSettings() (SerialSettings, error) { + settings := SerialSettings{ + BaudRate: strconv.Itoa(serialPortMode.BaudRate), + DataBits: strconv.Itoa(serialPortMode.DataBits), + StopBits: "1", + Parity: "none", + } + + switch serialPortMode.StopBits { + case serial.OneStopBit: + settings.StopBits = "1" + case serial.OnePointFiveStopBits: + settings.StopBits = "1.5" + case serial.TwoStopBits: + settings.StopBits = "2" + } + + switch serialPortMode.Parity { + case serial.NoParity: + settings.Parity = "none" + case serial.OddParity: + settings.Parity = "odd" + case serial.EvenParity: + settings.Parity = "even" + case serial.MarkParity: + settings.Parity = "mark" + case serial.SpaceParity: + settings.Parity = "space" + } + + return settings, nil +} + +var serialPortMode = defaultMode + +func rpcSetSerialSettings(settings SerialSettings) error { + baudRate, err := strconv.Atoi(settings.BaudRate) + if err != nil { + return fmt.Errorf("invalid baud rate: %v", err) + } + dataBits, err := strconv.Atoi(settings.DataBits) + if err != nil { + return fmt.Errorf("invalid data bits: %v", err) + } + + var stopBits serial.StopBits + switch settings.StopBits { + case "1": + stopBits = serial.OneStopBit + case "1.5": + stopBits = serial.OnePointFiveStopBits + case "2": + stopBits = serial.TwoStopBits + default: + return fmt.Errorf("invalid stop bits: %s", settings.StopBits) + } + + var parity serial.Parity + switch settings.Parity { + case "none": + parity = serial.NoParity + case "odd": + parity = serial.OddParity + case "even": + parity = serial.EvenParity + case "mark": + parity = serial.MarkParity + case "space": + parity = serial.SpaceParity + default: + return fmt.Errorf("invalid parity: %s", settings.Parity) + } + serialPortMode = &serial.Mode{ + BaudRate: baudRate, + DataBits: dataBits, + StopBits: stopBits, + Parity: parity, + } + + port.SetMode(serialPortMode) + + return nil +} + var rpcHandlers = map[string]RPCHandler{ "ping": {Func: rpcPing}, "getDeviceID": {Func: rpcGetDeviceID}, @@ -554,4 +776,14 @@ 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}, + "getDCPowerState": {Func: rpcGetDCPowerState}, + "setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}}, + "getActiveExtension": {Func: rpcGetActiveExtension}, + "setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}}, + "getATXState": {Func: rpcGetATXState}, + "setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}}, + "getSerialSettings": {Func: rpcGetSerialSettings}, + "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, } diff --git a/main.go b/main.go index 7ff771f..e23e9c8 100644 --- a/main.go +++ b/main.go @@ -71,6 +71,7 @@ func Main() { if config.CloudToken != "" { go RunWebsocketClient() } + initSerialPort() sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) <-sigs diff --git a/native.go b/native.go index d34ab07..7940f64 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,15 @@ func ensureBinaryUpdated(destPath string) error { return nil } + +// Restore the HDMI EDID value from the config. +// Called after successful connection to jetkvm_native. +func restoreHdmiEdid() { + 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) + } + } +} diff --git a/network.go b/network.go index f461e45..120f9f6 100644 --- a/network.go +++ b/network.go @@ -1,22 +1,35 @@ package kvm import ( + "bytes" "fmt" + "net" + "os" + "strings" + "time" + + "os/exec" + + "github.com/hashicorp/go-envparse" "github.com/pion/mdns/v2" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" - "net" - "time" "github.com/vishvananda/netlink" "github.com/vishvananda/netlink/nl" ) -var networkState struct { +var mDNSConn *mdns.Conn + +var networkState NetworkState + +type NetworkState struct { Up bool IPv4 string IPv6 string MAC string + + checked bool } type LocalIpInfo struct { @@ -25,44 +38,93 @@ type LocalIpInfo struct { MAC string } +const ( + NetIfName = "eth0" + DHCPLeaseFile = "/run/udhcpc.%s.info" +) + +// 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") + iface, err := netlink.LinkByName(NetIfName) if err != nil { - fmt.Printf("failed to get eth0 interface: %v\n", err) + fmt.Printf("failed to get [%s] interface: %v\n", NetIfName, err) return } - newState := struct { - Up bool - IPv4 string - IPv6 string - MAC string - }{ + newState := NetworkState{ Up: iface.Attrs().OperState == netlink.OperUp, MAC: iface.Attrs().HardwareAddr.String(), + + checked: true, } addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL) if err != nil { - fmt.Printf("failed to get addresses for eth0: %v\n", err) + fmt.Printf("failed to get addresses for [%s]: %v\n", NetIfName, 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() } } 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 @@ -83,16 +145,50 @@ 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() return nil } +func getNTPServersFromDHCPInfo() ([]string, error) { + buf, err := os.ReadFile(fmt.Sprintf(DHCPLeaseFile, NetIfName)) + if err != nil { + // do not return error if file does not exist + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to load udhcpc info: %w", err) + } + + // parse udhcpc info + env, err := envparse.Parse(bytes.NewReader(buf)) + if err != nil { + return nil, fmt.Errorf("failed to parse udhcpc info: %w", err) + } + + val, ok := env["ntpsrv"] + if !ok { + return nil, nil + } + + var servers []string + + for _, server := range strings.Fields(val) { + if net.ParseIP(server) == nil { + fmt.Printf("invalid NTP server IP: %s, ignoring ... \n", server) + } + servers = append(servers, server) + } + + return servers, nil +} + func init() { updates := make(chan netlink.LinkUpdate) done := make(chan struct{}) @@ -111,7 +207,7 @@ func init() { for { select { case update := <-updates: - if update.Link.Attrs().Name == "eth0" { + if update.Link.Attrs().Name == NetIfName { fmt.Printf("link update: %+v\n", update) checkNetworkState() } @@ -122,7 +218,6 @@ func init() { } } }() - fmt.Println("Starting mDNS server") err := startMDNS() if err != nil { fmt.Println("failed to run mDNS: %v", err) diff --git a/ntp.go b/ntp.go index f785d96..92d0471 100644 --- a/ntp.go +++ b/ntp.go @@ -11,20 +11,56 @@ import ( "github.com/beevik/ntp" ) -var timeSynced = false +const ( + timeSyncRetryStep = 5 * time.Second + timeSyncRetryMaxInt = 1 * time.Minute + timeSyncWaitNetChkInt = 100 * time.Millisecond + timeSyncWaitNetUpInt = 3 * time.Second + timeSyncInterval = 1 * time.Hour + timeSyncTimeout = 2 * time.Second +) + +var ( + timeSynced = false + timeSyncRetryInterval = 0 * time.Second + defaultNTPServers = []string{ + "time.cloudflare.com", + "time.apple.com", + } +) func TimeSyncLoop() { for { - fmt.Println("Syncing system time") + if !networkState.checked { + time.Sleep(timeSyncWaitNetChkInt) + continue + } + + if !networkState.Up { + log.Printf("Waiting for network to come up") + time.Sleep(timeSyncWaitNetUpInt) + continue + } + + log.Printf("Syncing system time") start := time.Now() err := SyncSystemTime() if err != nil { log.Printf("Failed to sync system time: %v", err) + + // retry after a delay + timeSyncRetryInterval += timeSyncRetryStep + time.Sleep(timeSyncRetryInterval) + // reset the retry interval if it exceeds the max interval + if timeSyncRetryInterval > timeSyncRetryMaxInt { + timeSyncRetryInterval = 0 + } + continue } log.Printf("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start)) timeSynced = true - time.Sleep(1 * time.Hour) //once the first sync is done, sync every hour + time.Sleep(timeSyncInterval) // after the first sync is done } } @@ -41,13 +77,22 @@ func SyncSystemTime() (err error) { } func queryNetworkTime() (*time.Time, error) { - ntpServers := []string{ - "time.cloudflare.com", - "time.apple.com", + ntpServers, err := getNTPServersFromDHCPInfo() + if err != nil { + log.Printf("failed to get NTP servers from DHCP info: %v\n", err) } + + if ntpServers == nil { + ntpServers = defaultNTPServers + log.Printf("Using default NTP servers: %v\n", ntpServers) + } else { + log.Printf("Using NTP servers from DHCP: %v\n", ntpServers) + } + for _, server := range ntpServers { - now, err := queryNtpServer(server, 2*time.Second) + now, err := queryNtpServer(server, timeSyncTimeout) if err == nil { + log.Printf("NTP server [%s] returned time: %v\n", server, now) return now, nil } } @@ -56,7 +101,7 @@ func queryNetworkTime() (*time.Time, error) { "http://cloudflare.com", } for _, url := range httpUrls { - now, err := queryHttpTime(url, 2*time.Second) + now, err := queryHttpTime(url, timeSyncTimeout) if err == nil { return now, nil } diff --git a/serial.go b/serial.go new file mode 100644 index 0000000..3ad56d3 --- /dev/null +++ b/serial.go @@ -0,0 +1,269 @@ +package kvm + +import ( + "bufio" + "io" + "strconv" + "strings" + "time" + + "github.com/pion/webrtc/v4" + "go.bug.st/serial" +) + +const serialPortPath = "/dev/ttyS3" + +var port serial.Port + +func mountATXControl() error { + port.SetMode(defaultMode) + go runATXControl() + + return nil +} + +func unmountATXControl() error { + reopenSerialPort() + return nil +} + +var ( + ledHDDState bool + ledPWRState bool + btnRSTState bool + btnPWRState bool +) + +func runATXControl() { + reader := bufio.NewReader(port) + for { + line, err := reader.ReadString('\n') + if err != nil { + logger.Errorf("Error reading from serial port: %v", err) + return + } + + // Each line should be 4 binary digits + newline + if len(line) != 5 { + logger.Warnf("Invalid line length: %d", len(line)) + continue + } + + // Parse new states + newLedHDDState := line[0] == '0' + newLedPWRState := line[1] == '0' + newBtnRSTState := line[2] == '1' + newBtnPWRState := line[3] == '1' + + if currentSession != nil { + writeJSONRPCEvent("atxState", ATXState{ + Power: newLedPWRState, + HDD: newLedHDDState, + }, currentSession) + } + + if newLedHDDState != ledHDDState || + newLedPWRState != ledPWRState || + newBtnRSTState != btnRSTState || + newBtnPWRState != btnPWRState { + + logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v", + newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState) + + // Update states + ledHDDState = newLedHDDState + ledPWRState = newLedPWRState + btnRSTState = newBtnRSTState + btnPWRState = newBtnPWRState + } + } +} + +func pressATXPowerButton(duration time.Duration) error { + _, err := port.Write([]byte("\n")) + if err != nil { + return err + } + + _, err = port.Write([]byte("BTN_PWR_ON\n")) + if err != nil { + return err + } + + time.Sleep(duration) + + _, err = port.Write([]byte("BTN_PWR_OFF\n")) + if err != nil { + return err + } + + return nil +} + +func pressATXResetButton(duration time.Duration) error { + _, err := port.Write([]byte("\n")) + if err != nil { + return err + } + + _, err = port.Write([]byte("BTN_RST_ON\n")) + if err != nil { + return err + } + + time.Sleep(duration) + + _, err = port.Write([]byte("BTN_RST_OFF\n")) + if err != nil { + return err + } + + return nil +} + +func mountDCControl() error { + port.SetMode(defaultMode) + go runDCControl() + return nil +} + +func unmountDCControl() error { + reopenSerialPort() + return nil +} + +var dcState DCPowerState + +func runDCControl() { + reader := bufio.NewReader(port) + for { + line, err := reader.ReadString('\n') + if err != nil { + logger.Errorf("Error reading from serial port: %v", err) + return + } + + // Split the line by semicolon + parts := strings.Split(strings.TrimSpace(line), ";") + if len(parts) != 4 { + logger.Warnf("Invalid line: %s", line) + continue + } + + // Parse new states + powerState, err := strconv.Atoi(parts[0]) + if err != nil { + logger.Warnf("Invalid power state: %v", err) + continue + } + dcState.IsOn = powerState == 1 + milliVolts, err := strconv.ParseFloat(parts[1], 64) + if err != nil { + logger.Warnf("Invalid voltage: %v", err) + continue + } + volts := milliVolts / 1000 // Convert mV to V + + milliAmps, err := strconv.ParseFloat(parts[2], 64) + if err != nil { + logger.Warnf("Invalid current: %v", err) + continue + } + amps := milliAmps / 1000 // Convert mA to A + + milliWatts, err := strconv.ParseFloat(parts[3], 64) + if err != nil { + logger.Warnf("Invalid power: %v", err) + continue + } + watts := milliWatts / 1000 // Convert mW to W + + dcState.Voltage = volts + dcState.Current = amps + dcState.Power = watts + + if currentSession != nil { + writeJSONRPCEvent("dcState", dcState, currentSession) + } + } +} + +func setDCPowerState(on bool) error { + _, err := port.Write([]byte("\n")) + if err != nil { + return err + } + command := "PWR_OFF\n" + if on { + command = "PWR_ON\n" + } + _, err = port.Write([]byte(command)) + if err != nil { + return err + } + return nil +} + +var defaultMode = &serial.Mode{ + BaudRate: 115200, + DataBits: 8, + Parity: serial.NoParity, + StopBits: serial.OneStopBit, +} + +func initSerialPort() { + reopenSerialPort() + if config.ActiveExtension == "atx-power" { + mountATXControl() + } else if config.ActiveExtension == "dc-power" { + mountDCControl() + } +} + +func reopenSerialPort() error { + if port != nil { + port.Close() + } + var err error + port, err = serial.Open(serialPortPath, defaultMode) + if err != nil { + logger.Errorf("Error opening serial port: %v", err) + } + return nil +} + +func handleSerialChannel(d *webrtc.DataChannel) { + d.OnOpen(func() { + go func() { + buf := make([]byte, 1024) + for { + n, err := port.Read(buf) + if err != nil { + if err != io.EOF { + logger.Errorf("Failed to read from serial port: %v", err) + } + break + } + err = d.Send(buf[:n]) + if err != nil { + logger.Errorf("Failed to send serial output: %v", err) + break + } + } + }() + }) + + d.OnMessage(func(msg webrtc.DataChannelMessage) { + if port == nil { + return + } + _, err := port.Write(msg.Data) + if err != nil { + logger.Errorf("Failed to write to serial: %v", err) + } + }) + + d.OnClose(func() { + + }) +} diff --git a/ui/.env.development b/ui/.env.development index 4534cc5..172328c 100644 --- a/ui/.env.development +++ b/ui/.env.development @@ -2,3 +2,5 @@ VITE_SIGNAL_API=http://localhost:3000 VITE_CLOUD_APP=http://localhost:5173 VITE_CLOUD_API=http://localhost:3000 + +VITE_JETKVM_HEAD= \ No newline at end of file diff --git a/ui/.env.device b/ui/.env.device index f13097c..2aaa6a7 100644 --- a/ui/.env.device +++ b/ui/.env.device @@ -2,3 +2,5 @@ VITE_SIGNAL_API= # Uses the KVM device's IP address as the signal API endpoint VITE_CLOUD_APP=https://app.jetkvm.com VITE_CLOUD_API=https://api.jetkvm.com + +VITE_JETKVM_HEAD= \ No newline at end of file diff --git a/ui/.env.production b/ui/.env.production index de65a37..2587c0c 100644 --- a/ui/.env.production +++ b/ui/.env.production @@ -1,4 +1,6 @@ VITE_SIGNAL_API=https://api.jetkvm.com VITE_CLOUD_APP=https://app.jetkvm.com -VITE_CLOUD_API=https://api.jetkvm.com \ No newline at end of file +VITE_CLOUD_API=https://api.jetkvm.com + +VITE_JETKVM_HEAD= \ No newline at end of file diff --git a/ui/.env.staging b/ui/.env.staging new file mode 100644 index 0000000..651e5bc --- /dev/null +++ b/ui/.env.staging @@ -0,0 +1,4 @@ +VITE_SIGNAL_API=https://staging-api.jetkvm.com + +VITE_CLOUD_APP=https://staging-app.jetkvm.com +VITE_CLOUD_API=https://staging-api.jetkvm.com \ No newline at end of file diff --git a/ui/dev_device.sh b/ui/dev_device.sh new file mode 100755 index 0000000..2fa8e4e --- /dev/null +++ b/ui/dev_device.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Print header +echo "┌──────────────────────────────────────┐" +echo "│ JetKVM Development Setup │" +echo "└──────────────────────────────────────┘" + +# Prompt for IP address +printf "Please enter the IP address of your JetKVM device: " +read ip_address + +# Validate input is not empty +if [ -z "$ip_address" ]; then + echo "Error: IP address cannot be empty" + exit 1 +fi + +# Set the environment variable and run Vite +echo "Starting development server with JetKVM device at: $ip_address" +sleep 1 +JETKVM_PROXY_URL="http://$ip_address" vite dev --mode=device diff --git a/ui/index.html b/ui/index.html index af9bdfb..72d2594 100644 --- a/ui/index.html +++ b/ui/index.html @@ -28,6 +28,7 @@ JetKVM + %VITE_JETKVM_HEAD%