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

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

  1. HomeLab
  2. 2024.11.20
  3. 21 min read

简介

基于树莓派的视频推流方案 我们尝试了通过树莓派推流到流媒体服务器, 然后通过 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
sudo raspi-config -> 5.Interfacing Options -> P5 I2C 设置enable,然后重启树莓派
  1. i2c-tools 测试舵机连接状态
sudo apt-get install i2c-tools
sudo i2cdetect -y 1
  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 控制台应该能看到视频流了:

20250212185748_TCP4MjUl.webp

嵌入到 Web UI

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

20250212185909_ILxW2kgL.webp

舵机控制

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

20250212192838_Js0nHyfm.webp

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 页面回去播放地址:

20250212191120_4V6TuXsz.webp

效果展示

20250212191238_CRx4L2ui.webp

{% video https://cdn.dong4j.site/source/image/PCA9685.mp4 %}

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

参考资料

Homelab 自动化运维 学习笔记