from __future__ import annotations import argparse import sys from dataclasses import dataclass from pathlib import Path from typing import Any, List, Optional from ruamel.yaml import YAML from cobot import ui _PROJECT_DIR = Path(__file__).parent.parent.parent _CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml" _TOOLS_YAML = _PROJECT_DIR / "src" / "iiwa_config" / "config" / "tools.yaml" _TOOL_ACTIVE_XACRO = ( _PROJECT_DIR / "src" / "iiwa_description" / "urdf" / "tools" / "tool_active.xacro" ) _SRDF_PATH = _PROJECT_DIR / "src" / "iiwa_config" / "config" / "moveit" / "iiwa7.srdf" # Use ruamel.yaml instead of PyYAML so comments and formatting in the config are preserved. # Используем ruamel.yaml вместо PyYAML, чтобы комментарии и форматирование в конфиге сохранялись. _yaml = YAML() _yaml.preserve_quotes = True @dataclass class _Field: key: str # dot-separated path within the block, e.g. "webots.world" question: str default: Any note: str = "" options: Optional[List[str]] = None # if set - select(), else - text() def label(self) -> str: return self.key.split(".")[-1] @dataclass class _Block: yaml_key: str # top-level key in cobot-setting.yaml title: str # shown in "Настроить ?" prompt fields: List[_Field] def _load_tools_registry() -> dict: """Читает tools.yaml и возвращает словарь инструментов.""" if not _TOOLS_YAML.exists(): return {} _y = YAML() with open(_TOOLS_YAML, encoding="utf-8") as f: data = _y.load(f) return dict(data.get("tools", {})) def _build_tool_block() -> Optional[_Block]: """Строит блок выбора инструмента из реестра tools.yaml. Возвращает None если реестр недоступен.""" registry = _load_tools_registry() if not registry: return None options = list(registry.keys()) labels = " | ".join( f"{name}: {registry[name].get('label', '')}" for name in options ) return _Block( yaml_key="tool", title="Инструмент / Захват", fields=[ _Field("active", "Выберите активный инструмент:", options[0], note=labels, options=options), ], ) # All configuration blocks. Each block maps to a top-level key in cobot-setting.yaml. # Все блоки конфигурации. Каждый блок соответствует ключу верхнего уровня в cobot-setting.yaml. _BLOCKS: List[_Block] = [ _Block( yaml_key="foxglove", title="Foxglove bridge", fields=[ _Field("enabled", "Включить Foxglove bridge?", "true", note="Запускать foxglove_bridge вместе с узлом робота", options=["true", "false"]), _Field("port", "Порт WebSocket:", "8765", note="Порт, к которому подключается Foxglove Studio (по умолчанию 8765)"), _Field("address", "Адрес прослушивания:", "0.0.0.0", note="0.0.0.0 = все интерфейсы, 127.0.0.1 = только localhost", options=["0.0.0.0", "127.0.0.1"]), _Field("use_sim_time", "Использовать симуляционное время (/clock)?", "false", note="Подписываться на /clock вместо системного времени", options=["false", "true"]), _Field("debug", "Подробное логирование bridge?", "false", options=["false", "true"]), _Field("num_threads", "Потоки executor (0 = авто):", "0"), ], ), _Block( yaml_key="web", title="Веб-сервер (FastAPI)", fields=[ _Field("enabled", "Включить веб-сервер?", "true", note="Запускать FastAPI-сервер для HTTP/WebSocket-управления", options=["true", "false"]), _Field("host", "Адрес прослушивания:", "0.0.0.0", note="0.0.0.0 = все интерфейсы, 127.0.0.1 = только localhost", options=["0.0.0.0", "127.0.0.1"]), _Field("port", "Порт HTTP:", "8007", note="Порт FastAPI-сервера (по умолчанию 8007)"), _Field("token", "Токен Bearer для REST API и MCP:", "", note="Длинный секрет. Хранится в cobot-setting.yaml и нужен при внешнем host; пустое значение отключает авторизацию"), ], ), _Block( yaml_key="planning", title="MoveIt планирование", fields=[ _Field("planning_group", "Группа планирования:", "iiwa_arm", note="Группа планирования MoveIt из SRDF"), _Field("default_frame", "Система отсчёта по умолчанию:", "base_link"), _Field("default_planner", "Планировщик по умолчанию:", "ompl", options=["ompl", "pilz_industrial_motion_planner", "chomp"]), _Field("planning_attempts", "Попыток планирования:", "3"), ], ), _Block( yaml_key="digital_twin", title="Цифровой двойник (Webots / RViz)", fields=[ _Field("webots.transform", "Трансформ робота в сцене Webots (x y z, метры):", "-0.25 0 0.79"), _Field("webots.rotation", "Поворот робота в сцене Webots (ax ay az угол):", "0 0 1 0"), _Field("webots.controller_timer", "Шаг таймера контроллера Webots (мс):", "50"), ], ), _Block( yaml_key="robot", title="Подключение робота", fields=[ _Field("name", "Имя модели робота:", "iiwa7"), _Field("ip", "IP-адрес робота:", "192.170.10.2", note="IP контроллера KUKA на сетевом интерфейсе FRI"), _Field("port", "Порт FRI:", "30200"), _Field("fri_cycle_ms", "Цикл FRI (мс):", "10", note="5 мс = 200 Гц, 10 мс = 100 Гц", options=["10", "5"]), _Field("active_controller", "Активный ROS-контроллер:", "jtc", note="jtc = JointTrajectoryController (MoveIt), forward = ForwardCommandController", options=["jtc", "forward"]), _Field("joint_position_tau", "EMA tau фильтра положения (с):", "0.04", note="Сглаживает команды положения перед отправкой в FRI"), _Field("joint_velocity_tau", "EMA tau фильтра скорости (с):", "0.01", note="Убирает выбросы из оценки скорости конечной разностью"), ], ), ] def _coerce(value: str, original: Any) -> Any: """Convert a string value to match the type of the original YAML value. Преобразует строковое значение к типу исходного значения YAML. """ if isinstance(original, bool): return value.lower() == "true" if isinstance(original, int): try: return int(value) except ValueError: return value if isinstance(original, float): try: return float(value) except ValueError: return value return value def _get_nested(mapping: Any, path: str) -> Any: """Return the value at a dot-separated path inside a nested YAML mapping, or None. Возвращает значение по пути с точками внутри вложенного YAML-словаря, или None. """ keys = path.split(".") cur = mapping for k in keys: if cur is None or k not in cur: return None cur = cur[k] return cur def _infer(value: str) -> Any: """Infer a bool / int / float / str from a raw string when there is no original value to match the type against (i.e. the key is new in the config). Выводит bool / int / float / str из строки, когда нет исходного значения для сопоставления типа (т.е. ключ новый в конфиге). """ low = value.strip().lower() if low in ("true", "false"): return low == "true" try: return int(value) except ValueError: pass try: return float(value) except ValueError: pass return value def _set_nested(mapping: Any, path: str, value: Any) -> None: """Set the value at a dot-separated path, creating missing intermediate maps. If the leaf key already exists its type is preserved via _coerce; otherwise the type is inferred from the string with _infer. This makes the wizard tolerant of configs that do not yet contain every field (e.g. an older cobot-setting.yaml). Устанавливает значение по пути с точками, создавая отсутствующие промежуточные словари. Если конечный ключ уже есть — тип сохраняется через _coerce; иначе тип выводится из строки через _infer. Это делает мастер устойчивым к конфигам, где ещё нет всех полей (например, более старый cobot-setting.yaml). """ keys = path.split(".") cur = mapping for k in keys[:-1]: if k not in cur or cur[k] is None: cur[k] = {} cur = cur[k] leaf = keys[-1] if leaf in cur and cur[leaf] is not None: cur[leaf] = _coerce(value, cur[leaf]) else: cur[leaf] = _infer(value) def _load_config() -> Any: """Load cobot-setting.yaml preserving comments and key order. Загружает cobot-setting.yaml, сохраняя комментарии и порядок ключей. """ with open(_CONFIG_PATH, "r", encoding="utf-8") as fh: return _yaml.load(fh) def _save_config(data: Any) -> None: """Write the modified YAML back to cobot-setting.yaml, preserving comments. Записывает изменённый YAML обратно в cobot-setting.yaml, сохраняя комментарии. """ with open(_CONFIG_PATH, "w", encoding="utf-8") as fh: _yaml.dump(data, fh) def _run_wizard(data: Any, blocks: List[_Block]) -> bool: """Walk every block: ask "Configure X?", and if yes step through its fields. Returns True if the user completed the wizard (config was saved), False on cancel. Проходит по каждому блоку: спрашивает "Настроить X?", и если да — проходит по полям. Возвращает True если мастер завершён (конфиг сохранён), False при отмене. """ total = len(blocks) for bi, block in enumerate(blocks): ui.header(f"Блок {bi + 1}/{total}", block.title) if not ui.confirm(f"Настроить {block.title}?", default=True): continue for fi, f in enumerate(block.fields): yaml_val = _get_nested(data[block.yaml_key], f.key) current = str(yaml_val) if yaml_val is not None else str(f.default) step_note = f"Поле {fi + 1}/{len(block.fields)}" note = f"{step_note}\n{f.note}" if f.note else step_note if f.options: default_opt = current if current in f.options else f.options[0] value = ui.select(f.question, f.options, default_opt, note=note) else: value = ui.text(f.question, current, note=note) if value is None: ui.info("[yellow]Отменено.[/yellow]") return False _set_nested(data[block.yaml_key], f.key, value) _save_config(data) ui.done(True, f"Конфигурация сохранена в {_CONFIG_PATH.name}") return True def register(subparsers: argparse._SubParsersAction) -> None: p = subparsers.add_parser("robot-setup", help="Configure cobot-setting.yaml interactively") p.set_defaults(func=run) def run(args: argparse.Namespace) -> None: ui.header("Настройка робота", "cobot-setting.yaml") if not _CONFIG_PATH.exists(): ui.error(f"Конфиг не найден: {_CONFIG_PATH}") sys.exit(1) data = _load_config() # Убеждаемся что секция tool существует в данных (для старых конфигов) if "tool" not in data: data["tool"] = {"active": "patron"} tool_block = _build_tool_block() blocks = ([tool_block] if tool_block else []) + list(_BLOCKS) if not _run_wizard(data, blocks): return # Применяем выбранный инструмент: перезаписываем tool_active.xacro и iiwa7.srdf active_tool = str(data["tool"].get("active", "patron")) if not _TOOLS_YAML.exists(): ui.info(f"[yellow]tools.yaml не найден ({_TOOLS_YAML}) — пропуск применения инструмента[/yellow]") return try: registry = _load_tools_registry() if active_tool not in registry: raise ValueError(f"Неизвестный инструмент '{active_tool}'. Доступны: {', '.join(registry)}") tool_cfg = dict(registry[active_tool]) sys.path.insert(0, str(_PROJECT_DIR / "src" / "iiwa_utils")) from iiwa_utils.tool_manager import apply_tool apply_tool(tool_cfg=tool_cfg, xacro_out_path=_TOOL_ACTIVE_XACRO, srdf_path=_SRDF_PATH) # Синхронизируем planning.pose_link с tcp_link выбранного инструмента tcp_link = tool_cfg.get("tcp_link", "link_ee") if "planning" in data: data["planning"]["pose_link"] = tcp_link _save_config(data) ui.info( f"[green]✓[/green] Инструмент [bold]{active_tool}[/bold] применён: " f"tool_active.xacro, iiwa7.srdf обновлены, " f"planning.pose_link → [bold]{tcp_link}[/bold]." ) except Exception as exc: ui.error(f"Не удалось применить инструмент: {exc}")