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() }