diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..30162b3 Binary files /dev/null and b/.DS_Store differ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index c5f0764..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,105 +0,0 @@ -# CLAUDE.md - EtherCAT TaskEditor 项目指南 - -## 项目概述 - -EtherCAT TaskEditor 是一个基于 Vue 2 的 Web 应用,用于配置 EtherCAT 从站模块并生成对应的 `config.yaml` 配置文件。该配置文件供 [EcatV2_Master](https://github.com/AIMEtherCAT/EcatV2_Master) 使用。 - -## 技术栈 - -- **框架**: Vue 2.6 (Options API) -- **UI 库**: Element UI 2.x -- **构建工具**: Vue CLI 4.5 -- **包管理**: npm / yarn -- **其他依赖**: echarts, three.js, urdf-loader, xacro-parser, lodash, axios - -## 开发命令 - -```bash -npm install # 安装依赖 -npm run serve # 启动开发服务器 (需要 --openssl-legacy-provider) -npm run build # 构建生产版本 -npm run lint # 代码检查 -``` - -注意:由于 Node.js 版本兼容性问题,`serve` 和 `build` 脚本中设置了 `NODE_OPTIONS=--openssl-legacy-provider`。 - -## 项目结构 - -``` -src/ -├── App.vue # 根组件,包含两个 Tab 页 -├── main.js # 入口文件,注册 Element UI -├── pages/ -│ ├── module_settings.vue # 模块配置页面(主要交互页面) -│ └── code_generator.vue # YAML 生成与下载页面 -├── components/ -│ ├── CanSelector.vue # CAN 总线实例选择器 -│ ├── ConnectionLostActionSelector.vue # 连接丢失动作选择器 -│ ├── ControlPeriodInput.vue # 控制周期输入 -│ ├── HexInput.vue # 十六进制输入框 -│ ├── NumberInput.vue # 通用数字输入框 -│ ├── PortSelector.vue # 端口选择器 -│ ├── Ros2TopicNameInput.vue # ROS2 Topic 名称输入 -│ └── message_types/ # ROS2 消息定义展示组件 -│ ├── Read*.vue # 读消息类型(从站→主站) -│ └── Write*.vue # 写消息类型(主站→从站) -└── utils/ - └── generate-module-def.js # 核心 YAML 生成逻辑 -``` - -## 核心架构 - -### 数据流 - -1. 用户在 `module_settings.vue` 中添加模块和任务,数据保存在 `localStorage` 的 `modules_info` 键中 -2. 切换到 `code_generator.vue` Tab 时,读取 `localStorage` 中的配置 -3. `generate-module-def.js` 中的 `generateModuleDef()` 函数计算 PDO/SDO 偏移量并生成 YAML 文本 -4. 用户可下载完整的 `config.yaml` 文件 - -### 模块类型 (Module Types) - -| Type ID | 名称 | PDO 限制 (TX/RX) | -|---------|------|-------------------| -| 0x03 | H750 Universal Module | 80 / 80 bytes | -| 0x04 | H750 Universal Module (Large PDO V.) | 112 / 80 bytes | - -### 任务类型 (Task/App Types) - -| Type ID | 名称 | 方向 | 说明 | -|---------|------|------|------| -| 0x01 | DJI RC | Read | DJI 遥控器 | -| 0x02 | LkTech Motor | Read+Write | 力矩电机(多种控制模式) | -| 0x03 | HIPNUC IMU | Read | CAN 总线 IMU | -| 0x04 | DSHOT600 | Write | ESC 电调协议 | -| 0x05 | DJI Motor | Read+Write | 大疆电机(最多 4 个,含 PID 配置) | -| 0x06 | OnBoard PWM | Write | 板载 PWM | -| 0x07 | External PWM | Write | 外置 PWM 板 | -| 0x08 | MS5837(30BA) | Read | I2C 压力传感器 | -| 0x0A | PMU(CAN) | Read | CAN 电源管理单元 | -| 0x0B | SBUS RC | Read | SBUS 遥控器 | -| 0x0C | DM Motor | Read+Write | 达妙电机 | -| 0x0D | Super Capacitor | Read+Write | 超级电容 | - -## 编码约定 - -- 使用 Vue 2 Options API(`data()`, `methods`, `watch`, `mounted`) -- 组件通过 `localStorage` 共享状态,未使用 Vuex -- 任务类型使用十六进制 ID 标识(如 `0x01`, `0x05`) -- 任务数据模板定义在 `module_settings.vue` 的 `examples` 对象中,新增任务类型需在此添加默认配置 -- `generate-module-def.js` 中的 `switch(task_info.type)` 是 YAML 生成的核心分发逻辑,新增任务类型需在此添加生成分支 -- ROS2 消息展示组件放在 `src/components/message_types/` 目录下 - -## 新增任务类型的步骤 - -1. 在 `module_settings.vue` 的 `examples` 中添加任务默认数据模板 -2. 在 `module_settings.vue` 的 `getAppTypeFriendlyName()` 中添加友好名称 -3. 在 `module_settings.vue` 的模板中添加该任务类型的配置表单 -4. 在 `src/components/message_types/` 中创建对应的 ROS2 消息展示组件 -5. 在 `generate-module-def.js` 的 `switch` 中添加该类型的 YAML 生成逻辑(包括 PDO 偏移计算) -6. 在 `code_generator.vue` 中验证 PDO 长度是否溢出 - -## 部署 - -- `vue.config.js` 中配置了 `publicPath: '/TaskEditor/'` -- 构建产物在 `dist/` 目录 -- 应用版本号在 `App.vue` 中标注(当前为 v2.1) diff --git a/README.md b/README.md index 2d3018d..e85086e 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,27 @@ This project is a Vue-based web application designed for configuring EtherCAT mo * **Module Management**: Add and manage multiple EtherCAT slave modules by Serial Number (SN). * **Task Assignment**: Assign various hardware tasks to each module, including: - * **Remote Controllers**: DJI RC, SBUS RC. - * **Sensors**: HIPNUC IMU (CAN), SUPER CAP (CAN). - * **Actuators**: DJI Motors, DM Motors, LkTech Motors, DSHOT600, OnBoard PWM. + * **Remote Controllers**: DJI RC, SBUS RC, VT13 RC. + * **Sensors**: HIPNUC IMU (CAN), CAN PMU (CAN), SUPER CAP (CAN), MS5837. + * **Actuators**: DJI Motors, DM Motors, DD Motors, LkTech Motors, DSHOT600, OnBoard PWM, External PWM. * **Task Configuration**: Modify the configuration items of each task. +* **Import Configuration**: Upload a previously generated `config.yaml` to re-edit it in the editor. +* **Configuration Validation**: Built-in verification script to validate config files for type correctness and structural integrity. * **ROS2 Integration**: Customize publisher and subscriber topic names for seamless communication with ROS2. * **Message Schema Visualization**: View the ROS2 message structure directly within the task configuration. +* **Syntax Highlighting**: YAML output with syntax highlighting powered by highlight.js. * **Automatic YAML Generation**: Generates a complete `config.yaml` file that can be used by the [EcatV2_Master](https://github.com/AIMEtherCAT/EcatV2_Master). ## Project Structure -* `src/pages/`: Contains the main application views (`module_settings.vue` for configuration and `code_generator.vue` for file generation). +* `src/pages/`: Main application views (`ModuleSettings.vue` for configuration and `CodeGenerator.vue` for file generation). * `src/components/`: Reusable UI components. -* `src/components/message_types/`: Vue templates representing ROS2 message definitions. -* `src/utils/generate-module-def.js`: The core logic that calculates memory offsets (PDO/SDO) and formats the YAML output. +* `src/components/message-types/`: Vue templates representing ROS2 message definitions. +* `src/components/module-settings/`: Module settings page specific components (e.g., `ImportConfigDialog.vue`). +* `src/components/code-generator/`: Code generator page specific components. +* `src/utils/generate-module-def.js`: Core logic that calculates memory offsets (PDO/SDO) and formats the YAML output. +* `src/utils/parse-config.js`: Parses a `config.yaml` back into the editor data structure for re-editing. +* `src/utils/verify-config.js`: Validates configuration files with type checks and structural verification. +* `scripts/verify-config.js`: Standalone script for validating config YAML files. ## Getting Started diff --git a/config_sentry_vt13.yaml b/config_sentry_vt13.yaml new file mode 100644 index 0000000..6ed394c --- /dev/null +++ b/config_sentry_vt13.yaml @@ -0,0 +1,128 @@ +slaves: + - sn4653115: + sdo_len: !uint16_t 151 + task_count: !uint8_t 4 + latency_pub_topic: !std::string "/ecat/sn4653115/latency" + + tasks: + - app_1: + sdowrite_task_type: !uint8_t 3 + conf_connection_lost_read_action: !uint8_t 0x01 + pub_topic: !std::string "/ecat/sn4653115/app2/read" + pdoread_offset: !uint16_t 0 + sdowrite_can_inst: !uint8_t 2 + sdowrite_packet1_id: !uint32_t 0x01 + sdowrite_packet2_id: !uint32_t 0x02 + sdowrite_packet3_id: !uint32_t 0x03 + conf_frame_name: !std::string "imu_link" + + - app_2: + sdowrite_task_type: !uint8_t 5 + conf_connection_lost_read_action: !uint8_t 0x01 + sdowrite_connection_lost_write_action: !uint8_t 0x02 + pub_topic: !std::string "/ecat/sn4653115/app3/read" + pdoread_offset: !uint16_t 21 + sub_topic: !std::string "/ecat/sn4653115/app3/write" + pdowrite_offset: !uint16_t 0 + sdowrite_control_period: !uint16_t 1 + sdowrite_can_packet_id: !uint32_t 0x200 + sdowrite_motor1_can_id: !uint32_t 0x201 + sdowrite_motor2_can_id: !uint32_t 0x202 + sdowrite_motor3_can_id: !uint32_t 0x203 + sdowrite_motor4_can_id: !uint32_t 0 + sdowrite_can_inst: !uint8_t 1 + sdowrite_motor1_control_type: !uint8_t 2 + sdowrite_motor1_speed_pid_kp: !float 13.5 + sdowrite_motor1_speed_pid_ki: !float 1 + sdowrite_motor1_speed_pid_kd: !float 0 + sdowrite_motor1_speed_pid_max_out: !float 16384 + sdowrite_motor1_speed_pid_max_iout: !float 2000 + sdowrite_motor2_control_type: !uint8_t 2 + sdowrite_motor2_speed_pid_kp: !float 13.5 + sdowrite_motor2_speed_pid_ki: !float 1 + sdowrite_motor2_speed_pid_kd: !float 0 + sdowrite_motor2_speed_pid_max_out: !float 16384 + sdowrite_motor2_speed_pid_max_iout: !float 2000 + sdowrite_motor3_control_type: !uint8_t 1 + + - app_3: + sdowrite_task_type: !uint8_t 5 + conf_connection_lost_read_action: !uint8_t 0x01 + sdowrite_connection_lost_write_action: !uint8_t 0x02 + pub_topic: !std::string "/ecat/sn4653115/app4/read" + pdoread_offset: !uint16_t 48 + sub_topic: !std::string "/ecat/sn4653115/app4/write" + pdowrite_offset: !uint16_t 9 + sdowrite_control_period: !uint16_t 1 + sdowrite_can_packet_id: !uint32_t 0x1ff + sdowrite_motor1_can_id: !uint32_t 0 + sdowrite_motor2_can_id: !uint32_t 0 + sdowrite_motor3_can_id: !uint32_t 0 + sdowrite_motor4_can_id: !uint32_t 0x208 + sdowrite_can_inst: !uint8_t 1 + sdowrite_motor4_control_type: !uint8_t 3 + sdowrite_motor4_speed_pid_kp: !float 65 + sdowrite_motor4_speed_pid_ki: !float 0.2 + sdowrite_motor4_speed_pid_kd: !float 0 + sdowrite_motor4_speed_pid_max_out: !float 25000 + sdowrite_motor4_speed_pid_max_iout: !float 3000 + sdowrite_motor4_angle_pid_kp: !float 1.5 + sdowrite_motor4_angle_pid_ki: !float 0 + sdowrite_motor4_angle_pid_kd: !float 10.5 + sdowrite_motor4_angle_pid_max_out: !float 1800 + sdowrite_motor4_angle_pid_max_iout: !float 0 + + - app_4: + sdowrite_task_type: !uint8_t 14 + conf_connection_lost_read_action: !uint8_t 0x02 + pub_topic: !std::string "/ecat/sn4653115/vt13/read" + pdoread_offset: !uint16_t 57 + + - sn4128829: + sdo_len: !uint16_t 11 + task_count: !uint8_t 1 + latency_pub_topic: !std::string "/ecat/sn4128829/latency" + + tasks: + - app_1: + sdowrite_task_type: !uint8_t 2 + conf_connection_lost_read_action: !uint8_t 0x01 + sdowrite_connection_lost_write_action: !uint8_t 0x02 + pub_topic: !std::string "/ecat/sn4128829/app1/read" + pdoread_offset: !uint16_t 0 + sub_topic: !std::string "/ecat/sn4128829/app1/write" + pdowrite_offset: !uint16_t 0 + sdowrite_control_period: !uint16_t 1 + sdowrite_can_packet_id: !uint32_t 0x141 + sdowrite_can_inst: !uint8_t 1 + sdowrite_control_type: !uint8_t 2 + + - sn2228292: + sdo_len: !uint16_t 13 + task_count: !uint8_t 2 + latency_pub_topic: !std::string "/ecat/sn2228292/latency" + + tasks: + - app_1: + sdowrite_task_type: !uint8_t 2 + conf_connection_lost_read_action: !uint8_t 0x01 + sdowrite_connection_lost_write_action: !uint8_t 0x02 + pub_topic: !std::string "/ecat/sn2228292/app1/read" + pdoread_offset: !uint16_t 0 + sub_topic: !std::string "/ecat/sn2228292/app1/write" + pdowrite_offset: !uint16_t 0 + sdowrite_control_period: !uint16_t 1 + sdowrite_can_inst: !uint8_t 1 + sdowrite_control_type: !uint8_t 8 + + - app_2: + sdowrite_task_type: !uint8_t 2 + conf_connection_lost_read_action: !uint8_t 0x01 + sdowrite_connection_lost_write_action: !uint8_t 0x02 + pub_topic: !std::string "/ecat/sn2228292/app2/read" + pdoread_offset: !uint16_t 32 + sub_topic: !std::string "/ecat/sn2228292/app2/write" + pdowrite_offset: !uint16_t 8 + sdowrite_control_period: !uint16_t 1 + sdowrite_can_inst: !uint8_t 2 + sdowrite_control_type: !uint8_t 8 diff --git a/package.json b/package.json index 29464a7..417c898 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,14 @@ "name": "EtherCAT_TaskEditor", "version": "0.1.0", "private": true, + "type": "module", "scripts": { "serve": "vite", "build": "vite build", "lint": "eslint --ext .js,.vue src" }, "dependencies": { + "@element-plus/icons-vue": "^2.3.2", "element-plus": "^2.11.2", "highlight.js": "^11.11.1", "vue": "^3.5.22" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5783ce..36eda72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@element-plus/icons-vue': + specifier: ^2.3.2 + version: 2.3.2(vue@3.5.32) element-plus: specifier: ^2.11.2 version: 2.13.7(vue@3.5.32) diff --git a/scripts/verify-config.js b/scripts/verify-config.js new file mode 100644 index 0000000..cf8c3cd --- /dev/null +++ b/scripts/verify-config.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * verify-config.js — CLI entry point for config.yaml verification + * + * Usage: + * node scripts/verify-config.js [--json] [--quiet] + * + * Options: + * --json Output results as JSON (for CI parsing) + * --quiet Only show errors, suppress warnings + * + * Exit code: 0 = valid, 1 = has errors + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +// ─── ANSI colors (zero-dependency) ─────────────────────────────────────────── + +const RESET = '\x1b[0m'; +const RED = '\x1b[31m'; +const YELLOW = '\x1b[33m'; +const GREEN = '\x1b[32m'; +const CYAN = '\x1b[36m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; + +// ─── Inline the verifier (to avoid import resolution issues) ───────────────── +// We import from the shared module using a relative path. + +import { verifyConfig } from '../src/utils/verify-config.js'; + +// ─── Main ──────────────────────────────────────────────────────────────────── + +function main() { + const args = process.argv.slice(2); + const flags = { + json: args.includes('--json'), + quiet: args.includes('--quiet'), + }; + const files = args.filter(a => !a.startsWith('--')); + + if (files.length === 0) { + console.error(`${RED}Error: No file specified${RESET}`); + console.error(`Usage: node scripts/verify-config.js [--json] [--quiet]`); + process.exit(2); + } + + const filePath = resolve(files[0]); + let yamlText; + try { + yamlText = readFileSync(filePath, 'utf-8'); + } catch (e) { + console.error(`${RED}Error: Cannot read file "${filePath}": ${e.message}${RESET}`); + process.exit(2); + } + + const result = verifyConfig(yamlText); + + if (flags.json) { + console.log(JSON.stringify(result, null, 2)); + process.exit(result.valid ? 0 : 1); + } + + // ─── Human-readable output ───────────────────────────────────────────── + + const { valid, errors, warnings, stats } = result; + + console.log(`\n${BOLD}EtherCAT Config Verification${RESET}`); + console.log(`${DIM}File: ${filePath}${RESET}`); + console.log(`${DIM}Modules: ${stats.moduleCount} | Tasks: ${stats.taskCount}${RESET}`); + console.log('─'.repeat(50)); + + if (errors.length > 0) { + console.log(`\n${RED}${BOLD}Errors (${errors.length})${RESET}`); + for (const err of errors) { + const loc = formatLocation(err); + console.log(` ${RED}✗${RESET} ${loc}${err.message}`); + } + } + + if (warnings.length > 0 && !flags.quiet) { + console.log(`\n${YELLOW}${BOLD}Warnings (${warnings.length})${RESET}`); + for (const warn of warnings) { + const loc = formatLocation(warn); + console.log(` ${YELLOW}⚠${RESET} ${loc}${warn.message}`); + } + } + + console.log('─'.repeat(50)); + if (valid) { + console.log(`${GREEN}${BOLD}✓ Configuration is valid${RESET}`); + if (warnings.length > 0 && !flags.quiet) { + console.log(`${YELLOW} (${warnings.length} warning(s))${RESET}`); + } + } else { + console.log(`${RED}${BOLD}✗ Configuration has ${errors.length} error(s)${RESET}`); + } + console.log(); + + process.exit(valid ? 0 : 1); +} + +function formatLocation(issue) { + const parts = []; + if (issue.module) parts.push(`sn${issue.module}`); + if (issue.task) parts.push(issue.task); + const loc = parts.length > 0 ? `[${parts.join('/')}] ` : ''; + const line = issue.line ? `${DIM}L${issue.line}:${RESET} ` : ''; + return `${loc}${line}`; +} + +main(); diff --git a/src/components/module-settings/ImportConfigDialog.vue b/src/components/module-settings/ImportConfigDialog.vue new file mode 100644 index 0000000..603bbd5 --- /dev/null +++ b/src/components/module-settings/ImportConfigDialog.vue @@ -0,0 +1,413 @@ + + + diff --git a/src/pages/ModuleSettings.vue b/src/pages/ModuleSettings.vue index 5210f34..2bc24be 100644 --- a/src/pages/ModuleSettings.vue +++ b/src/pages/ModuleSettings.vue @@ -6,6 +6,12 @@ Module List + + Import Config + + + Verify +
@@ -809,6 +815,7 @@ label="Operations"> @@ -850,11 +857,77 @@
+ + + + +
+ + + + + + + +
+
Errors:
+
+ {{ formatVerifyIssue(err) }} +
+
+ ... and {{ verifyResult.errors.length - 20 }} more errors +
+
+ +
+
Warnings:
+
+ {{ formatVerifyIssue(warn) }} +
+
+ ... and {{ verifyResult.warnings.length - 20 }} more warnings +
+
+
+ +
+ + +

