简介
在 基于树莓派的视频推流方案 我们尝试了通过树莓派推流到流媒体服务器, 然后通过 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(所有通道总电流)。

接线方式:

外接供电:

驱动板右侧的黑黄蓝红 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 舵机:

树莓派配置
- 树莓派开启 I2C
sudo raspi-config -> 5.Interfacing Options -> P5 I2C 设置enable,然后重启树莓派
- i2c-tools 测试舵机连接状态
sudo apt-get install i2c-tools
sudo i2cdetect -y 1
使用 PCA9685 python 库控制舵机
例子源码 在这里(example 目录下)
sudo pip3 install adafruit-pca9685 python3 ./simpletest.py
集成
启动流媒体服务器
上一篇文章中我已经写了一个启动脚本:
#!/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:
# 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 控制台应该能看到视频流了:

嵌入到 Web UI
并排显示 2 个摄像头的画面:

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

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 代码:
<!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 页面回去播放地址:
效果展示

{% video https://cdn.dong4j.site/source/image/PCA9685.mp4 %}
代码开源在 https://github.com/dong4j/pi-pca9685-controller
参考资料
- 树莓派搭建简易远程监控,利用舵机制作可旋转的摄像头
- 树莓派 3B+ PCA9685 舵机驱动板控制舵机
- 树莓派搭建简易远程监控,利用舵机制作可旋转的摄像头
- 总线舵机驱动板 集成 ESP32 和控制电路 适用于 ST/RSBL 系列总线舵机
- 【树莓派 C 语言开发】实验 14:PS2 游戏手柄模块(关联 PCF8591)_树莓派 ps2 操纵杆实验-CSDN 博客
- 树莓派基础实验 14:PS2 操纵杆实验_ps2 操纵杆和液晶显示器 将操纵杆的变化显示在液晶显示器上-CSDN 博客
- 在树莓派 Pico 上使用摇杆 – 树莓派 Pico 实验室(RP2040)
- 用本地网络控制的树莓派摄影云台 | 树莓派实验室
- 基于树莓派的多舵机控制的定位拍照云台 | 树莓派实验室
- 树莓派 4B-Python-使用 PCA9685 控制舵机云台+跟随人脸转动_Python 资料_Python 教程开发文档资料-Python 资料网
- 树莓派,mediapipe,Picamera2 利用舵机云台追踪人手(PID 控制)_树莓派云台追踪封装好的函数-CSDN 博客
- 使用树莓派 gpio 连接 ps2 手柄模块(附程序)「建议收藏」-腾讯云开发者社区-腾讯云
- 咸鱼 ZTMR 实例—PS2 手柄-腾讯云开发者社区-腾讯云
- ps2 摇杆传感器控制舵机实验 – 树莓酱
- 树莓派通过 16 路 PCA9685 模块驱动舵机 | My-Blog

