Compare commits

..
Author SHA1 Message Date
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
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
Oshgnacknak fb85cc6db7 Merge pull request #896 from qitechgmbh/877-remove-dependencies-that-are-not-necessary-or-bloated
Remove futures crate
2025-11-24 23:13:55 +01:00
Robin Krämer 4e128ded36 Merge pull request #885 from qitechgmbh/699-add-min-and-max-diameter-on-laser-page2
699 add min and max diameter on laser page
2025-11-24 13:02:35 +01:00
Oshgnacknak f50388b141 Just restart failed tasks 2025-11-24 12:54:40 +01:00
ChristianandCopilot 4025a68fe1 Update electron/src/control/MinMaxValue.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-23 17:58:07 +01:00
ChristianandCopilot 7a05fa206f Update electron/src/control/MinMaxValue.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-23 17:58:07 +01:00
Christian 8594b06c3b Reorganize shared timeframe selector for clarity and improve layout for diameter range display 2025-11-23 17:58:07 +01:00
Christian 17523574d8 Add shared timeframe selector to Laser1ControlPage and expose TIMEFRAME_OPTIONS 2025-11-23 17:58:07 +01:00
Christian 7971797178 Refactor diameter range display for 2-axis lasers and improve layout 2025-11-23 17:58:07 +01:00
Christian a8b0837e58 Add MinMaxValue component for displaying diameter ranges 2025-11-23 17:58:07 +01:00
Oshgnacknak e419974844 Refactor init logic to work without futures crate 2025-11-22 17:30:37 +01:00
Oshgnacknak 773d0f6c15 Remove tonic 2025-11-22 16:15:48 +01:00
Oshgnacknak afc4e0d8b4 Completely remove opentelemetry and update dependencies 2025-11-22 16:15:47 +01:00
Robin Krämer 5e86988ac4 Merge pull request #907 from qitechgmbh/882-add-nozzle-docs-for-extruder
882 add nozzle docs for extruder
2025-11-19 11:38:07 +01:00
snacj 2d28c18b1a Added Heading for new Mechanical Diagram 2025-11-18 14:04:32 +01:00
snacj b19764281a Added Nozzle docs 2025-11-18 13:58:51 +01:00
snacj c25d63f781 Added Nozzle docs 2025-11-18 13:58:51 +01:00
Robin Krämer d79efc412f Merge pull request #904 from qitechgmbh/898-mock-machine-doesnt-work
898 mock machine doesnt work
2025-11-18 12:53:24 +01:00
rob 7ad74cf089 fixed formatting 2025-11-17 18:00:43 +01:00
rob 6073d37892 fixed remaining compile errors 2025-11-17 18:00:43 +01:00
rob c04937553f machines show up but dont get subscribed to 2025-11-17 18:00:43 +01:00
rob e08bdfa3c7 removed unnecessary and outdated docs 2025-11-17 18:00:43 +01:00
rob d282678b19 fixed compile errors for mock-machines 2025-11-17 18:00:43 +01:00
Robin Krämer 9ff4243350 Merge pull request #902 from qitechgmbh/2.12-releasenotes
updated Changelog
2025-11-17 16:38:48 +01:00
rob 6c79b33752 updated Changelog 2025-11-17 16:36:41 +01:00
rob 861b5da7a3 updated Changelog 2025-11-17 16:35:28 +01:00
124 changed files with 7564 additions and 1057 deletions

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@ dist/
Cargo.lock
dhat-heap.json
build.sh
shell.nix
+16
View File
@@ -1,3 +1,19 @@
# `2.12.0`
_17.11.2025_
## General
- [#847](https://github.com/qitechgmbh/control/pull/847) Improved discovery and initialization
- [#859](https://github.com/qitechgmbh/control/pull/859) Improved update speed
- [#897](https://github.com/qitechgmbh/control/pull/897), [#899](https://github.com/qitechgmbh/control/pull/899), [#852](https://github.com/qitechgmbh/control/pull/852) Improved system stability
- [#817](https://github.com/qitechgmbh/control/pull/817) Added an update bar
## Laser
- [#857](https://github.com/qitechgmbh/control/pull/857) Fixed potential issues with 2 Axis Laser
## Extruder V2
- [#881](https://github.com/qitechgmbh/control/pull/881) Extended Temperature PID Configuration
# `2.11.0`
_21.10.2025_
Generated
+424 -407
View File
File diff suppressed because it is too large. Load diff
+227 -1
View File
@@ -74,7 +74,9 @@ 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 | - |
# More Docs
# More Docs docs
/assets
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
@@ -152,3 +154,227 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
- [x] [Ethercat Basics](./docs/ethercat-basics.md)
- [x] [NixOS Operating System](./docs/nixos/README.md)
## 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
![](./docs/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.
![](./docs/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="./docs/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="./docs/assets/el2004.jpeg" width="400">
---
## 3.4 Final Assembled Setup
### **Figure 3 — EK1100 + EL2004 Connected**
<img src="./docs/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="./docs/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="./docs/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
![](./docs/assets/discovery.png)
Steps:
- Click Assign on the EK1100
- Select TestMachine V1
![](./docs/assets/setmachine.png)
- Enter a serial number (use the same for EK1100 + EL2004)
![](./docs/assets/serserial.png)
- Click Write
- Repeat for the EL2004
## 5.2 Testing LED Control
Navigate to:
Machines → TestMachine
![](./docs/assets/machinedetected.png)
You will see this interface:
![](./docs/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.
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

Binary file not shown.
-43
View File
@@ -1,43 +0,0 @@
# Installation Guide for Qitech-Control Software on Linux
This guide assumes you have an already installed linux distribution like debian,fedora etc.
It needs to a kernel that either has PREEMPT_RT patched or which we heavily recommend a mainline kernel later that 6.12, as versions afterwards have PREEMPT_RT integrated and can be switched with a kernel parameter.
Additionally it is assumed that at least four cpu cores are usable.
## Kernel Parameters
For our Ethercat control to run determinstically while also being as fast as possible you will need to configure a few kernel parameters.
Most Distributions use something like grub to persistently configure kernel parameters.
For Grub add the kernel parameters to the LINUX_COMMANDLINE_DEFAULT:
```
```
isolcpus=2,3 isolates the third and fourth core from the system scheduler, cutting down latency
nohz_full=2,3 isolates third anf fourth core from kernel interrupts
rcu_nocbs=2,3 isolates read copy update from the given cpu cores, you guessed it less latency
preempt="full" turns linux into a realtime capable OS. Offering deterministic Execution for programs
After you changed the kernel parameters you have to rebuild the initramfs and bootloader, with something like grub.
```
# On Most Systems you need this command
sudo update-grub
# Or this one
sudo update-grub2
```
If those commands dont work for you or you dont use grub then simply,
look up for your given distro what you need to do to persist kernel parameters.
## Installation On Generic Linux
This applies to any linux distro, that is not explicitily covered by this guide.
### Download Rust-toolchain
The easiest way is through rustup, simply execute the script as you home user and for everything keep it at defaults
### Download Npm and Node
The exact commands depend on which distro you use, but for most npm should exist as a package you can download in the package manager
+17 -40
View File
@@ -91,7 +91,7 @@
"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",
@@ -175,7 +175,6 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -639,7 +638,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
},
@@ -662,7 +660,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1148,7 +1145,6 @@
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chalk": "^4.1.1",
"fs-extra": "^9.0.1",
@@ -4095,7 +4091,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.130.17.tgz",
"integrity": "sha512-F1tyMEbX7PJclnA25hAFXhLKq4jqeODvvaogsd0/NX02Kmz3QoT9GV+rgpv2TxamU6RwjRgihSH06Dn5hM6SOw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.130.12",
"@tanstack/react-store": "^0.7.0",
@@ -4177,7 +4172,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.130.17.tgz",
"integrity": "sha512-0AH4s/52X2shYLifr9hR+6R6NpGsBp1M3LyjBTZm6Jlkud+nOhkkRxKxYS7l+JErRlffdxVHxMdoTnKcQ+duiQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.130.12",
"@tanstack/store": "^0.7.0",
@@ -4411,7 +4405,8 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -4568,7 +4563,6 @@
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.10.0"
}
@@ -4585,7 +4579,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.16.tgz",
"integrity": "sha512-WBM/nDbEZmDUORKnh5i1bTnAz6vTohUf9b8esSMu+b24+srbaxa04UbJgWx78CVfNXA20sNu0odEIluZDFdCog==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4595,7 +4588,6 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
"devOptional": true,
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@@ -4678,7 +4670,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz",
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.38.0",
"@typescript-eslint/types": "8.38.0",
@@ -5033,7 +5024,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5481,7 +5471,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -6174,8 +6163,7 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"peer": true
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/data-urls": {
"version": "5.0.0",
@@ -6448,7 +6436,8 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/dom-helpers": {
"version": "5.2.1",
@@ -7448,7 +7437,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz",
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7509,7 +7497,6 @@
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -8816,7 +8803,6 @@
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -8879,7 +8865,6 @@
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -9554,7 +9539,6 @@
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -10120,6 +10104,7 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -12060,11 +12045,11 @@
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz",
"integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==",
"dev": true,
"peer": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -12178,6 +12163,7 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -12192,6 +12178,7 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"peer": true,
"engines": {
"node": ">=10"
},
@@ -12320,7 +12307,6 @@
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -12329,7 +12315,6 @@
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -12341,7 +12326,6 @@
"version": "7.62.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -12382,7 +12366,8 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
@@ -13133,7 +13118,6 @@
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -13923,8 +13907,7 @@
"node_modules/tailwindcss": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
"peer": true
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@@ -14026,8 +14009,7 @@
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"peer": true
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/tiny-warning": {
"version": "1.0.3",
@@ -14078,7 +14060,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14390,7 +14371,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14752,7 +14732,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -14874,7 +14853,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15418,7 +15396,6 @@
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+1 -1
View File
@@ -55,7 +55,7 @@
"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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

@@ -720,3 +720,7 @@ The following persons must not work on the machine:
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 8](images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_8-008.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 9](images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_9-009.png)
## Mechanical Diagrams
![QiTech Pro Extruder Nozzle 2025](images/manuals/extruder/Nozzle.png)
+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;
+173
View File
@@ -0,0 +1,173 @@
import { Icon, IconName } from "@/components/Icon";
import React, { useState, useMemo } from "react";
import {
getUnitIcon,
renderValueToReactNode,
renderUnitSymbol,
Unit,
} from "./units";
import { Label } from "./Label";
import { TimeSeries, TimeSeriesValue } from "@/lib/timeseries";
type TimeframeOption = {
label: string;
value: number; // in milliseconds
};
export const TIMEFRAME_OPTIONS: TimeframeOption[] = [
{ label: "1 min", value: 1 * 60 * 1000 },
{ label: "5 min", value: 5 * 60 * 1000 },
{ label: "10 min", value: 10 * 60 * 1000 },
{ label: "30 min", value: 30 * 60 * 1000 },
{ label: "1 hour", value: 60 * 60 * 1000 },
];
type Props = {
label: string;
unit?: Unit;
timeseries: TimeSeries;
icon?: IconName;
renderValue?: (value: number) => string;
defaultTimeframe?: number; // in milliseconds, defaults to 5 minutes
/** Optional externally-controlled timeframe (ms). When provided, component becomes controlled. */
timeframe?: number;
/** Optional callback when timeframe is changed via this component's selector */
onTimeframeChange?: (t: number) => void;
/** When true, hide the built-in timeframe selector (useful when sharing a selector outside) */
hideSelector?: boolean;
};
export function MinMaxValue({
unit,
timeseries,
icon,
label,
renderValue,
defaultTimeframe = 5 * 60 * 1000, // 5 minutes default
timeframe,
onTimeframeChange,
hideSelector = false,
}: Props) {
const [internalTimeframe, setInternalTimeframe] = useState(defaultTimeframe);
const selectedTimeframe = timeframe ?? internalTimeframe;
// Calculate min/max from the timeseries using the selected timeframe
const { min, max, hasData } = useMemo(() => {
// Use short series for timeframes <= 5 seconds, otherwise use long series
// Short series typically has ~5 second retention with high resolution
// Long series typically has ~1 hour retention with lower resolution
const series =
selectedTimeframe <= 5000 ? timeseries.short : timeseries.long;
// Manually calculate min/max to exclude zero values (which might be invalid readings)
const cutoffTime = series.lastTimestamp - selectedTimeframe;
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
let hasValidData = false;
let dataCount = 0;
const { values: raw, index, size, validCount } = series;
const startIdx = validCount < size ? 0 : index;
for (let i = 0; i < validCount; i++) {
const idx = (startIdx + i) % size;
const val = raw[idx];
if (
val &&
val.timestamp > 0 &&
val.timestamp >= cutoffTime &&
val.value > 0
) {
hasValidData = true;
dataCount++;
if (val.value < min) min = val.value;
if (val.value > max) max = val.value;
}
}
if (!hasValidData) {
return { min: 0, max: 0, hasData: false };
}
return { min, max, hasData: true };
}, [
timeseries.short,
timeseries.long,
timeseries.current,
selectedTimeframe,
]);
return (
<div className="flex w-full flex-col gap-3">
<Label label={label}>
<div className="flex flex-row items-center gap-6">
{/* Min Value */}
<div className="flex flex-row items-center gap-2">
<Icon
name={icon ?? (unit ? getUnitIcon(unit) : undefined)}
className="size-5 text-blue-500"
/>
<div className="flex flex-col">
<span className="text-xs text-gray-500">Min</span>
<div className="flex flex-row items-center gap-1">
<span className="font-mono text-xl font-bold">
{hasData
? renderValueToReactNode(min, unit, renderValue)
: "-"}
</span>
{hasData && (
<span className="text-sm">{renderUnitSymbol(unit)}</span>
)}
</div>
</div>
</div>
{/* Max Value */}
<div className="flex flex-row items-center gap-2">
<Icon
name={icon ?? (unit ? getUnitIcon(unit) : undefined)}
className="size-5 text-red-500"
/>
<div className="flex flex-col">
<span className="text-xs text-gray-500">Max</span>
<div className="flex flex-row items-center gap-1">
<span className="font-mono text-xl font-bold">
{hasData
? renderValueToReactNode(max, unit, renderValue)
: "-"}
</span>
{hasData && (
<span className="text-sm">{renderUnitSymbol(unit)}</span>
)}
</div>
</div>
</div>
</div>
</Label>
{/* Timeframe Selector (hidden when sharing selector externally) */}
{!hideSelector && (
<div className="flex flex-row flex-wrap gap-2">
{TIMEFRAME_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => {
if (onTimeframeChange) onTimeframeChange(option.value);
else setInternalTimeframe(option.value);
}}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
selectedTimeframe === option.value
? "bg-blue-500 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{option.label}
</button>
))}
</div>
)}
</div>
);
}
+7
View File
@@ -35,6 +35,8 @@ export function getUnitIcon(unit: Unit): IconName {
return "lu:BatteryFull";
case "l/min":
return "lu:Waves";
case "%":
return "lu:ChartNoAxesColumn";
default:
return "lu:ChartNoAxesColumn";
}
@@ -72,6 +74,8 @@ export function renderUnitSymbol(unit: Unit | undefined): string {
return "kWh";
case "l/min":
return "l/min";
case "%":
return "%";
default:
return "";
}
@@ -131,6 +135,8 @@ export function renderUnitSymbolLong(unit: Unit): string {
return "kilowatt-hours";
case "l/min":
return "liters/minute";
case "%":
return "percent";
default:
return "";
}
@@ -152,6 +158,7 @@ export const units = [
"A",
"kWh",
"l/min",
"%",
] as const;
export type Unit = (typeof units)[number];
@@ -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}
/>
@@ -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,203 @@
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, PresetData } 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 = typeof extruder3PresetDataSchema;
const schemas = new Map([[1, extruder3PresetDataSchema]]);
const previewEntries: PresetPreviewEntries<Extruder3> = [
{
name: "Target Front Temperature",
unit: "C",
renderValue: (data: PresetData<Extruder3>) =>
data.targetFrontHeatingTemperature?.toFixed(1),
},
{
name: "Target Middle Temperature",
unit: "C",
renderValue: (data: PresetData<Extruder3>) =>
data.targetMiddleHeatingTemperature?.toFixed(1),
},
{
name: "Target Back Temperature",
unit: "C",
renderValue: (data: PresetData<Extruder3>) =>
data.targetBackHeatingTemperature?.toFixed(1),
},
{
name: "Target Nozzle Temperature",
unit: "C",
renderValue: (data: PresetData<Extruder3>) =>
data.targetNozzleHeatingTemperature?.toFixed(1),
},
previewSeparator,
{
name: "Inverter Regulation",
renderValue: (data: PresetData<Extruder3>) => data.inverterRegulation,
},
{
name: "Target Inverter RPM",
unit: "rpm",
renderValue: (data: PresetData<Extruder3>) =>
data.targetInverterRpm?.toFixed(2),
},
{
name: "Target Inverter Pressure",
unit: "bar",
renderValue: (data: PresetData<Extruder3>) =>
data.targetInverterPressure?.toFixed(1),
},
{
name: "Inverter Direction",
renderValue: (data: PresetData<Extruder3>) =>
data.inverterRotationDirection,
},
previewSeparator,
{
name: "Pressure Limit",
unit: "bar",
renderValue: (data: PresetData<Extruder3>) =>
data.pressureLimit?.toFixed(1),
},
{
name: "Enable Pressure Limit",
renderValue: (data: PresetData<Extruder3>) =>
data.pressureLimitEnabled ? "on" : "off",
},
previewSeparator,
{
name: "PID Pressue Kp",
renderValue: (data: PresetData<Extruder3>) =>
data.pidPressureKp?.toFixed(4),
},
{
name: "PID Pressue Ki",
renderValue: (data: PresetData<Extruder3>) =>
data.pidPressureKi?.toFixed(4),
},
{
name: "PID Pressue Kd",
renderValue: (data: PresetData<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): PresetData<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,433 @@
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;
};
// 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: backTemperature, insert: addBackTemperature } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: frontTemperature, insert: addFrontTemperature } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: middleTemperature, insert: addMiddleTemperature } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: nozzleTemperature, insert: addNozzleTemperature } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: nozzlePower, insert: addNozzlePower } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: frontPower, insert: addFrontPower } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
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,
);
const { initialTimeSeries: combinedPower, insert: addCombinedPower } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: totalEnergyKWh, insert: addTotalEnergyKWh } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: motorCurrent, insert: addMotorCurrent } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: motorFrequency, insert: addMotorFrequency } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: motorScrewRpm, insert: addMotorScrewRpm } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: motorPower, insert: addMotorPower } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
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,
};
}
@@ -3,6 +3,7 @@ import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
import { MinMaxValue, TIMEFRAME_OPTIONS } from "@/control/MinMaxValue";
import { EditValue } from "@/control/EditValue";
import { Label } from "@/control/Label";
@@ -28,6 +29,12 @@ export function Laser1ControlPage() {
const targetDiameter = state?.laser_state?.target_diameter ?? 0;
const lowerTolerance = state?.laser_state?.lower_tolerance ?? 0;
const higherTolerance = state?.laser_state?.higher_tolerance ?? 0;
// Detect if this is a 2-axis laser
const isTwoAxis = !!x_diameter?.current || !!y_diameter?.current;
// Shared timeframe state (default 5 minutes)
const [timeframe, setTimeframe] = React.useState<number>(5 * 60 * 1000);
return (
<Page>
<ControlGrid columns={2}>
@@ -72,11 +79,64 @@ 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>
)}
<div className="mt-4 border-t pt-4">
{isTwoAxis ? (
// For 2-axis lasers: show diameter and roundness min/max side by side
<div className="grid grid-cols-2 gap-4">
<MinMaxValue
label="Diameter Range"
unit="mm"
timeseries={diameter}
renderValue={(value) => value.toFixed(3)}
timeframe={timeframe}
hideSelector
/>
{roundness?.current && (
<MinMaxValue
label="Roundness Range"
unit="%"
timeseries={roundness}
renderValue={(value) => (value * 100).toFixed(2)}
timeframe={timeframe}
hideSelector
/>
)}
</div>
) : (
// For single-axis lasers: show only diameter min/max with shared selector
<MinMaxValue
label="Diameter Range"
unit="mm"
timeseries={diameter}
renderValue={(value) => value.toFixed(3)}
timeframe={timeframe}
hideSelector
/>
)}
{/* Shared timeframe selector for diameter/roundness */}
<div className="mt-3 flex flex-row flex-wrap gap-2">
{TIMEFRAME_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => setTimeframe(option.value)}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
timeframe === option.value
? "bg-blue-500 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{option.label}
</button>
))}
</div>
</div>
</ControlCard>
<ControlCard title="Settings">
<Label label="Set Target Diameter">
@@ -19,6 +19,32 @@ export function Laser1GraphsPage() {
const isTwoAxis = !!x_diameter?.current || !!y_diameter?.current;
// Transform roundness from ratio (0-1) to percentage (0-100)
const roundnessPercent = React.useMemo(() => {
if (!roundness) return null;
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";
const xDiameterColor = "#ef4444";
const yDiameterColor = "#22c55e";
@@ -121,13 +147,12 @@ export function Laser1GraphsPage() {
title: "Y-Diameter",
},
{
newData: roundness,
newData: roundnessPercent,
color: roundnessColor,
title: "Roundness",
title: "Roundness (%)",
},
]}
unit="mm"
renderValue={(value) => value.toFixed(3)}
renderValue={(value) => value.toFixed(2)}
config={config}
graphId="diameter-graph"
/>
+124
View File
@@ -103,6 +103,94 @@ export const winder2: MachineProperties = {
],
};
export const extruder3: MachineProperties = {
name: "Extruder V3",
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",
@@ -339,14 +427,50 @@ 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 machineProperties: MachineProperties[] = [
winder2,
extruder3,
extruder2,
laser1,
mock1,
buffer1,
aquapath1,
testmachine,
];
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,
};
}
+83 -10
View File
@@ -1,46 +1,62 @@
import { createRoute, Outlet } from "@tanstack/react-router";
import { RootRoute } from "./__root";
import React from "react";
import { z } from "zod";
import { ChooseVersionPage } from "@/setup/ChooseVersionPage";
import { fallback, zodValidator } from "@tanstack/zod-adapter";
import { githubSourceSchema } from "@/setup/GithubSourceDialog";
import { SidebarLayout } from "@/components/SidebarLayout";
import { SetupPage } from "@/setup/SetupPage";
import { EthercatPage } from "@/setup/EthercatPage";
import { MachinesPage } from "@/setup/MachinesPage";
import { ChangelogPage } from "@/setup/ChangelogPage";
import { TroubleshootPage } from "@/setup/Trobleshoot";
import { UpdateExecutePage } from "@/setup/UpdateExecutePage";
import { Winder2Page } from "@/machines/winder/winder2/Winder2Page";
import { Winder2ControlPage } from "@/machines/winder/winder2/Winder2ControlPage";
import { Winder2ManualPage } from "@/machines/winder/winder2/Winder2Manual";
import { Winder2SettingPage } from "@/machines/winder/winder2/Winder2Settings";
import { Winder2GraphsPage } from "@/machines/winder/winder2/Winder2Graphs";
import { Winder2PresetsPage } from "@/machines/winder/winder2/Winder2PresetsPage";
import { Extruder2Page } from "@/machines/extruder/extruder2/Extruder2Page";
import { Extruder2ControlPage } from "@/machines/extruder/extruder2/Extruder2ControlPage";
import { Extruder2SettingsPage } from "@/machines/extruder/extruder2/Extruder2Settings";
import { ExtruderV2ManualPage } from "@/machines/extruder/extruder2/Extruder2Manual";
import { ChooseVersionPage } from "@/setup/ChooseVersionPage";
import { fallback, zodValidator } from "@tanstack/zod-adapter";
import { Extruder2GraphsPage } from "@/machines/extruder/extruder2/Extruder2Graph";
import { Extruder2PresetsPage } from "@/machines/extruder/extruder2/Extruder2PresetsPage";
import { Extruder3Page } from "@/machines/extruder/extruder3/Extruder3Page";
import { Extruder3ControlPage } from "@/machines/extruder/extruder3/Extruder3ControlPage";
import { Extruder3SettingsPage } from "@/machines/extruder/extruder3/Extruder3Settings";
import { ExtruderV3ManualPage } from "@/machines/extruder/extruder3/Extruder3Manual";
import { Extruder3GraphsPage } from "@/machines/extruder/extruder3/Extruder3Graph";
import { Extruder3PresetsPage } from "@/machines/extruder/extruder3/Extruder3PresetsPage";
import { Buffer1ControlPage } from "@/machines/buffer/buffer1/Buffer1ControlPage";
import { Buffer1Page } from "@/machines/buffer/buffer1/Buffer1Page";
import { Buffer1SettingsPage } from "@/machines/buffer/buffer1/Buffer1Settings";
import { ChangelogPage } from "@/setup/ChangelogPage";
import { Extruder2GraphsPage } from "@/machines/extruder/extruder2/Extruder2Graph";
import { Extruder2PresetsPage } from "@/machines/extruder/extruder2/Extruder2PresetsPage";
import { githubSourceSchema } from "@/setup/GithubSourceDialog";
import { Laser1ControlPage } from "@/machines/laser/laser1/Laser1ControlPage";
import { Laser1GraphsPage } from "@/machines/laser/laser1/Laser1Graph";
import { Laser1Page } from "@/machines/laser/laser1/Laser1Page";
import { Mock1ControlPage } from "@/machines/mock/mock1/Mock1ControlPage";
import { Mock1GraphPage } from "@/machines/mock/mock1/Mock1Graph";
import { Mock1ManualPage } from "@/machines/mock/mock1/Mock1Manual";
import { Mock1Page } from "@/machines/mock/mock1/Mock1Page";
import { Mock1PresetsPage } from "@/machines/mock/mock1/Mock1PresetsPage";
import { TroubleshootPage } from "@/setup/Trobleshoot";
import { UpdateExecutePage } from "@/setup/UpdateExecutePage";
import { Winder2PresetsPage } from "@/machines/winder/winder2/Winder2PresetsPage";
import { Aquapath1ControlPage } from "@/machines/aquapath/aquapath1/Aquapath1ControlPage";
import { Aquapath1Page } from "@/machines/aquapath/aquapath1/Aquapath1Page";
import { Aquapath1GraphPage } from "@/machines/aquapath/aquapath1/Aquapath1Graph";
import { z } from "zod";
import { TestMachinePage } from "@/machines/testmachine/TestMachinePage";
import { TestMachineControlPage } from "@/machines/testmachine/TestMachineControlPage";
import { Laser1PresetsPage } from "@/machines/laser/laser1/Laser1PresetsPage";
// make a route tree like this
// _mainNavigation/machines/winder2/$serial/control
// _mainNavigation/configuration/a
@@ -48,6 +64,18 @@ import { Laser1PresetsPage } from "@/machines/laser/laser1/Laser1PresetsPage";
// the mainNavigation has a custom layout
// the winder2 winder2 and configuration also have a custom layout
// the leaf routes are just pages
export const testMachineSerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "testmachine/$serial",
component: () => <TestMachinePage />,
});
// Leaf route: control page
export const testMachineControlRoute = createRoute({
getParentRoute: () => testMachineSerialRoute,
path: "control",
component: () => <TestMachineControlPage />,
});
export const sidebarRoute = createRoute({
getParentRoute: () => RootRoute,
@@ -96,6 +124,42 @@ export const extruder2PresetsRoute = createRoute({
component: () => <Extruder2PresetsPage />,
});