Compare commits

...
Author SHA1 Message Date
Robin Krämer 5fbdf7b9ab Merge pull request #1053 from qitechgmbh/fix-ethercat-devices-event
fixed broken schema for ethercat devices event
2026-01-20 13:09:02 +01:00
kraemr 85bd4b0d96 fixed formatting 2026-01-20 12:59:39 +01:00
kraemr dfce760521 fixed validation error 2026-01-20 12:58:15 +01:00
kraemr 30c97bf9aa fixed formatting 2026-01-19 16:00:00 +01:00
Oshgnacknak 2c8373adcf Fix rust enum zod check 2026-01-19 15:54:52 +01:00
kraemr d08972254c fixed broken schema for ethercat devices event 2026-01-19 15:12:43 +01:00
Christian a76d39e0cd Merge pull request #1016 from qitechgmbh/1015-fix-xlsx-vulnerabilities
Update xlsx dependency to version 0.20.3 for improved security
2026-01-19 13:23:34 +01:00
Christian a74d0dbb80 Set npmDepsHash for Electron package 2026-01-18 16:57:30 +01:00
Christian 17ab24739d Remove npmDepsHash for improved compatibility 2026-01-18 15:21:11 +01:00
Christian 09f0ea3454 Updated package-lock 2026-01-18 15:00:50 +01:00
Christian 0dd2494ea0 Update xlsx dependency to version 0.20.3 for improved security 2026-01-17 11:41:48 +01:00
Robin Krämer 57c1cda229 Merge pull request #1040 from qitechgmbh/1001-rest-api
Rest API /api/v2
2026-01-15 17:06:34 +01:00
Oshgnacknak 8c2b20b148 Rest API: Docs 2026-01-15 16:57:09 +01:00
Oshgnacknak 12e7f245ce Rest API: Allow posting mutations to each machine 2026-01-15 16:57:09 +01:00
Oshgnacknak 671e36fa0f Rest API: Getting values from all machines 2026-01-15 16:57:09 +01:00
Oshgnacknak 1ffd7eade6 Rest API: Getting values from all mock machines 2026-01-15 16:57:09 +01:00
Oshgnacknak 703bbfc9e6 Rest API: Get mock winder v1 values 2026-01-15 16:57:09 +01:00
Oshgnacknak 057afb0b2e List machines in rest api 2026-01-15 16:57:09 +01:00
Robin Krämer 94bd29c734 Merge pull request #1048 from qitechgmbh/zod4
Upgrade to zod v4
2026-01-15 11:37:41 +01:00
Oshgnacknak d078b7b143 Fix nix hash 2026-01-14 22:03:07 +01:00
dependabot[bot] acbeb98f4f Upgrade to zod v4.
To make this work, I lot if typescript types had to be fixed.
In general, they became a little simpler, so the update is worth it.
Also removes zod-adapter and upgrades zod-validation-error to work with zod v4.

