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:
1 parent
85eaa2114e
commit
bba7ee3eb4
31 files changed
+2155
No files matched your search
Whitespace-only changes.
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 # 滤波值向测量值收敛
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
Reference in new issue
Block a user