From 99a8c2711c334fbbdda6b8f598cb782bcde2bc17 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Tue, 7 Oct 2025 04:43:25 -0500 Subject: [PATCH 1/4] Add podman support (#875) Reimplement #141 since we've changed everything since --- .devcontainer/{ => docker}/devcontainer.json | 2 +- .devcontainer/podman/devcontainer.json | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) rename .devcontainer/{ => docker}/devcontainer.json (95%) create mode 100644 .devcontainer/podman/devcontainer.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/docker/devcontainer.json similarity index 95% rename from .devcontainer/devcontainer.json rename to .devcontainer/docker/devcontainer.json index a7cb7c77..6a4e6ae0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/docker/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "JetKVM", + "name": "JetKVM docker devcontainer", "image": "mcr.microsoft.com/devcontainers/go:1.25-trixie", "features": { "ghcr.io/devcontainers/features/node:1": { diff --git a/.devcontainer/podman/devcontainer.json b/.devcontainer/podman/devcontainer.json new file mode 100644 index 00000000..bba58a31 --- /dev/null +++ b/.devcontainer/podman/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "JetKVM podman devcontainer", + "image": "mcr.microsoft.com/devcontainers/go:1.25-trixie", + "features": { + "ghcr.io/devcontainers/features/node:1": { + // Should match what is defined in ui/package.json + "version": "22.19.0" + } + }, + "runArgs": [ + "--userns=keep-id", + "--security-opt=label=disable", + "--security-opt=label=nested" + ], + "containerUser": "vscode", + "containerEnv": { + "HOME": "/home/vscode" + } +} \ No newline at end of file From e755a6e1b15dbed7bb2bcedea76ad0e9eace84f5 Mon Sep 17 00:00:00 2001 From: Aylen Date: Tue, 7 Oct 2025 11:57:10 +0200 Subject: [PATCH 2/4] Update openSUSE image reference to Leap 16.0 (#865) --- ui/src/routes/devices.$id.mount.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/routes/devices.$id.mount.tsx b/ui/src/routes/devices.$id.mount.tsx index bc29c455..4672ef99 100644 --- a/ui/src/routes/devices.$id.mount.tsx +++ b/ui/src/routes/devices.$id.mount.tsx @@ -374,8 +374,8 @@ function UrlView({ 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", + name: "openSUSE Leap 16.0", + url: "https://download.opensuse.org/distribution/leap/16.0/offline/Leap-16.0-online-installer-x86_64.install.iso", icon: OpenSUSEIcon, }, { From b144d9926fd2f58789b140c4650dbf9dbf2e0171 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Tue, 7 Oct 2025 04:57:26 -0500 Subject: [PATCH 3/4] Remove the temporary directory after extracting buildkit (#874) --- .devcontainer/install-deps.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/install-deps.sh b/.devcontainer/install-deps.sh index 4435d25b..079c8cdc 100755 --- a/.devcontainer/install-deps.sh +++ b/.devcontainer/install-deps.sh @@ -32,4 +32,5 @@ wget https://github.com/jetkvm/rv1106-system/releases/download/${BUILDKIT_VERSIO sudo mkdir -p /opt/jetkvm-native-buildkit && \ sudo tar --use-compress-program="unzstd --long=31" -xvf buildkit.tar.zst -C /opt/jetkvm-native-buildkit && \ rm buildkit.tar.zst -popd \ No newline at end of file +popd +rm -rf "${BUILDKIT_TMPDIR}" \ No newline at end of file From cc9ff7427645ab3c73a7fdb020150fb437c7de5a Mon Sep 17 00:00:00 2001 From: Aveline <352441+ym@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:52:51 +0200 Subject: [PATCH 4/4] feat: add HDMI sleep mode (#881) --- config.go | 1 + internal/native/native.go | 4 ++ internal/native/video.go | 68 +++++++++++++++++++++++++ jsonrpc.go | 2 + main.go | 3 ++ video.go | 103 +++++++++++++++++++++++++++++++++++++- webrtc.go | 38 +++++++++++--- 7 files changed, 212 insertions(+), 7 deletions(-) diff --git a/config.go b/config.go index c83ccfc7..0b008912 100644 --- a/config.go +++ b/config.go @@ -104,6 +104,7 @@ type Config struct { UsbDevices *usbgadget.Devices `json:"usb_devices"` NetworkConfig *network.NetworkConfig `json:"network_config"` DefaultLogLevel string `json:"default_log_level"` + VideoSleepAfterSec int `json:"video_sleep_after_sec"` } func (c *Config) GetDisplayRotation() uint16 { diff --git a/internal/native/native.go b/internal/native/native.go index 90fda520..b89b37a3 100644 --- a/internal/native/native.go +++ b/internal/native/native.go @@ -19,6 +19,7 @@ type Native struct { onVideoFrameReceived func(frame []byte, duration time.Duration) onIndevEvent func(event string) onRpcEvent func(event string) + sleepModeSupported bool videoLock sync.Mutex screenLock sync.Mutex } @@ -62,6 +63,8 @@ func NewNative(opts NativeOptions) *Native { } } + sleepModeSupported := isSleepModeSupported() + return &Native{ ready: make(chan struct{}), l: nativeLogger, @@ -73,6 +76,7 @@ func NewNative(opts NativeOptions) *Native { onVideoFrameReceived: onVideoFrameReceived, onIndevEvent: onIndevEvent, onRpcEvent: onRpcEvent, + sleepModeSupported: sleepModeSupported, videoLock: sync.Mutex{}, screenLock: sync.Mutex{}, } diff --git a/internal/native/video.go b/internal/native/video.go index 77291eb7..d5008756 100644 --- a/internal/native/video.go +++ b/internal/native/video.go @@ -1,5 +1,12 @@ package native +import ( + "os" +) + +const sleepModeFile = "/sys/devices/platform/ff470000.i2c/i2c-4/4-000f/sleep_mode" + +// VideoState is the state of the video stream. type VideoState struct { Ready bool `json:"ready"` Error string `json:"error,omitempty"` //no_signal, no_lock, out_of_range @@ -8,6 +15,58 @@ type VideoState struct { FramePerSecond float64 `json:"fps"` } +func isSleepModeSupported() bool { + _, err := os.Stat(sleepModeFile) + return err == nil +} + +func (n *Native) setSleepMode(enabled bool) error { + if !n.sleepModeSupported { + return nil + } + + bEnabled := "0" + if enabled { + bEnabled = "1" + } + return os.WriteFile(sleepModeFile, []byte(bEnabled), 0644) +} + +func (n *Native) getSleepMode() (bool, error) { + if !n.sleepModeSupported { + return false, nil + } + + data, err := os.ReadFile(sleepModeFile) + if err == nil { + return string(data) == "1", nil + } + + return false, nil +} + +// VideoSetSleepMode sets the sleep mode for the video stream. +func (n *Native) VideoSetSleepMode(enabled bool) error { + n.videoLock.Lock() + defer n.videoLock.Unlock() + + return n.setSleepMode(enabled) +} + +// VideoGetSleepMode gets the sleep mode for the video stream. +func (n *Native) VideoGetSleepMode() (bool, error) { + n.videoLock.Lock() + defer n.videoLock.Unlock() + + return n.getSleepMode() +} + +// VideoSleepModeSupported checks if the sleep mode is supported. +func (n *Native) VideoSleepModeSupported() bool { + return n.sleepModeSupported +} + +// VideoSetQualityFactor sets the quality factor for the video stream. func (n *Native) VideoSetQualityFactor(factor float64) error { n.videoLock.Lock() defer n.videoLock.Unlock() @@ -15,6 +74,7 @@ func (n *Native) VideoSetQualityFactor(factor float64) error { return videoSetStreamQualityFactor(factor) } +// VideoGetQualityFactor gets the quality factor for the video stream. func (n *Native) VideoGetQualityFactor() (float64, error) { n.videoLock.Lock() defer n.videoLock.Unlock() @@ -22,6 +82,7 @@ func (n *Native) VideoGetQualityFactor() (float64, error) { return videoGetStreamQualityFactor() } +// VideoSetEDID sets the EDID for the video stream. func (n *Native) VideoSetEDID(edid string) error { n.videoLock.Lock() defer n.videoLock.Unlock() @@ -29,6 +90,7 @@ func (n *Native) VideoSetEDID(edid string) error { return videoSetEDID(edid) } +// VideoGetEDID gets the EDID for the video stream. func (n *Native) VideoGetEDID() (string, error) { n.videoLock.Lock() defer n.videoLock.Unlock() @@ -36,6 +98,7 @@ func (n *Native) VideoGetEDID() (string, error) { return videoGetEDID() } +// VideoLogStatus gets the log status for the video stream. func (n *Native) VideoLogStatus() (string, error) { n.videoLock.Lock() defer n.videoLock.Unlock() @@ -43,6 +106,7 @@ func (n *Native) VideoLogStatus() (string, error) { return videoLogStatus(), nil } +// VideoStop stops the video stream. func (n *Native) VideoStop() error { n.videoLock.Lock() defer n.videoLock.Unlock() @@ -51,10 +115,14 @@ func (n *Native) VideoStop() error { return nil } +// VideoStart starts the video stream. func (n *Native) VideoStart() error { n.videoLock.Lock() defer n.videoLock.Unlock() + // disable sleep mode before starting video + _ = n.setSleepMode(false) + videoStart() return nil } diff --git a/jsonrpc.go b/jsonrpc.go index 0ff44a78..6b321c6d 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1215,6 +1215,8 @@ var rpcHandlers = map[string]RPCHandler{ "getEDID": {Func: rpcGetEDID}, "setEDID": {Func: rpcSetEDID, Params: []string{"edid"}}, "getVideoLogStatus": {Func: rpcGetVideoLogStatus}, + "getVideoSleepMode": {Func: rpcGetVideoSleepMode}, + "setVideoSleepMode": {Func: rpcSetVideoSleepMode, Params: []string{"duration"}}, "getDevChannelState": {Func: rpcGetDevChannelState}, "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, "getLocalVersion": {Func: rpcGetLocalVersion}, diff --git a/main.go b/main.go index e9931d46..81c85431 100644 --- a/main.go +++ b/main.go @@ -77,6 +77,9 @@ func Main() { // initialize display initDisplay() + // start video sleep mode timer + startVideoSleepModeTicker() + go func() { time.Sleep(15 * time.Minute) for { diff --git a/video.go b/video.go index 3460440b..cd74e680 100644 --- a/video.go +++ b/video.go @@ -1,10 +1,22 @@ package kvm import ( + "context" + "fmt" + "time" + "github.com/jetkvm/kvm/internal/native" ) -var lastVideoState native.VideoState +var ( + lastVideoState native.VideoState + videoSleepModeCtx context.Context + videoSleepModeCancel context.CancelFunc +) + +const ( + defaultVideoSleepModeDuration = 1 * time.Minute +) func triggerVideoStateUpdate() { go func() { @@ -17,3 +29,92 @@ func triggerVideoStateUpdate() { func rpcGetVideoState() (native.VideoState, error) { return lastVideoState, nil } + +type rpcVideoSleepModeResponse struct { + Supported bool `json:"supported"` + Enabled bool `json:"enabled"` + Duration int `json:"duration"` +} + +func rpcGetVideoSleepMode() rpcVideoSleepModeResponse { + sleepMode, _ := nativeInstance.VideoGetSleepMode() + return rpcVideoSleepModeResponse{ + Supported: nativeInstance.VideoSleepModeSupported(), + Enabled: sleepMode, + Duration: config.VideoSleepAfterSec, + } +} + +func rpcSetVideoSleepMode(duration int) error { + if duration < 0 { + duration = -1 // disable + } + + config.VideoSleepAfterSec = duration + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + // we won't restart the ticker here, + // as the session can't be inactive when this function is called + return nil +} + +func stopVideoSleepModeTicker() { + nativeLogger.Trace().Msg("stopping HDMI sleep mode ticker") + + if videoSleepModeCancel != nil { + nativeLogger.Trace().Msg("canceling HDMI sleep mode ticker context") + videoSleepModeCancel() + videoSleepModeCancel = nil + videoSleepModeCtx = nil + } +} + +func startVideoSleepModeTicker() { + if !nativeInstance.VideoSleepModeSupported() { + return + } + + var duration time.Duration + + if config.VideoSleepAfterSec == 0 { + duration = defaultVideoSleepModeDuration + } else if config.VideoSleepAfterSec > 0 { + duration = time.Duration(config.VideoSleepAfterSec) * time.Second + } else { + stopVideoSleepModeTicker() + return + } + + // Stop any existing timer and goroutine + stopVideoSleepModeTicker() + + // Create new context for this ticker + videoSleepModeCtx, videoSleepModeCancel = context.WithCancel(context.Background()) + + go doVideoSleepModeTicker(videoSleepModeCtx, duration) +} + +func doVideoSleepModeTicker(ctx context.Context, duration time.Duration) { + timer := time.NewTimer(duration) + defer timer.Stop() + + nativeLogger.Trace().Msg("HDMI sleep mode ticker started") + + for { + select { + case <-timer.C: + if getActiveSessions() > 0 { + nativeLogger.Warn().Msg("not going to enter HDMI sleep mode because there are active sessions") + continue + } + + nativeLogger.Trace().Msg("entering HDMI sleep mode") + _ = nativeInstance.VideoSetSleepMode(true) + case <-ctx.Done(): + nativeLogger.Trace().Msg("HDMI sleep mode ticker stopped") + return + } + } +} diff --git a/webrtc.go b/webrtc.go index a0a8473b..37488f77 100644 --- a/webrtc.go +++ b/webrtc.go @@ -39,6 +39,34 @@ type Session struct { keysDownStateQueue chan usbgadget.KeysDownState } +var ( + actionSessions int = 0 + activeSessionsMutex = &sync.Mutex{} +) + +func incrActiveSessions() int { + activeSessionsMutex.Lock() + defer activeSessionsMutex.Unlock() + + actionSessions++ + return actionSessions +} + +func decrActiveSessions() int { + activeSessionsMutex.Lock() + defer activeSessionsMutex.Unlock() + + actionSessions-- + return actionSessions +} + +func getActiveSessions() int { + activeSessionsMutex.Lock() + defer activeSessionsMutex.Unlock() + + return actionSessions +} + func (s *Session) resetKeepAliveTime() { s.keepAliveJitterLock.Lock() defer s.keepAliveJitterLock.Unlock() @@ -312,9 +340,8 @@ func newSession(config SessionConfig) (*Session, error) { if connectionState == webrtc.ICEConnectionStateConnected { if !isConnected { isConnected = true - actionSessions++ onActiveSessionsChanged() - if actionSessions == 1 { + if incrActiveSessions() == 1 { onFirstSessionConnected() } } @@ -353,9 +380,8 @@ func newSession(config SessionConfig) (*Session, error) { } if isConnected { isConnected = false - actionSessions-- onActiveSessionsChanged() - if actionSessions == 0 { + if decrActiveSessions() == 0 { onLastSessionDisconnected() } } @@ -364,16 +390,16 @@ func newSession(config SessionConfig) (*Session, error) { return session, nil } -var actionSessions = 0 - func onActiveSessionsChanged() { requestDisplayUpdate(true, "active_sessions_changed") } func onFirstSessionConnected() { _ = nativeInstance.VideoStart() + stopVideoSleepModeTicker() } func onLastSessionDisconnected() { _ = nativeInstance.VideoStop() + startVideoSleepModeTicker() }