树莓派集成 PCA9685 舵机控制与流媒体服务器视频推流的综合应用

random-pic-api

简介

基于树莓派的视频推流方案 我们尝试了通过树莓派推流到流媒体服务器, 然后通过 Web 查看视频, 这次我们来尝试一下通过树莓派控制舵机.

想法这这样的, 使用一个 Web 页面实时展示 2 个摄像头的画面, 然后通过 PCA9685 舵机来控制摄像头角度.这样就可以实现一个简单的监控了.

PCA9685

PCA9685 是 NXP 生产的一款 16 通道 PWM(脉宽调制)控制器,主要用于驱动 LED 或舵机,广泛应用于机器人、灯光控制和 DIY 电子项目。

主要特点

  • 16 路独立 PWM 输出(每个通道 12 位分辨率,0~4096 可调)。
  • I²C 接口通信,地址可调(0x40~0x7F)。
  • 频率可调,支持 24Hz~1526Hz 的 PWM 频率。
  • 支持外部时钟(适用于需要更高精度的场景)。
  • 可编程 LED 亮度控制,支持单独和分组控制。
  • 工作电压:2.3V~5.5V(兼容 3.3V 和 5V 逻辑电平)。
  • 最大输出电流:每个通道 25mA(默认),最大 400mA(所有通道总电流)。

20250212192831_nZXdtUU9.webp

接线方式:

20250212211654_e9WCc1VN.webp

外接供电:

20250212211654_iXBUaeEi.webp

驱动板右侧的黑黄蓝红 4 条线的接法毫无争议。关键是最底下我自己加上的一根紫色的 v+ 线,这根线要连接至电源才能驱动舵机,至于是 3v 电源还是 5v 电源,是树莓派 GPIO 口提供的还是外接电源都无所谓,只要接上电源即可。

一般接 3v 的就够用了,如果有扩展板的话,就接到树莓派的 1 号 3v 供电口。如果没有拓展板的话,3v 供电口已经被驱动板的 vcc 供电口占了,那接树莓派 2 号 5v 供电口也是可以的,这是比较简洁的接线方式。反正舵机如果没动静,多半是电源线的问题。

一般情况下,config.txt 文件配置完成后,电源如果接通的话,无需任何代码舵机就会开始旋转至最大角度。

树莓派和舵机驱动板按照教程分别连接对应 GND,SDA.0,SCL0,VCC,V+ 即可.

注意是 SDA.0,SCL.0,不要连成了 SDA.1,SCL.1

舵机

购买的 SG90 MG90S 9g 舵机:

20250212192831_8GLVKcak.webp

树莓派配置

  1. 树莓派开启 I2C
1
sudo raspi-config -> 5.Interfacing Options -> P5 I2C 设置enable,然后重启树莓派
  1. i2c-tools 测试舵机连接状态
1
2
sudo apt-get install i2c-tools
sudo i2cdetect -y 1
  1. 使用 PCA9685 python 库控制舵机

    例子源码 在这里(example 目录下)

    1
    2
    sudo pip3 install adafruit-pca9685
    python3 ./simpletest.py

集成

启动流媒体服务器

上一篇文章中我已经写了一个启动脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#!/bin/bash