Select target module:

+ + + + +
diff --git a/src/utils/parse-config.js b/src/utils/parse-config.js new file mode 100644 index 0000000..f33112a --- /dev/null +++ b/src/utils/parse-config.js @@ -0,0 +1,456 @@ +/** + * parse-config.js + * Parses a generated config.yaml back into the modules_info data structure + * for re-editing in the TaskEditor. + * + * Two-pass architecture: + * Pass 1 (tokenize): line-by-line extraction of flat key-value pairs + * Pass 2 (map): task-type-specific mappers convert flat data to structured objects + */ + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +const VALID_TAGS = new Set([ + 'uint8_t', 'uint16_t', 'uint32_t', + 'int8_t', 'int16_t', 'int32_t', + 'float', 'std::string', +]); + +const INT_TAGS = new Set([ + 'uint8_t', 'uint16_t', 'uint32_t', + 'int8_t', 'int16_t', 'int32_t', +]); + +function parseTypedValue(rawValue, tag, lineNum, errors) { + if (tag === 'std::string') { + let v = rawValue; + while (v.length >= 2 && + ((v[0] === "'" && v[v.length - 1] === "'") || + (v[0] === '"' && v[v.length - 1] === '"'))) { + v = v.slice(1, -1); + } + return v; + } + if (tag === 'float') { + const num = parseFloat(rawValue); + if (isNaN(num)) { + errors.push({ line: lineNum + 1, message: `Invalid float value: "${rawValue}"` }); + return 0; + } + return num; + } + if (INT_TAGS.has(tag)) { + if (rawValue.startsWith('0x') || rawValue.startsWith('0X')) { + const num = parseInt(rawValue, 16); + if (isNaN(num)) { + errors.push({ line: lineNum + 1, message: `Invalid hex value: "${rawValue}" for !${tag}` }); + return 0; + } + return num; + } + const num = parseInt(rawValue, 10); + if (isNaN(num)) { + errors.push({ line: lineNum + 1, message: `Invalid integer value: "${rawValue}" for !${tag}` }); + return 0; + } + return num; + } + // Unknown tag — should not happen if parsePropertyLine validates + errors.push({ line: lineNum + 1, message: `Unknown type tag: !${tag}` }); + return rawValue; +} + +function parsePropertyLine(trimmed, lineNum, errors) { + const propMatch = trimmed.match(/^([\w]+):\s+!(\S+)\s+(.+)$/); + if (propMatch) { + const tag = propMatch[2]; + if (!VALID_TAGS.has(tag)) { + errors.push({ line: lineNum + 1, message: `Unknown type tag !${tag}, expected one of: ${[...VALID_TAGS].join(', ')}` }); + return null; + } + return { key: propMatch[1], value: parseTypedValue(propMatch[3].trim(), tag, lineNum, errors) }; + } + // Lines without a !type tag are invalid in this config format + const fallback = trimmed.match(/^([\w]+):\s+(.+)$/); + if (fallback) { + errors.push({ line: lineNum + 1, message: `Missing type tag on "${fallback[1]}", expected format: key: !type value` }); + return null; + } + return null; +} + +function stripHexPrefix(value) { + if (value === undefined || value === null) return ''; + if (typeof value === 'number') { + return value.toString(16).padStart(2, '0'); + } + return String(value).replace(/^0x/i, ''); +} + +function defaultPid() { + return { kp: 1, ki: 0, kd: 0, maxout: 10000, maxiout: 1000 }; +} + +function extractPidParams(flat, prefix) { + const pid = defaultPid(); + const yamlFields = ['kp', 'ki', 'kd', 'max_out', 'max_iout']; + const localFields = ['kp', 'ki', 'kd', 'maxout', 'maxiout']; + for (let i = 0; i < yamlFields.length; i++) { + const key = `${prefix}_${yamlFields[i]}`; + if (key in flat) pid[localFields[i]] = flat[key]; + } + return pid; +} + +/** + * Reverse of get_dji_motor_report_id from generate-module-def.js + */ +function reverseDjiMotorId(canPacketId, reportId) { + const ctrlId = typeof canPacketId === 'number' + ? '0x' + canPacketId.toString(16) + : canPacketId; + switch (ctrlId) { + case '0x200': + return reportId - 0x200; + case '0x2ff': + case '0x2fe': + case '0x1fe': + return reportId - 0x204; + case '0x1ff': + if (reportId >= 0x205 && reportId <= 0x208) return reportId - 0x204; + return reportId - 0x200; + default: + return reportId - 0x200; + } +} + +// ─── Pass 1: Tokenize ─────────────────────────────────────────────────────── + +function pass1Tokenize(yamlText) { + const lines = yamlText.split('\n'); + const modules = []; + const errors = []; + let currentModule = null; + let currentTask = null; + let inTasksSection = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trimStart(); + if (trimmed === '' || trimmed.startsWith('#') || trimmed === 'slaves:') continue; + + // Module header: " - sn{N}:" + const moduleMatch = trimmed.match(/^- (sn\d+):$/); + if (moduleMatch) { + currentModule = { sn: moduleMatch[1].replace('sn', ''), latency_topic: '', tasks: [] }; + modules.push(currentModule); + currentTask = null; + inTasksSection = false; + continue; + } + + if (!currentModule) { + errors.push({ line: i + 1, message: `Line outside module: ${trimmed}` }); + continue; + } + + // "tasks:" marks start of tasks section + if (!inTasksSection && trimmed === 'tasks:') { + inTasksSection = true; + continue; + } + + // Module-level properties (before tasks section) + if (!inTasksSection) { + if (trimmed.startsWith('latency_pub_topic:')) { + const parsed = parsePropertyLine(trimmed, i, errors); + if (parsed) currentModule.latency_topic = parsed.value; + } + // sdo_len, task_count are informational — skip + continue; + } + + // Task header: " - app_{N}:" + const taskMatch = trimmed.match(/^- app_\d+:$/); + if (taskMatch) { + currentTask = { _flat: {} }; + currentModule.tasks.push(currentTask); + continue; + } + + if (!currentTask) { + errors.push({ line: i + 1, message: `Property before task header: ${trimmed}` }); + continue; + } + + // Task properties + const parsed = parsePropertyLine(trimmed, i, errors); + if (parsed) { + currentTask._flat[parsed.key] = parsed.value; + } + } + + return { modules, errors }; +} + +// ─── Pass 2: Task-type mappers ────────────────────────────────────────────── + +function mapDjirc(flat) { + return { + type: 0x01, + read_topic: flat['pub_topic'] || '', + connection_lost_read_action: flat['conf_connection_lost_read_action'] ?? 0x01, + }; +} + +function mapVt13(flat) { + return { + type: 14, + read_topic: flat['pub_topic'] || '', + connection_lost_read_action: flat['conf_connection_lost_read_action'] ?? 0x01, + }; +} + +function mapSbusRc(flat) { + return { + type: 11, + read_topic: flat['pub_topic'] || '', + connection_lost_read_action: flat['conf_connection_lost_read_action'] ?? 0x01, + }; +} + +function mapLkMotor(flat) { + const canPacketId = flat['sdowrite_can_packet_id'] ?? 0x141; + return { + type: 0x02, + can_inst: flat['sdowrite_can_inst'] ?? 1, + motor_id: canPacketId - 0x140 || 1, + can_packet_id: canPacketId, + control_period: flat['sdowrite_control_period'] ?? 1, + control_type: flat['sdowrite_control_type'] ?? 0x01, + read_topic: flat['pub_topic'] || '', + write_topic: flat['sub_topic'] || '', + connection_lost_read_action: flat['conf_connection_lost_read_action'] ?? 0x01, + connection_lost_write_action: flat['sdowrite_connection_lost_write_action'] ?? 0x01, + }; +} + +function mapHipnucImu(flat) { + return { + type: 0x03, + read_topic: flat['pub_topic'] || '', + frame_name: flat['conf_frame_name'] || 'imu_link', + can_inst: flat['sdowrite_can_inst'] ?? 1, + packet1_id: stripHexPrefix(flat['sdowrite_packet1_id']), + packet2_id: stripHexPrefix(flat['sdowrite_packet2_id']), + packet3_id: stripHexPrefix(flat['sdowrite_packet3_id']), + connection_lost_read_action: flat['conf_connection_lost_read_action'] ?? 0x01, + }; +} + +function mapDshot(flat) { + return { + type: 0x04, + dshot_id: flat['sdowrite_dshot_id'] ?? 1, + write_topic: flat['sub_topic'] || '', + init_value: flat['sdowrite_init_value'] ?? 0, + connection_lost_write_action: flat['sdowrite_connection_lost_write_action'] ?? 0x01, + }; +} + +function mapDjiMotor(flat) { + const canPacketId = flat['sdowrite_can_packet_id'] ?? 0x200; + const motor_enable = [false, false, false, false]; + const motor_id = [1, 2, 3, 4]; + const motor_control_type = [0x01, 0x01, 0x01, 0x01]; + const motor_speed_pid_param = [defaultPid(), defaultPid(), defaultPid(), defaultPid()]; + const motor_angle_pid_param = [defaultPid(), defaultPid(), defaultPid(), defaultPid()]; + + for (let i = 1; i <= 4; i++) { + const canIdKey = `sdowrite_motor${i}_can_id`; + if (canIdKey in flat && flat[canIdKey] !== 0) { + motor_enable[i - 1] = true; + motor_id[i - 1] = reverseDjiMotorId(canPacketId, flat[canIdKey]); + } + const ctrlKey = `sdowrite_motor${i}_control_type`; + if (ctrlKey in flat) motor_control_type[i - 1] = flat[ctrlKey]; + motor_speed_pid_param[i - 1] = extractPidParams(flat, `sdowrite_motor${i}_speed_pid`); + motor_angle_pid_param[i - 1] = extractPidParams(flat, `sdowrite_motor${i}_angle_pid`); + } + + return { + type: 0x05, + can_inst: flat['sdowrite_can_inst'] ?? 1, + can_packet_id: canPacketId, + control_period: flat['sdowrite_control_period'] ?? 1, + motor_enable, + motor_id, + motor_control_type, + motor_speed_pid_param, + motor_angle_pid_param, + connection_lost_read_action: flat['conf_connection_lost_read_action'] ?? 0x01, + connection_lost_write_action: flat['sdowrite_connection_lost_write_action'] ?? 0x01, + read_topic: flat['pub_topic'] || '', + write_topic: flat['sub_topic'] || '', + }; +} + +function mapOnboardPwm(flat) { + return { + type: 6, + port_id: flat['sdowrite_port_id'] ?? 1, + expected_period: flat['sdowrite_pwm_period'] ?? 0, + init_value: flat['sdowrite_init_value'] ?? 0, + write_topic: flat['sub_topic'] || '', + connection_lost_write_action: flat['sdowrite_connection_lost_write_action'] ?? 0x01, + }; +} + +function mapExternalPwm(flat) { + return { + type: 7, + uart_id: flat['sdowrite_uart_id'] ?? 1, + expected_period: flat['sdowrite_pwm_period'] ?? 0, + enabled_channel_count: flat['sdowrite_channel_num'] ?? 1, + init_value: flat['sdowrite_init_value'] ?? 0, + write_topic: flat['sub_topic'] || '', + connection_lost_write_action: flat['sdowrite_connection_lost_write_action'] ?? 0x01, + }; +} + +function mapMs5837(flat) { + return { + type: 8, + i2c_id: flat['sdowrite_i2c_id'] ?? 3, + osr_id: flat['sdowrite_osr_id'] ?? 1, + read_topic: flat['pub_topic'] || '', + connection_lost_read_action: flat['conf_connection_lost_read_action'] ?? 0x01, + }; +} + +function mapPmu(flat) { + return { + type: 10, + read_topic: flat['pub_topic'] || '', + connection_lost_read_action: flat['conf_connection_lost_read_action'] ?? 0x01, + }; +} + +function mapDmMotor(flat) { + return { + type: 12, + can_inst: flat['sdowrite_can_inst'] ?? 1, + can_id: stripHexPrefix(flat['sdowrite_can_id']), + master_id: stripHexPrefix(flat['sdowrite_master_id']), + control_period: flat['sdowrite_control_period'] ?? 1, + control_type: flat['sdowrite_control_type'] ?? 1, + pmax: flat['conf_pmax'] ?? 3.141592653589793, + vmax: flat['conf_vmax'] ?? 30, + tmax: flat['conf_tmax'] ?? 10, + connection_lost_read_action: flat['conf_connection_lost_read_action'] ?? 0x01, + connection_lost_write_action: flat['sdowrite_connection_lost_write_action'] ?? 0x01, + read_topic: flat['pub_topic'] || '', + write_topic: flat['sub_topic'] || '', + }; +} + +function mapSuperCap(flat) { + return { + type: 13, + can_inst: flat['sdowrite_can_inst'] ?? 1, + chassis_to_cap_id: stripHexPrefix(flat['sdowrite_chassis_to_cap_id']), + cap_to_chassis_id: stripHexPrefix(flat['sdowrite_cap_to_chassis_id']), + connection_lost_read_action: flat['conf_connection_lost_read_action'] ?? 0x01, + connection_lost_write_action: flat['sdowrite_connection_lost_write_action'] ?? 0x01, + read_topic: flat['pub_topic'] || '', + write_topic: flat['sub_topic'] || '', + }; +} + +function mapDdMotor(flat) { + const motor_enable = [false, false, false, false]; + const motor_id = [1, 2, 3, 4]; + const motor_control_type = [0x01, 0x01, 0x01, 0x01]; + + for (let i = 1; i <= 4; i++) { + const canIdKey = `sdowrite_motor${i}_can_id`; + if (canIdKey in flat && flat[canIdKey] !== 0) { + motor_enable[i - 1] = true; + motor_id[i - 1] = flat[canIdKey] - 0x96; + } + const ctrlKey = `sdowrite_motor${i}_control_type`; + if (ctrlKey in flat) motor_control_type[i - 1] = flat[ctrlKey]; + } + + return { + type: 15, + can_inst: flat['sdowrite_can_inst'] ?? 1, + can_type: flat['sdowrite_can_baudrate'] ?? 1, + can_packet_id: flat['sdowrite_can_packet_id'] ?? 0x32, + control_period: flat['sdowrite_control_period'] ?? 1, + motor_enable, + motor_id, + motor_control_type, + connection_lost_read_action: flat['conf_connection_lost_read_action'] ?? 0x01, + connection_lost_write_action: flat['sdowrite_connection_lost_write_action'] ?? 0x01, + read_topic: flat['pub_topic'] || '', + write_topic: flat['sub_topic'] || '', + }; +} + +const TASK_MAPPERS = { + 1: mapDjirc, + 2: mapLkMotor, + 3: mapHipnucImu, + 4: mapDshot, + 5: mapDjiMotor, + 6: mapOnboardPwm, + 7: mapExternalPwm, + 8: mapMs5837, + 10: mapPmu, + 11: mapSbusRc, + 12: mapDmMotor, + 13: mapSuperCap, + 14: mapVt13, + 15: mapDdMotor, +}; + +// ─── Main entry ───────────────────────────────────────────────────────────── + +export function parseConfigYaml(yamlText) { + if (!yamlText || typeof yamlText !== 'string') { + return { modules: [], errors: [{ message: 'Empty or invalid input' }] }; + } + + const { modules: rawModules, errors } = pass1Tokenize(yamlText); + + const modules = rawModules.map(rawMod => { + const tasks = rawMod.tasks.map(rawTask => { + const taskType = rawTask._flat['sdowrite_task_type']; + if (taskType === undefined) { + errors.push({ message: 'Task missing sdowrite_task_type, skipped' }); + return null; + } + const mapper = TASK_MAPPERS[taskType]; + if (!mapper) { + errors.push({ message: `Unknown task type ${taskType}, skipped` }); + return null; + } + try { + return mapper(rawTask._flat); + } catch (e) { + errors.push({ message: `Error parsing task type ${taskType}: ${e.message}` }); + return null; + } + }).filter(Boolean); + + return { + type: 0x03, + sn: rawMod.sn, + latency_topic: rawMod.latency_topic, + task: tasks, + }; + }); + + return { modules, errors }; +} diff --git a/src/utils/verify-config.js b/src/utils/verify-config.js new file mode 100644 index 0000000..a19d5b0 --- /dev/null +++ b/src/utils/verify-config.js @@ -0,0 +1,806 @@ +/** + * verify-config.js + * Validates an EtherCAT config.yaml file against structural and semantic rules. + * + * Shared by CLI (`scripts/verify-config.js`) and Web UI. + * + * Main export: verifyConfig(yamlText) → { valid, errors, warnings, stats } + */ + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const VALID_TAGS = new Set([ + 'uint8_t', 'uint16_t', 'uint32_t', + 'int8_t', 'int16_t', 'int32_t', + 'float', 'std::string', +]); + +const INT_TAGS = new Set([ + 'uint8_t', 'uint16_t', 'uint32_t', + 'int8_t', 'int16_t', 'int32_t', +]); + +const TAG_BYTE_SIZE = { + uint8_t: 1, int8_t: 1, + uint16_t: 2, int16_t: 2, + uint32_t: 4, int32_t: 4, + float: 4, +}; + +const TAG_RANGES = { + uint8_t: [0, 255], + int8_t: [-128, 127], + uint16_t: [0, 65535], + int16_t: [-32768, 32767], + uint32_t: [0, 4294967295], + int32_t: [-2147483648, 2147483647], +}; + +const TASK_TYPE_NAMES = { + 1: 'DJI RC', + 2: 'LkTech Motor', + 3: 'HIPNUC IMU(CAN)', + 4: 'DSHOT600', + 5: 'DJI Motor', + 6: 'OnBoard PWM', + 7: 'External PWM', + 8: 'MS5837(30BA)', + 10: 'PMU(CAN)', + 11: 'SBUS RC', + 12: 'DM Motor', + 13: 'Super Capacitor', + 14: 'DJI VT13', + 15: 'DD Motor', +}; + +const VALID_TASK_TYPES = new Set(Object.keys(TASK_TYPE_NAMES).map(Number)); + +/** + * Required fields per task type (keys = YAML field names) + * Common fields (sdowrite_task_type, pub_topic, sub_topic, pdoread_offset, pdowrite_offset, + * conf_connection_lost_read_action, sdowrite_connection_lost_write_action) are checked separately. + */ +const REQUIRED_FIELDS = { + 1: [], // DJI RC — no extra required fields beyond common + 2: ['sdowrite_control_period', 'sdowrite_can_inst', 'sdowrite_control_type'], + 3: ['sdowrite_can_inst', 'sdowrite_packet1_id', 'sdowrite_packet2_id', 'sdowrite_packet3_id', 'conf_frame_name'], + 4: ['sdowrite_dshot_id', 'sdowrite_init_value'], + 5: ['sdowrite_control_period', 'sdowrite_can_packet_id', 'sdowrite_motor1_can_id', 'sdowrite_motor2_can_id', 'sdowrite_motor3_can_id', 'sdowrite_motor4_can_id', 'sdowrite_can_inst'], + 6: ['sdowrite_port_id', 'sdowrite_pwm_period', 'sdowrite_init_value'], + 7: ['sdowrite_uart_id', 'sdowrite_pwm_period', 'sdowrite_channel_num', 'sdowrite_init_value'], + 8: ['sdowrite_i2c_id', 'sdowrite_osr_id'], + 10: [], + 11: [], + 12: ['sdowrite_control_period', 'sdowrite_can_id', 'sdowrite_master_id', 'sdowrite_can_inst', 'sdowrite_control_type', 'conf_pmax', 'conf_vmax', 'conf_tmax'], + 13: ['sdowrite_can_inst', 'sdowrite_chassis_to_cap_id', 'sdowrite_cap_to_chassis_id'], + 14: [], + 15: ['sdowrite_control_period', 'sdowrite_can_baudrate', 'sdowrite_can_packet_id', 'sdowrite_motor1_can_id', 'sdowrite_motor2_can_id', 'sdowrite_motor3_can_id', 'sdowrite_motor4_can_id', 'sdowrite_can_inst'], +}; + +/** + * All known YAML field names per task type (for unknown-field detection) + */ +const KNOWN_FIELDS = { + 1: ['sdowrite_task_type', 'conf_connection_lost_read_action', 'pub_topic', 'pdoread_offset'], + 2: ['sdowrite_task_type', 'conf_connection_lost_read_action', 'sdowrite_connection_lost_write_action', 'pub_topic', 'pdoread_offset', 'sub_topic', 'pdowrite_offset', 'sdowrite_control_period', 'sdowrite_can_packet_id', 'sdowrite_can_inst', 'sdowrite_control_type'], + 3: ['sdowrite_task_type', 'conf_connection_lost_read_action', 'pub_topic', 'pdoread_offset', 'sdowrite_can_inst', 'sdowrite_packet1_id', 'sdowrite_packet2_id', 'sdowrite_packet3_id', 'conf_frame_name'], + 4: ['sdowrite_task_type', 'sdowrite_connection_lost_write_action', 'sub_topic', 'pdowrite_offset', 'sdowrite_dshot_id', 'sdowrite_init_value'], + 5: ['sdowrite_task_type', 'conf_connection_lost_read_action', 'sdowrite_connection_lost_write_action', 'pub_topic', 'pdoread_offset', 'sub_topic', 'pdowrite_offset', 'sdowrite_control_period', 'sdowrite_can_packet_id', 'sdowrite_can_inst'], + 6: ['sdowrite_task_type', 'sdowrite_connection_lost_write_action', 'sub_topic', 'pdowrite_offset', 'sdowrite_port_id', 'sdowrite_pwm_period', 'sdowrite_init_value'], + 7: ['sdowrite_task_type', 'sdowrite_connection_lost_write_action', 'sub_topic', 'pdowrite_offset', 'sdowrite_uart_id', 'sdowrite_pwm_period', 'sdowrite_channel_num', 'sdowrite_init_value'], + 8: ['sdowrite_task_type', 'conf_connection_lost_read_action', 'pub_topic', 'pdoread_offset', 'sdowrite_i2c_id', 'sdowrite_osr_id'], + 10: ['sdowrite_task_type', 'conf_connection_lost_read_action', 'pub_topic', 'pdoread_offset'], + 11: ['sdowrite_task_type', 'conf_connection_lost_read_action', 'pub_topic', 'pdoread_offset'], + 12: ['sdowrite_task_type', 'conf_connection_lost_read_action', 'sdowrite_connection_lost_write_action', 'pub_topic', 'pdoread_offset', 'sub_topic', 'pdowrite_offset', 'sdowrite_control_period', 'sdowrite_can_id', 'sdowrite_master_id', 'sdowrite_can_inst', 'sdowrite_control_type', 'conf_pmax', 'conf_vmax', 'conf_tmax'], + 13: ['sdowrite_task_type', 'conf_connection_lost_read_action', 'sdowrite_connection_lost_write_action', 'pub_topic', 'pdoread_offset', 'sub_topic', 'pdowrite_offset', 'sdowrite_can_inst', 'sdowrite_chassis_to_cap_id', 'sdowrite_cap_to_chassis_id'], + 14: ['sdowrite_task_type', 'conf_connection_lost_read_action', 'pub_topic', 'pdoread_offset'], + 15: ['sdowrite_task_type', 'conf_connection_lost_read_action', 'sdowrite_connection_lost_write_action', 'pub_topic', 'pdoread_offset', 'sub_topic', 'pdowrite_offset', 'sdowrite_control_period', 'sdowrite_can_baudrate', 'sdowrite_can_packet_id', 'sdowrite_can_inst'], +}; + +// Add motor-specific fields for types 5 and 15 +for (let i = 1; i <= 4; i++) { + [5, 15].forEach(type => { + KNOWN_FIELDS[type].push(`sdowrite_motor${i}_can_id`, `sdowrite_motor${i}_control_type`); + }); + // PID fields for DJI Motor (type 5) + ['speed_pid_kp', 'speed_pid_ki', 'speed_pid_kd', 'speed_pid_max_out', 'speed_pid_max_iout', + 'angle_pid_kp', 'angle_pid_ki', 'angle_pid_kd', 'angle_pid_max_out', 'angle_pid_max_iout'].forEach(suffix => { + KNOWN_FIELDS[5].push(`sdowrite_motor${i}_${suffix}`); + }); +} + +// ─── Tokenize (reuse pass1 from parse-config.js, extended to capture raw lines) ── + +function tokenize(yamlText) { + const lines = yamlText.split('\n'); + const modules = []; + const errors = []; + const warnings = []; + let currentModule = null; + let currentTask = null; + let inTasksSection = false; + let hasSlavesHeader = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trimStart(); + + // Empty lines and comments + if (trimmed === '' || trimmed.startsWith('#')) continue; + + // slaves: header + if (i === 0 || (!hasSlavesHeader && trimmed === 'slaves:')) { + if (trimmed === 'slaves:') { + hasSlavesHeader = true; + continue; + } + } + + // Module header: " - sn{N}:" + const moduleMatch = trimmed.match(/^- (sn(\d+)):/); + if (moduleMatch) { + if (!hasSlavesHeader) { + errors.push({ line: i + 1, code: 'MISSING_HEADER', message: 'File does not start with "slaves:"' }); + hasSlavesHeader = true; // suppress repeat + } + currentModule = { + sn: moduleMatch[2], + snRaw: moduleMatch[1], + line: i + 1, + latency_topic: '', + task_count_declared: null, + sdo_len_declared: null, + tasks: [], + rawLines: [], + }; + modules.push(currentModule); + currentTask = null; + inTasksSection = false; + continue; + } + + if (!currentModule) { + errors.push({ line: i + 1, code: 'ORPHAN_LINE', message: `Line outside module: "${trimmed}"` }); + continue; + } + + currentModule.rawLines.push({ lineNum: i, text: trimmed }); + + // "tasks:" marks start of tasks section + if (!inTasksSection && trimmed === 'tasks:') { + inTasksSection = true; + continue; + } + + // Module-level properties (before tasks section) + if (!inTasksSection) { + const parsed = parseLineForValidation(trimmed, i, errors, warnings); + if (parsed) { + if (parsed.key === 'latency_pub_topic') currentModule.latency_topic = parsed.value; + if (parsed.key === 'task_count') currentModule.task_count_declared = parsed.value; + if (parsed.key === 'sdo_len') currentModule.sdo_len_declared = parsed.value; + } + continue; + } + + // Task header: " - app_{N}:" + const taskMatch = trimmed.match(/^- app_(\d+):$/); + if (taskMatch) { + currentTask = { + appNum: parseInt(taskMatch[1]), + line: i + 1, + _flat: {}, + _typed: [], // { key, tag, rawValue, lineNum } + }; + currentModule.tasks.push(currentTask); + continue; + } + + if (!currentTask) { + errors.push({ line: i + 1, code: 'ORPHAN_PROPERTY', message: `Property before task header: "${trimmed}"` }); + continue; + } + + // Task properties + const parsed = parseLineForValidation(trimmed, i, errors, warnings); + if (parsed) { + currentTask._flat[parsed.key] = parsed.value; + currentTask._typed.push({ key: parsed.key, tag: parsed.tag, rawValue: parsed.rawValue, lineNum: i + 1 }); + } + } + + if (!hasSlavesHeader) { + errors.push({ code: 'MISSING_HEADER', message: 'File does not contain "slaves:" header' }); + } + + return { modules, errors, warnings }; +} + +/** + * Parse a single property line with validation. + * Returns { key, value, tag, rawValue } or null. + */ +function parseLineForValidation(trimmed, lineIdx, errors, warnings) { + const propMatch = trimmed.match(/^([\w]+):\s+!(\S+)\s+(.+)$/); + if (propMatch) { + const key = propMatch[1]; + const tag = propMatch[2]; + const rawValue = propMatch[3].trim(); + + if (!VALID_TAGS.has(tag)) { + errors.push({ + line: lineIdx + 1, + code: 'UNKNOWN_TAG', + message: `Unknown type tag "!${tag}" on "${key}", expected one of: ${[...VALID_TAGS].join(', ')}`, + }); + return null; + } + + // Validate value against tag + validateTypedValue(rawValue, tag, key, lineIdx + 1, errors, warnings); + + // Parse actual value + const value = parseValue(rawValue, tag); + return { key, value, tag, rawValue }; + } + + // Line without a !type tag + const fallback = trimmed.match(/^([\w]+):\s+(.+)$/); + if (fallback) { + // Special case: known module-level keys with no tag + const key = fallback[1]; + if (['sdo_len', 'task_count', 'latency_pub_topic', 'tasks', 'slaves'].includes(key)) { + errors.push({ + line: lineIdx + 1, + code: 'MISSING_TAG', + message: `"${key}" is missing type tag, expected format: ${key}: ! `, + }); + return null; + } + errors.push({ + line: lineIdx + 1, + code: 'MISSING_TAG', + message: `Missing type tag on "${key}", expected format: key: !type value`, + }); + return null; + } + + return null; +} + +function validateTypedValue(rawValue, tag, key, lineNum, errors, warnings) { + if (tag === 'std::string') { + if (rawValue.startsWith("'") || rawValue.startsWith('"')) { + // OK — quoted string + } else { + warnings.push({ + line: lineNum, + code: 'UNQUOTED_STRING', + message: `String value for "${key}" is not quoted: ${rawValue}`, + }); + } + return; + } + + if (tag === 'float') { + if (isNaN(parseFloat(rawValue))) { + errors.push({ + line: lineNum, + code: 'INVALID_FLOAT', + message: `Invalid float value: "${rawValue}" for "${key}"`, + }); + } + return; + } + + if (INT_TAGS.has(tag)) { + let num; + if (rawValue.startsWith('0x') || rawValue.startsWith('0X')) { + num = parseInt(rawValue, 16); + if (isNaN(num)) { + errors.push({ + line: lineNum, + code: 'INVALID_HEX', + message: `Invalid hex value: "${rawValue}" for "${key}"`, + }); + return; + } + } else { + num = parseInt(rawValue, 10); + if (isNaN(num)) { + errors.push({ + line: lineNum, + code: 'INVALID_INT', + message: `Invalid integer value: "${rawValue}" for "${key}"`, + }); + return; + } + } + + // Range check + const range = TAG_RANGES[tag]; + if (range && (num < range[0] || num > range[1])) { + errors.push({ + line: lineNum, + code: 'VALUE_OUT_OF_RANGE', + message: `Value ${num} for "${key}" (!${tag}) out of range [${range[0]}, ${range[1]}]`, + }); + } + } +} + +function parseValue(rawValue, tag) { + if (tag === 'std::string') { + if ((rawValue.startsWith("'") && rawValue.endsWith("'")) || + (rawValue.startsWith('"') && rawValue.endsWith('"'))) { + return rawValue.slice(1, -1); + } + return rawValue; + } + if (tag === 'float') return parseFloat(rawValue); + if (INT_TAGS.has(tag)) { + if (rawValue.startsWith('0x') || rawValue.startsWith('0X')) return parseInt(rawValue, 16); + return parseInt(rawValue, 10); + } + return rawValue; +} + +// ─── sdo_len calculation (mirrors get_length from generate-module-def.js) ───── + +function calculateSdoLen(typedEntries) { + let total = 0; + for (const { key, tag } of typedEntries) { + if (!key.startsWith('sdowrite_')) continue; + const size = TAG_BYTE_SIZE[tag]; + if (size) total += size; + } + return total + 1; // +1 as in generate-module-def.js +} + +// ─── PDO offset calculation (mirrors generate-module-def.js logic) ──────────── + +function calculatePdoOffsets(tasks) { + let pdoreadOffset = 0; + let pdowriteOffset = 0; + const results = []; + + for (const task of tasks) { + const flat = task._flat; + const type = flat['sdowrite_task_type']; + const entry = { type, pdoread_offset: pdoreadOffset, pdowrite_offset: pdowriteOffset }; + + switch (type) { + case 1: pdoreadOffset += 19; break; + case 2: { + const ctrlType = flat['sdowrite_control_type']; + if (ctrlType !== 8) { + pdoreadOffset += 8; + } else { + pdoreadOffset += 32; + } + switch (ctrlType) { + case 1: case 2: pdowriteOffset += 3; break; + case 3: pdowriteOffset += 7; break; + case 4: pdowriteOffset += 5; break; + case 5: pdowriteOffset += 7; break; + case 6: pdowriteOffset += 6; break; + case 7: pdowriteOffset += 8; break; + case 8: pdowriteOffset += 8; break; + } + break; + } + case 3: pdoreadOffset += 21; break; + case 4: pdowriteOffset += 8; break; + case 5: { + let readLen = 0, writeLen = 0; + for (let j = 1; j <= 4; j++) { + if (flat[`sdowrite_motor${j}_can_id`] && flat[`sdowrite_motor${j}_can_id`] !== 0) { + readLen += 9; + writeLen += 3; + } + } + pdoreadOffset += readLen; + pdowriteOffset += writeLen; + break; + } + case 6: pdowriteOffset += 8; break; + case 7: pdowriteOffset += (flat['sdowrite_channel_num'] || 1) * 2; break; + case 8: pdoreadOffset += 9; break; + case 10: pdoreadOffset += 7; break; + case 11: pdoreadOffset += 24; break; + case 12: { + pdoreadOffset += 9; + const ctrlType = flat['sdowrite_control_type']; + if (ctrlType === 1 || ctrlType === 2) pdowriteOffset += 9; + else if (ctrlType === 3) pdowriteOffset += 5; + break; + } + case 13: pdoreadOffset += 7; pdowriteOffset += 4; break; + case 14: pdoreadOffset += 18; break; + case 15: { + let readLen = 0, writeLen = 0; + for (let j = 1; j <= 4; j++) { + if (flat[`sdowrite_motor${j}_can_id`] && flat[`sdowrite_motor${j}_can_id`] !== 0) { + readLen += 9; + writeLen += 3; + } + } + pdoreadOffset += readLen; + pdowriteOffset += writeLen; + break; + } + } + + results.push(entry); + } + + return results; +} + +// ─── Main verification ─────────────────────────────────────────────────────── + +export function verifyConfig(yamlText) { + if (!yamlText || typeof yamlText !== 'string') { + return { + valid: false, + errors: [{ code: 'EMPTY_INPUT', message: 'Empty or invalid input' }], + warnings: [], + stats: { moduleCount: 0, taskCount: 0, totalErrors: 1, totalWarnings: 0 }, + }; + } + + const { modules, errors, warnings } = tokenize(yamlText); + + // ── A. SN uniqueness ── + const seenSNs = new Map(); // sn → first module line + for (const mod of modules) { + if (seenSNs.has(mod.sn)) { + errors.push({ + code: 'DUPLICATE_SN', + module: mod.sn, + line: mod.line, + message: `Duplicate SN "sn${mod.sn}" (first defined at line ${seenSNs.get(mod.sn)})`, + }); + } else { + seenSNs.set(mod.sn, mod.line); + } + } + + // ── B. Per-module and per-task checks ── + const allTopics = []; // { topic, moduleSn, appNum, line, kind } + let totalTasks = 0; + + for (const mod of modules) { + // ── task_count check ── + if (mod.task_count_declared !== null && mod.task_count_declared !== mod.tasks.length) { + errors.push({ + code: 'TASK_COUNT_MISMATCH', + module: mod.sn, + line: mod.line, + fixable: true, + message: `Declared task_count=${mod.task_count_declared} but found ${mod.tasks.length} task(s)`, + }); + } + + // ── sdo_len check ── + const allTyped = []; + for (const task of mod.tasks) { + allTyped.push(...task._typed); + } + const expectedSdoLen = calculateSdoLen(allTyped); + if (mod.sdo_len_declared !== null && mod.sdo_len_declared !== expectedSdoLen) { + errors.push({ + code: 'SDO_LEN_MISMATCH', + module: mod.sn, + line: mod.line, + fixable: true, + message: `Declared sdo_len=${mod.sdo_len_declared} but calculated=${expectedSdoLen}`, + }); + } + + // ── latency_topic check ── + if (mod.latency_topic) { + allTopics.push({ topic: mod.latency_topic, moduleSn: mod.sn, appNum: 0, line: mod.line, kind: 'latency' }); + if (!mod.latency_topic.startsWith('/')) { + warnings.push({ + code: 'TOPIC_FORMAT', + module: mod.sn, + line: mod.line, + message: `latency_pub_topic "${mod.latency_topic}" does not start with "/"`, + }); + } + const expectedLatency = `/ecat/sn${mod.sn}/latency`; + if (mod.latency_topic !== expectedLatency) { + warnings.push({ + code: 'TOPIC_CONVENTION', + module: mod.sn, + line: mod.line, + message: `latency_pub_topic "${mod.latency_topic}" does not match convention "${expectedLatency}"`, + }); + } + } + + // ── App numbering check ── + for (let i = 0; i < mod.tasks.length; i++) { + if (mod.tasks[i].appNum !== i + 1) { + warnings.push({ + code: 'APP_NUMBERING', + module: mod.sn, + line: mod.tasks[i].line, + message: `Expected app_${i + 1} but found app_${mod.tasks[i].appNum}`, + }); + } + } + + // ── PDO offset checks ── + const expectedOffsets = calculatePdoOffsets(mod.tasks); + for (let i = 0; i < mod.tasks.length; i++) { + const task = mod.tasks[i]; + const flat = task._flat; + const type = flat['sdowrite_task_type']; + + // ── Required task_type ── + if (type === undefined) { + errors.push({ + code: 'MISSING_TASK_TYPE', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `Task app_${task.appNum} is missing sdowrite_task_type`, + }); + continue; + } + + // ── Unknown task type ── + if (!VALID_TASK_TYPES.has(type)) { + errors.push({ + code: 'UNKNOWN_TASK_TYPE', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `Unknown task type ${type}`, + }); + continue; + } + + const typeName = TASK_TYPE_NAMES[type] || `Type ${type}`; + + // ── Required fields ── + const required = REQUIRED_FIELDS[type] || []; + for (const field of required) { + if (!(field in flat)) { + errors.push({ + code: 'MISSING_REQUIRED_FIELD', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `${typeName}: missing required field "${field}"`, + }); + } + } + + // ── Unknown fields ── + const known = KNOWN_FIELDS[type] || []; + for (const key of Object.keys(flat)) { + if (!known.includes(key)) { + warnings.push({ + code: 'UNKNOWN_FIELD', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `${typeName}: unknown field "${key}"`, + }); + } + } + + // ── Motor-specific logic checks ── + if (type === 5) { + // DJI Motor: if motor enabled, must have control_type + for (let j = 1; j <= 4; j++) { + const canId = flat[`sdowrite_motor${j}_can_id`]; + if (canId && canId !== 0) { + if (!(`sdowrite_motor${j}_control_type` in flat)) { + errors.push({ + code: 'MISSING_MOTOR_CONTROL_TYPE', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `DJI Motor: motor${j} enabled (can_id=${canId}) but missing motor${j}_control_type`, + }); + } + const ctrlType = flat[`sdowrite_motor${j}_control_type`]; + if (ctrlType > 1) { + const speedFields = ['kp', 'ki', 'kd', 'max_out', 'max_iout']; + for (const suffix of speedFields) { + if (!(`sdowrite_motor${j}_speed_pid_${suffix}` in flat)) { + errors.push({ + code: 'MISSING_PID_FIELD', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `DJI Motor: motor${j} control_type=${ctrlType} but missing motor${j}_speed_pid_${suffix}`, + }); + } + } + } + if (ctrlType > 2) { + const angleFields = ['kp', 'ki', 'kd', 'max_out', 'max_iout']; + for (const suffix of angleFields) { + if (!(`sdowrite_motor${j}_angle_pid_${suffix}` in flat)) { + errors.push({ + code: 'MISSING_PID_FIELD', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `DJI Motor: motor${j} control_type=${ctrlType} but missing motor${j}_angle_pid_${suffix}`, + }); + } + } + } + } + } + } + + if (type === 15) { + // DD Motor: if motor enabled, must have control_type + for (let j = 1; j <= 4; j++) { + const canId = flat[`sdowrite_motor${j}_can_id`]; + if (canId && canId !== 0) { + if (!(`sdowrite_motor${j}_control_type` in flat)) { + errors.push({ + code: 'MISSING_MOTOR_CONTROL_TYPE', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `DD Motor: motor${j} enabled (can_id=${canId}) but missing motor${j}_control_type`, + }); + } + } + } + } + + if (type === 2) { + // LkTech: if control_type != 8, must have can_packet_id + const ctrlType = flat['sdowrite_control_type']; + if (ctrlType !== 8 && !('sdowrite_can_packet_id' in flat)) { + errors.push({ + code: 'MISSING_CAN_PACKET_ID', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `LkTech Motor: control_type=${ctrlType} requires sdowrite_can_packet_id`, + }); + } + // LkTech control_type range + if (ctrlType !== undefined && (ctrlType < 1 || ctrlType > 8)) { + errors.push({ + code: 'INVALID_CONTROL_TYPE', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `LkTech Motor: control_type=${ctrlType} out of valid range [1, 8]`, + }); + } + } + + // ── Topic checks ── + if ('pub_topic' in flat) { + const topic = flat['pub_topic']; + allTopics.push({ topic, moduleSn: mod.sn, appNum: task.appNum, line: task.line, kind: 'read' }); + if (!topic) { + warnings.push({ + code: 'EMPTY_TOPIC', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `pub_topic is empty`, + }); + } else if (!topic.startsWith('/')) { + errors.push({ + code: 'TOPIC_FORMAT', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `pub_topic "${topic}" does not start with "/"`, + }); + } + // Check SN in topic matches module SN + const snInTopic = topic.match(/sn(\d+)/); + if (snInTopic && snInTopic[1] !== mod.sn) { + warnings.push({ + code: 'TOPIC_SN_MISMATCH', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `pub_topic "${topic}" references sn${snInTopic[1]} but module is sn${mod.sn}`, + }); + } + } + + if ('sub_topic' in flat) { + const topic = flat['sub_topic']; + allTopics.push({ topic, moduleSn: mod.sn, appNum: task.appNum, line: task.line, kind: 'write' }); + if (!topic) { + warnings.push({ + code: 'EMPTY_TOPIC', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `sub_topic is empty`, + }); + } else if (!topic.startsWith('/')) { + errors.push({ + code: 'TOPIC_FORMAT', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `sub_topic "${topic}" does not start with "/"`, + }); + } + const snInTopic = topic.match(/sn(\d+)/); + if (snInTopic && snInTopic[1] !== mod.sn) { + warnings.push({ + code: 'TOPIC_SN_MISMATCH', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + message: `sub_topic "${topic}" references sn${snInTopic[1]} but module is sn${mod.sn}`, + }); + } + } + + // ── PDO offset validation ── + if (i < expectedOffsets.length) { + const expected = expectedOffsets[i]; + if ('pdoread_offset' in flat && flat['pdoread_offset'] !== expected.pdoread_offset) { + errors.push({ + code: 'PDOREAD_OFFSET_MISMATCH', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + fixable: true, + message: `pdoread_offset=${flat['pdoread_offset']} but expected ${expected.pdoread_offset}`, + }); + } + if ('pdowrite_offset' in flat && flat['pdowrite_offset'] !== expected.pdowrite_offset) { + errors.push({ + code: 'PDOWRITE_OFFSET_MISMATCH', + module: mod.sn, + task: `app_${task.appNum}`, + line: task.line, + fixable: true, + message: `pdowrite_offset=${flat['pdowrite_offset']} but expected ${expected.pdowrite_offset}`, + }); + } + } + + totalTasks++; + } + } + + // ── Cross-module topic uniqueness ── + const topicMap = new Map(); // topic → [{ moduleSn, appNum, line, kind }] + for (const entry of allTopics) { + if (!topicMap.has(entry.topic)) topicMap.set(entry.topic, []); + topicMap.get(entry.topic).push(entry); + } + for (const [topic, entries] of topicMap) { + if (entries.length > 1 && topic) { + const details = entries.map(e => `sn${e.moduleSn}/app_${e.appNum}`).join(', '); + errors.push({ + code: 'DUPLICATE_TOPIC', + message: `Topic "${topic}" used by multiple tasks: ${details}`, + }); + } + } + + const totalErrors = errors.length; + const totalWarnings = warnings.length; + + return { + valid: totalErrors === 0, + errors, + warnings, + stats: { moduleCount: modules.length, taskCount: totalTasks, totalErrors, totalWarnings }, + }; +}