feat(pybot): bot4cj 的 Python 迁移版(.mt 解析 + 硬件驱动 + 插件系统)

- parser: FSM 解析器与仓颉版逐语义对齐,修 S01/S02/S04/Q05
- robot/hardvars: 六类硬件变量工厂,修 r.cj:911 重名 FIXME、Q07 倒序释放
- plugin: Python 插件替代 Lua 桥接,修 S07 三级路径查找
- hd: i2c(每fd锁 Q03/S06)/spi/servo/kalman/mpu6050(S05/Q06)/speaker(S03 实声)
- modules: PCA9685 与 Lua 版逐字节对齐;ADS1115 重写(experimental);ADS1256 空桩
- tests: 42 项 pytest 全绿(Mock 总线 journal 断言)
- CLI: --dry-run / --test roundtrip / --verbose;运行时零第三方依赖
This commit is contained in:
qing committed 2026-08-09 18:57:03 +00:00
1 parent 85eaa2114e
commit bba7ee3eb4
31 files changed
+2155

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""硬件层(Linux I2C / SPI / 舵机 / 传感器 / 扬声器)"""
+219
View File
@@ -0,0 +1,219 @@
"""Linux I2C 封装 —— 移植自 bot4cj/src/hd/I2C.cj + src/cpp/I2C.c
纯标准库实现(fcntl.ioctl + os.read/write),零第三方依赖。
与仓颉版的差异(审计修复,语义不变):
S06: read_reg 返回 0..255 无符号 int(仓颉版返回有符号 Int8,
16 位拼接时符号扩展破坏低字节);
Q03: 每 fd 锁用 dict[fd → threading.Lock](仓颉版 20 槽数组 O(n) 扫描且容量受限)。
后端可切换:默认真实总线;use_mock() 切换为记录型 Mock(--dry-run 与单测共用),
Mock 的写序列 journal 是与仓颉版做逐字节 diff 的等价性验证工件(方案 §6.3)。
"""
import fcntl
import logging
import os
import threading
from contextlib import contextmanager
log = logging.getLogger("pybot.i2c")
I2C_SLAVE = 0x0703 # at least on arm64(与仓颉版一致)
class I2CError(OSError):
"""I2C 设备异常(对应仓颉版 I2cExcept 系列)"""
# ---------------- 后端(真实总线 / Mock) ----------------
class _RealBackend:
"""真实 I2C 总线:glibc open/ioctl/read/write 一层到底"""
def open(self, path):
return os.open(path, os.O_RDWR)
def close(self, fd):
os.close(fd)
def set_addr(self, fd, addr):
fcntl.ioctl(fd, I2C_SLAVE, addr)
def write_reg(self, fd, reg, val):
if os.write(fd, bytes([reg & 0xFF, val & 0xFF])) != 2:
raise I2CError(f"failed to write i2c reg 0x{reg:02X} (fd={fd})")
def read_reg(self, fd, reg):
# 读取寄存器之前要先写寄存器地址
if os.write(fd, bytes([reg & 0xFF])) != 1:
raise I2CError(f"failed to select i2c reg 0x{reg:02X} (fd={fd})")
data = os.read(fd, 1)
if len(data) != 1:
raise I2CError(f"failed to read i2c reg 0x{reg:02X} (fd={fd})")
return data[0] # 0..255 无符号(修复 S06)
def write_block(self, fd, reg, data):
"""向 reg 起连写多字节(ADS1115 等 16 位寄存器设备)"""
buf = bytes([reg & 0xFF]) + bytes(data)
if os.write(fd, buf) != len(buf):
raise I2CError(f"failed to write i2c block at reg 0x{reg:02X} (fd={fd})")
def read_block(self, fd, reg, n):
"""从 reg 起连读 n 字节(自动递增寄存器,MPU6050 一次读 14 字节)"""
if os.write(fd, bytes([reg & 0xFF])) != 1:
raise I2CError(f"failed to select i2c reg 0x{reg:02X} (fd={fd})")
data = os.read(fd, n)
if len(data) != n:
raise I2CError(f"short read at i2c reg 0x{reg:02X}: "
f"{len(data)}/{n} bytes (fd={fd})")
return data
class MockBackend:
"""记录型 Mock 总线:不碰硬件,记录全部操作序列供断言/diff"""
def __init__(self):
self.journal = [] # [(op, ...), ...] 全部操作按序记录
self.reg_values = {} # {(fd, reg): int} 单寄存器读注入
self.block_values = {} # {(fd, reg): bytes} 块读注入
self._next_fd = 1000
def open(self, path):
fd = self._next_fd
self._next_fd += 1
self.journal.append(("open", path, fd))
return fd
def close(self, fd):
self.journal.append(("close", fd))
def set_addr(self, fd, addr):
self.journal.append(("set_addr", fd, addr))
def write_reg(self, fd, reg, val):
self.journal.append(("write_reg", fd, reg & 0xFF, val & 0xFF))
def read_reg(self, fd, reg):
self.journal.append(("read_reg", fd, reg))
return self.reg_values.get((fd, reg), 0) & 0xFF
def write_block(self, fd, reg, data):
self.journal.append(("write_block", fd, reg & 0xFF, bytes(data)))
def read_block(self, fd, reg, n):
self.journal.append(("read_block", fd, reg, n))
return self.block_values.get((fd, reg), bytes(n))
_backend = _RealBackend()
def use_mock():
"""切换到 Mock 总线(--dry-run / 单测),返回 MockBackend 以便读取 journal"""
global _backend
_backend = MockBackend()
return _backend
def use_real():
"""切换回真实总线"""
global _backend
_backend = _RealBackend()
# ---------------- I2C 锁具(每 fd 一把,修复 Q03) ----------------
# 同一 fd 上"设地址 + 读/写寄存器"必须是原子序列(多设备共享总线)
_MAIN_LOCK = threading.Lock() # 主锁,操作锁具注册表时使用
_LOCKS = {} # {fd: threading.Lock}
def _register_lock(fd):
with _MAIN_LOCK:
_LOCKS[fd] = threading.Lock()
def _unregister_lock(fd):
with _MAIN_LOCK:
_LOCKS.pop(fd, None)
@contextmanager
def transaction(fd):
"""with transaction(fd): 持有该 fd 的总线锁,保证 I2C 操作序列原子"""
with _MAIN_LOCK:
lock = _LOCKS.get(fd)
if lock is None:
raise I2CError(f"no i2c lock for fd {fd} (device not opened by pybot?)")
with lock:
yield
# ---------------- 模块级函数(供插件 modules/*.py 直接调用) ----------------
# 对应仓颉版注册给 Lua 的 i2c_open/i2c_close/i2c_set_addr/i2c_write_reg/i2c_read_reg
def open_dev(path):
fd = _backend.open(path)
_register_lock(fd)
log.debug("i2c open %s -> fd=%d", path, fd)
return fd
def close_dev(fd):
_unregister_lock(fd)
_backend.close(fd)
log.debug("i2c close fd=%d", fd)
def set_addr(fd, addr):
_backend.set_addr(fd, addr)
def write_reg(fd, reg, val):
_backend.write_reg(fd, reg, val)
def read_reg(fd, reg):
return _backend.read_reg(fd, reg)
def write_block(fd, reg, data):
_backend.write_block(fd, reg, data)
def read_block(fd, reg, n):
return _backend.read_block(fd, reg, n)
# ---------------- I2C 总线对象(对应仓颉版 I2C_Orig) ----------------
class I2CBus:
"""持有一个 I2C 设备文件句柄;close() 幂等"""
def __init__(self, path):
self.path = path
self.fd = open_dev(path)
def close(self):
if self.fd >= 0:
close_dev(self.fd)
self.fd = -1
def set_addr(self, addr):
set_addr(self.fd, addr)
def write_reg(self, reg, val):
write_reg(self.fd, reg, val)
def read_reg(self, reg):
return read_reg(self.fd, reg)
def write_block(self, reg, data):
write_block(self.fd, reg, data)
def read_block(self, reg, n):
return read_block(self.fd, reg, n)
def transaction(self):
return transaction(self.fd)
+29
View File
@@ -0,0 +1,29 @@
"""一维卡尔曼滤波器 —— 移植自 bot4cj/src/math/kalman.cj
用法:
k = Kalman(20.0, 200.0) # Q=20 R=200(与仓颉版 MPU6050 参数一致)
filtered = k.filter(sensor_value)
R 固定时,Q 越大代表越信任测量值;Q 越小代表越信任模型预测值。
"""
class Kalman:
"""A=1 B=0 H=1 的一维卡尔曼滤波(五个核心公式)"""
def __init__(self, q, r):
self.x_last = 0.0 # 上一时刻的最优结果 X(k-1|k-1)
self.p_last = 0.0 # 上一时刻最优结果的协方差 P(k-1|k-1)
self.a = 1.0
self.q = q # 系统噪声协方差
self.r = r # 测量噪声协方差
def filter(self, dat):
x_mid = self.a * self.x_last # (1) x(k|k-1) = A*X(k-1|k-1)
p_mid = self.a * self.p_last + self.q # (2) p(k|k-1) = A*p(k-1|k-1)*A'+Q
kg = p_mid / (p_mid + self.r) # (4) kg(k) = p(k|k-1)/(p(k|k-1)+R)
x_now = x_mid + kg * (dat - x_mid) # (3) x(k|k) = X(k|k-1)+kg*(Z(k)-X(k|k-1))
p_now = (1.0 - kg) * p_mid # (5) p(k|k) = (1-kg)*P(k|k-1)
self.p_last = p_now
self.x_last = x_now
return x_now
+134
View File
@@ -0,0 +1,134 @@
"""MPU6050 六轴姿态传感器 —— 移植自 bot4cj/src/hd/MPU6050.cj
10ms 周期采样线程 + 6 路一维卡尔曼滤波,输出 roll/pitch 姿态角。
与仓颉版的差异(审计修复,语义不变):
S05: ADDR_1 = 0x69(AD0 拉高时的地址;仓颉版误写 0x68 与 ADDR_0 重复);
Q06: 停止采样用 threading.Event(仓颉版裸 bool 无内存屏障);
pitch 用 atan2(-ax, sqrt(ay²+az²)) 代替 atan(-ax/sqrt(...)),防除零且值域一致。
"""
import logging
import math
import struct
import threading
import time
from . import i2c
from .kalman import Kalman
log = logging.getLogger("pybot.mpu6050")
ADDR_0 = 0x68 # AD0 引脚接地
ADDR_1 = 0x69 # AD0 引脚拉高(修复 S05)
# 寄存器地址
PWR_MGMT_1 = 0x6B
GYRO_CONFIG = 0x1B
ACCEL_CONFIG = 0x1C
ACCEL_XOUT_H = 0x3B # 14 字节连读起点:加速度(6) + 温度(2) + 陀螺仪(6)
RAD_TO_DEG = 57.2956 # 弧度转角度(与仓颉版一致)
SAMPLE_INTERVAL = 0.01 # 10ms 采样周期
class MPU6050:
"""MPU6050 传感器:绑定到某条 I2C 总线的 fd 上
用法:
m = MPU6050(fd, ADDR_0)
m.setup()
m.start_daemon()
... m.latest() ...
m.stop_daemon(); m.wait_daemon()
"""
def __init__(self, fd, addr=ADDR_0):
self.fd = fd
self.addr = addr
# 6 路卡尔曼滤波(Q=20 R=200,与仓颉版参数一致)
self._k_ax = Kalman(20.0, 200.0)
self._k_ay = Kalman(20.0, 200.0)
self._k_az = Kalman(20.0, 200.0)
self._k_gx = Kalman(20.0, 200.0)
self._k_gy = Kalman(20.0, 200.0)
self._k_gz = Kalman(20.0, 200.0)
self._stop = threading.Event() # 修复 Q06
self._thread = None
self._lock = threading.Lock()
self._latest = {"roll": 0.0, "pitch": 0.0,
"ax": 0.0, "ay": 0.0, "az": 0.0,
"gx": 0.0, "gy": 0.0, "gz": 0.0}
# ---------------- 初始化 ----------------
def setup(self):
"""唤醒 + 配置量程:陀螺仪 ±500°/s、加速度 ±8g(与仓颉版一致)"""
with i2c.transaction(self.fd):
i2c.set_addr(self.fd, self.addr)
time.sleep(0.001)
i2c.write_reg(self.fd, PWR_MGMT_1, 0x00) # 解除休眠
i2c.write_reg(self.fd, GYRO_CONFIG, 0x08) # ±500°/s
i2c.write_reg(self.fd, ACCEL_CONFIG, 0x10) # ±8g
time.sleep(0.1) # 等待传感器稳定(锁外,不占总线)
log.debug("mpu6050 setup done (fd=%d addr=0x%02X)", self.fd, self.addr)
# ---------------- 读取 ----------------
def read_sensor(self):
"""一次事务连读 14 字节,返回 (ax, ay, az, gx, gy, gz) 原始值"""
with i2c.transaction(self.fd):
i2c.set_addr(self.fd, self.addr)
data = i2c.read_block(self.fd, ACCEL_XOUT_H, 14)
vals = struct.unpack(">7h", data) # 大端 7 个有符号 16 位
# 索引 3 是温度,跳过
return vals[0], vals[1], vals[2], vals[4], vals[5], vals[6]
def _sample_once(self):
ax, ay, az, gx, gy, gz = self.read_sensor()
fax = self._k_ax.filter(float(ax))
fay = self._k_ay.filter(float(ay))
faz = self._k_az.filter(float(az))
fgx = self._k_gx.filter(float(gx))
fgy = self._k_gy.filter(float(gy))
fgz = self._k_gz.filter(float(gz))
# 姿态角(加速度计解算,与仓颉版公式一致;pitch 用 atan2 防除零)
roll = math.atan2(fay, faz) * RAD_TO_DEG
pitch = math.atan2(-fax, math.sqrt(fay * fay + faz * faz)) * RAD_TO_DEG
with self._lock:
self._latest.update(roll=roll, pitch=pitch,
ax=fax, ay=fay, az=faz,
gx=fgx, gy=fgy, gz=fgz)
def latest(self):
"""最近一次滤波后的姿态数据(线程安全,返回副本)"""
with self._lock:
return dict(self._latest)
# ---------------- 采样线程 ----------------
def start_daemon(self):
if self._thread is not None:
return
self._stop.clear()
self._thread = threading.Thread(target=self._loop,
name="mpu6050-sampler", daemon=True)
self._thread.start()
log.debug("mpu6050 sampler started")
def _loop(self):
while not self._stop.wait(SAMPLE_INTERVAL): # 10ms 周期
try:
self._sample_once()
except i2c.I2CError as e:
log.warning("mpu6050 sample failed: %s", e)
def stop_daemon(self):
self._stop.set()
def wait_daemon(self):
if self._thread is not None:
self._thread.join()
self._thread = None
log.debug("mpu6050 sampler stopped")
+29
View File
@@ -0,0 +1,29 @@
"""舵机类 —— 移植自 bot4cj/src/hd/Servo.cj
含最大值、最小值、漂移值三个调整项,以及一个设置角度回调函数。
角度算法与仓颉版逐行对齐:
a = angle + offset
a = clamp(a, |min|, |max|)
if max < 0: a = 180 - a # 负 max 表示舵机反装,角度翻转
"""
MAX_ANGLE = 180.0
class Servo:
def __init__(self, max_angle, min_angle, offset, set_angle_callback):
self.max = max_angle
self.min = min_angle
self.offset = offset
self._set_angle = set_angle_callback
def set_angle(self, angle):
a = angle + self.offset # 加上漂移值
if a > abs(self.max):
a = abs(self.max)
if a < abs(self.min):
a = abs(self.min)
if self.max < 0.0:
a = MAX_ANGLE - a # 反装翻转
self._set_angle(a)
+98
View File
@@ -0,0 +1,98 @@
"""扬声器 —— 移植自 bot4cj/src/hd/snd.cj
与仓颉版的差异(审计修复 S03):
仓颉版 play 只是 sleep 占位、不出声;本版用标准库 wave 生成正弦波
PCM 文件,经 `aplay -q` 播放(树莓派自带 alsa-utils)。
aplay 不存在时降级为 sleep 占位(保持时序节拍不变);
dry_run 模式下仅记日志立即返回(不 sleep、不出声)。
时序约定:play() 阻塞总耗时对齐 delay_ms(与仓颉版 sleep(delay) 节拍一致),
播放提前结束则补足剩余时间。
"""
import logging
import math
import os
import shutil
import struct
import subprocess
import tempfile
import time
import wave
log = logging.getLogger("pybot.speaker")
FADE_MS = 5 # 首尾淡入淡出时长,消除爆音
class Speaker:
"""正弦波蜂鸣扬声器
volumn: 0.0~1.0 音量;max_f/min_f: 频率钳制范围(Hz)
"""
def __init__(self, channels=1, sample_rate=44100, volumn=0.5,
max_f=20000.0, min_f=20.0, dry_run=False):
self.channels = channels
self.sample_rate = sample_rate
self.volumn = volumn
self.max_f = max_f
self.min_f = min_f
self.dry_run = dry_run
self._aplay = shutil.which("aplay")
if self._aplay is None and not dry_run:
log.warning("aplay not found, speaker degrades to silent sleep")
def play(self, frequency, delay_ms):
"""播放 frequency Hz 正弦波 delay_ms 毫秒(阻塞)"""
# 频率钳制(与仓颉版一致)
if frequency > self.max_f:
frequency = self.max_f
if frequency < self.min_f:
frequency = self.min_f
if self.dry_run:
log.info("speaker(dry-run): %.1fHz %dms", frequency, delay_ms)
return
start = time.monotonic()
if self._aplay is not None:
try:
self._play_aplay(frequency, delay_ms)
except (OSError, subprocess.SubprocessError) as e:
log.warning("aplay failed: %s", e)
# 补足剩余时间,保证总耗时对齐 delay_ms
remain = delay_ms / 1000.0 - (time.monotonic() - start)
if remain > 0:
time.sleep(remain)
# ---------------- 内部 ----------------
def _synth(self, frequency, delay_ms):
"""生成带 5ms 淡入淡出的 16bit PCM 正弦波"""
n = int(self.sample_rate * delay_ms / 1000.0)
fade = min(int(self.sample_rate * FADE_MS / 1000.0), n // 2)
amp = self.volumn * 32767.0
w = 2.0 * math.pi * frequency / self.sample_rate
frames = bytearray()
for i in range(n):
gain = 1.0
if i < fade:
gain = i / fade # 淡入
elif i >= n - fade:
gain = (n - i) / fade # 淡出
v = int(amp * gain * math.sin(w * i))
frames += struct.pack("<h", v) * self.channels
return bytes(frames)
def _play_aplay(self, frequency, delay_ms):
fd, path = tempfile.mkstemp(suffix=".wav", prefix="pybot_beep_")
os.close(fd)
try:
with wave.open(path, "wb") as f:
f.setnchannels(self.channels)
f.setsampwidth(2) # 16bit
f.setframerate(self.sample_rate)
f.writeframes(self._synth(frequency, delay_ms))
subprocess.run([self._aplay, "-q", path], check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
finally:
os.unlink(path)
+120
View File
@@ -0,0 +1,120 @@
"""Linux spidev 封装 —— 对应 bot4cj/modules/SPI.lua 所需的底层能力
纯标准库实现(fcntl.ioctl + ctypes 缓冲区),零第三方依赖。
与 hd.i2c 同构:后端可切换(use_mock()/use_real()),
Mock journal 供 --dry-run 与单测断言。
spi_ioc_transfer 结构(32 字节,struct.pack 'QQIIHBBBBH'):
__u64 tx_buf; __u64 rx_buf; __u32 len; __u32 speed_hz;
__u16 delay_usecs; __u8 bits_per_word; __u8 cs_change;
__u8 tx_nbits; __u8 rx_nbits; __u16 pad;
"""
import ctypes
import fcntl
import logging
import os
import struct
log = logging.getLogger("pybot.spi")
# spidev ioctl 常量(linux/spi/spidev.h,小端 arm64/x86_64 通用)
SPI_IOC_WR_MODE = 0x40016B01
SPI_IOC_WR_BITS_PER_WORD = 0x40016B03
SPI_IOC_WR_MAX_SPEED_HZ = 0x40046B04
SPI_IOC_MESSAGE_1 = 0x40206B00 # SPI_IOC_MESSAGE(1)
class SPIError(OSError):
"""SPI 设备异常"""
# ---------------- 后端(真实总线 / Mock) ----------------
class _RealBackend:
def open(self, path):
return os.open(path, os.O_RDWR)
def close(self, fd):
os.close(fd)
def setup(self, fd, mode, bits, speed):
fcntl.ioctl(fd, SPI_IOC_WR_MODE, struct.pack("B", mode))
fcntl.ioctl(fd, SPI_IOC_WR_BITS_PER_WORD, struct.pack("B", bits))
fcntl.ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, struct.pack("I", speed))
def transfer(self, fd, tx, speed, bits):
"""全双工传输:发送 tx 字节串,返回等长 rx 字节串"""
n = len(tx)
tx_buf = ctypes.create_string_buffer(bytes(tx), n)
rx_buf = ctypes.create_string_buffer(n)
msg = struct.pack("QQIIHBBBBH",
ctypes.addressof(tx_buf), ctypes.addressof(rx_buf),
n, speed, 0, bits, 0, 0, 0, 0)
if fcntl.ioctl(fd, SPI_IOC_MESSAGE_1, msg) < 0:
raise SPIError(f"spi transfer failed (fd={fd}, {n} bytes)")
return rx_buf.raw
class MockBackend:
"""记录型 Mock:不碰硬件,记录全部操作序列供断言/diff"""
def __init__(self):
self.journal = []
self.rx_values = {} # {fd: bytes} transfer 返回值注入
self._next_fd = 2000
def open(self, path):
fd = self._next_fd
self._next_fd += 1
self.journal.append(("open", path, fd))
return fd
def close(self, fd):
self.journal.append(("close", fd))
def setup(self, fd, mode, bits, speed):
self.journal.append(("setup", fd, mode, bits, speed))
def transfer(self, fd, tx, speed, bits):
self.journal.append(("transfer", fd, bytes(tx), speed, bits))
rx = self.rx_values.get(fd, b"")
return rx.ljust(len(tx), b"\x00")[:len(tx)]
_backend = _RealBackend()
def use_mock():
"""切换到 Mock 总线(--dry-run / 单测),返回 MockBackend 以便读取 journal"""
global _backend
_backend = MockBackend()
return _backend
def use_real():
"""切换回真实总线"""
global _backend
_backend = _RealBackend()
# ---------------- 模块级函数(供插件 modules/*.py 直接调用) ----------------
def open_dev(path):
fd = _backend.open(path)
log.debug("spi open %s -> fd=%d", path, fd)
return fd
def close_dev(fd):
_backend.close(fd)
log.debug("spi close fd=%d", fd)
def setup(fd, mode=0, bits=8, speed=500000):
_backend.setup(fd, mode, bits, speed)
def transfer(fd, tx, speed=500000, bits=8):
return _backend.transfer(fd, tx, speed, bits)