# 检查是否有 -h 参数
if [[ "$1" == "-h" ]]; then
echo "使用方法: $0 [协议] [摄像头编号] [宽度] [高度] [URL地址]"
echo "示例: $0 rtmp 0 1920 1080 192.168.21.7/pi5b, 最终URL: rtmp://192.168.21.7/pi5b/0"
echo
echo "参数说明:"
echo " 协议 : rtmp 或 rtsp (用于选择流媒体传输协议)"
echo " 摄像头编号 : 摄像头编号 (例如 0 或 1)"
echo " 宽度 : 分辨率宽度 (例如 1920)"
echo " 高度 : 分辨率高度 (例如 1080)"
echo " URL地址 : 基础的 URL 地址 (例如 192.168.21.7/pi5b)"
echo
echo "RTMP 推流说明:"
echo " 192.168.21.7:1935/pi5b --> rtmp://192.168.21.7:1935/pi5b/0 推流至 m920x 的 zlm 服务, 默认端口 1935, 会出现在 WVP 的推流列表中"
echo " 192.168.21.7:41935/pi5b --> rtmp://192.168.21.7:41935/pi5b/0 推流至 m920x 的 mediamtx 服务, 使用 mediamtx 服务的 WebRTC 访问: http://192.168.21.7:48889/pi5b/{CAMERA}"
echo " 127.0.0.1:1935/pi5b --> 本地推流至 mediamtx 服务, 使用 WebRTC 访问: http://ip:8889/pi5b/{CAMERA}"
echo " ===================================================="
echo " ./stream.sh rtmp 0 1920 1080 192.168.21.7/pi5a"
echo " ./stream.sh rtmp 0 2560 1440 192.168.21.7:41935/pi5a"
echo " ./stream.sh rtmp 0 3840 2160 192.168.21.7/pi5a"
echo " ./stream.sh rtmp 0 3840 2160 127.0.0.1/pi5a"
echo
echo "RTSP 推流说明:"
echo " 192.168.21.7:554/pi5b --> rtsp://192.168.21.7:554/pi5a/0 推流至 m920x 的 zlm 服务, 默认端口 554, 会出现在 WVP 的推流列表中"
echo " 192.168.21.7:48554/pi5b --> rtsp://192.168.21.7:48554/pi5b/0 推流至 m920x 的 mediamtx 服务, 使用 WebRTC 访问: http://192.168.21.7:48889/pi5b/{CAMERA}"
echo " 127.0.0.1:8554/pi5b --> 本地推流至 mediamtx 服务, 使用 WebRTC 访问: http://ip:8889/pi5b/{CAMERA}"
echo " ===================================================="
echo " ./stream.sh rtsp 1 1920 1080 192.168.21.7/pi5b"
echo " ./stream.sh rtsp 1 2560 1440 192.168.21.7:48554/pi5a"
echo " ./stream.sh rtsp 1 3840 2160 192.168.21.7:8554/pi5a"
echo " ./stream.sh rtsp 1 3840 2160 127.0.0.1:8554/pi5a"
exit 0
fi

# 参数赋值
PROTOCOL=$1 # 第一个参数为协议 (rtmp 或 rtsp)
CAMERA=$2 # 第二个参数为摄像头编号
WIDTH=$3 # 第三个参数为宽度 1920x1080 2560x1440 3840x2160
HEIGHT=$4 # 第四个参数为高度
URL=$5 # 第五个参数为基础的 URL 地址

# 根据协议动态设置输出流地址和格式
if [ "$PROTOCOL" == "rtmp" ]; then
OUTPUT_URL="rtmp://${URL}/${CAMERA}"
FFMPEG_FORMAT="flv"
elif [ "$PROTOCOL" == "rtsp" ]; then
OUTPUT_URL="rtsp://${URL}/${CAMERA}"
FFMPEG_FORMAT="rtsp"
else
echo "不支持的协议: $PROTOCOL"
exit 1
fi

# 运行命令
nohup bash -c "rpicam-vid --hflip --vflip -t 0 --camera $CAMERA --nopreview --codec yuv420 --width $WIDTH --height $HEIGHT --inline --listen -o - | ffmpeg -f rawvideo -pix_fmt yuv420p -s:v ${WIDTH}x${HEIGHT} -i /dev/stdin -c:v libx264 -preset ultrafast -tune zerolatency -f $FFMPEG_FORMAT $OUTPUT_URL" > ${PROTOCOL}-cam${CAMERA}.log 2>&1 &

因为 Web 端处理 WebRTC 还有点麻烦, 所以这里先使用 ZLM 将视频流转成 mp4, 然后直接使用 video 标签播放视频.

使用以下命令将视频流推送到 WVP:

1
2
3
4
# RTMP 推流
./stream.sh rtmp 0 1920 1080 192.168.21.7/pi5a
# RTSP 推流
./stream.sh rtsp 1 1920 1080 192.168.21.7/pi5a

然后在 WVP 控制台应该能看到视频流了:

20250212185748_TCP4MjUl.webp

嵌入到 Web UI

并排显示 2 个摄像头的画面:

20250212185909_ILxW2kgL.webp

舵机控制

我将舵机连接到了 Zero 2W 上, 然后摄像头与树莓派 5 连接, 所以舵机的控制我需要在 Zero 2W 上处理.

20250212192838_Js0nHyfm.webp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
from flask import Flask, render_template, request, jsonify
from flask_cors import CORS
import Adafruit_PCA9685
import time
import threading
import math
from concurrent.futures import ThreadPoolExecutor
import re

