Compare commits

...
Author SHA1 Message Date
Oshgnacknak 2cdc5f791e Loop until modbus device has been found 2025-12-25 21:55:08 +01:00
nixos-install.sh 1f426b698b Enable dhcp and dns server on lan interface 2025-12-25 21:55:08 +01:00
Oshgnacknak fd2790ad10 Fix loop logic 2025-12-25 21:55:08 +01:00
Oshgnacknak b98f92426c Use actual serial number from device 2025-12-25 21:55:08 +01:00
Oshgnacknak a759970488 Fix mock-compile 2025-12-25 21:55:08 +01:00
Oshgnacknak 26c91c7ddd Turn power on/off in UI 2025-12-25 21:55:08 +01:00
Oshgnacknak 1f6f43c7ec Show error if mutation fails 2025-12-25 21:55:08 +01:00
Oshgnacknak 2c84aeed5b Actually set registers correctly 2025-12-25 21:55:08 +01:00
Oshgnacknak a0625b376d Allow setting mode 2025-12-25 21:55:08 +01:00
Oshgnacknak 866dfcec34 Allow turning power on and off 2025-12-25 21:55:08 +01:00
Oshgnacknak 977927810e Read live values from wago power supply 2025-12-25 21:55:08 +01:00
Oshgnacknak 82d62be4ef Display values from namespace 2025-12-25 21:55:08 +01:00
Oshgnacknak 9008f77b8b Basic machine now known its namespace 2025-12-25 21:55:08 +01:00
Oshgnacknak 793b0ebf37 Most basic WAGO power page 2025-12-25 21:55:08 +01:00
Oshgnacknak 2df42517a0 Initialize very basic WAGO machine 2025-12-25 21:55:08 +01:00
Oshgnacknak 026549c37f Fix mock compile 2025-12-25 21:55:08 +01:00
Oshgnacknak c6a85d4fe0 Remove machine new trait from Machine trait
Machines should not need to know, how they where created.
Also, no machine should by assumption be considered an EtherCAT machine.
2025-12-25 21:55:08 +01:00
Oshgnacknak 07d04f3204 Add timeout to Modbus TCP connection 2025-12-25 21:55:08 +01:00
Oshgnacknak 3c325ee386 Discover both EtherCAT and Modbus TCP devices 2025-12-25 21:55:08 +01:00
Oshgnacknak f36723f435 Merge pull request #1011 from qitechgmbh/1010-move-minimal-getting-started-machines-to-their-own-doc
Refactor minimal examples to be in their own md, link from readme and add to getting started docs
2025-12-24 22:49:04 +01:00
ChristianandCopilot e96854a6bc Update docs/developer-docs/minimal-example-el3021.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-24 22:47:17 +01:00
Christian 04c5a673da Refactor minimal examples to be in their own md, link from readme and add to getting started docs 2025-12-24 22:47:17 +01:00
Oshgnacknak 40e17ef5f1 Merge pull request #943 from qitechgmbh/892-add-datasheets-to-the-machines-manual-tab
892 add datasheets to the machines manual tab
2025-12-24 22:26:10 +01:00
Christian 39200aac12 Update import path for extruder manual to align with new documentation structure 2025-12-24 22:24:03 +01:00
Christian 43a405eec4 Update extruder manual import path to reflect new documentation structure 2025-12-24 22:24:03 +01:00
Christian 0206a4ddca Refactor Markdown components for improved readability and maintainability 2025-12-24 22:24:03 +01:00
Christian 34da7178f8 Change manual markdown location and add an readme to explain where images are stored. 2025-12-24 22:24:03 +01:00
Christian 2726c47280 Update Images of datasheet 2025-12-24 22:24:03 +01:00
Christian 63cf25b3d6 Add extruder datasheet to manual 2025-12-24 22:24:03 +01:00
Christian eae13207e4 Add winder datasheet 2025-12-24 22:24:03 +01:00
Oshgnacknak f65d100cd5 Merge pull request #1008 from ThatsMeGIT/patch-1
Fix typo in README about EtherCAT master software
2025-12-24 22:14:36 +01:00
ThatsMe 13e4ea1ac7 Fix typo in README about EtherCAT master software 2025-12-24 22:13:49 +01:00
ᴏxᴏ.ᴍɪᴋᴀ 49788246c8 Merge pull request #945 from qitechgmbh/916-when-assigning-the-ethercat-terminals-no-keyboard-is-showing-up
916 when assigning the ethercat terminals no keyboard is showing up
2025-12-24 20:53:25 +01:00
Christian d4e849d588 Merge pull request #1009 from qitechgmbh/1006-implement-minmal-machine-für-ip-20-community-8di8do
1006 implement minmal machine for ip 20 community 8di8do
2025-12-24 11:11:50 +01:00
Christian 2689d19928 Refactor import statements for IP20 EC DI8 DO8 module across multiple files 2025-12-23 16:07:07 +01:00
Christian 968e9327f6 Remove unused u16dump and u16print debug functions from machine identification 2025-12-23 16:04:37 +01:00
Christian 3cbb74b3b0 Refactor IP20 EC DI8 DO8 device integration: move implementation to wago_modules and update module imports 2025-12-23 15:55:53 +01:00
Christian 50fcfd6e30 Refactor IP20 Test Machine control page and improve code formatting 2025-12-23 15:50:52 +01:00
Christian ce0f27787c Refactor error handling and logging in IP20EcDi8Do8 implementation 2025-12-23 15:49:48 +01:00
Christian 08ab7fd404 Implement IP20 Test Machine support with control and namespace handling 2025-12-23 15:39:16 +01:00
Christian f138b61a45 Add support for IP20 EC DI8 DO8 device identification 2025-12-23 15:06:18 +01:00
Christian 0ad0a58d46 Add Wago IP20 EC DI8 DO8 device support and initialization logic 2025-12-23 15:01:35 +01:00
EinsPommes 3d13e6cee2 Refactor DeviceEepromDialog to simplify state management by updating setOpen handler to a no-argument function, enhancing clarity in dialog closure logic. 2025-12-20 16:11:19 +01:00
EinsPommes 86915c2987 Shorten onOpenChange handler in DeviceEepromDialog 2025-12-20 16:09:42 +01:00
EinsPommes ada8d0f12c Refactor numpad handling in DeviceEepromDialog to simplify position updates and improve interaction logic. Removed unnecessary observers and streamlined event handling for opening and closing the numpad. 2025-12-20 15:47:30 +01:00
EinsPommes 735456b6eb Enhance DeviceEepromDialog with improved numpad handling and dialog interactions. Added position tracking for the numpad, preventing dialog closure on outside interactions, and ensuring focus remains on the serial input when the numpad is open. 2025-12-20 15:47:30 +01:00
EinsPommes b46a528adb add aria-label for numpad toggle button in DeviceEepromDialog 2025-12-20 15:47:30 +01:00
EinsPommes 80f2343a60 fix: prettier formatting 2025-12-20 15:47:30 +01:00
EinsPommes e030fbe23e Implement touch numpad for serial input in DeviceEepromDialog 2025-12-20 15:47:30 +01:00
Oshgnacknak 1ef0cb608c Merge pull request #997 from qitechgmbh/996-fix-mock-machines-not-running-under-macos
Fix thread ID retrieval for macOS in start_loop_thread function
2025-12-20 15:46:20 +01:00
Christian 789a84e511 Fix thread ID retrieval for unsupported platforms and windows 2025-12-20 15:22:25 +01:00
ChristianandCopilot 28620b6a23 Update server/src/loop.rs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-20 13:54:26 +01:00
Christian 01609513fb Fix formatting in start_loop_thread function for better readability 2025-12-20 13:48:34 +01:00
Christian 379a4ec417 Fix thread ID retrieval for macOS in start_loop_thread function 2025-12-20 13:47:06 +01:00
Robin Krämer 28d898e780 Merge pull request #927 from qitechgmbh/dependabot/cargo/syn-2.0.111
Bump syn from 2.0.110 to 2.0.111
2025-12-19 14:46:02 +01:00
dependabot[bot] daabb170f5 Bump syn from 2.0.110 to 2.0.111
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.110 to 2.0.111.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.110...2.0.111)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-19 14:43:02 +01:00
Robin Krämer c4803e63dd Merge pull request #993 from qitechgmbh/992-fix-install-script
Revert "Merge pull request #914 from qitechgmbh/bin-directory"
2025-12-19 14:12:58 +01:00
rob 0022d86035 Revert "Merge pull request #914 from qitechgmbh/bin-directory"
This reverts commit aecabfde43, reversing
changes made to b8168c6b43.
2025-12-19 14:10:40 +01:00
Hans Sebastian Pöhlmann dc711a820f Analog input test machine (#989)
Adding AnalogInputTestMachine, a minimal demo setup with EK1100 and EL3021. Adding setup instructions to readme.
2025-12-18 16:45:28 +00:00
Robin Krämer 59cf2cea2a Merge pull request #849 from qitechgmbh/769-add-an-alert-or-warning-in-the-frontend-for-filament-out-of-spec
769 add an alert or warning in the frontend for filament out of spec
2025-12-18 12:00:52 +01:00
Snacj da69ad1c3e Removed unused import 2025-12-18 11:22:22 +01:00
Snacj 6ff434fb47 Fixed Formatting 2025-12-18 11:22:22 +01:00
Snacj a63c57a0b9 Global error on Filament out of tolerance. 2025-12-18 11:22:22 +01:00
Snacj 21c43adcb4 Rebase and error fixing 2025-12-18 11:22:22 +01:00
snacj afa23666e2 Resolved small writing and logic errors. 2025-12-18 11:22:22 +01:00
snacj 245ad64426 Fixed Formatting 2025-12-18 11:22:22 +01:00
snacj 9665971150 Fixed 0mm threshold for Laser Diameter Warning 2025-12-18 11:22:22 +01:00
snacj 3b056c8f26 Changed Styling of Checkbox. 2025-12-18 11:22:22 +01:00
snacj 62b35e0237 Added AutoStopWinderOnOutOfTolerance to Laser Presets 2025-12-18 11:22:22 +01:00
snacj 11bc62f40e formatting 2025-12-18 11:22:22 +01:00
snacj 116c76d218 Added AutoStopWinderOnOutOfTolerance 2025-12-18 11:22:22 +01:00
snacj 4dcedb537d Changed Styling of Warning. 2025-12-18 11:22:22 +01:00
snacj 09022c9c86 Global Laser Warning addded 2025-12-18 11:22:22 +01:00
snacj 80cf98b11d Laser Warning works across all laser pages 2025-12-18 11:22:22 +01:00
snacj 644e750353 Added global Laser Alert 2025-12-18 11:22:22 +01:00
Robin Krämer aecabfde43 Merge pull request #914 from qitechgmbh/bin-directory
Move scripts to bin directory with better names
2025-12-18 11:20:28 +01:00
Oshgnacknak 686678a1fe Rename install script in electron app 2025-12-18 11:16:39 +01:00
Oshgnacknak 7ac0b788c2 Move scripts to bin directory with better names 2025-12-18 11:16:39 +01:00
Robin Krämer b8168c6b43 Merge pull request #976 from qitechgmbh/aquapath-fix-port-selection
Aquapath fix port selection
2025-12-17 10:12:30 +01:00
rob 6026d2a658 ports are now correctly selected for the aquapath 2025-12-17 10:10:18 +01:00
Achraf Jalleli a301f3e228 Merge pull request #979 from qitechgmbh/932-add-metrics
Add compile and runtime metrics
2025-12-16 22:45:54 +01:00
JalleliAchraf 0b6bd68a24 frontend UI improvements + compile metrics script fixes 2025-12-16 17:43:33 +01:00
Christian e7fd390263 Merge pull request #969 from qitechgmbh/968-Add-mock-machines-to-pr-checks
968 add mock machines to pr checks and fix mocks and dev build
2025-12-16 16:09:17 +01:00
JalleliAchraf eab675795a Add compile and runtime metrics 2025-12-16 14:07:03 +01:00
Christian 8c2dc9a507 Merge branch 'master' into 968-Add-mock-machines-to-pr-checks 2025-12-16 10:49:14 +01:00
Christian f0fd9e8ce6 fix: clean up import formatting in registry.rs 2025-12-15 11:00:49 +01:00
Christian c5579a4471 fix mock machines in dev build 2025-12-15 10:58:07 +01:00
Christian de7a65bf12 feat: add mock machine build step to CI workflow 2025-12-15 10:43:52 +01:00
136 changed files with 10073 additions and 2692 deletions

No files matched your search

+3
View File
@@ -46,3 +46,6 @@ jobs:
- name: Run tests
run: cargo test --verbose
- name: Mock Machine
run: cargo build --features development-build --features mock-machine
+3
View File
@@ -5,3 +5,6 @@ Cargo.lock
dhat-heap.json
build.sh
shell.nix
node_modules/
runtime_metrics.csv
compile_metrics.csv
Generated
+24 -23
View File
@@ -204,7 +204,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -221,7 +221,7 @@ checksum = "99e1aca718ea7b89985790c94aad72d77533063fe00bc497bb79a7c2dae6a661"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -291,7 +291,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -539,7 +539,7 @@ version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -682,7 +682,7 @@ dependencies = [
"deluxe-macros",
"once_cell",
"proc-macro2",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -695,7 +695,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -710,7 +710,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -721,7 +721,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -962,7 +962,7 @@ dependencies = [
"deluxe",
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -1014,7 +1014,7 @@ dependencies = [
"criterion",
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -1878,7 +1878,7 @@ dependencies = [
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -1908,7 +1908,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -2221,7 +2221,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -2251,7 +2251,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -2323,6 +2323,7 @@ dependencies = [
"dhat",
"ethercat_hal",
"ethercrab",
"libc",
"machines",
"regex",
"serde",
@@ -2572,9 +2573,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.110"
version = "2.0.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
dependencies = [
"proc-macro2",
"quote",
@@ -2620,7 +2621,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -2680,7 +2681,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -2842,7 +2843,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -3073,7 +3074,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
"wasm-bindgen-shared",
]
@@ -3138,7 +3139,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -3149,7 +3150,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
[[package]]
@@ -3425,5 +3426,5 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"syn 2.0.111",
]
+10 -227
View File
@@ -35,7 +35,7 @@ Other
## Backend
To interface with Beckhoff and other EtherCAT devices we need an EtherCAT master stoftware. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
To interface with Beckhoff and other EtherCAT devices we need an EtherCAT master software. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
[SocketIO](https://socket.io/) was chosen for performant event driven communication from the backend to the server. But we still use REST with [axum](https://docs.rs/axum/latest/axum/) for the communication thet benefits from the request/response model.
@@ -74,9 +74,16 @@ 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 docs
# Minimal Hardware Examples
/assets
For complete step-by-step tutorials on setting up your first hardware, including wiring diagrams and software configuration, see the [Getting Started Guide](./docs/developer-docs/getting-started.md#minimal-hardware-examples).
Available examples:
- **LED Control with EL2004** - Digital output control (simplest setup)
- **Analog Input with EL3021** - Reading analog current measurements
# More Docs
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
@@ -154,227 +161,3 @@ 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.
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
BUILD_DIR="${BUILD_DIR:-target}"
CSV_FILE="${CSV_FILE:-compile_metrics.csv}"
DEFAULT_PKG="server"
if [ "$#" -eq 0 ]; then
set -- cargo build
fi
if [ "${1:-}" = "cargo" ] && [ "${2:-}" = "build" ]; then
cargo clean
BUILD_CMD=(cargo rustc -p "$DEFAULT_PKG")
shift 2
BUILD_CMD+=("$@")
BUILD_CMD+=(-- --emit=llvm-ir -C debuginfo=0)
else
BUILD_CMD=("$@")
fi
compile_start_ns=$(date +%s%N)
"${BUILD_CMD[@]}"
compile_end_ns=$(date +%s%N)
compile_ms=$(( (compile_end_ns - compile_start_ns) / 1000000 ))
if [ -d "$BUILD_DIR" ]; then
llvm_ir_lines=$(
find "$BUILD_DIR" -type f -name '*.ll' -print0 \
| xargs -0 cat \
| wc -l
)
else
llvm_ir_lines=0
fi
bin_path=""
if [ -n "${BINARY_PATH:-}" ] && [ -f "$BINARY_PATH" ]; then
bin_path="$BINARY_PATH"
elif [ -f "./target/release/server" ]; then
bin_path="./target/release/server"
elif [ -f "./target/debug/server" ]; then
bin_path="./target/debug/server"
elif [ -f "./server/target/release/server" ]; then
bin_path="./server/target/release/server"
elif [ -f "./server/target/debug/server" ]; then
bin_path="./server/target/debug/server"
fi
if [ -n "$bin_path" ]; then
binary_size_bytes=$(stat -c '%s' "$bin_path")
else
binary_size_bytes=0
fi
finished_timestamp_ms=$(($(date +%s%N) / 1000000))
finished_datetime=$(date -Iseconds)
if [ ! -f "$CSV_FILE" ]; then
echo "datetime,finished_timestamp_ms,compile_ms,llvm_ir_lines,binary_size_bytes" > "$CSV_FILE"
fi
echo "${finished_datetime},${finished_timestamp_ms},${compile_ms},${llvm_ir_lines},${binary_size_bytes}" \
>> "$CSV_FILE"
echo "Metrics recorded in $CSV_FILE"
@@ -1,189 +0,0 @@
use ethercrab::{
MainDevice, MainDeviceConfig, PduStorage, Timeouts,
std::{ethercat_now, tx_rx_task},
};
use interfaces::Interface;
use std::{process::Command, sync::Arc, time::Duration};
// Constants for EtherCAT configuration
const IFACE_DISCOVERY_MAX_SUBDEVICES: usize = 16; // Must be power of 2 > 1
const IFACE_DISCOVERY_MAX_PDU_DATA: usize = PduStorage::element_size(1100);
const IFACE_DISCOVERY_MAX_FRAMES: usize = 16;
const IFACE_DISCOVERY_MAX_PDI_LEN: usize = 128;
/// Sets a network interface to unmanaged by NetworkManager.
/// Returns true if the command succeeded.
pub fn set_interface_managed(interface: &str, managed: bool) -> bool {
let managed_str = match managed {
true => "yes",
false => "no",
};
tracing::info!(
"set_interface_managed for {} managed was set to: {}",
interface,
managed_str
);
let status = Command::new("nmcli")
.args(["dev", "set", interface, "managed", managed_str])
.status();
matches!(status, Ok(s) if s.success())
}
/// Returns true if the given network interface is Ethernet.
/// Prevents testing wlan,loopback and other non ethernet devices
fn is_ethernet(interface: &str) -> std::io::Result<bool> {
#[cfg(target_os = "linux")]
{
let base_path = format!("/sys/class/net/{}", interface);
let type_path = std::path::Path::new(&base_path).join("type");
let iface_type = std::fs::read_to_string(&type_path)?.trim().to_string();
// 1 means Ethernet
let reports_as_ethernet = iface_type == "1";
// Double-check that it's not a wireless interface
let uevent_path = std::path::Path::new(&base_path).join("uevent");
let uevent = std::fs::read_to_string(&uevent_path).unwrap_or_default();
// If "DEVTYPE=wlan" appears, it's lying
let actually_wifi = uevent.contains("DEVTYPE=wlan");
return Ok(reports_as_ethernet && !actually_wifi);
}
#[cfg(not(target_os = "linux"))]
return Ok(true);
}
pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
tracing::info!("Discovering EtherCAT interface...");
// Set up a custom panic hook that suppresses panic output
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
// Optional: log the panic in a controlled way
if let Some(location) = panic_info.location() {
tracing::debug!(
"Suppressed panic in interface test: {} (at {}:{})",
panic_info,
location.file(),
location.line()
);
} else {
tracing::debug!("Suppressed panic in interface test: {}", panic_info);
}
}));
// Get eligible interfaces
let mut interfaces = Interface::get_all()
.map_err(|e| anyhow::anyhow!("Failed to get network interfaces: {}", e))?
.into_iter()
.filter(|iface| {
iface.is_up()
&& iface.is_running()
&& !iface.is_loopback()
&& is_ethernet(&iface.name).unwrap_or(false)
&& !iface.name.starts_with("bridge")
&& !iface.name.starts_with("utun") // Exclude tunnel interfaces
&& !iface.name.starts_with("awdl") // Exclude Apple Wireless Direct Link
&& !iface.name.starts_with("anpi") // Exclude Apple Network Privacy Interface
&& !iface.name.starts_with("llw") // Exclude Apple low-latency WLAN
})
.collect::<Vec<_>>();
interfaces.sort_by(|a, b| a.name.cmp(&b.name));
let mut interface: Option<&str> = None;
for i in 0..interfaces.len() {
match test_interface(&interfaces[i].name) {
Ok(_) => {
// if interface found with ethercat Exit early, we expect only one interface with ethercat
interface = Some(&interfaces[i].name);
break;
}
Err(_) => (),
}
}
for i in 0..interfaces.len() {
if interface.is_some() && interface.unwrap() == &interfaces[i].name {
set_interface_managed(&interfaces[i].name, false);
} else {
set_interface_managed(&interfaces[i].name, true);
}
}
// Restore the default panic hook
std::panic::set_hook(default_hook);
match interface {
Some(interface) => return Ok(interface.to_string()),
None => return Err(anyhow::anyhow!("No suitable EtherCAT interface found")),
}
}
fn test_interface(interface: &str) -> Result<(), anyhow::Error> {
tracing::info!("Testing interface: {}", interface);
let pdu_storage = Box::leak(Box::new(PduStorage::<
IFACE_DISCOVERY_MAX_FRAMES,
IFACE_DISCOVERY_MAX_PDU_DATA,
>::new()));
let (tx, rx, pdu_loop) = pdu_storage
.try_split()
.map_err(|e| anyhow::anyhow!("Failed to split PDU storage: {:?}", e))?;
let rt = smol::LocalExecutor::new();
let result = rt.run(async {
let tx_rx_handle = rt.spawn(
tx_rx_task(interface, tx, rx)
.map_err(|e| anyhow::anyhow!("Failed to spawn TX/RX task: {}", e))?,
);
let maindevice = Arc::new(MainDevice::new(
pdu_loop,
Timeouts {
// Default 5000ms
state_transition: Duration::from_millis(5000),
// Default 30_000us
pdu: Duration::from_micros(30_000),
// Default 10ms
eeprom: Duration::from_millis(10),
// Default 0ms
wait_loop_delay: Duration::from_millis(0),
// Default 100ms
mailbox_echo: Duration::from_millis(100),
// Default 1000ms
mailbox_response: Duration::from_millis(1000),
},
MainDeviceConfig {
// Default 10000
dc_static_sync_iterations: 10000,
// Default None
retry_behaviour: ethercrab::RetryBehaviour::Count(3),
},
));
let result = maindevice
.init_single_group::<IFACE_DISCOVERY_MAX_SUBDEVICES, IFACE_DISCOVERY_MAX_PDI_LEN>(
ethercat_now,
)
.await
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Failed to initialize group: {}", e));
tx_rx_handle.cancel().await;
result
});
// await the result of the async block
let result = smol::block_on(result);
if let Err(e) = result {
return Err(anyhow::anyhow!(
"Failed to initialize EtherCAT on interface {}: {:?}",
interface,
e
));
}
Ok(())
}
-1
View File
@@ -1 +0,0 @@
pub mod interface_discovery;
@@ -0,0 +1,126 @@
use anyhow::{Context, Result};
use ethercrab::{
MainDevice, MainDeviceConfig, PduStorage, RetryBehaviour, Timeouts,
std::{ethercat_now, tx_rx_task},
};
use crate::{
ethernet::{get_interfaces, set_interface_managed},
futures::FutureIteratorExt,
};
// Constants for EtherCAT configuration
const IFACE_DISCOVERY_MAX_SUBDEVICES: usize = 16; // Must be power of 2 > 1
const IFACE_DISCOVERY_MAX_PDU_DATA: usize = PduStorage::element_size(1100);
const IFACE_DISCOVERY_MAX_FRAMES: usize = 16;
const IFACE_DISCOVERY_MAX_PDI_LEN: usize = 128;
pub async fn probe_ethercat() -> Option<String> {
let interfaces = match get_interfaces() {
Ok(x) => x,
Err(_) => return None,
};
interfaces
.iter()
.map(|iface| iface.name.to_owned())
.map(probe_ethercat_interface)
.join_all()
.await
.into_iter()
.find_map(|x| x.ok())
}
async fn probe_ethercat_interface(interface: String) -> Result<String> {
tracing::info!("Testing ethercat on interface: {}", interface);
let pdu_storage = Box::leak(Box::new(PduStorage::<
IFACE_DISCOVERY_MAX_FRAMES,
IFACE_DISCOVERY_MAX_PDU_DATA,
>::new()));
let (tx, rx, pdu_loop) = pdu_storage
.try_split()
.expect("Failed to split PDU storage");
let tx_rx_handle =
smol::spawn(tx_rx_task(&interface, tx, rx).context("Failed to spawn TX/RX task")?);
let maindevice = MainDevice::new(
pdu_loop,
Timeouts::default(),
MainDeviceConfig {
// Default 10000
dc_static_sync_iterations: 10000,
// Default None
retry_behaviour: RetryBehaviour::Count(3),
},
);
let res = maindevice
.init_single_group::<IFACE_DISCOVERY_MAX_SUBDEVICES, IFACE_DISCOVERY_MAX_PDI_LEN>(
ethercat_now,
)
.await
.context(format!(
"Failed to initialize EtherCAT on interface {}",
interface
));
tx_rx_handle.cancel().await;
res?;
Ok(interface)
}
pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
tracing::info!("Discovering EtherCAT interface...");
// Set up a custom panic hook that suppresses panic output
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
// Optional: log the panic in a controlled way
if let Some(location) = panic_info.location() {
tracing::debug!(
"Suppressed panic in interface test: {} (at {}:{})",
panic_info,
location.file(),
location.line()
);
} else {
tracing::debug!("Suppressed panic in interface test: {}", panic_info);
}
}));
let mut interfaces = get_interfaces()?;
interfaces.sort_by(|a, b| a.name.cmp(&b.name));
let mut interface: Option<&str> = None;
for i in 0..interfaces.len() {
match probe_ethercat_interface(interfaces[i].name.to_string()).await {
Ok(_) => {
// if interface found with ethercat Exit early, we expect only one interface with ethercat
interface = Some(&interfaces[i].name);
break;
}
Err(_) => (),
}
}
for i in 0..interfaces.len() {
if interface.is_some() && interface.unwrap() == &interfaces[i].name {
set_interface_managed(&interfaces[i].name, false);
} else {
set_interface_managed(&interfaces[i].name, true);
}
}
// Restore the default panic hook
std::panic::set_hook(default_hook);
match interface {
Some(interface) => Ok(interface.to_string()),
None => Err(anyhow::anyhow!("No suitable EtherCAT interface found")),
}
}
+66
View File
@@ -0,0 +1,66 @@
use std::process::Command;
use anyhow::Result;
use interfaces::Interface;
pub mod ethercat_interface_discovery;
pub mod modbus_tcp_discovery;
/// Returns true if the given network interface is Ethernet.
/// Prevents testing wlan,loopback and other non ethernet devices
fn is_ethernet(interface: &str) -> std::io::Result<bool> {
#[cfg(target_os = "linux")]
{
let base_path = format!("/sys/class/net/{}", interface);
let type_path = std::path::Path::new(&base_path).join("type");
let iface_type = std::fs::read_to_string(&type_path)?.trim().to_string();
// 1 means Ethernet
let reports_as_ethernet = iface_type == "1";
// Double-check that it's not a wireless interface
let uevent_path = std::path::Path::new(&base_path).join("uevent");
let uevent = std::fs::read_to_string(&uevent_path).unwrap_or_default();
// If "DEVTYPE=wlan" appears, it's lying
let actually_wifi = uevent.contains("DEVTYPE=wlan");
Ok(reports_as_ethernet && !actually_wifi)
}
#[cfg(not(target_os = "linux"))]
return Ok(true);
}
pub fn get_interfaces() -> Result<Vec<Interface>> {
let vec = Interface::get_all()
.map_err(|e| anyhow::anyhow!("Failed to get network interfaces: {}", e))?
.into_iter()
.filter(|iface| {
iface.is_up()
&& iface.is_running()
&& !iface.is_loopback()
&& is_ethernet(&iface.name).unwrap_or(false)
&& !iface.name.starts_with("bridge")
&& !iface.name.starts_with("utun") // Exclude tunnel interfaces
&& !iface.name.starts_with("awdl") // Exclude Apple Wireless Direct Link
&& !iface.name.starts_with("anpi") // Exclude Apple Network Privacy Interface
&& !iface.name.starts_with("llw") // Exclude Apple low-latency WLAN
})
.collect::<Vec<_>>();
Ok(vec)
}
/// Sets a network interface to unmanaged by NetworkManager.
/// Returns true if the command succeeded.
pub fn set_interface_managed(interface: &str, managed: bool) -> bool {
let managed_str = match managed {
true => "yes",
false => "no",
};
tracing::info!(
"set_interface_managed for {} managed was set to: {}",
interface,
managed_str
);
let status = Command::new("nmcli")
.args(["dev", "set", interface, "managed", managed_str])
.status();
matches!(status, Ok(s) if s.success())
}
@@ -0,0 +1,78 @@
use crate::ethernet::get_interfaces;
use crate::{futures::FutureIteratorExt, modbus::tcp::ModbusTcpDevice};
use anyhow::{Result, bail};
use interfaces::Interface;
use std::cmp::min;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
pub struct ModbusTcpProbe {
pub addr: SocketAddr,
pub serial: u16,
}
pub async fn probe_modbus_tcp() -> Vec<ModbusTcpProbe> {
let interfaces = match get_interfaces() {
Ok(x) => x,
Err(_) => return vec![],
};
interfaces
.into_iter()
.map(probe_modbus_tcp_addresses)
.join_all()
.await
.into_iter()
.flatten()
.collect()
}
async fn probe_modbus_tcp_addresses(interface: Interface) -> Vec<ModbusTcpProbe> {
let out: Vec<ModbusTcpProbe> = interface
.addresses
.iter()
.filter_map(|addr| {
let a = addr.addr.map(|a| a.ip());
let m = addr.mask.map(|a| a.ip());
match (a, m) {
(Some(IpAddr::V4(addr)), Some(IpAddr::V4(mask))) => Some((addr, mask)),
_ => None,
}
})
.flat_map(|(addr, mask)| {
let a = u32::from(addr);
let m = u32::from(mask);
let network = a & m;
let prefix = min(24, m.count_ones()); // mask → prefix length
let size = 1u32 << (32 - prefix); // number of addresses
(200..size).map(move |i| SocketAddr::new(Ipv4Addr::from(network + i).into(), 502))
})
.map(|addr| smol::spawn(ping_modbus_device(addr)))
.join_all()
.await
.into_iter()
.filter_map(|x| x.ok())
.collect();
out
}
async fn ping_modbus_device(addr: SocketAddr) -> Result<ModbusTcpProbe> {
let mut device = ModbusTcpDevice::new(addr).await?;
let module_number1 = device.get_u32(0x2).await?;
let module_number2 = device.get_u32(0x4).await?;
if module_number1 != 0x2787_2144 || module_number2 != 0x0000_0000 {
bail!("Unknown modbus tcp device!");
}
let serial = device.get_u32(0x000A).await?;
Ok(ModbusTcpProbe {
addr,
serial: serial as u16,
})
}
+26
View File
@@ -0,0 +1,26 @@
use core::future::Future;
// Extension trait for iterators of futures
pub trait FutureIteratorExt<F>: Iterator<Item = F> + Sized
where
F: Future,
{
/// Await all futures and return their outputs
fn join_all(self) -> impl Future<Output = Vec<F::Output>>;
}
impl<I, F> FutureIteratorExt<F> for I
where
I: Iterator<Item = F> + Sized,
F: Future,
{
async fn join_all(self) -> Vec<F::Output> {
let mut out = Vec::new();
for fut in self {
out.push(fut.await);
}
out
}
}
+2 -1
View File
@@ -1,7 +1,8 @@
pub mod controllers;
pub mod converters;
pub mod downcast;
pub mod ethercat;
pub mod ethernet;
pub mod futures;
pub mod helpers;
pub mod irq_handling;
pub mod modbus;
+2
View File
@@ -5,6 +5,8 @@ use crc::{CRC_16_MODBUS, Crc};
use serialport::SerialPort;
use std::time::Duration;
pub mod tcp;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParityType {
Even,
+274
View File
@@ -0,0 +1,274 @@
use std::fmt::Debug;
use std::io::ErrorKind;
use std::net::SocketAddr;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use smol::Timer;
use smol::future::FutureExt;
use smol::io;
use smol::io::AsyncReadExt;
use smol::io::AsyncWriteExt;
use smol::net::TcpStream;
const PROTOCOL_ID: u16 = 0;
const UNIT_ID: u8 = 0;
const READ_HOLDING_FUNCTION_CODE: u8 = 3;
const WRITE_HOLDING_FUNCTION_CODE: u8 = 16;
const READ_HOLDING_LENGTH: u16 = 6; // Unit ID 00 + Func Code 03 + Start Address XXXX + Quantity XXXX = 6 bytes
const WRITE_HOLDING_LENGTH_WITHOUT_DATA: u16 = 7; // Unit ID 00 + Func Code 16 + Start Address XXXX + Quantity XXXX + Num Data Bytes XX = 7 bytes
struct Packet {
buf: Vec<u8>,
}
impl Packet {
pub const fn new() -> Self {
Self { buf: Vec::new() }
}
pub fn add_u8(&mut self, x: u8) {
self.buf.push(x);
}
pub fn add_u16(&mut self, x: u16) {
let buf = x.to_be_bytes();
self.buf.push(buf[0]);
self.buf.push(buf[1]);
}
pub fn as_bytes(&self) -> &[u8] {
&self.buf
}
}
pub struct ModbusTcpDevice {
stream: TcpStream,
transactions: u16,
}
impl Debug for ModbusTcpDevice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.stream.peer_addr() {
Ok(addr) => write!(f, "ModbusTcpDevice({:?})", addr),
Err(_) => std::fmt::Result::Err(std::fmt::Error),
}
}
}
impl ModbusTcpDevice {
pub async fn new(addr: SocketAddr) -> Result<Self> {
let timeout = async {
Timer::after(Duration::from_millis(10)).await;
Err(io::Error::from(ErrorKind::TimedOut))
};
let stream = TcpStream::connect(addr)
.or(timeout)
.await
.context("Could not connect to modbus device!")?;
Ok(Self {
stream,
transactions: 0,
})
}
async fn read_and_check_responce_header(&mut self, func_code: u8) -> Result<()> {
assert_eq!(
self.read_u16().await?,
self.transactions,
"Modbus device sent unexpected transaction id!"
);
assert_eq!(
self.read_u16().await?,
PROTOCOL_ID,
"Modbus device sent unexpected protocol id!"
);
let length = self.read_u16().await?;
assert!(
length >= 2,
"Modbus device will send too few bytes to understand the response!"
);
assert_eq!(
self.read_u8().await?,
UNIT_ID,
"Modbus device sent unexpected unit id!"
);
let func_code_res = self.read_u8().await?;
if func_code != func_code_res {
let error_code = self.read_u8().await?;
bail!(
"Modbus device sent unexpected function code 0x{:x}! It probably encountered an error. Error code: 0x{:x}",
func_code_res,
error_code
);
}
Ok(())
}
async fn send_read_holding_request(&mut self, addr: u16, count: u16) -> Result<u16> {
let mut packet = Packet::new();
self.transactions += 1;
packet.add_u16(self.transactions);
packet.add_u16(PROTOCOL_ID);
packet.add_u16(READ_HOLDING_LENGTH);
packet.add_u8(UNIT_ID);
packet.add_u8(READ_HOLDING_FUNCTION_CODE);
packet.add_u16(addr);
packet.add_u16(count);
self.send(packet).await?;
self.read_and_check_responce_header(READ_HOLDING_FUNCTION_CODE)
.await?;
let num_data_bytes = self.read_u8().await? as u16;
assert_eq!(
count * 2,
num_data_bytes,
"Modbus device wants to send only a portion of the requested range - UNIMPLEMENTED!"
);
Ok(num_data_bytes)
}
pub async fn get_holding_registers(&mut self, addr: u16, count: u16) -> Result<Vec<u16>> {
self.send_read_holding_request(addr, count).await?;
let mut res: Vec<u16> = Vec::new();
while res.len() < count as usize {
res.push(self.read_u16().await?);
}
Ok(res)
}
pub async fn set_holding_registers(&mut self, addr: u16, values: &[u16]) -> Result<()> {
let count = values.len();
let num_bytes = count * 2;
assert!(
count <= 128,
"Cannot send that many bytes to modbus device in one go! Trying to send {} bytes of data.",
num_bytes
);
let mut packet = Packet::new();
self.transactions += 1;
packet.add_u16(self.transactions);
packet.add_u16(PROTOCOL_ID);
packet.add_u16(WRITE_HOLDING_LENGTH_WITHOUT_DATA + num_bytes as u16);
packet.add_u8(UNIT_ID);
packet.add_u8(WRITE_HOLDING_FUNCTION_CODE);
packet.add_u16(addr);
packet.add_u16(count as u16);
packet.add_u8(num_bytes as u8);
for value in values {
packet.add_u16(value.to_owned());
}
self.send(packet).await?;
self.read_and_check_responce_header(WRITE_HOLDING_FUNCTION_CODE)
.await?;
assert_eq!(
self.read_u16().await?,
addr,
"Modbus device wrote to wrong register address!"
);
assert_eq!(
self.read_u16().await?,
count as u16,
"Modbus device wrote wrong number of registers!"
);
Ok(())
}
pub async fn get_string<const N: usize>(&mut self, addr: u16) -> Result<String> {
assert!(N % 2 == 0, "Strings are always of even length!");
assert!(N <= 256, "Cannot read long strings in a single swoop!");
let count = N / 2;
self.send_read_holding_request(addr, count as u16).await?;
let buf = self.read_bytes::<N>().await?;
let s = String::from_utf8_lossy(&buf);
let s = match s.split_once('\0') {
None => s.to_string(),
Some((l, _)) => l.to_string(),
};
Ok(s)
}
pub async fn set_string<const N: usize>(&mut self, _addr: u16, s: &str) -> Result<()> {
assert!(s.len() <= N, "String is too long to fit!");
Ok(())
}
pub async fn get_u16(&mut self, addr: u16) -> Result<u16> {
let length = self.send_read_holding_request(addr, 1).await?;
assert_eq!(
length, 2,
"Expected modbus device to send exactly 2 bytes of data!"
);
self.read_u16().await
}
pub async fn get_u32(&mut self, addr: u16) -> Result<u32> {
let length = self.send_read_holding_request(addr, 2).await?;
assert_eq!(
length, 4,
"Expected modbus device to send exactly 4 bytes of data!"
);
let buf = self.read_bytes::<4>().await?;
Ok(u32::from_be_bytes(buf))
}
// We have to send the request in a single packet,
// otherwise the device may fail to respond correctly.
async fn send(&mut self, packet: Packet) -> Result<()> {
self.stream
.write_all(packet.as_bytes())
.await
.context("Could write to modbus device!")
}
async fn read_u8(&mut self) -> Result<u8> {
let buf = self.read_bytes::<1>().await?;
Ok(u8::from_be_bytes(buf))
}
async fn read_u16(&mut self) -> Result<u16> {
let buf = self.read_bytes::<2>().await?;
Ok(u16::from_be_bytes(buf))
}
async fn read_bytes<const N: usize>(&mut self) -> Result<[u8; N]> {
let mut buf = [0; N];
self.stream
.read_exact(&mut buf)
.await
.context("Could not read from modbus device!")?;
Ok(buf)
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

+9
View File
@@ -82,3 +82,12 @@ git push
# Recommended Editor Setup
We recommend you to use an Editor with rust-analyzer support like VSCode to speed up development and detect errors before compiling.
# Minimal Hardware Examples
To get started with actual hardware, check out these step-by-step tutorials:
- **[LED Control with EL2004](./minimal-example-el2004.md)** - Digital output control, the simplest possible hardware setup
- **[Analog Input with EL3021](./minimal-example-el3021.md)** - Reading analog current measurements
These examples provide complete hardware wiring diagrams and software setup instructions.
@@ -0,0 +1,224 @@
# Minimal Example — LED Control on the EL2004
A complete hardware + software walkthrough
---
## Table of Contents
1. [Introduction](#1-introduction)
2. [Requirements](#2-requirements)
3. [Hardware Setup](#3-hardware-setup)
4. [Software Setup](#4-software-setup)
5. [Demo](#5-demo)
6. [References](#6-references)
7. [Acknowledgements](#7-acknowledgements)
---
## 1. Introduction
The EL2004 LED Toggle Example is a minimal demonstration showing how to control digital outputs on a **Beckhoff EL2004 EtherCAT terminal** using the QiTech machine framework.
It represents the simplest possible hardware interaction in the system:
**toggling LED outputs using the QiTech Control Dashboard.**
---
## 2. Requirements
### Hardware
- Beckhoff **EL2004 EtherCAT Terminal** (4-channel digital output)
- Beckhoff **EK1100 EtherCAT Coupler**
- **24 V DC power supply** (AC/DC adapter + DC hollow plug)
- Jumper / bridge wires (0.5–1.5 mm² recommended)
- A **Linux PC** (Ubuntu/Debian recommended)
- Standard Ethernet cable
- Flat screwdriver
### Software
*(Installation steps in Section 4)*
- Rust toolchain
- Node.js + npm
- Git
- QiTech Control repository
- EtherCAT HAL (included inside repo)
---
## 3. Hardware Setup
### 3.1 Schematic
![](../assets/schematic.png)
---
### 3.2 EK1100 Wiring
This wiring configuration powers the EL2004 and prepares it for LED control.
It is not the only possible wiring but is the **simplest functional setup**.
#### ⚠️ Safety Warning
Always disconnect power before wiring.
Working on live EtherCAT terminals can cause serious damage or electrical shock.
---
#### 3.2.1 Safe Wiring Procedure (Beckhoff Recommended)
1. Insert a screwdriver **straight** into the square release hole.
2. Insert the stripped wire into the round opening.
3. Remove the screwdriver — the spring clamp locks the wire.
![](../assets/wiring.png)
---
#### 3.2.2 Wiring Steps (Used in This Example)
We supply power using a **DC hollow-plug adapter**, like this one:
https://www.amazon.de/dp/B093FTFZ8Q
Perform the following wiring on the EK1100:
1. Red wire **(+24 V)** → Terminal **2**
2. Black wire **(0 V)** → Terminal **3**
3. Jumper wire from **Terminal 1 → Terminal 6**
4. Jumper wire from **Terminal 5 → Terminal 7**
After wiring, your module should look like **Figure 1**.
---
#### **Figure 1 — EK1100 Minimal Wiring**
<img src="../assets/ek1100.jpeg" width="400">
---
### 3.3 EL2004 Integration
Slide the EL2004 onto the **right side of the EK1100** until it locks.
The EtherCAT E-Bus and power contacts connect automatically — **no wiring required**.
#### **Figure 2 — EL2004 Terminal**
<img src="../assets/el2004.jpeg" width="400">
---
### 3.4 Final Assembled Setup
#### **Figure 3 — EK1100 + EL2004 Connected**
<img src="../assets/complete.jpeg" width="400">
---
### 3.5 Power & Ethernet
#### Power
Connect the 24 V adapter to the hollow plug used earlier.
**Example AC/DC Adapter (Figure 4):**
<img src="../assets/adapter.jpeg" width="400">
#### Ethernet
Use a standard LAN cable to connect your PC → EK1100.
The final powered up and connected setup should look like this:
<img src="../assets/power.jpeg" width="400">
---
## 4. Software Setup
### 4.1 Installing on Ubuntu/Debian
Paste this into your terminal:
```bash
# Press Enter when prompted
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
sudo apt update
sudo apt install -y npm nodejs git
git clone git@github.com:qitechgmbh/control.git
cd control/electron
npm install
```
### 4.2 Running the Backend
```bash
./cargo_run_linux.sh
```
This script:
- Builds the backend
- Grants required system capabilities (raw sockets)
- Starts EtherCAT communication
Ensure the EK1100 is connected.
### 4.3 Running the Frontend
```bash
cd electron
npm run start
```
This launches the QiTech Control dashboard.
## 5. Demo
### 5.1 Assigning Devices in the Dashboard
Once the backend + frontend are running, you should see:
- EK1100 Coupler
- EL2004 Digital Output Terminal
![](../assets/discovery.png)
Steps:
- Click Assign on the EK1100
- Select TestMachine V1
![](../assets/setmachine.png)
- Enter a serial number (use the same for EK1100 + EL2004)
![](../assets/serserial.png)
- Click Write
- Repeat for the EL2004
### 5.2 Testing LED Control
Navigate to:
Machines → TestMachine
![](../assets/machinedetected.png)
You will see this interface:
![](../assets/machinecontrol.png)
**You can now toggle the four digital outputs of the EL2004.**
## 6. References
This guide incorporates information from official Beckhoff documentation.
All diagrams, product names, and figures belong to Beckhoff Automation GmbH & Co. KG and are used here solely for educational purposes.
Referenced Manuals
[Beckhoff EK1100 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/ek110x_ek15xx_en.pdf)
[Beckhoff EL2004 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/el20xx_el2124_de.pdf)
## 7. Acknowledgements
This tutorial is inspired by the clarity and educational quality of Beckhoff manuals.
All wiring illustrations and hardware descriptions in this guide are provided for demonstration purposes only and do not replace official Beckhoff installation guidelines.
Special thanks to the QiTech engineering team for providing the backend architecture, EtherCAT HAL abstraction, and the TestMachine framework that makes this example possible.
@@ -0,0 +1,178 @@
# Minimal Example — Analog Input EL3021
## Table of Contents
1. [Introduction](#1-introduction)
2. [Requirements](#2-requirements)
3. [Hardware Setup](#3-hardware-setup)
4. [Software Setup](#4-software-setup)
5. [Demo](#5-demo)
6. [References](#6-references)
## 1. Introduction
The EL3021 Analog Input example demonstrates how to measure analog current on a **Beckhoff EL3021 EtherCAT terminal** using the QiTech machine framework.
## 2. Requirements
### Hardware
- Beckhoff **EL3021 EtherCAT Terminal** (1-channel analog input)
- Beckhoff **EK1100 EtherCAT Coupler**
- **24 V DC power supply** (AC/DC adapter + DC hollow plug)
- **Adjustable DC power supply** (or any other means of producing 4-20mA DC @ <10V )
- Jumper / bridge wires (0.5–1.5 mm² recommended)
- A **Linux PC** (Ubuntu/Debian recommended)
- Standard Ethernet cable
- Flat screwdriver
## 3. Hardware Setup
### 3.1 Schematic
![](../assets/EL3021_schematic.png)
The 24V supply powers the EL2004, while the adjustable power supply generates the analog input signal for the EL3021.
#### ⚠️ Safety Warning
Always disconnect power before wiring.
Working on live EtherCAT terminals can cause serious damage or electrical shock.
### 3.2.1 Safe Wiring Procedure (Beckhoff Recommended)
1. Insert a screwdriver **straight** into the square release hole.
2. Insert the stripped wire into the round opening.
3. Remove the screwdriver — the spring clamp locks the wire.
![](../assets/wiring.png)
---
### 3.2.2 Wiring Steps (Used in This Example)
We supply power using a **DC hollow-plug adapter**, like this one:
https://www.amazon.de/dp/B093FTFZ8Q
Perform the following wiring on the EK1100:
1. Red wire **(+24 V)** → Terminal **2**
2. Black wire **(0 V)** → Terminal **3**
3. Jumper wire from **Terminal 1 → Terminal 6**
4. Jumper wire from **Terminal 5 → Terminal 7**
After wiring, your module should look like **Figure 1**.
---
#### **Figure 1 — EK1100 Minimal Wiring**
<img src="../assets/ek1100.jpeg" width="400">
---
### 3.3 EL3021 Integration
Slide the EL3021 onto the **right side of the EK1100** until it locks.
The EtherCAT E-Bus and power contacts connect automatically.
Then you need to wire the adjustable power supply to the EL3021.
#### **Figure 2 — EL3021 Terminal**
<img src="../assets/EL3021.jpg" width="400">
1. Wire ⊕ to **Terminal 1**
2. Wire ⊖ to **Terminal 2**
---
### 3.4 Final Assembled Setup
#### **Figure 3 — EK1100 + EL3021 Connected**
<img src="../assets/EK1100+EL3021.jpg" width="400">
---
## 3.5 Power & Ethernet
### 3.5.1 Power
Connect the 24 V adapter to the hollow plug used earlier.
#### Example AC/DC Adapter (Figure 4):
<img src="../assets/adapter.jpeg" width="400">
### 3.5.2 Ethernet
Use a standard LAN cable to connect your PC → EK1100.
The final powered up and connected setup should look like this:
<img src="../assets/EL3021_power+eth.jpg" width="400">
---
## 4. Software Setup
### 4.1 Installing on Ubuntu/Debian
Paste this into your terminal:
```bash
# Press Enter when prompted
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
sudo apt update
sudo apt install -y npm nodejs git
git clone git@github.com:qitechgmbh/control.git
cd control/electron
npm install
```
### 4.2 Running the Backend
```bash
./cargo_run_linux.sh
```
This script:
- Builds the backend
- Grants required system capabilities (raw sockets)
- Starts EtherCAT communication
Ensure the EK1100 is connected.
### 4.3 Running the Frontend
```bash
cd electron
npm run start
```
This launches the QiTech Control dashboard.
## 5. Demo
### 5.1 Assigning Devices in the Dashboard
Once both backend and frontend are running, the Setup -> EtherCat page should look something like this:
![](../assets/EL3021_overview.png)
Listing the EK1100 and the EL3021.
To assign the two devices to a machine, click "Assign" on both devices. This should open the following pop-up modal:
**EK1100**
![](../assets/EL3021_assign.png)
1. Select "AnalogTest V1" under "Machine"
2. Choose a serial number to uniquely identify the machine (1 in this example)
3. Under "Device Role" select "0 - Bus Coupler"
**EL3021**
![](../assets/EL3021_assign2.png)
1. Select "AnalogTest V1" under "Machine"
2. Use the same serial number you chose for the EK1100
3. Under "Device Role" select "1 - EL3021"
---
The machine is now configured.
A new Tab "AnalogTest" should appear in the right sidebar. Clicking that tab should show the machine control interface:
![](../assets/EL3021_control.png)
Results should show the current measured by the analog input - the current set on the adjustable power supply.
## 6. References
This guide incorporates information from official Beckhoff documentation.
All diagrams, product names, and figures belong to Beckhoff Automation GmbH & Co. KG and are used here solely for educational purposes.
Referenced Manuals
[Beckhoff EK1100 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/ek110x_ek15xx_en.pdf)
[Beckhoff EL3021 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/el30xx_de.pdf)
+40
View File
@@ -0,0 +1,40 @@
# Machine Documentation
This directory contains documentation for QiTech machines.
## Structure
```
docs/machines/
├── manuals/ # Machine operation manuals (markdown files)
│ ├── extruder.md
│ ├── winder.md
│ ├── laser.md
│ └── mock.md
├── extruder/ # Machine-specific documentation
├── winder/
├── laser/
└── mock/
```
## Images
All manual images are stored in: **`electron/public/images/manuals/[machine-name]/`**
For example:
- Winder images: `electron/public/images/manuals/winder/`
- Extruder images: `electron/public/images/manuals/extruder/`
- Laser images: `electron/public/images/manuals/laser/`
### Image References in Manuals
When referencing images in manual markdown files (located in `docs/machines/manuals/`), use relative paths:
```markdown
![Image description](../../../../electron/public/images/manuals/[machine-name]/image.png)
```
## Adding Images
1. Place images in: `electron/public/images/manuals/[machine-name]/`
2. Reference in markdown: `../../../../electron/public/images/manuals/[machine-name]/your-image.png`
@@ -1,4 +1,65 @@
![Titelseite,screenshot from Word doccument](images/manuals/extruder/Titelseite.jpeg)
![Titelseite,screenshot from Word doccument](/images/manuals/extruder/Titelseite.jpeg)
<!-- Datasheet: Added below Titelseite. Placeholder images to be replaced with final exports. -->
# QiTech Pro Extruder – Datasheet
![Datasheet Header](/images/manuals/extruder/datasheet/header.png)
## Included Assemblies
Electrical cabinet · Temperature controller · Frequency inverter · Cable routing + connection to QiTech Control (digital control)
## Motor
| Specification | Value |
| ------------- | ----- |
| Motor power | 1.5 kW |
| Output speed | 41 rpm |
| Output torque | 336 Nm |
| Transmission ratio | 34:2 |
| Service factor cG | 1.4 |
| Voltage / Frequency | 400 V / 50 Hz |
| Efficiency class | IE3 (Premium Efficiency) |
| Efficiency @ 100% rated load / % rated current (std. voltage) | 5.98 / 3.45 A |
| Protection class | IP55, Insulation class F |
| Construction design | Flange design, terminal box top |
| Shaft end (d×L) | 40 × 80 mm |
## Melt Pressure Sensor (DSI1-1/2-500-F-15)
| Specification | Value |
| ------------- | ----- |
| Output signal | 4–20 mA (2-conductor) |
| Sensor system | Mercury-free |
| Sensor | DMS Vollbrückentechnik |
| Measuring accuracy | 0.5 % |
| Calibration signal | 80 % |
| Supply voltage | 15–32 VDC |
| Measuring range | 0–500 bar |
| Temperature range (membrane) | max. 410 °C |
| Temperature range (sensor head) | max. 85 °C |
| Screw-in thread | 1/2"–20 UNF |
## Extruder Barrel & Screw
Nitrided extruder barrel (Ø 65 mm). Screw hardened from high‑alloyed steel (Ø 25 mm).
## Heating Elements & Temperature Sensors
3 heating zones (max. 350 °C), Heater 415 V, 3 × 750 W. 3× Type K temperature sensors, 1/8" NPT.
## Hopper
Volume: 4 L · Material: Aluminum · Easily detachable.
## Dimensions
![Dimensions](/images/manuals/extruder/datasheet/dimensions.png)
Overall length 1365 mm · Width 220 mm · Height 384 mm.
Fully wired and assembled (raised on 2 aluminum profiles).
Delivery time after completion: 2–5 working days.
Warranty: 24 months after delivery (excluding wearing parts).
<br>
<br>
@@ -55,7 +116,7 @@ Darmstadt, 10/06/2025
64392 Darmstadt,
<img src="images/manuals/extruder/signature.jpeg" width="300" height="60"/>
<img src="/images/manuals/extruder/signature.jpeg" width="300" height="60"/>
Milan von dem Bussche
@@ -101,7 +162,7 @@ Operating instructions
# 1 Machine description
![maschinenbeschreibung](images/manuals/extruder/description.jpeg)
![maschinenbeschreibung](/images/manuals/extruder/description.jpeg)
<br><br><br>
@@ -168,7 +229,7 @@ Accuracy class: 2
| Picture | Danger! |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ![Warning](images/manuals/extruder/w001.jpeg) | **Danger to life due to technically faulty machine.** <br> The machine may only be used when it is in perfect technical condition and in accordance with its intended use, in a safety-conscious and hazard-conscious manner and in compliance with these operating instructions. All faults, especially those that could impair safety, must be rectified immediately before starting the winder. Everyone who works on or with the Qitech Winder is responsible for observing these safety aspects! |
| ![Warning](/images/manuals/extruder/w001.jpeg) | **Danger to life due to technically faulty machine.** <br> The machine may only be used when it is in perfect technical condition and in accordance with its intended use, in a safety-conscious and hazard-conscious manner and in compliance with these operating instructions. All faults, especially those that could impair safety, must be rectified immediately before starting the winder. Everyone who works on or with the Qitech Winder is responsible for observing these safety aspects! |
<br><br><br>
@@ -176,17 +237,17 @@ Accuracy class: 2
| Pictogram | Description of pictogram | Pictogram | Pictogram Description |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ![w001](images/manuals/extruder/w001.jpeg) | W001 General warning sign The combination of the symbol W001 and the signal word **"Danger"** indicates an imminently hazardous situation which, if not avoided death or serious injury if it is not avoided. The combination of the symbol W001 and the signal word **"Warning"** indicates an imminently hazardous situation which, if not avoided, will result in death or serious injury. death or serious injury if it is not avoided. The combination of the symbol W001 and the signal word **"Caution"** indicates an imminently hazardous situation which, if not avoided death or serious injury if not avoided. | ![m001](images/manuals/extruder/m001.jpeg) | M001 General warning sign Mandatory sign This combination of symbol and signal word indicates a potentially hazardous situation which, if not avoided, may result in damage to property or the environment. not avoided. |
| ![w008](images/manuals/extruder/w008.jpeg) | W008 Warning of risk of falling | ![m003](images/manuals/extruder/m003.jpeg) | M003 Use hearing protection |
| ![w011](images/manuals/extruder/w011.jpeg) | W011 Warning of danger of slipping | ![m004](images/manuals/extruder/m004.jpeg) | M004 Use eye protection |
| ![w004](images/manuals/extruder/w004.jpeg) | W004 Warning of laser beam | ![m008](images/manuals/extruder/m008.jpeg) | M008 Use foot protection |
| ![w012](images/manuals/extruder/w012.jpeg) | W012 Warning of electrical voltage | ![m009](images/manuals/extruder/m009.jpeg) | M009 Use hand protection |
| ![w017](images/manuals/extruder/w017.jpeg) | W017 Warning of hot surface | ![m010](images/manuals/extruder/m010.jpeg) | M010 Use protective clothing |
| ![w018](images/manuals/extruder/w018.jpeg) | W018 Warning against automatic start-up | ![m012](images/manuals/extruder/m012.jpeg) | M012 Use handrail |
| ![w019](images/manuals/extruder/w019.jpeg) | W019 Warning of crushing hazard | ![m021](images/manuals/extruder/m021.jpeg) | M021 Switch off before maintenance or repair |
| ![w024](images/manuals/extruder/w024.jpeg) | W024 Warning against hand injuries | ![m014](images/manuals/extruder/m014.jpeg) | M014 Use head protection |
| ![w025](images/manuals/extruder/w025.jpeg) | W025 Warning against counter-rotating rollers | | |
| ![w016](images/manuals/extruder/w016.jpeg) | W016 Warning of toxic substances | | |
| ![w001](/images/manuals/extruder/w001.jpeg) | W001 General warning sign The combination of the symbol W001 and the signal word **"Danger"** indicates an imminently hazardous situation which, if not avoided death or serious injury if it is not avoided. The combination of the symbol W001 and the signal word **"Warning"** indicates an imminently hazardous situation which, if not avoided, will result in death or serious injury. death or serious injury if it is not avoided. The combination of the symbol W001 and the signal word **"Caution"** indicates an imminently hazardous situation which, if not avoided death or serious injury if not avoided. | ![m001](/images/manuals/extruder/m001.jpeg) | M001 General warning sign Mandatory sign This combination of symbol and signal word indicates a potentially hazardous situation which, if not avoided, may result in damage to property or the environment. not avoided. |
| ![w008](/images/manuals/extruder/w008.jpeg) | W008 Warning of risk of falling | ![m003](/images/manuals/extruder/m003.jpeg) | M003 Use hearing protection |
| ![w011](/images/manuals/extruder/w011.jpeg) | W011 Warning of danger of slipping | ![m004](/images/manuals/extruder/m004.jpeg) | M004 Use eye protection |
| ![w004](/images/manuals/extruder/w004.jpeg) | W004 Warning of laser beam | ![m008](/images/manuals/extruder/m008.jpeg) | M008 Use foot protection |
| ![w012](/images/manuals/extruder/w012.jpeg) | W012 Warning of electrical voltage | ![m009](/images/manuals/extruder/m009.jpeg) | M009 Use hand protection |
| ![w017](/images/manuals/extruder/w017.jpeg) | W017 Warning of hot surface | ![m010](/images/manuals/extruder/m010.jpeg) | M010 Use protective clothing |
| ![w018](/images/manuals/extruder/w018.jpeg) | W018 Warning against automatic start-up | ![m012](/images/manuals/extruder/m012.jpeg) | M012 Use handrail |
| ![w019](/images/manuals/extruder/w019.jpeg) | W019 Warning of crushing hazard | ![m021](/images/manuals/extruder/m021.jpeg) | M021 Switch off before maintenance or repair |
| ![w024](/images/manuals/extruder/w024.jpeg) | W024 Warning against hand injuries | ![m014](/images/manuals/extruder/m014.jpeg) | M014 Use head protection |
| ![w025](/images/manuals/extruder/w025.jpeg) | W025 Warning against counter-rotating rollers | | |
| ![w016](/images/manuals/extruder/w016.jpeg) | W016 Warning of toxic substances | | |
<br><br><br>
@@ -194,15 +255,15 @@ Accuracy class: 2
| Prohibition signs Pictogram | Pictogram description | International pictograms / other Pictogram | Pictogram Description |
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| <img src="images/manuals/extruder/p001.jpeg" width="158" height="158"/> | P001 General prohibition sign | <img src="images/manuals/extruder/f001.jpeg" width="158" height="158"> | F001 Fire extinguisher |
| <img src="images/manuals/extruder/p002.jpeg" width="158" height="158"/> | P002 No smoking | <img src="images/manuals/extruder/ANIS_danger.jpeg" width="232" height="60"> | ANSI Z535.3 - DANGER |
| <img src="images/manuals/extruder/p003.jpeg" width="158" height="158"/> | P003 No open flames; fire, open source of ignition and smoking prohibited | <img src="images/manuals/extruder/ANIS_warning.jpeg" width="232" height="60"> | ANSI Z535.3 - WARNING |
| <img src="images/manuals/extruder/p006.jpeg" width="158" height="158"/> | P006 Prohibited for industrial trucks | <img src="images/manuals/extruder/ANIS_caution.jpeg" width="232" height="60"> | ANSI Z535.3 - CAUTION |
| <img src="images/manuals/extruder/p009.jpeg" width="158" height="158"/> | P009 Climbing prohibited - In the sense of climbing prohibited for unauthorized persons | <img src="images/manuals/extruder/ANIS_notice.jpeg" width="232" height="60"> | ANSI Z535.3 - NOTICE |
| <img src="images/manuals/extruder/p022.jpeg" width="158" height="158"/> | P022 Eating and drinking prohibited | <img src="images/manuals/extruder/ANIS_safetyinstructions.jpeg" width="232" height="60"> | ANSI Z535.3 - SAFETY INSTRUCTIONS |
| <img src="images/manuals/extruder/p024.jpeg" width="158" height="158"/> | P024 Entering the area prohibited | <img src="images/manuals/extruder/trashcan.jpeg" width="158" height="158"> | Symbol for separate collection of waste electrical and electronic equipment |
| <img src="images/manuals/extruder/p031.jpeg" width="158" height="158"/> | P031 Switching prohibited | | |
| <img src="images/manuals/extruder/D-p006.jpeg" width="158" height="158"/> | D-P006 Access prohibited for unauthorized persons | | |
| <img src="/images/manuals/extruder/p001.jpeg" width="158" height="158"/> | P001 General prohibition sign | <img src="/images/manuals/extruder/f001.jpeg" width="158" height="158"> | F001 Fire extinguisher |
| <img src="/images/manuals/extruder/p002.jpeg" width="158" height="158"/> | P002 No smoking | <img src="/images/manuals/extruder/ANIS_danger.jpeg" width="232" height="60"> | ANSI Z535.3 - DANGER |
| <img src="/images/manuals/extruder/p003.jpeg" width="158" height="158"/> | P003 No open flames; fire, open source of ignition and smoking prohibited | <img src="/images/manuals/extruder/ANIS_warning.jpeg" width="232" height="60"> | ANSI Z535.3 - WARNING |
| <img src="/images/manuals/extruder/p006.jpeg" width="158" height="158"/> | P006 Prohibited for industrial trucks | <img src="/images/manuals/extruder/ANIS_caution.jpeg" width="232" height="60"> | ANSI Z535.3 - CAUTION |
| <img src="/images/manuals/extruder/p009.jpeg" width="158" height="158"/> | P009 Climbing prohibited - In the sense of climbing prohibited for unauthorized persons | <img src="/images/manuals/extruder/ANIS_notice.jpeg" width="232" height="60"> | ANSI Z535.3 - NOTICE |
| <img src="/images/manuals/extruder/p022.jpeg" width="158" height="158"/> | P022 Eating and drinking prohibited | <img src="/images/manuals/extruder/ANIS_safetyinstructions.jpeg" width="232" height="60"> | ANSI Z535.3 - SAFETY INSTRUCTIONS |
| <img src="/images/manuals/extruder/p024.jpeg" width="158" height="158"/> | P024 Entering the area prohibited | <img src="/images/manuals/extruder/trashcan.jpeg" width="158" height="158"> | Symbol for separate collection of waste electrical and electronic equipment |
| <img src="/images/manuals/extruder/p031.jpeg" width="158" height="158"/> | P031 Switching prohibited | | |
| <img src="/images/manuals/extruder/D-p006.jpeg" width="158" height="158"/> | D-P006 Access prohibited for unauthorized persons | | |
<br><br><br>
@@ -275,7 +336,7 @@ The machine has a main switch that disconnects/connects the mains supply. The ON
The emergency stop switch is used to quickly stop the machine in the event of danger. This is designed with redundant emergency stop contactors. An emergency stop switching device also prevents the machine from being switched on again after a power failure. By activating the emergency stop device, the entire machine is shut down and disconnected from the mains supply.
![emergency stop button / switch](images/manuals/extruder/emergency_stop.jpeg)
![emergency stop button / switch](/images/manuals/extruder/emergency_stop.jpeg)
Hazardous movements are brought to a controlled standstill as quickly as possible. The machine can only be restarted by physically resetting the emergency stop switch and then starting one of the operating modes in the software.
@@ -357,11 +418,11 @@ Depending on the activity or the duration of the activity, the operator must det
<br><br>
| Picture | Danger |
|---------|--------|
| ![Warning](images/manuals/extruder/w001.jpeg)|**Risk of injury due to improper transport** <br> Be careful when loading the machine, do not place any limbs under the parts when setting them down, do not stand under suspended loads or between moving loads and walls or machine parts. Wear personal protective equipment: safety shoes, protective work clothing, protective gloves.
| ![Warning](/images/manuals/extruder/w001.jpeg)|**Risk of injury due to improper transport** <br> Be careful when loading the machine, do not place any limbs under the parts when setting them down, do not stand under suspended loads or between moving loads and walls or machine parts. Wear personal protective equipment: safety shoes, protective work clothing, protective gloves.
| Picture | Caution! |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ![Warning](images/manuals/extruder/w001.jpeg) | **Danger to life due to incorrect installation and commissioning!** <brErrors during installation can lead to life-threatening situations or cause considerable damage to property. Installation and commissioning may only be carried out by the manufacturer's employees. |
| ![Warning](/images/manuals/extruder/w001.jpeg) | **Danger to life due to incorrect installation and commissioning!** <brErrors during installation can lead to life-threatening situations or cause considerable damage to property. Installation and commissioning may only be carried out by the manufacturer's employees. |
<br>
<br>
@@ -390,7 +451,7 @@ The CEE plug of the extruder must be plugged into a suitable three-phase socket
The machines are delivered with the newest version of the software installed. Updates, as well as previous updates and the software version can be viewed under Setup-> Updates. If a new version of the software is available, it will be displayed here. To perform the update an internet connection is required. In the next step, click the red "Select" button next to the chosen update. The display will now shut down, and when it is turned back on, the latest version of the software will be installed.
![screenshot updates](images/manuals/extruder/screenshot_updates.png)
![screenshot updates](/images/manuals/extruder/screenshot_updates.png)
<br>
@@ -431,7 +492,7 @@ Then start the machine as follows:
- Set the “target temperature” of the individual heating zones according to the melting material in the 3 input fields
- Set the “target temperature” of the nozzle
- After reaching the target temperature, wait 15 minutes until the machine has heated through and the plastic in the screw barrel has completely melted.
![Screenshot: set traget temperature](images/manuals/extruder/screenshot_adjust_heat.png)
![Screenshot: set traget temperature](/images/manuals/extruder/screenshot_adjust_heat.png)
<br>
@@ -443,7 +504,7 @@ Then start the machine as follows:
- Press the "Extrude" button in the “mode” box
- Wait until old melt has been "flushed" out of the extruder, i.e. no more discolouration of the filament is no longer discoloured and a constant material flow has been achieved
![screenshot: adjust screw speed](images/manuals/extruder/screenshot_extruder_adjustscrew_speed.png)
![screenshot: adjust screw speed](/images/manuals/extruder/screenshot_extruder_adjustscrew_speed.png)
<br>
@@ -471,7 +532,7 @@ Then start the machine as follows:
- Go to "Config" in the funktion bar
- To set the screw direction click on "Forward" or "Backward" in the "Invert settings" box
![Changing screrw direction](images/manuals/extruder/screenshot_config.png)
![Changing screrw direction](/images/manuals/extruder/screenshot_config.png)
<br>
@@ -495,7 +556,7 @@ Then start the machine as follows:
| Picture | DANGER! |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ![Warning](images/manuals/extruder/w001.jpeg) | **Danger to life due to operation of the machine in a faulty condition.** <br> If safety-relevant changes or defects occur on the machine, shut it down immediately, secure it against being switched on again and report the incident to the responsible office/person! |
| ![Warning](/images/manuals/extruder/w001.jpeg) | **Danger to life due to operation of the machine in a faulty condition.** <br> If safety-relevant changes or defects occur on the machine, shut it down immediately, secure it against being switched on again and report the incident to the responsible office/person! |
<br>
<br>
@@ -533,11 +594,11 @@ If further work is necessary, such as replacing the extruder screw, please conta
| Picture | Caution! |
| --------------------------------------------- | ----------------------------------------------------------------------------- |
| ![Warning](images/manuals/extruder/w001.jpeg) | **Observe the instructions for maintenance work when servicing the machine!** |
| ![Warning](/images/manuals/extruder/w001.jpeg) | **Observe the instructions for maintenance work when servicing the machine!** |
| Picture | Caution! |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| ![Warning](images/manuals/extruder/w001.jpeg) | **Risk of screw breakage if disassembled while hot!** <br> Screw connections may only be loosened when the system has cooled down. |
| ![Warning](/images/manuals/extruder/w001.jpeg) | **Risk of screw breakage if disassembled while hot!** <br> Screw connections may only be loosened when the system has cooled down. |
<br>
<br>
@@ -620,15 +681,15 @@ Appliances with this logo on the packaging or on the appliance must be disposed
| Picture | Danger! |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <img src="images/manuals/extruder/w001.jpeg" width="150" height="150"/> | **Danger of getting caught, pulled in or pulled along by moving parts of the machine** <br> Do not wear loose long hair, loose clothing or jewelry! There is always a risk of getting caught, pulled in or pulled along by moving parts! |
| <img src="images/manuals/extruder/w001.jpeg" width="150" height="150"/> | **Operation only permitted with protective devices fitted!** <br> The extruder may only be operated with properly fitted and functioning safety guards. The operator is responsible for ensuring that all protective devices are properly fitted. |
| <img src="images/manuals/extruder/w001.jpeg" width="150" height="150"/> | **Danger of cutting tensioned filament** <br> The stretched filament must not be touched between the puller and the spool. Danger of cutting! Do not wind any pointed, sharp-edged or particularly thin materials! |
| <img src="images/manuals/extruder/w012.jpeg" width="150" height="150"/> | **Electric shock** <br> Before carrying out any repair, set-up or maintenance work, the extruder must be de-energised by disconnecting the mains plug! Pull out the mains plug! |
| <img src="images/manuals/extruder/w012.jpeg" width="150" height="150"/> | **Electrostatic charges on the puller and spool** <br> Friction of the filament on the puller rollers and on the spool can lead to electrostatic charges. There is a risk of minor electric shocks! Do not touch the affected areas, but keep a sufficient distance! |
| <img src="images/manuals/extruder/w018.jpeg" width="150" height="150"/> | **Automatic speed adjustment of puller, reel and traverse** <br> The bobbin automatically adjusts its speed to keep the winding pressure constant. The traverse automatically adjusts its speed to the reel in order to maintain the same travel distance per revolution. |
| <img src="images/manuals/extruder/w025.jpeg" width="150" height="150"/> | **Feeding of body parts** <br> Extreme caution is required in the area of the puller and spool axle! There is an acute danger of getting caught or pulled in! Make sure you wear close-fitting clothing! Always keep the emergency stop switch within easy reach so that it can be activated immediately in an emergency. |
| <img src="images/manuals/extruder/w024.jpeg" width="150" height="150"/> | **Risk of crushing when threading the filament** <br> Exercise extreme caution when threading the filament through the puller rollers! There is a risk of crushing between the upper and lower pair of rollers. |
| <img src="images/manuals/extruder/w024.jpeg" width="150" height="150"/> | **Risk of crushing between the traversing unit and machine housing** <br> Exercise extreme caution when moving the traverser towards the housing! Do not place any limbs in the gap! |
| <img src="/images/manuals/extruder/w001.jpeg" width="150" height="150"/> | **Danger of getting caught, pulled in or pulled along by moving parts of the machine** <br> Do not wear loose long hair, loose clothing or jewelry! There is always a risk of getting caught, pulled in or pulled along by moving parts! |
| <img src="/images/manuals/extruder/w001.jpeg" width="150" height="150"/> | **Operation only permitted with protective devices fitted!** <br> The extruder may only be operated with properly fitted and functioning safety guards. The operator is responsible for ensuring that all protective devices are properly fitted. |
| <img src="/images/manuals/extruder/w001.jpeg" width="150" height="150"/> | **Danger of cutting tensioned filament** <br> The stretched filament must not be touched between the puller and the spool. Danger of cutting! Do not wind any pointed, sharp-edged or particularly thin materials! |
| <img src="/images/manuals/extruder/w012.jpeg" width="150" height="150"/> | **Electric shock** <br> Before carrying out any repair, set-up or maintenance work, the extruder must be de-energised by disconnecting the mains plug! Pull out the mains plug! |
| <img src="/images/manuals/extruder/w012.jpeg" width="150" height="150"/> | **Electrostatic charges on the puller and spool** <br> Friction of the filament on the puller rollers and on the spool can lead to electrostatic charges. There is a risk of minor electric shocks! Do not touch the affected areas, but keep a sufficient distance! |
| <img src="/images/manuals/extruder/w018.jpeg" width="150" height="150"/> | **Automatic speed adjustment of puller, reel and traverse** <br> The bobbin automatically adjusts its speed to keep the winding pressure constant. The traverse automatically adjusts its speed to the reel in order to maintain the same travel distance per revolution. |
| <img src="/images/manuals/extruder/w025.jpeg" width="150" height="150"/> | **Feeding of body parts** <br> Extreme caution is required in the area of the puller and spool axle! There is an acute danger of getting caught or pulled in! Make sure you wear close-fitting clothing! Always keep the emergency stop switch within easy reach so that it can be activated immediately in an emergency. |
| <img src="/images/manuals/extruder/w024.jpeg" width="150" height="150"/> | **Risk of crushing when threading the filament** <br> Exercise extreme caution when threading the filament through the puller rollers! There is a risk of crushing between the upper and lower pair of rollers. |
| <img src="/images/manuals/extruder/w024.jpeg" width="150" height="150"/> | **Risk of crushing between the traversing unit and machine housing** <br> Exercise extreme caution when moving the traverser towards the housing! Do not place any limbs in the gap! |
<br>
<br>
@@ -674,7 +735,7 @@ The following persons must not work on the machine:
| Picture | DANGER! |
| ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| ![Warning](images/manuals/extruder/w001.jpeg) | **Risk of injury due to insufficient qualification!** Improper handling can lead to considerable personal injury and damage to property. |
| ![Warning](/images/manuals/extruder/w001.jpeg) | **Risk of injury due to insufficient qualification!** Improper handling can lead to considerable personal injury and damage to property. |
| - Special activities may only be carried out by the persons specified in the respective chapters of these instructions. |
<br>
@@ -703,24 +764,24 @@ The following persons must not work on the machine:
## Electrical Diagrams
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 1](images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025-001.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 1](/images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025-001.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 2](images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_2-002.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 2](/images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_2-002.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 3](images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_3-003.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 3](/images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_3-003.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 4](images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_4-004.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 4](/images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_4-004.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 5](images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_5-005.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 5](/images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_5-005.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 6](images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_6-006.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 6](/images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_6-006.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 7](images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_7-007.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 7](/images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_7-007.png)
![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 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)
![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)
![QiTech Pro Extruder Nozzle 2025](/images/manuals/extruder/Nozzle.png)
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,88 @@
![Titelseite,screenshot from Word doccument](images/manuals/winder/Titelseite.jpeg)
![Titelseite,screenshot from Word doccument](/images/manuals/winder/Titelseite.jpeg)
<br>
# Data sheet: QiTech Pro Winder
![datasheet header illustration](/images/manuals/winder/datasheet/header.png)
Short description:
The QiTech Pro Winder is a machine for winding and rewinding 3D printing filaments and other strand materials. It is an improvement upon the proven QiTech Winder with support for multiple filament diameters, spools up to 5 kg, angular speeds up to 20 m/min and automatic adjustment of the winding speed depending on the filament tension.
It consists of a pulling unit, a tension arm, a filament guiding traverse and the spool axis. It is controlled by a computer using the QiTech Control software. The traverse movement ensures a clean wind of the filament on the spool. Automatic load adjustment allows for tight winding without having to manually readjust the winding speed each layer. A laser in the traverse allows for easy calibration of the spool width.
<br>
## Scope of delivery
| # | Item |
| - | ----------------------------------------- |
| 1 | QiTech Pro Winder |
| 2 | 2x Spool Carrier-Pins |
| 3 | 4x Weights for tension arm |
| 4 | 230V/110V to 24V Power Supply |
| 5 | USB C to USB A cable |
| 6 | USB Drive with QiTech Control software |
| 7 | Operating manual |
| 8 | optional: wheeled frame |
<br>
## Dimensions and Weight
![dimensions top view](/images/manuals/winder/datasheet/dimensions-top.png)
![dimensions front view](/images/manuals/winder/datasheet/gestell.png)
| | no frame | incl. frame |
| -------------- | -------- | ----------- |
| Weight | 20 kg | 35 kg |
| Length (1) | 91 cm | 91 cm |
| Width (2) | 50 cm | 50 cm |
| Height (3) | 42 cm | 134 cm |
<br>
## Requirements of the Spool
The QiTech Pro Winder is compatible with almost all spools. The spool is rotated by a Carrier-Pin and secured against lateral slipping by a quick release mechanism. The maximum spool weight is 5 kg.
![min/max spool dimensions](/images/manuals/winder/datasheet/spool-min-max.png)
<br>
## Winding Speed
How many meters of filament can be wound per minute at maximum speed depends on the inner diameter of the spool/how much filament has already been wound. The resulting path speed determines how much filament can be wound up in a given time. We take a usual spool with an inner diameter of 100 mm to calculate an expected maximum speed. If the inner diameter is larger, the maximum speed shifts upwards.
Winding Speed:
- Min.: 0.5 Meters/Minute
- Max.: 20 Meters/Minute
<br>
## Friction of the puller rolls
Since the diameter of the extruded filament depends primarily on the pulling speed of the Pro Winder, it is important that it remains constant. This is ensured on the one hand by the use of a stepper motor as drive. At the same time, a vulcanized PU coating of the rolls and a spring-increased contact pressure make the filament only slip through from approx. 30 N tension.
<br>
## Auto-Stop function
When extruding or rewinding, it is often the case that a specific amount of meters must be wound onto a spool. The QiTech Pro Winder has an integrated filament length measurement. For example, after 100 m, it can automatically switch from "wind" to "pull" mode. In addition, the operator is alerted acoustically that the spool has finished winding.
![auto stop ui placeholder](/images/manuals/winder/datasheet/autostop-ui.png)
<br>
## Electronics / Power consumption
| Specification | Value |
| --------------------- | -------------------------- |
| Mains connection | 230V/50Hz or 110V/60Hz |
| Ø Load in operation | 3A at 24V |
| Ø Consumption in use | 72 Wh |
<br>
@@ -55,7 +139,7 @@ Darmstadt, 10/06/2025
64392 Darmstadt,
<img src="images/manuals/winder/signature.jpeg" width="300" height="60"/>
<img src="/images/manuals/winder/signature.jpeg" width="300" height="60"/>
Milan von dem Bussche
@@ -101,7 +185,7 @@ Operating instructions
# 1 Machine description
![maschinenbeschreibung](images/manuals/winder/description.png)
![maschinenbeschreibung](/images/manuals/winder/description.png)
| Item number | Item number Description |
| ----------- | ----------------------------------------- |
@@ -186,7 +270,7 @@ Accuracy class: 2
| Picture | Danger! |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ![Warning](images/manuals/winder/w001.jpeg) | **Danger to life due to technically faulty machine.** <br> The machine may only be used when it is in perfect technical condition and in accordance with its intended use, in a safety-conscious and hazard-conscious manner and in compliance with these operating instructions. All faults, especially those that could impair safety, must be rectified immediately before starting the winder. Everyone who works on or with the Qitech Winder is responsible for observing these safety aspects! |
| ![Warning](/images/manuals/winder/w001.jpeg) | **Danger to life due to technically faulty machine.** <br> The machine may only be used when it is in perfect technical condition and in accordance with its intended use, in a safety-conscious and hazard-conscious manner and in compliance with these operating instructions. All faults, especially those that could impair safety, must be rectified immediately before starting the winder. Everyone who works on or with the Qitech Winder is responsible for observing these safety aspects! |
<br><br><br>
@@ -194,17 +278,17 @@ Accuracy class: 2
| Pictogram | Description of pictogram | Pictogram | Pictogram Description |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ![w001](images/manuals/winder/w001.jpeg) | W001 General warning sign The combination of the symbol W001 and the signal word **"Danger"** indicates an imminently hazardous situation which, if not avoided death or serious injury if it is not avoided. The combination of the symbol W001 and the signal word **"Warning"** indicates an imminently hazardous situation which, if not avoided, will result in death or serious injury. death or serious injury if it is not avoided. The combination of the symbol W001 and the signal word **"Caution"** indicates an imminently hazardous situation which, if not avoided death or serious injury if not avoided. | ![m001](images/manuals/winder/m001.jpeg) | M001 General warning sign Mandatory sign This combination of symbol and signal word indicates a potentially hazardous situation which, if not avoided, may result in damage to property or the environment. not avoided. |
| ![w008](images/manuals/winder/w008.jpeg) | W008 Warning of risk of falling | ![m003](images/manuals/winder/m003.jpeg) | M003 Use hearing protection |
| ![w011](images/manuals/winder/w011.jpeg) | W011 Warning of danger of slipping | ![m004](images/manuals/winder/m004.jpeg) | M004 Use eye protection |
| ![w004](images/manuals/winder/w004.jpeg) | W004 Warning of laser beam | ![m008](images/manuals/winder/m008.jpeg) | M008 Use foot protection |
| ![w012](images/manuals/winder/w012.jpeg) | W012 Warning of electrical voltage | ![m009](images/manuals/winder/m009.jpeg) | M009 Use hand protection |
| ![w017](images/manuals/winder/w017.jpeg) | W017 Warning of hot surface | ![m010](images/manuals/winder/m010.jpeg) | M010 Use protective clothing |
| ![w018](images/manuals/winder/w018.jpeg) | W018 Warning against automatic start-up | ![m012](images/manuals/winder/m012.jpeg) | M012 Use handrail |
| ![w019](images/manuals/winder/w019.jpeg) | W019 Warning of crushing hazard | ![m021](images/manuals/winder/m021.jpeg) | M021 Switch off before maintenance or repair |
| ![w024](images/manuals/winder/w024.jpeg) | W024 Warning against hand injuries | ![m014](images/manuals/winder/m014.jpeg) | M014 Use head protection |
| ![w025](images/manuals/winder/w025.jpeg) | W025 Warning against counter-rotating rollers | | |
| ![w016](images/manuals/winder/w016.jpeg) | W016 Warning of toxic substances | | |
| ![w001](/images/manuals/winder/w001.jpeg) | W001 General warning sign The combination of the symbol W001 and the signal word **"Danger"** indicates an imminently hazardous situation which, if not avoided death or serious injury if it is not avoided. The combination of the symbol W001 and the signal word **"Warning"** indicates an imminently hazardous situation which, if not avoided, will result in death or serious injury. death or serious injury if it is not avoided. The combination of the symbol W001 and the signal word **"Caution"** indicates an imminently hazardous situation which, if not avoided death or serious injury if not avoided. | ![m001](/images/manuals/winder/m001.jpeg) | M001 General warning sign Mandatory sign This combination of symbol and signal word indicates a potentially hazardous situation which, if not avoided, may result in damage to property or the environment. not avoided. |
| ![w008](/images/manuals/winder/w008.jpeg) | W008 Warning of risk of falling | ![m003](/images/manuals/winder/m003.jpeg) | M003 Use hearing protection |
| ![w011](/images/manuals/winder/w011.jpeg) | W011 Warning of danger of slipping | ![m004](/images/manuals/winder/m004.jpeg) | M004 Use eye protection |
| ![w004](/images/manuals/winder/w004.jpeg) | W004 Warning of laser beam | ![m008](/images/manuals/winder/m008.jpeg) | M008 Use foot protection |
| ![w012](/images/manuals/winder/w012.jpeg) | W012 Warning of electrical voltage | ![m009](/images/manuals/winder/m009.jpeg) | M009 Use hand protection |
| ![w017](/images/manuals/winder/w017.jpeg) | W017 Warning of hot surface | ![m010](/images/manuals/winder/m010.jpeg) | M010 Use protective clothing |
| ![w018](/images/manuals/winder/w018.jpeg) | W018 Warning against automatic start-up | ![m012](/images/manuals/winder/m012.jpeg) | M012 Use handrail |
| ![w019](/images/manuals/winder/w019.jpeg) | W019 Warning of crushing hazard | ![m021](/images/manuals/winder/m021.jpeg) | M021 Switch off before maintenance or repair |
| ![w024](/images/manuals/winder/w024.jpeg) | W024 Warning against hand injuries | ![m014](/images/manuals/winder/m014.jpeg) | M014 Use head protection |
| ![w025](/images/manuals/winder/w025.jpeg) | W025 Warning against counter-rotating rollers | | |
| ![w016](/images/manuals/winder/w016.jpeg) | W016 Warning of toxic substances | | |
<br><br><br>
@@ -212,15 +296,15 @@ Accuracy class: 2
| Prohibition signs Pictogram | Pictogram description | International pictograms / other Pictogram | Pictogram Description |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| <img src="images/manuals/winder/p001.jpeg" width="158" height="158"/> | P001 General prohibition sign | <img src="images/manuals/winder/f001.jpeg" width="158" height="158"> | F001 Fire extinguisher |
| <img src="images/manuals/winder/p002.jpeg" width="158" height="158"/> | P002 No smoking | <img src="images/manuals/winder/ANIS_danger.jpeg" width="232" height="60"> | ANSI Z535.3 - DANGER |
| <img src="images/manuals/winder/p003.jpeg" width="158" height="158"/> | P003 No open flames; fire, open source of ignition and smoking prohibited | <img src="images/manuals/winder/ANIS_warning.jpeg" width="232" height="60"> | ANSI Z535.3 - WARNING |
| <img src="images/manuals/winder/p006.jpeg" width="158" height="158"/> | P006 Prohibited for industrial trucks | <img src="images/manuals/winder/ANIS_caution.jpeg" width="232" height="60"> | ANSI Z535.3 - CAUTION |
| <img src="images/manuals/winder/p009.jpeg" width="158" height="158"/> | P009 Climbing prohibited - In the sense of climbing prohibited for unauthorized persons | <img src="images/manuals/winder/ANIS_notice.jpeg" width="232" height="60"> | ANSI Z535.3 - NOTICE |
| <img src="images/manuals/winder/p022.jpeg" width="158" height="158"/> | P022 Eating and drinking prohibited | <img src="images/manuals/winder/ANIS_safetyinstructions.jpeg" width="232" height="60"> | ANSI Z535.3 - SAFETY INSTRUCTIONS |
| <img src="images/manuals/winder/p024.jpeg" width="158" height="158"/> | P024 Entering the area prohibited | <img src="images/manuals/winder/trashcan.jpeg" width="158" height="158"> | Symbol for separate collection of waste electrical and electronic equipment |
| <img src="images/manuals/winder/p031.jpeg" width="158" height="158"/> | P031 Switching prohibited | | |
| <img src="images/manuals/winder/D-p006.jpeg" width="158" height="158"/> | D-P006 Access prohibited for unauthorized persons | | |
| <img src="/images/manuals/winder/p001.jpeg" width="158" height="158"/> | P001 General prohibition sign | <img src="/images/manuals/winder/f001.jpeg" width="158" height="158"> | F001 Fire extinguisher |
| <img src="/images/manuals/winder/p002.jpeg" width="158" height="158"/> | P002 No smoking | <img src="/images/manuals/winder/ANIS_danger.jpeg" width="232" height="60"> | ANSI Z535.3 - DANGER |
| <img src="/images/manuals/winder/p003.jpeg" width="158" height="158"/> | P003 No open flames; fire, open source of ignition and smoking prohibited | <img src="/images/manuals/winder/ANIS_warning.jpeg" width="232" height="60"> | ANSI Z535.3 - WARNING |
| <img src="/images/manuals/winder/p006.jpeg" width="158" height="158"/> | P006 Prohibited for industrial trucks | <img src="/images/manuals/winder/ANIS_caution.jpeg" width="232" height="60"> | ANSI Z535.3 - CAUTION |
| <img src="/images/manuals/winder/p009.jpeg" width="158" height="158"/> | P009 Climbing prohibited - In the sense of climbing prohibited for unauthorized persons | <img src="/images/manuals/winder/ANIS_notice.jpeg" width="232" height="60"> | ANSI Z535.3 - NOTICE |
| <img src="/images/manuals/winder/p022.jpeg" width="158" height="158"/> | P022 Eating and drinking prohibited | <img src="/images/manuals/winder/ANIS_safetyinstructions.jpeg" width="232" height="60"> | ANSI Z535.3 - SAFETY INSTRUCTIONS |
| <img src="/images/manuals/winder/p024.jpeg" width="158" height="158"/> | P024 Entering the area prohibited | <img src="/images/manuals/winder/trashcan.jpeg" width="158" height="158"> | Symbol for separate collection of waste electrical and electronic equipment |
| <img src="/images/manuals/winder/p031.jpeg" width="158" height="158"/> | P031 Switching prohibited | | |
| <img src="/images/manuals/winder/D-p006.jpeg" width="158" height="158"/> | D-P006 Access prohibited for unauthorized persons | | |
<br><br><br>
@@ -258,7 +342,7 @@ In addition, the following procedures must be regarded as misuse:
<br><br><br>
![spool sizes](images/manuals/winder/spools.jpeg)
![spool sizes](/images/manuals/winder/spools.jpeg)
<br><br><br>
@@ -286,7 +370,7 @@ Hazardous movements are brought to a controlled standstill as quickly as possibl
<br>
<img src="images/manuals/winder/emergency_stop.jpeg" width="300" height="300"/>
<img src="/images/manuals/winder/emergency_stop.jpeg" width="300" height="300"/>
<br>
@@ -343,8 +427,8 @@ Depending on the activity or the duration of the activity, the operator must det
<br><br>
| Picture | Danger |
|---------|--------|
| <img src="images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Risk of injury due to improper transport** <br> Be careful when loading the machine, do not place any limbs under the parts when setting them down, do not stand under suspended loads or between moving loads and walls or machine parts. Wear personal protective equipment: safety shoes, protective work clothing, protective gloves. |
| <img src="images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Danger to life due to incorrect installation and commissioning!** <br> Errors during installation can lead to life-threatening situations or cause considerable damage to property. Installation and commissioning may only be carried out by the manufacturer's employees. |
| <img src="/images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Risk of injury due to improper transport** <br> Be careful when loading the machine, do not place any limbs under the parts when setting them down, do not stand under suspended loads or between moving loads and walls or machine parts. Wear personal protective equipment: safety shoes, protective work clothing, protective gloves. |
| <img src="/images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Danger to life due to incorrect installation and commissioning!** <br> Errors during installation can lead to life-threatening situations or cause considerable damage to property. Installation and commissioning may only be carried out by the manufacturer's employees. |
<br><br>
@@ -364,7 +448,7 @@ The machines are delivered with the newest version of the software installed. Up
<br>
![screenshot of update tab](images/manuals/winder/screenshot_updates.png)
![screenshot of update tab](/images/manuals/winder/screenshot_updates.png)
<br>
@@ -412,7 +496,7 @@ Then start the machine as follows:
<br>
![screenshot outer limit](images/manuals/winder/screenshot_traverse.png)
![screenshot outer limit](/images/manuals/winder/screenshot_traverse.png)
<br>
@@ -435,7 +519,7 @@ The **padding** parameter creates a safety margin that prevents the filament fro
The default padding is 0.88mm, but you can adjust this value in the Control settings based on your filament diameter and winding requirements. Larger filaments may require more padding, while smaller filaments can use less padding for maximum spool utilization.
![Padding example](images/manuals/winder/winder_padding_example_image.jpg)
![Padding example](/images/manuals/winder/winder_padding_example_image.jpg)
<br>
@@ -448,7 +532,7 @@ The default padding is 0.88mm, but you can adjust this value in the Control sett
<br>
![screernshot target speed](images/manuals/winder/screenshot_targetspeed.png)
![screernshot target speed](/images/manuals/winder/screenshot_targetspeed.png)
<br>
@@ -514,7 +598,7 @@ All settings such as the position of the spool sides, the winding speed or the f
| Picture | Danger! |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <img src="images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Danger to life due to operation of the machine in faulty condition** <br> If safety-relevant changes or defects occur on the machine, shut down immediately, secure against restarting and report the process to the responsible office/person! |
| <img src="/images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Danger to life due to operation of the machine in faulty condition** <br> If safety-relevant changes or defects occur on the machine, shut down immediately, secure against restarting and report the process to the responsible office/person! |
<br><br>
@@ -558,14 +642,14 @@ The polyurethane coating of the puller rollers will inevitably wear out over tim
Now remove the black plastic caps and simply pull out the puller rollers. To install the new rollers back on, slide them on the axes, put the plastic cap back on and screw the screws.
<img src="images/manuals/winder/swapping_out_rollers.jpeg" width="300" height="400"/>
<img src="/images/manuals/winder/swapping_out_rollers.jpeg" width="300" height="400"/>
<br>
<br>
| Picture | Caution! |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| <img src="images/manuals/winder/w001.jpeg" width="150" height="150"/> | Observe the instructions for maintenance work when servicing the machine! |
| <img src="/images/manuals/winder/w001.jpeg" width="150" height="150"/> | Observe the instructions for maintenance work when servicing the machine! |
<br>
<br>
@@ -630,7 +714,7 @@ The machine consists largely of aluminum and plastic and can be easily separated
| Picture | Disposal Instructions |
| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <img src="images/manuals/winder/trashcan.jpeg" width="150" height="150"/> | Appliances with this logo on the packaging or on the appliance must be disposed of separately. These devices must not be disposed of with normal household waste. You are responsible for ensuring that any electrical or electronic waste is disposed of at the appropriate locations, e.g., recycling centers. |
| <img src="/images/manuals/winder/trashcan.jpeg" width="150" height="150"/> | Appliances with this logo on the packaging or on the appliance must be disposed of separately. These devices must not be disposed of with normal household waste. You are responsible for ensuring that any electrical or electronic waste is disposed of at the appropriate locations, e.g., recycling centers. |
<br><br><br>
@@ -638,15 +722,15 @@ The machine consists largely of aluminum and plastic and can be easily separated
| Picture | Danger! |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <img src="images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Danger of getting caught, pulled in or pulled along by moving parts of the machine** <br> Do not wear loose long hair, loose clothing or jewelry! There is always a risk of getting caught, pulled in or pulled along by moving parts! |
| <img src="images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Operation only permitted with protective devices fitted!** <br> The winder may only be operated with properly fitted and functioning safety guards. The operator is responsible for ensuring that all protective devices are properly fitted. |
| <img src="images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Danger of cutting tensioned filament** <br> The stretched filament must not be touched between the puller and the spool. Danger of cutting! Do not wind any pointed, sharp-edged or particularly thin materials! |
| <img src="images/manuals/winder/w012.jpeg" width="150" height="150"/> | **Electric shock** <br> Before carrying out any repair, set-up or maintenance work, the winder must be de-energised by disconnecting the mains plug! Pull out the mains plug! |
| <img src="images/manuals/winder/w012.jpeg" width="150" height="150"/> | **Electrostatic charges on the puller and spool** <br> Friction of the filament on the puller rollers and on the spool can lead to electrostatic charges. There is a risk of minor electric shocks! Do not touch the affected areas, but keep a sufficient distance! |
| <img src="images/manuals/winder/w018.jpeg" width="150" height="150"/> | **Automatic speed adjustment of puller, reel and traverse** <br> The bobbin automatically adjusts its speed to keep the winding pressure constant. The traverse automatically adjusts its speed to the reel in order to maintain the same travel distance per revolution. |
| <img src="images/manuals/winder/w025.jpeg" width="150" height="150"/> | **Feeding of body parts** <br> Extreme caution is required in the area of the puller and spool axle! There is an acute danger of getting caught or pulled in! Make sure you wear close-fitting clothing! Always keep the emergency stop switch within easy reach so that it can be activated immediately in an emergency. |
| <img src="images/manuals/winder/w024.jpeg" width="150" height="150"/> | **Risk of crushing when threading the filament** <br> Exercise extreme caution when threading the filament through the puller rollers! There is a risk of crushing between the upper and lower pair of rollers. |
| <img src="images/manuals/winder/w024.jpeg" width="150" height="150"/> | **Risk of crushing between the traversing unit and machine housing** <br> Exercise extreme caution when moving the traverser towards the housing! Do not place any limbs in the gap! |
| <img src="/images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Danger of getting caught, pulled in or pulled along by moving parts of the machine** <br> Do not wear loose long hair, loose clothing or jewelry! There is always a risk of getting caught, pulled in or pulled along by moving parts! |
| <img src="/images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Operation only permitted with protective devices fitted!** <br> The winder may only be operated with properly fitted and functioning safety guards. The operator is responsible for ensuring that all protective devices are properly fitted. |
| <img src="/images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Danger of cutting tensioned filament** <br> The stretched filament must not be touched between the puller and the spool. Danger of cutting! Do not wind any pointed, sharp-edged or particularly thin materials! |
| <img src="/images/manuals/winder/w012.jpeg" width="150" height="150"/> | **Electric shock** <br> Before carrying out any repair, set-up or maintenance work, the winder must be de-energised by disconnecting the mains plug! Pull out the mains plug! |
| <img src="/images/manuals/winder/w012.jpeg" width="150" height="150"/> | **Electrostatic charges on the puller and spool** <br> Friction of the filament on the puller rollers and on the spool can lead to electrostatic charges. There is a risk of minor electric shocks! Do not touch the affected areas, but keep a sufficient distance! |
| <img src="/images/manuals/winder/w018.jpeg" width="150" height="150"/> | **Automatic speed adjustment of puller, reel and traverse** <br> The bobbin automatically adjusts its speed to keep the winding pressure constant. The traverse automatically adjusts its speed to the reel in order to maintain the same travel distance per revolution. |
| <img src="/images/manuals/winder/w025.jpeg" width="150" height="150"/> | **Feeding of body parts** <br> Extreme caution is required in the area of the puller and spool axle! There is an acute danger of getting caught or pulled in! Make sure you wear close-fitting clothing! Always keep the emergency stop switch within easy reach so that it can be activated immediately in an emergency. |
| <img src="/images/manuals/winder/w024.jpeg" width="150" height="150"/> | **Risk of crushing when threading the filament** <br> Exercise extreme caution when threading the filament through the puller rollers! There is a risk of crushing between the upper and lower pair of rollers. |
| <img src="/images/manuals/winder/w024.jpeg" width="150" height="150"/> | **Risk of crushing between the traversing unit and machine housing** <br> Exercise extreme caution when moving the traverser towards the housing! Do not place any limbs in the gap! |
# 4 Glossary and Definitions
@@ -687,7 +771,7 @@ The following persons may not work on the machine:
| Picture | Danger! |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <img src="images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Risk of injury if insufficiently qualified!** <br> Improper handling can lead to considerable personal injury and damage to property. Special activities may only be carried out by the persons specified in the relevant sections of these instructions. |
| <img src="/images/manuals/winder/w001.jpeg" width="150" height="150"/> | **Risk of injury if insufficiently qualified!** <br> Improper handling can lead to considerable personal injury and damage to property. Special activities may only be carried out by the persons specified in the relevant sections of these instructions. |
<br>
<br>
@@ -716,12 +800,12 @@ The following persons may not work on the machine:
## Electrical Diagrams
![QiTech Pro Winder Electrical Diagram - Sheet 1](images/manuals/winder/QiTech_Pro_Winder_Electrical_Diagram-001.png)
![QiTech Pro Winder Electrical Diagram - Sheet 1](/images/manuals/winder/QiTech_Pro_Winder_Electrical_Diagram-001.png)
![QiTech Pro Winder Electrical Diagram - Sheet 2](images/manuals/winder/QiTech_Pro_Winder_Electrical_Diagram_2-002.png)
![QiTech Pro Winder Electrical Diagram - Sheet 2](/images/manuals/winder/QiTech_Pro_Winder_Electrical_Diagram_2-002.png)
![QiTech Pro Winder Electrical Diagram - Sheet 3](images/manuals/winder/QiTech_Pro_Winder_Electrical_Diagram_3-003.png)
![QiTech Pro Winder Electrical Diagram - Sheet 3](/images/manuals/winder/QiTech_Pro_Winder_Electrical_Diagram_3-003.png)
![QiTech Pro Winder Electrical Diagram - Sheet 4](images/manuals/winder/QiTech_Pro_Winder_Electrical_Diagram_4-004.png)
![QiTech Pro Winder Electrical Diagram - Sheet 4](/images/manuals/winder/QiTech_Pro_Winder_Electrical_Diagram_4-004.png)
![QiTech Pro Winder Electrical Diagram - Sheet 5](images/manuals/winder/QiTech_Pro_Winder_Electrical_Diagram_5-005.png)
![QiTech Pro Winder Electrical Diagram - Sheet 5](/images/manuals/winder/QiTech_Pro_Winder_Electrical_Diagram_5-005.png)
+1 -1
View File
@@ -73,7 +73,7 @@ typings/
# vuepress build output
.vuepress/dist
# Serverless directories
# Serverless directoriesProcessMetrics
.serverless/
# FuseBox cache
+3838 -1747
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

+7 -1
View File
@@ -9,6 +9,7 @@ import { RouterProvider } from "@tanstack/react-router";
import { Toaster } from "./components/ui/sonner";
import { enableMapSet } from "immer";
import { useGlobalLogStreaming } from "./hooks/useGlobalLogStreaming";
import { GlobalLaserToastManager } from "./setup/GlobalLaserToastManager";
export default function App() {
const { i18n } = useTranslation();
@@ -21,7 +22,12 @@ export default function App() {
updateAppLanguage(i18n);
}, [i18n]);
return <RouterProvider router={router} />;
return (
<>
<GlobalLaserToastManager />
<RouterProvider router={router} />
</>
);
}
// enable immer MapSet plugin
@@ -0,0 +1,67 @@
import React from "react";
import { Markdown } from "./Markdown";
type ManualMarkdownProps = {
/**
* The markdown content to render
*/
markdownContent: string;
};
/**
* A specialized Markdown component for rendering machine manuals.
*
* This component transforms image paths in markdown to work correctly in the Electron app.
* Manual markdown files are stored in `docs/machines/manuals/` with relative image paths
* pointing to `../../../../electron/public/images/manuals/[machine-name]/...`
*
* This component transforms those paths to absolute paths `/images/manuals/[machine-name]/...`
* that work correctly in the Electron app's context.
*
* Path transformation examples:
* - `../../../../electron/public/images/manuals/winder/image.png` → `/images/manuals/winder/image.png`
* - `images/manuals/winder/image.png` → `/images/manuals/winder/image.png`
*/
export function ManualMarkdown({ markdownContent }: ManualMarkdownProps) {
const transformedContent = React.useMemo(() => {
// Transform image paths to work in Electron context
// Pattern 1: ../../../../electron/public/images/manuals/... → /images/manuals/...
let content = markdownContent.replace(
/!\[([^\]]*)\]\(\.\.\/\.\.\/\.\.\/\.\.\/electron\/public\/(images\/manuals\/[^)]+)\)/g,
"![$1](/$2)",
);
// Pattern 2: ../../../electron/public/images/manuals/... → /images/manuals/... (backward compatibility)
content = content.replace(
/!\[([^\]]*)\]\(\.\.\/\.\.\/\.\.\/electron\/public\/(images\/manuals\/[^)]+)\)/g,
"![$1](/$2)",
);
// Pattern 3: images/manuals/... → /images/manuals/... (for backward compatibility)
content = content.replace(
/!\[([^\]]*)\]\(images\/manuals\/([^)]+)\)/g,
"![$1](/images/manuals/$2)",
);
// Pattern 4: Handle <img> tags with src attribute (new path)
content = content.replace(
/<img\s+([^>]*)src="\.\.\/\.\.\/\.\.\/\.\.\/electron\/public\/(images\/manuals\/[^"]+)"/g,
'<img $1src="/$2"',
);
// Pattern 5: Handle <img> tags with src attribute (old path)
content = content.replace(
/<img\s+([^>]*)src="\.\.\/\.\.\/\.\.\/electron\/public\/(images\/manuals\/[^"]+)"/g,
'<img $1src="/$2"',
);
content = content.replace(
/<img\s+([^>]*)src="images\/manuals\/([^"]+)"/g,
'<img $1src="/images/manuals/$2"',
);
return content;
}, [markdownContent]);
return <Markdown text={transformedContent} />;
}
+14 -1
View File
@@ -1,5 +1,6 @@
import React from "react";
import { Markdown } from "./Markdown";
import { ManualMarkdown } from "./ManualMarkdown";
import { MarkdownToc } from "./MarkdownToc";
type MarkdownWithTocProps = {
@@ -26,6 +27,11 @@ type MarkdownWithTocProps = {
* @default "top-4"
*/
stickyTop?: string;
/**
* Whether this is a machine manual (enables image path transformation)
* @default false
*/
isManual?: boolean;
};
/**
@@ -44,12 +50,19 @@ export function MarkdownWithToc({
tocWidth = "w-64",
gap = "gap-6",
stickyTop = "top-6",
isManual = false,
}: MarkdownWithTocProps) {
const MarkdownComponent = isManual ? ManualMarkdown : Markdown;
return (
<div className={`flex ${gap}`}>
{/* Main Content */}
<div className="min-w-0 flex-1">
<Markdown text={markdownContent} />
{isManual ? (
<ManualMarkdown markdownContent={markdownContent} />
) : (
<Markdown text={markdownContent} />
)}
</div>
{/* Table of Contents Sidebar */}
+48 -2
View File
@@ -25,11 +25,12 @@ export function getUnitIcon(unit: Unit): IconName {
return "lu:Gauge";
case "mHz":
return "lu:AudioWaveform";
case "Hz":
return "lu:AudioWaveform";
case "W":
return "lu:Zap";
case "V":
return "lu:Zap";
case "A":
case "mA":
return "lu:Zap";
case "kWh":
return "lu:BatteryFull";
@@ -37,6 +38,16 @@ export function getUnitIcon(unit: Unit): IconName {
return "lu:Waves";
case "%":
return "lu:ChartNoAxesColumn";
case "µs":
return "lu:RefreshCcw";
case "s":
return "lu:Clock";
case "MiB":
return "lu:MemoryStick";
case "Mbit/s":
return "lu:Network";
case "/s":
return "lu:Gauge";
default:
return "lu:ChartNoAxesColumn";
}
@@ -64,10 +75,14 @@ export function renderUnitSymbol(unit: Unit | undefined): string {
return "m/min";
case "mHz":
return "mHz";
case "Hz":
return "Hz";
case "W":
return "W";
case "V":
return "V";
case "mA":
return "mA";
case "A":
return "A";
case "kWh":
@@ -76,6 +91,16 @@ export function renderUnitSymbol(unit: Unit | undefined): string {
return "l/min";
case "%":
return "%";
case "µs":
return "µs";
case "s":
return "s";
case "MiB":
return "MiB";
case "Mbit/s":
return "Mbit/s";
case "/s":
return "/s";
default:
return "";
}
@@ -125,10 +150,14 @@ export function renderUnitSymbolLong(unit: Unit): string {
return "meters/minute";
case "mHz":
return "millihertz";
case "Hz":
return "hertz";
case "W":
return "watts";
case "V":
return "volts";
case "mA":
return "milliamperes";
case "A":
return "amperes";
case "kWh":
@@ -137,6 +166,16 @@ export function renderUnitSymbolLong(unit: Unit): string {
return "liters/minute";
case "%":
return "percent";
case "µs":
return "microseconds";
case "s":
return "seconds";
case "MiB":
return "mebibytes";
case "Mbit/s":
return "megabits/second";
case "/s":
return "per second";
default:
return "";
}
@@ -153,12 +192,19 @@ export const units = [
"bar",
"m/min",
"mHz",
"Hz",
"W",
"V",
"mA",
"A",
"kWh",
"l/min",
"%",
"µs",
"s",
"MiB",
"Mbit/s",
"/s",
] as const;
export type Unit = (typeof units)[number];
@@ -0,0 +1,59 @@
import { MiniGraph } from "@/components/graph/MiniGraph";
import { Page } from "@/components/Page";
import { ControlCard } from "@/control/ControlCard";
import { ControlGrid } from "@/control/ControlGrid";
import React from "react";
import { useAnalogInputTestMachine } from "./useAnalogInputTestMachine";
export function AnalogInputTestMachineControl(): React.JSX.Element {
const { seriesData, state, updateMeasurementRate } =
useAnalogInputTestMachine();
if (!seriesData) return <div>Initializing Sensor...</div>;
return (
<Page>
<ControlGrid columns={2}>
<ControlCard title="Measurement Control">
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
}}
>
<div>Measurement Rate (Hz)</div>
<input
type="number"
style={{
border: "2px solid transparent",
borderRadius: "4px",
padding: "3px",
}}
className={"bg-neutral-100 shadow"}
value={state.measurementRateHz}
onChange={(v) => {
const val = Number(v.currentTarget.value);
updateMeasurementRate(val);
}}
min={1}
></input>
</div>
</ControlCard>
<ControlCard title="Results">
<div className="flex flex-row">
<MiniGraph
newData={seriesData}
width={500}
renderValue={(v) => v.toFixed(2)}
></MiniGraph>
<div className="flex flex-col justify-center">
{seriesData.current?.value.toFixed(2)}mA
</div>
</div>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,21 @@
import { Topbar } from "@/components/Topbar";
import { analogInputTestMachineSerialRoute } from "@/routes/routes";
import React from "react";
export function AnalogInputTestMachine(): React.JSX.Element {
const { serial } = analogInputTestMachineSerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/analoginputtestmachine/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:CirclePlay",
},
]}
/>
);
}
@@ -0,0 +1,107 @@
import { useMachineMutate } from "@/client/useClient";
import {
createTimeSeries,
TimeSeries,
TimeSeriesValue,
TimeSeriesWithInsert,
} from "@/lib/timeseries";
import { useEffect, useMemo, useState } from "react";
import z from "zod";
import {
StateEvent,
useAnalogInputTestMachineNamespace,
} from "./useAnalogInputTestMachineNamespace";
import { toastError } from "@/components/Toast";
import { MachineIdentificationUnique } from "../types";
import { analogInputTestMachineSerialRoute } from "@/routes/routes";
import { analogInputTestMachine } from "../properties";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { produce } from "immer";
export function useAnalogInputTestMachine() {
const { serial: serialString } =
analogInputTestMachineSerialRoute.useParams();
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
const serial = parseInt(serialString);
if (isNaN(serial)) {
toastError(
"Invalid Serial Number",
`"${serialString}" is not a valid serial number.`,
);
return {
machine_identification: { vendor: 0, machine: 0 },
serial: 0,
};
}
return {
machine_identification: analogInputTestMachine.machine_identification,
serial,
};
}, [serialString]);
const state = useAnalogInputTestMachineNamespace({
type: "machine",
machine_identification_unique: machineIdentification,
});
const mutateMachine = useMachineMutate(
z.object({
measurement_rate_hz: z.number(),
}),
);
const stateOptimistic = useStateOptimistic<StateEvent>();
const updateStateOptimistically = (
producer: (current: StateEvent) => void,
serverRequest?: () => void,
) => {
const currentState = stateOptimistic.value;
if (currentState)
stateOptimistic.setOptimistic(produce(currentState, producer));
serverRequest?.();
};
const updateMeasurementRate = (val: number) => {
updateStateOptimistically(
(current) => {
current.measurementRateHz = val;
},
() => {
mutateMachine.request({
machine_identification_unique: machineIdentification,
data: { measurement_rate_hz: val },
});
},
);
};
const timeSeries: TimeSeriesWithInsert = createTimeSeries(
20,
1000,
5000,
60 * 60 * 1000,
);
const [seriesData, setSeriesData] = useState<TimeSeries | null>(
timeSeries.initialTimeSeries,
);
useEffect(() => {
const measurement = state.currentMeasurement;
if (measurement && seriesData) {
const dataPoint: TimeSeriesValue = {
value: measurement[0] * 1000,
timestamp: Number(measurement[1]),
};
setSeriesData(timeSeries.insert(seriesData, dataPoint));
}
}, [state]);
return {
seriesData,
state,
updateMeasurementRate,
};
}
@@ -0,0 +1,67 @@
import {
createNamespaceHookImplementation,
EventHandler,
eventSchema,
} from "@/client/socketioStore";
import z from "zod";
import { create, StoreApi } from "zustand";
// ========== Event Schema ==========
export const stateEventDataSchema = z.object({
measurementRateHz: z.number().optional(),
currentMeasurement: z.tuple([z.number(), z.string()]).optional(),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
// ========== Store ==========
export type AnalogInputTestMachineNamespaceStore = z.infer<
typeof stateEventDataSchema
>;
const createMachineStore = (): StoreApi<AnalogInputTestMachineNamespaceStore> =>
create<AnalogInputTestMachineNamespaceStore>(() => ({
measurementRateHz: 1,
currentMeasurement: undefined,
}));
// ========== Message Handler ==========
function analogInputTestMachineMessageHandler(
store: StoreApi<AnalogInputTestMachineNamespaceStore>,
): EventHandler {
return (event) => {
const oldState = store.getState();
const newMeasurementDataHz = event.data["MeasurementRateHz"];
const newMeasurement: [number, string] = event.data["Measurement"];
switch (event.name) {
case "MeasurementRateHz":
if (
newMeasurementDataHz &&
newMeasurementDataHz !== oldState.measurementRateHz
)
store.setState({
...oldState,
measurementRateHz: newMeasurementDataHz,
});
break;
case "Measurement":
if (newMeasurement && newMeasurement !== oldState.currentMeasurement)
store.setState({
...oldState,
currentMeasurement: newMeasurement,
});
break;
}
};
}
// ========== Namespace Hook ==========
export const useAnalogInputTestMachineNamespace =
createNamespaceHookImplementation({
createStore: () => createMachineStore(),
createEventHandler: analogInputTestMachineMessageHandler,
});
@@ -39,7 +39,7 @@ export function Aquapath1ControlPage() {
<div className="grid grid-cols-2 gap-6">
<div className="flex flex-col gap-8">
<TimeSeriesValueNumeric
label="Front Flow"
label="Flow 1"
unit="l/min"
timeseries={front_flow}
renderValue={(value) => value.toFixed(1)}
@@ -48,7 +48,7 @@ export function Aquapath1ControlPage() {
<div className="flex flex-col gap-8">
<TimeSeriesValueNumeric
label="Back Flow"
label="Flow 2"
unit="l/min"
timeseries={back_flow}
renderValue={(value) => value.toFixed(1)}
@@ -57,7 +57,7 @@ export function Aquapath1ControlPage() {
</div>
<div className="mt-8">
<Label label="Front Pump">
<Label label="Pump 1">
<SelectionGroup<"On" | "Off">
value={frontTargetFlow ? "On" : "Off"}
orientation="vertical"
@@ -86,7 +86,7 @@ export function Aquapath1ControlPage() {
/>
</Label>
<Label label="Back Pump">
<Label label="Pump 2">
<SelectionGroup<"On" | "Off">
value={backTargetFlow ? "On" : "Off"}
orientation="vertical"
@@ -121,20 +121,14 @@ export function Aquapath1ControlPage() {
<div className="grid grid-cols-2 gap-6">
<div className="flex flex-col gap-8">
<TimeSeriesValueNumeric
label="Front in"
label="Reservoir 1"
unit="C"
timeseries={front_temperature}
renderValue={(value) => value.toFixed(1)}
/>
<TimeSeriesValueNumeric
label="Front Reservoir"
unit="C"
timeseries={front_temp_reservoir}
renderValue={(value) => value.toFixed(1)}
/>
<Label label="Set Front Target Temperature">
<Label label="Set Reservoir 1 Target Temperature">
<EditValue
title="Set Front Target Temperature"
title="Set Reservoir 1 Target Temperature"
value={frontTargetTemperature}
unit="C"
renderValue={(value) => value.toFixed(1)}
@@ -150,20 +144,14 @@ export function Aquapath1ControlPage() {
<div className="flex flex-col gap-8">
<TimeSeriesValueNumeric
label="Back in"
label="Reservoir 2"
unit="C"
timeseries={back_temperature}
renderValue={(value) => value.toFixed(1)}
/>
<TimeSeriesValueNumeric
label="Back Reservoir"
unit="C"
timeseries={back_temp_reservoir}
renderValue={(value) => value.toFixed(1)}
/>
<Label label="Set Back Target Temperature">
<Label label="Set Reservoir 2 Target Temperature">
<EditValue
title="Set Back Target Temperature"
title="Set Reservoir 2 Target Temperature"
value={backTargetTemperature}
unit="C"
renderValue={(value) => value.toFixed(1)}
@@ -1,12 +1,15 @@
import { Page } from "@/components/Page";
import { MarkdownWithToc } from "@/components/MarkdownWithToc";
import React from "react";
import extruderManualContent from "@/assets/markdown/extruder/manual.md?raw";
import extruderManualContent from "@root/docs/machines/manuals/extruder.md?raw";
export function ExtruderV2ManualPage() {
return (
<Page>
<MarkdownWithToc markdownContent={extruderManualContent} />
<MarkdownWithToc
markdownContent={extruderManualContent}
isManual={true}
/>
</Page>
);
}
@@ -1,7 +1,7 @@
import { Page } from "@/components/Page";
import { MarkdownWithToc } from "@/components/MarkdownWithToc";
import React from "react";
import extruderManualContent from "@/assets/markdown/extruder/manual.md?raw";
import extruderManualContent from "@root/docs/machines/manuals/extruder.md?raw";
export function ExtruderV3ManualPage() {
return (
@@ -0,0 +1,111 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Label } from "@/control/Label";
import { Badge } from "@/components/ui/badge";
import { useIP20TestMachine } from "./useIP20TestMachine";
export function IP20TestMachineControlPage() {
const { state, liveValues, setOutput, setAllOutputs } = useIP20TestMachine();
const safeState = state ?? {
outputs: [false, false, false, false, false, false, false, false],
};
const safeLiveValues = liveValues ?? {
inputs: [false, false, false, false, false, false, false, false],
};
return (
<Page>
<ControlGrid columns={2}>
{/* Digital Output Controls */}
<ControlCard title="Digital Outputs">
<div className="grid grid-cols-2 gap-6">
{safeState.outputs.map((output, index) => (
<Label key={index} label={`Output ${index + 1}`}>
<SelectionGroup<"On" | "Off">
value={output ? "On" : "Off"}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Off: {
children: "Off",
icon: "lu:CirclePause",
isActiveClassName: "bg-red-600",
className: "h-full",
},
On: {
children: "On",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
className: "h-full",
},
}}
onChange={(value) => setOutput(index, value === "On")}
/>
</Label>
))}
</div>
</ControlCard>
{/* Digital Input Display */}
<ControlCard title="Digital Inputs">
<div className="grid grid-cols-2 gap-6">
{safeLiveValues.inputs.map((input, index) => (
<Label key={index} label={`Input ${index + 1}`}>
<div className="flex h-full items-center justify-center">
<Badge
className={`text-md ${input ? "bg-green-600" : "bg-gray-400"}`}
>
{input ? "HIGH" : "LOW"}
</Badge>
</div>
</Label>
))}
</div>
</ControlCard>
{/* Master Output Control */}
<ControlCard title="Master Output Control">
<SelectionGroup<"On" | "Off">
value={safeState.outputs.every(Boolean) ? "On" : "Off"}
orientation="horizontal"
options={{
Off: {
children: "Turn All Outputs Off",
icon: "lu:CirclePause",
isActiveClassName: "bg-red-600",
},
On: {
children: "Turn All Outputs On",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
},
}}
onChange={(value) => setAllOutputs(value === "On")}
/>
</ControlCard>
{/* Input Summary */}
<ControlCard title="Input Summary">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Active Inputs:</span>
<Badge className="bg-blue-500">
{safeLiveValues.inputs.filter(Boolean).length} / 8
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Inactive Inputs:</span>
<Badge className="bg-gray-500">
{safeLiveValues.inputs.filter((i) => !i).length} / 8
</Badge>
</div>
</div>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,21 @@
import { Topbar } from "@/components/Topbar";
import { ip20TestMachineSerialRoute } from "@/routes/routes";
import React from "react";
export function IP20TestMachinePage(): React.JSX.Element {
const { serial } = ip20TestMachineSerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/ip20testmachine/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:ToggleLeft",
},
]}
/>
);
}
@@ -0,0 +1,89 @@
import { StoreApi } from "zustand";
import { create } from "zustand";
import { z } from "zod";
import {
EventHandler,
eventSchema,
Event,
handleUnhandledEventError,
NamespaceId,
createNamespaceHookImplementation,
ThrottledStoreUpdater,
} from "@/client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";
// ========== Event Schema ==========
export const stateEventDataSchema = z.object({
outputs: z.array(z.boolean()).length(8),
});
export const liveValuesEventDataSchema = z.object({
inputs: z.array(z.boolean()).length(8),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export const liveValuesEventSchema = eventSchema(liveValuesEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
export type LiveValuesEvent = z.infer<typeof liveValuesEventDataSchema>;
// ========== Store ==========
export type IP20TestMachineNamespaceStore = {
state: StateEvent | null;
liveValues: LiveValuesEvent | null;
};
export const createIP20TestMachineNamespaceStore =
(): StoreApi<IP20TestMachineNamespaceStore> =>
create<IP20TestMachineNamespaceStore>(() => ({
state: null,
liveValues: null,
}));
// ========== Message Handler ==========
export function ip20TestMachineMessageHandler(
store: StoreApi<IP20TestMachineNamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<IP20TestMachineNamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const updateStore = (
updater: (
state: IP20TestMachineNamespaceStore,
) => IP20TestMachineNamespaceStore,
) => throttledUpdater.updateWith(updater);
try {
if (event.name === "StateEvent") {
const parsed = stateEventSchema.parse(event);
updateStore((current) => ({ ...current, state: parsed.data }));
} else if (event.name === "LiveValuesEvent") {
const parsed = liveValuesEventSchema.parse(event);
updateStore((current) => ({ ...current, liveValues: parsed.data }));
} else {
handleUnhandledEventError(event.name);
}
} catch (error) {
console.error(`Error processing ${event.name}:`, error);
throw error;
}
};
}
// ========== Namespace Hook ==========
const useIP20TestMachineNamespaceImplementation =
createNamespaceHookImplementation<IP20TestMachineNamespaceStore>({
createStore: createIP20TestMachineNamespaceStore,
createEventHandler: ip20TestMachineMessageHandler,
});
export function useIP20TestMachineNamespace(
machine_identification_unique: MachineIdentificationUnique,
): IP20TestMachineNamespaceStore {
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};
return useIP20TestMachineNamespaceImplementation(namespaceId);
}
@@ -0,0 +1,103 @@
import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { ip20TestMachineSerialRoute } from "@/routes/routes";
import { MachineIdentificationUnique } from "@/machines/types";
import {
useIP20TestMachineNamespace,
StateEvent,
LiveValuesEvent,
} from "./ip20TestMachineNamespace";
import { useMachineMutate } from "@/client/useClient";
import { produce } from "immer";
import { useEffect, useMemo } from "react";
import { ip20TestMachine } from "@/machines/properties";
import { z } from "zod";
export function useIP20TestMachine() {
const { serial: serialString } = ip20TestMachineSerialRoute.useParams();
// Memoize machine identification
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
const serial = parseInt(serialString);
if (isNaN(serial)) {
toastError(
"Invalid Serial Number",
`"${serialString}" is not a valid serial number.`,
);
return {
machine_identification: { vendor: 0, machine: 0 },
serial: 0,
};
}
return {
machine_identification: ip20TestMachine.machine_identification,
serial,
};
}, [serialString]);
// Namespace state from backend
const { state, liveValues } = useIP20TestMachineNamespace(
machineIdentification,
);
// Optimistic state
const stateOptimistic = useStateOptimistic<StateEvent>();
useEffect(() => {
if (state) stateOptimistic.setReal(state);
}, [state, stateOptimistic]);
// Generic mutation sender
const { request: sendMutation } = useMachineMutate(
z.object({
action: z.string(),
value: z.any(),
}),
);
const updateStateOptimistically = (
producer: (current: StateEvent) => void,
serverRequest?: () => void,
) => {
const currentState = stateOptimistic.value;
if (currentState)
stateOptimistic.setOptimistic(produce(currentState, producer));
serverRequest?.();
};
const setOutput = (index: number, on: boolean) => {
updateStateOptimistically(
(current) => {
current.outputs[index] = on;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetOutput", value: { index, on } },
}),
);
};
const setAllOutputs = (on: boolean) => {
updateStateOptimistically(
(current) => {
current.outputs = [on, on, on, on, on, on, on, on];
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetAllOutputs", value: { on } },
}),
);
};
return {
state: stateOptimistic.value,
liveValues,
setOutput,
setAllOutputs,
};
}
@@ -1,12 +1,12 @@
import { Page } from "@/components/Page";
import { MarkdownWithToc } from "@/components/MarkdownWithToc";
import React from "react";
import laserManualContent from "@/assets/markdown/laser/manual.md?raw";
import laserManualContent from "@root/docs/machines/manuals/laser.md?raw";
export function Laser1ManualPage() {
return (
<Page>
<MarkdownWithToc markdownContent={laserManualContent} />
<MarkdownWithToc markdownContent={laserManualContent} isManual={true} />
</Page>
);
}
@@ -21,6 +21,12 @@ export function Laser1Page() {
icon: "lu:ChartSpline",
},
// {
// link: "settings",
// activeLink: "settings",
// title: "Config",
// icon: "lu:Settings",
// },
// {
// link: "manual",
// activeLink: "manual",
// title: "Manual",
@@ -42,6 +42,7 @@ export const stateEventDataSchema = z.object({
higher_tolerance: z.number(),
lower_tolerance: z.number(),
target_diameter: z.number(),
in_tolerance: z.boolean(),
}),
});
@@ -68,24 +69,25 @@ export type Laser1NamespaceStore = {
// Constants for time durations
const TWENTY_MILLISECOND = 20;
const ONE_SECOND = 1000;
const HALF_SECOND = 500;
const FIVE_SECOND = 5 * ONE_SECOND;
const ONE_HOUR = 60 * 60 * ONE_SECOND;
const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
HALF_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const { initialTimeSeries: x_diameter, insert: addXDiameter } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries(TWENTY_MILLISECOND, HALF_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: y_diameter, insert: addYDiameter } =
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
createTimeSeries(TWENTY_MILLISECOND, HALF_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: roundness, insert: addRoundness } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
HALF_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
@@ -102,7 +104,7 @@ export const createLaser1NamespaceStore = (): StoreApi<Laser1NamespaceStore> =>
diameter: diameter,
x_diameter,
y_diameter,
roundness: roundness,
roundness,
};
});
@@ -1,12 +1,12 @@
import { Page } from "@/components/Page";
import { MarkdownWithToc } from "@/components/MarkdownWithToc";
import React from "react";
import winderManualContent from "@/assets/markdown/winder/manual.md?raw";
import mockManualContent from "@root/docs/machines/manuals/mock.md?raw";
export function Mock1ManualPage() {
return (
<Page>
<MarkdownWithToc markdownContent={winderManualContent} />
<MarkdownWithToc markdownContent={mockManualContent} isManual={true} />
</Page>
);
}
+74
View File
@@ -427,6 +427,7 @@ export const aquapath1: MachineProperties = {
},
],
};
export const testmachine: MachineProperties = {
name: "TestMachine",
version: "V1",
@@ -461,6 +462,76 @@ export const testmachine: MachineProperties = {
},
],
};
export const wagoPower1: MachineProperties = {
name: "WAGO Power",
version: "V1",
slug: "wago_power1",
icon: "lu:PlugZap",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x000a,
},
device_roles: [],
};
export const analogInputTestMachine: MachineProperties = {
name: "AnalogTest",
version: "V1",
slug: "analogInputTestMachine",
icon: "lu:Clock",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0035,
},
device_roles: [
{
role: 0,
role_label: "Bus Coupler",
allowed_devices: [
{
vendor_id: 2,
product_id: 0x44c2c52,
revision: 0x120000,
},
],
},
{
role: 1,
role_label: "EL3021",
allowed_devices: [
{
vendor_id: 2,
product_id: 0xbcd3052,
revision: 0x140000,
},
],
},
],
};
export const ip20TestMachine: MachineProperties = {
name: "IP20 Test",
version: "V1",
slug: "ip20testmachine",
icon: "lu:ToggleLeft",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0034,
},
device_roles: [
{
role: 0,
role_label: "IP20-EC-DI8-DO8",
allowed_devices: [
{
vendor_id: 0x741,
product_id: 0x117b6722,
revision: 0x1,
},
],
},
],
};
export const machineProperties: MachineProperties[] = [
winder2,
@@ -469,8 +540,11 @@ export const machineProperties: MachineProperties[] = [
laser1,
mock1,
buffer1,
wagoPower1,
aquapath1,
testmachine,
analogInputTestMachine,
ip20TestMachine,
];
export const getMachineProperties = (
@@ -0,0 +1,67 @@
import { Page } from "@/components/Page";
import React from "react";
import { useWagoPower1 } from "./useWagoPower1";
import { ControlGrid } from "@/control/ControlGrid";
import { ControlCard } from "@/control/ControlCard";
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Mode } from "./wagoPower1Namespace";
export function WagoPower1ControlPage() {
const {
state,
voltage,
current,
setMode,
isLoading,
isDisabled,
} = useWagoPower1();
return (
<Page>
<ControlGrid columns={2}>
<ControlCard title="Power">
<TimeSeriesValueNumeric
label="Voltage"
unit="V"
timeseries={voltage}
renderValue={(value) => value.toFixed(2)}
/>
<TimeSeriesValueNumeric
label="Current"
unit="mA"
timeseries={current}
renderValue={(value) => value.toFixed(2)}
/>
</ControlCard>
<ControlCard title="Mode">
<SelectionGroup<Mode>
value={state?.mode}
disabled={isDisabled}
loading={isLoading}
onChange={setMode}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Off: {
children: "Off",
icon: "lu:PowerOff",
isActiveClassName: "bg-gray-600",
className: "h-full",
},
On24V: {
children: "On",
icon: "lu:Power",
isActiveClassName: "bg-green-600",
className: "h-full",
},
}}
/>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,20 @@
import { Topbar } from "@/components/Topbar";
import { wagoPower1SerialRoute } from "@/routes/routes";
import React from "react";
export function WagoPower1Page() {
const { serial } = wagoPower1SerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/wago_power1/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:PlugZap",
},
]}
/>
);
}
@@ -0,0 +1,104 @@
import { toastError } from "@/components/Toast";
import { wagoPower1 } from "@/machines/properties";
import { MachineIdentificationUnique } from "@/machines/types";
import { wagoPower1SerialRoute } from "@/routes/routes";
import { useEffect, useMemo } from "react";
import {
Mode,
modeSchema,
StateEvent,
useWagoPower1Namespace,
} from "./wagoPower1Namespace";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { produce } from "immer";
import z from "zod";
import { useMachineMutate } from "@/client/useClient";
export function useWagoPower1() {
const { serial: serialString } = wagoPower1SerialRoute.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: wagoPower1.machine_identification,
serial,
};
}, [serialString]);
const { request: requestModeSet } = useMachineMutate(
z.object({ SetMode: modeSchema }),
);
const { state, defaultState, voltage, current } = useWagoPower1Namespace(
machineIdentification,
);
//
// Single optimistic state for all state management
const stateOptimistic = useStateOptimistic<StateEvent>();
// Update optimistic state when real state changes
useEffect(() => {
if (state) {
stateOptimistic.setReal(state);
}
}, [state]);
// 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();
};
const setMode = (mode: Mode) => {
updateStateOptimistically(
(current) => {
current.data.mode = mode;
},
() =>
requestModeSet({
machine_identification_unique: machineIdentification,
data: { SetMode: mode },
}),
);
};
// Calculate loading states
const isLoading = stateOptimistic.isOptimistic;
const isDisabled = !stateOptimistic.isInitialized;
return {
state: stateOptimistic.value?.data,
defaultState: defaultState?.data,
current,
voltage,
setMode,
isLoading,
isDisabled,
};
}
@@ -0,0 +1,149 @@
import { StoreApi } from "zustand";
import { create } from "zustand";
import { z } from "zod";
import {
EventHandler,
eventSchema,
Event,
handleUnhandledEventError,
NamespaceId,
createNamespaceHookImplementation,
ThrottledStoreUpdater,
} from "../../../client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";
import { useMemo } from "react";
import {
createTimeSeries,
TimeSeries,
TimeSeriesValue,
} from "@/lib/timeseries";
export const liveValuesEventSchema = eventSchema(
z.object({
voltage: z.number(),
current: z.number(),
}),
);
export type LiveValuesEvent = z.infer<typeof liveValuesEventSchema>;
export const modeSchema = z.enum(["Off", "On24V"]);
export type Mode = z.infer<typeof modeSchema>;
export const stateEventSchema = eventSchema(
z.object({
mode: modeSchema,
is_default_state: z.boolean(),
}),
);
export type StateEvent = z.infer<typeof stateEventSchema>;
export type WagoPower1NamespaceStore = {
state: StateEvent | null;
defaultState: StateEvent | null;
current: TimeSeries;
voltage: TimeSeries;
};
const TWENTY_MILLISECOND = 20;
const ONE_SECOND = 1000;
const FIVE_SECOND = 5 * ONE_SECOND;
const ONE_HOUR = 60 * 60 * ONE_SECOND;
const { initialTimeSeries: voltage, insert: addVoltage } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const { initialTimeSeries: current, insert: addCurrent } = createTimeSeries(
TWENTY_MILLISECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const createWagoPower1NamespaceStore = (): StoreApi<WagoPower1NamespaceStore> =>
create<WagoPower1NamespaceStore>(() => {
return {
state: null,
defaultState: null,
voltage,
current,
};
});
function wagoPower1MessageHandler(
store: StoreApi<WagoPower1NamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<WagoPower1NamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const eventName = event.name;
// Helper function to update store through buffer
const updateStore = (
updater: (state: WagoPower1NamespaceStore) => WagoPower1NamespaceStore,
) => {
throttledUpdater.updateWith(updater);
};
try {
if (eventName === "StateEvent") {
const stateEvent = stateEventSchema.parse(event);
updateStore((state) => ({
...state,
state: stateEvent,
// only set default state if is_default_state is true
defaultState: stateEvent.data.is_default_state
? stateEvent
: state.defaultState,
}));
} else if (eventName === "LiveValuesEvent") {
console.log(event);
const liveValues = liveValuesEventSchema.parse(event);
const { voltage, current } = liveValues.data;
const voltageValue: TimeSeriesValue = {
value: voltage,
timestamp: liveValues.ts,
};
const currentValue: TimeSeriesValue = {
value: current,
timestamp: liveValues.ts,
};
updateStore((store: WagoPower1NamespaceStore) => ({
...store,
voltage: addVoltage(store.voltage, voltageValue),
current: addCurrent(store.current, currentValue),
}));
} else {
handleUnhandledEventError(eventName);
}
} catch (e) {
console.error(e);
}
};
}
const useWagoPower1NamespaceImplementation =
createNamespaceHookImplementation<WagoPower1NamespaceStore>({
createStore: createWagoPower1NamespaceStore,
createEventHandler: wagoPower1MessageHandler,
});
export function useWagoPower1Namespace(
machine_identification_unique: MachineIdentificationUnique,
): WagoPower1NamespaceStore {
// 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 useWagoPower1NamespaceImplementation(namespaceId);
}
@@ -1,12 +1,12 @@
import { Page } from "@/components/Page";
import { MarkdownWithToc } from "@/components/MarkdownWithToc";
import React from "react";
import winderManualContent from "@/assets/markdown/winder/manual.md?raw";
import winderManualContent from "@root/docs/machines/manuals/winder.md?raw";
export function Winder2ManualPage() {
return (
<Page>
<MarkdownWithToc markdownContent={winderManualContent} />
<MarkdownWithToc markdownContent={winderManualContent} isManual={true} />
</Page>
);
}
@@ -1,8 +1,3 @@
/**
* @file winder2Namespace.ts
* @description TypeScript implementation of Winder2 namespace with Zod schema validation.
*/
import { StoreApi } from "zustand";
import { create } from "zustand";
import { z } from "zod";
+150
View File
@@ -0,0 +1,150 @@
import React from "react";
import { Page } from "@/components/Page";
import { useRuntimeMetrics } from "./useRuntimeMetrics";
import { SectionTitle } from "@/components/SectionTitle";
import {
renderValueToReactNode,
renderUnitSymbol,
getUnitIcon,
type Unit,
} from "@/control/units";
import { Icon } from "@/components/Icon";
type MetricCardProps = {
title: string;
value: React.ReactNode;
unit?: Unit;
};
function renderMetricWithUnit(
value: number | null | undefined,
unit: Unit,
digits = 1,
) {
if (value == null) {
return "—";
}
return (
<>
{renderValueToReactNode(value, unit, (v) => v.toFixed(digits))}
<span className="text-muted-foreground ml-1 text-xs">
{renderUnitSymbol(unit)}
</span>
</>
);
}
function MetricCard({ title, value, unit }: MetricCardProps) {
const iconName = unit ? getUnitIcon(unit) : "lu:StickyNote";
return (
<div className="flex flex-col gap-4 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{iconName && (
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 text-slate-500">
<Icon name={iconName} className="h-4 w-4" />
</span>
)}
<h2 className="text-sm font-medium text-slate-700">{title}</h2>
</div>
<span className="text-base font-semibold text-slate-900">{value}</span>
</div>
</div>
);
}
export function MetricsControlPage() {
// Global poller: immediate first fetch, then every 5 seconds
const { sample, cpuPercent, preemptionRate } = useRuntimeMetrics(true, 5000);
const rssMb = sample != null ? sample.rss_bytes / (1024 * 1024) : null;
// jitter_*_ns is nanoseconds; divide by 1e3 to get microseconds (µs)
const jitterMaxUs = sample != null ? sample.jitter_max_ns / 1e3 : null;
const rxMbit =
sample != null ? (sample.rx_rate_bytes_per_sec * 8.0) / 1_000_000.0 : null;
const txMbit =
sample != null ? (sample.tx_rate_bytes_per_sec * 8.0) / 1_000_000.0 : null;
const cpuTimeSec = sample != null ? sample.cpu_time_seconds : null;
const minorFaults = sample != null ? sample.minor_faults : null;
const majorFaults = sample != null ? sample.major_faults : null;
return (
<Page>
<div className="space-y-6">
<SectionTitle title="Backend Metrics" />
<div className="grid gap-6 md:grid-cols-2">
{/* Loop jitter: max, in µs */}
<MetricCard
title="Max Loop Jitter"
unit={"µs"}
value={renderMetricWithUnit(jitterMaxUs, "µs")}
/>
{/* CPU usage */}
<MetricCard
title="CPU Usage"
unit={"%"}
value={renderMetricWithUnit(cpuPercent, "%")}
/>
{/* CPU time */}
<MetricCard
title="CPU Time"
unit={"s"}
value={renderMetricWithUnit(cpuTimeSec, "s")}
/>
{/* Memory usage */}
<MetricCard
title="Memory Usage"
unit={"MiB"}
value={renderMetricWithUnit(rssMb, "MiB")}
/>
{/* Total page faults (composite, no single unit) */}
<MetricCard
title="Total Page Faults"
value={
minorFaults != null || majorFaults != null
? `${minorFaults ?? "–"} minor | ${majorFaults ?? "–"} major`
: "—"
}
/>
{/* EtherCAT throughput (RX | TX) */}
<MetricCard
title="EtherCAT Throughput (RX | TX)"
unit={"Mbit/s"}
value={
rxMbit != null && txMbit != null ? (
<span className="flex items-center gap-1">
{renderMetricWithUnit(rxMbit, "Mbit/s")}
<span className="text-muted-foreground mx-1 text-xs">|</span>
{renderMetricWithUnit(txMbit, "Mbit/s")}
</span>
) : (
"—"
)
}
/>
{/* Preemptions per second */}
<MetricCard
title="Preemptions"
unit={"/s"}
value={
preemptionRate != null
? renderMetricWithUnit(preemptionRate, "/s")
: "—"
}
/>
</div>
</div>
</Page>
);
}
+169
View File
@@ -0,0 +1,169 @@
import React from "react";
import { Page } from "@/components/Page";
import {
AutoSyncedBigGraph,
SyncedFloatingControlPanel,
useGraphSync,
type GraphConfig,
} from "@/components/graph";
import { useRuntimeMetrics } from "./useRuntimeMetrics";
import { useRuntimeMetricSeries } from "./runtimeSeries";
import { getUnitIcon, type Unit } from "@/control/units";
export function MetricsGraphsPage() {
// Start / keep the global poller alive
useRuntimeMetrics(true, 5000);
// Shared series (fed by global poller)
const series = useRuntimeMetricSeries();
const syncHook = useGraphSync("runtime-metrics");
const jitterUnit: Unit = "µs";
const cpuUnit: Unit = "%";
const memUnit: Unit = "MiB";
const ioUnit: Unit = "Mbit/s";
const preemptUnit: Unit = "/s";
const jitterConfig: GraphConfig = {
title: "Average Loop Jitter",
icon: getUnitIcon(jitterUnit),
colors: {
primary: "#f97316",
grid: "#e2e8f0",
background: "#ffffff",
},
exportFilename: "runtime_jitter",
};
const cpuConfig: GraphConfig = {
title: "CPU Usage",
icon: getUnitIcon(cpuUnit),
colors: {
primary: "#3b82f6",
grid: "#e2e8f0",
background: "#ffffff",
},
exportFilename: "runtime_cpu",
};
const memConfig: GraphConfig = {
title: "Memory Usage",
icon: getUnitIcon(memUnit),
colors: {
primary: "#10b981",
grid: "#e2e8f0",
background: "#ffffff",
},
exportFilename: "runtime_memory",
};
const ioConfig: GraphConfig = {
title: "EtherCAT Throughput",
icon: getUnitIcon(ioUnit),
colors: {
primary: "#10b981",
grid: "#e2e8f0",
background: "#ffffff",
},
exportFilename: "runtime_io",
showLegend: true,
};
const preemptConfig: GraphConfig = {
title: "Preemptions Per Second",
icon: getUnitIcon(preemptUnit),
colors: {
primary: "#6366f1",
grid: "#e2e8f0",
background: "#ffffff",
},
exportFilename: "runtime_preemptions",
};
return (
<Page className="pb-27">
<div className="flex flex-col gap-4">
{series.jitter && (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: series.jitter,
color: "#f97316",
}}
unit={jitterUnit}
renderValue={(v) => v.toFixed(1)}
config={jitterConfig}
graphId="runtime_jitter"
/>
)}
{series.cpu && (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: series.cpu,
color: "#3b82f6",
}}
unit={cpuUnit}
renderValue={(v) => v.toFixed(1)}
config={cpuConfig}
graphId="runtime_cpu"
/>
)}
{series.mem && (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: series.mem,
color: "#10b981",
}}
unit={memUnit}
renderValue={(v) => v.toFixed(1)}
config={memConfig}
graphId="runtime_memory"
/>
)}
{series.rx && series.tx && (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={[
{
newData: series.rx,
title: "RX",
color: "#10b981",
},
{
newData: series.tx,
title: "TX",
color: "#ef4444",
},
]}
unit={ioUnit}
renderValue={(v) => v.toFixed(2)}
config={ioConfig}
graphId="runtime_io"
/>
)}
{series.preempt && (
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: series.preempt,
color: "#6366f1",
}}
unit={preemptUnit}
renderValue={(v) => v.toFixed(1)}
config={preemptConfig}
graphId="runtime_preemptions"
/>
)}
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useState } from "react";
import { createTimeSeries, TimeSeries } from "@/lib/timeseries";
import type { RuntimeMetricsSample } from "./useRuntimeMetrics";
const ONE_SECOND = 1000;
const FIVE_SECOND = 5 * ONE_SECOND;
const ONE_HOUR = 60 * 60 * ONE_SECOND;
// 5 s window, 1 s bucket, 1 h history
const { initialTimeSeries: jitterInitial, insert: insertJitter } =
createTimeSeries(FIVE_SECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
const { initialTimeSeries: cpuInitial, insert: insertCpu } = createTimeSeries(
FIVE_SECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const { initialTimeSeries: memInitial, insert: insertMem } = createTimeSeries(
FIVE_SECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const { initialTimeSeries: rxInitial, insert: insertRx } = createTimeSeries(
FIVE_SECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const { initialTimeSeries: txInitial, insert: insertTx } = createTimeSeries(
FIVE_SECOND,
ONE_SECOND,
FIVE_SECOND,
ONE_HOUR,
);
const { initialTimeSeries: preemptInitial, insert: insertPreempt } =
createTimeSeries(FIVE_SECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
export type RuntimeSeriesState = {
jitter: TimeSeries | null;
cpu: TimeSeries | null;
mem: TimeSeries | null;
rx: TimeSeries | null;
tx: TimeSeries | null;
preempt: TimeSeries | null;
};
let baseTimestampMs: number | null = null;
let currentSeries: RuntimeSeriesState = {
jitter: jitterInitial,
cpu: cpuInitial,
mem: memInitial,
rx: rxInitial,
tx: txInitial,
preempt: preemptInitial,
};
const subscribers = new Set<(s: RuntimeSeriesState) => void>();
function notifySubscribers() {
for (const cb of subscribers) cb(currentSeries);
}
/**
* Push one sample into the shared time-series.
* Always records; UI toggles only control what is displayed.
*/
export function pushRuntimeSample(
sample: RuntimeMetricsSample,
cpuPercent: number | null,
preemptionRate: number | null,
) {
if (baseTimestampMs === null) {
baseTimestampMs = sample.timestamp_ms;
}
const t = sample.timestamp_ms - baseTimestampMs; // ms since first sample
let { jitter, cpu, mem, rx, tx, preempt } = currentSeries;
// avg jitter in µs
jitter = insertJitter(jitter ?? jitterInitial, {
value: sample.jitter_avg_ns / 1e3,
timestamp: t,
});
if (cpuPercent != null) {
cpu = insertCpu(cpu ?? cpuInitial, {
value: cpuPercent,
timestamp: t,
});
}
const rssMb = sample.rss_bytes / (1024 * 1024);
mem = insertMem(mem ?? memInitial, {
value: rssMb,
timestamp: t,
});
const rxMbit = (sample.rx_rate_bytes_per_sec * 8.0) / 1_000_000.0;
const txMbit = (sample.tx_rate_bytes_per_sec * 8.0) / 1_000_000.0;
rx = insertRx(rx ?? rxInitial, {
value: rxMbit,
timestamp: t,
});
tx = insertTx(tx ?? txInitial, {
value: txMbit,
timestamp: t,
});
if (preemptionRate != null) {
preempt = insertPreempt(preempt ?? preemptInitial, {
value: preemptionRate,
timestamp: t,
});
}
currentSeries = { jitter, cpu, mem, rx, tx, preempt };
notifySubscribers();
}
/**
* Read shared time-series; history survives unmount/remount.
*/
export function useRuntimeMetricSeries(): RuntimeSeriesState {
const [state, setState] = useState<RuntimeSeriesState>(currentSeries);
useEffect(() => {
const cb = (s: RuntimeSeriesState) => setState(s);
subscribers.add(cb);
cb(currentSeries);
return () => {
subscribers.delete(cb);
};
}, []);
return state;
}
+156
View File
@@ -0,0 +1,156 @@
import { useEffect, useState } from "react";
import { pushRuntimeSample } from "./runtimeSeries";
// Talk directly to the Rust API on port 3001.
// If you later proxy via Vite/Electron, make this configurable.
const API_BASE = "http://127.0.0.1:3001";
const RUNTIME_URL = `${API_BASE}/api/v1/metrics/runtime/latest`;
export type RuntimeMetricsSample = {
timestamp_ms: number;
rss_bytes: number;
cpu_time_seconds: number;
minor_faults: number;
major_faults: number;
jitter_min_ns: number;
jitter_avg_ns: number;
jitter_max_ns: number;
rx_rate_bytes_per_sec: number;
tx_rate_bytes_per_sec: number;
rt_loop_cpu_time_seconds?: number | null;
rt_nr_switches: number | null;
rt_nr_voluntary_switches: number | null;
rt_nr_involuntary_switches: number | null;
};
type MetricsState = {
sample: RuntimeMetricsSample | null;
cpuPercent: number | null;
preemptionRate: number | null;
};
// ---- shared module-level state (global poller) ----
let currentState: MetricsState = {
sample: null,
cpuPercent: null,
preemptionRate: null,
};
let lastSample: RuntimeMetricsSample | null = null;
let pollIntervalMs = 5000;
let pollTimer: ReturnType<typeof setInterval> | null = null;
const subscribers = new Set<(s: MetricsState) => void>();
function notifySubscribers() {
for (const cb of subscribers) cb(currentState);
}
async function fetchOnce() {
try {
const res = await fetch(RUNTIME_URL);
if (!res.ok) {
console.error(
"Runtime metrics fetch failed:",
res.status,
res.statusText,
);
return;
}
const json = (await res.json()) as RuntimeMetricsSample | null;
if (!json) {
// backend returned null (no sample yet)
return;
}
const current = json;
const last = lastSample;
let cpuPercent: number | null = null;
let preemptionRate: number | null = null;
if (last) {
const dtWallSec = (current.timestamp_ms - last.timestamp_ms) / 1000.0;
if (dtWallSec > 0) {
const dtCpu = current.cpu_time_seconds - last.cpu_time_seconds;
cpuPercent = (dtCpu / dtWallSec) * 100.0;
if (
current.rt_nr_involuntary_switches != null &&
last.rt_nr_involuntary_switches != null
) {
const dtPreempt =
current.rt_nr_involuntary_switches -
last.rt_nr_involuntary_switches;
preemptionRate = dtPreempt / dtWallSec;
}
}
}
lastSample = current;
currentState = {
sample: current,
cpuPercent,
preemptionRate,
};
// feed into shared time-series for graphs
pushRuntimeSample(current, cpuPercent, preemptionRate);
notifySubscribers();
} catch (err) {
console.error("Failed to fetch runtime metrics:", err);
}
}
function startPolling() {
if (pollTimer != null) return;
void fetchOnce();
pollTimer = setInterval(fetchOnce, pollIntervalMs);
}
/**
* Shared runtime metrics hook.
*
* - One global poller per app (module-level).
* - Polling continues even if no components are mounted.
* - Components just subscribe to the latest state.
*/
export function useRuntimeMetrics(
enabled: boolean,
intervalMs: number = 5000,
): MetricsState {
const [state, setState] = useState<MetricsState>(currentState);
// Subscribe to shared state
useEffect(() => {
const cb = (s: MetricsState) => setState(s);
subscribers.add(cb);
cb(currentState);
return () => {
subscribers.delete(cb);
// do NOT stop polling here; recording continues in background
};
}, []);
// Control poller configuration
useEffect(() => {
pollIntervalMs = intervalMs;
if (enabled) {
if (pollTimer != null) {
clearInterval(pollTimer);
pollTimer = null;
}
startPolling();
}
}, [enabled, intervalMs]);
return state;
}
export default useRuntimeMetrics;
+67 -2
View File
@@ -42,6 +42,7 @@ import { Buffer1SettingsPage } from "@/machines/buffer/buffer1/Buffer1Settings";
import { Laser1ControlPage } from "@/machines/laser/laser1/Laser1ControlPage";
import { Laser1GraphsPage } from "@/machines/laser/laser1/Laser1Graph";
import { Laser1Page } from "@/machines/laser/laser1/Laser1Page";
import { Laser1PresetsPage } from "@/machines/laser/laser1/Laser1PresetsPage";
import { Mock1ControlPage } from "@/machines/mock/mock1/Mock1ControlPage";
import { Mock1GraphPage } from "@/machines/mock/mock1/Mock1Graph";
@@ -55,7 +56,17 @@ import { Aquapath1GraphPage } from "@/machines/aquapath/aquapath1/Aquapath1Graph
import { TestMachinePage } from "@/machines/testmachine/TestMachinePage";
import { TestMachineControlPage } from "@/machines/testmachine/TestMachineControlPage";
import { Laser1PresetsPage } from "@/machines/laser/laser1/Laser1PresetsPage";
import { AnalogInputTestMachine } from "@/machines/analoginputtestmachine/AnalogInputTestMachinePage";
import { AnalogInputTestMachineControl } from "@/machines/analoginputtestmachine/AnalogInputTestMachineControlPage";
import { IP20TestMachinePage } from "@/machines/ip20testmachine/IP20TestMachinePage";
import { IP20TestMachineControlPage } from "@/machines/ip20testmachine/IP20TestMachineControlPage";
import { MetricsGraphsPage } from "@/metrics/MetricsGraphsPage";
import { MetricsControlPage } from "@/metrics/MetricsControlPage";
import { WagoPower1Page } from "@/machines/wago_power/wago_power1/WagoPower1Page";
import { WagoPower1ControlPage } from "@/machines/wago_power/wago_power1/WagoPower1ControlPage";
// make a route tree like this
// _mainNavigation/machines/winder2/$serial/control
@@ -77,6 +88,30 @@ export const testMachineControlRoute = createRoute({
component: () => <TestMachineControlPage />,
});
export const analogInputTestMachineSerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "analogInputTestMachine/$serial",
component: () => <AnalogInputTestMachine />,
});
export const analogInputTestMachineControlRoute = createRoute({
getParentRoute: () => analogInputTestMachineSerialRoute,
path: "control",
component: () => <AnalogInputTestMachineControl />,
});
export const ip20TestMachineSerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "ip20testmachine/$serial",
component: () => <IP20TestMachinePage />,
});
export const ip20TestMachineControlRoute = createRoute({
getParentRoute: () => ip20TestMachineSerialRoute,
path: "control",
component: () => <IP20TestMachineControlPage />,
});
export const sidebarRoute = createRoute({
getParentRoute: () => RootRoute,
path: "_sidebar",
@@ -286,6 +321,18 @@ export const buffer1SettingsRoute = createRoute({
component: () => <Buffer1SettingsPage />,
});
export const wagoPower1SerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "wago_power1/$serial",
component: () => <WagoPower1Page />,
});
export const wagoPower1ControlRoute = createRoute({
getParentRoute: () => wagoPower1SerialRoute,
path: "control",
component: () => <WagoPower1ControlPage />,
});
export const setupRoute = createRoute({
getParentRoute: () => sidebarRoute,
path: "setup",
@@ -321,6 +368,16 @@ export const updateChooseVersionRoute = createRoute({
path: "choose-version",
component: () => <ChooseVersionPage />,
});
export const metricsRoute = createRoute({
getParentRoute: () => setupRoute,
path: "metrics",
component: () => (
<>
<MetricsControlPage />
<MetricsGraphsPage />
</>
),
});
export const versionSearchSchema = z
.object({
@@ -369,8 +426,8 @@ export const rootTree = RootRoute.addChildren([
updateExecuteRoute,
]),
troubleshootRoute,
metricsRoute,
]),
machinesRoute.addChildren([
laser1SerialRoute.addChildren([
laser1ControlRoute,
@@ -379,6 +436,12 @@ export const rootTree = RootRoute.addChildren([
]),
testMachineSerialRoute.addChildren([testMachineControlRoute]),
analogInputTestMachineSerialRoute.addChildren([
analogInputTestMachineControlRoute,
]),
ip20TestMachineSerialRoute.addChildren([ip20TestMachineControlRoute]),
aquapath1SerialRoute.addChildren([
aquapath1ControlRoute,
aquapath1GraphRoute,
@@ -415,6 +478,8 @@ export const rootTree = RootRoute.addChildren([
mock1PresetsRoute,
]),
wagoPower1SerialRoute.addChildren([wagoPower1ControlRoute]),
buffer1SerialRoute.addChildren([buffer1ControlRoute]),
]),
]),
+340 -102
View File
@@ -15,7 +15,7 @@ import {
machineProperties,
VENDOR_QITECH,
} from "@/machines/properties";
import React, { useEffect, useMemo } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -45,6 +45,7 @@ import { Icon } from "@/components/Icon";
import { toast } from "sonner";
import { Toast } from "@/components/Toast";
import { EthercatDevicesEventData } from "@/client/mainNamespace";
import { TouchNumpad } from "@/components/touch/TouchNumpad";
type Device = NonNullable<EthercatDevicesEventData["Done"]>["devices"][number];
@@ -63,15 +64,22 @@ type FormSchema = z.infer<typeof formSchema>;
export function DeviceEepromDialog({ device }: Props) {
const [open, setOpen] = React.useState(false);
const key = useMemo(() => Math.random(), [open]);
const onClose = () => setOpen(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={setOpen}
// Prevent closing via Escape to keep numpad open while interacting
modal
>
<DialogTrigger asChild>
<Button variant="outline">
<Icon name="lu:Pencil" />
Assign
</Button>
</DialogTrigger>
<DeviceEeepromDialogContent device={device} key={key} setOpen={setOpen} />
<DeviceEeepromDialogContent device={device} key={key} setOpen={onClose} />
</Dialog>
);
}
@@ -79,11 +87,17 @@ export function DeviceEepromDialog({ device }: Props) {
// (Empty comment block removed)
type ContentProps = {
device: Device;
setOpen: (open: boolean) => void;
setOpen: () => void;
};
export function DeviceEeepromDialogContent({ device, setOpen }: ContentProps) {
const client = useClient();
const serialInputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const numpadRef = useRef<HTMLDivElement>(null);
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadPosition, setNumpadPosition] = useState({ left: 0, top: 0 });
const serialContainerRef = useRef<HTMLDivElement>(null);
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
@@ -126,7 +140,7 @@ export function DeviceEeepromDialogContent({ device, setOpen }: ContentProps) {
Machine assignment written successfully.
</Toast>,
);
setOpen(false);
setOpen();
}
});
};
@@ -166,104 +180,328 @@ export function DeviceEeepromDialogContent({ device, setOpen }: ContentProps) {
}
}, [filteredAllowedDevices]);
// Position numpad once when it opens
useEffect(() => {
if (!numpadOpen || !dialogRef.current) return;
const rect = dialogRef.current.getBoundingClientRect();
setNumpadPosition({
left: rect.right + 20,
top: rect.top + rect.height / 2,
});
}, [numpadOpen]);
// Close numpad when clicking outside input/numpad
useEffect(() => {
if (!numpadOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
const insideSerial = serialContainerRef.current?.contains(target);
const insideNumpad = numpadRef.current?.contains(target);
if (!insideSerial && !insideNumpad) {
setNumpadOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
};
}, [numpadOpen]);
// Keep focus on input field when numpad is opened
useEffect(() => {
if (numpadOpen && serialInputRef.current) {
// Use setTimeout to ensure this runs after any other focus changes
setTimeout(() => {
if (serialInputRef.current) {
serialInputRef.current.focus();
}
}, 0);
}
}, [numpadOpen]);
// Numpad handlers for serial input
const numpadHandlers = useMemo(() => {
const ensureFocus = () => {
if (
serialInputRef.current &&
document.activeElement !== serialInputRef.current
) {
serialInputRef.current.focus();
}
};
const updateCursorPosition = (position: number) => {
setTimeout(() => {
if (serialInputRef.current) {
serialInputRef.current.setSelectionRange(position, position);
}
}, 0);
};
const getCurrentValue = () => {
return form.getValues("serial") || "";
};
return {
appendDigit: (digit: string) => {
if (!serialInputRef.current) return;
ensureFocus();
const input = serialInputRef.current;
const start = input.selectionStart || 0;
const end = input.selectionEnd || 0;
const currentValue = getCurrentValue();
const newValue =
currentValue.slice(0, start) + digit + currentValue.slice(end);
form.setValue("serial", newValue, { shouldValidate: true });
updateCursorPosition(start + 1);
},
addDecimal: () => {
// Not needed for serial (U16 integer), but keeping for consistency
// Could be used for other numeric inputs if needed
},
deleteChar: () => {
if (!serialInputRef.current) return;
ensureFocus();
const input = serialInputRef.current;
const start = input.selectionStart || 0;
const end = input.selectionEnd || 0;
const currentValue = getCurrentValue();
let newValue: string;
let newPosition: number;
if (start !== end) {
// Delete selection
newValue = currentValue.slice(0, start) + currentValue.slice(end);
newPosition = start;
} else if (start > 0) {
// Backspace
newValue =
currentValue.slice(0, start - 1) + currentValue.slice(start);
newPosition = start - 1;
} else {
return;
}
form.setValue("serial", newValue, { shouldValidate: true });
updateCursorPosition(newPosition);
},
toggleSign: () => {
// Not needed for U16 (unsigned), but keeping for consistency
},
moveCursorLeft: () => {
if (!serialInputRef.current) return;
ensureFocus();
const currentPos = serialInputRef.current.selectionStart || 0;
if (currentPos > 0) {
serialInputRef.current.setSelectionRange(
currentPos - 1,
currentPos - 1,
);
}
},
moveCursorRight: () => {
if (!serialInputRef.current) return;
ensureFocus();
const currentPos = serialInputRef.current.selectionStart || 0;
const currentValue = getCurrentValue();
if (currentPos < currentValue.length) {
serialInputRef.current.setSelectionRange(
currentPos + 1,
currentPos + 1,
);
}
},
};
}, [form]);
return (
<DialogContent>
<DialogHeader>
<DialogTitle>Machine Assignment</DialogTitle>
<p>
for {device.name}
<Hex value={device.configured_address} />
</p>
<DialogDescription>
To assign the device to a machine, select the machine, serial number &
device role.
</DialogDescription>
</DialogHeader>
<Separator />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* machine type dropdown */}
<FormField
control={form.control}
name="machine"
render={({ field }) => (
<FormItem>
<FormLabel>Maschine</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="w-min">
<SelectValue placeholder="Machine" />
</SelectTrigger>
<SelectContent>
{machineProperties.map((machine) => (
<SelectItem
key={machine.machine_identification.machine}
value={machine.machine_identification.machine.toString()}
>
{machine.name} {machine.version}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
<>
<DialogContent
ref={dialogRef}
// Keep dialog open on any outside interaction; closing is manual via controls
onInteractOutside={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Machine Assignment</DialogTitle>
<p>
for {device.name}
<Hex value={device.configured_address} />
</p>
<DialogDescription>
To assign the device to a machine, select the machine, serial number
& device role.
</DialogDescription>
</DialogHeader>
<Separator />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* machine type dropdown */}
<FormField
control={form.control}
name="machine"
render={({ field }) => (
<FormItem>
<FormLabel>Maschine</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="w-min">
<SelectValue placeholder="Machine" />
</SelectTrigger>
<SelectContent>
{machineProperties.map((machine) => (
<SelectItem
key={machine.machine_identification.machine}
value={machine.machine_identification.machine.toString()}
>
{machine.name} {machine.version}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Serial Number */}
<FormField
control={form.control}
name="serial"
render={({ field }) => (
<FormItem>
<FormLabel>Serial</FormLabel>
<FormControl>
<div
ref={serialContainerRef}
className="flex items-center gap-2"
>
<Input
{...field}
ref={(e) => {
field.ref(e);
serialInputRef.current = e;
}}
placeholder="1234"
onFocus={() => setNumpadOpen(true)}
onClick={() => setNumpadOpen(true)}
onBlur={(event) => {
const next = event.relatedTarget as Node | null;
if (
serialContainerRef.current?.contains(next) ||
numpadRef.current?.contains(next)
) {
return;
}
setNumpadOpen(false);
}}
/>
</div>
</FormControl>
<FormDescription>
Serial number of the machine.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Device Role */}
<FormField
control={form.control}
name="role"
disabled={!machinePreset}
render={({ field }) => (
<FormItem>
<FormLabel>Device Role</FormLabel>
<FormControl>
<Select {...field}>
<SelectTrigger className="w-min">
<SelectValue placeholder="Device Role" />
</SelectTrigger>
<SelectContent>
{machinePreset?.device_roles.map((device_role, i) => (
<SelectItem
key={device_role.role}
value={device_role.role.toString()}
disabled={!filteredAllowedDevices[i]}
>
<DeviceRoleComponent device_role={device_role} />
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator />
<Button type="submit" disabled={!form.formState.isValid}>
<Icon name="lu:Save" /> Write
</Button>
<Alert title="Restart mandatory" variant="info">
The device must be restarted for the changes to take effect
</Alert>
</form>
</Form>
</DialogContent>
{/* Numpad as separate window right of dialog */}
{numpadOpen && (
<div
ref={numpadRef}
data-numpad
className="fixed z-[100] w-auto rounded-md border border-neutral-200 bg-white p-4 shadow-md dark:border-neutral-800 dark:bg-neutral-950"
style={{
left: `${numpadPosition.left}px`,
top: `${numpadPosition.top}px`,
transform: "translateY(-50%)",
pointerEvents: "auto",
}}
tabIndex={-1}
onMouseDown={(e) => {
// Prevent clicks on numpad from closing the dialog and stealing focus from input
e.preventDefault();
e.stopPropagation();
// Ensure input field keeps focus
if (serialInputRef.current) {
serialInputRef.current.focus();
}
}}
onClick={(e) => {
// Prevent clicks on numpad from closing the dialog
e.stopPropagation();
// Ensure input field keeps focus
if (serialInputRef.current) {
serialInputRef.current.focus();
}
}}
onKeyDown={(e) => {
// Prevent Escape or other keys from bubbling and closing the dialog
e.stopPropagation();
}}
>
<TouchNumpad
onDigit={numpadHandlers.appendDigit}
onDelete={numpadHandlers.deleteChar}
onCursorLeft={numpadHandlers.moveCursorLeft}
onCursorRight={numpadHandlers.moveCursorRight}
/>
{/* Serial Number */}
<FormField
control={form.control}
name="serial"
render={({ field }) => (
<FormItem>
<FormLabel>Serial</FormLabel>
<FormControl>
<Input placeholder="1234" {...field} />
</FormControl>
<FormDescription>Serial number of the machine.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Device Role */}
<FormField
control={form.control}
name="role"
disabled={!machinePreset}
render={({ field }) => (
<FormItem>
<FormLabel>Device Role</FormLabel>
<FormControl>
<Select {...field}>
<SelectTrigger className="w-min">
<SelectValue placeholder="Device Role" />
</SelectTrigger>
<SelectContent>
{machinePreset?.device_roles.map((device_role, i) => (
<SelectItem
key={device_role.role}
value={device_role.role.toString()}
disabled={!filteredAllowedDevices[i]}
>
<DeviceRoleComponent device_role={device_role} />
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator />
<Button type="submit" disabled={!form.formState.isValid}>
<Icon name="lu:Save" /> Write
</Button>
<Alert title="Restart mandatory" variant="info">
The device must be restarted for the changes to take effect
</Alert>
</form>
</Form>
</DialogContent>
</div>
)}
</>
);
}
@@ -0,0 +1,160 @@
import React, { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useMainNamespace } from "@/client/mainNamespace";
import { useLaser1Namespace } from "@/machines/laser/laser1/laser1Namespace";
import { laser1 } from "@/machines/properties";
import type { MachineIdentificationUnique } from "@/machines/types";
import { Icon } from "@/components/Icon";
/**
* Global manager that toasts laser StateEvent changes.
* If machineIdentification is omitted it will attempt to discover the laser via the main namespace.
*
*/
export function GlobalLaserToastManager({
machineIdentification,
}: {
machineIdentification?: MachineIdentificationUnique;
}) {
const main = useMainNamespace();
const [discovered, setDiscovered] =
useState<MachineIdentificationUnique | null>(null);
// Prefer explicit prop, otherwise use discovered id
const effectiveId = machineIdentification ?? discovered;
// Attempt to discover the single laser machine from the main namespace
useEffect(() => {
if (machineIdentification) return;
const machinesEvent = (main as any)?.machines;
if (!machinesEvent) {
setDiscovered(null);
return;
}
// Defensive shapes: try common structures
let list: any[] = [];
if (Array.isArray(machinesEvent?.data?.machines))
list = machinesEvent.data.machines;
else if (Array.isArray(machinesEvent?.data)) list = machinesEvent.data;
else if (Array.isArray(machinesEvent?.machines))
list = machinesEvent.machines;
else if (machinesEvent && typeof machinesEvent === "object") {
list = Object.values(machinesEvent).filter(
(v) => v && typeof v === "object",
);
}
const match = list.find((entry) => {
const mi =
entry?.machine_identification ||
entry?.machine_identification_unique?.machine_identification ||
entry?.device_machine_identification?.machine_identification;
if (!mi) return false;
return (
mi.vendor === (laser1.machine_identification as any).vendor &&
mi.machine === (laser1.machine_identification as any).machine
);
});
if (match) {
const serial =
match?.serial ??
match?.machine_identification_unique?.serial ??
match?.device_machine_identification?.machine_identification_unique
?.serial ??
null;
if (serial != null && !Number.isNaN(Number(serial))) {
setDiscovered({
machine_identification: laser1.machine_identification,
serial: Number(serial),
});
return;
}
}
setDiscovered(null);
}, [main, machineIdentification]);
return effectiveId ? (
<LaserToastWatcher machineIdentification={effectiveId} />
) : null;
}
/**
* Watches StateEvent for the laser namespace and shows toasts for non-default states.
* Component is non-visual.
*/
function LaserToastWatcher({
machineIdentification,
}: {
machineIdentification: MachineIdentificationUnique;
}) {
const { state } = useLaser1Namespace(machineIdentification);
// Deduplicate toasts by event timestamp
const lastToastTs = useRef<number | string | null>(null);
useEffect(() => {
if (!state) return;
const eventTs = (state as any)?.ts ?? null;
const inTolerance = (state as any)?.data?.laser_state.in_tolerance;
const isDefault = !!(state as any)?.data?.is_default_state;
const toastId = eventTs?.toString() ?? String(Date.now());
if (isDefault) return;
// Only show toast if laser is out of tolerance and not already shown for this timestamp
if (!inTolerance && lastToastTs.current !== eventTs) {
lastToastTs.current = eventTs;
// Sonner toast call
toast(
<div className="flex w-100 flex-col gap-3 rounded-xl border border-red-400 bg-red-600 p-4 text-white shadow-xl backdrop-blur-sm transition-all duration-300">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon
name="lu:TriangleAlert"
className="h-5 w-5 text-yellow-200"
/>
<strong className="text-lg font-semibold tracking-wide">
Warning
</strong>
</div>
<button
className="rounded-md p-1 text-2xl font-bold text-white/80 hover:bg-red-500 hover:text-white focus:ring-2 focus:ring-white/30 focus:outline-none"
onClick={() => {
toast.dismiss(toastId);
lastToastTs.current = null;
}}
aria-label="Close"
>
×
</button>
</div>
<p className="text-base leading-snug text-red-50">
Laser diameter is <strong>out of tolerance</strong>.<br />
Please check the filament immediately.
</p>
</div>,
{
id: toastId,
duration: Infinity,
position: "top-center",
style: {
background: "transparent",
padding: 0,
boxShadow: "none",
border: "none",
},
},
);
}
if (inTolerance) {
lastToastTs.current = null;
}
}, [state]);
return null;
}
+6
View File
@@ -30,6 +30,12 @@ export function SetupPage() {
title: "Troubleshoot",
icon: "lu:MessageSquareWarning",
},
{
link: "metrics",
activeLink: "metrics",
title: "Metrics",
icon: "lu:Activity",
},
]}
/>
);
-1
View File
@@ -17,7 +17,6 @@ export function TroubleshootPage() {
// Get backend logs for display
const backendLogs = getLogsBySource("qitech-control-server");
const logLines = backendLogs.map((log) => log.raw);
console.log(backendLogs);
const handleRebootHmi = async () => {
setIsRebootLoading(true);
+2 -1
View File
@@ -16,7 +16,8 @@
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@root/*": ["../*"]
},
"outDir": "dist",
"moduleResolution": "node",
+1
View File
@@ -30,6 +30,7 @@ export default defineConfig((env) => {
preserveSymlinks: true,
alias: {
"@": path.resolve(__dirname, "./src"),
"@root": path.resolve(__dirname, ".."),
},
},
clearScreen: false,
+2
View File
@@ -54,6 +54,7 @@ use ethercrab::{MainDevice, SubDeviceIdentity};
use smol::lock::RwLock;
use std::{any::Any, fmt::Debug, sync::Arc};
use wago_750_354::{WAGO_750_354_IDENTITY_A, Wago750_354};
use wago_modules::ip20_ec_di8_do8::{IP20_EC_DI8_DO8_IDENTITY, IP20EcDi8Do8};
#[derive(Debug, Clone, Copy)]
pub struct Module {
@@ -214,6 +215,7 @@ pub fn device_from_subdevice_identity_tuple(
) -> Result<Arc<RwLock<dyn EthercatDevice>>, anyhow::Error> {
match subdevice_identity_tuple {
WAGO_750_354_IDENTITY_A => Ok(Arc::new(RwLock::new(Wago750_354::new()))),
IP20_EC_DI8_DO8_IDENTITY => Ok(Arc::new(RwLock::new(IP20EcDi8Do8::new()))),
EK1100_IDENTITY_A => Ok(Arc::new(RwLock::new(EK1100::new()))),
EL1002_IDENTITY_A => Ok(Arc::new(RwLock::new(EL1002::new()))),
EL1008_IDENTITY_A => Ok(Arc::new(RwLock::new(EL1008::new()))),
@@ -0,0 +1,489 @@
use super::*;
use crate::devices::{
EthercatDevice, EthercatDeviceProcessing, EthercatDeviceUsed, NewEthercatDevice,
SubDeviceIdentityTuple,
};
use crate::{
devices::{DynamicEthercatDevice, Module},
helpers::ethercrab_types::EthercrabSubDevicePreoperational,
io::{
digital_input::{DigitalInputDevice, DigitalInputInput},
digital_output::{DigitalOutputDevice, DigitalOutputOutput},
},
};
use anyhow::Error;
use smol::lock::RwLock;
use std::sync::Arc;
const MODULE_COUNT_INDEX: (u16, u8) = (0xf050, 0x00);
const TX_MAPPING_INDEX: (u16, u8) = (0x1c13, 0x00);
const RX_MAPPING_INDEX: (u16, u8) = (0x1c12, 0x00);
/// Digital output port enumeration for the 8 outputs
#[derive(Debug, Clone, Copy)]
pub enum IP20EcDi8Do8OutputPort {
DO1,
DO2,
DO3,
DO4,
DO5,
DO6,
DO7,
DO8,
}
impl From<IP20EcDi8Do8OutputPort> for usize {
fn from(value: IP20EcDi8Do8OutputPort) -> Self {
match value {
IP20EcDi8Do8OutputPort::DO1 => 0,
IP20EcDi8Do8OutputPort::DO2 => 1,
IP20EcDi8Do8OutputPort::DO3 => 2,
IP20EcDi8Do8OutputPort::DO4 => 3,
IP20EcDi8Do8OutputPort::DO5 => 4,
IP20EcDi8Do8OutputPort::DO6 => 5,
IP20EcDi8Do8OutputPort::DO7 => 6,
IP20EcDi8Do8OutputPort::DO8 => 7,
}
}
}
/// Digital input port enumeration for the 8 inputs
#[derive(Debug, Clone, Copy)]
pub enum IP20EcDi8Do8InputPort {
DI1,
DI2,
DI3,
DI4,
DI5,
DI6,
DI7,
DI8,
}
impl From<IP20EcDi8Do8InputPort> for usize {
fn from(value: IP20EcDi8Do8InputPort) -> Self {
match value {
IP20EcDi8Do8InputPort::DI1 => 0,
IP20EcDi8Do8InputPort::DI2 => 1,
IP20EcDi8Do8InputPort::DI3 => 2,
IP20EcDi8Do8InputPort::DI4 => 3,
IP20EcDi8Do8InputPort::DI5 => 4,
IP20EcDi8Do8InputPort::DI6 => 5,
IP20EcDi8Do8InputPort::DI7 => 6,
IP20EcDi8Do8InputPort::DI8 => 7,
}
}
}
/// RX PDO structure (Master → Device, Outputs)
/// Mapped at 0x7000:01-08, 8 bits total
#[derive(Clone, Default)]
pub struct IP20EcDi8Do8RxPdo {
pub do1: bool,
pub do2: bool,
pub do3: bool,
pub do4: bool,
pub do5: bool,
pub do6: bool,
pub do7: bool,
pub do8: bool,
}
/// TX PDO structure (Device → Master, Inputs)
/// Mapped at 0x6000:01-08, 8 bits total
#[derive(Clone, Default)]
pub struct IP20EcDi8Do8TxPdo {
pub di1: bool,
pub di2: bool,
pub di3: bool,
pub di4: bool,
pub di5: bool,
pub di6: bool,
pub di7: bool,
pub di8: bool,
}
/// Wago 0x741:0x117b6722 bus coupler with digital I/O
/// This device has 8x Digital Input and 8x Digital Output terminals
pub struct IP20EcDi8Do8 {
is_used: bool,
pub slots: [Option<Module>; 64],
pub slot_devices: [Option<Arc<RwLock<dyn DynamicEthercatDevice>>>; 64],
pub dev_count: usize,
pub module_count: usize,
rx_offsets: Vec<usize>,
tx_offsets: Vec<usize>,
tx_size: usize,
rx_size: usize,
rx_pdo: IP20EcDi8Do8RxPdo,
tx_pdo: IP20EcDi8Do8TxPdo,
}
impl EthercatDevice for IP20EcDi8Do8 {
fn input(
&mut self,
input: &bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
) -> Result<(), anyhow::Error> {
// Read the 8 digital inputs (bits 0-7)
if input.len() >= 8 {
self.tx_pdo.di1 = input[0];
self.tx_pdo.di2 = input[1];
self.tx_pdo.di3 = input[2];
self.tx_pdo.di4 = input[3];
self.tx_pdo.di5 = input[4];
self.tx_pdo.di6 = input[5];
self.tx_pdo.di7 = input[6];
self.tx_pdo.di8 = input[7];
}
// Handle slot devices if any
for slot_device in &mut self.slot_devices {
match slot_device {
Some(device) => {
let mut d = device.write_blocking();
let _ = d.input(input);
drop(d);
}
None => break,
}
}
Ok(())
}
fn input_len(&self) -> usize {
if self.tx_size > 0 {
self.tx_size
} else {
8 // 8 digital inputs
}
}
fn output(
&self,
output: &mut bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
) -> Result<(), anyhow::Error> {
// Write the 8 digital outputs (bits 0-7)
if output.len() >= 8 {
output.set(0, self.rx_pdo.do1);
output.set(1, self.rx_pdo.do2);
output.set(2, self.rx_pdo.do3);
output.set(3, self.rx_pdo.do4);
output.set(4, self.rx_pdo.do5);
output.set(5, self.rx_pdo.do6);
output.set(6, self.rx_pdo.do7);
output.set(7, self.rx_pdo.do8);
}
// Handle slot devices if any
for slot_device in &self.slot_devices {
match slot_device {
Some(device) => {
let d = device.read_blocking();
let _ = d.output(output);
drop(d);
}
None => break,
}
}
Ok(())
}
fn output_len(&self) -> usize {
if self.rx_size > 0 {
self.rx_size
} else {
8 // 8 digital outputs
}
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn is_module(&self) -> bool {
false
}
fn get_module(&self) -> Option<Module> {
None
}
fn set_module(&mut self, module: Module) {
self.slots[self.module_count] = Some(module);
self.module_count += 1;
self.tx_size += module.tx_offset;
self.rx_size += module.rx_offset;
}
}
impl EthercatDeviceUsed for IP20EcDi8Do8 {
fn is_used(&self) -> bool {
self.is_used
}
fn set_used(&mut self, used: bool) {
self.is_used = used;
}
}
impl EthercatDeviceProcessing for IP20EcDi8Do8 {}
impl NewEthercatDevice for IP20EcDi8Do8 {
fn new() -> Self {
Self {
is_used: false,
slots: [const { None }; 64],
slot_devices: [const { None }; 64],
module_count: 0,
dev_count: 0,
tx_size: 0,
rx_size: 0,
rx_offsets: vec![],
tx_offsets: vec![],
rx_pdo: IP20EcDi8Do8RxPdo::default(),
tx_pdo: IP20EcDi8Do8TxPdo::default(),
}
}
}
impl std::fmt::Debug for IP20EcDi8Do8 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "WagoMaster_0x741_0x117b6722")
}
}
impl DigitalInputDevice<IP20EcDi8Do8InputPort> for IP20EcDi8Do8 {
fn get_input(&self, port: IP20EcDi8Do8InputPort) -> Result<DigitalInputInput, anyhow::Error> {
Ok(DigitalInputInput {
value: match port {
IP20EcDi8Do8InputPort::DI1 => self.tx_pdo.di1,
IP20EcDi8Do8InputPort::DI2 => self.tx_pdo.di2,
IP20EcDi8Do8InputPort::DI3 => self.tx_pdo.di3,
IP20EcDi8Do8InputPort::DI4 => self.tx_pdo.di4,
IP20EcDi8Do8InputPort::DI5 => self.tx_pdo.di5,
IP20EcDi8Do8InputPort::DI6 => self.tx_pdo.di6,
IP20EcDi8Do8InputPort::DI7 => self.tx_pdo.di7,
IP20EcDi8Do8InputPort::DI8 => self.tx_pdo.di8,
},
})
}
}
impl DigitalOutputDevice<IP20EcDi8Do8OutputPort> for IP20EcDi8Do8 {
fn set_output(&mut self, port: IP20EcDi8Do8OutputPort, value: DigitalOutputOutput) {
let output_value: bool = value.into();
match port {
IP20EcDi8Do8OutputPort::DO1 => self.rx_pdo.do1 = output_value,
IP20EcDi8Do8OutputPort::DO2 => self.rx_pdo.do2 = output_value,
IP20EcDi8Do8OutputPort::DO3 => self.rx_pdo.do3 = output_value,
IP20EcDi8Do8OutputPort::DO4 => self.rx_pdo.do4 = output_value,
IP20EcDi8Do8OutputPort::DO5 => self.rx_pdo.do5 = output_value,
IP20EcDi8Do8OutputPort::DO6 => self.rx_pdo.do6 = output_value,
IP20EcDi8Do8OutputPort::DO7 => self.rx_pdo.do7 = output_value,
IP20EcDi8Do8OutputPort::DO8 => self.rx_pdo.do8 = output_value,
}
}
fn get_output(&self, port: IP20EcDi8Do8OutputPort) -> DigitalOutputOutput {
let current_value = match port {
IP20EcDi8Do8OutputPort::DO1 => self.rx_pdo.do1,
IP20EcDi8Do8OutputPort::DO2 => self.rx_pdo.do2,
IP20EcDi8Do8OutputPort::DO3 => self.rx_pdo.do3,
IP20EcDi8Do8OutputPort::DO4 => self.rx_pdo.do4,
IP20EcDi8Do8OutputPort::DO5 => self.rx_pdo.do5,
IP20EcDi8Do8OutputPort::DO6 => self.rx_pdo.do6,
IP20EcDi8Do8OutputPort::DO7 => self.rx_pdo.do7,
IP20EcDi8Do8OutputPort::DO8 => self.rx_pdo.do8,
};
DigitalOutputOutput(current_value)
}
}
impl IP20EcDi8Do8 {
pub async fn get_pdo_offsets<'a>(
&mut self,
device: &EthercrabSubDevicePreoperational<'a>,
get_tx: bool,
) -> Result<(), Error> {
let mut vec: Vec<usize> = vec![];
let mut bit_offset = 0;
let start_subindex = 0x1;
let index = match get_tx {
true => (TX_MAPPING_INDEX.0, TX_MAPPING_INDEX.1),
false => (RX_MAPPING_INDEX.0, RX_MAPPING_INDEX.1),
};
let count = device.sdo_read::<u8>(index.0, index.1).await?;
for i in 0..count {
vec.push(bit_offset);
let pdo_index = device.sdo_read(index.0, start_subindex + i).await?;
if pdo_index != 0 {
let pdo_map_count = device.sdo_read::<u8>(pdo_index, 0).await?;
// Iterate over every PDO Mapped entry and add it to the cumulative bit offset
for j in 0..pdo_map_count {
let pdo_mapping: u32 = device.sdo_read(pdo_index, 1 + j).await?;
// We only need / Want the bit len, which we extract with a bitmask extracting the lsb
let bit_length = (pdo_mapping & 0xFF) as u8;
bit_offset += bit_length as usize;
}
}
}
if get_tx {
self.tx_offsets = vec;
} else {
self.rx_offsets = vec;
}
Ok(())
}
pub async fn get_module_count<'a>(
device: &EthercrabSubDevicePreoperational<'a>,
) -> Result<usize, Error> {
match device
.sdo_read::<u8>(MODULE_COUNT_INDEX.0, MODULE_COUNT_INDEX.1)
.await
{
Ok(value) => Ok(value as usize),
Err(_e) => Ok(0),
}
}
pub async fn get_modules<'a>(
device: &EthercrabSubDevicePreoperational<'a>,
module_count: usize,
) -> Result<Vec<crate::devices::Module>, Error> {
const MODULES_START_ADDR: u16 = 0x9000;
const MODULE_IDENT_SUBINDEX: u8 = 0x0a;
let mut modules: Vec<Module> = vec![];
for i in 0..module_count {
let module_addr = MODULES_START_ADDR + (i * 0x10) as u16;
let ident_iom = device
.sdo_read::<u32>(module_addr, MODULE_IDENT_SUBINDEX)
.await?;
// For Wago the IOM will be the product ID
let mut module = Module {
slot: i as u16,
belongs_to_addr: device.configured_address(),
vendor_id: device.identity().vendor_id,
product_id: ident_iom,
has_tx: false,
has_rx: false,
tx_offset: 0,
rx_offset: 0,
};
match ident_iom {
// Add Module idents here when discovered
wago_750_1506::WAGO_750_1506_PRODUCT_ID => {
module.has_tx = true;
module.has_rx = true;
}
wago_750_501::WAGO_750_501_PRODUCT_ID => {
module.has_tx = false;
module.has_rx = true;
}
wago_750_652::WAGO_750_652_PRODUCT_ID => {
module.has_tx = true;
module.has_rx = true;
}
_ => {}
}
modules.push(module);
}
Ok(modules)
}
/// Call after all modules have been added
pub fn init_slot_modules<'a>(&mut self, device: &EthercrabSubDevicePreoperational<'a>) {
// Already initialized
if self.dev_count != 0 {
return;
}
let mut tx_index = 1;
let mut rx_index = 1;
smol::block_on(async {
let _ = self.get_pdo_offsets(device, true).await;
let _ = self.get_pdo_offsets(device, false).await;
});
for module in self.slots {
match module {
Some(m) => {
// Map ModuleIdent's to Terminals
let dev: Arc<RwLock<dyn DynamicEthercatDevice>> =
match (m.vendor_id, m.product_id) {
wago_750_501::WAGO_750_501_MODULE_IDENT => {
Arc::new(RwLock::new(wago_750_501::Wago750_501::new()))
}
wago_750_1506::WAGO_750_1506_MODULE_IDENT => {
Arc::new(RwLock::new(wago_750_1506::Wago750_1506::new()))
}
wago_750_652::WAGO_750_652_MODULE_IDENT => {
Arc::new(RwLock::new(wago_750_652::Wago750_652::new()))
}
_ => {
return;
}
};
let tx_pdo_offset = self.tx_offsets.get(tx_index as usize);
if m.has_tx {
tx_index += 1;
}
let rx_pdo_offset = self.rx_offsets.get(rx_index as usize);
if m.has_rx {
rx_index += 1;
}
let mut dev_guard = dev.write_blocking();
match tx_pdo_offset {
Some(offset) => dev_guard.set_tx_offset(*offset),
None => (),
}
match rx_pdo_offset {
Some(offset) => dev_guard.set_rx_offset(*offset),
None => (),
}
drop(dev_guard);
self.slot_devices[self.dev_count] = Some(dev);
self.dev_count += 1;
}
None => break,
}
}
}
pub async fn initialize_modules<'a>(
device: &EthercrabSubDevicePreoperational<'a>,
) -> Result<Vec<Module>, Error> {
let count = match IP20EcDi8Do8::get_module_count(device).await {
Ok(count) => count,
Err(e) => return Err(e),
};
if count == 0 {
return Ok(vec![]);
}
let modules = IP20EcDi8Do8::get_modules(device, count).await?;
Ok(modules)
}
}
pub const IP20_EC_DI8_DO8_VENDOR_ID: u32 = 0x741;
pub const IP20_EC_DI8_DO8_PRODUCT_ID: u32 = 0x117b6722;
pub const IP20_EC_DI8_DO8_REVISION: u32 = 0x1;
pub const IP20_EC_DI8_DO8_IDENTITY: SubDeviceIdentityTuple = (
IP20_EC_DI8_DO8_VENDOR_ID,
IP20_EC_DI8_DO8_PRODUCT_ID,
IP20_EC_DI8_DO8_REVISION,
);
@@ -1,3 +1,4 @@
pub mod ip20_ec_di8_do8;
pub mod wago_750_1506;
pub mod wago_750_501;
pub mod wago_750_652;
@@ -0,0 +1,48 @@
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::{MachineAct, analog_input_test_machine::AnalogInputTestMachine};
impl MachineAct for AnalogInputTestMachine {
fn act_machine_message(&mut self, msg: crate::MachineMessage) {
match msg {
crate::MachineMessage::SubscribeNamespace(namespace) => {
self.namespace.namespace = Some(namespace);
self.emit_measurement_rate();
}
crate::MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
crate::MachineMessage::HttpApiJsonRequest(value) => {
use crate::MachineApi;
let _res = self.api_mutate(value);
}
crate::MachineMessage::ConnectToMachine(_machine_connection) => {}
crate::MachineMessage::DisconnectMachine(_machine_connection) => {}
}
}
fn act(&mut self, now: std::time::Instant) {
let recv = self.api_receiver.try_recv();
if let Ok(msg) = recv {
self.act_machine_message(msg);
}
if now.duration_since(self.last_measurement)
> Duration::from_secs_f64(1.0 / self.measurement_rate_hz)
{
let measured_value = self.analog_input.get_physical();
match measured_value {
ethercat_hal::io::analog_input::physical::AnalogInputValue::Potential(
_quantity,
) => {
// Don't do anything
}
ethercat_hal::io::analog_input::physical::AnalogInputValue::Current(quantity) => {
let now_milliseconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Now is expected to be after UNIX_EPOCH")
.as_millis();
self.emit_measurement(quantity.value, now_milliseconds);
}
}
self.last_measurement = Instant::now();
}
}
}
@@ -0,0 +1,70 @@
use std::sync::Arc;
use control_core::socketio::{
event::{Event, GenericEvent},
namespace::{
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_first_and_last_event,
},
};
use serde::{Deserialize, Serialize};
use crate::{MachineApi, analog_input_test_machine::AnalogInputTestMachine};
#[derive(Debug, Clone)]
pub struct AnalogInputTestMachineNamespace {
pub namespace: Option<Namespace>,
}
#[derive(Serialize, Debug, Clone)]
pub enum MeasurementEvent {
MeasurementRateHz(f64),
Measurement(f64, String),
}
impl NamespaceCacheingLogic<AnalogInputTestMachineEvents> for AnalogInputTestMachineNamespace {
fn emit(&mut self, events: AnalogInputTestMachineEvents) {
let event = Arc::new(events.event_value());
let buffer_fn = events.event_cache_fn();
if let Some(ns) = &mut self.namespace {
ns.emit(event, &buffer_fn);
}
}
}
impl CacheableEvents<AnalogInputTestMachineEvents> for AnalogInputTestMachineEvents {
fn event_value(&self) -> GenericEvent {
match self {
AnalogInputTestMachineEvents::State(event) => event.clone().into(),
}
}
fn event_cache_fn(&self) -> CacheFn {
cache_first_and_last_event()
}
}
pub enum AnalogInputTestMachineEvents {
State(Event<MeasurementEvent>),
}
#[derive(Deserialize)]
pub struct Mutation {
measurement_rate_hz: i32,
}
impl MachineApi for AnalogInputTestMachine {
fn api_get_sender(&self) -> smol::channel::Sender<crate::MachineMessage> {
self.api_sender.clone()
}
fn api_mutate(&mut self, value: serde_json::Value) -> Result<(), anyhow::Error> {
let mutation: Mutation = serde_json::from_value(value)?;
self.measurement_rate_hz = f64::from(mutation.measurement_rate_hz);
self.emit_measurement_rate();
Ok(())
}
fn api_event_namespace(&mut self) -> Option<control_core::socketio::namespace::Namespace> {
self.namespace.namespace.clone()
}
}
@@ -0,0 +1,66 @@
use std::time::Instant;
use control_core::socketio::{event::Event, namespace::NamespaceCacheingLogic};
use ethercat_hal::io::analog_input::AnalogInput;
use smol::channel::{Receiver, Sender};
use crate::{
ANALOG_INPUT_TEST_MACHINE, AsyncThreadMessage, Machine, MachineMessage, VENDOR_QITECH,
analog_input_test_machine::api::{
AnalogInputTestMachineEvents, AnalogInputTestMachineNamespace, MeasurementEvent,
},
machine_identification::{MachineIdentification, MachineIdentificationUnique},
};
pub mod act;
pub mod api;
pub mod new;
#[derive(Debug)]
pub struct AnalogInputTestMachine {
api_receiver: Receiver<MachineMessage>,
api_sender: Sender<MachineMessage>,
machine_identification_unique: MachineIdentificationUnique,
main_sender: Option<Sender<AsyncThreadMessage>>,
namespace: AnalogInputTestMachineNamespace,
last_measurement: Instant,
measurement_rate_hz: f64,
analog_input: AnalogInput,
}
impl Machine for AnalogInputTestMachine {
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
self.machine_identification_unique.clone()
}
fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>> {
self.main_sender.clone()
}
}
impl AnalogInputTestMachine {
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
vendor: VENDOR_QITECH,
machine: ANALOG_INPUT_TEST_MACHINE,
};
pub fn emit_measurement(&mut self, value: f64, unix_timestamp_ms: u128) {
let event = MeasurementEvent::Measurement(value, unix_timestamp_ms.to_string());
self.namespace
.emit(AnalogInputTestMachineEvents::State(Event::new(
"Measurement",
event.clone(),
)));
}
pub fn emit_measurement_rate(&mut self) {
let event = MeasurementEvent::MeasurementRateHz(self.measurement_rate_hz);
self.namespace
.emit(AnalogInputTestMachineEvents::State(Event::new(
"MeasurementRateHz",
event.clone(),
)));
}
}
@@ -0,0 +1,64 @@
use std::time::Instant;
use anyhow::Error;
use ethercat_hal::{
devices::el3021::{EL3021, EL3021_IDENTITY_A, EL3021Port},
io::analog_input::AnalogInput,
};
use smol::{block_on, channel::unbounded};
use crate::{
EtherCATMachine, EtherCATParams, MachineNewHardware,
analog_input_test_machine::{AnalogInputTestMachine, api::AnalogInputTestMachineNamespace},
get_ethercat_device, validate_no_role_dublicates, validate_same_machine_identification_unique,
};
impl EtherCATMachine for AnalogInputTestMachine {
fn new<'maindevice>(params: &EtherCATParams) -> Result<Self, Error> {
// validate general stuff
let device_identification = params
.device_group
.iter()
.map(|device_identification| device_identification.clone())
.collect::<Vec<_>>();
validate_same_machine_identification_unique(&device_identification)?;
validate_no_role_dublicates(&device_identification)?;
let hardware = match &params.hardware {
MachineNewHardware::Ethercat(x) => x,
_ => {
return Err(anyhow::anyhow!(
"[{}::EtherCATMachine/TestMachine::new] MachineNewHardware is not Ethercat",
module_path!()
));
}
};
block_on(async {
let el3021 =
get_ethercat_device::<EL3021>(hardware, params, 1, [EL3021_IDENTITY_A].to_vec())
.await?
.0;
let ai1 = AnalogInput::new(el3021.clone(), EL3021Port::AI1);
let (sender, receiver) = unbounded();
let namespace = AnalogInputTestMachineNamespace {
namespace: params.namespace.clone(),
};
let new_analog_input_test_machine = Self {
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
main_sender: params.main_sender.clone(),
namespace,
last_measurement: Instant::now(),
measurement_rate_hz: 1.0,
analog_input: ai1,
};
Ok(new_analog_input_test_machine)
})
}
}
+1 -18
View File
@@ -28,7 +28,6 @@ pub struct Controller {
pub cooling_relais: DigitalOutput,
pub heating_relais_1: DigitalOutput,
pub heating_relais_2: DigitalOutput,
pub temperature_sensor_in: TemperatureInput,
pub temperature_sensor_out: TemperatureInput,
@@ -55,7 +54,6 @@ impl Controller {
cooling_controller: AnalogOutput,
cooling_relais: DigitalOutput,
heating_relais_1: DigitalOutput,
heating_relais_2: DigitalOutput,
temp_sensor_in: TemperatureInput,
temp_sensor_out: TemperatureInput,
@@ -78,7 +76,6 @@ impl Controller {
cooling_controller: cooling_controller,
cooling_relais: cooling_relais,
heating_relais_1: heating_relais_1,
heating_relais_2: heating_relais_2,
cooling_allowed: false,
heating_allowed: false,
temperature_sensor_in: temp_sensor_in,
@@ -95,15 +92,11 @@ impl Controller {
}
pub fn turn_pump_off(&mut self) {
tracing::info!("turn_pump_off");
self.flow.pump = false;
self.pump_relais.set(false);
}
pub fn turn_pump_on(&mut self) {
tracing::info!("turn_pump_on");
self.flow.pump = true;
self.pump_relais.set(true);
}
@@ -180,32 +173,23 @@ impl Controller {
}
pub fn turn_cooling_on(&mut self) {
tracing::info!("turn_cooling_on");
self.cooling_relais.set(true);
self.cooling_controller.set(10.0);
self.temperature.cooling = true;
}
pub fn turn_cooling_off(&mut self) {
tracing::info!("turn_cooling_off");
self.cooling_relais.set(false);
self.temperature.cooling = false;
}
pub fn turn_heating_on(&mut self) {
tracing::info!("turn_heating_on");
self.heating_relais_1.set(true);
self.heating_relais_2.set(true);
self.temperature.heating = true;
}
pub fn turn_heating_off(&mut self) {
tracing::info!("turn_heating_off");
self.heating_relais_1.set(false);
self.heating_relais_2.set(false);
self.temperature.heating = false;
}
@@ -220,8 +204,7 @@ impl Controller {
pub fn get_flow(&mut self) -> VolumeRate {
let value = match self.flow_sensor.get_frequency_value() {
Ok(val) => val,
Err(e) => {
tracing::debug!("Error calculating frequency: {}", e);
Err(_e) => {
return VolumeRate::new::<liter_per_minute>(0.0);
}
};
+1 -3
View File
@@ -17,15 +17,13 @@ use crate::{
machine_identification::MachineIdentification,
};
use super::machine_identification::MachineIdentificationUnique;
use smol::channel::{Receiver, Sender};
use super::machine_identification::MachineIdentificationUnique;
pub mod act;
pub mod api;
pub mod controller;
// pub mod flow_controller;
pub mod new;
// pub mod temperature_controller;
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub enum AquaPathV1Mode {
+18 -18
View File
@@ -1,5 +1,5 @@
use crate::{
MachineNewHardware, MachineNewParams, MachineNewTrait, get_ethercat_device,
EtherCATMachine, EtherCATParams, MachineNewHardware, get_ethercat_device,
get_subdevice_by_index, validate_no_role_dublicates,
validate_same_machine_identification_unique,
};
@@ -12,7 +12,7 @@ use ethercat_hal::{
coe::ConfigurableDevice,
devices::{
ek1100::{EK1100, EK1100_IDENTITY_A},
el2008::{EL2008, EL2008_IDENTITY_A, EL2008Port},
el2008::{EL2008, EL2008_IDENTITY_A, EL2008_IDENTITY_B, EL2008Port},
el3204::{EL3204, EL3204_IDENTITY_A, EL3204_IDENTITY_B, EL3204Port},
el4002::{EL4002, EL4002_IDENTITY_A, EL4002Port},
el5152::{
@@ -28,8 +28,8 @@ use ethercat_hal::{
use std::time::{Duration, Instant};
use units::thermodynamic_temperature::{ThermodynamicTemperature, degree_celsius};
impl MachineNewTrait for AquaPathV1 {
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
impl EtherCATMachine for AquaPathV1 {
fn new<'maindevice>(params: &EtherCATParams) -> Result<Self, Error> {
// validate general stuff
let device_identification = params
.device_group
@@ -55,10 +55,14 @@ impl MachineNewTrait for AquaPathV1 {
get_ethercat_device::<EK1100>(hardware, params, 0, [EK1100_IDENTITY_A].to_vec());
// Role 1 - EL2008 Digital Output Module
let el2008 =
get_ethercat_device::<EL2008>(hardware, params, 1, [EL2008_IDENTITY_A].to_vec())
.await?
.0;
let el2008 = get_ethercat_device::<EL2008>(
hardware,
params,
1,
[EL2008_IDENTITY_A, EL2008_IDENTITY_B].to_vec(),
)
.await?
.0;
// Role 2 - EL4002 Analog Output Module
let el4002 =
@@ -108,8 +112,7 @@ impl MachineNewTrait for AquaPathV1 {
//phys 5
let do2 = DigitalOutput::new(el2008.clone(), EL2008Port::DO2);
//heating
//phys 2
let do3 = DigitalOutput::new(el2008.clone(), EL2008Port::DO3);
//phys 6
let do4 = DigitalOutput::new(el2008.clone(), EL2008Port::DO4);
//phys 3
@@ -117,8 +120,7 @@ impl MachineNewTrait for AquaPathV1 {
//phys 7
let do6 = DigitalOutput::new(el2008.clone(), EL2008Port::DO6);
//cooling power cut
//phys 4
let do7 = DigitalOutput::new(el2008.clone(), EL2008Port::DO7);
//phys 8
let do8 = DigitalOutput::new(el2008.clone(), EL2008Port::DO8);
@@ -133,9 +135,8 @@ impl MachineNewTrait for AquaPathV1 {
Temperature::default(),
ThermodynamicTemperature::new::<degree_celsius>(25.0),
ao1,
do7,
do3,
do5,
do4,
do2,
t1,
t2,
Flow::default(),
@@ -152,17 +153,16 @@ impl MachineNewTrait for AquaPathV1 {
ThermodynamicTemperature::new::<degree_celsius>(25.0),
ao2,
do8,
do4,
do6,
t3,
t4,
Flow::default(),
do2,
do5,
enc2,
);
let (sender, receiver) = smol::channel::unbounded();
let mut water_cooling = Self {
main_sender: params.main_thread_channel.clone(),
main_sender: params.main_sender.clone(),
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
+4 -4
View File
@@ -18,16 +18,16 @@ use ethercat_hal::{
};
use crate::{
MachineNewHardware, MachineNewHardwareEthercat, MachineNewParams, MachineNewTrait,
EtherCATMachine, EtherCATParams, MachineNewHardware, MachineNewHardwareEthercat,
buffer1::BufferV1Mode, get_ethercat_device, validate_same_machine_identification_unique,
};
use crate::{buffer1::buffer_tower_controller::BufferTowerController, validate_no_role_dublicates};
use super::{BufferV1, api::Buffer1Namespace};
impl MachineNewTrait for BufferV1 {
impl EtherCATMachine for BufferV1 {
fn new<'maindevice>(
params: &MachineNewParams<'maindevice, '_, '_, '_, '_, '_, '_>,
params: &EtherCATParams<'maindevice, '_, '_, '_, '_, '_, '_>,
) -> Result<Self, Error> {
// validate general stuff
let device_identification = params.device_group.to_vec();
@@ -128,7 +128,7 @@ impl MachineNewTrait for BufferV1 {
// create buffer instance
let mut buffer: BufferV1 = Self {
main_sender: params.main_thread_channel.clone(),
main_sender: params.main_sender.clone(),
api_receiver: receiver,
api_sender: sender,
namespace: Buffer1Namespace {
+4 -4
View File
@@ -1,5 +1,5 @@
use crate::{
MachineNewHardware, MachineNewParams, MachineNewTrait,
EtherCATMachine, EtherCATParams, MachineNewHardware,
extruder1::{
ExtruderV2Mode,
api::{
@@ -12,8 +12,8 @@ use crate::{
},
};
impl MachineNewTrait for ExtruderV2 {
fn new(params: &MachineNewParams<'_, '_, '_, '_, '_, '_, '_>) -> Result<Self, anyhow::Error>
impl EtherCATMachine for ExtruderV2 {
fn new(params: &EtherCATParams<'_, '_, '_, '_, '_, '_, '_>) -> Result<Self, anyhow::Error>
where
Self: Sized,
{
@@ -35,7 +35,7 @@ impl MachineNewTrait for ExtruderV2 {
let (sender, receiver) = smol::channel::unbounded();
let mut extruder_mock_machine = Self {
main_sender: params.main_thread_channel.clone(),
main_sender: params.main_sender.clone(),
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
+1
View File
@@ -14,6 +14,7 @@ use units::electric_current::ampere;
#[cfg(not(feature = "mock-machine"))]
use units::electric_potential::volt;
#[cfg(not(feature = "mock-machine"))]
use crate::{AsyncThreadMessage, Machine};
use units::f64::*;
use units::thermodynamic_temperature::degree_celsius;
+4 -4
View File
@@ -1,5 +1,5 @@
#[cfg(not(feature = "mock-machine"))]
use crate::{MachineNewParams, MachineNewTrait, get_ethercat_device};
use crate::{EtherCATMachine, EtherCATParams, get_ethercat_device};
#[cfg(not(feature = "mock-machine"))]
use anyhow::Error;
@@ -56,8 +56,8 @@ use super::{
};
#[cfg(not(feature = "mock-machine"))]
impl MachineNewTrait for ExtruderV2 {
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
impl EtherCATMachine for ExtruderV2 {
fn new<'maindevice>(params: &EtherCATParams) -> Result<Self, Error> {
// validate general stuff
use crate::{
MachineNewHardware, MachineNewHardwareEthercat, validate_no_role_dublicates,
@@ -224,7 +224,7 @@ impl MachineNewTrait for ExtruderV2 {
let (sender, receiver) = smol::channel::unbounded();
let mut extruder: ExtruderV2 = Self {
main_sender: params.main_thread_channel.clone(),
main_sender: params.main_sender.clone(),
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
+1 -1
View File
@@ -1,8 +1,8 @@
use crate::extruder1::{
ExtruderV2Mode, HeatingType,
api::{ExtruderV2Events, LiveValuesEvent, ModeState, PidSettings, StateEvent, TemperaturePid},
mock::ExtruderV2,
};
use crate::extruder2::mock::ExtruderV2;
use control_core::{
helpers::hasher_serializer::hash_with_serde_model,
+2 -2
View File
@@ -16,7 +16,7 @@ use std::time::Instant;
#[cfg(feature = "mock-machine")]
use crate::{
MACHINE_EXTRUDER_V1, VENDOR_QITECH,
MACHINE_EXTRUDER_V2, VENDOR_QITECH,
extruder1::{
ExtruderV2Mode,
api::{
@@ -124,6 +124,6 @@ pub struct ExtruderV2 {
impl ExtruderV2 {
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
vendor: VENDOR_QITECH,
machine: MACHINE_EXTRUDER_V1,
machine: MACHINE_EXTRUDER_V2,
};
}
+5 -5
View File
@@ -1,5 +1,5 @@
use crate::{
MachineNewHardware, MachineNewParams, MachineNewTrait,
EtherCATMachine, EtherCATParams, MachineNewHardware,
extruder1::{
ExtruderV2Mode,
api::{
@@ -8,12 +8,12 @@ use crate::{
PressureState, RegulationState, RotationState, ScrewState, TemperaturePid,
TemperaturePidStates,
},
mock::ExtruderV2,
},
extruder2::mock::ExtruderV2,
};
impl MachineNewTrait for ExtruderV2 {
fn new(params: &MachineNewParams<'_, '_, '_, '_, '_, '_, '_>) -> Result<Self, anyhow::Error>
impl EtherCATMachine for ExtruderV2 {
fn new(params: &EtherCATParams<'_, '_, '_, '_, '_, '_, '_>) -> Result<Self, anyhow::Error>
where
Self: Sized,
{
@@ -35,7 +35,7 @@ impl MachineNewTrait for ExtruderV2 {
let (sender, receiver) = smol::channel::unbounded();
let mut extruder_mock_machine = Self {
main_sender: params.main_thread_channel.clone(),
main_sender: params.main_sender.clone(),
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
+7
View File
@@ -18,6 +18,8 @@ use units::electric_potential::volt;
#[cfg(not(feature = "mock-machine"))]
use crate::MACHINE_EXTRUDER_V2;
#[cfg(not(feature = "mock-machine"))]
use crate::{AsyncThreadMessage, Machine};
#[cfg(not(feature = "mock-machine"))]
@@ -29,10 +31,15 @@ use crate::{
machine_identification::{MachineIdentification, MachineIdentificationUnique},
};
#[cfg(not(feature = "mock-machine"))]
pub mod act;
#[cfg(not(feature = "mock-machine"))]
pub mod api;
#[cfg(not(feature = "mock-machine"))]
pub mod emit;
#[cfg(feature = "mock-machine")]
pub mod mock;
#[cfg(not(feature = "mock-machine"))]
pub mod new;
pub mod temperature_controller;
+4 -4
View File
@@ -1,5 +1,5 @@
#[cfg(not(feature = "mock-machine"))]
use crate::{MachineNewParams, MachineNewTrait, get_ethercat_device};
use crate::{EtherCATMachine, EtherCATParams, get_ethercat_device};
#[cfg(not(feature = "mock-machine"))]
use anyhow::Error;
@@ -51,8 +51,8 @@ use crate::extruder1::temperature_controller::TemperatureController;
use super::ExtruderV3;
#[cfg(not(feature = "mock-machine"))]
impl MachineNewTrait for ExtruderV3 {
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
impl EtherCATMachine for ExtruderV3 {
fn new<'maindevice>(params: &EtherCATParams) -> Result<Self, Error> {
// validate general stuff
use crate::{
@@ -223,7 +223,7 @@ impl MachineNewTrait for ExtruderV3 {
let (sender, receiver) = smol::channel::unbounded();
let mut extruder: ExtruderV3 = Self {
main_sender: params.main_thread_channel.clone(),
main_sender: params.main_sender.clone(),
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
+47
View File
@@ -0,0 +1,47 @@
use super::IP20TestMachine;
use crate::{MachineAct, MachineMessage};
use std::time::{Duration, Instant};
impl MachineAct for IP20TestMachine {
fn act(&mut self, now: Instant) {
if let Ok(msg) = self.api_receiver.try_recv() {
self.act_machine_message(msg);
}
// Read inputs
self.read_inputs();
// Emit state at 30 Hz
if now.duration_since(self.last_state_emit) > Duration::from_secs_f64(1.0 / 30.0) {
self.emit_state();
self.last_state_emit = now;
}
// Emit live values at 10 Hz
if now.duration_since(self.last_live_values_emit) > Duration::from_secs_f64(1.0 / 10.0) {
self.emit_live_values();
self.last_live_values_emit = now;
}
}
fn act_machine_message(&mut self, msg: MachineMessage) {
match msg {
MachineMessage::SubscribeNamespace(namespace) => {
self.namespace.namespace = Some(namespace);
self.emit_state();
self.emit_live_values();
}
MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
MachineMessage::HttpApiJsonRequest(value) => {
use crate::MachineApi;
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(_machine_connection) => {
// Does not connect to any Machine; do nothing
}
MachineMessage::DisconnectMachine(_machine_connection) => {
// Does not connect to any Machine; do nothing
}
}
}
}
+93
View File
@@ -0,0 +1,93 @@
use super::IP20TestMachine;
use crate::{MachineApi, MachineMessage};
use control_core::socketio::{
event::{Event, GenericEvent},
namespace::{
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_first_and_last_event,
},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
#[derive(Serialize, Debug, Clone)]
pub struct StateEvent {
pub outputs: [bool; 8],
}
impl StateEvent {
pub fn build(&self) -> Event<Self> {
Event::new("StateEvent", self.clone())
}
}
#[derive(Serialize, Debug, Clone)]
pub struct LiveValuesEvent {
pub inputs: [bool; 8],
}
impl LiveValuesEvent {
pub fn build(&self) -> Event<Self> {
Event::new("LiveValuesEvent", self.clone())
}
}
pub enum IP20TestMachineEvents {
State(Event<StateEvent>),
LiveValues(Event<LiveValuesEvent>),
}
#[derive(Deserialize)]
#[serde(tag = "action", content = "value")]
pub enum Mutation {
SetOutput { index: usize, on: bool },
SetAllOutputs { on: bool },
}
#[derive(Debug, Clone)]
pub struct IP20TestMachineNamespace {
pub namespace: Option<Namespace>,
}
impl NamespaceCacheingLogic<IP20TestMachineEvents> for IP20TestMachineNamespace {
fn emit(&mut self, events: IP20TestMachineEvents) {
let event = Arc::new(events.event_value());
let buffer_fn = events.event_cache_fn();
if let Some(ns) = &mut self.namespace {
ns.emit(event, &buffer_fn);
}
}
}
impl CacheableEvents<IP20TestMachineEvents> for IP20TestMachineEvents {
fn event_value(&self) -> GenericEvent {
match self {
IP20TestMachineEvents::State(event) => event.clone().into(),
IP20TestMachineEvents::LiveValues(event) => event.clone().into(),
}
}
fn event_cache_fn(&self) -> CacheFn {
cache_first_and_last_event()
}
}
impl MachineApi for IP20TestMachine {
fn api_get_sender(&self) -> smol::channel::Sender<MachineMessage> {
self.api_sender.clone()
}
fn api_mutate(&mut self, request_body: Value) -> Result<(), anyhow::Error> {
let mutation: Mutation = serde_json::from_value(request_body)?;
match mutation {
Mutation::SetOutput { index, on } => self.set_output(index, on),
Mutation::SetAllOutputs { on } => self.set_all_outputs(on),
}
Ok(())
}
fn api_event_namespace(&mut self) -> Option<Namespace> {
self.namespace.namespace.clone()
}
}
+91
View File
@@ -0,0 +1,91 @@
use crate::ip20_test_machine::api::{IP20TestMachineEvents, LiveValuesEvent, StateEvent};
use crate::machine_identification::{MachineIdentification, MachineIdentificationUnique};
use crate::{AsyncThreadMessage, Machine, MachineMessage};
use control_core::socketio::namespace::NamespaceCacheingLogic;
use ethercat_hal::io::digital_input::DigitalInput;
use ethercat_hal::io::digital_output::DigitalOutput;
use smol::channel::{Receiver, Sender};
use std::time::Instant;
pub mod act;
pub mod api;
pub mod new;
use crate::ip20_test_machine::api::IP20TestMachineNamespace;
use crate::{IP20_TEST_MACHINE, VENDOR_QITECH};
#[derive(Debug)]
pub struct IP20TestMachine {
pub api_receiver: Receiver<MachineMessage>,
pub api_sender: Sender<MachineMessage>,
pub machine_identification_unique: MachineIdentificationUnique,
pub namespace: IP20TestMachineNamespace,
pub last_state_emit: Instant,
pub last_live_values_emit: Instant,
pub outputs: [bool; 8],
pub inputs: [bool; 8],
pub main_sender: Option<Sender<AsyncThreadMessage>>,
pub douts: [DigitalOutput; 8],
pub dins: [DigitalInput; 8],
}
impl Machine for IP20TestMachine {
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
self.machine_identification_unique.clone()
}
fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>> {
self.main_sender.clone()
}
}
impl IP20TestMachine {
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
vendor: VENDOR_QITECH,
machine: IP20_TEST_MACHINE,
};
}
impl IP20TestMachine {
pub fn emit_state(&mut self) {
let event = StateEvent {
outputs: self.outputs,
}
.build();
self.namespace.emit(IP20TestMachineEvents::State(event));
}
pub fn emit_live_values(&mut self) {
let event = LiveValuesEvent {
inputs: self.inputs,
}
.build();
self.namespace
.emit(IP20TestMachineEvents::LiveValues(event));
}
/// Set the state of a specific output
pub fn set_output(&mut self, index: usize, on: bool) {
if index < self.outputs.len() {
self.outputs[index] = on;
self.douts[index].set(on);
self.emit_state();
}
}
/// Set all outputs at once
pub fn set_all_outputs(&mut self, on: bool) {
self.outputs = [on; 8];
for (dout, &value) in self.douts.iter().zip(self.outputs.iter()) {
dout.set(value);
}
self.emit_state();
}
/// Read all digital inputs
pub fn read_inputs(&mut self) {
for (i, din) in self.dins.iter().enumerate() {
self.inputs[i] = din.get_value().unwrap_or(false);
}
}
}
+92
View File
@@ -0,0 +1,92 @@
use crate::ip20_test_machine::IP20TestMachine;
use crate::ip20_test_machine::api::IP20TestMachineNamespace;
use smol::block_on;
use std::time::Instant;
use crate::{
EtherCATMachine, EtherCATParams, MachineNewHardware, get_ethercat_device,
validate_no_role_dublicates, validate_same_machine_identification_unique,
};
use anyhow::Error;
use ethercat_hal::devices::wago_modules::ip20_ec_di8_do8::{
IP20_EC_DI8_DO8_IDENTITY, IP20EcDi8Do8, IP20EcDi8Do8InputPort, IP20EcDi8Do8OutputPort,
};
use ethercat_hal::io::digital_input::DigitalInput;
use ethercat_hal::io::digital_output::DigitalOutput;
impl EtherCATMachine for IP20TestMachine {
fn new<'maindevice>(params: &EtherCATParams) -> Result<Self, Error> {
// validate general stuff
let device_identification = params
.device_group
.iter()
.map(|device_identification| device_identification.clone())
.collect::<Vec<_>>();
validate_same_machine_identification_unique(&device_identification)?;
validate_no_role_dublicates(&device_identification)?;
let hardware = match &params.hardware {
MachineNewHardware::Ethercat(x) => x,
_ => {
return Err(anyhow::anyhow!(
"[{}::EtherCATMachine/IP20TestMachine::new] MachineNewHardware is not Ethercat",
module_path!()
));
}
};
block_on(async {
let ip20_device = get_ethercat_device::<IP20EcDi8Do8>(
hardware,
params,
0,
[IP20_EC_DI8_DO8_IDENTITY].to_vec(),
)
.await?
.0;
// Create digital outputs
let do1 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO1);
let do2 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO2);
let do3 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO3);
let do4 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO4);
let do5 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO5);
let do6 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO6);
let do7 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO7);
let do8 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO8);
// Create digital inputs
let di1 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI1);
let di2 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI2);
let di3 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI3);
let di4 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI4);
let di5 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI5);
let di6 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI6);
let di7 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI7);
let di8 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI8);
let (sender, receiver) = smol::channel::unbounded();
let mut machine = Self {
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
namespace: IP20TestMachineNamespace {
namespace: params.namespace.clone(),
},
last_state_emit: Instant::now(),
last_live_values_emit: Instant::now(),
outputs: [false; 8],
inputs: [false; 8],
main_sender: params.main_sender.clone(),
douts: [do1, do2, do3, do4, do5, do6, do7, do8],
dins: [di1, di2, di3, di4, di5, di6, di7, di8],
};
machine.emit_state();
machine.emit_live_values();
Ok(machine)
})
}
}
+4
View File
@@ -29,6 +29,10 @@ impl MachineAct for LaserMachine {
};
self.update();
if self.did_change_state {
self.emit_state();
}
// more than 33ms have passed since last emit (30 "fps" target)
if now.duration_since(self.last_measurement_emit) > Duration::from_secs_f64(1.0 / 30.0) {
self.emit_live_values();
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more