Compare commits

..
Author SHA1 Message Date
snacj 97ec252a8d rebase and merge issue fixes 2025-10-15 15:17:45 +02:00
snacj c5d96922a3 Fixed Formatting 2025-10-15 14:59:03 +02:00
snacj 468dc921be Fixed Rebase Issues and removed unused imports 2025-10-15 14:58:45 +02:00
snacj 858a145d6f Fixed Formatting 2025-10-15 14:58:45 +02:00
snacj 9b0100f894 Fixed target speed communication between buffer and winder. 2025-10-15 14:58:45 +02:00
snacj 52b7da434b Fixed merge issue 2025-10-15 14:58:29 +02:00
snacj 51d57bc977 Finished Homing Routine 2025-10-15 14:58:29 +02:00
snacj a13292e568 Removed unused top and bottom limits 2025-10-15 14:58:29 +02:00
snacj 7b96398221 Removed "Traverse Bar" 2025-10-15 14:58:29 +02:00
snacj 6b139a886c Fixed mode 2025-10-15 14:58:29 +02:00
snacj 558f769c08 Fixed step converter errors 2025-10-15 14:58:29 +02:00
snacj a43114319e Added Capability checks for moving 2025-10-15 14:58:29 +02:00
snacj f0a2b40bf9 Fixed Comments 2025-10-15 14:58:13 +02:00
snacj 87363375cf Fixed State 2025-10-15 14:58:13 +02:00
snacj 59eb1693cb Added Control Functions 2025-10-15 14:58:13 +02:00
snacj 0b5e2d25c9 Adjusted Values and restructured controls 2025-10-15 14:58:13 +02:00
snacj 1f900a5984 Added Lift State frontend 2025-10-15 14:58:13 +02:00
snacj bace6ab78f Added Lift Control functions 2025-10-15 14:58:13 +02:00
snacj 87eaf2616e Added Lift State and Mutations 2025-10-15 14:57:48 +02:00
snacj 97fa5e6543 Added Lift State and Mutations 2025-10-15 14:57:48 +02:00
snacj aec5018029 Fixed update speed function 2025-10-15 14:57:48 +02:00
snacj 18063f3dcd Added Homing Routine Functions 2025-10-15 14:57:48 +02:00
snacj 51338ce622 Fixed Merge Conflicts 2025-10-15 14:57:48 +02:00
snacj 3484363bfa Resolved merge conflicts 2025-10-15 14:57:22 +02:00
snacj aafe78d6fd Implemented Buffer state for Winder 2025-10-15 14:56:29 +02:00
snacj 8c0f3256ad Updated End Switch Logic 2025-10-15 14:55:48 +02:00
snacj 3b15517fe0 Fixed Formatting 2025-10-15 14:55:48 +02:00
snacj 202c9d12db Added Digital Input switch for end Switch (Top) at Buffer 2025-10-15 14:55:48 +02:00
snacj 74693e80c4 Changed Formatting and Removed unused imports 2025-10-15 14:55:09 +02:00
snacj 9ceeeb82b7 Changed Mode names for Buffer + Added Hold Mode 2025-10-15 14:54:54 +02:00
snacj 05a4909914 Added Comment 2025-10-15 14:54:54 +02:00
snacj 348576a21c Update sync of puller speed and buffer input speed & Added description to helper function 2025-10-15 14:54:54 +02:00
snacj d37760bcd1 Added settings page to buffer route 2025-10-15 14:54:54 +02:00
snacj e73f6508e2 Added helper function to get Connected Machine reference. --Needs testing 2025-10-15 14:54:54 +02:00
snacj 08994de8bf Added Puller frontend functions, hooks and Controlpage for Buffer 2025-10-15 14:54:14 +02:00
snacj 17024717bf Implemented Puller functions in Buffer Mod 2025-10-15 14:54:14 +02:00
snacj 1019398b34 Added pullerSpeed State Event 2025-10-15 14:53:56 +02:00
snacj 2de135f6bd Renamed Function 2025-10-15 14:53:56 +02:00
snacj 9c55b07634 Added call to winder on buffering 2025-10-15 14:53:56 +02:00
snacj f9c80096a9 Fixed Formatting 2025-10-15 14:53:18 +02:00
snacj 1907f284f4 Refactored to clean Act function in Winder2 2025-10-15 14:53:18 +02:00
snacj 51e3f1b597 Changed updating speed logic to use JerkSpeedController 2025-10-15 14:51:56 +02:00
snacj 22f3b226d9 Implemented Buffer Business Logic 2025-10-15 14:45:53 +02:00
snacj 2cd7626197 Updated Act function to implement puller sync and buffer input speed 2025-10-15 14:43:56 +02:00
snacj 1d8ba63577 Added Puller Speed Controller to Buffer 2025-10-15 14:43:56 +02:00
snacj 36f0844f82 Added start of business logic for buffer lift 2025-10-15 14:43:56 +02:00
snacj f64a8e6646 Added current_input_speed state to test the calculation of buffer_lift_speed 2025-10-15 14:41:13 +02:00
snacj 4ae448b792 Changed name buffer_tower_controller -> buffer-lift_controller 2025-10-15 14:40:55 +02:00
Robin Krämer 9cc63bf8fb Merge pull request #805 from qitechgmbh/743-electron-doesnt-use-gpu-for-hw-acceleration
Remove bad hardware acceleration flag
2025-10-15 10:31:04 +02:00
Oshgnacknak 70ce7625b2 Remove bad hardware acceleration flag 2025-10-13 15:00:21 +02:00
Robin Krämer 9936764125 Merge pull request #717 from qitechgmbh/700-add-a-show-from-option-for-graphs
700 add a show from option for graphs
2025-10-13 12:17:26 +02:00
Christian a0f09e1671 Improve formatting and adjust future time validation messages in TimeInput component 2025-10-13 12:14:09 +02:00
Christian 1100b880f9 Add validation to prevent future time selection in TimeInput component 2025-10-13 12:14:09 +02:00
Christian 6224f33ba5 fix eslint 2025-10-13 12:14:09 +02:00
Christian b0a9b5a6d2 Fix styling 2025-10-13 12:14:09 +02:00
ChristianandCopilot ac7247c87a Update electron/src/components/time/TimeInput.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-13 12:14:09 +02:00
Christian 2ea459f3be Refactor button styles and improve formatting in GraphControls and TimeInput components 2025-10-13 12:14:09 +02:00
Christian 25250f640f Add showFromTimestamp functionality to graph components and implement TimeInput for time selection 2025-10-13 12:14:09 +02:00
Robin Krämer 57a0cc8af4 Merge pull request #776 from qitechgmbh/775-add-explanation-of-padding-to-the-winder-manual
Add padding parameter explanation to manual for filament winding
2025-10-13 12:13:53 +02:00
Christian 5ebb978eae Add padding example image to the winder docs 2025-10-13 12:09:20 +02:00
Christian d647ee6ebe fix format 2025-10-13 12:09:20 +02:00
Christian fea203ae7a Add padding parameter explanation to manual for filament winding 2025-10-13 12:09:20 +02:00
Robin Krämer b5eb413d37 Merge pull request #791 from qitechgmbh/788-fix-remote-setting-reset-and-update-label-in-update-tab
Fix remote setting reset and update label to "Repository Owner" in Update tab
2025-10-13 12:08:43 +02:00
Christian aa1e5c4f39 Fix typos in GitHub repository owner label and description 2025-10-13 12:05:31 +02:00
Christian ee91934bf7 Refactor Github source management: integrate condition store and update UI labels 2025-10-13 12:05:31 +02:00
Milan von dem Bussche 1fa04eae9a Update README.md
Added YouTube Explainer Video about QiTech Control
2025-10-10 18:41:46 +02:00
Robin Krämer 98e1269f6f Merge pull request #800 from qitechgmbh/changelog-2.9.1
updated Changelog
2025-10-10 15:07:51 +02:00
kraemr 8f640fcb21 updated Changelog 2025-10-10 15:06:30 +02:00
Robin Krämer 6c120b7fe9 Merge pull request #796 from qitechgmbh/795-winding-when-puller-reverse-doesnt-work-in-adaptive
adaptive spool speed now takes negative speeds into account,
2025-10-08 18:12:23 +02:00
kraemr 033b93b9da puller speed now is always positive in frontend 2025-10-08 17:49:55 +02:00
kraemr 6092358e87 adaptive spool speed now takes negative speeds into account,
aswell as Auto Stop Spool
2025-10-08 17:40:50 +02:00
Robin Krämer 71de39e871 Merge pull request #793 from qitechgmbh/790-refactor-aquapath-newrs-to-match-others
790 refactor aquapath newrs to match others
2025-10-08 16:29:49 +02:00
joe 54ffa193c3 fixed fmt 2025-10-08 16:23:40 +02:00
joe 03164f1ceb refactor(aquapath1): cleanup and fix device initialization and configuration 2025-10-08 15:45:58 +02:00
Robin Krämer f67a46ab4c Merge pull request #787 from qitechgmbh/786-add-memory-locking
added CAP_IPC_LOCK and added memory locking
2025-10-07 13:54:02 +02:00
kraemr 610790a778 added CAP_IPC_LOCK and memory locking 2025-10-07 13:04:49 +02:00
Robin Krämer 6eaf451ad4 Merge pull request #692 from JanHBade/master
Update README.md
2025-10-07 12:57:27 +02:00
JanHBade 022e0adae3 Update README.md
Minor typo in EtherCAT
2025-10-07 12:57:16 +02:00
Robin Krämer 6e6d4b31e7 Merge pull request #746 from qitechgmbh/640-improve-scroll-bar-styling-for-manuals-to-resemble-macos-style
Update scrollbar styling to be similar to macos
2025-10-07 12:45:12 +02:00
Christian 0dafad3fb5 fix code stye 2025-10-07 12:40:01 +02:00
Christian 534de55239 Refactor scrollbar styles to apply only to html and body elements 2025-10-07 12:40:01 +02:00
Christian d9f92bcdf2 Refactor scrollbar styles for Markdown and Terminal components to be similar to MacOS styling 2025-10-07 12:40:01 +02:00
Robin Krämer 1570299e38 Merge pull request #782 from qitechgmbh/781-refactor-winderv2-emit-functions-to-emitrs
refactored winder2's emit methods to emit.rs
2025-10-06 17:01:38 +02:00
kraemr c95f6f3bd9 refactored winder2's emit methods to emit.rs 2025-10-06 16:36:59 +02:00
Robin Krämer 9fcde2a234 Merge pull request #780 from qitechgmbh/772-remove-dependencies-that-are-not-necessary
decreased compile time by removing jemalloc, and using only f64 uom
2025-10-06 16:05:08 +02:00
kraemr a2ffc41e05 decreased compile time by removing jemalloc, and using only f64 uom 2025-10-06 16:02:29 +02:00
Robin Krämer ff13baf257 Merge pull request #779 from qitechgmbh/changelog-2.9.0
updated Changelog
2025-10-06 14:25:39 +02:00
kraemr 8f5df07c19 updated Changelog 2025-10-06 14:24:43 +02:00
Robin Krämer 299b38515a Merge pull request #778 from qitechgmbh/777-increase-max-step-size
Increased Winder Step Size
2025-10-06 14:12:24 +02:00
kraemr 2b073aeb76 Increased Winder Step Size 2025-10-06 13:56:20 +02:00
Robin Krämer 7a6f92f8a4 Merge pull request #771 from qitechgmbh/770-update-machine-implementation-status-in-the-readme
Update machine documentation to reflect current implementation status
2025-10-02 17:07:13 +02:00
Christian 2e283ea95f Update machine documentation to reflect current implementation status 2025-10-02 17:03:55 +02:00
Robin Krämer 829bb5ea1c Merge pull request #625 from qitechgmbh/improved-disconnect-handling
Introduce machine connection
2025-10-02 14:26:07 +02:00
Oshgnacknak 0be18aaed0 Derive machines from macro again
This time we also add get_machine_identification_unique.

Also fix import warnings
2025-10-02 13:58:54 +02:00
Oshgnacknak ffe0f09061 Clean up machine event creation 2025-10-01 16:50:04 +02:00
nixos-install.sh 730d61a18d Don't clear ethercat slots, we want to keep the namespaces 2025-10-01 15:57:03 +02:00
Oshgnacknak fc07525976 Don't remove, only disconnect serial machines
This allows use to keep the namespaces
2025-10-01 15:57:03 +02:00
Oshgnacknak c48953aa6b Introduce machine connection
This is to hold on to the namespace of
a machine even if the devices of that
machine has disconnected and to allow
reattaching to the namespace once the
devices reconnects.