app = Flask(__name__)
CORS(app, resources={r"/*": {"origins": "*"}}) # 允许所有来源的跨域请求

# 初始化 PCA9685
pwm = Adafruit_PCA9685.PCA9685()

# 配置舵机的最小和最大脉冲长度
servo_min = 150
servo_max = 600
step_size = 5 # 增加步长,但保持相对小的值
update_frequency = 100 # Hz,进一步增加更新频率
move_duration = 0.05 # 秒,减少单次移动的持续时间

# 设置频率为 60Hz
pwm.set_pwm_freq(60)

# 初始化舵机位置
servo0_position = 375
servo1_position = 445

# 创建一个锁来保护共享资源
lock = threading.Lock()

# 创建一个事件来控制连续调整
continuous_event = threading.Event()

# 更新舵机位置的函数
def update_servo(channel, position):
pwm.set_pwm(channel, 0, position)

# 初始化舵机的函数
def initialize_servos():
global servo0_position, servo1_position
with lock:
update_servo(0, servo0_position)
update_servo(1, servo1_position)
print("舵机已初始化到中间位置")

# 平滑移动函数
def smooth_move(channel, start, end, duration):
steps = int(duration * update_frequency)
for i in range(steps):
t = i / steps
# 使用平方函数来创建更快但仍然平滑的加速和减速效果
smooth_t = t * t * (3 - 2 * t)
position = int(start + (end - start) * smooth_t)
update_servo(channel, position)
time.sleep(1 / update_frequency)

# 调整舵机的函数
def adjust_servo(direction):
global servo0_position, servo1_position
with lock:
if direction == 'left':
target = max(servo_min, servo0_position + step_size)
smooth_move(0, servo0_position, target, move_duration)
servo0_position = target
elif direction == 'right':
target = min(servo_max, servo0_position - step_size)
smooth_move(0, servo0_position, target, move_duration)
servo0_position = target
elif direction == 'up':
target = max(servo_min, servo1_position - step_size)
smooth_move(1, servo1_position, target, move_duration)
servo1_position = target
elif direction == 'down':
target = min(servo_max, servo1_position + step_size)
smooth_move(1, servo1_position, target, move_duration)
servo1_position = target
return servo0_position, servo1_position

def continuous_adjust(direction):
while not continuous_event.is_set():
adjust_servo(direction)

def reset_servos():
with lock:
update_servo(0, 375)
update_servo(1, 445)
print("舵机已重置到中间位置")
return 375, 445

# 新增:处理摇杆输入的函数
def handle_joystick(horizontal, vertical, last_servo0, last_servo1):
global servo0_position, servo1_position

# 使用上次记录的位置作为起始点
servo0_position = last_servo0
servo1_position = last_servo1

# 计算移动距离
distance = math.sqrt(horizontal**2 + vertical**2)

# 如果移动距离太小,保持当前位置
if distance < 0.1:
return servo0_position, servo1_position

# 计算水平和垂直方向的移动量
horizontal_move = int(horizontal * step_size * 2)
vertical_move = int(vertical * step_size * 2)

with lock:
# 更新水平舵机位置
new_servo0 = max(servo_min, min(servo_max, servo0_position + horizontal_move))
smooth_move(0, servo0_position, new_servo0, move_duration)
servo0_position = new_servo0

# 更新垂直舵机位置
new_servo1 = max(servo_min, min(servo_max, servo1_position - vertical_move))
smooth_move(1, servo1_position, new_servo1, move_duration)
servo1_position = new_servo1

return servo0_position, servo1_position

def get_video_type(url):
"""
根据 URL 确定视频类型
"""
if re.search(r'\.flv($|\?)', url):
return 'flv'
elif re.search(r'\.m3u8($|\?)', url):
return 'm3u8'
elif re.search(r'\.mp4($|\?)', url):
return 'mp4'
else:
# 如果无法确定,可以返回一个默认值或者 None
return None

@app.route('/')
def index():
video_url = "http://192.168.21.7:9090/pi5a/0.live.mp4" # 从配置或数据库获取
video_type = get_video_type(video_url)

if video_type is None:
# 或者返回错误
return "无法确定视频类型", 400

return render_template('index.html', video_url=video_url, video_type=video_type)

@app.route('/control', methods=['POST'])
def control():
direction = request.json['direction']
action = request.json['action'] # 'single', 'start', 或 'stop'

