Compare commits

...
Author SHA1 Message Date
93094049+kraemr@users.noreply.github.com b92cae0ac1 lasergraph fix 2025-06-23 14:22:55 +02:00
93094049+kraemr@users.noreply.github.com 40e4a95268 fixed errors after merge and added graphs to pages 2025-06-23 13:15:02 +02:00
93094049+kraemr@users.noreply.github.com da5bfc5ed0 Merge branch '377-inverter-state-machine-and-el6021-incorrectly-handle-requestresponses' into extruder_feature_testing 2025-06-23 13:02:17 +02:00
93094049+kraemr@users.noreply.github.com 745a3c95f1 refactor 2025-06-23 13:02:14 +02:00
93094049+kraemr@users.noreply.github.com e34ed6929a Merge branch 'master' into 175-fine-tune-pressure-pid-controller 2025-06-23 12:55:40 +02:00
93094049+kraemr@users.noreply.github.com a121f99be4 Revert "Revert "frontend changes for api""
This reverts commit fbcae62e25.
2025-06-23 12:47:52 +02:00
93094049+kraemr@users.noreply.github.com 7b15161ada Revert "Revert "added comments to el6021 init""
This reverts commit c455b8b2b0.
2025-06-23 12:46:05 +02:00
93094049+kraemr@users.noreply.github.com c455b8b2b0 Revert "added comments to el6021 init"
This reverts commit a15d708138.
2025-06-23 12:45:23 +02:00
93094049+kraemr@users.noreply.github.com dbfb4410a1 Revert "Revert "bakcend api""
This reverts commit 0b3479bc16.
2025-06-23 12:44:58 +02:00
93094049+kraemr@users.noreply.github.com 004f5d5d5c Merge branch 'master' of github.com:qitechgmbh/control 2025-06-23 12:29:25 +02:00
Darmenov Alisher fa85501578 Merge pull request #343 from qitechgmbh/fix/318
Add Graphs to Extruder
2025-06-23 12:28:14 +02:00
Anatomik5 500390e6dd extruder graph code enchancing 2025-06-23 12:22:48 +02:00
Anatomik5 bf73f996ca fix 2025-06-23 12:22:48 +02:00
Anatomik5 cb7fb902be extruder graph 2025-06-23 12:22:48 +02:00
Anatomik5 8fabc46ad4 fix padding 2025-06-23 12:22:48 +02:00
Anatomik5 38a7da7ee2 extruder graph var 1 2025-06-23 12:22:48 +02:00
Anatomik5 bbae14a727 Group Excel Button 2025-06-23 12:22:48 +02:00
93094049+kraemr@users.noreply.github.com f4609bfb3c Merge branch 'master' into 377-inverter-state-machine-and-el6021-incorrectly-handle-requestresponses 2025-06-23 12:04:27 +02:00
93094049+kraemr@users.noreply.github.com afa4663176 cleanup of EL6021 and inverter 2025-06-23 12:02:21 +02:00
93094049+kraemr@users.noreply.github.com 9b806ee8b3 Merge branch 'inverter-fix' into 377-inverter-state-machine-and-el6021-incorrectly-handle-requestresponses 2025-06-23 11:54:57 +02:00
93094049+kraemr@users.noreply.github.com 4975d2f6a7 fixed el6021 and inverter state machine 2025-06-23 11:54:30 +02:00
93094049+kraemr@users.noreply.github.com 1a675db872 Merge branch 'master' of github.com:qitechgmbh/control 2025-06-23 11:47:00 +02:00
Robin Krämer 0230ff4f81 Merge pull request #373 from qitechgmbh/360-show-inverter-errors-and-recover
360 show inverter errors and recover
2025-06-23 11:46:50 +02:00
Robin Krämer d8039156ae Update mod.rs 2025-06-23 11:45:09 +02:00
Darmenov Alisher e7105334c4 Merge pull request #358 from qitechgmbh/fix/319
Fix/319 Code Nicer
2025-06-20 15:44:22 +02:00
Darmenov Alisher ad04ea0a62 Merge branch 'master' into fix/319 2025-06-20 15:42:48 +02:00
Anatomik5 0128268719 nicer code + props sync 2025-06-20 15:28:14 +02:00
Anatomik5 7873975980 Group Excel Button 2025-06-20 15:17:29 +02:00
Icedsoda 57883fcb7e Add retry logic to syncToStore 2025-06-20 15:16:17 +02:00
Icedsoda 5979d9497e Ship Chrome DevTools in Production 2025-06-20 15:16:17 +02:00
Icedsoda 5dade5a633 Retry EtherCAT TX/RX if it fails 2025-06-20 15:16:17 +02:00
Icedsoda 694b46afbe Reduce logging of ethercrab 2025-06-20 15:16:17 +02:00
Icedsoda 5f81b2df5b increase namespace disconnect time 2025-06-20 15:16:17 +02:00
Icedsoda ff47ed3261 fix correct cacheing durations 2025-06-20 15:16:17 +02:00
Icedsoda da84e2a562 fix heating power value flickering 2025-06-20 15:16:17 +02:00
Icedsoda a272fd57bb Use 16.67 instead of 16ms for 60fps event updates
Emit events at 60FPS
2025-06-20 15:16:17 +02:00
Icedsoda e2fb623cbb Throttle Event Stores' setState to 60fps 2025-06-20 15:16:17 +02:00
Icedsoda d797584637 Use msgpack with SocketIO 2025-06-20 15:16:17 +02:00
Icedsoda 4549d806c9 Don't use parse to improve performance 2025-06-20 15:16:17 +02:00
Icedsoda a22d1d0ff1 Don't use produce to improve performance 2025-06-20 15:16:17 +02:00
Icedsoda adde259aa0 Reduce namespace logging 2025-06-20 15:16:17 +02:00
Icedsoda 2768609286 Fix some thread assigments 2025-06-20 15:16:17 +02:00
Icedsoda 1973d8841d Add global SocketIO Queue
add init_socketio_queue
2025-06-20 15:16:17 +02:00
Icedsoda b337031272 Pass GenericEvents using Arc
make socket queue faster
2025-06-20 15:16:17 +02:00
Icedsoda 507b7d507a Fix 0 division bug in Namespace 2025-06-20 15:16:17 +02:00
Icedsoda 2f231b203c Remove trait NamespaceInterface 2025-06-20 15:16:17 +02:00
Icedsoda 6e7a528d6f Workaround for socket performance issues (hopefully solved by #269) 2025-06-20 15:16:17 +02:00
Icedsoda 43dd37e8e0 Workaround for socket performance issues (hopefully solved by #269) 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 11e965ddeb Revert "bakcend api"
This reverts commit db039c7ccc.
2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com eb08493fdc Revert "frontend changes for api"
This reverts commit 7f7984ebe4.
2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 27b0386eac added comments to el6021 init 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com c4314a45dd refactored, clamped nozzle temp 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 02a436f053 el6021 properly handles receive_request 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com fea487c74e rpm now gets read perfectly again 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 7e317c4b23 temporary fix for rpm monitor 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 2c02d98890 bakcend api 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com bdbad1dca2 frontend changes for api 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 202ae24eea fixed pressure PID 2025-06-20 15:16:17 +02:00
Icedsoda 38db16468e Add Extruder Icon 2025-06-20 15:16:17 +02:00
Icedsoda 1f272f4bce Selection buttons can have a confirmation prompt 2025-06-20 15:16:17 +02:00
dependabot[bot] ed2b94ce05 Bump globals from 15.15.0 to 16.2.0 in /electron
Bumps [globals](https://github.com/sindresorhus/globals) from 15.15.0 to 16.2.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v15.15.0...v16.2.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 16.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-20 15:16:17 +02:00
dependabot[bot] 146365fae2 Bump crc from 3.2.1 to 3.3.0
Bumps [crc](https://github.com/mrhooray/crc-rs) from 3.2.1 to 3.3.0.
- [Release notes](https://github.com/mrhooray/crc-rs/releases)
- [Commits](https://github.com/mrhooray/crc-rs/commits)

---
updated-dependencies:
- dependency-name: crc
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-20 15:16:17 +02:00
dependabot[bot] 3bf9956c41 Bump @vitejs/plugin-react from 4.3.4 to 4.5.1 in /electron
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.3.4 to 4.5.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@4.5.1/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-20 15:16:17 +02:00
Icedsoda 7d8f833063 Add EL6021_IDENTITY_D 2025-06-20 15:16:17 +02:00
Icedsoda fcdac437eb Change Laser Icon 2025-06-20 15:16:17 +02:00
Icedsoda cb74dbc22b Reanem Dre to Laser 2025-06-20 15:16:17 +02:00
Icedsoda a749f98e81 Fix NixOS settings with wrong units 2025-06-20 15:15:11 +02:00
Icedsoda 634c00cdd1 Switches to tee for safer file redirection in script
Replaces `cat` with `sudo tee` to avoid potential permission issues and
ensure proper handling of file redirection when generating `installInfo.nix`.
Redirects output to `/dev/null` to suppress unnecessary output.
2025-06-20 15:15:11 +02:00
dependabot[bot] 5524be3ce0 Bump react-hook-form from 7.54.2 to 7.57.0 in /electron
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.54.2 to 7.57.0.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.54.2...v7.57.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-20 15:15:11 +02:00
Icedsoda 77bdb2f92c Bump OpenTelemetry Dependencies 2025-06-20 15:15:11 +02:00
nixos-install.sh 163fae21eb Remove KV pairs in logs 2025-06-20 15:15:11 +02:00
nixos-install.sh f58226aa27 Add Markdown TOC 2025-06-20 15:15:11 +02:00
nixos-install.sh 2742a296ef Revert "Add installInfo.nix file with current commit information"
This reverts commit d0a98557d3.
2025-06-20 15:15:11 +02:00
Icedsoda bb204d6efb delete summary.txt 2025-06-20 14:56:39 +02:00
Anatomik5 3a7c99cbbd fixed lint bugs 2025-06-18 17:51:47 +02:00
Anatomik5 7796d20984 nice code, modular 2025-06-18 16:40:00 +02:00
Anatomik5 9b3223c014 fixed problem with time and problem with mode switching 2025-06-18 16:40:00 +02:00
Anatomik5 a5c5ba4b87 nicer code + props sync 2025-06-18 16:40:00 +02:00
93094049+kraemr@users.noreply.github.com b53fea1812 frontendChanges for readStatus and resetInverter 2025-06-17 18:59:44 +02:00
93094049+kraemr@users.noreply.github.com b71a3456ce backend changes for readStatus and ResetInverter 2025-06-17 18:59:27 +02:00
93094049+kraemr@users.noreply.github.com 81cb44ed2b handle_response inverterstatus 2025-06-17 17:48:35 +02:00
Icedsoda 8c1fe6df1f Merge pull request #370 from qitechgmbh/add-retry-logic-to-sync-to-store
Add retry logic to `syncToStore`
2025-06-17 17:05:34 +02:00
Icedsoda 91fadd1c3d Merge pull request #372 from qitechgmbh/371-ship-chrome-devtools-in-production
Ship Chrome DevTools in Production
2025-06-17 16:51:02 +02:00
Icedsoda 28660a0873 Ship Chrome DevTools in Production 2025-06-17 16:49:57 +02:00
Icedsoda 5171ef5ef4 Add retry logic to syncToStore 2025-06-17 16:42:54 +02:00
Icedsoda 0cfd7eec9f Merge pull request #369 from qitechgmbh/367-retry-ethercat-txrx-if-it-fails
Retry EtherCAT TX/RX if it fails
2025-06-17 16:41:24 +02:00
Icedsoda 2ed2361fdf Retry EtherCAT TX/RX if it fails 2025-06-17 16:39:30 +02:00
Icedsoda 7c8b05241a Merge pull request #366 from qitechgmbh/365-reduce-logging-of-ethercrab
Reduce logging of ethercrab
2025-06-17 15:42:22 +02:00
Icedsoda 80e6edc474 Reduce logging of ethercrab 2025-06-17 15:29:06 +02:00
93094049+kraemr@users.noreply.github.com 9f13765db6 Merge branch 'master' of github.com:qitechgmbh/control 2025-06-17 14:13:56 +02:00
93094049+kraemr@users.noreply.github.com 465f8e9f07 added revision 0x10000 el6021 to frontend 2025-06-17 14:13:46 +02:00
Icedsoda ebbd4a5d20 Merge pull request #269 from qitechgmbh/improve-socket-perf
Improve SocketIO Performance
2025-06-16 18:19:42 +02:00
Icedsoda 727a87eb99 increase namespace disconnect time 2025-06-16 18:18:01 +02:00
Icedsoda dd754bdb60 Merge pull request #359 from qitechgmbh/175-fine-tune-pressure-pid-controller
175 fine tune pressure pid controller
2025-06-16 18:13:59 +02:00
Icedsoda 830a58874f fix correct cacheing durations 2025-06-16 18:04:35 +02:00
Icedsoda 2459d8236e fix heating power value flickering 2025-06-16 17:59:21 +02:00
Icedsoda 35c38dc28c Use 16.67 instead of 16ms for 60fps event updates
Emit events at 60FPS
2025-06-16 17:59:21 +02:00
Icedsoda 45d1bed990 Throttle Event Stores' setState to 60fps 2025-06-16 17:59:21 +02:00
Icedsoda 6c4f8ac37c Use msgpack with SocketIO 2025-06-16 17:59:21 +02:00
Icedsoda 22d131e9c4 Don't use parse to improve performance 2025-06-16 17:59:21 +02:00
Icedsoda 232e74c659 Don't use produce to improve performance 2025-06-16 17:59:21 +02:00
Icedsoda 85f90676e9 Reduce namespace logging 2025-06-16 17:59:21 +02:00
Icedsoda 6a46997f24 Fix some thread assigments 2025-06-16 17:59:21 +02:00
Icedsoda 69b34adb7f Add global SocketIO Queue
add init_socketio_queue
2025-06-16 17:59:21 +02:00
Icedsoda f7546058bf Pass GenericEvents using Arc
make socket queue faster
2025-06-16 17:59:21 +02:00
Icedsoda daf45d5990 Fix 0 division bug in Namespace 2025-06-16 17:59:21 +02:00
Icedsoda fa4db9c66d Remove trait NamespaceInterface 2025-06-16 17:59:21 +02:00
Icedsoda 9ce47bdfd1 Workaround for socket performance issues (hopefully solved by #269) 2025-06-16 17:59:21 +02:00
Icedsoda b0c43e9260 Workaround for socket performance issues (hopefully solved by #269) 2025-06-16 17:59:21 +02:00
Icedsoda 6f567a24b6 Merge pull request #362 from qitechgmbh/361-add-extruder-icon
Add Extruder Icon
2025-06-16 17:58:56 +02:00
Icedsoda 2680a59080 Add Extruder Icon 2025-06-16 17:51:32 +02:00
93094049+kraemr@users.noreply.github.com 0b3479bc16 Revert "bakcend api"
This reverts commit db039c7ccc.
2025-06-16 17:15:14 +02:00
Anatomik5 87112035fb nicer code + props sync 2025-06-16 17:13:05 +02:00
93094049+kraemr@users.noreply.github.com fbcae62e25 Revert "frontend changes for api"
This reverts commit 7f7984ebe4.
2025-06-16 17:00:04 +02:00
93094049+kraemr@users.noreply.github.com a15d708138 added comments to el6021 init 2025-06-16 16:58:05 +02:00
93094049+kraemr@users.noreply.github.com d4794055e1 refactored, clamped nozzle temp 2025-06-16 16:44:00 +02:00
Icedsoda eb273f928f Merge pull request #354 from qitechgmbh/282-add-a-warning-element-for-frontend
Selection buttons can have a confirmation prompt
2025-06-16 16:39:43 +02:00
Icedsoda f497a343fb Selection buttons can have a confirmation prompt 2025-06-16 16:36:31 +02:00
Anatomik5 ecb27ea068 sync enchance 2025-06-16 16:32:36 +02:00
93094049+kraemr@users.noreply.github.com 45c56e7d6e el6021 properly handles receive_request 2025-06-16 16:00:05 +02:00
Anatomik5 8c7a71a3f6 enchance 2025-06-16 15:41:38 +02:00
93094049+kraemr@users.noreply.github.com 40b167757a rpm now gets read perfectly again 2025-06-16 15:29:11 +02:00
93094049+kraemr@users.noreply.github.com 83c405abb6 temporary fix for rpm monitor 2025-06-16 13:16:58 +02:00
Anatomik5 4ea2d3bee2 button format changed 2025-06-16 01:17:06 +02:00
Icedsoda 4ac533548d Merge pull request #215 from qitechgmbh/dependabot/npm_and_yarn/electron/globals-16.2.0
Bump globals from 15.15.0 to 16.2.0 in /electron
2025-06-15 13:57:35 +02:00
dependabot[bot] 7b8525f795 Bump globals from 15.15.0 to 16.2.0 in /electron
Bumps [globals](https://github.com/sindresorhus/globals) from 15.15.0 to 16.2.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v15.15.0...v16.2.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 16.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-15 11:55:43 +00:00
Icedsoda a6c65bab22 Merge pull request #156 from qitechgmbh/dependabot/cargo/crc-3.3.0
Bump crc from 3.2.1 to 3.3.0
2025-06-15 13:52:23 +02:00
dependabot[bot] eeb0733c63 Bump crc from 3.2.1 to 3.3.0
Bumps [crc](https://github.com/mrhooray/crc-rs) from 3.2.1 to 3.3.0.
- [Release notes](https://github.com/mrhooray/crc-rs/releases)
- [Commits](https://github.com/mrhooray/crc-rs/commits)

---
updated-dependencies:
- dependency-name: crc
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-15 11:50:24 +00:00
Icedsoda 62a10ace1c Merge pull request #303 from qitechgmbh/dependabot/npm_and_yarn/electron/vitejs/plugin-react-4.5.1
Bump @vitejs/plugin-react from 4.3.4 to 4.5.1 in /electron
2025-06-15 13:49:17 +02:00
dependabot[bot] fec87f1ba3 Bump @vitejs/plugin-react from 4.3.4 to 4.5.1 in /electron
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.3.4 to 4.5.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@4.5.1/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-15 11:46:51 +00:00
Icedsoda b1cfe581f6 Merge pull request #341 from qitechgmbh/340-add-el6021-revision-0x100000
Add `EL6021_IDENTITY_D`
2025-06-13 20:05:37 +02:00
Icedsoda 7993481d0f Add EL6021_IDENTITY_D 2025-06-13 20:05:09 +02:00
Icedsoda 7c74cedf32 Merge pull request #335 from qitechgmbh/332-rename-dre-to-laser
Rename `Dre` to `Laser`
2025-06-13 20:04:35 +02:00
Icedsoda 3ec3d0bbc7 Change Laser Icon 2025-06-13 20:03:57 +02:00
Icedsoda 1fca8821b5 Reanem Dre to Laser 2025-06-13 19:45:12 +02:00
Icedsoda 59b0cde17f Merge pull request #339 from qitechgmbh/336-never-dim-the-hmi-panel
Fix NixOS settings with wrong units
2025-06-13 19:44:29 +02:00
Icedsoda b2e1019222 Fix NixOS settings with wrong units 2025-06-13 19:32:20 +02:00
Icedsoda 66be2a143a Merge pull request #338 from qitechgmbh/337-installinfonix-is-empty
Switches to `tee` for safer file redirection in script
2025-06-13 19:23:04 +02:00
Icedsoda 1db27548fb Merge pull request #264 from qitechgmbh/dependabot/npm_and_yarn/electron/react-hook-form-7.57.0
Bump react-hook-form from 7.54.2 to 7.57.0 in /electron
2025-06-13 19:22:13 +02:00
Icedsoda cba84b93f0 Switches to tee for safer file redirection in script
Replaces `cat` with `sudo tee` to avoid potential permission issues and
ensure proper handling of file redirection when generating `installInfo.nix`.
Redirects output to `/dev/null` to suppress unnecessary output.
2025-06-13 19:10:08 +02:00
dependabot[bot] 8dbd6a3a91 Bump react-hook-form from 7.54.2 to 7.57.0 in /electron
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.54.2 to 7.57.0.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.54.2...v7.57.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-13 16:13:57 +00:00
Icedsoda 0bab7e53a7 Merge pull request #334 from qitechgmbh/bump-otel-deps
Bump OpenTelemetry Dependencies
2025-06-13 17:43:59 +02:00
Icedsoda fa0611b1bb Bump OpenTelemetry Dependencies 2025-06-13 17:41:44 +02:00
Icedsoda 156e69eb0d Merge pull request #333 from qitechgmbh/294-include-kv-traces-in-journald-log
Remove KV pairs in logs
2025-06-13 17:31:06 +02:00
nixos-install.sh 626115b284 Remove KV pairs in logs 2025-06-13 17:28:14 +02:00
Icedsoda 54c264d03f Merge pull request #331 from qitechgmbh/329-add-markdown-table-of-contents-component
Add Markdown TOC
2025-06-13 16:23:02 +02:00
nixos-install.sh f1399332bb Add Markdown TOC 2025-06-13 16:20:40 +02:00
93094049+kraemr@users.noreply.github.com 943f1081a8 Merge branch 'master' into 175-fine-tune-pressure-pid-controller 2025-06-13 15:09:52 +02:00
93094049+kraemr@users.noreply.github.com db039c7ccc bakcend api 2025-06-13 15:08:08 +02:00
93094049+kraemr@users.noreply.github.com 7f7984ebe4 frontend changes for api 2025-06-13 15:07:46 +02:00
nixos-install.sh 3752a69c78 Revert "Add installInfo.nix file with current commit information"
This reverts commit d0a98557d3.
2025-06-13 12:53:50 +02:00
Anatomik5 42867bf41c Group Excel Button 2025-06-12 17:34:59 +02:00
93094049+kraemr@users.noreply.github.com b3f06b4973 fixed pressure PID 2025-06-12 15:42:21 +02:00
Icedsoda 710c6cb2dd Merge pull request #322 from qitechgmbh/321-git-abbrevation-from-tag
Improve `install.sh` to properly capture tag names into `gitAbbrevation` and not `HEAD`
2025-06-10 21:04:22 +02:00
nixos-install.sh d0a98557d3 Add installInfo.nix file with current commit information 2025-06-10 21:00:14 +02:00
Icedsoda 416475aaea Improve install.sh 2025-06-10 20:54:56 +02:00
Icedsoda b2c7f6e0b1 Release Version 2.0.0
- Omit Winder V2 Manual
2025-06-10 20:28:41 +02:00
7e26bb787d Fix/33 (#317)
* Create `LoopThrottle`

* Use `Loopthrottle` for panic channel loop

* Use `LoopThrottle` to 1ms cycles.

* fixed timeseries in Extruder2

* Make sure that traverse inner and outer limit won't overlap

* Use `jemalloc`

* Add `useThrottle`

* fix Extuder control page

* Big Graph + Excel + length check during modbus responce added

* fix merge error

* Fix time window error

* Extract `GraphConfig`

* enchance graph

* enchance

---------

Co-authored-by: 93094049+kraemr@users.noreply.github.com <93094049+kraemr@users.noreply.github.com>
Co-authored-by: Anatomik5 <darmenovalisher@gmail.com>
Co-authored-by: Darmenov Alisher <115714693+Anatomik5@users.noreply.github.com>
2025-06-10 19:56:20 +02:00
Icedsoda e7d3581e94 Show NixOS generations on "Update" page (#310)
* Show NixOS generations on "Update" page

* use nixos-rebuild commands

* skip first line

* fix set issue

* set & reboot

* improve generation cards

* truncate generation name

* fix date line

* make text syle the same

* fix delete nixos generation

* change text
2025-06-10 17:10:44 +02:00
Icedsoda 7ca76accc4 Merge pull request #302 from qitechgmbh/dependabot/npm_and_yarn/electron/electron-36.4.0
Bump electron from 34.0.2 to 36.4.0 in /electron
2025-06-10 16:37:42 +02:00
dependabot[bot] 2f41582e9d Bump electron from 34.0.2 to 36.4.0 in /electron
Bumps [electron](https://github.com/electron/electron) from 34.0.2 to 36.4.0.
- [Release notes](https://github.com/electron/electron/releases)
- [Changelog](https://github.com/electron/electron/blob/main/docs/breaking-changes.md)
- [Commits](https://github.com/electron/electron/compare/v34.0.2...v36.4.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 36.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-10 14:36:04 +00:00
Icedsoda 7b09d1b42f Merge pull request #313 from qitechgmbh/fix/274
fix dre panic
2025-06-10 16:33:01 +02:00
Anatomik5 17056d61e9 fix dre panic 2025-06-10 16:06:22 +02:00
Icedsoda 49b25a017a Merge pull request #267 from qitechgmbh/dependabot/cargo/clap-4.5.39
Bump clap from 4.5.37 to 4.5.39
2025-06-10 12:18:47 +02:00
dependabot[bot] 2b9dc727ba Bump clap from 4.5.37 to 4.5.39
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.37 to 4.5.39.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.37...clap_complete-v4.5.39)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.39
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-10 10:18:25 +00:00
Icedsoda 229ac7cace Merge pull request #300 from qitechgmbh/dependabot/cargo/tower-http-0.6.6
Bump tower-http from 0.6.5 to 0.6.6
2025-06-10 12:17:06 +02:00
dependabot[bot] 73ec7ef2ab Bump tower-http from 0.6.5 to 0.6.6
Bumps [tower-http](https://github.com/tower-rs/tower-http) from 0.6.5 to 0.6.6.
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.5...tower-http-0.6.6)

---
updated-dependencies:
- dependency-name: tower-http
  dependency-version: 0.6.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-10 10:16:21 +00:00
Icedsoda 0558b9a125 Merge pull request #309 from qitechgmbh/fix/66-v2
Add troubleshoot page
2025-06-10 12:15:12 +02:00
Icedsoda bd373024f1 Add button to export <Terminal> log lines 2025-06-10 12:12:48 +02:00
Robin KrämerandIcedsoda 22eb39522b 193 use a hashmap instead of vecdequeue for modbus requests (#298)
* mitsubishiinverter now uses hashmap

* new bug no responses ...

* cleaned code up and fixed errors

* cleaned up unused imports etc

* remove `println`

---------

Co-authored-by: Icedsoda <29167146+1cedsoda@users.noreply.github.com>
2025-06-10 11:57:46 +02:00
Icedsoda 93c42078a9 Merge pull request #297 from qitechgmbh/290-add-a-wattage-graph-for-heatingzone
290 add a wattage graph for heatingzone
2025-06-10 11:48:32 +02:00
Icedsoda 29bf15a266 Add troubleshoot page 2025-06-10 11:36:02 +02:00
Icedsoda a3541e7906 log panics of main thread 2025-06-09 17:48:20 +02:00
Icedsoda fcfeb448d6 add test_calculate_traverse_speed 2025-06-09 14:41:45 +02:00
Icedsoda 4d8c15df31 Merge pull request #306 from qitechgmbh/fix/299
Wrap tension arm angle from 270:360 to -90:0
2025-06-09 13:36:56 +02:00
Icedsoda 9ff63b7389 Wrap tension arm angle from 270:360 to -90:0 2025-06-09 13:36:25 +02:00
93094049+kraemr@users.noreply.github.com 1df8504c4e fixed warnings 2025-06-06 17:53:25 +02:00
93094049+kraemr@users.noreply.github.com 886a5a2a2a removed heating bool from frontend and api 2025-06-06 17:51:19 +02:00
93094049+kraemr@users.noreply.github.com 34142b8063 addewd backend logic for wattage graphs 2025-06-06 15:46:32 +02:00
93094049+kraemr@users.noreply.github.com c8eb44b697 added frontend logic for heating wattage timeseries 2025-06-06 15:46:16 +02:00
Icedsoda f0dd0c5a74 Merge pull request #293 from qitechgmbh/fix/292
Increase Mode Buttons Size
2025-06-06 14:34:30 +02:00
Icedsoda a108384305 Increase Mode Buttons Size 2025-06-06 14:34:06 +02:00
Icedsoda 37a82b1bb8 Puller target speed 1 decimal point 2025-06-06 14:28:20 +02:00
93094049+kraemr@users.noreply.github.com df03e4c2a2 added wattage unit and removed flame from HeatingZone 2025-06-06 14:24:50 +02:00
Icedsoda 4b0321975b Merge pull request #291 from qitechgmbh/fix/289
fix brach version cards are always disabled
2025-06-06 14:06:39 +02:00
Icedsoda 43d495db38 fix brach version cards are always disabled 2025-06-06 14:06:12 +02:00
Icedsoda 46aff0e080 Merge pull request #288 from qitechgmbh/improve-interface-discovery-logging
Improve Interface discovery logging
2025-06-06 13:41:36 +02:00
Icedsoda a65ec0b920 Improve Interface discovery logging 2025-06-06 13:41:18 +02:00
Icedsoda a3ef0f5bbe Merge pull request #230 from qitechgmbh/fix/224
Swap Machine and Serial Word Addresses
2025-06-06 13:38:06 +02:00
Icedsoda d91d413aaf swap machine and serial word addresses 2025-06-06 13:36:36 +02:00
Icedsoda 4e7cb845d6 Merge pull request #287 from qitechgmbh/fix/260-traverse-turnaround
In traversing mode turn around based on theshhold not point
2025-06-05 16:57:09 +02:00
Icedsoda 677276a2b7 In traversing mode turn around based on theshhold not point 2025-06-05 16:55:35 +02:00
Icedsoda a846928ff5 Merge pull request #286 from qitechgmbh/fix/249-redo-homing
Redo traverse homing routine if it failed
2025-06-05 16:38:28 +02:00
Icedsoda f760d3567a Redo traverse homing routine if it failed 2025-06-05 16:35:14 +02:00
Icedsoda e2ebaf69a0 remove log 2025-06-05 16:19:07 +02:00
Icedsoda 0b160581b9 Merge pull request #280 from qitechgmbh/251-add-a-configurable-limit-for-pressure
251 add a configurable limit for pressure
2025-06-05 16:13:09 +02:00
Icedsoda 1a7fe8b93d Merge pull request #281 from qitechgmbh/217-add-hard-limit-of-300c-for-heating
added a hardcoded limit for extruder temperature_controllers
2025-06-05 16:12:28 +02:00
Icedsoda b745524257 Merge pull request #285 from qitechgmbh/fix/276-traverse-too-fast
Fix Traverse Speed Formula
2025-06-05 16:11:44 +02:00
Icedsoda 14cd458d60 Fix traverse speed formula 2025-06-05 16:09:25 +02:00
Icedsoda 224a3cb66f Merge pull request #284 from qitechgmbh/fix/275-part-2
Improve Spool Acceleration Profile v2
2025-06-05 16:09:04 +02:00
Icedsoda 25be1678dc Remove most of Spool API 2025-06-05 16:00:20 +02:00
Icedsoda 7a05ce93b5 Improve spool speed syncing used puller speed 2025-06-05 16:00:20 +02:00
Icedsoda d556f0225b Merge pull request #283 from qitechgmbh/fix/275-spool-accel
Improve Spool Acceleration Profile
2025-06-04 22:07:54 +02:00
Icedsoda 4845036256 Use FilamentTensionCalculator in SpoolSpeedController 2025-06-04 21:51:38 +02:00
Icedsoda 0cf761f529 Add FilamentTensionCalculator 2025-06-04 21:04:07 +02:00
Icedsoda 3b414192e9 Add TimeagnosticPidController 2025-06-04 21:04:07 +02:00
93094049+kraemr@users.noreply.github.com 736f75f413 added a hardcoded limit for extruder temperature_controllers 2025-06-03 17:25:25 +02:00
93094049+kraemr@users.noreply.github.com db5046f7e1 fixed minor issues in backend and frontend 2025-06-03 16:56:41 +02:00
93094049+kraemr@users.noreply.github.com eb6758bf3b Merge branch 'master' into 251-add-a-configurable-limit-for-pressure 2025-06-03 15:52:14 +02:00
Robin Krämer 7a77d3ba73 Merge pull request #244 from qitechgmbh/241-pressure-readout-not-showing-up-on-frontend
pressure readout is now accurate and shows up in frontend, also added min_raw and max_raw to AnalogInputRange to define what raw value corresponds to what physical unit.
2025-06-03 15:48:24 +02:00
93094049+kraemr@users.noreply.github.com eba54a4acb fixed comments 2025-06-03 15:45:33 +02:00
93094049+kraemr@users.noreply.github.com 1049dd22b3 added logic for motor shutoff at specified pressure 2025-06-03 15:30:52 +02:00
93094049+kraemr@users.noreply.github.com cee244da23 frontend is now interactable 2025-06-03 15:30:21 +02:00
93094049+kraemr@users.noreply.github.com fcf6000e8d added api logic front and backend 2025-06-03 15:29:47 +02:00
Icedsoda 74ce4ad340 Merge pull request #273 from qitechgmbh/fix/285
Fix initial acceleration on `SpoolSpeedController`
2025-06-02 20:07:32 +02:00
Icedsoda 73c5de46a5 Merge pull request #272 from qitechgmbh/fix/270-panic
Improve panic logging
2025-06-02 20:06:46 +02:00
Icedsoda 36bbd92ee1 Improve panic logging 2025-06-02 20:04:22 +02:00
Icedsoda 30cab528f9 Fix initial acceleration on SpoolSpeedController 2025-06-02 18:20:06 +02:00
Icedsoda 3dfe362983 Merge pull request #271 from qitechgmbh/fix/256
Fix terminal colors
2025-06-02 18:14:32 +02:00
Icedsoda a711542863 Fix ternimal colors 2025-06-02 18:09:01 +02:00
Icedsoda bc00962d8e Merge pull request #266 from qitechgmbh/dependabot/cargo/tower-http-0.6.5
Bump tower-http from 0.6.2 to 0.6.5
2025-06-02 09:47:45 +02:00
dependabot[bot] 0776506e30 Bump tower-http from 0.6.2 to 0.6.5
Bumps [tower-http](https://github.com/tower-rs/tower-http) from 0.6.2 to 0.6.5.
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.2...tower-http-0.6.5)

---
updated-dependencies:
- dependency-name: tower-http
  dependency-version: 0.6.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-02 07:45:36 +00:00
Icedsoda c8fdfc79ee Merge pull request #181 from qitechgmbh/dependabot/cargo/socketioxide-0.17.1
Bump socketioxide from 0.17.0 to 0.17.1
2025-06-02 09:44:21 +02:00
dependabot[bot] 51b797f9dd Bump socketioxide from 0.17.0 to 0.17.1
Bumps [socketioxide](https://github.com/totodore/socketioxide) from 0.17.0 to 0.17.1.
- [Release notes](https://github.com/totodore/socketioxide/releases)
- [Changelog](https://github.com/Totodore/socketioxide/blob/main/CHANGELOG.md)
- [Commits](https://github.com/totodore/socketioxide/commits)

---
updated-dependencies:
- dependency-name: socketioxide
  dependency-version: 0.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-02 07:40:27 +00:00
Icedsoda 6f50515144 Merge pull request #268 from qitechgmbh/log-memory
Log memory usage
2025-06-02 09:39:17 +02:00
Icedsoda 1911604fc3 Log memory usage 2025-06-02 09:37:01 +02:00
Icedsoda f507f27d13 Merge pull request #263 from qitechgmbh/improve-logging
Use `tracing` instead of `log` & add Opentelemetry
2025-06-01 21:43:52 +02:00
Icedsoda 68260bc379 Add Opentelemetry logging 2025-06-01 21:39:51 +02:00
Icedsoda b63adbb601 use tracing crate instead of log
remove module path from logs

setup tracing subscriber
2025-06-01 18:17:29 +02:00
Icedsoda 754533834b Merge pull request #262 from qitechgmbh/fix/261
Continuously change the value when pressing down a [-] [+] button
2025-06-01 13:21:25 +02:00
Icedsoda 0d8fb2789f Continuously change the value when pressing down a [-] [+] button 2025-06-01 13:19:50 +02:00
Icedsoda 680ecd9483 Improve TouchNumpad UI 2025-05-31 17:13:38 +02:00
Icedsoda 92414e42b1 Merge pull request #200 from qitechgmbh/fix/198
Improve `EditValue`
2025-05-31 15:58:25 +02:00
Icedsoda 74fbb8ee4b Improve EditValue UI 2025-05-31 15:56:08 +02:00
Icedsoda 0361e04e2e Add Numpad 2025-05-31 14:58:28 +02:00
Icedsoda 2587ebb63c fix Extuder control page 2025-05-30 18:26:45 +02:00
93094049+kraemr@users.noreply.github.com 8f3894f68e now tests should pass 2025-05-29 22:12:31 +02:00
93094049+kraemr@users.noreply.github.com cd9b034ef2 now tests should pass again 2025-05-29 21:53:07 +02:00
93094049+kraemr@users.noreply.github.com 5d79eaff5b tests should all compile now 2025-05-29 21:49:44 +02:00
Icedsoda e77827ef6a Merge pull request #254 from qitechgmbh/fix/253
Add `useThrottle`
2025-05-29 20:27:23 +02:00
Icedsoda d00a195599 Add useThrottle 2025-05-29 20:25:34 +02:00
Icedsoda 2b6c7be71d Merge pull request #248 from qitechgmbh/fix/247-jemalloc
Use `jemalloc`
2025-05-29 18:19:37 +02:00
93094049+kraemr@users.noreply.github.com 2c8f1e6989 refactored get_pressure 2025-05-29 18:12:57 +02:00
93094049+kraemr@users.noreply.github.com 17934bdf08 added min_raw max_raw to AnalogInputRange 2025-05-29 17:58:35 +02:00
Icedsoda 06f7759321 Use jemalloc 2025-05-29 17:36:23 +02:00
Icedsoda 25075c2cfd Merge pull request #237 from qitechgmbh/fix/234
Make sure that traverse inner and outer limit won't overlap
2025-05-29 16:13:03 +02:00
93094049+kraemr@users.noreply.github.com 0438a6e641 Merge branch 'master' into 241-pressure-readout-not-showing-up-on-frontend 2025-05-29 15:32:36 +02:00
Robin Krämer e92a0421f4 Merge pull request #246 from qitechgmbh/245-extruder-page-fails-to-parse-events-from-api-because-of-timeseries
fixed timeseries and by extension graphs in Extruder2ControlPage
2025-05-29 15:31:48 +02:00
Icedsoda 2a09a74703 Merge pull request #243 from qitechgmbh/fix/242
Throttle Loops agains high CPU usage
2025-05-29 15:29:02 +02:00
93094049+kraemr@users.noreply.github.com 7023fe76fc fixed timeseries in Extruder2 2025-05-29 15:28:23 +02:00
Icedsoda 0f0e3258ce Merge pull request #236 from qitechgmbh/fix/187
Use nixpkgs `25.05`
2025-05-29 15:28:22 +02:00
Icedsoda 374a93b0f2 Use LoopThrottle to 1ms cycles. 2025-05-29 14:02:24 +02:00
93094049+kraemr@users.noreply.github.com 0019db3394 pressure readout is now accurate and shows up in frontend 2025-05-29 14:02:13 +02:00
Icedsoda b58f300171 Use Loopthrottle for panic channel loop 2025-05-29 13:46:48 +02:00
Icedsoda 75d01146ac Create LoopThrottle 2025-05-29 13:46:16 +02:00
Icedsoda d231e30f20 Merge pull request #240 from qitechgmbh/fix/239
Improve `<TimeSeriesValue/>`
2025-05-28 23:11:50 +02:00
Icedsoda 484be08a60 Improve <TimeSeriesValue/> 2025-05-28 23:10:00 +02:00
Icedsoda 326653dc71 Merge pull request #208 from qitechgmbh/fix/32
Add Mini Graph
2025-05-28 20:32:04 +02:00
Icedsoda 82c005f2c3 Mini Graph implementation with history with uplot+ enchance of timeseries 2025-05-28 20:24:43 +02:00
Icedsoda c88a44aef7 Merge pull request #238 from qitechgmbh/fix/232
Keep track of disconnections
2025-05-28 20:05:27 +02:00
Icedsoda 400bfe6dfd Keep track of diconnections 2025-05-28 17:59:10 +02:00
Icedsoda 9d11d9914d Make sure that traverse inner and outer limit won't overlap 2025-05-28 17:08:05 +02:00
Icedsoda ed68f7a5eb Use nigpkgs 25.05 2025-05-28 16:46:03 +02:00
Icedsoda 0090d3decf fix electron lints 2025-05-28 16:21:30 +02:00
Icedsoda 019f7ddacd Merge pull request #228 from qitechgmbh/fix/97
Add Electron Pipeline
2025-05-28 16:15:09 +02:00
Icedsoda 36c662a201 Merge pull request #235 from qitechgmbh/fix/231
Add Mock Machine 0x7
2025-05-28 16:14:56 +02:00
Icedsoda e7617d2ac2 Add Mock Machine 0x7 2025-05-28 16:11:38 +02:00
Icedsoda 9b721c19ba fix lints 2025-05-28 13:14:12 +02:00
Icedsoda 755b6fd69a Specify Nodejs 22 2025-05-28 13:11:59 +02:00
Icedsoda 317fdce027 fix linted errors 2025-05-28 13:11:59 +02:00
Icedsoda 90d81ee7df Add Electron Pipeline 2025-05-28 13:11:59 +02:00
Icedsoda 2241c7f996 Merge pull request #229 from qitechgmbh/fix/227
Add Event Queue with Retry Logic
2025-05-27 21:25:45 +02:00
Icedsoda 170c46563b disconnect properly 2025-05-27 21:17:12 +02:00
Icedsoda 6d67ff9843 Reemit State events first 2025-05-27 21:17:02 +02:00
Icedsoda d5b2b1e76e Improve SocketQueue 2025-05-27 21:16:48 +02:00
Icedsoda a3cb746d15 Move SocketQueue to seperate module 2025-05-27 21:09:12 +02:00
Icedsoda 4cb6d684f8 Increase native Socket.IO event buffer 2025-05-27 21:08:54 +02:00
Icedsoda bcd131238a Improve pushing many events onto SocketQueue 2025-05-27 20:04:14 +02:00
Icedsoda 65d49b3d81 Add event queue with retry logic 2025-05-27 19:54:07 +02:00
Icedsoda f2550897a1 Merge pull request #226 from qitechgmbh/fix/225
Disable Laserpointer Buttons if `traverseState` is Not Available
2025-05-27 17:34:46 +02:00
Icedsoda e9de191593 Disable Laserpointer buttons if traverseState is not available 2025-05-27 17:33:16 +02:00
Icedsoda e3047b3141 Disable DigitalOutputSetter by default 2025-05-27 17:32:46 +02:00
Icedsoda 38b220e4c9 remove some warnings 2025-05-27 16:28:05 +02:00
Icedsoda a64a2b812c Merge pull request #223 from qitechgmbh/fix/222
Only Process IO of `EthercatDevice`s If They are Used by a Machine
2025-05-27 16:25:11 +02:00
Icedsoda c11a70a0b7 Only process io of ethercat devices if they are used by a machine 2025-05-27 16:22:18 +02:00
Icedsoda 215857b372 Merge pull request #221 from qitechgmbh/fix/218
Reconnect Socket.IO after server restart
2025-05-27 13:57:03 +02:00
Icedsoda 89e028d3c4 Add client reconnection logic 2025-05-27 13:52:54 +02:00
Robin Krämer b81b9ce6c0 Merge pull request #220 from qitechgmbh/191-gearbox-is-inverted
fix/191
2025-05-26 19:40:42 +02:00
93094049+kraemr@users.noreply.github.com 4fa0d389b6 when rotation is set to forward the inverter now rotates clockwise 2025-05-26 19:29:46 +02:00
Robin Krämer 1090ce7f97 Merge pull request #205 from qitechgmbh/extruder_v2
Extruder v2
2025-05-26 18:36:08 +02:00
93094049+kraemr@users.noreply.github.com c8cf92bc73 removed i from temp pid and fixed target rpm bug in frontend 2025-05-26 18:26:16 +02:00
93094049+kraemr@users.noreply.github.com 2acd0f73d9 fixed oversight in Controlpage 2025-05-26 17:32:00 +02:00
93094049+kraemr@users.noreply.github.com 36462865f6 fixed naming now 2025-05-26 17:30:38 +02:00
93094049+kraemr@users.noreply.github.com ffeb2b6494 added transmission_converter 2025-05-26 17:15:37 +02:00
Icedsoda 339e461d2f Merge pull request #216 from qitechgmbh/fix/137
Exit program if loop fails
2025-05-26 17:11:57 +02:00
Icedsoda 4748b4c332 Exit program if loop fails 2025-05-26 17:09:58 +02:00
93094049+kraemr@users.noreply.github.com 125efe0e63 hopefully fixed naming for useExtruder2 2025-05-26 16:54:52 +02:00
93094049+kraemr@users.noreply.github.com 7aa3222efd fixed compile error 2025-05-26 16:38:56 +02:00
93094049+kraemr@users.noreply.github.com ee305b4254 fixed set_target_screw_rpm 2025-05-26 16:36:54 +02:00
93094049+kraemr@users.noreply.github.com 007dab9ed9 fixed compile error 2025-05-26 16:35:24 +02:00
93094049+kraemr@users.noreply.github.com 18adf63937 non static pwm_period 2025-05-26 16:33:13 +02:00
93094049+kraemr@users.noreply.github.com 2e538fdad5 pid factors are now inside the controller for screw_speed 2025-05-26 16:27:37 +02:00
93094049+kraemr@users.noreply.github.com 58c1880488 removed useles change 2025-05-26 16:25:26 +02:00
93094049+kraemr@users.noreply.github.com 7cca56e20a removed empty line from HeatingZone 2025-05-26 16:24:34 +02:00
93094049+kraemr@users.noreply.github.com 50ac55c2c1 removed rpm.rs 2025-05-26 16:22:20 +02:00
93094049+kraemr@users.noreply.github.com 926c8f7f4d use Frequency::Zero and fixed cargo.toml 2025-05-26 16:21:26 +02:00
93094049+kraemr@users.noreply.github.com ac037bf977 removed unused imports and warnings 2025-05-26 15:37:33 +02:00
93094049+kraemr@users.noreply.github.com 0c3d26a08b switched almost everything to uom ... 2025-05-26 15:27:08 +02:00
93094049+kraemr@users.noreply.github.com 70e96a0fa2 changed Flame to lu:flame 2025-05-26 13:54:44 +02:00
93094049+kraemr@users.noreply.github.com 22ec270561 renamed mutations to match winder 2025-05-26 13:23:31 +02:00
93094049+kraemr@users.noreply.github.com e8d9c231dc renamed RpmStateEvent to ScrewStateEvent 2025-05-26 13:13:15 +02:00
93094049+kraemr@users.noreply.github.com 45c708909b renamed get_rpm to get_screw_rpm 2025-05-26 13:11:54 +02:00
93094049+kraemr@users.noreply.github.com 3e963d8c22 renamed set_target_rpm to set_screw_speed 2025-05-26 13:11:12 +02:00
93094049+kraemr@users.noreply.github.com 45038fc9c3 added transmission into calculation of Target RPM 2025-05-26 13:06:20 +02:00
93094049+kraemr@users.noreply.github.com 4ad8abb88f replaced member acces to temperature with getters 2025-05-26 12:51:23 +02:00
93094049+kraemr@users.noreply.github.com 13610f6612 forward instead of forwards 2025-05-26 12:47:40 +02:00
93094049+kraemr@users.noreply.github.com 173861bbd5 fixed errors, refactored and simplified temperature control 2025-05-26 12:42:55 +02:00
93094049+kraemr@users.noreply.github.com 4b952f55ab removed unused Mutations 2025-05-26 12:12:22 +02:00
93094049+kraemr@users.noreply.github.com 00af80f531 rewrote act and extruder, added ScrewSpeedController 2025-05-26 12:07:17 +02:00
93094049+kraemr@users.noreply.github.com 7caad4bb32 only one update needed for temperature control now 2025-05-26 11:22:57 +02:00
93094049+kraemr@users.noreply.github.com a0667f6bc7 extruder now uses the correct id 2025-05-26 11:19:46 +02:00
93094049+kraemr@users.noreply.github.com 1d28abe664 removed empty lines 2025-05-26 11:13:57 +02:00
93094049+kraemr@users.noreply.github.com 029edb2e54 removed empty line 2025-05-26 11:11:55 +02:00
93094049+kraemr@users.noreply.github.com 27959f0411 fixed el1002 description 2025-05-26 11:10:29 +02:00
93094049+kraemr@users.noreply.github.com cd52a99fe5 renamed role_labels in frontend 2025-05-26 11:07:50 +02:00
93094049+kraemr@users.noreply.github.com 691f03e905 removed no Modbus response error log 2025-05-26 11:02:01 +02:00
93094049+kraemr@users.noreply.github.com ae93075dc8 removed current_rpm from inverter 2025-05-26 11:00:41 +02:00
93094049+kraemr@users.noreply.github.com e6b99dbb51 refactored MitsubishiInverterRS485Actor into different sections 2025-05-26 10:57:08 +02:00
93094049+kraemr@users.noreply.github.com 3539193f46 removed unused file 2025-05-26 10:53:25 +02:00
93094049+kraemr@users.noreply.github.com 9a140d803c removed empty line 2025-05-26 10:51:44 +02:00
93094049+kraemr@users.noreply.github.com 3afa2d0cb6 Merge branch 'extruder_v2' of github.com:qitechgmbh/control into extruder_v2 2025-05-26 10:48:23 +02:00
Robin KrämerandCopilot 757c529b18 Update electron/src/machines/extruder/HeatingZone.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-26 10:45:33 +02:00
Robin KrämerandCopilot e3e3839dbc Update electron/src/setup/DeviceEepromDialog.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-26 10:43:50 +02:00
Robin KrämerandCopilot f8fdcbcf39 Update electron/src/setup/DeviceEepromDialog.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-26 10:43:38 +02:00
Icedsoda 0d3a9be546 Merge pull request #212 from qitechgmbh/fix/211
fix `test_steps_to_velocity_conversion` and `test_velocity_to_steps_c…
2025-05-26 01:05:36 +02:00
Icedsoda 7b5251e80f fix test_steps_to_velocity_conversion and test_velocity_to_steps_conversion 2025-05-26 01:04:21 +02:00
Icedsoda 70b4a417ff Merge pull request #209 from qitechgmbh/fix/203
Improve acceleration of winder steppers
2025-05-26 00:49:28 +02:00
Icedsoda 5dada9a06c Ignore doc tests 2025-05-26 00:46:48 +02:00
Icedsoda 95758fe71d Improve `AccelerationSpeedControllerz and its wrappers 2025-05-26 00:43:57 +02:00
Icedsoda 21fdc4dbab Use LinearJerkSpeedController in SpoolSpeedController 2025-05-25 23:46:37 +02:00
Icedsoda c80100c38b Increase acceleration on SpoolSpeedController 2025-05-25 22:47:22 +02:00
Icedsoda b358fe6ab1 Use second degree motion controller with puller 2025-05-25 17:12:49 +02:00
Icedsoda 80c7b3d0e7 Add second degree motion controllers 2025-05-25 17:12:49 +02:00
Icedsoda 9455d7fc47 Merge pull request #207 from qitechgmbh/fix/197
Fix #197 and #202
2025-05-23 17:57:20 +02:00
Icedsoda 27f7d8568f Add capability checks for traverse and winding operations
Introduces detailed capability checks for traverse operations
(inner/outer limit, home) and winding mode to ensure safer
and more precise control. Updates frontend and backend logic
to utilize these checks, improving synchronization and user
feedback. Also emits mode state updates when traverse state
or tension arm state changes to maintain frontend consistency.
2025-05-23 17:56:20 +02:00
Icedsoda 08b149a4d1 disable winding if not homed 2025-05-23 17:21:04 +02:00
Icedsoda 39148d6b18 Merge pull request #206 from qitechgmbh/fix/204
Add button to reverse puller direction
2025-05-23 17:16:28 +02:00
Icedsoda aed1960ce4 Add button to reverse puller direction 2025-05-23 17:15:34 +02:00
93094049+kraemr@users.noreply.github.com 07d765da48 cleanup 2025-05-22 18:37:40 +02:00
93094049+kraemr@users.noreply.github.com 24e33e504b fixing errors caused by merge 2025-05-22 18:29:53 +02:00
93094049+kraemr@users.noreply.github.com 93a8215a22 Merge remote-tracking branch 'origin/master' into extruder_v2 2025-05-22 18:21:23 +02:00
93094049+kraemr@users.noreply.github.com b7ea958034 removed dead code 2025-05-22 18:16:10 +02:00
93094049+kraemr@users.noreply.github.com 95e16d13e0 refactor 2025-05-22 18:15:20 +02:00
93094049+kraemr@users.noreply.github.com 7f958210fd fixed Reset Inverter request 2025-05-22 16:28:53 +02:00
Icedsoda 998d0010af Merge pull request #201 from qitechgmbh/fix/199
Reverse `<Spool/>` animation direction
2025-05-21 19:03:56 +02:00
Icedsoda 7a29b74d05 Reverse <Spool/> animation direction 2025-05-21 19:03:31 +02:00
Icedsoda 1020bd2438 Merge pull request #185 from qitechgmbh/fix/29
Add Traverse Functionality
2025-05-21 18:04:06 +02:00
Icedsoda 6de0ba238d Fix ControlGrid columns property 2025-05-21 18:03:07 +02:00
Icedsoda 28d871f45e Remove "Homed: badge when homed 2025-05-21 17:59:22 +02:00
Icedsoda 675aa0daf6 Emit TraverseStateEvent more often 2025-05-21 17:57:10 +02:00
Icedsoda ce98e1b71b Add traversing functionality 2025-05-21 17:36:57 +02:00
Icedsoda 7887860a91 Improve test_round_propabilistic 2025-05-21 08:46:24 +02:00
Icedsoda 67ec75a09a Set Traverse Limit to 180mm 2025-05-20 22:46:39 +02:00
Icedsoda eba6656cf2 Improve traverse speed when GoingIn and GoingOut 2025-05-20 22:46:09 +02:00
Icedsoda fbe0d46b82 Merge branch 'master' into fix/29 2025-05-20 22:32:06 +02:00
Icedsoda 6cc70b97ba Fix TraverseBar visualization 2025-05-20 22:27:31 +02:00
Icedsoda b527bbe11e Fix position conversion from stepper terminal (consider microstepping) 2025-05-20 21:48:45 +02:00
Icedsoda eee13c0dcb Merge pull request #189 from qitechgmbh/fix/188
DRE Frontend
2025-05-20 21:39:17 +02:00
Darmenov Alisher e141111ad4 Merge branch 'master' into fix/188 2025-05-20 21:37:15 +02:00
Anatomik5 83f247f8e9 Solved async problem in dre act and enchanced useDre with useStateOptimistic 2025-05-20 21:35:55 +02:00
Icedsoda 8d46ee0af8 Fix CounterWrapperU16U128 2025-05-20 20:35:47 +02:00
Icedsoda a6b41bf530 IMprove traverse homing routine 2025-05-20 18:53:29 +02:00
Icedsoda a73eec2d5a Able to home traverse (fix #29) 2025-05-20 15:07:32 +02:00
Icedsoda aafb37324d Fix EL70x1VelocityConverter 2025-05-20 14:41:59 +02:00
Icedsoda cc78e84615 Add docs to AngularStepConverter and LinearStepConverter 2025-05-20 13:24:21 +02:00
Icedsoda c8968c1d3d Add DigitalInputGetter 2025-05-20 13:22:54 +02:00
Icedsoda 0062f420a4 Add propabilistic rounding to EL70x1VelocityConverter 2025-05-20 13:22:19 +02:00
Icedsoda ceb8c3ed16 Merge pull request #190 from qitechgmbh/134-nixos-installsh-not-working-on-fresh-nixos-electron-build-hangs
fix for electron build hanging
2025-05-20 12:32:17 +02:00
93094049+kraemr@users.noreply.github.com f21858d54f fix for electron build hanging 2025-05-20 10:33:42 +02:00
Anatomik5 cde791e01d dre frontend + backend enchance 2025-05-19 20:53:46 +02:00
Icedsoda 9919439bf6 Rename step_converter.rs to angular_step_converter.rs 2025-05-19 17:24:14 +02:00
Icedsoda 033986af9e Move EL70x1VelocityCalculator to ethercat-hal 2025-05-19 17:22:48 +02:00
Icedsoda 02a7d30e35 Change StepperVelocityEL70x1 interface 2025-05-19 17:20:42 +02:00
Icedsoda 9ebc0f352c reforctor helper location in ethercat-hal 2025-05-19 17:12:51 +02:00
Icedsoda 8e23884679 Add Trait EthercatDeviceProcessing 2025-05-19 15:38:11 +02:00
Anatomik5 c88b44f712 fix compile error 2025-05-19 14:12:53 +02:00
Darmenov Alisher f1dbe23131 Merge pull request #110 from qitechgmbh/dre-usb-driver
Dre usb driver
2025-05-19 14:04:08 +02:00
Darmenov Alisher d2a80b07c2 Merge branch 'master' into dre-usb-driver 2025-05-19 14:03:52 +02:00
Icedsoda 74b98195da Implement Traverse API 2025-05-19 13:49:46 +02:00
Icedsoda 551161c690 Rename some occurences from Winder1 to Winder2 2025-05-19 12:45:34 +02:00
Icedsoda 34a914dd50 Add LinearStepConverter 2025-05-19 12:38:51 +02:00
Icedsoda ad1657b283 Merge pull request #184 from qitechgmbh/fix/183
Reverse Puller Direction
2025-05-19 11:14:16 +02:00
Icedsoda fe37ea63a0 Reverse Puller Direction 2025-05-19 11:13:47 +02:00
Anatomik5 5dbaa01b7a dre enchance 2025-05-18 16:59:57 +02:00
Anatomik5 f259b8cb06 Added dre machine configuration (target diameter, lower tolerance, higher tolerance) and added mutation 2025-05-18 16:59:57 +02:00
Anatomik5 19ccb67a27 Enhance DreMachine functionality, create act() fucntion for DreMachine 2025-05-18 16:59:57 +02:00
Anatomik5 48a1adb672 Refactor Modbus functions for improved CRC validation 2025-05-18 16:59:57 +02:00
Anatomik5 bc9f795129 Implement hashing functions and integrate them into the Dre device identification process 2025-05-18 16:59:57 +02:00
Icedsoda f0b18f4b15 Merge pull request #178 from qitechgmbh/fix/177
Implement Puller with RPM Control
2025-05-16 12:10:03 +02:00
Icedsoda 15445870e3 fix spool_set_speed_max and spool_set_speed_min 2025-05-16 12:09:51 +02:00
Icedsoda 8d47ec48ad Implement Puller with RPM Control 2025-05-16 11:51:11 +02:00
Icedsoda 3cc6e3b465 Merge pull request #173 from qitechgmbh/fix/172
Use Exponential Interpolation in `SpoolSpeedController`
2025-05-15 22:17:50 +02:00
Icedsoda 7e7c70276f Use Exponential Interpolation in SpoolSpeedController 2025-05-15 22:06:07 +02:00
Icedsoda ed9d9d1425 Merge pull request #171 from qitechgmbh/fix/71
Fix Spool Speed
2025-05-15 22:03:26 +02:00
Icedsoda 7caa0eb117 Fix Spool Speed 2025-05-15 20:44:15 +02:00
Icedsoda cbe7421f9f Merge pull request #170 from qitechgmbh/fix/63
Add `LinearAngleAccelerationController`
2025-05-15 20:19:49 +02:00
Icedsoda 056ad8e6fb SpoolSpeedController now uses LinearAngularAccelerationController istead of PID controller for acceleration 2025-05-15 20:18:50 +02:00
Icedsoda 03a258f583 Add LinearAccelerationController 2025-05-15 15:17:01 +02:00
Icedsoda af721b867c Merge pull request #168 from qitechgmbh/fix/167
Subsitute `El3001` with `EL7031-0030`
2025-05-15 14:47:31 +02:00
Icedsoda 9957d512a5 Remove EL3001 from Winder2 2025-05-15 14:46:02 +02:00
Icedsoda 0af5d9ef05 Fix EL70x1 bugs & add role 4 & 5 to Winder2 2025-05-15 13:58:49 +02:00
Icedsoda 545888ecd6 Merge pull request #166 from qitechgmbh/fix/130-2
Move a `send_panic out of a loop to reduce memory
2025-05-14 13:56:47 +02:00
Icedsoda 52a9e7c1d7 fix dre-usb-driver branch 2025-05-13 23:46:51 +02:00
Darmenov Alisher a936b0e139 Create README.md
Update README.md

Add initial diagram for serial detection process and create serial documentation

Update serial.md

Update serial.md

Update serial.md

Update serial.md

Update serial.md

Update serial.md

Add serial device detection diagram

Add serial device to machine diagram

png of serial detection loop
2025-05-13 23:27:45 +02:00
Anatomik5 8d156f0af9 1
Add dre-usb-driver

Created Modul for separate usb detection and validation without tokio

TODO: rmodbus is working directly with frames, no higher level abstraction -> IDEA to write own abstraction to avoid tokio complexity and size

Modbus communication established

abstract begin

Abstraction of serial device detection

Extended the modbus for serialports demand

added dre

working dre detection with update

control-core - serial registry update

Working DRE

test serial events

add <Markdown> component

add fullscreen button

add <Markdown> component

add fullscreen button

1

Add dre-usb-driver

abstract begin

s

d

solved some errors

add <Markdown> component

add fullscreen button

add fullscreen button

add <Markdown> component

add fullscreen button

1

Add dre-usb-driver

abstract begin

Working DRE

add <Markdown> component

Add dre-usb-driver

d

solved some errors

up

c

123

Update rust.yml

dre dev

dre implemented
2025-05-13 23:27:42 +02:00
Darmenov Alisher a2384d61e0 stash 2025-05-10 18:09:02 +02:00
300 changed files with 29950 additions and 5742 deletions

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
name: Electron
on:
push:
branches:
- "**"
pull_request:
branches:
- "*"
jobs:
build-electron:
runs-on: ubuntu-latest
defaults:
run:
working-directory: electron
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18" # or '20' for latest LTS
- uses: actions/checkout@v4
- name: Cache NPM dependencies
uses: actions/cache@v3
with:
path: |
~/.npm
node_modules/
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install
run: npm i
- name: Lint
run: npm run lint
- name: Test
run: npm run test
- name: Build
run: npm run package
+2
View File
@@ -16,6 +16,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Install libudev development package
run: sudo apt-get update && sudo apt-get install -y libudev-dev
- uses: actions/checkout@v4
- name: Set Rust version
run: rustup override set ${{ env.RUST_VERSION }}
+1 -1
View File
@@ -2,4 +2,4 @@ target/
dist/
.DS_Store
Cargo.lock
dhat-heap.json
dhat-heap.json
+4 -1
View File
@@ -1 +1,4 @@
# Unreleased
# 2.0.0
Initial stable release of QiTech Control.
## Features
- Stabilized **Winder V2**
Generated
+1318 -100
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -63,6 +63,9 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
| Extruder | V1 | ??? | Single Screw Extruder | - | 1 (Qitech Industries GmbH) | 3 | Reserved | - |
| Extruder | V2 | 2025 | Single Screw Extruder | PT100 Thermometers, Optional Additional Heating Zone 4 | 1 (Qitech Industries GmbH) | 4 | Not Yet | |
| Waterway | V1 | 2025 | Filament Water Cooling | - | 1 (Qitech Industries GmbH) | 5 | Not Yet | |
| Laser | V1 | ??? | Diameter Measuring Laser | - | 1 (Qitech Industries GmbH) | 6 | Not Yet | |
| Mock | - | ??? | Mock Machine for Testing | - | 1 (Qitech Industries GmbH) | 7 | Not Yet | - |
# More Docs
@@ -88,6 +91,7 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
- Server
- [X] [Threading](./docs/control-loop.md)
- [X] [Logging](./docs/logging.md)
- [ ] Control Loop Setup
- Control Loop Thread
- [ ] realtime
+6 -3
View File
@@ -9,13 +9,16 @@ axum = "0.8.4"
ethercat_hal = { path = "../ethercat-hal" }
ethercrab = "0.6"
interfaces = "0.0.9"
log = "0.4.27"
serde = "1.0.219"
serde_json = "1.0.140"
serialport = "4.7.1"
smol = "2.0.2"
socketioxide = "0.17.0"
socketioxide = "0.17.1"
uom = "0.36.0"
bitvec = { version = "1.0.1", features = ["alloc"] }
crc = "3.2.1"
crc = "3.3.0"
serial = "0.4.0"
tracing = "0.1.41"
erased-serde = "0.4.6"
[dev-dependencies]
approx = "0.5.1"
@@ -0,0 +1,35 @@
use super::Actor;
use ethercat_hal::io::digital_input::DigitalInput;
use std::{future::Future, pin::Pin, time::Instant};
/// Log the state of a digital input
#[derive(Debug)]
pub struct DigitalInputGetter {
input: DigitalInput,
value: bool,
}
impl DigitalInputGetter {
pub fn new(input: DigitalInput) -> Self {
Self {
input,
value: false,
}
}
/// Get the current value of the digital input
pub fn value(&self) -> bool {
self.value
}
}
impl Actor for DigitalInputGetter {
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(async move {
let state = (self.input.state)()
.await
.expect("Failed to get digital input state");
self.value = state.input.value;
})
}
}
@@ -8,7 +8,6 @@ use std::{
/// Set a digital output high and low with a given interval
#[derive(Debug)]
pub struct DigitalOutputBlinker {
last_toggle: Instant,
output: DigitalOutput,
@@ -13,7 +13,7 @@ impl DigitalOutputSetter {
pub fn new(output: DigitalOutput) -> Self {
Self {
output,
enabled: true,
enabled: false,
}
}
pub fn set(&mut self, enabled: bool) {
@@ -1 +0,0 @@
// In this file create all Requests that are needed to int the inverter
@@ -1,13 +1,18 @@
use super::Actor;
use crate::{
converters::motor_converter::MotorConverter,
modbus::{ModbusFunctionCode, ModbusRequest, ModbusResponse, calculate_modbus_rtu_timeout},
use crate::modbus::{
ModbusFunctionCode, ModbusRequest, ModbusResponse, calculate_modbus_rtu_timeout,
};
use bitvec::{order::Lsb0, slice::BitSlice};
use ethercat_hal::io::serial_interface::{SerialEncoding, SerialInterface};
use std::{
collections::VecDeque,
collections::HashMap,
pin::Pin,
time::{Duration, Instant},
u16,
};
use uom::{
ConstZero,
si::{f64::Frequency, frequency::centihertz},
};
#[derive(Debug)]
@@ -35,17 +40,15 @@ pub enum OperationMode {
}
impl MitsubishiInverterRS485Actor {
pub fn convert_hz_float_to_word(&mut self, value: f32, little_endian: bool) -> u16 {
let scaled = value * 100.0; // Convert Hz to 0.01 Hz units
pub fn convert_hz_float_to_word(&mut self, value: Frequency) -> u16 {
let scaled = value.get::<centihertz>(); // Convert Hz to 0.01 Hz units
scaled.round() as u16
}
pub fn set_running_rpm_target(&mut self, rpm: f32) {
pub fn set_frequency_target(&mut self, frequency: Frequency) {
let mut request: MitsubishiModbusRequest =
MitsubishiControlRequests::WriteRunningFrequency.into();
let hz = MotorConverter::rpm_to_hz(rpm); // convert rpm to hz
let result: u16 = self.convert_hz_float_to_word(hz, true); // convert hz float to short
let result: u16 = self.convert_hz_float_to_word(frequency); // convert hz float to short
request.request.data[2] = result.to_le_bytes()[1];
request.request.data[3] = result.to_le_bytes()[0];
self.add_request(request);
@@ -100,6 +103,21 @@ impl MitsubishiControlRequests {
}
}
/*
MitsubishiModbusRequest get executed by their priority
Start and StopMotor are highest priority while writeRunningFrequency and readMotorFrequency are one lower
lets say we had StartMotor and readMotorFrequency the order of execution is:
1. StartMotor
2. readMotorFrequency
this is because StartMotor is higher priority
Since the events do not need to be pushed into a queue this makes the inverter operation more stable
Lets say one request "A" with priority 1 and one with 2 "B" are queued up, assume that request B is frequently used
1. Request "B" is executed due to higher priority
2. When B is added again request A has the same priority because it was ignored. B is executed once again
3. B is added again, now A has an effective priority of 3, which is higher then B
*/
impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
fn from(request: MitsubishiControlRequests) -> Self {
match request {
@@ -116,6 +134,9 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
},
request_type: RequestType::OperationCommand,
expected_response_type: ResponseType::WriteFrequency,
priority: u16::MAX - 1,
control_request_type: MitsubishiControlRequests::WriteRunningFrequency,
ignored_times: 0,
}
}
MitsubishiControlRequests::ReadInverterStatus => {
@@ -131,6 +152,11 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
},
request_type: RequestType::OperationCommand,
expected_response_type: ResponseType::InverterStatus,
// Priority is -6 because we do not want to know the status as frequently as the frequency, which is why its priority is lower
// In essence this means for every fifth ReadMotorFrequency request an InverterStatus request is sent
priority: u16::MAX - 6,
control_request_type: MitsubishiControlRequests::ReadInverterStatus,
ignored_times: 0,
}
}
MitsubishiControlRequests::StopMotor => {
@@ -146,6 +172,9 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
},
request_type: RequestType::OperationCommand,
expected_response_type: ResponseType::InverterControl,
priority: u16::MAX, // StopMotor should have highest priority
control_request_type: MitsubishiControlRequests::StopMotor,
ignored_times: 0,
}
}
MitsubishiControlRequests::StartForwardRotation => {
@@ -161,6 +190,9 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
},
request_type: RequestType::OperationCommand,
expected_response_type: ResponseType::InverterControl,
priority: u16::MAX - 1,
control_request_type: MitsubishiControlRequests::StartForwardRotation,
ignored_times: 0,
}
}
MitsubishiControlRequests::StartReverseRotation => {
@@ -176,6 +208,9 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
},
request_type: RequestType::OperationCommand,
expected_response_type: ResponseType::InverterControl,
priority: u16::MAX - 1,
control_request_type: MitsubishiControlRequests::StartReverseRotation,
ignored_times: 0,
}
}
MitsubishiControlRequests::ReadRunningFrequency => {
@@ -191,6 +226,9 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
},
request_type: RequestType::OperationCommand,
expected_response_type: ResponseType::ReadFrequency,
priority: u16::MAX - 4,
control_request_type: MitsubishiControlRequests::ReadRunningFrequency,
ignored_times: 0,
}
}
MitsubishiControlRequests::ReadMotorFrequency => {
@@ -206,11 +244,14 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
},
request_type: RequestType::OperationCommand,
expected_response_type: ResponseType::ReadMotorFrequency,
priority: u16::MAX - 2,
control_request_type: MitsubishiControlRequests::ReadMotorFrequency,
ignored_times: 0,
}
}
MitsubishiControlRequests::ResetInverter => {
let reg: u16 = MitsubishiControlRequests::get_system_register(
MitsubishiSystemRegister::InverterStatusAndControl,
MitsubishiSystemRegister::InverterReset,
);
let reg_bytes = reg.to_be_bytes();
MitsubishiModbusRequest {
@@ -219,20 +260,25 @@ impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
function_code: ModbusFunctionCode::PresetHoldingRegister,
data: vec![reg_bytes[0], reg_bytes[1], 0x0, 0b00000001],
},
request_type: RequestType::OperationCommand,
expected_response_type: ResponseType::ReadMotorFrequency,
request_type: RequestType::Reset,
expected_response_type: ResponseType::NoResponse,
priority: u16::MAX,
control_request_type: MitsubishiControlRequests::ResetInverter,
ignored_times: 0,
}
}
MitsubishiControlRequests::ClearAllParameters => todo!(),
MitsubishiControlRequests::ClearNonCommunicationParameter => todo!(),
MitsubishiControlRequests::ClearNonCommunicationParameters => todo!(),
MitsubishiControlRequests::None => todo!(),
}
}
}
/// These Requests Serve as Templates for controling the inverter
#[derive(Debug)]
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
pub enum MitsubishiControlRequests {
None,
/// Register 40002, Reset/Restart the Inverter
ResetInverter,
/// Register 40004, Clear ALL parameters
@@ -258,11 +304,14 @@ pub enum MitsubishiControlRequests {
}
// We need to know from the request queue which events are of what operation type, so that the correct timeout can be used
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct MitsubishiModbusRequest {
request: ModbusRequest,
control_request_type: MitsubishiControlRequests,
request_type: RequestType,
expected_response_type: ResponseType,
priority: u16,
ignored_times: u32,
}
#[derive(Debug)]
@@ -272,36 +321,50 @@ pub enum RotationDirection {
Stopped,
}
#[derive(Debug)]
#[derive(Debug, Clone, Copy)]
pub enum ResponseType {
NoResponse,
ReadFrequency,
ReadMotorFrequency, // Motor Frequency is the actual frequency, that the motor is running at right now
ReadInverterStatus,
WriteFrequency,
InverterStatus,
InverterControl,
}
#[derive(Debug, Default)]
pub struct MitsubishiInverterStatus {
pub running: bool,
pub forward_running: bool,
pub reverse_running: bool,
pub su: bool,
pub ol: bool,
pub no_function: bool,
pub fu: bool,
pub abc_: bool,
pub fault_occurence: bool,
}
#[derive(Debug)]
pub struct MitsubishiInverterRS485Actor {
// Communication
pub serial_interface: SerialInterface,
pub baudrate: Option<u32>,
pub encoding: Option<SerialEncoding>,
pub request_map: HashMap<MitsubishiControlRequests, MitsubishiModbusRequest>,
pub response: Option<ModbusResponse>,
pub inverter_status: MitsubishiInverterStatus,
// State
pub last_ts: Instant,
pub last_message_size: usize,
pub last_request_type: RequestType,
pub last_control_request_type: MitsubishiControlRequests,
pub state: State,
pub baudrate: Option<u32>,
pub encoding: Option<SerialEncoding>,
pub request_queue: VecDeque<MitsubishiModbusRequest>,
pub response_queue: VecDeque<ModbusResponse>,
pub forward_rotation: bool,
pub next_response_type: ResponseType,
pub current_freq: f32,
pub current_rpm: f32,
pub frequency: Frequency,
}
impl MitsubishiInverterRS485Actor {
@@ -310,30 +373,36 @@ impl MitsubishiInverterRS485Actor {
serial_interface,
last_ts: Instant::now(),
state: State::Uninitialized,
request_queue: VecDeque::new(),
response_queue: VecDeque::new(),
forward_rotation: true,
next_response_type: ResponseType::ReadMotorFrequency,
request_map: HashMap::new(),
response: None,
next_response_type: ResponseType::NoResponse,
last_request_type: RequestType::OperationCommand,
last_message_size: 0,
baudrate: None,
encoding: None,
current_freq: 0.0,
current_rpm: 0.0,
frequency: Frequency::ZERO,
last_control_request_type: MitsubishiControlRequests::ResetInverter,
inverter_status: MitsubishiInverterStatus::default(),
}
}
/// This would get called by the api to add a new request to the inverter
pub fn add_request(&mut self, request: MitsubishiModbusRequest) {
self.request_queue.push_front(request);
}
// If a request already exists and its values get replaced, then use the olde requests ignored_times, to ensure its executed at some point
// Otherwise requests that are added frequently are never called, because new requests have ignored_times = 0
if self.request_map.contains_key(&request.control_request_type) {
// unwrap is safe here
let old_request = self.request_map.get(&request.control_request_type).unwrap();
/// This is used by the Api to pop off the Response of our Request
pub fn get_response(&mut self) -> Option<ModbusResponse> {
if self.response_queue.len() == 0 {
return None;
let mut new_request = request.clone();
new_request.ignored_times = old_request.ignored_times;
self.request_map
.insert(request.control_request_type.clone(), new_request);
} else {
self.request_map
.insert(request.control_request_type.clone(), request);
}
self.response_queue.pop_back()
}
/// This is used internally to read the receive buffer of the el6021
@@ -341,52 +410,77 @@ impl MitsubishiInverterRS485Actor {
&mut self,
) -> Pin<Box<dyn Future<Output = Result<ModbusResponse, anyhow::Error>> + Send + '_>> {
Box::pin(async move {
if !(self.serial_interface.has_message)().await {
// return;
}
let res: Option<Vec<u8>> = (self.serial_interface.read_message)().await;
let raw_response = match res {
Some(res) => res,
None => {
log::error!("ERROR: No Modbus Response");
vec![]
}
};
let response: Result<ModbusResponse, _> =
ModbusResponse::try_from(raw_response.clone());
match response {
Ok(result) => {
self.last_message_size = result.clone().data.len() + 4;
self.state = State::ReadyToSend;
self.state = State::WaitingForReceiveAccept;
Ok(result)
}
Err(_) => {
log::error!("Error Parsing ModbusResponse!");
self.state = State::ReadyToSend;
self.last_message_size = 22;
// self.state = State::WaitingForReceiveAccept;
Err(anyhow::anyhow!("error"))
}
}
})
}
/// This is used internally to fill the write buffer of the el6021
fn set_ignored_times_modbus_requests(&mut self) {
for (_, value) in self.request_map.iter_mut() {
value.ignored_times += 1;
}
}
/// This is used internally to fill the write buffer of the el6021 with the modbus request
/// Decides what requests to send first by finding the one with the highest priority
/// For example Highest Priority requests: ResetInverter StopMotor
fn send_modbus_request(&mut self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(async move {
if self.request_queue.len() == 0 {
if self.request_map.len() == 0 {
return;
};
let request: MitsubishiModbusRequest = self.request_queue.pop_back().unwrap();
self.next_response_type = request.expected_response_type;
self.last_request_type = self.last_request_type;
let modbus_request: Vec<u8> = request.request.into();
let mut highest_prio_request: Option<&mut MitsubishiModbusRequest> = None;
let mut highest_priority: u32 = 0;
for (_, value) in self.request_map.iter_mut() {
// borrowchecker complaining
let priority = value.priority as u32;
let ignored_times = value.ignored_times;
let effective_priority: u32 = priority as u32 + ignored_times;
if effective_priority > highest_priority {
highest_prio_request = Some(value);
highest_priority = effective_priority;
}
}
let request = match highest_prio_request {
Some(request) => request,
None => return,
};
let modbus_request: Vec<u8> = request.request.clone().into();
let res = (self.serial_interface.write_message)(modbus_request.clone()).await;
match res {
Ok(_) => (),
Err(_) => log::error!("ERROR: serial_interface.write_message has failed"),
Ok(_) => {
self.next_response_type = request.expected_response_type;
self.last_request_type = request.request_type;
self.last_control_request_type = request.control_request_type.clone();
}
Err(_) => tracing::error!("ERROR: serial_interface.write_message has failed"),
}
self.state = State::WaitingForRequestAccept;
self.last_message_size = modbus_request.len();
})
@@ -395,13 +489,14 @@ impl MitsubishiInverterRS485Actor {
#[derive(Debug, Clone, Copy)]
pub enum RequestType {
None,
/// Monitoring, Operation (start,stop etc) command, frequency setting (RAM), less than 12 milliseconds timeout for Response
OperationCommand,
/// Parameter Read/Write and Frequency (EEPROM), Less than 30 milliseconds timeout for Response
ReadWrite,
/// Less than 5 seconds timeout for Response
ParamClear,
/// no Timeout for Response
/// Supposedly no waiting time, however inverter takes a while to start ~300ms should be more than enough
Reset,
}
@@ -411,7 +506,8 @@ impl RequestType {
RequestType::OperationCommand => Duration::from_millis(12),
RequestType::ReadWrite => Duration::from_millis(30),
RequestType::ParamClear => Duration::from_millis(5000),
RequestType::Reset => Duration::from_millis(30),
RequestType::Reset => Duration::from_millis(300),
RequestType::None => Duration::from_millis(12),
}
}
}
@@ -447,29 +543,38 @@ impl From<u8> for MitsubishiModbusExceptionCode {
// Handle different Response types
impl MitsubishiInverterRS485Actor {
// When we get respone from Pr. 40014 (Running Frequency) Convert to rpm and save it
fn handle_motor_frequency(&mut self, resp: ModbusResponse) {
if resp.data.len() < 4 {
//return;
}
let freq_bytes = &resp.data[1..3]; // bytes 1 and 2 are needed
self.current_freq = u16::from_be_bytes([freq_bytes[0], freq_bytes[1]]) as f32 / 100.0;
self.current_rpm = MotorConverter::hz_to_rpm(self.current_freq);
let raw_frequency = u16::from_be_bytes([freq_bytes[0], freq_bytes[1]]) as f64;
self.frequency = Frequency::new::<centihertz>(raw_frequency);
}
fn handle_inverter_status(&mut self, resp: ModbusResponse) {}
fn handle_read_inverter_status(&mut self, resp: ModbusResponse) {
let status_bytes: [u8; 2] = resp.data[1..3].try_into().unwrap();
let bits: &BitSlice<u8, Lsb0> = BitSlice::<_, Lsb0>::from_slice(&status_bytes);
self.inverter_status = MitsubishiInverterStatus {
running: bits[8],
forward_running: bits[9],
reverse_running: bits[10],
su: bits[11],
ol: bits[12],
no_function: bits[13],
fu: bits[14],
abc_: bits[15],
fault_occurence: bits[7],
};
}
fn handle_inverter_frequency(&mut self, resp: ModbusResponse) {}
async fn handle_no_response(&mut self) {}
fn handle_response(&mut self, resp: ModbusResponse) {
async fn handle_response(&mut self, resp: ModbusResponse) {
match self.next_response_type {
ResponseType::ReadFrequency => self.handle_inverter_frequency(resp),
ResponseType::ReadInverterStatus => (),
ResponseType::ReadFrequency => (),
ResponseType::WriteFrequency => (),
ResponseType::ReadMotorFrequency => self.handle_motor_frequency(resp),
ResponseType::InverterStatus => self.handle_inverter_status(resp),
ResponseType::InverterStatus => self.handle_read_inverter_status(resp),
ResponseType::InverterControl => (),
ResponseType::NoResponse => (),
ResponseType::NoResponse => self.handle_no_response().await,
}
}
}
@@ -487,8 +592,11 @@ impl Actor for MitsubishiInverterRS485Actor {
self.add_request(MitsubishiControlRequests::ResetInverter.into());
self.baudrate = (self.serial_interface.get_baudrate)().await;
self.encoding = (self.serial_interface.get_serial_encoding)().await;
self.last_ts = now_ts;
return;
} else {
return;
}
return;
}
let encoding = match self.encoding {
@@ -508,29 +616,51 @@ impl Actor for MitsubishiInverterRS485Actor {
self.last_message_size,
);
// if we have no requests add ReadMotorFrequency
if self.request_queue.is_empty() {
self.add_request(MitsubishiControlRequests::ReadMotorFrequency.into());
}
if elapsed < timeout {
return;
}
self.add_request(MitsubishiControlRequests::ReadMotorFrequency.into());
self.add_request(MitsubishiControlRequests::ReadInverterStatus.into());
self.last_ts = now_ts;
match self.state {
State::WaitingForResponse => {
let ret = self.read_modbus_response().await;
match ret {
Ok(ret) => self.handle_response(ret),
Err(_) => (), // Do nothing for now
Ok(ret) => {
self.handle_response(ret).await;
}
Err(_) => {
if let MitsubishiControlRequests::ResetInverter =
self.last_control_request_type
{
self.state = State::ReadyToSend;
}
}
}
self.next_response_type = ResponseType::NoResponse;
}
State::ReadyToSend => {
self.send_modbus_request().await;
self.request_map.remove(&self.last_control_request_type);
self.set_ignored_times_modbus_requests();
}
State::WaitingForReceiveAccept => {
_ = (self.serial_interface.read_finished)().await;
self.state = State::ReadyToSend;
}
State::WaitingForRequestAccept => {
let res = (self.serial_interface.write_finished)().await;
if res == true {
self.state = State::WaitingForResponse;
}
}
State::ReadyToSend => self.send_modbus_request().await,
State::WaitingForReceiveAccept => self.state = State::ReadyToSend,
State::WaitingForRequestAccept => self.state = State::WaitingForResponse,
_ => (),
}
self.response = None;
})
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod analog_function_generator;
pub mod analog_input_getter;
pub mod digital_input_getter;
pub mod digital_input_logger;
pub mod digital_output_blinker;
pub mod digital_output_blinkers;
@@ -1,88 +0,0 @@
use ethercat_hal::shared_config::el70x1::EL70x1SpeedRange;
#[derive(Debug)]
pub struct EL70x1VelocityCalculator {
max_steps_per_seconds: u16,
}
const MAX_VELOCITY: i16 = 10_000; // 100% of max speed
impl EL70x1VelocityCalculator {
pub fn new(speed_range: &EL70x1SpeedRange) -> Self {
let speed_range_value = match speed_range {
EL70x1SpeedRange::Steps1000 => 1000,
EL70x1SpeedRange::Steps2000 => 2000,
EL70x1SpeedRange::Steps4000 => 4000,
EL70x1SpeedRange::Steps8000 => 8000,
EL70x1SpeedRange::Steps16000 => 16000,
EL70x1SpeedRange::Steps32000 => 32000,
};
Self {
max_steps_per_seconds: speed_range_value,
}
}
/// Convert steps per second to the i16 velocity value used by the EL7031
pub fn steps_to_velocity(&self, steps_per_second: i32) -> i16 {
// Calculate the velocity value (10000 = 100% of max speed)
let velocity_f64 =
(steps_per_second as f64 / self.max_steps_per_seconds as f64) * MAX_VELOCITY as f64;
// Clamp to i16 range to be safe
velocity_f64.round() as i16
}
/// Convert i16 velocity value back to steps per second
pub fn velocity_to_steps(&self, velocity: i16) -> i16 {
// Calculate steps per second from velocity value
let steps_per_second =
(velocity as f64 / MAX_VELOCITY as f64) * self.max_steps_per_seconds as f64;
steps_per_second.round() as i16
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_steps_to_velocity_conversion() {
let calc = EL70x1VelocityCalculator::new(&EL70x1SpeedRange::Steps4000); // 4000 steps/s
// 0 sps = 0% velocity
assert_eq!(calc.steps_to_velocity(0), 0);
// 1000 sps = 25% velocity = 2500
let v1 = calc.steps_to_velocity(1000);
assert!((v1 - 2500i16).abs() <= 1, "Expected 2500±1, got {}", v1);
// 2000 sps = 50% velocity = 5000
let v2 = calc.steps_to_velocity(2000);
assert!((v2 - 5000i16).abs() <= 1, "Expected 5000±1, got {}", v2);
// 3000 sps = 75% velocity = 7500
let v3 = calc.steps_to_velocity(3000);
assert!((v3 - 7500i16).abs() <= 1, "Expected 7500±1, got {}", v3);
// 4000 sps = 100% velocity = 10000
let v4 = calc.steps_to_velocity(4000);
assert!((v4 - 10000i16).abs() <= 1, "Expected 10000±1, got {}", v4);
}
#[test]
fn test_velocity_to_steps_conversion() {
let calc = EL70x1VelocityCalculator::new(&EL70x1SpeedRange::Steps4000); // 4000 steps/s
// 0% velocity = 0i16 = 0 sps
assert_eq!(calc.velocity_to_steps(0), 0);
// 25% velocity = 2500i16 = 1000 sps
let s1 = calc.velocity_to_steps(2500);
assert_eq!((s1 - 1000i16).abs(), 0, "Expected 1000±0, got {}", s1);
// 50% velocity = 5000i16 = 2000 sps
let s2 = calc.velocity_to_steps(5000);
assert_eq!((s2 - 2000i16).abs(), 0, "Expected 2000±0, got {}", s2);
// 75% velocity = 7500i16 = 3000 sps
let s3 = calc.velocity_to_steps(7500);
assert_eq!((s3 - 3000i16).abs(), 0, "Expected 3000±0, got {}", s3);
// 100% velocity = 10000i16 = 4000 sps
let s4 = calc.velocity_to_steps(10000);
assert_eq!((s4 - 4000i16).abs(), 0, "Expected 4000±0, got {}", s4);
}
}
@@ -1,20 +1,20 @@
use super::Actor;
use core::panic;
use el70xx_velocity_converter::EL70x1VelocityCalculator;
use ethercat_hal::{
helpers::el70xx_velocity_converter::EL70x1VelocityConverter,
io::stepper_velocity_el70x1::StepperVelocityEL70x1, shared_config::el70x1::EL70x1SpeedRange,
};
use std::{future::Future, pin::Pin, time::Instant};
pub mod el70xx_velocity_converter;
/// Set a digital output high and low with a given interval
#[derive(Debug)]
pub struct StepperDriverEL70x1 {
stepper: StepperVelocityEL70x1,
enabled: bool,
velocity: i16,
pub converter: EL70x1VelocityCalculator,
position: i128,
set_position: Option<i128>,
pub converter: EL70x1VelocityConverter,
}
impl StepperDriverEL70x1 {
@@ -23,18 +23,20 @@ impl StepperDriverEL70x1 {
stepper,
enabled: false,
velocity: 0,
converter: EL70x1VelocityCalculator::new(speed_range),
converter: EL70x1VelocityConverter::new(speed_range),
position: 0,
set_position: None,
}
}
/// Set the speed in steps per second
pub fn set_speed(&mut self, steps_per_second: i32) {
self.velocity = self.converter.steps_to_velocity(steps_per_second)
self.velocity = self.converter.steps_to_velocity(steps_per_second, true)
}
/// Get the speed in steps per second
pub fn get_speed(&self) -> i32 {
self.converter.velocity_to_steps(self.velocity) as i32
self.converter.velocity_to_steps(self.velocity, true) as i32
}
/// Enable or disable the stepper
@@ -46,6 +48,16 @@ impl StepperDriverEL70x1 {
pub fn is_enabled(&self) -> bool {
self.enabled
}
/// Get the current position of the stepper
pub fn get_position(&self) -> i128 {
self.position
}
/// Set the position of the stepper
pub fn set_position(&mut self, position: i128) {
self.set_position = Some(position);
}
}
impl Actor for StepperDriverEL70x1 {
@@ -57,11 +69,15 @@ impl Actor for StepperDriverEL70x1 {
panic!("Error while reading StepperVelocity {:?}", e);
}
};
// set the input
self.position = state.input.counter_value;
let mut output = state.output.clone();
// set the output
output.stm_control.enable = self.enabled;
output.stm_velocity.velocity = self.velocity;
output.enable = self.enabled;
output.velocity = self.velocity;
output.set_counter = self.set_position.take();
// write the output
match (self.stepper.write)(output).await {
@@ -7,8 +7,8 @@ use super::Actor;
#[derive(Debug)]
pub struct TemperatureInputGetter {
input: TemperatureInput,
pub temperature: f32,
pub wiring_error: bool,
temperature: f64,
wiring_error: bool,
}
impl TemperatureInputGetter {
@@ -19,13 +19,21 @@ impl TemperatureInputGetter {
wiring_error: false,
}
}
pub fn get_temperature(&self) -> f64 {
self.temperature
}
pub fn get_wiring_error(&self) -> bool {
self.wiring_error
}
}
impl Actor for TemperatureInputGetter {
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(async move {
let input = (self.input.state)().await.input;
self.temperature = input.temperature;
self.temperature = input.temperature as f64;
self.wiring_error = input.overvoltage | input.undervoltage;
if self.wiring_error {
self.temperature = 0.0;
@@ -0,0 +1,434 @@
use std::time::Instant;
#[derive(Debug)]
pub struct AccelerationSpeedController {
/// Maximum deceleration in units per second (positive value)
max_acceleration: f64,
/// Maximum acceleratoin in units per second (negative value)
min_acceleration: f64,
/// Minimum speed limit (None for no limit)
min_speed: Option<f64>,
/// Maximum speed limit (None for no limit)
max_speed: Option<f64>,
/// Calculated speed at the last update
last_speed: f64,
/// Last update time
last_t: Option<Instant>,
}
impl AccelerationSpeedController {
pub fn new(
min_speed: Option<f64>,
max_speed: Option<f64>,
min_acceleration: f64,
max_acceleration: f64,
initial_speed: f64,
) -> Self {
Self {
min_acceleration,
max_acceleration,
min_speed,
max_speed,
last_speed: initial_speed,
last_t: None,
}
}
/// Creates a new acceleration speed controller with simplified parameters.
/// Sets min_acceleration to -max_acceleration for symmetric behavior.
/// No speed limits are applied.
pub fn new_simple(max_acceleration: f64, initial_speed: f64) -> Self {
Self::new(
None, // min_speed
None, // max_speed
-max_acceleration, // min_acceleration (deceleration)
max_acceleration, // max_acceleration
initial_speed,
)
}
pub fn update(&mut self, target_speed: f64, t: Instant) -> f64 {
// Calculate time delta
let dt = match self.last_t {
Some(last) => {
let duration = t.duration_since(last);
duration.as_secs_f64()
}
None => 0.0, // First update, no acceleration applied
};
// Update the last update time
self.last_t = Some(t);
// Get acceleration
let acceleration = if target_speed > self.last_speed {
// We are accelerating
self.max_acceleration
} else if target_speed < self.last_speed {
// We are decelerating
self.min_acceleration
} else {
0.0
};
let speed_change = acceleration * dt;
let new_speed = self.last_speed + speed_change;
// Prevent overshooting the target speed
let new_speed = if acceleration > 0.0 {
// Limit speed when accelerating
new_speed.min(target_speed)
} else if acceleration < 0.0 {
// Limit speed when decelerating
new_speed.max(target_speed)
} else {
new_speed
};
// Apply speed limits
let new_speed = self.apply_speed_limits(new_speed);
self.last_speed = new_speed;
new_speed
}
pub fn reset(&mut self, initial_speed: f64) {
self.last_speed = initial_speed;
self.last_t = None; // Reset the last update time
}
pub fn set_max_acceleration(&mut self, acceleration: f64) {
self.max_acceleration = acceleration;
}
fn apply_speed_limits(&self, speed: f64) -> f64 {
let mut limited_speed = speed;
if let Some(min) = self.min_speed {
limited_speed = limited_speed.max(min);
}
if let Some(max) = self.max_speed {
limited_speed = limited_speed.min(max);
}
limited_speed
}
pub fn get_min_speed(&self) -> Option<f64> {
self.min_speed
}
pub fn get_max_speed(&self) -> Option<f64> {
self.max_speed
}
pub fn set_min_speed(&mut self, min_speed: Option<f64>) {
self.min_speed = min_speed;
}
pub fn set_max_speed(&mut self, max_speed: Option<f64>) {
self.max_speed = max_speed;
}
pub fn set_min_acceleration(&mut self, deceleration: f64) {
self.min_acceleration = deceleration;
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use std::time::Duration;
const EPSILON: f64 = 0.001;
// Custom function to create a "future" instant for testing
fn future_instant(now: Instant, seconds: f64) -> Instant {
now + Duration::from_secs_f64(seconds)
}
#[test]
fn test_initialization() {
let controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 5.0);
assert_relative_eq!(controller.max_acceleration, 10.0, epsilon = EPSILON);
assert_relative_eq!(controller.min_acceleration, -15.0, epsilon = EPSILON);
assert_relative_eq!(controller.last_speed, 5.0, epsilon = EPSILON);
assert!(controller.last_t.is_none());
}
#[test]
fn test_first_update() {
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 5.0);
let now = Instant::now();
let speed = controller.update(5.0, now);
assert_relative_eq!(speed, 5.0, epsilon = EPSILON);
assert_relative_eq!(controller.last_speed, 5.0, epsilon = EPSILON);
assert_eq!(controller.last_t.unwrap(), now);
}
#[test]
fn test_acceleration() {
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 0.0);
let t1 = Instant::now();
controller.update(0.0, t1); // Initialize last_t
// Use 0.1 seconds as our simulated time difference
let dt = 0.1;
let t2 = future_instant(t1, dt);
let expected_speed = (10.0 * dt).min(5.0); // Should accelerate by max_acceleration * dt, capped at target
let actual_speed = controller.update(5.0, t2);
assert!(actual_speed > 0.0);
assert!(actual_speed <= 5.0);
assert_relative_eq!(actual_speed, expected_speed, epsilon = EPSILON);
}
#[test]
fn test_deceleration() {
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 10.0);
let t1 = Instant::now();
controller.update(10.0, t1); // Initialize last_t
// Use 0.1 seconds as our simulated time difference
let dt = 0.1;
let t2 = future_instant(t1, dt);
let expected_speed = (10.0 + (-15.0 * dt)).max(5.0); // Should decelerate by max_deceleration * dt, floored at target
let actual_speed = controller.update(5.0, t2);
assert!(actual_speed < 10.0);
assert!(actual_speed >= 5.0);
assert_relative_eq!(actual_speed, expected_speed, epsilon = EPSILON);
}
#[test]
fn test_constant_speed() {
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 7.0);
let t1 = Instant::now();
controller.update(7.0, t1); // Initialize last_t
let t2 = future_instant(t1, 0.1);
let speed = controller.update(7.0, t2);
assert_relative_eq!(speed, 7.0, epsilon = EPSILON);
}
#[test]
fn test_acceleration_limit() {
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 0.0);
let t1 = Instant::now();
controller.update(0.0, t1); // Initialize last_t
let dt = 0.1;
let t2 = future_instant(t1, dt);
let max_possible_change = 10.0 * dt;
// Target is far away, should be limited by max_acceleration
let actual_speed = controller.update(100.0, t2);
assert_relative_eq!(actual_speed, max_possible_change, epsilon = EPSILON);
}
#[test]
fn test_deceleration_limit() {
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 20.0);
let t1 = Instant::now();
controller.update(20.0, t1); // Initialize last_t
let dt = 0.1;
let t2 = future_instant(t1, dt);
let max_possible_change = -15.0 * dt;
// Target is far away, should be limited by max_deceleration
let actual_speed = controller.update(0.0, t2);
assert_relative_eq!(actual_speed, 20.0 + max_possible_change, epsilon = EPSILON);
}
#[test]
fn test_zero_time_delta() {
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 5.0);
let now = Instant::now();
controller.update(5.0, now); // Initialize last_t
// Update again with the same time stamp
let speed = controller.update(10.0, now);
// With zero time delta, speed shouldn't change
assert_relative_eq!(speed, 5.0, epsilon = EPSILON);
}
#[test]
fn test_acceleration_capped_at_target() {
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 100.0, 3.0);
let t1 = Instant::now();
controller.update(3.0, t1); // Initialize last_t
// With a high acceleration of 100.0 and dt of 0.1,
// we could go up by 10.0 units, from 3.0 to 13.0
// But target is only 5.0, so we should cap at 5.0
let dt = 0.1;
let t2 = future_instant(t1, dt);
let speed = controller.update(5.0, t2);
// Speed should be capped at target_speed (5.0), not 13.0
assert_relative_eq!(speed, 5.0, epsilon = EPSILON);
}
#[test]
fn test_deceleration_capped_at_target() {
let mut controller = AccelerationSpeedController::new(None, None, -100.0, 10.0, 8.0);
let t1 = Instant::now();
controller.update(8.0, t1); // Initialize last_t
// With a high deceleration of -100.0 and dt of 0.1,
// we could go down by 10.0 units, from 8.0 to -2.0
// But target is 5.0, so we should cap at 5.0
let dt = 0.1;
let t2 = future_instant(t1, dt);
let speed = controller.update(5.0, t2);
// Speed should be capped at target_speed (5.0), not -2.0
assert_relative_eq!(speed, 5.0, epsilon = EPSILON);
}
#[test]
fn test_exact_acceleration_to_target() {
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 20.0, 3.0);
let t1 = Instant::now();
controller.update(3.0, t1); // Initialize last_t
// With acceleration of 20.0 and dt of 0.1,
// we would go up exactly 2.0 units, from 3.0 to 5.0
// Target is 5.0, so we should hit it exactly
let dt = 0.1;
let t2 = future_instant(t1, dt);
let speed = controller.update(5.0, t2);
assert_relative_eq!(speed, 5.0, epsilon = EPSILON);
}
#[test]
fn test_exact_deceleration_to_target() {
let mut controller = AccelerationSpeedController::new(None, None, -30.0, 10.0, 8.0);
let t1 = Instant::now();
controller.update(8.0, t1); // Initialize last_t
// With deceleration of -30.0 and dt of 0.1,
// we would go down exactly 3.0 units, from 8.0 to 5.0
// Target is 5.0, so we should hit it exactly
let dt = 0.1;
let t2 = future_instant(t1, dt);
let speed = controller.update(5.0, t2);
assert_relative_eq!(speed, 5.0, epsilon = EPSILON);
}
#[test]
fn test_speed_limits_initialization() {
let controller =
AccelerationSpeedController::new(Some(-50.0), Some(100.0), -10.0, 20.0, 5.0);
assert_eq!(controller.get_min_speed(), Some(-50.0));
assert_eq!(controller.get_max_speed(), Some(100.0));
}
#[test]
fn test_max_speed_limit() {
let mut controller = AccelerationSpeedController::new(None, Some(50.0), -10.0, 100.0, 0.0);
let t1 = Instant::now();
controller.update(0.0, t1); // Initialize last_t
let dt = 1.0; // 1 second
let t2 = future_instant(t1, dt);
// Try to reach 80.0, but should be limited to 50.0
let speed = controller.update(80.0, t2);
assert_eq!(speed, 50.0);
}
#[test]
fn test_min_speed_limit() {
let mut controller = AccelerationSpeedController::new(Some(-30.0), None, -100.0, 10.0, 0.0);
let t1 = Instant::now();
controller.update(0.0, t1); // Initialize last_t
let dt = 1.0; // 1 second
let t2 = future_instant(t1, dt);
// Try to reach -50.0, but should be limited to -30.0
let speed = controller.update(-50.0, t2);
assert_eq!(speed, -30.0);
}
#[test]
fn test_speed_within_limits() {
let mut controller =
AccelerationSpeedController::new(Some(-50.0), Some(50.0), -20.0, 20.0, 0.0);
let t1 = Instant::now();
controller.update(0.0, t1); // Initialize last_t
let dt = 0.5; // 0.5 seconds
let t2 = future_instant(t1, dt);
// Target 10.0, with acceleration 20.0, should reach 10.0 in 0.5s
let speed = controller.update(10.0, t2);
assert_relative_eq!(speed, 10.0, epsilon = EPSILON);
}
#[test]
fn test_speed_limit_setters() {
let mut controller = AccelerationSpeedController::new(None, None, -15.0, 10.0, 5.0);
// Initially no limits
assert_eq!(controller.get_min_speed(), None);
assert_eq!(controller.get_max_speed(), None);
// Set limits
controller.set_min_speed(Some(-100.0));
controller.set_max_speed(Some(100.0));
assert_eq!(controller.get_min_speed(), Some(-100.0));
assert_eq!(controller.get_max_speed(), Some(100.0));
// Remove limits
controller.set_min_speed(None);
controller.set_max_speed(None);
assert_eq!(controller.get_min_speed(), None);
assert_eq!(controller.get_max_speed(), None);
}
#[test]
fn test_new_simple_constructor() {
let controller = AccelerationSpeedController::new_simple(20.0, 5.0);
// Check that acceleration values are symmetric
assert_eq!(controller.min_acceleration, -20.0);
assert_eq!(controller.max_acceleration, 20.0);
assert_eq!(controller.last_speed, 5.0);
// Check that speed limits are None
assert_eq!(controller.get_min_speed(), None);
assert_eq!(controller.get_max_speed(), None);
}
}
@@ -0,0 +1,91 @@
use std::time::Instant;
use uom::si::{
angular_acceleration::radian_per_second_squared,
angular_velocity::radian_per_second,
f64::{AngularAcceleration, AngularVelocity},
};
use super::acceleration_speed_controller::AccelerationSpeedController;
/// [`LinearAngularAccelerationController`] wraps [`LinearAccelerationController`]
/// to handle angular velocities and accelerations.
#[derive(Debug)]
pub struct AngularAccelerationSpeedController {
pub controller: AccelerationSpeedController,
}
impl AngularAccelerationSpeedController {
pub fn new(
min_speed: Option<AngularVelocity>,
max_speed: Option<AngularVelocity>,
min_acceleration: AngularAcceleration,
max_acceleration: AngularAcceleration,
initial_speed: AngularVelocity,
) -> Self {
Self {
controller: AccelerationSpeedController::new(
min_speed.map(|s| s.get::<radian_per_second>()),
max_speed.map(|s| s.get::<radian_per_second>()),
min_acceleration.get::<radian_per_second_squared>(),
max_acceleration.get::<radian_per_second_squared>(),
initial_speed.get::<radian_per_second>(),
),
}
}
/// Creates a new angular acceleration speed controller with simplified parameters.
/// Sets min_acceleration to -max_acceleration for symmetric behavior.
/// No speed limits are applied.
pub fn new_simple(max_acceleration: AngularAcceleration, initial_speed: AngularVelocity) -> Self {
Self::new(
None, // min_speed
None, // max_speed
-max_acceleration, // min_acceleration (deceleration)
max_acceleration, // max_acceleration
initial_speed,
)
}
pub fn update(&mut self, target_speed: AngularVelocity, t: Instant) -> AngularVelocity {
let target_speed = target_speed.get::<radian_per_second>();
let new_speed = self.controller.update(target_speed, t);
return AngularVelocity::new::<radian_per_second>(new_speed);
}
pub fn reset(&mut self, initial_speed: AngularVelocity) {
let initial_speed = initial_speed.get::<radian_per_second>();
self.controller.reset(initial_speed);
}
pub fn set_max_acceleration(&mut self, acceleration: AngularAcceleration) {
self.controller
.set_max_acceleration(acceleration.get::<radian_per_second_squared>());
}
pub fn set_min_acceleration(&mut self, deceleration: AngularAcceleration) {
self.controller
.set_min_acceleration(deceleration.get::<radian_per_second_squared>());
}
pub fn get_min_speed(&self) -> Option<AngularVelocity> {
self.controller
.get_min_speed()
.map(|s| AngularVelocity::new::<radian_per_second>(s))
}
pub fn get_max_speed(&self) -> Option<AngularVelocity> {
self.controller
.get_max_speed()
.map(|s| AngularVelocity::new::<radian_per_second>(s))
}
pub fn set_min_speed(&mut self, min_speed: Option<AngularVelocity>) {
self.controller
.set_min_speed(min_speed.map(|s| s.get::<radian_per_second>()));
}
pub fn set_max_speed(&mut self, max_speed: Option<AngularVelocity>) {
self.controller
.set_max_speed(max_speed.map(|s| s.get::<radian_per_second>()));
}
}
@@ -0,0 +1,83 @@
use std::time::Instant;
use uom::si::{
acceleration::meter_per_second_squared,
f64::{Acceleration, Velocity},
velocity::meter_per_second,
};
use super::acceleration_speed_controller::AccelerationSpeedController;
/// [`LinearAccelerationController`] wraps [`LinearF64AccelerationController`]
/// to handle linear velocities and accelerations.
#[derive(Debug)]
pub struct LinearAccelerationLimitingController {
pub controller: AccelerationSpeedController,
}
impl LinearAccelerationLimitingController {
pub fn new(
min_speed: Option<Velocity>,
max_speed: Option<Velocity>,
acceleration: Acceleration,
deceleration: Acceleration,
initial_speed: Velocity,
) -> Self {
Self {
controller: AccelerationSpeedController::new(
min_speed.map(|s| s.get::<meter_per_second>()),
max_speed.map(|s| s.get::<meter_per_second>()),
acceleration.get::<meter_per_second_squared>(),
deceleration.get::<meter_per_second_squared>(),
initial_speed.get::<meter_per_second>(),
),
}
}
/// Creates a new linear acceleration speed controller with simplified parameters.
/// Sets deceleration to -acceleration for symmetric behavior.
/// No speed limits are applied.
pub fn new_simple(acceleration: Acceleration, initial_speed: Velocity) -> Self {
Self::new(
None, // min_speed
None, // max_speed
acceleration, // acceleration
-acceleration, // deceleration (negative for deceleration)
initial_speed,
)
}
pub fn update(&mut self, target_speed: Velocity, t: Instant) -> Velocity {
let target_speed = target_speed.get::<meter_per_second>();
let new_speed = self.controller.update(target_speed, t);
return Velocity::new::<meter_per_second>(new_speed);
}
pub fn reset(&mut self, initial_speed: Velocity) {
let initial_speed = initial_speed.get::<meter_per_second>();
self.controller.reset(initial_speed);
}
pub fn set_acceleration(&mut self, acceleration: Acceleration) {
self.controller
.set_max_acceleration(acceleration.get::<meter_per_second_squared>());
}
pub fn set_deceleration(&mut self, deceleration: Acceleration) {
self.controller
.set_min_acceleration(deceleration.get::<meter_per_second_squared>());
}
pub fn get_min_speed(&self) -> Option<Velocity> {
self.controller
.get_min_speed()
.map(|s| Velocity::new::<meter_per_second>(s))
}
pub fn get_max_speed(&self) -> Option<Velocity> {
self.controller
.get_max_speed()
.map(|s| Velocity::new::<meter_per_second>(s))
}
pub fn set_min_speed(&mut self, min_speed: Option<Velocity>) {
self.controller
.set_min_speed(min_speed.map(|s| s.get::<meter_per_second>()));
}
pub fn set_max_speed(&mut self, max_speed: Option<Velocity>) {
self.controller
.set_max_speed(max_speed.map(|s| s.get::<meter_per_second>()));
}
}
@@ -0,0 +1,3 @@
pub mod acceleration_speed_controller;
pub mod angular_acceleration_speed_controller;
pub mod linear_acceleration_speed_controller;
+3
View File
@@ -1 +1,4 @@
pub mod first_degree_motion;
pub mod pid;
pub mod second_degree_motion;
pub mod timeagnostic_pid;
+19
View File
@@ -34,6 +34,25 @@ impl PidController {
}
}
pub fn configure(&mut self, ki: f64, kp: f64, kd: f64) {
self.reset();
self.kp = kp;
self.ki = ki;
self.kd = kd;
}
pub fn get_kp(&self) -> f64 {
self.kp
}
pub fn get_ki(&self) -> f64 {
self.ki
}
pub fn get_kd(&self) -> f64 {
self.kd
}
pub fn update(&mut self, error: f64, t: Instant) -> f64 {
let signal = match self.last {
// First update
@@ -0,0 +1,223 @@
use std::time::Instant;
use uom::si::{
angle::radian,
angular_acceleration::radian_per_second_squared,
angular_velocity::radian_per_second,
f64::{Angle, AngularAcceleration, AngularVelocity},
};
use super::acceleration_position_controller::{
AccelerationPositionController, MotionControllerError,
};
/// Angular Acceleration Position Controller with proper physical units
#[derive(Debug)]
pub struct AngularAccelerationPositionController {
controller: AccelerationPositionController,
last_update: Option<Instant>,
}
impl AngularAccelerationPositionController {
/// Create a new angular position controller with acceleration limits
pub fn new(
min_position: Option<Angle>,
max_position: Option<Angle>,
min_speed: AngularVelocity,
max_speed: AngularVelocity,
min_acceleration: AngularAcceleration,
max_acceleration: AngularAcceleration,
) -> Self {
Self {
controller: AccelerationPositionController::new(
min_speed.get::<radian_per_second>(), // min_speed
max_speed.get::<radian_per_second>(), // max_speed
min_acceleration.get::<radian_per_second_squared>(), // min_acceleration
max_acceleration.get::<radian_per_second_squared>(), // max_acceleration
min_position.map(|angle| angle.get::<radian>()), // min_position
max_position.map(|angle| angle.get::<radian>()), // max_position
1e-6, // position_tolerance
1e-6, // speed_tolerance
)
.expect("Failed to create AccelerationPositionController"),
last_update: None,
}
}
/// Create a new angular position controller with simple symmetric limits
///
/// This is a convenience constructor that creates symmetric limits:
/// - Position limits: [-position, +position] (if position is Some)
/// - Speed limits: [-speed, +speed]
/// - Acceleration limits: [-acceleration, +acceleration]
/// - Default tolerances: 1e-6 for both position and speed
///
/// # Parameters (ordered: position, speed, acceleration)
/// - `position`: Optional maximum position magnitude (None for no limits, Some(x) creates [-x, +x])
/// - `speed`: Maximum angular velocity magnitude (creates limits [-speed, +speed])
/// - `acceleration`: Maximum angular acceleration magnitude (creates limits [-acceleration, +acceleration])
pub fn new_simple(
position: Option<Angle>,
speed: AngularVelocity,
acceleration: AngularAcceleration,
) -> Self {
Self {
controller: AccelerationPositionController::new_simple(
position.map(|angle| angle.get::<radian>()),
speed.get::<radian_per_second>(),
acceleration.get::<radian_per_second_squared>(),
)
.expect("Failed to create AccelerationPositionController"),
last_update: None,
}
}
/// Update the controller with a new target angle
pub fn update(&mut self, target_angle: Angle, t: Instant) -> Angle {
// Calculate dt from the last update
let dt = if let Some(last_t) = self.last_update {
t.duration_since(last_t).as_secs_f64()
} else {
0.0 // First update, no time has passed
};
self.last_update = Some(t);
// Update controller with raw angle values
let result = self
.controller
.update(target_angle.get::<radian>(), dt)
.expect("Failed to update AccelerationPositionController");
Angle::new::<radian>(result)
}
/// Get the current angle position
pub fn get_position(&self) -> Angle {
Angle::new::<radian>(self.controller.get_position())
}
/// Get the target angle
pub fn get_target_position(&self) -> Angle {
Angle::new::<radian>(self.controller.get_target_position())
}
/// Get the minimum angle position limit
pub fn get_min_position(&self) -> Option<Angle> {
self.controller
.get_min_position()
.map(|angle| Angle::new::<radian>(angle))
}
/// Get the maximum angle position limit
pub fn get_max_position(&self) -> Option<Angle> {
self.controller
.get_max_position()
.map(|angle| Angle::new::<radian>(angle))
}
/// Set the minimum angle position limit
pub fn set_min_position(
&mut self,
min_position: Option<Angle>,
) -> Result<(), MotionControllerError> {
self.controller
.set_min_position(min_position.map(|angle| angle.get::<radian>()))
}
/// Set the maximum angle position limit
pub fn set_max_position(
&mut self,
max_position: Option<Angle>,
) -> Result<(), MotionControllerError> {
self.controller
.set_max_position(max_position.map(|angle| angle.get::<radian>()))
}
/// Get the current angular velocity
pub fn get_speed(&self) -> AngularVelocity {
AngularVelocity::new::<radian_per_second>(self.controller.get_speed())
}
/// Set the minimum angular velocity
pub fn set_min_speed(
&mut self,
min_speed: AngularVelocity,
) -> Result<(), MotionControllerError> {
self.controller
.set_min_speed(min_speed.get::<radian_per_second>())
}
/// Set the maximum angular velocity
pub fn set_max_speed(
&mut self,
max_speed: AngularVelocity,
) -> Result<(), MotionControllerError> {
self.controller
.set_max_speed(max_speed.get::<radian_per_second>())
}
/// Get the current angular acceleration
pub fn get_acceleration(&self) -> AngularAcceleration {
AngularAcceleration::new::<radian_per_second_squared>(self.controller.get_acceleration())
}
/// Set the minimum angular acceleration
pub fn set_min_acceleration(
&mut self,
min_acceleration: AngularAcceleration,
) -> Result<(), MotionControllerError> {
self.controller
.set_min_acceleration(min_acceleration.get::<radian_per_second_squared>())
}
/// Set the maximum angular acceleration
pub fn set_max_acceleration(
&mut self,
max_acceleration: AngularAcceleration,
) -> Result<(), MotionControllerError> {
self.controller
.set_max_acceleration(max_acceleration.get::<radian_per_second_squared>())
}
/// Resets the controller to a specific angular position
///
/// This method completely reinitializes the controller state, setting:
/// - Current angular position to the specified value
/// - Target angular position to the specified value (stops any ongoing motion)
/// - Current angular velocity to 0
/// - Current angular acceleration to 0
/// - Motion phase to Idle
/// - Clears any motion planning state and timing information
///
/// Use this method when you need to teleport the controlled object to a new angular position
/// or when recovering from errors/emergency stops.
///
/// # Arguments
/// * `position` - The new angular position to reset to
///
/// # Returns
/// Returns `Ok(())` on success, or `Err(MotionControllerError)` if the position is outside configured limits.
///
/// # Errors
/// - `InvalidPositionLimits`: If the specified position is outside the configured min/max position limits
///
/// # Example
/// ```ignore
/// use control_core::controllers::second_degree_motion::angular_acceleration_position_controller::AngularAccelerationPositionController;
/// use uom::si::f64::Angle;
/// use uom::si::angle::degree;
///
/// let mut controller = AngularAccelerationPositionController::new_simple(
/// Some(Angle::new::<degree>(180.0)),
/// // ... other parameters
/// )?;
///
/// // Reset to 45 degrees
/// let new_position = Angle::new::<degree>(45.0);
/// controller.reset(new_position)?;
/// ```
pub fn reset(&mut self, position: Angle) -> Result<(), MotionControllerError> {
self.controller.reset(position.get::<radian>())?;
self.last_update = None;
Ok(())
}
}
@@ -0,0 +1,327 @@
use std::time::Instant;
use uom::si::{
angular_acceleration::radian_per_second_squared,
angular_jerk::radian_per_second_cubed,
angular_velocity::radian_per_second,
f64::{AngularAcceleration, AngularJerk, AngularVelocity},
};
use super::acceleration_position_controller::MotionControllerError;
use super::jerk_speed_controller::JerkSpeedController;
/// Angular Jerk Speed Controller with proper physical units
///
/// This controller provides angular motion control with proper SI units for rotational systems.
/// It wraps the core JerkSpeedController and provides unit-typed interfaces for angular velocity
/// (AngularVelocity), angular acceleration (AngularAcceleration), and angular jerk (AngularJerk).
///
/// The controller manages smooth rotational speed profiles with configurable limits and tolerances,
/// ensuring that angular velocity, acceleration, and jerk constraints are respected during motion.
/// This is particularly useful for motor control, servo systems, and any rotational machinery
/// requiring smooth speed transitions.
///
/// # Example
/// ```ignore
/// use uom::si::{
/// angular_velocity::revolution_per_minute,
/// angular_acceleration::revolution_per_minute_per_second,
/// angular_jerk::revolution_per_minute_per_second_squared,
/// f64::{AngularVelocity, AngularAcceleration, AngularJerk}
/// };
///
/// let max_speed = AngularVelocity::new::<revolution_per_minute>(3000.0);
/// let max_accel = AngularAcceleration::new::<revolution_per_minute_per_second>(1000.0);
/// let max_jerk = AngularJerk::new::<revolution_per_minute_per_second_squared>(500.0);
///
/// let controller = AngularJerkSpeedController::new_simple(
/// Some(max_speed),
/// max_accel,
/// max_jerk,
/// );
/// ```
#[derive(Debug)]
pub struct AngularJerkSpeedController {
controller: JerkSpeedController,
last_update: Option<Instant>,
}
impl AngularJerkSpeedController {
/// Create a new angular speed controller with jerk limits
///
/// This constructor allows full control over all angular velocity, acceleration, and jerk limits.
/// Use this when you need asymmetric limits or fine-grained control over the rotational motion profile.
///
/// # Parameters (ordered: speed, acceleration, jerk)
/// * `min_speed` - Optional minimum allowed angular velocity (None for no limit)
/// * `max_speed` - Optional maximum allowed angular velocity (None for no limit)
/// * `min_acceleration` - Minimum angular acceleration (typically negative for deceleration)
/// * `max_acceleration` - Maximum angular acceleration (typically positive)
/// * `min_jerk` - Minimum angular jerk for decreasing acceleration (typically negative)
/// * `max_jerk` - Maximum angular jerk for increasing acceleration (typically positive)
///
/// # Returns
/// A new AngularJerkSpeedController instance
///
/// # Example
/// ```ignore
/// use uom::si::{
/// angular_velocity::revolution_per_minute,
/// angular_acceleration::revolution_per_minute_per_second,
/// angular_jerk::revolution_per_minute_per_second_squared,
/// f64::{AngularVelocity, AngularAcceleration, AngularJerk}
/// };
///
/// // Create controller with asymmetric limits
/// let controller = AngularJerkSpeedController::new(
/// Some(AngularVelocity::new::<revolution_per_minute>(-1000.0)), // Min: -1000 RPM
/// Some(AngularVelocity::new::<revolution_per_minute>(3000.0)), // Max: +3000 RPM
/// AngularAcceleration::new::<revolution_per_minute_per_second>(-800.0), // Min accel
/// AngularAcceleration::new::<revolution_per_minute_per_second>(1200.0), // Max accel
/// AngularJerk::new::<revolution_per_minute_per_second_squared>(-400.0), // Min jerk
/// AngularJerk::new::<revolution_per_minute_per_second_squared>(600.0), // Max jerk
/// );
/// ```
pub fn new(
min_speed: Option<AngularVelocity>,
max_speed: Option<AngularVelocity>,
min_acceleration: AngularAcceleration,
max_acceleration: AngularAcceleration,
min_jerk: AngularJerk,
max_jerk: AngularJerk,
) -> Self {
Self {
controller: JerkSpeedController::new(
min_speed.map(|speed| speed.get::<radian_per_second>()),
max_speed.map(|speed| speed.get::<radian_per_second>()),
min_acceleration.get::<radian_per_second_squared>(),
max_acceleration.get::<radian_per_second_squared>(),
min_jerk.get::<radian_per_second_cubed>(),
max_jerk.get::<radian_per_second_cubed>(),
),
last_update: None,
}
}
/// Create a new simple angular jerk speed controller with symmetric limits
///
/// This is a convenience constructor that creates symmetric limits around zero for all parameters.
/// This is the most common use case and simplifies controller setup for typical rotational applications.
///
/// The created limits are:
/// - Angular velocity limits: [-speed, +speed] (if speed is Some), no limits if None
/// - Angular acceleration limits: [-acceleration, +acceleration]
/// - Angular jerk limits: [-jerk, +jerk]
/// - Default tolerances: 1e-6 for both position and speed
///
/// # Parameters (ordered: speed, acceleration, jerk)
/// * `speed` - Optional maximum angular velocity magnitude (None for no limits, Some(x) creates [-x, +x])
/// * `acceleration` - Maximum angular acceleration magnitude (creates limits [-acceleration, +acceleration])
/// * `jerk` - Maximum angular jerk magnitude (creates limits [-jerk, +jerk])
///
/// # Returns
/// A new AngularJerkSpeedController instance
///
/// # Panics
/// Panics if acceleration or jerk values are negative or zero
///
/// # Example
/// ```ignore
/// use uom::si::{
/// angular_velocity::revolution_per_minute,
/// angular_acceleration::revolution_per_minute_per_second,
/// angular_jerk::revolution_per_minute_per_second_squared,
/// f64::{AngularVelocity, AngularAcceleration, AngularJerk}
/// };
///
/// // Create controller with symmetric limits
/// let max_speed = AngularVelocity::new::<revolution_per_minute>(2000.0);
/// let max_accel = AngularAcceleration::new::<revolution_per_minute_per_second>(1000.0);
/// let max_jerk = AngularJerk::new::<revolution_per_minute_per_second_squared>(500.0);
///
/// let controller = AngularJerkSpeedController::new_simple(
/// Some(max_speed), // Angular velocity limits: [-2000, +2000] RPM
/// max_accel, // Angular acceleration limits: [-1000, +1000] RPM/s
/// max_jerk, // Angular jerk limits: [-500, +500] RPM/s²
/// );
///
/// // Create controller with no speed limits
/// let unlimited_controller = AngularJerkSpeedController::new_simple(
/// None, // No angular velocity limits
/// max_accel, // Angular acceleration limits: [-1000, +1000] RPM/s
/// max_jerk, // Angular jerk limits: [-500, +500] RPM/s²
/// );
/// ```
pub fn new_simple(
speed: Option<AngularVelocity>,
acceleration: AngularAcceleration,
jerk: AngularJerk,
) -> Self {
let controller = JerkSpeedController::new_simple(
speed.map(|s| s.get::<radian_per_second>()),
acceleration.get::<radian_per_second_squared>(),
jerk.get::<radian_per_second_cubed>(),
);
Self {
controller,
last_update: None,
}
}
/// Update the controller with a new target angular velocity
///
/// Calculates the next angular velocity in the motion profile based on the target and elapsed time.
/// The controller automatically manages angular acceleration and jerk to reach the target angular
/// velocity while respecting all configured limits.
///
/// # Parameters
/// * `target_speed` - The desired target angular velocity
/// * `t` - Current timestamp for calculating time delta
///
/// # Returns
/// The current angular velocity after applying the motion profile for this time step
///
/// # Example
/// ```ignore
/// use std::time::Instant;
/// use uom::si::{angular_velocity::revolution_per_minute, f64::AngularVelocity};
///
/// let mut controller = AngularJerkSpeedController::new_simple(/* ... */);
/// let target = AngularVelocity::new::<revolution_per_minute>(1500.0);
///
/// let current_speed = controller.update(target, Instant::now());
/// ```
pub fn update(&mut self, target_speed: AngularVelocity, t: Instant) -> AngularVelocity {
// Calculate dt from the last update
let dt = if let Some(last_t) = self.last_update {
t.duration_since(last_t).as_secs_f64()
} else {
0.0 // First update, no time has passed
};
self.last_update = Some(t);
// Convert target to raw value, update controller, and convert result back to AngularVelocity
let target_raw = target_speed.get::<radian_per_second>();
let speed_raw = self.controller.update(target_raw, dt);
AngularVelocity::new::<radian_per_second>(speed_raw)
}
/// Get the current angular velocity
///
/// Returns the controller's current angular velocity in the motion profile.
///
/// # Returns
/// Current angular velocity as an AngularVelocity value
pub fn get_speed(&self) -> AngularVelocity {
AngularVelocity::new::<radian_per_second>(self.controller.get_speed())
}
/// Get the target angular velocity
///
/// Returns the current target angular velocity that the controller is trying to reach.
///
/// # Returns
/// Target angular velocity as an AngularVelocity value
pub fn get_target_speed(&self) -> AngularVelocity {
AngularVelocity::new::<radian_per_second>(self.controller.get_target_speed())
}
/// Get the minimum angular velocity limit
///
/// Returns the minimum angular velocity constraint, if one is set.
///
/// # Returns
/// Minimum angular velocity limit as an Option<AngularVelocity>, None if no limit is set
pub fn get_min_speed(&self) -> Option<AngularVelocity> {
self.controller
.get_min_speed()
.map(|speed| AngularVelocity::new::<radian_per_second>(speed))
}
/// Get the maximum angular velocity limit
///
/// Returns the maximum angular velocity constraint, if one is set.
///
/// # Returns
/// Maximum angular velocity limit as an Option<AngularVelocity>, None if no limit is set
pub fn get_max_speed(&self) -> Option<AngularVelocity> {
self.controller
.get_max_speed()
.map(|speed| AngularVelocity::new::<radian_per_second>(speed))
}
/// Set the minimum angular velocity limit
pub fn set_min_speed(
&mut self,
min_speed: Option<AngularVelocity>,
) -> Result<(), MotionControllerError> {
self.controller
.set_min_speed(min_speed.map(|speed| speed.get::<radian_per_second>()))
}
/// Set the maximum angular velocity limit
pub fn set_max_speed(
&mut self,
max_speed: Option<AngularVelocity>,
) -> Result<(), MotionControllerError> {
self.controller
.set_max_speed(max_speed.map(|speed| speed.get::<radian_per_second>()))
}
/// Get the current angular acceleration
pub fn get_acceleration(&self) -> AngularAcceleration {
AngularAcceleration::new::<radian_per_second_squared>(self.controller.get_acceleration())
}
/// Set the minimum angular acceleration
pub fn set_min_acceleration(
&mut self,
min_acceleration: AngularAcceleration,
) -> Result<(), MotionControllerError> {
self.controller
.set_min_acceleration(min_acceleration.get::<radian_per_second_squared>())
}
/// Set the maximum angular acceleration
pub fn set_max_acceleration(
&mut self,
max_acceleration: AngularAcceleration,
) -> Result<(), MotionControllerError> {
self.controller
.set_max_acceleration(max_acceleration.get::<radian_per_second_squared>())
}
/// Get the current angular jerk
pub fn get_jerk(&self) -> AngularJerk {
AngularJerk::new::<radian_per_second_cubed>(self.controller.get_jerk())
}
/// Set the minimum angular jerk
pub fn set_min_jerk(&mut self, min_jerk: AngularJerk) -> Result<(), MotionControllerError> {
self.controller
.set_min_jerk(min_jerk.get::<radian_per_second_cubed>())
}
/// Set the maximum angular jerk
pub fn set_max_jerk(&mut self, max_jerk: AngularJerk) -> Result<(), MotionControllerError> {
self.controller
.set_max_jerk(max_jerk.get::<radian_per_second_cubed>())
}
/// Reset the controller to a new angular velocity and acceleration
///
/// This resets all internal state including:
/// - Current angular velocity to the provided value
/// - Current angular acceleration to 0
/// - Current angular jerk to 0
/// - Target angular velocity to the current velocity
///
/// # Parameters
/// - `velocity`: The new current angular velocity
pub fn reset(&mut self, velocity: AngularVelocity) -> Result<(), MotionControllerError> {
self.controller.reset(velocity.get::<radian_per_second>())?;
self.last_update = None;
Ok(())
}
}
@@ -0,0 +1,378 @@
use super::acceleration_position_controller::{
AccelerationPositionController, MotionControllerError,
};
/// Controller for speed-based motion with jerk limits.
///
/// This controller manages motion profiles where speed is the primary controlled variable,
/// with constraints on acceleration (rate of change of speed) and jerk (rate of change of acceleration).
/// It wraps an AccelerationPositionController but remaps the parameters to represent one higher
/// degree of derivation in the motion hierarchy.
///
/// The control hierarchy is:
/// - **Speed**: The primary controlled variable (analogous to position in position control)
/// - **Acceleration**: First derivative of speed (analogous to velocity in position control)
/// - **Jerk**: Second derivative of speed, rate of change of acceleration (analogous to acceleration in position control)
///
/// This controller is particularly useful for applications where you need to control rotational
/// or linear speed with smooth acceleration profiles, such as motor control, conveyor systems,
/// or any application requiring smooth speed transitions.
///
/// # Example
/// ```ignore
/// // Create a controller for speed control with jerk limiting
/// let controller = JerkSpeedController::new_simple(
/// Some(100.0), // Max speed ±100 units/s
/// 50.0, // Max acceleration ±50 units/s²
/// 25.0, // Max jerk ±25 units/s³
/// );
///
/// // Update towards target speed
/// let current_speed = controller.update(80.0, 0.01); // Target 80 units/s, dt=10ms
/// ```
#[derive(Debug)]
pub struct JerkSpeedController {
base_controller: AccelerationPositionController,
}
impl JerkSpeedController {
/// Create a new speed controller with the given constraints
///
/// This constructor allows full control over all speed, acceleration, and jerk limits.
/// Use this when you need asymmetric limits or fine-grained control over the motion profile.
///
/// # Parameters (ordered: speed, acceleration, jerk)
/// * `min_speed` - Optional minimum allowed speed (None for no limit)
/// * `max_speed` - Optional maximum allowed speed (None for no limit)
/// * `min_acceleration` - Minimum acceleration (typically negative for deceleration)
/// * `max_acceleration` - Maximum acceleration (typically positive)
/// * `min_jerk` - Minimum jerk for decreasing acceleration (typically negative)
/// * `max_jerk` - Maximum jerk for increasing acceleration (typically positive)
///
/// # Returns
/// A new JerkSpeedController instance
///
/// # Panics
/// Panics if the underlying AccelerationPositionController cannot be created with the given parameters
///
/// # Example
/// ```ignore
/// // Create controller with asymmetric limits
/// let controller = JerkSpeedController::new(
/// Some(-50.0), // Min speed: -50 units/s
/// Some(100.0), // Max speed: +100 units/s
/// -30.0, // Min acceleration: -30 units/s²
/// 40.0, // Max acceleration: +40 units/s²
/// -20.0, // Min jerk: -20 units/s³
/// 25.0, // Max jerk: +25 units/s³
/// );
/// ```
pub fn new(
min_speed: Option<f64>,
max_speed: Option<f64>,
min_acceleration: f64,
max_acceleration: f64,
min_jerk: f64,
max_jerk: f64,
) -> Self {
// Create the base controller with renamed parameters
let base_controller = AccelerationPositionController::new(
min_jerk, // min_speed in the base controller
max_jerk, // max_speed in the base controller
min_acceleration, // min_acceleration in the base controller
max_acceleration, // max_acceleration in the base controller
min_speed, // min_position in the base controller
max_speed, // max_position in the base controller
1e-6, // position_tolerance
1e-6, // speed_tolerance
)
.expect("Failed to create AccelerationPositionController");
JerkSpeedController { base_controller }
}
/// Create a new speed controller with simple symmetric limits
///
/// This is a convenience constructor that creates symmetric limits around zero for all parameters.
/// This is the most common use case and simplifies controller setup for typical applications.
///
/// The created limits are:
/// - Speed limits: [-speed, +speed] (if speed is Some), no limits if None
/// - Acceleration limits: [-acceleration, +acceleration]
/// - Jerk limits: [-jerk, +jerk]
/// - Default tolerances: 1e-6 for both position and speed
///
/// # Parameters (ordered: speed, acceleration, jerk)
/// * `speed` - Optional maximum speed magnitude (None for no limits, Some(x) creates [-x, +x])
/// * `acceleration` - Maximum acceleration magnitude (creates limits [-acceleration, +acceleration])
/// * `jerk` - Maximum jerk magnitude (creates limits [-jerk, +jerk])
///
/// # Returns
/// A new JerkSpeedController instance
///
/// # Panics
/// Panics if acceleration or jerk values are negative or zero
///
/// # Example
/// ```ignore
/// // Create controller with symmetric limits
/// let controller = JerkSpeedController::new_simple(
/// Some(100.0), // Speed limits: [-100, +100] units/s
/// 50.0, // Acceleration limits: [-50, +50] units/s²
/// 25.0, // Jerk limits: [-25, +25] units/s³
/// );
///
/// // Create controller with no speed limits
/// let unlimited_controller = JerkSpeedController::new_simple(
/// None, // No speed limits
/// 30.0, // Acceleration limits: [-30, +30] units/s²
/// 15.0, // Jerk limits: [-15, +15] units/s³
/// );
/// ```
pub fn new_simple(speed: Option<f64>, acceleration: f64, jerk: f64) -> Self {
if acceleration <= 0.0 || jerk <= 0.0 {
panic!("Acceleration and jerk must be positive values");
}
Self::new(
speed.map(|s| -s), // min_speed
speed, // max_speed
-acceleration, // min_acceleration
acceleration, // max_acceleration
-jerk, // min_jerk
jerk, // max_jerk
)
}
/// Update the controller state based on the target speed and time step
///
/// Calculates the next speed in the motion profile based on the target speed and elapsed time.
/// The controller automatically manages acceleration and jerk to reach the target speed
/// while respecting all configured limits.
///
/// # Parameters
/// * `target_speed` - The desired target speed to reach
/// * `dt` - Time step in seconds since the last update
///
/// # Returns
/// The current speed after applying the motion profile for this time step
///
/// # Panics
/// Panics if the underlying controller update fails (should not happen in normal operation)
///
/// # Example
/// ```ignore
/// let mut controller = JerkSpeedController::new_simple(Some(100.0), 50.0, 25.0);
///
/// // Control loop
/// loop {
/// let current_speed = controller.update(target_speed, 0.01); // 10ms update cycle
/// // Use current_speed for your application
/// }
/// ```
pub fn update(&mut self, target_speed: f64, dt: f64) -> f64 {
// Call the base controller's update method with the target speed
self.base_controller
.update(target_speed, dt)
.expect("Failed to update controller")
}
/// Get the current speed
///
/// Returns the controller's current speed in the motion profile.
///
/// # Returns
/// Current speed as a floating-point value
pub fn get_speed(&self) -> f64 {
self.base_controller.get_position()
}
/// Get the current acceleration
///
/// Returns the controller's current acceleration (rate of change of speed).
///
/// # Returns
/// Current acceleration as a floating-point value
pub fn get_acceleration(&self) -> f64 {
self.base_controller.get_speed()
}
/// Get the current jerk
///
/// Returns the controller's current jerk (rate of change of acceleration).
///
/// # Returns
/// Current jerk as a floating-point value
pub fn get_jerk(&self) -> f64 {
self.base_controller.get_acceleration()
}
/// Get the target speed
///
/// Returns the current target speed that the controller is trying to reach.
///
/// # Returns
/// Target speed as a floating-point value
pub fn get_target_speed(&self) -> f64 {
self.base_controller.get_target_position()
}
/// Get the minimum speed limit
///
/// Returns the minimum speed constraint, if one is set.
///
/// # Returns
/// Minimum speed limit as an Option<f64>, None if no limit is set
pub fn get_min_speed(&self) -> Option<f64> {
self.base_controller.get_min_position()
}
/// Get the maximum speed limit
///
/// Returns the maximum speed constraint, if one is set.
///
/// # Returns
/// Maximum speed limit as an Option<f64>, None if no limit is set
pub fn get_max_speed(&self) -> Option<f64> {
self.base_controller.get_max_position()
}
/// Set the minimum speed limit
///
/// Updates the minimum speed constraint for the controller.
///
/// # Parameters
/// * `min_speed` - New minimum speed limit (None to remove limit)
///
/// # Returns
/// Result indicating success or failure of the operation
///
/// # Errors
/// Returns MotionControllerError if the new limit would be invalid
/// (e.g., greater than the maximum speed limit)
pub fn set_min_speed(&mut self, min_speed: Option<f64>) -> Result<(), MotionControllerError> {
self.base_controller.set_min_position(min_speed)
}
/// Set the maximum speed limit
///
/// Updates the maximum speed constraint for the controller.
///
/// # Parameters
/// * `max_speed` - New maximum speed limit (None to remove limit)
///
/// # Returns
/// Result indicating success or failure of the operation
///
/// # Errors
/// Returns MotionControllerError if the new limit would be invalid
/// (e.g., less than the minimum speed limit)
pub fn set_max_speed(&mut self, max_speed: Option<f64>) -> Result<(), MotionControllerError> {
self.base_controller.set_max_position(max_speed)
}
/// Set the minimum acceleration
///
/// Updates the minimum acceleration constraint for the controller.
///
/// # Parameters
/// * `min_acceleration` - New minimum acceleration limit (typically negative for deceleration)
///
/// # Returns
/// Result indicating success or failure of the operation
///
/// # Errors
/// Returns MotionControllerError if the new limit would be invalid
/// (e.g., greater than the maximum acceleration limit)
pub fn set_min_acceleration(
&mut self,
min_acceleration: f64,
) -> Result<(), MotionControllerError> {
self.base_controller.set_min_speed(min_acceleration)
}
/// Set the maximum acceleration
///
/// Updates the maximum acceleration constraint for the controller.
///
/// # Parameters
/// * `max_acceleration` - New maximum acceleration limit (typically positive)
///
/// # Returns
/// Result indicating success or failure of the operation
///
/// # Errors
/// Returns MotionControllerError if the new limit would be invalid
/// (e.g., less than the minimum acceleration limit or negative/zero value)
pub fn set_max_acceleration(
&mut self,
max_acceleration: f64,
) -> Result<(), MotionControllerError> {
self.base_controller.set_max_speed(max_acceleration)
}
/// Set the minimum jerk
///
/// Updates the minimum jerk constraint for the controller.
///
/// # Parameters
/// * `min_jerk` - New minimum jerk limit (typically negative for decreasing acceleration)
///
/// # Returns
/// Result indicating success or failure of the operation
///
/// # Errors
/// Returns MotionControllerError if the new limit would be invalid
/// (e.g., greater than the maximum jerk limit)
pub fn set_min_jerk(&mut self, min_jerk: f64) -> Result<(), MotionControllerError> {
self.base_controller.set_min_acceleration(min_jerk)
}
/// Set the maximum jerk
///
/// Updates the maximum jerk constraint for the controller.
///
/// # Parameters
/// * `max_jerk` - New maximum jerk limit (typically positive for increasing acceleration)
///
/// # Returns
/// Result indicating success or failure of the operation
///
/// # Errors
/// Returns MotionControllerError if the new limit would be invalid
/// (e.g., less than the minimum jerk limit or negative/zero value)
pub fn set_max_jerk(&mut self, max_jerk: f64) -> Result<(), MotionControllerError> {
self.base_controller.set_max_acceleration(max_jerk)
}
/// Reset the controller to a new speed and acceleration
///
/// This resets all internal state of the controller to start fresh from a new state.
/// The controller will reset its speed, clear any accumulated motion profile state,
/// and set the target speed to the current speed.
///
/// # Parameters
/// * `speed` - The new current speed to reset to
///
/// # Returns
/// Result indicating success or failure of the reset operation
///
/// # Errors
/// Returns MotionControllerError if the speed is outside the configured limits
///
/// # Example
/// ```ignore
/// let mut controller = JerkSpeedController::new_simple(Some(100.0), 50.0, 25.0);
///
/// // Reset to zero speed with zero acceleration
/// controller.reset(0.0)?;
///
/// // Reset to specific speed
/// controller.reset(25.0)?;
/// ```
pub fn reset(&mut self, speed: f64) -> Result<(), MotionControllerError> {
// Reset to the speed position (treating speed as position in the base controller)
self.base_controller.reset(speed)?;
Ok(())
}
}
@@ -0,0 +1,393 @@
use std::time::Instant;
use uom::si::{
acceleration::meter_per_second_squared,
f64::{Acceleration, Length, Velocity},
length::meter,
velocity::meter_per_second,
};
use super::acceleration_position_controller::{
AccelerationPositionController, MotionControllerError,
};
/// Linear Acceleration Position Controller with proper physical units
///
/// This controller provides linear motion control with proper SI units (meters, m/s, m/s²).
/// It wraps the core AccelerationPositionController and provides unit-typed interfaces
/// for position (Length), velocity (Velocity), and acceleration (Acceleration).
///
/// The controller manages smooth motion profiles with configurable limits and tolerances,
/// ensuring that position, speed, and acceleration constraints are respected during motion.
///
/// # Example
/// ```ignore
/// use uom::si::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
/// use uom::si::f64::{Length, Velocity, Acceleration};
///
/// let position_limit = Length::new::<meter>(10.0);
/// let max_speed = Velocity::new::<meter_per_second>(2.0);
/// let max_acceleration = Acceleration::new::<meter_per_second_squared>(5.0);
///
/// let controller = LinearAccelerationPositionController::new_simple(
/// Some(position_limit),
/// max_speed,
/// max_acceleration,
/// ).unwrap();
/// ```
#[derive(Debug)]
pub struct LinearAccelerationPositionController {
controller: AccelerationPositionController,
last_update: Option<Instant>,
}
impl LinearAccelerationPositionController {
/// Create a new linear position controller with acceleration limits
///
/// # Parameters
/// * `min_position` - Minimum position limit (optional)
/// * `max_position` - Maximum position limit (optional)
/// * `min_speed` - Minimum speed limit (typically negative)
/// * `max_speed` - Maximum speed limit (typically positive)
/// * `min_acceleration` - Minimum acceleration limit (typically negative for deceleration)
/// * `max_acceleration` - Maximum acceleration limit (typically positive)
///
/// # Returns
/// A new LinearAccelerationPositionController instance
///
/// # Panics
/// Panics if the underlying AccelerationPositionController cannot be created with the given parameters
pub fn new(
min_position: Option<Length>,
max_position: Option<Length>,
min_speed: Velocity,
max_speed: Velocity,
min_acceleration: Acceleration,
max_acceleration: Acceleration,
) -> Self {
Self {
controller: AccelerationPositionController::new(
min_speed.get::<meter_per_second>(),
max_speed.get::<meter_per_second>(),
min_acceleration.get::<meter_per_second_squared>(),
max_acceleration.get::<meter_per_second_squared>(),
min_position.map(|length| length.get::<meter>()),
max_position.map(|length| length.get::<meter>()),
1e-6, // position_tolerance
1e-6, // speed_tolerance
)
.expect("Failed to create AccelerationPositionController"),
last_update: None,
}
}
/// Create a new simple linear acceleration position controller with symmetric limits
///
/// This is a convenience constructor that creates symmetric limits around zero for
/// position, speed, and acceleration. This is useful for most common use cases.
///
/// # Parameters
/// * `position` - Maximum position limit (creates [-position, +position] range if Some)
/// * `speed` - Maximum speed limit (creates [-speed, +speed] range)
/// * `acceleration` - Maximum acceleration limit (creates [-acceleration, +acceleration] range)
///
/// # Returns
/// A Result containing the new controller or a MotionControllerError
///
/// # Errors
/// Returns MotionControllerError if:
/// - Speed or acceleration values are negative or zero
/// - The underlying controller cannot be created with the calculated limits
///
/// # Example
/// ```ignore
/// use uom::si::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
/// use uom::si::f64::{Length, Velocity, Acceleration};
///
/// let position_limit = Length::new::<meter>(5.0); // Creates [-5.0, +5.0] meter range
/// let max_speed = Velocity::new::<meter_per_second>(1.0); // Creates [-1.0, +1.0] m/s range
/// let max_accel = Acceleration::new::<meter_per_second_squared>(2.0); // Creates [-2.0, +2.0] m/s² range
///
/// let controller = LinearAccelerationPositionController::new_simple(
/// Some(position_limit),
/// max_speed,
/// max_accel,
/// )?;
/// ```
pub fn new_simple(
position: Option<Length>,
speed: Velocity,
acceleration: Acceleration,
) -> Result<Self, MotionControllerError> {
let controller = AccelerationPositionController::new_simple(
position.map(|p| p.get::<meter>()),
speed.get::<meter_per_second>(),
acceleration.get::<meter_per_second_squared>(),
)?;
Ok(Self {
controller,
last_update: None,
})
}
/// Update the controller with a new target position
///
/// Calculates the next position in the motion profile based on the target position
/// and elapsed time since the last update. The controller automatically manages
/// acceleration and velocity to reach the target while respecting all limits.
///
/// # Parameters
/// * `target_position` - The desired target position
/// * `t` - Current timestamp for calculating time delta
///
/// # Returns
/// The current position after applying the motion profile for this time step
///
/// # Panics
/// Panics if the underlying controller update fails (should not happen in normal operation)
///
/// # Example
/// ```ignore
/// use std::time::Instant;
/// use uom::si::{length::meter, f64::Length};
///
/// let mut controller = LinearAccelerationPositionController::new_simple(
/// Some(Length::new::<meter>(10.0)),
/// // ... other parameters
/// )?;
///
/// let target = Length::new::<meter>(5.0);
/// let current_position = controller.update(target, Instant::now());
/// ```
pub fn update(&mut self, target_position: Length, t: Instant) -> Length {
// Calculate dt from the last update
let dt = if let Some(last_t) = self.last_update {
t.duration_since(last_t).as_secs_f64()
} else {
0.0 // First update, no time has passed
};
self.last_update = Some(t);
// Convert target to raw value, update controller, and convert result back to Length
let target_raw = target_position.get::<meter>();
let position_raw = self
.controller
.update(target_raw, dt)
.expect("Failed to update controller");
Length::new::<meter>(position_raw)
}
/// Get the current position
///
/// Returns the controller's current position in the motion profile.
///
/// # Returns
/// Current position as a Length value
pub fn get_position(&self) -> Length {
Length::new::<meter>(self.controller.get_position())
}
/// Get the target position
///
/// Returns the current target position that the controller is trying to reach.
///
/// # Returns
/// Target position as a Length value
pub fn get_target_position(&self) -> Length {
Length::new::<meter>(self.controller.get_target_position())
}
/// Get the minimum position limit
///
/// Returns the minimum position constraint, if one is set.
///
/// # Returns
/// Minimum position limit as an Option<Length>, None if no limit is set
pub fn get_min_position(&self) -> Option<Length> {
self.controller
.get_min_position()
.map(|length| Length::new::<meter>(length))
}
/// Get the maximum position limit
///
/// Returns the maximum position constraint, if one is set.
///
/// # Returns
/// Maximum position limit as an Option<Length>, None if no limit is set
pub fn get_max_position(&self) -> Option<Length> {
self.controller
.get_max_position()
.map(|length| Length::new::<meter>(length))
}
/// Set the minimum position limit
///
/// Updates the minimum position constraint for the controller.
///
/// # Parameters
/// * `min_position` - New minimum position limit (None to remove limit)
///
/// # Returns
/// Result indicating success or failure of the operation
///
/// # Errors
/// Returns MotionControllerError if the new limit would be invalid
/// (e.g., greater than the maximum position limit)
pub fn set_min_position(
&mut self,
min_position: Option<Length>,
) -> Result<(), MotionControllerError> {
self.controller
.set_min_position(min_position.map(|length| length.get::<meter>()))
}
/// Set the maximum position limit
///
/// Updates the maximum position constraint for the controller.
///
/// # Parameters
/// * `max_position` - New maximum position limit (None to remove limit)
///
/// # Returns
/// Result indicating success or failure of the operation
///
/// # Errors
/// Returns MotionControllerError if the new limit would be invalid
/// (e.g., less than the minimum position limit)
pub fn set_max_position(
&mut self,
max_position: Option<Length>,
) -> Result<(), MotionControllerError> {
self.controller
.set_max_position(max_position.map(|length| length.get::<meter>()))
}
/// Get the current speed
///
/// Returns the controller's current velocity in the motion profile.
///
/// # Returns
/// Current velocity as a Velocity value
pub fn get_speed(&self) -> Velocity {
Velocity::new::<meter_per_second>(self.controller.get_speed())
}
/// Set the minimum speed
///
/// Updates the minimum velocity constraint for the controller.
///
/// # Parameters
/// * `min_speed` - New minimum speed limit (typically negative for reverse motion)
///
/// # Returns
/// Result indicating success or failure of the operation
///
/// # Errors
/// Returns MotionControllerError if the new limit would be invalid
/// (e.g., greater than the maximum speed limit)
pub fn set_min_speed(&mut self, min_speed: Velocity) -> Result<(), MotionControllerError> {
self.controller
.set_min_speed(min_speed.get::<meter_per_second>())
}
/// Set the maximum speed
///
/// Updates the maximum velocity constraint for the controller.
///
/// # Parameters
/// * `max_speed` - New maximum speed limit (typically positive)
///
/// # Returns
/// Result indicating success or failure of the operation
///
/// # Errors
/// Returns MotionControllerError if the new limit would be invalid
/// (e.g., less than the minimum speed limit or negative/zero value)
pub fn set_max_speed(&mut self, max_speed: Velocity) -> Result<(), MotionControllerError> {
self.controller
.set_max_speed(max_speed.get::<meter_per_second>())
}
/// Get the current acceleration
///
/// Returns the controller's current acceleration in the motion profile.
///
/// # Returns
/// Current acceleration as an Acceleration value
pub fn get_acceleration(&self) -> Acceleration {
Acceleration::new::<meter_per_second_squared>(self.controller.get_acceleration())
}
/// Set the minimum acceleration
///
/// Updates the minimum acceleration constraint for the controller.
///
/// # Parameters
/// * `min_acceleration` - New minimum acceleration limit (typically negative for deceleration)
///
/// # Returns
/// Result indicating success or failure of the operation
///
/// # Errors
/// Returns MotionControllerError if the new limit would be invalid
/// (e.g., greater than the maximum acceleration limit)
pub fn set_min_acceleration(
&mut self,
min_acceleration: Acceleration,
) -> Result<(), MotionControllerError> {
self.controller
.set_min_acceleration(min_acceleration.get::<meter_per_second_squared>())
}
/// Set the maximum acceleration
///
/// Updates the maximum acceleration constraint for the controller.
///
/// # Parameters
/// * `max_acceleration` - New maximum acceleration limit (typically positive)
///
/// # Returns
/// Result indicating success or failure of the operation
///
/// # Errors
/// Returns MotionControllerError if the new limit would be invalid
/// (e.g., less than the minimum acceleration limit or negative/zero value)
pub fn set_max_acceleration(
&mut self,
max_acceleration: Acceleration,
) -> Result<(), MotionControllerError> {
self.controller
.set_max_acceleration(max_acceleration.get::<meter_per_second_squared>())
}
/// Reset the controller to a new position and velocity
///
/// This resets all internal state of the controller to start fresh from a new state.
/// The controller will reset its position, clear any accumulated motion profile state,
/// and set the target position to the current position.
///
/// # Parameters
/// * `position` - The new current position to reset to
///
/// # Returns
/// Result indicating success or failure of the reset operation
///
/// # Errors
/// Returns MotionControllerError if the position is outside the configured limits
///
/// # Example
/// ```ignore
/// use uom::si::{length::meter, velocity::meter_per_second, f64::{Length, Velocity}};
///
/// let new_position = Length::new::<meter>(0.0);
///
/// controller.reset(new_position)?;
/// ```
pub fn reset(&mut self, position: Length) -> Result<(), MotionControllerError> {
self.controller.reset(position.get::<meter>())?;
self.last_update = None;
Ok(())
}
}
@@ -0,0 +1,281 @@
use std::time::Instant;
use uom::si::{
acceleration::meter_per_second_squared,
f64::{Acceleration, Jerk, Velocity},
jerk::meter_per_second_cubed,
velocity::meter_per_second,
};
use super::acceleration_position_controller::MotionControllerError;
use super::jerk_speed_controller::JerkSpeedController;
/// Linear Jerk Speed Controller with proper physical units
///
/// This controller provides linear motion control with proper SI units for translational systems.
/// It wraps the core JerkSpeedController and provides unit-typed interfaces for velocity (Velocity),
/// acceleration (Acceleration), and jerk (Jerk).
///
/// The controller manages smooth linear speed profiles with configurable limits and tolerances,
/// ensuring that velocity, acceleration, and jerk constraints are respected during motion.
/// This is particularly useful for linear actuators, conveyor systems, CNC machines, and any
/// translational machinery requiring smooth speed transitions.
///
/// # Example
/// ```ignore
/// use uom::si::{
/// velocity::meter_per_second,
/// acceleration::meter_per_second_squared,
/// jerk::meter_per_second_cubed,
/// f64::{Velocity, Acceleration, Jerk}
/// };
///
/// let max_speed = Velocity::new::<meter_per_second>(5.0);
/// let max_accel = Acceleration::new::<meter_per_second_squared>(2.0);
/// let max_jerk = Jerk::new::<meter_per_second_cubed>(1.0);
///
/// let controller = LinearJerkSpeedController::new_simple(
/// Some(max_speed),
/// max_accel,
/// max_jerk,
/// );
/// ```
#[derive(Debug)]
pub struct LinearJerkSpeedController {
controller: JerkSpeedController,
last_update: Option<Instant>,
}
impl LinearJerkSpeedController {
/// Create a new linear speed controller with jerk limits
///
/// This constructor allows full control over all velocity, acceleration, and jerk limits.
/// Use this when you need asymmetric limits or fine-grained control over the linear motion profile.
///
/// # Parameters (ordered: speed, acceleration, jerk)
/// * `min_speed` - Optional minimum allowed velocity (None for no limit)
/// * `max_speed` - Optional maximum allowed velocity (None for no limit)
/// * `min_acceleration` - Minimum acceleration (typically negative for deceleration)
/// * `max_acceleration` - Maximum acceleration (typically positive)
/// * `min_jerk` - Minimum jerk for decreasing acceleration (typically negative)
/// * `max_jerk` - Maximum jerk for increasing acceleration (typically positive)
///
/// # Returns
/// A new LinearJerkSpeedController instance
///
/// # Example
/// ```ignore
/// use uom::si::{
/// velocity::meter_per_second,
/// acceleration::meter_per_second_squared,
/// jerk::meter_per_second_cubed,
/// f64::{Velocity, Acceleration, Jerk}
/// };
///
/// // Create controller with asymmetric limits
/// let controller = LinearJerkSpeedController::new(
/// Some(Velocity::new::<meter_per_second>(-2.0)), // Min: -2 m/s
/// Some(Velocity::new::<meter_per_second>(5.0)), // Max: +5 m/s
/// Acceleration::new::<meter_per_second_squared>(-3.0), // Min accel
/// Acceleration::new::<meter_per_second_squared>(4.0), // Max accel
/// Jerk::new::<meter_per_second_cubed>(-2.0), // Min jerk
/// Jerk::new::<meter_per_second_cubed>(3.0), // Max jerk
/// );
/// ```
pub fn new(
min_speed: Option<Velocity>,
max_speed: Option<Velocity>,
min_acceleration: Acceleration,
max_acceleration: Acceleration,
min_jerk: Jerk,
max_jerk: Jerk,
) -> Self {
Self {
controller: JerkSpeedController::new(
min_speed.map(|speed| speed.get::<meter_per_second>()),
max_speed.map(|speed| speed.get::<meter_per_second>()),
min_acceleration.get::<meter_per_second_squared>(),
max_acceleration.get::<meter_per_second_squared>(),
min_jerk.get::<meter_per_second_cubed>(),
max_jerk.get::<meter_per_second_cubed>(),
),
last_update: None,
}
}
/// Create a new simple linear jerk speed controller with symmetric limits
///
/// This is a convenience constructor that creates symmetric limits around zero for all parameters.
/// This is the most common use case and simplifies controller setup for typical linear applications.
///
/// The created limits are:
/// - Velocity limits: [-speed, +speed] (if speed is Some), no limits if None
/// - Acceleration limits: [-acceleration, +acceleration]
/// - Jerk limits: [-jerk, +jerk]
/// - Default tolerances: 1e-6 for both position and speed
///
/// # Parameters (ordered: speed, acceleration, jerk)
/// * `speed` - Optional maximum velocity magnitude (None for no limits, Some(x) creates [-x, +x])
/// * `acceleration` - Maximum acceleration magnitude (creates limits [-acceleration, +acceleration])
/// * `jerk` - Maximum jerk magnitude (creates limits [-jerk, +jerk])
///
/// # Returns
/// A new LinearJerkSpeedController instance
///
/// # Panics
/// Panics if acceleration or jerk values are negative or zero
///
/// # Example
/// ```ignore
/// use uom::si::{
/// velocity::meter_per_second,
/// acceleration::meter_per_second_squared,
/// jerk::meter_per_second_cubed,
/// f64::{Velocity, Acceleration, Jerk}
/// };
///
/// // Create controller with symmetric limits
/// let max_speed = Velocity::new::<meter_per_second>(3.0);
/// let max_accel = Acceleration::new::<meter_per_second_squared>(2.0);
/// let max_jerk = Jerk::new::<meter_per_second_cubed>(1.5);
///
/// let controller = LinearJerkSpeedController::new_simple(
/// Some(max_speed), // Velocity limits: [-3, +3] m/s
/// max_accel, // Acceleration limits: [-2, +2] m/s²
/// max_jerk, // Jerk limits: [-1.5, +1.5] m/s³
/// );
///
/// // Create controller with no speed limits
/// let unlimited_controller = LinearJerkSpeedController::new_simple(
/// None, // No velocity limits
/// max_accel, // Acceleration limits: [-2, +2] m/s²
/// max_jerk, // Jerk limits: [-1.5, +1.5] m/s³
/// );
/// ```
pub fn new_simple(speed: Option<Velocity>, acceleration: Acceleration, jerk: Jerk) -> Self {
let controller = JerkSpeedController::new_simple(
speed.map(|s| s.get::<meter_per_second>()),
acceleration.get::<meter_per_second_squared>(),
jerk.get::<meter_per_second_cubed>(),
);
Self {
controller,
last_update: None,
}
}
/// Update the controller with a new target speed
pub fn update(&mut self, target_speed: Velocity, t: Instant) -> Velocity {
// Calculate dt from the last update
let dt = if let Some(last_t) = self.last_update {
t.duration_since(last_t).as_secs_f64()
} else {
0.0 // First update, no time has passed
};
self.last_update = Some(t);
// Convert target to raw value, update controller, and convert result back to Velocity
let target_raw = target_speed.get::<meter_per_second>();
let speed_raw = self.controller.update(target_raw, dt);
Velocity::new::<meter_per_second>(speed_raw)
}
/// Get the current speed
pub fn get_speed(&self) -> Velocity {
Velocity::new::<meter_per_second>(self.controller.get_speed())
}
/// Get the target speed
pub fn get_target_speed(&self) -> Velocity {
Velocity::new::<meter_per_second>(self.controller.get_target_speed())
}
/// Get the minimum velocity limit
pub fn get_min_speed(&self) -> Option<Velocity> {
self.controller
.get_min_speed()
.map(|speed| Velocity::new::<meter_per_second>(speed))
}
/// Get the maximum velocity limit
pub fn get_max_speed(&self) -> Option<Velocity> {
self.controller
.get_max_speed()
.map(|speed| Velocity::new::<meter_per_second>(speed))
}
/// Set the minimum velocity limit
pub fn set_min_speed(
&mut self,
min_speed: Option<Velocity>,
) -> Result<(), MotionControllerError> {
self.controller
.set_min_speed(min_speed.map(|speed| speed.get::<meter_per_second>()))
}
/// Set the maximum velocity limit
pub fn set_max_speed(
&mut self,
max_speed: Option<Velocity>,
) -> Result<(), MotionControllerError> {
self.controller
.set_max_speed(max_speed.map(|speed| speed.get::<meter_per_second>()))
}
/// Get the current acceleration
pub fn get_acceleration(&self) -> Acceleration {
Acceleration::new::<meter_per_second_squared>(self.controller.get_acceleration())
}
/// Set the minimum acceleration
pub fn set_min_acceleration(
&mut self,
min_acceleration: Acceleration,
) -> Result<(), MotionControllerError> {
self.controller
.set_min_acceleration(min_acceleration.get::<meter_per_second_squared>())
}
/// Set the maximum acceleration
pub fn set_max_acceleration(
&mut self,
max_acceleration: Acceleration,
) -> Result<(), MotionControllerError> {
self.controller
.set_max_acceleration(max_acceleration.get::<meter_per_second_squared>())
}
/// Get the current jerk
pub fn get_jerk(&self) -> Jerk {
Jerk::new::<meter_per_second_cubed>(self.controller.get_jerk())
}
/// Set the minimum jerk
pub fn set_min_jerk(&mut self, min_jerk: Jerk) -> Result<(), MotionControllerError> {
self.controller
.set_min_jerk(min_jerk.get::<meter_per_second_cubed>())
}
/// Set the maximum jerk
pub fn set_max_jerk(&mut self, max_jerk: Jerk) -> Result<(), MotionControllerError> {
self.controller
.set_max_jerk(max_jerk.get::<meter_per_second_cubed>())
}
/// Reset the controller to a new velocity and acceleration
///
/// This resets all internal state including:
/// - Current velocity to the provided value
/// - Current acceleration to 0
/// - Current jerk to 0
/// - Target velocity to the current velocity
///
/// # Parameters
/// - `velocity`: The new current velocity
pub fn reset(&mut self, velocity: Velocity) -> Result<(), MotionControllerError> {
self.controller.reset(velocity.get::<meter_per_second>())?;
self.last_update = None;
Ok(())
}
}
@@ -0,0 +1,6 @@
pub mod acceleration_position_controller;
pub mod angular_acceleration_position_controller;
pub mod angular_jerk_speed_controller;
pub mod jerk_speed_controller;
pub mod linear_acceleration_position_controller;
pub mod linear_jerk_speed_controller;
@@ -0,0 +1,92 @@
use std::time::Instant;
#[derive(Debug)]
pub struct TimeagnosticPidController {
// Params
/// Proportional gain
kp: f64,
/// Integral gain
ki: f64,
/// Derivative gain
kd: f64,
// State
/// Proportional error
ep: f64,
/// Integral error
ei: f64,
/// Derivative error
ed: f64,
last: Option<Instant>,
}
impl TimeagnosticPidController {
pub fn new(kp: f64, ki: f64, kd: f64) -> Self {
Self {
kp,
ki,
kd,
ep: 0.0,
ei: 0.0,
ed: 0.0,
last: None,
}
}
pub fn update(&mut self, error: f64, t: Instant) -> f64 {
let signal = match self.last {
// First update
None => {
// Calculate error
let ep = error;
// Calculate signal
let signal = self.kp * ep;
// Set values
self.ep = ep;
self.ei = 0.0;
self.ed = 0.0;
self.last = Some(t);
signal
}
// Subsequent updates
Some(last) => {
// Calculate the time delta in seconds
let dt = t.duration_since(last).as_secs_f64();
// Calculate errors
let ep = error;
let ei = self.ei + ep * dt;
let ed = (ep - self.ep) / dt;
// Make factors timeagnostic
let kp = self.kp / dt;
let ki = self.ki / dt;
let kd = self.kd / dt;
// Calculate signal
let signal = kp * ep + ki * ei + kd * ed;
// Set values
self.ep = ep;
self.ei = ei;
self.ed = ed;
self.last = Some(t);
signal
}
};
signal
}
pub fn reset(&mut self) {
self.ep = 0.0;
self.ei = 0.0;
self.ed = 0.0;
self.last = None;
}
}
@@ -0,0 +1,315 @@
use std::f32::consts::PI;
use uom::si::angle::radian;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AngleConverter {
pub flip_x: bool, // true to flip X-axis (mirror horizontally)
pub flip_y: bool, // true to flip Y-axis (mirror vertically)
pub is_cw: bool, // true for clockwise, false for counter-clockwise
}
impl AngleConverter {
/// Create a custom coordinate system
pub fn new(flip_x: bool, flip_y: bool, is_cw: bool) -> Self {
Self {
flip_x,
flip_y,
is_cw,
}
}
/// Standard mathematical coordinate system (CCW positive, 0° at positive X)
pub fn mathematical() -> Self {
Self::new(false, false, false)
}
/// Screen/graphics coordinate system (CW positive, 0° at positive X, Y-flipped)
pub fn screen() -> Self {
Self::new(false, true, false)
}
/// System where 0° points up, clockwise positive
pub fn y_up_cw() -> Self {
Self::new(false, false, true)
}
/// System where 0° points down, counter-clockwise positive
pub fn y_down_ccw() -> Self {
Self::new(false, true, true)
}
/// System where 0° points left, clockwise positive
pub fn x_left_cw() -> Self {
Self::new(true, false, false)
}
/// System where 0° points right, counter-clockwise positive (same as mathematical)
pub fn x_right_ccw() -> Self {
Self::mathematical()
}
/// System where 0° points up, counter-clockwise positive
pub fn y_up_ccw() -> Self {
Self::new(false, false, false)
}
/// System where 0° points down, clockwise positive
pub fn y_down_cw() -> Self {
Self::new(false, true, false)
}
/// System where 0° points left, counter-clockwise positive
pub fn x_left_ccw() -> Self {
Self::new(true, false, true)
}
/// Convert angle from mathematical system to this system (f32)
pub fn degrees_encode(&self, math_angle_degrees: f32) -> f32 {
let normalized_input = self.normalize_angle(math_angle_degrees);
let mut result = normalized_input;
// Apply axis flips
match (self.flip_x, self.flip_y) {
(false, false) => {} // No change
(true, false) => result = 180.0 - result, // X-flip: mirror across Y-axis
(false, true) => result = -result, // Y-flip: mirror across X-axis
(true, true) => result = 180.0 + result, // Both flips: 180° rotation
}
// Handle rotation direction
if self.is_cw {
result = -result;
}
self.normalize_angle(result)
}
/// Convert angle from this system to mathematical system (f32)
pub fn degrees_decode(&self, system_angle_degrees: f32) -> f32 {
let normalized_input = self.normalize_angle(system_angle_degrees);
let mut result = normalized_input;
// Handle rotation direction
if self.is_cw {
result = -result;
}
// Apply inverse axis flips
match (self.flip_x, self.flip_y) {
(false, false) => {} // No change
(true, false) => result = 180.0 - result, // X-flip: mirror across Y-axis
(false, true) => result = -result, // Y-flip: mirror across X-axis
(true, true) => result = result - 180.0, // Both flips: inverse 180° rotation
}
self.normalize_angle(result)
}
/// Convert angle in radians from mathematical system to this system (f32)
pub fn radians_encode(&self, math_angle_radians: f32) -> f32 {
let degrees = math_angle_radians * 180.0 / PI;
let converted_degrees = self.degrees_encode(degrees);
converted_degrees * PI / 180.0
}
/// Convert angle in radians from this system to mathematical system (f32)
pub fn radians_decode(&self, system_angle_radians: f32) -> f32 {
let degrees = system_angle_radians * 180.0 / PI;
let converted_degrees = self.degrees_decode(degrees);
converted_degrees * PI / 180.0
}
/// Convert angle from mathematical system to this system (f64)
pub fn degrees_encode_f64(&self, math_angle_degrees: f64) -> f64 {
let normalized_input = self.normalize_angle_f64(math_angle_degrees);
let mut result = normalized_input;
// Apply axis flips
match (self.flip_x, self.flip_y) {
(false, false) => {} // No change
(true, false) => result = 180.0 - result, // X-flip: mirror across Y-axis
(false, true) => result = -result, // Y-flip: mirror across X-axis
(true, true) => result = 180.0 + result, // Both flips: 180° rotation
}
// Handle rotation direction
if self.is_cw {
result = -result;
}
self.normalize_angle_f64(result)
}
/// Convert angle from this system to mathematical system (f64)
pub fn degrees_decode_f64(&self, system_angle_degrees: f64) -> f64 {
let normalized_input = self.normalize_angle_f64(system_angle_degrees);
let mut result = normalized_input;
// Handle rotation direction
if self.is_cw {
result = -result;
}
// Apply inverse axis flips
match (self.flip_x, self.flip_y) {
(false, false) => {} // No change
(true, false) => result = 180.0 - result, // X-flip: mirror across Y-axis
(false, true) => result = -result, // Y-flip: mirror across X-axis
(true, true) => result = result - 180.0, // Both flips: inverse 180° rotation
}
self.normalize_angle_f64(result)
}
/// Convert angle in radians from mathematical system to this system (f64)
pub fn radians_encode_f64(&self, math_angle_radians: f64) -> f64 {
let degrees = math_angle_radians * 180.0 / std::f64::consts::PI;
let converted_degrees = self.degrees_encode_f64(degrees);
converted_degrees * std::f64::consts::PI / 180.0
}
/// Convert angle in radians from this system to mathematical system (f64)
pub fn radians_decode_f64(&self, system_angle_radians: f64) -> f64 {
let degrees = system_angle_radians * 180.0 / std::f64::consts::PI;
let converted_degrees = self.degrees_decode_f64(degrees);
converted_degrees * std::f64::consts::PI / 180.0
}
/// Normalize angle to [0, 360) range (f32)
fn normalize_angle(&self, angle: f32) -> f32 {
let mut normalized = angle % 360.0;
if normalized < 0.0 {
normalized += 360.0;
}
normalized
}
/// Normalize angle to [0, 360) range (f64)
fn normalize_angle_f64(&self, angle: f64) -> f64 {
let mut normalized = angle % 360.0;
if normalized < 0.0 {
normalized += 360.0;
}
normalized
}
}
#[derive(Debug, Clone)]
pub struct AngleConverterUom {
angle_converter: AngleConverter,
}
impl AngleConverterUom {
pub fn new(angle_converter: AngleConverter) -> Self {
Self { angle_converter }
}
pub fn encode(&self, angle: uom::si::f32::Angle) -> uom::si::f32::Angle {
uom::si::f32::Angle::new::<radian>(
self.angle_converter.radians_encode(angle.get::<radian>()),
)
}
pub fn decode(&self, angle: uom::si::f32::Angle) -> uom::si::f32::Angle {
uom::si::f32::Angle::new::<radian>(
self.angle_converter.radians_decode(angle.get::<radian>()),
)
}
pub fn encode_f64(&self, angle: uom::si::f64::Angle) -> uom::si::f64::Angle {
uom::si::f64::Angle::new::<radian>(
self.angle_converter
.radians_encode_f64(angle.get::<radian>()),
)
}
pub fn decode_f64(&self, angle: uom::si::f64::Angle) -> uom::si::f64::Angle {
uom::si::f64::Angle::new::<radian>(
self.angle_converter
.radians_decode_f64(angle.get::<radian>()),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_constructors() {
let math = AngleConverter::mathematical();
assert_eq!(math.flip_x, false);
assert_eq!(math.flip_y, false);
assert_eq!(math.is_cw, false);
let screen = AngleConverter::screen();
assert_eq!(screen.flip_x, false);
assert_eq!(screen.flip_y, true);
assert_eq!(screen.is_cw, false);
}
#[test]
fn test_screen_converter_bidirectional() {
let converter = AngleConverter::screen();
// Math to screen (Y-flip only)
assert_eq!(converter.degrees_encode(0.0), 0.0); // Right stays right
assert_eq!(converter.degrees_encode(90.0), 270.0); // Up becomes down
assert_eq!(converter.degrees_encode(270.0), 90.0); // Down becomes up
assert_eq!(converter.degrees_encode(180.0), 180.0); // Left stays left
// Screen to math (should be inverse)
assert_eq!(converter.degrees_decode(0.0), 0.0); // Right stays right
assert_eq!(converter.degrees_decode(270.0), 90.0); // Down becomes up
assert_eq!(converter.degrees_decode(90.0), 270.0); // Up becomes down
assert_eq!(converter.degrees_decode(180.0), 180.0); // Left stays left
}
#[test]
fn test_x_flip_converter() {
let converter = AngleConverter::x_left_cw();
// Math to x-flipped system
assert_eq!(converter.degrees_encode(0.0), 180.0); // Right becomes left
assert_eq!(converter.degrees_encode(90.0), 90.0); // Up stays up
assert_eq!(converter.degrees_encode(180.0), 0.0); // Left becomes right
assert_eq!(converter.degrees_encode(270.0), 270.0); // Down stays down
}
#[test]
fn test_both_flips_converter() {
let converter = AngleConverter::new(true, true, false);
// Math to both-flipped system (180° rotation)
assert_eq!(converter.degrees_encode(0.0), 180.0); // Right becomes left
assert_eq!(converter.degrees_encode(90.0), 270.0); // Up becomes down
assert_eq!(converter.degrees_encode(180.0), 0.0); // Left becomes right
assert_eq!(converter.degrees_encode(270.0), 90.0); // Down becomes up
}
#[test]
fn test_radians_conversion() {
let converter = AngleConverter::screen();
// Test radians_encode
let result = converter.radians_encode(PI / 2.0); // 90° in radians
let expected = 270.0 * PI / 180.0; // 270° in radians
assert!((result - expected).abs() < 1e-6);
}
#[test]
fn test_roundtrip_conversion() {
let converter = AngleConverter::new(true, false, true); // X-flip + CW
let original = 75.0_f32;
let converted = converter.degrees_encode(original);
let roundtrip = converter.degrees_decode(converted);
assert!((original - roundtrip).abs() < 1e-6);
}
}
@@ -5,12 +5,19 @@ use uom::si::{
f64::{Angle, AngularAcceleration, AngularVelocity},
};
/// Converts between motor steps and angular measurements
///
/// This converter handles the transformation between motor steps and angular
/// measurements (angle, angular velocity, and angular acceleration) based on
/// the number of steps required for a full revolution.
#[derive(Debug, Clone)]
pub struct StepConverter {
steps_per_revolution: i16,
pub struct AngularStepConverter {
/// Number of motor steps required for one complete revolution
pub steps_per_revolution: i16,
}
impl StepConverter {
impl AngularStepConverter {
/// Create a new converter with the specified steps per revolution
pub fn new(steps_per_revolution: i16) -> Self {
Self {
steps_per_revolution,
@@ -18,38 +25,58 @@ impl StepConverter {
}
/// Convert steps to angle
///
/// Formula: angle (in revolutions) = steps / steps_per_revolution
pub fn steps_to_angle(&self, steps: f64) -> Angle {
let revolutions = steps / self.steps_per_revolution as f64;
Angle::new::<revolution>(revolutions)
}
/// Convert angle to steps
///
/// Formula: steps = angle (in revolutions) * steps_per_revolution
pub fn angle_to_steps(&self, angle: Angle) -> f64 {
let revolutions = angle.get::<revolution>();
revolutions * self.steps_per_revolution as f64
}
/// Convert steps/second to angular velocity
pub fn steps_to_angular_velocity(&self, steps: f64) -> AngularVelocity {
let revolutions_per_second = steps / self.steps_per_revolution as f64;
///
/// Formula: angular velocity (in rev/s) = steps_per_second / steps_per_revolution
pub fn steps_to_angular_velocity(&self, steps_per_second: f64) -> AngularVelocity {
let revolutions_per_second = steps_per_second / self.steps_per_revolution as f64;
AngularVelocity::new::<revolution_per_second>(revolutions_per_second)
}
/// Convert angular velocity to steps/second
///
/// Formula: steps_per_second = angular velocity (in rev/s) * steps_per_revolution
pub fn angular_velocity_to_steps(&self, angular_velocity: AngularVelocity) -> f64 {
let revolutions_per_second = angular_velocity.get::<revolution_per_second>();
revolutions_per_second * self.steps_per_revolution as f64
}
/// Convert steps/second^2 to angular acceleration
pub fn steps_to_angular_acceleration(&self, steps: f64) -> AngularAcceleration {
let revolutions_per_second_squared = steps / self.steps_per_revolution as f64;
/// Convert steps/second² to angular acceleration
///
/// Formula:
/// - revolutions_per_second² = steps_per_second² / steps_per_revolution
/// - angular acceleration (in rad/s²) = revolutions_per_second² * 2π
pub fn steps_to_angular_acceleration(
&self,
steps_per_second_squared: f64,
) -> AngularAcceleration {
let revolutions_per_second_squared =
steps_per_second_squared / self.steps_per_revolution as f64;
let radians_per_second_squared =
revolutions_per_second_squared * 2.0 * std::f64::consts::PI;
AngularAcceleration::new::<radian_per_second_squared>(radians_per_second_squared)
}
/// Convert angular acceleration to steps/second^2
/// Convert angular acceleration to steps/second²
///
/// Formula:
/// - revolutions_per_second² = angular acceleration (in rad/s²) / 2π
/// - steps_per_second² = revolutions_per_second² * steps_per_revolution
pub fn angular_acceleration_to_steps(&self, angular_acceleration: AngularAcceleration) -> f64 {
let radians_per_second_squared = angular_acceleration.get::<radian_per_second_squared>();
let revolutions_per_second_squared =
@@ -70,13 +97,13 @@ mod tests {
#[test]
fn test_new() {
let converter = StepConverter::new(200);
let converter = AngularStepConverter::new(200);
assert_eq!(converter.steps_per_revolution, 200);
}
#[test]
fn test_steps_to_angle() {
let converter = StepConverter::new(200);
let converter = AngularStepConverter::new(200);
// Test full revolution
let angle = converter.steps_to_angle(200.0);
@@ -94,14 +121,14 @@ mod tests {
assert_relative_eq!(angle.get::<degree>(), 90.0, epsilon = EPSILON);
// Test with different steps_per_revolution
let converter = StepConverter::new(400);
let converter = AngularStepConverter::new(400);
let angle = converter.steps_to_angle(100.0);
assert_relative_eq!(angle.get::<revolution>(), 0.25, epsilon = EPSILON);
}
#[test]
fn test_angle_to_steps() {
let converter = StepConverter::new(200);
let converter = AngularStepConverter::new(200);
// Test full revolution
let steps = converter.angle_to_steps(Angle::new::<revolution>(1.0));
@@ -116,14 +143,14 @@ mod tests {
assert_relative_eq!(steps, 50.0, epsilon = EPSILON);
// Test with different steps_per_revolution
let converter = StepConverter::new(400);
let converter = AngularStepConverter::new(400);
let steps = converter.angle_to_steps(Angle::new::<degree>(90.0));
assert_relative_eq!(steps, 100.0, epsilon = EPSILON);
}
#[test]
fn test_steps_to_angular_velocity() {
let converter = StepConverter::new(200);
let converter = AngularStepConverter::new(200);
// Test one revolution per second
let angular_velocity = converter.steps_to_angular_velocity(200.0);
@@ -147,7 +174,7 @@ mod tests {
);
// Test with different steps_per_revolution
let converter = StepConverter::new(400);
let converter = AngularStepConverter::new(400);
let angular_velocity = converter.steps_to_angular_velocity(200.0);
assert_relative_eq!(
angular_velocity.get::<revolution_per_second>(),
@@ -158,7 +185,7 @@ mod tests {
#[test]
fn test_angular_velocity_to_steps() {
let converter = StepConverter::new(200);
let converter = AngularStepConverter::new(200);
// Test one revolution per second
let steps =
@@ -171,7 +198,7 @@ mod tests {
assert_relative_eq!(steps, 100.0, epsilon = EPSILON);
// Test with different steps_per_revolution
let converter = StepConverter::new(400);
let converter = AngularStepConverter::new(400);
let steps =
converter.angular_velocity_to_steps(AngularVelocity::new::<revolution_per_second>(0.5));
assert_relative_eq!(steps, 200.0, epsilon = EPSILON);
@@ -179,7 +206,7 @@ mod tests {
#[test]
fn test_steps_to_angular_acceleration() {
let converter = StepConverter::new(200);
let converter = AngularStepConverter::new(200);
// Test one revolution per second squared
let angular_acceleration = converter.steps_to_angular_acceleration(200.0);
@@ -199,7 +226,7 @@ mod tests {
);
// Test with different steps_per_revolution
let converter = StepConverter::new(400);
let converter = AngularStepConverter::new(400);
let angular_acceleration = converter.steps_to_angular_acceleration(400.0);
assert_relative_eq!(
angular_acceleration.get::<radian_per_second_squared>(),
@@ -210,7 +237,7 @@ mod tests {
#[test]
fn test_angular_acceleration_to_steps() {
let converter = StepConverter::new(200);
let converter = AngularStepConverter::new(200);
// Test one revolution per second squared (2π rad/s²)
let rps2 = 2.0 * std::f64::consts::PI;
@@ -226,7 +253,7 @@ mod tests {
assert_relative_eq!(steps, 100.0, epsilon = EPSILON);
// Test with different steps_per_revolution
let converter = StepConverter::new(400);
let converter = AngularStepConverter::new(400);
let steps = converter.angular_acceleration_to_steps(AngularAcceleration::new::<
radian_per_second_squared,
>(rps2));
@@ -235,7 +262,7 @@ mod tests {
#[test]
fn test_roundtrip_conversions() {
let converter = StepConverter::new(200);
let converter = AngularStepConverter::new(200);
// Test angle roundtrip
let original_steps = 123.0;
@@ -0,0 +1,587 @@
use uom::si::{
acceleration::meter_per_second_squared,
angle::revolution,
angular_acceleration::radian_per_second_squared,
angular_velocity::revolution_per_second,
f64::{Acceleration, Angle, AngularAcceleration, AngularVelocity, Length, Velocity},
length::meter,
velocity::meter_per_second,
};
use super::angular_step_converter::AngularStepConverter;
/// Converts between linear measurements and motor steps
///
/// This converter handles the transformation between linear motion (distance, velocity,
/// acceleration) and motor steps, using the relationship between linear and rotary motion
/// through a wheel or pulley of a specific radius.
#[derive(Debug, Clone)]
pub struct LinearStepConverter {
angular_step_converter: AngularStepConverter,
radius: Length,
circumference: Length,
}
// Constructor and basic getters
impl LinearStepConverter {
/// Create a new converter from radius and steps per revolution
pub fn from_radius(steps_per_revolution: i16, radius: Length) -> Self {
let circumference =
Length::new::<meter>(2.0 * std::f64::consts::PI * radius.get::<meter>());
Self {
angular_step_converter: AngularStepConverter::new(steps_per_revolution),
radius,
circumference,
}
}
/// Create a new converter from diameter and steps per revolution
pub fn from_diameter(steps_per_revolution: i16, diameter: Length) -> Self {
let radius = diameter / 2.0;
Self::from_radius(steps_per_revolution, radius)
}
/// Create a new converter from circumference and steps per revolution
pub fn from_circumference(steps_per_revolution: i16, circumference: Length) -> Self {
let radius =
Length::new::<meter>(circumference.get::<meter>() / (2.0 * std::f64::consts::PI));
Self {
angular_step_converter: AngularStepConverter::new(steps_per_revolution),
radius,
circumference,
}
}
/// Get the radius used by the converter
pub fn radius(&self) -> Length {
self.radius
}
/// Get the diameter of the system
pub fn diameter(&self) -> Length {
self.radius * 2.0
}
/// Get the circumference of the system
pub fn circumference(&self) -> Length {
self.circumference
}
/// Get the steps per revolution
pub fn steps_per_revolution(&self) -> i16 {
self.angular_step_converter.steps_per_revolution
}
}
// Linear to/from steps conversions
impl LinearStepConverter {
/// Convert linear distance to steps
///
/// Formula: steps = (distance / circumference) * steps_per_revolution
pub fn distance_to_steps(&self, distance: Length) -> f64 {
// Convert distance to angle: angle = distance/circumference (in revolutions)
let revolutions = distance.get::<meter>() / self.circumference.get::<meter>();
let angle = Angle::new::<revolution>(revolutions);
// Convert angle to steps
self.angular_step_converter.angle_to_steps(angle)
}
/// Convert steps to linear distance
///
/// Formula: distance = (steps / steps_per_revolution) * circumference
pub fn steps_to_distance(&self, steps: f64) -> Length {
// Convert steps to angle
let angle = self.angular_step_converter.steps_to_angle(steps);
// Convert angle to distance: distance = angle * circumference
let revolutions = angle.get::<revolution>();
let distance = revolutions * self.circumference.get::<meter>();
Length::new::<meter>(distance)
}
/// Convert linear velocity to steps/second
///
/// Formula: steps/second = (velocity / circumference) * steps_per_revolution
pub fn velocity_to_steps(&self, velocity: Velocity) -> f64 {
// Convert linear velocity to angular velocity: ω = v/circumference (in rev/s)
let linear_velocity = velocity.get::<meter_per_second>();
let angular_velocity_rps = linear_velocity / self.circumference.get::<meter>();
let angular_velocity = AngularVelocity::new::<revolution_per_second>(angular_velocity_rps);
// Convert angular velocity to steps/second
self.angular_step_converter
.angular_velocity_to_steps(angular_velocity)
}
/// Convert steps/second to linear velocity
///
/// Formula: velocity = (steps_per_second / steps_per_revolution) * circumference
pub fn steps_to_velocity(&self, steps_per_second: f64) -> Velocity {
// Convert steps/second to angular velocity
let angular_velocity = self
.angular_step_converter
.steps_to_angular_velocity(steps_per_second);
// Convert angular velocity to linear velocity: v = ω * circumference
let angular_velocity_rps = angular_velocity.get::<revolution_per_second>();
let linear_velocity = angular_velocity_rps * self.circumference.get::<meter>();
Velocity::new::<meter_per_second>(linear_velocity)
}
/// Convert linear acceleration to steps/second²
///
/// Formula: steps/second² = (acceleration / radius) * (steps_per_revolution / (2π))
pub fn acceleration_to_steps(&self, acceleration: Acceleration) -> f64 {
// Convert linear acceleration to angular acceleration: α = a/r
let linear_acceleration = acceleration.get::<meter_per_second_squared>();
// Calculate angular acceleration in radians per second squared
let angular_acceleration_rad_per_s2 = linear_acceleration / self.radius.get::<meter>();
let angular_acceleration =
AngularAcceleration::new::<radian_per_second_squared>(angular_acceleration_rad_per_s2);
// Convert angular acceleration to steps/second²
self.angular_step_converter
.angular_acceleration_to_steps(angular_acceleration)
}
/// Convert steps/second² to linear acceleration
///
/// Formula: acceleration = (steps_per_second² / steps_per_revolution) * (2π) * radius
pub fn steps_to_acceleration(&self, steps_per_second_squared: f64) -> Acceleration {
// Convert steps/second² to angular acceleration
let angular_acceleration = self
.angular_step_converter
.steps_to_angular_acceleration(steps_per_second_squared);
// Convert angular acceleration to linear acceleration: a = α * r
let angular_acceleration_rad_per_s2 =
angular_acceleration.get::<radian_per_second_squared>();
let linear_acceleration = angular_acceleration_rad_per_s2 * self.radius.get::<meter>();
Acceleration::new::<meter_per_second_squared>(linear_acceleration)
}
}
// Linear to/from angular conversions
impl LinearStepConverter {
/// Convert linear distance to angle
///
/// Formula: angle (in revolutions) = distance / circumference
pub fn distance_to_angle(&self, distance: Length) -> Angle {
let revolutions = distance.get::<meter>() / self.circumference.get::<meter>();
Angle::new::<revolution>(revolutions)
}
/// Convert angle to linear distance
///
/// Formula: distance = angle (in revolutions) * circumference
pub fn angle_to_distance(&self, angle: Angle) -> Length {
let revolutions = angle.get::<revolution>();
let distance = revolutions * self.circumference.get::<meter>();
Length::new::<meter>(distance)
}
/// Convert linear velocity to angular velocity
///
/// Formula: angular velocity (in rev/s) = velocity / circumference
pub fn velocity_to_angular_velocity(&self, velocity: Velocity) -> AngularVelocity {
let linear_velocity = velocity.get::<meter_per_second>();
let angular_velocity_rps = linear_velocity / self.circumference.get::<meter>();
AngularVelocity::new::<revolution_per_second>(angular_velocity_rps)
}
/// Convert angular velocity to linear velocity
///
/// Formula: velocity = angular velocity (in rev/s) * circumference
pub fn angular_velocity_to_velocity(&self, angular_velocity: AngularVelocity) -> Velocity {
let angular_velocity_rps = angular_velocity.get::<revolution_per_second>();
let linear_velocity = angular_velocity_rps * self.circumference.get::<meter>();
Velocity::new::<meter_per_second>(linear_velocity)
}
/// Convert linear acceleration to angular acceleration
///
/// Formula: angular acceleration (in rad/s²) = acceleration / radius
pub fn acceleration_to_angular_acceleration(
&self,
acceleration: Acceleration,
) -> AngularAcceleration {
let linear_acceleration = acceleration.get::<meter_per_second_squared>();
let angular_acceleration_rad_per_s2 = linear_acceleration / self.radius.get::<meter>();
AngularAcceleration::new::<radian_per_second_squared>(angular_acceleration_rad_per_s2)
}
/// Convert angular acceleration to linear acceleration
///
/// Formula: acceleration = angular acceleration (in rad/s²) * radius
pub fn angular_acceleration_to_acceleration(
&self,
angular_acceleration: AngularAcceleration,
) -> Acceleration {
let angular_acceleration_rad_per_s2 =
angular_acceleration.get::<radian_per_second_squared>();
let linear_acceleration = angular_acceleration_rad_per_s2 * self.radius.get::<meter>();
Acceleration::new::<meter_per_second_squared>(linear_acceleration)
}
}
// Forward angular to/from steps conversions from StepConverter
impl LinearStepConverter {
/// Convert steps to angle
pub fn steps_to_angle(&self, steps: f64) -> Angle {
self.angular_step_converter.steps_to_angle(steps)
}
/// Convert angle to steps
pub fn angle_to_steps(&self, angle: Angle) -> f64 {
self.angular_step_converter.angle_to_steps(angle)
}
/// Convert steps/second to angular velocity
pub fn steps_to_angular_velocity(&self, steps: f64) -> AngularVelocity {
self.angular_step_converter.steps_to_angular_velocity(steps)
}
/// Convert angular velocity to steps/second
pub fn angular_velocity_to_steps(&self, angular_velocity: AngularVelocity) -> f64 {
self.angular_step_converter
.angular_velocity_to_steps(angular_velocity)
}
/// Convert steps/second² to angular acceleration
pub fn steps_to_angular_acceleration(&self, steps: f64) -> AngularAcceleration {
self.angular_step_converter
.steps_to_angular_acceleration(steps)
}
/// Convert angular acceleration to steps/second²
pub fn angular_acceleration_to_steps(&self, angular_acceleration: AngularAcceleration) -> f64 {
self.angular_step_converter
.angular_acceleration_to_steps(angular_acceleration)
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use std::f64::EPSILON;
#[test]
fn test_new() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
assert_eq!(converter.steps_per_revolution(), 200);
assert_eq!(converter.radius().get::<meter>(), 0.1);
}
#[test]
fn test_diameter_and_circumference() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
// Test diameter
let diameter = converter.diameter();
assert_relative_eq!(diameter.get::<meter>(), 0.2, epsilon = EPSILON);
// Test circumference
let circumference = converter.circumference();
let expected_circumference = 2.0 * std::f64::consts::PI * 0.1;
assert_relative_eq!(
circumference.get::<meter>(),
expected_circumference,
epsilon = EPSILON
);
}
// Tests for linear <-> steps conversions
#[test]
fn test_distance_to_steps() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
// One full revolution
let steps = converter.distance_to_steps(Length::new::<meter>(circumference));
assert_relative_eq!(steps, 200.0, epsilon = EPSILON);
// Half revolution
let steps = converter.distance_to_steps(Length::new::<meter>(circumference / 2.0));
assert_relative_eq!(steps, 100.0, epsilon = EPSILON);
}
#[test]
fn test_steps_to_distance() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
// One full revolution
let distance = converter.steps_to_distance(200.0);
assert_relative_eq!(distance.get::<meter>(), circumference, epsilon = EPSILON);
// Half revolution
let distance = converter.steps_to_distance(100.0);
assert_relative_eq!(
distance.get::<meter>(),
circumference / 2.0,
epsilon = EPSILON
);
}
#[test]
fn test_velocity_to_steps() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
// Linear velocity equivalent to one revolution per second
let velocity = Velocity::new::<meter_per_second>(circumference);
let steps_per_second = converter.velocity_to_steps(velocity);
assert_relative_eq!(steps_per_second, 200.0, epsilon = EPSILON);
// Half revolution per second
let velocity = Velocity::new::<meter_per_second>(circumference / 2.0);
let steps_per_second = converter.velocity_to_steps(velocity);
assert_relative_eq!(steps_per_second, 100.0, epsilon = EPSILON);
}
#[test]
fn test_steps_to_velocity() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
// Steps for one revolution per second
let velocity = converter.steps_to_velocity(200.0);
assert_relative_eq!(
velocity.get::<meter_per_second>(),
circumference,
epsilon = EPSILON
);
// Half revolution per second
let velocity = converter.steps_to_velocity(100.0);
assert_relative_eq!(
velocity.get::<meter_per_second>(),
circumference / 2.0,
epsilon = EPSILON
);
}
#[test]
fn test_acceleration_to_steps() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
// Create a linear acceleration of 1 m/s²
// This will result in angular acceleration of a/r = 1/0.1 = 10 rad/s²
let acceleration = Acceleration::new::<meter_per_second_squared>(1.0);
let steps_per_second_squared = converter.acceleration_to_steps(acceleration);
// Expected steps for 10 rad/s² angular acceleration:
// 10 rad/s² * (1 rev/2π rad) * 200 steps/rev = 318.31 steps/s²
let expected_steps = 200.0 * 10.0 / (2.0 * std::f64::consts::PI);
assert_relative_eq!(
steps_per_second_squared,
expected_steps,
epsilon = EPSILON * 10.0
);
}
#[test]
fn test_steps_to_acceleration() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
// Steps for one revolution per second squared
let acceleration = converter.steps_to_acceleration(200.0);
let expected = 2.0 * std::f64::consts::PI * 0.1; // rad/s² * radius
assert_relative_eq!(
acceleration.get::<meter_per_second_squared>(),
expected,
epsilon = EPSILON
);
}
// Tests for linear <-> angular conversions
#[test]
fn test_distance_to_angle() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
// Full circumference = 1 revolution
let angle = converter.distance_to_angle(Length::new::<meter>(circumference));
assert_relative_eq!(angle.get::<revolution>(), 1.0, epsilon = EPSILON);
// Half circumference = 0.5 revolution
let angle = converter.distance_to_angle(Length::new::<meter>(circumference / 2.0));
assert_relative_eq!(angle.get::<revolution>(), 0.5, epsilon = EPSILON);
}
#[test]
fn test_angle_to_distance() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
// 1 revolution = full circumference
let distance = converter.angle_to_distance(Angle::new::<revolution>(1.0));
assert_relative_eq!(distance.get::<meter>(), circumference, epsilon = EPSILON);
// 0.5 revolution = half circumference
let distance = converter.angle_to_distance(Angle::new::<revolution>(0.5));
assert_relative_eq!(
distance.get::<meter>(),
circumference / 2.0,
epsilon = EPSILON
);
}
#[test]
fn test_velocity_to_angular_velocity() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
// Linear velocity of full circumference per second = 1 revolution per second
let angular_velocity = converter
.velocity_to_angular_velocity(Velocity::new::<meter_per_second>(circumference));
assert_relative_eq!(
angular_velocity.get::<revolution_per_second>(),
1.0,
epsilon = EPSILON
);
// Half circumference per second = 0.5 revolution per second
let angular_velocity =
converter.velocity_to_angular_velocity(Velocity::new::<meter_per_second>(
circumference / 2.0,
));
assert_relative_eq!(
angular_velocity.get::<revolution_per_second>(),
0.5,
epsilon = EPSILON
);
}
#[test]
fn test_angular_velocity_to_velocity() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
let circumference = 2.0 * std::f64::consts::PI * 0.1; // 0.628m
// 1 revolution per second = linear velocity of full circumference per second
let velocity = converter
.angular_velocity_to_velocity(AngularVelocity::new::<revolution_per_second>(1.0));
assert_relative_eq!(
velocity.get::<meter_per_second>(),
circumference,
epsilon = EPSILON
);
// 0.5 revolution per second = half circumference per second
let velocity = converter
.angular_velocity_to_velocity(AngularVelocity::new::<revolution_per_second>(0.5));
assert_relative_eq!(
velocity.get::<meter_per_second>(),
circumference / 2.0,
epsilon = EPSILON
);
}
#[test]
fn test_acceleration_to_angular_acceleration() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
// Linear acceleration of 1 m/s² = angular acceleration of 10 rad/s²
let angular_accel = converter.acceleration_to_angular_acceleration(Acceleration::new::<
meter_per_second_squared,
>(1.0));
assert_relative_eq!(
angular_accel.get::<radian_per_second_squared>(),
10.0, // a/r = 1/0.1 = 10
epsilon = EPSILON
);
}
#[test]
fn test_angular_acceleration_to_acceleration() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
// Angular acceleration of 10 rad/s² = linear acceleration of 1 m/s²
let acceleration =
converter.angular_acceleration_to_acceleration(AngularAcceleration::new::<
radian_per_second_squared,
>(10.0));
assert_relative_eq!(
acceleration.get::<meter_per_second_squared>(),
1.0, // α*r = 10*0.1 = 1
epsilon = EPSILON
);
}
// Tests for forwarded angular <-> steps methods
#[test]
fn test_forwarded_steps_to_angle() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
// 200 steps = 1 revolution
let angle = converter.steps_to_angle(200.0);
assert_relative_eq!(angle.get::<revolution>(), 1.0, epsilon = EPSILON);
// 100 steps = 0.5 revolution
let angle = converter.steps_to_angle(100.0);
assert_relative_eq!(angle.get::<revolution>(), 0.5, epsilon = EPSILON);
}
#[test]
fn test_forwarded_angle_to_steps() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
// 1 revolution = 200 steps
let steps = converter.angle_to_steps(Angle::new::<revolution>(1.0));
assert_relative_eq!(steps, 200.0, epsilon = EPSILON);
// 0.5 revolution = 100 steps
let steps = converter.angle_to_steps(Angle::new::<revolution>(0.5));
assert_relative_eq!(steps, 100.0, epsilon = EPSILON);
}
#[test]
fn test_roundtrip_conversions() {
let converter = LinearStepConverter::from_radius(200, Length::new::<meter>(0.1));
// Test linear distance to steps roundtrip
let original_distance = Length::new::<meter>(0.456);
let steps = converter.distance_to_steps(original_distance);
let roundtrip_distance = converter.steps_to_distance(steps);
assert_relative_eq!(
original_distance.get::<meter>(),
roundtrip_distance.get::<meter>(),
epsilon = EPSILON
);
// Test linear velocity to steps roundtrip
let original_velocity = Velocity::new::<meter_per_second>(0.789);
let steps_per_second = converter.velocity_to_steps(original_velocity);
let roundtrip_velocity = converter.steps_to_velocity(steps_per_second);
assert_relative_eq!(
original_velocity.get::<meter_per_second>(),
roundtrip_velocity.get::<meter_per_second>(),
epsilon = EPSILON
);
// Test linear distance to angle roundtrip
let original_distance = Length::new::<meter>(0.123);
let angle = converter.distance_to_angle(original_distance);
let roundtrip_distance = converter.angle_to_distance(angle);
assert_relative_eq!(
original_distance.get::<meter>(),
roundtrip_distance.get::<meter>(),
epsilon = EPSILON
);
// Test angle to steps roundtrip
let original_angle = Angle::new::<revolution>(0.75);
let steps = converter.angle_to_steps(original_angle);
let roundtrip_angle = converter.steps_to_angle(steps);
assert_relative_eq!(
original_angle.get::<revolution>(),
roundtrip_angle.get::<revolution>(),
epsilon = EPSILON
);
}
}
+4 -1
View File
@@ -1,2 +1,5 @@
pub mod angle_converter;
pub mod angular_step_converter;
pub mod linear_step_converter;
pub mod motor_converter;
pub mod step_converter;
pub mod transmission_converter;
@@ -1,9 +1 @@
pub struct MotorConverter {}
impl MotorConverter {
pub fn rpm_to_hz(rpm: f32) -> f32 {
rpm / 60.0
}
pub fn hz_to_rpm(hz: f32) -> f32 {
hz * 60.0
}
}
@@ -0,0 +1,22 @@
use uom::si::f64::AngularVelocity;
#[derive(Debug, Clone)]
pub struct TransmissionConverter {
pub transmission_ratio: f64,
}
impl TransmissionConverter {
pub fn new() -> Self {
Self {
transmission_ratio: 34.0,
}
}
pub fn calculate_screw_output_rpm(&self, rpm: AngularVelocity) -> AngularVelocity {
rpm / self.transmission_ratio
}
pub fn calculate_screw_input_rpm(&self, screw_rpm: AngularVelocity) -> AngularVelocity {
screw_rpm * self.transmission_ratio
}
}
@@ -14,19 +14,16 @@ use ethercat_hal::devices::el3001::EL3001_IDENTITY_A;
use ethercat_hal::devices::el3021::EL3021_IDENTITY_A;
use ethercat_hal::devices::el3024::EL3024_IDENTITY_A;
use ethercat_hal::devices::el3204::EL3204_IDENTITY_A;
use ethercat_hal::devices::el6021::EL6021_IDENTITY_A;
use ethercat_hal::devices::el6021::EL6021_IDENTITY_B;
use ethercat_hal::devices::el3204::EL3204_IDENTITY_B;
use ethercat_hal::devices::el6021::EL6021_IDENTITY_C;
use ethercat_hal::devices::el7031::EL7031_IDENTITY_A;
use ethercat_hal::devices::el7031::EL7031_IDENTITY_B;
use ethercat_hal::devices::el6021::{
EL6021_IDENTITY_A, EL6021_IDENTITY_B, EL6021_IDENTITY_C, EL6021_IDENTITY_D,
};
use ethercat_hal::devices::el7031::{EL7031_IDENTITY_A, EL7031_IDENTITY_B};
use ethercat_hal::devices::el7031_0030::EL7031_0030_IDENTITY_A;
use ethercat_hal::devices::el7041_0052::EL7041_0052_IDENTITY_A;
use ethercat_hal::devices::subdevice_identity_to_tuple;
use ethercat_hal::types::{
EthercrabSubDeviceOperational,
EthercrabSubDevicePreoperational,
use ethercat_hal::helpers::ethercrab_types::{
EthercrabSubDeviceOperational, EthercrabSubDevicePreoperational,
};
use ethercrab::MainDevice;
use ethercrab::SubDeviceIdentity;
@@ -58,8 +55,8 @@ impl Default for MachineIdentificationAddresses {
fn default() -> Self {
Self {
vendor_word: 0x0028,
serial_word: 0x0029,
machine_word: 0x002a,
machine_word: 0x0029,
serial_word: 0x002a,
role_word: 0x002b,
}
}
@@ -68,9 +65,7 @@ impl Default for MachineIdentificationAddresses {
/// Reads the EEPROM of all subdevices to get their machine device identifications
///
/// Returns a vector of MachineDeviceIdentification for all subdevices
pub async fn read_device_identifications<
'maindevice,
>(
pub async fn read_device_identifications<'maindevice>(
subdevices: &Vec<EthercrabSubDevicePreoperational<'maindevice>>,
maindevice: &MainDevice<'maindevice>,
) -> Vec<Result<DeviceMachineIdentification, Error>> {
@@ -123,7 +118,6 @@ pub async fn machine_device_identification<'maindevice>(
module_path!(),
subdevice.name()
)))?,
},
role: subdevice
.eeprom_read::<u16>(maindevice, addresses.role_word)
@@ -135,17 +129,20 @@ pub async fn machine_device_identification<'maindevice>(
)))?,
};
log::debug!(
"[{}::machine_device_identification] Read MDI from EEPROM for device {}\nVendor: 0x{:08x} at 0x{:04x}-0x{:04x}\nSerial: 0x{:08x} at 0x{:04x}-0x{:04x}\nMachine: 0x{:08x} at 0x{:04x}-0x{:04x}\nRole: 0x{:08x} at 0x{:04x}-0x{:04x}",
module_path!(),
tracing::debug!(
"Read MDI from EEPROM for device {}\nVendor: 0x{:08x} at 0x{:04x}-0x{:04x}\nSerial: 0x{:08x} at 0x{:04x}-0x{:04x}\nMachine: 0x{:08x} at 0x{:04x}-0x{:04x}\nRole: 0x{:08x} at 0x{:04x}-0x{:04x}",
subdevice.name(),
mdi.machine_identification_unique.machine_identification.vendor,
mdi.machine_identification_unique
.machine_identification
.vendor,
addresses.vendor_word,
addresses.vendor_word + 1,
mdi.machine_identification_unique.serial,
addresses.serial_word,
addresses.serial_word + 1,
mdi.machine_identification_unique.machine_identification.machine,
mdi.machine_identification_unique
.machine_identification
.machine,
addresses.machine_word,
addresses.machine_word + 1,
mdi.role,
@@ -163,17 +160,22 @@ pub async fn write_machine_device_identification<'maindevice, const MAX_PDI: usi
device_identification: &DeviceMachineIdentification,
) -> Result<(), Error> {
let addresses = get_identification_addresses(&subdevice.identity(), subdevice.name())?;
log::debug!(
"[{}::write_machine_device_identification] Writing MDI to EEPROM for device {}\nVendor: 0x{:08x} at 0x{:04x}-0x{:04x}\nSerial: 0x{:08x} at 0x{:04x}-0x{:04x}\nMachine: 0x{:08x} at 0x{:04x}-0x{:04x}\nRole: 0x{:08x} at 0x{:04x}-0x{:04x}",
module_path!(),
tracing::debug!(
"Writing MDI to EEPROM for device {}\nVendor: 0x{:08x} at 0x{:04x}-0x{:04x}\nSerial: 0x{:08x} at 0x{:04x}-0x{:04x}\nMachine: 0x{:08x} at 0x{:04x}-0x{:04x}\nRole: 0x{:08x} at 0x{:04x}-0x{:04x}",
subdevice.name(),
device_identification.machine_identification_unique.machine_identification.vendor,
device_identification
.machine_identification_unique
.machine_identification
.vendor,
addresses.vendor_word,
addresses.vendor_word + 1,
device_identification.machine_identification_unique.serial,
addresses.serial_word,
addresses.serial_word + 1,
device_identification.machine_identification_unique.machine_identification.machine,
device_identification
.machine_identification_unique
.machine_identification
.machine,
addresses.machine_word,
addresses.machine_word + 1,
device_identification.role,
@@ -185,7 +187,10 @@ pub async fn write_machine_device_identification<'maindevice, const MAX_PDI: usi
.eeprom_write_dangerously(
maindevice,
addresses.vendor_word,
device_identification.machine_identification_unique.machine_identification.vendor,
device_identification
.machine_identification_unique
.machine_identification
.vendor,
)
.await?;
subdevice
@@ -199,7 +204,10 @@ pub async fn write_machine_device_identification<'maindevice, const MAX_PDI: usi
.eeprom_write_dangerously(
maindevice,
addresses.machine_word,
device_identification.machine_identification_unique.machine_identification.machine,
device_identification
.machine_identification_unique
.machine_identification
.machine,
)
.await?;
subdevice
@@ -232,8 +240,11 @@ pub fn get_identification_addresses<'maindevice>(
EL3024_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3021_IDENTITY_A => MachineIdentificationAddresses::default(),
EL7031_IDENTITY_A | EL7031_IDENTITY_B => MachineIdentificationAddresses::default(),
EL7031_0030_IDENTITY_A => MachineIdentificationAddresses::default(),
EL7041_0052_IDENTITY_A => MachineIdentificationAddresses::default(),
EL6021_IDENTITY_A | EL6021_IDENTITY_B | EL6021_IDENTITY_C => MachineIdentificationAddresses::default(),
EL6021_IDENTITY_A | EL6021_IDENTITY_B | EL6021_IDENTITY_C | EL6021_IDENTITY_D => {
MachineIdentificationAddresses::default()
}
_ => {
// block_on(u16dump(&subdevice, maindevice, 0x00, 0xff))?;
Err(anyhow!(
@@ -21,21 +21,21 @@ const IFACE_DISCOVERY_MAX_PDI_LEN: usize = 128;
/// }
/// ```
pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
log::info!("Discovering EtherCAT interface...");
tracing::info!("Discovering EtherCAT interface...");
// Set up a custom panic hook that suppresses panic output
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
// Optional: log the panic in a controlled way
if let Some(location) = panic_info.location() {
log::debug!(
tracing::debug!(
"Suppressed panic in interface test: {} (at {}:{})",
panic_info,
location.file(),
location.line()
);
} else {
log::debug!("Suppressed panic in interface test: {}", panic_info);
tracing::debug!("Suppressed panic in interface test: {}", panic_info);
}
// Don't call the default hook, which would print the backtrace
}));
@@ -49,6 +49,10 @@ pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
&& iface.is_running()
&& !iface.is_loopback()
&& !iface.name.starts_with("bridge")
&& !iface.name.starts_with("utun") // Exclude tunnel interfaces
&& !iface.name.starts_with("awdl") // Exclude Apple Wireless Direct Link
&& !iface.name.starts_with("anpi") // Exclude Apple Network Privacy Interface
&& !iface.name.starts_with("llw") // Exclude Apple low-latency WLAN
})
.collect::<Vec<_>>();
@@ -60,34 +64,40 @@ pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
.iter()
.map(|iface| {
let name = iface.name.clone();
std::thread::spawn(move || {
std::panic::catch_unwind(|| {
smol::block_on(async {
match test_interface(&name) {
Ok(_) => {
log::debug!("Found working EtherCAT interface: {}", name);
Some(name.clone())
std::thread::Builder::new()
.name(format!("ethercat-test-{}", name))
.spawn(move || {
std::panic::catch_unwind(|| {
smol::block_on(async {
match test_interface(&name) {
Ok(_) => {
tracing::info!("Found working EtherCAT interface: {}", name);
Some(name.clone())
}
Err(_) => {
tracing::debug!("Interface {} failed", name);
None
}
}
Err(e) => {
log::debug!("Interface {} failed: {}", name, e);
None
}
}
})
})
.unwrap_or(None)
})
.unwrap_or(None)
})
.expect("Failed to spawn thread")
})
.collect::<Vec<_>>();
// Return first successful interface
let result =
tasks
.into_iter()
.find_map(|task| match task.join().expect("Should join thread") {
Some(name) => Some(name),
None => None,
});
// Wait for all tasks to complete and collect successful results
let successful_interfaces: Vec<String> = tasks
.into_iter()
.filter_map(|task| match task.join().expect("Should join thread") {
Some(name) => Some(name),
None => None,
})
.collect();
// Return the first successful interface (they're sorted by name)
let result = successful_interfaces.into_iter().next();
// Restore the default panic hook
std::panic::set_hook(default_hook);
@@ -99,7 +109,7 @@ pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
}
fn test_interface(interface: &str) -> Result<(), anyhow::Error> {
log::debug!("Testing interface: {}", interface);
tracing::trace!("Testing interface: {}", interface);
let pdu_storage = Box::leak(Box::new(PduStorage::<
IFACE_DISCOVERY_MAX_FRAMES,
@@ -133,7 +143,12 @@ fn test_interface(interface: &str) -> Result<(), anyhow::Error> {
// Default 1000ms
mailbox_response: Duration::from_millis(1000),
},
MainDeviceConfig::default(),
MainDeviceConfig {
// Default 10000
dc_static_sync_iterations: 10000,
// Default None
retry_behaviour: ethercrab::RetryBehaviour::None,
},
));
let result = maindevice
+42
View File
@@ -0,0 +1,42 @@
pub struct ListChange<'t, T>
where
T: PartialEq,
{
pub added: Vec<&'t T>,
pub removed: Vec<&'t T>,
pub same: Vec<&'t T>,
}
pub fn compare_lists<'oldlist, 'newlist, 't, T: PartialEq>(
old_list: &'oldlist Vec<T>,
new_list: &'newlist Vec<T>,
) -> ListChange<'t, T>
where
T: PartialEq,
'oldlist: 't,
'newlist: 't,
{
let mut added = Vec::new();
let mut removed = Vec::new();
let mut same = Vec::new();
for item in new_list.iter() {
if !old_list.iter().any(|x| x == item) {
added.push(item);
} else {
same.push(item);
}
}
for item in old_list.iter() {
if !new_list.iter().any(|x| x == item) {
removed.push(item);
}
}
ListChange {
added,
removed,
same,
}
}
+40
View File
@@ -0,0 +1,40 @@
pub fn xor_u128_to_u64(value: u128) -> u64 {
(value as u64) ^ ((value >> 64) as u64)
}
pub fn xor_u128_to_u32(value: u128) -> u32 {
(value as u32) ^ ((value >> 32) as u32) ^ ((value >> 64) as u32) ^ ((value >> 96) as u32)
}
pub fn xor_u128_to_u16(value: u128) -> u16 {
value
.to_le_bytes()
.iter()
.fold(0, |buf, &byte| buf ^ byte as u16)
}
pub fn xor_u128_to_u8(value: u128) -> u8 {
value.to_le_bytes().iter().fold(0, |buf, &byte| buf ^ byte)
}
// hash algorithm djb2
pub fn hashing(input_to_hash: &str) -> u128 {
let mut hash: u128 = 5381;
let chars = input_to_hash.chars();
for ch in chars {
hash = ((hash << 5) + hash) + (ch as u128);
}
hash
}
#[test]
fn test_hash() {
let test_case = "/dev/ttyUSB1";
let hash = hashing(test_case);
let hash_2bytes = xor_u128_to_u16(hash);
tracing::info!("{}", test_case);
assert_eq!(test_case, "/dev/ttyUSB1");
assert!(hash == 8977446972540388683742);
assert!(hash_2bytes == 165);
println!("{:?}", hash);
}
+186
View File
@@ -0,0 +1,186 @@
/// Normalizes a value from a given range [min, max] to the range [0, 1].
pub fn normalize<T>(value: T, min: T, max: T) -> f64
where
T: Into<f64>,
{
let value: f64 = value.into();
let min: f64 = min.into();
let max: f64 = max.into();
if min >= max {
panic!("min must be less than max");
}
if value < min {
return 0.0;
} else if value > max {
return 1.0;
}
(value - min) / (max - min)
}
/// Scales a normalized value from the range [0, 1] to a new range [new_min, new_max].
pub fn scale<T>(normalized_value: f64, new_min: T, new_max: T) -> T
where
T: Into<f64> + From<f64>,
{
let normalized_value: f64 = normalized_value.into();
let new_min: f64 = new_min.into();
let new_max: f64 = new_max.into();
let result = normalized_value * (new_max - new_min) + new_min;
T::from(result)
}
/// Transforma normalized value from the range [0, 1] to a [0, 1] range using an normalized exponential function.
pub fn interpolate_exponential(normalized_value: f64, a: f64) -> f64 {
if a.abs() < 1e-10 {
// When a is very close to zero, the function becomes linear
return normalized_value;
}
let numerator = a.exp().powf(normalized_value) - 1.0;
let denominator = a.exp() - 1.0;
clip(numerator / denominator)
}
/// Inverts a value from the range [0, 1] to [1, 0].
pub fn invert(value: f64) -> f64 {
let value = clip(value);
1.0 - value
}
pub fn clip(value: f64) -> f64 {
if value < 0.0 {
return 0.0;
} else if value > 1.0 {
return 1.0;
}
value
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
const EPSILON: f64 = 1e-10;
#[test]
fn test_scale() {
// Basic normalization with same types
assert_relative_eq!(normalize(5.0f64, 0.0f64, 10.0f64), 0.5, epsilon = EPSILON);
assert_relative_eq!(normalize(5.0f32, 0.0f32, 10.0f32), 0.5, epsilon = EPSILON);
assert_relative_eq!(normalize(5i32, 0i32, 10i32), 0.5, epsilon = EPSILON);
assert_relative_eq!(normalize(5u8, 0u8, 10u8), 0.5, epsilon = EPSILON);
// Edge cases
assert_relative_eq!(normalize(0.0, 0.0, 10.0), 0.0, epsilon = EPSILON);
assert_relative_eq!(normalize(10.0, 0.0, 10.0), 1.0, epsilon = EPSILON);
// Out of range (should clamp)
assert_relative_eq!(normalize(-5.0, 0.0, 10.0), 0.0, epsilon = EPSILON);
assert_relative_eq!(normalize(15.0, 0.0, 10.0), 1.0, epsilon = EPSILON);
// Different ranges
assert_relative_eq!(normalize(0.0, -10.0, 10.0), 0.5, epsilon = EPSILON);
assert_relative_eq!(normalize(5.0, -5.0, 5.0), 1.0, epsilon = EPSILON);
}
#[test]
fn test_interpolate_linear() {
// Basic interpolation - f64
let result_f64: f64 = scale(0.5, 0.0, 10.0);
assert_relative_eq!(result_f64, 5.0, epsilon = EPSILON);
// For non-f64 return types, we need to use a separate implementation
// that uses `as` for the conversion
// For testing f32 output with f32 input - use direct calculation
let normalized_value = 0.5;
let new_min_f32 = 0.0f32;
let new_max_f32 = 10.0f32;
let expected_f32 = (normalized_value * (new_max_f32 as f64 - new_min_f32 as f64)
+ new_min_f32 as f64) as f32;
assert_relative_eq!(expected_f32, 5.0f32, epsilon = EPSILON as f32);
// Testing i32 return type with direct calculation
let new_min_i32 = 0i32;
let new_max_i32 = 10i32;
let expected_i32 = (normalized_value * (new_max_i32 as f64 - new_min_i32 as f64)
+ new_min_i32 as f64) as i32;
assert_eq!(expected_i32, 5i32);
// Edge cases
assert_relative_eq!(scale(0.0, 0.0, 10.0), 0.0, epsilon = EPSILON);
assert_relative_eq!(scale(1.0, 0.0, 10.0), 10.0, epsilon = EPSILON);
// Negative ranges
assert_relative_eq!(scale(0.5, -10.0, 10.0), 0.0, epsilon = EPSILON);
assert_relative_eq!(scale(0.5, -20.0, -10.0), -15.0, epsilon = EPSILON);
}
#[test]
fn test_interpolate_exponential() {
// Test boundary points (0,0) and (1,1) for various values of a
for a in [-10.0, -5.0, -1.0, -0.1, 0.1, 1.0, 5.0, 10.0].iter() {
assert_relative_eq!(interpolate_exponential(0.0, *a), 0.0, epsilon = EPSILON);
assert_relative_eq!(interpolate_exponential(1.0, *a), 1.0, epsilon = EPSILON);
}
// Test behavior for x outside [0,1]
let a = 2.0;
assert_relative_eq!(interpolate_exponential(-0.1, a), 0.0, epsilon = EPSILON);
assert_relative_eq!(interpolate_exponential(1.1, a), 1.0, epsilon = EPSILON);
// Test monotonicity - function should be strictly increasing for any a
for a in [-10.0, -1.0, 0.0, 1.0, 10.0].iter() {
let mut prev = interpolate_exponential(0.0, *a);
for i in 1..=10 {
let x = i as f64 / 10.0;
let current = interpolate_exponential(x, *a);
assert!(
current > prev,
"Failed monotonicity test at x={}, a={}",
x,
a
);
prev = current;
}
}
// Test symmetry property: f(x, a) + f(1-x, -a) ≈ 1
for x in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9].iter() {
for a in [0.5, 1.0, 2.0, 5.0].iter() {
let sum = interpolate_exponential(*x, *a) + interpolate_exponential(1.0 - *x, -*a);
assert_relative_eq!(sum, 1.0, epsilon = 1e-8);
}
}
}
#[test]
fn test_invert() {
assert_relative_eq!(invert(0.0), 1.0, epsilon = EPSILON);
assert_relative_eq!(invert(1.0), 0.0, epsilon = EPSILON);
assert_relative_eq!(invert(0.3), 0.7, epsilon = EPSILON);
assert_relative_eq!(invert(0.5), 0.5, epsilon = EPSILON);
// Test clipping instead of panicking
assert_relative_eq!(invert(-0.1), 1.0, epsilon = EPSILON);
assert_relative_eq!(invert(1.1), 0.0, epsilon = EPSILON);
}
#[test]
fn test_clip() {
assert_relative_eq!(clip(0.5), 0.5, epsilon = EPSILON);
assert_relative_eq!(clip(0.0), 0.0, epsilon = EPSILON);
assert_relative_eq!(clip(1.0), 1.0, epsilon = EPSILON);
assert_relative_eq!(clip(-0.5), 0.0, epsilon = EPSILON);
assert_relative_eq!(clip(1.5), 1.0, epsilon = EPSILON);
}
#[test]
#[should_panic]
fn test_normalize_invalid_range() {
normalize(5.0, 10.0, 5.0); // min >= max should panic
}
}
+189
View File
@@ -0,0 +1,189 @@
use smol::Timer;
use std::time::{Duration, Instant};
use tracing::instrument;
pub struct LoopThrottle {
target_cycle_time: Duration,
update_interval_cycles: usize,
window_start: Instant,
total_cycles: usize,
delay: Duration,
last_avg_cycle_time: Duration,
on_update: Option<Box<dyn Fn(LoopThrottleUpdate)>>,
}
pub struct LoopThrottleUpdate {
pub delay: Duration,
pub avg_cycle_time: Duration,
pub error: i128, // Error in nanoseconds
}
impl LoopThrottle {
pub fn new(
target_cycle_time: Duration,
update_interval_cycles: usize,
on_update: Option<Box<dyn Fn(LoopThrottleUpdate)>>,
) -> Self {
Self {
target_cycle_time,
update_interval_cycles,
window_start: Instant::now(),
total_cycles: 0,
delay: target_cycle_time,
last_avg_cycle_time: target_cycle_time,
on_update,
}
}
#[instrument(skip_all)]
pub async fn sleep(&mut self) {
self.total_cycles += 1;
// Check if it's time to update (every N cycles)
if self.total_cycles % self.update_interval_cycles == 0 {
let actual_total_time = self.window_start.elapsed();
// Use integer arithmetic where possible
let avg_actual_cycle_time_nanos =
actual_total_time.as_nanos() / self.update_interval_cycles as u128;
let target_cycle_time_nanos = self.target_cycle_time.as_nanos();
// Calculate error in nanoseconds (signed)
let error_nanos = target_cycle_time_nanos as i128 - avg_actual_cycle_time_nanos as i128;
// Update current delay, allowing zero or negative values to become zero
let new_delay_nanos = (self.delay.as_nanos() as i128 + error_nanos).max(0) as u64;
self.delay = Duration::from_nanos(new_delay_nanos);
self.last_avg_cycle_time = Duration::from_nanos(avg_actual_cycle_time_nanos as u64);
// Reset for next measurement window
self.window_start = Instant::now();
self.total_cycles = 0; // Reset to prevent overflow
// if we have an update callback, call it
if let Some(on_update) = &self.on_update {
on_update(LoopThrottleUpdate {
delay: self.delay,
avg_cycle_time: self.last_avg_cycle_time,
error: error_nanos,
});
}
}
Timer::after(self.delay).await;
}
pub fn get_cycle_time(&self) -> Duration {
self.last_avg_cycle_time
}
pub fn get_delay(&self) -> Duration {
self.delay
}
}
#[cfg(test)]
mod tests {
use super::*;
use smol::block_on;
#[test]
fn test_adaptive_throttle_timing() {
block_on(async {
let update_interval_cycles = 100;
let target_cycle_time = Duration::from_micros(5);
let mut throttle = LoopThrottle::new(target_cycle_time, update_interval_cycles, None);
let test_start = Instant::now();
let mut cycle_times = Vec::new();
// Run for several cycles to trigger multiple updates
for _ in 0..10000 {
let cycle_start = Instant::now();
// Simulate some work
Timer::after(Duration::from_micros(5)).await;
throttle.sleep().await;
let cycle_time = cycle_start.elapsed();
cycle_times.push(cycle_time);
}
let total_test_time = test_start.elapsed();
let avg_cycle_time = cycle_times.iter().sum::<Duration>() / cycle_times.len() as u32;
println!("Test completed in: {:?}", total_test_time);
println!("Average cycle time: {:?}", avg_cycle_time);
println!("Target cycle time: {:?}", target_cycle_time);
println!("Last measured avg: {:?}", throttle.get_cycle_time());
// Assert that we're reasonably close to target timing
let tolerance = Duration::from_millis(10);
assert!(
avg_cycle_time >= target_cycle_time.saturating_sub(tolerance)
&& avg_cycle_time <= target_cycle_time + tolerance,
"Average cycle time {:?} not within tolerance of target {:?}",
avg_cycle_time,
target_cycle_time
);
});
}
#[test]
fn test_throttle_adaptation() {
block_on(async {
let target_cycle_time = Duration::from_millis(100);
let update_interval_cycles = 3;
let mut throttle = LoopThrottle::new(target_cycle_time, update_interval_cycles, None);
let initial_delay = throttle.get_delay();
// Run enough cycles to trigger multiple updates
for _ in 0..12 {
// Add variable work to test adaptation
Timer::after(Duration::from_millis(10)).await;
throttle.sleep().await;
}
println!("Initial delay: {:?}", initial_delay);
println!("Final delay: {:?}", throttle.get_delay());
println!("Last avg cycle time: {:?}", throttle.get_cycle_time());
assert!(throttle.get_cycle_time() > Duration::ZERO);
});
}
#[test]
fn test_throttle_initialization() {
let update_interval_cycles = 10;
let target_cycle_time = Duration::from_millis(75);
let throttle = LoopThrottle::new(target_cycle_time, update_interval_cycles, None);
assert_eq!(throttle.get_delay(), target_cycle_time);
assert_eq!(throttle.get_cycle_time(), target_cycle_time);
assert_eq!(throttle.total_cycles, 0);
}
#[test]
fn test_overflow_protection() {
block_on(async {
let target_cycle_time = Duration::from_micros(1);
let update_interval_cycles = 5;
let mut throttle = LoopThrottle::new(target_cycle_time, update_interval_cycles, None);
// Run many cycles to ensure total_cycles resets properly
for i in 0..50 {
throttle.sleep().await;
// Verify that total_cycles never exceeds update_interval_cycles
assert!(
throttle.total_cycles <= update_interval_cycles,
"Cycle {}: total_cycles ({}) exceeded update_interval_cycles ({})",
i,
throttle.total_cycles,
update_interval_cycles
);
}
});
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod compare_lists;
pub mod hashing;
pub mod interpolation;
pub mod loop_trottle;
pub mod moving_time_window;
pub mod retry;
@@ -0,0 +1,261 @@
use std::{
collections::VecDeque,
iter::Sum,
time::{Duration, Instant},
};
/// A helper trait to handle division for averaging
pub trait DivideByCount {
fn divide_by_count(self, count: usize) -> Self;
}
impl DivideByCount for f64 {
fn divide_by_count(self, count: usize) -> Self {
self / (count as f64)
}
}
impl DivideByCount for f32 {
fn divide_by_count(self, count: usize) -> Self {
self / (count as f32)
}
}
impl DivideByCount for i32 {
fn divide_by_count(self, count: usize) -> Self {
self / (count as i32)
}
}
impl DivideByCount for i64 {
fn divide_by_count(self, count: usize) -> Self {
self / (count as i64)
}
}
impl DivideByCount for u32 {
fn divide_by_count(self, count: usize) -> Self {
self / (count as u32)
}
}
impl DivideByCount for u64 {
fn divide_by_count(self, count: usize) -> Self {
self / (count as u64)
}
}
/// A generic moving time window that works with numeric types.
///
/// # Examples
///
/// ```rust
/// use std::time::{Duration, Instant};
/// use control_core::helpers::moving_time_window::MovingTimeWindow;
///
/// let mut window = MovingTimeWindow::new(Duration::from_secs(10), 100);
/// window.update(1.5, Instant::now());
/// window.update(2.5, Instant::now());
///
/// let average = window.average();
/// println!("Average: {}", average);
/// ```
// Type aliases for common use cases
pub type F64Window = MovingTimeWindow<f64>;
pub type F32Window = MovingTimeWindow<f32>;
pub type I32Window = MovingTimeWindow<i32>;
pub type I64Window = MovingTimeWindow<i64>;
#[derive(Debug)]
pub struct MovingTimeWindow<T>
where
T: Copy + PartialOrd + Sum + DivideByCount + std::fmt::Debug + Default,
{
max_samples: usize,
samples: VecDeque<(T, Instant)>,
last_sample: Option<(T, Instant)>,
sample_time: Duration,
cached_average: Option<T>,
cached_max: Option<T>,
cached_min: Option<T>,
}
/// A time-based moving window that maintains samples within a specified duration and count limit.
///
/// This structure efficiently tracks values over time, automatically removing samples that fall
/// outside the configured time window or exceed the maximum sample count. It provides cached
/// average calculation to avoid redundant computations.
impl<T> MovingTimeWindow<T>
where
T: Copy + PartialOrd + Sum + DivideByCount + std::fmt::Debug + Default,
{
/// Creates a new moving time window with the specified duration and maximum sample count.
///
/// # Arguments
///
/// * `duration` - The time window duration; samples older than this will be automatically removed
/// * `samples` - Maximum number of samples to retain, regardless of their age
///
/// # Returns
///
/// A new `MovingTimeWindow` instance with empty sample collection and initialized cache
pub fn new(duration: Duration, max_samples: usize) -> Self {
Self {
max_samples,
samples: VecDeque::with_capacity(max_samples),
last_sample: None,
sample_time: duration / (max_samples as u32),
cached_average: None,
cached_max: None,
cached_min: None,
}
}
/// Updates the window with a new sample value at the given timestamp.
///
/// This method adds the new sample to the window and automatically removes samples that:
/// - Are older than the configured duration from the current timestamp
/// - Exceed the maximum sample count limit
///
/// The cached average is invalidated after this operation.
///
/// # Arguments
///
/// * `value` - The numeric value to add to the window
/// * `timestamp` - The time at which this sample was taken
pub fn update(&mut self, value: T, timestamp: Instant) {
// Chekc if we should add a new sample based on the sample time and the last sample
if let Some((_, last_timestamp)) = self.last_sample {
if timestamp.duration_since(last_timestamp) < self.sample_time {
// If the new sample is too close to the last one, we skip adding it
return;
}
}
// If we have reached the maximum sample count, remove the oldest sample
if self.samples.len() >= self.max_samples {
self.samples.pop_back();
}
// Add the new sample
self.samples.push_front((value, timestamp));
self.last_sample = Some((value, timestamp));
// Invalidate all cached values since we added a new sample
self.cached_average = None;
self.cached_max = None;
self.cached_min = None;
}
/// Calculates and returns the average of all samples currently in the window.
///
/// This method uses caching to avoid redundant calculations. The average is only
/// recalculated when the window contents have changed since the last call.
///
/// # Returns
///
/// The arithmetic mean of all current samples, or T::default() if the window is empty
pub fn average(&mut self) -> T {
// Return cached value if available
if let Some(cached) = self.cached_average {
return cached;
}
let sum: T = self.samples.iter().map(|(v, _)| *v).sum();
let count = self.samples.len();
let avg = if count > 0 {
sum.divide_by_count(count)
} else {
T::default()
};
// Cache the calculated value
self.cached_average = Some(avg);
avg
}
/// Calculates and returns the maximum value of all samples currently in the window.
///
/// This method uses caching to avoid redundant calculations. The maximum is only
/// recalculated when the window contents have changed since the last call.
///
/// # Returns
///
/// The maximum value of all current samples, or T::default() if the window is empty
pub fn max(&mut self) -> T {
// Return cached value if available
if let Some(cached) = self.cached_max {
return cached;
}
let result = self
.samples
.iter()
.map(|(v, _)| *v)
.fold(None, |acc, val| match acc {
None => Some(val),
Some(current_max) => {
if val > current_max {
Some(val)
} else {
Some(current_max)
}
}
})
.unwrap_or_default();
// Cache the calculated value
self.cached_max = Some(result);
result
}
/// Calculates and returns the minimum value of all samples currently in the window.
///
/// This method uses caching to avoid redundant calculations. The minimum is only
/// recalculated when the window contents have changed since the last call.
///
/// # Returns
///
/// The minimum value of all current samples, or T::default() if the window is empty
pub fn min(&mut self) -> T {
// Return cached value if available
if let Some(cached) = self.cached_min {
return cached;
}
let result = self
.samples
.iter()
.map(|(v, _)| *v)
.fold(None, |acc, val| match acc {
None => Some(val),
Some(current_min) => {
if val < current_min {
Some(val)
} else {
Some(current_min)
}
}
})
.unwrap_or_default();
// Cache the calculated value
self.cached_min = Some(result);
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generic() {
// Test that the struct compiles with basic numeric types
let _: MovingTimeWindow<f64> = MovingTimeWindow::new(Duration::from_secs(1), 1);
let _: MovingTimeWindow<f32> = MovingTimeWindow::new(Duration::from_secs(1), 1);
let _: MovingTimeWindow<i32> = MovingTimeWindow::new(Duration::from_secs(1), 1);
}
}
+427
View File
@@ -0,0 +1,427 @@
use std::future::Future;
/// Retries a fallible operation up to `n` times synchronously.
///
/// This function executes the provided closure `f` repeatedly until either:
/// - The operation succeeds (returns `Ok(T)`)
/// - All retry attempts are exhausted
///
/// # Arguments
///
/// * `n` - The maximum number of retry attempts (0 means only one attempt)
/// * `f` - A mutable closure that returns a `Result<T, E>`
///
/// # Returns
///
/// Returns `Ok(T)` if any attempt succeeds, or `Err(E)` containing the error
/// from the last failed attempt if all retries are exhausted.
///
/// # Examples
///
/// ```rust
/// use control_core::helpers::retry::retry_n_times;
///
/// let mut attempt = 0;
/// let result = retry_n_times(3, || {
/// attempt += 1;
/// if attempt < 3 {
/// Err("failed")
/// } else {
/// Ok("success")
/// }
/// });
/// assert_eq!(result, Ok("success"));
/// ```
pub fn retry_n_times<T, E, F>(n: usize, mut f: F) -> Result<T, E>
where
F: FnMut() -> Result<T, E>,
{
let mut last_err: Option<E> = None;
for _ in 0..=n {
match f() {
Ok(value) => return Ok(value),
Err(err) => last_err = Some(err),
}
}
Err(last_err.unwrap())
}
/// Retries a fallible async operation up to `n` times.
///
/// This function executes the provided async closure `f` repeatedly until either:
/// - The operation succeeds (returns `Ok(T)`)
/// - All retry attempts are exhausted
///
/// Each attempt is awaited before proceeding to the next retry or returning.
///
/// # Arguments
///
/// * `n` - The maximum number of retry attempts (0 means only one attempt)
/// * `f` - A mutable closure that returns a `Future<Output = Result<T, E>>`
///
/// # Returns
///
/// Returns `Ok(T)` if any attempt succeeds, or `Err(E)` containing the error
/// from the last failed attempt if all retries are exhausted.
///
/// # Examples
///
/// ```rust
/// use control_core::helpers::retry::retry_n_times_async;
/// use std::sync::Arc;
/// use std::sync::atomic::{AtomicUsize, Ordering};
///
/// async fn example() {
/// let attempt = Arc::new(AtomicUsize::new(0));
/// let attempt_clone = attempt.clone();
/// let result = retry_n_times_async(3, move || {
/// let attempt = attempt_clone.clone();
/// async move {
/// let current = attempt.fetch_add(1, Ordering::SeqCst) + 1;
/// if current < 3 {
/// Err("failed")
/// } else {
/// Ok("success")
/// }
/// }
/// }).await;
/// assert_eq!(result, Ok("success"));
/// }
/// ```
pub async fn retry_n_times_async<T, E, F, Fut>(n: usize, mut f: F) -> Result<T, E>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, E>>,
{
let mut last_err: Option<E> = None;
for _ in 0..=n {
match f().await {
Ok(value) => return Ok(value),
Err(err) => last_err = Some(err),
}
}
Err(last_err.unwrap())
}
/// Retries a fallible operation until a closure decides to stop retrying.
///
/// This function executes the provided closure `f` repeatedly until either:
/// - The operation succeeds (returns `Ok(T)`)
/// - The retry condition returns false
///
/// # Arguments
///
/// * `f` - A mutable closure that returns a `Result<T, E>`
/// * `should_retry` - A closure that takes `&E` and returns `bool` to decide if retry should continue
///
/// # Returns
///
/// Returns `Ok(T)` if any attempt succeeds, or `Err(E)` containing the error
/// from the last failed attempt when retries stop.
///
/// # Examples
///
/// ```rust
/// use control_core::helpers::retry::retry_conditionally;
///
/// let mut attempt = 0;
/// let result = retry_conditionally(
/// || {
/// attempt += 1;
/// if attempt < 3 {
/// Err(format!("failed attempt {}", attempt))
/// } else {
/// Ok("success")
/// }
/// },
/// |err| {
/// // Retry up to 3 times
/// !err.contains("attempt 3")
/// }
/// );
/// assert_eq!(result, Ok("success"));
/// ```
pub fn retry_conditionally<T, E, F, R>(mut f: F, mut should_retry: R) -> Result<T, E>
where
F: FnMut() -> Result<T, E>,
R: FnMut(&E) -> bool,
{
loop {
match f() {
Ok(value) => return Ok(value),
Err(err) => {
if !should_retry(&err) {
return Err(err);
}
}
}
}
}
/// Retries a fallible async operation until a closure decides to stop retrying.
///
/// This function executes the provided async closure `f` repeatedly until either:
/// - The operation succeeds (returns `Ok(T)`)
/// - The retry condition returns false
///
/// # Arguments
///
/// * `f` - A mutable closure that returns a `Future<Output = Result<T, E>>`
/// * `should_retry` - A closure that takes `&E` and returns `bool` to decide if retry should continue
///
/// # Returns
///
/// Returns `Ok(T)` if any attempt succeeds, or `Err(E)` containing the error
/// from the last failed attempt when retries stop.
///
/// # Examples
///
/// ```rust
/// use control_core::helpers::retry::retry_conditionally_async;
/// use std::sync::Arc;
/// use std::sync::atomic::{AtomicUsize, Ordering};
///
/// async fn example() {
/// let attempt = Arc::new(AtomicUsize::new(0));
/// let attempt_clone = attempt.clone();
/// let result = retry_conditionally_async(
/// move || {
/// let attempt = attempt_clone.clone();
/// async move {
/// let current = attempt.fetch_add(1, Ordering::SeqCst) + 1;
/// if current < 3 {
/// Err(format!("failed attempt {}", current))
/// } else {
/// Ok("success")
/// }
/// }
/// },
/// |err| {
/// // Retry up to 3 times
/// !err.contains("attempt 3")
/// }
/// ).await;
/// assert_eq!(result, Ok("success"));
/// }
/// ```
pub async fn retry_conditionally_async<T, E, F, Fut, R>(
mut f: F,
mut should_retry: R,
) -> Result<T, E>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, E>>,
R: FnMut(&E) -> bool,
{
loop {
match f().await {
Ok(value) => return Ok(value),
Err(err) => {
if !should_retry(&err) {
return Err(err);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::rc::Rc;
// Simple blocking executor for async functions in tests
fn block_on<F: std::future::Future>(future: F) -> F::Output {
use std::sync::Arc;
use std::task::{Context, Poll, Waker};
use std::thread;
struct DummyWaker;
impl std::task::Wake for DummyWaker {
fn wake(self: Arc<Self>) {}
}
let waker = Waker::from(Arc::new(DummyWaker));
let mut context = Context::from_waker(&waker);
let mut future = Box::pin(future);
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(result) => return result,
Poll::Pending => {
thread::yield_now();
}
}
}
}
#[test]
fn test_retry_success_first_attempt() {
let result = retry_n_times(3, || Ok::<i32, &str>(42));
assert_eq!(result, Ok(42));
}
#[test]
fn test_retry_success_after_failures() {
let mut attempt = 0;
let result = retry_n_times(3, || {
attempt += 1;
if attempt < 3 { Err("failed") } else { Ok(42) }
});
assert_eq!(result, Ok(42));
assert_eq!(attempt, 3);
}
#[test]
fn test_retry_all_attempts_fail() {
let mut attempt = 0;
let result: Result<i32, &str> = retry_n_times(2, || {
attempt += 1;
Err("always fails")
});
assert_eq!(result, Err("always fails"));
assert_eq!(attempt, 3); // 0..=2 means 3 attempts
}
#[test]
fn test_retry_zero_retries() {
let mut attempt = 0;
let result: Result<i32, &str> = retry_n_times(0, || {
attempt += 1;
Err("failed")
});
assert_eq!(result, Err("failed"));
assert_eq!(attempt, 1); // Only one attempt
}
#[test]
fn test_retry_async_success_first_attempt() {
let result = block_on(retry_n_times_async(3, || async { Ok::<i32, &str>(42) }));
assert_eq!(result, Ok(42));
}
#[test]
fn test_retry_async_success_after_failures() {
let attempt = Rc::new(RefCell::new(0));
let attempt_clone = attempt.clone();
let result = block_on(retry_n_times_async(3, move || {
let attempt = attempt_clone.clone();
async move {
let mut a = attempt.borrow_mut();
*a += 1;
if *a < 3 { Err("failed") } else { Ok(42) }
}
}));
assert_eq!(result, Ok(42));
assert_eq!(*attempt.borrow(), 3);
}
#[test]
fn test_retry_async_all_attempts_fail() {
let attempt = Rc::new(RefCell::new(0));
let attempt_clone = attempt.clone();
let result: Result<i32, &str> = block_on(retry_n_times_async(2, move || {
let attempt = attempt_clone.clone();
async move {
let mut a = attempt.borrow_mut();
*a += 1;
Err("always fails")
}
}));
assert_eq!(result, Err("always fails"));
assert_eq!(*attempt.borrow(), 3); // 0..=2 means 3 attempts
}
#[test]
fn test_retry_and_success_with_condition() {
let mut attempt = 0;
let result: Result<i32, String> = retry_conditionally(
|| {
attempt += 1;
if attempt < 3 {
Err(format!("failed attempt {}", attempt))
} else {
Ok(42)
}
},
|err| {
// Retry as long as it's not the 3rd attempt
!err.contains("attempt 3")
},
);
assert_eq!(result, Ok(42));
assert_eq!(attempt, 3);
}
#[test]
fn test_retry_and_stop_early() {
let mut attempt = 0;
let result: Result<i32, String> = retry_conditionally(
|| {
attempt += 1;
Err(format!("failed attempt {}", attempt))
},
|_err| {
// Stop after first failure
false
},
);
assert!(result.is_err());
assert_eq!(attempt, 1);
assert_eq!(result.unwrap_err(), "failed attempt 1");
}
#[test]
fn test_retry_async_success_with_condition() {
let attempt = Rc::new(RefCell::new(0));
let attempt_clone = attempt.clone();
let result: Result<i32, String> = block_on(retry_conditionally_async(
move || {
let attempt = attempt_clone.clone();
async move {
let mut a = attempt.borrow_mut();
*a += 1;
if *a < 3 {
Err(format!("failed attempt {}", *a))
} else {
Ok(42)
}
}
},
|err| {
// Retry as long as it's not the 3rd attempt
!err.contains("attempt 3")
},
));
assert_eq!(result, Ok(42));
assert_eq!(*attempt.borrow(), 3);
}
#[test]
fn test_retry_async_stop_early() {
let attempt = Rc::new(RefCell::new(0));
let attempt_clone = attempt.clone();
let result: Result<i32, String> = block_on(retry_conditionally_async(
move || {
let attempt = attempt_clone.clone();
async move {
let mut a = attempt.borrow_mut();
*a += 1;
Err(format!("failed attempt {}", *a))
}
},
|_err| {
// Stop after first failure
false
},
));
assert!(result.is_err());
assert_eq!(*attempt.borrow(), 1);
assert_eq!(result.unwrap_err(), "failed attempt 1");
}
}
+6
View File
@@ -2,7 +2,13 @@ pub mod actors;
pub mod controllers;
pub mod converters;
pub mod ethercat;
pub mod helpers;
pub mod machines;
pub mod modbus;
pub mod rest;
pub mod serial;
pub mod socketio;
pub mod uom_extensions;
#[macro_use]
extern crate uom;
+3 -2
View File
@@ -1,7 +1,8 @@
use crate::socketio::namespace::NamespaceInterface;
use serde_json::Value;
use crate::socketio::namespace::Namespace;
pub trait MachineApi {
fn api_mutate(&mut self, value: Value) -> Result<(), anyhow::Error>;
fn api_event_namespace(&mut self) -> &mut dyn NamespaceInterface;
fn api_event_namespace(&mut self) -> &mut Namespace;
}
+17 -1
View File
@@ -1,3 +1,5 @@
use std::fmt::Display;
use serde::Deserialize;
use serde::Serialize;
@@ -15,6 +17,16 @@ impl MachineIdentificationUnique {
}
}
impl Display for MachineIdentificationUnique {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}/{}/{}",
self.machine_identification.vendor, self.machine_identification.machine, self.serial
)
}
}
/// Identifies a machine
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct MachineIdentification {
@@ -88,10 +100,14 @@ impl TryFrom<DeviceIdentification> for DeviceIdentificationIdentified {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum DeviceHardwareIdentification {
Ethercat(DeviceHardwareIdentificationEthercat),
// UsbDevice...,
Serial(DeviceHardwareIdentificationSerial),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeviceHardwareIdentificationEthercat {
pub subdevice_index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeviceHardwareIdentificationSerial {
pub path: String,
}
+70 -10
View File
@@ -1,17 +1,26 @@
use smol::lock::Mutex;
use crate::machines::{
identification::MachineIdentificationUnique,
new::{MachineNewHardware, MachineNewHardwareEthercat, MachineNewParams},
use smol::{
channel::Sender,
lock::{Mutex, RwLock},
};
use std::collections::HashMap;
use socketioxide::extract::SocketRef;
use crate::{
machines::{
identification::MachineIdentificationUnique,
new::{MachineNewHardware, MachineNewHardwareEthercat, MachineNewParams},
},
serial::SerialDevice,
socketio::event::GenericEvent,
};
use std::{collections::HashMap, sync::Arc};
use super::{
Machine,
identification::{DeviceIdentification, DeviceIdentificationIdentified},
new::MachineNewHardwareSerial,
registry::MachineRegistry,
};
#[derive(Debug)]
pub struct MachineManager {
pub ethercat_machines:
HashMap<MachineIdentificationUnique, Result<Box<Mutex<dyn Machine>>, anyhow::Error>>,
@@ -32,6 +41,7 @@ impl MachineManager {
device_identifications: &Vec<DeviceIdentification>,
machine_registry: &MachineRegistry,
hardware: &MachineNewHardwareEthercat,
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
) {
// empty ethercat machines
self.ethercat_machines.clear();
@@ -39,9 +49,8 @@ impl MachineManager {
// group devices by machine device identification
let device_grouping_result = group_devices_by_identification(device_identifications);
log::info!(
"[{}::set_ethercat_devices] Device Groups {:?}",
module_path!(),
tracing::info!(
"Device Groups {}",
device_grouping_result.device_groups.len()
);
@@ -59,6 +68,7 @@ impl MachineManager {
let new_machine = machine_registry.new_machine(&MachineNewParams {
device_group,
hardware: &machine_new_hardware,
socket_queue_tx: socket_queue_tx.clone(),
});
// insert the machine into the ethercat machines map
@@ -77,6 +87,56 @@ impl MachineManager {
.get(machine_identification)
.or_else(|| self.serial_machines.get(machine_identification))
}
pub fn add_serial_device(
&mut self,
device_identification: &DeviceIdentification,
device: Arc<RwLock<dyn SerialDevice>>,
machine_registry: &MachineRegistry,
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
) {
let hardware = MachineNewHardwareSerial { device };
let device_identification_identified: DeviceIdentificationIdentified =
device_identification
.clone()
.try_into()
.expect("Serial devices always have machine identification");
let new_machine = machine_registry.new_machine(&MachineNewParams {
device_group: &vec![device_identification_identified.clone()],
hardware: &MachineNewHardware::Serial(&hardware),
socket_queue_tx,
});
tracing::info!("Adding serial machine {:?}", new_machine);
self.serial_machines.insert(
device_identification_identified
.device_machine_identification
.machine_identification_unique,
new_machine,
);
}
pub fn remove_serial_device(&mut self, device_identification: &DeviceIdentification) {
let device_identification_identified: DeviceIdentificationIdentified =
device_identification
.clone()
.try_into()
.expect("Serial devices always have machine identification");
tracing::info!(
"Removing serial machine {:?}",
device_identification_identified
);
self.serial_machines.remove(
&device_identification_identified
.device_machine_identification
.machine_identification_unique,
);
}
}
/// Groups devices by machine identification
-1
View File
@@ -3,7 +3,6 @@ use api::MachineApi;
use new::MachineNewTrait;
use std::any::Any;
use std::fmt::Debug;
pub mod api;
pub mod identification;
pub mod manager;
+19 -3
View File
@@ -1,13 +1,20 @@
use crate::serial::SerialDevice;
use super::identification::DeviceIdentificationIdentified;
use anyhow::Error;
use ethercat_hal::{devices::EthercatDevice, types::EthercrabSubDevicePreoperational};
use ethercat_hal::{
devices::EthercatDevice, helpers::ethercrab_types::EthercrabSubDevicePreoperational,
};
use ethercrab::{SubDevice, SubDeviceRef};
use smol::lock::RwLock;
use smol::{channel::Sender, lock::RwLock};
use socketioxide::extract::SocketRef;
use std::sync::Arc;
use crate::socketio::event::GenericEvent;
pub trait MachineNewTrait {
fn new<'maindevice, 'subdevices>(
params: &MachineNewParams<'maindevice, 'subdevices, '_, '_, '_, '_>,
params: &MachineNewParams<'maindevice, 'subdevices, '_, '_, '_, '_, '_>,
) -> Result<Self, Error>
where
Self: Sized;
@@ -19,6 +26,7 @@ pub struct MachineNewParams<
'device_identifications_identified,
'ethercat_devices,
'machine_new_hardware_etehrcat,
'machine_new_hardware_serial,
'machine_new_hardware,
> where
'maindevice: 'machine_new_hardware,
@@ -32,7 +40,9 @@ pub struct MachineNewParams<
'subdevices,
'ethercat_devices,
'machine_new_hardware_etehrcat,
'machine_new_hardware_serial,
>,
pub socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
}
pub enum MachineNewHardware<
@@ -40,6 +50,7 @@ pub enum MachineNewHardware<
'subdevices,
'ethercat_devices,
'machine_new_hardware_etehrcat,
'machine_new_hardware_serial,
> where
'maindevice: 'machine_new_hardware_etehrcat,
'subdevices: 'machine_new_hardware_etehrcat,
@@ -52,6 +63,7 @@ pub enum MachineNewHardware<
'ethercat_devices,
>,
),
Serial(&'machine_new_hardware_serial MachineNewHardwareSerial),
}
pub struct MachineNewHardwareEthercat<'maindevice, 'subdevices, 'ethercat_devices> {
@@ -60,6 +72,10 @@ pub struct MachineNewHardwareEthercat<'maindevice, 'subdevices, 'ethercat_device
pub ethercat_devices: &'ethercat_devices Vec<Arc<RwLock<dyn EthercatDevice>>>,
}
pub struct MachineNewHardwareSerial {
pub device: Arc<RwLock<dyn SerialDevice>>,
}
// validates that all devices in the group have the same machine identification
pub fn validate_same_machine_identification_unique(
identified_device_group: &Vec<DeviceIdentificationIdentified>,
+2 -40
View File
@@ -1,11 +1,7 @@
use super::{Machine, identification::MachineIdentification, new::MachineNewParams};
use anyhow::Error;
use smol::lock::{Mutex, RwLock};
use std::{
any::{Any, TypeId},
collections::HashMap,
sync::Arc,
};
use smol::lock::Mutex;
use std::{any::TypeId, collections::HashMap};
pub type MachineNewClosure =
Box<dyn Fn(&MachineNewParams) -> Result<Box<Mutex<dyn Machine>>, Error> + Send + Sync>;
@@ -69,38 +65,4 @@ impl MachineRegistry {
// call machine new function by reference
(machine_new_closure)(machine_new_params)
}
pub fn downcast_arc_rwlock<T: Machine + 'static>(
&self,
machine: Arc<RwLock<dyn Machine>>,
) -> Result<Arc<RwLock<T>>, Error> {
if TypeId::of::<T>() == machine.type_id() {
// transmute Arc
let arc = unsafe { Arc::from_raw(Arc::into_raw(machine) as *const RwLock<T>) };
Ok(arc)
} else {
Err(anyhow::anyhow!(
"[{}::MachineConstructor::downcast] Machine is not of type {}",
module_path!(),
std::any::type_name::<T>()
))
}
}
pub fn downcast_box_rwlock<T: Machine + 'static>(
&self,
machine: Box<RwLock<dyn Machine>>,
) -> Result<RwLock<T>, Error> {
if TypeId::of::<T>() == machine.type_id() {
// transmute Box
let box_machine = unsafe { Box::from_raw(Box::into_raw(machine) as *mut RwLock<T>) };
Ok(*box_machine)
} else {
Err(anyhow::anyhow!(
"[{}::MachineConstructor::downcast] Machine is not of type {}",
module_path!(),
std::any::type_name::<T>()
))
}
}
}
+51 -31
View File
@@ -1,7 +1,7 @@
use std::time::Duration;
use anyhow::Error;
use crc::{CRC_16_MODBUS, Crc};
use serial;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParityType {
Even,
@@ -14,6 +14,8 @@ pub enum ParityType {
pub enum ModbusFunctionCode {
/// Read one or more Registers
ReadHoldingRegister,
/// Read Input register
ReadInputRegister,
/// write one Register Value
PresetHoldingRegister,
/// The response should echo back your request
@@ -35,30 +37,13 @@ pub struct ModbusResponse {
pub crc: u16,
}
impl ModbusResponse {
fn is_exception(self) -> bool {
if self.data.len() > 2 {
let function_code = self.data[1];
return (function_code & 0b10000000) > 0; // first bit is set when an exception happens in Modbus
}
return false;
}
fn get_exception_code(self) -> u8 {
if self.data.len() > 3 {
let exception = self.data[2];
return exception;
}
return 0;
}
}
impl TryFrom<u8> for ModbusFunctionCode {
type Error = anyhow::Error;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0x03 => Ok(ModbusFunctionCode::ReadHoldingRegister),
0x04 => Ok(ModbusFunctionCode::ReadInputRegister),
0x06 => Ok(ModbusFunctionCode::PresetHoldingRegister),
0x08 => Ok(ModbusFunctionCode::DiagnoseFunction),
_ => Err(anyhow::anyhow!("Error: Modbus Function Code doesnt exist!")),
@@ -70,6 +55,7 @@ impl From<ModbusFunctionCode> for u8 {
fn from(value: ModbusFunctionCode) -> Self {
match value {
ModbusFunctionCode::ReadHoldingRegister => 0x03,
ModbusFunctionCode::ReadInputRegister => 0x04,
ModbusFunctionCode::PresetHoldingRegister => 0x06,
ModbusFunctionCode::DiagnoseFunction => 0x08,
}
@@ -94,41 +80,75 @@ impl From<ModbusRequest> for Vec<u8> {
return buffer;
}
}
/// Reads data from a Modbus device via a serial port.
///
/// # Parameters
/// - `port`: A mutable reference to a serial port.
///
/// # Returns
/// A `Result` containing an `Option` with the raw Modbus response as a `Vec<u8>` if successful,
/// or `None` if the response is invalid or an error occurs.
pub fn receive_data_modbus(
port: &mut dyn serial::SerialPort,
) -> Result<Option<Vec<u8>>, anyhow::Error> {
let mut buf: [u8; 256] = [0; 256];
let data_length = port.read(&mut buf)?;
if data_length == 0 {
return Ok(None);
}
validate_modbus_response(buf[..data_length].to_vec()).map(Some)
}
fn validate_modbus_response(raw_data: Vec<u8>) -> Result<(), Error> {
if raw_data.len() == 0 {
/// Computes Modbus CRC-16 using `crc` crate.
pub fn modbus_crc16(data: &[u8]) -> u16 {
let modbus: Crc<u16> = Crc::<u16>::new(&CRC_16_MODBUS);
modbus.checksum(data)
}
/// Checks if the message's CRC is correct (uses Modbus CRC-16).
fn check_crc(raw_data: &Vec<u8>) -> Result<bool, Error> {
let expected_crc = extract_crc(raw_data)?;
let actual_crc = modbus_crc16(&raw_data[..raw_data.len() - 2]);
Ok(expected_crc == actual_crc)
}
fn validate_modbus_response(raw_data: Vec<u8>) -> Result<Vec<u8>, Error> {
if raw_data.is_empty() {
return Err(anyhow::anyhow!("Error: Response is Empty!"));
}
// 5 is the smallest possible Response Size
if raw_data.len() < 5 {
println!("raw_data.len() < 5");
if raw_data.len() <= 5 {
return Err(anyhow::anyhow!(
"Error: Response is invalid, its less than 5 bytes"
));
}
// check if the slave id is valid
if raw_data[0] < 1 || raw_data[0] > 247 {
return Err(anyhow::anyhow!(
"Error: Response is invalid, slave_id is outside of the valid range 1-247"
));
}
return Ok(());
if check_crc(&raw_data)? {
Ok(raw_data)
} else {
Err(anyhow::anyhow!("Error: CRC does not match"))
}
}
// expects to be given the entire raw message
fn extract_crc(raw_data: &Vec<u8>) -> Result<u16, Error> {
let raw_data1 = raw_data.clone();
let result = validate_modbus_response(raw_data1);
if let Err(error) = result {
return Err(error);
if raw_data.len() < 5 {
return Err(anyhow::anyhow!("Not enough data to extract CRC"));
}
let low_byte = raw_data[raw_data.len() - 2];
let high_byte = raw_data[raw_data.len() - 1];
let crc = u16::from_le_bytes([low_byte, high_byte]);
Ok(crc)
}
impl TryFrom<Vec<u8>> for ModbusResponse {
type Error = anyhow::Error;
fn try_from(value: Vec<u8>) -> Result<ModbusResponse, Error> {
+35
View File
@@ -0,0 +1,35 @@
use std::{any::Any, sync::Arc};
use smol::lock::RwLock;
use std::fmt::Debug;
use crate::machines::identification::DeviceIdentification;
pub mod panic;
pub mod registry;
pub mod serial_detection;
pub trait SerialDevice: Any + Send + Sync + SerialDeviceNew + Debug {}
pub trait SerialDeviceNew {
fn new_serial(
params: &SerialDeviceNewParams,
) -> Result<(DeviceIdentification, Arc<RwLock<Self>>), anyhow::Error>
where
Self: Sized;
}
pub trait SerialDeviceThread {
fn start_thread() -> Result<(), anyhow::Error>;
}
pub struct SerialDeviceNewParams {
pub path: String,
pub device_thread_panic_tx: smol::channel::Sender<(String, anyhow::Error)>,
}
#[derive(PartialEq, Clone, Debug)]
pub struct SerialDeviceIdentification {
pub vendor_id: u16,
pub product_id: u16,
}
+52
View File
@@ -0,0 +1,52 @@
use smol::channel::Sender;
/// Device-level panic handler for individual device crashes
/// Sends device identifier and error information for cleanup
/// Used by serial devices and other individual components that need to be removed on panic
pub fn send_serial_device_panic<T>(
device_identifier: T,
thread_panic_tx: Sender<(T, anyhow::Error)>,
) where
T: Send + Sync + Clone + 'static,
{
// Ensure backtrace is enabled for panics
if std::env::var("RUST_BACKTRACE").is_err() {
unsafe {
std::env::set_var("RUST_BACKTRACE", "full");
}
}
let device_id_clone = device_identifier.clone();
std::panic::set_hook(Box::new(move |panic_info| {
let thread_name = std::thread::current()
.name()
.unwrap_or("unnamed")
.to_string();
let location = panic_info.location().map_or("unknown".to_string(), |loc| {
format!("{}:{}:{}", loc.file(), loc.line(), loc.column())
});
let message = panic_info
.payload()
.downcast_ref::<&str>()
.unwrap_or(&"Box<dyn Any>")
.to_string();
let panic_message = format!(
"Device panicked in thread '{}' at {}: {}",
thread_name, location, message
);
// Send device panic info through channel
smol::block_on(async {
let _ = thread_panic_tx
.send((
device_id_clone.clone(),
anyhow::anyhow!("{}", panic_message),
))
.await;
});
// Note: We don't call old_hook to avoid duplicate logging
// All logging is handled in the main thread when receiving panic details
}));
}
+90
View File
@@ -0,0 +1,90 @@
use super::SerialDeviceNewParams;
use crate::{
machines::identification::DeviceIdentification,
serial::{SerialDevice, SerialDeviceIdentification},
};
use anyhow::{Error, Result};
use smol::lock::RwLock;
use std::{any::TypeId, collections::HashMap, sync::Arc};
pub type SerialDeviceNewClosure = Arc<
dyn Fn(
&SerialDeviceNewParams,
) -> Result<(DeviceIdentification, Arc<RwLock<dyn SerialDevice>>), Error>
+ Send
+ Sync,
>;
#[derive(Clone)]
pub struct SerialDeviceRegistry {
pub type_map: HashMap<TypeId, (SerialDeviceIdentification, SerialDeviceNewClosure)>,
}
impl SerialDeviceRegistry {
pub fn new() -> Self {
Self {
type_map: HashMap::new(),
}
}
pub fn register<T: SerialDevice + 'static>(
&mut self,
serial_device_identification: SerialDeviceIdentification,
) {
self.type_map.insert(
TypeId::of::<T>(),
(
serial_device_identification,
Arc::new(move |params| {
let (identification, device) = T::new_serial(params)?;
Ok((identification, device))
}),
),
);
}
pub fn new_serial_device(
&self,
serial_device_new_params: &SerialDeviceNewParams,
serial_device_identification: &SerialDeviceIdentification,
) -> Result<(DeviceIdentification, Arc<RwLock<dyn SerialDevice>>), anyhow::Error> {
// find serial new function by comparing ProdutConfig
let (_, serial_new_fn) = self
.type_map
.values()
.find(|(sdi, _)| sdi == serial_device_identification)
.ok_or(anyhow::anyhow!(
"[{}::MachineConstructor::new_machine] Machine not found",
module_path!()
))?;
// call machine new function by reference
(serial_new_fn)(serial_device_new_params)
}
pub async fn downcast_arc_rwlock<T: SerialDevice + 'static>(
&self,
serial_device: Arc<RwLock<dyn SerialDevice>>,
) -> Result<Arc<RwLock<T>>, Error> {
// Use the Any trait for type checking
let type_id = {
let type_id_fn = Arc::new(|device: &dyn SerialDevice| device.type_id());
let guard = serial_device.read().await;
let id = type_id_fn(&*guard);
drop(guard);
id
};
if TypeId::of::<T>() == type_id {
// transmute Arc
let arc = unsafe { Arc::from_raw(Arc::into_raw(serial_device) as *const RwLock<T>) };
Ok(arc)
} else {
Err(anyhow::anyhow!(
"[{}::MachineConstructor::downcast] Machine is not of type {}",
module_path!(),
std::any::type_name::<T>()
))
}
}
}
+181
View File
@@ -0,0 +1,181 @@
/*
*@author: Alisher Darmenov
*@company: QiTech
*@created: 20.04.2025
*
*@description: This module is responsible for usb detection and validation, specially made with serialport to avoid complexity and size of tokio_serial
*/
use serialport::{SerialPortInfo, SerialPortType, UsbPortInfo};
use smol::{
channel::{Receiver, Sender, unbounded},
lock::RwLock,
};
use std::{collections::HashMap, sync::Arc};
use crate::{
helpers::compare_lists::compare_lists, machines::identification::DeviceIdentification,
};
use super::{
SerialDevice, SerialDeviceIdentification, SerialDeviceNewParams, registry::SerialDeviceRegistry,
};
pub struct SerialDetection<'serialdeviceregistry> {
pub serial_device_registry: &'serialdeviceregistry SerialDeviceRegistry,
pub ports: HashMap<
String,
(
UsbPortInfo,
DeviceIdentification,
Arc<RwLock<dyn SerialDevice>>,
),
>,
pub device_removal_signal_rx: Receiver<(String, anyhow::Error)>,
pub device_removal_signal_tx: Sender<(String, anyhow::Error)>,
}
impl<'serialdeviceregistry> SerialDetection<'serialdeviceregistry> {
pub fn new(serial_device_registry: &'serialdeviceregistry SerialDeviceRegistry) -> Self {
let (device_removal_signal_tx, device_removal_signal_rx) =
unbounded::<(String, anyhow::Error)>();
SerialDetection {
serial_device_registry,
ports: HashMap::new(),
device_removal_signal_rx,
device_removal_signal_tx,
}
}
/*
*@return: Option::None if there are no available ports on device
*@return: Option::Some(Vec) with all available ports
*
*@description: detects all available ports and returns them as list
*/
fn get_ports() -> Vec<SerialPortInfo> {
let all = match serialport::available_ports() {
Ok(ports) => ports,
Err(_) => return Vec::new(),
};
// exclude ports that contai "cu" in their name
all.into_iter()
.filter(|port| !port.port_name.contains("cu"))
.collect::<Vec<_>>()
}
/* @param: port_list -> list of available ports
*
* @return: Vec<(String, UsbPortInfo)> -> list of ports with their names and usb information
*
* @description: This function extracts the USB serial devices from the given list of ports.
*/
fn extract_usb_serial_devices(
&self,
port_list: Vec<SerialPortInfo>,
) -> Vec<(String, UsbPortInfo)> {
let mut usb_ports: Vec<(String, UsbPortInfo)> = Vec::new();
for port in port_list {
match &port.port_type {
SerialPortType::UsbPort(usb_info) => {
usb_ports.push((port.port_name.to_string(), usb_info.clone()));
}
_ => {}
};
}
return usb_ports;
}
pub async fn check_ports(&mut self) -> CheckPortsResult {
// get available ports
let ports = Self::get_ports();
// extract all ports that are usb ports
let usb_ports = self.extract_usb_serial_devices(ports);
let last_usb_ports = self
.ports
.iter()
.map(|last_usb_port| (last_usb_port.0.clone(), last_usb_port.1.0.clone()))
.collect::<Vec<_>>();
let usb_ports_diff = compare_lists(&last_usb_ports, &usb_ports);
let mut result = CheckPortsResult {
added: Vec::new(),
removed: Vec::new(),
};
// reamove ports
for removed in usb_ports_diff.removed {
if let Some((_, device_identification, _)) = self.ports.remove(&removed.0) {
result.removed.push(device_identification);
}
tracing::trace!("Removed port: {}", removed.0);
}
// add new ports
for added in usb_ports_diff.added {
// add the port to the list
let serial_device_identification = SerialDeviceIdentification {
vendor_id: added.1.vid,
product_id: added.1.pid,
};
let device_result = self.serial_device_registry.new_serial_device(
&SerialDeviceNewParams {
path: added.0.clone(),
device_thread_panic_tx: self.device_removal_signal_tx.clone(),
},
&serial_device_identification,
);
// only if created device driver sucessfully
if let Ok((device_identification, device)) = device_result {
// add the device to the ports list
self.ports.insert(
added.0.clone(),
(
added.1.clone(),
device_identification.clone(),
device.clone(),
),
);
// add to result list
result.added.push((device_identification, device.clone()));
tracing::trace!("Added port: {}", added.0);
}
}
result
}
pub async fn check_remove_signals(&mut self) -> Vec<DeviceIdentification> {
let mut removed_signals: Vec<DeviceIdentification> = Vec::new();
match self.device_removal_signal_rx.try_recv() {
Ok((path, error)) => {
// remove the device when the tuple positon 1 equals signal
if let Some(info) = self.ports.get(&path.clone()) {
removed_signals.push(info.1.clone());
};
self.ports.remove(&path);
tracing::trace!("Removed device: {:?}: {}", path, error);
}
Err(_) => {}
}
removed_signals
}
}
#[derive(Debug)]
pub struct CheckPortsResult {
pub added: Vec<(DeviceIdentification, Arc<RwLock<dyn SerialDevice>>)>,
pub removed: Vec<DeviceIdentification>,
}
+27 -21
View File
@@ -1,20 +1,30 @@
use std::time::{SystemTime, UNIX_EPOCH};
use erased_serde::Serialize as ErasedSerialize;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, Serialize)]
#[derive(Serialize)]
pub struct GenericEvent {
pub name: String,
pub data: Value,
pub data: Box<dyn ErasedSerialize + Send + Sync>,
/// Timestamp in milliseconds
pub ts: u64,
}
impl std::fmt::Debug for GenericEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GenericEvent")
.field("name", &self.name)
.field("data", &"[erased]")
.field("ts", &self.ts)
.finish()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Event<T>
where
T: Serialize,
T: Serialize + Send + Sync + 'static,
{
pub name: String,
pub data: T,
@@ -22,39 +32,35 @@ where
pub ts: u64,
}
impl<T> TryFrom<Event<T>> for GenericEvent
impl<T> From<Event<T>> for GenericEvent
where
T: Serialize,
T: Serialize + Send + Sync + 'static,
{
type Error = serde_json::Error;
fn try_from(event: Event<T>) -> Result<Self, Self::Error> {
Ok(Self {
fn from(event: Event<T>) -> Self {
Self {
name: event.name,
data: serde_json::to_value(event.data)?,
data: Box::new(event.data),
ts: event.ts,
})
}
}
}
impl<T> TryFrom<&Event<T>> for GenericEvent
impl<T> From<&Event<T>> for GenericEvent
where
T: Serialize,
T: Serialize + Clone + Send + Sync + 'static,
{
type Error = serde_json::Error;
fn try_from(event: &Event<T>) -> Result<Self, Self::Error> {
Ok(Self {
fn from(event: &Event<T>) -> Self {
Self {
name: event.name.clone(),
data: serde_json::to_value(&event.data)?,
data: Box::new(event.data.clone()),
ts: event.ts,
})
}
}
}
impl<T> Event<T>
where
T: Serialize + Clone,
T: Serialize + Clone + Send + Sync + 'static,
{
pub fn new(event: &str, data: T) -> Self {
Self {
+141 -117
View File
@@ -1,25 +1,48 @@
use crate::socketio::event::GenericEvent;
use smol::channel::Sender;
use socketioxide::extract::SocketRef;
use std::{
alloc::System,
collections::HashMap,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use std::{collections::HashMap, sync::Arc, time::Duration};
use tracing::instrument;
pub trait NamespaceInterface {
#[derive(Debug)]
pub struct Namespace {
pub sockets: Vec<SocketRef>,
pub events: HashMap<String, Vec<Arc<GenericEvent>>>,
pub socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
}
impl Namespace {
pub fn new(socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>) -> Self {
Self {
sockets: vec![],
events: HashMap::new(),
socket_queue_tx,
}
}
}
impl Namespace {
/// Adds a socket to the namespace.
///
/// # Arguments
///
/// * `socket` - A reference to the socket to be added
fn subscribe(&mut self, socket: SocketRef);
#[instrument(skip_all)]
pub fn subscribe(&mut self, socket: SocketRef) {
// add the socket to the list
self.sockets.push(socket.clone());
}
/// Removes a socket from the namespace.
///
/// # Arguments
///
/// * `socket` - A reference to the socket to be removed
fn unsubscribe(&mut self, socket: SocketRef);
#[instrument(skip_all)]
pub fn unsubscribe(&mut self, socket: SocketRef) {
// remove the socket from the list
self.sockets.retain(|s| s.id != socket.id);
}
/// Re-emits cached events to a specific socket.
///
@@ -29,14 +52,23 @@ pub trait NamespaceInterface {
/// # Arguments
///
/// * `socket` - A reference to the socket that will receive the cached events
fn reemit(&mut self, socket: SocketRef);
#[instrument(skip_all)]
pub fn reemit(&mut self, socket: SocketRef) {
// Collect events grouped by name/kind with their counts for sorting
let mut event_groups: Vec<(&String, &Vec<Arc<GenericEvent>>)> =
self.events.iter().collect();
/// Emits an event to all sockets in the namespace.
///
/// # Arguments
///
/// * `event` - The event to be emitted
fn emit(&mut self, event: &GenericEvent);
// Sort by event count (ascending - lowest count first)
event_groups.sort_by(|a, b| a.1.len().cmp(&b.1.len()));
// Emit events in order of lowest count first
for (_event_name, events) in event_groups {
for event in events {
// Send to global queue instead of per-socket queue
self.send_to_queue(&socket, &event, "reemit");
}
}
}
/// Caches an event with a specific key for later retrieval.
///
@@ -45,97 +77,79 @@ pub trait NamespaceInterface {
/// * `cache_key` - A string key to identify the cache
/// * `event` - The event to be cached
/// * `buffer_fn` - A function that defines how the event should be added to the cache buffer
#[instrument(skip_all)]
fn cache(
&mut self,
event: &GenericEvent,
buffer_fn: &Box<dyn Fn(&mut Vec<GenericEvent>, &GenericEvent) -> ()>,
);
/// Emits an event to all sockets in the namespace and caches it.
///
/// This is a convenience method that combines the functionality of
/// [`Namespace::emit`] and [`Namespace::cache`].
///
/// # Arguments
///
/// * `event` - The event to be emitted and cached
/// * `cache_key` - A string key to identify the cache
/// * `buffer_fn` - A function that defines how the event should be added to the cache buffer
fn emit_cached(
&mut self,
event: &GenericEvent,
buffer_fn: &Box<dyn Fn(&mut Vec<GenericEvent>, &GenericEvent) -> ()>,
);
}
#[derive(Debug)]
pub struct Namespace {
sockets: Vec<SocketRef>,
events: HashMap<String, Vec<GenericEvent>>,
}
impl Namespace {
pub fn new() -> Self {
Self {
sockets: vec![],
events: HashMap::new(),
}
}
}
impl NamespaceInterface for Namespace {
fn subscribe(&mut self, socket: SocketRef) {
// add the socket to the list
self.sockets.push(socket.clone());
}
fn unsubscribe(&mut self, socket: SocketRef) {
// remove the socket from the list
self.sockets.retain(|s| s.id != socket.id);
}
fn reemit(&mut self, socket: SocketRef) {
for (_, events) in self.events.iter() {
log::debug!(
"Re-emitting {} {} events to socket {}",
events.len(),
events[0].name,
socket.id
);
for event in events.iter() {
let _ = socket.emit("event", &event);
}
}
}
fn emit(&mut self, event: &GenericEvent) {
for socket in self.sockets.iter() {
let _ = socket.emit("event", &event);
}
}
fn cache(
&mut self,
event: &GenericEvent,
buffer_fn: &Box<dyn Fn(&mut Vec<GenericEvent>, &GenericEvent) -> ()>,
event: Arc<GenericEvent>,
buffer_fn: &Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>) -> ()>,
) {
let mut cached_events_for_key = self
.events
.entry(event.name.clone())
.or_insert_with(Vec::new);
buffer_fn(&mut cached_events_for_key, event);
buffer_fn(&mut cached_events_for_key, &event);
}
fn emit_cached(
/// Emits an event to all sockets in the namespace and caches it.
///
///
/// # Arguments
///
/// * `event` - The event to be emitted and cached
/// * `buffer_fn` - A function that defines how the event should be added to the cache buffer
#[instrument(skip_all)]
pub fn emit(
&mut self,
event: &GenericEvent,
buffer_fn: &Box<dyn Fn(&mut Vec<GenericEvent>, &GenericEvent) -> ()>,
event: Arc<GenericEvent>,
buffer_fn: &Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>) -> ()>,
) {
// cache the event
self.cache(event, buffer_fn);
self.cache(event.clone(), &buffer_fn);
// emit the event
self.emit(event);
// emit the event - inlined from emit function
// Send to global queue for each socket in the namespace
for socket in self.sockets.clone() {
self.send_to_queue(&socket, &event, "emit");
}
}
/// Sends an event to the global queue for a specific socket.
///
/// # Arguments
///
/// * `socket` - The socket to send the event to
/// * `event` - The event to be sent
/// * `operation` - A description of the operation for logging purposes (e.g., "reemit", "emit")
#[instrument(skip_all)]
fn send_to_queue(&self, socket: &SocketRef, event: &Arc<GenericEvent>, operation: &str) {
tracing::trace!(
socket_id = ?socket.id,
event = %event.name,
operation = %operation,
"Sending event to global queue"
);
match self
.socket_queue_tx
.try_send((socket.clone(), event.clone()))
{
Ok(_) => {
tracing::trace!(
socket_id = ?socket.id,
event = %event.name,
operation = %operation,
"Successfully sent event to global queue"
);
}
Err(e) => {
tracing::error!(
socket_id = ?socket.id,
event = %event.name,
operation = %operation,
error = %e,
"Failed to send event to global queue"
);
}
}
}
}
@@ -147,15 +161,15 @@ pub trait NamespaceCacheingLogic<V>
where
V: CacheableEvents<V>,
{
fn emit_cached(&mut self, event: V);
fn emit(&mut self, event: V);
}
pub trait CacheableEvents<Events> {
fn event_value(&self) -> Result<GenericEvent, serde_json::Error>;
fn event_value(&self) -> GenericEvent;
fn event_cache_fn(&self) -> CacheFn;
}
pub type CacheFn = Box<dyn Fn(&mut Vec<GenericEvent>, &GenericEvent) -> ()>;
pub type CacheFn = Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>) -> ()>;
/// [BufferFn] that stores the last n events
pub fn cache_n_events(n: usize) -> CacheFn {
@@ -179,9 +193,10 @@ pub fn cache_duration(duration: Duration, bucket_size: Duration) -> CacheFn {
let current_time = event.ts as u128;
// calculate current bucket & last bucket
let bucket = current_time / bucket_size.as_millis();
let bucket_size_ms = bucket_size.as_millis().max(1);
let bucket = current_time / bucket_size_ms; // Avoid division by zero
let last_bucket = match events.last() {
Some(last_event) => last_event.ts as u128 / bucket_size.as_millis(),
Some(last_event) => last_event.ts as u128 / bucket_size_ms,
None => 0,
};
@@ -211,24 +226,32 @@ pub fn cache_duration(duration: Duration, bucket_size: Duration) -> CacheFn {
mod tests {
use std::cmp::min;
use serde::Serialize;
use super::*;
#[derive(Debug, Clone, Serialize)]
struct TestEventData {
value: usize,
}
#[test]
fn test_cache_n_events() {
// Create a cache function that stores the last 2 events
let cache_fn = cache_n_events(2);
// Create namespace
let mut namespace = Namespace::new();
// Create namespace with dummy queue_tx
let (queue_tx, _queue_rx) = smol::channel::unbounded();
let mut namespace = Namespace::new(queue_tx);
assert!(namespace.events.is_empty());
// Add event
let event1 = GenericEvent {
let event1 = Arc::new(GenericEvent {
name: "test_event".to_string(),
data: serde_json::json!({"value": 1}),
data: Box::new(TestEventData { value: 1 }),
ts: 0,
};
namespace.cache(&event1, &cache_fn);
});
namespace.cache(event1, &cache_fn);
// Check that we have one event name in the map
assert_eq!(namespace.events.len(), 1);
@@ -237,12 +260,12 @@ mod tests {
assert_eq!(namespace.events.get("test_event").unwrap()[0].ts, 0);
// Add another event
let event2 = GenericEvent {
let event2 = Arc::new(GenericEvent {
name: "test_event".to_string(),
data: serde_json::json!({"value": 2}),
data: Box::new(TestEventData { value: 2 }),
ts: 1,
};
namespace.cache(&event2, &cache_fn);
});
namespace.cache(event2, &cache_fn);
// Still one event name
assert_eq!(namespace.events.len(), 1);
@@ -252,12 +275,12 @@ mod tests {
assert_eq!(namespace.events.get("test_event").unwrap()[1].ts, 1);
// Add a third event, which should remove the first one
let event3 = GenericEvent {
let event3 = Arc::new(GenericEvent {
name: "test_event".to_string(),
data: serde_json::json!({"value": 3}),
data: Box::new(TestEventData { value: 3 }),
ts: 2,
};
namespace.cache(&event3, &cache_fn);
});
namespace.cache(event3, &cache_fn);
// Still one event name
assert_eq!(namespace.events.len(), 1);
@@ -276,19 +299,20 @@ mod tests {
let duration = Duration::new(10, 0);
let bucket_size = Duration::new(1, 0);
let cache_fn = cache_duration(duration, bucket_size);
let (queue_tx, _queue_rx) = smol::channel::unbounded();
// Create namespace
let mut namespace = Namespace::new();
let mut namespace = Namespace::new(queue_tx);
assert!(namespace.events.is_empty());
// Add events every 100ms for 20 seconds
for i in 0..200 {
let event = GenericEvent {
let event = Arc::new(GenericEvent {
name: "test_event".to_string(),
data: serde_json::json!({"value": i}),
data: Box::new(TestEventData { value: i }),
ts: (i * 100) as u64,
};
namespace.cache(&event, &cache_fn);
});
namespace.cache(event, &cache_fn);
let should_have_events = min(i / 10, 11);
assert_eq!(
@@ -0,0 +1,7 @@
unit! {
system: uom::si;
quantity: uom::si::acceleration;
@meter_per_minute_per_second: 0.016_666_666_666_666_67; "m/min/s", "meters per minute per second",
"meters per minute per second";
}
@@ -0,0 +1,7 @@
unit! {
system: uom::si;
quantity: uom::si::angular_acceleration;
@revolution_per_minute_per_second: 0.104_719_755_119_659_77; "rev/min/s", "revolutions per minute per second",
"revolutions per minute per second";
}
@@ -0,0 +1,7 @@
unit! {
system: uom::si;
quantity: uom::si::angular_jerk;
@revolution_per_minute_per_second_squared: 0.104_719_755_119_659_77; "rev/min/s²", "revolution per minute per second squared",
"revolution per minute per second squared";
}
+7
View File
@@ -0,0 +1,7 @@
unit! {
system: uom::si;
quantity: uom::si::jerk;
@meter_per_minute_per_second_squared: 0.016_666_666_666_666_666_67; "m/min/s²", "meters per minute per second squared",
"meters per minute per second squared";
}
+5
View File
@@ -0,0 +1,5 @@
pub mod acceleration;
pub mod angular_acceleration;
pub mod angular_jerk;
pub mod jerk;
pub mod velocity;
@@ -0,0 +1,7 @@
unit! {
system: uom::si;
quantity: uom::si::velocity;
@meter_per_minute: 0.016_666_666_666_666_67; "m/min", "meters per minute",
"meters per minute";
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+34
View File
@@ -15,6 +15,9 @@ Here is a checklist of what we need to implement:
- field `rxpdo` instance of `EL0000RxPdo` (only if the device has outputs)
- field `configuration` instance of `EL0000Configuration` (only if configurable)
- implement `NewDevice` trait
- implement `EthercatDeviceProcessing` trait
- optional: override `input_post_process()` method for custom input data processing
- optional: override `output_pre_process()` method for custom output data preparation
- TxPdo / RxPdo Struct `struct EL0000TxPdo` and/or `struct EL0000RxPdo`
- derive `#[derive(Debug, Clone)]`
- derive `#[derive(TxPdo)]` and/or `#[derive(RxPdo)]`
@@ -46,6 +49,37 @@ These are helpful resources:
ESI Files are not needed but could be an alternative reference, though not a very readable one.
## EthercatDeviceProcessing
The `EthercatDeviceProcessing` trait provides hooks for custom processing of input and output data that happens between the EtherCAT data exchange and the device's IO layer. Every EtherCAT device must implement this trait, even if it doesn't need custom processing.
This trait provides two optional methods:
- `input_post_process()`: Called after the device has received input data from the EtherCAT bus, allowing transformation of the raw PDO data before it's exposed through the IO interfaces.
- `output_pre_process()`: Called before data is sent to the EtherCAT bus, allowing preparation or transformation of data from the IO interfaces before it's written to the PDO objects.
For most devices, the default implementations (which do nothing) are sufficient. You only need to override these methods when the raw PDO data requires transformation before being used by the IO layer or vice versa.
Example implementation:
```rust
impl EthercatDeviceProcessing for EL0000 {
// Optional: Only override when needed
fn input_post_process(&mut self) -> Result<(), anyhow::Error> {
// Process data after it's received from EtherCAT but before it's accessed via IO interfaces
// Example: Transform raw sensor data into engineering units
Ok(())
}
// Optional: Only override when needed
fn output_pre_process(&mut self) -> Result<(), anyhow::Error> {
// Process data before it's sent to EtherCAT
// Example: Apply limits or transforms to output values
Ok(())
}
}
```
## Identity
We need to extract the possible EtherCAT identity values matching this driver implementation and provide them as constants.
+126
View File
@@ -0,0 +1,126 @@
<mxfile host="65bd71144e">
<diagram id="66gCWvEem9su0pM2-AdM" name="Page-1">
<mxGraphModel dx="1454" dy="512" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="44" value="Serial Device Detection and Recognition process" style="swimlane;horizontal=0;whiteSpace=wrap;html=1;swimlaneFillColor=default;" parent="1" vertex="1">
<mxGeometry x="-790" y="60" width="710" height="880" as="geometry"/>
</mxCell>
<mxCell id="38" value="ADD new Devices&amp;nbsp;" style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;" parent="44" vertex="1">
<mxGeometry x="525" y="290" width="130" height="50" as="geometry"/>
</mxCell>
<mxCell id="10" value="SerialDeviceOld-&amp;gt; only USB" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="-735" y="825" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="11" value="Serial Detect" style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;" parent="1" vertex="1">
<mxGeometry x="-705" y="735" width="120" height="40" as="geometry"/>
</mxCell>
<mxCell id="15" value="" style="endArrow=classic;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="10" target="11" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-660" y="755" as="sourcePoint"/>
<mxPoint x="-610" y="705" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="16" value="SerialDeviceNewFound&lt;span style=&quot;color: rgb(0, 0, 0);&quot;&gt;-&amp;gt; only USB&lt;/span&gt;" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="-735" y="645" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="17" value="" style="endArrow=classic;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="11" target="16" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-660" y="755" as="sourcePoint"/>
<mxPoint x="-610" y="705" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="18" value="Compare" style="rhombus;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="-485" y="715" width="80" height="80" as="geometry"/>
</mxCell>
<mxCell id="19" value="" style="endArrow=classic;html=1;exitX=1;exitY=1;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="16" target="18" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-565" y="695" as="sourcePoint"/>
<mxPoint x="-515" y="645" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="20" value="" style="endArrow=classic;html=1;exitX=1;exitY=0;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="10" target="18" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-565" y="695" as="sourcePoint"/>
<mxPoint x="-515" y="645" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="21" value="Tuple (Added, Removed)" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="-365" y="725" width="150" height="60" as="geometry"/>
</mxCell>
<mxCell id="22" value="" style="endArrow=classic;html=1;" parent="1" source="18" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-385" y="805" as="sourcePoint"/>
<mxPoint x="-365" y="755" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="23" value="" style="shape=flexArrow;endArrow=classic;html=1;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-645" y="925" as="sourcePoint"/>
<mxPoint x="-645" y="865" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="24" value="BEGIN" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="23" vertex="1" connectable="0">
<mxGeometry x="-0.5752" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="25" value="Delete Removed Devices from HashMap" style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;" parent="1" vertex="1">
<mxGeometry x="-430" y="615" width="125" height="50" as="geometry"/>
</mxCell>
<mxCell id="26" value="Try to connect to detected devices" style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;" parent="1" vertex="1">
<mxGeometry x="-265" y="615" width="130" height="50" as="geometry"/>
</mxCell>
<mxCell id="27" value="" style="endArrow=classic;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="21" target="25" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-325" y="605" as="sourcePoint"/>
<mxPoint x="-275" y="555" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="28" value="" style="endArrow=classic;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="21" target="26" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-280" y="735" as="sourcePoint"/>
<mxPoint x="-357" y="675" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="29" value="Vec&amp;lt;(path: String, Result&amp;lt;SerialDevice, Error&amp;gt;)" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="-267.5" y="495" width="135" height="60" as="geometry"/>
</mxCell>
<mxCell id="30" value="" style="endArrow=classic;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="26" target="29" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-325" y="465" as="sourcePoint"/>
<mxPoint x="-275" y="415" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="33" value="" style="endArrow=classic;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="29" target="38" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-325" y="325" as="sourcePoint"/>
<mxPoint x="-200" y="415" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="36" value="Global&amp;nbsp;&amp;nbsp;&lt;span style=&quot;color: rgb(0, 0, 0);&quot;&gt;Result&amp;lt;HashMap, Error&amp;gt; with connected devices&lt;/span&gt;" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="-725" y="90" width="250" height="130" as="geometry"/>
</mxCell>
<mxCell id="41" value="" style="edgeStyle=elbowEdgeStyle;elbow=vertical;endArrow=classic;html=1;curved=0;rounded=0;endSize=8;startSize=8;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.468;entryY=1.001;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="25" target="36" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-345" y="465" as="sourcePoint"/>
<mxPoint x="-295" y="415" as="targetPoint"/>
<Array as="points">
<mxPoint x="-480" y="550"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="42" value="" style="edgeStyle=elbowEdgeStyle;elbow=horizontal;endArrow=classic;html=1;curved=0;rounded=0;endSize=8;startSize=8;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="38" target="36" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-475" y="245" as="sourcePoint"/>
<mxPoint x="-425" y="195" as="targetPoint"/>
<Array as="points">
<mxPoint x="-280" y="260"/>
</Array>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
+3 -3
View File
@@ -27,13 +27,13 @@ We need to know what the purpose of each device in a machine is:
How and where do we save the identification values? A machine does not have a central storage medium. Saving the network configuration in the application is bad when changing the EtherCAT master device. Additionally, the serial number in the EEPROM is always `0`. Not all devices have the CoE key value store.
The solution: Persisting the values in empty spaces inside each device's EEPROM. As seen in the [ESC Access](https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_io_intro/1358008331.html&id=) documentation, there are reserved spaces. With a hex dump of the EEPROM, we can confirm that these spaces are likely not used. The EEPROM is very small, so we can fit the values as four `u32` (Big Endian) values. In case we need to customize the position of the values, we customize the addresses for each device.
The solution: Persisting the values in empty spaces inside each device's EEPROM. As seen in the [ESC Access](https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_io_intro/1358008331.html&id=) documentation, there are reserved spaces. With a hex dump of the EEPROM, we can confirm that these spaces are likely not used. The EEPROM is very small, so we can fit the values as four `u16` (Big Endian) values. In case we need to customize the position of the values, we customize the addresses for each device.
The default word (not byte) addresses are:
- Vendor: `0x0028`
- Machine: `0x0029`
- Serial: `0x002A`
- Machine: `0x002C`
- Role: `0x002E`
- Role: `0x002B`
## Writing values
1. For each new device, we need to identify free space in the EEPROM and add them to `get_identification_addresses` in `control-core/src/identification/mod.rs`.
+479
View File
@@ -0,0 +1,479 @@
# Logging and Tracing Configuration
This document describes the logging and tracing system for the QiTech control server, including structured logging, distributed tracing, and different output formats.
## Overview
The QiTech control server uses a modular tracing system built on top of the `tracing` crate, supporting multiple output formats and observability backends. The system is designed to be flexible and configurable through Cargo features.
## Features
The logging system supports three main features that can be enabled independently:
- **`tracing-fmt`**: Human-readable console output (default)
- **`tracing-journald`**: systemd journal integration for production Linux systems
- **`tracing-otel`**: OpenTelemetry integration for distributed tracing
### Default Configuration
By default, the server uses `tracing-fmt` for development-friendly console output:
```bash
cargo run # Uses tracing-fmt by default
```
## Feature-Based Configuration
### Format Logging (`tracing-fmt`)
The fmt logger provides structured, human-readable output to the console. This is the default feature and is ideal for development and debugging.
**Features:**
- Colored output (when supported)
- Thread names and IDs
- Line numbers and file locations
- Configurable time formats (debug vs release)
- Target filtering
**Example output:**
```
2025-06-01T10:30:45.123Z INFO server::main: Starting QiTech Control Server
2025-06-01T10:30:45.125Z DEBUG ethercat::init: Initializing EtherCAT master
```
**Usage:**
```bash
# Default behavior
cargo run
# Explicit feature selection
cargo run --features tracing-fmt --no-default-features
```
### Journal Logging (`tracing-journald`)
The journald logger integrates with systemd's journal for production Linux deployments. **This is the standard logging backend used on NixOS systems** and other systemd-based distributions.
**Features:**
- Native systemd journal integration
- Structured metadata preservation
- System-level log aggregation
- Log rotation and retention policies
- Remote log collection support
**Usage:**
```bash
# Enable journald logging
cargo run --features tracing-journald --no-default-features
# Combined with other features
cargo run --features "tracing-journald,tracing-otel"
```
**Viewing logs on NixOS:**
```bash
# View all logs from the service
journalctl -u qitech-control-server -f
# Filter by log level
journalctl -u qitech-control-server -p info
# JSON output for structured data
journalctl -u qitech-control-server -o json-pretty
# Follow logs with timestamp
journalctl -u qitech-control-server -f --since "1 hour ago"
```
### OpenTelemetry Tracing (`tracing-otel`)
OpenTelemetry provides distributed tracing capabilities by exporting traces to observability platforms like Jaeger, Zipkin, or commercial APM solutions.
**Features:**
- Custom channel-based OTLP gRPC exporter for async runtime isolation
- Hardcoded service metadata (no environment variables)
- Dedicated Tokio thread for span export (isolated from main smol runtime)
- Batch span processing for efficient export
- Distributed trace correlation across services
- Integration with HTTP middleware and database operations
**Technical Implementation:**
The implementation uses a custom `ChannelSpanExporter` that sends spans via `std::sync::mpsc::channel` to a dedicated Tokio thread. This design completely isolates the OpenTelemetry Tokio dependency from the main application's smol-based async runtime, preventing runtime conflicts.
**Configuration:**
- Service Name: `qitech-control-server`
- Service Version: Automatically set from `Cargo.toml`
- Service Namespace: `qitech`
- OTLP Endpoint: `http://localhost:4317` (Jaeger gRPC endpoint)
- Export Method: Channel-based with dedicated Tokio thread
**Setup Jaeger for Development:**
```bash
# Run Jaeger with OTLP support
docker run -d --name jaeger \
-e COLLECTOR_OTLP_ENABLED=true \
-p 16686:16686 \
-p 14268:14268 \
-p 4317:4317 \
-p 4318:4318 \
jaegertracing/all-in-one:latest
# Access Jaeger UI
open http://localhost:16686
```
**Usage:**
```bash
# Enable OpenTelemetry with OTLP export to Jaeger
cargo run --features tracing-otel --no-default-features
# Combined with console output
cargo run --features "tracing-fmt,tracing-otel"
```
The server will automatically export traces to Jaeger at `http://localhost:4317`. All HTTP requests, database operations, and custom spans will be visible in the Jaeger UI. The implementation uses a custom channel-based exporter that spawns a dedicated Tokio thread for OTLP gRPC communication, ensuring compatibility with the main application's smol async runtime.
## Multiple Feature Combinations
You can enable multiple logging features simultaneously:
```bash
# All logging features enabled
cargo run --features "tracing-fmt,tracing-journald,tracing-otel"
# Production setup: journald + OpenTelemetry
cargo run --features "tracing-journald,tracing-otel" --no-default-features
# Development setup: console + OpenTelemetry
cargo run --features "tracing-fmt,tracing-otel" --no-default-features
```
## Environment Variables
### Log Level Configuration
The log level is controlled through the `RUST_LOG` environment variable:
```bash
# Basic log levels
RUST_LOG=debug cargo run
RUST_LOG=info cargo run # Default
RUST_LOG=warn cargo run
RUST_LOG=error cargo run
# Module-specific filtering
RUST_LOG=server=debug,ethercat=info cargo run
# Complex filtering
RUST_LOG="info,tower_http=debug,axum=debug" cargo run
```
### OpenTelemetry Configuration
When using the `tracing-otel` feature, the service configuration is hardcoded and uses a custom channel-based exporter for async runtime compatibility:
#### Service Configuration
- **Service Name**: "qitech-control-server"
- **Service Version**: Automatically set from Cargo.toml package version
- **Service Namespace**: "qitech"
- **OTLP Endpoint**: "http://localhost:4317" (Jaeger gRPC endpoint)
#### Implementation Details
The OpenTelemetry implementation uses a custom `ChannelSpanExporter` that:
1. Receives spans from the tracing layer via an MPSC channel
2. Spawns a dedicated Tokio thread for OTLP gRPC export
3. Isolates OpenTelemetry's async runtime from the main smol-based application
4. Provides reliable span export to Jaeger without runtime conflicts
This design ensures that the OpenTelemetry tracing system works seamlessly with applications using different async runtimes (smol, tokio, etc.).
## Usage Examples
### Development Setup
For local development with console output:
```bash
RUST_LOG=debug cargo run --features tracing-fmt
```
### Production Setup (NixOS)
For production deployment on NixOS systems, we use journald logging as the primary backend:
```bash
RUST_LOG=info cargo run --features "tracing-journald,tracing-otel" --no-default-features
```
This configuration is automatically used in our NixOS deployments through the system service configuration.
### Distributed Tracing with OpenTelemetry
The OpenTelemetry implementation provides full distributed tracing capabilities with automatic export to Jaeger:
```bash
RUST_LOG=debug cargo run --features "tracing-fmt,tracing-otel"
```
This starts the application with both console logging and OpenTelemetry tracing enabled. Spans are automatically exported to Jaeger via the custom channel-based exporter, allowing you to view distributed traces in the Jaeger UI at `http://localhost:16686`.
### Setting Up Jaeger to Receive Traces
To collect and visualize traces from your application, you can run Jaeger as a trace receiver:
#### 1. Start Jaeger with OTLP Support
Run Jaeger using Docker with OTLP endpoints enabled:
```bash
docker run -d --name jaeger \
-e COLLECTOR_OTLP_ENABLED=true \
-p 16686:16686 \
-p 14268:14268 \
-p 14250:14250 \
-p 4317:4317 \
-p 4318:4318 \
jaegertracing/all-in-one:latest
```
#### 2. Access Jaeger UI
Open your browser and go to:
- **URL**: http://localhost:16686
- **Service**: Select "qitech-control-server" from the dropdown
- **Operation**: Choose specific operations to trace
- **Find Traces**: Click to view collected traces
#### 3. Run Your Application with OpenTelemetry
The OpenTelemetry implementation automatically exports traces to Jaeger:
```bash
# Run with OpenTelemetry tracing enabled
cargo run --features "tracing-fmt,tracing-otel"
```
The application uses these hardcoded service identifiers:
- **Service Name**: "qitech-control-server"
- **Service Version**: From Cargo.toml package version
- **Service Namespace**: "qitech"
- **OTLP Endpoint**: "http://localhost:4317" (gRPC)
Traces will automatically appear in the Jaeger UI under the service name "qitech-control-server". The custom channel-based exporter ensures reliable trace delivery even when using different async runtimes.
### Alternative: Using Docker Compose
Create a `docker-compose.yml` for easier management:
```yaml
version: '3.8'
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # Jaeger UI
- "14268:14268" # HTTP collector
- "14250:14250" # gRPC collector
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
environment:
- COLLECTOR_OTLP_ENABLED=true
```
Run with:
```bash
docker-compose up -d jaeger
```
### Other Observability Platforms
The current channel-based OpenTelemetry exporter is configured for Jaeger (OTLP gRPC endpoint). For other platforms like Grafana, DataDog, or custom OTLP collectors, you can modify the endpoint configuration in `src/logging/opentelemetry.rs` by updating the `OTLP_ENDPOINT` constant.
## Adding Tracing to Code
### Basic Logging
```rust
use tracing::{info, debug, warn, error, trace};
pub fn my_function() {
trace!("Very detailed debug information");
debug!("Debug information for developers");
info!("General information about program execution");
warn!("Something unexpected happened");
error!("An error occurred: {}", error_message);
}
```
### Structured Logging
**Important**: When logging to journald (systemd's logging service), key-value pairs in event logs are not properly captured. Instead, include structured data using string formatting within the message itself.
```rust
use tracing::{info, instrument};
// ❌ Don't use key-value pairs in event logs (won't work with journald)
info!(
user_id = %user.id,
email = %user.email,
"Updating user profile"
);
// ✅ Use string formatting instead (works with journald)
info!(
"Updating user profile user_id={} email={}",
user.id,
user.email
);
// ✅ Spans can still use structured fields
#[instrument(fields(user_id = %user.id, operation = "update"))]
pub fn update_user(user: &User) {
info!(
"Updating user profile user_id={} email={}",
user.id,
user.email
);
}
```
### Custom Spans
```rust
use tracing::{Span, instrument};
pub fn complex_operation() {
let span = tracing::info_span!(
"complex_operation",
operation_id = 123,
stage = "initialization"
);
let _enter = span.enter();
info!("Starting complex operation");
// Create child span
let child_span = tracing::debug_span!("database_query");
let _child_enter = child_span.enter();
debug!("Executing database query");
}
```
### Error Handling
```rust
use tracing::{error, warn};
use anyhow::Result;
pub fn operation_with_error_handling() -> Result<()> {
match risky_operation() {
Ok(result) => {
info!("Operation completed successfully result={:?}", result);
Ok(())
}
Err(e) => {
error!("Operation failed error={}", e);
Err(e)
}
}
}
```
## Docker Compose Example
Complete observability stack with Jaeger:
```yaml
version: '3.8'
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686"
- "14268:14268"
environment:
- COLLECTOR_OTLP_ENABLED=true
control-server:
build: .
environment:
- RUST_LOG=info
- OTEL_EXPORTER=jaeger
- OTEL_EXPORTER_JAEGER_ENDPOINT=http://jaeger:14268/api/traces
- OTEL_SERVICE_NAME=qitech-control-server
depends_on:
- jaeger
# Use journald for system logs
logging:
driver: journald
```
## Performance Considerations
- **Fmt logging**: Minimal overhead, suitable for development
- **Journald logging**: Low overhead, optimized for production systems
- **OpenTelemetry**: Moderate overhead due to span collection and channel-based export; the custom implementation minimizes impact by using a dedicated thread for OTLP communication
## Troubleshooting
### No log output
- Check `RUST_LOG` environment variable
- Verify the correct features are enabled
- Ensure the logging initialization is called in `main()`
### OpenTelemetry traces not appearing in Jaeger
- Verify Jaeger is running with OTLP support on port 4317
- Check that the `tracing-otel` feature is enabled
- Ensure Jaeger OTLP collector is enabled: `COLLECTOR_OTLP_ENABLED=true`
- Look for error messages in the application logs related to OTLP export
- Verify the service name "qitech-control-server" appears in Jaeger's service dropdown
### Service name shows as "unknown_service:server" in Jaeger
- This is a known issue where the resource configuration may not be applied correctly
- The traces will still work correctly, but the service name display may be incorrect
- Check the span metadata for the correct service information
### Journald logs not appearing
- Ensure systemd is running
- Check systemd service configuration
- Verify the `tracing-journald` feature is enabled
## NixOS Configuration
The QiTech control server is automatically configured for optimal logging on NixOS systems:
### Package Configuration
The server package is built with the `tracing-journald` feature enabled:
```nix
# In nixos/packages/server.nix
buildFeatures = [ "tracing-journald" ];
buildNoDefaultFeatures = true;
```
### Service Configuration
The systemd service is configured to use journald logging:
```nix
# In nixos/modules/qitech.nix
systemd.services.qitech-control-server = {
serviceConfig = {
StandardOutput = "journal";
StandardError = "journal";
SyslogIdentifier = "qitech-control-server";
};
environment = {
RUST_LOG = "info,tower_http=debug,axum=debug";
QITECH_OS = "1"; # Legacy compatibility
};
};
```
This configuration ensures that all logs are properly structured and integrated with the systemd journal.
For more specific configuration options, see the individual module documentation in the source code.
+10 -1
View File
@@ -15,6 +15,9 @@ const prettierIgnorePath = path.resolve(__dirname, ".prettierignore");
/** @type {import('eslint').Linter.Config[]} */
export default [
includeIgnoreFile(prettierIgnorePath),
{
ignores: ["src/components/ui/**/*"],
},
{
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
plugins: {
@@ -22,7 +25,6 @@ export default [
},
rules: {
"react-compiler/react-compiler": "error",
"@typescript-eslint/no-explicit-any": "warning",
},
},
{ languageOptions: { globals: globals.browser } },
@@ -30,4 +32,11 @@ export default [
pluginReact.configs.flat.recommended,
eslintPluginPrettierRecommended,
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "warn",
"react/no-unknown-property": "warn",
},
},
];
+2246 -2386
View File
File diff suppressed because it is too large. Load diff
+10 -6
View File
@@ -43,15 +43,14 @@
"@types/node": "^22.13.4",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"electron": "34.0.2",
"electron-devtools-installer": "^4.0.0",
"electron": "36.4.0",
"electron-playwright-helpers": "^1.7.1",
"eslint": "^9.20.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
"globals": "^15.15.0",
"globals": "^16.2.0",
"jsdom": "^26.0.0",
"postcss": "^8.5.2",
"prettier": "^3.5.1",
@@ -68,6 +67,7 @@
"@icons-pack/react-simple-icons": "^11.2.0",
"@radix-ui/react-collapsible": "^1.1.8",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.7",
@@ -82,25 +82,29 @@
"@tanstack/react-table": "^8.21.2",
"@tanstack/router-devtools": "^1.105.0",
"@tanstack/zod-adapter": "^1.116.0",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^4.5.2",
"babel-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-devtools-installer": "^4.0.0",
"electron-squirrel-startup": "^1.0.1",
"i18next": "^25.0.2",
"immer": "^10.1.1",
"lucide-react": "^0.488.0",
"lucide-react": "^0.511.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-hook-form": "^7.57.0",
"react-i18next": "^15.4.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.1",
"socket.io-msgpack-parser": "^3.0.2",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"uplot": "^1.6.32",
"xlsx": "^0.18.5",
"zod": "^3.24.2",
"zod-validation-error": "^3.4.0",
"zustand": "^5.0.3"
+4
View File
@@ -8,10 +8,14 @@ import { router } from "./routes/router";
import { RouterProvider } from "@tanstack/react-router";
import { Toaster } from "./components/ui/sonner";
import { enableMapSet } from "immer";
import { useGlobalLogStreaming } from "./hooks/useGlobalLogStreaming";
export default function App() {
const { i18n } = useTranslation();
// Start global log streaming
useGlobalLogStreaming();
useEffect(() => {
syncThemeWithLocal();
updateAppLanguage(i18n);
@@ -0,0 +1 @@
# Extruder Manual
@@ -0,0 +1 @@
# Laser Manual
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,80 @@
# Mock Manual
## Overview
This is the operating manual for the Mock Machine control system. The Mock Machine is a simulated industrial device used for testing and development purposes.
![Mock Machine Duck](duck.jpg)
*Figure 1: Mock Machine Mascot*
## Safety Information
⚠️ **Important Safety Notes:**
- Always ensure proper safety protocols are followed
- Verify all connections before operation
- Keep emergency stop accessible at all times
## Operating Instructions
### Starting the Machine
1. **Power On Sequence**
- Check all safety switches
- Verify emergency stops are released
- Power on the main controller
2. **Initial Setup**
- Navigate to the Control tab
- Check system status indicators
- Verify all sensors are operational
### Control Interface
The mock machine interface provides:
- **Control Tab**: Main operation controls and status
- **Graph Tab**: Real-time data visualization
- **Manual Tab**: This handbook and documentation
### Monitoring
Use the Graph tab to monitor:
- System performance metrics
- Sensor readings
- Historical data trends
## Troubleshooting
### Common Issues
| Issue | Possible Cause | Solution |
| -------------------- | -------------------- | ------------------------- |
| No communication | Network disconnected | Check network connections |
| Sensor errors | Sensor malfunction | Verify sensor wiring |
| Control unresponsive | Software issue | Restart control system |
### Error Codes
- **E001**: Communication timeout
- **E002**: Sensor out of range
- **E003**: Emergency stop activated
## Maintenance
### Daily Checks
- [ ] Verify all indicators are green
- [ ] Check for error messages
- [ ] Review system logs
### Weekly Maintenance
- [ ] Clean control panel
- [ ] Check cable connections
- [ ] Update system logs
## Contact Information
For technical support, contact the engineering team or refer to the system documentation.
---
*Last updated: June 2025*
@@ -0,0 +1 @@
# Winder Manual
+12 -16
View File
@@ -1,6 +1,5 @@
import { StoreApi } from "zustand";
import { create } from "zustand";
import { produce } from "immer";
import { z } from "zod";
import {
EventHandler,
@@ -113,28 +112,25 @@ export function mainMessageHandler(
if (eventName === "EthercatDevicesEvent") {
const validatedEvent = ethercatDevicesEventSchema.parse(event);
console.log("EthercatDevicesEvent", validatedEvent);
store.setState(
produce((state) => {
state.ethercatDevices = validatedEvent;
}),
);
store.setState((state) => ({
...state,
ethercatDevices: validatedEvent,
}));
} else if (eventName === "MachinesEvent") {
const validatedEvent = machinesEventSchema.parse(event);
console.log("MachinesEvent", validatedEvent);
store.setState(
produce((state) => {
state.machines = validatedEvent;
}),
);
store.setState((state) => ({
...state,
machines: validatedEvent,
}));
} else if (eventName === "EthercatInterfaceDiscoveryEvent") {
const validatedEvent =
ethercatInterfaceDiscoveryEventSchema.parse(event);
console.log("EthercatInterfaceDiscoveryEvent", validatedEvent);
store.setState(
produce((state) => {
state.ethercatInterfaceDiscovery = validatedEvent;
}),
);
store.setState((state) => ({
...state,
ethercatInterfaceDiscovery: validatedEvent,
}));
} else {
handleUnhandledEventError(eventName);
}
+191 -25
View File
@@ -1,12 +1,126 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo } from "react";
import { create, StoreApi } from "zustand";
import { produce } from "immer";
import { io, Socket } from "socket.io-client";
import MsgPackParser from "socket.io-msgpack-parser";
import { useSyncExternalStore } from "react";
import { z } from "zod";
import { toastError, toastZodError } from "@/components/Toast";
import { MachineIdentificationUnique } from "@/machines/types";
/**
* Simple buffer-based store updater to limit React re-renders to ~60 FPS
* Writes events to a plain JS object and syncs to store every 16ms
*/
export class ThrottledStoreUpdater<S> {
private store: StoreApi<S>;
private buffer: S;
private syncTimer: NodeJS.Timeout | null = null;
private readonly syncDelay = 1000 / 60; // ~60 FPS (16.67ms)
constructor(store: StoreApi<S>) {
this.store = store;
// Initialize buffer with current store state
this.buffer = { ...store.getState() };
}
/**
* Update the buffer directly (no store update yet)
* @param updates Partial state to merge into buffer
*/
update(updates: Partial<S>): void {
this.buffer = { ...this.buffer, ...updates };
this.scheduleSync();
}
/**
* Apply a function to update the buffer
* @param updater Function that takes current buffer and returns new state
*/
updateWith(updater: (state: S) => S): void {
this.buffer = updater(this.buffer);
this.scheduleSync();
}
/**
* Get current buffer state for immediate reads
*/
getBufferState(): S {
return this.buffer;
}
/**
* Schedule a sync to store if not already scheduled
*/
private scheduleSync(): void {
if (this.syncTimer === null) {
this.syncTimer = setTimeout(() => {
this.syncToStore();
}, this.syncDelay);
}
}
/**
* Sync the entire buffer to the store
*/
private syncToStore(): void {
try {
this.store.setState(this.buffer);
this.syncTimer = null;
} catch (error) {
console.error("Error syncing to store:", error);
this.syncTimer = null;
// Retry after a short delay
setTimeout(() => {
this.scheduleSync();
}, 100);
}
}
/**
* Force immediate sync of buffer to store
*/
forceSync(): void {
if (this.syncTimer) {
clearTimeout(this.syncTimer);
this.syncToStore();
}
}
/**
* Cleanup resources
*/
destroy(): void {
if (this.syncTimer) {
clearTimeout(this.syncTimer);
this.syncTimer = null;
}
// Final sync before destroy
this.syncToStore();
}
}
/**
* Creates a throttled event handler that batches store updates for better performance
* @param originalHandler The original event handler that processes events
* @returns A new event handler that batches updates at 60 FPS
*/
export function createThrottledEventHandler<S>(
store: StoreApi<S>,
originalHandler: (
event: Event<any>,
updateBuffer: (updater: (state: S) => S) => void,
) => void,
): EventHandler {
const throttledUpdater = new ThrottledStoreUpdater(store);
return (event: Event<any>) => {
originalHandler(event, (updater) => {
throttledUpdater.updateWith(updater);
});
};
}
/**
* Generic event schema builder
* Creates a Zod schema for an Event with strongly typed payload
@@ -76,6 +190,10 @@ type Namespace<S> = {
handler: EventHandler;
/** Zustand store holding the room state */
store: StoreApi<S>;
/** Timeout ID for disconnection */
disconnectTimeoutId?: NodeJS.Timeout;
/** Throttled store updater for batching state updates */
throttledUpdater: ThrottledStoreUpdater<S>;
};
/**
@@ -130,7 +248,10 @@ type SocketioStore = {
initNamespace: <S>(
namespaceId: NamespaceId,
createStore: () => StoreApi<S>,
createEventHandler: (store: StoreApi<S>) => EventHandler,
createEventHandler: (
store: StoreApi<S>,
throttledUpdater: ThrottledStoreUpdater<S>,
) => EventHandler,
) => void;
incrementNamespace: (namespaceId: NamespaceId) => void;
decrementNamespace: (namespaceId: NamespaceId) => void;
@@ -150,6 +271,7 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
const namespace_path = serializeNamespaceId(namespaceId);
return !!get().namespaces[namespace_path];
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
initNamespace: <S>(namespaceId, createStore, createEventHandler) => {
const namespace_path = serializeNamespaceId(namespaceId);
@@ -159,7 +281,10 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
}
// create a new socket
const socket = io(get().baseUrl + namespace_path, { autoConnect: false });
const socket = io(get().baseUrl + namespace_path, {
autoConnect: false,
parser: MsgPackParser,
});
// create function to reset the store
// creating a new store and a new event handler
@@ -167,9 +292,11 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
set(
produce((state: SocketioStore) => {
const store = createStore();
const eventHandler = createEventHandler(store);
const throttledUpdater = new ThrottledStoreUpdater(store);
const eventHandler = createEventHandler(store, throttledUpdater);
state.namespaces[namespace_path].store = store;
state.namespaces[namespace_path].handler = eventHandler;
state.namespaces[namespace_path].throttledUpdater = throttledUpdater;
}),
);
};
@@ -181,16 +308,28 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
// reset the store
resetStore(set);
});
socket.on("disconnect", () => {
console.warn(`Disconnected from ${namespace_path}`);
socket.on("disconnect", (reason) => {
socket.disconnect();
console.warn(`Disconnected from ${namespace_path}, reason: ${reason}`);
// reset the store
resetStore(set);
// Attempt to reconnect after a short delay for any disconnect reason
console.log(
`Disconnected from ${namespace_path}, attempting reconnect in 1s...`,
);
setTimeout(() => {
if (get().hasNamespace(namespaceId) && !socket.connected) {
console.log(`Reconnecting to ${namespace_path}...`);
socket.connect();
}
}, 1000);
});
socket.on("event", (event: unknown) => {
// validate the event
let event_parsed = eventSchema(z.any()).safeParse(event);
const event_parsed = eventSchema(z.any()).safeParse(event);
if (!event_parsed.success) {
toastZodError(event_parsed.error, "Invalid event");
return;
@@ -203,12 +342,15 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
set(
produce((state: SocketioStore) => {
const store = createStore();
const handler = createEventHandler(store);
const throttledUpdater = new ThrottledStoreUpdater(store);
const handler = createEventHandler(store, throttledUpdater);
state.namespaces[namespace_path] = {
count: 0,
socket,
handler,
store,
throttledUpdater,
disconnectTimeoutId: undefined,
};
}),
);
@@ -224,13 +366,20 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
throw new Error(`Namespace ${namespace_path} not initialized`);
}
// increment the count
// increment the count and clear any pending disconnect timeout
set(
produce((state: SocketioStore) => {
state.namespaces[namespace_path].count++;
// Clear any pending disconnect timeout since we have active subscribers
if (state.namespaces[namespace_path].disconnectTimeoutId) {
clearTimeout(state.namespaces[namespace_path].disconnectTimeoutId);
state.namespaces[namespace_path].disconnectTimeoutId = undefined;
}
}),
);
},
decrementNamespace: (namespaceId: NamespaceId) => {
const namespace_path = serializeNamespaceId(namespaceId);
@@ -248,22 +397,35 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
namespaceId.type !== "main" &&
state.namespaces[namespace_path].count <= 0
) {
// Create a timeout to check if the namespace is still unused after 10 seconds
// In an edge case ther could be a use inside the 10 seconds and the again 0 but we will still disconnect (simplicity)
const timeout = setTimeout(() => {
set(
produce((state: SocketioStore) => {
const ns = state.namespaces[namespace_path];
if (ns && ns.count <= 0) {
ns.socket.disconnect();
delete state.namespaces[namespace_path];
console.log(
`Namespace ${namespace_path} disconnected after 10s of inactivity`,
);
}
}),
// Clear any existing timeout first
if (state.namespaces[namespace_path].disconnectTimeoutId) {
clearTimeout(
state.namespaces[namespace_path].disconnectTimeoutId,
);
}, 10000);
}
// Create a timeout to check if the namespace is still unused after 1 hour
const timeoutId = setTimeout(
() => {
set(
produce((state: SocketioStore) => {
const ns = state.namespaces[namespace_path];
if (ns && ns.count <= 0) {
ns.socket.disconnect();
ns.throttledUpdater.destroy(); // Clean up throttled updater
delete state.namespaces[namespace_path];
console.log(
`Namespace ${namespace_path} disconnected after 1h of inactivity`,
);
}
}),
);
},
// 1h until disconnect
60 * 60 * 1000,
);
state.namespaces[namespace_path].disconnectTimeoutId = timeoutId;
}
}),
);
@@ -285,9 +447,13 @@ export interface NamespaceImplementationConfig<S> {
/**
* Function that creates a message handler for this namespace
* @param store The store that will be updated by the handler
* @param throttledUpdater Throttled updater for batching updates
* @returns A message handler function
*/
createEventHandler: (store: StoreApi<S>) => EventHandler;
createEventHandler: (
store: StoreApi<S>,
throttledUpdater: ThrottledStoreUpdater<S>,
) => EventHandler;
}
export type NamespaceImplementationResult<S> = (namespaceId: NamespaceId) => S;
+1 -1
View File
@@ -16,7 +16,7 @@ type UseMachine = {
icon: MachinePreset["icon"];
};
// returns only valid mahcines
// returns only valid machines
export function useMachines(): UseMachine[] {
const { machines } = useMainNamespace();
+11 -1
View File
@@ -1,8 +1,13 @@
import { icons as lucideIcons } from "lucide-react";
import React from "react";
import { qitechIcons } from "./QitechIcon";
type QitechIconName = `qi:${keyof typeof qitechIcons}`;
type LucideIconName = `lu:${keyof typeof lucideIcons}`;
// prefix keys with library to avoid conflicts with other icon libraries
export type IconName = `lu:${keyof typeof lucideIcons}`;
export type IconName = QitechIconName | LucideIconName;
type Props = {
name?: IconName;
@@ -21,6 +26,11 @@ export const Icon = ({ name, className }: Props) => {
return <LucideIcon className={"size-6 " + className} />;
}
if (library === "qi" && rawIcon in qitechIcons) {
const QitechIcon = qitechIcons[rawIcon as keyof typeof qitechIcons];
return <QitechIcon className={"size-6 " + className} />;
}
console.error(`Icon ${name} not found`, library, rawIcon, lucideIcons);
return null;
+139 -14
View File
@@ -1,39 +1,152 @@
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { generateHeadingId } from "@/lib/markdown/heading";
type MarkdownProps = {
text: string;
};
// Extract plain text from React children
function extractTextFromChildren(children: React.ReactNode): string {
return React.Children.toArray(children)
.map((child) => {
if (typeof child === "string") return child;
if (typeof child === "number") return child.toString();
if (
React.isValidElement(child) &&
typeof child.props === "object" &&
child.props !== null
) {
return extractTextFromChildren((child.props as any).children || "");
}
return "";
})
.join("");
}
export function Markdown({ text }: MarkdownProps) {
/**
* First heading detection for removing top padding
*
* This logic detects if the markdown content starts with a heading and removes
* the top padding from only that first heading to create better visual spacing
* between the topbar and content.
*
* How it works:
* 1. Parse the first line of markdown text using regex: /^(#{1,6})\s+(.+)/
* - Matches 1-6 hash symbols at the start of the text
* - Captures the heading level (number of #) and the heading text
* 2. Each heading component (h1, h2, h3, h4) calls isFirstHeading() with its text and level
* 3. Only the heading that exactly matches the first heading's text and level gets isFirst = true
* 4. The first heading renders without pt-4 (top padding), all others keep their padding
*
* Example: For markdown starting with "# Mock Machine Manual"
* - firstHeadingInfo = { level: 1, text: "Mock Machine Manual", startsWithHeading: true }
* - Only the h1 with text "Mock Machine Manual" will have no top padding
* - Subsequent headings like "## Overview" will keep their normal pt-4 padding
*/
const firstHeadingInfo = React.useMemo(() => {
const trimmedText = text.trim();
const firstLineMatch = trimmedText.match(/^(#{1,6})\s+(.+)/);
if (firstLineMatch) {
return {
level: firstLineMatch[1].length,
text: firstLineMatch[2].trim(),
startsWithHeading: true,
};
}
return { startsWithHeading: false };
}, [text]);
const isFirstHeading = (headingText: string, level: number) => {
return (
firstHeadingInfo.startsWithHeading &&
firstHeadingInfo.text === headingText &&
firstHeadingInfo.level === level
);
};
return (
<div>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ node, ...props }) => (
<h1 className="pt-4 pb-1 text-2xl" {...props} />
),
h2: ({ node, ...props }) => (
<h2 className="pt-4 pb-1 text-xl" {...props} />
),
h3: ({ node, ...props }) => (
<h3 className="pt-4 pb-1 text-lg" {...props} />
),
h4: ({ node, ...props }) => (
<h4 className="pt-4 pb-1 text-lg" {...props} />
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
h1: ({ node, children, ...props }) => {
const text = extractTextFromChildren(children);
const id = generateHeadingId(text);
const isFirst = isFirstHeading(text, 1);
return (
<h1
id={id}
className={`${isFirst ? "" : "pt-4"} pb-2 text-2xl`}
{...props}
>
{children}
</h1>
);
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
h2: ({ node, children, ...props }) => {
const text = extractTextFromChildren(children);
const id = generateHeadingId(text);
const isFirst = isFirstHeading(text, 2);
return (
<h2
id={id}
className={`${isFirst ? "" : "pt-4"} pb-2 text-xl`}
{...props}
>
{children}
</h2>
);
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
h3: ({ node, children, ...props }) => {
const text = extractTextFromChildren(children);
const id = generateHeadingId(text);
const isFirst = isFirstHeading(text, 3);
return (
<h3
id={id}
className={`${isFirst ? "" : "pt-4"} pb-2 text-lg`}
{...props}
>
{children}
</h3>
);
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
h4: ({ node, children, ...props }) => {
const text = extractTextFromChildren(children);
const id = generateHeadingId(text);
const isFirst = isFirstHeading(text, 4);
return (
<h4
id={id}
className={`${isFirst ? "" : "pt-4"} pb-2 text-lg`}
{...props}
>
{children}
</h4>
);
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
ul: ({ node, ...props }) => (
<ul className="markdown-list list-inside list-disc" {...props} />
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
li: ({ node, ...props }) => (
<li className="markdown-list list-inside list-disc" {...props} />
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
ol: ({ node, ...props }) => (
<ol className="markdown-list list-inside list-decimal" {...props} />
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
p: ({ node, ...props }) => <p className="text-base" {...props} />,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
a: ({ node, ...props }) => (
<a
className="text-blue-500 underline"
@@ -42,49 +155,61 @@ export function Markdown({ text }: MarkdownProps) {
rel="noreferrer"
/>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
strong: ({ node, ...props }) => (
<strong className="font-bold" {...props} />
),
img: ({ node, ...props }) => (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
img: ({ node, alt, ...props }) => (
<img
className="h-auto max-w-full"
{...props}
alt={props.alt || ""}
alt={alt || ""}
loading="lazy"
/>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
code: ({ node, ...props }) => (
<code
className="rounded-full bg-gray-100 p-0.5 px-2 text-sm"
{...props}
/>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
blockquote: ({ node, ...props }) => (
<blockquote
className="border-l-4 border-gray-300 pl-4 text-gray-700 italic"
{...props}
/>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pre: ({ node, ...props }) => (
<pre
className="my-4 overflow-auto rounded bg-gray-100 p-4"
{...props}
/>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
table: ({ node, ...props }) => (
<table className="my-4 w-full table-auto" {...props} />
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
thead: ({ node, ...props }) => <thead className="" {...props} />,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
tbody: ({ node, ...props }) => <tbody className="" {...props} />,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
tr: ({ node, ...props }) => (
<tr className="border-b border-gray-300" {...props} />
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
th: ({ node, ...props }) => (
<th className="p-2 text-left font-bold" {...props} />
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
td: ({ node, ...props }) => (
<td className="p-2 text-left" {...props} />
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
hr: ({ node, ...props }) => <hr className="my-4" {...props} />,
}}
>
+336
View File
@@ -0,0 +1,336 @@
import React, {
useEffect,
useLayoutEffect,
useState,
useCallback,
useMemo,
} from "react";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { generateHeadingId } from "@/lib/markdown/heading";
interface TocItem {
id: string;
title: string;
level: number;
children: TocItem[];
}
interface MarkdownTocProps {
markdownContent: string;
className?: string;
}
// CVA variants for TOC container
const tocContainerVariants = cva("relative flex items-center", {
variants: {
isActive: {
true: "bg-gray-100 dark:bg-gray-800",
false: "",
},
roundedCorners: {
none: "",
top: "rounded-t-lg",
bottom: "rounded-b-lg",
all: "rounded-lg",
},
},
defaultVariants: {
isActive: false,
roundedCorners: "none",
},
});
// CVA variants for TOC button
const tocButtonVariants = cva(
"w-full px-2 py-2 text-left text-sm transition-all duration-150 text-gray-900 dark:text-gray-100",
{
variants: {
isActive: {
true: "pr-4",
false: "",
},
depth: {
0: "",
1: "",
2: "",
3: "",
4: "",
5: "",
},
},
defaultVariants: {
isActive: false,
depth: 0,
},
},
);
/**
* Table of Contents component that automatically generates navigation
* from markdown headings with active section highlighting
*/
export function MarkdownToc({
markdownContent,
className = "",
}: MarkdownTocProps) {
const tocItems = useMemo(
() => parseMarkdownHeadings(markdownContent),
[markdownContent],
);
const [activeIds, setActiveIds] = useState<Set<string>>(new Set());
// Get all heading IDs in visual order for active range detection
const allHeadingIds = useMemo(() => {
const flattenHeadings = (items: TocItem[]): string[] => {
return items.flatMap((item) => [
item.id,
...flattenHeadings(item.children),
]);
};
return flattenHeadings(tocItems);
}, [tocItems]);
// Find first and last active items for continuous highlighting
const { firstActiveId, lastActiveId } = useMemo(() => {
const activeItemsInOrder = allHeadingIds.filter((id) => activeIds.has(id));
return {
firstActiveId: activeItemsInOrder[0] || null,
lastActiveId: activeItemsInOrder[activeItemsInOrder.length - 1] || null,
};
}, [allHeadingIds, activeIds]);
// Handle TOC item click navigation
const handleItemClick = useCallback((id: string) => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
}, []);
// Set up intersection observer for active heading detection
useLayoutEffect(() => {
// Update active headings
const updateActiveHeadings = () => {
const headingElements = allHeadingIds
.map((id) => document.getElementById(id))
.filter(Boolean) as HTMLElement[];
if (headingElements.length === 0) {
setActiveIds(new Set());
return;
}
const visibleHeadings = headingElements.filter((element) => {
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Consider element visible if it's in the top 95% of viewport
return rect.top >= 0 && rect.top <= viewportHeight * 0.95;
});
const activeHeadingIds = new Set<string>();
if (visibleHeadings.length > 0) {
// Add all visible headings
visibleHeadings.forEach((h) => activeHeadingIds.add(h.id));
// Find the heading that comes before the first visible one
const firstVisibleId = visibleHeadings[0].id;
const firstVisibleIndex = allHeadingIds.indexOf(firstVisibleId);
// If there's a heading before the first visible one, add it too
if (firstVisibleIndex > 0) {
const previousHeadingId = allHeadingIds[firstVisibleIndex - 1];
activeHeadingIds.add(previousHeadingId);
}
}
setActiveIds(activeHeadingIds);
};
// Set initial active headings immediately
updateActiveHeadings();
// Scroll listener for instant responsiveness
const handleScroll = () => {
updateActiveHeadings();
};
// Add scroll listener to the scroll container
const scrollContainer =
document.querySelector("[data-scroll-container]") ||
document.querySelector(".overflow-y-auto") ||
window;
scrollContainer.addEventListener("scroll", handleScroll, { passive: true });
return () => {
scrollContainer.removeEventListener("scroll", handleScroll);
};
}, [allHeadingIds]);
// Additional effect to ensure immediate highlighting on mount and content changes
useEffect(() => {
// Use requestAnimationFrame to ensure DOM is fully rendered
const checkVisible = () => {
const headingElements = allHeadingIds
.map((id) => document.getElementById(id))
.filter(Boolean) as HTMLElement[];
if (headingElements.length > 0) {
const visibleHeadings = headingElements.filter((element) => {
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight;
return rect.top >= 0 && rect.top <= viewportHeight * 0.95;
});
if (visibleHeadings.length > 0) {
setActiveIds(new Set(visibleHeadings.map((h) => h.id)));
}
}
};
requestAnimationFrame(checkVisible);
}, [markdownContent, allHeadingIds]);
// Helper function to determine rounded corner variant
const getRoundedCorners = useCallback(
(isActive: boolean, isFirst: boolean, isLast: boolean) => {
if (!isActive) return "none" as const;
if (isFirst && isLast) return "all" as const;
if (isFirst) return "top" as const;
if (isLast) return "bottom" as const;
return "none" as const;
},
[],
);
// Helper function to get depth variant (capped at 5 for CVA)
const getDepthVariant = useCallback((depth: number) => {
return Math.min(depth, 5) as 0 | 1 | 2 | 3 | 4 | 5;
}, []);
// Render individual TOC items with proper styling and nesting
const renderTocItems = useCallback(
(items: TocItem[], depth = 0) => {
return (
<div className="relative">
{items.map((item) => {
const isActive = activeIds.has(item.id);
const leftPadding = depth === 0 ? 16 : depth * 16 + 8;
const isFirstActive = item.id === firstActiveId;
const isLastActive = item.id === lastActiveId;
const roundedCorners = getRoundedCorners(
isActive,
isFirstActive,
isLastActive,
);
const depthVariant = getDepthVariant(depth);
return (
<div key={item.id} className="relative">
<div
className={tocContainerVariants({
isActive,
roundedCorners,
})}
>
<button
onClick={() => handleItemClick(item.id)}
className={tocButtonVariants({
isActive,
depth: depthVariant,
})}
style={{ paddingLeft: `${leftPadding}px` }}
>
{item.title}
</button>
</div>
{item.children.length > 0 && (
<div className="relative">
{renderTocItems(item.children, depth + 1)}
</div>
)}
</div>
);
})}
</div>
);
},
[
activeIds,
firstActiveId,
lastActiveId,
handleItemClick,
getRoundedCorners,
getDepthVariant,
],
);
if (tocItems.length === 0) {
return (
<div
className={cn("text-sm text-gray-500 dark:text-gray-400", className)}
>
No headings found
</div>
);
}
return (
<nav
className={cn("toc w-full text-xs", className)}
aria-label="Table of contents"
>
<div>{renderTocItems(tocItems)}</div>
</nav>
);
}
function parseMarkdownHeadings(markdownContent: string): TocItem[] {
const lines = markdownContent.split("\n");
const headings: Array<{ level: number; title: string; id: string }> = [];
lines.forEach((line) => {
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match) {
const level = match[1].length;
const title = match[2].trim();
const id = generateHeadingId(title);
headings.push({ level, title, id });
}
});
return buildTocTree(headings);
}
function buildTocTree(
headings: Array<{ level: number; title: string; id: string }>,
): TocItem[] {
const root: TocItem[] = [];
const stack: TocItem[] = [];
headings.forEach(({ level, title, id }) => {
const item: TocItem = {
id,
title,
level,
children: [],
};
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop();
}
if (stack.length === 0) {
root.push(item);
} else {
stack[stack.length - 1].children.push(item);
}
stack.push(item);
});
return root;
}
@@ -0,0 +1,74 @@
import React from "react";
import { Markdown } from "./Markdown";
import { MarkdownToc } from "./MarkdownToc";
type MarkdownWithTocProps = {
/**
* The markdown content to render
*/
markdownContent: string;
/**
* Additional CSS classes for the table of contents container
*/
tocClassName?: string;
/**
* Width of the table of contents sidebar (in Tailwind units)
* @default "w-64"
*/
tocWidth?: string;
/**
* Gap between the TOC and main content (in Tailwind units)
* @default "gap-6"
*/
gap?: string;
/**
* Sticky positioning offset from top (in Tailwind units)
* @default "top-4"
*/
stickyTop?: string;
};
/**
* A combined component that renders markdown content alongside a table of contents
*
* This component provides a two-column layout with:
* - Left main area: Rendered markdown content
* - Right sidebar: Sticky table of contents for navigation
*
* The table of contents automatically generates from headings in the markdown
* and provides clickable navigation to scroll to specific sections.
*/
export function MarkdownWithToc({
markdownContent,
tocClassName = "",
tocWidth = "w-64",
gap = "gap-6",
stickyTop = "top-6",
}: MarkdownWithTocProps) {
return (
<div className={`flex ${gap}`}>
{/* Main Content */}
<div className="min-w-0 flex-1">
<Markdown text={markdownContent} />
</div>
{/* Table of Contents Sidebar */}
<div className={`${tocWidth} flex-shrink-0`}>
<div
className={`sticky ${stickyTop}`}
style={{ maxHeight: "calc(100vh - 6rem)" }}
>
<div
className="overflow-y-auto scrollbar-hide"
style={{ maxHeight: "calc(100vh - 8rem)" }}
>
<MarkdownToc
markdownContent={markdownContent}
className={tocClassName}
/>
</div>
</div>
</div>
</div>
);
}
+7 -2
View File
@@ -2,8 +2,13 @@ import React from "react";
type Props = {
children: React.ReactNode;
className?: string;
};
export function Page({ children }: Props) {
return <div className="flex w-full flex-col gap-6 p-6">{children}</div>;
export function Page({ children, className }: Props) {
return (
<div className={`flex w-full flex-col gap-6 p-6 ${className || ""}`}>
{children}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import React from "react";
export const qitechIcons = {
Extruder: Extruder,
};
function Extruder({ className }: { className?: string }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 17V15L6.97707 12.2495C7.27322 12.0859 7.60606 12 7.94444 12H20C21.1046 12 22 12.8954 22 14V18C22 19.1046 21.1046 20 20 20H7.94444C7.60606 20 7.27322 19.9141 6.97707 19.7505L2 17Z"
stroke="black"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14 12V9.16205C14 8.92811 13.918 8.70158 13.7682 8.52187L11.3668 5.64018C10.824 4.98886 11.2872 4 12.135 4H19.865C20.7128 4 21.176 4.98886 20.6332 5.64018L18.2318 8.52187C18.082 8.70158 18 8.92811 18 9.16205V12"
stroke="black"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+80 -18
View File
@@ -7,6 +7,7 @@ type Props = {
autoScroll?: boolean; // Optional prop to control if terminal should auto-scroll
className?: string; // Optional prop to control terminal height
title?: string;
exportPrefix?: string; // Optional prefix for exported log files
};
const terminalStyle = cva([
@@ -45,6 +46,7 @@ const colorMap: Record<string, string> = {
// Parse ANSI color codes in text
const parseColorCodes = (text: string) => {
// Split by ANSI escape sequences
// eslint-disable-next-line no-control-regex
const parts = text.split(/(\x1b\[\d+m)/g);
let currentClass = "";
@@ -68,6 +70,7 @@ const parseColorCodes = (text: string) => {
// Function to strip ANSI color codes for plain text copy
const stripColorCodes = (text: string): string => {
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\[\d+m/g, "");
};
@@ -76,10 +79,12 @@ export function Terminal({
autoScroll = true,
className,
title = "Terminal",
exportPrefix,
}: Props) {
const terminalRef = useRef<HTMLDivElement>(null);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
const [copySuccess, setCopySuccess] = useState(false);
const [exportSuccess, setExportSuccess] = useState(false);
// Handle scrolling
useEffect(() => {
@@ -123,28 +128,85 @@ export function Terminal({
}
};
// Handle export to file
const handleExport = () => {
if (!exportPrefix) return;
// Strip ANSI color codes and join lines
const plainText = lines.map((line) => stripColorCodes(line)).join("\n");
// Create timestamp for filename
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, -5);
const filename = `${exportPrefix}_${timestamp}.log`;
// Create blob and download
const blob = new Blob([plainText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
// Create temporary link element and trigger download
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up
URL.revokeObjectURL(url);
// Show success feedback
setExportSuccess(true);
setTimeout(() => {
setExportSuccess(false);
}, 2000);
};
return (
<div className={terminalStyle({ className })}>
{/* Terminal header */}
<div className="flex items-center justify-between border-b border-neutral-700 bg-neutral-800 px-4 py-2">
<div className="text-xs text-neutral-400">{title}</div>
<button
onClick={handleCopy}
className="flex items-center text-xs text-neutral-400 transition-colors hover:text-neutral-200"
title="Copy to clipboard"
>
{copySuccess ? (
<>
<Icon name="lu:ClipboardCheck" className="mr-2 size-4" />
Copied!
</>
) : (
<>
<Icon name="lu:Clipboard" className="mr-2 size-4" />
Copy
</>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="flex items-center text-xs text-neutral-400 transition-colors hover:text-neutral-200"
title="Copy to clipboard"
>
{copySuccess ? (
<>
<Icon name="lu:ClipboardCheck" className="mr-2 size-4" />
Copied!
</>
) : (
<>
<Icon name="lu:Clipboard" className="mr-2 size-4" />
Copy
</>
)}
</button>
{exportPrefix && (
<button
onClick={handleExport}
className="flex items-center text-xs text-neutral-400 transition-colors hover:text-neutral-200"
title="Export logs to file"
>
{exportSuccess ? (
<>
<Icon name="lu:Check" className="mr-2 size-4" />
Exported!
</>
) : (
<>
<Icon name="lu:Save" className="mr-2 size-4" />
Export
</>
)}
</button>
)}
</button>
</div>
</div>
{/* Terminal content with custom scrollbar styling */}
@@ -157,7 +219,8 @@ export function Terminal({
scrollbarColor: "rgb(82 82 91) transparent",
}}
>
<style jsx global>{`
<style>{`
Add commentMore actions
/* For Webkit browsers (Chrome, Safari) */
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
@@ -178,7 +241,6 @@ export function Terminal({
scrollbar-color: rgb(82 82 91) transparent;
}
`}</style>
{lines.map((line, index) => {
const colorParts = parseColorCodes(line);
-1
View File
@@ -1,4 +1,3 @@
import { Moon } from "lucide-react";
import React from "react";
import { Button } from "@/components/ui/button";
import { toggleTheme } from "@/helpers/theme_helpers";
+5 -4
View File
@@ -41,10 +41,9 @@ type TopbarProps = {
export function Topbar({ items, pathname }: TopbarProps) {
const sidebarlessWidth = useSidebarlessWidth();
return (
<div className="flex h-full w-full flex-col">
<div className="h-18"></div>
<div className="flex h-screen w-full flex-col">
<div
className="fixed flex h-18 gap-2 bg-neutral-200 pt-2 pr-2"
className="fixed top-0 flex h-18 gap-2 bg-neutral-200 pt-2 pr-2"
// 50 is below popup dialogs
style={{ zIndex: 50, width: sidebarlessWidth }}
>
@@ -70,7 +69,9 @@ export function Topbar({ items, pathname }: TopbarProps) {
<FullscreenButton />
</div>
<Outlet />
<div className="mt-18 h-full overflow-y-auto">
<Outlet />
</div>
</div>
);
}
+479
View File
@@ -0,0 +1,479 @@
import React, { useRef, useState, useCallback, useEffect } from "react";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
import { renderUnitSymbol, getUnitIcon } from "@/control/units";
import { Icon } from "@/components/Icon";
import { BigGraphProps, SeriesData } from "./types";
import { GraphExportData } from "./excelExport";
import { DEFAULT_COLORS } from "./constants";
import { useAnimationRefs, stopAnimations } from "./animation";
import { HandlerRefs } from "./handlers";
import { useLiveMode } from "./liveMode";
import { useHistoricalMode } from "./historicalMode";
import { useBigGraphEffects } from "./useBigGraphEffects";
import { TouchButton } from "../touch/TouchButton";
// Helper function to normalize data to array format
function normalizeDataSeries(data: BigGraphProps["newData"]): SeriesData[] {
if (Array.isArray(data)) {
return data;
}
return [data];
}
// Helper function to get primary series for display value
function getPrimarySeries(data: BigGraphProps["newData"]): SeriesData | null {
const normalized = normalizeDataSeries(data);
return normalized.find((series) => series.newData !== null) || null;
}
// Helper function to format value for display
function formatDisplayValue(
value: number | undefined | null,
renderValue?: (value: number) => string,
): string {
if (value === undefined || value === null) return "N/A";
return renderValue ? renderValue(value) : value.toFixed(2);
}
export function BigGraph({
newData,
unit,
renderValue,
config,
graphId,
syncGraph,
onRegisterForExport,
onUnregisterFromExport,
}: BigGraphProps & {
onRegisterForExport?: (
graphId: string,
getDataFn: () => GraphExportData | null,
) => void;
onUnregisterFromExport?: (graphId: string) => void;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const uplotRef = useRef<uPlot | null>(null);
const chartCreatedRef = useRef(false);
// Animation refs
const animationRefs = useAnimationRefs();
// Series visibility state
const normalizedSeries = normalizeDataSeries(newData);
const [visibleSeries, setVisibleSeries] = useState<boolean[]>(
new Array(normalizedSeries.length).fill(true),
);
// Track when visibility changes to force chart recreation
const [visibilityVersion, setVisibilityVersion] = useState(0);
const [isRecreatingChart, setIsRecreatingChart] = useState(false);
// Initialize state from props or defaults
const [viewMode, setViewMode] = useState<"default" | "all" | "manual">(
syncGraph?.viewMode ?? "default",
);
const [isLiveMode, setIsLiveMode] = useState(syncGraph?.isLiveMode ?? true);
const [selectedTimeWindow, setSelectedTimeWindow] = useState<number | "all">(
syncGraph?.timeWindow ?? config.defaultTimeWindow ?? 30 * 60 * 1000,
);
const [cursorValue, setCursorValue] = useState<number | null>(null);
const startTimeRef = useRef<number | null>(null);
const manualScaleRef = useRef<{
x: { min: number; max: number };
y: { min: number; max: number };
} | null>(null);
const lastProcessedCountRef = useRef(0);
// Handler refs
const handlerRefs: HandlerRefs = {
isUserZoomingRef: useRef(false),
isDraggingRef: useRef(false),
lastDragXRef: useRef<number | null>(null),
isPinchingRef: useRef(false),
lastPinchDistanceRef: useRef<number | null>(null),
pinchCenterRef: useRef<{ x: number; y: number } | null>(null),
touchStartRef: useRef<{ x: number; y: number; time: number } | null>(null),
touchDirectionRef: useRef<"horizontal" | "vertical" | "unknown">("unknown"),
};
const colors = {
primary: config.colors?.primary ?? DEFAULT_COLORS.primary,
grid: config.colors?.grid ?? DEFAULT_COLORS.grid,
axis: config.colors?.axis ?? DEFAULT_COLORS.axis,
background: config.colors?.background ?? DEFAULT_COLORS.background,
};
// Effect to ensure at least one series is visible
useEffect(() => {
const hasVisibleSeries = visibleSeries.some((visible) => visible);
if (!hasVisibleSeries && normalizedSeries.length > 0) {
const firstValidIndex = normalizedSeries.findIndex(
(series) => series.newData !== null,
);
if (firstValidIndex !== -1) {
setVisibleSeries((prev) => {
const newVisibility = [...prev];
newVisibility[firstValidIndex] = true;
return newVisibility;
});
}
}
}, [visibleSeries, normalizedSeries]);
// Filter data based on visibility
const filteredData = React.useMemo(() => {
if (Array.isArray(newData)) {
const filtered = newData.filter((_, index) => visibleSeries[index]);
return filtered.length > 0 ? filtered : newData.slice(0, 1); // Fallback to first series
}
return visibleSeries[0] ? newData : { newData: null };
}, [newData, visibleSeries]);
// Register export function that includes ALL series (not just visible ones)
useEffect(() => {
if (onRegisterForExport) {
const getExportData = (): GraphExportData | null => {
// Return ALL series data for export, not just visible ones
return {
config,
data: newData, // Use original newData, not filtered
unit,
renderValue,
};
};
onRegisterForExport(graphId, getExportData);
return () => {
if (onUnregisterFromExport) {
onUnregisterFromExport(graphId);
}
};
}
}, [
onRegisterForExport,
onUnregisterFromExport,
graphId,
config,
newData, // Use original newData
unit,
renderValue,
]);
const updateYAxisScale = useCallback(
(timestamps: number[], values: number[], xMin?: number, xMax?: number) => {
if (!uplotRef.current || values.length === 0) return;
let visibleValues: number[] = [];
if (xMin !== undefined && xMax !== undefined) {
for (let i = 0; i < timestamps.length; i++) {
if (timestamps[i] >= xMin && timestamps[i] <= xMax) {
visibleValues.push(values[i]);
}
}
} else {
visibleValues = [...values];
}
config.lines?.forEach((line) => {
if (line.show !== false) {
visibleValues.push(line.value);
}
});
if (visibleValues.length === 0) {
visibleValues = values;
}
const minY = Math.min(...visibleValues);
const maxY = Math.max(...visibleValues);
const range = maxY - minY || Math.abs(maxY) * 0.1 || 1;
const yRange = {
min: minY - range * 0.1,
max: maxY + range * 0.1,
};
try {
uplotRef.current.batch(() => {
uplotRef.current!.setScale("y", yRange);
});
if (viewMode === "manual" && manualScaleRef.current) {
manualScaleRef.current.y = yRange;
}
} catch (error) {
console.warn("Error updating Y-axis scale:", error);
}
},
[config.lines, viewMode],
);
// Initialize live mode handlers
const liveMode = useLiveMode({
newData: filteredData,
uplotRef,
config,
animationRefs,
viewMode,
selectedTimeWindow,
startTimeRef,
updateYAxisScale,
lastProcessedCountRef,
chartCreatedRef,
});
// Initialize historical mode handlers
const historicalMode = useHistoricalMode({
newData: filteredData,
uplotRef,
animationRefs,
getCurrentLiveEndTimestamp: liveMode.getCurrentLiveEndTimestamp,
updateYAxisScale,
lastProcessedCountRef,
manualScaleRef,
});
const handleTimeWindowChangeInternal = useCallback(
(newTimeWindow: number | "all", isSync: boolean = false) => {
stopAnimations(animationRefs);
setSelectedTimeWindow(newTimeWindow);
if (!uplotRef.current) {
return;
}
// FIXED: Handle time window changes properly based on current mode
if (newTimeWindow === "all") {
setViewMode("all");
// Only switch to live mode when selecting "all"
if (!isLiveMode) {
setIsLiveMode(true);
historicalMode.switchToLiveMode();
}
// Always use live mode handler for "all"
liveMode.handleLiveTimeWindow(newTimeWindow);
} else {
// For specific time windows (30m, 1h, etc.)
setViewMode("default");
// Stay in current mode (don't change isLiveMode)
if (isLiveMode) {
liveMode.handleLiveTimeWindow(newTimeWindow);
} else {
// Stay in historical mode for specific time windows
historicalMode.handleHistoricalTimeWindow(newTimeWindow);
}
}
// Notify parent about time window change
if (!isSync && syncGraph?.onTimeWindowChange) {
syncGraph.onTimeWindowChange(graphId, newTimeWindow);
}
},
[
animationRefs,
isLiveMode,
liveMode.handleLiveTimeWindow,
historicalMode.handleHistoricalTimeWindow,
historicalMode.switchToLiveMode,
syncGraph,
graphId,
],
);
// Toggle series visibility with smooth chart recreation
const toggleSeries = useCallback((index: number) => {
setVisibleSeries((prev) => {
const newVisibility = [...prev];
// Check if this would hide all series
const wouldHideAll =
newVisibility[index] &&
newVisibility.filter((visible, i) => i !== index && visible).length ===
0;
if (wouldHideAll) {
// Don't allow hiding the last visible series
return prev;
}
newVisibility[index] = !newVisibility[index];
// Set recreating flag to prevent flicker
setIsRecreatingChart(true);
// Force chart recreation by incrementing version
setVisibilityVersion((v) => v + 1);
return newVisibility;
});
}, []);
// Force chart recreation when visibility changes
useEffect(() => {
if (visibilityVersion > 0 && uplotRef.current) {
// Destroy existing chart
uplotRef.current.destroy();
uplotRef.current = null;
chartCreatedRef.current = false;
// Stop any ongoing animations
stopAnimations(animationRefs);
// Reset processed count to force recreation
lastProcessedCountRef.current = 0;
// Clear recreating flag after a short delay to allow chart to rebuild
setTimeout(() => {
setIsRecreatingChart(false);
}, 100);
}
}, [visibilityVersion, animationRefs]);
// Use the extracted useEffect hooks
useBigGraphEffects({
// Refs
containerRef,
uplotRef,
startTimeRef,
manualScaleRef,
lastProcessedCountRef,
animationRefs,
handlerRefs,
chartCreatedRef,
// Props
newData: filteredData,
unit,
renderValue,
config,
graphId,
syncGraph,
onRegisterForExport,
onUnregisterFromExport,
// State
viewMode,
isLiveMode,
selectedTimeWindow,
// State setters
setSelectedTimeWindow,
setViewMode,
setIsLiveMode,
setCursorValue,
// Handlers
liveMode,
historicalMode,
colors,
updateYAxisScale,
handleTimeWindowChangeInternal,
});
const primarySeries = getPrimarySeries(filteredData);
const displayValue =
cursorValue !== null ? cursorValue : primarySeries?.newData?.current?.value;
const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"];
return (
<div className="h-[50vh] w-full">
<div className="flex h-full w-full flex-col overflow-hidden rounded-3xl border border-gray-200 bg-white shadow">
<div className="flex items-center justify-between pt-4 pr-5 pb-6 pl-6">
<div className="mt-1 flex items-center gap-4">
<Icon
name={unit ? getUnitIcon(unit) : "lu:TrendingUp"}
className="size-6 text-gray-600"
/>
<h2 className="text-2xl leading-none font-bold text-gray-900">
{config.title}
</h2>
{/* Show main display value only for single series or primary series */}
{normalizedSeries.length === 1 && (
<div className="flex items-center gap-2 text-base text-gray-600">
<span className="font-mono leading-none font-bold text-gray-900">
{formatDisplayValue(displayValue, renderValue)}
</span>
<span className="leading-none text-gray-500">
{renderUnitSymbol(unit)}
</span>
</div>
)}
</div>
{/* Series Toggle Controls */}
{normalizedSeries.length > 1 && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{normalizedSeries.map((series, index) => {
const seriesColor =
series.color || defaultColors[index % defaultColors.length];
const currentValue = series.newData?.current?.value;
const formattedValue = formatDisplayValue(
currentValue,
renderValue,
);
// Check if this is the last visible series
const isLastVisible =
visibleSeries[index] &&
visibleSeries.filter((visible) => visible).length === 1;
return (
<TouchButton
key={index}
onClick={() => toggleSeries(index)}
disabled={isLastVisible}
className={`rounded-md px-3 py-1 text-sm transition-all ${
visibleSeries[index]
? "text-white shadow-md"
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
} ${isLastVisible ? "cursor-not-allowed opacity-75" : ""}`}
style={{
backgroundColor: visibleSeries[index]
? seriesColor
: undefined,
}}
title={`${series.title || `Series ${index + 1}`}: ${formattedValue} ${renderUnitSymbol(unit) || ""}`}
>
<div className="flex flex-col items-center">
<span className="text-xs font-medium">
{series.title || `S${index + 1}`}
</span>
<span className="font-mono leading-none font-bold text-white">
{formatDisplayValue(displayValue, renderValue)}{" "}
{renderUnitSymbol(unit)}
</span>
</div>
</TouchButton>
);
})}
</div>
</div>
)}
</div>
<div className="-mt-2 px-6">
<div className="h-px bg-gray-200"></div>
</div>
<div className="flex-1 overflow-hidden rounded-b-3xl pt-4">
<div
ref={containerRef}
className={`h-full w-full overflow-hidden transition-opacity duration-100 ${
isRecreatingChart ? "opacity-50" : "opacity-100"
}`}
style={{ backgroundColor: colors.background }}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,210 @@
import React, { useState } from "react";
import { TouchButton } from "@/components/touch/TouchButton";
import { Icon } from "@/components/Icon";
import { ControlCard } from "@/control/ControlCard";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ControlProps } from "./types";
import { DEFAULT_TIME_WINDOW_OPTIONS } from "./constants";
export function GraphControls({
timeWindow,
isLiveMode,
onTimeWindowChange,
onSwitchToLive,
onSwitchToHistorical,
onExport,
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
}: ControlProps) {
const getSelectedTimeWindowLabel = () => {
const option = timeWindowOptions.find((opt) => opt.value === timeWindow);
return option ? option.label : "1m";
};
return (
<ControlCard className="ml-auto w-fit py-4">
<div className="flex items-center justify-end">
<div className="flex items-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TouchButton
variant="outline"
className="h-auto border-gray-300 bg-white px-3 py-3 text-base text-gray-900 hover:bg-gray-50"
>
{getSelectedTimeWindowLabel()}
<Icon name="lu:ChevronDown" className="ml-2 size-4" />
</TouchButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel className="text-base font-medium">
Time Window
</DropdownMenuLabel>
<DropdownMenuSeparator />
{timeWindowOptions.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => onTimeWindowChange(option.value)}
className={`min-h-[48px] px-4 py-3 text-base ${
timeWindow === option.value ? "bg-blue-50" : ""
}`}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<TouchButton
onClick={onSwitchToHistorical}
variant="outline"
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
!isLiveMode
? "bg-black text-white"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
}`}
>
Historical
</TouchButton>
<TouchButton
onClick={onSwitchToLive}
variant="outline"
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
isLiveMode
? "bg-black text-white"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
}`}
>
Live
</TouchButton>
{onExport && (
<>
<div className="mx-2 h-8 w-px bg-gray-200"></div>
<TouchButton
onClick={onExport}
variant="outline"
className="h-auto bg-green-600 px-3 py-3 text-base font-medium text-white hover:bg-green-700"
>
Export
</TouchButton>
</>
)}
</div>
</div>
</ControlCard>
);
}
export function FloatingControlPanel({
timeWindow,
isLiveMode,
onTimeWindowChange,
onSwitchToLive,
onSwitchToHistorical,
onExport,
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
}: ControlProps) {
const [isExpanded, setIsExpanded] = useState(false);
const getSelectedTimeWindowLabel = () => {
const option = timeWindowOptions.find((opt) => opt.value === timeWindow);
return option ? option.label : "1m";
};
return (
<div className="fixed right-6 bottom-6 z-50">
<ControlCard className="overflow-hidden px-4 py-4 transition-all duration-300 ease-in-out">
<div
className={`flex items-center ${isExpanded ? "gap-3" : "justify-center"}`}
>
<div
className={`flex items-center gap-3 transition-all duration-300 ease-in-out ${
isExpanded
? "max-w-none translate-x-0 opacity-100"
: "w-0 max-w-0 overflow-hidden opacity-0"
}`}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TouchButton
variant="outline"
className="h-auto border-gray-300 bg-white px-3 py-3 text-base text-gray-900 hover:bg-gray-50"
>
{getSelectedTimeWindowLabel()}
<Icon name="lu:ChevronDown" className="ml-2 size-4" />
</TouchButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel className="text-base font-medium">
Time Window
</DropdownMenuLabel>
<DropdownMenuSeparator />
{timeWindowOptions.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => onTimeWindowChange(option.value)}
className={`min-h-[48px] px-4 py-3 text-base ${
timeWindow === option.value ? "bg-blue-50" : ""
}`}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<TouchButton
onClick={onSwitchToHistorical}
variant="outline"
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
!isLiveMode
? "bg-black text-white"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
}`}
>
Historical
</TouchButton>
<TouchButton
onClick={onSwitchToLive}
variant="outline"
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
isLiveMode
? "bg-black text-white"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
}`}
>
Live
</TouchButton>
{isExpanded && onExport && (
<div className="h-8 w-px bg-gray-200"></div>
)}
{onExport && (
<TouchButton
onClick={onExport}
variant="outline"
className="h-auto bg-green-600 px-3 py-3 text-base font-medium text-white hover:bg-green-700"
>
Export
</TouchButton>
)}
</div>
{isExpanded && <div className="h-8 w-px bg-gray-200"></div>}
<TouchButton
onClick={() => setIsExpanded(!isExpanded)}
variant="outline"
className="h-auto flex-shrink-0 bg-black p-3 text-white hover:bg-gray-100"
icon={isExpanded ? "lu:ChevronRight" : "lu:ChevronLeft"}
/>
</div>
</ControlCard>
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
import React, { useEffect, useRef, useCallback, useMemo } from "react";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
import { getSeriesMinMax, seriesToUPlotData, TimeSeries } from "@/lib/timeseries";
type MiniGraphProps = {
newData: TimeSeries | null;
width: number;
renderValue?: (value: number) => string;
};
const HEIGHT = 64;
export function MiniGraph({ newData, width, renderValue }: MiniGraphProps) {
const divRef = useRef<HTMLDivElement | null>(null);
const uplotRef = useRef<uPlot | null>(null);
// Performance tracking refs
const lastUpdateTimestamp = useRef<number>(0);
const lastDataHash = useRef<string>("");
const lastMinMax = useRef<{ min: number; max: number }>({ min: 0, max: 0 });
const rafId = useRef<number>(0);
const isInitialized = useRef<boolean>(false);
const pendingUpdate = useRef<boolean>(false);
// Fast hash function for data change detection
const hashData = useCallback((timestamps: number[], values: number[]): string => {
if (timestamps.length === 0) return "";
// Hash only first, last, and length for performance
return `${timestamps[0]}-${timestamps[timestamps.length - 1]}-${timestamps.length}-${values[values.length - 1]}`;
}, []);
// Memoize the tick formatter to avoid recreating on every render
const tickFormatter = useMemo(() => {
return renderValue ?
(u: uPlot, ticks: number[]) => ticks.map(v => renderValue(v)) :
(u: uPlot, ticks: number[]) => ticks.map(v => v.toFixed(1));
}, [renderValue]);
// Ultra-efficient update function
const updateChart = useCallback(() => {
if (!uplotRef.current || !newData?.short || !newData?.current) {
pendingUpdate.current = false;
return;
}
const cur = newData.current;
// Skip if no new data
if (cur.timestamp <= lastUpdateTimestamp.current) {
pendingUpdate.current = false;
return;
}
const short = newData.short;
const timeWindow = short.timeWindow;
// Get data
const [timestamps, values] = seriesToUPlotData(short);
if (timestamps.length === 0) {
pendingUpdate.current = false;
return;
}
// Check if data actually changed using hash
const dataHash = hashData(timestamps, values);
if (dataHash === lastDataHash.current) {
pendingUpdate.current = false;
return;
}
// Get min/max and check if scales need updating
const { min: minY, max: maxY } = getSeriesMinMax(short);
const scalesChanged = minY !== lastMinMax.current.min || maxY !== lastMinMax.current.max;
// Update tracking vars
lastUpdateTimestamp.current = cur.timestamp;
lastDataHash.current = dataHash;
lastMinMax.current = { min: minY, max: maxY };
const range = maxY - minY || 1;
const cutoff = cur.timestamp - timeWindow;
// Batch all updates to minimize redraws
uplotRef.current.batch(() => {
// Always update data and x-scale (time moves forward)
uplotRef.current!.setData([timestamps, values]);
uplotRef.current!.setScale("x", { min: cutoff, max: cur.timestamp });
// Only update y-scale if min/max changed
if (scalesChanged) {
uplotRef.current!.setScale("y", {
min: minY - range * 0.1,
max: maxY + range * 0.1,
});
}
});
pendingUpdate.current = false;
}, [newData, hashData]);
// RAF-throttled update scheduler
const scheduleUpdate = useCallback(() => {
if (pendingUpdate.current || !isInitialized.current) return;
pendingUpdate.current = true;
if (rafId.current) {
cancelAnimationFrame(rafId.current);
}
rafId.current = requestAnimationFrame(updateChart);
}, [updateChart]);
// Initialize chart only once
useEffect(() => {
if (!divRef.current || !newData?.short?.timeWindow || isInitialized.current) return;
const short = newData.short;
const timeWindow = short.timeWindow;
// Get initial data
const [allTimestamps, allValues] = seriesToUPlotData(short);
const { min: minY, max: maxY } = getSeriesMinMax(short);
const range = maxY - minY || 1;
// Initialize tracking
const dataHash = hashData(allTimestamps, allValues);
lastDataHash.current = dataHash;
lastMinMax.current = { min: minY, max: maxY };
const now = Date.now();
const latestTimestamp = allTimestamps.length > 0 ? allTimestamps[allTimestamps.length - 1] : now;
const cutoff = latestTimestamp - timeWindow;
const opts: uPlot.Options = {
width,
height: HEIGHT,
padding: [4, 0, 4, 0],
cursor: { show: false },
legend: { show: false },
scales: {
x: {
time: true,
min: cutoff,
max: latestTimestamp,
},
y: {
auto: false,
min: minY - range * 0.1,
max: maxY + range * 0.1,
},
},
axes: [
{ show: false },
{
side: 1,
grid: { stroke: "#ccc", width: 0.5 },
ticks: { stroke: "#ccc", width: 0.5 },
values: tickFormatter, // Use the memoized formatter
},
],
series: [
{},
{
stroke: "black",
width: 2,
spanGaps: true,
points: { show: false }, // Hide the dots/points
},
],
};
uplotRef.current = new uPlot(opts, [allTimestamps, allValues], divRef.current);
isInitialized.current = true;
return () => {
if (rafId.current) {
cancelAnimationFrame(rafId.current);
}
uplotRef.current?.destroy();
uplotRef.current = null;
isInitialized.current = false;
pendingUpdate.current = false;
};
}, [width, newData?.short?.timeWindow, hashData, tickFormatter]);
// Trigger updates only when timestamp changes
useEffect(() => {
scheduleUpdate();
}, [newData?.current?.timestamp, scheduleUpdate]);
// Efficient width handling
useEffect(() => {
if (!uplotRef.current || !isInitialized.current) return;
uplotRef.current.setSize({ width, height: HEIGHT });
}, [width]);
return (
<div
ref={divRef}
style={{
width: "100%",
height: HEIGHT,
overflow: "hidden",
}}
/>
);
}
@@ -0,0 +1,87 @@
import React from "react";
import { BigGraph } from "./BigGraph";
import { GraphControls, FloatingControlPanel } from "./GraphControls";
import { useGraphSync } from "./useGraphSync";
import { BigGraphProps, PropGraphSync, TimeWindowOption } from "./types";
import { GraphExportData } from "./excelExport";
export function SyncedBigGraph({
syncGraph: externalSyncGraph,
registerForExport,
unregisterFromExport,
...props
}: Omit<BigGraphProps, "syncGraph"> & {
syncGraph?: PropGraphSync;
registerForExport?: (
graphId: string,
getDataFn: () => GraphExportData | null,
) => void;
unregisterFromExport?: (graphId: string) => void;
}) {
const defaultSync = useGraphSync();
return (
<BigGraph
{...props}
syncGraph={externalSyncGraph || defaultSync.syncGraph}
onRegisterForExport={registerForExport}
onUnregisterFromExport={unregisterFromExport}
/>
);
}
export function SyncedGraphControls({
controlProps,
timeWindowOptions,
...props
}: {
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
timeWindowOptions?: TimeWindowOption[];
}) {
const defaultSync = useGraphSync();
const finalProps = controlProps || defaultSync.controlProps;
return (
<GraphControls
{...finalProps}
timeWindowOptions={timeWindowOptions}
{...props}
/>
);
}
export function SyncedFloatingControlPanel({
controlProps,
timeWindowOptions,
...props
}: {
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
timeWindowOptions?: TimeWindowOption[];
}) {
const defaultSync = useGraphSync();
const finalProps = controlProps || defaultSync.controlProps;
return (
<FloatingControlPanel
{...finalProps}
timeWindowOptions={timeWindowOptions}
{...props}
/>
);
}
export function AutoSyncedBigGraph({
syncHook,
...props
}: Omit<BigGraphProps, "syncGraph"> & {
syncHook: ReturnType<typeof useGraphSync>;
}) {
return (
<SyncedBigGraph
{...props}
syncGraph={syncHook.syncGraph}
registerForExport={syncHook.registerGraphForExport}
unregisterFromExport={syncHook.unregisterGraphFromExport}
/>
);
}
+253
View File
@@ -0,0 +1,253 @@
import { useRef } from "react";
import uPlot from "uplot";
import { POINT_ANIMATION_DURATION } from "./constants";
export interface AnimationState {
isAnimating: boolean;
startTime: number;
fromValue: number;
toValue: number;
fromTimestamp: number;
toTimestamp: number;
targetIndex: number;
}
export interface AnimationRefs {
animationFrame: React.RefObject<number | null>;
animationState: React.RefObject<AnimationState>;
lastRenderedData: React.RefObject<{
timestamps: number[];
values: number[];
}>;
realPointsCount: React.RefObject<number>;
}
export function useAnimationRefs(): AnimationRefs {
const animationFrameRef = useRef<number | null>(null);
const animationStateRef = useRef<AnimationState>({
isAnimating: false,
startTime: 0,
fromValue: 0,
toValue: 0,
fromTimestamp: 0,
toTimestamp: 0,
targetIndex: 0,
});
const lastRenderedDataRef = useRef<{
timestamps: number[];
values: number[];
}>({ timestamps: [], values: [] });
const realPointsCountRef = useRef(0);
return {
animationFrame: animationFrameRef,
animationState: animationStateRef,
lastRenderedData: lastRenderedDataRef,
realPointsCount: realPointsCountRef,
};
}
export function lerp(start: number, end: number, t: number): number {
return start + (end - start) * t;
}
export function stopAnimations(refs: AnimationRefs): void {
if (refs.animationFrame.current) {
cancelAnimationFrame(refs.animationFrame.current);
refs.animationFrame.current = null;
}
refs.animationState.current.isAnimating = false;
}
export function buildUPlotData(
timestamps: number[],
values: number[],
realPointsCount: number | undefined,
realPointsCountRef: React.RefObject<number>,
config: { lines?: Array<{ show?: boolean; value: number }> },
allSeriesData?: number[][],
): uPlot.AlignedData {
const uData: uPlot.AlignedData = [timestamps];
// Add primary series (for animation)
uData.push(values);
// Add additional series if provided
if (allSeriesData) {
allSeriesData.forEach((seriesValues) => {
uData.push(seriesValues);
});
}
if (realPointsCount !== undefined) {
realPointsCountRef.current = realPointsCount;
}
// Add config lines
config.lines?.forEach((line) => {
if (line.show !== false) {
uData.push(timestamps.map(() => line.value));
}
});
return uData;
}
export function animateNewPoint(
currentData: { timestamps: number[]; values: number[] },
targetData: { timestamps: number[]; values: number[] },
refs: AnimationRefs,
uplotRef: React.RefObject<uPlot | null>,
isLiveMode: boolean,
viewMode: string,
selectedTimeWindow: number | "all",
startTimeRef: React.RefObject<number | null>,
config: { lines?: Array<{ show?: boolean; value: number }> },
updateYAxisScale: (
timestamps: number[],
values: number[],
xMin?: number,
xMax?: number,
) => void,
getAllSeriesData?: () => number[][],
): void {
if (targetData.timestamps.length <= currentData.timestamps.length) {
return;
}
if (!isLiveMode || viewMode === "manual") {
return;
}
const newIndex = currentData.timestamps.length;
const prevValue =
currentData.values[newIndex - 1] ?? targetData.values[newIndex];
const prevTimestamp =
currentData.timestamps[newIndex - 1] ?? targetData.timestamps[newIndex];
const newValue = targetData.values[newIndex];
const newTimestamp = targetData.timestamps[newIndex];
if (refs.animationFrame.current) {
cancelAnimationFrame(refs.animationFrame.current);
refs.animationFrame.current = null;
}
refs.animationState.current = {
isAnimating: true,
startTime: performance.now(),
fromValue: prevValue,
toValue: newValue,
fromTimestamp: prevTimestamp,
toTimestamp: newTimestamp,
targetIndex: newIndex,
};
const animate = (currentTime: number) => {
if (
!uplotRef.current ||
!refs.animationState.current.isAnimating ||
!isLiveMode
)
return;
const elapsed = currentTime - refs.animationState.current.startTime;
const progress = Math.min(elapsed / POINT_ANIMATION_DURATION, 1);
const animatedTimestamps = [...currentData.timestamps];
const animatedValues = [...currentData.values];
if (progress < 1) {
const interpolatedTimestamp = lerp(
refs.animationState.current.fromTimestamp,
refs.animationState.current.toTimestamp,
progress,
);
const interpolatedValue = lerp(
refs.animationState.current.fromValue,
refs.animationState.current.toValue,
progress,
);
animatedTimestamps.push(interpolatedTimestamp);
animatedValues.push(interpolatedValue);
} else {
animatedTimestamps.push(refs.animationState.current.toTimestamp);
animatedValues.push(refs.animationState.current.toValue);
refs.lastRenderedData.current = {
timestamps: [...animatedTimestamps],
values: [...animatedValues],
};
refs.realPointsCount.current = animatedTimestamps.length;
refs.animationState.current.isAnimating = false;
}
// Get all series data for complete uPlot update
const allSeriesData = getAllSeriesData ? getAllSeriesData() : undefined;
const animatedUData = buildUPlotData(
animatedTimestamps,
animatedValues,
animatedTimestamps.length,
refs.realPointsCount,
config,
allSeriesData,
);
uplotRef.current.setData(animatedUData);
if (isLiveMode && animatedTimestamps.length > 0) {
const latestTimestamp = animatedTimestamps[animatedTimestamps.length - 1];
if (viewMode === "default") {
let xMin: number | undefined, xMax: number | undefined;
if (selectedTimeWindow === "all") {
const fullStart = startTimeRef.current ?? animatedTimestamps[0];
xMin = fullStart;
xMax = latestTimestamp;
} else {
xMin = latestTimestamp - (selectedTimeWindow as number);
xMax = latestTimestamp;
}
uplotRef.current.setScale("x", { min: xMin, max: xMax });
updateYAxisScale(animatedTimestamps, animatedValues, xMin, xMax);
} else if (viewMode === "all") {
const fullStart = startTimeRef.current ?? animatedTimestamps[0];
uplotRef.current.setScale("x", {
min: fullStart,
max: latestTimestamp,
});
updateYAxisScale(
animatedTimestamps,
animatedValues,
fullStart,
latestTimestamp,
);
}
}
if (progress < 1) {
refs.animationFrame.current = requestAnimationFrame(animate);
} else {
if (targetData.timestamps.length > animatedTimestamps.length) {
animateNewPoint(
{ timestamps: animatedTimestamps, values: animatedValues },
targetData,
refs,
uplotRef,
isLiveMode,
viewMode,
selectedTimeWindow,
startTimeRef,
config,
updateYAxisScale,
getAllSeriesData,
);
}
}
};
refs.animationFrame.current = requestAnimationFrame(animate);
}
@@ -0,0 +1,21 @@
import { TimeWindowOption } from "./types";
export const DEFAULT_TIME_WINDOW_OPTIONS: TimeWindowOption[] = [
{ value: 10 * 1000, label: "10s" },
{ value: 30 * 1000, label: "30s" },
{ value: 1 * 60 * 1000, label: "1m" },
{ value: 5 * 60 * 1000, label: "5m" },
{ value: 10 * 60 * 1000, label: "10m" },
{ value: 30 * 60 * 1000, label: "30m" },
{ value: 1 * 60 * 60 * 1000, label: "1h" },
{ value: "all" as const, label: "Show All" },
];
export const POINT_ANIMATION_DURATION = 1000;
export const DEFAULT_COLORS = {
primary: "#3b82f6",
grid: "#e2e8f0",
axis: "#64748b",
background: "#ffffff",
};
@@ -0,0 +1,550 @@
import uPlot from "uplot";
import { seriesToUPlotData, TimeSeries } from "@/lib/timeseries";
import {
createEventHandlers,
attachEventHandlers,
HandlerRefs,
HandlerCallbacks,
} from "./handlers";
import { BigGraphProps, SeriesData } from "./types";
import { AnimationRefs } from "./animation";
export interface CreateChartParams {
containerRef: React.RefObject<HTMLDivElement | null>;
uplotRef: React.RefObject<uPlot | null>;
newData: BigGraphProps["newData"];
config: BigGraphProps["config"];
colors: {
primary: string;
grid: string;
axis: string;
background: string;
};
renderValue?: (value: number) => string;
viewMode: "default" | "all" | "manual";
selectedTimeWindow: number | "all";
isLiveMode: boolean;
startTimeRef: React.RefObject<number | null>;
manualScaleRef: React.RefObject<{
x: { min: number; max: number };
y: { min: number; max: number };
} | null>;
animationRefs: AnimationRefs;
handlerRefs: HandlerRefs;
graphId: string;
syncGraph?: BigGraphProps["syncGraph"];
getHistoricalEndTimestamp: () => number;
updateYAxisScale: (
timestamps: number[],
values: number[],
xMin?: number,
xMax?: number,
) => void;
setViewMode: React.Dispatch<
React.SetStateAction<"default" | "all" | "manual">
>;
setIsLiveMode: React.Dispatch<React.SetStateAction<boolean>>;
setCursorValue: React.Dispatch<React.SetStateAction<number | null>>;
}
// Helper function to normalize data to array format
function normalizeDataSeries(data: BigGraphProps["newData"]): SeriesData[] {
if (Array.isArray(data)) {
return data;
}
return [data];
}
// Helper function to get all valid TimeSeries from DataSeries
function getAllTimeSeries(
data: BigGraphProps["newData"],
): Array<{ series: TimeSeries; title?: string; color?: string }> {
const normalized = normalizeDataSeries(data);
return normalized
.filter((series) => series.newData !== null)
.map((series) => ({
series: series.newData!,
title: series.title,
color: series.color,
}));
}
export function createChart({
containerRef,
uplotRef,
newData,
config,
colors,
renderValue,
viewMode,
selectedTimeWindow,
isLiveMode,
startTimeRef,
manualScaleRef,
animationRefs,
handlerRefs,
graphId,
syncGraph,
getHistoricalEndTimestamp,
updateYAxisScale,
setViewMode,
setIsLiveMode,
setCursorValue,
}: CreateChartParams): (() => void) | undefined {
if (!containerRef.current) return;
const allSeries = getAllTimeSeries(newData);
if (allSeries.length === 0) return;
// Use the first series for timing calculations
const primarySeries = allSeries[0].series;
const [timestamps, primaryValues] = seriesToUPlotData(primarySeries.long);
if (timestamps.length === 0) return;
if (startTimeRef.current === null && timestamps.length > 0) {
startTimeRef.current = timestamps[0];
}
// Build uPlot data for all series
const uPlotData: uPlot.AlignedData = [timestamps as any];
allSeries.forEach(({ series }) => {
const [, values] = seriesToUPlotData(series.long);
uPlotData.push(values as any);
});
// Add config lines data
config.lines?.forEach((line) => {
if (line.show !== false) {
const lineData = new Array(timestamps.length).fill(line.value);
uPlotData.push(lineData as any);
}
});
const fullStart = startTimeRef.current ?? timestamps[0] ?? 0;
let initialMin: number, initialMax: number;
if (viewMode === "manual" && manualScaleRef.current) {
initialMin = manualScaleRef.current.x.min;
initialMax = manualScaleRef.current.x.max;
} else if (selectedTimeWindow === "all") {
initialMin = fullStart;
initialMax = getHistoricalEndTimestamp();
} else {
const endTimestamp = getHistoricalEndTimestamp();
if (isLiveMode) {
const defaultViewStart = Math.max(
endTimestamp - (selectedTimeWindow as number),
fullStart,
);
initialMin = defaultViewStart;
} else {
initialMin = endTimestamp - (selectedTimeWindow as number);
}
initialMax = endTimestamp;
}
// Calculate initial Y range from all series
const initialVisibleValues: number[] = [];
allSeries.forEach(({ series }) => {
const [seriesTimestamps, seriesValues] = seriesToUPlotData(series.long);
for (let i = 0; i < seriesTimestamps.length; i++) {
if (
seriesTimestamps[i] >= initialMin &&
seriesTimestamps[i] <= initialMax
) {
initialVisibleValues.push(seriesValues[i]);
}
}
});
config.lines?.forEach((line) => {
if (line.show !== false) {
initialVisibleValues.push(line.value);
}
});
if (initialVisibleValues.length === 0) {
allSeries.forEach(({ series }) => {
const [, values] = seriesToUPlotData(series.long);
initialVisibleValues.push(...values);
});
config.lines?.forEach((line) => {
if (line.show !== false) {
initialVisibleValues.push(line.value);
}
});
}
let initialYMin: number, initialYMax: number;
if (initialVisibleValues.length > 0) {
const minY = Math.min(...initialVisibleValues);
const maxY = Math.max(...initialVisibleValues);
const range = maxY - minY || Math.abs(maxY) * 0.1 || 1;
initialYMin = minY - range * 0.1;
initialYMax = maxY + range * 0.1;
} else {
initialYMin = -1;
initialYMax = 1;
}
const rect = containerRef.current.getBoundingClientRect();
const width = rect.width;
const height = Math.min(rect.height, window.innerHeight * 0.5);
// Build series configuration
const seriesConfig: uPlot.Series[] = [{ label: "Time" }];
// Add data series
const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"];
allSeries.forEach(({ title, color }, index) => {
seriesConfig.push({
label: title || `Series ${index + 1}`,
stroke: color || defaultColors[index % defaultColors.length],
width: 2,
spanGaps: true,
show: true,
points: {
show: (_u, _seriesIdx, dataIdx) => {
return dataIdx < animationRefs.realPointsCount.current;
},
size: 6,
stroke: color || defaultColors[index % defaultColors.length],
fill: color || defaultColors[index % defaultColors.length],
width: 2,
},
});
});
// Add config lines
config.lines?.forEach((line) => {
if (line.show !== false) {
seriesConfig.push({
label: line.label,
stroke: line.color,
width: line.width ?? 1,
dash: line.dash ?? (line.type === "threshold" ? [5, 5] : undefined),
show: true,
points: { show: false },
});
}
});
if (uplotRef.current) {
uplotRef.current.destroy();
uplotRef.current = null;
}
uplotRef.current = new uPlot(
{
width,
height,
padding: [-10, 20, -10, 20],
cursor: {
show: true,
x: true,
y: true,
drag: {
x: true,
y: false,
setScale: true,
},
sync: { key: "myCursor" },
},
legend: {
show: config.showLegend ?? allSeries.length > 1,
},
hooks: {
setScale: [
(u) => {
if (handlerRefs.isUserZoomingRef.current) {
const xScale = u.scales.x;
if (xScale.min !== undefined && xScale.max !== undefined) {
// Update Y axis based on all visible series
const allVisibleValues: number[] = [];
allSeries.forEach(({ series }) => {
const [seriesTimestamps, seriesValues] = seriesToUPlotData(
series.long,
);
for (let i = 0; i < seriesTimestamps.length; i++) {
if (
seriesTimestamps[i] >= xScale.min! &&
seriesTimestamps[i] <= xScale.max!
) {
allVisibleValues.push(seriesValues[i]);
}
}
});
if (allVisibleValues.length > 0) {
const minY = Math.min(...allVisibleValues);
const maxY = Math.max(...allVisibleValues);
const range = maxY - minY || Math.abs(maxY) * 0.1 || 1;
u.setScale("y", {
min: minY - range * 0.1,
max: maxY + range * 0.1,
});
}
manualScaleRef.current = {
x: { min: xScale.min ?? 0, max: xScale.max ?? 0 },
y: {
min: u.scales.y?.min ?? 0,
max: u.scales.y?.max ?? 1,
},
};
setViewMode("manual");
setIsLiveMode(false);
if (syncGraph?.onZoomChange) {
syncGraph.onZoomChange(graphId, {
min: xScale.min ?? 0,
max: xScale.max ?? 0,
});
}
if (syncGraph?.onViewModeChange) {
syncGraph.onViewModeChange(graphId, "manual", false);
}
}
handlerRefs.isUserZoomingRef.current = false;
}
},
],
setCursor: [
(u) => {
if (
typeof u.cursor.idx === "number" &&
u.data[1] &&
u.data[1][u.cursor.idx] !== undefined
) {
const timestamp = u.data[0][u.cursor.idx];
const value = u.data[1][u.cursor.idx]; // Use first series for cursor value
const cur = allSeries[0]?.series?.current;
const isNearCurrent =
cur &&
timestamp !== undefined &&
Math.abs(timestamp - cur.timestamp) < 1000;
const displayValue = isNearCurrent ? cur.value : value;
setCursorValue(displayValue ?? null);
} else {
setCursorValue(null);
}
},
],
},
scales: {
x: {
time: true,
auto: true,
min: initialMin,
max: initialMax,
distr: 1,
},
y: {
auto: false,
min: initialYMin,
max: initialYMax,
},
},
axes: [
{
stroke: colors.axis,
labelSize: 12,
labelFont: "Inter, system-ui, sans-serif",
grid: { stroke: colors.grid, width: 1 },
space: 60,
values: (u, ticks) => {
const xScale = u.scales.x;
if (
!xScale ||
xScale.min === undefined ||
xScale.max === undefined
) {
return ticks.map((ts) => {
const date = new Date(ts);
return date.toLocaleTimeString(undefined, {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
});
}
const timeRange = xScale.max - xScale.min;
return ticks.map((ts) => {
const date = new Date(ts);
if (timeRange <= 30 * 1000) {
return date.toLocaleTimeString(undefined, {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} else if (timeRange <= 5 * 60 * 1000) {
return date.toLocaleTimeString(undefined, {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} else if (timeRange <= 60 * 60 * 1000) {
return date.toLocaleTimeString(undefined, {
hour12: false,
hour: "2-digit",
minute: "2-digit",
});
} else if (timeRange <= 24 * 60 * 60 * 1000) {
return date.toLocaleTimeString(undefined, {
hour12: false,
hour: "2-digit",
minute: "2-digit",
});
} else {
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
});
},
splits: (_u, _axisIdx, scaleMin, scaleMax) => {
const timeRange = scaleMax - scaleMin;
const ticks: number[] = [];
if (timeRange <= 10 * 1000) {
const startTime = Math.ceil(scaleMin / 1000) * 1000;
for (let t = startTime; t <= scaleMax; t += 1000) {
if (t >= scaleMin) ticks.push(t);
}
} else if (timeRange <= 30 * 1000) {
const startTime = Math.ceil(scaleMin / 5000) * 5000;
for (let t = startTime; t <= scaleMax; t += 5000) {
if (t >= scaleMin) ticks.push(t);
}
} else if (timeRange <= 60 * 1000) {
const startTime = Math.ceil(scaleMin / 10000) * 10000;
for (let t = startTime; t <= scaleMax; t += 10000) {
if (t >= scaleMin) ticks.push(t);
}
} else if (timeRange <= 5 * 60 * 1000) {
const startTime = Math.ceil(scaleMin / 30000) * 30000;
for (let t = startTime; t <= scaleMax; t += 30000) {
if (t >= scaleMin) ticks.push(t);
}
} else if (timeRange <= 10 * 60 * 1000) {
const startTime = Math.ceil(scaleMin / 60000) * 60000;
for (let t = startTime; t <= scaleMax; t += 60000) {
if (t >= scaleMin) ticks.push(t);
}
} else if (timeRange <= 30 * 60 * 1000) {
const startTime = Math.ceil(scaleMin / 120000) * 120000;
for (let t = startTime; t <= scaleMax; t += 120000) {
if (t >= scaleMin) ticks.push(t);
}
} else if (timeRange <= 60 * 60 * 1000) {
const startTime = Math.ceil(scaleMin / 300000) * 300000;
for (let t = startTime; t <= scaleMax; t += 300000) {
if (t >= scaleMin) ticks.push(t);
}
} else if (timeRange <= 6 * 60 * 60 * 1000) {
const startTime = Math.ceil(scaleMin / 1800000) * 1800000;
for (let t = startTime; t <= scaleMax; t += 1800000) {
if (t >= scaleMin) ticks.push(t);
}
} else {
const startTime = Math.ceil(scaleMin / 3600000) * 3600000;
for (let t = startTime; t <= scaleMax; t += 3600000) {
if (t >= scaleMin) ticks.push(t);
}
}
return ticks;
},
},
{
stroke: colors.axis,
labelSize: 12,
labelFont: "Inter, system-ui, sans-serif",
grid: { stroke: colors.grid, width: 1 },
side: 1,
space: 60,
values: (_u, ticks) => {
if (renderValue) {
const renderedValues = ticks.map(renderValue);
const uniqueValues = new Set(renderedValues);
if (uniqueValues.size === renderedValues.length) {
return renderedValues;
}
}
const precision = 0;
const maxPrecision = 4;
for (let p = precision; p <= maxPrecision; p++) {
const formattedValues = ticks.map((v) => v.toFixed(p));
const uniqueFormatted = new Set(formattedValues);
if (uniqueFormatted.size === formattedValues.length) {
return formattedValues;
}
}
return ticks.map((v) => v.toFixed(maxPrecision));
},
},
],
series: seriesConfig,
},
uPlotData,
containerRef.current,
);
// Create handler callbacks
const handlerCallbacks: HandlerCallbacks = {
updateYAxisScale,
setViewMode,
setIsLiveMode,
onZoomChange: syncGraph?.onZoomChange,
onViewModeChange: syncGraph?.onViewModeChange,
};
// Create and attach event handlers
const handlers = createEventHandlers(
containerRef,
uplotRef,
handlerRefs,
handlerCallbacks,
newData,
config,
graphId,
manualScaleRef,
width,
);
const cleanup = attachEventHandlers(containerRef.current, handlers);
animationRefs.lastRenderedData.current = {
timestamps: [...timestamps],
values: [...primaryValues],
};
return cleanup;
}
@@ -0,0 +1,453 @@
import * as XLSX from "xlsx";
import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries";
import { renderUnitSymbol, Unit } from "@/control/units";
import { GraphConfig, BigGraphProps, SeriesData } from "./types";
export type GraphExportData = {
config: GraphConfig;
data: BigGraphProps["newData"];
unit?: Unit;
renderValue?: (value: number) => string;
};
// Helper functions for multi-series support
function normalizeDataSeries(data: BigGraphProps["newData"]): SeriesData[] {
if (Array.isArray(data)) {
return data;
}
return [data];
}
function getAllValidSeries(data: BigGraphProps["newData"]): Array<{
series: TimeSeries;
title?: string;
color?: string;
index: number;
}> {
const normalized = normalizeDataSeries(data);
return normalized
.filter((series) => series.newData !== null)
.map((series, index) => ({
series: series.newData!,
title: series.title,
color: series.color,
index,
}));
}
export function exportGraphsToExcel(
graphDataMap: Map<string, () => GraphExportData | null>,
groupId: string,
): void {
try {
const workbook = XLSX.utils.book_new();
const exportTimestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, 19);
// Keep track of used sheet names to ensure uniqueness
const usedSheetNames = new Set<string>();
let totalSeriesCount = 0;
let hasValidData = false;
const allSeriesData: Array<{
graphTitle: string;
seriesTitle: string;
series: TimeSeries;
unit?: Unit;
renderValue?: (value: number) => string;
}> = [];
// First pass: collect all series data and count
graphDataMap.forEach((getDataFn) => {
const exportData = getDataFn();
if (exportData?.data) {
const validSeries = getAllValidSeries(exportData.data);
totalSeriesCount += validSeries.length;
if (validSeries.length > 0) {
hasValidData = true;
validSeries.forEach((seriesInfo) => {
allSeriesData.push({
graphTitle: exportData.config.title,
seriesTitle: seriesInfo.title || `Series ${seriesInfo.index + 1}`,
series: seriesInfo.series,
unit: exportData.unit,
renderValue: exportData.renderValue,
});
});
}
}
});
if (!hasValidData) {
alert("No data available to export from any graphs in this group");
return;
}
// 1. Create summary sheet
const summaryData = createSummarySheet(graphDataMap, totalSeriesCount);
const summaryWorksheet = XLSX.utils.aoa_to_sheet(summaryData);
XLSX.utils.book_append_sheet(workbook, summaryWorksheet, "Summary");
usedSheetNames.add("Summary");
// 2. Create collected values sheet (all series combined)
const collectedData = createCollectedValuesSheet(allSeriesData);
const collectedWorksheet = XLSX.utils.json_to_sheet(collectedData);
XLSX.utils.book_append_sheet(workbook, collectedWorksheet, "All Values");
usedSheetNames.add("All Values");
// 3. Export individual series (data + stats sheets for each)
exportSeriesWithStats(workbook, graphDataMap, usedSheetNames);
const filename = `${groupId.toLowerCase().replace(/\s+/g, "_")}_export_${exportTimestamp}.xlsx`;
XLSX.writeFile(workbook, filename);
} catch (error) {
alert(
`Error exporting data to Excel: ${error instanceof Error ? error.message : "Unknown error"}. Please try again.`,
);
}
}
function createSummarySheet(
graphDataMap: Map<string, () => GraphExportData | null>,
totalSeriesCount: number,
): any[][] {
const summaryData = [
[`Export Summary`, ""],
["Export Date", new Date()],
["Total Graphs", graphDataMap.size.toString()],
["Total Series", totalSeriesCount.toString()],
["", ""],
["Graphs and Series:", ""],
];
graphDataMap.forEach((getDataFn) => {
const exportData = getDataFn();
if (exportData?.data) {
const validSeries = getAllValidSeries(exportData.data);
if (validSeries.length > 0) {
summaryData.push([
`Graph: ${exportData.config.title}`,
`${validSeries.length} series`,
]);
validSeries.forEach((seriesInfo) => {
const seriesTitle =
seriesInfo.title || `Series ${seriesInfo.index + 1}`;
const [timestamps] = seriesToUPlotData(seriesInfo.series.long);
summaryData.push([
` - ${seriesTitle}`,
`${timestamps.length} data points`,
]);
});
summaryData.push(["", ""]);
}
}
});
summaryData.push(["", ""], ["Sheet Structure:", ""]);
summaryData.push(["Summary", "This overview sheet"]);
summaryData.push(["All Values", "Combined data from all series"]);
summaryData.push(["[Series Name]", "Individual series data"]);
summaryData.push(["[Series Name] Stats", "Statistics for each series"]);
return summaryData;
}
function createCollectedValuesSheet(
allSeriesData: Array<{
graphTitle: string;
seriesTitle: string;
series: TimeSeries;
unit?: Unit;
renderValue?: (value: number) => string;
}>,
): any[] {
if (allSeriesData.length === 0) return [];
// Get all unique timestamps from all series
const allTimestampsSet = new Set<number>();
const seriesDataMap = new Map<string, Map<number, number>>();
allSeriesData.forEach((seriesData) => {
const [timestamps, values] = seriesToUPlotData(seriesData.series.long);
const valueMap = new Map<number, number>();
timestamps.forEach((timestamp, index) => {
allTimestampsSet.add(timestamp);
valueMap.set(timestamp, values[index]);
});
const seriesKey = `${seriesData.graphTitle} - ${seriesData.seriesTitle}`;
seriesDataMap.set(seriesKey, valueMap);
});
// Sort all timestamps
const allTimestamps = Array.from(allTimestampsSet).sort((a, b) => a - b);
// Create rows with all series data
return allTimestamps.map((timestamp) => {
const row: any = {
Timestamp: new Date(timestamp),
};
// Add data for each series
allSeriesData.forEach((seriesData) => {
const seriesKey = `${seriesData.graphTitle} - ${seriesData.seriesTitle}`;
const valueMap = seriesDataMap.get(seriesKey);
const value = valueMap?.get(timestamp);
const unitSymbol = renderUnitSymbol(seriesData.unit) || "";
const columnName = `${seriesKey} (${unitSymbol})`;
if (value !== undefined) {
row[columnName] = seriesData.renderValue
? seriesData.renderValue(value)
: value?.toFixed(3) || "";
} else {
row[columnName] = ""; // Empty cell for missing data points
}
});
return row;
});
}
function exportSeriesWithStats(
workbook: XLSX.WorkBook,
graphDataMap: Map<string, () => GraphExportData | null>,
usedSheetNames: Set<string>,
): void {
graphDataMap.forEach((getDataFn) => {
const exportData = getDataFn();
if (!exportData?.data) return;
const validSeries = getAllValidSeries(exportData.data);
if (validSeries.length === 0) return;
// Create data and stats sheets for each series
validSeries.forEach((seriesInfo) => {
const seriesTitle = seriesInfo.title || `Series ${seriesInfo.index + 1}`;
// 1. Create data sheet for this series
const seriesDataRows = createSingleSeriesDataRows(seriesInfo, exportData);
if (seriesDataRows.length > 0) {
const dataWorksheet = XLSX.utils.json_to_sheet(seriesDataRows);
const dataSheetName = generateUniqueSheetName(
seriesTitle,
usedSheetNames,
);
XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName);
}
// 2. Create stats sheet for this series
const statsData = createSeriesStatsSheet(seriesInfo, exportData);
const statsWorksheet = XLSX.utils.aoa_to_sheet(statsData);
const statsSheetName = generateUniqueSheetName(
`${seriesTitle} Stats`,
usedSheetNames,
);
XLSX.utils.book_append_sheet(workbook, statsWorksheet, statsSheetName);
});
});
}
function createSingleSeriesDataRows(
seriesInfo: {
series: TimeSeries;
title?: string;
color?: string;
index: number;
},
exportData: GraphExportData,
): any[] {
const [timestamps, values] = seriesToUPlotData(seriesInfo.series.long);
if (timestamps.length === 0) return [];
const unitSymbol = renderUnitSymbol(exportData.unit) || "";
return timestamps.map((timestamp, index) => {
const value = values[index];
const row: any = {
Timestamp: new Date(timestamp),
[`Value (${unitSymbol})`]: exportData.renderValue
? exportData.renderValue(value)
: value?.toFixed(3) || "",
};
// Add config lines for reference
exportData.config.lines?.forEach((line) => {
const lineColumnName = `${line.label} (${unitSymbol})`;
row[lineColumnName] = exportData.renderValue
? exportData.renderValue(line.value)
: line.value.toFixed(3);
// Add threshold analysis
if (line.type === "threshold" && value !== undefined) {
const withinThreshold =
Math.abs(value - line.value) <= line.value * 0.05;
row[`Within ${line.label}`] = withinThreshold ? "Yes" : "No";
row[`Difference from ${line.label}`] = exportData.renderValue
? exportData.renderValue(value - line.value)
: (value - line.value).toFixed(3);
}
});
return row;
});
}
function createSeriesStatsSheet(
seriesInfo: {
series: TimeSeries;
title?: string;
color?: string;
index: number;
},
exportData: GraphExportData,
): any[][] {
const seriesTitle = seriesInfo.title || `Series ${seriesInfo.index + 1}`;
const [timestamps, values] = seriesToUPlotData(seriesInfo.series.long);
const unitSymbol = renderUnitSymbol(exportData.unit) || "";
const statsData = [
[`Statistics: ${seriesTitle}`, ""],
["Generated", new Date()],
["", ""],
["Basic Information", ""],
["Total Data Points", timestamps.length.toString()],
];
if (timestamps.length > 0) {
statsData.push(["Time Range Start", new Date(timestamps[0])]);
statsData.push([
"Time Range End",
new Date(timestamps[timestamps.length - 1]),
]);
const duration = timestamps[timestamps.length - 1] - timestamps[0];
const durationHours = (duration / (1000 * 60 * 60)).toFixed(2);
statsData.push(["Duration (hours)", durationHours]);
if (values.length > 0) {
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const avgValue = values.reduce((a, b) => a + b, 0) / values.length;
const stdDev = Math.sqrt(
values.reduce((sum, val) => sum + Math.pow(val - avgValue, 2), 0) /
values.length,
);
statsData.push(["", ""], ["Statistical Analysis", ""]);
statsData.push([
`Minimum Value (${unitSymbol})`,
exportData.renderValue
? exportData.renderValue(minValue)
: minValue.toFixed(3),
]);
statsData.push([
`Maximum Value (${unitSymbol})`,
exportData.renderValue
? exportData.renderValue(maxValue)
: maxValue.toFixed(3),
]);
statsData.push([
`Average Value (${unitSymbol})`,
exportData.renderValue
? exportData.renderValue(avgValue)
: avgValue.toFixed(3),
]);
statsData.push([
`Standard Deviation (${unitSymbol})`,
exportData.renderValue
? exportData.renderValue(stdDev)
: stdDev.toFixed(3),
]);
statsData.push([
`Range (${unitSymbol})`,
exportData.renderValue
? exportData.renderValue(maxValue - minValue)
: (maxValue - minValue).toFixed(3),
]);
// Percentiles
const sortedValues = [...values].sort((a, b) => a - b);
const p25 = sortedValues[Math.floor(sortedValues.length * 0.25)];
const p50 = sortedValues[Math.floor(sortedValues.length * 0.5)];
const p75 = sortedValues[Math.floor(sortedValues.length * 0.75)];
statsData.push(["", ""], ["Percentiles", ""]);
statsData.push([
`25th Percentile (${unitSymbol})`,
exportData.renderValue ? exportData.renderValue(p25) : p25.toFixed(3),
]);
statsData.push([
`50th Percentile/Median (${unitSymbol})`,
exportData.renderValue ? exportData.renderValue(p50) : p50.toFixed(3),
]);
statsData.push([
`75th Percentile (${unitSymbol})`,
exportData.renderValue ? exportData.renderValue(p75) : p75.toFixed(3),
]);
}
}
// Add config lines analysis
if (exportData.config.lines && exportData.config.lines.length > 0) {
statsData.push(["", ""], ["Threshold Analysis", ""]);
exportData.config.lines.forEach((line) => {
if (line.type === "threshold" && values.length > 0) {
const withinThreshold = values.filter(
(val) => Math.abs(val - line.value) <= line.value * 0.05,
).length;
const percentageWithin = (
(withinThreshold / values.length) *
100
).toFixed(1);
statsData.push([
`${line.label} Threshold (${unitSymbol})`,
exportData.renderValue
? exportData.renderValue(line.value)
: line.value.toFixed(3),
]);
statsData.push([
`Points Within ${line.label}`,
`${withinThreshold} (${percentageWithin}%)`,
]);
}
});
}
return statsData;
}
function generateUniqueSheetName(
name: string,
usedSheetNames: Set<string>,
): string {
// Clean the name for use as sheet name
let baseSheetName = name
.replace(/[\\/?*$:[\]]/g, "_") // Remove invalid characters
.substring(0, 31); // Excel sheet name limit
// Ensure we have a valid name
if (!baseSheetName || baseSheetName.trim().length === 0) {
baseSheetName = "Sheet";
}
// Make it unique
let sheetName = baseSheetName;
let counter = 1;
while (usedSheetNames.has(sheetName)) {
const suffix = `_${counter}`;
const maxBaseLength = 31 - suffix.length;
sheetName = `${baseSheetName.substring(0, maxBaseLength)}${suffix}`;
counter++;
}
usedSheetNames.add(sheetName);
return sheetName;
}
+308
View File
@@ -0,0 +1,308 @@
import { RefObject } from "react";
import uPlot from "uplot";
import { seriesToUPlotData } from "@/lib/timeseries";
import { GraphConfig } from "./types";
export interface HandlerRefs {
isUserZoomingRef: RefObject<boolean>;
isDraggingRef: RefObject<boolean>;
lastDragXRef: RefObject<number | null>;
isPinchingRef: RefObject<boolean>;
lastPinchDistanceRef: RefObject<number | null>;
pinchCenterRef: RefObject<{ x: number; y: number } | null>;
touchStartRef: RefObject<{
x: number;
y: number;
time: number;
} | null>;
touchDirectionRef: RefObject<"horizontal" | "vertical" | "unknown">;
}
export interface HandlerCallbacks {
updateYAxisScale: (
timestamps: number[],
values: number[],
xMin?: number,
xMax?: number,
) => void;
setViewMode: (mode: "default" | "all" | "manual") => void;
setIsLiveMode: (isLive: boolean) => void;
onZoomChange?: (graphId: string, range: { min: number; max: number }) => void;
onViewModeChange?: (
graphId: string,
mode: "default" | "all" | "manual",
isLive: boolean,
) => void;
}
export function createEventHandlers(
containerRef: RefObject<HTMLDivElement | null>,
uplotRef: RefObject<uPlot | null>,
handlerRefs: HandlerRefs,
callbacks: HandlerCallbacks,
newData: any,
config: GraphConfig,
graphId: string,
manualScaleRef: RefObject<{
x: { min: number; max: number };
y: { min: number; max: number };
} | null>,
width: number,
) {
const handleTouchStart = (e: TouchEvent) => {
const touch = e.touches[0];
handlerRefs.touchStartRef.current = {
x: touch.clientX,
y: touch.clientY,
time: Date.now(),
};
handlerRefs.touchDirectionRef.current = "unknown";
if (e.touches.length === 2) {
handlerRefs.isPinchingRef.current = true;
handlerRefs.isDraggingRef.current = false;
handlerRefs.touchDirectionRef.current = "horizontal";
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2),
);
handlerRefs.lastPinchDistanceRef.current = distance;
handlerRefs.pinchCenterRef.current = {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2,
};
e.preventDefault();
}
};
const handleTouchMove = (e: TouchEvent) => {
if (!handlerRefs.touchStartRef.current) return;
if (e.touches.length === 1) {
const touch = e.touches[0];
const deltaX = Math.abs(
touch.clientX - handlerRefs.touchStartRef.current.x,
);
const deltaY = Math.abs(
touch.clientY - handlerRefs.touchStartRef.current.y,
);
const timeDelta = Date.now() - handlerRefs.touchStartRef.current.time;
if (
handlerRefs.touchDirectionRef.current === "unknown" &&
deltaX > 20 &&
deltaY < 10 &&
deltaX > deltaY * 4 &&
timeDelta < 500
) {
handlerRefs.touchDirectionRef.current = "horizontal";
handlerRefs.isDraggingRef.current = true;
handlerRefs.lastDragXRef.current = touch.clientX;
e.preventDefault();
} else if (handlerRefs.touchDirectionRef.current === "unknown") {
return;
}
if (
handlerRefs.touchDirectionRef.current === "horizontal" &&
handlerRefs.isDraggingRef.current
) {
e.preventDefault();
const currentX = touch.clientX;
const dragDelta = currentX - (handlerRefs.lastDragXRef.current || 0);
handlerRefs.lastDragXRef.current = currentX;
if (uplotRef.current && Math.abs(dragDelta) > 2) {
const xScale = uplotRef.current.scales.x;
if (xScale && xScale.min !== undefined && xScale.max !== undefined) {
const pixelToTime = (xScale.max - xScale.min) / width;
const timeDelta = -dragDelta * pixelToTime;
const newMin = xScale.min + timeDelta;
const newMax = xScale.max + timeDelta;
uplotRef.current.setScale("x", { min: newMin, max: newMax });
const [timestamps, values] = seriesToUPlotData(newData.long);
callbacks.updateYAxisScale(timestamps, values, newMin, newMax);
manualScaleRef.current = {
x: { min: newMin, max: newMax },
y: {
min: uplotRef.current.scales.y?.min ?? 0,
max: uplotRef.current.scales.y?.max ?? 1,
},
};
callbacks.setViewMode("manual");
callbacks.setIsLiveMode(false);
if (callbacks.onZoomChange) {
callbacks.onZoomChange(graphId, {
min: newMin,
max: newMax,
});
}
if (callbacks.onViewModeChange) {
callbacks.onViewModeChange(graphId, "manual", false);
}
}
}
}
} else if (e.touches.length === 2 && handlerRefs.isPinchingRef.current) {
e.preventDefault();
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const newDistance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2),
);
if (handlerRefs.lastPinchDistanceRef.current && uplotRef.current) {
const scaleFactor =
newDistance / handlerRefs.lastPinchDistanceRef.current;
const xScale = uplotRef.current.scales.x;
if (
xScale &&
xScale.min !== undefined &&
xScale.max !== undefined &&
handlerRefs.pinchCenterRef.current
) {
const rect = containerRef.current?.getBoundingClientRect();
if (rect) {
const touchXRelative =
(handlerRefs.pinchCenterRef.current.x - rect.left) / rect.width;
const centerTime =
xScale.min + (xScale.max - xScale.min) * touchXRelative;
const currentRange = xScale.max - xScale.min;
const newRange = currentRange / scaleFactor;
const leftRatio = (centerTime - xScale.min) / currentRange;
const rightRatio = (xScale.max - centerTime) / currentRange;
const newMin = centerTime - newRange * leftRatio;
const newMax = centerTime + newRange * rightRatio;
uplotRef.current.setScale("x", { min: newMin, max: newMax });
const [timestamps, values] = seriesToUPlotData(newData.long);
callbacks.updateYAxisScale(timestamps, values, newMin, newMax);
manualScaleRef.current = {
x: { min: newMin, max: newMax },
y: {
min: uplotRef.current.scales.y?.min ?? 0,
max: uplotRef.current.scales.y?.max ?? 1,
},
};
callbacks.setViewMode("manual");
callbacks.setIsLiveMode(false);
if (callbacks.onZoomChange) {
callbacks.onZoomChange(graphId, {
min: newMin,
max: newMax,
});
}
if (callbacks.onViewModeChange) {
callbacks.onViewModeChange(graphId, "manual", false);
}
}
}
}
handlerRefs.lastPinchDistanceRef.current = newDistance;
}
};
const handleTouchEnd = (e: TouchEvent) => {
if (e.touches.length === 0) {
handlerRefs.isDraggingRef.current = false;
handlerRefs.isPinchingRef.current = false;
handlerRefs.lastDragXRef.current = null;
handlerRefs.lastPinchDistanceRef.current = null;
handlerRefs.pinchCenterRef.current = null;
handlerRefs.touchStartRef.current = null;
handlerRefs.touchDirectionRef.current = "unknown";
} else if (e.touches.length === 1 && handlerRefs.isPinchingRef.current) {
handlerRefs.isPinchingRef.current = false;
handlerRefs.lastPinchDistanceRef.current = null;
handlerRefs.pinchCenterRef.current = null;
const touch = e.touches[0];
handlerRefs.touchStartRef.current = {
x: touch.clientX,
y: touch.clientY,
time: Date.now(),
};
handlerRefs.touchDirectionRef.current = "unknown";
handlerRefs.isDraggingRef.current = false;
}
if (
handlerRefs.touchDirectionRef.current === "horizontal" &&
handlerRefs.isDraggingRef.current
) {
e.preventDefault();
}
};
const handleMouseDown = (e: MouseEvent) => {
if (e.button === 0) {
handlerRefs.isUserZoomingRef.current = true;
}
};
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
};
return {
handleTouchStart,
handleTouchMove,
handleTouchEnd,
handleMouseDown,
handleWheel,
};
}
export function attachEventHandlers(
containerElement: HTMLDivElement,
handlers: ReturnType<typeof createEventHandlers>,
) {
containerElement.addEventListener("touchstart", handlers.handleTouchStart, {
passive: false,
});
containerElement.addEventListener("touchmove", handlers.handleTouchMove, {
passive: false,
});
containerElement.addEventListener("touchend", handlers.handleTouchEnd, {
passive: false,
});
containerElement.addEventListener("mousedown", handlers.handleMouseDown);
containerElement.addEventListener("wheel", handlers.handleWheel, {
passive: false,
});
return () => {
containerElement.removeEventListener(
"touchstart",
handlers.handleTouchStart,
);
containerElement.removeEventListener("touchmove", handlers.handleTouchMove);
containerElement.removeEventListener("touchend", handlers.handleTouchEnd);
containerElement.removeEventListener("mousedown", handlers.handleMouseDown);
containerElement.removeEventListener("wheel", handlers.handleWheel);
};
}
Loaded 100 of 300 files, more files were not shown because too many files have changed in this diff. Show more