feat(v2): T-309 test-connection button with result popup

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 <noreply@anthropic.com>
This commit is contained in:
ila
2026-07-23 22:05:52 +08:00
co-authored by Claude Opus 4.8
parent aced262f6b
commit 4b7dfc2db6
4 changed files with 138 additions and 6 deletions
+3 -2
View File
@@ -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,
+3 -3
View File
@@ -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),
+6
View File
@@ -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")
+126 -1
View File
@@ -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 {