if action == 'single':
servo0, servo1 = adjust_servo(direction)
elif action == 'start':
continuous_event.clear()
threading.Thread(target=continuous_adjust, args=(direction,), daemon=True).start()
servo0, servo1 = servo0_position, servo1_position
else: # 'stop'
continuous_event.set()
servo0, servo1 = servo0_position, servo1_position

return jsonify({
'servo0': servo0,
'servo1': servo1
})

@app.route('/reset', methods=['POST'])
def reset():
servo0, servo1 = reset_servos()
return jsonify({
'servo0': servo0,
'servo1': servo1
})

@app.route('/joystick-control', methods=['POST'])
def joystick_control():
data = request.json
horizontal = data['horizontal']
vertical = data['vertical']
last_servo0 = data['lastServo0']
last_servo1 = data['lastServo1']

servo0, servo1 = handle_joystick(horizontal, vertical, last_servo0, last_servo1)

return jsonify({
'servo0': servo0,
'servo1': servo1
})

@app.after_request
def add_security_headers(response):
# 完全禁用 CSP
response.headers['Content-Security-Policy'] = "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;"

# CORS headers
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'

return response

if __name__ == '__main__':
# 在启动服务器之前初始化舵机
initialize_servos()
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)

完整的 HTML 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>摄像头控制系统</title>
<style>
html,
body {
height: 100%;
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
background-color: #f0f0f0;
}
.container {
display: flex;
flex-direction: column;
height: 100vh;
padding: 10px;
box-sizing: border-box;
}
.video-container {
display: flex;
flex: 1;
gap: 10px;
margin-bottom: 10px;
min-height: 0; /* 防止溢出 */
}
.video-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: #000;
border-radius: 8px;
overflow: hidden;
}
.video-wrapper video {
width: 100%;
height: 100%;
object-fit: contain; /* 改回 contain 以显示完整视频 */
}
.control-panel {
display: flex;
flex-direction: column;
align-items: center;
background-color: #fff;
border-radius: 8px;
padding: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
max-width: 800px;
margin: 0 auto;
}
.control-row {
display: flex;
justify-content: space-between;
align-items: center; /* 添加这行以垂直居中对齐 */
width: 100%;
margin-bottom: 10px;
}
.control-section {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.control-grid {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.control-btn {
width: 60px;
height: 60px;
font-size: 20px;
margin: 2px;
border: none;
background-color: #007bff;
color: white;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.control-btn:hover {
background-color: #0056b3;
}
.control-btn:active {
background-color: #004085;
}
.servo-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center; /* 垂直居中 */
align-items: center; /* 水平居中 */
text-align: center;
margin: 0 20px;
}
#reset {
width: calc(100% - 20px);
height: 40px;
font-size: 16px;
margin-top: 10px;
border: none;
background-color: #28a745;
color: white;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
#reset:hover {
background-color: #218838;
}
#reset:active {
background-color: #1e7e34;
}
#joystick-container {
width: 150px;
height: 150px;
position: relative;
}
#joystick {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #f0f0f0;
position: relative;
overflow: visible; /* 改为 visible */
}
#joystick-knob {
width: 30px;
height: 30px;
border-radius: 50%;
background-color: #007bff;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
transition: transform 0.1s ease-out; /* 添加平滑过渡效果 */
}
@media (max-height: 600px) {
.control-btn {
width: 40px;
height: 40px;
font-size: 16px;
}
#reset {
width: calc(120px + 10px);
height: 30px;
font-size: 14px;
}
}
</style>
<link
href="https://vjs.zencdn.net/7.20.3/video-js.min.css"
rel="stylesheet"
/>
<script src="https://vjs.zencdn.net/7.20.3/video.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/videojs-contrib-hls/5.15.0/videojs-contrib-hls.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/flv.js/1.6.2/flv.min.js"></script>
</head>
<body>
<div class="container">
<div class="video-container">
<div class="video-wrapper">
<video
id="my-video"
class="video-js"
controls
preload="auto"
width="640"
height="360"
>
<p class="vjs-no-js">
To view this video please enable JavaScript, and consider
upgrading to a web browser that
<a href="https://videojs.com/html5-video-support/" target="_blank"
>supports HTML5 video</a
>
</p>
</video>
</div>
<div class="video-wrapper">
<video
src="http://192.168.21.7:9090/pi5a/1.live.mp4"
onerror="handleVideoError(this)"
autoplay
muted
loop
></video>
</div>
</div>
<div class="control-panel">
<div class="control-row">
<div class="control-section">
<div class="control-grid">
<div class="control-row">
<button class="control-btn" style="visibility: hidden;">

