Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4250606a1 | ||
|
|
15733dab56 | ||
|
|
45e4ca94a7 | ||
|
|
345802e9eb | ||
|
|
e0a6e50f70 | ||
|
|
930d1c887f | ||
|
|
e427a449ba | ||
|
|
93f62640c3 | ||
|
|
4e7fc0ee91 | ||
|
|
ea5ca42e49 | ||
|
|
99b7d92884 | ||
|
|
a055e47634 | ||
|
|
7ed3ee87bf | ||
|
|
a3319900fb | ||
|
|
5df48baaf9 | ||
|
|
10d9e9e6db | ||
|
|
8a1695f9fb | ||
|
|
24b5145163 | ||
|
|
a095669aea | ||
|
|
c4bcf82c14 | ||
|
|
d8fe3b90a4 | ||
|
|
ba4d86ebd3 | ||
|
|
5045af7eb7 | ||
|
|
860cddc6c5 | ||
|
|
eaeb5d345c | ||
|
|
28217fe839 | ||
|
|
a5b51968d3 | ||
|
|
1cbcf499e4 | ||
|
|
d6b0dd966c | ||
|
|
45ec2ac257 | ||
|
|
b7ba698a26 | ||
|
|
25e0a7665a | ||
|
|
c6b46838fe | ||
|
|
c4fe451534 | ||
|
|
f650a8e538 | ||
|
|
644d7585cf | ||
|
|
6615d1c1e2 | ||
|
|
fd87017687 | ||
|
|
d7cede7dc0 | ||
|
|
05fcca77c3 | ||
|
|
577bff45ef | ||
|
|
3b5c92f8bb | ||
|
|
7c06ad4313 | ||
|
|
3f7958645e | ||
|
|
fe59209783 | ||
|
|
16d0f7cc56 | ||
|
|
91083550a8 | ||
|
|
ed8c64b473 | ||
|
|
e4750ee823 | ||
|
|
b14c38b5e7 | ||
|
|
18aefa2867 | ||
|
|
36ce437499 | ||
|
|
37c9e143a8 | ||
|
|
868089a6bf | ||
|
|
258b4409c7 | ||
|
|
3dbf9cc304 | ||
|
|
02eb00883c | ||
|
|
ff16df1304 | ||
|
|
b67065b9c3 | ||
|
|
d0ed404afa | ||
|
|
2d9ca0e702 | ||
|
|
44e59d9d9a | ||
|
|
7b950c93a0 | ||
|
|
73032f6b46 | ||
|
|
f5413d9b8e | ||
|
|
c609e8dc6a | ||
|
|
394b5fd8ea | ||
|
|
18b471af39 | ||
|
|
0505609a01 | ||
|
|
7062d28ff1 | ||
|
|
4c7bce5462 | ||
|
|
ce7a128b2d | ||
|
|
8f19b135e1 | ||
|
|
49ed575ee2 | ||
|
|
2970c4e924 | ||
|
|
f200f38862 | ||
|
|
c7df114544 | ||
|
|
1594c3a0b6 | ||
|
|
34a44a1b17 | ||
|
|
d3b05a0a89 | ||
|
|
223dd74c24 | ||
|
|
14859506f3 | ||
|
|
740350f920 | ||
|
|
c527f85ebc | ||
|
|
c2f3086cde | ||
|
|
60233c3e7f | ||
|
|
066bee0511 | ||
|
|
be3c604294 | ||
|
|
d64edbd9c0 | ||
|
|
63d11fbfe7 | ||
|
|
5ad56880ee | ||
|
|
5522b87f49 | ||
|
|
fe5767ca50 | ||
|
|
6e7b6c028e | ||
|
|
a4aa3524e6 | ||
|
|
30bec6e3c3 | ||
|
|
03ce5cf6a2 | ||
|
|
9ac46f4857 | ||
|
|
b111c8e5c3 | ||
|
|
0c0c9de2f2 | ||
|
|
6606cfdcce | ||
|
|
7f46fdc666 | ||
|
|
e67a147278 | ||
|
|
a3277f299a | ||
|
|
d1f07e8d01 | ||
|
|
a5da005f7b | ||
|
|
b91e62fad7 | ||
|
|
e7bfcf6942 | ||
|
|
fbdf3d0521 | ||
|
|
1ef69df731 | ||
|
|
8d652ce4c3 | ||
|
|
e83967b4d3 | ||
|
|
68476c0ce6 | ||
|
|
36f3de7626 | ||
|
|
605fa3a7fb | ||
|
|
c9e7bb4973 | ||
|
|
3792196b5a | ||
|
|
b3ee0f9d60 | ||
|
|
2c5597285b | ||
|
|
ee3c2c0214 | ||
|
|
ea91efd811 | ||
|
|
1a5ee2fb98 | ||
|
|
e919e49f25 | ||
|
|
446daf26b0 | ||
|
|
c3db0a110e | ||
|
|
01f2b49778 | ||
|
|
f445d44809 | ||
|
|
11c6f806c6 | ||
|
|
eb085fa80c | ||
|
|
315ed1b31c | ||
|
|
d83488a471 | ||
|
|
3efee724e1 | ||
|
|
3dd1b86ac5 | ||
|
|
3f2e40e774 | ||
|
|
5543eb7c83 | ||
|
|
63367211a1 | ||
|
|
af0b5deff0 | ||
|
|
743a589c1d | ||
|
|
ee7550d89f | ||
|
|
ec13f715aa | ||
|
|
2af746e7a4 | ||
|
|
3af3fb9d79 | ||
|
|
ff3103bec2 | ||
|
|
5eb3c39f84 | ||
|
|
2c715d6a8e | ||
|
|
fb12001721 | ||
|
|
ad94754fcf | ||
|
|
b919721c3a | ||
|
|
a428a2aa02 | ||
|
|
7b479c6021 | ||
|
|
17ac24c79d | ||
|
|
8c7a04b616 | ||
|
|
fe248c79ae | ||
|
|
038df09611 | ||
|
|
9e43d8d431 | ||
|
|
d364263d6e | ||
|
|
376e4a4ea2 | ||
|
|
0b30ae9a85 | ||
|
|
528952238d | ||
|
|
8c32ce2a2b | ||
|
|
579ef0d2e2 | ||
|
|
b6263e90d6 | ||
|
|
f67f098ff3 | ||
|
|
1a3c26b789 | ||
|
|
49f0c9f289 | ||
|
|
e60daed274 | ||
|
|
9a9120398e | ||
|
|
dec342498c | ||
|
|
bc8cc5dd3d | ||
|
|
4a1f3d125d | ||
|
|
1d1eeb3d48 | ||
|
|
c395d4e172 | ||
|
|
5009c5ca75 | ||
|
|
50dbc46b8c | ||
|
|
774e7e2f8b | ||
|
|
270872fe81 | ||
|
|
a749f59186 | ||
|
|
bae1de3c54 | ||
|
|
7681dae32c | ||
|
|
2d98d369e1 | ||
|
|
310ac42a88 | ||
|
|
182df9c569 | ||
|
|
153a719d64 | ||
|
|
84603d1594 | ||
|
|
17b2e3e297 | ||
|
|
fb1cf33c7f | ||
|
|
ae2d0fa8b1 | ||
|
|
ed06b63b77 | ||
|
|
9f0f45d19c | ||
|
|
a75c0ff08a | ||
|
|
205432c926 | ||
|
|
781783464b | ||
|
|
4fa2a1a221 | ||
|
|
b734468741 | ||
|
|
1ed4ae3d2c | ||
|
|
8095222cad | ||
|
|
d19af0923c | ||
|
|
08ce5a37bd | ||
|
|
78c7667d96 | ||
|
|
bef3389874 | ||
|
|
985937c227 | ||
|
|
22be4b79f0 | ||
|
|
be84edf0fd | ||
|
|
ff3e787a03 | ||
|
|
093663c4e6 | ||
|
|
1b2d6df459 | ||
|
|
cd2e15cb29 | ||
|
|
f1571c0daa | ||
|
|
96e0fb83ef | ||
|
|
94cffbf717 | ||
|
|
38b2e907f9 | ||
|
|
7e5498751a | ||
|
|
eef9d63de2 | ||
|
|
d9d9b9d344 | ||
|
|
2ff3d8df56 | ||
|
|
462672bb6a | ||
|
|
84d539a4fa | ||
|
|
850b2d411d | ||
|
|
008efe6ba1 | ||
|
|
3bd248b9fe | ||
|
|
d73cfe7a02 | ||
|
|
6a6c09e283 | ||
|
|
6d2279d44b | ||
|
|
61b9ac8a39 | ||
|
|
728dd6eb26 | ||
|
|
742fd35d5f | ||
|
|
5e1126e896 | ||
|
|
e91719f902 | ||
|
|
0efa9bcb07 | ||
|
|
37d897d240 | ||
|
|
bb169e3b33 | ||
|
|
3d735f8abd | ||
|
|
a9a973e336 | ||
|
|
b66a59dba9 | ||
|
|
9dbc962dbc | ||
|
|
93e2558803 | ||
|
|
8cf7480833 | ||
|
|
08d655effc | ||
|
|
c1c867fca1 | ||
|
|
1378c00e1a | ||
|
|
85f17befbe | ||
|
|
f2e764f93a | ||
|
|
9adeeda490 | ||
|
|
68bf075172 | ||
|
|
7d23190939 | ||
|
|
5c68e2c78a | ||
|
|
980e14a96e | ||
|
|
d5fb45e4e3 | ||
|
|
cdb7ab1e0c | ||
|
|
da934e0bb3 |
No files matched your search
@@ -1,3 +0,0 @@
|
||||
.png filter=lfs diff=lfs merge=lfs -text
|
||||
.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: 'BUG: '
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Navigate to '...'
|
||||
2. Select/Press '....'
|
||||
3. Execute operation '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots/Photos**
|
||||
If applicable, add screenshots from the HMI display or photos of the machine screen to help explain your problem.
|
||||
|
||||
**Machine Information (please complete the following):**
|
||||
- Machine Type: [Winder2, Laser, Aquapath...]
|
||||
- Control Software Version: [e.g. 2.4.1]
|
||||
- Settings: [e.g. Different Gear Ratios, PID Settings, …]
|
||||
|
||||
**Device Information (if issue occurs on external device):**
|
||||
- Device Type: [e.g. Tablet, PLC, Remote Control Panel]
|
||||
- OS: [e.g. Windows 10, Linux, etc.]
|
||||
- Connection Type: [e.g. Ethernet, USB]
|
||||
|
||||
**Error Messages/Codes**
|
||||
If applicable, provide exact error codes or messages displayed or found in the troubleshooting terminal.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here (e.g., recent software updates, environmental factors).
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: 'Feat: '
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. During operation, I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions, workarounds, or features you've considered.
|
||||
|
||||
**Use Case/Application:**
|
||||
- Machine Type(s): [e.g. All machines, Winder, Aquapath, ...]
|
||||
- Frequency of Use: [e.g. Daily, During setup, Occasionally]
|
||||
|
||||
**Expected Benefits:**
|
||||
- Efficiency Improvement: [e.g. Reduces setup time by X minutes, Eliminates manual steps]
|
||||
- Safety Enhancement: [e.g. Prevents operator error, Adds safety checks]
|
||||
- Production Impact: [e.g. Increases throughput, Reduces downtime, Improves quality]
|
||||
- User Experience: [e.g. Simplifies workflow, Reduces training time]
|
||||
|
||||
**Mockups/Sketches**
|
||||
If applicable, add sketches, mockups, or photos showing how the feature would appear on the HMI display or integrate with the machine.
|
||||
|
||||
**Additional context**
|
||||
Add any other context, industry standards, or examples from other control systems about the feature request here.
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Update npm deps hash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'electron/package-lock.json'
|
||||
- 'electron/package.json'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
NIX_PATH: nixpkgs=channel:nixos-25.05
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
update-hash:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Nix
|
||||
uses: nixbuild/nix-quick-install-action@v34
|
||||
with:
|
||||
nix_conf: |
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- name: Calculate new npm deps hash
|
||||
id: calc-hash
|
||||
run: |
|
||||
# Calculate the hash using nix run with nixpkgs
|
||||
NEW_HASH=$(nix run 'nixpkgs#prefetch-npm-deps' electron/package-lock.json)
|
||||
echo "hash=$NEW_HASH" >> $GITHUB_OUTPUT
|
||||
echo "Calculated hash: $NEW_HASH"
|
||||
|
||||
- name: Update npmDepsHash in electron.nix
|
||||
run: |
|
||||
NEW_HASH="${{ steps.calc-hash.outputs.hash }}"
|
||||
sed -i "s|npmDepsHash = \".*\";|npmDepsHash = \"$NEW_HASH\";|" nixos/packages/electron.nix
|
||||
|
||||
- name: Check if hash changed
|
||||
id: check-changes
|
||||
run: |
|
||||
if git diff --quiet nixos/packages/electron.nix; then
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
echo "No changes needed - hash is already correct"
|
||||
else
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "Hash updated in electron.nix"
|
||||
fi
|
||||
|
||||
- name: Commit and push if changed
|
||||
if: steps.check-changes.outputs.changed == 'true'
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add nixos/packages/electron.nix
|
||||
git commit -m "chore: update npmDepsHash to ${{ steps.calc-hash.outputs.hash }}"
|
||||
git push
|
||||
|
||||
- name: Comment on PR
|
||||
if: steps.check-changes.outputs.changed == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: 'Automatically updated `npmDepsHash` to `${{ steps.calc-hash.outputs.hash }}`\n\n**Next step:** Trigger the checks by pushing an empty commit:\n```bash\ngit commit --allow-empty -m "trigger ci" && git push\n```'
|
||||
})
|
||||
@@ -7,4 +7,5 @@ build.sh
|
||||
shell.nix
|
||||
node_modules/
|
||||
runtime_metrics.csv
|
||||
compile_metrics.csv
|
||||
compile_metrics.csv
|
||||
.idea/
|
||||
@@ -1,3 +1,9 @@
|
||||
# `2.14.1`
|
||||
_26.01.2026_
|
||||
|
||||
## Laser
|
||||
-[#1079](https://github.com/qitechgmbh/control/pull/1079) Fixed Overflowing of buffer for Graphs causing crashes
|
||||
|
||||
# `2.14.0`
|
||||
_22.01.2026_
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -221,7 +221,7 @@ checksum = "99e1aca718ea7b89985790c94aad72d77533063fe00bc497bb79a7c2dae6a661"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -232,9 +232,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.7"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
@@ -291,7 +291,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -390,9 +390,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.0"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -539,7 +539,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -682,7 +682,7 @@ dependencies = [
|
||||
"deluxe-macros",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -695,7 +695,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -710,7 +710,7 @@ dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -721,7 +721,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -962,14 +962,13 @@ dependencies = [
|
||||
"deluxe",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ethercrab"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbb13becfa15713adb09b27fdb74468b827cdc78aa99aea638a89c1b0db3ce94"
|
||||
source = "git+https://github.com/v-morlock/ethercrab?branch=main#d297d13389d85a4edb4e69cc98f9dd7f12626cac"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"atomic-waker",
|
||||
@@ -998,8 +997,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ethercrab-wire"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d15828cb020fb44c92ca67e1ee3c122c9621c2e00d383d066e488f0619023ba2"
|
||||
source = "git+https://github.com/v-morlock/ethercrab?branch=main#d297d13389d85a4edb4e69cc98f9dd7f12626cac"
|
||||
dependencies = [
|
||||
"ethercrab-wire-derive",
|
||||
"heapless",
|
||||
@@ -1008,20 +1006,19 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ethercrab-wire-derive"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e790b8011211ed70babfcacbd4277b91ee21402ad5ba6a2c293db02e2f12919"
|
||||
source = "git+https://github.com/v-morlock/ethercrab?branch=main#d297d13389d85a4edb4e69cc98f9dd7f12626cac"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "euclid"
|
||||
version = "0.22.11"
|
||||
version = "0.22.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48"
|
||||
checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
@@ -1878,7 +1875,7 @@ dependencies = [
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1908,7 +1905,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2221,7 +2218,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2251,20 +2248,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.145"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2332,6 +2329,7 @@ dependencies = [
|
||||
"smol",
|
||||
"socketioxide",
|
||||
"spin_sleep",
|
||||
"ta",
|
||||
"textplots",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
@@ -2573,9 +2571,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.111"
|
||||
version = "2.0.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2588,6 +2586,12 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "ta"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
@@ -2621,7 +2625,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2681,7 +2685,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2843,7 +2847,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2880,9 +2884,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.20"
|
||||
version = "0.3.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"matchers",
|
||||
@@ -3074,7 +3078,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -3139,7 +3143,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3150,7 +3154,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3426,5 +3430,11 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
|
||||
@@ -2,167 +2,76 @@
|
||||
|
||||
# QiTech Control
|
||||
|
||||
QiTech Control is an open-source framework designed to bring modern software paradigms to certified industrial hardware.
|
||||
QiTech Control is an open-source framework designed to bring modern software development practices to certified industrial hardware.
|
||||
|
||||
It frees developers from proprietary, license-heavy PLC ecosystems and rigid "point-and-click" workflows that are no longer adequate for today's complex automation challenges.
|
||||
|
||||
QiTech Control combines the modularity and reliability of standard EtherCAT terminals (e.g., WAGO, Beckhoff) with the power of a modern Rust & React stack.
|
||||
|
||||
## Documentation
|
||||
|
||||
[](https://www.youtube.com/watch?v=KI3YeBwfV-s)
|
||||
*Click here to watch a video demo of our software.*
|
||||
**[View Full Documentation Wiki](https://github.com/qitechgmbh/control/wiki)**
|
||||
|
||||
[](https://youtu.be/55egCAkQgyM)
|
||||
*Click here to watch a full explainer Video of our Software.*
|
||||
- [Getting Started](https://github.com/qitechgmbh/control/wiki/Getting-Started)
|
||||
- [Architecture Overview](https://github.com/qitechgmbh/control/wiki/Architecture-Overview)
|
||||
- [EtherCAT Basics](https://github.com/qitechgmbh/control/wiki/Ethercat-Basics)
|
||||
- [Device Examples](https://github.com/qitechgmbh/control/wiki/Hardware-Examples)
|
||||
- [REST API Reference](https://github.com/qitechgmbh/control/wiki/Rest-Api)
|
||||
|
||||
# Repo Structure
|
||||
## Videos
|
||||
|
||||
Frontend
|
||||
| Software Demo | Full Explainer |
|
||||
|---------------|----------------|
|
||||
| [](https://www.youtube.com/watch?v=KI3YeBwfV-s)<br/>*Watch a demo of the control software in action* | [](https://youtu.be/55egCAkQgyM)<br/>*Watch a complete overview of QiTech Control* |
|
||||
|
||||
- `/electron`: Frontend code for the control software built with React and Electron.
|
||||
## Repository Structure
|
||||
|
||||
Backend
|
||||
- **`/electron`** - React + Electron frontend
|
||||
- **`/server`** - Rust backend implementing machine logic
|
||||
- **`/ethercat-hal`** - Hardware abstraction layer for EtherCAT devices
|
||||
- **`/control-core`** - Core control logic
|
||||
- **`/nixos`** - Custom Linux OS with realtime kernel
|
||||
|
||||
- `/server`: Glue between Beckhoff and Electron. Implements machine logic.
|
||||
- `/ethercat-hal`: Hardware abstraction layer for Beckhoff (and possibly other EtherCat) devices and protocols.
|
||||
- `/ethercat-hal-derive`: Macros for `ethercat-hal`
|
||||
- `/control-core`: Core control logic for the server.
|
||||
## Technology Stack
|
||||
|
||||
Operating System
|
||||
**Backend:** Rust with [Ethercrab](https://github.com/ethercrab-rs/ethercrab) for EtherCAT, [SocketIO](https://socket.io/) for real-time communication, [axum](https://docs.rs/axum/latest/axum/) for REST API
|
||||
|
||||
- `/nixos`: Custom Linux with realtime kernel & preconfigured for touchscreens.
|
||||
|
||||
Other
|
||||
|
||||
- `/docs`: Documentation for the project.
|
||||
|
||||
# Technology Choices
|
||||
|
||||
## Backend
|
||||
|
||||
To interface with Beckhoff and other EtherCAT devices we need an EtherCAT master software. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
|
||||
|
||||
[SocketIO](https://socket.io/) was chosen for performant event driven communication from the backend to the server. But we still use REST with [axum](https://docs.rs/axum/latest/axum/) for the communication thet benefits from the request/response model.
|
||||
|
||||
We use [Smol](https://github.com/smol-rs/smol) for EtherCAT IO in the control loop for it's performance and [Tokio](https://tokio.rs/) for server IO because of it's ecosystem and maturity.
|
||||
|
||||
## Frontend
|
||||
|
||||
We could combine the code of the frontend and backend using [Doxius](https://dioxuslabs.com/) but it lacks good Linux support. We chose [Electron](https://www.electronjs.org/) with [React](https://react.dev/) for it's maturity and ecosystem. For the UI we use [Shadcn](https://ui.shadcn.com/) components and [Tailwind](https://tailwindcss.com/) for styling. For routing we use [TanStack Router](https://tanstack.com/router/v1).
|
||||
|
||||
# Dev Setup
|
||||
|
||||
[Developer Documentation](./docs/developer-docs/)
|
||||
|
||||
## Backend
|
||||
|
||||
- Rust stable 1.86^ toolchain (install via [rustup](https://rustup.rs/))
|
||||
- `rust-analyzer` extension for VSCode
|
||||
- Set your interface in `server/src/ethercat/init.rs` like `en10`
|
||||
- Connect a Beckhoff EK1100 to your interface
|
||||
- run `cd server && cargo run` to start the server (localhost:3001)
|
||||
|
||||
## Frontend
|
||||
|
||||
- nodejs and npm installed
|
||||
- run `cd electron && npm i && npm run start` to start the frontend
|
||||
|
||||
# Machines
|
||||
|
||||
| Machine Type | Version | Release Date | Description | Change to Previous Version | Vendor ID | Machine ID | Implemented | Docs |
|
||||
| ------------ | ------- | ------------ | --------------------------- | ------------------------------------------------------ | -------------------------- | ---------- | ----------- | ------------------------------- |
|
||||
| Winder | V1 | ??? | Winding Filaments & Similar | - | 1 (Qitech Industries GmbH) | 1 | Reserved | - |
|
||||
| Winder | V2 | 2025 | Winding Filaments & Similar | Reengineered Traverse | 1 (Qitech Industries GmbH) | 2 | Yes | [](./docs/machines/winder-1.md) |
|
||||
| Extruder | V1 | ??? | Single Screw Extruder | - | 1 (Qitech Industries GmbH) | 3 | Reserved | - |
|
||||
| Extruder | V2 | 2025 | Single Screw Extruder | PT100 Thermometers, Optional Additional Heating Zone 4 | 1 (Qitech Industries GmbH) | 4 | Yes | |
|
||||
| Waterway | V1 | 2025 | Filament Water Cooling | - | 1 (Qitech Industries GmbH) | 5 | In Progress | |
|
||||
| Laser | V1 | ??? | Diameter Measuring Laser | - | 1 (Qitech Industries GmbH) | 6 | Yes | |
|
||||
| Mock | - | ??? | Mock Machine for Testing | - | 1 (Qitech Industries GmbH) | 7 | Yes | - |
|
||||
|
||||
# Minimal Hardware Examples
|
||||
|
||||
For complete step-by-step tutorials on setting up your first hardware, including wiring diagrams and software configuration, see the [Getting Started Guide](./docs/developer-docs/getting-started.md#minimal-hardware-examples).
|
||||
|
||||
Available examples:
|
||||
- **LED Control with EL2004** - Digital output control (simplest setup)
|
||||
- **Analog Input with EL3021** - Reading analog current measurements
|
||||
**Frontend:** [Electron](https://www.electronjs.org/) + [React](https://react.dev/) with [Shadcn](https://ui.shadcn.com/) components and [Tailwind](https://tailwindcss.com/) styling
|
||||
|
||||
|
||||
# More Docs
|
||||
## Hardware Examples
|
||||
|
||||
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
|
||||
QiTech Control supports a wide range of EtherCAT hardware from WAGO and Beckhoff. We provide complete step-by-step tutorials with wiring diagrams and software configuration for many common modules.
|
||||
|
||||
- [x] Example Winder V2
|
||||
**[View all hardware examples and tutorials on the wiki](https://github.com/qitechgmbh/control/wiki/Hardware-Examples)**
|
||||
|
||||
- [ ] Electron
|
||||
Quick links to popular examples:
|
||||
- [EL2004 - LED Control](https://github.com/qitechgmbh/control/wiki/Minimal-Example-El2004) (simplest setup for beginners)
|
||||
- [EL3021 - Analog Input](https://github.com/qitechgmbh/control/wiki/Minimal-Example-El3021)
|
||||
- [EL7031 - Stepper Motor Control](https://github.com/qitechgmbh/control/wiki/Minimal-Example-El7031-Motor)
|
||||
- [WAGO 750-402 - Digital Input](https://github.com/qitechgmbh/control/wiki/Minimal-Example-Wago750-402)
|
||||
- [WAGO 750-455 - Analog Input](https://github.com/qitechgmbh/control/wiki/Minimal-Example-Wago-750-455)
|
||||
|
||||
- Folder Structure
|
||||
- Routing with TanStack Router
|
||||
- Design with Tailwind & Shadcn
|
||||
- ...
|
||||
## QiTech Machines
|
||||
|
||||
- [ ] Interfacing with Electron/Server
|
||||
QiTech Control powers 10+ production machines for filament extrusion, winding, measurement, and material processing.
|
||||
|
||||
- [ ] SocketIO
|
||||
- Machine Namespace
|
||||
- Main Namespace
|
||||
- [ ] REST
|
||||
- Machine Mutations
|
||||
- Write Device Identification
|
||||
**[View complete machine catalog on the wiki](https://github.com/qitechgmbh/control/wiki/QiTech-Machines)**
|
||||
|
||||
- Server
|
||||
|
||||
- [x] [Threading](./docs/control-loop.md)
|
||||
- [x] [Logging](./docs/logging.md)
|
||||
- [ ] Control Loop Setup
|
||||
- Control Loop Thread
|
||||
- [ ] realtime
|
||||
- Maindevice
|
||||
- Group
|
||||
- Extracting Device Identifications
|
||||
- Identifying Groups
|
||||
- Validating Machines
|
||||
- Run Control Loop
|
||||
- [x] [Control Loop](./docs/control-loop.md)
|
||||
- [x] [Machine/Device Identification](./docs/identification.md)
|
||||
- [ ] Machines
|
||||
- When to create a new Machine?
|
||||
- Versioning
|
||||
- Code sharing
|
||||
- Creating/Validating a Machine
|
||||
- Validation
|
||||
- Configuration
|
||||
- [ ] Machine Implementation Guide
|
||||
- Link: How to create a Device
|
||||
- Link: How to create an Actor
|
||||
- Link: How to create a Machine
|
||||
- API (SocketIO + REST)
|
||||
- Creation/Validation Logic
|
||||
- Optional/Mandatory Devices
|
||||
- Validate Devices
|
||||
- Business Logic
|
||||
- Link: How to create Machine Abstraction (Like Traverse/Puller/...)
|
||||
- Forward `act` in winder.
|
||||
## Contributing
|
||||
|
||||
- [ ] Control Core
|
||||
This is an open-source project. Contributions are welcome! Please see the [wiki](https://github.com/qitechgmbh/control/wiki) for development guidelines.
|
||||
|
||||
- [x] [Actors](./docs/actors.md)
|
||||
- [ ] SocketIO
|
||||
- Namespaces & Caching
|
||||
- Joining leaving namespaces
|
||||
- NamespaceId
|
||||
- Caching
|
||||
- Serverside Caching
|
||||
- Clientside Caching
|
||||
- [ ] REST
|
||||
|
||||
- [x] Ethercat HAL
|
||||
|
||||
- [x] [Devices](./docs/devices.md)
|
||||
- [x] [Configuration (CoE)](./docs/coe.md)
|
||||
- [x] [IO](./docs/io.md)
|
||||
- [x] [PDO](./docs/pdo.md)
|
||||
## Additional Videos
|
||||
|
||||
- [x] [Ethercat Basics](./docs/ethercat-basics.md)
|
||||
| Episode Highlights | |
|
||||
|--------------------|--------------------|
|
||||
| [](https://www.youtube.com/watch?v=U2wNN6sF4to&t=163s)<br/>**Day in the Software Team**<br/>*Behind the scenes with Robin and how the team ships control software.* | [](https://www.youtube.com/watch?v=R5WGAY3WRWA&t=1s)<br/>**Software Stack Day**<br/>*A fast tour through the tools, stack, and daily workflow.* |
|
||||
| [](https://www.youtube.com/watch?v=xyaHwxXguw4&t=32s)<br/>**First Client**<br/>*A milestone week building the first real client project.* | [](https://www.youtube.com/watch?v=moI7z2PVdF4)<br/>**Steelworks Week**<br/>*A deep-dive week on a demanding steelworks order.* |
|
||||
|
||||
- [x] [NixOS Operating System](./docs/nixos/README.md)
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE) file for details.
|
||||
@@ -4,10 +4,13 @@ set -e
|
||||
|
||||
FEATURE=""
|
||||
|
||||
# Check if "mock-machine" was passed as an argument
|
||||
# Check if mock features were passed as an argument
|
||||
if [[ "$1" == "mock-machine" ]]; then
|
||||
echo "Building Debug Code with mock-machine feature"
|
||||
FEATURE="--features mock-machine"
|
||||
elif [[ "$1" == "gluetex-mock" ]]; then
|
||||
echo "Building Debug Code with gluetex-mock feature"
|
||||
FEATURE="--features gluetex-mock"
|
||||
fi
|
||||
|
||||
if [[ "$1" == "release" ]]; then
|
||||
@@ -18,8 +21,8 @@ else
|
||||
cargo build $FEATURE --features development-build
|
||||
fi
|
||||
|
||||
# Only setcap if not building with mock-machine
|
||||
if [ "$1" != "mock-machine" ]; then
|
||||
# Only setcap if not building with mock features
|
||||
if [ "$1" != "mock-machine" ] && [ "$1" != "gluetex-mock" ]; then
|
||||
echo "Setting capabilities for server executable"
|
||||
sudo setcap 'cap_dac_override,cap_net_raw,cap_sys_nice,cap_ipc_lock=eip' ./target/debug/server
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ workspace = true
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
|
||||
axum = "0.8.6"
|
||||
axum = "0.8.8"
|
||||
|
||||
ethercat_hal = { path = "../ethercat-hal" }
|
||||
units = { path = "../units" }
|
||||
ethercrab = "0.6"
|
||||
ethercrab = { git = "https://github.com/v-morlock/ethercrab", branch = "main" }
|
||||
interfaces = "0.0.9"
|
||||
serde = "1.0.219"
|
||||
serialport = "4.7.3"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod clamping_timeagnostic_pid;
|
||||
pub mod first_degree_motion;
|
||||
pub mod pid;
|
||||
pub mod pid_autotuner;
|
||||
pub mod second_degree_motion;
|
||||
@@ -0,0 +1,438 @@
|
||||
//! PID Auto-tuner based on Klipper's algorithm
|
||||
//!
|
||||
//! This implementation uses the Astrom-Hagglund relay method to identify
|
||||
//! system oscillations, then applies Ziegler-Nichols tuning rules to
|
||||
//! calculate PID parameters.
|
||||
//!
|
||||
//! The algorithm works by:
|
||||
//! 1. Heating to target + delta, then cooling to target - delta repeatedly
|
||||
//! 2. Recording temperature peaks during these oscillations
|
||||
//! 3. Calculating the ultimate gain (Ku) and period (Tu) from peak data
|
||||
//! 4. Applying Ziegler-Nichols rules to derive Kp, Ki, Kd values
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
/// Configuration for PID auto-tuning
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutoTuneConfig {
|
||||
/// Temperature oscillation amplitude (e.g., ±5°C)
|
||||
pub tune_delta: f64,
|
||||
/// Maximum heating power (0.0 to 1.0)
|
||||
pub max_power: f64,
|
||||
/// Maximum duration for auto-tuning in seconds
|
||||
pub max_duration_secs: f64,
|
||||
}
|
||||
|
||||
impl Default for AutoTuneConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tune_delta: 5.0,
|
||||
max_power: 1.0,
|
||||
max_duration_secs: 3600.0, // 60 minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of PID auto-tuning
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutoTuneResult {
|
||||
pub kp: f64,
|
||||
pub ki: f64,
|
||||
pub kd: f64,
|
||||
/// Ultimate gain (Ku) used in calculation
|
||||
pub ku: f64,
|
||||
/// Ultimate period (Tu) used in calculation
|
||||
pub tu: f64,
|
||||
}
|
||||
|
||||
/// State of the auto-tuning process
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum AutoTuneState {
|
||||
NotStarted,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// PID auto-tuner implementation
|
||||
#[derive(Debug)]
|
||||
pub struct PidAutoTuner {
|
||||
config: AutoTuneConfig,
|
||||
state: AutoTuneState,
|
||||
|
||||
// Target temperature
|
||||
target_temp: f64,
|
||||
|
||||
// Heating control
|
||||
heating: bool,
|
||||
|
||||
// Peak tracking
|
||||
peak_value: f64,
|
||||
peak_time: Instant,
|
||||
peaks: Vec<(f64, Instant)>,
|
||||
|
||||
// Timing
|
||||
start_time: Option<Instant>,
|
||||
|
||||
// Results
|
||||
pub result: Option<AutoTuneResult>,
|
||||
}
|
||||
|
||||
impl PidAutoTuner {
|
||||
/// Create a new PID auto-tuner with given configuration
|
||||
pub fn new(config: AutoTuneConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
state: AutoTuneState::NotStarted,
|
||||
target_temp: 0.0,
|
||||
heating: false,
|
||||
peak_value: 0.0,
|
||||
peak_time: Instant::now(),
|
||||
peaks: Vec::new(),
|
||||
start_time: None,
|
||||
result: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the auto-tuning process
|
||||
pub fn start(&mut self, now: Instant, target_temp: f64) {
|
||||
self.state = AutoTuneState::Running;
|
||||
self.target_temp = target_temp;
|
||||
self.heating = true;
|
||||
self.peak_value = 9999999.0; // When heating, track minimum (valley)
|
||||
self.peak_time = now;
|
||||
self.peaks.clear();
|
||||
self.start_time = Some(now);
|
||||
self.result = None;
|
||||
}
|
||||
|
||||
/// Stop the auto-tuning process
|
||||
pub fn stop(&mut self) {
|
||||
if self.state == AutoTuneState::Running {
|
||||
self.state = AutoTuneState::Failed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the auto-tuner with current temperature
|
||||
/// Returns (duty_cycle, completed)
|
||||
pub fn update(&mut self, current_temp: f64, now: Instant) -> (f64, bool) {
|
||||
if self.state != AutoTuneState::Running {
|
||||
return (0.0, true);
|
||||
}
|
||||
|
||||
// Check for timeout
|
||||
if let Some(start) = self.start_time {
|
||||
let elapsed = now.duration_since(start).as_secs_f64();
|
||||
if elapsed > self.config.max_duration_secs {
|
||||
self.state = AutoTuneState::Failed;
|
||||
return (0.0, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Temperature targets for oscillation
|
||||
let upper_target = self.target_temp + self.config.tune_delta;
|
||||
let lower_target = self.target_temp - self.config.tune_delta;
|
||||
|
||||
// Track peak values BEFORE checking for crossing
|
||||
if self.heating {
|
||||
// When heating, track minimum (valley)
|
||||
if current_temp < self.peak_value {
|
||||
self.peak_value = current_temp;
|
||||
self.peak_time = now;
|
||||
}
|
||||
} else {
|
||||
// When cooling, track maximum (peak)
|
||||
if current_temp > self.peak_value {
|
||||
self.peak_value = current_temp;
|
||||
self.peak_time = now;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we've crossed the target and need to switch heating/cooling
|
||||
if self.heating && current_temp >= upper_target {
|
||||
self.heating = false;
|
||||
self.record_peak();
|
||||
} else if !self.heating && current_temp <= lower_target {
|
||||
self.heating = true;
|
||||
self.record_peak();
|
||||
}
|
||||
|
||||
// Check if we have enough peaks to calculate PID
|
||||
if self.peaks.len() >= 12 {
|
||||
self.calculate_pid();
|
||||
self.state = AutoTuneState::Completed;
|
||||
return (0.0, true);
|
||||
}
|
||||
|
||||
// Return appropriate duty cycle
|
||||
let duty_cycle = if self.heating {
|
||||
self.config.max_power
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
(duty_cycle, false)
|
||||
}
|
||||
|
||||
/// Record a peak in the oscillation
|
||||
fn record_peak(&mut self) {
|
||||
self.peaks.push((self.peak_value, self.peak_time));
|
||||
|
||||
// Reset peak tracking for next cycle (following Klipper's logic)
|
||||
if self.heating {
|
||||
self.peak_value = 9999999.0; // When heating, we want to find the minimum (valley)
|
||||
} else {
|
||||
self.peak_value = -9999999.0; // When cooling, we want to find the maximum (peak)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate PID parameters from recorded peaks
|
||||
fn calculate_pid(&mut self) {
|
||||
if self.peaks.len() < 4 {
|
||||
self.state = AutoTuneState::Failed;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the cycle times and select the median cycle
|
||||
let mut cycle_times: Vec<(f64, usize)> = Vec::new();
|
||||
for pos in 4..self.peaks.len() {
|
||||
let time_diff = self.peaks[pos]
|
||||
.1
|
||||
.duration_since(self.peaks[pos - 2].1)
|
||||
.as_secs_f64();
|
||||
cycle_times.push((time_diff, pos));
|
||||
}
|
||||
|
||||
// Sort by time and pick the median
|
||||
cycle_times.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||||
let midpoint_pos = if cycle_times.is_empty() {
|
||||
self.state = AutoTuneState::Failed;
|
||||
return;
|
||||
} else {
|
||||
cycle_times[cycle_times.len() / 2].1
|
||||
};
|
||||
|
||||
// Calculate PID parameters using this cycle
|
||||
if let Some(result) = self.calculate_pid_at_position(midpoint_pos) {
|
||||
self.result = Some(result);
|
||||
self.state = AutoTuneState::Completed;
|
||||
} else {
|
||||
self.state = AutoTuneState::Failed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate PID at a specific peak position using Astrom-Hagglund and Ziegler-Nichols
|
||||
fn calculate_pid_at_position(&self, pos: usize) -> Option<AutoTuneResult> {
|
||||
if pos < 2 || pos >= self.peaks.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Calculate temperature amplitude and period
|
||||
let temp_diff = self.peaks[pos].0 - self.peaks[pos - 1].0;
|
||||
let time_diff = self.peaks[pos]
|
||||
.1
|
||||
.duration_since(self.peaks[pos - 2].1)
|
||||
.as_secs_f64();
|
||||
|
||||
// Astrom-Hagglund method to estimate Ku and Tu
|
||||
let amplitude = 0.5 * temp_diff.abs();
|
||||
let ku = 4.0 * self.config.max_power / (std::f64::consts::PI * amplitude);
|
||||
let tu = time_diff;
|
||||
|
||||
// Ziegler-Nichols method to generate PID parameters
|
||||
let ti = 0.5 * tu;
|
||||
let td = 0.125 * tu;
|
||||
let kp = 0.4 * ku;
|
||||
let ki = kp / ti;
|
||||
let kd = kp * td;
|
||||
|
||||
Some(AutoTuneResult { kp, ki, kd, ku, tu })
|
||||
}
|
||||
|
||||
/// Check if auto-tuning is completed successfully
|
||||
pub fn is_completed(&self) -> bool {
|
||||
self.state == AutoTuneState::Completed
|
||||
}
|
||||
|
||||
/// Check if auto-tuning failed
|
||||
pub fn is_failed(&self) -> bool {
|
||||
self.state == AutoTuneState::Failed
|
||||
}
|
||||
|
||||
/// Check if auto-tuning is running
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.state == AutoTuneState::Running
|
||||
}
|
||||
|
||||
/// Get the current state
|
||||
pub fn state(&self) -> &str {
|
||||
match self.state {
|
||||
AutoTuneState::NotStarted => "not_started",
|
||||
AutoTuneState::Running => "running",
|
||||
AutoTuneState::Completed => "completed",
|
||||
AutoTuneState::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get progress as a percentage (0-100)
|
||||
pub fn get_progress_percent(&self) -> f64 {
|
||||
if self.state != AutoTuneState::Running {
|
||||
return if self.state == AutoTuneState::Completed {
|
||||
100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
// Progress is based on number of peaks collected (need 12)
|
||||
let progress = (self.peaks.len() as f64 / 12.0) * 100.0;
|
||||
progress.min(99.0) // Cap at 99% until actually completed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_autotuner_creation() {
|
||||
let config = AutoTuneConfig::default();
|
||||
let tuner = PidAutoTuner::new(config);
|
||||
assert_eq!(tuner.state(), "not_started");
|
||||
assert_eq!(tuner.get_progress_percent(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autotuner_start() {
|
||||
let config = AutoTuneConfig::default();
|
||||
let mut tuner = PidAutoTuner::new(config);
|
||||
let now = Instant::now();
|
||||
|
||||
tuner.start(now, 150.0);
|
||||
assert_eq!(tuner.state(), "running");
|
||||
assert!(tuner.is_running());
|
||||
assert_eq!(tuner.target_temp, 150.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autotuner_stop() {
|
||||
let config = AutoTuneConfig::default();
|
||||
let mut tuner = PidAutoTuner::new(config);
|
||||
let now = Instant::now();
|
||||
|
||||
tuner.start(now, 150.0);
|
||||
tuner.stop();
|
||||
assert!(tuner.is_failed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autotuner_oscillation() {
|
||||
let config = AutoTuneConfig {
|
||||
tune_delta: 5.0,
|
||||
max_power: 1.0,
|
||||
max_duration_secs: 3600.0, // 1 hour
|
||||
};
|
||||
let mut tuner = PidAutoTuner::new(config);
|
||||
let mut now = Instant::now();
|
||||
|
||||
tuner.start(now, 150.0);
|
||||
|
||||
// Simulate temperature oscillations
|
||||
// Should heat to 155, then cool to 145, repeatedly
|
||||
let mut temp = 140.0;
|
||||
let mut completed = false;
|
||||
let mut iterations = 0;
|
||||
|
||||
for cycle in 0..20 {
|
||||
// Heating phase
|
||||
while temp < 155.0 && !completed && iterations < 1000 {
|
||||
temp += 0.5; // Simulate temperature rise
|
||||
now += Duration::from_millis(100);
|
||||
let (duty_cycle, done) = tuner.update(temp, now);
|
||||
completed = done;
|
||||
iterations += 1;
|
||||
|
||||
if !completed && !tuner.heating {
|
||||
// We switched to cooling during heating phase - this is expected near the boundary
|
||||
println!("Switched to cooling at temp={}", temp);
|
||||
break;
|
||||
}
|
||||
if completed || temp >= 155.0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if completed {
|
||||
break;
|
||||
}
|
||||
|
||||
// Cooling phase
|
||||
while temp > 145.0 && !completed && iterations < 1000 {
|
||||
temp -= 0.3; // Simulate temperature drop
|
||||
now += Duration::from_millis(100);
|
||||
let (duty_cycle, done) = tuner.update(temp, now);
|
||||
completed = done;
|
||||
iterations += 1;
|
||||
|
||||
if !completed && tuner.heating {
|
||||
// We switched to heating during cooling phase - this is expected near the boundary
|
||||
println!("Switched to heating at temp={}", temp);
|
||||
break;
|
||||
}
|
||||
if completed || temp <= 145.0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if completed {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check progress
|
||||
if cycle % 3 == 0 {
|
||||
let progress = tuner.get_progress_percent();
|
||||
println!("Cycle {}: Progress {}%", cycle, progress);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
completed,
|
||||
"Auto-tuning should complete after sufficient oscillations"
|
||||
);
|
||||
assert!(tuner.is_completed(), "Should be in completed state");
|
||||
assert!(tuner.result.is_some(), "Should have a result");
|
||||
|
||||
let result = tuner.result.as_ref().unwrap();
|
||||
println!(
|
||||
"Auto-tune result: Kp={:.3}, Ki={:.3}, Kd={:.3}",
|
||||
result.kp, result.ki, result.kd
|
||||
);
|
||||
println!("Ku={:.3}, Tu={:.3}", result.ku, result.tu);
|
||||
|
||||
// Basic sanity checks
|
||||
assert!(result.kp > 0.0, "Kp should be positive");
|
||||
assert!(result.ki > 0.0, "Ki should be positive");
|
||||
assert!(result.kd > 0.0, "Kd should be positive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autotuner_timeout() {
|
||||
let config = AutoTuneConfig {
|
||||
tune_delta: 5.0,
|
||||
max_power: 1.0,
|
||||
max_duration_secs: 1.0, // Very short timeout
|
||||
};
|
||||
let mut tuner = PidAutoTuner::new(config);
|
||||
let mut now = Instant::now();
|
||||
|
||||
tuner.start(now, 150.0);
|
||||
|
||||
// Wait for timeout
|
||||
now += Duration::from_secs(2);
|
||||
let (_, completed) = tuner.update(150.0, now);
|
||||
|
||||
assert!(completed, "Should timeout");
|
||||
assert!(tuner.is_failed(), "Should fail on timeout");
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 687 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 985 KiB |
@@ -89,5 +89,86 @@ To get started with actual hardware, check out these step-by-step tutorials:
|
||||
|
||||
- **[LED Control with EL2004](./minimal-example-el2004.md)** - Digital output control, the simplest possible hardware setup
|
||||
- **[Analog Input with EL3021](./minimal-example-el3021.md)** - Reading analog current measurements
|
||||
- **[Stepper Motor Control with EL7031-0030](./minimal-example-el7031-motor.md)** - Complete motor control integration with velocity control
|
||||
|
||||
These examples provide complete hardware wiring diagrams and software setup instructions.
|
||||
|
||||
|
||||
# More Docs
|
||||
|
||||
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
|
||||
|
||||
- [x] Example Winder V2
|
||||
|
||||
- [ ] Electron
|
||||
|
||||
- Folder Structure
|
||||
- Routing with TanStack Router
|
||||
- Design with Tailwind & Shadcn
|
||||
- ...
|
||||
|
||||
- [ ] Interfacing with Electron/Server
|
||||
|
||||
- [ ] SocketIO
|
||||
- Machine Namespace
|
||||
- Main Namespace
|
||||
- [ ] REST
|
||||
- Machine Mutations
|
||||
- Write Device Identification
|
||||
|
||||
- Server
|
||||
|
||||
- [x] [Threading](./docs/control-loop.md)
|
||||
- [x] [Logging](./docs/logging.md)
|
||||
- [ ] Control Loop Setup
|
||||
- Control Loop Thread
|
||||
- [ ] realtime
|
||||
- Maindevice
|
||||
- Group
|
||||
- Extracting Device Identifications
|
||||
- Identifying Groups
|
||||
- Validating Machines
|
||||
- Run Control Loop
|
||||
- [x] [Control Loop](./docs/control-loop.md)
|
||||
- [x] [Machine/Device Identification](./docs/identification.md)
|
||||
- [ ] Machines
|
||||
- When to create a new Machine?
|
||||
- Versioning
|
||||
- Code sharing
|
||||
- Creating/Validating a Machine
|
||||
- Validation
|
||||
- Configuration
|
||||
- [ ] Machine Implementation Guide
|
||||
- Link: How to create a Device
|
||||
- Link: How to create an Actor
|
||||
- Link: How to create a Machine
|
||||
- API (SocketIO + REST)
|
||||
- Creation/Validation Logic
|
||||
- Optional/Mandatory Devices
|
||||
- Validate Devices
|
||||
- Business Logic
|
||||
- Link: How to create Machine Abstraction (Like Traverse/Puller/...)
|
||||
- Forward `act` in winder.
|
||||
|
||||
- [ ] Control Core
|
||||
|
||||
- [x] [Actors](./docs/actors.md)
|
||||
- [ ] SocketIO
|
||||
- Namespaces & Caching
|
||||
- Joining leaving namespaces
|
||||
- NamespaceId
|
||||
- Caching
|
||||
- Serverside Caching
|
||||
- Clientside Caching
|
||||
- [ ] REST
|
||||
|
||||
- [x] Ethercat HAL
|
||||
|
||||
- [x] [Devices](./docs/devices.md)
|
||||
- [x] [Configuration (CoE)](./docs/coe.md)
|
||||
- [x] [IO](./docs/io.md)
|
||||
- [x] [PDO](./docs/pdo.md)
|
||||
|
||||
- [x] [Ethercat Basics](./docs/ethercat-basics.md)
|
||||
|
||||
- [x] [NixOS Operating System](./docs/nixos/README.md)
|
||||
@@ -0,0 +1,236 @@
|
||||
# Minimal Example — Beckhoff EL7031-0030 Stepper Motor Integration
|
||||
A complete hardware + software walkthrough
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
1. [Introduction](#1-introduction)
|
||||
2. [Requirements](#2-requirements)
|
||||
3. [Hardware Setup](#3-hardware-setup)
|
||||
- [3.2 EK1100 Wiring](#32-ek1100-wiring)
|
||||
- [3.2.1 Safe Wiring Procedure](#321-safe-wiring-procedure-beckhoff-recommended)
|
||||
- [3.2.2 Wiring (EL7031-0030)](#322-wiring-el7031-0030)
|
||||
- [3.3 Safety Warning](#33--safety-warning)
|
||||
4. [Software Setup](#4-software-setup)
|
||||
- [4.1 Installing on Linux](#41-installing-on-linux-this-depends-on-your-distro)
|
||||
- [4.2 Running the Backend](#42-running-the-backend)
|
||||
- [4.3 Running the Frontend](#43-running-the-frontend)
|
||||
5. [Demo](#5-demo)
|
||||
- [5.1 Assigning Devices in the Dashboard](#51-assigning-devices-in-the-dashboard)
|
||||
6. [Documentation](#6-documentation)
|
||||
7. [Software Architecture](#7-software-architecture)
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
This project documents the successful integration of a **Beckhoff EL7031-0030 Stepper Motor Terminal** into the QiTech control software stack.
|
||||
The goal was to control a 24V stepper motor via EtherCAT using a custom Rust backend and a React/Electron frontend.
|
||||
|
||||
---
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
### Software
|
||||
- Rust toolchain
|
||||
- Node.js + npm
|
||||
- Git
|
||||
- QiTech Control repository
|
||||
- EtherCAT HAL (included inside repo)
|
||||
|
||||
### Hardware
|
||||
- **EtherCAT Master:** Linux PC
|
||||
- **Bus Coupler:** EtherCAT Beckhoff EK1100
|
||||
- **Stepper Driver:** Beckhoff EL7031-0030 (THIS IS DIFFERENT FROM EL7031)
|
||||
- **Motor:** Standard 4-wire Stepper Motor
|
||||
- **Power Supply:** 24V DC
|
||||
- **Ethernet Cable:** Standard Ethernet cable
|
||||
- **Wiring Tools:** Screwdriver, wires
|
||||
|
||||
---
|
||||
|
||||
## 3. Hardware Setup
|
||||
|
||||
### 3.2 EK1100 Wiring
|
||||
|
||||
This wiring configuration powers the EL7031-0030.
|
||||
It is not the only possible wiring but is the **simplest functional setup**.
|
||||
|
||||
#### ⚠️ Safety Warning
|
||||
Always disconnect power before wiring.
|
||||
Working on live EtherCAT terminals can cause serious damage or electrical shock.
|
||||
|
||||
---
|
||||
|
||||
#### 3.2.1 Safe Wiring Procedure (Beckhoff Recommended)
|
||||
|
||||
1. Insert a screwdriver **straight** into the square release hole.
|
||||
2. Insert the stripped wire into the round opening.
|
||||
3. Remove the screwdriver — the spring clamp locks the wire.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
We supply power using a **DC hollow-plug adapter**, like this one:
|
||||
https://www.amazon.de/dp/B093FTFZ8Q
|
||||
|
||||
Perform the following wiring on the EK1100:
|
||||
|
||||
1. Red wire **(+24 V)** → Terminal **2**
|
||||
2. Black wire **(0 V)** → Terminal **3**
|
||||
3. Jumper wire from **Terminal 1 → Terminal 6**
|
||||
4. Jumper wire from **Terminal 5 → Terminal 7**
|
||||
|
||||
After wiring, your module should look like **Figure 1**.
|
||||
|
||||
---
|
||||
|
||||
#### **Figure 1 — EK1100 Minimal Wiring**
|
||||
<img src="../assets/ek1100.jpeg" width="400">
|
||||
|
||||
---
|
||||
|
||||
#### **Figure 2 — EL7031-0030 Terminal**
|
||||
|
||||
<img src="../assets/EL7031_0030.jpg" width="300">
|
||||
|
||||
Slide the EL7031-0030 onto the right side of the EK1100 until it locks.
|
||||
The EtherCAT E-Bus and power contacts connect automatically — **no wiring required**.
|
||||
|
||||
---
|
||||
|
||||
#### **Figure 3 — Motor**
|
||||
<img src="../assets/motor.jpg" width="300">
|
||||
|
||||
---
|
||||
|
||||
#### **Figure 4 — Motor Wiring**
|
||||
<img src="../assets/motor_wiring.jpg" width="300">
|
||||
|
||||
Now the motor is wired via the pins 4, 5, 12, 13 on the EL7031-0030.
|
||||
|
||||
---
|
||||
|
||||
#### **Figure 5 — EL7031 Integration Connected**
|
||||
|
||||
<img src="../assets/EL7031_0030_connected.jpg" width="400">
|
||||
|
||||
That's what the pin should look like.
|
||||
|
||||
---
|
||||
|
||||
### 3.2.2 Wiring (EL7031-0030)
|
||||
|
||||
**Crucial:** The EL7031 requires two power sources: E-Bus (side contacts) for logic, and Front Terminal (6 & 14) for motor power.
|
||||
|
||||
| Terminal Point | Function | Cable Color (Example) |
|
||||
| :------------- | :--------------- | :-------------------- |
|
||||
| **4** | Motor Coil A1 | Red |
|
||||
| **12** | Motor Coil A2 | Blue |
|
||||
| **5** | Motor Coil B1 | Green |
|
||||
| **13** | Motor Coil B2 | Black |
|
||||
| **6** | **Power +24V** | PSU Red (+) |
|
||||
| **14** | **Power 0V/GND** | PSU Black (-) |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 ⚠️ Safety Warning
|
||||
|
||||
- **Mandatory Power Supply:** Without 24V connected to **Pin 6 (+24V)** and **Pin 14 (GND)**, the terminal will remain in `PREOP` state or show a "Warning" LED (No Power). The motor will **not** move without this external supply.
|
||||
|
||||
#### Risk of Destruction (Short Circuit):
|
||||
- **Never** connect the 24V Power Supply to the Motor Output pins (**4, 5, 12, 13**). This will instantly destroy the terminal (causing pins 12 & 13 to glow red/burn).
|
||||
- Ensure strict separation: **Pins 4/5/12/13 are for the MOTOR ONLY**.
|
||||
- **Pins 6/14 are for POWER ONLY**.
|
||||
|
||||
Also, see the documentation of the EtherCAT terminal ([EL7031-0030](https://download.beckhoff.com/download/document/io/ethercat-terminals/el7031-0030de.pdf)) for the different power modes (page 46 ff.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Software Setup
|
||||
|
||||
### 4.1 Installing on Linux (this depends on your distro)
|
||||
|
||||
Paste this into your terminal:
|
||||
|
||||
```bash
|
||||
# Press Enter when prompted
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
sudo apt update
|
||||
sudo apt install -y npm nodejs git
|
||||
|
||||
git clone git@github.com:qitechgmbh/control.git
|
||||
cd control/electron
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4.2 Running the Backend
|
||||
|
||||
```bash
|
||||
./cargo_run_linux.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
- Builds the backend
|
||||
- Grants required system capabilities (raw sockets)
|
||||
- Starts EtherCAT communication
|
||||
|
||||
### 4.3 Running the Frontend
|
||||
|
||||
```bash
|
||||
# move to the control directory
|
||||
cd electron
|
||||
npm run start
|
||||
```
|
||||
|
||||
This launches the QiTech Control dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 5. Demo
|
||||
|
||||
### 5.1 Assigning Devices in the Dashboard
|
||||
|
||||
Once the backend + frontend are running, you should see:
|
||||
|
||||

|
||||
|
||||
Make sure that under "Assign" → "Machine Assignment"
|
||||
the correct serial number is selected (each device should have the same one, here it is 1).
|
||||
Under "Machine" select your new Machine however you named it (I named it "TestMotor V1"), else it will detect that there is something connected to it but the connection won't work.
|
||||
|
||||

|
||||
|
||||
**NOW THE MOTOR SHOULD TURN!**
|
||||
|
||||
In the interface "TestMotor" on the left side, you can now control the motor (its state and its speed).
|
||||
|
||||

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