From depbot:

Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.3.4.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.76...v4.3.4)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.3.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-14 21:51:23 +01:00
Robin Krämer cab8bf062d Merge pull request #1046 from qitechgmbh/1038-implement-wago-750-652-driver
added implementation for wago 750-652 serial
2026-01-14 14:45:28 +01:00
kraemr 9e04a7fa33 added implementation for wago 750-652 serial 2026-01-14 14:12:01 +01:00
Robin Krämer c92d4847ca Merge pull request #1045 from qitechgmbh/impl-wago-530-driver
added Wago750_530 implementation
2026-01-14 11:57:57 +01:00
kraemr aceeaa08c2 fixed formatting 2026-01-14 11:09:36 +01:00
kraemr 35946d0c88 added Wago750_530 implementation 2026-01-14 11:03:30 +01:00
Oshgnacknak a5b9a0b1d5 Merge pull request #1039 from qitechgmbh/883-implement-modbus-tcp-for-wago-2789-9052
Fix modbus discovery
2026-01-11 23:19:10 +01:00
Oshgnacknak 412d625138 Fix modbus discovery 2026-01-09 18:12:33 +01:00
Oshgnacknak 1ec20074e9 Rewrite doc for renderUnitSyntax (#981) 2026-01-08 12:22:28 +01:00
4d796f8422 Implement Wago Power Supply Machine (#915)
* Add timeout to Modbus TCP connection

* Remove machine new trait from Machine trait

Machines should not need to know, how they where created.
Only in the machine registry do we assume we know the constructor.

* Initialize very basic WAGO machine

* Most basic WAGO power page

* Basic machine now known its namespace

* Display values from namespace

* Read live values from wago power supply

* Allow turning power on and off

* Allow setting mode

* Actually set registers correctly

* Show error if mutation fails

* Turn power on/off in UI

* Formatting

* Use actual serial number from device

* Fix loop logic

* Enable dhcp and dns server on lan interface

* Loop until modbus device has been found

* Format

* Fix time series creation

* Convert asserts into Err values

This is to make sure that the device cannot panic our backend.

---------

Co-authored-by: nixos-install.sh <nixos-install.sh@localhost>
Co-authored-by: Robin Krämer <93094049+kraemr@users.noreply.github.com>
2026-01-08 12:01:18 +01:00
Robin Krämer 1406d6c724 Merge pull request #1035 from qitechgmbh/changelog-2.13
added ChangeLog
2026-01-07 18:34:00 +01:00
rob 33441b95a5 added ChangeLog 2026-01-07 18:21:09 +01:00
kraemr 8a3f312675 Revert "Merge pull request #943 from qitechgmbh/892-add-datasheets-to-the-machines-manual-tab"
This reverts commit 40e17ef5f1, reversing
changes made to f65d100cd5.
2026-01-07 18:01:14 +01:00
Robin Krämer 5c327428d6 Merge pull request #1034 from qitechgmbh/1032-fix-metrics-page
1032 fix metrics page
2026-01-07 15:57:12 +01:00
rob 4e4d31cad1 changed metrics for frontend 2026-01-07 15:51:59 +01:00
rob 031fdf25a9 changed minor_faults to per sec in frontend 2026-01-07 15:33:06 +01:00
Oshgnacknak 857cda1455 Merge pull request #1013 from qitechgmbh/Oshgnacknak-patch-2
Update nixos docs
2026-01-07 15:17:21 +01:00
OshgnacknakandThomas Symalla de5f3a5cfc Update docs/nixos/README.md
Co-authored-by: Thomas Symalla <github@thomassymalla.de>
2026-01-07 13:23:47 +01:00
Oshgnacknak 2da7af522c Update nixos docs 2026-01-07 13:23:47 +01:00
Robin Krämer 712284e2f0 Merge pull request #1030 from qitechgmbh/fixes-2.13
Fixes 2.13
2026-01-07 13:16:01 +01:00
rob 402c47a81b removed need for el5152 hack, aquapath now usable with other machines 2026-01-06 16:49:34 +01:00
rob 54a000a280 fixed formatting 2026-01-06 16:49:34 +01:00
rob 41fe118144 removed print again 2026-01-06 16:49:34 +01:00
rob aaa6ff604f fixded unnecessary additional version in machine name 2026-01-06 16:49:34 +01:00
rob a350bb378f added debug print 2026-01-06 16:49:34 +01:00
rob aa2212820e fixed ethercat setup 2026-01-06 16:49:34 +01:00
Robin Krämer 578e70119d Merge pull request #1025 from qitechgmbh/dependabot/cargo/tracing-0.1.44
Bump tracing from 0.1.41 to 0.1.44
2026-01-06 16:06:29 +01:00
dependabot[bot] e8796e00af Bump tracing from 0.1.41 to 0.1.44
Bumps [tracing](https://github.com/tokio-rs/tracing) from 0.1.41 to 0.1.44.
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-0.1.41...tracing-0.1.44)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-05 10:23:54 +01:00
Christian 735f27770a Merge pull request #1019 from qitechgmbh/615-test-nixos-builds-in-ci-cd
Rework github pipeline
2026-01-05 07:17:47 +01:00
Christian 700a255157 Merge pull request #971 from qitechgmbh/946-create-docs-for-xtrem-protocol
Added docs
2026-01-05 07:12:08 +01:00
nixos-install.sh ec06275c08 Split and merge nix stores 2026-01-04 23:02:25 +00:00
nixos-install.sh 11d0c8bb53 Add lock files to nix store cache hash 2026-01-04 22:04:46 +00:00
nixos-install.sh 535f24f1d1 Add derivations to store so they appear in the cache 2026-01-04 20:57:15 +00:00
nixos-install.sh 172913d8ec Fix server.nix dependency caching 2026-01-04 20:16:42 +00:00
nixos-install.sh 0f99c54587 Format Nix Files 2026-01-04 18:34:03 +00:00
nixos-install.sh f89d70c5f6 Build whole nix flake in ci 2026-01-04 18:32:15 +00:00
Oshgnacknak 0fc070be69 Build rust in nix inside ci 2026-01-04 18:27:46 +00:00
Oshgnacknak b93eaf530e Build electron in nix inside ci 2026-01-04 18:27:46 +00:00
Oshgnacknak 76d8504651 Redo electron nix build 2026-01-04 18:27:46 +00:00
Oshgnacknak 06acd43ff9 Replace electron forge with vite-plugin-electron 2026-01-04 18:27:46 +00:00
Oshgnacknak 870090c950 Split rust build jobs.
Also use existing rustup action.
2026-01-04 18:27:46 +00:00
Oshgnacknak 65777f10ab Split electron build jobs 2026-01-04 18:27:46 +00:00
Oshgnacknak 67d145de56 Don't run electron workflow twice.
Only run on master push and on pulls to master.
Also use setup-node's built-in cache.
2026-01-04 18:27:46 +00:00
93094049+kraemr@users.noreply.github.com d45d1ed01a test 2026-01-04 18:27:46 +00:00
93094049+kraemr@users.noreply.github.com 0d6c7bc0a5 fixed incorrect path 2026-01-04 18:27:46 +00:00
93094049+kraemr@users.noreply.github.com 15f013f710 fix path 2026-01-04 18:27:46 +00:00
93094049+kraemr@users.noreply.github.com a32e26a041 stubbed hardware config 2026-01-04 18:27:46 +00:00
93094049+kraemr@users.noreply.github.com 41c7e1f74e ci/cd fix 2026-01-04 18:27:46 +00:00
93094049+kraemr@users.noreply.github.com 5a856e6e2b added --impure flag 2026-01-04 18:27:46 +00:00
93094049+kraemr@users.noreply.github.com 8ca16d447e removed buid from nix workflow 2026-01-04 18:27:46 +00:00
93094049+kraemr@users.noreply.github.com b99549895b added nixos test 2026-01-04 18:27:46 +00:00
Christian 07cbaa4518 Merge pull request #1017 from qitechgmbh/fix-nixos-build
Fix nixos electron build
2026-01-02 18:55:07 +01:00
Oshgnacknak c78f5ff5a0 Fix compile warnings 2026-01-02 11:06:23 +01:00
nixos-install.sh 33946a6fa3 Fix nixos electron build 2026-01-02 11:06:23 +01:00
Milan von dem Bussche 02dc67bb85 Revise QiTech Control main description in README
Expanded description of QiTech Control to highlight its open-source nature and modern software paradigms.
2025-12-31 14:43:12 +01:00
Christian b5ae86b19c Merge pull request #988 from qitechgmbh/982-createtimeseries-is-unreadable
refactor of createTimeSeries
2025-12-28 13:41:42 +01:00
Christian 35567b967f Fix analog input test machine issue after rebasing 2025-12-28 13:16:41 +01:00
Hamid-el c45718e670 refactor 2025-12-28 12:26:05 +01:00
Hamid-el 74dcd027e0 refactor of createTimeSeries 2025-12-28 12:26:05 +01:00
Oshgnacknak c793d3fa50 Merge pull request #1012 from qitechgmbh/980-aqua-path-change-max--temp-to-80
Added Min/Max value for AquaPath target temperature
2025-12-25 23:46:06 +01:00
Snacj 04fdc8a9d1 Added Min/Max value for AquaPath target temperature 2025-12-25 23:32:38 +01:00
Oshgnacknak fd702ff0bc Merge pull request #973 from qitechgmbh/948-change-default-settings-graph-dre
Move roundness into separate graph in DRE 

Fixes #948
2025-12-25 22:17:00 +01:00
Hamid-el f25bf71753 added english comments 2025-12-25 22:10:40 +01:00
Hamid-el 4c33873095 added english comments 2025-12-25 22:10:40 +01:00
Hamid-el 8dbcb5efc3 english comments 2025-12-25 22:10:40 +01:00
Hamid-el 36426aafe0 turned all comments into english ones 2025-12-25 22:10:40 +01:00
Hamid-el 21cf9314be turned all comments into english ones 2025-12-25 22:10:40 +01:00
Hamid-el 4f2af7b90d turned all comments into english ones 2025-12-25 22:10:40 +01:00
Hamid-el 06795dcf78 finished issue "Change Default settings Graph DRE #948
" (version2)
2025-12-25 22:10:40 +01:00
Hamid-el 1241d0a4a7 finished issue "Change Default settings Graph DRE #948
"
2025-12-25 22:10:40 +01:00
Oshgnacknak f36723f435 Merge pull request #1011 from qitechgmbh/1010-move-minimal-getting-started-machines-to-their-own-doc
Refactor minimal examples to be in their own md, link from readme and add to getting started docs
2025-12-24 22:49:04 +01:00
ChristianandCopilot e96854a6bc Update docs/developer-docs/minimal-example-el3021.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-24 22:47:17 +01:00
Christian 04c5a673da Refactor minimal examples to be in their own md, link from readme and add to getting started docs 2025-12-24 22:47:17 +01:00
Oshgnacknak 40e17ef5f1 Merge pull request #943 from qitechgmbh/892-add-datasheets-to-the-machines-manual-tab
892 add datasheets to the machines manual tab
2025-12-24 22:26:10 +01:00
Christian 39200aac12 Update import path for extruder manual to align with new documentation structure 2025-12-24 22:24:03 +01:00
Christian 43a405eec4 Update extruder manual import path to reflect new documentation structure 2025-12-24 22:24:03 +01:00
Christian 0206a4ddca Refactor Markdown components for improved readability and maintainability 2025-12-24 22:24:03 +01:00
Christian 34da7178f8 Change manual markdown location and add an readme to explain where images are stored. 2025-12-24 22:24:03 +01:00
Christian 2726c47280 Update Images of datasheet 2025-12-24 22:24:03 +01:00
Christian 63cf25b3d6 Add extruder datasheet to manual 2025-12-24 22:24:03 +01:00
Christian eae13207e4 Add winder datasheet 2025-12-24 22:24:03 +01:00
Oshgnacknak f65d100cd5 Merge pull request #1008 from ThatsMeGIT/patch-1
Fix typo in README about EtherCAT master software
2025-12-24 22:14:36 +01:00
ThatsMe 13e4ea1ac7 Fix typo in README about EtherCAT master software 2025-12-24 22:13:49 +01:00
ᴏxᴏ.ᴍɪᴋᴀ 49788246c8 Merge pull request #945 from qitechgmbh/916-when-assigning-the-ethercat-terminals-no-keyboard-is-showing-up
916 when assigning the ethercat terminals no keyboard is showing up
2025-12-24 20:53:25 +01:00
Christian d4e849d588 Merge pull request #1009 from qitechgmbh/1006-implement-minmal-machine-für-ip-20-community-8di8do
1006 implement minmal machine for ip 20 community 8di8do
2025-12-24 11:11:50 +01:00
Christian 2689d19928 Refactor import statements for IP20 EC DI8 DO8 module across multiple files 2025-12-23 16:07:07 +01:00
Christian 968e9327f6 Remove unused u16dump and u16print debug functions from machine identification 2025-12-23 16:04:37 +01:00
Christian 3cbb74b3b0 Refactor IP20 EC DI8 DO8 device integration: move implementation to wago_modules and update module imports 2025-12-23 15:55:53 +01:00
Christian 50fcfd6e30 Refactor IP20 Test Machine control page and improve code formatting 2025-12-23 15:50:52 +01:00
Christian ce0f27787c Refactor error handling and logging in IP20EcDi8Do8 implementation 2025-12-23 15:49:48 +01:00
Christian 08ab7fd404 Implement IP20 Test Machine support with control and namespace handling 2025-12-23 15:39:16 +01:00
Christian f138b61a45 Add support for IP20 EC DI8 DO8 device identification 2025-12-23 15:06:18 +01:00
Christian 0ad0a58d46 Add Wago IP20 EC DI8 DO8 device support and initialization logic 2025-12-23 15:01:35 +01:00
EinsPommes 3d13e6cee2 Refactor DeviceEepromDialog to simplify state management by updating setOpen handler to a no-argument function, enhancing clarity in dialog closure logic. 2025-12-20 16:11:19 +01:00
EinsPommes 86915c2987 Shorten onOpenChange handler in DeviceEepromDialog 2025-12-20 16:09:42 +01:00
EinsPommes ada8d0f12c Refactor numpad handling in DeviceEepromDialog to simplify position updates and improve interaction logic. Removed unnecessary observers and streamlined event handling for opening and closing the numpad. 2025-12-20 15:47:30 +01:00
EinsPommes 735456b6eb Enhance DeviceEepromDialog with improved numpad handling and dialog interactions. Added position tracking for the numpad, preventing dialog closure on outside interactions, and ensuring focus remains on the serial input when the numpad is open. 2025-12-20 15:47:30 +01:00
EinsPommes b46a528adb add aria-label for numpad toggle button in DeviceEepromDialog 2025-12-20 15:47:30 +01:00
EinsPommes 80f2343a60 fix: prettier formatting 2025-12-20 15:47:30 +01:00
EinsPommes e030fbe23e Implement touch numpad for serial input in DeviceEepromDialog 2025-12-20 15:47:30 +01:00
Oshgnacknak 1ef0cb608c Merge pull request #997 from qitechgmbh/996-fix-mock-machines-not-running-under-macos
Fix thread ID retrieval for macOS in start_loop_thread function
2025-12-20 15:46:20 +01:00
Christian 789a84e511 Fix thread ID retrieval for unsupported platforms and windows 2025-12-20 15:22:25 +01:00
ChristianandCopilot 28620b6a23 Update server/src/loop.rs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-20 13:54:26 +01:00
Christian 01609513fb Fix formatting in start_loop_thread function for better readability 2025-12-20 13:48:34 +01:00
Christian 379a4ec417 Fix thread ID retrieval for macOS in start_loop_thread function 2025-12-20 13:47:06 +01:00
Robin Krämer 28d898e780 Merge pull request #927 from qitechgmbh/dependabot/cargo/syn-2.0.111
Bump syn from 2.0.110 to 2.0.111
2025-12-19 14:46:02 +01:00
dependabot[bot] daabb170f5 Bump syn from 2.0.110 to 2.0.111
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.110 to 2.0.111.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.110...2.0.111)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-19 14:43:02 +01:00
Robin Krämer c4803e63dd Merge pull request #993 from qitechgmbh/992-fix-install-script
Revert "Merge pull request #914 from qitechgmbh/bin-directory"
2025-12-19 14:12:58 +01:00
rob 0022d86035 Revert "Merge pull request #914 from qitechgmbh/bin-directory"
This reverts commit aecabfde43, reversing
changes made to b8168c6b43.
2025-12-19 14:10:40 +01:00
Hans Sebastian Pöhlmann dc711a820f Analog input test machine (#989)
Adding AnalogInputTestMachine, a minimal demo setup with EK1100 and EL3021. Adding setup instructions to readme.
2025-12-18 16:45:28 +00:00
Robin Krämer 59cf2cea2a Merge pull request #849 from qitechgmbh/769-add-an-alert-or-warning-in-the-frontend-for-filament-out-of-spec
769 add an alert or warning in the frontend for filament out of spec
2025-12-18 12:00:52 +01:00
Snacj da69ad1c3e Removed unused import 2025-12-18 11:22:22 +01:00
Snacj 6ff434fb47 Fixed Formatting 2025-12-18 11:22:22 +01:00
Snacj a63c57a0b9 Global error on Filament out of tolerance. 2025-12-18 11:22:22 +01:00
Snacj 21c43adcb4 Rebase and error fixing 2025-12-18 11:22:22 +01:00
snacj afa23666e2 Resolved small writing and logic errors. 2025-12-18 11:22:22 +01:00
snacj 245ad64426 Fixed Formatting 2025-12-18 11:22:22 +01:00
snacj 9665971150 Fixed 0mm threshold for Laser Diameter Warning 2025-12-18 11:22:22 +01:00
snacj 3b056c8f26 Changed Styling of Checkbox. 2025-12-18 11:22:22 +01:00
snacj 62b35e0237 Added AutoStopWinderOnOutOfTolerance to Laser Presets 2025-12-18 11:22:22 +01:00
snacj 11bc62f40e formatting 2025-12-18 11:22:22 +01:00
snacj 116c76d218 Added AutoStopWinderOnOutOfTolerance 2025-12-18 11:22:22 +01:00
snacj 4dcedb537d Changed Styling of Warning. 2025-12-18 11:22:22 +01:00
snacj 09022c9c86 Global Laser Warning addded 2025-12-18 11:22:22 +01:00
snacj 80cf98b11d Laser Warning works across all laser pages 2025-12-18 11:22:22 +01:00
snacj 644e750353 Added global Laser Alert 2025-12-18 11:22:22 +01:00
Robin Krämer aecabfde43 Merge pull request #914 from qitechgmbh/bin-directory
Move scripts to bin directory with better names
2025-12-18 11:20:28 +01:00
Oshgnacknak 686678a1fe Rename install script in electron app 2025-12-18 11:16:39 +01:00
Oshgnacknak 7ac0b788c2 Move scripts to bin directory with better names 2025-12-18 11:16:39 +01:00
Snacj 3791ee785d Fixed Spelling 2025-12-18 11:15:00 +01:00
snacj 7341dee976 Added docs 2025-12-18 11:15:00 +01:00
Robin Krämer b8168c6b43 Merge pull request #976 from qitechgmbh/aquapath-fix-port-selection
Aquapath fix port selection
2025-12-17 10:12:30 +01:00
rob 6026d2a658 ports are now correctly selected for the aquapath 2025-12-17 10:10:18 +01:00
Achraf Jalleli a301f3e228 Merge pull request #979 from qitechgmbh/932-add-metrics
Add compile and runtime metrics
2025-12-16 22:45:54 +01:00
JalleliAchraf 0b6bd68a24 frontend UI improvements + compile metrics script fixes 2025-12-16 17:43:33 +01:00
Christian e7fd390263 Merge pull request #969 from qitechgmbh/968-Add-mock-machines-to-pr-checks
968 add mock machines to pr checks and fix mocks and dev build
2025-12-16 16:09:17 +01:00
JalleliAchraf eab675795a Add compile and runtime metrics 2025-12-16 14:07:03 +01:00
Christian 8c2dc9a507 Merge branch 'master' into 968-Add-mock-machines-to-pr-checks 2025-12-16 10:49:14 +01:00
Robin Krämer f6c0744efa Merge pull request #937 from qitechgmbh/860-wago-coupler-750-354-cant-enter-op
860 wago coupler 750 354 cant enter op
2025-12-16 10:45:25 +01:00
rob 8c7e810359 wago modules detected and wrapped as an EtherCatDevice 2025-12-16 08:25:19 +01:00
Christian f0fd9e8ce6 fix: clean up import formatting in registry.rs 2025-12-15 11:00:49 +01:00
Christian c5579a4471 fix mock machines in dev build 2025-12-15 10:58:07 +01:00
Christian de7a65bf12 feat: add mock machine build step to CI workflow 2025-12-15 10:43:52 +01:00
rob e4d0b3c5be added logic for initializing wago modules on a 750_354 2025-12-12 15:24:59 +01:00
Robin Krämer dad029ab0d Merge pull request #924 from qitechgmbh/905-change-the-laser-graph-roundness-to-display-in-percentage-instead-of-mm
display laser roundness as percentage
2025-12-07 12:00:46 +01:00
EinsPommes 9c780799d1 fix: prettier formatting 2025-12-07 11:59:15 +01:00
EinsPommes 27a33f9def transform roundness to percentage and update related components 2025-12-07 11:59:15 +01:00
Robin Krämer f44cd029f9 Merge pull request #925 from qitechgmbh/891-switch-to-setup-page-when-selected-machine-disconnected
891 switch to setup page when selected machine disconnected
2025-12-07 11:58:58 +01:00
EinsPommes 9abb7629fb fix: prettier formatting 2025-12-07 11:56:41 +01:00
EinsPommes a161d85a63 fix: clarify machine existence check in SidebarLayout 2025-12-07 11:56:41 +01:00
EinsPommes 9df9e6df4a remove console.log statement 2025-12-07 11:56:41 +01:00
EinsPommes 29d465c13e fix: handle machine disconnects properly
The app now detects when a machine disconnects and redirects you
to the setup page. Before, it would just stay on the machine page
even though the machine was gone.

Fixed both backend (USB detection) and frontend (auto-navigation).
Added error handling for parseInt and pathMatch array access.
2025-12-07 11:56:41 +01:00
EinsPommes 622c94f2d9 update: redirect to setup when machine disconnects 2025-12-07 11:56:41 +01:00
EinsPommes f31a2789ca auto-redirect to setup when machine disconnects 2025-12-07 11:56:41 +01:00
Robin Krämer e7632e8d1b Merge pull request #941 from qitechgmbh/936
936-documentation for minimal example
2025-12-07 11:56:25 +01:00
Achraf Jalleli 2f4f3de32a Update schematic asset path
Removed placeholder text for schematic asset path.
2025-12-06 21:23:01 +01:00
Achraf Jalleli c9f49198c7 updated image reference 2025-12-06 19:01:06 +01:00
Achraf Jalleli e2c12a0168 Refinements
Updated instructions for connecting and configuring the EK1100 and EL2004.
2025-12-06 18:59:43 +01:00
Achraf Jalleli cb6e066512 Fix formatting of Acknowledgements section header 2025-12-06 18:50:35 +01:00
Achraf Jalleli 1dd0421f96 documentation for minimal example 2025-12-06 18:48:49 +01:00
Achraf Jalleli 64855f2e7a Minimal Example Documentation added 2025-12-06 18:42:17 +01:00
Achraf Jalleli 855ae7b3cf minimal example docu assets 2025-12-06 18:20:54 +01:00
Robin Krämer 5f9a8605c9 Merge pull request #930 from qitechgmbh/923-create-extruder-v3
923 create extruder v3
2025-12-04 13:55:20 +01:00
rob 567730a8d4 fixed typos in machines 2025-12-04 13:25:07 +01:00
rob 6b2164b88b added frontend for extruder3 and fixed rpm calculation 2025-12-04 11:30:53 +01:00
rob 930ea10409 added extruder2 2025-12-04 11:26:17 +01:00
Robin Krämer 24b703e262 Merge pull request #931 from qitechgmbh/895-small-sample-machine-for-testing
small test machine
2025-12-04 11:23:42 +01:00
JalleliAchraf 152dd3efa0 small test machine 2025-12-04 11:17:24 +01:00
Milan von dem Bussche ebc5bae55f added Minimal Example README.md
Minimal example to explain the Code
2025-12-03 16:19:13 +01:00
Robin Krämer 88214c46a8 Merge pull request #911 from qitechgmbh/909-implement-ecma-error-messages
909 implement ecmy error messages
2025-11-30 00:41:49 +01:00
rob c9485a92e9 fixed formatting 2025-11-30 00:38:34 +01:00
rob 9df42c4d13 fixed server setup 2025-11-30 00:38:34 +01:00
rob fc9a439ae8 removed unused variables 2025-11-30 00:38:34 +01:00
rob e451153406 removed unused variables 2025-11-30 00:38:34 +01:00
rob 500e2fe481 fixed parsing etc 2025-11-30 00:38:34 +01:00
rob a5884d93e7 test 2025-11-30 00:38:34 +01:00
rob d2e32cdd78 added comment 2025-11-30 00:38:34 +01:00
rob f66c6d4f65 Note entirely sure it works ... 2025-11-30 00:38:34 +01:00
225 changed files with 16101 additions and 6940 deletions

No files matched your search

+52 -29
View File
@@ -3,50 +3,73 @@ name: Electron
on:
push:
branches:
- "**"
- master
pull_request:
branches:
- "*"
- master
defaults:
run:
working-directory: electron
jobs:
build-electron:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: electron
steps:
- name: Setup Node.js
- &checkout
name: Checkout
uses: actions/checkout@v4
- &setup-node
name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18" # or '20' for latest LTS
node-version: 20
cache: "npm"
cache-dependency-path: |
electron/package-lock.json
- uses: actions/checkout@v4
- &install
name: Install dependencies
run: npm ci
- name: Cache NPM dependencies
uses: actions/cache@v3
with:
path: |
~/.npm
node_modules/
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install
run: npm i
- name: Build
- name: Compile
run: tsc
- name: Format
run: npm run format
- name: Build
run: npm run build
- name: Lint
run: npm run lint
test:
runs-on: ubuntu-latest
needs: build
steps:
- *checkout
- *setup-node
- *install
- name: Test
run: npm run test
- name: Package
run: npm run package
format:
runs-on: ubuntu-latest
steps:
- *checkout
- *setup-node
- *install
- name: Format
run: npm run format
lint:
runs-on: ubuntu-latest
steps:
- *checkout
- *setup-node
- *install
- name: Lint
run: npm run lint
+96
View File
@@ -0,0 +1,96 @@
name: Nix CI
on:
push:
branches:
- master
pull_request:
branches:
- master
env:
NIX_PATH: nixpkgs=channel:nixos-25.05
jobs:
electron:
runs-on: ubuntu-latest
steps:
- &checkout
name: Check out repository
uses: actions/checkout@v4
- &nix-install
uses: nixbuild/nix-quick-install-action@v34
with:
nix_conf: |
keep-env-derivations = true
keep-outputs = true
- uses: nix-community/cache-nix-action@v6
with:
primary-key: nix-electron-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock', './electron/package-lock.json') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
purge: true
purge-prefixes: nix-electron-${{ runner.os }}-
- name: Build Electron in Nix
run: nix build ".#packages.x86_64-linux.electron"
rust:
runs-on: ubuntu-latest
steps:
- *checkout
- *nix-install
- uses: nix-community/cache-nix-action@v6
with:
primary-key: nix-server-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock', './Cargo.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
purge: true
purge-prefixes: nix-server-${{ runner.os }}-
- name: Build Server in Nix
run: nix build ".#packages.x86_64-linux.server"
build:
runs-on: ubuntu-latest
needs: [electron, rust]
steps:
- *checkout
- *nix-install
- name: Use both Electron and Server Nix Store
uses: nix-community/cache-nix-action@v6
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock', './Cargo.lock', './electron/package-lock.json') }}
# if no hit on the primary key, merge individual caches
restore-prefixes-all-matches: |
nix-electron-${{ runner.os }}-
nix-server-${{ runner.os }}-
- name: Build Flake and System Configuration
run: nix build ".#nixosConfigurations.nixos.config.system.build.toplevel" --impure
format:
runs-on: ubuntu-latest
steps:
- *checkout
- *nix-install
- name: Format
run: nix run 'nixpkgs#nixfmt-classic' $(find . nixos -name '*.nix')
check:
runs-on: ubuntu-latest
steps:
- *checkout
- *nix-install
- name: Build Flake and Configuration.nix
run: nix flake check "." --impure
+48 -9
View File
@@ -3,9 +3,10 @@ name: Rust
on:
push:
branches:
- "**"
- master
pull_request:
branches: "*"
branches:
- master
env:
CARGO_TERM_COLOR: always
@@ -16,14 +17,24 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Install libudev development package
- &udev-install
name: Install libudev development package
run: sudo apt-get update && sudo apt-get install -y libudev-dev
- uses: actions/checkout@v4
- name: Set Rust version
run: rustup override set ${{ env.RUST_VERSION }}
- name: Install Formatter
run: rustup component add rustfmt
- name: Cache Rust dependencies
- &checkout
uses: actions/checkout@v4
- &setup-rust
name: Install Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
profile: minimal
override: true
components: rustfmt
- &cargo-cache
name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
@@ -41,8 +52,36 @@ jobs:
RUSTFLAGS: "-D warnings"
run: cargo build --verbose
format:
runs-on: ubuntu-latest
steps:
- *checkout
- *setup-rust
- name: Format
run: cargo fmt --check --verbose
test:
runs-on: ubuntu-latest
steps:
- *udev-install
- *checkout
- *setup-rust
- *cargo-cache
- name: Run tests
run: cargo test --verbose
mock:
runs-on: ubuntu-latest
steps:
- *udev-install
- *checkout
- *setup-rust
- *cargo-cache
- name: Mock Machine
run: cargo build --features "development-build,mock-machine"
+4 -1
View File
@@ -4,4 +4,7 @@ dist/
Cargo.lock
dhat-heap.json
build.sh
shell.nix
node_modules/
runtime_metrics.csv
compile_metrics.csv
+21
View File
@@ -1,3 +1,24 @@
# `2.13.0`
_07.01.2026_
## General
- [#925](https://github.com/qitechgmbh/control/pull/925) Switch to Setup when selected machine is disconnected
- [#937](https://github.com/qitechgmbh/control/pull/937) Added minimal Support for WAGO
- [#945](https://github.com/qitechgmbh/control/pull/945) Improvements for On-Screen Keyboard usage in Ethercat Setup Page
- []
## Extruder V3
- [#930](https://github.com/qitechgmbh/control/pull/930) Added Extruder V3
## Laser
- [#924](https://github.com/qitechgmbh/control/pull/924) Roundness is now displayed as a percentage
- [#885](https://github.com/qitechgmbh/control/pull/885) Added Min and Max Diameter
- [#849](https://github.com/qitechgmbh/control/pull/849) Added a Warning if Filament is out of Spec
## Aquapath
- [#976](https://github.com/qitechgmbh/control/pull/976) Improved Stability
# `2.12.0`
_17.11.2025_
Generated
+30 -29
View File
@@ -204,7 +204,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -221,7 +221,7 @@ checksum = "99e1aca718ea7b89985790c94aad72d77533063fe00bc497bb79a7c2dae6a661"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -291,7 +291,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -539,7 +539,7 @@ version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -682,7 +682,7 @@ dependencies = [
"deluxe-macros",
"once_cell",
"proc-macro2",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -695,7 +695,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -710,7 +710,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -721,7 +721,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -962,7 +962,7 @@ dependencies = [
"deluxe",
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -1014,7 +1014,7 @@ dependencies = [
"criterion",
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -1878,7 +1878,7 @@ dependencies = [
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -1908,7 +1908,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -2221,7 +2221,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -2251,7 +2251,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -2323,6 +2323,7 @@ dependencies = [
"dhat",
"ethercat_hal",
"ethercrab",
"libc",
"machines",
"regex",
"serde",
@@ -2572,9 +2573,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.110"
version = "2.0.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
dependencies = [
"proc-macro2",
"quote",
@@ -2620,7 +2621,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -2680,7 +2681,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -2824,9 +2825,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.41"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
@@ -2836,20 +2837,20 @@ dependencies = [
[[package]]
name = "tracing-attributes"
version = "0.1.30"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
name = "tracing-core"
version = "0.1.34"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
@@ -3073,7 +3074,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
"wasm-bindgen-shared",
]
@@ -3138,7 +3139,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -3149,7 +3150,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -3425,5 +3426,5 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
+16 -2
View File
@@ -2,7 +2,12 @@
# QiTech Control
QiTech Control is an industrial control panel software for the next generation of QiTech recycling machines built on top of Beckhoff Automation hardware.
QiTech Control is an open-source framework designed to bring modern software paradigms 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.
[![](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.*
@@ -35,7 +40,7 @@ Other
## Backend
To interface with Beckhoff and other EtherCAT devices we need an EtherCAT master stoftware. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
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.
@@ -74,6 +79,15 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
| 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
# More Docs
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
BUILD_DIR="${BUILD_DIR:-target}"
CSV_FILE="${CSV_FILE:-compile_metrics.csv}"
DEFAULT_PKG="server"
if [ "$#" -eq 0 ]; then
set -- cargo build
fi
if [ "${1:-}" = "cargo" ] && [ "${2:-}" = "build" ]; then
cargo clean
BUILD_CMD=(cargo rustc -p "$DEFAULT_PKG")
shift 2
BUILD_CMD+=("$@")
BUILD_CMD+=(-- --emit=llvm-ir -C debuginfo=0)
else
BUILD_CMD=("$@")
fi
compile_start_ns=$(date +%s%N)
"${BUILD_CMD[@]}"
compile_end_ns=$(date +%s%N)
compile_ms=$(( (compile_end_ns - compile_start_ns) / 1000000 ))
if [ -d "$BUILD_DIR" ]; then
llvm_ir_lines=$(
find "$BUILD_DIR" -type f -name '*.ll' -print0 \
| xargs -0 cat \
| wc -l
)
else
llvm_ir_lines=0
fi
bin_path=""
if [ -n "${BINARY_PATH:-}" ] && [ -f "$BINARY_PATH" ]; then
bin_path="$BINARY_PATH"
elif [ -f "./target/release/server" ]; then
bin_path="./target/release/server"
elif [ -f "./target/debug/server" ]; then
bin_path="./target/debug/server"
elif [ -f "./server/target/release/server" ]; then
bin_path="./server/target/release/server"
elif [ -f "./server/target/debug/server" ]; then
bin_path="./server/target/debug/server"
fi
if [ -n "$bin_path" ]; then
binary_size_bytes=$(stat -c '%s' "$bin_path")
else
binary_size_bytes=0
fi
finished_timestamp_ms=$(($(date +%s%N) / 1000000))
finished_datetime=$(date -Iseconds)
if [ ! -f "$CSV_FILE" ]; then
echo "datetime,finished_timestamp_ms,compile_ms,llvm_ir_lines,binary_size_bytes" > "$CSV_FILE"
fi
echo "${finished_datetime},${finished_timestamp_ms},${compile_ms},${llvm_ir_lines},${binary_size_bytes}" \
>> "$CSV_FILE"
echo "Metrics recorded in $CSV_FILE"
+1 -1
View File
@@ -21,7 +21,7 @@ smol = "2.0.2"
socketioxide = "0.17.2"
bitvec = { version = "1.0.1", features = ["alloc"] }
crc = "3.3.0"
tracing = "0.1.41"
tracing = "0.1.44"
erased-serde = "0.4.8"
core_affinity = "0.8.3"
control_core_derive = { version = "0.1.0", path = "../control-core-derive" }
+65
View File
@@ -0,0 +1,65 @@
use std::process::Command;
use anyhow::Result;
use interfaces::Interface;
pub mod modbus_tcp_discovery;
/// Returns true if the given network interface is Ethernet.
/// Prevents testing wlan,loopback and other non ethernet devices
fn is_ethernet(interface: &str) -> std::io::Result<bool> {
#[cfg(target_os = "linux")]
{
let base_path = format!("/sys/class/net/{}", interface);
let type_path = std::path::Path::new(&base_path).join("type");
let iface_type = std::fs::read_to_string(&type_path)?.trim().to_string();
// 1 means Ethernet
let reports_as_ethernet = iface_type == "1";
// Double-check that it's not a wireless interface
let uevent_path = std::path::Path::new(&base_path).join("uevent");
let uevent = std::fs::read_to_string(&uevent_path).unwrap_or_default();
// If "DEVTYPE=wlan" appears, it's lying
let actually_wifi = uevent.contains("DEVTYPE=wlan");
Ok(reports_as_ethernet && !actually_wifi)
}
#[cfg(not(target_os = "linux"))]
return Ok(true);
}
pub fn get_interfaces() -> Result<Vec<Interface>> {
let vec = Interface::get_all()
.map_err(|e| anyhow::anyhow!("Failed to get network interfaces: {}", e))?
.into_iter()
.filter(|iface| {
iface.is_up()
&& iface.is_running()
&& !iface.is_loopback()
&& is_ethernet(&iface.name).unwrap_or(false)
&& !iface.name.starts_with("bridge")
&& !iface.name.starts_with("utun") // Exclude tunnel interfaces
&& !iface.name.starts_with("awdl") // Exclude Apple Wireless Direct Link
&& !iface.name.starts_with("anpi") // Exclude Apple Network Privacy Interface
&& !iface.name.starts_with("llw") // Exclude Apple low-latency WLAN
})
.collect::<Vec<_>>();
Ok(vec)
}
/// Sets a network interface to unmanaged by NetworkManager.
/// Returns true if the command succeeded.
pub fn set_interface_managed(interface: &str, managed: bool) -> bool {
let managed_str = match managed {
true => "yes",
false => "no",
};
tracing::info!(
"set_interface_managed for {} managed was set to: {}",
interface,
managed_str
);
let status = Command::new("nmcli")
.args(["dev", "set", interface, "managed", managed_str])
.status();
matches!(status, Ok(s) if s.success())
}
@@ -0,0 +1,78 @@
use crate::ethernet::get_interfaces;
use crate::{futures::FutureIteratorExt, modbus::tcp::ModbusTcpDevice};
use anyhow::{Result, bail};
use interfaces::Interface;
use std::cmp::min;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
pub struct ModbusTcpProbe {
pub addr: SocketAddr,
pub serial: u16,
}
pub async fn probe_modbus_tcp() -> Vec<ModbusTcpProbe> {
let interfaces = match get_interfaces() {
Ok(x) => x,
Err(_) => return vec![],
};
interfaces
.into_iter()
.map(probe_modbus_tcp_addresses)
.join_all()
.await
.into_iter()
.flatten()
.collect()
}
async fn probe_modbus_tcp_addresses(interface: Interface) -> Vec<ModbusTcpProbe> {
let out: Vec<ModbusTcpProbe> = interface
.addresses
.iter()
.filter_map(|addr| {
let a = addr.addr.map(|a| a.ip());
let m = addr.mask.map(|a| a.ip());
match (a, m) {
(Some(IpAddr::V4(addr)), Some(IpAddr::V4(mask))) => Some((addr, mask)),
_ => None,
}
})
.flat_map(|(addr, mask)| {
let a = u32::from(addr);
let m = u32::from(mask);
let network = a & m;
let prefix = min(24, m.count_ones()); // mask → prefix length
let size = 1u32 << (32 - prefix); // number of addresses
(0..size).map(move |i| SocketAddr::new(Ipv4Addr::from(network + i).into(), 502))
})
.map(|addr| smol::spawn(ping_modbus_device(addr)))
.join_all()
.await
.into_iter()
.filter_map(|x| x.ok())
.collect();
out
}
async fn ping_modbus_device(addr: SocketAddr) -> Result<ModbusTcpProbe> {
let mut device = ModbusTcpDevice::new(addr).await?;
let module_number1 = device.get_u32(0x2).await?;
let module_number2 = device.get_u32(0x4).await?;
if module_number1 != 0x2787_2144 || module_number2 != 0x0000_0000 {
bail!("Unknown modbus tcp device!");
}
let serial = device.get_u32(0x000A).await?;
Ok(ModbusTcpProbe {
addr,
serial: serial as u16,
})
}
+26
View File
@@ -0,0 +1,26 @@
use core::future::Future;
// Extension trait for iterators of futures
pub trait FutureIteratorExt<F>: Iterator<Item = F> + Sized
where
F: Future,
{
/// Await all futures and return their outputs
fn join_all(self) -> impl Future<Output = Vec<F::Output>>;
}
impl<I, F> FutureIteratorExt<F> for I
where
I: Iterator<Item = F> + Sized,
F: Future,
{
async fn join_all(self) -> Vec<F::Output> {
let mut out = Vec::new();
for fut in self {
out.push(fut.await);
}
out
}
}
+2
View File
@@ -2,6 +2,8 @@ pub mod controllers;
pub mod converters;
pub mod downcast;
pub mod ethercat;
pub mod ethernet;
pub mod futures;
pub mod helpers;
pub mod irq_handling;
pub mod modbus;
+1
View File
@@ -1,4 +1,5 @@
pub mod modbus_serial_interface;
pub mod tcp;
use anyhow::Error;
use crc::{CRC_16_MODBUS, Crc};
+263
View File
@@ -0,0 +1,263 @@
use std::fmt::Debug;
use std::io::ErrorKind;
use std::net::SocketAddr;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use smol::Timer;
use smol::future::FutureExt;
use smol::io;
use smol::io::AsyncReadExt;
use smol::io::AsyncWriteExt;
use smol::net::TcpStream;
const PROTOCOL_ID: u16 = 0;
const UNIT_ID: u8 = 0;
const READ_HOLDING_FUNCTION_CODE: u8 = 3;
const WRITE_HOLDING_FUNCTION_CODE: u8 = 16;
const READ_HOLDING_LENGTH: u16 = 6; // Unit ID 00 + Func Code 03 + Start Address XXXX + Quantity XXXX = 6 bytes
const WRITE_HOLDING_LENGTH_WITHOUT_DATA: u16 = 7; // Unit ID 00 + Func Code 16 + Start Address XXXX + Quantity XXXX + Num Data Bytes XX = 7 bytes
struct Packet {
buf: Vec<u8>,
}
impl Packet {
pub const fn new() -> Self {
Self { buf: Vec::new() }
}
pub fn add_u8(&mut self, x: u8) {
self.buf.push(x);
}
pub fn add_u16(&mut self, x: u16) {
let buf = x.to_be_bytes();
self.buf.push(buf[0]);
self.buf.push(buf[1]);
}
pub fn as_bytes(&self) -> &[u8] {
&self.buf
}
}
pub struct ModbusTcpDevice {
stream: TcpStream,
transactions: u16,
}
impl Debug for ModbusTcpDevice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.stream.peer_addr() {
Ok(addr) => write!(f, "ModbusTcpDevice({:?})", addr),
Err(_) => std::fmt::Result::Err(std::fmt::Error),
}
}
}
impl ModbusTcpDevice {
pub async fn new(addr: SocketAddr) -> Result<Self> {
let timeout = async {
Timer::after(Duration::from_millis(10)).await;
Err(io::Error::from(ErrorKind::TimedOut))
};
let stream = TcpStream::connect(addr)
.or(timeout)
.await
.context("Could not connect to modbus device!")?;
Ok(Self {
stream,
transactions: 0,
})
}
async fn read_and_check_responce_header(&mut self, func_code: u8) -> Result<()> {
if self.read_u16().await? != self.transactions {
bail!("Modbus device sent unexpected transaction id!");
}
if self.read_u16().await? != PROTOCOL_ID {
bail!("Modbus device sent unexpected protocol id!");
}
let length = self.read_u16().await?;
if length < 2 {
bail!("Modbus device will send too few bytes to understand the response!");
}
if self.read_u8().await? != UNIT_ID {
bail!("Modbus device sent unexpected unit id!");
}
let func_code_res = self.read_u8().await?;
if func_code != func_code_res {
let error_code = self.read_u8().await?;
bail!(
"Modbus device sent unexpected function code 0x{:x}! It probably encountered an error. Error code: 0x{:x}",
func_code_res,
error_code
);
}
Ok(())
}
async fn send_read_holding_request(&mut self, addr: u16, count: u16) -> Result<u16> {
let mut packet = Packet::new();
self.transactions += 1;
packet.add_u16(self.transactions);
packet.add_u16(PROTOCOL_ID);
packet.add_u16(READ_HOLDING_LENGTH);
packet.add_u8(UNIT_ID);
packet.add_u8(READ_HOLDING_FUNCTION_CODE);
packet.add_u16(addr);
packet.add_u16(count);
self.send(packet).await?;
self.read_and_check_responce_header(READ_HOLDING_FUNCTION_CODE)
.await?;
let num_data_bytes = self.read_u8().await? as u16;
if count * 2 != num_data_bytes {
bail!(
"Modbus device wants to send only a portion of the requested range - UNIMPLEMENTED!"
);
}
Ok(num_data_bytes)
}
pub async fn get_holding_registers(&mut self, addr: u16, count: u16) -> Result<Vec<u16>> {
self.send_read_holding_request(addr, count).await?;
let mut res: Vec<u16> = Vec::new();
while res.len() < count as usize {
res.push(self.read_u16().await?);
}
Ok(res)
}
pub async fn set_holding_registers(&mut self, addr: u16, values: &[u16]) -> Result<()> {
let count = values.len();
let num_bytes = count * 2;
assert!(
count <= 128,
"Cannot send that many bytes to modbus device in one go! Trying to send {} bytes of data.",
num_bytes
);
let mut packet = Packet::new();
self.transactions += 1;
packet.add_u16(self.transactions);
packet.add_u16(PROTOCOL_ID);
packet.add_u16(WRITE_HOLDING_LENGTH_WITHOUT_DATA + num_bytes as u16);
packet.add_u8(UNIT_ID);
packet.add_u8(WRITE_HOLDING_FUNCTION_CODE);
packet.add_u16(addr);
packet.add_u16(count as u16);
packet.add_u8(num_bytes as u8);
for value in values {
packet.add_u16(value.to_owned());
}
self.send(packet).await?;
self.read_and_check_responce_header(WRITE_HOLDING_FUNCTION_CODE)
.await?;
if self.read_u16().await? != addr {
bail!("Modbus device wrote to wrong register address!");
}
if self.read_u16().await? != count as u16 {
bail!("Modbus device wrote wrong number of registers!");
}
Ok(())
}
pub async fn get_string<const N: usize>(&mut self, addr: u16) -> Result<String> {
assert!(N % 2 == 0, "Strings are always of even length!");
assert!(N <= 256, "Cannot read long strings in a single swoop!");
let count = N / 2;
self.send_read_holding_request(addr, count as u16).await?;
let buf = self.read_bytes::<N>().await?;
let s = String::from_utf8_lossy(&buf);
let s = match s.split_once('\0') {
None => s.to_string(),
Some((l, _)) => l.to_string(),
};
Ok(s)
}
pub async fn set_string<const N: usize>(&mut self, _addr: u16, s: &str) -> Result<()> {
assert!(s.len() <= N, "String is too long to fit!");
todo!()
}
pub async fn get_u16(&mut self, addr: u16) -> Result<u16> {
let length = self.send_read_holding_request(addr, 1).await?;
assert_eq!(
length, 2,
"Expected modbus device to send exactly 2 bytes of data!"
);
self.read_u16().await
}
pub async fn get_u32(&mut self, addr: u16) -> Result<u32> {
let length = self.send_read_holding_request(addr, 2).await?;
assert_eq!(
length, 4,
"Expected modbus device to send exactly 4 bytes of data!"
);
let buf = self.read_bytes::<4>().await?;
Ok(u32::from_be_bytes(buf))
}
// We have to send the request in a single packet,
// otherwise the device may fail to respond correctly.
async fn send(&mut self, packet: Packet) -> Result<()> {
self.stream
.write_all(packet.as_bytes())
.await
.context("Could write to modbus device!")
}
async fn read_u8(&mut self) -> Result<u8> {
let buf = self.read_bytes::<1>().await?;
Ok(u8::from_be_bytes(buf))
}
async fn read_u16(&mut self) -> Result<u16> {
let buf = self.read_bytes::<2>().await?;
Ok(u16::from_be_bytes(buf))
}
async fn read_bytes<const N: usize>(&mut self) -> Result<[u8; N]> {
let mut buf = [0; N];
self.stream
.read_exact(&mut buf)
.await
.context("Could not read from modbus device!")?;
Ok(buf)
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+139
View File
@@ -0,0 +1,139 @@
# XTREM Communication Protocol
**Version:** 3.007
**Device:** XTREM / XTREM-S Weighing Module
**Standard:** OIML R76:2006 / EN45501:2015 compliant
---
## Overview
The **XTREM communication protocol** allows a terminal or host controller to exchange data and commands with the XTREM weighing module.
The device transmits and receives ASCII-coded frames over a serial interface (RS232, RS485, Wi-Fi, or Ethernet, depending on configuration).
Since we are using the Ethernet interface we need to provide some kind of DHCP to assign an IP - Address to each XTREM Device.
They then may be addressed via a **broadcast** IP (e.g.: 192.168.4.255/24).
A **Unicast** communication is applied through the device ID inside the XTREM Frame.
---
## Frame Structure
Each message—both query and response—has the same format:
```
STX ID_O ID_D F D_ADDRESS D_L DATA LRC ETX
```
| Field | Description | Length (bytes) |
| :------------ | :------------------------------------------------ | :------------: |
| **STX** | Start of message (ASCII `02h`) | 1 |
| **ID_O** | Sender address (2 ASCII hex chars) | 2 |
| **ID_D** | Destination address (2 ASCII hex chars) | 2 |
| **F** | Function code (`R`, `W`, `E`, etc.) | 1 |
| **D_ADDRESS** | Data address (register, 4 ASCII hex chars) | 4 |
| **D_L** | Data length (2 ASCII hex chars) | 2 |
| **DATA** | Optional data field | Variable |
| **LRC** | Longitudinal Redundancy Check (2 ASCII hex chars) | 2 |
| **ETX** | End of message (ASCII `03h`) | 1 |
In the default configuration the Frame also requires a CRLF at the end.
**Example:**
```
STX ‘1’ ‘7’ ‘0’ ‘1’ ‘R’ ‘0’ ‘1’ ‘0’ ‘1’ ‘0’ ‘0’ LRC ETX CR LF
```
---
## Function Codes
| Function | ASCII | Hex | Description |
| :------- | :---- | :---| :-------------- |
| **R** | R | 52h | Read request |
| **r** | r | 72h | Read response |
| **W** | W | 57h | Write request |
| **w** | w | 77h | Write response |
| **E** | E | 45h | Execute command |
| **e** | e | 65h | Execute response|
### Write response codes
* `'0'` – OK
* `'1'` – Write denied (sealed)
* `'2'` – Read-only register
* `'3'` – Invalid value / out of range
### Execute response codes
* `'0'` – Executed successfully
* `'1'` – Blocked by seal switch
---
## LRC Calculation
The **Longitudinal Redundancy Check** is a one-byte XOR of all message bytes, excluding:
* `STX`
* `ETX`
* The LRC itself
Example C function:
```c
unsigned char LRC(unsigned char* s) {
unsigned char lrc = 0;
while (*s) {
lrc ^= *(s++);
}
return lrc;
}
```
Example Rust function:
```rust
pub fn compute_lrc(data: &[u8]) -> u8 {
data.iter().fold(0u8, |acc, &b| acc ^ b)
}
```
---
## Key Data Registers
| Address (hex) | Description | Type | Notes |
| :------------ | :------------------------- | :--: | :------------------------------------ |
| **0000h** | Serial number | R | Factory-set |
| **0001h** | Device ID | RW | Network address |
| **0007h** | Hardware version | R | Read-only |
| **0008h** | Software version | R | Read-only |
| **0009h** | Sealing switch status | R | `0=UNLOCK`, `1=LOCK` |
| **0010h** | Baud rate | RW | `0–4` → 9600–115200 baud |
| **0013h** | Stream mode output rate | RW | Time in ms |
| **0100h** | Device state | R | Status bits (power, Wi-Fi, ADC, etc.) |
| **0101h** | Weight value | R | Gross weight |
| **0102h** | Tare value | RE | Read/Execute |
| **0103h** | Net weight | R | Gross − Tare |
| **0110h** | ADC counts (instant) | R | Raw ADC data |
| **0111h** | ADC counts (filtered) | R | Post-filter data |
| **1010h** | Stop stream mode | E | Execute |
| **1011h** | Start stream mode (weight) | E | Continuous output |
| **9999h** | Device reset | E | Software reset |
| **EEEEh** | Factory reset | E | Restores defaults |
---
## Post-Factory Reset Configuration
After performing a factory reset, several registers must be configured to ensure proper operation of the device.
Note: The unit may remain set to 'g' after a factory reset, but the values received may be in 'kg' depending on the device configuration.
The following registers should be reviewed and set as required:
1. **Address: 0022 | Max (Max1) (scale maximum capacity)**
The default value for this register may vary between devices (e.g., 60000 or 6000). Set this value to 6000 to ensure correct operation.
2. **Address: 0023 | e (e1) (scale interval)**
3. **Address: 0026 | Decimal position**
+9
View File
@@ -82,3 +82,12 @@ git push
# Recommended Editor Setup
We recommend you to use an Editor with rust-analyzer support like VSCode to speed up development and detect errors before compiling.
# Minimal Hardware Examples
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
These examples provide complete hardware wiring diagrams and software setup instructions.
@@ -0,0 +1,224 @@
# Minimal Example — LED Control on the EL2004
A complete hardware + software walkthrough
---
## Table of Contents
1. [Introduction](#1-introduction)
2. [Requirements](#2-requirements)
3. [Hardware Setup](#3-hardware-setup)
4. [Software Setup](#4-software-setup)
5. [Demo](#5-demo)
6. [References](#6-references)
7. [Acknowledgements](#7-acknowledgements)
---
## 1. Introduction
The EL2004 LED Toggle Example is a minimal demonstration showing how to control digital outputs on a **Beckhoff EL2004 EtherCAT terminal** using the QiTech machine framework.
It represents the simplest possible hardware interaction in the system:
**toggling LED outputs using the QiTech Control Dashboard.**
---
## 2. Requirements
### Hardware
- Beckhoff **EL2004 EtherCAT Terminal** (4-channel digital output)
- Beckhoff **EK1100 EtherCAT Coupler**
- **24 V DC power supply** (AC/DC adapter + DC hollow plug)
- Jumper / bridge wires (0.5–1.5 mm² recommended)
- A **Linux PC** (Ubuntu/Debian recommended)
- Standard Ethernet cable
- Flat screwdriver
### Software
*(Installation steps in Section 4)*
- Rust toolchain
- Node.js + npm
- Git
- QiTech Control repository
- EtherCAT HAL (included inside repo)
---
## 3. Hardware Setup
### 3.1 Schematic
![](../assets/schematic.png)
---
### 3.2 EK1100 Wiring
This wiring configuration powers the EL2004 and prepares it for LED control.
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)
---
#### 3.2.2 Wiring Steps (Used in This Example)
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">
---
### 3.3 EL2004 Integration
Slide the EL2004 onto the **right side of the EK1100** until it locks.
The EtherCAT E-Bus and power contacts connect automatically — **no wiring required**.
#### **Figure 2 — EL2004 Terminal**
<img src="../assets/el2004.jpeg" width="400">
---
### 3.4 Final Assembled Setup
#### **Figure 3 — EK1100 + EL2004 Connected**
<img src="../assets/complete.jpeg" width="400">
---
### 3.5 Power & Ethernet
#### Power
Connect the 24 V adapter to the hollow plug used earlier.
**Example AC/DC Adapter (Figure 4):**
<img src="../assets/adapter.jpeg" width="400">
#### Ethernet
Use a standard LAN cable to connect your PC → EK1100.
The final powered up and connected setup should look like this:
<img src="../assets/power.jpeg" width="400">
---
## 4. Software Setup
### 4.1 Installing on Ubuntu/Debian
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
Ensure the EK1100 is connected.
### 4.3 Running the Frontend
```bash
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:
- EK1100 Coupler
- EL2004 Digital Output Terminal
![](../assets/discovery.png)
Steps:
- Click Assign on the EK1100
- Select TestMachine V1
![](../assets/setmachine.png)
- Enter a serial number (use the same for EK1100 + EL2004)
![](../assets/serserial.png)
- Click Write
- Repeat for the EL2004
### 5.2 Testing LED Control
Navigate to:
Machines → TestMachine
![](../assets/machinedetected.png)
You will see this interface:
![](../assets/machinecontrol.png)
**You can now toggle the four digital outputs of the EL2004.**
## 6. References
This guide incorporates information from official Beckhoff documentation.
All diagrams, product names, and figures belong to Beckhoff Automation GmbH & Co. KG and are used here solely for educational purposes.
Referenced Manuals
[Beckhoff EK1100 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/ek110x_ek15xx_en.pdf)
[Beckhoff EL2004 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/el20xx_el2124_de.pdf)
## 7. Acknowledgements
This tutorial is inspired by the clarity and educational quality of Beckhoff manuals.
All wiring illustrations and hardware descriptions in this guide are provided for demonstration purposes only and do not replace official Beckhoff installation guidelines.
Special thanks to the QiTech engineering team for providing the backend architecture, EtherCAT HAL abstraction, and the TestMachine framework that makes this example possible.
@@ -0,0 +1,178 @@
# Minimal Example — Analog Input EL3021
## Table of Contents
1. [Introduction](#1-introduction)
2. [Requirements](#2-requirements)
3. [Hardware Setup](#3-hardware-setup)
4. [Software Setup](#4-software-setup)
5. [Demo](#5-demo)
6. [References](#6-references)
## 1. Introduction
The EL3021 Analog Input example demonstrates how to measure analog current on a **Beckhoff EL3021 EtherCAT terminal** using the QiTech machine framework.
## 2. Requirements
### Hardware
- Beckhoff **EL3021 EtherCAT Terminal** (1-channel analog input)
- Beckhoff **EK1100 EtherCAT Coupler**
- **24 V DC power supply** (AC/DC adapter + DC hollow plug)
- **Adjustable DC power supply** (or any other means of producing 4-20mA DC @ <10V )
- Jumper / bridge wires (0.5–1.5 mm² recommended)
- A **Linux PC** (Ubuntu/Debian recommended)
- Standard Ethernet cable
- Flat screwdriver
## 3. Hardware Setup
### 3.1 Schematic
![](../assets/EL3021_schematic.png)
The 24V supply powers the EL2004, while the adjustable power supply generates the analog input signal for the EL3021.
#### ⚠️ 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)
---
### 3.2.2 Wiring Steps (Used in This Example)
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">
---
### 3.3 EL3021 Integration
Slide the EL3021 onto the **right side of the EK1100** until it locks.
The EtherCAT E-Bus and power contacts connect automatically.
Then you need to wire the adjustable power supply to the EL3021.
#### **Figure 2 — EL3021 Terminal**
<img src="../assets/EL3021.jpg" width="400">
1. Wire ⊕ to **Terminal 1**
2. Wire ⊖ to **Terminal 2**
---
### 3.4 Final Assembled Setup
#### **Figure 3 — EK1100 + EL3021 Connected**
<img src="../assets/EK1100+EL3021.jpg" width="400">
---
## 3.5 Power & Ethernet
### 3.5.1 Power
Connect the 24 V adapter to the hollow plug used earlier.
#### Example AC/DC Adapter (Figure 4):
<img src="../assets/adapter.jpeg" width="400">
### 3.5.2 Ethernet
Use a standard LAN cable to connect your PC → EK1100.
The final powered up and connected setup should look like this:
<img src="../assets/EL3021_power+eth.jpg" width="400">
---
## 4. Software Setup
### 4.1 Installing on Ubuntu/Debian
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
Ensure the EK1100 is connected.
### 4.3 Running the Frontend
```bash
cd electron
npm run start
```
This launches the QiTech Control dashboard.
## 5. Demo
### 5.1 Assigning Devices in the Dashboard
Once both backend and frontend are running, the Setup -> EtherCat page should look something like this:
![](../assets/EL3021_overview.png)
Listing the EK1100 and the EL3021.
To assign the two devices to a machine, click "Assign" on both devices. This should open the following pop-up modal:
**EK1100**
![](../assets/EL3021_assign.png)
1. Select "AnalogTest V1" under "Machine"
2. Choose a serial number to uniquely identify the machine (1 in this example)
3. Under "Device Role" select "0 - Bus Coupler"
**EL3021**
![](../assets/EL3021_assign2.png)
1. Select "AnalogTest V1" under "Machine"
2. Use the same serial number you chose for the EK1100
3. Under "Device Role" select "1 - EL3021"
---
The machine is now configured.
A new Tab "AnalogTest" should appear in the right sidebar. Clicking that tab should show the machine control interface:
![](../assets/EL3021_control.png)
Results should show the current measured by the analog input - the current set on the adjustable power supply.
## 6. References
This guide incorporates information from official Beckhoff documentation.
All diagrams, product names, and figures belong to Beckhoff Automation GmbH & Co. KG and are used here solely for educational purposes.
Referenced Manuals
[Beckhoff EK1100 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/ek110x_ek15xx_en.pdf)
[Beckhoff EL3021 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/el30xx_de.pdf)
+2 -2
View File
@@ -10,7 +10,7 @@ This is why we chose nixos
# How we do updates
1. The user selects a version (commit/branch/tag) of the software they want to install
2. The repo is cloned to the local machine
3. the `installInfo.nix` is created and commited locally. It contains infos like the commit hash, branch, tag, timestamp etc. These are later available as environment variables `QITECH_OS...`
3. Information like the commit hash, branch, tag, timestamp etc. is passed to the nix build process as environment variables `QITECH_OS...`
4. The `nixos-switch` command is run. After rebuilding & compiling the software the system reboots.
- The nixos-switch command does the following:
1. The system is built with the new configuration
@@ -21,4 +21,4 @@ This is why we chose nixos
# More Docs
- [Implementation Details](./details.md)
- [Fresh Installation Quick Start guide](./quick-start.md)
- [Fresh Installation Quick Start guide](./quick-start.md)
+235
View File
@@ -0,0 +1,235 @@
# REST API `/api/v2`
The Qitech Control Panel exposes a small HTTP interface for discovering machines and reading their current values. All examples below assume you are connected to the panel’s Ethernet subnet and talk directly to the panel at:
- **Base URL:** `http://10.10.10.1:3001`
> **Schema note:** This documentation intentionally stays light on field details beyond what is shown in the examples. For the exact datatypes and complete payload shapes, refer to the corresponding Rust types (linked below).
---
## Authentication
The panel does **not** perform HTTP authentication. The expected security model is **network-level isolation**: anything that can send packets to the panel’s Ethernet interface is treated as trusted. This matches common security assumptions in EtherCAT-style control networks.
The panel is configured to administer its own subnet `10.10.10.0/24` via DHCP, while Wi-Fi can be used for upstream internet connectivity (if configured). If you need authentication or access from outside the isolated subnet, place a router/device in **client/bridge mode** on the panel network and expose the panel through a **reverse proxy** where you can add authentication, logging, rate limiting, etc.
If DNS is available on the subnet, you may be able to resolve `qitech.control`; otherwise use the static address `10.10.10.1`.
---
## List machines `GET /api/v2/machine`
Returns the set of machines currently known/connected to the panel.
Machines are identified by:
- `slug`: the machine type / model identifier (string)
- `serial`: the specific machine instance identifier (int)
Each machine also includes a `legacy_id` to support older **v1** workflows. If a machine reports an issue, the `error` field may be present and non-null (containing an error message).
### Example request
```bash
curl -X GET "http://10.10.10.1:3001/api/v2/machine"
```
### Example response
```json
{
"machines": [
{
"legacy_id": {
"machine_identification": {
"vendor": 1,
"machine": 7
},
"serial": 57922
},
"serial": 57922,
"vendor": "QiTech",
"slug": "mock",
"error": null
},
{
"legacy_id": {
"machine_identification": {
"vendor": 1,
"machine": 4
},
"serial": 57922
},
"serial": 57922,
"vendor": "QiTech",
"slug": "extruder_v1",
"error": null
},
{
"legacy_id": {
"machine_identification": {
"vendor": 1,
"machine": 2
},
"serial": 57922
},
"serial": 57922,
"vendor": "QiTech",
"slug": "winder_v1",
"error": null
},
{
"legacy_id": {
"machine_identification": {
"vendor": 1,
"machine": 10
},
"serial": 48879
},
"serial": 48879,
"vendor": "QiTech",
"slug": "wago_power_v1",
"error": null
}
]
}
```
---
## Get current values `GET /api/v2/machine/<slug>/<serial>`
Returns all currently known values for a single machine.
Values are categorized into two groups:
- **State**: requested/commanded values (these typically change only after a state-change request, or if another controller updates them)
- **Live Values**: measured/observed values coming from the machine and potentially changing quickly
This REST endpoint returns **only the current snapshot**, not a stream of live values.
To receive continuous updates (via WebSockets), subscribe to the machine namespace (see **WebSockets** below).
### Example request (mock machine)
```bash
curl -X GET "http://10.10.10.1:3001/api/v2/machine/mock/57922"
```
### Example response
```json
{
"machine": {
"legacy_id": {
"machine_identification": {
"vendor": 1,
"machine": 7
},
"serial": 57922
},
"serial": 57922,
"vendor": "QiTech",
"slug": "mock",
"error": null
},
"state": {
"frequency1": 100.0,
"frequency2": 200.0,
"frequency3": 500.0,
"is_default_state": false,
"mode_state": {
"mode": "Running"
}
},
"live_values": {
"amplitude1": -0.03438523433309566,
"amplitude2": -0.06872980145477608,
"amplitude3": -0.1711138370170743,
"amplitude_sum": -0.27422887280494607
}
}
```
---
## Change machine state `POST /api/v1/machine/<slug>/<serial>`
State changes are submitted as **mutations**. The mutation payload is defined per machine type in Rust. Conceptually, each item in the mutation list represents a setter-style operation that is applied by the real-time control loop.
The API does **not** return the newly-applied state in the POST response. The panel runs a real-time loop and generally won’t block waiting for the physical system to converge. Instead:
- Submit the mutation via `POST`
- Poll `GET /api/v2/machine/<slug>/<serial>` to observe the updated state and/or any reported errors
### Example request (mock machine)
```bash
curl -X POST \
-d \
-H "Content-Type: application/json" \
"http://10.10.10.1:3001/api/v1/machine/mock/57922"
```
### Example response
```json
null
```
---
## WebSockets
For continuous updates, subscribe to a machine-specific namespace derived from its `legacy_id`:
- Namespace: `/machine/<vendor>/<id>/<serial>`
The stream emits events for:
- state changes (`StateEvent`)
- live value updates (`LiveValuesEvent`)
Both event payloads use the same machine-specific schema as the `/api/v2` REST responses.
---
## List of all machines
Below is a template you can fill with links to the relevant Rust types (mutations + state/live structs).
For each machine, link to:
- **Mutations:** the request payload type used by `POST /api/v1/machine/<slug>/<serial>`
- **State / Live Values:** the response payload types returned by `GET /api/v2/machine/<slug>/<serial>`
### Machines
- **winder_v1**
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L87>
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L163>
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L143>
- **extruder_v1**
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L214>
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L89>
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L55>
- **laser_v1**
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L88>
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L32>
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L17>
- **mock**
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L84>
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L38>
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L24>
- **extruder_v2**
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L126>
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L93>
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L59>
+2 -1
View File
@@ -73,7 +73,7 @@ typings/
# vuepress build output
.vuepress/dist
# Serverless directories
# Serverless directoriesProcessMetrics
.serverless/
# FuseBox cache
@@ -88,6 +88,7 @@ typings/
# Vite
.vite/
dist/
dist-electron/
# Electron-Forge
out/
-59
View File
@@ -1,59 +0,0 @@
import type { ForgeConfig } from "@electron-forge/shared-types";
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
import { MakerZIP } from "@electron-forge/maker-zip";
import { MakerDeb } from "@electron-forge/maker-deb";
import { MakerRpm } from "@electron-forge/maker-rpm";
import { VitePlugin } from "@electron-forge/plugin-vite";
import { FusesPlugin } from "@electron-forge/plugin-fuses";
import { FuseV1Options, FuseVersion } from "@electron/fuses";
const config: ForgeConfig = {
packagerConfig: {
executableName: "electron-shadcn",
asar: true,
icon: "src/assets/icon",
},
rebuildConfig: {},
makers: [
new MakerSquirrel({}),
new MakerZIP({}, ["darwin"]),
new MakerRpm({}),
new MakerDeb({}),
],
plugins: [
new VitePlugin({
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
// If you are familiar with Vite configuration, it will look really familiar.
build: [
{
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
entry: "src/main.ts",
config: "vite.main.config.ts",
},
{
entry: "src/preload.ts",
config: "vite.preload.config.ts",
},
],
renderer: [
{
name: "main_window",
config: "vite.renderer.config.ts",
},
],
}),
// Fuses are used to enable/disable various Electron functionality
// at package time, before code signing the application
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
};
export default config;
-35
View File
@@ -1,35 +0,0 @@
export {}; // Make this a module
declare global {
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Vite
// plugin that tells the Electron app where to look for the Vite-bundled app code (depending on
// whether you're running in development or production).
const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
const MAIN_WINDOW_VITE_NAME: string;
namespace NodeJS {
interface Process {
// Used for hot reload after preload scripts.
viteDevServers: Record<string, import("vite").ViteDevServer>;
}
}
type VitePluginConfig = ConstructorParameters<
typeof import("@electron-forge/plugin-vite").VitePlugin
>[0];
interface VitePluginRuntimeKeys {
VITE_DEV_SERVER_URL: `${string}_VITE_DEV_SERVER_URL`;
VITE_NAME: `${string}_VITE_NAME`;
}
}
declare module "vite" {
interface ConfigEnv<
K extends keyof VitePluginConfig = keyof VitePluginConfig,
> {
root: string;
forgeConfig: VitePluginConfig;
forgeConfigSelf: VitePluginConfig[K][number];
}
}
+2042 -5250
View File
File diff suppressed because it is too large. Load diff
+8 -18
View File
@@ -6,10 +6,8 @@
"main": ".vite/build/main.js",
"private": true,
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"start": "vite",
"build": "vite build",
"lint": "eslint .",
"lint:write": "eslint . --fix",
"format": "prettier --check .",
@@ -22,15 +20,6 @@
},
"author": "",
"devDependencies": {
"@electron-forge/cli": "^7.6.1",
"@electron-forge/maker-deb": "^7.8.0",
"@electron-forge/maker-rpm": "^7.8.3",
"@electron-forge/maker-squirrel": "^7.6.1",
"@electron-forge/maker-zip": "^7.6.1",
"@electron-forge/plugin-auto-unpack-natives": "^7.6.1",
"@electron-forge/plugin-fuses": "^7.10.2",
"@electron-forge/plugin-vite": "^7.6.1",
"@electron-forge/shared-types": "^7.4.0",
"@electron/fuses": "^1.8.0",
"@eslint/compat": "^1.2.6",
"@eslint/js": "^9.18.0",
@@ -55,13 +44,15 @@
"globals": "^16.2.0",
"jsdom": "^26.0.0",
"postcss": "^8.5.2",
"prettier": "^3.6.2",
"prettier": "^3.7.3",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.4",
"ts-node": "^10.9.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.1",
"vite": "^6.4.1",
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6",
"vitest": "^3.0.6"
},
"dependencies": {
@@ -84,7 +75,6 @@
"@tanstack/react-router": "^1.130.17",
"@tanstack/react-table": "^8.21.2",
"@tanstack/router-devtools": "^1.105.0",
"@tanstack/zod-adapter": "^1.116.0",
"@vitejs/plugin-react": "^4.5.2",
"babel-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
"class-variance-authority": "^0.7.1",
@@ -109,9 +99,9 @@
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"uplot": "^1.6.32",
"xlsx": "^0.18.5",
"zod": "^3.24.2",
"zod-validation-error": "^3.4.0",
"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"
}
}
+7 -1
View File
@@ -9,6 +9,7 @@ import { RouterProvider } from "@tanstack/react-router";
import { Toaster } from "./components/ui/sonner";
import { enableMapSet } from "immer";
import { useGlobalLogStreaming } from "./hooks/useGlobalLogStreaming";
import { GlobalLaserToastManager } from "./setup/GlobalLaserToastManager";
export default function App() {
const { i18n } = useTranslation();
@@ -21,7 +22,12 @@ export default function App() {
updateAppLanguage(i18n);
}, [i18n]);
return <RouterProvider router={router} />;
return (
<>
<GlobalLaserToastManager />
<RouterProvider router={router} />
</>
);
}
// enable immer MapSet plugin
+31 -27
View File
@@ -15,25 +15,31 @@ import {
machineIdentificationUnique,
} from "@/machines/types";
import { useRef } from "react";
import { rustEnumSchema } from "@/lib/types";
import { rustEnum } from "@/lib/types";
export type EthercatDevices = z.infer<typeof ethercatDevicesSchema>;
export const ethercatDevicesSchema = z.object({
devices: z.array(
z.object({
configured_address: z.number().int(),
name: z.string(),
vendor_id: z.number().int(),
product_id: z.number().int(),
revision: z.number().int(),
device_identification: deviceIdentification,
}),
),
});
// Update the EthercatDevicesEventData schema
export const ethercatDevicesEventDataSchema = rustEnumSchema({
Initializing: z.boolean(),
Done: z.object({
devices: z.array(
z.object({
configured_address: z.number().int(),
name: z.string(),
vendor_id: z.number().int(),
product_id: z.number().int(),
revision: z.number().int(),
device_identification: deviceIdentification,
}),
),
}),
Error: z.string(),
});
export const ethercatDevicesEventDataSchema = z
.object({
Initializing: z.boolean(),
Done: ethercatDevicesSchema,
Error: z.string(),
})
.check(rustEnum);
export type EthercatDevicesEventData = z.infer<
typeof ethercatDevicesEventDataSchema
@@ -62,10 +68,12 @@ export const machinesEventSchema = eventSchema(machinesEventDataSchema);
export type MachinesEvent = z.infer<typeof machinesEventSchema>;
// Keep the EthercatInterfaceDiscovery event
export const ethercatInterfaceDiscoveryEventDataSchema = rustEnumSchema({
Discovering: z.boolean(),
Done: z.string(),
});
export const ethercatInterfaceDiscoveryEventDataSchema = z
.object({
Discovering: z.boolean(),
Done: z.string(),
})
.check(rustEnum);
export type EthercatInterfaceDiscoveryEventData = z.infer<
typeof ethercatInterfaceDiscoveryEventDataSchema
@@ -110,23 +118,19 @@ export function mainMessageHandler(
try {
// Apply appropriate caching strategy based on event type
if (eventName === "EthercatDevicesEvent") {
const validatedEvent = ethercatDevicesEventSchema.parse(event);
console.log("EthercatDevicesEvent", validatedEvent);
const validatedEvent = event;
store.setState((state) => ({
...state,
ethercatDevices: validatedEvent,
}));
} else if (eventName === "MachinesEvent") {
const validatedEvent = machinesEventSchema.parse(event);
console.log("MachinesEvent", validatedEvent);
store.setState((state) => ({
...state,
machines: validatedEvent,
}));
} else if (eventName === "EthercatInterfaceDiscoveryEvent") {
const validatedEvent =
ethercatInterfaceDiscoveryEventSchema.parse(event);
console.log("EthercatInterfaceDiscoveryEvent", validatedEvent);
const validatedEvent = event;
store.setState((state) => ({
...state,
ethercatInterfaceDiscovery: validatedEvent,
+4 -4
View File
@@ -8,8 +8,7 @@ import { z } from "zod";
import { toastError, toastZodError } from "@/components/Toast";
import { MachineIdentificationUnique } from "@/machines/types";
import { FPS_30 } from "@/lib/constants";
import { mainNamespaceStore, createMainNamespaceStore } from "./mainNamespace";
import { softReloadMainNamespaceStore } from "./softReload";
import { mainNamespaceStore } from "./mainNamespace";
/**
* Simple buffer-based store updater to limit React re-renders to ~30 FPS
@@ -164,6 +163,7 @@ export const handleEventValidationError = (
error: z.ZodError,
eventName: string,
) => {
console.log(error);
toastZodError(error, `Event Validation Error for ${eventName}`);
throw new Error(`Event validation failed for ${eventName}`);
};
@@ -385,11 +385,11 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
},
decrementNamespace: (namespaceId: NamespaceId) => {
const namespace_path = serializeNamespaceId(namespaceId);
/*const namespace_path = serializeNamespaceId(namespaceId);
// check if the namespace exists
const namespace = get().namespaces[namespace_path];
/*if (namespace) {
if (namespace) {
set(
produce((state: SocketioStore) => {
// decrement the count
+52 -2
View File
@@ -1,10 +1,16 @@
import { OutsideCorner } from "@/components/OutsideCorner";
import { useMachines } from "@/client/useMachines";
import { useOnSubpath } from "@/lib/useOnSubpath";
import { Link, Outlet } from "@tanstack/react-router";
import { Fragment } from "react";
import {
Link,
Outlet,
useRouter,
useRouterState,
} from "@tanstack/react-router";
import { Fragment, useEffect } from "react";
import React from "react";
import { Icon, IconName } from "./Icon";
import { useMainNamespace } from "@/client/mainNamespace";
type SidebarItemContent = {
link: string;
@@ -57,7 +63,51 @@ export function useSidebarlessWidth() {
export function SidebarLayout() {
const machines = useMachines();
const router = useRouter();
const routerState = useRouterState();
const { machines: machinesEvent } = useMainNamespace();
const [contentWidth, setContentWidth] = React.useState<number>(0);
// Machine connection guard: redirect to setup if selected machine disconnects
useEffect(() => {
// Wait for machines data to be loaded
if (!machinesEvent?.data) return;
// Extract serial from current path (e.g., /machines/laser1/12345/control)
const pathMatch = routerState.location.pathname.match(
/\/machines\/[^/]+\/(\d+)/,
);
if (!pathMatch) return; // Not on a machine page
// Safety check: ensure pathMatch has at least 2 elements (full match + capture group)
if (pathMatch.length < 2) {
console.warn("[ConnectionGuard] Invalid pathMatch structure:", pathMatch);
return;
}
// Parse serial number with error handling
const serialString = pathMatch[1];
const serialNumber = parseInt(serialString, 10);
// Check if parseInt succeeded (NaN means parsing failed)
if (isNaN(serialNumber)) {
console.warn(
`[ConnectionGuard] Failed to parse serial number from "${serialString}"`,
{ pathname: routerState.location.pathname },
);
return;
}
// Check if machine still exists in the raw machines data
const machineExists = machinesEvent.data.machines.some(
(m) => m.machine_identification_unique.serial === serialNumber,
);
if (!machineExists) {
router.navigate({ to: "/_sidebar/setup/machines" });
}
}, [machines, machinesEvent, routerState.location.pathname, router]);
const items: SidebarItemContent[] = [
...machines.map((machine) => ({
link: `/_sidebar/machines/${machine.slug}/${machine.machine_identification_unique.serial}/control`,
+5 -1
View File
@@ -63,7 +63,11 @@ export type DataSeries = SeriesData | SeriesData[];
export type BigGraphProps = {
newData: DataSeries;
unit?: Unit;
renderValue?: (value: number) => string;
renderValue?: (
value: number,
seriesIndex?: number,
seriesTitle?: string,
) => string;
config: GraphConfig;
graphId: string;
syncGraph?: PropGraphSync;
@@ -1,4 +1,3 @@
import { PresetData, PresetSchema } from "@/lib/preset/preset";
import {
Dialog,
DialogContent,
@@ -14,13 +13,13 @@ import { Separator } from "../ui/separator";
import { PresetPreviewEntries, PresetPreviewTable } from "./PresetPreviewTable";
import { Input } from "../ui/input";
export type NewPresetDialogProps<T extends PresetSchema> = {
currentState?: PresetData<T>;
export type NewPresetDialogProps<T> = {
currentState?: T;
previewEntries: PresetPreviewEntries<T>;
onSave: (name: string) => void;
};
export function NewPresetDialog<T extends PresetSchema>({
export function NewPresetDialog<T>({
currentState,
onSave,
previewEntries,
@@ -60,7 +60,7 @@ export function PresetCardMenu({
);
}
export type PresetCardProps<T extends PresetSchema> = {
export type PresetCardProps<T> = {
preset: Preset<T>;
onApply: (preset: Preset<T>) => void;
onOverwrite: (preset: Preset<T>) => void;
@@ -72,7 +72,7 @@ export type PresetCardProps<T extends PresetSchema> = {
isActive?: boolean;
};
export function PresetCard<T extends PresetSchema>({
export function PresetCard<T>({
preset,
onApply,
onOverwrite,
@@ -1,28 +1,27 @@
import React, { Fragment } from "react";
import { PresetData, PresetSchema } from "@/lib/preset/preset";
import { renderUnitSymbol, renderUnitSyntax, Unit } from "@/control/units";
export const previewSeparator = undefined;
type PreviewSeparator = typeof previewSeparator;
type PresetPreviewEntry<T extends PresetSchema> = {
type PresetPreviewEntry<T> = {
name: string;
unit?: Unit;
renderValue: (data: PresetData<T>) => string | undefined;
renderValue: (data: T) => string | undefined;
};
export type PresetPreviewEntries<T extends PresetSchema> = (
export type PresetPreviewEntries<T> = (
| PresetPreviewEntry<T>
| PreviewSeparator
)[];
export type PresetPreviewTableProps<T extends PresetSchema> = {
data: PresetData<T>;
export type PresetPreviewTableProps<T> = {
data?: T;
entries: PresetPreviewEntries<T>;
};
export function PresetPreviewTable<T extends PresetSchema>({
export function PresetPreviewTable<T>({
data,
entries,
}: PresetPreviewTableProps<T>) {
@@ -39,7 +38,7 @@ export function PresetPreviewTable<T extends PresetSchema>({
);
}
const value = entry.renderValue(data);
const value = data !== undefined ? entry.renderValue(data) : undefined;
return (
<Fragment key={entry.name}>
<div>{entry.name}</div>
@@ -1,4 +1,4 @@
import { Preset, PresetSchema } from "@/lib/preset/preset";
import { Preset } from "@/lib/preset/preset";
import {
Dialog,
DialogContent,
@@ -13,14 +13,14 @@ import { Icon } from "../Icon";
import { Separator } from "../ui/separator";
import { PresetPreviewEntries, PresetPreviewTable } from "./PresetPreviewTable";
export type PresetShowDialogProps<T extends PresetSchema> = {
export type PresetShowDialogProps<T> = {
preset: Preset<T>;
previewEntries: PresetPreviewEntries<T>;
onApply: (preset: Preset<T>) => void;
hideDate?: boolean;
};
export function PresetShowDialog<T extends PresetSchema>({
export function PresetShowDialog<T>({
preset,
onApply,
previewEntries,
@@ -2,19 +2,19 @@ import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { Page } from "@/components/Page";
import { usePresets, UsePresetsParams } from "@/lib/preset/usePresets";
import { Preset, PresetSchema } from "@/lib/preset/preset";
import { Preset } from "@/lib/preset/preset";
import { PresetCard } from "./PresetCard";
import { PresetPreviewEntries } from "./PresetPreviewTable";
import { NewPresetDialog } from "./NewPresetDialog";
import { downloadJson } from "@/lib/download";
import { JsonFileInput } from "../FileInput";
type PresetsPageProps<T extends PresetSchema> = UsePresetsParams<T> & {
type PresetsPageProps<T> = UsePresetsParams<T> & {
applyPreset: (preset: Preset<T>) => void;
previewEntries: PresetPreviewEntries<T>;
};
export function PresetsPage<T extends PresetSchema>({
export function PresetsPage<T>({
applyPreset,
machine_identification,
currentState,
+2 -13
View File
@@ -36,7 +36,6 @@ type Props = {
maxSlider?: number; // Override the slider max value
maxLabel?: string;
step?: number;
valueSchema?: z.ZodType<number>;
inverted?: boolean;
confirmation?: string;
renderValue: (value: number) => string;
@@ -89,7 +88,6 @@ export function EditValue({
description,
defaultValue,
step = 1,
valueSchema: schema,
inverted,
min,
max,
@@ -104,7 +102,7 @@ export function EditValue({
// Form setup
const formSchema = z.object({
value: schema ?? z.number(),
value: z.number(),
});
type FormSchema = z.infer<typeof formSchema>;
@@ -191,7 +189,6 @@ export function EditValue({
const numericValue = parseFloat(normalizedInput);
const hasValidationError =
isNaN(numericValue) ||
(schema && !schema.safeParse(numericValue).success) ||
(max !== undefined && numericValue > max) ||
(min !== undefined && numericValue < min);
@@ -222,15 +219,7 @@ export function EditValue({
setValueStringError(false);
preventFormSyncRef.current = false;
}
}, [
valueString,
valueStringDirty,
schema,
max,
min,
form,
roundToStepDecimals,
]);
}, [valueString, valueStringDirty, max, min, form, roundToStepDecimals]);
// Reset input state to clean form value
const resetInput = React.useCallback(() => {
+59 -6
View File
@@ -25,16 +25,29 @@ export function getUnitIcon(unit: Unit): IconName {
return "lu:Gauge";
case "mHz":
return "lu:AudioWaveform";
case "Hz":
return "lu:AudioWaveform";
case "W":
return "lu:Zap";
case "V":
return "lu:Zap";
case "A":
case "mA":
return "lu:Zap";
case "kWh":
return "lu:BatteryFull";
case "l/min":
return "lu:Waves";
case "%":
return "lu:ChartNoAxesColumn";
case "µs":
return "lu:RefreshCcw";
case "s":
return "lu:Clock";
case "MiB":
return "lu:MemoryStick";
case "Mbit/s":
return "lu:Network";
case "/s":
return "lu:Gauge";
default:
return "lu:ChartNoAxesColumn";
}
@@ -62,26 +75,42 @@ export function renderUnitSymbol(unit: Unit | undefined): string {
return "m/min";
case "mHz":
return "mHz";
case "Hz":
return "Hz";
case "W":
return "W";
case "V":
return "V";
case "mA":
return "mA";
case "A":
return "A";
case "kWh":
return "kWh";
case "l/min":
return "l/min";
case "%":
return "%";
case "µs":
return "µs";
case "s":
return "s";
case "MiB":
return "MiB";
case "Mbit/s":
return "Mbit/s";
case "/s":
return "/s";
default:
return "";
}
}
// this function will add prefix/suffix to the valueString but without unit symbol.
// example
// value: 10.0
// -> valueString: "10" (custom input)
// this function will add prefix/suffix to the valueString but without unit symbol
// -> "10°"
// const f = 3.1415;
// renderUnitSyntax(f.toFixed(2), "deg")
// result: "3.14°"
export function renderUnitSyntax(
valueString: string | undefined,
unit: Unit | undefined,
@@ -121,16 +150,32 @@ export function renderUnitSymbolLong(unit: Unit): string {
return "meters/minute";
case "mHz":
return "millihertz";
case "Hz":
return "hertz";
case "W":
return "watts";
case "V":
return "volts";
case "mA":
return "milliamperes";
case "A":
return "amperes";
case "kWh":
return "kilowatt-hours";
case "l/min":
return "liters/minute";
case "%":
return "percent";
case "µs":
return "microseconds";
case "s":
return "seconds";
case "MiB":
return "mebibytes";
case "Mbit/s":
return "megabits/second";
case "/s":
return "per second";
default:
return "";
}
@@ -147,11 +192,19 @@ export const units = [
"bar",
"m/min",
"mHz",
"Hz",
"W",
"V",
"mA",
"A",
"kWh",
"l/min",
"%",
"µs",
"s",
"MiB",
"Mbit/s",
"/s",
] as const;
export type Unit = (typeof units)[number];
+4 -1
View File
@@ -1,4 +1,7 @@
export function deepEquals<T>(a: T, b: T): boolean {
export function deepEquals(
a: object | undefined,
b: object | undefined,
): boolean {
if (a === b) {
return true;
}
+4 -8
View File
@@ -1,20 +1,16 @@
import { machineIdentification } from "@/machines/types";
import { z } from "zod";
export type PresetSchema = z.ZodTypeAny;
export type PresetSchema<T> = z.ZodType<T>;
export const presetSchema = <S extends PresetSchema>(dataSchema: S) =>
export const presetSchema = <T>(dataSchema: PresetSchema<T>) =>
z.object({
id: z.number().int().nonnegative().optional(),
name: z.string().nonempty(),
lastModified: z.coerce.date(),
machineIdentification,
schemaVersion: z.number().int().positive(),
data: dataSchema,
data: dataSchema.optional(),
});
export type Preset<S extends PresetSchema> = z.infer<
ReturnType<typeof presetSchema<S>>
>;
export type PresetData<S extends PresetSchema> = z.infer<S>;
export type Preset<T> = z.infer<ReturnType<typeof presetSchema<T>>>;
+7 -7
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { persist, PersistStorage, StorageValue } from "zustand/middleware";
import { Preset, PresetSchema, presetSchema } from "./preset";
import { Preset, presetSchema } from "./preset";
import { MachineIdentification } from "@/machines/types";
import { z } from "zod";
import { toastError } from "@/components/Toast";
@@ -33,9 +33,9 @@ type PresetStoreData = {
};
export type PresetStore = PresetStoreData & {
insert: <T extends PresetSchema>(preset: Preset<T>) => PersistedPreset;
update: <T extends PresetSchema>(preset: Preset<T>) => void;
remove: <T extends PresetSchema>(preset: Preset<T>) => void;
insert: <T>(preset: Preset<T>) => PersistedPreset;
update: <T>(preset: Preset<T>) => void;
remove: <T>(preset: Preset<T>) => void;
getById: (id: number) => PersistedPreset | undefined;
setLatestPresetId: (
@@ -100,7 +100,7 @@ export const usePresetStore = create<PresetStore>()(
presets: [],
latestPresetIds: new Map(),
insert: <T extends PresetSchema>(preset: Preset<T>) => {
insert: <T>(preset: Preset<T>) => {
const state = get();
const { presets } = state;
const ids = presets.map((p: PersistedPreset) => p.id);
@@ -115,7 +115,7 @@ export const usePresetStore = create<PresetStore>()(
return persistedPreset;
},
update: <T extends PresetSchema>(preset: Preset<T>) => {
update: <T>(preset: Preset<T>) => {
const state = get();
const presets: PersistedPreset[] = state.presets.map(
(p: PersistedPreset) =>
@@ -124,7 +124,7 @@ export const usePresetStore = create<PresetStore>()(
set({ ...state, presets });
},
remove: <T extends PresetSchema>(preset: Preset<T>) => {
remove: <T>(preset: Preset<T>) => {
const state = get();
const presets = state.presets.filter(
(p: Preset<any>) => p.id !== preset.id,
+22 -11
View File
@@ -3,13 +3,13 @@ import {
machineIdentificationEquals,
} from "@/machines/types";
import { usePresetStore } from "./presetStore";
import { Preset, PresetData, presetSchema, PresetSchema } from "./preset";
import { Preset, presetSchema, PresetSchema } from "./preset";
import { deepEquals } from "@/lib/objects";
import { useEffect } from "react";
import { toastError } from "@/components/Toast";
import { z } from "zod";
export type Presets<T extends PresetSchema> = {
export type Presets<T> = {
get: () => Preset<T>[];
createFromCurrentState: (name: string) => Preset<T>;
updateFromCurrentState: (preset: Preset<T>) => Preset<T>;
@@ -20,15 +20,15 @@ export type Presets<T extends PresetSchema> = {
import: (json: any) => void;
};
export type UsePresetsParams<T extends PresetSchema> = {
export type UsePresetsParams<T> = {
machine_identification: MachineIdentification;
schemas: Map<number, T>;
schemas: Map<number, PresetSchema<T>>;
schemaVersion: number;
currentState?: PresetData<T>;
defaultState?: PresetData<T>;
currentState?: T;
defaultState?: T;
};
export function usePresets<T extends PresetSchema>({
export function usePresets<T>({
machine_identification,
schemas,
schemaVersion,
@@ -49,7 +49,10 @@ export function usePresets<T extends PresetSchema>({
}
try {
const parsed = presetSchema(schema).parse(preset);
const parsed: Preset<T> = {
...preset,
data: schema.parse(preset.data),
};
if (parsed.schemaVersion < schemaVersion) {
parsed.schemaVersion = schemaVersion;
@@ -93,7 +96,11 @@ export function usePresets<T extends PresetSchema>({
return { ...preset, data: currentState };
};
const updateFromCurrentState = (preset: Preset<T>) => {
const updateFromCurrentState = (preset: Preset<T>): Preset<T> => {
if (!currentState) {
return preset;
}
const data = currentState;
const newPreset = {
@@ -153,12 +160,16 @@ export function usePresets<T extends PresetSchema>({
};
const isActive = (preset: Preset<T>) => {
return deepEquals(currentState, preset.data);
if (!currentState) {
return false;
}
return deepEquals(currentState, preset.data ?? {});
};
const importPreset = (json: any) => {
try {
const anyPreset = presetSchema(z.any()).parse(json);
const anyPreset = presetSchema(z.object()).parse(json);
if (
!machineIdentificationEquals(
+31 -4
View File
@@ -38,6 +38,28 @@ export interface TimeSeriesWithInsert {
insert: (series: TimeSeries, valueObj: TimeSeriesValue) => TimeSeries;
}
/**
* Configuration object for creating a time series
*/
export interface TimeSeriesConfig {
sampleIntervalShort: number;
sampleIntervalLong: number;
retentionDurationShort: number;
retentionDurationLong: number;
}
/**
* Default configuration values
* Short: 20ms sample, 5s retention
* Long: 1s sample, 1h retention
*/
export const DEFAULT_TIMESERIES_CONFIG: TimeSeriesConfig = {
sampleIntervalShort: 20, // 20ms
sampleIntervalLong: 1000, // 1s
retentionDurationShort: 5000, // 5s
retentionDurationLong: 60 * 60 * 1000, // 1h
};
/**
* Extract data with time window filtering in chronological order
*/
@@ -177,13 +199,18 @@ export function seriesToUPlotData(
/**
* Factory function to create a new time series with circular buffers
* @param config Optional configuration object. If omitted or partial, defaults from DEFAULT_TIMESERIES_CONFIG are used.
*/
export const createTimeSeries = (
sampleIntervalShort: number,
sampleIntervalLong: number,
retentionDurationShort: number,
retentionDurationLong: number,
config: Partial<TimeSeriesConfig> = {},
): TimeSeriesWithInsert => {
const {
sampleIntervalShort,
sampleIntervalLong,
retentionDurationShort,
retentionDurationLong,
} = { ...DEFAULT_TIMESERIES_CONFIG, ...config };
const shortSize = Math.ceil(retentionDurationShort / sampleIntervalShort);
const longSize = Math.ceil(retentionDurationLong / sampleIntervalLong);
+13 -34
View File
@@ -1,38 +1,17 @@
import { z } from "zod";
/**
* Creates a Zod schema for objects where exactly one property must be defined.
* This is useful for handling Rust enum JSON representations where only one variant exists at a time.
*
* @param schemas A record mapping property names to their Zod schemas
* @returns A Zod schema that validates that exactly one property is defined
*/
export function rustEnumSchema<T extends Record<string, z.ZodType>>(
schemas: T,
): z.ZodType<{ [K in keyof T]?: z.infer<T[K]> }> {
// Create an object schema where all properties are optional
const schemaEntries = Object.entries(schemas).map(([key, schema]) => [
key,
schema.optional(),
]);
const objectSchema = z.object(
Object.fromEntries(schemaEntries) as {
[K in keyof T]: z.ZodOptional<T[K]>;
},
export const rustEnum: z.core.CheckFn<object> = (ctx) => {
const definedKeys = Object.keys(ctx.value).filter(
(key) => ctx.value[key] !== undefined,
);
// Add refinement to ensure exactly one property is defined
return objectSchema.refine(
(data) => {
const definedKeys = Object.keys(data).filter(
(key) => data[key] !== undefined,
);
return definedKeys.length === 1;
},
{
message: "Exactly one property must be defined",
path: ["_exclusive"], // Add a path to help with error identification
},
);
}
if (definedKeys.length === 1) {
return;
}
ctx.issues.push({
code: "custom",
message: "Exactly one property must be defined",
input: definedKeys,
});
};
-19
View File
@@ -1,19 +0,0 @@
import { z } from "zod";
// super refine function to validate if a string is a valid u16 number
export function validateU16<T>(): (value: T, ctx: z.RefinementCtx) => boolean {
const refine = (value: T, ctx: z.RefinementCtx) => {
console.log("value", value);
if (typeof value === "string") {
const number = parseInt(value, 10);
if (number < 0 || number > 65535) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Wert muss u16 integer sein",
});
}
}
return true;
};
return refine;
}
@@ -0,0 +1,59 @@
import { MiniGraph } from "@/components/graph/MiniGraph";
import { Page } from "@/components/Page";
import { ControlCard } from "@/control/ControlCard";
import { ControlGrid } from "@/control/ControlGrid";
import React from "react";
import { useAnalogInputTestMachine } from "./useAnalogInputTestMachine";
export function AnalogInputTestMachineControl(): React.JSX.Element {
const { seriesData, state, updateMeasurementRate } =
useAnalogInputTestMachine();
if (!seriesData) return <div>Initializing Sensor...</div>;
return (
<Page>
<ControlGrid columns={2}>
<ControlCard title="Measurement Control">
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
}}
>
<div>Measurement Rate (Hz)</div>
<input
type="number"
style={{
border: "2px solid transparent",
borderRadius: "4px",
padding: "3px",
}}
className={"bg-neutral-100 shadow"}
value={state.measurementRateHz}
onChange={(v) => {
const val = Number(v.currentTarget.value);
updateMeasurementRate(val);
}}
min={1}
></input>
</div>
</ControlCard>
<ControlCard title="Results">
<div className="flex flex-row">
<MiniGraph
newData={seriesData}
width={500}
renderValue={(v) => v.toFixed(2)}
></MiniGraph>
<div className="flex flex-col justify-center">
{seriesData.current?.value.toFixed(2)}mA
</div>
</div>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,21 @@
import { Topbar } from "@/components/Topbar";
import { analogInputTestMachineSerialRoute } from "@/routes/routes";
import React from "react";
export function AnalogInputTestMachine(): React.JSX.Element {
const { serial } = analogInputTestMachineSerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/analoginputtestmachine/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:CirclePlay",
},
]}
/>
);
}
@@ -0,0 +1,107 @@
import { useMachineMutate } from "@/client/useClient";
import {
createTimeSeries,
TimeSeries,
TimeSeriesValue,
TimeSeriesWithInsert,
} from "@/lib/timeseries";
import { useEffect, useMemo, useState } from "react";
import z from "zod";
import {
StateEvent,
useAnalogInputTestMachineNamespace,
} from "./useAnalogInputTestMachineNamespace";
import { toastError } from "@/components/Toast";
import { MachineIdentificationUnique } from "../types";
import { analogInputTestMachineSerialRoute } from "@/routes/routes";
import { analogInputTestMachine } from "../properties";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { produce } from "immer";
export function useAnalogInputTestMachine() {
const { serial: serialString } =
analogInputTestMachineSerialRoute.useParams();
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: analogInputTestMachine.machine_identification,
serial,
};
}, [serialString]);
const state = useAnalogInputTestMachineNamespace({
type: "machine",
machine_identification_unique: machineIdentification,
});
const mutateMachine = useMachineMutate(
z.object({
measurement_rate_hz: z.number(),
}),
);
const stateOptimistic = useStateOptimistic<StateEvent>();
const updateStateOptimistically = (
producer: (current: StateEvent) => void,
serverRequest?: () => void,
) => {
const currentState = stateOptimistic.value;
if (currentState)
stateOptimistic.setOptimistic(produce(currentState, producer));
serverRequest?.();
};
const updateMeasurementRate = (val: number) => {
updateStateOptimistically(
(current) => {
current.measurementRateHz = val;
},
() => {
mutateMachine.request({
machine_identification_unique: machineIdentification,
data: { measurement_rate_hz: val },
});
},
);
};
const timeSeries: TimeSeriesWithInsert = createTimeSeries({
sampleIntervalShort: 20,
sampleIntervalLong: 1000,
retentionDurationShort: 5000,
retentionDurationLong: 60 * 60 * 1000,
});
const [seriesData, setSeriesData] = useState<TimeSeries | null>(
timeSeries.initialTimeSeries,
);
useEffect(() => {
const measurement = state.currentMeasurement;
if (measurement && seriesData) {
const dataPoint: TimeSeriesValue = {
value: measurement[0] * 1000,
timestamp: Number(measurement[1]),
};
setSeriesData(timeSeries.insert(seriesData, dataPoint));
}
}, [state]);
return {
seriesData,
state,
updateMeasurementRate,
};
}
@@ -0,0 +1,67 @@
import {
createNamespaceHookImplementation,
EventHandler,
eventSchema,
} from "@/client/socketioStore";
import z from "zod";
import { create, StoreApi } from "zustand";
// ========== Event Schema ==========
export const stateEventDataSchema = z.object({
measurementRateHz: z.number().optional(),
currentMeasurement: z.tuple([z.number(), z.string()]).optional(),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
// ========== Store ==========
export type AnalogInputTestMachineNamespaceStore = z.infer<
typeof stateEventDataSchema
>;
const createMachineStore = (): StoreApi<AnalogInputTestMachineNamespaceStore> =>
create<AnalogInputTestMachineNamespaceStore>(() => ({
measurementRateHz: 1,
currentMeasurement: undefined,
}));
// ========== Message Handler ==========
function analogInputTestMachineMessageHandler(
store: StoreApi<AnalogInputTestMachineNamespaceStore>,
): EventHandler {
return (event) => {
const oldState = store.getState();
const newMeasurementDataHz = event.data["MeasurementRateHz"];
const newMeasurement: [number, string] = event.data["Measurement"];
switch (event.name) {
case "MeasurementRateHz":
if (
newMeasurementDataHz &&
newMeasurementDataHz !== oldState.measurementRateHz
)
store.setState({
...oldState,
measurementRateHz: newMeasurementDataHz,
});
break;
case "Measurement":
if (newMeasurement && newMeasurement !== oldState.currentMeasurement)
store.setState({
...oldState,
currentMeasurement: newMeasurement,
});
break;
}
};
}
// ========== Namespace Hook ==========
export const useAnalogInputTestMachineNamespace =
createNamespaceHookImplementation({
createStore: () => createMachineStore(),
createEventHandler: analogInputTestMachineMessageHandler,
});
@@ -39,7 +39,7 @@ export function Aquapath1ControlPage() {
<div className="grid grid-cols-2 gap-6">
<div className="flex flex-col gap-8">
<TimeSeriesValueNumeric
label="Front Flow"
label="Flow 1"
unit="l/min"
timeseries={front_flow}
renderValue={(value) => value.toFixed(1)}
@@ -48,7 +48,7 @@ export function Aquapath1ControlPage() {
<div className="flex flex-col gap-8">
<TimeSeriesValueNumeric
label="Back Flow"
label="Flow 2"
unit="l/min"
timeseries={back_flow}
renderValue={(value) => value.toFixed(1)}
@@ -57,7 +57,7 @@ export function Aquapath1ControlPage() {
</div>
<div className="mt-8">
<Label label="Front Pump">
<Label label="Pump 1">
<SelectionGroup<"On" | "Off">
value={frontTargetFlow ? "On" : "Off"}
orientation="vertical"
@@ -86,7 +86,7 @@ export function Aquapath1ControlPage() {
/>
</Label>
<Label label="Back Pump">
<Label label="Pump 2">
<SelectionGroup<"On" | "Off">
value={backTargetFlow ? "On" : "Off"}
orientation="vertical"
@@ -121,22 +121,19 @@ export function Aquapath1ControlPage() {
<div className="grid grid-cols-2 gap-6">
<div className="flex flex-col gap-8">
<TimeSeriesValueNumeric
label="Front in"
label="Reservoir 1"
unit="C"
timeseries={front_temperature}
renderValue={(value) => value.toFixed(1)}
/>
<TimeSeriesValueNumeric
label="Front Reservoir"
unit="C"
timeseries={front_temp_reservoir}
renderValue={(value) => value.toFixed(1)}
/>
<Label label="Set Front Target Temperature">
<Label label="Set Reservoir 1 Target Temperature">
<EditValue
title="Set Front Target Temperature"
title="Set Reservoir 1 Target Temperature"
value={frontTargetTemperature}
unit="C"
step={0.1}
min={0.0}
max={80.0}
renderValue={(value) => value.toFixed(1)}
onChange={(val) => {
setFrontTemperature(val);
@@ -150,22 +147,19 @@ export function Aquapath1ControlPage() {
<div className="flex flex-col gap-8">
<TimeSeriesValueNumeric
label="Back in"
label="Reservoir 2"
unit="C"
timeseries={back_temperature}
renderValue={(value) => value.toFixed(1)}
/>
<TimeSeriesValueNumeric
label="Back Reservoir"
unit="C"
timeseries={back_temp_reservoir}
renderValue={(value) => value.toFixed(1)}
/>
<Label label="Set Back Target Temperature">
<Label label="Set Reservoir 2 Target Temperature">
<EditValue
title="Set Back Target Temperature"
title="Set Reservoir 2 Target Temperature"
value={backTargetTemperature}
unit="C"
step={0.1}
min={0.0}
max={80.0}
renderValue={(value) => value.toFixed(1)}
onChange={(val) => {
setBackTemperature(val);
@@ -98,37 +98,17 @@ export type Aquapath1NamespaceStore = {
back_temp_reservoir: TimeSeries;
};
// Constants for time durations
const TWENTY_MILLISECOND = 20;
const ONE_SECOND = 1000;
const FIVE_SECOND = 5 * ONE_SECOND;
const ONE_HOUR = 60 * 60 * ONE_SECOND;
const { initialTimeSeries: front_temperature, insert: addTemperature1 } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: back_temperature, insert: addTemperature2 } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: front_temp_reservoir, insert: addTempReserv1 } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: back_temp_reservoir, insert: addTempReserv2 } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: front_flow, insert: addFlow1 } = createTimeSeries();
const { initialTimeSeries: back_flow, insert: addFlow2 } = createTimeSeries();
const { initialTimeSeries: front_flow, insert: addFlow1 } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const { initialTimeSeries: back_flow, insert: addFlow2 } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
/**
* Factory function to create a new Aquapath namespace store
* @returns A new Zustand store instance for Aquapath namespace
@@ -152,7 +152,7 @@ export function Extruder2ControlPage() {
unit="rpm"
title="Target Output RPM"
min={0}
max={106}
max={44}
renderValue={(value) => roundToDecimals(value, 0)}
onChange={setInverterTargetRpm}
/>
@@ -7,7 +7,7 @@ import {
PresetPreviewEntries,
previewSeparator,
} from "@/components/preset/PresetPreviewTable";
import { Preset, PresetData } from "@/lib/preset/preset";
import { Preset } from "@/lib/preset/preset";
const extruder1PresetDataSchema = z
.object({
@@ -31,7 +31,7 @@ const extruder1PresetDataSchema = z
})
.partial();
type Extruder2 = typeof extruder1PresetDataSchema;
type Extruder2 = z.infer<typeof extruder1PresetDataSchema>;
const schemas = new Map([[1, extruder1PresetDataSchema]]);
@@ -39,76 +39,69 @@ const previewEntries: PresetPreviewEntries<Extruder2> = [
{
name: "Target Front Temperature",
unit: "C",
renderValue: (data: PresetData<Extruder2>) =>
renderValue: (data: Extruder2) =>
data.targetFrontHeatingTemperature?.toFixed(1),
},
{
name: "Target Middle Temperature",
unit: "C",
renderValue: (data: PresetData<Extruder2>) =>
renderValue: (data: Extruder2) =>
data.targetMiddleHeatingTemperature?.toFixed(1),
},
{
name: "Target Back Temperature",
unit: "C",
renderValue: (data: PresetData<Extruder2>) =>
renderValue: (data: Extruder2) =>
data.targetBackHeatingTemperature?.toFixed(1),
},
{
name: "Target Nozzle Temperature",
unit: "C",
renderValue: (data: PresetData<Extruder2>) =>
renderValue: (data: Extruder2) =>
data.targetNozzleHeatingTemperature?.toFixed(1),
},
previewSeparator,
{
name: "Inverter Regulation",
renderValue: (data: PresetData<Extruder2>) => data.inverterRegulation,
renderValue: (data: Extruder2) => data.inverterRegulation,
},
{
name: "Target Inverter RPM",
unit: "rpm",
renderValue: (data: PresetData<Extruder2>) =>
data.targetInverterRpm?.toFixed(2),
renderValue: (data: Extruder2) => data.targetInverterRpm?.toFixed(2),
},
{
name: "Target Inverter Pressure",
unit: "bar",
renderValue: (data: PresetData<Extruder2>) =>
data.targetInverterPressure?.toFixed(1),
renderValue: (data: Extruder2) => data.targetInverterPressure?.toFixed(1),
},
{
name: "Inverter Direction",
renderValue: (data: PresetData<Extruder2>) =>
data.inverterRotationDirection,
renderValue: (data: Extruder2) => data.inverterRotationDirection,
},
previewSeparator,
{
name: "Pressure Limit",
unit: "bar",
renderValue: (data: PresetData<Extruder2>) =>
data.pressureLimit?.toFixed(1),
renderValue: (data: Extruder2) => data.pressureLimit?.toFixed(1),
},
{
name: "Enable Pressure Limit",
renderValue: (data: PresetData<Extruder2>) =>
renderValue: (data: Extruder2) =>
data.pressureLimitEnabled ? "on" : "off",
},
previewSeparator,
{
name: "PID Pressue Kp",
renderValue: (data: PresetData<Extruder2>) =>
data.pidPressureKp?.toFixed(4),
renderValue: (data: Extruder2) => data.pidPressureKp?.toFixed(4),
},
{
name: "PID Pressue Ki",
renderValue: (data: PresetData<Extruder2>) =>
data.pidPressureKi?.toFixed(4),
renderValue: (data: Extruder2) => data.pidPressureKi?.toFixed(4),
},
{
name: "PID Pressue Kd",
renderValue: (data: PresetData<Extruder2>) =>
data.pidPressureKd?.toFixed(4),
renderValue: (data: Extruder2) => data.pidPressureKd?.toFixed(4),
},
];
@@ -135,7 +128,7 @@ export function Extruder2PresetsPage() {
setPressurePidKp,
} = useExtruder2();
const toPresetData = (s?: typeof state): PresetData<Extruder2> => ({
const toPresetData = (s?: typeof state): Extruder2 => ({
targetFrontHeatingTemperature: s?.heating_states.front.target_temperature,
targetMiddleHeatingTemperature: s?.heating_states.middle.target_temperature,
targetBackHeatingTemperature: s?.heating_states.back.target_temperature,
@@ -161,32 +154,32 @@ export function Extruder2PresetsPage() {
const applyPreset = (preset: Preset<Extruder2>) => {
setFrontHeatingTemperature(
preset.data.targetFrontHeatingTemperature ?? 150.0,
preset?.data?.targetFrontHeatingTemperature ?? 150.0,
);
setMiddleHeatingTemperature(
preset.data.targetMiddleHeatingTemperature ?? 150.0,
preset?.data?.targetMiddleHeatingTemperature ?? 150.0,
);
setBackHeatingTemperature(
preset.data.targetBackHeatingTemperature ?? 150.0,
preset?.data?.targetBackHeatingTemperature ?? 150.0,
);
setNozzleHeatingTemperature(
preset.data.targetNozzleHeatingTemperature ?? 150.0,
preset?.data?.targetNozzleHeatingTemperature ?? 150.0,
);
setInverterTargetRpm(preset.data.targetInverterRpm ?? 0);
setInverterTargetPressure(preset.data.targetInverterPressure ?? 0);
setInverterRegulation(preset.data.inverterRegulation === "RPM");
setInverterTargetRpm(preset?.data?.targetInverterRpm ?? 0);
setInverterTargetPressure(preset?.data?.targetInverterPressure ?? 0);
setInverterRegulation(preset?.data?.inverterRegulation === "RPM");
setInverterRotationDirection(
preset.data.inverterRotationDirection === "Forward",
preset?.data?.inverterRotationDirection === "Forward",
);
setExtruderPressureLimit(preset.data.pressureLimit ?? 100.0);
setExtruderPressureLimitEnabled(preset.data.pressureLimitEnabled ?? true);
setExtruderPressureLimit(preset?.data?.pressureLimit ?? 100.0);
setExtruderPressureLimitEnabled(preset?.data?.pressureLimitEnabled ?? true);
setPressurePidKp(preset.data.pidPressureKp ?? 0.16);
setPressurePidKi(preset.data.pidPressureKi ?? 0.0);
setPressurePidKd(preset.data.pidPressureKd ?? 0.008);
setPressurePidKp(preset?.data?.pidPressureKp ?? 0.16);
setPressurePidKi(preset?.data?.pidPressureKi ?? 0.0);
setPressurePidKd(preset?.data?.pidPressureKd ?? 0.008);
};
return (
@@ -223,64 +223,35 @@ export type Extruder2NamespaceStore = {
totalEnergyKWh: TimeSeries;
};
// Constants for time durations
const TWENTY_MILLISECOND = 20;
const ONE_SECOND = 1000;
const FIVE_SECOND = 5 * ONE_SECOND;
const ONE_HOUR = 60 * 60 * ONE_SECOND;
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries();
const { initialTimeSeries: backTemperature, insert: addBackTemperature } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: frontTemperature, insert: addFrontTemperature } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: middleTemperature, insert: addMiddleTemperature } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: nozzleTemperature, insert: addNozzleTemperature } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: nozzlePower, insert: addNozzlePower } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: frontPower, insert: addFrontPower } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: middlePower, insert: addMiddlePower } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: backPower, insert: addBackPower } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
createTimeSeries();
const { initialTimeSeries: backPower, insert: addBackPower } =
createTimeSeries();
const { initialTimeSeries: combinedPower, insert: addCombinedPower } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: totalEnergyKWh, insert: addTotalEnergyKWh } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: motorCurrent, insert: addMotorCurrent } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: motorFrequency, insert: addMotorFrequency } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: motorScrewRpm, insert: addMotorScrewRpm } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
const { initialTimeSeries: motorPower, insert: addMotorPower } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries();
export function extruder2MessageHandler(
store: StoreApi<Extruder2NamespaceStore>,
@@ -0,0 +1,258 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import {
SelectionGroup,
SelectionGroupBoolean,
} from "@/control/SelectionGroup";
import { HeatingZone } from "../HeatingZone";
import { Label } from "@/control/Label";
import { EditValue } from "@/control/EditValue";
import { roundToDecimals } from "@/lib/decimal";
import { useExtruder3 } from "./useExtruder";
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
import { StatusBadge } from "@/control/StatusBadge";
export function Extruder3ControlPage() {
const {
state,
defaultState,
nozzleTemperature,
nozzlePower,
frontTemperature,
frontPower,
backTemperature,
backPower,
middleTemperature,
middlePower,
pressure,
motorScrewRpm,
motorPower,
motorCurrent,
totalEnergyKWh,
combinedPower,
setExtruderMode,
setBackHeatingTemperature,
setFrontHeatingTemperature,
setMiddleHeatingTemperature,
setNozzleHeatingTemperature,
setInverterRegulation,
setInverterTargetPressure,
setInverterTargetRpm,
isLoading,
isDisabled,
} = useExtruder3();
function isZoneReadyForExtrusion(
temperature: number,
targetTemperature: number,
) {
// if temperature is 90% of the target temperature, then we are ready for extrusion
return temperature >= 0.9 * targetTemperature && targetTemperature > 0.0;
}
const allZonesReadyForExtrude = () => {
const frontReady = isZoneReadyForExtrusion(
frontTemperature.current?.value ?? 0,
state?.heating_states.front.target_temperature ?? 1,
);
const middleReady = isZoneReadyForExtrusion(
middleTemperature.current?.value ?? 0,
state?.heating_states.middle.target_temperature ?? 1,
);
const backReady = isZoneReadyForExtrusion(
backTemperature.current?.value ?? 0,
state?.heating_states.back.target_temperature ?? 1,
);
const nozzleReady = isZoneReadyForExtrusion(
nozzleTemperature.current?.value ?? 0,
state?.heating_states.nozzle.target_temperature ?? 1,
);
return frontReady && middleReady && backReady && nozzleReady;
};
return (
<Page>
<ControlGrid>
<HeatingZone
title={"Heating Front"}
heatingState={state?.heating_states.front}
heatingTimeSeries={frontTemperature}
heatingPower={frontPower}
onChangeTargetTemp={setFrontHeatingTemperature}
min={0}
max={300}
/>
<HeatingZone
title={"Heating Middle"}
heatingState={state?.heating_states.middle}
heatingTimeSeries={middleTemperature}
heatingPower={middlePower}
onChangeTargetTemp={setMiddleHeatingTemperature}
min={0}
max={300}
/>
<HeatingZone
title={"Heating Back"}
heatingState={state?.heating_states.back}
heatingTimeSeries={backTemperature}
heatingPower={backPower}
onChangeTargetTemp={setBackHeatingTemperature}
min={0}
max={300}
/>
<HeatingZone
title={"Heating Nozzle"}
heatingState={state?.heating_states.nozzle}
heatingTimeSeries={nozzleTemperature}
heatingPower={nozzlePower}
onChangeTargetTemp={setNozzleHeatingTemperature}
min={0}
max={300}
/>
<ControlCard className="bg-red" title="Screw Drive">
{state?.inverter_status_state.overload_warning == true ? (
<StatusBadge variant="error">
Inverter is overloaded! Please check the extruder and reduce load
if necessary.
</StatusBadge>
) : state?.inverter_status_state.fault_occurence == true ? (
<StatusBadge variant="error">
Inverter encountered an error!! Press the restart button in Config
</StatusBadge>
) : state?.inverter_status_state.running == true &&
state.inverter_status_state.fault_occurence == false ? (
<StatusBadge variant="success">Running</StatusBadge>
) : null}
{state?.inverter_status_state.running == false &&
state.inverter_status_state.fault_occurence == false && (
<StatusBadge variant="success">Healthy</StatusBadge>
)}
<Label label="Regulation">
<SelectionGroupBoolean
value={state?.regulation_state.uses_rpm}
optionTrue={{ children: "RPM" }}
optionFalse={{ children: "Pressure" }}
onChange={setInverterRegulation}
disabled={isDisabled}
loading={isLoading}
/>
</Label>
<div className="flex flex-row flex-wrap gap-4">
<Label label="Target Output RPM">
<EditValue
value={state?.screw_state.target_rpm}
defaultValue={defaultState?.screw_state.target_rpm}
unit="rpm"
title="Target Output RPM"
min={0}
max={100}
renderValue={(value) => roundToDecimals(value, 0)}
onChange={setInverterTargetRpm}
/>
</Label>
<Label label="Target Pressure">
<EditValue
value={state?.pressure_state.target_bar}
defaultValue={defaultState?.pressure_state.target_bar}
unit="bar"
title="Target Pressure"
min={0.0}
max={270.0}
renderValue={(value) => roundToDecimals(value, 0)}
onChange={setInverterTargetPressure}
/>
</Label>
</div>
<div className="flex flex-row flex-wrap gap-4">
<TimeSeriesValueNumeric
label="Rpm"
unit="rpm"
renderValue={(value) => roundToDecimals(value, 0)}
timeseries={motorScrewRpm}
/>
{state?.pressure_state?.wiring_error && (
<StatusBadge variant="error">
Cant Measure Pressure! Check Pressure Sensor Wiring!
</StatusBadge>
)}
<TimeSeriesValueNumeric
label="Pressure"
unit="bar"
renderValue={(value) => roundToDecimals(value, 0)}
timeseries={pressure}
/>
</div>
</ControlCard>
<ControlCard className="bg-red" title="Mode">
<SelectionGroup<"Standby" | "Heat" | "Extrude">
value={state?.mode_state.mode}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Standby: {
children: "Standby",
icon: "lu:CirclePause",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Heat: {
children: "Heat",
icon: "lu:Flame",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Extrude: {
children: "Extrude",
icon: "lu:ArrowBigLeftDash",
isActiveClassName: "bg-green-600",
className: "h-full",
confirmation: allZonesReadyForExtrude()
? undefined
: "Temperature is too low. Are you sure you want to extrude?",
},
}}
onChange={setExtruderMode}
disabled={isDisabled}
loading={isLoading}
/>
</ControlCard>
<ControlCard className="bg-blue" title="Power Consumption">
<TimeSeriesValueNumeric
label="Total Power"
unit="W"
renderValue={(value) => roundToDecimals(value, 0)}
timeseries={combinedPower}
/>
<TimeSeriesValueNumeric
label="Motor Power"
unit="W"
renderValue={(value) => roundToDecimals(value, 0)}
timeseries={motorPower}
/>
<TimeSeriesValueNumeric
label="Motor Current"
unit="A"
renderValue={(value) => roundToDecimals(value, 1)}
timeseries={motorCurrent}
/>
<TimeSeriesValueNumeric
label="Total Energy Consumption"
unit="kWh"
renderValue={(value) => roundToDecimals(value, 3)}
timeseries={totalEnergyKWh}
/>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,325 @@
import { Page } from "@/components/Page";
import {
AutoSyncedBigGraph,
SyncedFloatingControlPanel,
useGraphSync,
type GraphConfig,
} from "@/components/graph";
import React from "react";
import { useExtruder3 } from "./useExtruder";
export function Extruder3GraphsPage() {
const {
state,
nozzleTemperature,
nozzlePower,
frontTemperature,
frontPower,
backTemperature,
backPower,
middleTemperature,
middlePower,
pressure,
motorScrewRpm,
motorCurrent,
motorPower,
combinedPower,
} = useExtruder3();
const syncHook = useGraphSync("extruder-graphs");
// Base config
const baseConfig: GraphConfig = {
defaultTimeWindow: 30 * 60 * 1000, // 30 minutes
exportFilename: "extruder_data",
showLegend: true,
title: "extruder data",
};
const temperatureData = [
...(nozzleTemperature
? [
{
newData: nozzleTemperature,
title: "Nozzle",
color: "#ef4444",
lines:
state?.heating_states.nozzle?.target_temperature !== undefined
? [
{
type: "target" as const,
value: state.heating_states.nozzle.target_temperature,
color: "#ef4444",
show: true,
},
]
: [],
},
]
: []),
...(frontTemperature
? [
{
newData: frontTemperature,
title: "Front",
color: "#f59e0b",
lines:
state?.heating_states.front?.target_temperature !== undefined
? [
{
type: "target" as const,
value: state.heating_states.front.target_temperature,
color: "#f59e0b",
show: true,
},
]
: [],
},
]
: []),
...(middleTemperature
? [
{
newData: middleTemperature,
title: "Middle",
color: "#8b5cf6",
lines:
state?.heating_states.middle?.target_temperature !== undefined
? [
{
type: "target" as const,
value: state.heating_states.middle.target_temperature,
color: "#8b5cf6",
show: true,
},
]
: [],
},
]
: []),
...(backTemperature
? [
{
newData: backTemperature,
title: "Back",
color: "#3b82f6",
lines:
state?.heating_states.back?.target_temperature !== undefined
? [
{
type: "target" as const,
value: state.heating_states.back.target_temperature,
color: "#3b82f6",
show: true,
},
]
: [],
},
]
: []),
];
const temperatureConfig: GraphConfig = {
...baseConfig,
title: "Temperatures",
exportFilename: "temperatures_data",
colors: {
primary: "#ef4444",
grid: "#e2e8f0",
axis: "#64748b",
background: "#ffffff",
},
};
// Combined Power Graph (no target lines for power)
const powerData = [
...(nozzlePower
? [
{
newData: nozzlePower,
title: "Nozzle",
color: "#ef4444",
},
]
: []),
...(frontPower
? [
{
newData: frontPower,
title: "Front",
color: "#f59e0b",
},
]
: []),
...(middlePower
? [
{
newData: middlePower,
title: "Middle",
color: "#8b5cf6",
},
]
: []),
...(backPower
? [
{
newData: backPower,
title: "Back",
color: "#3b82f6",
},
]
: []),
...(motorPower
? [
{
newData: motorPower,
title: "Motor",
color: "#10b981",
},
]
: []),
...(combinedPower
? [
{
newData: combinedPower,
title: "Total",
color: "#000000",
},
]
: []),
];
const powerConfig: GraphConfig = {
...baseConfig,
title: "Power Outputs",
exportFilename: "power_data",
colors: {
primary: "#10b981",
grid: "#e2e8f0",
axis: "#64748b",
background: "#ffffff",
},
};
const currentConfig: GraphConfig = {
...baseConfig,
title: "Motor Current",
exportFilename: "motor_current_data",
colors: {
primary: "#3b82f6",
grid: "#e2e8f0",
axis: "#64748b",
background: "#ffffff",
},
};
// Pressure Graph with connected target line
const pressureConfig: GraphConfig = {
...baseConfig,
title: "Pressure",
exportFilename: "pressure_data",
colors: {
primary: "#3b82f6",
grid: "#e2e8f0",
axis: "#64748b",
background: "#ffffff",
},
};
// RPM Graph with connected target line
const rpmConfig: GraphConfig = {
...baseConfig,
title: "RPM",
exportFilename: "rpm_data",
colors: {
primary: "#8b5cf6",
grid: "#e2e8f0",
axis: "#64748b",
background: "#ffffff",
},
};
return (
<Page className="pb-25">
<div className="flex flex-col gap-4">
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: pressure,
color: "#3b82f6",
lines:
state?.pressure_state.target_bar !== undefined
? [
{
type: "target" as const,
value: state.pressure_state.target_bar,
color: "#3b82f6",
show: true,
},
]
: [],
}}
config={pressureConfig}
unit="bar"
renderValue={(value) => value.toFixed(2)}
graphId="pressure-graph"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={temperatureData}
config={temperatureConfig}
unit="C"
renderValue={(value) => value.toFixed(1)}
graphId="combined-temperatures"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={powerData}
config={powerConfig}
unit="W"
renderValue={(value) => value.toFixed(1)}
graphId="combined-power"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: motorCurrent,
color: "#3b82f6",
}}
config={currentConfig}
unit="A"
renderValue={(value) => value.toFixed(2)}
graphId="motor-current"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: motorScrewRpm,
color: "#8b5cf6",
lines:
state?.screw_state.target_rpm !== undefined
? [
{
type: "target" as const,
value: state.screw_state.target_rpm,
color: "#8b5cf6",
show: true,
},
]
: [],
}}
config={rpmConfig}
unit="rpm"
renderValue={(value) => value.toFixed(0)}
graphId="rpm-graph"
/>
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
}
@@ -0,0 +1,12 @@
import { Page } from "@/components/Page";
import { MarkdownWithToc } from "@/components/MarkdownWithToc";
import React from "react";
import extruderManualContent from "@/assets/markdown/extruder/manual.md?raw";
export function ExtruderV3ManualPage() {
return (
<Page>
<MarkdownWithToc markdownContent={extruderManualContent} />
</Page>
);
}
@@ -0,0 +1,44 @@
import { Topbar } from "@/components/Topbar";
import { extruder3Route } from "@/routes/routes";
import React from "react";
export function Extruder3Page() {
const { serial } = extruder3Route.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/extruder3/${serial}`}
items={[
{
link: "control",
title: "Control",
icon: "lu:CirclePlay",
activeLink: "control",
},
{
link: "graphs",
title: "Graphs",
icon: "lu:ChartSpline",
activeLink: "graphs",
},
{
link: "settings",
title: "Config",
icon: "lu:Settings",
activeLink: "settings",
},
{
link: "manual",
title: "Manual",
icon: "lu:BookOpen",
activeLink: "manual",
},
{
link: "presets",
title: "Presets",
icon: "lu:Save",
activeLink: "presets",
},
]}
/>
);
}
@@ -0,0 +1,196 @@
import React from "react";
import { PresetsPage } from "@/components/preset/PresetsPage";
import { extruder3 } from "@/machines/properties";
import { useExtruder3 } from "./useExtruder";
import { z } from "zod";
import {
PresetPreviewEntries,
previewSeparator,
} from "@/components/preset/PresetPreviewTable";
import { Preset } from "@/lib/preset/preset";
const extruder3PresetDataSchema = z
.object({
targetFrontHeatingTemperature: z.number(),
targetMiddleHeatingTemperature: z.number(),
targetBackHeatingTemperature: z.number(),
targetNozzleHeatingTemperature: z.number(),
targetInverterRpm: z.number(),
targetInverterPressure: z.number(),
inverterRegulation: z.enum(["RPM", "Pressure"]),
inverterRotationDirection: z.enum(["Forward", "Backward"]),
pressureLimit: z.number(),
pressureLimitEnabled: z.boolean(),
pidPressureKp: z.number(),
pidPressureKi: z.number(),
pidPressureKd: z.number(),
})
.partial();
type Extruder3 = z.infer<typeof extruder3PresetDataSchema>;
const schemas = new Map([[1, extruder3PresetDataSchema]]);
const previewEntries: PresetPreviewEntries<Extruder3> = [
{
name: "Target Front Temperature",
unit: "C",
renderValue: (data: Extruder3) =>
data.targetFrontHeatingTemperature?.toFixed(1),
},
{
name: "Target Middle Temperature",
unit: "C",
renderValue: (data: Extruder3) =>
data.targetMiddleHeatingTemperature?.toFixed(1),
},
{
name: "Target Back Temperature",
unit: "C",
renderValue: (data: Extruder3) =>
data.targetBackHeatingTemperature?.toFixed(1),
},
{
name: "Target Nozzle Temperature",
unit: "C",
renderValue: (data: Extruder3) =>
data.targetNozzleHeatingTemperature?.toFixed(1),
},
previewSeparator,
{
name: "Inverter Regulation",
renderValue: (data: Extruder3) => data.inverterRegulation,
},
{
name: "Target Inverter RPM",
unit: "rpm",
renderValue: (data: Extruder3) => data.targetInverterRpm?.toFixed(2),
},
{
name: "Target Inverter Pressure",
unit: "bar",
renderValue: (data: Extruder3) => data.targetInverterPressure?.toFixed(1),
},
{
name: "Inverter Direction",
renderValue: (data: Extruder3) => data.inverterRotationDirection,
},
previewSeparator,
{
name: "Pressure Limit",
unit: "bar",
renderValue: (data: Extruder3) => data.pressureLimit?.toFixed(1),
},
{
name: "Enable Pressure Limit",
renderValue: (data: Extruder3) =>
data.pressureLimitEnabled ? "on" : "off",
},
previewSeparator,
{
name: "PID Pressue Kp",
renderValue: (data: Extruder3) => data.pidPressureKp?.toFixed(4),
},
{
name: "PID Pressue Ki",
renderValue: (data: Extruder3) => data.pidPressureKi?.toFixed(4),
},
{
name: "PID Pressue Kd",
renderValue: (data: Extruder3) => data.pidPressureKd?.toFixed(4),
},
];
export function Extruder3PresetsPage() {
const {
state,
defaultState,
setFrontHeatingTemperature,
setMiddleHeatingTemperature,
setBackHeatingTemperature,
setNozzleHeatingTemperature,
setInverterTargetRpm,
setInverterTargetPressure,
setInverterRegulation,
setInverterRotationDirection,
setExtruderPressureLimit,
setExtruderPressureLimitEnabled,
setPressurePidKd,
setPressurePidKi,
setPressurePidKp,
} = useExtruder3();
const toPresetData = (s?: typeof state): Extruder3 => ({
targetFrontHeatingTemperature: s?.heating_states.front.target_temperature,
targetMiddleHeatingTemperature: s?.heating_states.middle.target_temperature,
targetBackHeatingTemperature: s?.heating_states.back.target_temperature,
targetNozzleHeatingTemperature: s?.heating_states.nozzle.target_temperature,
targetInverterRpm: s?.screw_state.target_rpm,
targetInverterPressure: s?.pressure_state.target_bar,
inverterRegulation: s?.regulation_state.uses_rpm ? "RPM" : "Pressure",
inverterRotationDirection: s?.rotation_state?.forward
? "Forward"
: "Backward",
pressureLimit: s?.extruder_settings_state.pressure_limit,
pressureLimitEnabled: s?.extruder_settings_state.pressure_limit_enabled,
pidPressureKp: s?.pid_settings.pressure.kp,
pidPressureKi: s?.pid_settings.pressure.ki,
pidPressureKd: s?.pid_settings.pressure.kd,
});
const defaults = toPresetData(defaultState);
const applyPreset = (preset: Preset<Extruder3>) => {
setFrontHeatingTemperature(
preset?.data?.targetFrontHeatingTemperature ?? 150.0,
);
setMiddleHeatingTemperature(
preset?.data?.targetMiddleHeatingTemperature ?? 150.0,
);
setBackHeatingTemperature(
preset?.data?.targetBackHeatingTemperature ?? 150.0,
);
setNozzleHeatingTemperature(
preset?.data?.targetNozzleHeatingTemperature ?? 150.0,
);
setInverterTargetRpm(preset?.data?.targetInverterRpm ?? 0);
setInverterTargetPressure(preset?.data?.targetInverterPressure ?? 0);
setInverterRegulation(preset?.data?.inverterRegulation === "RPM");
setInverterRotationDirection(
preset?.data?.inverterRotationDirection === "Forward",
);
setExtruderPressureLimit(preset?.data?.pressureLimit ?? 100.0);
setExtruderPressureLimitEnabled(preset?.data?.pressureLimitEnabled ?? true);
setPressurePidKp(preset?.data?.pidPressureKp ?? 0.16);
setPressurePidKi(preset?.data?.pidPressureKi ?? 0.0);
setPressurePidKd(preset?.data?.pidPressureKd ?? 0.008);
};
return (
<PresetsPage
machine_identification={extruder3.machine_identification}
currentState={toPresetData(state)}
schemas={schemas}
schemaVersion={1}
applyPreset={applyPreset}
previewEntries={previewEntries}
defaultState={defaults}
/>
);
}
@@ -0,0 +1,291 @@
import React, { useState } from "react";
import { Page } from "@/components/Page";
import { ControlCard } from "@/control/ControlCard";
import { Label } from "@/control/Label";
import { SelectionGroupBoolean } from "@/control/SelectionGroup";
import { EditValue } from "@/control/EditValue";
import { roundToDecimals } from "@/lib/decimal";
import { useExtruder3 } from "./useExtruder";
import { ControlGrid } from "@/control/ControlGrid";
export function Extruder3SettingsPage() {
const {
state,
defaultState,
setInverterRotationDirection,
resetInverter,
setExtruderPressureLimit,
setExtruderPressureLimitEnabled,
setPressurePidKp,
setPressurePidKi,
setPressurePidKd,
setTemperaturePidValue,
} = useExtruder3();
const [showAdvanced, setShowAdvanced] = useState(false);
return (
<Page>
<ControlCard className="bg-red" title="Inverter Settings">
<Label label="Rotation Direction">
<SelectionGroupBoolean
value={state?.rotation_state.forward}
optionTrue={{ children: "Forward" }}
optionFalse={{ children: "Backward" }}
onChange={setInverterRotationDirection}
/>
</Label>
<Label label="Reset Inverter">
<button
onClick={resetInverter}
className="inline-block w-fit max-w-max rounded bg-red-600 px-4 py-4 text-base whitespace-nowrap text-white hover:bg-red-700"
style={{ minWidth: "auto", width: "fit-content" }}
>
Reset Inverter
</button>
</Label>
</ControlCard>
<ControlCard className="bg-red" title="Extruder Settings">
<Label label="Nozzle Pressure Limit">
<EditValue
value={state?.extruder_settings_state.pressure_limit}
defaultValue={defaultState?.extruder_settings_state.pressure_limit}
unit="bar"
title="Nozzle Pressure Limit"
min={0}
max={350}
renderValue={(value) => roundToDecimals(value, 0)}
onChange={setExtruderPressureLimit}
/>
</Label>
<Label label="Nozzle Pressure Limit Enabled">
<SelectionGroupBoolean
value={state?.extruder_settings_state.pressure_limit_enabled}
optionTrue={{ children: "Enabled" }}
optionFalse={{ children: "Disabled" }}
onChange={setExtruderPressureLimitEnabled}
/>
</Label>
<Label label="Show Advanced PID Settings">
<SelectionGroupBoolean
value={showAdvanced}
optionTrue={{ children: "Show" }}
optionFalse={{ children: "Hide" }}
onChange={setShowAdvanced}
/>
</Label>
</ControlCard>
{showAdvanced && (
<>
<ControlCard title="Pressure PID Settings ">
<Label label="Kp">
<EditValue
value={state?.pid_settings.pressure.kp}
defaultValue={defaultState?.pid_settings.pressure.kp}
min={0}
max={100}
step={0.01}
renderValue={(v) => roundToDecimals(v, 2)}
onChange={setPressurePidKp}
title="Pressure PID KP"
/>
</Label>
<Label label="Ki">
<EditValue
value={state?.pid_settings.pressure.ki}
defaultValue={defaultState?.pid_settings.pressure.ki}
min={0}
max={100}
step={0.01}
renderValue={(v) => roundToDecimals(v, 2)}
onChange={setPressurePidKi}
title="Pressure PID KI"
/>
</Label>
<Label label="Kd">
<EditValue
value={state?.pid_settings.pressure.kd}
defaultValue={defaultState?.pid_settings.pressure.kd}
min={0}
max={100}
step={0.01}
renderValue={(v) => roundToDecimals(v, 2)}
onChange={setPressurePidKd}
title="Pressure PID KD"
/>
</Label>
</ControlCard>
<ControlGrid>
<ControlCard title="Temperature PID Settings (Front) ">
<Label label="Kp">
<EditValue
value={state?.pid_settings.temperature.front.kp}
defaultValue={defaultState?.pid_settings.temperature.front.kp}
min={0}
max={100}
step={0.001}
renderValue={(v) => roundToDecimals(v, 3)}
onChange={(v) => setTemperaturePidValue("front", "kp", v)}
title="Temperature PID KP"
/>
</Label>
<Label label="Ki">
<EditValue
value={state?.pid_settings.temperature.front.ki}
defaultValue={defaultState?.pid_settings.temperature.front.ki}
min={0}
max={100}
step={0.001}
renderValue={(v) => roundToDecimals(v, 3)}
onChange={(v) => setTemperaturePidValue("front", "ki", v)}
title="Temperature PID KI"
/>
</Label>
<Label label="Kd">
<EditValue
value={state?.pid_settings.temperature.front.kd}
defaultValue={defaultState?.pid_settings.temperature.front.kd}
min={0}
max={100}
step={0.001}
renderValue={(v) => roundToDecimals(v, 3)}
onChange={(v) => setTemperaturePidValue("front", "kd", v)}
title="Temperature PID KD"
/>
</Label>
</ControlCard>
<ControlCard title="Temperature PID Settings (Middle) ">
<Label label="Kp">
<EditValue
value={state?.pid_settings.temperature.middle.kp}
defaultValue={
defaultState?.pid_settings.temperature.middle.kp
}
min={0}
max={100}
step={0.001}
renderValue={(v) => roundToDecimals(v, 3)}
onChange={(v) => setTemperaturePidValue("middle", "kp", v)}
title="Temperature PID KP"
/>
</Label>
<Label label="Ki">
<EditValue
value={state?.pid_settings.temperature.middle.ki}
defaultValue={
defaultState?.pid_settings.temperature.middle.ki
}
min={0}
max={100}
step={0.001}
renderValue={(v) => roundToDecimals(v, 3)}
onChange={(v) => setTemperaturePidValue("middle", "ki", v)}
title="Temperature PID KI"
/>
</Label>
<Label label="Kd">
<EditValue
value={state?.pid_settings.temperature.middle.kd}
defaultValue={
defaultState?.pid_settings.temperature.middle.kd
}
min={0}
max={100}
step={0.001}
renderValue={(v) => roundToDecimals(v, 3)}
onChange={(v) => setTemperaturePidValue("middle", "kd", v)}
title="Temperature PID KD"
/>
</Label>
</ControlCard>
<ControlCard title="Temperature PID Settings (Back) ">
<Label label="Kp">
<EditValue
value={state?.pid_settings.temperature.back.kp}
defaultValue={defaultState?.pid_settings.temperature.back.kp}
min={0}
max={100}
step={0.001}
renderValue={(v) => roundToDecimals(v, 3)}
onChange={(v) => setTemperaturePidValue("back", "kp", v)}
title="Temperature PID KP"
/>
</Label>
<Label label="Ki">
<EditValue
value={state?.pid_settings.temperature.back.ki}
defaultValue={defaultState?.pid_settings.temperature.back.ki}
min={0}
max={100}
step={0.001}
renderValue={(v) => roundToDecimals(v, 3)}
onChange={(v) => setTemperaturePidValue("back", "ki", v)}
title="Temperature PID KI"
/>
</Label>
<Label label="Kd">
<EditValue
value={state?.pid_settings.temperature.back.kd}
defaultValue={defaultState?.pid_settings.temperature.back.kd}
min={0}
max={100}
step={0.001}
renderValue={(v) => roundToDecimals(v, 3)}
onChange={(v) => setTemperaturePidValue("back", "kd", v)}
title="Temperature PID KD"
/>
</Label>
</ControlCard>
<ControlCard title="Temperature PID Settings (Nozzle) ">
<Label label="Kp">
<EditValue
value={state?.pid_settings.temperature.nozzle.kp}
defaultValue={
defaultState?.pid_settings.temperature.nozzle.kp
}
min={0}
max={100}
step={0.001}
renderValue={(v) => roundToDecimals(v, 3)}
onChange={(v) => setTemperaturePidValue("nozzle", "kp", v)}
title="Temperature PID KP"
/>
</Label>
<Label label="Ki">
<EditValue
value={state?.pid_settings.temperature.nozzle.ki}
defaultValue={
defaultState?.pid_settings.temperature.nozzle.ki
}
min={0}
max={100}
step={0.001}
renderValue={(v) => roundToDecimals(v, 3)}
onChange={(v) => setTemperaturePidValue("nozzle", "ki", v)}
title="Temperature PID KI"
/>
</Label>
<Label label="Kd">
<EditValue
value={state?.pid_settings.temperature.nozzle.kd}
defaultValue={
defaultState?.pid_settings.temperature.nozzle.kd
}
min={0}
max={100}
step={0.001}
renderValue={(v) => roundToDecimals(v, 3)}
onChange={(v) => setTemperaturePidValue("nozzle", "kd", v)}
title="Temperature PID KD"
/>
</Label>
</ControlCard>
</ControlGrid>
</>
)}
</Page>
);
}
@@ -0,0 +1,404 @@
import { StoreApi } from "zustand";
import { create } from "zustand";
import { z } from "zod";
import {
EventHandler,
eventSchema,
Event,
NamespaceId,
createNamespaceHookImplementation,
ThrottledStoreUpdater,
handleUnhandledEventError,
} from "../../../client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";
import { createTimeSeries, TimeSeries } from "@/lib/timeseries";
import { useMemo } from "react";
// ========== Event Schema Definitions ==========
/**
* Machine operation mode enum
*/
export const modeSchema = z.enum(["Standby", "Heat", "Extrude"]);
export type Mode = z.infer<typeof modeSchema>;
export const liveMotorStatusDataSchema = z.object({
screw_rpm: z.number(),
frequency: z.number(),
current: z.number(),
power: z.number(),
});
export type MotorStatus = z.infer<typeof liveMotorStatusDataSchema>;
/**
* Consolidated live values event schema (30FPS data)
*/
export const liveValuesEventDataSchema = z.object({
motor_status: liveMotorStatusDataSchema,
pressure: z.number(),
nozzle_temperature: z.number(),
front_temperature: z.number(),
back_temperature: z.number(),
middle_temperature: z.number(),
nozzle_power: z.number(),
front_power: z.number(),
back_power: z.number(),
middle_power: z.number(),
combined_power: z.number(),
total_energy_kwh: z.number(),
});
/**
* Rotation state schema
*/
export const rotationStateSchema = z.object({
forward: z.boolean(),
});
/**
* Mode state schema
*/
export const modeStateSchema = z.object({
mode: modeSchema,
});
/**
* Regulation state schema
*/
export const regulationStateSchema = z.object({
uses_rpm: z.boolean(),
});
/**
* Pressure state schema
*/
export const pressureStateSchema = z.object({
target_bar: z.number(),
wiring_error: z.boolean(),
});
/**
* Screw state schema
*/
export const screwStateSchema = z.object({
target_rpm: z.number(),
});
/**
* Heating state schema
*/
export const heatingStateSchema = z.object({
target_temperature: z.number(),
wiring_error: z.boolean(),
});
export type HeatingState = z.infer<typeof heatingStateSchema>;
/**
* Heating states schema
*/
export const heatingStatesSchema = z.object({
nozzle: heatingStateSchema,
front: heatingStateSchema,
back: heatingStateSchema,
middle: heatingStateSchema,
});
/**
* Extruder settings state schema
*/
export const extruderSettingsStateSchema = z.object({
pressure_limit: z.number(),
pressure_limit_enabled: z.boolean(),
});
/**
* Inverter status state schema
*/
export const inverterStatusStateSchema = z.object({
running: z.boolean(),
forward_running: z.boolean(),
reverse_running: z.boolean(),
up_to_frequency: z.boolean(),
overload_warning: z.boolean(),
no_function: z.boolean(),
output_frequency_detection: z.boolean(),
abc_fault: z.boolean(),
fault_occurence: z.boolean(),
});
/**
* PID settings schema
*/
export const pidSettingsSchema = z.object({
temperature: z.object({
front: z.object({
ki: z.number(),
kp: z.number(),
kd: z.number(),
zone: z.string(),
}),
middle: z.object({
ki: z.number(),
kp: z.number(),
kd: z.number(),
zone: z.string(),
}),
back: z.object({
ki: z.number(),
kp: z.number(),
kd: z.number(),
zone: z.string(),
}),
nozzle: z.object({
ki: z.number(),
kp: z.number(),
kd: z.number(),
zone: z.string(),
}),
}),
pressure: z.object({
ki: z.number(),
kp: z.number(),
kd: z.number(),
}),
});
/**
* Consolidated state event schema (state changes only)
*/
export const stateEventDataSchema = z.object({
is_default_state: z.boolean(),
rotation_state: rotationStateSchema,
mode_state: modeStateSchema,
regulation_state: regulationStateSchema,
pressure_state: pressureStateSchema,
screw_state: screwStateSchema,
heating_states: heatingStatesSchema,
extruder_settings_state: extruderSettingsStateSchema,
inverter_status_state: inverterStatusStateSchema,
pid_settings: pidSettingsSchema,
});
// ========== Event Schemas with Wrappers ==========
export const liveValuesEventSchema = eventSchema(liveValuesEventDataSchema);
export const stateEventSchema = eventSchema(stateEventDataSchema);
// ========== Type Inferences ==========
export type StateEvent = z.infer<typeof stateEventSchema>;
// Additional exports for backward compatibility
export const SetRegulationSchema = z.object({
uses_rpm: z.boolean(),
});
export const mode = z.object({
mode: modeSchema,
});
export type Extruder3NamespaceStore = {
// Single state event from server
state: StateEvent | null;
defaultState: StateEvent | null;
// Time series data for live values
motorFrequency: TimeSeries;
motorCurrent: TimeSeries;
motorScrewRpm: TimeSeries;
motorPower: TimeSeries;
pressure: TimeSeries;
nozzleTemperature: TimeSeries;
frontTemperature: TimeSeries;
backTemperature: TimeSeries;
middleTemperature: TimeSeries;
nozzlePower: TimeSeries;
frontPower: TimeSeries;
middlePower: TimeSeries;
backPower: TimeSeries;
// Combined power consumption and energy
combinedPower: TimeSeries;
totalEnergyKWh: TimeSeries;
};
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries();
const { initialTimeSeries: backTemperature, insert: addBackTemperature } =
createTimeSeries();
const { initialTimeSeries: frontTemperature, insert: addFrontTemperature } =
createTimeSeries();
const { initialTimeSeries: middleTemperature, insert: addMiddleTemperature } =
createTimeSeries();
const { initialTimeSeries: nozzleTemperature, insert: addNozzleTemperature } =
createTimeSeries();
const { initialTimeSeries: nozzlePower, insert: addNozzlePower } =
createTimeSeries();
const { initialTimeSeries: frontPower, insert: addFrontPower } =
createTimeSeries();
const { initialTimeSeries: middlePower, insert: addMiddlePower } =
createTimeSeries();
const { initialTimeSeries: backPower, insert: addBackPower } =
createTimeSeries();
const { initialTimeSeries: combinedPower, insert: addCombinedPower } =
createTimeSeries();
const { initialTimeSeries: totalEnergyKWh, insert: addTotalEnergyKWh } =
createTimeSeries();
const { initialTimeSeries: motorCurrent, insert: addMotorCurrent } =
createTimeSeries();
const { initialTimeSeries: motorFrequency, insert: addMotorFrequency } =
createTimeSeries();
const { initialTimeSeries: motorScrewRpm, insert: addMotorScrewRpm } =
createTimeSeries();
const { initialTimeSeries: motorPower, insert: addMotorPower } =
createTimeSeries();
export function extruder3MessageHandler(
store: StoreApi<Extruder3NamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<Extruder3NamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const eventName = event.name;
// Helper function to update store through buffer
const updateStore = (
updater: (state: Extruder3NamespaceStore) => Extruder3NamespaceStore,
) => {
throttledUpdater.updateWith(updater);
};
try {
if (eventName === "StateEvent") {
console.log(event);
const stateEvent = stateEventSchema.parse(event);
updateStore((state) => ({
...state,
state: stateEvent,
// only set default state if is_default_state is true
defaultState: stateEvent.data.is_default_state
? stateEvent
: state.defaultState,
}));
} else if (eventName === "LiveValuesEvent") {
const liveValuesEvent = liveValuesEventSchema.parse(event);
const timestamp = event.ts;
updateStore((state) => ({
...state,
motorScrewRpm: addMotorScrewRpm(state.motorScrewRpm, {
value: liveValuesEvent.data.motor_status.screw_rpm,
timestamp,
}),
motorCurrent: addMotorCurrent(state.motorCurrent, {
value: liveValuesEvent.data.motor_status.current,
timestamp,
}),
motorFrequency: addMotorFrequency(state.motorFrequency, {
value: liveValuesEvent.data.motor_status.frequency,
timestamp,
}),
motorPower: addMotorPower(state.motorPower, {
value: liveValuesEvent.data.motor_status.power,
timestamp,
}),
pressure: addPressure(state.pressure, {
value: liveValuesEvent.data.pressure,
timestamp,
}),
nozzleTemperature: addNozzleTemperature(state.nozzleTemperature, {
value: liveValuesEvent.data.nozzle_temperature,
timestamp,
}),
frontTemperature: addFrontTemperature(state.frontTemperature, {
value: liveValuesEvent.data.front_temperature,
timestamp,
}),
backTemperature: addBackTemperature(state.backTemperature, {
value: liveValuesEvent.data.back_temperature,
timestamp,
}),
middleTemperature: addMiddleTemperature(state.middleTemperature, {
value: liveValuesEvent.data.middle_temperature,
timestamp,
}),
nozzlePower: addNozzlePower(state.nozzlePower, {
value: liveValuesEvent.data.nozzle_power,
timestamp,
}),
frontPower: addFrontPower(state.frontPower, {
value: liveValuesEvent.data.front_power,
timestamp,
}),
middlePower: addMiddlePower(state.middlePower, {
value: liveValuesEvent.data.middle_power,
timestamp,
}),
backPower: addBackPower(state.backPower, {
value: liveValuesEvent.data.back_power,
timestamp,
}),
combinedPower: addCombinedPower(state.combinedPower, {
value: liveValuesEvent.data.combined_power,
timestamp,
}),
totalEnergyKWh: addTotalEnergyKWh(state.totalEnergyKWh, {
value: liveValuesEvent.data.total_energy_kwh,
timestamp,
}),
}));
} else {
handleUnhandledEventError(eventName);
}
} catch (error) {
console.error(`Unexpected error processing ${eventName} event:`, error);
throw error;
}
};
}
export const createExtruder3NamespaceStore =
(): StoreApi<Extruder3NamespaceStore> =>
create<Extruder3NamespaceStore>(() => {
return {
state: null,
defaultState: null,
motorCurrent,
motorFrequency,
motorScrewRpm,
motorPower,
pressure,
nozzleTemperature,
frontTemperature,
backTemperature,
middleTemperature,
nozzlePower,
frontPower,
backPower,
middlePower,
combinedPower,
totalEnergyKWh,
};
});
const useExtruder3NamespaceImplementation =
createNamespaceHookImplementation<Extruder3NamespaceStore>({
createStore: createExtruder3NamespaceStore,
createEventHandler: extruder3MessageHandler,
});
export function useExtruder3Namespace(
machine_identification_unique: MachineIdentificationUnique,
): Extruder3NamespaceStore {
// Generate namespace ID from validated machine ID
const namespaceId = useMemo<NamespaceId>(
() => ({
type: "machine",
machine_identification_unique,
}),
[machine_identification_unique],
);
// Use the implementation with validated namespace ID
return useExtruder3NamespaceImplementation(namespaceId);
}
@@ -0,0 +1,448 @@
import { toastError } from "@/components/Toast";
import { useMachineMutate as useMachineMutation } from "@/client/useClient";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { MachineIdentificationUnique } from "@/machines/types";
import { extruder3 } from "@/machines/properties";
import { extruder3Route } from "@/routes/routes";
import { z } from "zod";
import { StateEvent, Mode, useExtruder3Namespace } from "./extruder3Namespace";
import { useEffect, useMemo } from "react";
import { produce } from "immer";
export function useExtruder3() {
const { serial: serialString } = extruder3Route.useParams();
// Memoize the machine identification to keep it stable between renders
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
const serial = parseInt(serialString);
if (isNaN(serial)) {
toastError(
"Invalid Serial Number",
`"${serialString}" is not a valid serial number.`,
);
return {
machine_identification: {
vendor: 0,
machine: 0,
},
serial: 0,
};
}
return {
machine_identification: extruder3.machine_identification,
serial,
};
}, [serialString]);
// Get consolidated state and live values from namespace
const {
state,
defaultState,
motorCurrent,
motorFrequency,
motorScrewRpm,
motorPower,
pressure,
nozzleTemperature,
frontTemperature,
backTemperature,
middleTemperature,
nozzlePower,
frontPower,
middlePower,
backPower,
combinedPower,
totalEnergyKWh,
} = useExtruder3Namespace(machineIdentification);
// Single optimistic state for all state management
const stateOptimistic = useStateOptimistic<StateEvent>();
useEffect(() => {
if (state) {
stateOptimistic.setReal(state);
}
}, [state]);
// Helper function for optimistic updates using produce
const updateStateOptimistically = (
producer: (current: StateEvent) => void,
serverRequest: () => void,
) => {
const currentState = stateOptimistic.value;
if (currentState && !stateOptimistic.isOptimistic) {
stateOptimistic.setOptimistic(produce(currentState, producer));
}
serverRequest();
};
// Action functions with verb-first names
const setInverterRotationDirection = (forward: boolean) => {
updateStateOptimistically(
(current) => {
current.data.rotation_state.forward = forward;
},
() =>
requestInverterRotationDirection({
machine_identification_unique: machineIdentification,
data: { SetInverterRotationDirection: forward },
}),
);
};
const setExtruderMode = (mode: Mode) => {
updateStateOptimistically(
(current) => {
current.data.mode_state.mode = mode;
},
() =>
requestExtruderMode({
machine_identification_unique: machineIdentification,
data: { SetExtruderMode: mode },
}),
);
};
const setInverterRegulation = (usesRpm: boolean) => {
updateStateOptimistically(
(current) => {
current.data.regulation_state.uses_rpm = usesRpm;
},
() =>
requestInverterRegulation({
machine_identification_unique: machineIdentification,
data: { SetInverterRegulation: usesRpm },
}),
);
};
const setInverterTargetRpm = (rpm: number) => {
updateStateOptimistically(
(current) => {
current.data.screw_state.target_rpm = rpm;
},
() =>
requestInverterTargetRpm({
machine_identification_unique: machineIdentification,
data: { SetInverterTargetRpm: rpm },
}),
);
};
const setInverterTargetPressure = (pressure: number) => {
updateStateOptimistically(
(current) => {
current.data.pressure_state.target_bar = pressure;
},
() =>
requestInverterTargetPressure({
machine_identification_unique: machineIdentification,
data: { SetInverterTargetPressure: pressure },
}),
);
};
const setNozzleHeatingTemperature = (temperature: number) => {
updateStateOptimistically(
(current) => {
current.data.heating_states.nozzle.target_temperature = temperature;
},
() =>
requestNozzleHeatingTemperature({
machine_identification_unique: machineIdentification,
data: { SetNozzleHeatingTemperature: temperature },
}),
);
};
const setFrontHeatingTemperature = (temperature: number) => {
updateStateOptimistically(
(current) => {
current.data.heating_states.front.target_temperature = temperature;
},
() =>
requestFrontHeatingTemperature({
machine_identification_unique: machineIdentification,
data: { SetFrontHeatingTargetTemperature: temperature },
}),
);
};
const setBackHeatingTemperature = (temperature: number) => {
updateStateOptimistically(
(current) => {
current.data.heating_states.back.target_temperature = temperature;
},
() =>
requestBackHeatingTemperature({
machine_identification_unique: machineIdentification,
data: { SetBackHeatingTargetTemperature: temperature },
}),
);
};
const setMiddleHeatingTemperature = (temperature: number) => {
updateStateOptimistically(
(current) => {
current.data.heating_states.middle.target_temperature = temperature;
},
() =>
requestMiddleHeatingTemperature({
machine_identification_unique: machineIdentification,
data: { SetMiddleHeatingTemperature: temperature },
}),
);
};
const setExtruderPressureLimit = (pressureLimit: number) => {
updateStateOptimistically(
(current) => {
current.data.extruder_settings_state.pressure_limit = pressureLimit;
},
() =>
requestExtruderPressureLimit({
machine_identification_unique: machineIdentification,
data: { SetExtruderPressureLimit: pressureLimit },
}),
);
};
const setExtruderPressureLimitEnabled = (enabled: boolean) => {
updateStateOptimistically(
(current) => {
current.data.extruder_settings_state.pressure_limit_enabled = enabled;
},
() =>
requestExtruderPressureLimitEnabled({
machine_identification_unique: machineIdentification,
data: { SetExtruderPressureLimitIsEnabled: enabled },
}),
);
};
const setPressurePidKp = (kp: number) => {
updateStateOptimistically(
(current) => {
current.data.pid_settings.pressure.kp = kp;
},
() => {
const currentState = stateOptimistic.value;
if (currentState) {
const settings = produce(
currentState.data.pid_settings.pressure,
(draft) => {
draft.kp = kp;
},
);
requestPressurePidSettings({
machine_identification_unique: machineIdentification,
data: { SetPressurePidSettings: settings },
});
}
},
);
};
const setPressurePidKi = (ki: number) => {
updateStateOptimistically(
(current) => {
current.data.pid_settings.pressure.ki = ki;
},
() => {
const currentState = stateOptimistic.value;
if (currentState) {
const settings = produce(
currentState.data.pid_settings.pressure,
(draft) => {
draft.ki = ki;
},
);
requestPressurePidSettings({
machine_identification_unique: machineIdentification,
data: { SetPressurePidSettings: settings },
});
}
},
);
};
const setPressurePidKd = (kd: number) => {
updateStateOptimistically(
(current) => {
current.data.pid_settings.pressure.kd = kd;
},
() => {
const currentState = stateOptimistic.value;
if (currentState) {
const settings = produce(
currentState.data.pid_settings.pressure,
(draft) => {
draft.kd = kd;
},
);
requestPressurePidSettings({
machine_identification_unique: machineIdentification,
data: { SetPressurePidSettings: settings },
});
}
},
);
};
const setTemperaturePidValue = (
zone: "front" | "middle" | "back" | "nozzle",
key: "kp" | "ki" | "kd",
value: number,
) => {
updateStateOptimistically(
(current) => {
current.data.pid_settings.temperature[zone][key] = value;
},
() => {
const currentState = stateOptimistic.value;
if (currentState) {
const settings = produce(
currentState.data.pid_settings.temperature[zone],
(draft) => {
draft[key] = value;
},
);
requestTemperaturePidSettings({
machine_identification_unique: machineIdentification,
data: { SetTemperaturePidSettings: settings },
});
}
},
);
};
const resetInverter = () => {
// No optimistic update needed for reset
requestResetInverter({
machine_identification_unique: machineIdentification,
data: { ResetInverter: true },
});
};
// Mutation hooks
const { request: requestInverterRotationDirection } = useMachineMutation(
z.object({ SetInverterRotationDirection: z.boolean() }),
);
const { request: requestExtruderMode } = useMachineMutation(
z.object({ SetExtruderMode: z.enum(["Heat", "Extrude", "Standby"]) }),
);
const { request: requestInverterRegulation } = useMachineMutation(
z.object({ SetInverterRegulation: z.boolean() }),
);
const { request: requestInverterTargetRpm } = useMachineMutation(
z.object({ SetInverterTargetRpm: z.number() }),
);
const { request: requestInverterTargetPressure } = useMachineMutation(
z.object({ SetInverterTargetPressure: z.number() }),
);
const { request: requestNozzleHeatingTemperature } = useMachineMutation(
z.object({ SetNozzleHeatingTemperature: z.number() }),
);
const { request: requestFrontHeatingTemperature } = useMachineMutation(
z.object({ SetFrontHeatingTargetTemperature: z.number() }),
);
const { request: requestBackHeatingTemperature } = useMachineMutation(
z.object({ SetBackHeatingTargetTemperature: z.number() }),
);
const { request: requestMiddleHeatingTemperature } = useMachineMutation(
z.object({ SetMiddleHeatingTemperature: z.number() }),
);
const { request: requestExtruderPressureLimit } = useMachineMutation(
z.object({ SetExtruderPressureLimit: z.number() }),
);
const { request: requestExtruderPressureLimitEnabled } = useMachineMutation(
z.object({ SetExtruderPressureLimitIsEnabled: z.boolean() }),
);
const { request: requestPressurePidSettings } = useMachineMutation(
z.object({
SetPressurePidSettings: z.object({
ki: z.number(),
kp: z.number(),
kd: z.number(),
}),
}),
);
const { request: requestTemperaturePidSettings } = useMachineMutation(
z.object({
SetTemperaturePidSettings: z.object({
ki: z.number(),
kp: z.number(),
kd: z.number(),
zone: z.string(),
}),
}),
);
const { request: requestResetInverter } = useMachineMutation(
z.object({ ResetInverter: z.boolean() }),
);
return {
// Consolidated state
state: stateOptimistic.value?.data,
// Default state for initial values
defaultState: defaultState?.data,
// Individual live values (TimeSeries)
motorCurrent,
motorFrequency,
motorScrewRpm,
motorPower,
pressure,
nozzleTemperature,
frontTemperature,
backTemperature,
middleTemperature,
nozzlePower,
frontPower,
middlePower,
backPower,
combinedPower,
totalEnergyKWh,
// Loading states
isLoading: stateOptimistic.isOptimistic,
isDisabled: !stateOptimistic.isInitialized,
// Action functions (verb-first)
setInverterRotationDirection,
setExtruderMode,
setInverterRegulation,
setInverterTargetRpm,
setInverterTargetPressure,
setNozzleHeatingTemperature,
setFrontHeatingTemperature,
setBackHeatingTemperature,
setMiddleHeatingTemperature,
setExtruderPressureLimit,
setExtruderPressureLimitEnabled,
setPressurePidKp,
setPressurePidKi,
setPressurePidKd,
setTemperaturePidValue,
resetInverter,
};
}
@@ -0,0 +1,111 @@
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 { Badge } from "@/components/ui/badge";
import { useIP20TestMachine } from "./useIP20TestMachine";
export function IP20TestMachineControlPage() {
const { state, liveValues, setOutput, setAllOutputs } = useIP20TestMachine();
const safeState = state ?? {
outputs: [false, false, false, false, false, false, false, false],
};
const safeLiveValues = liveValues ?? {
inputs: [false, false, false, false, false, false, false, false],
};
return (
<Page>
<ControlGrid columns={2}>
{/* Digital Output Controls */}
<ControlCard title="Digital Outputs">
<div className="grid grid-cols-2 gap-6">
{safeState.outputs.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) => setOutput(index, value === "On")}
/>
</Label>
))}
</div>
</ControlCard>
{/* Digital Input Display */}
<ControlCard title="Digital Inputs">
<div className="grid grid-cols-2 gap-6">
{safeLiveValues.inputs.map((input, index) => (
<Label key={index} label={`Input ${index + 1}`}>
<div className="flex h-full items-center justify-center">
<Badge
className={`text-md ${input ? "bg-green-600" : "bg-gray-400"}`}
>
{input ? "HIGH" : "LOW"}
</Badge>
</div>
</Label>
))}
</div>
</ControlCard>
{/* Master Output Control */}
<ControlCard title="Master Output Control">
<SelectionGroup<"On" | "Off">
value={safeState.outputs.every(Boolean) ? "On" : "Off"}
orientation="horizontal"
options={{
Off: {
children: "Turn All Outputs Off",
icon: "lu:CirclePause",
isActiveClassName: "bg-red-600",
},
On: {
children: "Turn All Outputs On",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
},
}}
onChange={(value) => setAllOutputs(value === "On")}
/>
</ControlCard>
{/* Input Summary */}
<ControlCard title="Input Summary">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Active Inputs:</span>
<Badge className="bg-blue-500">
{safeLiveValues.inputs.filter(Boolean).length} / 8
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Inactive Inputs:</span>
<Badge className="bg-gray-500">
{safeLiveValues.inputs.filter((i) => !i).length} / 8
</Badge>
</div>
</div>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,21 @@
import { Topbar } from "@/components/Topbar";
import { ip20TestMachineSerialRoute } from "@/routes/routes";
import React from "react";
export function IP20TestMachinePage(): React.JSX.Element {
const { serial } = ip20TestMachineSerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/ip20testmachine/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:ToggleLeft",
},
]}
/>
);
}
@@ -0,0 +1,89 @@
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({
outputs: z.array(z.boolean()).length(8),
});
export const liveValuesEventDataSchema = z.object({
inputs: z.array(z.boolean()).length(8),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export const liveValuesEventSchema = eventSchema(liveValuesEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
export type LiveValuesEvent = z.infer<typeof liveValuesEventDataSchema>;
// ========== Store ==========
export type IP20TestMachineNamespaceStore = {
state: StateEvent | null;
liveValues: LiveValuesEvent | null;
};
export const createIP20TestMachineNamespaceStore =
(): StoreApi<IP20TestMachineNamespaceStore> =>
create<IP20TestMachineNamespaceStore>(() => ({
state: null,
liveValues: null,
}));
// ========== Message Handler ==========
export function ip20TestMachineMessageHandler(
store: StoreApi<IP20TestMachineNamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<IP20TestMachineNamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const updateStore = (
updater: (
state: IP20TestMachineNamespaceStore,
) => IP20TestMachineNamespaceStore,
) => throttledUpdater.updateWith(updater);
try {
if (event.name === "StateEvent") {
const parsed = stateEventSchema.parse(event);
updateStore((current) => ({ ...current, state: parsed.data }));
} else if (event.name === "LiveValuesEvent") {
const parsed = liveValuesEventSchema.parse(event);
updateStore((current) => ({ ...current, liveValues: parsed.data }));
} else {
handleUnhandledEventError(event.name);
}
} catch (error) {
console.error(`Error processing ${event.name}:`, error);
throw error;
}
};
}
// ========== Namespace Hook ==========
const useIP20TestMachineNamespaceImplementation =
createNamespaceHookImplementation<IP20TestMachineNamespaceStore>({
createStore: createIP20TestMachineNamespaceStore,
createEventHandler: ip20TestMachineMessageHandler,
});
export function useIP20TestMachineNamespace(
machine_identification_unique: MachineIdentificationUnique,
): IP20TestMachineNamespaceStore {
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};
return useIP20TestMachineNamespaceImplementation(namespaceId);
}
@@ -0,0 +1,103 @@
import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { ip20TestMachineSerialRoute } from "@/routes/routes";
import { MachineIdentificationUnique } from "@/machines/types";
import {
useIP20TestMachineNamespace,
StateEvent,
LiveValuesEvent,
} from "./ip20TestMachineNamespace";
import { useMachineMutate } from "@/client/useClient";
import { produce } from "immer";
import { useEffect, useMemo } from "react";
import { ip20TestMachine } from "@/machines/properties";
import { z } from "zod";
export function useIP20TestMachine() {
const { serial: serialString } = ip20TestMachineSerialRoute.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: ip20TestMachine.machine_identification,
serial,
};
}, [serialString]);
// Namespace state from backend
const { state, liveValues } = useIP20TestMachineNamespace(
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 setOutput = (index: number, on: boolean) => {
updateStateOptimistically(
(current) => {
current.outputs[index] = on;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetOutput", value: { index, on } },
}),
);
};
const setAllOutputs = (on: boolean) => {
updateStateOptimistically(
(current) => {
current.outputs = [on, on, on, on, on, on, on, on];
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetAllOutputs", value: { on } },
}),
);
};
return {
state: stateOptimistic.value,
liveValues,
setOutput,
setAllOutputs,
};
}
@@ -79,8 +79,9 @@ export function Laser1ControlPage() {
<div className="flex flex-row items-center gap-6">
<TimeSeriesValueNumeric
label="Roundness"
unit="%"
timeseries={roundness}
renderValue={(value) => value.toFixed(3)}
renderValue={(value) => (value * 100).toFixed(2)}
/>
</div>
)}
@@ -99,8 +100,9 @@ export function Laser1ControlPage() {
{roundness?.current && (
<MinMaxValue
label="Roundness Range"
unit="%"
timeseries={roundness}
renderValue={(value) => value.toFixed(3)}
renderValue={(value) => (value * 100).toFixed(2)}
timeframe={timeframe}
hideSelector
/>
+122 -107
View File
@@ -12,128 +12,143 @@ import { useLaser1 } from "./useLaser1";
export function Laser1GraphsPage() {
const { diameter, x_diameter, y_diameter, roundness, state } = useLaser1();
const syncHook = useGraphSync("diameter-group");
const syncHook = useGraphSync("diameter-roundness-group");
const targetDiameter = state?.laser_state?.target_diameter ?? 0;
const lowerTolerance = state?.laser_state?.lower_tolerance ?? 0;
const higherTolerance = state?.laser_state?.higher_tolerance ?? 0;
const isTwoAxis = !!x_diameter?.current || !!y_diameter?.current;
const diameterColor = "#3b82f6";
const xDiameterColor = "#ef4444";
const yDiameterColor = "#22c55e";
const roundnessColor = "#eab308";
// Convert roundness from ratio (0-1) to percentage (0-100)
const roundnessPercent = React.useMemo(() => {
if (!roundness) return null;
const config: GraphConfig = {
title: "Diameter",
defaultTimeWindow: 30 * 60 * 1000, // 30 minutes
exportFilename: "diameter_data",
const transformValue = (
val: { value: number; timestamp: number } | null,
) => {
if (!val) return null;
return { value: val.value * 100, timestamp: val.timestamp };
};
const transformSeries = (series: any) => {
if (!series || !series.values) return series;
return {
...series,
values: series.values.map(transformValue),
};
};
return {
current: roundness.current ? transformValue(roundness.current) : null,
long: transformSeries(roundness.long),
short: transformSeries(roundness.short),
};
}, [roundness]);
const diameterColor = "#3b82f6"; // blue
const xDiameterColor = "#ef4444"; // red
const yDiameterColor = "#22c55e"; // green
const roundnessColor = "#eab308"; // yellow
// config for all graphs
const baseGraphConfig: GraphConfig = {
title: "",
defaultTimeWindow: 30 * 60 * 1000, // 30 minutes Standard
colors: {
primary: diameterColor,
grid: "#e2e8f0",
axis: "#64748b",
background: "#ffffff",
},
};
if (!isTwoAxis) {
return (
<Page className="pb-27">
<div className="flex flex-col gap-4">
// Diameter-Graph (Diameter, X-Diameter, Y-Diameter)
const diameterGraphData = [
{
newData: diameter,
color: diameterColor,
title: "Diameter",
lines: [
// Tolerance for diameter
{
type: "threshold" as const,
value: targetDiameter + higherTolerance,
label: "Upper Tolerance",
color: diameterColor,
dash: [5, 5],
},
{
type: "threshold" as const,
value: targetDiameter - lowerTolerance,
label: "Lower Tolerance",
color: diameterColor,
dash: [5, 5],
},
{
type: "target" as const,
value: targetDiameter,
label: "Target",
color: diameterColor,
},
],
},
...(isTwoAxis && x_diameter
? [{ newData: x_diameter, color: xDiameterColor, title: "X-Diameter" }]
: []),
...(isTwoAxis && y_diameter
? [{ newData: y_diameter, color: yDiameterColor, title: "Y-Diameter" }]
: []),
];
// Roundness-Graph
const roundnessGraphData = {
newData: roundnessPercent,
color: roundnessColor,
title: "Roundness (%)",
};
return (
<Page className="pb-27">
<div className="flex flex-col gap-4">
{/* Graph 1: Diameter */}
<AutoSyncedBigGraph
syncHook={syncHook}
newData={diameterGraphData}
unit="mm"
renderValue={(value) => value.toFixed(3)}
config={{
...baseGraphConfig,
title: "Diameter (mm)",
exportFilename: "diameter_data",
colors: {
...baseGraphConfig.colors,
primary: diameterColor,
},
}}
graphId="diameter-graph"
/>
{/* Graph 2: Roundness */}
{isTwoAxis && roundnessPercent && (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: diameter,
color: diameterColor,
lines: [
{
type: "threshold",
value: targetDiameter + higherTolerance,
label: "Upper Threshold",
color: diameterColor,
dash: [5, 5],
},
{
type: "threshold",
value: targetDiameter - lowerTolerance,
label: "Lower Threshold",
color: diameterColor,
dash: [5, 5],
},
{
type: "target",
value: targetDiameter,
label: "Target",
color: diameterColor,
},
],
newData={roundnessGraphData}
unit="%"
renderValue={(value) => value.toFixed(2)}
config={{
...baseGraphConfig,
title: "Roundness (%)",
exportFilename: "roundness_data",
colors: {
...baseGraphConfig.colors,
primary: roundnessColor,
},
}}
unit="mm"
renderValue={(value) => value.toFixed(3)}
config={config}
graphId="diameter-graph"
graphId="roundness-graph"
/>
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
} else {
return (
<Page className="pb-27">
<div className="flex flex-col gap-4">
<AutoSyncedBigGraph
syncHook={syncHook}
newData={[
{
newData: diameter,
color: diameterColor,
title: "Diameter",
lines: [
{
type: "threshold",
value: targetDiameter + higherTolerance,
label: "Upper Threshold",
color: diameterColor,
dash: [5, 5],
},
{
type: "threshold",
value: targetDiameter - lowerTolerance,
label: "Lower Threshold",
color: diameterColor,
dash: [5, 5],
},
{
type: "target",
value: targetDiameter,
label: "Target",
color: diameterColor,
},
],
},
{
newData: x_diameter,
color: xDiameterColor,
title: "X-Diameter",
},
{
newData: y_diameter,
color: yDiameterColor,
title: "Y-Diameter",
},
{
newData: roundness,
color: roundnessColor,
title: "Roundness",
},
]}
unit="mm"
renderValue={(value) => value.toFixed(3)}
config={config}
graphId="diameter-graph"
/>
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
}
)}
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
}
@@ -21,6 +21,12 @@ export function Laser1Page() {
icon: "lu:ChartSpline",
},
// {
// link: "settings",
// activeLink: "settings",
// title: "Config",
// icon: "lu:Settings",
// },
// {
// link: "manual",
// activeLink: "manual",
// title: "Manual",
@@ -1,7 +1,7 @@
import React from "react";
import { z } from "zod";
import { useLaser1 } from "./useLaser1";
import { Preset, PresetData } from "@/lib/preset/preset";
import { Preset } from "@/lib/preset/preset";
import { laser1 } from "@/machines/properties";
import { PresetPreviewEntries } from "@/components/preset/PresetPreviewTable";
import { PresetsPage } from "@/components/preset/PresetsPage";
@@ -14,7 +14,7 @@ const laser1PresetDataSchema = z
})
.partial();
type Laser1 = typeof laser1PresetDataSchema;
type Laser1 = z.infer<typeof laser1PresetDataSchema>;
const schemas = new Map([[1, laser1PresetDataSchema]]);
@@ -22,18 +22,17 @@ const previewEntries: PresetPreviewEntries<Laser1> = [
{
name: "Target Diameter",
unit: "mm",
renderValue: (data: PresetData<Laser1>) => data?.targetDiameter?.toFixed(3),
renderValue: (data: Laser1) => data?.targetDiameter?.toFixed(3),
},
{
name: "Lower Tolerance",
unit: "mm",
renderValue: (data: PresetData<Laser1>) => data?.lowerTolerance?.toFixed(3),
renderValue: (data: Laser1) => data?.lowerTolerance?.toFixed(3),
},
{
name: "Higher Tolerance",
unit: "mm",
renderValue: (data: PresetData<Laser1>) =>
data?.higherTolerance?.toFixed(3),
renderValue: (data: Laser1) => data?.higherTolerance?.toFixed(3),
},
];
@@ -47,16 +46,16 @@ export function Laser1PresetsPage() {
} = useLaser1();
const applyPreset = (preset: Preset<Laser1>) => {
const targetDiameter = preset.data.targetDiameter ?? 1.75;
const lowerTolerance = preset.data?.lowerTolerance ?? 0.05;
const higherTolerance = preset.data?.higherTolerance ?? 0.05;
const targetDiameter = preset?.data?.targetDiameter ?? 1.75;
const lowerTolerance = preset?.data?.lowerTolerance ?? 0.05;
const higherTolerance = preset?.data?.higherTolerance ?? 0.05;
setTargetDiameter(targetDiameter);
setLowerTolerance(lowerTolerance);
setHigherTolerance(higherTolerance);
};
const toPresetData = (s: typeof state): PresetData<Laser1> => ({
const toPresetData = (s: typeof state): Laser1 => ({
targetDiameter: s?.laser_state.target_diameter,
lowerTolerance: s?.laser_state.lower_tolerance,
higherTolerance: s?.laser_state.higher_tolerance,
@@ -42,6 +42,7 @@ export const stateEventDataSchema = z.object({
higher_tolerance: z.number(),
lower_tolerance: z.number(),
target_diameter: z.number(),
in_tolerance: z.boolean(),
}),
});
@@ -66,28 +67,17 @@ export type Laser1NamespaceStore = {
};
// Constants for time durations
const TWENTY_MILLISECOND = 20;
const ONE_SECOND = 1000;
const FIVE_SECOND = 5 * ONE_SECOND;
const ONE_HOUR = 60 * 60 * ONE_SECOND;
const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const HALF_SECOND = 500;
const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries({
sampleIntervalLong: HALF_SECOND,
});
const { initialTimeSeries: x_diameter, insert: addXDiameter } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries({ sampleIntervalLong: HALF_SECOND });
const { initialTimeSeries: y_diameter, insert: addYDiameter } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries({ sampleIntervalLong: HALF_SECOND });
const { initialTimeSeries: roundness, insert: addRoundness } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
{ sampleIntervalLong: HALF_SECOND },
);
/**
@@ -102,7 +92,7 @@ export const createLaser1NamespaceStore = (): StoreApi<Laser1NamespaceStore> =>
diameter: diameter,
x_diameter,
y_diameter,
roundness: roundness,
roundness,
};
});
@@ -3,7 +3,7 @@ import { useMock1 } from "./useMock";
import { mock1 } from "@/machines/properties";
import { PresetsPage } from "@/components/preset/PresetsPage";
import { Preset, PresetData } from "@/lib/preset/preset";
import { Preset } from "@/lib/preset/preset";
import { PresetPreviewEntries } from "@/components/preset/PresetPreviewTable";
import { z } from "zod";
@@ -15,7 +15,7 @@ const mock1PresetDataSchema = z
})
.partial();
type Mock1 = typeof mock1PresetDataSchema;
type Mock1 = z.infer<typeof mock1PresetDataSchema>;
const schemas = new Map([[1, mock1PresetDataSchema]]);
@@ -23,17 +23,17 @@ const previewEntries: PresetPreviewEntries<Mock1> = [
{
name: "Frequency 1",
unit: "mHz",
renderValue: (data: PresetData<Mock1>) => data?.frequency1?.toFixed(3),
renderValue: (data: Mock1) => data?.frequency1?.toFixed(3),
},
{
name: "Frequency 2",
unit: "mHz",
renderValue: (data: PresetData<Mock1>) => data?.frequency2?.toFixed(3),
renderValue: (data: Mock1) => data?.frequency2?.toFixed(3),
},
{
name: "Frequency 3",
unit: "mHz",
renderValue: (data: PresetData<Mock1>) => data?.frequency3?.toFixed(3),
renderValue: (data: Mock1) => data?.frequency3?.toFixed(3),
},
];
@@ -51,7 +51,7 @@ export function Mock1PresetsPage() {
setFrequency3(frequency3);
};
const toPresetData = (s: typeof state): PresetData<Mock1> => ({
const toPresetData = (s: typeof state): Mock1 => ({
frequency1: s?.frequency1 ?? defaultState?.frequency1,
frequency2: s?.frequency2 ?? defaultState?.frequency2,
frequency3: s?.frequency3 ?? defaultState?.frequency3,
@@ -78,18 +78,7 @@ export type Mock1NamespaceStore = {
sineWave3: TimeSeries;
};
// Constants for time durations
const TWENTY_MILLISECOND = 20;
const ONE_SECOND = 1000;
const FIVE_SECOND = 5 * ONE_SECOND;
const ONE_HOUR = 60 * 60 * ONE_SECOND;
const { initialTimeSeries: sineWave, insert: addSineWave } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const { initialTimeSeries: sineWave, insert: addSineWave } = createTimeSeries();
/**
* Factory function to create a new Mock1 namespace store
+198
View File
@@ -103,6 +103,94 @@ export const winder2: MachineProperties = {
],
};
export const extruder3: MachineProperties = {
name: "Extruder",
version: "V3",
slug: "extruder3",
icon: "qi:Extruder",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0016,
},
device_roles: [
{
role: 0,
role_label: "Bus Coupler",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x44c2c52,
revision: 0x120000,
},
],
},
{
role: 1,
role_label: "Inverter Interface",
allowed_devices: [
{
vendor_id: 2,
product_id: 394604626,
revision: 1376256,
},
{
vendor_id: 2,
product_id: 394604626,
revision: 0x140000,
},
{
vendor_id: 2,
product_id: 394604626,
revision: 0x160000,
},
{
vendor_id: 2,
product_id: 394604626,
revision: 0x100000,
},
],
},
{
role: 2,
role_label: "Heating Elements",
allowed_devices: [
{
vendor_id: 2,
product_id: 131346514,
revision: 1179648,
},
],
},
{
role: 3,
role_label: "Pressure Sensor",
allowed_devices: [
{
vendor_id: 2,
product_id: 197996626,
revision: 1310720,
},
],
},
{
role: 4,
role_label: "Thermometers",
allowed_devices: [
{
vendor_id: 2,
product_id: 0xc843052,
revision: 1441792,
},
{
vendor_id: 2,
product_id: 0xc843052,
revision: 0x150000,
},
],
},
],
};
export const extruder2: MachineProperties = {
name: "Extruder",
version: "V2",
@@ -340,13 +428,123 @@ export const aquapath1: MachineProperties = {
],
};
export const testmachine: MachineProperties = {
name: "TestMachine",
version: "V1",
slug: "testmachine",
icon: "lu:Disc3",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0033,
},
device_roles: [
{
role: 0,
role_label: "Bus Coupler",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x44c2c52,
revision: 0x120000,
},
],
},
{
role: 1,
role_label: "EL2004",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x7d43052,
revision: 0x120000,
},
],
},
],
};
export const wagoPower1: MachineProperties = {
name: "WAGO Power",
version: "V1",
slug: "wago_power1",
icon: "lu:PlugZap",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x000a,
},
device_roles: [],
};
export const analogInputTestMachine: MachineProperties = {
name: "AnalogTest",
version: "V1",
slug: "analogInputTestMachine",
icon: "lu:Clock",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0035,
},
device_roles: [
{
role: 0,
role_label: "Bus Coupler",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x44c2c52,
revision: 0x120000,
},
],
},
{
role: 1,
role_label: "EL3021",
allowed_devices: [
{
vendor_id: 2,
product_id: 0xbcd3052,
revision: 0x140000,
},
],
},
],
};
export const ip20TestMachine: MachineProperties = {
name: "IP20 Test",
version: "V1",
slug: "ip20testmachine",
icon: "lu:ToggleLeft",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0034,
},
device_roles: [
{
role: 0,
role_label: "IP20-EC-DI8-DO8",
allowed_devices: [
{
vendor_id: 0x741,
product_id: 0x117b6722,
revision: 0x1,
},
],
},
],
};
export const machineProperties: MachineProperties[] = [
winder2,
extruder3,
extruder2,
laser1,
mock1,
buffer1,
wagoPower1,
aquapath1,
testmachine,
analogInputTestMachine,
ip20TestMachine,
];
export const getMachineProperties = (
@@ -0,0 +1,62 @@
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 { useTestMachine } from "./useTestMachine";
export function TestMachineControlPage() {
const { state, setLed, setAllLeds } = useTestMachine();
const safeState = state ?? { led_on: [false, false, false, false] };
return (
<Page>
<ControlGrid columns={2}>
{/* LED Controls */}
<ControlCard title="Machine LEDs">
<div className="grid grid-cols-2 gap-6">
{safeState.led_on.map((led, index) => (
<Label key={index} label={`LED ${index + 1}`}>
<SelectionGroup<"On" | "Off">
value={led ? "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) => setLed(index, value === "On")}
/>
</Label>
))}
</div>
</ControlCard>
{/* Master LED Control */}
<ControlCard title="Master LED 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,20 @@
import { Topbar } from "@/components/Topbar";
import { testMachineSerialRoute } from "@/routes/routes";
import React from "react";
export function TestMachinePage() {
const { serial } = testMachineSerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/testmachine/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:CirclePlay",
},
]}
/>
);
}
@@ -0,0 +1,76 @@
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(4),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
// ========== Store ==========
export type TestMachineNamespaceStore = {
state: StateEvent | null;
};
export const createTestMachineNamespaceStore =
(): StoreApi<TestMachineNamespaceStore> =>
create<TestMachineNamespaceStore>(() => ({
state: null,
}));
// ========== Message Handler ==========
export function testMachineMessageHandler(
store: StoreApi<TestMachineNamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<TestMachineNamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const updateStore = (
updater: (state: TestMachineNamespaceStore) => TestMachineNamespaceStore,
) => 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 useTestMachineNamespaceImplementation =
createNamespaceHookImplementation<TestMachineNamespaceStore>({
createStore: createTestMachineNamespaceStore,
createEventHandler: testMachineMessageHandler,
});
export function useTestMachineNamespace(
machine_identification_unique: MachineIdentificationUnique,
): TestMachineNamespaceStore {
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};
return useTestMachineNamespaceImplementation(namespaceId);
}
@@ -0,0 +1,95 @@
import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { testMachineSerialRoute } from "@/routes/routes";
import { MachineIdentificationUnique } from "@/machines/types";
import { useTestMachineNamespace, StateEvent } from "./testMachineNamespace";
import { useMachineMutate } from "@/client/useClient";
import { produce } from "immer";
import { useEffect, useMemo } from "react";
import { testmachine } from "@/machines/properties";
import { z } from "zod";
export function useTestMachine() {
const { serial: serialString } = testMachineSerialRoute.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: testmachine.machine_identification,
serial,
};
}, [serialString]);
// Namespace state from backend
const { state } = useTestMachineNamespace(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 = [on, on, on, on];
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetAllLeds", value: { on } },
}),
);
};
return {
state: stateOptimistic.value,
setLed,
setAllLeds,
};
}
+6 -4
View File
@@ -2,7 +2,7 @@
// each role can only be given once
import { IconName } from "@/components/Icon";
import { rustEnumSchema } from "@/lib/types";
import { rustEnum } from "@/lib/types";
import { z } from "zod";
// EK1100 should have role 0
@@ -62,9 +62,11 @@ export type DeviceHardwareIdentificationEthercat = z.infer<
typeof deviceHardwareIdentificationEthercatSchema
>;
export const deviceHardwareIdentificationSchema = rustEnumSchema({
Ethercat: deviceHardwareIdentificationEthercatSchema,
});
export const deviceHardwareIdentificationSchema = z
.object({
Ethercat: deviceHardwareIdentificationEthercatSchema,
})
.check(rustEnum);
export type DeviceHardwareIdentification = z.infer<
typeof deviceHardwareIdentificationSchema
@@ -0,0 +1,67 @@
import { Page } from "@/components/Page";
import React from "react";
import { useWagoPower1 } from "./useWagoPower1";
import { ControlGrid } from "@/control/ControlGrid";
import { ControlCard } from "@/control/ControlCard";
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Mode } from "./wagoPower1Namespace";
export function WagoPower1ControlPage() {
const {
state,
voltage,
current,
setMode,
isLoading,
isDisabled,
} = useWagoPower1();
return (
<Page>
<ControlGrid columns={2}>
<ControlCard title="Power">
<TimeSeriesValueNumeric
label="Voltage"
unit="V"
timeseries={voltage}
renderValue={(value) => value.toFixed(2)}
/>
<TimeSeriesValueNumeric
label="Current"
unit="mA"
timeseries={current}
renderValue={(value) => value.toFixed(2)}
/>
</ControlCard>
<ControlCard title="Mode">
<SelectionGroup<Mode>
value={state?.mode}
disabled={isDisabled}
loading={isLoading}
onChange={setMode}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Off: {
children: "Off",
icon: "lu:PowerOff",
isActiveClassName: "bg-gray-600",
className: "h-full",
},
On24V: {
children: "On",
icon: "lu:Power",
isActiveClassName: "bg-green-600",
className: "h-full",
},
}}
/>
</ControlCard>
</ControlGrid>
</Page>
);
}
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff. Show more