fix: stop HDMI capture when logged out (#841)

* fix: stop HDMI capture when logged out

* feat: stop HDMI capture when viewers are idle

* fix: keep HDMI capture active for consumers
This commit is contained in:
watermeko
2026-07-30 17:29:07 +08:00
committed by GitHub
parent 004fd59093
commit b587b9f912
56 changed files with 1150 additions and 124 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -13,6 +13,7 @@ import (
"NanoKVM-Server/logger"
"NanoKVM-Server/middleware"
"NanoKVM-Server/router"
"NanoKVM-Server/service/vm"
"NanoKVM-Server/service/vm/jiggler"
"NanoKVM-Server/utils"
@@ -38,12 +39,12 @@ func initialize() {
_ = common.GetScreen()
// init HDMI
vision := common.GetKvmVision()
vision.SetHDMI(false)
vm.DisableHdmiCapture()
time.Sleep(10 * time.Millisecond)
if !utils.IsHdmiDisabled() {
vision.SetHDMI(true)
vm.EnableHdmiCapture()
}
vm.SetHdmiViewerCount(0)
// run mouse jiggler
jiggler.GetJiggler().Run()

View File

@@ -98,7 +98,12 @@ type GetOLEDRsp struct {
}
type GetGetHdmiStateRsp struct {
Enabled bool `json:"enabled"`
Enabled bool `json:"enabled"`
IdleTimeout int `json:"idleTimeout"`
}
type SetHdmiIdleTimeoutReq struct {
Minutes int `validate:"gte=0,lte=10080"`
}
type GetSSHStateRsp struct {

View File

@@ -40,6 +40,7 @@ func vmRouter(r *gin.Engine) {
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
api.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
api.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
api.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
api.GET("/vm/ssh", service.GetSSHState) // get SSH state
api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH

View File

@@ -11,7 +11,10 @@ import (
)
const (
defaultQuality = 85
defaultQuality = 60
minimumQuality = 51
maximumQuality = 60
maximumJPEGBytes = 1 << 20
screenshotRetryDelay = 100 * time.Millisecond
maxTimeoutMS = 30_000
)
@@ -21,20 +24,27 @@ type VisionReader interface {
}
type ScreenReader func() (width uint16, height uint16)
type CaptureLeaseAcquirer func(context.Context) (release func(), claimFresh func() bool, err error)
type Snapshotter struct {
vision VisionReader
readScreen ScreenReader
captureSlot chan struct{}
retryDelay time.Duration
vision VisionReader
readScreen ScreenReader
captureSlot chan struct{}
retryDelay time.Duration
acquireLease CaptureLeaseAcquirer
}
func New(vision VisionReader, readScreen ScreenReader) *Snapshotter {
return NewWithCaptureLease(vision, readScreen, nil)
}
func NewWithCaptureLease(vision VisionReader, readScreen ScreenReader, acquireLease CaptureLeaseAcquirer) *Snapshotter {
return &Snapshotter{
vision: vision,
readScreen: readScreen,
captureSlot: make(chan struct{}, 1),
retryDelay: screenshotRetryDelay,
vision: vision,
readScreen: readScreen,
captureSlot: make(chan struct{}, 1),
retryDelay: screenshotRetryDelay,
acquireLease: acquireLease,
}
}
@@ -50,9 +60,9 @@ func (s *Snapshotter) Capture(ctx context.Context, req mcpservice.SnapshotReques
if quality == 0 {
quality = defaultQuality
}
quality = clamp(quality, 1, 100)
quality = clamp(quality, minimumQuality, maximumQuality)
timeoutMS := 1000
timeoutMS := 3000
if req.TimeoutMS != nil {
timeoutMS = clamp(*req.TimeoutMS, 0, maxTimeoutMS)
}
@@ -63,9 +73,29 @@ func (s *Snapshotter) Capture(ctx context.Context, req mcpservice.SnapshotReques
case <-ctx.Done():
return mcpservice.Snapshot{}, ctx.Err()
}
if err := ctx.Err(); err != nil {
return mcpservice.Snapshot{}, err
}
if s.acquireLease != nil {
releaseLease, claimFresh, err := s.acquireLease(ctx)
if err != nil {
return mcpservice.Snapshot{}, err
}
if releaseLease != nil {
defer releaseLease()
}
deadline := time.Now().Add(time.Duration(timeoutMS) * time.Millisecond)
width, height := s.readScreen()
return s.capture(ctx, width, height, quality, deadline, timeoutMS, claimFresh)
}
deadline := time.Now().Add(time.Duration(timeoutMS) * time.Millisecond)
width, height := s.readScreen()
return s.capture(ctx, width, height, quality, deadline, timeoutMS, nil)
}
func (s *Snapshotter) capture(ctx context.Context, width uint16, height uint16, quality int, deadline time.Time, timeoutMS int, claimFresh func() bool) (mcpservice.Snapshot, error) {
for {
if err := ctx.Err(); err != nil {
return mcpservice.Snapshot{}, err
@@ -83,10 +113,19 @@ func (s *Snapshotter) Capture(ctx context.Context, req mcpservice.SnapshotReques
}
switch {
case result >= 0 && result != 5 && len(data) > 0:
if claimFresh != nil && claimFresh() {
claimFresh = nil
continue
}
if len(data) > maximumJPEGBytes {
snapshot.JPEG = nil
snapshot.Message = "captured JPEG exceeds the MCP response size limit"
return snapshot, nil
}
config, err := jpeg.DecodeConfig(bytes.NewReader(data))
if err != nil {
snapshot.Message = "captured data is not a valid JPEG"
return snapshot, nil
break
}
snapshot.OK = true
snapshot.Width = config.Width
@@ -98,7 +137,6 @@ func (s *Snapshotter) Capture(ctx context.Context, req mcpservice.SnapshotReques
snapshot.Message = "screenshot capture is temporarily unavailable"
case result < 0 || len(data) == 0:
snapshot.Message = "failed to capture screenshot"
return snapshot, nil
}
if timeoutMS == 0 || time.Now().Add(s.retryDelay).After(deadline) {

View File

@@ -35,15 +35,19 @@ type fakeVision struct {
responses []visionResponse
calls int
quality uint16
width uint16
height uint16
active int
maxActive int
delay time.Duration
}
func (v *fakeVision) ReadMjpeg(_ uint16, _ uint16, quality uint16) ([]byte, int) {
func (v *fakeVision) ReadMjpeg(width uint16, height uint16, quality uint16) ([]byte, int) {
v.mu.Lock()
v.calls++
v.quality = quality
v.width = width
v.height = height
v.active++
if v.active > v.maxActive {
v.maxActive = v.active
@@ -73,12 +77,12 @@ func TestCaptureSuccessAndQualityBounds(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !snapshot.OK || snapshot.Width != 1920 || snapshot.Height != 1080 || vision.quality != defaultQuality {
t.Fatalf("snapshot=%+v quality=%d", snapshot, vision.quality)
if !snapshot.OK || snapshot.Width != 1920 || snapshot.Height != 1080 || vision.quality != defaultQuality || vision.width != 1920 || vision.height != 1080 {
t.Fatalf("snapshot=%+v capture=%dx%d quality=%d", snapshot, vision.width, vision.height, vision.quality)
}
_, err = snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{Quality: 200})
if err != nil || vision.quality != 100 {
if err != nil || vision.quality != maximumQuality {
t.Fatalf("quality clamp=%d err=%v", vision.quality, err)
}
}
@@ -102,6 +106,34 @@ func TestCaptureRetriesNoSignal(t *testing.T) {
}
}
func TestCaptureRetriesInitialReadFailure(t *testing.T) {
vision := &fakeVision{responses: []visionResponse{
{result: -1},
{data: testJPEG(t, 1280, 720), result: 0},
}}
snapshotter := New(vision, func() (uint16, uint16) { return 1280, 720 })
snapshotter.retryDelay = 0
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
if err != nil || !snapshot.OK || vision.calls != 2 {
t.Fatalf("snapshot=%+v calls=%d err=%v", snapshot, vision.calls, err)
}
}
func TestCaptureRetriesInvalidJPEG(t *testing.T) {
vision := &fakeVision{responses: []visionResponse{
{data: []byte("partial jpeg"), result: 0},
{data: testJPEG(t, 1280, 720), result: 0},
}}
snapshotter := New(vision, func() (uint16, uint16) { return 1280, 720 })
snapshotter.retryDelay = 0
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
if err != nil || !snapshot.OK || vision.calls != 2 {
t.Fatalf("snapshot=%+v calls=%d err=%v", snapshot, vision.calls, err)
}
}
func TestCaptureRejectsCropAndEmptyData(t *testing.T) {
vision := &fakeVision{responses: []visionResponse{{result: 0}}}
snapshotter := New(vision, func() (uint16, uint16) { return 800, 600 })
@@ -139,7 +171,7 @@ func TestCaptureSerializesConcurrentCalls(t *testing.T) {
}
}
func TestCaptureUsesJPEGDimensionsForAutomaticResolution(t *testing.T) {
func TestCapturePassesThroughAutomaticResolution(t *testing.T) {
vision := &fakeVision{responses: []visionResponse{{data: testJPEG(t, 640, 480), result: 0}}}
snapshotter := New(vision, func() (uint16, uint16) { return 0, 0 })
@@ -147,8 +179,40 @@ func TestCaptureUsesJPEGDimensionsForAutomaticResolution(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !snapshot.OK || snapshot.Width != 640 || snapshot.Height != 480 {
t.Fatalf("snapshot=%+v", snapshot)
if !snapshot.OK || snapshot.Width != 640 || snapshot.Height != 480 || vision.width != 0 || vision.height != 0 {
t.Fatalf("snapshot=%+v capture=%dx%d", snapshot, vision.width, vision.height)
}
}
func TestCaptureDropsClaimedFirstFreshFrame(t *testing.T) {
first := testJPEG(t, 320, 240)
second := testJPEG(t, 640, 480)
vision := &fakeVision{responses: []visionResponse{
{data: first, result: 0},
{data: second, result: 0},
}}
claimed := 0
snapshotter := NewWithCaptureLease(vision, func() (uint16, uint16) { return 640, 480 }, func(context.Context) (func(), func() bool, error) {
return nil, func() bool {
claimed++
return claimed == 1
}, nil
})
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
if err != nil || !snapshot.OK || !bytes.Equal(snapshot.JPEG, second) || vision.calls != 2 || claimed != 1 {
t.Fatalf("snapshot=%+v calls=%d claimed=%d err=%v", snapshot, vision.calls, claimed, err)
}
}
func TestCaptureRejectsOversizedJPEG(t *testing.T) {
data := make([]byte, maximumJPEGBytes+1)
vision := &fakeVision{responses: []visionResponse{{data: data, result: 0}}}
snapshotter := New(vision, func() (uint16, uint16) { return 960, 540 })
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
if err != nil || snapshot.OK || len(snapshot.JPEG) != 0 || snapshot.Message == "" {
t.Fatalf("snapshot=%+v err=%v", snapshot, err)
}
}
@@ -194,3 +258,66 @@ func TestCaptureTimeoutStartsAfterSlotIsAcquired(t *testing.T) {
t.Fatal("capture did not complete after slot release")
}
}
func TestCaptureReleasesLeaseOnSuccessAndCancellation(t *testing.T) {
vision := &fakeVision{responses: []visionResponse{{data: testJPEG(t, 320, 240), result: 0}}}
acquired := 0
released := 0
snapshotter := NewWithCaptureLease(vision, func() (uint16, uint16) { return 320, 240 }, func(context.Context) (func(), func() bool, error) {
acquired++
return func() { released++ }, nil, nil
})
if _, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{}); err != nil {
t.Fatal(err)
}
if acquired != 1 || released != 1 {
t.Fatalf("lease acquired=%d released=%d, want 1/1", acquired, released)
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := snapshotter.Capture(canceled, mcpservice.SnapshotRequest{}); !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want canceled", err)
}
if acquired != 1 || released != 1 {
t.Fatalf("lease acquired=%d released=%d after cancellation before acquisition, want 1/1", acquired, released)
}
}
type blockingVision struct {
started chan struct{}
finish chan struct{}
}
func (v *blockingVision) ReadMjpeg(_ uint16, _ uint16, _ uint16) ([]byte, int) {
v.started <- struct{}{}
<-v.finish
return nil, 5
}
func TestCaptureReleasesLeaseAfterAcquisitionCancellation(t *testing.T) {
vision := &blockingVision{started: make(chan struct{}, 1), finish: make(chan struct{})}
acquired := 0
released := 0
snapshotter := NewWithCaptureLease(vision, func() (uint16, uint16) { return 320, 240 }, func(context.Context) (func(), func() bool, error) {
acquired++
return func() { released++ }, nil, nil
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
_, err := snapshotter.Capture(ctx, mcpservice.SnapshotRequest{})
done <- err
}()
<-vision.started
cancel()
close(vision.finish)
if err := <-done; !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want canceled", err)
}
if acquired != 1 || released != 1 {
t.Fatalf("lease acquired=%d released=%d after cancellation during capture, want 1/1", acquired, released)
}
}

View File

@@ -4,12 +4,13 @@ import (
"NanoKVM-Server/common"
mcpservice "NanoKVM-Server/service/mcp"
"NanoKVM-Server/service/mcp/capture"
"NanoKVM-Server/service/vm"
)
func New() mcpservice.Snapshotter {
return mcpcapture.New(common.GetKvmVision(), func() (uint16, uint16) {
return mcpcapture.NewWithCaptureLease(common.GetKvmVision(), func() (uint16, uint16) {
screen := common.GetScreen()
common.CheckScreen()
return screen.Width, screen.Height
})
}, vm.AcquireHdmiCaptureLeaseForRead)
}

View File

@@ -103,7 +103,7 @@ func newMCPHandler(control *controlmode.Manager, coordinator *inputcontrol.Coord
registerTools(server, executor, remote, snapshotter)
handler := protocol.NewStreamableHTTPHandler(
func(*http.Request) *protocol.Server { return server },
&protocol.StreamableHTTPOptions{Stateless: true},
&protocol.StreamableHTTPOptions{Stateless: true, JSONResponse: true},
)
return http.MaxBytesHandler(handler, maxRequestBodyBytes)
}
@@ -496,7 +496,7 @@ func scrollMouseSchema() *jsonschema.Schema {
func screenshotSchema() *jsonschema.Schema {
return objectSchema(map[string]*jsonschema.Schema{
"quality": integerSchema("JPEG quality", 1, 100),
"quality": integerSchema("JPEG quality; values are limited to 51-60 on the device", 1, 100),
"timeoutMs": integerSchema("Capture timeout in milliseconds", 0, 30000),
})
}

View File

@@ -111,6 +111,9 @@ func TestMCPInitializeAndToolsList(t *testing.T) {
if initialize.Code != http.StatusOK {
t.Fatalf("initialize status=%d body=%s", initialize.Code, initialize.Body.String())
}
if contentType := initialize.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
t.Fatalf("initialize content type=%q, want application/json", contentType)
}
sessionID := initialize.Header().Get("Mcp-Session-Id")
if sessionID == "" || !strings.Contains(initialize.Body.String(), "nanokvm-cube-remote-control") {
t.Fatalf("session=%q body=%s", sessionID, initialize.Body.String())
@@ -157,6 +160,9 @@ func TestMCPInitializeAndToolsList(t *testing.T) {
if call.name == "cube_screenshot" && !strings.Contains(response.Body.String(), "image/jpeg") {
t.Fatalf("screenshot response missing image: %s", response.Body.String())
}
if call.name == "cube_screenshot" && strings.HasPrefix(response.Body.String(), "data:") {
t.Fatalf("screenshot unexpectedly used SSE response: %s", response.Body.String())
}
}
invalidCalls := []struct {

View File

@@ -0,0 +1,145 @@
package picoclaw
import (
"context"
"encoding/json"
"strings"
"time"
"NanoKVM-Server/service/vm"
)
const defaultTaskCaptureLeaseDuration = 2 * time.Minute
const maxTaskCaptureLeaseDuration = 30 * time.Minute
func (s *Service) acquireCaptureLease(ctx context.Context) (func(), func() bool, error) {
if s != nil && s.acquireHDMIForRead != nil {
return s.acquireHDMIForRead(ctx)
}
return vm.AcquireHdmiCaptureLeaseForRead(ctx)
}
func (s *Service) activateTaskCaptureLease(sessionID string, taskID string, duration time.Duration) {
if s == nil || sessionID == "" {
return
}
if duration <= 0 {
duration = defaultTaskCaptureLeaseDuration
}
if duration > maxTaskCaptureLeaseDuration {
duration = maxTaskCaptureLeaseDuration
}
key := taskCaptureLeaseKey(sessionID, taskID)
s.captureLeaseMu.Lock()
defer s.captureLeaseMu.Unlock()
if s.captureLeases == nil {
s.captureLeases = make(map[string]func())
}
if s.captureLeaseTimers == nil {
s.captureLeaseTimers = make(map[string]*time.Timer)
}
if s.captureLeases[key] == nil {
if s.acquireHDMILease != nil {
s.captureLeases[key] = s.acquireHDMILease()
} else {
s.captureLeases[key] = vm.AcquireHdmiCaptureLease()
}
}
if timer := s.captureLeaseTimers[key]; timer != nil {
timer.Stop()
}
s.captureLeaseTimers[key] = time.AfterFunc(duration, func() {
s.releaseCaptureLease(key)
})
}
func (s *Service) updateTaskCaptureLease(source string, sessionID string, data []byte) {
if s == nil || sessionID == "" || len(data) == 0 {
return
}
var message struct {
ID string `json:"id"`
Type string `json:"type"`
Payload struct {
MaxRuntimeMS int `json:"max_runtime_ms"`
} `json:"payload"`
}
if err := json.Unmarshal(data, &message); err != nil {
return
}
switch {
case source == "downstream" && message.Type == "message.send":
duration := defaultTaskCaptureLeaseDuration
if message.Payload.MaxRuntimeMS > 0 {
maxMilliseconds := int(maxTaskCaptureLeaseDuration / time.Millisecond)
duration = time.Duration(min(message.Payload.MaxRuntimeMS, maxMilliseconds)) * time.Millisecond
}
s.activateTaskCaptureLease(sessionID, message.ID, duration)
case source == "downstream" && message.Type == "message.cancel":
if message.ID == "" {
s.releaseCaptureLeasesForSession(sessionID)
return
}
s.releaseTaskCaptureLease(sessionID, message.ID)
case source == "upstream" && (message.Type == "typing.stop" || message.Type == "message.create" || message.Type == "message.update" || message.Type == "error"):
if message.ID == "" {
s.releaseCaptureLeasesForSession(sessionID)
return
}
s.releaseTaskCaptureLease(sessionID, message.ID)
}
}
func taskCaptureLeaseKey(sessionID string, taskID string) string {
if taskID == "" {
taskID = "default"
}
return "task:" + sessionID + ":" + taskID
}
func (s *Service) releaseTaskCaptureLease(sessionID string, taskID string) {
if sessionID == "" {
return
}
s.releaseCaptureLease(taskCaptureLeaseKey(sessionID, taskID))
}
func (s *Service) releaseCaptureLeasesForSession(sessionID string) {
if s == nil || sessionID == "" {
return
}
prefix := "task:" + sessionID + ":"
s.captureLeaseMu.Lock()
keys := make([]string, 0, len(s.captureLeases))
for key := range s.captureLeases {
if strings.HasPrefix(key, prefix) {
keys = append(keys, key)
}
}
s.captureLeaseMu.Unlock()
for _, key := range keys {
s.releaseCaptureLease(key)
}
}
func (s *Service) releaseCaptureLease(key string) {
if s == nil || key == "" {
return
}
s.captureLeaseMu.Lock()
release := s.captureLeases[key]
delete(s.captureLeases, key)
if timer := s.captureLeaseTimers[key]; timer != nil {
timer.Stop()
delete(s.captureLeaseTimers, key)
}
s.captureLeaseMu.Unlock()
if release != nil {
release()
}
}

View File

@@ -155,6 +155,7 @@ func (s *Service) proxyMessages(source string, session *GatewaySession, src *web
}
return
}
s.updateTaskCaptureLease(source, session.SessionID, data)
var writeErr error
switch source {
@@ -215,6 +216,7 @@ func (s *Service) closeGatewaySession(session *GatewaySession, closeCode int, re
hadDownstream := session.Downstream != nil
mjpeg.DisableLatestFrameCache()
s.releaseCaptureLeasesForSession(session.SessionID)
GetSessionManager().SetState(session.SessionID, SessionStateClosing)
if session.Upstream != nil {

View File

@@ -242,7 +242,7 @@ func (s *Service) mcpScreenshot(req jsonRPCRequest, args json.RawMessage, c *gin
Quality: params.Quality,
}
data, meta, err := s.captureScreenshot(query)
data, meta, err := s.captureScreenshot(c.Request.Context(), query)
if err != nil {
return mcpToolError(req, err.Message)
}

View File

@@ -18,6 +18,7 @@ func (s *Service) ReleaseRuntimeSession(c *gin.Context) {
if session, ok := GetSessionManager().Get(sessionID); ok {
s.closeGatewaySession(session, websocket.CloseNormalClosure, "session released")
}
s.releaseCaptureLeasesForSession(sessionID)
status := s.runtime.Get()
status.CurrentSession = s.lock.Owner()

View File

@@ -1,12 +1,12 @@
package picoclaw
import (
"context"
"encoding/base64"
"net/http"
"time"
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream/mjpeg"
"github.com/gin-gonic/gin"
)
@@ -14,8 +14,7 @@ import (
var screenshotRetryDelay = 100 * time.Millisecond
const (
screenshotRetryCount = 3
cachedFrameMaxAge = 2 * time.Second
screenshotRetryCount = 30
defaultPicoclawScreenshotWidth = 960
defaultPicoclawScreenshotHeight = 540
defaultPicoclawScreenshotQuality = 60
@@ -43,7 +42,7 @@ func (s *Service) Screenshot(c *gin.Context) {
defer s.lock.Release(sessionID)
}
data, meta, err := s.captureScreenshot(query)
data, meta, err := s.captureScreenshot(c.Request.Context(), query)
if err != nil {
writePicoclawError(c, err)
return
@@ -58,36 +57,42 @@ func (s *Service) Screenshot(c *gin.Context) {
c.Data(http.StatusOK, "image/jpeg", data)
}
func (s *Service) captureScreenshot(query ScreenshotQuery) ([]byte, ScreenshotMeta, *PicoclawError) {
func (s *Service) captureScreenshot(ctx context.Context, query ScreenshotQuery) ([]byte, ScreenshotMeta, *PicoclawError) {
width, height, quality := resolveScreenshotRequest(query)
if canUseCachedFrame(query) {
if frame, ok := mjpeg.GetLatestFrame(); ok && time.Since(frame.CapturedAt) <= cachedFrameMaxAge {
return frame.Data, ScreenshotMeta{
SourceWidth: frame.Width,
SourceHeight: frame.Height,
CaptureWidth: frame.Width,
CaptureHeight: frame.Height,
Format: "jpeg",
}, nil
}
}
screen := common.GetScreen()
common.CheckScreen()
releaseLease, claimFresh, leaseErr := s.acquireCaptureLease(ctx)
if leaseErr != nil {
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "screenshot capture canceled")
}
defer releaseLease()
for attempt := 0; attempt < screenshotRetryCount; attempt++ {
if err := ctx.Err(); err != nil {
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "screenshot capture canceled")
}
data, result := s.vision.ReadMjpeg(width, height, quality)
switch {
case result == 5:
case result == 5 || result == -3 || result == -4 || result == -5:
if attempt < screenshotRetryCount-1 {
time.Sleep(screenshotRetryDelay)
timer := time.NewTimer(screenshotRetryDelay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "screenshot capture canceled")
case <-timer.C:
}
continue
}
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotNoSignal, "no HDMI signal or frame unavailable")
case result < 0 || len(data) == 0:
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "failed to capture screenshot")
default:
if claimFresh != nil && claimFresh() {
claimFresh = nil
continue
}
return data, ScreenshotMeta{
SourceWidth: screen.Width,
SourceHeight: screen.Height,
@@ -101,10 +106,6 @@ func (s *Service) captureScreenshot(query ScreenshotQuery) ([]byte, ScreenshotMe
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "failed to capture screenshot")
}
func canUseCachedFrame(query ScreenshotQuery) bool {
return query.Width == 0 && query.Height == 0 && query.Quality == 0
}
func resolveScreenshotRequest(query ScreenshotQuery) (uint16, uint16, uint16) {
screen := common.GetScreen()
width := screen.Width

View File

@@ -9,6 +9,7 @@ import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/vm"
"github.com/gin-gonic/gin"
)
@@ -32,15 +33,19 @@ func NewService(control *controlmode.Manager) *Service {
control = controlmode.GetManager()
}
service := &Service{
vision: common.GetKvmVision(),
hid: hid.GetHid(),
config: getConfigStore(),
lock: GetSessionLock(),
runtime: getRuntimeStore(),
runtimeIntent: getRuntimeIntentStore(),
control: control,
releaseHID: hid.ReleaseAllHIDStateBestEffort,
operations: newControlOperationTracker(),
vision: common.GetKvmVision(),
hid: hid.GetHid(),
config: getConfigStore(),
lock: GetSessionLock(),
runtime: getRuntimeStore(),
runtimeIntent: getRuntimeIntentStore(),
control: control,
releaseHID: hid.ReleaseAllHIDStateBestEffort,
operations: newControlOperationTracker(),
acquireHDMILease: vm.AcquireHdmiCaptureLease,
acquireHDMIForRead: vm.AcquireHdmiCaptureLeaseForRead,
captureLeases: make(map[string]func()),
captureLeaseTimers: make(map[string]*time.Timer),
}
service.ensureDependencies()
service.startRuntimeIntentReconcile()
@@ -78,6 +83,18 @@ func (s *Service) ensureDependencies() {
if s.operations == nil {
s.operations = newControlOperationTracker()
}
if s.acquireHDMILease == nil {
s.acquireHDMILease = vm.AcquireHdmiCaptureLease
}
if s.acquireHDMIForRead == nil {
s.acquireHDMIForRead = vm.AcquireHdmiCaptureLeaseForRead
}
if s.captureLeases == nil {
s.captureLeases = make(map[string]func())
}
if s.captureLeaseTimers == nil {
s.captureLeaseTimers = make(map[string]*time.Timer)
}
}
func getConfigStore() *ConfigStore {

View File

@@ -1,6 +1,7 @@
package picoclaw
import (
"context"
"encoding/json"
"reflect"
"strings"
@@ -32,6 +33,11 @@ type Service struct {
control *controlmode.Manager
releaseHID func() error
operations *controlOperationTracker
acquireHDMILease func() func()
acquireHDMIForRead func(context.Context) (func(), func() bool, error)
captureLeaseMu sync.Mutex
captureLeases map[string]func()
captureLeaseTimers map[string]*time.Timer
runtimeLifecycleMu sync.Mutex
reconcileOnce sync.Once
}

View File

@@ -3,6 +3,7 @@ package direct
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"NanoKVM-Server/service/vm"
"bytes"
"encoding/binary"
"sync"
@@ -18,6 +19,7 @@ type Streamer struct {
clients map[*websocket.Conn]bool
clientSnapshot atomic.Pointer[[]*websocket.Conn]
running int32
viewerVersion uint64
}
func newStreamer() *Streamer {
@@ -32,8 +34,11 @@ func newStreamer() *Streamer {
func (s *Streamer) addClient(ws *websocket.Conn) {
s.mutex.Lock()
s.clients[ws] = true
s.updateClientSnapshotLocked()
count := s.updateClientSnapshotLocked()
s.viewerVersion++
version := s.viewerVersion
s.mutex.Unlock()
vm.UpdateHdmiViewerSnapshot("direct", count, version)
if atomic.CompareAndSwapInt32(&s.running, 0, 1) {
go s.run()
@@ -45,7 +50,10 @@ func (s *Streamer) removeClient(ws *websocket.Conn) {
s.mutex.Lock()
delete(s.clients, ws)
count := s.updateClientSnapshotLocked()
s.viewerVersion++
version := s.viewerVersion
s.mutex.Unlock()
vm.UpdateHdmiViewerSnapshot("direct", count, version)
log.Debugf("h264 websocket disconnected, remaining clients: %d", count)
}

View File

@@ -3,6 +3,7 @@ package mjpeg
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"NanoKVM-Server/service/vm"
"fmt"
"strconv"
"sync"
@@ -23,6 +24,7 @@ type Streamer struct {
frameMutex sync.RWMutex
latestFrame LatestFrame
cacheRefs int32
viewerVersion uint64
}
func NewStreamer() *Streamer {
@@ -37,8 +39,11 @@ func NewStreamer() *Streamer {
func (s *Streamer) AddClient(c *gin.Context) {
s.mutex.Lock()
s.clients[c] = true
s.updateClientSnapshotLocked()
count := s.updateClientSnapshotLocked()
s.viewerVersion++
version := s.viewerVersion
s.mutex.Unlock()
vm.UpdateHdmiViewerSnapshot("mjpeg", count, version)
if atomic.CompareAndSwapInt32(&s.running, 0, 1) {
go s.run()
@@ -50,7 +55,10 @@ func (s *Streamer) RemoveClient(c *gin.Context) {
s.mutex.Lock()
delete(s.clients, c)
count := s.updateClientSnapshotLocked()
s.viewerVersion++
version := s.viewerVersion
s.mutex.Unlock()
vm.UpdateHdmiViewerSnapshot("mjpeg", count, version)
log.Debugf("mjpeg connection removed, remaining clients: %d", count)
}

View File

@@ -3,6 +3,7 @@ package webrtc
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"NanoKVM-Server/service/vm"
"sync/atomic"
"time"
@@ -27,7 +28,10 @@ func (m *WebRTCManager) AddClient(ws *websocket.Conn, client *Client) {
m.mutex.Lock()
m.clients[ws] = client
count := m.updateClientSnapshotLocked()
m.viewerVersion++
version := m.viewerVersion
m.mutex.Unlock()
vm.UpdateHdmiViewerSnapshot("webrtc", count, version)
log.Debugf("added client %s, total clients: %d", ws.RemoteAddr(), count)
}
@@ -36,7 +40,10 @@ func (m *WebRTCManager) RemoveClient(ws *websocket.Conn) {
m.mutex.Lock()
delete(m.clients, ws)
count := m.updateClientSnapshotLocked()
m.viewerVersion++
version := m.viewerVersion
m.mutex.Unlock()
vm.UpdateHdmiViewerSnapshot("webrtc", count, version)
log.Debugf("removed client %s, total clients: %d", ws.RemoteAddr(), count)
}

View File

@@ -14,6 +14,7 @@ type WebRTCManager struct {
clientSnapshot atomic.Pointer[[]*Client]
videoSending int32
mutex sync.Mutex
viewerVersion uint64
}
type Client struct {

View File

@@ -1,25 +1,41 @@
package vm
import (
"context"
"sync"
"time"
"NanoKVM-Server/common"
"NanoKVM-Server/proto"
hdmistate "NanoKVM-Server/service/vm/hdmi_state"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const hdmiCaptureWarmupDuration = time.Second
var (
hdmiMutex sync.Mutex
hdmiCaptureReadSlot = make(chan struct{}, 1)
hdmiIdleTimer *time.Timer
hdmiIdleGeneration uint64
hdmiDemand = hdmistate.New()
hdmiStoppedForIdle bool
setHDMI = func(enabled bool) { common.GetKvmVision().SetHDMI(enabled) }
isHdmiDisabled = utils.IsHdmiDisabled
getHdmiIdleTimeout = utils.GetHDMIIdleTimeout
)
func (s *Service) ResetHdmi(c *gin.Context) {
var rsp proto.Response
vision := common.GetKvmVision()
vision.SetHDMI(false)
time.Sleep(1 * time.Second)
vision.SetHDMI(true)
utils.PersistHDMIEnabled()
DisableHdmiCapture()
time.Sleep(1 * time.Second)
EnableHdmiCapture()
rsp.OkRsp(c)
log.Debug("reset hdmi")
@@ -28,10 +44,8 @@ func (s *Service) ResetHdmi(c *gin.Context) {
func (s *Service) EnableHdmi(c *gin.Context) {
var rsp proto.Response
vision := common.GetKvmVision()
vision.SetHDMI(true)
utils.PersistHDMIEnabled()
EnableHdmiCapture()
rsp.OkRsp(c)
log.Debug("enable hdmi")
@@ -40,10 +54,8 @@ func (s *Service) EnableHdmi(c *gin.Context) {
func (s *Service) DisableHdmi(c *gin.Context) {
var rsp proto.Response
vision := common.GetKvmVision()
vision.SetHDMI(false)
utils.PersistHDMIDisabled()
DisableHdmiCapture()
rsp.OkRsp(c)
log.Debug("disable hdmi")
@@ -53,8 +65,236 @@ func (s *Service) GetHdmiState(c *gin.Context) {
var rsp proto.Response
rsp.OkRspWithData(c, &proto.GetGetHdmiStateRsp{
Enabled: !utils.IsHdmiDisabled(),
Enabled: !utils.IsHdmiDisabled(),
IdleTimeout: utils.GetHDMIIdleTimeout(),
})
log.Debug("get hdmi state")
}
func EnableHdmiCapture() {
hdmiMutex.Lock()
defer hdmiMutex.Unlock()
setHDMI(true)
hdmiDemand.MarkWarming(time.Now().Add(hdmiCaptureWarmupDuration))
hdmiStoppedForIdle = false
hdmiIdleGeneration++
stopHdmiIdleTimerLocked()
scheduleHdmiIdleTimerLocked()
}
func DisableHdmiCapture() {
hdmiMutex.Lock()
defer hdmiMutex.Unlock()
setHDMI(false)
hdmiDemand.ClearReadyAt()
hdmiStoppedForIdle = false
hdmiIdleGeneration++
stopHdmiIdleTimerLocked()
}
func (s *Service) SetHdmiIdleTimeout(c *gin.Context) {
var req proto.SetHdmiIdleTimeoutReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
SetHdmiIdleTimeout(req.Minutes)
rsp.OkRsp(c)
}
func SetHdmiIdleTimeout(minutes int) {
if minutes < 0 || minutes > utils.MaxHDMIIdleTimeoutMinutes {
return
}
utils.PersistHDMIIdleTimeout(minutes)
hdmiMutex.Lock()
defer hdmiMutex.Unlock()
hdmiIdleGeneration++
stopHdmiIdleTimerLocked()
if hdmiStoppedForIdle && !isHdmiDisabled() {
resumeHdmiCaptureLocked()
hdmiStoppedForIdle = false
}
if !hdmiDemand.HasDemand() && !hdmiStoppedForIdle {
scheduleHdmiIdleTimerLocked()
}
}
func SetHdmiViewerCount(count int) {
SetHdmiViewerCountForSource("legacy", count)
}
// SetHdmiViewerCountForSource is retained for callers that cannot provide a
// source revision. Streamers should use UpdateHdmiViewerSnapshot instead.
func SetHdmiViewerCountForSource(source string, count int) {
hdmiMutex.Lock()
defer hdmiMutex.Unlock()
version := hdmiDemand.NextVersion(source)
updateHdmiViewerSnapshotLocked(source, count, version)
}
// UpdateHdmiViewerSnapshot applies a source's authoritative client count.
// Revisions let streamers report after releasing their own client-map locks
// without allowing an older count to overwrite a newer one.
func UpdateHdmiViewerSnapshot(source string, count int, version uint64) {
hdmiMutex.Lock()
defer hdmiMutex.Unlock()
updateHdmiViewerSnapshotLocked(source, count, version)
}
func updateHdmiViewerSnapshotLocked(source string, count int, version uint64) {
if count < 0 {
count = 0
}
if !hdmiDemand.UpdateViewer(source, count, version) {
return
}
reconcileHdmiCaptureDemandLocked()
}
// AcquireHdmiCaptureLease prevents idle shutdown while a short-lived capture
// consumer, such as an MCP screenshot, is active. The returned release is
// safe to call more than once.
func AcquireHdmiCaptureLease() func() {
release, _ := acquireHdmiCaptureLease()
return release
}
// AcquireHdmiCaptureLeaseForRead keeps capture active and waits outside
// hdmiMutex until a capture resumed from idle has had time to produce a new
// frame. The returned claim function is consumed only by the first successful
// reader, so concurrent readers do not all discard a frame.
func AcquireHdmiCaptureLeaseForRead(ctx context.Context) (func(), func() bool, error) {
release, readyAt := acquireHdmiCaptureLease()
if wait := time.Until(readyAt); wait > 0 {
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
release()
return nil, nil, ctx.Err()
case <-timer.C:
}
}
select {
case hdmiCaptureReadSlot <- struct{}{}:
case <-ctx.Done():
release()
return nil, nil, ctx.Err()
}
var releaseReadOnce sync.Once
releaseRead := func() {
releaseReadOnce.Do(func() {
<-hdmiCaptureReadSlot
release()
})
}
hdmiMutex.Lock()
needsFreshFrame := hdmiDemand.NeedsFreshFrame()
hdmiMutex.Unlock()
if !needsFreshFrame {
return releaseRead, nil, nil
}
claimFresh := func() bool {
hdmiMutex.Lock()
claimed := hdmiDemand.ClaimFreshFrame()
hdmiMutex.Unlock()
return claimed
}
return releaseRead, claimFresh, nil
}
func acquireHdmiCaptureLease() (func(), time.Time) {
hdmiMutex.Lock()
hdmiDemand.AcquireLease()
reconcileHdmiCaptureDemandLocked()
readyAt := hdmiDemand.ReadyAt()
hdmiMutex.Unlock()
var once sync.Once
release := func() {
once.Do(func() {
hdmiMutex.Lock()
defer hdmiMutex.Unlock()
if !hdmiDemand.ReleaseLease() {
return
}
reconcileHdmiCaptureDemandLocked()
})
}
return release, readyAt
}
func reconcileHdmiCaptureDemandLocked() {
hdmiIdleGeneration++
stopHdmiIdleTimerLocked()
if hdmiHasCaptureDemandLocked() {
if hdmiStoppedForIdle && !isHdmiDisabled() {
resumeHdmiCaptureLocked()
}
hdmiStoppedForIdle = false
return
}
scheduleHdmiIdleTimerLocked()
}
func hdmiHasCaptureDemandLocked() bool {
return hdmiDemand.HasDemand()
}
func stopHdmiIdleTimerLocked() {
if hdmiIdleTimer != nil {
hdmiIdleTimer.Stop()
hdmiIdleTimer = nil
}
}
func scheduleHdmiIdleTimerLocked() {
if hdmiHasCaptureDemandLocked() || hdmiStoppedForIdle || isHdmiDisabled() {
return
}
minutes := getHdmiIdleTimeout()
if minutes == 0 {
return
}
generation := hdmiIdleGeneration
hdmiIdleTimer = time.AfterFunc(time.Duration(minutes)*time.Minute, func() {
hdmiMutex.Lock()
defer hdmiMutex.Unlock()
if generation != hdmiIdleGeneration || hdmiHasCaptureDemandLocked() || isHdmiDisabled() {
return
}
hdmiIdleTimer = nil
setHDMI(false)
hdmiDemand.ClearReadyAt()
hdmiStoppedForIdle = true
log.Debugf("disabled hdmi capture after %d minutes without viewers", minutes)
})
}
func resumeHdmiCaptureLocked() {
setHDMI(true)
hdmiDemand.MarkWarming(time.Now().Add(hdmiCaptureWarmupDuration))
log.Debug("resumed hdmi capture after viewer connected")
}

View File

@@ -0,0 +1,100 @@
package hdmi_state
import "time"
type viewerSource struct {
count int
version uint64
}
// State stores capture demand. Callers must serialize access to State.
type State struct {
viewerCount int
leaseCount int
sources map[string]viewerSource
readyAt time.Time
needsFreshFrame bool
}
func New() State {
return State{sources: make(map[string]viewerSource)}
}
// UpdateViewer accepts only newer source snapshots so a delayed report cannot
// overwrite a newer client-map snapshot.
func (s *State) UpdateViewer(source string, count int, version uint64) bool {
if count < 0 {
count = 0
}
if current, ok := s.sources[source]; ok && version <= current.version {
return false
}
s.sources[source] = viewerSource{count: count, version: version}
s.viewerCount = 0
for _, current := range s.sources {
s.viewerCount += current.count
}
return true
}
func (s *State) NextVersion(source string) uint64 {
return s.sources[source].version + 1
}
func (s *State) AcquireLease() {
s.leaseCount++
}
func (s *State) ReleaseLease() bool {
if s.leaseCount == 0 {
return false
}
s.leaseCount--
return true
}
func (s *State) HasDemand() bool {
return s.viewerCount > 0 || s.leaseCount > 0
}
func (s *State) ViewerCount() int {
return s.viewerCount
}
func (s *State) LeaseCount() int {
return s.leaseCount
}
func (s *State) MarkWarming(readyAt time.Time) {
s.readyAt = readyAt
s.needsFreshFrame = true
}
func (s *State) ClearReadyAt() {
s.readyAt = time.Time{}
s.needsFreshFrame = false
}
func (s *State) ReadyAt() time.Time {
return s.readyAt
}
func (s *State) NeedsFreshFrame() bool {
return s.needsFreshFrame
}
// ClaimFreshFrame returns true for the first successful reader after HDMI
// resumes. Failed reads do not consume the claim.
func (s *State) ClaimFreshFrame() bool {
if !s.needsFreshFrame {
return false
}
s.needsFreshFrame = false
return true
}
func (s *State) Viewer(source string) (count int, version uint64, ok bool) {
value, ok := s.sources[source]
return value.count, value.version, ok
}

View File

@@ -0,0 +1,98 @@
package hdmi_state
import (
"sync"
"testing"
"time"
)
func TestUpdateViewerIgnoresOlderSnapshot(t *testing.T) {
state := New()
if !state.UpdateViewer("direct", 1, 2) {
t.Fatal("new snapshot was rejected")
}
if state.UpdateViewer("direct", 0, 1) {
t.Fatal("older snapshot was accepted")
}
count, version, ok := state.Viewer("direct")
if !ok || count != 1 || version != 2 || state.ViewerCount() != 1 {
t.Fatalf("viewer=(%d,%d,%t) total=%d", count, version, ok, state.ViewerCount())
}
}
func TestCaptureReadyAtTracksWarmup(t *testing.T) {
state := New()
readyAt := time.Now().Add(time.Second)
state.MarkWarming(readyAt)
if got := state.ReadyAt(); !got.Equal(readyAt) {
t.Fatalf("readyAt=%v, want %v", got, readyAt)
}
if !state.NeedsFreshFrame() {
t.Fatal("warmup did not require a fresh frame")
}
if !state.ClaimFreshFrame() {
t.Fatal("fresh frame was not claimed")
}
if state.NeedsFreshFrame() || state.ClaimFreshFrame() {
t.Fatal("fresh frame requirement was not cleared after the first claim")
}
state.MarkWarming(readyAt)
state.ClearReadyAt()
if !state.ReadyAt().IsZero() || state.NeedsFreshFrame() {
t.Fatalf("readyAt=%v needsFresh=%t, want zero/false", state.ReadyAt(), state.NeedsFreshFrame())
}
}
func TestClaimFreshFrameOnlySucceedsOnce(t *testing.T) {
state := New()
state.MarkWarming(time.Now())
if !state.ClaimFreshFrame() {
t.Fatal("first reader did not claim the fresh frame")
}
if state.ClaimFreshFrame() {
t.Fatal("second reader claimed the same fresh frame")
}
}
func TestLeasesKeepCaptureDemandActive(t *testing.T) {
state := New()
state.AcquireLease()
state.AcquireLease()
if !state.HasDemand() || state.LeaseCount() != 2 {
t.Fatalf("demand=%t leases=%d", state.HasDemand(), state.LeaseCount())
}
if !state.ReleaseLease() || !state.HasDemand() {
t.Fatal("first lease release cleared demand")
}
if !state.ReleaseLease() || state.HasDemand() {
t.Fatal("last lease release did not clear demand")
}
if state.ReleaseLease() {
t.Fatal("release accepted after all leases were released")
}
}
func TestConcurrentSourceReportsLeaveLatestSnapshot(t *testing.T) {
state := New()
var mutex sync.Mutex
var wait sync.WaitGroup
for version := uint64(1); version <= 100; version++ {
wait.Add(1)
go func(version uint64) {
defer wait.Done()
mutex.Lock()
state.UpdateViewer("webrtc", int(version%2), version)
mutex.Unlock()
}(version)
}
wait.Wait()
mutex.Lock()
_, version, ok := state.Viewer("webrtc")
mutex.Unlock()
if !ok || version != 100 {
t.Fatalf("version=%d exists=%t, want 100/true", version, ok)
}
}

View File

@@ -2,12 +2,17 @@ package utils
import (
"os"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
)
const (
HDMIDisableFile = "/etc/kvm/hdmi_disable"
HDMIDisableFile = "/etc/kvm/hdmi_disable"
HDMIIdleTimeoutFile = "/etc/kvm/hdmi_idle_timeout"
DefaultHDMIIdleTimeout = 0
MaxHDMIIdleTimeoutMinutes = 7 * 24 * 60
)
func PersistHDMIDisabled() {
@@ -36,3 +41,27 @@ func IsHdmiDisabled() bool {
}
return true // HDMI is disabled
}
func PersistHDMIIdleTimeout(minutes int) {
if err := os.WriteFile(HDMIIdleTimeoutFile, []byte(strconv.Itoa(minutes)), 0644); err != nil {
log.Error("failed to persist hdmi idle timeout:", err)
}
}
func GetHDMIIdleTimeout() int {
data, err := os.ReadFile(HDMIIdleTimeoutFile)
if err != nil {
if !os.IsNotExist(err) {
log.Error("failed to read hdmi idle timeout:", err)
}
return DefaultHDMIIdleTimeout
}
minutes, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil || minutes < 0 || minutes > MaxHDMIIdleTimeoutMinutes {
log.Error("invalid hdmi idle timeout")
return DefaultHDMIIdleTimeout
}
return minutes
}

View File

@@ -34,6 +34,7 @@
#define default_mjpeg_qlty 60
#define default_h264_qlty 1000
#define default_h264_gop 30
#define fresh_frame_discard_count 5
#define kvmv_data_buffer_size 4
#define Try_rounds_HDMI_err_res 5
@@ -96,6 +97,7 @@ typedef struct {
uint8_t hdmi_try_rounds = 0;
uint8_t vi_detect_state = 0;
uint8_t venc_auto_recyc = 0;
uint8_t fresh_frame_count = 0;
} kvmv_cfg_t;
typedef struct {
@@ -1438,12 +1440,21 @@ uint8_t frame_changed(image::Image *raw)
return ret;
}
void jpg_dump(kvmv_data_t* dump_to, image::Image *raw)
bool jpg_dump(kvmv_data_t* dump_to, image::Image *raw)
{
if(dump_to == NULL || raw == NULL || raw->data() == NULL || raw->data_size() == 0){
return false;
}
dump_to->p_img_data = (uint8_t *)malloc(raw->data_size());
if(dump_to->p_img_data == NULL){
dump_to->img_data_size = 0;
dump_to->img_data_type = 0;
return false;
}
dump_to->img_data_size = raw->data_size();
dump_to->img_data_type = VENC_MJPEG;
memcpy(dump_to->p_img_data, (uint8_t *)raw->data(), raw->data_size());
return true;
}
uint8_t kvmvenc_gop = default_h264_gop;
@@ -1684,6 +1695,8 @@ void set_venc_auto_recyc(uint8_t _enable)
**********************************************************************************/
int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _qlty, uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size)
{
*_pp_kvm_data = NULL;
*_p_kvmv_data_size = 0;
static uint8_t frame_undetact_count = 0;
// uint64_t __attribute__((unused)) start_time = time::time_ms();
debug("[kvmv]kvmv_read_img type = %d...\n", _type);
@@ -1743,6 +1756,16 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
// debug("[kvmv]read img: %d \r\n", (int)(time::time_ms() - start_time));
if(img != NULL){
if(kvmv_cfg.fresh_frame_count != 0){
// Do not restart VI after HDMI idle. Reopening the MMF
// channel can exhaust the carveout heap when the detector
// thread is also transitioning. Consume queued frames
// instead; the camera buffer contains at most three frames.
kvmv_cfg.fresh_frame_count--;
delete img;
continue;
}
// frame detect
if(_type == VENC_MJPEG && kvmv_cfg.frame_detact != 0){
if(kvmv_cfg.stream_stop == 0){
@@ -1801,18 +1824,22 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
kvmv_cfg.venc_type = _type;
if(kvmv_cfg.venc_type == VENC_MJPEG){
image::Image *jpg = img->to_jpeg(maxmin_data(99, 51, (int)_qlty));
kvmv_data_t* p_kvmv_data = get_save_buffer();
if(p_kvmv_data == NULL){
// buffer full
delete jpg;
delete img;
debug("[kvmv]jpg buffer full\n");
*_pp_kvm_data = NULL;
pthread_mutex_unlock(&vi_mutex);
return IMG_BUFFER_FULL;
}
jpg_dump(p_kvmv_data, jpg);
image::Image *jpg = img->to_jpeg(maxmin_data(99, 51, (int)_qlty));
if(jpg == NULL || !jpg_dump(p_kvmv_data, jpg)){
delete jpg;
delete img;
debug("[kvmv]failed to allocate jpg buffer\n");
pthread_mutex_unlock(&vi_mutex);
return IMG_BUFFER_FULL;
}
delete jpg;
delete img;
*_pp_kvm_data = p_kvmv_data->p_img_data;
@@ -1868,7 +1895,7 @@ int free_kvmv_data(uint8_t ** _pp_kvm_data)
void free_all_kvmv_data()
{
for(int i = 0; i <= kvmv_data_buffer_size; i++){
for(int i = 0; i < kvmv_data_buffer_size; i++){
if(kvmv_data_buffer[i].p_img_data != NULL){
free(kvmv_data_buffer[i].p_img_data);
kvmv_data_buffer[i].p_img_data = NULL;
@@ -1922,6 +1949,11 @@ uint8_t kvmv_hdmi_control(uint8_t _en)
} else {
kvmv_cfg.hdmi_stop_flag = 0;
system("echo 1 > /sys/class/gpio/gpio451/value");
// Keep the existing VI channel and consume queued frames after idle.
// Reopening it here or from kvmv_read_img can exhaust the carveout
// heap while the HDMI detector is transitioning.
kvmv_cfg.fresh_frame_count = fresh_frame_discard_count;
return 0;
}
return -1;

View File

@@ -1293,6 +1293,8 @@ int mmf_vi_init(void)
s32Ret = _mmf_vpss_init_new(0, priv.vi_size.u32Width, priv.vi_size.u32Height, PIXEL_FORMAT_UYVY); // PIXEL_FORMAT_UYVY PIXEL_FORMAT_NV21
if (s32Ret != CVI_SUCCESS) {
SAMPLE_PRT("_mmf_vpss_init_new failed. s32Ret: 0x%x !\n", s32Ret);
priv.vi_is_inited = false;
return s32Ret;
}
priv.vi_is_inited = true;
@@ -1505,9 +1507,22 @@ int mmf_vi_frame_pop(int ch, void **data, int *len, int *width, int *height, int
int image_size = frame->stVFrame.u32Length[0]
+ frame->stVFrame.u32Length[1]
+ frame->stVFrame.u32Length[2];
CVI_VOID *vir_addr;
vir_addr = CVI_SYS_MmapCache(frame->stVFrame.u64PhyAddr[0], image_size);
CVI_SYS_IonInvalidateCache(frame->stVFrame.u64PhyAddr[0], vir_addr, image_size);
if (frame->stVFrame.u64PhyAddr[0] == 0 || image_size <= 0) {
SAMPLE_PRT("invalid VI frame address or size\n");
CVI_VPSS_ReleaseChnFrame(0, ch, frame);
memset(frame, 0, sizeof(*frame));
return -1;
}
CVI_VOID *vir_addr;
vir_addr = CVI_SYS_MmapCache(frame->stVFrame.u64PhyAddr[0], image_size);
if (vir_addr == NULL) {
SAMPLE_PRT("CVI_SYS_MmapCache failed for VI frame\n");
CVI_VPSS_ReleaseChnFrame(0, ch, frame);
memset(frame, 0, sizeof(*frame));
return -1;
}
CVI_SYS_IonInvalidateCache(frame->stVFrame.u64PhyAddr[0], vir_addr, image_size);
frame->stVFrame.pu8VirAddr[0] = (CVI_U8 *)vir_addr; // save virtual address for munmap
priv.vi_frame_valid[ch] = true;

View File

@@ -79,7 +79,7 @@ namespace maix::camera
}
if (0 != mmf_vi_init()) {
err::check_raise(err::ERR_RUNTIME, "mmf vi init failed");
log::error("mmf vi init failed");
}
}
@@ -97,7 +97,7 @@ namespace maix::camera
}
if (0 != mmf_vi_init()) {
err::check_raise(err::ERR_RUNTIME, "mmf vi init failed");
log::error("mmf vi init failed");
}
}

View File

@@ -135,8 +135,9 @@ namespace maix::camera
if (open) {
e = this->open(_width, _height, _format, _buff_num);
// err::check_raise(e, "camera open failed");
if (e != err::ERR_NONE) {
log::error("camera open failed: %d", (int)e);
}
}
}
@@ -422,4 +423,3 @@ namespace maix::camera
return _impl->vflip(value);
}
}

View File

@@ -85,6 +85,11 @@ export function setHdmiState(enabled: boolean) {
return disableHdmi();
}
// set HDMI idle timeout in minutes
export function setHdmiIdleTimeout(minutes: number) {
return http.post('/api/vm/hdmi/timeout', { minutes });
}
// get SSH state
export function getSSHState() {
return http.get('/api/vm/ssh');

View File

@@ -369,7 +369,10 @@ const ca = {
tip: 'Desactiva-ho si no és necessari'
},
hdmi: {
description: 'Activa la sortida HDMI'
description: 'Activa la sortida HDMI',
idleTimeoutTitle: "Temps d'espera d'inactivitat de captura",
idleTimeoutDescription: "Atura la captura HDMI després de no detectar espectadors actius durant",
minutes: 'min'
},
autostart: {
title: "Configuració dels scripts d'inici automàtic",

View File

@@ -372,7 +372,10 @@ const cz = {
tip: 'Vypnutí, pokud to není potřeba'
},
hdmi: {
description: 'Povolit výstup HDMI/monitor'
description: 'Povolit výstup HDMI/monitor',
idleTimeoutTitle: 'Časový limit nečinnosti snímání',
idleTimeoutDescription: 'Zastavit snímání HDMI po době bez aktivních diváků',
minutes: 'min'
},
autostart: {
title: 'Nastavení automatického spuštění skriptů',

View File

@@ -369,7 +369,10 @@ const da = {
tip: 'Slukker den, hvis den ikke er nødvendig'
},
hdmi: {
description: 'Aktiver HDMI/monitor output'
description: 'Aktiver HDMI/monitor output',
idleTimeoutTitle: 'Timeout for inaktiv optagelse',
idleTimeoutDescription: 'Stop HDMI-optagelse efter en periode uden aktive seere på',
minutes: 'min'
},
autostart: {
title: 'Indstillinger for autostart scripts',

View File

@@ -375,7 +375,10 @@ const de = {
tip: 'Deaktivieren Sie den Dienst, wenn Sie ihn nicht benötigen'
},
hdmi: {
description: 'HDMI/Monitor-Ausgabe aktivieren'
description: 'HDMI/Monitor-Ausgabe aktivieren',
idleTimeoutTitle: 'Zeitlimit für inaktive Aufnahme',
idleTimeoutDescription: 'HDMI-Aufnahme stoppen, wenn keine aktiven Zuschauer vorhanden sind für',
minutes: 'Min.'
},
autostart: {
title: 'Autostart-Skripteinstellungen',

View File

@@ -368,7 +368,10 @@ const en = {
tip: "Turning it off if it's not needed"
},
hdmi: {
description: 'Enable HDMI/monitor output'
description: 'Enable HDMI/monitor output',
idleTimeoutTitle: 'Capture idle timeout',
idleTimeoutDescription: 'Stop HDMI capture after there are no active viewers for',
minutes: 'min'
},
autostart: {
title: 'Autostart Scripts Settings',

View File

@@ -372,7 +372,10 @@ const es = {
tip: 'Desactívalo si no es necesario'
},
hdmi: {
description: 'Habilitar salida HDMI/monitor'
description: 'Habilitar salida HDMI/monitor',
idleTimeoutTitle: 'Tiempo de espera de captura inactiva',
idleTimeoutDescription: 'Detener la captura HDMI después de no haber espectadores activos durante',
minutes: 'min'
},
autostart: {
title: 'Configuración de scripts de inicio automático',

View File

@@ -374,7 +374,10 @@ const fr = {
tip: "L'éteindre si ce n'est pas nécessaire"
},
hdmi: {
description: 'Activer HDMI/sortie moniteur'
description: 'Activer HDMI/sortie moniteur',
idleTimeoutTitle: "Délai d'inactivité de la capture",
idleTimeoutDescription: "Arrêter la capture HDMI lorsqu'il n'y a aucun spectateur actif pendant",
minutes: 'min'
},
autostart: {
title: 'Paramètres des scripts de démarrage automatique',

View File

@@ -373,7 +373,10 @@ const hu = {
tip: 'Kikapcsolás, ha nincs rá szükség'
},
hdmi: {
description: 'HDMI/monitor kimenet engedélyezése'
description: 'HDMI/monitor kimenet engedélyezése',
idleTimeoutTitle: 'Inaktív rögzítés időkorlátja',
idleTimeoutDescription: 'A HDMI-rögzítés leállítása, ha nincs aktív néző ennyi ideig:',
minutes: 'perc'
},
autostart: {
title: 'Automatikus indítási parancsfájlok beállításai',

View File

@@ -371,7 +371,10 @@ const id = {
tip: 'Mematikan jika tidak diperlukan'
},
hdmi: {
description: 'Aktifkan keluaran HDMI/monitor'
description: 'Aktifkan keluaran HDMI/monitor',
idleTimeoutTitle: 'Batas waktu tangkapan tidak aktif',
idleTimeoutDescription: 'Hentikan tangkapan HDMI setelah tidak ada penonton aktif selama',
minutes: 'mnt'
},
autostart: {
title: 'Pengaturan Skrip Mulai Otomatis',

View File

@@ -373,7 +373,10 @@ const it = {
tip: 'Spegnerlo se non è necessario'
},
hdmi: {
description: 'Abilita HDMI/monitora uscita'
description: 'Abilita HDMI/monitora uscita',
idleTimeoutTitle: 'Timeout cattura inattiva',
idleTimeoutDescription: 'Interrompi la cattura HDMI dopo che non ci sono visualizzatori attivi per',
minutes: 'min'
},
autostart: {
title: 'Impostazioni script di avvio automatico',

View File

@@ -372,7 +372,10 @@ const ja = {
tip: 'この機能を使用していない場合は、オフにすることをお勧めします'
},
hdmi: {
description: 'HDMI/モニター 出力機能を有効にする'
description: 'HDMI/モニター 出力機能を有効にする',
idleTimeoutTitle: 'キャプチャのアイドルタイムアウト',
idleTimeoutDescription: 'アクティブな閲覧者がいない状態が次の時間続いたら HDMI キャプチャを停止',
minutes: '分'
},
autostart: {
title: '自動起動スクリプト設定',

View File

@@ -367,7 +367,10 @@ const ko = {
tip: '사용하지 않는 경우 끄는 것이 좋습니다'
},
hdmi: {
description: 'HDMI/모니터 출력 활성화'
description: 'HDMI/모니터 출력 활성화',
idleTimeoutTitle: '캡처 유휴 시간 제한',
idleTimeoutDescription: '활성 시청자가 없는 상태가 다음 시간 동안 지속되면 HDMI 캡처 중지',
minutes: '분'
},
autostart: {
title: '자동 시작 스크립트 설정',

View File

@@ -370,7 +370,10 @@ const nb = {
tip: 'Slå den av hvis den ikke er nødvendig'
},
hdmi: {
description: 'Aktiver HDMI/skjermutgang'
description: 'Aktiver HDMI/skjermutgang',
idleTimeoutTitle: 'Tidsavbrudd for inaktivt opptak',
idleTimeoutDescription: 'Stopp HDMI-opptak etter at det ikke har vært aktive seere i',
minutes: 'min'
},
autostart: {
title: 'Autostart skriptinnstillinger',

View File

@@ -373,7 +373,10 @@ const nl = {
tip: 'Schakel het uit als het niet nodig is'
},
hdmi: {
description: 'Schakel HDMI/monitoruitgang in'
description: 'Schakel HDMI/monitoruitgang in',
idleTimeoutTitle: 'Time-out voor inactieve opname',
idleTimeoutDescription: 'HDMI-opname stoppen nadat er gedurende deze tijd geen actieve kijkers zijn:',
minutes: 'min'
},
autostart: {
title: 'Instellingen voor automatisch starten van scripts',

View File

@@ -372,7 +372,10 @@ const pl = {
tip: 'Wyłączanie, jeśli nie jest potrzebne'
},
hdmi: {
description: 'Włącz HDMI/wyjście monitora'
description: 'Włącz HDMI/wyjście monitora',
idleTimeoutTitle: 'Limit czasu bezczynności przechwytywania',
idleTimeoutDescription: 'Zatrzymaj przechwytywanie HDMI po czasie bez aktywnych widzów:',
minutes: 'min'
},
autostart: {
title: 'Ustawienia skryptów autostartu',

View File

@@ -371,7 +371,10 @@ const pt_br = {
tip: 'Desligue se não for necessário'
},
hdmi: {
description: 'Habilitar saída HDMI/monitor'
description: 'Habilitar saída HDMI/monitor',
idleTimeoutTitle: 'Tempo limite de captura inativa',
idleTimeoutDescription: 'Parar a captura HDMI após não haver visualizadores ativos por',
minutes: 'min'
},
autostart: {
title: 'Configurações de scripts de inicialização automática',

View File

@@ -372,7 +372,10 @@ const ru = {
tip: 'Выключите, если нет необходимости'
},
hdmi: {
description: 'Включить HDMI/выход монитора'
description: 'Включить HDMI/выход монитора',
idleTimeoutTitle: 'Тайм-аут неактивного захвата',
idleTimeoutDescription: 'Остановить захват HDMI, если активных зрителей нет в течение',
minutes: 'мин'
},
autostart: {
title: 'Настройки сценариев автозапуска',

View File

@@ -368,7 +368,10 @@ const se = {
tip: 'Stäng av om det inte behövs'
},
hdmi: {
description: 'Aktivera HDMI/monitorutgång'
description: 'Aktivera HDMI/monitorutgång',
idleTimeoutTitle: 'Tidsgräns för inaktiv inspelning',
idleTimeoutDescription: 'Stoppa HDMI-inspelning efter att det inte har funnits aktiva tittare i',
minutes: 'min'
},
autostart: {
title: 'Autostart skriptinställningar',

View File

@@ -364,7 +364,10 @@ const th = {
tip: 'ปิดเครื่องหากไม่จำเป็น'
},
hdmi: {
description: 'เปิดใช้งาน HDMI/เอาต์พุตมอนิเตอร์'
description: 'เปิดใช้งาน HDMI/เอาต์พุตมอนิเตอร์',
idleTimeoutTitle: 'หมดเวลาการจับภาพเมื่อไม่มีการใช้งาน',
idleTimeoutDescription: 'หยุดการจับภาพ HDMI เมื่อไม่มีผู้ชมที่ใช้งานอยู่เป็นเวลา',
minutes: 'นาที'
},
autostart: {
title: 'การตั้งค่าสคริปต์เริ่มอัตโนมัติ',

View File

@@ -370,7 +370,10 @@ const tr = {
tip: 'Kullanmıyorsanız devre dışı bırakabilirsiniz'
},
hdmi: {
description: 'HDMI/Momitör çıktısını aktifleştir'
description: 'HDMI/Momitör çıktısını aktifleştir',
idleTimeoutTitle: 'Etkin olmayan yakalama zaman aşımı',
idleTimeoutDescription: 'Etkin görüntüleyici olmadığında HDMI yakalamayı şu süre sonunda durdur:',
minutes: 'dk'
},
autostart: {
title: 'Otomatik Başlatılan Komut Dosyaları Ayarları',

View File

@@ -371,7 +371,10 @@ const uk = {
tip: 'Вимкнути, якщо це не потрібно'
},
hdmi: {
description: 'Увімкнути вихід HDMI/monitor'
description: 'Увімкнути вихід HDMI/monitor',
idleTimeoutTitle: 'Час очікування неактивного захоплення',
idleTimeoutDescription: 'Зупинити захоплення HDMI, якщо активних глядачів немає протягом',
minutes: 'хв'
},
autostart: {
title: 'Налаштування сценаріїв автозапуску',

View File

@@ -368,7 +368,10 @@ const vi = {
tip: 'Tắt đi nếu không cần thiết'
},
hdmi: {
description: 'Kích hoạt HDMI/đầu ra màn hình'
description: 'Kích hoạt HDMI/đầu ra màn hình',
idleTimeoutTitle: 'Thời gian chờ khi không hoạt động',
idleTimeoutDescription: 'Dừng việc ghi hình HDMI sau khi không có người xem hoạt động trong',
minutes: 'phút'
},
autostart: {
title: 'Cài đặt tập lệnh tự khởi động',

View File

@@ -359,7 +359,10 @@ const zh = {
tip: '如果您未使用此功能,建议将其关闭'
},
hdmi: {
description: '启用 HDMI/显示器 输出功能'
description: '启用 HDMI/显示器 输出功能',
idleTimeoutTitle: '无观看者自动停止采集',
idleTimeoutDescription: '没有活跃观看者后停止 HDMI 采集0 表示永不停止',
minutes: '分钟'
},
autostart: {
title: '自动启动脚本设置',

View File

@@ -359,7 +359,10 @@ const zh_tw = {
tip: '若無需求,建議關閉此功能'
},
hdmi: {
description: '啟用 HDMI/螢幕 輸出'
description: '啟用 HDMI/螢幕 輸出',
idleTimeoutTitle: '擷取閒置逾時',
idleTimeoutDescription: '沒有活躍觀看者時,在指定時間後停止 HDMI 擷取',
minutes: '分鐘'
},
autostart: {
title: '啟動時指令碼設定',

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Switch } from 'antd';
import { InputNumber, Switch } from 'antd';
import { useAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
@@ -13,6 +13,9 @@ export const Hdmi = () => {
const [isPcie, setIsPcie] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [idleTimeout, setIdleTimeout] = useState(0);
const [idleTimeoutInput, setIdleTimeoutInput] = useState<number | null>(0);
const [isIdleTimeoutLoading, setIsIdleTimeoutLoading] = useState(false);
useEffect(() => {
getHardware();
@@ -34,11 +37,40 @@ export const Hdmi = () => {
const rsp = await api.getHdmiState();
if (rsp.code === 0) {
setIsHdmiEnabled(rsp.data.enabled);
const timeout = rsp.data.idleTimeout ?? 0;
setIdleTimeout(timeout);
setIdleTimeoutInput(timeout);
}
setIsLoading(false);
}
function updateIdleTimeout() {
if (
isIdleTimeoutLoading ||
idleTimeoutInput === null ||
idleTimeoutInput < 0 ||
idleTimeoutInput === idleTimeout
) {
return;
}
setIsIdleTimeoutLoading(true);
api
.setHdmiIdleTimeout(idleTimeoutInput)
.then((rsp) => {
if (rsp.code !== 0) {
setIdleTimeoutInput(idleTimeout);
return;
}
setIdleTimeout(idleTimeoutInput);
})
.finally(() => {
setIsIdleTimeoutLoading(false);
});
}
async function setHdmiState() {
if (isLoading) return;
setIsLoading(true);
@@ -60,16 +92,40 @@ export const Hdmi = () => {
return (
<>
{isPcie && (
<div className="flex items-center justify-between">
<div className="flex flex-col space-y-1">
<span>HDMI</span>
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col space-y-1">
<span>HDMI</span>
<span className="text-xs text-neutral-500">
{t('settings.device.hdmi.description')}
</span>
<span className="text-xs text-neutral-500">
{t('settings.device.hdmi.description')}
</span>
</div>
<Switch checked={isHdmiEnabled} loading={isLoading} onChange={setHdmiState} />
</div>
<Switch checked={isHdmiEnabled} loading={isLoading} onChange={setHdmiState} />
<div className="flex items-center justify-between">
<div className="flex flex-col space-y-1">
<span>{t('settings.device.hdmi.idleTimeoutTitle')}</span>
<span className="text-xs text-neutral-500">
{t('settings.device.hdmi.idleTimeoutDescription')}
</span>
</div>
<InputNumber
style={{ width: 150 }}
min={0}
max={10080}
precision={0}
value={idleTimeoutInput}
addonAfter={t('settings.device.hdmi.minutes')}
disabled={isIdleTimeoutLoading}
onChange={setIdleTimeoutInput}
onBlur={updateIdleTimeout}
onPressEnter={updateIdleTimeout}
/>
</div>
</div>
)}
</>

View File

@@ -135,12 +135,16 @@ export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSess
}
]);
const sent = sendChatMessage(content, {
const task = sendChatMessage(content, {
id,
maxSteps: config.maxSteps,
maxRuntimeMs: config.maxRuntimeMs
});
return sent !== null;
if (task === null) {
setMessages((current) => current.filter((message) => message.id !== id));
return false;
}
return true;
}
async function handleReconnectGateway() {