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