diff --git a/README.md b/README.md index 744bbfd..1e7c2d4 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,11 @@ LinkerHand-CPP-SDK 由灵心巧手(北京)科技有限公司开发,提供 - [API 文档](#-api-文档) - [通信协议](#-通信协议) - [支持的型号](#-支持的型号) +- [网页示教器](#-网页示教器) - [项目结构](#-项目结构) - [示例程序列表](#-示例程序列表) - [关节映射表](#-关节映射表) -- [故障排查](#-故障排查) -- [常见问题](#-常见问题) -- [贡献](#-贡献) -- [更新日志](#-更新日志) +- [故障排查与常见问题](#-故障排查与常见问题) - [许可证](#-许可证) - [联系我们](#-联系我们) @@ -55,7 +53,7 @@ LinkerHand-CPP-SDK 由灵心巧手(北京)科技有限公司开发,提供 - 操作系统:Windows 10 / 11 (x64) - 编译器:MinGW-w64 GCC 13+,线程模型需为 `win32`(`g++ -v` 查看 `Thread model`),或 MSVC(VS2017 / VS2019 / VS2022 任一,已实测 VS2017 消费 CI artifact 通过) - CMake:建议 4.0+(已实测 4.0.3) -- 依赖:`PCAN-Basic` 与 mingw 运行时 DLL 已随发布包附带 +- 依赖:`PCAN-Basic` 与 mingw 运行时 DLL 已随 SDK 附带 ## 🚀 快速开始 @@ -69,7 +67,7 @@ cd linkerhand-cpp-sdk ### 2. 使用脚本构建 -发布包内附 `build.sh`(Linux)与 `build.bat`(Windows)。 +SDK 目录内附 `build.sh`(Linux)与 `build.bat`(Windows)。 | 选项 | 说明 | |------|------| @@ -105,6 +103,16 @@ build.bat # 编译 # Windows (无需操作) ``` +**不想每次手动敲命令?** 用 `can-autocfg.sh` 一次性安装自动配置(systemd 模板服务 + udev 规则):接口**插入 / 开机时自动 up,关机 / 重启时自动干净 down**,并按设备能力自动识别经典 CAN 或 CAN-FD(FD 自动加 `dbitrate 5000000 fd on`)、设 `txqueuelen`、开 bus-off 自动恢复。 + +```bash +sudo ./can-autocfg.sh # 经典 1000000;FD 设备自动 dbitrate 5000000 +sudo BITRATE=500000 ./can-autocfg.sh # 改经典 / 仲裁段波特率 +sudo BITRATE=1000000 DBITRATE=2000000 ./can-autocfg.sh # 改 FD 数据段波特率 +``` + +安装后无需再手动 `ip link set canX up`,开机 / 插入即自动配置。适用于 PEAK PCAN-USB / candleLight(gs_usb) 等 SocketCAN 适配器。 + ### 4. 运行示例程序 构建完成后,可执行文件位于 `build/bin/`: @@ -136,10 +144,10 @@ touch main.cpp CMakeLists.txt 两种集成方式的目录布局: ``` -# 解压即用(未安装 SDK,把发布包整个拷到工程内) +# 解压即用(未安装 SDK,把 SDK 目录整个拷到工程内) demo/ ├── CMakeLists.txt -├── linkerhand-cpp-sdk/ # 即本发布包 +├── linkerhand-cpp-sdk/ # 即本 SDK 目录 └── main.cpp # 已安装 SDK(系统级,find_package 即可找到) @@ -197,7 +205,7 @@ project(my_app LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) -# 解压即用:把发布包目录加进 CMake 搜索路径,无需安装。 +# 解压即用:把 SDK 目录加进 CMake 搜索路径,无需安装。 # 等价命令行:cmake -S . -B build -DCMAKE_PREFIX_PATH=${CMAKE_SOURCE_DIR}/linkerhand-cpp-sdk list(APPEND CMAKE_PREFIX_PATH "${CMAKE_CURRENT_SOURCE_DIR}/linkerhand-cpp-sdk") @@ -274,6 +282,50 @@ cmake --build . -j | LinkerHand O6 | CAN / Modbus | O6 series robotic hand | | LinkerHand O20 | CAN-FD | O20 series robotic hand(不支持Linux aarch64) | +## 🌐 网页示教器 + +`webui/` 是一套开箱即用的网页控制界面,全型号(L6 / L7 / L10 / L20 / L21 / L25 / G20 / O6 / O20)通用,O20 走 CAN-FD。 + +> **`./build.sh -b` 编译完成后即可直接使用**,无需额外构建,`python3 webui/run.py` 起服务即用。 + + + + webui 主界面 + + +### 功能 + +- 按型号动态渲染**关节滑块**,实时位置回读 +- **速度 / 力矩**设置与回读 +- **触觉压感热力图**(含掌心,O6 / G20),维度按型号自适应 +- **温度 / 故障**监控表(温度 >50 黄、>60 红,故障码非 0 红) +- 版本信息展示、明暗主题切换 +- 在线连接:前端「设置」面板选择型号 / 侧别 / 通信方式,支持热重连与主动断开 + +### 运行 + +编译完成后,拉起对应总线、给手上电,启动服务即可: + +```bash +./build.sh -b # 一次编译,webui 随之就绪 +sudo ip link set can0 up type can bitrate 1000000 # CAN 型号;Modbus 见下 +python3 webui/run.py # 起服务,型号/通信在前端「设置」里选 +``` + +浏览器打开 `http://<本机IP>:8080/`。也可命令行直连指定型号: + +```bash +python3 webui/run.py --model O6 --side left +python3 webui/run.py --model L10 --side left --channel can0 +python3 webui/run.py --model O20 --side right # 厂商 CAN-FD 设备 +python3 webui/run.py --model O20 --side right --channel socketcan:can0 +python3 webui/run.py --model L10 --comm modbus --channel /dev/ttyUSB0 +``` + +主要参数:`--model`(不填则在前端在线连接)、`--side left|right`、`--comm can|canfd|modbus`(缺省按型号自动)、`--channel`(CAN 接口名 / Modbus 串口)、`--host`(默认 `0.0.0.0`)、`--port`(默认 `8080`)。更多用法见 [`webui/README.md`](webui/README.md)。 + +> Modbus 型号需串口读写权限:`sudo chmod 0777 /dev/ttyUSB0` 或把用户加入 `dialout` 组。 + ## 📁 项目结构 ``` @@ -300,13 +352,16 @@ linkerhand-cpp-sdk/ │ ├── mingw/ # Windows MinGW(.dll) │ └── msvc/ # Windows MSVC(.dll / .lib) ├── examples/ # 示例源码(CAN / CAN-FD / Modbus) +├── webui/ # 网页示教器(浏览器控制界面) ├── docs/ # API-Reference / FAQ / TROUBLESHOOTING +│ └── images/ # 文档配图 ├── cmake/ # find_package 用的 *-config.cmake ├── third_party/ │ ├── libcanbus/ # CAN / CAN-FD 驱动 │ └── PCAN_Basic/ # Windows PCAN 驱动 ├── build.sh # 构建脚本(Linux) ├── build.bat # 构建脚本(Windows) +├── can-autocfg.sh # CAN/CAN-FD 自动激活/关闭(可选) └── CMakeLists.txt # 顶层 CMake:聚合 examples + 安装规则 ``` @@ -356,53 +411,27 @@ linkerhand-cpp-sdk/ "食指侧摆", "无名指侧摆", "小指侧摆", "拇指旋转"] ``` -- L20 +- G20 ``` -["拇指根部", "食指根部", "中指根部", "无名指根部", "小指根部", - "拇指侧摆", "食指侧摆", "中指侧摆", "无名指侧摆", "小指侧摆", - "拇指横摆", "预留", "预留", "预留", "预留", - "拇指尖部", "食指末端", "中指末端", "无名指末端", "小指末端"] +["大拇指根部", "食指根部", "中指根部","无名指根部","小拇指根部", + "大拇指侧摆","食指侧摆","中指侧摆","无名指侧摆","小拇指侧摆", + "大拇指横滚","大拇指指尖","食指指尖","中指指尖","无名指指尖","小拇指指尖"] ``` - L21 / L25 详细映射见 [`docs/API-Reference.md`](docs/API-Reference.md)。 -## 🔧 故障排查 +## 🔧 故障排查与常见问题 - [故障排查指南](docs/TROUBLESHOOTING.md) — 编译 / 运行时 / 通信 / API 使用 / 性能 -- [常见问题解答](docs/FAQ.md) — 安装、使用、API、兼容性、性能 +- [常见问题解答](docs/FAQ.md) — 安装、使用、接口选择、各型号关节数、性能等 如文档无法解决: + 1. 在 [GitHub Issues](https://github.com/linker-bot/linkerhand-cpp-sdk/issues) 搜索同类问题 2. 提交新 Issue,附错误信息与复现步骤 3. 联系技术支持: -## ❓ 常见问题 - -快速答案见 [FAQ](docs/FAQ.md),覆盖: - -- 如何安装与配置 SDK -- 如何选择通信接口 -- 各型号关节数量 -- 如何提升性能 -- 如何获取技术支持 - -## 🤝 贡献 - -欢迎社区贡献: - -1. Fork 本仓库 -2. 创建特性分支:`git checkout -b feature/AmazingFeature` -3. 提交更改:`git commit -m 'feat: 新增 AmazingFeature'` -4. 推送:`git push origin feature/AmazingFeature` -5. 开启 Pull Request - -更多贡献指南见 `CONTRIBUTING.md`(待创建)。 - -## 📝 更新日志 - -详细版本记录见 `CHANGELOG.md`(待创建)。 - ## 📄 许可证 本项目采用 [MIT 许可证](LICENSE)。 @@ -418,4 +447,3 @@ Copyright (c) 2026 灵心巧手(北京)科技有限公司 --- **注意**:使用前请确保设备已正确连接并配置好通信接口。 - diff --git a/can-autocfg.sh b/can-autocfg.sh new file mode 100755 index 0000000..72a6eea --- /dev/null +++ b/can-autocfg.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# +# CAN 自动配置安装脚本(适用于 PEAK PCAN-USB / candleLight(gs_usb) 等 SocketCAN 适配器) +# +# 作用:安装一个 systemd 模板服务 + udev 规则,使任意 canX 接口 +# - 插入/开机时自动 up(按设备能力自动选 经典CAN 或 CANFD、设 txqueuelen、bus-off 自动恢复); +# - 关机/重启时自动干净 down,避免适配器接收通路卡死。 +# +# 用法: sudo ./can-autocfg.sh # 经典 1000000;FD 设备自动 dbitrate 5000000 +# sudo BITRATE=500000 ./can-autocfg.sh # 改经典/仲裁段波特率 +# sudo BITRATE=1000000 DBITRATE=2000000 ./can-autocfg.sh # 改 FD 数据段波特率 +# +set -euo pipefail + +BITRATE="${BITRATE:-1000000}" +DBITRATE="${DBITRATE:-5000000}" +TXQUEUELEN="${TXQUEUELEN:-1024}" +RESTART_MS="${RESTART_MS:-100}" + +if [ "$(id -u)" -ne 0 ]; then + echo "请用 sudo 运行: sudo ./can-autocfg.sh" >&2 + exit 1 +fi + +IP_BIN="$(command -v ip || echo /usr/sbin/ip)" +HELPER=/usr/local/sbin/can-setup-up.sh +SERVICE=/etc/systemd/system/can-setup@.service +RULE=/etc/udev/rules.d/90-can-setup.rules + +echo "[1/5] 写入能力判别启动脚本 $HELPER ..." +cat > "$HELPER" </dev/null || true + +# FD 能力判别:仅 FD 控制器广播数据段时序常量 data_bittiming_const +# (DOWN 时即存在,与 max_mtu / 是否已 fd on 无关,厂商/驱动无关) +FDARGS="" +if "\$IP" -d -j link show "\$IFACE" | grep -q data_bittiming_const; then + FDARGS="dbitrate \$DBITRATE fd on" +fi + +# restart-ms 容错:部分 FD 控制器(如 gs_usb)不支持 Bus-Off 自动恢复,失败则降级重试 +"\$IP" link set "\$IFACE" type can bitrate "\$BITRATE" \$FDARGS restart-ms "\$RESTART_MS" 2>/dev/null \\ + || "\$IP" link set "\$IFACE" type can bitrate "\$BITRATE" \$FDARGS + +"\$IP" link set "\$IFACE" up +EOF +chmod +x "$HELPER" + +echo "[2/5] 写入模板服务 $SERVICE (bitrate=$BITRATE dbitrate=$DBITRATE) ..." +cat > "$SERVICE" < "$RULE" <<'EOF' +# 任何 CAN 网络接口出现时,自动启动对应的 can-setup@ 实例 +SUBSYSTEM=="net", ACTION=="add", KERNEL=="can*", TAG+="systemd", ENV{SYSTEMD_WANTS}+="can-setup@$name.service" +EOF + +echo "[4/5] 重新加载 systemd 与 udev ..." +systemctl daemon-reload +udevadm control --reload + +echo "[5/5] 对已存在的 CAN 接口触发一次(无需重启即可生效)..." +for IFACE in $(ls /sys/class/net/ | grep -E '^can[0-9]+$' || true); do + echo " -> 触发 $IFACE" + udevadm trigger --action=add --subsystem-match=net --sysname-match="$IFACE" || true +done +sleep 2 + +echo +echo "===== 安装完成,当前 CAN 接口状态 =====" +for IFACE in $(ls /sys/class/net/ | grep -E '^can[0-9]+$' || true); do + systemctl is-active "can-setup@${IFACE}.service" >/dev/null 2>&1 \ + && echo " $IFACE : 服务 active" || echo " $IFACE : 服务未激活(检查日志)" + "$IP_BIN" -details link show "$IFACE" | sed -n '1p;3p' | sed 's/^/ /' +done +echo +echo "提示:以后无需手动 ip link set canX up,开机/插入即自动配置(经典/FD 自动识别)。" diff --git a/docs/API-Reference.md b/docs/API-Reference.md index 66c2516..6fa9dfe 100644 --- a/docs/API-Reference.md +++ b/docs/API-Reference.md @@ -114,6 +114,42 @@ std::vector>> getForce(); --- +### 获取全掌压感点阵 +```cpp +std::vector> getPalmForce(); +``` +**Description**: +获取手掌区域的触觉压感点阵。矩阵维度随掌部传感器类型而定:`TSSP_JZG` 为 20 行 × 28 列,`TSSP_HWK` 为 14 行 × 16 列。 +**支持型号**:L6 / O6 / G20(其余型号返回空并提示不支持)。 +**Returns**: +- 返回二维向量 `std::vector>`:外层为行、内层为列,每个元素为该传感单元的压感值(uint8_t)。 + +--- + +### 获取五指合力值 +```cpp +std::vector> getFingerForceSum(); +``` +**Description**: +获取五根手指的法向合力值。每指分量数随传感器类型而定:`TSSP_JZG` 每指 3 个,`TSSP_HWK` 每指 1 个。 +**支持型号**:G20(其余型号返回空并提示不支持)。 +**Returns**: +- 返回二维向量 `std::vector>`:外层大小 = 5(五根手指),内层为该指的合力分量。 + +--- + +### 获取全掌合力值 +```cpp +std::vector getPalmForceSum(); +``` +**Description**: +获取整个手掌的法向合力值。分量数随传感器类型而定:`TSSP_JZG` 为 2 个,`TSSP_HWK` 为 1 个。 +**支持型号**:G20(其余型号返回空并提示不支持)。 +**Returns**: +- 返回一个 `std::vector`,为手掌合力分量。 + +--- + ### 获取速度 ```cpp std::vector getSpeed(); diff --git a/docs/FAQ.md b/docs/FAQ.md index 7b6a721..e7d7819 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -1,16 +1,75 @@ # 常见问题解答 -本文档面向 `linkerhand-cpp-sdk/` 发布包,以及从 repository root 源码构建安装 SDK 的开发者。 +本文档面向从 repository root 源码构建、安装并接入 LinkerHand C++ SDK 的开发者。 + +## 安装与接入 + +### Q1: 源码构建后怎么快速验证? + +Linux/macOS: + +```bash +mkdir build && cd build +cmake .. +cmake --build . -j$(nproc) +./bin/test_l10_can_0 +``` + +Windows MinGW: + +```cmd +mkdir build +cd build +cmake -G "MinGW Makefiles" -DCMAKE_MAKE_PROGRAM=mingw32-make .. +mingw32-make -j +build\bin\test_l10_can_0.exe +``` + +### Q2: 对外头文件为什么比最小 API 入口多? + +SDK 不仅面向统一 API,也要支持 `examples/`、`hand_teach_pendant/` 和部分需要直连通信层的客户工程,所以会一起释放 `include/communication/` 下的通信相关头。新项目优先从 `LinkerHandApi.h` 开始接入。 + +### Q3: 安装后 `find_package` 应该怎么接? + +从源码执行 `cmake --install` 或 `./build.sh -i` 后,可直接在下游工程里写: + +```cmake +find_package(linkerhand-cpp-sdk CONFIG REQUIRED) +target_link_libraries(my_app PRIVATE LinkerHand::linkerhand_cpp_sdk) +``` + +## 构建与运行 + +### Q4: `build.sh` / `build.bat` 能做什么? + +它们服务于持有源码工程的开发者,用于重建、安装或卸载 SDK。日常一站式构建见 `./build.sh -b`,安装见 `sudo ./build.sh -i`。 + +### Q5: `--skip-tests` 为什么没有关闭 examples? + +当前 `build.sh` 透传的是 `-DBUILD_TESTS=OFF`,这是 examples 子目录内部开关,不影响顶层 `BUILD_EXAMPLES` / `BUILD_PENDANT`。如果只想构建 SDK,请直接使用: + +```bash +cmake -S . -B build -DBUILD_EXAMPLES=OFF -DBUILD_PENDANT=OFF +cmake --build build -j +``` + +### Q6: 哪些平台是当前主要验证目标? + +- Linux `x86_64` +- Linux `aarch64` +- Windows `x64`(MinGW / MSVC 导入库) + +其中 O20 的 CAN-FD 示例目前主要面向 Linux `x86_64`。 ## 通信与接口 -### Q1: CAN、Modbus、EtherCAT 怎么选? +### Q7: CAN、Modbus、EtherCAT 怎么选? - 统一 API 层通过 `COMM_TYPE::CAN`、`COMM_TYPE::MODBUS`、`COMM_TYPE::ETHERCAT` 选择通信方式。 - 不同手型支持范围不同,具体以 `README.md` 型号表和现有 examples 为准。 - 需要自己管理底层总线时,优先参考 `examples/test_*` 和 `hand_teach_pendant/src/HandController.cpp` 的实际用法。 -### Q2: Linux 下 CAN 默认怎么配? +### Q8: Linux 下 CAN 默认怎么配? ```bash sudo modprobe can @@ -22,14 +81,98 @@ ip link show can0 如果示例使用了 `can1` 或其他设备名,请按现场总线名替换。 -### Q3: Windows 下还需要额外 DLL 吗? +### Q9: Windows 下还需要额外 DLL 吗? + +需要。除了 SDK 自身 DLL 外,还要确保 `PCANBasic.dll`(来自 `third_party/PCAN_Basic/`)与可执行文件同目录,或能被系统搜索路径找到。 + +### Q10: O20 的 CAN-FD 双手 / 手型怎么用? -需要。发布包已经带上 `third_party/PCAN_Basic/` 和对应导入库;客户工程除了 SDK 自身 DLL 外,还要确保 `PCANBasic.dll` 与可执行文件同目录,或能被系统搜索路径找到。 +O20 的 CAN-FD 走这样一条链路: + +``` +LinkerHandApi(O20, HAND_TYPE::LEFT|RIGHT, COMM_TYPE::CAN) + └─ IHand (O20Hand) ── 组 29-bit 扩展帧 ID(含 device_id: 0x01=右手 / 0x02=左手) + └─ tx_callback ── 用户自建 Communication::CanFD(dev_num, ch_num) 只负责收发字节 +``` + +- `Communication::CanFD` **不知道左右手**,构造参数只有物理适配器编号 `dev_num` 和通道号 `ch_num`。 +- 左右手由 `LinkerHandApi` 构造函数的 `HAND_TYPE` 决定;`O20Hand` 内部把它编码到帧 ID 里。 +- **一根 CAN-FD 适配器对应一只手**——双手要用两根适配器(先插的编号 0,后插的编号 1),各自建独立的 `CanFD` + `LinkerHandApi`。 + +单手最小样板(参考 `examples/test_o20_canfd_0.cpp`): + +```cpp +Communication::CanFD canfd(0, 0); +canfd.init(); + +LinkerHandApi api(LINKER_HAND::O20, HAND_TYPE::LEFT, COMM_TYPE::CAN); +api.setCanTxCallback([&](uint32_t can_id, const uint8_t* data, uintptr_t len) -> int32_t { + canfd.send(std::vector(data, data + len), can_id, /*extended=*/true); + return 0; +}); +api.setCanRxCallback([&](uint32_t* id_out, uint8_t* data_out, uint8_t* len_out) -> int32_t { + auto frame = canfd.recv(10); + if (!frame.valid) return -1; + *id_out = frame.can_id; + *len_out = Communication::CanFD::dlcToLen(frame.can_dlc); + memcpy(data_out, frame.data, *len_out); + return 0; +}); +// 后续调用 api.getVersion() / setPosition() / getSpeed() ... +``` + +双手样板见 `examples/test_o20_canfd_double.cpp`——两只 `Communication::CanFD`(`dev_num=0/1`)+ 两只 `LinkerHandApi`(`LEFT/RIGHT`),tx/rx lambda **必须闭包捕获各自的 CanFD 实例**,切勿共用。 + +### Q11: 传错 HAND_TYPE 会怎么样? + +`O20Hand` 在挂上 `setCanRxCallback` 时会主动发一次 `SYS_DEVICE_INFO=0x00` 探测——先按用户传入的 `HAND_TYPE` 对应的 `device_id` 探一次,超时(300ms)会自动换另一侧再探一次。命中后: + +- **一致**:`stdout` 打印 `[LinkerHand O20] 手型探测: 固件自报 LEFT (hand_flag=2),与用户传入一致。` +- **不一致**:**静默按固件自报值校正** `device_id`,并打印 `[LinkerHand O20] ⚠ 手型自动校正: 用户传入 HAND_TYPE::RIGHT ... 与固件自报 LEFT ... 不一致,已按固件校正 device_id -> 0x02。` +- **两侧都超时**:保留原 `device_id`,打印警告并提示排查供电 / 接线 / 波特率。 + +这个机制是为了兼容"用户构造时把左右手传反"或"不确定当前 CAN-FD 适配器上接的是哪只手"的场景。**推荐仍按实际情况正确传入 `HAND_TYPE`**,自动校正只作为兜底。 + +### Q12: `CanFD` 和 `CanFDSocket` 有什么区别?该用哪个? + +SDK 提供两种 CAN-FD 后端,接口一致(都实现 `Communication::ICanFD`,`send` / `recv` / `isOpen` 用法相同),底层链路不同: + +| | `Communication::CanFD` | `Communication::CanFDSocket` | +|---|---|---| +| 底层 | 厂商私有驱动 `third_party/libcanbus` | Linux 内核原生 SocketCAN | +| 构造 | `CanFD(dev_num, ch_num)` 扫 USB-CANFD 适配器 | `CanFDSocket("can0")` 打开内核接口 | +| 波特率 | 代码内配置(仲裁 1M / 数据 5M) | **内核侧** `ip link ... fd on` 配置 | +| 平台 | 跨平台;ARM/Ubuntu≤18 被 `USE_CANFD` 关闭 | **仅 Linux**;不受 `USE_CANFD` 门控,ARM 也可用 | +| 依赖 | 需 `libcanbus` | 只用内核 `linux/can.h`,零 third_party | + +用 `CanFDSocket` 前先在内核侧把接口配成 CAN-FD 模式: + +```bash +sudo ip link set can0 down +sudo ip link set can0 type can bitrate 1000000 dbitrate 5000000 fd on restart-ms 100 +sudo ip link set can0 up +sudo ip link set can0 txqueuelen 1000 +``` + +回调接线与 `CanFD` 几乎一致,仅 `recv()` 返回的 `CanFDFrame.can_dlc` **直接是实际字节长度**(SocketCAN 用真实长度,不是 DLC 编码,无需 `dlcToLen`): + +```cpp +Communication::CanFDSocket canfd("can0"); +canfd.init(); +// tx: canfd.send(data_vec, can_id, true); +// rx: len = frame.can_dlc; memcpy(out, frame.data, len); +``` + +单手样板见 `examples/test_o20_canfd_socket_0.cpp`。手型逻辑(O20)与传输层完全解耦,换后端只需改这一处构造 + 回调,`LinkerHandApi` 用法不变。 ## 文档与支持 -### Q4: 先看哪个文档最合适? +### Q13: 先看哪个文档最合适? - 想了解总体接入方式:看 `README.md` - 想确认接口签名:看 `docs/API-Reference.md` - 遇到构建或运行问题:看 `docs/TROUBLESHOOTING.md` + +### Q14: 旧版参考目录还能直接当文档源吗? + +不能。旧版目录只适合做历史对照,内容可能与当前命名空间、构建开关不一致。请以 repository root 的源码与文档为准。 diff --git a/docs/images/webui-overview-dark.png b/docs/images/webui-overview-dark.png new file mode 100644 index 0000000..c7a6e78 Binary files /dev/null and b/docs/images/webui-overview-dark.png differ diff --git a/docs/images/webui-overview-light.png b/docs/images/webui-overview-light.png new file mode 100644 index 0000000..cb444bc Binary files /dev/null and b/docs/images/webui-overview-light.png differ diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index c820bfd..dff1dd2 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -398,6 +398,9 @@ if(BUILD_TESTS) if(USE_CANFD AND NOT CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64") list(APPEND EXAMPLE_STEMS ${LINKERHAND_EXAMPLES_CANFD}) endif() + if(UNIX AND NOT APPLE) + list(APPEND EXAMPLE_STEMS ${LINKERHAND_EXAMPLES_LINUX}) + endif() foreach(stem ${EXAMPLE_STEMS}) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${stem}.cpp") @@ -408,6 +411,18 @@ if(BUILD_TESTS) copy_dependencies(${target}) endif() endforeach() + + # web_bridge:无依赖 Python web 前端(webui/run.py)的后端桥,全平台构建。 + # 非 O20 型号走普通 CAN,处处可用;O20 需 CAN-FD——有厂商 CAN-FD 时定义 + # WEB_BRIDGE_HAS_CANFD 编入支持,否则仅 Linux 下可用 socketcan 原生 CAN-FD。 + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/web_bridge.cpp") + add_executable(web_bridge web_bridge.cpp) + target_link_libraries(web_bridge PRIVATE ${COMMON_LIBS}) + if(USE_CANFD AND NOT CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64") + target_compile_definitions(web_bridge PRIVATE WEB_BRIDGE_HAS_CANFD=1) + endif() + copy_dependencies(web_bridge) + endif() endif() message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") diff --git a/examples/linker_hand_example.cpp b/examples/linker_hand_example.cpp index 09c371d..6e5eb1f 100644 --- a/examples/linker_hand_example.cpp +++ b/examples/linker_hand_example.cpp @@ -252,7 +252,7 @@ void interactiveMode(LinkerHandApi &hand) std::cout << "Obtain speed: " << bytesToHex(hand.getSpeed()) << std::endl; break; case 6: - for (size_t i = 0; i < 30; i++) + for (size_t i = 0; i < 60; i++) { std::vector>> touch_mats = hand.getForce(); // 手指名称表,顺序对应 touch_mats[0..4] @@ -271,6 +271,19 @@ void interactiveMode(LinkerHandApi &hand) std::cout << '\n'; } + // 掌心矩阵(TSSP_JZG 20x28 / TSSP_HWK 14x16) + std::vector> palm_mat = hand.getPalmForce(); + if (!palm_mat.empty()) { + std::cout << "PALM_TOUCH:\n"; + for (const auto &row : palm_mat) + { + for (uint8_t val : row) + std::cout << std::setw(3) << static_cast(val) << ' '; + std::cout << '\n'; + } + std::cout << '\n'; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); } break; diff --git a/examples/sources.cmake b/examples/sources.cmake index 943d42c..8f03a76 100644 --- a/examples/sources.cmake +++ b/examples/sources.cmake @@ -18,6 +18,7 @@ set(LINKERHAND_EXAMPLES test_l21_can_0 test_g20_can_0 test_g20_can_1 + test_l6_can_0 test_o6_can_0 test_o6_can_1 test_o6_can_3 @@ -33,5 +34,12 @@ set(LINKERHAND_EXAMPLES set(LINKERHAND_EXAMPLES_CANFD test_o20_canfd_0 test_o20_canfd_1 + test_o20_canfd_double linker_hand_example ) + +# 仅 Linux 构建:SocketCAN 原生 CANFD(CanFDSocket),只用内核 linux/can.h, +# 无需 libcanbus,不受 USE_CANFD 门控。 +set(LINKERHAND_EXAMPLES_LINUX + test_o20_canfd_socket_0 +) diff --git a/examples/test_g20_can_1.cpp b/examples/test_g20_can_1.cpp index 077ad2e..40e92fb 100644 --- a/examples/test_g20_can_1.cpp +++ b/examples/test_g20_can_1.cpp @@ -175,14 +175,32 @@ void threadGetTouchData(LinkerHandApi& hand, int iteration) { for (size_t n = 0; n < touch_mats.size(); ++n) { if (!touch_mats[n].empty()) { std::stringstream ss2; - ss2 << " " << finger_name[n] << " 矩阵 " - << touch_mats[n].size() << "x" + ss2 << " " << finger_name[n] << " 矩阵 " + << touch_mats[n].size() << "x" << (touch_mats[n].empty() ? 0 : touch_mats[n][0].size()) << " 第一个值: " << (int)touch_mats[n][0][0]; printWithLock(ss2.str()); } } - + + // 手掌点阵(TSSP_JZG/TSSP_HWK) + auto palm = hand.getPalmForce(); + if (!palm.empty()) { + std::stringstream ssp; + ssp << " PALM 矩阵 " << palm.size() << "x" << palm[0].size(); + printWithLock(ssp.str()); + } + + // 合力值(五指 + 手掌) + auto finger_sum = hand.getFingerForceSum(); + auto palm_sum = hand.getPalmForceSum(); + if (!finger_sum.empty() || !palm_sum.empty()) { + std::stringstream ssf; + ssf << " 合力值 五指组数=" << finger_sum.size() + << " 手掌合力个数=" << palm_sum.size(); + printWithLock(ssf.str()); + } + } catch (const std::exception& e) { std::stringstream ss; ss << "[Thread4-Iter" << iteration << "] 压感获取失败: " << e.what(); diff --git a/examples/test_l6_can_0.cpp b/examples/test_l6_can_0.cpp new file mode 100644 index 0000000..6071b2d --- /dev/null +++ b/examples/test_l6_can_0.cpp @@ -0,0 +1,122 @@ +// L6 / CAN / 右手 —— 读取版本/状态并演示五指压感(B1~B5)与全掌压感(B6,带 TX/RX 日志) +#include +#include +#include +#include +#include +#include + +#include "_win_console_utf8.h" +#include "LinkerHandApi.h" +#include "CommFactory.h" + +// 格式化当前时间,用于 TX/RX 日志 +std::string getCurrentTime() { + auto now = std::chrono::system_clock::now(); + auto now_time = std::chrono::system_clock::to_time_t(now); + auto now_ms = std::chrono::duration_cast( + now.time_since_epoch()) % 1000; + + #if defined(_WIN32) || defined(_WIN64) + std::tm tm_info = {}; + localtime_s(&tm_info, &now_time); + std::tm* tm = &tm_info; + #else + std::tm* tm = std::localtime(&now_time); + #endif + char buffer[80]; + std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", tm); + + std::ostringstream oss; + oss << buffer << "." << std::setfill('0') << std::setw(3) << now_ms.count(); + return oss.str(); +} + +int main() { + try { + // 初始化灵巧手(右手,与 candump CAN_ID 0x27 对应) + std::shared_ptr hand = std::make_shared(LINKER_HAND::L6, HAND_TYPE::RIGHT); + + // 按手别创建 CAN 总线对象 + std::shared_ptr bus = Communication::CommFactory::createCanBus(HAND_TYPE::RIGHT); + + hand->setCanTxCallback([bus](uint32_t can_id, const uint8_t *data, uintptr_t data_len) -> int32_t { + static int tx_count = 0; + tx_count++; + std::cout << "\033[32m[TX #" << tx_count << "]\033[0m " + << getCurrentTime() + << " | CAN_ID: 0x" << std::hex << std::setw(8) << std::setfill('0') << can_id + << std::dec << " (" << can_id << ")" + << " | Len: " << data_len + << " | Data: "; + for (uintptr_t i = 0; i < data_len; i++) { + std::cout << std::hex << std::setw(2) << std::setfill('0') << (int)data[i] << " "; + } + std::cout << std::dec << std::endl; + + std::vector data_vec(data, data + data_len); + bus->send(data_vec, can_id); + return 0; + }); + + hand->setCanRxCallback([bus](uint32_t* can_id_out, uint8_t* data_out, uint8_t* len_out) -> int32_t { + auto frame = bus->recv(); + if (frame.can_id == 0 && frame.can_dlc == 0) { + return -1; + } + *can_id_out = frame.can_id; + *len_out = frame.can_dlc; + memcpy(data_out, frame.data, frame.can_dlc); + return 0; + }); + + std::cout << "----------------------------------------" << std::endl; + + // 打印版本 + SN(SN 里的厂商标识决定 B6 请求报文如何下发) + std::cout << hand->getVersion() << std::endl; + + hand->setTorque({200, 200, 200, 200, 200, 200}); + hand->setSpeed({200, 200, 200, 200, 200, 200}); + + // 握拳 / 张开 + hand->setPosition({255, 255, 0, 0, 0, 0}); + std::this_thread::sleep_for(std::chrono::seconds(1)); + hand->setPosition({255, 104, 255, 255, 255, 255}); + std::this_thread::sleep_for(std::chrono::seconds(1)); + + // 五指矩阵压感 B1~B5 + std::cout << "getForce(五指矩阵)----------------------------------------" << std::endl; + std::vector>> touch_mats = hand->getForce(); + const std::array finger_name = { + "THUMB_TOUCH", "INDEX_TOUCH", "MIDDLE_TOUCH", "RING_TOUCH", "LITTLE_TOUCH" + }; + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + for (size_t n = 0; n < touch_mats.size(); ++n) { + std::cout << finger_name[n] << ":\n"; + for (const auto &row : touch_mats[n]) { + for (uint8_t val : row) + std::cout << std::setw(2) << static_cast(val) << ' '; + std::cout << '\n'; + } + std::cout << '\n'; + } + + // 全掌压感 B6(尺寸随厂商/固件动态变化,palm_mat 按帧行列扩容) + std::cout << "getPalmForce(全掌压感)----------------------------------------" << std::endl; + hand->getPalmForce(); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + std::vector> palm = hand->getPalmForce(); + std::cout << "palm rows: " << palm.size() << std::endl; + for (const auto &row : palm) { + for (uint8_t val : row) + std::cout << std::setw(3) << static_cast(val) << ' '; + std::cout << '\n'; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } + return 0; +} diff --git a/examples/test_o20_canfd_double.cpp b/examples/test_o20_canfd_double.cpp new file mode 100644 index 0000000..014af07 --- /dev/null +++ b/examples/test_o20_canfd_double.cpp @@ -0,0 +1,109 @@ +// O20 / CAN-FD / 双手并存示例 +// ----------------------------------------------------------------------------- +// 关键约定(详见 docs/FAQ.md Q11 / Q12): +// 1. 一根 CAN-FD 适配器对应一只手:右手用 dev_num=0,左手用 dev_num=1 +// (编号取决于插入顺序:先插的是 0,后插的是 1)。 +// 2. Communication::CanFD 是纯传输层,不知道左右手;左右手由 +// LinkerHandApi 构造函数的 HAND_TYPE 决定,O20Hand 内部把它编码为 +// device_id(0x01=右手 / 0x02=左手)写进 29-bit 扩展帧的高位。 +// 3. tx/rx lambda 必须闭包捕获**各自**的 CanFD 实例,两只手绝不共用。 +// 4. HAND_TYPE 传错也没关系:O20Hand 会在 setCanRxCallback 时主动探测 +// 固件自报手型,与传入不一致时静默校正 device_id 并在 stdout 打印 +// "[LinkerHand O20] ⚠ 手型自动校正: ..." 提示。 +// ----------------------------------------------------------------------------- +#include +#include +#include +#include +#include +#include "_win_console_utf8.h" +#include "LinkerHandApi.h" +#include "CommunicationCallbacks.h" +#include "CanFD.h" + +namespace { + +struct HandRuntime { + Communication::CanFD canfd; + std::unique_ptr api; + const char* label; + + HandRuntime(uint32_t dev_num, const char* lbl) + : canfd(dev_num, 0), label(lbl) {} +}; + +bool bringUp(HandRuntime& hand, HAND_TYPE side) { + if (!hand.canfd.init()) { + std::cerr << "[" << hand.label << "] Failed to initialize CANFD" << std::endl; + return false; + } + std::cout << "[" << hand.label << "] CANFD initialized" << std::endl; + + hand.api.reset(new LinkerHandApi(LINKER_HAND::O20, side, COMM_TYPE::CAN)); + + auto tx = [&hand](uint32_t can_id, const uint8_t* data, uintptr_t data_len) -> int32_t { + std::vector data_vec(data, data + data_len); + hand.canfd.send(data_vec, can_id, true); + return 0; + }; + auto rx = [&hand](uint32_t* can_id_out, uint8_t* data_out, uint8_t* data_len_out) -> int32_t { + Communication::CanFDFrame frame = hand.canfd.recv(10); + if (frame.valid) { + *can_id_out = frame.can_id; + *data_len_out = Communication::CanFD::dlcToLen(frame.can_dlc); + memcpy(data_out, frame.data, *data_len_out); + return 0; + } + return -1; + }; + + hand.api->setCanTxCallback(tx); + hand.api->setCanRxCallback(rx); + return true; +} + +} // namespace + +int main() { + std::cout << "O20 CANFD Double-Hand Test" << std::endl; + + HandRuntime right(0, "RIGHT"); + HandRuntime left (1, "LEFT "); + + if (!bringUp(right, HAND_TYPE::RIGHT)) { + return 1; + } + if (!bringUp(left, HAND_TYPE::LEFT)) { + right.canfd.close(); + return 1; + } + + // 探测 + 常规读请求已在 setCanRxCallback 里发起,稍等一下让响应就绪 + std::this_thread::sleep_for(std::chrono::seconds(1)); + + try { + std::cout << "[RIGHT] Version: " << right.api->getVersion() << std::endl; + std::cout << "[LEFT ] Version: " << left.api->getVersion() << std::endl; + + std::vector pos(34, 0); + right.api->setPosition(pos); + left.api->setPosition(pos); + + std::this_thread::sleep_for(std::chrono::seconds(1)); + + auto dump_speed = [](const char* lbl, const std::vector& v) { + std::cout << "[" << lbl << "] Speed:"; + for (auto b : v) std::cout << ' ' << (int)b; + std::cout << std::endl; + }; + dump_speed("RIGHT", right.api->getSpeed()); + dump_speed("LEFT ", left.api->getSpeed()); + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << std::endl; + } + + right.canfd.close(); + left.canfd.close(); + std::cout << "Test completed" << std::endl; + return 0; +} diff --git a/examples/test_o20_canfd_socket_0.cpp b/examples/test_o20_canfd_socket_0.cpp new file mode 100644 index 0000000..b9bcaac --- /dev/null +++ b/examples/test_o20_canfd_socket_0.cpp @@ -0,0 +1,84 @@ +// O20 / SocketCAN 原生 CAN-FD / 右手 —— 版本/动作/速度最小示例 +// +// 与 test_o20_canfd_0.cpp 的区别:本示例走 Linux 内核 SocketCAN 原生 CAN FD +// (CanFDSocket),而非厂商 libcanbus 驱动(CanFD)。使用前先在内核侧配置 can0: +// sudo ip link set can0 down +// sudo ip link set can0 type can bitrate 1000000 dbitrate 5000000 fd on restart-ms 100 +// sudo ip link set can0 up +// sudo ip link set can0 txqueuelen 1000 +#include +#include +#include +#include +#include +#include "_win_console_utf8.h" +#include "LinkerHandApi.h" +#include "CommunicationCallbacks.h" +#include "CanFDSocket.h" + +int main() { + std::cout << "O20 SocketCAN CANFD Test" << std::endl; + + // 打开 SocketCAN 原生 CANFD 接口(波特率已由 ip link 在内核侧配置) + Communication::CanFDSocket canfd("can0"); + if (!canfd.init()) { + std::cerr << "Failed to open SocketCAN CANFD (can0)" << std::endl; + return 1; + } + std::cout << "SocketCAN CANFD opened successfully" << std::endl; + + try { + LinkerHandApi api(LINKER_HAND::O20, HAND_TYPE::RIGHT, COMM_TYPE::CAN); + + std::cout << "LinkerHandApi created" << std::endl; + + auto can_tx_callback = [&](uint32_t can_id, const uint8_t* data, uintptr_t data_len) -> int32_t { + std::vector data_vec(data, data + data_len); + canfd.send(data_vec, can_id, true); + return 0; + }; + + auto can_rx_callback = [&](uint32_t* can_id_out, uint8_t* data_out, uint8_t* data_len_out) -> int32_t { + Communication::CanFDFrame frame = canfd.recv(10); + if (frame.valid) { + *can_id_out = frame.can_id; + *data_len_out = frame.can_dlc; // SocketCAN 变体:can_dlc 即实际字节长度 + memcpy(data_out, frame.data, *data_len_out); + return 0; + } + return -1; + }; + + api.setCanTxCallback(can_tx_callback); + api.setCanRxCallback(can_rx_callback); + + std::cout << "Callbacks registered, sleeping 1 second..." << std::endl; + std::this_thread::sleep_for(std::chrono::seconds(1)); + + std::cout << "Calling getVersion()..." << std::endl; + std::string version = api.getVersion(); + std::cout << "Version: " << version << std::endl; + + std::vector pos(34, 0); + std::cout << "Calling setPosition()..." << std::endl; + api.setPosition(pos); + + std::cout << "Calling getSpeed()..." << std::endl; + std::vector speed = api.getSpeed(); + for (size_t i = 0; i < speed.size(); i++) { + std::cout << (int)speed[i] << " "; + } + std::cout << std::dec << std::endl; + + std::this_thread::sleep_for(std::chrono::seconds(1)); + + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << std::endl; + canfd.close(); + return 1; + } + + canfd.close(); + std::cout << "Test completed" << std::endl; + return 0; +} diff --git a/examples/web_bridge.cpp b/examples/web_bridge.cpp new file mode 100644 index 0000000..0b93ebf --- /dev/null +++ b/examples/web_bridge.cpp @@ -0,0 +1,392 @@ +// web_bridge — 全型号 stdin->CAN 桥,供无依赖 Python web 前端(webui/run.py)驱动。 +// 进程内构造 LinkerHandApi,按型号选普通 CAN 或 O20 CAN-FD 接线,从 stdin 读文本命令下发 +// 姿势/速度/力矩,周期性把实测位置、速度、力矩、触觉压感(含掌心)以文本/JSON 行写到 stdout。 +// +// 用法: web_bridge [comm] [channel] +// MODEL L6/L7/L10/L20/L21/L25/G20/O6/O20 +// side left|right +// comm 可选,缺省 can。can | canfd | modbus。 +// modbus 仅支持 O6/L7/L10;O20 请用 canfd。 +// channel 可选。can:CAN 接口名(如 can0),留空按 side 自动探测。 +// canfd(O20):留空用厂商 CAN-FD 设备(0,0);"socketcan:can0" 用内核原生 CAN-FD。 +// modbus:串口路径(如 /dev/ttyUSB0),留空按 side 自动探测。 +// +// stdin 命令(空白分隔,每行一条): +// P v0..vN -> setPosition (N 个 0..255,长度须 = DOF) +// S v0..vN -> setSpeed +// T v0..vN -> setTorque +// R ch hz -> 设置某通道回读频率(ch=pos|st|force|temp|fault,hz=1~100) +// Q -> 退出 +// stdout 输出: +// READY 初始化完成 +// META {json} 一次:{"model","dof","version"} +// POS v0..vN 关节实测位置,~10Hz +// SPD v0..vN / TRQ v0..vN 当前速度/力矩回读,低频 +// FORCE {"fingers":[[[..]]]} 触觉矩阵(维度按型号自适应),有传感器时持续 ~5Hz(全 0 也发) +// PALM {"palm":[[..]]} 掌心矩阵(O6/G20),有传感器时持续 ~5Hz(全 0 也发) +// TEMP v0..vN 逐关节温度(°C),~1Hz,非空才发 +// FAULT v0..vN 逐关节故障码,~1Hz,非空才发 +// +// 读线程只把 stdin 整行入队;所有 SDK/CAN 调用都在主线程做,命令下发与回读轮询不会 +// 在总线上竞争。基于 examples/o6_web_bridge.cpp(参考仓库)与 test_o6_can_0.cpp。 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "LinkerHandApi.h" +#include "CommFactory.h" +#include "Modbus.h" +#if defined(WEB_BRIDGE_HAS_CANFD) +#include "CanFD.h" +#endif +#if defined(__linux__) +#include "CanFDSocket.h" +#endif + +static std::mutex g_qmtx; +static std::deque g_queue; +static std::atomic g_running{true}; + +// 型号字符串 -> 枚举(与 hand_teach_pendant/src/main.cpp 的 -t 解析保持一致)。 +static bool parse_model(const std::string& s, LINKER_HAND& out) { + if (s == "L6") out = LINKER_HAND::L6; + else if (s == "L7") out = LINKER_HAND::L7; + else if (s == "L10") out = LINKER_HAND::L10; + else if (s == "L20") out = LINKER_HAND::L20; + else if (s == "L21") out = LINKER_HAND::L21; + else if (s == "L25") out = LINKER_HAND::L25; + else if (s == "G20") out = LINKER_HAND::G20; + else if (s == "O6") out = LINKER_HAND::O6; + else if (s == "O20") out = LINKER_HAND::O20; + else return false; + return true; +} + +// 型号 -> 控制自由度(复刻 hand_teach_pendant/src/HandController.cpp:get_dof)。 +// 前端按此渲染滑块;回读长度以 getPosition().size() 为准(O20 回读 17 > DOF 16)。 +static int dof_of(LINKER_HAND m) { + switch (m) { + case L6: case O6: return 6; + case L7: return 7; + case L10: return 10; + case L20: return 20; + case L21: case L25: return 25; + case G20: return 16; + case O20: return 16; + default: return 10; + } +} + +// —— 极简 JSON 序列化(避免任何第三方 JSON 依赖)—— +static std::string json_row(const std::vector& v) { + std::ostringstream o; + o << '['; + for (size_t i = 0; i < v.size(); ++i) { if (i) o << ','; o << (int)v[i]; } + o << ']'; + return o.str(); +} +static std::string json_mat(const std::vector>& m) { + std::ostringstream o; + o << '['; + for (size_t i = 0; i < m.size(); ++i) { if (i) o << ','; o << json_row(m[i]); } + o << ']'; + return o.str(); +} +static std::string json_cube(const std::vector>>& c) { + std::ostringstream o; + o << '['; + for (size_t i = 0; i < c.size(); ++i) { if (i) o << ','; o << json_mat(c[i]); } + o << ']'; + return o.str(); +} + +// 是否含单元格结构(有传感器即持续上报,哪怕全 0;无传感器时 getForce 返回空则不发)。 +static bool cube_has_cells(const std::vector>>& c) { + for (auto& m : c) for (auto& r : m) if (!r.empty()) return true; + return false; +} +static bool mat_has_cells(const std::vector>& m) { + for (auto& r : m) if (!r.empty()) return true; + return false; +} + +int main(int argc, char** argv) { + if (argc < 3) { + std::cerr << "usage: web_bridge [channel]" << std::endl; + return 2; + } + LINKER_HAND model; + if (!parse_model(argv[1], model)) { + std::cerr << "BRIDGE_ERROR: unknown model '" << argv[1] << "'" << std::endl; + return 2; + } + const std::string model_str = argv[1]; + HAND_TYPE side = (std::string(argv[2]) == "right") ? HAND_TYPE::RIGHT : HAND_TYPE::LEFT; + const std::string comm = (argc > 3) ? argv[3] : "can"; + const std::string channel = (argc > 4) ? argv[4] : ""; + const int dof = dof_of(model); + + // canfd 是 CAN 之上的传输变体(仅 O20 有意义),SDK 仍以 COMM_TYPE::CAN 构造。 + const bool is_modbus = (comm == "modbus"); + const bool is_canfd = (comm == "canfd") || (model == LINKER_HAND::O20 && comm != "modbus"); + + try { + COMM_TYPE ct = is_modbus ? COMM_TYPE::MODBUS : COMM_TYPE::CAN; + auto hand = std::make_shared(model, side, ct); + + if (is_modbus) { + // Modbus 仅 O6/L7/L10 有对应 hand 实现(HandFactory 其余会抛异常)。 + if (model != LINKER_HAND::O6 && model != LINKER_HAND::L7 && model != LINKER_HAND::L10) { + std::cerr << "BRIDGE_ERROR: Modbus 仅支持 O6/L7/L10(当前 " << model_str << ")" << std::endl; + return 1; + } + // channel 为串口路径;留空按 side 自动探测。 + auto mb = channel.empty() + ? std::make_shared(side, 115200) + : std::make_shared(channel, 115200); + if (!mb->isOpen()) { std::cerr << "BRIDGE_ERROR: Modbus 串口打开失败 (" << (channel.empty() ? "auto" : channel) << ")" << std::endl; return 1; } + hand->setModbusTxCallback([mb](uint8_t /*sid*/, uint16_t /*addr*/, const uint8_t* d, uintptr_t n) -> int32_t { + try { return mb->sendRawFrame(d, n) ? 0 : -1; } catch (...) { return -1; } + }); + hand->setModbusRxCallback([mb](uint8_t sid, uint16_t* addr_out, uint8_t* d_out, uint8_t* n_out) -> int32_t { + try { + int len = mb->receiveCompleteFrame(d_out, 256, 500); + if (len <= 0 || d_out[0] != sid) return -1; + *n_out = static_cast(len); + if (addr_out) *addr_out = 0; + } catch (...) { return -1; } + return 0; + }); + } else if (is_canfd) { + // O20 走 CAN-FD。默认厂商 libcanbus;channel="socketcan:can0" 时用内核原生。 + bool use_socketcan = channel.rfind("socketcan:", 0) == 0; + if (use_socketcan) { +#if defined(__linux__) + std::string iface = channel.substr(std::string("socketcan:").size()); + if (iface.empty()) iface = "can0"; + auto cf = std::make_shared(iface); + if (!cf->init()) { std::cerr << "BRIDGE_ERROR: CanFDSocket init failed (" << iface << ")" << std::endl; return 1; } + hand->setCanTxCallback([cf](uint32_t id, const uint8_t* d, uintptr_t n) -> int32_t { + try { std::vector v(d, d + n); cf->send(v, id, true); } catch (...) { return -1; } + return 0; + }); + hand->setCanRxCallback([cf](uint32_t* id_out, uint8_t* d_out, uint8_t* n_out) -> int32_t { + try { + Communication::CanFDFrame f = cf->recv(10); + if (!f.valid) return -1; + *id_out = f.can_id; + *n_out = f.can_dlc; // SocketCAN 变体:can_dlc 即实际字节长度 + memcpy(d_out, f.data, f.can_dlc); + } catch (...) { return -1; } + return 0; + }); +#else + std::cerr << "BRIDGE_ERROR: socketcan CAN-FD is Linux-only" << std::endl; return 1; +#endif + } else { +#if defined(WEB_BRIDGE_HAS_CANFD) + auto cf = std::make_shared(0, 0); + if (!cf->init()) { std::cerr << "BRIDGE_ERROR: CanFD init failed" << std::endl; return 1; } + hand->setCanTxCallback([cf](uint32_t id, const uint8_t* d, uintptr_t n) -> int32_t { + try { std::vector v(d, d + n); cf->send(v, id, true); } catch (...) { return -1; } + return 0; + }); + hand->setCanRxCallback([cf](uint32_t* id_out, uint8_t* d_out, uint8_t* n_out) -> int32_t { + try { + Communication::CanFDFrame f = cf->recv(10); + if (!f.valid) return -1; + *id_out = f.can_id; + *n_out = Communication::CanFD::dlcToLen(f.can_dlc); // 厂商变体:DLC 编码需转字节数 + memcpy(d_out, f.data, *n_out); + } catch (...) { return -1; } + return 0; + }); +#else + std::cerr << "BRIDGE_ERROR: O20 needs CAN-FD, but this build has no vendor CAN-FD. " + "Rebuild with USE_CANFD=ON, or pass channel 'socketcan:can0'." << std::endl; + return 1; +#endif + } + } else { + // 普通型号:SocketCAN / PCAN。channel 留空按 side 自动探测。 + std::shared_ptr bus = + channel.empty() ? Communication::CommFactory::createCanBus(side) + : Communication::CommFactory::createCanBus(channel, 1000000); + hand->setCanTxCallback([bus](uint32_t id, const uint8_t* d, uintptr_t n) -> int32_t { + try { std::vector v(d, d + n); bus->send(v, id); } catch (...) { return -1; } + return 0; + }); + hand->setCanRxCallback([bus](uint32_t* id_out, uint8_t* d_out, uint8_t* n_out) -> int32_t { + try { + auto f = bus->recv(); + if (f.can_id == 0 && f.can_dlc == 0) return -1; + *id_out = f.can_id; + *n_out = f.can_dlc; + memcpy(d_out, f.data, f.can_dlc); + } catch (...) { return -1; } + return 0; + }); + } + + // 保守初始化:中等速度/力矩,长度 = DOF。 + hand->setSpeed(std::vector(dof, 180)); + hand->setTorque(std::vector(dof, 180)); + + // 版本串各字段以换行分隔;压进单行 JSON 用 ';' 保留字段边界(前端按 ';' 切段),引号替空格。 + auto sanitize = [](std::string v) { + for (char& c : v) { if (c == '"') c = ' '; else if (c == '\n' || c == '\r') c = ';'; } + return v; + }; + // 各回读通道周期(ms),前端可经 stdin "R " 调整(1~100Hz)。 + // force 决定 getForce() 触发 B1~B5 的节拍,也就是 B5→下一轮 B1 的空档。 + int pos_ms = 100, st_ms = 200, force_ms = 33, temp_ms = 1000, fault_ms = 1000; + + auto hz_of = [](int period_ms) { return period_ms > 0 ? 1000 / period_ms : 0; }; + auto emit_meta = [&](const std::string& v) { + std::cout << "META {\"model\":\"" << model_str << "\",\"dof\":" << dof + << ",\"version\":\"" << v << "\"" + << ",\"rates\":{\"pos\":" << hz_of(pos_ms) << ",\"st\":" << hz_of(st_ms) + << ",\"force\":" << hz_of(force_ms) << ",\"temp\":" << hz_of(temp_ms) + << ",\"fault\":" << hz_of(fault_ms) << "}" + << "}" << std::endl; + }; + + // getVersion 发请求后读缓存,版本回帧由 rx 线程异步填充;刚上电时手在预热,可能迟迟不到。 + // 启动先快速轮询 ~1.5s;仍拿不到也照常 READY,之后由主循环每 ~500ms 重试并补发 META。 + std::string version; + for (int i = 0; i < 30 && version.empty(); ++i) { + try { version = hand->getVersion(); } catch (...) {} + if (version.empty()) std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + version = sanitize(version); + bool meta_has_version = !version.empty(); + + std::cout << "READY" << std::endl; + emit_meta(version); + + // 读线程:只把 stdin 整行入队;EOF -> 停机。 + std::thread reader([] { + std::string line; + while (std::getline(std::cin, line)) { + std::lock_guard lk(g_qmtx); + g_queue.push_back(line); + } + g_running = false; + }); + reader.detach(); + + auto apply = [&](const std::string& line) { + std::istringstream ss(line); + std::string cmd; + if (!(ss >> cmd)) return; + if (cmd == "Q" || cmd == "q") { g_running = false; return; } + if (cmd == "R" || cmd == "r") { // 回读频率(Hz):R ,限幅 1~100 + std::string chan; int hz = 0; + if ((ss >> chan) && (ss >> hz)) { + if (hz < 1) hz = 1; + if (hz > 100) hz = 100; + const int p = 1000 / hz; + if (chan == "pos") pos_ms = p; + else if (chan == "st") st_ms = p; + else if (chan == "force") force_ms = p; + else if (chan == "temp") temp_ms = p; + else if (chan == "fault") fault_ms = p; + } + return; + } + if (cmd != "P" && cmd != "S" && cmd != "T") return; + std::vector vals; + int x; + while (ss >> x) { + if (x < 0) x = 0; + if (x > 255) x = 255; + vals.push_back(static_cast(x)); + } + if ((int)vals.size() != dof) return; // 长度不符直接丢弃 + try { + if (cmd == "P") hand->setPosition(vals); + else if (cmd == "S") hand->setSpeed(vals); + else hand->setTorque(vals); + } catch (const std::exception& e) { + std::cerr << "BRIDGE_WARN: apply failed: " << e.what() << std::endl; + } + }; + + // 主循环:5ms 轮询排空命令 + 时间戳驱动分频回读,各通道按各自周期上报。 + // 用时间戳而非 tick 计数,因 30Hz(33ms) 无法由固定 tick 整除得到。 + using clk = std::chrono::steady_clock; + using ms = std::chrono::milliseconds; + auto now = clk::now(); + auto next_pos = now, next_st = now, next_force = now, next_temp = now, next_fault = now; + auto next_meta = now + ms(500); // 版本未就绪时的补发节拍 + while (g_running) { + std::deque local; + { + std::lock_guard lk(g_qmtx); + local.swap(g_queue); + } + for (auto& ln : local) apply(ln); + + now = clk::now(); + // 刚上电时设备信息可能晚到;未就绪则周期重试 getVersion,拿到后补发 META + // (Python 端 reader 会 meta.update,前端轮询时刷新设备信息,无需重启)。 + if (!meta_has_version && now >= next_meta) { + next_meta = now + ms(500); + std::string v; + try { v = sanitize(hand->getVersion()); } catch (...) {} + if (!v.empty()) { meta_has_version = true; emit_meta(v); } + } + try { + if (now >= next_pos) { // 位置回读(周期可调) + next_pos = now + ms(pos_ms); + std::vector pos = hand->getPosition(); + if (!pos.empty()) { std::ostringstream o; o << "POS"; for (auto v : pos) o << ' ' << (int)v; std::cout << o.str() << std::endl; } + } + if (now >= next_st) { // 速度/力矩回读(周期可调) + next_st = now + ms(st_ms); + std::vector sp = hand->getSpeed(); + std::vector tq = hand->getTorque(); + if (!sp.empty()) { std::ostringstream o; o << "SPD"; for (auto v : sp) o << ' ' << (int)v; std::cout << o.str() << std::endl; } + if (!tq.empty()) { std::ostringstream o; o << "TRQ"; for (auto v : tq) o << ' ' << (int)v; std::cout << o.str() << std::endl; } + } + if (now >= next_force) { // 触觉/掌心,周期由前端可调 + next_force = now + ms(force_ms); + auto force = hand->getForce(); + if (cube_has_cells(force)) + std::cout << "FORCE {\"fingers\":" << json_cube(force) << "}" << std::endl; + auto palm = hand->getPalmForce(); + if (mat_has_cells(palm)) + std::cout << "PALM {\"palm\":" << json_mat(palm) << "}" << std::endl; + } + if (now >= next_temp) { // 温度回读(周期可调) + next_temp = now + ms(temp_ms); + std::vector tp = hand->getTemperature(); + if (!tp.empty()) { std::ostringstream o; o << "TEMP"; for (auto v : tp) o << ' ' << (int)v; std::cout << o.str() << std::endl; } + } + if (now >= next_fault) { // 故障码回读(周期可调) + next_fault = now + ms(fault_ms); + std::vector fc = hand->getFaultCode(); + if (!fc.empty()) { std::ostringstream o; o << "FAULT"; for (auto v : fc) o << ' ' << (int)v; std::cout << o.str() << std::endl; } + } + } catch (const std::exception& e) { + std::cerr << "BRIDGE_WARN: readback failed: " << e.what() << std::endl; + } + + std::this_thread::sleep_for(ms(5)); + } + } catch (const std::exception& e) { + std::cerr << "BRIDGE_ERROR: " << e.what() << std::endl; + return 1; + } catch (...) { + std::cerr << "BRIDGE_ERROR: unknown exception" << std::endl; + return 1; + } + return 0; +} diff --git a/include/api/LinkerHandApi.h b/include/api/LinkerHandApi.h index e83be67..41ba90d 100644 --- a/include/api/LinkerHandApi.h +++ b/include/api/LinkerHandApi.h @@ -69,6 +69,13 @@ class LINKERHAND_API LinkerHandApi // 获取压感 std::vector>> getForce(); + // 获取手掌点阵(TSSP_JZG 20x28 / TSSP_HWK 14x16) + std::vector> getPalmForce(); + // 获取五指合力值(TSSP_JZG 每指3 / TSSP_HWK 每指1) + std::vector> getFingerForceSum(); + // 获取手掌合力值(TSSP_JZG 2 / TSSP_HWK 1) + std::vector getPalmForceSum(); + // 获取版本号 std::string getVersion(); // ---------------------------------------------------------- diff --git a/include/communication/CanFDSocket.h b/include/communication/CanFDSocket.h new file mode 100644 index 0000000..036a95b --- /dev/null +++ b/include/communication/CanFDSocket.h @@ -0,0 +1,52 @@ +#ifdef __linux__ +#ifndef CANFD_SOCKET_H +#define CANFD_SOCKET_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "communication/ICanFD.h" +#include "core/LinkerHandExport.h" + +namespace linkerhand { +namespace communication +{ + // 基于 Linux 内核 SocketCAN 的原生 CAN FD 后端。 + // 与厂商驱动版 CanFD(third_party/libcanbus)并列,只用 linux/can.h,无 third_party 依赖。 + // 波特率由用户在内核侧配置(sudo ip link set canX type can bitrate dbitrate fd on), + // 本类只负责打开套接字、开启 FD 帧模式、收发。 + class LINKERHAND_API CanFDSocket : public ICanFD + { + public: + explicit CanFDSocket(const std::string& interface = "can0"); + ~CanFDSocket(); + + bool init(); + void close(); + bool isOpen() const override; + + void send(const std::vector& data, uint32_t can_id, bool is_extended = true) override; + CanFDFrame recv(int timeout_ms = 100) override; + + private: + int socket_fd = -1; + std::string interface; + std::atomic is_open{false}; + std::mutex tx_mutex; + std::mutex rx_mutex; + }; +} // namespace communication +} // namespace linkerhand + +namespace Communication { + using CanFDSocket = ::linkerhand::communication::CanFDSocket; +} + +#endif // CANFD_SOCKET_H +#endif // __linux__ diff --git a/include/communication/CommFactory.h b/include/communication/CommFactory.h index 64d9b7a..06f8a3f 100644 --- a/include/communication/CommFactory.h +++ b/include/communication/CommFactory.h @@ -9,8 +9,11 @@ #include "communication/ICanBus.h" #include "communication/CanBus.h" #include "communication/PCANBus.h" -#if LINKERHAND_USE_CANFD #include "communication/ICanFD.h" +#ifdef __linux__ +#include "communication/CanFDSocket.h" +#endif +#if LINKERHAND_USE_CANFD #include "communication/CanFD.h" #endif #include "communication/IModbus.h" @@ -123,6 +126,19 @@ namespace communication } #endif // LINKERHAND_USE_CANFD + // ============ CAN FD (SocketCAN 原生, 仅 Linux) ============ + // 内核 SocketCAN 后端,不依赖 third_party/libcanbus,故不受 USE_CANFD 门控。 + // 波特率需先在内核侧配置: + // sudo ip link set canX type can bitrate dbitrate fd on + #ifdef __linux__ + static std::unique_ptr createCanFDSocket(const std::string& interface = "can0") + { + auto fd = std::unique_ptr(new CanFDSocket(interface)); + fd->init(); + return std::unique_ptr(std::move(fd)); + } + #endif + // ====================== Modbus RTU ====================== static std::unique_ptr createModbus(const std::string& device, diff --git a/lib/linux/arm64/linkerhand_cpp_sdk.so.2 b/lib/linux/arm64/linkerhand_cpp_sdk.so.2 index 5046f44..d427e22 120000 --- a/lib/linux/arm64/linkerhand_cpp_sdk.so.2 +++ b/lib/linux/arm64/linkerhand_cpp_sdk.so.2 @@ -1 +1 @@ -linkerhand_cpp_sdk.so.2.0.0 \ No newline at end of file +linkerhand_cpp_sdk.so.2.1.9 \ No newline at end of file diff --git a/lib/linux/arm64/linkerhand_cpp_sdk.so.2.0.0 b/lib/linux/arm64/linkerhand_cpp_sdk.so.2.0.0 deleted file mode 100755 index 79352eb..0000000 Binary files a/lib/linux/arm64/linkerhand_cpp_sdk.so.2.0.0 and /dev/null differ diff --git a/lib/linux/arm64/linkerhand_cpp_sdk.so.2.1.9 b/lib/linux/arm64/linkerhand_cpp_sdk.so.2.1.9 new file mode 100644 index 0000000..b353985 Binary files /dev/null and b/lib/linux/arm64/linkerhand_cpp_sdk.so.2.1.9 differ diff --git a/lib/linux/x86_64/linkerhand_cpp_sdk.so.2 b/lib/linux/x86_64/linkerhand_cpp_sdk.so.2 index 5046f44..d427e22 120000 --- a/lib/linux/x86_64/linkerhand_cpp_sdk.so.2 +++ b/lib/linux/x86_64/linkerhand_cpp_sdk.so.2 @@ -1 +1 @@ -linkerhand_cpp_sdk.so.2.0.0 \ No newline at end of file +linkerhand_cpp_sdk.so.2.1.9 \ No newline at end of file diff --git a/lib/linux/x86_64/linkerhand_cpp_sdk.so.2.0.0 b/lib/linux/x86_64/linkerhand_cpp_sdk.so.2.0.0 deleted file mode 100755 index 1166e39..0000000 Binary files a/lib/linux/x86_64/linkerhand_cpp_sdk.so.2.0.0 and /dev/null differ diff --git a/lib/linux/x86_64/linkerhand_cpp_sdk.so.2.1.9 b/lib/linux/x86_64/linkerhand_cpp_sdk.so.2.1.9 new file mode 100644 index 0000000..281c474 Binary files /dev/null and b/lib/linux/x86_64/linkerhand_cpp_sdk.so.2.1.9 differ diff --git a/lib/win64/mingw/libgcc_s_seh-1.dll b/lib/win64/mingw/libgcc_s_seh-1.dll old mode 100755 new mode 100644 diff --git a/lib/win64/mingw/libstdc++-6.dll b/lib/win64/mingw/libstdc++-6.dll old mode 100755 new mode 100644 diff --git a/lib/win64/mingw/linkerhand_cpp_sdk.dll b/lib/win64/mingw/linkerhand_cpp_sdk.dll old mode 100755 new mode 100644 index c7bf3c8..3d5d47e Binary files a/lib/win64/mingw/linkerhand_cpp_sdk.dll and b/lib/win64/mingw/linkerhand_cpp_sdk.dll differ diff --git a/lib/win64/msvc/linkerhand_cpp_sdk.dll b/lib/win64/msvc/linkerhand_cpp_sdk.dll index aa7f786..6d939dd 100644 Binary files a/lib/win64/msvc/linkerhand_cpp_sdk.dll and b/lib/win64/msvc/linkerhand_cpp_sdk.dll differ diff --git a/lib/win64/msvc/linkerhand_cpp_sdk.lib b/lib/win64/msvc/linkerhand_cpp_sdk.lib index fc129d1..3722ccc 100644 Binary files a/lib/win64/msvc/linkerhand_cpp_sdk.lib and b/lib/win64/msvc/linkerhand_cpp_sdk.lib differ diff --git a/webui/README.md b/webui/README.md new file mode 100644 index 0000000..6be1bae --- /dev/null +++ b/webui/README.md @@ -0,0 +1,72 @@ +# Web 示教器(webui/) + +一套开箱即用的灵巧手网页控制界面,全型号通用,`./build.sh -b` 编译后 `python3 webui/run.py` 即可使用。 + +支持型号:L6 / L7 / L10 / L20 / L21 / L25 / G20 / O6 / O20(O20 走 CAN-FD)。 + +## 前置 + +1. 编译 SDK: + ```bash + ./build.sh -b + ``` +2. **CAN / CAN-FD 型号**:拉起对应 CAN 接口并给手上电,例如: + ```bash + sudo ip link set can0 up type can bitrate 1000000 + # O20 原生 CAN-FD(可选,配合 --channel socketcan:can0): + # sudo ip link set can0 up type can bitrate 1000000 dbitrate 5000000 fd on + ``` + 也可用仓库根目录的 `can-autocfg.sh` 一次性配好自动激活/关闭。 +3. **Modbus 型号(RS485 串口)**:给串口读写权限: + ```bash + sudo chmod 0777 /dev/ttyUSB0 + # 或把当前用户加入 dialout 组(重新登录后生效): + # sudo usermod -aG dialout $USER + ``` + +## 运行 + +两种方式,任选其一。 + +**① 在线连接(推荐)**:不带 `--model` 启动,浏览器打开后在页面「设置」面板里选择型号 / 侧别 / 通信方式并连接,支持热重连与主动断开。 + +```bash +python3 webui/run.py +``` + +**② 命令行直连**:启动即按参数连接指定型号。 + +```bash +python3 webui/run.py --model O6 --side left +python3 webui/run.py --model L10 --side left --channel can0 +python3 webui/run.py --model O20 --side right # 厂商 CAN-FD 设备 +python3 webui/run.py --model O20 --side right --channel socketcan:can0 +python3 webui/run.py --model L10 --comm modbus --channel /dev/ttyUSB0 +``` + +启动后浏览器打开 `http://<本机IP>:8080/`。 + +参数: + +| 参数 | 说明 | 默认 | +|------|------|------| +| `--model` | 启动即连的型号(L6/O6/L7/L10/L20/G20/L21/L25/O20);不填则在前端在线连接 | 无 | +| `--side` | `left` / `right` | `left` | +| `--comm` | `can` / `canfd` / `modbus`;缺省按型号自动(O20→canfd,其余→can) | 自动 | +| `--channel` | CAN 接口名(如 `can0`)/ Modbus 串口(如 `/dev/ttyUSB0`);O20 可用 `socketcan:can0` | 自动 | +| `--host` | 监听地址 | `0.0.0.0` | +| `--port` | 监听端口 | `8080` | + +## 功能 + +- 按型号动态渲染关节滑块,实时位置回读 +- 速度 / 力矩设置与回读 +- 触觉压感热力图(含掌心,O6 / G20) +- 温度 / 故障监控(温度 >50 黄、>60 红,故障码非 0 红) +- 版本信息展示、明暗主题切换 + +## 注意 + +- **未拉起 CAN / 未上电**:前端提示"等待 READY 超时",检查总线与供电。 +- **Modbus 串口无权限**(`Permission denied: /dev/ttyUSB0`):先 `sudo chmod 0777 /dev/ttyUSB0`,或把用户加入 `dialout` 组(见「前置」第 3 步)。 +- **触觉 / 温度 / 故障**:仅支持的型号有数据;无数据时不渲染或显示占位提示。 diff --git a/webui/run.py b/webui/run.py new file mode 100644 index 0000000..c337cfb --- /dev/null +++ b/webui/run.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""LinkerHand 全型号 Web 示教器 —— 纯 Python 标准库,零第三方依赖。 + +启动编译好的 C++ 桥 `web_bridge`(build/bin/)作为子进程,通过 stdin/stdout 文本协议 +下发姿势/速度/力矩、读回实测位置与触觉压感;浏览器端用 HTTP 轮询交互。任何装了 Python3 +的平台解压即用,无需 pip、Boost、websocketpp。 + +用法: + python3 webui/run.py --model O6 --side left [--channel can0] + [--host 0.0.0.0] [--port 8080] [--bridge PATH] + +然后浏览器打开 http://<本机>:8080/ 。运行前需先拉起对应 CAN 接口并给手上电。 +""" +import argparse +import json +import mimetypes +import os +import threading +import subprocess +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_BRIDGE = os.path.join(REPO, "build", "bin", "web_bridge") +STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") + +# 型号 -> 关节名(复刻 hand_teach_pendant/src/HandController.h:joint_names_)。 +# 数量 = 该型号控制自由度,前端据此渲染滑块与标签。 +JOINT_NAMES = { + "L6": ["拇指根部", "拇指侧摆", "食指根部", "中指根部", "无名指根部", "小指根部"], + "O6": ["拇指根部", "拇指侧摆", "食指根部", "中指根部", "无名指根部", "小指根部"], + "L7": ["大拇指弯曲", "大拇指横摆", "食指弯曲", "中指弯曲", "无名指弯曲", "小拇指弯曲", "拇指旋转"], + "L10": ["拇指根部", "拇指侧摆", "食指根部", "中指根部", "无名指根部", "小指根部", + "食指侧摆", "无名指侧摆", "小指侧摆", "拇指旋转"], + "L20": ["拇指根部", "食指根部", "中指根部", "无名指根部", "小指根部", "拇指侧摆", + "食指侧摆", "中指侧摆", "无名指侧摆", "小指侧摆", "拇指横摆", "预留1", + "预留2", "预留3", "预留4", "拇指尖部", "食指末端", "中指末端", "无名指末端", "小指末端"], + "G20": ["拇指根部", "食指根部", "中指根部", "无名指根部", "小指根部", + "拇指侧摆", "食指侧摆", "中指侧摆", "无名指侧摆", "小指侧摆", "拇指横摆", + "拇指指尖", "食指指尖", "中指指尖", "无名指指尖", "小指指尖"], + "L21": ["大拇指根部", "食指根部", "中指根部", "无名指根部", "小拇指根部", "大拇指侧摆", + "食指侧摆", "中指侧摆", "无名指侧摆", "小拇指侧摆", "大拇指横滚", "预留1", + "预留2", "预留3", "预留4", "大拇指中部", "预留5", "预留6", "预留7", "预留8", + "大拇指指尖", "食指指尖", "中指指尖", "无名指指尖", "小拇指指尖"], + "L25": ["大拇指根部", "食指根部", "中指根部", "无名指根部", "小拇指根部", "大拇指侧摆", + "食指侧摆", "中指侧摆", "无名指侧摆", "小拇指侧摆", "大拇指横滚", "预留1", + "预留2", "预留3", "预留4", "大拇指中部", "食指中部", "中指中部", "无名指中部", + "小拇指中部", "大拇指指尖", "食指指尖", "中指指尖", "无名指指尖", "小拇指指尖"], + "O20": ["拇指指根", "拇指指尖", "拇指侧摆", "拇指旋转", + "食指侧摆", "食指指根", "食指指尖", + "中指侧摆", "中指指根", "中指指尖", + "无名指侧摆", "无名指指根", "无名指指尖", + "小指侧摆", "小指指根", "小指指尖"], +} + +# 与 JOINT_NAMES 同型号、同顺序的英文关节名,供前端英文界面使用。 +JOINT_NAMES_EN = { + "L6": ["Thumb base", "Thumb abd.", "Index base", "Middle base", "Ring base", "Pinky base"], + "O6": ["Thumb base", "Thumb abd.", "Index base", "Middle base", "Ring base", "Pinky base"], + "L7": ["Thumb flex", "Thumb roll", "Index flex", "Middle flex", "Ring flex", "Pinky flex", "Thumb rot."], + "L10": ["Thumb base", "Thumb abd.", "Index base", "Middle base", "Ring base", "Pinky base", + "Index abd.", "Ring abd.", "Pinky abd.", "Thumb rot."], + "L20": ["Thumb base", "Index base", "Middle base", "Ring base", "Pinky base", "Thumb abd.", + "Index abd.", "Middle abd.", "Ring abd.", "Pinky abd.", "Thumb roll", "Reserved 1", + "Reserved 2", "Reserved 3", "Reserved 4", "Thumb tip", "Index tip", "Middle tip", "Ring tip", "Pinky tip"], + "G20": ["Thumb base", "Index base", "Middle base", "Ring base", "Pinky base", + "Thumb abd.", "Index abd.", "Middle abd.", "Ring abd.", "Pinky abd.", "Thumb roll", + "Thumb tip", "Index tip", "Middle tip", "Ring tip", "Pinky tip"], + "L21": ["Thumb base", "Index base", "Middle base", "Ring base", "Pinky base", "Thumb abd.", + "Index abd.", "Middle abd.", "Ring abd.", "Pinky abd.", "Thumb roll", "Reserved 1", + "Reserved 2", "Reserved 3", "Reserved 4", "Thumb mid", "Reserved 5", "Reserved 6", "Reserved 7", "Reserved 8", + "Thumb tip", "Index tip", "Middle tip", "Ring tip", "Pinky tip"], + "L25": ["Thumb base", "Index base", "Middle base", "Ring base", "Pinky base", "Thumb abd.", + "Index abd.", "Middle abd.", "Ring abd.", "Pinky abd.", "Thumb roll", "Reserved 1", + "Reserved 2", "Reserved 3", "Reserved 4", "Thumb mid", "Index mid", "Middle mid", "Ring mid", + "Pinky mid", "Thumb tip", "Index tip", "Middle tip", "Ring tip", "Pinky tip"], + "O20": ["Thumb base", "Thumb tip", "Thumb abd.", "Thumb rot.", + "Index abd.", "Index base", "Index tip", + "Middle abd.", "Middle base", "Middle tip", + "Ring abd.", "Ring base", "Ring tip", + "Pinky abd.", "Pinky base", "Pinky tip"], +} + +bridge = None +bridge_lock = threading.Lock() +state_lock = threading.Lock() + +BRIDGE_PATH = DEFAULT_BRIDGE # main() 里按 --bridge 覆盖,供 /connect 复用 +current = None # 当前连接参数 {"model","side","comm","channel"};未连接为 None + +# 型号 -> 可选通信方式(前端下拉联动 + /connect 校验依据)。 +# CAN 全型号支持;CAN-FD 仅 O20;Modbus 仅 O6/L7/L10(HandFactory 其余会抛异常)。 +# EtherCAT 当前 SDK 未接通(工厂直接抛异常),不列入。 +COMM_SUPPORT = { + "L6": ["can"], + "O6": ["can", "modbus"], + "L7": ["can", "modbus"], + "L10": ["can", "modbus"], + "L20": ["can"], + "L21": ["can"], + "L25": ["can"], + "G20": ["can"], + "O20": ["canfd"], +} + +meta = {} # {"model","dof","version"} +latest = { # 最新回读缓存 + "position": None, + "speed": None, + "torque": None, + "force": None, # {"fingers":[[[..]]]} + "palm": None, # {"palm":[[..]]} + "temperature": None, # 逐关节温度(°C) + "fault": None, # 逐关节故障码 +} + + +def start_bridge(bridge_path, model, side, comm, channel, timeout=8.0): + global bridge, current + if not os.path.exists(bridge_path): + raise SystemExit(f"web_bridge 未找到: {bridge_path}\n请先构建 SDK 示例 (BUILD_EXAMPLES=ON)。") + argv = [bridge_path, model, side, comm] + if channel: + argv.append(channel) + proc = subprocess.Popen( + argv, + cwd=os.path.dirname(bridge_path), # 让 $ORIGIN 定位到同目录 .so + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, + ) + bridge = proc + current = {"model": model, "side": side, "comm": comm, "channel": channel} + + ready = threading.Event() + + def reader(): + for line in proc.stdout: # 绑定到本次进程,重连后旧线程随其 EOF 自退 + line = line.rstrip() + if not line: + continue + if line == "READY": + ready.set() + elif line.startswith("META "): + try: + with state_lock: + meta.update(json.loads(line[5:])) + except ValueError: + pass + elif line.startswith("POS "): + _store("position", _ints(line[4:])) + elif line.startswith("SPD "): + _store("speed", _ints(line[4:])) + elif line.startswith("TRQ "): + _store("torque", _ints(line[4:])) + elif line.startswith("FORCE "): + _store_json("force", line[6:]) + elif line.startswith("PALM "): + _store_json("palm", line[5:]) + elif line.startswith("TEMP "): + _store("temperature", _ints(line[5:])) + elif line.startswith("FAULT "): + _store("fault", _ints(line[6:])) + else: + print(f"[bridge] {line}", flush=True) # 冒出 BRIDGE_ERROR/WARN 等 + ready.set() # EOF 也解除等待 + + threading.Thread(target=reader, daemon=True).start() + + if not ready.wait(timeout): + print("[warn] 等待 bridge READY 超时——检查 CAN 接口是否已拉起、手是否上电。", flush=True) + if proc.poll() is not None: + raise SystemExit("bridge 在就绪前退出——检查接口 / 供电 / 型号与通信是否匹配。") + + +def stop_bridge(): + """停掉当前 bridge 子进程并清空回读缓存。旧 reader 线程随 stdout EOF 自退。""" + global bridge, current + proc = bridge + bridge = None + current = None + if proc is not None and proc.poll() is None: + try: + proc.stdin.write("Q\n") + proc.stdin.flush() + except Exception: + pass + try: + proc.wait(timeout=2.0) + except Exception: + proc.terminate() + try: + proc.wait(timeout=2.0) + except Exception: + proc.kill() + with state_lock: + meta.clear() + for k in latest: + latest[k] = None + + + +def _ints(s): + out = [] + for tok in s.split(): + try: + out.append(int(tok)) + except ValueError: + pass + return out + + +def _store(key, val): + with state_lock: + latest[key] = val + + +def _store_json(key, s): + try: + val = json.loads(s) + except ValueError: + return + with state_lock: + latest[key] = val + + +def _write(line): + with bridge_lock: + if bridge is None or bridge.poll() is not None: + raise RuntimeError("bridge 进程已退出") + bridge.stdin.write(line) + bridge.stdin.flush() + + +def _clamp_vec(vals): + return [max(0, min(255, int(v))) for v in vals] + + +def send_pose(vals): + _write("P " + " ".join(str(v) for v in _clamp_vec(vals)) + "\n") + + +def send_speed(vals): + _write("S " + " ".join(str(v) for v in _clamp_vec(vals)) + "\n") + + +def send_torque(vals): + _write("T " + " ".join(str(v) for v in _clamp_vec(vals)) + "\n") + + +def _dof(): + return int(meta.get("dof", 0)) or 10 + + +def _vec_from_payload(payload): + """接受 {"vals":[...]}(整向量)或 {"val":N}(单标量广播成 DOF 长)。""" + if "vals" in payload: + return list(payload["vals"]) + if "val" in payload: + return [int(payload["val"])] * _dof() + raise ValueError("需要 'vals' 或 'val'") + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def handle_one_request(self): + # 前端每隔几百 ms 轮询 /state,页面刷新/切换时旧连接会中途断开, + # 服务端 write 抛 BrokenPipe/ConnectionReset。属正常现象,静默关闭即可,避免刷 traceback。 + try: + super().handle_one_request() + except (BrokenPipeError, ConnectionResetError): + self.close_connection = True + + def _send(self, code, body, ctype="text/html; charset=utf-8"): + data = body.encode("utf-8") if isinstance(body, str) else body + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _serve_static(self, path): + # "/" -> index.html;其余按 static/ 下相对路径取文件(防目录穿越)。 + rel = path.split("?", 1)[0] + if rel in ("/", ""): + rel = "/index.html" + full = os.path.normpath(os.path.join(STATIC_DIR, rel.lstrip("/"))) + if not full.startswith(STATIC_DIR) or not os.path.isfile(full): + return self._send(404, "not found", "text/plain") + ctype = mimetypes.guess_type(full)[0] or "application/octet-stream" + if ctype.startswith("text/") or ctype in ("application/javascript", "application/json"): + ctype += "; charset=utf-8" + with open(full, "rb") as f: + self._send(200, f.read(), ctype) + + def do_GET(self): + if self.path == "/meta": + connected = bridge is not None and bridge.poll() is None + with state_lock: + model = meta.get("model", "?") + m = { + "connected": connected, + "model": model, + "dof": _dof(), + "version": meta.get("version", ""), + "rates": meta.get("rates", {}), + "names": JOINT_NAMES.get(model, [f"关节{i}" for i in range(_dof())]), + "names_en": JOINT_NAMES_EN.get(model, [f"Joint {i}" for i in range(_dof())]), + } + self._send(200, json.dumps(m), "application/json") + elif self.path == "/options": + o = { + "models": list(JOINT_NAMES.keys()), + "comm_support": COMM_SUPPORT, + "sides": ["left", "right"], + "current": current, + } + self._send(200, json.dumps(o), "application/json") + elif self.path == "/state": + with state_lock: + snap = dict(latest) + self._send(200, json.dumps(snap), "application/json") + else: + self._serve_static(self.path) + + def do_POST(self): + try: + n = int(self.headers.get("Content-Length", 0)) + payload = json.loads(self.rfile.read(n) or b"{}") + if self.path == "/connect": + return self._handle_connect(payload) + if self.path == "/disconnect": + with bridge_lock: + stop_bridge() + return self._send(200, json.dumps({"ok": True}), "application/json") + if self.path == "/rate": + # 回读频率(Hz),转成 bridge 的 "R " 命令;chan 白名单校验,hz 限幅 1~100 + chan = str(payload.get("chan", "")).lower() + if chan not in ("pos", "st", "force", "temp", "fault"): + return self._send(400, json.dumps({"ok": False, "error": f"未知通道: {chan}"}), "application/json") + hz = max(1, min(100, int(payload.get("hz", 30)))) + _write(f"R {chan} {hz}\n") + return self._send(200, json.dumps({"ok": True, "chan": chan, "hz": hz}), "application/json") + if self.path == "/pose": + vals = _clamp_vec(_vec_from_payload(payload)) + send_pose(vals) + elif self.path == "/speed": + vals = _clamp_vec(_vec_from_payload(payload)) + send_speed(vals) + elif self.path == "/torque": + vals = _clamp_vec(_vec_from_payload(payload)) + send_torque(vals) + else: + return self._send(404, "not found", "text/plain") + self._send(200, json.dumps({"ok": True, "vals": vals}), "application/json") + except Exception as e: + self._send(400, json.dumps({"ok": False, "error": str(e)}), "application/json") + + def _handle_connect(self, payload): + model = str(payload.get("model", "")).upper() + side = str(payload.get("side", "left")).lower() + comm = str(payload.get("comm", "")).lower() + channel = str(payload.get("channel", "")).strip() + if model not in JOINT_NAMES: + return self._send(200, json.dumps({"ok": False, "error": f"未知型号: {model}"}), "application/json") + if side not in ("left", "right"): + side = "left" + if comm == "ethercat": + return self._send(200, json.dumps({"ok": False, "error": "EtherCAT 暂不支持"}), "application/json") + if comm not in COMM_SUPPORT.get(model, []): + return self._send(200, json.dumps({"ok": False, "error": f"{model} 不支持通信方式 {comm or '(空)'}"}), "application/json") + with bridge_lock: + stop_bridge() + try: + start_bridge(BRIDGE_PATH, model, side, comm, channel) + except (Exception, SystemExit) as e: + stop_bridge() + return self._send(200, json.dumps({"ok": False, "error": str(e)}), "application/json") + return self._send(200, json.dumps({"ok": True, "model": model, "dof": _dof()}), "application/json") + + + +def main(): + global BRIDGE_PATH + ap = argparse.ArgumentParser(description="LinkerHand 全型号无依赖 Web 示教器") + ap.add_argument("--model", choices=list(JOINT_NAMES.keys()), + help="启动即连的型号;不填则待前端设置面板在线连接") + ap.add_argument("--side", choices=["left", "right"], default="left") + ap.add_argument("--comm", choices=["can", "canfd", "modbus"], default="", + help="通信方式;缺省按型号自动(O20→canfd,其余→can)") + ap.add_argument("--channel", default="", help="CAN 接口名(如 can0)/ Modbus 串口(如 /dev/ttyUSB0);O20 可用 'socketcan:can0'") + ap.add_argument("--host", default="0.0.0.0") + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--bridge", default=DEFAULT_BRIDGE, help="web_bridge 可执行文件路径") + args = ap.parse_args() + BRIDGE_PATH = args.bridge + + if args.model: + comm = args.comm or ("canfd" if args.model == "O20" else "can") + print(f"启动 bridge (model={args.model} side={args.side} comm={comm} channel={args.channel or 'auto'})...", flush=True) + start_bridge(args.bridge, args.model, args.side, comm, args.channel) + else: + print("未指定 --model,等待前端设置面板在线连接。", flush=True) + + srv = ThreadingHTTPServer((args.host, args.port), Handler) + shown = "localhost" if args.host in ("0.0.0.0", "") else args.host + print(f"\n 控制界面 -> http://{shown}:{args.port}/\n", flush=True) + if args.host == "0.0.0.0": + print(" (已绑定所有网卡——同局域网其他设备可访问)\n", flush=True) + try: + srv.serve_forever() + except KeyboardInterrupt: + print("\n正在关闭", flush=True) + finally: + try: + _write("Q\n") + except Exception: + pass + + +if __name__ == "__main__": + main() diff --git a/webui/static/app.css b/webui/static/app.css new file mode 100644 index 0000000..050f729 --- /dev/null +++ b/webui/static/app.css @@ -0,0 +1,202 @@ +:root{ + --bg:#0f1117; --panel:#1b1e26; --panel2:#161922; + --text:#e8eaed; --muted:#8b93a1; --accent:#2f6f8f; --accent2:#3a86ab; + --track:#2a2f3a; --track2:#333a47; + --border:#242833; --ok:#10b981; --warn:#f59e0b; --err:#ef4444; + --radius:14px; --shadow:0 1px 3px rgba(0,0,0,.4),0 6px 20px rgba(0,0,0,.22); + color-scheme:dark; +} +[data-theme="light"]{ + --bg:#eef1f5; --panel:#ffffff; --panel2:#f2f5f9; + --text:#1c2430; --muted:#5b6472; --accent:#2f6f8f; --accent2:#3a86ab; + --track:#e2e7ee; --track2:#cbd3de; + --border:#e6eaf0; --ok:#10b981; --warn:#f59e0b; --err:#ef4444; + --shadow:0 1px 2px rgba(20,30,50,.06),0 6px 18px rgba(20,30,50,.07); + color-scheme:light; +} + +*{box-sizing:border-box} +body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;background:var(--bg);color:var(--text); + -webkit-font-smoothing:antialiased;transition:background .2s,color .2s} + +/* ---- 顶栏 ---- */ +.topbar{position:sticky;top:0;z-index:10;display:flex;justify-content:space-between;align-items:center; + gap:12px;padding:14px 28px;background:var(--panel); + background:color-mix(in srgb,var(--panel) 88%,transparent); + backdrop-filter:blur(8px);border-bottom:1px solid var(--border)} +.brand{display:flex;align-items:center;gap:11px} +.brand .logo{height:36px;width:auto;display:block;flex:0 0 auto} +.topbar .actions{display:flex;align-items:center;gap:14px} +/* logo 黑字在暗黑模式看不清:反亮度再转回色相,黑字变白、彩色大致保留 */ +[data-theme="dark"] .brand .logo{filter:invert(1) hue-rotate(180deg)} +.topbar h1{font-size:22px;margin:0;font-weight:650;letter-spacing:.01em} +.conn{display:inline-flex;align-items:center;color:var(--muted);transition:color .2s} +.conn svg{width:18px;height:18px;display:block} +.conn.on{color:var(--ok)} /* 已连接:整条链变绿 */ +.conn.on .slash{display:none} /* 已连接:隐藏断开斜杠 */ +.ghost{background:transparent;border:1px solid var(--track2);font-size:18px;line-height:1; + padding:7px 10px;border-radius:10px;cursor:pointer;color:var(--text);transition:background .15s,border-color .15s} +.ghost:hover{background:var(--track);border-color:var(--accent)} +/* 顶栏按钮固定盒子尺寸并居中,避免切换后字形宽高不同导致按钮忽大忽小 */ +.topbar .actions .ghost{display:inline-flex;align-items:center;justify-content:center;width:42px;height:38px;padding:0} +.topbar .actions .ghost svg{width:18px;height:18px;display:block} + +/* ---- 布局:整宽纵向堆叠,充分利用宽度 ---- */ +.wrap{max-width:1200px;margin:0 auto;padding:22px 28px 76px;display:flex;flex-direction:column;gap:16px} +@media(max-width:720px){.wrap{padding:16px 14px 72px}.topbar{padding:12px 16px}} + +/* ---- 卡片 ---- */ +.panel{background:var(--panel);padding:18px 20px;border-radius:var(--radius); + border:1px solid var(--border);box-shadow:var(--shadow)} +.panel h3{margin:0 0 15px;font-size:17px;font-weight:650;letter-spacing:.06em;text-transform:uppercase; + color:var(--muted);display:flex;align-items:center;gap:9px} +.panel h3::before{content:"";width:3px;height:14px;border-radius:2px;background:var(--accent)} + +/* ---- 版本信息:横向排布 ---- */ +.info{display:flex;flex-direction:column;gap:8px;font-size:15px} +.info-row{flex-direction:row;flex-wrap:wrap;align-items:center;gap:10px 30px} +.info .kv{display:flex;align-items:center;gap:9px} +.info-row .kv{justify-content:flex-start} +.info .ver{display:flex;align-items:center;gap:12px} +.info-row .ver{flex:1;min-width:240px} +.info span{color:var(--muted)} +.info b{font-weight:650;font-variant-numeric:tabular-nums} +.info #infoVer{display:flex;flex-wrap:wrap;gap:6px 18px} +.vrow{display:flex;align-items:center;gap:7px} +.vrow b{font-weight:600;word-break:break-all} + +/* ---- 滑块 ---- */ +input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:6px;border-radius:6px; + background:var(--track);outline:none;cursor:pointer} +input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%; + background:var(--accent);border:2px solid var(--panel);box-shadow:0 1px 3px rgba(0,0,0,.35);transition:background .15s} +input[type=range]:hover::-webkit-slider-thumb{background:var(--accent2)} +input[type=range]::-moz-range-thumb{width:15px;height:15px;border:2px solid var(--panel);border-radius:50%; + background:var(--accent);cursor:pointer} +.val{font-variant-numeric:tabular-nums;text-align:right;font-size:15px;color:var(--text);min-width:34px} + +/* ---- 速度/力矩:双列 ---- */ +.st-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px 34px} +@media(max-width:640px){.st-grid{grid-template-columns:1fr}} +.line{display:flex;gap:14px;align-items:center;margin:2px 0} +.line label{width:44px;font-size:15px;color:var(--muted);flex:0 0 auto} +.line input[type=range]{flex:1;min-width:120px} + +/* ---- 滑块统一外观(惠及刷新率页、速度/扭矩、关节控制)---- */ +input[type=range]{-webkit-appearance:none;appearance:none;height:20px;background:transparent;cursor:pointer} +input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:2px;background:var(--track2)} +input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:15px;height:15px; + margin-top:-5.5px;border-radius:50%;background:var(--accent);border:2px solid var(--panel); + box-shadow:0 1px 3px rgba(0,0,0,.35);transition:background .15s} +input[type=range]:hover::-webkit-slider-thumb{background:var(--accent2)} +input[type=range]::-moz-range-track{height:4px;border-radius:2px;background:var(--track2)} +input[type=range]::-moz-range-thumb{width:15px;height:15px;border-radius:50%;background:var(--accent); + border:2px solid var(--panel);box-shadow:0 1px 3px rgba(0,0,0,.35)} +input[type=range]:hover::-moz-range-thumb{background:var(--accent2)} + +/* ---- 按钮 ---- */ +button{font-size:16px;padding:9px 13px;border:0;border-radius:9px;cursor:pointer; + background:var(--track);color:var(--text);transition:background .15s} +button:hover{background:var(--track2)} +button.pri{background:var(--accent);color:#fff} +button.pri:hover{background:var(--accent2)} + +/* ---- 状态栏 ---- */ +.statusbar{display:none;position:fixed;left:0;right:0;bottom:0;padding:7px 28px;font-size:15px;z-index:10; + color:var(--muted);background:var(--panel); + background:color-mix(in srgb,var(--panel) 92%,transparent); + backdrop-filter:blur(8px);border-top:1px solid var(--border)} + +/* ---- 触觉压感 ---- */ +#waveWrap{margin-bottom:16px} +#wave{width:100%;height:150px;background:var(--panel2);border-radius:8px;display:block} +.legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:8px;font-size:14px;color:var(--muted);justify-content:center} +.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:middle} +#forceWrap{display:flex;flex-wrap:wrap;gap:16px 24px;align-items:flex-start;justify-content:center} +#palmWrap{margin-top:16px;display:flex;justify-content:center} +.fblock{margin:0;display:flex;flex-direction:column;align-items:center} +.flabel{font-size:14px;color:var(--muted);margin-bottom:6px;font-weight:600} +canvas{background:var(--panel2);border-radius:6px;image-rendering:pixelated} +.fblock canvas{border-radius:0} /* 触觉矩阵是像素网格,圆角会裁掉四角边框线 */ + +/* ---- 预设 chip ---- */ +.chip{display:inline-flex;gap:6px;align-items:center;background:var(--panel2);border:1px solid var(--border); + border-radius:20px;padding:4px 6px 4px 12px;margin:8px 8px 0 0;font-size:15px} +.chip button{padding:2px 8px;font-size:14px;border-radius:14px} +.muted{color:var(--muted);font-size:14px} + +/* ---- 关节监控与控制表 ---- */ +#joints{overflow-x:auto} +.mon{width:100%;border-collapse:collapse;font-size:15px} +.mon th,.mon td{padding:8px 12px;text-align:center;white-space:nowrap} +.mon thead th{color:var(--muted);font-weight:650;font-size:13px;letter-spacing:.04em;text-transform:uppercase; + border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--panel)} +.mon tbody tr{border-bottom:1px solid var(--track)} +.mon tbody tr:last-child{border-bottom:0} +.mon tbody tr:hover{background:var(--panel2)} +.mon tbody td{height:36px} +.mon th.num,.mon td.num{font-variant-numeric:tabular-nums} +.mon td.ok{color:var(--ok)} .mon td.warn{color:var(--warn)} .mon td.err{color:var(--err)} +.jt td.ctl{min-width:150px;width:35%} +.jt td.ctl input[type=range]{display:inline-block;width:100%;margin:0;vertical-align:middle} + +/* ---- 连接设置弹窗 ---- */ +.modal{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center; + background:rgba(0,0,0,.5);backdrop-filter:blur(2px)} +.modal[hidden]{display:none} +.modal-card{background:var(--panel);border:1px solid var(--border);border-radius:var(--radius); + box-shadow:var(--shadow);width:min(94vw,560px);overflow:hidden} +.modal-head{display:flex;align-items:center;justify-content:space-between;padding:16px 20px; + border-bottom:1px solid var(--border)} +.modal-head h3{margin:0;font-size:16px;font-weight:650;letter-spacing:.05em;text-transform:uppercase} +/* 左侧竖排 TAB + 右侧内容区 */ +.modal-split{display:flex} +.tabs{display:flex;flex-direction:column;gap:2px;flex:0 0 128px;padding:12px 8px; + border-right:1px solid var(--border)} +.tab{background:transparent;border:0;border-left:2px solid transparent;color:var(--muted); + text-align:left;padding:10px 14px;border-radius:0 8px 8px 0;font-size:15px;font-weight:600; + letter-spacing:.02em;cursor:pointer;transition:color .15s,background .15s,border-color .15s} +.tab:hover{color:var(--text);background:var(--panel2)} +.tab.active{color:var(--accent);border-left-color:var(--accent);background:var(--panel2)} +.tabmain{flex:1;min-width:0;display:flex;flex-direction:column} +.modal-body{padding:18px 20px;display:grid;grid-template-columns:1fr} +/* 三个 tabpane 层叠进同一网格单元:弹窗高度恒等于最高的一页,切 TAB 不跳变。 + 非激活页保留占位(display 仍为 flex),仅用 visibility 隐藏,故不可见也不拦点击。 */ +.tabpane{grid-column:1;grid-row:1;display:flex;flex-direction:column;gap:14px} +.tabpane[hidden]{visibility:hidden} +.frow{display:flex;align-items:center;justify-content:space-between;gap:14px;font-size:15px} +.frow>span{color:var(--muted);flex:0 0 84px} +.frow select,.frow input{flex:1;background:var(--panel2);color:var(--text);border:1px solid var(--track2); + border-radius:9px;padding:9px 11px;font-size:15px} +.frow select:focus,.frow input:focus{outline:none;border-color:var(--accent)} + +/* 关于页:软件名 + SDK 版本 + 简介 */ +.about{display:flex;flex-direction:column;gap:12px} +.about-name{font-size:18px;font-weight:700;color:var(--text)} +.about-row{display:flex;justify-content:space-between;gap:14px;font-size:15px;color:var(--muted)} +.about-row b{color:var(--text);font-variant-numeric:tabular-nums} +.about-row a{color:var(--accent);text-decoration:none} +.about-row a:hover{text-decoration:underline} +.about-desc{margin:0;font-size:14px;line-height:1.6;color:var(--muted)} + +/* 窄屏:TAB 回落顶部横排 */ +@media(max-width:520px){ + .modal-split{flex-direction:column} + .tabs{flex-direction:row;flex:none;padding:0 8px;border-right:0;border-bottom:1px solid var(--border)} + .tab{flex:1;text-align:center;border-left:0;border-bottom:2px solid transparent;border-radius:0} + .tab.active{border-left-color:transparent;border-bottom-color:var(--accent)} +} + +/* 刷新率页:标签(定宽) | 滑块(自适应) | 大号数值 + 小字单位 */ +.raterow{display:flex;align-items:center;gap:16px} +.rlabel{flex:0 0 76px;color:var(--muted);font-size:14px;white-space:nowrap} +.rate{flex:1;min-width:0} +.rval{flex:0 0 auto;min-width:56px;display:flex;align-items:baseline;justify-content:flex-end;gap:3px} +.rval b{font-size:20px;font-weight:700;color:var(--text);font-variant-numeric:tabular-nums} +.rval i{font-size:11px;font-style:normal;color:var(--muted)} +.conn-msg{min-height:20px;font-size:14px;color:var(--err)} +.conn-msg.ok{color:var(--ok)} +.modal-foot{padding:0 20px 20px;display:flex;justify-content:flex-end;gap:10px} +.modal-foot button{min-width:96px} +.modal-foot #disconnectBtn{margin-right:auto} /* 断开靠左,连接始终靠右 */ +.modal-foot #disconnectBtn[hidden]{display:none} diff --git a/webui/static/app.js b/webui/static/app.js new file mode 100644 index 0000000..7668a78 --- /dev/null +++ b/webui/static/app.js @@ -0,0 +1,538 @@ +// LinkerHand Web 示教器前端 —— 原生 JS,无框架/CDN。 +// 与后端契约:GET /meta、GET /state;POST /pose|/speed|/torque,body {vals:[..]} 或 {val:N}。 +let META = {dof: 0, names: [], model: "?"}; +let verReady = false, verTick = 0; // 设备信息是否已渲染 + 补拉节拍 +let vals = []; +const sliders = [], outs = [], monCells = []; + +// ---- 国际化(中/英)---- +const I18N = { + zh: { + doc_title: 'LinkerHand CPP Web', + conn_title: '连接状态', lang_title: '切换语言', theme_title: '切换主题', set_title: '设置', + tab_conn: '连接', + tab_about: '关于', about_name: '灵心巧手', + about_sdk: 'SDK 版本', about_site: '官网', about_desc: 'LinkerHand 系列 C++ SDK 的 Web 控制界面。', + f_model: '型号', f_comm: '通信方式', f_side: '左右手', f_chan: '设备', + side_left: '左手', side_right: '右手', connect: '连接', connecting: '连接中…', + connected_ok: '连接成功', not_connected: '未连接——点击右上角 ⚙ 选择型号与通信方式后连接', + disconnect: '断开连接', disconnecting: '断开中…', disconnected: '已断开', + ver_info: '设备信息', joints_panel: '监控 & 控制', sp_tq_panel: '速度 & 扭矩', + speed: '速度', torque: '扭矩', force_panel: '压感数据', rate: '频率', + rate_pos: '位置', rate_st: '速度/扭矩', rate_force: '触觉', rate_temp: '温度', rate_fault: '故障码', + no_force: '暂无触觉数据(该型号无传感器或未接入)。', ready: '就绪', + h_joint: '关节', h_ctrl: '控制', h_target: '目标', h_pos: '实际', + h_speed: '速度', h_torque: '扭矩', h_temp: '温度°C', h_fault: '故障码', + joint: '关节', fingers: ['拇指', '食指', '中指', '无名指', '小指'], finger_fb: '指', + palm: '掌心', peak: '峰值', apply: '应用', + sent: '已下发', err: '错误', net_err: '网络错误', no_version: '暂无版本信息', + ver: {'Device Serial': '序列号', 'Software Version': '软件版本', + 'Hardware Version': '硬件版本', 'Mechanical Version': '机械版本', + 'freedom': '自由度', 'Robot version': '机器人版本', 'Version Number': '版本号', + 'Hand direction': '手方向', 'Freedom': '自由度', 'RobotVersion': '机器人版本', + 'VersionNumber': '版本号', 'HandDirection': '手方向', + 'SoftwareVersion': '软件版本', 'HardwareVersion': '硬件版本'}, + }, + en: { + doc_title: 'LinkerHand CPP Web', + conn_title: 'Connection', lang_title: 'Switch language', theme_title: 'Toggle theme', set_title: 'Settings', + tab_conn: 'Connection', + tab_about: 'About', about_name: 'LinkerHand', + about_sdk: 'SDK Version', about_site: 'Website', about_desc: 'Web control UI for the LinkerHand C++ SDK.', + f_model: 'Model', f_comm: 'Comm', f_side: 'Hand', f_chan: 'Device', + side_left: 'Left', side_right: 'Right', connect: 'Connect', connecting: 'Connecting…', + connected_ok: 'Connected', not_connected: 'Not connected — click ⚙ to pick model & comm, then connect', + disconnect: 'Disconnect', disconnecting: 'Disconnecting…', disconnected: 'Disconnected', + ver_info: 'Device Info', joints_panel: 'Monitor & Control', sp_tq_panel: 'Speed & Torque', + speed: 'Speed', torque: 'Torque', force_panel: 'Pressure Data', rate: 'Frequency', + rate_pos: 'Position', rate_st: 'Speed/Torque', rate_force: 'Tactile', rate_temp: 'Temp', rate_fault: 'Fault', + no_force: 'No tactile data (this model has no sensor or is not connected).', ready: 'Ready', + h_joint: 'Joint', h_ctrl: 'Control', h_target: 'Target', h_pos: 'Actual', + h_speed: 'Speed', h_torque: 'Torque', h_temp: 'Temp°C', h_fault: 'Fault', + joint: 'Joint', fingers: ['Thumb', 'Index', 'Middle', 'Ring', 'Pinky'], finger_fb: 'F', + palm: 'Palm', peak: 'Peak', apply: 'Apply', + sent: 'Sent', err: 'Error', net_err: 'Network error', no_version: 'No version info', + ver: {'Device Serial': 'Serial No.', 'Software Version': 'Firmware', + 'Hardware Version': 'Hardware', 'Mechanical Version': 'Mechanical', + 'freedom': 'DoF', 'Robot version': 'Robot Ver.', 'Version Number': 'Version', + 'Hand direction': 'Hand', 'Freedom': 'DoF', 'RobotVersion': 'Robot Ver.', + 'VersionNumber': 'Version', 'HandDirection': 'Hand', + 'SoftwareVersion': 'Firmware', 'HardwareVersion': 'Hardware'}, + }, +}; +let LANG = localStorage.getItem('lh_lang') || 'zh'; +function t(k){ const d = I18N[LANG] || I18N.zh; return d[k] != null ? d[k] : (I18N.zh[k] != null ? I18N.zh[k] : k); } +function jointName(i){ + const arr = (LANG === 'en' && META.names_en) ? META.names_en : META.names; + return (arr && arr[i]) || (t('joint') + i); +} + +function status(t){ document.getElementById('status').textContent = t; } +async function post(url, body){ + const r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); + return r.json(); +} + +// ---- 主题 ---- +const themeBtn = document.getElementById('themeBtn'); +function applyTheme(t){ + document.documentElement.dataset.theme = t; + themeBtn.textContent = (t === 'light') ? '☀' : '🌙'; +} +applyTheme(localStorage.getItem('lh_theme') || 'dark'); +themeBtn.addEventListener('click', () => { + const t = (document.documentElement.dataset.theme === 'light') ? 'dark' : 'light'; + localStorage.setItem('lh_theme', t); + applyTheme(t); +}); + +// ---- 语言 ---- +const langBtn = document.getElementById('langBtn'); +function applyStatic(){ + document.documentElement.lang = LANG; + document.title = t('doc_title'); + langBtn.textContent = (LANG === 'zh') ? 'EN' : 'CN'; + const logoEl = document.querySelector('.brand .logo'); + if(logoEl) logoEl.src = (LANG === 'en') ? 'en_logo.png' : 'logo.png'; // 中英各一套 logo + document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); }); + document.querySelectorAll('[data-i18n-title]').forEach(el => { el.title = t(el.dataset.i18nTitle); }); +} +function applyLang(lang){ + LANG = lang; localStorage.setItem('lh_lang', lang); + applyStatic(); + if(META.connected && META.dof){ + buildJoints(); drawSaved(); + renderVersion(document.getElementById('infoVer'), META.version); + } + // 复位触觉/波形状态,让下一轮 poll 用新语言重画手指名与图例 + forceCanvases = []; palmCanvas = null; waveBuilt = false; + status(t('ready')); +} +applyStatic(); +langBtn.addEventListener('click', () => applyLang(LANG === 'zh' ? 'en' : 'zh')); + +// ---- 连接设置弹窗 ---- +const setModal = document.getElementById('setModal'); +const mModel = document.getElementById('mModel'), mComm = document.getElementById('mComm'); +const mSide = document.getElementById('mSide'), mChan = document.getElementById('mChan'); +const connMsg = document.getElementById('connMsg'); +const disconnectBtn = document.getElementById('disconnectBtn'); +let OPTS = null; +const COMM_LABEL = {can: 'CAN', canfd: 'CAN-FD', modbus: 'Modbus', ethercat: 'EtherCAT'}; +const CHAN_PH = {can: 'can0', canfd: 'socketcan:can0', modbus: '/dev/ttyUSB0'}; + +async function openSettings(){ + setModal.hidden = false; + setActiveTab('conn'); // 每次打开复位到“连接”页 + connMsg.textContent = ''; connMsg.className = 'conn-msg'; + if(!OPTS){ + try{ OPTS = await (await fetch('/options')).json(); } + catch(e){ connMsg.textContent = t('net_err'); return; } + mModel.innerHTML = ''; + OPTS.models.forEach(m => { const o = document.createElement('option'); o.value = o.textContent = m; mModel.appendChild(o); }); + } + const cur = OPTS.current; + disconnectBtn.hidden = !cur; // 仅已连接时可断开 + if(cur && cur.model) mModel.value = cur.model; + onModelChange(); + if(cur){ if(cur.comm) mComm.value = cur.comm; mSide.value = cur.side || 'left'; mChan.value = cur.channel || ''; onCommChange(); } +} +function closeSettings(){ setModal.hidden = true; } +// 设置弹窗内 TAB 切换:高亮所选 .tab、显隐对应 .tabpane;连接/断开按钮仅“连接”页显示。 +function setActiveTab(name){ + setModal.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.pane === name)); + setModal.querySelectorAll('.tabpane').forEach(p => { p.hidden = (p.dataset.pane !== name); }); + document.querySelector('.modal-foot').style.visibility = (name === 'conn') ? '' : 'hidden'; +} +// 型号变更 -> 依 comm_support 重建通信下拉;EtherCAT 始终附加(选中连接时后端提示暂不支持)。 +function onModelChange(){ + const comms = (OPTS.comm_support && OPTS.comm_support[mModel.value]) || ['can']; + mComm.innerHTML = ''; + comms.concat(['ethercat']).forEach(c => { + const o = document.createElement('option'); o.value = c; o.textContent = COMM_LABEL[c] || c; mComm.appendChild(o); + }); + onCommChange(); +} +function onCommChange(){ mChan.placeholder = CHAN_PH[mComm.value] || ''; } +async function doConnect(){ + connMsg.className = 'conn-msg'; connMsg.textContent = t('connecting'); + const body = {model: mModel.value, side: mSide.value, comm: mComm.value, channel: mChan.value.trim()}; + try{ + const j = await post('/connect', body); + if(j.ok){ + connMsg.className = 'conn-msg ok'; connMsg.textContent = t('connected_ok'); + OPTS = null; // 下次打开重新拉取 current + closeSettings(); + await init(); // 按新型号/DOF 重建界面 + }else{ + connMsg.className = 'conn-msg'; connMsg.textContent = t('err') + ': ' + j.error; + } + }catch(e){ connMsg.textContent = t('net_err') + ': ' + e; } +} +async function doDisconnect(){ + connMsg.className = 'conn-msg'; connMsg.textContent = t('disconnecting'); + try{ + const j = await post('/disconnect', {}); + if(j.ok){ + OPTS = null; // 下次打开重新拉取 current + closeSettings(); + await init(); // 回到未连接态 + }else{ + connMsg.textContent = t('err') + ': ' + j.error; + } + }catch(e){ connMsg.textContent = t('net_err') + ': ' + e; } +} +document.getElementById('setBtn').addEventListener('click', openSettings); +document.getElementById('setClose').addEventListener('click', closeSettings); +setModal.querySelectorAll('.tab').forEach(b => b.addEventListener('click', () => setActiveTab(b.dataset.pane))); +setModal.addEventListener('click', e => { if(e.target === setModal) closeSettings(); }); +mModel.addEventListener('change', onModelChange); +mComm.addEventListener('change', onCommChange); +document.getElementById('connectBtn').addEventListener('click', doConnect); +disconnectBtn.addEventListener('click', doDisconnect); + +// ---- 关节监控与控制(合并为一张表:控制列持久,监控列仅更新单元格)---- +function buildJoints(){ + const jd = document.getElementById('joints'); jd.innerHTML = ''; + sliders.length = 0; outs.length = 0; monCells.length = 0; + const table = document.createElement('table'); table.className = 'mon jt'; + table.innerHTML = '' + t('h_joint') + '' + t('h_ctrl') + '' + t('h_target') + '' + + '' + t('h_pos') + '' + t('h_speed') + '' + t('h_torque') + '' + + '' + t('h_temp') + '' + t('h_fault') + ''; + const tb = document.createElement('tbody'); + for(let i = 0; i < META.dof; i++){ + const tr = document.createElement('tr'); + const nameTd = document.createElement('td'); nameTd.textContent = jointName(i); + const ctlTd = document.createElement('td'); ctlTd.className = 'ctl'; + const s = document.createElement('input'); s.type = 'range'; s.min = 0; s.max = 255; s.value = vals[i]; + ctlTd.appendChild(s); + const valTd = document.createElement('td'); valTd.className = 'num'; valTd.textContent = vals[i]; + s.addEventListener('input', () => { vals[i] = +s.value; valTd.textContent = s.value; sendThrottled(); }); + const posTd = document.createElement('td'); posTd.className = 'num'; posTd.textContent = '--'; + const spdTd = document.createElement('td'); spdTd.className = 'num'; spdTd.textContent = '--'; + const trqTd = document.createElement('td'); trqTd.className = 'num'; trqTd.textContent = '--'; + const tempTd = document.createElement('td'); tempTd.className = 'num'; tempTd.textContent = '--'; + const faultTd = document.createElement('td'); faultTd.className = 'num'; faultTd.textContent = '--'; + tr.appendChild(nameTd); tr.appendChild(ctlTd); tr.appendChild(valTd); + tr.appendChild(posTd); tr.appendChild(spdTd); tr.appendChild(trqTd); tr.appendChild(tempTd); tr.appendChild(faultTd); + tb.appendChild(tr); + sliders.push(s); outs.push(valTd); + monCells.push({pos: posTd, spd: spdTd, trq: trqTd, temp: tempTd, fault: faultTd}); + } + table.appendChild(tb); jd.appendChild(table); +} +function refresh(){ for(let i = 0; i < META.dof; i++){ sliders[i].value = vals[i]; outs[i].textContent = vals[i]; } } + +let timer = null, pending = false; +function sendThrottled(){ + if(timer){ pending = true; return; } + sendPose(); + timer = setTimeout(() => { timer = null; if(pending){ pending = false; sendThrottled(); } }, 60); +} +async function sendPose(){ + try{ const j = await post('/pose', {vals}); status(j.ok ? (t('sent') + ' [' + j.vals.join(', ') + ']') : (t('err') + ': ' + j.error)); } + catch(e){ status(t('net_err') + ': ' + e); } +} + +// ---- 速度 / 力矩 ---- +const speedEl = document.getElementById('speed'), speedVal = document.getElementById('speedVal'); +const torqueEl = document.getElementById('torque'), torqueVal = document.getElementById('torqueVal'); +speedEl.addEventListener('input', () => { speedVal.textContent = speedEl.value; post('/speed', {val:+speedEl.value}); }); +torqueEl.addEventListener('input', () => { torqueVal.textContent = torqueEl.value; post('/torque', {val:+torqueEl.value}); }); + +// ---- 回读频率(各通道 Hz):驱动 web_bridge 分频轮询;force 通道决定 B5→B1 空档 ---- +const rateEls = Array.from(document.querySelectorAll('input.rate')); +const rateTimers = {}; +rateEls.forEach(el => { + const chan = el.dataset.chan, out = el.parentElement.querySelector('.rval b'); + el.addEventListener('input', () => { + out.textContent = el.value; + clearTimeout(rateTimers[chan]); // 拖动去抖,避免刷爆 /rate + rateTimers[chan] = setTimeout(() => post('/rate', {chan, hz:+el.value}), 120); + }); +}); + +function themeIsDark(){ return document.documentElement.dataset.theme !== 'light'; } + +// ---- 触觉热力图 ---- +// 浅色:白(无压力)渐深到红。暗黑:深灰蓝(隐入面板)渐亮到红,不再是刺眼白块。 +function heatColor(v){ + const t = Math.max(0, Math.min(1, v / 255)); + if(themeIsDark()){ + // 0 -> ≈--panel2 深底; 255 -> 亮红。红分量用 sqrt(t) 略陡,让低值段就泛红而非发灰。 + const s = Math.sqrt(t); + const r = Math.round(28 + (255 - 28) * s); + const g = Math.round(32 + (70 - 32) * s); + const b = Math.round(44 + (70 - 44) * s); + return `rgb(${r},${g},${b})`; + } + const g = Math.round(255 * (1 - t)); + return `rgb(255,${g},${g})`; +} +// 按背景亮度选字色,保证数值可读 +function textColor(v){ + if(themeIsDark()) return '#e8eaed'; // 深底/红底均用浅字 + const t = Math.max(0, Math.min(1, v / 255)); + const g = 255 * (1 - t); + return (0.299 * 255 + 0.701 * g) > 140 ? '#000' : '#fff'; +} +function drawMatrix(canvas, mat, cellOverride){ + const rows = mat.length, cols = rows ? mat[0].length : 0; + if(!rows || !cols) return; + const cell = cellOverride || Math.max(20, Math.floor(140 / Math.max(rows, cols))); + // 画布多留 1px,使最右/最下网格线(位于 cols*cell+0.5)完整落在画布内不被裁掉 + canvas.width = cols * cell + 1; canvas.height = rows * cell + 1; + const ctx = canvas.getContext('2d'); + ctx.font = Math.floor(cell * 0.42) + 'px system-ui'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + // 先逐格填色 + 写数值 + for(let r = 0; r < rows; r++) for(let c = 0; c < cols; c++){ + const v = mat[r][c] || 0; // 没按压 -> 0 + const x = c * cell, y = r * cell; + ctx.fillStyle = heatColor(v); + ctx.fillRect(x, y, cell, cell); + ctx.fillStyle = textColor(v); + ctx.fillText(v, x + cell / 2, y + cell / 2); + } + // 再统一描一遍网格:每条边界线只画一次,内外线宽都恰为 1px, + // 避免逐格 strokeRect 时相邻格边框并排叠成 2px、而外缘只有 1px + ctx.strokeStyle = themeIsDark() ? '#2b3040' : '#bbb'; ctx.lineWidth = 1; + ctx.beginPath(); + for(let c = 0; c <= cols; c++){ const x = c * cell + 0.5; ctx.moveTo(x, 0); ctx.lineTo(x, rows * cell + 1); } + for(let r = 0; r <= rows; r++){ const y = r * cell + 0.5; ctx.moveTo(0, y); ctx.lineTo(cols * cell + 1, y); } + ctx.stroke(); +} +let forceCanvases = []; +let fingerCell = 0; // 手指矩阵当前格子边长,供掌心复用以保持一致 +// 依据容器可用宽度算出格子边长,让矩阵撑大填满并居中;窄屏取下限后由 flex-wrap 换行。 +function fitCell(wrapW, count, cols, gap, min, max){ + if(!wrapW || !count || !cols) return min; + const per = (wrapW - (count - 1) * gap) / count; + return Math.max(min, Math.min(max, Math.floor(per / cols))); +} +function renderForce(force){ + const wrap = document.getElementById('forceWrap'); + const fingers = force && force.fingers; + if(!fingers || !fingers.length){ + if(forceCanvases.length){ wrap.innerHTML = '' + t('no_force') + ''; forceCanvases = []; } + return; + } + // 参考 C++ example:每指一块,名称标题在上、矩阵在下,按 拇→食→中→无名→小 横向排列。 + if(forceCanvases.length !== fingers.length){ + wrap.innerHTML = ''; forceCanvases = []; + fingers.forEach((_, i) => { + const block = document.createElement('div'); block.className = 'fblock'; + const lb = document.createElement('div'); lb.className = 'flabel'; lb.textContent = t('fingers')[i] || (t('finger_fb') + i); + const cv = document.createElement('canvas'); + block.appendChild(lb); block.appendChild(cv); wrap.appendChild(block); forceCanvases.push(cv); + }); + } + const maxCols = fingers.reduce((m, f) => Math.max(m, f[0] ? f[0].length : 0), 1); + const cell = fitCell(wrap.clientWidth, fingers.length, maxCols, 22, 20, 46); + fingerCell = cell; + fingers.forEach((m, i) => drawMatrix(forceCanvases[i], m, cell)); +} +let palmCanvas = null; +function renderPalm(palm){ + const wrap = document.getElementById('palmWrap'); + const mat = palm && palm.palm; + if(!mat || !mat.length){ if(palmCanvas){ wrap.innerHTML = ''; palmCanvas = null; } return; } + if(!palmCanvas){ + wrap.innerHTML = '
' + t('palm') + '
'; + palmCanvas = wrap.querySelector('canvas'); + } + drawMatrix(palmCanvas, mat, fingerCell || undefined); // 与手指矩阵格子尺寸保持一致 +} + +// ---- 压感波形:每指所有单元求和,随时间滚动;另叠加掌心合力(有掌心数据时)---- +const WAVE_COLORS = ['#e6550d', '#3182bd', '#31a354', '#9467bd', '#e7298a']; +const PALM_COLOR = '#00c2c2'; // 掌心合力线,区别于 5 指暖色系 +const WAVE_LEN = 180; // ≈6s @30Hz +let forceHist = []; +let palmHist = []; // 与 forceHist 等长;无掌心数据的帧为 null +let waveBuilt = false; +let waveHasPalm = false; // 图例当前是否含掌心项 +const matSum = m => m.reduce((a, row) => a + row.reduce((b, v) => b + (v || 0), 0), 0); +function buildWaveLegend(hasPalm){ + const lg = document.getElementById('waveLegend'); lg.innerHTML = ''; + t('fingers').forEach((n, i) => { + const s = document.createElement('span'); + s.innerHTML = `${n}`; + lg.appendChild(s); + }); + if(hasPalm){ + const s = document.createElement('span'); + s.innerHTML = `${t('palm')}`; + lg.appendChild(s); + } +} +function renderWave(force, palm){ + const wrap = document.getElementById('waveWrap'); + const fingers = force && force.fingers; + if(!fingers || !fingers.length){ + if(waveBuilt){ wrap.style.display = 'none'; forceHist = []; palmHist = []; waveBuilt = false; } + return; + } + const palmMat = palm && palm.palm; + const hasPalm = !!(palmMat && palmMat.length); + if(!waveBuilt || hasPalm !== waveHasPalm){ + wrap.style.display = ''; buildWaveLegend(hasPalm); waveBuilt = true; waveHasPalm = hasPalm; + } + forceHist.push(fingers.map(matSum)); + palmHist.push(hasPalm ? matSum(palmMat) : null); + if(forceHist.length > WAVE_LEN){ forceHist.shift(); palmHist.shift(); } + drawWave(); +} +function drawWave(){ + const cv = document.getElementById('wave'); + const w = cv.clientWidth || 600, h = 140; + cv.width = w; cv.height = h; + const ctx = cv.getContext('2d'); + ctx.clearRect(0, 0, w, h); + const pad = 6; + let maxV = 1; + for(const row of forceHist) for(const v of row) if(v > maxV) maxV = v; + for(const v of palmHist) if(v != null && v > maxV) maxV = v; + // 网格底线 + ctx.strokeStyle = 'rgba(128,128,128,.25)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, h - pad); ctx.lineTo(w, h - pad); ctx.stroke(); + const n = forceHist.length; + if(n >= 2){ + for(let f = 0; f < 5; f++){ + ctx.strokeStyle = WAVE_COLORS[f]; ctx.lineWidth = 1.5; ctx.beginPath(); + for(let i = 0; i < n; i++){ + const x = w * i / (n - 1); + const y = (h - pad) - (forceHist[i][f] / maxV) * (h - 2 * pad); + i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); + } + ctx.stroke(); + } + // 掌心合力线:仅画有数据的段,null 处断开 + if(palmHist.some(v => v != null)){ + ctx.strokeStyle = PALM_COLOR; ctx.lineWidth = 1.5; ctx.beginPath(); + let started = false; + for(let i = 0; i < n; i++){ + const v = palmHist[i]; + if(v == null){ started = false; continue; } + const x = w * i / (n - 1); + const y = (h - pad) - (v / maxV) * (h - 2 * pad); + started ? ctx.lineTo(x, y) : ctx.moveTo(x, y); + started = true; + } + ctx.stroke(); + } + } + // 峰值量级 + ctx.fillStyle = 'rgba(128,128,128,.8)'; ctx.font = '11px system-ui'; + ctx.textAlign = 'right'; ctx.textBaseline = 'top'; + ctx.fillText(t('peak') + ' ' + maxV, w - 4, 3); +} + +// ---- 关节监控:更新合并表里的监控单元格(不重建,避免打断控制滑块)---- +function tempClass(v){ return v > 60 ? 'err' : (v > 50 ? 'warn' : 'ok'); } +function renderMonitor(pos, spd, trq, temp, fault){ + const get = (a, i) => (Array.isArray(a) && a[i] != null) ? a[i] : null; + const setNum = (td, v) => { td.className = 'num'; td.textContent = v == null ? '--' : v; }; + for(let i = 0; i < monCells.length; i++){ + const c = monCells[i]; + const p = get(pos, i); + // 实际值与目标偏差超过容差时标橙,提示尚未到位(容差避开反馈抖动导致的常亮) + c.pos.textContent = p == null ? '--' : p; + c.pos.className = (p != null && Math.abs(p - vals[i]) > 5) ? 'num warn' : 'num'; + setNum(c.spd, get(spd, i)); setNum(c.trq, get(trq, i)); + const tp = get(temp, i), f = get(fault, i); + c.temp.textContent = tp == null ? '--' : tp; + c.temp.className = tp == null ? 'num' : ('num ' + tempClass(tp)); + c.fault.textContent = f == null ? '--' : f; + c.fault.className = f == null ? 'num' : ('num ' + (f ? 'err' : 'ok')); + } +} + +// ---- 轮询:关节反馈 + 触觉 ---- +async function poll(){ + try{ + const j = await (await fetch('/state')).json(); + document.getElementById('conn').classList.add('on'); + renderForce(j.force); + renderPalm(j.palm); + renderWave(j.force, j.palm); + renderMonitor(j.position, j.speed, j.torque, j.temperature, j.fault); + // 设备信息可能刚上电时晚到:未就绪则每 ~1s 重拉 /meta(web_bridge 拿到后会补发 META)。 + if(META.connected && !verReady && (verTick++ % 30 === 0)){ + const m = await (await fetch('/meta')).json(); + if(m.version) META.version = m.version; + verReady = renderVersion(document.getElementById('infoVer'), META.version); + } + }catch(e){ document.getElementById('conn').classList.remove('on'); } + setTimeout(poll, 33); // ~30Hz,跟上后端压感刷新率 +} + +// ---- 预设(localStorage)---- +function key(){ return 'lh_' + META.model; } +function loadSaved(){ return JSON.parse(localStorage.getItem(key()) || '{}'); } +function drawSaved(){ + const s = loadSaved(), box = document.getElementById('saved'); box.innerHTML = ''; + Object.keys(s).forEach(name => { + const c = document.createElement('span'); c.className = 'chip'; c.innerHTML = `${name}`; + const a = document.createElement('button'); a.textContent = t('apply'); a.onclick = () => { vals = s[name].slice(); refresh(); sendPose(); }; + const d = document.createElement('button'); d.textContent = '×'; + d.onclick = () => { const o = loadSaved(); delete o[name]; localStorage.setItem(key(), JSON.stringify(o)); drawSaved(); }; + c.appendChild(a); c.appendChild(d); box.appendChild(c); + }); +} + +// 版本串各字段以 ';' 分隔(web_bridge 保留字段边界),各型号字段不同(L7 有 freedom/ +// Hand direction 等,值可含空格如 "Left hand")。按 ';' 切段、每段首个 ':' 拆 key/value, +// 全部展示;缺失/Unknown 跳过,全无则占位。旧格式(无 ';')退化为整串单段,尽量兜底。 +function renderVersion(el, s){ + el.innerHTML = ''; + s = (s || '').trim(); + const rows = []; + s.split(';').forEach(seg => { + seg = seg.trim(); + const i = seg.indexOf(':'); + if(i < 0) return; + const k = seg.slice(0, i).trim(); + const v = seg.slice(i + 1).trim(); + if(k && v && !/^unknown$/i.test(v)) rows.push([k, v]); + }); + if(!rows.length){ el.innerHTML = '' + t('no_version') + ''; return false; } + // 序列号(Device Serial)固定排到最前,其余字段保持原顺序。 + const si = rows.findIndex(r => r[0] === 'Device Serial'); + if(si > 0) rows.unshift(rows.splice(si, 1)[0]); + rows.forEach(([k, v]) => { + const d = document.createElement('div'); d.className = 'vrow'; + const ks = document.createElement('span'); ks.textContent = t('ver')[k] || k; + const vb = document.createElement('b'); vb.textContent = v; + d.appendChild(ks); d.appendChild(vb); el.appendChild(d); + }); + return true; +} + +let polling = false; +function showDisconnected(){ + document.getElementById('infoVer').innerHTML = '' + t('not_connected') + ''; + document.getElementById('joints').innerHTML = '' + t('not_connected') + ''; + document.getElementById('saved').innerHTML = ''; + sliders.length = 0; outs.length = 0; monCells.length = 0; + verReady = false; + status(t('not_connected')); +} +async function init(){ + META = await (await fetch('/meta')).json(); + if(META.rates) rateEls.forEach(el => { + const v = META.rates[el.dataset.chan]; + if(v){ el.value = v; el.parentElement.querySelector('.rval b').textContent = v; } + }); + if(!polling){ polling = true; poll(); } // poll 循环单例,重连不重复启动 + if(!META.connected){ + showDisconnected(); + openSettings(); // 未连接时自动弹出设置面板 + return; + } + verReady = renderVersion(document.getElementById('infoVer'), META.version); + vals = new Array(META.dof).fill(255); + buildJoints(); drawSaved(); + sendPose(); // 推初始张开姿势 +} +init(); diff --git a/webui/static/en_logo.png b/webui/static/en_logo.png new file mode 100644 index 0000000..770988f Binary files /dev/null and b/webui/static/en_logo.png differ diff --git a/webui/static/favicon.png b/webui/static/favicon.png new file mode 100644 index 0000000..3adad36 Binary files /dev/null and b/webui/static/favicon.png differ diff --git a/webui/static/index.html b/webui/static/index.html new file mode 100644 index 0000000..4fa591d --- /dev/null +++ b/webui/static/index.html @@ -0,0 +1,150 @@ + + + + + +LinkerHand CPP Web + + + + +
+
+ +

+
+
+ + + + + + +
+
+ +
+
+

版本信息

+
+
--
+
+
+ +
+

关节监控与控制

+
+
+
+ +
+

速度 / 力矩

+
+
+ + + 180 +
+
+ + + 180 +
+
+
+ +
+

触觉压感

+
+
暂无触觉数据(该型号无传感器或未接入)。
+
+
+
+ +
就绪
+ + + + + + diff --git a/webui/static/logo.png b/webui/static/logo.png new file mode 100644 index 0000000..7ef15bd Binary files /dev/null and b/webui/static/logo.png differ