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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user