From 4b7dfc2db65f7d499ea342f6b300df19951a3239 Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 23 Jul 2026 22:05:52 +0800 Subject: [PATCH] feat(v2): T-309 test-connection button with result popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 测试连接摄像头 button next to save; it builds the RTSP URL from the current form fields and runs ffprobe (8s timeout) off the UI thread, then shows a result popup (success WxH@fps, or a redacted failure). Exports config.BuildRTSPURL and source.Probe/Redact for it. Cross-compiles for Windows; config/source tests pass. Co-Authored-By: Claude Opus 4.8 --- v2/cmd/silver-pose/main.go | 5 +- v2/internal/config/config.go | 6 +- v2/internal/source/stream.go | 6 ++ v2/internal/ui/window.go | 127 ++++++++++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 6 deletions(-) diff --git a/v2/cmd/silver-pose/main.go b/v2/cmd/silver-pose/main.go index 35f855e..60f281f 100644 --- a/v2/cmd/silver-pose/main.go +++ b/v2/cmd/silver-pose/main.go @@ -187,8 +187,9 @@ func settingsFor(cfg config.Config, configPath string) ui.Settings { EventSummary: fmt.Sprintf("确认 %.1f 秒;水平角 %.0f°;快速下移=%t;下肢=%t", cfg.Event.ConfirmWindowSeconds, cfg.Event.HorizontalAngleThresholdDegrees, cfg.Event.RequireRapidDrop, cfg.Event.RequireLowerBody), - ConfigPath: configPath, - Camera: cameraFieldsFrom(configPath), + FFprobePath: cfg.Tools.FFprobePath, + ConfigPath: configPath, + Camera: cameraFieldsFrom(configPath), Params: ui.ParamFields{ KeypointConfidence: float64(cfg.Event.KeypointConfidenceThreshold), ConfirmWindowSeconds: cfg.Event.ConfirmWindowSeconds, diff --git a/v2/internal/config/config.go b/v2/internal/config/config.go index 2c02d1f..af21bf8 100644 --- a/v2/internal/config/config.go +++ b/v2/internal/config/config.go @@ -248,11 +248,11 @@ func structuredSourceURL(raw rawSource) (string, error) { if raw.Channel != nil && strings.TrimSpace(*raw.Channel) != "" { channel = strings.TrimSpace(*raw.Channel) } - return buildRTSPURL(host, port, strings.TrimSpace(*raw.Username), *raw.Password, channel), nil + return BuildRTSPURL(host, port, strings.TrimSpace(*raw.Username), *raw.Password, channel), nil } -// buildRTSPURL assembles a Hikvision RTSP URL with percent-encoded credentials. -func buildRTSPURL(host string, port int, username, password, channel string) string { +// BuildRTSPURL assembles a Hikvision RTSP URL with percent-encoded credentials. +func BuildRTSPURL(host string, port int, username, password, channel string) string { address := url.URL{ Scheme: "rtsp", User: url.UserPassword(username, password), diff --git a/v2/internal/source/stream.go b/v2/internal/source/stream.go index 1f97ce2..f25012d 100644 --- a/v2/internal/source/stream.go +++ b/v2/internal/source/stream.go @@ -199,6 +199,12 @@ func waitContext(ctx context.Context, duration time.Duration) error { } } +// Probe attempts to read stream metadata once, for a settings "test connection". +func Probe(ctx context.Context, config Config) (Metadata, error) { return probe(ctx, config) } + +// Redact removes the source URL/credentials from text for safe display. +func Redact(text, sourceURL string) string { return redact(text, sourceURL) } + func probe(ctx context.Context, config Config) (Metadata, error) { if strings.TrimSpace(config.FFprobePath) == "" { return Metadata{}, errors.New("FFprobe executable is not configured") diff --git a/v2/internal/ui/window.go b/v2/internal/ui/window.go index 9c7123e..73b207b 100644 --- a/v2/internal/ui/window.go +++ b/v2/internal/ui/window.go @@ -1,6 +1,7 @@ package ui import ( + "context" "fmt" "image" "image/color" @@ -27,6 +28,7 @@ import ( "silverpose/v2/internal/config" "silverpose/v2/internal/fall" "silverpose/v2/internal/monitor" + "silverpose/v2/internal/source" ) const ( @@ -52,6 +54,7 @@ type Settings struct { ModelSHA256 string RuntimeSummary string EventSummary string + FFprobePath string ConfigPath string Camera CameraFields Params ParamFields @@ -152,9 +155,15 @@ type Window struct { requireLower widget.Bool saveCamera widget.Clickable saveParams widget.Clickable + testConn widget.Clickable + testClose widget.Clickable settingsStatus string settingsList widget.List cameraID string + ffprobePath string + testMessage string + showTest bool + testing bool } func setEditor(editor *widget.Editor, value string) { @@ -217,6 +226,7 @@ func NewWindow(settings Settings, commands Commands) (*Window, error) { window.requireLower.Value = params.RequireLowerBody window.settingsList.Axis = layout.Vertical window.cameraID = defaultString(camera.ID, "ip-camera") + window.ffprobePath = settings.FFprobePath return window, nil } @@ -365,6 +375,8 @@ func (window *Window) layout(gtx layout.Context) layout.Dimensions { pendingAt := window.pendingAt incoming := window.incoming running := window.running + showTest := window.showTest + testMessage := window.testMessage window.incoming = nil window.mu.Unlock() @@ -394,6 +406,12 @@ func (window *Window) layout(gtx layout.Context) layout.Dimensions { if window.viewerClose.Clicked(gtx) { window.viewing = nil } + if window.testClose.Clicked(gtx) { + window.mu.Lock() + window.showTest = false + window.mu.Unlock() + showTest = false + } // Esc closes the full-image viewer. for { event, ok := gtx.Event(key.Filter{Focus: window, Name: key.NameEscape}) @@ -441,6 +459,16 @@ func (window *Window) layout(gtx layout.Context) layout.Dimensions { if window.viewing != nil { children = append(children, layout.Expanded(scrim), layout.Expanded(window.layoutViewer)) } + if showTest { + children = append(children, + layout.Expanded(scrim), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return window.layoutTestDialog(gtx, testMessage) + }) + }), + ) + } if pending != nil { record := *pending children = append(children, @@ -705,6 +733,9 @@ func (window *Window) layoutSettings(gtx layout.Context, model ViewModel) layout if window.saveParams.Clicked(gtx) { window.onSaveParams() } + if window.testConn.Clicked(gtx) { + window.onTestConnection() + } cameraCard := verticalRows( window.heading("摄像头连接(账号密码仅存本地配置,下次启动生效)"), window.editorRow("IP / 主机", &window.editHost), @@ -715,7 +746,7 @@ func (window *Window) layoutSettings(gtx layout.Context, model ViewModel) layout window.editorRow("传输 (tcp/udp)", &window.editTransport), window.editorRow("连接超时 (秒)", &window.editTimeout), window.checkRow("低延迟", &window.lowLatency), - window.buttonRow(&window.saveCamera, "保存摄像头参数"), + window.cameraButtonsRow(), ) paramsCard := verticalRows( window.heading("常用检测参数(下次启动生效)"), @@ -857,6 +888,100 @@ func (window *Window) onSaveParams() { window.settingsStatus = "参数已保存,将在下次开始监控时生效" } +func (window *Window) cameraButtonsRow() layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return smallButton(gtx, window.theme, &window.saveCamera, "保存摄像头参数") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return smallButton(gtx, window.theme, &window.testConn, "测试连接摄像头") + }), + ) + } +} + +func (window *Window) onTestConnection() { + window.mu.Lock() + if window.testing { + window.mu.Unlock() + return + } + window.mu.Unlock() + + host := strings.TrimSpace(window.editHost.Text()) + username := strings.TrimSpace(window.editUsername.Text()) + password := window.editPassword.Text() + channel := strings.TrimSpace(window.editChannel.Text()) + transport := strings.ToLower(strings.TrimSpace(window.editTransport.Text())) + port, portErr := strconv.Atoi(strings.TrimSpace(window.editPort.Text())) + timeout, _ := strconv.ParseFloat(strings.TrimSpace(window.editTimeout.Text()), 64) + if host == "" || username == "" || password == "" { + window.setTestResult("请先填写主机、账号和密码", false) + return + } + if portErr != nil || port < 1 || port > 65535 { + window.setTestResult("端口无效", false) + return + } + if timeout <= 0 { + timeout = 5 + } + window.setTestResult("正在测试连接…", true) + go func() { + streamURL := config.BuildRTSPURL(host, port, username, password, channel) + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + metadata, err := source.Probe(ctx, source.Config{ + SourceURL: streamURL, FFprobePath: window.ffprobePath, Transport: transport, + Timeout: time.Duration(timeout * float64(time.Second)), + }) + message := fmt.Sprintf("连接成功:%dx%d @ %.1f fps", metadata.Width, metadata.Height, metadata.FPS) + if err != nil { + message = "连接失败:" + source.Redact(err.Error(), streamURL) + } + window.setTestResult(message, false) + }() +} + +func (window *Window) setTestResult(message string, testing bool) { + window.mu.Lock() + window.testMessage = message + window.showTest = true + window.testing = testing + window.mu.Unlock() + window.appWindow.Invalidate() +} + +func (window *Window) layoutTestDialog(gtx layout.Context, message string) layout.Dimensions { + width := gtx.Dp(440) + if width > gtx.Constraints.Max.X { + width = gtx.Constraints.Max.X + } + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + rect := image.Rectangle{Max: gtx.Constraints.Min} + paint.FillShape(gtx.Ops, colorSurface, clip.UniformRRect(rect, gtx.Dp(8)).Op(gtx.Ops)) + return layout.Dimensions{Size: gtx.Constraints.Min} + }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.H6(window.theme, "测试连接摄像头").Layout), + layout.Rigid(material.Body1(window.theme, message).Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return smallButton(gtx, window.theme, &window.testClose, "关闭") + }), + ) + }) + }), + ) +} + func highestState(result fall.FrameResult) fall.State { state := fall.Normal for _, person := range result.People {