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

View File
Whitespace-only changes.
+30
View File
@@ -0,0 +1,30 @@
"""pytest 公共配置:把项目根加入 sys.path(无需安装即可 import pybot)"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import pytest # noqa: E402
from pybot.hd import i2c, spi # noqa: E402
SCRIPTS_DIR = ROOT / "scripts"
@pytest.fixture()
def mock_i2c():
"""切换到 Mock I2C 总线,测试结束恢复真实后端"""
mock = i2c.use_mock()
yield mock
i2c.use_real()
@pytest.fixture()
def mock_spi():
"""切换到 Mock SPI 总线,测试结束恢复真实后端"""
mock = spi.use_mock()
yield mock
spi.use_real()
+121
View File
@@ -0,0 +1,121 @@
"""硬件变量 + RobotStruct 全流程测试(Mock I2C 总线,journal 断言)
关键期望值(与 bot4cj + PCA9685.lua 逐字节对齐):
45° → 脉宽 0.5+2*45/180 = 1.0ms → offval = int(1.0*4096/20) = 204
135° → 脉宽 2.0ms → offval = int(2.0*4096/20) = 409(s3 反装 45°→135°)
0° → 脉宽 0.5ms → offval = 102(首行 '-' 占位解析为 0)
"""
from pybot.robot import RobotStruct
from tests.conftest import SCRIPTS_DIR
PRESCALE = 121 # int(25e6/(4096*50) - 1 + 0.5)
def std_robot(mock_i2c):
with open(SCRIPTS_DIR / "std.mt", encoding="utf-8") as f:
return RobotStruct(f, dry_run=True)
def _writes(mock, fd):
return [(e[2], e[3]) for e in mock.journal
if e[0] == "write_reg" and e[1] == fd]
def test_create_hardvars(mock_i2c):
robot = std_robot(mock_i2c)
assert set(robot.M) == {"A", "F", "i2c-0", "i2c-1", "pca9685-0", "spk",
"s1", "s2", "s3", "s4", "s5",
"s6", "s7", "s8", "s9", "s10"}
# 两条 I2C 总线在工厂阶段即打开
opens = [e for e in mock_i2c.journal if e[0] == "open"]
assert [e[1] for e in opens] == ["/dev/i2c-0", "/dev/i2c-1"]
# 别名变量
assert robot.M["A"].play({}) == "angle"
assert robot.M["F"].play({}) == "frequency"
robot.deactive()
def test_pca9685_build_sequence(mock_i2c):
robot = std_robot(mock_i2c)
fd = robot.M["i2c-1"].play({})["fd"]
robot.active()
# 初始化序列:复位 → SLEEP → prescale=121 → 唤醒(与 Lua 版一致)
assert _writes(mock_i2c, fd) == [
(0x00, 0x80), (0x00, 0x10), (0xFE, PRESCALE), (0x00, 0x20)]
assert ("set_addr", fd, 0x40) in mock_i2c.journal
robot.deactive()
def test_run_stand_action(mock_i2c):
robot = std_robot(mock_i2c)
fd = robot.M["i2c-1"].play({})["fd"]
robot.active()
mock_i2c.journal.clear() # 只看 @Action 阶段
robot.run()
writes = _writes(mock_i2c, fd)
reg = lambda ch: 0x06 + 4 * ch # noqa: E731
def servo_writes(ch, offval):
return [(reg(ch) + 0, 0x00), (reg(ch) + 1, 0x00),
(reg(ch) + 2, offval & 0xFF), (reg(ch) + 3, offval >> 8)]
# s1: 45° 正装 → offval 204;s2: '-' 首行 → 0° → 102
assert writes[0:4] == servo_writes(0, 204)
assert writes[4:8] == servo_writes(1, 102)
# s3: max=-90 反装,45° → 135° → offval 409(写 [153, 1])
assert writes[8:12] == servo_writes(2, 409)
# s9/s10: 90° → offval = int(1.5*4096/20) = 307
assert writes[32:36] == servo_writes(8, 307)
assert writes[36:40] == servo_writes(9, 307)
# 10 舵机 × 4 寄存器
assert len(writes) == 40
robot.deactive()
def test_deactive_reverse_and_idempotent(mock_i2c):
robot = std_robot(mock_i2c)
fd0 = robot.M["i2c-0"].play({})["fd"]
fd1 = robot.M["i2c-1"].play({})["fd"]
robot.active()
robot.deactive()
# 倒序:先 PCA9685 复位(fd1 写 0x80),再关 i2c-1,最后关 i2c-0
closes = [e[1] for e in mock_i2c.journal if e[0] == "close"]
assert closes == [fd1, fd0]
reset_idx = mock_i2c.journal.index(("write_reg", fd1, 0x00, 0x80),
mock_i2c.journal.index(("close", fd1))
- 5)
assert reset_idx < mock_i2c.journal.index(("close", fd1))
# 幂等:重复调用不再产生操作
n = len(mock_i2c.journal)
robot.deactive()
assert len(mock_i2c.journal) == n
def test_redefined_hardvar_destroys_old(mock_i2c, caplog):
"""重名硬件变量:destroy 旧值 + 告警(修复 r.cj:911 FIXME)"""
text = ("@I2C\nname path;\nx /dev/i2c-0;\nx /dev/i2c-1;\n@\n"
"@Action\nx.angle;\n0;\n@\n")
robot = RobotStruct(text, dry_run=True)
closes = [e for e in mock_i2c.journal if e[0] == "close"]
assert len(closes) == 1 # 旧的 /dev/i2c-0 已被关闭
assert any("redefined" in r.message for r in caplog.records)
robot.deactive()
def test_action_validation(mock_i2c):
"""Q05:执行前整表校验列名格式与设备存在性"""
import pytest
text = ("@I2C\nname path;\ni /dev/i2c-0;\n@\n"
"@Action\nbadcolumn;\n0;\n@\n")
robot = RobotStruct(text, dry_run=True)
with pytest.raises(ValueError, match="not valid"):
robot.run()
robot.deactive()
text2 = ("@I2C\nname path;\ni /dev/i2c-0;\n@\n"
"@Action\nghost.angle;\n0;\n@\n")
robot2 = RobotStruct(text2, dry_run=True)
with pytest.raises(ValueError, match="not defined"):
robot2.run()
robot2.deactive()
+48
View File
@@ -0,0 +1,48 @@
"""MPU6050 测试:14 字节连读解包(含负值符号,回归 S06)与采样滤波"""
import struct
from pybot.hd import i2c
from pybot.hd.mpu6050 import (ACCEL_CONFIG, ACCEL_XOUT_H, ADDR_0, ADDR_1,
GYRO_CONFIG, MPU6050, PWR_MGMT_1)
def test_addr_constants():
"""修复 S05:ADDR_1 必须是 0x69(仓颉版误写 0x68)"""
assert ADDR_0 == 0x68
assert ADDR_1 == 0x69
def test_setup_sequence(mock_i2c):
fd = i2c.open_dev("/dev/i2c-1")
m = MPU6050(fd, ADDR_0)
m.setup()
writes = [(e[2], e[3]) for e in mock_i2c.journal if e[0] == "write_reg"]
assert writes == [(PWR_MGMT_1, 0x00), # 解除休眠
(GYRO_CONFIG, 0x08), # ±500°/s
(ACCEL_CONFIG, 0x10)] # ±8g
assert ("set_addr", fd, ADDR_0) in mock_i2c.journal
def test_read_sensor_signed(mock_i2c):
"""负值(高字节 ≥ 0x80)必须正确解为负数——回归 S06 符号扩展错误"""
fd = i2c.open_dev("/dev/i2c-1")
m = MPU6050(fd)
# ax=-2 ay=3 az=4096 temp=999(跳过) gx=100 gy=-200 gz=300
mock_i2c.block_values[(fd, ACCEL_XOUT_H)] = struct.pack(
">7h", -2, 3, 4096, 999, 100, -200, 300)
assert m.read_sensor() == (-2, 3, 4096, 100, -200, 300)
def test_sample_and_latest(mock_i2c):
fd = i2c.open_dev("/dev/i2c-1")
m = MPU6050(fd)
# 静止平放:az=4096(+1g@±8g),其余 0 → roll/pitch 趋近 0
mock_i2c.block_values[(fd, ACCEL_XOUT_H)] = struct.pack(
">7h", 0, 0, 4096, 0, 0, 0, 0)
for _ in range(50): # 多次采样让卡尔曼收敛
m._sample_once()
latest = m.latest()
assert abs(latest["roll"]) < 1.0
assert abs(latest["pitch"]) < 1.0
assert latest["az"] > 3000.0 # 滤波值向测量值收敛
+112
View File
@@ -0,0 +1,112 @@
"""解析器测试:FSM / 列数校验(S01) / 占位符语义 / 序列化往返 / 全量脚本"""
import io
import pytest
from pybot.parser import (DoesNotSync, MatrixParser, MatrixSaver,
get_mt, parse_mt)
from tests.conftest import SCRIPTS_DIR
def parse_all(text):
parser = MatrixParser(text)
objects = []
while True:
obj = parser.parse()
if obj is None:
break
objects.append(obj)
return objects
# ---------------- 基本解析 ----------------
def test_parse_simple_block():
objs = parse_all("@Foo\nname val;\na 1;\nb 2;\n@\n")
assert len(objs) == 1
m = objs[0]
assert m.name == "Foo"
assert m.size == 3 and m.col == 2
assert m[0, 0] == "name" and m[2, 1] == "2"
def test_parse_multiple_blocks_and_comments():
text = ("# 头部注释\n"
"@A\nx y;\n1 2;\n@\n"
"@B\nn;\nv; # 行内注释\n@\n")
objs = parse_all(text)
assert [o.name for o in objs] == ["A", "B"]
assert objs[1][1, 0] == "v"
def test_parse_std_mt():
objs = parse_all((SCRIPTS_DIR / "std.mt").read_text(encoding="utf-8"))
names = [o.name for o in objs]
assert names == ["Alias", "I2C", "PCA9685", "MPU6050_BIND",
"Speaker", "Servo", "Action"]
servo = objs[names.index("Servo")]
assert servo.size == 11 and servo.col == 6 # 表头 + 10 舵机
# 注释掉的 MPU6050_BIND 数据行不产生内容
assert objs[names.index("MPU6050_BIND")].size == 1
@pytest.mark.parametrize("script", sorted(SCRIPTS_DIR.glob("*.mt")),
ids=lambda p: p.name)
def test_parse_all_scripts(script):
"""全量 scripts/*.mt 解析:块内每行列数一致且非空"""
objs = parse_all(script.read_text(encoding="utf-8"))
assert objs, f"{script.name}: no blocks parsed"
for o in objs:
assert o.name
for row in o.values:
assert len(row) == o.col
# ---------------- S01:列数校验 ----------------
def test_does_not_sync():
with pytest.raises(DoesNotSync) as ei:
parse_all("@Foo\na b c;\n1 2;\n@\n")
assert ei.value.expected == 3 and ei.value.actual == 2
def test_missing_semicolon_merges_lines():
"""漏写分号导致跨行合并成一行(S04 回归场景),列数不齐即报错"""
with pytest.raises(DoesNotSync):
parse_all("@Foo\na b;\n1 2\n3 4;\n@\n")
# ---------------- 占位符语义 ----------------
def _matrix(text):
return parse_all(text)[0]
def test_placeholder_recursion():
m = _matrix("@T\nname v;\na 5;\nb -;\nc -;\n@\n")
assert get_mt(m, 3, 1) == "5" # 递归两级到首行
assert parse_mt(m, 3, 1) == 5.0
def test_placeholder_first_row():
"""首行 '-':get_mt 返回字面 '-';parse_mt 解析为 typ("0")
⚠️ 与 actpred/mt_parser.py 不同(后者用 @Servo 初始角度填充),不得误统一"""
m = _matrix("@T\nname v;\na -;\n@\n")
assert get_mt(m, 1, 1) == "-"
assert parse_mt(m, 1, 1) == 0.0
assert parse_mt(m, 1, 1, int) == 0
# ---------------- 序列化往返 ----------------
def test_roundtrip():
text = (SCRIPTS_DIR / "std.mt").read_text(encoding="utf-8")
objs = parse_all(text)
out = io.StringIO()
MatrixSaver(out).save(objs)
objs2 = parse_all(out.getvalue())
assert len(objs2) == len(objs)
for a, b in zip(objs, objs2):
assert a.name == b.name
assert a.values == b.values
+49
View File
@@ -0,0 +1,49 @@
"""插件加载器测试:三级路径查找(S07) / 缺失报错 / 实例隔离"""
import pytest
from pybot import plugin
def test_find_builtin_module():
"""包内置 modules/ 能找到 PCA9685"""
path = plugin.find_module("PCA9685")
assert path.name == "PCA9685.py"
assert path.parent.name == "modules"
def test_plugin_not_found():
with pytest.raises(plugin.PluginNotFound) as ei:
plugin.find_module("NoSuchDevice")
assert "NoSuchDevice" in str(ei.value)
assert "modules" in str(ei.value) # 报错含搜索路径
def test_env_path_priority(tmp_path, monkeypatch):
"""$PYBOT_MODULES_PATH 优先于包内置目录"""
(tmp_path / "PCA9685.py").write_text(
"def build(ctx): return {'from': 'env'}\n"
"def call(ctx): return {}\n"
"def destroy(ctx): return {}\n", encoding="utf-8")
monkeypatch.setenv("PYBOT_MODULES_PATH", str(tmp_path))
assert plugin.find_module("PCA9685") == tmp_path / "PCA9685.py"
mod = plugin.load_module("PCA9685")
assert mod.build({}) == {"from": "env"}
def test_missing_protocol_function(tmp_path, monkeypatch):
"""插件缺 build/call/destroy 任一函数即拒绝加载"""
(tmp_path / "Broken.py").write_text(
"def build(ctx): return {}\n", encoding="utf-8")
monkeypatch.setenv("PYBOT_MODULES_PATH", str(tmp_path))
with pytest.raises(plugin.PluginNotFound):
plugin.load_module("Broken")
def test_independent_instances():
"""每次加载新模块实例:模块级全局状态互不共享(对齐 Lua 每设备状态机)"""
a = plugin.load_module("SPI")
b = plugin.load_module("SPI")
assert a is not b
a._fd = 42
assert b._fd == -1
+47
View File
@@ -0,0 +1,47 @@
"""Servo 校准算法测试:正装/反装/漂移/钳位(与 Servo.cj 语义逐项对齐)"""
from pybot.hd.servo import Servo
def make(max_a, min_a, offset):
sent = []
servo = Servo(max_a, min_a, offset, sent.append)
return servo, sent
def test_normal_install():
servo, sent = make(90.0, 0.0, 0.0)
servo.set_angle(45.0)
assert sent == [45.0]
def test_clamp_max():
servo, sent = make(90.0, 0.0, 0.0)
servo.set_angle(120.0)
assert sent == [90.0]
def test_clamp_min():
servo, sent = make(180.0, 30.0, 0.0)
servo.set_angle(10.0)
assert sent == [30.0]
def test_offset():
servo, sent = make(180.0, 0.0, 5.0)
servo.set_angle(40.0)
assert sent == [45.0]
def test_inverted_install():
"""负 max 表示反装:a = 180 - a(std.mt 的 s3 max=-90,45° → 135°)"""
servo, sent = make(-90.0, 0.0, 0.0)
servo.set_angle(45.0)
assert sent == [180.0 - 45.0]
def test_inverted_clamp_uses_abs():
"""反装钳位用 |max|:-90 装机 100° → 钳到 90 → 翻转 90"""
servo, sent = make(-90.0, 0.0, 0.0)
servo.set_angle(100.0)
assert sent == [90.0]