init pro_sdk
This commit is contained in:
79
tools/README.md
Normal file
79
tools/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Tools
|
||||
|
||||
一次性诊断/校准工具. 不是 SDK 核心.
|
||||
|
||||
## 密钥提取
|
||||
|
||||
### extract_blowfish_key.sh
|
||||
|
||||
在狗上 (Pi) 运行, 从 Legged_sport 进程内存提取 Blowfish state.
|
||||
|
||||
```bash
|
||||
scp tools/extract_blowfish_key.sh pi@192.168.123.161:/tmp/
|
||||
ssh pi@192.168.123.161 "sudo bash /tmp/extract_blowfish_key.sh"
|
||||
scp pi@192.168.123.161:/tmp/blowfish_dump.tar.gz ./
|
||||
```
|
||||
|
||||
### analyze_blowfish_dump.py
|
||||
|
||||
Mac 端解析 dump, 找出 4168 字节 state.
|
||||
|
||||
```bash
|
||||
python tools/analyze_blowfish_dump.py blowfish_dump.tar.gz
|
||||
```
|
||||
|
||||
## 校准
|
||||
|
||||
### calibrate_joints.py
|
||||
|
||||
手动转动法测每个关节真实活动范围.
|
||||
|
||||
```bash
|
||||
# 狗悬空 + L2+B damping, 然后:
|
||||
python tools/calibrate_joints.py --duration 90
|
||||
# 慢慢把每条腿的每个关节转到极限
|
||||
# 退出时输出 JOINT_LIMITS_MEASURED 字典, 可粘贴到 utils/constants.py
|
||||
```
|
||||
|
||||
## 抢占源管理
|
||||
|
||||
### stop_sportmode.sh
|
||||
|
||||
```bash
|
||||
bash tools/stop_sportmode.sh stop # 杀 keep_sport_alive + Legged_sport + appTransit
|
||||
bash tools/stop_sportmode.sh start # 恢复
|
||||
bash tools/stop_sportmode.sh status # 看当前状态
|
||||
```
|
||||
|
||||
### run_on_mac.sh
|
||||
|
||||
一键流程: 停抢占源 → 跑测试 → (手动恢复).
|
||||
|
||||
```bash
|
||||
bash tools/run_on_mac.sh example
|
||||
bash tools/run_on_mac.sh monitor
|
||||
bash tools/run_on_mac.sh stop
|
||||
bash tools/run_on_mac.sh start
|
||||
```
|
||||
|
||||
### run_on_pi.sh
|
||||
|
||||
把测试脚本部署到 Pi 上跑 (源 IP 跟 Legged_sport 一致, 现在不需要了, 但保留作为备选).
|
||||
|
||||
## 协议研究
|
||||
|
||||
### capture_lowcmd.sh + _raw_capture.py
|
||||
|
||||
Pi 端 tcpdump 抓 Legged_sport 真实发送的 LowCmd 流量.
|
||||
|
||||
```bash
|
||||
bash tools/capture_lowcmd.sh 3
|
||||
```
|
||||
|
||||
### diff_real_lowcmd.sh + _compare_lowcmd.py
|
||||
|
||||
抓真实包 + 跟本地 buildCmd 输出做字节级 diff. 用于发现协议细节差异.
|
||||
|
||||
```bash
|
||||
bash tools/diff_real_lowcmd.sh 3
|
||||
```
|
||||
212
tools/_compare_lowcmd.py
Normal file
212
tools/_compare_lowcmd.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
解析 pcap, 取出真实 LowCmd 密文 → Blowfish 解密 → 跟本地 buildCmd() 字节对比
|
||||
找出剩余差异 (SN/version/frameReserve/CRC/padding 等)
|
||||
"""
|
||||
import sys, os, struct, argparse
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from blowfish_codec import Blowfish
|
||||
from ucl.lowCmd import lowCmd
|
||||
from ucl.complex import motorCmd, motorCmdArray
|
||||
|
||||
|
||||
# LowCmd 字段布局 (基于 buildCmd 源码)
|
||||
LAYOUT = [
|
||||
( 0, 2, 'head'),
|
||||
( 2, 3, 'levelFlag'),
|
||||
( 3, 4, 'frameReserve'),
|
||||
( 4, 12, 'SN'),
|
||||
( 12, 20, 'version'),
|
||||
( 20, 22, 'bandWidth'),
|
||||
( 22, 562, 'motorCmd[20]'),
|
||||
(562, 566, 'bms'),
|
||||
(566, 606, 'wirelessRemote'),
|
||||
(606, 610, 'reserve'),
|
||||
(610, 614, 'CRC'),
|
||||
(614, 616, 'padding'),
|
||||
]
|
||||
|
||||
|
||||
def field_at(offset):
|
||||
"""根据 offset 返回字段名"""
|
||||
for start, end, name in LAYOUT:
|
||||
if start <= offset < end:
|
||||
return name, start, end
|
||||
return '?', -1, -1
|
||||
|
||||
|
||||
def parse_pcap(path):
|
||||
"""提取所有 UDP payload (兼容 SLL / Ethernet)"""
|
||||
packets = []
|
||||
with open(path, 'rb') as f:
|
||||
gh = f.read(24)
|
||||
if len(gh) < 24: return packets
|
||||
magic = struct.unpack('<I', gh[:4])[0]
|
||||
endian = '<' if magic == 0xa1b2c3d4 else '>'
|
||||
linktype = struct.unpack(f'{endian}I', gh[20:24])[0]
|
||||
|
||||
while True:
|
||||
ph = f.read(16)
|
||||
if len(ph) < 16: break
|
||||
ts_sec, ts_usec, caplen, _ = struct.unpack(f'{endian}IIII', ph)
|
||||
pkt = f.read(caplen)
|
||||
if len(pkt) < caplen: break
|
||||
|
||||
if linktype == 1:
|
||||
if len(pkt) < 14 or struct.unpack('>H', pkt[12:14])[0] != 0x0800: continue
|
||||
ip_off = 14
|
||||
elif linktype == 113: # SLL
|
||||
if len(pkt) < 16 or struct.unpack('>H', pkt[14:16])[0] != 0x0800: continue
|
||||
ip_off = 16
|
||||
elif linktype == 276: # SLL2
|
||||
if len(pkt) < 20 or struct.unpack('>H', pkt[:2])[0] != 0x0800: continue
|
||||
ip_off = 20
|
||||
else:
|
||||
continue
|
||||
|
||||
if len(pkt) < ip_off + 20: continue
|
||||
ihl = (pkt[ip_off] & 0x0f) * 4
|
||||
if pkt[ip_off + 9] != 17: continue
|
||||
udp_off = ip_off + ihl
|
||||
if len(pkt) < udp_off + 8: continue
|
||||
_, _, ulen, _ = struct.unpack('>HHHH', pkt[udp_off:udp_off+8])
|
||||
payload = pkt[udp_off+8:udp_off+ulen]
|
||||
packets.append(payload)
|
||||
return packets
|
||||
|
||||
|
||||
def build_local_lowcmd(encrypted=True):
|
||||
"""本地生成 LowCmd (跟 test_sin_leg / lowcmd_codec 一致)"""
|
||||
lcmd = lowCmd()
|
||||
lcmd.SN = bytearray.fromhex('0402030508020100')
|
||||
lcmd.version = bytearray.fromhex('000109000200fc7f')
|
||||
mca = motorCmdArray()
|
||||
for i in range(20):
|
||||
mca.setMotorCmd(i, motorCmd(mode=0, q=0, dq=0, tau=0, Kp=0, Kd=0))
|
||||
lcmd.motorCmd = mca
|
||||
plain = lcmd.buildCmd(debug=False) # 614B
|
||||
if not encrypted:
|
||||
return plain
|
||||
bf = Blowfish.from_state_file('data/lowlevel_captures/blowfish_state.bin')
|
||||
padded = plain + b'\x00' * (8 - len(plain) % 8)
|
||||
return padded, bf.encrypt_ecb(padded) # (明文616, 密文616)
|
||||
|
||||
|
||||
def hex_dump(b, indent=' '):
|
||||
for i in range(0, len(b), 32):
|
||||
print(f"{indent}{i:04x}: {b[i:i+32].hex()}")
|
||||
|
||||
|
||||
def diff(a, b, label_a='REAL', label_b='LOCAL'):
|
||||
"""对比两段字节, 按字段分组报告差异"""
|
||||
n = min(len(a), len(b))
|
||||
print(f"\n{'='*70}")
|
||||
print(f"对比 {label_a} ({len(a)}B) vs {label_b} ({len(b)}B)")
|
||||
print(f"{'='*70}")
|
||||
if len(a) != len(b):
|
||||
print(f"⚠️ 长度不一致")
|
||||
|
||||
# 按字段分组
|
||||
diffs_by_field = {}
|
||||
for i in range(n):
|
||||
if a[i] != b[i]:
|
||||
name, _, _ = field_at(i)
|
||||
diffs_by_field.setdefault(name, []).append(i)
|
||||
|
||||
if not diffs_by_field:
|
||||
print(f"✅ 完全一致")
|
||||
return
|
||||
|
||||
print(f"\n字段级差异:")
|
||||
for name in [n for _, _, n in LAYOUT]:
|
||||
if name not in diffs_by_field: continue
|
||||
idxs = diffs_by_field[name]
|
||||
start, end = None, None
|
||||
for s, e, nm in LAYOUT:
|
||||
if nm == name: start, end = s, e; break
|
||||
seg_a = a[start:end]
|
||||
seg_b = b[start:end]
|
||||
print(f"\n 📍 {name} [{start}..{end}] ({len(idxs)}/{end-start} 字节不同):")
|
||||
print(f" {label_a}: {seg_a.hex()}")
|
||||
print(f" {label_b}: {seg_b.hex()}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('pcap')
|
||||
parser.add_argument('--cipher-out', help='保存第一帧密文')
|
||||
parser.add_argument('--plain-out', help='保存第一帧解密后明文')
|
||||
parser.add_argument('--state', default='data/lowlevel_captures/blowfish_state.bin')
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"\n📥 解析 {args.pcap}...")
|
||||
packets = parse_pcap(args.pcap)
|
||||
print(f" 共 {len(packets)} 个 UDP 包")
|
||||
if not packets:
|
||||
print(" ❌ 空 pcap")
|
||||
return 1
|
||||
|
||||
# 长度统计
|
||||
from collections import Counter
|
||||
sizes = Counter(len(p) for p in packets)
|
||||
print(f" 包大小分布:")
|
||||
for sz, cnt in sorted(sizes.items()):
|
||||
print(f" {sz}B: {cnt}")
|
||||
|
||||
# 取第一帧
|
||||
real_cipher = packets[0]
|
||||
print(f"\n📦 真实 LowCmd 密文 ({len(real_cipher)}B):")
|
||||
hex_dump(real_cipher[:64])
|
||||
print(f" ...")
|
||||
hex_dump(real_cipher[-32:])
|
||||
|
||||
# 解密
|
||||
bf = Blowfish.from_state_file(args.state)
|
||||
aligned = (len(real_cipher) // 8) * 8
|
||||
real_plain = bf.decrypt_ecb(real_cipher[:aligned])
|
||||
print(f"\n🔓 解密后 ({len(real_plain)}B):")
|
||||
hex_dump(real_plain[:64])
|
||||
print(f" ...")
|
||||
hex_dump(real_plain[-32:])
|
||||
|
||||
if not real_plain.startswith(b'\xfe\xef\xff'):
|
||||
print(f"\n❌ 解密失败! 包头不是 feef ff")
|
||||
print(f" Blowfish state 可能不对, 或者不是 LowCmd 流")
|
||||
return 1
|
||||
print(f"\n✅ 包头匹配 feef ff")
|
||||
|
||||
# 保存
|
||||
if args.cipher_out:
|
||||
with open(args.cipher_out, 'wb') as f:
|
||||
f.write(real_cipher)
|
||||
print(f"💾 密文 → {args.cipher_out}")
|
||||
if args.plain_out:
|
||||
with open(args.plain_out, 'wb') as f:
|
||||
f.write(real_plain)
|
||||
print(f"💾 明文 → {args.plain_out}")
|
||||
|
||||
# 本地生成对比
|
||||
local_plain, local_cipher = build_local_lowcmd(encrypted=True)
|
||||
print(f"\n🛠 本地生成 LowCmd (明文 614B padded 616B):")
|
||||
hex_dump(local_plain[:64])
|
||||
print(f" ...")
|
||||
hex_dump(local_plain[-32:])
|
||||
|
||||
# 明文对比 (核心!)
|
||||
diff(real_plain, local_plain, '真实(解密后)', '本地(buildCmd)')
|
||||
|
||||
# 密文对比 (验证加密是否一致)
|
||||
diff(real_cipher, local_cipher, '真实密文', '本地加密')
|
||||
|
||||
# 看连续两帧真实包的变化 (说明哪些字段是动态的)
|
||||
if len(packets) > 1:
|
||||
p2 = bf.decrypt_ecb(packets[1][:aligned])
|
||||
diff(real_plain, p2, '真实#0', '真实#1')
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
121
tools/_raw_capture.py
Normal file
121
tools/_raw_capture.py
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
纯Python 抓包工具 (在Pi上运行, 无需 tcpdump)
|
||||
|
||||
用 AF_PACKET 原始套接字抓所有以太网包, 过滤出指定的 UDP 流, 写成标准 pcap 格式
|
||||
用法: sudo python3 _raw_capture.py <输出pcap> <时长秒> [--filter-host IP] [--filter-port PORT]
|
||||
"""
|
||||
import socket, struct, sys, time, argparse
|
||||
|
||||
ETH_P_ALL = 0x0003
|
||||
|
||||
PCAP_MAGIC = 0xa1b2c3d4
|
||||
LINKTYPE_ETHERNET = 1
|
||||
|
||||
|
||||
def write_pcap_global_header(f):
|
||||
f.write(struct.pack('<IHHIIII',
|
||||
PCAP_MAGIC, 2, 4, 0, 0, 65535, LINKTYPE_ETHERNET))
|
||||
|
||||
|
||||
def write_pcap_packet(f, ts, pkt):
|
||||
ts_sec = int(ts)
|
||||
ts_usec = int((ts - ts_sec) * 1e6)
|
||||
f.write(struct.pack('<IIII', ts_sec, ts_usec, len(pkt), len(pkt)))
|
||||
f.write(pkt)
|
||||
|
||||
|
||||
def parse_packet(pkt):
|
||||
"""返回 (src_ip, dst_ip, proto, src_port, dst_port) 或 None"""
|
||||
if len(pkt) < 14: return None
|
||||
eth_type = struct.unpack('>H', pkt[12:14])[0]
|
||||
if eth_type != 0x0800: return None # 只要IPv4
|
||||
|
||||
# IP 头
|
||||
if len(pkt) < 14 + 20: return None
|
||||
ihl = (pkt[14] & 0x0f) * 4
|
||||
proto = pkt[14 + 9]
|
||||
if proto != 17: return None # 只要 UDP
|
||||
src_ip = '.'.join(str(b) for b in pkt[14+12:14+16])
|
||||
dst_ip = '.'.join(str(b) for b in pkt[14+16:14+20])
|
||||
|
||||
# UDP 头
|
||||
udp_off = 14 + ihl
|
||||
if len(pkt) < udp_off + 8: return None
|
||||
src_port, dst_port = struct.unpack('>HH', pkt[udp_off:udp_off+4])
|
||||
return src_ip, dst_ip, src_port, dst_port
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('output', help='输出pcap路径')
|
||||
parser.add_argument('duration', type=float, help='时长(秒)')
|
||||
parser.add_argument('--filter-host', help='只抓涉及此IP的')
|
||||
parser.add_argument('--filter-port', type=int, help='只抓涉及此端口的')
|
||||
parser.add_argument('--iface', default=None, help='绑定接口名(如eth0)')
|
||||
args = parser.parse_args()
|
||||
|
||||
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ALL))
|
||||
if args.iface:
|
||||
sock.bind((args.iface, 0))
|
||||
print(f" 抓包接口: {args.iface}", flush=True)
|
||||
else:
|
||||
print(f" 抓包接口: ALL", flush=True)
|
||||
sock.settimeout(0.5)
|
||||
|
||||
out = open(args.output, 'wb')
|
||||
write_pcap_global_header(out)
|
||||
|
||||
end = time.time() + args.duration
|
||||
n_total = 0
|
||||
n_match = 0
|
||||
n_ipv4 = 0
|
||||
n_udp = 0
|
||||
from collections import Counter
|
||||
flow_counter = Counter() # 统计 UDP 流向
|
||||
|
||||
print(f" 抓包 {args.duration}s...", flush=True)
|
||||
while time.time() < end:
|
||||
try:
|
||||
pkt, _ = sock.recvfrom(65535)
|
||||
except socket.timeout:
|
||||
continue
|
||||
n_total += 1
|
||||
# 粗略判断: 14字节以太网头 + IP头
|
||||
if len(pkt) >= 14:
|
||||
eth_type = struct.unpack('>H', pkt[12:14])[0]
|
||||
if eth_type == 0x0800:
|
||||
n_ipv4 += 1
|
||||
if len(pkt) >= 14+20 and pkt[14+9] == 17:
|
||||
n_udp += 1
|
||||
info = parse_packet(pkt)
|
||||
if info is None: continue
|
||||
src_ip, dst_ip, sp, dp = info
|
||||
flow_counter[(src_ip, sp, dst_ip, dp)] += 1
|
||||
|
||||
if args.filter_host:
|
||||
if args.filter_host not in (src_ip, dst_ip): continue
|
||||
if args.filter_port:
|
||||
if args.filter_port not in (sp, dp): continue
|
||||
# 每命中第一个新流就打印一下, 方便诊断
|
||||
flow = (src_ip, sp, dst_ip, dp)
|
||||
if not hasattr(main, '_seen'):
|
||||
main._seen = set()
|
||||
if flow not in main._seen:
|
||||
main._seen.add(flow)
|
||||
print(f" 新流: {src_ip}:{sp} → {dst_ip}:{dp}", flush=True)
|
||||
|
||||
write_pcap_packet(out, time.time(), pkt)
|
||||
n_match += 1
|
||||
|
||||
out.close()
|
||||
sock.close()
|
||||
print(f" 总包: {n_total}, IPv4: {n_ipv4}, UDP: {n_udp}, 命中: {n_match}", flush=True)
|
||||
print(f" Top UDP 流 (前20):", flush=True)
|
||||
for flow, cnt in flow_counter.most_common(20):
|
||||
sip, spt, dip, dpt = flow
|
||||
print(f" {sip}:{spt} → {dip}:{dpt} ({cnt})", flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
310
tools/analyze_blowfish_dump.py
Normal file
310
tools/analyze_blowfish_dump.py
Normal file
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
分析从狗上提取的Blowfish密钥dump
|
||||
1. 在内存中搜索Blowfish P-array (key schedule后的运行时值)
|
||||
2. 提取P-array和S-box,反推原始密钥
|
||||
3. 用提取的P-array+S-box解密已知密文,验证正确性
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import struct
|
||||
import tarfile
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Blowfish标准初始常数 (从π推导)
|
||||
P_INITIAL = [
|
||||
0x243f6a88, 0x85a308d3, 0x13198a2e, 0x03707344,
|
||||
0xa4093822, 0x299f31d0, 0x082efa98, 0xec4e6c89,
|
||||
0x452821e6, 0x38d01377, 0xbe5466cf, 0x34e90c6c,
|
||||
0xc0ac29b7, 0xc97c50dd, 0x3f84d5b5, 0xb5470917,
|
||||
0x9216d5d9, 0x8979fb1b
|
||||
]
|
||||
|
||||
S0_INITIAL_FIRST = 0xd1310ba6
|
||||
S1_INITIAL_FIRST = 0x4b7a70e9
|
||||
S2_INITIAL_FIRST = 0xe93d5a68
|
||||
S3_INITIAL_FIRST = 0x3a39ce37
|
||||
|
||||
# 已知明文-密文对 (用于验证)
|
||||
KNOWN_PAIRS = [
|
||||
(bytes.fromhex('0000000000000000'), bytes.fromhex('12aa0354236b66e3')),
|
||||
(bytes.fromhex('feefff0004020305'), bytes.fromhex('5d9874ad7eee5851')),
|
||||
(bytes.fromhex('0802010000010900'), bytes.fromhex('c5ce0887ceb86f91')),
|
||||
]
|
||||
|
||||
|
||||
def find_pattern_locations(data, pattern_bytes):
|
||||
"""在data中查找pattern的所有位置"""
|
||||
positions = []
|
||||
pos = 0
|
||||
while True:
|
||||
p = data.find(pattern_bytes, pos)
|
||||
if p < 0:
|
||||
break
|
||||
positions.append(p)
|
||||
pos = p + 1
|
||||
return positions
|
||||
|
||||
|
||||
def looks_like_blowfish_buffer(data, offset):
|
||||
"""
|
||||
检查offset处是否看起来像Blowfish state:
|
||||
布局假设: P[18] + S0[256] + S1[256] + S2[256] + S3[256]
|
||||
总大小: (18 + 4*256) * 4 = 4168 字节
|
||||
"""
|
||||
BUF_SIZE = 4168
|
||||
if offset + BUF_SIZE > len(data):
|
||||
return False
|
||||
|
||||
# 取出整个buffer
|
||||
buf = data[offset:offset+BUF_SIZE]
|
||||
|
||||
# 解释为uint32列表
|
||||
words = struct.unpack(f'<{BUF_SIZE//4}I', buf)
|
||||
|
||||
# 检查S-box是否包含至少部分初始值
|
||||
# 运行时,P和S-box已被key schedule修改,但所有1042个值的统计特性应该接近随机
|
||||
# 这里只能做粗略的"看起来像加密表"的检查
|
||||
|
||||
# 唯一性检查: 1042个32位值应该几乎都不同
|
||||
unique = len(set(words))
|
||||
if unique < 1000: # 太多重复说明不是表
|
||||
return False
|
||||
|
||||
# 零值检查: 不应该有很多0
|
||||
zeros = sum(1 for w in words if w == 0)
|
||||
if zeros > 50:
|
||||
return False
|
||||
|
||||
# 熵检查: 字节分布应该均匀
|
||||
byte_counts = [0] * 256
|
||||
for b in buf:
|
||||
byte_counts[b] += 1
|
||||
max_count = max(byte_counts)
|
||||
if max_count > len(buf) // 50: # 任何字节出现频率超过2%可疑
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def encrypt_with_state(plaintext_8, P, S0, S1, S2, S3, endian='<'):
|
||||
"""
|
||||
用给定的P-array + S-box加密8字节明文
|
||||
endian: '<' (LE - Unitree style) 或 '>' (BE - standard)
|
||||
"""
|
||||
pack_fmt = endian + 'II'
|
||||
L, R = struct.unpack(pack_fmt, plaintext_8)
|
||||
|
||||
for i in range(16):
|
||||
L ^= P[i]
|
||||
# F(L)
|
||||
a = (L >> 24) & 0xff
|
||||
b = (L >> 16) & 0xff
|
||||
c = (L >> 8) & 0xff
|
||||
d = L & 0xff
|
||||
F = (((S0[a] + S1[b]) & 0xFFFFFFFF) ^ S2[c]) & 0xFFFFFFFF
|
||||
F = (F + S3[d]) & 0xFFFFFFFF
|
||||
R ^= F
|
||||
L, R = R, L
|
||||
|
||||
L, R = R, L
|
||||
R ^= P[16]
|
||||
L ^= P[17]
|
||||
|
||||
return struct.pack(pack_fmt, L, R)
|
||||
|
||||
|
||||
def try_buffer_as_blowfish(buf_data, label="?"):
|
||||
"""
|
||||
把一个4168字节的buffer解释为Blowfish state
|
||||
用已知明密文对验证
|
||||
"""
|
||||
if len(buf_data) < 4168:
|
||||
return None
|
||||
|
||||
# 假设布局1: P[0..17] + S0[0..255] + S1[0..255] + S2[0..255] + S3[0..255]
|
||||
words = struct.unpack('<1042I', buf_data[:4168])
|
||||
P = list(words[0:18])
|
||||
S0 = list(words[18:18+256])
|
||||
S1 = list(words[18+256:18+512])
|
||||
S2 = list(words[18+512:18+768])
|
||||
S3 = list(words[18+768:18+1024])
|
||||
|
||||
# 测试已知对 - 同时尝试LE和BE两种字节序
|
||||
best_matches = 0
|
||||
best_endian = None
|
||||
for endian in ['<', '>']:
|
||||
matches = 0
|
||||
for pt, ct_expected in KNOWN_PAIRS:
|
||||
try:
|
||||
ct = encrypt_with_state(pt, P, S0, S1, S2, S3, endian=endian)
|
||||
if ct == ct_expected:
|
||||
matches += 1
|
||||
except Exception:
|
||||
pass
|
||||
if matches > best_matches:
|
||||
best_matches = matches
|
||||
best_endian = endian
|
||||
|
||||
return best_matches, best_endian
|
||||
|
||||
|
||||
def search_blowfish_state(data, source_name, verbose=False, save_candidates=False):
|
||||
"""在内存dump中搜索Blowfish状态"""
|
||||
print(f"\n=== 在 {source_name} 中搜索 Blowfish state ===")
|
||||
print(f" 大小: {len(data)} 字节")
|
||||
candidates_looking_like_bf = []
|
||||
|
||||
# 策略1: 找S-box[0]首字 (运行时S-box会被修改,但偶尔可能未变)
|
||||
# 实际上key schedule后S-box几乎肯定变化,所以这条不会找到
|
||||
s0_pos = find_pattern_locations(data, struct.pack('<I', S0_INITIAL_FIRST))
|
||||
s1_pos = find_pattern_locations(data, struct.pack('<I', S1_INITIAL_FIRST))
|
||||
print(f" S0初始首字位置数: {len(s0_pos)}")
|
||||
print(f" S1初始首字位置数: {len(s1_pos)}")
|
||||
|
||||
if s0_pos:
|
||||
# 这些是静态初始值,在二进制中也有 (说明是从binary mmap过来的)
|
||||
for p in s0_pos[:3]:
|
||||
print(f" 初始S0@0x{p:x}")
|
||||
|
||||
# 策略2: 暴力搜索 - 假设 4168字节连续Blowfish state
|
||||
# 每4字节对齐位置都试
|
||||
print(f" 暴力搜索 Blowfish state (4字节对齐)...")
|
||||
found = []
|
||||
|
||||
STEP = 4
|
||||
count = 0
|
||||
total = (len(data) - 4168) // STEP
|
||||
|
||||
for offset in range(0, len(data) - 4168, STEP):
|
||||
count += 1
|
||||
if count % 50000 == 0:
|
||||
print(f" 进度: {count}/{total} ({100*count/total:.1f}%)")
|
||||
|
||||
# 快速过滤: 必须looks_like
|
||||
if not looks_like_blowfish_buffer(data, offset):
|
||||
continue
|
||||
|
||||
candidates_looking_like_bf.append(offset)
|
||||
if save_candidates and len(candidates_looking_like_bf) <= 20:
|
||||
cand_path = f'data/lowlevel_captures/candidate_{source_name}_{offset:08x}.bin'
|
||||
os.makedirs('data/lowlevel_captures', exist_ok=True)
|
||||
with open(cand_path, 'wb') as fp:
|
||||
fp.write(data[offset:offset+4168])
|
||||
print(f" 💾 保存候选 -> {cand_path}")
|
||||
|
||||
result = try_buffer_as_blowfish(data[offset:offset+4168])
|
||||
if result is None:
|
||||
continue
|
||||
matches, endian = result
|
||||
if matches and matches > 0:
|
||||
endian_name = 'LE' if endian == '<' else 'BE'
|
||||
print(f"\n ⭐ offset 0x{offset:x}: {matches}/3 已知对匹配! ({endian_name})")
|
||||
found.append((offset, matches, endian))
|
||||
if matches == 3:
|
||||
return found, data[offset:offset+4168]
|
||||
|
||||
if candidates_looking_like_bf:
|
||||
print(f" 共找到 {len(candidates_looking_like_bf)} 个通过启发式过滤的候选位置")
|
||||
|
||||
return found, None
|
||||
|
||||
|
||||
def extract_key_from_state(state_buf):
|
||||
"""从运行时P-array+S-box反推原始密钥 (这是Blowfish key schedule的逆过程)
|
||||
|
||||
技术: Blowfish key schedule是
|
||||
1. P[i] = P_INITIAL[i] ^ key_word(i % keylen)
|
||||
2. 用此P和初始S-box加密 (0,0), 用结果替换P[0..1]
|
||||
3. 继续加密前一次结果,替换P[2..3], 直到P[16..17]
|
||||
4. 同样替换S-box
|
||||
|
||||
要反推key: 由于步骤2-4都是确定性的,只要知道key就能复现
|
||||
而key只在步骤1出现, P[i] XOR P_INITIAL[i] = key_word(i % keylen)
|
||||
"""
|
||||
words = struct.unpack('<1042I', state_buf[:4168])
|
||||
P_runtime = list(words[0:18])
|
||||
|
||||
# 但是! P-array在步骤2-4也被修改了, 所以不能直接 P XOR P_INITIAL
|
||||
# 实际上, 步骤1后的P[i]立即在步骤2中被覆盖, 我们看到的是覆盖后的值
|
||||
# 这意味着无法从最终state直接反推key
|
||||
|
||||
# 但我们已经有了完整的state, 不需要key就能加密/解密!
|
||||
print("\n=== 提取的密钥state ===")
|
||||
print(f"P-array (18 uint32):")
|
||||
for i in range(18):
|
||||
print(f" P[{i:2d}] = 0x{P_runtime[i]:08x}")
|
||||
|
||||
return P_runtime
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('input', help='blowfish_dump.tar.gz 或 包含.bin文件的目录')
|
||||
parser.add_argument('--verbose', '-v', action='store_true')
|
||||
parser.add_argument('--save-candidates', action='store_true',
|
||||
help='保存所有"看起来像Blowfish state"的候选 (即使不能验证已知对)')
|
||||
args = parser.parse_args()
|
||||
|
||||
input_path = Path(args.input)
|
||||
|
||||
# 提取/找到所有bin文件
|
||||
bin_files = []
|
||||
if input_path.is_file() and input_path.suffix == '.gz':
|
||||
# tar.gz - 解压到临时目录
|
||||
import tempfile
|
||||
tmpdir = tempfile.mkdtemp(prefix='blowfish_analyze_')
|
||||
print(f"解压到 {tmpdir}...")
|
||||
with tarfile.open(input_path) as tf:
|
||||
tf.extractall(tmpdir)
|
||||
for f in Path(tmpdir).rglob('*.bin'):
|
||||
bin_files.append(f)
|
||||
elif input_path.is_dir():
|
||||
for f in input_path.rglob('*.bin'):
|
||||
bin_files.append(f)
|
||||
else:
|
||||
print(f"❌ 不支持的输入: {input_path}")
|
||||
return 1
|
||||
|
||||
print(f"找到 {len(bin_files)} 个 .bin 文件")
|
||||
for f in bin_files:
|
||||
size = f.stat().st_size
|
||||
print(f" {f.name} ({size} 字节)")
|
||||
|
||||
# 按大小排序,先搜索小的 (更快)
|
||||
bin_files.sort(key=lambda f: f.stat().st_size)
|
||||
|
||||
found_state = None
|
||||
for f in bin_files:
|
||||
with open(f, 'rb') as fp:
|
||||
data = fp.read()
|
||||
results, state = search_blowfish_state(data, f.name, verbose=args.verbose, save_candidates=args.save_candidates)
|
||||
if state is not None:
|
||||
found_state = state
|
||||
print(f"\n🎉 在 {f.name} 中找到完整匹配!")
|
||||
break
|
||||
elif results:
|
||||
print(f"\n⚠️ 在 {f.name} 中找到 {len(results)} 个部分匹配位置")
|
||||
|
||||
if found_state:
|
||||
P = extract_key_from_state(found_state)
|
||||
# 保存
|
||||
outpath = 'data/lowlevel_captures/blowfish_state.bin'
|
||||
os.makedirs('data/lowlevel_captures', exist_ok=True)
|
||||
with open(outpath, 'wb') as fp:
|
||||
fp.write(found_state)
|
||||
print(f"\n✅ Blowfish state已保存到 {outpath}")
|
||||
print(f" 可以用此state直接加密/解密通信数据")
|
||||
return 0
|
||||
else:
|
||||
print("\n❌ 未找到完整匹配的Blowfish state")
|
||||
print(" 可能原因:")
|
||||
print(" 1. 状态不在已dump的内存区域")
|
||||
print(" 2. 状态被分散存储 (不连续)")
|
||||
print(" 3. dump时机不对 (state还未初始化)")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
99
tools/calibrate_joints.py
Normal file
99
tools/calibrate_joints.py
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""关节活动范围校准 - 手动转动法.
|
||||
|
||||
流程:
|
||||
1. 狗悬空 + L2+B damping
|
||||
2. 跑这个脚本
|
||||
3. 慢慢把每条腿的每个关节转到极限两端 (软力, 听到阻力就停)
|
||||
4. 输出每个关节实测的 min/max/range
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from go1_pro_sdk import MCUClient, LowCmd, JOINT_NAMES
|
||||
from go1_pro_sdk.utils.constants import JOINT_LIMITS_URDF
|
||||
|
||||
|
||||
running = True
|
||||
|
||||
|
||||
def sigint(s, f):
|
||||
global running
|
||||
running = False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--state', default=None)
|
||||
parser.add_argument('--duration', type=float, default=60)
|
||||
args = parser.parse_args()
|
||||
signal.signal(signal.SIGINT, sigint)
|
||||
|
||||
print('🦾 关节活动范围校准')
|
||||
print(f' 采样时长: {args.duration}s')
|
||||
print()
|
||||
print('操作:')
|
||||
print(' 1. 狗悬空, L2+B damping (电机失能)')
|
||||
print(' 2. 按顺序逐条腿/逐个关节转到极限')
|
||||
print(' 3. Ctrl+C 提前结束')
|
||||
input('回车开始采样...')
|
||||
|
||||
with MCUClient(state_path=args.state) as client:
|
||||
recv = client.wake_mcu(50)
|
||||
if recv == 0:
|
||||
print('❌ 无回包')
|
||||
return 1
|
||||
|
||||
damping = LowCmd().all_damping()
|
||||
q_min = [float('inf')] * 12
|
||||
q_max = [float('-inf')] * 12
|
||||
|
||||
start = time.time()
|
||||
last_print = 0
|
||||
n_decoded = 0
|
||||
|
||||
while running and time.time() - start < args.duration:
|
||||
client.send(damping)
|
||||
time.sleep(0.01)
|
||||
state = client.recv_latest()
|
||||
if state is None:
|
||||
continue
|
||||
n_decoded += 1
|
||||
for i in range(12):
|
||||
q = state.motorState[i].q
|
||||
if q < q_min[i]: q_min[i] = q
|
||||
if q > q_max[i]: q_max[i] = q
|
||||
|
||||
now = time.time()
|
||||
if now - last_print >= 2.0:
|
||||
print(f'\r⏱ {now-start:5.1f}/{args.duration:.0f}s 解码 {n_decoded} 帧',
|
||||
end='', flush=True)
|
||||
last_print = now
|
||||
|
||||
print('\n\n实测范围 vs URDF 标称:')
|
||||
print('-' * 80)
|
||||
print(f'{"关节":8} {"实测min":>9} {"实测max":>9} {"范围":>8} '
|
||||
f'{"URDF min":>9} {"URDF max":>9}')
|
||||
for i, name in enumerate(JOINT_NAMES):
|
||||
jt = ('hip' if i % 3 == 0 else 'thigh' if i % 3 == 1 else 'knee')
|
||||
urdf_lo, urdf_hi = JOINT_LIMITS_URDF[jt]
|
||||
lo, hi = q_min[i], q_max[i]
|
||||
if lo == float('inf'):
|
||||
print(f'{name:8} {"未采到":>9}')
|
||||
continue
|
||||
print(f'{name:8} {lo:+9.4f} {hi:+9.4f} {hi-lo:8.4f} '
|
||||
f'{urdf_lo:+9.4f} {urdf_hi:+9.4f}')
|
||||
|
||||
print('\nJOINT_LIMITS_MEASURED = {')
|
||||
for i, name in enumerate(JOINT_NAMES):
|
||||
if q_min[i] == float('inf'): continue
|
||||
print(f" '{name}': ({q_min[i]:+.4f}, {q_max[i]:+.4f}),")
|
||||
print('}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main() or 0)
|
||||
73
tools/capture_lowcmd.sh
Normal file
73
tools/capture_lowcmd.sh
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# 抓 Legged_sport 发给 MCU :8007 的 LowCmd UDP 包, 拉回本地解析
|
||||
#
|
||||
# 用法: bash tools/onboard/capture_lowcmd.sh [seconds]
|
||||
#
|
||||
# 流程:
|
||||
# 1. SSH 到狗启动 tcpdump 抓包 (持续N秒)
|
||||
# 2. 拉回 pcap 到本地
|
||||
# 3. 用 Python 解析 pcap, 提取 LowCmd 字节
|
||||
# 4. 跟本地 buildCmd() 的输出对比
|
||||
|
||||
PI_HOST="${PI_HOST:-192.168.123.161}"
|
||||
PI_USER="${PI_USER:-pi}"
|
||||
DURATION="${1:-5}" # 抓包秒数
|
||||
|
||||
PCAP_REMOTE=/tmp/legged_sport.pcap
|
||||
PCAP_LOCAL=data/lowlevel_captures/legged_sport_$(date +%Y%m%d_%H%M%S).pcap
|
||||
|
||||
echo "============================================"
|
||||
echo "🔍 抓 Legged_sport → MCU 的 LowCmd 包"
|
||||
echo "============================================"
|
||||
echo " 抓包时长: ${DURATION}s"
|
||||
echo " 远端 pcap: ${PCAP_REMOTE}"
|
||||
echo " 本地保存: ${PCAP_LOCAL}"
|
||||
echo ""
|
||||
|
||||
# 先确认 Legged_sport 在运行 (否则抓不到包)
|
||||
echo "[1/4] 确认 Legged_sport 在运行..."
|
||||
if ssh ${PI_USER}@${PI_HOST} "pgrep Legged_sport > /dev/null 2>&1"; then
|
||||
echo " ✅ Legged_sport 在运行"
|
||||
else
|
||||
echo " ⚠️ Legged_sport 未运行! 先恢复 sportMode:"
|
||||
echo " bash tools/onboard/stop_sportmode.sh start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 抓包: 用 tcpdump (已在 Pi 上安装)
|
||||
echo ""
|
||||
echo "[2/4] 启动 tcpdump 抓包 (${DURATION}s, eth0)..."
|
||||
|
||||
ssh ${PI_USER}@${PI_HOST} bash -s <<EOF || true
|
||||
set +e
|
||||
sudo rm -f ${PCAP_REMOTE}
|
||||
sudo timeout ${DURATION} /usr/sbin/tcpdump -i eth0 -w ${PCAP_REMOTE} -s 0 -n \\
|
||||
'udp and host 192.168.123.10 and port 8007' 2>/tmp/tcpdump.log
|
||||
echo "tcpdump 退出码: \$?"
|
||||
if [ -f ${PCAP_REMOTE} ]; then
|
||||
sudo chmod 644 ${PCAP_REMOTE}
|
||||
echo "pcap 大小: \$(stat -c%s ${PCAP_REMOTE}) 字节"
|
||||
cat /tmp/tcpdump.log
|
||||
else
|
||||
echo "❌ pcap 未生成!"
|
||||
cat /tmp/tcpdump.log
|
||||
exit 1
|
||||
fi
|
||||
EOF
|
||||
|
||||
echo " ✅ 抓包完成"
|
||||
|
||||
# 拉回本地
|
||||
echo ""
|
||||
echo "[3/4] 拉回本地..."
|
||||
mkdir -p $(dirname ${PCAP_LOCAL})
|
||||
if ! scp ${PI_USER}@${PI_HOST}:${PCAP_REMOTE} ${PCAP_LOCAL}; then
|
||||
echo "❌ 拉取失败"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✅ ${PCAP_LOCAL} ($(stat -f%z ${PCAP_LOCAL} 2>/dev/null || stat -c%s ${PCAP_LOCAL}) 字节)"
|
||||
|
||||
# 解析+对比
|
||||
echo ""
|
||||
echo "[4/4] 解析+对比..."
|
||||
conda run -n free_dog_sdk python tools/onboard/diff_lowcmd.py ${PCAP_LOCAL}
|
||||
76
tools/diff_real_lowcmd.sh
Normal file
76
tools/diff_real_lowcmd.sh
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# 抓 Legged_sport 真实 LowCmd → 解密 → 跟本地脚本生成的对比
|
||||
#
|
||||
# 前提: Legged_sport 必须在跑且正常向 MCU 发包 (狗在工作状态, 不能是idle)
|
||||
# 用法: bash tools/onboard/diff_real_lowcmd.sh [seconds]
|
||||
#
|
||||
# 输出:
|
||||
# data/lowlevel_captures/real_lowcmd_<ts>.pcap - 抓的原包
|
||||
# data/lowlevel_captures/real_lowcmd_<ts>.bin - 一帧密文 (616B)
|
||||
# data/lowlevel_captures/real_lowcmd_dec_<ts>.bin - 解密后 (616B)
|
||||
# 字节级 diff 输出到屏幕
|
||||
|
||||
PI_HOST="${PI_HOST:-192.168.123.161}"
|
||||
PI_USER="${PI_USER:-pi}"
|
||||
DURATION="${1:-3}"
|
||||
TS=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
PCAP_REMOTE=/tmp/real_lowcmd.pcap
|
||||
PCAP_LOCAL=data/lowlevel_captures/real_lowcmd_${TS}.pcap
|
||||
CIPHER_BIN=data/lowlevel_captures/real_lowcmd_${TS}.bin
|
||||
PLAIN_BIN=data/lowlevel_captures/real_lowcmd_dec_${TS}.bin
|
||||
|
||||
mkdir -p $(dirname ${PCAP_LOCAL})
|
||||
|
||||
echo "============================================"
|
||||
echo "🔍 抓真实 LowCmd 跟本地对比"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# 0. 确认 Legged_sport 在发包 (而不是 idle)
|
||||
echo "[0/4] 确认 Legged_sport 在向 MCU 发包..."
|
||||
COUNT=$(ssh ${PI_USER}@${PI_HOST} \
|
||||
"sudo timeout 1 /usr/sbin/tcpdump -i any -nn -c 5 'udp and dst host 192.168.123.10 and dst port 8007' 2>/dev/null | grep -c 'IP 192' || true")
|
||||
if [ "$COUNT" -lt 1 ]; then
|
||||
echo " ❌ Legged_sport 没在发 LowCmd!"
|
||||
echo " 可能原因:"
|
||||
echo " - 狗在 idle/damping 待机, 需要遥控器唤醒 (L2+A 让它站起来)"
|
||||
echo " - MCU 被其他客户端 (我们 Mac 的脚本) 抢占了"
|
||||
echo " - sportMode 服务挂了"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✅ 检测到 ${COUNT} 个发往 MCU 的包"
|
||||
echo ""
|
||||
|
||||
# 1. 抓包
|
||||
echo "[1/4] tcpdump 抓 ${DURATION}s ..."
|
||||
ssh ${PI_USER}@${PI_HOST} bash -s <<EOF
|
||||
set +e
|
||||
sudo rm -f ${PCAP_REMOTE}
|
||||
sudo timeout ${DURATION} /usr/sbin/tcpdump -i any -w ${PCAP_REMOTE} -s 0 -n \
|
||||
'udp and dst host 192.168.123.10 and dst port 8007' 2>/tmp/tcpdump.log
|
||||
sudo chmod 644 ${PCAP_REMOTE}
|
||||
echo "pcap 大小: \$(stat -c%s ${PCAP_REMOTE}) 字节"
|
||||
EOF
|
||||
|
||||
# 2. 拉回
|
||||
echo ""
|
||||
echo "[2/4] 拉回本地..."
|
||||
scp ${PI_USER}@${PI_HOST}:${PCAP_REMOTE} ${PCAP_LOCAL}
|
||||
echo " ✅ ${PCAP_LOCAL}"
|
||||
|
||||
# 3. 解析+解密+对比
|
||||
echo ""
|
||||
echo "[3/4] 解析+解密..."
|
||||
conda run -n free_dog_sdk python tools/onboard/_compare_lowcmd.py \
|
||||
${PCAP_LOCAL} \
|
||||
--cipher-out ${CIPHER_BIN} \
|
||||
--plain-out ${PLAIN_BIN}
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo "完成. 文件:"
|
||||
echo " pcap: ${PCAP_LOCAL}"
|
||||
echo " 密文: ${CIPHER_BIN}"
|
||||
echo " 明文: ${PLAIN_BIN}"
|
||||
echo "============================================"
|
||||
208
tools/extract_blowfish_key.sh
Normal file
208
tools/extract_blowfish_key.sh
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/bin/bash
|
||||
# Blowfish密钥提取脚本 - 在树莓派上以root运行
|
||||
# 用法: sudo bash extract_blowfish_key.sh [PID]
|
||||
#
|
||||
# 安全保证:
|
||||
# - 只读取内存,不修改任何寄存器或内存
|
||||
# - GDB attach时间最短化 (<30秒)
|
||||
# - 自动detach,即使中途异常
|
||||
|
||||
set -e
|
||||
|
||||
PID=${1:-$(pgrep -f Legged_sport | head -1)}
|
||||
OUTDIR=/tmp/blowfish_dump_$$
|
||||
mkdir -p $OUTDIR
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
echo "❌ 找不到 Legged_sport 进程"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查PID存在
|
||||
if [ ! -d "/proc/$PID" ]; then
|
||||
echo "❌ PID $PID 不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "============================================"
|
||||
echo "🐕 Blowfish密钥提取"
|
||||
echo "============================================"
|
||||
echo "PID: $PID"
|
||||
echo "输出: $OUTDIR"
|
||||
echo ""
|
||||
|
||||
# 检查当前狗的状态 (通过/tmp下的状态文件,如果有的话)
|
||||
# 提示用户确认安全状态
|
||||
echo "⚠️ 确认狗的状态:"
|
||||
echo " - 趴下(damping)或在脚架上"
|
||||
echo " - 当前是高级模式 (未在执行运动指令)"
|
||||
echo ""
|
||||
read -p "确认安全状态? (yes/no): " confirm
|
||||
if [ "$confirm" != "yes" ]; then
|
||||
echo "❌ 用户取消"
|
||||
rm -rf $OUTDIR
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ===== 第1步: 保存进程内存映射 =====
|
||||
echo ""
|
||||
echo "[1/5] 保存进程内存映射..."
|
||||
cat /proc/$PID/maps > $OUTDIR/maps.txt
|
||||
echo " ✅ /proc/$PID/maps -> $OUTDIR/maps.txt"
|
||||
|
||||
# 找出代码段范围 (anonymous r-x 段,是UPX解压后的代码)
|
||||
ANON_RX=$(awk '/r-xp/ && !/\// {print $1}' $OUTDIR/maps.txt | head -1)
|
||||
if [ -n "$ANON_RX" ]; then
|
||||
ANON_RX_START=$(echo $ANON_RX | cut -d- -f1)
|
||||
ANON_RX_END=$(echo $ANON_RX | cut -d- -f2)
|
||||
echo " 📌 匿名代码段(UPX解压): 0x$ANON_RX_START - 0x$ANON_RX_END"
|
||||
fi
|
||||
|
||||
# 找堆段
|
||||
HEAP=$(awk '/\[heap\]/ {print $1}' $OUTDIR/maps.txt | head -1)
|
||||
if [ -n "$HEAP" ]; then
|
||||
HEAP_START=$(echo $HEAP | cut -d- -f1)
|
||||
HEAP_END=$(echo $HEAP | cut -d- -f2)
|
||||
echo " 📌 堆: 0x$HEAP_START - 0x$HEAP_END"
|
||||
fi
|
||||
|
||||
# ===== 第2步: 准备GDB脚本 (只读,不修改) =====
|
||||
echo ""
|
||||
echo "[2/5] 生成GDB脚本..."
|
||||
|
||||
cat > $OUTDIR/gdb_script.gdb <<'GDBSCRIPT'
|
||||
# GDB脚本: 只读模式提取Blowfish密钥
|
||||
# 严格规定: 不修改任何寄存器、不写入任何内存、不调用任何函数
|
||||
|
||||
set pagination off
|
||||
set print pretty on
|
||||
set logging redirect on
|
||||
set logging overwrite on
|
||||
|
||||
# 1. 进程基本信息
|
||||
printf "=== 进程信息 ===\n"
|
||||
info proc mappings
|
||||
|
||||
printf "\n=== 寄存器状态 ===\n"
|
||||
info registers
|
||||
|
||||
printf "\n=== 调用栈 ===\n"
|
||||
bt 5
|
||||
|
||||
# 2. 不设断点,只dump关键内存
|
||||
|
||||
# 完成
|
||||
printf "\n=== 完成,准备detach ===\n"
|
||||
GDBSCRIPT
|
||||
|
||||
echo " ✅ $OUTDIR/gdb_script.gdb"
|
||||
|
||||
# ===== 第3步: GDB attach (最短时间) =====
|
||||
echo ""
|
||||
echo "[3/5] GDB attach (目标<5秒)..."
|
||||
|
||||
START_TS=$(date +%s)
|
||||
|
||||
# 用-batch模式,自动执行后立即退出
|
||||
timeout 30 gdb -batch \
|
||||
-p $PID \
|
||||
-x $OUTDIR/gdb_script.gdb \
|
||||
> $OUTDIR/gdb_output.txt 2>&1
|
||||
|
||||
END_TS=$(date +%s)
|
||||
ELAPSED=$((END_TS - START_TS))
|
||||
echo " ✅ GDB完成,耗时 ${ELAPSED}s"
|
||||
|
||||
# 检查狗是否还在线
|
||||
if ! kill -0 $PID 2>/dev/null; then
|
||||
echo " ⚠️ 警告: Legged_sport进程消失!"
|
||||
fi
|
||||
|
||||
# ===== 第4步: dump堆内存 (用/proc/PID/mem直接读取,比GDB快) =====
|
||||
echo ""
|
||||
echo "[4/5] 直接读取堆内存 (不用GDB)..."
|
||||
|
||||
if [ -n "$HEAP_START" ]; then
|
||||
HEAP_SIZE=$((0x$HEAP_END - 0x$HEAP_START))
|
||||
HEAP_OFFSET=$((0x$HEAP_START))
|
||||
echo " 读取堆 ${HEAP_SIZE} 字节..."
|
||||
|
||||
# 用dd直接从/proc/$PID/mem读 (bs=65536大块读取,比bs=1快1000x)
|
||||
dd if=/proc/$PID/mem of=$OUTDIR/heap.bin \
|
||||
bs=65536 count=$HEAP_SIZE skip=$HEAP_OFFSET \
|
||||
iflag=skip_bytes,count_bytes 2>/dev/null || \
|
||||
echo " ⚠️ /proc/mem读取失败,可能需要ptrace"
|
||||
|
||||
if [ -f $OUTDIR/heap.bin ]; then
|
||||
HEAPACTUAL=$(stat -c%s $OUTDIR/heap.bin)
|
||||
echo " ✅ heap.bin ($HEAPACTUAL 字节)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 也dump匿名代码段 (UPX解压后的代码,含运行时S-box)
|
||||
if [ -n "$ANON_RX_START" ]; then
|
||||
RX_SIZE=$((0x$ANON_RX_END - 0x$ANON_RX_START))
|
||||
RX_OFFSET=$((0x$ANON_RX_START))
|
||||
echo " 读取匿名代码段 ${RX_SIZE} 字节..."
|
||||
|
||||
dd if=/proc/$PID/mem of=$OUTDIR/anon_code.bin \
|
||||
bs=65536 count=$RX_SIZE skip=$RX_OFFSET \
|
||||
iflag=skip_bytes,count_bytes 2>/dev/null
|
||||
|
||||
if [ -f $OUTDIR/anon_code.bin ]; then
|
||||
CODEACTUAL=$(stat -c%s $OUTDIR/anon_code.bin)
|
||||
echo " ✅ anon_code.bin ($CODEACTUAL 字节)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 也dump所有匿名rw段 (含运行时S-box和P-array)
|
||||
echo " 读取所有匿名rw段..."
|
||||
RW_COUNT=0
|
||||
awk '/rw-p/ && !/\// && !/\[stack\]/ && !/\[vvar\]/ {print $1}' $OUTDIR/maps.txt | while read range; do
|
||||
START=$(echo $range | cut -d- -f1)
|
||||
END=$(echo $range | cut -d- -f2)
|
||||
SIZE=$((0x$END - 0x$START))
|
||||
OFFSET=$((0x$START))
|
||||
|
||||
# 只dump合理大小的段 (1KB - 64MB)
|
||||
if [ $SIZE -gt 1024 ] && [ $SIZE -lt 67108864 ]; then
|
||||
OUTFILE=$OUTDIR/rw_${START}.bin
|
||||
dd if=/proc/$PID/mem of=$OUTFILE \
|
||||
bs=65536 count=$SIZE skip=$OFFSET \
|
||||
iflag=skip_bytes,count_bytes 2>/dev/null
|
||||
|
||||
if [ -f $OUTFILE ] && [ -s $OUTFILE ]; then
|
||||
echo " rw_${START}.bin ($SIZE 字节)"
|
||||
RW_COUNT=$((RW_COUNT + 1))
|
||||
else
|
||||
rm -f $OUTFILE
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# ===== 第5步: 打包 =====
|
||||
echo ""
|
||||
echo "[5/5] 打包输出..."
|
||||
cd /tmp
|
||||
TARBALL=blowfish_dump_$(date +%Y%m%d_%H%M%S).tar.gz
|
||||
tar czf $TARBALL -C $OUTDIR .
|
||||
mv $TARBALL /tmp/blowfish_dump.tar.gz
|
||||
echo " ✅ /tmp/blowfish_dump.tar.gz ($(stat -c%s /tmp/blowfish_dump.tar.gz) 字节)"
|
||||
|
||||
# 检查狗是否还在线
|
||||
echo ""
|
||||
if kill -0 $PID 2>/dev/null; then
|
||||
echo "✅ Legged_sport仍在运行 (PID $PID)"
|
||||
else
|
||||
echo "❌ Legged_sport已退出!"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo "完成!"
|
||||
echo "============================================"
|
||||
echo "数据位置: /tmp/blowfish_dump.tar.gz"
|
||||
echo "中间文件: $OUTDIR/"
|
||||
echo ""
|
||||
echo "从Mac拉取:"
|
||||
echo " scp pi@192.168.123.161:/tmp/blowfish_dump.tar.gz ./data/lowlevel_captures/"
|
||||
83
tools/run_on_mac.sh
Normal file
83
tools/run_on_mac.sh
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# 在 Mac 上直连 MCU 跑控制 (需要先停 Pi 上的抢占源)
|
||||
#
|
||||
# 用法:
|
||||
# bash tools/onboard/run_on_mac.sh example # 跑 example_position_pro.py
|
||||
# bash tools/onboard/run_on_mac.sh sin # 跑 test_sin_leg.py
|
||||
# bash tools/onboard/run_on_mac.sh monitor # 只读监听
|
||||
# bash tools/onboard/run_on_mac.sh stop # 只停 Pi 上的抢占源
|
||||
# bash tools/onboard/run_on_mac.sh start # 恢复 sportMode
|
||||
|
||||
PI_HOST="${PI_HOST:-192.168.123.161}"
|
||||
PI_USER="${PI_USER:-pi}"
|
||||
TEST="${1:-example}"
|
||||
|
||||
stop_competitors() {
|
||||
echo "=== 停 Pi 上的 MCU:8007 抢占源 ==="
|
||||
ssh ${PI_USER}@${PI_HOST} bash <<'EOF' || true
|
||||
set +e
|
||||
if sudo pkill -9 -f keep_sport_alive 2>/dev/null; then echo ' ✅ keep_sport_alive.sh'; else echo ' (无看门狗)'; fi
|
||||
sleep 0.3
|
||||
if sudo pkill -9 -f Legged_sport 2>/dev/null; then echo ' ✅ Legged_sport'; else echo ' (无)'; fi
|
||||
if sudo pkill -9 -f appTransit 2>/dev/null; then echo ' ✅ appTransit'; else echo ' (无)'; fi
|
||||
sleep 0.5
|
||||
echo '--- 验证残留 ---'
|
||||
if pgrep -fl 'Legged_sport|appTransit|keep_sport_alive' 2>/dev/null; then
|
||||
echo ' ⚠️ 仍有残留'
|
||||
else
|
||||
echo ' ✅ 全部清除'
|
||||
fi
|
||||
EOF
|
||||
}
|
||||
|
||||
restart_sport() {
|
||||
echo "=== 恢复 sportMode (Pi 端 watchdog 会拉起 Legged_sport) ==="
|
||||
ssh ${PI_USER}@${PI_HOST} \
|
||||
"sudo bash -c 'nohup /home/pi/Unitree/autostart/sportMode/keep_sport_alive.sh > /dev/null 2>&1 &' 2>/dev/null || \
|
||||
sudo systemctl start sportMode 2>/dev/null || \
|
||||
echo '⚠️ 请手动重启或重启狗'"
|
||||
sleep 2
|
||||
ssh ${PI_USER}@${PI_HOST} "pgrep -fl Legged_sport || echo '⚠️ Legged_sport 未运行'"
|
||||
}
|
||||
|
||||
case "$TEST" in
|
||||
stop)
|
||||
stop_competitors
|
||||
;;
|
||||
start)
|
||||
restart_sport
|
||||
;;
|
||||
example)
|
||||
stop_competitors
|
||||
echo ""
|
||||
echo "=== Mac 直连跑 example_position_pro.py ==="
|
||||
conda run -n free_dog_sdk python tools/onboard/example_position_pro.py \
|
||||
--state data/lowlevel_captures/blowfish_state.bin \
|
||||
--max-steps 5000
|
||||
;;
|
||||
sin)
|
||||
stop_competitors
|
||||
echo ""
|
||||
echo "=== Mac 直连跑 test_sin_leg.py ==="
|
||||
conda run -n free_dog_sdk python tools/onboard/test_sin_leg.py \
|
||||
--amplitude 0.3 --freq 0.5 --duration 10
|
||||
;;
|
||||
monitor)
|
||||
stop_competitors
|
||||
echo ""
|
||||
echo "=== Mac 直连只读监听 30 秒 ==="
|
||||
conda run -n free_dog_sdk python tools/onboard/monitor_state.py \
|
||||
--state data/lowlevel_captures/blowfish_state.bin \
|
||||
--duration 30 --verbose
|
||||
;;
|
||||
*)
|
||||
echo "用法: bash run_on_mac.sh [example|sin|monitor|stop|start]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo "测试结束. 恢复 sportMode:"
|
||||
echo " bash tools/onboard/run_on_mac.sh start"
|
||||
echo "============================================"
|
||||
104
tools/run_on_pi.sh
Normal file
104
tools/run_on_pi.sh
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/bin/bash
|
||||
# 部署+执行测试 (在树莓派上, 源IP=192.168.123.161)
|
||||
#
|
||||
# 用法:
|
||||
# bash tools/onboard/run_on_pi.sh sin # 单腿正弦
|
||||
# bash tools/onboard/run_on_pi.sh damping # 零力矩连通性
|
||||
# bash tools/onboard/run_on_pi.sh deploy # 仅部署不执行
|
||||
set -e
|
||||
|
||||
PI_HOST="${PI_HOST:-192.168.123.161}"
|
||||
PI_USER="${PI_USER:-pi}"
|
||||
TEST="${1:-sin}"
|
||||
REMOTE_DIR=/tmp/dog_test
|
||||
|
||||
echo "=== 部署到 ${PI_USER}@${PI_HOST}:${REMOTE_DIR} ==="
|
||||
|
||||
# 1. 创建远程目录结构
|
||||
ssh ${PI_USER}@${PI_HOST} "rm -rf ${REMOTE_DIR} && mkdir -p ${REMOTE_DIR}/ucl"
|
||||
|
||||
# 2. 拷贝文件 (所有.py平铺在REMOTE_DIR, ucl/子目录放SDK文件)
|
||||
echo "[1/2] 拷贝文件..."
|
||||
scp tools/onboard/blowfish_codec.py \
|
||||
tools/onboard/build_pro_lowcmd.py \
|
||||
tools/onboard/test_sin_leg.py \
|
||||
tools/onboard/test_lowcmd.py \
|
||||
tools/onboard/example_position_pro.py \
|
||||
data/lowlevel_captures/blowfish_state.bin \
|
||||
${PI_USER}@${PI_HOST}:${REMOTE_DIR}/
|
||||
|
||||
# 空 __init__.py 让 ucl 成为 package
|
||||
ssh ${PI_USER}@${PI_HOST} "touch ${REMOTE_DIR}/ucl/__init__.py"
|
||||
scp ucl/lowCmd.py ucl/lowState.py ucl/common.py ucl/complex.py ucl/enums.py \
|
||||
${PI_USER}@${PI_HOST}:${REMOTE_DIR}/ucl/
|
||||
|
||||
# 提前杀掉 MCU:8007 的所有抢占者:
|
||||
# 1. keep_sport_alive.sh - Legged_sport 看门狗 (每0.2秒重启Legged_sport, 必须先杀)
|
||||
# 2. Legged_sport - 主控制循环
|
||||
# 3. appTransit - 也跟 MCU:8007 通信
|
||||
echo ""
|
||||
echo "=== 停止 MCU:8007 抢占源 (顺序: 看门狗 → Legged_sport → appTransit) ==="
|
||||
ssh ${PI_USER}@${PI_HOST} bash <<'EOF' || true
|
||||
set +e
|
||||
if sudo pkill -9 -f keep_sport_alive 2>/dev/null; then
|
||||
echo ' ✅ keep_sport_alive.sh (看门狗)'
|
||||
else
|
||||
echo ' (无看门狗)'
|
||||
fi
|
||||
sleep 0.3
|
||||
if sudo pkill -9 -f Legged_sport 2>/dev/null; then
|
||||
echo ' ✅ Legged_sport'
|
||||
else
|
||||
echo ' (无)'
|
||||
fi
|
||||
if sudo pkill -9 -f appTransit 2>/dev/null; then
|
||||
echo ' ✅ appTransit'
|
||||
else
|
||||
echo ' (无)'
|
||||
fi
|
||||
sleep 0.5
|
||||
echo '--- 验证残留: ---'
|
||||
if pgrep -fl 'Legged_sport|appTransit|keep_sport_alive' 2>/dev/null; then
|
||||
echo ' ⚠️ 仍有残留, 可能看门狗在别处'
|
||||
else
|
||||
echo ' ✅ 全部清除'
|
||||
fi
|
||||
EOF
|
||||
sleep 1
|
||||
|
||||
echo ""
|
||||
echo "[2/2] 执行测试..."
|
||||
|
||||
case "$TEST" in
|
||||
sin)
|
||||
echo "=== 单腿正弦测试 (Pi端) ==="
|
||||
ssh -t ${PI_USER}@${PI_HOST} \
|
||||
"cd ${REMOTE_DIR} && python3 test_sin_leg.py --amplitude 0.3 --freq 0.5 --duration 10"
|
||||
;;
|
||||
damping)
|
||||
echo "=== 零力矩连通性测试 (Pi端) ==="
|
||||
ssh -t ${PI_USER}@${PI_HOST} \
|
||||
"cd ${REMOTE_DIR} && python3 test_lowcmd.py --skip-stop --skip-mqtt"
|
||||
;;
|
||||
example)
|
||||
echo "=== example_position(lowlevel) PRO 版本 (Pi端) ==="
|
||||
ssh -t ${PI_USER}@${PI_HOST} \
|
||||
"cd ${REMOTE_DIR} && python3 example_position_pro.py --max-steps 5000"
|
||||
;;
|
||||
deploy)
|
||||
echo "✅ 已部署到 ${PI_USER}@${PI_HOST}:${REMOTE_DIR}"
|
||||
echo " ssh ${PI_USER}@${PI_HOST}"
|
||||
echo " cd ${REMOTE_DIR} && python3 test_sin_leg.py ..."
|
||||
;;
|
||||
*)
|
||||
echo "用法: bash run_on_pi.sh [sin|damping|example|deploy]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo "恢复 sportMode:"
|
||||
echo " bash tools/onboard/stop_sportmode.sh start"
|
||||
echo " 或 ssh ${PI_USER}@${PI_HOST} sudo systemctl start sportMode"
|
||||
echo "============================================"
|
||||
121
tools/stop_sportmode.sh
Executable file
121
tools/stop_sportmode.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
# 停止/启动 sportMode 服务 (从Mac SSH到狗执行)
|
||||
# 用法:
|
||||
# bash tools/onboard/stop_sportmode.sh stop # 停止sportMode,进入低层控制
|
||||
# bash tools/onboard/stop_sportmode.sh start # 重新启动sportMode
|
||||
# bash tools/onboard/stop_sportmode.sh status # 检查状态
|
||||
|
||||
set -e
|
||||
|
||||
PI_HOST="${PI_HOST:-192.168.123.161}"
|
||||
PI_USER="${PI_USER:-pi}"
|
||||
|
||||
# 等待用户按键继续的 pause 函数
|
||||
pause() {
|
||||
echo ""
|
||||
read -p "按 Enter 继续..." dummy
|
||||
}
|
||||
|
||||
cmd="${1:-status}"
|
||||
|
||||
case "$cmd" in
|
||||
stop)
|
||||
echo "============================================"
|
||||
echo "⚠️ 停止 sportMode — 进入低层控制"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "这将会:"
|
||||
echo " 1. 先发 damping 让电机失能"
|
||||
echo " 2. 停止 Legged_sport 进程"
|
||||
echo " 3. 停止后, :8007 将只接受你的 LowCmd"
|
||||
echo ""
|
||||
echo "⚠️ 停止后狗会软瘫, 确保:"
|
||||
echo " - 狗趴在地上或脚架上"
|
||||
echo " - 遥控器在手边 (L2+B 紧急停止)"
|
||||
echo " - 10分钟内必须重启 (安全看门狗)"
|
||||
echo ""
|
||||
read -p "确认停止? (yes/no): " confirm
|
||||
if [ "$confirm" != "yes" ]; then
|
||||
echo "取消"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[1/3] 发送 damping ..."
|
||||
ssh ${PI_USER}@${PI_HOST} \
|
||||
"mosquitto_pub -h localhost -t controller/action -m damping" 2>/dev/null || \
|
||||
echo " (MQTT可能不可用, 继续...)"
|
||||
|
||||
sleep 2
|
||||
|
||||
echo "[2/3] 停止 Legged_sport 看门狗 + Legged_sport + appTransit ..."
|
||||
# 必须按顺序: 先杀看门狗 keep_sport_alive.sh, 否则它每 0.2s 重拉 Legged_sport
|
||||
# 同时杀 appTransit, 它也跟 MCU:8007 通信抢占
|
||||
ssh ${PI_USER}@${PI_HOST} \
|
||||
"sudo pkill -9 -f keep_sport_alive 2>/dev/null && echo ' ✅ keep_sport_alive (看门狗)' || echo ' (无看门狗)';
|
||||
sudo pkill -9 -f Legged_sport 2>/dev/null && echo ' ✅ Legged_sport' || echo ' (无)';
|
||||
sudo pkill -9 -f appTransit 2>/dev/null && echo ' ✅ appTransit' || echo ' (无)';
|
||||
sudo systemctl stop sportMode 2>/dev/null || true"
|
||||
|
||||
sleep 1
|
||||
|
||||
echo "[3/3] 验证..."
|
||||
if ssh ${PI_USER}@${PI_HOST} "! pgrep Legged_sport > /dev/null 2>&1"; then
|
||||
echo " ✅ Legged_sport 已完全停止"
|
||||
elif ssh ${PI_USER}@${PI_HOST} "pgrep Legged_sport > /dev/null 2>&1"; then
|
||||
echo " ⚠️ Legged_sport 可能仍在运行(被STOP暂停)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo "✅ sportMode 已停止, 现在可以做低层控制测试"
|
||||
echo ""
|
||||
echo "恢复: bash tools/onboard/stop_sportmode.sh start"
|
||||
echo "============================================"
|
||||
;;
|
||||
|
||||
start)
|
||||
echo "============================================"
|
||||
echo "🔄 恢复 sportMode"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
echo "[1/2] 恢复 Legged_sport ..."
|
||||
ssh ${PI_USER}@${PI_HOST} \
|
||||
"sudo systemctl restart sportMode 2>/dev/null || sudo systemctl restart legged_sport 2>/dev/null || (sudo killall -CONT Legged_sport 2>/dev/null) || echo '尝试手动启动'" && \
|
||||
echo " ✅ 重启命令已执行" || \
|
||||
echo " ⚠️ 重启失败, 尝试: sudo systemctl start sportMode 或重启狗"
|
||||
|
||||
sleep 3
|
||||
|
||||
echo "[2/2] 验证..."
|
||||
if ssh ${PI_USER}@${PI_HOST} "pgrep Legged_sport > /dev/null 2>&1"; then
|
||||
echo " ✅ Legged_sport 已恢复运行"
|
||||
else
|
||||
echo " ⚠️ Legged_sport 未运行, 可能需要重启狗"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ sportMode 已恢复"
|
||||
;;
|
||||
|
||||
status)
|
||||
echo "检查 sportMode 状态..."
|
||||
echo ""
|
||||
echo "--- Legged_sport 进程 ---"
|
||||
ssh ${PI_USER}@${PI_HOST} "ps aux | grep -v grep | grep Legged_sport || echo ' 未运行'"
|
||||
echo ""
|
||||
echo "--- systemd 服务 ---"
|
||||
ssh ${PI_USER}@${PI_HOST} \
|
||||
"systemctl status sportMode 2>/dev/null || systemctl status Legged_sport 2>/dev/null || echo ' 服务名未知'" 2>/dev/null | head -10
|
||||
echo ""
|
||||
echo "--- MQTT 状态 ---"
|
||||
ssh ${PI_USER}@${PI_HOST} \
|
||||
"mosquitto_sub -h localhost -t programming/action -C 1 -W 2 2>/dev/null || echo ' (无消息/timeout)'"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "用法: bash stop_sportmode.sh [stop|start|status]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user