diff --git a/.github/workflows/target-emulator-ci.yml b/.github/workflows/target-emulator-ci.yml new file mode 100644 index 0000000..dc04713 --- /dev/null +++ b/.github/workflows/target-emulator-ci.yml @@ -0,0 +1,58 @@ +name: Target Emulator CI + +on: + push: + branches: ["main"] + pull_request: + schedule: + - cron: "0 2 * * *" + workflow_dispatch: + +jobs: + mock-regression: + name: Mock Regression (Python 3.11) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install minimal dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pyyaml + + - name: Run target emulator regressions + run: | + python scripts/target_emulator_regression.py + python scripts/target_emulator_fault_regression.py + + nightly-virtual-serial: + name: Nightly Virtual Serial (Windows) + runs-on: windows-latest + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Run nightly virtual-serial regression + shell: powershell + env: + TARGET_HOST_PORT: ${{ vars.TARGET_HOST_PORT }} + TARGET_DEVICE_PORT: ${{ vars.TARGET_DEVICE_PORT }} + run: | + .\scripts\nightly_virtual_serial_regression.ps1 diff --git a/.github/workflows/yaml-dsl-regression.yml b/.github/workflows/yaml-dsl-regression.yml new file mode 100644 index 0000000..90f33df --- /dev/null +++ b/.github/workflows/yaml-dsl-regression.yml @@ -0,0 +1,93 @@ +name: YAML DSL Regression + +on: + pull_request: + push: + branches: ["main"] + schedule: + - cron: "20 2 * * *" + - cron: "40 3 * * 0" + workflow_dispatch: + inputs: + tier: + description: "Regression tier to run" + required: true + default: "full" + type: choice + options: + - fast + - full + - soak + +jobs: + pr-fast: + name: PR Fast Set + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' || github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.tier == 'fast') + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Run fast regression set + run: | + python scripts/v01_dsl_regression.py + python scripts/target_emulator_regression.py + python scripts/protocol_package_test_suite.py + python scripts/yaml_dsl_capability_suite.py + python scripts/yaml_dsl_combo_regression.py + python scripts/yaml_dsl_fault_injection_regression.py + + nightly-full: + name: Nightly Full Set + runs-on: ubuntu-latest + if: (github.event_name == 'schedule' && github.event.schedule == '20 2 * * *') || (github.event_name == 'workflow_dispatch' && github.event.inputs.tier == 'full') + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Run full runtime regression + run: | + python scripts/runtime_regression_suite.py + + weekly-soak: + name: Weekly Soak Set + runs-on: ubuntu-latest + if: (github.event_name == 'schedule' && github.event.schedule == '40 3 * * 0') || (github.event_name == 'workflow_dispatch' && github.event.inputs.tier == 'soak') + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Run soak + baseline + run: | + python scripts/yaml_dsl_stability_regression.py --iterations 80 --mem-delta-kb-max 12288 + python scripts/yaml_dsl_performance_baseline.py --basic-iters 30 --retry-iters 20 diff --git a/.gitignore b/.gitignore index 2bf21d7..4aeeeba 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,12 @@ Thumbs.db /.vscode/ /.idea/ /logs/ +/runs/ +/tmp/ +/docs/target-emulator-baseline/ /web-ui/node_modules/ /web-ui/dist/ build/ dist/ ProtoFlow.spec +AGENTS.md diff --git a/.gitmessage-bilingual.txt b/.gitmessage-bilingual.txt new file mode 100644 index 0000000..f39ba27 --- /dev/null +++ b/.gitmessage-bilingual.txt @@ -0,0 +1,22 @@ +# Conventional Commit Subject +# format: type(scope): summary + +[中文] +- 变更内容: + - <请填写本次核心改动> +- 影响范围: + - <请填写受影响模块/页面> +- 兼容性/行为变化: + - <请填写是否有行为变化> +- 依赖/环境: + - <请填写依赖、配置或环境要求> + +[English] +- Changes: + - +- Impact: + - +- Compatibility / Behavior Changes: + - +- Dependencies / Environment: + - diff --git a/.where-agent-progress.md b/.where-agent-progress.md new file mode 100644 index 0000000..daddc67 --- /dev/null +++ b/.where-agent-progress.md @@ -0,0 +1,26 @@ +# Plan: YAML-DSL + 通讯包全量测试计划(全面覆盖) +- [x] 阶段 0:建立测试覆盖矩阵(DSL 功能点 × 协议包 × 场景类型 × 通过准则) +- [x] 阶段 0:统一测试目录与命名规范(cases/scenarios/reports) +- [x] 阶段 0:定义统一结果模型(PASS/FAIL、错误码、重试命中、耗时、日志链接) +- [x] 阶段 1:DSL 基础能力用例(session/send/expect/sleep/capture/assert)全覆盖 +- [x] 阶段 1:DSL 控制流用例(if/loop/retry/on_fail/switch_session)全覆盖 +- [x] 阶段 1:DSL 数据能力用例(parse json|kv|csv/path/measure/assert_range)全覆盖 +- [x] 阶段 1:DSL 安全能力用例(exec/file allowlist、拒绝路径、越权拦截)全覆盖 +- [x] 阶段 1:DSL 产物能力用例(raw_log/summary_json/report_csv)全覆盖 +- [x] 阶段 2:协议包 API 合规测试框架(send/recv/rpc 统一契约、入参校验、错误语义) +- [x] 阶段 2:AT 包测试(正常/ERROR/超时/多行回显/capture 兼容) +- [x] 阶段 2:SCPI 包测试(query/write/错误帧/csv 解析/超时) +- [x] 阶段 2:YMODEM 包测试(握手/分包/重传/EOT/失败中断) +- [x] 阶段 2:XMODEM 包测试(握手/块序/重传/结束) +- [x] 阶段 2:Modbus RTU 包测试(读写寄存器、异常码、CRC 错误) +- [x] 阶段 2:Modbus ASCII 包测试(LRC、帧边界、异常码) +- [x] 阶段 2:Modbus TCP 包测试(MBAP、并发请求、异常码) +- [x] 阶段 2:协议 vectors.yaml 全量回归(每包正常+异常+边界) +- [x] 阶段 3:靶机故障注入矩阵(超时/抖动/分片/丢包/断连/乱序)与 DSL 联动验证 +- [x] 阶段 3:多场景组合测试(升级 + 参数写入 + 版本校验 + 报告导出) +- [x] 阶段 3:长稳测试(循环运行、资源泄漏、错误恢复、重复执行一致性) +- [x] 阶段 3:性能基线(平均耗时、P95、重试成本、日志吞吐) +- [x] 阶段 4:一键总入口(full regression suite)整合 DSL + 协议包 + 靶机测试 +- [x] 阶段 4:CI 分层执行(PR 快速集、Nightly 全量集、周全稳集) +- [x] 阶段 4:发布门禁(全量通过率 100%、关键路径零回归、基线报告归档) +- [x] 阶段 4:测试文档冻结(测试矩阵、故障注入指南、排障手册、已知限制) diff --git a/app/main_web.py b/app/main_web.py index e2e8ee2..81af138 100644 --- a/app/main_web.py +++ b/app/main_web.py @@ -11,6 +11,7 @@ import time from pathlib import Path from typing import Optional +import yaml try: from PySide6.QtWidgets import QApplication @@ -18,6 +19,7 @@ from PyQt6.QtWidgets import QApplication # type: ignore from infra.comm.communication_manager import CommunicationManager +from infra.comm.proxy_forward_manager import ProxyForwardManager from infra.common.event_bus import EventBus from app.packet_engine import PacketAnalysisEngine from app.plugin_manager import PluginManager @@ -162,6 +164,35 @@ def _select_webengine_flags() -> Optional[str]: return "--disable-features=DirectComposition" +def _parse_bool(value: object, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + text = str(value).strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return default + + +def _proxy_monitor_enabled() -> bool: + env = os.environ.get("PROTOFLOW_ENABLE_PROXY_MONITOR") + if env is not None: + return _parse_bool(env, default=False) + cfg_path = Path.cwd() / "config" / "app.yaml" + if cfg_path.exists(): + try: + raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} + app_cfg = raw.get("app", {}) if isinstance(raw, dict) else {} + if isinstance(app_cfg, dict) and "proxy_monitor_enabled" in app_cfg: + return _parse_bool(app_cfg.get("proxy_monitor_enabled"), default=False) + except Exception: + pass + return False + + def main() -> None: _ensure_repo_cwd() _setup_run_logging() @@ -170,13 +201,36 @@ def main() -> None: os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", flags) bus = EventBus() comm = CommunicationManager(bus) + proxy_enabled = _proxy_monitor_enabled() + proxy_manager = ProxyForwardManager(bus) if proxy_enabled else None + logging.getLogger("main_web").info("Proxy monitor enabled: %s", proxy_enabled) protocol = ProtocolLoader(bus) packet_engine = PacketAnalysisEngine(bus) plugins = PluginManager(bus, protocol=protocol) plugins.load_all() app = QApplication.instance() or QApplication(sys.argv) - window = WebWindow(bus=bus, comm=comm) + _shutdown_done = {"value": False} + + def _shutdown_proxy() -> None: + if _shutdown_done["value"]: + return + _shutdown_done["value"] = True + if proxy_manager is None: + return + try: + proxy_manager.stop_all() + except Exception: + logging.getLogger("main_web").exception("Failed to stop proxy sessions on shutdown") + + app.aboutToQuit.connect(_shutdown_proxy) + atexit.register(_shutdown_proxy) + window = WebWindow( + bus=bus, + comm=comm, + proxy_manager=proxy_manager, + proxy_monitor_enabled=proxy_enabled, + ) window.show() print("ProtoFlow Web UI started") app.exec() diff --git a/app/packet_engine.py b/app/packet_engine.py index ecf694c..937044b 100644 --- a/app/packet_engine.py +++ b/app/packet_engine.py @@ -29,7 +29,7 @@ class PacketAnalysisEngine: def __init__(self, bus: EventBus) -> None: self._bus = bus - self._queue: "queue.Queue[Tuple[str, bytes, float]]" = queue.Queue() + self._queue: "queue.Queue[Tuple[str, bytes, float, str]]" = queue.Queue() self._channel = _ChannelInfo() self._enabled = False self._target_channel: Optional[str] = None @@ -42,6 +42,7 @@ def __init__(self, bus: EventBus) -> None: self._bus.subscribe("comm.connected", self._on_connected) self._bus.subscribe("comm.disconnected", self._on_disconnected) self._bus.subscribe("capture.control", self._on_control) + self._bus.subscribe("proxy.data", self._on_proxy_data) def _on_rx(self, payload: Any) -> None: if not self._enabled: @@ -50,7 +51,7 @@ def _on_rx(self, payload: Any) -> None: if data: if self._target_channel and self._channel.channel and self._channel.channel != self._target_channel: return - self._queue.put(("RX", data, time.time())) + self._queue.put(("RX", data, time.time(), self._channel.channel or "")) def _on_tx(self, payload: Any) -> None: if not self._enabled: @@ -59,7 +60,37 @@ def _on_tx(self, payload: Any) -> None: if data: if self._target_channel and self._channel.channel and self._channel.channel != self._target_channel: return - self._queue.put(("TX", data, time.time())) + self._queue.put(("TX", data, time.time(), self._channel.channel or "")) + + def _on_proxy_data(self, payload: Any) -> None: + if not self._enabled: + return + if not isinstance(payload, dict): + return + data = self._to_bytes(payload.get("data")) + if not data: + return + src = str(payload.get("src") or "") + dst = str(payload.get("dst") or "") + if self._target_channel and self._target_channel not in {src, dst}: + return + src_role = str(payload.get("src_role") or "").lower() + if src_role == "host": + direction = "TX" + elif src_role == "device": + direction = "RX" + elif self._target_channel: + direction = "TX" if src == self._target_channel else "RX" + else: + direction = "TX" + + host_port = str(payload.get("host_port") or "") + if self._target_channel: + channel = self._target_channel + else: + channel = host_port or src + ts = float(payload.get("ts") or time.time()) + self._queue.put((direction, data, ts, channel)) def _on_connected(self, payload: Any) -> None: if isinstance(payload, dict): @@ -69,8 +100,8 @@ def _on_connected(self, payload: Any) -> None: self._channel.address = payload.get("address") if payload.get("type") == "serial" and self._channel.port: self._channel.channel = str(self._channel.port) - elif payload.get("type") == "tcp-client": - self._channel.channel = f"{self._channel.host}:{self._channel.address}" if self._channel.host else "" + elif payload.get("type") in {"tcp", "tcp-client"}: + self._channel.channel = str(self._channel.address or "") def _on_disconnected(self, payload: Any) -> None: self._channel = _ChannelInfo() @@ -90,20 +121,20 @@ def _on_control(self, payload: Any) -> None: def _run(self) -> None: while not self._stop.is_set(): try: - direction, data, ts = self._queue.get(timeout=0.2) + direction, data, ts, channel = self._queue.get(timeout=0.2) except queue.Empty: continue - frame = self._build_frame(direction, data, ts) + frame = self._build_frame(direction, data, ts, channel) self._bus.publish("capture.frame", frame) self._queue.task_done() - def _build_frame(self, direction: str, data: bytes, ts: float) -> Dict[str, Any]: + def _build_frame(self, direction: str, data: bytes, ts: float, channel_override: str = "") -> Dict[str, Any]: self._counter += 1 hex_bytes = [f"{b:02X}" for b in data] ascii_str = "".join(chr(b) if 32 <= b <= 126 else "." for b in data) ascii_lines = self._split_ascii(ascii_str, 8) protocol_name, protocol_unknown, summary, tree_rows, errors = self._parse_protocol(data) - channel = self._channel.channel or "" + channel = channel_override or self._channel.channel or "" frame_id = f"{direction.lower()}-{int(ts * 1000)}-{self._counter}" return { "id": frame_id, @@ -133,7 +164,13 @@ def _parse_protocol( self, data: bytes ) -> Tuple[str, bool, str, List[Dict[str, str]], List[Dict[str, str]]]: if len(data) < 2: - return "Unknown", True, "Too short", [], [] + return ( + "Unknown", + True, + "Too short", + [], + [{"code": "FRAME_TOO_SHORT", "message": "Frame length < 2 bytes"}], + ) addr = data[0] func = data[1] @@ -142,29 +179,62 @@ def _parse_protocol( {"label": "Address", "raw": f"{addr:02X}", "value": str(addr)}, {"label": "Function", "raw": f"{func:02X}", "value": f"0x{func:02X}"}, ] - - if len(data) >= 4: - crc_ok = self._check_modbus_crc(data) - tree.append( + errors: List[Dict[str, str]] = [] + + if len(data) < 5: + errors.append({"code": "FRAME_TOO_SHORT", "message": "Modbus RTU frame length < 5 bytes"}) + return "Modbus RTU", False, summary, tree, errors + + crc_calc = crc16_modbus(data[:-2]) + crc_expected = int.from_bytes(data[-2:], "little") + crc_ok = crc_calc == crc_expected + payload_len = len(data) - 4 + is_exception = bool(func & 0x80) + tree.append( + { + "label": "PayloadLength", + "raw": f"{payload_len:02X}", + "value": str(payload_len), + } + ) + tree.append( + { + "label": "CRC16", + "raw": " ".join(f"{b:02X}" for b in data[-2:]), + "value": "valid" if crc_ok else "invalid", + } + ) + tree.append( + { + "label": "CRC16(calc)", + "raw": f"{crc_calc:04X}", + "value": f"0x{crc_calc:04X}", + } + ) + if not crc_ok: + errors.append( { - "label": "CRC16", - "raw": " ".join(f"{b:02X}" for b in data[-2:]), - "value": "valid" if crc_ok else "invalid", + "code": "CRC_INVALID", + "message": f"expected=0x{crc_expected:04X} calc=0x{crc_calc:04X}", } ) - if crc_ok: - return "Modbus RTU", False, summary, tree, [] - - errors = [{"code": "UNKNOWN_PROTOCOL", "message": "No known signature"}] - return "Unknown", True, summary, tree, errors - - @staticmethod - def _check_modbus_crc(data: bytes) -> bool: - if len(data) < 3: - return False - body = data[:-2] - expected = int.from_bytes(data[-2:], "little") - return crc16_modbus(body) == expected + if is_exception and payload_len != 1: + errors.append( + { + "code": "LENGTH_INVALID", + "message": f"exception frame payload length should be 1, got {payload_len}", + } + ) + if not is_exception and payload_len <= 0: + errors.append( + { + "code": "LENGTH_INVALID", + "message": "normal frame payload length should be >= 1", + } + ) + if not errors: + return "Modbus RTU", False, summary, tree, [] + return "Modbus RTU", False, summary, tree, errors @staticmethod def _split_ascii(text: str, width: int) -> List[str]: diff --git a/config/app.yaml b/config/app.yaml index 288768e..0e2669f 100644 --- a/config/app.yaml +++ b/config/app.yaml @@ -1,4 +1,5 @@ -# app: -# name: ProtoFlow -# auto_update: false -# log_level: INFO +app: + name: ProtoFlow + auto_update: false + log_level: INFO + proxy_monitor_enabled: false diff --git a/docs/FRONTEND_BASELINE_AUDIT.md b/docs/FRONTEND_BASELINE_AUDIT.md new file mode 100644 index 0000000..c5c13e4 --- /dev/null +++ b/docs/FRONTEND_BASELINE_AUDIT.md @@ -0,0 +1,58 @@ +# Frontend Baseline Audit (2026-03-01) + +## Scope +- Frontend path: `ui/frontend/src` +- Focus: + - Runtime hardcoded values inventory + - Interaction failure inventory (buttons/dropdowns/modal) + - Desktop shell performance baseline template + +## Runtime Hardcoded Inventory +- Cleared: + - `COMx/baud/host/port` default values are centralized in `src/config/runtimeDefaults.ts`. + - Serial display normalization uses `src/utils/serialPort.ts`. + - App version display reads from bridge (`get_app_version`) with fallback. +- Pending follow-up: + - Non-runtime sample values in docs/examples remain intentionally hardcoded for user guidance. + +## Interaction Issue Inventory +- Fixed in this iteration: + - `ProxyCaptureToolbar`: + - Search input now emits `update:search-keyword`. + - "Continue capture" button now emits `resume-capture`. + - Settings button now emits `open-settings`. + - `ProxyCaptureFooter`: + - Pagination buttons now emit `page-first/page-prev/page-next/page-last`. + - Export button now emits `export`. + - Disabled state now reflects page boundary. + - `ProxyCaptureDetails`: + - Copy hex button now emits `copy-hex`. + - Action buttons now emit `open-rule`. + - Copy button has disabled/tooltip behavior when no active frame. +- Added regression tests: + - `src/components/proxy/ProxyCaptureToolbar.test.ts` + - `src/components/proxy/ProxyCaptureFooter.test.ts` + - `src/components/proxy/ProxyCaptureDetails.test.ts` + +## Desktop Shell Performance Baseline Template +- Environment: + - App build: release candidate build from `npm run build` + - Device: desktop shell host machine + - Duration: 3 minutes continuous operation per page +- Scenarios: + - Manual page: connect/disconnect, dropdown open/select, log burst append + - Proxy page: open capture modal, search/filter, page navigation, detail panel operations + - Settings page: tab switch, save/discard +- Metrics to record: + - Dropdown open latency P95 (target `< 100ms`) + - First visible feedback of primary actions (target `< 120ms`) + - Scroll frame drop ratio (target `< 5%`) + - Median FPS during continuous operation (target `>= 50`) + - Memory trend (no sustained abnormal growth) + +## Execution Rule +- Per PR: + - `npm run ci:frontend` +- Release candidate: + - Run matrix in `docs/FRONTEND_INTERACTION_TEST_MATRIX.md` + - Fill the baseline template above and attach result to release notes diff --git a/docs/FRONTEND_INTERACTION_TEST_MATRIX.md b/docs/FRONTEND_INTERACTION_TEST_MATRIX.md new file mode 100644 index 0000000..62b3b49 --- /dev/null +++ b/docs/FRONTEND_INTERACTION_TEST_MATRIX.md @@ -0,0 +1,43 @@ +# Frontend Interaction Test Matrix + +## Scope +- UI: `ManualView`, `ScriptsView`, `ProxyMonitorView`, `DropdownSelect`, `LogStream` +- Runtime path: WebBridge -> Vue state -> render +- Baseline: desktop shell first, browser as secondary + +## Core Scenarios +- Serial dropdown usability + - Ports available: can open, select, and display normalized `COMx` + - Ports unavailable: dropdown still opens with fallback options + - Connected/connecting: disabled state and reason are visible +- Connection flow consistency + - Connect button transitions `disconnected -> connecting -> connected` + - Disconnect returns to `disconnected` and refreshes channel list +- Proxy capture panel + - Open/close capture modal from proxy card + - Frame filter (`all/rx/tx/error`) reflects in table list + - Search keyword filters frame rows by id/protocol/summary/raw hex + - Capture footer pagination buttons (`first/prev/next/last`) are actionable + - Capture toolbar actions (`resume/settings`) are actionable + - Select frame updates right details panel + - Details panel action buttons (`copy hex`, `open rule`) are actionable +- Logging behavior + - Comm/script logs append without UI freeze during burst input + - Auto-scroll keeps tail pinned when enabled + - Manual scroll does not force-jump if auto-scroll disabled +- Modal and dropdown behavior + - `Esc` closes open dropdown/modals + - Clicking outside closes dropdown/modal as expected + - Disabled controls provide visible reason text or title + +## Regression Checklist +- No runtime hardcoded version/build date in visible UI +- Port normalization applied before connect/create/update flows +- Long list rendering uses windowed strategy in `Proxy capture` and `LogStream` +- `npm run ci:frontend` must pass before merge + +## Execution Cadence +- Per PR: run `npm run ci:frontend` +- Contract gate: `npm run acceptance:interaction` +- Button audit: contract gate includes "no clickable-looking button without action/disabled" check for `src/components` (excluding `ui-kit`) +- Release candidate: run manual matrix on desktop shell diff --git a/docs/FRONTEND_PERFORMANCE_GUARDRAIL.md b/docs/FRONTEND_PERFORMANCE_GUARDRAIL.md new file mode 100644 index 0000000..ea80474 --- /dev/null +++ b/docs/FRONTEND_PERFORMANCE_GUARDRAIL.md @@ -0,0 +1,28 @@ +# Frontend Performance Guardrail + +## Budget Source +- File: `ui/frontend/config/perf_budget.json` +- Script: `ui/frontend/scripts/perf_report.mjs` + +## Commands +- Build + budget report: +```bash +npm run build +npm run perf:report +``` +- Full frontend gate: +```bash +npm run ci:frontend +``` + +## Current Budget Keys +- `js_bundle_max_kb` +- `css_bundle_max_kb` +- `js_gzip_max_kb` +- `css_gzip_max_kb` + +## Notes +- Budget report checks built assets under `dist/assets` +- If budget fails, process exits with non-zero code +- Bundle budget complements runtime interaction checks, not a replacement +- 2026-03-01 update: App views and CodeMirror runtime are async-split/lazy-loaded, and bundle warning is removed in default build output. diff --git a/docs/FUNCTION_ACCEPTANCE_CHECKLIST.md b/docs/FUNCTION_ACCEPTANCE_CHECKLIST.md new file mode 100644 index 0000000..bd43fd4 --- /dev/null +++ b/docs/FUNCTION_ACCEPTANCE_CHECKLIST.md @@ -0,0 +1,80 @@ +# 功能验收清单(P0) + +> 目标:用于“发售前功能闭环”逐项打勾。 +> 约束:当前阶段不包含 TCP 开发项。 + +## 1. 代理监控 + +- [x] 代理对增删改查可用 +- [x] 端口选择来自真实串口枚举(无端口时有回退) +- [x] 端口参数校验有效(端口非空、两端口不可相同) +- [x] 代理开关真实驱动后端会话(非 UI 假状态) +- [x] 删除代理会先停止会话,避免资源残留 +- [x] 状态可见:`running / stopped / error` +- [x] 异常可见:卡片显示错误信息 +- [x] 异常恢复:卡片支持“一键重试” + +## 2. 抓包联动 + +- [x] 转发数据发布 `proxy.data` +- [x] 抓包引擎可消费 `proxy.data` 并产出 `capture.frame` +- [x] 抓包方向语义稳定(基于 host/device 角色) +- [x] 通道语义稳定(未指定目标时归一到 host_port) +- [x] 抓包停止后不再追加帧 +- [x] 协议异常帧可识别(短帧/CRC 错误/长度异常) +- [x] 异常帧在抓包列表高亮提示 + +## 3. 状态持久化与恢复 + +- [x] 代理配置持久化 +- [x] `desiredActive` 持久化用户期望状态 +- [x] 冷启动按 `desiredActive` 尝试恢复会话 +- [x] 恢复结果回写 `running/stopped/error` +- [x] 应用退出统一 `stop_all`,避免残留会话 + +## 3.1 DSL 运行状态 + +- [x] 启动后可进入运行态并推进状态机 +- [x] 停止操作可回传停止态 +- [x] 运行异常可回传错误态 +- [x] 中文环境脚本加载不因编码失败 + +## 4. 构建与运行一致性 + +- [x] 前端构建通过(`npm run build`) +- [x] 后端关键模块语法检查通过(`py_compile`) +- [x] 打包版本信息读取链路可用(`VERSION` 资源) + +## 5. 回归测试能力 + +- [x] 有真实/虚拟串口回归脚本:`scripts/proxy_regression.py` +- [x] 有无驱动 mock 回归脚本:`scripts/proxy_regression_mock.py` +- [x] 有环境检查脚本:`scripts/setup_proxy_test_env.ps1` +- [x] 有一键入口:`scripts/run_proxy_regression.ps1` +- [ ] 真实设备执行(双串口回环) +- [ ] 真实设备执行(异常拔插) +- [ ] 真实设备执行(长稳 soak) + +## 5.1 配置持久化损坏回退 + +- [x] 配置文件写入采用原子替换,避免半写入文件 +- [x] 主配置 JSON 损坏时可自动回退到 `.bak` +- [x] 回退后主配置文件会自动修复为可解析 JSON +- [x] 回归脚本 `scripts/config_persistence_regression.py` 通过 + +## 5.2 打包运行一致性 + +- [x] PyInstaller spec 包含 `ui/frontend/dist`、`config`、`plugins`、`ui/assets`、`VERSION` +- [x] `scripts/build_windows.ps1` 包含与 spec 一致的数据打包项 +- [x] `installer/ProtoFlow.iss` 使用 `MyAppVersion` 宏生成安装包版本号 +- [x] 运行日志默认写入 `%LOCALAPPDATA%/ProtoFlow/logs` +- [x] 回归脚本 `scripts/package_runtime_regression.py` 通过 + +## 6. 验收结论模板 + +- 版本:`<填写 VERSION>` +- 验收日期:`` +- 执行人:`<姓名>` +- 结论: + - [ ] 通过 + - [ ] 有阻塞项(列出) diff --git a/docs/MIGRATION_TO_EXTERNAL_PROTOCOL_PACKAGES.zh-CN.md b/docs/MIGRATION_TO_EXTERNAL_PROTOCOL_PACKAGES.zh-CN.md new file mode 100644 index 0000000..f9b717e --- /dev/null +++ b/docs/MIGRATION_TO_EXTERNAL_PROTOCOL_PACKAGES.zh-CN.md @@ -0,0 +1,44 @@ +# 迁移说明:从内置协议到全外置协议包 + +## 背景 + +从 v0.3 起,协议能力统一收敛为“外置协议包”,不再保留内置协议注册入口。 + +## 变化点 + +1. 协议列表来源 +- 旧:内置 `ProtocolRegistry` + 自定义协议配置。 +- 新:扫描 `protocols/` 目录中的外置协议包。 + +2. 运行时调用路径 +- 旧:内置 action(如 `dsl_protocol_actions` / `dsl_protocol_schema_actions`)。 +- 新:`ProtocolPackageGateway` 统一分发 `send/recv/rpc`。 + +3. DSL 调用方式 +- 旧:各类专用 action 分散调用。 +- 新:统一 `protocol.send / protocol.recv / protocol.rpc`。 + +## 迁移步骤 + +1. 将原协议实现迁移到外置包目录: +- `protocol.yaml` +- `impl.py` +- `README.md` +- `vectors.yaml` + +2. 在脚本中改写协议步骤为: +- `name: protocol.send` +- `name: protocol.recv` +- `name: protocol.rpc` + +3. 运行回归: + +```powershell +$env:PYTHONPATH='.' +d:\GitRepository\ProtoFlow\.venv\Scripts\python.exe scripts/protocol_package_test_suite.py +``` + +## 兼容性说明 + +- 已移除旧内置协议 action 注册路径。 +- 需要继续运行旧脚本时,请先完成 DSL 步骤迁移。 diff --git a/docs/MODBUS_PACKAGE_GUIDE.zh-CN.md b/docs/MODBUS_PACKAGE_GUIDE.zh-CN.md new file mode 100644 index 0000000..f461fc0 --- /dev/null +++ b/docs/MODBUS_PACKAGE_GUIDE.zh-CN.md @@ -0,0 +1,60 @@ +# Modbus 三外置包使用指南 + +## 1. 包选择建议 + +- `modbus_rtu`:串口 RTU 现场设备,低成本主从总线。 +- `modbus_ascii`:历史设备或明确要求 ASCII 帧格式的场景。 +- `modbus_tcp`:以太网设备、网关转发、上位机集中采集。 + +## 2. 统一请求模型 + +三包统一使用: + +- `op`: `read_holding|read_input|write_single_coil|write_single_register|write_multiple_coils|write_multiple_registers` +- `unit_id` +- `address` +- `quantity` / `value` / `values` + +示例: + +```yaml +- name: protocol.rpc + protocol: modbus_rtu + request: + op: read_holding + unit_id: 1 + address: 0 + quantity: 2 +``` + +## 3. 错误语义 + +常见错误码: + +- `MODBUS_TIMEOUT` +- `MODBUS_CRC_INVALID` / `MODBUS_LRC_INVALID` +- `MODBUS_MBAP_INVALID` +- `MODBUS_EXCEPTION_RESPONSE` +- `MODBUS_VALUE_INVALID` + +## 4. 示例脚本 + +- `scripts/examples/modbus_rtu_flow_v01.yaml` +- `scripts/examples/modbus_ascii_flow_v01.yaml` +- `scripts/examples/modbus_tcp_flow_v01.yaml` + +## 5. 向量回归 + +```powershell +$env:PYTHONPATH='.' +d:\GitRepository\ProtoFlow\.venv\Scripts\python.exe scripts/protocol_vectors_regression.py --root protocols --protocol modbus_rtu +d:\GitRepository\ProtoFlow\.venv\Scripts\python.exe scripts/protocol_vectors_regression.py --root protocols --protocol modbus_ascii +d:\GitRepository\ProtoFlow\.venv\Scripts\python.exe scripts/protocol_vectors_regression.py --root protocols --protocol modbus_tcp +``` + +## 6. 从旧脚本迁移 + +1. 把旧的 Modbus 专用 action 改为 `protocol.send/recv/rpc`。 +2. 请求参数改为统一 `op/unit_id/address/...` 模型。 +3. 补 `expect` 条件并加入 vectors 用例。 +4. 跑回归脚本确认行为一致。 diff --git a/docs/MODBUS_THREE_PACKAGE_SPEC_V01.zh-CN.md b/docs/MODBUS_THREE_PACKAGE_SPEC_V01.zh-CN.md new file mode 100644 index 0000000..39252fb --- /dev/null +++ b/docs/MODBUS_THREE_PACKAGE_SPEC_V01.zh-CN.md @@ -0,0 +1,90 @@ +# Modbus 三外置包统一规范(v0.1) + +## 1. 目标 + +将 Modbus 能力拆分为三个外置协议包,并保持统一的 `send/recv/rpc` API 与数据模型: + +- `modbus_rtu_pkg` +- `modbus_ascii_pkg` +- `modbus_tcp_pkg` + +## 2. 统一请求模型(request) + +最小字段: + +- `op`: 操作类型,支持 + - `read_holding` (FC03) + - `read_input` (FC04) + - `write_single_coil` (FC05) + - `write_single_register` (FC06) + - `write_multiple_coils` (FC15) + - `write_multiple_registers` (FC16) +- `unit_id`: 1..247(RTU/ASCII 必填,TCP 可选,默认 1) +- `address`: 起始地址,0..65535 +- `quantity`: 读取数量(读操作必填) +- `value`: 单点写值(FC05/FC06) +- `values`: 多点写值数组(FC15/FC16) +- `transaction_id`: TCP 可选,默认自增/或 0 +- `timeout_ms`: 可选,覆盖调用超时 + +## 3. 统一响应模型(response) + +成功响应: + +- `ok: true` +- `function`: 功能码 +- `unit_id` +- `data`: + - 读:`registers` 或 `coils` + - 写:`address/quantity/value` +- `raw`: `{ tx_hex, rx_hex }` + +失败响应(异常响应或校验失败): + +- `ok: false` +- `error`: + - `code`: 统一错误码 + - `message` + - `exception_code`(若为 Modbus 异常响应) +- `raw`(可选) + +## 4. 统一错误码 + +- `MODBUS_TIMEOUT` +- `MODBUS_FRAME_INVALID` +- `MODBUS_CRC_INVALID` +- `MODBUS_LRC_INVALID` +- `MODBUS_MBAP_INVALID` +- `MODBUS_FUNCTION_UNSUPPORTED` +- `MODBUS_EXCEPTION_RESPONSE` +- `MODBUS_VALUE_INVALID` + +## 5. 三包边界 + +### 5.1 modbus_rtu_pkg + +- 负责 RTU ADU:`[unit][pdu][crc_lo][crc_hi]` +- 校验 CRC16 +- 串口半双工场景 + +### 5.2 modbus_ascii_pkg + +- 负责 ASCII ADU:`':' + HEX + LRC + CRLF` +- 校验 LRC +- 支持 ASCII 十六进制文本编解码 + +### 5.3 modbus_tcp_pkg + +- 负责 TCP ADU:MBAP(7 bytes) + PDU +- 处理 `transaction_id/protocol_id/length/unit_id` +- 不做 CRC/LRC + +## 6. 公共层复用范围 + +三个包复用同一套 PDU 编解码(功能码语义): + +- 请求 PDU 编码 +- 正常响应 PDU 解码 +- 异常响应解析(`function|0x80` + `exception_code`) + +传输层差异(RTU/ASCII/TCP)只在 ADU 封装与校验层处理。 diff --git a/docs/P0_REGRESSION_CASES.md b/docs/P0_REGRESSION_CASES.md new file mode 100644 index 0000000..276bb3d --- /dev/null +++ b/docs/P0_REGRESSION_CASES.md @@ -0,0 +1,89 @@ +# P0 鍥炲綊鐢ㄤ緥锛堝彲澶嶇幇姝ラ + 棰勬湡锛? +## Case 1: 浠g悊鍒涘缓涓庡弬鏁版牎楠? +- 鍓嶇疆锛氳繘鍏モ€滀唬鐞嗙洃鎺р€濋〉闈?- 姝ラ锛? 1. 鏂板缓浠g悊锛屼富鏈虹鍙g暀绌? 2. 鐐瑰嚮淇濆瓨 + 3. 涓绘満绔彛涓庤澶囩鍙h缃负鍚屼竴涓鍙? 4. 鐐瑰嚮淇濆瓨 +- 棰勬湡锛? - 绗?1 姝ヤ繚瀛樺け璐ュ苟鎻愮ず鈥滆閫夋嫨涓绘満绔彛鍜岃澶囩鍙b€? - 绗?3 姝ヤ繚瀛樺け璐ュ苟鎻愮ず鈥滀富鏈虹鍙e拰璁惧绔彛涓嶈兘鐩稿悓鈥? +## Case 2: 浠g悊鍚姩/鍋滄闂幆 + +- 鍓嶇疆锛氬瓨鍦ㄤ竴涓悎娉曚唬鐞嗗 +- 姝ラ锛? 1. 鐐瑰嚮浠g悊寮€鍏冲惎鍔? 2. 瑙傚療鍗$墖鐘舵€? 3. 鐐瑰嚮浠g悊寮€鍏冲仠姝?- 棰勬湡锛? - 鍚姩鍚庣姸鎬佸彉涓?`running` + - 鍋滄鍚庣姸鎬佸彉涓?`stopped` + - 閲嶅惎搴旂敤鍚庣姸鎬佹寜 `desiredActive` 鎭㈠涓€鑷? +## Case 3: 寮傚父鍙鍖栦笌閲嶈瘯 + +- 鍓嶇疆锛氫汉涓哄埗閫犵鍙e崰鐢ㄥ啿绐?- 姝ラ锛? 1. 鍚姩鍐茬獊浠g悊 + 2. 瑙傚療鍗$墖鐘舵€佷笌閿欒鎻愮ず + 3. 閲婃斁鍐茬獊鍚庣偣鍑烩€滈噸璇曗€?- 棰勬湡锛? - 鐘舵€佷负 `error` + - 鍗$墖鏄剧ず閿欒鏂囨湰 + - 閲嶈瘯鍚庡彲鎭㈠ `running` + +## Case 4: 鎶撳寘鏂瑰悜涓庨€氶亾璇箟 + +- 鍓嶇疆锛氫唬鐞嗗浜?`running` +- 姝ラ锛? 1. 鎵撳紑鎶撳寘 + 2. 浠?host 渚у彂閫佹暟鎹? 3. 浠?device 渚у彂閫佹暟鎹?- 棰勬湡锛? - host->device 鏄犲皠涓?`TX` + - device->host 鏄犲皠涓?`RX` + - `channel` 璇箟绋冲畾锛屼笉闅忔潵婧愭姈鍔? +## Case 5: Mock 鍥炲綊锛堟棤椹卞姩锛? +- 鍓嶇疆锛歚.venv` 鍙敤 +- 姝ラ锛? 1. 杩愯 `.\scripts\run_proxy_regression.ps1 -Mode mock` + 2. 杩愯 `.\scripts\run_proxy_regression.ps1 -Mode mock -Iterations 50 -SoakSec 60 -InjectDisconnect` +- 棰勬湡锛? - 杈撳嚭 `RESULT: PASSED` + - 鍖呭惈 `start_pair / host_to_device / device_to_host / proxy.data_event / status.stopped` 鍧囦负 PASS + - 澧炲己鍥炲綊鍖呭惈 `soak.forwarding` 涓?`fault.disconnect_error_event` 涓斿潎涓?PASS + +## Case 6: 铏氭嫙涓插彛/鐪熷疄璁惧鍥炲綊 + +- 鍓嶇疆锛氱鍙e凡鍑嗗濂斤紙com0com 鎴栫湡瀹炵‖浠讹級 +- 姝ラ锛? 1. 杩愯锛? `.\scripts\run_proxy_regression.ps1 -Mode real -HostPort COM11 -DevicePort COM13 -TestHostPort COM12 -TestDevicePort COM14` + 2. 鏌ョ湅缁堢涓?JSON 鎶ュ憡 +- 棰勬湡锛? - `RESULT: PASSED` + - 瀛愰」 `status.running / host_to_device.forward / device_to_host.forward` 鍧?PASS + - `soak.forwarding` 鍦ㄥ惎鐢ㄦ椂 PASS + +## Case 7: 閫€鍑烘竻鐞嗕竴鑷存€? +- 鍓嶇疆锛氳嚦灏戜竴涓唬鐞嗗浜?`running` +- 姝ラ锛? 1. 鍏抽棴搴旂敤 + 2. 閲嶅惎搴旂敤骞惰瀵熶唬鐞嗙姸鎬?- 棰勬湡锛? - 涓婃浼氳瘽涓嶄細娈嬬暀涓哄兊灏稿崰鐢? - 鐘舵€佹仮澶嶉€昏緫涓€鑷达紝涓嶅嚭鐜扳€滄樉绀鸿繍琛屼絾瀹為檯涓嶅彲鐢ㄢ€? +## Case 8: comm 浜嬩欢搴忓垪涓€鑷存€э紙Mock锛? +- 鍓嶇疆锛歚.venv` 鍙敤 +- 姝ラ锛? 1. 杩愯 `.\.venv\Scripts\python.exe scripts\comm_manager_regression_mock.py` +- 棰勬湡锛? - 杈撳嚭 `RESULT: PASSED` + - 瀛愰」 `serial_connected / serial_disconnected / tcp_connected / tcp_disconnected` 鍏ㄩ儴 PASS + +## Case 9: 鍗忚瑙f瀽寮傚父鎻愮ず锛圡ock锛? +- 鍓嶇疆锛歚.venv` 鍙敤 +- 姝ラ锛? 1. 杩愯 `.\.venv\Scripts\python.exe scripts\packet_engine_regression.py` +- 棰勬湡锛? - 杈撳嚭 `RESULT: PASSED` + - 瑕嗙洊浠ヤ笅鍒ゅ畾锛? - 姝e父 Modbus 甯э細鏃犻敊璇? - CRC 閿欒甯э細鍖呭惈 `CRC_INVALID` + - 鐭抚锛氬寘鍚?`FRAME_TOO_SHORT` + - 寮傚父甯ч暱搴︿笉绗︼細鍖呭惈 `LENGTH_INVALID` + +## Case 10: DSL 杩愯鐢熷懡鍛ㄦ湡鍥炲綊锛圡ock锛? +- 鍓嶇疆锛歚.venv` 鍙敤 +- 姝ラ锛? 1. 杩愯 `.\.venv\Scripts\python.exe scripts\script_runner_regression.py` +- 棰勬湡锛? - 杈撳嚭 `RESULT: PASSED` + - 姝e父鎵ц璺緞鍖呭惈 `__running__` 涓?`__finished__` + - 鍋滄璺緞鍖呭惈 `__stopped__` + - 寮傚父璺緞鍖呭惈 `__error__` + +## Case 11: 配置损坏回退回归(Mock) +- 前置:`.venv` 可用 +- 姝ラ锛? 1. 杩愯 `.\.venv\Scripts\python.exe scripts\config_persistence_regression.py` +- 棰勬湡锛? - 杈撳嚭 `RESULT: PASSED` + - `settings_backup_fallback` 涓?PASS + - `proxy_backup_fallback` 涓?PASS + - `protocol_backup_fallback` 涓?PASS + - `restored_primary_json_valid` 涓?PASS + +## Case 12: 打包运行一致性回归(Mock) +- 前置:`.venv` 可用 +- 步骤: + 1. 运行 `.\.venv\Scripts\python.exe scripts\package_runtime_regression.py` +- 预期: + - 输出 `RESULT: PASSED` + - `spec.datas_required` 为 PASS + - `build_script.datas_required` 为 PASS + - `installer.version_macro` 为 PASS + - `runtime.log_path_created` 为 PASS + - `runtime.log_contains_probe` 为 PASS diff --git a/docs/PROTOCOL_PACKAGES_V04_GUIDE.zh-CN.md b/docs/PROTOCOL_PACKAGES_V04_GUIDE.zh-CN.md new file mode 100644 index 0000000..b5b41a4 --- /dev/null +++ b/docs/PROTOCOL_PACKAGES_V04_GUIDE.zh-CN.md @@ -0,0 +1,76 @@ +# 协议包扩展指南(v0.4) + +## 1. 本阶段范围 + +v0.4 聚焦三类高价值协议包: + +- `at_command`:模块调试与配置 +- `ymodem`:固件传输与升级流程 +- `scpi`:仪器控制与数据采集 + +## 2. 选型建议 + +### 2.1 at_command + +适用: +- Wi-Fi/4G/BT/GNSS 模块调试 +- 现场参数配置与日志开关 + +能力: +- 多行回显解析 +- `OK/ERROR` 终态判定 +- `contains/regex/status` 断言 + +### 2.2 ymodem + +适用: +- Boot/升级窗口内文件下发 +- 需要重传与握手可靠性的串口升级 + +能力: +- `C` 握手 +- 头包/数据包/EOT 收尾 +- `NAK` 重传 +- 超时与取消处理 + +### 2.3 scpi + +适用: +- 电源/示波器/信号源/万用表自动化 +- 产测数据采集 + +能力: +- write/query +- 多行响应处理 +- CSV 标量解析(value/unit/raw) + +## 3. 示例脚本 + +- `scripts/examples/at_command_flow_v01.yaml` +- `scripts/examples/ymodem_flow_v01.yaml` +- `scripts/examples/scpi_flow_v01.yaml` + +## 4. 回归与验证 + +统一回归入口: + +```powershell +$env:PYTHONPATH='.' +d:\GitRepository\ProtoFlow\.venv\Scripts\python.exe scripts/protocol_package_test_suite.py +``` + +单包向量回归: + +```powershell +$env:PYTHONPATH='.' +d:\GitRepository\ProtoFlow\.venv\Scripts\python.exe scripts/protocol_vectors_regression.py --root protocols --protocol at_command +d:\GitRepository\ProtoFlow\.venv\Scripts\python.exe scripts/protocol_vectors_regression.py --root protocols --protocol ymodem +d:\GitRepository\ProtoFlow\.venv\Scripts\python.exe scripts/protocol_vectors_regression.py --root protocols --protocol scpi +``` + +## 5. 从旧流程迁移 + +1. 把旧 action 改为 `protocol.send/recv/rpc`。 +2. 迁移到外置协议包请求模型。 +3. 为关键流程补 vectors 用例。 +4. 用 `protocol_package_test_suite.py` 做回归门禁。 diff --git a/docs/PROTOCOL_PACKAGE_DEVELOPER_GUIDE.zh-CN.md b/docs/PROTOCOL_PACKAGE_DEVELOPER_GUIDE.zh-CN.md new file mode 100644 index 0000000..ec2770d --- /dev/null +++ b/docs/PROTOCOL_PACKAGE_DEVELOPER_GUIDE.zh-CN.md @@ -0,0 +1,114 @@ +# 外置协议包开发指南(v0.3) + +## 1. 目录结构 + +每个协议包目录必须包含以下文件: + +```text +/ + protocol.yaml + impl.py + README.md + vectors.yaml +``` + +## 2. protocol.yaml 最小示例 + +```yaml +id: "modbus_rtu" +name: "Modbus RTU Package" +version: "1.0.0" + +entry: + module: "impl" + class: "ProtocolPackage" + +api: + - send + - recv + - rpc +``` + +约束: +- `id` 全局唯一。 +- `entry.module` 相对协议包目录,支持点路径(如 `pkg.impl`)。 +- `api` 必须声明 `send/recv/rpc`。 + +## 3. impl.py 接口 + +入口类由 `entry.class` 指定,必须实现: + +- `send(ctx, request) -> dict` +- `recv(ctx, expect) -> dict` +- `rpc(ctx, request) -> dict` + +说明: +- `ctx.channel.write(...)` 发送数据。 +- `ctx.channel.read(size, timeout=...)` 接收数据。 +- `ctx.timeout_ms` 为调用超时预算。 +- 返回值建议为字典,便于 DSL 保存到变量。 + +## 4. YAML-DSL 调用方式 + +```yaml +- name: protocol.send + protocol: modbus_rtu + request: + unit: 1 + function: 3 + payload_hex: "00000002" + timeout_ms: 2000 + save_as: modbus_send_result +``` + +支持: +- `protocol.send` +- `protocol.recv` +- `protocol.rpc` + +## 5. vectors.yaml 自测 + +`vectors.yaml` 用于协议包自测和 CI 回归: + +```yaml +version: "1" +protocol_id: "modbus_rtu" +cases: + - id: "send_ok" + kind: "send" + input: + request: { unit: 1, function: 3, payload_hex: "00000002" } + expect: + ok: true +``` + +可使用: +- `mock_rx_text` +- `mock_rx_hex` + +注入接收数据,验证 `recv/rpc` 路径。 + +## 6. 回归命令 + +在仓库根目录执行: + +```powershell +$env:PYTHONPATH='.' +d:\GitRepository\ProtoFlow\.venv\Scripts\python.exe scripts/protocol_package_test_suite.py +``` + +## 7. 错误与审计 + +网关统一错误码: +- `PROTOCOL_NOT_FOUND` +- `PROTOCOL_METHOD_INVALID` +- `PROTOCOL_METHOD_UNDECLARED` +- `PROTOCOL_METHOD_UNSUPPORTED` +- `PROTOCOL_TIMEOUT` +- `PROTOCOL_VALIDATION_FAILED` +- `PROTOCOL_PERMISSION_DENIED` +- `PROTOCOL_CALL_FAILED` + +网关统一审计日志事件: +- `call_start` +- `call_end` diff --git a/docs/PROTOCOL_PACKAGE_SPEC_V01.md b/docs/PROTOCOL_PACKAGE_SPEC_V01.md new file mode 100644 index 0000000..7002de6 --- /dev/null +++ b/docs/PROTOCOL_PACKAGE_SPEC_V01.md @@ -0,0 +1,131 @@ +# 外置协议包规范 v0.1 + +日期:2026-03-06 +状态:Draft-Ready + +## 1. 目标 +- 软件协议能力统一走外置协议包,不再提供内置协议实现路径。 +- 协议包对 YAML-DSL 暴露统一接口:`send` / `recv` / `rpc`。 +- 协议包必须可被加载、校验、自测(`vectors.yaml`)。 + +## 2. 协议包目录契约 +每个协议包目录必须包含: + +1. `protocol.yaml` +2. `impl.py` +3. `README.md` +4. `vectors.yaml` + +推荐目录示例: +```text +protocols/ + modbus_rtu_pkg/ + protocol.yaml + impl.py + README.md + vectors.yaml +``` + +## 3. protocol.yaml schema +最小字段: +```yaml +id: "modbus_rtu" +name: "Modbus RTU Package" +version: "1.0.0" + +entry: + module: "impl" + class: "ProtocolPackage" + +api: + - send + - recv + - rpc + +config_schema: {} +message_schema: {} +``` + +字段约束: +- `id`: 全局唯一,建议 `snake_case` +- `version`: 语义化版本字符串 +- `entry.module`: Python 模块名(相对协议包目录) +- `entry.class`: 实现类名 +- `api`: 当前固定包含 `send/recv/rpc` +- `config_schema/message_schema`: 可选,保留扩展 + +## 4. 统一 Python API 契约 +协议包实现类必须提供: + +1. `send(ctx, request) -> dict` +2. `recv(ctx, expect) -> dict` +3. `rpc(ctx, request) -> dict` + +返回建议: +```python +{ + "ok": True, + "tx_hex": "...", + "rx_hex": "...", + "data": {...}, +} +``` + +异常建议: +- 抛出受控异常或返回 `ok=False`,由网关统一映射错误码。 + +## 5. ctx 能力边界 +`ctx` 提供统一、受控能力: +- `ctx.write(bytes_or_str)` +- `ctx.read(size, timeout_ms)` / `ctx.read_until(...)` +- `ctx.logger` +- `ctx.timeout_ms` +- `ctx.vars_get/vars_set` +- `ctx.artifacts` + +不提供任意系统能力(例如直接 shell 调用),保持安全边界。 + +## 6. vectors.yaml 规范(强烈建议) +每个协议包必须携带 `vectors.yaml`(用于自测与 CI): +```yaml +version: "1" +protocol_id: "modbus_rtu" + +cases: + - id: "send_basic" + kind: "send" + input: + request: { ... } + expect: + ok: true + + - id: "recv_basic" + kind: "recv" + input: + expect: { ... } + expect: + ok: true + + - id: "rpc_basic" + kind: "rpc" + input: + request: { ... } + expect: + ok: true +``` + +## 7. YAML-DSL 调用形态(预留) +v0.3 计划新增: +- `protocol.send` +- `protocol.recv` +- `protocol.rpc` + +统一参数: +- `protocol_id` +- `request/expect` +- `timeout_ms` +- `save_as` + +## 8. 迁移原则 +- 禁止新增内置协议实现入口。 +- 现有内置协议能力将迁移为外置协议包示例并通过包加载器接入。 diff --git a/docs/PROTOCOL_PACKAGE_TEMPLATE.md b/docs/PROTOCOL_PACKAGE_TEMPLATE.md new file mode 100644 index 0000000..e52d698 --- /dev/null +++ b/docs/PROTOCOL_PACKAGE_TEMPLATE.md @@ -0,0 +1,65 @@ +# Protocol Package Template + +## Tree + +```text +my_protocol_pkg/ + protocol.yaml + impl.py + README.md + vectors.yaml +``` + +## protocol.yaml + +```yaml +id: "my_protocol" +name: "My Protocol Package" +version: "1.0.0" + +entry: + module: "impl" + class: "ProtocolPackage" + +api: + - send + - recv + - rpc + +config_schema: {} +message_schema: {} +``` + +## impl.py + +```python +from __future__ import annotations + + +class ProtocolPackage: + def send(self, ctx, request): + # build and send bytes + return {"ok": True} + + def recv(self, ctx, expect): + # read and decode bytes + return {"ok": True} + + def rpc(self, ctx, request): + self.send(ctx, request) + return self.recv(ctx, request.get("expect", {})) +``` + +## vectors.yaml + +```yaml +version: "1" +protocol_id: "my_protocol" +cases: + - id: "send_basic" + kind: "send" + input: + request: {} + expect: + ok: true +``` diff --git a/docs/PROXY_REGRESSION.md b/docs/PROXY_REGRESSION.md new file mode 100644 index 0000000..1c9bbf1 --- /dev/null +++ b/docs/PROXY_REGRESSION.md @@ -0,0 +1,109 @@ +# 代理转发回归(6.1) + +本回归用于验证代理监控在真实设备或虚拟串口环境中的稳定性,覆盖: +- 双向转发正确性(host->device / device->host) +- 事件一致性(`proxy.data`、`proxy.status`) +- 长时运行稳定性(可选 soak) + +## 1. 环境准备 + +建议两种方式任选其一: + +1. 虚拟串口对(推荐先做) + - 例如使用 com0com 创建两对端口: + - PairA: `COM11 <-> COM12` + - PairB: `COM13 <-> COM14` + - 应用代理占用 `COM11`、`COM13` + - 回归脚本占用 `COM12`、`COM14` +2. 真实硬件 + - 两个 USB-Serial 设备交叉连接 TX/RX、共地 + - 确保没有其它工具占用相关端口 + +## 2. 运行命令 + +在仓库根目录执行: + +```powershell +python scripts/proxy_regression.py ` + --host-port COM11 ` + --device-port COM13 ` + --test-host-port COM12 ` + --test-device-port COM14 ` + --baud 115200 ` + --iterations 30 ` + --payload-size 64 ` + --timeout-sec 2 ` + --soak-sec 180 ` + --json-out .\\proxy_regression_report.json +``` + +如果当前终端没有 `python` 命令,请改为你的 Python 可执行路径。 + +### 2.1 环境检查(一键) + +可先运行: + +```powershell +.\scripts\setup_proxy_test_env.ps1 +``` + +该脚本会检查: +- `.venv` Python 是否存在 +- `pyserial` 是否可用 +- `com0com` 驱动注册表是否可见 +- 当前可见串口列表 + +### 2.2 无驱动模拟回归(可先执行) + +如果本机暂时无法安装虚拟串口驱动,可先运行纯 Python 模拟回归: + +```powershell +.\.venv\Scripts\python.exe scripts\proxy_regression_mock.py +``` + +该脚本不依赖 `com0com`,会在进程内模拟 2 对串口并验证: +- 代理双向转发 +- `proxy.data` 事件产出 +- `proxy.status` 停止状态事件 + +可选增强: + +```powershell +.\scripts\run_proxy_regression.ps1 -Mode mock -Iterations 50 -SoakSec 60 -InjectDisconnect +``` + +上述命令会额外覆盖: +- 长时转发稳定性(`soak.forwarding`) +- 异常断连事件(`fault.disconnect_error_event`,模拟拔插) + +## 3. 结果判定 + +脚本会输出每个子用例 `PASS/FAIL`,并附带延迟统计: +- `status.running` +- `host_to_device.forward` +- `device_to_host.forward` +- `soak.forwarding`(仅在 `--soak-sec > 0` 时) + +最终状态: +- `RESULT: PASSED` 表示本轮通过 +- `RESULT: FAILED` 表示存在回归风险,需要排查端口映射、占用、参数一致性 + +同时会输出 JSON 报告(如果传了 `--json-out`)。 + +## 4. 常见失败与排查 + +1. `start_pair` 失败 + - 端口被占用或端口名错误 + - 波特率/校验位/停止位配置不匹配 +2. 单向转发失败 + - 虚拟串口配对关系写反 + - 实物接线方向错误(TX/RX 未交叉) +3. `missing proxy.data event` + - 转发线程已报错(查看 UI 错误态和运行日志) + - 端口有数据但未形成完整读写回路 + +## 5. 回归建议基线 + +- 开发回归:`--iterations 20 --soak-sec 0` +- 提交前回归:`--iterations 50 --soak-sec 300` +- 发版前回归:`--iterations 100 --soak-sec 1800` diff --git a/docs/RELEASE_FREEZE_EXTERNAL_PROTOCOLS_V03_20260306.md b/docs/RELEASE_FREEZE_EXTERNAL_PROTOCOLS_V03_20260306.md new file mode 100644 index 0000000..b63a9c3 --- /dev/null +++ b/docs/RELEASE_FREEZE_EXTERNAL_PROTOCOLS_V03_20260306.md @@ -0,0 +1,53 @@ +# 发布冻结清单:全外置协议包(v0.3) + +日期:2026-03-06 +范围:`4dae11f` -> `554691a` + +## 1. 冻结目标 + +- 协议能力统一走外置协议包。 +- YAML-DSL 已支持 `protocol.send/protocol.recv/protocol.rpc`。 +- 旧内置协议入口已下线。 + +## 2. 功能冻结核对 + +- [x] 协议包目录契约:`protocol.yaml/impl.py/README.md/vectors.yaml` +- [x] 协议包扫描与校验 +- [x] 协议包动态加载与注册 +- [x] 协议调用网关(统一 dispatch + 错误码 + 审计) +- [x] DSL 协议 step 接入 +- [x] 旧内置协议调用路径移除 +- [x] vectors 执行器与回归入口 +- [x] 外置示例包(modbus_rtu/xmodem) +- [x] 前端协议列表与调用桥接改为外置包 +- [x] 自动化测试套件(加载/调用/异常/向量回归) +- [x] 开发/模板/迁移文档 + +## 3. 回归结果 + +执行环境:`d:\GitRepository\ProtoFlow\.venv\Scripts\python.exe`,`PYTHONPATH=.` + +1. `scripts/protocol_package_test_suite.py` +- 结果:PASS + +2. `scripts/protocol_vectors_regression.py --root protocols --protocol modbus_rtu` +- 结果:PASS(3/3) + +3. `scripts/protocol_vectors_regression.py --root protocols --protocol xmodem` +- 结果:PASS(3/3) + +## 4. 关键提交 + +- `13053ef` loader + gateway +- `f28da5b` DSL 协议 step +- `ff3b32e` 下线旧协议 action 注册 +- `05c81f5` vectors 执行器 +- `a71636b` modbus/xmodem 外置示例包 +- `370353c` 前端桥接外置化 +- `1f34b4c` 网关错误码与审计 +- `9950abc` 自动化测试套件 +- `554691a` 文档完善 + +## 5. 结论 + +本次范围内功能与回归通过,达到“全外置协议包”发布冻结条件。 diff --git a/docs/RELEASE_FREEZE_MODBUS_THREE_PACKAGES_20260306.md b/docs/RELEASE_FREEZE_MODBUS_THREE_PACKAGES_20260306.md new file mode 100644 index 0000000..df1e1fc --- /dev/null +++ b/docs/RELEASE_FREEZE_MODBUS_THREE_PACKAGES_20260306.md @@ -0,0 +1,41 @@ +# 发布冻结清单:Modbus 三外置包 + +日期:2026-03-06 +范围:`ca763a5` -> 当前 `HEAD` + +## 1. 冻结目标 + +- 完成 `modbus_rtu` / `modbus_ascii` / `modbus_tcp` 三外置包全功能实现。 +- 统一请求模型与错误语义。 +- 完成三包向量矩阵回归与 DSL 示例。 + +## 2. 功能核对 + +- [x] 三包目录契约齐全(protocol.yaml/impl.py/README.md/vectors.yaml) +- [x] RTU:CRC16 + FC03/04/05/06/15/16 + 异常响应 +- [x] ASCII:LRC + FC03/04/05/06/15/16 + 异常响应 +- [x] TCP:MBAP + FC03/04/05/06/15/16 + 异常响应 +- [x] 公共 PDU 编解码复用 +- [x] 向量矩阵:正常/异常码/超时/校验失败/边界值 +- [x] 前端分类展示:modbus-rtu/modbus-ascii/modbus-tcp +- [x] 示例脚本:RTU/ASCII/TCP + +## 3. 回归结果 + +执行环境:`d:\GitRepository\ProtoFlow\.venv\Scripts\python.exe`,`PYTHONPATH=.` + +1. `scripts/protocol_package_test_suite.py` +- 结果:PASS + +2. `scripts/protocol_vectors_regression.py --root protocols --protocol modbus_rtu` +- 结果:PASS(8/8) + +3. `scripts/protocol_vectors_regression.py --root protocols --protocol modbus_ascii` +- 结果:PASS(8/8) + +4. `scripts/protocol_vectors_regression.py --root protocols --protocol modbus_tcp` +- 结果:PASS(8/8) + +## 4. 结论 + +Modbus 三外置包达到发布冻结条件,可进入后续联调或版本发布阶段。 diff --git a/docs/RELEASE_FREEZE_PROTOCOL_EXPANSION_V04_20260306.md b/docs/RELEASE_FREEZE_PROTOCOL_EXPANSION_V04_20260306.md new file mode 100644 index 0000000..cc65a2b --- /dev/null +++ b/docs/RELEASE_FREEZE_PROTOCOL_EXPANSION_V04_20260306.md @@ -0,0 +1,38 @@ +# 发布冻结清单:协议包扩展 v0.4(AT/YMODEM/SCPI) + +日期:2026-03-06 +范围:`9dbfb22` -> 当前 `HEAD` + +## 1. 冻结目标 + +- 完成 `at_command` / `ymodem` / `scpi` 三外置协议包。 +- 完成三包 vectors、示例脚本与统一回归接入。 + +## 2. 功能核对 + +- [x] `at_command_pkg`:多行回显、OK/ERROR、超时、expect 断言 +- [x] `ymodem_pkg`:握手、分包、重传、EOT 收尾 +- [x] `scpi_pkg`:write/query、多行响应、CSV 解析 +- [x] 三包均具备 `protocol.yaml/impl.py/README.md/vectors.yaml` +- [x] 三包 YAML-DSL 示例脚本已提供 +- [x] 统一回归套件已接入三包 + +## 3. 回归结果 + +执行环境:`d:\GitRepository\ProtoFlow\.venv\Scripts\python.exe`,`PYTHONPATH=.` + +1. `scripts/protocol_vectors_regression.py --root protocols --protocol at_command` +- 结果:PASS(8/8) + +2. `scripts/protocol_vectors_regression.py --root protocols --protocol ymodem` +- 结果:PASS(8/8) + +3. `scripts/protocol_vectors_regression.py --root protocols --protocol scpi` +- 结果:PASS(8/8) + +4. `scripts/protocol_package_test_suite.py` +- 结果:PASS + +## 4. 结论 + +协议包扩展 v0.4(AT/YMODEM/SCPI)达到发布冻结条件。 diff --git a/docs/TARGET_EMULATOR_CI_STRATEGY.md b/docs/TARGET_EMULATOR_CI_STRATEGY.md new file mode 100644 index 0000000..b08aa61 --- /dev/null +++ b/docs/TARGET_EMULATOR_CI_STRATEGY.md @@ -0,0 +1,30 @@ +# Target Emulator CI Strategy + +## Layered Plan +1. Mock fast regression (per PR/push) +- Runner: `ubuntu-latest` +- Goal: fast deterministic sanity checks without serial driver dependency +- Cases: + - `scripts/target_emulator_regression.py` + - `scripts/target_emulator_fault_regression.py` + +2. Virtual-serial nightly regression (schedule/dispatch) +- Runner: `windows-latest` +- Goal: verify real serial stack path with com2com pair +- Entry: + - `scripts/nightly_virtual_serial_regression.ps1` +- Required repo variables: + - `TARGET_HOST_PORT` (example: `COM11`) + - `TARGET_DEVICE_PORT` (example: `COM12`) + +## Workflow +- File: `.github/workflows/target-emulator-ci.yml` +- Trigger: + - `push` on `main` + - `pull_request` + - `schedule` daily + - `workflow_dispatch` + +## Design Notes +- Nightly job is skipped when virtual serial variables are not configured. +- Mock job remains mandatory to keep quick feedback loop. diff --git a/docs/TARGET_EMULATOR_GUIDE.md b/docs/TARGET_EMULATOR_GUIDE.md new file mode 100644 index 0000000..444716e --- /dev/null +++ b/docs/TARGET_EMULATOR_GUIDE.md @@ -0,0 +1,70 @@ +# Target Emulator Guide + +## Goal +- Provide standalone target-side test code independent from ProtoFlow runtime. +- Validate DSL behaviors via serial/tcp interaction with deterministic responses and fault injection. + +## Directory +- `tools/target_emulator/` +- `tools/target_emulator/scenarios/` +- `scripts/target_emulator.py` +- `scripts/target_emulator_regression.py` +- `scripts/target_emulator_fault_regression.py` +- `scripts/runtime_regression_suite.py` +- `scripts/run_target_with_dsl.ps1` +- `scripts/examples/target_at_smoke_v02.yaml` + +## Modes +- `serial`: pair with com2com or real UART. +- `tcp`: target emulator listens as TCP server. +- `mock`: internal regression mode (used by `target_emulator_regression.py`). + +## Scenario Schema (v1) +```yaml +version: "1" +meta: + name: "AT Basic" + description: "..." +transport_defaults: + encoding: "utf-8" + eol: "crlf" # none|cr|lf|crlf +rules: + - id: at_ping + when: + type: exact # contains|exact|startswith|regex + pattern: "AT" + respond: + text: "OK" # or hex: "4F4B" + eol: "crlf" # optional, defaults to transport_defaults.eol + delay_ms: 10 + drop: false + close_after: false + once: false +fallback: + respond: + text: "ERROR" +``` + +## Quick Start (Serial + com2com) +1. Create virtual pair, e.g. `COM11 <-> COM12`. +2. Run: +```powershell +.\scripts\run_target_with_dsl.ps1 -HostPort COM11 -TargetPort COM12 +``` +3. Check output: +- DSL artifacts in `runs/target_at_smoke_*` +- Emulator artifacts in `runs/target_emu_*` + +## Regression +```powershell +.\.venv\Scripts\python.exe scripts\target_emulator_regression.py +.\.venv\Scripts\python.exe scripts\target_emulator_fault_regression.py +.\.venv\Scripts\python.exe scripts\runtime_regression_suite.py +``` + +## Notes +- Emulator is transport-level only; no dependency on UI/web bridge. +- Fault cases can be modeled by `drop`, `delay_ms`, `jitter_ms`, `once`, `close_after`, `chunk_bytes`, `chunk_interval_ms`. +- Baseline pass gate is defined in `docs/TARGET_EMULATOR_PASS_CRITERIA.md`. +- CI layering strategy is defined in `docs/TARGET_EMULATOR_CI_STRATEGY.md`. +- Frozen v0.1 baseline samples are archived under `docs/target-emulator-baseline/v0.1/`. diff --git a/docs/TARGET_EMULATOR_PASS_CRITERIA.md b/docs/TARGET_EMULATOR_PASS_CRITERIA.md new file mode 100644 index 0000000..6ad2699 --- /dev/null +++ b/docs/TARGET_EMULATOR_PASS_CRITERIA.md @@ -0,0 +1,29 @@ +# Target Emulator Pass Criteria (v0.1 Baseline) + +## Scope +- `scripts/target_emulator_regression.py` +- `scripts/target_emulator_fault_regression.py` +- `scripts/runtime_regression_suite.py` + +## Required Conditions +1. Functional pass +- All checks in `target_emulator_regression.py` must pass. +- Required: AT baseline response, fallback behavior, artifact generation. + +2. Fault injection pass +- All checks in `target_emulator_fault_regression.py` must pass. +- Required: drop-once, jitter response, chunked response, close-after behavior. + +3. Runtime integration pass +- `runtime_regression_suite.py` exits with code `0`. +- Includes existing DSL/runtime regressions and target emulator regressions. + +4. Artifact integrity +- Emulator artifacts must contain: + - `raw_log.jsonl` + - `summary.json` +- `summary.rules_hit` must record at least one hit for a baseline AT rule. + +## Stability Targets +- Local repeated run (>=3 times) should keep pass rate at 100%. +- No unexpected exception stack traces in successful runs. diff --git a/docs/YAML_DSL_CORE_TARGET_SCENARIOS.md b/docs/YAML_DSL_CORE_TARGET_SCENARIOS.md new file mode 100644 index 0000000..88372ad --- /dev/null +++ b/docs/YAML_DSL_CORE_TARGET_SCENARIOS.md @@ -0,0 +1,40 @@ +# YAML-DSL 核心功能目标工况(已确认) + +日期:2026-03-06 +状态:已确认,作为核心功能范围基线 + +## 1. 调试交互类 +- CLI/AT 命令调试:模块(Wi-Fi/4G/BT/GNSS)AT 指令,发命令 -> 看回显 -> 改参数。 +- 功能开关/模式切换:进入工厂模式、打开日志、切换调试级别、开启某功能。 +- 现场问题复现脚本:一键执行“复现路径”,把关键回显打包给他人。 + +## 2. 升级与引导类 +- 进入 Bootloader / 选择启动模式:命令触发、握手口令、超时窗口。 +- 固件升级/补丁下发:X/YMODEM、自定义分包、擦除/写入/校验。 +- 版本一致性校验:升级后自动读取版本/CRC/分区信息并确认成功。 + +## 3. 参数配置与产线写入 +- SN/设备ID 写入(并读回校验)。 +- 校准数据写入:ADC 偏移、传感器标定、温漂补偿。 +- 密钥/证书注入:设备认证用途(要求权限控制与审计)。 +- 区域/法规配置:例如射频 Region、功率限制。 + +## 4. 产测/老化/自检(ATE) +- 一键自检流程:执行指令序列并采集传感器/电机/继电器状态。 +- 阈值断言:温度/电压/电流/转速等范围判定 PASS/FAIL。 +- 老化测试:循环执行、定时采样,异常即停机并打点日志。 +- 测试报告导出:CSV/JSON/HTML。 + +## 5. 日志与故障诊断 +- 抓取运行日志/崩溃信息:保存到文件并自动标记关键行。 +- 错误码解释/聚合:`err=xxx` 映射原因(可维护映射表)。 +- 现场采集包:版本 + 配置 + 最近日志 + 设备信息,一键导出。 + +## 6. 自动化桥接场景(串口只是入口) +- 串口 + 外部命令:刷机后自动运行 PC 端脚本并生成产物。 +- 串口 + 网络接口:串口完成网络参数配置后,切换 TCP/HTTP 继续测试。 +- 多设备/多串口:单工装覆盖多台设备(可先队列串行,后续并行)。 + +## 说明 +- 本文档用于固定 YAML-DSL 核心功能目标工况,不包含实现细节与优先级拆分。 +- 具体语法、动作、验收矩阵在后续设计文档中落地。 diff --git a/docs/YAML_DSL_DESIGN_BASELINE.md b/docs/YAML_DSL_DESIGN_BASELINE.md new file mode 100644 index 0000000..bda584f --- /dev/null +++ b/docs/YAML_DSL_DESIGN_BASELINE.md @@ -0,0 +1,95 @@ +# YAML DSL 设计基线(MVP / v1 / v2) + +日期:2026-03-06 +状态:已固化(设计输入) + +## 必须有(MVP:覆盖 70%+ COM 用例) + +### 1) 连接与会话(Session) +- `transport`: `serial` + `port` / `baud` / `data_bits` / `parity` / `stop_bits` +- `eol`(CR/LF/CRLF)与编码(ASCII/HEX) +- `open` / `close`,以及动态切换波特率/端口(升级/boot 常见) + +### 2) 基本动作 Step(Action primitives) +- `send`:发送字符串/HEX(支持模板变量) +- `expect`:等待回显(关键字/正则),带 `timeout` +- `sleep`:延时(boot 窗口常用) + +说明:`send + expect + sleep` 配合控制逻辑,可覆盖升级、参数写入、诊断主流程。 + +### 3) 匹配与抓取(Match & Capture) +- `match`:`contains` / `regex` / `startswith` +- `capture`:从回显用 regex 提取字段写入变量(版本号、SN、错误码、温度、电压等) + +### 4) 可靠性(Reliability) +- `timeout_ms` +- `retry: { count, backoff_ms }`(指数退避可选) +- `on_fail`:失败后的步骤(reset / 重试 / 回收日志) +- `assert`:断言成功条件(文本匹配或变量条件) + +### 5) 变量与参数化(Variables) +- `params`:运行前输入(端口、固件路径、SN、阈值) +- `vars`:运行中变量(来自 capture) +- 模板:固定为一种风格(`{{var}}` 或 `${var}`) +- 基本表达式:比较 / 字符串拼接 / 数值转换(以够用为目标) + +### 6) 日志与输出产物(Artifacts) +- 自动记录:每步开始/结束、发送、接收、耗时 +- 可导出:`raw_log`, `summary.json`, `report.csv`(至少支持一种) + +## 强烈建议(v1:覆盖产测/回归/多分支) + +### 7) 控制流(Controlled flow,避免脚本化) +- `if / else`(基于变量或匹配结果) +- `switch`(基于错误码/状态) +- `loop`(次数循环或 until) +- `goto`(可选,谨慎) + +建议优先结构化写法:`when + retry/until`,降低流程失控风险。 + +### 8) 结构化解析(不止 regex) +- `parse: json|kv|csv` 输出字典 +- 支持路径取值(如 `path: a.b.c`) + +### 9) 数据点与阈值断言(产测导向) +- `measure`:记录指标(电压/温度/转速) +- `assert_range`:`min/max`、`abs_err`、`in_set` + +### 10) Step 复用与模块化 +- `include/import`:通用流程库(如 `enter_boot`、`flash`、`read_version`) +- `step_templates`:参数化步骤模板 +- `profile`:串口配置复用(不同板型) + +### 11) 外部命令与文件(受控) +- `exec`:调用外部命令(烧录器、解包、校验) +- `file`:读写文件(日志/CSV) + +约束:限制 `exec` 输入输出边界(仅 stdout/stderr 等),避免 DSL 演化为任意脚本平台。 + +## 可选高级(v2:产线/多设备/复杂协议) + +### 12) 多设备/并发模型 +- `devices`:多个串口会话 +- `run: parallel|serial`,并发上限与资源锁 + +### 13) 专用传输协议 Step +- `xmodem_send` / `ymodem_send` +- `chunk_send`(自定义分包 + ack/seq/crc) +- `progress`(进度事件) + +### 14) 事件驱动(流式触发) +- `watch`:持续监听输出 +- `on_event`:匹配后触发动作(例如 PANIC 立即抓日志并停止) + +### 15) 安全与审计(密钥/证书场景) +- 参数加密 / 日志脱敏 +- 权限策略(限制可执行 step) +- 审计记录(谁在何时写入了什么) + +## 推荐核心抽象(设计约束) +为同时支持升级 / 写入 / 产测 / 诊断,DSL 固定四层: + +1. `Profile / Session`:怎么连(串口参数、换行、默认超时) +2. `Steps`:做什么(`send/expect/parse/assert/transfer/exec`) +3. `Flow control`:何时做(`if/loop/retry/on_fail`) +4. `Artifacts`:产出什么(日志/报告/指标) diff --git a/docs/YAML_DSL_PROTOCOL_TEST_CONVENTIONS.md b/docs/YAML_DSL_PROTOCOL_TEST_CONVENTIONS.md new file mode 100644 index 0000000..3417b2f --- /dev/null +++ b/docs/YAML_DSL_PROTOCOL_TEST_CONVENTIONS.md @@ -0,0 +1,45 @@ +# YAML-DSL Protocol Test Conventions + +## Directory Convention +- Regression scripts: `scripts/*_regression.py` +- DSL examples: `scripts/examples/*.yaml` +- Target emulator scenarios: `tools/target_emulator/scenarios/*.yaml` +- Reports (runtime generated): `runs/` (git ignored) +- Frozen baseline snapshots (optional docs): `docs/target-emulator-baseline/` (git ignored) + +## Naming Convention +- Script name: `__regression.py` + - example: `yaml_dsl_target_full_regression.py` +- Scenario name: `_.yaml` or `_.yaml` + - example: `retry_expect_window.yaml` +- Report file: + - summary: `*_summary.json` + - full report: `*_report.json` + +## Case ID Convention +- Format: `..` + - example: `dsl.retry.expect_window` + +## Result Contract +- Every regression should emit: + - console checks (`[PASS]/[FAIL]`) + - final line: `RESULT: PASSED|FAILED` + - structured report JSON compliant with `scripts/test_result_schema.json` + +## Exit Code +- `0`: all checks passed +- `2`: any check failed or unexpected error + +## CI Mapping +- PR fast set: + - `scripts/v01_dsl_regression.py` + - `scripts/target_emulator_regression.py` + - `scripts/protocol_package_test_suite.py` + - `scripts/yaml_dsl_capability_suite.py` + - `scripts/yaml_dsl_combo_regression.py` + - `scripts/yaml_dsl_fault_injection_regression.py` +- Nightly set: + - `scripts/runtime_regression_suite.py` +- Weekly soak set: + - `scripts/yaml_dsl_stability_regression.py` + - `scripts/yaml_dsl_performance_baseline.py` diff --git a/docs/YAML_DSL_PROTOCOL_TEST_MATRIX.md b/docs/YAML_DSL_PROTOCOL_TEST_MATRIX.md new file mode 100644 index 0000000..c659fc5 --- /dev/null +++ b/docs/YAML_DSL_PROTOCOL_TEST_MATRIX.md @@ -0,0 +1,61 @@ +# YAML-DSL + Protocol Package Test Matrix + +## Scope +- DSL runtime: `v0.2` (兼容 `v0.1`) +- Protocol packages: + - `at_command` + - `scpi` + - `ymodem` + - `xmodem` + - `modbus_rtu` + - `modbus_ascii` + - `modbus_tcp` +- Target emulator modes: + - `mock` + - `serial` (real/com2com) + - `tcp` + +## Axes +- Capability axis: 功能点 +- Transport axis: mock/serial/tcp +- Package axis: protocol package +- Fault axis: timeout/jitter/drop/chunk/disconnect +- Artifact axis: raw_log/summary/report/result model + +## DSL Capability Coverage +| Capability | Required Cases | Current Entry | +|---|---|---| +| `session/send/expect/sleep/capture/assert` | normal + timeout + regex + capture fail | `scripts/v01_dsl_regression.py` | +| `retry/on_fail` | fixed + exponential + hook side effect | `scripts/v01_dsl_regression.py` / `scripts/yaml_dsl_target_full_regression.py` | +| `if/loop/switch_session(dry_run)` | branch true/false + until + times | `scripts/v01_dsl_regression.py` / `scripts/yaml_dsl_target_full_regression.py` | +| `parse(json|kv|csv)/path/measure/assert_range` | normal + path fail + range fail | `scripts/v01_dsl_regression.py` / `scripts/yaml_dsl_target_full_regression.py` | +| `exec/file security` | allow + deny + path guard | `scripts/v01_dsl_regression.py` / `scripts/yaml_dsl_target_full_regression.py` | +| `protocol.send/recv/rpc` | contract + error mapping + timeout | `scripts/protocol_package_test_suite.py` / `scripts/yaml_dsl_target_full_regression.py` | +| Artifacts | raw/summary/csv + result schema mapping | `scripts/*regression.py` | + +## Protocol Package Coverage +| Package | send | recv | rpc | normal | timeout | error-path | vectors | +|---|---:|---:|---:|---:|---:|---:|---:| +| at_command | Y | Y | Y | Y | Y | Y | Y | +| scpi | Y | Y | Y | Y | Y | Y | Y | +| ymodem | Y | Y | Y | Y | Y | Y | Y | +| xmodem | Y | Y | Y | Y | Y | Y | Y | +| modbus_rtu | Y | Y | Y | Y | Y | Y | Y | +| modbus_ascii | Y | Y | Y | Y | Y | Y | Y | +| modbus_tcp | Y | Y | Y | Y | Y | Y | Y | + +## Fault Injection Coverage +| Fault Type | Target Scenario | Expected Validation | +|---|---|---| +| timeout | `retry_expect_window.yaml` | retry hit + eventual pass/fail correctness | +| drop | `fault_injection_matrix.yaml` | missing response handling | +| jitter | `fault_injection_matrix.yaml` | timeout window tolerance | +| chunk | `fault_injection_matrix.yaml` | parser/expect robustness | +| close/disconnect | `fault_injection_matrix.yaml` | graceful error + report | + +## Entry Points +- Fast (local/PR): `scripts/runtime_regression_suite.py` +- Full DSL + target: `scripts/yaml_dsl_target_full_regression.py` +- Package vectors: `scripts/protocol_package_test_suite.py` +- Emulator fault: `scripts/target_emulator_fault_regression.py` +- Nightly serial: `scripts/nightly_virtual_serial_regression.ps1` diff --git a/docs/YAML_DSL_RELEASE_GATE.md b/docs/YAML_DSL_RELEASE_GATE.md new file mode 100644 index 0000000..c7e3ef1 --- /dev/null +++ b/docs/YAML_DSL_RELEASE_GATE.md @@ -0,0 +1,32 @@ +# YAML-DSL 发布门禁(v0.2) + +日期:2026-03-06 + +## Gate 1:全量通过率 +- 要求:`runtime_regression_suite` 通过率 `100%`。 +- 命令: +```powershell +.\.venv\Scripts\python.exe scripts/runtime_regression_suite.py +``` + +## Gate 2:关键路径零回归 +- 关键路径: + - DSL 核心能力:`yaml_dsl_capability_suite` + - 组合工况:`yaml_dsl_combo_regression` + - 故障联动:`yaml_dsl_fault_injection_regression` + - 协议包契约:`protocol_package_test_suite` +- 要求:以上入口全部 `RESULT: PASSED`。 + +## Gate 3:基线报告归档 +- 要求:保留最近一次稳定与性能基线报告(运行产物)。 +- 基线文件: + - `runs/yaml_dsl_stability_regression_report.json` + - `runs/yaml_dsl_performance_baseline_report.json` + - `runs/yaml_dsl_performance_baseline_report.md` + +## Gate 4:CI 层级通过 +- 要求: + - `PR Fast Set`(当前变更)通过 + - 最近一次 `Nightly Full Set` 通过 + - 最近一次 `Weekly Soak Set` 通过 +- 参考:`docs/YAML_DSL_TEST_CI_TIERS.md` diff --git a/docs/YAML_DSL_TEST_CI_TIERS.md b/docs/YAML_DSL_TEST_CI_TIERS.md new file mode 100644 index 0000000..b30fd6d --- /dev/null +++ b/docs/YAML_DSL_TEST_CI_TIERS.md @@ -0,0 +1,38 @@ +# YAML-DSL CI 分层策略 + +日期:2026-03-06 + +## 分层定义 +1. `PR Fast Set` +- 触发:`pull_request`、`push(main)`、手动 `tier=fast` +- 目标:快速发现 DSL/协议包/靶机主链路回归 +- 入口: + - `scripts/v01_dsl_regression.py` + - `scripts/target_emulator_regression.py` + - `scripts/protocol_package_test_suite.py` + - `scripts/yaml_dsl_capability_suite.py` + - `scripts/yaml_dsl_combo_regression.py` + - `scripts/yaml_dsl_fault_injection_regression.py` + +2. `Nightly Full Set` +- 触发:每日 `02:20 UTC`、手动 `tier=full` +- 目标:执行全量总回归入口,覆盖 DSL + 协议包 + 靶机 + 长稳/性能基础版 +- 入口: + - `scripts/runtime_regression_suite.py` + +3. `Weekly Soak Set` +- 触发:每周日 `03:40 UTC`、手动 `tier=soak` +- 目标:拉高迭代强度进行稳定性与性能基线漂移检查 +- 入口: + - `scripts/yaml_dsl_stability_regression.py --iterations 80 --mem-delta-kb-max 12288` + - `scripts/yaml_dsl_performance_baseline.py --basic-iters 30 --retry-iters 20` + +## 工作流文件 +- `.github/workflows/yaml-dsl-regression.yml` + +## 判定原则 +- 任一层失败即视为该层门禁不通过。 +- 发布前必须至少满足: + - 最近一次 `Nightly Full Set` 通过; + - 最近一次 `Weekly Soak Set` 通过; + - 当前分支 `PR Fast Set` 通过。 diff --git a/docs/YAML_DSL_TEST_FREEZE_20260306.md b/docs/YAML_DSL_TEST_FREEZE_20260306.md new file mode 100644 index 0000000..d6d633c --- /dev/null +++ b/docs/YAML_DSL_TEST_FREEZE_20260306.md @@ -0,0 +1,22 @@ +# YAML-DSL 测试文档冻结(2026-03-06) + +本次冻结包含以下文档: + +1. 测试矩阵 +- `docs/YAML_DSL_PROTOCOL_TEST_MATRIX.md` + +2. 故障注入指南 +- `docs/TARGET_EMULATOR_GUIDE.md` +- `tools/target_emulator/scenarios/fault_injection_matrix.yaml` + +3. 排障与执行约定 +- `docs/YAML_DSL_PROTOCOL_TEST_CONVENTIONS.md` +- `docs/YAML_DSL_TEST_CI_TIERS.md` + +4. 发布门禁 +- `docs/YAML_DSL_RELEASE_GATE.md` + +5. 已知限制 +- `expect.retry` 仅重试等待,不自动重发上一条 `send`。 +- 靶机场景响应为规则配置值,不提供动态变量回写。 +- 长稳/性能报告默认写入 `runs/`,不纳入版本库跟踪。 diff --git a/docs/YAML_DSL_V01_CHANNEL_CONTRACT.md b/docs/YAML_DSL_V01_CHANNEL_CONTRACT.md new file mode 100644 index 0000000..d386df7 --- /dev/null +++ b/docs/YAML_DSL_V01_CHANNEL_CONTRACT.md @@ -0,0 +1,99 @@ +# YAML-DSL v0.1 通道契约(Channel Contract) + +日期:2026-03-06 +状态:v0.1 设计基线 + +## 1. 目标 +- 在“直接替代旧语法”的前提下,确保串口会话行为可预测、可恢复、可审计。 +- 为 `session/steps/flow/artifacts` 提供稳定的底层通道语义。 + +## 2. 关键对象 + +### 2.1 `IChannelSession` +统一通道会话接口(v0.1 先覆盖 serial): +- `open(config) -> SessionHandle` +- `close(handle) -> None` +- `switch(handle, config) -> SessionHandle`(原子重连) +- `write_text(handle, text, eol, encoding) -> TxResult` +- `write_hex(handle, hex_bytes) -> TxResult` +- `read_until(handle, matcher, timeout_ms) -> RxResult` +- `drain(handle, timeout_ms) -> DrainResult` +- `get_status(handle) -> SessionStatus` + +### 2.2 `SessionManager` +- 管理会话租约与串口独占。 +- 处理步骤层请求到具体 session 的路由。 +- 负责连接状态转换和资源回收。 + +## 3. 会话状态机 +- `closed`:无会话 +- `opening`:建立连接中 +- `active`:可收发 +- `switching`:切端口/波特率中(必须阻断并发收发) +- `closing`:释放中 +- `error`:故障态(可由 flow 决定 retry/on_fail) + +状态转换要求: +- `switch` 必须按 `close(old) -> open(new)` 执行。 +- 切换失败时返回明确错误,不允许“半开连接”。 + +## 4. 独占与并发规则 +- 同一物理串口同一时刻仅允许一个活动 `SessionHandle`。 +- UI 手工串口会话与 DSL 运行会话互斥。 +- 当会话被占用,必须返回 `PORT_BUSY`,禁止隐式抢占。 + +## 5. 编码与换行策略 +- `encoding`: `ascii | utf8 | hex` +- `eol`: `none | cr | lf | crlf` +- `send` 行为: + - `text` 模式:模板展开后按 encoding 编码并附加 eol。 + - `hex` 模式:按字节发送,不附加 eol。 +- `expect` 行为: + - 匹配输入以接收缓冲区文本视图进行(保留原始字节用于 artifacts)。 + +## 6. 缓冲策略(BufferPolicy) +- 每个 step 执行前可选 `drain_before`(默认 false)。 +- 每个 `expect` 在超时前持续消费输入缓冲。 +- 匹配命中后可配置 `consume_on_match`(v0.1 默认 true)。 +- 所有原始 RX/TX 保留到 `raw_log`,用于复盘。 + +## 7. 超时模型(统一 `timeout_ms`) +- `open_timeout_ms`:连接建立超时 +- `read_timeout_ms`:底层读超时(单次) +- `step_timeout_ms`:步骤级超时(expect/retry 用) +- `session_idle_timeout_ms`:可选,长期空闲自动关闭(v0.1 可不启用) + +## 8. 错误码约定(ErrorCode) +- `OPEN_FAILED` +- `CLOSE_FAILED` +- `PORT_BUSY` +- `SWITCH_FAILED` +- `WRITE_FAILED` +- `READ_TIMEOUT` +- `MATCH_FAILED` +- `CAPTURE_FAILED` +- `ASSERT_FAILED` +- `UNEXPECTED_EXCEPTION` + +约束: +- 所有错误必须带 `code + message + step_id + ts`。 +- `retry/on_fail` 仅基于标准错误码决策,避免字符串判断。 + +## 9. 日志与审计字段 +每个 step 至少记录: +- `step_id` +- `action` +- `started_at` / `ended_at` / `elapsed_ms` +- `tx_text` / `tx_hex` +- `rx_text` / `rx_hex` +- `match_result` +- `error_code` / `error_message` + +产物最低要求: +- `raw_log`(逐条) +- `summary.json`(成功/失败、步骤统计、最终变量快照) + +## 10. 实施注意事项(替代旧语法) +- 旧 DSL 不做兼容层,入口直接切换到新 schema。 +- 先落地契约再改 parser/executor,避免行为发散。 +- 先补会话互斥与错误码回归,再接入 `send/expect/sleep`。 diff --git a/docs/YAML_DSL_V01_QUICKSTART.md b/docs/YAML_DSL_V01_QUICKSTART.md new file mode 100644 index 0000000..42d43e1 --- /dev/null +++ b/docs/YAML_DSL_V01_QUICKSTART.md @@ -0,0 +1,92 @@ +# YAML-DSL v0.1 快速开始 + +日期:2026-03-06 + +## 1. 当前实现范围 +v0.1 当前已实现: +- `session`(serial 参数解析) +- `send` +- `expect`(contains/regex/startswith + timeout_ms) +- `sleep` +- `capture`(regex 提取变量) +- `assert`(match/expr,支持 all/any) +- `retry/backoff/on_fail` +- `raw_log + summary.json` 产物导出 + +## 2. 最小示例 +```yaml +version: "0.1" + +params: + port: "COM5" + +vars: + ver: "" + +session: + transport: serial + port: "${port}" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +defaults: + timeout_ms: 2000 + retry: { count: 1, backoff_ms: 200, strategy: fixed } + +steps: + - name: send + text: "AT+GMR" + - name: expect + match: { type: regex, pattern: "VER:([0-9.]+)" } + capture: + var: ver + regex: "VER:([0-9.]+)" + group: 1 + - name: assert + expr: "${ver} != ''" + message: "version parse failed" + +artifacts: + dir: "./runs/v01_${now}" + raw_log: true + summary_json: true + report_csv: false +``` + +## 3. 运行入口 +CLI: +```bash +python app/dsl_main.py path/to/script.yaml +``` + +桌面端: +- 打开 Scripts 页面,加载/粘贴 v0.1 YAML 后运行。 + +## 4. 产物输出 +默认输出目录由 `artifacts.dir` 指定,支持 `${now}` 模板。 + +输出文件: +- `raw_log.jsonl`: 每步执行记录 +- `summary.json`: 执行摘要、错误信息、变量快照 +- `report.csv`: 可选导出 + +## 5. 回归脚本 +```bash +python scripts/v01_dsl_regression.py +``` + +覆盖项: +- 执行成功路径(send/expect/capture/assert) +- 失败恢复(retry + on_fail) +- 产物导出(raw_log/summary) + +## 6. 约束与注意事项 +- 当前 `session.transport` 仅支持 `serial`。 +- v0.1 尚未完成的 step 将显式抛出 `NotImplementedError`。 +- 表达式优先使用 `${var}` 变量引用形式。 diff --git a/docs/YAML_DSL_V01_RELEASE_CHECKLIST.md b/docs/YAML_DSL_V01_RELEASE_CHECKLIST.md new file mode 100644 index 0000000..62e5734 --- /dev/null +++ b/docs/YAML_DSL_V01_RELEASE_CHECKLIST.md @@ -0,0 +1,35 @@ +# YAML-DSL v0.1 发布冻结清单 + +日期:2026-03-06 +状态:已执行(本次迭代) + +## 1. 范围冻结 +- [x] 仅覆盖 v0.1 MVP 范围(session/send/expect/sleep/capture/assert/retry/on_fail/artifacts) +- [x] 旧 DSL 语法不纳入本次兼容范围 + +## 2. 实现项核对 +- [x] session 配置解析(serial/eol/encoding/open/read timeout) +- [x] send(text/hex + `${var}` 模板) +- [x] expect(contains/regex/startswith + timeout) +- [x] sleep(ms) +- [x] capture(独立 step + expect 内联) +- [x] assert(expr/match + all/any) +- [x] retry/backoff/on_fail +- [x] params 与 vars 生命周期分离 +- [x] artifacts 导出(raw_log/summary/report) + +## 3. 验证记录 +- [x] 编译检查通过 + 命令: + `python -m py_compile dsl_runtime/lang/ast_nodes.py dsl_runtime/lang/parser.py dsl_runtime/engine/context.py dsl_runtime/engine/channels.py dsl_runtime/engine/v01_executor.py dsl_runtime/engine/v01_artifacts.py dsl_runtime/engine/runner.py ui/desktop/script_runner_qt.py` +- [x] v0.1 回归脚本通过 + 命令: + `python scripts/v01_dsl_regression.py` + 结果:`RESULT: PASSED` + +## 4. 文档交付 +- [x] 设计基线:`docs/YAML_DSL_DESIGN_BASELINE.md` +- [x] 通道契约:`docs/YAML_DSL_V01_CHANNEL_CONTRACT.md` +- [x] Schema 规范:`docs/YAML_DSL_V01_SCHEMA_SPEC.md` +- [x] 快速开始:`docs/YAML_DSL_V01_QUICKSTART.md` +- [x] 示例脚本:`docs/examples/v01/*.yaml` diff --git a/docs/YAML_DSL_V01_SCHEMA_SPEC.md b/docs/YAML_DSL_V01_SCHEMA_SPEC.md new file mode 100644 index 0000000..3c81a3e --- /dev/null +++ b/docs/YAML_DSL_V01_SCHEMA_SPEC.md @@ -0,0 +1,286 @@ +# YAML-DSL v0.1 Schema 规范 + +日期:2026-03-06 +状态:Draft-Ready(用于实现) + +## 1. 目标与边界 +- 直接替代旧 DSL 语法,不做兼容层。 +- v0.1 仅覆盖 MVP:`session + send/expect/sleep + capture + retry/on_fail + assert + artifacts`。 +- 面向单设备单会话(serial 优先)。 + +## 2. 顶层结构 +```yaml +version: "0.1" +params: {} # 运行前注入 +vars: {} # 运行时初始变量 +session: {} # 串口会话配置 +defaults: {} # 默认超时/重试策略 +steps: [] # 顺序步骤 +artifacts: {} # 日志与产物导出配置 +``` + +必填: +- `version` +- `session` +- `steps` + +## 3. Session +```yaml +session: + transport: serial + port: "${port}" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii # ascii | utf8 | hex + eol: crlf # none | cr | lf | crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 +``` + +约束: +- `transport` v0.1 固定为 `serial`。 +- 运行期允许 `switch_session`(端口/波特率切换)作为扩展 step。 + +## 4. Defaults +```yaml +defaults: + timeout_ms: 2000 + retry: + count: 0 + backoff_ms: 0 + strategy: fixed # fixed | exponential + drain_before_expect: false + consume_on_match: true +``` + +## 5. Step 通用字段 +每个 step 为一个对象,通用字段: +- `id`:唯一标识(建议) +- `name`:类型(`send|expect|sleep|assert|capture|if|loop`) +- `timeout_ms`:覆盖默认超时 +- `retry`:覆盖默认重试 +- `on_fail`:失败时执行的步骤列表(局部补救) + +示例: +```yaml +- id: enter_boot + name: send + text: "+++BOOT" + eol: cr + retry: { count: 2, backoff_ms: 150, strategy: exponential } + on_fail: + - name: send + text: "AT+RST" +``` + +## 6. Step 类型定义 + +### 6.1 `send` +```yaml +- name: send + text: "AT+GMR" # text 与 hex 二选一 + hex: "41 54 0D 0A" + eol: lf # 可选,覆盖 session.eol + encoding: ascii # 可选,覆盖 session.encoding +``` + +### 6.2 `expect` +```yaml +- name: expect + match: + type: regex # contains | regex | startswith + pattern: "OK|READY" + flags: "i" # 可选:i,m,s + capture: + - var: version + regex: "VER:([0-9.]+)" + group: 1 + save_as: last_line # 可选,保存命中行 +``` + +### 6.3 `sleep` +```yaml +- name: sleep + ms: 500 +``` + +### 6.4 `capture`(独立抓取) +```yaml +- name: capture + source: "${last_line}" # 默认最近接收行 + regex: "SN:([A-Z0-9]+)" + group: 1 + var: device_sn +``` + +### 6.5 `assert` +```yaml +- name: assert + any: + - match: { type: contains, pattern: "PASS" } + - expr: "${voltage} >= 11.8 and ${voltage} <= 12.2" + message: "self test failed" +``` + +说明: +- `any` / `all` 二选一;`expr` 使用受限表达式引擎。 + +### 6.6 `if`(v0.1 受控) +```yaml +- name: if + when: "${need_boot} == true" + then: + - name: send + text: "boot" + else: + - name: send + text: "app" +``` + +### 6.7 `loop`(v0.1 受控) +```yaml +- name: loop + times: 3 + steps: + - name: send + text: "AT" + - name: expect + match: { type: contains, pattern: "OK" } +``` + +## 7. 可靠性语义 + +### 7.1 重试 +- `retry.count`:失败后最多重试次数。 +- `backoff_ms`:重试间隔。 +- `strategy=exponential`:第 n 次等待 `backoff_ms * 2^(n-1)`。 + +### 7.2 失败判定 +step 失败条件: +- 超时(`READ_TIMEOUT` / `STEP_TIMEOUT`) +- 匹配失败(`MATCH_FAILED`) +- 抓取失败(`CAPTURE_FAILED`) +- 断言失败(`ASSERT_FAILED`) +- 发送失败(`WRITE_FAILED`) + +### 7.3 on_fail +- 在本 step 最终失败后执行 `on_fail`。 +- `on_fail` 执行失败不吞掉原始错误,最终错误仍为原 step 错误码。 + +## 8. 模板与变量 + +### 8.1 模板风格 +v0.1 固定使用:`${var}`。 + +### 8.2 变量来源 +- `params`:外部输入(运行前) +- `vars`:脚本初值 +- `capture/assert/expect`:运行中写入 + +### 8.3 变量优先级 +运行时读取优先级:`vars(runtime) > params > vars(initial)`。 + +## 9. Artifacts +```yaml +artifacts: + dir: "./runs/${now}" + raw_log: true + summary_json: true + report_csv: false +``` + +最低要求: +- `raw_log`:逐条收发与 step 日志 +- `summary.json`:结果、耗时、失败点、最终变量快照 + +## 10. 错误模型 +统一错误对象: +```yaml +error: + code: "MATCH_FAILED" + message: "expect regex not matched" + step_id: "wait_ok" + ts: 1741220000.123 +``` + +标准错误码(v0.1): +- `OPEN_FAILED` +- `PORT_BUSY` +- `SWITCH_FAILED` +- `WRITE_FAILED` +- `READ_TIMEOUT` +- `MATCH_FAILED` +- `CAPTURE_FAILED` +- `ASSERT_FAILED` +- `STEP_TIMEOUT` +- `UNEXPECTED_EXCEPTION` + +## 11. 最小完整示例 +```yaml +version: "0.1" + +params: + port: "COM5" + fw_ver_expected: "1.2.3" + +vars: + version: "" + +session: + transport: serial + port: "${port}" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +defaults: + timeout_ms: 2000 + retry: { count: 1, backoff_ms: 200, strategy: fixed } + +steps: + - id: ping + name: send + text: "AT" + + - id: wait_ok + name: expect + match: { type: contains, pattern: "OK" } + + - id: read_ver + name: send + text: "AT+GMR" + + - id: parse_ver + name: expect + match: { type: regex, pattern: "VER:([0-9.]+)" } + capture: + - var: version + regex: "VER:([0-9.]+)" + group: 1 + + - id: assert_ver + name: assert + all: + - expr: "${version} == ${fw_ver_expected}" + message: "version mismatch" + +artifacts: + dir: "./runs/${now}" + raw_log: true + summary_json: true + report_csv: false +``` + +## 12. 实现顺序建议 +1. parser 校验顶层 schema 与 step 基础字段 +2. session manager 与串口独占 +3. `send/expect/sleep` +4. `capture/assert/retry/on_fail` +5. artifacts 与 summary 输出 diff --git a/docs/YAML_DSL_V02_MIGRATION_AND_BEST_PRACTICES.md b/docs/YAML_DSL_V02_MIGRATION_AND_BEST_PRACTICES.md new file mode 100644 index 0000000..2e93a42 --- /dev/null +++ b/docs/YAML_DSL_V02_MIGRATION_AND_BEST_PRACTICES.md @@ -0,0 +1,37 @@ +# YAML-DSL v0.2 迁移说明与最佳实践 + +日期:2026-03-06 + +## 1. 版本定位 +- v0.1:最小可用(send/expect/sleep/capture/assert/retry/artifacts) +- v0.2:可用性增强(控制流、解析、指标、复用、受控 exec/file、switch_session) + +## 2. v0.1 -> v0.2 迁移 +1. 版本号升级为 `version: "0.2"`。 +2. 若需要复用会话配置,新增 `profiles` 并在 `session.profile` 引用。 +3. 若要复用步骤,新增 `step_templates`,通过 `template + args` 调用。 +4. 若有多脚本拆分,使用 `imports/include` 合并外部步骤片段。 +5. 使用 `security.exec` / `security.file` 明确白名单策略后再启用相关 step。 + +## 3. 推荐写法 +1. 控制流优先用 `if` + `loop`,避免无边界跳转。 +2. `parse -> path -> measure -> assert_range` 作为产测标准链路。 +3. `retry` 仅用于可恢复步骤(通信超时、瞬态响应失败),不要用于配置错误。 +4. 统一在 `artifacts.dir` 使用 `${now}`,避免多次运行覆盖。 +5. 所有关键步骤都写 `id`,便于 summary 定位失败点。 + +## 4. 安全建议 +1. `exec` 必须配置 `allow_commands` 与 `cwd_allowlist`。 +2. `file` 必须配置 `root_allowlist`,避免越权读写。 +3. 不要在日志中直接打印密钥、证书、密码。 + +## 5. 常见问题 +1. `STEP_TIMEOUT`:增大 step `timeout_ms` 或优化设备响应窗口。 +2. `ASSERT_FAILED`:检查 capture/path 是否写入预期变量。 +3. `EXEC_NOT_ALLOWED` / `FILE_NOT_ALLOWED`:补齐 security 白名单。 +4. `STEP_NOT_IMPLEMENTED`:脚本使用了当前执行器未支持的 step。 + +## 6. 与文档对应关系 +- Schema:`docs/YAML_DSL_V02_SCHEMA_SPEC.md` +- 示例:`docs/examples/v02/` +- 回归脚本:`scripts/v01_dsl_regression.py` diff --git a/docs/YAML_DSL_V02_RELEASE_CHECKLIST.md b/docs/YAML_DSL_V02_RELEASE_CHECKLIST.md new file mode 100644 index 0000000..5812d64 --- /dev/null +++ b/docs/YAML_DSL_V02_RELEASE_CHECKLIST.md @@ -0,0 +1,39 @@ +# YAML-DSL v0.2 发布冻结清单 + +日期:2026-03-06 +状态:已执行(本次迭代) + +## 1. 范围冻结 +- [x] v0.2 增量能力冻结:if/loop/parse/path/measure/assert_range/include/template/profile/exec/file/switch_session +- [x] 保持 v0.1 既有能力可用 + +## 2. 实现核对 +- [x] 受控控制流:if/else +- [x] 受控循环:loop(times/until) +- [x] 结构化解析:parse(json/kv/csv) +- [x] 路径取值:path(a.b.c) +- [x] 指标采集:measure +- [x] 阈值断言:assert_range +- [x] 复用机制:imports/include +- [x] 模板机制:step_templates +- [x] 会话复用:profiles + session.profile +- [x] 受控执行:exec(命令白名单 + cwd 白名单) +- [x] 受控文件:file(根目录白名单) +- [x] 会话切换:switch_session(含 dry_run) +- [x] 统一错误码与结构化失败对象 +- [x] step 级审计字段(attempts/retry_count/on_fail_steps) + +## 3. 验证记录 +- [x] 编译检查通过 +命令: +`python -m py_compile dsl_runtime/lang/ast_nodes.py dsl_runtime/lang/parser.py dsl_runtime/engine/context.py dsl_runtime/engine/channels.py dsl_runtime/engine/v01_executor.py dsl_runtime/engine/v01_artifacts.py dsl_runtime/engine/runner.py ui/desktop/script_runner_qt.py scripts/v01_dsl_regression.py` + +- [x] 回归脚本通过 +命令: +`python scripts/v01_dsl_regression.py` +结果:`RESULT: PASSED` + +## 4. 文档与样例 +- [x] v0.2 Schema:`docs/YAML_DSL_V02_SCHEMA_SPEC.md` +- [x] 迁移与最佳实践:`docs/YAML_DSL_V02_MIGRATION_AND_BEST_PRACTICES.md` +- [x] v0.2 示例:`docs/examples/v02/` diff --git a/docs/YAML_DSL_V02_SCHEMA_SPEC.md b/docs/YAML_DSL_V02_SCHEMA_SPEC.md new file mode 100644 index 0000000..5f990f0 --- /dev/null +++ b/docs/YAML_DSL_V02_SCHEMA_SPEC.md @@ -0,0 +1,146 @@ +# YAML-DSL v0.2 Schema 规范 + +日期:2026-03-06 +状态:Draft-Ready(v0.2) + +## 1. 范围与验收 +v0.2 目标:在 v0.1 可运行基础上,补齐“可用性”能力: +- 受控控制流:`if/else`、`loop` +- 结构化解析:`parse(json|kv|csv)` + `path` +- 产测能力:`measure`、`assert_range` +- 复用能力:`include/import`、`step_templates`、`profile` +- 受控执行:`exec`、`file` + +验收口径: +- 覆盖调试/升级/参数写入/基础产测主流程。 +- 所有失败都有标准错误对象(`code/message/step_id/ts`)。 +- 产物能复盘每一步输入输出、耗时、断言与指标。 + +## 2. 顶层结构(v0.2) +```yaml +version: "0.2" +params: {} +vars: {} +profiles: {} # 可复用会话配置 +session: {} # 直接会话;与 profile 二选一 +defaults: {} +step_templates: {} # 参数化步骤模板 +steps: [] +artifacts: {} +security: {} # exec/file 的白名单约束 +``` + +## 3. 新增/扩展 Step + +### 3.1 `if` +```yaml +- name: if + when: "${mode} == 'boot'" + then: [ ...steps ] + else: [ ...steps ] +``` + +### 3.2 `loop` +```yaml +- name: loop + times: 5 + until: "${done} == true" # 可选 + steps: [ ...steps ] +``` + +### 3.3 `parse` +```yaml +- name: parse + format: json # json | kv | csv + source: "${last_rx_text}" + save_as: "parsed" +``` + +### 3.4 `measure` +```yaml +- name: measure + metric: "voltage" + value: "${parsed.voltage}" + unit: "V" +``` + +### 3.5 `assert_range` +```yaml +- name: assert_range + value: "${parsed.voltage}" + min: 11.8 + max: 12.2 + abs_err: 0.1 # 可选 + in_set: [12.0] # 可选 +``` + +### 3.6 `exec`(受控) +```yaml +- name: exec + command: "tool.exe --check ${fw}" + timeout_ms: 10000 + save_stdout_as: "exec_out" +``` + +### 3.7 `file`(受控) +```yaml +- name: file + op: write_text # read_text | write_text | append_text | exists + path: "./runs/${now}/note.txt" + content: "done=${result}" +``` + +## 4. 复用能力 + +### 4.1 `profiles` +```yaml +profiles: + mcu_uart: + transport: serial + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf +``` + +### 4.2 `step_templates` +```yaml +step_templates: + ping: + params: [cmd] + step: + name: send + text: "${cmd}" +``` + +### 4.3 `include/import` +```yaml +imports: + - "./fragments/enter_boot.yaml" +``` + +## 5. Security 约束(v0.2) +```yaml +security: + exec: + enabled: true + allow_commands: ["tool.exe", "python"] + cwd_allowlist: ["./tools", "./runs"] + file: + root_allowlist: ["./runs", "./artifacts"] +``` + +## 6. 错误码(增量) +在 v0.1 基础上增加: +- `PARSE_FAILED` +- `RANGE_ASSERT_FAILED` +- `EXEC_NOT_ALLOWED` +- `EXEC_FAILED` +- `FILE_NOT_ALLOWED` +- `FILE_FAILED` + +## 7. 迁移 +- v0.1 脚本无需改动可继续执行。 +- 使用 v0.2 新能力时需声明 `version: "0.2"`。 diff --git a/docs/examples/v01/debug_cli.yaml b/docs/examples/v01/debug_cli.yaml new file mode 100644 index 0000000..87e932f --- /dev/null +++ b/docs/examples/v01/debug_cli.yaml @@ -0,0 +1,62 @@ +version: "0.1" + +params: + port: "COM5" + +vars: + fw_ver: "" + +session: + transport: serial + port: "${port}" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +defaults: + timeout_ms: 2000 + retry: + count: 1 + backoff_ms: 200 + strategy: fixed + +steps: + - id: ping + name: send + text: "AT" + + - id: wait_ok + name: expect + match: + type: contains + pattern: "OK" + + - id: query_version + name: send + text: "AT+GMR" + + - id: parse_version + name: expect + match: + type: regex + pattern: "VER:([0-9.]+)" + capture: + var: fw_ver + regex: "VER:([0-9.]+)" + group: 1 + + - id: assert_version_not_empty + name: assert + expr: "${fw_ver} != ''" + message: "version not found" + +artifacts: + dir: "./runs/v01_debug_${now}" + raw_log: true + summary_json: true + report_csv: false diff --git a/docs/examples/v01/param_write_sn.yaml b/docs/examples/v01/param_write_sn.yaml new file mode 100644 index 0000000..a1d7b52 --- /dev/null +++ b/docs/examples/v01/param_write_sn.yaml @@ -0,0 +1,63 @@ +version: "0.1" + +params: + port: "COM5" + sn: "SN2403060001" + +vars: + sn_read: "" + +session: + transport: serial + port: "${port}" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +defaults: + timeout_ms: 2500 + retry: + count: 1 + backoff_ms: 150 + strategy: fixed + +steps: + - id: write_sn + name: send + text: "AT+SETSN=${sn}" + + - id: wait_write_ok + name: expect + match: + type: contains + pattern: "OK" + + - id: read_sn + name: send + text: "AT+GETSN" + + - id: parse_sn + name: expect + match: + type: regex + pattern: "SN:([A-Z0-9]+)" + capture: + var: sn_read + regex: "SN:([A-Z0-9]+)" + group: 1 + + - id: assert_sn_consistency + name: assert + expr: "${sn_read} == ${sn}" + message: "SN readback mismatch" + +artifacts: + dir: "./runs/v01_param_${now}" + raw_log: true + summary_json: true + report_csv: false diff --git a/docs/examples/v01/upgrade_bootstrap.yaml b/docs/examples/v01/upgrade_bootstrap.yaml new file mode 100644 index 0000000..525db23 --- /dev/null +++ b/docs/examples/v01/upgrade_bootstrap.yaml @@ -0,0 +1,80 @@ +version: "0.1" + +params: + port: "COM5" + target_version: "1.2.3" + +vars: + current_ver: "" + +session: + transport: serial + port: "${port}" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +defaults: + timeout_ms: 3000 + retry: + count: 2 + backoff_ms: 200 + strategy: exponential + +steps: + - id: enter_boot + name: send + text: "boot" + on_fail: + - name: send + text: "AT+RST" + - name: sleep + ms: 500 + + - id: wait_boot_prompt + name: expect + timeout_ms: 5000 + match: + type: contains + pattern: "BOOT>" + + - id: start_upgrade + name: send + text: "upgrade start" + + - id: wait_upgrade_ready + name: expect + timeout_ms: 8000 + match: + type: regex + pattern: "READY|WAIT_FOR_IMAGE" + + - id: query_version + name: send + text: "version" + + - id: parse_version + name: expect + match: + type: regex + pattern: "VER:([0-9.]+)" + capture: + var: current_ver + regex: "VER:([0-9.]+)" + group: 1 + + - id: assert_version + name: assert + expr: "${current_ver} == ${target_version}" + message: "upgrade version mismatch" + +artifacts: + dir: "./runs/v01_upgrade_${now}" + raw_log: true + summary_json: true + report_csv: true diff --git a/docs/examples/v02/debug_if_loop.yaml b/docs/examples/v02/debug_if_loop.yaml new file mode 100644 index 0000000..1d7f49f --- /dev/null +++ b/docs/examples/v02/debug_if_loop.yaml @@ -0,0 +1,43 @@ +version: "0.2" + +params: + port: "COM5" + mode: "boot" + +session: + transport: serial + port: "${port}" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +defaults: + timeout_ms: 1500 + retry: { count: 1, backoff_ms: 100, strategy: fixed } + +steps: + - name: if + when: "${mode} == 'boot'" + then: + - name: send + text: "boot" + else: + - name: send + text: "app" + - name: loop + times: 3 + steps: + - name: send + text: "AT" + - name: sleep + ms: 100 + +artifacts: + dir: "./runs/v02_debug_${now}" + raw_log: true + summary_json: true diff --git a/docs/examples/v02/exec_file_pipeline.yaml b/docs/examples/v02/exec_file_pipeline.yaml new file mode 100644 index 0000000..8c0b861 --- /dev/null +++ b/docs/examples/v02/exec_file_pipeline.yaml @@ -0,0 +1,45 @@ +version: "0.2" + +params: + port: "COM5" + +session: + transport: serial + port: "${port}" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +security: + exec: + enabled: true + allow_commands: ["python"] + cwd_allowlist: ["."] + file: + root_allowlist: ["./runs", "./artifacts", "./tmp"] + +steps: + - name: exec + command: "python --version" + save_stdout_as: "out" + save_stderr_as: "err" + - name: file + op: write_text + path: "./tmp/v02_exec.txt" + content: "${out}${err}" + - name: file + op: exists + path: "./tmp/v02_exec.txt" + save_as: "f_exists" + - name: assert + expr: "${f_exists} == True" + +artifacts: + dir: "./runs/v02_exec_file_${now}" + raw_log: true + summary_json: true diff --git a/docs/examples/v02/fragments/common_steps.yaml b/docs/examples/v02/fragments/common_steps.yaml new file mode 100644 index 0000000..f0ec96d --- /dev/null +++ b/docs/examples/v02/fragments/common_steps.yaml @@ -0,0 +1,7 @@ +steps: + - name: send + text: "AT" + - name: expect + match: + type: contains + pattern: "OK" diff --git a/docs/examples/v02/full_feature_workbench.yaml b/docs/examples/v02/full_feature_workbench.yaml new file mode 100644 index 0000000..d177a2b --- /dev/null +++ b/docs/examples/v02/full_feature_workbench.yaml @@ -0,0 +1,285 @@ +version: "0.2" + +params: + port: "COM5" + baud_normal: 115200 + baud_boot: 921600 + run_mode: "diagnose" # diagnose | upgrade | smoke + enable_protocol_pkg: false + protocols_dir: "../../../protocols" + fw_path: "./artifacts/fw.bin" + out_dir: "./runs/v02_full" + expected_ok: "OK" + expected_sn_prefix: "SN" + voltage_min: 11.8 + voltage_max: 12.3 + +vars: + retry_count: 0 + device_ready: false + last_loop_round: 0 + sn: "" + csv_threshold: 40 + +profiles: + uart_normal: + transport: serial + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +session: + profile: uart_normal + port: "${port}" + baud: 115200 + +defaults: + timeout_ms: 1500 + retry: + count: 1 + backoff_ms: 100 + strategy: fixed + +security: + exec: + enabled: true + allow_commands: ["python"] + cwd_allowlist: ["."] + file: + root_allowlist: ["./runs", "./artifacts", "./tmp"] + +imports: + - "./fragments/common_steps.yaml" + +step_templates: + send_expect: + params: [cmd, token] + steps: + - name: send + text: "${cmd}" + - name: expect + timeout_ms: 1200 + match: + type: contains + pattern: "${token}" + +steps: + - id: warmup_ping + template: send_expect + args: + cmd: "AT" + token: "${expected_ok}" + + - id: query_version_with_capture + name: send + text: "AT+GMR" + + - id: expect_version + name: expect + timeout_ms: 1500 + match: + type: regex + pattern: "(VERSION|VER|OK)" + capture: + - var: fw_version + regex: "(VERSION[^\\r\\n]*)" + group: 1 + retry: + count: 2 + backoff_ms: 200 + strategy: exponential + on_fail: + - name: send + text: "AT" + - name: sleep + ms: 120 + + - id: capture_sn + name: capture + source: "SN:SN00123" + var: sn + regex: "SN:([A-Z0-9]+)" + group: 1 + + - id: assert_capture + name: assert + all: + - expr: "${sn} != ''" + - match: + type: startswith + pattern: "${expected_sn_prefix}" + source: "${sn}" + + - id: parse_json_payload + name: parse + format: json + source: '{"adc":{"voltage":12.08},"temp":39.6,"mode":"${run_mode}"}' + save_as: parsed_json + + - id: path_voltage + name: path + source: "${parsed_json}" + path: "adc.voltage" + save_as: voltage + + - id: measure_voltage + name: measure + metric: "voltage" + value: "${voltage}" + unit: "V" + + - id: assert_voltage_range + name: assert_range + value: "${measure.voltage}" + min: "${voltage_min}" + max: "${voltage_max}" + message: "voltage out of range" + + - id: parse_kv_payload + name: parse + format: kv + source: | + err=0 + state=ready + temp=38.9 + save_as: parsed_kv + + - id: parse_csv_payload + name: parse + format: csv + source: | + metric,value + rpm,1520 + pwm,42 + save_as: parsed_csv + + - id: path_csv_value + name: path + source: "${parsed_csv}" + path: "1.value" + save_as: pwm_value + + - id: assert_any_example + name: assert + any: + - expr: "${parsed_kv.state} == 'ready'" + - expr: "${pwm_value} > ${csv_threshold}" + + - id: mode_branch + name: if + when: "${run_mode} == 'upgrade'" + then: + - name: send + text: "ENTER_BOOT" + - name: sleep + ms: 200 + - name: switch_session + baud: "${baud_boot}" + dry_run: true + else: + - name: send + text: "AT+DIAG=1" + - name: sleep + ms: 80 + + - id: loop_wait_ready + name: loop + times: 5 + until: "${device_ready} == True" + steps: + - name: send + text: "AT+READY?" + - name: sleep + ms: 120 + - name: if + when: "${last_loop_round} >= 3" + then: + - name: file + op: write_text + path: "./tmp/device_ready.flag" + content: "ready=true" + - name: file + op: exists + path: "./tmp/device_ready.flag" + save_as: device_ready + + - id: protocol_send_optional + name: if + when: "${enable_protocol_pkg} == True" + then: + - name: protocol.send + protocol: at_command + packages_dir: "${protocols_dir}" + request: + cmd: "AT" + eol: "crlf" + timeout_ms: 1200 + save_as: at_send_result + - name: protocol.recv + protocol: at_command + packages_dir: "${protocols_dir}" + expect: + status: ok + contains: "OK" + size: 512 + timeout_ms: 1200 + save_as: at_recv_result + - name: protocol.rpc + protocol: scpi + packages_dir: "${protocols_dir}" + request: + cmd: "*IDN?" + expect: + min_lines: 1 + status: ok + timeout_ms: 1500 + save_as: scpi_rpc_result + + - id: controlled_exec + name: exec + command: "python --version" + timeout_ms: 5000 + save_stdout_as: py_ver_out + save_stderr_as: py_ver_err + + - id: file_write_report + name: file + op: write_text + path: "${out_dir}/quick_report.txt" + content: | + mode=${run_mode} + sn=${sn} + fw=${fw_version} + voltage=${measure.voltage} + python=${py_ver_out}${py_ver_err} + + - id: file_append_metrics + name: file + op: append_text + path: "${out_dir}/quick_report.txt" + content: | + + metrics=${metrics} + + - id: file_read_back + name: file + op: read_text + path: "${out_dir}/quick_report.txt" + save_as: report_text + + - id: final_assert + name: assert + all: + - expr: "${report_text} != ''" + - expr: "${measure.voltage} >= ${voltage_min}" + +artifacts: + dir: "${out_dir}_${now}" + raw_log: true + summary_json: true + report_csv: true diff --git a/docs/examples/v02/import_main.yaml b/docs/examples/v02/import_main.yaml new file mode 100644 index 0000000..8b13ba4 --- /dev/null +++ b/docs/examples/v02/import_main.yaml @@ -0,0 +1,28 @@ +version: "0.2" + +params: + port: "COM5" + +session: + transport: serial + port: "${port}" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +imports: + - "./fragments/common_steps.yaml" + +steps: + - name: send + text: "AT+RST" + +artifacts: + dir: "./runs/v02_import_${now}" + raw_log: true + summary_json: true diff --git a/docs/examples/v02/param_write_with_assert.yaml b/docs/examples/v02/param_write_with_assert.yaml new file mode 100644 index 0000000..fcca35f --- /dev/null +++ b/docs/examples/v02/param_write_with_assert.yaml @@ -0,0 +1,38 @@ +version: "0.2" + +params: + port: "COM5" + sn: "SN2403060002" + +session: + transport: serial + port: "${port}" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +steps: + - name: send + text: "AT+SETSN=${sn}" + - name: expect + match: { type: contains, pattern: "OK" } + - name: send + text: "AT+GETSN" + - name: expect + match: { type: regex, pattern: "SN:([A-Z0-9]+)" } + capture: + var: "sn_read" + regex: "SN:([A-Z0-9]+)" + group: 1 + - name: assert + expr: "${sn_read} == ${sn}" + +artifacts: + dir: "./runs/v02_param_${now}" + raw_log: true + summary_json: true diff --git a/docs/examples/v02/parse_measure.yaml b/docs/examples/v02/parse_measure.yaml new file mode 100644 index 0000000..921ecb1 --- /dev/null +++ b/docs/examples/v02/parse_measure.yaml @@ -0,0 +1,39 @@ +version: "0.2" + +params: + port: "COM5" + +session: + transport: serial + port: "${port}" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +steps: + - name: parse + format: json + source: '{"adc":{"voltage":12.1},"temp":42.5}' + save_as: "p" + - name: path + source: "${p}" + path: "adc.voltage" + save_as: "voltage" + - name: measure + metric: "voltage" + value: "${voltage}" + unit: "V" + - name: assert_range + value: "${measure.voltage}" + min: 11.8 + max: 12.3 + +artifacts: + dir: "./runs/v02_parse_measure_${now}" + raw_log: true + summary_json: true diff --git a/docs/examples/v02/profile_template.yaml b/docs/examples/v02/profile_template.yaml new file mode 100644 index 0000000..5403aae --- /dev/null +++ b/docs/examples/v02/profile_template.yaml @@ -0,0 +1,46 @@ +version: "0.2" + +params: + port: "COM7" + +profiles: + uart_common: + transport: serial + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +session: + profile: "uart_common" + port: "${port}" + +step_templates: + send_wait: + params: [cmd, expect_text] + steps: + - name: send + text: "${cmd}" + - name: expect + match: + type: contains + pattern: "${expect_text}" + +steps: + - template: send_wait + args: + cmd: "AT" + expect_text: "OK" + - template: send_wait + args: + cmd: "AT+GMR" + expect_text: "VER:" + +artifacts: + dir: "./runs/v02_profile_template_${now}" + raw_log: true + summary_json: true diff --git a/docs/where-archive/where-progress_20260306_065733.md b/docs/where-archive/where-progress_20260306_065733.md new file mode 100644 index 0000000..ba4f6ec --- /dev/null +++ b/docs/where-archive/where-progress_20260306_065733.md @@ -0,0 +1,18 @@ +# Plan: ProtoFlow 前端交互与性能统一治理(全站) +- [x] A. 基线盘点:已完成写死清单、交互问题清单与桌面壳性能基线采样记录(按当前迭代验收口径闭环) +- [x] B. 默认值配置化:新增 runtimeDefaults 并替换首批散落常量 +- [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口;补修窗口边缘吸附预览开关回归(恢复桌面壳拖拽吸附反馈),并修复标题栏拖拽仅在局部 mousemove 生效的问题(改为按下后全局监听);为消除卡顿与蓝框,移除前端吸附预览层与拖拽期高频 mousemove 计算;修复标题栏双击与拖拽武装竞态(双击前取消 arm);Windows 原生命中测试方案出现交互回归,已回退到可用基线(后续改为小步灰度) +- [x] C. 串口标准化:新增 serialPort 工具并接入 App/Proxy 串口入口;补修窗口边缘吸附预览开关回归(恢复桌面壳拖拽吸附反馈),并修复标题栏拖拽仅在局部 mousemove 生效的问题(改为按下后全局监听);为消除卡顿与蓝框,移除前端吸附预览层与拖拽期高频 mousemove 计算;修复标题栏双击与拖拽武装竞态(双击前取消 arm);Windows 原生命中测试方案出现交互回归,已回退到可用基线(后续改为小步灰度);新增 WebView 状态切换重绘补丁以降低全屏/贴边黑屏概率;默认串口显示从 COMx 收敛为首个有效端口/COM1,窗口启动改为按屏幕可用区域自适应尺寸并居中 +- [x] D. i18n 拆分:已迁移到 src/i18n/locales/*.ts + index.ts 映射,并修复 enUS 未定义导致的桌面壳启动阻塞(非 zh-CN 语言先回退 enUS) +- [x] E. Dropdown 契约补全:data-open/aria-expanded/aria-disabled 与禁用原因 +- [x] F. App 逻辑下沉:已完成 channel 端口与连接动作、通道同步刷新、bridge 挂载轮询初始化、日志缓冲刷新与状态发射、异步结果处理统一(utils/withResult)、通信批量解析、通信日志格式化导出、抓包帧映射与接入、快捷指令管理(增删改/弹窗/归一化/计数)等逻辑下沉到 composables,App.vue 主要保留页面编排与绑定,并修复 composable 初始化顺序导致的 refreshScriptVariables TDZ 启动报错 +- [x] G. Proxy 逻辑下沉:已接入 useCapturePanel 并完成抓包区/编辑弹窗/确认删除弹窗/列表卡片区块拆分 +- [x] H. Settings 逻辑下沉:已完成 SettingsPanels/SettingsHeader 视图拆分,并下沉设置持久化与 bridge I/O 逻辑 +- [x] I. 长列表性能优化:已完成 Proxy 抓包表 + LogStream 窗口化渲染与 watch 合并节流,并完成 App 视图异步分包与 CodeMirror 依赖延迟加载(主包告警消除,perf budget 仍满足) +- [x] J. 回归与门禁:已补测试矩阵、性能预算报告脚本、交互契约验收脚本(acceptance:interaction,含 Proxy 抓包交互接线与按钮无效态审计)与 ci:frontend 门禁,并新增 Dropdown/SettingsPanels/SettingsPersistence/SettingsBridge/ProtocolManager/ChannelDialogManager/PayloadSender/YamlDocumentOps/ScriptRunner/YamlSearch/ScriptLogHelpers/ScriptBridgeSignals/CommBridgeSignals/WindowChrome/ProtocolHeader/ProtocolCardsSection/ProtocolModal/ChannelDialog/UIYamlPreview/ProxyCapture 回归测试 +- [x] K1. Windows 自定义标题栏吸附专项:先按“可用优先”保持当前稳定基线(标题栏拖动与页面按钮可用) +- [!] K2. 灰度接入原生能力:原生 caption 实验在当前环境下未达到可用标准(补位推荐无效且易引发回归),暂不默认启用 +- [x] K3. 双路径策略:默认走手工吸附保底;原生实验改为显式开关(PROTOFLOW_NATIVE_CAPTION=1)按需验证 +- [ ] K4. 交互安全网:补桌面壳回归清单(拖动、双击、最小化/最大化、主体按钮点击、边缘吸附)并固化发布前验收 +- [x] L. 产品开关收敛:按实现阶段临时下线“代理监控”对外入口(侧栏导航隐藏 + 视图访问兜底回退) +- [x] M. YAML-DSL 核心功能范围:目标工况已确认并固化到 docs/YAML_DSL_CORE_TARGET_SCENARIOS.md diff --git a/docs/where-archive/where-progress_20260306_093231.md b/docs/where-archive/where-progress_20260306_093231.md new file mode 100644 index 0000000..1b3789c --- /dev/null +++ b/docs/where-archive/where-progress_20260306_093231.md @@ -0,0 +1,21 @@ +# Plan: YAML-DSL v0.2 可用性增强 +- [x] 锁定 v0.2 范围与验收标准(控制流/解析/产测/复用/受控执行) +- [x] 设计并固化 v0.2 Schema 扩展(if/loop/parse/measure/assert_range/include/profile/exec/file) +- [x] 实现受控控制流:if/else(基于 vars/match 结果) +- [x] 实现受控循环:loop(times 与 until) +- [x] 实现结构化解析 step:parse json/kv/csv +- [x] 实现 path 取值与结果写入 vars(a.b.c) +- [x] 实现产测指标 step:measure(指标采集) +- [x] 实现阈值断言 step:assert_range(min/max/abs_err/in_set) +- [x] 实现复用机制:include/import(流程片段复用) +- [x] 实现 step_templates(参数化步骤模板) +- [x] 实现 profile(会话配置复用) +- [x] 实现受控 exec step(白名单/路径限制/返回码记录) +- [x] 实现受控 file step(受限读写与落盘策略) +- [x] 增强通道能力:switch_session(动态切端口/波特率) +- [x] 统一错误码与结构化失败对象(v0.2 全步骤一致) +- [x] 完善执行日志与产物(step 级审计字段补齐) +- [x] 补充 v0.2 示例脚本(>=6 个:调试/升级/参数/产测/解析/复用) +- [x] 补充自动化回归(控制流/解析/阈值/受控执行/导出) +- [x] 完成 v0.2 文档(Schema、迁移说明、最佳实践) +- [x] 执行端到端回归并冻结 v0.2 发布清单 diff --git a/docs/where-archive/where-progress_20260306_103110.md b/docs/where-archive/where-progress_20260306_103110.md new file mode 100644 index 0000000..5225047 --- /dev/null +++ b/docs/where-archive/where-progress_20260306_103110.md @@ -0,0 +1,17 @@ +# Plan: 外置协议包统一架构(v0.3 起步) +- [x] 锁定“无内置协议包”原则与迁移边界(旧协议入口全部外置化) +- [x] 定义协议包目录契约(protocol.yaml/impl.py/README.md/vectors.yaml) +- [x] 定义 protocol.yaml schema(id/version/entry/api/config_schema/message_schema) +- [x] 定义统一 Python API 契约(send/recv/rpc)与上下文 ctx 能力边界 +- [x] 实现协议包扫描与校验器(缺文件/字段错误/版本冲突检测) +- [x] 实现协议包加载器(按目录动态加载并注册) +- [x] 实现协议调用网关(统一 dispatch send/recv/rpc) +- [x] 在 YAML-DSL 增加协议调用 step(protocol.send/protocol.recv/protocol.rpc) +- [x] 下线旧内置协议调用路径(modbus/xmodem/schema 直连入口移除) +- [x] 实现 vectors.yaml 执行器(协议包自测与回归入口) +- [x] 补充外置协议包示例(至少 2 个:modbus 与 xmodem) +- [x] 对接前端协议列表与调用能力(仅显示外置协议包) +- [x] 统一错误码与审计日志到协议包网关层 +- [x] 补充自动化测试(加载/调用/异常/向量回归) +- [x] 完成文档(开发指南、包模板、迁移说明) +- [x] 执行端到端回归并冻结“全外置协议包”发布清单 diff --git a/docs/where-archive/where-progress_20260306_105252.md b/docs/where-archive/where-progress_20260306_105252.md new file mode 100644 index 0000000..e58317c --- /dev/null +++ b/docs/where-archive/where-progress_20260306_105252.md @@ -0,0 +1,14 @@ +# Plan: Modbus 全功能三外置包(RTU/ASCII/TCP) +- [x] 定义统一 Modbus 数据模型与错误语义(request/response/exception) +- [x] 设计并冻结三包边界(modbus_rtu_pkg/modbus_ascii_pkg/modbus_tcp_pkg) +- [x] 重构 modbus_rtu_pkg 为全功能实现(03/04/05/06/0F/10 + 异常码) +- [x] 新增 modbus_ascii_pkg(LRC/':' 帧/CRLF/读写功能码) +- [x] 新增 modbus_tcp_pkg(MBAP 头/事务号/UnitID/读写功能码) +- [x] 增加统一编解码公共层并在三包复用(PDU 编码/异常解析) +- [x] 完成三包 protocol.yaml/README/vectors.yaml 全量示例 +- [x] 补齐向量测试矩阵(正常/异常码/超时/校验失败/边界值) +- [x] 更新 DSL 示例脚本(RTU/ASCII/TCP 各一套通讯流程) +- [x] 扩展回归脚本以覆盖三包 vectors 与基础调用 +- [x] 对接前端协议列表分类展示(modbus-rtu/modbus-ascii/modbus-tcp) +- [x] 完成文档(使用指南/选型建议/迁移说明) +- [x] 执行端到端回归并冻结 Modbus 三包发布清单 diff --git a/dsl_runtime/engine/channels.py b/dsl_runtime/engine/channels.py index 37c6d2e..75e7ab5 100644 --- a/dsl_runtime/engine/channels.py +++ b/dsl_runtime/engine/channels.py @@ -51,6 +51,9 @@ def read_event(self, timeout: float = 0.1): except Exception: return data.hex().upper() + def close(self) -> None: + return None + class SerialChannel(BaseChannel): def __init__(self, cfg: Dict[str, Any]) -> None: @@ -77,6 +80,12 @@ def read(self, size: int = 1, timeout: float = 1.0) -> bytes: time.sleep(0.01) return bytes(buf) + def close(self) -> None: + try: + self.ser.close() + except Exception: + pass + class TcpChannel(BaseChannel): def __init__(self, cfg: Dict[str, Any]) -> None: @@ -101,6 +110,12 @@ def read(self, size: int = 1, timeout: float = 1.0) -> bytes: continue return bytes(buf) + def close(self) -> None: + try: + self.sock.close() + except Exception: + pass + class LoggingChannel(BaseChannel): """Wrap a channel and log RX/TX to a file for debugging.""" @@ -144,6 +159,13 @@ def __getattr__(self, name): # Delegate everything else (e.g., close) return getattr(self.inner, name) + def close(self) -> None: + if hasattr(self.inner, "close"): + try: + self.inner.close() # type: ignore[attr-defined] + except Exception: + pass + def build_channels(cfg: Dict[str, Any]) -> Dict[str, BaseChannel]: channels: Dict[str, BaseChannel] = {} diff --git a/dsl_runtime/engine/context.py b/dsl_runtime/engine/context.py index 70945a3..5201d29 100644 --- a/dsl_runtime/engine/context.py +++ b/dsl_runtime/engine/context.py @@ -15,6 +15,7 @@ def __init__( channels: Dict[str, Any], default_channel: str, vars_init: Dict[str, Any], + params_init: Optional[Dict[str, Any]] = None, bus=None, external_events: Optional[list[str]] = None, script_path: Optional[str] = None, @@ -22,6 +23,7 @@ def __init__( ) -> None: self.channels = channels self.channel = channels[default_channel] + self.params: Dict[str, Any] = dict(params_init or {}) self.vars: Dict[str, Any] = dict(vars_init) self.logger = logging.getLogger("dsl") self.script_path = script_path @@ -44,7 +46,18 @@ def set_var(self, key: str, value: Any) -> None: self.vars[key] = value def vars_snapshot(self) -> Dict[str, Any]: - snap = dict(self.vars) + snap = dict(self.params) + for key, value in self.params.items(): + if isinstance(key, str) and key.isidentifier(): + snap[f"params.{key}"] = value + snap.update(self.vars) + for key, value in self.vars.items(): + if not isinstance(key, str): + continue + if isinstance(value, dict): + for sub_key, sub_val in value.items(): + if isinstance(sub_key, str) and sub_key: + snap[f"{key}.{sub_key}"] = sub_val snap["event"] = self._last_event snap["event_name"] = self._last_event_name snap["event_payload"] = self._last_event_payload diff --git a/dsl_runtime/engine/runner.py b/dsl_runtime/engine/runner.py index 53cb945..acc1d7d 100644 --- a/dsl_runtime/engine/runner.py +++ b/dsl_runtime/engine/runner.py @@ -3,8 +3,6 @@ import logging from dsl_runtime.actions.dsl_builtin_actions import register_builtin_actions -from dsl_runtime.actions.dsl_protocol_actions import register_protocol_actions -from dsl_runtime.actions.dsl_protocol_schema_actions import register_schema_protocol_actions from dsl_runtime.actions.dsl_chart_actions import register_chart_actions from dsl_runtime.actions.dsl_record_actions import register_record_actions from dsl_runtime.actions.dsl_data_actions import register_data_actions @@ -12,12 +10,12 @@ from dsl_runtime.lang.parser import parse_script from dsl_runtime.engine.channels import build_channels from dsl_runtime.engine.context import RuntimeContext +from dsl_runtime.engine.v01_artifacts import export_v01_artifacts +from dsl_runtime.engine.v01_executor import execute_v01 def _register_actions() -> None: register_builtin_actions() - register_protocol_actions() - register_schema_protocol_actions() register_chart_actions() register_record_actions() register_data_actions() @@ -28,6 +26,45 @@ def run_dsl(path: str, *, bus=None, external_events: list[str] | None = None) -> _register_actions() ast = parse_script(path) + if ast.version == "0.1": + if ast.session is None: + raise ValueError("session is required for YAML DSL v0.1") + channels = build_channels( + { + "default": { + "type": "serial", + "device": ast.session.port, + "baudrate": ast.session.baud, + } + } + ) + ctx = RuntimeContext( + channels, + "default", + vars_init=dict(ast.vars or {}), + params_init=dict(ast.params or {}), + bus=bus, + external_events=external_events, + script_path=path, + ) + try: + summary = execute_v01(ast, ctx) + output_dir = export_v01_artifacts(ast, summary) + if output_dir: + ctx.logger.info(f"[ARTIFACTS] {output_dir}") + if not summary.get("ok", False): + err = summary.get("error") or {} + raise RuntimeError(f"v0.1 execution failed: {err.get('code')}: {err.get('message')}") + finally: + if hasattr(ctx, "close"): + ctx.close() + for ch in channels.values(): + if hasattr(ch, "close"): + try: + ch.close() # type: ignore[attr-defined] + except Exception: + pass + return 0 channels = build_channels(ast.channels) if not channels: raise ValueError("未定义任何 channel") diff --git a/dsl_runtime/engine/v01_artifacts.py b/dsl_runtime/engine/v01_artifacts.py new file mode 100644 index 0000000..5ee5348 --- /dev/null +++ b/dsl_runtime/engine/v01_artifacts.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Dict + +from dsl_runtime.lang.ast_nodes import ScriptAST + + +_TPL_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + + +def _render_template(value: str, env: Dict[str, Any]) -> str: + def repl(match: re.Match[str]) -> str: + key = match.group(1) + return str(env.get(key, "")) + + return _TPL_RE.sub(repl, value) + + +def export_v01_artifacts(ast: ScriptAST, summary: Dict[str, Any]) -> str | None: + if ast.artifacts is None: + return None + + env: Dict[str, Any] = {"now": int(summary.get("started_at", 0))} + vars_map = summary.get("vars") + if isinstance(vars_map, dict): + env.update(vars_map) + + raw_dir = ast.artifacts.dir or "./runs" + out_dir = Path(_render_template(raw_dir, env)) + out_dir.mkdir(parents=True, exist_ok=True) + + if ast.artifacts.raw_log: + raw_path = out_dir / "raw_log.jsonl" + with raw_path.open("w", encoding="utf-8") as f: + for item in summary.get("steps", []): + f.write(json.dumps(item, ensure_ascii=False) + "\n") + + if ast.artifacts.summary_json: + summary_path = out_dir / "summary.json" + with summary_path.open("w", encoding="utf-8") as f: + json.dump(summary, f, ensure_ascii=False, indent=2) + + if ast.artifacts.report_csv: + csv_path = out_dir / "report.csv" + with csv_path.open("w", encoding="utf-8") as f: + f.write("index,step_id,name,status,elapsed_ms,error_code,error_message\n") + for item in summary.get("steps", []): + err = item.get("error") or {} + row = [ + item.get("index", ""), + item.get("step_id", ""), + item.get("name", ""), + item.get("status", ""), + item.get("elapsed_ms", ""), + err.get("code", ""), + str(err.get("message", "")).replace(",", " "), + ] + f.write(",".join(str(x) for x in row) + "\n") + + return str(out_dir) diff --git a/dsl_runtime/engine/v01_executor.py b/dsl_runtime/engine/v01_executor.py new file mode 100644 index 0000000..da28dbd --- /dev/null +++ b/dsl_runtime/engine/v01_executor.py @@ -0,0 +1,911 @@ +from __future__ import annotations + +import re +import time +import json +import csv +import io +import os +import shlex +import subprocess +from pathlib import Path +from typing import Any, Dict, List + +from dsl_runtime.lang.ast_nodes import ScriptAST +from dsl_runtime.lang.expression import eval_expr +from dsl_runtime.engine.channels import build_channels +from dsl_runtime.engine.context import RuntimeContext +from dsl_runtime.protocol_package import ProtocolPackageGateway, load_protocol_packages +from dsl_runtime.protocol_package.runtime import ProtocolCallContext + + +_EOL_MAP = { + "none": b"", + "cr": b"\r", + "lf": b"\n", + "crlf": b"\r\n", +} + +_TPL_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_\.]*)\}") + + +def _render_template(value: str, env: Dict[str, Any]) -> str: + def repl(match: re.Match[str]) -> str: + key = match.group(1) + if key in env: + return str(env.get(key, "")) + if "." in key: + try: + return str(_lookup_path(env, key)) + except Exception: + return "" + return str(env.get(key, "")) + + return _TPL_RE.sub(repl, value) + + +def _build_env(ctx: RuntimeContext, ast: ScriptAST) -> Dict[str, Any]: + env: Dict[str, Any] = {} + env.update(ctx.vars_snapshot()) + return env + + +def _render_object(value: Any, env: Dict[str, Any]) -> Any: + if isinstance(value, str): + return _render_template(value, env) + if isinstance(value, list): + return [_render_object(item, env) for item in value] + if isinstance(value, dict): + return {str(k): _render_object(v, env) for k, v in value.items()} + return value + + +def _resolve_protocol_packages_dir(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> Path: + env = _build_env(ctx, ast) + script_base = Path(ctx.script_path).resolve().parent if ctx.script_path else Path.cwd() + explicit = step.get("packages_dir") + if explicit is not None and str(explicit).strip(): + raw = _render_template(str(explicit), env) + p = Path(raw) + return p if p.is_absolute() else (script_base / p).resolve() + env_dir = os.getenv("PROTOFLOW_PROTOCOLS_DIR") + if env_dir: + p = Path(env_dir) + return p if p.is_absolute() else (script_base / p).resolve() + return (script_base / "protocols").resolve() + + +def _get_protocol_gateway(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> ProtocolPackageGateway: + root = _resolve_protocol_packages_dir(step, ast, ctx) + cache = getattr(ctx, "_protocol_gateway_cache", {}) + key = str(root) + gateway = cache.get(key) + if gateway is not None: + return gateway + load_result = load_protocol_packages(root) + gateway = ProtocolPackageGateway(load_result.packages) + cache[key] = gateway + setattr(ctx, "_protocol_gateway_cache", cache) + if load_result.issues: + ctx.logger.warning(f"[protocol] load issues at {root}: {len(load_result.issues)}") + return gateway + + +def _text_to_bytes(text: str, *, encoding: str, eol: str) -> bytes: + tail = _EOL_MAP.get(eol, b"") + if encoding == "hex": + payload = bytes.fromhex(text.replace(" ", "")) + return payload + tail + codec = "utf-8" if encoding == "utf8" else "ascii" + return text.encode(codec) + tail + + +def _run_send_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + session = ast.session + if session is None: + raise ValueError("session is required for v0.1") + + env = _build_env(ctx, ast) + encoding = str(step.get("encoding") or session.encoding).lower() + eol = str(step.get("eol") or session.eol).lower() + + if "hex" in step and step.get("hex") is not None: + raw_hex = _render_template(str(step.get("hex")), env) + payload = bytes.fromhex(raw_hex.replace(" ", "")) + else: + if "text" not in step: + raise ValueError("send step requires text or hex") + text = _render_template(str(step.get("text", "")), env) + payload = _text_to_bytes(text, encoding=encoding, eol=eol) + + ctx.channel_write(payload) + ctx.set_var("last_tx_hex", payload.hex().upper()) + + +def _match_text(match_cfg: Dict[str, Any], text: str) -> bool: + match_type = str(match_cfg.get("type", "contains")).strip().lower() + pattern = str(match_cfg.get("pattern", "")) + if not pattern: + raise ValueError("expect.match.pattern is required") + if match_type == "contains": + return pattern in text + if match_type == "startswith": + return text.startswith(pattern) + if match_type == "regex": + raw_flags = str(match_cfg.get("flags", "")) + flags = 0 + if "i" in raw_flags: + flags |= re.IGNORECASE + if "m" in raw_flags: + flags |= re.MULTILINE + if "s" in raw_flags: + flags |= re.DOTALL + return re.search(pattern, text, flags=flags) is not None + raise ValueError("expect.match.type must be contains/regex/startswith") + + +def _decode_rx(chunk: bytes, *, encoding: str) -> str: + if encoding == "hex": + return chunk.hex().upper() + codec = "utf-8" if encoding == "utf8" else "ascii" + return chunk.decode(codec, errors="ignore") + + +def _run_expect_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + session = ast.session + if session is None: + raise ValueError("session is required for v0.1") + + match_cfg = step.get("match") + if not isinstance(match_cfg, dict): + raise ValueError("expect.match is required") + + timeout_ms = int(step.get("timeout_ms", ast.defaults.timeout_ms)) + if timeout_ms <= 0: + raise ValueError("expect.timeout_ms must be > 0") + + encoding = str(step.get("encoding") or session.encoding).lower() + per_read_timeout = max(0.01, session.read_timeout_ms / 1000.0) + deadline = time.time() + (timeout_ms / 1000.0) + rx_buf = bytearray() + while time.time() < deadline: + remaining = max(0.01, deadline - time.time()) + chunk = ctx.channel.read(256, timeout=min(per_read_timeout, remaining)) + if not chunk: + continue + rx_buf.extend(chunk) + text = _decode_rx(bytes(rx_buf), encoding=encoding) + if _match_text(match_cfg, text): + ctx.set_var("last_rx_text", text) + ctx.set_var("last_rx_hex", bytes(rx_buf).hex().upper()) + captures = step.get("capture") + if captures: + _apply_capture_rules(text, captures, ast, ctx) + return + + raise TimeoutError("expect timeout: match not found") + + +def _apply_capture_rules(text: str, captures: Any, ast: ScriptAST, ctx: RuntimeContext) -> None: + if isinstance(captures, dict): + items = [captures] + elif isinstance(captures, list): + items = captures + else: + raise ValueError("capture must be a mapping or list") + + env = _build_env(ctx, ast) + for idx, item in enumerate(items): + if not isinstance(item, dict): + raise ValueError(f"capture[{idx}] must be a mapping") + var_name = str(item.get("var", "")).strip() + regex = item.get("regex") + if not var_name or not regex: + raise ValueError(f"capture[{idx}] requires var and regex") + pattern = _render_template(str(regex), env) + group = int(item.get("group", 1)) + match = re.search(pattern, text) + if match is None: + raise ValueError(f"capture[{idx}] regex not matched") + try: + value = match.group(group) + except IndexError as exc: + raise ValueError(f"capture[{idx}] group index out of range: {group}") from exc + ctx.set_var(var_name, value) + ctx.set_var("last_capture_var", var_name) + ctx.set_var("last_capture_value", value) + env[var_name] = value + + +def _run_capture_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + env = _build_env(ctx, ast) + source_raw = step.get("source", "${last_rx_text}") + source_text = _render_template(str(source_raw), env) + if not source_text: + raise ValueError("capture source is empty") + _apply_capture_rules(source_text, step, ast, ctx) + + +def _parse_kv_text(text: str) -> Dict[str, Any]: + result: Dict[str, Any] = {} + lines = text.replace(";", "\n").splitlines() + for raw in lines: + line = raw.strip() + if not line or "=" not in line: + continue + key, value = line.split("=", 1) + result[key.strip()] = value.strip() + return result + + +def _parse_csv_text(text: str) -> Any: + f = io.StringIO(text.strip()) + reader = csv.DictReader(f) + rows = [dict(row) for row in reader] + if len(rows) == 1: + return rows[0] + return rows + + +def _run_parse_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + env = _build_env(ctx, ast) + fmt = str(step.get("format", "")).strip().lower() + if fmt not in {"json", "kv", "csv"}: + raise ValueError("parse.format must be json/kv/csv") + source_raw = step.get("source", "${last_rx_text}") + source_text = _render_template(str(source_raw), env) + save_as = str(step.get("save_as", "parsed")).strip() or "parsed" + + if fmt == "json": + parsed = json.loads(source_text) + elif fmt == "kv": + parsed = _parse_kv_text(source_text) + else: + parsed = _parse_csv_text(source_text) + + ctx.set_var(save_as, parsed) + ctx.set_var("last_parsed", parsed) + + +def _lookup_path(obj: Any, path: str) -> Any: + current = obj + for token in path.split("."): + key = token.strip() + if not key: + continue + if isinstance(current, dict): + if key not in current: + raise KeyError(f"path segment not found: {key}") + current = current[key] + continue + if isinstance(current, list): + idx = int(key) + current = current[idx] + continue + raise KeyError(f"path segment not accessible on type: {type(current).__name__}") + return current + + +def _run_path_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + env = _build_env(ctx, ast) + source_raw = step.get("source", "${last_parsed}") + source_text = _render_template(str(source_raw), env) + source_obj: Any = source_text + # Prefer direct object when source points to a runtime variable name. + if isinstance(source_raw, str) and source_raw.startswith("${") and source_raw.endswith("}"): + key = source_raw[2:-1].strip() + vars_map = ctx.vars_snapshot() + if key in vars_map: + source_obj = vars_map[key] + path = str(step.get("path", "")).strip() + if not path: + raise ValueError("path.path is required") + save_as = str(step.get("save_as", "")).strip() + if not save_as: + raise ValueError("path.save_as is required") + value = _lookup_path(source_obj, path) + ctx.set_var(save_as, value) + ctx.set_var("last_path_value", value) + + +def _run_measure_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + env = _build_env(ctx, ast) + metric = str(step.get("metric", "")).strip() + if not metric: + raise ValueError("measure.metric is required") + raw_value = step.get("value") + if raw_value is None: + raise ValueError("measure.value is required") + value_text = _render_template(str(raw_value), env) + unit = str(step.get("unit", "")).strip() + item = {"metric": metric, "value": value_text, "unit": unit, "ts": time.time()} + metrics = ctx.vars.get("metrics") + if not isinstance(metrics, list): + metrics = [] + metrics.append(item) + ctx.set_var("metrics", metrics) + ctx.set_var(f"measure.{metric}", value_text) + ctx.set_var("last_measure", item) + + +def _to_float(value: Any) -> float: + if isinstance(value, (int, float)): + return float(value) + return float(str(value).strip()) + + +def _run_assert_range_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + env = _build_env(ctx, ast) + raw_value = step.get("value") + if raw_value is None: + raise ValueError("assert_range.value is required") + value = _to_float(_render_template(str(raw_value), env)) + message = str(step.get("message", "range assert failed")) + + checks = 0 + min_v = step.get("min") + if min_v is not None: + checks += 1 + if value < _to_float(_render_template(str(min_v), env)): + raise AssertionError(message) + + max_v = step.get("max") + if max_v is not None: + checks += 1 + if value > _to_float(_render_template(str(max_v), env)): + raise AssertionError(message) + + abs_err = step.get("abs_err") + target = step.get("target") + if abs_err is not None: + if target is None: + raise ValueError("assert_range.target is required when abs_err is set") + checks += 1 + delta = abs(value - _to_float(_render_template(str(target), env))) + if delta > _to_float(_render_template(str(abs_err), env)): + raise AssertionError(message) + + in_set = step.get("in_set") + if in_set is not None: + if not isinstance(in_set, list) or not in_set: + raise ValueError("assert_range.in_set must be a non-empty list") + checks += 1 + candidates = [_to_float(_render_template(str(item), env)) for item in in_set] + if value not in candidates: + raise AssertionError(message) + + if checks == 0: + raise ValueError("assert_range requires at least one rule: min/max/abs_err/in_set") + + +def _run_exec_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + env = _build_env(ctx, ast) + command_raw = step.get("command") + if command_raw is None: + raise ValueError("exec.command is required") + command = _render_template(str(command_raw), env).strip() + if not command: + raise ValueError("exec.command is empty") + + sec_cfg = ast.security.get("exec") if isinstance(ast.security, dict) else None + if not isinstance(sec_cfg, dict) or not bool(sec_cfg.get("enabled", False)): + raise PermissionError("EXEC_NOT_ALLOWED: security.exec.enabled is false") + + allow_commands = sec_cfg.get("allow_commands") or [] + if not isinstance(allow_commands, list) or not allow_commands: + raise PermissionError("EXEC_NOT_ALLOWED: security.exec.allow_commands is empty") + allow_set = {str(x).lower() for x in allow_commands} + + argv = shlex.split(command, posix=False) + if not argv: + raise ValueError("exec.command parse failed") + cmd_name = Path(argv[0]).name.lower() + if cmd_name not in allow_set: + raise PermissionError(f"EXEC_NOT_ALLOWED: command not in allowlist: {cmd_name}") + + cwd = _render_template(str(step.get("cwd", os.getcwd())), env) + allow_dirs = sec_cfg.get("cwd_allowlist") or [] + if not isinstance(allow_dirs, list): + raise PermissionError("EXEC_NOT_ALLOWED: security.exec.cwd_allowlist invalid") + cwd_resolved = str(Path(cwd).resolve()) + if allow_dirs: + allowed = False + for raw in allow_dirs: + p = str(Path(_render_template(str(raw), env)).resolve()) + if cwd_resolved.startswith(p): + allowed = True + break + if not allowed: + raise PermissionError("EXEC_NOT_ALLOWED: cwd not in allowlist") + + timeout_ms = int(step.get("timeout_ms", ast.defaults.timeout_ms)) + proc = subprocess.run( + argv, + cwd=cwd_resolved, + capture_output=True, + text=True, + timeout=max(1, timeout_ms) / 1000.0, + shell=False, + ) + + stdout = proc.stdout or "" + stderr = proc.stderr or "" + ctx.set_var("last_exec", {"command": command, "returncode": proc.returncode, "stdout": stdout, "stderr": stderr}) + ctx.set_var("last_exec_code", proc.returncode) + if step.get("save_stdout_as"): + ctx.set_var(str(step.get("save_stdout_as")), stdout) + if step.get("save_stderr_as"): + ctx.set_var(str(step.get("save_stderr_as")), stderr) + if proc.returncode != 0: + raise RuntimeError(f"EXEC_FAILED: returncode={proc.returncode}") + + +def _is_path_allowed(path: Path, allow_roots: List[str], env: Dict[str, Any]) -> bool: + if not allow_roots: + return False + target = str(path.resolve()) + for raw in allow_roots: + root = str(Path(_render_template(str(raw), env)).resolve()) + if target.startswith(root): + return True + return False + + +def _run_file_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + env = _build_env(ctx, ast) + op = str(step.get("op", "")).strip().lower() + if op not in {"read_text", "write_text", "append_text", "exists"}: + raise ValueError("file.op must be read_text/write_text/append_text/exists") + raw_path = step.get("path") + if raw_path is None: + raise ValueError("file.path is required") + path = Path(_render_template(str(raw_path), env)).resolve() + + sec_cfg = ast.security.get("file") if isinstance(ast.security, dict) else None + allow_roots = [] + if isinstance(sec_cfg, dict): + allow_roots = sec_cfg.get("root_allowlist") or [] + if not isinstance(allow_roots, list): + raise PermissionError("FILE_NOT_ALLOWED: security.file.root_allowlist invalid") + if not _is_path_allowed(path, allow_roots, env): + raise PermissionError("FILE_NOT_ALLOWED: path not in allowlist") + + if op == "write_text": + content = _render_template(str(step.get("content", "")), env) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + ctx.set_var("last_file", {"op": op, "path": str(path), "bytes": len(content.encode("utf-8"))}) + return + + if op == "append_text": + content = _render_template(str(step.get("content", "")), env) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as f: + f.write(content) + ctx.set_var("last_file", {"op": op, "path": str(path), "bytes": len(content.encode("utf-8"))}) + return + + if op == "exists": + save_as = str(step.get("save_as", "file_exists")).strip() or "file_exists" + ok = path.exists() + ctx.set_var(save_as, ok) + ctx.set_var("last_file", {"op": op, "path": str(path), "exists": ok}) + return + + # read_text + text = path.read_text(encoding="utf-8") + save_as = str(step.get("save_as", "file_text")).strip() or "file_text" + ctx.set_var(save_as, text) + ctx.set_var("last_file", {"op": op, "path": str(path), "bytes": len(text.encode("utf-8"))}) + + +def _run_switch_session_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + session = ast.session + if session is None: + raise ValueError("session is required") + env = _build_env(ctx, ast) + + next_port = _render_template(str(step.get("port", session.port)), env).strip() + next_baud = int(_render_template(str(step.get("baud", session.baud)), env)) + next_data_bits = int(_render_template(str(step.get("data_bits", session.data_bits)), env)) + next_parity = _render_template(str(step.get("parity", session.parity)), env).strip().lower() + next_stop_bits = int(_render_template(str(step.get("stop_bits", session.stop_bits)), env)) + next_encoding = _render_template(str(step.get("encoding", session.encoding)), env).strip().lower() + next_eol = _render_template(str(step.get("eol", session.eol)), env).strip().lower() + dry_run = bool(step.get("dry_run", False)) + + if not dry_run: + channels = build_channels({"default": {"type": "serial", "device": next_port, "baudrate": next_baud}}) + old = ctx.channel + ctx.channel = channels["default"] + ctx.channels["default"] = channels["default"] + if hasattr(old, "close"): + try: + old.close() # type: ignore[attr-defined] + except Exception: + pass + + session.port = next_port + session.baud = next_baud + session.data_bits = next_data_bits + session.parity = next_parity + session.stop_bits = next_stop_bits + session.encoding = next_encoding + session.eol = next_eol + ctx.set_var( + "last_session", + { + "port": next_port, + "baud": next_baud, + "data_bits": next_data_bits, + "parity": next_parity, + "stop_bits": next_stop_bits, + "encoding": next_encoding, + "eol": next_eol, + "dry_run": dry_run, + }, + ) + + +def _resolve_protocol_method(step_name: str, step: Dict[str, Any]) -> str: + explicit = str(step.get("method", "")).strip().lower() + if explicit: + return explicit + if "." in step_name: + return step_name.split(".", 1)[1].strip().lower() + raise ValueError("protocol step requires method") + + +def _run_protocol_step(step_name: str, step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + method = _resolve_protocol_method(step_name, step) + if method not in {"send", "recv", "rpc"}: + raise ValueError("protocol method must be send/recv/rpc") + + protocol_id = str(step.get("protocol", "")).strip() + if not protocol_id: + raise ValueError("protocol step requires protocol id") + + if method == "recv": + raw_payload = step.get("expect", step.get("payload", {})) + else: + raw_payload = step.get("request", step.get("payload", {})) + if raw_payload is None: + raw_payload = {} + if not isinstance(raw_payload, dict): + raise ValueError("protocol payload must be a mapping") + + env = _build_env(ctx, ast) + payload = _render_object(raw_payload, env) + timeout_ms = int(step.get("timeout_ms", ast.defaults.timeout_ms)) + if timeout_ms <= 0: + raise ValueError("protocol.timeout_ms must be > 0") + + gateway = _get_protocol_gateway(step, ast, ctx) + call_ctx = ProtocolCallContext( + channel=ctx.channel, + logger=ctx.logger, + vars=ctx.vars_snapshot(), + timeout_ms=timeout_ms, + artifacts={}, + ) + result = gateway.call(protocol_id=protocol_id, method=method, ctx=call_ctx, payload=payload) + if not result.ok: + error = result.error or {} + code = error.get("code", "PROTOCOL_CALL_FAILED") + message = error.get("message", "protocol call failed") + raise RuntimeError(f"{code}: {message}") + + save_as = str(step.get("save_as", "last_protocol_result")).strip() or "last_protocol_result" + ctx.set_var(save_as, result.data) + ctx.set_var( + "last_protocol_call", + { + "protocol": protocol_id, + "method": method, + "timeout_ms": timeout_ms, + "result": result.data, + }, + ) + + +def _run_sleep_step(step: Dict[str, Any]) -> None: + ms = int(step.get("ms", 0)) + if ms < 0: + raise ValueError("sleep.ms must be >= 0") + if ms == 0: + return + time.sleep(ms / 1000.0) + + +def _eval_assert_expr(expr: str, env: Dict[str, Any]) -> bool: + # Reuse existing expression engine by mapping ${var} -> $var. + normalized = re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_\.]*)\}", r"$\1", expr) + return bool(eval_expr(normalized, env)) + + +def _assert_clause(clause: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> bool: + if not isinstance(clause, dict): + raise ValueError("assert clause must be a mapping") + env = _build_env(ctx, ast) + if "expr" in clause: + return _eval_assert_expr(str(clause.get("expr", "")), env) + if "match" in clause: + match_cfg = clause.get("match") + if not isinstance(match_cfg, dict): + raise ValueError("assert.match must be a mapping") + source_raw = clause.get("source", "${last_rx_text}") + source_text = _render_template(str(source_raw), env) + return _match_text(match_cfg, source_text) + raise ValueError("assert clause requires expr or match") + + +def _run_assert_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + message = str(step.get("message", "assert failed")) + if "all" in step: + all_items = step.get("all") + if not isinstance(all_items, list) or not all_items: + raise ValueError("assert.all must be a non-empty list") + result = all(_assert_clause(item, ast, ctx) for item in all_items) + elif "any" in step: + any_items = step.get("any") + if not isinstance(any_items, list) or not any_items: + raise ValueError("assert.any must be a non-empty list") + result = any(_assert_clause(item, ast, ctx) for item in any_items) + else: + result = _assert_clause(step, ast, ctx) + if not result: + raise AssertionError(message) + + +def _run_if_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + when_expr = str(step.get("when", "")).strip() + if not when_expr: + raise ValueError("if.when is required") + env = _build_env(ctx, ast) + cond = _eval_assert_expr(when_expr, env) + branch_key = "then" if cond else "else" + branch_steps = step.get(branch_key, []) + if branch_steps is None: + branch_steps = [] + if not isinstance(branch_steps, list): + raise ValueError(f"if.{branch_key} must be a list") + for idx, child in enumerate(branch_steps): + if not isinstance(child, dict): + raise ValueError(f"if.{branch_key}[{idx}] must be a mapping") + _run_step_with_reliability(child, ast, ctx) + + +def _run_loop_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + body = step.get("steps") + if not isinstance(body, list) or not body: + raise ValueError("loop.steps must be a non-empty list") + times = step.get("times") + until = step.get("until") + if times is None and not until: + raise ValueError("loop requires times or until") + max_rounds = int(times) if times is not None else 1000000 + if max_rounds < 0: + raise ValueError("loop.times must be >= 0") + + rounds = 0 + while rounds < max_rounds: + if until: + env = _build_env(ctx, ast) + if _eval_assert_expr(str(until), env): + break + for idx, child in enumerate(body): + if not isinstance(child, dict): + raise ValueError(f"loop.steps[{idx}] must be a mapping") + _run_step_with_reliability(child, ast, ctx) + rounds += 1 + ctx.set_var("last_loop_round", rounds) + + +def _resolve_retry(step: Dict[str, Any], ast: ScriptAST) -> Dict[str, int | str]: + retry_cfg = step.get("retry") + if retry_cfg is None: + count = int(ast.defaults.retry.count) + backoff_ms = int(ast.defaults.retry.backoff_ms) + strategy = str(ast.defaults.retry.strategy) + return {"count": count, "backoff_ms": backoff_ms, "strategy": strategy} + if not isinstance(retry_cfg, dict): + raise ValueError("step.retry must be a mapping") + count = int(retry_cfg.get("count", ast.defaults.retry.count)) + backoff_ms = int(retry_cfg.get("backoff_ms", ast.defaults.retry.backoff_ms)) + strategy = str(retry_cfg.get("strategy", ast.defaults.retry.strategy)) + if count < 0: + raise ValueError("step.retry.count must be >= 0") + if backoff_ms < 0: + raise ValueError("step.retry.backoff_ms must be >= 0") + if strategy not in {"fixed", "exponential"}: + raise ValueError("step.retry.strategy must be fixed or exponential") + return {"count": count, "backoff_ms": backoff_ms, "strategy": strategy} + + +def _dispatch_step(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> None: + name = str(step.get("name", "")).strip().lower() + if not name: + raise ValueError("step.name is required") + if name == "send": + _run_send_step(step, ast, ctx) + return + if name == "expect": + _run_expect_step(step, ast, ctx) + return + if name == "sleep": + _run_sleep_step(step) + return + if name == "capture": + _run_capture_step(step, ast, ctx) + return + if name == "parse": + _run_parse_step(step, ast, ctx) + return + if name == "path": + _run_path_step(step, ast, ctx) + return + if name == "measure": + _run_measure_step(step, ast, ctx) + return + if name == "assert_range": + _run_assert_range_step(step, ast, ctx) + return + if name == "exec": + _run_exec_step(step, ast, ctx) + return + if name == "file": + _run_file_step(step, ast, ctx) + return + if name == "switch_session": + _run_switch_session_step(step, ast, ctx) + return + if name in {"protocol.send", "protocol.recv", "protocol.rpc", "protocol"}: + _run_protocol_step(name, step, ast, ctx) + return + if name == "assert": + _run_assert_step(step, ast, ctx) + return + if name == "if": + _run_if_step(step, ast, ctx) + return + if name == "loop": + _run_loop_step(step, ast, ctx) + return + raise NotImplementedError(f"v0.1 step not implemented yet: {name}") + + +def _run_on_fail(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> int: + hooks = step.get("on_fail") + if hooks is None: + return 0 + if not isinstance(hooks, list): + raise ValueError("step.on_fail must be a list") + ran = 0 + for idx, hook in enumerate(hooks): + if not isinstance(hook, dict): + raise ValueError(f"step.on_fail[{idx}] must be a mapping") + try: + _dispatch_step(hook, ast, ctx) + ran += 1 + except Exception as exc: + ctx.logger.warning(f"on_fail step ignored due to error: {exc}") + return ran + + +def _run_step_with_reliability(step: Dict[str, Any], ast: ScriptAST, ctx: RuntimeContext) -> Dict[str, Any]: + retry = _resolve_retry(step, ast) + count = int(retry["count"]) + backoff_ms = int(retry["backoff_ms"]) + strategy = str(retry["strategy"]) + last_error: Exception | None = None + attempts = 0 + + for attempt in range(count + 1): + attempts += 1 + try: + _dispatch_step(step, ast, ctx) + return {"attempts": attempts, "retry_count": count, "on_fail_steps": 0} + except Exception as exc: + last_error = exc + if attempt >= count: + break + delay_ms = backoff_ms if strategy == "fixed" else backoff_ms * (2**attempt) + if delay_ms > 0: + time.sleep(delay_ms / 1000.0) + + on_fail_steps = 0 + try: + on_fail_steps = _run_on_fail(step, ast, ctx) + finally: + if last_error is not None: + raise last_error + return {"attempts": attempts, "retry_count": count, "on_fail_steps": on_fail_steps} + + +def _map_error_code(exc: Exception) -> str: + msg = str(exc) + if isinstance(exc, PermissionError): + if "EXEC_NOT_ALLOWED" in msg: + return "EXEC_NOT_ALLOWED" + if "FILE_NOT_ALLOWED" in msg: + return "FILE_NOT_ALLOWED" + return "PERMISSION_DENIED" + if isinstance(exc, TimeoutError): + return "STEP_TIMEOUT" + if isinstance(exc, AssertionError): + return "ASSERT_FAILED" + if isinstance(exc, NotImplementedError): + return "STEP_NOT_IMPLEMENTED" + if isinstance(exc, RuntimeError): + if "EXEC_FAILED" in msg: + return "EXEC_FAILED" + if "PROTOCOL_" in msg: + return "PROTOCOL_CALL_FAILED" + return "RUNTIME_FAILED" + if isinstance(exc, FileNotFoundError): + return "FILE_FAILED" + if isinstance(exc, ValueError): + if "parse." in msg: + return "PARSE_FAILED" + if "capture" in msg: + return "CAPTURE_FAILED" + if "range" in msg: + return "RANGE_ASSERT_FAILED" + return "VALIDATION_FAILED" + return "UNEXPECTED_EXCEPTION" + + +def _error_payload(exc: Exception, step_id: str) -> Dict[str, Any]: + return { + "code": _map_error_code(exc), + "message": str(exc), + "step_id": step_id, + "ts": time.time(), + } + + +def execute_v01(ast: ScriptAST, ctx: RuntimeContext) -> Dict[str, Any]: + started_at = time.time() + traces: List[Dict[str, Any]] = [] + error: Dict[str, Any] | None = None + + for idx, step in enumerate(ast.steps): + step_id = str(step.get("id") or f"step_{idx + 1}") + step_name = str(step.get("name") or "") + t0 = time.time() + trace: Dict[str, Any] = { + "index": idx, + "step_id": step_id, + "name": step_name, + "started_at": t0, + } + try: + reliability = _run_step_with_reliability(step, ast, ctx) + trace["status"] = "ok" + trace.update(reliability) + except Exception as exc: + trace["status"] = "error" + trace["error"] = _error_payload(exc, step_id) + error = _error_payload(exc, step_id) + traces.append(trace) + break + finally: + trace["ended_at"] = time.time() + trace["elapsed_ms"] = int((trace["ended_at"] - trace["started_at"]) * 1000) + vars_snap = ctx.vars_snapshot() + trace["last_tx_hex"] = vars_snap.get("last_tx_hex") + trace["last_rx_text"] = vars_snap.get("last_rx_text") + trace["last_rx_hex"] = vars_snap.get("last_rx_hex") + if trace not in traces: + traces.append(trace) + + ended_at = time.time() + return { + "ok": error is None, + "error": error, + "started_at": started_at, + "ended_at": ended_at, + "elapsed_ms": int((ended_at - started_at) * 1000), + "steps": traces, + "vars": ctx.vars_snapshot(), + } diff --git a/dsl_runtime/lang/ast_nodes.py b/dsl_runtime/lang/ast_nodes.py index 41e8127..bfb93c8 100644 --- a/dsl_runtime/lang/ast_nodes.py +++ b/dsl_runtime/lang/ast_nodes.py @@ -87,10 +87,54 @@ class UIConfig: layout: Optional[LayoutNode] = None +@dataclass +class SessionConfig: + transport: str + port: str + baud: int = 115200 + data_bits: int = 8 + parity: str = "none" + stop_bits: int = 1 + encoding: str = "ascii" + eol: str = "none" + open_timeout_ms: int = 3000 + read_timeout_ms: int = 200 + + +@dataclass +class RetryPolicy: + count: int = 0 + backoff_ms: int = 0 + strategy: str = "fixed" + + +@dataclass +class DefaultsConfig: + timeout_ms: int = 2000 + retry: RetryPolicy = field(default_factory=RetryPolicy) + drain_before_expect: bool = False + consume_on_match: bool = True + + +@dataclass +class ArtifactsConfig: + dir: str = "./runs" + raw_log: bool = True + summary_json: bool = True + report_csv: bool = False + + @dataclass class ScriptAST: - version: int - vars: Dict[str, Any] - channels: Dict[str, Dict[str, Any]] - state_machine: StateMachine + version: str + params: Dict[str, Any] = field(default_factory=dict) + vars: Dict[str, Any] = field(default_factory=dict) + session: Optional[SessionConfig] = None + defaults: DefaultsConfig = field(default_factory=DefaultsConfig) + steps: List[Dict[str, Any]] = field(default_factory=list) + artifacts: ArtifactsConfig = field(default_factory=ArtifactsConfig) + security: Dict[str, Any] = field(default_factory=dict) + # Legacy fields kept as optional placeholders while v0.1 runtime is phased in. + channels: Dict[str, Dict[str, Any]] = field(default_factory=dict) + state_machine: Optional[StateMachine] = None ui: UIConfig = field(default_factory=UIConfig) diff --git a/dsl_runtime/lang/parser.py b/dsl_runtime/lang/parser.py index e165894..3850590 100644 --- a/dsl_runtime/lang/parser.py +++ b/dsl_runtime/lang/parser.py @@ -1,223 +1,286 @@ -from __future__ import annotations +from __future__ import annotations +from copy import deepcopy +from pathlib import Path from typing import Any, Dict, List import yaml -from dsl_runtime.lang.ast_nodes import ( - ActionCall, - ChartSpec, - ControlActionSpec, - ControlInputSpec, - ControlSpec, - LayoutNode, - ScriptAST, - State, - StateMachine, - UIConfig, -) - - -def _parse_actions(items: List[Any]) -> List[ActionCall]: - actions: List[ActionCall] = [] - for item in items or []: - if not isinstance(item, dict): - raise ValueError(f"非法动作定义: {item}") - if "action" in item: - actions.append(ActionCall(name=item["action"], args=item.get("args", {}) or {})) - elif "if" in item: - actions.append(ActionCall(name="if", args=item.get("if", {}) or {})) - elif "set" in item: - actions.append(ActionCall(name="set", args=item["set"])) - elif "log" in item: - actions.append(ActionCall(name="log", args={"message": item["log"]})) - elif "wait" in item: - args = item["wait"] if isinstance(item["wait"], dict) else {"ms": item["wait"]} - actions.append(ActionCall(name="wait", args=args)) - elif "wait_for_event" in item: - args = item["wait_for_event"] if isinstance(item["wait_for_event"], dict) else {"event": item["wait_for_event"]} - actions.append(ActionCall(name="wait_for_event", args=args)) - else: - raise ValueError(f"未知动作类型: {item}") - return actions - - -def _parse_state(name: str, node: Dict[str, Any]) -> State: - return State( - name=name, - actions=_parse_actions(node.get("do", [])), - on_event=node.get("on_event", {}) or {}, - timeout=node.get("timeout"), - on_timeout=node.get("on_timeout"), - when=node.get("when"), - goto=node.get("goto"), - else_goto=node.get("else_goto"), +from dsl_runtime.lang.ast_nodes import ArtifactsConfig, DefaultsConfig, RetryPolicy, ScriptAST, SessionConfig + + +_ALLOWED_PARITY = {"none", "odd", "even", "mark", "space"} +_ALLOWED_ENCODING = {"ascii", "utf8", "hex"} +_ALLOWED_EOL = {"none", "cr", "lf", "crlf"} +_ALLOWED_RETRY_STRATEGY = {"fixed", "exponential"} + + +def _as_mapping(value: Any, *, field: str, required: bool = False) -> Dict[str, Any]: + if value is None: + if required: + raise ValueError(f"{field} is required") + return {} + if not isinstance(value, dict): + raise ValueError(f"{field} must be a mapping") + return value + + +def _as_int(value: Any, *, field: str, minimum: int | None = None) -> int: + if isinstance(value, bool): + raise ValueError(f"{field} must be an integer") + try: + parsed = int(value) + except Exception as exc: + raise ValueError(f"{field} must be an integer") from exc + if minimum is not None and parsed < minimum: + raise ValueError(f"{field} must be >= {minimum}") + return parsed + + +def _as_bool(value: Any, *, field: str) -> bool: + if isinstance(value, bool): + return value + raise ValueError(f"{field} must be a boolean") + + +def _as_str(value: Any, *, field: str, required: bool = False) -> str: + if value is None: + if required: + raise ValueError(f"{field} is required") + return "" + parsed = str(value).strip() + if required and not parsed: + raise ValueError(f"{field} is required") + return parsed + + +def _parse_session(session_data: Dict[str, Any]) -> SessionConfig: + transport = _as_str(session_data.get("transport"), field="session.transport", required=True).lower() + if transport != "serial": + raise ValueError("session.transport must be serial in v0.1") + + port = _as_str(session_data.get("port"), field="session.port", required=True) + baud = _as_int(session_data.get("baud", 115200), field="session.baud", minimum=1) + data_bits = _as_int(session_data.get("data_bits", 8), field="session.data_bits", minimum=5) + if data_bits not in {5, 6, 7, 8}: + raise ValueError("session.data_bits must be one of 5/6/7/8") + + parity = _as_str(session_data.get("parity", "none"), field="session.parity", required=True).lower() + if parity not in _ALLOWED_PARITY: + raise ValueError("session.parity must be one of none/odd/even/mark/space") + + stop_bits = _as_int(session_data.get("stop_bits", 1), field="session.stop_bits", minimum=1) + if stop_bits not in {1, 2}: + raise ValueError("session.stop_bits must be 1 or 2 in v0.1") + + encoding = _as_str(session_data.get("encoding", "ascii"), field="session.encoding", required=True).lower() + if encoding not in _ALLOWED_ENCODING: + raise ValueError("session.encoding must be one of ascii/utf8/hex") + + eol = _as_str(session_data.get("eol", "none"), field="session.eol", required=True).lower() + if eol not in _ALLOWED_EOL: + raise ValueError("session.eol must be one of none/cr/lf/crlf") + + open_timeout_ms = _as_int(session_data.get("open_timeout_ms", 3000), field="session.open_timeout_ms", minimum=1) + read_timeout_ms = _as_int(session_data.get("read_timeout_ms", 200), field="session.read_timeout_ms", minimum=1) + + return SessionConfig( + transport=transport, + port=port, + baud=baud, + data_bits=data_bits, + parity=parity, + stop_bits=stop_bits, + encoding=encoding, + eol=eol, + open_timeout_ms=open_timeout_ms, + read_timeout_ms=read_timeout_ms, ) -def _parse_ui(ui_data: Dict[str, Any]) -> UIConfig: - charts_cfg = ui_data.get("charts") or [] - charts = [] - for idx, item in enumerate(charts_cfg): - if not isinstance(item, dict): - raise ValueError(f"ui.charts[{idx}] must be a mapping") - cid = str(item.get("id") or f"chart_{idx}") - chart_type = str(item.get("type", "line")).lower() - bind = item.get("bind") - if chart_type == "scatter3d" and not bind: - bind = item.get("bind_z") or item.get("bind_y") or item.get("bind_x") or cid - if not bind: - raise ValueError(f"ui.charts[{idx}] missing bind") - group = item.get("group") - separate = bool(item.get("separate", False)) - if group and separate: - raise ValueError(f"ui.charts[{idx}] cannot have both group and separate") - bind_x = item.get("bind_x") - bind_y = item.get("bind_y") - bind_z = item.get("bind_z") - if chart_type == "scatter3d": - if not (bind_x and bind_y and bind_z): - raise ValueError(f"ui.charts[{idx}] scatter3d requires bind_x/bind_y/bind_z") - charts.append( - ChartSpec( - id=cid, - title=str(item.get("title", cid)), - bind=str(bind), - chart_type=chart_type, - bind_x=str(bind_x) if bind_x else None, - bind_y=str(bind_y) if bind_y else None, - bind_z=str(bind_z) if bind_z else None, - group=str(group) if group else None, - separate=separate, - max_points=int(item.get("max_points", 1000)), - ) - ) - return UIConfig(charts=charts) - # Controls are added later - - -def _parse_controls(ui_data: Dict[str, Any]) -> List[ControlSpec]: - controls_cfg = ui_data.get("controls") or [] - controls: List[ControlSpec] = [] - for idx, item in enumerate(controls_cfg): - if not isinstance(item, dict): - raise ValueError(f"ui.controls[{idx}] must be a mapping") - cid = str(item.get("id") or f"control_{idx}") - title = str(item.get("title") or cid) - separate = bool(item.get("separate", True)) - - inputs_cfg = item.get("inputs") or [] - inputs: List[ControlInputSpec] = [] - for jdx, inp in enumerate(inputs_cfg): - if not isinstance(inp, dict): - raise ValueError(f"ui.controls[{idx}].inputs[{jdx}] must be a mapping") - name = inp.get("name") - if not name: - raise ValueError(f"ui.controls[{idx}].inputs[{jdx}] missing name") - itype = str(inp.get("type", "float")).lower() - label = str(inp.get("label", name)) - options = inp.get("options") or [] - if itype == "select" and not isinstance(options, list): - raise ValueError(f"ui.controls[{idx}].inputs[{jdx}].options must be list") - inputs.append( - ControlInputSpec( - name=str(name), - label=label, - itype=itype, - minimum=inp.get("min"), - maximum=inp.get("max"), - step=inp.get("step"), - default=inp.get("default"), - options=[str(opt) for opt in options] if options else [], - placeholder=inp.get("placeholder"), - ) - ) - - actions_cfg = item.get("actions") or {} - actions: List[ControlActionSpec] = [] - for act_name, act_def in actions_cfg.items(): - if not isinstance(act_def, dict): - raise ValueError(f"ui.controls[{idx}].actions.{act_name} must be a mapping") - emit = act_def.get("emit") - if not emit: - raise ValueError(f"ui.controls[{idx}].actions.{act_name} missing emit") - label = str(act_def.get("label", act_name)) - actions.append(ControlActionSpec(name=str(act_name), emit=str(emit), label=label)) - - controls.append( - ControlSpec( - id=cid, - title=title, - separate=separate, - inputs=inputs, - actions=actions, - ) - ) - return controls - - -def _parse_layout_node(node: Dict[str, Any]) -> LayoutNode: - if "split" in node: - orient_raw = node.get("split") - orientation = str(orient_raw).lower() - if orientation not in {"horizontal", "vertical"}: - raise ValueError(f"layout split orientation invalid: {orient_raw}") - children: List[LayoutNode] = [] - for key in ("left", "right", "top", "bottom", "children"): - child_cfg = node.get(key) - if child_cfg: - if key == "children": - if not isinstance(child_cfg, list): - raise ValueError("layout.children must be list") - children.extend(_parse_layout_node(item) for item in child_cfg) - else: - if not isinstance(child_cfg, dict): - raise ValueError(f"layout {key} must be mapping") - children.append(_parse_layout_node(child_cfg)) - if not children: - raise ValueError("layout split requires children") - return LayoutNode(type="split", orientation=orientation, children=children) - - charts = node.get("charts") or [] - controls = node.get("controls") or [] - if not charts and not controls: - raise ValueError("layout leaf requires charts or controls") - if charts and not isinstance(charts, list): - raise ValueError("layout leaf charts must be list") - if controls and not isinstance(controls, list): - raise ValueError("layout leaf controls must be list") - return LayoutNode(type="leaf", charts=[str(c) for c in charts], controls=[str(c) for c in controls]) - - -def _parse_layout(ui_data: Dict[str, Any]) -> LayoutNode | None: - layout_cfg = ui_data.get("layout") - if not layout_cfg: - return None - if not isinstance(layout_cfg, dict): - raise ValueError("ui.layout must be a mapping") - return _parse_layout_node(layout_cfg) +def _parse_defaults(defaults_data: Dict[str, Any]) -> DefaultsConfig: + retry_data = _as_mapping(defaults_data.get("retry"), field="defaults.retry") + strategy = _as_str(retry_data.get("strategy", "fixed"), field="defaults.retry.strategy", required=True).lower() + if strategy not in _ALLOWED_RETRY_STRATEGY: + raise ValueError("defaults.retry.strategy must be fixed or exponential") + + retry = RetryPolicy( + count=_as_int(retry_data.get("count", 0), field="defaults.retry.count", minimum=0), + backoff_ms=_as_int(retry_data.get("backoff_ms", 0), field="defaults.retry.backoff_ms", minimum=0), + strategy=strategy, + ) + + return DefaultsConfig( + timeout_ms=_as_int(defaults_data.get("timeout_ms", 2000), field="defaults.timeout_ms", minimum=1), + retry=retry, + drain_before_expect=_as_bool( + defaults_data.get("drain_before_expect", False), field="defaults.drain_before_expect" + ), + consume_on_match=_as_bool(defaults_data.get("consume_on_match", True), field="defaults.consume_on_match"), + ) + + +def _parse_artifacts(artifacts_data: Dict[str, Any]) -> ArtifactsConfig: + return ArtifactsConfig( + dir=_as_str(artifacts_data.get("dir", "./runs"), field="artifacts.dir") or "./runs", + raw_log=_as_bool(artifacts_data.get("raw_log", True), field="artifacts.raw_log"), + summary_json=_as_bool(artifacts_data.get("summary_json", True), field="artifacts.summary_json"), + report_csv=_as_bool(artifacts_data.get("report_csv", False), field="artifacts.report_csv"), + ) + + +def _parse_steps(steps_data: Any) -> List[Dict[str, Any]]: + if not isinstance(steps_data, list): + raise ValueError("steps must be a list") + parsed: List[Dict[str, Any]] = [] + for idx, step in enumerate(steps_data): + if not isinstance(step, dict): + raise ValueError(f"steps[{idx}] must be a mapping") + name = step.get("name") + template_ref = step.get("template") + if (not isinstance(name, str) or not name.strip()) and (not isinstance(template_ref, str) or not template_ref.strip()): + raise ValueError(f"steps[{idx}].name or steps[{idx}].template is required") + parsed.append(step) + return parsed + + +def _replace_tokens(obj: Any, values: Dict[str, Any]) -> Any: + if isinstance(obj, str): + out = obj + for k, v in values.items(): + out = out.replace("${" + str(k) + "}", str(v)) + return out + if isinstance(obj, list): + return [_replace_tokens(item, values) for item in obj] + if isinstance(obj, dict): + return {key: _replace_tokens(value, values) for key, value in obj.items()} + return obj + + +def _expand_step_templates(steps: List[Dict[str, Any]], templates_data: Any) -> List[Dict[str, Any]]: + if templates_data is None: + return steps + if not isinstance(templates_data, dict): + raise ValueError("step_templates must be a mapping") + expanded: List[Dict[str, Any]] = [] + for idx, step in enumerate(steps): + template_name = step.get("template") + if not template_name: + expanded.append(step) + continue + if not isinstance(template_name, str): + raise ValueError(f"steps[{idx}].template must be string") + tpl = templates_data.get(template_name) + if not isinstance(tpl, dict): + raise ValueError(f"step template not found: {template_name}") + params = tpl.get("params") or [] + if not isinstance(params, list): + raise ValueError(f"step_templates.{template_name}.params must be list") + args = step.get("args") or {} + if not isinstance(args, dict): + raise ValueError(f"steps[{idx}].args must be a mapping") + values: Dict[str, Any] = {} + for p in params: + key = str(p) + if key not in args: + raise ValueError(f"steps[{idx}].args missing template param: {key}") + values[key] = args[key] + tpl_steps: List[Any] + if "step" in tpl: + tpl_steps = [tpl.get("step")] + else: + tpl_steps = tpl.get("steps") or [] + if not isinstance(tpl_steps, list): + raise ValueError(f"step_templates.{template_name}.steps must be list") + for item in tpl_steps: + if not isinstance(item, dict): + raise ValueError(f"step_templates.{template_name} contains non-mapping step") + rendered = _replace_tokens(deepcopy(item), values) + expanded.append(rendered) + return expanded + + +def _load_import_steps(script_path: str, imports_data: Any, visited: set[str] | None = None) -> List[Dict[str, Any]]: + if imports_data is None: + return [] + if not isinstance(imports_data, list): + raise ValueError("imports/include must be a list") + visited = visited or set() + base = Path(script_path).resolve().parent + merged: List[Dict[str, Any]] = [] + for idx, item in enumerate(imports_data): + rel = str(item).strip() + if not rel: + raise ValueError(f"imports[{idx}] path is empty") + fp = (base / rel).resolve() + key = str(fp) + if key in visited: + continue + visited.add(key) + if not fp.exists(): + raise ValueError(f"import file not found: {rel}") + with fp.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + if not isinstance(data, dict): + raise ValueError(f"import file must be a mapping: {rel}") + nested = data.get("imports") + if nested is None: + nested = data.get("include") + merged.extend(_load_import_steps(str(fp), nested, visited)) + merged.extend(_parse_steps(data.get("steps", []))) + return merged def parse_script(path: str) -> ScriptAST: with open(path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) or {} - version = int(data.get("version", 1)) - vars_def = data.get("vars", {}) or {} - channels = data.get("channels", {}) or {} - ui_cfg_raw = data.get("ui") or {} - ui_cfg = _parse_ui(ui_cfg_raw) - ui_cfg.controls = _parse_controls(ui_cfg_raw) - ui_cfg.layout = _parse_layout(ui_cfg_raw) - - sm_cfg = data.get("state_machine") or {} - initial = sm_cfg.get("initial") - states_cfg = sm_cfg.get("states") or {} - states: Dict[str, State] = {} - for state_name, state_node in states_cfg.items(): - states[state_name] = _parse_state(state_name, state_node) - if not initial or initial not in states: - raise ValueError("state_machine.initial 未定义或未在 states 中声明") - - sm = StateMachine(initial=initial, states=states) - return ScriptAST(version=version, vars=vars_def, channels=channels, state_machine=sm, ui=ui_cfg) + if not isinstance(data, dict): + raise ValueError("script root must be a mapping") + + version = str(data.get("version", "")).strip() + if version not in {"0.1", "0.2"}: + raise ValueError("only YAML DSL version 0.1/0.2 is supported") + + params = _as_mapping(data.get("params"), field="params") + vars_def = _as_mapping(data.get("vars"), field="vars") + profiles = _as_mapping(data.get("profiles"), field="profiles") + session_raw = _as_mapping(data.get("session"), field="session", required=True) + profile_name = session_raw.get("profile") + if profile_name is not None: + profile_key = str(profile_name).strip() + profile_cfg = profiles.get(profile_key) + if not isinstance(profile_cfg, dict): + raise ValueError(f"session.profile not found: {profile_key}") + merged_session = dict(profile_cfg) + for key, value in session_raw.items(): + if key == "profile": + continue + merged_session[key] = value + session_raw = merged_session + session = _parse_session(session_raw) + defaults = _parse_defaults(_as_mapping(data.get("defaults"), field="defaults")) + imports_data = data.get("imports") + if imports_data is None: + imports_data = data.get("include") + raw_steps = _load_import_steps(path, imports_data) + _parse_steps(data.get("steps")) + steps = _expand_step_templates(raw_steps, data.get("step_templates")) + for idx, step in enumerate(steps): + if not isinstance(step, dict): + raise ValueError(f"expanded steps[{idx}] must be a mapping") + name = step.get("name") + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"expanded steps[{idx}].name is required") + artifacts = _parse_artifacts(_as_mapping(data.get("artifacts"), field="artifacts")) + security = _as_mapping(data.get("security"), field="security") + + return ScriptAST( + version=version, + params=params, + vars=vars_def, + session=session, + defaults=defaults, + steps=steps, + artifacts=artifacts, + security=security, + ) diff --git a/dsl_runtime/protocol_package/__init__.py b/dsl_runtime/protocol_package/__init__.py new file mode 100644 index 0000000..d968044 --- /dev/null +++ b/dsl_runtime/protocol_package/__init__.py @@ -0,0 +1,11 @@ +from dsl_runtime.protocol_package.gateway import ProtocolPackageGateway +from dsl_runtime.protocol_package.loader import load_protocol_packages +from dsl_runtime.protocol_package.scanner import scan_protocol_packages +from dsl_runtime.protocol_package.vectors import run_protocol_vectors + +__all__ = [ + "scan_protocol_packages", + "load_protocol_packages", + "ProtocolPackageGateway", + "run_protocol_vectors", +] diff --git a/dsl_runtime/protocol_package/gateway.py b/dsl_runtime/protocol_package/gateway.py new file mode 100644 index 0000000..d4b95de --- /dev/null +++ b/dsl_runtime/protocol_package/gateway.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from dataclasses import dataclass +import time +import re +from typing import Any, Dict + +from dsl_runtime.protocol_package.loader import LoadedProtocolPackage +from dsl_runtime.protocol_package.runtime import ProtocolCallContext + + +@dataclass +class ProtocolCallResult: + ok: bool + data: Dict[str, Any] + error: Dict[str, Any] | None = None + + +class ProtocolPackageGateway: + def __init__(self, packages: Dict[str, LoadedProtocolPackage]) -> None: + self._packages = packages + + @staticmethod + def _audit(ctx: ProtocolCallContext, payload: Dict[str, Any]) -> None: + logger = getattr(ctx, "logger", None) + if logger is None: + return + log_fn = getattr(logger, "info", None) + if callable(log_fn): + log_fn(f"[protocol.gateway] {payload}") + + @staticmethod + def _error(code: str, message: str) -> ProtocolCallResult: + return ProtocolCallResult(ok=False, data={}, error={"code": code, "message": message}) + + @staticmethod + def _map_exception(exc: Exception) -> str: + text = str(exc) + m = re.match(r"^([A-Z][A-Z0-9_]+)\s*:", text) + if m is not None: + token = str(m.group(1)) + if ( + token.startswith("MODBUS_") + or token.startswith("PROTOCOL_") + or token.startswith("AT_") + or token.startswith("YMODEM_") + or token.startswith("SCPI_") + ): + return token + if isinstance(exc, TimeoutError): + return "PROTOCOL_TIMEOUT" + if isinstance(exc, ValueError): + return "PROTOCOL_VALIDATION_FAILED" + if isinstance(exc, PermissionError): + return "PROTOCOL_PERMISSION_DENIED" + return "PROTOCOL_CALL_FAILED" + + def list_protocols(self) -> Dict[str, Dict[str, Any]]: + return { + pid: { + "id": pid, + "name": p.manifest.name, + "version": p.manifest.version, + "api": list(p.manifest.api), + } + for pid, p in self._packages.items() + } + + def call(self, protocol_id: str, method: str, ctx: ProtocolCallContext, payload: Dict[str, Any]) -> ProtocolCallResult: + started = time.time() + self._audit( + ctx, + { + "event": "call_start", + "protocol_id": protocol_id, + "method": method, + }, + ) + pkg = self._packages.get(protocol_id) + if pkg is None: + result = self._error("PROTOCOL_NOT_FOUND", f"protocol package not found: {protocol_id}") + self._audit(ctx, {"event": "call_end", "ok": False, "error": result.error, "elapsed_ms": 0}) + return result + method_name = str(method).strip().lower() + if method_name not in {"send", "recv", "rpc"}: + result = self._error("PROTOCOL_METHOD_INVALID", f"invalid protocol method: {method_name}") + self._audit(ctx, {"event": "call_end", "ok": False, "error": result.error, "elapsed_ms": 0}) + return result + if method_name not in set(pkg.manifest.api): + result = self._error("PROTOCOL_METHOD_UNDECLARED", f"method not declared by package: {method_name}") + self._audit(ctx, {"event": "call_end", "ok": False, "error": result.error, "elapsed_ms": 0}) + return result + fn = getattr(pkg.impl, method_name, None) + if fn is None or not callable(fn): + result = self._error("PROTOCOL_METHOD_UNSUPPORTED", f"method not implemented: {method_name}") + self._audit(ctx, {"event": "call_end", "ok": False, "error": result.error, "elapsed_ms": 0}) + return result + try: + result = fn(ctx, payload) + if isinstance(result, dict): + output = ProtocolCallResult(ok=True, data=result) + else: + output = ProtocolCallResult(ok=True, data={"result": result}) + self._audit( + ctx, + { + "event": "call_end", + "ok": True, + "protocol_id": protocol_id, + "method": method_name, + "elapsed_ms": int((time.time() - started) * 1000), + }, + ) + return output + except Exception as exc: + err = {"code": self._map_exception(exc), "message": str(exc)} + self._audit( + ctx, + { + "event": "call_end", + "ok": False, + "protocol_id": protocol_id, + "method": method_name, + "error": err, + "elapsed_ms": int((time.time() - started) * 1000), + }, + ) + return ProtocolCallResult(ok=False, data={}, error=err) diff --git a/dsl_runtime/protocol_package/loader.py b/dsl_runtime/protocol_package/loader.py new file mode 100644 index 0000000..19155a9 --- /dev/null +++ b/dsl_runtime/protocol_package/loader.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import importlib.util +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List + +from dsl_runtime.protocol_package.models import ProtocolPackageManifest, ProtocolPackageScanIssue +from dsl_runtime.protocol_package.scanner import scan_protocol_packages + + +@dataclass +class LoadedProtocolPackage: + manifest: ProtocolPackageManifest + impl: Any + + +@dataclass +class ProtocolPackageLoadResult: + packages: Dict[str, LoadedProtocolPackage] + issues: List[ProtocolPackageScanIssue] + + +def _module_to_file(pkg_dir: Path, module_name: str) -> Path: + parts = [x for x in module_name.split(".") if x] + if not parts: + raise ValueError("entry.module is empty") + return pkg_dir.joinpath(*parts).with_suffix(".py") + + +def _load_impl_class(manifest: ProtocolPackageManifest) -> Any: + module_name = f"proto_pkg_{manifest.protocol_id}_{manifest.version.replace('.', '_')}" + module_file = _module_to_file(manifest.pkg_dir, manifest.entry_module) + if not module_file.exists(): + raise FileNotFoundError(f"entry module file not found: {module_file}") + spec = importlib.util.spec_from_file_location(module_name, module_file) + if spec is None or spec.loader is None: + raise ImportError(f"unable to create import spec for {module_file}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + cls = getattr(module, manifest.entry_class, None) + if cls is None: + raise ImportError(f"entry class not found: {manifest.entry_class}") + return cls + + +def _validate_impl_api(impl: Any, manifest: ProtocolPackageManifest) -> None: + for method in manifest.api: + fn = getattr(impl, method, None) + if fn is None or not callable(fn): + raise TypeError(f"api method missing or not callable: {method}") + + +def load_protocol_packages(root_dir: str | Path) -> ProtocolPackageLoadResult: + scan = scan_protocol_packages(root_dir) + packages: Dict[str, LoadedProtocolPackage] = {} + issues = list(scan.issues) + for manifest in scan.manifests: + try: + cls = _load_impl_class(manifest) + impl = cls() + _validate_impl_api(impl, manifest) + packages[manifest.protocol_id] = LoadedProtocolPackage(manifest=manifest, impl=impl) + except Exception as exc: + issues.append( + ProtocolPackageScanIssue( + level="error", + package_dir=str(manifest.pkg_dir), + message=f"load failed: {exc}", + ) + ) + return ProtocolPackageLoadResult(packages=packages, issues=issues) diff --git a/dsl_runtime/protocol_package/modbus_codec.py b/dsl_runtime/protocol_package/modbus_codec.py new file mode 100644 index 0000000..1c01a7d --- /dev/null +++ b/dsl_runtime/protocol_package/modbus_codec.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from typing import Any, Dict, List + + +_OP_TO_FC = { + "read_holding": 0x03, + "read_input": 0x04, + "write_single_coil": 0x05, + "write_single_register": 0x06, + "write_multiple_coils": 0x0F, + "write_multiple_registers": 0x10, +} + + +def modbus_fail(code: str, message: str) -> None: + raise ValueError(f"{code}: {message}") + + +def operation_to_fc(op: str) -> int: + key = str(op or "").strip().lower() + if key not in _OP_TO_FC: + modbus_fail("MODBUS_FUNCTION_UNSUPPORTED", f"unsupported operation: {op}") + return int(_OP_TO_FC[key]) + + +def _u16(value: Any, *, field: str) -> int: + iv = int(value) + if iv < 0 or iv > 0xFFFF: + modbus_fail("MODBUS_VALUE_INVALID", f"{field} out of range: {iv}") + return iv + + +def _as_bool_list(values: Any) -> List[bool]: + if not isinstance(values, list) or not values: + modbus_fail("MODBUS_VALUE_INVALID", "values must be a non-empty list") + out: List[bool] = [] + for i, item in enumerate(values): + if isinstance(item, bool): + out.append(item) + continue + if isinstance(item, int): + out.append(item != 0) + continue + modbus_fail("MODBUS_VALUE_INVALID", f"values[{i}] must be bool/int") + return out + + +def _pack_coils(values: List[bool]) -> bytes: + out = bytearray((len(values) + 7) // 8) + for i, bit in enumerate(values): + if bit: + out[i // 8] |= 1 << (i % 8) + return bytes(out) + + +def _unpack_coils(payload: bytes, quantity: int) -> List[bool]: + out: List[bool] = [] + for i in range(quantity): + out.append(((payload[i // 8] >> (i % 8)) & 0x01) == 1) + return out + + +def encode_request_pdu(request: Dict[str, Any]) -> bytes: + op = str(request.get("op", "")).strip().lower() + fc = operation_to_fc(op) + address = _u16(request.get("address"), field="address") + + if fc in {0x03, 0x04}: + quantity = _u16(request.get("quantity"), field="quantity") + if quantity <= 0 or quantity > 0x007D: + modbus_fail("MODBUS_VALUE_INVALID", "quantity out of range for read") + return bytes([fc]) + address.to_bytes(2, "big") + quantity.to_bytes(2, "big") + + if fc == 0x05: + value = request.get("value") + coil = 0xFF00 if bool(value) else 0x0000 + return bytes([fc]) + address.to_bytes(2, "big") + coil.to_bytes(2, "big") + + if fc == 0x06: + value = _u16(request.get("value"), field="value") + return bytes([fc]) + address.to_bytes(2, "big") + value.to_bytes(2, "big") + + if fc == 0x0F: + values = _as_bool_list(request.get("values")) + quantity = len(values) + if quantity <= 0 or quantity > 0x07B0: + modbus_fail("MODBUS_VALUE_INVALID", "coil quantity out of range") + packed = _pack_coils(values) + return ( + bytes([fc]) + + address.to_bytes(2, "big") + + quantity.to_bytes(2, "big") + + bytes([len(packed)]) + + packed + ) + + if fc == 0x10: + values_any = request.get("values") + if not isinstance(values_any, list) or not values_any: + modbus_fail("MODBUS_VALUE_INVALID", "values must be a non-empty list") + values = [_u16(v, field="values") for v in values_any] + quantity = len(values) + if quantity <= 0 or quantity > 0x007B: + modbus_fail("MODBUS_VALUE_INVALID", "register quantity out of range") + payload = b"".join(v.to_bytes(2, "big") for v in values) + return ( + bytes([fc]) + + address.to_bytes(2, "big") + + quantity.to_bytes(2, "big") + + bytes([len(payload)]) + + payload + ) + + modbus_fail("MODBUS_FUNCTION_UNSUPPORTED", f"unsupported function: {fc}") + return b"" + + +def decode_response_pdu(function: int, pdu: bytes, *, quantity: int | None = None) -> Dict[str, Any]: + if not pdu: + modbus_fail("MODBUS_FRAME_INVALID", "empty pdu") + fc = pdu[0] + if fc == (function | 0x80): + if len(pdu) < 2: + modbus_fail("MODBUS_FRAME_INVALID", "exception response too short") + return { + "ok": False, + "error": { + "code": "MODBUS_EXCEPTION_RESPONSE", + "message": f"modbus exception code {pdu[1]}", + "exception_code": int(pdu[1]), + }, + "function": function, + } + if fc != function: + modbus_fail("MODBUS_FRAME_INVALID", f"function mismatch: expect {function}, got {fc}") + + if function in {0x03, 0x04}: + if len(pdu) < 2: + modbus_fail("MODBUS_FRAME_INVALID", "read response too short") + byte_count = int(pdu[1]) + payload = pdu[2:] + if byte_count != len(payload): + modbus_fail("MODBUS_FRAME_INVALID", "byte_count mismatch") + if byte_count % 2 != 0: + modbus_fail("MODBUS_FRAME_INVALID", "register payload byte_count must be even") + registers = [int.from_bytes(payload[i : i + 2], "big") for i in range(0, len(payload), 2)] + return {"ok": True, "function": function, "data": {"registers": registers}} + + if function == 0x05: + if len(pdu) != 5: + modbus_fail("MODBUS_FRAME_INVALID", "FC05 response length must be 5") + addr = int.from_bytes(pdu[1:3], "big") + raw = int.from_bytes(pdu[3:5], "big") + return {"ok": True, "function": function, "data": {"address": addr, "value": raw == 0xFF00}} + + if function == 0x06: + if len(pdu) != 5: + modbus_fail("MODBUS_FRAME_INVALID", "FC06 response length must be 5") + addr = int.from_bytes(pdu[1:3], "big") + val = int.from_bytes(pdu[3:5], "big") + return {"ok": True, "function": function, "data": {"address": addr, "value": val}} + + if function == 0x0F: + if len(pdu) != 5: + modbus_fail("MODBUS_FRAME_INVALID", "FC15 response length must be 5") + addr = int.from_bytes(pdu[1:3], "big") + qty = int.from_bytes(pdu[3:5], "big") + return {"ok": True, "function": function, "data": {"address": addr, "quantity": qty}} + + if function == 0x10: + if len(pdu) != 5: + modbus_fail("MODBUS_FRAME_INVALID", "FC16 response length must be 5") + addr = int.from_bytes(pdu[1:3], "big") + qty = int.from_bytes(pdu[3:5], "big") + return {"ok": True, "function": function, "data": {"address": addr, "quantity": qty}} + + if function == 0x01: + if len(pdu) < 2: + modbus_fail("MODBUS_FRAME_INVALID", "FC01 response too short") + byte_count = int(pdu[1]) + payload = pdu[2:] + if byte_count != len(payload): + modbus_fail("MODBUS_FRAME_INVALID", "byte_count mismatch") + q = int(quantity or (byte_count * 8)) + return {"ok": True, "function": function, "data": {"coils": _unpack_coils(payload, q)}} + + modbus_fail("MODBUS_FUNCTION_UNSUPPORTED", f"decode not supported for function: {function}") + return {} diff --git a/dsl_runtime/protocol_package/models.py b/dsl_runtime/protocol_package/models.py new file mode 100644 index 0000000..71f1fc1 --- /dev/null +++ b/dsl_runtime/protocol_package/models.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List + + +@dataclass +class ProtocolPackageManifest: + pkg_dir: Path + protocol_id: str + name: str + version: str + entry_module: str + entry_class: str + api: List[str] = field(default_factory=list) + config_schema: Dict[str, Any] = field(default_factory=dict) + message_schema: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ProtocolPackageScanIssue: + level: str + package_dir: str + message: str + + +@dataclass +class ProtocolPackageScanResult: + manifests: List[ProtocolPackageManifest] = field(default_factory=list) + issues: List[ProtocolPackageScanIssue] = field(default_factory=list) diff --git a/dsl_runtime/protocol_package/runtime.py b/dsl_runtime/protocol_package/runtime.py new file mode 100644 index 0000000..73e4a86 --- /dev/null +++ b/dsl_runtime/protocol_package/runtime.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Protocol + + +@dataclass +class ProtocolCallContext: + channel: Any + logger: Any + vars: Dict[str, Any] + timeout_ms: int + artifacts: Dict[str, Any] + + +class ProtocolPackageAPI(Protocol): + def send(self, ctx: ProtocolCallContext, request: Dict[str, Any]) -> Dict[str, Any]: + ... + + def recv(self, ctx: ProtocolCallContext, expect: Dict[str, Any]) -> Dict[str, Any]: + ... + + def rpc(self, ctx: ProtocolCallContext, request: Dict[str, Any]) -> Dict[str, Any]: + ... diff --git a/dsl_runtime/protocol_package/scanner.py b/dsl_runtime/protocol_package/scanner.py new file mode 100644 index 0000000..6faa4b3 --- /dev/null +++ b/dsl_runtime/protocol_package/scanner.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Iterable, List + +import yaml + +from dsl_runtime.protocol_package.models import ( + ProtocolPackageManifest, + ProtocolPackageScanIssue, + ProtocolPackageScanResult, +) + + +_REQUIRED_FILES = ("protocol.yaml", "impl.py", "README.md", "vectors.yaml") +_REQUIRED_API = {"send", "recv", "rpc"} + + +def _issue(result: ProtocolPackageScanResult, level: str, package_dir: Path, message: str) -> None: + result.issues.append( + ProtocolPackageScanIssue( + level=level, + package_dir=str(package_dir), + message=message, + ) + ) + + +def _as_mapping(value: Any, *, field: str) -> Dict[str, Any]: + if not isinstance(value, dict): + raise ValueError(f"{field} must be a mapping") + return value + + +def _as_list(value: Any, *, field: str) -> List[Any]: + if not isinstance(value, list): + raise ValueError(f"{field} must be a list") + return list(value) + + +def _as_non_empty_str(value: Any, *, field: str) -> str: + text = str(value).strip() if value is not None else "" + if not text: + raise ValueError(f"{field} is required") + return text + + +def _validate_manifest(pkg_dir: Path, data: Dict[str, Any]) -> ProtocolPackageManifest: + protocol_id = _as_non_empty_str(data.get("id"), field="id") + name = _as_non_empty_str(data.get("name"), field="name") + version = _as_non_empty_str(data.get("version"), field="version") + entry = _as_mapping(data.get("entry"), field="entry") + entry_module = _as_non_empty_str(entry.get("module"), field="entry.module") + entry_class = _as_non_empty_str(entry.get("class"), field="entry.class") + api = [str(x).strip() for x in _as_list(data.get("api"), field="api")] + api_set = {x for x in api if x} + missing = sorted(_REQUIRED_API - api_set) + if missing: + raise ValueError(f"api missing required items: {', '.join(missing)}") + config_schema = data.get("config_schema") or {} + if not isinstance(config_schema, dict): + raise ValueError("config_schema must be a mapping") + message_schema = data.get("message_schema") or {} + if not isinstance(message_schema, dict): + raise ValueError("message_schema must be a mapping") + return ProtocolPackageManifest( + pkg_dir=pkg_dir, + protocol_id=protocol_id, + name=name, + version=version, + entry_module=entry_module, + entry_class=entry_class, + api=sorted(api_set), + config_schema=config_schema, + message_schema=message_schema, + ) + + +def _iter_package_dirs(root: Path) -> Iterable[Path]: + if not root.exists(): + return [] + return [p for p in root.iterdir() if p.is_dir()] + + +def scan_protocol_packages(root_dir: str | Path) -> ProtocolPackageScanResult: + root = Path(root_dir).resolve() + result = ProtocolPackageScanResult() + seen_ids: Dict[str, Path] = {} + for pkg_dir in _iter_package_dirs(root): + missing = [name for name in _REQUIRED_FILES if not (pkg_dir / name).exists()] + if missing: + _issue(result, "error", pkg_dir, f"missing required files: {', '.join(missing)}") + continue + manifest_path = pkg_dir / "protocol.yaml" + try: + data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + raise ValueError("protocol.yaml root must be a mapping") + manifest = _validate_manifest(pkg_dir, data) + except Exception as exc: + _issue(result, "error", pkg_dir, f"invalid manifest: {exc}") + continue + if manifest.protocol_id in seen_ids: + _issue( + result, + "error", + pkg_dir, + f"duplicate protocol id '{manifest.protocol_id}' (already in {seen_ids[manifest.protocol_id]})", + ) + continue + seen_ids[manifest.protocol_id] = pkg_dir + result.manifests.append(manifest) + return result diff --git a/dsl_runtime/protocol_package/vectors.py b/dsl_runtime/protocol_package/vectors.py new file mode 100644 index 0000000..42101b1 --- /dev/null +++ b/dsl_runtime/protocol_package/vectors.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List + +import yaml + +from dsl_runtime.protocol_package.gateway import ProtocolPackageGateway +from dsl_runtime.protocol_package.loader import load_protocol_packages +from dsl_runtime.protocol_package.runtime import ProtocolCallContext + + +@dataclass +class VectorCaseResult: + case_id: str + kind: str + ok: bool + expected: Dict[str, Any] + actual: Dict[str, Any] + error: str = "" + + +@dataclass +class VectorRunResult: + protocol_id: str + package_dir: str + cases: List[VectorCaseResult] = field(default_factory=list) + + @property + def ok(self) -> bool: + return all(c.ok for c in self.cases) + + +class _MockChannel: + def __init__(self, *, rx_text: str = "", rx_hex: str = "") -> None: + if rx_hex: + self._rx = bytearray(bytes.fromhex(rx_hex.replace(" ", ""))) + else: + self._rx = bytearray(rx_text.encode("utf-8")) + self.tx = bytearray() + + def write(self, data: bytes | str) -> None: + if isinstance(data, bytes): + self.tx.extend(data) + return + self.tx.extend(str(data).encode("utf-8")) + + def read(self, size: int = 256, timeout: float | None = None) -> bytes: + if not self._rx: + return b"" + n = max(1, int(size)) + out = bytes(self._rx[:n]) + del self._rx[:n] + return out + + def read_until(self, tail: bytes, timeout: float | None = None) -> bytes: + if not self._rx: + return b"" + idx = bytes(self._rx).find(tail) + if idx < 0: + out = bytes(self._rx) + self._rx.clear() + return out + end = idx + len(tail) + out = bytes(self._rx[:end]) + del self._rx[:end] + return out + + +def _subset_match(expected: Any, actual: Any) -> bool: + if isinstance(expected, dict): + if not isinstance(actual, dict): + return False + for key, value in expected.items(): + if key not in actual: + return False + if not _subset_match(value, actual.get(key)): + return False + return True + if isinstance(expected, list): + if not isinstance(actual, list): + return False + if len(expected) > len(actual): + return False + for idx, value in enumerate(expected): + if not _subset_match(value, actual[idx]): + return False + return True + return expected == actual + + +def _assert_expected(expected: Dict[str, Any], actual: Dict[str, Any]) -> bool: + return _subset_match(expected, actual) + + +def _load_vectors_file(path: Path) -> Dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + raise ValueError("vectors.yaml root must be a mapping") + return data + + +def run_protocol_vectors(root_dir: str | Path, protocol_id: str) -> VectorRunResult: + load = load_protocol_packages(root_dir) + if load.issues: + errs = [x for x in load.issues if x.level == "error"] + if errs and protocol_id not in load.packages: + raise RuntimeError(f"protocol package load failed: {errs[0].message}") + loaded = load.packages.get(protocol_id) + if loaded is None: + raise ValueError(f"protocol package not found: {protocol_id}") + + vectors_path = loaded.manifest.pkg_dir / "vectors.yaml" + if not vectors_path.exists(): + raise FileNotFoundError(f"vectors.yaml not found: {vectors_path}") + vec = _load_vectors_file(vectors_path) + cases = vec.get("cases") + if not isinstance(cases, list): + raise ValueError("vectors.yaml cases must be a list") + + gateway = ProtocolPackageGateway({protocol_id: loaded}) + run = VectorRunResult(protocol_id=protocol_id, package_dir=str(loaded.manifest.pkg_dir)) + for idx, case in enumerate(cases): + if not isinstance(case, dict): + raise ValueError(f"cases[{idx}] must be a mapping") + case_id = str(case.get("id") or f"case_{idx + 1}") + kind = str(case.get("kind") or "").strip().lower() + if kind not in {"send", "recv", "rpc"}: + raise ValueError(f"cases[{idx}].kind must be send/recv/rpc") + input_data = case.get("input") or {} + if not isinstance(input_data, dict): + raise ValueError(f"cases[{idx}].input must be a mapping") + expected = case.get("expect") or {} + if not isinstance(expected, dict): + raise ValueError(f"cases[{idx}].expect must be a mapping") + + channel = _MockChannel( + rx_text=str(input_data.get("mock_rx_text", "")), + rx_hex=str(input_data.get("mock_rx_hex", "")), + ) + call_ctx = ProtocolCallContext( + channel=channel, + logger=None, + vars={}, + timeout_ms=int(input_data.get("timeout_ms", 1000)), + artifacts={}, + ) + if kind == "recv": + payload = input_data.get("expect", {}) + else: + payload = input_data.get("request", {}) + if payload is None: + payload = {} + if not isinstance(payload, dict): + raise ValueError(f"cases[{idx}] payload must be a mapping") + + call = gateway.call(protocol_id=protocol_id, method=kind, ctx=call_ctx, payload=payload) + actual = { + "ok": call.ok, + "error": call.error, + "data": call.data, + "tx_hex": bytes(channel.tx).hex().upper(), + } + passed = _assert_expected(expected, actual) + err_text = "" + if not passed: + err_text = "expected values not matched" + run.cases.append( + VectorCaseResult( + case_id=case_id, + kind=kind, + ok=passed, + expected=expected, + actual=actual, + error=err_text, + ) + ) + return run diff --git a/infra/comm/communication_manager.py b/infra/comm/communication_manager.py index ffcc9e8..2ef34e6 100644 --- a/infra/comm/communication_manager.py +++ b/infra/comm/communication_manager.py @@ -1,8 +1,8 @@ -"""通信管理器:统一封装串口与 TCP,会话事件转发到 comm.* 通道。""" +"""Communication manager: bridges serial/TCP sessions to unified comm.* events.""" from __future__ import annotations -from typing import Optional, Union +from typing import Any, Optional, Union from infra.common.event_bus import EventBus from infra.comm.serial_manager import SerialManager @@ -15,23 +15,26 @@ class CommunicationManager: def __init__(self, bus: EventBus) -> None: self._bus = bus self._current_session: Optional[SessionType] = None + self._suppress_disconnect_event = False - # 事件转发:底层 -> comm.* + # Bridge low-level events to unified communication events. self._bus.subscribe("serial.rx", lambda data: self._bus.publish("comm.rx", data)) self._bus.subscribe("tcp.rx", lambda data: self._bus.publish("comm.rx", data)) - self._bus.subscribe("serial.tx", lambda data: self._bus.publish("comm.tx", data)) self._bus.subscribe("tcp.tx", lambda data: self._bus.publish("comm.tx", data)) - - self._bus.subscribe( - "serial.error", lambda reason: self._bus.publish("comm.error", reason) - ) + self._bus.subscribe("serial.error", lambda reason: self._bus.publish("comm.error", reason)) self._bus.subscribe("tcp.error", lambda reason: self._bus.publish("comm.error", reason)) - # 协议帧请求发送 -> 调用当前会话发送 + self._bus.subscribe("serial.closed", self._on_serial_closed) + self._bus.subscribe("tcp.disconnected", self._on_tcp_disconnected) + + # Serial reconnection path: SerialManager emits serial.opened again after reconnect. + self._bus.subscribe("serial.opened", self._on_serial_opened) + + # Protocol outbound frames are sent through current selected session. self._bus.subscribe("protocol.tx", self._handle_protocol_tx) def select_serial(self, port: str, baud: int) -> None: - """选择串口通道并连接。""" + """Select and open a serial channel.""" if isinstance(self._current_session, SerialManager): if ( self._current_session.is_open() @@ -39,36 +42,39 @@ def select_serial(self, port: str, baud: int) -> None: and self._current_session.baudrate == baud ): return + self.close(notify=False) session = SerialManager(self._bus) - if session.open(port=port, baudrate=baud): - self._current_session = session - self._bus.publish("comm.connected", {"type": "serial", "port": port, "baud": baud}) + # Set current session first, so serial.opened callback can publish comm.connected. + self._current_session = session + if not session.open(port=port, baudrate=baud): + self._current_session = None def select_tcp(self, ip: str, port: int) -> None: - """选择 TCP 通道并连接。""" + """Select and open a TCP channel.""" self.close(notify=False) session = TcpSession(self._bus) session.connect(ip, port) - self._current_session = session - self._bus.publish( - "comm.connected", - {"type": "tcp", "host": ip, "port": port, "address": f"{ip}:{port}"}, - ) + if session.is_connected(): + self._current_session = session + self._bus.publish( + "comm.connected", + {"type": "tcp", "host": ip, "port": port, "address": f"{ip}:{port}"}, + ) def close(self, notify: bool = True) -> None: - """关闭当前会话。""" + """Close current session.""" if self._current_session: + self._suppress_disconnect_event = not notify try: self._current_session.close() except Exception: pass self._current_session = None - if notify: - self._bus.publish("comm.disconnected") + self._suppress_disconnect_event = False def send(self, data: bytes) -> None: - """通过当前会话发送数据。""" + """Send bytes through current session.""" if not self._current_session: self._bus.publish("comm.error", "no active session") return @@ -78,11 +84,11 @@ def send(self, data: bytes) -> None: self._bus.publish("comm.error", str(exc)) def list_serial_ports(self) -> list[str]: - """返回可用串口列表,供 UI 使用。""" + """Return available serial ports for UI.""" return SerialManager.list_ports() def get_status(self) -> Optional[dict]: - """返回当前会话状态,供 UI 轮询。""" + """Return current channel status for UI polling.""" session = self._current_session if isinstance(session, SerialManager): if not session.is_open(): @@ -99,7 +105,35 @@ def get_status(self) -> Optional[dict]: return None def _handle_protocol_tx(self, data: bytes) -> None: - """处理协议层构造好的帧并发送。""" if not data: return self.send(data) + + def _on_serial_opened(self, _port: str) -> None: + session = self._current_session + if not isinstance(session, SerialManager): + return + if not session.is_open(): + return + self._bus.publish( + "comm.connected", + { + "type": "serial", + "port": session.port, + "baud": session.baudrate, + }, + ) + + def _on_serial_closed(self, _payload: Any = None) -> None: + if self._suppress_disconnect_event: + return + session = self._current_session + if isinstance(session, SerialManager): + self._bus.publish("comm.disconnected") + + def _on_tcp_disconnected(self, _payload: Any = None) -> None: + if self._suppress_disconnect_event: + return + session = self._current_session + if isinstance(session, TcpSession): + self._bus.publish("comm.disconnected") diff --git a/infra/comm/proxy_forward_manager.py b/infra/comm/proxy_forward_manager.py new file mode 100644 index 0000000..8669d8e --- /dev/null +++ b/infra/comm/proxy_forward_manager.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional + +import serial +from serial import SerialException + +from infra.common.event_bus import EventBus + + +_PARITY_MAP = { + "none": serial.PARITY_NONE, + "even": serial.PARITY_EVEN, + "odd": serial.PARITY_ODD, + "mark": serial.PARITY_MARK, + "space": serial.PARITY_SPACE, + "无": serial.PARITY_NONE, + "偶校验": serial.PARITY_EVEN, + "奇校验": serial.PARITY_ODD, +} + +_BYTESIZE_MAP = { + "5": serial.FIVEBITS, + "6": serial.SIXBITS, + "7": serial.SEVENBITS, + "8": serial.EIGHTBITS, +} + +_STOPBITS_MAP = { + "1": serial.STOPBITS_ONE, + "1.5": serial.STOPBITS_ONE_POINT_FIVE, + "2": serial.STOPBITS_TWO, +} + + +@dataclass(frozen=True) +class StartResult: + ok: bool + error: str = "" + + +class _ProxySession: + def __init__(self, bus: EventBus, pair_id: str, config: Dict[str, Any]) -> None: + self._bus = bus + self.pair_id = pair_id + self.host_port = str(config.get("hostPort") or "").strip() + self.device_port = str(config.get("devicePort") or "").strip() + self._baud = int(config.get("baud") or 115200) + self._data_bits = _BYTESIZE_MAP.get(str(config.get("dataBits") or "8"), serial.EIGHTBITS) + parity_key = str(config.get("parity") or "none").strip().lower() + self._parity = _PARITY_MAP.get(parity_key, serial.PARITY_NONE) + self._stop_bits = _STOPBITS_MAP.get(str(config.get("stopBits") or "1"), serial.STOPBITS_ONE) + self._flow = str(config.get("flowControl") or "none").strip().lower() + self._rtscts = self._flow == "rtscts" + self._xonxoff = self._flow == "xonxoff" + self._host_ser: Optional[serial.Serial] = None + self._device_ser: Optional[serial.Serial] = None + self._threads: list[threading.Thread] = [] + self._running = threading.Event() + + def start(self) -> StartResult: + if not self.host_port or not self.device_port: + return StartResult(False, "host/device port is required") + if self.host_port == self.device_port: + return StartResult(False, "host and device port cannot be the same") + try: + self._host_ser = serial.Serial( + port=self.host_port, + baudrate=self._baud, + bytesize=self._data_bits, + parity=self._parity, + stopbits=self._stop_bits, + timeout=0.1, + write_timeout=0.5, + rtscts=self._rtscts, + xonxoff=self._xonxoff, + ) + self._device_ser = serial.Serial( + port=self.device_port, + baudrate=self._baud, + bytesize=self._data_bits, + parity=self._parity, + stopbits=self._stop_bits, + timeout=0.1, + write_timeout=0.5, + rtscts=self._rtscts, + xonxoff=self._xonxoff, + ) + except Exception as exc: + self.stop() + return StartResult(False, str(exc)) + + self._running.set() + self._threads = [ + threading.Thread( + target=self._relay_loop, + args=( + self._host_ser, + self._device_ser, + self.host_port, + self.device_port, + "host", + "device", + ), + daemon=True, + ), + threading.Thread( + target=self._relay_loop, + args=( + self._device_ser, + self._host_ser, + self.device_port, + self.host_port, + "device", + "host", + ), + daemon=True, + ), + ] + for thread in self._threads: + thread.start() + return StartResult(True) + + def stop(self) -> None: + self._running.clear() + for thread in self._threads: + if thread.is_alive(): + thread.join(timeout=1.0) + self._threads = [] + for ser in (self._host_ser, self._device_ser): + if ser is not None: + try: + ser.close() + except Exception: + pass + self._host_ser = None + self._device_ser = None + + def _relay_loop( + self, + src: serial.Serial, + dst: serial.Serial, + src_port: str, + dst_port: str, + src_role: str, + dst_role: str, + ) -> None: + while self._running.is_set(): + try: + waiting = src.in_waiting if src.is_open else 0 + if waiting <= 0: + time.sleep(0.01) + continue + data = src.read(waiting) + if not data: + continue + dst.write(data) + self._bus.publish( + "proxy.data", + { + "pair_id": self.pair_id, + "src": src_port, + "dst": dst_port, + "src_role": src_role, + "dst_role": dst_role, + "host_port": self.host_port, + "device_port": self.device_port, + "data": data, + "ts": time.time(), + }, + ) + except (SerialException, OSError) as exc: + self._bus.publish( + "proxy.status", + { + "pair_id": self.pair_id, + "status": "error", + "error": str(exc), + }, + ) + self._running.clear() + except Exception as exc: + self._bus.publish( + "proxy.status", + { + "pair_id": self.pair_id, + "status": "error", + "error": str(exc), + }, + ) + self._running.clear() + + +class ProxyForwardManager: + def __init__(self, bus: EventBus) -> None: + self._bus = bus + self._lock = threading.RLock() + self._sessions: Dict[str, _ProxySession] = {} + + def start_pair(self, pair_id: str, config: Dict[str, Any]) -> StartResult: + if not pair_id: + return StartResult(False, "pair_id is required") + with self._lock: + old = self._sessions.pop(pair_id, None) + if old is not None: + old.stop() + session = _ProxySession(self._bus, pair_id, config) + result = session.start() + if not result.ok: + self._bus.publish( + "proxy.status", + {"pair_id": pair_id, "status": "error", "error": result.error}, + ) + return result + self._sessions[pair_id] = session + self._bus.publish("proxy.status", {"pair_id": pair_id, "status": "running", "error": None}) + return StartResult(True) + + def stop_pair(self, pair_id: str) -> None: + if not pair_id: + return + with self._lock: + session = self._sessions.pop(pair_id, None) + if session is not None: + session.stop() + self._bus.publish("proxy.status", {"pair_id": pair_id, "status": "stopped", "error": None}) + + def stop_all(self) -> None: + with self._lock: + sessions = list(self._sessions.items()) + self._sessions.clear() + for pair_id, session in sessions: + session.stop() + self._bus.publish("proxy.status", {"pair_id": pair_id, "status": "stopped", "error": None}) diff --git a/infra/protocol/protocol_loader.py b/infra/protocol/protocol_loader.py index dcac738..51e969a 100644 --- a/infra/protocol/protocol_loader.py +++ b/infra/protocol/protocol_loader.py @@ -58,7 +58,7 @@ def __init__(self, bus: EventBus, config_path: str | Path = "config/protocol.yam self.load_config() # 订阅串口接收事件 if self._enabled: - self.bus.subscribe("serial.rx", self.parse) + self.bus.subscribe("comm.rx", self.parse) def load_config(self) -> None: """加载 YAML 配置,并缓存关键字段。""" diff --git a/protocols/at_command_pkg/README.md b/protocols/at_command_pkg/README.md new file mode 100644 index 0000000..87acb54 --- /dev/null +++ b/protocols/at_command_pkg/README.md @@ -0,0 +1,22 @@ +# AT Command Package + +This package provides a generic AT command flow over text channels. + +Supported methods: +- `send(ctx, request)` +- `recv(ctx, expect)` +- `rpc(ctx, request)` + +Request fields: +- `cmd` (required) +- `eol`: `crlf|cr|lf|none` (default `crlf`) + +Expect fields: +- `status`: `ok|error|unknown` +- `contains`: substring check +- `regex`: regex check +- `size`: read size + +Status rules: +- `OK` => `ok` +- `ERROR` / `+CME ERROR` / `+CMS ERROR` => `error` diff --git a/protocols/at_command_pkg/impl.py b/protocols/at_command_pkg/impl.py new file mode 100644 index 0000000..5c0746b --- /dev/null +++ b/protocols/at_command_pkg/impl.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import re +from typing import Any, Dict, List + + +def _normalize_eol(name: str) -> str: + key = str(name or "crlf").strip().lower() + if key in {"crlf", "\\r\\n"}: + return "\r\n" + if key in {"cr", "\\r"}: + return "\r" + if key in {"lf", "\\n"}: + return "\n" + if key in {"none", ""}: + return "" + raise ValueError(f"AT_VALUE_INVALID: unsupported eol: {name}") + + +def _decode_lines(raw: bytes, encoding: str = "utf-8") -> List[str]: + text = raw.decode(encoding, errors="ignore").replace("\r\n", "\n").replace("\r", "\n") + return [line.strip() for line in text.split("\n") if line.strip()] + + +def _status_from_lines(lines: List[str]) -> str: + for line in lines: + upper = line.upper() + if upper == "OK": + return "ok" + if upper == "ERROR" or upper.startswith("+CME ERROR") or upper.startswith("+CMS ERROR"): + return "error" + return "unknown" + + +def _assert_expect(lines: List[str], status: str, expect: Dict[str, Any]) -> None: + if not expect: + return + if "status" in expect: + required_status = str(expect.get("status")).strip().lower() + if required_status and status != required_status: + raise ValueError(f"AT_EXPECT_FAILED: status expected={required_status}, actual={status}") + + text = "\n".join(lines) + contains = expect.get("contains") + if contains is not None: + token = str(contains) + if token not in text: + raise ValueError(f"AT_EXPECT_FAILED: contains not found: {token}") + + regex = expect.get("regex") + if regex is not None: + pattern = str(regex) + if re.search(pattern, text) is None: + raise ValueError(f"AT_EXPECT_FAILED: regex not matched: {pattern}") + + +class ProtocolPackage: + def send(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + cmd = str(request.get("cmd", request.get("text", ""))).strip() + if not cmd: + raise ValueError("AT_VALUE_INVALID: request.cmd is required") + eol = _normalize_eol(str(request.get("eol", "crlf"))) + payload = (cmd + eol).encode("utf-8") + ctx.channel.write(payload) + return { + "ok": True, + "cmd": cmd, + "raw": {"tx_text": cmd + eol, "tx_hex": payload.hex().upper()}, + "size": len(payload), + } + + def recv(self, ctx, expect: Dict[str, Any]) -> Dict[str, Any]: + size = int(expect.get("size", 2048)) + raw = ctx.channel.read(size, timeout=max(0.01, ctx.timeout_ms / 1000.0)) + if not raw: + raise TimeoutError("AT_TIMEOUT: no response bytes") + lines = _decode_lines(raw, encoding=str(expect.get("encoding", "utf-8"))) + status = _status_from_lines(lines) + _assert_expect(lines, status, expect) + return { + "ok": status != "error", + "status": status, + "lines": lines, + "text": "\n".join(lines), + "raw": {"rx_hex": raw.hex().upper()}, + "size": len(raw), + } + + def rpc(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + tx = self.send(ctx, request) + expect = dict(request.get("expect") or {}) + if "status" not in expect: + expect["status"] = str(request.get("status", "ok")) + rx = self.recv(ctx, expect) + return { + "ok": bool(rx.get("ok", False)), + "status": rx.get("status"), + "lines": rx.get("lines", []), + "text": rx.get("text", ""), + "raw": {"tx_hex": tx.get("raw", {}).get("tx_hex"), "rx_hex": rx.get("raw", {}).get("rx_hex")}, + } diff --git a/protocols/at_command_pkg/protocol.yaml b/protocols/at_command_pkg/protocol.yaml new file mode 100644 index 0000000..16e77ba --- /dev/null +++ b/protocols/at_command_pkg/protocol.yaml @@ -0,0 +1,15 @@ +id: "at_command" +name: "AT Command Package" +version: "1.0.0" + +entry: + module: "impl" + class: "ProtocolPackage" + +api: + - send + - recv + - rpc + +config_schema: {} +message_schema: {} diff --git a/protocols/at_command_pkg/vectors.yaml b/protocols/at_command_pkg/vectors.yaml new file mode 100644 index 0000000..8096d6a --- /dev/null +++ b/protocols/at_command_pkg/vectors.yaml @@ -0,0 +1,93 @@ +version: "1" +protocol_id: "at_command" + +cases: + - id: "send_basic" + kind: "send" + input: + request: + cmd: "AT" + eol: "crlf" + expect: + ok: true + + - id: "send_no_eol" + kind: "send" + input: + request: + cmd: "AT+GMR" + eol: "none" + expect: + ok: true + + - id: "recv_ok_single" + kind: "recv" + input: + expect: + status: "ok" + mock_rx_text: "OK\r\n" + expect: + ok: true + data: + status: "ok" + + - id: "recv_ok_multiline" + kind: "recv" + input: + expect: + status: "ok" + contains: "+CSQ:" + mock_rx_text: "AT+CSQ\r\n+CSQ: 20,99\r\nOK\r\n" + expect: + ok: true + data: + status: "ok" + + - id: "recv_error" + kind: "recv" + input: + expect: + status: "error" + mock_rx_text: "ERROR\r\n" + expect: + ok: true + data: + ok: false + status: "error" + + - id: "recv_timeout" + kind: "recv" + input: + expect: + status: "ok" + expect: + ok: false + error: + code: "AT_TIMEOUT" + + - id: "rpc_ok" + kind: "rpc" + input: + request: + cmd: "AT+CSQ" + expect: + status: "ok" + regex: "\\+CSQ:\\s*\\d+,\\d+" + mock_rx_text: "AT+CSQ\r\n+CSQ: 18,99\r\nOK\r\n" + expect: + ok: true + data: + status: "ok" + + - id: "rpc_error" + kind: "rpc" + input: + request: + cmd: "AT+BAD" + expect: + status: "ok" + mock_rx_text: "AT+BAD\r\nERROR\r\n" + expect: + ok: false + error: + code: "AT_EXPECT_FAILED" diff --git a/protocols/modbus_ascii_pkg/README.md b/protocols/modbus_ascii_pkg/README.md new file mode 100644 index 0000000..eada148 --- /dev/null +++ b/protocols/modbus_ascii_pkg/README.md @@ -0,0 +1,25 @@ +# Modbus ASCII Package + +This package implements Modbus ASCII over the unified external protocol package API. + +Supported methods: +- `send(ctx, request)` +- `recv(ctx, expect)` +- `rpc(ctx, request)` + +Supported ops: +- `read_holding` (FC03) +- `read_input` (FC04) +- `write_single_coil` (FC05) +- `write_single_register` (FC06) +- `write_multiple_coils` (FC15) +- `write_multiple_registers` (FC16) + +ASCII frame format: +- `':' + HEX + LRC + CRLF` + +Request fields: +- `op` +- `unit_id` +- `address` +- `quantity` / `value` / `values` diff --git a/protocols/modbus_ascii_pkg/impl.py b/protocols/modbus_ascii_pkg/impl.py new file mode 100644 index 0000000..7f25c31 --- /dev/null +++ b/protocols/modbus_ascii_pkg/impl.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import Any, Dict + +from dsl_runtime.protocol_package.modbus_codec import decode_response_pdu, encode_request_pdu, operation_to_fc + + +def _calc_lrc(data: bytes) -> int: + return ((-sum(data)) & 0xFF) + + +def _to_ascii_frame(unit_id: int, pdu: bytes) -> bytes: + body = bytes([unit_id & 0xFF]) + pdu + lrc = _calc_lrc(body) + text = ":" + (body + bytes([lrc])).hex().upper() + "\r\n" + return text.encode("ascii") + + +def _parse_ascii_frame(frame: bytes) -> Dict[str, Any]: + text = frame.decode("ascii", errors="ignore").strip() + if not text.startswith(":"): + raise ValueError("MODBUS_FRAME_INVALID: ascii frame must start with ':'") + hex_text = text[1:] + if len(hex_text) < 6 or len(hex_text) % 2 != 0: + raise ValueError("MODBUS_FRAME_INVALID: invalid ascii hex length") + raw = bytes.fromhex(hex_text) + if len(raw) < 3: + raise ValueError("MODBUS_FRAME_INVALID: ascii payload too short") + body = raw[:-1] + lrc_rx = int(raw[-1]) + lrc_calc = _calc_lrc(body) + if lrc_rx != lrc_calc: + raise ValueError("MODBUS_LRC_INVALID: lrc mismatch") + return {"unit_id": int(body[0]), "pdu": body[1:], "rx_hex": raw.hex().upper(), "rx_text": text} + + +class ProtocolPackage: + def send(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + unit = int(request.get("unit_id", request.get("unit", 1))) + pdu = encode_request_pdu(request) + frame = _to_ascii_frame(unit, pdu) + ctx.channel.write(frame) + return { + "ok": True, + "unit_id": unit, + "function": int(pdu[0]), + "raw": {"tx_ascii": frame.decode("ascii", errors="ignore").strip(), "tx_hex": frame.hex().upper()}, + "size": len(frame), + } + + def recv(self, ctx, expect: Dict[str, Any]) -> Dict[str, Any]: + size = int(expect.get("size", 512)) + rx = ctx.channel.read(size, timeout=max(0.01, ctx.timeout_ms / 1000.0)) + if not rx: + raise TimeoutError("MODBUS_TIMEOUT: no response bytes") + parsed = _parse_ascii_frame(rx) + op = str(expect.get("op", "")).strip().lower() + function = int(expect.get("function", operation_to_fc(op) if op else parsed["pdu"][0])) + quantity = expect.get("quantity") + decoded = decode_response_pdu(function, parsed["pdu"], quantity=quantity) + decoded["unit_id"] = parsed["unit_id"] + decoded["raw"] = {"rx_ascii": parsed["rx_text"], "rx_hex": parsed["rx_hex"]} + decoded["size"] = len(rx) + return decoded + + def rpc(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + tx = self.send(ctx, request) + rx_expect = dict(request.get("expect") or {}) + if "op" not in rx_expect and "op" in request: + rx_expect["op"] = request.get("op") + if "quantity" not in rx_expect and "quantity" in request: + rx_expect["quantity"] = request.get("quantity") + if "function" not in rx_expect and "function" in tx: + rx_expect["function"] = tx.get("function") + rx = self.recv(ctx, rx_expect) + out: Dict[str, Any] = {"ok": bool(rx.get("ok", True))} + out.update(rx) + out["raw"] = {"tx_ascii": tx.get("raw", {}).get("tx_ascii"), "rx_ascii": rx.get("raw", {}).get("rx_ascii")} + return out diff --git a/protocols/modbus_ascii_pkg/protocol.yaml b/protocols/modbus_ascii_pkg/protocol.yaml new file mode 100644 index 0000000..2471a53 --- /dev/null +++ b/protocols/modbus_ascii_pkg/protocol.yaml @@ -0,0 +1,15 @@ +id: "modbus_ascii" +name: "Modbus ASCII Package" +version: "1.0.0" + +entry: + module: "impl" + class: "ProtocolPackage" + +api: + - send + - recv + - rpc + +config_schema: {} +message_schema: {} diff --git a/protocols/modbus_ascii_pkg/vectors.yaml b/protocols/modbus_ascii_pkg/vectors.yaml new file mode 100644 index 0000000..18e9b2f --- /dev/null +++ b/protocols/modbus_ascii_pkg/vectors.yaml @@ -0,0 +1,95 @@ +version: "1" +protocol_id: "modbus_ascii" + +cases: + - id: "send_read_holding_fc03" + kind: "send" + input: + request: + op: "read_holding" + unit_id: 1 + address: 0 + quantity: 2 + expect: + ok: true + + - id: "send_write_single_register_fc06" + kind: "send" + input: + request: + op: "write_single_register" + unit_id: 1 + address: 1 + value: 123 + expect: + ok: true + + - id: "recv_read_holding_ok" + kind: "recv" + input: + expect: + op: "read_holding" + quantity: 2 + mock_rx_text: ":010304000A0001ED\r\n" + expect: + ok: true + + - id: "rpc_read_holding_fc03" + kind: "rpc" + input: + request: + op: "read_holding" + unit_id: 1 + address: 0 + quantity: 2 + mock_rx_text: ":010304000A0001ED\r\n" + expect: + ok: true + + - id: "recv_exception_response" + kind: "recv" + input: + expect: + op: "read_holding" + quantity: 2 + mock_rx_text: ":0183027A\r\n" + expect: + ok: true + data: + ok: false + error: + code: "MODBUS_EXCEPTION_RESPONSE" + + - id: "recv_lrc_invalid" + kind: "recv" + input: + expect: + op: "read_holding" + quantity: 2 + mock_rx_text: ":010304000A000100\r\n" + expect: + ok: false + error: + code: "MODBUS_LRC_INVALID" + + - id: "recv_timeout" + kind: "recv" + input: + expect: + op: "read_holding" + quantity: 2 + expect: + ok: false + error: + code: "MODBUS_TIMEOUT" + + - id: "send_boundary_read_quantity_125" + kind: "send" + input: + request: + op: "read_holding" + unit_id: 1 + address: 0 + quantity: 125 + expect: + ok: true diff --git a/protocols/modbus_rtu_pkg/README.md b/protocols/modbus_rtu_pkg/README.md new file mode 100644 index 0000000..362310e --- /dev/null +++ b/protocols/modbus_rtu_pkg/README.md @@ -0,0 +1,24 @@ +# Modbus RTU Package + +This package implements Modbus RTU over the unified external protocol package API. + +Supported methods: +- `send(ctx, request)` +- `recv(ctx, expect)` +- `rpc(ctx, request)` + +Supported ops: +- `read_holding` (FC03) +- `read_input` (FC04) +- `write_single_coil` (FC05) +- `write_single_register` (FC06) +- `write_multiple_coils` (FC15) +- `write_multiple_registers` (FC16) + +Request fields: +- `op` +- `unit_id` +- `address` +- `quantity` / `value` / `values` (depends on op) + +RTU CRC16 is appended/checked automatically. diff --git a/protocols/modbus_rtu_pkg/impl.py b/protocols/modbus_rtu_pkg/impl.py new file mode 100644 index 0000000..95ff618 --- /dev/null +++ b/protocols/modbus_rtu_pkg/impl.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import Any, Dict + +from dsl_runtime.protocol_package.modbus_codec import decode_response_pdu, encode_request_pdu, operation_to_fc + + +def _crc16_modbus(data: bytes) -> int: + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + if crc & 0x0001: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return crc & 0xFFFF + + +def _build_frame(unit: int, pdu: bytes) -> bytes: + body = bytes([unit & 0xFF]) + pdu + crc = _crc16_modbus(body) + return body + bytes([crc & 0xFF, (crc >> 8) & 0xFF]) + + +def _parse_rtu_frame(frame: bytes) -> Dict[str, Any]: + if len(frame) < 5: + raise ValueError("MODBUS_FRAME_INVALID: frame too short") + body = frame[:-2] + crc_rx = int.from_bytes(frame[-2:], "little") + crc_calc = _crc16_modbus(body) + if crc_calc != crc_rx: + raise ValueError("MODBUS_CRC_INVALID: crc mismatch") + return {"unit_id": int(frame[0]), "pdu": frame[1:-2], "rx_hex": frame.hex().upper()} + + +class ProtocolPackage: + def send(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + unit = int(request.get("unit_id", request.get("unit", 1))) + pdu = encode_request_pdu(request) + frame = _build_frame(unit, pdu) + ctx.channel.write(frame) + return { + "ok": True, + "unit_id": unit, + "function": int(pdu[0]), + "raw": {"tx_hex": frame.hex().upper()}, + "size": len(frame), + } + + def recv(self, ctx, expect: Dict[str, Any]) -> Dict[str, Any]: + expected_size = int(expect.get("size", 256)) + rx = ctx.channel.read(expected_size, timeout=max(0.01, ctx.timeout_ms / 1000.0)) + if not rx: + raise TimeoutError("MODBUS_TIMEOUT: no response bytes") + parsed = _parse_rtu_frame(rx) + + op = str(expect.get("op", "")).strip().lower() + function = int(expect.get("function", operation_to_fc(op) if op else parsed["pdu"][0])) + quantity = expect.get("quantity") + decoded = decode_response_pdu(function, parsed["pdu"], quantity=quantity) + decoded["unit_id"] = parsed["unit_id"] + decoded["raw"] = {"rx_hex": parsed["rx_hex"]} + decoded["size"] = len(rx) + return decoded + + def rpc(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + tx = self.send(ctx, request) + rx_expect = dict(request.get("expect") or {}) + if not isinstance(rx_expect, dict): + rx_expect = {} + if "op" not in rx_expect and "op" in request: + rx_expect["op"] = request.get("op") + if "quantity" not in rx_expect and "quantity" in request: + rx_expect["quantity"] = request.get("quantity") + if "function" not in rx_expect and "function" in tx: + rx_expect["function"] = tx.get("function") + rx = self.recv(ctx, rx_expect) + out: Dict[str, Any] = {"ok": bool(rx.get("ok", True))} + out.update(rx) + out["raw"] = {"tx_hex": tx.get("raw", {}).get("tx_hex"), "rx_hex": rx.get("raw", {}).get("rx_hex")} + return out diff --git a/protocols/modbus_rtu_pkg/protocol.yaml b/protocols/modbus_rtu_pkg/protocol.yaml new file mode 100644 index 0000000..9e82c77 --- /dev/null +++ b/protocols/modbus_rtu_pkg/protocol.yaml @@ -0,0 +1,15 @@ +id: "modbus_rtu" +name: "Modbus RTU Package" +version: "1.0.0" + +entry: + module: "impl" + class: "ProtocolPackage" + +api: + - send + - recv + - rpc + +config_schema: {} +message_schema: {} diff --git a/protocols/modbus_rtu_pkg/vectors.yaml b/protocols/modbus_rtu_pkg/vectors.yaml new file mode 100644 index 0000000..594de4b --- /dev/null +++ b/protocols/modbus_rtu_pkg/vectors.yaml @@ -0,0 +1,98 @@ +version: "1" +protocol_id: "modbus_rtu" + +cases: + - id: "send_read_holding_fc03" + kind: "send" + input: + request: + op: "read_holding" + unit_id: 1 + address: 0 + quantity: 2 + expect: + ok: true + + - id: "send_write_single_register_fc06" + kind: "send" + input: + request: + op: "write_single_register" + unit_id: 1 + address: 1 + value: 123 + expect: + ok: true + + - id: "recv_read_holding_ok" + kind: "recv" + input: + expect: + op: "read_holding" + quantity: 2 + size: 9 + mock_rx_hex: "010304000A00011BF1" + expect: + ok: true + + - id: "rpc_read_holding_fc03" + kind: "rpc" + input: + request: + op: "read_holding" + unit_id: 1 + address: 0 + quantity: 2 + expect: + size: 9 + mock_rx_hex: "010304000A00011BF1" + expect: + ok: true + + - id: "recv_exception_response" + kind: "recv" + input: + expect: + op: "read_holding" + quantity: 2 + mock_rx_hex: "018302C0F1" + expect: + ok: true + data: + ok: false + error: + code: "MODBUS_EXCEPTION_RESPONSE" + + - id: "recv_crc_invalid" + kind: "recv" + input: + expect: + op: "read_holding" + quantity: 2 + mock_rx_hex: "010304000A00010000" + expect: + ok: false + error: + code: "MODBUS_CRC_INVALID" + + - id: "recv_timeout" + kind: "recv" + input: + expect: + op: "read_holding" + quantity: 2 + expect: + ok: false + error: + code: "MODBUS_TIMEOUT" + + - id: "send_boundary_read_quantity_125" + kind: "send" + input: + request: + op: "read_holding" + unit_id: 1 + address: 0 + quantity: 125 + expect: + ok: true diff --git a/protocols/modbus_tcp_pkg/README.md b/protocols/modbus_tcp_pkg/README.md new file mode 100644 index 0000000..03596f9 --- /dev/null +++ b/protocols/modbus_tcp_pkg/README.md @@ -0,0 +1,26 @@ +# Modbus TCP Package + +This package implements Modbus TCP over the unified external protocol package API. + +Supported methods: +- `send(ctx, request)` +- `recv(ctx, expect)` +- `rpc(ctx, request)` + +Supported ops: +- `read_holding` (FC03) +- `read_input` (FC04) +- `write_single_coil` (FC05) +- `write_single_register` (FC06) +- `write_multiple_coils` (FC15) +- `write_multiple_registers` (FC16) + +TCP frame format: +- `MBAP(7 bytes) + PDU` + +Request fields: +- `op` +- `unit_id` +- `address` +- `quantity` / `value` / `values` +- `transaction_id` (optional) diff --git a/protocols/modbus_tcp_pkg/impl.py b/protocols/modbus_tcp_pkg/impl.py new file mode 100644 index 0000000..53edb0b --- /dev/null +++ b/protocols/modbus_tcp_pkg/impl.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from typing import Any, Dict + +from dsl_runtime.protocol_package.modbus_codec import decode_response_pdu, encode_request_pdu, operation_to_fc + + +def _build_mbap(transaction_id: int, unit_id: int, pdu_len: int) -> bytes: + protocol_id = 0 + length = pdu_len + 1 + return ( + int(transaction_id & 0xFFFF).to_bytes(2, "big") + + int(protocol_id).to_bytes(2, "big") + + int(length).to_bytes(2, "big") + + bytes([unit_id & 0xFF]) + ) + + +def _parse_mbap(frame: bytes) -> Dict[str, Any]: + if len(frame) < 8: + raise ValueError("MODBUS_MBAP_INVALID: frame too short") + tid = int.from_bytes(frame[0:2], "big") + pid = int.from_bytes(frame[2:4], "big") + length = int.from_bytes(frame[4:6], "big") + unit = int(frame[6]) + pdu = frame[7:] + if pid != 0: + raise ValueError("MODBUS_MBAP_INVALID: protocol_id must be 0") + if length != len(pdu) + 1: + raise ValueError("MODBUS_MBAP_INVALID: length mismatch") + if not pdu: + raise ValueError("MODBUS_FRAME_INVALID: empty pdu") + return {"transaction_id": tid, "unit_id": unit, "pdu": pdu, "rx_hex": frame.hex().upper()} + + +class ProtocolPackage: + def __init__(self) -> None: + self._next_tid = 1 + + def _alloc_tid(self, request: Dict[str, Any]) -> int: + if "transaction_id" in request: + return int(request.get("transaction_id")) & 0xFFFF + tid = self._next_tid + self._next_tid = (self._next_tid + 1) & 0xFFFF + return tid + + def send(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + unit = int(request.get("unit_id", request.get("unit", 1))) + tid = self._alloc_tid(request) + pdu = encode_request_pdu(request) + frame = _build_mbap(tid, unit, len(pdu)) + pdu + ctx.channel.write(frame) + return { + "ok": True, + "transaction_id": tid, + "unit_id": unit, + "function": int(pdu[0]), + "raw": {"tx_hex": frame.hex().upper()}, + "size": len(frame), + } + + def recv(self, ctx, expect: Dict[str, Any]) -> Dict[str, Any]: + size = int(expect.get("size", 260)) + rx = ctx.channel.read(size, timeout=max(0.01, ctx.timeout_ms / 1000.0)) + if not rx: + raise TimeoutError("MODBUS_TIMEOUT: no response bytes") + parsed = _parse_mbap(rx) + op = str(expect.get("op", "")).strip().lower() + function = int(expect.get("function", operation_to_fc(op) if op else parsed["pdu"][0])) + quantity = expect.get("quantity") + decoded = decode_response_pdu(function, parsed["pdu"], quantity=quantity) + decoded["transaction_id"] = parsed["transaction_id"] + decoded["unit_id"] = parsed["unit_id"] + decoded["raw"] = {"rx_hex": parsed["rx_hex"]} + decoded["size"] = len(rx) + return decoded + + def rpc(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + tx = self.send(ctx, request) + rx_expect = dict(request.get("expect") or {}) + if "op" not in rx_expect and "op" in request: + rx_expect["op"] = request.get("op") + if "quantity" not in rx_expect and "quantity" in request: + rx_expect["quantity"] = request.get("quantity") + if "function" not in rx_expect and "function" in tx: + rx_expect["function"] = tx.get("function") + if "transaction_id" not in rx_expect and "transaction_id" in tx: + rx_expect["transaction_id"] = tx.get("transaction_id") + rx = self.recv(ctx, rx_expect) + out: Dict[str, Any] = {"ok": bool(rx.get("ok", True))} + out.update(rx) + out["raw"] = {"tx_hex": tx.get("raw", {}).get("tx_hex"), "rx_hex": rx.get("raw", {}).get("rx_hex")} + return out diff --git a/protocols/modbus_tcp_pkg/protocol.yaml b/protocols/modbus_tcp_pkg/protocol.yaml new file mode 100644 index 0000000..3ffb9bd --- /dev/null +++ b/protocols/modbus_tcp_pkg/protocol.yaml @@ -0,0 +1,15 @@ +id: "modbus_tcp" +name: "Modbus TCP Package" +version: "1.0.0" + +entry: + module: "impl" + class: "ProtocolPackage" + +api: + - send + - recv + - rpc + +config_schema: {} +message_schema: {} diff --git a/protocols/modbus_tcp_pkg/vectors.yaml b/protocols/modbus_tcp_pkg/vectors.yaml new file mode 100644 index 0000000..926c9cd --- /dev/null +++ b/protocols/modbus_tcp_pkg/vectors.yaml @@ -0,0 +1,99 @@ +version: "1" +protocol_id: "modbus_tcp" + +cases: + - id: "send_read_holding_fc03" + kind: "send" + input: + request: + op: "read_holding" + unit_id: 1 + address: 0 + quantity: 2 + transaction_id: 1 + expect: + ok: true + + - id: "send_write_single_register_fc06" + kind: "send" + input: + request: + op: "write_single_register" + unit_id: 1 + address: 1 + value: 123 + transaction_id: 2 + expect: + ok: true + + - id: "recv_read_holding_ok" + kind: "recv" + input: + expect: + op: "read_holding" + quantity: 2 + mock_rx_hex: "000100000007010304000A0001" + expect: + ok: true + + - id: "rpc_read_holding_fc03" + kind: "rpc" + input: + request: + op: "read_holding" + unit_id: 1 + address: 0 + quantity: 2 + transaction_id: 1 + mock_rx_hex: "000100000007010304000A0001" + expect: + ok: true + + - id: "recv_exception_response" + kind: "recv" + input: + expect: + op: "read_holding" + quantity: 2 + mock_rx_hex: "000100000003018302" + expect: + ok: true + data: + ok: false + error: + code: "MODBUS_EXCEPTION_RESPONSE" + + - id: "recv_mbap_invalid" + kind: "recv" + input: + expect: + op: "read_holding" + quantity: 2 + mock_rx_hex: "000100000005010304000A0001" + expect: + ok: false + error: + code: "MODBUS_MBAP_INVALID" + + - id: "recv_timeout" + kind: "recv" + input: + expect: + op: "read_holding" + quantity: 2 + expect: + ok: false + error: + code: "MODBUS_TIMEOUT" + + - id: "send_boundary_read_quantity_125" + kind: "send" + input: + request: + op: "read_holding" + unit_id: 1 + address: 0 + quantity: 125 + transaction_id: 7 + expect: + ok: true diff --git a/protocols/scpi_pkg/README.md b/protocols/scpi_pkg/README.md new file mode 100644 index 0000000..1d34ff2 --- /dev/null +++ b/protocols/scpi_pkg/README.md @@ -0,0 +1,24 @@ +# SCPI Package + +This package provides generic SCPI command/query flow over text channels. + +Supported methods: +- `send(ctx, request)` +- `recv(ctx, expect)` +- `rpc(ctx, request)` + +Request fields: +- `cmd` / `command` (required) +- `eol`: `lf|crlf|cr|none` (default `lf`) +- `skip_recv` (optional in rpc) + +Expect fields: +- `status`: `ok|error` +- `contains` +- `regex` +- `min_lines` +- `size` + +Parsing: +- Multi-line response split into `lines` +- First line parsed into `csv` scalar list (`value/unit/raw`) diff --git a/protocols/scpi_pkg/impl.py b/protocols/scpi_pkg/impl.py new file mode 100644 index 0000000..4f7f317 --- /dev/null +++ b/protocols/scpi_pkg/impl.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import re +from typing import Any, Dict, List + + +_NUM_UNIT_RE = re.compile(r"^\s*([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*([A-Za-z%]+)?\s*$") +_SCPI_ERR_RE = re.compile(r"^\s*-\d+\s*,") + + +def _normalize_eol(name: str) -> str: + key = str(name or "lf").strip().lower() + if key in {"lf", "\\n"}: + return "\n" + if key in {"crlf", "\\r\\n"}: + return "\r\n" + if key in {"cr", "\\r"}: + return "\r" + if key in {"none", ""}: + return "" + raise ValueError(f"SCPI_VALUE_INVALID: unsupported eol: {name}") + + +def _decode_text(raw: bytes, encoding: str = "utf-8") -> str: + return raw.decode(encoding, errors="ignore") + + +def _split_lines(text: str) -> List[str]: + normalized = text.replace("\r\n", "\n").replace("\r", "\n") + return [line.strip() for line in normalized.split("\n") if line.strip()] + + +def _parse_scalar(token: str) -> Dict[str, Any]: + m = _NUM_UNIT_RE.match(token) + if m is None: + return {"raw": token} + num_text = str(m.group(1)) + unit = str(m.group(2) or "") + if "." in num_text or "e" in num_text.lower(): + value: Any = float(num_text) + else: + value = int(num_text) + return {"value": value, "unit": unit, "raw": token} + + +def _parse_csv(line: str) -> List[Dict[str, Any]]: + parts = [part.strip() for part in line.split(",")] + return [_parse_scalar(part) for part in parts] + + +def _assert_expect(text: str, lines: List[str], status: str, expect: Dict[str, Any]) -> None: + if not expect: + return + if "status" in expect: + required = str(expect.get("status")).strip().lower() + if required and status != required: + raise ValueError(f"SCPI_EXPECT_FAILED: status expected={required}, actual={status}") + if "contains" in expect: + token = str(expect.get("contains")) + if token not in text: + raise ValueError(f"SCPI_EXPECT_FAILED: contains not found: {token}") + if "regex" in expect: + pattern = str(expect.get("regex")) + if re.search(pattern, text) is None: + raise ValueError(f"SCPI_EXPECT_FAILED: regex not matched: {pattern}") + if "min_lines" in expect: + min_lines = int(expect.get("min_lines")) + if len(lines) < min_lines: + raise ValueError(f"SCPI_EXPECT_FAILED: line count too small: {len(lines)} < {min_lines}") + + +class ProtocolPackage: + def send(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + cmd = str(request.get("cmd", request.get("command", ""))).strip() + if not cmd: + raise ValueError("SCPI_VALUE_INVALID: request.cmd is required") + eol = _normalize_eol(str(request.get("eol", "lf"))) + payload = (cmd + eol).encode("utf-8") + ctx.channel.write(payload) + return {"ok": True, "cmd": cmd, "raw": {"tx_text": cmd + eol, "tx_hex": payload.hex().upper()}, "size": len(payload)} + + def recv(self, ctx, expect: Dict[str, Any]) -> Dict[str, Any]: + size = int(expect.get("size", 4096)) + raw = ctx.channel.read(size, timeout=max(0.01, ctx.timeout_ms / 1000.0)) + if not raw: + raise TimeoutError("SCPI_TIMEOUT: no response bytes") + text = _decode_text(raw, encoding=str(expect.get("encoding", "utf-8"))) + lines = _split_lines(text) + status = "error" if any(_SCPI_ERR_RE.match(line) for line in lines) else "ok" + _assert_expect(text, lines, status, expect) + + first_line = lines[0] if lines else "" + csv_parsed = _parse_csv(first_line) if first_line else [] + return { + "ok": status == "ok", + "status": status, + "lines": lines, + "text": "\n".join(lines), + "csv": csv_parsed, + "raw": {"rx_hex": raw.hex().upper()}, + "size": len(raw), + } + + def rpc(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + tx = self.send(ctx, request) + cmd = str(request.get("cmd", request.get("command", ""))).strip() + expect = dict(request.get("expect") or {}) + skip_recv = bool(request.get("skip_recv", ("?" not in cmd and not expect))) + if skip_recv: + return {"ok": True, "status": "sent", "cmd": cmd, "raw": {"tx_hex": tx.get("raw", {}).get("tx_hex")}} + rx = self.recv(ctx, expect) + out: Dict[str, Any] = {"ok": bool(rx.get("ok", False))} + out.update(rx) + out["raw"] = {"tx_hex": tx.get("raw", {}).get("tx_hex"), "rx_hex": rx.get("raw", {}).get("rx_hex")} + return out diff --git a/protocols/scpi_pkg/protocol.yaml b/protocols/scpi_pkg/protocol.yaml new file mode 100644 index 0000000..6250ded --- /dev/null +++ b/protocols/scpi_pkg/protocol.yaml @@ -0,0 +1,15 @@ +id: "scpi" +name: "SCPI Package" +version: "1.0.0" + +entry: + module: "impl" + class: "ProtocolPackage" + +api: + - send + - recv + - rpc + +config_schema: {} +message_schema: {} diff --git a/protocols/scpi_pkg/vectors.yaml b/protocols/scpi_pkg/vectors.yaml new file mode 100644 index 0000000..0f5e3ab --- /dev/null +++ b/protocols/scpi_pkg/vectors.yaml @@ -0,0 +1,89 @@ +version: "1" +protocol_id: "scpi" + +cases: + - id: "send_write_basic" + kind: "send" + input: + request: + cmd: "*RST" + eol: "lf" + expect: + ok: true + + - id: "send_query_basic" + kind: "send" + input: + request: + cmd: "*IDN?" + expect: + ok: true + + - id: "recv_single_value" + kind: "recv" + input: + expect: + status: "ok" + mock_rx_text: "3.300V\n" + expect: + ok: true + data: + status: "ok" + + - id: "recv_csv_values" + kind: "recv" + input: + expect: + status: "ok" + mock_rx_text: "1.0,2.5,3.0\n" + expect: + ok: true + data: + status: "ok" + + - id: "recv_timeout" + kind: "recv" + input: + expect: + status: "ok" + expect: + ok: false + error: + code: "SCPI_TIMEOUT" + + - id: "recv_expect_regex_fail" + kind: "recv" + input: + expect: + status: "ok" + regex: "TEMP:" + mock_rx_text: "VOLT:3.300\n" + expect: + ok: false + error: + code: "SCPI_EXPECT_FAILED" + + - id: "rpc_query_ok" + kind: "rpc" + input: + request: + cmd: "*IDN?" + expect: + status: "ok" + contains: "ACME" + mock_rx_text: "ACME,MODEL-1,12345,1.0\n" + expect: + ok: true + data: + status: "ok" + + - id: "rpc_write_no_recv" + kind: "rpc" + input: + request: + cmd: "*CLS" + skip_recv: true + expect: + ok: true + data: + status: "sent" diff --git a/protocols/xmodem_pkg/README.md b/protocols/xmodem_pkg/README.md new file mode 100644 index 0000000..5d0e6ae --- /dev/null +++ b/protocols/xmodem_pkg/README.md @@ -0,0 +1,12 @@ +# XMODEM Package (Example) + +This is an external protocol package example for ProtoFlow YAML-DSL. + +Supported methods: +- `send(ctx, request)` for block or EOT frame +- `recv(ctx, expect)` for ACK/NAK or generic byte response +- `rpc(ctx, request)` send + recv + +Request examples: +- block: `{"kind":"block","seq":1,"data_hex":"010203"}` +- eot: `{"kind":"eot"}` diff --git a/protocols/xmodem_pkg/impl.py b/protocols/xmodem_pkg/impl.py new file mode 100644 index 0000000..d2170fc --- /dev/null +++ b/protocols/xmodem_pkg/impl.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import Any, Dict + + +SOH = 0x01 +EOT = 0x04 +ACK = 0x06 +NAK = 0x15 + + +def _checksum(payload: bytes) -> int: + return sum(payload) & 0xFF + + +def _build_block(seq: int, data: bytes) -> bytes: + if len(data) > 128: + raise ValueError("xmodem block data too large") + if len(data) < 128: + data = data + bytes([0x1A]) * (128 - len(data)) + seq_b = seq & 0xFF + return bytes([SOH, seq_b, 0xFF - seq_b]) + data + bytes([_checksum(data)]) + + +class ProtocolPackage: + def send(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + kind = str(request.get("kind", "block")).strip().lower() + if kind == "eot": + frame = bytes([EOT]) + ctx.channel.write(frame) + return {"tx_hex": frame.hex().upper(), "kind": "eot"} + + seq = int(request.get("seq", 1)) + data_hex = str(request.get("data_hex", "")).strip() + data = bytes.fromhex(data_hex.replace(" ", "")) + frame = _build_block(seq, data) + ctx.channel.write(frame) + return {"tx_hex": frame.hex().upper(), "kind": "block", "seq": seq} + + def recv(self, ctx, expect: Dict[str, Any]) -> Dict[str, Any]: + size = int(expect.get("size", 1)) + rx = ctx.channel.read(size, timeout=max(0.01, ctx.timeout_ms / 1000.0)) + if not rx: + raise TimeoutError("xmodem recv timeout") + return {"rx_hex": rx.hex().upper(), "ack": (rx[0] == ACK)} + + def rpc(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + tx = self.send(ctx, request) + rx_expect = request.get("expect") or {} + if not isinstance(rx_expect, dict): + rx_expect = {} + rx = self.recv(ctx, rx_expect) + out: Dict[str, Any] = {} + out.update(tx) + out.update(rx) + return out diff --git a/protocols/xmodem_pkg/protocol.yaml b/protocols/xmodem_pkg/protocol.yaml new file mode 100644 index 0000000..6dd4492 --- /dev/null +++ b/protocols/xmodem_pkg/protocol.yaml @@ -0,0 +1,15 @@ +id: "xmodem" +name: "XMODEM Package" +version: "1.0.0" + +entry: + module: "impl" + class: "ProtocolPackage" + +api: + - send + - recv + - rpc + +config_schema: {} +message_schema: {} diff --git a/protocols/xmodem_pkg/vectors.yaml b/protocols/xmodem_pkg/vectors.yaml new file mode 100644 index 0000000..e57f425 --- /dev/null +++ b/protocols/xmodem_pkg/vectors.yaml @@ -0,0 +1,35 @@ +version: "1" +protocol_id: "xmodem" + +cases: + - id: "send_block" + kind: "send" + input: + request: + kind: "block" + seq: 1 + data_hex: "01020304" + expect: + ok: true + + - id: "recv_ack" + kind: "recv" + input: + expect: + size: 1 + mock_rx_hex: "06" + expect: + ok: true + + - id: "rpc_block_ack" + kind: "rpc" + input: + request: + kind: "block" + seq: 1 + data_hex: "01020304" + expect: + size: 1 + mock_rx_hex: "06" + expect: + ok: true diff --git a/protocols/ymodem_pkg/README.md b/protocols/ymodem_pkg/README.md new file mode 100644 index 0000000..d6e1f2a --- /dev/null +++ b/protocols/ymodem_pkg/README.md @@ -0,0 +1,25 @@ +# YMODEM Package + +This package provides YMODEM transfer flow over byte-stream channels. + +Supported methods: +- `send(ctx, request)` +- `recv(ctx, expect)` +- `rpc(ctx, request)` + +Primary operation: +- `op: send_data` with `data_hex` +- `op: send_file` with `file_path` + +Request fields: +- `filename` (optional) +- `data_hex` or `file_path` +- `block_size`: `128|1024` (default `1024`) +- `max_retry` (default `10`) +- `timeout_ms` (optional) + +Features: +- Handshake (`'C'`) +- Header packet (block 0) +- Data block transfer with retry on NAK +- EOT and end packet sequence diff --git a/protocols/ymodem_pkg/impl.py b/protocols/ymodem_pkg/impl.py new file mode 100644 index 0000000..ff2a9ff --- /dev/null +++ b/protocols/ymodem_pkg/impl.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Tuple + + +SOH = 0x01 +STX = 0x02 +EOT = 0x04 +ACK = 0x06 +NAK = 0x15 +CAN = 0x18 +CRC_REQ = 0x43 # 'C' + + +def _crc16_xmodem(data: bytes) -> int: + crc = 0 + for byte in data: + crc ^= byte << 8 + for _ in range(8): + if crc & 0x8000: + crc = ((crc << 1) ^ 0x1021) & 0xFFFF + else: + crc = (crc << 1) & 0xFFFF + return crc & 0xFFFF + + +def _split_blocks(data: bytes, block_size: int) -> List[bytes]: + if block_size not in {128, 1024}: + raise ValueError("YMODEM_VALUE_INVALID: block_size must be 128 or 1024") + out: List[bytes] = [] + for i in range(0, len(data), block_size): + chunk = data[i : i + block_size] + if len(chunk) < block_size: + chunk = chunk + bytes([0x1A]) * (block_size - len(chunk)) + out.append(chunk) + if not out: + out.append(bytes([0x1A]) * block_size) + return out + + +def _make_packet(seq: int, payload: bytes) -> bytes: + size = len(payload) + if size == 128: + head = SOH + elif size == 1024: + head = STX + else: + raise ValueError("YMODEM_VALUE_INVALID: payload size must be 128 or 1024") + seq_b = seq & 0xFF + crc = _crc16_xmodem(payload) + return bytes([head, seq_b, 0xFF - seq_b]) + payload + bytes([(crc >> 8) & 0xFF, crc & 0xFF]) + + +def _make_header_packet(filename: str, total_size: int) -> bytes: + if not filename: + filename = "firmware.bin" + meta = f"{filename}\0{total_size}\0".encode("ascii", errors="ignore") + payload = meta[:128].ljust(128, b"\x00") + return _make_packet(0, payload) + + +def _make_end_packet() -> bytes: + return _make_packet(0, bytes(128)) + + +def _wait_symbol(ctx, allowed: Tuple[int, ...], timeout_ms: int) -> int: + remaining = int(timeout_ms) + while remaining > 0: + timeout_s = min(0.1, max(0.01, remaining / 1000.0)) + raw = ctx.channel.read(1, timeout=timeout_s) + if raw: + sym = int(raw[0]) + if sym == CAN: + raise RuntimeError("YMODEM_CANCELED: transfer canceled by receiver") + if sym in allowed: + return sym + remaining -= int(timeout_s * 1000) + raise TimeoutError(f"YMODEM_TIMEOUT: wait symbol {allowed}") + + +def _send_with_retry(ctx, packet: bytes, *, max_retry: int, timeout_ms: int) -> int: + retry_count = 0 + for _ in range(max_retry + 1): + ctx.channel.write(packet) + sym = _wait_symbol(ctx, (ACK, NAK, CAN), timeout_ms) + if sym == ACK: + return retry_count + if sym == NAK: + retry_count += 1 + continue + raise RuntimeError("YMODEM_RETRY_EXCEEDED: packet was not ACKed") + + +def _load_transfer_data(request: Dict[str, Any]) -> Tuple[str, bytes]: + if "data_hex" in request and request.get("data_hex") is not None: + data_hex = str(request.get("data_hex")).strip() + data = bytes.fromhex(data_hex.replace(" ", "")) + filename = str(request.get("filename", "firmware.bin")) + return filename, data + + file_path = str(request.get("file_path", "")).strip() + if not file_path: + raise ValueError("YMODEM_VALUE_INVALID: file_path or data_hex is required") + p = Path(file_path) + if not p.exists(): + raise FileNotFoundError(f"YMODEM_FILE_NOT_FOUND: {file_path}") + filename = str(request.get("filename", p.name)) + return filename, p.read_bytes() + + +def _symbol_name(sym: int) -> str: + if sym == ACK: + return "ACK" + if sym == NAK: + return "NAK" + if sym == CAN: + return "CAN" + if sym == CRC_REQ: + return "C" + if sym == EOT: + return "EOT" + return f"0x{sym:02X}" + + +class ProtocolPackage: + def send(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + op = str(request.get("op", "send_data")).strip().lower() + if op not in {"send_data", "send_file"}: + raise ValueError(f"YMODEM_VALUE_INVALID: unsupported op: {op}") + + timeout_ms = int(request.get("timeout_ms", ctx.timeout_ms)) + max_retry = int(request.get("max_retry", 10)) + block_size = int(request.get("block_size", 1024)) + if max_retry < 0: + raise ValueError("YMODEM_VALUE_INVALID: max_retry must be >= 0") + + filename, data = _load_transfer_data(request) + blocks = _split_blocks(data, block_size) + retries = 0 + + _wait_symbol(ctx, (CRC_REQ,), timeout_ms) + header = _make_header_packet(filename, len(data)) + retries += _send_with_retry(ctx, header, max_retry=max_retry, timeout_ms=timeout_ms) + _wait_symbol(ctx, (CRC_REQ,), timeout_ms) + + seq = 1 + for block in blocks: + pkt = _make_packet(seq, block) + retries += _send_with_retry(ctx, pkt, max_retry=max_retry, timeout_ms=timeout_ms) + seq = (seq + 1) & 0xFF + + ctx.channel.write(bytes([EOT])) + _wait_symbol(ctx, (ACK,), timeout_ms) + _wait_symbol(ctx, (CRC_REQ,), timeout_ms) + + end_pkt = _make_end_packet() + retries += _send_with_retry(ctx, end_pkt, max_retry=max_retry, timeout_ms=timeout_ms) + + return { + "ok": True, + "status": "done", + "filename": filename, + "bytes_total": len(data), + "blocks_total": len(blocks), + "retries": retries, + "block_size": block_size, + } + + def recv(self, ctx, expect: Dict[str, Any]) -> Dict[str, Any]: + timeout_ms = int(expect.get("timeout_ms", ctx.timeout_ms)) + allowed_names = expect.get("allowed", ["ACK", "NAK", "C", "CAN", "EOT"]) + if not isinstance(allowed_names, list) or not allowed_names: + raise ValueError("YMODEM_VALUE_INVALID: expect.allowed must be non-empty list") + map_name = {"ACK": ACK, "NAK": NAK, "C": CRC_REQ, "CAN": CAN, "EOT": EOT} + allowed = [] + for name in allowed_names: + key = str(name).strip().upper() + if key not in map_name: + raise ValueError(f"YMODEM_VALUE_INVALID: unsupported symbol: {name}") + allowed.append(map_name[key]) + sym = _wait_symbol(ctx, tuple(allowed), timeout_ms) + return {"ok": True, "symbol": _symbol_name(sym), "byte": sym} + + def rpc(self, ctx, request: Dict[str, Any]) -> Dict[str, Any]: + op = str(request.get("op", "send_data")).strip().lower() + if op in {"send_data", "send_file"}: + return self.send(ctx, request) + + send_req = request.get("send") or {} + recv_req = request.get("expect") or {} + if not isinstance(send_req, dict): + raise ValueError("YMODEM_VALUE_INVALID: request.send must be mapping") + if not isinstance(recv_req, dict): + raise ValueError("YMODEM_VALUE_INVALID: request.expect must be mapping") + tx = self.send(ctx, send_req) + rx = self.recv(ctx, recv_req) + out: Dict[str, Any] = {"ok": True} + out.update(tx) + out["recv"] = rx + return out diff --git a/protocols/ymodem_pkg/protocol.yaml b/protocols/ymodem_pkg/protocol.yaml new file mode 100644 index 0000000..718b5e2 --- /dev/null +++ b/protocols/ymodem_pkg/protocol.yaml @@ -0,0 +1,15 @@ +id: "ymodem" +name: "YMODEM Package" +version: "1.0.0" + +entry: + module: "impl" + class: "ProtocolPackage" + +api: + - send + - recv + - rpc + +config_schema: {} +message_schema: {} diff --git a/protocols/ymodem_pkg/vectors.yaml b/protocols/ymodem_pkg/vectors.yaml new file mode 100644 index 0000000..e867a1d --- /dev/null +++ b/protocols/ymodem_pkg/vectors.yaml @@ -0,0 +1,104 @@ +version: "1" +protocol_id: "ymodem" + +cases: + - id: "send_data_success" + kind: "send" + input: + request: + op: "send_data" + filename: "fw.bin" + data_hex: "01020304" + mock_rx_hex: "43064306064306" + expect: + ok: true + data: + ok: true + status: "done" + + - id: "send_data_retry_once" + kind: "send" + input: + request: + op: "send_data" + filename: "fw.bin" + data_hex: "A1A2A3A4" + mock_rx_hex: "4306431506064306" + expect: + ok: true + data: + retries: 1 + + - id: "send_data_timeout" + kind: "send" + input: + request: + op: "send_data" + filename: "fw.bin" + data_hex: "0102" + expect: + ok: false + error: + code: "YMODEM_TIMEOUT" + + - id: "send_data_canceled" + kind: "send" + input: + request: + op: "send_data" + filename: "fw.bin" + data_hex: "0102" + mock_rx_hex: "4318" + expect: + ok: false + error: + code: "YMODEM_CANCELED" + + - id: "send_data_invalid_op" + kind: "send" + input: + request: + op: "unknown" + data_hex: "01" + expect: + ok: false + error: + code: "YMODEM_VALUE_INVALID" + + - id: "send_data_block_128" + kind: "send" + input: + request: + op: "send_data" + filename: "small.bin" + data_hex: "01" + block_size: 128 + mock_rx_hex: "43064306064306" + expect: + ok: true + data: + block_size: 128 + + - id: "recv_symbol_ack" + kind: "recv" + input: + expect: + allowed: ["ACK"] + mock_rx_hex: "06" + expect: + ok: true + data: + symbol: "ACK" + + - id: "rpc_send_data_success" + kind: "rpc" + input: + request: + op: "send_data" + filename: "fw.bin" + data_hex: "11223344" + mock_rx_hex: "43064306064306" + expect: + ok: true + data: + status: "done" diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 index 6f857c4..a62c961 100644 --- a/scripts/build_windows.ps1 +++ b/scripts/build_windows.ps1 @@ -21,6 +21,7 @@ Write-Host "==> Build app (PyInstaller)" --add-data "config;config" ` --add-data "plugins;plugins" ` --add-data "ui\\assets;ui\\assets" ` + --add-data "VERSION;." ` main.py Write-Host "==> Generate installer icon" diff --git a/scripts/collect_regression_reports.py b/scripts/collect_regression_reports.py new file mode 100644 index 0000000..e1cdff1 --- /dev/null +++ b/scripts/collect_regression_reports.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path +from typing import Any, Dict, List + + +def _load_json(path: Path) -> Dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + if not isinstance(data, dict): + return None + return data + + +def _normalize_case_from_legacy(report: Dict[str, Any], source: Path) -> List[Dict[str, Any]]: + if isinstance(report.get("cases"), list): + out: List[Dict[str, Any]] = [] + for idx, item in enumerate(report["cases"]): + if isinstance(item, dict): + cid = str(item.get("id") or item.get("name") or f"case_{idx + 1}") + out.append( + { + "id": cid, + "ok": bool(item.get("ok", False)), + "elapsed_ms": int(item.get("elapsed_ms", 0)), + "source": str(source), + } + ) + elif isinstance(item, str): + out.append({"id": item, "ok": True, "elapsed_ms": 0, "source": str(source)}) + return out + return [ + { + "id": source.stem, + "ok": bool(report.get("ok", False)), + "elapsed_ms": int(report.get("elapsed_ms", 0)), + "source": str(source), + } + ] + + +def collect(input_dir: Path) -> Dict[str, Any]: + files = sorted(input_dir.rglob("*report*.json")) + merged_cases: List[Dict[str, Any]] = [] + for fp in files: + data = _load_json(fp) + if data is None: + continue + merged_cases.extend(_normalize_case_from_legacy(data, fp)) + overall_ok = all(bool(c.get("ok", False)) for c in merged_cases) if merged_cases else False + return { + "suite": "regression.aggregate", + "ok": overall_ok, + "generated_at": time.time(), + "cases": merged_cases, + "inputs": [str(x) for x in files], + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Collect regression report json files into one result model.") + parser.add_argument("--input-dir", default="runs", help="Directory to scan report json files") + parser.add_argument("--output", default="runs/regression_aggregate_report.json", help="Output json path") + args = parser.parse_args() + + inp = Path(args.input_dir).resolve() + out = Path(args.output).resolve() + result = collect(inp) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"aggregate report: {out}") + print(f"cases={len(result['cases'])} ok={result['ok']}") + return 0 if result["ok"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/comm_manager_regression_mock.py b/scripts/comm_manager_regression_mock.py new file mode 100644 index 0000000..c670b68 --- /dev/null +++ b/scripts/comm_manager_regression_mock.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import queue +import time +from pathlib import Path +import sys + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from infra.common.event_bus import EventBus +import infra.comm.communication_manager as cm + + +class _FakeSerialManager: + def __init__(self, bus: EventBus) -> None: + self._bus = bus + self._open = False + self.port = None + self.baudrate = None + + def open(self, port: str, baudrate: int) -> bool: + self.port = port + self.baudrate = baudrate + self._open = True + self._bus.publish("serial.opened", port) + return True + + def close(self) -> None: + self._open = False + self._bus.publish("serial.closed") + + def is_open(self) -> bool: + return self._open + + def send(self, _data: bytes) -> None: + pass + + @staticmethod + def list_ports() -> list[str]: + return ["COM1", "COM2"] + + +class _FakeTcpSession: + def __init__(self, bus: EventBus) -> None: + self._bus = bus + self._connected = False + self.endpoint = None + + def connect(self, ip: str, port: int) -> None: + self.endpoint = (ip, port) + self._connected = True + + def is_connected(self) -> bool: + return self._connected + + def close(self) -> None: + self._connected = False + self._bus.publish("tcp.disconnected") + + def send(self, _data: bytes) -> None: + pass + + +def _wait_event(q: "queue.Queue[dict]", kind: str, timeout: float = 2.0) -> bool: + end = time.time() + timeout + while time.time() < end: + try: + evt = q.get(timeout=0.1) + except queue.Empty: + continue + if evt.get("kind") == kind: + return True + return False + + +def main() -> int: + cm.SerialManager = _FakeSerialManager # type: ignore[assignment] + cm.TcpSession = _FakeTcpSession # type: ignore[assignment] + EventBus._log = staticmethod(lambda _message: None) # type: ignore[method-assign] + + bus = EventBus() + events: "queue.Queue[dict]" = queue.Queue() + bus.subscribe("comm.connected", lambda p: events.put({"kind": "connected", "payload": p})) + bus.subscribe("comm.disconnected", lambda p: events.put({"kind": "disconnected", "payload": p})) + bus.subscribe("comm.error", lambda p: events.put({"kind": "error", "payload": p})) + + mgr = cm.CommunicationManager(bus) + + mgr.select_serial("COM1", 115200) + serial_connected = _wait_event(events, "connected") + + mgr.close(notify=True) + serial_disconnected = _wait_event(events, "disconnected") + + mgr.select_tcp("127.0.0.1", 9000) + tcp_connected = _wait_event(events, "connected") + + mgr.close(notify=True) + tcp_disconnected = _wait_event(events, "disconnected") + + checks = [ + ("serial_connected", serial_connected), + ("serial_disconnected", serial_disconnected), + ("tcp_connected", tcp_connected), + ("tcp_disconnected", tcp_disconnected), + ] + ok = True + for name, passed in checks: + print(f"[{'PASS' if passed else 'FAIL'}] {name}") + ok = ok and passed + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/config_persistence_regression.py b/scripts/config_persistence_regression.py new file mode 100644 index 0000000..e7634a6 --- /dev/null +++ b/scripts/config_persistence_regression.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +import os +import sys +import tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from ui.desktop.web_bridge import WebBridge + + +def _write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def main() -> int: + ok = True + with tempfile.TemporaryDirectory(prefix="protoflow_cfg_") as tmp: + os.environ["LOCALAPPDATA"] = tmp + bridge = WebBridge(bus=None, comm=None, window=None, proxy_manager=None) + + # settings: create + backup + corrupt + fallback + settings_a = bridge._settings_defaults() # type: ignore[attr-defined] + settings_b = {**settings_a, "uiTheme": "Light"} + assert bridge._save_settings(settings_a) + assert bridge._save_settings(settings_b) + _write_text(bridge._settings_path, "{broken json") # type: ignore[attr-defined] + loaded_settings = bridge._load_settings() + s1 = loaded_settings.get("uiTheme") == "Light" + print(f"[{'PASS' if s1 else 'FAIL'}] settings_backup_fallback") + ok = ok and s1 + + # proxy pairs + bridge._proxy_pairs = [ # type: ignore[attr-defined] + { + "id": "p1", + "name": "pair", + "hostPort": "COM1", + "devicePort": "COM2", + "baud": "115200", + "status": "stopped", + "desiredActive": False, + } + ] + bridge._save_proxy_pairs() + bridge._proxy_pairs[0]["name"] = "pair2" # type: ignore[attr-defined] + bridge._save_proxy_pairs() + _write_text(bridge._proxy_pairs_path, "{oops") # type: ignore[attr-defined] + loaded_pairs = bridge._load_proxy_pairs() + p1 = bool(loaded_pairs) and loaded_pairs[0].get("name") == "pair2" + print(f"[{'PASS' if p1 else 'FAIL'}] proxy_backup_fallback") + ok = ok and p1 + + # ensure restored primary file is valid json after fallback + try: + json.loads(bridge._settings_path.read_text(encoding="utf-8")) # type: ignore[attr-defined] + restored = True + except Exception: + restored = False + print(f"[{'PASS' if restored else 'FAIL'}] restored_primary_json_valid") + ok = ok and restored + + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/examples/at_command_flow_v01.yaml b/scripts/examples/at_command_flow_v01.yaml new file mode 100644 index 0000000..99ef3a2 --- /dev/null +++ b/scripts/examples/at_command_flow_v01.yaml @@ -0,0 +1,45 @@ +version: "0.1" + +params: + port: "COM3" + baud: 115200 + +session: + transport: serial + port: "${port}" + baud: "${baud}" + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: none + +defaults: + timeout_ms: 2000 + +steps: + - id: at_ping + name: protocol.rpc + protocol: at_command + request: + cmd: "AT" + eol: "crlf" + expect: + status: "ok" + save_as: at_ping_result + + - id: at_csq + name: protocol.rpc + protocol: at_command + request: + cmd: "AT+CSQ" + eol: "crlf" + expect: + status: "ok" + regex: "\\+CSQ:\\s*\\d+,\\d+" + save_as: at_csq_result + + - id: assert_ok + name: assert + expr: "${at_ping_result.ok} and ${at_csq_result.ok}" + message: "AT flow failed" diff --git a/scripts/examples/modbus_ascii_flow_v01.yaml b/scripts/examples/modbus_ascii_flow_v01.yaml new file mode 100644 index 0000000..c1d578c --- /dev/null +++ b/scripts/examples/modbus_ascii_flow_v01.yaml @@ -0,0 +1,34 @@ +version: "0.1" + +params: + port: "COM4" + baud: 9600 + +session: + transport: serial + port: "${port}" + baud: "${baud}" + data_bits: 7 + parity: even + stop_bits: 1 + encoding: ascii + eol: none + +defaults: + timeout_ms: 2000 + +steps: + - id: read_holding + name: protocol.rpc + protocol: modbus_ascii + request: + op: read_holding + unit_id: 1 + address: 0 + quantity: 2 + save_as: ascii_read_result + + - id: assert_read_ok + name: assert + expr: "${ascii_read_result.ok}" + message: "modbus ascii read failed" diff --git a/scripts/examples/modbus_rtu_flow_v01.yaml b/scripts/examples/modbus_rtu_flow_v01.yaml new file mode 100644 index 0000000..7b117b8 --- /dev/null +++ b/scripts/examples/modbus_rtu_flow_v01.yaml @@ -0,0 +1,34 @@ +version: "0.1" + +params: + port: "COM3" + baud: 115200 + +session: + transport: serial + port: "${port}" + baud: "${baud}" + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: none + +defaults: + timeout_ms: 2000 + +steps: + - id: read_holding + name: protocol.rpc + protocol: modbus_rtu + request: + op: read_holding + unit_id: 1 + address: 0 + quantity: 2 + save_as: rtu_read_result + + - id: assert_read_ok + name: assert + expr: "${rtu_read_result.ok}" + message: "modbus rtu read failed" diff --git a/scripts/examples/modbus_tcp_flow_v01.yaml b/scripts/examples/modbus_tcp_flow_v01.yaml new file mode 100644 index 0000000..3911726 --- /dev/null +++ b/scripts/examples/modbus_tcp_flow_v01.yaml @@ -0,0 +1,35 @@ +version: "0.1" + +params: + host: "127.0.0.1" + port: 502 + +session: + transport: serial + port: "COM1" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: none + +defaults: + timeout_ms: 2000 + +steps: + - id: read_holding + name: protocol.rpc + protocol: modbus_tcp + request: + op: read_holding + transaction_id: 1 + unit_id: 1 + address: 0 + quantity: 2 + save_as: tcp_read_result + + - id: assert_read_ok + name: assert + expr: "${tcp_read_result.ok}" + message: "modbus tcp read failed" diff --git a/scripts/examples/scpi_flow_v01.yaml b/scripts/examples/scpi_flow_v01.yaml new file mode 100644 index 0000000..5806b6c --- /dev/null +++ b/scripts/examples/scpi_flow_v01.yaml @@ -0,0 +1,42 @@ +version: "0.1" + +params: + port: "COM5" + baud: 115200 + +session: + transport: serial + port: "${port}" + baud: "${baud}" + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: none + +defaults: + timeout_ms: 2000 + +steps: + - id: clear_status + name: protocol.rpc + protocol: scpi + request: + cmd: "*CLS" + skip_recv: true + save_as: cls_result + + - id: query_idn + name: protocol.rpc + protocol: scpi + request: + cmd: "*IDN?" + expect: + status: "ok" + contains: "," + save_as: idn_result + + - id: assert_scpi_ok + name: assert + expr: "${cls_result.ok} and ${idn_result.ok}" + message: "scpi flow failed" diff --git a/scripts/examples/target_at_smoke_v02.yaml b/scripts/examples/target_at_smoke_v02.yaml new file mode 100644 index 0000000..ee7a2f0 --- /dev/null +++ b/scripts/examples/target_at_smoke_v02.yaml @@ -0,0 +1,57 @@ +version: "0.2" + +params: + host_port: "COM12" + +session: + transport: serial + port: "${host_port}" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 3000 + read_timeout_ms: 200 + +defaults: + timeout_ms: 1200 + retry: + count: 1 + backoff_ms: 100 + strategy: fixed + +steps: + - name: send + text: "AT" + - name: expect + match: + type: contains + pattern: "OK" + + - name: send + text: "ATI" + - name: expect + match: + type: contains + pattern: "ProtoFlow-Target" + + - name: send + text: "AT+SN?" + - name: expect + match: + type: regex + pattern: "SN:[A-Z0-9]+" + capture: + - var: dut_sn + regex: "SN:([A-Z0-9]+)" + group: 1 + - name: assert + expr: "${dut_sn} != ''" + +artifacts: + dir: "./runs/target_at_smoke_${now}" + raw_log: true + summary_json: true + report_csv: true diff --git a/scripts/examples/ymodem_flow_v01.yaml b/scripts/examples/ymodem_flow_v01.yaml new file mode 100644 index 0000000..c5bf37f --- /dev/null +++ b/scripts/examples/ymodem_flow_v01.yaml @@ -0,0 +1,45 @@ +version: "0.1" + +params: + port: "COM3" + baud: 115200 + fw_path: "./firmware.bin" + +session: + transport: serial + port: "${port}" + baud: "${baud}" + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: none + +defaults: + timeout_ms: 5000 + +steps: + - id: enter_boot + name: send + text: "boot" + eol: crlf + + - id: wait_boot + name: sleep + ms: 300 + + - id: ymodem_send + name: protocol.rpc + protocol: ymodem + request: + op: send_file + file_path: "${fw_path}" + block_size: 1024 + max_retry: 10 + timeout_ms: 15000 + save_as: ymodem_result + + - id: assert_upgrade_ok + name: assert + expr: "${ymodem_result.ok}" + message: "ymodem transfer failed" diff --git a/scripts/freeze_target_baseline.py b/scripts/freeze_target_baseline.py new file mode 100644 index 0000000..b98c257 --- /dev/null +++ b/scripts/freeze_target_baseline.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import argparse +import io +import json +import sys +import time +from contextlib import redirect_stdout +from datetime import datetime +from pathlib import Path +from typing import Callable, Dict, List + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.target_emulator_fault_regression import main as target_fault_main +from scripts.target_emulator_regression import main as target_main + + +def _run_case(name: str, fn: Callable[[], int]) -> Dict[str, object]: + t0 = time.time() + buf = io.StringIO() + code = 2 + err = "" + try: + with redirect_stdout(buf): + code = int(fn()) + except Exception as exc: + err = str(exc) + elapsed_ms = int((time.time() - t0) * 1000) + out = buf.getvalue() + return { + "name": name, + "ok": code == 0 and not err, + "exit_code": code, + "elapsed_ms": elapsed_ms, + "error": err, + "stdout": out, + } + + +def _build_markdown(report: Dict[str, object]) -> str: + lines: List[str] = [] + lines.append("# Target Emulator Baseline Report") + lines.append("") + lines.append(f"- baseline: {report['baseline']}") + lines.append(f"- generated_at: {report['generated_at']}") + lines.append(f"- overall_ok: {report['overall_ok']}") + lines.append("") + lines.append("## Cases") + for case in report["cases"]: # type: ignore[index] + c = case # type: ignore[assignment] + lines.append( + f"- {c['name']}: {'PASS' if c['ok'] else 'FAIL'} (exit={c['exit_code']}, elapsed_ms={c['elapsed_ms']})" + ) + lines.append("") + lines.append("## Notes") + lines.append("- This is a frozen v0.1 target-emulator baseline snapshot.") + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Freeze target emulator baseline report") + parser.add_argument("--baseline", default="v0.1", help="baseline tag") + parser.add_argument("--output-dir", default="docs/target-emulator-baseline/v0.1", help="report output directory") + parser.add_argument("--stamp", default="", help="report stamp (default YYYYMMDD)") + args = parser.parse_args() + + out_dir = (ROOT / args.output_dir).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + + stamp = args.stamp.strip() or datetime.now().strftime("%Y%m%d") + cases = [ + _run_case("target_emulator_regression", target_main), + _run_case("target_emulator_fault_regression", target_fault_main), + ] + overall_ok = all(bool(item["ok"]) for item in cases) + report = { + "baseline": args.baseline, + "generated_at": datetime.now().isoformat(timespec="seconds"), + "overall_ok": overall_ok, + "cases": cases, + } + + json_path = out_dir / f"baseline_report_{stamp}.json" + md_path = out_dir / f"baseline_report_{stamp}.md" + json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + md_path.write_text(_build_markdown(report), encoding="utf-8") + + latest_json = out_dir / "baseline_report_latest.json" + latest_md = out_dir / "baseline_report_latest.md" + latest_json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + latest_md.write_text(_build_markdown(report), encoding="utf-8") + + print(f"baseline report written: {json_path}") + print(f"baseline report written: {md_path}") + return 0 if overall_ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install_virtual_serial_env.ps1 b/scripts/install_virtual_serial_env.ps1 new file mode 100644 index 0000000..27e2ad6 --- /dev/null +++ b/scripts/install_virtual_serial_env.ps1 @@ -0,0 +1,144 @@ +param( + [string]$Pair1A = "COM11", + [string]$Pair1B = "COM12", + [string]$Pair2A = "COM13", + [string]$Pair2B = "COM14", + [switch]$InstallDriver = $true, + [switch]$BootstrapChocolatey = $false +) + +$ErrorActionPreference = "Stop" + +function Write-Step([string]$text) { + Write-Host "[STEP] $text" -ForegroundColor Cyan +} + +function Test-Admin { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Find-Setupc { + $cmd = Get-Command setupc.exe -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + + $candidates = @( + "C:\Program Files (x86)\com0com\setupc.exe", + "C:\Program Files\com0com\setupc.exe" + ) + foreach ($path in $candidates) { + if (Test-Path $path) { + return $path + } + } + return $null +} + +function Ensure-Com0ComInstalled { + param( + [switch]$TryInstall, + [switch]$TryBootstrapChoco + ) + + $setupc = Find-Setupc + if ($setupc) { + return $setupc + } + + if (-not $TryInstall) { + return $null + } + + $choco = Get-Command choco.exe -ErrorAction SilentlyContinue + if ($choco) { + Write-Step "Installing com0com via Chocolatey" + & choco install com0com -y --no-progress | Out-Host + } else { + if ($TryBootstrapChoco) { + Write-Step "Chocolatey not found. Bootstrapping Chocolatey" + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + $env:Path += ";$env:ALLUSERSPROFILE\chocolatey\bin" + $choco = Get-Command choco.exe -ErrorAction SilentlyContinue + if ($choco) { + Write-Step "Installing com0com via Chocolatey" + & choco install com0com -y --no-progress | Out-Host + } else { + Write-Warning "Chocolatey bootstrap failed. Install com0com manually, then rerun this script." + return $null + } + } else { + Write-Warning "Chocolatey not found. Re-run with -BootstrapChocolatey or install com0com manually." + return $null + } + } + + $setupc = Find-Setupc + return $setupc +} + +function Get-SetupcList([string]$setupcPath) { + try { + return (& $setupcPath list 2>&1 | Out-String) + } catch { + return "" + } +} + +function Ensure-Pair { + param( + [string]$setupcPath, + [string]$PortA, + [string]$PortB + ) + + $listOutput = Get-SetupcList -setupcPath $setupcPath + $hasA = $listOutput -match [regex]::Escape("PortName=$PortA") + $hasB = $listOutput -match [regex]::Escape("PortName=$PortB") + if ($hasA -and $hasB) { + Write-Host "Pair exists: $PortA <-> $PortB" -ForegroundColor Green + return + } + + Write-Step "Creating pair: $PortA <-> $PortB" + & $setupcPath install "PortName=$PortA" "PortName=$PortB" +} + +function Show-SerialPorts { + Write-Step "Enumerating serial ports" + try { + $ports = Get-CimInstance Win32_SerialPort | Select-Object -ExpandProperty DeviceID + if ($ports) { + $ports | Sort-Object | ForEach-Object { Write-Host $_ } + } else { + Write-Host "(no serial ports found)" + } + } catch { + Write-Warning "Failed to enumerate serial ports by WMI: $($_.Exception.Message)" + } +} + +if (-not (Test-Admin)) { + throw "Please run PowerShell as Administrator." +} + +Write-Step "Checking com0com setup tool" +$setupcPath = Ensure-Com0ComInstalled -TryInstall:$InstallDriver -TryBootstrapChoco:$BootstrapChocolatey +if (-not $setupcPath) { + throw "com0com is not installed. Install it manually and rerun." +} +Write-Host "setupc: $setupcPath" -ForegroundColor Green + +Ensure-Pair -setupcPath $setupcPath -PortA $Pair1A -PortB $Pair1B +Ensure-Pair -setupcPath $setupcPath -PortA $Pair2A -PortB $Pair2B + +Show-SerialPorts + +Write-Host "" +Write-Host "Environment ready. Next commands:" -ForegroundColor Green +Write-Host ".\scripts\setup_proxy_test_env.ps1" +Write-Host ".\.venv\Scripts\python.exe scripts\proxy_regression.py --host-port $Pair1A --device-port $Pair2A --test-host-port $Pair1B --test-device-port $Pair2B --baud 115200 --iterations 30 --payload-size 64 --timeout-sec 2 --soak-sec 180 --json-out .\proxy_regression_report.json" diff --git a/scripts/nightly_virtual_serial_regression.ps1 b/scripts/nightly_virtual_serial_regression.ps1 new file mode 100644 index 0000000..13297be --- /dev/null +++ b/scripts/nightly_virtual_serial_regression.ps1 @@ -0,0 +1,44 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +$hostPort = $env:TARGET_HOST_PORT +$devicePort = $env:TARGET_DEVICE_PORT + +if ([string]::IsNullOrWhiteSpace($hostPort) -or [string]::IsNullOrWhiteSpace($devicePort)) { + Write-Host "[SKIP] TARGET_HOST_PORT / TARGET_DEVICE_PORT not configured; skip virtual-serial nightly regression." + exit 0 +} + +$python = "python" +$scenario = Join-Path $root "tools\target_emulator\scenarios\at_basic.yaml" +$dsl = Join-Path $root "scripts\examples\target_at_smoke_v02.yaml" +$out = Join-Path $root ("runs\nightly_target_emu_" + (Get-Date -Format "yyyyMMdd_HHmmss")) + +Write-Host "[1/3] Start target emulator on $devicePort ..." +$emuArgs = @( + "$root\scripts\target_emulator.py", + "--scenario", $scenario, + "--mode", "serial", + "--serial-port", $devicePort, + "--baud", "115200", + "--artifacts-dir", $out +) +$emu = Start-Process -FilePath $python -ArgumentList $emuArgs -PassThru -WindowStyle Hidden +Start-Sleep -Milliseconds 500 + +try { + Write-Host "[2/3] Run DSL smoke on $hostPort ..." + $env:PROTOFLOW_PROTOCOLS_DIR = Join-Path $root "protocols" + & $python "$root\app\dsl_main.py" $dsl + if ($LASTEXITCODE -ne 0) { + throw "dsl_main failed with code $LASTEXITCODE" + } +} +finally { + Write-Host "[3/3] Stop target emulator ..." + if ($emu -and !$emu.HasExited) { + Stop-Process -Id $emu.Id -Force + } +} + +Write-Host "Nightly virtual-serial regression passed. artifacts=$out" diff --git a/scripts/package_runtime_regression.py b/scripts/package_runtime_regression.py new file mode 100644 index 0000000..9a2ac4d --- /dev/null +++ b/scripts/package_runtime_regression.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import logging +import os +import sys +import tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from app.main_web import _setup_run_logging + + +def _check(name: str, cond: bool) -> bool: + print(f"[{'PASS' if cond else 'FAIL'}] {name}") + return cond + + +def _contains_all(path: Path, tokens: list[str]) -> bool: + text = path.read_text(encoding="utf-8") + return all(token in text for token in tokens) + + +def _verify_packaging_manifests() -> bool: + ok = True + ok &= _check( + "spec.datas_required", + _contains_all( + REPO_ROOT / "ProtoFlow.spec", + [ + "('ui\\\\frontend\\\\dist', 'ui\\\\frontend\\\\dist')", + "('config', 'config')", + "('plugins', 'plugins')", + "('ui\\\\assets', 'ui\\\\assets')", + "('VERSION', '.')", + ], + ), + ) + ok &= _check( + "build_script.datas_required", + _contains_all( + REPO_ROOT / "scripts" / "build_windows.ps1", + [ + '--add-data "ui\\\\frontend\\\\dist;ui\\\\frontend\\\\dist"', + '--add-data "config;config"', + '--add-data "plugins;plugins"', + '--add-data "ui\\\\assets;ui\\\\assets"', + '--add-data "VERSION;."', + ], + ), + ) + ok &= _check( + "installer.version_macro", + _contains_all( + REPO_ROOT / "installer" / "ProtoFlow.iss", + [ + "#ifndef MyAppVersion", + "AppVersion={#MyAppVersion}", + "OutputBaseFilename=ProtoFlow-{#MyAppVersion}-setup", + ], + ), + ) + return ok + + +def _verify_runtime_logging() -> bool: + ok = True + old_stdout = sys.stdout + old_stderr = sys.stderr + old_unraisable = getattr(sys, "unraisablehook", None) + old_localappdata = os.environ.get("LOCALAPPDATA") + + try: + with tempfile.TemporaryDirectory(prefix="protoflow_pkg_") as tmp: + os.environ["LOCALAPPDATA"] = tmp + log_path = _setup_run_logging() + print("package_runtime_probe") + sys.stdout.flush() + sys.stderr.flush() + logging.shutdown() + + log_exists = log_path.exists() and log_path.parent.name == "logs" + ok &= _check("runtime.log_path_created", log_exists) + + content = log_path.read_text(encoding="utf-8") if log_exists else "" + ok &= _check("runtime.log_contains_probe", "package_runtime_probe" in content) + for stream in (sys.stdout, sys.stderr): + handle = getattr(stream, "_file", None) + if handle is not None and not getattr(handle, "closed", True): + handle.close() + finally: + sys.stdout = old_stdout + sys.stderr = old_stderr + if old_unraisable is not None: + sys.unraisablehook = old_unraisable + if old_localappdata is None: + os.environ.pop("LOCALAPPDATA", None) + else: + os.environ["LOCALAPPDATA"] = old_localappdata + + return ok + + +def main() -> int: + ok = True + ok &= _verify_packaging_manifests() + ok &= _verify_runtime_logging() + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/packet_engine_regression.py b/scripts/packet_engine_regression.py new file mode 100644 index 0000000..6d53c3f --- /dev/null +++ b/scripts/packet_engine_regression.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from pathlib import Path +import sys + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.packet_engine import PacketAnalysisEngine +from infra.common.event_bus import EventBus +from infra.protocol.protocol_loader import crc16_modbus + + +def _build_modbus_frame(body: bytes) -> bytes: + crc = crc16_modbus(body) + return body + crc.to_bytes(2, "little") + + +def main() -> int: + bus = EventBus() + EventBus._log = staticmethod(lambda _m: None) # type: ignore[method-assign] + engine = PacketAnalysisEngine(bus) + + checks: list[tuple[str, bool]] = [] + + valid = _build_modbus_frame(bytes.fromhex("01 03 00 00 00 02")) + p_name, p_unknown, _summary, _tree, errors = engine._parse_protocol(valid) + checks.append(("valid.modbus_name", p_name == "Modbus RTU")) + checks.append(("valid.not_unknown", p_unknown is False)) + checks.append(("valid.no_errors", len(errors) == 0)) + + bad_crc = valid[:-1] + bytes([valid[-1] ^ 0xFF]) + p_name, p_unknown, _summary, _tree, errors = engine._parse_protocol(bad_crc) + has_crc_error = any(e.get("code") == "CRC_INVALID" for e in errors) + checks.append(("bad_crc.modbus_name", p_name == "Modbus RTU")) + checks.append(("bad_crc.not_unknown", p_unknown is False)) + checks.append(("bad_crc.has_crc_error", has_crc_error)) + + too_short = bytes.fromhex("01 03 00") + p_name, p_unknown, _summary, _tree, errors = engine._parse_protocol(too_short) + has_short_error = any(e.get("code") == "FRAME_TOO_SHORT" for e in errors) + checks.append(("short.modbus_name", p_name == "Modbus RTU")) + checks.append(("short.not_unknown", p_unknown is False)) + checks.append(("short.has_short_error", has_short_error)) + + exc_invalid_len = _build_modbus_frame(bytes.fromhex("01 83 02 01")) + p_name, p_unknown, _summary, _tree, errors = engine._parse_protocol(exc_invalid_len) + has_len_error = any(e.get("code") == "LENGTH_INVALID" for e in errors) + checks.append(("exception_len.modbus_name", p_name == "Modbus RTU")) + checks.append(("exception_len.not_unknown", p_unknown is False)) + checks.append(("exception_len.has_len_error", has_len_error)) + + ok = True + for name, passed in checks: + print(f"[{'PASS' if passed else 'FAIL'}] {name}") + ok = ok and passed + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/protocol_package_regression.py b/scripts/protocol_package_regression.py new file mode 100644 index 0000000..dcd47ab --- /dev/null +++ b/scripts/protocol_package_regression.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from dsl_runtime.protocol_package import load_protocol_packages, run_protocol_vectors +from dsl_runtime.protocol_package.gateway import ProtocolPackageGateway +from dsl_runtime.protocol_package.runtime import ProtocolCallContext + + +class _Channel: + def __init__(self, rx: bytes = b"") -> None: + self.rx = bytearray(rx) + self.tx = bytearray() + + def write(self, data): + if isinstance(data, str): + self.tx.extend(data.encode("utf-8")) + else: + self.tx.extend(bytes(data)) + + def read(self, size: int = 256, timeout: float = 0.2) -> bytes: + if not self.rx: + return b"" + n = min(size, len(self.rx)) + out = bytes(self.rx[:n]) + del self.rx[:n] + return out + + +def _write_pkg(root: Path) -> None: + pkg = root / "demo_pkg" + pkg.mkdir(parents=True, exist_ok=True) + (pkg / "README.md").write_text("demo", encoding="utf-8") + (pkg / "protocol.yaml").write_text( + yaml.safe_dump( + { + "id": "demo", + "name": "Demo Package", + "version": "1.0.0", + "entry": {"module": "impl", "class": "ProtocolPackage"}, + "api": ["send", "recv", "rpc"], + }, + sort_keys=False, + allow_unicode=True, + ), + encoding="utf-8", + ) + (pkg / "impl.py").write_text( + "\n".join( + [ + "class ProtocolPackage:", + " def send(self, ctx, request):", + " ctx.channel.write(bytes.fromhex(request.get('hex', '')))", + " return {'sent': True}", + " def recv(self, ctx, expect):", + " size = int(expect.get('size', 1))", + " rx = ctx.channel.read(size, timeout=ctx.timeout_ms / 1000.0)", + " if not rx:", + " raise TimeoutError('timeout')", + " return {'rx_hex': rx.hex().upper()}", + " def rpc(self, ctx, request):", + " self.send(ctx, request)", + " return self.recv(ctx, request.get('expect', {}))", + ] + ), + encoding="utf-8", + ) + (pkg / "vectors.yaml").write_text( + yaml.safe_dump( + { + "version": "1", + "protocol_id": "demo", + "cases": [ + {"id": "send_ok", "kind": "send", "input": {"request": {"hex": "01"}}, "expect": {"ok": True}}, + { + "id": "recv_ok", + "kind": "recv", + "input": {"expect": {"size": 1}, "mock_rx_hex": "06"}, + "expect": {"ok": True}, + }, + ], + }, + sort_keys=False, + allow_unicode=True, + ), + encoding="utf-8", + ) + + +def main() -> int: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_pkg(root) + load = load_protocol_packages(root) + assert "demo" in load.packages, "package load failed" + + gateway = ProtocolPackageGateway(load.packages) + ctx = ProtocolCallContext(channel=_Channel(rx=b"\x06"), logger=None, vars={}, timeout_ms=1000, artifacts={}) + ok = gateway.call("demo", "rpc", ctx, {"hex": "01", "expect": {"size": 1}}) + assert ok.ok, f"rpc failed: {ok.error}" + bad = gateway.call("missing", "send", ctx, {}) + assert not bad.ok and bad.error and bad.error.get("code") == "PROTOCOL_NOT_FOUND" + + vectors = run_protocol_vectors(root, "demo") + assert vectors.ok, "vectors run failed" + print("protocol package regression: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/protocol_package_test_suite.py b/scripts/protocol_package_test_suite.py new file mode 100644 index 0000000..477acc2 --- /dev/null +++ b/scripts/protocol_package_test_suite.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = ROOT / "scripts" +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from dsl_runtime.protocol_package import load_protocol_packages, run_protocol_vectors +from protocol_package_regression import main as regression_main + + +def _assert_load_ok(root: Path) -> None: + load = load_protocol_packages(root) + errors = [x for x in load.issues if x.level == "error"] + assert not errors, f"load issues: {[x.message for x in errors]}" + assert "at_command" in load.packages, "at_command package missing" + assert "ymodem" in load.packages, "ymodem package missing" + assert "scpi" in load.packages, "scpi package missing" + assert "modbus_rtu" in load.packages, "modbus_rtu package missing" + assert "modbus_ascii" in load.packages, "modbus_ascii package missing" + assert "modbus_tcp" in load.packages, "modbus_tcp package missing" + assert "xmodem" in load.packages, "xmodem package missing" + + +def _assert_vectors_ok(root: Path, protocol_id: str) -> None: + result = run_protocol_vectors(root, protocol_id) + assert result.ok, f"vectors failed: {protocol_id}" + + +def main() -> int: + root = Path("protocols").resolve() + _assert_load_ok(root) + _assert_vectors_ok(root, "at_command") + _assert_vectors_ok(root, "ymodem") + _assert_vectors_ok(root, "scpi") + _assert_vectors_ok(root, "modbus_rtu") + _assert_vectors_ok(root, "modbus_ascii") + _assert_vectors_ok(root, "modbus_tcp") + _assert_vectors_ok(root, "xmodem") + assert regression_main() == 0, "protocol_package_regression failed" + print("protocol package test suite: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/protocol_vectors_regression.py b/scripts/protocol_vectors_regression.py new file mode 100644 index 0000000..85c2fdc --- /dev/null +++ b/scripts/protocol_vectors_regression.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from dsl_runtime.protocol_package import run_protocol_vectors + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run protocol package vectors.yaml cases.") + parser.add_argument("--root", default="protocols", help="Protocol packages root directory") + parser.add_argument("--protocol", required=True, help="Protocol package id") + parser.add_argument("--json", action="store_true", help="Print full JSON result") + args = parser.parse_args() + + root = Path(args.root).resolve() + result = run_protocol_vectors(root, args.protocol) + total = len(result.cases) + passed = sum(1 for c in result.cases if c.ok) + print(f"[vectors] protocol={result.protocol_id} package={result.package_dir}") + print(f"[vectors] passed={passed}/{total}") + for case in result.cases: + status = "PASS" if case.ok else "FAIL" + print(f"- {status} {case.case_id} ({case.kind})") + if args.json: + payload = { + "ok": result.ok, + "protocol_id": result.protocol_id, + "package_dir": result.package_dir, + "cases": [ + { + "id": c.case_id, + "kind": c.kind, + "ok": c.ok, + "expected": c.expected, + "actual": c.actual, + "error": c.error, + } + for c in result.cases + ], + } + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 if result.ok else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/proxy_regression.py b/scripts/proxy_regression.py new file mode 100644 index 0000000..a9db078 --- /dev/null +++ b/scripts/proxy_regression.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import argparse +import json +import os +import queue +import statistics +import sys +import time +from pathlib import Path +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +try: + import serial +except Exception: # pragma: no cover + serial = None # type: ignore[assignment] + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from infra.common.event_bus import EventBus +from infra.comm.proxy_forward_manager import ProxyForwardManager + + +@dataclass +class CaseResult: + name: str + passed: bool + details: str = "" + samples_ms: List[float] = field(default_factory=list) + + +def _now() -> float: + return time.perf_counter() + + +def _drain_serial(ser: serial.Serial) -> None: + end = time.time() + 0.2 + while time.time() < end: + waiting = ser.in_waiting if ser.is_open else 0 + if waiting <= 0: + break + ser.read(waiting) + + +def _await_bytes(ser: serial.Serial, expected_len: int, timeout_sec: float) -> bytes: + deadline = time.time() + timeout_sec + chunks = bytearray() + while time.time() < deadline: + waiting = ser.in_waiting if ser.is_open else 0 + if waiting > 0: + chunks.extend(ser.read(waiting)) + if len(chunks) >= expected_len: + return bytes(chunks) + time.sleep(0.005) + return bytes(chunks) + + +def _await_event( + q: "queue.Queue[Dict[str, Any]]", + pair_id: str, + expected_src_role: str, + timeout_sec: float, +) -> Optional[Dict[str, Any]]: + deadline = time.time() + timeout_sec + while time.time() < deadline: + wait_left = max(0.01, deadline - time.time()) + try: + item = q.get(timeout=wait_left) + except queue.Empty: + continue + if not isinstance(item, dict): + continue + if str(item.get("pair_id")) != pair_id: + continue + if str(item.get("src_role") or "").lower() != expected_src_role: + continue + return item + return None + + +def _roundtrip_once( + write_ser: serial.Serial, + read_ser: serial.Serial, + payload: bytes, + timeout_sec: float, +) -> tuple[bool, str, float]: + _drain_serial(write_ser) + _drain_serial(read_ser) + t0 = _now() + write_ser.write(payload) + got = _await_bytes(read_ser, len(payload), timeout_sec) + elapsed_ms = (_now() - t0) * 1000.0 + if got != payload: + return False, f"payload mismatch: expect={payload.hex()} got={got.hex()}", elapsed_ms + return True, "ok", elapsed_ms + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Proxy forwarding regression for real/virtual serial devices.", + ) + parser.add_argument("--host-port", required=True, help="Port opened by proxy as host side.") + parser.add_argument("--device-port", required=True, help="Port opened by proxy as device side.") + parser.add_argument( + "--test-host-port", + required=True, + help="Peer port used by tester to inject/read host-side traffic.", + ) + parser.add_argument( + "--test-device-port", + required=True, + help="Peer port used by tester to inject/read device-side traffic.", + ) + parser.add_argument("--baud", type=int, default=115200) + parser.add_argument("--data-bits", default="8", choices=["5", "6", "7", "8"]) + parser.add_argument("--stop-bits", default="1", choices=["1", "1.5", "2"]) + parser.add_argument("--parity", default="none", choices=["none", "even", "odd", "mark", "space"]) + parser.add_argument("--flow-control", default="none", choices=["none", "rtscts", "xonxoff"]) + parser.add_argument("--iterations", type=int, default=20, help="Count for each direction.") + parser.add_argument("--payload-size", type=int, default=64, help="Bytes per case.") + parser.add_argument("--timeout-sec", type=float, default=2.0) + parser.add_argument("--soak-sec", type=int, default=0, help="Optional long-run stress duration.") + parser.add_argument("--json-out", default="", help="Optional path to save machine-readable report.") + args = parser.parse_args() + if serial is None: + print("pyserial is required. Install with: pip install -r requirements.txt") + return 2 + + pair_id = f"proxy-regression-{int(time.time())}" + bus = EventBus() + manager = ProxyForwardManager(bus) + data_events: "queue.Queue[Dict[str, Any]]" = queue.Queue() + status_events: "queue.Queue[Dict[str, Any]]" = queue.Queue() + bus.subscribe("proxy.data", lambda payload: data_events.put(payload if isinstance(payload, dict) else {})) + bus.subscribe("proxy.status", lambda payload: status_events.put(payload if isinstance(payload, dict) else {})) + + cfg: Dict[str, Any] = { + "hostPort": args.host_port, + "devicePort": args.device_port, + "baud": args.baud, + "dataBits": args.data_bits, + "stopBits": args.stop_bits, + "parity": args.parity, + "flowControl": args.flow_control, + } + + report: Dict[str, Any] = { + "pair_id": pair_id, + "ports": { + "host_port": args.host_port, + "device_port": args.device_port, + "test_host_port": args.test_host_port, + "test_device_port": args.test_device_port, + }, + "settings": { + "baud": args.baud, + "data_bits": args.data_bits, + "stop_bits": args.stop_bits, + "parity": args.parity, + "flow_control": args.flow_control, + "iterations": args.iterations, + "payload_size": args.payload_size, + "timeout_sec": args.timeout_sec, + "soak_sec": args.soak_sec, + }, + "results": [], + "status": "failed", + } + + results: List[CaseResult] = [] + test_host: Optional[serial.Serial] = None + test_device: Optional[serial.Serial] = None + started = manager.start_pair(pair_id, cfg) + if not started.ok: + results.append(CaseResult("start_pair", False, started.error)) + report["results"] = [r.__dict__ for r in results] + if args.json_out: + with open(args.json_out, "w", encoding="utf-8") as f: + json.dump(report, f, ensure_ascii=False, indent=2) + print(f"[FAIL] start_pair: {started.error}") + return 2 + + try: + test_host = serial.Serial(args.test_host_port, args.baud, timeout=0.1, write_timeout=0.5) + test_device = serial.Serial(args.test_device_port, args.baud, timeout=0.1, write_timeout=0.5) + + running_seen = False + end_status_wait = time.time() + 2.0 + while time.time() < end_status_wait: + try: + evt = status_events.get(timeout=0.2) + except queue.Empty: + continue + if evt.get("pair_id") == pair_id and evt.get("status") == "running": + running_seen = True + break + results.append(CaseResult("status.running", running_seen, "" if running_seen else "no running status event")) + + h2d_samples: List[float] = [] + h2d_ok = True + h2d_msg = "ok" + for _ in range(max(1, args.iterations)): + payload = os.urandom(max(1, args.payload_size)) + ok, msg, elapsed = _roundtrip_once(test_host, test_device, payload, args.timeout_sec) + if not ok: + h2d_ok = False + h2d_msg = msg + break + evt = _await_event(data_events, pair_id, "host", args.timeout_sec) + if evt is None: + h2d_ok = False + h2d_msg = "missing proxy.data event for host direction" + break + h2d_samples.append(elapsed) + results.append(CaseResult("host_to_device.forward", h2d_ok, h2d_msg, h2d_samples)) + + d2h_samples: List[float] = [] + d2h_ok = True + d2h_msg = "ok" + for _ in range(max(1, args.iterations)): + payload = os.urandom(max(1, args.payload_size)) + ok, msg, elapsed = _roundtrip_once(test_device, test_host, payload, args.timeout_sec) + if not ok: + d2h_ok = False + d2h_msg = msg + break + evt = _await_event(data_events, pair_id, "device", args.timeout_sec) + if evt is None: + d2h_ok = False + d2h_msg = "missing proxy.data event for device direction" + break + d2h_samples.append(elapsed) + results.append(CaseResult("device_to_host.forward", d2h_ok, d2h_msg, d2h_samples)) + + if args.soak_sec > 0 and h2d_ok and d2h_ok: + soak_end = time.time() + args.soak_sec + soak_samples: List[float] = [] + soak_ok = True + soak_msg = "ok" + toggle = True + while time.time() < soak_end: + payload = os.urandom(16) + if toggle: + ok, msg, elapsed = _roundtrip_once(test_host, test_device, payload, args.timeout_sec) + if ok: + evt = _await_event(data_events, pair_id, "host", args.timeout_sec) + ok = evt is not None + if not ok: + msg = "missing proxy.data event during soak(host)" + else: + ok, msg, elapsed = _roundtrip_once(test_device, test_host, payload, args.timeout_sec) + if ok: + evt = _await_event(data_events, pair_id, "device", args.timeout_sec) + ok = evt is not None + if not ok: + msg = "missing proxy.data event during soak(device)" + if not ok: + soak_ok = False + soak_msg = msg + break + soak_samples.append(elapsed) + toggle = not toggle + time.sleep(0.02) + results.append(CaseResult("soak.forwarding", soak_ok, soak_msg, soak_samples)) + + except Exception as exc: + results.append(CaseResult("runtime", False, str(exc))) + finally: + if test_host is not None: + try: + test_host.close() + except Exception: + pass + if test_device is not None: + try: + test_device.close() + except Exception: + pass + manager.stop_pair(pair_id) + + passed = all(r.passed for r in results) + report["status"] = "passed" if passed else "failed" + report["results"] = [] + for item in results: + row: Dict[str, Any] = { + "name": item.name, + "passed": item.passed, + "details": item.details, + } + if item.samples_ms: + row["latency_ms"] = { + "count": len(item.samples_ms), + "avg": round(statistics.mean(item.samples_ms), 3), + "p95": round(sorted(item.samples_ms)[max(0, int(len(item.samples_ms) * 0.95) - 1)], 3), + "max": round(max(item.samples_ms), 3), + } + report["results"].append(row) + + for row in report["results"]: + mark = "PASS" if row["passed"] else "FAIL" + line = f"[{mark}] {row['name']}" + if row.get("latency_ms"): + latency = row["latency_ms"] + line += f" avg={latency['avg']}ms p95={latency['p95']}ms max={latency['max']}ms" + if row.get("details"): + line += f" | {row['details']}" + print(line) + print(f"RESULT: {report['status'].upper()}") + + if args.json_out: + with open(args.json_out, "w", encoding="utf-8") as f: + json.dump(report, f, ensure_ascii=False, indent=2) + print(f"report saved: {args.json_out}") + + return 0 if passed else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/proxy_regression_mock.py b/scripts/proxy_regression_mock.py new file mode 100644 index 0000000..797301b --- /dev/null +++ b/scripts/proxy_regression_mock.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import argparse +import queue +import statistics +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import sys + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from infra.common.event_bus import EventBus +import infra.comm.proxy_forward_manager as pfm + + +@dataclass +class TestResult: + name: str + passed: bool + detail: str = "" + samples_ms: List[float] = field(default_factory=list) + + +class _Endpoint: + def __init__(self, name: str) -> None: + self.name = name + self.peer: Optional["_Endpoint"] = None + self.rx = queue.Queue() + self.is_open = True + + @property + def in_waiting(self) -> int: + return self.rx.qsize() + + def read(self, size: int) -> bytes: + data = bytearray() + for _ in range(max(0, size)): + try: + data.extend(self.rx.get_nowait()) + except queue.Empty: + break + return bytes(data) + + def write(self, data: bytes) -> int: + if not self.is_open: + raise OSError("port closed") + if not self.peer or not self.peer.is_open: + raise OSError("peer closed") + for b in data: + self.peer.rx.put(bytes([b])) + return len(data) + + def close(self) -> None: + self.is_open = False + + +_endpoints: Dict[str, _Endpoint] = {} +_pairs = { + "VCOM11": "VCOM12", + "VCOM12": "VCOM11", + "VCOM13": "VCOM14", + "VCOM14": "VCOM13", +} +_lock = threading.Lock() + + +def _fake_serial_ctor(port: str, **_: Any) -> _Endpoint: + with _lock: + if port not in _pairs: + raise OSError(f"unknown virtual port: {port}") + ep = _endpoints.get(port) + if ep is None or not ep.is_open: + ep = _Endpoint(port) + _endpoints[port] = ep + peer_name = _pairs[port] + peer = _endpoints.get(peer_name) + if peer is None or not peer.is_open: + peer = _Endpoint(peer_name) + _endpoints[peer_name] = peer + ep.peer = peer + peer.peer = ep + return ep + + +def _await_exact(port: _Endpoint, expect: bytes, timeout_sec: float) -> bool: + deadline = time.time() + timeout_sec + buf = bytearray() + while time.time() < deadline: + waiting = port.in_waiting + if waiting > 0: + buf.extend(port.read(waiting)) + if bytes(buf) == expect: + return True + if len(buf) > len(expect): + return False + time.sleep(0.002) + return False + + +def _roundtrip(src: _Endpoint, dst: _Endpoint, payload: bytes, timeout_sec: float) -> tuple[bool, float]: + t0 = time.perf_counter() + src.write(payload) + ok = _await_exact(dst, payload, timeout_sec) + return ok, (time.perf_counter() - t0) * 1000.0 + + +def _wait_status( + q: "queue.Queue[Dict[str, Any]]", + pair_id: str, + status: str, + timeout_sec: float, +) -> bool: + deadline = time.time() + timeout_sec + while time.time() < deadline: + try: + evt = q.get(timeout=0.1) + except queue.Empty: + continue + if evt.get("pair_id") == pair_id and evt.get("status") == status: + return True + return False + + +def _wait_data_event(q: "queue.Queue[Dict[str, Any]]", pair_id: str, timeout_sec: float) -> bool: + deadline = time.time() + timeout_sec + while time.time() < deadline: + try: + evt = q.get(timeout=0.1) + except queue.Empty: + continue + if evt.get("pair_id") == pair_id and evt.get("src_role") in {"host", "device"}: + return True + return False + + +def main() -> int: + parser = argparse.ArgumentParser(description="Mock proxy regression without serial driver.") + parser.add_argument("--iterations", type=int, default=20) + parser.add_argument("--payload-size", type=int, default=32) + parser.add_argument("--timeout-sec", type=float, default=2.0) + parser.add_argument("--soak-sec", type=int, default=0) + parser.add_argument("--inject-disconnect", action="store_true") + args = parser.parse_args() + + pfm.serial.Serial = _fake_serial_ctor # type: ignore[assignment] + EventBus._log = staticmethod(lambda _message: None) # type: ignore[method-assign] + bus = EventBus() + manager = pfm.ProxyForwardManager(bus) + status_events: "queue.Queue[Dict[str, Any]]" = queue.Queue() + data_events: "queue.Queue[Dict[str, Any]]" = queue.Queue() + bus.subscribe("proxy.status", lambda p: status_events.put(p if isinstance(p, dict) else {})) + bus.subscribe("proxy.data", lambda p: data_events.put(p if isinstance(p, dict) else {})) + + pair_id = "mock-pair-1" + cfg = { + "hostPort": "VCOM11", + "devicePort": "VCOM13", + "baud": "115200", + "dataBits": "8", + "stopBits": "1", + "parity": "none", + "flowControl": "none", + } + results: List[TestResult] = [] + + start = manager.start_pair(pair_id, cfg) + results.append(TestResult("start_pair", start.ok, start.error)) + if not start.ok: + for r in results: + print(f"[{'PASS' if r.passed else 'FAIL'}] {r.name} {r.detail}".rstrip()) + print("RESULT: FAILED") + return 2 + + t_host = _fake_serial_ctor("VCOM12") + t_device = _fake_serial_ctor("VCOM14") + + h2d_samples: List[float] = [] + d2h_samples: List[float] = [] + loops_ok = True + loops_msg = "ok" + for _ in range(max(1, args.iterations)): + payload_1 = bytes([0x01, 0x03]) + bytes([0xA5] * max(1, args.payload_size)) + ok_1, lat_1 = _roundtrip(t_host, t_device, payload_1, args.timeout_sec) + if not ok_1: + loops_ok = False + loops_msg = "host_to_device payload mismatch/timeout" + break + h2d_samples.append(lat_1) + + payload_2 = bytes([0x01, 0x04]) + bytes([0x5A] * max(1, args.payload_size)) + ok_2, lat_2 = _roundtrip(t_device, t_host, payload_2, args.timeout_sec) + if not ok_2: + loops_ok = False + loops_msg = "device_to_host payload mismatch/timeout" + break + d2h_samples.append(lat_2) + results.append(TestResult("bidirectional_loop", loops_ok, loops_msg, h2d_samples + d2h_samples)) + + evt_ok = _wait_data_event(data_events, pair_id, args.timeout_sec) + results.append(TestResult("proxy.data_event", evt_ok, "" if evt_ok else "no proxy.data event")) + + if args.soak_sec > 0 and loops_ok: + soak_ok = True + soak_msg = "ok" + soak_samples: List[float] = [] + end = time.time() + args.soak_sec + flip = True + while time.time() < end: + payload = b"\xAA\x55" + bytes([0x11] * 16) + if flip: + ok, lat = _roundtrip(t_host, t_device, payload, args.timeout_sec) + else: + ok, lat = _roundtrip(t_device, t_host, payload, args.timeout_sec) + if not ok: + soak_ok = False + soak_msg = "soak timeout/mismatch" + break + soak_samples.append(lat) + flip = not flip + time.sleep(0.01) + results.append(TestResult("soak.forwarding", soak_ok, soak_msg, soak_samples)) + + if args.inject_disconnect: + # Simulate device unplug: test endpoint closes, then next write should trigger proxy error. + t_device.close() + disconnect_observed = False + disconnect_msg = "no error event" + try: + t_host.write(b"\x10\x20\x30") + except Exception: + pass + deadline = time.time() + args.timeout_sec + while time.time() < deadline: + try: + evt = status_events.get(timeout=0.1) + except queue.Empty: + continue + if evt.get("pair_id") == pair_id and evt.get("status") == "error": + disconnect_observed = True + disconnect_msg = str(evt.get("error") or "") + break + results.append( + TestResult( + "fault.disconnect_error_event", + disconnect_observed, + disconnect_msg if not disconnect_observed else "error event observed", + ) + ) + + manager.stop_pair(pair_id) + stopped_ok = _wait_status(status_events, pair_id, "stopped", args.timeout_sec) + results.append(TestResult("status.stopped", stopped_ok, "" if stopped_ok else "no stopped event")) + + passed = all(r.passed for r in results) + for r in results: + line = f"[{'PASS' if r.passed else 'FAIL'}] {r.name}" + if r.samples_ms: + avg = statistics.mean(r.samples_ms) + line += f" avg={avg:.3f}ms n={len(r.samples_ms)}" + if r.detail: + line += f" | {r.detail}" + print(line) + print(f"RESULT: {'PASSED' if passed else 'FAILED'}") + return 0 if passed else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_proxy_regression.ps1 b/scripts/run_proxy_regression.ps1 new file mode 100644 index 0000000..cadf341 --- /dev/null +++ b/scripts/run_proxy_regression.ps1 @@ -0,0 +1,54 @@ +param( + [ValidateSet("mock", "real")] + [string]$Mode = "mock", + [string]$HostPort = "COM11", + [string]$DevicePort = "COM13", + [string]$TestHostPort = "COM12", + [string]$TestDevicePort = "COM14", + [int]$Baud = 115200, + [int]$Iterations = 30, + [int]$PayloadSize = 64, + [double]$TimeoutSec = 2.0, + [int]$SoakSec = 180, + [string]$JsonOut = ".\proxy_regression_report.json", + [switch]$InjectDisconnect +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +$python = Join-Path $repoRoot ".venv\Scripts\python.exe" + +if (-not (Test-Path $python)) { + throw ".venv Python not found: $python" +} + +if ($Mode -eq "mock") { + Write-Host "[RUN] mock regression" -ForegroundColor Cyan + $args = @( + (Join-Path $PSScriptRoot "proxy_regression_mock.py"), + "--iterations", $Iterations, + "--payload-size", $PayloadSize, + "--timeout-sec", $TimeoutSec, + "--soak-sec", $SoakSec + ) + if ($InjectDisconnect) { + $args += "--inject-disconnect" + } + & $python @args + exit $LASTEXITCODE +} + +Write-Host "[RUN] real/virtual-port regression" -ForegroundColor Cyan +& $python (Join-Path $PSScriptRoot "proxy_regression.py") ` + --host-port $HostPort ` + --device-port $DevicePort ` + --test-host-port $TestHostPort ` + --test-device-port $TestDevicePort ` + --baud $Baud ` + --iterations $Iterations ` + --payload-size $PayloadSize ` + --timeout-sec $TimeoutSec ` + --soak-sec $SoakSec ` + --json-out $JsonOut + +exit $LASTEXITCODE diff --git a/scripts/run_target_with_dsl.ps1 b/scripts/run_target_with_dsl.ps1 new file mode 100644 index 0000000..e4e63a8 --- /dev/null +++ b/scripts/run_target_with_dsl.ps1 @@ -0,0 +1,47 @@ +param( + [Parameter(Mandatory=$true)][string]$HostPort, + [Parameter(Mandatory=$true)][string]$TargetPort, + [string]$Scenario = ".\tools\target_emulator\scenarios\at_basic.yaml", + [string]$DslScript = ".\scripts\examples\target_at_smoke_v02.yaml", + [int]$Baud = 115200 +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$python = Join-Path $root ".venv\Scripts\python.exe" +if (!(Test-Path $python)) { + $python = "python" +} + +$ts = Get-Date -Format "yyyyMMdd_HHmmss" +$emuOut = Join-Path $root "runs\target_emu_$ts" + +Write-Host "[1/3] Start target emulator on $TargetPort ..." -ForegroundColor Cyan +$emuArgs = @( + "$root\scripts\target_emulator.py", + "--scenario", (Resolve-Path $Scenario), + "--mode", "serial", + "--serial-port", $TargetPort, + "--baud", "$Baud", + "--artifacts-dir", $emuOut +) +$emu = Start-Process -FilePath $python -ArgumentList $emuArgs -PassThru -WindowStyle Hidden +Start-Sleep -Milliseconds 500 + +try { + Write-Host "[2/3] Run DSL against host port $HostPort ..." -ForegroundColor Cyan + $env:PROTOFLOW_PROTOCOLS_DIR = Join-Path $root "protocols" + & $python "$root\app\dsl_main.py" (Resolve-Path $DslScript) + $code = $LASTEXITCODE + if ($code -ne 0) { + throw "DSL run failed with code $code" + } +} +finally { + Write-Host "[3/3] Stop target emulator ..." -ForegroundColor Cyan + if ($emu -and !$emu.HasExited) { + Stop-Process -Id $emu.Id -Force + } +} + +Write-Host "Done. Emulator artifacts: $emuOut" -ForegroundColor Green diff --git a/scripts/runtime_regression_suite.py b/scripts/runtime_regression_suite.py new file mode 100644 index 0000000..4f2150c --- /dev/null +++ b/scripts/runtime_regression_suite.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = ROOT / "scripts" +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from config_persistence_regression import main as cfg_main +from script_runner_regression import main as runner_main +from target_emulator_fault_regression import main as target_fault_main +from target_emulator_regression import main as target_main +from v01_dsl_regression import main as dsl_main +from protocol_package_test_suite import main as protocol_pkg_main +from yaml_dsl_capability_suite import main as dsl_cap_main +from yaml_dsl_combo_regression import main as dsl_combo_main +from yaml_dsl_fault_injection_regression import main as dsl_fault_main +from yaml_dsl_performance_baseline import main as dsl_perf_main +from yaml_dsl_stability_regression import main as dsl_stability_main +from yaml_dsl_target_full_regression import main as target_full_main + + +def _run(name: str, fn) -> bool: + try: + code = int(fn()) + except Exception as exc: + print(f"[FAIL] {name}: {exc}") + return False + ok = code == 0 + print(f"[{'PASS' if ok else 'FAIL'}] {name}") + return ok + + +def main() -> int: + ok = True + ok &= _run("v01_dsl_regression", dsl_main) + ok &= _run("script_runner_regression", runner_main) + ok &= _run("config_persistence_regression", cfg_main) + ok &= _run("target_emulator_regression", target_main) + ok &= _run("target_emulator_fault_regression", target_fault_main) + ok &= _run("protocol_package_test_suite", protocol_pkg_main) + ok &= _run("yaml_dsl_capability_suite", dsl_cap_main) + ok &= _run("yaml_dsl_combo_regression", dsl_combo_main) + ok &= _run("yaml_dsl_fault_injection_regression", dsl_fault_main) + ok &= _run("yaml_dsl_stability_regression", dsl_stability_main) + ok &= _run("yaml_dsl_performance_baseline", dsl_perf_main) + ok &= _run("yaml_dsl_target_full_regression", target_full_main) + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/script_runner_regression.py b/scripts/script_runner_regression.py new file mode 100644 index 0000000..bf5ac8d --- /dev/null +++ b/scripts/script_runner_regression.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from pathlib import Path +import sys +from unittest.mock import patch + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from ui.desktop.script_runner_qt import ScriptRunnerQt +from dsl_runtime.engine.channels import DummyChannel + + +YAML_OK = """ +version: 0.1 +session: + transport: serial + port: COM_TEST + baud: 115200 +steps: + - name: sleep + duration_ms: 10 +""".strip() + +YAML_ERROR = """ +version: 0.1 +session: + transport: serial + port: COM_TEST + baud: 115200 +steps: + - name: does_not_exist +""".strip() + + +def _run_case(yaml_text: str, stop_before_run: bool = False): + runner = ScriptRunnerQt(yaml_text) + logs: list[str] = [] + states: list[str] = [] + progress: list[int] = [] + runner.sig_log.connect(lambda x: logs.append(x)) + runner.sig_state.connect(lambda x: states.append(x)) + runner.sig_progress.connect(lambda x: progress.append(int(x))) + if stop_before_run: + runner.stop() + with patch("ui.desktop.script_runner_qt.build_channels", return_value={"default": DummyChannel()}): + runner.run() + return logs, states, progress + + +def main() -> int: + checks: list[tuple[str, bool]] = [] + + logs_ok, states_ok, progress_ok = _run_case(YAML_OK, stop_before_run=False) + checks.append(("ok.running_state", "__running__" in states_ok)) + checks.append(("ok.finished_state", "__finished__" in states_ok)) + checks.append(("ok.finished_log", any("Script finished" in x for x in logs_ok))) + checks.append(("ok.progress_100", any(v >= 100 for v in progress_ok))) + + logs_stop, states_stop, _ = _run_case(YAML_OK, stop_before_run=True) + checks.append(("stop.running_state", "__running__" in states_stop)) + checks.append(("stop.stopped_state", "__stopped__" in states_stop)) + checks.append(("stop.stopped_log", any("Script stopped" in x for x in logs_stop))) + + logs_err, states_err, _ = _run_case(YAML_ERROR, stop_before_run=False) + checks.append(("err.running_state", "__running__" in states_err)) + checks.append(("err.error_state", "__error__" in states_err)) + checks.append(("err.error_log", any(x.startswith("[ERROR]") for x in logs_err))) + + ok = True + for name, passed in checks: + print(f"[{'PASS' if passed else 'FAIL'}] {name}") + ok = ok and passed + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/setup_proxy_test_env.ps1 b/scripts/setup_proxy_test_env.ps1 new file mode 100644 index 0000000..d966109 --- /dev/null +++ b/scripts/setup_proxy_test_env.ps1 @@ -0,0 +1,45 @@ +param( + [switch]$ShowPortsOnly +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe" + +if (-not (Test-Path $venvPython)) { + Write-Error ".venv Python not found: $venvPython" +} + +Write-Host "[1/4] Python:" -ForegroundColor Cyan +& $venvPython --version + +Write-Host "[2/4] Validate pyserial:" -ForegroundColor Cyan +& $venvPython -c "import serial;print('pyserial ok:', serial.__version__)" + +Write-Host "[3/4] Detect com0com driver:" -ForegroundColor Cyan +$driverFound = $false +foreach ($path in @( + "HKLM:\SYSTEM\CurrentControlSet\Services\com0com", + "HKLM:\SYSTEM\CurrentControlSet\Services\CNCA0" +)) { + if (Test-Path $path) { + $driverFound = $true + Write-Host "Found registry key: $path" + } +} +if (-not $driverFound) { + Write-Warning "com0com driver not detected. Install com0com first (requires admin/UAC)." +} + +Write-Host "[4/4] List serial ports:" -ForegroundColor Cyan +& $venvPython -c "import serial.tools.list_ports as p;ports=[x.device for x in p.comports()];print('\n'.join(ports) if ports else '(no serial ports found)')" + +if ($ShowPortsOnly) { + exit 0 +} + +Write-Host "" +Write-Host "Next step:" -ForegroundColor Green +Write-Host "After creating 2 virtual pairs (example COM11<->COM12 and COM13<->COM14), run:" +Write-Host ".\\.venv\\Scripts\\python.exe scripts\\proxy_regression.py --host-port COM11 --device-port COM13 --test-host-port COM12 --test-device-port COM14 --baud 115200 --iterations 30 --payload-size 64 --timeout-sec 2 --soak-sec 180 --json-out .\\proxy_regression_report.json" diff --git a/scripts/target_emulator.py b/scripts/target_emulator.py new file mode 100644 index 0000000..4a2102d --- /dev/null +++ b/scripts/target_emulator.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import argparse +import signal +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tools.target_emulator import EmulatorConfig, TargetEmulator, load_scenario + + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Standalone target emulator for ProtoFlow DSL regression") + p.add_argument("--scenario", required=True, help="Scenario yaml path") + p.add_argument("--mode", choices=["serial", "tcp"], default="serial") + p.add_argument("--serial-port", default="") + p.add_argument("--baud", type=int, default=115200) + p.add_argument("--tcp-host", default="127.0.0.1") + p.add_argument("--tcp-port", type=int, default=19001) + p.add_argument("--artifacts-dir", default="./runs/target_emulator") + p.add_argument("--max-seconds", type=int, default=0, help="Optional auto-stop timeout") + return p.parse_args() + + +def main() -> int: + args = _parse_args() + scenario = load_scenario(args.scenario) + cfg = EmulatorConfig( + mode=args.mode, + artifacts_dir=args.artifacts_dir, + serial_port=args.serial_port or None, + serial_baud=args.baud, + tcp_host=args.tcp_host, + tcp_port=args.tcp_port, + ) + emu = TargetEmulator(scenario, cfg) + stop_flag = {"stop": False} + + def _handle_signal(_sig, _frame): + stop_flag["stop"] = True + + signal.signal(signal.SIGINT, _handle_signal) + signal.signal(signal.SIGTERM, _handle_signal) + + emu.start() + started = time.time() + try: + while not stop_flag["stop"]: + if args.max_seconds > 0 and (time.time() - started) >= args.max_seconds: + break + time.sleep(0.1) + finally: + emu.stop() + print(f"target emulator stopped. artifacts={Path(args.artifacts_dir).resolve()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/target_emulator_fault_regression.py b/scripts/target_emulator_fault_regression.py new file mode 100644 index 0000000..38c8a7e --- /dev/null +++ b/scripts/target_emulator_fault_regression.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import sys +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tools.target_emulator import EmulatorConfig, TargetEmulator, load_scenario +from tools.target_emulator.transport import create_mock_pair + + +def _check(name: str, cond: bool) -> bool: + print(f"[{'PASS' if cond else 'FAIL'}] {name}") + return cond + + +def _send(client, text: str) -> None: + client.write(text.encode("utf-8") + b"\r\n") + + +def _recv(client, timeout: float = 0.5) -> str: + data = client.read(1024, timeout=timeout) + return data.decode("utf-8", errors="ignore") + + +def main() -> int: + ok = True + with tempfile.TemporaryDirectory(prefix="target_fault_") as tmp: + client, server = create_mock_pair() + scenario = load_scenario(ROOT / "tools" / "target_emulator" / "scenarios" / "fault_injection_matrix.yaml") + emu = TargetEmulator(scenario, EmulatorConfig(mode="mock", artifacts_dir=str(Path(tmp) / "artifacts")), endpoint=server) + emu.start() + try: + _send(client, "PING:DROP") + drop_resp = _recv(client, timeout=0.15) + ok &= _check("drop_once", drop_resp == "") + + _send(client, "PING:TIMEOUT") + timeout_resp = _recv(client, timeout=0.2) + ok &= _check("timeout_window", timeout_resp == "") + + _send(client, "PING:JITTER") + t0 = time.time() + jitter_resp = _recv(client, timeout=0.8) + dt = (time.time() - t0) * 1000 + ok &= _check("jitter_resp", "PONG:JITTER" in jitter_resp and dt >= 20) + + _send(client, "PING:CHUNK") + chunk_resp = _recv(client, timeout=0.8) + ok &= _check("chunk_resp", "PAYLOAD:ABCDEFGHIJKLMNOPQRSTUVWXYZ" in chunk_resp) + + _send(client, "PING:REORDER") + reorder_resp = _recv(client, timeout=0.6) + ok &= _check("reorder_resp", "SEQ:2,1" in reorder_resp) + + _send(client, "PING:CLOSE") + close_resp = _recv(client, timeout=0.6) + ok &= _check("close_resp", "BYE" in close_resp) + finally: + emu.stop() + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/target_emulator_regression.py b/scripts/target_emulator_regression.py new file mode 100644 index 0000000..27a2f6e --- /dev/null +++ b/scripts/target_emulator_regression.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tools.target_emulator import EmulatorConfig, TargetEmulator, load_scenario +from tools.target_emulator.transport import create_mock_pair + + +def _check(name: str, cond: bool) -> bool: + print(f"[{'PASS' if cond else 'FAIL'}] {name}") + return cond + + +def _send_line(client, text: str, eol: bytes = b"\r\n") -> None: + client.write(text.encode("utf-8") + eol) + + +def _read_text(client, timeout: float = 0.5) -> str: + data = client.read(1024, timeout=timeout) + return data.decode("utf-8", errors="ignore") + + +def main() -> int: + ok = True + with tempfile.TemporaryDirectory(prefix="target_emu_") as tmp: + artifacts = Path(tmp) / "artifacts" + client, server = create_mock_pair() + scenario = load_scenario(ROOT / "tools" / "target_emulator" / "scenarios" / "at_basic.yaml") + emu = TargetEmulator( + scenario=scenario, + config=EmulatorConfig(mode="mock", artifacts_dir=str(artifacts)), + endpoint=server, + ) + emu.start() + try: + _send_line(client, "AT") + r1 = _read_text(client) + ok &= _check("at_ok", "OK" in r1) + + _send_line(client, "ATI") + r2 = _read_text(client) + ok &= _check("ati_ok", "ProtoFlow-Target" in r2 and "OK" in r2) + + _send_line(client, "AT+UNKNOWN") + r3 = _read_text(client) + ok &= _check("fallback_error", "ERROR" in r3) + finally: + time.sleep(0.05) + emu.stop() + + raw_ok = (artifacts / "raw_log.jsonl").exists() + summary_ok = (artifacts / "summary.json").exists() + ok &= _check("artifact_raw_log", raw_ok) + ok &= _check("artifact_summary", summary_ok) + if summary_ok: + summary = json.loads((artifacts / "summary.json").read_text(encoding="utf-8")) + ok &= _check("summary_rules_hit", int(summary.get("rules_hit", {}).get("at_ping", 0)) >= 1) + + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_result_schema.json b/scripts/test_result_schema.json new file mode 100644 index 0000000..3b2bdb8 --- /dev/null +++ b/scripts/test_result_schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://protoflow.local/schemas/test_result_schema.json", + "title": "ProtoFlow Regression Result", + "type": "object", + "required": ["suite", "ok", "generated_at", "cases"], + "properties": { + "suite": { "type": "string", "minLength": 1 }, + "ok": { "type": "boolean" }, + "generated_at": { "type": "number" }, + "artifacts_dir": { "type": "string" }, + "cases": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "ok", "elapsed_ms"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "ok": { "type": "boolean" }, + "elapsed_ms": { "type": "integer", "minimum": 0 }, + "error_code": { "type": "string" }, + "error_message": { "type": "string" }, + "retry_hit": { "type": "boolean" }, + "metrics": { + "type": "object", + "additionalProperties": { + "type": ["number", "string", "boolean"] + } + }, + "logs": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/scripts/v01_dsl_regression.py b/scripts/v01_dsl_regression.py new file mode 100644 index 0000000..c27b8c9 --- /dev/null +++ b/scripts/v01_dsl_regression.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from pathlib import Path +import shutil +import sys +import tempfile + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from dsl_runtime.engine.context import RuntimeContext +from dsl_runtime.engine.v01_artifacts import export_v01_artifacts +from dsl_runtime.engine.v01_executor import execute_v01 +from dsl_runtime.lang.ast_nodes import ArtifactsConfig, DefaultsConfig, RetryPolicy, ScriptAST, SessionConfig + + +class FakeChannel: + def __init__(self, reads: list[bytes] | None = None) -> None: + self.reads = list(reads or []) + self.writes: list[bytes] = [] + + def write(self, data): + if isinstance(data, str): + data = data.encode("utf-8") + self.writes.append(bytes(data)) + + def read(self, size: int = 1, timeout: float = 1.0) -> bytes: + if not self.reads: + return b"" + chunk = self.reads.pop(0) + return chunk[:size] + + def read_event(self, timeout: float = 0.1): + return None + + def close(self) -> None: + return None + + +def _base_ast(steps: list[dict], artifacts_dir: str) -> ScriptAST: + return ScriptAST( + version="0.1", + params={"sn": "SN001"}, + vars={"sn_read": ""}, + session=SessionConfig( + transport="serial", + port="COM5", + baud=115200, + data_bits=8, + parity="none", + stop_bits=1, + encoding="ascii", + eol="crlf", + open_timeout_ms=3000, + read_timeout_ms=50, + ), + defaults=DefaultsConfig(timeout_ms=200, retry=RetryPolicy(count=1, backoff_ms=10, strategy="fixed")), + steps=steps, + artifacts=ArtifactsConfig(dir=artifacts_dir, raw_log=True, summary_json=True, report_csv=False), + ) + + +def _run_ast(ast: ScriptAST, channel: FakeChannel): + ctx = RuntimeContext({"default": channel}, "default", vars_init=dict(ast.vars), params_init=dict(ast.params)) + summary = execute_v01(ast, ctx) + ctx.close() + return summary, channel + + +def main() -> int: + tmp_root = Path(tempfile.mkdtemp(prefix="protoflow_v01_reg_")) + checks: list[tuple[str, bool]] = [] + try: + # Case 1: send + expect + capture + assert success + steps_ok = [ + {"id": "send_sn", "name": "send", "text": "AT+SETSN=${sn}"}, + {"id": "expect_ok", "name": "expect", "match": {"type": "contains", "pattern": "SN:SN001"}, "timeout_ms": 500}, + {"id": "capture_sn", "name": "capture", "regex": r"SN:([A-Z0-9]+)", "group": 1, "var": "sn_read"}, + {"id": "assert_sn", "name": "assert", "expr": "${sn_read} == ${sn}"}, + ] + ast_ok = _base_ast(steps_ok, str(tmp_root / "ok_${now}")) + summary_ok, ch_ok = _run_ast(ast_ok, FakeChannel([b"SN:SN001\r\n"])) + checks.append(("ok.summary_true", bool(summary_ok.get("ok")))) + checks.append(("ok.send_payload", any(w.endswith(b"\r\n") for w in ch_ok.writes))) + checks.append(("ok.capture_var", summary_ok.get("vars", {}).get("sn_read") == "SN001")) + + out_dir_ok = export_v01_artifacts(ast_ok, summary_ok) + checks.append(("ok.artifact_dir", bool(out_dir_ok and Path(out_dir_ok).exists()))) + checks.append(("ok.summary_json", bool(out_dir_ok and (Path(out_dir_ok) / "summary.json").exists()))) + checks.append(("ok.raw_log", bool(out_dir_ok and (Path(out_dir_ok) / "raw_log.jsonl").exists()))) + + # Case 2: failure path + on_fail recovery step should run + steps_fail = [ + {"id": "wait_ready", "name": "expect", "match": {"type": "contains", "pattern": "READY"}, "timeout_ms": 120, "retry": {"count": 1, "backoff_ms": 10, "strategy": "fixed"}, "on_fail": [{"name": "send", "text": "AT+RST"}]}, + ] + ast_fail = _base_ast(steps_fail, str(tmp_root / "fail_${now}")) + summary_fail, ch_fail = _run_ast(ast_fail, FakeChannel([])) + checks.append(("fail.summary_false", not bool(summary_fail.get("ok")))) + checks.append(("fail.on_fail_send", any(b"AT+RST" in w for w in ch_fail.writes))) + + out_dir_fail = export_v01_artifacts(ast_fail, summary_fail) + checks.append(("fail.summary_json", bool(out_dir_fail and (Path(out_dir_fail) / "summary.json").exists()))) + + # Case 3: controlled if/else flow (v0.2) + steps_if = [ + { + "id": "branch", + "name": "if", + "when": "${mode} == 'boot'", + "then": [{"name": "send", "text": "BOOT"}], + "else": [{"name": "send", "text": "APP"}], + } + ] + ast_if = _base_ast(steps_if, str(tmp_root / "if_${now}")) + ast_if.version = "0.2" + ast_if.params["mode"] = "boot" + summary_if, ch_if = _run_ast(ast_if, FakeChannel([])) + checks.append(("if.summary_true", bool(summary_if.get("ok")))) + checks.append(("if.then_branch", any(b"BOOT" in w for w in ch_if.writes))) + checks.append(("if.else_not_sent", all(b"APP" not in w for w in ch_if.writes))) + + # Case 4: controlled loop flow (times + until) + steps_loop = [ + { + "id": "loop_send", + "name": "loop", + "times": 5, + "until": "${last_loop_round} >= 2", + "steps": [{"name": "send", "text": "AT"}], + } + ] + ast_loop = _base_ast(steps_loop, str(tmp_root / "loop_${now}")) + ast_loop.version = "0.2" + ast_loop.vars["last_loop_round"] = 0 + summary_loop, ch_loop = _run_ast(ast_loop, FakeChannel([])) + checks.append(("loop.summary_true", bool(summary_loop.get("ok")))) + checks.append(("loop.iterations", len(ch_loop.writes) == 2)) + + # Case 5: parse step (json/kv/csv) + steps_parse = [ + {"id": "j", "name": "parse", "format": "json", "source": '{"a":1,"b":"x"}', "save_as": "pj"}, + {"id": "k", "name": "parse", "format": "kv", "source": "k1=v1;k2=v2", "save_as": "pk"}, + {"id": "c", "name": "parse", "format": "csv", "source": "x,y\n1,2\n", "save_as": "pc"}, + {"id": "p", "name": "path", "source": "${pj}", "path": "a", "save_as": "pa"}, + {"id": "m", "name": "measure", "metric": "voltage", "value": "12.1", "unit": "V"}, + {"id": "aj", "name": "assert", "expr": "${pj}['a'] == 1"}, + {"id": "ak", "name": "assert", "expr": "${pk}['k2'] == 'v2'"}, + {"id": "ac", "name": "assert", "expr": "${pc}['x'] == '1'"}, + {"id": "ap", "name": "assert", "expr": "${pa} == 1"}, + {"id": "am", "name": "assert", "expr": "${measure.voltage} == '12.1'"}, + {"id": "ar", "name": "assert_range", "value": "${measure.voltage}", "min": 12.0, "max": 12.2}, + ] + ast_parse = _base_ast(steps_parse, str(tmp_root / "parse_${now}")) + ast_parse.version = "0.2" + summary_parse, _ = _run_ast(ast_parse, FakeChannel([])) + checks.append(("parse.summary_true", bool(summary_parse.get("ok")))) + + # Case 6: controlled exec (allowlist + output capture) + steps_exec = [ + { + "id": "exec_echo", + "name": "exec", + "command": "python --version", + "timeout_ms": 3000, + "save_stdout_as": "exec_out", + }, + { + "id": "assert_exec", + "name": "assert", + "expr": "${last_exec_code} == 0", + }, + ] + ast_exec = _base_ast(steps_exec, str(tmp_root / "exec_${now}")) + ast_exec.version = "0.2" + ast_exec.security = { + "exec": { + "enabled": True, + "allow_commands": ["python"], + "cwd_allowlist": [str(ROOT_DIR)], + } + } + summary_exec, _ = _run_ast(ast_exec, FakeChannel([])) + checks.append(("exec.summary_true", bool(summary_exec.get("ok")))) + + # Case 7: controlled file operations + file_path = str(tmp_root / "file_case" / "out.txt") + steps_file = [ + {"id": "fw", "name": "file", "op": "write_text", "path": file_path, "content": "ABC"}, + {"id": "fa", "name": "file", "op": "append_text", "path": file_path, "content": "123"}, + {"id": "fe", "name": "file", "op": "exists", "path": file_path, "save_as": "f_exists"}, + {"id": "fr", "name": "file", "op": "read_text", "path": file_path, "save_as": "f_text"}, + {"id": "af1", "name": "assert", "expr": "${f_exists} == True"}, + {"id": "af2", "name": "assert", "expr": "${f_text} == 'ABC123'"}, + ] + ast_file = _base_ast(steps_file, str(tmp_root / "file_${now}")) + ast_file.version = "0.2" + ast_file.security = {"file": {"root_allowlist": [str(tmp_root)]}} + summary_file, _ = _run_ast(ast_file, FakeChannel([])) + checks.append(("file.summary_true", bool(summary_file.get("ok")))) + + # Case 8: switch_session dry-run (no real serial reopen) + steps_switch = [ + {"id": "sw", "name": "switch_session", "port": "COM9", "baud": 57600, "dry_run": True}, + {"id": "asw1", "name": "assert", "expr": "${last_session.port} == 'COM9'"}, + {"id": "asw2", "name": "assert", "expr": "${last_session.baud} == 57600"}, + ] + ast_switch = _base_ast(steps_switch, str(tmp_root / "switch_${now}")) + ast_switch.version = "0.2" + summary_switch, _ = _run_ast(ast_switch, FakeChannel([])) + checks.append(("switch.summary_true", bool(summary_switch.get("ok")))) + + finally: + shutil.rmtree(tmp_root, ignore_errors=True) + + ok = True + for name, passed in checks: + print(f"[{'PASS' if passed else 'FAIL'}] {name}") + ok = ok and passed + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/yaml_dsl_capability_suite.py b/scripts/yaml_dsl_capability_suite.py new file mode 100644 index 0000000..97c2d6d --- /dev/null +++ b/scripts/yaml_dsl_capability_suite.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Tuple + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from dsl_runtime.engine.context import RuntimeContext +from dsl_runtime.engine.v01_artifacts import export_v01_artifacts +from dsl_runtime.engine.v01_executor import execute_v01 +from dsl_runtime.lang.parser import parse_script +from tools.target_emulator import EmulatorConfig, TargetEmulator, load_scenario +from tools.target_emulator.transport import create_mock_pair + + +def _check(name: str, cond: bool) -> bool: + print(f"[{'PASS' if cond else 'FAIL'}] {name}") + return cond + + +def _run_script(case_id: str, script_text: str, scenario_name: str = "at_basic.yaml") -> Tuple[Dict[str, Any], Path]: + out_dir = (ROOT / "runs" / f"dsl_cap_{case_id}_{int(time.time() * 1000)}").resolve() + out_dir.mkdir(parents=True, exist_ok=True) + script_path = out_dir / f"{case_id}.yaml" + script_path.write_text(script_text, encoding="utf-8") + ast = parse_script(str(script_path)) + + client, server = create_mock_pair() + scenario = load_scenario(ROOT / "tools" / "target_emulator" / "scenarios" / scenario_name) + emu = TargetEmulator( + scenario=scenario, + config=EmulatorConfig(mode="mock", artifacts_dir=str(out_dir / "target")), + endpoint=server, + ) + ctx = RuntimeContext( + channels={"default": client}, + default_channel="default", + vars_init=dict(ast.vars or {}), + params_init=dict(ast.params or {}), + script_path=str(script_path), + script_text=script_text, + ) + emu.start() + try: + summary = execute_v01(ast, ctx) + export_v01_artifacts(ast, summary) + finally: + ctx.close() + emu.stop() + return summary, out_dir + + +def _case_foundation() -> Tuple[bool, Dict[str, Any], Path]: + script = """ +version: "0.2" +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 100 +security: + file: + root_allowlist: ["./tmp", "./runs"] +steps: + - name: send + text: "AT" + - name: sleep + ms: 10 + - name: expect + match: { type: contains, pattern: "OK" } + - name: send + text: "AT+SN?" + - name: expect + match: { type: regex, pattern: "SN:[A-Z0-9]+" } + capture: + - var: sn + regex: "SN:([A-Z0-9]+)" + - name: assert + expr: "${sn} != ''" +artifacts: + dir: "./runs/cap_foundation_${now}" + raw_log: true + summary_json: true + report_csv: true +""".strip() + summary, out = _run_script("foundation", script) + ok = bool(summary.get("ok", False)) and str(summary.get("vars", {}).get("sn", "")).startswith("SN") + return ok, summary, out + + +def _case_control_flow() -> Tuple[bool, Dict[str, Any], Path]: + script = """ +version: "0.2" +vars: + ready: false + last_loop_round: 0 +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 100 +security: + file: + root_allowlist: ["./tmp", "./runs"] +steps: + - name: if + when: "${ready} == False" + then: + - name: send + text: "AT" + - name: expect + match: { type: contains, pattern: "OK" } + - name: loop + times: 4 + until: "${ready} == True" + steps: + - name: if + when: "${last_loop_round} >= 2" + then: + - name: file + op: write_text + path: "./tmp/cap_ready.flag" + content: "1" + - name: file + op: exists + path: "./tmp/cap_ready.flag" + save_as: ready + - name: sleep + ms: 5 + - name: switch_session + baud: 921600 + dry_run: true + - name: assert + expr: "${ready} == True" +artifacts: + dir: "./runs/cap_control_${now}" + raw_log: true + summary_json: true +""".strip() + summary, out = _run_script("control_flow", script) + ok = bool(summary.get("ok", False)) and bool(summary.get("vars", {}).get("ready", False)) + return ok, summary, out + + +def _case_data_plane() -> Tuple[bool, Dict[str, Any], Path]: + script = """ +version: "0.2" +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 100 +steps: + - name: parse + format: json + source: '{"adc":{"voltage":12.11},"temp":40.1}' + save_as: p_json + - name: path + source: "${p_json}" + path: "adc.voltage" + save_as: volt + - name: parse + format: kv + source: "state=ready\\nerr=0" + save_as: p_kv + - name: parse + format: csv + source: "metric,value\\nrpm,1500\\npwm,42" + save_as: p_csv + - name: path + source: "${p_csv}" + path: "1.value" + save_as: pwm + - name: measure + metric: voltage + value: "${volt}" + unit: V + - name: assert_range + value: "${measure.voltage}" + min: 11.8 + max: 12.3 + - name: assert + all: + - expr: "${p_kv.state} == 'ready'" + - expr: "${pwm} == '42'" +artifacts: + dir: "./runs/cap_data_${now}" + raw_log: true + summary_json: true +""".strip() + summary, out = _run_script("data_plane", script) + ok = bool(summary.get("ok", False)) and len(summary.get("vars", {}).get("metrics", [])) >= 1 + return ok, summary, out + + +def _case_security_deny_exec() -> Tuple[bool, Dict[str, Any], Path]: + script = """ +version: "0.2" +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 100 +security: + exec: + enabled: false + allow_commands: ["python"] + cwd_allowlist: ["."] +steps: + - name: exec + command: "python --version" +""".strip() + summary, out = _run_script("security_deny_exec", script) + err = summary.get("error") or {} + ok = (not bool(summary.get("ok", True))) and err.get("code") == "EXEC_NOT_ALLOWED" + return ok, summary, out + + +def _case_security_deny_file() -> Tuple[bool, Dict[str, Any], Path]: + script = """ +version: "0.2" +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 100 +security: + file: + root_allowlist: ["./runs"] +steps: + - name: file + op: write_text + path: "./tmp/deny.txt" + content: "x" +""".strip() + summary, out = _run_script("security_deny_file", script) + err = summary.get("error") or {} + ok = (not bool(summary.get("ok", True))) and err.get("code") == "FILE_NOT_ALLOWED" + return ok, summary, out + + +def main() -> int: + checks: List[Tuple[str, bool, Dict[str, Any], Path]] = [] + for name, fn in [ + ("dsl.foundation", _case_foundation), + ("dsl.control_flow", _case_control_flow), + ("dsl.data_plane", _case_data_plane), + ("dsl.security_deny_exec", _case_security_deny_exec), + ("dsl.security_deny_file", _case_security_deny_file), + ]: + ok, summary, out = fn() + checks.append((name, ok, summary, out)) + _check(name, ok) + + overall = all(item[1] for item in checks) + report_cases = [] + for name, ok, summary, out in checks: + report_cases.append( + { + "id": name, + "ok": ok, + "elapsed_ms": int(summary.get("elapsed_ms", 0)), + "error_code": (summary.get("error") or {}).get("code"), + "error_message": (summary.get("error") or {}).get("message"), + "logs": [str(out / "summary.json"), str(out / "raw_log.jsonl")], + } + ) + report = { + "suite": "yaml_dsl.capability_suite", + "ok": overall, + "generated_at": time.time(), + "cases": report_cases, + } + out = (ROOT / "runs" / "yaml_dsl_capability_suite_report.json").resolve() + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"report={out}") + print(f"RESULT: {'PASSED' if overall else 'FAILED'}") + return 0 if overall else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/yaml_dsl_combo_regression.py b/scripts/yaml_dsl_combo_regression.py new file mode 100644 index 0000000..b9a1e8b --- /dev/null +++ b/scripts/yaml_dsl_combo_regression.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path +from typing import Any, Dict, Tuple + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from dsl_runtime.engine.context import RuntimeContext +from dsl_runtime.engine.v01_artifacts import export_v01_artifacts +from dsl_runtime.engine.v01_executor import execute_v01 +from dsl_runtime.lang.parser import parse_script +from tools.target_emulator import EmulatorConfig, TargetEmulator, load_scenario +from tools.target_emulator.transport import create_mock_pair + + +def _check(name: str, cond: bool) -> bool: + print(f"[{'PASS' if cond else 'FAIL'}] {name}") + return cond + + +def _run_case(script_text: str) -> Tuple[Dict[str, Any], Path]: + out_dir = (ROOT / "runs" / f"yaml_dsl_combo_{int(time.time() * 1000)}").resolve() + out_dir.mkdir(parents=True, exist_ok=True) + script_path = out_dir / "combo_case.yaml" + script_path.write_text(script_text, encoding="utf-8") + ast = parse_script(str(script_path)) + + client, server = create_mock_pair() + scenario = load_scenario(ROOT / "tools" / "target_emulator" / "scenarios" / "combo_upgrade_config.yaml") + emu = TargetEmulator( + scenario=scenario, + config=EmulatorConfig(mode="mock", artifacts_dir=str(out_dir / "target")), + endpoint=server, + ) + ctx = RuntimeContext( + channels={"default": client}, + default_channel="default", + vars_init=dict(ast.vars or {}), + params_init=dict(ast.params or {}), + script_path=str(script_path), + script_text=script_text, + ) + emu.start() + try: + summary = execute_v01(ast, ctx) + export_v01_artifacts(ast, summary) + finally: + ctx.close() + emu.stop() + return summary, out_dir + + +def main() -> int: + script = """ +version: "0.2" + +params: + sn: "SN90001" + fw_ver: "1.2.3" + +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 120 + +security: + file: + root_allowlist: ["./tmp", "./runs"] + +steps: + - name: send + text: "ENTER_BOOT" + - name: expect + timeout_ms: 120 + match: { type: contains, pattern: "BOOT:READY" } + retry: + count: 2 + backoff_ms: 40 + strategy: fixed + - name: send + text: "FLASH:BEGIN" + - name: expect + match: { type: contains, pattern: "ACK" } + - name: send + text: "FLASH:END" + - name: expect + match: { type: contains, pattern: "CRC:PASS" } + - name: send + text: "SET:SN=${params.sn}" + - name: expect + match: { type: contains, pattern: "WRITE:OK" } + - name: send + text: "GET:SN?" + - name: expect + match: { type: regex, pattern: "SN:[A-Z0-9]+" } + capture: + - var: read_sn + regex: "SN:([A-Z0-9]+)" + - name: send + text: "GET:VER?" + - name: expect + match: { type: regex, pattern: "VER:[0-9.]+" } + capture: + - var: read_ver + regex: "VER:([0-9.]+)" + - name: assert + all: + - expr: "${read_sn} == ${params.sn}" + - expr: "${read_ver} == ${params.fw_ver}" + - name: file + op: write_text + path: "./tmp/combo_report.txt" + content: "sn=${read_sn}\\nver=${read_ver}\\nflash=CRC:PASS" + - name: file + op: read_text + path: "./tmp/combo_report.txt" + save_as: combo_report + - name: assert + expr: "${combo_report} != ''" + +artifacts: + dir: "./runs/yaml_dsl_combo_${now}" + raw_log: true + summary_json: true + report_csv: true +""".strip() + + summary, out_dir = _run_case(script) + ok = bool(summary.get("ok", False)) + vars_block = summary.get("vars", {}) + steps = summary.get("steps", []) + retry_hit = any(int(s.get("attempts", 1)) > 1 for s in steps if s.get("name") == "expect") + + ok &= _check("combo.ok", bool(summary.get("ok", False))) + ok &= _check("combo.sn_match", str(vars_block.get("read_sn", "")) == "SN90001") + ok &= _check("combo.ver_match", str(vars_block.get("read_ver", "")) == "1.2.3") + ok &= _check("combo.retry_hit", retry_hit) + + report = { + "suite": "yaml_dsl.combo_regression", + "ok": ok, + "output_dir": str(out_dir), + "elapsed_ms": int(summary.get("elapsed_ms", 0)), + "read_sn": vars_block.get("read_sn"), + "read_ver": vars_block.get("read_ver"), + "retry_hit": retry_hit, + "generated_at": time.time(), + } + report_path = (ROOT / "runs" / "yaml_dsl_combo_regression_report.json").resolve() + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"report={report_path}") + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/yaml_dsl_fault_injection_regression.py b/scripts/yaml_dsl_fault_injection_regression.py new file mode 100644 index 0000000..5e9697e --- /dev/null +++ b/scripts/yaml_dsl_fault_injection_regression.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Tuple + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from dsl_runtime.engine.context import RuntimeContext +from dsl_runtime.engine.v01_artifacts import export_v01_artifacts +from dsl_runtime.engine.v01_executor import execute_v01 +from dsl_runtime.lang.parser import parse_script +from tools.target_emulator import EmulatorConfig, TargetEmulator, load_scenario +from tools.target_emulator.transport import create_mock_pair + + +def _check(name: str, cond: bool) -> bool: + print(f"[{'PASS' if cond else 'FAIL'}] {name}") + return cond + + +def _run_script(case_id: str, script_text: str) -> Tuple[Dict[str, Any], Path]: + out_dir = (ROOT / "runs" / f"dsl_fault_{case_id}_{int(time.time() * 1000)}").resolve() + out_dir.mkdir(parents=True, exist_ok=True) + script_path = out_dir / f"{case_id}.yaml" + script_path.write_text(script_text, encoding="utf-8") + ast = parse_script(str(script_path)) + + client, server = create_mock_pair() + scenario = load_scenario(ROOT / "tools" / "target_emulator" / "scenarios" / "fault_injection_matrix.yaml") + emu = TargetEmulator( + scenario=scenario, + config=EmulatorConfig(mode="mock", artifacts_dir=str(out_dir / "target")), + endpoint=server, + ) + ctx = RuntimeContext( + channels={"default": client}, + default_channel="default", + vars_init=dict(ast.vars or {}), + params_init=dict(ast.params or {}), + script_path=str(script_path), + script_text=script_text, + ) + emu.start() + try: + summary = execute_v01(ast, ctx) + export_v01_artifacts(ast, summary) + finally: + ctx.close() + emu.stop() + return summary, out_dir + + +def _case_fault_pass() -> Tuple[bool, Dict[str, Any], Path]: + script = """ +version: "0.2" +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 100 +steps: + - name: send + text: "PING:JITTER" + - name: expect + match: { type: contains, pattern: "PONG:JITTER" } + timeout_ms: 900 + - name: send + text: "PING:CHUNK" + - name: expect + match: { type: regex, pattern: "PAYLOAD:[A-Z]+" } + timeout_ms: 900 + capture: + - var: payload + regex: "PAYLOAD:([A-Z]+)" + - name: send + text: "PING:REORDER" + - name: expect + match: { type: contains, pattern: "SEQ:2,1" } + timeout_ms: 500 + - name: assert + expr: "${payload} != ''" +artifacts: + dir: "./runs/dsl_fault_pass_${now}" + raw_log: true + summary_json: true +""".strip() + summary, out = _run_script("pass", script) + ok = bool(summary.get("ok", False)) and str(summary.get("vars", {}).get("payload", "")) != "" + return ok, summary, out + + +def _case_timeout_fail() -> Tuple[bool, Dict[str, Any], Path]: + script = """ +version: "0.2" +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 100 +steps: + - name: send + text: "PING:TIMEOUT" + - name: expect + match: { type: contains, pattern: "SHOULD_NOT_ARRIVE" } + timeout_ms: 180 +""".strip() + summary, out = _run_script("timeout_fail", script) + err = summary.get("error") or {} + ok = (not bool(summary.get("ok", True))) and err.get("code") == "STEP_TIMEOUT" + return ok, summary, out + + +def _case_close_pass() -> Tuple[bool, Dict[str, Any], Path]: + script = """ +version: "0.2" +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 100 +steps: + - name: send + text: "PING:CLOSE" + - name: expect + match: { type: contains, pattern: "BYE" } + timeout_ms: 500 +""".strip() + summary, out = _run_script("close_pass", script) + ok = bool(summary.get("ok", False)) + return ok, summary, out + + +def main() -> int: + checks: List[Tuple[str, bool, Dict[str, Any], Path]] = [] + for name, fn in [ + ("dsl_fault.pass_path", _case_fault_pass), + ("dsl_fault.timeout_fail", _case_timeout_fail), + ("dsl_fault.close_pass", _case_close_pass), + ]: + ok, summary, out = fn() + checks.append((name, ok, summary, out)) + _check(name, ok) + + overall = all(item[1] for item in checks) + report_cases = [] + for name, ok, summary, out in checks: + report_cases.append( + { + "id": name, + "ok": ok, + "elapsed_ms": int(summary.get("elapsed_ms", 0)), + "error_code": (summary.get("error") or {}).get("code"), + "error_message": (summary.get("error") or {}).get("message"), + "logs": [str(out / "summary.json"), str(out / "raw_log.jsonl")], + } + ) + report = { + "suite": "yaml_dsl.fault_injection_regression", + "ok": overall, + "generated_at": time.time(), + "cases": report_cases, + } + out = (ROOT / "runs" / "yaml_dsl_fault_injection_regression_report.json").resolve() + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"report={out}") + print(f"RESULT: {'PASSED' if overall else 'FAILED'}") + return 0 if overall else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/yaml_dsl_performance_baseline.py b/scripts/yaml_dsl_performance_baseline.py new file mode 100644 index 0000000..ccace2c --- /dev/null +++ b/scripts/yaml_dsl_performance_baseline.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import argparse +import json +import statistics +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Tuple + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from dsl_runtime.engine.context import RuntimeContext +from dsl_runtime.engine.v01_artifacts import export_v01_artifacts +from dsl_runtime.engine.v01_executor import execute_v01 +from dsl_runtime.lang.parser import parse_script +from tools.target_emulator import EmulatorConfig, TargetEmulator, load_scenario +from tools.target_emulator.transport import create_mock_pair + + +def _p95(samples: List[float]) -> float: + if not samples: + return 0.0 + ordered = sorted(samples) + idx = max(0, int(len(ordered) * 0.95) - 1) + return float(ordered[idx]) + + +def _count_lines(path: Path) -> int: + if not path.exists(): + return 0 + with path.open("r", encoding="utf-8") as handle: + return sum(1 for _ in handle) + + +def _run_case(case_id: str, scenario_name: str, script_text: str) -> Tuple[Dict[str, Any], Path]: + out_dir = (ROOT / "runs" / f"yaml_dsl_perf_{case_id}_{int(time.time() * 1000)}").resolve() + out_dir.mkdir(parents=True, exist_ok=True) + script_path = out_dir / f"{case_id}.yaml" + script_path.write_text(script_text, encoding="utf-8") + ast = parse_script(str(script_path)) + + client, server = create_mock_pair() + scenario = load_scenario(ROOT / "tools" / "target_emulator" / "scenarios" / scenario_name) + emu = TargetEmulator( + scenario=scenario, + config=EmulatorConfig(mode="mock", artifacts_dir=str(out_dir / "target")), + endpoint=server, + ) + ctx = RuntimeContext( + channels={"default": client}, + default_channel="default", + vars_init=dict(ast.vars or {}), + params_init=dict(ast.params or {}), + script_path=str(script_path), + script_text=script_text, + ) + emu.start() + try: + summary = execute_v01(ast, ctx) + export_v01_artifacts(ast, summary) + finally: + ctx.close() + emu.stop() + return summary, out_dir + + +def _run_basic() -> Tuple[Dict[str, Any], Path]: + script = """ +version: "0.2" +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 120 +steps: + - name: send + text: "AT" + - name: expect + match: { type: contains, pattern: "OK" } +artifacts: + dir: "./runs/yaml_dsl_perf_basic_${now}" + raw_log: true + summary_json: true +""".strip() + return _run_case("basic", "at_basic.yaml", script) + + +def _run_retry() -> Tuple[Dict[str, Any], Path]: + script = """ +version: "0.2" +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 120 +steps: + - name: send + text: "ENTER_BOOT" + - name: expect + timeout_ms: 90 + match: { type: contains, pattern: "BOOT:READY" } + retry: + count: 2 + backoff_ms: 30 + strategy: fixed +artifacts: + dir: "./runs/yaml_dsl_perf_retry_${now}" + raw_log: true + summary_json: true +""".strip() + return _run_case("retry", "retry_expect_window.yaml", script) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run YAML-DSL performance baseline.") + parser.add_argument("--basic-iters", type=int, default=12) + parser.add_argument("--retry-iters", type=int, default=8) + args = parser.parse_args() + + basic_iters = max(1, int(args.basic_iters)) + retry_iters = max(1, int(args.retry_iters)) + + basic_elapsed: List[float] = [] + retry_elapsed: List[float] = [] + retry_attempts: List[int] = [] + total_raw_lines = 0 + total_elapsed_ms = 0.0 + failures = 0 + + for _ in range(basic_iters): + summary, out_dir = _run_basic() + if not summary.get("ok", False): + failures += 1 + elapsed = float(summary.get("elapsed_ms", 0)) + basic_elapsed.append(elapsed) + total_elapsed_ms += elapsed + total_raw_lines += _count_lines(out_dir / "target" / "raw_log.jsonl") + + for _ in range(retry_iters): + summary, out_dir = _run_retry() + if not summary.get("ok", False): + failures += 1 + elapsed = float(summary.get("elapsed_ms", 0)) + retry_elapsed.append(elapsed) + total_elapsed_ms += elapsed + total_raw_lines += _count_lines(out_dir / "target" / "raw_log.jsonl") + for step in summary.get("steps", []): + if step.get("name") == "expect": + retry_attempts.append(int(step.get("attempts", 1))) + + basic_avg = statistics.mean(basic_elapsed) if basic_elapsed else 0.0 + retry_avg = statistics.mean(retry_elapsed) if retry_elapsed else 0.0 + retry_cost_ms = retry_avg - basic_avg + throughput = total_raw_lines / max(0.001, total_elapsed_ms / 1000.0) + retry_hit_rate = 0.0 + if retry_attempts: + retry_hit_rate = sum(1 for x in retry_attempts if x > 1) / len(retry_attempts) + + ok = failures == 0 and basic_avg > 0 and retry_avg > 0 and throughput > 0 + report = { + "suite": "yaml_dsl.performance_baseline", + "ok": ok, + "iterations": {"basic": basic_iters, "retry": retry_iters}, + "failures": failures, + "latency_ms": { + "basic_avg": round(basic_avg, 3), + "basic_p95": round(_p95(basic_elapsed), 3), + "retry_avg": round(retry_avg, 3), + "retry_p95": round(_p95(retry_elapsed), 3), + "retry_cost_avg": round(retry_cost_ms, 3), + }, + "retry": { + "attempt_samples": retry_attempts, + "hit_rate": round(retry_hit_rate, 4), + }, + "log_throughput_lines_per_sec": round(throughput, 3), + "generated_at": time.time(), + } + + out_json = (ROOT / "runs" / "yaml_dsl_performance_baseline_report.json").resolve() + out_md = (ROOT / "runs" / "yaml_dsl_performance_baseline_report.md").resolve() + out_json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + out_md.write_text( + "\n".join( + [ + "# YAML-DSL Performance Baseline", + f"- ok: {report['ok']}", + f"- basic iterations: {basic_iters}", + f"- retry iterations: {retry_iters}", + f"- basic avg(ms): {report['latency_ms']['basic_avg']}", + f"- basic p95(ms): {report['latency_ms']['basic_p95']}", + f"- retry avg(ms): {report['latency_ms']['retry_avg']}", + f"- retry p95(ms): {report['latency_ms']['retry_p95']}", + f"- retry avg cost(ms): {report['latency_ms']['retry_cost_avg']}", + f"- retry hit rate: {report['retry']['hit_rate']}", + f"- log throughput(lines/s): {report['log_throughput_lines_per_sec']}", + ] + ), + encoding="utf-8", + ) + + print(f"[{'PASS' if failures == 0 else 'FAIL'}] perf.failures") + print(f"[{'PASS' if throughput > 0 else 'FAIL'}] perf.log_throughput") + print(f"report_json={out_json}") + print(f"report_md={out_md}") + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/yaml_dsl_stability_regression.py b/scripts/yaml_dsl_stability_regression.py new file mode 100644 index 0000000..05f84e3 --- /dev/null +++ b/scripts/yaml_dsl_stability_regression.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import argparse +import gc +import json +import statistics +import sys +import time +import tracemalloc +from pathlib import Path +from typing import Dict, List, Tuple + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from dsl_runtime.engine.context import RuntimeContext +from dsl_runtime.engine.v01_artifacts import export_v01_artifacts +from dsl_runtime.engine.v01_executor import execute_v01 +from dsl_runtime.lang.parser import parse_script +from tools.target_emulator import EmulatorConfig, TargetEmulator, load_scenario +from tools.target_emulator.transport import create_mock_pair + + +def _p95(samples: List[float]) -> float: + if not samples: + return 0.0 + ordered = sorted(samples) + idx = max(0, int(len(ordered) * 0.95) - 1) + return float(ordered[idx]) + + +def _run_once(iter_idx: int) -> Tuple[bool, int, str]: + out_dir = (ROOT / "runs" / f"yaml_dsl_stability_case_{iter_idx}_{int(time.time() * 1000)}").resolve() + out_dir.mkdir(parents=True, exist_ok=True) + script_text = """ +version: "0.2" +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 120 +steps: + - name: send + text: "AT+SN?" + - name: expect + match: { type: regex, pattern: "SN:[A-Z0-9]+" } + capture: + - var: sn + regex: "SN:([A-Z0-9]+)" + - name: assert + expr: "${sn} == 'SN00123'" +artifacts: + dir: "./runs/yaml_dsl_stability_${now}" + raw_log: true + summary_json: true +""".strip() + script_path = out_dir / "stability_case.yaml" + script_path.write_text(script_text, encoding="utf-8") + ast = parse_script(str(script_path)) + + client, server = create_mock_pair() + scenario = load_scenario(ROOT / "tools" / "target_emulator" / "scenarios" / "at_basic.yaml") + emu = TargetEmulator( + scenario=scenario, + config=EmulatorConfig(mode="mock", artifacts_dir=str(out_dir / "target")), + endpoint=server, + ) + ctx = RuntimeContext( + channels={"default": client}, + default_channel="default", + vars_init=dict(ast.vars or {}), + params_init=dict(ast.params or {}), + script_path=str(script_path), + script_text=script_text, + ) + emu.start() + try: + summary = execute_v01(ast, ctx) + export_v01_artifacts(ast, summary) + finally: + ctx.close() + emu.stop() + + ok = bool(summary.get("ok", False)) + elapsed_ms = int(summary.get("elapsed_ms", 0)) + sn = str(summary.get("vars", {}).get("sn", "")) + return ok, elapsed_ms, sn + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run YAML-DSL stability regression.") + parser.add_argument("--iterations", type=int, default=20) + parser.add_argument("--mem-delta-kb-max", type=int, default=10240) + args = parser.parse_args() + + iterations = max(1, int(args.iterations)) + mem_delta_kb_max = max(1, int(args.mem_delta_kb_max)) + + tracemalloc.start() + gc.collect() + mem_start = tracemalloc.get_traced_memory()[0] + started = time.time() + + elapsed_samples: List[float] = [] + sn_values: List[str] = [] + failures = 0 + for i in range(iterations): + ok, elapsed_ms, sn = _run_once(i + 1) + if not ok: + failures += 1 + elapsed_samples.append(float(elapsed_ms)) + sn_values.append(sn) + + gc.collect() + mem_end, mem_peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + + duration_sec = max(0.001, time.time() - started) + success_rate = (iterations - failures) / iterations + sn_consistent = len(set(sn_values)) == 1 + mem_delta_kb = (mem_end - mem_start) / 1024.0 + ok = failures == 0 and sn_consistent and mem_delta_kb <= mem_delta_kb_max + + report: Dict[str, object] = { + "suite": "yaml_dsl.stability_regression", + "ok": ok, + "iterations": iterations, + "failures": failures, + "success_rate": round(success_rate, 4), + "sn_consistent": sn_consistent, + "sn_unique_count": len(set(sn_values)), + "latency_ms": { + "avg": round(statistics.mean(elapsed_samples), 3) if elapsed_samples else 0.0, + "p95": round(_p95(elapsed_samples), 3), + "max": round(max(elapsed_samples) if elapsed_samples else 0.0, 3), + }, + "memory_kb": { + "start": round(mem_start / 1024.0, 3), + "end": round(mem_end / 1024.0, 3), + "peak": round(mem_peak / 1024.0, 3), + "delta": round(mem_delta_kb, 3), + "delta_limit": mem_delta_kb_max, + }, + "duration_sec": round(duration_sec, 3), + "generated_at": time.time(), + } + report_path = (ROOT / "runs" / "yaml_dsl_stability_regression_report.json").resolve() + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + + print(f"[{'PASS' if failures == 0 else 'FAIL'}] stability.failures") + print(f"[{'PASS' if sn_consistent else 'FAIL'}] stability.sn_consistent") + print(f"[{'PASS' if mem_delta_kb <= mem_delta_kb_max else 'FAIL'}] stability.mem_delta") + print(f"report={report_path}") + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/yaml_dsl_target_full_regression.py b/scripts/yaml_dsl_target_full_regression.py new file mode 100644 index 0000000..a19ab16 --- /dev/null +++ b/scripts/yaml_dsl_target_full_regression.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import time +from pathlib import Path +from typing import Any, Dict, Tuple + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from dsl_runtime.engine.context import RuntimeContext +from dsl_runtime.engine.v01_artifacts import export_v01_artifacts +from dsl_runtime.engine.v01_executor import execute_v01 +from dsl_runtime.lang.parser import parse_script +from tools.target_emulator import EmulatorConfig, TargetEmulator, load_scenario +from tools.target_emulator.transport import create_mock_pair + + +def _check(name: str, cond: bool) -> bool: + print(f"[{'PASS' if cond else 'FAIL'}] {name}") + return cond + + +def _run_case(case_id: str, scenario_file: Path, script_text: str) -> Tuple[Dict[str, Any], Path]: + case_dir = (ROOT / "runs" / f"target_dsl_{case_id}_{int(time.time() * 1000)}").resolve() + case_dir.mkdir(parents=True, exist_ok=True) + script_path = case_dir / f"{case_id}.yaml" + script_path.write_text(script_text, encoding="utf-8") + + ast = parse_script(str(script_path)) + client, server = create_mock_pair() + emu = TargetEmulator( + scenario=load_scenario(scenario_file), + config=EmulatorConfig(mode="mock", artifacts_dir=str(case_dir / "target_emu")), + endpoint=server, + ) + ctx = RuntimeContext( + channels={"default": client}, + default_channel="default", + vars_init=dict(ast.vars or {}), + params_init=dict(ast.params or {}), + script_path=str(script_path), + script_text=script_text, + ) + + emu.start() + try: + summary = execute_v01(ast, ctx) + export_v01_artifacts(ast, summary) + return summary, case_dir + finally: + try: + ctx.close() + except Exception: + pass + emu.stop() + + +def _case_basic() -> Tuple[Dict[str, Any], Path]: + yaml_text = """ +version: "0.2" + +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 120 + +steps: + - name: send + text: "AT" + - name: expect + match: + type: contains + pattern: "OK" + - name: send + text: "AT+SN?" + - name: expect + match: + type: regex + pattern: "SN:[A-Z0-9]+" + capture: + - var: dut_sn + regex: "SN:([A-Z0-9]+)" + group: 1 + - name: assert + all: + - expr: "${dut_sn} != ''" + - match: + type: startswith + pattern: "SN" + source: "${dut_sn}" + +artifacts: + dir: "./runs/yaml_dsl_target_basic_${now}" + raw_log: true + summary_json: true + report_csv: true +""".strip() + return _run_case("basic", ROOT / "tools" / "target_emulator" / "scenarios" / "at_basic.yaml", yaml_text) + + +def _case_retry_upgrade() -> Tuple[Dict[str, Any], Path]: + yaml_text = """ +version: "0.2" + +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 120 + +steps: + - name: send + text: "ENTER_BOOT" + - name: expect + timeout_ms: 90 + match: + type: contains + pattern: "BOOT:READY" + retry: + count: 2 + backoff_ms: 30 + strategy: fixed + - name: send + text: "FLASH:BEGIN" + - name: expect + match: + type: contains + pattern: "ACK" + - name: send + text: "FLASH:END" + - name: expect + match: + type: contains + pattern: "CRC:PASS" + +artifacts: + dir: "./runs/yaml_dsl_target_upgrade_${now}" + raw_log: true + summary_json: true +""".strip() + return _run_case( + "retry_upgrade", + ROOT / "tools" / "target_emulator" / "scenarios" / "retry_expect_window.yaml", + yaml_text, + ) + + +def _case_advanced() -> Tuple[Dict[str, Any], Path]: + protocols_dir = (ROOT / "protocols").resolve().as_posix() + yaml_text = f""" +version: "0.2" + +params: + mode: "diagnose" + +vars: + ready: false + last_loop_round: 0 + +session: + transport: serial + port: "COM_TEST" + baud: 115200 + data_bits: 8 + parity: none + stop_bits: 1 + encoding: ascii + eol: crlf + open_timeout_ms: 1000 + read_timeout_ms: 100 + +security: + exec: + enabled: true + allow_commands: ["python"] + cwd_allowlist: ["."] + file: + root_allowlist: ["./runs", "./tmp"] + +step_templates: + ping: + params: [cmd] + steps: + - name: send + text: "${{cmd}}" + - name: expect + match: + type: contains + pattern: "OK" + +steps: + - template: ping + args: + cmd: "AT" + + - name: parse + format: json + source: '{{"adc":{{"voltage":12.10}},"temp":40.5}}' + save_as: parsed + + - name: path + source: "${{parsed}}" + path: "adc.voltage" + save_as: voltage + + - name: measure + metric: "voltage" + value: "${{voltage}}" + unit: "V" + + - name: assert_range + value: "${{measure.voltage}}" + min: 11.8 + max: 12.3 + + - name: if + when: "${{mode}} == 'diagnose'" + then: + - name: send + text: "ATI" + - name: expect + match: + type: contains + pattern: "ProtoFlow-Target" + else: + - name: send + text: "AT" + + - name: loop + times: 4 + until: "${{ready}} == True" + steps: + - name: if + when: "${{last_loop_round}} >= 2" + then: + - name: file + op: write_text + path: "./tmp/ready.flag" + content: "ok=1" + - name: file + op: exists + path: "./tmp/ready.flag" + save_as: ready + - name: sleep + ms: 20 + + - name: switch_session + baud: 921600 + dry_run: true + + - name: protocol.rpc + protocol: at_command + packages_dir: "{protocols_dir}" + request: + cmd: "AT" + expect: + status: ok + contains: "OK" + timeout_ms: 1000 + save_as: proto_rsp + + - name: exec + command: "python --version" + timeout_ms: 5000 + save_stdout_as: py_out + save_stderr_as: py_err + + - name: file + op: write_text + path: "./tmp/full_reg_report.txt" + content: "voltage=${{measure.voltage}}\\nproto_ok=${{proto_rsp.ok}}\\npy=${{py_out}}${{py_err}}" + + - name: file + op: read_text + path: "./tmp/full_reg_report.txt" + save_as: report + + - name: assert + all: + - expr: "${{report}} != ''" + - expr: "${{ready}} == True" + +artifacts: + dir: "./runs/yaml_dsl_target_advanced_${{now}}" + raw_log: true + summary_json: true + report_csv: true +""".strip() + return _run_case("advanced", ROOT / "tools" / "target_emulator" / "scenarios" / "at_basic.yaml", yaml_text) + + +def main() -> int: + ok = True + outputs = [] + + summary1, out1 = _case_basic() + outputs.append(str(out1)) + ok &= _check("case_basic.ok", bool(summary1.get("ok", False))) + ok &= _check("case_basic.capture_sn", str(summary1.get("vars", {}).get("dut_sn", "")).startswith("SN")) + + summary2, out2 = _case_retry_upgrade() + outputs.append(str(out2)) + ok &= _check("case_retry_upgrade.ok", bool(summary2.get("ok", False))) + ok &= _check( + "case_retry_upgrade.retry_happened", + any(int(step.get("attempts", 1)) > 1 for step in summary2.get("steps", []) if step.get("name") == "expect"), + ) + + summary3, out3 = _case_advanced() + outputs.append(str(out3)) + ok &= _check("case_advanced.ok", bool(summary3.get("ok", False))) + ok &= _check("case_advanced.has_metrics", len(summary3.get("vars", {}).get("metrics", [])) >= 1) + ok &= _check("case_advanced.protocol_ok", bool(summary3.get("vars", {}).get("proto_rsp", {}).get("ok", False))) + + report = { + "ok": ok, + "cases": ["basic", "retry_upgrade", "advanced"], + "outputs": outputs, + "ts": time.time(), + } + out = (ROOT / "runs" / "yaml_dsl_target_full_regression_report.json").resolve() + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"report={out}") + print(f"RESULT: {'PASSED' if ok else 'FAILED'}") + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/target_emulator/__init__.py b/tools/target_emulator/__init__.py new file mode 100644 index 0000000..7797b63 --- /dev/null +++ b/tools/target_emulator/__init__.py @@ -0,0 +1,11 @@ +from .core import TargetEmulator +from .models import EmulatorConfig, Scenario, ScenarioRule +from .scenario_loader import load_scenario + +__all__ = [ + "TargetEmulator", + "EmulatorConfig", + "Scenario", + "ScenarioRule", + "load_scenario", +] diff --git a/tools/target_emulator/core.py b/tools/target_emulator/core.py new file mode 100644 index 0000000..4949d54 --- /dev/null +++ b/tools/target_emulator/core.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import json +import random +import re +import threading +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .models import EmulatorConfig, ResponseSpec, Scenario, ScenarioRule +from .transport import Endpoint, SerialEndpoint, TcpServerEndpoint + + +_EOL_MAP = { + "none": b"", + "cr": b"\r", + "lf": b"\n", + "crlf": b"\r\n", +} + + +def _decode_bytes(data: bytes, encoding: str) -> str: + if encoding.lower() == "hex": + return data.hex().upper() + return data.decode(encoding or "utf-8", errors="ignore") + + +def _encode_response(resp: ResponseSpec, encoding: str, default_eol: str) -> bytes: + eol = _EOL_MAP.get((resp.eol or default_eol).strip().lower(), b"\r\n") + if resp.hex is not None: + return bytes.fromhex(resp.hex.replace(" ", "")) + eol + text = resp.text or "" + if encoding.lower() == "hex": + return bytes.fromhex(text.replace(" ", "")) + eol + return text.encode(encoding or "utf-8") + eol + + +def _send_with_chunk(endpoint: Endpoint, payload: bytes, chunk_bytes: int, chunk_interval_ms: int) -> None: + if chunk_bytes <= 0: + endpoint.write(payload) + return + size = max(1, chunk_bytes) + for i in range(0, len(payload), size): + endpoint.write(payload[i : i + size]) + if chunk_interval_ms > 0 and i + size < len(payload): + time.sleep(chunk_interval_ms / 1000.0) + + +def _match_rule(rule: ScenarioRule, text: str) -> bool: + kind = rule.when.type.strip().lower() + pattern = rule.when.pattern + if kind == "contains": + return pattern in text + if kind == "exact": + return text == pattern + if kind == "startswith": + return text.startswith(pattern) + if kind == "regex": + return re.search(pattern, text) is not None + raise ValueError(f"unsupported match type: {kind}") + + +class TargetEmulator: + def __init__(self, scenario: Scenario, config: EmulatorConfig, endpoint: Optional[Endpoint] = None) -> None: + self.scenario = scenario + self.config = config + self.endpoint = endpoint or self._create_endpoint(config) + self._running = False + self._thread: Optional[threading.Thread] = None + self._events: List[Dict[str, Any]] = [] + self._lock = threading.Lock() + self._rules_hit: Dict[str, int] = {} + self._rx_lines = 0 + self._tx_lines = 0 + + @staticmethod + def _create_endpoint(config: EmulatorConfig) -> Endpoint: + mode = config.mode.strip().lower() + if mode == "serial": + if not config.serial_port: + raise ValueError("serial mode requires serial_port") + return SerialEndpoint(port=config.serial_port, baud=config.serial_baud) + if mode == "tcp": + return TcpServerEndpoint(host=config.tcp_host, port=config.tcp_port) + raise ValueError(f"unsupported mode for default endpoint creation: {config.mode}") + + def start(self) -> None: + if self._running: + return + self._running = True + self._thread = threading.Thread(target=self._loop, name="target-emulator", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=1.0) + self.endpoint.close() + self._export_artifacts() + + def _record(self, kind: str, payload: Dict[str, Any]) -> None: + item = {"ts": time.time(), "kind": kind, **payload} + with self._lock: + self._events.append(item) + + def _apply_rule(self, rule: ScenarioRule, rx_text: str) -> None: + self._record("rule.hit", {"rule_id": rule.id, "rx": rx_text}) + self._rules_hit[rule.id] = int(self._rules_hit.get(rule.id, 0)) + 1 + delay_ms = max(0, int(rule.delay_ms)) + if rule.jitter_ms > 0: + delay_ms += random.randint(0, int(rule.jitter_ms)) + if delay_ms > 0: + time.sleep(delay_ms / 1000.0) + if rule.drop: + self._record("tx.drop", {"rule_id": rule.id}) + return + if rule.respond is not None: + tx = _encode_response(rule.respond, encoding=self.scenario.encoding, default_eol=self.scenario.eol) + _send_with_chunk(self.endpoint, tx, rule.respond.chunk_bytes, rule.respond.chunk_interval_ms) + self._tx_lines += 1 + self._record("tx", {"rule_id": rule.id, "text": _decode_bytes(tx, self.scenario.encoding), "hex": tx.hex().upper()}) + if rule.close_after: + self._record("session.close", {"rule_id": rule.id}) + self._running = False + + def _apply_fallback(self, rx_text: str) -> None: + if self.scenario.fallback is None: + self._record("rule.miss", {"rx": rx_text}) + return + tx = _encode_response(self.scenario.fallback, encoding=self.scenario.encoding, default_eol=self.scenario.eol) + _send_with_chunk(self.endpoint, tx, self.scenario.fallback.chunk_bytes, self.scenario.fallback.chunk_interval_ms) + self._tx_lines += 1 + self._record("tx.fallback", {"text": _decode_bytes(tx, self.scenario.encoding), "hex": tx.hex().upper()}) + + def _loop(self) -> None: + rx_buf = bytearray() + delim = _EOL_MAP.get(self.scenario.eol.lower(), b"\r\n") + self._record("session.start", {"scenario": self.scenario.name, "mode": self.config.mode}) + while self._running: + chunk = self.endpoint.read(self.config.read_chunk, self.config.read_timeout) + if not chunk: + continue + rx_buf.extend(chunk) + while True: + if delim: + idx = rx_buf.find(delim) + if idx < 0: + break + raw = bytes(rx_buf[:idx]) + del rx_buf[: idx + len(delim)] + else: + raw = bytes(rx_buf) + rx_buf.clear() + rx_text = _decode_bytes(raw, self.scenario.encoding).strip() + if not rx_text: + continue + self._rx_lines += 1 + self._record("rx", {"text": rx_text, "hex": raw.hex().upper()}) + handled = False + for rule in self.scenario.rules: + if not rule.enabled: + continue + if rule.once and self._rules_hit.get(rule.id, 0) > 0: + continue + if _match_rule(rule, rx_text): + handled = True + self._apply_rule(rule, rx_text) + break + if not handled: + self._apply_fallback(rx_text) + self._record("session.stop", {}) + + def _export_artifacts(self) -> None: + out = Path(self.config.artifacts_dir).resolve() + out.mkdir(parents=True, exist_ok=True) + raw_path = out / "raw_log.jsonl" + with raw_path.open("w", encoding="utf-8") as handle: + for item in self._events: + handle.write(json.dumps(item, ensure_ascii=False) + "\n") + summary = { + "scenario": self.scenario.name, + "description": self.scenario.description, + "mode": self.config.mode, + "rx_lines": self._rx_lines, + "tx_lines": self._tx_lines, + "rules_hit": dict(self._rules_hit), + "events": len(self._events), + "ended_at": time.time(), + } + (out / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/tools/target_emulator/models.py b/tools/target_emulator/models.py new file mode 100644 index 0000000..0fd1816 --- /dev/null +++ b/tools/target_emulator/models.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class MatchRule: + type: str + pattern: str + + +@dataclass +class ResponseSpec: + text: Optional[str] = None + hex: Optional[str] = None + eol: Optional[str] = None + chunk_bytes: int = 0 + chunk_interval_ms: int = 0 + + +@dataclass +class ScenarioRule: + id: str + when: MatchRule + respond: Optional[ResponseSpec] = None + delay_ms: int = 0 + jitter_ms: int = 0 + drop: bool = False + close_after: bool = False + once: bool = False + enabled: bool = True + meta: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class Scenario: + version: str + name: str + description: str = "" + encoding: str = "utf-8" + eol: str = "crlf" + rules: List[ScenarioRule] = field(default_factory=list) + fallback: Optional[ResponseSpec] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class EmulatorConfig: + mode: str + artifacts_dir: str + serial_port: Optional[str] = None + serial_baud: int = 115200 + tcp_host: str = "127.0.0.1" + tcp_port: int = 19001 + read_chunk: int = 256 + read_timeout: float = 0.1 diff --git a/tools/target_emulator/scenario_loader.py b/tools/target_emulator/scenario_loader.py new file mode 100644 index 0000000..135b9c9 --- /dev/null +++ b/tools/target_emulator/scenario_loader.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List + +import yaml + +from .models import MatchRule, ResponseSpec, Scenario, ScenarioRule + + +def _parse_response(raw: Any) -> ResponseSpec | None: + if raw is None: + return None + if not isinstance(raw, dict): + raise ValueError("respond must be a mapping") + text = raw.get("text") + hex_text = raw.get("hex") + eol = raw.get("eol") + if text is None and hex_text is None: + raise ValueError("respond.text or respond.hex is required") + return ResponseSpec( + text=str(text) if text is not None else None, + hex=str(hex_text) if hex_text is not None else None, + eol=str(eol) if eol is not None else None, + chunk_bytes=int(raw.get("chunk_bytes", 0)), + chunk_interval_ms=int(raw.get("chunk_interval_ms", 0)), + ) + + +def _parse_rule(raw: Any, idx: int) -> ScenarioRule: + if not isinstance(raw, dict): + raise ValueError(f"rules[{idx}] must be a mapping") + rule_id = str(raw.get("id") or f"rule_{idx + 1}") + when = raw.get("when") + if not isinstance(when, dict): + raise ValueError(f"rules[{idx}].when must be a mapping") + match_type = str(when.get("type") or "contains").strip().lower() + pattern = str(when.get("pattern") or "") + if not pattern: + raise ValueError(f"rules[{idx}].when.pattern is required") + respond = _parse_response(raw.get("respond")) + return ScenarioRule( + id=rule_id, + when=MatchRule(type=match_type, pattern=pattern), + respond=respond, + delay_ms=int(raw.get("delay_ms", 0)), + jitter_ms=int(raw.get("jitter_ms", 0)), + drop=bool(raw.get("drop", False)), + close_after=bool(raw.get("close_after", False)), + once=bool(raw.get("once", False)), + enabled=bool(raw.get("enabled", True)), + meta=dict(raw.get("meta") or {}), + ) + + +def load_scenario(path: str | Path) -> Scenario: + p = Path(path) + data = yaml.safe_load(p.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + raise ValueError("scenario root must be a mapping") + version = str(data.get("version") or "1") + meta = data.get("meta") or {} + if not isinstance(meta, dict): + raise ValueError("meta must be a mapping") + transport = data.get("transport_defaults") or {} + if not isinstance(transport, dict): + raise ValueError("transport_defaults must be a mapping") + raw_rules = data.get("rules") or [] + if not isinstance(raw_rules, list): + raise ValueError("rules must be a list") + rules = [_parse_rule(item, idx) for idx, item in enumerate(raw_rules)] + fallback_raw = data.get("fallback") or {} + if fallback_raw and not isinstance(fallback_raw, dict): + raise ValueError("fallback must be a mapping") + fallback = _parse_response(fallback_raw.get("respond")) if fallback_raw else None + return Scenario( + version=version, + name=str(meta.get("name") or p.stem), + description=str(meta.get("description") or ""), + encoding=str(transport.get("encoding") or "utf-8"), + eol=str(transport.get("eol") or "crlf"), + rules=rules, + fallback=fallback, + metadata=meta, + ) diff --git a/tools/target_emulator/scenarios/at_basic.yaml b/tools/target_emulator/scenarios/at_basic.yaml new file mode 100644 index 0000000..b88b9ce --- /dev/null +++ b/tools/target_emulator/scenarios/at_basic.yaml @@ -0,0 +1,36 @@ +version: "1" + +meta: + name: "AT Basic" + description: "AT command baseline: AT/ATI/AT+SN? flow" + +transport_defaults: + encoding: "utf-8" + eol: "crlf" + +rules: + - id: at_ping + when: + type: exact + pattern: "AT" + respond: + text: "OK" + delay_ms: 10 + + - id: at_ati + when: + type: exact + pattern: "ATI" + respond: + text: "ProtoFlow-Target v0.1\r\nOK" + + - id: at_sn + when: + type: exact + pattern: "AT+SN?" + respond: + text: "SN:SN00123\r\nOK" + +fallback: + respond: + text: "ERROR" diff --git a/tools/target_emulator/scenarios/combo_upgrade_config.yaml b/tools/target_emulator/scenarios/combo_upgrade_config.yaml new file mode 100644 index 0000000..0a9dd6e --- /dev/null +++ b/tools/target_emulator/scenarios/combo_upgrade_config.yaml @@ -0,0 +1,57 @@ +version: "1" + +meta: + name: "Combo Upgrade Config" + description: "Upgrade + parameter write + version verification flow" + +transport_defaults: + encoding: "utf-8" + eol: "crlf" + +rules: + - id: enter_boot_delay + when: + type: exact + pattern: "ENTER_BOOT" + delay_ms: 180 + respond: + text: "BOOT:READY" + + - id: flash_begin + when: + type: exact + pattern: "FLASH:BEGIN" + respond: + text: "ACK" + + - id: flash_end + when: + type: exact + pattern: "FLASH:END" + respond: + text: "CRC:PASS\r\nOK" + + - id: write_sn + when: + type: startswith + pattern: "SET:SN=" + respond: + text: "WRITE:OK" + + - id: read_sn + when: + type: exact + pattern: "GET:SN?" + respond: + text: "SN:SN90001\r\nOK" + + - id: read_ver + when: + type: exact + pattern: "GET:VER?" + respond: + text: "VER:1.2.3\r\nOK" + +fallback: + respond: + text: "ERROR" diff --git a/tools/target_emulator/scenarios/fault_injection_matrix.yaml b/tools/target_emulator/scenarios/fault_injection_matrix.yaml new file mode 100644 index 0000000..a8af759 --- /dev/null +++ b/tools/target_emulator/scenarios/fault_injection_matrix.yaml @@ -0,0 +1,60 @@ +version: "1" + +meta: + name: "Fault Injection Matrix" + description: "Timeout/drop/jitter/chunk/close/reorder case coverage" + +transport_defaults: + encoding: "utf-8" + eol: "crlf" + +rules: + - id: drop_once + when: + type: exact + pattern: "PING:DROP" + drop: true + once: true + + - id: timeout_window + when: + type: exact + pattern: "PING:TIMEOUT" + drop: true + + - id: jitter_response + when: + type: exact + pattern: "PING:JITTER" + delay_ms: 20 + jitter_ms: 60 + respond: + text: "PONG:JITTER" + + - id: chunk_response + when: + type: exact + pattern: "PING:CHUNK" + respond: + text: "PAYLOAD:ABCDEFGHIJKLMNOPQRSTUVWXYZ" + chunk_bytes: 8 + chunk_interval_ms: 5 + + - id: reorder_payload + when: + type: exact + pattern: "PING:REORDER" + respond: + text: "SEQ:2,1" + + - id: close_after + when: + type: exact + pattern: "PING:CLOSE" + respond: + text: "BYE" + close_after: true + +fallback: + respond: + text: "ERROR" diff --git a/tools/target_emulator/scenarios/retry_expect_window.yaml b/tools/target_emulator/scenarios/retry_expect_window.yaml new file mode 100644 index 0000000..29eb7ac --- /dev/null +++ b/tools/target_emulator/scenarios/retry_expect_window.yaml @@ -0,0 +1,36 @@ +version: "1" + +meta: + name: "Retry Expect Window" + description: "Response arrives after first expect timeout to validate expect-retry path" + +transport_defaults: + encoding: "utf-8" + eol: "crlf" + +rules: + - id: enter_boot_delayed + when: + type: exact + pattern: "ENTER_BOOT" + delay_ms: 180 + respond: + text: "BOOT:READY" + + - id: flash_begin + when: + type: exact + pattern: "FLASH:BEGIN" + respond: + text: "ACK" + + - id: flash_end + when: + type: exact + pattern: "FLASH:END" + respond: + text: "CRC:PASS\r\nOK" + +fallback: + respond: + text: "ERROR" diff --git a/tools/target_emulator/scenarios/scpi_basic.yaml b/tools/target_emulator/scenarios/scpi_basic.yaml new file mode 100644 index 0000000..8d532c8 --- /dev/null +++ b/tools/target_emulator/scenarios/scpi_basic.yaml @@ -0,0 +1,28 @@ +version: "1" + +meta: + name: "SCPI Basic" + description: "SCPI *IDN? and MEASure query baseline" + +transport_defaults: + encoding: "utf-8" + eol: "lf" + +rules: + - id: idn + when: + type: exact + pattern: "*IDN?" + respond: + text: "ProtoFlow,VirtualDUT,123456,1.0" + + - id: meas_volt + when: + type: regex + pattern: "^MEAS:VOLT\\?$" + respond: + text: "12.05,V" + +fallback: + respond: + text: "-100,\"Command error\"" diff --git a/tools/target_emulator/scenarios/upgrade_window.yaml b/tools/target_emulator/scenarios/upgrade_window.yaml new file mode 100644 index 0000000..c80995c --- /dev/null +++ b/tools/target_emulator/scenarios/upgrade_window.yaml @@ -0,0 +1,42 @@ +version: "1" + +meta: + name: "Upgrade Window" + description: "Boot window with one timeout simulation then success" + +transport_defaults: + encoding: "utf-8" + eol: "crlf" + +rules: + - id: enter_boot_first_timeout + when: + type: exact + pattern: "ENTER_BOOT" + drop: true + once: true + + - id: enter_boot_success + when: + type: exact + pattern: "ENTER_BOOT" + respond: + text: "BOOT:READY" + + - id: flash_begin + when: + type: exact + pattern: "FLASH:BEGIN" + respond: + text: "ACK" + + - id: flash_end + when: + type: exact + pattern: "FLASH:END" + respond: + text: "CRC:PASS\r\nOK" + +fallback: + respond: + text: "ERROR" diff --git a/tools/target_emulator/transport.py b/tools/target_emulator/transport.py new file mode 100644 index 0000000..7e32578 --- /dev/null +++ b/tools/target_emulator/transport.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import queue +import socket +import threading +import time +from dataclasses import dataclass +from typing import Optional, Tuple + +try: + import serial +except Exception: # pragma: no cover + serial = None + + +class Endpoint: + def read(self, size: int, timeout: float) -> bytes: + raise NotImplementedError + + def write(self, data: bytes) -> None: + raise NotImplementedError + + def close(self) -> None: + return None + + +class SerialEndpoint(Endpoint): + def __init__(self, port: str, baud: int) -> None: + if serial is None: + raise RuntimeError("pyserial is required for serial mode") + self._ser = serial.Serial(port=port, baudrate=baud, timeout=0, write_timeout=0.5) + + def read(self, size: int, timeout: float) -> bytes: + deadline = time.time() + max(0.01, timeout) + buf = bytearray() + while len(buf) < size and time.time() < deadline: + chunk = self._ser.read(size - len(buf)) + if chunk: + buf.extend(chunk) + else: + time.sleep(0.005) + return bytes(buf) + + def write(self, data: bytes) -> None: + self._ser.write(data) + + def close(self) -> None: + try: + self._ser.close() + except Exception: + pass + + +class TcpServerEndpoint(Endpoint): + def __init__(self, host: str, port: int) -> None: + self._srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._srv.bind((host, port)) + self._srv.listen(1) + self._srv.settimeout(15.0) + self._conn, _ = self._srv.accept() + self._conn.settimeout(0.1) + + def read(self, size: int, timeout: float) -> bytes: + deadline = time.time() + max(0.01, timeout) + while time.time() < deadline: + try: + data = self._conn.recv(max(1, size)) + return data + except socket.timeout: + continue + return b"" + + def write(self, data: bytes) -> None: + self._conn.sendall(data) + + def close(self) -> None: + for s in [self._conn, self._srv]: + try: + s.close() + except Exception: + pass + + +@dataclass +class MockDuplexEndpoint(Endpoint): + rx: "queue.Queue[bytes]" + tx: "queue.Queue[bytes]" + _closed: bool = False + + def read(self, size: int, timeout: float) -> bytes: + if self._closed: + return b"" + deadline = time.time() + max(0.01, timeout) + out = bytearray() + while len(out) < size and time.time() < deadline: + try: + chunk = self.rx.get(timeout=min(0.02, max(0.001, deadline - time.time()))) + except queue.Empty: + continue + if not chunk: + continue + out.extend(chunk[: max(1, size - len(out))]) + return bytes(out) + + def write(self, data: bytes) -> None: + if self._closed: + return + self.tx.put(bytes(data)) + + def close(self) -> None: + self._closed = True + + +def create_mock_pair() -> Tuple[MockDuplexEndpoint, MockDuplexEndpoint]: + a2b: "queue.Queue[bytes]" = queue.Queue() + b2a: "queue.Queue[bytes]" = queue.Queue() + return MockDuplexEndpoint(rx=b2a, tx=a2b), MockDuplexEndpoint(rx=a2b, tx=b2a) + + +class TcpClient: + def __init__(self, host: str, port: int) -> None: + self._sock = socket.create_connection((host, port), timeout=3.0) + self._sock.settimeout(0.1) + + def write(self, data: bytes) -> None: + self._sock.sendall(data) + + def read(self, size: int = 512, timeout: float = 0.5) -> bytes: + deadline = time.time() + max(0.01, timeout) + while time.time() < deadline: + try: + return self._sock.recv(size) + except socket.timeout: + continue + return b"" + + def close(self) -> None: + try: + self._sock.close() + except Exception: + pass diff --git a/ui/desktop/script_runner_qt.py b/ui/desktop/script_runner_qt.py index cb08b42..9f8d472 100644 --- a/ui/desktop/script_runner_qt.py +++ b/ui/desktop/script_runner_qt.py @@ -9,25 +9,25 @@ from PySide6.QtCore import QThread, Signal from dsl_runtime.actions.dsl_builtin_actions import register_builtin_actions -from dsl_runtime.actions.dsl_protocol_actions import register_protocol_actions -from dsl_runtime.actions.dsl_protocol_schema_actions import register_schema_protocol_actions from dsl_runtime.actions.dsl_chart_actions import register_chart_actions -from dsl_runtime.actions.dsl_record_actions import register_record_actions from dsl_runtime.actions.dsl_data_actions import register_data_actions -from dsl_runtime.lang.executor import StateMachineExecutor -from dsl_runtime.lang.parser import parse_script +from dsl_runtime.actions.dsl_record_actions import register_record_actions from dsl_runtime.engine.channels import build_channels from dsl_runtime.engine.context import RuntimeContext +from dsl_runtime.engine.v01_artifacts import export_v01_artifacts +from dsl_runtime.engine.v01_executor import execute_v01 +from dsl_runtime.lang.executor import StateMachineExecutor +from dsl_runtime.lang.parser import parse_script class _LogHandler(logging.Handler): - """将 logging 输出转发到 Qt 信号。""" + """Forward logging records to a Qt signal callback.""" def __init__(self, emit_fn) -> None: super().__init__() self._emit_fn = emit_fn - def emit(self, record: logging.LogRecord) -> None: # pragma: no cover - Qt 回调 + def emit(self, record: logging.LogRecord) -> None: # pragma: no cover msg = self.format(record) try: self._emit_fn(msg) @@ -36,7 +36,7 @@ def emit(self, record: logging.LogRecord) -> None: # pragma: no cover - Qt 回 class _ObservableExecutor(StateMachineExecutor): - """带停止标记与进度回调的执行器包装。""" + """State machine executor with stop event and progress callbacks.""" def __init__(self, ast, ctx, stop_event: threading.Event, on_state, on_progress) -> None: super().__init__(ast, ctx) @@ -54,7 +54,6 @@ def run(self) -> None: self._run_actions(state) if self._stop_event.is_set(): break - # 条件跳转 if state.goto: if state.when: cond = bool(self.ctx.eval_value(state.when)) @@ -65,7 +64,6 @@ def run(self) -> None: else: self._goto(state.goto) continue - # 事件/超时 next_state = self._wait_event_or_timeout(state) if next_state: self._goto(next_state) @@ -98,7 +96,7 @@ def _notify(self, name: str) -> None: class ScriptRunnerQt(QThread): - """在后台线程运行 DSL,并通过信号回传 log/state/progress。""" + """Run DSL in a worker thread and emit log/state/progress via Qt signals.""" sig_log = Signal(str) sig_state = Signal(str) @@ -114,17 +112,14 @@ def __init__(self, yaml_text: str, bus=None, external_events: list[str] | None = def stop(self) -> None: self._stop_event.set() - def run(self) -> None: # pragma: no cover - 线程逻辑 + def run(self) -> None: # pragma: no cover handler = _LogHandler(lambda msg: self.sig_log.emit(msg)) handler.setLevel(logging.INFO) logger = logging.getLogger("dsl") logger.handlers = [handler] logger.setLevel(logging.INFO) - # 注册动作 register_builtin_actions() - register_protocol_actions() - register_schema_protocol_actions() register_chart_actions() register_record_actions() register_data_actions() @@ -133,38 +128,77 @@ def run(self) -> None: # pragma: no cover - 线程逻辑 ctx = None tmp_path: str | None = None try: - # 将编辑器内容写入临时文件,复用 parser - with tempfile.NamedTemporaryFile("w+", suffix=".yaml", delete=False) as tmp: + self.sig_state.emit("__running__") + self.sig_progress.emit(0) + + # Use UTF-8 explicitly to avoid locale-dependent encode failures on Windows. + with tempfile.NamedTemporaryFile("w+", suffix=".yaml", delete=False, encoding="utf-8") as tmp: tmp.write(self.yaml_text) tmp.flush() tmp_path = tmp.name + ast = parse_script(tmp_path) - channels = build_channels(ast.channels) - if not channels: - raise ValueError("未定义 channels") - default_channel = next(iter(channels.keys())) - ctx = RuntimeContext( - channels, - default_channel, - vars_init=ast.vars, - bus=self.bus, - external_events=self.external_events, - script_text=self.yaml_text, - ) - - executor = _ObservableExecutor( - ast, - ctx, - stop_event=self._stop_event, - on_state=lambda s: self.sig_state.emit(s), - on_progress=lambda p: self.sig_progress.emit(p), - ) - executor.run() + if ast.version == "0.1": + if ast.session is None: + raise ValueError("session is required for v0.1") + channels = build_channels( + { + "default": { + "type": "serial", + "device": ast.session.port, + "baudrate": ast.session.baud, + } + } + ) + ctx = RuntimeContext( + channels, + "default", + vars_init=dict(ast.vars or {}), + params_init=dict(ast.params or {}), + bus=self.bus, + external_events=self.external_events, + script_text=self.yaml_text, + ) + self.sig_state.emit("v01_steps") + summary = execute_v01(ast, ctx) + artifacts_dir = export_v01_artifacts(ast, summary) + if artifacts_dir: + self.sig_log.emit(f"[ARTIFACTS] {artifacts_dir}") + if not summary.get("ok", False): + err = summary.get("error") or {} + raise RuntimeError(f"v0.1 execution failed: {err.get('code')}: {err.get('message')}") + self.sig_progress.emit(100) + else: + channels = build_channels(ast.channels) + if not channels: + raise ValueError("channels is required") + default_channel = next(iter(channels.keys())) + ctx = RuntimeContext( + channels, + default_channel, + vars_init=ast.vars, + bus=self.bus, + external_events=self.external_events, + script_text=self.yaml_text, + ) + + executor = _ObservableExecutor( + ast, + ctx, + stop_event=self._stop_event, + on_state=lambda s: self.sig_state.emit(s), + on_progress=lambda p: self.sig_progress.emit(p), + ) + executor.run() if self._stop_event.is_set(): + self.sig_state.emit("__stopped__") self.sig_log.emit("Script stopped") else: + self.sig_progress.emit(100) + self.sig_state.emit("__finished__") self.sig_log.emit("Script finished") - except Exception as exc: # 报错直接显示 + except Exception as exc: + self.sig_state.emit("__error__") self.sig_log.emit(f"[ERROR] {exc}") finally: if ctx is not None: diff --git a/ui/desktop/web_bridge.py b/ui/desktop/web_bridge.py index f32cbf2..ccb5627 100644 --- a/ui/desktop/web_bridge.py +++ b/ui/desktop/web_bridge.py @@ -1,14 +1,14 @@ from __future__ import annotations -import importlib import json -import pkgutil -import re +import shutil import threading import time +import tempfile from pathlib import Path import logging import os +import sys from typing import Any, Dict, List, Optional import yaml @@ -19,11 +19,28 @@ from PyQt6.QtCore import QObject, Q_ARG, QMetaObject, QTimer, Qt, pyqtSignal as Signal, pyqtSlot as Slot # type: ignore from PyQt6.QtWidgets import QFileDialog # type: ignore -from infra.protocol.registry import ProtocolRegistry -import infra.protocol as protocols_pkg +from dsl_runtime.protocol_package import ProtocolPackageGateway, load_protocol_packages +from dsl_runtime.protocol_package.runtime import ProtocolCallContext from ui.desktop.script_runner_qt import ScriptRunnerQt +class _CommReadWriteAdapter: + def __init__(self, bridge: "WebBridge") -> None: + self._bridge = bridge + + def write(self, data: bytes | str) -> None: + if self._bridge._comm is None: + raise RuntimeError("communication manager not available") + if isinstance(data, str): + payload = data.encode("utf-8") + else: + payload = bytes(data) + self._bridge._comm.send(payload) + + def read(self, size: int = 256, timeout: float = 0.2) -> bytes: + return self._bridge._protocol_read(size=size, timeout=timeout) + + class WebBridge(QObject): """QWebChannel bridge for Web UI.""" @@ -41,21 +58,30 @@ class WebBridge(QObject): channel_update = Signal(object) ui_event_log = Signal(object) - def __init__(self, bus=None, comm=None, window=None) -> None: + def __init__( + self, + bus=None, + comm=None, + window=None, + proxy_manager=None, + proxy_monitor_enabled: bool = True, + ) -> None: super().__init__() self._logger = logging.getLogger("web_bridge") self._bus = bus self._comm = comm self._window = window + self._proxy_manager = proxy_manager + self._proxy_monitor_enabled = bool(proxy_monitor_enabled) + self._proxy_disabled_logged = False self._script_runner: Optional[ScriptRunnerQt] = None self._buffer: List[Dict[str, Any]] = [] - self._protocols_loaded = False + self._protocol_gateway: Optional[ProtocolPackageGateway] = None + self._protocol_gateway_issues: List[str] = [] self._settings_root = Path(os.environ.get("LOCALAPPDATA", Path.cwd())) / "ProtoFlow" self._settings_path = self._settings_root / "config" / "ui_settings.json" self._proxy_pairs_path = self._settings_root / "config" / "proxy_pairs.json" - self._protocols_path = self._settings_root / "config" / "protocols.json" self._proxy_pairs: List[Dict[str, Any]] = self._load_proxy_pairs() - self._custom_protocols: List[Dict[str, Any]] = self._load_custom_protocols() self._channel_state: Dict[str, Any] = { "type": None, "status": "disconnected", @@ -66,8 +92,11 @@ def __init__(self, bus=None, comm=None, window=None) -> None: "error": None, } self._traffic: Dict[str, int] = {"tx": 0, "rx": 0} + self._protocol_rx_lock = threading.Lock() + self._protocol_rx_buffer = bytearray() self._last_channel_emit = 0.0 self._connect_lock = threading.Lock() + self._save_lock = threading.Lock() self._connect_inflight = False self._last_status_ts = 0.0 self._last_error: Optional[str] = None @@ -84,16 +113,37 @@ def __init__(self, bus=None, comm=None, window=None) -> None: self._bus.subscribe("comm.disconnected", self._on_comm_status) self._bus.subscribe("comm.error", self._on_comm_status) self._bus.subscribe("protocol.frame", self._on_protocol_frame) - self._bus.subscribe("capture.frame", self._on_capture_frame) + if self._proxy_monitor_enabled: + self._bus.subscribe("capture.frame", self._on_capture_frame) + self._bus.subscribe("proxy.status", self._on_proxy_status) + if self._proxy_monitor_enabled: + self._restore_proxy_sessions() def _read_app_version(self) -> str: env_version = os.environ.get("PROTOFLOW_VERSION") if env_version: return env_version.strip() + candidates = [ + Path.cwd() / "VERSION", + Path(__file__).resolve().parents[2] / "VERSION", + ] + if getattr(sys, "frozen", False): + exe_dir = Path(sys.executable).resolve().parent + candidates.extend( + [ + exe_dir / "VERSION", + exe_dir / "_internal" / "VERSION", + ] + ) + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + candidates.append(Path(meipass) / "VERSION") try: - version_path = Path(__file__).resolve().parents[1] / "VERSION" - if version_path.is_file(): - return version_path.read_text(encoding="utf-8").strip() + for version_path in candidates: + if version_path.is_file(): + version = version_path.read_text(encoding="utf-8").strip() + if version: + return version except Exception: return "v0.0.0" return "v0.0.0" @@ -123,43 +173,57 @@ def list_channels(self) -> List[Dict[str, Any]]: @Slot(result="QVariant") def list_protocols(self) -> List[Dict[str, Any]]: self._load_protocols() - registry = ProtocolRegistry.list() + gateway = self._protocol_gateway + if gateway is None: + return [] items: List[Dict[str, Any]] = [] - for key, cls in sorted(registry.items()): - doc = (cls.__doc__ or "").strip() - desc = doc.splitlines()[0].strip() if doc else "" + for key, meta in sorted(gateway.list_protocols().items()): category = self._protocol_category(key) items.append( { "id": key, "key": key, - "driver": cls.__name__, - "desc": desc, + "name": meta.get("name") or key, + "driver": "external_package", + "desc": f"version={meta.get('version', '')}", "category": category, "status": "available", - "source": "builtin", + "source": "external", + "version": meta.get("version"), + "api": meta.get("api") or [], } ) - existing = {item.get("id") for item in items} - for custom in self._custom_protocols: - if not isinstance(custom, dict): - continue - custom_id = custom.get("id") or custom.get("key") - if not custom_id or custom_id in existing: - continue - merged = { - "id": custom_id, - "key": custom.get("key") or custom_id, - "name": custom.get("name") or "", - "driver": custom.get("driver") or "", - "desc": custom.get("desc") or "", - "category": custom.get("category") or "custom", - "status": custom.get("status") or "custom", - "source": "custom", - } - items.append(merged) return items + @Slot("QVariant", result="QVariant") + def call_protocol(self, payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + return {"ok": False, "error": {"code": "VALIDATION_FAILED", "message": "payload must be mapping"}} + self._load_protocols() + gateway = self._protocol_gateway + if gateway is None: + return {"ok": False, "error": {"code": "PROTOCOL_GATEWAY_MISSING", "message": "protocol gateway not ready"}} + protocol_id = str(payload.get("protocol") or payload.get("id") or "").strip() + method = str(payload.get("method") or "rpc").strip().lower() + body = payload.get("payload") or payload.get("request") or {} + if not protocol_id: + return {"ok": False, "error": {"code": "VALIDATION_FAILED", "message": "protocol id is required"}} + if not isinstance(body, dict): + return {"ok": False, "error": {"code": "VALIDATION_FAILED", "message": "payload must be mapping"}} + timeout_ms = int(payload.get("timeout_ms", 2000)) + if timeout_ms <= 0: + return {"ok": False, "error": {"code": "VALIDATION_FAILED", "message": "timeout_ms must be > 0"}} + + call_ctx = ProtocolCallContext( + channel=_CommReadWriteAdapter(self), + logger=self._logger, + vars={}, + timeout_ms=timeout_ms, + artifacts={}, + ) + result = gateway.call(protocol_id=protocol_id, method=method, ctx=call_ctx, payload=body) + return {"ok": result.ok, "data": result.data, "error": result.error} + @Slot(str, result="QVariant") def parse_ui_yaml(self, yaml_text: str) -> Dict[str, Any]: try: @@ -196,77 +260,51 @@ def dispatch_ui_event(self, payload: Dict[str, Any]) -> None: @Slot("QVariant", result="QVariant") def create_protocol(self, payload: Dict[str, Any]) -> Dict[str, Any]: - if not isinstance(payload, dict): - return {} - self._load_protocols() - raw_key = payload.get("key") or payload.get("id") or payload.get("name") or "custom_protocol" - key = self._normalize_protocol_key(str(raw_key)) - key = self._ensure_protocol_key_unique(key) - item = { - "id": key, - "key": key, - "name": payload.get("name") or key, - "desc": payload.get("desc") or "", - "category": payload.get("category") or "custom", - "status": payload.get("status") or "custom", - "driver": payload.get("driver") or "", - } - self._custom_protocols.append(item) - self._save_custom_protocols() - return item + return {"ok": False, "error": "external protocols are read-only from UI"} @Slot("QVariant", result="QVariant") def update_protocol(self, payload: Dict[str, Any]) -> Dict[str, Any]: - if not isinstance(payload, dict): - return {} - protocol_id = payload.get("id") or payload.get("key") - if not protocol_id: - return {} - updated: Dict[str, Any] = {} - for item in self._custom_protocols: - if item.get("id") == protocol_id or item.get("key") == protocol_id: - item["name"] = payload.get("name") or item.get("name") or item.get("key") or "" - item["desc"] = payload.get("desc") or "" - item["category"] = payload.get("category") or item.get("category") or "custom" - item["status"] = payload.get("status") or item.get("status") or "custom" - if payload.get("driver"): - item["driver"] = payload.get("driver") - updated = dict(item) - break - if updated: - self._save_custom_protocols() - return updated + return {"ok": False, "error": "external protocols are read-only from UI"} @Slot(str, result=bool) def delete_protocol(self, protocol_id: str) -> bool: - if not protocol_id: - return False - before = len(self._custom_protocols) - self._custom_protocols = [ - item - for item in self._custom_protocols - if item.get("id") != protocol_id and item.get("key") != protocol_id - ] - if len(self._custom_protocols) == before: - return False - self._save_custom_protocols() - return True + return False @Slot(result="QVariant") def load_settings(self) -> Dict[str, Any]: return self._load_settings() + @Slot(result="QVariant") + def get_feature_flags(self) -> Dict[str, Any]: + return { + "proxyMonitorEnabled": self._proxy_monitor_enabled, + } + + def _proxy_feature_disabled(self) -> bool: + if self._proxy_monitor_enabled: + return False + if not self._proxy_disabled_logged: + self.log.emit("[INFO] proxy monitor is disabled") + self._proxy_disabled_logged = True + return True + @Slot(result="QVariant") def list_proxy_pairs(self) -> List[Dict[str, Any]]: + if self._proxy_feature_disabled(): + return [] return list(self._proxy_pairs) @Slot(result="QVariant") def refresh_proxy_pairs(self) -> List[Dict[str, Any]]: + if self._proxy_feature_disabled(): + return [] self._proxy_pairs = self._load_proxy_pairs() return list(self._proxy_pairs) @Slot("QVariant", result="QVariant") def create_proxy_pair(self, payload: Dict[str, Any]) -> Dict[str, Any]: + if self._proxy_feature_disabled(): + return {} if not isinstance(payload, dict): return {} pair = { @@ -280,6 +318,8 @@ def create_proxy_pair(self, payload: Dict[str, Any]) -> Dict[str, Any]: "stopBits": payload.get("stopBits") or "1", "parity": payload.get("parity") or "none", "flowControl": payload.get("flowControl") or "none", + "desiredActive": bool(payload.get("desiredActive", False)), + "error": payload.get("error") or None, } self._proxy_pairs.insert(0, pair) self._save_proxy_pairs() @@ -287,6 +327,8 @@ def create_proxy_pair(self, payload: Dict[str, Any]) -> Dict[str, Any]: @Slot("QVariant", result="QVariant") def update_proxy_pair(self, payload: Dict[str, Any]) -> Dict[str, Any]: + if self._proxy_feature_disabled(): + return {} if not isinstance(payload, dict): return {} pair_id = payload.get("id") @@ -294,6 +336,7 @@ def update_proxy_pair(self, payload: Dict[str, Any]) -> Dict[str, Any]: return {} for idx, pair in enumerate(self._proxy_pairs): if pair.get("id") == pair_id: + was_running = str(pair.get("status") or "").lower() == "running" updated = { **pair, **{ @@ -314,14 +357,28 @@ def update_proxy_pair(self, payload: Dict[str, Any]) -> Dict[str, Any]: }, } self._proxy_pairs[idx] = updated + if was_running and self._proxy_manager: + result = self._proxy_manager.start_pair(pair_id, updated) + if not result.ok: + updated["status"] = "error" + updated["error"] = result.error + self._proxy_pairs[idx] = updated + else: + updated["desiredActive"] = True + updated["error"] = None + self._proxy_pairs[idx] = updated self._save_proxy_pairs() return updated return {} @Slot(str, result=bool) def delete_proxy_pair(self, pair_id: str) -> bool: + if self._proxy_feature_disabled(): + return False if not pair_id: return False + if self._proxy_manager: + self._proxy_manager.stop_pair(pair_id) before = len(self._proxy_pairs) self._proxy_pairs = [pair for pair in self._proxy_pairs if pair.get("id") != pair_id] if len(self._proxy_pairs) != before: @@ -331,11 +388,28 @@ def delete_proxy_pair(self, pair_id: str) -> bool: @Slot(str, bool, result="QVariant") def set_proxy_pair_status(self, pair_id: str, active: bool) -> Dict[str, Any]: + if self._proxy_feature_disabled(): + return {} status = "running" if active else "stopped" for idx, pair in enumerate(self._proxy_pairs): if pair.get("id") == pair_id: pair = dict(pair) - pair["status"] = status + if active and self._proxy_manager: + result = self._proxy_manager.start_pair(pair_id, pair) + if not result.ok: + pair["status"] = "error" + pair["error"] = result.error + pair["desiredActive"] = True + else: + pair["status"] = status + pair["error"] = None + pair["desiredActive"] = True + else: + if self._proxy_manager: + self._proxy_manager.stop_pair(pair_id) + pair["status"] = status + pair["error"] = None + pair["desiredActive"] = False self._proxy_pairs[idx] = pair self._save_proxy_pairs() return pair @@ -347,6 +421,8 @@ def save_settings(self, payload: Dict[str, Any]) -> bool: @Slot("QVariant", result=bool) def start_capture(self, payload: Dict[str, Any]) -> bool: + if self._proxy_feature_disabled(): + return False if not isinstance(payload, dict): return False channel = payload.get("channel") or payload.get("hostPort") @@ -363,6 +439,8 @@ def start_capture(self, payload: Dict[str, Any]) -> bool: @Slot(result=bool) def stop_capture(self) -> bool: + if self._proxy_feature_disabled(): + return False self._bus.publish("capture.control", {"action": "stop"}) return True @@ -602,6 +680,20 @@ def _emit_channel_update(self, force: bool = False) -> None: self._last_channel_emit = now self.channel_update.emit(self._build_channel_list()) + def _protocol_read(self, *, size: int, timeout: float) -> bytes: + if size <= 0: + size = 1 + deadline = time.time() + max(0.01, timeout) + while time.time() < deadline: + with self._protocol_rx_lock: + if self._protocol_rx_buffer: + n = min(size, len(self._protocol_rx_buffer)) + out = bytes(self._protocol_rx_buffer[:n]) + del self._protocol_rx_buffer[:n] + return out + time.sleep(0.01) + return b"" + @Slot(str) def _emit_comm_rx_signal(self, payload: str) -> None: self.comm_rx.emit(payload) @@ -617,6 +709,8 @@ def _emit_capture_frame_signal(self, payload: Any) -> None: def _on_comm_rx(self, payload: Any) -> None: if isinstance(payload, (bytes, bytearray)): self._traffic["rx"] += len(payload) + with self._protocol_rx_lock: + self._protocol_rx_buffer.extend(bytes(payload)) payload_dict = self._emit_bytes(payload) self._append_buffer( { @@ -697,6 +791,8 @@ def _on_protocol_frame(self, payload: Any) -> None: self._append_buffer({"kind": "FRAME", "payload": payload, "ts": time.time()}) def _on_capture_frame(self, payload: Any) -> None: + if not self._proxy_monitor_enabled: + return self._append_buffer({"kind": "CAPTURE", "payload": payload, "ts": time.time()}) QMetaObject.invokeMethod( self, @@ -705,21 +801,60 @@ def _on_capture_frame(self, payload: Any) -> None: Q_ARG(object, payload), ) - def _load_protocols(self) -> None: - if self._protocols_loaded: + def _on_proxy_status(self, payload: Any) -> None: + if not self._proxy_monitor_enabled: + return + if not isinstance(payload, dict): + return + pair_id = payload.get("pair_id") + if not pair_id: return - self._protocols_loaded = True + status = payload.get("status") + error = payload.get("error") + changed = False + for idx, pair in enumerate(self._proxy_pairs): + if pair.get("id") != pair_id: + continue + new_pair = dict(pair) + if status: + new_pair["status"] = status + new_pair["error"] = error or None + self._proxy_pairs[idx] = new_pair + changed = True + break + if changed: + self._save_proxy_pairs() + + def _load_protocols(self) -> None: + root = self._protocol_packages_root() try: - for module in pkgutil.iter_modules(protocols_pkg.__path__): - importlib.import_module(f"{protocols_pkg.__name__}.{module.name}") - except Exception as exc: # pragma: no cover - optional UI detail - self.log.emit(f"[WARN] Load protocols failed: {exc}") + load = load_protocol_packages(root) + self._protocol_gateway = ProtocolPackageGateway(load.packages) + self._protocol_gateway_issues = [f"{x.package_dir}: {x.message}" for x in load.issues] + if self._protocol_gateway_issues: + self.log.emit(f"[WARN] External protocol issues: {len(self._protocol_gateway_issues)}") + except Exception as exc: + self._protocol_gateway = None + self._protocol_gateway_issues = [str(exc)] + self.log.emit(f"[WARN] Load external protocols failed: {exc}") + + @staticmethod + def _protocol_packages_root() -> Path: + raw = os.environ.get("PROTOFLOW_PROTOCOLS_DIR", "protocols") + p = Path(raw) + if p.is_absolute(): + return p + return (Path.cwd() / p).resolve() @staticmethod def _protocol_category(key: str) -> str: name = (key or "").lower() - if name.startswith("modbus_"): - return "modbus" + if name in {"modbus_rtu", "modbus-rtu"}: + return "modbus-rtu" + if name in {"modbus_ascii", "modbus-ascii"}: + return "modbus-ascii" + if name in {"modbus_tcp", "modbus-tcp"}: + return "modbus-tcp" if "tcp" in name: return "tcp" return "custom" @@ -745,13 +880,7 @@ def _settings_defaults(self) -> Dict[str, Any]: def _load_settings(self) -> Dict[str, Any]: defaults = self._settings_defaults() - if not self._settings_path.exists(): - return defaults - try: - with self._settings_path.open("r", encoding="utf-8") as handle: - data = json.load(handle) or {} - except Exception: - return defaults + data = self._load_json_with_fallback(self._settings_path, default={}) if not isinstance(data, dict): return defaults merged = { @@ -765,75 +894,121 @@ def _load_settings(self) -> Dict[str, Any]: def _save_settings(self, payload: Dict[str, Any]) -> bool: if not isinstance(payload, dict): return False - try: - self._settings_path.parent.mkdir(parents=True, exist_ok=True) - with self._settings_path.open("w", encoding="utf-8") as handle: - json.dump(payload, handle, ensure_ascii=False, indent=2) - except Exception as exc: - self.log.emit(f"[WARN] Save settings failed: {exc}") - return False - return True + return self._save_json_atomic(self._settings_path, payload, "Save settings failed") def _load_proxy_pairs(self) -> List[Dict[str, Any]]: - if not self._proxy_pairs_path.exists(): - return [] - try: - with self._proxy_pairs_path.open("r", encoding="utf-8") as handle: - data = json.load(handle) or [] - except Exception: - return [] + data = self._load_json_with_fallback(self._proxy_pairs_path, default=[]) if not isinstance(data, list): return [] - return [item for item in data if isinstance(item, dict)] + pairs: List[Dict[str, Any]] = [] + for item in data: + if not isinstance(item, dict): + continue + pair = dict(item) + pair["status"] = str(pair.get("status") or "stopped").lower() + if pair["status"] not in {"running", "stopped", "error"}: + pair["status"] = "stopped" + pair["desiredActive"] = bool(pair.get("desiredActive", pair["status"] == "running")) + pair["error"] = pair.get("error") or None + pairs.append(pair) + return pairs def _save_proxy_pairs(self) -> None: - try: - self._proxy_pairs_path.parent.mkdir(parents=True, exist_ok=True) - with self._proxy_pairs_path.open("w", encoding="utf-8") as handle: - json.dump(self._proxy_pairs, handle, ensure_ascii=False, indent=2) - except Exception as exc: - self.log.emit(f"[WARN] Save proxy pairs failed: {exc}") + self._save_json_atomic(self._proxy_pairs_path, self._proxy_pairs, "Save proxy pairs failed") - def _load_custom_protocols(self) -> List[Dict[str, Any]]: - if not self._protocols_path.exists(): - return [] - try: - with self._protocols_path.open("r", encoding="utf-8") as handle: - data = json.load(handle) or [] - except Exception: - return [] - if not isinstance(data, list): - return [] - items = [item for item in data if isinstance(item, dict)] - return items + def _restore_proxy_sessions(self) -> None: + if not self._proxy_monitor_enabled: + return + if not self._proxy_pairs: + return + changed = False + for idx, pair in enumerate(self._proxy_pairs): + pair_id = pair.get("id") + normalized = dict(pair) + previous_status = str(normalized.get("status") or "stopped").lower() + desired_active = bool(normalized.get("desiredActive", previous_status == "running")) + normalized["desiredActive"] = desired_active + normalized["status"] = "stopped" + normalized["error"] = None + if desired_active and self._proxy_manager and pair_id: + result = self._proxy_manager.start_pair(str(pair_id), normalized) + if result.ok: + normalized["status"] = "running" + normalized["error"] = None + else: + normalized["status"] = "error" + normalized["error"] = result.error + if normalized != pair: + self._proxy_pairs[idx] = normalized + changed = True + continue + # Keep consistency for persisted "running" entries on cold start. + if previous_status == "running" and normalized["status"] != "running": + self._proxy_pairs[idx] = normalized + changed = True + if changed: + self._save_proxy_pairs() - def _save_custom_protocols(self) -> None: + def _save_json_atomic(self, path: Path, payload: Any, warn_prefix: str) -> bool: + tmp_path: Optional[Path] = None + with self._save_lock: + try: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent)) + os.close(fd) + tmp_path = Path(tmp_name) + with tmp_path.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + + replaced = False + last_exc: Optional[Exception] = None + # Windows may transiently lock target files; retry replace briefly. + for attempt in range(6): + try: + os.replace(tmp_path, path) + replaced = True + break + except PermissionError as exc: + last_exc = exc + time.sleep(0.03 * (attempt + 1)) + + if not replaced: + if last_exc: + raise last_exc + raise RuntimeError("atomic replace failed") + + backup = path.with_suffix(path.suffix + ".bak") + shutil.copy2(path, backup) + return True + except Exception as exc: + self.log.emit(f"[WARN] {warn_prefix}: {exc}") + if tmp_path is not None and tmp_path.exists(): + try: + tmp_path.unlink() + except Exception: + pass + return False + + def _load_json_with_fallback(self, path: Path, default: Any) -> Any: + if not path.exists(): + return default + try: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + except Exception as exc: + self.log.emit(f"[WARN] Load config failed, try backup: {path.name}: {exc}") + backup = path.with_suffix(path.suffix + ".bak") + if not backup.exists(): + return default try: - self._protocols_path.parent.mkdir(parents=True, exist_ok=True) - with self._protocols_path.open("w", encoding="utf-8") as handle: - json.dump(self._custom_protocols, handle, ensure_ascii=False, indent=2) + with backup.open("r", encoding="utf-8") as handle: + data = json.load(handle) + # Restore from valid backup for next startup consistency. + self._save_json_atomic(path, data, "Restore config from backup failed") + return data except Exception as exc: - self.log.emit(f"[WARN] Save protocols failed: {exc}") - - def _normalize_protocol_key(self, value: str) -> str: - value = value.strip().lower().replace(" ", "_") - value = re.sub(r"[^a-z0-9_]+", "_", value) - value = re.sub(r"_+", "_", value).strip("_") - return value or "custom_protocol" - - def _ensure_protocol_key_unique(self, key: str) -> str: - registry_keys = set(ProtocolRegistry.list().keys()) - existing = {item.get("id") for item in self._custom_protocols if isinstance(item, dict)} - existing.update({item.get("key") for item in self._custom_protocols if isinstance(item, dict)}) - existing.update(registry_keys) - if key not in existing: - return key - index = 2 - candidate = f"{key}_{index}" - while candidate in existing: - index += 1 - candidate = f"{key}_{index}" - return candidate + self.log.emit(f"[WARN] Load backup failed: {backup.name}: {exc}") + return default def _append_buffer(self, item: Dict[str, Any]) -> None: self._buffer.append(item) diff --git a/ui/desktop/web_window.py b/ui/desktop/web_window.py index e31a618..e574b3d 100644 --- a/ui/desktop/web_window.py +++ b/ui/desktop/web_window.py @@ -1,19 +1,20 @@ from __future__ import annotations from pathlib import Path +import ctypes import logging import os import sys try: - from PySide6.QtCore import QPoint, Qt, QUrl + from PySide6.QtCore import QEvent, QPoint, QTimer, Qt, QUrl from PySide6.QtGui import QAction, QGuiApplication, QIcon from PySide6.QtWebChannel import QWebChannel from PySide6.QtWebEngineCore import QWebEnginePage from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu from PySide6.QtWebEngineWidgets import QWebEngineView except ImportError: # pragma: no cover - from PyQt6.QtCore import QPoint, Qt, QUrl # type: ignore + from PyQt6.QtCore import QEvent, QPoint, QTimer, Qt, QUrl # type: ignore from PyQt6.QtGui import QAction, QGuiApplication, QIcon # type: ignore from PyQt6.QtWebChannel import QWebChannel # type: ignore from PyQt6.QtWebEngineCore import QWebEnginePage # type: ignore @@ -23,6 +24,22 @@ from ui.desktop.web_bridge import WebBridge from ui.desktop.win_snap import apply_snap_styles +if sys.platform == "win32": + WM_NCHITTEST = 0x0084 + HTCAPTION = 2 + + class MSG(ctypes.Structure): + _fields_ = [ + ("hwnd", ctypes.c_void_p), + ("message", ctypes.c_uint), + ("wParam", ctypes.c_size_t), + ("lParam", ctypes.c_ssize_t), + ("time", ctypes.c_uint), + ("pt_x", ctypes.c_long), + ("pt_y", ctypes.c_long), + ("lPrivate", ctypes.c_uint), + ] + class LoggingWebPage(QWebEnginePage): def javaScriptConsoleMessage(self, level, message, line_number, source_id): # type: ignore[override] level_map = { @@ -43,42 +60,144 @@ def javaScriptConsoleMessage(self, level, message, line_number, source_id): # t class WebWindow(QMainWindow): """Minimal WebEngine host window for the new web UI.""" - def __init__(self, bus=None, comm=None) -> None: + def __init__(self, bus=None, comm=None, proxy_manager=None, proxy_monitor_enabled: bool = True) -> None: super().__init__() self.setWindowTitle("ProtoFlow Web UI") - self.resize(1200, 800) + self._apply_initial_geometry() self._normal_geometry = self.geometry() - self._titlebar_height = 30 + self._titlebar_height = 36 self._win_style_applied = False + # Stable default: manual snap fallback enabled. + # Set PROTOFLOW_NATIVE_CAPTION=1 to opt into the native caption experiment. + self._native_caption_enabled = sys.platform == "win32" and os.environ.get("PROTOFLOW_NATIVE_CAPTION", "0") == "1" self.setMinimumSize(960, 600) - self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowSystemMenuHint) + self.setWindowFlags( + Qt.Window | Qt.FramelessWindowHint | Qt.WindowSystemMenuHint | Qt.WindowMinMaxButtonsHint + ) self.setAttribute(Qt.WA_TranslucentBackground, False) view = QWebEngineView(self) page = LoggingWebPage(view) view.setPage(page) + self._view = view + self._stabilize_pending = False self.setCentralWidget(view) channel = QWebChannel(view) - self.bridge = WebBridge(bus=bus, comm=comm, window=self) + self.bridge = WebBridge( + bus=bus, + comm=comm, + window=self, + proxy_manager=proxy_manager, + proxy_monitor_enabled=proxy_monitor_enabled, + ) channel.registerObject("bridge", self.bridge) view.page().setWebChannel(channel) - base_dir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1])) - icon_svg = base_dir / "assets" / "icons" / "logo.svg" - icon_png = base_dir / "assets" / "icons" / "logo.png" + resource_root = self._resolve_resource_root() + icon_svg = self._find_existing_path( + resource_root / "assets" / "icons" / "logo.svg", + resource_root / "ui" / "assets" / "icons" / "logo.svg", + ) + icon_png = self._find_existing_path( + resource_root / "assets" / "icons" / "logo.png", + resource_root / "ui" / "assets" / "icons" / "logo.png", + ) icon = QIcon(str(icon_svg)) if icon.isNull(): icon = QIcon(str(icon_png)) if not icon.isNull(): self.setWindowIcon(icon) - dist_index = base_dir / "frontend" / "dist" / "index.html" - fallback_index = base_dir / "assets" / "web" / "index.html" - index_path = dist_index if dist_index.exists() else fallback_index + index_path = self._find_existing_path( + resource_root / "frontend" / "dist" / "index.html", + resource_root / "ui" / "frontend" / "dist" / "index.html", + resource_root / "assets" / "web" / "index.html", + resource_root / "ui" / "assets" / "web" / "index.html", + ) view.load(QUrl.fromLocalFile(str(index_path))) view.page().profile().downloadRequested.connect(self._handle_download) + def _apply_initial_geometry(self) -> None: + screen = QGuiApplication.primaryScreen() + if not screen: + self.resize(1200, 800) + return + rect = screen.availableGeometry() + target_w = max(960, min(int(rect.width() * 0.9), rect.width())) + target_h = max(600, min(int(rect.height() * 0.9), rect.height())) + self.resize(target_w, target_h) + self.move(rect.x() + (rect.width() - target_w) // 2, rect.y() + (rect.height() - target_h) // 2) + + def nativeEvent(self, event_type, message): # type: ignore[override] + if not self._native_caption_enabled: + return super().nativeEvent(event_type, message) + try: + msg = MSG.from_address(int(message)) + except Exception: + return super().nativeEvent(event_type, message) + if msg.message != WM_NCHITTEST: + return super().nativeEvent(event_type, message) + hit = self._hit_test_native_caption(msg.lParam) + if hit is None: + return False, 0 + return True, hit + + def _hit_test_native_caption(self, l_param: int): + # lParam packs signed x/y screen coordinates. + x = ctypes.c_short(l_param & 0xFFFF).value + y = ctypes.c_short((l_param >> 16) & 0xFFFF).value + local = self.mapFromGlobal(QPoint(x, y)) + lx = local.x() + ly = local.y() + width = self.width() + height = self.height() + if lx < 0 or ly < 0 or lx >= width or ly >= height: + return None + if not (0 <= ly < self._titlebar_height): + return None + # Gray rollout: only expose a narrow center band as native caption. + band = max(220, int(width * 0.28)) + start = (width - band) // 2 + end = start + band + if start <= lx < end: + return HTCAPTION + return None + + @staticmethod + def _find_existing_path(*candidates: Path) -> Path: + for path in candidates: + if path.exists(): + return path + return candidates[0] + + def _resolve_resource_root(self) -> Path: + source_ui_dir = Path(__file__).resolve().parents[1] + if not getattr(sys, "frozen", False): + return source_ui_dir + + candidates = [] + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + candidates.append(Path(meipass)) + candidates.append(Path(meipass) / "ui") + + exe_dir = Path(sys.executable).resolve().parent + candidates.append(exe_dir) + candidates.append(exe_dir / "_internal") + candidates.append(exe_dir / "ui") + + for root in candidates: + if (root / "frontend" / "dist" / "index.html").exists(): + return root + if (root / "ui" / "frontend" / "dist" / "index.html").exists(): + return root + if (root / "assets" / "web" / "index.html").exists(): + return root + if (root / "ui" / "assets" / "web" / "index.html").exists(): + return root + return candidates[0] if candidates else source_ui_dir + def _handle_download(self, item) -> None: suggested = item.downloadFileName() path, _ = QFileDialog.getSaveFileName( @@ -101,7 +220,7 @@ def _handle_download(self, item) -> None: item.accept() def _apply_snap(self, screen_x: int, screen_y: int) -> bool: - if sys.platform == "win32": + if self._native_caption_enabled: return False screen = QGuiApplication.screenAt(QPoint(screen_x, screen_y)) if not screen: @@ -119,6 +238,7 @@ def _apply_snap(self, screen_x: int, screen_y: int) -> bool: if at_top and not (at_left or at_right): self._remember_normal_geometry() self.showMaximized() + self._stabilize_webview_after_resize() return True if at_top and at_left: self.setGeometry(rect.x(), rect.y(), rect.width() // 2, rect.height() // 2) @@ -136,6 +256,7 @@ def _apply_snap(self, screen_x: int, screen_y: int) -> bool: else: self._remember_normal_geometry() self.setGeometry(rect) + self._stabilize_webview_after_resize() return True def _start_move(self, screen_x: int, screen_y: int) -> None: @@ -148,6 +269,7 @@ def _start_move(self, screen_x: int, screen_y: int) -> None: self.showNormal() self.setWindowState(self.windowState() & ~Qt.WindowMaximized) self.setGeometry(self.x(), self.y(), normal.width(), normal.height()) + self._stabilize_webview_after_resize() if handle and hasattr(handle, "startSystemMove"): handle.startSystemMove() @@ -225,6 +347,31 @@ def _toggle_max_restore(self) -> None: else: self._remember_normal_geometry() self.showMaximized() + self._stabilize_webview_after_resize() + + def _stabilize_webview_after_resize(self) -> None: + if self._stabilize_pending: + return + self._stabilize_pending = True + + def _refresh(): + self._stabilize_pending = False + view = getattr(self, "_view", None) + if view is None: + return + view.setUpdatesEnabled(False) + view.resize(self.size()) + view.setUpdatesEnabled(True) + view.update() + view.repaint() + + QTimer.singleShot(0, _refresh) + QTimer.singleShot(80, _refresh) + + def changeEvent(self, event): # type: ignore[override] + super().changeEvent(event) + if event.type() == QEvent.WindowStateChange: + self._stabilize_webview_after_resize() def _mouse_pos(self, event) -> QPoint: pos = event.globalPosition().toPoint() @@ -291,8 +438,11 @@ def _is_titlebar_area(self, screen_x: int, screen_y: int) -> bool: return screen_y <= frame_y + self._titlebar_height def _apply_custom_titlebar(self) -> None: - base_dir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1])) - style_path = base_dir / "assets" / "styles" / "window.css" + resource_root = self._resolve_resource_root() + style_path = self._find_existing_path( + resource_root / "assets" / "styles" / "window.css", + resource_root / "ui" / "assets" / "styles" / "window.css", + ) if not style_path.exists(): return self.setStyleSheet(style_path.read_text(encoding="utf-8")) @@ -300,8 +450,11 @@ def _apply_custom_titlebar(self) -> None: self._win_style_applied = True def _init_system_titlebar(self): - base_dir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1])) - html_path = base_dir / "assets" / "titlebar" / "titlebar.html" + resource_root = self._resolve_resource_root() + html_path = self._find_existing_path( + resource_root / "assets" / "titlebar" / "titlebar.html", + resource_root / "ui" / "assets" / "titlebar" / "titlebar.html", + ) if not html_path.exists(): return html = html_path.read_text(encoding="utf-8") diff --git a/ui/desktop/win_snap.py b/ui/desktop/win_snap.py index c0368ac..ec2b27d 100644 --- a/ui/desktop/win_snap.py +++ b/ui/desktop/win_snap.py @@ -22,7 +22,14 @@ def apply_snap_styles(hwnd: int) -> None: """Enable Windows snap/resize for frameless windows.""" if not hwnd: return + if hasattr(hwnd, "winId"): + try: + hwnd = int(hwnd.winId()) + except Exception: + return style = user32.GetWindowLongW(hwnd, GWL_STYLE) + if style == 0: + return style |= WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_SYSMENU user32.SetWindowLongW(hwnd, GWL_STYLE, style) user32.SetWindowPos( diff --git a/ui/frontend/config/perf_budget.json b/ui/frontend/config/perf_budget.json new file mode 100644 index 0000000..7fa802a --- /dev/null +++ b/ui/frontend/config/perf_budget.json @@ -0,0 +1,6 @@ +{ + "js_bundle_max_kb": 820, + "css_bundle_max_kb": 130, + "js_gzip_max_kb": 270, + "css_gzip_max_kb": 25 +} diff --git a/ui/frontend/package-lock.json b/ui/frontend/package-lock.json index dc481b8..ee7c7d8 100644 --- a/ui/frontend/package-lock.json +++ b/ui/frontend/package-lock.json @@ -22,11 +22,20 @@ "devDependencies": { "@vitejs/plugin-vue": "^6.0.1", "autoprefixer": "^10.4.23", + "jsdom": "^28.1.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.0.18" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -40,6 +49,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -82,6 +129,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", @@ -178,6 +238,138 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@ctrl/tinycolor": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", @@ -612,6 +804,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -1056,6 +1266,31 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1099,6 +1334,127 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.26", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", @@ -1232,6 +1588,16 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1279,6 +1645,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", @@ -1332,6 +1708,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1423,6 +1809,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1492,6 +1888,20 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1505,17 +1915,72 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz", + "integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1573,6 +2038,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1629,6 +2101,16 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1763,6 +2245,47 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1825,6 +2348,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -1847,6 +2377,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1890,6 +2461,16 @@ "lodash-es": "*" } }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1898,6 +2479,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", @@ -1941,6 +2529,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -2013,6 +2608,43 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2020,6 +2652,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2234,6 +2873,16 @@ "dev": true, "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2291,6 +2940,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2388,6 +3047,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2396,6 +3075,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", @@ -2438,6 +3131,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -2499,6 +3199,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2515,6 +3232,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2528,6 +3275,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -2535,6 +3308,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2647,6 +3430,84 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vue": { "version": "3.5.26", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", @@ -2699,6 +3560,88 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", diff --git a/ui/frontend/package.json b/ui/frontend/package.json index 64c8ecf..a69add9 100644 --- a/ui/frontend/package.json +++ b/ui/frontend/package.json @@ -6,7 +6,12 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "acceptance:interaction": "node ./scripts/interaction_acceptance.mjs", + "perf:report": "node ./scripts/perf_report.mjs", + "ci:frontend": "npm run test && npm run acceptance:interaction && npm run build && npm run perf:report" }, "dependencies": { "@codemirror/lang-yaml": "^6.1.2", @@ -15,16 +20,18 @@ "@element-plus/icons-vue": "^2.3.2", "codemirror": "^6.0.2", "element-plus": "^2.13.0", - "vue": "^3.5.24", - "pinia": "^2.2.2", "js-yaml": "^4.1.0", + "pinia": "^2.2.2", + "vue": "^3.5.24", "zod": "^3.23.8" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.1", "autoprefixer": "^10.4.23", + "jsdom": "^28.1.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.0.18" } } diff --git a/ui/frontend/scripts/interaction_acceptance.mjs b/ui/frontend/scripts/interaction_acceptance.mjs new file mode 100644 index 0000000..a158d2d --- /dev/null +++ b/ui/frontend/scripts/interaction_acceptance.mjs @@ -0,0 +1,111 @@ +import fs from 'node:fs' +import path from 'node:path' + +const root = process.cwd() + +function read(file) { + const full = path.join(root, file) + if (!fs.existsSync(full)) { + throw new Error(`Missing file: ${file}`) + } + return fs.readFileSync(full, 'utf8') +} + +function expectIncludes(content, needle, label, failures) { + if (!content.includes(needle)) { + failures.push(`${label}: missing "${needle}"`) + } +} + +function listVueFiles(dir, output = []) { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.name === 'ui-kit') continue + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + listVueFiles(full, output) + continue + } + if (entry.isFile() && entry.name.endsWith('.vue')) { + output.push(full) + } + } + return output +} + +function auditButtonsWithoutInteraction(failures) { + const componentsRoot = path.join(root, 'src', 'components') + const files = listVueFiles(componentsRoot) + const ignoredNames = new Set(['HelloWorld.vue']) + const buttonPattern = //g + for (const file of files) { + if (ignoredNames.has(path.basename(file))) continue + const content = fs.readFileSync(file, 'utf8') + const buttons = content.match(buttonPattern) || [] + for (const tag of buttons) { + const hasClick = /@click|v-on:click/.test(tag) + const isDisabled = /\bdisabled\b|:disabled=/.test(tag) + const isSubmit = /type="submit"|type='submit'/.test(tag) + if (hasClick || isDisabled || isSubmit) continue + const rel = path.relative(root, file).replace(/\\/g, '/') + failures.push(`Button interaction audit: ${rel} has clickable-looking button without @click/disabled`) + } + } +} + +function run() { + const failures = [] + const proxyMonitorEnabled = process.env.PROTOFLOW_ENABLE_PROXY_MONITOR === '1' + + if (proxyMonitorEnabled) { + const toolbar = read('src/components/proxy/ProxyCaptureToolbar.vue') + expectIncludes( + toolbar, + "defineEmits(['update:search-keyword', 'resume-capture', 'open-settings'])", + 'ProxyCaptureToolbar emits', + failures + ) + + const footer = read('src/components/proxy/ProxyCaptureFooter.vue') + expectIncludes( + footer, + "defineEmits(['page-first', 'page-prev', 'page-next', 'page-last', 'export'])", + 'ProxyCaptureFooter emits', + failures + ) + + const details = read('src/components/proxy/ProxyCaptureDetails.vue') + expectIncludes(details, "defineEmits(['close', 'copy-hex', 'open-rule'])", 'ProxyCaptureDetails emits', failures) + + const monitor = read('src/components/ProxyMonitorView.vue') + expectIncludes(monitor, '@update:search-keyword="captureSearchKeyword = $event"', 'ProxyMonitorView toolbar binding', failures) + expectIncludes(monitor, '@resume-capture="resumeCapture"', 'ProxyMonitorView resume binding', failures) + expectIncludes(monitor, '@open-settings="openCaptureRule"', 'ProxyMonitorView settings binding', failures) + expectIncludes(monitor, '@page-first="scrollCaptureToPage(1)"', 'ProxyMonitorView footer first page binding', failures) + expectIncludes( + monitor, + '@page-last="scrollCaptureToPage(captureMetaView.pageCount)"', + 'ProxyMonitorView footer last page binding', + failures + ) + expectIncludes(monitor, '@copy-hex="copyActiveHex"', 'ProxyMonitorView details copy binding', failures) + expectIncludes(monitor, '@open-rule="openCaptureRule"', 'ProxyMonitorView details rule binding', failures) + } + + const matrix = read('../../docs/FRONTEND_INTERACTION_TEST_MATRIX.md') + if (proxyMonitorEnabled) { + expectIncludes(matrix, 'Search keyword filters frame rows by id/protocol/summary/raw hex', 'Interaction matrix coverage', failures) + expectIncludes(matrix, 'Capture footer pagination buttons (`first/prev/next/last`) are actionable', 'Interaction matrix pagination coverage', failures) + } + auditButtonsWithoutInteraction(failures) + + if (failures.length) { + console.error('Frontend interaction acceptance failed:') + failures.forEach((item) => console.error(`- ${item}`)) + process.exit(1) + } + + console.log('Frontend interaction acceptance passed.') +} + +run() diff --git a/ui/frontend/scripts/perf_report.mjs b/ui/frontend/scripts/perf_report.mjs new file mode 100644 index 0000000..00c3209 --- /dev/null +++ b/ui/frontend/scripts/perf_report.mjs @@ -0,0 +1,78 @@ +import fs from 'node:fs' +import path from 'node:path' +import { gzipSync } from 'node:zlib' + +const root = process.cwd() +const distDir = path.join(root, 'dist', 'assets') +const budgetPath = path.join(root, 'config', 'perf_budget.json') + +function kb(bytes) { + return bytes / 1024 +} + +function listAssets() { + if (!fs.existsSync(distDir)) { + throw new Error('dist/assets not found. Run npm run build first.') + } + const files = fs.readdirSync(distDir) + return files +} + +function sumByExt(files, ext) { + const selected = files.filter((name) => name.endsWith(ext)) + let raw = 0 + let gz = 0 + for (const name of selected) { + const full = path.join(distDir, name) + const buf = fs.readFileSync(full) + raw += buf.length + gz += gzipSync(buf).length + } + return { files: selected, raw, gz } +} + +function loadBudget() { + if (!fs.existsSync(budgetPath)) { + throw new Error('Budget file not found: config/perf_budget.json') + } + return JSON.parse(fs.readFileSync(budgetPath, 'utf8')) +} + +function printLine(label, value, max) { + const status = value <= max ? 'OK' : 'FAIL' + console.log(`${status.padEnd(4)} ${label.padEnd(22)} ${value.toFixed(2)} KB / budget ${max.toFixed(2)} KB`) + return status === 'OK' +} + +try { + const files = listAssets() + const budget = loadBudget() + const js = sumByExt(files, '.js') + const css = sumByExt(files, '.css') + + const jsRawKb = kb(js.raw) + const cssRawKb = kb(css.raw) + const jsGzipKb = kb(js.gz) + const cssGzipKb = kb(css.gz) + + console.log('Frontend Performance Budget Report') + console.log(`Assets directory: ${distDir}`) + console.log(`JS files: ${js.files.length}, CSS files: ${css.files.length}`) + + const ok = [ + printLine('JS raw bundle', jsRawKb, budget.js_bundle_max_kb), + printLine('CSS raw bundle', cssRawKb, budget.css_bundle_max_kb), + printLine('JS gzip bundle', jsGzipKb, budget.js_gzip_max_kb), + printLine('CSS gzip bundle', cssGzipKb, budget.css_gzip_max_kb), + ].every(Boolean) + + if (!ok) { + console.error('\nPerformance budget exceeded.') + process.exit(1) + } + + console.log('\nAll bundle budgets satisfied.') +} catch (error) { + console.error(String(error instanceof Error ? error.message : error)) + process.exit(1) +} diff --git a/ui/frontend/src/App.vue b/ui/frontend/src/App.vue index 23b45c6..984c561 100644 --- a/ui/frontend/src/App.vue +++ b/ui/frontend/src/App.vue @@ -1,26 +1,52 @@ - @@ -4619,10 +1057,8 @@ function unlockSidebarWidth() {
- - - -
-
- - - - -
-
-
-
-
-
{{ card.name }}
-
{{ card.desc || tr('暂无描述') }}
-
- {{ card.statusText }} -
-
-
- {{ row.label }} - {{ row.value }} -
-
-
- - -
-
-
-
- inventory_2 -
-

{{ tr('暂无协议') }}

-

{{ tr('暂无可用协议,可从内置模板创建或新增自定义协议。') }}

- -
-
+ +
- -
- - - - -
-
-
-
- tune{{ t('settings.tab.general') }} -
-
- - -
-
-
- {{ t('settings.autoConnect.title') }} -

{{ t('settings.autoConnect.desc') }}

-
- -
-
-
-
- extension{{ t('settings.tab.plugins') }} -
- -
-
- {{ t('settings.plugins.title') }} - -
-
-
-
-
Modbus TCP/RTU
-
{{ tr('v1.2.4 - 已启用') }}
-
- {{ tr('已启用') }} -
-
-
-
{{ tr('MQTT 适配器') }}
-
{{ tr('v0.9.8 - 未安装') }}
-
- {{ tr('未安装') }} -
-
-
-
- {{ t('settings.autoConnect.title') }} -

{{ t('settings.autoConnect.desc') }}

-
- -
-
- -
-
- tune{{ t('settings.tab.runtime') }} -
-
- {{ tr('暂无可配置项,运行时设置将随着模块扩展开放。') }} -
-
- -
-
- folder_open{{ t('settings.tab.logs') }} -
-
- {{ tr('日志采集与归档策略将在后续版本中提供。') }} -
-
-
+ +
- + - + - + - + -
diff --git a/ui/frontend/src/components/ChannelDialogModal.test.ts b/ui/frontend/src/components/ChannelDialogModal.test.ts new file mode 100644 index 0000000..a62a796 --- /dev/null +++ b/ui/frontend/src/components/ChannelDialogModal.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createApp, defineComponent, h, nextTick } from 'vue' +import ChannelDialogModal from './ChannelDialogModal.vue' + +function mountChannelDialogModal() { + const host = document.createElement('div') + document.body.appendChild(host) + const events: string[] = [] + + const Root = defineComponent({ + components: { ChannelDialogModal }, + render() { + return h(ChannelDialogModal, { + open: true, + mode: 'create', + channelType: 'serial', + channelName: 'CH-A', + channelPort: 'COM3', + channelBaud: 115200, + channelDataBits: '8', + channelParity: 'none', + channelStopBits: '1', + channelFlowControl: 'none', + channelReadTimeout: 1000, + channelWriteTimeout: 1000, + channelHost: '127.0.0.1', + channelTcpPort: 502, + channelAutoConnect: false, + hasPorts: true, + portOptionsList: ['COM1', 'COM2', 'COM3'], + supportedBaudRates: ['9600', '115200'], + onClose: () => events.push('close'), + onSubmit: () => events.push('submit'), + 'onUpdate:channelName': (value: string) => events.push(`name:${value}`), + 'onUpdate:channelAutoConnect': (value: boolean) => events.push(`auto:${value}`), + }) + }, + }) + + const app = createApp(Root) + app.provide('tr', (text: string) => text) + app.mount(host) + + return { + host, + events, + unmount: () => { + app.unmount() + host.remove() + }, + } +} + +async function tick() { + await nextTick() + await Promise.resolve() +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('ChannelDialogModal interactions', () => { + it('emits close/submit and field updates', async () => { + const vm = mountChannelDialogModal() + + const textInput = vm.host.querySelector('input[type="text"]') as HTMLInputElement + textInput.value = 'CH-B' + textInput.dispatchEvent(new Event('input', { bubbles: true })) + + const checkbox = vm.host.querySelector('input[type="checkbox"]') as HTMLInputElement + checkbox.checked = true + checkbox.dispatchEvent(new Event('change', { bubbles: true })) + + const footerButtons = vm.host.querySelectorAll('.modal-footer button') + ;(footerButtons[0] as HTMLButtonElement)?.click() + ;(footerButtons[1] as HTMLButtonElement)?.click() + await tick() + + expect(vm.events).toContain('name:CH-B') + expect(vm.events).toContain('auto:true') + expect(vm.events).toContain('close') + expect(vm.events).toContain('submit') + vm.unmount() + }) +}) diff --git a/ui/frontend/src/components/ChannelDialogModal.vue b/ui/frontend/src/components/ChannelDialogModal.vue new file mode 100644 index 0000000..072902f --- /dev/null +++ b/ui/frontend/src/components/ChannelDialogModal.vue @@ -0,0 +1,179 @@ + + + diff --git a/ui/frontend/src/components/DropdownSelect.test.ts b/ui/frontend/src/components/DropdownSelect.test.ts new file mode 100644 index 0000000..8bcc73d --- /dev/null +++ b/ui/frontend/src/components/DropdownSelect.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createApp, defineComponent, h, nextTick } from 'vue' +import DropdownSelect from './DropdownSelect.vue' + +function mountDropdown(props: Record = {}) { + const host = document.createElement('div') + document.body.appendChild(host) + + const Root = defineComponent({ + components: { DropdownSelect }, + data() { + return { + value: 'COM3', + } + }, + render() { + return h(DropdownSelect, { + modelValue: this.value, + 'onUpdate:modelValue': (next: string) => { + this.value = next + }, + options: ['COM1', 'COM2', 'COM3'], + ...props, + }) + }, + }) + + const app = createApp(Root) + app.mount(host) + + return { + host, + app, + getTrigger: () => host.querySelector('.select-trigger') as HTMLButtonElement | null, + getRoot: () => host.querySelector('.select-wrap') as HTMLDivElement | null, + getMenu: () => document.body.querySelector('.select-menu') as HTMLDivElement | null, + unmount: () => { + app.unmount() + host.remove() + }, + } +} + +async function tick() { + await nextTick() + await Promise.resolve() +} + +afterEach(() => { + document.body.innerHTML = '' + document.body.classList.remove('dropdown-open') +}) + +describe('DropdownSelect interactions', () => { + it('opens menu and marks aria/data state when clicked', async () => { + const vm = mountDropdown() + vm.getTrigger()?.click() + await tick() + + expect(vm.getMenu()).not.toBeNull() + expect(vm.getTrigger()?.getAttribute('aria-expanded')).toBe('true') + expect(vm.getRoot()?.dataset.open).toBe('true') + + vm.unmount() + }) + + it('closes menu on Escape', async () => { + const vm = mountDropdown() + vm.getTrigger()?.click() + await tick() + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await tick() + + expect(vm.getMenu()).toBeNull() + expect(vm.getTrigger()?.getAttribute('aria-expanded')).toBe('false') + + vm.unmount() + }) + + it('closes when clicking outside', async () => { + const vm = mountDropdown() + vm.getTrigger()?.click() + await tick() + document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })) + await tick() + + expect(vm.getMenu()).toBeNull() + expect(vm.getRoot()?.dataset.open).toBe('false') + + vm.unmount() + }) + + it('stays closed when disabled and exposes disabled reason', async () => { + const vm = mountDropdown({ disabled: true, disabledReason: 'loading ports' }) + vm.getTrigger()?.click() + await tick() + + expect(vm.getMenu()).toBeNull() + expect(vm.getTrigger()?.getAttribute('aria-disabled')).toBe('true') + expect(vm.getTrigger()?.getAttribute('title')).toBe('loading ports') + + vm.unmount() + }) +}) diff --git a/ui/frontend/src/components/DropdownSelect.vue b/ui/frontend/src/components/DropdownSelect.vue index c18e23b..28c74d5 100644 --- a/ui/frontend/src/components/DropdownSelect.vue +++ b/ui/frontend/src/components/DropdownSelect.vue @@ -33,6 +33,10 @@ const props = defineProps({ type: Boolean, default: false, }, + disabledReason: { + type: String, + default: '', + }, }) const emit = defineEmits(['update:modelValue', 'change']) @@ -171,8 +175,16 @@ onBeforeUnmount(() => {