From b3b34b5d91b35b59736843699e60ca8d7203e807 Mon Sep 17 00:00:00 2001 From: CaoWangrenbo Date: Sat, 1 Nov 2025 01:11:35 +0800 Subject: [PATCH] initial commit --- .gitignore | 4 + app.py | 111 ++++++++++++++++ gimbal.py | 210 ++++++++++++++++++++++++++++++ requirements.txt | 2 + static/js/virtual-joystick.js | 231 +++++++++++++++++++++++++++++++++ templates/index.html | 236 ++++++++++++++++++++++++++++++++++ 6 files changed, 794 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 gimbal.py create mode 100644 requirements.txt create mode 100644 static/js/virtual-joystick.js create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58189e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +venv/ +__pycache__/ +.vscode/ + diff --git a/app.py b/app.py new file mode 100644 index 0000000..a087a5f --- /dev/null +++ b/app.py @@ -0,0 +1,111 @@ +from flask import Flask, render_template, request, jsonify +from flask_socketio import SocketIO, emit +import logging +import math +from gimbal import gimbal_ctrl + +# 配置日志 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Flask 应用初始化 +app = Flask(__name__) +app.config['SECRET_KEY'] = 'h2Ms4pw9GzvwiFHyNPhH' # 请更换为安全的密钥 +socketio = SocketIO(app, cors_allowed_origins="*") + +# 全局变量存储摇杆数据 +joystick_data = {} + +ptz = gimbal_ctrl("192.168.17.114", 49125) + +@app.route('/') +def index(): + return render_template('index.html') + +@socketio.on('joystick_data') +def handle_joystick_data(data): + global joystick_data + joystick_data = data + # logger.info(f"收到摇杆数据:{data}") + + force = data.get('force', 0) + force = force if force <= 1 else 1 + + degree = data.get('degree', 0) + + x = math.cos(math.radians(degree)) * force + y = math.sin(math.radians(degree)) * force + + # print(f"force: {force} x: {x}, y: {y}") + ptz.set_pitch_speed(int(y * 100)) + ptz.set_yaw_speed(int(x * 100)) + + # direction = data.get('direction', '') + # logger.info(f"收到摇杆数据:{direction}") + # if direction: + # handle_robot_movement(direction, data.get('force', 0)) + + # emit('data_received', {'status': 'success', 'timestamp': data.get('timestamp')}) + +# def handle_robot_movement(direction, force): +# """ +# 根据摇杆方向和力度控制机器人 +# 这里是示例实现,您可以根据实际需求修改 +# """ +# # 示例:根据方向执行不同的动作 +# actions = { +# 'n': '向前移动', +# 's': '向后移动', +# 'w': '向左移动', +# 'e': '向右移动', +# 'nw': '左前移动', +# 'ne': '右前移动', +# 'sw': '左后移动', +# 'se': '右后移动' +# } + +# action = actions.get(direction, '停止移动') +# speed = int(force * 100) if force else 0 + +# logger.info(f"执行动作:{action}, 速度:{speed}%") + +@socketio.on('connect') +def handle_connect(): + """处理客户端连接""" + logger.info(f"客户端已连接:{request.sid}") + emit('connection_response', {'status': 'connected', 'message': 'WebSocket 连接成功'}) + +@socketio.on('disconnect') +def handle_disconnect(): + """处理客户端断开连接""" + logger.info(f"客户端已断开连接:{request.sid}") + +@socketio.on('ping') +def handle_ping(): + """处理心跳检测""" + emit('pong') + +@app.route('/get_joystick_data') +def get_joystick_data(): + """API 端点:获取当前摇杆数据""" + from flask import jsonify + return jsonify(joystick_data) + +@app.route('/control/') +def robot_control(action): + """ + 示例控制端点 + 可用于直接控制机器人动作 + """ + from flask import jsonify + valid_actions = ['forward', 'backward', 'left', 'right', 'stop'] + + if action in valid_actions: + logger.info(f"执行机器人控制:{action}") + # 在这里添加实际的机器人控制逻辑 + return jsonify({'status': 'success', 'action': action}) + else: + return jsonify({'status': 'error', 'message': '无效的动作'}), 400 + +if __name__ == '__main__': + socketio.run(app, host='0.0.0.0', port=5000, debug=True) \ No newline at end of file diff --git a/gimbal.py b/gimbal.py new file mode 100644 index 0000000..bf8a0de --- /dev/null +++ b/gimbal.py @@ -0,0 +1,210 @@ +import socket +import struct +import urllib.request +import urllib.error +import time + +class gimbal_ctrl: + """ + 云台控制器类,用于通过 TCP 和 HTTP 协议控制云台。 + 支持长连接模式,避免频繁新建连接导致设备 RST。 + """ + + def __init__(self, host, port, timeout=5): + """ + 初始化控制器。 + + Args: + host (str): 云台设备的 IP 地址。 + port (int): 云台设备的 TCP 控制端口。 + timeout (int, optional): TCP 连接和接收响应的超时时间(秒)。默认为 5。 + """ + self.host = host + self.port = port + self.base_url = f"http://{host}" # 基础 URL 用于 HTTP 请求 + self.timeout = timeout + self.client_socket = None # 长连接 socket + + def connect(self): + """建立 TCP 长连接""" + if self.client_socket is not None: + return # 已连接,直接返回 + + try: + self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.client_socket.settimeout(self.timeout) + self.client_socket.connect((self.host, self.port)) + print(f"Connected to {self.host}:{self.port}") + except Exception as e: + print(f"Failed to connect to {self.host}:{self.port}: {e}") + self.client_socket = None + + def disconnect(self): + """断开 TCP 连接""" + if self.client_socket: + try: + self.client_socket.close() + except: + pass + self.client_socket = None + print("TCP connection closed.") + + def calculate_checksum(self, data_bytes): + """ + 计算校验和。 + 使用简单累加和(32位有符号),与原始代码一致。 + 如设备使用 CRC32,请替换为 zlib.crc32。 + """ + checksum = 0 + for i in range(0, len(data_bytes), 4): + chunk = data_bytes[i:i+4] + if len(chunk) == 4: + value = struct.unpack(' { + const dirs = ['ne', 'n', 'nw', 'w', 'sw', 's', 'se']; + const acute = 45; + let threshold = 22.5; + for (let dir of dirs) { + if (degree >= threshold && degree < (threshold += acute)) { + return dir; + } + } + return 'e'; + } + static #getUniqueDir(a = '', b = '') { + let dir = ''; + if (a.includes(b[0]) === false) { + dir = b[0]; + } + if (b[1] && a.includes(b[1]) === false) { + dir += b[1]; + } + return dir; + } + #setXY(x, y) { + this.#element.style.setProperty('--x', `${x}px`); + this.#element.style.setProperty('--y', `${y}px`); + }; + #calcCrow({ clientX, clientY }) { + const { lock } = this.dataset; + this.#rect = this.#element.getBoundingClientRect(); + const dx = lock === 'x' ? this.#r : clientX - this.#rect.left; + const dy = lock === 'y' ? this.#r : clientY - this.#rect.top; + const dxr = dx - this.#r; + const dyr = dy - this.#r; + const hypot = Math.hypot(dxr, dyr); + this.#crow = { dx, dy, dxr, dyr, hypot }; + } + #log({ + degree = 0, + force = 0, + radian = 0, + distance = 0, + direction = '', + hypot = 0, + capture = '', + release = '', + x = this.#rect.width + this.#rect.left, + y = this.#rect.top + this.#rect.top, + }) { + Object.assign( + this.dataset, + { degree, force, radian, distance, direction, hypot, capture, release, x, y } + ); + } + #isInside(event) { + const { clientX, clientY } = event; + const { + left: x, + top: y, + width: w, + height: h + } = this.dataset.mode ? this.getBoundingClientRect() : this.#rect; + const inside = clientX >= x && clientX <= x + w && clientY >= y && clientY <= y + h; + return inside; + } + #r = 0 + #element = null + #rect = null + #crow = null + #pointers = [] + constructor() { + super(); + let output = {}; + this.attachShadow({ mode: 'open' }); + this.shadowRoot.innerHTML = ` + + + `; + this.#element = this.shadowRoot.lastElementChild; + if (this.dataset.mode === 'semi' || this.dataset.mode === 'dynamic') { + this.#element.part.add('dynamic'); + output = { x: 0, y: 0 }; + } + if (this.dataset.shape) { + this.#element.part.add('box'); + } + this.#rect = this.#element.getBoundingClientRect(); + this.#r = this.#rect.width / 2; + this.#log(output); + } + connectedCallback() { + document.addEventListener('pointerdown', this.#start); + document.addEventListener('pointermove', this.#move); + document.addEventListener('pointerup', this.#up); + } + #start = (event) => { + const { clientX, clientY } = event; + const attachEvents = () => { + this.#pointers.push(event.pointerId); + this.#element.part.add('active'); + this.#bind(event); + this.dispatchEvent(new CustomEvent('joystickdown')); + }; + if (this.#pointers.length && this.dataset.mode !== 'fixed') { + return; + } + this.#rect = this.#element.getBoundingClientRect(); + if (this.#isInside(event)) { + if (this.dataset.mode) { + if (this.dataset.mode !== 'fixed') { + this.dataset.mode === 'semi' && this.#element.part.remove('dynamic'); + const { top, left } = this.getBoundingClientRect(); + this.#element.style.left = `${clientX - left - this.#r}px`; + this.#element.style.top = `${clientY - top - this.#r}px`; + } + this.#calcCrow(event); + return attachEvents(); + } + this.#calcCrow(event); + if (this.#crow.hypot <= this.#r || this.dataset.shape) { + attachEvents(); + } + } + } + #move = (event) => { + if (this.#pointers.at(-1) === event.pointerId) { + this.#calcCrow(event); + this.#bind(event); + this.dispatchEvent(new CustomEvent('joystickmove')); + } + } + #bind = () => { + const { dx, dy, dxr, dyr, hypot } = this.#crow; + const r = this.#r; + const angle = Math.atan2(dyr, dxr); + let degree = angle * 180 / Math.PI; + let x = dx; + let y = dy; + const force = hypot / r; + if (!this.dataset.shape && hypot > r) { + x = r * Math.cos(angle) + r; + y = r * Math.sin(angle) + r; + } + degree = (degree > 0 ? 360 : 0) - degree; + const direction = + this.dataset.threshold > force ? '' : VirtualJoystick.#getDir(degree); + this.#log({ + hypot, + degree, + force, + direction, + capture: VirtualJoystick.#getUniqueDir(this.dataset.direction, direction), + release: VirtualJoystick.#getUniqueDir(direction, this.dataset.direction), + x: x + this.#rect.left, + y: y + this.#rect.top, + radian: (angle > 0 ? 2 * Math.PI : 0) - angle, + distance: Math.min(hypot, r), + }); + this.#setXY(x, y); + }; + #up = (event) => { + if (this.#pointers.at(-1) === event.pointerId) { + this.#pointers.pop(); + this.#element.part.remove('active'); + this.#log({ release: this.dataset.direction }); + this.#setXY(this.#r, this.#r); + this.dispatchEvent(new CustomEvent('joystickup')); + } + const pointerIndex = this.#pointers.indexOf(event.pointerId); + if (pointerIndex !== -1) { + this.#pointers.splice(pointerIndex, 1); + } + }; +}); diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6012fdf --- /dev/null +++ b/templates/index.html @@ -0,0 +1,236 @@ + + + + + + 摄像头监控页面 + + + +
未连接
+ +
+
+
云台
+
+ +
+
+
前视相机
+
+ +
+
+
后视相机
+
+ +
+
+
左侧
+
+ +
+
+
右侧
+
+ +
+
+ +
+
+ + + + + + + + \ No newline at end of file