Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f8d5070e | ||
|
|
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 | ||
|
|
7488e1d97a | ||
|
|
bb169e3b33 | ||
|
|
3d735f8abd | ||
|
|
0a87e45901 | ||
|
|
6205189b14 | ||
|
|
9d7da4f0af | ||
|
|
241b24be43 | ||
|
|
aa77d12879 | ||
|
|
78f183a8e5 | ||
|
|
9f6fc8184e | ||
|
|
ec3bdcb1df | ||
|
|
30292650bc | ||
|
|
1e9bcf9b19 | ||
|
|
1e88979ced | ||
|
|
a9a973e336 | ||
|
|
1112fbaae2 | ||
|
|
1abe0bbbdb | ||
|
|
3644d16cb5 | ||
|
|
cbd82ee7c3 | ||
|
|
b66a59dba9 | ||
|
|
9dbc962dbc | ||
|
|
93e2558803 | ||
|
|
8cf7480833 | ||
|
|
08d655effc | ||
|
|
c1c867fca1 | ||
|
|
1378c00e1a | ||
|
|
85f17befbe | ||
|
|
f2e764f93a | ||
|
|
9adeeda490 | ||
|
|
68bf075172 | ||
|
|
7d23190939 | ||
|
|
5c68e2c78a | ||
|
|
980e14a96e | ||
|
|
d5fb45e4e3 | ||
|
|
cdb7ab1e0c | ||
|
|
da934e0bb3 | ||
|
|
06ea89d500 | ||
|
|
0868a90513 | ||
|
|
39fb295b02 | ||
|
|
85dbae7458 | ||
|
|
6ce2174e25 | ||
|
|
3ccaa1cd26 | ||
|
|
72fec6dc9e | ||
|
|
547e4ac2bd | ||
|
|
5fbdf7b9ab | ||
|
|
85bd4b0d96 | ||
|
|
dfce760521 | ||
|
|
30c97bf9aa | ||
|
|
2c8373adcf | ||
|
|
d08972254c | ||
|
|
a76d39e0cd | ||
|
|
a74d0dbb80 | ||
|
|
17ab24739d | ||
|
|
09f0ea3454 | ||
|
|
0dd2494ea0 | ||
|
|
502c6d010e | ||
|
|
57c1cda229 | ||
|
|
8c2b20b148 | ||
|
|
12e7f245ce | ||
|
|
671e36fa0f | ||
|
|
1ffd7eade6 | ||
|
|
703bbfc9e6 | ||
|
|
057afb0b2e | ||
|
|
94bd29c734 | ||
|
|
d078b7b143 | ||
|
|
acbeb98f4f | ||
|
|
cab8bf062d | ||
|
|
9e04a7fa33 | ||
|
|
c92d4847ca | ||
|
|
aceeaa08c2 | ||
|
|
35946d0c88 | ||
|
|
e864a91caa | ||
|
|
2d2415a801 | ||
|
|
b4cce1dba4 | ||
|
|
f80cdb05d5 | ||
|
|
f24f31259b | ||
|
|
6411f9ff80 | ||
|
|
8b462d5a1d | ||
|
|
d8a0039db3 | ||
|
|
dec8ea481c | ||
|
|
0c8b8388d0 | ||
|
|
d3a56710d6 | ||
|
|
f9f67d3309 | ||
|
|
66122ad957 | ||
|
|
95feaf46bf | ||
|
|
88f14e0329 | ||
|
|
5bf19af844 | ||
|
|
19e2a337cf | ||
|
|
0fd47cfcb1 | ||
|
|
e0548d9a65 | ||
|
|
259ed30dd5 | ||
|
|
2e028f0c0f | ||
|
|
1328c6ce03 | ||
|
|
adc06623cd | ||
|
|
172ad0ab28 | ||
|
|
bfabb5aa13 | ||
|
|
94d5a844cf | ||
|
|
75c528a430 | ||
|
|
36045455db | ||
|
|
bb12f96723 | ||
|
|
8151c99944 | ||
|
|
6408010aff | ||
|
|
c96c96217a | ||
|
|
93d179a81d | ||
|
|
0f6f2240fc | ||
|
|
74505c6c22 | ||
|
|
3dfb879101 | ||
|
|
44d4c650cc | ||
|
|
3121679205 | ||
|
|
9fe5e582b0 | ||
|
|
3e6b838757 | ||
|
|
3d7bcfecca | ||
|
|
63a472a1ba | ||
|
|
df6b9e72c0 | ||
|
|
6ad4b9c8bb | ||
|
|
4fcc8dfe12 | ||
|
|
1da64c9cea | ||
|
|
9b6912be8d | ||
|
|
3beed9478c | ||
|
|
3715728fc6 | ||
|
|
5beee78cad | ||
|
|
32fe774e62 | ||
|
|
dead77b49c | ||
|
|
d7aa976eb1 | ||
|
|
6f9e89d616 | ||
|
|
bb3f6c0d5a | ||
|
|
620974ac59 | ||
|
|
6c78ff95e4 | ||
|
|
a814236bfa | ||
|
|
2bee030410 | ||
|
|
285ad59b08 | ||
|
|
f8b861cb39 | ||
|
|
f6a3aa6458 | ||
|
|
dd5f3e8e73 | ||
|
|
f50e32af02 | ||
|
|
345e083edd | ||
|
|
5660ddd05f | ||
|
|
c4360850af | ||
|
|
ce2123c88c | ||
|
|
520f33bf29 | ||
|
|
c620f1db7f | ||
|
|
d352e88d6f | ||
|
|
cc009b31f3 | ||
|
|
b5c189ab49 | ||
|
|
64f57277f2 | ||
|
|
af8ee60fe8 | ||
|
|
beb2e3cc2d | ||
|
|
0e2bcfd7bc | ||
|
|
417ffb23c2 | ||
|
|
a57d4cdf36 |
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,15 @@
|
||||
# `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_
|
||||
|
||||
## General
|
||||
- [#1040](https://github.com/qitechgmbh/control/pull/1040) Added API for reading Machine Data
|
||||
|
||||
# `2.13.0`
|
||||
_07.01.2026_
|
||||
|
||||
@@ -5,7 +17,6 @@ _07.01.2026_
|
||||
- [#925](https://github.com/qitechgmbh/control/pull/925) Switch to Setup when selected machine is disconnected
|
||||
- [#937](https://github.com/qitechgmbh/control/pull/937) Added minimal Support for WAGO
|
||||
- [#945](https://github.com/qitechgmbh/control/pull/945) Improvements for On-Screen Keyboard usage in Ethercat Setup Page
|
||||
- []
|
||||
|
||||
## Extruder V3
|
||||
- [#930](https://github.com/qitechgmbh/control/pull/930) Added Extruder V3
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 29 KiB |
|
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: 6.6 MiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 5.9 MiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 5.4 MiB |
|
After Width: | Height: | Size: 5.9 MiB |
|
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 |
|
After Width: | Height: | Size: 55 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)
|
||||
@@ -1,371 +0,0 @@
|
||||
# IndexedDB Chart Data Persistence
|
||||
|
||||
This document describes the IndexedDB-based persistence layer for chart/timeseries data in the QiTech Control application.
|
||||
|
||||
## Overview
|
||||
|
||||
The application now supports persistent storage of live chart data using IndexedDB. This system:
|
||||
|
||||
- **Persists long timeseries data** (1h+ retention) to IndexedDB automatically
|
||||
- **Keeps short timeseries** (5s retention) in RAM only for performance
|
||||
- **Preserves data** across page navigation and browser sessions
|
||||
- **Maintains API compatibility** with existing namespace implementations
|
||||
- **Loads historical data** on demand for visible time windows
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **`timeseriesDB.ts`**: Low-level IndexedDB wrapper
|
||||
- Handles all database operations (create, read, delete)
|
||||
- Provides efficient time-range queries
|
||||
- Manages data cleanup and retention policies
|
||||
|
||||
2. **`timeseriesPersistent.ts`**: Persistent TimeSeries implementation
|
||||
- Drop-in replacement for `createTimeSeries()`
|
||||
- Automatically persists long-series data points
|
||||
- Loads historical data on initialization
|
||||
- Maintains circular buffer in RAM for performance
|
||||
|
||||
3. **`namespacePersistence.ts`**: Namespace integration helpers
|
||||
- Simplifies creating multiple persistent series
|
||||
- Provides type-safe APIs for namespace stores
|
||||
- Handles async initialization gracefully
|
||||
|
||||
4. **`winder2NamespaceHybrid.example.ts`**: Migration example
|
||||
- Shows how to migrate existing namespaces
|
||||
- Demonstrates best practices
|
||||
- Includes detailed migration notes
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Socket.io Event → Namespace Handler → Insert Function →
|
||||
├─ Short Series (RAM only)
|
||||
└─ Long Series (RAM + IndexedDB)
|
||||
├─ Update circular buffer
|
||||
└─ Persist to IndexedDB (async)
|
||||
|
||||
Page Load → Initialize Store →
|
||||
└─ Load Historical Data from IndexedDB (async) →
|
||||
└─ Populate Long Series Buffer
|
||||
```
|
||||
|
||||
### IndexedDB Schema
|
||||
|
||||
**Database**: `QiTechControlTimeSeries`
|
||||
|
||||
**Store**: `timeseries_data`
|
||||
- **Key**: `[namespaceId, seriesName, timestamp]` (composite)
|
||||
- **Indexes**:
|
||||
- `by-namespace`: Query all series for a namespace
|
||||
- `by-namespace-series`: Query specific series
|
||||
- `by-namespace-series-time`: Efficient time-range queries
|
||||
|
||||
**Data Model**:
|
||||
```typescript
|
||||
{
|
||||
namespaceId: string, // Serialized NamespaceId
|
||||
seriesName: string, // e.g., "pullerSpeed"
|
||||
timestamp: number, // Unix timestamp in ms
|
||||
value: number // Data point value
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Option 1: Quick Migration (Recommended)
|
||||
|
||||
Replace `createTimeSeries()` with `createPersistentTimeSeries()`:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const { initialTimeSeries: pullerSpeed, insert: addPullerSpeed } =
|
||||
createTimeSeries();
|
||||
|
||||
// After
|
||||
const { initialTimeSeries: pullerSpeed, insert: addPullerSpeed } =
|
||||
createPersistentTimeSeries(
|
||||
serializeNamespaceId(namespaceId),
|
||||
"pullerSpeed"
|
||||
);
|
||||
```
|
||||
|
||||
### Option 2: Namespace-Level Integration
|
||||
|
||||
Use the helper for multiple series:
|
||||
|
||||
```typescript
|
||||
import { createNamespacePersistentTimeSeries } from "@/lib/namespacePersistence";
|
||||
|
||||
export const createWinder2NamespaceStore = (
|
||||
namespaceId: NamespaceId
|
||||
): StoreApi<Winder2NamespaceStore> => {
|
||||
const seriesResult = createNamespacePersistentTimeSeries(
|
||||
namespaceId,
|
||||
{
|
||||
traversePosition: "traversePosition",
|
||||
pullerSpeed: "pullerSpeed",
|
||||
spoolRpm: "spoolRpm",
|
||||
tensionArmAngle: "tensionArmAngle",
|
||||
spoolProgress: "spoolProgress",
|
||||
}
|
||||
);
|
||||
|
||||
const store = create<Winder2NamespaceStore>(() => ({
|
||||
state: null,
|
||||
defaultState: null,
|
||||
...seriesResult.initialState,
|
||||
}));
|
||||
|
||||
// Load historical data
|
||||
seriesResult.ready.then(historicalSeries => {
|
||||
store.setState(historicalSeries);
|
||||
});
|
||||
|
||||
return store;
|
||||
};
|
||||
```
|
||||
|
||||
### Option 3: Custom Configuration
|
||||
|
||||
Override default retention and cleanup settings:
|
||||
|
||||
```typescript
|
||||
const { initialTimeSeries, insert } = createPersistentTimeSeries(
|
||||
namespaceId,
|
||||
"seriesName",
|
||||
{
|
||||
enablePersistence: true,
|
||||
maxRetentionTime: 14 * 24 * 60 * 60 * 1000, // 14 days
|
||||
cleanupInterval: 2 * 60 * 60 * 1000, // 2 hours
|
||||
retentionDurationLong: 2 * 60 * 60 * 1000, // 2h buffer
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Step 1: Identify Target Namespaces
|
||||
|
||||
Identify which namespaces should use persistence:
|
||||
- Namespaces with important historical data
|
||||
- Namespaces where users navigate away and back
|
||||
- High-frequency data that benefits from retention
|
||||
|
||||
### Step 2: Update Store Creation
|
||||
|
||||
Modify the `createXNamespaceStore` function:
|
||||
|
||||
```typescript
|
||||
// Add namespaceId parameter
|
||||
export const createWinder2NamespaceStore = (
|
||||
namespaceId: NamespaceId // ← Add this
|
||||
): StoreApi<Winder2NamespaceStore> => {
|
||||
// Use createNamespacePersistentTimeSeries
|
||||
const seriesResult = createNamespacePersistentTimeSeries(
|
||||
namespaceId,
|
||||
{ /* series definitions */ }
|
||||
);
|
||||
|
||||
// Store with initial state
|
||||
const store = create(() => ({
|
||||
...seriesResult.initialState,
|
||||
}));
|
||||
|
||||
// Load historical data
|
||||
seriesResult.ready.then(data => store.setState(data));
|
||||
|
||||
return store;
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Update Message Handler
|
||||
|
||||
Modify the message handler to use insert functions from `seriesResult`:
|
||||
|
||||
```typescript
|
||||
export function winder2MessageHandler(
|
||||
namespaceId: NamespaceId, // ← Add this
|
||||
store: StoreApi<Winder2NamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<Winder2NamespaceStore>,
|
||||
): EventHandler {
|
||||
const insertFns = getSeriesInsertFunctions(namespaceId);
|
||||
|
||||
return (event: Event<any>) => {
|
||||
// Use insertFns.seriesName instead of addSeriesName
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
pullerSpeed: insertFns.pullerSpeed(
|
||||
state.pullerSpeed,
|
||||
newValue
|
||||
),
|
||||
}));
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update Hook Implementation
|
||||
|
||||
Pass namespaceId through to the store creator:
|
||||
|
||||
```typescript
|
||||
export function useWinder2Namespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): Winder2NamespaceStore {
|
||||
const namespaceId = useMemo<NamespaceId>(() => ({
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
}), [machine_identification_unique]);
|
||||
|
||||
const useImpl = useMemo(() => {
|
||||
return createNamespaceHookImplementation<Winder2NamespaceStore>({
|
||||
createStore: () => createWinder2NamespaceStore(namespaceId), // ← Pass ID
|
||||
createEventHandler: (store, updater) =>
|
||||
winder2MessageHandler(namespaceId, store, updater), // ← Pass ID
|
||||
});
|
||||
}, [namespaceId]);
|
||||
|
||||
return useImpl(namespaceId);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Test
|
||||
|
||||
1. **Verify data persistence**: Navigate away and back, check that charts retain data
|
||||
2. **Check performance**: Ensure no noticeable slowdown
|
||||
3. **Test with large datasets**: Generate hours of data
|
||||
4. **Monitor IndexedDB**: Use browser DevTools → Application → IndexedDB
|
||||
5. **Test cleanup**: Verify old data is removed according to retention policy
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createPersistentTimeSeries(namespaceId, seriesName, config?)`
|
||||
|
||||
Creates a persistent timeseries.
|
||||
|
||||
**Parameters**:
|
||||
- `namespaceId: string` - Serialized namespace identifier
|
||||
- `seriesName: string` - Name of the series (e.g., "pullerSpeed")
|
||||
- `config?: Partial<PersistentTimeSeriesConfig>` - Optional configuration
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
{
|
||||
initialTimeSeries: TimeSeries,
|
||||
insert: (series: TimeSeries, value: TimeSeriesValue) => TimeSeries,
|
||||
ready: Promise<TimeSeries>, // Resolves when historical data is loaded
|
||||
cleanup: () => Promise<void> // Manually trigger cleanup
|
||||
}
|
||||
```
|
||||
|
||||
### `createNamespacePersistentTimeSeries(namespaceId, seriesDefinitions, config?)`
|
||||
|
||||
Creates multiple persistent series for a namespace.
|
||||
|
||||
**Parameters**:
|
||||
- `namespaceId: NamespaceId` - Namespace identifier
|
||||
- `seriesDefinitions: Record<string, string>` - Map of series keys to names
|
||||
- `config?: Partial<PersistentTimeSeriesConfig>` - Global configuration
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
{
|
||||
initialState: Record<string, TimeSeries>,
|
||||
insertFns: Record<string, InsertFunction>,
|
||||
ready: Promise<Record<string, TimeSeries>>,
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
```typescript
|
||||
interface PersistentTimeSeriesConfig {
|
||||
// From TimeSeriesConfig
|
||||
sampleIntervalShort: number; // Default: 20ms
|
||||
sampleIntervalLong: number; // Default: 1000ms (1s)
|
||||
retentionDurationShort: number; // Default: 5000ms (5s)
|
||||
retentionDurationLong: number; // Default: 3600000ms (1h)
|
||||
|
||||
// Persistence-specific
|
||||
enablePersistence: boolean; // Default: true
|
||||
maxRetentionTime: number; // Default: 7 days
|
||||
cleanupInterval: number; // Default: 1 hour
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Memory Usage
|
||||
|
||||
- **Short series**: ~250 points × 8 bytes/point = ~2KB per series
|
||||
- **Long series**: ~3600 points × 8 bytes/point = ~28KB per series
|
||||
- **Per namespace**: ~5 series × 30KB = ~150KB per machine instance
|
||||
|
||||
### IndexedDB Storage
|
||||
|
||||
- **Per data point**: ~40 bytes (including keys and metadata)
|
||||
- **1 hour of data**: 3600 points × 40 bytes × 5 series = ~720KB per machine
|
||||
- **7 days retention**: ~120MB per machine
|
||||
- **Cleanup**: Automatic, runs every hour
|
||||
|
||||
### Query Performance
|
||||
|
||||
- **Time-range queries**: O(log n) with indexed lookups
|
||||
- **Latest N points**: O(n) scan required
|
||||
- **Typical load time**: <100ms for 1 hour of data
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Data not persisting
|
||||
|
||||
1. Check browser's IndexedDB quota (DevTools → Application → Storage)
|
||||
2. Verify `enablePersistence: true` in config
|
||||
3. Check console for errors during `storeDataPoint()` calls
|
||||
|
||||
### Slow initial load
|
||||
|
||||
1. Reduce `maxRetentionTime` to limit historical data
|
||||
2. Check for large number of series in namespace
|
||||
3. Consider implementing pagination for very long time ranges
|
||||
|
||||
### Memory issues
|
||||
|
||||
1. Monitor `validCount` in series buffers
|
||||
2. Check that cleanup is running (watch console logs)
|
||||
3. Consider reducing `retentionDurationLong`
|
||||
|
||||
### IndexedDB quota exceeded
|
||||
|
||||
1. Reduce `maxRetentionTime` across all namespaces
|
||||
2. Implement manual data export/clear functionality
|
||||
3. Run cleanup more frequently (reduce `cleanupInterval`)
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use consistent series names**: They're used as database keys
|
||||
2. **Implement loading indicators**: Show users when historical data is loading
|
||||
3. **Handle the ready promise**: Don't assume data is immediately available
|
||||
4. **Monitor storage usage**: Add UI to show used storage
|
||||
5. **Provide clear data**: Add buttons for users to manually clear old data
|
||||
6. **Test offline behavior**: Ensure graceful degradation when IndexedDB fails
|
||||
7. **Version your data format**: Plan for schema migrations
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Data compression**: Use compression for long-term storage
|
||||
- **Selective persistence**: Allow users to choose which series to persist
|
||||
- **Cross-tab synchronization**: Sync data across multiple browser tabs
|
||||
- **Export/import**: Allow users to export data as CSV/JSON
|
||||
- **Cloud sync**: Optionally sync data to cloud storage
|
||||
- **Intelligent loading**: Load only visible time windows on demand
|
||||
- **Progressive loading**: Load recent data first, then backfill
|
||||
|
||||
## See Also
|
||||
|
||||
- [winder2NamespaceHybrid.example.ts](../electron/src/machines/winder/winder2/winder2NamespaceHybrid.example.ts) - Complete migration example
|
||||
- [timeseries.ts](../electron/src/lib/timeseries.ts) - Original RAM-only implementation
|
||||
- [timeseriesDB.ts](../electron/src/lib/timeseriesDB.ts) - IndexedDB wrapper
|
||||
- [timeseriesPersistent.ts](../electron/src/lib/timeseriesPersistent.ts) - Persistent implementation
|
||||
- [namespacePersistence.ts](../electron/src/lib/namespacePersistence.ts) - Namespace helpers
|
||||
@@ -1,314 +0,0 @@
|
||||
# IndexedDB Chart Data Storage Implementation - Summary
|
||||
|
||||
## Implementation Complete ✓
|
||||
|
||||
The IndexedDB-based chart data storage system has been successfully implemented. This system provides persistent storage for live chart/timeseries data, preserving complete historical data across navigation and browser sessions.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### Core Infrastructure
|
||||
|
||||
1. **`src/lib/timeseriesDB.ts`** - IndexedDB wrapper layer
|
||||
- Low-level database operations (CRUD)
|
||||
- Efficient time-range queries with composite indexes
|
||||
- Automatic cleanup of old data
|
||||
- Schema: `[namespaceId, seriesName, timestamp]` composite key
|
||||
|
||||
2. **`src/lib/timeseriesPersistent.ts`** - Persistent TimeSeries implementation
|
||||
- Drop-in replacement for `createTimeSeries()`
|
||||
- Short series (5s) stays in RAM for performance
|
||||
- Long series (1h+) automatically persists to IndexedDB
|
||||
- Async initialization with historical data loading
|
||||
- Configurable retention and cleanup policies
|
||||
|
||||
3. **`src/lib/namespacePersistence.ts`** - Namespace integration helpers
|
||||
- Type-safe API for creating multiple persistent series
|
||||
- Factory pattern for reusable series definitions
|
||||
- Simplified async initialization handling
|
||||
- Batch operations for efficiency
|
||||
|
||||
### User-Facing Features
|
||||
|
||||
4. **`src/lib/storageManagement.ts`** - Storage management utilities
|
||||
- Check storage usage and quota
|
||||
- Export data to CSV/JSON
|
||||
- Clear old data (series or namespace level)
|
||||
- Request persistent storage (browser won't clear)
|
||||
- Format utilities for display
|
||||
|
||||
5. **`src/hooks/useStorageManagement.ts`** - React hooks
|
||||
- `useStorageUsage()` - Monitor storage in real-time
|
||||
- `useSeriesStats()` - Get statistics for a series
|
||||
- `useSeriesManagement()` - Clear/export series data
|
||||
- `useNamespaceManagement()` - Manage namespace data
|
||||
- `usePersistenceCapabilities()` - Check browser support
|
||||
- `useStorageDashboard()` - All-in-one hook
|
||||
|
||||
6. **`src/components/StorageManagement.example.tsx`** - UI component example
|
||||
- Complete storage dashboard component
|
||||
- Display usage, stats, and controls
|
||||
- Export and clear functionality
|
||||
- Ready to integrate into settings pages
|
||||
|
||||
### Documentation & Examples
|
||||
|
||||
7. **`src/machines/winder/winder2/winder2NamespaceHybrid.example.ts`**
|
||||
- Complete migration example
|
||||
- Shows how to convert existing namespaces
|
||||
- Detailed inline comments and migration notes
|
||||
- Production-ready template
|
||||
|
||||
8. **`docs/developer-docs/indexeddb-chart-persistence.md`**
|
||||
- Comprehensive documentation
|
||||
- Architecture overview and data flow
|
||||
- API reference
|
||||
- Migration guide with step-by-step instructions
|
||||
- Performance considerations
|
||||
- Troubleshooting guide
|
||||
- Best practices
|
||||
|
||||
## Key Features
|
||||
|
||||
### ✅ Automatic Persistence
|
||||
- Long timeseries data (1h+ retention) automatically saved to IndexedDB
|
||||
- Fire-and-forget async writes don't block UI updates
|
||||
- No code changes needed in chart components
|
||||
|
||||
### ✅ Performance Optimized
|
||||
- Short timeseries (5s window) remains RAM-only
|
||||
- Indexed queries for fast time-range lookups
|
||||
- Circular buffers maintain constant memory usage
|
||||
- Throttled writes at 30 FPS prevent excessive DB operations
|
||||
|
||||
### ✅ Historical Data Loading
|
||||
- Automatically loads historical data on mount
|
||||
- Only loads data within retention window
|
||||
- Graceful degradation if loading fails
|
||||
- Non-blocking initialization
|
||||
|
||||
### ✅ API Compatibility
|
||||
- Maintains compatibility with existing `createTimeSeries()` API
|
||||
- Existing components work without changes
|
||||
- Gradual migration path - can run both systems side-by-side
|
||||
- Drop-in replacement pattern
|
||||
|
||||
### ✅ Data Management
|
||||
- Automatic cleanup of old data based on retention policy
|
||||
- Manual clear/export functionality
|
||||
- CSV and JSON export formats
|
||||
- Browser persistence protection request
|
||||
|
||||
### ✅ Production Ready
|
||||
- TypeScript type safety throughout
|
||||
- Error handling and logging
|
||||
- Browser compatibility checks
|
||||
- Configurable retention and cleanup
|
||||
- Memory and storage monitoring
|
||||
|
||||
## How to Use
|
||||
|
||||
### Quick Start (Minimal Changes)
|
||||
|
||||
Replace `createTimeSeries()` calls:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const { initialTimeSeries, insert } = createTimeSeries();
|
||||
|
||||
// After
|
||||
import { createPersistentTimeSeries } from '@/lib/timeseriesPersistent';
|
||||
import { serializeNamespaceId } from '@/client/socketioStore';
|
||||
|
||||
const { initialTimeSeries, insert } = createPersistentTimeSeries(
|
||||
serializeNamespaceId(namespaceId),
|
||||
'seriesName'
|
||||
);
|
||||
```
|
||||
|
||||
### Full Namespace Migration
|
||||
|
||||
See [`winder2NamespaceHybrid.example.ts`](../electron/src/machines/winder/winder2/winder2NamespaceHybrid.example.ts) for complete example.
|
||||
|
||||
Key changes:
|
||||
1. Add `namespaceId` parameter to store creation
|
||||
2. Use `createNamespacePersistentTimeSeries()` for all series
|
||||
3. Update message handler to use insert functions from result
|
||||
4. Handle async initialization with `.ready` promise
|
||||
|
||||
### Add Storage UI
|
||||
|
||||
Use the example component:
|
||||
|
||||
```typescript
|
||||
import { StorageManagement } from '@/components/StorageManagement.example';
|
||||
|
||||
function SettingsPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Storage Settings</h1>
|
||||
<StorageManagement />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Default retention settings:
|
||||
- **Short series**: 5 seconds (RAM only)
|
||||
- **Long series**: 1 hour (RAM + IndexedDB)
|
||||
- **Max retention**: 7 days (IndexedDB only)
|
||||
- **Cleanup interval**: 1 hour
|
||||
- **Sample rate short**: 20ms
|
||||
- **Sample rate long**: 1s
|
||||
|
||||
All values are configurable per-series or globally.
|
||||
|
||||
## Files Created
|
||||
|
||||
### Core Implementation (5 files)
|
||||
- `src/lib/timeseriesDB.ts` (379 lines)
|
||||
- `src/lib/timeseriesPersistent.ts` (315 lines)
|
||||
- `src/lib/namespacePersistence.ts` (194 lines)
|
||||
- `src/lib/storageManagement.ts` (394 lines)
|
||||
- `src/hooks/useStorageManagement.ts` (227 lines)
|
||||
|
||||
### Examples & Documentation (3 files)
|
||||
- `src/components/StorageManagement.example.tsx` (370 lines)
|
||||
- `src/machines/winder/winder2/winder2NamespaceHybrid.example.ts` (463 lines)
|
||||
- `docs/developer-docs/indexeddb-chart-persistence.md` (588 lines)
|
||||
|
||||
**Total**: 8 new files, ~2,930 lines of production-ready code
|
||||
|
||||
## Dependencies
|
||||
|
||||
Added `idb` package:
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"idb": "^8.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No other dependencies required. Uses standard browser APIs (IndexedDB, Storage API).
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Testing (Recommended)
|
||||
1. Keep existing RAM-only namespaces unchanged
|
||||
2. Create one test namespace with persistence
|
||||
3. Verify functionality and performance
|
||||
4. Monitor storage usage
|
||||
|
||||
### Phase 2: Gradual Rollout
|
||||
1. Migrate high-priority namespaces first
|
||||
2. Run both systems in parallel
|
||||
3. Compare behavior and performance
|
||||
4. Collect user feedback
|
||||
|
||||
### Phase 3: Full Migration
|
||||
1. Migrate all remaining namespaces
|
||||
2. Add storage management UI
|
||||
3. Update user documentation
|
||||
4. Monitor production metrics
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] TypeScript compilation passes with no errors
|
||||
- [ ] Unit tests for timeseriesDB operations
|
||||
- [ ] Integration tests for namespace persistence
|
||||
- [ ] Browser compatibility testing (Chrome, Firefox, Safari)
|
||||
- [ ] Performance testing with large datasets
|
||||
- [ ] Storage quota handling
|
||||
- [ ] Offline behavior
|
||||
- [ ] Cross-tab synchronization (future enhancement)
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Immediate
|
||||
1. **Test the implementation** - Create a test namespace and verify persistence
|
||||
2. **Add to one machine type** - Start with a single machine type (e.g., Winder2)
|
||||
3. **Monitor metrics** - Watch storage usage, performance, and any errors
|
||||
4. **Gather feedback** - Get user input on data retention and export features
|
||||
|
||||
### Short Term
|
||||
1. **Add storage UI** - Integrate StorageManagement component into settings
|
||||
2. **Migrate more namespaces** - Expand to other machine types
|
||||
3. **Add loading indicators** - Show users when historical data is loading
|
||||
4. **Implement data validation** - Add integrity checks for imported data
|
||||
|
||||
### Long Term
|
||||
1. **Data compression** - Implement compression for long-term storage
|
||||
2. **Cloud sync** - Optional cloud backup/sync functionality
|
||||
3. **Advanced queries** - Add more sophisticated data analysis tools
|
||||
4. **Performance optimization** - Implement lazy loading and pagination
|
||||
5. **Cross-tab sync** - Synchronize data across multiple browser tabs
|
||||
|
||||
## Performance Impact
|
||||
|
||||
Based on the implementation:
|
||||
|
||||
### Memory
|
||||
- **Minimal increase**: Only historical data in circular buffers (~30KB per machine)
|
||||
- **Automatic cleanup**: Old data removed to maintain constant memory
|
||||
- **No memory leaks**: Proper cleanup on unmount
|
||||
|
||||
### CPU
|
||||
- **Negligible**: Async writes don't block main thread
|
||||
- **Throttled**: Maximum 1 write per second (sample rate)
|
||||
- **Indexed queries**: O(log n) lookups with proper indexes
|
||||
|
||||
### Storage
|
||||
- **~720KB per machine per hour** of data (5 series)
|
||||
- **~120MB per machine per week** (7 day default retention)
|
||||
- **Automatic cleanup**: Runs every hour to remove old data
|
||||
- **User control**: Export and clear functionality
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
✅ **Supported:**
|
||||
- Chrome/Edge 89+
|
||||
- Firefox 90+
|
||||
- Safari 14.1+
|
||||
- Electron (built-in)
|
||||
|
||||
⚠️ **Graceful Degradation:**
|
||||
- Falls back to RAM-only storage if IndexedDB unavailable
|
||||
- Shows warning message to user
|
||||
- All features remain functional (just not persistent)
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
- **Local storage only**: Data never leaves the device
|
||||
- **No external services**: All operations are client-side
|
||||
- **User control**: Clear data at any time
|
||||
- **Follows browser policies**: Respects user privacy settings
|
||||
- **No PII**: Only numeric timeseries data stored
|
||||
|
||||
## Support & Troubleshooting
|
||||
|
||||
See the documentation for:
|
||||
- Common issues and solutions
|
||||
- Performance optimization tips
|
||||
- Storage quota management
|
||||
- Data export/import procedures
|
||||
|
||||
## Conclusion
|
||||
|
||||
The IndexedDB chart data storage system is **production-ready** and can be integrated into the application with minimal risk. The implementation:
|
||||
|
||||
✅ Solves the data loss problem on navigation
|
||||
✅ Maintains backward compatibility
|
||||
✅ Provides excellent performance
|
||||
✅ Includes comprehensive documentation
|
||||
✅ Offers a clear migration path
|
||||
✅ Has user-facing management tools
|
||||
✅ Follows best practices
|
||||
|
||||
**Recommended Action**: Start with one namespace as a pilot, gather metrics and feedback, then gradually roll out to other namespaces.
|
||||
|
||||
---
|
||||
|
||||
*Implementation completed: January 12, 2026*
|
||||
*Branch: `1014-better-chart-datapoint-handling`*
|
||||
@@ -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.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Minimal Example — Analog Input Wago 750-455
|
||||
|
||||
## Table of Contents
|
||||
1. [Introduction](#1-introduction)
|
||||
2. [Requirements](#2-requirements)
|
||||
3. [Hardware Setup](#3-hardware-setup)
|
||||
4. [Software Setup](#4-software-setup)
|
||||
5. [Demo](#5-demo)
|
||||
6. [References](#6-references)
|
||||
|
||||
## 1. Introduction
|
||||
The Wago 750-455 Analog Input example demonstrates how to measure analog current on a **Wago 750-455 EtherCAT module** using the QiTech machine framework. This module provides 4 independent analog input channels that can read 4-20mA signals.
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
### Hardware
|
||||
- **Wago 750-354 EtherCAT Coupler**
|
||||
- **Wago 750-602 Power Supply** (24V VDC)
|
||||
- **Wago 750-455 EtherCAT Module** (4-channel analog input, 4-20mA)
|
||||
- **Wago 750-600 End Module**
|
||||
- **24 V DC power supply** for the Wago system
|
||||
- **Adjustable DC power supply** (or any other means of producing 4-20mA DC @ <10V)
|
||||
- Jumper / bridge wires (0.5–1.5 mm² recommended)
|
||||
- A **Linux PC** (Ubuntu/Debian recommended)
|
||||
- Standard Ethernet cable
|
||||
- Flat screwdriver
|
||||
|
||||
### Software
|
||||
*(Installation steps in Section 4)*
|
||||
- Rust toolchain
|
||||
- Node.js + npm
|
||||
- Git
|
||||
- QiTech Control repository
|
||||
- EtherCAT HAL (included inside repo)
|
||||
|
||||
## 3. Hardware Setup
|
||||
|
||||
### 3.1 Overview
|
||||
The Wago 750-455 is a 4-channel analog input module that measures current signals in the 4-20mA range. Each channel has its own input terminals and can operate independently.
|
||||
|
||||
#### ⚠️ Safety Warning
|
||||
Always disconnect power before wiring.
|
||||
Working on live EtherCAT modules can cause serious damage or electrical shock.
|
||||
|
||||
### 3.2 Wiring Procedure
|
||||
|
||||
#### 3.2.1 Safe Wiring (Wago Spring Clamp)
|
||||
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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 3.3 Wago 750-455 Module Wiring
|
||||
|
||||
The Wago 750-455 has 4 analog input channels. Each channel requires two terminals:
|
||||
|
||||
**Channel 1 (AI1):**
|
||||
- Terminal 1: ⊕ (Positive input)
|
||||
- Terminal 2: ⊖ (Negative input/Ground)
|
||||
|
||||
**Channel 2 (AI2):**
|
||||
- Terminal 3: ⊕ (Positive input)
|
||||
- Terminal 4: ⊖ (Negative input/Ground)
|
||||
|
||||
**Channel 3 (AI3):**
|
||||
- Terminal 5: ⊕ (Positive input)
|
||||
- Terminal 6: ⊖ (Negative input/Ground)
|
||||
|
||||
**Channel 4 (AI4):**
|
||||
- Terminal 7: ⊕ (Positive input)
|
||||
- Terminal 8: ⊖ (Negative input/Ground)
|
||||
|
||||
For this minimal example, we'll connect at least one channel to an adjustable power supply:
|
||||
|
||||
1. Wire adjustable power supply ⊕ to **Terminal 1** (Channel 1 positive)
|
||||
2. Wire adjustable power supply ⊖ to **Terminal 2** (Channel 1 negative)
|
||||
|
||||
*Repeat for additional channels as needed.*
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Wago I/O System Integration
|
||||
|
||||
The Wago 750-455 module plugs into a Wago fieldbus system. Consult your specific Wago controller/coupler documentation for:
|
||||
- Power supply requirements
|
||||
- Network configuration
|
||||
- Module placement in the I/O rack
|
||||
|
||||
The module automatically connects to the backplane for EtherCAT communication and power.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Power & Ethernet
|
||||
|
||||
#### Power
|
||||
Ensure your Wago system has adequate 24V DC power connected per manufacturer specifications.
|
||||
|
||||
#### Ethernet
|
||||
Connect your PC to the Wago controller/coupler using a standard Ethernet cable.
|
||||
|
||||
---
|
||||
|
||||
## 4. Software Setup
|
||||
|
||||
### 4.1 Installing on Ubuntu/Debian
|
||||
|
||||
Paste this into your terminal:
|
||||
|
||||
```bash
|
||||
# Press Enter when prompted
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
sudo apt update
|
||||
sudo apt install -y npm nodejs git
|
||||
|
||||
git clone git@github.com:qitechgmbh/control.git
|
||||
cd control/electron
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4.2 Running the Backend
|
||||
|
||||
```bash
|
||||
./cargo_run_linux.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
- Builds the backend
|
||||
- Grants required system capabilities (raw sockets)
|
||||
- Starts EtherCAT communication
|
||||
|
||||
Ensure the Wago controller is connected and powered.
|
||||
|
||||
### 4.3 Running the Frontend
|
||||
|
||||
```bash
|
||||
cd electron
|
||||
npm run start
|
||||
```
|
||||
|
||||
This launches the QiTech Control dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 5. Demo
|
||||
|
||||
### 5.1 Assigning Devices in the Dashboard
|
||||
|
||||
Once both backend and frontend are running:
|
||||
|
||||
1. Navigate to **Setup → EtherCAT** page
|
||||
2. You should see your Wago controller and the 750-455 module listed
|
||||
3. Select the Device called 750-354 and assign the Wago AI Test V1 Machine, set the Serial to 1 and set the device role to 0 (Wago Bus Coupler)
|
||||
4. Press write thn restart the backend to load the changes
|
||||
|
||||
### 5.2 Testing Analog Input
|
||||
|
||||
1. Set your adjustable power supply to output a current in the 4-20mA range
|
||||
2. In the dashboard, navigate to the monitoring view
|
||||
3. Observe the analog input value for the connected channel(s)
|
||||
|
||||
### 5.3 Channel Specifications
|
||||
|
||||
The Wago 750-455 provides:
|
||||
- **Input Range:** 4-20mA
|
||||
- **Wiring Error Detection:** Built-in detection for open/short circuit conditions
|
||||
- **4 Independent Channels:** Each can be read separately
|
||||
|
||||
### 5.4 Error Detection
|
||||
|
||||
The module includes wiring error detection. If you see a wiring error flag:
|
||||
- Check that your current source is within the 4-20mA range
|
||||
- Verify all wire connections are secure
|
||||
- Ensure the current loop is properly closed
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
### Wago Documentation
|
||||
- [Wago 750-455 Product Page](https://www.wago.com/de-en/io-systems/4-channel-analog-input/p/750-455)
|
||||
- Wago I/O System manual
|
||||
- EtherCAT configuration guide for Wago systems
|
||||
|
||||
### QiTech Control
|
||||
- [Architecture Overview](../architecture-overview.md)
|
||||
- [EtherCAT Basics](../ethercat-basics.md)
|
||||
- [Device Documentation](../devices.md)
|
||||
- [Getting Started Guide](getting-started.md)
|
||||
|
||||
### Related Examples
|
||||
- [Minimal Example — EL3021](minimal-example-el3021.md) (Single channel analog input)
|
||||
- [Minimal Example — EL2004](minimal-example-el2004.md) (Digital output)
|
||||
@@ -0,0 +1,240 @@
|
||||
# Minimal Example — LED Control on the WAGO750-402
|
||||
A complete hardware + software walkthrough
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
1. [Introduction](#1-introduction)
|
||||
2. [Requirements](#2-requirements)
|
||||
3. [Hardware Setup](#3-hardware-setup)
|
||||
4. [Software Setup](#4-software-setup)
|
||||
5. [Demo](#5-demo)
|
||||
6. [References](#6-references)
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction
|
||||
The WAGO 750-402 Display Example is a minimal demonstation showing how to display the digital inputs on a WAGO 750-402 EtherCAT terminal using the QiTech machine framework.
|
||||
It represents the simplest possible hardware interaction in the system:
|
||||
Displaying the Digital Inputs using the QiTech Control Dashboard.
|
||||
## 2. Requirements
|
||||
|
||||
### Hardware
|
||||
- WAGO **2787-2144 EtherCAT**
|
||||
- WAGO **750-354 EtherCAT Coupler**
|
||||
- WAGO **750-602 EtherCAT Power Terminal**
|
||||
- WAGO **750-402 EtherCAT Terminal** (4.channle digital input)
|
||||
- WAGO **750-600 EtherCAT Endmodule**
|
||||
- WAGO **852-1322 Ethernet-Switch**
|
||||
- **24V DC Lab power supply**(AC/DC adapter)
|
||||
- A **Linux PC** (Ubuntu/Debian recommended)
|
||||
- Standard Ethernet cable
|
||||
- Flat screwdriver
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Software
|
||||
*(Installation steps in Section 4)*
|
||||
|
||||
- Rust tollchain
|
||||
- Node-js + npm
|
||||
- Git
|
||||
- QiTech Control repository
|
||||
- EtherCAT HAL *(include inside repo)*
|
||||
|
||||
## 3. Hardware Setup
|
||||
|
||||
### 3.1 Schematic
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
#### ⚠️ 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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 3.2.2 Wiring Steps (Used in This Example)
|
||||
|
||||
We supply power to the other devices by using the **750-602 EtherCAT Power Terminal**
|
||||
|
||||
Perform the following wiring on the Ethernet-Switch:
|
||||
|
||||
1. Red wire (+24V) → PWR+
|
||||
2. Black wire (0V) → RPS-
|
||||
|
||||
#### **Figure 1 —**
|
||||
<img src="../assets/WAGO750_402_switch.JPG" width="400">
|
||||
|
||||
---
|
||||
|
||||
### 3.3 WAGO Wiring the Coupler
|
||||
|
||||
Wire the **750-354 EtherCAT Coupler** to the power supply
|
||||
1. red wire (+24V)
|
||||
2. yellow (0V)
|
||||
|
||||
#### **Figure 2 —**
|
||||
<img src="../assets/WAGO750_402_coupler.JPG" width="400">
|
||||
|
||||
---
|
||||
|
||||
### 3.4 750-602 EtherCAT Power Terminal Integration
|
||||
|
||||
Slide the **750-602 EtherCAT Power Terminal** onto the **right side of the 750-354 Coupler** until it locks.
|
||||
|
||||
Next We need to wire the EtherCat Power Terminal
|
||||
|
||||
Perform the next wiring Steps:
|
||||
|
||||
1. Green wire (Ground) → **Terminal4**
|
||||
2. Red wire (+24V) from **750-602 EtherCAT Power Terminal** → **Terminal 2**
|
||||
3. Black wire(0V) from **750-602 EtherCAT Power Terminal** → **Terminal 3**
|
||||
4. Red wire (+24V) from **750-354 EtherCAT Coupler** → **Terminal 6**
|
||||
5. Yellow wire (0V) from **750-354 EtherCAT Coupler** → **Terminal 7**
|
||||
|
||||
#### **Figure 3 — Wiring**
|
||||
<img src="../assets/WAGO750_402_power_terminal.JPG" width="400">
|
||||
|
||||
---
|
||||
|
||||
## 3.5 750-402 EtherCAT Terminal Integration
|
||||
|
||||
Slide the **750-402 EtherCAT Terminal** onto to **right side of the 750-602 EtherCAT Power Terminal** until it locks.
|
||||
|
||||
Wire from the **24V DC Lab Adapter** → **Terminal 1**
|
||||
|
||||
#### **Figure 4 — Wiring**
|
||||
<img src="../assets/WAGO750_402_terminal.JPG" width="400">
|
||||
|
||||
## 3.6 750-600 Final Assemly
|
||||
|
||||
Slide the **750-600 EtherCAT Endmodule** onto to **right side of the 7750-402 EtherCAT Terminal** until it locks.
|
||||
|
||||
No wiring is required
|
||||
|
||||
#### **Figure 5 — 750-402 + 750-600 Connected**
|
||||
<img src="../assets/WAGO750_402_end_modul.JPG" width="400">
|
||||
|
||||
## 3.7 Power & Etherneet
|
||||
|
||||
### Power
|
||||
|
||||
Connect the 2787-2144 power supply to the power outlet
|
||||
|
||||
### Ehternet
|
||||
|
||||
Use a standard LAN cable to connect to your PC → **852-1322 Ethernet-Switch**
|
||||
|
||||
And another from **852-1322 Ethernet-Switch** → **750-354 EtherCAT Coupler**
|
||||
|
||||
#### **Figure 6 — Ethernet**
|
||||
<img src="../assets/WAGO750_402_ethernet.JPG" width="400">
|
||||
|
||||
## 4. Software Setup
|
||||
|
||||
### 4.1 Installing on Ubuntu/Debian
|
||||
Paste this into your terminal:
|
||||
|
||||
```bash
|
||||
# Press Enter when prompted
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
sudo apt update
|
||||
sudo apt install -y npm nodejs git
|
||||
|
||||
git clone git@github.com:qitechgmbh/control.git
|
||||
cd control/electron
|
||||
npm install
|
||||
```
|
||||
### 4.2 Running the Backend
|
||||
```bash
|
||||
./cargo_run_linux.sh
|
||||
```
|
||||
This script:
|
||||
|
||||
- Builds the backend
|
||||
|
||||
- Grants required system capabilities (raw sockets)
|
||||
|
||||
- Starts EtherCAT communication
|
||||
|
||||
Ensure the WAGO750-402 is connected.
|
||||
|
||||
### 4.3 Running the Frontend
|
||||
```bash
|
||||
cd electron
|
||||
npm run start
|
||||
```
|
||||
|
||||
This launches the QiTech Control dashboard.
|
||||
|
||||
## 5. Demo
|
||||
|
||||
### 5.1 Assigning Devices in the Dashboard
|
||||
|
||||
Once the backend + frontend are running, you should see:
|
||||
|
||||
- WAGO750-354 (Coupler)
|
||||
|
||||
- wago module (Digital Input Terminal)
|
||||
|
||||

|
||||
|
||||
Steps:
|
||||
|
||||
- Click Assign on the wago module
|
||||
|
||||
- Select Digital Input Machine V1
|
||||
|
||||

|
||||
|
||||
|
||||
- Enter a serial number except for 0
|
||||
|
||||

|
||||
|
||||
- Click Write
|
||||
|
||||
### 5.2 Diyplaying LED
|
||||
Navigate to:
|
||||
|
||||
Machines → DigitalInputMachine
|
||||
|
||||

|
||||
|
||||
You will see this interface:
|
||||
|
||||

|
||||
|
||||
Now you can apply a Voltage of the Range of 15V-30V (+24V recommended) from the DC lab power supply to the Terminal.
|
||||
The Terminal can read the digital input, which is signaled by the LED on the Terminal and should be displayed in the Control software.
|
||||
|
||||
In our example it lights up the first LED
|
||||
|
||||

|
||||
|
||||
## 6. References
|
||||
This guide incorporates information from official WAGO documentation.
|
||||
All diagrams, product names, and figures belong to Beckhoff Automation GmbH & Co. KG and are used here solely for educational purposes.
|
||||
|
||||
Referenced Manuals
|
||||
|
||||
[WAGO 750-402 Documentation](https://www.wago.com/de/io-systeme/4-kanal-digitaleingang/p/750-402#downloads)
|
||||
|
||||
[WAGO 750-354 Documentation](https://www.wago.com/de/io-systeme/feldbuskoppler-ethercat/p/750-354#details)
|
||||
|
||||
[WAGO 750-602 Documentation](https://www.wago.com/de/io-systeme/potentialeinspeisung/p/750-602#details)
|
||||
|
||||
[WAGO 750-354 Documentation](https://www.wago.com/de/io-systeme/endmodul/p/750-600#details)
|
||||
@@ -0,0 +1,241 @@
|
||||
# REST API `/api/v2`
|
||||
|
||||
The Qitech Control Panel exposes a small HTTP interface for discovering machines and reading their current values. All examples below assume you are connected to the panel’s Ethernet subnet and talk directly to the panel at:
|
||||
|
||||
- **Base URL:** `http://10.10.10.1:3001`
|
||||
|
||||
> **Schema note:** This documentation intentionally stays light on field details beyond what is shown in the examples. For the exact datatypes and complete payload shapes, refer to the corresponding Rust types (linked below).
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
The panel does **not** perform HTTP authentication. The expected security model is **network-level isolation**: anything that can send packets to the panel’s Ethernet interface is treated as trusted. This matches common security assumptions in EtherCAT-style control networks. It is therefore suggested to run the entire production line in isolated operation.
|
||||
|
||||
The panel is configured to administer its own subnet `10.10.10.0/24` via DHCP, while Wi-Fi can be used for upstream internet connectivity. To let the outside world communicate with the production line, a router should be placed at the network bondary bridging the production line's subnet and the rest of the network. The router can run in **client/bridge mode** on the line's subnet and expose the panel's API via **port forwading** or through a **reverse proxy** where one can add authentication, logging, rate limiting, etc.
|
||||
|
||||
The following graphic depicts the suggested network topology for a single production line. Many lines could potentially share the same router.
|
||||
|
||||
<p align="center">
|
||||
<img width="769" height="528" alt="Suggested network topology" src="./assets/subnet.png" />
|
||||
</p>
|
||||
|
||||
If DNS is available on the subnet, you may be able to resolve `qitech.control`; otherwise use the static address `10.10.10.1`.
|
||||
|
||||
---
|
||||
|
||||
## List machines `GET /api/v2/machine`
|
||||
|
||||
Returns the set of machines currently known/connected to the panel.
|
||||
|
||||
Machines are identified by:
|
||||
|
||||
- `slug`: the machine type / model identifier (string)
|
||||
- `serial`: the specific machine instance identifier (int)
|
||||
|
||||
Each machine also includes a `legacy_id` to support older **v1** workflows. If a machine reports an issue, the `error` field may be present and non-null (containing an error message).
|
||||
|
||||
### Example request
|
||||
|
||||
```bash
|
||||
curl -X GET "http://10.10.10.1:3001/api/v2/machine"
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
```json
|
||||
{
|
||||
"machines": [
|
||||
{
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 7
|
||||
},
|
||||
"serial": 57922
|
||||
},
|
||||
"serial": 57922,
|
||||
"vendor": "QiTech",
|
||||
"slug": "mock",
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 4
|
||||
},
|
||||
"serial": 57922
|
||||
},
|
||||
"serial": 57922,
|
||||
"vendor": "QiTech",
|
||||
"slug": "extruder_v1",
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 2
|
||||
},
|
||||
"serial": 57922
|
||||
},
|
||||
"serial": 57922,
|
||||
"vendor": "QiTech",
|
||||
"slug": "winder_v1",
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 10
|
||||
},
|
||||
"serial": 48879
|
||||
},
|
||||
"serial": 48879,
|
||||
"vendor": "QiTech",
|
||||
"slug": "wago_power_v1",
|
||||
"error": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get current values `GET /api/v2/machine/<slug>/<serial>`
|
||||
|
||||
Returns all currently known values for a single machine.
|
||||
|
||||
Values are categorized into two groups:
|
||||
|
||||
- **State**: requested/commanded values (these typically change only after a state-change request, or if another controller updates them)
|
||||
- **Live Values**: measured/observed values coming from the machine and potentially changing quickly
|
||||
|
||||
This REST endpoint returns **only the current snapshot**, not a stream of live values.
|
||||
To receive continuous updates (via WebSockets), subscribe to the machine namespace (see **WebSockets** below).
|
||||
|
||||
### Example request (mock machine)
|
||||
|
||||
```bash
|
||||
curl -X GET "http://10.10.10.1:3001/api/v2/machine/mock/57922"
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
```json
|
||||
{
|
||||
"machine": {
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 7
|
||||
},
|
||||
"serial": 57922
|
||||
},
|
||||
"serial": 57922,
|
||||
"vendor": "QiTech",
|
||||
"slug": "mock",
|
||||
"error": null
|
||||
},
|
||||
"state": {
|
||||
"frequency1": 100.0,
|
||||
"frequency2": 200.0,
|
||||
"frequency3": 500.0,
|
||||
"is_default_state": false,
|
||||
"mode_state": {
|
||||
"mode": "Running"
|
||||
}
|
||||
},
|
||||
"live_values": {
|
||||
"amplitude1": -0.03438523433309566,
|
||||
"amplitude2": -0.06872980145477608,
|
||||
"amplitude3": -0.1711138370170743,
|
||||
"amplitude_sum": -0.27422887280494607
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Change machine state `POST /api/v1/machine/<slug>/<serial>`
|
||||
|
||||
State changes are submitted as **mutations**. The mutation payload is defined per machine type in Rust. Conceptually, each item in the mutation list represents a setter-style operation that is applied by the real-time control loop.
|
||||
|
||||
The API does **not** return the newly-applied state in the POST response. The panel runs a real-time loop and generally won’t block waiting for the physical system to converge. Instead:
|
||||
|
||||
- Submit the mutation via `POST`
|
||||
- Poll `GET /api/v2/machine/<slug>/<serial>` to observe the updated state and/or any reported errors
|
||||
|
||||
### Example request (mock machine)
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-d \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://10.10.10.1:3001/api/v1/machine/mock/57922"
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
```json
|
||||
null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSockets
|
||||
|
||||
For continuous updates, subscribe to a machine-specific namespace derived from its `legacy_id`:
|
||||
|
||||
- Namespace: `/machine/<vendor>/<id>/<serial>`
|
||||
|
||||
The stream emits events for:
|
||||
|
||||
- state changes (`StateEvent`)
|
||||
- live value updates (`LiveValuesEvent`)
|
||||
|
||||
Both event payloads use the same machine-specific schema as the `/api/v2` REST responses.
|
||||
|
||||
---
|
||||
|
||||
## List of all machines
|
||||
|
||||
Below is a template you can fill with links to the relevant Rust types (mutations + state/live structs).
|
||||
For each machine, link to:
|
||||
|
||||
- **Mutations:** the request payload type used by `POST /api/v1/machine/<slug>/<serial>`
|
||||
- **State / Live Values:** the response payload types returned by `GET /api/v2/machine/<slug>/<serial>`
|
||||
|
||||
### Machines
|
||||
|
||||
- **winder_v1**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L87>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L163>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L143>
|
||||
|
||||
- **extruder_v1**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L214>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L89>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L55>
|
||||
|
||||
- **laser_v1**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L88>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L32>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L17>
|
||||
|
||||
- **mock**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L84>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L38>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L24>
|
||||
|
||||
- **extruder_v2**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L126>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L93>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L59>
|
||||
@@ -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",
|
||||
@@ -75,7 +75,6 @@
|
||||
"@tanstack/react-router": "^1.130.17",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tanstack/router-devtools": "^1.105.0",
|
||||
"@tanstack/zod-adapter": "^1.116.0",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"babel-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -83,27 +82,26 @@
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"i18next": "^25.0.2",
|
||||
"idb": "^8.0.3",
|
||||
"immer": "^10.1.1",
|
||||
"lucide-react": "^0.511.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"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",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uplot": "^1.6.32",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.2",
|
||||
"zod-validation-error": "^3.4.0",
|
||||
"zustand": "^5.0.8"
|
||||
"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.11"
|
||||
}
|
||||
}
|
||||
@@ -15,25 +15,31 @@ import {
|
||||
machineIdentificationUnique,
|
||||
} from "@/machines/types";
|
||||
import { useRef } from "react";
|
||||
import { rustEnumSchema } from "@/lib/types";
|
||||
import { rustEnum } from "@/lib/types";
|
||||
|
||||
export type EthercatDevices = z.infer<typeof ethercatDevicesSchema>;
|
||||
|
||||
export const ethercatDevicesSchema = z.object({
|
||||
devices: z.array(
|
||||
z.object({
|
||||
configured_address: z.number().int(),
|
||||
name: z.string(),
|
||||
vendor_id: z.number().int(),
|
||||
product_id: z.number().int(),
|
||||
revision: z.number().int(),
|
||||
device_identification: deviceIdentification,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Update the EthercatDevicesEventData schema
|
||||
export const ethercatDevicesEventDataSchema = rustEnumSchema({
|
||||
Initializing: z.boolean(),
|
||||
Done: z.object({
|
||||
devices: z.array(
|
||||
z.object({
|
||||
configured_address: z.number().int(),
|
||||
name: z.string(),
|
||||
vendor_id: z.number().int(),
|
||||
product_id: z.number().int(),
|
||||
revision: z.number().int(),
|
||||
device_identification: deviceIdentification,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Error: z.string(),
|
||||
});
|
||||
export const ethercatDevicesEventDataSchema = z
|
||||
.object({
|
||||
Initializing: z.boolean(),
|
||||
Done: ethercatDevicesSchema,
|
||||
Error: z.string(),
|
||||
})
|
||||
.check(rustEnum);
|
||||
|
||||
export type EthercatDevicesEventData = z.infer<
|
||||
typeof ethercatDevicesEventDataSchema
|
||||
@@ -62,10 +68,12 @@ export const machinesEventSchema = eventSchema(machinesEventDataSchema);
|
||||
export type MachinesEvent = z.infer<typeof machinesEventSchema>;
|
||||
|
||||
// Keep the EthercatInterfaceDiscovery event
|
||||
export const ethercatInterfaceDiscoveryEventDataSchema = rustEnumSchema({
|
||||
Discovering: z.boolean(),
|
||||
Done: z.string(),
|
||||
});
|
||||
export const ethercatInterfaceDiscoveryEventDataSchema = z
|
||||
.object({
|
||||
Discovering: z.boolean(),
|
||||
Done: z.string(),
|
||||
})
|
||||
.check(rustEnum);
|
||||
|
||||
export type EthercatInterfaceDiscoveryEventData = z.infer<
|
||||
typeof ethercatInterfaceDiscoveryEventDataSchema
|
||||
@@ -110,23 +118,19 @@ export function mainMessageHandler(
|
||||
try {
|
||||
// Apply appropriate caching strategy based on event type
|
||||
if (eventName === "EthercatDevicesEvent") {
|
||||
const validatedEvent = ethercatDevicesEventSchema.parse(event);
|
||||
console.log("EthercatDevicesEvent", validatedEvent);
|
||||
const validatedEvent = event;
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
ethercatDevices: validatedEvent,
|
||||
}));
|
||||
} else if (eventName === "MachinesEvent") {
|
||||
const validatedEvent = machinesEventSchema.parse(event);
|
||||
console.log("MachinesEvent", validatedEvent);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
machines: validatedEvent,
|
||||
}));
|
||||
} else if (eventName === "EthercatInterfaceDiscoveryEvent") {
|
||||
const validatedEvent =
|
||||
ethercatInterfaceDiscoveryEventSchema.parse(event);
|
||||
console.log("EthercatInterfaceDiscoveryEvent", validatedEvent);
|
||||
const validatedEvent = event;
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
ethercatInterfaceDiscovery: validatedEvent,
|
||||
|
||||
@@ -163,6 +163,7 @@ export const handleEventValidationError = (
|
||||
error: z.ZodError,
|
||||
eventName: string,
|
||||
) => {
|
||||
console.log(error);
|
||||
toastZodError(error, `Event Validation Error for ${eventName}`);
|
||||
throw new Error(`Event validation failed for ${eventName}`);
|
||||
};
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
/**
|
||||
* Storage Management Component - Example Implementation
|
||||
*
|
||||
* This component demonstrates how to integrate the IndexedDB storage
|
||||
* management system into your UI. It can be used as-is in a settings
|
||||
* page or adapted to your specific needs.
|
||||
*
|
||||
* Features:
|
||||
* - Display storage usage and quota
|
||||
* - Show series statistics
|
||||
* - Export data to CSV/JSON
|
||||
* - Clear old data
|
||||
* - Request persistent storage
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useStorageDashboard } from "@/hooks/useStorageManagement";
|
||||
import { serializeNamespaceId, NamespaceId } from "@/client/socketioStore";
|
||||
|
||||
interface StorageManagementProps {
|
||||
/** Optional namespace to manage */
|
||||
namespaceId?: NamespaceId;
|
||||
/** Optional series name within the namespace */
|
||||
seriesName?: string;
|
||||
}
|
||||
|
||||
export function StorageManagement({
|
||||
namespaceId,
|
||||
seriesName,
|
||||
}: StorageManagementProps) {
|
||||
const serializedNamespaceId = namespaceId
|
||||
? serializeNamespaceId(namespaceId)
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
// Storage usage
|
||||
usage,
|
||||
usageError,
|
||||
|
||||
// Capabilities
|
||||
available,
|
||||
persisted,
|
||||
requesting,
|
||||
requestPersistence,
|
||||
|
||||
// Formatting
|
||||
formatStorageSize,
|
||||
formatPercentage,
|
||||
formatTimestamp,
|
||||
|
||||
// Series stats
|
||||
seriesStats,
|
||||
seriesLoading,
|
||||
seriesError,
|
||||
refreshSeriesStats,
|
||||
|
||||
// Series management
|
||||
clearSeries,
|
||||
exportCSV,
|
||||
exportJSON,
|
||||
clearingSeriesData,
|
||||
|
||||
// Namespace management
|
||||
clearNamespace,
|
||||
clearingNamespaceData,
|
||||
} = useStorageDashboard(serializedNamespaceId, seriesName);
|
||||
|
||||
if (!available) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold text-red-900">
|
||||
Storage Not Available
|
||||
</h3>
|
||||
<p className="text-red-700">
|
||||
IndexedDB storage is not supported in this browser. Chart data will
|
||||
only be stored in memory and will be lost on navigation.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRequestPersistence = async () => {
|
||||
const granted = await requestPersistence();
|
||||
if (granted) {
|
||||
alert("Persistent storage granted! Your data is now protected.");
|
||||
} else {
|
||||
alert(
|
||||
"Persistent storage denied. Data may be cleared by the browser when storage is low.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
if (!exportCSV) return;
|
||||
try {
|
||||
await exportCSV();
|
||||
alert("Data exported successfully!");
|
||||
} catch (error) {
|
||||
alert(`Export failed: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportJSON = async () => {
|
||||
if (!exportJSON) return;
|
||||
try {
|
||||
await exportJSON();
|
||||
alert("Data exported successfully!");
|
||||
} catch (error) {
|
||||
alert(`Export failed: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSeries = async () => {
|
||||
if (!clearSeries) return;
|
||||
const confirmed = confirm(
|
||||
"Are you sure you want to clear all data for this series? This cannot be undone.",
|
||||
);
|
||||
if (confirmed) {
|
||||
try {
|
||||
await clearSeries();
|
||||
refreshSeriesStats?.();
|
||||
alert("Series data cleared successfully!");
|
||||
} catch (error) {
|
||||
alert(`Clear failed: ${error}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearNamespace = async () => {
|
||||
if (!clearNamespace) return;
|
||||
const confirmed = confirm(
|
||||
"Are you sure you want to clear ALL data for this namespace? This cannot be undone.",
|
||||
);
|
||||
if (confirmed) {
|
||||
try {
|
||||
await clearNamespace();
|
||||
refreshSeriesStats?.();
|
||||
alert("Namespace data cleared successfully!");
|
||||
} catch (error) {
|
||||
alert(`Clear failed: ${error}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Overall Storage Usage */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-xl font-semibold">Storage Usage</h2>
|
||||
|
||||
{usageError ? (
|
||||
<p className="text-red-600">Error loading storage info: {usageError.message}</p>
|
||||
) : !usage ? (
|
||||
<p className="text-gray-500">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-700">Used:</span>
|
||||
<span className="font-mono font-medium">
|
||||
{formatStorageSize(usage.usage)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-700">Quota:</span>
|
||||
<span className="font-mono font-medium">
|
||||
{formatStorageSize(usage.quota)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-700">Percent Used:</span>
|
||||
<span className="font-mono font-medium">
|
||||
{formatPercentage(usage.percentUsed)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Visual progress bar */}
|
||||
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
usage.percentUsed > 90
|
||||
? "bg-red-500"
|
||||
: usage.percentUsed > 70
|
||||
? "bg-yellow-500"
|
||||
: "bg-green-500"
|
||||
}`}
|
||||
style={{ width: `${Math.min(usage.percentUsed, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Persistence Status */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-xl font-semibold">Persistence Status</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-700">Protected from browser cleanup:</span>
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-sm font-medium ${
|
||||
persisted
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{persisted ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!persisted && (
|
||||
<button
|
||||
onClick={handleRequestPersistence}
|
||||
disabled={requesting}
|
||||
className="w-full rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{requesting ? "Requesting..." : "Request Persistent Storage"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-600">
|
||||
{persisted
|
||||
? "Your data is protected and won't be cleared by the browser automatically."
|
||||
: "Your data may be cleared if browser storage is low. Click to request protection."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Series-Specific Management */}
|
||||
{seriesName && serializedNamespaceId && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-xl font-semibold">
|
||||
Series: {seriesName}
|
||||
</h2>
|
||||
|
||||
{seriesError ? (
|
||||
<p className="text-red-600">Error: {seriesError.message}</p>
|
||||
) : seriesLoading ? (
|
||||
<p className="text-gray-500">Loading series stats...</p>
|
||||
) : !seriesStats ? (
|
||||
<p className="text-gray-500">No data available</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-700">Data Points:</span>
|
||||
<span className="font-mono">{seriesStats.count.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-700">Estimated Size:</span>
|
||||
<span className="font-mono">
|
||||
{formatStorageSize(seriesStats.estimatedSizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-700">Oldest Data:</span>
|
||||
<span className="font-mono text-sm">
|
||||
{formatTimestamp(seriesStats.oldestTimestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-700">Newest Data:</span>
|
||||
<span className="font-mono text-sm">
|
||||
{formatTimestamp(seriesStats.newestTimestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={refreshSeriesStats}
|
||||
className="w-full rounded border border-gray-300 bg-white px-4 py-2 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Refresh Stats
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="rounded border border-gray-300 bg-white px-4 py-2 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportJSON}
|
||||
className="rounded border border-gray-300 bg-white px-4 py-2 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleClearSeries}
|
||||
disabled={clearingSeriesData}
|
||||
className="w-full rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{clearingSeriesData ? "Clearing..." : "Clear Series Data"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Namespace-Level Management */}
|
||||
{serializedNamespaceId && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-xl font-semibold text-red-900">
|
||||
Danger Zone
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-red-700">
|
||||
Clear all timeseries data for this namespace. This action cannot be undone.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleClearNamespace}
|
||||
disabled={clearingNamespaceData}
|
||||
className="w-full rounded bg-red-700 px-4 py-2 text-white hover:bg-red-800 disabled:opacity-50"
|
||||
>
|
||||
{clearingNamespaceData
|
||||
? "Clearing..."
|
||||
: "Clear All Namespace Data"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage in a settings page:
|
||||
*
|
||||
* ```tsx
|
||||
* import { StorageManagement } from '@/components/StorageManagement';
|
||||
*
|
||||
* function SettingsPage() {
|
||||
* return (
|
||||
* <div className="p-8">
|
||||
* <h1>Storage Settings</h1>
|
||||
* <StorageManagement />
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Example with specific namespace and series:
|
||||
*
|
||||
* ```tsx
|
||||
* function Winder2Settings({ machineId }) {
|
||||
* const namespaceId = {
|
||||
* type: 'machine',
|
||||
* machine_identification_unique: machineId
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <StorageManagement
|
||||
* namespaceId={namespaceId}
|
||||
* seriesName="pullerSpeed"
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PresetData, PresetSchema } from "@/lib/preset/preset";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,13 +13,13 @@ import { Separator } from "../ui/separator";
|
||||
import { PresetPreviewEntries, PresetPreviewTable } from "./PresetPreviewTable";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
export type NewPresetDialogProps<T extends PresetSchema> = {
|
||||
currentState?: PresetData<T>;
|
||||
export type NewPresetDialogProps<T> = {
|
||||
currentState?: T;
|
||||
previewEntries: PresetPreviewEntries<T>;
|
||||
onSave: (name: string) => void;
|
||||
};
|
||||
|
||||
export function NewPresetDialog<T extends PresetSchema>({
|
||||
export function NewPresetDialog<T>({
|
||||
currentState,
|
||||
onSave,
|
||||
previewEntries,
|
||||
|
||||
@@ -60,7 +60,7 @@ export function PresetCardMenu({
|
||||
);
|
||||
}
|
||||
|
||||
export type PresetCardProps<T extends PresetSchema> = {
|
||||
export type PresetCardProps<T> = {
|
||||
preset: Preset<T>;
|
||||
onApply: (preset: Preset<T>) => void;
|
||||
onOverwrite: (preset: Preset<T>) => void;
|
||||
@@ -72,7 +72,7 @@ export type PresetCardProps<T extends PresetSchema> = {
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
export function PresetCard<T extends PresetSchema>({
|
||||
export function PresetCard<T>({
|
||||
preset,
|
||||
onApply,
|
||||
onOverwrite,
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
import React, { Fragment } from "react";
|
||||
import { PresetData, PresetSchema } from "@/lib/preset/preset";
|
||||
import { renderUnitSymbol, renderUnitSyntax, Unit } from "@/control/units";
|
||||
|
||||
export const previewSeparator = undefined;
|
||||
|
||||
type PreviewSeparator = typeof previewSeparator;
|
||||
|
||||
type PresetPreviewEntry<T extends PresetSchema> = {
|
||||
type PresetPreviewEntry<T> = {
|
||||
name: string;
|
||||
unit?: Unit;
|
||||
renderValue: (data: PresetData<T>) => string | undefined;
|
||||
renderValue: (data: T) => string | undefined;
|
||||
};
|
||||
|
||||
export type PresetPreviewEntries<T extends PresetSchema> = (
|
||||
export type PresetPreviewEntries<T> = (
|
||||
| PresetPreviewEntry<T>
|
||||
| PreviewSeparator
|
||||
)[];
|
||||
|
||||
export type PresetPreviewTableProps<T extends PresetSchema> = {
|
||||
data: PresetData<T>;
|
||||
export type PresetPreviewTableProps<T> = {
|
||||
data?: T;
|
||||
entries: PresetPreviewEntries<T>;
|
||||
};
|
||||
|
||||
export function PresetPreviewTable<T extends PresetSchema>({
|
||||
export function PresetPreviewTable<T>({
|
||||
data,
|
||||
entries,
|
||||
}: PresetPreviewTableProps<T>) {
|
||||
@@ -39,7 +38,7 @@ export function PresetPreviewTable<T extends PresetSchema>({
|
||||
);
|
||||
}
|
||||
|
||||
const value = entry.renderValue(data);
|
||||
const value = data !== undefined ? entry.renderValue(data) : undefined;
|
||||
return (
|
||||
<Fragment key={entry.name}>
|
||||
<div>{entry.name}</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Preset, PresetSchema } from "@/lib/preset/preset";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,14 +13,14 @@ import { Icon } from "../Icon";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { PresetPreviewEntries, PresetPreviewTable } from "./PresetPreviewTable";
|
||||
|
||||
export type PresetShowDialogProps<T extends PresetSchema> = {
|
||||
export type PresetShowDialogProps<T> = {
|
||||
preset: Preset<T>;
|
||||
previewEntries: PresetPreviewEntries<T>;
|
||||
onApply: (preset: Preset<T>) => void;
|
||||
hideDate?: boolean;
|
||||
};
|
||||
|
||||
export function PresetShowDialog<T extends PresetSchema>({
|
||||
export function PresetShowDialog<T>({
|
||||
preset,
|
||||
onApply,
|
||||
previewEntries,
|
||||
|
||||
@@ -2,19 +2,19 @@ import React from "react";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { Page } from "@/components/Page";
|
||||
import { usePresets, UsePresetsParams } from "@/lib/preset/usePresets";
|
||||
import { Preset, PresetSchema } from "@/lib/preset/preset";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
import { PresetCard } from "./PresetCard";
|
||||
import { PresetPreviewEntries } from "./PresetPreviewTable";
|
||||
import { NewPresetDialog } from "./NewPresetDialog";
|
||||
import { downloadJson } from "@/lib/download";
|
||||
import { JsonFileInput } from "../FileInput";
|
||||
|
||||
type PresetsPageProps<T extends PresetSchema> = UsePresetsParams<T> & {
|
||||
type PresetsPageProps<T> = UsePresetsParams<T> & {
|
||||
applyPreset: (preset: Preset<T>) => void;
|
||||
previewEntries: PresetPreviewEntries<T>;
|
||||
};
|
||||
|
||||
export function PresetsPage<T extends PresetSchema>({
|
||||
export function PresetsPage<T>({
|
||||
applyPreset,
|
||||
machine_identification,
|
||||
currentState,
|
||||
|
||||
@@ -36,7 +36,6 @@ type Props = {
|
||||
maxSlider?: number; // Override the slider max value
|
||||
maxLabel?: string;
|
||||
step?: number;
|
||||
valueSchema?: z.ZodType<number>;
|
||||
inverted?: boolean;
|
||||
confirmation?: string;
|
||||
renderValue: (value: number) => string;
|
||||
@@ -89,7 +88,6 @@ export function EditValue({
|
||||
description,
|
||||
defaultValue,
|
||||
step = 1,
|
||||
valueSchema: schema,
|
||||
inverted,
|
||||
min,
|
||||
max,
|
||||
@@ -104,7 +102,7 @@ export function EditValue({
|
||||
|
||||
// Form setup
|
||||
const formSchema = z.object({
|
||||
value: schema ?? z.number(),
|
||||
value: z.number(),
|
||||
});
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -191,7 +189,6 @@ export function EditValue({
|
||||
const numericValue = parseFloat(normalizedInput);
|
||||
const hasValidationError =
|
||||
isNaN(numericValue) ||
|
||||
(schema && !schema.safeParse(numericValue).success) ||
|
||||
(max !== undefined && numericValue > max) ||
|
||||
(min !== undefined && numericValue < min);
|
||||
|
||||
@@ -222,15 +219,7 @@ export function EditValue({
|
||||
setValueStringError(false);
|
||||
preventFormSyncRef.current = false;
|
||||
}
|
||||
}, [
|
||||
valueString,
|
||||
valueStringDirty,
|
||||
schema,
|
||||
max,
|
||||
min,
|
||||
form,
|
||||
roundToStepDecimals,
|
||||
]);
|
||||
}, [valueString, valueStringDirty, max, min, form, roundToStepDecimals]);
|
||||
|
||||
// Reset input state to clean form value
|
||||
const resetInput = React.useCallback(() => {
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* React hooks for IndexedDB storage management
|
||||
*
|
||||
* Provides easy-to-use hooks for React components to interact with
|
||||
* the persistent timeseries storage system.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getStorageUsage,
|
||||
getNamespaceSeriesStats,
|
||||
clearSeries,
|
||||
clearNamespace,
|
||||
downloadSeriesAsCSV,
|
||||
downloadSeriesAsJSON,
|
||||
isPersistenceAvailable,
|
||||
isStoragePersisted,
|
||||
requestPersistentStorage,
|
||||
formatBytes,
|
||||
} from "@/lib/storageManagement";
|
||||
|
||||
/**
|
||||
* Hook to monitor storage usage
|
||||
* Updates every 5 seconds by default
|
||||
*/
|
||||
export function useStorageUsage(updateInterval: number = 5000) {
|
||||
const [usage, setUsage] = useState<{
|
||||
usage: number;
|
||||
quota: number;
|
||||
percentUsed: number;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateUsage = async () => {
|
||||
try {
|
||||
const result = await getStorageUsage();
|
||||
setUsage(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
}
|
||||
};
|
||||
|
||||
updateUsage();
|
||||
const interval = setInterval(updateUsage, updateInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [updateInterval]);
|
||||
|
||||
return { usage, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get statistics for a specific series
|
||||
*/
|
||||
export function useSeriesStats(namespaceId: string, seriesName: string) {
|
||||
const [stats, setStats] = useState<{
|
||||
count: number;
|
||||
oldestTimestamp: number | null;
|
||||
newestTimestamp: number | null;
|
||||
estimatedSizeBytes: number;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getNamespaceSeriesStats(namespaceId, seriesName);
|
||||
setStats(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [namespaceId, seriesName]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { stats, loading, error, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage series data clearing
|
||||
*/
|
||||
export function useSeriesManagement(namespaceId: string, seriesName: string) {
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
setClearing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const success = await clearSeries(namespaceId, seriesName);
|
||||
if (!success) {
|
||||
throw new Error("Failed to clear series data");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
throw err;
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}, [namespaceId, seriesName]);
|
||||
|
||||
const exportCSV = useCallback(
|
||||
async (startTime?: number, endTime?: number) => {
|
||||
try {
|
||||
await downloadSeriesAsCSV(namespaceId, seriesName, startTime, endTime);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[namespaceId, seriesName],
|
||||
);
|
||||
|
||||
const exportJSON = useCallback(
|
||||
async (startTime?: number, endTime?: number) => {
|
||||
try {
|
||||
await downloadSeriesAsJSON(namespaceId, seriesName, startTime, endTime);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[namespaceId, seriesName],
|
||||
);
|
||||
|
||||
return {
|
||||
clear,
|
||||
exportCSV,
|
||||
exportJSON,
|
||||
clearing,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage namespace-level data
|
||||
*/
|
||||
export function useNamespaceManagement(namespaceId: string) {
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const clearAll = useCallback(async () => {
|
||||
setClearing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const success = await clearNamespace(namespaceId);
|
||||
if (!success) {
|
||||
throw new Error("Failed to clear namespace data");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
throw err;
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}, [namespaceId]);
|
||||
|
||||
return {
|
||||
clearAll,
|
||||
clearing,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check persistence capabilities
|
||||
*/
|
||||
export function usePersistenceCapabilities() {
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [persisted, setPersisted] = useState(false);
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setAvailable(isPersistenceAvailable());
|
||||
|
||||
isStoragePersisted().then(setPersisted);
|
||||
}, []);
|
||||
|
||||
const requestPersistence = useCallback(async () => {
|
||||
setRequesting(true);
|
||||
try {
|
||||
const granted = await requestPersistentStorage();
|
||||
setPersisted(granted);
|
||||
return granted;
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
available,
|
||||
persisted,
|
||||
requesting,
|
||||
requestPersistence,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to format storage values for display
|
||||
*/
|
||||
export function useStorageFormatting() {
|
||||
const formatStorageSize = useCallback((bytes: number) => {
|
||||
return formatBytes(bytes);
|
||||
}, []);
|
||||
|
||||
const formatPercentage = useCallback((percent: number) => {
|
||||
return `${percent.toFixed(1)}%`;
|
||||
}, []);
|
||||
|
||||
const formatTimestamp = useCallback((timestamp: number | null) => {
|
||||
if (timestamp === null) return "N/A";
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
formatStorageSize,
|
||||
formatPercentage,
|
||||
formatTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined hook for storage dashboard
|
||||
*/
|
||||
export function useStorageDashboard(namespaceId?: string, seriesName?: string) {
|
||||
const { usage, error: usageError } = useStorageUsage();
|
||||
const capabilities = usePersistenceCapabilities();
|
||||
const formatting = useStorageFormatting();
|
||||
|
||||
const seriesStats =
|
||||
namespaceId && seriesName
|
||||
? // eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useSeriesStats(namespaceId, seriesName)
|
||||
: null;
|
||||
|
||||
const seriesManagement =
|
||||
namespaceId && seriesName
|
||||
? // eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useSeriesManagement(namespaceId, seriesName)
|
||||
: null;
|
||||
|
||||
const namespaceManagement = namespaceId
|
||||
? // eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useNamespaceManagement(namespaceId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
// Storage usage
|
||||
usage,
|
||||
usageError,
|
||||
|
||||
// Capabilities
|
||||
...capabilities,
|
||||
|
||||
// Formatting
|
||||
...formatting,
|
||||
|
||||
// Series-specific (if provided)
|
||||
seriesStats: seriesStats?.stats,
|
||||
seriesLoading: seriesStats?.loading,
|
||||
seriesError: seriesStats?.error,
|
||||
refreshSeriesStats: seriesStats?.refresh,
|
||||
|
||||
// Series management (if provided)
|
||||
clearSeries: seriesManagement?.clear,
|
||||
exportCSV: seriesManagement?.exportCSV,
|
||||
exportJSON: seriesManagement?.exportJSON,
|
||||
clearingSeriesData: seriesManagement?.clearing,
|
||||
|
||||
// Namespace management (if provided)
|
||||
clearNamespace: namespaceManagement?.clearAll,
|
||||
clearingNamespaceData: namespaceManagement?.clearing,
|
||||
};
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
# IndexedDB Chart Data Storage - Quick Reference
|
||||
|
||||
This directory contains the implementation of the IndexedDB-based persistent storage system for chart/timeseries data.
|
||||
|
||||
## 📁 Files Overview
|
||||
|
||||
### Core Implementation
|
||||
|
||||
| File | Purpose | Key Exports |
|
||||
|------|---------|-------------|
|
||||
| **timeseriesDB.ts** | IndexedDB wrapper | `storeDataPoint()`, `queryDataPoints()`, `deleteOldDataPoints()`, etc. |
|
||||
| **timeseriesPersistent.ts** | Persistent TimeSeries | `createPersistentTimeSeries()` |
|
||||
| **namespacePersistence.ts** | Namespace helpers | `createNamespacePersistentTimeSeries()` |
|
||||
| **storageManagement.ts** | Management utilities | `getStorageUsage()`, `exportToCSV()`, `clearSeries()`, etc. |
|
||||
|
||||
### React Integration
|
||||
|
||||
| File | Purpose | Key Exports |
|
||||
|------|---------|-------------|
|
||||
| **useStorageManagement.ts** | React hooks | `useStorageUsage()`, `useSeriesStats()`, `useStorageDashboard()` |
|
||||
| **StorageManagement.example.tsx** | UI component | `<StorageManagement />` |
|
||||
|
||||
### Examples & Documentation
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| **winder2NamespaceHybrid.example.ts** | Complete migration example for a namespace |
|
||||
| **indexeddb-chart-persistence.md** | Full documentation with migration guide |
|
||||
| **indexeddb-implementation-summary.md** | Implementation summary and next steps |
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Basic Usage (Single Series)
|
||||
|
||||
```typescript
|
||||
import { createPersistentTimeSeries } from '@/lib/timeseriesPersistent';
|
||||
import { serializeNamespaceId } from '@/client/socketioStore';
|
||||
|
||||
const { initialTimeSeries, insert, ready } = createPersistentTimeSeries(
|
||||
serializeNamespaceId(namespaceId),
|
||||
'seriesName'
|
||||
);
|
||||
|
||||
// Use initialTimeSeries in your store
|
||||
// Use insert() to add new data points
|
||||
// Await ready to get series with historical data loaded
|
||||
```
|
||||
|
||||
### 2. Namespace Integration (Multiple Series)
|
||||
|
||||
```typescript
|
||||
import { createNamespacePersistentTimeSeries } from '@/lib/namespacePersistence';
|
||||
|
||||
const seriesResult = createNamespacePersistentTimeSeries(
|
||||
namespaceId,
|
||||
{
|
||||
speed: 'speed',
|
||||
position: 'position',
|
||||
temperature: 'temperature',
|
||||
}
|
||||
);
|
||||
|
||||
// Use seriesResult.initialState in your store
|
||||
// Use seriesResult.insertFns to insert data
|
||||
// Await seriesResult.ready for historical data
|
||||
```
|
||||
|
||||
### 3. Add Storage UI
|
||||
|
||||
```typescript
|
||||
import { StorageManagement } from '@/components/StorageManagement.example';
|
||||
|
||||
function SettingsPage() {
|
||||
return <StorageManagement />;
|
||||
}
|
||||
```
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- **Migration Guide**: See [indexeddb-chart-persistence.md](../../docs/developer-docs/indexeddb-chart-persistence.md)
|
||||
- **Complete Example**: See [winder2NamespaceHybrid.example.ts](../machines/winder/winder2/winder2NamespaceHybrid.example.ts)
|
||||
- **Implementation Summary**: See [indexeddb-implementation-summary.md](../../docs/developer-docs/indexeddb-implementation-summary.md)
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
```typescript
|
||||
const config = {
|
||||
// Timeseries config
|
||||
sampleIntervalShort: 20, // 20ms (RAM only)
|
||||
sampleIntervalLong: 1000, // 1s (RAM + IndexedDB)
|
||||
retentionDurationShort: 5000, // 5s (RAM only)
|
||||
retentionDurationLong: 3600000, // 1h (RAM + IndexedDB)
|
||||
|
||||
// Persistence config
|
||||
enablePersistence: true,
|
||||
maxRetentionTime: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
cleanupInterval: 60 * 60 * 1000, // 1 hour
|
||||
};
|
||||
|
||||
createPersistentTimeSeries(namespaceId, seriesName, config);
|
||||
```
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
✅ **Automatic Persistence** - Long series data automatically saved
|
||||
✅ **Historical Data** - Loads previous data on mount
|
||||
✅ **API Compatible** - Works with existing code
|
||||
✅ **Performance Optimized** - Short series stays in RAM
|
||||
✅ **Data Management** - Export, clear, and monitor storage
|
||||
✅ **Type Safe** - Full TypeScript support
|
||||
|
||||
## 🔄 Migration Steps
|
||||
|
||||
1. **Choose a namespace** to migrate (e.g., Winder2)
|
||||
2. **Update store creation** to pass `namespaceId`
|
||||
3. **Replace `createTimeSeries()`** with persistence version
|
||||
4. **Update message handler** to use new insert functions
|
||||
5. **Handle async initialization** with `.ready` promise
|
||||
6. **Test thoroughly** before deploying
|
||||
|
||||
See [winder2NamespaceHybrid.example.ts](../machines/winder/winder2/winder2NamespaceHybrid.example.ts) for complete example.
|
||||
|
||||
## 📊 Storage Usage
|
||||
|
||||
- **Per data point**: ~40 bytes
|
||||
- **1 hour of data**: ~720KB per machine (5 series)
|
||||
- **7 days retention**: ~120MB per machine
|
||||
- **Automatic cleanup**: Every hour
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Data not persisting
|
||||
- Check `enablePersistence: true` in config
|
||||
- Verify browser supports IndexedDB
|
||||
- Check console for errors
|
||||
|
||||
### Slow loading
|
||||
- Reduce `maxRetentionTime`
|
||||
- Check number of series
|
||||
- Monitor query performance
|
||||
|
||||
### Storage quota exceeded
|
||||
- Reduce `maxRetentionTime`
|
||||
- Increase `cleanupInterval`
|
||||
- Add manual clear functionality
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```typescript
|
||||
// Check if persistence is available
|
||||
import { isPersistenceAvailable } from '@/lib/storageManagement';
|
||||
|
||||
if (isPersistenceAvailable()) {
|
||||
console.log('IndexedDB persistence is supported');
|
||||
}
|
||||
|
||||
// Get storage stats
|
||||
import { useStorageUsage } from '@/hooks/useStorageManagement';
|
||||
|
||||
function MyComponent() {
|
||||
const { usage } = useStorageUsage();
|
||||
console.log(`Using ${usage?.usage} of ${usage?.quota} bytes`);
|
||||
}
|
||||
```
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For questions or issues:
|
||||
1. Check the [full documentation](../../docs/developer-docs/indexeddb-chart-persistence.md)
|
||||
2. Review the [example implementation](../machines/winder/winder2/winder2NamespaceHybrid.example.ts)
|
||||
3. See [troubleshooting section](../../docs/developer-docs/indexeddb-chart-persistence.md#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ Production Ready
|
||||
**Version**: 1.0.0
|
||||
**Last Updated**: January 12, 2026
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* Namespace integration helpers for persistent timeseries
|
||||
*
|
||||
* This module provides utilities to easily integrate IndexedDB-backed
|
||||
* persistent timeseries into Socket.io namespace implementations.
|
||||
*
|
||||
* The key challenge is that we need to:
|
||||
* 1. Initialize timeseries with historical data from IndexedDB
|
||||
* 2. Maintain compatibility with existing namespace hooks
|
||||
* 3. Handle async initialization gracefully
|
||||
*
|
||||
* Usage in a namespace:
|
||||
*
|
||||
* ```ts
|
||||
* import { createNamespacePersistentTimeSeries } from '@/lib/namespacePersistence';
|
||||
*
|
||||
* const seriesFactory = createNamespacePersistentTimeSeries(
|
||||
* (namespaceId) => ({
|
||||
* pullerSpeed: 'pullerSpeed',
|
||||
* spoolRpm: 'spoolRpm',
|
||||
* // ... other series
|
||||
* })
|
||||
* );
|
||||
*
|
||||
* export const createWinder2NamespaceStore = (): StoreApi<Winder2NamespaceStore> => {
|
||||
* const series = seriesFactory.createInitialSeries(namespaceId);
|
||||
*
|
||||
* return create<Winder2NamespaceStore>(() => ({
|
||||
* state: null,
|
||||
* defaultState: null,
|
||||
* ...series.initialState,
|
||||
* }));
|
||||
* };
|
||||
*
|
||||
* // Later, load historical data when the namespace is mounted
|
||||
* useEffect(() => {
|
||||
* series.loadHistoricalData().then(historicalSeries => {
|
||||
* store.setState(historicalSeries);
|
||||
* });
|
||||
* }, []);
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { serializeNamespaceId, NamespaceId } from "@/client/socketioStore";
|
||||
import { TimeSeries, TimeSeriesValue } from "./timeseries";
|
||||
import {
|
||||
createPersistentTimeSeries,
|
||||
PersistentTimeSeriesWithInsert,
|
||||
PersistentTimeSeriesConfig,
|
||||
} from "./timeseriesPersistent";
|
||||
|
||||
/**
|
||||
* Configuration for a series within a namespace
|
||||
*/
|
||||
export interface SeriesDefinition {
|
||||
/** Name of the series (used as key in store and DB) */
|
||||
name: string;
|
||||
/** Optional configuration override */
|
||||
config?: Partial<PersistentTimeSeriesConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type helper for series definitions map
|
||||
*/
|
||||
export type SeriesDefinitionMap = Record<string, string | SeriesDefinition>;
|
||||
|
||||
/**
|
||||
* Result of creating namespace series
|
||||
*/
|
||||
export interface NamespaceSeriesResult<T extends SeriesDefinitionMap> {
|
||||
/** Initial timeseries state for the store */
|
||||
initialState: Record<keyof T, TimeSeries>;
|
||||
/** Insert functions for each series */
|
||||
insertFns: Record<keyof T, (series: TimeSeries, value: TimeSeriesValue) => TimeSeries>;
|
||||
/** Promise that resolves when all series are loaded with historical data (or immediately with lazy loading) */
|
||||
ready: Promise<Record<keyof T, TimeSeries>>;
|
||||
/**
|
||||
* Register a callback to be called when historical data finishes loading (for lazy loading).
|
||||
* With lazy loading enabled, charts render immediately with live data, and this callback
|
||||
* fires when historical data is ready to be merged in.
|
||||
*/
|
||||
onHistoryLoaded: (callback: (historicalSeries: Record<keyof T, TimeSeries>) => void) => void;
|
||||
/** Cleanup function to remove old data */
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create persistent timeseries for a namespace with type safety
|
||||
*
|
||||
* @param namespaceId The namespace ID (will be serialized)
|
||||
* @param seriesDefinitions Map of series names to definitions
|
||||
* @param globalConfig Optional global configuration for all series
|
||||
* @returns Object with initial state, insert functions, and ready promise
|
||||
*/
|
||||
export function createNamespacePersistentTimeSeries<T extends SeriesDefinitionMap>(
|
||||
namespaceId: NamespaceId,
|
||||
seriesDefinitions: T,
|
||||
globalConfig?: Partial<PersistentTimeSeriesConfig>,
|
||||
): NamespaceSeriesResult<T> {
|
||||
const serializedNamespaceId = serializeNamespaceId(namespaceId);
|
||||
|
||||
const initialState: Record<string, TimeSeries> = {};
|
||||
const insertFns: Record<string, (series: TimeSeries, value: TimeSeriesValue) => TimeSeries> = {};
|
||||
const readyPromises: Promise<[string, TimeSeries]>[] = [];
|
||||
const cleanupFns: (() => Promise<void>)[] = [];
|
||||
|
||||
// Track individual series for history callbacks
|
||||
const seriesMap: Record<string, { persistentSeries: PersistentTimeSeriesWithInsert; key: string }> = {};
|
||||
|
||||
// Create persistent timeseries for each definition
|
||||
for (const [key, definition] of Object.entries(seriesDefinitions)) {
|
||||
const seriesName = typeof definition === "string" ? definition : definition.name;
|
||||
const config = typeof definition === "string" ? globalConfig : { ...globalConfig, ...definition.config };
|
||||
|
||||
const persistentSeries = createPersistentTimeSeries(
|
||||
serializedNamespaceId,
|
||||
seriesName,
|
||||
config,
|
||||
);
|
||||
|
||||
initialState[key] = persistentSeries.initialTimeSeries;
|
||||
insertFns[key] = persistentSeries.insert;
|
||||
cleanupFns.push(persistentSeries.cleanup);
|
||||
seriesMap[key] = { persistentSeries, key };
|
||||
|
||||
// Add to ready promises
|
||||
readyPromises.push(
|
||||
persistentSeries.ready.then((loadedSeries) => [key, loadedSeries] as [string, TimeSeries]),
|
||||
);
|
||||
}
|
||||
|
||||
// Combine all ready promises
|
||||
const ready = Promise.all(readyPromises).then((results) => {
|
||||
const loadedState: Record<string, TimeSeries> = {};
|
||||
for (const [key, series] of results) {
|
||||
loadedState[key] = series;
|
||||
}
|
||||
return loadedState as Record<keyof T, TimeSeries>;
|
||||
});
|
||||
|
||||
// Combined cleanup function
|
||||
const cleanup = async () => {
|
||||
await Promise.all(cleanupFns.map((fn) => fn()));
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a callback to be called when ALL historical data is loaded (lazy mode)
|
||||
* This aggregates callbacks from all series and fires once all are ready
|
||||
*/
|
||||
const onHistoryLoaded = (callback: (historicalSeries: Record<keyof T, TimeSeries>) => void): void => {
|
||||
const loadedSeries: Record<string, TimeSeries> = {};
|
||||
const seriesKeys = Object.keys(seriesMap);
|
||||
let loadedCount = 0;
|
||||
|
||||
// Register callback on each series
|
||||
for (const [key, { persistentSeries }] of Object.entries(seriesMap)) {
|
||||
persistentSeries.onHistoryLoaded((series) => {
|
||||
loadedSeries[key] = series;
|
||||
loadedCount++;
|
||||
|
||||
// Fire callback when all series are loaded
|
||||
if (loadedCount === seriesKeys.length) {
|
||||
callback(loadedSeries as Record<keyof T, TimeSeries>);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
initialState: initialState as Record<keyof T, TimeSeries>,
|
||||
insertFns: insertFns as Record<keyof T, (series: TimeSeries, value: TimeSeriesValue) => TimeSeries>,
|
||||
ready,
|
||||
onHistoryLoaded,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory pattern for creating series across multiple namespace instances
|
||||
* This is useful when you have multiple machine instances using the same namespace structure
|
||||
*/
|
||||
export class NamespaceSeriesFactory<T extends SeriesDefinitionMap> {
|
||||
private seriesDefinitions: T;
|
||||
private globalConfig?: Partial<PersistentTimeSeriesConfig>;
|
||||
|
||||
constructor(
|
||||
seriesDefinitions: T,
|
||||
globalConfig?: Partial<PersistentTimeSeriesConfig>,
|
||||
) {
|
||||
this.seriesDefinitions = seriesDefinitions;
|
||||
this.globalConfig = globalConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create series for a specific namespace instance
|
||||
*/
|
||||
create(namespaceId: NamespaceId): NamespaceSeriesResult<T> {
|
||||
return createNamespacePersistentTimeSeries(
|
||||
namespaceId,
|
||||
this.seriesDefinitions,
|
||||
this.globalConfig,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create insert functions that are bound to a specific series instance
|
||||
* This is useful in the old createTimeSeries pattern where insert functions are created once
|
||||
*
|
||||
* @deprecated This is for backward compatibility. New code should use createNamespacePersistentTimeSeries
|
||||
*/
|
||||
export function createCompatibleInsertFunctions<T extends SeriesDefinitionMap>(
|
||||
namespaceId: NamespaceId,
|
||||
seriesDefinitions: T,
|
||||
globalConfig?: Partial<PersistentTimeSeriesConfig>,
|
||||
): {
|
||||
initialSeries: Record<keyof T, TimeSeries>;
|
||||
insertFns: Record<keyof T, (series: TimeSeries, value: TimeSeriesValue) => TimeSeries>;
|
||||
ready: Promise<Record<keyof T, TimeSeries>>;
|
||||
} {
|
||||
const result = createNamespacePersistentTimeSeries(
|
||||
namespaceId,
|
||||
seriesDefinitions,
|
||||
globalConfig,
|
||||
);
|
||||
|
||||
return {
|
||||
initialSeries: result.initialState,
|
||||
insertFns: result.insertFns,
|
||||
ready: result.ready,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export function deepEquals<T>(a: T, b: T): boolean {
|
||||
export function deepEquals(
|
||||
a: object | undefined,
|
||||
b: object | undefined,
|
||||
): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { machineIdentification } from "@/machines/types";
|
||||
import { z } from "zod";
|
||||
|
||||
export type PresetSchema = z.ZodTypeAny;
|
||||
export type PresetSchema<T> = z.ZodType<T>;
|
||||
|
||||
export const presetSchema = <S extends PresetSchema>(dataSchema: S) =>
|
||||
export const presetSchema = <T>(dataSchema: PresetSchema<T>) =>
|
||||
z.object({
|
||||
id: z.number().int().nonnegative().optional(),
|
||||
name: z.string().nonempty(),
|
||||
lastModified: z.coerce.date(),
|
||||
machineIdentification,
|
||||
schemaVersion: z.number().int().positive(),
|
||||
data: dataSchema,
|
||||
data: dataSchema.optional(),
|
||||
});
|
||||
|
||||
export type Preset<S extends PresetSchema> = z.infer<
|
||||
ReturnType<typeof presetSchema<S>>
|
||||
>;
|
||||
|
||||
export type PresetData<S extends PresetSchema> = z.infer<S>;
|
||||
export type Preset<T> = z.infer<ReturnType<typeof presetSchema<T>>>;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, PersistStorage, StorageValue } from "zustand/middleware";
|
||||
import { Preset, PresetSchema, presetSchema } from "./preset";
|
||||
import { Preset, presetSchema } from "./preset";
|
||||
import { MachineIdentification } from "@/machines/types";
|
||||
import { z } from "zod";
|
||||
import { toastError } from "@/components/Toast";
|
||||
@@ -33,9 +33,9 @@ type PresetStoreData = {
|
||||
};
|
||||
|
||||
export type PresetStore = PresetStoreData & {
|
||||
insert: <T extends PresetSchema>(preset: Preset<T>) => PersistedPreset;
|
||||
update: <T extends PresetSchema>(preset: Preset<T>) => void;
|
||||
remove: <T extends PresetSchema>(preset: Preset<T>) => void;
|
||||
insert: <T>(preset: Preset<T>) => PersistedPreset;
|
||||
update: <T>(preset: Preset<T>) => void;
|
||||
remove: <T>(preset: Preset<T>) => void;
|
||||
getById: (id: number) => PersistedPreset | undefined;
|
||||
|
||||
setLatestPresetId: (
|
||||
@@ -100,7 +100,7 @@ export const usePresetStore = create<PresetStore>()(
|
||||
presets: [],
|
||||
latestPresetIds: new Map(),
|
||||
|
||||
insert: <T extends PresetSchema>(preset: Preset<T>) => {
|
||||
insert: <T>(preset: Preset<T>) => {
|
||||
const state = get();
|
||||
const { presets } = state;
|
||||
const ids = presets.map((p: PersistedPreset) => p.id);
|
||||
@@ -115,7 +115,7 @@ export const usePresetStore = create<PresetStore>()(
|
||||
return persistedPreset;
|
||||
},
|
||||
|
||||
update: <T extends PresetSchema>(preset: Preset<T>) => {
|
||||
update: <T>(preset: Preset<T>) => {
|
||||
const state = get();
|
||||
const presets: PersistedPreset[] = state.presets.map(
|
||||
(p: PersistedPreset) =>
|
||||
@@ -124,7 +124,7 @@ export const usePresetStore = create<PresetStore>()(
|
||||
set({ ...state, presets });
|
||||
},
|
||||
|
||||
remove: <T extends PresetSchema>(preset: Preset<T>) => {
|
||||
remove: <T>(preset: Preset<T>) => {
|
||||
const state = get();
|
||||
const presets = state.presets.filter(
|
||||
(p: Preset<any>) => p.id !== preset.id,
|
||||
|
||||
@@ -3,13 +3,13 @@ import {
|
||||
machineIdentificationEquals,
|
||||
} from "@/machines/types";
|
||||
import { usePresetStore } from "./presetStore";
|
||||
import { Preset, PresetData, presetSchema, PresetSchema } from "./preset";
|
||||
import { Preset, presetSchema, PresetSchema } from "./preset";
|
||||
import { deepEquals } from "@/lib/objects";
|
||||
import { useEffect } from "react";
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { z } from "zod";
|
||||
|
||||
export type Presets<T extends PresetSchema> = {
|
||||
export type Presets<T> = {
|
||||
get: () => Preset<T>[];
|
||||
createFromCurrentState: (name: string) => Preset<T>;
|
||||
updateFromCurrentState: (preset: Preset<T>) => Preset<T>;
|
||||
@@ -20,15 +20,15 @@ export type Presets<T extends PresetSchema> = {
|
||||
import: (json: any) => void;
|
||||
};
|
||||
|
||||
export type UsePresetsParams<T extends PresetSchema> = {
|
||||
export type UsePresetsParams<T> = {
|
||||
machine_identification: MachineIdentification;
|
||||
schemas: Map<number, T>;
|
||||
schemas: Map<number, PresetSchema<T>>;
|
||||
schemaVersion: number;
|
||||
currentState?: PresetData<T>;
|
||||
defaultState?: PresetData<T>;
|
||||
currentState?: T;
|
||||
defaultState?: T;
|
||||
};
|
||||
|
||||
export function usePresets<T extends PresetSchema>({
|
||||
export function usePresets<T>({
|
||||
machine_identification,
|
||||
schemas,
|
||||
schemaVersion,
|
||||
@@ -49,7 +49,10 @@ export function usePresets<T extends PresetSchema>({
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = presetSchema(schema).parse(preset);
|
||||
const parsed: Preset<T> = {
|
||||
...preset,
|
||||
data: schema.parse(preset.data),
|
||||
};
|
||||
|
||||
if (parsed.schemaVersion < schemaVersion) {
|
||||
parsed.schemaVersion = schemaVersion;
|
||||
@@ -93,7 +96,11 @@ export function usePresets<T extends PresetSchema>({
|
||||
return { ...preset, data: currentState };
|
||||
};
|
||||
|
||||
const updateFromCurrentState = (preset: Preset<T>) => {
|
||||
const updateFromCurrentState = (preset: Preset<T>): Preset<T> => {
|
||||
if (!currentState) {
|
||||
return preset;
|
||||
}
|
||||
|
||||
const data = currentState;
|
||||
|
||||
const newPreset = {
|
||||
@@ -153,12 +160,16 @@ export function usePresets<T extends PresetSchema>({
|
||||
};
|
||||
|
||||
const isActive = (preset: Preset<T>) => {
|
||||
return deepEquals(currentState, preset.data);
|
||||
if (!currentState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return deepEquals(currentState, preset.data ?? {});
|
||||
};
|
||||
|
||||
const importPreset = (json: any) => {
|
||||
try {
|
||||
const anyPreset = presetSchema(z.any()).parse(json);
|
||||
const anyPreset = presetSchema(z.object()).parse(json);
|
||||
|
||||
if (
|
||||
!machineIdentificationEquals(
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
/**
|
||||
* IndexedDB Storage Management Utilities
|
||||
*
|
||||
* This module provides user-facing utilities for managing IndexedDB storage:
|
||||
* - Check storage usage and quota
|
||||
* - Clear data for specific namespaces or series
|
||||
* - Export data to CSV/JSON
|
||||
* - Import data from backups
|
||||
* - Monitor storage health
|
||||
*
|
||||
* These utilities can be integrated into settings pages or developer tools.
|
||||
*/
|
||||
|
||||
import { TimeSeriesValue } from "./timeseries";
|
||||
import {
|
||||
queryDataPoints,
|
||||
clearSeriesData,
|
||||
clearNamespaceData,
|
||||
getSeriesStats,
|
||||
closeDB,
|
||||
} from "./timeseriesDB";
|
||||
|
||||
/**
|
||||
* Storage statistics for a namespace
|
||||
*/
|
||||
export interface NamespaceStorageStats {
|
||||
namespaceId: string;
|
||||
series: {
|
||||
name: string;
|
||||
count: number;
|
||||
oldestTimestamp: number | null;
|
||||
newestTimestamp: number | null;
|
||||
estimatedSizeBytes: number;
|
||||
}[];
|
||||
totalDataPoints: number;
|
||||
estimatedTotalSizeBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall storage health information
|
||||
*/
|
||||
export interface StorageHealth {
|
||||
usedBytes: number;
|
||||
quotaBytes: number;
|
||||
percentUsed: number;
|
||||
namespaces: NamespaceStorageStats[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser's IndexedDB storage usage
|
||||
*/
|
||||
export async function getStorageUsage(): Promise<{
|
||||
usage: number;
|
||||
quota: number;
|
||||
percentUsed: number;
|
||||
}> {
|
||||
if (!navigator.storage || !navigator.storage.estimate) {
|
||||
throw new Error("Storage API not supported");
|
||||
}
|
||||
|
||||
const estimate = await navigator.storage.estimate();
|
||||
const usage = estimate.usage || 0;
|
||||
const quota = estimate.quota || 0;
|
||||
const percentUsed = quota > 0 ? (usage / quota) * 100 : 0;
|
||||
|
||||
return { usage, quota, percentUsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for a specific namespace and series
|
||||
*/
|
||||
export async function getNamespaceSeriesStats(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
): Promise<{
|
||||
count: number;
|
||||
oldestTimestamp: number | null;
|
||||
newestTimestamp: number | null;
|
||||
estimatedSizeBytes: number;
|
||||
}> {
|
||||
const stats = await getSeriesStats(namespaceId, seriesName);
|
||||
|
||||
// Estimate size: ~40 bytes per data point (value, timestamp, keys, metadata)
|
||||
const estimatedSizeBytes = stats.count * 40;
|
||||
|
||||
return {
|
||||
...stats,
|
||||
estimatedSizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data for a specific series
|
||||
* @returns true if successful
|
||||
*/
|
||||
export async function clearSeries(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await clearSeriesData(namespaceId, seriesName);
|
||||
console.log(`Cleared data for ${namespaceId}:${seriesName}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to clear ${namespaceId}:${seriesName}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data for a namespace (all series)
|
||||
* @returns true if successful
|
||||
*/
|
||||
export async function clearNamespace(namespaceId: string): Promise<boolean> {
|
||||
try {
|
||||
await clearNamespaceData(namespaceId);
|
||||
console.log(`Cleared all data for namespace ${namespaceId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to clear namespace ${namespaceId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export series data to CSV format
|
||||
*/
|
||||
export async function exportToCSV(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
): Promise<string> {
|
||||
const dataPoints = await queryDataPoints(
|
||||
namespaceId,
|
||||
seriesName,
|
||||
startTime,
|
||||
endTime,
|
||||
);
|
||||
|
||||
// Create CSV header
|
||||
let csv = "timestamp,value,datetime\n";
|
||||
|
||||
// Add data rows
|
||||
for (const point of dataPoints) {
|
||||
const datetime = new Date(point.timestamp).toISOString();
|
||||
csv += `${point.timestamp},${point.value},${datetime}\n`;
|
||||
}
|
||||
|
||||
return csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export series data to JSON format
|
||||
*/
|
||||
export async function exportToJSON(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
): Promise<string> {
|
||||
const dataPoints = await queryDataPoints(
|
||||
namespaceId,
|
||||
seriesName,
|
||||
startTime,
|
||||
endTime,
|
||||
);
|
||||
|
||||
const exportData = {
|
||||
namespaceId,
|
||||
seriesName,
|
||||
exportTimestamp: Date.now(),
|
||||
startTime,
|
||||
endTime,
|
||||
dataPoints: dataPoints.map((point) => ({
|
||||
...point,
|
||||
datetime: new Date(point.timestamp).toISOString(),
|
||||
})),
|
||||
};
|
||||
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download data as a file
|
||||
*/
|
||||
export function downloadFile(
|
||||
content: string,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
): void {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export series data and download as CSV file
|
||||
*/
|
||||
export async function downloadSeriesAsCSV(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
): Promise<void> {
|
||||
const csv = await exportToCSV(namespaceId, seriesName, startTime, endTime);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = `${seriesName}_${timestamp}.csv`;
|
||||
downloadFile(csv, filename, "text/csv");
|
||||
}
|
||||
|
||||
/**
|
||||
* Export series data and download as JSON file
|
||||
*/
|
||||
export async function downloadSeriesAsJSON(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
): Promise<void> {
|
||||
const json = await exportToJSON(namespaceId, seriesName, startTime, endTime);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = `${seriesName}_${timestamp}.json`;
|
||||
downloadFile(json, filename, "application/json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate imported JSON data
|
||||
*/
|
||||
export function parseImportedJSON(jsonString: string): {
|
||||
namespaceId: string;
|
||||
seriesName: string;
|
||||
dataPoints: TimeSeriesValue[];
|
||||
} {
|
||||
const data = JSON.parse(jsonString);
|
||||
|
||||
if (
|
||||
!data.namespaceId ||
|
||||
!data.seriesName ||
|
||||
!Array.isArray(data.dataPoints)
|
||||
) {
|
||||
throw new Error("Invalid import format");
|
||||
}
|
||||
|
||||
// Validate data points
|
||||
const dataPoints: TimeSeriesValue[] = data.dataPoints.map(
|
||||
(point: any, index: number) => {
|
||||
if (
|
||||
typeof point.timestamp !== "number" ||
|
||||
typeof point.value !== "number"
|
||||
) {
|
||||
throw new Error(`Invalid data point at index ${index}`);
|
||||
}
|
||||
return {
|
||||
timestamp: point.timestamp,
|
||||
value: point.value,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
namespaceId: data.namespaceId,
|
||||
seriesName: data.seriesName,
|
||||
dataPoints,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the IndexedDB connection
|
||||
* Useful when clearing browser storage or before reinstalling
|
||||
*/
|
||||
export async function closeDatabase(): Promise<void> {
|
||||
await closeDB();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if persistence is available in this browser
|
||||
*/
|
||||
export function isPersistenceAvailable(): boolean {
|
||||
return (
|
||||
typeof indexedDB !== "undefined" &&
|
||||
typeof navigator.storage !== "undefined" &&
|
||||
typeof navigator.storage.estimate === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request persistent storage (prevents browser from clearing data)
|
||||
*/
|
||||
export async function requestPersistentStorage(): Promise<boolean> {
|
||||
if (!navigator.storage || !navigator.storage.persist) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const isPersisted = await navigator.storage.persisted();
|
||||
if (isPersisted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = await navigator.storage.persist();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to request persistent storage:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage is persisted (won't be cleared by browser)
|
||||
*/
|
||||
export async function isStoragePersisted(): Promise<boolean> {
|
||||
if (!navigator.storage || !navigator.storage.persisted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await navigator.storage.persisted();
|
||||
} catch (error) {
|
||||
console.error("Failed to check persistence status:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable string
|
||||
*/
|
||||
export function formatBytes(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to human-readable date/time
|
||||
*/
|
||||
export function formatTimestamp(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate time range duration in human-readable format
|
||||
*/
|
||||
export function formatDuration(startTime: number, endTime: number): string {
|
||||
const durationMs = endTime - startTime;
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours % 24}h`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
@@ -50,17 +50,14 @@ export interface TimeSeriesConfig {
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
* Short: 20ms sample, 5s retention (250 points)
|
||||
* Long: 20ms sample, 10min retention (30,000 points)
|
||||
*
|
||||
* Using same sample rate for both short and long series provides
|
||||
* consistent high-resolution data capture while keeping memory reasonable.
|
||||
* Short: 20ms sample, 5s retention
|
||||
* Long: 1s sample, 1h retention
|
||||
*/
|
||||
export const DEFAULT_TIMESERIES_CONFIG: TimeSeriesConfig = {
|
||||
sampleIntervalShort: 20, // 20ms
|
||||
sampleIntervalLong: 20, // 20ms (same as short for high resolution)
|
||||
sampleIntervalLong: 1000, // 1s
|
||||
retentionDurationShort: 5000, // 5s
|
||||
retentionDurationLong: 10 * 60 * 1000, // 10 minutes
|
||||
retentionDurationLong: 60 * 60 * 1000, // 1h
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
/**
|
||||
* IndexedDB-based persistence layer for TimeSeries data
|
||||
*
|
||||
* This module provides persistent storage for long timeseries data using IndexedDB.
|
||||
* Short timeseries data remains in memory only for performance.
|
||||
*
|
||||
* Database schema:
|
||||
* - Store: "timeseries_data"
|
||||
* - Key: [namespaceId, seriesName, timestamp]
|
||||
* - Value: { value: number, timestamp: number }
|
||||
*
|
||||
* The namespaceId is serialized from the NamespaceId object to uniquely identify
|
||||
* each machine instance's data.
|
||||
*/
|
||||
|
||||
import { openDB, DBSchema, IDBPDatabase } from "idb";
|
||||
import { TimeSeriesValue } from "./timeseries";
|
||||
|
||||
// Database configuration
|
||||
const DB_NAME = "QiTechControlTimeSeries";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "timeseries_data";
|
||||
|
||||
/**
|
||||
* Database schema definition for TypeScript
|
||||
*/
|
||||
interface TimeSeriesDB extends DBSchema {
|
||||
timeseries_data: {
|
||||
key: string; // Composite key: "namespaceId:seriesName:timestamp"
|
||||
value: TimeSeriesValue & {
|
||||
namespaceId: string;
|
||||
seriesName: string;
|
||||
};
|
||||
indexes: {
|
||||
"by-namespace": string; // namespaceId
|
||||
"by-namespace-series": string; // "namespaceId:seriesName"
|
||||
"by-namespace-series-time": [string, number]; // ["namespaceId:seriesName", timestamp]
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton database instance
|
||||
*/
|
||||
let dbInstance: IDBPDatabase<TimeSeriesDB> | null = null;
|
||||
|
||||
/**
|
||||
* Initialize or get the database instance
|
||||
*/
|
||||
async function getDB(): Promise<IDBPDatabase<TimeSeriesDB>> {
|
||||
if (dbInstance) {
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
dbInstance = await openDB<TimeSeriesDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
// Create the object store with composite key
|
||||
const store = db.createObjectStore(STORE_NAME, {
|
||||
keyPath: ["namespaceId", "seriesName", "timestamp"],
|
||||
});
|
||||
|
||||
// Create indexes for efficient querying
|
||||
store.createIndex("by-namespace", "namespaceId", { unique: false });
|
||||
store.createIndex("by-namespace-series", ["namespaceId", "seriesName"], {
|
||||
unique: false,
|
||||
});
|
||||
store.createIndex(
|
||||
"by-namespace-series-time",
|
||||
["namespaceId", "seriesName", "timestamp"],
|
||||
{ unique: true },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a single data point
|
||||
* @param namespaceId Serialized namespace identifier
|
||||
* @param seriesName Name of the time series (e.g., "pullerSpeed")
|
||||
* @param value The time series value to store
|
||||
*/
|
||||
export async function storeDataPoint(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
value: TimeSeriesValue,
|
||||
): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.put(STORE_NAME, {
|
||||
namespaceId,
|
||||
seriesName,
|
||||
...value,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Store multiple data points in a single transaction (more efficient)
|
||||
* @param namespaceId Serialized namespace identifier
|
||||
* @param seriesName Name of the time series
|
||||
* @param values Array of time series values to store
|
||||
*/
|
||||
export async function storeDataPoints(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
values: TimeSeriesValue[],
|
||||
): Promise<void> {
|
||||
const db = await getDB();
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
|
||||
await Promise.all([
|
||||
...values.map((value) =>
|
||||
tx.store.put({
|
||||
namespaceId,
|
||||
seriesName,
|
||||
...value,
|
||||
}),
|
||||
),
|
||||
tx.done,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query data points within a time window
|
||||
* @param namespaceId Serialized namespace identifier
|
||||
* @param seriesName Name of the time series
|
||||
* @param startTime Start timestamp (inclusive)
|
||||
* @param endTime End timestamp (inclusive)
|
||||
* @returns Array of time series values in chronological order
|
||||
*/
|
||||
export async function queryDataPoints(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
): Promise<TimeSeriesValue[]> {
|
||||
const db = await getDB();
|
||||
|
||||
// Use the composite index for efficient range queries
|
||||
const index = db
|
||||
.transaction(STORE_NAME)
|
||||
.store.index("by-namespace-series-time");
|
||||
|
||||
let range: IDBKeyRange;
|
||||
|
||||
if (startTime !== undefined && endTime !== undefined) {
|
||||
// Query for specific time range
|
||||
range = IDBKeyRange.bound(
|
||||
[namespaceId, seriesName, startTime],
|
||||
[namespaceId, seriesName, endTime],
|
||||
);
|
||||
} else if (startTime !== undefined) {
|
||||
// Query from startTime onwards
|
||||
range = IDBKeyRange.lowerBound([namespaceId, seriesName, startTime]);
|
||||
} else if (endTime !== undefined) {
|
||||
// Query up to endTime
|
||||
range = IDBKeyRange.upperBound([namespaceId, seriesName, endTime]);
|
||||
} else {
|
||||
// Query all data for this namespace:series combination
|
||||
range = IDBKeyRange.bound(
|
||||
[namespaceId, seriesName, 0],
|
||||
[namespaceId, seriesName, Number.MAX_SAFE_INTEGER],
|
||||
);
|
||||
}
|
||||
|
||||
const results = await index.getAll(range);
|
||||
|
||||
// Convert to TimeSeriesValue format (remove namespaceId and seriesName)
|
||||
return results.map(({ value, timestamp }) => ({ value, timestamp }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest N data points for a series
|
||||
* @param namespaceId Serialized namespace identifier
|
||||
* @param seriesName Name of the time series
|
||||
* @param count Number of latest points to retrieve
|
||||
* @returns Array of time series values in chronological order
|
||||
*/
|
||||
export async function queryLatestDataPoints(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
count: number,
|
||||
): Promise<TimeSeriesValue[]> {
|
||||
const db = await getDB();
|
||||
|
||||
// Get all data for this series (we need to scan to find the latest)
|
||||
const index = db
|
||||
.transaction(STORE_NAME)
|
||||
.store.index("by-namespace-series-time");
|
||||
const range = IDBKeyRange.bound(
|
||||
[namespaceId, seriesName, 0],
|
||||
[namespaceId, seriesName, Number.MAX_SAFE_INTEGER],
|
||||
);
|
||||
|
||||
const results = await index.getAll(range);
|
||||
|
||||
// Sort by timestamp descending and take the first 'count' items
|
||||
const sorted = results
|
||||
.map(({ value, timestamp }) => ({ value, timestamp }))
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, count);
|
||||
|
||||
// Reverse to get chronological order
|
||||
return sorted.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old data points before a given timestamp
|
||||
* @param namespaceId Serialized namespace identifier
|
||||
* @param seriesName Name of the time series
|
||||
* @param beforeTimestamp Delete all points before this timestamp
|
||||
* @returns Number of deleted entries
|
||||
*/
|
||||
export async function deleteOldDataPoints(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
beforeTimestamp: number,
|
||||
): Promise<number> {
|
||||
const db = await getDB();
|
||||
|
||||
// Query old data points
|
||||
const index = db
|
||||
.transaction(STORE_NAME, "readwrite")
|
||||
.store.index("by-namespace-series-time");
|
||||
|
||||
const range = IDBKeyRange.bound(
|
||||
[namespaceId, seriesName, 0],
|
||||
[namespaceId, seriesName, beforeTimestamp],
|
||||
false,
|
||||
true, // exclusive upper bound
|
||||
);
|
||||
|
||||
const keys = await index.getAllKeys(range);
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
|
||||
await Promise.all([
|
||||
...keys.map((key) => tx.store.delete(key as any)),
|
||||
tx.done,
|
||||
]);
|
||||
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all data for a specific namespace and series
|
||||
* @param namespaceId Serialized namespace identifier
|
||||
* @param seriesName Name of the time series
|
||||
*/
|
||||
export async function clearSeriesData(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
): Promise<void> {
|
||||
const db = await getDB();
|
||||
|
||||
const index = db
|
||||
.transaction(STORE_NAME, "readwrite")
|
||||
.store.index("by-namespace-series-time");
|
||||
|
||||
const range = IDBKeyRange.bound(
|
||||
[namespaceId, seriesName, 0],
|
||||
[namespaceId, seriesName, Number.MAX_SAFE_INTEGER],
|
||||
);
|
||||
|
||||
const keys = await index.getAllKeys(range);
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
|
||||
await Promise.all([
|
||||
...keys.map((key) => tx.store.delete(key as any)),
|
||||
tx.done,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all data for a specific namespace (all series)
|
||||
* @param namespaceId Serialized namespace identifier
|
||||
*/
|
||||
export async function clearNamespaceData(namespaceId: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
|
||||
const index = db
|
||||
.transaction(STORE_NAME, "readwrite")
|
||||
.store.index("by-namespace");
|
||||
|
||||
const keys = await index.getAllKeys(namespaceId);
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
|
||||
await Promise.all([
|
||||
...keys.map((key) => tx.store.delete(key as any)),
|
||||
tx.done,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about stored data
|
||||
* @param namespaceId Serialized namespace identifier
|
||||
* @param seriesName Name of the time series
|
||||
*/
|
||||
export async function getSeriesStats(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
): Promise<{
|
||||
count: number;
|
||||
oldestTimestamp: number | null;
|
||||
newestTimestamp: number | null;
|
||||
}> {
|
||||
const db = await getDB();
|
||||
|
||||
const index = db
|
||||
.transaction(STORE_NAME)
|
||||
.store.index("by-namespace-series-time");
|
||||
|
||||
const range = IDBKeyRange.bound(
|
||||
[namespaceId, seriesName, 0],
|
||||
[namespaceId, seriesName, Number.MAX_SAFE_INTEGER],
|
||||
);
|
||||
|
||||
const results = await index.getAll(range);
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
oldestTimestamp: null,
|
||||
newestTimestamp: null,
|
||||
};
|
||||
}
|
||||
|
||||
const timestamps = results.map((r) => r.timestamp);
|
||||
|
||||
return {
|
||||
count: results.length,
|
||||
oldestTimestamp: Math.min(...timestamps),
|
||||
newestTimestamp: Math.max(...timestamps),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
export async function closeDB(): Promise<void> {
|
||||
if (dbInstance) {
|
||||
dbInstance.close();
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
/**
|
||||
* IndexedDB-backed TimeSeries implementation
|
||||
*
|
||||
* This module provides a persistent TimeSeries implementation that:
|
||||
* 1. Keeps short timeseries (5s window) in RAM only for performance
|
||||
* 2. Persists long timeseries (1h+ window) to IndexedDB automatically
|
||||
* 3. Loads data from IndexedDB on initialization
|
||||
* 4. Maintains API compatibility with the original timeseries.ts
|
||||
*
|
||||
* Usage is nearly identical to createTimeSeries() but with persistence:
|
||||
*
|
||||
* ```ts
|
||||
* const { initialTimeSeries, insert } = createPersistentTimeSeries(
|
||||
* "namespace-id",
|
||||
* "seriesName"
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { produce } from "immer";
|
||||
import {
|
||||
TimeSeries,
|
||||
TimeSeriesValue,
|
||||
TimeSeriesConfig,
|
||||
DEFAULT_TIMESERIES_CONFIG,
|
||||
Series,
|
||||
} from "./timeseries";
|
||||
import {
|
||||
storeDataPoint,
|
||||
queryDataPoints,
|
||||
deleteOldDataPoints,
|
||||
clearSeriesData,
|
||||
} from "./timeseriesDB";
|
||||
|
||||
/**
|
||||
* Configuration specific to persistent timeseries
|
||||
*/
|
||||
export interface PersistentTimeSeriesConfig extends TimeSeriesConfig {
|
||||
/** Enable/disable persistence (useful for testing) */
|
||||
enablePersistence?: boolean;
|
||||
/** Maximum retention time for data in IndexedDB (default: 7 days) */
|
||||
maxRetentionTime?: number;
|
||||
/** How often to cleanup old data (default: 1 hour) */
|
||||
cleanupInterval?: number;
|
||||
/**
|
||||
* Maximum window to load from IndexedDB on init (default: retentionDurationLong).
|
||||
* Set to 0 to skip historical loading entirely.
|
||||
*/
|
||||
initialLoadWindowMs?: number;
|
||||
/**
|
||||
* When true, historical data loads lazily in the background AFTER
|
||||
* the timeseries is already active with live data (default: true).
|
||||
* This ensures charts render immediately with live data.
|
||||
*/
|
||||
lazyLoadHistory?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration for persistent timeseries
|
||||
*
|
||||
* Long series buffer: 10 minutes at 20ms = 30,000 points (~240KB per series)
|
||||
* Max retention in DB: 7 days (~100MB per series)
|
||||
* Cleanup interval: 1 hour
|
||||
* Lazy load: enabled by default so charts render immediately with live data
|
||||
*/
|
||||
export const DEFAULT_PERSISTENT_CONFIG: Required<PersistentTimeSeriesConfig> = {
|
||||
...DEFAULT_TIMESERIES_CONFIG,
|
||||
enablePersistence: true,
|
||||
maxRetentionTime: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
cleanupInterval: 60 * 60 * 1000, // 1 hour
|
||||
initialLoadWindowMs: 10 * 60 * 1000, // load last 10 minutes of history
|
||||
lazyLoadHistory: true, // load history in background after live data starts
|
||||
};
|
||||
|
||||
/**
|
||||
* Return type with insert function and initialization promise
|
||||
*/
|
||||
export interface PersistentTimeSeriesWithInsert {
|
||||
initialTimeSeries: TimeSeries;
|
||||
insert: (series: TimeSeries, valueObj: TimeSeriesValue) => TimeSeries;
|
||||
/**
|
||||
* Promise that resolves when initial data is loaded from IndexedDB.
|
||||
* With lazyLoadHistory=true, this resolves immediately with empty series,
|
||||
* and onHistoryLoaded callback is fired later when history is ready.
|
||||
*/
|
||||
ready: Promise<TimeSeries>;
|
||||
/** Callback fired when historical data finishes loading (for lazy loading) */
|
||||
onHistoryLoaded: (callback: (historicalSeries: TimeSeries) => void) => void;
|
||||
/** Manually trigger cleanup of old data */
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load historical data from IndexedDB to populate the long series buffer
|
||||
* @param namespaceId Serialized namespace identifier
|
||||
* @param seriesName Name of the time series
|
||||
* @param retentionDuration How much history to load
|
||||
*/
|
||||
async function loadHistoricalData(
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
loadWindowMs: number,
|
||||
): Promise<TimeSeriesValue[]> {
|
||||
const now = Date.now();
|
||||
const startTime = now - loadWindowMs;
|
||||
|
||||
try {
|
||||
const rawData = await queryDataPoints(
|
||||
namespaceId,
|
||||
seriesName,
|
||||
startTime,
|
||||
now,
|
||||
);
|
||||
|
||||
// Filter out invalid/corrupted entries defensively to avoid breaking charts
|
||||
const dataPoints = rawData.filter(
|
||||
(p) =>
|
||||
Number.isFinite(p.timestamp) &&
|
||||
Number.isFinite(p.value) &&
|
||||
p.timestamp > 0,
|
||||
);
|
||||
|
||||
// If we filtered out everything but had records, proactively clear the series
|
||||
if (rawData.length > 0 && dataPoints.length === 0) {
|
||||
console.warn(
|
||||
`All historical points for ${namespaceId}:${seriesName} were invalid; clearing series in IndexedDB to recover`,
|
||||
);
|
||||
await clearSeriesData(namespaceId, seriesName).catch((err) =>
|
||||
console.error(`Failed to clear corrupted series ${namespaceId}:${seriesName}`, err),
|
||||
);
|
||||
}
|
||||
|
||||
return dataPoints;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to load historical data for ${namespaceId}:${seriesName}`,
|
||||
error,
|
||||
);
|
||||
|
||||
// Attempt automatic recovery by clearing the series to prevent UI breakage
|
||||
try {
|
||||
await clearSeriesData(namespaceId, seriesName);
|
||||
console.warn(
|
||||
`Cleared ${namespaceId}:${seriesName} after load failure; proceeding with empty history`,
|
||||
);
|
||||
} catch (clearErr) {
|
||||
console.error(
|
||||
`Failed to clear series ${namespaceId}:${seriesName} after load failure`,
|
||||
clearErr,
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate a Series buffer with historical data from IndexedDB
|
||||
* @param series The series to populate
|
||||
* @param dataPoints Historical data points
|
||||
* @returns Updated series with historical data
|
||||
*/
|
||||
function populateSeriesFromHistory(
|
||||
series: Series,
|
||||
dataPoints: TimeSeriesValue[],
|
||||
): Series {
|
||||
if (dataPoints.length === 0) {
|
||||
return series;
|
||||
}
|
||||
|
||||
// Sort by timestamp to ensure chronological order
|
||||
const sorted = [...dataPoints].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// Filter by sample interval (downsample if needed)
|
||||
const sampled: TimeSeriesValue[] = [];
|
||||
let lastTimestamp = 0;
|
||||
|
||||
for (const point of sorted) {
|
||||
if (point.timestamp - lastTimestamp >= series.sampleInterval) {
|
||||
sampled.push(point);
|
||||
lastTimestamp = point.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the circular buffer
|
||||
const newSeries = { ...series };
|
||||
let writeIndex = 0;
|
||||
|
||||
for (const point of sampled.slice(-series.size)) {
|
||||
// Take only the most recent data that fits in the buffer
|
||||
newSeries.values[writeIndex] = point;
|
||||
writeIndex = (writeIndex + 1) % series.size;
|
||||
}
|
||||
|
||||
newSeries.index = writeIndex;
|
||||
newSeries.validCount = Math.min(sampled.length, series.size);
|
||||
|
||||
if (sampled.length > 0) {
|
||||
newSeries.lastTimestamp = sampled[sampled.length - 1].timestamp;
|
||||
}
|
||||
|
||||
return newSeries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a persistent time series with IndexedDB backing
|
||||
* @param namespaceId Unique identifier for the namespace (must be serialized)
|
||||
* @param seriesName Name of the time series (e.g., "pullerSpeed")
|
||||
* @param config Optional configuration
|
||||
* @returns Object with initial timeseries, insert function, and ready promise
|
||||
*/
|
||||
export const createPersistentTimeSeries = (
|
||||
namespaceId: string,
|
||||
seriesName: string,
|
||||
config: Partial<PersistentTimeSeriesConfig> = {},
|
||||
): PersistentTimeSeriesWithInsert => {
|
||||
const fullConfig = { ...DEFAULT_PERSISTENT_CONFIG, ...config };
|
||||
const {
|
||||
sampleIntervalShort,
|
||||
sampleIntervalLong,
|
||||
retentionDurationShort,
|
||||
retentionDurationLong,
|
||||
enablePersistence,
|
||||
maxRetentionTime,
|
||||
cleanupInterval,
|
||||
initialLoadWindowMs,
|
||||
lazyLoadHistory,
|
||||
} = fullConfig;
|
||||
|
||||
const shortSize = Math.ceil(retentionDurationShort / sampleIntervalShort);
|
||||
const longSize = Math.ceil(retentionDurationLong / sampleIntervalLong);
|
||||
|
||||
const emptyEntry: TimeSeriesValue = { value: 0, timestamp: 0 };
|
||||
|
||||
// Create initial state with empty buffers
|
||||
const initialTimeSeries: TimeSeries = {
|
||||
current: null,
|
||||
short: {
|
||||
values: Array.from({ length: shortSize }, () => ({ ...emptyEntry })),
|
||||
index: 0,
|
||||
size: shortSize,
|
||||
lastTimestamp: 0,
|
||||
timeWindow: retentionDurationShort,
|
||||
sampleInterval: sampleIntervalShort,
|
||||
validCount: 0,
|
||||
},
|
||||
long: {
|
||||
values: Array.from({ length: longSize }, () => ({ ...emptyEntry })),
|
||||
index: 0,
|
||||
size: longSize,
|
||||
lastTimestamp: 0,
|
||||
timeWindow: retentionDurationLong,
|
||||
sampleInterval: sampleIntervalLong,
|
||||
validCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Track last cleanup time
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
/**
|
||||
* Cleanup old data from IndexedDB
|
||||
*/
|
||||
const cleanup = async (): Promise<void> => {
|
||||
if (!enablePersistence) return;
|
||||
|
||||
try {
|
||||
const cutoffTime = Date.now() - maxRetentionTime;
|
||||
await deleteOldDataPoints(namespaceId, seriesName, cutoffTime);
|
||||
lastCleanup = Date.now();
|
||||
} catch (error) {
|
||||
console.error(`Cleanup failed for ${namespaceId}:${seriesName}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if cleanup is needed and trigger if necessary
|
||||
*/
|
||||
const maybeCleanup = async (): Promise<void> => {
|
||||
const timeSinceLastCleanup = Date.now() - lastCleanup;
|
||||
if (timeSinceLastCleanup >= cleanupInterval) {
|
||||
await cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert function that persists long series data to IndexedDB
|
||||
*/
|
||||
const insert = (series: TimeSeries, value: TimeSeriesValue): TimeSeries => {
|
||||
const newSeries = produce(series, (draft) => {
|
||||
draft.current = value;
|
||||
|
||||
// Insert into short buffer (RAM only)
|
||||
const shortSampleInterval = draft.short.sampleInterval;
|
||||
const timeSinceLastShort = value.timestamp - draft.short.lastTimestamp;
|
||||
|
||||
if (timeSinceLastShort >= shortSampleInterval) {
|
||||
const shortOldValue = draft.short.values[draft.short.index];
|
||||
const isShortOverwriting = shortOldValue && shortOldValue.timestamp > 0;
|
||||
|
||||
draft.short.values[draft.short.index] = value;
|
||||
draft.short.index = (draft.short.index + 1) % draft.short.size;
|
||||
draft.short.lastTimestamp = value.timestamp;
|
||||
|
||||
if (!isShortOverwriting) {
|
||||
draft.short.validCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into long buffer (RAM + IndexedDB)
|
||||
const longSampleInterval = draft.long.sampleInterval;
|
||||
const timeSinceLastLong = value.timestamp - draft.long.lastTimestamp;
|
||||
|
||||
if (timeSinceLastLong >= longSampleInterval) {
|
||||
const longOldValue = draft.long.values[draft.long.index];
|
||||
const isLongOverwriting = longOldValue && longOldValue.timestamp > 0;
|
||||
|
||||
draft.long.values[draft.long.index] = value;
|
||||
draft.long.index = (draft.long.index + 1) % draft.long.size;
|
||||
draft.long.lastTimestamp = value.timestamp;
|
||||
|
||||
if (!isLongOverwriting) {
|
||||
draft.long.validCount++;
|
||||
}
|
||||
|
||||
// Persist to IndexedDB (async, fire-and-forget)
|
||||
if (enablePersistence) {
|
||||
storeDataPoint(namespaceId, seriesName, value).catch((error) => {
|
||||
console.error(
|
||||
`Failed to persist data point for ${namespaceId}:${seriesName}`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
// Maybe trigger cleanup
|
||||
maybeCleanup().catch((error) => {
|
||||
console.error(
|
||||
`Cleanup check failed for ${namespaceId}:${seriesName}`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return newSeries;
|
||||
};
|
||||
|
||||
// History loaded callbacks for lazy loading
|
||||
const historyCallbacks: ((historicalSeries: TimeSeries) => void)[] = [];
|
||||
let historyLoadedSeries: TimeSeries | null = null;
|
||||
|
||||
/**
|
||||
* Register a callback to be called when historical data is loaded (lazy mode)
|
||||
*/
|
||||
const onHistoryLoaded = (callback: (historicalSeries: TimeSeries) => void): void => {
|
||||
// If history already loaded, call immediately
|
||||
if (historyLoadedSeries !== null) {
|
||||
callback(historyLoadedSeries);
|
||||
} else {
|
||||
historyCallbacks.push(callback);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fire history loaded callbacks
|
||||
*/
|
||||
const fireHistoryCallbacks = (series: TimeSeries): void => {
|
||||
historyLoadedSeries = series;
|
||||
for (const callback of historyCallbacks) {
|
||||
try {
|
||||
callback(series);
|
||||
} catch (error) {
|
||||
console.error(`History callback error for ${namespaceId}:${seriesName}`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load historical data and populate series
|
||||
*/
|
||||
const loadAndPopulateHistory = async (): Promise<TimeSeries> => {
|
||||
try {
|
||||
// Limit initial load window to avoid UI stalls when history is large
|
||||
const loadWindowMs = Math.min(
|
||||
retentionDurationLong,
|
||||
initialLoadWindowMs,
|
||||
);
|
||||
|
||||
// Skip loading if window is 0
|
||||
if (loadWindowMs === 0) {
|
||||
return initialTimeSeries;
|
||||
}
|
||||
|
||||
const historicalData = await loadHistoricalData(
|
||||
namespaceId,
|
||||
seriesName,
|
||||
loadWindowMs,
|
||||
);
|
||||
|
||||
// Populate the long series buffer
|
||||
const populatedLong = populateSeriesFromHistory(
|
||||
initialTimeSeries.long,
|
||||
historicalData,
|
||||
);
|
||||
|
||||
return {
|
||||
...initialTimeSeries,
|
||||
long: populatedLong,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to initialize persistent timeseries for ${namespaceId}:${seriesName}`,
|
||||
error,
|
||||
);
|
||||
return initialTimeSeries;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine ready behavior based on lazyLoadHistory setting
|
||||
let ready: Promise<TimeSeries>;
|
||||
|
||||
if (!enablePersistence) {
|
||||
// Persistence disabled: resolve immediately with empty series
|
||||
ready = Promise.resolve(initialTimeSeries);
|
||||
} else if (lazyLoadHistory) {
|
||||
// Lazy loading: resolve immediately, load history in background
|
||||
ready = Promise.resolve(initialTimeSeries);
|
||||
|
||||
// Start background loading after a small delay to let UI render first
|
||||
setTimeout(() => {
|
||||
loadAndPopulateHistory().then((loadedSeries) => {
|
||||
fireHistoryCallbacks(loadedSeries);
|
||||
});
|
||||
}, 100); // Small delay to ensure live data starts flowing first
|
||||
} else {
|
||||
// Blocking mode: wait for history before resolving
|
||||
ready = loadAndPopulateHistory();
|
||||
}
|
||||
|
||||
return {
|
||||
initialTimeSeries,
|
||||
insert,
|
||||
ready,
|
||||
onHistoryLoaded,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper hook to wait for timeseries to be ready with historical data
|
||||
* This can be used in React components to ensure data is loaded before rendering
|
||||
*/
|
||||
export async function waitForTimeSeriesReady(
|
||||
series: PersistentTimeSeriesWithInsert,
|
||||
): Promise<TimeSeries> {
|
||||
return await series.ready;
|
||||
}
|
||||
@@ -1,38 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Creates a Zod schema for objects where exactly one property must be defined.
|
||||
* This is useful for handling Rust enum JSON representations where only one variant exists at a time.
|
||||
*
|
||||
* @param schemas A record mapping property names to their Zod schemas
|
||||
* @returns A Zod schema that validates that exactly one property is defined
|
||||
*/
|
||||
export function rustEnumSchema<T extends Record<string, z.ZodType>>(
|
||||
schemas: T,
|
||||
): z.ZodType<{ [K in keyof T]?: z.infer<T[K]> }> {
|
||||
// Create an object schema where all properties are optional
|
||||
const schemaEntries = Object.entries(schemas).map(([key, schema]) => [
|
||||
key,
|
||||
schema.optional(),
|
||||
]);
|
||||
|
||||
const objectSchema = z.object(
|
||||
Object.fromEntries(schemaEntries) as {
|
||||
[K in keyof T]: z.ZodOptional<T[K]>;
|
||||
},
|
||||
export const rustEnum: z.core.CheckFn<object> = (ctx) => {
|
||||
const definedKeys = Object.keys(ctx.value).filter(
|
||||
(key) => ctx.value[key] !== undefined,
|
||||
);
|
||||
|
||||
// Add refinement to ensure exactly one property is defined
|
||||
return objectSchema.refine(
|
||||
(data) => {
|
||||
const definedKeys = Object.keys(data).filter(
|
||||
(key) => data[key] !== undefined,
|
||||
);
|
||||
return definedKeys.length === 1;
|
||||
},
|
||||
{
|
||||
message: "Exactly one property must be defined",
|
||||
path: ["_exclusive"], // Add a path to help with error identification
|
||||
},
|
||||
);
|
||||
}
|
||||
if (definedKeys.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.issues.push({
|
||||
code: "custom",
|
||||
message: "Exactly one property must be defined",
|
||||
input: definedKeys,
|
||||
});
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// super refine function to validate if a string is a valid u16 number
|
||||
export function validateU16<T>(): (value: T, ctx: z.RefinementCtx) => boolean {
|
||||
const refine = (value: T, ctx: z.RefinementCtx) => {
|
||||
console.log("value", value);
|
||||
if (typeof value === "string") {
|
||||
const number = parseInt(value, 10);
|
||||
if (number < 0 || number > 65535) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Wert muss u16 integer sein",
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
return refine;
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
|
||||
import { useAquapath1 } from "./useAquapath";
|
||||
import { SelectionGroup } from "@/control/SelectionGroup";
|
||||
import { EditValue } from "@/control/EditValue";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/control/Label";
|
||||
import { HeatingZone } from "../../extruder/HeatingZone";
|
||||
|
||||
export function Aquapath1ControlPage() {
|
||||
const {
|
||||
@@ -16,8 +18,12 @@ export function Aquapath1ControlPage() {
|
||||
back_flow,
|
||||
front_temperature,
|
||||
back_temperature,
|
||||
front_temp_reservoir,
|
||||
back_temp_reservoir,
|
||||
front_revolutions,
|
||||
back_revolutions,
|
||||
front_power,
|
||||
back_power,
|
||||
front_total_energy,
|
||||
back_total_energy,
|
||||
setAquapathMode,
|
||||
setFrontTemperature,
|
||||
setBackTemperature,
|
||||
@@ -29,111 +35,55 @@ export function Aquapath1ControlPage() {
|
||||
const backTargetTemperature =
|
||||
state?.temperature_states?.back.target_temperature ?? 0;
|
||||
|
||||
const frontCurrentTemperature =
|
||||
state?.temperature_states?.front.temperature ?? 0;
|
||||
const backCurrentTemperature =
|
||||
state?.temperature_states?.back.temperature ?? 0;
|
||||
|
||||
const frontCoolingBoundary =
|
||||
frontTargetTemperature + (state?.tolerance_states.front.cooling ?? 0);
|
||||
const frontHeatingBoundary =
|
||||
frontTargetTemperature - (state?.tolerance_states.front.heating ?? 0);
|
||||
const backCoolingBoundary =
|
||||
backTargetTemperature + (state?.tolerance_states.back.cooling ?? 0);
|
||||
const backHeatingBoundary =
|
||||
backTargetTemperature - (state?.tolerance_states.back.heating ?? 0);
|
||||
|
||||
const frontTargetFlow = state?.flow_states.front.should_flow ?? false;
|
||||
const backTargetFlow = state?.flow_states.back.should_flow ?? false;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
<ControlCard title="Flow Measurements">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="flex flex-col gap-8">
|
||||
<ControlCard title="Reservoir 1">
|
||||
<div className="grid grid-rows-5 gap-4">
|
||||
<div className="flex flex-row">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Flow 1"
|
||||
label="Flow"
|
||||
unit="l/min"
|
||||
timeseries={front_flow}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-row">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Flow 2"
|
||||
unit="l/min"
|
||||
timeseries={back_flow}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Label label="Pump 1">
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={frontTargetFlow ? "On" : "Off"}
|
||||
orientation="vertical"
|
||||
className="grid h-full grid-cols-2 gap-2"
|
||||
options={{
|
||||
Off: {
|
||||
children: "Off",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
On: {
|
||||
children: "On",
|
||||
icon: "lu:CirclePlay",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (value == "On") {
|
||||
setFrontFlow(true);
|
||||
} else {
|
||||
setFrontFlow(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label label="Pump 2">
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={backTargetFlow ? "On" : "Off"}
|
||||
orientation="vertical"
|
||||
className="grid h-full grid-cols-2 gap-2"
|
||||
options={{
|
||||
Off: {
|
||||
children: "Off",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
On: {
|
||||
children: "On",
|
||||
icon: "lu:CirclePlay",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (value == "On") {
|
||||
setBackFlow(true);
|
||||
} else {
|
||||
setBackFlow(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard title="Temperature Measurements">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="flex flex-col gap-8">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Reservoir 1"
|
||||
label="Temperature"
|
||||
unit="C"
|
||||
timeseries={front_temperature}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
<Label label="Set Reservoir 1 Target Temperature">
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4">
|
||||
<Label label="Set Target Temperature">
|
||||
<EditValue
|
||||
title="Set Reservoir 1 Target Temperature"
|
||||
title="Set Target Temperature"
|
||||
min={0}
|
||||
value={frontTargetTemperature}
|
||||
max={80}
|
||||
unit="C"
|
||||
step={0.1}
|
||||
min={0.0}
|
||||
max={80.0}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
onChange={(val) => {
|
||||
setFrontTemperature(val);
|
||||
@@ -143,23 +93,88 @@ export function Aquapath1ControlPage() {
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
{frontCurrentTemperature < frontHeatingBoundary &&
|
||||
(state?.flow_states.front.flow ?? 0) > 0 && (
|
||||
<Badge variant="default">
|
||||
Power: {front_power.current?.value} W<br />
|
||||
Total: {front_total_energy.current?.value} kWh
|
||||
</Badge>
|
||||
)}
|
||||
{frontCurrentTemperature > frontCoolingBoundary &&
|
||||
frontTargetFlow &&
|
||||
(state?.flow_states.front.flow ?? 0) && (
|
||||
<Badge variant="secondary">Cooling</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-row">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Reservoir 2"
|
||||
label="Revolution Speed"
|
||||
unit="%"
|
||||
timeseries={front_revolutions}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row">
|
||||
<Label label="Pump">
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={frontTargetFlow ? "On" : "Off"}
|
||||
orientation="vertical"
|
||||
className="grid h-full grid-cols-2 gap-2"
|
||||
options={{
|
||||
Off: {
|
||||
children: "Off",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
On: {
|
||||
children: "On",
|
||||
icon: "lu:CirclePlay",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setFrontFlow(value == "On");
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard title="Reservoir 2">
|
||||
<div className="grid grid-rows-4 gap-4">
|
||||
<div className="flex flex-row">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Flow"
|
||||
unit="l/min"
|
||||
timeseries={back_flow}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Temperature"
|
||||
unit="C"
|
||||
timeseries={back_temperature}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
<Label label="Set Reservoir 2 Target Temperature">
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4">
|
||||
<Label label="Set Target Temperature">
|
||||
<EditValue
|
||||
title="Set Reservoir 2 Target Temperature"
|
||||
title="Set Target Temperature"
|
||||
min={0}
|
||||
value={backTargetTemperature}
|
||||
max={80}
|
||||
unit="C"
|
||||
step={0.1}
|
||||
min={0.0}
|
||||
max={80.0}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
onChange={(val) => {
|
||||
setBackTemperature(val);
|
||||
@@ -169,6 +184,56 @@ export function Aquapath1ControlPage() {
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
{backCurrentTemperature < backHeatingBoundary &&
|
||||
backTargetFlow &&
|
||||
(state?.flow_states.front.flow ?? 0) && (
|
||||
<Badge variant="default">
|
||||
Power: {back_power.current?.value} W<br />
|
||||
Total: {back_total_energy.current?.value} kWh
|
||||
</Badge>
|
||||
)}
|
||||
{backCurrentTemperature > backCoolingBoundary &&
|
||||
frontTargetFlow &&
|
||||
(state?.flow_states.front.flow ?? 0) && (
|
||||
<Badge variant="secondary">Cooling</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Revolution Speed"
|
||||
unit="%"
|
||||
timeseries={back_revolutions}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row">
|
||||
<Label label="Pump">
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={backTargetFlow ? "On" : "Off"}
|
||||
orientation="vertical"
|
||||
className="grid h-full grid-cols-2 gap-2"
|
||||
options={{
|
||||
Off: {
|
||||
children: "Off",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
On: {
|
||||
children: "On",
|
||||
icon: "lu:CirclePlay",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setBackFlow(value == "On");
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</ControlCard>
|
||||
|
||||
@@ -18,7 +18,13 @@ export function Aquapath1Page() {
|
||||
link: "graph",
|
||||
title: "Graph",
|
||||
icon: "lu:ChartSpline",
|
||||
activeLink: "graphs",
|
||||
activeLink: "graph",
|
||||
},
|
||||
{
|
||||
link: "settings",
|
||||
title: "Config",
|
||||
icon: "lu:Settings",
|
||||
activeLink: "settings",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { EditValue } from "@/control/EditValue";
|
||||
import { Label } from "@/control/Label";
|
||||
import { useAquapath1 } from "./useAquapath";
|
||||
import React from "react";
|
||||
|
||||
export function Aquapath1SettingsPage() {
|
||||
const {
|
||||
state,
|
||||
setFrontRevolutions,
|
||||
setBackRevolutions,
|
||||
setFrontHeatingTolerance,
|
||||
setFrontCoolingTolerance,
|
||||
setBackHeatingTolerance,
|
||||
setBackCoolingTolerance,
|
||||
} = useAquapath1();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
<ControlCard title="Front Fan Revolutions">
|
||||
<Label label="Set Max Revolution Speed">
|
||||
<EditValue
|
||||
title="Set Max Revolution Speed"
|
||||
min={0}
|
||||
value={state?.fan_states.front.revolutions}
|
||||
max={100}
|
||||
unit="%"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
onChange={(val) => {
|
||||
setFrontRevolutions(val);
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard title="Back Fan Revolutions">
|
||||
<Label label="Set Max Revolution Speed">
|
||||
<EditValue
|
||||
title="Set Max Revolution Speed"
|
||||
min={0}
|
||||
value={state?.fan_states.back.revolutions}
|
||||
max={100}
|
||||
unit="%"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
onChange={(val) => {
|
||||
setBackRevolutions(val);
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard title="Front Temperature Tolerances">
|
||||
<Label label="Set Heating Tolerance">
|
||||
<EditValue
|
||||
title="Set Heating Tolerance"
|
||||
min={0}
|
||||
value={state?.tolerance_states.front.heating}
|
||||
max={10}
|
||||
step={0.1}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
onChange={(val) => {
|
||||
setFrontHeatingTolerance(val);
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label label="Set Cooling Tolerance">
|
||||
<EditValue
|
||||
title="Set Cooling Tolerance"
|
||||
min={0}
|
||||
value={state?.tolerance_states.front.cooling}
|
||||
max={10}
|
||||
step={0.1}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
onChange={(val) => {
|
||||
setFrontCoolingTolerance(val);
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard title="Back Temperature Tolerances">
|
||||
<Label label="Set Heating Tolerance">
|
||||
<EditValue
|
||||
title="Set Heating Tolerance"
|
||||
min={0}
|
||||
value={state?.tolerance_states.back.heating}
|
||||
max={10}
|
||||
step={0.1}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
onChange={(val) => {
|
||||
setBackHeatingTolerance(val);
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label label="Set Cooling Tolerance">
|
||||
<EditValue
|
||||
title="Set Cooling Tolerance"
|
||||
min={0}
|
||||
value={state?.tolerance_states.back.cooling}
|
||||
max={10}
|
||||
step={0.1}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
onChange={(val) => {
|
||||
setBackCoolingTolerance(val);
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
ThrottledStoreUpdater,
|
||||
} from "../../../client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
import { createPersistentTimeSeries } from "@/lib/timeseriesPersistent";
|
||||
import { createTimeSeries, TimeSeries } from "@/lib/timeseries";
|
||||
|
||||
// ========== Event Schema Definitions ==========
|
||||
/**
|
||||
@@ -52,6 +51,23 @@ export const flowStatesSchema = z.object({
|
||||
back: flowStateSchema,
|
||||
});
|
||||
|
||||
export const fanStateSchema = z.object({
|
||||
revolutions: z.number(),
|
||||
max_revolutions: z.number(),
|
||||
});
|
||||
export const fanStatesSchema = z.object({
|
||||
back: fanStateSchema,
|
||||
front: fanStateSchema,
|
||||
});
|
||||
|
||||
export const toleranceStateSchema = z.object({
|
||||
heating: z.number(),
|
||||
cooling: z.number(),
|
||||
});
|
||||
export const toleranceStatesSchema = z.object({
|
||||
back: toleranceStateSchema,
|
||||
front: toleranceStateSchema,
|
||||
});
|
||||
/**
|
||||
* Live values event schema (time-series data)
|
||||
*/
|
||||
@@ -62,6 +78,12 @@ export const liveValuesEventDataSchema = z.object({
|
||||
back_temperature: z.number(),
|
||||
front_temp_reservoir: z.number(),
|
||||
back_temp_reservoir: z.number(),
|
||||
front_revolutions: z.number(),
|
||||
back_revolutions: z.number(),
|
||||
back_power: z.number(),
|
||||
front_power: z.number(),
|
||||
front_total_energy: z.number(),
|
||||
back_total_energy: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -72,6 +94,8 @@ export const stateEventDataSchema = z.object({
|
||||
mode_state: modeStateSchema,
|
||||
flow_states: flowStatesSchema,
|
||||
temperature_states: tempStatesSchema,
|
||||
fan_states: fanStatesSchema,
|
||||
tolerance_states: toleranceStatesSchema,
|
||||
});
|
||||
|
||||
// ========== Event Schemas with Wrappers ==========
|
||||
@@ -97,20 +121,39 @@ export type Aquapath1NamespaceStore = {
|
||||
|
||||
front_temp_reservoir: TimeSeries;
|
||||
back_temp_reservoir: TimeSeries;
|
||||
|
||||
front_revolutions: TimeSeries;
|
||||
back_revolutions: TimeSeries;
|
||||
|
||||
front_power: TimeSeries;
|
||||
back_power: TimeSeries;
|
||||
|
||||
front_total_energy: TimeSeries;
|
||||
back_total_energy: TimeSeries;
|
||||
};
|
||||
|
||||
const { initialTimeSeries: front_temperature, insert: addTemperature1 } =
|
||||
createPersistentTimeSeries("aquapath1", "front_temperature");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: back_temperature, insert: addTemperature2 } =
|
||||
createPersistentTimeSeries("aquapath1", "back_temperature");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: front_temp_reservoir, insert: addTempReserv1 } =
|
||||
createPersistentTimeSeries("aquapath1", "front_temp_reservoir");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: back_temp_reservoir, insert: addTempReserv2 } =
|
||||
createPersistentTimeSeries("aquapath1", "back_temp_reservoir");
|
||||
const { initialTimeSeries: front_flow, insert: addFlow1 } =
|
||||
createPersistentTimeSeries("aquapath1", "front_flow");
|
||||
const { initialTimeSeries: back_flow, insert: addFlow2 } =
|
||||
createPersistentTimeSeries("aquapath1", "back_flow");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: front_flow, insert: addFlow1 } = createTimeSeries();
|
||||
const { initialTimeSeries: back_flow, insert: addFlow2 } = createTimeSeries();
|
||||
const { initialTimeSeries: front_revolutions, insert: addFan1 } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: back_revolutions, insert: addFan2 } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: front_power, insert: addFrontPower } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: back_power, insert: addBackPower } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: front_total_energy, insert: addFrontEnergy } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: back_total_energy, insert: addBackEnergy } =
|
||||
createTimeSeries();
|
||||
|
||||
/**
|
||||
* Factory function to create a new Aquapath namespace store
|
||||
@@ -128,6 +171,12 @@ export const createAquapath1NamespaceStore =
|
||||
back_flow: back_flow,
|
||||
front_temp_reservoir: front_temp_reservoir,
|
||||
back_temp_reservoir: back_temp_reservoir,
|
||||
front_revolutions: front_revolutions,
|
||||
back_revolutions: back_revolutions,
|
||||
back_power: back_power,
|
||||
front_power: front_power,
|
||||
front_total_energy: front_total_energy,
|
||||
back_total_energy: back_total_energy,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -195,6 +244,30 @@ export function aquapath1MessageHandler(
|
||||
value: liveValuesEvent.data.back_temp_reservoir,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
front_revolutions: addFan1(state.front_revolutions, {
|
||||
value: liveValuesEvent.data.front_revolutions,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
back_revolutions: addFan2(state.back_revolutions, {
|
||||
value: liveValuesEvent.data.back_revolutions,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
front_power: addFrontPower(state.front_power, {
|
||||
value: liveValuesEvent.data.front_power,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
back_power: addBackPower(state.back_power, {
|
||||
value: liveValuesEvent.data.back_power,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
front_total_energy: addFrontEnergy(state.front_total_energy, {
|
||||
value: liveValuesEvent.data.front_total_energy,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
back_total_energy: addBackEnergy(state.back_total_energy, {
|
||||
value: liveValuesEvent.data.back_total_energy,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
}));
|
||||
} else {
|
||||
handleUnhandledEventError(eventName);
|
||||
|
||||
@@ -42,12 +42,18 @@ export function useAquapath1() {
|
||||
const {
|
||||
state,
|
||||
defaultState,
|
||||
front_temperature: front_temperature,
|
||||
back_temperature: back_temperature,
|
||||
front_flow: front_flow,
|
||||
back_flow: back_flow,
|
||||
front_temp_reservoir: front_temp_reservoir,
|
||||
back_temp_reservoir: back_temp_reservoir,
|
||||
front_temperature,
|
||||
back_temperature,
|
||||
front_flow,
|
||||
back_flow,
|
||||
front_temp_reservoir,
|
||||
back_temp_reservoir,
|
||||
front_revolutions,
|
||||
back_revolutions,
|
||||
front_power,
|
||||
back_power,
|
||||
front_total_energy,
|
||||
back_total_energy,
|
||||
} = useAquapath1Namespace(machineIdentification);
|
||||
|
||||
// Single optimistic state for all state management
|
||||
@@ -125,6 +131,89 @@ export function useAquapath1() {
|
||||
);
|
||||
};
|
||||
|
||||
const setFrontRevolutions = (revolutions: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.fan_states.back.revolutions = revolutions;
|
||||
},
|
||||
() => {
|
||||
requestFrontRevolutions({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetFrontRevolutions: revolutions },
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setBackRevolutions = (revolutions: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.fan_states.back.revolutions = revolutions;
|
||||
},
|
||||
() =>
|
||||
requestBackRevolutions({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetBackRevolutions: revolutions },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setFrontHeatingTolerance = (tolerance: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.tolerance_states.front.heating = tolerance;
|
||||
},
|
||||
() => {
|
||||
requestFrontHeatingTolerance({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetFrontHeatingTolerance: tolerance },
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setBackHeatingTolerance = (tolerance: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.tolerance_states.back.heating = tolerance;
|
||||
},
|
||||
() => {
|
||||
requestBackHeatingTolerance({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetBackHeatingTolerance: tolerance },
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setFrontCoolingTolerance = (tolerance: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.tolerance_states.front.cooling = tolerance;
|
||||
},
|
||||
() => {
|
||||
requestFrontCoolingTolerance({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetFrontCoolingTolerance: tolerance },
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setBackCoolingTolerance = (tolerance: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.tolerance_states.back.cooling = tolerance;
|
||||
},
|
||||
() => {
|
||||
requestBackCoolingTolerance({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetBackCoolingTolerance: tolerance },
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Mutation hooks
|
||||
const { request: requestAquapathMode } = useMachineMutation(
|
||||
z.object({ SetAquaPathMode: z.enum(["Standby", "Auto"]) }),
|
||||
@@ -141,6 +230,25 @@ export function useAquapath1() {
|
||||
const { request: requestBackFlow } = useMachineMutation(
|
||||
z.object({ SetBackFlow: z.boolean() }),
|
||||
);
|
||||
const { request: requestFrontRevolutions } = useMachineMutation(
|
||||
z.object({ SetFrontRevolutions: z.number() }),
|
||||
);
|
||||
const { request: requestBackRevolutions } = useMachineMutation(
|
||||
z.object({ SetBackRevolutions: z.number() }),
|
||||
);
|
||||
const { request: requestFrontHeatingTolerance } = useMachineMutation(
|
||||
z.object({ SetFrontHeatingTolerance: z.number() }),
|
||||
);
|
||||
const { request: requestFrontCoolingTolerance } = useMachineMutation(
|
||||
z.object({ SetFrontCoolingTolerance: z.number() }),
|
||||
);
|
||||
const { request: requestBackHeatingTolerance } = useMachineMutation(
|
||||
z.object({ SetBackHeatingTolerance: z.number() }),
|
||||
);
|
||||
const { request: requestBackCoolingTolerance } = useMachineMutation(
|
||||
z.object({ SetBackCoolingTolerance: z.number() }),
|
||||
);
|
||||
|
||||
// Helper function for optimistic updates using produce
|
||||
const updateStateOptimistically = (
|
||||
producer: (current: StateEvent) => void,
|
||||
@@ -165,11 +273,23 @@ export function useAquapath1() {
|
||||
back_temperature,
|
||||
front_temp_reservoir,
|
||||
back_temp_reservoir,
|
||||
front_revolutions,
|
||||
back_revolutions,
|
||||
front_power,
|
||||
back_power,
|
||||
front_total_energy,
|
||||
back_total_energy,
|
||||
|
||||
setAquapathMode,
|
||||
setFrontTemperature,
|
||||
setBackTemperature,
|
||||
setFrontFlow,
|
||||
setBackFlow,
|
||||
setFrontRevolutions,
|
||||
setBackRevolutions,
|
||||
setFrontHeatingTolerance,
|
||||
setBackHeatingTolerance,
|
||||
setFrontCoolingTolerance,
|
||||
setBackCoolingTolerance,
|
||||
};
|
||||
}
|
||||
@@ -130,13 +130,12 @@ export function buffer1MessageHandler(
|
||||
* @returns A new Zustand store instance for Buffer1 namespace
|
||||
*/
|
||||
export const createBuffer1NamespaceStore =
|
||||
(): StoreApi<Buffer1NamespaceStore> => {
|
||||
return create<Buffer1NamespaceStore>(() => {
|
||||
(): StoreApi<Buffer1NamespaceStore> =>
|
||||
create<Buffer1NamespaceStore>(() => {
|
||||
return {
|
||||
state: null,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the Buffer1 namespace implementation
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { Page } from "@/components/Page";
|
||||
import React from "react";
|
||||
import { Label } from "@/control/Label";
|
||||
import { useDigitalInputTestMachine } from "./useDigitalInputTestMachine";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { SelectionGroup } from "@/control/SelectionGroup";
|
||||
import { toastError } from "@/components/Toast";
|
||||
|
||||
export function DigitalInputTestMachineControlPage() {
|
||||
const { state } = useDigitalInputTestMachine();
|
||||
|
||||
const safeState = state ?? { led_on: [false, false, false, false] };
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlCard title="Machine LEDs">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{safeState.led_on.map((led, index) => (
|
||||
<Label key={index} label={`LED ${index + 1}`}>
|
||||
<Badge variant={led ? "outline" : "destructive"}>
|
||||
{led ? "On" : "OFF"}
|
||||
</Badge>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</ControlCard>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Topbar } from "@/components/Topbar";
|
||||
import { digitalInputTestMachineSerialRoute } from "@/routes/routes";
|
||||
import React from "react";
|
||||
|
||||
export function DigitalInputTestMachinePage() {
|
||||
const { serial } = digitalInputTestMachineSerialRoute.useParams();
|
||||
return (
|
||||
<Topbar
|
||||
pathname={`/_sidebar/machines/digitalInputTestMachine/${serial}`}
|
||||
items={[
|
||||
{
|
||||
link: "control",
|
||||
activeLink: "control",
|
||||
title: "Control",
|
||||
icon: "lu:CirclePlay",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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(4),
|
||||
});
|
||||
|
||||
export const stateEventSchema = eventSchema(stateEventDataSchema);
|
||||
|
||||
export type StateEvent = z.infer<typeof stateEventDataSchema>;
|
||||
|
||||
// ========== Store ==========
|
||||
export type DigitalTestMachineNamespaceStore = {
|
||||
state: StateEvent | null;
|
||||
};
|
||||
|
||||
export const createTestMachineNamespaceStore =
|
||||
(): StoreApi<DigitalTestMachineNamespaceStore> =>
|
||||
create<DigitalTestMachineNamespaceStore>(() => ({
|
||||
state: null,
|
||||
}));
|
||||
|
||||
// ========== Message Handler ==========
|
||||
export function digitalInputTestMachineMessageHandler(
|
||||
store: StoreApi<DigitalTestMachineNamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<DigitalTestMachineNamespaceStore>,
|
||||
): EventHandler {
|
||||
return (event: Event<any>) => {
|
||||
const updateStore = (
|
||||
updater: (
|
||||
state: DigitalTestMachineNamespaceStore,
|
||||
) => DigitalTestMachineNamespaceStore,
|
||||
) => 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 useDigitalInputTestMachineNamespaceImplementation =
|
||||
createNamespaceHookImplementation<DigitalTestMachineNamespaceStore>({
|
||||
createStore: createTestMachineNamespaceStore,
|
||||
createEventHandler: digitalInputTestMachineMessageHandler,
|
||||
});
|
||||
|
||||
export function useDigitalInputTestMachineNamespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): DigitalTestMachineNamespaceStore {
|
||||
const namespaceId: NamespaceId = {
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
};
|
||||
|
||||
return useDigitalInputTestMachineNamespaceImplementation(namespaceId);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Toast, toastError } from "@/components/Toast";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { digitalInputTestMachineSerialRoute } from "@/routes/routes";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import {
|
||||
useDigitalInputTestMachineNamespace,
|
||||
StateEvent,
|
||||
} from "./digitalInputTestMachineNamespace";
|
||||
import { useMachineMutate } from "@/client/useClient";
|
||||
import { produce } from "immer";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { digitalInputTestMachine } from "@/machines/properties";
|
||||
import { z } from "zod";
|
||||
export function useDigitalInputTestMachine() {
|
||||
const { serial: serialString } =
|
||||
digitalInputTestMachineSerialRoute.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: digitalInputTestMachine.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]);
|
||||
|
||||
// Namespace state from backend
|
||||
const { state } = useDigitalInputTestMachineNamespace(machineIdentification);
|
||||
|
||||
// Optimistic state
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
|
||||
useEffect(() => {
|
||||
if (state) stateOptimistic.setReal(state);
|
||||
}, [state, stateOptimistic]);
|
||||
|
||||
return {
|
||||
state: stateOptimistic.value,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
PresetPreviewEntries,
|
||||
previewSeparator,
|
||||
} from "@/components/preset/PresetPreviewTable";
|
||||
import { Preset, PresetData } from "@/lib/preset/preset";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
|
||||
const extruder1PresetDataSchema = z
|
||||
.object({
|
||||
@@ -31,7 +31,7 @@ const extruder1PresetDataSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
type Extruder2 = typeof extruder1PresetDataSchema;
|
||||
type Extruder2 = z.infer<typeof extruder1PresetDataSchema>;
|
||||
|
||||
const schemas = new Map([[1, extruder1PresetDataSchema]]);
|
||||
|
||||
@@ -39,76 +39,69 @@ const previewEntries: PresetPreviewEntries<Extruder2> = [
|
||||
{
|
||||
name: "Target Front Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
renderValue: (data: Extruder2) =>
|
||||
data.targetFrontHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Target Middle Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
renderValue: (data: Extruder2) =>
|
||||
data.targetMiddleHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Target Back Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
renderValue: (data: Extruder2) =>
|
||||
data.targetBackHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Target Nozzle Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
renderValue: (data: Extruder2) =>
|
||||
data.targetNozzleHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "Inverter Regulation",
|
||||
renderValue: (data: PresetData<Extruder2>) => data.inverterRegulation,
|
||||
renderValue: (data: Extruder2) => data.inverterRegulation,
|
||||
},
|
||||
{
|
||||
name: "Target Inverter RPM",
|
||||
unit: "rpm",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.targetInverterRpm?.toFixed(2),
|
||||
renderValue: (data: Extruder2) => data.targetInverterRpm?.toFixed(2),
|
||||
},
|
||||
{
|
||||
name: "Target Inverter Pressure",
|
||||
unit: "bar",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.targetInverterPressure?.toFixed(1),
|
||||
renderValue: (data: Extruder2) => data.targetInverterPressure?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Inverter Direction",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.inverterRotationDirection,
|
||||
renderValue: (data: Extruder2) => data.inverterRotationDirection,
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "Pressure Limit",
|
||||
unit: "bar",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.pressureLimit?.toFixed(1),
|
||||
renderValue: (data: Extruder2) => data.pressureLimit?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Enable Pressure Limit",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
renderValue: (data: Extruder2) =>
|
||||
data.pressureLimitEnabled ? "on" : "off",
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "PID Pressue Kp",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.pidPressureKp?.toFixed(4),
|
||||
renderValue: (data: Extruder2) => data.pidPressureKp?.toFixed(4),
|
||||
},
|
||||
{
|
||||
name: "PID Pressue Ki",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.pidPressureKi?.toFixed(4),
|
||||
renderValue: (data: Extruder2) => data.pidPressureKi?.toFixed(4),
|
||||
},
|
||||
{
|
||||
name: "PID Pressue Kd",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.pidPressureKd?.toFixed(4),
|
||||
renderValue: (data: Extruder2) => data.pidPressureKd?.toFixed(4),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -135,7 +128,7 @@ export function Extruder2PresetsPage() {
|
||||
setPressurePidKp,
|
||||
} = useExtruder2();
|
||||
|
||||
const toPresetData = (s?: typeof state): PresetData<Extruder2> => ({
|
||||
const toPresetData = (s?: typeof state): Extruder2 => ({
|
||||
targetFrontHeatingTemperature: s?.heating_states.front.target_temperature,
|
||||
targetMiddleHeatingTemperature: s?.heating_states.middle.target_temperature,
|
||||
targetBackHeatingTemperature: s?.heating_states.back.target_temperature,
|
||||
@@ -161,32 +154,32 @@ export function Extruder2PresetsPage() {
|
||||
|
||||
const applyPreset = (preset: Preset<Extruder2>) => {
|
||||
setFrontHeatingTemperature(
|
||||
preset.data.targetFrontHeatingTemperature ?? 150.0,
|
||||
preset?.data?.targetFrontHeatingTemperature ?? 150.0,
|
||||
);
|
||||
setMiddleHeatingTemperature(
|
||||
preset.data.targetMiddleHeatingTemperature ?? 150.0,
|
||||
preset?.data?.targetMiddleHeatingTemperature ?? 150.0,
|
||||
);
|
||||
setBackHeatingTemperature(
|
||||
preset.data.targetBackHeatingTemperature ?? 150.0,
|
||||
preset?.data?.targetBackHeatingTemperature ?? 150.0,
|
||||
);
|
||||
setNozzleHeatingTemperature(
|
||||
preset.data.targetNozzleHeatingTemperature ?? 150.0,
|
||||
preset?.data?.targetNozzleHeatingTemperature ?? 150.0,
|
||||
);
|
||||
|
||||
setInverterTargetRpm(preset.data.targetInverterRpm ?? 0);
|
||||
setInverterTargetPressure(preset.data.targetInverterPressure ?? 0);
|
||||
setInverterRegulation(preset.data.inverterRegulation === "RPM");
|
||||
setInverterTargetRpm(preset?.data?.targetInverterRpm ?? 0);
|
||||
setInverterTargetPressure(preset?.data?.targetInverterPressure ?? 0);
|
||||
setInverterRegulation(preset?.data?.inverterRegulation === "RPM");
|
||||
|
||||
setInverterRotationDirection(
|
||||
preset.data.inverterRotationDirection === "Forward",
|
||||
preset?.data?.inverterRotationDirection === "Forward",
|
||||
);
|
||||
|
||||
setExtruderPressureLimit(preset.data.pressureLimit ?? 100.0);
|
||||
setExtruderPressureLimitEnabled(preset.data.pressureLimitEnabled ?? true);
|
||||
setExtruderPressureLimit(preset?.data?.pressureLimit ?? 100.0);
|
||||
setExtruderPressureLimitEnabled(preset?.data?.pressureLimitEnabled ?? true);
|
||||
|
||||
setPressurePidKp(preset.data.pidPressureKp ?? 0.16);
|
||||
setPressurePidKi(preset.data.pidPressureKi ?? 0.0);
|
||||
setPressurePidKd(preset.data.pidPressureKd ?? 0.008);
|
||||
setPressurePidKp(preset?.data?.pidPressureKp ?? 0.16);
|
||||
setPressurePidKi(preset?.data?.pidPressureKi ?? 0.0);
|
||||
setPressurePidKd(preset?.data?.pidPressureKd ?? 0.008);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
handleUnhandledEventError,
|
||||
} from "../../../client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
import { createPersistentTimeSeries } from "@/lib/timeseriesPersistent";
|
||||
import { createTimeSeries, TimeSeries } from "@/lib/timeseries";
|
||||
import { useMemo } from "react";
|
||||
|
||||
// ========== Event Schema Definitions ==========
|
||||
@@ -224,36 +223,35 @@ export type Extruder2NamespaceStore = {
|
||||
totalEnergyKWh: TimeSeries;
|
||||
};
|
||||
|
||||
const { initialTimeSeries: pressure, insert: addPressure } =
|
||||
createPersistentTimeSeries("extruder2", "pressure");
|
||||
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries();
|
||||
const { initialTimeSeries: backTemperature, insert: addBackTemperature } =
|
||||
createPersistentTimeSeries("extruder2", "backTemperature");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: frontTemperature, insert: addFrontTemperature } =
|
||||
createPersistentTimeSeries("extruder2", "frontTemperature");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: middleTemperature, insert: addMiddleTemperature } =
|
||||
createPersistentTimeSeries("extruder2", "middleTemperature");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: nozzleTemperature, insert: addNozzleTemperature } =
|
||||
createPersistentTimeSeries("extruder2", "nozzleTemperature");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: nozzlePower, insert: addNozzlePower } =
|
||||
createPersistentTimeSeries("extruder2", "nozzlePower");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: frontPower, insert: addFrontPower } =
|
||||
createPersistentTimeSeries("extruder2", "frontPower");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: middlePower, insert: addMiddlePower } =
|
||||
createPersistentTimeSeries("extruder2", "middlePower");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: backPower, insert: addBackPower } =
|
||||
createPersistentTimeSeries("extruder2", "backPower");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: combinedPower, insert: addCombinedPower } =
|
||||
createPersistentTimeSeries("extruder2", "combinedPower");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: totalEnergyKWh, insert: addTotalEnergyKWh } =
|
||||
createPersistentTimeSeries("extruder2", "totalEnergyKWh");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorCurrent, insert: addMotorCurrent } =
|
||||
createPersistentTimeSeries("extruder2", "motorCurrent");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorFrequency, insert: addMotorFrequency } =
|
||||
createPersistentTimeSeries("extruder2", "motorFrequency");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorScrewRpm, insert: addMotorScrewRpm } =
|
||||
createPersistentTimeSeries("extruder2", "motorScrewRpm");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorPower, insert: addMotorPower } =
|
||||
createPersistentTimeSeries("extruder2", "motorPower");
|
||||
createTimeSeries();
|
||||
|
||||
export function extruder2MessageHandler(
|
||||
store: StoreApi<Extruder2NamespaceStore>,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
PresetPreviewEntries,
|
||||
previewSeparator,
|
||||
} from "@/components/preset/PresetPreviewTable";
|
||||
import { Preset, PresetData } from "@/lib/preset/preset";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
|
||||
const extruder3PresetDataSchema = z
|
||||
.object({
|
||||
@@ -31,7 +31,7 @@ const extruder3PresetDataSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
type Extruder3 = typeof extruder3PresetDataSchema;
|
||||
type Extruder3 = z.infer<typeof extruder3PresetDataSchema>;
|
||||
|
||||
const schemas = new Map([[1, extruder3PresetDataSchema]]);
|
||||
|
||||
@@ -39,76 +39,69 @@ const previewEntries: PresetPreviewEntries<Extruder3> = [
|
||||
{
|
||||
name: "Target Front Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
renderValue: (data: Extruder3) =>
|
||||
data.targetFrontHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Target Middle Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
renderValue: (data: Extruder3) =>
|
||||
data.targetMiddleHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Target Back Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
renderValue: (data: Extruder3) =>
|
||||
data.targetBackHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Target Nozzle Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
renderValue: (data: Extruder3) =>
|
||||
data.targetNozzleHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "Inverter Regulation",
|
||||
renderValue: (data: PresetData<Extruder3>) => data.inverterRegulation,
|
||||
renderValue: (data: Extruder3) => data.inverterRegulation,
|
||||
},
|
||||
{
|
||||
name: "Target Inverter RPM",
|
||||
unit: "rpm",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.targetInverterRpm?.toFixed(2),
|
||||
renderValue: (data: Extruder3) => data.targetInverterRpm?.toFixed(2),
|
||||
},
|
||||
{
|
||||
name: "Target Inverter Pressure",
|
||||
unit: "bar",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.targetInverterPressure?.toFixed(1),
|
||||
renderValue: (data: Extruder3) => data.targetInverterPressure?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Inverter Direction",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.inverterRotationDirection,
|
||||
renderValue: (data: Extruder3) => data.inverterRotationDirection,
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "Pressure Limit",
|
||||
unit: "bar",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.pressureLimit?.toFixed(1),
|
||||
renderValue: (data: Extruder3) => data.pressureLimit?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Enable Pressure Limit",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
renderValue: (data: Extruder3) =>
|
||||
data.pressureLimitEnabled ? "on" : "off",
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "PID Pressue Kp",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.pidPressureKp?.toFixed(4),
|
||||
renderValue: (data: Extruder3) => data.pidPressureKp?.toFixed(4),
|
||||
},
|
||||
{
|
||||
name: "PID Pressue Ki",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.pidPressureKi?.toFixed(4),
|
||||
renderValue: (data: Extruder3) => data.pidPressureKi?.toFixed(4),
|
||||
},
|
||||
{
|
||||
name: "PID Pressue Kd",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.pidPressureKd?.toFixed(4),
|
||||
renderValue: (data: Extruder3) => data.pidPressureKd?.toFixed(4),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -135,7 +128,7 @@ export function Extruder3PresetsPage() {
|
||||
setPressurePidKp,
|
||||
} = useExtruder3();
|
||||
|
||||
const toPresetData = (s?: typeof state): PresetData<Extruder3> => ({
|
||||
const toPresetData = (s?: typeof state): Extruder3 => ({
|
||||
targetFrontHeatingTemperature: s?.heating_states.front.target_temperature,
|
||||
targetMiddleHeatingTemperature: s?.heating_states.middle.target_temperature,
|
||||
targetBackHeatingTemperature: s?.heating_states.back.target_temperature,
|
||||
@@ -161,32 +154,32 @@ export function Extruder3PresetsPage() {
|
||||
|
||||
const applyPreset = (preset: Preset<Extruder3>) => {
|
||||
setFrontHeatingTemperature(
|
||||
preset.data.targetFrontHeatingTemperature ?? 150.0,
|
||||
preset?.data?.targetFrontHeatingTemperature ?? 150.0,
|
||||
);
|
||||
setMiddleHeatingTemperature(
|
||||
preset.data.targetMiddleHeatingTemperature ?? 150.0,
|
||||
preset?.data?.targetMiddleHeatingTemperature ?? 150.0,
|
||||
);
|
||||
setBackHeatingTemperature(
|
||||
preset.data.targetBackHeatingTemperature ?? 150.0,
|
||||
preset?.data?.targetBackHeatingTemperature ?? 150.0,
|
||||
);
|
||||
setNozzleHeatingTemperature(
|
||||
preset.data.targetNozzleHeatingTemperature ?? 150.0,
|
||||
preset?.data?.targetNozzleHeatingTemperature ?? 150.0,
|
||||
);
|
||||
|
||||
setInverterTargetRpm(preset.data.targetInverterRpm ?? 0);
|
||||
setInverterTargetPressure(preset.data.targetInverterPressure ?? 0);
|
||||
setInverterRegulation(preset.data.inverterRegulation === "RPM");
|
||||
setInverterTargetRpm(preset?.data?.targetInverterRpm ?? 0);
|
||||
setInverterTargetPressure(preset?.data?.targetInverterPressure ?? 0);
|
||||
setInverterRegulation(preset?.data?.inverterRegulation === "RPM");
|
||||
|
||||
setInverterRotationDirection(
|
||||
preset.data.inverterRotationDirection === "Forward",
|
||||
preset?.data?.inverterRotationDirection === "Forward",
|
||||
);
|
||||
|
||||
setExtruderPressureLimit(preset.data.pressureLimit ?? 100.0);
|
||||
setExtruderPressureLimitEnabled(preset.data.pressureLimitEnabled ?? true);
|
||||
setExtruderPressureLimit(preset?.data?.pressureLimit ?? 100.0);
|
||||
setExtruderPressureLimitEnabled(preset?.data?.pressureLimitEnabled ?? true);
|
||||
|
||||
setPressurePidKp(preset.data.pidPressureKp ?? 0.16);
|
||||
setPressurePidKi(preset.data.pidPressureKi ?? 0.0);
|
||||
setPressurePidKd(preset.data.pidPressureKd ?? 0.008);
|
||||
setPressurePidKp(preset?.data?.pidPressureKp ?? 0.16);
|
||||
setPressurePidKi(preset?.data?.pidPressureKi ?? 0.0);
|
||||
setPressurePidKd(preset?.data?.pidPressureKd ?? 0.008);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
handleUnhandledEventError,
|
||||
} from "../../../client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
import { createPersistentTimeSeries } from "@/lib/timeseriesPersistent";
|
||||
import { createTimeSeries, TimeSeries } from "@/lib/timeseries";
|
||||
import { useMemo } from "react";
|
||||
|
||||
// ========== Event Schema Definitions ==========
|
||||
@@ -224,36 +223,35 @@ export type Extruder3NamespaceStore = {
|
||||
totalEnergyKWh: TimeSeries;
|
||||
};
|
||||
|
||||
const { initialTimeSeries: pressure, insert: addPressure } =
|
||||
createPersistentTimeSeries("extruder3", "pressure");
|
||||
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries();
|
||||
const { initialTimeSeries: backTemperature, insert: addBackTemperature } =
|
||||
createPersistentTimeSeries("extruder3", "backTemperature");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: frontTemperature, insert: addFrontTemperature } =
|
||||
createPersistentTimeSeries("extruder3", "frontTemperature");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: middleTemperature, insert: addMiddleTemperature } =
|
||||
createPersistentTimeSeries("extruder3", "middleTemperature");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: nozzleTemperature, insert: addNozzleTemperature } =
|
||||
createPersistentTimeSeries("extruder3", "nozzleTemperature");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: nozzlePower, insert: addNozzlePower } =
|
||||
createPersistentTimeSeries("extruder3", "nozzlePower");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: frontPower, insert: addFrontPower } =
|
||||
createPersistentTimeSeries("extruder3", "frontPower");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: middlePower, insert: addMiddlePower } =
|
||||
createPersistentTimeSeries("extruder3", "middlePower");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: backPower, insert: addBackPower } =
|
||||
createPersistentTimeSeries("extruder3", "backPower");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: combinedPower, insert: addCombinedPower } =
|
||||
createPersistentTimeSeries("extruder3", "combinedPower");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: totalEnergyKWh, insert: addTotalEnergyKWh } =
|
||||
createPersistentTimeSeries("extruder3", "totalEnergyKWh");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorCurrent, insert: addMotorCurrent } =
|
||||
createPersistentTimeSeries("extruder3", "motorCurrent");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorFrequency, insert: addMotorFrequency } =
|
||||
createPersistentTimeSeries("extruder3", "motorFrequency");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorScrewRpm, insert: addMotorScrewRpm } =
|
||||
createPersistentTimeSeries("extruder3", "motorScrewRpm");
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorPower, insert: addMotorPower } =
|
||||
createPersistentTimeSeries("extruder3", "motorPower");
|
||||
createTimeSeries();
|
||||
|
||||
export function extruder3MessageHandler(
|
||||
store: StoreApi<Extruder3NamespaceStore>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
import { useLaser1 } from "./useLaser1";
|
||||
import { Preset, PresetData } from "@/lib/preset/preset";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
import { laser1 } from "@/machines/properties";
|
||||
import { PresetPreviewEntries } from "@/components/preset/PresetPreviewTable";
|
||||
import { PresetsPage } from "@/components/preset/PresetsPage";
|
||||
@@ -14,7 +14,7 @@ const laser1PresetDataSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
type Laser1 = typeof laser1PresetDataSchema;
|
||||
type Laser1 = z.infer<typeof laser1PresetDataSchema>;
|
||||
|
||||
const schemas = new Map([[1, laser1PresetDataSchema]]);
|
||||
|
||||
@@ -22,18 +22,17 @@ const previewEntries: PresetPreviewEntries<Laser1> = [
|
||||
{
|
||||
name: "Target Diameter",
|
||||
unit: "mm",
|
||||
renderValue: (data: PresetData<Laser1>) => data?.targetDiameter?.toFixed(3),
|
||||
renderValue: (data: Laser1) => data?.targetDiameter?.toFixed(3),
|
||||
},
|
||||
{
|
||||
name: "Lower Tolerance",
|
||||
unit: "mm",
|
||||
renderValue: (data: PresetData<Laser1>) => data?.lowerTolerance?.toFixed(3),
|
||||
renderValue: (data: Laser1) => data?.lowerTolerance?.toFixed(3),
|
||||
},
|
||||
{
|
||||
name: "Higher Tolerance",
|
||||
unit: "mm",
|
||||
renderValue: (data: PresetData<Laser1>) =>
|
||||
data?.higherTolerance?.toFixed(3),
|
||||
renderValue: (data: Laser1) => data?.higherTolerance?.toFixed(3),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -47,16 +46,16 @@ export function Laser1PresetsPage() {
|
||||
} = useLaser1();
|
||||
|
||||
const applyPreset = (preset: Preset<Laser1>) => {
|
||||
const targetDiameter = preset.data.targetDiameter ?? 1.75;
|
||||
const lowerTolerance = preset.data?.lowerTolerance ?? 0.05;
|
||||
const higherTolerance = preset.data?.higherTolerance ?? 0.05;
|
||||
const targetDiameter = preset?.data?.targetDiameter ?? 1.75;
|
||||
const lowerTolerance = preset?.data?.lowerTolerance ?? 0.05;
|
||||
const higherTolerance = preset?.data?.higherTolerance ?? 0.05;
|
||||
|
||||
setTargetDiameter(targetDiameter);
|
||||
setLowerTolerance(lowerTolerance);
|
||||
setHigherTolerance(higherTolerance);
|
||||
};
|
||||
|
||||
const toPresetData = (s: typeof state): PresetData<Laser1> => ({
|
||||
const toPresetData = (s: typeof state): Laser1 => ({
|
||||
targetDiameter: s?.laser_state.target_diameter,
|
||||
lowerTolerance: s?.laser_state.lower_tolerance,
|
||||
higherTolerance: s?.laser_state.higher_tolerance,
|
||||
|
||||
@@ -14,13 +14,13 @@ import {
|
||||
NamespaceId,
|
||||
createNamespaceHookImplementation,
|
||||
ThrottledStoreUpdater,
|
||||
} from "../../../client/socketioStore";
|
||||
} from "@/client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import {
|
||||
createTimeSeries,
|
||||
TimeSeries,
|
||||
TimeSeriesValue,
|
||||
} from "@/lib/timeseries";
|
||||
import { createPersistentTimeSeries } from "@/lib/timeseriesPersistent";
|
||||
|
||||
// ========== Event Schema Definitions ==========
|
||||
/**
|
||||
@@ -66,25 +66,13 @@ export type Laser1NamespaceStore = {
|
||||
roundness: TimeSeries;
|
||||
};
|
||||
|
||||
// Constants for time durations
|
||||
const HALF_SECOND = 500;
|
||||
|
||||
const { initialTimeSeries: diameter, insert: addDiameter } =
|
||||
createPersistentTimeSeries("laser1", "diameter", {
|
||||
sampleIntervalLong: HALF_SECOND,
|
||||
});
|
||||
const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries();
|
||||
const { initialTimeSeries: x_diameter, insert: addXDiameter } =
|
||||
createPersistentTimeSeries("laser1", "x_diameter", {
|
||||
sampleIntervalLong: HALF_SECOND
|
||||
});
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: y_diameter, insert: addYDiameter } =
|
||||
createPersistentTimeSeries("laser1", "y_diameter", {
|
||||
sampleIntervalLong: HALF_SECOND
|
||||
});
|
||||
const { initialTimeSeries: roundness, insert: addRoundness } =
|
||||
createPersistentTimeSeries("laser1", "roundness", {
|
||||
sampleIntervalLong: HALF_SECOND,
|
||||
});
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: roundness, insert: addRoundness } =
|
||||
createTimeSeries();
|
||||
|
||||
/**
|
||||
* Factory function to create a new Laser1 namespace store
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMock1 } from "./useMock";
|
||||
import { mock1 } from "@/machines/properties";
|
||||
|
||||
import { PresetsPage } from "@/components/preset/PresetsPage";
|
||||
import { Preset, PresetData } from "@/lib/preset/preset";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
import { PresetPreviewEntries } from "@/components/preset/PresetPreviewTable";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -15,7 +15,7 @@ const mock1PresetDataSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
type Mock1 = typeof mock1PresetDataSchema;
|
||||
type Mock1 = z.infer<typeof mock1PresetDataSchema>;
|
||||
|
||||
const schemas = new Map([[1, mock1PresetDataSchema]]);
|
||||
|
||||
@@ -23,17 +23,17 @@ const previewEntries: PresetPreviewEntries<Mock1> = [
|
||||
{
|
||||
name: "Frequency 1",
|
||||
unit: "mHz",
|
||||
renderValue: (data: PresetData<Mock1>) => data?.frequency1?.toFixed(3),
|
||||
renderValue: (data: Mock1) => data?.frequency1?.toFixed(3),
|
||||
},
|
||||
{
|
||||
name: "Frequency 2",
|
||||
unit: "mHz",
|
||||
renderValue: (data: PresetData<Mock1>) => data?.frequency2?.toFixed(3),
|
||||
renderValue: (data: Mock1) => data?.frequency2?.toFixed(3),
|
||||
},
|
||||
{
|
||||
name: "Frequency 3",
|
||||
unit: "mHz",
|
||||
renderValue: (data: PresetData<Mock1>) => data?.frequency3?.toFixed(3),
|
||||
renderValue: (data: Mock1) => data?.frequency3?.toFixed(3),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -51,7 +51,7 @@ export function Mock1PresetsPage() {
|
||||
setFrequency3(frequency3);
|
||||
};
|
||||
|
||||
const toPresetData = (s: typeof state): PresetData<Mock1> => ({
|
||||
const toPresetData = (s: typeof state): Mock1 => ({
|
||||
frequency1: s?.frequency1 ?? defaultState?.frequency1,
|
||||
frequency2: s?.frequency2 ?? defaultState?.frequency2,
|
||||
frequency3: s?.frequency3 ?? defaultState?.frequency3,
|
||||
|
||||
@@ -16,13 +16,11 @@ import {
|
||||
ThrottledStoreUpdater,
|
||||
} from "../../../client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { useMemo } from "react";
|
||||
import { TimeSeries, TimeSeriesValue } from "@/lib/timeseries";
|
||||
import {
|
||||
createNamespacePersistentTimeSeries,
|
||||
NamespaceSeriesResult,
|
||||
} from "@/lib/namespacePersistence";
|
||||
import { serializeNamespaceId } from "../../../client/socketioStore";
|
||||
createTimeSeries,
|
||||
TimeSeries,
|
||||
TimeSeriesValue,
|
||||
} from "@/lib/timeseries";
|
||||
|
||||
// ========== Event Schema Definitions ==========
|
||||
/**
|
||||
@@ -80,83 +78,35 @@ export type Mock1NamespaceStore = {
|
||||
sineWave3: TimeSeries;
|
||||
};
|
||||
|
||||
// Store-level cache for persistent series
|
||||
const seriesCache = new Map<
|
||||
string,
|
||||
NamespaceSeriesResult<{
|
||||
sineWaveSum: string;
|
||||
sineWave1: string;
|
||||
sineWave2: string;
|
||||
sineWave3: string;
|
||||
}>
|
||||
>();
|
||||
const { initialTimeSeries: sineWave, insert: addSineWave } = createTimeSeries();
|
||||
|
||||
/**
|
||||
* Factory function to create a new Mock1 namespace store
|
||||
* @param namespaceId The namespace identifier for persistence
|
||||
* @returns A new Zustand store instance for Mock1 namespace
|
||||
*/
|
||||
export const createMock1NamespaceStore = (
|
||||
namespaceId: NamespaceId,
|
||||
): StoreApi<Mock1NamespaceStore> => {
|
||||
const cacheKey = serializeNamespaceId(namespaceId);
|
||||
|
||||
if (!seriesCache.has(cacheKey)) {
|
||||
const seriesResult = createNamespacePersistentTimeSeries(namespaceId, {
|
||||
sineWaveSum: "sineWaveSum",
|
||||
sineWave1: "sineWave1",
|
||||
sineWave2: "sineWave2",
|
||||
sineWave3: "sineWave3",
|
||||
});
|
||||
seriesCache.set(cacheKey, seriesResult);
|
||||
}
|
||||
|
||||
const seriesResult = seriesCache.get(cacheKey)!;
|
||||
|
||||
const store = create<Mock1NamespaceStore>(() => ({
|
||||
state: null,
|
||||
defaultState: null,
|
||||
sineWaveSum: seriesResult.initialState.sineWaveSum,
|
||||
sineWave1: seriesResult.initialState.sineWave1,
|
||||
sineWave2: seriesResult.initialState.sineWave2,
|
||||
sineWave3: seriesResult.initialState.sineWave3,
|
||||
}));
|
||||
|
||||
// With lazy loading, onHistoryLoaded fires when historical data is ready
|
||||
// Live data starts flowing immediately, then history merges in background
|
||||
seriesResult.onHistoryLoaded((historicalSeries) => {
|
||||
store.setState(historicalSeries);
|
||||
export const createMock1NamespaceStore = (): StoreApi<Mock1NamespaceStore> => {
|
||||
return create<Mock1NamespaceStore>(() => {
|
||||
return {
|
||||
state: null,
|
||||
defaultState: null,
|
||||
sineWaveSum: sineWave,
|
||||
sineWave1: sineWave,
|
||||
sineWave2: sineWave,
|
||||
sineWave3: sineWave,
|
||||
};
|
||||
});
|
||||
|
||||
return store;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get insert functions for a namespace
|
||||
*/
|
||||
function getInsertFunctions(namespaceId: NamespaceId) {
|
||||
const cacheKey = serializeNamespaceId(namespaceId);
|
||||
const seriesResult = seriesCache.get(cacheKey);
|
||||
if (!seriesResult) {
|
||||
throw new Error(`Series not initialized for namespace: ${cacheKey}`);
|
||||
}
|
||||
return seriesResult.insertFns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message handler for Mock1 namespace events with validation and appropriate caching strategies
|
||||
* @param namespaceId The namespace identifier
|
||||
* @param store The store to update when messages are received
|
||||
* @param throttledUpdater Throttled updater for batching updates at 30 FPS
|
||||
* @returns A message handler function
|
||||
*/
|
||||
export function mock1MessageHandler(
|
||||
namespaceId: NamespaceId,
|
||||
store: StoreApi<Mock1NamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<Mock1NamespaceStore>,
|
||||
): EventHandler {
|
||||
const insertFns = getInsertFunctions(namespaceId);
|
||||
|
||||
return (event: Event<any>) => {
|
||||
const eventName = event.name;
|
||||
|
||||
@@ -202,10 +152,10 @@ export function mock1MessageHandler(
|
||||
};
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
sineWaveSum: insertFns.sineWaveSum(state.sineWaveSum, waveSumValue),
|
||||
sineWave1: insertFns.sineWave1(state.sineWave1, wave1Value),
|
||||
sineWave2: insertFns.sineWave2(state.sineWave2, wave2Value),
|
||||
sineWave3: insertFns.sineWave3(state.sineWave3, wave3Value),
|
||||
sineWaveSum: addSineWave(state.sineWaveSum, waveSumValue),
|
||||
sineWave1: addSineWave(state.sineWave1, wave1Value),
|
||||
sineWave2: addSineWave(state.sineWave2, wave2Value),
|
||||
sineWave3: addSineWave(state.sineWave3, wave3Value),
|
||||
}));
|
||||
} else {
|
||||
handleUnhandledEventError(eventName);
|
||||
@@ -218,26 +168,23 @@ export function mock1MessageHandler(
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use Mock1 namespace with persistent timeseries
|
||||
* Create the Mock1 namespace implementation
|
||||
*/
|
||||
const useMock1NamespaceImplementation =
|
||||
createNamespaceHookImplementation<Mock1NamespaceStore>({
|
||||
createStore: createMock1NamespaceStore,
|
||||
createEventHandler: mock1MessageHandler,
|
||||
});
|
||||
|
||||
export function useMock1Namespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): Mock1NamespaceStore {
|
||||
const namespaceId = useMemo<NamespaceId>(
|
||||
() => ({
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
}),
|
||||
[machine_identification_unique],
|
||||
);
|
||||
// Generate namespace ID from validated machine ID
|
||||
const namespaceId: NamespaceId = {
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
};
|
||||
|
||||
const useImpl = useMemo(() => {
|
||||
return createNamespaceHookImplementation<Mock1NamespaceStore>({
|
||||
createStore: () => createMock1NamespaceStore(namespaceId),
|
||||
createEventHandler: (store, throttledUpdater) =>
|
||||
mock1MessageHandler(namespaceId, store, throttledUpdater),
|
||||
});
|
||||
}, [namespaceId]);
|
||||
|
||||
return useImpl(namespaceId);
|
||||
// Use the implementation with validated namespace ID
|
||||
return useMock1NamespaceImplementation(namespaceId);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -462,6 +467,41 @@ export const testmachine: MachineProperties = {
|
||||
},
|
||||
],
|
||||
};
|
||||
export const digitalInputTestMachine: MachineProperties = {
|
||||
name: "Digital Input Machine",
|
||||
version: "V1",
|
||||
slug: "digitalInputTestMachine",
|
||||
icon: "lu:Disc3",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x0040,
|
||||
},
|
||||
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",
|
||||
@@ -533,6 +573,88 @@ 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",
|
||||
slug: "wagoaitestmachine",
|
||||
icon: "lu:Activity",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x0036,
|
||||
},
|
||||
device_roles: [
|
||||
{
|
||||
role: 0,
|
||||
role_label: "Wago 750-354 Bus Coupler",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 0x21,
|
||||
product_id: 0x07500354,
|
||||
revision: 0x2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const machineProperties: MachineProperties[] = [
|
||||
winder2,
|
||||
extruder3,
|
||||
@@ -544,7 +666,12 @@ export const machineProperties: MachineProperties[] = [
|
||||
aquapath1,
|
||||
testmachine,
|
||||
analogInputTestMachine,
|
||||
wagoAiTestMachine,
|
||||
digitalInputTestMachine,
|
||||
wago8chDioTestMachine,
|
||||
ip20TestMachine,
|
||||
wagoDoTestMachine,
|
||||
TestMotor,
|
||||
];
|
||||
|
||||
export const getMachineProperties = (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// each role can only be given once
|
||||
|
||||
import { IconName } from "@/components/Icon";
|
||||
import { rustEnumSchema } from "@/lib/types";
|
||||
import { rustEnum } from "@/lib/types";
|
||||
import { z } from "zod";
|
||||
|
||||
// EK1100 should have role 0
|
||||
@@ -62,9 +62,11 @@ export type DeviceHardwareIdentificationEthercat = z.infer<
|
||||
typeof deviceHardwareIdentificationEthercatSchema
|
||||
>;
|
||||
|
||||
export const deviceHardwareIdentificationSchema = rustEnumSchema({
|
||||
Ethercat: deviceHardwareIdentificationEthercatSchema,
|
||||
});
|
||||
export const deviceHardwareIdentificationSchema = z
|
||||
.object({
|
||||
Ethercat: deviceHardwareIdentificationEthercatSchema,
|
||||
})
|
||||
.check(rustEnum);
|
||||
|
||||
export type DeviceHardwareIdentification = z.infer<
|
||||
typeof deviceHardwareIdentificationSchema
|
||||
|
||||