initial commit

This commit is contained in:
2025-11-01 01:11:35 +08:00
commit b3b34b5d91
6 changed files with 794 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
venv/
__pycache__/
.vscode/

111
app.py Normal file
View File

@@ -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/<action>')
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)

210
gimbal.py Normal file
View File

@@ -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('<i', chunk)[0]
checksum += value
checksum_unsigned = checksum & 0xFFFFFFFF
checksum_signed = struct.unpack('<i', struct.pack('<I', checksum_unsigned))[0]
return checksum_signed
def _send_tcp_command(self, command_id, param_value):
"""
发送 TCP 二进制命令(使用长连接,不等待响应)。
自动重连(如果连接断开)。
"""
if self.client_socket is None:
self.connect()
if self.client_socket is None:
return # 连接失败,直接返回
# 打包数据
header_data = struct.pack('<ii', command_id, param_value)
checksum = self.calculate_checksum(header_data)
checksum_data = struct.pack('<i', checksum)
binary_data = header_data + checksum_data
try:
sent_bytes = self.client_socket.send(binary_data)
# print(f"Sent {sent_bytes} bytes successfully.")
except (ConnectionResetError, BrokenPipeError, OSError) as e:
print(f"Connection lost: {e}. Reconnecting...")
self.disconnect()
# 可选:尝试立即重发(谨慎使用,避免死循环)
# self._send_tcp_command(command_id, param_value)
# --- TCP 控制方法 ---
def set_pitch_speed(self, speed):
if -99 <= speed <= 99:
self._send_tcp_command(0x0000000C, speed)
else:
print(f"Pitch speed value {speed} is out of range [-99, 99].")
def set_yaw_speed(self, speed):
if -99 <= speed <= 99:
self._send_tcp_command(0x0000000D, speed)
else:
print(f"Yaw speed value {speed} is out of range [-99, 99].")
def set_roll_speed(self, speed):
if -99 <= speed <= 99:
self._send_tcp_command(0x0000000E, speed)
else:
print(f"Roll speed value {speed} is out of range [-99, 99].")
def center(self):
self._send_tcp_command(0x00000010, 0)
def take_photo(self):
self._send_tcp_command(0x045c8701, 0)
def zoom_in(self):
self._send_tcp_command(0x0000001F, 0x01)
def zoom_out(self):
self._send_tcp_command(0x0000001F, 0x02)
def osd_on(self):
self._send_tcp_command(0x00000005, 0x01)
def osd_off(self):
self._send_tcp_command(0x00000005, 0x02)
def laser_on(self):
self._send_tcp_command(0x0000001A, 0x01)
def laser_off(self):
self._send_tcp_command(0x0000001A, 0x02)
def track_on(self):
self._send_tcp_command(0x0000001C, 0x01)
def track_off(self):
self._send_tcp_command(0x0000001C, 0x02)
# --- HTTP 控制方法(保持不变)---
def _send_http_get(self, path):
full_url = self.base_url + path
try:
req = urllib.request.Request(full_url)
req.add_header('User-Agent', 'PanTiltController')
response = urllib.request.urlopen(req)
# print(f"HTTP GET request to {full_url} successful.")
except urllib.error.HTTPError as e:
print(f"HTTP Error for {full_url}: {e.code} - {e.reason}")
except urllib.error.URLError as e:
print(f"URL Error for {full_url}: {e.reason}")
except Exception as e:
print(f"Unexpected error for {full_url}: {e}")
def set_pip_mode(self, mode):
if 0 <= mode <= 3:
path = f"/cgi-bin/Config.cgi?action=set&property=Camera.IrPip&value={mode}"
self._send_http_get(path)
else:
print(f"PIP mode value {mode} is out of range [0, 3].")
def set_ir_mode(self, mode):
if 0 <= mode <= 9:
path = f"/cgi-bin/Config.cgi?action=set&property=Camera.IrColorMode&value={mode}"
self._send_http_get(path)
else:
print(f"IR mode value {mode} is out of range [0, 9].")
# --- 使用示例 ---
if __name__ == "__main__":
PTZ_HOST = '192.168.17.114'
PTZ_PORT = 49125
ptz = gimbal_ctrl(PTZ_HOST, PTZ_PORT)
try:
# 可选:显式连接(非必须,发送时会自动连接)
# ptz.connect()
print("Sending commands...")
ptz.center()
time.sleep(1)
ptz.set_pitch_speed(99)
time.sleep(1)
ptz.set_pitch_speed(0)
time.sleep(1)
ptz.set_yaw_speed(-99)
time.sleep(1)
ptz.set_yaw_speed(0)
time.sleep(1)
ptz.set_roll_speed(-99)
time.sleep(1)
ptz.set_roll_speed(0)
time.sleep(1)
print("All commands sent.")
finally:
# 程序结束时断开连接
ptz.disconnect()
print("Connection closed.")
time.sleep(1)
exit(1)

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
flask
flask-socketio

