Compare commits

..
Author SHA1 Message Date
Christian d4250606a1 Add machine serial ID display to GluetexOverviewPage 2026-02-17 14:51:43 +01:00
Christian 15733dab56 Fix slave tension arm speed being high when in a negative angle 2026-02-17 14:33:20 +01:00
Christian 45e4ca94a7 Add monitoring alert handling to disable Production mode in GluetexOverviewPage 2026-02-17 14:30:55 +01:00
Christian 345802e9eb Add monitoring alert handling to disable Production mode in GluetexOverviewPage 2026-02-17 14:27:19 +01:00
Christian e0a6e50f70 Add delay functionality for Optris voltage monitoring in Gluetex 2026-02-17 14:23:42 +01:00
Christian 930d1c887f format 2026-02-17 13:25:16 +01:00
Christian e427a449ba Switch to Setup mode immediately upon error detection in GluetexErrorBanner 2026-02-17 13:23:26 +01:00
Christian 93f62640c3 Implement emergency stop without heaters for tension arm and voltage monitor triggers 2026-02-17 13:17:19 +01:00
Christian 4e7fc0ee91 Add Winder Mode selection to GluetexOverviewPage 2026-02-17 13:05:28 +01:00
Christian ea5ca42e49 Update emergency stop method to set machine mode to hold instead of standby 2026-02-17 12:59:40 +01:00
Christian 99b7d92884 Add support for resuming traverse from saved position in Gluetex machine 2026-02-17 12:55:10 +01:00
Christian a055e47634 Add estimated time remaining 2026-02-17 12:28:45 +01:00
Christian 7ed3ee87bf Remove unused pattern state display from GluetexHeatersPage 2026-02-17 10:01:26 +01:00
Christian a3319900fb Fix DeviceEepromDialogContent: update Select component to handle value changes 2026-02-17 09:57:15 +01:00
Christian 5df48baaf9 Add support for extra analog inputs in Gluetex machine 2026-02-16 16:24:00 +01:00
Christian 10d9e9e6db Add extra digital outputs support for Gluetex machine 2026-02-16 16:17:29 +01:00
Christian 8a1695f9fb Add default backend extended state for Gluetex namespace 2026-02-16 16:02:41 +01:00
Christian 24b5145163 Fix typo 2026-02-16 16:02:41 +01:00
Christian a095669aea Add gluetex mock feature: implement mock machine functionality and update build scripts 2026-02-16 16:02:41 +01:00
Christian c4bcf82c14 Implement valve control functionality: add state management, mutations, and UI components for valve operations 2026-02-16 16:01:36 +01:00
Christian d8fe3b90a4 Refactor sleep timer functionality: restrict countdown to Setup mode and update related methods 2026-02-16 16:01:36 +01:00
Christian ba4d86ebd3 Implement Addon Motor 5 Tension Control: add state management, UI components, and backend integration 2026-02-16 16:01:36 +01:00
Christian 5045af7eb7 Add status_out field to StateEvent and gluetexNamespace for output handling 2026-02-16 16:01:36 +01:00
Christian 860cddc6c5 Add status output handling to Gluetex: update status based on current activity 2026-02-16 16:01:36 +01:00
Christian eaeb5d345c Fix issues from rebases and other temporary changes 2026-02-16 16:01:36 +01:00
Christian 28217fe839 Automatically switch to Heating mode for auto-tuning in useGluetex hook 2026-02-16 16:01:36 +01:00
Christian a5b51968d3 Enhance StatusBadge and HeatingZone components: add 'info' variant to StatusBadge and integrate auto-tuning status display in HeatingZone 2026-02-16 16:01:36 +01:00
Christian 1cbcf499e4 Add manual homing functionality for addon motor 3 2026-02-16 16:01:36 +01:00
Christian d6b0dd966c Update layout in GluetexAddonsPage: change Stepper 3 container to a vertical flex layout 2026-02-16 16:01:36 +01:00
Christian 45ec2ac257 Update sleep timer logic: allow operation in all modes and simplify check method 2026-02-16 16:01:36 +01:00
Christian b7ba698a26 Add OptrisVoltageGraph component and integrate into GluetexGraphsPage for voltage monitoring 2026-02-16 16:01:36 +01:00
Christian 25e0a7665a Refactor Gluetex monitoring checks: streamline function signatures for clarity and consistency 2026-02-16 16:01:36 +01:00
Christian c6b46838fe Make Gluetex monitoring more object based 2026-02-16 16:01:36 +01:00
Christian c4fe451534 Enhance preset application logic: update onApply prop types to support Promise<void> and implement deepSubsetEquals for state comparison in GluetexPresetsPage 2026-02-16 16:01:36 +01:00
Christian f650a8e538 Refactor dialog content layout: enhance flex properties for better responsiveness and overflow handling in NewPresetDialog and PresetShowDialog components 2026-02-16 16:01:36 +01:00
Christian 644d7585cf Refactor Gluetex preset data schema: streamline heating states, add spool automatic action, and update related logic for preset application 2026-02-16 16:01:36 +01:00
Christian 6615d1c1e2 Add order information management: implement state schema, action functions, and UI integration for order number, serial number, and product description 2026-02-16 16:01:36 +01:00
Christian fd87017687 Implement voltage monitoring for Optris sensors: add state management, UI components, and emergency stop logic 2026-02-16 16:01:36 +01:00
Christian d7cede7dc0 Refactor quality control state: rename temperature to optris and update related logic for voltage handling 2026-02-16 16:01:36 +01:00
Christian 05fcca77c3 Implement separate tension arm monitoring for winder, addon, and slave arms with corresponding state management and UI components 2026-02-16 16:01:36 +01:00
Christian 577bff45ef Enhance GluetexErrorBanner: add operation mode switching and navigation on dialog dismissal 2026-02-16 16:01:36 +01:00
Christian 3b5c92f8bb Add debounce logic for tension arm monitoring and update error handling in Gluetex 2026-02-16 16:01:36 +01:00
Christian 7c06ad4313 Change order number and serial number types from String to u32 in OrderInfo and related structures 2026-02-16 16:01:36 +01:00
Christian 3f7958645e Add GluetexErrorBanner component to display system errors in Gluetex pages 2026-02-16 16:01:36 +01:00
Christian fe59209783 Fix state assignment for tension arm in Gluetex machine 2026-02-16 16:01:36 +01:00
Christian 16d0f7cc56 Refactor operation modes in Gluetex machine: rename and update logic for Setup and Production modes 2026-02-16 16:01:36 +01:00
Christian 91083550a8 Update PID parameters for temperature controllers in Gluetex machine 2026-02-16 16:01:36 +01:00
Christian ed8c64b473 Add operation mode management to Gluetex machine 2026-02-16 16:01:36 +01:00
Christian e4750ee823 Rename "Tension Arm" to "Slave Tension Arm" for clarity in GluetexAddonsPage 2026-02-16 16:01:36 +01:00
Christian b14c38b5e7 Add order information handling to Gluetex machine API and state management 2026-02-16 16:01:36 +01:00
Christian 18aefa2867 Add optris voltage readings to Gluetex machine and update related components 2026-02-16 16:01:36 +01:00
Christian 36ce437499 Add Gluetex machine constant to machine identification module 2026-02-16 16:01:36 +01:00
Christian 37c9e143a8 Add Gluetex machine identification and update REST API routes, remove hardcoded ui check 2026-02-16 16:01:36 +01:00
Christian 868089a6bf fix slave puller logic 2026-02-16 16:01:36 +01:00
Christian 258b4409c7 Refactor toPresetData function in GluetexPresetsPage to handle optional state properties and improve data structure for preset states 2026-02-16 16:01:36 +01:00
Christian 3dbf9cc304 Refactor Gluetex to use target angle and sensitivity for slave puller control 2026-02-16 16:01:36 +01:00
Christian 02eb00883c Refactor Slave Puller API to use target angle and sensitivity for speed control 2026-02-16 16:01:36 +01:00
Christian ff16df1304 Refactor sleep timer handling in Gluetex to reset on user interactions and remove activity detection logic 2026-02-16 16:01:36 +01:00
Christian b67065b9c3 Fix slave speed calculation in Gluetex by referencing the correct tension arm 2026-02-16 16:01:36 +01:00
Christian d0ed404afa Fix endstop detection logic in Gluetex by correcting voltage threshold condition 2026-02-16 16:01:36 +01:00
Christian 2d9ca0e702 Refactor code formatting 2026-02-16 16:01:36 +01:00
Christian 44e59d9d9a Refactor sleep timer handling in Gluetex to improve state emission logic and variable naming 2026-02-16 16:01:36 +01:00
Christian 7b950c93a0 Update pid algorithm 2026-02-16 16:01:36 +01:00
Christian 73032f6b46 Implement sleep mode handling and state emission in Gluetex 2026-02-16 16:01:36 +01:00
Christian f5413d9b8e Fix slave and addon tension arm switch and sample interval 2026-02-16 16:01:36 +01:00
Christian c609e8dc6a Refactor addon motor references from motor 5 as konturrad to motor 3 2026-02-16 16:01:36 +01:00
Christian 394b5fd8ea Refactor GluetexAddonsPage and GluetexOverviewPage to enhance heating mode and heater power display 2026-02-16 16:01:36 +01:00
Christian 18b471af39 Refactor Stepper 5 controls layout for improved readability and organization 2026-02-16 16:01:36 +01:00
Christian 0505609a01 Add product description input dialog and update order information layout in GluetexOverviewPage 2026-02-16 16:01:36 +01:00
Christian 7062d28ff1 Add heating mode selection to GluetexHeatersPage with ControlCard and SelectionGroup 2026-02-16 16:01:36 +01:00
Christian 4c7bce5462 Enhance GluetexGraphsPage with additional slave and addon tension arm angle and puller speed data support 2026-02-16 16:01:36 +01:00
Christian ce7a128b2d Organize settings and make it collapsible 2026-02-16 16:01:36 +01:00
Christian 8f19b135e1 Add support for "min" unit in unit icon and rendering functions 2026-02-16 16:01:36 +01:00
Christian 49ed575ee2 Add sleep timer functionality with state management and UI integration 2026-02-16 16:01:36 +01:00
Christian 2970c4e924 Put heater graphs into one 2026-02-16 16:01:35 +01:00
Christian f200f38862 Add temperature zone graphs and integrate them into GluetexGraphsPage and update pid values 2026-02-16 16:01:35 +01:00
Christian c7df114544 Add motor direction control and organize settings 2026-02-16 16:01:35 +01:00
Christian 1594c3a0b6 Refactor autotuning result handling to prevent retaining tuner state after completion or failure 2026-02-16 16:01:35 +01:00
Christian 34a44a1b17 fix datatypes in GluetexPresetsPage 2026-02-16 16:01:35 +01:00
Christian d3b05a0a89 Implement Gluetex preset functionality with detailed state management and UI integration 2026-02-16 16:01:35 +01:00
Christian 223dd74c24 fix format and switch addon motor 4 and slave puller. 2026-02-16 16:01:35 +01:00
Christian 14859506f3 Prevent duplicate toast notifications for PID auto-tuning completion events 2026-02-16 16:01:35 +01:00
Christian 740350f920 Update with new pid values 2026-02-16 16:01:35 +01:00
Christian c527f85ebc Update PID auto-tuning configuration: extend max duration to 1 hour and adjust peak tracking logic 2026-02-16 16:01:35 +01:00
Christian c2f3086cde Refactor PID auto-tuning configuration: update parameters for improved temperature oscillation and control 2026-02-16 16:01:35 +01:00
Christian 60233c3e7f Enhance PID auto-tuning: update configuration parameters for improved performance and stability 2026-02-16 16:01:35 +01:00
Christian 066bee0511 Refactor PID auto-tuner: streamline configuration, enhance peak detection, and improve output control logic 2026-02-16 16:01:35 +01:00
Christian be3c604294 Switch PID tuning method from Ziegler-Nichols to Tyreus-Luyben for improved temperature control in thermal systems 2026-02-16 16:01:35 +01:00
Christian d64edbd9c0 Update PID tuning parameters for Gluetex to improve temperature control accuracy 2026-02-16 16:01:35 +01:00
Christian 63d11fbfe7 Increase maximum duration for PID auto-tuning from 20 to 40 minutes for improved tuning accuracy 2026-02-16 16:01:35 +01:00
Christian 5ad56880ee Enhance PID auto-tuning: add progress tracking, cycle counting, and update configuration parameters for improved performance 2026-02-16 16:01:35 +01:00
Christian 5522b87f49 Refactor PID auto-tuning configuration: replace relay amplitude with hysteresis and update related logic 2026-02-16 16:01:35 +01:00
Christian fe5767ca50 remove role 12 from gluetex 2026-02-16 16:01:35 +01:00
Christian 6e7b6c028e fix machine id 2026-02-16 16:01:35 +01:00
Christian a4aa3524e6 Add auto pid tuning 2026-02-16 16:01:35 +01:00
Christian 30bec6e3c3 Fix rebase issues 2026-02-16 16:01:35 +01:00
Christian 03ce5cf6a2 Fix reference to addon tension arm in slave puller speed controller update 2026-02-16 16:01:35 +01:00
Christian 9ac46f4857 Add EL3062 configuration and initialization in Gluetex machine 2026-02-16 16:01:35 +01:00
Christian b111c8e5c3 Add support for addon voltage inputs in Gluetex machine 2026-02-16 16:01:35 +01:00
Christian 0c0c9de2f2 Refactor code for improved readability and consistency in addon motor controller and registry modules 2026-02-16 16:01:35 +01:00
Christian 6606cfdcce Add support for Konturlänge and Pause settings in Addon Motor 5 functionality 2026-02-16 16:01:35 +01:00
snacj 7f46fdc666 Fixed Imports 2026-02-16 16:01:35 +01:00
Christian e67a147278 Swap functionality of slave and addon tension arms in Gluetex machine API 2026-02-16 16:01:35 +01:00
Christian a3277f299a Restructuring of Gluetex Winder files 2026-02-16 16:01:35 +01:00
Christian d1f07e8d01 Add support for Addon Motor 5 in Gluetex machine, including state management and UI integration 2026-02-16 16:01:35 +01:00
Christian a5da005f7b Add slave puller state schema and update related types in Gluetex machine 2026-02-16 16:01:35 +01:00
Christian b91e62fad7 Add overview page and update routing for Gluetex machine 2026-02-16 16:01:35 +01:00
Christian e7bfcf6942 Implement user preference for enabling slave puller feature in Gluetex machine 2026-02-16 16:01:35 +01:00
Christian fbdf3d0521 Refactor PID tuning parameters for temperature controllers in Gluetex machine 2026-02-16 16:01:35 +01:00
Christian 1ef69df731 Add heating control functions for Gluetex machine and update state handling 2026-02-16 16:01:35 +01:00
Christian 8d652ce4c3 Add tension arm monitoring functionality to Gluetex machine 2026-02-16 16:01:35 +01:00
Christian e83967b4d3 Update navigation in MachinesPage to use dynamic serial parameter 2026-02-16 16:01:35 +01:00
Christian 68476c0ce6 Add addon tension arm functionality to Gluetex machine 2026-02-16 16:01:35 +01:00
Christian 36f3de7626 Refactor tension arm handling in GluetexAddonsPage to use slave tension arm variables 2026-02-16 16:01:35 +01:00
Christian 605fa3a7fb Add heating control functionality to Gluetex machine
- Introduced heating enabled state management in the Gluetex API and emit modules.
- Updated the Gluetex state schema to include heating states.
- Implemented helper functions to derive heating mode from backend state.
- Enhanced useGluetex hook to manage heating state updates.
2026-02-16 16:01:35 +01:00
Christian c9e7bb4973 Update Gluetex routing and machine identification handling to support dynamic serial numbers 2026-02-16 16:01:35 +01:00
Christian 3792196b5a Update Gluetex machine configuration to use EL7031-0030 for addon motors 3 and 4 2026-02-16 16:01:35 +01:00
Christian b3ee0f9d60 Update Gluetex machine identity vectors to include EL7031_0030_IDENTITY_A 2026-02-16 16:01:35 +01:00
Christian 2c5597285b Add additional Gluetex machine configurations and update error messages for identity mismatches 2026-02-16 16:01:35 +01:00
Christian ee3c2c0214 Add vendor ID to Gluetex machine properties and update role assignment logic in DeviceEepromDialogContent 2026-02-16 16:01:35 +01:00
Christian ea91efd811 Fix device role assignment in DeviceEepromDialogContent to use actual role number 2026-02-16 16:01:35 +01:00
Christian 1a5ee2fb98 Update Gluetex machine roles and device configurations for EL2008 2026-02-16 16:01:35 +01:00
Christian e919e49f25 Add Gluetex machine properties and device roles 2026-02-16 16:01:35 +01:00
Christian 446daf26b0 Add heating PID settings and controls for multiple zones in Gluetex 2026-02-16 16:01:17 +01:00
Christian c3db0a110e Format LinearStepConverter initialization for better readability in tests 2026-02-16 16:01:17 +01:00
Christian 01f2b49778 Update LinearStepConverter initialization in tests to use from_circumference method 2026-02-16 16:01:17 +01:00
Christian f445d44809 Remove unused import and update function parameter name in SlavePullerSpeedController 2026-02-16 16:01:17 +01:00
Christian 11c6f806c6 Implement slave puller functionality with state management and control in Gluetex 2026-02-16 16:01:17 +01:00
Christian eb085fa80c Add addon motor support with state management and control functions, remove cutting unit 2026-02-16 16:01:17 +01:00
Christian 315ed1b31c Implement heating control features for Gluetex machine, including temperature controllers and power management for heating zones. 2026-02-16 16:01:17 +01:00
Christian d83488a471 Add temperature sensor integration to Gluetex machine and update UI components 2026-02-16 16:01:17 +01:00
Christian 3efee724e1 Implement rust backend for default winding operations 2026-02-16 16:01:17 +01:00
Christian 3dd1b86ac5 Add Gluetex Heaters page and integrate simulated temperature and power data 2026-02-16 16:00:20 +01:00
Christian 3f2e40e774 Implement Gluetex Addons page and integrate motor ratio controls 2026-02-16 16:00:20 +01:00
Christian 5543eb7c83 Add temperature control features to Gluetex machine interface 2026-02-16 16:00:20 +01:00
Christian 63367211a1 Add default winder to gluetex device with dummy data 2026-02-16 16:00:19 +01:00
Christian af0b5deff0 Merge pull request #1135 from qitechgmbh/991-graph-tab-in-top-navigation-bar-not-connecting-to-graph-page-2
fixed the graph page not not connecting to navigation bar  bug
2026-02-13 08:06:53 +01:00
Christian 743a589c1d Update README.md to enhance video descriptions and add additional video section 2026-02-12 17:04:35 +01:00
Achraf Jalleli ee7550d89f Merge pull request #1127 from qitechgmbh/1042-implement-minimal-machine-example-for-wago-750-530
1042 implement minimal machine example for wago 750 530
2026-02-12 13:37:26 +01:00
Achraf JalleliandCopilot ec13f715aa slug fix
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-12 13:22:14 +01:00
Achraf Jalleli 2af746e7a4 Add files via upload 2026-02-12 13:14:04 +01:00
JalleliAchraf 3af3fb9d79 used array.fill 2026-02-12 11:22:11 +01:00
Achraf Jalleli ff3103bec2 Merge branch 'master' into 1042-implement-minimal-machine-example-for-wago-750-530 2026-02-12 11:00:23 +01:00
Hans Sebastian Pöhlmann 5eb3c39f84 1043 implement minimal machine example for wago 750 1506 (#1106)
* wago 750_1506 minimal machine backend apparently done, frontend still unfinished

* wago 750_1506 frontend apparently done, but untested

* added bus coupler to device roles
2026-02-12 10:54:06 +01:00
Robin Krämer 2c715d6a8e fixed initialization by raising timeouts for eeprom,eeprom write lock (#1136)
* fixed initialization by raising timeouts for eeprom,eeprom write lock

* fixed formatting
2026-02-11 14:24:48 +01:00
JalleliAchraf fb12001721 fixed the graph page not not connecting to navigation bar bug 2026-02-10 18:29:04 +01:00
Robin Krämer ad94754fcf Added missing revision for Watercooling (#1130) 2026-02-09 15:14:30 +01:00
Achraf Jalleli b919721c3a Update npmDepsHash for electron package 2026-02-06 16:20:26 +01:00
JalleliAchraf a428a2aa02 formatting 2026-02-06 16:12:25 +01:00
JalleliAchraf 7b479c6021 Merge branch '1042-implement-minimal-machine-example-for-wago-750-530' of github.com:qitechgmbh/control into 1042-implement-minimal-machine-example-for-wago-750-530 2026-02-06 16:09:03 +01:00
JalleliAchraf 17ac24c79d minor improvements 2026-02-06 16:08:25 +01:00
JalleliAchraf 8c7a04b616 formatting fix 2026-02-06 15:57:55 +01:00
github-actions[bot] fe248c79ae chore: update npmDepsHash to sha256-/7zfLof91S97OmW8dBeaVajYEv3r2eYOZ8Zb2ahykaQ= 2026-02-06 14:54:58 +00:00
JalleliAchraf 038df09611 removed unecessary differences between this branch and master 2026-02-06 15:49:21 +01:00
JalleliAchraf 9e43d8d431 several small fixes 2026-02-06 15:44:13 +01:00
JalleliAchraf d364263d6e wago digital output minimal machine 2026-02-06 15:22:00 +01:00
Christian 376e4a4ea2 Merge pull request #1117 from qitechgmbh/1100-update-the-main-readme
1100 update the main readme
2026-02-05 21:39:54 +01:00
Robin Krämer 0b30ae9a85 removed excessive logging, simplified socketio task with actual error printed … (#1122)
* removed excessive logging, simplified socketio task with actual error message

* fixed formatting
2026-02-05 17:34:48 +01:00
Christian 528952238d Update issue templates 2026-02-05 14:54:10 +01:00
Robin Krämer 8c32ce2a2b loop now uses Different Cycle time when development-build is set (#1112)
* loop now uses Different Cycle time when development-build is set

* fixed formatting

* hid stop_dnsmasq import behind feature flag

* added feature gate for start_dnsmasq in main
2026-02-05 11:52:56 +01:00
Christian 579ef0d2e2 Enhance video section in README.md with descriptive captions for better user engagement 2026-02-04 19:39:34 +01:00
Christian b6263e90d6 Refactor video section in README.md for improved layout and clarity 2026-02-04 19:32:33 +01:00
Christian f67f098ff3 better wording 2026-02-04 19:30:21 +01:00
Christian 1a3c26b789 Update issue templates 2026-02-04 18:30:07 +01:00
Oshgnacknak 49f0c9f289 Merge pull request #1113 from qitechgmbh/add-issue-templates
Add issue templates
2026-02-04 15:40:33 +01:00
Oshgnacknak e60daed274 Merge pull request #1116 from qitechgmbh/1115-remove-gitattributes-and-gitmodules
1115 Remove .gitattributes and .gitmodules files
2026-02-04 15:35:35 +01:00
Oshgnacknak 9a9120398e Merge pull request #1097 from qitechgmbh/dependabot/cargo/serde_json-1.0.149
Bump serde_json from 1.0.145 to 1.0.149
2026-02-04 15:22:07 +01:00
Christian dec342498c Fix link to Device Examples in README.md for improved navigation 2026-02-04 15:21:06 +01:00
Christian bc8cc5dd3d Update README.md to enhance documentation structure and clarity 2026-02-04 15:18:19 +01:00
Oshgnacknak 4a1f3d125d Merge pull request #1096 from qitechgmbh/dependabot/cargo/syn-2.0.114
Bump syn from 2.0.111 to 2.0.114
2026-02-04 15:07:29 +01:00
dependabot[bot] 1d1eeb3d48 Bump serde_json from 1.0.145 to 1.0.149
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.145 to 1.0.149.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.145...v1.0.149)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.149
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-04 14:06:09 +00:00
Oshgnacknak c395d4e172 Merge pull request #1094 from qitechgmbh/dependabot/cargo/euclid-0.22.13
Bump euclid from 0.22.11 to 0.22.13
2026-02-04 15:05:33 +01:00
Oshgnacknak 5009c5ca75 Merge pull request #1092 from qitechgmbh/dependabot/cargo/axum-0.8.8
Bump axum from 0.8.7 to 0.8.8
2026-02-04 15:03:41 +01:00
Oshgnacknak 50dbc46b8c Merge pull request #1089 from qitechgmbh/dependabot/cargo/tracing-subscriber-0.3.22
Bump tracing-subscriber from 0.3.20 to 0.3.22
2026-02-04 15:01:40 +01:00
Oshgnacknak 774e7e2f8b Merge pull request #1108 from qitechgmbh/dependabot/cargo/bytes-1.11.1
Bump bytes from 1.11.0 to 1.11.1
2026-02-04 14:59:55 +01:00
Oshgnacknak 270872fe81 Merge pull request #1114 from qitechgmbh/1081-automate-npm-deps-hash-for-electronnix
feat: automate npm deps hash update for Electron
2026-02-04 14:33:01 +01:00
Oshgnacknak a749f59186 Merge pull request #1110 from qitechgmbh/1109-remove-hooks
1109 remove hooks folder
2026-02-04 14:28:37 +01:00
Christian bae1de3c54 Remove .gitattributes and .gitmodules files 2026-02-04 13:52:33 +01:00
Christian 7681dae32c feat: automate npmDepsHash updates for Electron
- Add GitHub Actions workflow to automatically calculate and update npmDepsHash
- Workflow triggers on package-lock.json changes in PRs
- Uses nix prefetch-npm-deps to calculate correct hash
- Automatically commits hash updates to the PR
- Supports manual triggering via workflow_dispatch
2026-02-04 13:40:58 +01:00
ChristianandCopilot 2d98d369e1 Update .github/ISSUE_TEMPLATE/feature_request.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-04 13:04:29 +01:00
Christian 310ac42a88 Update issue templates
Add issue templates for better feature requests or bug reports.
2026-02-04 12:57:35 +01:00
Christian 182df9c569 Merge pull request #1107 from qitechgmbh/electron-deps
electron deps
2026-02-03 22:05:39 +01:00
Christian 153a719d64 remove Git hooks and pre-commit script 2026-02-03 21:56:06 +01:00
Christian 84603d1594 update hash 2026-02-03 21:53:28 +01:00
Christian 17b2e3e297 clear hash 2026-02-03 21:52:02 +01:00
Christian fb1cf33c7f Update package lock 2026-02-03 21:44:19 +01:00
dependabot[bot] ae2d0fa8b1 Bump bytes from 1.11.0 to 1.11.1
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.11.0 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.11.0...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 19:43:19 +00:00
ᴏxᴏ.ᴍɪᴋᴀ ed06b63b77 Merge pull request #1083 from qitechgmbh/1076-minimum-hardware-documentation-links
1076 minimum hardware documentation links
2026-02-03 17:15:21 +01:00
Oshgnacknak 9f0f45d19c Fix hash 2026-02-03 16:26:07 +01:00
dependabot[bot] a75c0ff08a Bump zustand from 5.0.9 to 5.0.11 in /electron
Bumps [zustand](https://github.com/pmndrs/zustand) from 5.0.9 to 5.0.11.
- [Release notes](https://github.com/pmndrs/zustand/releases)
- [Commits](https://github.com/pmndrs/zustand/compare/v5.0.9...v5.0.11)

---
updated-dependencies:
- dependency-name: zustand
  dependency-version: 5.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 16:19:11 +01:00
dependabot[bot] 205432c926 Bump vitest from 3.2.4 to 4.0.18 in /electron
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.4 to 4.0.18.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.18/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.0.18
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 16:18:11 +01:00
dependabot[bot] 781783464b Bump eslint from 9.39.1 to 9.39.2 in /electron
Bumps [eslint](https://github.com/eslint/eslint) from 9.39.1 to 9.39.2.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.1...v9.39.2)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.39.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 16:16:43 +01:00
dependabot[bot] 4fa2a1a221 Bump react-i18next from 15.7.4 to 16.5.4 in /electron
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 15.7.4 to 16.5.4.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v15.7.4...v16.5.4)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 16.5.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 16:14:36 +01:00
Oshgnacknak b734468741 Fix event names in wago power machine 2026-02-03 16:14:02 +01:00
dependabot[bot] 1ed4ae3d2c Bump socket.io-client from 4.8.1 to 4.8.3 in /electron
Bumps [socket.io-client](https://github.com/socketio/socket.io) from 4.8.1 to 4.8.3.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-client@4.8.1...socket.io-client@4.8.3)

---
updated-dependencies:
- dependency-name: socket.io-client
  dependency-version: 4.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 15:57:02 +01:00
dependabot[bot] 8095222cad Bump syn from 2.0.111 to 2.0.114
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.111 to 2.0.114.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.111...2.0.114)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 2.0.114
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 12:37:25 +00:00
dependabot[bot] d19af0923c Bump axum from 0.8.7 to 0.8.8
Bumps [axum](https://github.com/tokio-rs/axum) from 0.8.7 to 0.8.8.
- [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.7...axum-v0.8.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 12:37:25 +00:00
Robin Krämer 08ce5a37bd aquapath now works with all the other machines connected (#1103)
* aquapath now works with all the other machines connected

* fixed formatting

* removed unused import

* fixed formatting AGAIN
2026-02-03 13:36:07 +01:00
Robin Krämer 78c7667d96 Merge pull request #1087 from qitechgmbh/fix-wago-init-logic
fixed Wago Initialization Logic
2026-02-02 11:02:44 +01:00
dependabot[bot] bef3389874 Bump euclid from 0.22.11 to 0.22.13
Bumps [euclid](https://github.com/servo/euclid) from 0.22.11 to 0.22.13.
- [Release notes](https://github.com/servo/euclid/releases)
- [Commits](https://github.com/servo/euclid/commits)

---
updated-dependencies:
- dependency-name: euclid
  dependency-version: 0.22.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-01 02:25:36 +00:00
dependabot[bot] 985937c227 Bump tracing-subscriber from 0.3.20 to 0.3.22
Bumps [tracing-subscriber](https://github.com/tokio-rs/tracing) from 0.3.20 to 0.3.22.
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-subscriber-0.3.20...tracing-subscriber-0.3.22)

---
updated-dependencies:
- dependency-name: tracing-subscriber
  dependency-version: 0.3.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-01 02:25:04 +00:00
kraemr 22be4b79f0 fixed linter errors and added logic for 750_430 2026-01-30 16:33:50 +01:00
kraemr be84edf0fd removed unused parameter from ModulePDOMapping 2026-01-30 16:23:22 +01:00
kraemr ff3e787a03 fixed Wago Initialization Logic
fixed formatting
2026-01-30 16:16:10 +01:00
Christian 093663c4e6 Merge pull request #1086 from qitechgmbh/1085-change-heater-power-for-extruder-v3
Update heater power to the real values for extruder v3
2026-01-30 15:14:59 +01:00
Christian 1b2d6df459 Merge pull request #967 from qitechgmbh/Hamids-Branch
BECKHOFF minimal example motor
2026-01-30 13:24:54 +01:00
Christian cd2e15cb29 Update heater power to the real values for extruder v3 2026-01-30 13:23:46 +01:00
Christian f1571c0daa Merge pull request #1082 from qitechgmbh/revert-1077-1076-minimum-hardware-documentation-links
Revert "docs(readme): add documentation links and update machine table"
2026-01-29 14:50:33 +01:00
Christian 96e0fb83ef Revert "docs(readme): add documentation links and update machine table" 2026-01-29 14:50:10 +01:00
Christian 94cffbf717 Merge pull request #1077 from qitechgmbh/1076-minimum-hardware-documentation-links
docs(readme): add documentation links and update machine table
2026-01-29 13:22:14 +01:00
EinsPommes 38b2e907f9 docs(readme): update documentation links and improve machine table clarity 2026-01-28 17:16:10 +01:00
ᴏxᴏ.ᴍɪᴋᴀ 7e5498751a Merge branch 'master' into 1076-minimum-hardware-documentation-links 2026-01-28 17:13:52 +01:00
Christian eef9d63de2 Merge pull request #1074 from qitechgmbh/dependabot/npm_and_yarn/electron/multi-c5fe2c841a
Bump seroval and solid-js in /electron
2026-01-27 11:00:51 +01:00
Christian d9d9b9d344 update hash 2026-01-27 10:47:20 +01:00
Christian 2ff3d8df56 remove deps hash 2026-01-27 10:43:38 +01:00
Christian 462672bb6a Add peer dependency flag to multiple packages in package-lock.json 2026-01-27 10:38:58 +01:00
dependabot[bot] 84d539a4fa Bump seroval and solid-js in /electron
Bumps [seroval](https://github.com/lxsmnsyc/seroval) and [solid-js](https://github.com/solidjs/solid). These dependencies needed to be updated together.

Updates `seroval` from 1.3.2 to 1.5.0
- [Release notes](https://github.com/lxsmnsyc/seroval/releases)
- [Commits](https://github.com/lxsmnsyc/seroval/commits)

Updates `solid-js` from 1.9.10 to 1.9.11
- [Release notes](https://github.com/solidjs/solid/releases)
- [Changelog](https://github.com/solidjs/solid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/solidjs/solid/commits)

---
updated-dependencies:
- dependency-name: seroval
  dependency-version: 1.5.0
  dependency-type: indirect
- dependency-name: solid-js
  dependency-version: 1.9.11
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-27 10:38:58 +01:00
Robin Krämer 850b2d411d Merge pull request #1080 from qitechgmbh/changelog-2.14.1
updated Changelog
2026-01-26 16:37:03 +01:00
kraemr 008efe6ba1 updated Changelog 2026-01-26 16:36:09 +01:00
Robin KrämerandOshgnacknak 3bd248b9fe Don't use half sec interval (#1079)
Co-authored-by: Oshgnacknak <osh@oshgnacknak.de>
2026-01-26 16:29:11 +01:00
Oshgnacknak d73cfe7a02 Don't use half sec interval 2026-01-26 13:19:08 +01:00
EinsPommes 6a6c09e283 docs(readme): add documentation links and update machine table 2026-01-25 22:03:53 +01:00
dependabot[bot] 6d2279d44b Merge pull request #1062 from qitechgmbh/dependabot/npm_and_yarn/electron/seroval-1.4.2
Bump seroval from 1.4.0 to 1.4.2 in /electron
2026-01-25 17:00:04 +01:00
Oshgnacknak 61b9ac8a39 Merge branch 'master' into Hamids-Branch 2026-01-23 16:14:22 +01:00
Oshgnacknak 728dd6eb26 Merge pull request #1066 from qitechgmbh/update_numpad_position
Numpad position now updating on resize
2026-01-23 16:10:00 +01:00
Hans Sebastian Pöhlmann 742fd35d5f Numpad position now updating on resize 2026-01-23 15:56:58 +01:00
Milan von dem Bussche 5e1126e896 Revise machine table in README.md
Updated the machine table format and added version details.
2026-01-23 12:38:14 +01:00
Milan von dem Bussche e91719f902 Update getting started documentation with new sections 2026-01-23 12:37:30 +01:00
Milan von dem Bussche 0efa9bcb07 Revise README structure and add documentation links
Updated README to enhance structure and clarity, added minimal hardware examples section, and included API documentation link.
2026-01-23 12:33:58 +01:00
Milan von dem Bussche 37d897d240 Revise machine table with new entries and videos
Updated machine specifications and added video links for various machines.
2026-01-23 11:38:36 +01:00
Christian bb169e3b33 Fix formatting in registry.rs by removing extra blank line 2026-01-22 14:11:19 +01:00
Christian 3d735f8abd Merge branch 'master' into Hamids-Branch 2026-01-22 13:48:58 +01:00
Christian a9a973e336 Merge branch 'master' into Hamids-Branch 2026-01-21 17:11:58 +01:00
Christian b66a59dba9 Fix naming 2026-01-21 14:33:18 +01:00
Christian 9dbc962dbc Update file paths 2026-01-21 14:33:18 +01:00
Christian 93e2558803 Translate comments from German to English 2026-01-21 14:33:18 +01:00
Christian 8cf7480833 Fix path in routes.tsx 2026-01-21 14:33:18 +01:00
Christian 08d655effc Fix naming and folder structure 2026-01-21 14:33:18 +01:00
Christian c1c867fca1 Change minimal motor readme for current implementation 2026-01-21 14:33:18 +01:00
Hamid-el 1378c00e1a added .idea to .gitignore 2026-01-21 14:33:18 +01:00
Hamid-el 85f17befbe removed .idea 2026-01-21 14:33:18 +01:00
Hamid-el f2e764f93a finished stepper motor + doc finished (real version3) 2026-01-21 14:33:18 +01:00
Hamid-el 9adeeda490 finished stepper motor + doc finished (real version2) 2026-01-21 14:33:18 +01:00
Hamid-el 68bf075172 finished stepper motor + doc finished (real version) 2026-01-21 14:33:18 +01:00
Hamid-el 7d23190939 stepper motor doc finished 2026-01-21 14:33:18 +01:00
Hamid-el 5c68e2c78a finished stepper motor 2026-01-21 14:33:18 +01:00
Hamid-el 980e14a96e stepper motor with ethercat beckhoff Ek1100 and EL7031 0030 + UI works 2026-01-21 14:33:18 +01:00
Hamid-el d5fb45e4e3 stepper motor with ethercat beckhoff Ek1100 and EL7031 works 2026-01-21 14:33:18 +01:00
Hamid-el cdb7ab1e0c small adjustment 2026-01-21 14:33:18 +01:00
Hamid-el da934e0bb3 first version of manually running a stepper motor with ethercat beckhoff Ek1100 and EL7031
# Conflicts:
#	machines/src/aquapath1/act.rs
#	machines/src/registry.rs
#	server/src/ethercat/setup.rs
2026-01-21 14:33:18 +01:00
154 changed files with 23939 additions and 885 deletions

No files matched your search

-3
View File
@@ -1,3 +0,0 @@
.png filter=lfs diff=lfs merge=lfs -text
.jpg filter=lfs diff=lfs merge=lfs -text
.jpeg filter=lfs diff=lfs merge=lfs -text
+40
View File
@@ -0,0 +1,40 @@
---
name: Bug report
about: Create a report to help us improve
title: 'BUG: '
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Navigate to '...'
2. Select/Press '....'
3. Execute operation '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots/Photos**
If applicable, add screenshots from the HMI display or photos of the machine screen to help explain your problem.
**Machine Information (please complete the following):**
- Machine Type: [Winder2, Laser, Aquapath...]
- Control Software Version: [e.g. 2.4.1]
- Settings: [e.g. Different Gear Ratios, PID Settings, …]
**Device Information (if issue occurs on external device):**
- Device Type: [e.g. Tablet, PLC, Remote Control Panel]
- OS: [e.g. Windows 10, Linux, etc.]
- Connection Type: [e.g. Ethernet, USB]
**Error Messages/Codes**
If applicable, provide exact error codes or messages displayed or found in the troubleshooting terminal.
**Additional context**
Add any other context about the problem here (e.g., recent software updates, environmental factors).
+33
View File
@@ -0,0 +1,33 @@
---
name: Feature request
about: Suggest an idea for this project
title: 'Feat: '
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. During operation, I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions, workarounds, or features you've considered.
**Use Case/Application:**
- Machine Type(s): [e.g. All machines, Winder, Aquapath, ...]
- Frequency of Use: [e.g. Daily, During setup, Occasionally]
**Expected Benefits:**
- Efficiency Improvement: [e.g. Reduces setup time by X minutes, Eliminates manual steps]
- Safety Enhancement: [e.g. Prevents operator error, Adds safety checks]
- Production Impact: [e.g. Increases throughput, Reduces downtime, Improves quality]
- User Experience: [e.g. Simplifies workflow, Reduces training time]
**Mockups/Sketches**
If applicable, add sketches, mockups, or photos showing how the feature would appear on the HMI display or integrate with the machine.
**Additional context**
Add any other context, industry standards, or examples from other control systems about the feature request here.
+80
View File
@@ -0,0 +1,80 @@
name: Update npm deps hash
on:
pull_request:
paths:
- 'electron/package-lock.json'
- 'electron/package.json'
workflow_dispatch:
env:
NIX_PATH: nixpkgs=channel:nixos-25.05
permissions:
contents: write
pull-requests: write
actions: write
jobs:
update-hash:
runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Nix
uses: nixbuild/nix-quick-install-action@v34
with:
nix_conf: |
keep-env-derivations = true
keep-outputs = true
- name: Calculate new npm deps hash
id: calc-hash
run: |
# Calculate the hash using nix run with nixpkgs
NEW_HASH=$(nix run 'nixpkgs#prefetch-npm-deps' electron/package-lock.json)
echo "hash=$NEW_HASH" >> $GITHUB_OUTPUT
echo "Calculated hash: $NEW_HASH"
- name: Update npmDepsHash in electron.nix
run: |
NEW_HASH="${{ steps.calc-hash.outputs.hash }}"
sed -i "s|npmDepsHash = \".*\";|npmDepsHash = \"$NEW_HASH\";|" nixos/packages/electron.nix
- name: Check if hash changed
id: check-changes
run: |
if git diff --quiet nixos/packages/electron.nix; then
echo "changed=false" >> $GITHUB_OUTPUT
echo "No changes needed - hash is already correct"
else
echo "changed=true" >> $GITHUB_OUTPUT
echo "Hash updated in electron.nix"
fi
- name: Commit and push if changed
if: steps.check-changes.outputs.changed == 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add nixos/packages/electron.nix
git commit -m "chore: update npmDepsHash to ${{ steps.calc-hash.outputs.hash }}"
git push
- name: Comment on PR
if: steps.check-changes.outputs.changed == 'true'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Automatically updated `npmDepsHash` to `${{ steps.calc-hash.outputs.hash }}`\n\n**Next step:** Trigger the checks by pushing an empty commit:\n```bash\ngit commit --allow-empty -m "trigger ci" && git push\n```'
})
+2 -1
View File
@@ -7,4 +7,5 @@ build.sh
shell.nix
node_modules/
runtime_metrics.csv
compile_metrics.csv
compile_metrics.csv
.idea/
View File
Whitespace-only changes.
+6
View File
@@ -1,3 +1,9 @@
# `2.14.1`
_26.01.2026_
## Laser
-[#1079](https://github.com/qitechgmbh/control/pull/1079) Fixed Overflowing of buffer for Graphs causing crashes
# `2.14.0`
_22.01.2026_
Generated
+50 -40
View File
@@ -204,7 +204,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -221,7 +221,7 @@ checksum = "99e1aca718ea7b89985790c94aad72d77533063fe00bc497bb79a7c2dae6a661"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -232,9 +232,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.8.7"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"axum-macros",
@@ -291,7 +291,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -390,9 +390,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.0"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
dependencies = [
"serde",
]
@@ -539,7 +539,7 @@ version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -682,7 +682,7 @@ dependencies = [
"deluxe-macros",
"once_cell",
"proc-macro2",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -695,7 +695,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -710,7 +710,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -721,7 +721,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -962,14 +962,13 @@ dependencies = [
"deluxe",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
name = "ethercrab"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbb13becfa15713adb09b27fdb74468b827cdc78aa99aea638a89c1b0db3ce94"
source = "git+https://github.com/v-morlock/ethercrab?branch=main#d297d13389d85a4edb4e69cc98f9dd7f12626cac"
dependencies = [
"async-io",
"atomic-waker",
@@ -998,8 +997,7 @@ dependencies = [
[[package]]
name = "ethercrab-wire"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d15828cb020fb44c92ca67e1ee3c122c9621c2e00d383d066e488f0619023ba2"
source = "git+https://github.com/v-morlock/ethercrab?branch=main#d297d13389d85a4edb4e69cc98f9dd7f12626cac"
dependencies = [
"ethercrab-wire-derive",
"heapless",
@@ -1008,20 +1006,19 @@ dependencies = [
[[package]]
name = "ethercrab-wire-derive"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e790b8011211ed70babfcacbd4277b91ee21402ad5ba6a2c293db02e2f12919"
source = "git+https://github.com/v-morlock/ethercrab?branch=main#d297d13389d85a4edb4e69cc98f9dd7f12626cac"
dependencies = [
"criterion",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
name = "euclid"
version = "0.22.11"
version = "0.22.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48"
checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
dependencies = [
"num-traits",
]
@@ -1878,7 +1875,7 @@ dependencies = [
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -1908,7 +1905,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -2221,7 +2218,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -2251,20 +2248,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
name = "serde_json"
version = "1.0.145"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@@ -2332,6 +2329,7 @@ dependencies = [
"smol",
"socketioxide",
"spin_sleep",
"ta",
"textplots",
"tokio",
"tower-http",
@@ -2573,9 +2571,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.111"
version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [
"proc-macro2",
"quote",
@@ -2588,6 +2586,12 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "ta"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
[[package]]
name = "tap"
version = "1.0.1"
@@ -2621,7 +2625,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -2681,7 +2685,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -2843,7 +2847,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -2880,9 +2884,9 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
version = "0.3.20"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"chrono",
"matchers",
@@ -3074,7 +3078,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
"wasm-bindgen-shared",
]
@@ -3139,7 +3143,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -3150,7 +3154,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
@@ -3426,5 +3430,11 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.114",
]
[[package]]
name = "zmij"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
+43 -134
View File
@@ -2,167 +2,76 @@
# QiTech Control
QiTech Control is an open-source framework designed to bring modern software paradigms to certified industrial hardware.
QiTech Control is an open-source framework designed to bring modern software development practices to certified industrial hardware.
It frees developers from proprietary, license-heavy PLC ecosystems and rigid "point-and-click" workflows that are no longer adequate for today's complex automation challenges.
QiTech Control combines the modularity and reliability of standard EtherCAT terminals (e.g., WAGO, Beckhoff) with the power of a modern Rust & React stack.
## Documentation
[![](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.*
**[View Full Documentation Wiki](https://github.com/qitechgmbh/control/wiki)**
[![](https://img.youtube.com/vi/55egCAkQgyM/maxresdefault.jpg)](https://youtu.be/55egCAkQgyM)
*Click here to watch a full explainer Video of our Software.*
- [Getting Started](https://github.com/qitechgmbh/control/wiki/Getting-Started)
- [Architecture Overview](https://github.com/qitechgmbh/control/wiki/Architecture-Overview)
- [EtherCAT Basics](https://github.com/qitechgmbh/control/wiki/Ethercat-Basics)
- [Device Examples](https://github.com/qitechgmbh/control/wiki/Hardware-Examples)
- [REST API Reference](https://github.com/qitechgmbh/control/wiki/Rest-Api)
# Repo Structure
## Videos
Frontend
| Software Demo | Full Explainer |
|---------------|----------------|
| [![](https://img.youtube.com/vi/KI3YeBwfV-s/maxresdefault.jpg)](https://www.youtube.com/watch?v=KI3YeBwfV-s)<br/>*Watch a demo of the control software in action* | [![](https://img.youtube.com/vi/55egCAkQgyM/maxresdefault.jpg)](https://youtu.be/55egCAkQgyM)<br/>*Watch a complete overview of QiTech Control* |
- `/electron`: Frontend code for the control software built with React and Electron.
## Repository Structure
Backend
- **`/electron`** - React + Electron frontend
- **`/server`** - Rust backend implementing machine logic
- **`/ethercat-hal`** - Hardware abstraction layer for EtherCAT devices
- **`/control-core`** - Core control logic
- **`/nixos`** - Custom Linux OS with realtime kernel
- `/server`: Glue between Beckhoff and Electron. Implements machine logic.
- `/ethercat-hal`: Hardware abstraction layer for Beckhoff (and possibly other EtherCat) devices and protocols.
- `/ethercat-hal-derive`: Macros for `ethercat-hal`
- `/control-core`: Core control logic for the server.
## Technology Stack
Operating System
**Backend:** Rust with [Ethercrab](https://github.com/ethercrab-rs/ethercrab) for EtherCAT, [SocketIO](https://socket.io/) for real-time communication, [axum](https://docs.rs/axum/latest/axum/) for REST API
- `/nixos`: Custom Linux with realtime kernel & preconfigured for touchscreens.
Other
- `/docs`: Documentation for the project.
# Technology Choices
## Backend
To interface with Beckhoff and other EtherCAT devices we need an EtherCAT master software. 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.
We use [Smol](https://github.com/smol-rs/smol) for EtherCAT IO in the control loop for it's performance and [Tokio](https://tokio.rs/) for server IO because of it's ecosystem and maturity.
## Frontend
We could combine the code of the frontend and backend using [Doxius](https://dioxuslabs.com/) but it lacks good Linux support. We chose [Electron](https://www.electronjs.org/) with [React](https://react.dev/) for it's maturity and ecosystem. For the UI we use [Shadcn](https://ui.shadcn.com/) components and [Tailwind](https://tailwindcss.com/) for styling. For routing we use [TanStack Router](https://tanstack.com/router/v1).
# Dev Setup
[Developer Documentation](./docs/developer-docs/)
## Backend
- Rust stable 1.86^ toolchain (install via [rustup](https://rustup.rs/))
- `rust-analyzer` extension for VSCode
- Set your interface in `server/src/ethercat/init.rs` like `en10`
- Connect a Beckhoff EK1100 to your interface
- run `cd server && cargo run` to start the server (localhost:3001)
## Frontend
- nodejs and npm installed
- run `cd electron && npm i && npm run start` to start the frontend
# Machines
| 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 | 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 | 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 | - |
# Minimal Hardware Examples
For complete step-by-step tutorials on setting up your first hardware, including wiring diagrams and software configuration, see the [Getting Started Guide](./docs/developer-docs/getting-started.md#minimal-hardware-examples).
Available examples:
- **LED Control with EL2004** - Digital output control (simplest setup)
- **Analog Input with EL3021** - Reading analog current measurements
**Frontend:** [Electron](https://www.electronjs.org/) + [React](https://react.dev/) with [Shadcn](https://ui.shadcn.com/) components and [Tailwind](https://tailwindcss.com/) styling
# More Docs
## Hardware Examples
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
QiTech Control supports a wide range of EtherCAT hardware from WAGO and Beckhoff. We provide complete step-by-step tutorials with wiring diagrams and software configuration for many common modules.
- [x] Example Winder V2
**[View all hardware examples and tutorials on the wiki](https://github.com/qitechgmbh/control/wiki/Hardware-Examples)**
- [ ] Electron
Quick links to popular examples:
- [EL2004 - LED Control](https://github.com/qitechgmbh/control/wiki/Minimal-Example-El2004) (simplest setup for beginners)
- [EL3021 - Analog Input](https://github.com/qitechgmbh/control/wiki/Minimal-Example-El3021)
- [EL7031 - Stepper Motor Control](https://github.com/qitechgmbh/control/wiki/Minimal-Example-El7031-Motor)
- [WAGO 750-402 - Digital Input](https://github.com/qitechgmbh/control/wiki/Minimal-Example-Wago750-402)
- [WAGO 750-455 - Analog Input](https://github.com/qitechgmbh/control/wiki/Minimal-Example-Wago-750-455)
- Folder Structure
- Routing with TanStack Router
- Design with Tailwind & Shadcn
- ...
## QiTech Machines
- [ ] Interfacing with Electron/Server
QiTech Control powers 10+ production machines for filament extrusion, winding, measurement, and material processing.
- [ ] SocketIO
- Machine Namespace
- Main Namespace
- [ ] REST
- Machine Mutations
- Write Device Identification
**[View complete machine catalog on the wiki](https://github.com/qitechgmbh/control/wiki/QiTech-Machines)**
- Server
- [x] [Threading](./docs/control-loop.md)
- [x] [Logging](./docs/logging.md)
- [ ] Control Loop Setup
- Control Loop Thread
- [ ] realtime
- Maindevice
- Group
- Extracting Device Identifications
- Identifying Groups
- Validating Machines
- Run Control Loop
- [x] [Control Loop](./docs/control-loop.md)
- [x] [Machine/Device Identification](./docs/identification.md)
- [ ] Machines
- When to create a new Machine?
- Versioning
- Code sharing
- Creating/Validating a Machine
- Validation
- Configuration
- [ ] Machine Implementation Guide
- Link: How to create a Device
- Link: How to create an Actor
- Link: How to create a Machine
- API (SocketIO + REST)
- Creation/Validation Logic
- Optional/Mandatory Devices
- Validate Devices
- Business Logic
- Link: How to create Machine Abstraction (Like Traverse/Puller/...)
- Forward `act` in winder.
## Contributing
- [ ] Control Core
This is an open-source project. Contributions are welcome! Please see the [wiki](https://github.com/qitechgmbh/control/wiki) for development guidelines.
- [x] [Actors](./docs/actors.md)
- [ ] SocketIO
- Namespaces & Caching
- Joining leaving namespaces
- NamespaceId
- Caching
- Serverside Caching
- Clientside Caching
- [ ] REST
- [x] Ethercat HAL
- [x] [Devices](./docs/devices.md)
- [x] [Configuration (CoE)](./docs/coe.md)
- [x] [IO](./docs/io.md)
- [x] [PDO](./docs/pdo.md)
## Additional Videos
- [x] [Ethercat Basics](./docs/ethercat-basics.md)
| Episode Highlights | |
|--------------------|--------------------|
| [![](https://img.youtube.com/vi/U2wNN6sF4to/maxresdefault.jpg)](https://www.youtube.com/watch?v=U2wNN6sF4to&t=163s)<br/>**Day in the Software Team**<br/>*Behind the scenes with Robin and how the team ships control software.* | [![](https://img.youtube.com/vi/R5WGAY3WRWA/maxresdefault.jpg)](https://www.youtube.com/watch?v=R5WGAY3WRWA&t=1s)<br/>**Software Stack Day**<br/>*A fast tour through the tools, stack, and daily workflow.* |
| [![](https://img.youtube.com/vi/xyaHwxXguw4/maxresdefault.jpg)](https://www.youtube.com/watch?v=xyaHwxXguw4&t=32s)<br/>**First Client**<br/>*A milestone week building the first real client project.* | [![](https://img.youtube.com/vi/moI7z2PVdF4/maxresdefault.jpg)](https://www.youtube.com/watch?v=moI7z2PVdF4)<br/>**Steelworks Week**<br/>*A deep-dive week on a demanding steelworks order.* |
- [x] [NixOS Operating System](./docs/nixos/README.md)
## License
See [LICENSE](LICENSE) file for details.
+6 -3
View File
@@ -4,10 +4,13 @@ set -e
FEATURE=""
# Check if "mock-machine" was passed as an argument
# Check if mock features were passed as an argument
if [[ "$1" == "mock-machine" ]]; then
echo "Building Debug Code with mock-machine feature"
FEATURE="--features mock-machine"
elif [[ "$1" == "gluetex-mock" ]]; then
echo "Building Debug Code with gluetex-mock feature"
FEATURE="--features gluetex-mock"
fi
if [[ "$1" == "release" ]]; then
@@ -18,8 +21,8 @@ else
cargo build $FEATURE --features development-build
fi
# Only setcap if not building with mock-machine
if [ "$1" != "mock-machine" ]; then
# Only setcap if not building with mock features
if [ "$1" != "mock-machine" ] && [ "$1" != "gluetex-mock" ]; then
echo "Setting capabilities for server executable"
sudo setcap 'cap_dac_override,cap_net_raw,cap_sys_nice,cap_ipc_lock=eip' ./target/debug/server
+2 -2
View File
@@ -9,11 +9,11 @@ workspace = true
[dependencies]
anyhow = "1.0.100"
axum = "0.8.6"
axum = "0.8.8"
ethercat_hal = { path = "../ethercat-hal" }
units = { path = "../units" }
ethercrab = "0.6"
ethercrab = { git = "https://github.com/v-morlock/ethercrab", branch = "main" }
interfaces = "0.0.9"
serde = "1.0.219"
serialport = "4.7.3"
+1
View File
@@ -1,4 +1,5 @@
pub mod clamping_timeagnostic_pid;
pub mod first_degree_motion;
pub mod pid;
pub mod pid_autotuner;
pub mod second_degree_motion;
@@ -0,0 +1,438 @@
//! PID Auto-tuner based on Klipper's algorithm
//!
//! This implementation uses the Astrom-Hagglund relay method to identify
//! system oscillations, then applies Ziegler-Nichols tuning rules to
//! calculate PID parameters.
//!
//! The algorithm works by:
//! 1. Heating to target + delta, then cooling to target - delta repeatedly
//! 2. Recording temperature peaks during these oscillations
//! 3. Calculating the ultimate gain (Ku) and period (Tu) from peak data
//! 4. Applying Ziegler-Nichols rules to derive Kp, Ki, Kd values
use std::time::Instant;
/// Configuration for PID auto-tuning
#[derive(Debug, Clone)]
pub struct AutoTuneConfig {
/// Temperature oscillation amplitude (e.g., ±5°C)
pub tune_delta: f64,
/// Maximum heating power (0.0 to 1.0)
pub max_power: f64,
/// Maximum duration for auto-tuning in seconds
pub max_duration_secs: f64,
}
impl Default for AutoTuneConfig {
fn default() -> Self {
Self {
tune_delta: 5.0,
max_power: 1.0,
max_duration_secs: 3600.0, // 60 minutes
}
}
}
/// Result of PID auto-tuning
#[derive(Debug, Clone)]
pub struct AutoTuneResult {
pub kp: f64,
pub ki: f64,
pub kd: f64,
/// Ultimate gain (Ku) used in calculation
pub ku: f64,
/// Ultimate period (Tu) used in calculation
pub tu: f64,
}
/// State of the auto-tuning process
#[derive(Debug, Clone, Copy, PartialEq)]
enum AutoTuneState {
NotStarted,
Running,
Completed,
Failed,
}
/// PID auto-tuner implementation
#[derive(Debug)]
pub struct PidAutoTuner {
config: AutoTuneConfig,
state: AutoTuneState,
// Target temperature
target_temp: f64,
// Heating control
heating: bool,
// Peak tracking
peak_value: f64,
peak_time: Instant,
peaks: Vec<(f64, Instant)>,
// Timing
start_time: Option<Instant>,
// Results
pub result: Option<AutoTuneResult>,
}
impl PidAutoTuner {
/// Create a new PID auto-tuner with given configuration
pub fn new(config: AutoTuneConfig) -> Self {
Self {
config,
state: AutoTuneState::NotStarted,
target_temp: 0.0,
heating: false,
peak_value: 0.0,
peak_time: Instant::now(),
peaks: Vec::new(),
start_time: None,
result: None,
}
}
/// Start the auto-tuning process
pub fn start(&mut self, now: Instant, target_temp: f64) {
self.state = AutoTuneState::Running;
self.target_temp = target_temp;
self.heating = true;
self.peak_value = 9999999.0; // When heating, track minimum (valley)
self.peak_time = now;
self.peaks.clear();
self.start_time = Some(now);
self.result = None;
}
/// Stop the auto-tuning process
pub fn stop(&mut self) {
if self.state == AutoTuneState::Running {
self.state = AutoTuneState::Failed;
}
}
/// Update the auto-tuner with current temperature
/// Returns (duty_cycle, completed)
pub fn update(&mut self, current_temp: f64, now: Instant) -> (f64, bool) {
if self.state != AutoTuneState::Running {
return (0.0, true);
}
// Check for timeout
if let Some(start) = self.start_time {
let elapsed = now.duration_since(start).as_secs_f64();
if elapsed > self.config.max_duration_secs {
self.state = AutoTuneState::Failed;
return (0.0, true);
}
}
// Temperature targets for oscillation
let upper_target = self.target_temp + self.config.tune_delta;
let lower_target = self.target_temp - self.config.tune_delta;
// Track peak values BEFORE checking for crossing
if self.heating {
// When heating, track minimum (valley)
if current_temp < self.peak_value {
self.peak_value = current_temp;
self.peak_time = now;
}
} else {
// When cooling, track maximum (peak)
if current_temp > self.peak_value {
self.peak_value = current_temp;
self.peak_time = now;
}
}
// Check if we've crossed the target and need to switch heating/cooling
if self.heating && current_temp >= upper_target {
self.heating = false;
self.record_peak();
} else if !self.heating && current_temp <= lower_target {
self.heating = true;
self.record_peak();
}
// Check if we have enough peaks to calculate PID
if self.peaks.len() >= 12 {
self.calculate_pid();
self.state = AutoTuneState::Completed;
return (0.0, true);
}
// Return appropriate duty cycle
let duty_cycle = if self.heating {
self.config.max_power
} else {
0.0
};
(duty_cycle, false)
}
/// Record a peak in the oscillation
fn record_peak(&mut self) {
self.peaks.push((self.peak_value, self.peak_time));
// Reset peak tracking for next cycle (following Klipper's logic)
if self.heating {
self.peak_value = 9999999.0; // When heating, we want to find the minimum (valley)
} else {
self.peak_value = -9999999.0; // When cooling, we want to find the maximum (peak)
}
}
/// Calculate PID parameters from recorded peaks
fn calculate_pid(&mut self) {
if self.peaks.len() < 4 {
self.state = AutoTuneState::Failed;
return;
}
// Find the cycle times and select the median cycle
let mut cycle_times: Vec<(f64, usize)> = Vec::new();
for pos in 4..self.peaks.len() {
let time_diff = self.peaks[pos]
.1
.duration_since(self.peaks[pos - 2].1)
.as_secs_f64();
cycle_times.push((time_diff, pos));
}
// Sort by time and pick the median
cycle_times.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let midpoint_pos = if cycle_times.is_empty() {
self.state = AutoTuneState::Failed;
return;
} else {
cycle_times[cycle_times.len() / 2].1
};
// Calculate PID parameters using this cycle
if let Some(result) = self.calculate_pid_at_position(midpoint_pos) {
self.result = Some(result);
self.state = AutoTuneState::Completed;
} else {
self.state = AutoTuneState::Failed;
}
}
/// Calculate PID at a specific peak position using Astrom-Hagglund and Ziegler-Nichols
fn calculate_pid_at_position(&self, pos: usize) -> Option<AutoTuneResult> {
if pos < 2 || pos >= self.peaks.len() {
return None;
}
// Calculate temperature amplitude and period
let temp_diff = self.peaks[pos].0 - self.peaks[pos - 1].0;
let time_diff = self.peaks[pos]
.1
.duration_since(self.peaks[pos - 2].1)
.as_secs_f64();
// Astrom-Hagglund method to estimate Ku and Tu
let amplitude = 0.5 * temp_diff.abs();
let ku = 4.0 * self.config.max_power / (std::f64::consts::PI * amplitude);
let tu = time_diff;
// Ziegler-Nichols method to generate PID parameters
let ti = 0.5 * tu;
let td = 0.125 * tu;
let kp = 0.4 * ku;
let ki = kp / ti;
let kd = kp * td;
Some(AutoTuneResult { kp, ki, kd, ku, tu })
}
/// Check if auto-tuning is completed successfully
pub fn is_completed(&self) -> bool {
self.state == AutoTuneState::Completed
}
/// Check if auto-tuning failed
pub fn is_failed(&self) -> bool {
self.state == AutoTuneState::Failed
}
/// Check if auto-tuning is running
pub fn is_running(&self) -> bool {
self.state == AutoTuneState::Running
}
/// Get the current state
pub fn state(&self) -> &str {
match self.state {
AutoTuneState::NotStarted => "not_started",
AutoTuneState::Running => "running",
AutoTuneState::Completed => "completed",
AutoTuneState::Failed => "failed",
}
}
/// Get progress as a percentage (0-100)
pub fn get_progress_percent(&self) -> f64 {
if self.state != AutoTuneState::Running {
return if self.state == AutoTuneState::Completed {
100.0
} else {
0.0
};
}
// Progress is based on number of peaks collected (need 12)
let progress = (self.peaks.len() as f64 / 12.0) * 100.0;
progress.min(99.0) // Cap at 99% until actually completed
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_autotuner_creation() {
let config = AutoTuneConfig::default();
let tuner = PidAutoTuner::new(config);
assert_eq!(tuner.state(), "not_started");
assert_eq!(tuner.get_progress_percent(), 0.0);
}
#[test]
fn test_autotuner_start() {
let config = AutoTuneConfig::default();
let mut tuner = PidAutoTuner::new(config);
let now = Instant::now();
tuner.start(now, 150.0);
assert_eq!(tuner.state(), "running");
assert!(tuner.is_running());
assert_eq!(tuner.target_temp, 150.0);
}
#[test]
fn test_autotuner_stop() {
let config = AutoTuneConfig::default();
let mut tuner = PidAutoTuner::new(config);
let now = Instant::now();
tuner.start(now, 150.0);
tuner.stop();
assert!(tuner.is_failed());
}
#[test]
fn test_autotuner_oscillation() {
let config = AutoTuneConfig {
tune_delta: 5.0,
max_power: 1.0,
max_duration_secs: 3600.0, // 1 hour
};
let mut tuner = PidAutoTuner::new(config);
let mut now = Instant::now();
tuner.start(now, 150.0);
// Simulate temperature oscillations
// Should heat to 155, then cool to 145, repeatedly
let mut temp = 140.0;
let mut completed = false;
let mut iterations = 0;
for cycle in 0..20 {
// Heating phase
while temp < 155.0 && !completed && iterations < 1000 {
temp += 0.5; // Simulate temperature rise
now += Duration::from_millis(100);
let (duty_cycle, done) = tuner.update(temp, now);
completed = done;
iterations += 1;
if !completed && !tuner.heating {
// We switched to cooling during heating phase - this is expected near the boundary
println!("Switched to cooling at temp={}", temp);
break;
}
if completed || temp >= 155.0 {
break;
}
}
if completed {
break;
}
// Cooling phase
while temp > 145.0 && !completed && iterations < 1000 {
temp -= 0.3; // Simulate temperature drop
now += Duration::from_millis(100);
let (duty_cycle, done) = tuner.update(temp, now);
completed = done;
iterations += 1;
if !completed && tuner.heating {
// We switched to heating during cooling phase - this is expected near the boundary
println!("Switched to heating at temp={}", temp);
break;
}
if completed || temp <= 145.0 {
break;
}
}
if completed {
break;
}
// Check progress
if cycle % 3 == 0 {
let progress = tuner.get_progress_percent();
println!("Cycle {}: Progress {}%", cycle, progress);
}
}
assert!(
completed,
"Auto-tuning should complete after sufficient oscillations"
);
assert!(tuner.is_completed(), "Should be in completed state");
assert!(tuner.result.is_some(), "Should have a result");
let result = tuner.result.as_ref().unwrap();
println!(
"Auto-tune result: Kp={:.3}, Ki={:.3}, Kd={:.3}",
result.kp, result.ki, result.kd
);
println!("Ku={:.3}, Tu={:.3}", result.ku, result.tu);
// Basic sanity checks
assert!(result.kp > 0.0, "Kp should be positive");
assert!(result.ki > 0.0, "Ki should be positive");
assert!(result.kd > 0.0, "Kd should be positive");
}
#[test]
fn test_autotuner_timeout() {
let config = AutoTuneConfig {
tune_delta: 5.0,
max_power: 1.0,
max_duration_secs: 1.0, // Very short timeout
};
let mut tuner = PidAutoTuner::new(config);
let mut now = Instant::now();
tuner.start(now, 150.0);
// Wait for timeout
now += Duration::from_secs(2);
let (_, completed) = tuner.update(150.0, now);
assert!(completed, "Should timeout");
assert!(tuner.is_failed(), "Should fail on timeout");
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

+81
View File
@@ -89,5 +89,86 @@ To get started with actual hardware, check out these step-by-step tutorials:
- **[LED Control with EL2004](./minimal-example-el2004.md)** - Digital output control, the simplest possible hardware setup
- **[Analog Input with EL3021](./minimal-example-el3021.md)** - Reading analog current measurements
- **[Stepper Motor Control with EL7031-0030](./minimal-example-el7031-motor.md)** - Complete motor control integration with velocity control
These examples provide complete hardware wiring diagrams and software setup instructions.
# More Docs
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
- [x] Example Winder V2
- [ ] Electron
- Folder Structure
- Routing with TanStack Router
- Design with Tailwind & Shadcn
- ...
- [ ] Interfacing with Electron/Server
- [ ] SocketIO
- Machine Namespace
- Main Namespace
- [ ] REST
- Machine Mutations
- Write Device Identification
- Server
- [x] [Threading](./docs/control-loop.md)
- [x] [Logging](./docs/logging.md)
- [ ] Control Loop Setup
- Control Loop Thread
- [ ] realtime
- Maindevice
- Group
- Extracting Device Identifications
- Identifying Groups
- Validating Machines
- Run Control Loop
- [x] [Control Loop](./docs/control-loop.md)
- [x] [Machine/Device Identification](./docs/identification.md)
- [ ] Machines
- When to create a new Machine?
- Versioning
- Code sharing
- Creating/Validating a Machine
- Validation
- Configuration
- [ ] Machine Implementation Guide
- Link: How to create a Device
- Link: How to create an Actor
- Link: How to create a Machine
- API (SocketIO + REST)
- Creation/Validation Logic
- Optional/Mandatory Devices
- Validate Devices
- Business Logic
- Link: How to create Machine Abstraction (Like Traverse/Puller/...)
- Forward `act` in winder.
- [ ] Control Core
- [x] [Actors](./docs/actors.md)
- [ ] SocketIO
- Namespaces & Caching
- Joining leaving namespaces
- NamespaceId
- Caching
- Serverside Caching
- Clientside Caching
- [ ] REST
- [x] Ethercat HAL
- [x] [Devices](./docs/devices.md)
- [x] [Configuration (CoE)](./docs/coe.md)
- [x] [IO](./docs/io.md)
- [x] [PDO](./docs/pdo.md)
- [x] [Ethercat Basics](./docs/ethercat-basics.md)
- [x] [NixOS Operating System](./docs/nixos/README.md)
@@ -0,0 +1,236 @@
# Minimal Example — Beckhoff EL7031-0030 Stepper Motor Integration
A complete hardware + software walkthrough
---
## Table of Contents
1. [Introduction](#1-introduction)
2. [Requirements](#2-requirements)
3. [Hardware Setup](#3-hardware-setup)
- [3.2 EK1100 Wiring](#32-ek1100-wiring)
- [3.2.1 Safe Wiring Procedure](#321-safe-wiring-procedure-beckhoff-recommended)
- [3.2.2 Wiring (EL7031-0030)](#322-wiring-el7031-0030)
- [3.3 Safety Warning](#33--safety-warning)
4. [Software Setup](#4-software-setup)
- [4.1 Installing on Linux](#41-installing-on-linux-this-depends-on-your-distro)
- [4.2 Running the Backend](#42-running-the-backend)
- [4.3 Running the Frontend](#43-running-the-frontend)
5. [Demo](#5-demo)
- [5.1 Assigning Devices in the Dashboard](#51-assigning-devices-in-the-dashboard)
6. [Documentation](#6-documentation)
7. [Software Architecture](#7-software-architecture)
---
## 1. Introduction
This project documents the successful integration of a **Beckhoff EL7031-0030 Stepper Motor Terminal** into the QiTech control software stack.
The goal was to control a 24V stepper motor via EtherCAT using a custom Rust backend and a React/Electron frontend.
---
## 2. Requirements
### Software
- Rust toolchain
- Node.js + npm
- Git
- QiTech Control repository
- EtherCAT HAL (included inside repo)
### Hardware
- **EtherCAT Master:** Linux PC
- **Bus Coupler:** EtherCAT Beckhoff EK1100
- **Stepper Driver:** Beckhoff EL7031-0030 (THIS IS DIFFERENT FROM EL7031)
- **Motor:** Standard 4-wire Stepper Motor
- **Power Supply:** 24V DC
- **Ethernet Cable:** Standard Ethernet cable
- **Wiring Tools:** Screwdriver, wires
---
## 3. Hardware Setup
### 3.2 EK1100 Wiring
This wiring configuration powers the EL7031-0030.
It is not the only possible wiring but is the **simplest functional setup**.
#### ⚠️ Safety Warning
Always disconnect power before wiring.
Working on live EtherCAT terminals can cause serious damage or electrical shock.
---
#### 3.2.1 Safe Wiring Procedure (Beckhoff Recommended)
1. Insert a screwdriver **straight** into the square release hole.
2. Insert the stripped wire into the round opening.
3. Remove the screwdriver — the spring clamp locks the wire.
![](../assets/wiring.png)
---
We supply power using a **DC hollow-plug adapter**, like this one:
https://www.amazon.de/dp/B093FTFZ8Q
Perform the following wiring on the EK1100:
1. Red wire **(+24 V)** → Terminal **2**
2. Black wire **(0 V)** → Terminal **3**
3. Jumper wire from **Terminal 1 → Terminal 6**
4. Jumper wire from **Terminal 5 → Terminal 7**
After wiring, your module should look like **Figure 1**.
---
#### **Figure 1 — EK1100 Minimal Wiring**
<img src="../assets/ek1100.jpeg" width="400">
---
#### **Figure 2 — EL7031-0030 Terminal**
<img src="../assets/EL7031_0030.jpg" width="300">
Slide the EL7031-0030 onto the right side of the EK1100 until it locks.
The EtherCAT E-Bus and power contacts connect automatically — **no wiring required**.
---
#### **Figure 3 — Motor**
<img src="../assets/motor.jpg" width="300">
---
#### **Figure 4 — Motor Wiring**
<img src="../assets/motor_wiring.jpg" width="300">
Now the motor is wired via the pins 4, 5, 12, 13 on the EL7031-0030.
---
#### **Figure 5 — EL7031 Integration Connected**
<img src="../assets/EL7031_0030_connected.jpg" width="400">
That's what the pin should look like.
---
### 3.2.2 Wiring (EL7031-0030)
**Crucial:** The EL7031 requires two power sources: E-Bus (side contacts) for logic, and Front Terminal (6 & 14) for motor power.
| Terminal Point | Function | Cable Color (Example) |
| :------------- | :--------------- | :-------------------- |
| **4** | Motor Coil A1 | Red |
| **12** | Motor Coil A2 | Blue |
| **5** | Motor Coil B1 | Green |
| **13** | Motor Coil B2 | Black |
| **6** | **Power +24V** | PSU Red (+) |
| **14** | **Power 0V/GND** | PSU Black (-) |
---
### 3.3 ⚠️ Safety Warning
- **Mandatory Power Supply:** Without 24V connected to **Pin 6 (+24V)** and **Pin 14 (GND)**, the terminal will remain in `PREOP` state or show a "Warning" LED (No Power). The motor will **not** move without this external supply.
#### Risk of Destruction (Short Circuit):
- **Never** connect the 24V Power Supply to the Motor Output pins (**4, 5, 12, 13**). This will instantly destroy the terminal (causing pins 12 & 13 to glow red/burn).
- Ensure strict separation: **Pins 4/5/12/13 are for the MOTOR ONLY**.
- **Pins 6/14 are for POWER ONLY**.
Also, see the documentation of the EtherCAT terminal ([EL7031-0030](https://download.beckhoff.com/download/document/io/ethercat-terminals/el7031-0030de.pdf)) for the different power modes (page 46 ff.)
---
## 4. Software Setup
### 4.1 Installing on Linux (this depends on your distro)
Paste this into your terminal:
```bash
# Press Enter when prompted
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
sudo apt update
sudo apt install -y npm nodejs git
git clone git@github.com:qitechgmbh/control.git
cd control/electron
npm install
```
### 4.2 Running the Backend
```bash
./cargo_run_linux.sh
```
This script:
- Builds the backend
- Grants required system capabilities (raw sockets)
- Starts EtherCAT communication
### 4.3 Running the Frontend
```bash
# move to the control directory
cd electron
npm run start
```
This launches the QiTech Control dashboard.
---
## 5. Demo
### 5.1 Assigning Devices in the Dashboard
Once the backend + frontend are running, you should see:
![](../assets/UI_start_motor.png)
Make sure that under "Assign" → "Machine Assignment"
the correct serial number is selected (each device should have the same one, here it is 1).
Under "Machine" select your new Machine however you named it (I named it "TestMotor V1"), else it will detect that there is something connected to it but the connection won't work.
![](../assets/machine_assigment_motor.png)
**NOW THE MOTOR SHOULD TURN!**
In the interface "TestMotor" on the left side, you can now control the motor (its state and its speed).
![](../assets/UI_motor.png)
---
## 6. Documentation
Use the official documentation of the EL7031-0030 for more information:
[Beckhoff EL7031-0030 Documentation](https://download.beckhoff.com/download/document/io/ethercat-terminals/el7031-0030de.pdf)
---
## 7. Software Architecture
### Backend (Rust)
Located in [machines/src/motor_test_machine/](/machines/src/motor_test_machine/).
1. **`mod.rs`**: Defines the `MotorTestMachine` struct and holds the state (driver wrapper, enabled state, target velocity).
2. **`api.rs`**: Handles incoming JSON commands from the frontend (Enable/Disable, Set Velocity) via WebSockets/SocketIO.
3. **`act.rs`**: The real-time control loop. It updates the `StepperVelocityEL70x1` driver wrapper in every cycle based on the current state.
4. **`new.rs`**: Initializes the hardware.
### Frontend (TypeScript/React)
Located in [electron/src/machines/motor_test_machine/](/electron/src/machines/motor_test_machine/) and [electron/src/routes/routes.tsx](/electron/src/routes/routes.tsx).
1. **`useTestMotor.ts`**: Custom hook managing the optimistic state and communication with the backend.
2. **`TestMotorControlPage.tsx`**: The UI using QiTech UI components (`ControlCard`, `EditValue`, `SelectionGroupBoolean`) to match the look and feel of the Winder2.
3. **Routing**: Integrated into `routes.tsx` using TanStack Router, ensuring the machine appears in the sidebar and navigation works correctly.
+143 -275
View File
@@ -1,12 +1,12 @@
{
"name": "qitech-control-electron",
"version": "1.0.0",
"version": "2.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qitech-control-electron",
"version": "1.0.0",
"version": "2.4.1",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@icons-pack/react-simple-icons": "^11.2.0",
@@ -40,12 +40,12 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
"react-i18next": "^15.4.1",
"react-i18next": "^16.5.4",
"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",
"socket.io-client": "^4.8.3",
"socket.io-msgpack-parser": "^3.0.2",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.1",
@@ -54,7 +54,7 @@
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^4.3.5",
"zod-validation-error": "^5.0.0",
"zustand": "^5.0.8"
"zustand": "^5.0.11"
},
"devDependencies": {
"@electron/fuses": "^1.8.0",
@@ -73,7 +73,7 @@
"@types/react-virtualized": "^9.22.2",
"electron": "^37.3.1",
"electron-playwright-helpers": "^1.7.1",
"eslint": "^9.20.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.4",
@@ -90,7 +90,7 @@
"vite": "^6.4.1",
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6",
"vitest": "^3.0.6"
"vitest": "^4.0.18"
}
},
"node_modules/@adobe/css-tools": {
@@ -1305,9 +1305,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2889,6 +2889,13 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -4116,39 +4123,40 @@
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"@vitest/spy": "4.0.18",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
@@ -4160,42 +4168,41 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
@@ -4203,28 +4210,24 @@
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -4639,16 +4642,6 @@
"node": "*"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cacheable-lookup": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
@@ -4769,18 +4762,11 @@
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
@@ -4842,16 +4828,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/check-error": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -5134,16 +5110,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -5812,9 +5778,9 @@
}
},
"node_modules/eslint": {
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -5825,7 +5791,7 @@
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.1",
"@eslint/js": "9.39.2",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@@ -8069,13 +8035,6 @@
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@@ -9232,6 +9191,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -9420,16 +9390,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -9786,16 +9746,17 @@
}
},
"node_modules/react-i18next": {
"version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
"version": "16.5.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz",
"integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 23.4.0",
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
@@ -10326,9 +10287,9 @@
}
},
"node_modules/seroval": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz",
"integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==",
"license": "MIT",
"peer": true,
"engines": {
@@ -10336,9 +10297,9 @@
}
},
"node_modules/seroval-plugins": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.4.0.tgz",
"integrity": "sha512-zir1aWzoiax6pbBVjoYVd0O1QQXgIL3eVGBMsBsNmM8Ukq90yGaWlfx0AB9dTS8GPqrOrbXn79vmItCUP9U3BQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.0.tgz",
"integrity": "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==",
"license": "MIT",
"engines": {
"node": ">=10"
@@ -10509,13 +10470,13 @@
"license": "ISC"
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
@@ -10523,23 +10484,6 @@
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-msgpack-parser": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/socket.io-msgpack-parser/-/socket.io-msgpack-parser-3.0.2.tgz",
@@ -10581,36 +10525,15 @@
}
},
"node_modules/solid-js": {
"version": "1.9.10",
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz",
"integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.3.0",
"seroval-plugins": "~1.3.0"
}
},
"node_modules/solid-js/node_modules/seroval": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
"version": "1.9.11",
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz",
"integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/solid-js/node_modules/seroval-plugins": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz",
"integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"seroval": "^1.0"
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.5.0",
"seroval-plugins": "~1.5.0"
}
},
"node_modules/sonner": {
@@ -10816,26 +10739,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -10976,11 +10879,14 @@
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
@@ -11028,30 +10934,10 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11691,29 +11577,6 @@
}
}
},
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-plugin-electron": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.29.0.tgz",
@@ -11782,51 +11645,50 @@
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"happy-dom": "*",
"jsdom": "*"
},
@@ -11834,13 +11696,19 @@
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
@@ -12226,9 +12094,9 @@
}
},
"node_modules/zustand": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
+7 -7
View File
@@ -1,8 +1,8 @@
{
"name": "qitech-control-electron",
"productName": "QiTech Control",
"version": "1.0.0",
"description": "",
"version": "2.4.1",
"description": "Next Generation Machine Interface ",
"main": ".vite/build/main.js",
"private": true,
"scripts": {
@@ -36,7 +36,7 @@
"@types/react-virtualized": "^9.22.2",
"electron": "^37.3.1",
"electron-playwright-helpers": "^1.7.1",
"eslint": "^9.20.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.4",
@@ -53,7 +53,7 @@
"vite": "^6.4.1",
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6",
"vitest": "^3.0.6"
"vitest": "^4.0.18"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@@ -88,12 +88,12 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
"react-i18next": "^15.4.1",
"react-i18next": "^16.5.4",
"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",
"socket.io-client": "^4.8.3",
"socket.io-msgpack-parser": "^3.0.2",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.1",
@@ -102,6 +102,6 @@
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^4.3.5",
"zod-validation-error": "^5.0.0",
"zustand": "^5.0.8"
"zustand": "^5.0.11"
}
}
+12
View File
@@ -84,6 +84,18 @@ export function toastError(title: string, error: string) {
toast(<ErrorToast title={title} error={error} />);
}
export function SuccessToast({ title, message }: Props & { message: string }) {
return (
<Toast title={title} icon="lu:Check">
<div className="text-zinc-500">{message}</div>
</Toast>
);
}
export function toastSuccess(title: string, message: string) {
toast(<SuccessToast title={title} message={message} />);
}
const friendlyHttpStatus = {
"200": "OK",
"201": "Created",
@@ -41,7 +41,7 @@ export function NewPresetDialog<T>({
</TouchButton>
</DialogTrigger>
<DialogContent>
<DialogContent className="flex max-h-[90vh] flex-col">
<DialogHeader>
<DialogTitle className="flex flex-row items-center gap-2">
<Icon name="lu:SquarePlus" />
@@ -58,7 +58,7 @@ export function NewPresetDialog<T>({
onChange={(e) => setName(e.target.value)}
className="w-full"
/>
<div className="flex flex-col gap-6 text-sm">
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto text-sm">
<span>Current Settings:</span>
<PresetPreviewTable entries={previewEntries} data={currentState} />
</div>
@@ -62,7 +62,7 @@ export function PresetCardMenu({
export type PresetCardProps<T> = {
preset: Preset<T>;
onApply: (preset: Preset<T>) => void;
onApply: (preset: Preset<T>) => void | Promise<void>;
onOverwrite: (preset: Preset<T>) => void;
onDelete: (preset: Preset<T>) => void;
onExport?: (preset: Preset<T>) => void;
@@ -16,7 +16,7 @@ import { PresetPreviewEntries, PresetPreviewTable } from "./PresetPreviewTable";
export type PresetShowDialogProps<T> = {
preset: Preset<T>;
previewEntries: PresetPreviewEntries<T>;
onApply: (preset: Preset<T>) => void;
onApply: (preset: Preset<T>) => void | Promise<void>;
hideDate?: boolean;
};
@@ -41,7 +41,7 @@ export function PresetShowDialog<T>({
</TouchButton>
</DialogTrigger>
<DialogContent>
<DialogContent className="flex max-h-[90vh] flex-col">
<DialogHeader>
<DialogTitle className="flex flex-row items-center gap-2">
<Icon name="lu:Save" />
@@ -53,7 +53,7 @@ export function PresetShowDialog<T>({
</DialogHeader>
<Separator />
<div className="flex flex-col gap-6 text-sm">
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto text-sm">
{!hideDate && (
<div>
Latest modification at{" "}
@@ -10,7 +10,7 @@ import { downloadJson } from "@/lib/download";
import { JsonFileInput } from "../FileInput";
type PresetsPageProps<T> = UsePresetsParams<T> & {
applyPreset: (preset: Preset<T>) => void;
applyPreset: (preset: Preset<T>) => void | Promise<void>;
previewEntries: PresetPreviewEntries<T>;
};
@@ -0,0 +1,427 @@
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 RatioInputProps = {
master?: number;
slave?: number;
onRatioChange?: (master: number, slave: number) => void;
title?: string;
min?: number;
max?: number;
step?: number;
};
const ratioSchema = z.object({
master: z.number().min(0.1).max(1000),
slave: z.number().min(0.1).max(1000),
});
type RatioFormData = z.infer<typeof ratioSchema>;
export function RatioInput({
master = 1,
slave = 1,
onRatioChange,
title = "Motor Ratio",
min = 0.1,
max = 1000,
step = 0.1,
}: RatioInputProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeField, setActiveField] = useState<"master" | "slave" | null>(
null,
);
const [inputValue, setInputValue] = useState<string>("");
const getInitialValues = useCallback(() => {
return {
master: master ?? 1,
slave: slave ?? 1,
};
}, [master, slave]);
const form = useForm<RatioFormData>({
resolver: zodResolver(ratioSchema),
defaultValues: getInitialValues(),
mode: "all",
});
// Initialize form with current values when dialog opens
useEffect(() => {
if (isOpen) {
const initialValues = getInitialValues();
form.reset(initialValues);
setActiveField(null);
setInputValue("");
}
}, [isOpen, getInitialValues, form]);
const formatRatioDisplay = useCallback(() => {
const m = master ?? 1;
const s = slave ?? 1;
return `${m.toFixed(1)} : ${s.toFixed(1)}`;
}, [master, slave]);
// Increment/decrement functions with bounds checking
const adjustRatioValue = useCallback(
(field: "master" | "slave", increment: boolean) => {
const currentValue = form.getValues(field);
let newValue = increment ? currentValue + step : currentValue - step;
// Round to avoid floating point precision issues
newValue = Math.round(newValue * 10) / 10;
// Clamp to min/max bounds
if (newValue < min) {
newValue = min;
} else if (newValue > max) {
newValue = max;
}
form.setValue(field, newValue, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
},
[form, step, min, max],
);
const handleNumpadClick = (digit: string) => {
if (activeField === null) return;
const newValue = inputValue + digit;
setInputValue(newValue);
};
const handleDecimal = () => {
if (activeField === null) return;
if (!inputValue.includes(".")) {
setInputValue(inputValue === "" ? "0." : inputValue + ".");
}
};
const handleBackspace = () => {
setInputValue(inputValue.slice(0, -1));
};
const handleClear = () => {
setInputValue("");
};
const handleNumpadConfirm = () => {
if (activeField === null || inputValue === "") return;
let numValue = parseFloat(inputValue);
// Clamp to min/max bounds
if (numValue < min) {
numValue = min;
} else if (numValue > max) {
numValue = max;
}
form.setValue(activeField, numValue, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
setInputValue("");
setActiveField(null);
};
const handleFieldClick = (field: "master" | "slave") => {
if (activeField === field) {
// If same field, submit and close
handleNumpadConfirm();
} else {
// Switch to new field
if (activeField !== null && inputValue !== "") {
handleNumpadConfirm();
}
setActiveField(field);
setInputValue("");
}
};
const handleSubmit = () => {
if (activeField !== null && inputValue !== "") {
handleNumpadConfirm();
return;
}
form.handleSubmit((data) => {
onRatioChange?.(data.master, data.slave);
setIsOpen(false);
})();
};
const handleAbort = () => {
const initialValues = getInitialValues();
form.reset(initialValues);
setActiveField(null);
setInputValue("");
setIsOpen(false);
};
const handleReset = () => {
form.setValue("master", 1, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
form.setValue("slave", 1, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
setActiveField(null);
setInputValue("");
};
// Watch individual form fields for real-time updates
const masterValue = useWatch({
control: form.control,
name: "master",
defaultValue: getInitialValues().master,
});
const slaveValue = useWatch({
control: form.control,
name: "slave",
defaultValue: getInitialValues().slave,
});
// Get current form values with fallback
const currentValues = {
master: masterValue ?? 1,
slave: slaveValue ?? 1,
};
// RatioValueInput component for individual ratio fields
const RatioValueInput = ({
field,
label,
value,
}: {
field: "master" | "slave";
label: string;
value: number;
}) => {
const isActive = activeField === field;
const displayValue =
isActive && inputValue !== "" ? inputValue : value.toFixed(1);
return (
<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-20 p-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
adjustRatioValue(field, true);
}}
disabled={value >= max}
>
<Icon name="lu:ChevronUp" className="size-4" />
</TouchButton>
<div
onClick={() => handleFieldClick(field)}
className={`flex h-16 w-24 cursor-pointer items-center justify-center rounded border-2 font-mono text-3xl transition-colors ${
isActive
? "border-blue-500 bg-blue-50"
: "border-gray-300 bg-white"
}`}
>
{displayValue}
</div>
<TouchButton
variant="outline"
size="sm"
className="h-10 w-20 p-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
adjustRatioValue(field, false);
}}
disabled={value <= min}
>
<Icon name="lu:ChevronDown" className="size-4" />
</TouchButton>
</div>
</div>
);
};
// Numpad component
const Numpad = () => (
<div className="flex flex-col gap-3">
{/* Display showing input value */}
<div className="flex h-12 items-center justify-center rounded border-2 border-gray-300 bg-gray-50 font-mono text-xl">
{inputValue === "" ? "0" : inputValue}
</div>
{/* Numpad grid */}
<div className="grid grid-cols-3 gap-2">
{[
["1", "2", "3"],
["4", "5", "6"],
["7", "8", "9"],
[".", "0", "DEL"],
].map((row, i) => (
<div key={i} className="contents">
{row.map((key) => (
<TouchButton
key={key}
variant="outline"
className="h-12 p-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (key === "DEL") {
handleBackspace();
} else if (key === ".") {
handleDecimal();
} else {
handleNumpadClick(key);
}
}}
>
{key === "DEL" ? (
<Icon name="lu:Delete" className="size-4" />
) : (
key
)}
</TouchButton>
))}
</div>
))}
</div>
{/* Control buttons */}
<div className="flex gap-2">
<TouchButton
variant="outline"
className="h-10 flex-1"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleClear();
}}
>
C
</TouchButton>
<TouchButton
className="h-10 flex-1 bg-green-600 text-white hover:bg-green-700"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleNumpadConfirm();
}}
disabled={activeField === null || inputValue === ""}
>
<Icon name="lu:Check" 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:Settings" className="mr-2 size-4" />
{formatRatioDisplay()}
</TouchButton>
</DialogTrigger>
<DialogContent className="max-w-4xl 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:Settings" className="size-6" />
<h3 className="text-xl font-semibold">{title}</h3>
</div>
<TouchButton
variant="ghost"
size="sm"
onClick={handleReset}
className="text-blue-600 hover:text-blue-700"
>
<Icon name="lu:RotateCcw" className="mr-2 size-4" />
Reset to 1:1
</TouchButton>
</div>
{/* Description */}
<div className="-mt-4 text-base text-gray-600">
Set the gear ratio. For every {currentValues.master.toFixed(1)}{" "}
rotation(s) of the master puller, the motor will make{" "}
{currentValues.slave.toFixed(1)} rotation(s).
</div>
<Separator />
{/* Main content: Ratio inputs and Numpad */}
<div className="flex gap-8">
{/* Ratio inputs */}
<div className="flex flex-1 items-center justify-center gap-8">
<RatioValueInput
field="master"
label="Master"
value={currentValues.master}
/>
<div className="mt-8 text-4xl font-bold">:</div>
<RatioValueInput
field="slave"
label="Slave"
value={currentValues.slave}
/>
</div>
{/* Numpad */}
<div className="w-40">
<Numpad />
</div>
</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 bg-blue-600 text-white hover:bg-blue-700"
onClick={handleSubmit}
>
<Icon name="lu:Check" className="mr-2 size-4" />
Apply
</TouchButton>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+8 -2
View File
@@ -4,7 +4,7 @@ import { cva } from "class-variance-authority";
import React from "react";
type Props = {
variant: "error" | "success";
variant: "error" | "success" | "info";
children: React.ReactNode;
};
@@ -14,10 +14,16 @@ export function StatusBadge({ variant, children }: Props) {
variant: {
error: "bg-red-500",
success: "bg-green-600",
info: "bg-blue-500",
},
},
});
const icon: IconName = variant === "error" ? "lu:TriangleAlert" : "lu:Check";
const icon: IconName =
variant === "error"
? "lu:TriangleAlert"
: variant === "success"
? "lu:Check"
: "lu:Info";
return (
<Badge
className={badgeStyle({
+7
View File
@@ -42,6 +42,8 @@ export function getUnitIcon(unit: Unit): IconName {
return "lu:RefreshCcw";
case "s":
return "lu:Clock";
case "min":
return "lu:Clock";
case "MiB":
return "lu:MemoryStick";
case "Mbit/s":
@@ -95,6 +97,8 @@ export function renderUnitSymbol(unit: Unit | undefined): string {
return "µs";
case "s":
return "s";
case "min":
return "min";
case "MiB":
return "MiB";
case "Mbit/s":
@@ -170,6 +174,8 @@ export function renderUnitSymbolLong(unit: Unit): string {
return "microseconds";
case "s":
return "seconds";
case "min":
return "minutes";
case "MiB":
return "mebibytes";
case "Mbit/s":
@@ -202,6 +208,7 @@ export const units = [
"%",
"µs",
"s",
"min",
"MiB",
"Mbit/s",
"/s",
+64
View File
@@ -49,3 +49,67 @@ export function deepEquals(
return true;
}
/**
* Checks if all keys in `subset` match corresponding values in `superset`.
* Extra keys in `superset` are ignored. Empty objects in `subset` are
* treated as matching anything.
*/
export function deepSubsetEquals(
superset: object | undefined,
subset: object | undefined,
): boolean {
if (superset === subset) {
return true;
}
if (
!subset ||
typeof subset !== "object" ||
!superset ||
typeof superset !== "object"
) {
return superset === subset;
}
if (Array.isArray(superset) && Array.isArray(subset)) {
if (superset.length !== subset.length) {
return false;
}
for (let i = 0; i < subset.length; i++) {
if (!deepSubsetEquals(superset[i], subset[i])) {
return false;
}
}
return true;
}
if (Array.isArray(superset) || Array.isArray(subset)) {
return false;
}
const subRec = subset as Record<string, any>;
const supRec = superset as Record<string, any>;
const subKeys = Object.keys(subRec);
// Empty object in subset matches anything
if (subKeys.length === 0) {
return true;
}
for (const key of subKeys) {
if (!(key in supRec)) {
// Key missing in superset — only ok if subset value is undefined
if (subRec[key] !== undefined) {
return false;
}
continue;
}
if (!deepSubsetEquals(supRec[key], subRec[key])) {
return false;
}
}
return true;
}
+6 -3
View File
@@ -4,7 +4,7 @@ import {
} from "@/machines/types";
import { usePresetStore } from "./presetStore";
import { Preset, presetSchema, PresetSchema } from "./preset";
import { deepEquals } from "@/lib/objects";
import { deepSubsetEquals } from "@/lib/objects";
import { useEffect } from "react";
import { toastError } from "@/components/Toast";
import { z } from "zod";
@@ -132,7 +132,7 @@ export function usePresets<T>({
if (latestPresetId === undefined) {
const preset = store.insert({
id: undefined,
name: "Latest Machine Stettings",
name: "Latest Machine Settings",
machineIdentification: machine_identification,
lastModified: new Date(),
schemaVersion,
@@ -164,7 +164,10 @@ export function usePresets<T>({
return false;
}
return deepEquals(currentState, preset.data ?? {});
return deepSubsetEquals(
currentState as object,
(preset.data ?? {}) as object,
);
};
const importPreset = (json: any) => {
@@ -18,7 +18,7 @@ export function Aquapath1Page() {
link: "graph",
title: "Graph",
icon: "lu:ChartSpline",
activeLink: "graphs",
activeLink: "graph",
},
{
link: "settings",
+14 -1
View File
@@ -8,9 +8,15 @@ import { TimeSeries } from "@/lib/timeseries";
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
import { StatusBadge } from "@/control/StatusBadge";
// Extended heating state type that includes optional autotuning fields
type HeatingStateWithAutoTune = HeatingState & {
autotuning_active?: boolean;
autotuning_progress?: number;
};
type Props = {
title: string;
heatingState?: HeatingState;
heatingState?: HeatingStateWithAutoTune;
heatingTimeSeries: TimeSeries;
heatingPower: TimeSeries;
min: number;
@@ -70,6 +76,13 @@ export function HeatingZone({
Cant Read Temperature! Check Temperature Sensor Wiring!
</StatusBadge>
)}
{heatingState?.autotuning_active && (
<StatusBadge variant="info">
Auto-Tuning in Progress...{" "}
{roundToDecimals((heatingState?.autotuning_progress ?? 0) * 100, 0)}%
</StatusBadge>
)}
</ControlCard>
);
}
@@ -14,7 +14,7 @@ import {
NamespaceId,
createNamespaceHookImplementation,
ThrottledStoreUpdater,
} from "../../../client/socketioStore";
} from "@/client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";
import {
createTimeSeries,
@@ -66,19 +66,13 @@ export type Laser1NamespaceStore = {
roundness: TimeSeries;
};
// Constants for time durations
const HALF_SECOND = 500;
const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries({
sampleIntervalLong: HALF_SECOND,
});
const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries();
const { initialTimeSeries: x_diameter, insert: addXDiameter } =
createTimeSeries({ sampleIntervalLong: HALF_SECOND });
createTimeSeries();
const { initialTimeSeries: y_diameter, insert: addYDiameter } =
createTimeSeries({ sampleIntervalLong: HALF_SECOND });
const { initialTimeSeries: roundness, insert: addRoundness } = createTimeSeries(
{ sampleIntervalLong: HALF_SECOND },
);
createTimeSeries();
const { initialTimeSeries: roundness, insert: addRoundness } =
createTimeSeries();
/**
* Factory function to create a new Laser1 namespace store
@@ -0,0 +1,53 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { SelectionGroupBoolean } from "@/control/SelectionGroup";
import { EditValue } from "@/control/EditValue";
import { Label } from "@/control/Label";
import { useTestMotor } from "./useTestMotor";
export function TestMotorControlPage() {
const { state, setMotorOn, setVelocity } = useTestMotor();
// Fallback in case state is still null
const safeState = state ?? { motor_enabled: false, motor_velocity: 0 };
return (
<Page>
<ControlGrid columns={2}>
{/* Basic control */}
<ControlCard title="Motor Status">
{/* On/Off switch */}
<Label label="Power State">
<SelectionGroupBoolean
value={safeState.motor_enabled}
// Icon mapping for True/False
optionTrue={{ children: "Enabled", icon: "lu:Play" }}
optionFalse={{ children: "Disabled", icon: "lu:CirclePause" }}
onChange={(val) => setMotorOn(val)}
/>
</Label>
</ControlCard>
{/* Velocity */}
<ControlCard title="Settings">
{/* Velocity input with unit */}
<Label label="Target Velocity">
<EditValue
title="Velocity"
value={safeState.motor_velocity}
unit="rpm"
min={0}
max={1000} // Limit
step={1}
onChange={(val) => setVelocity(val)}
// Displays the value (as integer)
renderValue={(v) => v.toFixed(0)}
/>
</Label>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,22 @@
import { Topbar } from "@/components/Topbar";
import { testMotorSerialRoute } from "@/routes/routes";
import React from "react";
export function TestMotorPage() {
// Access is allowed here because this function is called later
const { serial } = testMotorSerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/testmotor/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:CirclePlay",
},
]}
/>
);
}
@@ -0,0 +1,73 @@
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";
// ========== Event Schema (Must match StateEvent) ==========
export const stateEventDataSchema = z.object({
motor_enabled: z.boolean(),
motor_velocity: z.number(),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
// ========== Store ==========
export type TestMotorNamespaceStore = {
state: StateEvent | null;
};
export const createTestMotorNamespaceStore =
(): StoreApi<TestMotorNamespaceStore> =>
create<TestMotorNamespaceStore>(() => ({
state: null,
}));
// ========== Message Handler ==========
export function testMotorMessageHandler(
chstore: StoreApi<TestMotorNamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<TestMotorNamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const updateStore = (
updater: (state: TestMotorNamespaceStore) => TestMotorNamespaceStore,
) => throttledUpdater.updateWith(updater);
try {
if (event.name === "StateEvent") {
const parsed = stateEventSchema.parse(event);
updateStore(() => ({ state: parsed.data }));
} else {
handleUnhandledEventError(event.name);
}
} catch (error) {
console.error(`Error processing ${event.name}:`, error);
throw error;
}
};
}
// ========== Namespace Hook ==========
const useTestMotorNamespaceImplementation =
createNamespaceHookImplementation<TestMotorNamespaceStore>({
createStore: createTestMotorNamespaceStore,
createEventHandler: testMotorMessageHandler,
});
export function useTestMotorNamespace(
machine_identification_unique: MachineIdentificationUnique,
): TestMotorNamespaceStore {
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};
return useTestMotorNamespaceImplementation(namespaceId);
}
@@ -0,0 +1,96 @@
import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { testMotorSerialRoute } from "@/routes/routes";
import { MachineIdentificationUnique } from "@/machines/types";
import { useTestMotorNamespace, StateEvent } from "./testMotorNamespace";
import { useMachineMutate } from "@/client/useClient";
import { produce } from "immer";
import { useEffect, useMemo } from "react";
import { TestMotor } from "@/machines/properties";
import { z } from "zod";
export function useTestMotor() {
const { serial: serialString } = testMotorSerialRoute.useParams();
// Memoize machine identification
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: TestMotor.machine_identification,
serial,
};
}, [serialString]);
// Namespace state from backend
const { state } = useTestMotorNamespace(machineIdentification);
// Optimistic state
const stateOptimistic = useStateOptimistic<StateEvent>();
useEffect(() => {
if (state) stateOptimistic.setReal(state);
}, [state, stateOptimistic]);
// Generic mutation sender
const { request: sendMutation } = useMachineMutate(
z.object({
type: z.string(),
payload: z.any(),
}),
);
const updateStateOptimistically = (
producer: (current: StateEvent) => void,
serverRequest?: () => void,
) => {
const currentState = stateOptimistic.value;
if (currentState)
stateOptimistic.setOptimistic(produce(currentState, producer));
serverRequest?.();
};
// --- ACTIONS ---
const setMotorOn = (on: boolean) => {
updateStateOptimistically(
(current) => {
current.motor_enabled = on;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
// Must exactly match the 'Mutation' enum!
data: { type: "SetMotorOn", payload: on },
}),
);
};
const setVelocity = (velocity: number) => {
updateStateOptimistically(
(current) => {
current.motor_velocity = velocity;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { type: "SetMotorVelocity", payload: velocity },
}),
);
};
return {
state: stateOptimistic.value,
setMotorOn,
setVelocity,
};
}
+337
View File
@@ -390,6 +390,11 @@ export const aquapath1: MachineProperties = {
product_id: 0x7d83052,
revision: 0x110000,
},
{
vendor_id: 2,
product_id: 0x7d83052,
revision: 0x120000,
},
],
},
{
@@ -473,6 +478,30 @@ export const digitalInputTestMachine: MachineProperties = {
},
device_roles: [],
};
export const wago8chDioTestMachine: MachineProperties = {
name: "WAGO 8ch DIO Test",
version: "V1",
slug: "wago8chdiotestmachine",
icon: "lu:Disc3",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0041,
},
device_roles: [
{
role: 0,
role_label: "Wago 750-354 Bus Coupler",
allowed_devices: [
{
vendor_id: 0x21,
product_id: 0x07500354,
revision: 0x2,
},
],
},
],
};
export const wagoPower1: MachineProperties = {
name: "WAGO Power",
version: "V1",
@@ -544,6 +573,64 @@ export const ip20TestMachine: MachineProperties = {
],
};
export const TestMotor: MachineProperties = {
name: "TestMotor",
version: "V1",
slug: "testmotor",
icon: "lu:Disc3",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0011,
},
device_roles: [
{
role: 0,
role_label: "Bus Coupler",
allowed_devices: [
{
vendor_id: 0x2,
product_id: 0x44c2c52,
revision: 0x120000,
},
],
},
{
role: 1,
role_label: "Stepper Motor",
allowed_devices: [
{
vendor_id: 0x2,
product_id: 0x1b773052,
revision: 0x10001e,
},
],
},
],
};
export const wagoDoTestMachine: MachineProperties = {
name: "Wago DO Test",
version: "V1",
slug: "wagodotestmachine",
icon: "lu:ToggleRight",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x000e,
},
device_roles: [
{
role: 0,
role_label: "Wago 750-354 Bus Coupler",
allowed_devices: [
{
vendor_id: 0x21,
product_id: 0x07500354,
revision: 0x2,
},
],
},
],
};
export const wagoAiTestMachine: MachineProperties = {
name: "Wago AI Test",
version: "V1",
@@ -568,6 +655,252 @@ export const wagoAiTestMachine: MachineProperties = {
],
};
export const gluetex: MachineProperties = {
name: "Gluetex",
version: "V1",
slug: "gluetex",
icon: "lu:Disc3",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x000b,
},
device_roles: [
{
role: 0,
role_label: "Bus Coupler",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x44c2c52,
revision: 0x120000,
},
],
},
{
role: 1,
role_label: "2x Digital Output",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x7d23052,
revision: 0x110000,
},
{
vendor_id: 2,
product_id: 0x7d23052,
revision: 0x120000,
},
{
vendor_id: 2,
product_id: 0x7d83052,
revision: 0x110000,
},
{
vendor_id: 2,
product_id: 0x7d83052,
revision: 0x120000,
},
],
},
{
role: 2,
role_label: "1x Stepper Spool EL7041-0052",
allowed_devices: [
{
vendor_id: 2,
product_id: 461451346,
revision: 0x100034,
},
],
},
{
role: 3,
role_label: "1x Stepper Traverse",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x1a0000,
},
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x190000,
},
],
},
{
role: 4,
role_label: "1x Stepper Puller",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x10001e,
},
],
},
{
role: 5,
role_label: "Temperature Sensors 1-4",
allowed_devices: [
{
vendor_id: 2,
product_id: 0xc843052,
revision: 0x160000,
},
{
vendor_id: 2,
product_id: 0xc843052,
revision: 0x150000,
},
{
vendor_id: 2,
product_id: 0xc843052,
revision: 0x1a0000,
},
],
},
{
role: 6,
role_label: "Temperature Sensors 5-6",
allowed_devices: [
{
vendor_id: 2,
product_id: 0xc843052,
revision: 0x160000,
},
{
vendor_id: 2,
product_id: 0xc843052,
revision: 0x150000,
},
{
vendor_id: 2,
product_id: 0xc843052,
revision: 0x1a0000,
},
],
},
{
role: 7,
role_label: "Digital Outputs for Heater SSRs",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x7d83052,
revision: 0x110000,
},
{
vendor_id: 2,
product_id: 0x7d83052,
revision: 0x120000,
},
],
},
{
role: 8,
role_label: "Addon Motor 3",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x1a0000,
},
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x190000,
},
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x10001e,
},
],
},
{
role: 9,
role_label: "Slave Puller",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x1a0000,
},
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x190000,
},
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x10001e,
},
],
},
{
role: 10,
role_label: "Addon Motor 4",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x1a0000,
},
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x190000,
},
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x10001e,
},
],
},
{
role: 11,
role_label: "Addon Motor 5",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x1a0000,
},
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x190000,
},
{
vendor_id: 2,
product_id: 0x1b773052,
revision: 0x10001e,
},
],
},
{
role: 12,
role_label: "Extra Digital Outputs",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x7d83052,
revision: 0x110000,
},
{
vendor_id: 2,
product_id: 0x7d83052,
revision: 0x120000,
},
],
},
],
};
export const machineProperties: MachineProperties[] = [
winder2,
extruder3,
@@ -581,7 +914,11 @@ export const machineProperties: MachineProperties[] = [
analogInputTestMachine,
wagoAiTestMachine,
digitalInputTestMachine,
wago8chDioTestMachine,
ip20TestMachine,
wagoDoTestMachine,
gluetex,
TestMotor,
];
export const getMachineProperties = (
@@ -0,0 +1,84 @@
import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { wago8chDioTestMachineRoute } from "@/routes/routes";
import { MachineIdentificationUnique } from "@/machines/types";
import { useMachineMutate } from "@/client/useClient";
import { produce } from "immer";
import { useEffect, useMemo } from "react";
import { wago8chDioTestMachine } from "@/machines/properties";
import { z } from "zod";
import {
StateEvent,
useWago8chDioTestMachineNamespace,
} from "./wago8chDioTestMachineNameSpace";
export function useWago8chDioTestMachine() {
const { serial: serialString } = wago8chDioTestMachineRoute.useParams();
// Memoize machine identification
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: wago8chDioTestMachine.machine_identification,
serial,
};
}, [serialString]);
// Namespace state from backend
const { state } = useWago8chDioTestMachineNamespace(machineIdentification);
// Optimistic state
const stateOptimistic = useStateOptimistic<StateEvent>();
useEffect(() => {
if (state) stateOptimistic.setReal(state);
}, [state, stateOptimistic]);
// Generic mutation sender
const { request: sendMutation } = useMachineMutate(
z.object({
action: z.string(),
value: z.any(),
}),
);
const updateStateOptimistically = (
producer: (current: StateEvent) => void,
serverRequest?: () => void,
) => {
const currentState = stateOptimistic.value;
if (currentState)
stateOptimistic.setOptimistic(produce(currentState, producer));
serverRequest?.();
};
const setOutputAtIndex = (index: number, value: boolean) => {
updateStateOptimistically(
(current) => {
current.digital_output[index] = value;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetOutput", value: { index, value } },
}),
);
};
return {
state: stateOptimistic.value,
setOutputAtIndex: setOutputAtIndex,
};
}
@@ -0,0 +1,66 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Label } from "@/control/Label";
import { useWago8chDioTestMachine } from "./useWago8chDioTestMachine";
import { Badge } from "@/components/ui/badge";
export function Wago8chDioTestMachineControlRoute() {
const { state, setOutputAtIndex } = useWago8chDioTestMachine();
const safeState = state ?? {
digital_input: [false, false, false, false, false, false, false, false],
digital_output: [false, false, false, false, false, false, false, false],
};
return (
<Page>
<ControlGrid columns={2}>
{/* Digital Inputs */}
<ControlCard title="Digital Inputs">
<div className="grid grid-cols-2 gap-4">
{safeState.digital_input.map((input, index) => (
<Label key={index} label={`Input ${index + 1}`}>
<Badge variant={input ? "outline" : "destructive"}>
{input ? "ON" : "OFF"}
</Badge>
</Label>
))}
</div>
</ControlCard>
{/* Digital Outputs */}
<ControlCard title="Digital Outputs">
<div className="grid grid-cols-2 gap-4">
{safeState.digital_output.map((output, index) => (
<Label key={index} label={`Output ${index + 1}`}>
<SelectionGroup<"On" | "Off">
value={output ? "On" : "Off"}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Off: {
children: "Off",
icon: "lu:CirclePause",
isActiveClassName: "bg-red-600",
className: "h-full",
},
On: {
children: "On",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
className: "h-full",
},
}}
onChange={(value) => setOutputAtIndex(index, value === "On")}
/>
</Label>
))}
</div>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,79 @@
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";
// ========== Event Schema ==========
export const stateEventDataSchema = z.object({
digital_input: z.array(z.boolean()).length(8),
digital_output: z.array(z.boolean()).length(8),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
// ========== Store ==========
export type Wago8chDioTestMachineNamespaceStore = {
state: StateEvent | null;
};
export const createWago8chDioTestMachineNamespaceStore =
(): StoreApi<Wago8chDioTestMachineNamespaceStore> =>
create<Wago8chDioTestMachineNamespaceStore>(() => ({
state: null,
}));
// ========== Message Handler ==========
export function wago8chDioTestMachineMessageHndler(
store: StoreApi<Wago8chDioTestMachineNamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<Wago8chDioTestMachineNamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const updateStore = (
updater: (
state: Wago8chDioTestMachineNamespaceStore,
) => Wago8chDioTestMachineNamespaceStore,
) => throttledUpdater.updateWith(updater);
try {
if (event.name === "StateEvent") {
const parsed = stateEventSchema.parse(event);
updateStore(() => ({ state: parsed.data }));
} else {
handleUnhandledEventError(event.name);
}
} catch (error) {
console.error(`Error processing ${event.name}:`, error);
throw error;
}
};
}
// ========== Namespace Hook ==========
const useWago8chDioTestMachineImplementation =
createNamespaceHookImplementation<Wago8chDioTestMachineNamespaceStore>({
createStore: createWago8chDioTestMachineNamespaceStore,
createEventHandler: wago8chDioTestMachineMessageHndler,
});
export function useWago8chDioTestMachineNamespace(
machine_identification_unique: MachineIdentificationUnique,
): Wago8chDioTestMachineNamespaceStore {
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};
return useWago8chDioTestMachineImplementation(namespaceId);
}
@@ -0,0 +1,20 @@
import { Topbar } from "@/components/Topbar";
import { wago8chDioTestMachineRoute } from "@/routes/routes";
import React from "react";
export function Wago8chDioTestMachinePage() {
const { serial } = wago8chDioTestMachineRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/wago8chdiotestmachine/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:CirclePlay",
},
]}
/>
);
}
@@ -75,7 +75,7 @@ function wagoPower1MessageHandler(
};
try {
if (eventName === "StateEvent") {
if (eventName === "State") {
const stateEvent = stateEventSchema.parse(event);
updateStore((state) => ({
@@ -86,7 +86,7 @@ function wagoPower1MessageHandler(
? stateEvent
: state.defaultState,
}));
} else if (eventName === "LiveValuesEvent") {
} else if (eventName === "LiveValues") {
console.log(event);
const liveValues = liveValuesEventSchema.parse(event);
const { voltage, current } = liveValues.data;
@@ -0,0 +1,64 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Label } from "@/control/Label";
import { useWagoDoTestMachine } from "./useWagoDoTestMachine";
export function WagoDoTestMachineControlPage() {
const { state, setLed, setAllLeds } = useWagoDoTestMachine();
const safeState = state ?? {
led_on: Array(8).fill(false),
};
return (
<Page>
<ControlGrid columns={2}>
{/* Digital Output Controls */}
<ControlCard title="Digital Outputs (Do1-Do8)">
<div className="grid grid-cols-2 gap-6">
{safeState.led_on.map((output, index) => (
<Label key={index} label={`Do${index + 1}`}>
<SelectionGroup<"On" | "Off">
value={output ? "On" : "Off"}
orientation="vertical"
className="flex flex-col gap-3"
options={{
Off: {
children: "Off",
icon: "lu:CirclePause",
isActiveClassName: "bg-red-600",
className: "flex-1",
},
On: {
children: "On",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
className: "flex-1",
},
}}
onChange={(value) => setLed(index, value === "On")}
/>
</Label>
))}
</div>
</ControlCard>
{/* Master Output Control */}
<ControlCard title="Master Output Control">
<SelectionGroup<"On" | "Off">
value={safeState.led_on.every(Boolean) ? "On" : "Off"}
orientation="horizontal"
options={{
Off: { children: "Turn All Off" },
On: { children: "Turn All On" },
}}
onChange={(value) => setAllLeds(value === "On")}
/>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,78 @@
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";
// ========== Event Schema ==========
export const stateEventDataSchema = z.object({
led_on: z.array(z.boolean()).length(8),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
// ========== Store ==========
export type WagoDoTestMachineNamespaceStore = {
state: StateEvent | null;
};
export const createWagoDoTestMachineNamespaceStore =
(): StoreApi<WagoDoTestMachineNamespaceStore> =>
create<WagoDoTestMachineNamespaceStore>(() => ({
state: null,
}));
// ========== Message Handler ==========
export function wagoDoTestMachineMessageHandler(
store: StoreApi<WagoDoTestMachineNamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<WagoDoTestMachineNamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const updateStore = (
updater: (
state: WagoDoTestMachineNamespaceStore,
) => WagoDoTestMachineNamespaceStore,
) => throttledUpdater.updateWith(updater);
try {
if (event.name === "StateEvent") {
const parsed = stateEventSchema.parse(event);
updateStore(() => ({ state: parsed.data }));
} else {
handleUnhandledEventError(event.name);
}
} catch (error) {
console.error(`Error processing ${event.name}:`, error);
throw error;
}
};
}
// ========== Namespace Hook ==========
const useWagoDoTestMachineNamespaceImplementation =
createNamespaceHookImplementation<WagoDoTestMachineNamespaceStore>({
createStore: createWagoDoTestMachineNamespaceStore,
createEventHandler: wagoDoTestMachineMessageHandler,
});
export function useWagoDoTestMachineNamespace(
machine_identification_unique: MachineIdentificationUnique,
): WagoDoTestMachineNamespaceStore {
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};
return useWagoDoTestMachineNamespaceImplementation(namespaceId);
}
@@ -0,0 +1,20 @@
import { Topbar } from "@/components/Topbar";
import { wagoDoTestMachineSerialRoute } from "@/routes/routes";
import React from "react";
export function WagoDoTestMachinePage() {
const { serial } = wagoDoTestMachineSerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/wagodotestmachine/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:CirclePlay",
},
]}
/>
);
}
@@ -0,0 +1,99 @@
import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { wagoDoTestMachineSerialRoute } from "@/routes/routes";
import { MachineIdentificationUnique } from "@/machines/types";
import {
useWagoDoTestMachineNamespace,
StateEvent,
} from "./WagoDoTestMachineNamespace";
import { useMachineMutate } from "@/client/useClient";
import { produce } from "immer";
import { useEffect, useMemo } from "react";
import { wagoDoTestMachine } from "@/machines/properties";
import { z } from "zod";
export function useWagoDoTestMachine() {
const { serial: serialString } = wagoDoTestMachineSerialRoute.useParams();
// Memoize machine identification
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: wagoDoTestMachine.machine_identification,
serial,
};
}, [serialString]);
// Namespace state from backend
const { state } = useWagoDoTestMachineNamespace(machineIdentification);
// Optimistic state
const stateOptimistic = useStateOptimistic<StateEvent>();
useEffect(() => {
if (state) stateOptimistic.setReal(state);
}, [state, stateOptimistic]);
// Generic mutation sender
const { request: sendMutation } = useMachineMutate(
z.object({
action: z.string(),
value: z.any(),
}),
);
const updateStateOptimistically = (
producer: (current: StateEvent) => void,
serverRequest?: () => void,
) => {
const currentState = stateOptimistic.value;
if (currentState)
stateOptimistic.setOptimistic(produce(currentState, producer));
serverRequest?.();
};
const setLed = (index: number, on: boolean) => {
updateStateOptimistically(
(current) => {
current.led_on[index] = on;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetLed", value: { index, on } },
}),
);
};
const setAllLeds = (on: boolean) => {
updateStateOptimistically(
(current) => {
current.led_on = Array(8).fill(on);
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetAllLeds", value: { on } },
}),
);
};
return {
state: stateOptimistic.value,
setLed,
setAllLeds,
};
}
@@ -0,0 +1,295 @@
import React, { useEffect, useState } from "react";
import { useGluetex } from "../hooks/useGluetex";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { TouchButton } from "@/components/touch/TouchButton";
import { useRouter } from "@tanstack/react-router";
import { gluetexRoute } from "@/routes/routes";
/**
* Error dialog component that displays critical system errors for the Gluetex winder
* Shows modal dialogs for tension arm monitoring, sleep timer, and other safety systems
* User must explicitly dismiss each error before continuing
*/
export function GluetexErrorBanner() {
const { state, setOperationMode } = useGluetex();
const router = useRouter();
const { serial } = gluetexRoute.useParams();
// Check for various error conditions - check each tension arm separately
const winderTensionArmTriggered =
state?.winder_tension_arm_monitor_state?.triggered;
const addonTensionArmTriggered =
state?.addon_tension_arm_monitor_state?.triggered;
const slaveTensionArmTriggered =
state?.slave_tension_arm_monitor_state?.triggered;
const anyTensionArmTriggered =
winderTensionArmTriggered ||
addonTensionArmTriggered ||
slaveTensionArmTriggered;
// Check voltage monitors
const optris1MonitorTriggered = state?.optris_1_monitor_state?.triggered;
const optris2MonitorTriggered = state?.optris_2_monitor_state?.triggered;
const anyVoltageMonitorTriggered =
optris1MonitorTriggered || optris2MonitorTriggered;
const sleepTimerTriggered = state?.sleep_timer_state?.triggered;
// Track if we should show the dialog (set to true when error triggers)
const [showTensionArmDialog, setShowTensionArmDialog] = useState(false);
const [showVoltageMonitorDialog, setShowVoltageMonitorDialog] =
useState(false);
const [showSleepTimerDialog, setShowSleepTimerDialog] = useState(false);
// Track previous trigger state to detect new errors
const [prevAnyTensionArmTriggered, setPrevAnyTensionArmTriggered] =
useState(false);
const [prevAnyVoltageMonitorTriggered, setPrevAnyVoltageMonitorTriggered] =
useState(false);
const [prevSleepTimerTriggered, setPrevSleepTimerTriggered] = useState(false);
// Show dialog when error is triggered (transition from false to true)
// Also immediately switch to Setup mode
useEffect(() => {
if (anyTensionArmTriggered && !prevAnyTensionArmTriggered) {
setShowTensionArmDialog(true);
// Immediately switch to Setup mode when error is detected
setOperationMode("Setup");
}
setPrevAnyTensionArmTriggered(anyTensionArmTriggered ?? false);
}, [anyTensionArmTriggered, prevAnyTensionArmTriggered, setOperationMode]);
useEffect(() => {
if (anyVoltageMonitorTriggered && !prevAnyVoltageMonitorTriggered) {
setShowVoltageMonitorDialog(true);
// Immediately switch to Setup mode when error is detected
setOperationMode("Setup");
}
setPrevAnyVoltageMonitorTriggered(anyVoltageMonitorTriggered ?? false);
}, [
anyVoltageMonitorTriggered,
prevAnyVoltageMonitorTriggered,
setOperationMode,
]);
useEffect(() => {
if (sleepTimerTriggered && !prevSleepTimerTriggered) {
setShowSleepTimerDialog(true);
// Immediately switch to Setup mode when timer expires
setOperationMode("Setup");
}
setPrevSleepTimerTriggered(sleepTimerTriggered ?? false);
}, [sleepTimerTriggered, prevSleepTimerTriggered, setOperationMode]);
// Handler to dismiss tension arm dialog (mode already switched to Setup)
const dismissTensionArmDialog = () => {
setShowTensionArmDialog(false);
router.navigate({
to: `/_sidebar/machines/gluetex/${serial}/overview`,
});
};
// Handler to dismiss voltage monitor dialog (mode already switched to Setup)
const dismissVoltageMonitorDialog = () => {
setShowVoltageMonitorDialog(false);
router.navigate({
to: `/_sidebar/machines/gluetex/${serial}/addons`,
});
};
// Handler to dismiss sleep timer dialog (mode already switched to Setup)
const dismissSleepTimerDialog = () => {
setShowSleepTimerDialog(false);
router.navigate({
to: `/_sidebar/machines/gluetex/${serial}/overview`,
});
};
// Determine which dialog to show (priority: tension arm > voltage > sleep timer)
const displayTensionArmDialog = showTensionArmDialog;
const displayVoltageMonitorDialog =
!displayTensionArmDialog && showVoltageMonitorDialog;
const displaySleepTimerDialog =
!displayTensionArmDialog &&
!displayVoltageMonitorDialog &&
showSleepTimerDialog;
return (
<>
{/* Tension Arm Error Dialog */}
<Dialog open={displayTensionArmDialog} onOpenChange={() => {}}>
<DialogContent
className="max-w-lg border-red-500"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex items-center gap-3">
<span className="text-3xl">⚠️</span>
<DialogTitle className="text-xl text-red-600 dark:text-red-400">
Tension Arm Safety Limit Exceeded
</DialogTitle>
</div>
</DialogHeader>
<DialogDescription className="space-y-3 text-base">
<p className="text-red-600/90 dark:text-red-400/90">
One or more tension arms have exceeded their configured safety
limits. The machine has been automatically stopped to prevent
damage.
</p>
{/* Show which specific tension arm(s) triggered */}
<div className="rounded-md bg-red-500/20 p-3">
<p className="mb-2 font-semibold text-red-600 dark:text-red-400">
Triggered Tension Arms:
</p>
<ul className="list-inside list-disc space-y-1 text-red-600/90 dark:text-red-400/90">
{winderTensionArmTriggered && <li>Winder Tension Arm</li>}
{addonTensionArmTriggered && <li>Addon Tension Arm</li>}
{slaveTensionArmTriggered && <li>Slave Tension Arm</li>}
</ul>
</div>
<p className="text-red-600/90 dark:text-red-400/90">
Check the tension arm positions and ensure they are within the
configured limits before resuming operation.
</p>
<p className="text-sm text-red-600/80 dark:text-red-400/80">
Configure limits in Settings → Winder/Addon/Slave Tension Arm
Monitor
</p>
</DialogDescription>
<div className="mt-4">
<TouchButton
variant="destructive"
className="w-full"
onClick={dismissTensionArmDialog}
>
Acknowledge & Return to Setup Mode
</TouchButton>
</div>
</DialogContent>
</Dialog>
{/* Voltage Monitor Error Dialog */}
<Dialog open={displayVoltageMonitorDialog} onOpenChange={() => {}}>
<DialogContent
className="max-w-lg border-red-500"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex items-center gap-3">
<span className="text-3xl">⚠️</span>
<DialogTitle className="text-xl text-red-600 dark:text-red-400">
Voltage Monitor Safety Limit Exceeded
</DialogTitle>
</div>
</DialogHeader>
<DialogDescription className="space-y-4">
<div className="text-base">
<p className="mb-3">
One or more Optris voltage sensors have detected readings
outside the configured safe operating range. The machine has
been automatically stopped to prevent potential damage.
</p>
<div className="rounded-md bg-red-50 p-4 dark:bg-red-950">
<p className="font-semibold text-red-700 dark:text-red-400">
Triggered monitors:
</p>
<ul className="mt-2 list-inside list-disc space-y-1 text-red-700 dark:text-red-400">
{optris1MonitorTriggered && (
<li>
Optris 1:{" "}
{state?.quality_control_state?.optris1.current_voltage?.toFixed(
2,
) ?? "N/A"}
V (Limits:{" "}
{state?.optris_1_monitor_state?.min_voltage?.toFixed(2)}V
- {state?.optris_1_monitor_state?.max_voltage?.toFixed(2)}
V)
</li>
)}
{optris2MonitorTriggered && (
<li>
Optris 2:{" "}
{state?.quality_control_state?.optris2.current_voltage?.toFixed(
2,
) ?? "N/A"}
V (Limits:{" "}
{state?.optris_2_monitor_state?.min_voltage?.toFixed(2)}V
- {state?.optris_2_monitor_state?.max_voltage?.toFixed(2)}
V)
</li>
)}
</ul>
</div>
<p className="mt-3">
Please check the Optris sensors and verify the readings are
correct before resuming operation.
</p>
</div>
<div className="flex flex-col gap-3">
<TouchButton
onClick={dismissVoltageMonitorDialog}
variant="default"
className="w-full"
>
Switch to Setup Mode & Check Sensors
</TouchButton>
</div>
</DialogDescription>
</DialogContent>
</Dialog>
{/* Sleep Timer Dialog */}
<Dialog open={displaySleepTimerDialog} onOpenChange={() => {}}>
<DialogContent
className="max-w-lg border-amber-500"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex items-center gap-3">
<span className="text-3xl">💤</span>
<DialogTitle className="text-xl text-amber-600 dark:text-amber-400">
Sleep Timer Activated
</DialogTitle>
</div>
</DialogHeader>
<DialogDescription className="space-y-3 text-base">
<p className="text-amber-600/90 dark:text-amber-400/90">
The machine has automatically entered standby mode due to
inactivity. This helps save energy and prevent unnecessary wear.
</p>
<p className="text-amber-600/90 dark:text-amber-400/90">
Switch to a run mode or perform any action to reactivate the
machine.
</p>
<p className="text-sm text-amber-600/80 dark:text-amber-400/80">
Configure timeout in Settings → Sleep Timer
</p>
</DialogDescription>
<div className="mt-4">
<TouchButton className="w-full" onClick={dismissSleepTimerDialog}>
Acknowledge & Return to Setup Mode
</TouchButton>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,65 @@
import { cn } from "@/utils/tailwind";
import React from "react";
type Props = {
min: number;
max: number;
minLimit: number;
maxLimit: number;
current: number;
className?: string;
};
export function TemperatureBar({
min,
max,
minLimit,
maxLimit,
current,
className,
}: Props) {
// Calculate percentages for positioning
const range = max - min;
const minLimitPercent = ((minLimit - min) / range) * 100;
const maxLimitPercent = ((maxLimit - min) / range) * 100;
const currentPercent = ((current - min) / range) * 100;
// Determine if current value is within limits
const isInRange = current >= minLimit && current <= maxLimit;
return (
<div className={cn("w-full space-y-1 pt-6", className)}>
<div className="relative h-4 w-full">
{/* Base bar with overflow hidden to clip inner bar */}
<div className="absolute h-full w-full overflow-hidden rounded-full bg-gray-200">
{/* Valid range (green section) */}
<div
className="absolute h-full bg-green-600"
style={{
left: `${minLimitPercent}%`,
width: `${maxLimitPercent - minLimitPercent}%`,
}}
/>
</div>
{/* Current temperature indicator - outside the overflow container so it's not clipped */}
<div
className={cn(
"absolute -top-5 z-10 h-14 w-1.5 rounded-full",
isInRange ? "bg-green-600" : "bg-red-600",
)}
style={{
left: `${currentPercent}%`,
transform: "translateX(-50%)",
}}
/>
</div>
{/* Labels */}
<div className="flex justify-between text-xs text-gray-400">
<span>MIN</span>
<span>MAX</span>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
/**
* Configuration helper for Gluetex machine settings
*/
const GLUETEX_XL_MODE_KEY = "gluetex_xl_mode";
export const GLUETEX_TRAVERSE_MAX_STANDARD = 180; // mm
export const GLUETEX_TRAVERSE_MAX_XL = 385; // mm
/**
* Get the XL mode setting from localStorage
* @returns true if XL mode is enabled, false otherwise
*/
export function getGluetexXLMode(): boolean {
const stored = localStorage.getItem(GLUETEX_XL_MODE_KEY);
return stored === "true";
}
/**
* Set the XL mode setting in localStorage
* @param enabled - true to enable XL mode, false to disable
*/
export function setGluetexXLMode(enabled: boolean): void {
localStorage.setItem(GLUETEX_XL_MODE_KEY, enabled.toString());
}
/**
* Get the maximum traverse limit based on XL mode
* @returns the maximum traverse limit in mm
*/
export function getGluetexTraverseMax(): number {
return getGluetexXLMode()
? GLUETEX_TRAVERSE_MAX_XL
: GLUETEX_TRAVERSE_MAX_STANDARD;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,24 @@
/**
* @file index.ts
* @description Public exports for Gluetex machine
*/
// Pages
export { GluetexPage } from "./pages/GluetexPage";
export { GluetexOverviewPage } from "./pages/GluetexOverviewPage";
export { GluetexControlPage } from "./pages/GluetexControlPage";
export { GluetexAddonsPage } from "./pages/GluetexAddonsPage";
export { GluetexHeatersPage } from "./pages/GluetexHeatersPage";
export { GluetexGraphsPage } from "./pages/GluetexGraphs";
export { GluetexSettingPage } from "./pages/GluetexSettings";
export { GluetexManualPage } from "./pages/GluetexManual";
export { GluetexPresetsPage } from "./pages/GluetexPresetsPage";
// Hooks
export { useGluetex } from "./hooks/useGluetex";
// State
export * from "./state/gluetexNamespace";
// Config
export * from "./config/gluetexConfig";
@@ -0,0 +1,359 @@
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 { TemperatureBar } from "../components/TemperatureBar";
import { SelectionGroup } from "@/control/SelectionGroup";
import { EditValue } from "@/control/EditValue";
import { Label } from "@/control/Label";
import { TouchButton } from "@/components/touch/TouchButton";
import { StatusBadge } from "@/control/StatusBadge";
import { useGluetex } from "../hooks/useGluetex";
import {
StepperMode,
HeatingMode,
getGearRatioMultiplier,
} from "../state/gluetexNamespace";
import { roundToDecimals, roundDegreesToDecimals } from "@/lib/decimal";
import { TensionArm } from "../../TensionArm";
import { RatioInput } from "@/components/ratio/RatioInput";
import { GluetexErrorBanner } from "../components/GluetexErrorBanner";
export function GluetexAddonsPage() {
const {
state,
defaultState,
isLoading,
isDisabled,
setStepper3Mode,
setStepper4Mode,
setStepper5Mode,
setHeatingMode,
optris1Voltage,
optris2Voltage,
slavePullerSpeed,
slaveTensionArmAngle,
addonTensionArmAngle,
zeroSlaveTensionArm,
zeroAddonTensionArm,
setStepper3Master,
setStepper3Slave,
setStepper4Master,
setStepper4Slave,
setStepper5Master,
setStepper5Slave,
setStepper3Konturlaenge,
setStepper3Pause,
homeAddonMotor3,
} = useGluetex();
// Calculate max speed based on gear ratio (same as main puller)
const gearRatioMultiplier = getGearRatioMultiplier(
state?.puller_state?.gear_ratio,
);
return (
<Page>
<GluetexErrorBanner />
<ControlGrid>
<ControlCard className="bg-red" title="Motors">
<Label label="Stepper 3">
<SelectionGroup<StepperMode>
value={state?.stepper_state?.stepper3_mode}
disabled={isDisabled}
loading={isLoading}
onChange={setStepper3Mode}
orientation="horizontal"
className="grid grid-cols-2 gap-2"
options={{
Standby: {
children: "Standby",
icon: "lu:Power",
isActiveClassName: "bg-green-600",
},
Run: {
children: "Run",
icon: "lu:Play",
isActiveClassName: "bg-green-600",
},
}}
/>
</Label>
<Label label="Stepper 4">
<SelectionGroup<StepperMode>
value={state?.stepper_state?.stepper4_mode}
disabled={isDisabled}
loading={isLoading}
onChange={setStepper4Mode}
orientation="horizontal"
className="grid grid-cols-2 gap-2"
options={{
Standby: {
children: "Standby",
icon: "lu:Power",
isActiveClassName: "bg-green-600",
},
Run: {
children: "Run",
icon: "lu:Play",
isActiveClassName: "bg-green-600",
},
}}
/>
</Label>
<Label label="Stepper 5">
<SelectionGroup<StepperMode>
value={state?.stepper_state?.stepper5_mode}
disabled={isDisabled}
loading={isLoading}
onChange={setStepper5Mode}
orientation="horizontal"
className="grid grid-cols-2 gap-2"
options={{
Standby: {
children: "Standby",
icon: "lu:Power",
isActiveClassName: "bg-green-600",
},
Run: {
children: "Run",
icon: "lu:Play",
isActiveClassName: "bg-green-600",
},
}}
/>
</Label>
</ControlCard>
<ControlCard title="Slave Tension Arm">
<TensionArm degrees={slaveTensionArmAngle.current?.value} />
<TimeSeriesValueNumeric
label="Slave Tension Arm"
unit="deg"
timeseries={slaveTensionArmAngle}
renderValue={(value) => roundDegreesToDecimals(value, 0)}
/>
<TimeSeriesValueNumeric
label="Slave Puller Speed"
unit="m/min"
timeseries={slavePullerSpeed}
renderValue={(value) => roundToDecimals(value, 1)}
/>
<TouchButton
variant="outline"
icon="lu:House"
onClick={zeroSlaveTensionArm}
disabled={isDisabled}
isLoading={isLoading}
>
Set Zero Point
</TouchButton>
{!state?.slave_puller_state?.tension_arm?.zeroed && (
<StatusBadge variant="error">Not Zeroed</StatusBadge>
)}
</ControlCard>
<ControlCard className="bg-red" height={2} title="Quality Control">
<div className="space-y-6">
{/* Optris 1 Voltage */}
<div className="space-y-3">
<TimeSeriesValueNumeric
label="Optris 1 Voltage"
unit="V"
timeseries={optris1Voltage}
renderValue={(value) => roundToDecimals(value, 2)}
/>
{state?.optris_1_monitor_state?.triggered && (
<StatusBadge variant="error">
Voltage Out of Range - Machine Stopped
</StatusBadge>
)}
<TemperatureBar
min={0}
max={10}
minLimit={
state?.optris_1_monitor_state?.min_voltage ??
state?.quality_control_state?.optris1.min_voltage ??
0
}
maxLimit={
state?.optris_1_monitor_state?.max_voltage ??
state?.quality_control_state?.optris1.max_voltage ??
10
}
current={optris1Voltage.current?.value ?? 0}
/>
</div>
{/* Optris 2 Voltage */}
<div className="space-y-3">
<TimeSeriesValueNumeric
label="Optris 2 Voltage"
unit="V"
timeseries={optris2Voltage}
renderValue={(value) => roundToDecimals(value, 2)}
/>
{state?.optris_2_monitor_state?.triggered && (
<StatusBadge variant="error">
Voltage Out of Range - Machine Stopped
</StatusBadge>
)}
<TemperatureBar
min={0}
max={10}
minLimit={
state?.optris_2_monitor_state?.min_voltage ??
state?.quality_control_state?.optris2.min_voltage ??
0
}
maxLimit={
state?.optris_2_monitor_state?.max_voltage ??
state?.quality_control_state?.optris2.max_voltage ??
10
}
current={optris2Voltage.current?.value ?? 0}
/>
</div>
{/* Heating Mode */}
<div className="mt-8 space-y-3">
<h3 className="text-lg font-semibold">Heating Mode</h3>
<SelectionGroup<HeatingMode>
value={state?.heating_state?.heating_mode}
disabled={isDisabled}
loading={isLoading}
onChange={setHeatingMode}
orientation="horizontal"
className="grid grid-cols-2 gap-2"
options={{
Standby: {
children: "Standby",
icon: "lu:Power",
isActiveClassName: "bg-green-600",
},
Heating: {
children: "Heating",
icon: "lu:Flame",
isActiveClassName: "bg-green-600",
},
}}
/>
</div>
</div>
</ControlCard>
<ControlCard className="bg-red" title="Motor Ratios">
<Label label="Stepper 3 Ratio">
<RatioInput
master={state?.motor_ratios_state?.stepper3_master}
slave={state?.motor_ratios_state?.stepper3_slave}
title="Stepper 3 Motor Ratio"
onRatioChange={(master, slave) => {
setStepper3Master(master);
setStepper3Slave(slave);
}}
/>
</Label>
<Label label="Stepper 4 Ratio">
<RatioInput
master={state?.motor_ratios_state?.stepper4_master}
slave={state?.motor_ratios_state?.stepper4_slave}
title="Stepper 4 Motor Ratio"
onRatioChange={(master, slave) => {
setStepper4Master(master);
setStepper4Slave(slave);
}}
/>
</Label>
<Label label="Stepper 5 Ratio">
<RatioInput
master={state?.motor_ratios_state?.stepper5_master}
slave={state?.motor_ratios_state?.stepper5_slave}
title="Stepper 5 Motor Ratio"
onRatioChange={(master, slave) => {
setStepper5Master(master);
setStepper5Slave(slave);
}}
/>
</Label>
<div className="flex flex-col gap-4">
<Label label="Stepper 3: Contour Length">
<EditValue
value={state?.addon_motor_3_state?.konturlaenge_mm}
title="Contour Length"
unit="mm"
step={1}
min={0}
max={10000}
defaultValue={0}
renderValue={(value) => roundToDecimals(value, 1)}
onChange={(value) => setStepper3Konturlaenge(value)}
/>
</Label>
<Label label="Pause">
<EditValue
value={state?.addon_motor_3_state?.pause_mm}
title="Pause"
unit="mm"
step={1}
min={0}
max={10000}
defaultValue={0}
renderValue={(value) => roundToDecimals(value, 1)}
onChange={(value) => setStepper3Pause(value)}
/>
</Label>
</div>
{state?.addon_motor_3_state?.konturlaenge_mm !== undefined &&
state?.addon_motor_3_state?.konturlaenge_mm > 0 && (
<Label label="Stepper 3 Pattern State">
<StatusBadge
variant={
state?.addon_motor_3_state?.pattern_state === "Running"
? "success"
: "error"
}
>
{state?.addon_motor_3_state?.pattern_state || "Idle"}
</StatusBadge>
</Label>
)}
<Label label="Stepper 3 Homing">
<TouchButton
variant="outline"
icon="lu:House"
onClick={homeAddonMotor3}
disabled={isDisabled}
isLoading={isLoading}
>
Home Motor
</TouchButton>
</Label>
</ControlCard>
<ControlCard title="Addon Tension Arm">
<TensionArm degrees={addonTensionArmAngle.current?.value} />
<TimeSeriesValueNumeric
label="Angle"
unit="deg"
timeseries={addonTensionArmAngle}
renderValue={(value) => roundDegreesToDecimals(value, 0)}
/>
<TouchButton
variant="outline"
icon="lu:House"
onClick={zeroAddonTensionArm}
disabled={isDisabled}
isLoading={isLoading}
>
Set Zero Point
</TouchButton>
{!state?.addon_tension_arm_state?.zeroed && (
<StatusBadge variant="error">Not Zeroed</StatusBadge>
)}
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,393 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React, { useState } from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
import { TraverseBar } from "../../TraverseBar";
import {
SelectionGroup,
SelectionGroupBoolean,
} from "@/control/SelectionGroup";
import { EditValue } from "@/control/EditValue";
import { Label } from "@/control/Label";
import { TouchButton } from "@/components/touch/TouchButton";
import { StatusBadge } from "@/control/StatusBadge";
import { useGluetex } from "../hooks/useGluetex";
import {
Mode,
SpoolAutomaticActionMode,
getGearRatioMultiplier,
} from "../state/gluetexNamespace";
import { TensionArm } from "../../TensionArm";
import { roundDegreesToDecimals, roundToDecimals } from "@/lib/decimal";
import { GluetexErrorBanner } from "../components/GluetexErrorBanner";
import { Spool } from "../../Spool";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getGluetexTraverseMax } from "../config/gluetexConfig";
export function GluetexControlPage() {
const [showResetConfirmDialog, setShowResetConfirmDialog] = useState(false);
const traverseMax = getGluetexTraverseMax();
// use optimistic state
const {
state,
defaultState,
enableTraverseLaserpointer,
tensionArmAngle,
zeroTensionArmAngle,
spoolRpm,
setMode,
pullerSpeed,
spoolProgress,
setPullerTargetSpeed,
traversePosition,
resetSpoolProgress,
setTraverseLimitInner,
setTraverseLimitOuter,
gotoTraverseLimitInner,
gotoTraverseLimitOuter,
gotoTraverseHome,
setSpoolAutomaticRequiredMeters,
setSpoolAutomaticAction,
isLoading,
isDisabled,
} = useGluetex();
// Calculate max speed based on gear ratio
const gearRatioMultiplier = getGearRatioMultiplier(
state?.puller_state?.gear_ratio,
);
const maxMotorSpeed = 75; // Maximum motor speed in m/min
const maxTargetSpeed = maxMotorSpeed / gearRatioMultiplier;
const handleResetProgress = () => {
// Check if the machine is currently in Wind mode
if (state?.mode_state?.mode === "Wind") {
setShowResetConfirmDialog(true);
} else {
resetSpoolProgress();
}
};
const confirmResetProgress = () => {
resetSpoolProgress();
setShowResetConfirmDialog(false);
};
return (
<Page>
<GluetexErrorBanner />
<ControlGrid>
<ControlCard title="Spool">
<Spool rpm={spoolRpm.current?.value} />
<TimeSeriesValueNumeric
label="Spool Speed"
unit="rpm"
timeseries={spoolRpm}
renderValue={(value) => roundToDecimals(value, 0)}
/>
</ControlCard>
<ControlCard className="bg-red" height={2} title="Traverse">
<TimeSeriesValueNumeric
label="Position"
unit="mm"
timeseries={traversePosition}
renderValue={(value) => roundToDecimals(value, 1)}
/>
{state?.traverse_state && (
<TraverseBar
inside={0}
outside={traverseMax}
min={state?.traverse_state.limit_inner}
max={state?.traverse_state.limit_outer}
current={traversePosition.current?.value ?? 0}
/>
)}
<div className="flex flex-row flex-wrap gap-4">
<Label label="Outer Limit">
<EditValue
value={state?.traverse_state?.limit_outer}
unit="mm"
title="Outer Limit"
defaultValue={defaultState?.traverse_state?.limit_outer}
// Traverse limit validation: Outer limit must be at least 0.9mm greater than inner limit
// We use 1mm buffer to ensure the backend validation (which requires >0.9mm) will pass
// Formula: min_outer = inner_limit + 1mm
min={Math.max(0, (state?.traverse_state?.limit_inner ?? 0) + 1)}
minLabel="IN"
maxLabel="OUT"
max={traverseMax}
renderValue={(value) => roundToDecimals(value, 0)}
onChange={setTraverseLimitOuter}
/>
<TouchButton
variant="outline"
icon="lu:ArrowLeftToLine"
onClick={gotoTraverseLimitOuter}
disabled={isDisabled}
isLoading={isLoading}
>
Go to Outer Limit
</TouchButton>
</Label>
<Label label="Inner Limit">
<EditValue
value={state?.traverse_state?.limit_inner}
unit="mm"
title="Inner Limit"
min={0}
// Traverse limit validation: Inner limit must be at least 0.9mm smaller than outer limit
// We use 1mm buffer to ensure the backend validation (which requires outer > inner + 0.9mm) will pass
// Formula: max_inner = outer_limit - 1mm
max={Math.min(
traverseMax,
(state?.traverse_state?.limit_outer ?? traverseMax) - 1,
)}
defaultValue={defaultState?.traverse_state?.limit_inner}
minLabel="IN"
maxLabel="OUT"
renderValue={(value) => roundToDecimals(value, 0)}
inverted
onChange={setTraverseLimitInner}
/>
<TouchButton
variant="outline"
icon="lu:ArrowRightToLine"
onClick={gotoTraverseLimitInner}
disabled={isDisabled}
isLoading={isLoading}
>
Go to Inner Limit
</TouchButton>
</Label>
</div>
<Label label="Laserpointer">
<SelectionGroupBoolean
value={state?.traverse_state.laserpointer}
disabled={isLoading || isDisabled}
loading={isLoading}
optionFalse={{ children: "Off", icon: "lu:LightbulbOff" }}
optionTrue={{ children: "On", icon: "lu:Lightbulb" }}
onChange={enableTraverseLaserpointer}
/>
</Label>
<Label label="Home">
<TouchButton
variant="outline"
icon="lu:House"
onClick={() => gotoTraverseHome()}
disabled={isDisabled}
isLoading={isLoading}
>
Go to Home
</TouchButton>
{state?.traverse_state?.is_homed !== true ? (
<StatusBadge variant={"error"}>{"Not Homed"}</StatusBadge>
) : null}
</Label>
</ControlCard>
<ControlCard title="Tension Arm">
<TensionArm degrees={tensionArmAngle.current?.value} />
<TimeSeriesValueNumeric
label="Tension Arm"
unit="deg"
timeseries={tensionArmAngle}
renderValue={(value) => roundDegreesToDecimals(value, 0)}
/>
<TouchButton
variant="outline"
icon="lu:House"
onClick={zeroTensionArmAngle}
disabled={isDisabled}
isLoading={isLoading}
>
Set Zero Point
</TouchButton>
{!state?.tension_arm_state?.zeroed && (
<StatusBadge variant="error">Not Zeroed</StatusBadge>
)}
</ControlCard>
<ControlCard className="bg-red" title="Winder Mode">
<SelectionGroup<Mode>
value={state?.mode_state.mode}
disabled={isDisabled}
loading={isLoading}
onChange={setMode}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Standby: {
children: "Standby",
icon: "lu:Power",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Hold: {
children: "Hold",
icon: "lu:CirclePause",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Pull: {
children: "Pull",
icon: "lu:ChevronsLeft",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Wind: {
children: "Wind",
icon: "lu:RefreshCcw",
isActiveClassName: "bg-green-600",
disabled: !state?.mode_state?.can_wind,
className: "h-full",
},
}}
/>
</ControlCard>
<ControlCard className="bg-red" title="Puller">
<TimeSeriesValueNumeric
label="Speed"
unit="m/min"
timeseries={pullerSpeed}
renderValue={(value) => roundToDecimals(value, 1)}
/>
<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={maxTargetSpeed}
step={0.1}
renderValue={(value) => roundToDecimals(value, 1)}
onChange={setPullerTargetSpeed}
/>
</Label>
</ControlCard>
<ControlCard className="bg-red" title="Spool Autostop">
<TimeSeriesValueNumeric
label="Pulled Distance"
renderValue={(value) => roundToDecimals(value, 2)}
unit="m"
timeseries={spoolProgress}
/>
<Label label="Target Length">
<EditValue
value={state?.spool_automatic_action_state.spool_required_meters}
unit="m"
title="Expected Meters"
defaultValue={250}
min={10}
max={10000}
step={10}
renderValue={(value) => roundToDecimals(value, 2)}
onChange={setSpoolAutomaticRequiredMeters}
/>
</Label>
<Label label="Estimated Time Remaining">
<span className="font-mono text-lg">
{(() => {
const minutes =
state?.spool_automatic_action_state
.estimated_minutes_remaining || 0;
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
return `${hours}h ${mins}min`;
}
return `${Math.round(minutes)} min`;
})()}
</span>
</Label>
<TouchButton
variant="outline"
onClick={handleResetProgress}
disabled={isDisabled}
isLoading={isLoading || state?.traverse_state?.is_going_out}
>
Reset Progress
</TouchButton>
<Label label="After Target Length Reached">
<SelectionGroup<SpoolAutomaticActionMode>
value={
state?.spool_automatic_action_state.spool_automatic_action_mode
}
disabled={isDisabled}
loading={isLoading}
onChange={setSpoolAutomaticAction}
orientation="vertical"
options={{
Hold: {
children: "Hold",
icon: "lu:CirclePause",
className: "h-full",
},
Pull: {
children: "Pull",
icon: "lu:ChevronsLeft",
className: "h-full",
},
NoAction: {
children: "No Action",
icon: "lu:RefreshCcw",
className: "h-full",
},
}}
/>
</Label>
</ControlCard>
</ControlGrid>
{/* Reset Progress Confirmation Dialog */}
<Dialog
open={showResetConfirmDialog}
onOpenChange={setShowResetConfirmDialog}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Reset Spool Progress?</DialogTitle>
<DialogDescription>
The machine is currently in Wind mode. Are you sure you want to
reset the spool progress?
</DialogDescription>
</DialogHeader>
<div className="mt-4 flex flex-col gap-2">
<TouchButton
variant="destructive"
onClick={confirmResetProgress}
disabled={isLoading}
isLoading={isLoading}
>
Yes, Reset Progress
</TouchButton>
<TouchButton
variant="outline"
onClick={() => setShowResetConfirmDialog(false)}
>
Cancel
</TouchButton>
</div>
</DialogContent>
</Dialog>
</Page>
);
}
@@ -0,0 +1,636 @@
import { Page } from "@/components/Page";
import {
AutoSyncedBigGraph,
SyncedFloatingControlPanel,
useGraphSync,
type GraphConfig,
type GraphLine,
} from "@/components/graph";
import React from "react";
import { useGluetex } from "../hooks/useGluetex";
import { roundDegreesToDecimals, roundToDecimals } from "@/lib/decimal";
import { TimeSeries } from "@/lib/timeseries";
import { Unit } from "@/control/units";
import { GluetexErrorBanner } from "../components/GluetexErrorBanner";
export function GluetexGraphsPage() {
const {
state,
spoolRpm,
traversePosition,
tensionArmAngle,
slaveTensionArmAngle,
addonTensionArmAngle,
pullerSpeed,
slavePullerSpeed,
spoolProgress,
temperature1,
temperature2,
temperature3,
temperature4,
temperature5,
temperature6,
optris1Voltage,
optris2Voltage,
} = useGluetex();
const syncHook = useGraphSync("gluetex-group");
return (
<Page className="pb-27">
<GluetexErrorBanner />
<div className="flex flex-col gap-4">
<div className="grid gap-4">
<PullerSpeedGraph
syncHook={syncHook}
newData={pullerSpeed}
slavePullerSpeed={slavePullerSpeed}
targetSpeed={state?.puller_state?.target_speed}
unit="m/min"
renderValue={(value) => roundToDecimals(value, 0)}
/>
<TensionArmAngleGraph
syncHook={syncHook}
newData={tensionArmAngle}
slaveTensionArmAngle={slaveTensionArmAngle}
addonTensionArmAngle={addonTensionArmAngle}
unit="deg"
renderValue={(value) => roundDegreesToDecimals(value, 0)}
/>
<SpoolRpmGraph
syncHook={syncHook}
newData={spoolRpm}
unit="rpm"
renderValue={(value) => roundToDecimals(value, 0)}
/>
<TraversePositionGraph
syncHook={syncHook}
newData={traversePosition}
limitInner={state?.traverse_state?.limit_inner}
limitOuter={state?.traverse_state?.limit_outer}
unit="mm"
renderValue={(value) => roundToDecimals(value, 1)}
/>
<SpoolProgressGraph
syncHook={syncHook}
newData={spoolProgress}
unit="m"
renderValue={(value) => roundToDecimals(value, 2)}
/>
<TemperaturesGraph
syncHook={syncHook}
temperature1={temperature1}
temperature2={temperature2}
temperature3={temperature3}
temperature4={temperature4}
temperature5={temperature5}
temperature6={temperature6}
targetTemperature1={
state?.heating_states?.zone_1?.target_temperature
}
targetTemperature2={
state?.heating_states?.zone_2?.target_temperature
}
targetTemperature3={
state?.heating_states?.zone_3?.target_temperature
}
targetTemperature4={
state?.heating_states?.zone_4?.target_temperature
}
targetTemperature5={
state?.heating_states?.zone_5?.target_temperature
}
targetTemperature6={
state?.heating_states?.zone_6?.target_temperature
}
unit="C"
renderValue={(value) => roundToDecimals(value, 1)}
/>
<OptrisVoltageGraph
syncHook={syncHook}
optris1Voltage={optris1Voltage}
optris2Voltage={optris2Voltage}
unit="V"
renderValue={(value) => roundToDecimals(value, 2)}
/>
</div>
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
}
export function SpoolRpmGraph({
syncHook,
newData,
unit,
renderValue,
}: {
syncHook: ReturnType<typeof useGraphSync>;
newData: TimeSeries | null;
unit?: Unit;
renderValue?: (value: number) => string;
}) {
const config: GraphConfig = {
title: "Spool Speed",
icon: "lu:RotateCcw",
colors: {
primary: "#10b981",
grid: "#e2e8f0",
background: "#ffffff",
},
exportFilename: "spool_rpm_data",
};
return (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData,
color: "#10b981",
}}
unit={unit}
renderValue={renderValue}
config={config}
graphId="spool-rpm"
/>
);
}
export function TraversePositionGraph({
syncHook,
newData,
limitInner,
limitOuter,
unit,
renderValue,
}: {
syncHook: ReturnType<typeof useGraphSync>;
newData: TimeSeries | null;
limitInner?: number;
limitOuter?: number;
unit?: Unit;
renderValue?: (value: number) => string;
}) {
const lines: GraphLine[] = [];
if (limitInner !== undefined) {
lines.push({
type: "threshold",
value: limitInner,
color: "#8b5cf6",
dash: [5, 5],
});
}
if (limitOuter !== undefined) {
lines.push({
type: "threshold",
value: limitOuter,
color: "#8b5cf6",
dash: [5, 5],
});
}
const config: GraphConfig = {
title: "Traverse Position",
icon: "lu:Move",
colors: {
primary: "#8b5cf6",
},
exportFilename: "traverse_position_data",
};
return (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData,
color: "#8b5cf6",
lines,
}}
unit={unit}
renderValue={renderValue}
config={config}
graphId="traverse-position"
/>
);
}
export function TensionArmAngleGraph({
syncHook,
newData,
slaveTensionArmAngle,
addonTensionArmAngle,
unit,
renderValue,
}: {
syncHook: ReturnType<typeof useGraphSync>;
newData: TimeSeries | null;
slaveTensionArmAngle: TimeSeries | null;
addonTensionArmAngle: TimeSeries | null;
unit?: Unit;
renderValue?: (value: number) => string;
}) {
const tensionArmData = [
...(newData
? [
{
newData,
title: "Main Tension Arm",
color: "#06b6d4",
},
]
: []),
...(addonTensionArmAngle
? [
{
newData: addonTensionArmAngle,
title: "Addon Tension Arm",
color: "#f59e0b",
},
]
: []),
...(slaveTensionArmAngle
? [
{
newData: slaveTensionArmAngle,
title: "Slave Tension Arm",
color: "#8b5cf6",
},
]
: []),
];
const config: GraphConfig = {
title: "Tension Arm Angle",
icon: "lu:RotateCw",
colors: {
primary: "#06b6d4",
},
exportFilename: "tension_arm_angle_data",
showLegend: true,
};
return (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={tensionArmData}
unit={unit}
renderValue={renderValue}
config={config}
graphId="tension-arm-angle"
/>
);
}
export function SpoolProgressGraph({
syncHook,
newData,
unit,
renderValue,
}: {
syncHook: ReturnType<typeof useGraphSync>;
newData: TimeSeries | null;
unit?: Unit;
renderValue?: (value: number) => string;
}) {
const config: GraphConfig = {
title: "Spool Progress",
icon: "lu:RotateCw",
colors: {
primary: "#f59e0b",
},
exportFilename: "spool_progress",
};
return (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData,
color: "#f59e0b",
}}
unit={unit}
renderValue={renderValue}
config={config}
graphId="spool-progress"
/>
);
}
export function PullerSpeedGraph({
syncHook,
newData,
slavePullerSpeed,
targetSpeed,
unit,
renderValue,
}: {
syncHook: ReturnType<typeof useGraphSync>;
newData: TimeSeries | null;
slavePullerSpeed: TimeSeries | null;
targetSpeed?: number;
unit?: Unit;
renderValue?: (value: number) => string;
}) {
const pullerSpeedData = [
...(newData
? [
{
newData,
title: "Main Puller Speed",
color: "#06b6d4",
lines:
targetSpeed !== undefined
? [
{
type: "target" as const,
value: targetSpeed,
label: "Target Speed",
color: "#06b6d4",
},
]
: [],
},
]
: []),
...(slavePullerSpeed
? [
{
newData: slavePullerSpeed,
title: "Slave Puller Speed",
color: "#8b5cf6",
},
]
: []),
];
const config: GraphConfig = {
title: "Puller Speed",
icon: "lu:Gauge",
colors: {
primary: "#06b6d4",
},
exportFilename: "puller_speed_data",
showLegend: true,
};
return (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={pullerSpeedData}
unit={unit}
renderValue={renderValue}
config={config}
graphId="puller-speed"
/>
);
}
export function TemperaturesGraph({
syncHook,
temperature1,
temperature2,
temperature3,
temperature4,
temperature5,
temperature6,
targetTemperature1,
targetTemperature2,
targetTemperature3,
targetTemperature4,
targetTemperature5,
targetTemperature6,
unit,
renderValue,
}: {
syncHook: ReturnType<typeof useGraphSync>;
temperature1: TimeSeries | null;
temperature2: TimeSeries | null;
temperature3: TimeSeries | null;
temperature4: TimeSeries | null;
temperature5: TimeSeries | null;
temperature6: TimeSeries | null;
targetTemperature1?: number;
targetTemperature2?: number;
targetTemperature3?: number;
targetTemperature4?: number;
targetTemperature5?: number;
targetTemperature6?: number;
unit?: Unit;
renderValue?: (value: number) => string;
}) {
const temperatureData = [
...(temperature1
? [
{
newData: temperature1,
title: "Zone 1",
color: "#ef4444",
lines:
targetTemperature1 !== undefined
? [
{
type: "target" as const,
value: targetTemperature1,
color: "#ef4444",
show: true,
},
]
: [],
},
]
: []),
...(temperature2
? [
{
newData: temperature2,
title: "Zone 2",
color: "#f97316",
lines:
targetTemperature2 !== undefined
? [
{
type: "target" as const,
value: targetTemperature2,
color: "#f97316",
show: true,
},
]
: [],
},
]
: []),
...(temperature3
? [
{
newData: temperature3,
title: "Zone 3",
color: "#f59e0b",
lines:
targetTemperature3 !== undefined
? [
{
type: "target" as const,
value: targetTemperature3,
color: "#f59e0b",
show: true,
},
]
: [],
},
]
: []),
...(temperature4
? [
{
newData: temperature4,
title: "Zone 4",
color: "#84cc16",
lines:
targetTemperature4 !== undefined
? [
{
type: "target" as const,
value: targetTemperature4,
color: "#84cc16",
show: true,
},
]
: [],
},
]
: []),
...(temperature5
? [
{
newData: temperature5,
title: "Zone 5",
color: "#22c55e",
lines:
targetTemperature5 !== undefined
? [
{
type: "target" as const,
value: targetTemperature5,
color: "#22c55e",
show: true,
},
]
: [],
},
]
: []),
...(temperature6
? [
{
newData: temperature6,
title: "Zone 6",
color: "#14b8a6",
lines:
targetTemperature6 !== undefined
? [
{
type: "target" as const,
value: targetTemperature6,
color: "#14b8a6",
show: true,
},
]
: [],
},
]
: []),
];
const config: GraphConfig = {
title: "Heater Temperatures",
icon: "lu:Thermometer",
colors: {
primary: "#ef4444",
grid: "#e2e8f0",
axis: "#64748b",
background: "#ffffff",
},
exportFilename: "heater_temperatures_data",
showLegend: true,
};
return (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={temperatureData}
unit={unit}
renderValue={renderValue}
config={config}
graphId="heater-temperatures"
/>
);
}
export function OptrisVoltageGraph({
syncHook,
optris1Voltage,
optris2Voltage,
unit,
renderValue,
}: {
syncHook: ReturnType<typeof useGraphSync>;
optris1Voltage: TimeSeries | null;
optris2Voltage: TimeSeries | null;
unit?: Unit;
renderValue?: (value: number) => string;
}) {
const optrisVoltageData = [
...(optris1Voltage
? [
{
newData: optris1Voltage,
title: "Optris 1",
color: "#3b82f6",
},
]
: []),
...(optris2Voltage
? [
{
newData: optris2Voltage,
title: "Optris 2",
color: "#8b5cf6",
},
]
: []),
];
const config: GraphConfig = {
title: "Optris Voltage",
icon: "lu:Zap",
colors: {
primary: "#3b82f6",
grid: "#e2e8f0",
axis: "#64748b",
background: "#ffffff",
},
exportFilename: "optris_voltage_data",
showLegend: true,
};
return (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={optrisVoltageData}
unit={unit}
renderValue={renderValue}
config={config}
graphId="optris-voltage"
/>
);
}
@@ -0,0 +1,216 @@
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { HeatingZone } from "@/machines/extruder/HeatingZone";
import { ControlCard } from "@/control/ControlCard";
import { SelectionGroup } from "@/control/SelectionGroup";
import { SelectionGroupBoolean } from "@/control/SelectionGroup";
import { useGluetex } from "../hooks/useGluetex";
import { HeatingMode } from "../state/gluetexNamespace";
import { GluetexErrorBanner } from "../components/GluetexErrorBanner";
import { EditValue } from "@/control/EditValue";
import { Label } from "@/control/Label";
export function GluetexHeatersPage() {
const {
state,
isLoading,
isDisabled,
temperature1,
temperature2,
temperature3,
temperature4,
temperature5,
temperature6,
heater1Power,
heater2Power,
heater3Power,
heater4Power,
heater5Power,
heater6Power,
setHeatingMode,
setHeatingZone1Temperature,
setHeatingZone2Temperature,
setHeatingZone3Temperature,
setHeatingZone4Temperature,
setHeatingZone5Temperature,
setHeatingZone6Temperature,
setValveEnabled,
setValveManualOverride,
setValveOnDistanceMm,
setValveOffDistanceMm,
} = useGluetex();
return (
<Page>
<GluetexErrorBanner />
<ControlGrid>
<HeatingZone
title={"Temperature 1"}
heatingState={state?.heating_states.zone_1}
heatingTimeSeries={temperature1}
heatingPower={heater1Power}
onChangeTargetTemp={setHeatingZone1Temperature}
min={0}
max={300}
/>
<HeatingZone
title={"Temperature 2"}
heatingState={state?.heating_states.zone_2}
heatingTimeSeries={temperature2}
heatingPower={heater2Power}
onChangeTargetTemp={setHeatingZone2Temperature}
min={0}
max={300}
/>
<HeatingZone
title={"Temperature 3"}
heatingState={state?.heating_states.zone_3}
heatingTimeSeries={temperature3}
heatingPower={heater3Power}
onChangeTargetTemp={setHeatingZone3Temperature}
min={0}
max={300}
/>
<HeatingZone
title={"Temperature 4"}
heatingState={state?.heating_states.zone_4}
heatingTimeSeries={temperature4}
heatingPower={heater4Power}
onChangeTargetTemp={setHeatingZone4Temperature}
min={0}
max={300}
/>
<HeatingZone
title={"Temperature 5"}
heatingState={state?.heating_states.zone_5}
heatingTimeSeries={temperature5}
heatingPower={heater5Power}
onChangeTargetTemp={setHeatingZone5Temperature}
min={0}
max={300}
/>
<HeatingZone
title={"Temperature 6"}
heatingState={state?.heating_states.zone_6}
heatingTimeSeries={temperature6}
heatingPower={heater6Power}
onChangeTargetTemp={setHeatingZone6Temperature}
min={0}
max={300}
/>
<ControlCard className="bg-red" title="Heating Mode">
<SelectionGroup<HeatingMode>
value={state?.heating_state?.heating_mode}
disabled={isDisabled}
loading={isLoading}
onChange={setHeatingMode}
orientation="horizontal"
className="grid grid-cols-2 gap-2"
options={{
Standby: {
children: "Standby",
icon: "lu:Power",
isActiveClassName: "bg-green-600",
},
Heating: {
children: "Heating",
icon: "lu:Flame",
isActiveClassName: "bg-green-600",
},
}}
/>
</ControlCard>
<ControlCard className="bg-blue-500" title="Valve Control">
<div className="flex flex-col gap-4">
<Label label="Enabled">
<SelectionGroupBoolean
value={state?.valve_state?.enabled ?? false}
disabled={isDisabled}
onChange={setValveEnabled}
optionTrue={{ children: "Enabled", icon: "lu:Play" }}
optionFalse={{ children: "Disabled", icon: "lu:CirclePause" }}
/>
</Label>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Manual Control</span>
<SelectionGroup
value={
state?.valve_state?.manual_override === null
? "auto"
: state?.valve_state?.manual_override
? "on"
: "off"
}
disabled={isDisabled || !state?.valve_state?.enabled}
loading={isLoading}
onChange={(value) => {
if (value === "auto") {
setValveManualOverride(null);
} else {
setValveManualOverride(value === "on");
}
}}
orientation="horizontal"
className="grid grid-cols-3 gap-1"
options={{
auto: {
children: "Auto",
isActiveClassName: "bg-blue-600",
},
off: {
children: "Off",
isActiveClassName: "bg-gray-600",
},
on: {
children: "On",
isActiveClassName: "bg-green-600",
},
}}
/>
</div>
<Label label="ON Distance (mm)">
<EditValue
title="Valve ON Distance"
value={state?.valve_state?.on_distance_mm ?? 0}
min={0}
max={10000}
step={1}
unit="mm"
renderValue={(value) => value.toFixed(0)}
onChange={(value) => {
if (isDisabled || !state?.valve_state?.enabled) {
return;
}
setValveOnDistanceMm(value);
}}
/>
</Label>
<Label label="OFF Distance (mm)">
<EditValue
title="Valve OFF Distance"
value={state?.valve_state?.off_distance_mm ?? 0}
min={0}
max={10000}
step={1}
unit="mm"
renderValue={(value) => value.toFixed(0)}
onChange={(value) => {
if (isDisabled || !state?.valve_state?.enabled) {
return;
}
setValveOffDistanceMm(value);
}}
/>
</Label>
</div>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,12 @@
import { Page } from "@/components/Page";
import { MarkdownWithToc } from "@/components/MarkdownWithToc";
import React from "react";
import winderManualContent from "@/assets/markdown/winder/manual.md?raw";
export function GluetexManualPage() {
return (
<Page>
<MarkdownWithToc markdownContent={winderManualContent} />
</Page>
);
}
@@ -0,0 +1,573 @@
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 { EditValue } from "@/control/EditValue";
import { Label } from "@/control/Label";
import { TouchButton } from "@/components/touch/TouchButton";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useGluetex } from "../hooks/useGluetex";
import { roundToDecimals } from "@/lib/decimal";
import { SpoolAutomaticActionMode, Mode } from "../state/gluetexNamespace";
import { SelectionGroup } from "@/control/SelectionGroup";
import { cn } from "@/lib/utils";
import { GluetexErrorBanner } from "../components/GluetexErrorBanner";
import { gluetexRoute } from "@/routes/routes";
export function GluetexOverviewPage() {
const { serial: serialString } = gluetexRoute.useParams();
const deviceSerialId = parseInt(serialString);
const {
state,
pullerSpeed,
temperature1,
temperature2,
temperature3,
temperature4,
temperature5,
temperature6,
heater1Power,
heater2Power,
heater3Power,
heater4Power,
heater5Power,
heater6Power,
optris1Voltage,
optris2Voltage,
spoolProgress,
setSpoolAutomaticRequiredMeters,
setSpoolAutomaticAction,
setOperationMode,
resetSpoolProgress,
isLoading,
isDisabled,
setMode,
setOrderNumber,
setSerialNumber,
setProductDescription,
} = useGluetex();
const [tempDescription, setTempDescription] = React.useState("");
const [descriptionOpen, setDescriptionOpen] = React.useState(false);
// Helper function to get temperature status color
const getTemperatureColor = (temp?: number, target?: number) => {
if (!temp || !target) return "bg-gray-300";
const diff = Math.abs(temp - target);
if (diff < 5) return "bg-green-500";
if (diff < 15) return "bg-yellow-500";
return "bg-red-500";
};
// Helper function to get stepper status color
const getStepperColor = (mode?: string) => {
if (!mode) return "bg-gray-300";
if (mode === "Run") return "bg-green-500";
return "bg-gray-300";
};
// Helper function to check if any monitoring is triggered in setup mode
const isAnyMonitoringTriggered = () => {
if (!state) return false;
return (
state.winder_tension_arm_monitor_state?.triggered ||
state.addon_tension_arm_monitor_state?.triggered ||
state.slave_tension_arm_monitor_state?.triggered ||
state.optris_1_monitor_state?.triggered ||
state.optris_2_monitor_state?.triggered ||
state.sleep_timer_state?.triggered ||
false
);
};
const handleResetProgress = () => {
resetSpoolProgress();
};
return (
<Page>
<GluetexErrorBanner />
<ControlGrid>
{/* Top Row: Speed (left) */}
<ControlCard title="Speed">
<TimeSeriesValueNumeric
label="Speed"
unit="m/min"
timeseries={pullerSpeed}
renderValue={(value) => roundToDecimals(value, 1)}
/>
</ControlCard>
{/* Top Row: Quali (center) */}
<ControlCard title="Quali">
<TimeSeriesValueNumeric
label="Optris 1"
unit="V"
timeseries={optris1Voltage}
renderValue={(value) => roundToDecimals(value, 2)}
/>
<TimeSeriesValueNumeric
label="Optris 2"
unit="V"
timeseries={optris2Voltage}
renderValue={(value) => roundToDecimals(value, 2)}
/>
</ControlCard>
{/* Right Column: Tall tile spans two rows */}
<ControlCard
height={2}
width={1}
title="Motors, Temperatures & Heaters"
>
<div className="flex flex-col gap-4">
{/* Addon Motors Status Grid */}
<div>
<h3 className="mb-2 text-lg font-semibold">Addon Motors</h3>
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col items-center gap-2 rounded-lg border p-3">
<span className="text-sm font-medium">Stepper 3</span>
<div
className={cn(
"h-8 w-8 rounded-full",
getStepperColor(state?.stepper_state?.stepper3_mode),
)}
/>
<span className="text-xs">
{state?.stepper_state?.stepper3_mode || "Standby"}
</span>
</div>
<div className="flex flex-col items-center gap-2 rounded-lg border p-3">
<span className="text-sm font-medium">Stepper 4</span>
<div
className={cn(
"h-8 w-8 rounded-full",
getStepperColor(state?.stepper_state?.stepper4_mode),
)}
/>
<span className="text-xs">
{state?.stepper_state?.stepper4_mode || "Standby"}
</span>
</div>
<div className="flex flex-col items-center gap-2 rounded-lg border p-3">
<span className="text-sm font-medium">Stepper 5</span>
<div
className={cn(
"h-8 w-8 rounded-full",
getStepperColor(state?.stepper_state?.stepper5_mode),
)}
/>
<span className="text-xs">
{state?.stepper_state?.stepper5_mode || "Standby"}
</span>
</div>
</div>
</div>
{/* Temperature Status Grid */}
<div>
<h3 className="mb-2 text-lg font-semibold">Temperatures</h3>
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col items-center gap-2 rounded-lg border p-3">
<span className="text-sm font-medium">Temp 1</span>
<div
className={cn(
"h-8 w-8 rounded-full",
getTemperatureColor(
temperature1.current?.value,
state?.heating_states?.zone_1?.target_temperature,
),
)}
/>
<span className="text-xs">
{temperature1.current?.value
? roundToDecimals(temperature1.current.value, 1)
: "—"}
°C
</span>
</div>
<div className="flex flex-col items-center gap-2 rounded-lg border p-3">
<span className="text-sm font-medium">Temp 2</span>
<div
className={cn(
"h-8 w-8 rounded-full",
getTemperatureColor(
temperature2.current?.value,
state?.heating_states?.zone_2?.target_temperature,
),
)}
/>
<span className="text-xs">
{temperature2.current?.value
? roundToDecimals(temperature2.current.value, 1)
: "—"}
°C
</span>
</div>
<div className="flex flex-col items-center gap-2 rounded-lg border p-3">
<span className="text-sm font-medium">Temp 3</span>
<div
className={cn(
"h-8 w-8 rounded-full",
getTemperatureColor(
temperature3.current?.value,
state?.heating_states?.zone_3?.target_temperature,
),
)}
/>
<span className="text-xs">
{temperature3.current?.value
? roundToDecimals(temperature3.current.value, 1)
: "—"}
°C
</span>
</div>
<div className="flex flex-col items-center gap-2 rounded-lg border p-3">
<span className="text-sm font-medium">Temp 4</span>
<div
className={cn(
"h-8 w-8 rounded-full",
getTemperatureColor(
temperature4.current?.value,
state?.heating_states?.zone_4?.target_temperature,
),
)}
/>
<span className="text-xs">
{temperature4.current?.value
? roundToDecimals(temperature4.current.value, 1)
: "—"}
°C
</span>
</div>
<div className="flex flex-col items-center gap-2 rounded-lg border p-3">
<span className="text-sm font-medium">Temp 5</span>
<div
className={cn(
"h-8 w-8 rounded-full",
getTemperatureColor(
temperature5.current?.value,
state?.heating_states?.zone_5?.target_temperature,
),
)}
/>
<span className="text-xs">
{temperature5.current?.value
? roundToDecimals(temperature5.current.value, 1)
: "—"}
°C
</span>
</div>
<div className="flex flex-col items-center gap-2 rounded-lg border p-3">
<span className="text-sm font-medium">Temp 6</span>
<div
className={cn(
"h-8 w-8 rounded-full",
getTemperatureColor(
temperature6.current?.value,
state?.heating_states?.zone_6?.target_temperature,
),
)}
/>
<span className="text-xs">
{temperature6.current?.value
? roundToDecimals(temperature6.current.value, 1)
: "—"}
°C
</span>
</div>
</div>
</div>
{/* Heaters Power Draw */}
<div>
<h3 className="mb-2 text-lg font-semibold">Heaters Power</h3>
<div className="flex flex-col items-center gap-2 rounded-lg border p-4">
<span className="text-sm font-medium">Total Power Draw</span>
<span className="text-3xl font-bold">
{(() => {
const powers = [
heater1Power.current?.value,
heater2Power.current?.value,
heater3Power.current?.value,
heater4Power.current?.value,
heater5Power.current?.value,
heater6Power.current?.value,
];
const allDefined = powers.every((p) => p !== undefined);
if (!allDefined) return "—";
const total = powers.reduce((sum, p) => sum + (p || 0), 0);
return roundToDecimals(total, 1);
})()}
</span>
<span className="text-muted-foreground text-sm">Watts</span>
</div>
</div>
</div>
</ControlCard>
{/* Second Row: Spool Autostop (left) */}
<ControlCard title="Spool Autostop">
<TimeSeriesValueNumeric
label="Pulled Distance"
renderValue={(value) => roundToDecimals(value, 2)}
unit="m"
timeseries={spoolProgress}
/>
<Label label="Target Length">
<EditValue
value={state?.spool_automatic_action_state.spool_required_meters}
unit="m"
title="Expected Meters"
defaultValue={250}
min={10}
max={10000}
step={10}
renderValue={(value) => roundToDecimals(value, 2)}
onChange={setSpoolAutomaticRequiredMeters}
/>
</Label>
<Label label="Estimated Time Remaining">
<span className="font-mono text-lg">
{(() => {
const minutes =
state?.spool_automatic_action_state
.estimated_minutes_remaining || 0;
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
return `${hours}h ${mins}min`;
}
return `${Math.round(minutes)} min`;
})()}
</span>
</Label>
<TouchButton
variant="outline"
onClick={handleResetProgress}
disabled={isDisabled}
isLoading={isLoading || state?.traverse_state?.is_going_out}
>
Reset Progress
</TouchButton>
<Label label="After Target Length Reached">
<SelectionGroup<SpoolAutomaticActionMode>
value={
state?.spool_automatic_action_state.spool_automatic_action_mode
}
disabled={isDisabled}
loading={isLoading}
onChange={setSpoolAutomaticAction}
orientation="vertical"
options={{
Hold: {
children: "Hold",
icon: "lu:CirclePause",
className: "h-full",
},
Pull: {
children: "Pull",
icon: "lu:ChevronsLeft",
className: "h-full",
},
NoAction: {
children: "No Action",
icon: "lu:RefreshCcw",
className: "h-full",
},
}}
/>
</Label>
</ControlCard>
{/* Second Row: Winder Mode (center) */}
<ControlCard title="Winder Mode">
<SelectionGroup<Mode>
value={state?.mode_state.mode}
disabled={isDisabled}
loading={isLoading}
onChange={setMode}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Standby: {
children: "Standby",
icon: "lu:Power",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Hold: {
children: "Hold",
icon: "lu:CirclePause",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Pull: {
children: "Pull",
icon: "lu:ChevronsLeft",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Wind: {
children: "Wind",
icon: "lu:RefreshCcw",
isActiveClassName: "bg-green-600",
disabled: !state?.mode_state?.can_wind,
className: "h-full",
},
}}
/>
</ControlCard>
{/* Bottom Row: Order Information (left, width 2) */}
<ControlCard width={2} title="Order Information">
<div className="grid gap-4">
<div className="grid grid-cols-3 gap-4">
<Label label="Machine Serial ID">
<div className="rounded border border-gray-200 bg-gray-50 px-3 py-2 font-mono text-lg">
{deviceSerialId}
</div>
</Label>
<Label label="Order Number">
<EditValue
value={state?.order_info_state?.order_number ?? 0}
title="Order Number"
defaultValue={0}
min={0}
max={999999}
renderValue={(value) => value.toString()}
onChange={setOrderNumber}
/>
</Label>
<Label label="Serial Number">
<EditValue
value={state?.order_info_state?.serial_number ?? 0}
title="Serial Number"
defaultValue={0}
min={0}
max={999999}
renderValue={(value) => value.toString()}
onChange={setSerialNumber}
/>
</Label>
</div>
<Label label="Product Description">
<Dialog
open={descriptionOpen}
onOpenChange={(isOpen) => {
setDescriptionOpen(isOpen);
if (isOpen) {
setTempDescription(
state?.order_info_state?.product_description ?? "",
);
}
}}
>
<DialogTrigger asChild>
<TouchButton
variant="outline"
className="h-12 w-full justify-start text-lg font-normal"
>
{state?.order_info_state?.product_description ||
"Enter description..."}
</TouchButton>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Product Description</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-6 py-4">
<Input
type="text"
placeholder="Enter description..."
className="h-14 text-xl"
value={tempDescription}
onChange={(e) => setTempDescription(e.target.value)}
autoComplete="off"
autoFocus
/>
<div className="flex gap-3">
<TouchButton
variant="outline"
className="h-14 flex-1 text-lg"
onClick={() => {
setDescriptionOpen(false);
setTempDescription(
state?.order_info_state?.product_description ?? "",
);
}}
>
Cancel
</TouchButton>
<TouchButton
variant="default"
className="h-14 flex-1 text-lg"
onClick={() => {
setProductDescription(tempDescription);
setDescriptionOpen(false);
}}
>
Save
</TouchButton>
</div>
</div>
</DialogContent>
</Dialog>
</Label>
</div>
</ControlCard>
{/* Bottom Row: Control Mode (right, width 1) */}
<ControlCard width={1} title="Control Mode">
<div className="flex flex-col gap-4">
<TouchButton
variant={
state?.mode_state?.operation_mode === "Setup"
? "default"
: "outline"
}
disabled={isDisabled}
className="h-20 text-lg"
icon="lu:Wrench"
onClick={() => setOperationMode("Setup")}
>
Setup Mode
</TouchButton>
<TouchButton
variant={
state?.mode_state?.operation_mode === "Production"
? "default"
: "outline"
}
disabled={isDisabled || isAnyMonitoringTriggered()}
className="h-20 text-lg"
icon="lu:ShieldCheck"
onClick={() => setOperationMode("Production")}
>
Production Mode
</TouchButton>
{isAnyMonitoringTriggered() && (
<div className="rounded bg-orange-50 p-2 text-center text-sm text-orange-600">
Production mode is disabled because monitoring alert is
triggered
</div>
)}
</div>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,62 @@
import { Topbar } from "@/components/Topbar";
import { gluetexRoute } from "@/routes/routes";
import React from "react";
export function GluetexPage() {
const { serial } = gluetexRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/gluetex/${serial}`}
items={[
{
link: "overview",
activeLink: "overview",
title: "Overview",
icon: "lu:LayoutDashboard",
},
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:CirclePlay",
},
{
link: "addons",
activeLink: "addons",
title: "Addons",
icon: "lu:Puzzle",
},
{
link: "heaters",
activeLink: "heaters",
title: "Heaters",
icon: "lu:Flame",
},
{
link: "graphs",
activeLink: "graphs",
title: "Graphs",
icon: "lu:ChartSpline",
},
{
link: "settings",
activeLink: "settings",
title: "Config",
icon: "lu:Settings",
},
{
link: "manual",
activeLink: "manual",
title: "Manual",
icon: "lu:BookOpen",
},
{
link: "presets",
activeLink: "presets",
title: "Presets",
icon: "lu:Save",
},
]}
/>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+126
View File
@@ -20,6 +20,16 @@ import { Winder2SettingPage } from "@/machines/winder/winder2/Winder2Settings";
import { Winder2GraphsPage } from "@/machines/winder/winder2/Winder2Graphs";
import { Winder2PresetsPage } from "@/machines/winder/winder2/Winder2PresetsPage";
import { GluetexPage } from "@/machines/winder/gluetex/pages/GluetexPage";
import { GluetexOverviewPage } from "@/machines/winder/gluetex/pages/GluetexOverviewPage";
import { GluetexControlPage } from "@/machines/winder/gluetex/pages/GluetexControlPage";
import { GluetexManualPage } from "@/machines/winder/gluetex/pages/GluetexManual";
import { GluetexSettingPage } from "@/machines/winder/gluetex/pages/GluetexSettings";
import { GluetexGraphsPage } from "@/machines/winder/gluetex/pages/GluetexGraphs";
import { GluetexPresetsPage } from "@/machines/winder/gluetex/pages/GluetexPresetsPage";
import { GluetexAddonsPage } from "@/machines/winder/gluetex/pages/GluetexAddonsPage";
import { GluetexHeatersPage } from "@/machines/winder/gluetex/pages/GluetexHeatersPage";
import { Extruder2Page } from "@/machines/extruder/extruder2/Extruder2Page";
import { Extruder2ControlPage } from "@/machines/extruder/extruder2/Extruder2ControlPage";
import { Extruder2SettingsPage } from "@/machines/extruder/extruder2/Extruder2Settings";
@@ -67,12 +77,18 @@ import { DigitalInputTestMachineControlPage } from "@/machines/digitalinputtestm
import { IP20TestMachinePage } from "@/machines/ip20testmachine/IP20TestMachinePage";
import { IP20TestMachineControlPage } from "@/machines/ip20testmachine/IP20TestMachineControlPage";
import { TestMotorPage } from "@/machines/motor_test_machine/TestMotorPage";
import { TestMotorControlPage } from "@/machines/motor_test_machine/TestMotorControlPage";
import { MetricsGraphsPage } from "@/metrics/MetricsGraphsPage";
import { MetricsControlPage } from "@/metrics/MetricsControlPage";
import { WagoPower1Page } from "@/machines/wago_power/wago_power1/WagoPower1Page";
import { WagoPower1ControlPage } from "@/machines/wago_power/wago_power1/WagoPower1ControlPage";
import { WagoDoTestMachinePage } from "@/machines/wagodotestmachine/WagoDoTestMachinePage";
import { WagoDoTestMachineControlPage } from "@/machines/wagodotestmachine/WagoDoTestMachineControlPage";
import { Wago8chDioTestMachinePage } from "@/machines/wago8chdiotestmachine/wago8chDioTestMachinePage";
import { Wago8chDioTestMachineControlRoute } from "@/machines/wago8chdiotestmachine/wago8chDioTestMachineControlPage";
// make a route tree like this
// _mainNavigation/machines/winder2/$serial/control
@@ -87,6 +103,18 @@ export const testMachineSerialRoute = createRoute({
component: () => <TestMachinePage />,
});
export const wago8chDioTestMachineRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "wago8chdiotestmachine/$serial",
component: () => <Wago8chDioTestMachinePage />,
});
export const wago8chDioTestMachineControlRoute = createRoute({
getParentRoute: () => wago8chDioTestMachineRoute,
path: "control",
component: () => <Wago8chDioTestMachineControlRoute />,
});
// Leaf route: control page
export const testMachineControlRoute = createRoute({
getParentRoute: () => testMachineSerialRoute,
@@ -142,6 +170,18 @@ export const ip20TestMachineControlRoute = createRoute({
component: () => <IP20TestMachineControlPage />,
});
export const testMotorSerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "testmotor/$serial",
component: () => <TestMotorPage />,
});
export const testMotorControlRoute = createRoute({
getParentRoute: () => testMotorSerialRoute,
path: "control",
component: () => <TestMotorControlPage />,
});
export const sidebarRoute = createRoute({
getParentRoute: () => RootRoute,
path: "_sidebar",
@@ -261,6 +301,60 @@ export const winder2PresetsRoute = createRoute({
component: () => <Winder2PresetsPage />,
});
export const gluetexRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "gluetex/$serial",
component: () => <GluetexPage />,
});
export const gluetexOverviewRoute = createRoute({
getParentRoute: () => gluetexRoute,
path: "overview",
component: () => <GluetexOverviewPage />,
});
export const gluetexControlRoute = createRoute({
getParentRoute: () => gluetexRoute,
path: "control",
component: () => <GluetexControlPage />,
});
export const gluetexManualRoute = createRoute({
getParentRoute: () => gluetexRoute,
path: "manual",
component: () => <GluetexManualPage />,
});
export const gluetexSettingsRoute = createRoute({
getParentRoute: () => gluetexRoute,
path: "settings",
component: () => <GluetexSettingPage />,
});
export const gluetexGraphsRoute = createRoute({
getParentRoute: () => gluetexRoute,
path: "graphs",
component: () => <GluetexGraphsPage />,
});
export const gluetexPresetsRoute = createRoute({
getParentRoute: () => gluetexRoute,
path: "presets",
component: () => <GluetexPresetsPage />,
});
export const gluetexAddonsRoute = createRoute({
getParentRoute: () => gluetexRoute,
path: "addons",
component: () => <GluetexAddonsPage />,
});
export const gluetexHeatersRoute = createRoute({
getParentRoute: () => gluetexRoute,
path: "heaters",
component: () => <GluetexHeatersPage />,
});
export const laser1SerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "laser1/$serial",
@@ -369,6 +463,18 @@ export const wagoPower1ControlRoute = createRoute({
component: () => <WagoPower1ControlPage />,
});
export const wagoDoTestMachineSerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "wagodotestmachine/$serial",
component: () => <WagoDoTestMachinePage />,
});
export const wagoDoTestMachineControlRoute = createRoute({
getParentRoute: () => wagoDoTestMachineSerialRoute,
path: "control",
component: () => <WagoDoTestMachineControlPage />,
});
export const setupRoute = createRoute({
getParentRoute: () => sidebarRoute,
path: "setup",
@@ -470,12 +576,17 @@ export const rootTree = RootRoute.addChildren([
laser1GraphsRoute,
laser1PresetsRoute,
]),
testMachineSerialRoute.addChildren([testMachineControlRoute]),
analogInputTestMachineSerialRoute.addChildren([
analogInputTestMachineControlRoute,
]),
wago8chDioTestMachineRoute.addChildren([
wago8chDioTestMachineControlRoute,
]),
wagoAiTestMachineSerialRoute.addChildren([wagoAiTestMachineControlRoute]),
digitalInputTestMachineSerialRoute.addChildren([
@@ -484,6 +595,8 @@ export const rootTree = RootRoute.addChildren([
ip20TestMachineSerialRoute.addChildren([ip20TestMachineControlRoute]),
testMotorSerialRoute.addChildren([testMotorControlRoute]),
aquapath1SerialRoute.addChildren([
aquapath1ControlRoute,
aquapath1GraphRoute,
@@ -498,6 +611,17 @@ export const rootTree = RootRoute.addChildren([
winder2PresetsRoute,
]),
gluetexRoute.addChildren([
gluetexOverviewRoute,
gluetexControlRoute,
gluetexManualRoute,
gluetexSettingsRoute,
gluetexGraphsRoute,
gluetexPresetsRoute,
gluetexAddonsRoute,
gluetexHeatersRoute,
]),
extruder2Route.addChildren([
extruder2ControlRoute,
extruder2SettingsRoute,
@@ -524,6 +648,8 @@ export const rootTree = RootRoute.addChildren([
wagoPower1SerialRoute.addChildren([wagoPower1ControlRoute]),
buffer1SerialRoute.addChildren([buffer1ControlRoute]),
wagoDoTestMachineSerialRoute.addChildren([wagoDoTestMachineControlRoute]),
]),
]),
]);
+35 -12
View File
@@ -15,7 +15,13 @@ import {
machineProperties,
VENDOR_QITECH,
} from "@/machines/properties";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -150,6 +156,15 @@ export function DeviceEeepromDialogContent({ device, setOpen }: ContentProps) {
});
};
const updateNumpadPosition = useCallback(() => {
if (!numpadOpen || !dialogRef.current) return;
const rect = dialogRef.current.getBoundingClientRect();
setNumpadPosition({
left: rect.right + 20,
top: rect.top + rect.height / 2,
});
}, [numpadOpen]);
const machinePreset = useMemo(() => {
if (!values.machine) return;
return getMachineProperties({
@@ -166,7 +181,7 @@ export function DeviceEeepromDialogContent({ device, setOpen }: ContentProps) {
device.revision,
machinePreset?.device_roles,
),
[device.product_id, device.revision, machinePreset],
[device.vendor_id, device.product_id, device.revision, machinePreset],
);
// if there is only one allowed role, set the role to that immediately
@@ -180,19 +195,19 @@ export function DeviceEeepromDialogContent({ device, setOpen }: ContentProps) {
const index = filteredAllowedDevices.findIndex(
(isAllowed) => isAllowed === true,
);
// set the device role of index
form.setValue("role", index.toString());
// set the device role using the actual role number, not the index
if (machinePreset && machinePreset.device_roles[index]) {
form.setValue(
"role",
machinePreset.device_roles[index].role.toString(),
);
}
}
}, [filteredAllowedDevices]);
}, [filteredAllowedDevices, machinePreset, form]);
// Position numpad once when it opens
useEffect(() => {
if (!numpadOpen || !dialogRef.current) return;
const rect = dialogRef.current.getBoundingClientRect();
setNumpadPosition({
left: rect.right + 20,
top: rect.top + rect.height / 2,
});
updateNumpadPosition();
}, [numpadOpen]);
// Close numpad when clicking outside input/numpad
@@ -226,6 +241,14 @@ export function DeviceEeepromDialogContent({ device, setOpen }: ContentProps) {
}
}, [numpadOpen]);
// Update numpad position whenever the window resizes
useEffect(() => {
window.addEventListener("resize", updateNumpadPosition);
return () => {
window.removeEventListener("resize", updateNumpadPosition);
};
}, [updateNumpadPosition]);
// Numpad handlers for serial input
const numpadHandlers = useMemo(() => {
const ensureFocus = () => {
@@ -433,7 +456,7 @@ export function DeviceEeepromDialogContent({ device, setOpen }: ContentProps) {
<FormItem>
<FormLabel>Device Role</FormLabel>
<FormControl>
<Select {...field}>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="w-min">
<SelectValue placeholder="Device Role" />
</SelectTrigger>
+1 -1
View File
@@ -8,7 +8,7 @@ workspace = true
[dependencies]
anyhow = "1.0.100"
ethercrab = "0.6"
ethercrab = { git = "https://github.com/v-morlock/ethercrab", branch = "main" }
ethercat_hal_derive = { version = "0.1.0", path = "../ethercat-hal-derive" }
units = { path = "../units" }
bitvec = { version = "1.0.1", features = ["alloc"] }
+1 -1
View File
@@ -180,7 +180,6 @@ impl Configuration for EL5152Configuration {
) -> 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
@@ -304,6 +303,7 @@ impl EL5152ChannelConfiguration {
device
.sdo_write(base_index, 0x11, self.frequency_window)
.await?;
device
.sdo_write(base_index, 0x13, self.frequency_scaling)
.await?;
@@ -351,6 +351,7 @@ pub enum EL7031_0030AnalogInputPort {
pub const EL7031_0030_VENDOR_ID: u32 = 0x2;
pub const EL7031_0030_PRODUCT_ID: u32 = 0x1b773052;
pub const EL7031_0030_REVISION_A: u32 = 0x10001E;
pub const EL7031_0030_IDENTITY_A: SubDeviceIdentityTuple = (
EL7031_0030_VENDOR_ID,
EL7031_0030_PRODUCT_ID,
+2 -1
View File
@@ -56,8 +56,9 @@ use std::{any::Any, fmt::Debug, sync::Arc};
use wago_750_354::{WAGO_750_354_IDENTITY_A, Wago750_354};
use wago_modules::ip20_ec_di8_do8::{IP20_EC_DI8_DO8_IDENTITY, IP20EcDi8Do8};
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
pub struct Module {
pub name: String,
pub slot: u16,
pub belongs_to_addr: u16,
pub has_tx: bool,
+118 -53
View File
@@ -2,6 +2,9 @@ use super::{
EthercatDevice, EthercatDeviceProcessing, EthercatDeviceUsed, NewEthercatDevice,
SubDeviceIdentityTuple,
};
use crate::devices::wago_modules::wago_750_430::{
WAGO_750_430_MODULE_IDENT, WAGO_750_430_PRODUCT_ID,
};
use crate::devices::wago_modules::*;
use crate::{
devices::{
@@ -20,24 +23,25 @@ use crate::{
use anyhow::Error;
use smol::lock::RwLock;
use std::sync::Arc;
const MODULE_COUNT_INDEX: (u16, u8) = (0xf050, 0x00);
const TX_MAPPING_INDEX: (u16, u8) = (0x1c13, 0x00);
const RX_MAPPING_INDEX: (u16, u8) = (0x1c12, 0x00);
// For both the rx and tx The Wago Coupler has 4 bytes, which we dont care about and skip
/// Wago750_354 bus coupler
/*
The "Modules" simply write at an offset into rx and read at an offset in tx
*/
#[derive(Clone, Debug)]
struct ModulePdoMapping {
pub offset: usize,
pub module_i: u32,
}
// For both the rx and tx The Wago Coupler has 4 bytes, which we dont care about and skip
pub struct Wago750_354 {
is_used: bool,
pub slots: [Option<Module>; 64],
pub slot_devices: [Option<Arc<RwLock<dyn DynamicEthercatDevice>>>; 64],
pub dev_count: usize,
pub module_count: usize,
rx_offsets: Vec<usize>,
tx_offsets: Vec<usize>,
rx_pdo_mappings: Vec<ModulePdoMapping>,
tx_pdo_mappings: Vec<ModulePdoMapping>,
tx_size: usize,
rx_size: usize,
}
@@ -105,7 +109,7 @@ impl EthercatDevice for Wago750_354 {
}
fn set_module(&mut self, module: Module) {
self.slots[self.module_count] = Some(module);
self.slots[self.module_count] = Some(module.clone());
self.module_count += 1;
self.tx_size += module.tx_offset;
self.rx_size += module.rx_offset;
@@ -134,8 +138,8 @@ impl NewEthercatDevice for Wago750_354 {
dev_count: 0,
tx_size: 0,
rx_size: 0,
rx_offsets: vec![],
tx_offsets: vec![],
rx_pdo_mappings: vec![],
tx_pdo_mappings: vec![],
}
}
}
@@ -147,39 +151,76 @@ impl std::fmt::Debug for Wago750_354 {
}
impl Wago750_354 {
pub fn calculate_module_index(pdo_mapping: u32, is_tx: bool) -> u32 {
let start_index = match is_tx {
true => 0x6000 as u32,
false => 0x7000 as u32,
};
let index_in_hex = ((pdo_mapping & 0xFFFF0000) >> 16) - start_index;
if index_in_hex < 16 {
return 0;
} else {
return index_in_hex / 16;
}
}
pub async fn get_pdo_offsets<'a>(
&mut self,
device: &EthercrabSubDevicePreoperational<'a>,
get_tx: bool,
) -> Result<(), Error> {
let mut vec: Vec<usize> = vec![];
let mut vec: Vec<ModulePdoMapping> = vec![];
let mut bit_offset = 0;
let start_subindex = 0x1;
let mut module_i;
let start_subindex = 0x2;
let index = match get_tx {
true => (TX_MAPPING_INDEX.0, TX_MAPPING_INDEX.1),
false => (RX_MAPPING_INDEX.0, RX_MAPPING_INDEX.1),
};
let count = device.sdo_read::<u8>(index.0, index.1).await?;
for i in 0..count {
vec.push(bit_offset);
let pdo_index = device.sdo_read(index.0, start_subindex + i).await?;
if pdo_index != 0 {
let pdo_map_count = device.sdo_read::<u8>(pdo_index, 0).await?;
// Iterate over every PDO Mapped entry and add it to the cumulative bit offset
for j in 0..pdo_map_count {
let pdo_mapping: u32 = device.sdo_read(pdo_index, 1 + j).await?;
// We only need / Want the bit len, which we extract with a bitmask extracting the lsb
let bit_length = (pdo_mapping & 0xFF) as u8;
bit_offset += bit_length as usize;
}
}
let count_mappings = device.sdo_read::<u8>(index.0, index.1).await?;
let pdo_index = device.sdo_read::<u16>(index.0, 1).await?;
let pdo_map_count = device.sdo_read::<u8>(pdo_index, 0).await?;
for i in 0..pdo_map_count {
let pdo_mapping: u32 = device.sdo_read(pdo_index, 1 + i).await?;
let bit_length = (pdo_mapping & 0xFF) as u8;
bit_offset += bit_length as usize;
}
let mut mappings_without_coupler: Vec<u32> = vec![];
for i in start_subindex..count_mappings {
let pdo_index = device.sdo_read(index.0, i).await?;
let pdo_map_count = device.sdo_read::<u8>(pdo_index, 0).await?;
for j in 0..pdo_map_count {
let pdo_mapping: u32 = device.sdo_read(pdo_index, 1 + j).await?;
mappings_without_coupler.push(pdo_mapping);
}
}
mappings_without_coupler.sort();
for pdo_mapping in mappings_without_coupler {
module_i = Wago750_354::calculate_module_index(pdo_mapping, get_tx);
let bit_length = (pdo_mapping & 0xFF) as u8;
if module_i < 64 {
vec.push(ModulePdoMapping {
offset: bit_offset,
module_i,
});
}
bit_offset += bit_length as usize;
}
vec.sort_by_key(|e| (e.module_i, e.offset));
// deduplicate by module_i, so we only have the offset to the start of inputs/outputs
vec.dedup_by(|a, b| a.module_i == b.module_i);
if get_tx {
self.tx_offsets = vec;
self.tx_pdo_mappings = vec;
} else {
self.rx_offsets = vec;
self.rx_pdo_mappings = vec;
}
Ok(())
}
@@ -222,6 +263,7 @@ impl Wago750_354 {
has_rx: false,
tx_offset: 0,
rx_offset: 0,
name: "".to_string(),
};
match ident_iom {
@@ -229,26 +271,37 @@ impl Wago750_354 {
WAGO_750_1506_PRODUCT_ID => {
module.has_tx = true;
module.has_rx = true;
module.name = "750-1506".to_string();
}
WAGO_750_455_PRODUCT_ID => {
module.has_tx = true;
module.has_rx = false;
module.name = "750-455".to_string();
}
WAGO_750_501_PRODUCT_ID => {
module.has_tx = false;
module.has_rx = true;
module.name = "750-501".to_string();
}
WAGO_750_530_PRODUCT_ID => {
module.has_tx = false;
module.has_rx = true;
module.name = "750-530".to_string();
}
WAGO_750_652_PRODUCT_ID => {
module.has_tx = true;
module.has_rx = true;
module.name = "750-652".to_string();
}
WAGO_750_402_PRODUCT_ID => {
module.has_tx = true;
module.has_rx = false;
module.name = "750-402".to_string();
}
WAGO_750_430_PRODUCT_ID => {
module.has_tx = true;
module.has_rx = false;
module.name = "750-430".to_string();
}
_ => println!(
"Wago-750-354 found Unknown/Unimplemented Module: {}",
@@ -266,17 +319,42 @@ impl Wago750_354 {
if self.dev_count != 0 {
return;
}
let mut tx_index = 1;
let mut rx_index = 1;
smol::block_on(async {
let _ = self.get_pdo_offsets(device, true).await;
let _ = self.get_pdo_offsets(device, false).await;
});
println!("{:?}\n\n{:?}", self.tx_offsets, self.rx_offsets);
for module in &mut self.slots {
match module {
Some(m) => {
let tx_pdo_mapping = self
.tx_pdo_mappings
.iter()
.find(|map| map.module_i == m.slot.into());
if m.has_tx {
m.tx_offset = match tx_pdo_mapping {
Some(map) => map.offset,
None => 0,
}
}
for module in self.slots {
let rx_pdo_mapping = self
.rx_pdo_mappings
.iter()
.find(|map| map.module_i == m.slot.into());
if m.has_rx {
m.rx_offset = match rx_pdo_mapping {
Some(map) => map.offset,
None => 0,
}
}
}
None => break,
}
}
for module in &self.slots {
match module {
Some(m) => {
// Map ModuleIdent's to Terminals
@@ -302,6 +380,10 @@ impl Wago750_354 {
WAGO_750_402_MODULE_IDENT => {
Arc::new(RwLock::new(wago_750_402::Wago750_402::new()))
}
WAGO_750_430_MODULE_IDENT => {
Arc::new(RwLock::new(wago_750_430::Wago750_430::new()))
}
_ => {
println!(
"{} Missing Implementation for Module Identification: vendor_id: {:?}, module ident: {:?} !",
@@ -312,27 +394,10 @@ impl Wago750_354 {
return;
}
};
let tx_pdo_offset = self.tx_offsets.get(tx_index as usize);
if m.has_tx {
tx_index += 1;
}
let rx_pdo_offset = self.rx_offsets.get(rx_index as usize);
if m.has_rx {
rx_index += 1;
}
let mut dev_guard = dev.write_blocking();
match tx_pdo_offset {
Some(offset) => dev_guard.set_tx_offset(*offset),
None => (),
}
match rx_pdo_offset {
Some(offset) => dev_guard.set_rx_offset(*offset),
None => (),
}
//println!("For {:?} setting tx: {} rx: {}",m.name,m.tx_offset,m.rx_offset);
dev_guard.set_tx_offset(m.tx_offset);
dev_guard.set_rx_offset(m.rx_offset);
drop(dev_guard);
self.slot_devices[self.dev_count] = Some(dev);
self.dev_count += 1;
@@ -214,7 +214,7 @@ impl EthercatDevice for IP20EcDi8Do8 {
}
fn set_module(&mut self, module: Module) {
self.slots[self.module_count] = Some(module);
self.slots[self.module_count] = Some(module.clone());
self.module_count += 1;
self.tx_size += module.tx_offset;
self.rx_size += module.rx_offset;
@@ -378,6 +378,7 @@ impl IP20EcDi8Do8 {
has_rx: false,
tx_offset: 0,
rx_offset: 0,
name: "".to_string(),
};
match ident_iom {
@@ -415,7 +416,7 @@ impl IP20EcDi8Do8 {
let _ = self.get_pdo_offsets(device, false).await;
});
for module in self.slots {
for module in &self.slots {
match module {
Some(m) => {
// Map ModuleIdent's to Terminals
@@ -1,6 +1,7 @@
pub mod ip20_ec_di8_do8;
pub mod wago_750_1506;
pub mod wago_750_402;
pub mod wago_750_430;
pub mod wago_750_455;
pub mod wago_750_501;
pub mod wago_750_530;
@@ -38,27 +38,27 @@ impl From<Wago750_1506OutputPort> for usize {
#[derive(Debug, Clone)]
pub enum Wago750_1506InputPort {
DO1,
DO2,
DO3,
DO4,
DO5,
DO6,
DO7,
DO8,
DI1,
DI2,
DI3,
DI4,
DI5,
DI6,
DI7,
DI8,
}
impl From<Wago750_1506InputPort> for usize {
fn from(value: Wago750_1506InputPort) -> Self {
match value {
Wago750_1506InputPort::DO1 => 0,
Wago750_1506InputPort::DO2 => 1,
Wago750_1506InputPort::DO3 => 2,
Wago750_1506InputPort::DO4 => 3,
Wago750_1506InputPort::DO5 => 4,
Wago750_1506InputPort::DO6 => 5,
Wago750_1506InputPort::DO7 => 6,
Wago750_1506InputPort::DO8 => 7,
Wago750_1506InputPort::DI1 => 0,
Wago750_1506InputPort::DI2 => 1,
Wago750_1506InputPort::DI3 => 2,
Wago750_1506InputPort::DI4 => 3,
Wago750_1506InputPort::DI5 => 4,
Wago750_1506InputPort::DI6 => 5,
Wago750_1506InputPort::DI7 => 6,
Wago750_1506InputPort::DI8 => 7,
}
}
}
@@ -91,14 +91,14 @@ impl DigitalInputDevice<Wago750_1506InputPort> for Wago750_1506 {
fn get_input(&self, port: Wago750_1506InputPort) -> Result<DigitalInputInput, anyhow::Error> {
Ok(DigitalInputInput {
value: match port {
Wago750_1506InputPort::DO1 => self.tx_pdo.port1,
Wago750_1506InputPort::DO2 => self.tx_pdo.port2,
Wago750_1506InputPort::DO3 => self.tx_pdo.port3,
Wago750_1506InputPort::DO4 => self.tx_pdo.port4,
Wago750_1506InputPort::DO5 => self.tx_pdo.port5,
Wago750_1506InputPort::DO6 => self.tx_pdo.port6,
Wago750_1506InputPort::DO7 => self.tx_pdo.port7,
Wago750_1506InputPort::DO8 => self.tx_pdo.port8,
Wago750_1506InputPort::DI1 => self.tx_pdo.port1,
Wago750_1506InputPort::DI2 => self.tx_pdo.port2,
Wago750_1506InputPort::DI3 => self.tx_pdo.port3,
Wago750_1506InputPort::DI4 => self.tx_pdo.port4,
Wago750_1506InputPort::DI5 => self.tx_pdo.port5,
Wago750_1506InputPort::DI6 => self.tx_pdo.port6,
Wago750_1506InputPort::DI7 => self.tx_pdo.port7,
Wago750_1506InputPort::DI8 => self.tx_pdo.port8,
},
})
}
@@ -189,28 +189,28 @@ impl EthercatDevice for Wago750_1506 {
) -> Result<(), anyhow::Error> {
let base = self.tx_bit_offset;
let idx1 = base + Into::<usize>::into(Wago750_1506InputPort::DO1);
let idx1 = base + Into::<usize>::into(Wago750_1506InputPort::DI1);
self.tx_pdo.port1 = *input.get(idx1).expect("Bit 1 out of bounds");
let idx2 = base + Into::<usize>::into(Wago750_1506InputPort::DO2);
let idx2 = base + Into::<usize>::into(Wago750_1506InputPort::DI2);
self.tx_pdo.port2 = *input.get(idx2).expect("Bit 2 out of bounds");
let idx3 = base + Into::<usize>::into(Wago750_1506InputPort::DO3);
let idx3 = base + Into::<usize>::into(Wago750_1506InputPort::DI3);
self.tx_pdo.port3 = *input.get(idx3).expect("Bit 3 out of bounds");
let idx4 = base + Into::<usize>::into(Wago750_1506InputPort::DO4);
let idx4 = base + Into::<usize>::into(Wago750_1506InputPort::DI4);
self.tx_pdo.port4 = *input.get(idx4).expect("Bit 4 out of bounds");
let idx5 = base + Into::<usize>::into(Wago750_1506InputPort::DO5);
let idx5 = base + Into::<usize>::into(Wago750_1506InputPort::DI5);
self.tx_pdo.port5 = *input.get(idx5).expect("Bit 5 out of bounds");
let idx6 = base + Into::<usize>::into(Wago750_1506InputPort::DO6);
let idx6 = base + Into::<usize>::into(Wago750_1506InputPort::DI6);
self.tx_pdo.port6 = *input.get(idx6).expect("Bit 6 out of bounds");
let idx7 = base + Into::<usize>::into(Wago750_1506InputPort::DO7);
let idx7 = base + Into::<usize>::into(Wago750_1506InputPort::DI7);
self.tx_pdo.port7 = *input.get(idx7).expect("Bit 7 out of bounds");
let idx8 = base + Into::<usize>::into(Wago750_1506InputPort::DO8);
let idx8 = base + Into::<usize>::into(Wago750_1506InputPort::DI8);
self.tx_pdo.port8 = *input.get(idx8).expect("Bit 8 out of bounds");
Ok(())
}
@@ -0,0 +1,211 @@
use crate::devices::{
DynamicEthercatDevice, EthercatDevice, EthercatDeviceUsed, EthercatDynamicPDO, Module,
SubDeviceProductTuple,
};
use crate::devices::{EthercatDeviceProcessing, NewEthercatDevice};
use crate::io::digital_input::{DigitalInputDevice, DigitalInputInput};
#[derive(Clone)]
pub struct Wago750_430 {
is_used: bool,
tx_bit_offset: usize,
rx_bit_offset: usize,
pub txpdo: Wago750_430TxPdo,
module: Option<Module>,
}
#[derive(Debug, Clone)]
pub enum Wago750_430Port {
Port1,
Port2,
Port3,
Port4,
Port5,
Port6,
Port7,
Port8,
}
impl From<Wago750_430Port> for usize {
fn from(value: Wago750_430Port) -> Self {
match value {
Wago750_430Port::Port1 => 0,
Wago750_430Port::Port2 => 1,
Wago750_430Port::Port3 => 2,
Wago750_430Port::Port4 => 3,
Wago750_430Port::Port5 => 4,
Wago750_430Port::Port6 => 5,
Wago750_430Port::Port7 => 6,
Wago750_430Port::Port8 => 7,
}
}
}
#[derive(Clone, Default)]
pub struct Wago750_430TxPdo {
pub port1: bool,
pub port2: bool,
pub port3: bool,
pub port4: bool,
pub port5: bool,
pub port6: bool,
pub port7: bool,
pub port8: bool,
}
impl DigitalInputDevice<Wago750_430Port> for Wago750_430 {
fn get_input(
&self,
port: Wago750_430Port,
) -> Result<crate::io::digital_input::DigitalInputInput, anyhow::Error> {
match port {
Wago750_430Port::Port1 => Ok(DigitalInputInput {
value: self.txpdo.port1,
}),
Wago750_430Port::Port2 => Ok(DigitalInputInput {
value: self.txpdo.port2,
}),
Wago750_430Port::Port3 => Ok(DigitalInputInput {
value: self.txpdo.port3,
}),
Wago750_430Port::Port4 => Ok(DigitalInputInput {
value: self.txpdo.port4,
}),
Wago750_430Port::Port5 => Ok(DigitalInputInput {
value: self.txpdo.port5,
}),
Wago750_430Port::Port6 => Ok(DigitalInputInput {
value: self.txpdo.port6,
}),
Wago750_430Port::Port7 => Ok(DigitalInputInput {
value: self.txpdo.port7,
}),
Wago750_430Port::Port8 => Ok(DigitalInputInput {
value: self.txpdo.port8,
}),
}
}
}
impl EthercatDeviceUsed for Wago750_430 {
fn is_used(&self) -> bool {
self.is_used
}
fn set_used(&mut self, used: bool) {
self.is_used = used;
}
}
impl DynamicEthercatDevice for Wago750_430 {}
impl EthercatDynamicPDO for Wago750_430 {
fn get_tx_offset(&self) -> usize {
self.tx_bit_offset
}
fn get_rx_offset(&self) -> usize {
self.rx_bit_offset
}
fn set_tx_offset(&mut self, offset: usize) {
self.tx_bit_offset = offset
}
fn set_rx_offset(&mut self, offset: usize) {
self.rx_bit_offset = offset
}
}
impl EthercatDevice for Wago750_430 {
fn input(
&mut self,
input: &bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
) -> Result<(), anyhow::Error> {
self.txpdo.port1 = input[0];
self.txpdo.port2 = input[1];
self.txpdo.port3 = input[2];
self.txpdo.port4 = input[3];
self.txpdo.port5 = input[4];
self.txpdo.port6 = input[5];
self.txpdo.port7 = input[6];
self.txpdo.port8 = input[7];
Ok(())
}
fn input_len(&self) -> usize {
8
}
fn output(
&self,
_output: &mut bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
) -> Result<(), anyhow::Error> {
Ok(())
}
fn output_len(&self) -> usize {
0
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn is_module(&self) -> bool {
true
}
fn input_checked(
&mut self,
_input: &bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
) -> Result<(), anyhow::Error> {
Ok(())
}
fn output_checked(
&self,
_output: &mut bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
) -> Result<(), anyhow::Error> {
Ok(())
}
fn get_module(&self) -> Option<crate::devices::Module> {
self.module.clone()
}
fn set_module(&mut self, module: crate::devices::Module) {
self.tx_bit_offset = module.tx_offset;
self.rx_bit_offset = module.rx_offset;
self.module = Some(module)
}
}
impl EthercatDeviceProcessing for Wago750_430 {}
impl NewEthercatDevice for Wago750_430 {
fn new() -> Self {
Self {
is_used: false,
tx_bit_offset: 0,
rx_bit_offset: 0,
module: None,
txpdo: Wago750_430TxPdo::default(),
}
}
}
impl std::fmt::Debug for Wago750_430 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Wago750_430")
}
}
pub const WAGO_750_430_VENDOR_ID: u32 = 0x00000021;
pub const WAGO_750_430_PRODUCT_ID: u32 = 2147483777;
pub const WAGO_750_430_MODULE_IDENT: SubDeviceProductTuple =
(WAGO_750_430_VENDOR_ID, WAGO_750_430_PRODUCT_ID);
@@ -148,8 +148,7 @@ impl EthercatDevice for Wago750_652 {
&self,
output: &mut bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
) -> Result<(), anyhow::Error> {
let base = 32;
let base = self.rx_bit_offset;
let mut ol0_i = 5; // Ol0 starts at bit 5 and goes to bit 7 (inclusive)
let mut ol1_i = 8;
@@ -174,12 +173,10 @@ impl EthercatDevice for Wago750_652 {
);
ol1_i += 1;
}
println!("{}", self.rx_pdo.control.output_length);
let data_start = base + 16;
for (i, &byte) in self.rx_pdo.out_buffer.iter().take(22).enumerate() {
output[(data_start + i * 8)..(data_start + (i + 1) * 8)].store_le(byte);
}
//println!("{:?}",self.rx_pdo.out_buffer);
Ok(())
}
+71
View File
@@ -2,3 +2,74 @@ pub mod counter_wrapper_u16_i128;
pub mod el70xx_velocity_converter;
pub mod ethercrab_types;
pub mod signing_converter_u16;
use ethercrab::{SubDevice, SubDeviceRef};
// This is the Codeword for disabling EEPROM writes on sdo writes
const BECKHOFF_EEPROM_LOCK_CODEWORD: u32 = 0x12345678;
// In this index Codewords can be written for Beckhoff Terminals
const BECKHOFF_CODEWORD_INDEX: u16 = 0xF008;
// This code locks the eeprom and protects it from writes
// When the toggle is in effect no writes propagate to the eeprom
// This is useful if you have a usecase where you very often write into CoE registers, as writes to EEPROM after a long time degrage the EEPROM
pub async fn set_mut_beckhoff_eeprom_lock_active<'a>(
subdevice: &SubDeviceRef<'a, &mut SubDevice>,
) -> Result<(), anyhow::Error> {
let code_word = match subdevice.sdo_read::<u32>(BECKHOFF_CODEWORD_INDEX, 0).await {
Ok(code_word) => code_word,
// This happens when the subdevice has no mailbox
// There is NO check in ethercrab for Mailbox presence, so we just have to pray that it has one and send a request
// If there is no Mailbox to write Coe Request to, then there is also no EEPROM meaning we achieved our goal in a sense
// This is why it returns OK on an error for sdo_read
Err(_) => return Ok(()),
};
let eeprom_lock_toggled = match code_word {
BECKHOFF_EEPROM_LOCK_CODEWORD => true,
_ => false,
};
if !eeprom_lock_toggled {
subdevice
.sdo_write(BECKHOFF_CODEWORD_INDEX, 0, BECKHOFF_EEPROM_LOCK_CODEWORD)
.await?;
tracing::info!(
"Activated EEPROM Lock for {} {}",
subdevice.name(),
subdevice.name()
);
}
Ok(())
}
pub async fn set_beckhoff_eeprom_lock_active<'a>(
subdevice: &SubDeviceRef<'a, &SubDevice>,
) -> Result<(), anyhow::Error> {
let code_word = match subdevice.sdo_read::<u32>(BECKHOFF_CODEWORD_INDEX, 0).await {
Ok(code_word) => code_word,
// This happens when the subdevice has no mailbox
// There is NO check in ethercrab for Mailbox presence, so we just have to pray that it has one and send a request
// If there is no Mailbox to write Coe Request to, then there is also no EEPROM meaning we achieved our goal in a sense
// This is why it returns OK on an error for sdo_read
Err(_) => return Ok(()),
};
let eeprom_lock_toggled = match code_word {
BECKHOFF_EEPROM_LOCK_CODEWORD => true,
_ => false,
};
if !eeprom_lock_toggled {
subdevice
.sdo_write(BECKHOFF_CODEWORD_INDEX, 0, BECKHOFF_EEPROM_LOCK_CODEWORD)
.await?;
tracing::info!(
"Activated EEPROM Lock for {} {}",
subdevice.name(),
subdevice.name()
);
}
Ok(())
}
-13
View File
@@ -1,13 +0,0 @@
# Git Hooks
This directory contains Git hooks for the project.
## Installation
To install the hooks, run the following command from the repository root:
```bash
# Copy the pre-commit hook to your local .git/hooks directory
cp hooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
```
-49
View File
@@ -1,49 +0,0 @@
#!/bin/sh
#
# Pre-commit hook to generate Rust documentation
#
# This hook runs the rustdoc.sh script to update documentation
# before each commit.
echo "Running rustdoc generation..."
# Get the repository root
REPO_ROOT="$(git rev-parse --show-toplevel)"
# iteralte all directories thst contain a Cargo.toml file
for dir in $(find "$REPO_ROOT" -name "Cargo.toml" -exec dirname {} \;); do
echo "Generating documentation in $dir"
# cargotoml conatains workspace members, skip this directory
# HAS_MEMBERS=$(grep -c '\[workspace\]' "$dir/Cargo.toml")
# if [ "$HAS_MEMBERS" -gt 0 ]; then
# echo "Skipping workspace directory $dir"
# continue
# fi
# get package name from Cargo.toml using cargo metadata
PACKAGE_NAME=$(cargo metadata --format-version 1 --no-deps --manifest-path "$dir/Cargo.toml" | jq -r '.packages[0].name')
if [ -z "$PACKAGE_NAME" ]; then
echo "Could not determine package name for $dir"
continue
fi
# go into the directory
cd "$dir" || continue
# generate the documentation using rustdoc-md
cargo install rustdoc-md --git https://github.com/qitechgmbh/rustdoc-md --rev 6dc34f9c9d878799d9c1b4f2d2bcbb1f2757ede7
RUSTC_BOOTSTRAP=1 RUSTDOCFLAGS="-Z unstable-options --output-format json" cargo doc --no-deps
rustdoc-md --path "${REPO_ROOT}/target/doc/${PACKAGE_NAME}.json" --output ./rustdoc.md
# stage the generated documentation file
git add "rustdoc.md"
# go back to the repository root
cd "$REPO_ROOT" || exit
done
git rm --cached rustdoc.md 2>/dev/null
rm rustdoc.md
exit 0
+4 -3
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
ethercrab = "0.6"
ethercrab = { git = "https://github.com/v-morlock/ethercrab", branch = "main" }
ethercat_hal = { path = "../ethercat-hal" }
control_core = { path = "../control-core" }
control_core_derive = { path = "../control-core-derive" }
@@ -15,13 +15,13 @@ anyhow = "1.0.100"
bitvec = "1.0.1"
lazy_static = "1.5.0"
euclid = "0.22.11"
euclid = "0.22.13"
# concurrency
smol = "2.0.2"
# web
serde_json = "1.0.143"
serde_json = "1.0.149"
socketioxide = { version = "0.17.2", features = ["msgpack"] }
# serial
@@ -40,6 +40,7 @@ default = ["tracing-fmt"]
never_type = []
tracing-fmt = []
mock-machine = []
gluetex-mock = []
memory-locking = []
io-uring = []
development-build = []
+7 -3
View File
@@ -1,7 +1,6 @@
use crate::{
MachineNewHardware, MachineNewParams, MachineNewTrait, get_ethercat_device,
get_subdevice_by_index, validate_no_role_dublicates,
validate_same_machine_identification_unique,
validate_no_role_dublicates, validate_same_machine_identification_unique,
};
use super::{
@@ -92,7 +91,12 @@ impl MachineNewTrait for AquaPathV1 {
pdo_assignment: EL5152PredefinedPdoAssignment::Frequency,
..Default::default()
};
let subdevice = get_subdevice_by_index(hardware.subdevices, 4)?;
let subdevice =
get_ethercat_device::<EL5152>(hardware, params, 4, [EL5152_IDENTITY_A].to_vec())
.await?
.1;
el5152
.write()
.await
+4 -4
View File
@@ -159,7 +159,7 @@ impl MachineNewTrait for ExtruderV3 {
digital_out_1,
Heating::default(),
Duration::from_millis(500),
700.0,
900.0,
1.0,
);
@@ -174,7 +174,7 @@ impl MachineNewTrait for ExtruderV3 {
digital_out_2,
Heating::default(),
Duration::from_millis(500),
700.0,
900.0,
1.0,
);
@@ -189,7 +189,7 @@ impl MachineNewTrait for ExtruderV3 {
digital_out_3,
Heating::default(),
Duration::from_millis(500),
700.0,
900.0,
1.0,
);
@@ -204,7 +204,7 @@ impl MachineNewTrait for ExtruderV3 {
digital_out_4,
Heating::default(),
Duration::from_millis(500),
200.0,
150.0,
0.95,
);
+128
View File
@@ -0,0 +1,128 @@
use super::Gluetex;
use crate::{MachineAct, MachineMessage, MachineValues};
use std::time::{Duration, Instant};
impl MachineAct for Gluetex {
fn act(&mut self, now: Instant) {
let machine_message = self.api_receiver.try_recv();
match machine_message {
Ok(machine_message) => self.act_machine_message(machine_message),
Err(_e) => (),
};
// sync the spool speed
self.sync_spool_speed(now);
// sync the puller speed
self.sync_puller_speed(now);
// sync the slave puller speed
self.sync_slave_puller_speed(now);
// record optris voltage readings with distance tracking for delayed readings
self.record_optris_voltages(now);
// sync valve
self.sync_valve(now);
// sync addon motor speeds
self.sync_addon_motor_3_speed(now);
self.sync_addon_motor_4_speed(now);
self.sync_addon_motor_5_speed(now);
// sync the traverse speed
self.sync_traverse_speed();
// automatically stops or pulls after N Meters if enabled
self.stop_or_pull_spool(now);
// check tension arm positions and trigger emergency stop if needed
self.check_tension_arm_monitor();
// check voltage monitors and trigger emergency stop if needed
self.check_voltage_monitors();
// check sleep timer and enter standby if inactive for too long
self.check_sleep_timer(now);
// update all temperature controllers
self.temperature_controller_1.update(now);
self.temperature_controller_2.update(now);
self.temperature_controller_3.update(now);
self.temperature_controller_4.update(now);
self.temperature_controller_5.update(now);
self.temperature_controller_6.update(now);
// update status output based on current activity
self.update_status_output();
// Check for completed auto-tuning and emit results
self.check_autotuning_results();
if self.traverse_controller.did_change_state() {
self.emit_state();
self.last_state_emit = now;
}
let autotuning_active = self.temperature_controller_1.is_autotuning()
|| self.temperature_controller_2.is_autotuning()
|| self.temperature_controller_3.is_autotuning()
|| self.temperature_controller_4.is_autotuning()
|| self.temperature_controller_5.is_autotuning()
|| self.temperature_controller_6.is_autotuning();
// Emit state regularly when autotuning or when sleep timer is enabled
let should_emit_state = autotuning_active || self.sleep_timer.config.enabled;
if should_emit_state
&& now.duration_since(self.last_state_emit) > Duration::from_millis(500)
{
self.emit_state();
self.last_state_emit = now;
}
// 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.emit_live_values();
self.last_measurement_emit = now;
}
}
fn act_machine_message(&mut self, msg: MachineMessage) {
match msg {
MachineMessage::SubscribeNamespace(namespace) => {
self.namespace.namespace = Some(namespace);
self.emit_state();
}
MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
MachineMessage::HttpApiJsonRequest(value) => {
use crate::MachineApi;
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(machine_connection) => {
if self.connected_machines.len() >= self.max_connected_machines {
tracing::debug!(
"Refusing to add Machine Connection {:?}, since self.connected_machines would be over the limit of {:?}",
machine_connection,
self.max_connected_machines
);
return;
}
self.connected_machines.push(machine_connection);
}
MachineMessage::DisconnectMachine(_machine_connection) => {
self.connected_machines.clear();
}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,461 @@
use crate::gluetex::controllers::{
puller_speed_controller::PullerSpeedController, tension_arm::TensionArm,
};
use crate::gluetex::features::{
clamp_revolution::Clamping, clamp_revolution::clamp_revolution_uom,
filament_tension::FilamentTensionCalculator,
};
use control_core::{
controllers::first_degree_motion::angular_acceleration_speed_controller::AngularAccelerationSpeedController,
helpers::{interpolation::scale, moving_time_window::MovingTimeWindow},
};
use core::f64;
use std::time::Instant;
use units::ConstZero;
use units::angle::degree;
use units::angular_acceleration::radian_per_second_squared;
use units::angular_velocity::{radian_per_second, revolution_per_minute};
use units::f64::*;
use units::length::{centimeter, meter};
use units::velocity::meter_per_second;
/// Adaptive spool speed controller that automatically adjusts to maintain optimal filament tension.
///
/// This controller monitors filament tension via the tension arm and learns the appropriate
/// maximum speed based on puller speed and tension feedback. It uses closed-loop control
/// to minimize tension error and applies smooth acceleration to prevent sudden motor commands.
#[derive(Debug)]
pub struct AdaptiveSpoolSpeedController {
/// Last commanded angular velocity sent to the spool motor
last_speed: AngularVelocity,
/// Whether the speed controller is enabled (false = always returns zero speed)
enabled: bool,
/// Acceleration controller to smooth speed transitions and prevent sudden changes
acceleration_controller: AngularAccelerationSpeedController,
/// Calculator for converting tension arm angle to normalized filament tension
filament_calc: FilamentTensionCalculator,
/// Moving window of recent speeds (in rad/s) used for dynamic acceleration limit calculation
speed_time_window: MovingTimeWindow<f64>,
/// Factor to control/calculate the feed speed based on filament tension
speed_factor: Length,
/// Timestamp of last max speed factor update, used for time-aware learning rate calculation
last_max_speed_factor_update: Option<Instant>,
/// Target normalized tension value (0.0-1.0) that the controller tries to maintain
tension_target: f64,
/// Proportional control gain for adaptive learning (negative: higher tension reduces speed)
radius_learning_rate: f64,
/// Speed multiplier when tension is at minimum (max speed factor)
max_speed_multiplier: f64,
/// Base acceleration as a fraction of max possible speed (per second)
acceleration_factor: f64,
/// Urgency multiplier for near-zero target speeds
deacceleration_urgency_multiplier: f64,
}
impl Default for AdaptiveSpoolSpeedController {
fn default() -> Self {
Self::new()
}
}
impl AdaptiveSpoolSpeedController {
/// Maximum speed limit (in RPM) used to initialize the acceleration controller
const INITIAL_MAX_SPEED_RPM: f64 = 150.0;
/// Absolute safety limit (in RPM) that the spool speed can never exceed to protect hardware
const SAFETY_MAX_SPEED_RPM: f64 = 600.0;
/// Time window duration (in seconds) for tracking recent speed history in acceleration calculations
const SPEED_WINDOW_DURATION_SECS: u64 = 5;
/// Maximum number of speed samples stored in the moving time window
const SPEED_WINDOW_MAX_SAMPLES: usize = 10;
/// Maximum angle (in degrees) of the tension arm, representing loosest filament tension
const TENSION_ARM_MAX_ANGLE_DEG: f64 = 90.0;
/// Minimum angle (in degrees) of the tension arm, representing tightest filament tension
const TENSION_ARM_MIN_ANGLE_DEG: f64 = 20.0;
/// Target normalized tension value (0.0-1.0) that the controller tries to maintain
const TENSION_TARGET: f64 = 0.7;
/// Proportional control gain for adaptive learning (negative: higher tension reduces speed)
const RADIUS_LEARNING_RATE: f64 = 0.5;
const FACTOR_MIN: f64 = 4.25;
const FACTOR_MAX: f64 = 20.0;
/// if the tension is the lowest, the speed can be up to 2x the puller speed
const MAX_SPEED_MULTIPLIER: f64 = 4.0;
/// Base acceleration as a fraction of max possible speed (per second)
const ACCELERATION_FACTOR: f64 = 0.2; // 20% of max speed per second
/// Urgency multiplier for near-zero target speeds
const DEACCELERATION_URGENCY_MULTIPLIER: f64 = 15.0;
/// Minimum acceleration limit to prevent completely frozen motion
const MIN_ACCELERATION_LIMIT: f64 = 0.5; // rad/s²
/// Creates a new adaptive spool speed controller with default settings.
///
/// Initializes all internal components and sets baseline parameters.
///
/// # Returns
/// A new `AdaptiveSpoolSpeedController` instance ready for use.
pub fn new() -> Self {
let max_speed = AngularVelocity::new::<revolution_per_minute>(Self::INITIAL_MAX_SPEED_RPM);
Self {
last_speed: AngularVelocity::ZERO,
enabled: false,
acceleration_controller: AngularAccelerationSpeedController::new(
Some(AngularVelocity::ZERO),
Some(max_speed),
-AngularAcceleration::ZERO, // Will be dynamically adjusted
AngularAcceleration::ZERO, // Will be dynamically adjusted
AngularVelocity::ZERO,
),
filament_calc: FilamentTensionCalculator::new(
Angle::new::<degree>(Self::TENSION_ARM_MAX_ANGLE_DEG),
Angle::new::<degree>(Self::TENSION_ARM_MIN_ANGLE_DEG),
),
speed_time_window: MovingTimeWindow::new(
std::time::Duration::from_secs(Self::SPEED_WINDOW_DURATION_SECS),
Self::SPEED_WINDOW_MAX_SAMPLES,
),
speed_factor: Length::new::<centimeter>(4.25),
last_max_speed_factor_update: None,
tension_target: Self::TENSION_TARGET,
radius_learning_rate: Self::RADIUS_LEARNING_RATE,
max_speed_multiplier: Self::MAX_SPEED_MULTIPLIER,
acceleration_factor: Self::ACCELERATION_FACTOR,
deacceleration_urgency_multiplier: Self::DEACCELERATION_URGENCY_MULTIPLIER,
}
}
/// Calculates the current maximum speed based on puller speed and learned factor.
///
/// Uses the fundamental relationship between linear and angular velocity:
/// ω = (v / r) × multiplier
///
/// Where:
/// - ω (omega) = angular velocity in radians per second
/// - v = linear velocity in meters per second
/// - r = radius in meters
/// - multiplier = MAX_SPEED_MULTIPLIER for speed scaling
///
/// This ensures the spool's rim speed is properly scaled relative to the puller's
/// linear speed, with the multiplier allowing for tension control adjustments.
///
/// # Parameters
/// - `puller_speed_controller`: Reference to puller for baseline speed calculation
///
/// # Returns
/// Current maximum angular velocity for the spool in radians per second
fn get_max_speed(&self, puller_speed_controller: &PullerSpeedController) -> AngularVelocity {
AngularVelocity::new::<radian_per_second>(
(puller_speed_controller.last_speed.get::<meter_per_second>()
/ self.speed_factor.get::<meter>())
* self.max_speed_multiplier,
)
}
/// Calculates the desired spool speed based on filament tension feedback.
///
/// Reads the tension arm angle, converts it to normalized tension, and calculates
/// the appropriate speed. Also updates the learned maximum speed based on tension error.
///
/// # Parameters
/// - `t`: Current timestamp for learning updates
/// - `tension_arm`: Reference to tension arm for angle measurements
/// - `puller_speed_controller`: Reference to puller for baseline speed calculations
///
/// # Returns
/// The calculated target angular velocity for the spool motor
fn calculate_speed(
&mut self,
t: Instant,
tension_arm: &TensionArm,
puller_speed_controller: &PullerSpeedController,
) -> AngularVelocity {
let min_speed = AngularVelocity::ZERO;
let max_speed = self.get_max_speed(puller_speed_controller).abs();
// Calculate filament tension from arm angle
let tension_arm_angle = tension_arm.get_angle();
let (clamped_angle, clamping_state) = clamp_revolution_uom(
tension_arm_angle,
self.filament_calc.get_max_angle(), // Inverted because min angle = max tension
self.filament_calc.get_min_angle(),
);
// Return minimum speed if tension arm is at limits
if matches!(clamping_state, Clamping::Min | Clamping::Max) {
return min_speed;
}
// 1.0 means maximum tension (high angle, low speed)
// 0.0 means minimum tension (low angle, high speed)
let filament_tension = self.filament_calc.calc_filament_tension(clamped_angle);
self.update_speed_factor(filament_tension, t);
// Calculate speed based on inverted tension (lower tension = higher speed)
AngularVelocity::new::<radian_per_second>(scale(
1.0 - filament_tension,
min_speed.get::<radian_per_second>(),
max_speed.get::<radian_per_second>(),
))
}
/// Simplified urgency-weighted acceleration that adapts to current operating conditions.
///
/// This approach uses the current max speed (calculated from puller speed and learned diameter)
/// as the basis for acceleration limits, ensuring smooth operation across all speed ranges
/// while providing instant deceleration capability when approaching zero.
///
/// # Parameters
/// - `target_speed`: Target speed to accelerate towards
/// - `puller_speed_controller`: Reference to puller for current max speed calculation
/// - `t`: Current timestamp for the acceleration controller
///
/// # Returns
/// Smoothly accelerated speed with urgency-weighted limits
fn accelerate_speed(
&mut self,
target_speed: AngularVelocity,
puller_speed_controller: &PullerSpeedController,
t: Instant,
) -> AngularVelocity {
let target_speed_rad_s = target_speed.get::<radian_per_second>();
let current_max_speed = self.get_max_speed(puller_speed_controller);
let max_speed_rad_s = current_max_speed.get::<radian_per_second>();
// Base acceleration proportional to current max operating speed
let base_acceleration = max_speed_rad_s * self.acceleration_factor;
// Simple urgency factor - dramatically increases near zero
let urgency_factor = if target_speed_rad_s.abs() < 0.1 {
self.deacceleration_urgency_multiplier * (1.0 / (target_speed_rad_s.abs() + 0.01))
} else {
1.0
};
// Final acceleration limit
let acceleration_limit =
(base_acceleration * urgency_factor).max(Self::MIN_ACCELERATION_LIMIT);
let acceleration =
AngularAcceleration::new::<radian_per_second_squared>(acceleration_limit);
// Update acceleration controller limits
self.acceleration_controller
.set_max_acceleration(acceleration);
self.acceleration_controller
.set_min_acceleration(-acceleration);
let new_speed = self.acceleration_controller.update(target_speed, t);
// Record for diagnostics
self.speed_time_window
.update(new_speed.get::<radian_per_second>(), t);
new_speed
}
/// Safety function that enforces absolute speed limits.
///
/// Ensures the spool speed never exceeds safe operational limits to protect hardware.
/// Acts as a final safety check after all other speed calculations.
///
/// # Parameters
/// - `speed`: The speed to be safety-checked and clamped
///
/// # Returns
/// Speed guaranteed to be within safe operational limits
fn clamp_speed(&self, speed: AngularVelocity) -> AngularVelocity {
let min_speed = AngularVelocity::ZERO;
let max_speed = AngularVelocity::new::<revolution_per_minute>(Self::SAFETY_MAX_SPEED_RPM);
speed.max(min_speed).min(max_speed)
}
/// Adaptive learning algorithm that adjusts maximum speed factor based on tension feedback.
///
/// Uses proportional control to adjust the speed factor based on tension error.
/// The controller learns over time to maintain optimal tension by scaling the
/// baseline speed derived from puller speed.
///
/// # Parameters
/// - `filament_tension`: Normalized tension value
/// - `t`: Current timestamp for time-aware learning rate
fn update_speed_factor(&mut self, filament_tension: f64, t: Instant) {
let delta_t = match self.last_max_speed_factor_update {
Some(last_update) => t.duration_since(last_update).as_secs_f64(),
None => {
// First call, initialize and return early
self.last_max_speed_factor_update = Some(t);
return;
}
};
// positive error means too much tension, so we reduce speed
// negative error means too little tension, so we increase speed
let tension_error = filament_tension - self.tension_target;
// Calculate proportional control adjustment
let proportional_gain = self.radius_learning_rate * delta_t;
let factor_change = tension_error * proportional_gain;
// Update the speed factor directly
let new_factor = (self.speed_factor.get::<centimeter>() + factor_change)
.clamp(Self::FACTOR_MIN, Self::FACTOR_MAX);
self.speed_factor = Length::new::<centimeter>(new_factor); // Convert to cm
self.last_max_speed_factor_update = Some(t);
}
/// Main update function that orchestrates the complete speed control pipeline.
///
/// This is the primary interface that calculates the target speed, applies enable/disable
/// logic, smooths speed changes, and applies safety limits.
///
/// Call this function every control loop iteration to get the commanded spool speed.
///
/// # Parameters
/// - `t`: Current timestamp for time-based calculations
/// - `tension_arm`: Reference to tension arm for feedback
/// - `puller_speed_controller`: Reference for adaptive speed scaling
///
/// # Returns
/// Final commanded angular velocity for the spool motor
pub fn update_speed(
&mut self,
t: Instant,
tension_arm: &TensionArm,
puller_speed_controller: &PullerSpeedController,
) -> AngularVelocity {
let target_speed = self.calculate_speed(t, tension_arm, puller_speed_controller);
let enabled_speed = if self.enabled {
target_speed
} else {
AngularVelocity::ZERO
};
let accelerated_speed = self.accelerate_speed(enabled_speed, puller_speed_controller, t);
// Store speed before clamping to preserve the actual commanded value
self.last_speed = accelerated_speed;
self.clamp_speed(accelerated_speed)
}
/// Enables or disables the speed controller.
///
/// When disabled, the controller returns zero speed regardless of tension feedback.
///
/// # Parameters
/// - `enabled`: True to enable speed control, false to disable
pub const fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
/// Returns whether the speed controller is currently enabled.
///
/// # Returns
/// True if the controller is enabled, false if disabled
pub const fn is_enabled(&self) -> bool {
self.enabled
}
/// Resets the controller to initial state, clearing all learned parameters.
///
/// Use this when starting a new winding operation or after significant
/// process changes that would invalidate learned parameters.
pub fn reset(&mut self) {
self.last_speed = AngularVelocity::ZERO;
self.acceleration_controller.reset(AngularVelocity::ZERO);
self.speed_factor = Length::new::<centimeter>(4.25);
self.last_max_speed_factor_update = None;
self.tension_target = Self::TENSION_TARGET;
self.radius_learning_rate = Self::RADIUS_LEARNING_RATE;
self.max_speed_multiplier = Self::MAX_SPEED_MULTIPLIER;
self.acceleration_factor = Self::ACCELERATION_FACTOR;
self.deacceleration_urgency_multiplier = Self::DEACCELERATION_URGENCY_MULTIPLIER;
}
/// Returns the last commanded speed from the controller.
///
/// # Returns
/// The angular velocity that was last commanded to the spool motor
pub fn get_speed(&self) -> AngularVelocity {
self.last_speed
}
/// Manually sets the current speed, bypassing normal calculation.
///
/// Use this for manual control or when switching control modes to ensure
/// smooth transitions.
///
/// # Parameters
/// - `speed`: The angular velocity to set as the current speed
pub fn set_speed(&mut self, speed: AngularVelocity) {
self.last_speed = speed;
self.acceleration_controller.reset(speed);
}
pub fn get_speed_factor(&self) -> Length {
self.speed_factor
}
// Getters and setters for the new configurable parameters
pub const fn get_tension_target(&self) -> f64 {
self.tension_target
}
pub const fn set_tension_target(&mut self, tension_target: f64) {
self.tension_target = tension_target.clamp(0.0, 1.0);
}
pub const fn get_radius_learning_rate(&self) -> f64 {
self.radius_learning_rate
}
pub const fn set_radius_learning_rate(&mut self, radius_learning_rate: f64) {
self.radius_learning_rate = radius_learning_rate.max(0.0);
}
pub const fn get_max_speed_multiplier(&self) -> f64 {
self.max_speed_multiplier
}
pub const fn set_max_speed_multiplier(&mut self, max_speed_multiplier: f64) {
self.max_speed_multiplier = max_speed_multiplier.max(0.1);
}
pub const fn get_acceleration_factor(&self) -> f64 {
self.acceleration_factor
}
pub const fn set_acceleration_factor(&mut self, acceleration_factor: f64) {
self.acceleration_factor = acceleration_factor.clamp(0.01, 1.0);
}
pub const fn get_deacceleration_urgency_multiplier(&self) -> f64 {
self.deacceleration_urgency_multiplier
}
pub const fn set_deacceleration_urgency_multiplier(
&mut self,
deacceleration_urgency_multiplier: f64,
) {
self.deacceleration_urgency_multiplier = deacceleration_urgency_multiplier.max(1.0);
}
}
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff. Show more