</button>
<button
class="control-btn"
onclick="singleControl('up')"
onmousedown="startControl('up')"
onmouseup="stopControl()"
onmouseleave="stopControl()"
>

</button>
<button class="control-btn" style="visibility: hidden;">

</button>
</div>
<div class="control-row">
<button
class="control-btn"
onclick="singleControl('left')"
onmousedown="startControl('left')"
onmouseup="stopControl()"
onmouseleave="stopControl()"
>

</button>
<button
class="control-btn"
onclick="singleControl('down')"
onmousedown="startControl('down')"
onmouseup="stopControl()"
onmouseleave="stopControl()"
>

</button>
<button
class="control-btn"
onclick="singleControl('right')"
onmousedown="startControl('right')"
onmouseup="stopControl()"
onmouseleave="stopControl()"
>

</button>
</div>
</div>
</div>
<div class="servo-info">
<p>水平舵机位置: <span id="servo0">375</span></p>
<p>垂直舵机位置: <span id="servo1">445</span></p>
</div>
<div class="control-section">
<div id="joystick-container">
<div id="joystick">
<div id="joystick-knob"></div>
</div>
</div>
</div>
</div>
<button id="reset">回正</button>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
let pressTimer;

function singleControl(direction) {
clearTimeout(pressTimer);
fetch("/control", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ direction: direction, action: "single" }),
})
.then((response) => response.json())
.then(updateServoInfo)
.catch(handleError);
}

function startControl(direction) {
pressTimer = setTimeout(() => {
fetch("/control", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ direction: direction, action: "start" }),
})
.then((response) => response.json())
.then(updateServoInfo)
.catch(handleError);
}, 200); // 200ms 延迟,区分单击和长按
}

function stopControl() {
clearTimeout(pressTimer);
fetch("/control", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ direction: "stop", action: "stop" }),
})
.then((response) => response.json())
.then(updateServoInfo)
.catch(handleError);
}

function updateServoInfo(data) {
document.getElementById("servo0").textContent = data.servo0;
document.getElementById("servo1").textContent = data.servo1;
}

function handleError(error) {
console.error("发生错误:", error);
// 可以在这里添加用户提示
}

function handleVideoError(video) {
console.error("视频加载失败:", video.src);
video.style.display = "none";
video.parentElement.textContent = "视频加载失败";
}

$(".control-btn")
.on("mousedown touchstart", function (e) {
e.preventDefault();
var direction = $(this).attr("id");
$.ajax({
url: "/control",
method: "POST",
contentType: "application/json",
data: JSON.stringify({ direction: direction, action: "start" }),
success: function (response) {
console.log(response);
},
});
})
.on("mouseup mouseleave touchend", function () {
$.ajax({
url: "/control",
method: "POST",
contentType: "application/json",
data: JSON.stringify({ direction: "", action: "stop" }),
success: function (response) {
console.log(response);
},
});
});

$("#reset").on("click", function () {
$.ajax({
url: "/reset",
method: "POST",
success: function (response) {
console.log("舵机已重置", response);
// 使用返回的数据更新舵机位置显示
updateServoInfo(response);
},
error: function (xhr, status, error) {
console.error("重置失败:", error);
// 可以在这里添加错误提示
},
});
});

document.addEventListener(
"touchmove",
function (e) {
e.preventDefault();
},
{ passive: false }
);

// 添加摇杆控制代码
const joystick = document.getElementById("joystick");
const knob = document.getElementById("joystick-knob");
let isDragging = false;
let centerX, centerY, knobRadius, joystickRadius;

let lastServo0Position = 375; // 初始水平舵机置
let lastServo1Position = 445; // 初始垂直舵机位置

function initJoystick() {
const joystickRect = joystick.getBoundingClientRect();
centerX = joystickRect.width / 2;
centerY = joystickRect.height / 2;
joystickRadius = joystickRect.width / 2;
knobRadius = knob.offsetWidth / 2;
}