View File

@@ -0,0 +1,231 @@
window.customElements.define('virtual-joystick', class VirtualJoystick extends HTMLElement {
static #style = `
:host {
--radius: 65px;
--size: calc(var(--radius) * 2);
}
:host,
slot {
position: relative;
display: block;
width: var(--size);
height: var(--size);
touch-action: none;
}
slot {
--x: var(--radius);
--y: var(--radius);
border: 1px solid gray;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 50%;
box-sizing: border-box;
transition: opacity 1s, background-color .2s;
&[part*="active"] {
background-color: rgba(255, 255, 255, 0.6);
&:after {
background-color: rgba(255, 255, 255, 0.8);
transition: transform .1s, background-color 0.2s;
}
}
}
slot:after,
slot:before {
content: "";
display: block;
position: absolute;
box-sizing: border-box;
border-radius: 50%;
width: 50px;
height: 50px;
}
slot:after {
border: 1px solid gray;
background-color: rgba(255, 255, 255, 0.5);
transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y)));
transition: transform .4s, background-color .2s;
}
slot:before {
border: 1px solid rgb(139, 139, 139);
transform: translate(calc(-50% + var(--radius)), calc(-50% + var(--radius)));
}
[part*="dynamic"] {
opacity: 0;
}
[part*="active"] {
opacity: 1;
}
[part*="box"]:after {
transform: translate(calc(-50% + clamp(0px, var(--x), var(--size))), calc(-50% + clamp(0px, var(--y), var(--size))));
}
`
static #getDir = (degree) => {
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 = `
<style>${VirtualJoystick.#style}</style>
<slot part="joystick"></slot>
`;
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);
}
};
});

236
templates/index.html Normal file
View File

@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>摄像头监控页面</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f0f0f0;
min-height: 100vh;
}
.container {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 10px;
max-width: 1800px;
margin: 0 auto;
height: calc(100vh - 40px);
}
.camera-frame {
border: 1px solid #ccc;
border-radius: 8px;
overflow: hidden;
background-color: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: all 0.3s ease;
position: relative;
}
.camera-frame.main-view {
grid-column: 1 / span 2;
grid-row: 1 / span 2;
z-index: 10;
border: 3px solid #4a90e2;
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
}
.camera-frame.main-view .camera-title {
background-color: #2c5aa0;
font-size: 1.2em;
}
.camera-frame.front-view {
grid-column: 3 / span 1;
grid-row: 1 / span 1;
}
.camera-frame.back-view {
grid-column: 3 / span 1;
grid-row: 2 / span 1;
}
.camera-frame.left-view {
grid-column: 1 / span 1;
grid-row: 3 / span 1;
}
.camera-frame.right-view {
grid-column: 2 / span 1;
grid-row: 3 / span 1;
}
.joystick-container {
grid-column: 3 / span 1;
grid-row: 3 / span 1;
display: flex;
justify-content: center;
align-items: center;
background-color: #f8f9fa;
border-radius: 8px;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
.camera-title {
background-color: #4a90e2;
color: white;
padding: 8px;
text-align: center;
font-weight: bold;
cursor: pointer;
user-select: none;
}
.camera-title:hover {
background-color: #3a7bc8;
}
iframe {
width: 100%;
height: 100%;
border: none;
}
.status-indicator {
position: absolute;
top: 5px;
right: 5px;
width: 10px;
height: 10px;
border-radius: 50%;
z-index: 5;
}
virtual-joystick {
--radius: 100px;
}
virtual-joystick::part(joystick) {
background-color: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(74, 144, 226, 0.5);
}
virtual-joystick::part(joystick):before {
background-color: rgba(74, 144, 226, 0.2);
}
virtual-joystick::part(joystick):after {
background-color: #4a90e2;
border: 1px solid #2c5aa0;
}
#connection-status {
position: fixed;
top: 10px;
right: 10px;
padding: 10px;
border-radius: 5px;
color: white;
font-weight: bold;
}
.connected {
background-color: #28a745;
}
.disconnected {
background-color: #dc3545;
}
</style>
</head>
<body>
<div id="connection-status" class="disconnected">未连接</div>
<div class="container">
<div class="camera-frame main-view" data-camera="cam_gimbal">
<div class="camera-title">云台</div>
<div class="status-indicator"></div>
<iframe src="http://10.21.31.250:8889/cam_gimbal/" title="云台"></iframe>
</div>
<div class="camera-frame front-view" data-camera="cam_f">
<div class="camera-title">前视相机</div>
<div class="status-indicator"></div>
<iframe src="http://10.21.31.250:8889/cam_f/" title="前视相机"></iframe>
</div>
<div class="camera-frame back-view" data-camera="cam_b">
<div class="camera-title">后视相机</div>
<div class="status-indicator"></div>
<iframe src="http://10.21.31.250:8889/cam_b/" title="后视相机"></iframe>
</div>
<div class="camera-frame left-view" data-camera="cam_l">
<div class="camera-title">左侧</div>
<div class="status-indicator"></div>
<iframe src="http://10.21.31.250:8889/cam_l/" title="左侧"></iframe>
</div>
<div class="camera-frame right-view" data-camera="cam_r">
<div class="camera-title">右侧</div>
<div class="status-indicator"></div>
<iframe src="http://10.21.31.250:8889/cam_r/" title="右侧"></iframe>
</div>
<div class="joystick-container">
<virtual-joystick></virtual-joystick>
</div>
</div>
<!-- Socket.IO 客户端库 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.js"></script>
<script src="{{ url_for('static', filename='js/virtual-joystick.js') }}"></script>
<script>
// 初始化 Socket.IO 连接
const socket = io();
// 连接状态管理
const connectionStatus = document.getElementById('connection-status');
socket.on('connect', function() {
console.log('WebSocket 连接已建立');
connectionStatus.textContent = '已连接';
connectionStatus.className = 'connected';
});
socket.on('disconnect', function() {
console.log('WebSocket 连接已断开');
connectionStatus.textContent = '已断开';
connectionStatus.className = 'disconnected';
});
socket.on('connection_response', function(data) {
console.log('服务器响应:', data);
});
socket.on('data_received', function(data) {
console.log('数据确认:', data);
});
// 获取摇杆元素
const joystick = document.querySelector('virtual-joystick');
// 监听摇杆移动事件
joystick.addEventListener('joystickmove', function(e) {
const data = {
x: parseFloat(joystick.dataset.x),
y: parseFloat(joystick.dataset.y),
degree: parseFloat(joystick.dataset.degree) || 0,
force: parseFloat(joystick.dataset.force) || 0,
direction: joystick.dataset.direction || '',
hypot: parseFloat(joystick.dataset.hypot) || 0,
timestamp: Date.now()
};
// 发送数据到服务器
socket.emit('joystick_data', data);
});
// 监听摇杆抬起事件
joystick.addEventListener('joystickup', function(e) {
// 发送停止信号
const stopData = {
x: 0,
y: 0,
degree: 0,
force: 0,
direction: '',
hypot: 0,
timestamp: Date.now(),
action: 'stop'
};
socket.emit('joystick_data', stopData);
});
// 心跳检测
setInterval(() => {
if (socket.connected) {
socket.emit('ping');
}
}, 30000); // 每 30 秒发送一次心跳
</script>
</body>
</html>