diff --git a/README.cn.md b/README.cn.md new file mode 100644 index 0000000..f812036 --- /dev/null +++ b/README.cn.md @@ -0,0 +1,85 @@ +# wireless-esp32-tools 网页用户界面 + +本项目是 [wireless-esp32-tools](https://github.com/kerms/wireless-esp32-tools) 项目的网页用户界面。 + +`wireless-esp32-tools` 项目为多种ESP芯片提供了兼容CMSIS-DAP的无线调试工具,可将ESP芯片变为一个功能强大的无线调试器。 + +## 关于此网页界面 + +此Web应用程序提供了一个用户友好的仪表板,用于管理您的 `wireless-esp32-tools` 设备并与之交互。它采用 Vue.js 3、Vite 和 Element Plus 构建,并设计为直接托管在 ESP32 上。 + +通过此界面,您可以构建一个自定义的仪表板来监控目标设备并与之交互。 + +### 主要功能 + +* **Wi-Fi配置**:包括终端模式(STA)和热点模式(AP),支持静态/动态IP地址分配和DNS设置。 +* **动态组件仪表板**:一个完全灵活的基于网格的面板。 +* **实时数据可视化**:包括一个用于监控目标设备串行通信的UART数据查看器。 +* **嵌入式优先设计**:部署为单个.html文件以减少http连接数。 +* **适用于小屏幕设计**:响应式布局适配移动设备和平板电脑,具有触摸友好的控件和针对小屏幕优化的UI元素。 + +## 界面截图 + +**组件面板** +![组件面板](assets/widgetPannel.png) + +**UART数据显示** +![UART数据显示](assets/uart.png) + +## 使用说明 + +### 最终用户 + +1. 将您的计算机或移动设备连接到运行 `wireless-esp32-tools` 的ESP32所承载的Wi-Fi网络。 +2. 打开Web浏览器并导航到ESP32设备的IP地址(例如 `http://dap.local` 或 `http://192.168.1.1`(连接AP的情况下))。 + +### 开发人员 (针对此Web UI) + +请按照以下步骤设置项目以进行本地开发或构建以进行部署。 + +#### 环境准备 + +* [Node.js](https://nodejs.org/) (v16 或更高版本) +* [npm](https://www.npmjs.com/) + +#### 本地开发 + +1. **安装依赖:** + ```sh + npm install + ``` +2. **运行开发服务器:** + ```sh + npm run dev + ``` + 这将在本地启动一个服务器,通常地址为 `http://localhost:5173`。 + +3. **为移动/远程调试运行:** + 要从同一网络上的其他设备(如手机)访问开发服务器,请使用: + ```sh + npm run devh + ``` + 这将把服务器暴露给您的本地网络(例如 `http://192.168.X.X:5173`)。 + +#### 构建生产版本 (部署到ESP32) + +1. **构建项目:** + ```sh + npm run build + ``` + 此命令将编译和打包应用程序到 `dist/` 目录,生成 `index.html` 和 `ws.sharedworker.js`。 + +2. **压缩输出文件:** + 进入 `dist/` 目录并使用gzip压缩构建产物。 + ```sh + cd dist + gzip * + ``` + 这将生成 `index.html.gz` 和 `ws.sharedworker.js.gz`。 + +3. **部署到ESP32:** + 将压缩后的 `.gz` 文件复制到您的 `wireless-esp32-tools` ESP-IDF项目中的相应目录(例如 `project_components/html/`),然后将固件刷入您的设备。 + +## 许可证 + +根据MIT许可证分发。更多信息请参见 `LICENCE.txt` 文件。 \ No newline at end of file diff --git a/README.md b/README.md index aaa15da..46ddc3d 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,87 @@ -# 允斯无线透传器的内嵌网页版上位机 +# Web UI for wireless-esp32-tools -此项目使用`NPM`包管理, 需要先安装`node`工具。 +This is the web user interface for the [wireless-esp32-tools](https://github.com/kerms/wireless-esp32-tools) project. -# 环境准备步骤 +The `wireless-esp32-tools` project provides CMSIS-DAP compatible wireless debugging tools for various ESP chips, turning them into powerful wireless debug probes. -##### 本地测试: +## About This Web Interface -1. `npm install` -2. `npm run dev`,或用 `npm run devh` 则可以用其他设备访问,如手机调试移动界面。 -3. 根据显示的地址,使用浏览器打开,默认地址为`localhost:5173`, 或者其他设备访问`192.168.X.X:5173` +This web application provides a user-friendly dashboard to manage and interact with your `wireless-esp32-tools` device. It is built with Vue.js 3, Vite, and Element Plus, and is designed to be hosted directly on the ESP32. -##### 发布至esp32: +From this interface, you can build a customized dashboard to monitor and interact with your target device. -1. `npm install` -2. `npm run build` -> 会在`dist/`生成`index.html`和`ws.sharedworker.js` -3. 在`dist/`里执行`gzip *` -> -> 会在`dist/`生成`index.html.gz`和`ws.sharedworker.js.gz` -4. 至此,可以使用这两个文件覆盖ESP32目录中的`project_components/html`里相对应的文件了。 +### Key Features + +* **Wi-Fi Configuration**: Easily configure Wi-Fi settings including station mode (STA) and access point mode (AP), with support for static/dynamic IP addressing and DNS settings. +* **Dynamic Widget Dashboard**: A fully flexible grid-based panel. +* **Real-time Data Visualization**: Includes a UART data viewer for monitoring serial communication from your target device. +* **Embedded-First Design**: Deploy in single .html file to reduce the number of http connection. +* **Designed to be usable on small screen**: Responsive layout that adapts to mobile devices and tablets, with touch-friendly controls and optimized UI elements for small displays. + +## Screenshots + +**Widget Panel** +![Widget Panel](assets/widgetPannel.png) + +**UART Data Viewer** +![UART Data Viewer](assets/uart.png) + +## Getting Started + +### For End-Users + +1. Connect your computer or mobile device to the Wi-Fi network hosted by the ESP32 running `wireless-esp32-tools`. +2. Open a web browser and navigate to the IP address of the ESP32 device (e.g., `http://dap.local` or `http://192.168.1.1`(when connected to the AP)). + +### For Developers (of this Web UI) + +Follow these steps to set up the project for local development or to build it for deployment. + +#### Prerequisites + +* [Node.js](https://nodejs.org/) (v16 or later) +* [npm](https://www.npmjs.com/) + +#### Local Development + +1. **Install dependencies:** + ```sh + npm install + ``` +2. **Run the development server:** + ```sh + npm run dev + ``` + This will start a local server, typically at `http://localhost:5173`. + +3. **Run for mobile/remote debugging:** + To access the development server from other devices on the same network (like a mobile phone), use: + ```sh + npm run devh + ``` + This will expose the server to your local network (e.g., `http://192.168.X.X:5173`). + +#### Building for Production (Deploying to ESP32) + +1. **Build the project:** + ```sh + npm run build + ``` + This command compiles and bundles the application into the `dist/` directory, creating `index.html` and `ws.sharedworker.js`. + + There is some bugs on Firefox or Vue3 when embbeding sharedWorker in single file mode. So at the time, the sharedWorker are build separately. + +2. **Compress the output files:** + Navigate into the `dist/` directory and compress the build artifacts using gzip. + ```sh + cd dist + gzip * + ``` + This will generate `index.html.gz` and `ws.sharedworker.js.gz`. + +3. **Deploy to ESP32:** + Copy the compressed `.gz` files to the appropriate directory in your `wireless-esp32-tools` ESP-IDF project (e.g., `project_components/html/`) and flash the firmware to your device. + +## License + +Distributed under the MIT License. See `LICENCE.txt` for more information. diff --git a/assets/uart.png b/assets/uart.png new file mode 100644 index 0000000..3cb6f58 Binary files /dev/null and b/assets/uart.png differ diff --git a/assets/widgetPannel.png b/assets/widgetPannel.png new file mode 100644 index 0000000..ed2f51a Binary files /dev/null and b/assets/widgetPannel.png differ diff --git a/index.html b/index.html index ddb979e..013709d 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite App + yunsi.studio project
diff --git a/package-lock.json b/package-lock.json index 6b22e3e..2fe64b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "element-plus": "^2.8.1", "mitt": "^3.0.1", "pinia": "^2.1.7", - "vue": "^3.4.21", + "vue": "^3.5.16", "vue-draggable-plus": "^0.4.1", "vue-grid-layout-v3": "^3.1.2", "vue-i18n": "^9.10.2", @@ -78,10 +78,29 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", - "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", + "dependencies": { + "@babel/types": "^7.27.3" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -89,6 +108,18 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/types": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@ctrl/tinycolor": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", @@ -499,9 +530,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -1102,49 +1133,49 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.4.21", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.21.tgz", - "integrity": "sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.16.tgz", + "integrity": "sha512-AOQS2eaQOaaZQoL1u+2rCJIKDruNXVBZSiUD3chnUrsoX5ZTQMaCvXlWNIfxBJuU15r1o7+mpo5223KVtIhAgQ==", "dependencies": { - "@babel/parser": "^7.23.9", - "@vue/shared": "3.4.21", + "@babel/parser": "^7.27.2", + "@vue/shared": "3.5.16", "entities": "^4.5.0", "estree-walker": "^2.0.2", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.4.21", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.21.tgz", - "integrity": "sha512-IZC6FKowtT1sl0CR5DpXSiEB5ayw75oT2bma1BEhV7RRR1+cfwLrxc2Z8Zq/RGFzJ8w5r9QtCOvTjQgdn0IKmA==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.16.tgz", + "integrity": "sha512-SSJIhBr/teipXiXjmWOVWLnxjNGo65Oj/8wTEQz0nqwQeP75jWZ0n4sF24Zxoht1cuJoWopwj0J0exYwCJ0dCQ==", "dependencies": { - "@vue/compiler-core": "3.4.21", - "@vue/shared": "3.4.21" + "@vue/compiler-core": "3.5.16", + "@vue/shared": "3.5.16" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.4.21", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.21.tgz", - "integrity": "sha512-me7epoTxYlY+2CUM7hy9PCDdpMPfIwrOvAXud2Upk10g4YLv9UBW7kL798TvMeDhPthkZ0CONNrK2GoeI1ODiQ==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.16.tgz", + "integrity": "sha512-rQR6VSFNpiinDy/DVUE0vHoIDUF++6p910cgcZoaAUm3POxgNOOdS/xgoll3rNdKYTYPnnbARDCZOyZ+QSe6Pw==", "dependencies": { - "@babel/parser": "^7.23.9", - "@vue/compiler-core": "3.4.21", - "@vue/compiler-dom": "3.4.21", - "@vue/compiler-ssr": "3.4.21", - "@vue/shared": "3.4.21", + "@babel/parser": "^7.27.2", + "@vue/compiler-core": "3.5.16", + "@vue/compiler-dom": "3.5.16", + "@vue/compiler-ssr": "3.5.16", + "@vue/shared": "3.5.16", "estree-walker": "^2.0.2", - "magic-string": "^0.30.7", - "postcss": "^8.4.35", - "source-map-js": "^1.0.2" + "magic-string": "^0.30.17", + "postcss": "^8.5.3", + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.4.21", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.21.tgz", - "integrity": "sha512-M5+9nI2lPpAsgXOGQobnIueVqc9sisBFexh5yMIMRAPYLa7+5wEJs8iqOZc1WAa9WQbx9GR2twgznU8LTIiZ4Q==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.16.tgz", + "integrity": "sha512-d2V7kfxbdsjrDSGlJE7my1ZzCXViEcqN6w14DOsDrUCHEA6vbnVCpRFfrc4ryCP/lCKzX2eS1YtnLE/BuC9f/A==", "dependencies": { - "@vue/compiler-dom": "3.4.21", - "@vue/shared": "3.4.21" + "@vue/compiler-dom": "3.5.16", + "@vue/shared": "3.5.16" } }, "node_modules/@vue/compiler-vue2": { @@ -1225,48 +1256,49 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.4.21", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.21.tgz", - "integrity": "sha512-UhenImdc0L0/4ahGCyEzc/pZNwVgcglGy9HVzJ1Bq2Mm9qXOpP8RyNTjookw/gOCUlXSEtuZ2fUg5nrHcoqJcw==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.16.tgz", + "integrity": "sha512-FG5Q5ee/kxhIm1p2bykPpPwqiUBV3kFySsHEQha5BJvjXdZTUfmya7wP7zC39dFuZAcf/PD5S4Lni55vGLMhvA==", "dependencies": { - "@vue/shared": "3.4.21" + "@vue/shared": "3.5.16" } }, "node_modules/@vue/runtime-core": { - "version": "3.4.21", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.21.tgz", - "integrity": "sha512-pQthsuYzE1XcGZznTKn73G0s14eCJcjaLvp3/DKeYWoFacD9glJoqlNBxt3W2c5S40t6CCcpPf+jG01N3ULyrA==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.16.tgz", + "integrity": "sha512-bw5Ykq6+JFHYxrQa7Tjr+VSzw7Dj4ldR/udyBZbq73fCdJmyy5MPIFR9IX/M5Qs+TtTjuyUTCnmK3lWWwpAcFQ==", "dependencies": { - "@vue/reactivity": "3.4.21", - "@vue/shared": "3.4.21" + "@vue/reactivity": "3.5.16", + "@vue/shared": "3.5.16" } }, "node_modules/@vue/runtime-dom": { - "version": "3.4.21", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.21.tgz", - "integrity": "sha512-gvf+C9cFpevsQxbkRBS1NpU8CqxKw0ebqMvLwcGQrNpx6gqRDodqKqA+A2VZZpQ9RpK2f9yfg8VbW/EpdFUOJw==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.16.tgz", + "integrity": "sha512-T1qqYJsG2xMGhImRUV9y/RseB9d0eCYZQ4CWca9ztCuiPj/XWNNN+lkNBuzVbia5z4/cgxdL28NoQCvC0Xcfww==", "dependencies": { - "@vue/runtime-core": "3.4.21", - "@vue/shared": "3.4.21", + "@vue/reactivity": "3.5.16", + "@vue/runtime-core": "3.5.16", + "@vue/shared": "3.5.16", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.4.21", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.21.tgz", - "integrity": "sha512-aV1gXyKSN6Rz+6kZ6kr5+Ll14YzmIbeuWe7ryJl5muJ4uwSwY/aStXTixx76TwkZFJLm1aAlA/HSWEJ4EyiMkg==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.16.tgz", + "integrity": "sha512-BrX0qLiv/WugguGsnQUJiYOE0Fe5mZTwi6b7X/ybGB0vfrPH9z0gD/Y6WOR1sGCgX4gc25L1RYS5eYQKDMoNIg==", "dependencies": { - "@vue/compiler-ssr": "3.4.21", - "@vue/shared": "3.4.21" + "@vue/compiler-ssr": "3.5.16", + "@vue/shared": "3.5.16" }, "peerDependencies": { - "vue": "3.4.21" + "vue": "3.5.16" } }, "node_modules/@vue/shared": { - "version": "3.4.21", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.21.tgz", - "integrity": "sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g==" + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.16.tgz", + "integrity": "sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg==" }, "node_modules/@vue/tsconfig": { "version": "0.5.1", @@ -3328,14 +3360,11 @@ } }, "node_modules/magic-string": { - "version": "0.30.8", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", - "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/mdn-data": { @@ -3445,9 +3474,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -3936,9 +3965,9 @@ } }, "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", + "integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==", "funding": [ { "type": "opencollective", @@ -3954,7 +3983,7 @@ } ], "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5168,15 +5197,15 @@ "dev": true }, "node_modules/vue": { - "version": "3.4.21", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.21.tgz", - "integrity": "sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.16.tgz", + "integrity": "sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==", "dependencies": { - "@vue/compiler-dom": "3.4.21", - "@vue/compiler-sfc": "3.4.21", - "@vue/runtime-dom": "3.4.21", - "@vue/server-renderer": "3.4.21", - "@vue/shared": "3.4.21" + "@vue/compiler-dom": "3.5.16", + "@vue/compiler-sfc": "3.5.16", + "@vue/runtime-dom": "3.5.16", + "@vue/server-renderer": "3.5.16", + "@vue/shared": "3.5.16" }, "peerDependencies": { "typescript": "*" diff --git a/package.json b/package.json index 4564802..4956047 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "element-plus": "^2.8.1", "mitt": "^3.0.1", "pinia": "^2.1.7", - "vue": "^3.4.21", + "vue": "^3.5.16", "vue-draggable-plus": "^0.4.1", "vue-grid-layout-v3": "^3.1.2", "vue-i18n": "^9.10.2", diff --git a/src/App.vue b/src/App.vue index 627b695..b37be23 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,83 +1,89 @@ diff --git a/src/assets/icon/close.svg b/src/assets/icon/close.svg new file mode 100644 index 0000000..fbc8515 --- /dev/null +++ b/src/assets/icon/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icon/lock.svg b/src/assets/icon/lock.svg new file mode 100644 index 0000000..07b2ef7 --- /dev/null +++ b/src/assets/icon/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icon/lock_open.svg b/src/assets/icon/lock_open.svg new file mode 100644 index 0000000..c061381 --- /dev/null +++ b/src/assets/icon/lock_open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icon/play.svg b/src/assets/icon/play.svg new file mode 100644 index 0000000..47a9e72 --- /dev/null +++ b/src/assets/icon/play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icon/repeat.svg b/src/assets/icon/repeat.svg new file mode 100644 index 0000000..701ed95 --- /dev/null +++ b/src/assets/icon/repeat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icon/text-data.svg b/src/assets/icon/text-data.svg new file mode 100644 index 0000000..7a53266 --- /dev/null +++ b/src/assets/icon/text-data.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/composables/useCommandLoopManager.ts b/src/composables/useCommandLoopManager.ts new file mode 100644 index 0000000..dc28fa8 --- /dev/null +++ b/src/composables/useCommandLoopManager.ts @@ -0,0 +1,176 @@ +/** + * @file Composable for managing a task scheduler that executes callbacks at specified intervals. + * This scheduler avoids time drift and handles race conditions from stale timers. + */ +import { ref, onUnmounted } from 'vue' +import { isDevMode } from './buildMode' + +interface ScheduledTask { + id: string + intervalMS: number + nextExecutionTime: number + executeCallback: () => Promise | void + oneTime: boolean + version: number +} + +// --- Module-level state (Singleton pattern) --- + +/** A sorted array of tasks to be executed. The task at index 0 is always the next one. */ +const scheduledTasks = ref([]) +/** Tracks the latest version for a given task ID to prevent stale timers from running. */ +const taskVersions = new Map() +/** The ID of the currently active `setTimeout` instance. */ +let currentTimerId: number | null = null +/** A reactive flag indicating if the scheduler has any pending tasks. */ +export const isSchedulerRunning = ref(false) +/** A lock to prevent concurrent task executions */ +let isTaskExecuting = false + +export function useCommandLoopManager() { + // --- Private Scheduler Core --- + + /** + * Executes the task at the front of the queue and reschedules it if it's recurring. + */ + const executeNextTask = async () => { + // If a task is already running, wait before trying to execute the next one. + if (isTaskExecuting) { + if (isDevMode()) console.log('[Scheduler] Delaying task execution: another task is already running.') + // This is a simple back-off strategy. + currentTimerId = window.setTimeout(executeNextTask, 50) + return + } + + if (scheduledTasks.value.length === 0) { + isSchedulerRunning.value = false + return + } + + const taskToExecute = scheduledTasks.value.shift()! + + // Stale check + const latestVersion = taskVersions.get(taskToExecute.id) + if (taskToExecute.version !== latestVersion) { + if (isDevMode()) console.log(`[Scheduler] Discarding stale task '${taskToExecute.id}' v${taskToExecute.version}.`) + scheduleNextExecution() // The queue has changed, so recalculate. + return + } + + // Acquire the lock and execute the callback. + try { + isTaskExecuting = true + if (isDevMode()) console.log(`[Scheduler] Executing task '${taskToExecute.id}' v${taskToExecute.version}`) + await taskToExecute.executeCallback() + } catch (error) { + console.error(`[Scheduler] Error in task '${taskToExecute.id}':`, error) + } finally { + isTaskExecuting = false // ALWAYS release the lock + } + + // If it's a recurring task, reschedule it. + if (!taskToExecute.oneTime) { + taskToExecute.nextExecutionTime = Date.now() + taskToExecute.intervalMS + // Re-insert the task and re-sort the queue. + scheduledTasks.value.push(taskToExecute) + scheduledTasks.value.sort((a, b) => a.nextExecutionTime - b.nextExecutionTime) + } else { + taskVersions.delete(taskToExecute.id) + } + + // Set the timer for the next task in the queue. + scheduleNextExecution() + } + + /** + * Sets a single master timer for the next task in the queue. + */ + const scheduleNextExecution = () => { + if (currentTimerId !== null) { + window.clearTimeout(currentTimerId) + currentTimerId = null + } + + if (scheduledTasks.value.length === 0) { + isSchedulerRunning.value = false + return + } + + const nextTask = scheduledTasks.value[0] + const timeout = Math.max(0, nextTask.nextExecutionTime - Date.now()) + + currentTimerId = window.setTimeout(executeNextTask, timeout) + isSchedulerRunning.value = true + + if (isDevMode()) { + console.log(`[Scheduler] Next task '${nextTask.id}' v${nextTask.version} scheduled in ${timeout}ms.`) + } + } + + // --- Public API --- + + /** + * Registers a new task, updates an existing one, or removes a task. + * This is the single entry point for all scheduling changes. + * @param id A unique identifier for the task. + * @param intervalMS The interval in milliseconds. Pass 0 or an invalid value to unregister. + * @param executeCallback The function to execute. + * @param oneTime If true, the task runs once and is not rescheduled. + */ + const registerLoop = ( + id: string, + intervalMS: number | string, + executeCallback: () => Promise | void, + oneTime = false, + ) => { + scheduledTasks.value = scheduledTasks.value.filter((task) => task.id !== id) + + const interval = typeof intervalMS === 'string' ? parseInt(intervalMS, 10) : intervalMS + if (!interval || isNaN(interval) || interval <= 0) { + if (isDevMode()) console.log(`[Scheduler] Unregistered task '${id}'.`) + taskVersions.delete(id) + scheduleNextExecution() + return + } + + const newVersion = (taskVersions.get(id) || 0) + 1 + taskVersions.set(id, newVersion) + + const newTask: ScheduledTask = { + id, + intervalMS: interval, + nextExecutionTime: Date.now() + interval, + executeCallback, + oneTime, + version: newVersion, + } + + scheduledTasks.value.push(newTask) + scheduledTasks.value.sort((a, b) => a.nextExecutionTime - b.nextExecutionTime) + + if (isDevMode()) { + console.log(`[Scheduler] Registered task '${id}' v${newVersion} with interval ${interval}ms.`) + } + + scheduleNextExecution() + } + + /** + * A convenience helper to explicitly remove a task from the scheduler. + * @param id The identifier of the task to remove. + */ + const unregisterLoop = (id: string) => { + registerLoop(id, -1, () => {}) + } + + // --- Lifecycle Hook --- + + onUnmounted(() => { + if (currentTimerId !== null) window.clearTimeout(currentTimerId) + scheduledTasks.value = [] + taskVersions.clear() + isSchedulerRunning.value = false + }) + + return { registerLoop, unregisterLoop, isRunning: isSchedulerRunning } +} \ No newline at end of file diff --git a/src/composables/useSequentialUart.ts b/src/composables/useSequentialUart.ts new file mode 100644 index 0000000..6d9ddb5 --- /dev/null +++ b/src/composables/useSequentialUart.ts @@ -0,0 +1,83 @@ +import { useDataViewerStore } from '@/stores/dataViewerStore' +import { watch } from 'vue' +import type { IDataBuf } from '@/stores/dataViewerStore' +import { isDevMode } from '@/composables/buildMode' + +function decodeUtf8(u8Arr: Uint8Array) { + try { + const decoder = new TextDecoder() + const decodedText = decoder.decode(u8Arr) // Attempt to decode + return decodedText.replace(/\uFFFD/g, '') // Remove all � characters + } catch (error) { + return '' + } +} + +export function useSequentialUart() { + const dataViewerStore = useDataViewerStore() as any + + async function sendCommand(command: string): Promise { + return new Promise((resolve) => { + let responseBuffer = '' + let responseTimeout: number | null = null + const RESPONSE_WAIT_TIME = 10 // Wait 500ms after last response before resolving + + // Index to track which messages we've already processed + let lastProcessedIndex = dataViewerStore.dataFiltered.length - 1 + + const stopWatch = watch( + () => dataViewerStore.dataFiltered, + (newBuf: IDataBuf[]) => { + if (isDevMode()) { + console.log('watch data', newBuf) + } + + // Process only new messages + for (let i = lastProcessedIndex + 1; i < newBuf.length; i++) { + const message = newBuf[i] + if (message.isRX) { + // Add to response buffer + responseBuffer += decodeUtf8(message.data) + + // Reset timeout to wait for more responses + if (responseTimeout) { + clearTimeout(responseTimeout) + } + + responseTimeout = window.setTimeout(() => { + stopWatch() + resolve(responseBuffer) + }, RESPONSE_WAIT_TIME) + } + } + + // Update last processed index + lastProcessedIndex = newBuf.length - 1 + }, + { deep: true } + ) + + dataViewerStore.addString(command, false, true) + + // Set a maximum timeout in case no response is received + const maxTimeout = window.setTimeout(() => { + stopWatch() + resolve(responseBuffer) + }, 1000) // 5 seconds maximum wait time + }) + } + + async function sendCommands(commands: string[]): Promise { + const responses: string[] = [] + for (const command of commands) { + const response = await sendCommand(command) + responses.push(response) + } + if (isDevMode()) { + console.log('sendCommands', responses) + } + return responses + } + + return { sendCommands } +} diff --git a/src/composables/useUartModule.ts b/src/composables/useUartModule.ts new file mode 100644 index 0000000..9a3e826 --- /dev/null +++ b/src/composables/useUartModule.ts @@ -0,0 +1,81 @@ +import type { IUartMsgConfig, IUartMsgNum } from '@/api/apiUart' +import type { IUartMsgBaud } from '@/api/apiUart' +import { uart_get_baud, uart_get_config, WtUartCmd } from '@/api/apiUart' +import { useUartStore } from '@/stores/useUartStore' +import { registerModule } from '@/router/msgRouter' +import * as api from '@/api' +import { isDevMode } from './buildMode' +import { useDataViewerStore } from '@/stores/dataViewerStore' +import type { ApiBinaryMsg } from '@/api/binDataDef' +import { ControlEvent } from '@/api' +import * as df from '@/api/apiDataFlow' +import { uart_get_default_num } from '@/api/apiUart' + +export function useUartModule() { + const dataViewerStore = useDataViewerStore() + const uartStore = useUartStore() + + function updateUartData() { + /* TODO: hard code for the moment, 0 is UART instance id (can be changed in the future) */ + uart_get_default_num() + df.wt_data_flow_attach_cur_to_sender(0) + } + + const onUartJsonMsg = (msg: api.ApiJsonMsg) => { + switch (msg.cmd as WtUartCmd) { + case WtUartCmd.GET_BAUD: + case WtUartCmd.SET_BAUD: { + const uartMsg = msg as IUartMsgBaud + if (uartMsg.baud) { + dataViewerStore.setUartBaud(uartMsg.baud) + } + break + } + case WtUartCmd.GET_CONFIG: + case WtUartCmd.SET_CONFIG: { + const uartMsg = msg as IUartMsgConfig + dataViewerStore.uartConfig.data_bits = uartMsg.data_bits + dataViewerStore.uartConfig.stop_bits = uartMsg.stop_bits + dataViewerStore.uartConfig.parity = uartMsg.parity + break + } + case WtUartCmd.GET_DEFAULT_NUM: + uartStore.uartNum = (msg as IUartMsgNum).num + uart_get_baud(uartStore.uartNum) + uart_get_config(uartStore.uartNum) + break + default: + if (isDevMode()) { + console.log('uart not treated', msg) + } + break + } + } + + const onUartBinaryMsg = (msg: ApiBinaryMsg) => { + if (isDevMode()) { + console.log('uart', msg) + } + + dataViewerStore.addSegment(new Uint8Array(msg.payload), true) + } + + const onClientCtrl = (msg: api.ControlMsg) => { + if (msg.type !== api.ControlMsgType.WS_EVENT) { + return + } + + if (msg.data === ControlEvent.DISCONNECTED) { + dataViewerStore.acceptIncomingData = false + } else if (msg.data === ControlEvent.CONNECTED) { + updateUartData() + dataViewerStore.acceptIncomingData = true + } + } + + registerModule(api.WtModuleID.UART, { + ctrlCallback: onClientCtrl, + serverJsonMsgCallback: onUartJsonMsg, + serverBinMsgCallback: onUartBinaryMsg + }) +} diff --git a/src/locales/en.ts b/src/locales/en.ts index 640aeee..d05d2a7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -37,7 +37,7 @@ export default { wifi: "Wi-Fi", about: "About", uart: "Uart", - at: "AT Command", + widget: "Widget", feedback: "Feedback", close: "Close", update: "Update", @@ -200,6 +200,36 @@ export default { connectionSuccess: "Connection Successful", enterAPName: "Entre the AP name", debuggerNotConnected: "Debugger not connected", - } + }, + widget: { + editGrid: 'Edit Grid', + editCell: 'Edit Cell', + loopWidget: 'Loop Widget', + loopWidgetDesc: 'Container for command sequences.', + addGrid: 'Add to Grid', + dataViewer: 'Data Viewer', + dataViewerDesc: 'Displays raw text data from UART.', + exportSettings: 'Export Settings', + importSettings: 'Import Settings', + resetToDefault: 'Reset to Default', + gridItemName: 'Widget name', + dropHere: 'Drop here', + run: 'Run', + loop: 'Loop', + delay: 'Delay', + addCommand: 'Add Command', + loopInterval: 'Loop Interval', + uartViewOnce: 'UART View Widget can only be added once.' + }, + + common: { + debuggerConnected: 'Debugger connected', + ok: 'OK' + }, + + navbar: { + navigationSidebar: 'Navigation Sidebar', + getSomeFries: "Let's go to the dock and get some fries", + }, }; \ No newline at end of file diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 8daba3d..8973caa 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -37,7 +37,7 @@ export default { wifi: "Wi-Fi", about: "À propos", uart: "Uart", - at: "Commande AT", + widget: "Widget", feedback: "Feedback", close: "Fermer", update: "Mise à jour", @@ -201,5 +201,36 @@ export default { connectionSuccess: "Connexion Réussie", enterAPName: "Entrez le nom du AP", debuggerNotConnected: "Debugger non connecté", - } + }, + + widget: { + editGrid: 'Modifier la grille', + editCell: 'Modifier les cellules', + loopWidget: 'Widget Boucle', + loopWidgetDesc: 'Conteneur pour les séquences de commandes.', + addGrid: 'Ajouter à la grille', + dataViewer: 'Visualiseur de données', + dataViewerDesc: "Affiche les données texte brutes de l'UART.", + exportSettings: 'Exporter les paramètres', + importSettings: 'Importer les paramètres', + resetToDefault: 'Réinitialiser par défaut', + gridItemName: "Nom", + dropHere: 'Déposer ici', + run: 'Exécuter', + loop: 'Boucle', + delay: 'Délai', + addCommand: 'Ajouter une commande', + loopInterval: 'Intervalle de répétition', + uartViewOnce: "Le widget de vue UART ne peut être ajouté qu'une seule fois." + }, + + common: { + debuggerConnected: 'Débogueur connecté', + ok: 'OK' + }, + + navbar: { + navigationSidebar: 'Barre latérale de navigation', + getSomeFries: 'Allons au quai prendre des frites', + }, }; \ No newline at end of file diff --git a/src/locales/zh.ts b/src/locales/zh.ts index ce1e502..6999b9f 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -37,7 +37,7 @@ export default { wifi: "Wi-Fi", about: "关于", uart: "UART", - at: "AT命令", + widget: "组件", feedback: "反馈", close: "关闭", update: "更新", @@ -143,7 +143,7 @@ export default { autoUpdate: "自动刷新", tempDisplayTooltip: "未满足断帧规则的数据(如:未超时),暂时实时显示在此区域。超过8192字节,自动断帧;", loopSend: "循环发送", - loopSendTooltip: "实际频率受界面刷新率影响,如需要更精确,可以尝试关闭‘自动刷新’", + loopSendTooltip: "实际频率受界面刷新率影响,如需要更精确,可以尝试关闭'自动刷新'", sendFormat: "发送格式", cachedFrame: "缓存帧数", format: "格式化", @@ -204,5 +204,36 @@ export default { connectionSuccess: "连接成功", enterAPName: "请输入AP名", debuggerNotConnected: "调试器未连接", - } + }, + + widget: { + editGrid: '编辑网格', + editCell: '编辑单元', + loopWidget: '循环小部件', + loopWidgetDesc: '用于命令序列的容器。', + addGrid: '添加到网格', + dataViewer: '数据显示器', + dataViewerDesc: '显示来自UART的原始文本数据。', + exportSettings: '导出设置', + importSettings: '导入设置', + resetToDefault: '重置', + gridItemName: '组件名称', + dropHere: '在此处放置', + run: '运行', + loop: '循环', + delay: '延迟', + addCommand: '添加命令', + loopInterval: '循环间隔', + uartViewOnce: 'UART视图组件只能添加一次。' + }, + + common: { + debuggerConnected: '调试器已连接', + ok: '好的' + }, + + navbar: { + navigationSidebar: '导航侧栏', + getSomeFries: '走,去码头整点薯条', + }, } \ No newline at end of file diff --git a/src/router/index.ts b/src/router/index.ts index a08ad0d..216e840 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,18 +1,18 @@ -import {createRouter, createWebHistory, type RouteLocationNormalizedLoaded} from 'vue-router' +import {createRouter, createWebHashHistory, type RouteLocationNormalizedLoaded} from 'vue-router' import Wifi from '@/views/Wifi.vue' import Feedback from '@/views/Feedback.vue' import About from '@/views/About.vue' import Uart from '@/views/Uart.vue' import Page404 from '@/views/404.vue' import Update from '@/views/Update.vue' -import AtCommand from '@/views/AtCommand.vue' +import WidgetPannel from '@/views/WidgetPannel.vue' import {translate} from "@/locales"; import {isOTAEnabled} from "@/composables/buildMode"; import {reactive, watch} from "vue"; import {getLang} from "@/i18n"; const languageState = reactive({ - currentLanguage: getLang(), // Get the current language from your i18n setup + lang: getLang() }); interface AppRouteMeta { @@ -37,14 +37,14 @@ function updateDocumentTitle(route: RouteLocationNormalizedLoaded) { } // Watch for language changes to update the titles dynamically -watch(() => languageState.currentLanguage, () => { +watch(() => languageState.lang, () => { // Recompute all route meta titles updateMetaTitles(); updateDocumentTitle(router.currentRoute.value); }, {deep: true}); const router = createRouter({ - history: createWebHistory(import.meta.env.BASE_URL), + history: createWebHashHistory(import.meta.env.BASE_URL), routes: [ { path: '/', @@ -68,9 +68,9 @@ const router = createRouter({ meta: { titleKey: 'page.uart' }, component: Uart, }, { - path: '/at:ext(.*)', - meta: { titleKey: 'page.at' }, - component: AtCommand, + path: '/widget:ext(.*)', + meta: { titleKey: 'page.widget' }, + component: WidgetPannel, }, { path: '/feedback:ext(.*)', meta: { titleKey: 'page.feedback' }, diff --git a/src/stores/useWidgetStore.ts b/src/stores/useWidgetStore.ts new file mode 100644 index 0000000..fc79645 --- /dev/null +++ b/src/stores/useWidgetStore.ts @@ -0,0 +1,424 @@ +import { computed, markRaw, ref, toRaw, watch } from 'vue' +import { defineStore } from 'pinia' +import type { WidgetItem } from '@/types/grid' +import { globalNotify } from '@/composables/notification' +import { ElMessageBox } from 'element-plus' +import UartAtCommand from '@/views/widgets/uartAtCommand.vue' +import WidgetLoop from '@/views/widgets/widgetLoop.vue' +import textDataViewer from '@/views/text-data-viewer/textDataViewer.vue' + +const componentMap: { [key: string]: any } = { + WidgetLoop, + textDataViewer, + UartAtCommand +} + +const getComponentName = (component: any): string | null => { + const rawComponent = toRaw(component) + for (const name in componentMap) { + if (componentMap[name] === rawComponent) { + return name + } + } + return null +} + +const getDefaultLayout = (): WidgetItem[] => [ + { + x: 0, + y: 0, + w: 10, + h: 10, + i: 0, + name: 'Widget A', + static: false, + widget: markRaw(WidgetLoop), + widgetProps: [ + { + id: 1, + componentType: markRaw(UartAtCommand), + props: { label: 'Device ID', command: 'AT+ID?', response: 'ID:xxxx' } + }, + { + id: 2, + componentType: markRaw(UartAtCommand), + props: { label: 'Version', command: 'AT+VER?', response: 'V1.0.0' } + }, + { + id: 3, + componentType: markRaw(UartAtCommand), + props: { label: 'Reset', command: 'AT+RESET', response: 'OK' } + } + ] + }, + { + x: 10, + y: 0, + w: 10, + h: 10, + i: 1, + name: 'Widget B', + static: false, + widget: markRaw(WidgetLoop), + widgetProps: [ + { + id: 1, + componentType: markRaw(UartAtCommand), + props: { + label: 'Scan WiFi', + command: 'AT+WSCANasdfasdfasdf', + response: 'SCAN OKasd fsdaf asdf asdf asdf asdf ' + } + }, + { + id: 2, + componentType: markRaw(UartAtCommand), + props: { + label: 'Connect WiFi', + command: 'AT+WCONN=ssid,pwd', + response: 'CONN OK' + } + } + ] + }, + { + x: 0, + y: 10, + w: 10, + h: 10, + i: 2, + name: 'Widget C', + static: false, + widget: markRaw(WidgetLoop), + widgetProps: [ + { + id: 1, + componentType: markRaw(UartAtCommand), + props: { + label: 'Ping Test', + command: 'AT+PING=google.com', + response: 'PING OK' + } + } + ] + }, + { + x: 10, + y: 10, + w: 10, + h: 10, + i: 3, + name: 'Widget D', + static: false, + widget: markRaw(textDataViewer), + widgetProps: [] + } +] + +const throttle = (fn: Function, wait: number) => { + let inThrottle: boolean, lastFn: number, lastTime: number + return function (this: any, ...args: any[]) { + const context = this + if (!inThrottle) { + fn.apply(context, args) + lastTime = Date.now() + inThrottle = true + } else { + clearTimeout(lastFn) + lastFn = window.setTimeout(() => { + if (Date.now() - lastTime >= wait) { + fn.apply(context, args) + lastTime = Date.now() + } + }, Math.max(wait - (Date.now() - lastTime), 0)) + } + } +} + +export const useWidgetStore = defineStore('widget', () => { + const layout = ref(getDefaultLayout()) + + const editCell = ref(false) + const editGrid = ref(false) + const showOptions = ref(true) + + const isUartViewAdded = computed(() => + layout.value.some((item) => toRaw(item.widget) === textDataViewer) + ) + + watch( + () => editGrid.value, + (newValue) => { + if (newValue) { + editCell.value = false + } + } + ) + + watch( + () => editCell.value, + (newValue) => { + if (newValue) { + editGrid.value = false + } + } + ) + + const getNextId = (): number => { + const numericIds = layout.value.map((item) => Number(item.i)).filter((id) => !isNaN(id)) + if (numericIds.length === 0) { + return 0 + } + return Math.max(...numericIds) + 1 + } + + const addLoopWidget = () => { + const nextId = getNextId() + let y = 0 + if (layout.value.length > 0) { + y = Math.max(...layout.value.map((item) => item.y + item.h)) + } + const newWidget: WidgetItem = { + x: 0, + y: y, + w: 10, + h: 5, + i: nextId, + name: `New Loop Widget`, + static: false, + widget: markRaw(WidgetLoop), + widgetProps: [] + } + layout.value.push(newWidget) + } + + const addUartViewWidget = () => { + if (isUartViewAdded.value) { + globalNotify('UART View Widget can only be added once.', 'warning') + return + } + + const nextId = getNextId() + let y = 0 + if (layout.value.length > 0) { + y = Math.max(...layout.value.map((item) => item.y + item.h)) + } + + const newWidget: WidgetItem = { + x: 0, + y: y, + w: 10, + h: 10, + i: nextId, + name: 'UART Data Viewer', + static: false, + widget: markRaw(textDataViewer), + widgetProps: [] + } + layout.value.push(newWidget) + } + + const deleteWidget = (index: number) => { + layout.value.splice(index, 1) + } + + const resetToDefault = () => { + ElMessageBox.confirm( + 'This will reset your layout to the default settings. Are you sure?', + 'Warning', + { + confirmButtonText: 'OK', + cancelButtonText: 'Cancel', + type: 'warning' + } + ) + .then(() => { + layout.value = getDefaultLayout() + globalNotify('Layout reset to default.', 'success') + }) + .catch(() => { + globalNotify('Layout reset cancelled.', 'info') + }) + } + + const exportSettings = () => { + try { + const layoutToSave = toRaw(layout.value).map((item) => { + const rawItem = toRaw(item) + return { + ...rawItem, + widget: getComponentName(rawItem.widget), + widgetProps: rawItem.widgetProps?.map((prop: any) => { + const rawProp = toRaw(prop) + return { + ...rawProp, + componentType: getComponentName(rawProp.componentType) + } + }) + } + }) + + const dataStr = JSON.stringify(layoutToSave, null, 2) + const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr) + + const exportFileDefaultName = 'at-command-settings.json' + + const linkElement = document.createElement('a') + linkElement.setAttribute('href', dataUri) + linkElement.setAttribute('download', exportFileDefaultName) + linkElement.click() + globalNotify('Settings exported successfully.', 'success') + } catch (error) { + console.error('Failed to export settings:', error) + globalNotify('Failed to export settings.', 'error') + } + } + + const importSettings = () => { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.json' + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (!file) return + + const reader = new FileReader() + reader.onload = (event) => { + try { + const fileContent = event.target?.result as string + const parsedLayout = JSON.parse(fileContent) + + // Basic validation + if (!Array.isArray(parsedLayout)) { + throw new Error('Invalid format: expected an array of widgets.') + } + + const newLayout = parsedLayout + .map((item: any) => { + if (item.widget && componentMap[item.widget]) { + item.widget = markRaw(componentMap[item.widget]) + } else { + console.warn(`Unknown widget type "${item.widget}" during import. Skipping item.`) + return null + } + + if (item.widgetProps) { + item.widgetProps.forEach((prop: any) => { + if (prop.componentType && componentMap[prop.componentType]) { + prop.componentType = markRaw(componentMap[prop.componentType]) + } else if (prop.componentType) { + console.warn( + `Unknown componentType "${prop.componentType}" for widget "${item.name}". It will be ignored.` + ) + prop.componentType = null + } + }) + item.widgetProps = item.widgetProps.filter((prop: any) => prop.componentType) + } + return item + }) + .filter(Boolean) // remove null items + + layout.value = newLayout + globalNotify('Settings imported successfully.', 'success') + } catch (error: any) { + console.error('Failed to import settings:', error) + globalNotify(`Failed to import settings: ${error.message}`, 'error') + } + } + reader.readAsText(file) + } + input.click() + } + + const saveLayoutToLocalStorage = () => { + try { + const layoutToSave = toRaw(layout.value).map((item) => { + const rawItem = toRaw(item) + return { + ...rawItem, + widget: getComponentName(rawItem.widget), + widgetProps: rawItem.widgetProps?.map((prop: any) => { + const rawProp = toRaw(prop) + return { + ...rawProp, + componentType: getComponentName(rawProp.componentType) + } + }) + } + }) + localStorage.setItem('at-command-layout', JSON.stringify(layoutToSave)) + } catch (error) { + console.error('Failed to save layout to localStorage:', error) + } + } + + const loadLayoutFromLocalStorage = () => { + const savedLayoutJSON = localStorage.getItem('at-command-layout') + if (!savedLayoutJSON) return + + try { + const parsedLayout = JSON.parse(savedLayoutJSON) + + if (!Array.isArray(parsedLayout)) { + throw new Error('Invalid format in localStorage: expected an array of widgets.') + } + + const newLayout = parsedLayout + .map((item: any) => { + if (item.widget && componentMap[item.widget]) { + item.widget = markRaw(componentMap[item.widget]) + } else { + console.warn(`Unknown widget type "${item.widget}" from localStorage. Skipping item.`) + return null + } + + if (item.widgetProps) { + item.widgetProps.forEach((prop: any) => { + if (prop.componentType && componentMap[prop.componentType]) { + prop.componentType = markRaw(componentMap[prop.componentType]) + } else if (prop.componentType) { + console.warn( + `Unknown componentType "${prop.componentType}" for widget "${item.name}" from localStorage. It will be ignored.` + ) + prop.componentType = null + } + }) + item.widgetProps = item.widgetProps.filter((prop: any) => prop.componentType) + } + return item + }) + .filter(Boolean) // remove null items + + layout.value = newLayout + globalNotify('Settings restored from last session.', 'success') + } catch (error: any) { + console.error('Failed to load layout from localStorage:', error) + globalNotify(`Failed to load settings from localStorage: ${error.message}`, 'error') + localStorage.removeItem('at-command-layout') + } + } + + watch( + layout, + throttle(() => saveLayoutToLocalStorage(), 1000), + { deep: true } + ) + + loadLayoutFromLocalStorage() + + return { + layout, + editGrid, + editCell, + showOptions, + isUartViewAdded, + addLoopWidget, + addUartViewWidget, + deleteWidget, + resetToDefault, + exportSettings, + importSettings, + getNextId + } +}) diff --git a/src/types/grid.ts b/src/types/grid.ts new file mode 100644 index 0000000..ea33981 --- /dev/null +++ b/src/types/grid.ts @@ -0,0 +1,26 @@ +import type { Component } from 'vue' + +export interface UartCommandData { + command: string + label: string + response: string +} + +export interface DraggableComponent> { + id: number + componentType: Component | string + props: T +} + +export interface WidgetItem { + x: number + y: number + w: number + h: number + i: number + name: string + static: boolean + widget: Component | string + widgetIconName?: string + widgetProps: DraggableComponent[] +} \ No newline at end of file diff --git a/src/views/AtCommand.vue b/src/views/AtCommand.vue deleted file mode 100644 index 3572375..0000000 --- a/src/views/AtCommand.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/views/Uart.vue b/src/views/Uart.vue index 5481547..27fa9ce 100644 --- a/src/views/Uart.vue +++ b/src/views/Uart.vue @@ -12,7 +12,7 @@
- +
@@ -82,17 +82,18 @@ import { /* TODO: use https://antoniandre.github.io/splitpanes/ */ +import { type ApiBinaryMsg } from '@/api/binDataDef' +import * as df from '@/api/apiDataFlow' +import textDataViewer from '@/views/text-data-viewer/textDataViewer.vue' +import textDataConfig from '@/views/text-data-viewer/textDataConfig.vue' +import { registerModule } from '@/router/msgRouter' +import { isDevMode } from '@/composables/buildMode' +import { useWsStore } from '@/stores/websocket' +import { useUartStore } from '@/stores/useUartStore' +import TextDataMacro from '@/views/text-data-viewer/textDataMacro.vue' +import { translate } from '@/locales' +import { useUartModule } from '@/composables/useUartModule' -import {type ApiBinaryMsg} from '@/api/binDataDef'; -import * as df from '@/api/apiDataFlow'; -import textDataViewer from "@/views/text-data-viewer/textDataViewer.vue"; -import textDataConfig from "@/views/text-data-viewer/textDataConfig.vue" -import {registerModule} from "@/router/msgRouter"; -import {isDevMode} from "@/composables/buildMode"; -import {useWsStore} from "@/stores/websocket"; -import {useUartStore} from "@/stores/useUartStore"; -import TextDataMacro from "@/views/text-data-viewer/textDataMacro.vue"; -import {translate} from "@/locales"; const store = useDataViewerStore() const wsStore = useWsStore() @@ -422,11 +423,7 @@ function handleWinSizeRefresh() { } onMounted(() => { - registerModule(api.WtModuleID.UART, { - ctrlCallback: onClientCtrl, - serverJsonMsgCallback: onUartJsonMsg, - serverBinMsgCallback: onUartBinaryMsg, - }); + useUartModule() firstWinResizeRef.value.style.borderWidth = store.winLeft.borderSize + "px"; thirdWinResizeRef.value.style.borderWidth = store.winRight.borderSize + "px"; diff --git a/src/views/WidgetPannel.vue b/src/views/WidgetPannel.vue new file mode 100644 index 0000000..30c5682 --- /dev/null +++ b/src/views/WidgetPannel.vue @@ -0,0 +1,345 @@ + + + + + + diff --git a/src/views/navigation/NavBar.vue b/src/views/navigation/NavBar.vue index 58f6ca4..e0da2b1 100644 --- a/src/views/navigation/NavBar.vue +++ b/src/views/navigation/NavBar.vue @@ -3,22 +3,17 @@
-