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", EventSummary: fmt.Sprintf("确认 %.1f 秒;水平角 %.0f°;快速下移=%t;下肢=%t",
cfg.Event.ConfirmWindowSeconds, cfg.Event.HorizontalAngleThresholdDegrees, cfg.Event.ConfirmWindowSeconds, cfg.Event.HorizontalAngleThresholdDegrees,
cfg.Event.RequireRapidDrop, cfg.Event.RequireLowerBody), cfg.Event.RequireRapidDrop, cfg.Event.RequireLowerBody),
ConfigPath: configPath, FFprobePath: cfg.Tools.FFprobePath,
Camera: cameraFieldsFrom(configPath), ConfigPath: configPath,
Camera: cameraFieldsFrom(configPath),
Params: ui.ParamFields{ Params: ui.ParamFields{
KeypointConfidence: float64(cfg.Event.KeypointConfidenceThreshold), KeypointConfidence: float64(cfg.Event.KeypointConfidenceThreshold),
ConfirmWindowSeconds: cfg.Event.ConfirmWindowSeconds, 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) != "" { if raw.Channel != nil && strings.TrimSpace(*raw.Channel) != "" {
channel = 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. // BuildRTSPURL assembles a Hikvision RTSP URL with percent-encoded credentials.
func buildRTSPURL(host string, port int, username, password, channel string) string { func BuildRTSPURL(host string, port int, username, password, channel string) string {
address := url.URL{ address := url.URL{
Scheme: "rtsp", Scheme: "rtsp",
User: url.UserPassword(username, password), 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) { func probe(ctx context.Context, config Config) (Metadata, error) {
if strings.TrimSpace(config.FFprobePath) == "" { if strings.TrimSpace(config.FFprobePath) == "" {
return Metadata{}, errors.New("FFprobe executable is not configured") return Metadata{}, errors.New("FFprobe executable is not configured")
+126 -1
View File
@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"context"
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
@@ -27,6 +28,7 @@ import (
"silverpose/v2/internal/config" "silverpose/v2/internal/config"
"silverpose/v2/internal/fall" "silverpose/v2/internal/fall"
"silverpose/v2/internal/monitor" "silverpose/v2/internal/monitor"
"silverpose/v2/internal/source"
) )
const ( const (
@@ -52,6 +54,7 @@ type Settings struct {
ModelSHA256 string ModelSHA256 string
RuntimeSummary string RuntimeSummary string
EventSummary string EventSummary string
FFprobePath string
ConfigPath string ConfigPath string
Camera CameraFields Camera CameraFields
Params ParamFields Params ParamFields
@@ -152,9 +155,15 @@ type Window struct {
requireLower widget.Bool requireLower widget.Bool
saveCamera widget.Clickable saveCamera widget.Clickable
saveParams widget.Clickable saveParams widget.Clickable
testConn widget.Clickable
testClose widget.Clickable
settingsStatus string settingsStatus string
settingsList widget.List settingsList widget.List
cameraID string cameraID string
ffprobePath string
testMessage string
showTest bool
testing bool
} }
func setEditor(editor *widget.Editor, value string) { 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.requireLower.Value = params.RequireLowerBody
window.settingsList.Axis = layout.Vertical window.settingsList.Axis = layout.Vertical
window.cameraID = defaultString(camera.ID, "ip-camera") window.cameraID = defaultString(camera.ID, "ip-camera")
window.ffprobePath = settings.FFprobePath
return window, nil return window, nil
} }
@@ -365,6 +375,8 @@ func (window *Window) layout(gtx layout.Context) layout.Dimensions {
pendingAt := window.pendingAt pendingAt := window.pendingAt
incoming := window.incoming incoming := window.incoming
running := window.running running := window.running
showTest := window.showTest
testMessage := window.testMessage
window.incoming = nil window.incoming = nil
window.mu.Unlock() window.mu.Unlock()
@@ -394,6 +406,12 @@ func (window *Window) layout(gtx layout.Context) layout.Dimensions {
if window.viewerClose.Clicked(gtx) { if window.viewerClose.Clicked(gtx) {
window.viewing = nil 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. // Esc closes the full-image viewer.
for { for {
event, ok := gtx.Event(key.Filter{Focus: window, Name: key.NameEscape}) 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 { if window.viewing != nil {
children = append(children, layout.Expanded(scrim), layout.Expanded(window.layoutViewer)) 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 { if pending != nil {
record := *pending record := *pending
children = append(children, children = append(children,
@@ -705,6 +733,9 @@ func (window *Window) layoutSettings(gtx layout.Context, model ViewModel) layout
if window.saveParams.Clicked(gtx) { if window.saveParams.Clicked(gtx) {
window.onSaveParams() window.onSaveParams()
} }
if window.testConn.Clicked(gtx) {
window.onTestConnection()
}
cameraCard := verticalRows( cameraCard := verticalRows(
window.heading("摄像头连接(账号密码仅存本地配置,下次启动生效)"), window.heading("摄像头连接(账号密码仅存本地配置,下次启动生效)"),
window.editorRow("IP / 主机", &window.editHost), 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("传输 (tcp/udp)", &window.editTransport),
window.editorRow("连接超时 (秒)", &window.editTimeout), window.editorRow("连接超时 (秒)", &window.editTimeout),
window.checkRow("低延迟", &window.lowLatency), window.checkRow("低延迟", &window.lowLatency),
window.buttonRow(&window.saveCamera, "保存摄像头参数"), window.cameraButtonsRow(),
) )
paramsCard := verticalRows( paramsCard := verticalRows(
window.heading("常用检测参数(下次启动生效)"), window.heading("常用检测参数(下次启动生效)"),
@@ -857,6 +888,100 @@ func (window *Window) onSaveParams() {
window.settingsStatus = "参数已保存,将在下次开始监控时生效" 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 { func highestState(result fall.FrameResult) fall.State {
state := fall.Normal state := fall.Normal
for _, person := range result.People { for _, person := range result.People {