Additionally to the new struct,
a wast refactor took place cleaning up
connections to machines, cross connections
between machines the way we downcast
machines downcasts.
2025-10-01 15:57:03 +02:00
Melvin Spieß 0c948b436d Merge pull request #764 from qitechgmbh/dependabot/cargo/axum-0.8.6
Bump axum from 0.8.4 to 0.8.6
2025-10-01 12:05:48 +02:00
Robin Krämer 1b3f92dd1c Merge pull request #725 from qitechgmbh/425-fix-terminal-component-issues-v2
425 fix terminal component issues v2
2025-10-01 11:58:55 +02:00
dependabot[bot] c29c549a1c Bump axum from 0.8.4 to 0.8.6
Bumps [axum](https://github.com/tokio-rs/axum) from 0.8.4 to 0.8.6.
- [Release notes](https://github.com/tokio-rs/axum/releases)
- [Changelog](https://github.com/tokio-rs/axum/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/axum/compare/axum-v0.8.4...axum-v0.8.6)

---
updated-dependencies:
- dependency-name: axum
  dependency-version: 0.8.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 09:57:52 +00:00
kraemr 17622cacde set log level to "info" for rust 2025-10-01 11:56:29 +02:00
kraemr 0b423454ec fixed line duplication in Terminal Component on Update Page 2025-10-01 11:56:29 +02:00
kraemr 81ec98f670 dont clear log, causes react errors 2025-10-01 11:56:29 +02:00
93094049+kraemr@users.noreply.github.com 1aed425544 eslint bypass for control character 2025-10-01 11:56:29 +02:00
93094049+kraemr@users.noreply.github.com da080ace68 try using clear functions before interactions 2025-10-01 11:56:29 +02:00
93094049+kraemr@users.noreply.github.com ded8dd2d2d added react-virtualized 2025-10-01 11:56:29 +02:00
93094049+kraemr@users.noreply.github.com edd83ba559 added virtualized list 2025-10-01 11:56:29 +02:00
Melvin Spieß 7d1d8559a5 Merge pull request #672 from qitechgmbh/671-refactor-machine-creation
reduced code duplication in new.rs of machines
2025-10-01 11:56:17 +02:00
kraemr f0bb9bfed1 fixed lifetimes of get_ethercat_device and refactored buffer 2025-10-01 11:52:13 +02:00
93094049+kraemr@users.noreply.github.com c6ea057736 renamed helper function 2025-10-01 11:52:13 +02:00
93094049+kraemr@users.noreply.github.com 90a00198a7 reduced code duplication in new.rs of machines 2025-10-01 11:52:12 +02:00
Melvin Spieß f8241547fe Merge pull request #762 from qitechgmbh/dependabot/cargo/erased-serde-0.4.8
Bump erased-serde from 0.4.6 to 0.4.8
2025-10-01 11:50:54 +02:00
Melvin Spieß 1cb5d51c13 Merge pull request #760 from qitechgmbh/dependabot/npm_and_yarn/electron/tanstack/react-router-1.130.17
Bump @tanstack/react-router from 1.130.12 to 1.130.17 in /electron
2025-10-01 11:38:53 +02:00
Melvin Spieß c42cdf8db1 Merge pull request #759 from qitechgmbh/dependabot/npm_and_yarn/electron/radix-ui/react-navigation-menu-1.2.14
Bump @radix-ui/react-navigation-menu from 1.2.13 to 1.2.14 in /electron
2025-10-01 11:38:22 +02:00
Melvin Spieß 69014ef8c6 Merge pull request #758 from qitechgmbh/dependabot/npm_and_yarn/electron/types/react-19.1.16
Bump @types/react from 19.1.9 to 19.1.16 in /electron
2025-10-01 11:35:53 +02:00
Melvin Spieß 5faab29a2b Merge pull request #763 from qitechgmbh/dependabot/cargo/libc-0.2.176
Bump libc from 0.2.175 to 0.2.176
2025-10-01 11:35:31 +02:00
dependabot[bot] 9a8b16ca69 Bump @radix-ui/react-navigation-menu from 1.2.13 to 1.2.14 in /electron
Bumps [@radix-ui/react-navigation-menu](https://github.com/radix-ui/primitives) from 1.2.13 to 1.2.14.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-navigation-menu"
  dependency-version: 1.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 09:32:49 +00:00
Melvin Spieß 53f3c66214 Merge pull request #765 from qitechgmbh/dependabot/cargo/regex-1.11.3
Bump regex from 1.11.2 to 1.11.3
2025-10-01 11:30:59 +02:00
dependabot[bot] 432bf0d6ba Bump libc from 0.2.175 to 0.2.176
Bumps [libc](https://github.com/rust-lang/libc) from 0.2.175 to 0.2.176.
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.176/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.175...0.2.176)

---
updated-dependencies:
- dependency-name: libc
  dependency-version: 0.2.176
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 09:30:49 +00:00
dependabot[bot] f3990f35f1 Bump erased-serde from 0.4.6 to 0.4.8
Bumps [erased-serde](https://github.com/dtolnay/erased-serde) from 0.4.6 to 0.4.8.
- [Release notes](https://github.com/dtolnay/erased-serde/releases)
- [Commits](https://github.com/dtolnay/erased-serde/compare/0.4.6...0.4.8)

---
updated-dependencies:
- dependency-name: erased-serde
  dependency-version: 0.4.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 09:30:40 +00:00
dependabot[bot] 6f17412363 Bump regex from 1.11.2 to 1.11.3
Bumps [regex](https://github.com/rust-lang/regex) from 1.11.2 to 1.11.3.
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.11.2...1.11.3)

---
updated-dependencies:
- dependency-name: regex
  dependency-version: 1.11.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 09:28:49 +00:00
dependabot[bot] 68907b9750 Bump @types/react from 19.1.9 to 19.1.16 in /electron
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 19.1.9 to 19.1.16.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.1.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 09:26:34 +00:00
dependabot[bot] e7007e1980 Bump @tanstack/react-router from 1.130.12 to 1.130.17 in /electron
Bumps [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) from 1.130.12 to 1.130.17.
- [Release notes](https://github.com/TanStack/router/releases)
- [Commits](https://github.com/TanStack/router/commits/v1.130.17/packages/react-router)

---
updated-dependencies:
- dependency-name: "@tanstack/react-router"
  dependency-version: 1.130.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 09:26:27 +00:00
Melvin Spieß 3bcd96d73c Merge pull request #766 from qitechgmbh/dependabot/cargo/anyhow-1.0.100
Bump anyhow from 1.0.99 to 1.0.100
2025-10-01 11:09:51 +02:00
dependabot[bot] 8a77c62a5e Bump anyhow from 1.0.99 to 1.0.100
Bumps [anyhow](https://github.com/dtolnay/anyhow) from 1.0.99 to 1.0.100.
- [Release notes](https://github.com/dtolnay/anyhow/releases)
- [Commits](https://github.com/dtolnay/anyhow/compare/1.0.99...1.0.100)

---
updated-dependencies:
- dependency-name: anyhow
  dependency-version: 1.0.100
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 02:24:32 +00:00
kraemr 93add82ab5 updated Changelog 2025-09-30 15:29:27 +02:00
Robin Krämer 690b1f7ab3 Merge pull request #757 from qitechgmbh/revert-ethercat-setup
fixed ethercat setup timeouts and retry
2025-09-29 17:19:33 +02:00
kraemr 3c07b94e3a fixed ethercat setup timeouts and retry 2025-09-29 17:18:49 +02:00
Robin Krämer 3b63b55859 Merge pull request #756 from qitechgmbh/fix-winder-regression
fixed winder regression due to refactor
2025-09-29 17:10:33 +02:00
kraemr f87982c465 fixed winder regression due to refactor 2025-09-29 17:01:04 +02:00
Robin Krämer b03cedb55c Merge pull request #447 from qitechgmbh/fix/438
Implementation AquaPath
2025-09-29 15:42:32 +02:00
kraemr f5934efa6c restored mock-machine functionality 2025-09-29 15:39:25 +02:00
kraemr 186619cd48 fixed compilation error 2025-09-29 15:33:25 +02:00
Darmenov Alisher 583d4c1a4d resolved comment 2025-09-29 15:33:25 +02:00
Anatomik5 63bee54948 AquapathV1 2025-09-29 15:33:25 +02:00
Robin Krämer 1523d64ed7 Merge pull request #749 from qitechgmbh/738-add-mock-machines
738 add mock machines
2025-09-29 11:32:39 +02:00
kraemr 48fdde61dc further improved readability by moving feature flags to mod.rs 2025-09-29 11:30:54 +02:00
kraemr 7bc700ac5f when selecting mock-machine feature extruder now appears as well 2025-09-29 11:30:54 +02:00
93094049+kraemr@users.noreply.github.com bdd6147f92 rewrote extruder and winder, also added mock-machine for extruder 2025-09-29 11:30:54 +02:00
93094049+kraemr@users.noreply.github.com f7f65bdaaa refactored all emit functions out of mod.rs for clarity for extruder 2025-09-29 11:30:54 +02:00
Robin Krämer 232b0782ca Merge pull request #754 from qitechgmbh/748-add-feature-flag-or-env-var-for-setting-loop-timeout
748 add feature flag or env var for setting loop timeout
2025-09-29 11:30:19 +02:00
kraemr c3c36a26b6 also added development-build flag to irq affinity code 2025-09-25 17:11:18 +02:00
kraemr 7f22bdfbf2 added feature development-build to set timeout 2025-09-25 17:06:42 +02:00
Robin Krämer ff4c579f7b Merge pull request #673 from qitechgmbh/632-add-documentation-for-creating-a-new-machine
added documentation for adding a machine
2025-09-25 15:33:41 +02:00
93094049+kraemr@users.noreply.github.com 9683882775 added documentation for adding a machine 2025-09-25 15:32:33 +02:00
Robin Krämer 893e01532d Merge pull request #753 from qitechgmbh/633-update-documentation-for-adding-new-devices
updated devices documentation by adding an example and explaining the architecture
2025-09-25 14:53:17 +02:00
kraemr 713f78fa4e updated devices documentation by adding an example
and explaining how devices fit into the architecture
2025-09-25 14:50:32 +02:00
Robin Krämer f444347fb9 Merge pull request #752 from qitechgmbh/751-document-performancestability-tweaks-made
added performance and stabillity documentation
2025-09-25 11:37:04 +02:00
kraemr ea03c0f755 added performance and stabillity documentation 2025-09-25 11:31:53 +02:00
Robin Krämer e283a0f164 Merge pull request #745 from qitechgmbh/744-windermode-switch-from-modewind-to-modepull-or-modehold-disables-the-wind-button
fixed emit state after state change
2025-09-23 15:41:28 +02:00
snacj 6820206d78 fixed emit state after state change 2025-09-23 15:38:47 +02:00
Robin Krämer 5069ee425c Merge pull request #742 from qitechgmbh/681-remove-unused-or-redundant-crates
removed unused crates
2025-09-23 13:37:48 +02:00
kraemr f93f342508 added ignores for cargo machete 2025-09-23 13:17:29 +02:00
kraemr c6d3563101 removed unused crates 2025-09-23 12:56:24 +02:00
Robin Krämer f2f893c917 Merge pull request #741 from qitechgmbh/739-add-roundness-value-to-laser-graph-and-control-page
Added Roundness Value to 2 Axis Laser
2025-09-22 14:27:30 +02:00
Robin KrämerandCopilot bbbca4085d Update server/src/machines/laser/mod.rs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-22 14:24:42 +02:00
Robin KrämerandCopilot 4fdf461520 Update server/src/machines/laser/mod.rs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-22 14:23:37 +02:00
snacj dcfc6728ba Made Roundness unitless 2025-09-22 14:10:25 +02:00
snacj 2673a92f50 Added Roundness Value to 2 Axis Laser 2025-09-22 13:39:59 +02:00
Robin Krämer 4d72185ba9 Merge pull request #675 from qitechgmbh/512-add-troubleshooting-documentation
512 add troubleshooting documentation
2025-09-18 19:44:43 +02:00
93094049+kraemr@users.noreply.github.com ade4f5cd2b updated troubleshooting.md 2025-09-18 19:41:15 +02:00
93094049+kraemr@users.noreply.github.com cf1ebf2b3d added troubleshooting.md 2025-09-18 19:41:15 +02:00
127 changed files with 8007 additions and 2611 deletions

No files matched your search

+40 -2
View File
@@ -1,3 +1,41 @@
# `2.9.1`
_10.10.2025_
## Winder V2
- [#796](https://github.com/qitechgmbh/control/pull/796) Winding in adaptive mode, while puller is in reverse mode is now possible
## General
- [#780](https://github.com/qitechgmbh/control/pull/780) Updates are faster now
- [#746](https://github.com/qitechgmbh/control/pull/746) Improved scrollbar styling for manuals
# `2.9.0`
_06.10.2025_
## Winder V2
- [#778](https://github.com/qitechgmbh/control/pull/778) Increased Winder Traverse Step size limit
## General
- [#725](https://github.com/qitechgmbh/control/pull/725) Improved troubleshooting and update page
- [#625](https://github.com/qitechgmbh/control/pull/625) Improved stability of Machine-Connections
# `2.8.0`
_30.09.2025_
## Laser V1
- [#741](https://github.com/qitechgmbh/control/pull/741) Added roundness measurement to two-axis Laser
## Winder V2
- [#745](https://github.com/qitechgmbh/control/pull/745), [#756](https://github.com/qitechgmbh/control/pull/756) improved Winder Frontend
## General
- [#447](https://github.com/qitechgmbh/control/pull/447) added AquaPath Machine
# `2.7.0`
_17.09.2025_
@@ -10,9 +48,9 @@ _17.09.2025_
- [#727](https://github.com/qitechgmbh/control/pull/727) Removed estimated diameter
## Laser V1/V2
## Laser V1
- [#731](https://github.com/qitechgmbh/control/pull/731) Added LaserV2 with two-axis measurements
- [#731](https://github.com/qitechgmbh/control/pull/731) Added Laser with two-axis measurements
## General
Generated
+31 -159
View File
@@ -99,9 +99,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.99"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "approx"
@@ -284,9 +284,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "axum"
version = "0.8.4"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871"
dependencies = [
"axum-core",
"axum-macros",
@@ -304,8 +304,7 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
@@ -319,9 +318,9 @@ dependencies = [
[[package]]
name = "axum-core"
version = "0.5.2"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6"
checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22"
dependencies = [
"bytes",
"futures-core",
@@ -330,7 +329,6 @@ dependencies = [
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
@@ -955,11 +953,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.6"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7"
checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b"
dependencies = [
"serde",
"serde_core",
"typeid",
]
@@ -1020,6 +1019,7 @@ dependencies = [
"futures",
"rand",
"smol",
"tracing",
"uom",
]
@@ -1678,15 +1678,6 @@ dependencies = [
"serde",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-terminal"
version = "0.4.16"
@@ -1698,16 +1689,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@@ -1747,9 +1728,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.175"
version = "0.2.176"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
[[package]]
name = "libloading"
@@ -2001,17 +1982,6 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "open"
version = "5.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "opentelemetry"
version = "0.30.0"
@@ -2121,12 +2091,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pcap"
version = "2.2.0"
@@ -2416,9 +2380,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.11.2"
version = "1.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c"
dependencies = [
"aho-corasick",
"memchr",
@@ -2428,9 +2392,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.9"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad"
dependencies = [
"aho-corasick",
"memchr",
@@ -2576,18 +2540,28 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.219"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@@ -2699,14 +2673,12 @@ dependencies = [
"bitvec",
"control_core",
"control_core_derive",
"crc",
"dhat",
"ethercat_hal",
"ethercrab",
"euclid",
"futures",
"lazy_static",
"open",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
@@ -2717,9 +2689,6 @@ dependencies = [
"smol",
"socketioxide",
"textplots",
"thread-priority",
"tikv-jemalloc-ctl",
"tikv-jemallocator",
"tokio",
"tonic",
"tower-http",
@@ -3032,20 +3001,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820"
[[package]]
name = "thread-priority"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd4ef372b29fbcc6cb0cef97bcbf3ffa9da90e02eb23dbf14db48ce30d11d2d5"
dependencies = [
"bitflags 2.9.0",
"cfg-if",
"libc",
"log",
"rustversion",
"windows",
]
[[package]]
name = "thread_local"
version = "1.1.8"
@@ -3056,37 +3011,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tikv-jemalloc-ctl"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f21f216790c8df74ce3ab25b534e0718da5a1916719771d3fec23315c99e468b"
dependencies = [
"libc",
"paste",
"tikv-jemalloc-sys",
]
[[package]]
name = "tikv-jemalloc-sys"
version = "0.6.0+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd3c60906412afa9c2b5b5a48ca6a5abe5736aec9eb48ad05037a677e52e4e2d"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "tikv-jemallocator"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cec5ff18518d81584f477e9bfdf957f5bb0979b0bac3af4ca30b5b3ae2d2865"
dependencies = [
"libc",
"tikv-jemalloc-sys",
]
[[package]]
name = "timerfd"
version = "1.6.0"
@@ -3659,28 +3583,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-link",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.61.2"
@@ -3694,17 +3596,6 @@ dependencies = [
"windows-strings 0.4.2",
]
[[package]]
name = "windows-future"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.0"
@@ -3733,16 +3624,6 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-numerics"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.4.0"
@@ -3844,15 +3725,6 @@ dependencies = [
"windows_x86_64_msvc 0.53.0",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
+9 -6
View File
@@ -7,6 +7,9 @@ QiTech Control is an industrial control panel software for the next generation o
[![](https://img.youtube.com/vi/KI3YeBwfV-s/maxresdefault.jpg)](https://www.youtube.com/watch?v=KI3YeBwfV-s)
*Click here to watch a video demo of our software.*
[![](https://img.youtube.com/vi/55egCAkQgyM/maxresdefault.jpg)](https://youtu.be/55egCAkQgyM)
*Click here to watch a full explainer Video of our Software.*
# Repo Structure
Frontend
@@ -32,7 +35,7 @@ Other
## Backend
To interface with Beckhoff and other EtherCAT devices we need an EtherCAY master stoftware. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
To interface with Beckhoff and other EtherCAT devices we need an EtherCAT master stoftware. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
[SocketIO](https://socket.io/) was chosen for performant event driven communication from the backend to the server. But we still use REST with [axum](https://docs.rs/axum/latest/axum/) for the communication thet benefits from the request/response model.
@@ -64,12 +67,12 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
| Machine Type | Version | Release Date | Description | Change to Previous Version | Vendor ID | Machine ID | Implemented | Docs |
| ------------ | ------- | ------------ | --------------------------- | ------------------------------------------------------ | -------------------------- | ---------- | ----------- | ------------------------------- |
| Winder | V1 | ??? | Winding Filaments & Similar | - | 1 (Qitech Industries GmbH) | 1 | Reserved | - |
| Winder | V2 | 2025 | Winding Filaments & Similar | Reengineered Traverse | 1 (Qitech Industries GmbH) | 2 | In Progress | [](./docs/machines/winder-1.md) |
| Winder | V2 | 2025 | Winding Filaments & Similar | Reengineered Traverse | 1 (Qitech Industries GmbH) | 2 | Yes | [](./docs/machines/winder-1.md) |
| 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 | - |
| Extruder | V2 | 2025 | Single Screw Extruder | PT100 Thermometers, Optional Additional Heating Zone 4 | 1 (Qitech Industries GmbH) | 4 | Yes | |
| Waterway | V1 | 2025 | Filament Water Cooling | - | 1 (Qitech Industries GmbH) | 5 | In Progress | |
| Laser | V1 | ??? | Diameter Measuring Laser | - | 1 (Qitech Industries GmbH) | 6 | Yes | |
| Mock | - | ??? | Mock Machine for Testing | - | 1 (Qitech Industries GmbH) | 7 | Yes | - |
# More Docs
+6 -1
View File
@@ -40,10 +40,15 @@ fn machine_derive2(item: TokenStream2) -> Result<TokenStream2, Error> {
let ast: DeriveInput = syn::parse2(item)?;
let ident = &ast.ident;
let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();
let expanded = quote! {
impl #impl_generics control_core::machines::Machine for #ident #ty_generics #where_clause {}
impl #impl_generics control_core::machines::Machine for #ident #ty_generics #where_clause {
fn get_machine_identification_unique(&self) -> control_core::machines::identification::MachineIdentificationUnique {
self.machine_identification_unique.clone()
}
}
impl #impl_generics control_core::machines::AnyGetters for #ident #ty_generics #where_clause {
fn as_any(&self) -> &dyn std::any::Any {
+10 -5
View File
@@ -7,8 +7,10 @@ edition = "2024"
workspace = true
[dependencies]
anyhow = "1.0.99"
axum = "0.8.4"
anyhow = "1.0.100"
axum = "0.8.6"
ethercat_hal = { path = "../ethercat-hal" }
ethercrab = "0.6"
interfaces = "0.0.9"
@@ -17,19 +19,19 @@ serde_json = "1.0.143"
serialport = "4.7.3"
smol = "2.0.2"
socketioxide = "0.17.2"
uom = "0.36.0"
uom = { version = "0.36.0", default-features = false, features = ["f64"] }
bitvec = { version = "1.0.1", features = ["alloc"] }
crc = "3.3.0"
serial = "0.4.0"
tracing = "0.1.41"
erased-serde = "0.4.6"
erased-serde = "0.4.8"
core_affinity = "0.8.3"
control_core_derive = { version = "0.1.0", path = "../control-core-derive" }
tokio = { version = "1.45.1", features = ["sync"], optional = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2.175"
libc = "0.2.176"
[dev-dependencies]
approx = "0.5.1"
@@ -40,3 +42,6 @@ tokio = { version = "1.45.1", features = ["rt-multi-thread", "macros", "time"] }
[features]
default = []
video-streaming = ["dep:tokio"]
[package.metadata.cargo-machete]
ignored = ["axum","bitvec","control_core_derive"]
+14 -32
View File
@@ -1,4 +1,4 @@
use uom::si::angle::radian;
use uom::si::{angle::radian, f64::Angle};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AngleConverter {
@@ -63,7 +63,7 @@ impl AngleConverter {
}
/// Convert angle from mathematical system to this system (f32)
pub fn degrees_encode(&self, math_angle_degrees: f32) -> f32 {
pub fn degrees_encode(&self, math_angle_degrees: f64) -> f64 {
let normalized_input = self.normalize_angle(math_angle_degrees);
let mut result = normalized_input;
@@ -85,7 +85,7 @@ impl AngleConverter {
}
/// Convert angle from this system to mathematical system (f32)
pub fn degrees_decode(&self, system_angle_degrees: f32) -> f32 {
pub fn degrees_decode(&self, system_angle_degrees: f64) -> f64 {
let normalized_input = self.normalize_angle(system_angle_degrees);
let mut result = normalized_input;
@@ -107,14 +107,14 @@ impl AngleConverter {
}
/// Convert angle in radians from mathematical system to this system (f32)
pub fn radians_encode(&self, math_angle_radians: f32) -> f32 {
pub fn radians_encode(&self, math_angle_radians: f64) -> f64 {
let degrees = math_angle_radians.to_degrees();
let converted_degrees = self.degrees_encode(degrees);
converted_degrees.to_radians()
}
/// Convert angle in radians from this system to mathematical system (f32)
pub fn radians_decode(&self, system_angle_radians: f32) -> f32 {
pub fn radians_decode(&self, system_angle_radians: f64) -> f64 {
let degrees = system_angle_radians.to_degrees();
let converted_degrees = self.degrees_decode(degrees);
converted_degrees.to_radians()
@@ -179,7 +179,7 @@ impl AngleConverter {
}
/// Normalize angle to [0, 360) range (f32)
fn normalize_angle(&self, angle: f32) -> f32 {
fn normalize_angle(&self, angle: f64) -> f64 {
let mut normalized = angle % 360.0;
if normalized < 0.0 {
normalized += 360.0;
@@ -207,30 +207,12 @@ impl AngleConverterUom {
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 encode(&self, angle: Angle) -> Angle {
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>()),
)
pub fn decode(&self, angle: Angle) -> Angle {
Angle::new::<radian>(self.angle_converter.radians_decode(angle.get::<radian>()))
}
}
@@ -297,8 +279,8 @@ mod tests {
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
let result = converter.radians_encode(PI as f64 / 2.0) as f64; // 90° in radians
let expected: f64 = 270.0f64 * PI as f64 / 180.0f64; // 270° in radians
assert!((result - expected).abs() < 1e-6);
}
@@ -306,10 +288,10 @@ mod tests {
fn test_roundtrip_conversion() {
let converter = AngleConverter::new(true, false, true); // X-flip + CW
let original = 75.0_f32;
let original = 75.0_f64;
let converted = converter.degrees_encode(original);
let roundtrip = converter.degrees_decode(converted);
assert!((original - roundtrip).abs() < 1e-6);
assert!((original - roundtrip as f64).abs() < 1e-6);
}
}
+36
View File
@@ -0,0 +1,36 @@
use anyhow::anyhow;
use smol::lock::Mutex;
use std::any::Any;
use std::sync::Arc;
pub trait Downcast<T> {
fn downcast(&self) -> Result<T, anyhow::Error>;
}
pub trait DowncastRef<T> {
fn downcast_ref(&self) -> Result<&T, anyhow::Error>;
}
impl<T: 'static, U: Any + 'static> Downcast<Arc<Mutex<T>>> for Arc<Mutex<U>> {
fn downcast(&self) -> Result<Arc<Mutex<T>>, anyhow::Error> {
// Acquire a read lock on the RwLock
let lock = self.lock_blocking();
// Check if the inner type can be downcasted to T
let any: &dyn Any = &*lock;
if any.is::<T>() {
// Clone the Arc and return it as the desired type
let cloned: Arc<Mutex<dyn Any>> = self.clone();
// Transmute the Arc to the desired type
unsafe {
let arc = Arc::from_raw(Arc::into_raw(cloned) as *const Mutex<T>);
Ok(arc)
}
} else {
Err(anyhow!("[{}::downcast] Downcast failed", module_path!()))
}
}
}
@@ -19,6 +19,7 @@ use ethercat_hal::devices::el3062_0030::EL3062_0030_IDENTITY_A;
use ethercat_hal::devices::el3204::EL3204_IDENTITY_A;
use ethercat_hal::devices::el3204::EL3204_IDENTITY_B;
use ethercat_hal::devices::el4002::EL4002_IDENTITY_A;
use ethercat_hal::devices::el5152::EL5152_IDENTITY_A;
use ethercat_hal::devices::el6021::{
EL6021_IDENTITY_A, EL6021_IDENTITY_B, EL6021_IDENTITY_C, EL6021_IDENTITY_D,
};
@@ -239,23 +240,25 @@ pub fn get_identification_addresses(
EL1008_IDENTITY_A => MachineIdentificationAddresses::default(),
EL2002_IDENTITY_A | EL2002_IDENTITY_B => MachineIdentificationAddresses::default(),
EL2004_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3204_IDENTITY_A | EL3204_IDENTITY_B => MachineIdentificationAddresses::default(),
EL2008_IDENTITY_A | EL2008_IDENTITY_B => MachineIdentificationAddresses::default(),
EL3001_IDENTITY_A => MachineIdentificationAddresses::default(),
EL2521_IDENTITY_0000_A | EL2521_IDENTITY_0000_B | EL2521_IDENTITY_0024_A => {
MachineIdentificationAddresses::default()
}
EL2522_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3001_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3021_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3024_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3062_0030_IDENTITY_A => MachineIdentificationAddresses::default(),
EL3204_IDENTITY_A | EL3204_IDENTITY_B => MachineIdentificationAddresses::default(),
EL4002_IDENTITY_A => MachineIdentificationAddresses::default(),
EL7031_IDENTITY_A | EL7031_IDENTITY_B => MachineIdentificationAddresses::default(),
EL7031_0030_IDENTITY_A => MachineIdentificationAddresses::default(),
EL7041_0052_IDENTITY_A => MachineIdentificationAddresses::default(),
EL5152_IDENTITY_A => MachineIdentificationAddresses::default(),
EL6021_IDENTITY_A | EL6021_IDENTITY_B | EL6021_IDENTITY_C | EL6021_IDENTITY_D => {
MachineIdentificationAddresses::default()
}
EL7031_IDENTITY_A | EL7031_IDENTITY_B => MachineIdentificationAddresses::default(),
EL7031_0030_IDENTITY_A => MachineIdentificationAddresses::default(),
EL7041_0052_IDENTITY_A => MachineIdentificationAddresses::default(),
_ => {
// block_on(u16dump(&subdevice, maindevice, 0x00, 0xff))?;
Err(anyhow!(
@@ -144,7 +144,7 @@ fn test_interface(interface: &str) -> Result<(), anyhow::Error> {
// Default 10000
dc_static_sync_iterations: 10000,
// Default None
retry_behaviour: ethercrab::RetryBehaviour::None,
retry_behaviour: ethercrab::RetryBehaviour::Count(5),
},
));
+1
View File
@@ -1,5 +1,6 @@
pub mod controllers;
pub mod converters;
pub mod downcast;
pub mod ethercat;
pub mod helpers;
pub mod irq_handling;
+3 -1
View File
@@ -1,10 +1,12 @@
use serde_json::Value;
use smol::lock::Mutex;
use std::sync::Arc;
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 Namespace;
fn api_event_namespace(&mut self) -> Arc<Mutex<Namespace>>;
/// Returns a list of available video stream identifiers for this machine
#[cfg(feature = "video-streaming")]
+208
View File
@@ -0,0 +1,208 @@
use crate::downcast::Downcast;
use crate::machines::identification::MachineIdentificationUnique;
use crate::machines::manager::MachineManager;
use crate::socketio::{event::GenericEvent, namespace::Namespace};
use serde::Serialize;
use smol::block_on;
use smol::lock::RwLock;
use std::sync::Arc;
use std::sync::Weak;
use super::Machine;
use anyhow::anyhow;
use smol::{channel::Sender, lock::Mutex};
use socketioxide::extract::SocketRef;
#[derive(Debug)]
pub enum MachineConnection<M: Machine + ?Sized>
where
Self: Sized,
{
Error(anyhow::Error),
Disconnected,
Connected(Arc<Mutex<M>>),
}
#[derive(Debug)]
pub struct MachineSlot<M: Machine + ?Sized>
where
Self: Sized,
{
pub machine_connection: MachineConnection<M>,
pub namespace: Arc<Mutex<Namespace>>,
}
pub type MachineConnectionGeneric = MachineConnection<dyn Machine>;
pub type MachineSlotGeneric = MachineSlot<dyn Machine>;
impl<M: Machine> Clone for MachineConnection<M> {
fn clone(&self) -> Self {
match self {
Self::Connected(m) => Self::Connected(m.clone()),
Self::Error(e) => Self::Error(anyhow!(e.to_string())),
Self::Disconnected => Self::Disconnected,
}
}
}
impl<M: Machine + ?Sized> MachineConnection<M> {
pub fn to_error(&self) -> Option<anyhow::Error> {
match self {
MachineConnection::Error(err) => Some(anyhow!(err.to_string())),
Self::Disconnected => Some(anyhow!("Machine is disconnected")),
_ => None,
}
}
pub fn to_machine(&self) -> Option<Arc<Mutex<M>>> {
match self {
Self::Connected(machine) => Some(machine.clone()),
_ => None,
}
}
}
impl<M: Machine + ?Sized> MachineSlot<M> {
pub fn new(socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>) -> Self {
let namespace = Namespace::new(socket_queue_tx);
Self {
machine_connection: MachineConnection::Disconnected,
namespace: Arc::new(Mutex::new(namespace)),
}
}
pub const fn is_connected(&self) -> bool {
matches!(self.machine_connection, MachineConnection::Connected(_))
}
}
pub trait CrossConnectableMachine<
F: CrossConnectableMachine<F, T>,
T: CrossConnectableMachine<T, F>,
>: Machine
{
fn get_cross_connection(&mut self) -> &mut MachineCrossConnection<F, T>;
}
#[derive(Debug)]
pub struct MachineCrossConnection<
F: CrossConnectableMachine<F, T>,
T: CrossConnectableMachine<T, F>,
> {
machine_manager: Weak<RwLock<MachineManager>>,
machine_identification_unique: MachineIdentificationUnique,
pub connected_machine: Weak<Mutex<MachineSlot<T>>>,
_make_compiler_happy: std::marker::PhantomData<F>, // This marks tells the typechecker, that we
// need F in our type arguemnts. We are ok with
// it being used only in a fake field. Without
// F being fixed, we would not be anble to implement
// reverse connecting and disconnecting properly,
// as the type on the other side would decay
// to an unknown type. See also https://github.com/qitechgmbh/control/pull/625#discussion_r2379566315
}
#[derive(Serialize, Debug, Clone)]
pub struct MachineCrossConnectionState {
/// Connected Machine
pub machine_identification_unique: Option<MachineIdentificationUnique>,
pub is_available: bool,
}
impl<F: CrossConnectableMachine<F, T>, T: CrossConnectableMachine<T, F>>
MachineCrossConnection<F, T>
{
pub fn new(
manager: Weak<RwLock<MachineManager>>,
machine_identification_unique: &MachineIdentificationUnique,
) -> Self {
Self {
machine_manager: manager,
connected_machine: Weak::new(),
machine_identification_unique: machine_identification_unique.clone(),
_make_compiler_happy: std::marker::PhantomData,
}
}
pub fn set_connected_machine(
&mut self,
machine_identification_unique: &MachineIdentificationUnique,
) {
if let Some(machine_manager) = self.machine_manager.upgrade() {
let machine_manager = block_on(machine_manager.read());
let slot: Arc<Mutex<MachineSlotGeneric>> =
match machine_manager.get(&machine_identification_unique) {
Some(c) => c,
None => return,
};
let slot: Arc<Mutex<MachineSlot<T>>> = match Downcast::downcast(&slot) {
Ok(s) => s,
Err(_) => return,
};
self.connected_machine = Arc::downgrade(&slot);
} else {
panic!("The machine manager has died, cannot do anything at this point");
}
}
pub fn reverse_connect(&self) {
if let Some(slot) = self.connected_machine.upgrade() {
let slot = &mut slot.lock_blocking();
if let MachineConnection::Connected(machine) = &slot.machine_connection {
let mut machine = machine.lock_blocking();
let cross_connection = machine.get_cross_connection();
if !cross_connection.is_connected() {
cross_connection.set_connected_machine(&self.machine_identification_unique);
}
};
}
}
pub fn disconnect(&mut self) {
self.connected_machine = Weak::new();
}
pub fn reverse_disconnect(&mut self) {
if let Some(slot) = self.connected_machine.upgrade() {
let slot = slot.lock_blocking();
if let MachineConnection::Connected(machine) = &slot.machine_connection {
let mut machine = machine.lock_blocking();
let cross_connection = machine.get_cross_connection();
cross_connection.disconnect();
};
}
}
pub fn is_connected(&self) -> bool {
let arc = self.connected_machine.upgrade();
arc.is_some()
}
pub fn to_state(&self) -> MachineCrossConnectionState {
if let Some(slot) = self.connected_machine.upgrade() {
let connection = &slot.lock_blocking().machine_connection;
if let MachineConnection::Connected(machine) = connection {
let machine = machine.lock_blocking();
return MachineCrossConnectionState {
machine_identification_unique: Some(
machine.get_machine_identification_unique(),
),
is_available: true,
};
}
}
MachineCrossConnectionState {
machine_identification_unique: None,
is_available: false,
}
}
}
+88 -66
View File
@@ -1,3 +1,9 @@
use super::{
Machine,
identification::{DeviceIdentification, DeviceIdentificationIdentified},
new::MachineNewHardwareSerial,
registry::MachineRegistry,
};
use smol::{
channel::Sender,
lock::{Mutex, RwLock},
@@ -6,6 +12,9 @@ use socketioxide::extract::SocketRef;
use crate::{
machines::{
connection::{
MachineConnection, MachineConnectionGeneric, MachineSlot, MachineSlotGeneric,
},
identification::MachineIdentificationUnique,
new::{MachineNewHardware, MachineNewHardwareEthercat, MachineNewParams},
},
@@ -17,18 +26,10 @@ use std::{
sync::{Arc, Weak},
};
use super::{
Machine,
identification::{DeviceIdentification, DeviceIdentificationIdentified},
new::MachineNewHardwareSerial,
registry::MachineRegistry,
};
#[derive(Debug)]
pub struct MachineManager {
pub ethercat_machines:
HashMap<MachineIdentificationUnique, Result<Arc<Mutex<dyn Machine>>, anyhow::Error>>,
pub serial_machines:
HashMap<MachineIdentificationUnique, Result<Arc<Mutex<dyn Machine>>, anyhow::Error>>,
pub ethercat_machines: HashMap<MachineIdentificationUnique, Arc<Mutex<MachineSlotGeneric>>>,
pub serial_machines: HashMap<MachineIdentificationUnique, Arc<Mutex<MachineSlotGeneric>>>,
}
impl Default for MachineManager {
@@ -53,9 +54,6 @@ impl MachineManager {
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
machine_manager: Weak<RwLock<Self>>,
) {
// empty ethercat machines
self.ethercat_machines.clear();
// group devices by machine device identification
let device_grouping_result = group_devices_by_identification(device_identifications);
@@ -69,10 +67,18 @@ impl MachineManager {
// iterate over all identified device groups but ignore unaffected machines
for device_group in device_grouping_result.device_groups.iter() {
// get the machine identification
let machine_identification = match device_group.first() {
Some(device_identification) => &device_identification.device_machine_identification,
None => continue, // Skip this group if empty
};
let machine_identification_unique: MachineIdentificationUnique =
match device_group.first() {
Some(device_identification) => device_identification
.device_machine_identification
.machine_identification_unique
.clone(),
None => continue, // Skip this group if empty
};
let slot =
self.get_or_create_slot(socket_queue_tx.clone(), machine_identification_unique);
let mut slot = slot.lock_blocking();
// create the machine
let new_machine = machine_registry.new_machine(&MachineNewParams {
@@ -80,23 +86,40 @@ impl MachineManager {
hardware: &machine_new_hardware,
socket_queue_tx: socket_queue_tx.clone(),
machine_manager: machine_manager.clone(),
namespace: slot.namespace.clone(),
});
// insert the machine into the ethercat machines map
self.ethercat_machines.insert(
machine_identification.machine_identification_unique.clone(),
new_machine,
);
slot.machine_connection = match new_machine {
Err(err) => MachineConnectionGeneric::Error(err),
Ok(machine) => MachineConnectionGeneric::Connected(machine),
};
}
}
fn get_or_create_slot(
&mut self,
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
machine_identification: MachineIdentificationUnique,
) -> Arc<Mutex<MachineSlotGeneric>> {
if let Some(slot) = self.get(&machine_identification) {
return slot;
}
let slot = Arc::new(Mutex::new(MachineSlot::new(socket_queue_tx)));
self.ethercat_machines
.insert(machine_identification, slot.clone());
return slot;
}
pub fn get(
&self,
machine_identification: &MachineIdentificationUnique,
) -> Option<&Result<Arc<Mutex<dyn Machine>>, anyhow::Error>> {
) -> Option<Arc<Mutex<MachineSlotGeneric>>> {
self.ethercat_machines
.get(machine_identification)
.or_else(|| self.serial_machines.get(machine_identification))
.map(|x| x.clone())
}
pub fn add_serial_device(
@@ -115,21 +138,28 @@ impl MachineManager {
.try_into()
.expect("Serial devices always have machine identification");
let machine_identification: MachineIdentificationUnique = device_identification_identified
.device_machine_identification
.machine_identification_unique
.clone();
let slot = self.get_or_create_slot(socket_queue_tx.clone(), machine_identification);
let mut slot = slot.lock_blocking();
let new_machine = machine_registry.new_machine(&MachineNewParams {
device_group: &vec![device_identification_identified.clone()],
hardware: &MachineNewHardware::Serial(&hardware),
socket_queue_tx,
machine_manager,
machine_manager: machine_manager.clone(),
namespace: slot.namespace.clone(),
});
tracing::info!("Adding serial machine {:?}", new_machine);
slot.machine_connection = match new_machine {
Err(err) => MachineConnectionGeneric::Error(err),
Ok(machine) => MachineConnectionGeneric::Connected(machine),
};
self.serial_machines.insert(
device_identification_identified
.device_machine_identification
.machine_identification_unique,
new_machine,
);
tracing::info!("Adding serial machine {:?}", slot);
}
pub fn remove_serial_device(&mut self, device_identification: &DeviceIdentification) {
@@ -144,53 +174,45 @@ impl MachineManager {
device_identification_identified
);
self.serial_machines.remove(
&device_identification_identified
.device_machine_identification
.machine_identification_unique,
);
let machine_identification_unique = &device_identification_identified
.device_machine_identification
.machine_identification_unique;
if let Some(slot) = self.serial_machines.get(machine_identification_unique) {
let mut slot = slot.lock_blocking();
slot.machine_connection = MachineConnection::Disconnected;
}
}
pub fn get_machine_weak(
pub fn get_ethercat_weak(
&self,
machine_identification: &MachineIdentificationUnique,
) -> Option<Weak<Mutex<dyn Machine>>> {
let machine = self.ethercat_machines.get(machine_identification);
let machine = match machine {
Some(machine) => machine,
None => {
return None;
}
};
let machine = match machine {
Ok(machine) => machine,
Err(_) => {
return None;
}
};
let machine_weak = Arc::downgrade(machine);
Some(machine_weak)
self.get_weak(&self.ethercat_machines, machine_identification)
}
pub fn get_serial_weak(
&self,
machine_identification: &MachineIdentificationUnique,
) -> Option<Weak<Mutex<dyn Machine>>> {
let machine = self.serial_machines.get(machine_identification);
let machine = match machine {
Some(machine) => machine,
None => {
return None;
self.get_weak(&self.serial_machines, machine_identification)
}
fn get_weak(
&self,
machines: &HashMap<MachineIdentificationUnique, Arc<Mutex<MachineSlotGeneric>>>,
machine_identification: &MachineIdentificationUnique,
) -> Option<Weak<Mutex<dyn Machine>>> {
let slot = machines.get(machine_identification).clone();
return slot.and_then(|slot| {
let connection = &slot.lock_blocking().machine_connection;
match connection {
MachineConnectionGeneric::Error(_) => None,
MachineConnectionGeneric::Disconnected => None,
MachineConnectionGeneric::Connected(machine) => Some(Arc::downgrade(&machine)),
}
};
let machine = match machine {
Ok(machine) => machine,
Err(_) => {
return None;
}
};
let machine_weak = Arc::downgrade(machine);
Some(machine_weak)
});
}
}
+10 -8
View File
@@ -1,22 +1,25 @@
use std::{collections::HashMap, sync::Arc};
use std::collections::HashMap;
use std::sync::Arc;
use smol::lock::Mutex;
use super::{Machine, identification::MachineIdentificationUnique, manager::MachineManager};
use crate::machines::connection::MachineSlotGeneric;
use super::{identification::MachineIdentificationUnique, manager::MachineManager};
pub enum MachineManagerIter<'a> {
EthercatMachines {
iter: std::collections::hash_map::Iter<
'a,
MachineIdentificationUnique,
Result<Arc<Mutex<dyn Machine>>, anyhow::Error>,
Arc<Mutex<MachineSlotGeneric>>,
>,
},
SerialMachines {
iter: std::collections::hash_map::Iter<
'a,
MachineIdentificationUnique,
Result<Arc<Mutex<dyn Machine>>, anyhow::Error>,
Arc<Mutex<MachineSlotGeneric>>,
>,
},
Done,
@@ -24,14 +27,13 @@ pub enum MachineManagerIter<'a> {
pub struct MachineManagerIterator<'a> {
iter: MachineManagerIter<'a>,
serial_machines:
&'a HashMap<MachineIdentificationUnique, Result<Arc<Mutex<dyn Machine>>, anyhow::Error>>,
serial_machines: &'a HashMap<MachineIdentificationUnique, Arc<Mutex<MachineSlotGeneric>>>,
}
impl<'a> Iterator for MachineManagerIterator<'a> {
type Item = (
&'a MachineIdentificationUnique,
&'a Result<Arc<Mutex<dyn Machine>>, anyhow::Error>,
&'a Arc<Mutex<MachineSlotGeneric>>,
);
fn next(&mut self) -> Option<Self::Item> {
@@ -71,7 +73,7 @@ impl<'a> Iterator for MachineManagerIterator<'a> {
impl MachineManager {
// Returns an iterator over all machines (both ethercat and serial)
pub fn iter(&'_ self) -> MachineManagerIterator<'_> {
pub fn iter(&self) -> MachineManagerIterator<'_> {
MachineManagerIterator {
iter: MachineManagerIter::EthercatMachines {
iter: self.ethercat_machines.iter(),
+8 -68
View File
@@ -1,83 +1,23 @@
use anyhow::anyhow;
use api::MachineApi;
use new::MachineNewTrait;
use serde::Serialize;
use smol::lock::Mutex;
use std::any::Any;
use std::fmt::Debug;
use std::sync::{Arc, Weak};
use crate::machines::identification::MachineIdentificationUnique;
use crate::machines::new::MachineAct;
use crate::machines::{
api::MachineApi, identification::MachineIdentificationUnique, new::MachineAct,
};
pub mod api;
pub mod connection;
pub mod identification;
pub mod manager;
pub mod manager_iter;
pub mod new;
pub mod registry;
pub trait Machine: MachineAct + MachineNewTrait + MachineApi + AnyGetters + Debug + Send {}
pub trait Machine: MachineAct + MachineNewTrait + MachineApi + Any + Debug + Send + Sync {
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique;
}
pub trait AnyGetters: Any {
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
}
/// Casts a `dyn Machine` to a specific machine type
pub async fn downcast_machine<T: Machine>(
machine: Arc<Mutex<dyn Machine>>,
) -> Result<Arc<Mutex<T>>, anyhow::Error> {
// Acquire a read lock on the RwLock
let read_lock = machine.lock().await;
// Check if the inner type can be downcasted to T
if read_lock.as_any().is::<T>() {
// Clone the Arc and return it as the desired type
let cloned_machine = Arc::clone(&machine);
// Transmute the Arc to the desired type
unsafe {
Ok(Arc::from_raw(
Arc::into_raw(cloned_machine) as *const Mutex<T>
))
}
} else {
Err(anyhow!(
"[{}::downcast_machine] Downcast failed",
module_path!()
))
}
}
/// Trait to get strong count to see if Weak has reference
pub trait GetStrongCount {
fn get_strong_count(&self) -> usize;
}
impl<T> GetStrongCount for Weak<Mutex<T>> {
fn get_strong_count(&self) -> usize {
self.strong_count()
}
}
#[derive(Debug)]
pub struct ConnectedMachine<T: GetStrongCount> {
pub machine_identification_unique: MachineIdentificationUnique,
pub machine: T,
}
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
pub struct ConnectedMachineData {
pub machine_identification_unique: MachineIdentificationUnique,
pub is_available: bool,
}
impl<T> From<&ConnectedMachine<T>> for ConnectedMachineData
where
T: GetStrongCount,
{
fn from(value: &ConnectedMachine<T>) -> Self {
Self {
machine_identification_unique: value.machine_identification_unique.clone(),
is_available: value.machine.get_strong_count() != 0,
}
}
}
+22 -2
View File
@@ -1,4 +1,8 @@
use crate::{machines::manager::MachineManager, serial::SerialDevice};
use crate::{
machines::{identification::MachineIdentificationUnique, manager::MachineManager},
serial::SerialDevice,
socketio::namespace::Namespace,
};
use super::identification::DeviceIdentificationIdentified;
use anyhow::Error;
@@ -6,7 +10,10 @@ use ethercat_hal::{
devices::EthercatDevice, helpers::ethercrab_types::EthercrabSubDevicePreoperational,
};
use ethercrab::{SubDevice, SubDeviceRef};
use smol::{channel::Sender, lock::RwLock};
use smol::{
channel::Sender,
lock::{Mutex, RwLock},
};
use socketioxide::extract::SocketRef;
use std::{
sync::{Arc, Weak},
@@ -49,6 +56,19 @@ pub struct MachineNewParams<
>,
pub socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
pub machine_manager: Weak<RwLock<MachineManager>>,
pub namespace: Arc<Mutex<Namespace>>,
}
impl MachineNewParams<'_, '_, '_, '_, '_, '_, '_> {
pub fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
self.device_group
.first()
.expect("device group must have at least one device")
.device_machine_identification
.machine_identification_unique
.clone()
}
}
pub enum MachineNewHardware<
+1 -1
View File
@@ -30,7 +30,7 @@ impl MachineRegistry {
self.type_map.insert(
TypeId::of::<T>(),
(
machine_identficiation,
machine_identficiation.clone(),
// create a machine construction closure
Box::new(|machine_new_params| {
Ok(Arc::new(Mutex::new(T::new(machine_new_params)?)))
+145
View File
@@ -0,0 +1,145 @@
# How can I add a new Machine?
Adding a new machine follows a common structure in `server/machines/`.
# TLDR
Summary
When adding a new machine:
- Create Folder server/machines/YOUR_MACHINE_NAME/.
- Add the 4 files (mod.rs, new.rs, act.rs, api.rs).
- Register it in machines/mod.rs with a unique constant.
- mod.rs: define the struct + emit functions.
- new.rs: assemble devices/controllers into the machine.
- act.rs: implement the machine loop (MachineAct).
- api.rs: define request (mutations) and response (events) types.
Tip: Start simple (copy MockMachine) and grow complexity as needed (add EtherCAT devices, controllers, etc.).
---
## 1. Create the folder
Create a new directory:
server/machines/YOUR_MACHINE_NAME
At a minimum, this folder needs the following files:
- `mod.rs`
- `new.rs`
- `act.rs`
- `api.rs`
---
## 2. Register the module
Add your machine to `server/machines/mod.rs`:
```rust
// mod.rs
pub mod buffer1;
pub mod extruder1;
pub mod laser;
pub mod mock;
pub mod registry;
pub mod winder2;
pub mod YOUR_MACHINE;
pub const VENDOR_QITECH: u16 = 0x0001;
pub const MACHINE_WINDER_V1: u16 = 0x0002;
pub const MACHINE_EXTRUDER_V1: u16 = 0x0004;
pub const MACHINE_LASER_V1: u16 = 0x0006;
pub const MACHINE_MOCK: u16 = 0x0007;
pub const MACHINE_BUFFER_V1: u16 = 0x0008;
pub const YOUR_MACHINE_V1: u16 = 0xffff; // pick a unique ID to avoid collisions also DO NOT touch any already defined ids!!
```
## 3. mod.rs
This file defines your machine struct and the required event emitters.
Look at MockMachine’s mod.rs for the simplest possible version.
At a minimum, you need:
- emit_state
- emit_live_values
- A machine struct for state(e.g. MockMachine, Extruder, Laser, Winder, …)
More complex machines (e.g. Extruder, Winder) encapsulate controllers that in turn manage devices like EtherCAT terminals.
## 4 new.rs
This file implements MachineNewTrait for your machine.
Here you assemble your machine from its parts — devices, controllers, and any state.
Minimal example: see MockMachine → basically no devices, just returns a struct.
Medium complexity: see Laser → wraps a single serial USB device.
High complexity: see Extruder → configures multiple EtherCAT devices inside controllers.
Think of new.rs as the factory:
This is the place to declare which devices make up your machine and how they’re configured.
## 5. act.rs
This file implements the machine control loop using MachineAct.
The act() method is called repeatedly by the control runtime.
```rs
impl MachineAct for YourMachine {
fn act(&mut self, _now_ts: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(async move {
let now = Instant::now();
// Update state, e.g. step a controller
self.some_controller.update();
// Emit live values ~30 Hz if machine is in Running mode
if now.duration_since(self.last_measurement_emit) > Duration::from_secs_f64(1.0 / 30.0) {
self.maybe_emit_state_event();
self.emit_live_values();
self.last_measurement_emit = now;
}
})
}
}
```
## 6. api.rs
Defines the frontend-facing API for your machine:
Mutations → requests that the machine accepts
(e.g. start/stop commands, setpoints, configuration updates).
Events → responses emitted to the frontend
(e.g. live sensor data, alarms, machine state changes).
Look at MockMachine’s api.rs for a minimal version.
```rs
/// Mutations are API requests to control the machine
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum YourMachineMutation {
Start,
Stop,
SetSpeed { rpm: f64 },
}
/// Events are responses sent to the frontend
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum YourMachineEvent {
StateChanged { state: String },
LiveValues { rpm: f64, torque: f64 },
}
```
@@ -0,0 +1,128 @@
# Performance/Stability Tweaks
## EtherCrab Config
We set ethercrab to default settings for everything except for RetryBehaviour, so that one unlikely failure doesnt destroy the EtherCAT network.
```rust
let maindevice = MainDevice::new(
pdu,
Timeouts {
// Default 5000ms
state_transition: Duration::from_millis(5000),
// Default 30_000us
pdu: Duration::from_micros(30_000),
// Default 10ms
eeprom: Duration::from_millis(10),
// Default 0ms
wait_loop_delay: Duration::from_millis(0),
// Default 100ms
mailbox_echo: Duration::from_millis(100),
// Default 1000ms
mailbox_response: Duration::from_millis(1000),
},
MainDeviceConfig {
// Default RetryBehaviour::None
retry_behaviour: RetryBehaviour::Count(5),
// Default 10_000
dc_static_sync_iterations: 10_000,
},
);
```
wait_loop_delay CAN in theory also decrease CPU usage if set exactly as the loop_delay we set, however this setting has caused us some issues, so we just leave it as is.
## Realtime Kernel
In our nixOs Config we use the latest realtime kernel and isolate TWO cores out of the Four available for the machines we use.
```nix
#From configuration.nix
boot.loader.efi.canTouchEfiVariables = true;
boot.kernelPackages = pkgs.linuxPackages-rt_latest;
boot.kernelModules = [ "i915" ];
```
Notice how we hardcode the intel gpu kernel module. This is because only the GPU driver gets blacklisted for some reason. By specifying it manually it loads like before.
One Core is used for the EtherCrab TXRX Thread and one is used for the machine Thread.
```
"isolcpus=2,3" # Isolate cpus 2 and 3 from scheduler for better latency, 2 runs ethercatthread and 3 runs server control-loop
"nohz_full=2,3" # In this mode, the periodic scheduler tick is stopped when only one task is running, reducing kernel interruptions on those CPUs.
"rcu_nocbs=2,3" # Moves RCU (Read-Copy Update) callback processing away from CPUs 2 and 3.
```
Essentially we completely disable acces from the operating system to these two cpus, which saves scheduler overhead, which would introduce noticable latency.
Its not entirely clear yet if what we did here is overkill. Maybe we could use only one core and get away with that?
Processor load normally hovers around 18% for one of the cores and like 9% for the other.
We also have some minor cpu tweaks in configuration.nix
```
# Graphical
"logo.nologo" # Remove kernel logo during boot
# Performance
# Specific Vulnerabilities Addressed by Mitigations:
# - Spectre variants (V1, V2, V4, SWAPGS, SpectreRSB, etc.)
# - Meltdown (Rogue Data Cache Load)
# - Foreshadow/L1TF (L1 Terminal Fault)
# - Microarchitectural Data Sampling (MDS, RIDL, Fallout, ZombieLoad)
# - SRBDS (Special Register Buffer Data Sampling)
# - TSX Asynchronous Abort (TAA)
# - iTLB Multihit
# - And others as they're discovered and mitigated
#
# With mitigations=off
# - PROS: Maximum performance, equivalent to pre-2018 behavior
# - CONS: Vulnerable to Spectre, Meltdown, Foreshadow, ZombieLoad, etc.
# Should ONLY be used in completely trusted environments
# - Improves performance by 7-43%
"mitigation=off"
"intel_pstate=performance" # Intel CPU-specific performance mode (if applicable)
# Memory Management
"transparent_hugepage=always" # Use larger memory pages for memory intense applications
"nmi_watchdog=0" # Disable NMI watchdog for reduced CPU overhead and realtime execution
# High-throughput ethernet parameters
"pcie_aspm=off" # Disable PCIe power management for NICs
"intel_iommu=off" # Disable IOMMU (performance gain)
# Reliability
"panic=10" # Auto-reboot 10 seconds after kernel panic
"oops=panic" # Treat kernel oops as panic for auto-recovery
"usbcore.autosuspend=-1" # Possibly fixes dre disconnect issue?
```
We should check if its really required to turn mitigations off as well.
## Rust Code Optimizations
In General we try to avoid memory allocations in our loop as much as possible, as it is not deterministic
One problem we found, is that due to the realtime kernel our ethernet driver caused us quite large spikes in our tx_rx cycle time for EtherCAT.
These spikes happen when CPU Load is very high on the two usable cores, causing the interrupt handler for Ethernet to stall.
Which is why we reassign the interrupt handler on startup to the same core that the EtherCatTxRx Thread runs on:
```rust
#[cfg(target_os = "linux")]
match set_irq_affinity(&interface, 3) {
Ok(_) => tracing::info!("ethernet interrupt handler now runs on cpu:{}", 3),
Err(e) => tracing::error!("set_irq_affinity failed: {:?}", e),
}
// Set core affinity to 4th core
let _ = set_core_affinity(3);
// Set the thread to real-time priority
let _ = set_realtime_priority();
```
This means that the Interrupt Handler ALWAYS has enough ressources to cope with our demand for realtime communication with EtherCAT
One Problem we have at the moment (we think) is the locking and async code blocking our server loop.
Note that this doesnt affect the EtherCAT communication at all.
## Electron Optimizations (Not Yet Implemented)
Currently we are struggling with the fact, that apparently Electron doesnt really have good support for HW acceleration on Linux.
Electron runs with the software-renderer, which is VERY slow compared to a gpu.
This would eliminate the weird stuttering seen on Graphs and make them run buttery smooth, while also alleviating load off of the CPU.
+127
View File
@@ -115,3 +115,130 @@ pub fn (
}
}
```
# Device Implementation Guide
This section describes how to implement EtherCAT devices in the framework used by the QiTech Control project (via `ethercat-hal`, `server`, etc.). We include a running example of a simple digital output device (EL2004) and discuss how it fits into the larger architecture.
## Architecture & Context
The QiTech Control system is built as:
- `ethercat-hal` / `ethercat-hal-derive`: Hardware abstraction, PDO / CoE handling, device traits.
- `server`: Glue code that reads device identities, instantiates the correct device types, performs CoE configuration, and runs cyclic process data.
- The `docs` directory documents APIs and device patterns.
:contentReference[oaicite:0]{index=0}
In this architecture:
1. During initialization, subdevice identity tuples (Vendor ID, Product ID, Revision) are read.
2. The `server` matches identity tuples to concrete `Device` types and constructs them (via `NewEthercatDevice`).
3. The device instance then handles PDO exchange, and optional CoE (configuration) writes.
4. Higher-level application code interacts with devices via traits like `DigitalOutputDevice`, `DigitalInputDevice`, etc.
---
## Example: Simple Digital Output Device (EL2004)
Below is a minimal example of a digital-output-only EtherCAT device. It shows how to map PDOs, implement traits, and define identity constants.
```rust
use super::{EthercatDeviceProcessing, NewEthercatDevice, SubDeviceIdentityTuple};
use crate::helpers::ethercrab_types::EthercrabSubDevicePreoperational;
use crate::io::digital_output::{DigitalOutputDevice, DigitalOutputOutput};
use crate::pdo::{RxPdo, basic::BoolPdoObject};
use ethercat_hal_derive::{EthercatDevice, RxPdo};
/// EL2004 4-channel digital output device — 24 V DC, 0.5 A per channel
#[derive(EthercatDevice)]
pub struct EL2004 {
/// The Rx PDO where output bits are written by the master
pub rxpdo: EL2004RxPdo,
is_used: bool,
}
impl EthercatDeviceProcessing for EL2004 {}
impl std::fmt::Debug for EL2004 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "EL2004")
}
}
impl NewEthercatDevice for EL2004 {
fn new() -> Self {
Self {
rxpdo: EL2004RxPdo::default(),
is_used: false,
}
}
}
impl DigitalOutputDevice<EL2004Port> for EL2004 {
fn set_output(&mut self, port: EL2004Port, value: DigitalOutputOutput) {
let expect_text = "All channels should be Some(_)";
match port {
EL2004Port::DO1 => {
self.rxpdo.channel1.as_mut().expect(expect_text).value = value.into()
}
EL2004Port::DO2 => {
self.rxpdo.channel2.as_mut().expect(expect_text).value = value.into()
}
EL2004Port::DO3 => {
self.rxpdo.channel3.as_mut().expect(expect_text).value = value.into()
}
EL2004Port::DO4 => {
self.rxpdo.channel4.as_mut().expect(expect_text).value = value.into()
}
}
}
fn get_output(&self, port: EL2004Port) -> DigitalOutputOutput {
let expect_text = "All channels should be Some(_)";
DigitalOutputOutput(match port {
EL2004Port::DO1 => self.rxpdo.channel1.as_ref().expect(expect_text).value,
EL2004Port::DO2 => self.rxpdo.channel2.as_ref().expect(expect_text).value,
EL2004Port::DO3 => self.rxpdo.channel3.as_ref().expect(expect_text).value,
EL2004Port::DO4 => self.rxpdo.channel4.as_ref().expect(expect_text).value,
})
}
}
#[derive(Debug, Clone)]
pub enum EL2004Port {
DO1,
DO2,
DO3,
DO4,
}
#[derive(Debug, Clone, RxPdo)]
pub struct EL2004RxPdo {
#[pdo_object_index(0x1600)]
pub channel1: Option<BoolPdoObject>,
#[pdo_object_index(0x1601)]
pub channel2: Option<BoolPdoObject>,
#[pdo_object_index(0x1602)]
pub channel3: Option<BoolPdoObject>,
#[pdo_object_index(0x1603)]
pub channel4: Option<BoolPdoObject>,
}
impl Default for EL2004RxPdo {
fn default() -> Self {
Self {
channel1: Some(BoolPdoObject::default()),
channel2: Some(BoolPdoObject::default()),
channel3: Some(BoolPdoObject::default()),
channel4: Some(BoolPdoObject::default()),
}
}
}
/// Identity constants for EL2004
pub const EL2004_VENDOR_ID: u32 = 0x2;
pub const EL2004_PRODUCT_ID: u32 = 131346514;
pub const EL2004_REVISION_A: u32 = 1179648;
pub const EL2004_IDENTITY_A: SubDeviceIdentityTuple =
(EL2004_VENDOR_ID, EL2004_PRODUCT_ID, EL2004_REVISION_A);
+40
View File
@@ -0,0 +1,40 @@
# Ethercat Tab shows no Ethercat Terminals!
When the Ethercat page does not show any terminals, this can be due to multiple reasons
## 1: Incorrect Connection of EK1100
Check that the computers ethernet cable is properly connected to the EK1100 IN port
Check the cabling if more than one EK1100 is used.
When you have multiple EK the connection should be like this:
Master (Your pc) -> Ethernet cable -> IN EK1100 (A)
OUT EK1100 (A) -> Ethernet cable -> IN EK1100 (B)
If you connected any part of this incorrectly the ethercat software will try to communicate but will fail, because the protocol works only if all devices are connect in series.
## 2: Defective EtherCAT Terminal
If a Terminal is somehow defective it can disrupt the communication flow expected by EtherCAT thus causing no devices to show up, as ethercat requires all terminals to work properly.
To identify the defective terminal start unplugging terminals starting from the End terminal. Working your way backwards. Every time you unplug a terminal restart the backend process.
You know that you have removed the defective terminal when the ethercat tab shows devices after your restart.
Replace that terminal with an appropriate one and repeat this if the error persists.
## 3: Corrupt Firmware on EtherCAT Terminal
Another reason, that an Ethercat Terminal doesnt work properly, is that the firmware is corrupt. This could happen due to overvoltage,undervoltage or shorts, in some cases this is recoverable by reflashing the firmware.
Our repository contains dumps for all of the EtherCAT Terminals, that we have used. To upload the firmware you can follow this guide: https://infosys.beckhoff.com/english.php?content=../content/1033/ethercatsystem/402909835.html&id=
To follow this guide, you need to have TwinCat installed and a License.
# Motor does not start!
If the Motor does not start after trying to talk to the inverter, the most common reason is that the inverter's serial connection settings (baudrate,encoding ...) are incorrect.
The correct settings can be found in mitsubishi_inverter.md under `All Settings needed for the Inverter Communication/Operation with Extruder`
+144 -24
View File
@@ -15,7 +15,7 @@
"@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-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.3",
@@ -24,7 +24,7 @@
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.2",
"@tanstack/react-query": "^5.84.2",
"@tanstack/react-router": "^1.105.0",
"@tanstack/react-router": "^1.130.17",
"@tanstack/react-table": "^8.21.2",
"@tanstack/router-devtools": "^1.105.0",
"@tanstack/zod-adapter": "^1.116.0",
@@ -43,6 +43,7 @@
"react-hook-form": "^7.57.0",
"react-i18next": "^15.4.1",
"react-markdown": "^10.1.0",
"react-virtualized": "^9.22.6",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.1",
@@ -77,8 +78,9 @@
"@types/electron-squirrel-startup": "^1.0.2",
"@types/eslint-config-prettier": "^6.11.3",
"@types/node": "^24.2.1",
"@types/react": "^19.0.10",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.0.4",
"@types/react-virtualized": "^9.22.2",
"electron": "^37.3.1",
"electron-playwright-helpers": "^1.7.1",
"eslint": "^9.20.1",
@@ -2655,18 +2657,19 @@
}
},
"node_modules/@radix-ui/react-navigation-menu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.13.tgz",
"integrity": "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
"integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
@@ -2689,6 +2692,63 @@
}
}
},
"node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz",
@@ -3964,13 +4024,14 @@
}
},
"node_modules/@tanstack/react-router": {
"version": "1.130.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.130.12.tgz",
"integrity": "sha512-7BYgOpGc1vK8MH1LIFLLBudGpH46GQy+hewnP7dNQJ4KHmkwPHv958L1IMA9jU/rs5g1ZH5n1f33BAMOBXUMYQ==",
"version": "1.130.17",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.130.17.tgz",
"integrity": "sha512-F1tyMEbX7PJclnA25hAFXhLKq4jqeODvvaogsd0/NX02Kmz3QoT9GV+rgpv2TxamU6RwjRgihSH06Dn5hM6SOw==",
"license": "MIT",
"dependencies": {
"@tanstack/history": "1.130.12",
"@tanstack/react-store": "^0.7.0",
"@tanstack/router-core": "1.130.12",
"@tanstack/router-core": "1.130.17",
"isbot": "^5.1.22",
"tiny-invariant": "^1.3.3",
"tiny-warning": "^1.0.3"
@@ -4044,9 +4105,10 @@
}
},
"node_modules/@tanstack/router-core": {
"version": "1.130.12",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.130.12.tgz",
"integrity": "sha512-emq3cRU9Na1hnIToojzkfJcOZm/MG2bv9M+Kr/elUxEf83enGEwQXC1EKezTuwNgeJrOv8vPJdEhWM7IQodnHQ==",
"version": "1.130.17",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.130.17.tgz",
"integrity": "sha512-0AH4s/52X2shYLifr9hR+6R6NpGsBp1M3LyjBTZm6Jlkud+nOhkkRxKxYS7l+JErRlffdxVHxMdoTnKcQ+duiQ==",
"license": "MIT",
"dependencies": {
"@tanstack/history": "1.130.12",
"@tanstack/store": "^0.7.0",
@@ -4442,10 +4504,18 @@
"undici-types": "~7.10.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"version": "19.1.16",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.16.tgz",
"integrity": "sha512-WBM/nDbEZmDUORKnh5i1bTnAz6vTohUf9b8esSMu+b24+srbaxa04UbJgWx78CVfNXA20sNu0odEIluZDFdCog==",
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4459,6 +4529,17 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/react-virtualized": {
"version": "9.22.2",
"resolved": "https://registry.npmjs.org/@types/react-virtualized/-/react-virtualized-9.22.2.tgz",
"integrity": "sha512-0Eg/ME3OHYWGxs+/n4VelfYrhXssireZaa1Uqj5SEkTpSaBu5ctFGOCVxcOqpGXRiEdrk/7uho9tlZaryCIjHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"@types/react": "*"
}
},
"node_modules/@types/responselike": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
@@ -6295,6 +6376,16 @@
"dev": true,
"peer": true
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -9917,7 +10008,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@@ -11301,7 +11391,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -12087,7 +12176,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@@ -12097,8 +12185,7 @@
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/property-information": {
"version": "7.1.0",
@@ -12226,6 +12313,12 @@
"dev": true,
"peer": true
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -12326,6 +12419,33 @@
}
}
},
"node_modules/react-virtualized": {
"version": "9.22.6",
"resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.6.tgz",
"integrity": "sha512-U5j7KuUQt3AaMatlMJ0UJddqSiX+Km0YJxSqbAzIiGw5EmNz0khMyqP2hzgu4+QUtm+QPIrxzUX4raJxmVJnHg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.7.2",
"clsx": "^1.0.4",
"dom-helpers": "^5.1.3",
"loose-envify": "^1.4.0",
"prop-types": "^15.7.2",
"react-lifecycles-compat": "^3.0.4"
},
"peerDependencies": {
"react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-virtualized/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/read-binary-file-arch": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
+5 -3
View File
@@ -42,8 +42,9 @@
"@types/electron-squirrel-startup": "^1.0.2",
"@types/eslint-config-prettier": "^6.11.3",
"@types/node": "^24.2.1",
"@types/react": "^19.0.10",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.0.4",
"@types/react-virtualized": "^9.22.2",
"electron": "^37.3.1",
"electron-playwright-helpers": "^1.7.1",
"eslint": "^9.20.1",
@@ -71,7 +72,7 @@
"@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-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.3",
@@ -80,7 +81,7 @@
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.2",
"@tanstack/react-query": "^5.84.2",
"@tanstack/react-router": "^1.105.0",
"@tanstack/react-router": "^1.130.17",
"@tanstack/react-table": "^8.21.2",
"@tanstack/router-devtools": "^1.105.0",
"@tanstack/zod-adapter": "^1.116.0",
@@ -99,6 +100,7 @@
"react-hook-form": "^7.57.0",
"react-i18next": "^15.4.1",
"react-markdown": "^10.1.0",
"react-virtualized": "^9.22.6",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

@@ -416,6 +416,29 @@ Then start the machine as follows:
<br>
**Understanding Padding**
The **padding** parameter creates a safety margin that prevents the filament from winding too close to the spool edges. When you set the inner and outer limits during calibration, the padding value is automatically subtracted from the actual travel distance.
**How padding works:**
- When you have a 1.75mm diameter filament and the traverse moves to a flank position, the **center** of the filament aligns with that flank
- If you set 1mm padding, the system automatically maintains at least 1mm distance from the flanks
- This prevents the filament from overlapping or winding beyond the spool edges
**Example:**
- Outer limit: 90mm, Inner limit: 22mm
- Padding: 1mm
- **Actual winding area:** 89mm to 23mm (reduced by 1mm on each side)
- This ensures the filament stays within the spool boundaries with a safe margin
The default padding is 0.88mm, but you can adjust this value in the Control settings based on your filament diameter and winding requirements. Larger filaments may require more padding, while smaller filaments can use less padding for maximum spool utilization.
![Padding example](images/manuals/winder/winder_padding_example_image.jpg)
<br>
**4. Start pulling**
- Select “Control” in the function bar
+2 -7
View File
@@ -58,15 +58,10 @@ export function MarkdownWithToc({
className={`sticky ${stickyTop}`}
style={{ maxHeight: "calc(100vh - 6rem)" }}
>
{/* Hide scrollbar for the TOC container across browsers */}
<style>{`
[data-toc-scroll-container] { -ms-overflow-style: none; scrollbar-width: none; }
[data-toc-scroll-container]::-webkit-scrollbar { display: none; }
`}</style>
{/* Table of Contents with hidden scrollbar */}
<div
className="overflow-y-auto"
className="scrollbar-hidden overflow-y-auto"
style={{ maxHeight: "calc(100vh - 8rem)" }}
data-toc-scroll-container
>
<MarkdownToc
markdownContent={markdownContent}
+78 -123
View File
@@ -1,20 +1,32 @@
import { cva } from "class-variance-authority";
import React, { useEffect, useRef, useState } from "react";
import { Icon } from "./Icon";
import {
List,
CellMeasurer,
CellMeasurerCache,
ListRowRenderer,
AutoSizer,
} from "react-virtualized";
// Create cache (outside the component so it persists across renders)
const cache = new CellMeasurerCache({
defaultHeight: 20,
fixedWidth: true,
});
type Props = {
lines: string[];
autoScroll?: boolean; // Optional prop to control if terminal should auto-scroll
className?: string; // Optional prop to control terminal height
autoScroll?: boolean;
className?: string;
title?: string;
exportPrefix?: string; // Optional prefix for exported log files
exportPrefix?: string;
};
const terminalStyle = cva([
"flex flex-col overflow-hidden rounded-md border border-neutral-700 font-mono text-sm",
]);
// Color mapping for ANSI color codes
const colorMap: Record<string, string> = {
"30": "text-gray-900",
"31": "text-red-600",
@@ -32,7 +44,6 @@ const colorMap: Record<string, string> = {
"95": "text-purple-400",
"96": "text-cyan-400",
"97": "text-white",
// Background colors
"40": "bg-gray-900",
"41": "bg-red-600",
"42": "bg-green-600",
@@ -43,36 +54,26 @@ const colorMap: Record<string, string> = {
"47": "bg-gray-200",
};
// Parse ANSI color codes in text
const parseColorCodes = (text: string) => {
// Split by ANSI escape sequences
// eslint-disable-next-line no-control-regex
/* eslint-disable-next-line no-control-regex */
const parts = text.split(/(\x1b\[\d+m)/g);
let currentClass = "";
const result: { text: string; className: string }[] = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.startsWith("\x1b[")) {
// This is a color code
const code = part.slice(2, -1); // Extract the number from \x1b[XXm
const code = part.slice(2, -1);
currentClass = colorMap[code] || "";
} else if (part) {
// This is text content
result.push({ text: part, className: currentClass });
}
}
return result;
};
// Function to strip ANSI color codes for plain text copy
const stripColorCodes = (text: string): string => {
const stripColorCodes = (text: string): string =>
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\[\d+m/g, "");
};
text.replace(/\x1b\[\d+m/g, "");
export function Terminal({
lines,
@@ -81,99 +82,88 @@ export function Terminal({
title = "Terminal",
exportPrefix,
}: Props) {
const terminalRef = useRef<HTMLDivElement>(null);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
const listRef = useRef<List>(null);
const [copySuccess, setCopySuccess] = useState(false);
const [exportSuccess, setExportSuccess] = useState(false);
// Handle scrolling
// Auto-scroll to bottom when new lines arrive
useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) return;
// If auto-scroll is enabled and user was at bottom, scroll to bottom when lines change
if (autoScroll && isScrolledToBottom) {
terminal.scrollTop = terminal.scrollHeight;
if (autoScroll && listRef.current) {
listRef.current.scrollToRow(lines.length - 1);
}
}, [lines, autoScroll, isScrolledToBottom]);
}, [lines, autoScroll]);
// Handle scroll events to detect if user is at bottom
const handleScroll = () => {
const terminal = terminalRef.current;
if (!terminal) return;
const isAtBottom =
Math.abs(
terminal.scrollHeight - terminal.clientHeight - terminal.scrollTop,
) < 10; // Small threshold to account for rounding errors
setIsScrolledToBottom(isAtBottom);
};
// Handle copy to clipboard
const handleCopy = async () => {
// Strip ANSI color codes and join lines
const plainText = lines.map((line) => stripColorCodes(line)).join("\n");
const plainText = lines.map(stripColorCodes).join("\n");
try {
await navigator.clipboard.writeText(plainText);
setCopySuccess(true);
// Reset copy success message after 2 seconds
setTimeout(() => {
setCopySuccess(false);
}, 2000);
setTimeout(() => setCopySuccess(false), 2000);
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
// 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 plainText = lines.map(stripColorCodes).join("\n");
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);
setTimeout(() => setExportSuccess(false), 2000);
};
const rowRenderer: ListRowRenderer = ({ index, key, parent, style }) => {
const colorParts = parseColorCodes(lines[index] || "");
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
<div
style={style}
className="bg-black px-2 py-1 whitespace-pre-wrap text-white"
>
{colorParts.length > 0
? colorParts.map((part, partIndex) => (
<span
key={partIndex}
className={part.className || "text-white"}
>
{part.text || " "}
</span>
))
: lines[index] || " "}
</div>
</CellMeasurer>
);
};
return (
<div className={terminalStyle({ className })}>
{/* Terminal header */}
{/* 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>
<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 ? (
<>
@@ -191,7 +181,6 @@ export function Terminal({
<button
onClick={handleExport}
className="flex items-center text-xs text-neutral-400 transition-colors hover:text-neutral-200"
title="Export logs to file"
>
{exportSuccess ? (
<>
@@ -209,62 +198,28 @@ export function Terminal({
</div>
</div>
{/* Terminal content with custom scrollbar styling */}
<div
ref={terminalRef}
onScroll={handleScroll}
className={`scrollbar-thin scrollbar-thumb-neutral-600 scrollbar-track-transparent flex-grow overflow-y-auto bg-neutral-900 p-4 text-neutral-300`}
style={{
scrollbarWidth: "thin",
scrollbarColor: "rgb(82 82 91) transparent",
}}
>
<style>{`
Add commentMore actions
/* For Webkit browsers (Chrome, Safari) */
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: rgb(82 82 91);
border-radius: 3px;
}
/* For Firefox */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgb(82 82 91) transparent;
}
`}</style>
{lines.map((line, index) => {
const colorParts = parseColorCodes(line);
return (
<div key={index} className="whitespace-pre-wrap">
{colorParts.length > 0
? colorParts.map((part, partIndex) => (
<span key={partIndex} className={part.className}>
{part.text || " "}
</span>
))
: line || " "}
</div>
);
})}
{/* Virtualized List */}
<div className="flex-grow overflow-y-auto bg-neutral-900 p-4 text-neutral-300">
<AutoSizer>
{({ width, height }) => (
<List
ref={listRef}
width={width}
height={height}
deferredMeasurementCache={cache}
rowHeight={cache.rowHeight}
rowCount={lines.length}
rowRenderer={rowRenderer}
overscanRowCount={5}
/>
)}
</AutoSizer>
</div>
{/* Status bar */}
{/* Footer */}
<div className="flex items-center justify-between bg-neutral-800 px-4 py-1 text-xs text-neutral-400">
<div>{lines.length} lines</div>
<div>
{isScrolledToBottom ? "At bottom" : "Scrolled up"} |
{autoScroll ? " Auto-scroll enabled" : " Auto-scroll disabled"}
</div>
<div>{autoScroll ? "Auto-scroll enabled" : "Auto-scroll disabled"}</div>
</div>
</div>
);
@@ -474,6 +474,7 @@ export function BigGraph({
isLiveMode,
selectedTimeWindow,
visibleSeries, // Keep for initial creation
showFromTimestamp: syncGraph?.showFromTimestamp,
setSelectedTimeWindow,
setViewMode,
setIsLiveMode,
@@ -2,6 +2,7 @@ import React, { useState } from "react";
import { TouchButton } from "@/components/touch/TouchButton";
import { Icon } from "@/components/Icon";
import { ControlCard } from "@/control/ControlCard";
import { TimeInput } from "@/components/time/TimeInput";
import {
DropdownMenu,
DropdownMenuContent,
@@ -21,8 +22,14 @@ export function GraphControls({
onSwitchToHistorical,
onExport,
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
showFromTimestamp,
onShowFromChange,
}: ControlProps) {
const getSelectedTimeWindowLabel = () => {
// If Show from Time is set, display "Show All" for the time window
if (showFromTimestamp) {
return "Show All";
}
const option = timeWindowOptions.find((opt) => opt.value === timeWindow);
return option ? option.label : "1m";
};
@@ -35,7 +42,10 @@ export function GraphControls({
<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"
className={`h-auto border-gray-300 bg-white px-3 py-3 text-base text-gray-900 hover:bg-gray-50 ${
showFromTimestamp ? "cursor-not-allowed opacity-50" : ""
}`}
disabled={!!showFromTimestamp}
>
{getSelectedTimeWindowLabel()}
<Icon name="lu:ChevronDown" className="ml-2 size-4" />
@@ -60,6 +70,15 @@ export function GraphControls({
</DropdownMenuContent>
</DropdownMenu>
{/* Show From Time Input - only visible in historical mode */}
{!isLiveMode && onShowFromChange && (
<TimeInput
timestamp={showFromTimestamp}
onTimeChange={onShowFromChange}
onClear={() => onShowFromChange(null)}
/>
)}
<TouchButton
onClick={onSwitchToHistorical}
variant="outline"
@@ -109,10 +128,16 @@ export function FloatingControlPanel({
onSwitchToHistorical,
onExport,
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
showFromTimestamp,
onShowFromChange,
}: ControlProps) {
const [isExpanded, setIsExpanded] = useState(false);
const getSelectedTimeWindowLabel = () => {
// If Show from Time is set, display "Show All" for the time window
if (showFromTimestamp) {
return "Show All";
}
const option = timeWindowOptions.find((opt) => opt.value === timeWindow);
return option ? option.label : "1m";
};
@@ -134,7 +159,10 @@ export function FloatingControlPanel({
<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"
className={`h-auto border-gray-300 bg-white px-3 py-3 text-base text-gray-900 hover:bg-gray-50 ${
showFromTimestamp ? "cursor-not-allowed opacity-50" : ""
}`}
disabled={!!showFromTimestamp}
>
{getSelectedTimeWindowLabel()}
<Icon name="lu:ChevronDown" className="ml-2 size-4" />
@@ -159,6 +187,15 @@ export function FloatingControlPanel({
</DropdownMenuContent>
</DropdownMenu>
{/* Show From Time Input - only visible in historical mode */}
{!isLiveMode && onShowFromChange && (
<TimeInput
timestamp={showFromTimestamp}
onTimeChange={onShowFromChange}
onClear={() => onShowFromChange(null)}
/>
)}
<TouchButton
onClick={onSwitchToHistorical}
variant="outline"
+6 -7
View File
@@ -31,10 +31,8 @@ export function createChart({
setCursorValue,
setCursorValues,
visibleSeries,
}: CreateChartParams & {
setCursorValues?: React.Dispatch<React.SetStateAction<(number | null)[]>>;
visibleSeries?: boolean[];
}): (() => void) | undefined {
showFromTimestamp,
}: CreateChartParams): (() => void) | undefined {
if (!containerRef.current) return;
// Get ALL series from original data
@@ -73,7 +71,7 @@ export function createChart({
initialMin = manualScaleRef.current.x.min;
initialMax = manualScaleRef.current.x.max;
} else if (selectedTimeWindow === "all") {
initialMin = fullStart;
initialMin = showFromTimestamp ?? fullStart;
initialMax = getHistoricalEndTimestamp();
} else {
const endTimestamp = getHistoricalEndTimestamp();
@@ -83,9 +81,10 @@ export function createChart({
endTimestamp - (selectedTimeWindow as number),
fullStart,
);
initialMin = defaultViewStart;
initialMin = showFromTimestamp ?? defaultViewStart;
} else {
initialMin = endTimestamp - (selectedTimeWindow as number);
initialMin =
showFromTimestamp ?? endTimestamp - (selectedTimeWindow as number);
}
initialMax = endTimestamp;
+6
View File
@@ -10,6 +10,7 @@ export type PropGraphSync = {
isLiveMode: boolean;
xRange?: { min: number; max: number };
historicalFreezeTimestamp?: number | null;
showFromTimestamp?: number | null;
onTimeWindowChange?: (graphId: string, timeWindow: number | "all") => void;
onViewModeChange?: (
graphId: string,
@@ -81,6 +82,8 @@ export type ControlProps = {
onSwitchToHistorical: () => void;
onExport?: () => void;
timeWindowOptions?: TimeWindowOption[];
showFromTimestamp?: number | null;
onShowFromChange?: (timestamp: number | null) => void;
};
export interface LiveModeHandlers {
@@ -128,6 +131,9 @@ export interface CreateChartParams {
>;
setIsLiveMode: React.Dispatch<React.SetStateAction<boolean>>;
setCursorValue: React.Dispatch<React.SetStateAction<number | null>>;
setCursorValues: React.Dispatch<React.SetStateAction<(number | null)[]>>;
visibleSeries: boolean[];
showFromTimestamp?: number | null;
}
export interface AnimationState {
@@ -41,6 +41,7 @@ interface UseBigGraphEffectsProps {
isLiveMode: boolean;
selectedTimeWindow: number | "all";
visibleSeries: boolean[];
showFromTimestamp?: number | null;
// State setters
setSelectedTimeWindow: (value: number | "all") => void;
@@ -90,6 +91,7 @@ export function useBigGraphEffects({
isLiveMode,
selectedTimeWindow,
visibleSeries,
showFromTimestamp,
setSelectedTimeWindow,
setViewMode,
@@ -275,6 +277,7 @@ export function useBigGraphEffects({
setCursorValue,
setCursorValues,
visibleSeries,
showFromTimestamp,
});
setIsChartCreated(true);
@@ -15,6 +15,9 @@ export function useGraphSync(exportGroupId?: string) {
const [xRange, setXRange] = useState<
{ min: number; max: number } | undefined
>();
const [showFromTimestamp, setShowFromTimestamp] = useState<number | null>(
null,
);
const historicalFreezeTimestampRef = useRef<number | null>(null);
const graphDataRef = useRef<Map<string, () => GraphExportData | null>>(
@@ -59,6 +62,7 @@ export function useGraphSync(exportGroupId?: string) {
viewMode?: "default" | "all" | "manual";
isLiveMode?: boolean;
xRange?: { min: number; max: number } | undefined;
showFromTimestamp?: number | null;
clearHistoricalFreeze?: boolean;
setHistoricalFreeze?: boolean;
},
@@ -90,6 +94,8 @@ export function useGraphSync(exportGroupId?: string) {
if (updates.viewMode !== undefined) setViewMode(updates.viewMode);
if (updates.isLiveMode !== undefined) setIsLiveMode(updates.isLiveMode);
if (updates.xRange !== undefined) setXRange(updates.xRange);
if (updates.showFromTimestamp !== undefined)
setShowFromTimestamp(updates.showFromTimestamp);
// Handle historical freeze timestamp
if (updates.clearHistoricalFreeze) {
@@ -195,6 +201,7 @@ export function useGraphSync(exportGroupId?: string) {
viewMode: timeWindow === "all" ? "all" : "default",
xRange: undefined,
clearHistoricalFreeze: true,
showFromTimestamp: null, // Clear the show from timestamp when switching to live
});
}, [updateSyncState, timeWindow]);
@@ -206,6 +213,15 @@ export function useGraphSync(exportGroupId?: string) {
});
}, [updateSyncState]);
const handleShowFromChange = useCallback(
(timestamp: number | null) => {
updateSyncState("control", {
showFromTimestamp: timestamp,
});
},
[updateSyncState],
);
const handleControlTimeWindowChange = useCallback(
(newTimeWindow: number | "all") => {
updateSyncState("control", {
@@ -225,6 +241,7 @@ export function useGraphSync(exportGroupId?: string) {
isLiveMode,
xRange,
historicalFreezeTimestamp: historicalFreezeTimestampRef.current,
showFromTimestamp,
onTimeWindowChange: handleTimeWindowChange,
onViewModeChange: handleViewModeChange,
onZoomChange: handleZoomChangeThrottled,
@@ -237,6 +254,8 @@ export function useGraphSync(exportGroupId?: string) {
onSwitchToLive: handleSwitchToLive,
onSwitchToHistorical: handleSwitchToHistorical,
onExport: handleExport,
showFromTimestamp,
onShowFromChange: handleShowFromChange,
};
return {
@@ -250,5 +269,6 @@ export function useGraphSync(exportGroupId?: string) {
isLiveMode,
xRange,
historicalFreezeTimestamp: historicalFreezeTimestampRef.current,
showFromTimestamp,
};
}
+367
View File
@@ -0,0 +1,367 @@
import React, { useState, useCallback, useEffect } from "react";
import { TouchButton } from "@/components/touch/TouchButton";
import { Icon } from "@/components/Icon";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Separator } from "@/components/ui/separator";
type TimeInputProps = {
timestamp?: number | null;
onTimeChange?: (timestamp: number | null) => void;
onClear?: () => void;
};
const timeSchema = z.object({
hours: z.number().min(0).max(23),
minutes: z.number().min(0).max(59),
seconds: z.number().min(0).max(59),
});
type TimeFormData = z.infer<typeof timeSchema>;
export function TimeInput({
timestamp,
onTimeChange,
onClear,
}: TimeInputProps) {
const [isOpen, setIsOpen] = useState(false);
// Get current time for initial values
const getCurrentTime = useCallback(() => {
const now = new Date();
return {
hours: now.getHours(),
minutes: now.getMinutes(),
seconds: now.getSeconds(),
};
}, []);
// Get initial values based on timestamp or current time
const getInitialValues = useCallback(() => {
if (timestamp) {
const date = new Date(timestamp);
return {
hours: date.getHours(),
minutes: date.getMinutes(),
seconds: date.getSeconds(),
};
}
return getCurrentTime();
}, [timestamp, getCurrentTime]);
const form = useForm<TimeFormData>({
resolver: zodResolver(timeSchema),
defaultValues: getInitialValues(),
mode: "all",
});
// Convert timestamp to time components
const timestampToTime = useCallback((ts: number) => {
const date = new Date(ts);
return {
hours: date.getHours(),
minutes: date.getMinutes(),
seconds: date.getSeconds(),
};
}, []);
// Check if the selected time would be in the future (today)
const isTimeInFuture = useCallback((time: TimeFormData) => {
const now = new Date();
const date = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
time.hours,
time.minutes,
time.seconds,
);
return date.getTime() > now.getTime();
}, []);
// Convert time components to timestamp (today's date only)
const timeToTimestamp = useCallback((time: TimeFormData) => {
const now = new Date();
const date = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
time.hours,
time.minutes,
time.seconds,
);
return date.getTime();
}, []);
// Initialize form with timestamp or current time when dialog opens
useEffect(() => {
if (isOpen) {
const initialValues = getInitialValues();
form.reset(initialValues);
}
}, [isOpen, getInitialValues, form]);
const formatTimeDisplay = useCallback(() => {
if (!timestamp) return "Show from Time";
const time = timestampToTime(timestamp);
return `${time.hours.toString().padStart(2, "0")}:${time.minutes.toString().padStart(2, "0")}:${time.seconds.toString().padStart(2, "0")}`;
}, [timestamp, timestampToTime]);
// Increment/decrement functions with bounds checking and cascading
const adjustTimeValue = useCallback(
(field: "hours" | "minutes" | "seconds", increment: boolean) => {
// Define the order of fields for cascading
const fields: ("hours" | "minutes" | "seconds")[] = [
"seconds",
"minutes",
"hours",
];
const startIndex = fields.indexOf(field);
let carry = increment ? 1 : -1;
for (let i = startIndex; i < fields.length; i++) {
const currentField = fields[i];
const currentValue = form.getValues(currentField);
const max = currentField === "hours" ? 23 : 59;
let newValue = currentValue + carry;
carry = 0;
if (increment) {
if (newValue > max) {
newValue = 0;
carry = 1;
}
} else {
if (newValue < 0) {
newValue = max;
carry = -1;
}
}
form.setValue(currentField, newValue, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
// If no carry, stop cascading
if (carry === 0) {
break;
}
}
},
[form],
);
const handleSubmit = () => {
form.handleSubmit((data) => {
// Prevent submission if time is in the future
if (isTimeInFuture(data)) {
return;
}
const newTimestamp = timeToTimestamp(data);
onTimeChange?.(newTimestamp);
setIsOpen(false);
})();
};
const handleAbort = () => {
if (timestamp) {
const time = timestampToTime(timestamp);
form.reset(time);
} else {
const currentTime = getCurrentTime();
form.reset(currentTime);
}
setIsOpen(false);
};
const handleClear = () => {
onClear?.();
setIsOpen(false);
};
// Watch individual form fields for real-time updates
const hours = useWatch({
control: form.control,
name: "hours",
defaultValue: getInitialValues().hours,
});
const minutes = useWatch({
control: form.control,
name: "minutes",
defaultValue: getInitialValues().minutes,
});
const seconds = useWatch({
control: form.control,
name: "seconds",
defaultValue: getInitialValues().seconds,
});
// Get current form values with fallback
const currentValues = {
hours: hours ?? 0,
minutes: minutes ?? 0,
seconds: seconds ?? 0,
};
// TimeValueInput component for individual time fields
const TimeValueInput = ({
field,
label,
value,
}: {
field: "hours" | "minutes" | "seconds";
label: string;
value: number;
}) => (
<div className="flex flex-col items-center gap-3">
<label className="text-sm font-medium text-gray-700">{label}</label>
<div className="flex flex-col items-center gap-2">
<TouchButton
variant="outline"
size="sm"
className="h-10 w-16 p-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
adjustTimeValue(field, true);
}}
>
<Icon name="lu:ChevronUp" className="size-4" />
</TouchButton>
<div className="flex h-16 w-20 items-center justify-center rounded border-2 border-gray-300 bg-white font-mono text-3xl">
{value.toString().padStart(2, "0")}
</div>
<TouchButton
variant="outline"
size="sm"
className="h-10 w-16 p-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
adjustTimeValue(field, false);
}}
>
<Icon name="lu:ChevronDown" className="size-4" />
</TouchButton>
</div>
</div>
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger 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"
>
<Icon name="lu:Clock" className="mr-2 size-4" />
{formatTimeDisplay()}
</TouchButton>
</DialogTrigger>
<DialogContent className="max-w-2xl p-8">
<div className="flex flex-col gap-8">
{/* Header */}
<div className="flex min-h-[2.5rem] items-center justify-between">
<div className="flex items-center gap-3">
<Icon name="lu:Clock" className="size-6" />
<h3 className="text-xl font-semibold">Show from Time</h3>
</div>
{timestamp && (
<TouchButton
variant="ghost"
size="sm"
onClick={handleClear}
className="text-red-600 hover:text-red-700"
>
<Icon name="lu:X" className="mr-2 size-4" />
Clear
</TouchButton>
)}
</div>
{/* Description */}
<div className="-mt-4 text-base text-gray-600">
Set a specific time to display data from that point forward in the
graph.
</div>
{/* Error for future time */}
{isTimeInFuture(currentValues) && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<div className="flex items-start gap-3">
<Icon name="lu:X" className="mt-0.5 size-5 text-red-600" />
<div>
<div className="font-medium text-red-800">
Cannot set future time
</div>
<div className="mt-1 text-sm text-red-700">
The selected time is in the future. Please choose a time
that has already occurred today.
</div>
</div>
</div>
</div>
)}
<Separator />
{/* Time inputs */}
<div className="flex items-center justify-center gap-8">
<TimeValueInput
field="hours"
label="Hours"
value={currentValues.hours}
/>
<div className="mt-8 text-4xl font-bold">:</div>
<TimeValueInput
field="minutes"
label="Minutes"
value={currentValues.minutes}
/>
<div className="mt-8 text-4xl font-bold">:</div>
<TimeValueInput
field="seconds"
label="Seconds"
value={currentValues.seconds}
/>
</div>
<Separator />
{/* Action buttons */}
<div className="flex gap-4">
<TouchButton
variant="outline"
className="h-14 flex-1"
onClick={handleAbort}
>
<Icon name="lu:X" className="mr-2 size-4" />
Cancel
</TouchButton>
<TouchButton
className={`h-14 flex-1 ${
isTimeInFuture(currentValues)
? "cursor-not-allowed bg-gray-300 text-gray-500"
: "bg-blue-600 text-white hover:bg-blue-700"
}`}
onClick={handleSubmit}
disabled={isTimeInFuture(currentValues)}
>
<Icon name="lu:Check" className="mr-2 size-4" />
Apply
</TouchButton>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+7
View File
@@ -33,6 +33,8 @@ export function getUnitIcon(unit: Unit): IconName {
return "lu:Zap";
case "kWh":
return "lu:BatteryFull";
case "l/min":
return "lu:Waves";
default:
return "lu:ChartNoAxesColumn";
}
@@ -68,6 +70,8 @@ export function renderUnitSymbol(unit: Unit | undefined): string {
return "A";
case "kWh":
return "kWh";
case "l/min":
return "l/min";
default:
return "";
}
@@ -125,6 +129,8 @@ export function renderUnitSymbolLong(unit: Unit): string {
return "amperes";
case "kWh":
return "kilowatt-hours";
case "l/min":
return "liters/minute";
default:
return "";
}
@@ -145,6 +151,7 @@ export const units = [
"V",
"A",
"kWh",
"l/min",
] as const;
export type Unit = (typeof units)[number];
@@ -18,17 +18,39 @@ type UpdateExecuteInvokeParams = {
export function exposeUpdateContext() {
const { contextBridge, ipcRenderer } = window.require("electron");
let currentLogListener: ((event: any, log: string) => void) | null = null;
let currentEndListener: ((event: any, params: any) => void) | null = null;
contextBridge.exposeInMainWorld("update", {
execute: (params: UpdateExecuteInvokeParams) =>
ipcRenderer.invoke(UPDATE_EXECUTE, params),
cancel: () => ipcRenderer.invoke(UPDATE_CANCEL),
onLog: (callback: (log: string) => void) =>
ipcRenderer.on(UPDATE_LOG, (_event, log: string) => {
onLog: (callback: (log: string) => void) => {
if (currentLogListener) {
ipcRenderer.removeListener(UPDATE_LOG, currentLogListener);
}
currentLogListener = (_event, log: string) => {
callback(log);
}),
onEnd: (callback: (params: { success: boolean; error?: string }) => void) =>
ipcRenderer.on(UPDATE_END, (_event, params) => {
};
ipcRenderer.on(UPDATE_LOG, currentLogListener);
},
onEnd: (
callback: (params: { success: boolean; error?: string }) => void,
) => {
if (currentEndListener) {
ipcRenderer.removeListener(UPDATE_END, currentEndListener);
}
currentEndListener = (_event, params) => {
callback(params);
}),
};
ipcRenderer.on(UPDATE_END, currentEndListener);
},
});
}
+18 -3
View File
@@ -1,16 +1,28 @@
import type { UpdateInfo } from "@/stores/updateStore";
import { useUpdateStore } from "@/stores/updateStore";
let currentLogListener: ((line: string) => void) | null = null;
export async function updateExecute(
source: UpdateInfo,
onLog: (log: string) => void,
): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve) => {
window.update.onLog(onLog);
// Remove previous listener if exists
if (currentLogListener) {
window.update.onLog(() => {}); // clear previous
currentLogListener = null;
}
currentLogListener = onLog;
window.update.onLog(currentLogListener);
window.update.execute(source);
window.update.onEnd((params) => {
window.update.onLog(() => {});
window.update.onLog(() => {}); // remove listener
window.update.onEnd(() => {});
currentLogListener = null;
resolve(params);
});
});
@@ -40,7 +52,10 @@ export async function updateCancel(): Promise<{
success: boolean;
error?: string;
}> {
return window.update.cancel();
const result = await window.update.cancel();
window.update.onLog(() => {}); // remove listener
window.update.onEnd(() => {}); // remove listener
return result;
}
// Enhanced helper that automatically manages store state
@@ -0,0 +1,207 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
import { useAquapath1 } from "./useAquapath";
import { SelectionGroup } from "@/control/SelectionGroup";
import { EditValue } from "@/control/EditValue";
import { Label } from "@/control/Label";
export function Aquapath1ControlPage() {
const {
state,
defaultState,
front_flow,
back_flow,
front_temperature,
back_temperature,
front_temp_reservoir,
back_temp_reservoir,
setAquapathMode,
setFrontTemperature,
setBackTemperature,
setFrontFlow,
setBackFlow,
} = useAquapath1();
const frontTargetTemperature =
state?.temperature_states?.front.target_temperature ?? 0;
const backTargetTemperature =
state?.temperature_states?.back.target_temperature ?? 0;
const frontTargetFlow = state?.flow_states.front.should_flow ?? false;
const backTargetFlow = state?.flow_states.back.should_flow ?? false;
return (
<Page>
<ControlGrid columns={2}>
<ControlCard title="Flow Measurements">
<div className="grid grid-cols-2 gap-6">
<div className="flex flex-col gap-8">
<TimeSeriesValueNumeric
label="Front Flow"
unit="l/min"
timeseries={front_flow}
renderValue={(value) => value.toFixed(1)}
/>
</div>
<div className="flex flex-col gap-8">
<TimeSeriesValueNumeric
label="Back Flow"
unit="l/min"
timeseries={back_flow}
renderValue={(value) => value.toFixed(1)}
/>
</div>
</div>
<div className="mt-8">
<Label label="Front Pump">
<SelectionGroup<"On" | "Off">
value={frontTargetFlow ? "On" : "Off"}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Off: {
children: "Off",
icon: "lu:CirclePause",
isActiveClassName: "bg-green-600",
className: "h-full",
},
On: {
children: "On",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
className: "h-full",
},
}}
onChange={(value) => {
if (value == "On") {
setFrontFlow(true);
} else {
setFrontFlow(false);
}
}}
/>
</Label>
<Label label="Back Pump">
<SelectionGroup<"On" | "Off">
value={backTargetFlow ? "On" : "Off"}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Off: {
children: "Off",
icon: "lu:CirclePause",
isActiveClassName: "bg-green-600",
className: "h-full",
},
On: {
children: "On",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
className: "h-full",
},
}}
onChange={(value) => {
if (value == "On") {
setBackFlow(true);
} else {
setBackFlow(false);
}
}}
/>
</Label>
</div>
</ControlCard>
<ControlCard title="Temperature Measurements">
<div className="grid grid-cols-2 gap-6">
<div className="flex flex-col gap-8">
<TimeSeriesValueNumeric
label="Front in"
unit="C"
timeseries={front_temperature}
renderValue={(value) => value.toFixed(1)}
/>
<TimeSeriesValueNumeric
label="Front Reservoir"
unit="C"
timeseries={front_temp_reservoir}
renderValue={(value) => value.toFixed(1)}
/>
<Label label="Set Front Target Temperature">
<EditValue
title="Set Front Target Temperature"
value={frontTargetTemperature}
unit="C"
renderValue={(value) => value.toFixed(1)}
onChange={(val) => {
setFrontTemperature(val);
}}
defaultValue={
defaultState?.temperature_states.front.target_temperature
}
/>
</Label>
</div>
<div className="flex flex-col gap-8">
<TimeSeriesValueNumeric
label="Back in"
unit="C"
timeseries={back_temperature}
renderValue={(value) => value.toFixed(1)}
/>
<TimeSeriesValueNumeric
label="Back Reservoir"
unit="C"
timeseries={back_temp_reservoir}
renderValue={(value) => value.toFixed(1)}
/>
<Label label="Set Back Target Temperature">
<EditValue
title="Set Back Target Temperature"
value={backTargetTemperature}
unit="C"
renderValue={(value) => value.toFixed(1)}
onChange={(val) => {
setBackTemperature(val);
}}
defaultValue={
defaultState?.temperature_states.back.target_temperature
}
/>
</Label>
</div>
</div>
</ControlCard>
<ControlCard className="bg-red" title="Mode">
<SelectionGroup<"Standby" | "Auto">
value={state?.mode_state.mode}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Standby: {
children: "Standby",
icon: "lu:CirclePause",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Auto: {
children: "Auto",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
className: "h-full",
},
}}
onChange={(value) => setAquapathMode(value)}
/>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,186 @@
import { Page } from "@/components/Page";
import {
AutoSyncedBigGraph,
SyncedFloatingControlPanel,
useGraphSync,
type GraphConfig,
} from "@/components/graph";
import React from "react";
import { useAquapath1 } from "./useAquapath";
import { TimeSeries } from "@/lib/timeseries";
export function Aquapath1GraphPage() {
const {
state,
front_flow,
back_flow,
front_temperature,
back_temperature,
front_temp_reservoir,
back_temp_reservoir,
} = useAquapath1();
const syncHook = useGraphSync("aquapath-group");
const front_temp_target =
state?.temperature_states?.front.target_temperature ?? 0;
const back_temp_target =
state?.temperature_states?.back.target_temperature ?? 0;
return (
<Page className="pb-27">
<div className="flex flex-col gap-4">
<FlowGraph
syncHook={syncHook}
flow={front_flow}
name={"Front Flow"}
id={"front_flow"}
/>
<FlowGraph
syncHook={syncHook}
flow={back_flow}
name={"Back Flow"}
id={"back_flow"}
/>
<TemperatureGraph
syncHook={syncHook}
temp_in={front_temperature}
temp_out={front_temp_reservoir}
targetTemp={front_temp_target}
name={"Front Temperature"}
id={"front_temp"}
/>
<TemperatureGraph
syncHook={syncHook}
temp_in={back_temperature}
temp_out={back_temp_reservoir}
targetTemp={back_temp_target}
name={"Back Temperature"}
id={"back_temp"}
/>
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
}
export function FlowGraph({
syncHook,
flow,
name,
id,
}: {
syncHook: ReturnType<typeof useGraphSync>;
flow: TimeSeries | null;
name: string;
id: string;
}) {
const config: GraphConfig = {
title: name,
icon: "lu:RotateCcw",
colors: {
primary: "#10b981",
grid: "#e2e8f0",
background: "#ffffff",
},
exportFilename: "flow_data",
};
return (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: flow,
color: "#3b82f6",
}}
unit="l/min"
renderValue={(value) => value.toFixed(1)}
config={config}
graphId={id}
/>
);
}
export function TemperatureGraph({
syncHook,
temp_in,
temp_out,
targetTemp,
name,
id,
}: {
syncHook: ReturnType<typeof useGraphSync>;
temp_in: TimeSeries | null;
temp_out: TimeSeries | null;
targetTemp: number;
name: string;
id: string;
}) {
const config: GraphConfig = {
title: name,
icon: "lu:RotateCcw",
colors: {
primary: "#10b981",
grid: "#e2e8f0",
background: "#ffffff",
},
exportFilename: "temperature_data",
showLegend: true,
lines: [],
};
const combinedData = [
...(temp_in
? [
{
newData: temp_in,
title: "Temperature In",
color: "#3b82f6",
lines: [
{
type: "threshold" as const,
value: targetTemp,
label: "Target Temperature",
color: "#3b82f6",
show: true,
width: 2,
dash: [5, 5],
},
],
},
]
: []),
...(temp_out
? [
{
newData: temp_out,
title: "Temperature Out",
color: "#f87171",
lines: [
{
type: "threshold" as const,
value: targetTemp,
label: "Target Temperature",
color: "#f87171",
show: true,
width: 2,
dash: [5, 5],
},
],
},
]
: []),
];
return (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={combinedData}
unit="C"
renderValue={(value) => value.toFixed(1)}
config={config}
graphId={id}
/>
);
}
@@ -0,0 +1,26 @@
import { Topbar } from "@/components/Topbar";
import { aquapath1SerialRoute } from "@/routes/routes";
import React from "react";
export function Aquapath1Page() {
const { serial } = aquapath1SerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/aquapath1/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:CirclePlay",
},
{
link: "graph",
title: "Graph",
icon: "lu:ChartSpline",
activeLink: "graphs",
},
]}
/>
);
}
@@ -0,0 +1,246 @@
/**
* @file Aquapath1Namespace.ts
* @description TypeScript implementation of Aquapath1 namespace with Zod schema validation.
*/
import { StoreApi } from "zustand";
import { create } from "zustand";
import { z } from "zod";
import {
EventHandler,
eventSchema,
Event,
handleUnhandledEventError,
NamespaceId,
createNamespaceHookImplementation,
ThrottledStoreUpdater,
} from "../../../client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";
import { createTimeSeries, TimeSeries } from "@/lib/timeseries";
// ========== Event Schema Definitions ==========
/**
* Mode enum for Aquapath Machine
*/
export const modeSchema = z.enum(["Standby", "Auto"]);
/**
* Mode state schema
*/
export const modeStateSchema = z.object({
mode: modeSchema,
});
export const tempStateSchema = z.object({
temperature: z.number(),
target_temperature: z.number(),
});
/**
* Cooling states schema
*/
export const tempStatesSchema = z.object({
front: tempStateSchema,
back: tempStateSchema,
});
export const flowStateSchema = z.object({
flow: z.number(),
should_flow: z.boolean(),
});
export const flowStatesSchema = z.object({
front: flowStateSchema,
back: flowStateSchema,
});
/**
* Live values event schema (time-series data)
*/
export const liveValuesEventDataSchema = z.object({
front_flow: z.number(),
back_flow: z.number(),
front_temperature: z.number(),
back_temperature: z.number(),
front_temp_reservoir: z.number(),
back_temp_reservoir: z.number(),
});
/**
* State event schema (consolidated state)
*/
export const stateEventDataSchema = z.object({
is_default_state: z.boolean(),
mode_state: modeStateSchema,
flow_states: flowStatesSchema,
temperature_states: tempStatesSchema,
});
// ========== Event Schemas with Wrappers ==========
export const liveValuesEventSchema = eventSchema(liveValuesEventDataSchema);
export const stateEventSchema = eventSchema(stateEventDataSchema);
// ========== Type Inferences ==========
export type Mode = z.infer<typeof modeSchema>;
export type ModeState = z.infer<typeof modeStateSchema>;
export type LiveValuesEvent = z.infer<typeof liveValuesEventSchema>;
export type StateEvent = z.infer<typeof stateEventSchema>;
export type Aquapath1NamespaceStore = {
// Single state event from server
state: StateEvent | null;
defaultState: StateEvent | null;
front_flow: TimeSeries;
back_flow: TimeSeries;
front_temperature: TimeSeries;
back_temperature: TimeSeries;
front_temp_reservoir: TimeSeries;
back_temp_reservoir: TimeSeries;
};
// Constants for time durations
const TWENTY_MILLISECOND = 20;
const ONE_SECOND = 1000;
const FIVE_SECOND = 5 * ONE_SECOND;
const ONE_HOUR = 60 * 60 * ONE_SECOND;
const { initialTimeSeries: front_temperature, insert: addTemperature1 } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: back_temperature, insert: addTemperature2 } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: front_temp_reservoir, insert: addTempReserv1 } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: back_temp_reservoir, insert: addTempReserv2 } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: front_flow, insert: addFlow1 } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const { initialTimeSeries: back_flow, insert: addFlow2 } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
/**
* Factory function to create a new Aquapath namespace store
* @returns A new Zustand store instance for Aquapath namespace
*/
export const createAquapath1NamespaceStore =
(): StoreApi<Aquapath1NamespaceStore> => {
return create<Aquapath1NamespaceStore>(() => {
return {
state: null,
defaultState: null,
front_temperature: front_temperature,
back_temperature: back_temperature,
front_flow: front_flow,
back_flow: back_flow,
front_temp_reservoir: front_temp_reservoir,
back_temp_reservoir: back_temp_reservoir,
};
});
};
/**
* Creates a message handler for Mock1 namespace events with validation and appropriate caching strategies
* @param store The store to update when messages are received
* @param throttledUpdater Throttled updater for batching updates at 60 FPS
* @returns A message handler function
*/
export function aquapath1MessageHandler(
store: StoreApi<Aquapath1NamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<Aquapath1NamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const eventName = event.name;
// Helper function to update store through buffer
const updateStore = (
updater: (state: Aquapath1NamespaceStore) => Aquapath1NamespaceStore,
) => {
throttledUpdater.updateWith(updater);
};
try {
// State events (latest only)
if (eventName === "StateEvent") {
const stateEvent = stateEventSchema.parse(event);
updateStore((state) => ({
...state,
state: stateEvent,
// only set default state if is_default_state is true
defaultState: stateEvent.data.is_default_state
? stateEvent
: state.defaultState,
}));
}
// Live values events (time-series data)
else if (eventName === "LiveValuesEvent") {
const liveValuesEvent = liveValuesEventSchema.parse(event);
updateStore((state) => ({
...state,
front_temperature: addTemperature1(state.front_temperature, {
value: liveValuesEvent.data.front_temperature,
timestamp: event.ts,
}),
back_temperature: addTemperature2(state.back_temperature, {
value: liveValuesEvent.data.back_temperature,
timestamp: event.ts,
}),
front_flow: addFlow1(state.front_flow, {
value: liveValuesEvent.data.front_flow,
timestamp: event.ts,
}),
back_flow: addFlow2(state.back_flow, {
value: liveValuesEvent.data.back_flow,
timestamp: event.ts,
}),
front_temp_reservoir: addTempReserv1(state.front_temp_reservoir, {
value: liveValuesEvent.data.front_temp_reservoir,
timestamp: event.ts,
}),
back_temp_reservoir: addTempReserv2(state.back_temp_reservoir, {
value: liveValuesEvent.data.back_temp_reservoir,
timestamp: event.ts,
}),
}));
} else {
handleUnhandledEventError(eventName);
}
} catch (error) {
console.error(`Unexpected error processing ${eventName} event:`, error);
throw error;
}
};
}
/**
* Create the Aquapath1 namespace implementation
*/
const useAquapath1NamespaceImplementation =
createNamespaceHookImplementation<Aquapath1NamespaceStore>({
createStore: createAquapath1NamespaceStore,
createEventHandler: aquapath1MessageHandler,
});
export function useAquapath1Namespace(
machine_identification_unique: MachineIdentificationUnique,
): Aquapath1NamespaceStore {
// Generate namespace ID from validated machine ID
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};
// Use the implementation with validated namespace ID
return useAquapath1NamespaceImplementation(namespaceId);
}
@@ -0,0 +1,175 @@
import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { MachineIdentificationUnique } from "@/machines/types";
import { aquapath1 } from "@/machines/properties";
import { aquapath1SerialRoute } from "@/routes/routes";
import { Mode, StateEvent, useAquapath1Namespace } from "./aquapath1Namespace";
import { useMachineMutate as useMachineMutation } from "@/client/useClient";
import { useEffect, useMemo } from "react";
import { produce } from "immer";
import { z } from "zod";
export function useAquapath1() {
const { serial: serialString } = aquapath1SerialRoute.useParams();
// Memoize the machine identification to keep it stable between renders
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
const serial = parseInt(serialString);
if (isNaN(serial)) {
toastError(
"Invalid Serial Number",
`"${serialString}" is not a valid serial number.`,
);
return {
machine_identification: {
vendor: 0,
machine: 0,
},
serial: 0,
};
}
return {
machine_identification: aquapath1.machine_identification,
serial,
};
}, [serialString]);
// Get consolidated state and live values from namespace
const {
state,
defaultState,
front_temperature: front_temperature,
back_temperature: back_temperature,
front_flow: front_flow,
back_flow: back_flow,
front_temp_reservoir: front_temp_reservoir,
back_temp_reservoir: back_temp_reservoir,
} = useAquapath1Namespace(machineIdentification);
// Single optimistic state for all state management
const stateOptimistic = useStateOptimistic<StateEvent>();
// Update optimistic state when real state changes
useEffect(() => {
if (state) {
stateOptimistic.setReal(state);
}
}, [state, stateOptimistic]);
const setAquapathMode = (mode: Mode) => {
updateStateOptimistically(
(current) => {
current.data.mode_state.mode = mode;
},
() =>
requestAquapathMode({
machine_identification_unique: machineIdentification,
data: { SetAquaPathMode: mode },
}),
);
};
const setFrontTemperature = (temperature: number) => {
updateStateOptimistically(
(current) => {
current.data.temperature_states.front.target_temperature = temperature;
},
() =>
requestFrontTemperature({
machine_identification_unique: machineIdentification,
data: { SetFrontTemperature: temperature },
}),
);
};
const setBackTemperature = (temperature: number) => {
updateStateOptimistically(
(current) => {
current.data.temperature_states.back.target_temperature = temperature;
},
() =>
requestBackTemperature({
machine_identification_unique: machineIdentification,
data: { SetBackTemperature: temperature },
}),
);
};
const setFrontFlow = (flow: boolean) => {
updateStateOptimistically(
(current) => {
current.data.flow_states.front.should_flow = flow;
},
() =>
requestFrontFlow({
machine_identification_unique: machineIdentification,
data: { SetFrontFlow: flow },
}),
);
};
const setBackFlow = (flow: boolean) => {
updateStateOptimistically(
(current) => {
current.data.flow_states.back.should_flow = flow;
},
() =>
requestBackFlow({
machine_identification_unique: machineIdentification,
data: { SetBackFlow: flow },
}),
);
};
// Mutation hooks
const { request: requestAquapathMode } = useMachineMutation(
z.object({ SetAquaPathMode: z.enum(["Standby", "Auto"]) }),
);
const { request: requestFrontTemperature } = useMachineMutation(
z.object({ SetFrontTemperature: z.number() }),
);
const { request: requestBackTemperature } = useMachineMutation(
z.object({ SetBackTemperature: z.number() }),
);
const { request: requestFrontFlow } = useMachineMutation(
z.object({ SetFrontFlow: z.boolean() }),
);
const { request: requestBackFlow } = useMachineMutation(
z.object({ SetBackFlow: z.boolean() }),
);
// Helper function for optimistic updates using produce
const updateStateOptimistically = (
producer: (current: StateEvent) => void,
serverRequest: () => void,
) => {
const currentState = stateOptimistic.value;
if (currentState) {
stateOptimistic.setOptimistic(produce(currentState, producer));
}
serverRequest();
};
return {
// Consolidated state
state: stateOptimistic.value?.data,
// Default state for initial values
defaultState: defaultState?.data,
front_flow,
back_flow,
front_temperature,
back_temperature,
front_temp_reservoir,
back_temp_reservoir,
setAquapathMode,
setFrontTemperature,
setBackTemperature,
setFrontFlow,
setBackFlow,
};
}
@@ -5,15 +5,37 @@ import React from "react";
import { useBuffer1 } from "./useBuffer1";
import { ControlCard } from "@/control/ControlCard";
import { SelectionGroup } from "@/control/SelectionGroup";
import { EditValue } from "@/control/EditValue";
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
import { roundToDecimals } from "@/lib/decimal";
import { Label } from "@/control/Label";
import { TouchButton } from "@/components/touch/TouchButton";
import { StatusBadge } from "@/control/StatusBadge";
export function Buffer1ControlPage() {
const { state, setBufferMode } = useBuffer1();
const {
state,
defaultState,
setBufferMode,
setCurrentInputSpeed,
pullerSpeed,
setPullerRegulationMode,
setPullerTargetSpeed,
liftPosition,
gotoLiftHome,
isLoading,
isDisabled,
} = useBuffer1();
const current_input_speed =
state?.current_input_speed_state.current_input_speed ?? 0.0;
const default_speed = 0.0;
return (
<Page>
<ControlGrid>
<ControlCard className="bg-red" title="Mode">
<SelectionGroup<"Standby" | "FillingBuffer" | "EmptyingBuffer">
<SelectionGroup<"Standby" | "Hold" | "Filling" | "Emptying">
value={state?.mode_state.mode}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
@@ -24,13 +46,19 @@ export function Buffer1ControlPage() {
isActiveClassName: "bg-green-600",
className: "h-full",
},
FillingBuffer: {
children: "FillingBuffer",
Hold: {
children: "Hold",
icon: "lu:Pause",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Filling: {
children: "Filling",
icon: "lu:Flame",
isActiveClassName: "bg-green-600",
className: "h-full",
},
EmptyingBuffer: {
Emptying: {
children: "EmptyingBuffer",
icon: "lu:ArrowBigLeftDash",
isActiveClassName: "bg-green-600",
@@ -40,6 +68,83 @@ export function Buffer1ControlPage() {
onChange={setBufferMode}
/>
</ControlCard>
<ControlCard title="Set Current Input Speed - (For Debugging)">
<EditValue
title="Input Speed"
unit="m/min"
value={current_input_speed}
defaultValue={default_speed}
min={0.0}
max={60.0}
step={0.1}
renderValue={(value) => roundToDecimals(value, 1)}
onChange={setCurrentInputSpeed}
/>
</ControlCard>
<ControlCard className="bg-red" height={2} title="Lift">
<TimeSeriesValueNumeric
label="Position"
unit="mm"
timeseries={liftPosition}
renderValue={(value) => roundToDecimals(value, 1)}
/>
<Label label="Home">
<TouchButton
variant="outline"
icon="lu:House"
onClick={() => gotoLiftHome()}
disabled={isDisabled}
isLoading={isLoading || state?.lift_state?.is_going_home}
>
Go to Home
</TouchButton>
{state?.lift_state?.is_homed !== true ? (
<StatusBadge variant={"error"}>{"Not Homed"}</StatusBadge>
) : null}
</Label>
</ControlCard>
<ControlCard className="bg-red" title="Puller">
<TimeSeriesValueNumeric
label="Speed"
unit="m/min"
timeseries={pullerSpeed}
renderValue={(value) => roundToDecimals(value, 1)}
/>
<Label label="Regulation">
<SelectionGroup
value={state?.puller_state?.regulation}
options={{
Speed: {
children: "Speed",
icon: "lu:Gauge",
},
Diameter: {
children: "Diameter",
icon: "lu:Sun",
disabled: true,
},
}}
onChange={setPullerRegulationMode}
disabled={isDisabled}
loading={isLoading}
/>
</Label>
<Label label="Target Speed">
<EditValue
value={state?.puller_state?.target_speed}
unit="m/min"
title="Target Speed"
defaultValue={defaultState?.puller_state?.target_speed}
min={0}
max={75}
step={0.1}
renderValue={(value) => roundToDecimals(value, 1)}
onChange={setPullerTargetSpeed}
/>
</Label>
</ControlCard>
</ControlGrid>
</Page>
);
@@ -16,23 +16,55 @@ import {
ThrottledStoreUpdater,
} from "../../../client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";
import {
createTimeSeries,
TimeSeries,
TimeSeriesValue,
} from "@/lib/timeseries";
// ========== Event Schema Definitions ==========
/**
* Machine operation mode enum
*/
export const modeSchema = z.enum([
"Standby",
"FillingBuffer",
"EmptyingBuffer",
]);
export const modeSchema = z.enum(["Standby", "Hold", "Filling", "Emptying"]);
export type Mode = z.infer<typeof modeSchema>;
/**
* Consolidated live values event schema (30FPS data)
*/
export const liveValuesEventDataSchema = z.object({});
export const liveValuesEventDataSchema = z.object({
lift_position: z.number().nullable(),
puller_speed: z.number(),
});
/**
* Puller regulation type enum
*/
export const pullerRegulationSchema = z.enum(["Speed", "Diameter"]);
export type PullerRegulation = z.infer<typeof pullerRegulationSchema>;
/**
* Puller state event schmema
*/
export const pullerStateSchema = z.object({
regulation: pullerRegulationSchema,
target_speed: z.number(),
target_diameter: z.number(),
forward: z.boolean(),
});
/**
* Lift state schema
*/
export const liftStateSchema = z.object({
is_going_up: z.boolean(),
is_going_down: z.boolean(),
is_homed: z.boolean(),
is_going_home: z.boolean(),
is_buffering: z.boolean(),
step_size: z.number(),
});
/**
* Mode state event schema
@@ -41,6 +73,10 @@ export const modeStateSchema = z.object({
mode: modeSchema,
});
export const currentInputSpeedSchema = z.object({
current_input_speed: z.number(),
});
/**
* Connected machine state scheme
*/
@@ -65,7 +101,10 @@ export const connectedMachineStateSchema = z.object({
export const stateEventDataSchema = z.object({
mode_state: modeStateSchema,
lift_state: liftStateSchema,
puller_state: pullerStateSchema,
connected_machine_state: connectedMachineStateSchema,
current_input_speed_state: currentInputSpeedSchema,
});
// ========== Event Schemas with Wrappers ==========
@@ -80,8 +119,24 @@ export type StateEvent = z.infer<typeof stateEventSchema>;
export type Buffer1NamespaceStore = {
// State events (latest only)
state: StateEvent | null;
defaultState: StateEvent | null;
// Time series data for live values
liftPosition: TimeSeries;
pullerSpeed: TimeSeries;
};
// Constants for time durations
const TWENTY_MILLISECOND = 20;
const ONE_SECOND = 1000;
const FIVE_SECOND = 5 * ONE_SECOND;
const ONE_HOUR = 60 * 60 * ONE_SECOND;
const { initialTimeSeries: pullerSpeed, insert: addPullerSpeed } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: liftPosition, insert: addLiftPosition } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
/**
* Creates a message handler for Buffer1 namespace events with validation and appropriate caching strategies
* @param store The store to update when messages are received
@@ -110,12 +165,41 @@ export function buffer1MessageHandler(
...state,
state: stateEvent,
}));
console.log(stateEvent);
} else if (eventName === "LiveValuesEvent") {
// Parse and validate the live values event
const liveValuesEvent = liveValuesEventSchema.parse(event);
const timestamp = event.ts;
updateStore((state) => ({
...state,
}));
// Extract values and add to time series
const { lift_position, puller_speed } = liveValuesEvent.data;
const timestamp = liveValuesEvent.ts;
updateStore((state) => {
const newState = { ...state };
// Add lift position if not null
if (lift_position !== null) {
const timeseriesValue: TimeSeriesValue = {
value: lift_position,
timestamp,
};
newState.liftPosition = addLiftPosition(
state.liftPosition,
timeseriesValue,
);
}
// Add puller speed
const pullerSpeedValue: TimeSeriesValue = {
value: puller_speed,
timestamp,
};
newState.pullerSpeed = addPullerSpeed(
state.pullerSpeed,
pullerSpeedValue,
);
return newState;
});
} else {
handleUnhandledEventError(eventName);
}
@@ -134,7 +218,13 @@ export const createBuffer1NamespaceStore =
(): StoreApi<Buffer1NamespaceStore> =>
create<Buffer1NamespaceStore>(() => {
return {
// State event from server
state: null,
defaultState: null,
// Time series data for live values
liftPosition,
pullerSpeed,
};
});
@@ -8,7 +8,13 @@ import {
import { buffer1SerialRoute } from "@/routes/routes";
import { z } from "zod";
import { useEffect, useMemo } from "react";
import { StateEvent, Mode, useBuffer1Namespace } from "./buffer1Namespace";
import {
StateEvent,
Mode,
useBuffer1Namespace,
pullerRegulationSchema,
PullerRegulation,
} from "./buffer1Namespace";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { produce } from "immer";
import { useMachines } from "@/client/useMachines";
@@ -45,7 +51,8 @@ export function useBuffer1() {
const machine_identification_unique = machineIdentification;
// Get consolidated state and live values from namespace
const { state } = useBuffer1Namespace(machineIdentification);
const { state, defaultState, liftPosition, pullerSpeed } =
useBuffer1Namespace(machineIdentification);
// Single optimistic state for all state management
const stateOptimistic = useStateOptimistic<StateEvent>();
@@ -122,10 +129,102 @@ export function useBuffer1() {
);
};
const setCurrentInputSpeed = (current_input_speed: number) => {
updateStateOptimistically(
(current) => {
current.data.current_input_speed_state.current_input_speed =
current_input_speed;
},
() =>
requestSetCurrentInputSpeed({
machine_identification_unique,
data: { SetCurrentInputSpeed: current_input_speed },
}),
);
};
const setPullerTargetSpeed = (targetSpeed: number) => {
updateStateOptimistically(
(current) => {
current.data.puller_state.target_speed = targetSpeed;
},
() =>
requestPullerSetTargetSpeed({
machine_identification_unique: machineIdentification,
data: { SetPullerTargetSpeed: targetSpeed },
}),
);
};
const setPullerTargetDiameter = (targetDiameter: number) => {
updateStateOptimistically(
(current) => {
current.data.puller_state.target_diameter = targetDiameter;
},
() =>
requestPullerSetTargetDiameter({
machine_identification_unique: machineIdentification,
data: { SetPullerTargetDiameter: targetDiameter },
}),
);
};
const setPullerRegulationMode = (regulationMode: PullerRegulation) => {
updateStateOptimistically(
(current) => {
current.data.puller_state.regulation = regulationMode;
},
() =>
requestPullerSetRegulationMode({
machine_identification_unique: machineIdentification,
data: { SetPullerRegulationMode: regulationMode },
}),
);
};
const setPullerForward = (forward: boolean) => {
updateStateOptimistically(
(current) => {
current.data.puller_state.forward = forward;
},
() =>
requestPullerSetForward({
machine_identification_unique: machineIdentification,
data: { SetPullerForward: forward },
}),
);
};
const gotoLiftHome = () => {
updateStateOptimistically(
(current) => {
current.data.lift_state.is_going_home = true;
},
() =>
requestLiftGotoHome({
machine_identification_unique: machineIdentification,
data: "GotoLiftHome",
}),
);
};
const setLiftStepSize = (stepSize: number) => {
updateStateOptimistically(
(current) => {
current.data.lift_state.step_size = stepSize;
},
() =>
requestLiftSetStepSize({
machine_identification_unique: machineIdentification,
data: { SetLiftStepSize: stepSize },
}),
);
};
// Mutation hooks
const { request: requestBufferMode } = useMachineMutation(
z.object({
SetBufferMode: z.enum(["Standby", "FillingBuffer", "EmptyingBuffer"]),
SetBufferMode: z.enum(["Standby", "Hold", "Filling", "Emptying"]),
}),
);
@@ -141,6 +240,38 @@ export function useBuffer1() {
}),
);
const { request: requestSetCurrentInputSpeed } = useMachineMutation(
z.object({
SetCurrentInputSpeed: z.number(),
}),
);
const { request: requestPullerSetTargetSpeed } = useMachineMutation(
z.object({ SetPullerTargetSpeed: z.number() }),
);
const { request: requestPullerSetTargetDiameter } = useMachineMutation(
z.object({ SetPullerTargetDiameter: z.number() }),
);
const { request: requestPullerSetRegulationMode } = useMachineMutation(
z.object({
SetPullerRegulationMode: pullerRegulationSchema,
}),
);
const { request: requestPullerSetForward } = useMachineMutation(
z.object({ SetPullerForward: z.boolean() }),
);
const { request: requestLiftGotoHome } = useMachineMutation(
z.literal("GotoLiftHome"),
);
const { request: requestLiftSetStepSize } = useMachineMutation(
z.object({ SetLiftStepSize: z.number() }),
);
// Calculate loading states
const isLoading = stateOptimistic.isOptimistic;
const isDisabled = !stateOptimistic.isInitialized;
// General Helper functions
const machines = useMachines();
// Filter machines for the correct type
@@ -176,11 +307,27 @@ export function useBuffer1() {
filteredMachines,
selectedMachine,
// Default state for initial values
defaultState: defaultState?.data,
// Individual live values (TimeSeries)
liftPosition,
pullerSpeed,
// Loading states
isLoading,
isDisabled,
// Action functions (verb-first)
setBufferMode,
gotoLiftHome,
setLiftStepSize,
setConnectedMachine,
disconnectMachine,
setCurrentInputSpeed,
setPullerTargetSpeed,
setPullerTargetDiameter,
setPullerRegulationMode,
setPullerForward,
};
}
@@ -7,8 +7,8 @@ type DiameterVisualisationProps = {
lowTolerance: number;
highTolerance: number;
diameter: TimeSeries;
x_value?: TimeSeries;
y_value?: TimeSeries;
x_diameter?: TimeSeries;
y_diameter?: TimeSeries;
};
// Ring color depending on tolerance
@@ -36,12 +36,12 @@ export function DiameterVisualisation({
lowTolerance,
highTolerance,
diameter,
x_value,
y_value,
x_diameter,
y_diameter,
}: DiameterVisualisationProps) {
const actualDiameter = diameter.current?.value ?? 0.0;
const actualX = x_value?.current?.value ?? actualDiameter;
const actualY = y_value?.current?.value ?? actualDiameter;
const actualX = x_diameter?.current?.value ?? actualDiameter;
const actualY = y_diameter?.current?.value ?? actualDiameter;
const minDia = targetDiameter - lowTolerance;
const maxDia = targetDiameter + highTolerance;
@@ -55,9 +55,15 @@ export function DiameterVisualisation({
inputMax: number,
outputMin: number,
outputMax: number,
) =>
outputMin +
((value - inputMin) * (outputMax - outputMin)) / (inputMax - inputMin);
) => {
if (inputMax === inputMin) {
return (outputMin + outputMax) / 2;
}
return (
outputMin +
((value - inputMin) * (outputMax - outputMin)) / (inputMax - inputMin)
);
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
@@ -14,8 +14,9 @@ import { DiameterVisualisation } from "../DiameterVisualisation";
export function Laser1ControlPage() {
const {
diameter,
x_value,
y_value,
x_diameter,
y_diameter,
roundness,
state,
defaultState,
setTargetDiameter,
@@ -36,8 +37,8 @@ export function Laser1ControlPage() {
lowTolerance={lowerTolerance}
highTolerance={higherTolerance}
diameter={diameter}
x_value={x_value}
y_value={y_value}
x_diameter={x_diameter}
y_diameter={y_diameter}
/>
<div className="flex flex-row items-center gap-6">
<TimeSeriesValueNumeric
@@ -47,22 +48,31 @@ export function Laser1ControlPage() {
renderValue={(value) => value.toFixed(3)}
/>
</div>
{x_value?.current && (
{x_diameter?.current && (
<div className="flex flex-row items-center gap-6">
<TimeSeriesValueNumeric
label="X-Diameter"
unit="mm"
timeseries={x_value}
timeseries={x_diameter}
renderValue={(value) => value.toFixed(3)}
/>
</div>
)}
{y_value?.current && (
{y_diameter?.current && (
<div className="flex flex-row items-center gap-6">
<TimeSeriesValueNumeric
label="Y-Diameter"
unit="mm"
timeseries={y_value}
timeseries={y_diameter}
renderValue={(value) => value.toFixed(3)}
/>
</div>
)}
{roundness?.current && (
<div className="flex flex-row items-center gap-6">
<TimeSeriesValueNumeric
label="Roundness"
timeseries={roundness}
renderValue={(value) => value.toFixed(3)}
/>
</div>
@@ -10,18 +10,19 @@ import React from "react";
import { useLaser1 } from "./useLaser1";
export function Laser1GraphsPage() {
const { diameter, x_value, y_value, state } = useLaser1();
const { diameter, x_diameter, y_diameter, roundness, state } = useLaser1();
const syncHook = useGraphSync("diameter-group");
const targetDiameter = state?.laser_state?.target_diameter ?? 0;
const lowerTolerance = state?.laser_state?.lower_tolerance ?? 0;
const higherTolerance = state?.laser_state?.higher_tolerance ?? 0;
const isTwoAxis = !!x_value?.current || !!y_value?.current;
const isTwoAxis = !!x_diameter?.current || !!y_diameter?.current;
const diameterColor = "#3b82f6";
const xDiameterColor = "#ef4444";
const yDiameterColor = "#22c55e";
const roundnessColor = "#eab308";
const config: GraphConfig = {
title: "Diameter",
@@ -110,15 +111,20 @@ export function Laser1GraphsPage() {
],
},
{
newData: x_value,
newData: x_diameter,
color: xDiameterColor,
title: "X-Diameter",
},
{
newData: y_value,
newData: y_diameter,
color: yDiameterColor,
title: "Y-Diameter",
},
{
newData: roundness,
color: roundnessColor,
title: "Roundness",
},
]}
unit="mm"
renderValue={(value) => value.toFixed(3)}
@@ -28,8 +28,9 @@ import {
*/
export const liveValuesEventDataSchema = z.object({
diameter: z.number(),
x_value: z.number().nullable(),
y_value: z.number().nullable(),
x_diameter: z.number().nullable(),
y_diameter: z.number().nullable(),
roundness: z.number().nullable(),
});
/**
@@ -59,8 +60,9 @@ export type Laser1NamespaceStore = {
// Time series data for live values
diameter: TimeSeries;
x_value: TimeSeries;
y_value: TimeSeries;
x_diameter: TimeSeries;
y_diameter: TimeSeries;
roundness: TimeSeries;
};
// Constants for time durations
@@ -75,19 +77,19 @@ const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries(
ONE_HOUR,
);
const { initialTimeSeries: x_value, insert: addXValue } = createTimeSeries(
const { initialTimeSeries: x_diameter, insert: addXDiameter } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: y_diameter, insert: addYDiameter } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: roundness, insert: addRoundness } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const { initialTimeSeries: y_value, insert: addYValue } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
/**
* Factory function to create a new Laser1 namespace store
* @returns A new Zustand store instance for Laser1 namespace
@@ -98,8 +100,9 @@ export const createLaser1NamespaceStore = (): StoreApi<Laser1NamespaceStore> =>
state: null,
defaultState: null,
diameter: diameter,
x_value: x_value,
y_value: y_value,
x_diameter,
y_diameter,
roundness: roundness,
};
});
@@ -143,31 +146,43 @@ export function laser1MessageHandler(
value: liveValuesEvent.data.diameter,
timestamp: event.ts,
};
if (liveValuesEvent.data.x_value !== null) {
const xValue: TimeSeriesValue = {
value: liveValuesEvent.data.x_value,
timestamp: event.ts,
};
updateStore((state) => ({
...state,
x_value: addXValue(state.x_value, xValue),
}));
}
if (liveValuesEvent.data.y_value !== null) {
const yValue: TimeSeriesValue = {
value: liveValuesEvent.data.y_value,
timestamp: event.ts,
};
updateStore((state) => ({
...state,
y_value: addYValue(state.y_value, yValue),
}));
}
updateStore((state) => ({
...state,
diameter: addDiameter(state.diameter, diameterValue),
}));
if (liveValuesEvent.data.x_diameter !== null) {
const xValue: TimeSeriesValue = {
value: liveValuesEvent.data.x_diameter,
timestamp: event.ts,
};
updateStore((state) => ({
...state,
x_diameter: addXDiameter(state.x_diameter, xValue),
}));
}
if (liveValuesEvent.data.y_diameter !== null) {
const yValue: TimeSeriesValue = {
value: liveValuesEvent.data.y_diameter,
timestamp: event.ts,
};
updateStore((state) => ({
...state,
y_diameter: addYDiameter(state.y_diameter, yValue),
}));
}
if (liveValuesEvent.data.roundness !== null) {
const roundnessValue: TimeSeriesValue = {
value: liveValuesEvent.data.roundness,
timestamp: event.ts,
};
updateStore((state) => ({
...state,
roundness: addRoundness(state.roundness, roundnessValue),
}));
}
} else {
handleUnhandledEventError(eventName);
}
@@ -11,7 +11,7 @@ import { produce } from "immer";
function useLaser(machine_identification_unique: MachineIdentificationUnique) {
// Get consolidated state and live values from namespace
const { state, defaultState, diameter, x_value, y_value } =
const { state, defaultState, diameter, x_diameter, y_diameter, roundness } =
useLaser1Namespace(machine_identification_unique);
// Single optimistic state for all state management
@@ -110,8 +110,9 @@ function useLaser(machine_identification_unique: MachineIdentificationUnique) {
// Live values (TimeSeries)
diameter,
x_value,
y_value,
x_diameter,
y_diameter,
roundness,
// Loading states
isLoading: stateOptimistic.isOptimistic,
+70
View File
@@ -271,12 +271,82 @@ export const buffer1: MachineProperties = {
},
],
};
export const aquapath1: MachineProperties = {
name: "Aquapath",
version: "V1",
slug: "aquapath1",
icon: "lu:Waves",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0009,
},
device_roles: [
{
role: 0,
role_label: "Bus Coupler",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x44c2c52,
revision: 0x120000,
},
],
},
{
role: 1,
role_label: "EL2008",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x7d83052,
revision: 0x110000,
},
],
},
{
role: 2,
role_label: "EL4002",
allowed_devices: [
{
vendor_id: 2,
product_id: 0xfa23052,
revision: 0x140000,
},
],
},
{
role: 3,
role_label: "EL3204",
allowed_devices: [
{
vendor_id: 2,
product_id: 0xc843052,
revision: 0x160000,
},
],
},
{
role: 4,
role_label: "EL5152",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x14203052,
revision: 0x140000,
},
],
},
],
};
export const machineProperties: MachineProperties[] = [
winder2,
extruder2,
laser1,
mock1,
buffer1,
aquapath1,
];
export const getMachineProperties = (
@@ -44,7 +44,7 @@ export function Winder2SettingPage() {
unit="mm"
step={0.05}
min={0.1}
max={10}
max={75}
defaultValue={defaultState?.traverse_state?.step_size}
renderValue={(value) => roundToDecimals(value, 2)}
onChange={(value) => setTraverseStepSize(value)}
+35 -5
View File
@@ -35,6 +35,10 @@ import { Mock1PresetsPage } from "@/machines/mock/mock1/Mock1PresetsPage";
import { TroubleshootPage } from "@/setup/Trobleshoot";
import { UpdateExecutePage } from "@/setup/UpdateExecutePage";
import { Winder2PresetsPage } from "@/machines/winder/winder2/Winder2PresetsPage";
import { Aquapath1ControlPage } from "@/machines/aquapath/aquapath1/Aquapath1ControlPage";
import { Aquapath1Page } from "@/machines/aquapath/aquapath1/Aquapath1Page";
import { Aquapath1GraphPage } from "@/machines/aquapath/aquapath1/Aquapath1Graph";
import { z } from "zod";
import { Laser1PresetsPage } from "@/machines/laser/laser1/Laser1PresetsPage";
// make a route tree like this
@@ -188,10 +192,22 @@ export const buffer1ControlRoute = createRoute({
component: () => <Buffer1ControlPage />,
});
export const mock1PresetsRoute = createRoute({
getParentRoute: () => mock1SerialRoute,
path: "presets",
component: () => <Mock1PresetsPage />,
export const aquapath1SerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "aquapath1/$serial",
component: () => <Aquapath1Page />,
});
export const aquapath1GraphRoute = createRoute({
getParentRoute: () => aquapath1SerialRoute,
path: "graph",
component: () => <Aquapath1GraphPage />,
});
export const aquapath1ControlRoute = createRoute({
getParentRoute: () => aquapath1SerialRoute,
path: "control",
component: () => <Aquapath1ControlPage />,
});
export const buffer1SettingsRoute = createRoute({
@@ -200,6 +216,12 @@ export const buffer1SettingsRoute = createRoute({
component: () => <Buffer1SettingsPage />,
});
export const mock1PresetsRoute = createRoute({
getParentRoute: () => mock1SerialRoute,
path: "presets",
component: () => <Mock1PresetsPage />,
});
export const setupRoute = createRoute({
getParentRoute: () => sidebarRoute,
path: "setup",
@@ -292,6 +314,11 @@ export const rootTree = RootRoute.addChildren([
laser1PresetsRoute,
]),
aquapath1SerialRoute.addChildren([
aquapath1ControlRoute,
aquapath1GraphRoute,
]),
winder2SerialRoute.addChildren([
winder2ControlRoute,
winder2ManualRoute,
@@ -315,7 +342,10 @@ export const rootTree = RootRoute.addChildren([
mock1PresetsRoute,
]),
buffer1SerialRoute.addChildren([buffer1ControlRoute]),
buffer1SerialRoute.addChildren([
buffer1ControlRoute,
buffer1SettingsRoute,
]),
]),
]),
]);
+3 -7
View File
@@ -3,11 +3,7 @@ import { Page } from "@/components/Page";
import { SectionTitle } from "@/components/SectionTitle";
import { TouchButton } from "@/components/touch/TouchButton";
import React, { useEffect, useState } from "react";
import {
defaultGithubSource,
GithubSource,
GithubSourceDialog,
} from "./GithubSourceDialog";
import { GithubSourceDialog } from "./GithubSourceDialog";
import { LoadingSpinner } from "@/components/LoadingSpinner";
import { Alert } from "@/components/Alert";
import { useNavigate } from "@tanstack/react-router";
@@ -21,6 +17,7 @@ import {
isNixOSAvailable,
} from "@/helpers/nixos_helpers";
import { useUpdateStore } from "@/stores/updateStore";
import { useGithubSourceStore } from "@/stores/githubSourceStore";
export function ChooseVersionPage() {
const navigate = useNavigate();
@@ -54,8 +51,7 @@ export function ChooseVersionPage() {
string | null
>(null);
const [githubSource, setGithubSource] =
useState<GithubSource>(defaultGithubSource);
const { githubSource, setGithubSource } = useGithubSourceStore();
const githubApiUrl = `https://api.github.com/repos/${githubSource.githubRepoOwner}/${githubSource.githubRepoName}`;
+4 -2
View File
@@ -114,11 +114,13 @@ export function GithubSourceDialogContent({
name="githubRepoOwner"
render={({ field }) => (
<FormItem>
<FormLabel>Serial</FormLabel>
<FormLabel>GitHub Repository Owner</FormLabel>
<FormControl>
<Input placeholder="qitechgmbh" {...field} />
</FormControl>
<FormDescription>Serial number of the machine.</FormDescription>
<FormDescription>
Name of the GitHub repository owner.
</FormDescription>
<FormMessage />
</FormItem>
)}
+4 -3
View File
@@ -11,15 +11,16 @@ import { toast } from "sonner";
export function TroubleshootPage() {
const [isRebootLoading, setIsRebootLoading] = useState(false);
const [isRestartLoading, setIsRestartLoading] = useState(false);
const { getLogsBySource, clearLogs } = useLogsStore();
const { getLogsBySource } = useLogsStore();
// Perhaps we just need to clear the logs ?
// Get backend logs for display
const backendLogs = getLogsBySource("qitech-control-server");
const logLines = backendLogs.map((log) => log.raw);
console.log(backendLogs);
const handleRebootHmi = async () => {
setIsRebootLoading(true);
try {
const result = await rebootHmi();
if (result.success) {
+5
View File
@@ -22,6 +22,8 @@ export function UpdateExecutePage() {
startUpdate,
stopUpdate,
addTerminalLine,
clearTerminalLines,
resetUpdateState,
} = useUpdateStore();
// Set update info from search params when component mounts or search changes
@@ -49,6 +51,7 @@ export function UpdateExecutePage() {
};
startUpdate();
// Perhaps we just need to clear the logs ?
const res = await updateExecute(updateInfo, addTerminalLine);
stopUpdate();
@@ -66,12 +69,14 @@ export function UpdateExecutePage() {
const res = await updateCancelWithStore();
if (res.success) {
toast.info("Update cancelled successfully");
clearTerminalLines();
} else {
toast.error("Failed to cancel update: " + res.error);
}
} catch (error: any) {
toast.error("Failed to cancel update: " + error.message);
}
resetUpdateState();
};
return (
+29
View File
@@ -0,0 +1,29 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { GithubSource, defaultGithubSource } from "@/setup/GithubSourceDialog";
export type GithubSourceState = {
githubSource: GithubSource;
};
export type GithubSourceActions = {
setGithubSource: (source: GithubSource) => void;
resetGithubSource: () => void;
};
export type GithubSourceStore = GithubSourceState & GithubSourceActions;
export const useGithubSourceStore = create<GithubSourceStore>()(
persist(
(set) => ({
githubSource: defaultGithubSource,
setGithubSource: (source: GithubSource) => set({ githubSource: source }),
resetGithubSource: () => set({ githubSource: defaultGithubSource }),
}),
{
name: "github-source-storage",
},
),
);
+1 -1
View File
@@ -33,7 +33,6 @@ const initialState: LogsState = {
export const useLogsStore = create<LogsStore>((set, get) => ({
...initialState,
addLogEntry: (entry) =>
set(
produce((state: LogsState) => {
@@ -69,6 +68,7 @@ export const useLogsStore = create<LogsStore>((set, get) => ({
),
getLogsBySource: (source) => {
console.log(source);
const state = get();
return state.entries.filter((entry) => entry.source === source);
},
+9 -4
View File
@@ -78,11 +78,16 @@ export const useUpdateStore = create<UpdateStore>((set) => ({
addTerminalLine: (line) =>
set(
produce((state: UpdateState) => {
state.terminalLines.push(line);
console.log(state.terminalLines);
// Keep only last 10000 lines to prevent memory issues
if (state.terminalLines.length > 10000) {
state.terminalLines.splice(0, state.terminalLines.length - 10000);
const lastLine = state.terminalLines[state.terminalLines.length - 1];
if (line !== lastLine) {
state.terminalLines.push(line);
// Keep only last 10000 lines to prevent memory issues
if (state.terminalLines.length > 10000) {
state.terminalLines.splice(0, state.terminalLines.length - 10000);
}
}
}),
),
+104
View File
@@ -177,3 +177,107 @@
@apply bg-background text-foreground;
}
}
/* macOS-style scrollbars */
@layer base {
/* WebKit browsers (Safari, Chrome, newer Edge) */
::-webkit-scrollbar {
width: 8px;
height: 16px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
border: 2px solid transparent;
background-clip: content-box;
transition: background-color 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.4);
background-clip: content-box;
}
::-webkit-scrollbar-thumb:active {
background: rgba(0, 0, 0, 0.6);
background-clip: content-box;
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* Dark mode adjustments */
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
background-clip: content-box;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.4);
background-clip: content-box;
}
.dark ::-webkit-scrollbar-thumb:active {
background: rgba(255, 255, 255, 0.6);
background-clip: content-box;
}
/* Firefox */
html,
body {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
.dark html,
.dark body {
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
/* Auto-hide behavior for overlay scrollbars */
@media (pointer: fine) {
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar:hover {
width: 8px;
height: 8px;
}
/* Container hover shows scrollbars */
.scrollbar-visible:hover::-webkit-scrollbar {
width: 8px;
height: 8px;
}
}
/* Utility classes for scrollbar customization */
.scrollbar-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hidden::-webkit-scrollbar {
display: none;
}
.scrollbar-always-visible::-webkit-scrollbar {
width: 8px !important;
height: 8px !important;
}
@media (pointer: fine) {
.scrollbar-always-visible::-webkit-scrollbar {
width: 8px !important;
height: 8px !important;
}
}
}
+5 -1
View File
@@ -14,4 +14,8 @@ tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] }
ethercat_hal = { path = "../ethercat-hal" }
control_core = { path = "../control-core" }
futures = "0.3.31"
anyhow = "1.0.99"
anyhow = "1.0.100"
[package.metadata.cargo-machete]
ignored = ["interfaces"]
Binary file not shown.
+3 -2
View File
@@ -7,14 +7,15 @@ edition = "2024"
workspace = true
[dependencies]
anyhow = "1.0.99"
anyhow = "1.0.100"
ethercrab = "0.6"
ethercat_hal_derive = { version = "0.1.0", path = "../ethercat-hal-derive" }
bitvec = { version = "1.0.1", features = ["alloc"] }
smol = "2.0.2"
uom = "0.36.0"
uom = { version = "0.36.0", default-features = false, features = ["f64","si"] }
rand = "0.9.2"
futures = "0.3.31"
tracing = "0.1.41"
[dev-dependencies]
approx = "0.5.1"
+85 -8
View File
@@ -14,7 +14,7 @@ use crate::{
helpers::ethercrab_types::EthercrabSubDevicePreoperational,
io::analog_input::{AnalogInputDevice, AnalogInputInput},
};
use ethercat_hal_derive::{EthercatDevice, RxPdo, TxPdo};
use ethercat_hal_derive::EthercatDevice;
use uom::si::{electric_potential::volt, f64::ElectricPotential};
#[derive(Debug, Clone)]
@@ -98,7 +98,9 @@ impl AnalogInputDevice<EL3062_0030Port> for EL3062_0030 {
EL30XXPresentation::Signed => raw_value.as_signed(),
EL30XXPresentation::SignedMagnitude => raw_value.as_signed_magnitude(),
};
let normalized = f32::from(value) / f32::from(i16::MAX);
AnalogInputInput {
normalized,
wiring_error: false,
@@ -138,22 +140,97 @@ pub enum EL3062_0030Port {
AI2,
}
#[derive(Debug, Clone, TxPdo)]
#[derive(Debug, Clone)]
pub struct EL3062_0030TxPdo {
#[pdo_object_index(0x1A00)]
pub ai_standard_channel1: Option<AiStandard>,
#[pdo_object_index(0x1A01)]
pub ai_compact_channel1: Option<AiCompact>,
#[pdo_object_index(0x1A02)]
pub ai_standard_channel2: Option<AiStandard>,
#[pdo_object_index(0x1A03)]
pub ai_compact_channel2: Option<AiCompact>,
}
#[derive(Debug, Clone, RxPdo)]
pub struct EL3062_0030RxPdo {}
impl crate::coe::Configuration for EL3062_0030TxPdo {
///Implemented by the ethercat_hal_derive::TxPdo derive macro
async fn write_config<'a>(
&self,
device: &EthercrabSubDevicePreoperational<'a>,
) -> Result<(), anyhow::Error> {
device.sdo_write(0x1C13, 0, 0u8).await?;
let mut len = 0;
if let Some(_) = &self.ai_standard_channel1 {
len += 1;
device.sdo_write(0x1C13, len, 0x1A00u16).await?;
}
if let Some(_) = &self.ai_compact_channel1 {
len += 1;
device.sdo_write(0x1C13, len, 0x1A01u16).await?;
}
if let Some(_) = &self.ai_standard_channel2 {
len += 1;
device.sdo_write(0x1C13, len, 0x1A02u16).await?;
}
if let Some(_) = &self.ai_compact_channel2 {
len += 1;
device.sdo_write(0x1C13, len, 0x1A03u16).await?;
}
device.sdo_write(0x1C13, 0, len).await?;
Ok(())
}
}
impl crate::pdo::TxPdo for EL3062_0030TxPdo {
///Implemented by the ethercat_hal_derive::TxPdo derive macro
fn get_objects(&self) -> Box<[Option<&dyn crate::pdo::TxPdoObject>]> {
Box::new([
self.ai_standard_channel1
.as_ref()
.map(|o| o as &dyn crate::pdo::TxPdoObject),
self.ai_compact_channel1
.as_ref()
.map(|o| o as &dyn crate::pdo::TxPdoObject),
self.ai_standard_channel2
.as_ref()
.map(|o| o as &dyn crate::pdo::TxPdoObject),
self.ai_compact_channel2
.as_ref()
.map(|o| o as &dyn crate::pdo::TxPdoObject),
])
}
///Implemented by the ethercat_hal_derive::TxPdo derive macro
fn get_objects_mut(&mut self) -> Box<[Option<&mut dyn crate::pdo::TxPdoObject>]> {
Box::new([
self.ai_standard_channel1
.as_mut()
.map(|o| o as &mut dyn crate::pdo::TxPdoObject),
self.ai_compact_channel1
.as_mut()
.map(|o| o as &mut dyn crate::pdo::TxPdoObject),
self.ai_standard_channel2
.as_mut()
.map(|o| o as &mut dyn crate::pdo::TxPdoObject),
self.ai_compact_channel2
.as_mut()
.map(|o| o as &mut dyn crate::pdo::TxPdoObject),
])
}
}
#[derive(Debug, Clone)]
pub struct EL3062_0030RxPdo {}
impl crate::coe::Configuration for EL3062_0030RxPdo {
///Implemented by the ethercat_hal_derive::RxPdo derive macro
async fn write_config<'a>(
&self,
_device: &EthercrabSubDevicePreoperational<'a>,
) -> Result<(), anyhow::Error> {
Ok(())
}
}
impl crate::pdo::RxPdo for EL3062_0030RxPdo {
///Implemented by the ethercat_hal_derive::RxPdo derive macro
fn get_objects(&self) -> Box<[Option<&dyn crate::pdo::RxPdoObject>]> {
Box::new([])
}
}
impl Configuration for EL3062_0030Configuration {
async fn write_config<'a>(
&self,
+1
View File
@@ -102,6 +102,7 @@ impl EL4002 {
&mut self,
subdevice: &EthercrabSubDevicePreoperational<'a>,
) -> Result<(), anyhow::Error> {
tracing::info!("el4002");
self.configuration
.channel1
.write_channel_config(subdevice, 0x8000)
+349
View File
@@ -0,0 +1,349 @@
use super::{EthercatDeviceProcessing, NewEthercatDevice, SubDeviceIdentityTuple};
use crate::coe::{ConfigurableDevice, Configuration};
use crate::helpers::ethercrab_types::EthercrabSubDevicePreoperational;
use crate::io::encoder_input::{
EncoderInputCounter, EncoderInputDevice, EncoderInputFrequency, EncoderInputPeriod,
};
use crate::pdo::PredefinedPdoAssignment;
use crate::pdo::RxPdo;
use crate::pdo::TxPdo;
use crate::pdo::el5152::{
El5152EncoderControl, El5152EncoderFrequency, El5152EncoderPeriod, El5152EncoderStatus,
};
use ethercat_hal_derive::{EthercatDevice, RxPdo, TxPdo};
/// EL5152 2-channel incremental encoder interface
///
/// 24V HTL, 100 kHz, dual channel
#[derive(EthercatDevice)]
pub struct EL5152 {
pub configuration: EL5152Configuration,
pub rxpdo: EL5152RxPdo,
pub txpdo: EL5152TxPdo,
is_used: bool,
}
impl EthercatDeviceProcessing for EL5152 {}
#[derive(Debug, Clone)]
pub struct EL5152Configuration {
pub pdo_assignment: EL5152PredefinedPdoAssignment,
pub channel1: EL5152ChannelConfiguration,
pub channel2: EL5152ChannelConfiguration,
}
#[derive(Debug, Clone)]
pub enum EL5152PredefinedPdoAssignment {
Period,
Frequency,
}
impl std::fmt::Debug for EL5152 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "EL5152")
}
}
#[derive(Debug, Clone, Copy)]
pub enum EL5152Port {
ENC1,
ENC2,
}
impl Default for EL5152Configuration {
fn default() -> Self {
Self {
pdo_assignment: EL5152PredefinedPdoAssignment::Period,
channel1: EL5152ChannelConfiguration::default(),
channel2: EL5152ChannelConfiguration::default(),
}
}
}
impl NewEthercatDevice for EL5152 {
fn new() -> Self {
let configuration: EL5152Configuration = EL5152Configuration::default();
Self {
configuration: configuration.clone(),
rxpdo: configuration.pdo_assignment.rxpdo_assignment(),
txpdo: configuration.pdo_assignment.txpdo_assignment(),
is_used: false,
}
}
}
impl ConfigurableDevice<EL5152Configuration> for EL5152 {
async fn write_config<'maindevice>(
&mut self,
device: &EthercrabSubDevicePreoperational<'maindevice>,
config: &EL5152Configuration,
) -> Result<(), anyhow::Error> {
config.write_config(device).await?;
self.configuration = config.clone();
self.txpdo = config.pdo_assignment.txpdo_assignment();
self.rxpdo = config.pdo_assignment.rxpdo_assignment();
Ok(())
}
fn get_config(&self) -> EL5152Configuration {
self.configuration.clone()
}
}
impl EncoderInputDevice<EL5152Port> for EL5152 {
fn get_counter_value(&self, port: EL5152Port) -> Result<EncoderInputCounter, anyhow::Error> {
let value = match port {
EL5152Port::ENC1 => self
.txpdo
.status_channel1
.as_ref()
.map_or(0, |status| status.counter_value),
EL5152Port::ENC2 => self
.txpdo
.status_channel2
.as_ref()
.map_or(0, |status| status.counter_value),
};
Ok(EncoderInputCounter { value })
}
fn get_frequency(
&self,
port: EL5152Port,
) -> Result<Option<EncoderInputFrequency>, anyhow::Error> {
let frequency = match port {
EL5152Port::ENC1 => {
self.txpdo
.frequency_channel1
.as_ref()
.map(|f| EncoderInputFrequency {
value: f.frequency_value,
})
}
EL5152Port::ENC2 => {
self.txpdo
.frequency_channel2
.as_ref()
.map(|f| EncoderInputFrequency {
value: f.frequency_value,
})
}
};
Ok(frequency)
}
fn get_period(&self, port: EL5152Port) -> Result<Option<EncoderInputPeriod>, anyhow::Error> {
let period = match port {
EL5152Port::ENC1 => self
.txpdo
.period_channel1
.as_ref()
.map(|p| EncoderInputPeriod {
value: p.period_value,
}),
EL5152Port::ENC2 => self
.txpdo
.period_channel2
.as_ref()
.map(|p| EncoderInputPeriod {
value: p.period_value,
}),
};
Ok(period)
}
fn set_counter(&mut self, port: EL5152Port, value: u32) -> Result<(), anyhow::Error> {
match port {
EL5152Port::ENC1 => {
if let Some(control) = self.rxpdo.control_channel1.as_mut() {
control.set_counter_value = value;
control.set_counter = true;
}
}
EL5152Port::ENC2 => {
if let Some(control) = self.rxpdo.control_channel2.as_mut() {
control.set_counter_value = value;
control.set_counter = true;
}
}
}
Ok(())
}
}
impl Configuration for EL5152Configuration {
async fn write_config<'a>(
&self,
device: &EthercrabSubDevicePreoperational<'a>,
) -> Result<(), anyhow::Error> {
// Configure channel 1
self.channel1.write_channel_config(device, 0x8000).await?;
// Configure channel 2
self.channel2.write_channel_config(device, 0x8010).await?;
// Write PDO assignments
self.pdo_assignment
.txpdo_assignment()
.write_config(device)
.await?;
self.pdo_assignment
.rxpdo_assignment()
.write_config(device)
.await?;
Ok(())
}
}
#[derive(Debug, Clone, RxPdo)]
pub struct EL5152RxPdo {
#[pdo_object_index(0x1600)]
pub control_channel1: Option<El5152EncoderControl>,
#[pdo_object_index(0x1602)]
pub control_channel2: Option<El5152EncoderControl>,
}
#[derive(Debug, Clone, TxPdo)]
pub struct EL5152TxPdo {
#[pdo_object_index(0x1A00)]
pub status_channel1: Option<El5152EncoderStatus>,
#[pdo_object_index(0x1A02)]
pub period_channel1: Option<El5152EncoderPeriod>,
#[pdo_object_index(0x1A03)]
pub frequency_channel1: Option<El5152EncoderFrequency>,
#[pdo_object_index(0x1A04)]
pub status_channel2: Option<El5152EncoderStatus>,
#[pdo_object_index(0x1A06)]
pub period_channel2: Option<El5152EncoderPeriod>,
#[pdo_object_index(0x1A07)]
pub frequency_channel2: Option<El5152EncoderFrequency>,
}
impl PredefinedPdoAssignment<EL5152TxPdo, EL5152RxPdo> for EL5152PredefinedPdoAssignment {
fn rxpdo_assignment(&self) -> EL5152RxPdo {
EL5152RxPdo {
control_channel1: Some(El5152EncoderControl::default()),
control_channel2: Some(El5152EncoderControl::default()),
}
}
fn txpdo_assignment(&self) -> EL5152TxPdo {
match self {
Self::Period => EL5152TxPdo {
status_channel1: Some(El5152EncoderStatus::default()),
period_channel1: Some(El5152EncoderPeriod::default()),
frequency_channel1: None,
status_channel2: Some(El5152EncoderStatus::default()),
period_channel2: Some(El5152EncoderPeriod::default()),
frequency_channel2: None,
},
Self::Frequency => EL5152TxPdo {
status_channel1: Some(El5152EncoderStatus::default()),
period_channel1: None,
frequency_channel1: Some(El5152EncoderFrequency::default()),
status_channel2: Some(El5152EncoderStatus::default()),
period_channel2: None,
frequency_channel2: Some(El5152EncoderFrequency::default()),
},
}
}
}
#[derive(Debug, Clone)]
pub struct EL5152ChannelConfiguration {
// 80n0:3
pub enable_counter: u8,
// 80n0:8
pub disable_filter: u8,
// 80n0:A
pub enable_micro_increment: u8,
// 80n0:E
pub reversion_rotation: u8,
// 80n0:F 0-µs, 1-ms
pub frequency_based_window: u8,
// 80n0:11
pub frequency_window: u16,
// 80n0:13
pub frequency_scaling: u16,
// 80n0:14
pub period_scaling: u16,
// 80n0:15 100: "0.01 Hz"
pub frequency_resolution: u16,
// 80n0:16 100: "100 ns"
pub period_resolution: u16,
// 80n0:17
pub frequency_wait_time: u16,
}
impl EL5152ChannelConfiguration {
pub async fn write_channel_config<'a>(
&self,
device: &EthercrabSubDevicePreoperational<'a>,
base_index: u16,
) -> Result<(), anyhow::Error> {
device
.sdo_write(base_index, 0x03, self.enable_counter)
.await?;
device
.sdo_write(base_index, 0x08, self.disable_filter)
.await?;
device
.sdo_write(base_index, 0x0A, self.enable_micro_increment)
.await?;
device
.sdo_write(base_index, 0x0E, self.reversion_rotation)
.await?;
device
.sdo_write(base_index, 0x0F, self.frequency_based_window)
.await?;
device
.sdo_write(base_index, 0x11, self.frequency_window)
.await?;
device
.sdo_write(base_index, 0x13, self.frequency_scaling)
.await?;
device
.sdo_write(base_index, 0x14, self.period_scaling)
.await?;
device
.sdo_write(base_index, 0x15, self.frequency_resolution)
.await?;
device
.sdo_write(base_index, 0x16, self.period_resolution)
.await?;
device
.sdo_write(base_index, 0x17, self.frequency_wait_time)
.await?;
Ok(())
}
}
impl Default for EL5152ChannelConfiguration {
fn default() -> Self {
Self {
enable_counter: 0x01u8,
disable_filter: 0x00u8,
enable_micro_increment: 0x00u8,
reversion_rotation: 0x00u8,
frequency_based_window: 0x00u8,
frequency_window: 0x2710u16,
frequency_scaling: 0x0064u16,
period_scaling: 0x0064u16,
frequency_resolution: 0x0064u16,
period_resolution: 0x0064u16,
frequency_wait_time: 0x0640u16,
}
}
}
pub const EL5152_VENDOR_ID: u32 = 0x2;
pub const EL5152_PRODUCT_ID: u32 = 0x14203052;
pub const EL5152_REVISION_A: u32 = 0x140000;
pub const EL5152_IDENTITY_A: SubDeviceIdentityTuple =
(EL5152_VENDOR_ID, EL5152_PRODUCT_ID, EL5152_REVISION_A);
+9 -5
View File
@@ -15,6 +15,7 @@ pub mod el3024;
pub mod el3062_0030;
pub mod el3204;
pub mod el4002;
pub mod el5152;
pub mod el6021;
pub mod el7031;
pub mod el7031_0030;
@@ -22,7 +23,7 @@ pub mod el7041_0052;
// pub mod el4008;
use super::devices::el1008::EL1008;
use crate::{
devices::{el2521::EL2521, el3062_0030::EL3062_0030_IDENTITY_A},
devices::{el2521::EL2521, el4002::EL4002},
helpers::ethercrab_types::EthercrabSubDeviceGroupPreoperational,
};
use anyhow::anyhow;
@@ -38,8 +39,10 @@ use el2522::{EL2522, EL2522_IDENTITY_A};
use el3001::EL3001_IDENTITY_A;
use el3021::EL3021_IDENTITY_A;
use el3024::EL3024_IDENTITY_A;
use el3062_0030::EL3062_0030_IDENTITY_A;
use el3204::EL3204_IDENTITY_A;
use el4002::EL4002_IDENTITY_A;
use el5152::{EL5152, EL5152_IDENTITY_A};
use el6021::{EL6021_IDENTITY_A, EL6021_IDENTITY_B, EL6021_IDENTITY_C, EL6021_IDENTITY_D};
use el3204::EL3204_IDENTITY_B;
@@ -191,9 +194,6 @@ pub fn device_from_subdevice_identity_tuple(
EL2008_IDENTITY_A | EL2008_IDENTITY_B => Ok(Arc::new(RwLock::new(EL2008::new()))),
// TODO: implement EL2024 identity
// EL2024 => Ok(Arc::new(RwLock::new(EL2024::new()))),
EL2521_IDENTITY_0000_A | EL2521_IDENTITY_0000_B | EL2521_IDENTITY_0024_A => {
Ok(Arc::new(RwLock::new(EL2521::new())))
}
EL2522_IDENTITY_A => Ok(Arc::new(RwLock::new(EL2522::new()))),
// TODO: implement EL2634 identity
// "EL2634" => Ok(Arc::new(RwLock::new(EL2634::new()))),
@@ -203,7 +203,8 @@ pub fn device_from_subdevice_identity_tuple(
EL3021_IDENTITY_A => Ok(Arc::new(RwLock::new(el3021::EL3021::new()))),
EL3024_IDENTITY_A => Ok(Arc::new(RwLock::new(el3024::EL3024::new()))),
EL3062_0030_IDENTITY_A => Ok(Arc::new(RwLock::new(el3062_0030::EL3062_0030::new()))),
EL4002_IDENTITY_A => Ok(Arc::new(RwLock::new(el4002::EL4002::new()))),
EL4002_IDENTITY_A => Ok(Arc::new(RwLock::new(EL4002::new()))),
EL5152_IDENTITY_A => Ok(Arc::new(RwLock::new(EL5152::new()))),
EL6021_IDENTITY_A | EL6021_IDENTITY_B | EL6021_IDENTITY_C | EL6021_IDENTITY_D => {
Ok(Arc::new(RwLock::new(el6021::EL6021::new())))
}
@@ -214,6 +215,9 @@ pub fn device_from_subdevice_identity_tuple(
EL7031_IDENTITY_A | EL7031_IDENTITY_B => Ok(Arc::new(RwLock::new(el7031::EL7031::new()))),
EL7031_0030_IDENTITY_A => Ok(Arc::new(RwLock::new(el7031_0030::EL7031_0030::new()))),
EL7041_0052_IDENTITY_A => Ok(Arc::new(RwLock::new(el7041_0052::EL7041_0052::new()))),
EL2521_IDENTITY_0000_A | EL2521_IDENTITY_0000_B | EL2521_IDENTITY_0024_A => {
Ok(Arc::new(RwLock::new(EL2521::new())))
}
_ => Err(anyhow::anyhow!(
"[{}::device_from_subdevice] No Driver: vendor_id: 0x{:x}, product_id: 0x{:x}, revision: 0x{:x}",
module_path!(),
+120
View File
@@ -0,0 +1,120 @@
use std::{fmt, sync::Arc};
use futures::executor::block_on;
use smol::lock::RwLock;
/// Encoder Input device
///
/// Reads encoder values (counter, frequency, period) from the device.
pub struct EncoderInput {
/// Read the counter value from the encoder
get_counter: Box<dyn Fn() -> Result<EncoderInputCounter, anyhow::Error> + Send + Sync>,
/// Read the frequency value from the encoder (if available)
get_frequency:
Box<dyn Fn() -> Result<Option<EncoderInputFrequency>, anyhow::Error> + Send + Sync>,
/// Read the period value from the encoder (if available)
get_period: Box<dyn Fn() -> Result<Option<EncoderInputPeriod>, anyhow::Error> + Send + Sync>,
/// Set the counter value
set_counter: Box<dyn Fn(u32) -> Result<(), anyhow::Error> + Send + Sync>,
}
impl fmt::Debug for EncoderInput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "EncoderInput")
}
}
/// Implement on device that have encoder inputs
impl EncoderInput {
pub fn new<PORT>(device: Arc<RwLock<dyn EncoderInputDevice<PORT>>>, port: PORT) -> Self
where
PORT: Clone + Send + Sync + 'static,
{
// build sync get counter closure
let port_counter = port.clone();
let device_counter = device.clone();
let get_counter = Box::new(move || {
let device = block_on(device_counter.read());
device.get_counter_value(port_counter.clone())
});
// build sync get frequency closure
let port_frequency = port.clone();
let device_frequency = device.clone();
let get_frequency = Box::new(move || {
let device = block_on(device_frequency.read());
device.get_frequency(port_frequency.clone())
});
// build sync get period closure
let port_period = port.clone();
let device_period = device.clone();
let get_period = Box::new(move || {
let device = block_on(device_period.read());
device.get_period(port_period.clone())
});
// build sync set counter closure
let port_set = port;
let device_set = device;
let set_counter = Box::new(move |value: u32| {
let mut device = block_on(device_set.write());
device.set_counter(port_set.clone(), value)
});
Self {
get_counter,
get_frequency,
get_period,
set_counter,
}
}
/// Get the current counter value of the encoder
pub fn get_counter_value(&self) -> Result<u32, anyhow::Error> {
let counter = (self.get_counter)()?;
Ok(counter.value)
}
/// Get the current frequency value of the encoder (if available)
pub fn get_frequency_value(&self) -> Result<Option<u32>, anyhow::Error> {
let frequency = (self.get_frequency)()?;
Ok(frequency.map(|f| f.value))
}
/// Get the current period value of the encoder (if available)
pub fn get_period_value(&self) -> Result<Option<u32>, anyhow::Error> {
let period = (self.get_period)()?;
Ok(period.map(|p| p.value))
}
/// Set the counter value
pub fn set_counter_value(&self, value: u32) -> Result<(), anyhow::Error> {
(self.set_counter)(value)
}
}
#[derive(Debug, Clone)]
pub struct EncoderInputCounter {
pub value: u32,
}
#[derive(Debug, Clone)]
pub struct EncoderInputFrequency {
pub value: u32,
}
#[derive(Debug, Clone)]
pub struct EncoderInputPeriod {
pub value: u32,
}
pub trait EncoderInputDevice<PORTS>: Send + Sync
where
PORTS: Clone,
{
fn get_counter_value(&self, port: PORTS) -> Result<EncoderInputCounter, anyhow::Error>;
fn get_frequency(&self, port: PORTS) -> Result<Option<EncoderInputFrequency>, anyhow::Error>;
fn get_period(&self, port: PORTS) -> Result<Option<EncoderInputPeriod>, anyhow::Error>;
fn set_counter(&mut self, port: PORTS, value: u32) -> Result<(), anyhow::Error>;
}
+1
View File
@@ -3,6 +3,7 @@ pub mod analog_input_dummy;
pub mod analog_output;
pub mod digital_input;
pub mod digital_output;
pub mod encoder_input;
pub mod pulse_train_output;
pub mod serial_interface;
pub mod stepper_velocity_el70x1;
+4 -4
View File
@@ -1,24 +1,24 @@
use super::RxPdoObject;
use bitvec::prelude::*;
use ethercat_hal_derive::PdoObject;
use super::RxPdoObject;
/// PDO Object for EL40xx (analog output) devices
///
/// The "Analog Output" holds the output value and status information.
#[derive(Debug, Clone, Default, PdoObject, PartialEq, Eq)]
#[pdo_object(bits = 16)]
pub struct AnalogOutput {
/// Output value (-32768-32767 typically corresponds to -10 -10V or 0mA-20mA (from 4-20mA for EL402x))
/// Output value (-32768-32767 typically corresponds to -10V to +10V)
pub value: i16,
}
impl RxPdoObject for AnalogOutput {
fn write(&self, bits: &mut BitSlice<u8, Lsb0>) {
// Write the output value to bits 0-15
bits[0..16].store_le(self.value);
bits[0..16].store_le(self.value as u16);
}
}
#[cfg(test)]
mod tests {
use super::*;
+96
View File
@@ -0,0 +1,96 @@
use super::{RxPdoObject, TxPdoObject};
use bitvec::prelude::*;
use ethercat_hal_derive::PdoObject;
/// PDO Object for EL5152 encoder control (RxPDO)
/// Based on 1600/1602 mapping: Set counter (1 bit) + alignment + Set counter value (32/16 bit)
#[derive(Debug, Clone, Default, PdoObject, PartialEq, Eq)]
#[pdo_object(bits = 48)] // Total 48 bits for full mapping
pub struct El5152EncoderControl {
/// Set counter - execute counter setting (1 bit) - 0x7000:03
pub set_counter: bool,
/// Set counter value (32-bit) - 0x7000:11
pub set_counter_value: u32,
}
impl RxPdoObject for El5152EncoderControl {
fn write(&self, bits: &mut BitSlice<u8, Lsb0>) {
// bit 2: Set counter (0x7000:03)
bits.set(2, self.set_counter);
// Set counter value (bits 16-47, 32-bit value) - 0x7000:11
bits[16..48].store_le(self.set_counter_value);
}
}
/// PDO Object for EL5152 encoder status (TxPDO)
/// Based on 1A00/1A04 mapping: Status bits + Counter value (32-bit)
#[derive(Debug, Clone, Default, PdoObject, PartialEq, Eq)]
#[pdo_object(bits = 48)] // 16 bits status + 32 bits counter
pub struct El5152EncoderStatus {
/// Set counter done - counter was set (1 bit) - 0x6000:03
pub set_counter_done: bool,
/// Extrapolation stall - extrapolated counter invalid (1 bit) - 0x6000:08
pub extrapolation_stall: bool,
/// Status of input A (1 bit) - 0x6000:09
pub status_input_a: bool,
/// Status of input B (1 bit) - 0x6000:0A
pub status_input_b: bool,
/// Sync Error - synchronization error occurred (1 bit) - 0x1C32:20
pub sync_error: bool,
/// TxPDO Toggle - toggled when TxPDO data is updated (1 bit) - 0x1800:09
pub txpdo_toggle: bool,
/// Counter value (32-bit) - 0x6000:11
pub counter_value: u32,
}
impl TxPdoObject for El5152EncoderStatus {
fn read(&mut self, bits: &BitSlice<u8, Lsb0>) {
// bit 2: Set counter done (0x6000:03)
self.set_counter_done = bits[2];
// bit 7: Extrapolation stall (0x6000:08)
self.extrapolation_stall = bits[7];
// bit 8: Status of input A (0x6000:09)
self.status_input_a = bits[8];
// bit 9: Status of input B (0x6000:0A)
self.status_input_b = bits[9];
// bit 13: Sync error (0x1C32:20)
self.sync_error = bits[13];
// bit 15: TxPDO Toggle (0x1800:09)
self.txpdo_toggle = bits[15];
// Counter value (bits 16-47, 32-bit value) - 0x6000:11
self.counter_value = bits[16..48].load_le::<u32>();
}
}
/// PDO Object for EL5152 encoder frequency measurement (TxPDO)
/// Based on 1A03/1A07 mapping: 32-bit frequency value only
#[derive(Debug, Clone, Default, PdoObject, PartialEq, Eq)]
#[pdo_object(bits = 32)]
pub struct El5152EncoderFrequency {
/// Frequency value (32-bit) - 0x6000:13 / 0x6010:13
pub frequency_value: u32,
}
impl TxPdoObject for El5152EncoderFrequency {
fn read(&mut self, bits: &BitSlice<u8, Lsb0>) {
// Frequency value (bits 0-31) - 0x6000:13 / 0x6010:13
self.frequency_value = bits[0..32].load_le::<u32>();
}
}
/// PDO Object for EL5152 encoder period measurement (TxPDO)
/// Based on 1A02/1A06 mapping: 32-bit period value only
#[derive(Debug, Clone, Default, PdoObject, PartialEq, Eq)]
#[pdo_object(bits = 32)]
pub struct El5152EncoderPeriod {
/// Period value (32-bit) - 0x6000:14 / 0x6010:14
pub period_value: u32,
}
impl TxPdoObject for El5152EncoderPeriod {
fn read(&mut self, bits: &BitSlice<u8, Lsb0>) {
// Period value (bits 0-31) - 0x6000:14 / 0x6010:14
self.period_value = bits[0..32].load_le::<u32>();
}
}
+1
View File
@@ -3,6 +3,7 @@ pub mod basic;
pub mod el252x;
pub mod el32xx;
pub mod el40xx;
pub mod el5152;
pub mod el70x1;
use crate::coe::Configuration;
use bitvec::prelude::*;
+2
View File
@@ -113,6 +113,8 @@ impl EL40XXChannelConfiguration {
device: &EthercrabSubDevicePreoperational<'a>,
base_index: u16,
) -> Result<(), anyhow::Error> {
tracing::info!("write_channel_config");
// Write all configuration parameters according to the documentation table
device
.sdo_write(base_index, 0x01, self.enable_user_scale as u8)
+5 -3
View File
@@ -78,18 +78,20 @@ serviceConfig = {
Restart = "always";
RestartSec = "10s";
# Capabilities (unchanged, as you wanted)
CapabilityBoundingSet = "CAP_NET_RAW CAP_NET_ADMIN CAP_SYS_NICE CAP_DAC_OVERRIDE";
AmbientCapabilities = "CAP_NET_RAW CAP_NET_ADMIN CAP_SYS_NICE CAP_DAC_OVERRIDE";
# Capabilities
CapabilityBoundingSet = "CAP_NET_RAW CAP_IPC_LOCK CAP_NET_ADMIN CAP_SYS_NICE CAP_DAC_OVERRIDE";
AmbientCapabilities = "CAP_NET_RAW CAP_IPC_LOCK CAP_NET_ADMIN CAP_SYS_NICE CAP_DAC_OVERRIDE";
# Hardening options
NoNewPrivileges = true;
ProtectSystem = "strict";
# Open only /proc/irq explicitly
ReadWritePaths = [ "/proc/irq" ];
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = false;
# Must disable this to allow /proc/irq writes
ProtectKernelTunables = false;
ProtectControlGroups = true;
+2 -3
View File
@@ -102,11 +102,10 @@ installPhase = ''
--name="QiTech Control" \
--single-instance \
--winrm-class="de.qitech.control-electron" \
--enable-gpu \
--enable-gpu \
--ignore-gpu-blocklist \
--enable-gpu-rasterization \
--disable-software-rasterizer \
--use-gl=desktop \
--disable-software-rasterizer \
"\$@"
EOF
chmod +x $out/bin/qitech-control-electron
+10 -9
View File
@@ -16,9 +16,8 @@ control_core_derive = { path = "../control-core-derive" }
uom = { version = "0.36.0", default-features = false, features = ["f64"] }
serde = "1.0.217"
anyhow = "1.0.99"
anyhow = "1.0.100"
open = "5.3.2"
bitvec = "1.0.1"
lazy_static = "1.5.0"
dhat = { version = "0.3.3", optional = true }
@@ -27,18 +26,16 @@ euclid = "0.22.11"
# concurrency
smol = "2.0.2"
tokio = { version = "1.45.1", features = ["rt-multi-thread"] }
thread-priority = "2.1.0"
regex = "1.11.2"
regex = "1.11.3"
futures = "0.3.31"
# web
serde_json = "1.0.143"
socketioxide = { version = "0.17.2", features = ["msgpack"] }
tower-http = { version = "0.6.6", features = ["cors", "trace", "fs"] }
axum = { version = "0.8.4", features = ["macros"] }
axum = { version = "0.8.6", features = ["macros"] }
# serial
crc = "3.3.0"
serialport = "4.7.3"
#tracing
@@ -58,9 +55,9 @@ opentelemetry-otlp = { version = "0.30.0", features = [
], optional = true }
tonic = { version = "0.13.1", optional = true }
[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemallocator = { version = "0.6", features = ["profiling", "stats"] }
tikv-jemalloc-ctl = "0.6"
#[target.'cfg(not(target_env = "msvc"))'.dependencies]
#tikv-jemallocator = { version = "0.6", features = ["profiling", "stats"] }
#tikv-jemalloc-ctl = "0.6"
[dev-dependencies]
approx = "0.5.1"
@@ -81,7 +78,11 @@ tracing-otel = [
]
mock-machine = []
io-uring = []
development-build = []
[profile.release]
codegen-units = 1
lto = "fat"
[package.metadata.cargo-machete]
ignored = ["tonic"]
+19
View File
@@ -1,6 +1,7 @@
use crate::ethercat::config::{MAX_SUBDEVICES, PDI_LEN};
use crate::performance_metrics::EthercatPerformanceMetrics;
use crate::serial::registry::SERIAL_DEVICE_REGISTRY;
use crate::socketio::main_namespace::machines_event::MachineObj;
use crate::socketio::namespaces::Namespaces;
use control_core::machines::Machine;
use control_core::machines::identification::{DeviceIdentification, MachineIdentificationUnique};
@@ -90,4 +91,22 @@ impl AppState {
performance_metrics: Arc::new(RwLock::new(EthercatPerformanceMetrics::new())),
}
}
pub fn get_machine_objs(&self) -> Vec<MachineObj> {
let machines = self.machines.read_blocking();
machines
.iter()
.map(|machine| {
let error = {
let slot = machine.1.lock_blocking();
slot.machine_connection.to_error().map(|e| e.to_string())
};
MachineObj {
machine_identification_unique: machine.0.clone(),
error,
}
})
.collect()
}
}
+15 -9
View File
@@ -9,7 +9,7 @@ use crate::{
ethercat::config::{MAX_FRAMES, MAX_PDU_DATA, MAX_SUBDEVICES, PDI_LEN},
};
use control_core::ethercat::eeprom_identification::read_device_identifications;
#[cfg(target_os = "linux")]
#[cfg(all(target_os = "linux", not(feature = "development-build")))]
use control_core::irq_handling::set_irq_affinity;
use control_core::machines::identification::{
DeviceHardwareIdentification, DeviceHardwareIdentificationEthercat, DeviceIdentification,
@@ -23,6 +23,9 @@ use ethercrab::{MainDevice, MainDeviceConfig, PduStorage, RetryBehaviour, Timeou
use smol::channel::Sender;
use std::{sync::Arc, time::Duration};
const SM_OUTPUT: u16 = 0x1C32;
const SM_INPUT: u16 = 0x1C33;
pub async fn setup_loop(
thread_panic_tx: Sender<PanicDetails>,
interface: &str,
@@ -47,7 +50,7 @@ pub async fn setup_loop(
.spawn(move || {
send_panic(thread_panic_tx_clone);
#[cfg(target_os = "linux")]
#[cfg(all(target_os = "linux", not(feature = "development-build")))]
match set_irq_affinity(&interface, 3) {
Ok(_) => tracing::info!("ethernet interrupt handler now runs on cpu:{}", 3),
Err(e) => tracing::error!("set_irq_handler_affinity failed: {:?}", e),
@@ -90,13 +93,13 @@ pub async fn setup_loop(
// Default 30_000us
pdu: Duration::from_micros(30_000),
// Default 10ms
eeprom: Duration::from_millis(10),
eeprom: Duration::from_millis(100),
// Default 0ms
wait_loop_delay: Duration::from_millis(0),
// Default 100ms
mailbox_echo: Duration::from_millis(100),
mailbox_echo: Duration::from_millis(1000),
// Default 1000ms
mailbox_response: Duration::from_millis(1000),
mailbox_response: Duration::from_millis(10000),
},
MainDeviceConfig {
// Default RetryBehaviour::None
@@ -157,14 +160,12 @@ pub async fn setup_loop(
},
)
.collect::<Vec<_>>();
let devices = device_identifications
.into_iter()
.zip(devices)
.zip(&subdevices)
.map(|((a, b), c)| (a, b, c))
.collect::<Vec<_>>();
// filter devices and if Option<DeviceMachineIdentification> is Some
// return identified_devices, identified_device_identifications, identified_subdevices
let (identified_device_identifications, identified_devices, identified_subdevices): (
@@ -198,7 +199,6 @@ pub async fn setup_loop(
acc
},
);
// construct machines
{
let mut machines_guard = app_state.machines.write().await;
@@ -220,6 +220,12 @@ pub async fn setup_loop(
.map(|(device_identification, device, _)| (device_identification.clone(), device.clone()))
.collect::<Vec<_>>();
for subdevice in subdevices.iter() {
if subdevice.name() == "EL5152" {
subdevice.sdo_write(SM_OUTPUT, 0x1, 0x00u16).await?; //set sync mode (1) for free run (0)
subdevice.sdo_write(SM_INPUT, 0x1, 0x00u16).await?; //set sync mode (1) for free run (0)
}
}
// Notify client via socketio
let app_state_clone = app_state.clone();
smol::block_on(async move {
@@ -229,7 +235,7 @@ pub async fn setup_loop(
.write()
.await
.main_namespace;
let event = MachinesEventBuilder().build(app_state_clone.clone()).await;
let event = MachinesEventBuilder().build(app_state_clone.clone());
main_namespace.emit(MainNamespaceEvents::MachinesEvent(event));
});
-64
View File
@@ -1,64 +0,0 @@
use std::{thread::sleep, time::Duration};
pub fn init_jemalloc_stats() {
std::thread::Builder::new()
.name("jemalloc-stats".to_string())
.spawn(|| {
let mut last_allocated = 0usize;
let mut last_time = std::time::Instant::now();
loop {
smol::block_on(async {
sleep(Duration::from_secs(10));
// Advance epoch and get allocated bytes
let _ = tikv_jemalloc_ctl::epoch::advance();
if let Ok(allocated_bytes) =
unsafe { tikv_jemalloc_ctl::raw::read::<usize>(b"stats.allocated\0") }
{
let current_time = std::time::Instant::now();
let time_diff = current_time.duration_since(last_time).as_secs_f64();
let change = allocated_bytes as i64 - last_allocated as i64;
let change_per_sec = if time_diff > 0.0 {
(change as f64 / time_diff) as i64
} else {
0
};
let change_str = if change_per_sec >= 0 {
format!("+{} KB/s", format_bytes(change_per_sec))
} else {
format!("-{} KB/s", format_bytes(-change_per_sec))
};
let formatted = format_bytes(allocated_bytes as i64);
tracing::debug!("Memory: {} KB ({})", formatted, change_str);
last_allocated = allocated_bytes;
last_time = current_time;
}
});
}
})
.expect("Failed to spawn jemalloc-stats thread");
}
fn format_bytes(bytes: i64) -> String {
let kb = bytes / 1024;
format!("{}", kb)
.chars()
.rev()
.enumerate()
.map(|(i, c)| {
if i > 0 && i % 3 == 0 {
format!("_{}", c)
} else {
c.to_string()
}
})
.collect::<String>()
.chars()
.rev()
.collect::<String>()
}
+8 -2
View File
@@ -1,6 +1,7 @@
use crate::app_state::AppState;
use crate::panic::{PanicDetails, send_panic};
use bitvec::prelude::*;
use control_core::machines::connection::MachineConnection;
use control_core::realtime::{set_core_affinity, set_realtime_priority};
use smol::channel::Sender;
use std::sync::Arc;
@@ -127,7 +128,11 @@ pub async fn loop_once<'maindevice>(app_state: Arc<AppState>) -> Result<(), anyh
)
})?;
}
// Apparently 500 Microseconds is a safe starting point for ethercat
#[cfg(feature = "development-build")]
smol::Timer::after(Duration::from_micros(2000)).await;
#[cfg(not(feature = "development-build"))]
smol::Timer::after(Duration::from_micros(500)).await;
}
@@ -140,7 +145,8 @@ pub async fn loop_once<'maindevice>(app_state: Arc<AppState>) -> Result<(), anyh
let now = std::time::Instant::now();
for machine in machine_guard.iter() {
if let Ok(machine) = machine.1 {
let connection = &machine.1.lock_blocking().machine_connection;
if let MachineConnection::Connected(machine) = connection {
// if the machine is currenlty locked (likely processing API call)
// we skip the machine
if let Some(mut machine_guard) = machine.try_lock() {
+26
View File
@@ -0,0 +1,26 @@
use super::{AquaPathV1, AquaPathV1Mode};
use control_core::machines::new::MachineAct;
use std::time::{Duration, Instant};
impl MachineAct for AquaPathV1 {
fn act(&mut self, now_ts: Instant) {
match self.mode {
AquaPathV1Mode::Standby => {
self.switch_to_standby();
}
AquaPathV1Mode::Auto => {
self.switch_to_auto();
}
}
let now = Instant::now();
self.front_controller.update(now_ts);
self.back_controller.update(now_ts);
if now.duration_since(self.last_measurement_emit) > Duration::from_secs_f64(1.0 / 30.0) {
self.emit_live_values();
self.last_measurement_emit = now;
}
}
}
+155
View File
@@ -0,0 +1,155 @@
use std::{sync::Arc, time::Duration};
use super::{AquaPathV1, AquaPathV1Mode};
use control_core::{
machines::api::MachineApi,
socketio::{
event::{Event, GenericEvent},
namespace::{
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_duration,
cache_first_and_last_event,
},
},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use smol::lock::Mutex;
use tracing::instrument;
#[derive(Serialize, Debug, Clone, Default)]
pub struct LiveValuesEvent {
pub front_flow: f64,
pub back_flow: f64,
pub front_temperature: f64,
pub back_temperature: f64,
pub front_temp_reservoir: f64,
pub back_temp_reservoir: f64,
}
impl LiveValuesEvent {
pub fn build(&self) -> Event<Self> {
Event::new("LiveValuesEvent", self.clone())
}
}
#[derive(Serialize, Debug, Clone)]
pub struct StateEvent {
pub is_default_state: bool,
/// mode state
pub mode_state: ModeState,
pub flow_states: FlowStates,
pub temperature_states: TempStates,
}
impl StateEvent {
pub fn build(&self) -> Event<Self> {
Event::new("StateEvent", self.clone())
}
}
#[derive(Serialize, Debug, Clone)]
pub struct TempStates {
pub front: TempState,
pub back: TempState,
}
#[derive(Serialize, Debug, Clone)]
pub struct TempState {
pub temperature: f64,
pub target_temperature: f64,
}
#[derive(Serialize, Debug, Clone)]
pub struct ModeState {
pub mode: AquaPathV1Mode,
}
#[derive(Serialize, Debug, Clone)]
pub struct FlowStates {
pub front: FlowState,
pub back: FlowState,
}
#[derive(Serialize, Debug, Clone)]
pub struct FlowState {
pub flow: f64,
pub should_flow: bool,
}
pub enum AquaPathV1Events {
LiveValues(Event<LiveValuesEvent>),
State(Event<StateEvent>),
}
#[derive(Deserialize, Serialize)]
enum Mutation {
//Mode
SetAquaPathMode(AquaPathV1Mode),
SetFrontTemperature(f64),
SetBackTemperature(f64),
SetFrontFlow(bool),
SetBackFlow(bool),
}
#[derive(Debug)]
pub struct AquaPathV1Namespace {
pub namespace: Arc<Mutex<Namespace>>,
}
impl NamespaceCacheingLogic<AquaPathV1Events> for AquaPathV1Namespace {
#[instrument(skip_all)]
fn emit(&mut self, events: AquaPathV1Events) {
let event = Arc::new(events.event_value());
let buffer_fn = events.event_cache_fn();
let mut namespace = self.namespace.lock_blocking();
namespace.emit(event, &buffer_fn);
}
}
impl CacheableEvents<AquaPathV1Events> for AquaPathV1Events {
fn event_value(&self) -> GenericEvent {
match self {
AquaPathV1Events::LiveValues(event) => event.into(),
AquaPathV1Events::State(event) => event.into(),
}
}
fn event_cache_fn(&self) -> CacheFn {
let cache_one_hour = cache_duration(Duration::from_secs(60 * 60), Duration::from_secs(1));
let cache_first_and_last = cache_first_and_last_event();
match self {
AquaPathV1Events::LiveValues(_) => cache_one_hour,
AquaPathV1Events::State(_) => cache_first_and_last,
}
}
}
impl MachineApi for AquaPathV1 {
fn api_mutate(&mut self, request_body: Value) -> Result<(), anyhow::Error> {
let control: Mutation = serde_json::from_value(request_body)?;
match control {
Mutation::SetAquaPathMode(mode) => self.set_mode_state(mode),
Mutation::SetBackTemperature(temperature) => {
self.set_target_temperature(temperature, super::AquaPathSideType::Back)
}
Mutation::SetFrontTemperature(temperature) => {
self.set_target_temperature(temperature, super::AquaPathSideType::Front)
}
Mutation::SetBackFlow(should_pump) => {
self.set_should_pump(should_pump, super::AquaPathSideType::Back)
}
Mutation::SetFrontFlow(should_pump) => {
self.set_should_pump(should_pump, super::AquaPathSideType::Front)
}
}
Ok(())
}
fn api_event_namespace(&mut self) -> Arc<Mutex<Namespace>> {
self.namespace.namespace.clone()
}
}
+311
View File
@@ -0,0 +1,311 @@
use crate::machines::aquapath1::VolumeRate;
use crate::machines::aquapath1::{Flow, Temperature};
use control_core::controllers::pid::PidController;
use ethercat_hal::io::encoder_input::EncoderInput;
use ethercat_hal::io::{
analog_output::AnalogOutput, digital_output::DigitalOutput, temperature_input::TemperatureInput,
};
use std::time::{Duration, Instant};
use uom::si::f64::ThermodynamicTemperature;
use uom::si::thermodynamic_temperature::degree_celsius;
use uom::si::volume_rate::liter_per_minute;
#[derive(Debug)]
pub struct Controller {
pub pid: PidController,
temperature_pid_output: f64,
window_start: Instant,
pwm_period: Duration,
pub temperature: Temperature,
pub target_temperature: ThermodynamicTemperature,
pub current_temperature: ThermodynamicTemperature,
pub temp_reservoir: ThermodynamicTemperature,
pub min_temperature: ThermodynamicTemperature,
pub max_temperature: ThermodynamicTemperature,
pub cooling_controller: AnalogOutput,
pub cooling_relais: DigitalOutput,
pub heating_relais_1: DigitalOutput,
pub heating_relais_2: DigitalOutput,
pub temperature_sensor_in: TemperatureInput,
pub temperature_sensor_out: TemperatureInput,
pub cooling_allowed: bool,
pub heating_allowed: bool,
pub flow: Flow,
pump_relais: DigitalOutput,
pub should_pump: bool,
pub flow_sensor: EncoderInput,
pub pump_allowed: bool,
pub current_flow: VolumeRate,
pub max_flow: VolumeRate,
}
impl Controller {
pub fn new(
kp: f64,
ki: f64,
kd: f64,
pwm_duration: Duration,
temp: Temperature,
target_tempetature: ThermodynamicTemperature,
cooling_controller: AnalogOutput,
cooling_relais: DigitalOutput,
heating_relais_1: DigitalOutput,
heating_relais_2: DigitalOutput,
temp_sensor_in: TemperatureInput,
temp_sensor_out: TemperatureInput,
flow: Flow,
pump_relais: DigitalOutput,
flow_sensor: EncoderInput,
) -> Self {
Self {
pid: PidController::new(kp, ki, kd),
temperature_pid_output: 0.0,
window_start: Instant::now(),
pwm_period: pwm_duration,
target_temperature: target_tempetature,
current_temperature: ThermodynamicTemperature::new::<degree_celsius>(25.0),
temp_reservoir: ThermodynamicTemperature::new::<degree_celsius>(25.0),
min_temperature: ThermodynamicTemperature::new::<degree_celsius>(10.0),
max_temperature: ThermodynamicTemperature::new::<degree_celsius>(50.0),
temperature: temp,
cooling_controller: cooling_controller,
cooling_relais: cooling_relais,
heating_relais_1: heating_relais_1,
heating_relais_2: heating_relais_2,
cooling_allowed: false,
heating_allowed: false,
temperature_sensor_in: temp_sensor_in,
temperature_sensor_out: temp_sensor_out,
flow: flow,
pump_relais: pump_relais,
flow_sensor: flow_sensor,
should_pump: false,
current_flow: VolumeRate::new::<liter_per_minute>(0.0),
pump_allowed: false,
max_flow: VolumeRate::new::<liter_per_minute>(10.0),
}
}
pub fn turn_pump_off(&mut self) {
tracing::info!("turn_pump_off");
self.flow.pump = false;
self.pump_relais.set(false);
}
pub fn turn_pump_on(&mut self) {
tracing::info!("turn_pump_on");
self.flow.pump = true;
self.pump_relais.set(true);
}
pub fn disallow_pump(&mut self) {
self.pump_allowed = false;
}
pub fn allow_pump(&mut self) {
self.pump_allowed = true;
}
pub fn get_pump(&mut self) -> bool {
self.pump_allowed
}
pub fn disable_cooling(&mut self) {
self.turn_cooling_off();
self.disallow_cooling();
}
pub fn disable_heating(&mut self) {
self.turn_heating_off();
self.disallow_heating();
}
pub fn enable_cooling(&mut self) {
self.turn_cooling_on();
self.allow_cooling();
}
pub fn enable_heating(&mut self) {
self.turn_heating_on();
self.allow_heating();
}
pub fn reset_pid(&mut self) {
self.pid.reset()
}
pub fn set_target_temperature(&mut self, temperature: ThermodynamicTemperature) {
self.reset_pid();
self.target_temperature = temperature;
}
pub fn get_temp_in(&mut self) -> ThermodynamicTemperature {
let temp = self.temperature_sensor_in.get_temperature();
match temp {
Ok(value) => ThermodynamicTemperature::new::<degree_celsius>(value),
Err(_) => ThermodynamicTemperature::new::<degree_celsius>(0.0),
}
}
pub fn get_temp_out(&mut self) -> ThermodynamicTemperature {
let temp = self.temperature_sensor_out.get_temperature();
match temp {
Ok(value) => ThermodynamicTemperature::new::<degree_celsius>(value),
Err(_) => ThermodynamicTemperature::new::<degree_celsius>(0.0),
}
}
pub fn disallow_cooling(&mut self) {
self.cooling_allowed = false;
}
pub fn allow_cooling(&mut self) {
self.cooling_allowed = true;
}
pub fn disallow_heating(&mut self) {
self.heating_allowed = false;
}
pub fn allow_heating(&mut self) {
self.heating_allowed = true;
}
pub fn turn_cooling_on(&mut self) {
tracing::info!("turn_cooling_on");
self.cooling_relais.set(true);
self.cooling_controller.set(10.0);
self.temperature.cooling = true;
}
pub fn turn_cooling_off(&mut self) {
tracing::info!("turn_cooling_off");
self.cooling_relais.set(false);
self.temperature.cooling = false;
}
pub fn turn_heating_on(&mut self) {
tracing::info!("turn_heating_on");
self.heating_relais_1.set(true);
self.heating_relais_2.set(true);
self.temperature.heating = true;
}
pub fn turn_heating_off(&mut self) {
tracing::info!("turn_heating_off");
self.heating_relais_1.set(false);
self.heating_relais_2.set(false);
self.temperature.heating = false;
}
pub fn set_should_pump(&mut self, should_pump: bool) {
self.should_pump = should_pump;
}
pub fn get_should_pump(&mut self) -> bool {
self.should_pump
}
pub fn get_flow(&mut self) -> VolumeRate {
let value = match self.flow_sensor.get_frequency_value() {
Ok(val) => val,
Err(e) => {
tracing::debug!("Error calculating frequency: {}", e);
return VolumeRate::new::<liter_per_minute>(0.0);
}
};
match value {
Some(val) => {
if val == 0 {
return VolumeRate::new::<liter_per_minute>(0.0);
}
// Formula: f = 8.1*q - 3, so q = (f + 3) / 8.1
let actual_flow = ((val / 100) as f32 + 3.0) / 8.1;
VolumeRate::new::<liter_per_minute>(actual_flow.into())
}
None => {
return VolumeRate::new::<liter_per_minute>(0.0);
}
}
}
pub fn update(&mut self, now: Instant) -> () {
let current_flow = self.get_flow();
self.current_flow = current_flow;
self.flow.flow = current_flow;
let should_flow = self.get_should_pump();
self.flow.should_pump = should_flow;
if !self.flow.pump && self.get_pump() && should_flow {
self.turn_pump_on();
} else if self.flow.pump && (!self.get_pump() || !should_flow) {
self.turn_pump_off();
}
self.current_temperature = self.get_temp_in();
self.temp_reservoir = self.get_temp_out();
if self.current_temperature < self.min_temperature && self.temperature.cooling {
self.turn_cooling_off();
} else if self.current_temperature > self.max_temperature && self.temperature.heating {
self.turn_heating_off();
}
// Calculate PID error once
let error = self.target_temperature.get::<degree_celsius>()
- self.current_temperature.get::<degree_celsius>();
let control = self.pid.update(error, now);
self.temperature_pid_output = control;
let elapsed = now.duration_since(self.window_start);
if elapsed >= self.pwm_period {
self.window_start = now;
}
// Decide whether to heat or cool based on error
if error > 0.0 {
// Need heating (current < target)
if self.temperature.cooling {
self.turn_cooling_off();
}
if self.heating_allowed && current_flow > VolumeRate::new::<liter_per_minute>(0.0) {
// Only start heating if pump is on
let duty = control.clamp(0.0, 1.0);
let on_time = self.pwm_period.mul_f64(duty);
let on = elapsed < on_time;
if on && !self.temperature.heating {
self.turn_heating_on();
} else if !on && self.temperature.heating {
self.turn_heating_off();
}
} else {
// Pump is off or heating not allowed - don't heat
if self.temperature.heating {
self.turn_heating_off();
}
}
} else {
// Need cooling (current > target)
if self.temperature.heating {
self.turn_heating_off();
}
if self.cooling_allowed && !self.temperature.cooling {
self.turn_cooling_on();
}
}
}
}
+264
View File
@@ -0,0 +1,264 @@
use control_core::{
machines::identification::{MachineIdentification, MachineIdentificationUnique},
socketio::namespace::NamespaceCacheingLogic,
};
use control_core_derive::Machine;
use serde::{Deserialize, Serialize};
use std::time::Instant;
use uom::si::{
f64::{ThermodynamicTemperature, VolumeRate},
thermodynamic_temperature::degree_celsius,
volume_rate::liter_per_minute,
};
use crate::machines::{
MACHINE_AQUAPATH_V1, VENDOR_QITECH,
aquapath1::{
api::{
AquaPathV1Events, AquaPathV1Namespace, FlowState, FlowStates, LiveValuesEvent,
ModeState, StateEvent, TempState, TempStates,
},
controller::Controller,
// flow_controller::FlowController,
// temperature_controller::TemperatureController,
},
};
pub mod act;
pub mod api;
pub mod controller;
// pub mod flow_controller;
pub mod new;
// pub mod temperature_controller;
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub enum AquaPathV1Mode {
Standby,
Auto,
}
pub enum AquaPathSideType {
Front,
Back,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Temperature {
pub temperature: ThermodynamicTemperature,
pub cooling: bool,
pub heating: bool,
pub target_temperature: ThermodynamicTemperature,
}
impl Default for Temperature {
fn default() -> Self {
Self {
temperature: ThermodynamicTemperature::new::<degree_celsius>(0.0),
cooling: false,
heating: false,
target_temperature: ThermodynamicTemperature::new::<degree_celsius>(0.0),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Flow {
pub flow: VolumeRate,
pub pump: bool,
pub should_pump: bool,
}
impl Default for Flow {
fn default() -> Self {
Self {
flow: VolumeRate::new::<liter_per_minute>(0.0),
pump: false,
should_pump: false,
}
}
}
#[derive(Debug, Machine)]
pub struct AquaPathV1 {
machine_identification_unique: MachineIdentificationUnique,
namespace: AquaPathV1Namespace,
mode: AquaPathV1Mode,
last_measurement_emit: Instant,
front_controller: Controller,
back_controller: Controller,
}
impl AquaPathV1 {
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
vendor: VENDOR_QITECH,
machine: MACHINE_AQUAPATH_V1,
};
}
impl std::fmt::Display for AquaPathV1 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Aquapath")
}
}
impl AquaPathV1 {
pub fn emit_live_values(&mut self) {
let live_values = LiveValuesEvent {
front_temperature: self
.front_controller
.current_temperature
.get::<degree_celsius>(),
back_temperature: self
.back_controller
.current_temperature
.get::<degree_celsius>(),
front_flow: self.front_controller.current_flow.get::<liter_per_minute>(),
back_flow: self.back_controller.current_flow.get::<liter_per_minute>(),
front_temp_reservoir: self.front_controller.temp_reservoir.get::<degree_celsius>(),
back_temp_reservoir: self.back_controller.temp_reservoir.get::<degree_celsius>(),
};
let event = live_values.build();
self.namespace.emit(AquaPathV1Events::LiveValues(event));
}
pub fn emit_state(&mut self) {
let state = StateEvent {
is_default_state: false,
mode_state: ModeState {
mode: self.mode.clone(),
},
temperature_states: TempStates {
front: TempState {
temperature: self
.front_controller
.current_temperature
.get::<degree_celsius>(),
target_temperature: self
.front_controller
.target_temperature
.get::<degree_celsius>(),
},
back: TempState {
temperature: self
.back_controller
.current_temperature
.get::<degree_celsius>(),
target_temperature: self
.back_controller
.target_temperature
.get::<degree_celsius>(),
},
},
flow_states: FlowStates {
front: FlowState {
flow: self.front_controller.current_flow.get::<liter_per_minute>(),
should_flow: self.front_controller.should_pump,
},
back: FlowState {
flow: self.back_controller.current_flow.get::<liter_per_minute>(),
should_flow: self.back_controller.should_pump,
},
},
};
let event = state.build();
self.namespace.emit(AquaPathV1Events::State(event));
}
}
impl AquaPathV1 {
fn turn_cooling_off(&mut self) {
self.front_controller.disable_cooling();
self.back_controller.disable_cooling();
}
fn turn_cooling_on(&mut self) {
self.front_controller.allow_cooling();
self.back_controller.allow_cooling();
}
fn turn_heating_off(&mut self) {
self.front_controller.disallow_heating();
self.back_controller.disallow_heating();
}
fn turn_heating_on(&mut self) {
self.front_controller.allow_heating();
self.back_controller.allow_heating();
}
fn turn_pump_on(&mut self) {
self.front_controller.allow_pump();
self.back_controller.allow_pump();
}
fn turn_pump_off(&mut self) {
self.front_controller.disallow_pump();
self.back_controller.disallow_pump();
}
fn turn_off_all(&mut self) {
self.turn_cooling_off();
self.turn_heating_off();
self.turn_pump_off();
}
fn turn_on_all(&mut self) {
self.turn_cooling_on();
self.turn_heating_on();
self.turn_pump_on();
}
// Turn all OFF and do nothing
fn switch_to_standby(&mut self) {
match self.mode {
AquaPathV1Mode::Standby => (),
AquaPathV1Mode::Auto => self.turn_off_all(),
};
self.mode = AquaPathV1Mode::Standby;
}
fn switch_to_auto(&mut self) {
match self.mode {
AquaPathV1Mode::Auto => (),
AquaPathV1Mode::Standby => self.turn_on_all(),
}
self.mode = AquaPathV1Mode::Auto;
}
fn switch_mode(&mut self, mode: AquaPathV1Mode) {
if self.mode == mode {
return;
}
match mode {
AquaPathV1Mode::Standby => self.switch_to_standby(),
AquaPathV1Mode::Auto => self.switch_to_auto(),
}
}
}
impl AquaPathV1 {
fn set_mode_state(&mut self, mode: AquaPathV1Mode) {
self.switch_mode(mode.clone());
self.emit_state();
}
}
impl AquaPathV1 {
fn set_target_temperature(&mut self, temperature: f64, cooling_type: AquaPathSideType) {
let target_temp = ThermodynamicTemperature::new::<degree_celsius>(temperature);
match cooling_type {
AquaPathSideType::Back => self.back_controller.set_target_temperature(target_temp),
AquaPathSideType::Front => self.front_controller.set_target_temperature(target_temp),
}
self.emit_state();
}
fn set_should_pump(&mut self, should_pump: bool, cooling_type: AquaPathSideType) {
match cooling_type {
AquaPathSideType::Back => self.back_controller.set_should_pump(should_pump),
AquaPathSideType::Front => self.front_controller.set_should_pump(should_pump),
}
self.emit_state();
}
}
+179
View File
@@ -0,0 +1,179 @@
use super::{AquaPathV1, AquaPathV1Mode};
use crate::machines::{
aquapath1::{Flow, Temperature, api::AquaPathV1Namespace, controller::Controller},
get_ethercat_device,
};
use anyhow::Error;
use control_core::machines::new::{
MachineNewHardware, MachineNewParams, MachineNewTrait, get_subdevice_by_index,
validate_no_role_dublicates, validate_same_machine_identification_unique,
};
use ethercat_hal::{
coe::ConfigurableDevice,
devices::{
ek1100::{EK1100, EK1100_IDENTITY_A},
el2008::{EL2008, EL2008_IDENTITY_A, EL2008Port},
el3204::{EL3204, EL3204_IDENTITY_A, EL3204_IDENTITY_B, EL3204Port},
el4002::{EL4002, EL4002_IDENTITY_A, EL4002Port},
el5152::{
EL5152, EL5152_IDENTITY_A, EL5152Configuration, EL5152Port,
EL5152PredefinedPdoAssignment,
},
},
io::{
analog_output::AnalogOutput, digital_output::DigitalOutput, encoder_input::EncoderInput,
temperature_input::TemperatureInput,
},
};
use std::time::{Duration, Instant};
use uom::si::{f64::ThermodynamicTemperature, thermodynamic_temperature::degree_celsius};
impl MachineNewTrait for AquaPathV1 {
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
// validate general stuff
let device_identification = params
.device_group
.iter()
.map(|device_identification| device_identification.clone())
.collect::<Vec<_>>();
validate_same_machine_identification_unique(&device_identification)?;
validate_no_role_dublicates(&device_identification)?;
let hardware = match &params.hardware {
MachineNewHardware::Ethercat(x) => x,
_ => {
return Err(anyhow::anyhow!(
"[{}::MachineNewTrait/AquaPath1::new] MachineNewHardware is not Ethercat",
module_path!()
));
}
};
smol::block_on(async {
// Role 0 - Buscoupler EK1100
let _ek1100 =
get_ethercat_device::<EK1100>(hardware, params, 0, [EK1100_IDENTITY_A].to_vec());
// Role 1 - EL2008 Digital Output Module
let el2008 =
get_ethercat_device::<EL2008>(hardware, params, 1, [EL2008_IDENTITY_A].to_vec())
.await?
.0;
// Role 2 - EL4002 Analog Output Module
let el4002 =
get_ethercat_device::<EL4002>(hardware, params, 2, [EL4002_IDENTITY_A].to_vec())
.await?
.0;
let el3204 = get_ethercat_device::<EL3204>(
hardware,
params,
3,
[EL3204_IDENTITY_A, EL3204_IDENTITY_B].to_vec(),
)
.await?
.0;
let el5152 =
get_ethercat_device::<EL5152>(hardware, params, 4, [EL5152_IDENTITY_A].to_vec())
.await?
.0;
let config = EL5152Configuration {
pdo_assignment: EL5152PredefinedPdoAssignment::Frequency,
..Default::default()
};
let subdevice = get_subdevice_by_index(hardware.subdevices, 4)?;
el5152
.write()
.await
.write_config(&subdevice, &config)
.await?;
let enc1 = EncoderInput::new(el5152.clone(), EL5152Port::ENC1);
let enc2 = EncoderInput::new(el5152.clone(), EL5152Port::ENC2);
//after heating
let t1 = TemperatureInput::new(el3204.clone(), EL3204Port::T1);
//in reservoir
let t2 = TemperatureInput::new(el3204.clone(), EL3204Port::T2);
//after heating
let t3 = TemperatureInput::new(el3204.clone(), EL3204Port::T3);
//in reservoir
let t4 = TemperatureInput::new(el3204.clone(), EL3204Port::T4);
//pump flow control
//phys 1
let do1 = DigitalOutput::new(el2008.clone(), EL2008Port::DO1);
//phys 5
let do2 = DigitalOutput::new(el2008.clone(), EL2008Port::DO2);
//heating
//phys 2
let do3 = DigitalOutput::new(el2008.clone(), EL2008Port::DO3);
//phys 6
let do4 = DigitalOutput::new(el2008.clone(), EL2008Port::DO4);
//phys 3
let do5 = DigitalOutput::new(el2008.clone(), EL2008Port::DO5);
//phys 7
let do6 = DigitalOutput::new(el2008.clone(), EL2008Port::DO6);
//cooling power cut
//phys 4
let do7 = DigitalOutput::new(el2008.clone(), EL2008Port::DO7);
//phys 8
let do8 = DigitalOutput::new(el2008.clone(), EL2008Port::DO8);
let ao1 = AnalogOutput::new(el4002.clone(), EL4002Port::AO1);
let ao2 = AnalogOutput::new(el4002.clone(), EL4002Port::AO2);
let front_controller = Controller::new(
0.10,
0.0,
0.015,
Duration::from_millis(500),
Temperature::default(),
ThermodynamicTemperature::new::<degree_celsius>(25.0),
ao1,
do7,
do3,
do5,
t1,
t2,
Flow::default(),
do1,
enc1,
);
let back_controller = Controller::new(
0.10,
0.0,
0.015,
Duration::from_millis(500),
Temperature::default(),
ThermodynamicTemperature::new::<degree_celsius>(25.0),
ao2,
do8,
do4,
do6,
t3,
t4,
Flow::default(),
do2,
enc2,
);
let mut water_cooling = Self {
machine_identification_unique: params.get_machine_identification_unique(),
namespace: AquaPathV1Namespace {
namespace: params.namespace.clone(),
},
mode: AquaPathV1Mode::Standby,
last_measurement_emit: Instant::now(),
front_controller,
back_controller,
};
water_cooling.emit_state();
Ok(water_cooling)
})
}
}
+13 -2
View File
@@ -4,9 +4,20 @@ use super::BufferV1;
use control_core::machines::new::MachineAct;
impl MachineAct for BufferV1 {
fn act(&mut self, now: Instant) {
// sync the lift speed
self.sync_lift_speed(now);
// sync the puller speed
self.sync_puller_speed(now);
// check if lift state changed
if self.buffer_lift_controller.did_change_state() {
self.emit_state();
}
// if last measurement is older than 1 second, emit a new measurement
if now.duration_since(self.last_measurement_emit) > Duration::from_secs_f64(1.0 / 30.0) {
// Emit live values at 30 FPS
if now.duration_since(self.last_measurement_emit) > Duration::from_secs_f64(1.0 / 60.0) {
// Emit live values at 60 FPS
self.emit_live_values();
self.last_measurement_emit = now;
+98 -18
View File
@@ -1,8 +1,13 @@
use std::{sync::Arc, time::Duration};
use crate::machines::buffer1::puller_speed_controller::PullerRegulationMode;
use super::{BufferV1, BufferV1Mode};
use control_core::{
machines::{api::MachineApi, identification::MachineIdentificationUnique},
machines::{
api::MachineApi, connection::MachineCrossConnectionState,
identification::MachineIdentificationUnique,
},
socketio::{
event::{Event, GenericEvent},
namespace::{
@@ -13,12 +18,16 @@ use control_core::{
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use smol::channel::Sender;
use socketioxide::extract::SocketRef;
use smol::lock::Mutex;
use tracing::instrument;
#[derive(Serialize, Debug, Clone, Default)]
pub struct LiveValuesEvent {}
pub struct LiveValuesEvent {
/// lift position in mm
pub lift_position: Option<f64>,
/// puller speed in m/min
pub puller_speed: f64,
}
impl LiveValuesEvent {
pub fn build(&self) -> Event<Self> {
@@ -30,8 +39,14 @@ impl LiveValuesEvent {
pub struct StateEvent {
/// mode state
pub mode_state: ModeState,
/// lift state
pub lift_state: LiftState,
/// puller state
pub puller_state: PullerState,
/// connected machine state
pub connected_machine_state: ConnectedMachineState,
pub connected_machine_state: MachineCrossConnectionState,
/// CurrentInputSpeed
pub current_input_speed_state: CurrentInputSpeedState,
}
impl StateEvent {
@@ -53,8 +68,9 @@ pub struct ModeState {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum Mode {
Standby,
FillingBuffer,
EmptyingBuffer,
Hold,
Filling,
Emptying,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
@@ -64,6 +80,54 @@ pub struct ConnectedMachineState {
pub is_available: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PullerState {
/// regulation type
pub regulation: PullerRegulationMode,
/// target speed in m/min
pub target_speed: f64,
/// target diameter in mm
pub target_diameter: f64,
/// forward rotation direction
pub forward: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct LiftState {
/// min position in mm
pub limit_top: f64,
/// max position in mm
pub limit_bottom: f64,
/// position in mm
pub position_in: f64,
/// position out in mm
pub position_out: f64,
/// is going to position in
pub is_going_up: bool,
/// is going to position out
pub is_going_down: bool,
/// if is homed
pub is_homed: bool,
/// if is homing
pub is_going_home: bool,
/// if is buffering
pub is_buffering: bool,
/// step size in mm
pub step_size: f64,
/// padding in mm
pub padding: f64,
/// can go top (to top limit)
pub can_go_top: bool,
/// can go bottom (to bottom limit)
pub can_go_bottom: bool,
/// can home
pub can_go_home: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct CurrentInputSpeedState {
pub current_input_speed: f64,
}
#[derive(Deserialize, Serialize)]
enum Mutation {
// Mode
@@ -74,11 +138,26 @@ enum Mutation {
// Disconnect Machine
DisconnectMachine(MachineIdentificationUnique),
// Set current input speed
SetCurrentInputSpeed(f64),
// Lift
/// Step size in mm for traverse movement
SetLiftStepSize(f64),
GotoLiftHome,
// Puller
/// on = speed, off = stop
SetPullerRegulationMode(PullerRegulationMode),
SetPullerTargetSpeed(f64),
SetPullerTargetDiameter(f64),
SetPullerForward(bool),
}
#[derive(Debug)]
pub struct Buffer1Namespace {
pub namespace: Namespace,
pub namespace: Arc<Mutex<Namespace>>,
}
impl NamespaceCacheingLogic<BufferV1Events> for Buffer1Namespace {
@@ -86,15 +165,9 @@ impl NamespaceCacheingLogic<BufferV1Events> for Buffer1Namespace {
fn emit(&mut self, events: BufferV1Events) {
let event = Arc::new(events.event_value());
let buffer_fn = events.event_cache_fn();
self.namespace.emit(event, &buffer_fn);
}
}
impl Buffer1Namespace {
pub fn new(socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>) -> Self {
Self {
namespace: Namespace::new(socket_queue_tx),
}
let mut namespace = self.namespace.lock_blocking();
namespace.emit(event, &buffer_fn);
}
}
@@ -128,11 +201,18 @@ impl MachineApi for BufferV1 {
Mutation::DisconnectMachine(machine_identification_unique) => {
self.disconnect_winder(machine_identification_unique);
}
Mutation::SetCurrentInputSpeed(speed) => self.set_current_input_speed(speed),
Mutation::SetPullerRegulationMode(regulation) => self.puller_set_regulation(regulation),
Mutation::SetPullerTargetSpeed(value) => self.puller_set_target_speed(value),
Mutation::SetPullerTargetDiameter(_) => todo!(),
Mutation::SetPullerForward(value) => self.puller_set_forward(value),
Mutation::SetLiftStepSize(step_size) => self.lift_set_step_size(step_size),
Mutation::GotoLiftHome => self.lift_goto_home(),
}
Ok(())
}
fn api_event_namespace(&mut self) -> &mut Namespace {
&mut self.namespace.namespace
fn api_event_namespace(&mut self) -> Arc<Mutex<Namespace>> {
self.namespace.namespace.clone()
}
}
@@ -0,0 +1,543 @@
use std::time::Instant;
use control_core::{
controllers::first_degree_motion::linear_acceleration_speed_controller::LinearAccelerationLimitingController,
converters::linear_step_converter::LinearStepConverter,
uom_extensions::velocity::meter_per_minute,
};
use ethercat_hal::io::{
digital_input::DigitalInput, stepper_velocity_el70x1::StepperVelocityEL70x1,
};
use tracing::info;
use uom::{
ConstZero,
si::{
acceleration::centimeter_per_second_squared,
f64::{Acceleration, Length, Velocity},
length::millimeter,
velocity::millimeter_per_second,
},
};
#[derive(Debug)]
pub struct BufferLiftController {
/// Whether the speed controller is enabled or not
enabled: bool,
position: Length,
limit_top: Length,
limit_bottom: Length,
step_size: Length,
padding: Length,
/// Stepper driver. Controls buffer stepper motor
pub stepper_driver: StepperVelocityEL70x1,
// Step Converter
pub fullstep_converter: LinearStepConverter,
pub microstep_converter: LinearStepConverter,
/// Linear acceleration controller to dampen speed change
acceleration_controller: LinearAccelerationLimitingController,
/// Forward rotation direction. If false, applies negative sign to speed
forward: bool,
state: State,
/// Fixed constants
spool_amount: u8,
/// Variables
current_input_speed: Velocity,
target_output_speed: Velocity,
lift_speed: Velocity,
// A sticky flag if the [`State`] changed (not the sub states)
// Needed to send state updates to the UI
did_change_state: bool,
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum State {
/// Initial state
NotHomed,
/// Doing nothing
/// Already homed
Idle,
/// Going to upper limit
///
/// After reaching the upper limit, the state will change to [`State::Idle`]
GoingUp,
/// Going to lower limit
///
/// After reaching the lower limit, the state will change to [`State::Idle`]
GoingDown,
/// Homing in progress
///
/// After homing is done, the state will change to [`State::Idle`]
Homing(HomingState),
/// Move at target speed to Buffer
Buffering(BufferingState),
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum BufferingState {
GoingUp,
Filling,
Emptying,
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum HomingState {
/// In this state the lift is not moving but checks if the endstop si triggered
/// If the endstop is triggered we go into [`HomingState::EscapeEndstop`]
/// If the endstop is not triggered we go into [`HomingState::FindEndstop`]
Initialize,
/// In this state the lift is moving out away from the endstop until it's not triggered anymore
/// The it goes into [`HomingState::FindEnstopFineDistancing`]
EscapeEndstop,
/// Moving out away from the endstop
/// Then Transition into [`HomingState::FindEndtopFine`]
FindEnstopFineDistancing,
/// In this state the lift is fast until it reaches the endstop
FindEndstopCoarse,
/// In this state the lift is moving slowly until it reaches the endstop
FindEndStopFine,
/// In this state we check if th current position is actually 0.0, if not we redo the homing routine
Validate(Instant),
}
impl BufferLiftController {
pub fn new(
driver: StepperVelocityEL70x1,
limit_top: Length,
limit_bot: Length,
microsteps: u8,
) -> Self {
Self {
enabled: false,
position: Length::ZERO,
limit_top,
limit_bottom: limit_bot,
step_size: Length::new::<millimeter>(1.75), // Default padding
padding: Length::new::<millimeter>(0.9), // Default padding
did_change_state: false,
stepper_driver: driver,
fullstep_converter: LinearStepConverter::from_diameter(
200,
Length::new::<millimeter>(32.22),
),
microstep_converter: LinearStepConverter::from_diameter(
200 * microsteps as i16,
Length::new::<millimeter>(32.22),
),
spool_amount: 13,
state: State::NotHomed,
forward: true,
acceleration_controller: LinearAccelerationLimitingController::new_simple(
Acceleration::new::<centimeter_per_second_squared>(1.0),
Velocity::new::<millimeter_per_second>(5.0),
),
current_input_speed: Velocity::ZERO,
target_output_speed: Velocity::ZERO,
lift_speed: Velocity::ZERO,
}
}
}
impl BufferLiftController {
/// Calculate the speed of the buffer lift from current input speed
///
/// Formula: input_speed / ( 2 * spool_amount - 1 )
pub fn calculate_buffer_lift_speed(&self) -> Velocity {
let lift_speed = Velocity::new::<millimeter_per_second>(
(self.current_input_speed.get::<millimeter_per_second>()
- self.target_output_speed.get::<millimeter_per_second>())
/ (2.0 * self.spool_amount as f64 - 1.0),
);
lift_speed
}
pub fn update_speed(
&mut self,
stepper_driver: &mut StepperVelocityEL70x1,
lift_end_stop: &DigitalInput,
t: Instant,
) -> Velocity {
let speed = match self.enabled {
true => self.get_speed(stepper_driver, lift_end_stop),
false => Velocity::ZERO,
};
// Stepper is installed in reverse direction so we change to sign
let speed = speed;
self.acceleration_controller.update(speed, t)
}
}
/// Getter & Setter
impl BufferLiftController {
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
self.stepper_driver.set_enabled(enabled);
}
pub fn set_limit_top(&mut self, limit: Length) {
self.limit_top = limit;
}
pub fn set_limit_bottom(&mut self, limit: Length) {
self.limit_bottom = limit;
}
pub fn set_step_size(&mut self, step_size: Length) {
self.step_size = step_size;
}
pub fn set_padding(&mut self, padding: Length) {
self.padding = padding;
}
pub fn get_limit_top(&self) -> Length {
self.limit_top
}
pub fn get_limit_bottom(&self) -> Length {
self.limit_bottom
}
pub fn get_step_size(&self) -> Length {
self.step_size
}
pub fn get_padding(&self) -> Length {
self.padding
}
pub fn set_forward(&mut self, forward: bool) {
self.forward = forward;
}
pub fn get_current_position(&self) -> Option<Length> {
match self.is_homed() {
true => Some(self.position),
false => None,
}
}
pub fn did_change_state(&mut self) -> bool {
let did_change = self.did_change_state;
// Reset the flag
self.did_change_state = false;
did_change
}
pub fn set_current_input_speed(&mut self, speed: f64) {
self.current_input_speed = Velocity::new::<meter_per_minute>(speed);
}
pub fn set_target_output_speed(&mut self, speed: f64) {
self.target_output_speed = Velocity::new::<meter_per_minute>(speed);
}
pub fn get_current_input_speed(&self) -> Velocity {
self.current_input_speed
}
pub fn get_target_output_speed(&self) -> Velocity {
self.target_output_speed
}
pub fn get_lift_speed(&self) -> Velocity {
self.lift_speed
}
}
/// State management
impl BufferLiftController {
pub fn goto_limit_top(&mut self) {
self.state = State::GoingUp
}
pub fn goto_limit_bottom(&mut self) {
self.state = State::GoingDown
}
pub fn goto_home(&mut self) {
self.state = State::Homing(HomingState::Initialize);
}
pub fn start_buffering(&mut self) {
self.state = State::Buffering(BufferingState::GoingUp);
}
pub fn is_going_up(&self) -> bool {
// [`State::GoingUp`]
matches!(self.state, State::GoingUp)
}
pub fn is_going_down(&self) -> bool {
// [`State::GoingDown`]
matches!(self.state, State::GoingDown)
}
pub fn is_homed(&self) -> bool {
// if not [`State::NotHomed`], then it is homed
!matches!(self.state, State::NotHomed)
}
pub fn is_filling(&self) -> bool {
// [`State::Filling`]
matches!(self.state, State::Buffering(BufferingState::Filling))
}
pub fn is_emptying(&self) -> bool {
// [`State::Emptying`]
matches!(self.state, State::Buffering(BufferingState::Emptying))
}
pub fn is_going_home(&self) -> bool {
// [`State::Homing`]
matches!(self.state, State::Homing(_))
}
pub fn is_buffering(&self) -> bool {
// [`State::Buffering`]
matches!(self.state, State::Buffering(_))
}
}
impl BufferLiftController {
// If at inner limit within a tolerance
fn is_at_position(&self, target_position: Length, tolerance: Length) -> bool {
let upper_tolerance = target_position + tolerance.abs();
let lower_tolerance = target_position - tolerance.abs();
if self.position >= lower_tolerance && self.position <= upper_tolerance {
return true;
} else {
return false;
}
}
/// Calculates distance to position
fn distance_to_position(&self, target_position: Length) -> Length {
if self.position > target_position {
return self.position - target_position;
} else if self.position < target_position {
return target_position - self.position;
} else {
return Length::ZERO;
}
}
// Changes the direction of the speed based on the current position and target position
fn speed_to_position(&self, target_position: Length, absolute_speed: Velocity) -> Velocity {
// If we are over the target position we need to move negative
if self.position > target_position {
return -absolute_speed.abs();
} else if self.position < target_position {
return absolute_speed.abs();
} else {
return Velocity::ZERO;
}
}
/// Gets the current lift position as a [`Length`].
pub fn sync_position(&mut self, stepper_driver: &StepperVelocityEL70x1) {
let steps = stepper_driver.get_position();
self.position = self.microstep_converter.steps_to_distance(steps as f64);
}
/// Update the [`did_change_state`] flag
/// Only consider the major state not the sub states
fn update_did_change_state(&mut self, old_state: &State) -> bool {
match self.state {
State::NotHomed => !matches!(old_state, State::NotHomed),
State::Idle => !matches!(old_state, State::Idle),
State::GoingUp => !matches!(old_state, State::GoingUp),
State::GoingDown => !matches!(old_state, State::GoingDown),
State::Homing(_) => !matches!(old_state, State::Homing(_)),
State::Buffering(_) => !matches!(old_state, State::Buffering(_)),
}
}
/// Calculates a desired speed based on the current state and the end stop status.
///
/// Positive speed moves Up, negative speed moves down
fn get_speed(
&mut self,
stepper_driver: &mut StepperVelocityEL70x1,
lift_end_stop: &DigitalInput,
) -> Velocity {
// Don't move if not enabled or in a state that doesn't result in movement
if !self.enabled {
return Velocity::ZERO;
}
self.sync_position(stepper_driver);
// save state before
let old_state = self.state.clone();
// Automatic Transitions
match &self.state {
State::NotHomed => {}
State::Idle => {}
State::GoingDown => {
// If lower limit is reached
if self.is_at_position(self.limit_bottom, Length::new::<millimeter>(0.01)) {
// Put Into Idle
self.state = State::Idle;
}
}
State::GoingUp => {
// If upper limit is reached
if self.is_at_position(self.limit_top, Length::new::<millimeter>(0.01)) {
// Put Into Idle
self.state = State::Idle;
}
}
State::Homing(homing_state) => match homing_state {
HomingState::Initialize => {
// If endstop is triggered, escape the endstop
if lift_end_stop.get_value().unwrap_or(false) == true {
self.state = State::Homing(HomingState::EscapeEndstop);
} else {
// If endstop is not triggered, move to the endstop
self.state = State::Homing(HomingState::FindEndstopCoarse);
}
}
HomingState::EscapeEndstop => {
// Move out until endstop is not triggered anymore
if lift_end_stop.get_value().unwrap_or(false) == false {
self.state = State::Homing(HomingState::FindEnstopFineDistancing);
}
}
HomingState::FindEnstopFineDistancing => {
// Move out until endstop is not triggered anymore
if lift_end_stop.get_value().unwrap_or(false) == false {
// Find endstop fine
self.state = State::Homing(HomingState::FindEndStopFine);
}
}
HomingState::FindEndStopFine => {
// If endstop is reached change to idle
if lift_end_stop.get_value().unwrap_or(false) == true {
// Set poition of lift to 0
stepper_driver.set_position(0);
// Put Into Idle
self.state = State::Homing(HomingState::Validate(Instant::now()));
}
}
HomingState::FindEndstopCoarse => {
// Move to endstop
if lift_end_stop.get_value().unwrap_or(false) == true {
// Move awaiy from endstop
self.state = State::Homing(HomingState::FindEnstopFineDistancing);
}
}
HomingState::Validate(instant) => {
// If 100ms have passed check if position is actually 0.0
if instant.elapsed().as_millis() > 100 {
if self.is_at_position(Length::ZERO, Length::new::<millimeter>(0.01)) {
// If position is 0.0, put into idle
self.state = State::Idle;
} else {
// If position is not 0.0, redo homing
self.state = State::Homing(HomingState::Initialize);
}
}
}
},
// If state changed we
State::Buffering(buffering_state) => match buffering_state {
BufferingState::GoingUp => {}
BufferingState::Emptying => {}
BufferingState::Filling => {}
},
}
// Set the [`did_change_state`] flag
if self.did_change_state == false {
self.did_change_state = self.update_did_change_state(&old_state);
info!("{:?}", self.state);
}
// Speed
let speed = match &self.state {
State::NotHomed => Velocity::ZERO, // Not homed, no movement
State::Idle => Velocity::ZERO, // No movement in idle state
State::GoingDown => {
// Move down at a speed of 10-100 mm/s
self.speed_to_position(
self.limit_bottom,
match self.distance_to_position(self.limit_bottom).abs()
> Length::new::<millimeter>(1.0)
{
true => Velocity::new::<millimeter_per_second>(100.0),
false => Velocity::new::<millimeter_per_second>(10.0),
},
)
}
State::GoingUp => {
// Move up at a speed of 10-100 mm/s
self.speed_to_position(
self.limit_top,
match self.distance_to_position(self.limit_top).abs()
> Length::new::<millimeter>(1.0)
{
true => Velocity::new::<millimeter_per_second>(100.0),
false => Velocity::new::<millimeter_per_second>(10.0),
},
)
}
State::Homing(homing_state) => match homing_state {
HomingState::Initialize => Velocity::ZERO,
HomingState::EscapeEndstop => {
// Move down at a speed of 10 mm/s
Velocity::new::<millimeter_per_second>(10.0)
}
HomingState::FindEnstopFineDistancing => {
// Move up at a speed of 2 mm/s
Velocity::new::<millimeter_per_second>(2.0)
}
HomingState::FindEndstopCoarse => {
// Move in at a speed of -100 mm/s
Velocity::new::<millimeter_per_second>(-100.0)
}
HomingState::FindEndStopFine => {
// move into the endstop at 2 mm/s
Velocity::new::<millimeter_per_second>(-2.0)
}
HomingState::Validate(_) => {
// We stand still until the validation cooldown has passed
Velocity::ZERO
}
}, // Homing speed
State::Buffering(buffering_state) => match buffering_state {
BufferingState::GoingUp => {
// Move top at a speed of 100 mm/s
self.speed_to_position(
self.limit_top - self.padding + Length::new::<millimeter>(0.01),
Velocity::new::<millimeter_per_second>(100.0),
)
}
BufferingState::Filling => self.speed_to_position(
self.limit_top + self.padding - Length::new::<millimeter>(0.01),
Self::calculate_buffer_lift_speed(self),
),
BufferingState::Emptying => self.speed_to_position(
self.limit_bottom - self.padding + Length::new::<millimeter>(0.01),
Self::calculate_buffer_lift_speed(self),
),
},
};
info!("{}", speed.get::<millimeter_per_second>());
speed
}
}
@@ -1,27 +0,0 @@
use ethercat_hal::io::stepper_velocity_el70x1::StepperVelocityEL70x1;
#[derive(Debug)]
pub struct BufferTowerController {
enabled: bool,
/// Stepper driver. Controls buffer stepper motor
pub stepper_driver: StepperVelocityEL70x1,
}
impl BufferTowerController {
pub const fn new(driver: StepperVelocityEL70x1) -> Self {
Self {
enabled: false,
stepper_driver: driver,
}
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
if enabled {
self.stepper_driver.set_enabled(true);
let _ = self.stepper_driver.set_speed(10.0);
} else {
self.stepper_driver.set_enabled(false);
let _ = self.stepper_driver.set_speed(0.0);
}
}
}
+302 -101
View File
@@ -1,35 +1,55 @@
pub mod act;
pub mod api;
pub mod buffer_tower_controller;
pub mod buffer_lift_controller;
pub mod new;
pub mod puller_speed_controller;
use api::{Buffer1Namespace, BufferV1Events, LiveValuesEvent, ModeState, StateEvent};
use buffer_tower_controller::BufferTowerController;
use buffer_lift_controller::BufferLiftController;
use control_core::{
converters::linear_step_converter::LinearStepConverter,
machines::{
ConnectedMachine, ConnectedMachineData, downcast_machine,
connection::{CrossConnectableMachine, MachineConnection, MachineCrossConnection},
identification::{MachineIdentification, MachineIdentificationUnique},
manager::MachineManager,
},
socketio::namespace::NamespaceCacheingLogic,
uom_extensions::velocity::meter_per_minute,
};
use control_core_derive::Machine;
use futures::executor::block_on;
use ethercat_hal::io::{
digital_input::DigitalInput, stepper_velocity_el70x1::StepperVelocityEL70x1,
};
use puller_speed_controller::PullerRegulationMode;
use serde::{Deserialize, Serialize};
use smol::lock::{Mutex, RwLock};
use std::{
sync::{Arc, Weak},
time::Instant,
use smol::lock::RwLock;
use std::{sync::Weak, time::Instant};
use uom::si::{
f64::{Length, Velocity},
length::millimeter,
};
use crate::machines::{
MACHINE_BUFFER_V1, VENDOR_QITECH, buffer1::api::ConnectedMachineState, winder2::Winder2,
MACHINE_BUFFER_V1, VENDOR_QITECH,
buffer1::{
api::{CurrentInputSpeedState, LiftState, PullerState},
puller_speed_controller::PullerSpeedController,
},
winder2::{BufferState, Winder2, Winder2Mode},
};
#[derive(Debug, Machine)]
pub struct BufferV1 {
// drivers
pub lift: StepperVelocityEL70x1,
pub lift_end_stop: DigitalInput,
pub puller: StepperVelocityEL70x1,
// controllers
pub buffer_tower_controller: BufferTowerController,
pub buffer_lift_controller: BufferLiftController,
pub puller_speed_controller: PullerSpeedController,
pub lift_step_converter: LinearStepConverter,
// socketio
namespace: Buffer1Namespace,
@@ -40,12 +60,18 @@ pub struct BufferV1 {
pub machine_identification_unique: MachineIdentificationUnique,
// connected machines
pub connected_winder: Option<ConnectedMachine<Weak<Mutex<Winder2>>>>,
pub connected_winder: MachineCrossConnection<BufferV1, Winder2>,
// mode
mode: BufferV1Mode,
}
impl CrossConnectableMachine<BufferV1, Winder2> for BufferV1 {
fn get_cross_connection(&mut self) -> &mut MachineCrossConnection<BufferV1, Winder2> {
&mut self.connected_winder
}
}
impl std::fmt::Display for BufferV1 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "BufferV1")
@@ -57,11 +83,25 @@ impl BufferV1 {
vendor: VENDOR_QITECH,
machine: MACHINE_BUFFER_V1,
};
}
impl BufferV1 {
pub fn emit_live_values(&mut self) {
let live_values = LiveValuesEvent {};
// Calculate puller speed from current motor steps
let steps_per_second = self.puller.get_speed();
let angular_velocity = self
.puller_speed_controller
.converter
.steps_to_angular_velocity(steps_per_second as f64);
let puller_speed = self
.puller_speed_controller
.angular_velocity_to_speed(angular_velocity);
// live values to be emittet
let live_values = LiveValuesEvent {
lift_position: self
.buffer_lift_controller
.get_current_position()
.map(|x| x.get::<millimeter>()),
puller_speed: puller_speed.get::<meter_per_minute>(),
};
let event = live_values.build();
self.namespace.emit(BufferV1Events::LiveValues(event));
@@ -72,67 +112,196 @@ impl BufferV1 {
mode_state: ModeState {
mode: self.mode.clone(),
},
connected_machine_state: ConnectedMachineState {
machine_identification_unique: self.connected_winder.as_ref().map(
|connected_machine| {
ConnectedMachineData::from(connected_machine).machine_identification_unique
},
),
is_available: self
.connected_winder
.as_ref()
.map(|connected_machine| {
ConnectedMachineData::from(connected_machine).is_available
})
.unwrap_or(false),
connected_machine_state: self.connected_winder.to_state(),
lift_state: LiftState {
limit_top: self
.buffer_lift_controller
.get_limit_top()
.get::<millimeter>(),
limit_bottom: self
.buffer_lift_controller
.get_limit_bottom()
.get::<millimeter>(),
position_in: self
.buffer_lift_controller
.get_limit_top()
.get::<millimeter>(),
position_out: self
.buffer_lift_controller
.get_limit_bottom()
.get::<millimeter>(),
is_going_up: self.buffer_lift_controller.is_going_up(),
is_going_down: self.buffer_lift_controller.is_going_down(),
is_homed: self.buffer_lift_controller.is_homed(),
is_going_home: self.buffer_lift_controller.is_going_home(),
is_buffering: self.buffer_lift_controller.is_buffering(),
step_size: self
.buffer_lift_controller
.get_step_size()
.get::<millimeter>(),
padding: self
.buffer_lift_controller
.get_padding()
.get::<millimeter>(),
can_go_top: self.can_go_up(),
can_go_bottom: self.can_go_down(),
can_go_home: self.can_go_home(),
},
current_input_speed_state: CurrentInputSpeedState {
current_input_speed: self
.buffer_lift_controller
.get_current_input_speed()
.get::<meter_per_minute>(),
},
puller_state: PullerState {
regulation: self.puller_speed_controller.regulation_mode.clone(),
target_speed: self
.puller_speed_controller
.target_speed
.get::<meter_per_minute>(),
target_diameter: self
.puller_speed_controller
.target_diameter
.get::<millimeter>(),
forward: self.puller_speed_controller.forward,
},
};
let event = state.build();
self.namespace.emit(BufferV1Events::State(event));
}
/// Can buffer capability check
pub fn can_buffer(&self) -> bool {
// Check if tension arm is zeroed and traverse is homed
self.buffer_lift_controller.is_homed() && !self.buffer_lift_controller.is_going_home()
}
/// Can go to upper limit capability check
pub fn can_go_up(&self) -> bool {
self.buffer_lift_controller.is_homed()
&& self.mode != BufferV1Mode::Standby
&& !self.buffer_lift_controller.is_going_up()
&& !self.buffer_lift_controller.is_going_home()
&& !self.buffer_lift_controller.is_buffering()
&& self.mode != BufferV1Mode::Filling
&& self.mode != BufferV1Mode::Emptying
}
/// Can go to lower limit capability check
pub fn can_go_down(&self) -> bool {
self.buffer_lift_controller.is_homed()
&& self.mode != BufferV1Mode::Standby
&& !self.buffer_lift_controller.is_going_down()
&& !self.buffer_lift_controller.is_going_home()
&& !self.buffer_lift_controller.is_buffering()
&& self.mode != BufferV1Mode::Filling
&& self.mode != BufferV1Mode::Emptying
}
/// Can go home capability check
pub fn can_go_home(&self) -> bool {
// Check if not in standby, not traversing
// Allow going home even when going in or out
self.mode != BufferV1Mode::Standby
&& !self.buffer_lift_controller.is_going_home()
&& !self.buffer_lift_controller.is_buffering()
&& self.mode != BufferV1Mode::Filling
&& self.mode != BufferV1Mode::Emptying
}
}
impl BufferV1 {
// To be implemented
fn fill_buffer(&mut self) {
todo!();
// stop the winder until the buffer is full
self.update_winder2_mode(Winder2Mode::Hold);
self.update_winder2_buffer_state(BufferState::Buffering);
}
fn empty_buffer(&mut self) {
todo!();
// Set the winder2 to a mode where its faster than before to empty the buffer slowly
self.update_winder2_mode(Winder2Mode::Wind);
self.update_winder2_buffer_state(BufferState::Emptying);
}
fn update_winder2_mode(&mut self, mode: Winder2Mode) {
if let Some(slot) = self.get_cross_connection().connected_machine.upgrade() {
let slot = slot.lock_blocking();
if let MachineConnection::Connected(machine) = &slot.machine_connection {
let mut winder = machine.lock_blocking();
if winder.mode != mode {
winder.mode = mode;
}
}
}
}
fn update_winder2_buffer_state(&mut self, state: BufferState) {
if let Some(slot) = self.get_cross_connection().connected_machine.upgrade() {
let slot = slot.lock_blocking();
if let MachineConnection::Connected(machine) = &slot.machine_connection {
let mut winder = machine.lock_blocking();
if winder.buffer_state != state {
winder.set_buffer_state(state);
}
}
}
}
// Turn off motor and do nothing
fn switch_to_standby(&mut self) {
match self.mode {
BufferV1Mode::Standby => (),
BufferV1Mode::FillingBuffer => {}
BufferV1Mode::EmptyingBuffer => {}
BufferV1Mode::Hold => {}
BufferV1Mode::Filling => {}
BufferV1Mode::Emptying => {}
};
self.mode = BufferV1Mode::Standby;
self.buffer_tower_controller.set_enabled(false);
self.buffer_lift_controller.set_enabled(false);
let _ = self.buffer_lift_controller.stepper_driver.set_speed(0.0);
self.update_winder2_buffer_state(BufferState::Hold);
}
// hold motor
fn switch_to_hold(&mut self) {
match self.mode {
BufferV1Mode::Standby => {}
BufferV1Mode::Hold => (),
BufferV1Mode::Filling => {}
BufferV1Mode::Emptying => {}
};
self.mode = BufferV1Mode::Hold;
self.buffer_lift_controller.set_enabled(true);
let _ = self.buffer_lift_controller.stepper_driver.set_speed(0.0);
self.update_winder2_buffer_state(BufferState::Hold);
}
// Turn on motor and fill buffer
fn switch_to_filling(&mut self) {
match self.mode {
BufferV1Mode::Standby => self.fill_buffer(),
BufferV1Mode::FillingBuffer => (),
BufferV1Mode::EmptyingBuffer => {}
BufferV1Mode::Hold => {}
BufferV1Mode::Filling => (),
BufferV1Mode::Emptying => {}
};
self.mode = BufferV1Mode::FillingBuffer;
self.buffer_tower_controller.set_enabled(true);
self.mode = BufferV1Mode::Filling;
self.buffer_lift_controller.set_forward(true);
self.buffer_lift_controller.set_enabled(true);
}
// Turn on motor reverse and empty buffer
fn switch_to_emptying(&mut self) {
match self.mode {
BufferV1Mode::Standby => self.empty_buffer(),
BufferV1Mode::FillingBuffer => {}
BufferV1Mode::EmptyingBuffer => (),
BufferV1Mode::Hold => {}
BufferV1Mode::Filling => {}
BufferV1Mode::Emptying => (),
};
self.mode = BufferV1Mode::EmptyingBuffer;
self.mode = BufferV1Mode::Emptying;
self.buffer_lift_controller.set_forward(false);
self.buffer_lift_controller.set_enabled(true);
}
fn switch_mode(&mut self, mode: BufferV1Mode) {
@@ -142,20 +311,99 @@ impl BufferV1 {
match mode {
BufferV1Mode::Standby => self.switch_to_standby(),
BufferV1Mode::FillingBuffer => self.switch_to_filling(),
BufferV1Mode::EmptyingBuffer => self.switch_to_emptying(),
BufferV1Mode::Hold => self.switch_to_hold(),
BufferV1Mode::Filling => self.switch_to_filling(),
BufferV1Mode::Emptying => self.switch_to_emptying(),
}
}
}
impl BufferV1 {
fn set_mode_state(&mut self, mode: BufferV1Mode) {
self.switch_mode(mode);
self.emit_state();
}
fn set_current_input_speed(&mut self, speed: f64) {
// speed comes as a f64 represents m/min
self.buffer_lift_controller.set_current_input_speed(speed);
self.emit_state();
}
}
/// Connecting/Disconnecting machine
// Implement Puller
impl BufferV1 {
/// called by `act`
pub fn sync_puller_speed(&mut self, t: Instant) {
let angular_velocity = self.puller_speed_controller.calc_angular_velocity(t);
let steps_per_second = self
.puller_speed_controller
.converter
.angular_velocity_to_steps(angular_velocity);
let _ = self.puller.set_speed(steps_per_second);
// sync puller speed to lift input speed
let linear_velocity = self
.puller_speed_controller
.converter
.angular_velocity_to_velocity(angular_velocity);
self.buffer_lift_controller
.set_current_input_speed(linear_velocity.get::<meter_per_minute>());
}
pub fn puller_set_regulation(&mut self, puller_regulation_mode: PullerRegulationMode) {
self.puller_speed_controller
.set_regulation_mode(puller_regulation_mode);
self.emit_state();
}
/// Set target speed in m/min
pub fn puller_set_target_speed(&mut self, target_speed: f64) {
// Convert m/min to velocity
let target_speed = Velocity::new::<meter_per_minute>(target_speed);
self.puller_speed_controller.set_target_speed(target_speed);
self.emit_state();
}
/// Set target diameter in mm
pub fn puller_set_target_diameter(&mut self, target_diameter: f64) {
// Convert mm to length
let target_diameter = Length::new::<millimeter>(target_diameter);
self.puller_speed_controller
.set_target_diameter(target_diameter);
self.emit_state();
}
/// Set forward direction
pub fn puller_set_forward(&mut self, forward: bool) {
self.puller_speed_controller.set_forward(forward);
self.emit_state();
}
}
// Implement Lift
impl BufferV1 {
pub fn sync_lift_speed(&mut self, t: Instant) {
let linear_velocity =
self.buffer_lift_controller
.update_speed(&mut self.lift, &self.lift_end_stop, t);
let steps_per_second = self.lift_step_converter.velocity_to_steps(linear_velocity);
let _ = self.lift.set_speed(steps_per_second);
}
pub fn lift_set_step_size(&mut self, step_size: f64) {
let step_size = Length::new::<millimeter>(step_size);
self.buffer_lift_controller.set_step_size(step_size);
self.emit_state();
}
pub fn lift_goto_home(&mut self) {
if self.can_go_home() {
self.buffer_lift_controller.goto_home();
}
self.emit_state();
}
}
// Connecting/Disconnecting machine
impl BufferV1 {
/// set connected winder
pub fn set_connected_winder(
@@ -168,34 +416,13 @@ impl BufferV1 {
) {
return;
}
let machine_manager_arc = match self.machine_manager.upgrade() {
Some(machine_manager_arc) => machine_manager_arc,
None => return,
};
let machine_manager_guard = block_on(machine_manager_arc.read());
let winder2_weak = machine_manager_guard.get_machine_weak(&machine_identification_unique);
let winder2_weak = match winder2_weak {
Some(winder2_weak) => winder2_weak,
None => return,
};
let winder2_strong = match winder2_weak.upgrade() {
Some(winder2_strong) => winder2_strong,
None => return,
};
let winder2: Arc<Mutex<Winder2>> = block_on(downcast_machine::<Winder2>(winder2_strong))
.expect("failed downcasting machine");
let machine = Arc::downgrade(&winder2);
self.connected_winder = Some(ConnectedMachine {
machine_identification_unique,
machine,
});
self.connected_winder
.set_connected_machine(&machine_identification_unique);
self.emit_state();
self.reverse_connect();
self.connected_winder.reverse_connect();
}
/// disconnect winder
@@ -209,42 +436,16 @@ impl BufferV1 {
) {
return;
}
if let Some(connected) = &self.connected_winder {
if let Some(winder2_arc) = connected.machine.upgrade() {
let future = async move {
let mut winder2 = winder2_arc.lock().await;
if winder2.connected_buffer.is_some() {
winder2.connected_buffer = None;
winder2.emit_state();
}
};
smol::spawn(future).detach();
}
}
self.connected_winder = None;
self.emit_state();
}
/// initiate connection from winder to buffer
pub fn reverse_connect(&mut self) {
let machine_identification_unique = self.machine_identification_unique.clone();
if let Some(connected) = &self.connected_winder {
if let Some(winder2_arc) = connected.machine.upgrade() {
let future = async move {
let mut winder2 = winder2_arc.lock().await;
if winder2.connected_buffer.is_none() {
winder2.set_connected_buffer(machine_identification_unique);
}
};
smol::spawn(future).detach();
}
}
self.connected_winder.disconnect();
self.emit_state();
}
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
pub enum BufferV1Mode {
Standby,
FillingBuffer,
EmptyingBuffer,
Hold,
Filling,
Emptying,
}
+122 -186
View File
@@ -1,232 +1,168 @@
use std::time::Instant;
use anyhow::Error;
use control_core::machines::identification::DeviceHardwareIdentification;
use control_core::converters::linear_step_converter::LinearStepConverter;
use control_core::machines::connection::MachineCrossConnection;
use control_core::machines::new::{
MachineNewHardware, MachineNewParams, MachineNewTrait, get_device_identification_by_role,
get_ethercat_device_by_index, get_subdevice_by_index, validate_no_role_dublicates,
MachineNewHardware, MachineNewParams, MachineNewTrait, validate_no_role_dublicates,
validate_same_machine_identification_unique,
};
use control_core::uom_extensions::velocity::meter_per_minute;
use ethercat_hal::coe::ConfigurableDevice;
use ethercat_hal::devices::EthercatDeviceUsed;
use ethercat_hal::devices::ek1100::EK1100;
use ethercat_hal::devices::ek1100::EK1100_IDENTITY_A;
use ethercat_hal::devices::el7031_0030::coe::EL7031_0030Configuration;
use ethercat_hal::devices::el7031_0030::pdo::EL7031_0030PredefinedPdoAssignment;
use ethercat_hal::devices::el7031_0030::{self, EL7031_0030, EL7031_0030_IDENTITY_A};
use ethercat_hal::devices::el7031_0030::{
EL7031_0030, EL7031_0030_IDENTITY_A, EL7031_0030StepperPort,
};
use ethercat_hal::devices::el7041_0052::coe::EL7041_0052Configuration;
use ethercat_hal::devices::el7041_0052::{EL7041_0052, EL7041_0052_IDENTITY_A, EL7041_0052Port};
use ethercat_hal::devices::{EthercatDeviceUsed, downcast_device, subdevice_identity_to_tuple};
use ethercat_hal::io::digital_input::DigitalInput;
use ethercat_hal::io::stepper_velocity_el70x1::StepperVelocityEL70x1;
use ethercat_hal::shared_config;
use ethercat_hal::shared_config::el70x1::{EL70x1OperationMode, StmMotorConfiguration};
use uom::si::f64::{Length, Velocity};
use uom::si::length::{centimeter, millimeter};
use crate::machines::buffer1::BufferV1Mode;
use crate::machines::buffer1::buffer_tower_controller::BufferTowerController;
use crate::machines::buffer1::buffer_lift_controller::BufferLiftController;
use crate::machines::buffer1::puller_speed_controller::PullerSpeedController;
use crate::machines::get_ethercat_device;
use super::{BufferV1, api::Buffer1Namespace};
impl MachineNewTrait for BufferV1 {
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
fn new<'maindevice>(
params: &MachineNewParams<'maindevice, '_, '_, '_, '_, '_, '_>,
) -> Result<Self, Error> {
// validate general stuff
let device_identification = params.device_group.to_vec();
validate_same_machine_identification_unique(&device_identification)?;
validate_no_role_dublicates(&device_identification)?;
let hardware = match &params.hardware {
MachineNewHardware::Ethercat(x) => x,
_ => {
return Err(anyhow::anyhow!(
"[{}::MachineNewTrait/Buffer::new] MachineNewHardware is not Ethercat",
module_path!()
));
}
};
let hardware: &&control_core::machines::new::MachineNewHardwareEthercat<'_, '_, '_> =
match &params.hardware {
MachineNewHardware::Ethercat(x) => x,
_ => {
return Err(anyhow::anyhow!(
"[{}::MachineNewTrait/Buffer::new] MachineNewHardware is not Ethercat",
module_path!()
));
}
};
smol::block_on(async {
// Role 0
// Buscoupler
// EK1100
// Role 0 - Buscoupler EK1100
let _ek1100 =
get_ethercat_device::<EK1100>(hardware, params, 0, [EK1100_IDENTITY_A].to_vec())
.await?
.0;
// Role 1 - Stepper Buffer EL7041-0052
let (el7041, subdevice) = get_ethercat_device::<EL7041_0052>(
hardware,
params,
1,
[EL7041_0052_IDENTITY_A].to_vec(),
)
.await?;
let el7041_config = EL7041_0052Configuration {
stm_features: shared_config::el70x1::StmFeatures {
operation_mode: EL70x1OperationMode::DirectVelocity,
..Default::default()
},
stm_motor: StmMotorConfiguration {
max_current: 6000,
..Default::default()
},
..Default::default()
};
el7041
.write()
.await
.write_config(subdevice, &el7041_config)
.await?;
{
let device_identification =
get_device_identification_by_role(params.device_group, 0)?;
let device_hardware_identification_ethercat =
match &device_identification.device_hardware_identification {
DeviceHardwareIdentification::Ethercat(
device_hardware_identification_ethercat,
) => device_hardware_identification_ethercat,
_ => Err(anyhow::anyhow!(
"[{}::MachineNewTrait/Buffer::new] Device with role 0 is not Ethercat",
module_path!()
))?, //uncommented
};
let subdevice_index = device_hardware_identification_ethercat.subdevice_index;
let subdevice = get_subdevice_by_index(hardware.subdevices, subdevice_index)?;
let subdevice_identity = subdevice.identity();
let device = match subdevice_identity_to_tuple(&subdevice_identity) {
EK1100_IDENTITY_A => {
let ethercat_device = get_ethercat_device_by_index(
hardware.ethercat_devices,
subdevice_index,
)?;
downcast_device::<EK1100>(ethercat_device).await?
}
_ => {
return Err(anyhow::anyhow!(
"[{}::MachineNewTrait/Buffer::new] Device with role 0 is not an EK1100",
module_path!()
));
}
};
{
let mut device_guard = device.write().await;
device_guard.set_used(true);
}
let mut device_guard = el7041.write().await;
device_guard.set_used(true);
}
// Role 1
// 1x Stepper Buffer
// EL7041-0052
let (el7041, _el7041_config) = {
let device_identification =
get_device_identification_by_role(params.device_group, 1)?;
let device_hardware_identification_ethercat =
match &device_identification.device_hardware_identification {
DeviceHardwareIdentification::Ethercat(
device_hardware_identification_ethercat,
) => device_hardware_identification_ethercat,
_ => Err(anyhow::anyhow!(
"[{}::MachineNewTrait/Buffer::new] Device with role 1 is not Ethercat",
module_path!()
))?,
};
let subdevice = get_subdevice_by_index(
hardware.subdevices,
device_hardware_identification_ethercat.subdevice_index,
)?;
let subdevice_index = device_hardware_identification_ethercat.subdevice_index;
let subdevice_identity = subdevice.identity();
let device = match subdevice_identity_to_tuple(&subdevice_identity) {
EL7041_0052_IDENTITY_A => {
let ethercat_device = get_ethercat_device_by_index(
hardware.ethercat_devices,
subdevice_index,
)?;
downcast_device::<EL7041_0052>(ethercat_device).await?
}
_ => Err(anyhow::anyhow!(
"[{}::MachineNewTrait/Buffer::new] Device with role 1 is not an EL7041-0052",
module_path!()
))?,
};
let config = EL7041_0052Configuration {
stm_features: shared_config::el70x1::StmFeatures {
operation_mode: EL70x1OperationMode::DirectVelocity,
..Default::default()
},
stm_motor: StmMotorConfiguration {
max_current: 6000,
..Default::default()
},
// Role 2 - Stepper Puller EL7031-0030
let (el7031, subdevice) = get_ethercat_device::<EL7031_0030>(
hardware,
params,
2,
[EL7031_0030_IDENTITY_A].to_vec(),
)
.await?;
let el7031_config = EL7031_0030Configuration {
stm_features: ethercat_hal::devices::el7031_0030::coe::StmFeatures {
operation_mode: EL70x1OperationMode::DirectVelocity,
// Max Speed of 1000 steps/s
// Max @ 8cm diameter = approx 75 m/min
speed_range: shared_config::el70x1::EL70x1SpeedRange::Steps1000,
..Default::default()
};
device
.write()
.await
.write_config(subdevice, &config)
.await?;
{
let mut device_guard = device.write().await;
device_guard.set_used(true);
}
(device, config)
},
stm_motor: StmMotorConfiguration {
max_current: 1500,
..Default::default()
},
pdo_assignment: EL7031_0030PredefinedPdoAssignment::VelocityControlCompact,
..Default::default()
};
// Role 2
// 1x Stepper Puller
// EL7031
let (_el7031_0030, _el7031_0030_config) = {
let device_identification =
get_device_identification_by_role(params.device_group, 2)?;
let device_hardware_identification_ethercat =
match &device_identification.device_hardware_identification {
DeviceHardwareIdentification::Ethercat(
device_hardware_identification_ethercat,
) => device_hardware_identification_ethercat,
_ => Err(anyhow::anyhow!(
"[{}::MachineNewTrait/Buffer::new] Device with role 2 is not Ethercat",
module_path!()
))?,
};
let subdevice = get_subdevice_by_index(
hardware.subdevices,
device_hardware_identification_ethercat.subdevice_index,
)?;
let subdevice_index = device_hardware_identification_ethercat.subdevice_index;
let subdevice_identity = subdevice.identity();
let device = match subdevice_identity_to_tuple(&subdevice_identity) {
EL7031_0030_IDENTITY_A => {
let ethercat_device = get_ethercat_device_by_index(
hardware.ethercat_devices,
subdevice_index,
)?;
downcast_device::<EL7031_0030>(ethercat_device).await?
}
_ => Err(anyhow::anyhow!(
"[{}::MachineNewTrait/Buffer1::new] Device with role 2 is not an EL7031-0030",
module_path!()
))?,
};
let config = EL7031_0030Configuration {
stm_features: el7031_0030::coe::StmFeatures {
operation_mode: EL70x1OperationMode::DirectVelocity,
// Max Speed of 1000 steps/s
// Max @ 8cm diameter = approx 75 m/min
speed_range: shared_config::el70x1::EL70x1SpeedRange::Steps1000,
..Default::default()
},
stm_motor: StmMotorConfiguration {
max_current: 1500,
..Default::default()
},
pdo_assignment: EL7031_0030PredefinedPdoAssignment::VelocityControlCompact,
..Default::default()
};
device
.write()
.await
.write_config(subdevice, &config)
.await?;
{
let mut device_guard = device.write().await;
device_guard.set_used(true);
}
(device, config)
};
el7031
.write()
.await
.write_config(subdevice, &el7031_config)
.await?;
{
let mut device_guard = el7031.write().await;
device_guard.set_used(true);
}
// Controller
let buffer_tower_controller = BufferTowerController::new(StepperVelocityEL70x1::new(
el7041.clone(),
EL7041_0052Port::STM1,
));
let buffer_lift_controller = BufferLiftController::new(
StepperVelocityEL70x1::new(el7041.clone(), EL7041_0052Port::STM1),
Length::new::<centimeter>(135.0),
Length::new::<centimeter>(0.0),
64,
);
let puller_speed_controller = PullerSpeedController::new(
Velocity::new::<meter_per_minute>(1.0),
Length::new::<millimeter>(1.75),
LinearStepConverter::from_diameter(200, Length::new::<centimeter>(8.0)),
);
let machine_id = params
.device_group
.first()
.expect("device group must have at least one device")
.device_machine_identification
.machine_identification_unique
.clone();
let machine_identification_unique = params.get_machine_identification_unique();
// create buffer instance
let mut buffer: Self = Self {
namespace: Buffer1Namespace::new(params.socket_queue_tx.clone()),
let mut buffer = Self {
lift: StepperVelocityEL70x1::new(el7041.clone(), EL7041_0052Port::STM1),
lift_end_stop: DigitalInput::new(el7041, EL7041_0052Port::DI1),
puller: StepperVelocityEL70x1::new(el7031.clone(), EL7031_0030StepperPort::STM1),
lift_step_converter: LinearStepConverter::from_diameter(
200,
Length::new::<millimeter>(32.22),
),
namespace: Buffer1Namespace {
namespace: params.namespace.clone(),
},
last_measurement_emit: Instant::now(),
mode: BufferV1Mode::Standby,
buffer_tower_controller,
buffer_lift_controller,
puller_speed_controller,
machine_manager: params.machine_manager.clone(),
machine_identification_unique: machine_id,
connected_winder: None,
machine_identification_unique: machine_identification_unique.clone(),
connected_winder: MachineCrossConnection::new(
params.machine_manager.clone(),
&machine_identification_unique,
),
};
buffer.emit_state();
Ok(buffer)
@@ -0,0 +1,119 @@
use std::time::Instant;
use control_core::{
controllers::second_degree_motion::linear_jerk_speed_controller::LinearJerkSpeedController,
converters::linear_step_converter::LinearStepConverter,
uom_extensions::{
acceleration::meter_per_minute_per_second, jerk::meter_per_minute_per_second_squared,
velocity::meter_per_minute,
},
};
use serde::{Deserialize, Serialize};
use uom::{
ConstZero,
si::f64::{Acceleration, AngularVelocity, Jerk, Length, Velocity},
};
#[derive(Debug)]
pub struct PullerSpeedController {
enabled: bool,
pub target_speed: Velocity,
pub target_diameter: Length,
pub regulation_mode: PullerRegulationMode,
/// Forward rotation direction. If false, applies negative sign to speed
pub forward: bool,
/// Linear acceleration controller to dampen speed change
acceleration_controller: LinearJerkSpeedController,
/// Converter for linear to angular transformations
pub converter: LinearStepConverter,
pub last_speed: Velocity,
}
impl PullerSpeedController {
pub fn new(
target_speed: Velocity,
target_diameter: Length,
converter: LinearStepConverter,
) -> Self {
let acceleration = Acceleration::new::<meter_per_minute_per_second>(5.0);
let jerk = Jerk::new::<meter_per_minute_per_second_squared>(10.0);
let speed = Velocity::new::<meter_per_minute>(50.0);
Self {
enabled: false,
target_speed,
target_diameter,
regulation_mode: PullerRegulationMode::Speed,
forward: true,
acceleration_controller: LinearJerkSpeedController::new_simple(
Some(speed),
acceleration,
jerk,
),
converter,
last_speed: Velocity::ZERO,
}
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
pub fn set_target_speed(&mut self, target: Velocity) {
self.target_speed = target;
}
pub fn set_target_diameter(&mut self, target: Length) {
self.target_diameter = target;
}
pub fn set_regulation_mode(&mut self, regulation: PullerRegulationMode) {
self.regulation_mode = regulation;
}
pub fn set_forward(&mut self, forward: bool) {
self.forward = forward;
}
fn update_speed(&mut self, t: Instant) -> Velocity {
let speed = match self.enabled {
true => match self.regulation_mode {
PullerRegulationMode::Speed => self.target_speed,
PullerRegulationMode::Diameter => unimplemented!(),
},
false => Velocity::ZERO,
};
let speed = if self.forward { speed } else { -speed };
let speed = self.acceleration_controller.update(speed, t);
self.last_speed = speed;
speed
}
pub fn speed_to_angular_velocity(&self, speed: Velocity) -> AngularVelocity {
// Use the converter to transform from linear velocity to angular velocity
self.converter.velocity_to_angular_velocity(speed)
}
pub fn angular_velocity_to_speed(&self, angular_speed: AngularVelocity) -> Velocity {
// Use the converter to transform from angular velocity to linear velocity
self.converter.angular_velocity_to_velocity(angular_speed)
}
pub fn calc_angular_velocity(&mut self, t: Instant) -> AngularVelocity {
let speed = self.update_speed(t);
self.speed_to_angular_velocity(speed)
}
pub fn get_target_speed(&self) -> Velocity {
self.target_speed
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum PullerRegulationMode {
Speed,
Diameter,
}
+5 -2
View File
@@ -1,8 +1,11 @@
#[cfg(not(feature = "mock-machine"))]
use crate::machines::extruder1::ExtruderV2;
#[cfg(not(feature = "mock-machine"))]
use control_core::machines::new::MachineAct;
#[cfg(not(feature = "mock-machine"))]
use std::time::{Duration, Instant};
use crate::machines::extruder1::ExtruderV2;
#[cfg(not(feature = "mock-machine"))]
impl MachineAct for ExtruderV2 {
fn act(&mut self, now: Instant) {
self.temperature_controller_back.update(now);
+29 -28
View File
@@ -1,19 +1,25 @@
use super::{ExtruderV2, ExtruderV2Mode, HeatingType, mitsubishi_cs80::MotorStatus};
use control_core::{
machines::api::MachineApi,
socketio::{
event::{Event, GenericEvent},
namespace::{
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_duration,
cache_first_and_last_event,
},
use super::{ExtruderV2Mode, mitsubishi_cs80::MotorStatus};
#[cfg(not(feature = "mock-machine"))]
use super::ExtruderV2;
#[cfg(not(feature = "mock-machine"))]
use crate::machines::extruder1::HeatingType;
#[cfg(not(feature = "mock-machine"))]
use control_core::machines::api::MachineApi;
use control_core::socketio::{
event::{Event, GenericEvent},
namespace::{
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_duration,
cache_first_and_last_event,
},
};
use control_core_derive::BuildEvent;
use serde::{Deserialize, Serialize};
#[cfg(not(feature = "mock-machine"))]
use serde_json::Value;
use smol::channel::Sender;
use socketioxide::extract::SocketRef;
use smol::lock::Mutex;
use std::{sync::Arc, time::Duration};
use tracing::instrument;
use uom::si::{
@@ -23,11 +29,11 @@ use uom::si::{
#[derive(Debug, Clone, Default, Serialize)]
pub struct MotorStatusValues {
screw_rpm: f64, // rpm of motor
frequency: f64, // frequency of motor
voltage: f64, // volt used for motor
current: f64, // current used for the motor
power: f64, // power in watts
pub screw_rpm: f64, // rpm of motor
pub frequency: f64, // frequency of motor
pub voltage: f64, // volt used for motor
pub current: f64, // current used for the motor
pub power: f64, // power in watts
}
impl From<MotorStatus> for MotorStatusValues {
@@ -189,7 +195,7 @@ pub enum ExtruderV2Events {
}
#[derive(Deserialize, Serialize)]
enum Mutation {
pub enum Mutation {
/// INVERTER
/// Frequency Control
// Set Rotation also starts the motor
@@ -218,7 +224,7 @@ enum Mutation {
#[derive(Debug)]
pub struct ExtruderV2Namespace {
pub namespace: Namespace,
pub namespace: Arc<Mutex<Namespace>>,
}
impl NamespaceCacheingLogic<ExtruderV2Events> for ExtruderV2Namespace {
@@ -226,15 +232,9 @@ impl NamespaceCacheingLogic<ExtruderV2Events> for ExtruderV2Namespace {
fn emit(&mut self, events: ExtruderV2Events) {
let event = Arc::new(events.event_value());
let buffer_fn = events.event_cache_fn();
self.namespace.emit(event, &buffer_fn);
}
}
impl ExtruderV2Namespace {
pub fn new(socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>) -> Self {
Self {
namespace: Namespace::new(socket_queue_tx),
}
let mut namespace = self.namespace.lock_blocking();
namespace.emit(event, &buffer_fn);
}
}
@@ -257,6 +257,7 @@ impl CacheableEvents<Self> for ExtruderV2Events {
}
}
#[cfg(not(feature = "mock-machine"))]
impl MachineApi for ExtruderV2 {
fn api_mutate(&mut self, request_body: Value) -> Result<(), anyhow::Error> {
// there are multiple Modbus Frames that are "prebuilt"
@@ -295,7 +296,7 @@ impl MachineApi for ExtruderV2 {
Ok(())
}
fn api_event_namespace(&mut self) -> &mut Namespace {
&mut self.namespace.namespace
fn api_event_namespace(&mut self) -> Arc<Mutex<Namespace>> {
self.namespace.namespace.clone()
}
}
+282
View File
@@ -0,0 +1,282 @@
#[cfg(not(feature = "mock-machine"))]
// Contains Implementations for All functions that use emit_state
use crate::machines::extruder1::{
ExtruderV2, ExtruderV2Mode, HeatingType,
api::{
ExtruderSettingsState, ExtruderV2Events, HeatingState, HeatingStates, InverterStatusState,
LiveValuesEvent, ModeState, PidSettings, PidSettingsStates, PressureState, RegulationState,
RotationState, ScrewState, StateEvent,
},
};
#[cfg(not(feature = "mock-machine"))]
use control_core::helpers::hasher_serializer::hash_with_serde_model;
#[cfg(not(feature = "mock-machine"))]
use control_core::socketio::event::BuildEvent;
#[cfg(not(feature = "mock-machine"))]
use control_core::socketio::namespace::NamespaceCacheingLogic;
#[cfg(not(feature = "mock-machine"))]
use uom::si::{angular_velocity::revolution_per_minute, thermodynamic_temperature::degree_celsius};
#[cfg(not(feature = "mock-machine"))]
use uom::si::angular_velocity::AngularVelocity;
#[cfg(not(feature = "mock-machine"))]
use uom::si::pressure::{Pressure, bar};
#[cfg(not(feature = "mock-machine"))]
use uom::si::thermodynamic_temperature::ThermodynamicTemperature;
#[cfg(not(feature = "mock-machine"))]
impl ExtruderV2 {
pub fn build_state_event(&mut self) -> StateEvent {
StateEvent {
is_default_state: !std::mem::replace(&mut self.emitted_default_state, true),
rotation_state: RotationState {
forward: self.screw_speed_controller.get_rotation_direction(),
},
mode_state: ModeState {
mode: self.mode.clone(),
},
regulation_state: RegulationState {
uses_rpm: self.screw_speed_controller.get_uses_rpm(),
},
pressure_state: PressureState {
target_bar: self
.screw_speed_controller
.get_target_pressure()
.get::<bar>(),
wiring_error: self.screw_speed_controller.get_wiring_error(),
},
screw_state: ScrewState {
target_rpm: self
.screw_speed_controller
.get_target_rpm()
.get::<revolution_per_minute>(),
},
heating_states: HeatingStates {
nozzle: HeatingState {
target_temperature: self
.temperature_controller_nozzle
.heating
.target_temperature
.get::<degree_celsius>(),
wiring_error: self.temperature_controller_nozzle.heating.wiring_error,
},
front: HeatingState {
target_temperature: self
.temperature_controller_front
.heating
.target_temperature
.get::<degree_celsius>(),
wiring_error: self.temperature_controller_front.heating.wiring_error,
},
back: HeatingState {
target_temperature: self
.temperature_controller_back
.heating
.target_temperature
.get::<degree_celsius>(),
wiring_error: self.temperature_controller_back.heating.wiring_error,
},
middle: HeatingState {
target_temperature: self
.temperature_controller_middle
.heating
.target_temperature
.get::<degree_celsius>(),
wiring_error: self.temperature_controller_middle.heating.wiring_error,
},
},
extruder_settings_state: ExtruderSettingsState {
pressure_limit: self
.screw_speed_controller
.get_nozzle_pressure_limit()
.get::<bar>(),
pressure_limit_enabled: self
.screw_speed_controller
.get_nozzle_pressure_limit_enabled(),
},
inverter_status_state: InverterStatusState {
running: self.screw_speed_controller.inverter.status.running,
forward_running: self.screw_speed_controller.inverter.status.forward_running,
reverse_running: self.screw_speed_controller.inverter.status.reverse_running,
up_to_frequency: self.screw_speed_controller.inverter.status.su,
overload_warning: self.screw_speed_controller.inverter.status.ol,
no_function: self.screw_speed_controller.inverter.status.no_function,
output_frequency_detection: self.screw_speed_controller.inverter.status.fu,
abc_fault: self.screw_speed_controller.inverter.status.abc_,
fault_occurence: self.screw_speed_controller.inverter.status.fault_occurence,
},
pid_settings: PidSettingsStates {
temperature: PidSettings {
ki: 0.0,
kp: 0.0,
kd: 0.0,
},
pressure: PidSettings {
ki: self.screw_speed_controller.pid.get_ki(),
kp: self.screw_speed_controller.pid.get_kp(),
kd: self.screw_speed_controller.pid.get_kd(),
},
},
}
}
}
#[cfg(not(feature = "mock-machine"))]
impl ExtruderV2 {
pub fn emit_state(&mut self) {
let state = self.build_state_event();
let hash = hash_with_serde_model(self.screw_speed_controller.get_inverter_status());
self.last_status_hash = Some(hash);
let event = state.build();
self.namespace.emit(ExtruderV2Events::State(event));
}
pub fn maybe_emit_state_event(&mut self) {
let old_status_hash = match self.last_status_hash {
Some(event) => event,
None => {
self.emit_state();
return;
}
};
let status = self.screw_speed_controller.get_inverter_status();
let new_status_hash = hash_with_serde_model(status);
if new_status_hash != old_status_hash {
self.emit_state();
}
}
pub fn emit_live_values(&mut self) {
use std::time::Instant;
let now = Instant::now();
let combined_power = self.calculate_combined_power();
self.update_total_energy(combined_power, now);
let live_values = LiveValuesEvent {
motor_status: self.screw_speed_controller.get_motor_status().into(),
pressure: self.screw_speed_controller.get_pressure().get::<bar>(),
nozzle_temperature: self
.temperature_controller_nozzle
.heating
.temperature
.get::<degree_celsius>(),
front_temperature: self
.temperature_controller_front
.heating
.temperature
.get::<degree_celsius>(),
back_temperature: self
.temperature_controller_back
.heating
.temperature
.get::<degree_celsius>(),
middle_temperature: self
.temperature_controller_middle
.heating
.temperature
.get::<degree_celsius>(),
nozzle_power: self
.temperature_controller_nozzle
.get_heating_element_wattage(),
front_power: self
.temperature_controller_front
.get_heating_element_wattage(),
back_power: self
.temperature_controller_back
.get_heating_element_wattage(),
middle_power: self
.temperature_controller_middle
.get_heating_element_wattage(),
combined_power,
total_energy_kwh: self.total_energy_kwh,
};
let event = live_values.build();
self.namespace.emit(ExtruderV2Events::LiveValues(event));
}
// === Steuerungsfunktionen mit emit_state ===
pub fn set_nozzle_pressure_limit_is_enabled(&mut self, enabled: bool) {
self.screw_speed_controller
.set_nozzle_pressure_limit_is_enabled(enabled);
self.emit_state();
}
pub fn set_nozzle_pressure_limit(&mut self, pressure: f64) {
self.screw_speed_controller
.set_nozzle_pressure_limit(Pressure::new::<bar>(pressure));
self.emit_state();
}
pub fn enable_heating(&mut self) {
self.temperature_controller_back.allow_heating();
self.temperature_controller_front.allow_heating();
self.temperature_controller_middle.allow_heating();
self.temperature_controller_nozzle.allow_heating();
self.emit_state();
}
pub fn set_rotation_state(&mut self, forward: bool) {
self.screw_speed_controller.set_rotation_direction(forward);
self.emit_state();
}
pub fn set_mode_state(&mut self, mode: ExtruderV2Mode) {
self.switch_mode(mode);
self.emit_state();
}
pub fn set_regulation(&mut self, uses_rpm: bool) {
if !self.screw_speed_controller.get_uses_rpm() && uses_rpm {
self.screw_speed_controller
.set_target_screw_rpm(self.screw_speed_controller.target_rpm);
self.screw_speed_controller.set_uses_rpm(uses_rpm);
}
if self.screw_speed_controller.get_uses_rpm() && !uses_rpm {
self.screw_speed_controller.set_uses_rpm(uses_rpm);
self.screw_speed_controller.start_pressure_regulation();
}
self.emit_state();
}
pub fn set_target_pressure(&mut self, pressure: f64) {
self.screw_speed_controller
.set_target_pressure(Pressure::new::<bar>(pressure));
self.emit_state();
}
pub fn set_target_rpm(&mut self, rpm: f64) {
self.screw_speed_controller
.set_target_screw_rpm(AngularVelocity::new::<revolution_per_minute>(rpm));
self.emit_state();
}
pub fn set_target_temperature(&mut self, target_temperature: f64, heating_type: HeatingType) {
let target_temp = ThermodynamicTemperature::new::<degree_celsius>(target_temperature);
match heating_type {
HeatingType::Nozzle => self
.temperature_controller_nozzle
.set_target_temperature(target_temp),
HeatingType::Front => self
.temperature_controller_front
.set_target_temperature(target_temp),
HeatingType::Back => self
.temperature_controller_back
.set_target_temperature(target_temp),
HeatingType::Middle => self
.temperature_controller_middle
.set_target_temperature(target_temp),
}
self.emit_state();
}
pub fn configure_pressure_pid(&mut self, settings: PidSettings) {
self.screw_speed_controller
.pid
.configure(settings.ki, settings.kp, settings.kd);
self.emit_state();
}
}
+15
View File
@@ -0,0 +1,15 @@
use crate::machines::extruder1::mock::ExtruderV2;
use control_core::machines::new::MachineAct;
use std::time::{Duration, Instant};
impl MachineAct for ExtruderV2 {
fn act(&mut self, now: Instant) {
// more than 33ms have passed since last emit (30 "fps" target)
if now.duration_since(self.last_measurement_emit) > Duration::from_secs_f64(1.0 / 30.0) {
self.maybe_emit_state_event();
// Emit live values at 30 FPS
self.emit_live_values();
self.last_measurement_emit = now;
}
}
}
+47
View File
@@ -0,0 +1,47 @@
use crate::machines::extruder1::{HeatingType, api::Mutation, mock::ExtruderV2};
use control_core::machines::api::MachineApi;
use control_core::socketio::namespace::Namespace;
use smol::lock::Mutex;
use std::sync::Arc;
impl MachineApi for ExtruderV2 {
fn api_mutate(&mut self, request_body: serde_json::Value) -> Result<(), anyhow::Error> {
// there are multiple Modbus Frames that are "prebuilt"
let control: Mutation = serde_json::from_value(request_body)?;
match control {
Mutation::SetExtruderMode(mode) => self.set_mode_state(mode),
Mutation::SetInverterRotationDirection(forward) => self.set_rotation_state(forward),
Mutation::SetInverterRegulation(uses_rpm) => self.set_regulation(uses_rpm),
Mutation::SetInverterTargetPressure(bar) => self.set_target_pressure(bar),
Mutation::SetInverterTargetRpm(rpm) => self.set_target_rpm(rpm),
Mutation::ResetInverter(_) => (),
Mutation::SetFrontHeatingTargetTemperature(temp) => {
self.set_target_temperature(temp, HeatingType::Front)
}
Mutation::SetMiddleHeatingTemperature(temp) => {
self.set_target_temperature(temp, HeatingType::Middle)
}
Mutation::SetBackHeatingTargetTemperature(temp) => {
self.set_target_temperature(temp, HeatingType::Back)
}
Mutation::SetNozzleHeatingTemperature(temp) => {
self.set_target_temperature(temp, HeatingType::Nozzle)
}
Mutation::SetExtruderPressureLimit(pressure_limit) => {
self.set_nozzle_pressure_limit(pressure_limit);
}
Mutation::SetExtruderPressureLimitIsEnabled(enabled) => {
self.set_nozzle_pressure_limit_is_enabled(enabled);
}
Mutation::SetPressurePidSettings(settings) => {
self.configure_pressure_pid(settings);
}
}
Ok(())
}
fn api_event_namespace(&mut self) -> Arc<Mutex<Namespace>> {
self.namespace.namespace.clone()
}
}
@@ -0,0 +1,136 @@
use crate::machines::extruder1::{
ExtruderV2Mode, HeatingType,
api::{ExtruderV2Events, LiveValuesEvent, ModeState, PidSettings, StateEvent},
mock::ExtruderV2,
};
use control_core::{
helpers::hasher_serializer::hash_with_serde_model,
socketio::{event::BuildEvent, namespace::NamespaceCacheingLogic},
};
impl ExtruderV2 {
pub fn build_state_event(&mut self) -> StateEvent {
// bad performance wise, but doesnt matter its only a mock machine
StateEvent {
is_default_state: !std::mem::replace(&mut self.emitted_default_state, true),
rotation_state: self.rotation_state.clone(),
mode_state: self.mode_state.clone(),
regulation_state: self.regulation_state.clone(),
pressure_state: self.pressure_state.clone(),
screw_state: self.screw_state.clone(),
heating_states: self.heating_states.clone(),
extruder_settings_state: self.extruder_settings_state.clone(),
inverter_status_state: self.inverter_status_state.clone(),
pid_settings: self.pid_settings.clone(),
}
}
}
impl ExtruderV2 {
pub fn emit_state(&mut self) {
let state = self.build_state_event();
let hash = hash_with_serde_model(self.inverter_status_state.clone());
self.last_status_hash = Some(hash);
let event = state.build();
self.namespace.emit(ExtruderV2Events::State(event));
}
pub fn maybe_emit_state_event(&mut self) {
let old_status_hash = match self.last_status_hash {
Some(event) => event,
None => {
self.emit_state();
return;
}
};
let status = self.inverter_status_state.clone();
let new_status_hash = hash_with_serde_model(status);
if new_status_hash != old_status_hash {
self.emit_state();
}
}
pub fn emit_live_values(&mut self) {
let live_values = LiveValuesEvent {
motor_status: self.motor_status.clone(),
pressure: self.pressure,
nozzle_temperature: self.nozzle_temperature,
front_temperature: self.front_temperature,
back_temperature: self.back_temperature,
middle_temperature: self.middle_temperature,
nozzle_power: self.nozzle_power,
front_power: self.front_power,
back_power: self.back_power,
middle_power: self.middle_power,
combined_power: self.combined_power,
total_energy_kwh: self.total_energy_kwh,
};
let event = live_values.build();
self.namespace.emit(ExtruderV2Events::LiveValues(event));
}
pub fn set_nozzle_pressure_limit_is_enabled(&mut self, enabled: bool) {
self.extruder_settings_state.pressure_limit_enabled = enabled;
self.emit_state();
}
pub fn set_nozzle_pressure_limit(&mut self, pressure: f64) {
self.extruder_settings_state.pressure_limit = pressure;
self.emit_state();
}
pub fn enable_heating(&mut self) {
self.back_heating_allowed = true;
self.front_heating_allowed = true;
self.nozzle_heating_allowed = true;
self.back_heating_allowed = true;
self.emit_state();
}
pub fn set_rotation_state(&mut self, forward: bool) {
self.rotation_state.forward = forward;
self.emit_state();
}
pub fn set_mode_state(&mut self, mode: ExtruderV2Mode) {
self.mode_state = ModeState { mode };
self.emit_state();
}
pub fn set_regulation(&mut self, uses_rpm: bool) {
self.regulation_state.uses_rpm = uses_rpm;
self.emit_state();
}
pub fn set_target_pressure(&mut self, pressure: f64) {
self.target_pressure = pressure;
self.emit_state();
}
pub fn set_target_rpm(&mut self, rpm: f64) {
self.screw_state.target_rpm = rpm;
self.emit_state();
}
pub fn set_target_temperature(&mut self, target_temperature: f64, heating_type: HeatingType) {
match heating_type {
HeatingType::Nozzle => {
self.heating_states.nozzle.target_temperature = target_temperature
}
HeatingType::Front => self.heating_states.front.target_temperature = target_temperature,
HeatingType::Back => self.heating_states.back.target_temperature = target_temperature,
HeatingType::Middle => {
self.heating_states.middle.target_temperature = target_temperature
}
}
self.emit_state();
}
pub fn configure_pressure_pid(&mut self, settings: PidSettings) {
self.pid_settings.pressure.ki = settings.ki;
self.pid_settings.pressure.kp = settings.kp;
self.pid_settings.pressure.kd = settings.kd;
self.emit_state();
}
}
+106
View File
@@ -0,0 +1,106 @@
#[cfg(feature = "mock-machine")]
use control_core::machines::identification::MachineIdentification;
#[cfg(feature = "mock-machine")]
use control_core::machines::identification::MachineIdentificationUnique;
#[cfg(feature = "mock-machine")]
use control_core_derive::Machine;
#[cfg(feature = "mock-machine")]
use std::time::Instant;
#[cfg(feature = "mock-machine")]
use crate::machines::{
MACHINE_EXTRUDER_V1, VENDOR_QITECH,
extruder1::{
ExtruderV2Mode,
api::{
ExtruderSettingsState, ExtruderV2Namespace, HeatingStates, InverterStatusState,
ModeState, MotorStatusValues, PidSettingsStates, PressureState, RegulationState,
RotationState, ScrewState,
},
},
};
// Just checking mock-machine feature here to exclude these modules from compilation entirely
#[cfg(feature = "mock-machine")]
pub mod act;
#[cfg(feature = "mock-machine")]
pub mod api;
#[cfg(feature = "mock-machine")]
pub mod mock_emit;
#[cfg(feature = "mock-machine")]
pub mod new;
#[cfg(feature = "mock-machine")]
#[derive(Debug, Machine)]
pub struct ExtruderV2 {
machine_identification_unique: MachineIdentificationUnique,
namespace: ExtruderV2Namespace,
last_measurement_emit: Instant,
last_status_hash: Option<u64>,
mode: ExtruderV2Mode,
/// Energy tracking for total consumption calculation
pub total_energy_kwh: f64,
pub last_energy_calculation_time: Option<Instant>,
/// will be initalized as false and set to true by `emit_state`
/// This way we can signal to the client that the first state emission is a default state
emitted_default_state: bool,
pub target_pressure: f64,
pub is_default_state: bool,
/// rotation state
pub rotation_state: RotationState,
/// mode state
pub mode_state: ModeState,
/// regulation state
pub regulation_state: RegulationState,
/// pressure state
pub pressure_state: PressureState,
/// screw state
pub screw_state: ScrewState,
/// heating states
pub heating_states: HeatingStates,
/// extruder settings state
pub extruder_settings_state: ExtruderSettingsState,
/// inverter status state
pub inverter_status_state: InverterStatusState,
/// pid settings
pub pid_settings: PidSettingsStates,
pub motor_status: MotorStatusValues,
/// pressure in bar
pub pressure: f64,
/// nozzle temperature in celsius
pub nozzle_temperature: f64,
/// front temperature in celsius
pub front_temperature: f64,
/// back temperature in celsius
pub back_temperature: f64,
/// middle temperature in celsius
pub middle_temperature: f64,
/// nozzle heating power in watts
pub nozzle_power: f64,
/// front heating power in watts
pub front_power: f64,
/// back heating power in watts
pub back_power: f64,
/// middle heating power in watts
pub middle_power: f64,
/// combined power consumption in watts
pub combined_power: f64,
pub nozzle_heating_allowed: bool,
pub front_heating_allowed: bool,
pub back_heating_allowed: bool,
pub middle_heating_allowed: bool,
}
#[cfg(feature = "mock-machine")]
impl ExtruderV2 {
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
vendor: VENDOR_QITECH,
machine: MACHINE_EXTRUDER_V1,
};
}
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more