diff --git a/README.md b/README.md index 4e22e03..23ef677 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ 

- ProtoFlow logo + ProtoFlow logo

diff --git a/README_EN.md b/README_EN.md index b9d9055..5689a90 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,5 +1,5 @@ 

- ProtoFlow logo + ProtoFlow logo

diff --git a/actions/__init__.py b/actions/__init__.py deleted file mode 100644 index 2b8d462..0000000 --- a/actions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# actions 包占位,便于导入 diff --git a/actions/at_command.py b/actions/at_command.py deleted file mode 100644 index c255131..0000000 --- a/actions/at_command.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict - -from protocols.registry import ProtocolRegistry - - -def run(task: Dict[str, Any], channels: Dict[str, Any], logger) -> Any: - channel_name = task.get("channel") - if not channel_name or channel_name not in channels: - raise KeyError(f"channel not found: {channel_name}") - - cmd = task.get("cmd") or task.get("command") - if not cmd: - raise ValueError("at_command.cmd is required") - - timeout = float(task.get("timeout", 2.0)) - terminator = task.get("terminator", "\r\n") - ok = str(task.get("ok", "OK")) - error = str(task.get("error", "ERROR")) - echo = bool(task.get("echo", True)) - - protocol_cls = ProtocolRegistry.get("at") - protocol = protocol_cls(channels[channel_name], logger) - return protocol.execute(cmd=str(cmd), timeout=timeout, terminator=terminator, ok=ok, error=error, echo=echo) diff --git a/actions/builtin_actions.py b/actions/builtin_actions.py deleted file mode 100644 index f1312e1..0000000 --- a/actions/builtin_actions.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import time -from typing import Any, Dict - -from actions.registry import ActionRegistry -from dsl.expression import eval_expr - - -def _eval(ctx, val): - if isinstance(val, str) and "$" in val: - return eval_expr(val, ctx.vars_snapshot()) - return val - - -def action_set(ctx, args: Dict[str, Any]): - for key, val in args.items(): - ctx.set_var(key, _eval(ctx, val)) - return ctx.vars_snapshot() - - -def action_log(ctx, args: Dict[str, Any]): - msg = args.get("message") or args.get("msg") or "" - if isinstance(msg, str) and "$" in msg: - msg = eval_expr(msg, ctx.vars_snapshot()) - ctx.logger.info(str(msg)) - - -def action_wait(ctx, args: Dict[str, Any]): - ms = int(args.get("ms", 0)) - time.sleep(ms / 1000.0) - - -def action_wait_for_event(ctx, args: Dict[str, Any]): - expected = args.get("event") - timeout = float(args.get("timeout", 1.0)) - end = time.time() + timeout - while time.time() < end: - evt = ctx.next_event(timeout=0.1) - if evt is None: - continue - if expected is None or evt == expected: - ctx.set_var("event", evt) - return evt - return None - - -def register_builtin_actions(): - ActionRegistry.register("set", action_set) - ActionRegistry.register("log", action_log) - ActionRegistry.register("send_text", action_send_text) - ActionRegistry.register("read_line", action_read_line) - ActionRegistry.register("read_stream", action_read_stream) - ActionRegistry.register("wait", action_wait) - ActionRegistry.register("wait_for_event", action_wait_for_event) - - -def action_send_text(ctx, args: Dict[str, Any]): - text = args.get("text", args.get("data", "")) - if isinstance(text, (bytes, bytearray)): - payload = bytes(text) - else: - payload = str(text) - append_cr = bool(args.get("append_cr", False)) - append_lf = bool(args.get("append_lf", False)) - if append_cr: - payload = payload + (b"\r" if isinstance(payload, (bytes, bytearray)) else "\r") - if append_lf: - payload = payload + (b"\n" if isinstance(payload, (bytes, bytearray)) else "\n") - ctx.channel_write(payload) - return {"text": text, "append_cr": append_cr, "append_lf": append_lf} - - -def action_read_line(ctx, args: Dict[str, Any]): - terminator = args.get("terminator", "\n") - timeout = float(args.get("timeout", 1.0)) - raw = ctx.channel.read_until( - terminator.encode() if isinstance(terminator, str) else bytes(terminator), - timeout=timeout, - ) - text = raw.decode(errors="ignore").strip() - ctx.set_var("last_line_rx", text) - ctx.set_var("last_line_rx_raw", raw.hex().upper()) - return {"text": text, "hex": raw.hex().upper()} - - -def action_read_stream(ctx, args: Dict[str, Any]): - duration_ms = int(args.get("duration_ms", args.get("duration", 1000))) - chunk_size = int(args.get("chunk_size", 256)) - timeout = float(args.get("timeout", 0.2)) - log_hex = bool(args.get("log_hex", True)) - end = time.time() + max(0.0, duration_ms / 1000.0) - last_text = "" - last_hex = "" - while time.time() < end: - chunk = ctx.channel.read(chunk_size, timeout=timeout) - if not chunk: - continue - last_hex = chunk.hex().upper() - try: - last_text = chunk.decode(errors="ignore") - except Exception: - last_text = "" - if log_hex: - ctx.logger.info(f"RX(hex): {last_hex}") - if last_text.strip(): - ctx.logger.info(f"RX(text): {last_text.strip()}") - ctx.set_var("last_stream_rx", last_text.strip()) - ctx.set_var("last_stream_rx_raw", last_hex) - return {"text": last_text.strip(), "hex": last_hex} diff --git a/actions/chart_actions.py b/actions/chart_actions.py deleted file mode 100644 index 693c289..0000000 --- a/actions/chart_actions.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import time -from typing import Any, Dict - -from actions.chart_bridge import chart_bridge -from actions.registry import ActionRegistry - - -def _eval(ctx, val: Any) -> Any: - if hasattr(ctx, "eval_value"): - return ctx.eval_value(val) - return val - - -def action_chart_add(ctx, args: Dict[str, Any]): - """Push a data point to chart system. Args: bind (str), value (num or expr), ts (seconds, optional).""" - bind = args.get("bind") - if not bind: - raise ValueError("chart_add requires bind") - raw_val = args.get("value", args.get("val")) - if raw_val is None: - raise ValueError("chart_add requires value") - ts_arg = args.get("ts") or args.get("timestamp") - ts = float(_eval(ctx, ts_arg)) if ts_arg is not None else time.time() - try: - val = float(_eval(ctx, raw_val)) - except Exception as exc: - raise ValueError(f"chart_add value invalid: {exc}") from exc - payload = {"ts": ts, str(bind): val} - if hasattr(ctx, "record_chart"): - try: - ctx.record_chart(payload) - except Exception: - pass - if chart_bridge is None: - ctx.logger.warning("chart bridge unavailable (Qt not loaded)") - return payload - chart_bridge.sig_data.emit(payload) - return {"ts": ts, "bind": str(bind), "value": val} - - -def action_chart_add3d(ctx, args: Dict[str, Any]): - """Push a 3D point. Args: x,y,z (expr), ts(optional), bind_x/y/z(optional keys, default x/y/z).""" - bx = str(args.get("bind_x", "x")) - by = str(args.get("bind_y", "y")) - bz = str(args.get("bind_z", "z")) - if args.get("x") is None or args.get("y") is None or args.get("z") is None: - raise ValueError("chart_add3d requires x, y, z") - x_val = _eval(ctx, args.get("x")) - y_val = _eval(ctx, args.get("y")) - z_val = _eval(ctx, args.get("z")) - ts_arg = args.get("ts") or args.get("timestamp") - ts = float(_eval(ctx, ts_arg)) if ts_arg is not None else time.time() - payload = {"ts": ts, bx: x_val, by: y_val, bz: z_val} - if hasattr(ctx, "record_chart"): - try: - ctx.record_chart(payload) - except Exception: - pass - if chart_bridge is None: - ctx.logger.warning("chart bridge unavailable (Qt not loaded)") - return payload - chart_bridge.sig_data.emit(payload) - return payload - - -def register_chart_actions() -> None: - ActionRegistry.register("chart_add", action_chart_add) - ActionRegistry.register("chart_add3d", action_chart_add3d) diff --git a/actions/data_actions.py b/actions/data_actions.py deleted file mode 100644 index 321f1d6..0000000 --- a/actions/data_actions.py +++ /dev/null @@ -1,149 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, Iterable, List, Tuple - -from actions.registry import ActionRegistry -from dsl.expression import eval_expr - - -def _vars_snapshot(ctx) -> Dict[str, Any]: - if hasattr(ctx, "vars_snapshot"): - return ctx.vars_snapshot() - if hasattr(ctx, "vars"): - return dict(ctx.vars) - return {} - - -def _parse_inline_actions(items: Any) -> List[Tuple[str, Dict[str, Any]]]: - if items is None: - return [] - if not isinstance(items, list): - raise ValueError("inline actions must be a list") - parsed: List[Tuple[str, Dict[str, Any]]] = [] - for item in items: - if not isinstance(item, dict): - raise ValueError(f"invalid inline action: {item}") - if "action" in item: - name = item["action"] - args = item.get("args", {}) or {} - if not isinstance(args, dict): - raise ValueError(f"action.args must be a mapping: {item}") - parsed.append((str(name), args)) - continue - if "set" in item: - args = item.get("set") or {} - if not isinstance(args, dict): - raise ValueError(f"set must be a mapping: {item}") - parsed.append(("set", args)) - continue - if "log" in item: - parsed.append(("log", {"message": item.get("log")})) - continue - if "wait" in item: - wait_cfg = item.get("wait") - args = wait_cfg if isinstance(wait_cfg, dict) else {"ms": wait_cfg} - parsed.append(("wait", args)) - continue - if "wait_for_event" in item: - wfe = item.get("wait_for_event") - args = wfe if isinstance(wfe, dict) else {"event": wfe} - parsed.append(("wait_for_event", args)) - continue - if "if" in item: - if_cfg = item.get("if") or {} - if not isinstance(if_cfg, dict): - raise ValueError(f"if must be a mapping: {item}") - parsed.append(("if", if_cfg)) - continue - raise ValueError(f"unknown inline action type: {item}") - return parsed - - -def action_if(ctx, args: Dict[str, Any]) -> Dict[str, Any]: - cond_expr = args.get("when") or args.get("cond") - if not cond_expr: - raise ValueError("if requires 'when'") - cond = bool(eval_expr(str(cond_expr), _vars_snapshot(ctx))) - then_items = args.get("then") or args.get("do") or [] - else_items = args.get("else") or args.get("otherwise") or [] - items = then_items if cond else else_items - actions = _parse_inline_actions(items) - for name, a in actions: - ctx.run_action(name, a) - return {"when": str(cond_expr), "taken": "then" if cond else "else", "count": len(actions)} - - -def _iterable_or_error(value: Any, *, name: str) -> Iterable[Any]: - if isinstance(value, (list, tuple)): - return value - raise ValueError(f"{name} must be a list/tuple") - - -def _item_env(item: Any, index: int) -> Dict[str, Any]: - env: Dict[str, Any] = {"item": item, "index": index} - if isinstance(item, dict): - for k, v in item.items(): - if isinstance(k, str) and k.isidentifier(): - env[f"item.{k}"] = v - return env - - -def action_list_filter(ctx, args: Dict[str, Any]) -> List[Any]: - src = args.get("src") or args.get("items") or args.get("in") - if src is None: - raise ValueError("list_filter requires 'src'") - src_val = ctx.eval_value(src) if hasattr(ctx, "eval_value") else src - if isinstance(src, str) and "$" not in src and hasattr(ctx, "vars") and src in ctx.vars: - src_val = ctx.vars[src] - where = args.get("where") or args.get("when") - if not where: - raise ValueError("list_filter requires 'where'") - limit = args.get("limit") - out: List[Any] = [] - base = _vars_snapshot(ctx) - for idx, item in enumerate(_iterable_or_error(src_val, name="src")): - env = dict(base) - env.update(_item_env(item, idx)) - if bool(eval_expr(str(where), env)): - out.append(item) - if limit is not None and len(out) >= int(limit): - break - dst = args.get("dst") or args.get("out") - if dst and hasattr(ctx, "set_var"): - ctx.set_var(str(dst), out) - return out - - -def action_list_map(ctx, args: Dict[str, Any]) -> List[Any]: - src = args.get("src") or args.get("items") or args.get("in") - if src is None: - raise ValueError("list_map requires 'src'") - src_val = ctx.eval_value(src) if hasattr(ctx, "eval_value") else src - if isinstance(src, str) and "$" not in src and hasattr(ctx, "vars") and src in ctx.vars: - src_val = ctx.vars[src] - expr = args.get("expr") or args.get("map") or args.get("value") - if expr is None: - raise ValueError("list_map requires 'expr'") - where = args.get("where") or args.get("when") - limit = args.get("limit") - out: List[Any] = [] - base = _vars_snapshot(ctx) - for idx, item in enumerate(_iterable_or_error(src_val, name="src")): - env = dict(base) - env.update(_item_env(item, idx)) - if where and not bool(eval_expr(str(where), env)): - continue - out.append(eval_expr(str(expr), env)) - if limit is not None and len(out) >= int(limit): - break - dst = args.get("dst") or args.get("out") - if dst and hasattr(ctx, "set_var"): - ctx.set_var(str(dst), out) - return out - - -def register_data_actions() -> None: - ActionRegistry.register("if", action_if) - ActionRegistry.register("list_filter", action_list_filter) - ActionRegistry.register("list_map", action_list_map) - diff --git a/actions/modbus_request.py b/actions/modbus_request.py deleted file mode 100644 index 5b6e943..0000000 --- a/actions/modbus_request.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict - -from protocols.registry import ProtocolRegistry - -_MAP = { - "rtu": "modbus_rtu", - "ascii": "modbus_ascii", - "tcp": "modbus_tcp", -} - - -def run(task: Dict[str, Any], channels: Dict[str, Any], logger) -> Any: - protocol_key = _MAP.get(str(task.get("protocol", "")).lower()) - if not protocol_key: - raise ValueError("modbus_request.protocol 必须为 rtu / ascii / tcp") - - channel_name = task.get("channel") - if not channel_name or channel_name not in channels: - raise KeyError(f"未找到通道: {channel_name}") - - function = int(task.get("function")) - address = int(task.get("address")) - quantity = int(task.get("quantity", 1)) - values = task.get("values") - unit_id = int(task.get("unit_id", 1)) - - protocol_cls = ProtocolRegistry.get(protocol_key) - protocol = protocol_cls(channels[channel_name], logger) - - if protocol_key == "modbus_tcp": - timeout_s = float(task.get("timeout", 2.0)) - return protocol.execute(function=function, address=address, quantity=quantity, values=values, unit_id=unit_id, timeout=timeout_s) - - retries = int(task.get("retries", 3)) - timeout_ms = int(task.get("timeout", 1000)) - return protocol.execute( - function=function, - address=address, - quantity=quantity, - values=values, - unit_id=unit_id, - retries=retries, - timeout=timeout_ms, - ) diff --git a/actions/record_actions.py b/actions/record_actions.py deleted file mode 100644 index 6ff44a2..0000000 --- a/actions/record_actions.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict - -from actions.registry import ActionRegistry -from runtime.experiment_recorder import ExperimentRecorder - - -def _eval_str(ctx, value: Any) -> str: - v = ctx.eval_value(value) if hasattr(ctx, "eval_value") else value - return str(v) if v is not None else "" - - -def action_record_start(ctx, args: Dict[str, Any]): - if getattr(ctx, "recorder", None) is not None: - # Already recording; return current path if available. - rec = ctx.recorder - return str(getattr(rec, "paths", {}).root) if rec else None - - raw_dir = args.get("dir") - base_dir = _eval_str(ctx, raw_dir) if raw_dir is not None else None - name = _eval_str(ctx, args.get("name", "run")) or "run" - script_text = args.get("script_text") - script_path = args.get("script_path") - - if script_text is not None and hasattr(ctx, "eval_value"): - script_text = ctx.eval_value(script_text) - script_text = str(script_text) if script_text else getattr(ctx, "script_text", None) - - if script_path is not None and hasattr(ctx, "eval_value"): - script_path = ctx.eval_value(script_path) - script_path = str(script_path) if script_path else getattr(ctx, "script_path", None) - - rec = ExperimentRecorder(base_dir=base_dir, name=name, script_text=script_text, script_path=script_path) - root = rec.start() - - if hasattr(ctx, "attach_recorder"): - ctx.attach_recorder(rec) - else: - ctx.recorder = rec - if hasattr(ctx, "logger"): - try: - ctx.logger.info(f"[REC] start -> {root}") - except Exception: - pass - if hasattr(ctx, "set_var"): - try: - ctx.set_var("record_dir", str(root)) - except Exception: - pass - return str(root) - - -def action_record_stop(ctx, args: Dict[str, Any]): - rec = getattr(ctx, "recorder", None) - if rec is None: - return None - vars_snapshot = None - if hasattr(ctx, "vars_snapshot"): - try: - vars_snapshot = ctx.vars_snapshot() - except Exception: - vars_snapshot = None - try: - rec.close(vars_snapshot=vars_snapshot) - finally: - if hasattr(ctx, "detach_recorder"): - ctx.detach_recorder() - else: - ctx.recorder = None - if hasattr(ctx, "logger"): - try: - ctx.logger.info(f"[REC] stop -> {rec.paths.root}") - except Exception: - pass - return str(rec.paths.root) - - -def register_record_actions() -> None: - ActionRegistry.register("record_start", action_record_start) - ActionRegistry.register("record_stop", action_record_stop) diff --git a/actions/registry.py b/actions/registry.py deleted file mode 100644 index 1be019e..0000000 --- a/actions/registry.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from typing import Any, Callable, Dict - - -class ActionRegistry: - actions: Dict[str, Callable[..., Any]] = {} - - @classmethod - def register(cls, name: str, fn: Callable[..., Any]) -> None: - cls.actions[name] = fn - - @classmethod - def get(cls, name: str) -> Callable[..., Any]: - if name not in cls.actions: - raise KeyError(f"动作未注册: {name}") - return cls.actions[name] diff --git a/actions/schema_protocol.py b/actions/schema_protocol.py deleted file mode 100644 index 06d3ab5..0000000 --- a/actions/schema_protocol.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from functools import lru_cache -from typing import Any, Dict - -from actions.registry import ActionRegistry -from protocols.schema_runtime import ProtocolSchema - - -@lru_cache(maxsize=16) -def _load_schema(path: str) -> ProtocolSchema: - return ProtocolSchema.load(path) - - -def action_send_frame(ctx, args: Dict[str, Any]): - schema_path = args.get("schema") - frame = args.get("frame") - if not schema_path or not frame: - raise ValueError("send_frame requires schema and frame") - values = {k: ctx.eval_value(v) for k, v in (args.get("values") or {}).items()} - schema = _load_schema(str(schema_path)) - packet = schema.build(frame, values) - ctx.channel_write(packet) - ctx.set_var("last_frame_tx", {"frame": frame, "values": values, "hex": packet.hex().upper()}) - return packet - - -def action_expect_frame(ctx, args: Dict[str, Any]): - schema_path = args.get("schema") - frame = args.get("frame") - timeout = float(args.get("timeout", 2.0)) - save_as = args.get("save_as", "last_frame_rx") - if not schema_path or not frame: - raise ValueError("expect_frame requires schema and frame") - - schema = _load_schema(str(schema_path)) - fd = schema.frames.get(frame) - if fd is None: - raise KeyError(f"unknown frame: {frame}") - - data = b"" - if fd.tail: - data = ctx.channel.read_until(fd.tail, timeout=timeout) - else: - fixed = fd.fixed_length() - if fixed is None: - raise ValueError("expect_frame requires tail or fixed frame length") - data = ctx.channel.read_exact(fixed, timeout=timeout) # type: ignore[attr-defined] - - if not data: - raise TimeoutError("expect_frame timeout") - - parsed = schema.parse(frame, data) - ctx.set_var(save_as, parsed) - ctx.set_var("last_frame_rx_raw", data.hex().upper()) - return parsed - - -def register_schema_protocol_actions() -> None: - ActionRegistry.register("send_frame", action_send_frame) - ActionRegistry.register("expect_frame", action_expect_frame) diff --git a/actions/scpi_command.py b/actions/scpi_command.py deleted file mode 100644 index 7e9d530..0000000 --- a/actions/scpi_command.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict - -from protocols.registry import ProtocolRegistry - - -def run(task: Dict[str, Any], channels: Dict[str, Any], logger) -> Any: - channel_name = task.get("channel") - if not channel_name or channel_name not in channels: - raise KeyError(f"channel not found: {channel_name}") - - cmd = task.get("cmd") or task.get("command") - if not cmd: - raise ValueError("scpi_command.cmd is required") - - timeout = float(task.get("timeout", 2.0)) - terminator = task.get("terminator", "\n") - expect_response = task.get("expect_response") - strip = bool(task.get("strip", True)) - - protocol_cls = ProtocolRegistry.get("scpi") - protocol = protocol_cls(channels[channel_name], logger) - return protocol.execute( - cmd=str(cmd), - expect_response=expect_response, - timeout=timeout, - terminator=terminator, - strip=strip, - ) diff --git a/actions/xmodem_send.py b/actions/xmodem_send.py deleted file mode 100644 index a634bea..0000000 --- a/actions/xmodem_send.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict - -from protocols.registry import ProtocolRegistry - - -def run(task: Dict[str, Any], channels: Dict[str, Any], logger) -> Any: - channel_name = task.get("channel") - if not channel_name or channel_name not in channels: - raise KeyError(f"未找到通道: {channel_name}") - file_path = task.get("file") or task.get("path") - if not file_path: - raise ValueError("xmodem_send 需要 file/path 参数") - - retries = int(task.get("retries", 10)) - start_timeout = float(task.get("start_timeout", 10.0)) - - protocol_cls = ProtocolRegistry.get("xmodem") - protocol = protocol_cls(channels[channel_name], logger) - return protocol.execute(file_path=file_path, retries=retries, start_timeout=start_timeout) diff --git a/actions/ymodem_send.py b/actions/ymodem_send.py deleted file mode 100644 index 4c5846d..0000000 --- a/actions/ymodem_send.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict - -from protocols.registry import ProtocolRegistry - - -def run(task: Dict[str, Any], channels: Dict[str, Any], logger) -> Any: - channel_name = task.get("channel") - if not channel_name or channel_name not in channels: - raise KeyError(f"未找到通道: {channel_name}") - file_path = task.get("file") or task.get("path") - if not file_path: - raise ValueError("ymodem_send 需要 file/path 参数") - - retries = int(task.get("retries", 10)) - start_timeout = float(task.get("start_timeout", 10.0)) - - protocol_cls = ProtocolRegistry.get("ymodem") - protocol = protocol_cls(channels[channel_name], logger) - return protocol.execute(file_path=file_path, retries=retries, start_timeout=start_timeout) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f1ee259 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Application entrypoints package. diff --git a/dsl_main.py b/app/dsl_main.py similarity index 88% rename from dsl_main.py rename to app/dsl_main.py index 43d6dca..399ab86 100644 --- a/dsl_main.py +++ b/app/dsl_main.py @@ -3,7 +3,7 @@ import argparse import sys -from runtime.runner import run_dsl +from dsl_runtime.engine.runner import run_dsl def main(argv=None) -> int: diff --git a/main_web.py b/app/main_web.py similarity index 91% rename from main_web.py rename to app/main_web.py index 626c6b1..e2e8ee2 100644 --- a/main_web.py +++ b/app/main_web.py @@ -17,12 +17,12 @@ except ImportError: # pragma: no cover from PyQt6.QtWidgets import QApplication # type: ignore -from core.communication_manager import CommunicationManager -from core.event_bus import EventBus -from core.packet_engine import PacketAnalysisEngine -from core.plugin_manager import PluginManager -from core.protocol_loader import ProtocolLoader -from desktop.web_window import WebWindow +from infra.comm.communication_manager import CommunicationManager +from infra.common.event_bus import EventBus +from app.packet_engine import PacketAnalysisEngine +from app.plugin_manager import PluginManager +from infra.protocol.protocol_loader import ProtocolLoader +from ui.desktop.web_window import WebWindow class _TeeStream: @@ -64,8 +64,8 @@ def flush(self) -> None: def _setup_run_logging() -> Path: - base_dir = Path(__file__).resolve().parent - user_root = Path(os.environ.get("LOCALAPPDATA", base_dir)) + root_dir = Path(__file__).resolve().parents[1] + user_root = Path(os.environ.get("LOCALAPPDATA", root_dir)) log_dir = user_root / "ProtoFlow" / "logs" log_dir.mkdir(parents=True, exist_ok=True) timestamp = time.strftime("%Y%m%d_%H%M%S") @@ -97,7 +97,7 @@ def _log_unraisable(unraisable): # type: ignore[override] def _ensure_repo_cwd() -> None: - base_dir = Path(__file__).resolve().parent + base_dir = Path(__file__).resolve().parents[1] try: os.chdir(base_dir) except OSError: diff --git a/core/packet_engine.py b/app/packet_engine.py similarity index 98% rename from core/packet_engine.py rename to app/packet_engine.py index 59da002..ecf694c 100644 --- a/core/packet_engine.py +++ b/app/packet_engine.py @@ -11,8 +11,8 @@ from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Optional, Tuple -from core.event_bus import EventBus -from core.protocol_loader import crc16_modbus +from infra.common.event_bus import EventBus +from infra.protocol.protocol_loader import crc16_modbus @dataclass diff --git a/core/plugin_manager.py b/app/plugin_manager.py similarity index 97% rename from core/plugin_manager.py rename to app/plugin_manager.py index 0e990fd..b77e20e 100644 --- a/core/plugin_manager.py +++ b/app/plugin_manager.py @@ -10,8 +10,8 @@ from types import ModuleType from typing import Dict, List, Optional -from core.event_bus import EventBus -from utils.path_utils import resolve_resource_path +from infra.common.event_bus import EventBus +from infra.common.utils.path_utils import resolve_resource_path class PluginManager: diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e378077..bbe4919 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -8,7 +8,7 @@ ProtoFlow 是面向嵌入式/工控/自动化测试的通信自动化运行时 ## 2. 安装与运行 - 依赖:`pip install pyyaml`,使用串口需 `pip install pyserial`。 -- 入口:`python dsl_main.py ` +- 入口:`python app/dsl_main.py ` - 输入:符合 DSL 规范的 YAML 脚本。 - 输出:日志(INFO/DEBUG),状态机执行的事件流;动作可产生下行数据,通道可回传事件。 @@ -104,7 +104,7 @@ stateDiagram-v2 - `list_filter`: 列表过滤(`src`/`where`,可选 `dst`)。 - `list_map`: 列表映射(`src`/`expr`,可选 `dst`,可选 `where`)。 - 协议动作:XMODEM/Modbus 等(下文详述)。 -- 自定义动作:在 Python 中 `ActionRegistry.register("name", fn)` 注册,`fn(ctx, args)` 使用 `ctx.channel_write` / `ctx.set_var` / `ctx.vars_snapshot`。 +- 自定义动作:继承 `DslActionBase`,实现 `execute(ctx, args)` 并定义参数 `schema`,使用 `ActionRegistry.register("name", MyAction())` 注册。 ## 9. XMODEM 动作 - `send_xmodem_block`:发送指定块号(128B,自动 0x1A 填充),参数 `block: "$block"`。 @@ -118,7 +118,7 @@ stateDiagram-v2 - 预留动作:`modbus_read` / `modbus_write`(当前 DSL Runner 未实现,仅文档占位) - 参数:`protocol: rtu|ascii|tcp`,`function`,`address`,`quantity`,`values`(写),`unit_id`。 - 差异:RTU(CRC16,二进制);ASCII(LRC,文本帧);TCP(MBAP,无 CRC)。 -说明:仓库中已实现 Modbus 协议驱动(`protocols/modbus_*.py`),并可在 `main_runtime.py` 的 tasks 模式中调用;若要在 DSL 中使用需新增对应动作注册。 +说明:仓库中已实现 Modbus 协议驱动(`infra/protocol/modbus_*.py`),可在 DSL 动作中调用(已注册 `modbus_read/modbus_write`)。 ## 12. 事件系统(Events) - 来源:通道 `read_event`(UART/TCP 读取到的字节,默认字符;无法解码则 HEX 字符串)。 @@ -267,15 +267,31 @@ state_machine: ## 15. 扩展指南 - 添加新动作: ```python - from actions.registry import ActionRegistry - def my_action(ctx, args): - # ctx.channel_write / ctx.set_var / ctx.vars_snapshot() - ... - ActionRegistry.register("my_action", my_action) + from dsl_runtime.actions.base import DslActionBase + from dsl_runtime.actions.registry import ActionRegistry + + class MyAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="my_action", + schema={ + "required": ["foo"], + "optional": {"bar": 1}, + "types": {"foo": "string", "bar": "number"}, + "aliases": {"baz": "foo"}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args): + # ctx.channel_write / ctx.set_var / ctx.vars_snapshot() + ... + + ActionRegistry.register("my_action", MyAction()) ``` -- 添加新协议动作:在 `actions/*.py` 中封装协议逻辑,调用协议封包构造器(如 XMODEM/Modbus)。 +- 添加新协议动作:在 `dsl_runtime/actions/*.py` 中封装协议逻辑,调用协议封包构造器(如 XMODEM/Modbus)。 - 添加新协议适配:实现协议封包/解析,供动作调用。 -- 扩展 DSL:修改 `dsl/parser.py` / `dsl/ast_nodes.py` / `dsl/executor.py` 增加新语法字段,保持向后兼容。 +- 扩展 DSL:修改 `dsl_runtime/lang/parser.py` / `dsl_runtime/lang/ast_nodes.py` / `dsl_runtime/lang/executor.py` 增加新语法字段,保持向后兼容。 - 让 AI 编写 DSL:提供章节 7/8 模板,明确事件名、超时、变量命名,AI 可按样例生成 YAML。 ## 16. 附录 diff --git a/docs/USER_GUIDE_EN.md b/docs/USER_GUIDE_EN.md index d3fa77b..f051e7d 100644 --- a/docs/USER_GUIDE_EN.md +++ b/docs/USER_GUIDE_EN.md @@ -6,7 +6,7 @@ Pipeline: `YAML DSL → state machine → actions → protocol adapter → chann ## 2. Installation & Run - Dependencies: `pip install pyyaml`; for serial use `pip install pyserial`. -- Entry point: `python dsl_main.py ` +- Entry point: `python app/dsl_main.py ` - Input: YAML script that follows the DSL spec. - Output: logs (INFO/DEBUG), state-machine event trace; actions can emit outbound data, channels can raise events. @@ -97,7 +97,7 @@ stateDiagram-v2 - `list_filter`: filter a list into a new list (`src`, `where`, optional `dst`). - `list_map`: map a list into a new list (`src`, `expr`, optional `dst`, optional `where`). - Protocol actions: XMODEM/Modbus etc. (see below). -- Custom actions: in Python `ActionRegistry.register("name", fn)`, where `fn(ctx, args)` can use `ctx.channel_write` / `ctx.set_var` / `ctx.vars_snapshot`. +- Custom actions: subclass `DslActionBase`, implement `execute(ctx, args)` with a parameter `schema`, then register the instance via `ActionRegistry.register("name", MyAction())`. ### 8.2 Data Processing (filter/transform) `if` (recommended to reduce extra states when you only need to filter/branch inside `do`): @@ -160,13 +160,18 @@ do: - `expect_frame`: reads by tail or fixed length, parses, stores result in `save_as` (default `last_frame_rx`), raw hex in `last_frame_rx_raw`. 3) Register more custom actions (also applied at startup): ```python - from actions.registry import ActionRegistry + from dsl_runtime.actions.base import DslActionBase + from dsl_runtime.actions.registry import ActionRegistry - def my_action(ctx, args): - # e.g., write custom bytes or combine multiple steps - ctx.channel_write(b"hello") + class MyAction(DslActionBase): + def __init__(self) -> None: + super().__init__(name="my_action", schema={"allow_extra": False}) - ActionRegistry.register("my_action", my_action) + def execute(self, ctx, args): + # e.g., write custom bytes or combine multiple steps + ctx.channel_write(b"hello") + + ActionRegistry.register("my_action", MyAction()) ``` Then call in DSL: `- action: my_action`. @@ -182,7 +187,7 @@ Currently examples are XMODEM-focused; YMODEM can be added similarly with action - Reserved actions: `modbus_read` / `modbus_write` (not implemented in current DSL runner; docs placeholder) - Args: `protocol: rtu|ascii|tcp`, `function`, `address`, `quantity`, `values` (for write), `unit_id`. - Differences: RTU (CRC16, binary); ASCII (LRC, text frame); TCP (MBAP, no CRC). -- Note: Modbus protocol drivers exist under `protocols/modbus_*.py` and are callable from `main_runtime.py` tasks mode; adding DSL actions requires registering them. +- Note: Modbus protocol drivers exist under `infra/protocol/modbus_*.py` and are available via DSL actions (`modbus_read/modbus_write`). ## 12. Event System - Sources: channel `read_event` (UART/TCP bytes; default decoded to text, fallback HEX string). @@ -331,15 +336,15 @@ state_machine: ## 15. Extension Guide - Add new action: ```python - from actions.registry import ActionRegistry + from dsl_runtime.actions.registry import ActionRegistry def my_action(ctx, args): # ctx.channel_write / ctx.set_var / ctx.vars_snapshot() ... - ActionRegistry.register("my_action", my_action) + ActionRegistry.register("my_action", MyAction()) ``` -- Add new protocol actions: encapsulate protocol logic in `actions/*.py`, call protocol pack/unpack helpers (e.g., XMODEM/Modbus). +- Add new protocol actions: encapsulate protocol logic in `dsl_runtime/actions/*.py`, call protocol pack/unpack helpers (e.g., XMODEM/Modbus). - Add new protocol adapter: implement packet build/parse for actions to call. -- Extend DSL: edit `dsl/parser.py` / `dsl/ast_nodes.py` / `dsl/executor.py` to add syntax (keep backward compatibility). +- Extend DSL: edit `dsl_runtime/lang/parser.py` / `dsl_runtime/lang/ast_nodes.py` / `dsl_runtime/lang/executor.py` to add syntax (keep backward compatibility). - Let an AI draft DSL: provide templates from sections 7/8 with event names, timeouts, variable names; an AI can generate YAML by example. ## 16. Appendix diff --git a/dsl_runtime/__init__.py b/dsl_runtime/__init__.py new file mode 100644 index 0000000..4a6b95c --- /dev/null +++ b/dsl_runtime/__init__.py @@ -0,0 +1 @@ +# DSL runtime package. diff --git a/dsl_runtime/actions/__init__.py b/dsl_runtime/actions/__init__.py new file mode 100644 index 0000000..a67640c --- /dev/null +++ b/dsl_runtime/actions/__init__.py @@ -0,0 +1 @@ +# DSL action modules package. diff --git a/dsl_runtime/actions/base.py b/dsl_runtime/actions/base.py new file mode 100644 index 0000000..2c9fc8a --- /dev/null +++ b/dsl_runtime/actions/base.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Tuple, Type + + +class ActionValidationError(ValueError): + pass + + +_TYPE_ALIASES: Dict[str, Tuple[Type[Any], ...]] = { + "number": (int, float), + "string": (str,), + "bool": (bool,), + "mapping": (dict,), + "list": (list, tuple), +} + + +@dataclass +class DslActionBase: + name: str + schema: Dict[str, Any] | None = None + + def run(self, ctx, args: Dict[str, Any] | None) -> Any: + payload = self.validate_args(args) + return self.execute(ctx, payload) + + def execute(self, ctx, args: Dict[str, Any]) -> Any: + raise NotImplementedError() + + def validate_args(self, args: Dict[str, Any] | None) -> Dict[str, Any]: + if args is None: + args = {} + if not isinstance(args, dict): + raise ActionValidationError("action args must be a mapping") + schema = self.schema or {} + aliases = schema.get("aliases", {}) or {} + required = list(schema.get("required", []) or []) + optional = dict(schema.get("optional", {}) or {}) + types = dict(schema.get("types", {}) or {}) + allow_extra = bool(schema.get("allow_extra", True)) + + normalized = dict(args) + for alias, target in aliases.items(): + if alias in normalized and target not in normalized: + normalized[target] = normalized.pop(alias) + + for key, spec in optional.items(): + if key not in normalized: + if isinstance(spec, dict) and "default" in spec: + normalized[key] = self._copy_default(spec.get("default")) + else: + normalized[key] = self._copy_default(spec) + + for key in required: + if key not in normalized: + raise ActionValidationError(f"missing required arg: {key}") + + if not allow_extra: + allowed = set(required) | set(optional) | set(types) + extra = [k for k in normalized.keys() if k not in allowed] + if extra: + raise ActionValidationError(f"unknown args: {', '.join(extra)}") + + for key, type_spec in types.items(): + if key not in normalized: + continue + value = normalized.get(key) + if value is None: + continue + expected = self._resolve_type_spec(type_spec) + if expected is not None and not isinstance(value, expected): + raise ActionValidationError( + f"arg '{key}' has invalid type: {type(value).__name__}" + ) + return normalized + + @staticmethod + def _resolve_type_spec(type_spec: Any) -> Tuple[Type[Any], ...] | None: + if type_spec is None: + return None + if isinstance(type_spec, tuple): + return type_spec + if isinstance(type_spec, list): + return tuple(type_spec) + if isinstance(type_spec, str): + return _TYPE_ALIASES.get(type_spec) + if isinstance(type_spec, type): + return (type_spec,) + return None + + @staticmethod + def _copy_default(value: Any) -> Any: + if isinstance(value, dict): + return dict(value) + if isinstance(value, list): + return list(value) + return value diff --git a/actions/chart_bridge.py b/dsl_runtime/actions/chart_bridge.py similarity index 100% rename from actions/chart_bridge.py rename to dsl_runtime/actions/chart_bridge.py diff --git a/dsl_runtime/actions/dsl_builtin_actions.py b/dsl_runtime/actions/dsl_builtin_actions.py new file mode 100644 index 0000000..e10bbad --- /dev/null +++ b/dsl_runtime/actions/dsl_builtin_actions.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import time +from typing import Any, Dict + +from dsl_runtime.actions.base import DslActionBase +from dsl_runtime.actions.registry import ActionRegistry +from dsl_runtime.lang.expression import eval_expr + + +def _eval(ctx, val): + if isinstance(val, str) and "$" in val: + return eval_expr(val, ctx.vars_snapshot()) + return val + + +class SetAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="set", + schema={ + "allow_extra": True, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + for key, val in args.items(): + ctx.set_var(key, _eval(ctx, val)) + return ctx.vars_snapshot() + + +class LogAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="log", + schema={ + "aliases": {"msg": "message"}, + "optional": {"message": ""}, + "types": {"message": (str, int, float, bool, bytes, bytearray)}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + msg = args.get("message") or "" + if isinstance(msg, str) and "$" in msg: + msg = eval_expr(msg, ctx.vars_snapshot()) + ctx.logger.info(str(msg)) + + +class WaitAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="wait", + schema={ + "optional": {"ms": 0}, + "types": {"ms": "number"}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + ms = int(args.get("ms", 0)) + time.sleep(ms / 1000.0) + + +class WaitForEventAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="wait_for_event", + schema={ + "optional": {"event": None, "timeout": 1.0}, + "types": {"timeout": "number"}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + expected = args.get("event") + timeout = float(args.get("timeout", 1.0)) + end = time.time() + timeout + while time.time() < end: + evt = ctx.next_event(timeout=0.1) + if evt is None: + continue + if expected is None or evt == expected: + ctx.set_var("event", evt) + return evt + return None + + +class SendTextAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="send_text", + schema={ + "aliases": {"data": "text"}, + "optional": {"text": "", "append_cr": False, "append_lf": False}, + "types": {"append_cr": bool, "append_lf": bool}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + text = args.get("text", "") + if isinstance(text, (bytes, bytearray)): + payload = bytes(text) + else: + payload = str(text) + append_cr = bool(args.get("append_cr", False)) + append_lf = bool(args.get("append_lf", False)) + if append_cr: + payload = payload + (b"\r" if isinstance(payload, (bytes, bytearray)) else "\r") + if append_lf: + payload = payload + (b"\n" if isinstance(payload, (bytes, bytearray)) else "\n") + ctx.channel_write(payload) + return {"text": text, "append_cr": append_cr, "append_lf": append_lf} + + +class ReadLineAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="read_line", + schema={ + "optional": {"terminator": "\n", "timeout": 1.0}, + "types": {"timeout": "number"}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + terminator = args.get("terminator", "\n") + timeout = float(args.get("timeout", 1.0)) + raw = ctx.channel.read_until( + terminator.encode() if isinstance(terminator, str) else bytes(terminator), + timeout=timeout, + ) + text = raw.decode(errors="ignore").strip() + ctx.set_var("last_line_rx", text) + ctx.set_var("last_line_rx_raw", raw.hex().upper()) + return {"text": text, "hex": raw.hex().upper()} + + +class ReadStreamAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="read_stream", + schema={ + "aliases": {"duration": "duration_ms"}, + "optional": { + "duration_ms": 1000, + "chunk_size": 256, + "timeout": 0.2, + "log_hex": True, + }, + "types": { + "duration_ms": "number", + "chunk_size": "number", + "timeout": "number", + "log_hex": bool, + }, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + duration_ms = int(args.get("duration_ms", 1000)) + chunk_size = int(args.get("chunk_size", 256)) + timeout = float(args.get("timeout", 0.2)) + log_hex = bool(args.get("log_hex", True)) + end = time.time() + max(0.0, duration_ms / 1000.0) + last_text = "" + last_hex = "" + while time.time() < end: + chunk = ctx.channel.read(chunk_size, timeout=timeout) + if not chunk: + continue + last_hex = chunk.hex().upper() + try: + last_text = chunk.decode(errors="ignore") + except Exception: + last_text = "" + if log_hex: + ctx.logger.info(f"RX(hex): {last_hex}") + if last_text.strip(): + ctx.logger.info(f"RX(text): {last_text.strip()}") + ctx.set_var("last_stream_rx", last_text.strip()) + ctx.set_var("last_stream_rx_raw", last_hex) + return {"text": last_text.strip(), "hex": last_hex} + + +def register_builtin_actions() -> None: + ActionRegistry.register("set", SetAction()) + ActionRegistry.register("log", LogAction()) + ActionRegistry.register("send_text", SendTextAction()) + ActionRegistry.register("read_line", ReadLineAction()) + ActionRegistry.register("read_stream", ReadStreamAction()) + ActionRegistry.register("wait", WaitAction()) + ActionRegistry.register("wait_for_event", WaitForEventAction()) diff --git a/dsl_runtime/actions/dsl_chart_actions.py b/dsl_runtime/actions/dsl_chart_actions.py new file mode 100644 index 0000000..f030f8e --- /dev/null +++ b/dsl_runtime/actions/dsl_chart_actions.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import time +from typing import Any, Dict + +from dsl_runtime.actions.base import DslActionBase +from dsl_runtime.actions.chart_bridge import chart_bridge +from dsl_runtime.actions.registry import ActionRegistry + + +def _eval(ctx, val: Any) -> Any: + if hasattr(ctx, "eval_value"): + return ctx.eval_value(val) + return val + + +class ChartAddAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="chart_add", + schema={ + "required": ["bind", "value"], + "aliases": {"val": "value", "timestamp": "ts"}, + "optional": {"ts": None}, + "types": {"ts": "number"}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + bind = args.get("bind") + raw_val = args.get("value") + ts_arg = args.get("ts") + ts = float(_eval(ctx, ts_arg)) if ts_arg is not None else time.time() + try: + val = float(_eval(ctx, raw_val)) + except Exception as exc: + raise ValueError(f"chart_add value invalid: {exc}") from exc + payload = {"ts": ts, str(bind): val} + if hasattr(ctx, "record_chart"): + try: + ctx.record_chart(payload) + except Exception: + pass + if chart_bridge is None: + ctx.logger.warning("chart bridge unavailable (Qt not loaded)") + return payload + chart_bridge.sig_data.emit(payload) + return {"ts": ts, "bind": str(bind), "value": val} + + +class ChartAdd3dAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="chart_add3d", + schema={ + "required": ["x", "y", "z"], + "aliases": {"timestamp": "ts"}, + "optional": {"ts": None, "bind_x": "x", "bind_y": "y", "bind_z": "z"}, + "types": {"ts": "number"}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + bx = str(args.get("bind_x", "x")) + by = str(args.get("bind_y", "y")) + bz = str(args.get("bind_z", "z")) + x_val = _eval(ctx, args.get("x")) + y_val = _eval(ctx, args.get("y")) + z_val = _eval(ctx, args.get("z")) + ts_arg = args.get("ts") + ts = float(_eval(ctx, ts_arg)) if ts_arg is not None else time.time() + payload = {"ts": ts, bx: x_val, by: y_val, bz: z_val} + if hasattr(ctx, "record_chart"): + try: + ctx.record_chart(payload) + except Exception: + pass + if chart_bridge is None: + ctx.logger.warning("chart bridge unavailable (Qt not loaded)") + return payload + chart_bridge.sig_data.emit(payload) + return payload + + +def register_chart_actions() -> None: + ActionRegistry.register("chart_add", ChartAddAction()) + ActionRegistry.register("chart_add3d", ChartAdd3dAction()) diff --git a/dsl_runtime/actions/dsl_data_actions.py b/dsl_runtime/actions/dsl_data_actions.py new file mode 100644 index 0000000..b133382 --- /dev/null +++ b/dsl_runtime/actions/dsl_data_actions.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from typing import Any, Dict, Iterable, List, Tuple + +from dsl_runtime.actions.base import DslActionBase +from dsl_runtime.actions.registry import ActionRegistry +from dsl_runtime.lang.expression import eval_expr + + +def _vars_snapshot(ctx) -> Dict[str, Any]: + if hasattr(ctx, "vars_snapshot"): + return ctx.vars_snapshot() + if hasattr(ctx, "vars"): + return dict(ctx.vars) + return {} + + +def _parse_inline_actions(items: Any) -> List[Tuple[str, Dict[str, Any]]]: + if items is None: + return [] + if not isinstance(items, list): + raise ValueError("inline actions must be a list") + parsed: List[Tuple[str, Dict[str, Any]]] = [] + for item in items: + if not isinstance(item, dict): + raise ValueError(f"invalid inline action: {item}") + if "action" in item: + name = item["action"] + args = item.get("args", {}) or {} + if not isinstance(args, dict): + raise ValueError(f"action.args must be a mapping: {item}") + parsed.append((str(name), args)) + continue + if "set" in item: + args = item.get("set") or {} + if not isinstance(args, dict): + raise ValueError(f"set must be a mapping: {item}") + parsed.append(("set", args)) + continue + if "log" in item: + parsed.append(("log", {"message": item.get("log")})) + continue + if "wait" in item: + wait_cfg = item.get("wait") + args = wait_cfg if isinstance(wait_cfg, dict) else {"ms": wait_cfg} + parsed.append(("wait", args)) + continue + if "wait_for_event" in item: + wfe = item.get("wait_for_event") + args = wfe if isinstance(wfe, dict) else {"event": wfe} + parsed.append(("wait_for_event", args)) + continue + if "if" in item: + if_cfg = item.get("if") or {} + if not isinstance(if_cfg, dict): + raise ValueError(f"if must be a mapping: {item}") + parsed.append(("if", if_cfg)) + continue + raise ValueError(f"unknown inline action type: {item}") + return parsed + + +class IfAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="if", + schema={ + "aliases": {"cond": "when", "do": "then", "otherwise": "else"}, + "required": ["when"], + "optional": {"then": [], "else": []}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]) -> Dict[str, Any]: + cond_expr = args.get("when") + cond = bool(eval_expr(str(cond_expr), _vars_snapshot(ctx))) + then_items = args.get("then") or [] + else_items = args.get("else") or [] + items = then_items if cond else else_items + actions = _parse_inline_actions(items) + for name, a in actions: + ctx.run_action(name, a) + return {"when": str(cond_expr), "taken": "then" if cond else "else", "count": len(actions)} + + +def _iterable_or_error(value: Any, *, name: str) -> Iterable[Any]: + if isinstance(value, (list, tuple)): + return value + raise ValueError(f"{name} must be a list/tuple") + + +def _item_env(item: Any, index: int) -> Dict[str, Any]: + env: Dict[str, Any] = {"item": item, "index": index} + if isinstance(item, dict): + for k, v in item.items(): + if isinstance(k, str) and k.isidentifier(): + env[f"item.{k}"] = v + return env + + +class ListFilterAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="list_filter", + schema={ + "aliases": {"items": "src", "in": "src", "when": "where", "out": "dst"}, + "required": ["src", "where"], + "optional": {"limit": None, "dst": None}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]) -> List[Any]: + src = args.get("src") + src_val = ctx.eval_value(src) if hasattr(ctx, "eval_value") else src + if isinstance(src, str) and "$" not in src and hasattr(ctx, "vars") and src in ctx.vars: + src_val = ctx.vars[src] + where = args.get("where") + limit = args.get("limit") + out: List[Any] = [] + base = _vars_snapshot(ctx) + for idx, item in enumerate(_iterable_or_error(src_val, name="src")): + env = dict(base) + env.update(_item_env(item, idx)) + if bool(eval_expr(str(where), env)): + out.append(item) + if limit is not None and len(out) >= int(limit): + break + dst = args.get("dst") + if dst and hasattr(ctx, "set_var"): + ctx.set_var(str(dst), out) + return out + + +class ListMapAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="list_map", + schema={ + "aliases": {"items": "src", "in": "src", "map": "expr", "value": "expr", "when": "where", "out": "dst"}, + "required": ["src", "expr"], + "optional": {"where": None, "limit": None, "dst": None}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]) -> List[Any]: + src = args.get("src") + src_val = ctx.eval_value(src) if hasattr(ctx, "eval_value") else src + if isinstance(src, str) and "$" not in src and hasattr(ctx, "vars") and src in ctx.vars: + src_val = ctx.vars[src] + expr = args.get("expr") + where = args.get("where") + limit = args.get("limit") + out: List[Any] = [] + base = _vars_snapshot(ctx) + for idx, item in enumerate(_iterable_or_error(src_val, name="src")): + env = dict(base) + env.update(_item_env(item, idx)) + if where and not bool(eval_expr(str(where), env)): + continue + out.append(eval_expr(str(expr), env)) + if limit is not None and len(out) >= int(limit): + break + dst = args.get("dst") + if dst and hasattr(ctx, "set_var"): + ctx.set_var(str(dst), out) + return out + + +def register_data_actions() -> None: + ActionRegistry.register("if", IfAction()) + ActionRegistry.register("list_filter", ListFilterAction()) + ActionRegistry.register("list_map", ListMapAction()) diff --git a/actions/protocol_actions.py b/dsl_runtime/actions/dsl_protocol_actions.py similarity index 50% rename from actions/protocol_actions.py rename to dsl_runtime/actions/dsl_protocol_actions.py index 6f8b579..0e8947c 100644 --- a/actions/protocol_actions.py +++ b/dsl_runtime/actions/dsl_protocol_actions.py @@ -2,11 +2,12 @@ from typing import Dict -from actions.registry import ActionRegistry -from protocols.registry import ProtocolRegistry -from protocols import modbus_ascii, modbus_rtu, modbus_tcp # noqa: F401 -from utils.crc16 import crc16_xmodem -from utils.file_utils import get_file_meta, read_block +from dsl_runtime.actions.base import DslActionBase +from dsl_runtime.actions.registry import ActionRegistry +from infra.protocol.registry import ProtocolRegistry +from infra.protocol import modbus_ascii, modbus_rtu, modbus_tcp # noqa: F401 +from infra.common.utils.crc16 import crc16_xmodem +from infra.common.utils.file_utils import get_file_meta, read_block class XMODEMPacketBuilder: @@ -25,24 +26,42 @@ def build_eot() -> bytes: return bytes([0x04]) -def send_xmodem_block(ctx, args: Dict[str, object]): - meta = get_file_meta(ctx) - # Cache file metadata so DSL can reference $file.size / $file.block_count in transitions. - ctx.set_var("file", meta) - # Also flatten for both dot and underscore style access - ctx.set_var("file.block_count", meta.get("block_count")) - ctx.set_var("file.size", meta.get("size")) - ctx.set_var("file_block_count", meta.get("block_count")) - ctx.set_var("file_size", meta.get("size")) - block = int(ctx.eval_value(args.get("block", 1))) - data = read_block(meta["path"], block, 128) - packet = XMODEMPacketBuilder.build_block(block, data) - ctx.channel_write(packet) - ctx.set_var("last_sent_block", block) +class SendXmodemBlockAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="send_xmodem_block", + schema={ + "optional": {"block": 1}, + "types": {"block": "number"}, + "allow_extra": False, + }, + ) + def execute(self, ctx, args: Dict[str, object]): + meta = get_file_meta(ctx) + ctx.set_var("file", meta) + ctx.set_var("file.block_count", meta.get("block_count")) + ctx.set_var("file.size", meta.get("size")) + ctx.set_var("file_block_count", meta.get("block_count")) + ctx.set_var("file_size", meta.get("size")) + block = int(ctx.eval_value(args.get("block", 1))) + data = read_block(meta["path"], block, 128) + packet = XMODEMPacketBuilder.build_block(block, data) + ctx.channel_write(packet) + ctx.set_var("last_sent_block", block) + + +class SendEotAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="send_eot", + schema={ + "allow_extra": False, + }, + ) -def send_eot(ctx, args: Dict[str, object]): - ctx.channel_write(XMODEMPacketBuilder.build_eot()) + def execute(self, ctx, args: Dict[str, object]): + ctx.channel_write(XMODEMPacketBuilder.build_eot()) _MODBUS_MAP = { @@ -131,16 +150,58 @@ def _run_modbus(ctx, args: Dict[str, object]): return result -def modbus_read(ctx, args: Dict[str, object]): - return _run_modbus(ctx, args) +class ModbusReadAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="modbus_read", + schema={ + "required": ["function", "address"], + "optional": { + "protocol": "rtu", + "channel": None, + "quantity": None, + "values": None, + "value": None, + "unit_id": 1, + "timeout": None, + "retries": 3, + "save_as": None, + }, + "allow_extra": False, + }, + ) + def execute(self, ctx, args: Dict[str, object]): + return _run_modbus(ctx, args) + + +class ModbusWriteAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="modbus_write", + schema={ + "required": ["function", "address"], + "optional": { + "protocol": "rtu", + "channel": None, + "quantity": None, + "values": None, + "value": None, + "unit_id": 1, + "timeout": None, + "retries": 3, + "save_as": None, + }, + "allow_extra": False, + }, + ) -def modbus_write(ctx, args: Dict[str, object]): - return _run_modbus(ctx, args) + def execute(self, ctx, args: Dict[str, object]): + return _run_modbus(ctx, args) def register_protocol_actions(): - ActionRegistry.register("send_xmodem_block", send_xmodem_block) - ActionRegistry.register("send_eot", send_eot) - ActionRegistry.register("modbus_read", modbus_read) - ActionRegistry.register("modbus_write", modbus_write) + ActionRegistry.register("send_xmodem_block", SendXmodemBlockAction()) + ActionRegistry.register("send_eot", SendEotAction()) + ActionRegistry.register("modbus_read", ModbusReadAction()) + ActionRegistry.register("modbus_write", ModbusWriteAction()) diff --git a/dsl_runtime/actions/dsl_protocol_schema_actions.py b/dsl_runtime/actions/dsl_protocol_schema_actions.py new file mode 100644 index 0000000..3d5b10f --- /dev/null +++ b/dsl_runtime/actions/dsl_protocol_schema_actions.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from functools import lru_cache +from typing import Any, Dict + +from dsl_runtime.actions.base import DslActionBase +from dsl_runtime.actions.registry import ActionRegistry +from infra.protocol.schema_runtime import ProtocolSchema + + +@lru_cache(maxsize=16) +def _load_schema(path: str) -> ProtocolSchema: + return ProtocolSchema.load(path) + + +class SendFrameAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="send_frame", + schema={ + "required": ["schema", "frame"], + "optional": {"values": {}}, + "types": {"values": "mapping"}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + schema_path = args.get("schema") + frame = args.get("frame") + values = {k: ctx.eval_value(v) for k, v in (args.get("values") or {}).items()} + schema = _load_schema(str(schema_path)) + packet = schema.build(frame, values) + ctx.channel_write(packet) + ctx.set_var("last_frame_tx", {"frame": frame, "values": values, "hex": packet.hex().upper()}) + return packet + + +class ExpectFrameAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="expect_frame", + schema={ + "required": ["schema", "frame"], + "optional": {"timeout": 2.0, "save_as": "last_frame_rx"}, + "types": {"timeout": "number"}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + schema_path = args.get("schema") + frame = args.get("frame") + timeout = float(args.get("timeout", 2.0)) + save_as = args.get("save_as", "last_frame_rx") + + schema = _load_schema(str(schema_path)) + fd = schema.frames.get(frame) + if fd is None: + raise KeyError(f"unknown frame: {frame}") + + data = b"" + if fd.tail: + data = ctx.channel.read_until(fd.tail, timeout=timeout) + else: + fixed = fd.fixed_length() + if fixed is None: + raise ValueError("expect_frame requires tail or fixed frame length") + data = ctx.channel.read_exact(fixed, timeout=timeout) # type: ignore[attr-defined] + + if not data: + raise TimeoutError("expect_frame timeout") + + parsed = schema.parse(frame, data) + ctx.set_var(save_as, parsed) + ctx.set_var("last_frame_rx_raw", data.hex().upper()) + return parsed + + +def register_schema_protocol_actions() -> None: + ActionRegistry.register("send_frame", SendFrameAction()) + ActionRegistry.register("expect_frame", ExpectFrameAction()) diff --git a/dsl_runtime/actions/dsl_record_actions.py b/dsl_runtime/actions/dsl_record_actions.py new file mode 100644 index 0000000..42cf11d --- /dev/null +++ b/dsl_runtime/actions/dsl_record_actions.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Any, Dict + +from dsl_runtime.actions.base import DslActionBase +from dsl_runtime.actions.registry import ActionRegistry +from dsl_runtime.engine.experiment_recorder import ExperimentRecorder + + +def _eval_str(ctx, value: Any) -> str: + v = ctx.eval_value(value) if hasattr(ctx, "eval_value") else value + return str(v) if v is not None else "" + + +class RecordStartAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="record_start", + schema={ + "optional": {"dir": None, "name": "run", "script_text": None, "script_path": None}, + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + if getattr(ctx, "recorder", None) is not None: + rec = ctx.recorder + return str(getattr(rec, "paths", {}).root) if rec else None + + raw_dir = args.get("dir") + base_dir = _eval_str(ctx, raw_dir) if raw_dir is not None else None + name = _eval_str(ctx, args.get("name", "run")) or "run" + script_text = args.get("script_text") + script_path = args.get("script_path") + + if script_text is not None and hasattr(ctx, "eval_value"): + script_text = ctx.eval_value(script_text) + script_text = str(script_text) if script_text else getattr(ctx, "script_text", None) + + if script_path is not None and hasattr(ctx, "eval_value"): + script_path = ctx.eval_value(script_path) + script_path = str(script_path) if script_path else getattr(ctx, "script_path", None) + + rec = ExperimentRecorder(base_dir=base_dir, name=name, script_text=script_text, script_path=script_path) + root = rec.start() + + if hasattr(ctx, "attach_recorder"): + ctx.attach_recorder(rec) + else: + ctx.recorder = rec + if hasattr(ctx, "logger"): + try: + ctx.logger.info(f"[REC] start -> {root}") + except Exception: + pass + if hasattr(ctx, "set_var"): + try: + ctx.set_var("record_dir", str(root)) + except Exception: + pass + return str(root) + + +class RecordStopAction(DslActionBase): + def __init__(self) -> None: + super().__init__( + name="record_stop", + schema={ + "allow_extra": False, + }, + ) + + def execute(self, ctx, args: Dict[str, Any]): + rec = getattr(ctx, "recorder", None) + if rec is None: + return None + vars_snapshot = None + if hasattr(ctx, "vars_snapshot"): + try: + vars_snapshot = ctx.vars_snapshot() + except Exception: + vars_snapshot = None + try: + rec.close(vars_snapshot=vars_snapshot) + finally: + if hasattr(ctx, "detach_recorder"): + ctx.detach_recorder() + else: + ctx.recorder = None + if hasattr(ctx, "logger"): + try: + ctx.logger.info(f"[REC] stop -> {rec.paths.root}") + except Exception: + pass + return str(rec.paths.root) + + +def register_record_actions() -> None: + ActionRegistry.register("record_start", RecordStartAction()) + ActionRegistry.register("record_stop", RecordStopAction()) diff --git a/dsl_runtime/actions/registry.py b/dsl_runtime/actions/registry.py new file mode 100644 index 0000000..0e58ee2 --- /dev/null +++ b/dsl_runtime/actions/registry.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Dict + +from dsl_runtime.actions.base import DslActionBase + + +class ActionRegistry: + actions: Dict[str, DslActionBase] = {} + + @classmethod + def register(cls, name: str, action: DslActionBase) -> None: + if not isinstance(action, DslActionBase): + raise TypeError("action must be a DslActionBase") + cls.actions[name] = action + + @classmethod + def get(cls, name: str) -> DslActionBase: + if name not in cls.actions: + raise KeyError(f"action not registered: {name}") + return cls.actions[name] diff --git a/dsl_runtime/engine/__init__.py b/dsl_runtime/engine/__init__.py new file mode 100644 index 0000000..0eae68f --- /dev/null +++ b/dsl_runtime/engine/__init__.py @@ -0,0 +1 @@ +# DSL runtime engine package. diff --git a/runtime/channels.py b/dsl_runtime/engine/channels.py similarity index 100% rename from runtime/channels.py rename to dsl_runtime/engine/channels.py diff --git a/runtime/chart_runtime.py b/dsl_runtime/engine/chart_runtime.py similarity index 100% rename from runtime/chart_runtime.py rename to dsl_runtime/engine/chart_runtime.py diff --git a/runtime/context.py b/dsl_runtime/engine/context.py similarity index 94% rename from runtime/context.py rename to dsl_runtime/engine/context.py index c3ccd3f..70945a3 100644 --- a/runtime/context.py +++ b/dsl_runtime/engine/context.py @@ -4,9 +4,9 @@ import queue from typing import Any, Dict, Optional -from actions.registry import ActionRegistry -from dsl.expression import eval_expr -from runtime.experiment_recorder import ExperimentRecorder, JsonlLogHandler +from dsl_runtime.actions.registry import ActionRegistry +from dsl_runtime.lang.expression import eval_expr +from dsl_runtime.engine.experiment_recorder import ExperimentRecorder, JsonlLogHandler class RuntimeContext: @@ -64,7 +64,8 @@ def eval_value(self, value: Any) -> Any: return value def run_action(self, name: str, args: Dict[str, Any]) -> Any: - fn = ActionRegistry.get(name) + action = ActionRegistry.get(name) + runner = action.run recorder_before = self._recorder if recorder_before: if name == "record_stop": @@ -72,9 +73,9 @@ def run_action(self, name: str, args: Dict[str, Any]) -> Any: recorder_before.record_action(name=name, args=args or {}, result={"event": "stop"}) except Exception: pass - return fn(self, args or {}) + return runner(self, args or {}) try: - result = fn(self, args or {}) + result = runner(self, args or {}) recorder_before.record_action(name=name, args=args or {}, result=result) return result except Exception as exc: @@ -83,7 +84,7 @@ def run_action(self, name: str, args: Dict[str, Any]) -> Any: # Allow record_start to be tracked after it attaches a recorder. try: - result = fn(self, args or {}) + result = runner(self, args or {}) except Exception as exc: recorder_after = self._recorder if recorder_after: diff --git a/runtime/experiment_recorder.py b/dsl_runtime/engine/experiment_recorder.py similarity index 100% rename from runtime/experiment_recorder.py rename to dsl_runtime/engine/experiment_recorder.py diff --git a/runtime/runner.py b/dsl_runtime/engine/runner.py similarity index 59% rename from runtime/runner.py rename to dsl_runtime/engine/runner.py index 08322e1..53cb945 100644 --- a/runtime/runner.py +++ b/dsl_runtime/engine/runner.py @@ -2,16 +2,16 @@ import logging -from actions.builtin_actions import register_builtin_actions -from actions.protocol_actions import register_protocol_actions -from actions.schema_protocol import register_schema_protocol_actions -from actions.chart_actions import register_chart_actions -from actions.record_actions import register_record_actions -from actions.data_actions import register_data_actions -from dsl.executor import StateMachineExecutor -from dsl.parser import parse_script -from runtime.channels import build_channels -from runtime.context import RuntimeContext +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.engine.channels import build_channels +from dsl_runtime.engine.context import RuntimeContext def _register_actions() -> None: diff --git a/dsl_runtime/lang/__init__.py b/dsl_runtime/lang/__init__.py new file mode 100644 index 0000000..7f1ebd1 --- /dev/null +++ b/dsl_runtime/lang/__init__.py @@ -0,0 +1 @@ +# DSL language package. diff --git a/dsl/ast_nodes.py b/dsl_runtime/lang/ast_nodes.py similarity index 100% rename from dsl/ast_nodes.py rename to dsl_runtime/lang/ast_nodes.py diff --git a/dsl/executor.py b/dsl_runtime/lang/executor.py similarity index 94% rename from dsl/executor.py rename to dsl_runtime/lang/executor.py index 0e41fb0..deaf528 100644 --- a/dsl/executor.py +++ b/dsl_runtime/lang/executor.py @@ -3,9 +3,9 @@ import time from typing import Optional -from dsl.ast_nodes import ScriptAST, State -from dsl.expression import eval_expr -from runtime.context import RuntimeContext +from dsl_runtime.lang.ast_nodes import ScriptAST, State +from dsl_runtime.lang.expression import eval_expr +from dsl_runtime.engine.context import RuntimeContext class StateMachineExecutor: diff --git a/dsl/expression.py b/dsl_runtime/lang/expression.py similarity index 100% rename from dsl/expression.py rename to dsl_runtime/lang/expression.py diff --git a/dsl/parser.py b/dsl_runtime/lang/parser.py similarity index 99% rename from dsl/parser.py rename to dsl_runtime/lang/parser.py index ced903d..e165894 100644 --- a/dsl/parser.py +++ b/dsl_runtime/lang/parser.py @@ -4,7 +4,7 @@ import yaml -from dsl.ast_nodes import ( +from dsl_runtime.lang.ast_nodes import ( ActionCall, ChartSpec, ControlActionSpec, diff --git a/dsl/state_machine.py b/dsl_runtime/lang/state_machine.py similarity index 79% rename from dsl/state_machine.py rename to dsl_runtime/lang/state_machine.py index 86716ce..5bc3f89 100644 --- a/dsl/state_machine.py +++ b/dsl_runtime/lang/state_machine.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Dict -from dsl.ast_nodes import State +from dsl_runtime.lang.ast_nodes import State @dataclass diff --git a/infra/__init__.py b/infra/__init__.py new file mode 100644 index 0000000..b5eeb83 --- /dev/null +++ b/infra/__init__.py @@ -0,0 +1 @@ +# Infrastructure root package. diff --git a/infra/comm/__init__.py b/infra/comm/__init__.py new file mode 100644 index 0000000..a705860 --- /dev/null +++ b/infra/comm/__init__.py @@ -0,0 +1 @@ +# Communication infrastructure package. diff --git a/core/communication_manager.py b/infra/comm/communication_manager.py similarity index 96% rename from core/communication_manager.py rename to infra/comm/communication_manager.py index 358b399..ffcc9e8 100644 --- a/core/communication_manager.py +++ b/infra/comm/communication_manager.py @@ -4,9 +4,9 @@ from typing import Optional, Union -from core.event_bus import EventBus -from core.serial_manager import SerialManager -from core.tcp_session import TcpSession +from infra.common.event_bus import EventBus +from infra.comm.serial_manager import SerialManager +from infra.comm.tcp_session import TcpSession SessionType = Union[SerialManager, TcpSession] diff --git a/core/serial_manager.py b/infra/comm/serial_manager.py similarity index 99% rename from core/serial_manager.py rename to infra/comm/serial_manager.py index 680120c..40de9b9 100644 --- a/core/serial_manager.py +++ b/infra/comm/serial_manager.py @@ -10,7 +10,7 @@ from serial import SerialException from serial.tools import list_ports -from core.event_bus import EventBus +from infra.common.event_bus import EventBus class SerialManager: diff --git a/core/tcp_session.py b/infra/comm/tcp_session.py similarity index 98% rename from core/tcp_session.py rename to infra/comm/tcp_session.py index 8bf9a81..478b100 100644 --- a/core/tcp_session.py +++ b/infra/comm/tcp_session.py @@ -7,7 +7,7 @@ import time from typing import Optional -from core.event_bus import EventBus +from infra.common.event_bus import EventBus class TcpSession: diff --git a/infra/common/__init__.py b/infra/common/__init__.py new file mode 100644 index 0000000..88d6fbd --- /dev/null +++ b/infra/common/__init__.py @@ -0,0 +1 @@ +# Common infrastructure package. diff --git a/core/event_bus.py b/infra/common/event_bus.py similarity index 100% rename from core/event_bus.py rename to infra/common/event_bus.py diff --git a/utils/__init__.py b/infra/common/utils/__init__.py similarity index 100% rename from utils/__init__.py rename to infra/common/utils/__init__.py diff --git a/utils/crc16.py b/infra/common/utils/crc16.py similarity index 100% rename from utils/crc16.py rename to infra/common/utils/crc16.py diff --git a/utils/file_utils.py b/infra/common/utils/file_utils.py similarity index 100% rename from utils/file_utils.py rename to infra/common/utils/file_utils.py diff --git a/utils/lrc.py b/infra/common/utils/lrc.py similarity index 100% rename from utils/lrc.py rename to infra/common/utils/lrc.py diff --git a/utils/path_utils.py b/infra/common/utils/path_utils.py similarity index 100% rename from utils/path_utils.py rename to infra/common/utils/path_utils.py diff --git a/infra/protocol/__init__.py b/infra/protocol/__init__.py new file mode 100644 index 0000000..151b9e5 --- /dev/null +++ b/infra/protocol/__init__.py @@ -0,0 +1,4 @@ +from infra.protocol.registry import ProtocolRegistry # noqa: F401 +from infra.protocol.base import BaseProtocol # noqa: F401 + +# 具体协议实现会在各自模块内完成注册 diff --git a/protocols/at.py b/infra/protocol/at.py similarity index 95% rename from protocols/at.py rename to infra/protocol/at.py index 18ef924..9d36d1a 100644 --- a/protocols/at.py +++ b/infra/protocol/at.py @@ -3,8 +3,8 @@ import time from typing import List -from protocols.base import BaseProtocol -from protocols.registry import ProtocolRegistry +from infra.protocol.base import BaseProtocol +from infra.protocol.registry import ProtocolRegistry class ATProtocol(BaseProtocol): diff --git a/protocols/base.py b/infra/protocol/base.py similarity index 100% rename from protocols/base.py rename to infra/protocol/base.py diff --git a/protocols/modbus_ascii.py b/infra/protocol/modbus_ascii.py similarity index 94% rename from protocols/modbus_ascii.py rename to infra/protocol/modbus_ascii.py index 91c53d0..bc93d67 100644 --- a/protocols/modbus_ascii.py +++ b/infra/protocol/modbus_ascii.py @@ -2,9 +2,9 @@ import time -from protocols.modbus_base import ModbusBase -from protocols.registry import ProtocolRegistry -from utils.lrc import lrc_modbus_ascii +from infra.protocol.modbus_base import ModbusBase +from infra.protocol.registry import ProtocolRegistry +from infra.common.utils.lrc import lrc_modbus_ascii class ModbusASCII(ModbusBase): diff --git a/protocols/modbus_base.py b/infra/protocol/modbus_base.py similarity index 99% rename from protocols/modbus_base.py rename to infra/protocol/modbus_base.py index a9a2d69..1351f6a 100644 --- a/protocols/modbus_base.py +++ b/infra/protocol/modbus_base.py @@ -2,7 +2,7 @@ from typing import Iterable, List, Sequence -from protocols.base import BaseProtocol +from infra.protocol.base import BaseProtocol class ModbusBase(BaseProtocol): diff --git a/protocols/modbus_rtu.py b/infra/protocol/modbus_rtu.py similarity index 95% rename from protocols/modbus_rtu.py rename to infra/protocol/modbus_rtu.py index 781eb9e..711ddf2 100644 --- a/protocols/modbus_rtu.py +++ b/infra/protocol/modbus_rtu.py @@ -3,9 +3,9 @@ import time from typing import Optional -from protocols.modbus_base import ModbusBase -from protocols.registry import ProtocolRegistry -from utils.crc16 import crc16_modbus +from infra.protocol.modbus_base import ModbusBase +from infra.protocol.registry import ProtocolRegistry +from infra.common.utils.crc16 import crc16_modbus class ModbusRTU(ModbusBase): diff --git a/protocols/modbus_tcp.py b/infra/protocol/modbus_tcp.py similarity index 95% rename from protocols/modbus_tcp.py rename to infra/protocol/modbus_tcp.py index 467023d..ca8037e 100644 --- a/protocols/modbus_tcp.py +++ b/infra/protocol/modbus_tcp.py @@ -3,8 +3,8 @@ import itertools import time -from protocols.modbus_base import ModbusBase -from protocols.registry import ProtocolRegistry +from infra.protocol.modbus_base import ModbusBase +from infra.protocol.registry import ProtocolRegistry class ModbusTCP(ModbusBase): diff --git a/core/protocol_loader.py b/infra/protocol/protocol_loader.py similarity index 98% rename from core/protocol_loader.py rename to infra/protocol/protocol_loader.py index 2d69b6e..dcac738 100644 --- a/core/protocol_loader.py +++ b/infra/protocol/protocol_loader.py @@ -13,8 +13,8 @@ import yaml -from core.event_bus import EventBus -from utils.path_utils import resolve_resource_path +from infra.common.event_bus import EventBus +from infra.common.utils.path_utils import resolve_resource_path def crc16_modbus(data: bytes) -> int: diff --git a/protocols/registry.py b/infra/protocol/registry.py similarity index 93% rename from protocols/registry.py rename to infra/protocol/registry.py index 4918251..157536d 100644 --- a/protocols/registry.py +++ b/infra/protocol/registry.py @@ -2,7 +2,7 @@ from typing import Dict, Type -from protocols.base import BaseProtocol +from infra.protocol.base import BaseProtocol class ProtocolRegistry: diff --git a/protocols/schema_runtime.py b/infra/protocol/schema_runtime.py similarity index 98% rename from protocols/schema_runtime.py rename to infra/protocol/schema_runtime.py index 4c42d07..f2d4ae2 100644 --- a/protocols/schema_runtime.py +++ b/infra/protocol/schema_runtime.py @@ -7,8 +7,8 @@ import yaml -from utils.crc16 import crc16_modbus -from utils.path_utils import resolve_resource_path +from infra.common.utils.crc16 import crc16_modbus +from infra.common.utils.path_utils import resolve_resource_path def _hex_to_bytes(value: str | bytes | None) -> bytes: diff --git a/protocols/scpi.py b/infra/protocol/scpi.py similarity index 97% rename from protocols/scpi.py rename to infra/protocol/scpi.py index 7754e2d..ed98d35 100644 --- a/protocols/scpi.py +++ b/infra/protocol/scpi.py @@ -3,8 +3,8 @@ import time from typing import Any, Dict -from protocols.base import BaseProtocol -from protocols.registry import ProtocolRegistry +from infra.protocol.base import BaseProtocol +from infra.protocol.registry import ProtocolRegistry class SCPIProtocol(BaseProtocol): diff --git a/protocols/xmodem.py b/infra/protocol/xmodem.py similarity index 95% rename from protocols/xmodem.py rename to infra/protocol/xmodem.py index f72867e..ee57c7b 100644 --- a/protocols/xmodem.py +++ b/infra/protocol/xmodem.py @@ -3,9 +3,9 @@ import time from pathlib import Path -from protocols.base import BaseProtocol -from protocols.registry import ProtocolRegistry -from utils.crc16 import crc16_xmodem +from infra.protocol.base import BaseProtocol +from infra.protocol.registry import ProtocolRegistry +from infra.common.utils.crc16 import crc16_xmodem SOH = 0x01 diff --git a/protocols/ymodem.py b/infra/protocol/ymodem.py similarity index 95% rename from protocols/ymodem.py rename to infra/protocol/ymodem.py index e9123be..0a3ff44 100644 --- a/protocols/ymodem.py +++ b/infra/protocol/ymodem.py @@ -3,9 +3,9 @@ import time from pathlib import Path -from protocols.base import BaseProtocol -from protocols.registry import ProtocolRegistry -from utils.crc16 import crc16_xmodem +from infra.protocol.base import BaseProtocol +from infra.protocol.registry import ProtocolRegistry +from infra.common.utils.crc16 import crc16_xmodem SOH = 0x01 diff --git a/main.py b/main.py index 6fd836c..f653d1c 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ from __future__ import annotations -from main_web import main +from app.main_web import main if __name__ == "__main__": diff --git a/main_runtime.py b/main_runtime.py deleted file mode 100644 index 1378ed8..0000000 --- a/main_runtime.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import annotations - -import argparse -import logging -import socket -import sys -import time -from typing import Any, Dict - -import yaml - -from actions import at_command, modbus_request, scpi_command, xmodem_send, ymodem_send -from protocols import at, modbus_ascii, modbus_rtu, modbus_tcp, scpi, xmodem, ymodem # noqa: F401 触发注册 -from utils.path_utils import resolve_resource_path - -try: - import serial -except ImportError: # pragma: no cover - serial = None - - -class BaseChannel: - def write(self, data: bytes | str) -> None: # pragma: no cover - 接口 - raise NotImplementedError() - - def read(self, size: int = 1, timeout: float = 1.0) -> bytes: # pragma: no cover - 接口 - raise NotImplementedError() - - def read_until(self, terminator: bytes, timeout: float = 1.0) -> bytes: - buf = bytearray() - deadline = time.time() + timeout - while time.time() < deadline: - chunk = self.read(1, timeout=max(0.01, deadline - time.time())) - if chunk: - buf.extend(chunk) - if buf.endswith(terminator): - break - else: - time.sleep(0.01) - return bytes(buf) - - def close(self) -> None: # pragma: no cover - 接口 - pass - - -class SerialChannel(BaseChannel): - def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0) -> None: - if serial is None: - raise ImportError("pyserial 未安装,无法创建串口通道") - self.ser = serial.Serial(port=port, baudrate=baudrate, timeout=0) - self._timeout = timeout - - def write(self, data: bytes | str) -> None: - payload = data.encode() if isinstance(data, str) else data - self.ser.write(payload) - - def read(self, size: int = 1, timeout: float = 1.0) -> bytes: - deadline = time.time() + 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.01) - return bytes(buf) - - def close(self) -> None: - try: - self.ser.close() - except Exception: - pass - - -class TcpChannel(BaseChannel): - def __init__(self, host: str, port: int, timeout: float = 2.0) -> None: - self.sock = socket.create_connection((host, port), timeout=timeout) - self.sock.settimeout(0.5) - - def write(self, data: bytes | str) -> None: - payload = data.encode() if isinstance(data, str) else data - self.sock.sendall(payload) - - def read(self, size: int = 1, timeout: float = 1.0) -> bytes: - deadline = time.time() + timeout - buf = bytearray() - while len(buf) < size and time.time() < deadline: - try: - chunk = self.sock.recv(size - len(buf)) - if chunk: - buf.extend(chunk) - else: - time.sleep(0.01) - except socket.timeout: - continue - return bytes(buf) - - def close(self) -> None: - try: - self.sock.close() - except Exception: - pass - - -ACTIONS = { - "at_command": at_command.run, - "modbus_request": modbus_request.run, - "scpi_command": scpi_command.run, - "xmodem_send": xmodem_send.run, - "ymodem_send": ymodem_send.run, -} - - -def load_yaml(path: Path) -> Dict[str, Any]: - with path.open("r", encoding="utf-8") as f: - return yaml.safe_load(f) or {} - - -def build_channels(config: Dict[str, Any]) -> Dict[str, BaseChannel]: - channels_cfg = config.get("channels") or config.get("app", {}).get("channels", {}) - channels: Dict[str, BaseChannel] = {} - for name, cfg in channels_cfg.items(): - ctype = str(cfg.get("type", "serial")).lower() - if ctype == "serial": - channels[name] = SerialChannel( - port=cfg["port"], - baudrate=int(cfg.get("baudrate", 115200)), - timeout=float(cfg.get("timeout", 1.0)), - ) - elif ctype == "tcp": - channels[name] = TcpChannel( - host=cfg["host"], - port=int(cfg["port"]), - timeout=float(cfg.get("timeout", 2.0)), - ) - else: - raise ValueError(f"不支持的通道类型: {ctype}") - return channels - - -def run_tasks(tasks: list[Dict[str, Any]], channels: Dict[str, BaseChannel], logger: logging.Logger) -> None: - for idx, task in enumerate(tasks): - action = task.get("action") - runner = ACTIONS.get(action) - if not runner: - raise ValueError(f"未知 action: {action}") - logger.info(f"执行任务 {idx + 1}/{len(tasks)}: {action}") - result = runner(task, channels, logger) - logger.info(f"任务完成 {action}: {result}") - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="ProtoFlow 通信运行时") - parser.add_argument( - "-c", "--config", default="config/app.yaml", help="任务 YAML 路径,默认 config/app.yaml" - ) - args = parser.parse_args(argv) - - logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") - logger = logging.getLogger("runtime") - - config_path = resolve_resource_path(args.config) - if not config_path.exists(): - logger.error(f"配置不存在: {config_path}") - return 2 - - config = load_yaml(config_path) - tasks = config.get("tasks") or config.get("app", {}).get("tasks", []) - if not tasks: - logger.warning("未在 YAML 中找到 tasks,退出") - return 0 - - channels: Dict[str, BaseChannel] = {} - try: - channels = build_channels(config) - run_tasks(tasks, channels, logger) - return 0 - except Exception as exc: - logger.exception("运行时异常: %s", exc) - return 1 - finally: - for ch in channels.values(): - try: - ch.close() - except Exception: - pass - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/protocols/__init__.py b/protocols/__init__.py deleted file mode 100644 index 6784aa7..0000000 --- a/protocols/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from protocols.registry import ProtocolRegistry # noqa: F401 -from protocols.base import BaseProtocol # noqa: F401 - -# 具体协议实现会在各自模块内完成注册 diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 index 61f8a5e..6f857c4 100644 --- a/scripts/build_windows.ps1 +++ b/scripts/build_windows.ps1 @@ -6,7 +6,7 @@ param( $ErrorActionPreference = "Stop" Write-Host "==> Build web UI" -Push-Location "frontend" +Push-Location "ui\\frontend" & $Node ci & $Node run build Pop-Location @@ -17,13 +17,13 @@ Write-Host "==> Install Python deps" Write-Host "==> Build app (PyInstaller)" & $Python -m PyInstaller --name ProtoFlow --windowed --onedir --noconfirm --icon "installer\ProtoFlow.ico" ` - --add-data "frontend\dist;frontend\dist" ` + --add-data "ui\\frontend\\dist;ui\\frontend\\dist" ` --add-data "config;config" ` --add-data "plugins;plugins" ` - --add-data "assets;assets" ` + --add-data "ui\\assets;ui\\assets" ` main.py Write-Host "==> Generate installer icon" if (-not (Test-Path -LiteralPath "installer\\ProtoFlow.ico")) { - & powershell -File scripts\generate_icon.ps1 -Source assets\\icons\\logo.png -Output installer\\ProtoFlow.ico -Size 256 + & powershell -File scripts\generate_icon.ps1 -Source ui\\assets\\icons\\logo.png -Output installer\\ProtoFlow.ico -Size 256 } diff --git a/scripts/generate_icon.ps1 b/scripts/generate_icon.ps1 index 779721e..bcfc514 100644 --- a/scripts/generate_icon.ps1 +++ b/scripts/generate_icon.ps1 @@ -1,5 +1,5 @@ param( - [string]$Source = "assets/icons/logo.png", + [string]$Source = "ui/assets/icons/logo.png", [string]$Output = "installer/ProtoFlow.ico", [int]$Size = 256 ) diff --git a/src/com_tool.py b/src/com_tool.py deleted file mode 100644 index 908aa89..0000000 --- a/src/com_tool.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Simple COM helper script for quick experiments. - -Usage example: - python src/com_tool.py --prog-id "Your.ProgID" --method "Ping" --args "hello" "world" -""" - -from __future__ import annotations - -import argparse -import os -import sys -from typing import Any, Sequence - -import pythoncom -import win32com.client -from dotenv import load_dotenv - - -def parse_args(argv: Sequence[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Call a method on a COM component.") - parser.add_argument( - "--prog-id", - dest="prog_id", - default=os.getenv("COM_PROG_ID"), - help="ProgID or CLSID of the COM component (env: COM_PROG_ID).", - ) - parser.add_argument( - "--method", - required=False, - help="Method name to invoke on the COM object.", - ) - parser.add_argument( - "--args", - nargs="*", - default=[], - help="Positional arguments passed to the COM method.", - ) - parser.add_argument( - "--visible", - action="store_true", - default=os.getenv("COM_VISIBLE") in {"1", "true", "True"}, - help="If the COM object supports a Visible property, set it to True.", - ) - return parser.parse_args(argv) - - -def create_com_instance(prog_id: str): - # CoInitialize/CoUninitialize must be paired; caller handles teardown. - return win32com.client.Dispatch(prog_id) - - -def invoke_method(target: Any, method: str | None, args: Sequence[str]) -> Any: - if not method: - return None - if not hasattr(target, method): - raise AttributeError(f"COM object has no method '{method}'") - callable_attr = getattr(target, method) - return callable_attr(*args) - - -def main(argv: Sequence[str] | None = None) -> int: - load_dotenv() - args = parse_args(argv or sys.argv[1:]) - - if not args.prog_id: - print("Error: --prog-id is required (or set COM_PROG_ID in .env).", file=sys.stderr) - return 2 - - pythoncom.CoInitialize() - try: - com_obj = create_com_instance(args.prog_id) - if args.visible and hasattr(com_obj, "Visible"): - try: - com_obj.Visible = True - except Exception: - pass # Some COM servers expose Visible but reject writes. - - result = invoke_method(com_obj, args.method, args.args) - if args.method: - print(f"[OK] {args.prog_id}.{args.method} -> {result!r}") - else: - print(f"[OK] Connected to {args.prog_id}, no method invoked.") - return 0 - except Exception as exc: - print(f"[ERROR] {exc}", file=sys.stderr) - return 1 - finally: - pythoncom.CoUninitialize() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e6a7566 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1 @@ +# UI package. diff --git a/assets/icons/logo.png b/ui/assets/icons/logo.png similarity index 100% rename from assets/icons/logo.png rename to ui/assets/icons/logo.png diff --git a/assets/icons/logo.svg b/ui/assets/icons/logo.svg similarity index 100% rename from assets/icons/logo.svg rename to ui/assets/icons/logo.svg diff --git a/assets/web/index.html b/ui/assets/web/index.html similarity index 100% rename from assets/web/index.html rename to ui/assets/web/index.html diff --git a/ui/desktop/__init__.py b/ui/desktop/__init__.py new file mode 100644 index 0000000..992022e --- /dev/null +++ b/ui/desktop/__init__.py @@ -0,0 +1 @@ +# Desktop UI package. diff --git a/desktop/script_runner_qt.py b/ui/desktop/script_runner_qt.py similarity index 89% rename from desktop/script_runner_qt.py rename to ui/desktop/script_runner_qt.py index 08d7bd6..cb08b42 100644 --- a/desktop/script_runner_qt.py +++ b/ui/desktop/script_runner_qt.py @@ -8,16 +8,16 @@ from PySide6.QtCore import QThread, Signal -from actions.builtin_actions import register_builtin_actions -from actions.protocol_actions import register_protocol_actions -from actions.schema_protocol import register_schema_protocol_actions -from actions.chart_actions import register_chart_actions -from actions.record_actions import register_record_actions -from actions.data_actions import register_data_actions -from dsl.executor import StateMachineExecutor -from dsl.parser import parse_script -from runtime.channels import build_channels -from runtime.context import RuntimeContext +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.engine.channels import build_channels +from dsl_runtime.engine.context import RuntimeContext class _LogHandler(logging.Handler): diff --git a/desktop/web_bridge.py b/ui/desktop/web_bridge.py similarity index 99% rename from desktop/web_bridge.py rename to ui/desktop/web_bridge.py index 7ffce9d..f32cbf2 100644 --- a/desktop/web_bridge.py +++ b/ui/desktop/web_bridge.py @@ -19,9 +19,9 @@ 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 protocols.registry import ProtocolRegistry -import protocols as protocols_pkg -from desktop.script_runner_qt import ScriptRunnerQt +from infra.protocol.registry import ProtocolRegistry +import infra.protocol as protocols_pkg +from ui.desktop.script_runner_qt import ScriptRunnerQt class WebBridge(QObject): diff --git a/desktop/web_window.py b/ui/desktop/web_window.py similarity index 99% rename from desktop/web_window.py rename to ui/desktop/web_window.py index 0411157..e31a618 100644 --- a/desktop/web_window.py +++ b/ui/desktop/web_window.py @@ -20,8 +20,8 @@ from PyQt6.QtWidgets import QFileDialog, QMainWindow, QMenu # type: ignore from PyQt6.QtWebEngineWidgets import QWebEngineView # type: ignore -from desktop.web_bridge import WebBridge -from desktop.win_snap import apply_snap_styles +from ui.desktop.web_bridge import WebBridge +from ui.desktop.win_snap import apply_snap_styles class LoggingWebPage(QWebEnginePage): def javaScriptConsoleMessage(self, level, message, line_number, source_id): # type: ignore[override] diff --git a/desktop/widgets/__init__.py b/ui/desktop/widgets/__init__.py similarity index 100% rename from desktop/widgets/__init__.py rename to ui/desktop/widgets/__init__.py diff --git a/desktop/win_snap.py b/ui/desktop/win_snap.py similarity index 100% rename from desktop/win_snap.py rename to ui/desktop/win_snap.py diff --git a/frontend/.gitignore b/ui/frontend/.gitignore similarity index 100% rename from frontend/.gitignore rename to ui/frontend/.gitignore diff --git a/frontend/.vscode/extensions.json b/ui/frontend/.vscode/extensions.json similarity index 100% rename from frontend/.vscode/extensions.json rename to ui/frontend/.vscode/extensions.json diff --git a/frontend/README.md b/ui/frontend/README.md similarity index 100% rename from frontend/README.md rename to ui/frontend/README.md diff --git a/frontend/config/ui_settings.json b/ui/frontend/config/ui_settings.json similarity index 78% rename from frontend/config/ui_settings.json rename to ui/frontend/config/ui_settings.json index c977230..ed7bddc 100644 --- a/frontend/config/ui_settings.json +++ b/ui/frontend/config/ui_settings.json @@ -1,6 +1,6 @@ { "autoConnectOnStart": false, - "dslWorkspacePath": "D:\\GitRepository\\ProtoFlow\\frontend\\workflows", + "dslWorkspacePath": "D:\\GitRepository\\ProtoFlow\\ui\\frontend\\workflows", "network": { "tcpHeartbeatSec": 60, "tcpRetryCount": 3, @@ -13,4 +13,4 @@ }, "uiLanguage": "English (US)", "uiTheme": "绯荤粺榛樿" -} \ No newline at end of file +} diff --git a/frontend/index.html b/ui/frontend/index.html similarity index 100% rename from frontend/index.html rename to ui/frontend/index.html diff --git a/frontend/package-lock.json b/ui/frontend/package-lock.json similarity index 100% rename from frontend/package-lock.json rename to ui/frontend/package-lock.json diff --git a/frontend/package.json b/ui/frontend/package.json similarity index 100% rename from frontend/package.json rename to ui/frontend/package.json diff --git a/frontend/postcss.config.js b/ui/frontend/postcss.config.js similarity index 100% rename from frontend/postcss.config.js rename to ui/frontend/postcss.config.js diff --git a/frontend/public/vite.svg b/ui/frontend/public/vite.svg similarity index 100% rename from frontend/public/vite.svg rename to ui/frontend/public/vite.svg diff --git a/frontend/src/App.vue b/ui/frontend/src/App.vue similarity index 100% rename from frontend/src/App.vue rename to ui/frontend/src/App.vue diff --git a/frontend/src/assets/fonts/MaterialSymbolsOutlined.ttf b/ui/frontend/src/assets/fonts/MaterialSymbolsOutlined.ttf similarity index 100% rename from frontend/src/assets/fonts/MaterialSymbolsOutlined.ttf rename to ui/frontend/src/assets/fonts/MaterialSymbolsOutlined.ttf diff --git a/frontend/src/assets/vue.svg b/ui/frontend/src/assets/vue.svg similarity index 100% rename from frontend/src/assets/vue.svg rename to ui/frontend/src/assets/vue.svg diff --git a/frontend/src/components/DropdownSelect.vue b/ui/frontend/src/components/DropdownSelect.vue similarity index 100% rename from frontend/src/components/DropdownSelect.vue rename to ui/frontend/src/components/DropdownSelect.vue diff --git a/frontend/src/components/HelloWorld.vue b/ui/frontend/src/components/HelloWorld.vue similarity index 100% rename from frontend/src/components/HelloWorld.vue rename to ui/frontend/src/components/HelloWorld.vue diff --git a/frontend/src/components/LogStream.vue b/ui/frontend/src/components/LogStream.vue similarity index 100% rename from frontend/src/components/LogStream.vue rename to ui/frontend/src/components/LogStream.vue diff --git a/frontend/src/components/ManualView.vue b/ui/frontend/src/components/ManualView.vue similarity index 100% rename from frontend/src/components/ManualView.vue rename to ui/frontend/src/components/ManualView.vue diff --git a/frontend/src/components/ProxyMonitorView.vue b/ui/frontend/src/components/ProxyMonitorView.vue similarity index 100% rename from frontend/src/components/ProxyMonitorView.vue rename to ui/frontend/src/components/ProxyMonitorView.vue diff --git a/frontend/src/components/ScriptsView.vue b/ui/frontend/src/components/ScriptsView.vue similarity index 100% rename from frontend/src/components/ScriptsView.vue rename to ui/frontend/src/components/ScriptsView.vue diff --git a/frontend/src/components/YamlUiLab.vue b/ui/frontend/src/components/YamlUiLab.vue similarity index 100% rename from frontend/src/components/YamlUiLab.vue rename to ui/frontend/src/components/YamlUiLab.vue diff --git a/frontend/src/components/ui-kit/AppShell.vue b/ui/frontend/src/components/ui-kit/AppShell.vue similarity index 100% rename from frontend/src/components/ui-kit/AppShell.vue rename to ui/frontend/src/components/ui-kit/AppShell.vue diff --git a/frontend/src/components/ui-kit/EventLogPanel.vue b/ui/frontend/src/components/ui-kit/EventLogPanel.vue similarity index 100% rename from frontend/src/components/ui-kit/EventLogPanel.vue rename to ui/frontend/src/components/ui-kit/EventLogPanel.vue diff --git a/frontend/src/components/ui-kit/InspectorJSON.vue b/ui/frontend/src/components/ui-kit/InspectorJSON.vue similarity index 100% rename from frontend/src/components/ui-kit/InspectorJSON.vue rename to ui/frontend/src/components/ui-kit/InspectorJSON.vue diff --git a/frontend/src/components/ui-kit/PanelCard.vue b/ui/frontend/src/components/ui-kit/PanelCard.vue similarity index 100% rename from frontend/src/components/ui-kit/PanelCard.vue rename to ui/frontend/src/components/ui-kit/PanelCard.vue diff --git a/frontend/src/components/ui-kit/SplitContainer.vue b/ui/frontend/src/components/ui-kit/SplitContainer.vue similarity index 100% rename from frontend/src/components/ui-kit/SplitContainer.vue rename to ui/frontend/src/components/ui-kit/SplitContainer.vue diff --git a/frontend/src/components/ui-kit/TopBar.vue b/ui/frontend/src/components/ui-kit/TopBar.vue similarity index 100% rename from frontend/src/components/ui-kit/TopBar.vue rename to ui/frontend/src/components/ui-kit/TopBar.vue diff --git a/frontend/src/components/ui-kit/actions/BatchActionGroup.vue b/ui/frontend/src/components/ui-kit/actions/BatchActionGroup.vue similarity index 100% rename from frontend/src/components/ui-kit/actions/BatchActionGroup.vue rename to ui/frontend/src/components/ui-kit/actions/BatchActionGroup.vue diff --git a/frontend/src/components/ui-kit/actions/ConfirmDangerButton.vue b/ui/frontend/src/components/ui-kit/actions/ConfirmDangerButton.vue similarity index 100% rename from frontend/src/components/ui-kit/actions/ConfirmDangerButton.vue rename to ui/frontend/src/components/ui-kit/actions/ConfirmDangerButton.vue diff --git a/frontend/src/components/ui-kit/actions/DropdownAction.vue b/ui/frontend/src/components/ui-kit/actions/DropdownAction.vue similarity index 100% rename from frontend/src/components/ui-kit/actions/DropdownAction.vue rename to ui/frontend/src/components/ui-kit/actions/DropdownAction.vue diff --git a/frontend/src/components/ui-kit/actions/GhostButton.vue b/ui/frontend/src/components/ui-kit/actions/GhostButton.vue similarity index 100% rename from frontend/src/components/ui-kit/actions/GhostButton.vue rename to ui/frontend/src/components/ui-kit/actions/GhostButton.vue diff --git a/frontend/src/components/ui-kit/actions/PrimaryButton.vue b/ui/frontend/src/components/ui-kit/actions/PrimaryButton.vue similarity index 100% rename from frontend/src/components/ui-kit/actions/PrimaryButton.vue rename to ui/frontend/src/components/ui-kit/actions/PrimaryButton.vue diff --git a/frontend/src/components/ui-kit/actions/SecondaryButton.vue b/ui/frontend/src/components/ui-kit/actions/SecondaryButton.vue similarity index 100% rename from frontend/src/components/ui-kit/actions/SecondaryButton.vue rename to ui/frontend/src/components/ui-kit/actions/SecondaryButton.vue diff --git a/frontend/src/components/ui-kit/inputs/CheckboxGroup.vue b/ui/frontend/src/components/ui-kit/inputs/CheckboxGroup.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/CheckboxGroup.vue rename to ui/frontend/src/components/ui-kit/inputs/CheckboxGroup.vue diff --git a/frontend/src/components/ui-kit/inputs/ColorInput.vue b/ui/frontend/src/components/ui-kit/inputs/ColorInput.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/ColorInput.vue rename to ui/frontend/src/components/ui-kit/inputs/ColorInput.vue diff --git a/frontend/src/components/ui-kit/inputs/DateTimeInput.vue b/ui/frontend/src/components/ui-kit/inputs/DateTimeInput.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/DateTimeInput.vue rename to ui/frontend/src/components/ui-kit/inputs/DateTimeInput.vue diff --git a/frontend/src/components/ui-kit/inputs/KeybindRecorder.vue b/ui/frontend/src/components/ui-kit/inputs/KeybindRecorder.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/KeybindRecorder.vue rename to ui/frontend/src/components/ui-kit/inputs/KeybindRecorder.vue diff --git a/frontend/src/components/ui-kit/inputs/MultiSelectChips.vue b/ui/frontend/src/components/ui-kit/inputs/MultiSelectChips.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/MultiSelectChips.vue rename to ui/frontend/src/components/ui-kit/inputs/MultiSelectChips.vue diff --git a/frontend/src/components/ui-kit/inputs/NumberInput.vue b/ui/frontend/src/components/ui-kit/inputs/NumberInput.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/NumberInput.vue rename to ui/frontend/src/components/ui-kit/inputs/NumberInput.vue diff --git a/frontend/src/components/ui-kit/inputs/PathPicker.vue b/ui/frontend/src/components/ui-kit/inputs/PathPicker.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/PathPicker.vue rename to ui/frontend/src/components/ui-kit/inputs/PathPicker.vue diff --git a/frontend/src/components/ui-kit/inputs/RadioGroup.vue b/ui/frontend/src/components/ui-kit/inputs/RadioGroup.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/RadioGroup.vue rename to ui/frontend/src/components/ui-kit/inputs/RadioGroup.vue diff --git a/frontend/src/components/ui-kit/inputs/SelectInput.vue b/ui/frontend/src/components/ui-kit/inputs/SelectInput.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/SelectInput.vue rename to ui/frontend/src/components/ui-kit/inputs/SelectInput.vue diff --git a/frontend/src/components/ui-kit/inputs/SliderInput.vue b/ui/frontend/src/components/ui-kit/inputs/SliderInput.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/SliderInput.vue rename to ui/frontend/src/components/ui-kit/inputs/SliderInput.vue diff --git a/frontend/src/components/ui-kit/inputs/SwitchInput.vue b/ui/frontend/src/components/ui-kit/inputs/SwitchInput.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/SwitchInput.vue rename to ui/frontend/src/components/ui-kit/inputs/SwitchInput.vue diff --git a/frontend/src/components/ui-kit/inputs/TextInput.vue b/ui/frontend/src/components/ui-kit/inputs/TextInput.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/TextInput.vue rename to ui/frontend/src/components/ui-kit/inputs/TextInput.vue diff --git a/frontend/src/components/ui-kit/inputs/TextareaInput.vue b/ui/frontend/src/components/ui-kit/inputs/TextareaInput.vue similarity index 100% rename from frontend/src/components/ui-kit/inputs/TextareaInput.vue rename to ui/frontend/src/components/ui-kit/inputs/TextareaInput.vue diff --git a/frontend/src/main.js b/ui/frontend/src/main.js similarity index 100% rename from frontend/src/main.js rename to ui/frontend/src/main.js diff --git a/frontend/src/stores/uiRuntime.ts b/ui/frontend/src/stores/uiRuntime.ts similarity index 100% rename from frontend/src/stores/uiRuntime.ts rename to ui/frontend/src/stores/uiRuntime.ts diff --git a/frontend/src/style.css b/ui/frontend/src/style.css similarity index 100% rename from frontend/src/style.css rename to ui/frontend/src/style.css diff --git a/frontend/src/ui/LayoutRenderer.vue b/ui/frontend/src/ui/LayoutRenderer.vue similarity index 100% rename from frontend/src/ui/LayoutRenderer.vue rename to ui/frontend/src/ui/LayoutRenderer.vue diff --git a/frontend/src/ui/UnknownWidget.vue b/ui/frontend/src/ui/UnknownWidget.vue similarity index 100% rename from frontend/src/ui/UnknownWidget.vue rename to ui/frontend/src/ui/UnknownWidget.vue diff --git a/frontend/src/ui/registry.ts b/ui/frontend/src/ui/registry.ts similarity index 100% rename from frontend/src/ui/registry.ts rename to ui/frontend/src/ui/registry.ts diff --git a/frontend/src/ui/schema.ts b/ui/frontend/src/ui/schema.ts similarity index 100% rename from frontend/src/ui/schema.ts rename to ui/frontend/src/ui/schema.ts diff --git a/frontend/src/ui/yaml.ts b/ui/frontend/src/ui/yaml.ts similarity index 100% rename from frontend/src/ui/yaml.ts rename to ui/frontend/src/ui/yaml.ts diff --git a/frontend/tailwind.config.js b/ui/frontend/tailwind.config.js similarity index 100% rename from frontend/tailwind.config.js rename to ui/frontend/tailwind.config.js diff --git a/frontend/vite.config.js b/ui/frontend/vite.config.js similarity index 100% rename from frontend/vite.config.js rename to ui/frontend/vite.config.js