mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
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:
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
145
server/service/picoclaw/capture_lease.go
Normal file
145
server/service/picoclaw/capture_lease.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ type WebRTCManager struct {
|
||||
clientSnapshot atomic.Pointer[[]*Client]
|
||||
videoSending int32
|
||||
mutex sync.Mutex
|
||||
viewerVersion uint64
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
100
server/service/vm/hdmi_state/state.go
Normal file
100
server/service/vm/hdmi_state/state.go
Normal 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
|
||||
}
|
||||
98
server/service/vm/hdmi_state/state_test.go
Normal file
98
server/service/vm/hdmi_state/state_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ů',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -372,7 +372,10 @@ const ja = {
|
||||
tip: 'この機能を使用していない場合は、オフにすることをお勧めします'
|
||||
},
|
||||
hdmi: {
|
||||
description: 'HDMI/モニター 出力機能を有効にする'
|
||||
description: 'HDMI/モニター 出力機能を有効にする',
|
||||
idleTimeoutTitle: 'キャプチャのアイドルタイムアウト',
|
||||
idleTimeoutDescription: 'アクティブな閲覧者がいない状態が次の時間続いたら HDMI キャプチャを停止',
|
||||
minutes: '分'
|
||||
},
|
||||
autostart: {
|
||||
title: '自動起動スクリプト設定',
|
||||
|
||||
@@ -367,7 +367,10 @@ const ko = {
|
||||
tip: '사용하지 않는 경우 끄는 것이 좋습니다'
|
||||
},
|
||||
hdmi: {
|
||||
description: 'HDMI/모니터 출력 활성화'
|
||||
description: 'HDMI/모니터 출력 활성화',
|
||||
idleTimeoutTitle: '캡처 유휴 시간 제한',
|
||||
idleTimeoutDescription: '활성 시청자가 없는 상태가 다음 시간 동안 지속되면 HDMI 캡처 중지',
|
||||
minutes: '분'
|
||||
},
|
||||
autostart: {
|
||||
title: '자동 시작 스크립트 설정',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -372,7 +372,10 @@ const ru = {
|
||||
tip: 'Выключите, если нет необходимости'
|
||||
},
|
||||
hdmi: {
|
||||
description: 'Включить HDMI/выход монитора'
|
||||
description: 'Включить HDMI/выход монитора',
|
||||
idleTimeoutTitle: 'Тайм-аут неактивного захвата',
|
||||
idleTimeoutDescription: 'Остановить захват HDMI, если активных зрителей нет в течение',
|
||||
minutes: 'мин'
|
||||
},
|
||||
autostart: {
|
||||
title: 'Настройки сценариев автозапуска',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -364,7 +364,10 @@ const th = {
|
||||
tip: 'ปิดเครื่องหากไม่จำเป็น'
|
||||
},
|
||||
hdmi: {
|
||||
description: 'เปิดใช้งาน HDMI/เอาต์พุตมอนิเตอร์'
|
||||
description: 'เปิดใช้งาน HDMI/เอาต์พุตมอนิเตอร์',
|
||||
idleTimeoutTitle: 'หมดเวลาการจับภาพเมื่อไม่มีการใช้งาน',
|
||||
idleTimeoutDescription: 'หยุดการจับภาพ HDMI เมื่อไม่มีผู้ชมที่ใช้งานอยู่เป็นเวลา',
|
||||
minutes: 'นาที'
|
||||
},
|
||||
autostart: {
|
||||
title: 'การตั้งค่าสคริปต์เริ่มอัตโนมัติ',
|
||||
|
||||
@@ -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ı',
|
||||
|
||||
@@ -371,7 +371,10 @@ const uk = {
|
||||
tip: 'Вимкнути, якщо це не потрібно'
|
||||
},
|
||||
hdmi: {
|
||||
description: 'Увімкнути вихід HDMI/monitor'
|
||||
description: 'Увімкнути вихід HDMI/monitor',
|
||||
idleTimeoutTitle: 'Час очікування неактивного захоплення',
|
||||
idleTimeoutDescription: 'Зупинити захоплення HDMI, якщо активних глядачів немає протягом',
|
||||
minutes: 'хв'
|
||||
},
|
||||
autostart: {
|
||||
title: 'Налаштування сценаріїв автозапуску',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -359,7 +359,10 @@ const zh = {
|
||||
tip: '如果您未使用此功能,建议将其关闭'
|
||||
},
|
||||
hdmi: {
|
||||
description: '启用 HDMI/显示器 输出功能'
|
||||
description: '启用 HDMI/显示器 输出功能',
|
||||
idleTimeoutTitle: '无观看者自动停止采集',
|
||||
idleTimeoutDescription: '没有活跃观看者后停止 HDMI 采集,0 表示永不停止',
|
||||
minutes: '分钟'
|
||||
},
|
||||
autostart: {
|
||||
title: '自动启动脚本设置',
|
||||
|
||||
@@ -359,7 +359,10 @@ const zh_tw = {
|
||||
tip: '若無需求,建議關閉此功能'
|
||||
},
|
||||
hdmi: {
|
||||
description: '啟用 HDMI/螢幕 輸出'
|
||||
description: '啟用 HDMI/螢幕 輸出',
|
||||
idleTimeoutTitle: '擷取閒置逾時',
|
||||
idleTimeoutDescription: '沒有活躍觀看者時,在指定時間後停止 HDMI 擷取',
|
||||
minutes: '分鐘'
|
||||
},
|
||||
autostart: {
|
||||
title: '啟動時指令碼設定',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user