function handleJoystickMove(e) {
if (!isDragging) return;

const joystickRect = joystick.getBoundingClientRect();
let mouseX = e.clientX - joystickRect.left;
let mouseY = e.clientY - joystickRect.top;

// 计算与中心的距离
let deltaX = mouseX - centerX;
let deltaY = mouseY - centerY;
let distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);

// 如果超出边界,进行限制
if (distance > joystickRadius - knobRadius) {
let angle = Math.atan2(deltaY, deltaX);
deltaX = Math.cos(angle) * (joystickRadius - knobRadius);
deltaY = Math.sin(angle) * (joystickRadius - knobRadius);
}

// 更新摇杆位置
knob.style.transform = `translate(calc(-50% + ${deltaX}px), calc(-50% + ${deltaY}px))`;

// 计算角度和强度
let angle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
let strength = Math.min(distance / (joystickRadius - knobRadius), 1);

// 发送控制命令
sendJoystickControl(angle, strength);
}

function sendJoystickControl(angle, strength) {
let horizontalMove = Math.cos((angle * Math.PI) / 180) * strength;
let verticalMove = -Math.sin((angle * Math.PI) / 180) * strength;

fetch("/joystick-control", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
horizontal: horizontalMove,
vertical: verticalMove,
lastServo0: lastServo0Position,
lastServo1: lastServo1Position,
}),
})
.then((response) => response.json())
.then((data) => {
updateServoInfo(data);
lastServo0Position = data.servo0;
lastServo1Position = data.servo1;
})
.catch((error) => console.error("Error:", error));
}

function resetJoystick() {
isDragging = false;
knob.style.transform = "translate(-50%, -50%)";
sendJoystickControl(0, 0); // 发送中心位置信号
}

joystick.addEventListener("mousedown", (e) => {
isDragging = true;
handleJoystickMove(e);
});

document.addEventListener("mousemove", handleJoystickMove);
document.addEventListener("mouseup", resetJoystick);

// 添加触摸事件支持
joystick.addEventListener("touchstart", (e) => {
isDragging = true;
handleJoystickMove(e.touches[0]);
});
joystick.addEventListener("touchmove", (e) => {
e.preventDefault(); // 防止页面滚动
handleJoystickMove(e.touches[0]);
});
joystick.addEventListener("touchend", resetJoystick);

window.addEventListener("resize", initJoystick);
initJoystick();

// 在 <script> 标签内添加以下代码

let player;

function initializePlayer(videoUrl, videoType) {
if (player) {
player.dispose();
}

let options = {
fluid: true,
controls: true,
preload: "auto",
};

switch (videoType) {
case "flv":
options.techOrder = ["html5", "flvjs"];
options.sources = [
{
type: "video/x-flv",
src: videoUrl,
},
];
break;
case "m3u8":
options.techOrder = ["html5", "hlsjs"];
options.sources = [
{
type: "application/x-mpegURL",
src: videoUrl,
},
];
break;
case "mp4":
options.sources = [
{
type: "video/mp4",
src: videoUrl,
},
];
break;
default:
console.error("Unsupported video type");
return;
}

player = videojs("my-video", options, function onPlayerReady() {
console.log("Player is ready");
this.play();
});
}

// 使用示例
// initializePlayer('http://example.com/video.mp4', 'mp4');

// 在页面加载完成后初始化播放器
window.addEventListener("load", function () {
// 从服务器获取视频 URL 和类型,或者直接在这里设置
let videoUrl = "{{ video_url }}"; // 假设这是从服务器传递的变量
let videoType = "{{ video_type }}"; // 可以是 'flv', 'm3u8', 或 'mp4'
initializePlayer(videoUrl, videoType);
});
</script>
</body>
</html>

右边的视频是直接使用 video 标签写死的: http://192.168.21.7:9090/pi5a/1.live.mp4, 另一个使用 videojs 并从后端获取视频地址: http://192.168.21.7:9090/pi5a/0.live.mp4

这里解释一下, 因为我们前面分别使用 rtsp 和 rtmp 将视频流推送到了 WVP, 这里的 9090 就 ZLM 服务的端口, 我们直接按照规则凭借 ZLM 即可, 如果使用过 ZLM 应该不难理解, 或者可以直接在 WVP 页面回去播放地址:

20250212191120_4V6TuXsz.webp

效果展示

20250212191238_CRx4L2ui.webp

代码开源在 https://github.com/dong4j/pi-pca9685-controller

参考资料