feat(mtc): .mt 脚本编译器落地 + pybot 扬声器静音语义
mtc 新模块:命令式动作脚本(Python DSL)→ .mt 矩阵编译器,非阻塞时间轴 IR(重叠截断/rest 静止值)+ 10ms 栅格化,产物可由 pybot/bot4cj 直接执行;34 测试全绿(含 pybot parser e2e)。 pybot speaker.py:frequency<=0 改为静音节拍(仅 sleep 保时序,不再钳到 min_f 低鸣),对齐仓颉版 sleep 占位语义;mtc Beep 通道 rest=0,段前/段间/段后回落静音,两侧语义闭环。 修复 test_actpred_compatibility 过时断言(99c1bc1 首行分解为重复微步 19→20 帧时漏改,14→15)。 文档三处同步:根 README 回填 memdb/pybot/actpred 并新增 mtc(模块表+端口一览+章节),MODULES/ARCHITECTURE 同步 mtc,新建 mtc/README.md。
This commit is contained in:
1 parent
205a60f039
commit
01126a5786
18 files changed
+1125
-7
No files matched your search
@@ -69,6 +69,7 @@
|
||||
| [dnfs](dnfs/) | Python(Flask) | 8006 | ~~树状网络文件系统~~(遗留,已由 dnfs2 平替) |
|
||||
| [db2](db2/) | C++20 | 8005 | 通用数据库服务,基于 pymsg 协议(TCP 长度前缀帧 + JSON)的 SQL-over-TCP,参数化查询防注入;内置集中式日志收集(Logs 表 + 自动清理) |
|
||||
| [db](db/) | Python(pymsg) | 8005 | ~~通用数据库服务~~(遗留,已由 db2 平替) |
|
||||
| [memdb](memdb/) | C++20 | 8011 | 内存缓存数据库(libmemdb 模板库 + libmsg RPC),TTL 惰性驱逐,9 路由(ping/set/get/del/update/exists/size/show/expire),线程安全 + 优雅退出;暂无业务调用方,预留热点数据/会话缓存 |
|
||||
| [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
|
||||
| [captcha2](captcha2/) | C++20 | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 ca 的 `GET /captcha/image` 代理给浏览器 |
|
||||
| [captcha](captcha/) | Python(pymsg) | 8009 | ~~验证码服务~~(遗留,已由 captcha2 平替) |
|
||||
@@ -77,6 +78,9 @@
|
||||
| [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
|
||||
| [BotOps](BotOps/) | Python | — | aibox 智能体 TUI 前端(Textual),WebSocket 连接 aibox,支持 Markdown 增量流式渲染、思考模式展示、任务独立侧栏、自动重连 |
|
||||
| [bot4cj](bot4cj/) | 仓颉(Cangjie) | — | 机器人硬件脚本解析器,解析 .mt 矩阵脚本通过 I2C 驱动 PCA9685 舵机 / MPU6050 陀螺仪,面向树莓派 Dashun 蜘蛛机器人平台(独立硬件项目) |
|
||||
| [pybot](pybot/) | Python | — | bot4cj 的 Python 迁移版:.mt 动作脚本解析器(零第三方依赖,与仓颉版逐语义对齐);支持执行 / `--dry-run` / `--record` 录制 actpred episode(含 imu.npy)/ `--loop` 实时预测闭环 |
|
||||
| [mtc](mtc/) | Python | — | 命令式动作脚本(Python DSL)→ .mt 编译器:非阻塞时间轴 + 10ms 栅格展开,输出可由 pybot/bot4cj 直接执行;零第三方依赖 |
|
||||
| [actpred](actpred/) | Python(PyTorch) | — | 动作预测模型(图像 + 动作历史 → 下一动作),bot4cj/pybot 的「大脑」配套;两阶段训练(.mt 预训练 → episode 微调),ONNX/Hailo HEF 部署(实验) |
|
||||
| [lci](lci/) | C++ | — | 嵌入式帧缓冲图形界面(LCI),基于 `/dev/fb0` 直接操作显存,无需 X11/Wayland,矢量字体、图片渲染、实时数据可视化,面向树莓派等嵌入式环境 |
|
||||
|
||||
### core(核心模块 · 进程托管服务)
|
||||
@@ -171,6 +175,12 @@
|
||||
- 安全机制:表名白名单(字母数字下划线)、`sqlite3_bind_text` 参数绑定、仅反射已有表无法建删表、RpcServer 异常脱敏。
|
||||
- 配置全部经 CLI 传递(CLI > 环境变量 > 默认值,原 `db.ini` 迁入 `core/services.yaml` 的 CLI args),不依赖 confs;详见 [db2/README.md](db2/README.md)。
|
||||
|
||||
### memdb(内存缓存数据库)
|
||||
|
||||
- 基于 [libmemdb](libmemdb/)(C++20 header-only 模板库:三索引存储——`std::list` 主存 + `unordered_map` 键索引 + `multiset` TTL 索引,TTL 惰性驱逐)+ [libmsg](libmsg/) RPC 服务,监听 127.0.0.1:8011。
|
||||
- 9 路由:`ping` / `set` / `get` / `del` / `update` / `exists` / `size` / `show` / `expire`;`std::mutex` 保护线程安全,SIGINT/SIGTERM 优雅退出。
|
||||
- 已纳入 `core/services.yaml` 自启动;暂无业务调用方,预留热点数据/会话缓存;详见 [memdb/README.md](memdb/README.md)。
|
||||
|
||||
### ai_review(AI 内容审核服务)
|
||||
|
||||
- 基于 DeepSeek LLM + ChromaDB 向量检索的 RAG 审核架构。
|
||||
@@ -243,6 +253,21 @@
|
||||
- 附带数学库(矩阵运算、NN/CNN、Adam 优化器)、CUDA GPU 加速 kernel 和 Lua 硬件模块(ADS1115 / ADS1256 / SPI)。
|
||||
- 与主干微服务完全独立,需部署在树莓派上独立运行。
|
||||
|
||||
### pybot(机器人动作脚本解析器 · Python 迁移版)
|
||||
|
||||
- [bot4cj](#bot4cj机器人硬件脚本解析器) 的 Python 迁移版,零第三方依赖(纯标准库),与仓颉版逐语义对齐(含占位符 `-` 递归继承、微步分解、块序依赖)。
|
||||
- 三种运行模式:脚本执行 `run()`、`--record` 录制 actpred episode(images + actions.npy + imu.npy,≤4 个 MPU6050 按块行序拼接)、`--loop` 实时预测闭环(daemon 线程队列非阻塞取帧,overrun 告警)。
|
||||
- 扬声器静音语义:`frequency <= 0` 不出声仅保持节拍(对齐仓颉版 sleep 占位语义)。
|
||||
- Windows 开发机可跑测试(fcntl mock + 符号链接 fallback);详见 [pybot/README.md](pybot/README.md)。
|
||||
|
||||
### mtc(.mt 脚本编译器)
|
||||
|
||||
- 命令式动作脚本(Python DSL)→ `.mt` 矩阵脚本编译器,零第三方依赖;产物可直接由 pybot / bot4cj 执行。
|
||||
- 管线:script.py `exec` 注入命名空间 → 事件收集 → 时间轴 IR(相对→绝对、重叠截断)→ 10ms 栅格化 → out.mt。
|
||||
- 非阻塞时间轴语义:`Action`/`SetTo`/`Beep` 在当前游标处注册运动段即返回,`Delay`/`Wait` 推进游标,并发运动天然表达;`dur` 须为 10 的倍数(与 pybot 微步节拍一致)。
|
||||
- 扬声器通道静止值回落 0(段前/段间空隙/段后 = 静音),与 pybot speaker 静音语义对齐;`spk.frequency`/`spk.delay` 列自动成对补齐。
|
||||
- 用法:`python -m mtc walk.py -o walk.mt`(须在 `mtc/` 目录或设 `PYTHONPATH=mtc` 运行);详见 [mtc/README.md](mtc/README.md)。
|
||||
|
||||
### lci(嵌入式帧缓冲图形界面)
|
||||
|
||||
- LCI(Linux Chinese Interface)是基于 Linux 帧缓冲设备(`/dev/fb0`)的图形界面系统,**无需 X11 或 Wayland**,直接 `mmap` 操作显存,专为树莓派等嵌入式环境设计。
|
||||
@@ -291,8 +316,12 @@
|
||||
| 8007 | ai_review | 127.0.0.1 | AI 审核(仅内部调用) |
|
||||
| 8009 | captcha2 | 127.0.0.1 | 验证码服务(pymsg/TCP,经 ca 代理) |
|
||||
| 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用,注册验证码邮件后端) |
|
||||
| 8011 | memdb | 127.0.0.1 | 内存缓存数据库(libmsg RPC,仅内部调用,暂无业务调用方) |
|
||||
| 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) |
|
||||
| — | bot4cj | — (树莓派 I2C) | 机器人硬件脚本解析器(独立硬件项目,不联网) |
|
||||
| — | pybot | — (树莓派) | bot4cj 的 Python 迁移版(.mt 解析/录制/--loop 实时闭环,独立硬件项目) |
|
||||
| — | mtc | — | 命令式动作脚本 → .mt 编译器(开发期工具,无端口) |
|
||||
| — | actpred | — | 动作预测模型训练/部署(实验,无端口) |
|
||||
| — | lci | — (树莓派 /dev/fb0) | 嵌入式帧缓冲图形界面(独立硬件项目,不联网) |
|
||||
| — | BotOps | — | aibox 智能体 TUI 前端(WebSocket 客户端,连接 aibox:8765) |
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
|
||||
BotOps (aibox 的 TUI 前端)
|
||||
bot4cj (Cangjie + 树莓派 I2C, 硬件)
|
||||
pybot (Python, bot4cj 的迁移版: .mt 解析/录制/实时闭环)
|
||||
mtc (Python, 命令式动作脚本 → .mt 编译器, 开发期工具)
|
||||
lci (C++ /dev/fb0, 嵌入式 GUI)
|
||||
actpred / libtry (实验 / 支撑库)
|
||||
|
||||
@@ -148,6 +149,7 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
|
||||
| **BotOps** | Python | — | — | aibox 的 TUI 前端 | 独立 |
|
||||
| **bot4cj** | Cangjie | — | 树莓派 I2C | 机器人脚本解析器 | 独立硬件 |
|
||||
| **pybot** | Python | — | 树莓派 | bot4cj 的 Python 迁移版(.mt 解析、actpred episode 录制、--loop 实时预测闭环) | 独立硬件 |
|
||||
| **mtc** | Python | — | — | 命令式动作脚本(Python DSL)→ .mt 编译器(非阻塞时间轴 IR + 10ms 栅格化),pybot/bot4cj 的脚本 authoring 工具 | 开发期工具 |
|
||||
| **lci** | C++ | — | /dev/fb0 | 嵌入式帧缓冲 GUI | 独立硬件 |
|
||||
| **actpred** | Python | — | — | 动作预测(图像+动作历史→下一动作),bot4cj/pybot 的「大脑」配套 | 实验 |
|
||||
| **libtry** | C | — | — | 嵌入式脚本解释器 | 支撑 |
|
||||
@@ -248,7 +250,7 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
|
||||
5. **fsys README 图示滞后**:✅ 已修正(2026-08-10)。`fsys/README.md` 架构图与配置章节已更新为 `ca:8443 → gateway:8445`、纯 CLI 配置。
|
||||
6. **工作记忆偏差**:✅ 已同步。`ca:8443 → gateway:8445`、`dnfs2`、confs 内嵌 core 均已写入各文档。
|
||||
7. **dnfs2 README 图示滞后**:✅ 已修正(2026-08-10)。`dnfs2/README.md` 架构图与验签描述已更新为 ca:8443(CLI 参数 `--gateway-url` 指向 ca)。
|
||||
8. **memdb / pybot 未进根 README**:⏳ 待回填(2026-08-15 发现)。`memdb:8011` 已入 services.yaml、`pybot`(bot4cj Python 迁移版)已落库,但根 `README.md` 模块表与端口一览尚未收录;本文件与 MODULES.md 已同步。
|
||||
8. **memdb / pybot / mtc 未进根 README**:✅ 已回填(2026-08-15)。`memdb:8011`、`pybot`(bot4cj Python 迁移版)、`mtc`(Python DSL → .mt 编译器,2026-08-15 落地)连同 `actpred` 已入根 `README.md` 模块表与端口一览;本文件与 MODULES.md 已同步。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
# 模块清单(blackbird / micro_servers)
|
||||
|
||||
> 一套 DIY 的轻量级「进程托管式微服务」(UniServe Framework)。
|
||||
> 混合语言:C++20(core/gateway/ca/dnfs2/db2/memdb/fsys/captcha2/lib*)、Python(static/dnfs/db/ai_review/smtp/captcha/confs/redirect80/BotOps/aibox/actpred/pybot/pymsg,多为遗留)、Go(waf)、仓颉/Cangjie(bot4cj)。
|
||||
> 混合语言:C++20(core/gateway/ca/dnfs2/db2/memdb/fsys/captcha2/lib*)、Python(static/dnfs/db/ai_review/smtp/captcha/confs/redirect80/BotOps/aibox/actpred/pybot/mtc/pymsg,多为遗留)、Go(waf)、仓颉/Cangjie(bot4cj)。
|
||||
> 由 `core` 读 `services.yaml` 拉起并守护各子进程;`waf:443 → ca:8443 → gateway:8445 → 业务服务` 为对外主链路。
|
||||
|
||||
## 一、核心与编排
|
||||
@@ -54,6 +54,7 @@
|
||||
|------|------|------|------|
|
||||
| bot4cj | 仓颉/Cangjie | — (树莓派 I2C) | 机器人硬件脚本解析器,解析 `.mt` 驱动 PCA9685 舵机 / MPU6050 陀螺仪 / 扬声器,面向 Dashun 蜘蛛机器人 |
|
||||
| pybot | Python | — (树莓派) | bot4cj 的 Python 迁移版:`.mt` 动作脚本解析器(零第三方依赖,与仓颉版逐语义对齐);支持执行 / `--dry-run` / `--record` 录制 actpred episode(含 imu.npy)/ `--loop` 实时预测闭环 |
|
||||
| mtc | Python | — | 命令式动作脚本(Python DSL)→ `.mt` 编译器:`exec` 注入命名空间 → 时间轴 IR(非阻塞语义、重叠截断)→ 10ms 栅格化;产物可由 pybot/bot4cj 直接执行,零第三方依赖 |
|
||||
| lci | C++ | — (/dev/fb0) | 嵌入式帧缓冲图形界面(无需 X11/Wayland),矢量 GB2312 中文字库、图片渲染、实时数据可视化 |
|
||||
| 3d | — (资产) | — | 机器人 / 机械臂 CAD 模型(STEP/STL/SLDPRT),bot4cj 配套硬件资产 |
|
||||
|
||||
@@ -91,5 +92,5 @@
|
||||
- **日志链路**:各服务 `log_client.py`(异步批量)→ `db2:8005` Logs 表(fail-safe,db2 不可用时静默丢弃)
|
||||
- **配置链路**:`gateway` → `core` 内嵌 confs:8010(内存缓存 + mtime 重载,仅 gateway.ini);其余服务配置纯 CLI 传递
|
||||
- **数据/审核/邮件/缓存**:`dnfs2` → `db2:8005`(pymsg 持久化);`dnfs2` → `ai_review:8007`(审核);注册邮件 `ca` → `smtp:8008`;登录验证码 `ca` → `captcha2:8009`;`memdb:8011`(内存缓存,libmsg RPC,暂无业务调用方,预留热点数据/会话缓存)
|
||||
- **机器人数据链**:`pybot --record` 录制 episode(images + actions.npy + imu.npy)→ `actpred` 两阶段训练 → ONNX/Hailo HEF 部署 → `pybot --loop` 实时预测闭环(bot4cj 语义对齐)
|
||||
- **机器人数据链**:`mtc` 编译 Python DSL → `.mt`(或手写 `.mt`)→ `pybot` 执行 / `--record` 录制 episode(images + actions.npy + imu.npy)→ `actpred` 两阶段训练 → ONNX/Hailo HEF 部署 → `pybot --loop` 实时预测闭环(bot4cj 语义对齐)
|
||||
- **端口一览**:80 redirect80 · 443 waf · 8443 ca · 8445 gateway · 5200/8010 core · 9200 fsys · 8006 dnfs2 · 8005 db2 · 8007 ai_review · 8009 captcha2 · 8008 smtp · 8011 memdb · 8765 aibox
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# mtc(.mt 脚本编译器)
|
||||
|
||||
命令式动作脚本(Python DSL)→ `.mt` 矩阵脚本编译器。零第三方依赖(纯标准库),产物可直接由 [pybot](../pybot/) / [bot4cj](../bot4cj/) 解析执行。
|
||||
|
||||
## 定位与管线
|
||||
|
||||
手写 `.mt` 矩阵脚本需要心算 10ms 栅格上的全通道快照,冗长且易错。mtc 让动作用命令式 Python 描述(`Action("s1", 10, 100)`),编译器负责展开为栅格矩阵:
|
||||
|
||||
```
|
||||
script.py ──exec(注入命名空间)──► 事件收集 ──► 时间轴 IR ──► 10ms 栅格化 ──► out.mt
|
||||
(相对→绝对、重叠截断)
|
||||
```
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
cd mtc # 须在本目录运行(外层目录会被识别为 namespace package)
|
||||
python -m mtc walk.py # 编译到 stdout(可管道:python -m mtc walk.py | python -m pybot --dry-run /dev/stdin)
|
||||
python -m mtc walk.py -o walk.mt # 写文件
|
||||
```
|
||||
|
||||
也可设 `PYTHONPATH=mtc` 后在任意目录运行。诊断(编译告警/错误)走 stderr,stdout 保持纯净 .mt 文本。
|
||||
|
||||
## DSL 参考
|
||||
|
||||
脚本即 `.py`,`exec` 注入两类函数。
|
||||
|
||||
### 硬件声明(函数名 = .mt 块名,位置参数 = 表头列,严格一一对应)
|
||||
|
||||
```python
|
||||
I2C("i2c-1", "/dev/i2c-1")
|
||||
PCA9685("pca9685-0", "i2c-1", "0x40")
|
||||
Servo("s1", 180, 0, 0, "pca9685-0", 0) # name,max,min,offset,controller,channel
|
||||
Speaker("spk", 2, 48000, 0.6, 5000, 20) # name,channels,sample_rate,volumn,max_f,min_f
|
||||
Camera("cam0", 640, 480, 30, 8)
|
||||
MPU6050_BIND("imu0", "i2c-1", "0x68")
|
||||
Alias("A", "angle")
|
||||
register_block("ADS1115", ["name", "I2C", "addr"]) # 开放扩展(首列恒为 name)
|
||||
```
|
||||
|
||||
### 时间轴指令(编译期求值,不产生 .mt 行)
|
||||
|
||||
| 函数 | 语义 |
|
||||
|------|------|
|
||||
| `Action(dev, delta, dur)` | 相对运动:当前游标处注册,dur ms 内走 delta |
|
||||
| `SetTo(dev, angle, dur)` | 绝对运动(糖:delta = angle − 已注册目标值) |
|
||||
| `Beep(freq, dur, dev="spk")` | 扬声器频率段:dur ms 内恒为 freq(阶跃段) |
|
||||
| `Delay(ms)` | 游标推进固定量 |
|
||||
| `Wait()` | 汇合:游标推进到所有在途运动的最晚完成时刻 |
|
||||
| `Pose(dev, angle)` | 声明编译期初始姿态(不产生运动段,须在 Action 之前) |
|
||||
|
||||
`dur` / `Delay` 必须是正的 10 的倍数(.mt 栅格与 pybot 微步节拍均为 10ms)。
|
||||
|
||||
## 语义规则
|
||||
|
||||
- **非阻塞时间轴**:`Action`/`SetTo`/`Beep` 在当前游标处注册运动段并立即返回,`Delay`/`Wait` 才推进游标。不同设备段在同一时间窗重叠即并发运动。
|
||||
- **重叠截断**:同通道新段与未完结末尾段重叠时,前段截断至新段起点,新段基准取前段**目标值**(编译期确定,不依赖运行时实际姿态),并产出告警(stderr)。
|
||||
- **静止值(rest)**:姿态通道(舵机等)段外保持前段目标值;`spk.frequency` 通道段前/段间空隙/段后统一回落 0 = 静音(对齐 pybot speaker 静音语义:`freq<=0` 不出声仅保节拍)。
|
||||
- **节拍列**:`spk.delay` 由编译器管理(恒 10),脚本不能直接写;无 Speaker 块则无节拍列(pybot 执行时行间隔不等待,编译器给出告警)。`spk.frequency` 无段时自动补恒 0 列(pybot 契约:frequency/delay 两列成对出现)。
|
||||
- **栅格行语义**:格点 t=10,20,…,T,每行代表该 10ms 结束时刻的绝对姿态;pybot `run()` 对 delay=10 的行分解为 1 个微步且插值系数 1.0,即直接到位——与栅格语义严格等价。
|
||||
|
||||
## 示例
|
||||
|
||||
```python
|
||||
Servo("s1", 180, 0, 0, "pca9685-0", 0)
|
||||
Servo("s2", 180, 0, 0, "pca9685-0", 1)
|
||||
Speaker("spk", 2, 48000, 0.6, 5000, 20)
|
||||
Action("s1", 10, 100) # s1 在 [0,100ms) 走 10°
|
||||
Delay(50) # 游标推进到 50ms
|
||||
Action("s2", 20, 50) # s2 在 [50,100ms) 走 20°(第 6 行起步)
|
||||
```
|
||||
|
||||
编译产物:
|
||||
|
||||
```
|
||||
@Servo
|
||||
name max min offset controller channel;
|
||||
s1 180 0 0 pca9685-0 0;
|
||||
s2 180 0 0 pca9685-0 1;
|
||||
@
|
||||
|
||||
@Speaker
|
||||
name channels sample_rate volumn max_f min_f;
|
||||
spk 2 48000 0.6 5000 20;
|
||||
@
|
||||
|
||||
@Action
|
||||
s1.angle s2.angle spk.frequency spk.delay;
|
||||
1 0 0 10;
|
||||
2 0 0 10;
|
||||
3 0 0 10;
|
||||
4 0 0 10;
|
||||
5 0 0 10;
|
||||
6 4 0 10;
|
||||
7 8 0 10;
|
||||
8 12 0 10;
|
||||
9 16 0 10;
|
||||
10 20 0 10;
|
||||
@
|
||||
```
|
||||
|
||||
## 输出契约(与 pybot parser 对齐)
|
||||
|
||||
- `@块名` / `@` 定界;行 0 表头;行尾 `;`;列间空格分隔;相邻同名块合并为一个块多行。
|
||||
- `@Action` 表头为 `设备.参数` 长名(恰含一个 `.`,Q05 校验),末列 `<speaker>.delay` 恒 10。
|
||||
- 数值格式化:整数无小数点,小数至多 3 位。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
cd mtc && python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
含 e2e 闭环验证:编译产物经 pybot `MatrixParser` 零修改解析(列数一致、Q05 表头契约、栅格行数值逐一比对、roundtrip 自检)。pybot 与 mtc 的 `tests/` 目录同名,两套测试须分进程运行。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
mtc/
|
||||
├── mtc/
|
||||
│ ├── __init__.py # 包入口(compile_script / CompileError)
|
||||
│ ├── __main__.py # CLI:python -m mtc script.py [-o out.mt]
|
||||
│ ├── dsl.py # DSL 命名空间 + 事件收集(Collector)
|
||||
│ ├── timeline.py # 时间轴 IR:Segment/Channel、重叠截断、rest 静止值
|
||||
│ └── emit.py # .mt 序列化:硬件块合并 + 10ms 栅格矩阵
|
||||
└── tests/ # pytest(含 pybot parser e2e 闭环)
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
"""mtc —— 命令式动作脚本(Python DSL)→ .mt 矩阵脚本编译器
|
||||
|
||||
管线:script.py --exec--> 事件收集 --> 时间轴 IR(相对→绝对,
|
||||
重叠截断)--> 10ms 栅格化 --> out.mt --> pybot 解析/执行。
|
||||
|
||||
用法:
|
||||
python -m mtc walk.py # 编译到 stdout(管道风格)
|
||||
python -m mtc walk.py -o walk.mt # 写文件
|
||||
python -m pybot --dry-run walk.mt # 交给 pybot 验证/执行
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from .dsl import CompileError, compile_script # noqa: F401
|
||||
@@ -0,0 +1,54 @@
|
||||
"""CLI 入口:python -m mtc script.py [-o out.mt]
|
||||
|
||||
诊断(编译告警/错误)走 stderr,stdout 保持纯净 .mt 文本
|
||||
(对齐 pybot 的 S02 日志规范,支持 mtc walk.py | pybot --dry-run 管道)。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from .dsl import CompileError, compile_script
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(
|
||||
prog="mtc",
|
||||
description="命令式动作脚本(Python DSL)→ .mt 编译器")
|
||||
p.add_argument("script", help="DSL 脚本路径(.py)")
|
||||
p.add_argument("-o", "--output", default=None,
|
||||
help="输出 .mt 路径(缺省写 stdout)")
|
||||
p.add_argument("--verbose", action="store_true", help="DEBUG 日志")
|
||||
args = p.parse_args(argv)
|
||||
logging.basicConfig(stream=sys.stderr,
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(levelname)s %(name)s: %(message)s")
|
||||
|
||||
try:
|
||||
with open(args.script, encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
except OSError as e:
|
||||
logging.error("无法读取脚本: %s", e)
|
||||
return 2
|
||||
|
||||
try:
|
||||
text, warnings = compile_script(source, filename=args.script)
|
||||
except CompileError as e:
|
||||
logging.error("编译失败: %s", e)
|
||||
return 2
|
||||
|
||||
for w in warnings:
|
||||
logging.warning("%s", w)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
logging.info("written: %s (%d lines)", args.output,
|
||||
text.count("\n"))
|
||||
else:
|
||||
sys.stdout.write(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
"""mtc DSL —— 命令式动作脚本(Python)→ 事件收集
|
||||
|
||||
脚本即 .py,通过 exec(source, make_namespace(collector)) 执行,
|
||||
命名空间内注入两类函数:
|
||||
|
||||
硬件声明(函数名 = .mt 块名,位置参数 = 表头列,严格一一对应):
|
||||
I2C("i2c-1", "/dev/i2c-1")
|
||||
PCA9685("pca9685-0", "i2c-1", "0x40")
|
||||
Servo("s1", 90, 0, 0, "pca9685-0", 0) # name,max,min,offset,controller,channel
|
||||
Speaker("spk", 2, 48000, 0.6, 5000, 20)
|
||||
Camera("cam0", 640, 480, 30, 8)
|
||||
MPU6050_BIND("imu0", "i2c-1", "0x68")
|
||||
Alias("A", "angle")
|
||||
register_block("ADS1115", ["name", "I2C", "addr"]) # 开放扩展
|
||||
|
||||
时间轴指令(编译期求值,不直接出现在 .mt 行中):
|
||||
Action(dev, delta, dur) # 相对运动:当前游标处注册,dur ms 内走 delta
|
||||
SetTo(dev, angle, dur) # 绝对运动(糖:delta = angle - 已注册目标值)
|
||||
Delay(ms) # 游标推进固定量
|
||||
Wait() # 游标推进到所有在途运动的最晚完成时刻(汇合)
|
||||
Beep(freq, dur) # 扬声器频率段(默认设备 spk)
|
||||
Pose(dev, angle) # 声明编译期初始姿态(默认 0,不产生运动段)
|
||||
|
||||
dur/Delay 必须是 10 的倍数(.mt 栅格与 pybot 微步节拍均为 10ms)。
|
||||
"""
|
||||
|
||||
from .timeline import STEP_MS, Channel, total_ms
|
||||
from .emit import emit_mt
|
||||
|
||||
# 已知块 → 表头列(位置参数顺序;首列恒为 name)
|
||||
_BLOCK_SCHEMAS = {
|
||||
"Alias": ["name", "alias"],
|
||||
"I2C": ["name", "path"],
|
||||
"PCA9685": ["name", "I2C", "address"],
|
||||
"MPU6050_BIND": ["name", "I2C", "addr"],
|
||||
"Speaker": ["name", "channels", "sample_rate", "volumn", "max_f", "min_f"],
|
||||
"Servo": ["name", "max", "min", "offset", "controller", "channel"],
|
||||
"Camera": ["name", "width", "height", "framerate", "capacity"],
|
||||
}
|
||||
|
||||
|
||||
class CompileError(Exception):
|
||||
"""脚本编译错误(携带可读信息,CLI 原样输出)"""
|
||||
|
||||
|
||||
class Collector:
|
||||
"""一次编译的全部状态:硬件块 + 通道时间轴 + 游标 + 告警"""
|
||||
|
||||
def __init__(self):
|
||||
self.blocks = [] # [(block_name, columns, row_values)]
|
||||
self.channels = {} # {通道名(dev.param): Channel}(注册序)
|
||||
self.cursor = 0.0 # 当前时间游标 ms(单调推进)
|
||||
self.warnings = []
|
||||
|
||||
# ---------------- 硬件 ----------------
|
||||
|
||||
def add_block(self, block_name, columns, values):
|
||||
self.blocks.append((block_name, columns, [str(v) for v in values]))
|
||||
|
||||
def device_names(self):
|
||||
"""已声明硬件变量名集合(每块首列恒为 name)"""
|
||||
return {row[0] for _, _, row in self.blocks}
|
||||
|
||||
def first_speaker(self):
|
||||
"""第一个 Speaker 块声明的设备名(delay 节拍列所属),无则 None"""
|
||||
for block_name, _, row in self.blocks:
|
||||
if block_name == "Speaker":
|
||||
return row[0]
|
||||
return None
|
||||
|
||||
# ---------------- 时间轴 ----------------
|
||||
|
||||
def channel(self, name):
|
||||
ch = self.channels.get(name)
|
||||
if ch is None:
|
||||
ch = self.channels[name] = Channel(name)
|
||||
return ch
|
||||
|
||||
def check_device(self, dev):
|
||||
if dev not in self.device_names():
|
||||
raise CompileError(
|
||||
f"设备 '{dev}' 未声明(先用 Servo/Speaker/插件块声明)")
|
||||
|
||||
def warn_overlaps(self):
|
||||
for ch in self.channels.values():
|
||||
self.warnings.extend(ch.overlap_warns)
|
||||
|
||||
# ---------------- 编译 ----------------
|
||||
|
||||
def compile(self):
|
||||
"""归一化 IR 并序列化为 .mt 文本,返回 (mt_text, warnings)"""
|
||||
self.warn_overlaps()
|
||||
active = [ch for ch in self.channels.values() if ch.segments]
|
||||
t_total = total_ms(self.channels, self.cursor)
|
||||
if not active and t_total <= 0:
|
||||
raise CompileError("脚本没有任何动作(@Action 将为空)")
|
||||
if self.first_speaker() is None:
|
||||
self.warnings.append(
|
||||
"无 Speaker 块:.mt 无 delay 节拍列,pybot 执行时行间隔"
|
||||
"不等待(时间轴语义丢失,仅 --loop/解析场景可用)")
|
||||
text = emit_mt(self.blocks, active, t_total,
|
||||
speaker=self.first_speaker())
|
||||
return text, self.warnings
|
||||
|
||||
|
||||
def _check_dur(ms, what):
|
||||
if float(ms) <= 0 or float(ms) % STEP_MS != 0:
|
||||
raise CompileError(f"{what} 必须是正的 {STEP_MS} 的倍数,got {ms!r}")
|
||||
|
||||
|
||||
def make_namespace(collector):
|
||||
"""构建脚本 exec 命名空间(硬件函数 + 时间轴指令)"""
|
||||
ns = {}
|
||||
schemas = dict(_BLOCK_SCHEMAS)
|
||||
|
||||
def _make_block_fn(block_name, columns):
|
||||
def fn(*args):
|
||||
if len(args) != len(columns):
|
||||
raise CompileError(
|
||||
f"{block_name}() 需要 {len(columns)} 个参数 "
|
||||
f"({', '.join(columns)}),got {len(args)}")
|
||||
collector.add_block(block_name, columns, args)
|
||||
fn.__name__ = block_name
|
||||
return fn
|
||||
|
||||
for block_name, cols in schemas.items():
|
||||
ns[block_name] = _make_block_fn(block_name, cols)
|
||||
|
||||
def register_block(block_name, columns):
|
||||
"""注册自定义块(插件扩展):register_block("ADS1115", [...])"""
|
||||
if not columns or columns[0] != "name":
|
||||
raise CompileError("register_block: 首列必须是 name")
|
||||
ns[block_name] = _make_block_fn(block_name, list(columns))
|
||||
|
||||
def _register(dev, param, value, dur, absolute):
|
||||
collector.check_device(dev)
|
||||
_check_dur(dur, f"{dev}.{param} 的 dur")
|
||||
if param == "delay":
|
||||
raise CompileError("spk.delay 列由编译器管理(恒 10),"
|
||||
"节拍请用 Delay()/Wait()")
|
||||
ch = collector.channel(f"{dev}.{param}")
|
||||
v0 = ch.target # 重叠时基准 = 前段目标值(编译期确定)
|
||||
v1 = float(value) if absolute else v0 + float(value)
|
||||
ch.add(collector.cursor, float(dur), v0, v1)
|
||||
|
||||
def Action(dev, delta, dur, param="angle"):
|
||||
"""相对运动:dur ms 内走 delta(可正可负)"""
|
||||
_register(dev, param, delta, dur, absolute=False)
|
||||
|
||||
def SetTo(dev, angle, dur, param="angle"):
|
||||
"""绝对运动:dur ms 内到达 angle"""
|
||||
_register(dev, param, angle, dur, absolute=True)
|
||||
|
||||
def Delay(ms):
|
||||
"""游标推进固定量"""
|
||||
_check_dur(ms, "Delay")
|
||||
collector.cursor += float(ms)
|
||||
|
||||
def Wait():
|
||||
"""汇合:游标推进到所有在途运动的最晚完成时刻"""
|
||||
busy = [ch.busy_until for ch in collector.channels.values()]
|
||||
collector.cursor = max([collector.cursor] + busy)
|
||||
|
||||
def Beep(freq, dur, dev="spk"):
|
||||
"""扬声器频率段:dev.frequency 在 dur ms 内恒为 freq(阶跃段)
|
||||
|
||||
通道静止值设为 0:段前/段间空隙/段后回落 0 = 静音
|
||||
(对齐 pybot speaker 静音语义:freq<=0 不出声仅保节拍)。
|
||||
"""
|
||||
collector.check_device(dev)
|
||||
_check_dur(dur, f"Beep({freq}) 的 dur")
|
||||
ch = collector.channel(f"{dev}.frequency")
|
||||
ch.rest = 0.0
|
||||
ch.add(collector.cursor, float(dur), float(freq), float(freq))
|
||||
|
||||
def Pose(dev, angle, param="angle"):
|
||||
"""声明编译期初始姿态(不产生运动段;须在 Action 之前)"""
|
||||
collector.check_device(dev)
|
||||
ch = collector.channel(f"{dev}.{param}")
|
||||
if ch.segments:
|
||||
raise CompileError(
|
||||
f"Pose({dev}) 必须在任何 Action 之前声明")
|
||||
ch.set_initial(angle)
|
||||
|
||||
ns.update(Action=Action, SetTo=SetTo, Delay=Delay, Wait=Wait,
|
||||
Beep=Beep, Pose=Pose, register_block=register_block)
|
||||
return ns
|
||||
|
||||
|
||||
def compile_script(source, filename="<script>"):
|
||||
"""编译 DSL 脚本 → (mt_text, warnings)
|
||||
|
||||
脚本语法错误包装为 CompileError(携带文件/行号)。
|
||||
"""
|
||||
collector = Collector()
|
||||
ns = make_namespace(collector)
|
||||
try:
|
||||
code = compile(source, filename, "exec")
|
||||
exec(code, ns)
|
||||
except CompileError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise CompileError(f"{filename}: {type(e).__name__}: {e}") from e
|
||||
return collector.compile()
|
||||
@@ -0,0 +1,80 @@
|
||||
""".mt 序列化 —— 硬件块(依赖序/声明序)+ @Action 栅格矩阵
|
||||
|
||||
输出契约(与 pybot parser 对齐):
|
||||
@块名 / @ 定界;行 0 表头;行尾 ';';列间空格分隔;
|
||||
@Action 表头为 '设备.参数' 长名(Q05 校验恰含一个 '.'),
|
||||
末列 '<speaker>.delay' 恒 10(栅格节拍),无 Speaker 块则无此列。
|
||||
|
||||
栅格行语义:t=10,20,…,T,行值为该 10ms 结束时刻的绝对姿态;
|
||||
pybot run() 对 delay=10 的行分解为 1 个微步且插值系数 1.0,
|
||||
即直接到位 —— 与栅格语义严格等价。
|
||||
"""
|
||||
|
||||
from .timeline import STEP_MS
|
||||
|
||||
|
||||
def fmt_num(v):
|
||||
"""数值格式化:整数无小数点,小数保留至多 3 位(栅格插值值)"""
|
||||
f = float(v)
|
||||
if f == int(f):
|
||||
return str(int(f))
|
||||
return f"{f:.3f}".rstrip("0")
|
||||
|
||||
|
||||
def _fmt_block(lines, name, columns, rows):
|
||||
lines.append(f"@{name}")
|
||||
lines.append(" ".join(columns) + ";")
|
||||
for row in rows:
|
||||
lines.append(" ".join(str(v) for v in row) + ";")
|
||||
lines.append("@")
|
||||
|
||||
|
||||
def emit_mt(blocks, active_channels, t_total, speaker=None):
|
||||
"""生成 .mt 全文
|
||||
|
||||
blocks: [(block_name, columns, row_values_str)](声明序)
|
||||
active_channels: [Channel](有运动段的通道,注册序)
|
||||
t_total: 编译总时长 ms(10 的倍数)
|
||||
speaker: delay 节拍列所属设备名;None 则无节拍列
|
||||
"""
|
||||
from .dsl import CompileError # 避免环:dsl 依赖 emit
|
||||
|
||||
lines = []
|
||||
merged = [] # 相邻同名块合并为一个块多行(.mt 惯例)
|
||||
for block_name, columns, row in blocks:
|
||||
if merged and merged[-1][0] == block_name and merged[-1][1] == columns:
|
||||
merged[-1][2].append(row)
|
||||
else:
|
||||
merged.append((block_name, columns, [row]))
|
||||
for block_name, columns, rows in merged:
|
||||
_fmt_block(lines, block_name, columns, rows)
|
||||
lines.append("") # 块间空行(MatrixSaver 风格)
|
||||
|
||||
delay_col = f"{speaker}.delay" if speaker else None
|
||||
freq_col = f"{speaker}.frequency" if speaker else None
|
||||
# pybot 契约:SpeakerVar.play 无条件读 frequency+delay 两键,
|
||||
# delay 节拍列必须与同设备 frequency 列成对出现(std.mt 惯例:
|
||||
# spk.F spk.delay;无发声需求时 frequency 恒 0,对齐 jump.mt 首行)
|
||||
active_names = {ch.name for ch in active_channels}
|
||||
fill_freq = bool(freq_col) and freq_col not in active_names
|
||||
|
||||
header = [ch.name for ch in active_channels]
|
||||
if fill_freq:
|
||||
header.append(freq_col)
|
||||
if delay_col:
|
||||
header.append(delay_col)
|
||||
if not header:
|
||||
raise CompileError("@Action 无列(既无运动通道也无 Speaker 节拍列)")
|
||||
|
||||
n = int(round(t_total / STEP_MS))
|
||||
rows = []
|
||||
for k in range(1, n + 1):
|
||||
t = k * STEP_MS
|
||||
row = [fmt_num(ch.value_at(t)) for ch in active_channels]
|
||||
if fill_freq:
|
||||
row.append("0")
|
||||
if delay_col:
|
||||
row.append(str(STEP_MS))
|
||||
rows.append(row)
|
||||
_fmt_block(lines, "Action", header, rows)
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""时间轴 IR —— 运动段、重叠截断、10ms 栅格化采样
|
||||
|
||||
非阻塞时间轴模型(与 mtc 设计定稿一致):
|
||||
Action 在当前游标处注册运动段并立即返回,Delay/Wait 推进游标;
|
||||
.mt 一行即全通道快照,段在不同通道上的重叠天然表达并发。
|
||||
|
||||
栅格语义:格点 t=10,20,…,T,每行代表该 10ms 结束时刻的姿态。
|
||||
与 pybot 执行的等价性:delay=10 的行在 robot.run() 中分解为 1 个
|
||||
微步且插值系数 1.0(k=0,n=1),即直接到位 —— 栅格行语义严格成立。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
STEP_MS = 10 # .mt 栅格步长,与 pybot run() 微步节拍一致
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
"""一个运动段:[t0, t0+dur) 内从 v0 线性运动到 v1(绝对值)"""
|
||||
|
||||
t0: float
|
||||
dur: float
|
||||
v0: float
|
||||
v1: float
|
||||
|
||||
@property
|
||||
def end(self):
|
||||
return self.t0 + self.dur
|
||||
|
||||
def value_at(self, t):
|
||||
"""t 时刻的值;段前取 v0、段后取 v1(保持语义)"""
|
||||
if t <= self.t0:
|
||||
return self.v0
|
||||
if t >= self.end:
|
||||
return self.v1
|
||||
return self.v0 + (self.v1 - self.v0) * (t - self.t0) / self.dur
|
||||
|
||||
|
||||
@dataclass
|
||||
class Channel:
|
||||
"""单通道时间轴(如 s1.angle / spk.frequency)
|
||||
|
||||
段按注册序追加(游标单调 ⇒ t0 单调),因此任何时刻最多一个
|
||||
未完结段(末尾段)。重叠规则:新段注册时若末尾段未完结,则
|
||||
截断末尾段至 t0,新段基准取前段目标值 v1(编译期确定,不依赖
|
||||
运行时实际姿态),由调用方在注册前取 target 作为新段 v0。
|
||||
|
||||
rest:静止值。None(舵机等姿态通道)段外保持前段目标值;
|
||||
数值(如 frequency 通道 = 0)段前/段间空隙/段后统一回落该值
|
||||
—— 对齐 pybot speaker 静音语义(freq<=0 不出声仅保节拍)。
|
||||
"""
|
||||
|
||||
name: str
|
||||
initial: float = 0.0 # Pose() 声明的编译期初始姿态
|
||||
rest: float = None # 静止值(None=保持前段目标值)
|
||||
segments: list = field(default_factory=list)
|
||||
overlap_warns: list = field(default_factory=list) # 截断告警文本
|
||||
|
||||
@property
|
||||
def target(self):
|
||||
"""已注册目标姿态(最后段 v1;无段取 initial)"""
|
||||
return self.segments[-1].v1 if self.segments else self.initial
|
||||
|
||||
@property
|
||||
def busy_until(self):
|
||||
"""最晚在途完成时刻"""
|
||||
return self.segments[-1].end if self.segments else 0.0
|
||||
|
||||
def set_initial(self, angle):
|
||||
if self.segments:
|
||||
raise ValueError(
|
||||
f"Pose({self.name}) 必须在任何 Action 之前声明")
|
||||
self.initial = float(angle)
|
||||
|
||||
def add(self, t0, dur, v0, v1):
|
||||
"""注册运动段;与未完结末尾段重叠时截断之并记录告警"""
|
||||
if self.segments and self.segments[-1].end > t0:
|
||||
prev = self.segments[-1]
|
||||
self.overlap_warns.append(
|
||||
f"{self.name}: 段 [{prev.t0:g},{prev.end:g}) 与新段 "
|
||||
f"[{t0:g},{t0 + dur:g}) 重叠,前段截断至 {t0:g}ms "
|
||||
f"(新段基准取前段目标值 {prev.v1:g})")
|
||||
prev.dur = t0 - prev.t0 # t0/t0 均为 10 倍数,截断后仍对齐栅格
|
||||
if prev.dur <= 0:
|
||||
self.segments.pop()
|
||||
self.segments.append(Segment(t0, dur, v0, v1))
|
||||
|
||||
def _rest_or(self, v):
|
||||
"""段外取值:设定了 rest 则回落静止值,否则保持前段目标值"""
|
||||
return self.rest if self.rest is not None else v
|
||||
|
||||
def value_at(self, t):
|
||||
"""格点采样:段内插值;段前/段间空隙/段后按 rest 规则取值"""
|
||||
if not self.segments:
|
||||
return self._rest_or(self.initial)
|
||||
prev_v1 = self.initial
|
||||
for seg in self.segments:
|
||||
if t <= seg.t0:
|
||||
return self._rest_or(prev_v1) # 段前(含段间空隙)
|
||||
if t <= seg.end:
|
||||
return seg.value_at(t) # 段内(端点连续)
|
||||
prev_v1 = seg.v1
|
||||
return self._rest_or(prev_v1) # 全部段后
|
||||
|
||||
|
||||
def total_ms(channels, cursor):
|
||||
"""编译总时长:所有通道最晚完成时刻与游标的最大值"""
|
||||
return max([ch.busy_until for ch in channels.values()] + [cursor])
|
||||
Whitespace-only changes.
@@ -0,0 +1,25 @@
|
||||
"""pytest 公共配置:mtc 根入 sys.path;pybot parser 独立加载(绕包 __init__)
|
||||
|
||||
Windows 开发机无 fcntl:import pybot 会经 robot.py → hd/i2c.py 触发
|
||||
fcntl 导入失败。e2e 仅需 MatrixParser,而 parser.py 只依赖 logging,
|
||||
用 spec_from_file_location 独立加载即可(与 pybot tests/conftest.py
|
||||
的符号链接 fallback 同属 Windows 兼容层)。
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MTC_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(MTC_ROOT))
|
||||
|
||||
PYBOT_PARSER = MTC_ROOT.parent / "pybot" / "pybot" / "parser.py"
|
||||
|
||||
|
||||
def load_pybot_parser():
|
||||
"""独立加载 pybot/parser.py(不触发 pybot/__init__ 的硬件链)"""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"pybot_parser_standalone", PYBOT_PARSER)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
@@ -0,0 +1,127 @@
|
||||
"""DSL 收集测试:硬件声明 / 时间轴指令 / 校验规则"""
|
||||
|
||||
import pytest
|
||||
|
||||
from mtc.dsl import Collector, CompileError, compile_script, make_namespace
|
||||
|
||||
|
||||
def run(source):
|
||||
"""exec 脚本并返回 collector"""
|
||||
c = Collector()
|
||||
exec(compile(source, "<test>", "exec"), make_namespace(c))
|
||||
return c
|
||||
|
||||
|
||||
# ---------------- 硬件声明 ----------------
|
||||
|
||||
def test_hw_blocks_collected_in_order():
|
||||
c = run('I2C("i2c-1", "/dev/i2c-1")\n'
|
||||
'Servo("s1", 90, 0, 0, "pca9685-0", 0)\n'
|
||||
'Servo("s2", 180, 0, 0, "pca9685-0", 1)\n')
|
||||
assert [b[0] for b in c.blocks] == ["I2C", "Servo", "Servo"]
|
||||
assert c.blocks[1][2] == ["s1", "90", "0", "0", "pca9685-0", "0"]
|
||||
assert c.device_names() == {"i2c-1", "s1", "s2"}
|
||||
|
||||
|
||||
def test_hw_block_arity_error():
|
||||
with pytest.raises(CompileError, match="需要 6 个参数"):
|
||||
run('Servo("s1", 90, 0)\n')
|
||||
|
||||
|
||||
def test_register_block_extension():
|
||||
c = run('register_block("ADS1115", ["name", "I2C", "addr"])\n'
|
||||
'ADS1115("ads0", "i2c-1", "0x48")\n')
|
||||
assert c.blocks[0][0] == "ADS1115"
|
||||
assert c.blocks[0][1] == ["name", "I2C", "addr"]
|
||||
|
||||
|
||||
def test_register_block_requires_name_first():
|
||||
with pytest.raises(CompileError, match="首列必须是 name"):
|
||||
run('register_block("Bad", ["addr"])\n')
|
||||
|
||||
|
||||
# ---------------- 时间轴指令 ----------------
|
||||
|
||||
def test_action_delay_wait_cursor():
|
||||
c = run('Servo("s1", 90, 0, 0, "pca9685-0", 0)\n'
|
||||
'Servo("s2", 90, 0, 0, "pca9685-0", 1)\n'
|
||||
'Action("s1", 10, 100)\n'
|
||||
'Delay(50)\n'
|
||||
'Action("s2", 20, 50)\n'
|
||||
'Wait()\n')
|
||||
assert c.cursor == 100.0 # Wait 推进到最晚完成时刻
|
||||
assert c.channels["s1.angle"].segments[0].v1 == 10
|
||||
assert c.channels["s2.angle"].segments[0].t0 == 50
|
||||
|
||||
|
||||
def test_action_relative_accumulates():
|
||||
c = run('Servo("s1", 90, 0, 0, "pca9685-0", 0)\n'
|
||||
'Action("s1", 10, 100)\n'
|
||||
'Wait()\n'
|
||||
'Action("s1", 10, 100)\n')
|
||||
segs = c.channels["s1.angle"].segments
|
||||
assert (segs[1].v0, segs[1].v1) == (10, 20) # 相对叠加在前段目标值上
|
||||
|
||||
|
||||
def test_setto_absolute():
|
||||
c = run('Servo("s1", 90, 0, 0, "pca9685-0", 0)\n'
|
||||
'Pose("s1", 45)\n'
|
||||
'SetTo("s1", 90, 100)\n')
|
||||
seg = c.channels["s1.angle"].segments[0]
|
||||
assert (seg.v0, seg.v1) == (45, 90)
|
||||
|
||||
|
||||
def test_beep_registers_frequency_channel():
|
||||
c = run('Speaker("spk", 2, 48000, 0.6, 5000, 20)\n'
|
||||
'Beep(1000, 50)\n')
|
||||
assert "spk.frequency" in c.channels
|
||||
assert c.channels["spk.frequency"].segments[0].v1 == 1000
|
||||
|
||||
|
||||
# ---------------- 校验 ----------------
|
||||
|
||||
def test_action_undeclared_device_rejected():
|
||||
with pytest.raises(CompileError, match="未声明"):
|
||||
run('Action("s1", 10, 100)\n')
|
||||
|
||||
|
||||
def test_dur_must_be_positive_multiple_of_10():
|
||||
with pytest.raises(CompileError, match="10 的倍数"):
|
||||
run('Servo("s1", 90, 0, 0, "pca9685-0", 0)\n'
|
||||
'Action("s1", 10, 15)\n')
|
||||
with pytest.raises(CompileError, match="10 的倍数"):
|
||||
run('Delay(5)\n')
|
||||
|
||||
|
||||
def test_delay_column_is_reserved():
|
||||
with pytest.raises(CompileError, match="编译器管理"):
|
||||
run('Speaker("spk", 2, 48000, 0.6, 5000, 20)\n'
|
||||
'Action("spk", 10, 100, param="delay")\n')
|
||||
|
||||
|
||||
def test_pose_after_action_rejected():
|
||||
with pytest.raises(CompileError, match="Action 之前"):
|
||||
run('Servo("s1", 90, 0, 0, "pca9685-0", 0)\n'
|
||||
'Action("s1", 10, 100)\n'
|
||||
'Pose("s1", 45)\n')
|
||||
|
||||
|
||||
def test_empty_script_rejected():
|
||||
with pytest.raises(CompileError, match="没有任何动作"):
|
||||
compile_script('Servo("s1", 90, 0, 0, "pca9685-0", 0)\n')
|
||||
|
||||
|
||||
def test_python_loop_and_math_work():
|
||||
"""Python DSL 红利:for 循环与数学运算直接可用"""
|
||||
c = run('Servo("s1", 90, 0, 0, "pca9685-0", 0)\n'
|
||||
'for i in range(4):\n'
|
||||
' Action("s1", 5 if i % 2 == 0 else -5, 20)\n'
|
||||
' Wait()\n')
|
||||
segs = c.channels["s1.angle"].segments
|
||||
assert len(segs) == 4
|
||||
assert [s.v1 for s in segs] == [5, 0, 5, 0]
|
||||
|
||||
|
||||
def test_syntax_error_wrapped():
|
||||
with pytest.raises(CompileError, match="SyntaxError"):
|
||||
compile_script("def broken(:\n")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""端到端:mtc 编译产物 → pybot MatrixParser 解析断言
|
||||
|
||||
验证闭环:编译产物必须被 pybot 零修改解析(列数一致、Q05 表头
|
||||
'设备.参数' 校验、栅格行数与数值逐一比对)。
|
||||
"""
|
||||
|
||||
from mtc.dsl import compile_script
|
||||
from tests.conftest import load_pybot_parser
|
||||
from tests.test_emit import EXAMPLE
|
||||
|
||||
|
||||
def _parse(mt_text):
|
||||
parser_mod = load_pybot_parser()
|
||||
parser = parser_mod.MatrixParser(mt_text)
|
||||
objects = []
|
||||
while True:
|
||||
obj = parser.parse()
|
||||
if obj is None:
|
||||
break
|
||||
objects.append(obj)
|
||||
return objects
|
||||
|
||||
|
||||
def test_compiled_mt_parses_with_pybot():
|
||||
objs = _parse(compile_script(EXAMPLE)[0])
|
||||
names = [o.name for o in objs]
|
||||
assert names == ["Servo", "Speaker", "Action"]
|
||||
action = objs[names.index("Action")]
|
||||
assert action.size == 11 # 表头 + 10 栅格行
|
||||
assert action.col == 4 # s1/s2 + spk.frequency + spk.delay
|
||||
for row in action.values: # 列数一致(DoesNotSync 不触发)
|
||||
assert len(row) == action.col
|
||||
|
||||
|
||||
def test_compiled_action_grid_values():
|
||||
objs = _parse(compile_script(EXAMPLE)[0])
|
||||
action = [o for o in objs if o.name == "Action"][0]
|
||||
assert action.values[0] == ["s1.angle", "s2.angle",
|
||||
"spk.frequency", "spk.delay"]
|
||||
grid = action.values[1:]
|
||||
assert [r[0] for r in grid] == [str(i) for i in range(1, 11)]
|
||||
assert [r[1] for r in grid] == ["0"] * 5 + ["4", "8", "12", "16", "20"]
|
||||
assert {r[2] for r in grid} == {"0"} # 无 Beep:frequency 恒 0
|
||||
assert {r[3] for r in grid} == {"10"}
|
||||
|
||||
|
||||
def test_compiled_mt_roundtrips_through_pybot():
|
||||
"""编译产物经 pybot 解析→序列化→再解析等价(roundtrip 自检)"""
|
||||
import io
|
||||
parser_mod = load_pybot_parser()
|
||||
objs = _parse(compile_script(EXAMPLE)[0])
|
||||
out = io.StringIO()
|
||||
parser_mod.MatrixSaver(out).save(objs)
|
||||
objs2 = _parse(out.getvalue())
|
||||
assert [(a.name, a.values) for a in objs] == \
|
||||
[(b.name, b.values) for b in objs2]
|
||||
|
||||
|
||||
def test_q05_header_contract_holds():
|
||||
"""@Action 全部列名恰含一个 '.'(pybot robot._validate_action 契约)"""
|
||||
objs = _parse(compile_script(EXAMPLE)[0])
|
||||
action = [o for o in objs if o.name == "Action"][0]
|
||||
for col in action.values[0]:
|
||||
assert col.count(".") == 1
|
||||
@@ -0,0 +1,97 @@
|
||||
"""emit 测试:数值格式化 / .mt 文本结构 / 用户设计示例逐字比对"""
|
||||
|
||||
from mtc.dsl import compile_script
|
||||
from mtc.emit import fmt_num
|
||||
|
||||
# 用户设计示例(修正后):非阻塞时间轴,s2 第 6 行起步
|
||||
EXAMPLE = '''
|
||||
Servo("s1", 180, 0, 0, "pca9685-0", 0)
|
||||
Servo("s2", 180, 0, 0, "pca9685-0", 1)
|
||||
Speaker("spk", 2, 48000, 0.6, 5000, 20)
|
||||
Action("s1", 10, 100)
|
||||
Delay(50)
|
||||
Action("s2", 20, 50)
|
||||
'''
|
||||
|
||||
EXPECTED_MT = """\
|
||||
@Servo
|
||||
name max min offset controller channel;
|
||||
s1 180 0 0 pca9685-0 0;
|
||||
s2 180 0 0 pca9685-0 1;
|
||||
@
|
||||
|
||||
@Speaker
|
||||
name channels sample_rate volumn max_f min_f;
|
||||
spk 2 48000 0.6 5000 20;
|
||||
@
|
||||
|
||||
@Action
|
||||
s1.angle s2.angle spk.frequency spk.delay;
|
||||
1 0 0 10;
|
||||
2 0 0 10;
|
||||
3 0 0 10;
|
||||
4 0 0 10;
|
||||
5 0 0 10;
|
||||
6 4 0 10;
|
||||
7 8 0 10;
|
||||
8 12 0 10;
|
||||
9 16 0 10;
|
||||
10 20 0 10;
|
||||
@
|
||||
"""
|
||||
|
||||
|
||||
def test_example_matches_user_design_exactly():
|
||||
text, warnings = compile_script(EXAMPLE)
|
||||
assert warnings == []
|
||||
assert text == EXPECTED_MT
|
||||
|
||||
|
||||
def test_fmt_num_integer_and_decimal():
|
||||
assert fmt_num(10.0) == "10"
|
||||
assert fmt_num(0) == "0"
|
||||
assert fmt_num(-3.0) == "-3"
|
||||
assert fmt_num(1.666666) == "1.667"
|
||||
assert fmt_num(2.500) == "2.5"
|
||||
|
||||
|
||||
def test_non_integer_step_values():
|
||||
"""10°/6 步:栅格值保留 3 位小数(pybot parse_mt float 可解析)"""
|
||||
text, _ = compile_script(
|
||||
'Servo("s1", 180, 0, 0, "pca9685-0", 0)\n'
|
||||
'Speaker("spk", 2, 48000, 0.6, 5000, 20)\n'
|
||||
'Action("s1", 10, 60)\n')
|
||||
rows = [l for l in text.splitlines() if l.endswith("10;")]
|
||||
assert len(rows) == 6
|
||||
assert rows[0].startswith("1.667 ")
|
||||
assert rows[-1].startswith("10 ")
|
||||
|
||||
|
||||
def test_no_speaker_no_delay_column_and_warns():
|
||||
text, warnings = compile_script(
|
||||
'Servo("s1", 180, 0, 0, "pca9685-0", 0)\n'
|
||||
'Action("s1", 10, 20)\n')
|
||||
assert any("无 Speaker" in w for w in warnings)
|
||||
assert "s1.angle;" in text # 表头无 frequency/delay 列
|
||||
assert "frequency" not in text
|
||||
|
||||
|
||||
def test_beep_gap_falls_back_to_zero_silence():
|
||||
text, _ = compile_script(
|
||||
'Speaker("spk", 2, 48000, 0.6, 5000, 20)\n'
|
||||
'Beep(1000, 30)\n'
|
||||
'Delay(50)\n') # T = max(段尾30, 游标50) = 50 → 5 行
|
||||
lines = text.splitlines()
|
||||
assert "spk.frequency spk.delay;" in lines
|
||||
rows = [l for l in lines if l.endswith(" 10;")]
|
||||
# 段内 1000;段后回落 0 = 静音(对齐 pybot speaker freq<=0 语义)
|
||||
assert [r.split()[0] for r in rows] == ["1000", "1000", "1000", "0", "0"]
|
||||
|
||||
|
||||
def test_pure_delay_script_fills_freq_zero_column():
|
||||
"""纯延时:自动补 spk.frequency 恒 0 列(SpeakerVar.play 两键契约)"""
|
||||
text, _ = compile_script(
|
||||
'Speaker("spk", 2, 48000, 0.6, 5000, 20)\n'
|
||||
'Delay(50)\n')
|
||||
assert "spk.frequency spk.delay;" in text
|
||||
assert text.count("0 10;") == 5
|
||||
@@ -0,0 +1,91 @@
|
||||
"""时间轴 IR 测试:Segment 插值 / Channel 重叠截断 / 采样保持语义"""
|
||||
|
||||
import pytest
|
||||
|
||||
from mtc.timeline import Channel, Segment, total_ms
|
||||
|
||||
|
||||
# ---------------- Segment ----------------
|
||||
|
||||
def test_segment_value_at_endpoints_and_midpoint():
|
||||
s = Segment(t0=50, dur=100, v0=10, v1=30)
|
||||
assert s.end == 150
|
||||
assert s.value_at(50) == 10 # 段起点取 v0
|
||||
assert s.value_at(100) == 20 # 中点线性
|
||||
assert s.value_at(150) == 30 # 段尾取 v1
|
||||
assert s.value_at(0) == 10 # 段前保持 v0
|
||||
assert s.value_at(999) == 30 # 段后保持 v1
|
||||
|
||||
|
||||
# ---------------- Channel ----------------
|
||||
|
||||
def test_channel_target_defaults_to_initial():
|
||||
ch = Channel("s1.angle")
|
||||
assert ch.target == 0.0 and ch.busy_until == 0.0
|
||||
ch.set_initial(45)
|
||||
assert ch.target == 45.0
|
||||
|
||||
|
||||
def test_channel_set_initial_after_segment_rejected():
|
||||
ch = Channel("s1.angle")
|
||||
ch.add(0, 100, 0, 10)
|
||||
with pytest.raises(ValueError):
|
||||
ch.set_initial(45)
|
||||
|
||||
|
||||
def test_channel_overlap_truncates_prev_and_warns():
|
||||
"""重叠:前段截断至新段 t0,基准取前段目标值(由调用方传 v0)"""
|
||||
ch = Channel("s1.angle")
|
||||
ch.add(0, 100, 0, 10) # [0,100) 0→10
|
||||
v0 = ch.target # 10(前段目标值)
|
||||
ch.add(50, 100, v0, v0 + 10) # [50,150) 10→20
|
||||
assert len(ch.segments) == 2
|
||||
assert ch.segments[0].dur == 50 # 前段截断至 t0=50
|
||||
assert ch.segments[0].end == 50
|
||||
assert ch.overlap_warns and "截断" in ch.overlap_warns[0]
|
||||
# 截断点连续:前段尾值 == 新段起点值
|
||||
assert ch.value_at(50) == 10
|
||||
assert ch.value_at(100) == 15
|
||||
|
||||
|
||||
def test_channel_value_at_no_segment_uses_initial():
|
||||
ch = Channel("s1.angle")
|
||||
ch.set_initial(30)
|
||||
assert ch.value_at(10) == 30
|
||||
assert ch.value_at(9999) == 30
|
||||
|
||||
|
||||
def test_channel_value_at_holds_last_target_after_all_segments():
|
||||
ch = Channel("s1.angle")
|
||||
ch.add(0, 100, 0, 10)
|
||||
ch.add(200, 100, 10, 5)
|
||||
assert ch.value_at(150) == 10 # 段间空隙保持前段目标值
|
||||
assert ch.value_at(100000) == 5 # 全部完结后保持最后目标值
|
||||
|
||||
|
||||
def test_channel_rest_value_falls_back_in_gaps():
|
||||
"""frequency 型通道(rest=0):段前/段间空隙/段后统一回落静止值"""
|
||||
ch = Channel("spk.frequency", rest=0.0)
|
||||
ch.add(20, 30, 1000, 1000) # [20,50) 阶跃段
|
||||
ch.add(70, 20, 2000, 2000) # [70,90)
|
||||
assert ch.value_at(10) == 0 # 首段之前
|
||||
assert ch.value_at(30) == 1000 # 段内
|
||||
assert ch.value_at(50) == 1000 # 段尾端点仍属段内
|
||||
assert ch.value_at(60) == 0 # 段间空隙回落 rest
|
||||
assert ch.value_at(80) == 2000
|
||||
assert ch.value_at(999) == 0 # 全部段后回落 rest
|
||||
|
||||
|
||||
def test_channel_rest_does_not_affect_in_segment_value():
|
||||
"""rest 仅作用段外:舵机通道设 rest 时段内插值不受影响"""
|
||||
ch = Channel("s1.angle", rest=0.0)
|
||||
ch.add(0, 100, 10, 20)
|
||||
assert ch.value_at(50) == 15
|
||||
|
||||
|
||||
def test_total_ms_takes_max_of_channels_and_cursor():
|
||||
a, b = Channel("a"), Channel("b")
|
||||
a.add(0, 100, 0, 1)
|
||||
b.add(50, 100, 0, 1)
|
||||
assert total_ms({"a": a, "b": b}, cursor=0) == 150
|
||||
assert total_ms({"a": a, "b": b}, cursor=500) == 500
|
||||
@@ -7,7 +7,8 @@
|
||||
dry_run 模式下仅记日志立即返回(不 sleep、不出声)。
|
||||
|
||||
时序约定:play() 阻塞总耗时对齐 delay_ms(与仓颉版 sleep(delay) 节拍一致),
|
||||
播放提前结束则补足剩余时间。
|
||||
播放提前结束则补足剩余时间。frequency <= 0 为静音节拍(不出声仅保节拍,
|
||||
对齐仓颉版 sleep 占位语义),正频率仍按 [min_f, max_f] 钳制。
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -44,7 +45,18 @@ class Speaker:
|
||||
log.warning("aplay not found, speaker degrades to silent sleep")
|
||||
|
||||
def play(self, frequency, delay_ms):
|
||||
"""播放 frequency Hz 正弦波 delay_ms 毫秒(阻塞)"""
|
||||
"""播放 frequency Hz 正弦波 delay_ms 毫秒(阻塞)
|
||||
|
||||
frequency <= 0 为静音节拍:不出声、仅保持 delay 时序(对齐
|
||||
仓颉版 sleep 占位语义)。此前 0Hz 会被钳制到 min_f 发出低鸣,
|
||||
破坏了 .mt 首行 freq=0(jump.mt/std.mt)的静默意图。
|
||||
"""
|
||||
if frequency <= 0: # 静音节拍
|
||||
if self.dry_run:
|
||||
log.info("speaker(dry-run): silence %dms", delay_ms)
|
||||
return
|
||||
time.sleep(delay_ms / 1000.0)
|
||||
return
|
||||
# 频率钳制(与仓颉版一致)
|
||||
if frequency > self.max_f:
|
||||
frequency = self.max_f
|
||||
|
||||
@@ -439,8 +439,9 @@ def test_actpred_compatibility(tmp_path, mock_i2c):
|
||||
from dataset import ActionDataset
|
||||
ds = ActionDataset(str(tmp_path), history_len=5,
|
||||
action_dim=18, frame_len=3)
|
||||
# 19 帧,start=max(5,2)=5,samples: t=5..18 → 14 个样本
|
||||
assert len(ds) == 14
|
||||
# 20 帧(10 行 × 2 微步,99c1bc1 起首行也分解为重复步),
|
||||
# start=max(5,2)=5,samples: t=5..19 → 15 个样本
|
||||
assert len(ds) == 15
|
||||
sample = ds[0]
|
||||
# ActionDataset 默认 resize 到 640×480,与相机原始尺寸无关
|
||||
assert sample["images"].shape == (3, 3, 480, 640) # (F, C, H, W)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Speaker 测试:静音语义 / 频率钳制 / SpeakerVar 参数透传
|
||||
|
||||
静音语义(2026-08-15 修复):frequency <= 0 不出声、仅保持 delay 节拍
|
||||
(对齐仓颉版 sleep 占位语义);此前 0Hz 被钳到 min_f 发出 20Hz 低鸣,
|
||||
破坏了 .mt 首行 freq=0(jump.mt/std.mt)的静默意图。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from pybot.hardvars import SpeakerVar
|
||||
from pybot.hd.speaker import Speaker
|
||||
|
||||
|
||||
class _FakeSpeaker:
|
||||
"""记录 play 调用的桩(SpeakerVar 透传测试用)"""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def play(self, freq, delay_ms):
|
||||
self.calls.append((freq, delay_ms))
|
||||
|
||||
|
||||
# ---------------- 静音语义 ----------------
|
||||
|
||||
def test_zero_frequency_is_silent_and_keeps_delay(monkeypatch):
|
||||
"""freq=0:不合成波形、不播放,仅 sleep 保持节拍"""
|
||||
spk = Speaker(dry_run=False)
|
||||
slept, synths = [], []
|
||||
monkeypatch.setattr("pybot.hd.speaker.time.sleep", lambda s: slept.append(s))
|
||||
monkeypatch.setattr(spk, "_synth", lambda *a: synths.append(a) or b"")
|
||||
spk.play(0, 100)
|
||||
assert slept == [0.1]
|
||||
assert synths == [] # 未走波形合成路径
|
||||
|
||||
|
||||
def test_negative_frequency_is_silent(monkeypatch):
|
||||
spk = Speaker(dry_run=False)
|
||||
slept = []
|
||||
monkeypatch.setattr("pybot.hd.speaker.time.sleep", lambda s: slept.append(s))
|
||||
spk.play(-5, 50)
|
||||
assert slept == [0.05]
|
||||
|
||||
|
||||
def test_zero_frequency_dry_run_logs_silence(caplog):
|
||||
"""dry_run:仅记日志立即返回(不 sleep),日志标识 silence"""
|
||||
spk = Speaker(dry_run=True)
|
||||
with caplog.at_level(logging.INFO, logger="pybot.speaker"):
|
||||
spk.play(0, 100)
|
||||
assert "silence 100ms" in caplog.text
|
||||
assert "Hz" not in caplog.text
|
||||
|
||||
|
||||
# ---------------- 正频率行为不破坏 ----------------
|
||||
|
||||
def test_positive_frequency_still_clamped(caplog):
|
||||
"""正频率钳制不变:5Hz → min_f(20),25000Hz → max_f"""
|
||||
spk = Speaker(dry_run=True, min_f=20.0, max_f=5000.0)
|
||||
with caplog.at_level(logging.INFO, logger="pybot.speaker"):
|
||||
spk.play(5, 10)
|
||||
spk.play(25000, 10)
|
||||
assert "20.0Hz 10ms" in caplog.text
|
||||
assert "5000.0Hz 10ms" in caplog.text
|
||||
|
||||
|
||||
# ---------------- SpeakerVar 透传 ----------------
|
||||
|
||||
def test_speakervar_passes_zero_frequency_through():
|
||||
""".mt 行 freq=0 经 SpeakerVar 原样透传到 Speaker.play(进入静音分支)"""
|
||||
fake = _FakeSpeaker()
|
||||
var = SpeakerVar("spk", fake)
|
||||
assert var.play({"frequency": "0", "delay": "100"}) == {"status": "OK"}
|
||||
assert fake.calls == [(0.0, 100)]
|
||||
|
||||
|
||||
def test_speakervar_requires_frequency_key():
|
||||
"""缺 frequency 键仍 KeyError(契约不变:delay 列必须与 frequency 成对)"""
|
||||
var = SpeakerVar("spk", _FakeSpeaker())
|
||||
with pytest.raises(KeyError):
|
||||
var.play({"delay": "100"})
|
||||
Reference in new issue
Block a user