Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2b20b148 | ||
|
|
12e7f245ce | ||
|
|
671e36fa0f | ||
|
|
1ffd7eade6 | ||
|
|
703bbfc9e6 | ||
|
|
057afb0b2e | ||
|
|
94bd29c734 | ||
|
|
d078b7b143 | ||
|
|
acbeb98f4f | ||
|
|
cab8bf062d | ||
|
|
9e04a7fa33 | ||
|
|
c92d4847ca | ||
|
|
aceeaa08c2 | ||
|
|
35946d0c88 | ||
|
|
a5b9a0b1d5 | ||
|
|
412d625138 | ||
|
|
1ec20074e9 | ||
|
|
4d796f8422 | ||
|
|
1406d6c724 | ||
|
|
33441b95a5 | ||
|
|
8a3f312675 | ||
|
|
5c327428d6 | ||
|
|
4e4d31cad1 | ||
|
|
031fdf25a9 | ||
|
|
857cda1455 | ||
|
|
de5f3a5cfc | ||
|
|
2da7af522c | ||
|
|
712284e2f0 | ||
|
|
402c47a81b | ||
|
|
54a000a280 | ||
|
|
41fe118144 | ||
|
|
aaa6ff604f | ||
|
|
a350bb378f | ||
|
|
aa2212820e | ||
|
|
578e70119d | ||
|
|
e8796e00af | ||
|
|
735f27770a | ||
|
|
700a255157 | ||
|
|
ec06275c08 | ||
|
|
11d0c8bb53 | ||
|
|
535f24f1d1 | ||
|
|
172913d8ec | ||
|
|
0f99c54587 | ||
|
|
f89d70c5f6 | ||
|
|
0fc070be69 | ||
|
|
b93eaf530e | ||
|
|
76d8504651 | ||
|
|
06acd43ff9 | ||
|
|
870090c950 | ||
|
|
65777f10ab | ||
|
|
67d145de56 | ||
|
|
d45d1ed01a | ||
|
|
0d6c7bc0a5 | ||
|
|
15f013f710 | ||
|
|
a32e26a041 | ||
|
|
41c7e1f74e | ||
|
|
5a856e6e2b | ||
|
|
8ca16d447e | ||
|
|
b99549895b | ||
|
|
07cbaa4518 | ||
|
|
c78f5ff5a0 | ||
|
|
33946a6fa3 | ||
|
|
02dc67bb85 | ||
|
|
b5ae86b19c | ||
|
|
35567b967f | ||
|
|
c45718e670 | ||
|
|
74dcd027e0 | ||
|
|
c793d3fa50 | ||
|
|
04fdc8a9d1 | ||
|
|
fd702ff0bc | ||
|
|
f25bf71753 | ||
|
|
4c33873095 | ||
|
|
8dbcb5efc3 | ||
|
|
36426aafe0 | ||
|
|
21cf9314be | ||
|
|
4f2af7b90d | ||
|
|
06795dcf78 | ||
|
|
1241d0a4a7 | ||
|
|
f36723f435 | ||
|
|
e96854a6bc | ||
|
|
04c5a673da | ||
|
|
40e17ef5f1 | ||
|
|
39200aac12 | ||
|
|
43a405eec4 | ||
|
|
0206a4ddca | ||
|
|
34da7178f8 | ||
|
|
2726c47280 | ||
|
|
63cf25b3d6 | ||
|
|
eae13207e4 | ||
|
|
f65d100cd5 | ||
|
|
13e4ea1ac7 | ||
|
|
49788246c8 | ||
|
|
d4e849d588 | ||
|
|
2689d19928 | ||
|
|
968e9327f6 | ||
|
|
3cbb74b3b0 | ||
|
|
50fcfd6e30 | ||
|
|
ce0f27787c | ||
|
|
08ab7fd404 | ||
|
|
f138b61a45 | ||
|
|
0ad0a58d46 | ||
|
|
3d13e6cee2 | ||
|
|
86915c2987 | ||
|
|
ada8d0f12c | ||
|
|
735456b6eb | ||
|
|
b46a528adb | ||
|
|
80f2343a60 | ||
|
|
e030fbe23e | ||
|
|
1ef0cb608c | ||
|
|
789a84e511 | ||
|
|
28620b6a23 | ||
|
|
01609513fb | ||
|
|
379a4ec417 | ||
|
|
28d898e780 | ||
|
|
daabb170f5 | ||
|
|
c4803e63dd | ||
|
|
0022d86035 | ||
|
|
dc711a820f | ||
|
|
59cf2cea2a | ||
|
|
da69ad1c3e | ||
|
|
6ff434fb47 | ||
|
|
a63c57a0b9 | ||
|
|
21c43adcb4 | ||
|
|
afa23666e2 | ||
|
|
245ad64426 | ||
|
|
9665971150 | ||
|
|
3b056c8f26 | ||
|
|
62b35e0237 | ||
|
|
11bc62f40e | ||
|
|
116c76d218 | ||
|
|
4dcedb537d | ||
|
|
09022c9c86 | ||
|
|
80cf98b11d | ||
|
|
644e750353 | ||
|
|
aecabfde43 | ||
|
|
686678a1fe | ||
|
|
7ac0b788c2 | ||
|
|
3791ee785d | ||
|
|
7341dee976 | ||
|
|
b8168c6b43 | ||
|
|
6026d2a658 | ||
|
|
a301f3e228 | ||
|
|
0b6bd68a24 | ||
|
|
e7fd390263 | ||
|
|
eab675795a | ||
|
|
8c2dc9a507 | ||
|
|
f0fd9e8ce6 | ||
|
|
c5579a4471 | ||
|
|
de7a65bf12 |
No files matched your search
@@ -3,50 +3,73 @@ name: Electron
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
- master
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: electron
|
||||
|
||||
jobs:
|
||||
build-electron:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: electron
|
||||
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
- &checkout
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- &setup-node
|
||||
name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18" # or '20' for latest LTS
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
electron/package-lock.json
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- &install
|
||||
name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
node_modules/
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install
|
||||
run: npm i
|
||||
|
||||
- name: Build
|
||||
- name: Compile
|
||||
run: tsc
|
||||
|
||||
- name: Format
|
||||
run: npm run format
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
|
||||
steps:
|
||||
- *checkout
|
||||
- *setup-node
|
||||
- *install
|
||||
|
||||
- name: Test
|
||||
run: npm run test
|
||||
|
||||
- name: Package
|
||||
run: npm run package
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- *checkout
|
||||
- *setup-node
|
||||
- *install
|
||||
|
||||
- name: Format
|
||||
run: npm run format
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- *checkout
|
||||
- *setup-node
|
||||
- *install
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
@@ -0,0 +1,96 @@
|
||||
name: Nix CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
NIX_PATH: nixpkgs=channel:nixos-25.05
|
||||
|
||||
jobs:
|
||||
electron:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- &checkout
|
||||
name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- &nix-install
|
||||
uses: nixbuild/nix-quick-install-action@v34
|
||||
with:
|
||||
nix_conf: |
|
||||
keep-env-derivations = true
|
||||
keep-outputs = true
|
||||
|
||||
- uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-electron-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock', './electron/package-lock.json') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-
|
||||
purge: true
|
||||
purge-prefixes: nix-electron-${{ runner.os }}-
|
||||
|
||||
- name: Build Electron in Nix
|
||||
run: nix build ".#packages.x86_64-linux.electron"
|
||||
|
||||
rust:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- *checkout
|
||||
- *nix-install
|
||||
|
||||
- uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-server-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock', './Cargo.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-
|
||||
purge: true
|
||||
purge-prefixes: nix-server-${{ runner.os }}-
|
||||
|
||||
- name: Build Server in Nix
|
||||
run: nix build ".#packages.x86_64-linux.server"
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [electron, rust]
|
||||
|
||||
steps:
|
||||
- *checkout
|
||||
- *nix-install
|
||||
|
||||
- name: Use both Electron and Server Nix Store
|
||||
uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock', './Cargo.lock', './electron/package-lock.json') }}
|
||||
|
||||
# if no hit on the primary key, merge individual caches
|
||||
restore-prefixes-all-matches: |
|
||||
nix-electron-${{ runner.os }}-
|
||||
nix-server-${{ runner.os }}-
|
||||
|
||||
- name: Build Flake and System Configuration
|
||||
run: nix build ".#nixosConfigurations.nixos.config.system.build.toplevel" --impure
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- *checkout
|
||||
- *nix-install
|
||||
|
||||
- name: Format
|
||||
run: nix run 'nixpkgs#nixfmt-classic' $(find . nixos -name '*.nix')
|
||||
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- *checkout
|
||||
- *nix-install
|
||||
|
||||
- name: Build Flake and Configuration.nix
|
||||
run: nix flake check "." --impure
|
||||
@@ -3,9 +3,10 @@ name: Rust
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
- master
|
||||
pull_request:
|
||||
branches: "*"
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -16,14 +17,24 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Install libudev development package
|
||||
- &udev-install
|
||||
name: Install libudev development package
|
||||
run: sudo apt-get update && sudo apt-get install -y libudev-dev
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set Rust version
|
||||
run: rustup override set ${{ env.RUST_VERSION }}
|
||||
- name: Install Formatter
|
||||
run: rustup component add rustfmt
|
||||
- name: Cache Rust dependencies
|
||||
|
||||
- &checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- &setup-rust
|
||||
name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
profile: minimal
|
||||
override: true
|
||||
components: rustfmt
|
||||
|
||||
- &cargo-cache
|
||||
name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
@@ -41,8 +52,36 @@ jobs:
|
||||
RUSTFLAGS: "-D warnings"
|
||||
run: cargo build --verbose
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- *checkout
|
||||
- *setup-rust
|
||||
|
||||
- name: Format
|
||||
run: cargo fmt --check --verbose
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- *udev-install
|
||||
- *checkout
|
||||
- *setup-rust
|
||||
- *cargo-cache
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
|
||||
mock:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- *udev-install
|
||||
- *checkout
|
||||
- *setup-rust
|
||||
- *cargo-cache
|
||||
|
||||
- name: Mock Machine
|
||||
run: cargo build --features "development-build,mock-machine"
|
||||
@@ -5,3 +5,6 @@ Cargo.lock
|
||||
dhat-heap.json
|
||||
build.sh
|
||||
shell.nix
|
||||
node_modules/
|
||||
runtime_metrics.csv
|
||||
compile_metrics.csv
|
||||
@@ -1,3 +1,24 @@
|
||||
# `2.13.0`
|
||||
_07.01.2026_
|
||||
|
||||
## General
|
||||
- [#925](https://github.com/qitechgmbh/control/pull/925) Switch to Setup when selected machine is disconnected
|
||||
- [#937](https://github.com/qitechgmbh/control/pull/937) Added minimal Support for WAGO
|
||||
- [#945](https://github.com/qitechgmbh/control/pull/945) Improvements for On-Screen Keyboard usage in Ethercat Setup Page
|
||||
- []
|
||||
|
||||
## Extruder V3
|
||||
- [#930](https://github.com/qitechgmbh/control/pull/930) Added Extruder V3
|
||||
|
||||
## Laser
|
||||
- [#924](https://github.com/qitechgmbh/control/pull/924) Roundness is now displayed as a percentage
|
||||
- [#885](https://github.com/qitechgmbh/control/pull/885) Added Min and Max Diameter
|
||||
- [#849](https://github.com/qitechgmbh/control/pull/849) Added a Warning if Filament is out of Spec
|
||||
|
||||
## Aquapath
|
||||
- [#976](https://github.com/qitechgmbh/control/pull/976) Improved Stability
|
||||
|
||||
|
||||
# `2.12.0`
|
||||
_17.11.2025_
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -221,7 +221,7 @@ checksum = "99e1aca718ea7b89985790c94aad72d77533063fe00bc497bb79a7c2dae6a661"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -291,7 +291,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -539,7 +539,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -682,7 +682,7 @@ dependencies = [
|
||||
"deluxe-macros",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -695,7 +695,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -710,7 +710,7 @@ dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -721,7 +721,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -962,7 +962,7 @@ dependencies = [
|
||||
"deluxe",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1014,7 +1014,7 @@ dependencies = [
|
||||
"criterion",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1908,7 +1908,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2221,7 +2221,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2251,7 +2251,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2323,6 +2323,7 @@ dependencies = [
|
||||
"dhat",
|
||||
"ethercat_hal",
|
||||
"ethercrab",
|
||||
"libc",
|
||||
"machines",
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -2572,9 +2573,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.110"
|
||||
version = "2.0.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
|
||||
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2620,7 +2621,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2680,7 +2681,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2824,9 +2825,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
@@ -2836,20 +2837,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.30"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.34"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
@@ -3073,7 +3074,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -3138,7 +3139,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3149,7 +3150,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3425,5 +3426,5 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
# QiTech Control
|
||||
|
||||
QiTech Control is an industrial control panel software for the next generation of QiTech recycling machines built on top of Beckhoff Automation hardware.
|
||||
QiTech Control is an open-source framework designed to bring modern software paradigms to certified industrial hardware.
|
||||
|
||||
It frees developers from proprietary, license-heavy PLC ecosystems and rigid "point-and-click" workflows that are no longer adequate for today's complex automation challenges.
|
||||
|
||||
QiTech Control combines the modularity and reliability of standard EtherCAT terminals (e.g., WAGO, Beckhoff) with the power of a modern Rust & React stack.
|
||||
|
||||
|
||||
[](https://www.youtube.com/watch?v=KI3YeBwfV-s)
|
||||
*Click here to watch a video demo of our software.*
|
||||
@@ -35,7 +40,7 @@ Other
|
||||
|
||||
## Backend
|
||||
|
||||
To interface with Beckhoff and other EtherCAT devices we need an EtherCAT master stoftware. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
|
||||
To interface with Beckhoff and other EtherCAT devices we need an EtherCAT master software. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
|
||||
|
||||
[SocketIO](https://socket.io/) was chosen for performant event driven communication from the backend to the server. But we still use REST with [axum](https://docs.rs/axum/latest/axum/) for the communication thet benefits from the request/response model.
|
||||
|
||||
@@ -74,9 +79,16 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
|
||||
| Laser | V1 | ??? | Diameter Measuring Laser | - | 1 (Qitech Industries GmbH) | 6 | Yes | |
|
||||
| Mock | - | ??? | Mock Machine for Testing | - | 1 (Qitech Industries GmbH) | 7 | Yes | - |
|
||||
|
||||
# More Docs docs
|
||||
# Minimal Hardware Examples
|
||||
|
||||
/assets
|
||||
For complete step-by-step tutorials on setting up your first hardware, including wiring diagrams and software configuration, see the [Getting Started Guide](./docs/developer-docs/getting-started.md#minimal-hardware-examples).
|
||||
|
||||
Available examples:
|
||||
- **LED Control with EL2004** - Digital output control (simplest setup)
|
||||
- **Analog Input with EL3021** - Reading analog current measurements
|
||||
|
||||
|
||||
# More Docs
|
||||
|
||||
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
|
||||
|
||||
@@ -154,227 +166,3 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
|
||||
- [x] [Ethercat Basics](./docs/ethercat-basics.md)
|
||||
|
||||
- [x] [NixOS Operating System](./docs/nixos/README.md)
|
||||
|
||||
|
||||
## Minimal Example — LED Control on the EL2004
|
||||
A complete hardware + software walkthrough
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
1. [Introduction](#1-introduction)
|
||||
2. [Requirements](#2-requirements)
|
||||
3. [Hardware Setup](#3-hardware-setup)
|
||||
4. [Software Setup](#4-software-setup)
|
||||
5. [Demo](#5-demo)
|
||||
6. [References](#6-references)
|
||||
7. [Acknowledgements](#7-acknowledgements)
|
||||
|
||||
---
|
||||
|
||||
# 1. Introduction
|
||||
|
||||
The EL2004 LED Toggle Example is a minimal demonstration showing how to control digital outputs on a **Beckhoff EL2004 EtherCAT terminal** using the QiTech machine framework.
|
||||
It represents the simplest possible hardware interaction in the system:
|
||||
**toggling LED outputs using the QiTech Control Dashboard.**
|
||||
|
||||
---
|
||||
|
||||
# 2. Requirements
|
||||
|
||||
## Hardware
|
||||
- Beckhoff **EL2004 EtherCAT Terminal** (4-channel digital output)
|
||||
- Beckhoff **EK1100 EtherCAT Coupler**
|
||||
- **24 V DC power supply** (AC/DC adapter + DC hollow plug)
|
||||
- Jumper / bridge wires (0.5–1.5 mm² recommended)
|
||||
- A **Linux PC** (Ubuntu/Debian recommended)
|
||||
- Standard Ethernet cable
|
||||
- Flat screwdriver
|
||||
|
||||
## Software
|
||||
*(Installation steps in Section 4)*
|
||||
- Rust toolchain
|
||||
- Node.js + npm
|
||||
- Git
|
||||
- QiTech Control repository
|
||||
- EtherCAT HAL (included inside repo)
|
||||
|
||||
---
|
||||
|
||||
# 3. Hardware Setup
|
||||
|
||||
## 3.1 Schematic
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 3.2 EK1100 Wiring
|
||||
|
||||
This wiring configuration powers the EL2004 and prepares it for LED control.
|
||||
It is not the only possible wiring but is the **simplest functional setup**.
|
||||
|
||||
### ⚠️ Safety Warning
|
||||
Always disconnect power before wiring.
|
||||
Working on live EtherCAT terminals can cause serious damage or electrical shock.
|
||||
|
||||
---
|
||||
|
||||
## 3.2.1 Safe Wiring Procedure (Beckhoff Recommended)
|
||||
|
||||
1. Insert a screwdriver **straight** into the square release hole.
|
||||
2. Insert the stripped wire into the round opening.
|
||||
3. Remove the screwdriver — the spring clamp locks the wire.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 3.2.2 Wiring Steps (Used in This Example)
|
||||
|
||||
We supply power using a **DC hollow-plug adapter**, like this one:
|
||||
https://www.amazon.de/dp/B093FTFZ8Q
|
||||
|
||||
Perform the following wiring on the EK1100:
|
||||
|
||||
1. Red wire **(+24 V)** → Terminal **2**
|
||||
2. Black wire **(0 V)** → Terminal **3**
|
||||
3. Jumper wire from **Terminal 1 → Terminal 6**
|
||||
4. Jumper wire from **Terminal 5 → Terminal 7**
|
||||
|
||||
After wiring, your module should look like **Figure 1**.
|
||||
|
||||
---
|
||||
|
||||
### **Figure 1 — EK1100 Minimal Wiring**
|
||||
<img src="./docs/assets/ek1100.jpeg" width="400">
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3.3 EL2004 Integration
|
||||
|
||||
Slide the EL2004 onto the **right side of the EK1100** until it locks.
|
||||
The EtherCAT E-Bus and power contacts connect automatically — **no wiring required**.
|
||||
|
||||
### **Figure 2 — EL2004 Terminal**
|
||||
<img src="./docs/assets/el2004.jpeg" width="400">
|
||||
|
||||
---
|
||||
|
||||
## 3.4 Final Assembled Setup
|
||||
|
||||
### **Figure 3 — EK1100 + EL2004 Connected**
|
||||
<img src="./docs/assets/complete.jpeg" width="400">
|
||||
|
||||
---
|
||||
|
||||
## 3.5 Power & Ethernet
|
||||
|
||||
### Power
|
||||
Connect the 24 V adapter to the hollow plug used earlier.
|
||||
**Example AC/DC Adapter (Figure 4):**
|
||||
|
||||
<img src="./docs/assets/adapter.jpeg" width="400">
|
||||
|
||||
|
||||
### Ethernet
|
||||
Use a standard LAN cable to connect your PC → EK1100.
|
||||
The final powered up and connected setup should look like this:
|
||||
|
||||
<img src="./docs/assets/power.jpeg" width="400">
|
||||
|
||||
---
|
||||
|
||||
# 4. Software Setup
|
||||
|
||||
## 4.1 Installing on Ubuntu/Debian
|
||||
|
||||
Paste this into your terminal:
|
||||
|
||||
```bash
|
||||
# Press Enter when prompted
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
sudo apt update
|
||||
sudo apt install -y npm nodejs git
|
||||
|
||||
git clone git@github.com:qitechgmbh/control.git
|
||||
cd control/electron
|
||||
npm install
|
||||
```
|
||||
## 4.2 Running the Backend
|
||||
```bash
|
||||
./cargo_run_linux.sh
|
||||
```
|
||||
This script:
|
||||
|
||||
- Builds the backend
|
||||
|
||||
- Grants required system capabilities (raw sockets)
|
||||
|
||||
- Starts EtherCAT communication
|
||||
|
||||
Ensure the EK1100 is connected.
|
||||
|
||||
## 4.3 Running the Frontend
|
||||
```bash
|
||||
cd electron
|
||||
npm run start
|
||||
```
|
||||
This launches the QiTech Control dashboard.
|
||||
# 5. Demo
|
||||
|
||||
## 5.1 Assigning Devices in the Dashboard
|
||||
|
||||
Once the backend + frontend are running, you should see:
|
||||
|
||||
EK1100 Coupler
|
||||
|
||||
EL2004 Digital Output Terminal
|
||||

|
||||
|
||||
Steps:
|
||||
|
||||
- Click Assign on the EK1100
|
||||
|
||||
- Select TestMachine V1
|
||||
|
||||

|
||||
|
||||
|
||||
- Enter a serial number (use the same for EK1100 + EL2004)
|
||||
|
||||

|
||||
|
||||
- Click Write
|
||||
|
||||
- Repeat for the EL2004
|
||||
|
||||
## 5.2 Testing LED Control
|
||||
Navigate to:
|
||||
|
||||
Machines → TestMachine
|
||||

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

|
||||
|
||||
**You can now toggle the four digital outputs of the EL2004.**
|
||||
# 6. References
|
||||
|
||||
This guide incorporates information from official Beckhoff documentation.
|
||||
All diagrams, product names, and figures belong to Beckhoff Automation GmbH & Co. KG and are used here solely for educational purposes.
|
||||
|
||||
Referenced Manuals
|
||||
|
||||
[Beckhoff EK1100 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/ek110x_ek15xx_en.pdf)
|
||||
|
||||
[Beckhoff EL2004 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/el20xx_el2124_de.pdf)
|
||||
|
||||
# 7. Acknowledgements
|
||||
|
||||
This tutorial is inspired by the clarity and educational quality of Beckhoff manuals.
|
||||
All wiring illustrations and hardware descriptions in this guide are provided for demonstration purposes only and do not replace official Beckhoff installation guidelines.
|
||||
|
||||
Special thanks to the QiTech engineering team for providing the backend architecture, EtherCAT HAL abstraction, and the TestMachine framework that makes this example possible.
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_DIR="${BUILD_DIR:-target}"
|
||||
CSV_FILE="${CSV_FILE:-compile_metrics.csv}"
|
||||
DEFAULT_PKG="server"
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
set -- cargo build
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "cargo" ] && [ "${2:-}" = "build" ]; then
|
||||
cargo clean
|
||||
BUILD_CMD=(cargo rustc -p "$DEFAULT_PKG")
|
||||
shift 2
|
||||
BUILD_CMD+=("$@")
|
||||
BUILD_CMD+=(-- --emit=llvm-ir -C debuginfo=0)
|
||||
else
|
||||
BUILD_CMD=("$@")
|
||||
fi
|
||||
|
||||
compile_start_ns=$(date +%s%N)
|
||||
"${BUILD_CMD[@]}"
|
||||
compile_end_ns=$(date +%s%N)
|
||||
compile_ms=$(( (compile_end_ns - compile_start_ns) / 1000000 ))
|
||||
|
||||
if [ -d "$BUILD_DIR" ]; then
|
||||
llvm_ir_lines=$(
|
||||
find "$BUILD_DIR" -type f -name '*.ll' -print0 \
|
||||
| xargs -0 cat \
|
||||
| wc -l
|
||||
)
|
||||
else
|
||||
llvm_ir_lines=0
|
||||
fi
|
||||
|
||||
bin_path=""
|
||||
|
||||
if [ -n "${BINARY_PATH:-}" ] && [ -f "$BINARY_PATH" ]; then
|
||||
bin_path="$BINARY_PATH"
|
||||
elif [ -f "./target/release/server" ]; then
|
||||
bin_path="./target/release/server"
|
||||
elif [ -f "./target/debug/server" ]; then
|
||||
bin_path="./target/debug/server"
|
||||
elif [ -f "./server/target/release/server" ]; then
|
||||
bin_path="./server/target/release/server"
|
||||
elif [ -f "./server/target/debug/server" ]; then
|
||||
bin_path="./server/target/debug/server"
|
||||
fi
|
||||
|
||||
if [ -n "$bin_path" ]; then
|
||||
binary_size_bytes=$(stat -c '%s' "$bin_path")
|
||||
else
|
||||
binary_size_bytes=0
|
||||
fi
|
||||
|
||||
finished_timestamp_ms=$(($(date +%s%N) / 1000000))
|
||||
finished_datetime=$(date -Iseconds)
|
||||
|
||||
if [ ! -f "$CSV_FILE" ]; then
|
||||
echo "datetime,finished_timestamp_ms,compile_ms,llvm_ir_lines,binary_size_bytes" > "$CSV_FILE"
|
||||
fi
|
||||
|
||||
echo "${finished_datetime},${finished_timestamp_ms},${compile_ms},${llvm_ir_lines},${binary_size_bytes}" \
|
||||
>> "$CSV_FILE"
|
||||
|
||||
echo "Metrics recorded in $CSV_FILE"
|
||||
@@ -21,7 +21,7 @@ smol = "2.0.2"
|
||||
socketioxide = "0.17.2"
|
||||
bitvec = { version = "1.0.1", features = ["alloc"] }
|
||||
crc = "3.3.0"
|
||||
tracing = "0.1.41"
|
||||
tracing = "0.1.44"
|
||||
erased-serde = "0.4.8"
|
||||
core_affinity = "0.8.3"
|
||||
control_core_derive = { version = "0.1.0", path = "../control-core-derive" }
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::Result;
|
||||
use interfaces::Interface;
|
||||
|
||||
pub mod modbus_tcp_discovery;
|
||||
|
||||
/// Returns true if the given network interface is Ethernet.
|
||||
/// Prevents testing wlan,loopback and other non ethernet devices
|
||||
fn is_ethernet(interface: &str) -> std::io::Result<bool> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let base_path = format!("/sys/class/net/{}", interface);
|
||||
let type_path = std::path::Path::new(&base_path).join("type");
|
||||
let iface_type = std::fs::read_to_string(&type_path)?.trim().to_string();
|
||||
// 1 means Ethernet
|
||||
let reports_as_ethernet = iface_type == "1";
|
||||
// Double-check that it's not a wireless interface
|
||||
let uevent_path = std::path::Path::new(&base_path).join("uevent");
|
||||
let uevent = std::fs::read_to_string(&uevent_path).unwrap_or_default();
|
||||
// If "DEVTYPE=wlan" appears, it's lying
|
||||
let actually_wifi = uevent.contains("DEVTYPE=wlan");
|
||||
Ok(reports_as_ethernet && !actually_wifi)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
pub fn get_interfaces() -> Result<Vec<Interface>> {
|
||||
let vec = Interface::get_all()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get network interfaces: {}", e))?
|
||||
.into_iter()
|
||||
.filter(|iface| {
|
||||
iface.is_up()
|
||||
&& iface.is_running()
|
||||
&& !iface.is_loopback()
|
||||
&& is_ethernet(&iface.name).unwrap_or(false)
|
||||
&& !iface.name.starts_with("bridge")
|
||||
&& !iface.name.starts_with("utun") // Exclude tunnel interfaces
|
||||
&& !iface.name.starts_with("awdl") // Exclude Apple Wireless Direct Link
|
||||
&& !iface.name.starts_with("anpi") // Exclude Apple Network Privacy Interface
|
||||
&& !iface.name.starts_with("llw") // Exclude Apple low-latency WLAN
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(vec)
|
||||
}
|
||||
|
||||
/// Sets a network interface to unmanaged by NetworkManager.
|
||||
/// Returns true if the command succeeded.
|
||||
pub fn set_interface_managed(interface: &str, managed: bool) -> bool {
|
||||
let managed_str = match managed {
|
||||
true => "yes",
|
||||
false => "no",
|
||||
};
|
||||
tracing::info!(
|
||||
"set_interface_managed for {} managed was set to: {}",
|
||||
interface,
|
||||
managed_str
|
||||
);
|
||||
let status = Command::new("nmcli")
|
||||
.args(["dev", "set", interface, "managed", managed_str])
|
||||
.status();
|
||||
|
||||
matches!(status, Ok(s) if s.success())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use crate::ethernet::get_interfaces;
|
||||
use crate::{futures::FutureIteratorExt, modbus::tcp::ModbusTcpDevice};
|
||||
use anyhow::{Result, bail};
|
||||
use interfaces::Interface;
|
||||
use std::cmp::min;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
pub struct ModbusTcpProbe {
|
||||
pub addr: SocketAddr,
|
||||
pub serial: u16,
|
||||
}
|
||||
|
||||
pub async fn probe_modbus_tcp() -> Vec<ModbusTcpProbe> {
|
||||
let interfaces = match get_interfaces() {
|
||||
Ok(x) => x,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
|
||||
interfaces
|
||||
.into_iter()
|
||||
.map(probe_modbus_tcp_addresses)
|
||||
.join_all()
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn probe_modbus_tcp_addresses(interface: Interface) -> Vec<ModbusTcpProbe> {
|
||||
let out: Vec<ModbusTcpProbe> = interface
|
||||
.addresses
|
||||
.iter()
|
||||
.filter_map(|addr| {
|
||||
let a = addr.addr.map(|a| a.ip());
|
||||
let m = addr.mask.map(|a| a.ip());
|
||||
|
||||
match (a, m) {
|
||||
(Some(IpAddr::V4(addr)), Some(IpAddr::V4(mask))) => Some((addr, mask)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.flat_map(|(addr, mask)| {
|
||||
let a = u32::from(addr);
|
||||
let m = u32::from(mask);
|
||||
let network = a & m;
|
||||
|
||||
let prefix = min(24, m.count_ones()); // mask → prefix length
|
||||
let size = 1u32 << (32 - prefix); // number of addresses
|
||||
|
||||
(0..size).map(move |i| SocketAddr::new(Ipv4Addr::from(network + i).into(), 502))
|
||||
})
|
||||
.map(|addr| smol::spawn(ping_modbus_device(addr)))
|
||||
.join_all()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter_map(|x| x.ok())
|
||||
.collect();
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
async fn ping_modbus_device(addr: SocketAddr) -> Result<ModbusTcpProbe> {
|
||||
let mut device = ModbusTcpDevice::new(addr).await?;
|
||||
|
||||
let module_number1 = device.get_u32(0x2).await?;
|
||||
let module_number2 = device.get_u32(0x4).await?;
|
||||
|
||||
if module_number1 != 0x2787_2144 || module_number2 != 0x0000_0000 {
|
||||
bail!("Unknown modbus tcp device!");
|
||||
}
|
||||
|
||||
let serial = device.get_u32(0x000A).await?;
|
||||
|
||||
Ok(ModbusTcpProbe {
|
||||
addr,
|
||||
serial: serial as u16,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use core::future::Future;
|
||||
|
||||
// Extension trait for iterators of futures
|
||||
pub trait FutureIteratorExt<F>: Iterator<Item = F> + Sized
|
||||
where
|
||||
F: Future,
|
||||
{
|
||||
/// Await all futures and return their outputs
|
||||
fn join_all(self) -> impl Future<Output = Vec<F::Output>>;
|
||||
}
|
||||
|
||||
impl<I, F> FutureIteratorExt<F> for I
|
||||
where
|
||||
I: Iterator<Item = F> + Sized,
|
||||
F: Future,
|
||||
{
|
||||
async fn join_all(self) -> Vec<F::Output> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
for fut in self {
|
||||
out.push(fut.await);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ pub mod controllers;
|
||||
pub mod converters;
|
||||
pub mod downcast;
|
||||
pub mod ethercat;
|
||||
pub mod ethernet;
|
||||
pub mod futures;
|
||||
pub mod helpers;
|
||||
pub mod irq_handling;
|
||||
pub mod modbus;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod modbus_serial_interface;
|
||||
pub mod tcp;
|
||||
|
||||
use anyhow::Error;
|
||||
use crc::{CRC_16_MODBUS, Crc};
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
use std::fmt::Debug;
|
||||
use std::io::ErrorKind;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use smol::Timer;
|
||||
use smol::future::FutureExt;
|
||||
use smol::io;
|
||||
use smol::io::AsyncReadExt;
|
||||
use smol::io::AsyncWriteExt;
|
||||
use smol::net::TcpStream;
|
||||
|
||||
const PROTOCOL_ID: u16 = 0;
|
||||
const UNIT_ID: u8 = 0;
|
||||
const READ_HOLDING_FUNCTION_CODE: u8 = 3;
|
||||
const WRITE_HOLDING_FUNCTION_CODE: u8 = 16;
|
||||
const READ_HOLDING_LENGTH: u16 = 6; // Unit ID 00 + Func Code 03 + Start Address XXXX + Quantity XXXX = 6 bytes
|
||||
const WRITE_HOLDING_LENGTH_WITHOUT_DATA: u16 = 7; // Unit ID 00 + Func Code 16 + Start Address XXXX + Quantity XXXX + Num Data Bytes XX = 7 bytes
|
||||
|
||||
struct Packet {
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Packet {
|
||||
pub const fn new() -> Self {
|
||||
Self { buf: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn add_u8(&mut self, x: u8) {
|
||||
self.buf.push(x);
|
||||
}
|
||||
|
||||
pub fn add_u16(&mut self, x: u16) {
|
||||
let buf = x.to_be_bytes();
|
||||
self.buf.push(buf[0]);
|
||||
self.buf.push(buf[1]);
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.buf
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ModbusTcpDevice {
|
||||
stream: TcpStream,
|
||||
transactions: u16,
|
||||
}
|
||||
|
||||
impl Debug for ModbusTcpDevice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self.stream.peer_addr() {
|
||||
Ok(addr) => write!(f, "ModbusTcpDevice({:?})", addr),
|
||||
Err(_) => std::fmt::Result::Err(std::fmt::Error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModbusTcpDevice {
|
||||
pub async fn new(addr: SocketAddr) -> Result<Self> {
|
||||
let timeout = async {
|
||||
Timer::after(Duration::from_millis(10)).await;
|
||||
Err(io::Error::from(ErrorKind::TimedOut))
|
||||
};
|
||||
|
||||
let stream = TcpStream::connect(addr)
|
||||
.or(timeout)
|
||||
.await
|
||||
.context("Could not connect to modbus device!")?;
|
||||
|
||||
Ok(Self {
|
||||
stream,
|
||||
transactions: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_and_check_responce_header(&mut self, func_code: u8) -> Result<()> {
|
||||
if self.read_u16().await? != self.transactions {
|
||||
bail!("Modbus device sent unexpected transaction id!");
|
||||
}
|
||||
|
||||
if self.read_u16().await? != PROTOCOL_ID {
|
||||
bail!("Modbus device sent unexpected protocol id!");
|
||||
}
|
||||
|
||||
let length = self.read_u16().await?;
|
||||
if length < 2 {
|
||||
bail!("Modbus device will send too few bytes to understand the response!");
|
||||
}
|
||||
|
||||
if self.read_u8().await? != UNIT_ID {
|
||||
bail!("Modbus device sent unexpected unit id!");
|
||||
}
|
||||
|
||||
let func_code_res = self.read_u8().await?;
|
||||
if func_code != func_code_res {
|
||||
let error_code = self.read_u8().await?;
|
||||
bail!(
|
||||
"Modbus device sent unexpected function code 0x{:x}! It probably encountered an error. Error code: 0x{:x}",
|
||||
func_code_res,
|
||||
error_code
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_read_holding_request(&mut self, addr: u16, count: u16) -> Result<u16> {
|
||||
let mut packet = Packet::new();
|
||||
|
||||
self.transactions += 1;
|
||||
packet.add_u16(self.transactions);
|
||||
packet.add_u16(PROTOCOL_ID);
|
||||
packet.add_u16(READ_HOLDING_LENGTH);
|
||||
|
||||
packet.add_u8(UNIT_ID);
|
||||
packet.add_u8(READ_HOLDING_FUNCTION_CODE);
|
||||
|
||||
packet.add_u16(addr);
|
||||
packet.add_u16(count);
|
||||
|
||||
self.send(packet).await?;
|
||||
self.read_and_check_responce_header(READ_HOLDING_FUNCTION_CODE)
|
||||
.await?;
|
||||
|
||||
let num_data_bytes = self.read_u8().await? as u16;
|
||||
if count * 2 != num_data_bytes {
|
||||
bail!(
|
||||
"Modbus device wants to send only a portion of the requested range - UNIMPLEMENTED!"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(num_data_bytes)
|
||||
}
|
||||
|
||||
pub async fn get_holding_registers(&mut self, addr: u16, count: u16) -> Result<Vec<u16>> {
|
||||
self.send_read_holding_request(addr, count).await?;
|
||||
|
||||
let mut res: Vec<u16> = Vec::new();
|
||||
while res.len() < count as usize {
|
||||
res.push(self.read_u16().await?);
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn set_holding_registers(&mut self, addr: u16, values: &[u16]) -> Result<()> {
|
||||
let count = values.len();
|
||||
let num_bytes = count * 2;
|
||||
assert!(
|
||||
count <= 128,
|
||||
"Cannot send that many bytes to modbus device in one go! Trying to send {} bytes of data.",
|
||||
num_bytes
|
||||
);
|
||||
|
||||
let mut packet = Packet::new();
|
||||
|
||||
self.transactions += 1;
|
||||
packet.add_u16(self.transactions);
|
||||
packet.add_u16(PROTOCOL_ID);
|
||||
packet.add_u16(WRITE_HOLDING_LENGTH_WITHOUT_DATA + num_bytes as u16);
|
||||
|
||||
packet.add_u8(UNIT_ID);
|
||||
packet.add_u8(WRITE_HOLDING_FUNCTION_CODE);
|
||||
|
||||
packet.add_u16(addr);
|
||||
packet.add_u16(count as u16);
|
||||
|
||||
packet.add_u8(num_bytes as u8);
|
||||
|
||||
for value in values {
|
||||
packet.add_u16(value.to_owned());
|
||||
}
|
||||
|
||||
self.send(packet).await?;
|
||||
self.read_and_check_responce_header(WRITE_HOLDING_FUNCTION_CODE)
|
||||
.await?;
|
||||
|
||||
if self.read_u16().await? != addr {
|
||||
bail!("Modbus device wrote to wrong register address!");
|
||||
}
|
||||
|
||||
if self.read_u16().await? != count as u16 {
|
||||
bail!("Modbus device wrote wrong number of registers!");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_string<const N: usize>(&mut self, addr: u16) -> Result<String> {
|
||||
assert!(N % 2 == 0, "Strings are always of even length!");
|
||||
assert!(N <= 256, "Cannot read long strings in a single swoop!");
|
||||
let count = N / 2;
|
||||
|
||||
self.send_read_holding_request(addr, count as u16).await?;
|
||||
let buf = self.read_bytes::<N>().await?;
|
||||
|
||||
let s = String::from_utf8_lossy(&buf);
|
||||
let s = match s.split_once('\0') {
|
||||
None => s.to_string(),
|
||||
Some((l, _)) => l.to_string(),
|
||||
};
|
||||
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub async fn set_string<const N: usize>(&mut self, _addr: u16, s: &str) -> Result<()> {
|
||||
assert!(s.len() <= N, "String is too long to fit!");
|
||||
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub async fn get_u16(&mut self, addr: u16) -> Result<u16> {
|
||||
let length = self.send_read_holding_request(addr, 1).await?;
|
||||
assert_eq!(
|
||||
length, 2,
|
||||
"Expected modbus device to send exactly 2 bytes of data!"
|
||||
);
|
||||
|
||||
self.read_u16().await
|
||||
}
|
||||
|
||||
pub async fn get_u32(&mut self, addr: u16) -> Result<u32> {
|
||||
let length = self.send_read_holding_request(addr, 2).await?;
|
||||
assert_eq!(
|
||||
length, 4,
|
||||
"Expected modbus device to send exactly 4 bytes of data!"
|
||||
);
|
||||
|
||||
let buf = self.read_bytes::<4>().await?;
|
||||
Ok(u32::from_be_bytes(buf))
|
||||
}
|
||||
|
||||
// We have to send the request in a single packet,
|
||||
// otherwise the device may fail to respond correctly.
|
||||
async fn send(&mut self, packet: Packet) -> Result<()> {
|
||||
self.stream
|
||||
.write_all(packet.as_bytes())
|
||||
.await
|
||||
.context("Could write to modbus device!")
|
||||
}
|
||||
|
||||
async fn read_u8(&mut self) -> Result<u8> {
|
||||
let buf = self.read_bytes::<1>().await?;
|
||||
Ok(u8::from_be_bytes(buf))
|
||||
}
|
||||
|
||||
async fn read_u16(&mut self) -> Result<u16> {
|
||||
let buf = self.read_bytes::<2>().await?;
|
||||
Ok(u16::from_be_bytes(buf))
|
||||
}
|
||||
|
||||
async fn read_bytes<const N: usize>(&mut self) -> Result<[u8; N]> {
|
||||
let mut buf = [0; N];
|
||||
self.stream
|
||||
.read_exact(&mut buf)
|
||||
.await
|
||||
.context("Could not read from modbus device!")?;
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 557 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 212 KiB |
@@ -0,0 +1,139 @@
|
||||
# XTREM Communication Protocol
|
||||
|
||||
**Version:** 3.007
|
||||
**Device:** XTREM / XTREM-S Weighing Module
|
||||
**Standard:** OIML R76:2006 / EN45501:2015 compliant
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The **XTREM communication protocol** allows a terminal or host controller to exchange data and commands with the XTREM weighing module.
|
||||
The device transmits and receives ASCII-coded frames over a serial interface (RS232, RS485, Wi-Fi, or Ethernet, depending on configuration).
|
||||
|
||||
Since we are using the Ethernet interface we need to provide some kind of DHCP to assign an IP - Address to each XTREM Device.
|
||||
They then may be addressed via a **broadcast** IP (e.g.: 192.168.4.255/24).
|
||||
A **Unicast** communication is applied through the device ID inside the XTREM Frame.
|
||||
|
||||
---
|
||||
|
||||
## Frame Structure
|
||||
|
||||
Each message—both query and response—has the same format:
|
||||
|
||||
```
|
||||
STX ID_O ID_D F D_ADDRESS D_L DATA LRC ETX
|
||||
```
|
||||
|
||||
| Field | Description | Length (bytes) |
|
||||
| :------------ | :------------------------------------------------ | :------------: |
|
||||
| **STX** | Start of message (ASCII `02h`) | 1 |
|
||||
| **ID_O** | Sender address (2 ASCII hex chars) | 2 |
|
||||
| **ID_D** | Destination address (2 ASCII hex chars) | 2 |
|
||||
| **F** | Function code (`R`, `W`, `E`, etc.) | 1 |
|
||||
| **D_ADDRESS** | Data address (register, 4 ASCII hex chars) | 4 |
|
||||
| **D_L** | Data length (2 ASCII hex chars) | 2 |
|
||||
| **DATA** | Optional data field | Variable |
|
||||
| **LRC** | Longitudinal Redundancy Check (2 ASCII hex chars) | 2 |
|
||||
| **ETX** | End of message (ASCII `03h`) | 1 |
|
||||
|
||||
In the default configuration the Frame also requires a CRLF at the end.
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
STX ‘1’ ‘7’ ‘0’ ‘1’ ‘R’ ‘0’ ‘1’ ‘0’ ‘1’ ‘0’ ‘0’ LRC ETX CR LF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Function Codes
|
||||
|
||||
| Function | ASCII | Hex | Description |
|
||||
| :------- | :---- | :---| :-------------- |
|
||||
| **R** | R | 52h | Read request |
|
||||
| **r** | r | 72h | Read response |
|
||||
| **W** | W | 57h | Write request |
|
||||
| **w** | w | 77h | Write response |
|
||||
| **E** | E | 45h | Execute command |
|
||||
| **e** | e | 65h | Execute response|
|
||||
|
||||
### Write response codes
|
||||
|
||||
* `'0'` – OK
|
||||
* `'1'` – Write denied (sealed)
|
||||
* `'2'` – Read-only register
|
||||
* `'3'` – Invalid value / out of range
|
||||
|
||||
### Execute response codes
|
||||
|
||||
* `'0'` – Executed successfully
|
||||
* `'1'` – Blocked by seal switch
|
||||
|
||||
---
|
||||
|
||||
## LRC Calculation
|
||||
|
||||
The **Longitudinal Redundancy Check** is a one-byte XOR of all message bytes, excluding:
|
||||
|
||||
* `STX`
|
||||
* `ETX`
|
||||
* The LRC itself
|
||||
|
||||
Example C function:
|
||||
|
||||
```c
|
||||
unsigned char LRC(unsigned char* s) {
|
||||
unsigned char lrc = 0;
|
||||
while (*s) {
|
||||
lrc ^= *(s++);
|
||||
}
|
||||
return lrc;
|
||||
}
|
||||
```
|
||||
|
||||
Example Rust function:
|
||||
```rust
|
||||
pub fn compute_lrc(data: &[u8]) -> u8 {
|
||||
data.iter().fold(0u8, |acc, &b| acc ^ b)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Data Registers
|
||||
|
||||
| Address (hex) | Description | Type | Notes |
|
||||
| :------------ | :------------------------- | :--: | :------------------------------------ |
|
||||
| **0000h** | Serial number | R | Factory-set |
|
||||
| **0001h** | Device ID | RW | Network address |
|
||||
| **0007h** | Hardware version | R | Read-only |
|
||||
| **0008h** | Software version | R | Read-only |
|
||||
| **0009h** | Sealing switch status | R | `0=UNLOCK`, `1=LOCK` |
|
||||
| **0010h** | Baud rate | RW | `0–4` → 9600–115200 baud |
|
||||
| **0013h** | Stream mode output rate | RW | Time in ms |
|
||||
| **0100h** | Device state | R | Status bits (power, Wi-Fi, ADC, etc.) |
|
||||
| **0101h** | Weight value | R | Gross weight |
|
||||
| **0102h** | Tare value | RE | Read/Execute |
|
||||
| **0103h** | Net weight | R | Gross − Tare |
|
||||
| **0110h** | ADC counts (instant) | R | Raw ADC data |
|
||||
| **0111h** | ADC counts (filtered) | R | Post-filter data |
|
||||
| **1010h** | Stop stream mode | E | Execute |
|
||||
| **1011h** | Start stream mode (weight) | E | Continuous output |
|
||||
| **9999h** | Device reset | E | Software reset |
|
||||
| **EEEEh** | Factory reset | E | Restores defaults |
|
||||
|
||||
---
|
||||
|
||||
## Post-Factory Reset Configuration
|
||||
|
||||
After performing a factory reset, several registers must be configured to ensure proper operation of the device.
|
||||
|
||||
Note: The unit may remain set to 'g' after a factory reset, but the values received may be in 'kg' depending on the device configuration.
|
||||
|
||||
The following registers should be reviewed and set as required:
|
||||
1. **Address: 0022 | Max (Max1) (scale maximum capacity)**
|
||||
The default value for this register may vary between devices (e.g., 60000 or 6000). Set this value to 6000 to ensure correct operation.
|
||||
2. **Address: 0023 | e (e1) (scale interval)**
|
||||
3. **Address: 0026 | Decimal position**
|
||||
@@ -82,3 +82,12 @@ git push
|
||||
# Recommended Editor Setup
|
||||
|
||||
We recommend you to use an Editor with rust-analyzer support like VSCode to speed up development and detect errors before compiling.
|
||||
|
||||
# Minimal Hardware Examples
|
||||
|
||||
To get started with actual hardware, check out these step-by-step tutorials:
|
||||
|
||||
- **[LED Control with EL2004](./minimal-example-el2004.md)** - Digital output control, the simplest possible hardware setup
|
||||
- **[Analog Input with EL3021](./minimal-example-el3021.md)** - Reading analog current measurements
|
||||
|
||||
These examples provide complete hardware wiring diagrams and software setup instructions.
|
||||
@@ -0,0 +1,224 @@
|
||||
# Minimal Example — LED Control on the EL2004
|
||||
A complete hardware + software walkthrough
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
1. [Introduction](#1-introduction)
|
||||
2. [Requirements](#2-requirements)
|
||||
3. [Hardware Setup](#3-hardware-setup)
|
||||
4. [Software Setup](#4-software-setup)
|
||||
5. [Demo](#5-demo)
|
||||
6. [References](#6-references)
|
||||
7. [Acknowledgements](#7-acknowledgements)
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
The EL2004 LED Toggle Example is a minimal demonstration showing how to control digital outputs on a **Beckhoff EL2004 EtherCAT terminal** using the QiTech machine framework.
|
||||
It represents the simplest possible hardware interaction in the system:
|
||||
**toggling LED outputs using the QiTech Control Dashboard.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
### Hardware
|
||||
- Beckhoff **EL2004 EtherCAT Terminal** (4-channel digital output)
|
||||
- Beckhoff **EK1100 EtherCAT Coupler**
|
||||
- **24 V DC power supply** (AC/DC adapter + DC hollow plug)
|
||||
- Jumper / bridge wires (0.5–1.5 mm² recommended)
|
||||
- A **Linux PC** (Ubuntu/Debian recommended)
|
||||
- Standard Ethernet cable
|
||||
- Flat screwdriver
|
||||
|
||||
### Software
|
||||
*(Installation steps in Section 4)*
|
||||
- Rust toolchain
|
||||
- Node.js + npm
|
||||
- Git
|
||||
- QiTech Control repository
|
||||
- EtherCAT HAL (included inside repo)
|
||||
|
||||
---
|
||||
|
||||
## 3. Hardware Setup
|
||||
|
||||
### 3.1 Schematic
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 3.2 EK1100 Wiring
|
||||
|
||||
This wiring configuration powers the EL2004 and prepares it for LED control.
|
||||
It is not the only possible wiring but is the **simplest functional setup**.
|
||||
|
||||
#### ⚠️ Safety Warning
|
||||
Always disconnect power before wiring.
|
||||
Working on live EtherCAT terminals can cause serious damage or electrical shock.
|
||||
|
||||
---
|
||||
|
||||
#### 3.2.1 Safe Wiring Procedure (Beckhoff Recommended)
|
||||
|
||||
1. Insert a screwdriver **straight** into the square release hole.
|
||||
2. Insert the stripped wire into the round opening.
|
||||
3. Remove the screwdriver — the spring clamp locks the wire.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
#### 3.2.2 Wiring Steps (Used in This Example)
|
||||
|
||||
We supply power using a **DC hollow-plug adapter**, like this one:
|
||||
https://www.amazon.de/dp/B093FTFZ8Q
|
||||
|
||||
Perform the following wiring on the EK1100:
|
||||
|
||||
1. Red wire **(+24 V)** → Terminal **2**
|
||||
2. Black wire **(0 V)** → Terminal **3**
|
||||
3. Jumper wire from **Terminal 1 → Terminal 6**
|
||||
4. Jumper wire from **Terminal 5 → Terminal 7**
|
||||
|
||||
After wiring, your module should look like **Figure 1**.
|
||||
|
||||
---
|
||||
|
||||
#### **Figure 1 — EK1100 Minimal Wiring**
|
||||
<img src="../assets/ek1100.jpeg" width="400">
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 3.3 EL2004 Integration
|
||||
|
||||
Slide the EL2004 onto the **right side of the EK1100** until it locks.
|
||||
The EtherCAT E-Bus and power contacts connect automatically — **no wiring required**.
|
||||
|
||||
#### **Figure 2 — EL2004 Terminal**
|
||||
<img src="../assets/el2004.jpeg" width="400">
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Final Assembled Setup
|
||||
|
||||
#### **Figure 3 — EK1100 + EL2004 Connected**
|
||||
<img src="../assets/complete.jpeg" width="400">
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Power & Ethernet
|
||||
|
||||
#### Power
|
||||
Connect the 24 V adapter to the hollow plug used earlier.
|
||||
**Example AC/DC Adapter (Figure 4):**
|
||||
|
||||
<img src="../assets/adapter.jpeg" width="400">
|
||||
|
||||
|
||||
#### Ethernet
|
||||
Use a standard LAN cable to connect your PC → EK1100.
|
||||
The final powered up and connected setup should look like this:
|
||||
|
||||
<img src="../assets/power.jpeg" width="400">
|
||||
|
||||
---
|
||||
|
||||
## 4. Software Setup
|
||||
|
||||
### 4.1 Installing on Ubuntu/Debian
|
||||
|
||||
Paste this into your terminal:
|
||||
|
||||
```bash
|
||||
# Press Enter when prompted
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
sudo apt update
|
||||
sudo apt install -y npm nodejs git
|
||||
|
||||
git clone git@github.com:qitechgmbh/control.git
|
||||
cd control/electron
|
||||
npm install
|
||||
```
|
||||
### 4.2 Running the Backend
|
||||
```bash
|
||||
./cargo_run_linux.sh
|
||||
```
|
||||
This script:
|
||||
|
||||
- Builds the backend
|
||||
|
||||
- Grants required system capabilities (raw sockets)
|
||||
|
||||
- Starts EtherCAT communication
|
||||
|
||||
Ensure the EK1100 is connected.
|
||||
|
||||
### 4.3 Running the Frontend
|
||||
```bash
|
||||
cd electron
|
||||
npm run start
|
||||
```
|
||||
This launches the QiTech Control dashboard.
|
||||
|
||||
## 5. Demo
|
||||
|
||||
### 5.1 Assigning Devices in the Dashboard
|
||||
|
||||
Once the backend + frontend are running, you should see:
|
||||
|
||||
- EK1100 Coupler
|
||||
|
||||
- EL2004 Digital Output Terminal
|
||||

|
||||
|
||||
Steps:
|
||||
|
||||
- Click Assign on the EK1100
|
||||
|
||||
- Select TestMachine V1
|
||||
|
||||

|
||||
|
||||
|
||||
- Enter a serial number (use the same for EK1100 + EL2004)
|
||||
|
||||

|
||||
|
||||
- Click Write
|
||||
|
||||
- Repeat for the EL2004
|
||||
|
||||
### 5.2 Testing LED Control
|
||||
Navigate to:
|
||||
|
||||
Machines → TestMachine
|
||||

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

|
||||
|
||||
**You can now toggle the four digital outputs of the EL2004.**
|
||||
|
||||
## 6. References
|
||||
|
||||
This guide incorporates information from official Beckhoff documentation.
|
||||
All diagrams, product names, and figures belong to Beckhoff Automation GmbH & Co. KG and are used here solely for educational purposes.
|
||||
|
||||
Referenced Manuals
|
||||
|
||||
[Beckhoff EK1100 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/ek110x_ek15xx_en.pdf)
|
||||
|
||||
[Beckhoff EL2004 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/el20xx_el2124_de.pdf)
|
||||
|
||||
## 7. Acknowledgements
|
||||
|
||||
This tutorial is inspired by the clarity and educational quality of Beckhoff manuals.
|
||||
All wiring illustrations and hardware descriptions in this guide are provided for demonstration purposes only and do not replace official Beckhoff installation guidelines.
|
||||
|
||||
Special thanks to the QiTech engineering team for providing the backend architecture, EtherCAT HAL abstraction, and the TestMachine framework that makes this example possible.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Minimal Example — Analog Input EL3021
|
||||
|
||||
## Table of Contents
|
||||
1. [Introduction](#1-introduction)
|
||||
2. [Requirements](#2-requirements)
|
||||
3. [Hardware Setup](#3-hardware-setup)
|
||||
4. [Software Setup](#4-software-setup)
|
||||
5. [Demo](#5-demo)
|
||||
6. [References](#6-references)
|
||||
|
||||
## 1. Introduction
|
||||
The EL3021 Analog Input example demonstrates how to measure analog current on a **Beckhoff EL3021 EtherCAT terminal** using the QiTech machine framework.
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
### Hardware
|
||||
- Beckhoff **EL3021 EtherCAT Terminal** (1-channel analog input)
|
||||
- Beckhoff **EK1100 EtherCAT Coupler**
|
||||
- **24 V DC power supply** (AC/DC adapter + DC hollow plug)
|
||||
- **Adjustable DC power supply** (or any other means of producing 4-20mA DC @ <10V )
|
||||
- Jumper / bridge wires (0.5–1.5 mm² recommended)
|
||||
- A **Linux PC** (Ubuntu/Debian recommended)
|
||||
- Standard Ethernet cable
|
||||
- Flat screwdriver
|
||||
|
||||
## 3. Hardware Setup
|
||||
|
||||
### 3.1 Schematic
|
||||

|
||||
The 24V supply powers the EL2004, while the adjustable power supply generates the analog input signal for the EL3021.
|
||||
|
||||
#### ⚠️ Safety Warning
|
||||
Always disconnect power before wiring.
|
||||
Working on live EtherCAT terminals can cause serious damage or electrical shock.
|
||||
|
||||
### 3.2.1 Safe Wiring Procedure (Beckhoff Recommended)
|
||||
|
||||
1. Insert a screwdriver **straight** into the square release hole.
|
||||
2. Insert the stripped wire into the round opening.
|
||||
3. Remove the screwdriver — the spring clamp locks the wire.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 3.2.2 Wiring Steps (Used in This Example)
|
||||
|
||||
We supply power using a **DC hollow-plug adapter**, like this one:
|
||||
https://www.amazon.de/dp/B093FTFZ8Q
|
||||
|
||||
Perform the following wiring on the EK1100:
|
||||
|
||||
1. Red wire **(+24 V)** → Terminal **2**
|
||||
2. Black wire **(0 V)** → Terminal **3**
|
||||
3. Jumper wire from **Terminal 1 → Terminal 6**
|
||||
4. Jumper wire from **Terminal 5 → Terminal 7**
|
||||
|
||||
After wiring, your module should look like **Figure 1**.
|
||||
|
||||
---
|
||||
|
||||
#### **Figure 1 — EK1100 Minimal Wiring**
|
||||
<img src="../assets/ek1100.jpeg" width="400">
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 3.3 EL3021 Integration
|
||||
|
||||
Slide the EL3021 onto the **right side of the EK1100** until it locks.
|
||||
The EtherCAT E-Bus and power contacts connect automatically.
|
||||
Then you need to wire the adjustable power supply to the EL3021.
|
||||
|
||||
#### **Figure 2 — EL3021 Terminal**
|
||||
<img src="../assets/EL3021.jpg" width="400">
|
||||
|
||||
1. Wire ⊕ to **Terminal 1**
|
||||
2. Wire ⊖ to **Terminal 2**
|
||||
---
|
||||
|
||||
### 3.4 Final Assembled Setup
|
||||
|
||||
#### **Figure 3 — EK1100 + EL3021 Connected**
|
||||
<img src="../assets/EK1100+EL3021.jpg" width="400">
|
||||
|
||||
---
|
||||
|
||||
## 3.5 Power & Ethernet
|
||||
|
||||
### 3.5.1 Power
|
||||
Connect the 24 V adapter to the hollow plug used earlier.
|
||||
|
||||
#### Example AC/DC Adapter (Figure 4):
|
||||
|
||||
<img src="../assets/adapter.jpeg" width="400">
|
||||
|
||||
|
||||
### 3.5.2 Ethernet
|
||||
Use a standard LAN cable to connect your PC → EK1100.
|
||||
The final powered up and connected setup should look like this:
|
||||
|
||||
<img src="../assets/EL3021_power+eth.jpg" width="400">
|
||||
|
||||
---
|
||||
|
||||
## 4. Software Setup
|
||||
|
||||
### 4.1 Installing on Ubuntu/Debian
|
||||
|
||||
Paste this into your terminal:
|
||||
|
||||
```bash
|
||||
# Press Enter when prompted
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
sudo apt update
|
||||
sudo apt install -y npm nodejs git
|
||||
|
||||
git clone git@github.com:qitechgmbh/control.git
|
||||
cd control/electron
|
||||
npm install
|
||||
```
|
||||
### 4.2 Running the Backend
|
||||
```bash
|
||||
./cargo_run_linux.sh
|
||||
```
|
||||
This script:
|
||||
|
||||
- Builds the backend
|
||||
|
||||
- Grants required system capabilities (raw sockets)
|
||||
|
||||
- Starts EtherCAT communication
|
||||
|
||||
Ensure the EK1100 is connected.
|
||||
|
||||
### 4.3 Running the Frontend
|
||||
```bash
|
||||
cd electron
|
||||
npm run start
|
||||
```
|
||||
This launches the QiTech Control dashboard.
|
||||
|
||||
## 5. Demo
|
||||
|
||||
### 5.1 Assigning Devices in the Dashboard
|
||||
Once both backend and frontend are running, the Setup -> EtherCat page should look something like this:
|
||||

|
||||
Listing the EK1100 and the EL3021.
|
||||
|
||||
To assign the two devices to a machine, click "Assign" on both devices. This should open the following pop-up modal:
|
||||
|
||||
**EK1100**
|
||||

|
||||
1. Select "AnalogTest V1" under "Machine"
|
||||
2. Choose a serial number to uniquely identify the machine (1 in this example)
|
||||
3. Under "Device Role" select "0 - Bus Coupler"
|
||||
|
||||
**EL3021**
|
||||

|
||||
1. Select "AnalogTest V1" under "Machine"
|
||||
2. Use the same serial number you chose for the EK1100
|
||||
3. Under "Device Role" select "1 - EL3021"
|
||||
---
|
||||
The machine is now configured.
|
||||
A new Tab "AnalogTest" should appear in the right sidebar. Clicking that tab should show the machine control interface:
|
||||

|
||||
Results should show the current measured by the analog input - the current set on the adjustable power supply.
|
||||
|
||||
|
||||
## 6. References
|
||||
This guide incorporates information from official Beckhoff documentation.
|
||||
All diagrams, product names, and figures belong to Beckhoff Automation GmbH & Co. KG and are used here solely for educational purposes.
|
||||
|
||||
Referenced Manuals
|
||||
|
||||
[Beckhoff EK1100 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/ek110x_ek15xx_en.pdf)
|
||||
[Beckhoff EL3021 Documentation](https://download.beckhoff.com/download/Document/io/ethercat-terminals/el30xx_de.pdf)
|
||||
@@ -10,7 +10,7 @@ This is why we chose nixos
|
||||
# How we do updates
|
||||
1. The user selects a version (commit/branch/tag) of the software they want to install
|
||||
2. The repo is cloned to the local machine
|
||||
3. the `installInfo.nix` is created and commited locally. It contains infos like the commit hash, branch, tag, timestamp etc. These are later available as environment variables `QITECH_OS...`
|
||||
3. Information like the commit hash, branch, tag, timestamp etc. is passed to the nix build process as environment variables `QITECH_OS...`
|
||||
4. The `nixos-switch` command is run. After rebuilding & compiling the software the system reboots.
|
||||
- The nixos-switch command does the following:
|
||||
1. The system is built with the new configuration
|
||||
@@ -21,4 +21,4 @@ This is why we chose nixos
|
||||
|
||||
# More Docs
|
||||
- [Implementation Details](./details.md)
|
||||
- [Fresh Installation Quick Start guide](./quick-start.md)
|
||||
- [Fresh Installation Quick Start guide](./quick-start.md)
|
||||
@@ -0,0 +1,235 @@
|
||||
# REST API `/api/v2`
|
||||
|
||||
The Qitech Control Panel exposes a small HTTP interface for discovering machines and reading their current values. All examples below assume you are connected to the panel’s Ethernet subnet and talk directly to the panel at:
|
||||
|
||||
- **Base URL:** `http://10.10.10.1:3001`
|
||||
|
||||
> **Schema note:** This documentation intentionally stays light on field details beyond what is shown in the examples. For the exact datatypes and complete payload shapes, refer to the corresponding Rust types (linked below).
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
The panel does **not** perform HTTP authentication. The expected security model is **network-level isolation**: anything that can send packets to the panel’s Ethernet interface is treated as trusted. This matches common security assumptions in EtherCAT-style control networks.
|
||||
|
||||
The panel is configured to administer its own subnet `10.10.10.0/24` via DHCP, while Wi-Fi can be used for upstream internet connectivity (if configured). If you need authentication or access from outside the isolated subnet, place a router/device in **client/bridge mode** on the panel network and expose the panel through a **reverse proxy** where you can add authentication, logging, rate limiting, etc.
|
||||
|
||||
If DNS is available on the subnet, you may be able to resolve `qitech.control`; otherwise use the static address `10.10.10.1`.
|
||||
|
||||
---
|
||||
|
||||
## List machines `GET /api/v2/machine`
|
||||
|
||||
Returns the set of machines currently known/connected to the panel.
|
||||
|
||||
Machines are identified by:
|
||||
|
||||
- `slug`: the machine type / model identifier (string)
|
||||
- `serial`: the specific machine instance identifier (int)
|
||||
|
||||
Each machine also includes a `legacy_id` to support older **v1** workflows. If a machine reports an issue, the `error` field may be present and non-null (containing an error message).
|
||||
|
||||
### Example request
|
||||
|
||||
```bash
|
||||
curl -X GET "http://10.10.10.1:3001/api/v2/machine"
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
```json
|
||||
{
|
||||
"machines": [
|
||||
{
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 7
|
||||
},
|
||||
"serial": 57922
|
||||
},
|
||||
"serial": 57922,
|
||||
"vendor": "QiTech",
|
||||
"slug": "mock",
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 4
|
||||
},
|
||||
"serial": 57922
|
||||
},
|
||||
"serial": 57922,
|
||||
"vendor": "QiTech",
|
||||
"slug": "extruder_v1",
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 2
|
||||
},
|
||||
"serial": 57922
|
||||
},
|
||||
"serial": 57922,
|
||||
"vendor": "QiTech",
|
||||
"slug": "winder_v1",
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 10
|
||||
},
|
||||
"serial": 48879
|
||||
},
|
||||
"serial": 48879,
|
||||
"vendor": "QiTech",
|
||||
"slug": "wago_power_v1",
|
||||
"error": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get current values `GET /api/v2/machine/<slug>/<serial>`
|
||||
|
||||
Returns all currently known values for a single machine.
|
||||
|
||||
Values are categorized into two groups:
|
||||
|
||||
- **State**: requested/commanded values (these typically change only after a state-change request, or if another controller updates them)
|
||||
- **Live Values**: measured/observed values coming from the machine and potentially changing quickly
|
||||
|
||||
This REST endpoint returns **only the current snapshot**, not a stream of live values.
|
||||
To receive continuous updates (via WebSockets), subscribe to the machine namespace (see **WebSockets** below).
|
||||
|
||||
### Example request (mock machine)
|
||||
|
||||
```bash
|
||||
curl -X GET "http://10.10.10.1:3001/api/v2/machine/mock/57922"
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
```json
|
||||
{
|
||||
"machine": {
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 7
|
||||
},
|
||||
"serial": 57922
|
||||
},
|
||||
"serial": 57922,
|
||||
"vendor": "QiTech",
|
||||
"slug": "mock",
|
||||
"error": null
|
||||
},
|
||||
"state": {
|
||||
"frequency1": 100.0,
|
||||
"frequency2": 200.0,
|
||||
"frequency3": 500.0,
|
||||
"is_default_state": false,
|
||||
"mode_state": {
|
||||
"mode": "Running"
|
||||
}
|
||||
},
|
||||
"live_values": {
|
||||
"amplitude1": -0.03438523433309566,
|
||||
"amplitude2": -0.06872980145477608,
|
||||
"amplitude3": -0.1711138370170743,
|
||||
"amplitude_sum": -0.27422887280494607
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Change machine state `POST /api/v1/machine/<slug>/<serial>`
|
||||
|
||||
State changes are submitted as **mutations**. The mutation payload is defined per machine type in Rust. Conceptually, each item in the mutation list represents a setter-style operation that is applied by the real-time control loop.
|
||||
|
||||
The API does **not** return the newly-applied state in the POST response. The panel runs a real-time loop and generally won’t block waiting for the physical system to converge. Instead:
|
||||
|
||||
- Submit the mutation via `POST`
|
||||
- Poll `GET /api/v2/machine/<slug>/<serial>` to observe the updated state and/or any reported errors
|
||||
|
||||
### Example request (mock machine)
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-d \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://10.10.10.1:3001/api/v1/machine/mock/57922"
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
```json
|
||||
null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSockets
|
||||
|
||||
For continuous updates, subscribe to a machine-specific namespace derived from its `legacy_id`:
|
||||
|
||||
- Namespace: `/machine/<vendor>/<id>/<serial>`
|
||||
|
||||
The stream emits events for:
|
||||
|
||||
- state changes (`StateEvent`)
|
||||
- live value updates (`LiveValuesEvent`)
|
||||
|
||||
Both event payloads use the same machine-specific schema as the `/api/v2` REST responses.
|
||||
|
||||
---
|
||||
|
||||
## List of all machines
|
||||
|
||||
Below is a template you can fill with links to the relevant Rust types (mutations + state/live structs).
|
||||
For each machine, link to:
|
||||
|
||||
- **Mutations:** the request payload type used by `POST /api/v1/machine/<slug>/<serial>`
|
||||
- **State / Live Values:** the response payload types returned by `GET /api/v2/machine/<slug>/<serial>`
|
||||
|
||||
### Machines
|
||||
|
||||
- **winder_v1**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L87>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L163>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L143>
|
||||
|
||||
- **extruder_v1**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L214>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L89>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L55>
|
||||
|
||||
- **laser_v1**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L88>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L32>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L17>
|
||||
|
||||
- **mock**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L84>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L38>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L24>
|
||||
|
||||
- **extruder_v2**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L126>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L93>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L59>
|
||||
@@ -73,7 +73,7 @@ typings/
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
# Serverless directoriesProcessMetrics
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
@@ -88,6 +88,7 @@ typings/
|
||||
# Vite
|
||||
.vite/
|
||||
dist/
|
||||
dist-electron/
|
||||
|
||||
# Electron-Forge
|
||||
out/
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
|
||||
import { MakerZIP } from "@electron-forge/maker-zip";
|
||||
import { MakerDeb } from "@electron-forge/maker-deb";
|
||||
import { MakerRpm } from "@electron-forge/maker-rpm";
|
||||
import { VitePlugin } from "@electron-forge/plugin-vite";
|
||||
import { FusesPlugin } from "@electron-forge/plugin-fuses";
|
||||
import { FuseV1Options, FuseVersion } from "@electron/fuses";
|
||||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
executableName: "electron-shadcn",
|
||||
asar: true,
|
||||
icon: "src/assets/icon",
|
||||
},
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
new MakerSquirrel({}),
|
||||
new MakerZIP({}, ["darwin"]),
|
||||
new MakerRpm({}),
|
||||
new MakerDeb({}),
|
||||
],
|
||||
plugins: [
|
||||
new VitePlugin({
|
||||
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
|
||||
// If you are familiar with Vite configuration, it will look really familiar.
|
||||
build: [
|
||||
{
|
||||
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
|
||||
entry: "src/main.ts",
|
||||
config: "vite.main.config.ts",
|
||||
},
|
||||
{
|
||||
entry: "src/preload.ts",
|
||||
config: "vite.preload.config.ts",
|
||||
},
|
||||
],
|
||||
renderer: [
|
||||
{
|
||||
name: "main_window",
|
||||
config: "vite.renderer.config.ts",
|
||||
},
|
||||
],
|
||||
}),
|
||||
// Fuses are used to enable/disable various Electron functionality
|
||||
// at package time, before code signing the application
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,35 +0,0 @@
|
||||
export {}; // Make this a module
|
||||
|
||||
declare global {
|
||||
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Vite
|
||||
// plugin that tells the Electron app where to look for the Vite-bundled app code (depending on
|
||||
// whether you're running in development or production).
|
||||
const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
|
||||
const MAIN_WINDOW_VITE_NAME: string;
|
||||
|
||||
namespace NodeJS {
|
||||
interface Process {
|
||||
// Used for hot reload after preload scripts.
|
||||
viteDevServers: Record<string, import("vite").ViteDevServer>;
|
||||
}
|
||||
}
|
||||
|
||||
type VitePluginConfig = ConstructorParameters<
|
||||
typeof import("@electron-forge/plugin-vite").VitePlugin
|
||||
>[0];
|
||||
|
||||
interface VitePluginRuntimeKeys {
|
||||
VITE_DEV_SERVER_URL: `${string}_VITE_DEV_SERVER_URL`;
|
||||
VITE_NAME: `${string}_VITE_NAME`;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "vite" {
|
||||
interface ConfigEnv<
|
||||
K extends keyof VitePluginConfig = keyof VitePluginConfig,
|
||||
> {
|
||||
root: string;
|
||||
forgeConfig: VitePluginConfig;
|
||||
forgeConfigSelf: VitePluginConfig[K][number];
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,8 @@
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"lint:write": "eslint . --fix",
|
||||
"format": "prettier --check .",
|
||||
@@ -22,15 +20,6 @@
|
||||
},
|
||||
"author": "",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.6.1",
|
||||
"@electron-forge/maker-deb": "^7.8.0",
|
||||
"@electron-forge/maker-rpm": "^7.8.3",
|
||||
"@electron-forge/maker-squirrel": "^7.6.1",
|
||||
"@electron-forge/maker-zip": "^7.6.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.6.1",
|
||||
"@electron-forge/plugin-fuses": "^7.10.2",
|
||||
"@electron-forge/plugin-vite": "^7.6.1",
|
||||
"@electron-forge/shared-types": "^7.4.0",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@eslint/compat": "^1.2.6",
|
||||
"@eslint/js": "^9.18.0",
|
||||
@@ -62,6 +51,8 @@
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-electron": "^0.29.0",
|
||||
"vite-plugin-electron-renderer": "^0.14.6",
|
||||
"vitest": "^3.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -84,7 +75,6 @@
|
||||
"@tanstack/react-router": "^1.130.17",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tanstack/router-devtools": "^1.105.0",
|
||||
"@tanstack/zod-adapter": "^1.116.0",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"babel-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -110,8 +100,8 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uplot": "^1.6.32",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.2",
|
||||
"zod-validation-error": "^3.4.0",
|
||||
"zod": "^4.3.5",
|
||||
"zod-validation-error": "^5.0.0",
|
||||
"zustand": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { RouterProvider } from "@tanstack/react-router";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { enableMapSet } from "immer";
|
||||
import { useGlobalLogStreaming } from "./hooks/useGlobalLogStreaming";
|
||||
import { GlobalLaserToastManager } from "./setup/GlobalLaserToastManager";
|
||||
|
||||
export default function App() {
|
||||
const { i18n } = useTranslation();
|
||||
@@ -21,7 +22,12 @@ export default function App() {
|
||||
updateAppLanguage(i18n);
|
||||
}, [i18n]);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
return (
|
||||
<>
|
||||
<GlobalLaserToastManager />
|
||||
<RouterProvider router={router} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// enable immer MapSet plugin
|
||||
|
||||
@@ -8,8 +8,7 @@ import { z } from "zod";
|
||||
import { toastError, toastZodError } from "@/components/Toast";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { FPS_30 } from "@/lib/constants";
|
||||
import { mainNamespaceStore, createMainNamespaceStore } from "./mainNamespace";
|
||||
import { softReloadMainNamespaceStore } from "./softReload";
|
||||
import { mainNamespaceStore } from "./mainNamespace";
|
||||
|
||||
/**
|
||||
* Simple buffer-based store updater to limit React re-renders to ~30 FPS
|
||||
@@ -385,11 +384,11 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
},
|
||||
|
||||
decrementNamespace: (namespaceId: NamespaceId) => {
|
||||
const namespace_path = serializeNamespaceId(namespaceId);
|
||||
/*const namespace_path = serializeNamespaceId(namespaceId);
|
||||
|
||||
// check if the namespace exists
|
||||
const namespace = get().namespaces[namespace_path];
|
||||
/*if (namespace) {
|
||||
if (namespace) {
|
||||
set(
|
||||
produce((state: SocketioStore) => {
|
||||
// decrement the count
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -25,11 +25,12 @@ export function getUnitIcon(unit: Unit): IconName {
|
||||
return "lu:Gauge";
|
||||
case "mHz":
|
||||
return "lu:AudioWaveform";
|
||||
case "Hz":
|
||||
return "lu:AudioWaveform";
|
||||
case "W":
|
||||
return "lu:Zap";
|
||||
case "V":
|
||||
return "lu:Zap";
|
||||
case "A":
|
||||
case "mA":
|
||||
return "lu:Zap";
|
||||
case "kWh":
|
||||
return "lu:BatteryFull";
|
||||
@@ -37,6 +38,16 @@ export function getUnitIcon(unit: Unit): IconName {
|
||||
return "lu:Waves";
|
||||
case "%":
|
||||
return "lu:ChartNoAxesColumn";
|
||||
case "µs":
|
||||
return "lu:RefreshCcw";
|
||||
case "s":
|
||||
return "lu:Clock";
|
||||
case "MiB":
|
||||
return "lu:MemoryStick";
|
||||
case "Mbit/s":
|
||||
return "lu:Network";
|
||||
case "/s":
|
||||
return "lu:Gauge";
|
||||
default:
|
||||
return "lu:ChartNoAxesColumn";
|
||||
}
|
||||
@@ -64,10 +75,14 @@ export function renderUnitSymbol(unit: Unit | undefined): string {
|
||||
return "m/min";
|
||||
case "mHz":
|
||||
return "mHz";
|
||||
case "Hz":
|
||||
return "Hz";
|
||||
case "W":
|
||||
return "W";
|
||||
case "V":
|
||||
return "V";
|
||||
case "mA":
|
||||
return "mA";
|
||||
case "A":
|
||||
return "A";
|
||||
case "kWh":
|
||||
@@ -76,16 +91,26 @@ export function renderUnitSymbol(unit: Unit | undefined): string {
|
||||
return "l/min";
|
||||
case "%":
|
||||
return "%";
|
||||
case "µs":
|
||||
return "µs";
|
||||
case "s":
|
||||
return "s";
|
||||
case "MiB":
|
||||
return "MiB";
|
||||
case "Mbit/s":
|
||||
return "Mbit/s";
|
||||
case "/s":
|
||||
return "/s";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// this function will add prefix/suffix to the valueString but without unit symbol.
|
||||
// example
|
||||
// value: 10.0
|
||||
// -> valueString: "10" (custom input)
|
||||
// this function will add prefix/suffix to the valueString but without unit symbol
|
||||
// -> "10°"
|
||||
// const f = 3.1415;
|
||||
// renderUnitSyntax(f.toFixed(2), "deg")
|
||||
// result: "3.14°"
|
||||
export function renderUnitSyntax(
|
||||
valueString: string | undefined,
|
||||
unit: Unit | undefined,
|
||||
@@ -125,10 +150,14 @@ export function renderUnitSymbolLong(unit: Unit): string {
|
||||
return "meters/minute";
|
||||
case "mHz":
|
||||
return "millihertz";
|
||||
case "Hz":
|
||||
return "hertz";
|
||||
case "W":
|
||||
return "watts";
|
||||
case "V":
|
||||
return "volts";
|
||||
case "mA":
|
||||
return "milliamperes";
|
||||
case "A":
|
||||
return "amperes";
|
||||
case "kWh":
|
||||
@@ -137,6 +166,16 @@ export function renderUnitSymbolLong(unit: Unit): string {
|
||||
return "liters/minute";
|
||||
case "%":
|
||||
return "percent";
|
||||
case "µs":
|
||||
return "microseconds";
|
||||
case "s":
|
||||
return "seconds";
|
||||
case "MiB":
|
||||
return "mebibytes";
|
||||
case "Mbit/s":
|
||||
return "megabits/second";
|
||||
case "/s":
|
||||
return "per second";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -153,12 +192,19 @@ export const units = [
|
||||
"bar",
|
||||
"m/min",
|
||||
"mHz",
|
||||
"Hz",
|
||||
"W",
|
||||
"V",
|
||||
"mA",
|
||||
"A",
|
||||
"kWh",
|
||||
"l/min",
|
||||
"%",
|
||||
"µs",
|
||||
"s",
|
||||
"MiB",
|
||||
"Mbit/s",
|
||||
"/s",
|
||||
] as const;
|
||||
|
||||
export type Unit = (typeof units)[number];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -38,6 +38,28 @@ export interface TimeSeriesWithInsert {
|
||||
insert: (series: TimeSeries, valueObj: TimeSeriesValue) => TimeSeries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration object for creating a time series
|
||||
*/
|
||||
export interface TimeSeriesConfig {
|
||||
sampleIntervalShort: number;
|
||||
sampleIntervalLong: number;
|
||||
retentionDurationShort: number;
|
||||
retentionDurationLong: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
* Short: 20ms sample, 5s retention
|
||||
* Long: 1s sample, 1h retention
|
||||
*/
|
||||
export const DEFAULT_TIMESERIES_CONFIG: TimeSeriesConfig = {
|
||||
sampleIntervalShort: 20, // 20ms
|
||||
sampleIntervalLong: 1000, // 1s
|
||||
retentionDurationShort: 5000, // 5s
|
||||
retentionDurationLong: 60 * 60 * 1000, // 1h
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract data with time window filtering in chronological order
|
||||
*/
|
||||
@@ -177,13 +199,18 @@ export function seriesToUPlotData(
|
||||
|
||||
/**
|
||||
* Factory function to create a new time series with circular buffers
|
||||
* @param config Optional configuration object. If omitted or partial, defaults from DEFAULT_TIMESERIES_CONFIG are used.
|
||||
*/
|
||||
export const createTimeSeries = (
|
||||
sampleIntervalShort: number,
|
||||
sampleIntervalLong: number,
|
||||
retentionDurationShort: number,
|
||||
retentionDurationLong: number,
|
||||
config: Partial<TimeSeriesConfig> = {},
|
||||
): TimeSeriesWithInsert => {
|
||||
const {
|
||||
sampleIntervalShort,
|
||||
sampleIntervalLong,
|
||||
retentionDurationShort,
|
||||
retentionDurationLong,
|
||||
} = { ...DEFAULT_TIMESERIES_CONFIG, ...config };
|
||||
|
||||
const shortSize = Math.ceil(retentionDurationShort / sampleIntervalShort);
|
||||
const longSize = Math.ceil(retentionDurationLong / sampleIntervalLong);
|
||||
|
||||
|
||||
@@ -9,18 +9,18 @@ import { z } from "zod";
|
||||
*/
|
||||
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 {
|
||||
const objectSchema = z.object({
|
||||
...(schemaEntries as {
|
||||
[K in keyof T]: z.ZodOptional<T[K]>;
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
// Add refinement to ensure exactly one property is defined
|
||||
return objectSchema.refine(
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// super refine function to validate if a string is a valid u16 number
|
||||
export function validateU16<T>(): (value: T, ctx: z.RefinementCtx) => boolean {
|
||||
const refine = (value: T, ctx: z.RefinementCtx) => {
|
||||
console.log("value", value);
|
||||
if (typeof value === "string") {
|
||||
const number = parseInt(value, 10);
|
||||
if (number < 0 || number > 65535) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Wert muss u16 integer sein",
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
return refine;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MiniGraph } from "@/components/graph/MiniGraph";
|
||||
import { Page } from "@/components/Page";
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
|
||||
import React from "react";
|
||||
import { useAnalogInputTestMachine } from "./useAnalogInputTestMachine";
|
||||
|
||||
export function AnalogInputTestMachineControl(): React.JSX.Element {
|
||||
const { seriesData, state, updateMeasurementRate } =
|
||||
useAnalogInputTestMachine();
|
||||
|
||||
if (!seriesData) return <div>Initializing Sensor...</div>;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
<ControlCard title="Measurement Control">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>Measurement Rate (Hz)</div>
|
||||
<input
|
||||
type="number"
|
||||
style={{
|
||||
border: "2px solid transparent",
|
||||
borderRadius: "4px",
|
||||
padding: "3px",
|
||||
}}
|
||||
className={"bg-neutral-100 shadow"}
|
||||
value={state.measurementRateHz}
|
||||
onChange={(v) => {
|
||||
const val = Number(v.currentTarget.value);
|
||||
updateMeasurementRate(val);
|
||||
}}
|
||||
min={1}
|
||||
></input>
|
||||
</div>
|
||||
</ControlCard>
|
||||
<ControlCard title="Results">
|
||||
<div className="flex flex-row">
|
||||
<MiniGraph
|
||||
newData={seriesData}
|
||||
width={500}
|
||||
renderValue={(v) => v.toFixed(2)}
|
||||
></MiniGraph>
|
||||
<div className="flex flex-col justify-center">
|
||||
{seriesData.current?.value.toFixed(2)}mA
|
||||
</div>
|
||||
</div>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Topbar } from "@/components/Topbar";
|
||||
import { analogInputTestMachineSerialRoute } from "@/routes/routes";
|
||||
import React from "react";
|
||||
|
||||
export function AnalogInputTestMachine(): React.JSX.Element {
|
||||
const { serial } = analogInputTestMachineSerialRoute.useParams();
|
||||
|
||||
return (
|
||||
<Topbar
|
||||
pathname={`/_sidebar/machines/analoginputtestmachine/${serial}`}
|
||||
items={[
|
||||
{
|
||||
link: "control",
|
||||
activeLink: "control",
|
||||
title: "Control",
|
||||
icon: "lu:CirclePlay",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMachineMutate } from "@/client/useClient";
|
||||
import {
|
||||
createTimeSeries,
|
||||
TimeSeries,
|
||||
TimeSeriesValue,
|
||||
TimeSeriesWithInsert,
|
||||
} from "@/lib/timeseries";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import z from "zod";
|
||||
import {
|
||||
StateEvent,
|
||||
useAnalogInputTestMachineNamespace,
|
||||
} from "./useAnalogInputTestMachineNamespace";
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { MachineIdentificationUnique } from "../types";
|
||||
import { analogInputTestMachineSerialRoute } from "@/routes/routes";
|
||||
import { analogInputTestMachine } from "../properties";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { produce } from "immer";
|
||||
|
||||
export function useAnalogInputTestMachine() {
|
||||
const { serial: serialString } =
|
||||
analogInputTestMachineSerialRoute.useParams();
|
||||
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
|
||||
const serial = parseInt(serialString);
|
||||
if (isNaN(serial)) {
|
||||
toastError(
|
||||
"Invalid Serial Number",
|
||||
`"${serialString}" is not a valid serial number.`,
|
||||
);
|
||||
return {
|
||||
machine_identification: { vendor: 0, machine: 0 },
|
||||
serial: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
machine_identification: analogInputTestMachine.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]);
|
||||
|
||||
const state = useAnalogInputTestMachineNamespace({
|
||||
type: "machine",
|
||||
machine_identification_unique: machineIdentification,
|
||||
});
|
||||
|
||||
const mutateMachine = useMachineMutate(
|
||||
z.object({
|
||||
measurement_rate_hz: z.number(),
|
||||
}),
|
||||
);
|
||||
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
|
||||
const updateStateOptimistically = (
|
||||
producer: (current: StateEvent) => void,
|
||||
serverRequest?: () => void,
|
||||
) => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState)
|
||||
stateOptimistic.setOptimistic(produce(currentState, producer));
|
||||
serverRequest?.();
|
||||
};
|
||||
|
||||
const updateMeasurementRate = (val: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.measurementRateHz = val;
|
||||
},
|
||||
() => {
|
||||
mutateMachine.request({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { measurement_rate_hz: val },
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const timeSeries: TimeSeriesWithInsert = createTimeSeries({
|
||||
sampleIntervalShort: 20,
|
||||
sampleIntervalLong: 1000,
|
||||
retentionDurationShort: 5000,
|
||||
retentionDurationLong: 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const [seriesData, setSeriesData] = useState<TimeSeries | null>(
|
||||
timeSeries.initialTimeSeries,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const measurement = state.currentMeasurement;
|
||||
|
||||
if (measurement && seriesData) {
|
||||
const dataPoint: TimeSeriesValue = {
|
||||
value: measurement[0] * 1000,
|
||||
timestamp: Number(measurement[1]),
|
||||
};
|
||||
setSeriesData(timeSeries.insert(seriesData, dataPoint));
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
seriesData,
|
||||
state,
|
||||
updateMeasurementRate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
createNamespaceHookImplementation,
|
||||
EventHandler,
|
||||
eventSchema,
|
||||
} from "@/client/socketioStore";
|
||||
import z from "zod";
|
||||
import { create, StoreApi } from "zustand";
|
||||
|
||||
// ========== Event Schema ==========
|
||||
|
||||
export const stateEventDataSchema = z.object({
|
||||
measurementRateHz: z.number().optional(),
|
||||
currentMeasurement: z.tuple([z.number(), z.string()]).optional(),
|
||||
});
|
||||
|
||||
export const stateEventSchema = eventSchema(stateEventDataSchema);
|
||||
|
||||
export type StateEvent = z.infer<typeof stateEventDataSchema>;
|
||||
|
||||
// ========== Store ==========
|
||||
export type AnalogInputTestMachineNamespaceStore = z.infer<
|
||||
typeof stateEventDataSchema
|
||||
>;
|
||||
|
||||
const createMachineStore = (): StoreApi<AnalogInputTestMachineNamespaceStore> =>
|
||||
create<AnalogInputTestMachineNamespaceStore>(() => ({
|
||||
measurementRateHz: 1,
|
||||
currentMeasurement: undefined,
|
||||
}));
|
||||
|
||||
// ========== Message Handler ==========
|
||||
function analogInputTestMachineMessageHandler(
|
||||
store: StoreApi<AnalogInputTestMachineNamespaceStore>,
|
||||
): EventHandler {
|
||||
return (event) => {
|
||||
const oldState = store.getState();
|
||||
const newMeasurementDataHz = event.data["MeasurementRateHz"];
|
||||
const newMeasurement: [number, string] = event.data["Measurement"];
|
||||
|
||||
switch (event.name) {
|
||||
case "MeasurementRateHz":
|
||||
if (
|
||||
newMeasurementDataHz &&
|
||||
newMeasurementDataHz !== oldState.measurementRateHz
|
||||
)
|
||||
store.setState({
|
||||
...oldState,
|
||||
measurementRateHz: newMeasurementDataHz,
|
||||
});
|
||||
break;
|
||||
case "Measurement":
|
||||
if (newMeasurement && newMeasurement !== oldState.currentMeasurement)
|
||||
store.setState({
|
||||
...oldState,
|
||||
currentMeasurement: newMeasurement,
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Namespace Hook ==========
|
||||
export const useAnalogInputTestMachineNamespace =
|
||||
createNamespaceHookImplementation({
|
||||
createStore: () => createMachineStore(),
|
||||
createEventHandler: analogInputTestMachineMessageHandler,
|
||||
});
|
||||
@@ -39,7 +39,7 @@ export function Aquapath1ControlPage() {
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="flex flex-col gap-8">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Front Flow"
|
||||
label="Flow 1"
|
||||
unit="l/min"
|
||||
timeseries={front_flow}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
@@ -48,7 +48,7 @@ export function Aquapath1ControlPage() {
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Back Flow"
|
||||
label="Flow 2"
|
||||
unit="l/min"
|
||||
timeseries={back_flow}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
@@ -57,7 +57,7 @@ export function Aquapath1ControlPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Label label="Front Pump">
|
||||
<Label label="Pump 1">
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={frontTargetFlow ? "On" : "Off"}
|
||||
orientation="vertical"
|
||||
@@ -86,7 +86,7 @@ export function Aquapath1ControlPage() {
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label label="Back Pump">
|
||||
<Label label="Pump 2">
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={backTargetFlow ? "On" : "Off"}
|
||||
orientation="vertical"
|
||||
@@ -121,22 +121,19 @@ export function Aquapath1ControlPage() {
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="flex flex-col gap-8">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Front in"
|
||||
label="Reservoir 1"
|
||||
unit="C"
|
||||
timeseries={front_temperature}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Front Reservoir"
|
||||
unit="C"
|
||||
timeseries={front_temp_reservoir}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
<Label label="Set Front Target Temperature">
|
||||
<Label label="Set Reservoir 1 Target Temperature">
|
||||
<EditValue
|
||||
title="Set Front Target Temperature"
|
||||
title="Set Reservoir 1 Target Temperature"
|
||||
value={frontTargetTemperature}
|
||||
unit="C"
|
||||
step={0.1}
|
||||
min={0.0}
|
||||
max={80.0}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
onChange={(val) => {
|
||||
setFrontTemperature(val);
|
||||
@@ -150,22 +147,19 @@ export function Aquapath1ControlPage() {
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Back in"
|
||||
label="Reservoir 2"
|
||||
unit="C"
|
||||
timeseries={back_temperature}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Back Reservoir"
|
||||
unit="C"
|
||||
timeseries={back_temp_reservoir}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
/>
|
||||
<Label label="Set Back Target Temperature">
|
||||
<Label label="Set Reservoir 2 Target Temperature">
|
||||
<EditValue
|
||||
title="Set Back Target Temperature"
|
||||
title="Set Reservoir 2 Target Temperature"
|
||||
value={backTargetTemperature}
|
||||
unit="C"
|
||||
step={0.1}
|
||||
min={0.0}
|
||||
max={80.0}
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
onChange={(val) => {
|
||||
setBackTemperature(val);
|
||||
|
||||
@@ -98,37 +98,17 @@ export type Aquapath1NamespaceStore = {
|
||||
back_temp_reservoir: TimeSeries;
|
||||
};
|
||||
|
||||
// Constants for time durations
|
||||
const TWENTY_MILLISECOND = 20;
|
||||
const ONE_SECOND = 1000;
|
||||
const FIVE_SECOND = 5 * ONE_SECOND;
|
||||
const ONE_HOUR = 60 * 60 * ONE_SECOND;
|
||||
|
||||
const { initialTimeSeries: front_temperature, insert: addTemperature1 } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: back_temperature, insert: addTemperature2 } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: front_temp_reservoir, insert: addTempReserv1 } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: back_temp_reservoir, insert: addTempReserv2 } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: front_flow, insert: addFlow1 } = createTimeSeries();
|
||||
const { initialTimeSeries: back_flow, insert: addFlow2 } = createTimeSeries();
|
||||
|
||||
const { initialTimeSeries: front_flow, insert: addFlow1 } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
|
||||
const { initialTimeSeries: back_flow, insert: addFlow2 } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
/**
|
||||
* Factory function to create a new Aquapath namespace store
|
||||
* @returns A new Zustand store instance for Aquapath namespace
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
PresetPreviewEntries,
|
||||
previewSeparator,
|
||||
} from "@/components/preset/PresetPreviewTable";
|
||||
import { Preset, PresetData } from "@/lib/preset/preset";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
|
||||
const extruder1PresetDataSchema = z
|
||||
.object({
|
||||
@@ -31,7 +31,7 @@ const extruder1PresetDataSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
type Extruder2 = typeof extruder1PresetDataSchema;
|
||||
type Extruder2 = z.infer<typeof extruder1PresetDataSchema>;
|
||||
|
||||
const schemas = new Map([[1, extruder1PresetDataSchema]]);
|
||||
|
||||
@@ -39,76 +39,69 @@ const previewEntries: PresetPreviewEntries<Extruder2> = [
|
||||
{
|
||||
name: "Target Front Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
renderValue: (data: Extruder2) =>
|
||||
data.targetFrontHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Target Middle Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
renderValue: (data: Extruder2) =>
|
||||
data.targetMiddleHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Target Back Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
renderValue: (data: Extruder2) =>
|
||||
data.targetBackHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Target Nozzle Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
renderValue: (data: Extruder2) =>
|
||||
data.targetNozzleHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "Inverter Regulation",
|
||||
renderValue: (data: PresetData<Extruder2>) => data.inverterRegulation,
|
||||
renderValue: (data: Extruder2) => data.inverterRegulation,
|
||||
},
|
||||
{
|
||||
name: "Target Inverter RPM",
|
||||
unit: "rpm",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.targetInverterRpm?.toFixed(2),
|
||||
renderValue: (data: Extruder2) => data.targetInverterRpm?.toFixed(2),
|
||||
},
|
||||
{
|
||||
name: "Target Inverter Pressure",
|
||||
unit: "bar",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.targetInverterPressure?.toFixed(1),
|
||||
renderValue: (data: Extruder2) => data.targetInverterPressure?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Inverter Direction",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.inverterRotationDirection,
|
||||
renderValue: (data: Extruder2) => data.inverterRotationDirection,
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "Pressure Limit",
|
||||
unit: "bar",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.pressureLimit?.toFixed(1),
|
||||
renderValue: (data: Extruder2) => data.pressureLimit?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Enable Pressure Limit",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
renderValue: (data: Extruder2) =>
|
||||
data.pressureLimitEnabled ? "on" : "off",
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "PID Pressue Kp",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.pidPressureKp?.toFixed(4),
|
||||
renderValue: (data: Extruder2) => data.pidPressureKp?.toFixed(4),
|
||||
},
|
||||
{
|
||||
name: "PID Pressue Ki",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.pidPressureKi?.toFixed(4),
|
||||
renderValue: (data: Extruder2) => data.pidPressureKi?.toFixed(4),
|
||||
},
|
||||
{
|
||||
name: "PID Pressue Kd",
|
||||
renderValue: (data: PresetData<Extruder2>) =>
|
||||
data.pidPressureKd?.toFixed(4),
|
||||
renderValue: (data: Extruder2) => data.pidPressureKd?.toFixed(4),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -135,7 +128,7 @@ export function Extruder2PresetsPage() {
|
||||
setPressurePidKp,
|
||||
} = useExtruder2();
|
||||
|
||||
const toPresetData = (s?: typeof state): PresetData<Extruder2> => ({
|
||||
const toPresetData = (s?: typeof state): Extruder2 => ({
|
||||
targetFrontHeatingTemperature: s?.heating_states.front.target_temperature,
|
||||
targetMiddleHeatingTemperature: s?.heating_states.middle.target_temperature,
|
||||
targetBackHeatingTemperature: s?.heating_states.back.target_temperature,
|
||||
@@ -161,32 +154,32 @@ export function Extruder2PresetsPage() {
|
||||
|
||||
const applyPreset = (preset: Preset<Extruder2>) => {
|
||||
setFrontHeatingTemperature(
|
||||
preset.data.targetFrontHeatingTemperature ?? 150.0,
|
||||
preset?.data?.targetFrontHeatingTemperature ?? 150.0,
|
||||
);
|
||||
setMiddleHeatingTemperature(
|
||||
preset.data.targetMiddleHeatingTemperature ?? 150.0,
|
||||
preset?.data?.targetMiddleHeatingTemperature ?? 150.0,
|
||||
);
|
||||
setBackHeatingTemperature(
|
||||
preset.data.targetBackHeatingTemperature ?? 150.0,
|
||||
preset?.data?.targetBackHeatingTemperature ?? 150.0,
|
||||
);
|
||||
setNozzleHeatingTemperature(
|
||||
preset.data.targetNozzleHeatingTemperature ?? 150.0,
|
||||
preset?.data?.targetNozzleHeatingTemperature ?? 150.0,
|
||||
);
|
||||
|
||||
setInverterTargetRpm(preset.data.targetInverterRpm ?? 0);
|
||||
setInverterTargetPressure(preset.data.targetInverterPressure ?? 0);
|
||||
setInverterRegulation(preset.data.inverterRegulation === "RPM");
|
||||
setInverterTargetRpm(preset?.data?.targetInverterRpm ?? 0);
|
||||
setInverterTargetPressure(preset?.data?.targetInverterPressure ?? 0);
|
||||
setInverterRegulation(preset?.data?.inverterRegulation === "RPM");
|
||||
|
||||
setInverterRotationDirection(
|
||||
preset.data.inverterRotationDirection === "Forward",
|
||||
preset?.data?.inverterRotationDirection === "Forward",
|
||||
);
|
||||
|
||||
setExtruderPressureLimit(preset.data.pressureLimit ?? 100.0);
|
||||
setExtruderPressureLimitEnabled(preset.data.pressureLimitEnabled ?? true);
|
||||
setExtruderPressureLimit(preset?.data?.pressureLimit ?? 100.0);
|
||||
setExtruderPressureLimitEnabled(preset?.data?.pressureLimitEnabled ?? true);
|
||||
|
||||
setPressurePidKp(preset.data.pidPressureKp ?? 0.16);
|
||||
setPressurePidKi(preset.data.pidPressureKi ?? 0.0);
|
||||
setPressurePidKd(preset.data.pidPressureKd ?? 0.008);
|
||||
setPressurePidKp(preset?.data?.pidPressureKp ?? 0.16);
|
||||
setPressurePidKi(preset?.data?.pidPressureKi ?? 0.0);
|
||||
setPressurePidKd(preset?.data?.pidPressureKd ?? 0.008);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -223,64 +223,35 @@ export type Extruder2NamespaceStore = {
|
||||
totalEnergyKWh: TimeSeries;
|
||||
};
|
||||
|
||||
// Constants for time durations
|
||||
const TWENTY_MILLISECOND = 20;
|
||||
const ONE_SECOND = 1000;
|
||||
const FIVE_SECOND = 5 * ONE_SECOND;
|
||||
const ONE_HOUR = 60 * 60 * ONE_SECOND;
|
||||
|
||||
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
|
||||
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries();
|
||||
const { initialTimeSeries: backTemperature, insert: addBackTemperature } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: frontTemperature, insert: addFrontTemperature } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: middleTemperature, insert: addMiddleTemperature } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: nozzleTemperature, insert: addNozzleTemperature } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: nozzlePower, insert: addNozzlePower } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: frontPower, insert: addFrontPower } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: middlePower, insert: addMiddlePower } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
const { initialTimeSeries: backPower, insert: addBackPower } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: backPower, insert: addBackPower } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: combinedPower, insert: addCombinedPower } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: totalEnergyKWh, insert: addTotalEnergyKWh } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorCurrent, insert: addMotorCurrent } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorFrequency, insert: addMotorFrequency } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorScrewRpm, insert: addMotorScrewRpm } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorPower, insert: addMotorPower } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
createTimeSeries();
|
||||
|
||||
export function extruder2MessageHandler(
|
||||
store: StoreApi<Extruder2NamespaceStore>,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -223,64 +223,35 @@ export type Extruder3NamespaceStore = {
|
||||
totalEnergyKWh: TimeSeries;
|
||||
};
|
||||
|
||||
// Constants for time durations
|
||||
const TWENTY_MILLISECOND = 20;
|
||||
const ONE_SECOND = 1000;
|
||||
const FIVE_SECOND = 5 * ONE_SECOND;
|
||||
const ONE_HOUR = 60 * 60 * ONE_SECOND;
|
||||
|
||||
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
|
||||
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries();
|
||||
const { initialTimeSeries: backTemperature, insert: addBackTemperature } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: frontTemperature, insert: addFrontTemperature } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: middleTemperature, insert: addMiddleTemperature } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: nozzleTemperature, insert: addNozzleTemperature } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: nozzlePower, insert: addNozzlePower } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: frontPower, insert: addFrontPower } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: middlePower, insert: addMiddlePower } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
const { initialTimeSeries: backPower, insert: addBackPower } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: backPower, insert: addBackPower } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: combinedPower, insert: addCombinedPower } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: totalEnergyKWh, insert: addTotalEnergyKWh } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorCurrent, insert: addMotorCurrent } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorFrequency, insert: addMotorFrequency } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorScrewRpm, insert: addMotorScrewRpm } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorPower, insert: addMotorPower } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
createTimeSeries();
|
||||
|
||||
export function extruder3MessageHandler(
|
||||
store: StoreApi<Extruder3NamespaceStore>,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { Page } from "@/components/Page";
|
||||
import React from "react";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { SelectionGroup } from "@/control/SelectionGroup";
|
||||
import { Label } from "@/control/Label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useIP20TestMachine } from "./useIP20TestMachine";
|
||||
|
||||
export function IP20TestMachineControlPage() {
|
||||
const { state, liveValues, setOutput, setAllOutputs } = useIP20TestMachine();
|
||||
|
||||
const safeState = state ?? {
|
||||
outputs: [false, false, false, false, false, false, false, false],
|
||||
};
|
||||
const safeLiveValues = liveValues ?? {
|
||||
inputs: [false, false, false, false, false, false, false, false],
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
{/* Digital Output Controls */}
|
||||
<ControlCard title="Digital Outputs">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{safeState.outputs.map((output, index) => (
|
||||
<Label key={index} label={`Output ${index + 1}`}>
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={output ? "On" : "Off"}
|
||||
orientation="vertical"
|
||||
className="grid h-full grid-cols-2 gap-2"
|
||||
options={{
|
||||
Off: {
|
||||
children: "Off",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-red-600",
|
||||
className: "h-full",
|
||||
},
|
||||
On: {
|
||||
children: "On",
|
||||
icon: "lu:CirclePlay",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
}}
|
||||
onChange={(value) => setOutput(index, value === "On")}
|
||||
/>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</ControlCard>
|
||||
|
||||
{/* Digital Input Display */}
|
||||
<ControlCard title="Digital Inputs">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{safeLiveValues.inputs.map((input, index) => (
|
||||
<Label key={index} label={`Input ${index + 1}`}>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Badge
|
||||
className={`text-md ${input ? "bg-green-600" : "bg-gray-400"}`}
|
||||
>
|
||||
{input ? "HIGH" : "LOW"}
|
||||
</Badge>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</ControlCard>
|
||||
|
||||
{/* Master Output Control */}
|
||||
<ControlCard title="Master Output Control">
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={safeState.outputs.every(Boolean) ? "On" : "Off"}
|
||||
orientation="horizontal"
|
||||
options={{
|
||||
Off: {
|
||||
children: "Turn All Outputs Off",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-red-600",
|
||||
},
|
||||
On: {
|
||||
children: "Turn All Outputs On",
|
||||
icon: "lu:CirclePlay",
|
||||
isActiveClassName: "bg-green-600",
|
||||
},
|
||||
}}
|
||||
onChange={(value) => setAllOutputs(value === "On")}
|
||||
/>
|
||||
</ControlCard>
|
||||
|
||||
{/* Input Summary */}
|
||||
<ControlCard title="Input Summary">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Active Inputs:</span>
|
||||
<Badge className="bg-blue-500">
|
||||
{safeLiveValues.inputs.filter(Boolean).length} / 8
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Inactive Inputs:</span>
|
||||
<Badge className="bg-gray-500">
|
||||
{safeLiveValues.inputs.filter((i) => !i).length} / 8
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Topbar } from "@/components/Topbar";
|
||||
import { ip20TestMachineSerialRoute } from "@/routes/routes";
|
||||
import React from "react";
|
||||
|
||||
export function IP20TestMachinePage(): React.JSX.Element {
|
||||
const { serial } = ip20TestMachineSerialRoute.useParams();
|
||||
|
||||
return (
|
||||
<Topbar
|
||||
pathname={`/_sidebar/machines/ip20testmachine/${serial}`}
|
||||
items={[
|
||||
{
|
||||
link: "control",
|
||||
activeLink: "control",
|
||||
title: "Control",
|
||||
icon: "lu:ToggleLeft",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { StoreApi } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
EventHandler,
|
||||
eventSchema,
|
||||
Event,
|
||||
handleUnhandledEventError,
|
||||
NamespaceId,
|
||||
createNamespaceHookImplementation,
|
||||
ThrottledStoreUpdater,
|
||||
} from "@/client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
|
||||
// ========== Event Schema ==========
|
||||
|
||||
export const stateEventDataSchema = z.object({
|
||||
outputs: z.array(z.boolean()).length(8),
|
||||
});
|
||||
|
||||
export const liveValuesEventDataSchema = z.object({
|
||||
inputs: z.array(z.boolean()).length(8),
|
||||
});
|
||||
|
||||
export const stateEventSchema = eventSchema(stateEventDataSchema);
|
||||
export const liveValuesEventSchema = eventSchema(liveValuesEventDataSchema);
|
||||
|
||||
export type StateEvent = z.infer<typeof stateEventDataSchema>;
|
||||
export type LiveValuesEvent = z.infer<typeof liveValuesEventDataSchema>;
|
||||
|
||||
// ========== Store ==========
|
||||
export type IP20TestMachineNamespaceStore = {
|
||||
state: StateEvent | null;
|
||||
liveValues: LiveValuesEvent | null;
|
||||
};
|
||||
|
||||
export const createIP20TestMachineNamespaceStore =
|
||||
(): StoreApi<IP20TestMachineNamespaceStore> =>
|
||||
create<IP20TestMachineNamespaceStore>(() => ({
|
||||
state: null,
|
||||
liveValues: null,
|
||||
}));
|
||||
|
||||
// ========== Message Handler ==========
|
||||
export function ip20TestMachineMessageHandler(
|
||||
store: StoreApi<IP20TestMachineNamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<IP20TestMachineNamespaceStore>,
|
||||
): EventHandler {
|
||||
return (event: Event<any>) => {
|
||||
const updateStore = (
|
||||
updater: (
|
||||
state: IP20TestMachineNamespaceStore,
|
||||
) => IP20TestMachineNamespaceStore,
|
||||
) => throttledUpdater.updateWith(updater);
|
||||
|
||||
try {
|
||||
if (event.name === "StateEvent") {
|
||||
const parsed = stateEventSchema.parse(event);
|
||||
updateStore((current) => ({ ...current, state: parsed.data }));
|
||||
} else if (event.name === "LiveValuesEvent") {
|
||||
const parsed = liveValuesEventSchema.parse(event);
|
||||
updateStore((current) => ({ ...current, liveValues: parsed.data }));
|
||||
} else {
|
||||
handleUnhandledEventError(event.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${event.name}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Namespace Hook ==========
|
||||
const useIP20TestMachineNamespaceImplementation =
|
||||
createNamespaceHookImplementation<IP20TestMachineNamespaceStore>({
|
||||
createStore: createIP20TestMachineNamespaceStore,
|
||||
createEventHandler: ip20TestMachineMessageHandler,
|
||||
});
|
||||
|
||||
export function useIP20TestMachineNamespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): IP20TestMachineNamespaceStore {
|
||||
const namespaceId: NamespaceId = {
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
};
|
||||
|
||||
return useIP20TestMachineNamespaceImplementation(namespaceId);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { ip20TestMachineSerialRoute } from "@/routes/routes";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import {
|
||||
useIP20TestMachineNamespace,
|
||||
StateEvent,
|
||||
LiveValuesEvent,
|
||||
} from "./ip20TestMachineNamespace";
|
||||
import { useMachineMutate } from "@/client/useClient";
|
||||
import { produce } from "immer";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ip20TestMachine } from "@/machines/properties";
|
||||
import { z } from "zod";
|
||||
|
||||
export function useIP20TestMachine() {
|
||||
const { serial: serialString } = ip20TestMachineSerialRoute.useParams();
|
||||
|
||||
// Memoize machine identification
|
||||
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
|
||||
const serial = parseInt(serialString);
|
||||
|
||||
if (isNaN(serial)) {
|
||||
toastError(
|
||||
"Invalid Serial Number",
|
||||
`"${serialString}" is not a valid serial number.`,
|
||||
);
|
||||
|
||||
return {
|
||||
machine_identification: { vendor: 0, machine: 0 },
|
||||
serial: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
machine_identification: ip20TestMachine.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]);
|
||||
|
||||
// Namespace state from backend
|
||||
const { state, liveValues } = useIP20TestMachineNamespace(
|
||||
machineIdentification,
|
||||
);
|
||||
|
||||
// Optimistic state
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
|
||||
useEffect(() => {
|
||||
if (state) stateOptimistic.setReal(state);
|
||||
}, [state, stateOptimistic]);
|
||||
|
||||
// Generic mutation sender
|
||||
const { request: sendMutation } = useMachineMutate(
|
||||
z.object({
|
||||
action: z.string(),
|
||||
value: z.any(),
|
||||
}),
|
||||
);
|
||||
|
||||
const updateStateOptimistically = (
|
||||
producer: (current: StateEvent) => void,
|
||||
serverRequest?: () => void,
|
||||
) => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState)
|
||||
stateOptimistic.setOptimistic(produce(currentState, producer));
|
||||
serverRequest?.();
|
||||
};
|
||||
|
||||
const setOutput = (index: number, on: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.outputs[index] = on;
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetOutput", value: { index, on } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setAllOutputs = (on: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.outputs = [on, on, on, on, on, on, on, on];
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetAllOutputs", value: { on } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
state: stateOptimistic.value,
|
||||
liveValues,
|
||||
setOutput,
|
||||
setAllOutputs,
|
||||
};
|
||||
}
|
||||
@@ -12,14 +12,14 @@ import { useLaser1 } from "./useLaser1";
|
||||
export function Laser1GraphsPage() {
|
||||
const { diameter, x_diameter, y_diameter, roundness, state } = useLaser1();
|
||||
|
||||
const syncHook = useGraphSync("diameter-group");
|
||||
const syncHook = useGraphSync("diameter-roundness-group");
|
||||
const targetDiameter = state?.laser_state?.target_diameter ?? 0;
|
||||
const lowerTolerance = state?.laser_state?.lower_tolerance ?? 0;
|
||||
const higherTolerance = state?.laser_state?.higher_tolerance ?? 0;
|
||||
|
||||
const isTwoAxis = !!x_diameter?.current || !!y_diameter?.current;
|
||||
|
||||
// Transform roundness from ratio (0-1) to percentage (0-100)
|
||||
// Convert roundness from ratio (0-1) to percentage (0-100)
|
||||
const roundnessPercent = React.useMemo(() => {
|
||||
if (!roundness) return null;
|
||||
|
||||
@@ -45,120 +45,110 @@ export function Laser1GraphsPage() {
|
||||
};
|
||||
}, [roundness]);
|
||||
|
||||
const diameterColor = "#3b82f6";
|
||||
const xDiameterColor = "#ef4444";
|
||||
const yDiameterColor = "#22c55e";
|
||||
const roundnessColor = "#eab308";
|
||||
const diameterColor = "#3b82f6"; // blue
|
||||
const xDiameterColor = "#ef4444"; // red
|
||||
const yDiameterColor = "#22c55e"; // green
|
||||
const roundnessColor = "#eab308"; // yellow
|
||||
|
||||
const config: GraphConfig = {
|
||||
title: "Diameter",
|
||||
defaultTimeWindow: 30 * 60 * 1000, // 30 minutes
|
||||
exportFilename: "diameter_data",
|
||||
// config for all graphs
|
||||
const baseGraphConfig: GraphConfig = {
|
||||
title: "",
|
||||
defaultTimeWindow: 30 * 60 * 1000, // 30 minutes Standard
|
||||
colors: {
|
||||
primary: diameterColor,
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
background: "#ffffff",
|
||||
},
|
||||
};
|
||||
if (!isTwoAxis) {
|
||||
return (
|
||||
<Page className="pb-27">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
// Diameter-Graph (Diameter, X-Diameter, Y-Diameter)
|
||||
const diameterGraphData = [
|
||||
{
|
||||
newData: diameter,
|
||||
color: diameterColor,
|
||||
title: "Diameter",
|
||||
lines: [
|
||||
// Tolerance for diameter
|
||||
{
|
||||
type: "threshold" as const,
|
||||
value: targetDiameter + higherTolerance,
|
||||
label: "Upper Tolerance",
|
||||
color: diameterColor,
|
||||
dash: [5, 5],
|
||||
},
|
||||
{
|
||||
type: "threshold" as const,
|
||||
value: targetDiameter - lowerTolerance,
|
||||
label: "Lower Tolerance",
|
||||
color: diameterColor,
|
||||
dash: [5, 5],
|
||||
},
|
||||
{
|
||||
type: "target" as const,
|
||||
value: targetDiameter,
|
||||
label: "Target",
|
||||
color: diameterColor,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
...(isTwoAxis && x_diameter
|
||||
? [{ newData: x_diameter, color: xDiameterColor, title: "X-Diameter" }]
|
||||
: []),
|
||||
...(isTwoAxis && y_diameter
|
||||
? [{ newData: y_diameter, color: yDiameterColor, title: "Y-Diameter" }]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Roundness-Graph
|
||||
const roundnessGraphData = {
|
||||
newData: roundnessPercent,
|
||||
color: roundnessColor,
|
||||
title: "Roundness (%)",
|
||||
};
|
||||
|
||||
return (
|
||||
<Page className="pb-27">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Graph 1: Diameter */}
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={diameterGraphData}
|
||||
unit="mm"
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
config={{
|
||||
...baseGraphConfig,
|
||||
title: "Diameter (mm)",
|
||||
exportFilename: "diameter_data",
|
||||
colors: {
|
||||
...baseGraphConfig.colors,
|
||||
primary: diameterColor,
|
||||
},
|
||||
}}
|
||||
graphId="diameter-graph"
|
||||
/>
|
||||
|
||||
{/* Graph 2: Roundness */}
|
||||
{isTwoAxis && roundnessPercent && (
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: diameter,
|
||||
color: diameterColor,
|
||||
lines: [
|
||||
{
|
||||
type: "threshold",
|
||||
value: targetDiameter + higherTolerance,
|
||||
label: "Upper Threshold",
|
||||
color: diameterColor,
|
||||
dash: [5, 5],
|
||||
},
|
||||
{
|
||||
type: "threshold",
|
||||
value: targetDiameter - lowerTolerance,
|
||||
label: "Lower Threshold",
|
||||
color: diameterColor,
|
||||
dash: [5, 5],
|
||||
},
|
||||
{
|
||||
type: "target",
|
||||
value: targetDiameter,
|
||||
label: "Target",
|
||||
color: diameterColor,
|
||||
},
|
||||
],
|
||||
}}
|
||||
unit="mm"
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
config={config}
|
||||
graphId="diameter-graph"
|
||||
/>
|
||||
</div>
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
</Page>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Page className="pb-27">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={[
|
||||
{
|
||||
newData: diameter,
|
||||
color: diameterColor,
|
||||
title: "Diameter",
|
||||
lines: [
|
||||
{
|
||||
type: "threshold",
|
||||
value: targetDiameter + higherTolerance,
|
||||
label: "Upper Threshold",
|
||||
color: diameterColor,
|
||||
dash: [5, 5],
|
||||
},
|
||||
{
|
||||
type: "threshold",
|
||||
value: targetDiameter - lowerTolerance,
|
||||
label: "Lower Threshold",
|
||||
color: diameterColor,
|
||||
dash: [5, 5],
|
||||
},
|
||||
{
|
||||
type: "target",
|
||||
value: targetDiameter,
|
||||
label: "Target",
|
||||
color: diameterColor,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
newData: x_diameter,
|
||||
color: xDiameterColor,
|
||||
title: "X-Diameter",
|
||||
},
|
||||
{
|
||||
newData: y_diameter,
|
||||
color: yDiameterColor,
|
||||
title: "Y-Diameter",
|
||||
},
|
||||
{
|
||||
newData: roundnessPercent,
|
||||
color: roundnessColor,
|
||||
title: "Roundness (%)",
|
||||
},
|
||||
]}
|
||||
newData={roundnessGraphData}
|
||||
unit="%"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
config={config}
|
||||
graphId="diameter-graph"
|
||||
config={{
|
||||
...baseGraphConfig,
|
||||
title: "Roundness (%)",
|
||||
exportFilename: "roundness_data",
|
||||
colors: {
|
||||
...baseGraphConfig.colors,
|
||||
primary: roundnessColor,
|
||||
},
|
||||
}}
|
||||
graphId="roundness-graph"
|
||||
/>
|
||||
</div>
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,12 @@ export function Laser1Page() {
|
||||
icon: "lu:ChartSpline",
|
||||
},
|
||||
// {
|
||||
// link: "settings",
|
||||
// activeLink: "settings",
|
||||
// title: "Config",
|
||||
// icon: "lu:Settings",
|
||||
// },
|
||||
// {
|
||||
// link: "manual",
|
||||
// activeLink: "manual",
|
||||
// title: "Manual",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
import { useLaser1 } from "./useLaser1";
|
||||
import { Preset, PresetData } from "@/lib/preset/preset";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
import { laser1 } from "@/machines/properties";
|
||||
import { PresetPreviewEntries } from "@/components/preset/PresetPreviewTable";
|
||||
import { PresetsPage } from "@/components/preset/PresetsPage";
|
||||
@@ -14,7 +14,7 @@ const laser1PresetDataSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
type Laser1 = typeof laser1PresetDataSchema;
|
||||
type Laser1 = z.infer<typeof laser1PresetDataSchema>;
|
||||
|
||||
const schemas = new Map([[1, laser1PresetDataSchema]]);
|
||||
|
||||
@@ -22,18 +22,17 @@ const previewEntries: PresetPreviewEntries<Laser1> = [
|
||||
{
|
||||
name: "Target Diameter",
|
||||
unit: "mm",
|
||||
renderValue: (data: PresetData<Laser1>) => data?.targetDiameter?.toFixed(3),
|
||||
renderValue: (data: Laser1) => data?.targetDiameter?.toFixed(3),
|
||||
},
|
||||
{
|
||||
name: "Lower Tolerance",
|
||||
unit: "mm",
|
||||
renderValue: (data: PresetData<Laser1>) => data?.lowerTolerance?.toFixed(3),
|
||||
renderValue: (data: Laser1) => data?.lowerTolerance?.toFixed(3),
|
||||
},
|
||||
{
|
||||
name: "Higher Tolerance",
|
||||
unit: "mm",
|
||||
renderValue: (data: PresetData<Laser1>) =>
|
||||
data?.higherTolerance?.toFixed(3),
|
||||
renderValue: (data: Laser1) => data?.higherTolerance?.toFixed(3),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -47,16 +46,16 @@ export function Laser1PresetsPage() {
|
||||
} = useLaser1();
|
||||
|
||||
const applyPreset = (preset: Preset<Laser1>) => {
|
||||
const targetDiameter = preset.data.targetDiameter ?? 1.75;
|
||||
const lowerTolerance = preset.data?.lowerTolerance ?? 0.05;
|
||||
const higherTolerance = preset.data?.higherTolerance ?? 0.05;
|
||||
const targetDiameter = preset?.data?.targetDiameter ?? 1.75;
|
||||
const lowerTolerance = preset?.data?.lowerTolerance ?? 0.05;
|
||||
const higherTolerance = preset?.data?.higherTolerance ?? 0.05;
|
||||
|
||||
setTargetDiameter(targetDiameter);
|
||||
setLowerTolerance(lowerTolerance);
|
||||
setHigherTolerance(higherTolerance);
|
||||
};
|
||||
|
||||
const toPresetData = (s: typeof state): PresetData<Laser1> => ({
|
||||
const toPresetData = (s: typeof state): Laser1 => ({
|
||||
targetDiameter: s?.laser_state.target_diameter,
|
||||
lowerTolerance: s?.laser_state.lower_tolerance,
|
||||
higherTolerance: s?.laser_state.higher_tolerance,
|
||||
|
||||
@@ -42,6 +42,7 @@ export const stateEventDataSchema = z.object({
|
||||
higher_tolerance: z.number(),
|
||||
lower_tolerance: z.number(),
|
||||
target_diameter: z.number(),
|
||||
in_tolerance: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -66,28 +67,17 @@ export type Laser1NamespaceStore = {
|
||||
};
|
||||
|
||||
// Constants for time durations
|
||||
const TWENTY_MILLISECOND = 20;
|
||||
const ONE_SECOND = 1000;
|
||||
const FIVE_SECOND = 5 * ONE_SECOND;
|
||||
const ONE_HOUR = 60 * 60 * ONE_SECOND;
|
||||
const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
const HALF_SECOND = 500;
|
||||
|
||||
const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries({
|
||||
sampleIntervalLong: HALF_SECOND,
|
||||
});
|
||||
const { initialTimeSeries: x_diameter, insert: addXDiameter } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries({ sampleIntervalLong: HALF_SECOND });
|
||||
const { initialTimeSeries: y_diameter, insert: addYDiameter } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
|
||||
createTimeSeries({ sampleIntervalLong: HALF_SECOND });
|
||||
const { initialTimeSeries: roundness, insert: addRoundness } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
{ sampleIntervalLong: HALF_SECOND },
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -102,7 +92,7 @@ export const createLaser1NamespaceStore = (): StoreApi<Laser1NamespaceStore> =>
|
||||
diameter: diameter,
|
||||
x_diameter,
|
||||
y_diameter,
|
||||
roundness: roundness,
|
||||
roundness,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMock1 } from "./useMock";
|
||||
import { mock1 } from "@/machines/properties";
|
||||
|
||||
import { PresetsPage } from "@/components/preset/PresetsPage";
|
||||
import { Preset, PresetData } from "@/lib/preset/preset";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
import { PresetPreviewEntries } from "@/components/preset/PresetPreviewTable";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -15,7 +15,7 @@ const mock1PresetDataSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
type Mock1 = typeof mock1PresetDataSchema;
|
||||
type Mock1 = z.infer<typeof mock1PresetDataSchema>;
|
||||
|
||||
const schemas = new Map([[1, mock1PresetDataSchema]]);
|
||||
|
||||
@@ -23,17 +23,17 @@ const previewEntries: PresetPreviewEntries<Mock1> = [
|
||||
{
|
||||
name: "Frequency 1",
|
||||
unit: "mHz",
|
||||
renderValue: (data: PresetData<Mock1>) => data?.frequency1?.toFixed(3),
|
||||
renderValue: (data: Mock1) => data?.frequency1?.toFixed(3),
|
||||
},
|
||||
{
|
||||
name: "Frequency 2",
|
||||
unit: "mHz",
|
||||
renderValue: (data: PresetData<Mock1>) => data?.frequency2?.toFixed(3),
|
||||
renderValue: (data: Mock1) => data?.frequency2?.toFixed(3),
|
||||
},
|
||||
{
|
||||
name: "Frequency 3",
|
||||
unit: "mHz",
|
||||
renderValue: (data: PresetData<Mock1>) => data?.frequency3?.toFixed(3),
|
||||
renderValue: (data: Mock1) => data?.frequency3?.toFixed(3),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -51,7 +51,7 @@ export function Mock1PresetsPage() {
|
||||
setFrequency3(frequency3);
|
||||
};
|
||||
|
||||
const toPresetData = (s: typeof state): PresetData<Mock1> => ({
|
||||
const toPresetData = (s: typeof state): Mock1 => ({
|
||||
frequency1: s?.frequency1 ?? defaultState?.frequency1,
|
||||
frequency2: s?.frequency2 ?? defaultState?.frequency2,
|
||||
frequency3: s?.frequency3 ?? defaultState?.frequency3,
|
||||
|
||||
@@ -78,18 +78,7 @@ export type Mock1NamespaceStore = {
|
||||
sineWave3: TimeSeries;
|
||||
};
|
||||
|
||||
// Constants for time durations
|
||||
const TWENTY_MILLISECOND = 20;
|
||||
const ONE_SECOND = 1000;
|
||||
const FIVE_SECOND = 5 * ONE_SECOND;
|
||||
const ONE_HOUR = 60 * 60 * ONE_SECOND;
|
||||
|
||||
const { initialTimeSeries: sineWave, insert: addSineWave } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
const { initialTimeSeries: sineWave, insert: addSineWave } = createTimeSeries();
|
||||
|
||||
/**
|
||||
* Factory function to create a new Mock1 namespace store
|
||||
|
||||
@@ -104,7 +104,7 @@ export const winder2: MachineProperties = {
|
||||
};
|
||||
|
||||
export const extruder3: MachineProperties = {
|
||||
name: "Extruder V3",
|
||||
name: "Extruder",
|
||||
version: "V3",
|
||||
slug: "extruder3",
|
||||
icon: "qi:Extruder",
|
||||
@@ -427,6 +427,7 @@ export const aquapath1: MachineProperties = {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const testmachine: MachineProperties = {
|
||||
name: "TestMachine",
|
||||
version: "V1",
|
||||
@@ -461,6 +462,76 @@ export const testmachine: MachineProperties = {
|
||||
},
|
||||
],
|
||||
};
|
||||
export const wagoPower1: MachineProperties = {
|
||||
name: "WAGO Power",
|
||||
version: "V1",
|
||||
slug: "wago_power1",
|
||||
icon: "lu:PlugZap",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x000a,
|
||||
},
|
||||
device_roles: [],
|
||||
};
|
||||
|
||||
export const analogInputTestMachine: MachineProperties = {
|
||||
name: "AnalogTest",
|
||||
version: "V1",
|
||||
slug: "analogInputTestMachine",
|
||||
icon: "lu:Clock",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x0035,
|
||||
},
|
||||
device_roles: [
|
||||
{
|
||||
role: 0,
|
||||
role_label: "Bus Coupler",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0x44c2c52,
|
||||
revision: 0x120000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 1,
|
||||
role_label: "EL3021",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0xbcd3052,
|
||||
revision: 0x140000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const ip20TestMachine: MachineProperties = {
|
||||
name: "IP20 Test",
|
||||
version: "V1",
|
||||
slug: "ip20testmachine",
|
||||
icon: "lu:ToggleLeft",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x0034,
|
||||
},
|
||||
device_roles: [
|
||||
{
|
||||
role: 0,
|
||||
role_label: "IP20-EC-DI8-DO8",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 0x741,
|
||||
product_id: 0x117b6722,
|
||||
revision: 0x1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const machineProperties: MachineProperties[] = [
|
||||
winder2,
|
||||
@@ -469,8 +540,11 @@ export const machineProperties: MachineProperties[] = [
|
||||
laser1,
|
||||
mock1,
|
||||
buffer1,
|
||||
wagoPower1,
|
||||
aquapath1,
|
||||
testmachine,
|
||||
analogInputTestMachine,
|
||||
ip20TestMachine,
|
||||
];
|
||||
|
||||
export const getMachineProperties = (
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import React from "react";
|
||||
import { useWagoPower1 } from "./useWagoPower1";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
|
||||
import { SelectionGroup } from "@/control/SelectionGroup";
|
||||
import { Mode } from "./wagoPower1Namespace";
|
||||
|
||||
export function WagoPower1ControlPage() {
|
||||
const {
|
||||
state,
|
||||
|
||||
voltage,
|
||||
current,
|
||||
|
||||
setMode,
|
||||
|
||||
isLoading,
|
||||
isDisabled,
|
||||
} = useWagoPower1();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
<ControlCard title="Power">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Voltage"
|
||||
unit="V"
|
||||
timeseries={voltage}
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
/>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Current"
|
||||
unit="mA"
|
||||
timeseries={current}
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
/>
|
||||
</ControlCard>
|
||||
<ControlCard title="Mode">
|
||||
<SelectionGroup<Mode>
|
||||
value={state?.mode}
|
||||
disabled={isDisabled}
|
||||
loading={isLoading}
|
||||
onChange={setMode}
|
||||
orientation="vertical"
|
||||
className="grid h-full grid-cols-2 gap-2"
|
||||
options={{
|
||||
Off: {
|
||||
children: "Off",
|
||||
icon: "lu:PowerOff",
|
||||
isActiveClassName: "bg-gray-600",
|
||||
className: "h-full",
|
||||
},
|
||||
On24V: {
|
||||
children: "On",
|
||||
icon: "lu:Power",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Topbar } from "@/components/Topbar";
|
||||
import { wagoPower1SerialRoute } from "@/routes/routes";
|
||||
import React from "react";
|
||||
|
||||
export function WagoPower1Page() {
|
||||
const { serial } = wagoPower1SerialRoute.useParams();
|
||||
return (
|
||||
<Topbar
|
||||
pathname={`/_sidebar/machines/wago_power1/${serial}`}
|
||||
items={[
|
||||
{
|
||||
link: "control",
|
||||
activeLink: "control",
|
||||
title: "Control",
|
||||
icon: "lu:PlugZap",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { wagoPower1 } from "@/machines/properties";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { wagoPower1SerialRoute } from "@/routes/routes";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
Mode,
|
||||
modeSchema,
|
||||
StateEvent,
|
||||
useWagoPower1Namespace,
|
||||
} from "./wagoPower1Namespace";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { produce } from "immer";
|
||||
import z from "zod";
|
||||
import { useMachineMutate } from "@/client/useClient";
|
||||
|
||||
export function useWagoPower1() {
|
||||
const { serial: serialString } = wagoPower1SerialRoute.useParams();
|
||||
|
||||
// Memoize the machine identification to keep it stable between renders
|
||||
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
|
||||
const serial = parseInt(serialString);
|
||||
|
||||
if (isNaN(serial)) {
|
||||
toastError(
|
||||
"Invalid Serial Number",
|
||||
`"${serialString}" is not a valid serial number.`,
|
||||
);
|
||||
|
||||
return {
|
||||
machine_identification: {
|
||||
vendor: 0,
|
||||
machine: 0,
|
||||
},
|
||||
serial: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
machine_identification: wagoPower1.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]);
|
||||
|
||||
const { request: requestModeSet } = useMachineMutate(
|
||||
z.object({ SetMode: modeSchema }),
|
||||
);
|
||||
|
||||
const { state, defaultState, voltage, current } = useWagoPower1Namespace(
|
||||
machineIdentification,
|
||||
);
|
||||
//
|
||||
// Single optimistic state for all state management
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
|
||||
// Update optimistic state when real state changes
|
||||
useEffect(() => {
|
||||
if (state) {
|
||||
stateOptimistic.setReal(state);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// Helper function for optimistic updates using produce
|
||||
const updateStateOptimistically = (
|
||||
producer: (current: StateEvent) => void,
|
||||
serverRequest: () => void,
|
||||
) => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState && !stateOptimistic.isOptimistic) {
|
||||
stateOptimistic.setOptimistic(produce(currentState, producer));
|
||||
}
|
||||
serverRequest();
|
||||
};
|
||||
|
||||
const setMode = (mode: Mode) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.mode = mode;
|
||||
},
|
||||
() =>
|
||||
requestModeSet({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetMode: mode },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Calculate loading states
|
||||
const isLoading = stateOptimistic.isOptimistic;
|
||||
const isDisabled = !stateOptimistic.isInitialized;
|
||||
|
||||
return {
|
||||
state: stateOptimistic.value?.data,
|
||||
defaultState: defaultState?.data,
|
||||
|
||||
current,
|
||||
voltage,
|
||||
|
||||
setMode,
|
||||
|
||||
isLoading,
|
||||
isDisabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { StoreApi } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
EventHandler,
|
||||
eventSchema,
|
||||
Event,
|
||||
handleUnhandledEventError,
|
||||
NamespaceId,
|
||||
createNamespaceHookImplementation,
|
||||
ThrottledStoreUpdater,
|
||||
} from "../../../client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
createTimeSeries,
|
||||
TimeSeries,
|
||||
TimeSeriesValue,
|
||||
} from "@/lib/timeseries";
|
||||
|
||||
export const liveValuesEventSchema = eventSchema(
|
||||
z.object({
|
||||
voltage: z.number(),
|
||||
current: z.number(),
|
||||
}),
|
||||
);
|
||||
|
||||
export type LiveValuesEvent = z.infer<typeof liveValuesEventSchema>;
|
||||
|
||||
export const modeSchema = z.enum(["Off", "On24V"]);
|
||||
export type Mode = z.infer<typeof modeSchema>;
|
||||
|
||||
export const stateEventSchema = eventSchema(
|
||||
z.object({
|
||||
mode: modeSchema,
|
||||
is_default_state: z.boolean(),
|
||||
}),
|
||||
);
|
||||
|
||||
export type StateEvent = z.infer<typeof stateEventSchema>;
|
||||
|
||||
export type WagoPower1NamespaceStore = {
|
||||
state: StateEvent | null;
|
||||
defaultState: StateEvent | null;
|
||||
|
||||
current: TimeSeries;
|
||||
voltage: TimeSeries;
|
||||
};
|
||||
|
||||
const { initialTimeSeries: voltage, insert: addVoltage } = createTimeSeries();
|
||||
const { initialTimeSeries: current, insert: addCurrent } = createTimeSeries();
|
||||
|
||||
const createWagoPower1NamespaceStore = (): StoreApi<WagoPower1NamespaceStore> =>
|
||||
create<WagoPower1NamespaceStore>(() => {
|
||||
return {
|
||||
state: null,
|
||||
defaultState: null,
|
||||
voltage,
|
||||
current,
|
||||
};
|
||||
});
|
||||
|
||||
function wagoPower1MessageHandler(
|
||||
store: StoreApi<WagoPower1NamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<WagoPower1NamespaceStore>,
|
||||
): EventHandler {
|
||||
return (event: Event<any>) => {
|
||||
const eventName = event.name;
|
||||
|
||||
// Helper function to update store through buffer
|
||||
const updateStore = (
|
||||
updater: (state: WagoPower1NamespaceStore) => WagoPower1NamespaceStore,
|
||||
) => {
|
||||
throttledUpdater.updateWith(updater);
|
||||
};
|
||||
|
||||
try {
|
||||
if (eventName === "StateEvent") {
|
||||
const stateEvent = stateEventSchema.parse(event);
|
||||
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
state: stateEvent,
|
||||
// only set default state if is_default_state is true
|
||||
defaultState: stateEvent.data.is_default_state
|
||||
? stateEvent
|
||||
: state.defaultState,
|
||||
}));
|
||||
} else if (eventName === "LiveValuesEvent") {
|
||||
console.log(event);
|
||||
const liveValues = liveValuesEventSchema.parse(event);
|
||||
const { voltage, current } = liveValues.data;
|
||||
const voltageValue: TimeSeriesValue = {
|
||||
value: voltage,
|
||||
timestamp: liveValues.ts,
|
||||
};
|
||||
const currentValue: TimeSeriesValue = {
|
||||
value: current,
|
||||
timestamp: liveValues.ts,
|
||||
};
|
||||
updateStore((store: WagoPower1NamespaceStore) => ({
|
||||
...store,
|
||||
voltage: addVoltage(store.voltage, voltageValue),
|
||||
current: addCurrent(store.current, currentValue),
|
||||
}));
|
||||
} else {
|
||||
handleUnhandledEventError(eventName);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const useWagoPower1NamespaceImplementation =
|
||||
createNamespaceHookImplementation<WagoPower1NamespaceStore>({
|
||||
createStore: createWagoPower1NamespaceStore,
|
||||
createEventHandler: wagoPower1MessageHandler,
|
||||
});
|
||||
|
||||
export function useWagoPower1Namespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): WagoPower1NamespaceStore {
|
||||
// Generate namespace ID from validated machine ID
|
||||
const namespaceId = useMemo<NamespaceId>(
|
||||
() => ({
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
}),
|
||||
[machine_identification_unique],
|
||||
);
|
||||
|
||||
// Use the implementation with validated namespace ID
|
||||
return useWagoPower1NamespaceImplementation(namespaceId);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useWinder2 } from "./useWinder";
|
||||
import { winder2 } from "@/machines/properties";
|
||||
|
||||
import { PresetsPage } from "@/components/preset/PresetsPage";
|
||||
import { Preset, PresetData } from "@/lib/preset/preset";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
import {
|
||||
pullerStateSchema,
|
||||
spoolSpeedControllerStateSchema,
|
||||
@@ -16,19 +16,21 @@ import {
|
||||
|
||||
const winder2PresetDataSchema = z
|
||||
.object({
|
||||
traverse_state: z.object({
|
||||
limit_inner: z.number(),
|
||||
limit_outer: z.number(),
|
||||
step_size: z.number(),
|
||||
padding: z.number(),
|
||||
laserpointer: z.boolean(),
|
||||
}),
|
||||
puller_state: pullerStateSchema,
|
||||
spool_speed_controller_state: spoolSpeedControllerStateSchema,
|
||||
traverse_state: z
|
||||
.object({
|
||||
limit_inner: z.number(),
|
||||
limit_outer: z.number(),
|
||||
step_size: z.number(),
|
||||
padding: z.number(),
|
||||
laserpointer: z.boolean(),
|
||||
})
|
||||
.partial(),
|
||||
puller_state: pullerStateSchema.partial(),
|
||||
spool_speed_controller_state: spoolSpeedControllerStateSchema.partial(),
|
||||
})
|
||||
.deepPartial();
|
||||
.partial();
|
||||
|
||||
type Winder2 = typeof winder2PresetDataSchema;
|
||||
type Winder2 = z.infer<typeof winder2PresetDataSchema>;
|
||||
|
||||
const schemas = new Map([[1, winder2PresetDataSchema]]);
|
||||
|
||||
@@ -36,40 +38,39 @@ const previewEntries: PresetPreviewEntries<Winder2> = [
|
||||
{
|
||||
name: "Inner Traverse Limit",
|
||||
unit: "mm",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
data?.traverse_state?.limit_inner?.toFixed(1) ?? "N/A",
|
||||
renderValue: (data: Winder2) =>
|
||||
data.traverse_state?.limit_inner?.toFixed(1) ?? "N/A",
|
||||
},
|
||||
{
|
||||
name: "Outer Traverse Limit",
|
||||
unit: "mm",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data?.traverse_state?.limit_outer?.toFixed(1) ?? "N/A",
|
||||
},
|
||||
{
|
||||
name: "Traverse Step Size",
|
||||
unit: "mm",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data?.traverse_state?.step_size?.toFixed(1) ?? "N/A",
|
||||
},
|
||||
{
|
||||
name: "Traverse Padding",
|
||||
unit: "mm",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
data?.traverse_state?.padding?.toFixed(1),
|
||||
renderValue: (data: Winder2) => data?.traverse_state?.padding?.toFixed(1),
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "Puller Regulation",
|
||||
renderValue: (data: PresetData<Winder2>) => data?.puller_state?.regulation,
|
||||
renderValue: (data: Winder2) => data?.puller_state?.regulation,
|
||||
},
|
||||
{
|
||||
name: "Puller Direction",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data.puller_state?.forward ? "Forward" : "Backward",
|
||||
},
|
||||
{
|
||||
name: "Puller Gear Ratio",
|
||||
renderValue: (data: PresetData<Winder2>) => {
|
||||
renderValue: (data: Winder2) => {
|
||||
const ratio = data.puller_state?.gear_ratio;
|
||||
if (ratio === "OneToOne") return "1:1";
|
||||
if (ratio === "OneToFive") return "1:5";
|
||||
@@ -80,74 +81,73 @@ const previewEntries: PresetPreviewEntries<Winder2> = [
|
||||
{
|
||||
name: "Puller Target Speed",
|
||||
unit: "m/min",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
data.puller_state?.target_speed?.toFixed(2),
|
||||
renderValue: (data: Winder2) => data.puller_state?.target_speed?.toFixed(2),
|
||||
},
|
||||
{
|
||||
name: "Puller Target Diameter",
|
||||
unit: "mm",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data.puller_state?.target_diameter?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Puller Target Diameter",
|
||||
unit: "mm",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data.puller_state?.target_diameter?.toFixed(1),
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "Spool Regulation",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data.spool_speed_controller_state?.regulation_mode,
|
||||
},
|
||||
{
|
||||
name: "Spool Direction",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data.spool_speed_controller_state?.forward ? "Forward" : "Reverse",
|
||||
},
|
||||
{
|
||||
name: "Spool Min Speed",
|
||||
unit: "rpm",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data.spool_speed_controller_state?.minmax_min_speed?.toFixed(2),
|
||||
},
|
||||
{
|
||||
name: "Spool Max Speed",
|
||||
unit: "rpm",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data.spool_speed_controller_state?.minmax_max_speed?.toFixed(2),
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "Adaptive Spool Tension Target",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data.spool_speed_controller_state?.adaptive_tension_target?.toFixed(2),
|
||||
},
|
||||
{
|
||||
name: "Adaptive Spool Learning Rate",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data.spool_speed_controller_state?.adaptive_radius_learning_rate?.toFixed(
|
||||
2,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Adaptive Spool Max Speed Multiplier",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data.spool_speed_controller_state?.adaptive_max_speed_multiplier?.toFixed(
|
||||
1,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Adaptive Spool Acceleration Factor",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data.spool_speed_controller_state?.adaptive_acceleration_factor?.toFixed(
|
||||
2,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Adaptive Spool Deaccel. Urgency",
|
||||
renderValue: (data: PresetData<Winder2>) =>
|
||||
renderValue: (data: Winder2) =>
|
||||
data.spool_speed_controller_state?.adaptive_deacceleration_urgency_multiplier?.toFixed(
|
||||
1,
|
||||
),
|
||||
@@ -230,11 +230,11 @@ export function Winder2PresetsPage() {
|
||||
// );
|
||||
|
||||
enableTraverseLaserpointer(
|
||||
preset.data.traverse_state?.laserpointer ?? false,
|
||||
preset.data?.traverse_state?.laserpointer ?? false,
|
||||
);
|
||||
};
|
||||
|
||||
const toPresetData = (s: typeof state): PresetData<Winder2> => ({
|
||||
const toPresetData = (s: typeof state): Winder2 => ({
|
||||
traverse_state: {
|
||||
limit_inner: s?.traverse_state?.limit_inner,
|
||||
limit_outer: s?.traverse_state?.limit_outer,
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* @file winder2Namespace.ts
|
||||
* @description TypeScript implementation of Winder2 namespace with Zod schema validation.
|
||||
*/
|
||||
|
||||
import { StoreApi } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { z } from "zod";
|
||||
@@ -218,26 +213,16 @@ export type Winder2NamespaceStore = {
|
||||
spoolProgress: TimeSeries;
|
||||
};
|
||||
|
||||
// Constants for time durations
|
||||
const TWENTY_MILLISECOND = 20;
|
||||
const ONE_SECOND = 1000;
|
||||
const FIVE_SECOND = 5 * ONE_SECOND;
|
||||
const ONE_HOUR = 60 * 60 * ONE_SECOND;
|
||||
|
||||
//Store Factory and Message Handler -> no param, so default values
|
||||
const { initialTimeSeries: spoolProgress, insert: addSpoolProgress } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: traversePosition, insert: addTraversePosition } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: pullerSpeed, insert: addPullerSpeed } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
const { initialTimeSeries: spoolRpm, insert: addSpoolRpm } = createTimeSeries(
|
||||
TWENTY_MILLISECOND,
|
||||
ONE_SECOND,
|
||||
FIVE_SECOND,
|
||||
ONE_HOUR,
|
||||
);
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: spoolRpm, insert: addSpoolRpm } = createTimeSeries();
|
||||
const { initialTimeSeries: tensionArmAngle, insert: addTensionArmAngle } =
|
||||
createTimeSeries(TWENTY_MILLISECOND, ONE_SECOND, FIVE_SECOND, ONE_HOUR);
|
||||
createTimeSeries();
|
||||
|
||||
/**
|
||||
* Factory function to create a new Winder2 namespace store
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import registerListeners from "./helpers/ipc/listeners-register";
|
||||
// "electron-squirrel-startup" seems broken when packaging with vite
|
||||
//import started from "electron-squirrel-startup";
|
||||
import path from "path";
|
||||
import {
|
||||
installExtension,
|
||||
REACT_DEVELOPER_TOOLS,
|
||||
} from "electron-devtools-installer";
|
||||
|
||||
const ARG1 = process.argv[1] ?? "./main.js";
|
||||
const DIR = path.dirname(ARG1);
|
||||
|
||||
// Set consistent app ID for Windows taskbar and GNOME dock integration
|
||||
app.setAppUserModelId("de.qitech.control-electron");
|
||||
|
||||
@@ -29,7 +30,9 @@ if (!gotTheLock) {
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const preload = path.join(__dirname, "preload.js");
|
||||
const preload = path.join(DIR, "preload.js");
|
||||
console.log("Setting preload.js to", preload);
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
@@ -43,7 +46,7 @@ function createWindow() {
|
||||
preload: preload,
|
||||
},
|
||||
// Add icon path for better integration
|
||||
icon: path.join(__dirname, "../assets/icon.png"),
|
||||
icon: path.join(DIR, "icon.png"),
|
||||
// Set window class explicitly for Linux/GNOME integration
|
||||
title: "QiTech Control",
|
||||
});
|
||||
@@ -53,12 +56,11 @@ function createWindow() {
|
||||
|
||||
registerListeners(mainWindow);
|
||||
|
||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile(
|
||||
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
|
||||
);
|
||||
const index = path.join(DIR, "index.html");
|
||||
mainWindow.loadFile(index);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from "react";
|
||||
import { Page } from "@/components/Page";
|
||||
import { useRuntimeMetrics } from "./useRuntimeMetrics";
|
||||
import { SectionTitle } from "@/components/SectionTitle";
|
||||
import {
|
||||
renderValueToReactNode,
|
||||
renderUnitSymbol,
|
||||
getUnitIcon,
|
||||
type Unit,
|
||||
} from "@/control/units";
|
||||
import { Icon } from "@/components/Icon";
|
||||
|
||||
type MetricCardProps = {
|
||||
title: string;
|
||||
value: React.ReactNode;
|
||||
unit?: Unit;
|
||||
};
|
||||
|
||||
function renderMetricWithUnit(
|
||||
value: number | null | undefined,
|
||||
unit: Unit,
|
||||
digits = 1,
|
||||
) {
|
||||
if (value == null) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderValueToReactNode(value, unit, (v) => v.toFixed(digits))}
|
||||
<span className="text-muted-foreground ml-1 text-xs">
|
||||
{renderUnitSymbol(unit)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ title, value, unit }: MetricCardProps) {
|
||||
const iconName = unit ? getUnitIcon(unit) : "lu:StickyNote";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{iconName && (
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 text-slate-500">
|
||||
<Icon name={iconName} className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
<h2 className="text-sm font-medium text-slate-700">{title}</h2>
|
||||
</div>
|
||||
<span className="text-base font-semibold text-slate-900">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricsControlPage() {
|
||||
// Global poller: immediate first fetch, then every 5 seconds
|
||||
const { sample, cpuPercent, preemptionRate } = useRuntimeMetrics(true, 5000);
|
||||
|
||||
const rssMb = sample != null ? sample.rss_bytes / (1024 * 1024) : null;
|
||||
|
||||
// jitter_*_ns is nanoseconds; divide by 1e3 to get microseconds (µs)
|
||||
const jitterMaxUs = sample != null ? sample.jitter_max_ns / 1e3 : null;
|
||||
|
||||
const rxMbit =
|
||||
sample != null ? (sample.rx_rate_bytes_per_sec * 8.0) / 1_000_000.0 : null;
|
||||
const txMbit =
|
||||
sample != null ? (sample.tx_rate_bytes_per_sec * 8.0) / 1_000_000.0 : null;
|
||||
|
||||
const cpuTimeSec = sample != null ? sample.cpu_time_seconds : null;
|
||||
const minorFaults = sample != null ? sample.minor_faults : null;
|
||||
const majorFaults = sample != null ? sample.major_faults : null;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="space-y-6">
|
||||
<SectionTitle title="Backend Metrics" />
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Loop jitter: max, in µs */}
|
||||
<MetricCard
|
||||
title="Max Loop Jitter"
|
||||
unit={"µs"}
|
||||
value={renderMetricWithUnit(jitterMaxUs, "µs")}
|
||||
/>
|
||||
|
||||
{/* CPU usage */}
|
||||
<MetricCard
|
||||
title="CPU Usage"
|
||||
unit={"%"}
|
||||
value={renderMetricWithUnit(cpuPercent, "%")}
|
||||
/>
|
||||
|
||||
{/* CPU time */}
|
||||
<MetricCard
|
||||
title="CPU Time"
|
||||
unit={"s"}
|
||||
value={renderMetricWithUnit(cpuTimeSec, "s")}
|
||||
/>
|
||||
|
||||
{/* Memory usage */}
|
||||
<MetricCard
|
||||
title="Memory Usage"
|
||||
unit={"MiB"}
|
||||
value={renderMetricWithUnit(rssMb, "MiB")}
|
||||
/>
|
||||
|
||||
{/* Total page faults (composite, no single unit) */}
|
||||
<MetricCard
|
||||
title="Page Faults"
|
||||
value={
|
||||
minorFaults != null || majorFaults != null
|
||||
? `${minorFaults ?? "–"} minor per second | ${majorFaults ?? "–"} major`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
|
||||
{/* EtherCAT throughput (RX | TX) */}
|
||||
<MetricCard
|
||||
title="EtherCAT Throughput (RX | TX)"
|
||||
unit={"Mbit/s"}
|
||||
value={
|
||||
rxMbit != null && txMbit != null ? (
|
||||
<span className="flex items-center gap-1">
|
||||
{renderMetricWithUnit(rxMbit, "Mbit/s")}
|
||||
<span className="text-muted-foreground mx-1 text-xs">|</span>
|
||||
{renderMetricWithUnit(txMbit, "Mbit/s")}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Preemptions per second */}
|
||||
<MetricCard
|
||||
title="Preemptions"
|
||||
unit={"/s"}
|
||||
value={
|
||||
preemptionRate != null
|
||||
? renderMetricWithUnit(preemptionRate, "/s")
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from "react";
|
||||
import { Page } from "@/components/Page";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
SyncedFloatingControlPanel,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
} from "@/components/graph";
|
||||
import { useRuntimeMetrics } from "./useRuntimeMetrics";
|
||||
import { useRuntimeMetricSeries } from "./runtimeSeries";
|
||||
import { getUnitIcon, type Unit } from "@/control/units";
|
||||
|
||||
export function MetricsGraphsPage() {
|
||||
// Start / keep the global poller alive
|
||||
useRuntimeMetrics(true, 5000);
|
||||
|
||||
// Shared series (fed by global poller)
|
||||
const series = useRuntimeMetricSeries();
|
||||
|
||||
const syncHook = useGraphSync("runtime-metrics");
|
||||
|
||||
const jitterUnit: Unit = "µs";
|
||||
const cpuUnit: Unit = "%";
|
||||
const memUnit: Unit = "MiB";
|
||||
const ioUnit: Unit = "Mbit/s";
|
||||
const preemptUnit: Unit = "/s";
|
||||
|
||||
const jitterConfig: GraphConfig = {
|
||||
title: "Average Loop Jitter",
|
||||
icon: getUnitIcon(jitterUnit),
|
||||
colors: {
|
||||
primary: "#f97316",
|
||||
grid: "#e2e8f0",
|
||||
background: "#ffffff",
|
||||
},
|
||||
exportFilename: "runtime_jitter",
|
||||
};
|
||||
|
||||
const cpuConfig: GraphConfig = {
|
||||
title: "CPU Usage",
|
||||
icon: getUnitIcon(cpuUnit),
|
||||
colors: {
|
||||
primary: "#3b82f6",
|
||||
grid: "#e2e8f0",
|
||||
background: "#ffffff",
|
||||
},
|
||||
exportFilename: "runtime_cpu",
|
||||
};
|
||||
|
||||
const memConfig: GraphConfig = {
|
||||
title: "Memory Usage",
|
||||
icon: getUnitIcon(memUnit),
|
||||
colors: {
|
||||
primary: "#10b981",
|
||||
grid: "#e2e8f0",
|
||||
background: "#ffffff",
|
||||
},
|
||||
exportFilename: "runtime_memory",
|
||||
};
|
||||
|
||||
const ioConfig: GraphConfig = {
|
||||
title: "EtherCAT Throughput",
|
||||
icon: getUnitIcon(ioUnit),
|
||||
colors: {
|
||||
primary: "#10b981",
|
||||
grid: "#e2e8f0",
|
||||
background: "#ffffff",
|
||||
},
|
||||
exportFilename: "runtime_io",
|
||||
showLegend: true,
|
||||
};
|
||||
|
||||
const preemptConfig: GraphConfig = {
|
||||
title: "Preemptions Per Second",
|
||||
icon: getUnitIcon(preemptUnit),
|
||||
colors: {
|
||||
primary: "#6366f1",
|
||||
grid: "#e2e8f0",
|
||||
background: "#ffffff",
|
||||
},
|
||||
exportFilename: "runtime_preemptions",
|
||||
};
|
||||
|
||||
return (
|
||||
<Page className="pb-27">
|
||||
<div className="flex flex-col gap-4">
|
||||
{series.jitter && (
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: series.jitter,
|
||||
color: "#f97316",
|
||||
}}
|
||||
unit={jitterUnit}
|
||||
renderValue={(v) => v.toFixed(1)}
|
||||
config={jitterConfig}
|
||||
graphId="runtime_jitter"
|
||||
/>
|
||||
)}
|
||||
|
||||
{series.cpu && (
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: series.cpu,
|
||||
color: "#3b82f6",
|
||||
}}
|
||||
unit={cpuUnit}
|
||||
renderValue={(v) => v.toFixed(1)}
|
||||
config={cpuConfig}
|
||||
graphId="runtime_cpu"
|
||||
/>
|
||||
)}
|
||||
|
||||
{series.mem && (
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: series.mem,
|
||||
color: "#10b981",
|
||||
}}
|
||||
unit={memUnit}
|
||||
renderValue={(v) => v.toFixed(1)}
|
||||
config={memConfig}
|
||||
graphId="runtime_memory"
|
||||
/>
|
||||
)}
|
||||
|
||||
{series.rx && series.tx && (
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={[
|
||||
{
|
||||
newData: series.rx,
|
||||
title: "RX",
|
||||
color: "#10b981",
|
||||
},
|
||||
{
|
||||
newData: series.tx,
|
||||
title: "TX",
|
||||
color: "#ef4444",
|
||||
},
|
||||
]}
|
||||
unit={ioUnit}
|
||||
renderValue={(v) => v.toFixed(2)}
|
||||
config={ioConfig}
|
||||
graphId="runtime_io"
|
||||
/>
|
||||
)}
|
||||
|
||||
{series.preempt && (
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: series.preempt,
|
||||
color: "#6366f1",
|
||||
}}
|
||||
unit={preemptUnit}
|
||||
renderValue={(v) => v.toFixed(1)}
|
||||
config={preemptConfig}
|
||||
graphId="runtime_preemptions"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createTimeSeries, TimeSeries } from "@/lib/timeseries";
|
||||
import type { RuntimeMetricsSample } from "./useRuntimeMetrics";
|
||||
|
||||
const ONE_SECOND = 1000;
|
||||
const FIVE_SECOND = 5 * ONE_SECOND;
|
||||
|
||||
// 5 s window, 1 s bucket, 1 h history
|
||||
const { initialTimeSeries: jitterInitial, insert: insertJitter } =
|
||||
createTimeSeries({ sampleIntervalShort: FIVE_SECOND });
|
||||
const { initialTimeSeries: cpuInitial, insert: insertCpu } = createTimeSeries({
|
||||
sampleIntervalShort: FIVE_SECOND,
|
||||
});
|
||||
const { initialTimeSeries: memInitial, insert: insertMem } = createTimeSeries({
|
||||
sampleIntervalShort: FIVE_SECOND,
|
||||
});
|
||||
const { initialTimeSeries: rxInitial, insert: insertRx } = createTimeSeries({
|
||||
sampleIntervalShort: FIVE_SECOND,
|
||||
});
|
||||
const { initialTimeSeries: txInitial, insert: insertTx } = createTimeSeries({
|
||||
sampleIntervalShort: FIVE_SECOND,
|
||||
});
|
||||
const { initialTimeSeries: preemptInitial, insert: insertPreempt } =
|
||||
createTimeSeries({ sampleIntervalShort: FIVE_SECOND });
|
||||
|
||||
export type RuntimeSeriesState = {
|
||||
jitter: TimeSeries | null;
|
||||
cpu: TimeSeries | null;
|
||||
mem: TimeSeries | null;
|
||||
rx: TimeSeries | null;
|
||||
tx: TimeSeries | null;
|
||||
preempt: TimeSeries | null;
|
||||
};
|
||||
|
||||
let baseTimestampMs: number | null = null;
|
||||
|
||||
let currentSeries: RuntimeSeriesState = {
|
||||
jitter: jitterInitial,
|
||||
cpu: cpuInitial,
|
||||
mem: memInitial,
|
||||
rx: rxInitial,
|
||||
tx: txInitial,
|
||||
preempt: preemptInitial,
|
||||
};
|
||||
|
||||
const subscribers = new Set<(s: RuntimeSeriesState) => void>();
|
||||
|
||||
function notifySubscribers() {
|
||||
for (const cb of subscribers) cb(currentSeries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push one sample into the shared time-series.
|
||||
* Always records; UI toggles only control what is displayed.
|
||||
*/
|
||||
export function pushRuntimeSample(
|
||||
sample: RuntimeMetricsSample,
|
||||
cpuPercent: number | null,
|
||||
preemptionRate: number | null,
|
||||
) {
|
||||
if (baseTimestampMs === null) {
|
||||
baseTimestampMs = sample.timestamp_ms;
|
||||
}
|
||||
const t = sample.timestamp_ms - baseTimestampMs; // ms since first sample
|
||||
|
||||
let { jitter, cpu, mem, rx, tx, preempt } = currentSeries;
|
||||
|
||||
// avg jitter in µs
|
||||
jitter = insertJitter(jitter ?? jitterInitial, {
|
||||
value: sample.jitter_avg_ns / 1e3,
|
||||
timestamp: t,
|
||||
});
|
||||
|
||||
if (cpuPercent != null) {
|
||||
cpu = insertCpu(cpu ?? cpuInitial, {
|
||||
value: cpuPercent,
|
||||
timestamp: t,
|
||||
});
|
||||
}
|
||||
|
||||
const rssMb = sample.rss_bytes / (1024 * 1024);
|
||||
mem = insertMem(mem ?? memInitial, {
|
||||
value: rssMb,
|
||||
timestamp: t,
|
||||
});
|
||||
|
||||
const rxMbit = (sample.rx_rate_bytes_per_sec * 8.0) / 1_000_000.0;
|
||||
const txMbit = (sample.tx_rate_bytes_per_sec * 8.0) / 1_000_000.0;
|
||||
|
||||
rx = insertRx(rx ?? rxInitial, {
|
||||
value: rxMbit,
|
||||
timestamp: t,
|
||||
});
|
||||
tx = insertTx(tx ?? txInitial, {
|
||||
value: txMbit,
|
||||
timestamp: t,
|
||||
});
|
||||
|
||||
if (preemptionRate != null) {
|
||||
preempt = insertPreempt(preempt ?? preemptInitial, {
|
||||
value: preemptionRate,
|
||||
timestamp: t,
|
||||
});
|
||||
}
|
||||
|
||||
currentSeries = { jitter, cpu, mem, rx, tx, preempt };
|
||||
notifySubscribers();
|
||||
}
|
||||
/**
|
||||
* Read shared time-series; history survives unmount/remount.
|
||||
*/
|
||||
export function useRuntimeMetricSeries(): RuntimeSeriesState {
|
||||
const [state, setState] = useState<RuntimeSeriesState>(currentSeries);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = (s: RuntimeSeriesState) => setState(s);
|
||||
subscribers.add(cb);
|
||||
cb(currentSeries);
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { pushRuntimeSample } from "./runtimeSeries";
|
||||
|
||||
// Talk directly to the Rust API on port 3001.
|
||||
// If you later proxy via Vite/Electron, make this configurable.
|
||||
const API_BASE = "http://127.0.0.1:3001";
|
||||
const RUNTIME_URL = `${API_BASE}/api/v1/metrics/runtime/latest`;
|
||||
|
||||
export type RuntimeMetricsSample = {
|
||||
timestamp_ms: number;
|
||||
rss_bytes: number;
|
||||
cpu_time_seconds: number;
|
||||
minor_faults: number;
|
||||
major_faults: number;
|
||||
jitter_min_ns: number;
|
||||
jitter_avg_ns: number;
|
||||
jitter_max_ns: number;
|
||||
rx_rate_bytes_per_sec: number;
|
||||
tx_rate_bytes_per_sec: number;
|
||||
rt_loop_cpu_time_seconds?: number | null;
|
||||
rt_nr_switches: number | null;
|
||||
rt_nr_voluntary_switches: number | null;
|
||||
rt_nr_involuntary_switches: number | null;
|
||||
};
|
||||
|
||||
type MetricsState = {
|
||||
sample: RuntimeMetricsSample | null;
|
||||
cpuPercent: number | null;
|
||||
preemptionRate: number | null;
|
||||
};
|
||||
|
||||
// ---- shared module-level state (global poller) ----
|
||||
|
||||
let currentState: MetricsState = {
|
||||
sample: null,
|
||||
cpuPercent: null,
|
||||
preemptionRate: null,
|
||||
};
|
||||
|
||||
let lastSample: RuntimeMetricsSample | null = null;
|
||||
let pollIntervalMs = 5000;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const subscribers = new Set<(s: MetricsState) => void>();
|
||||
|
||||
function notifySubscribers() {
|
||||
for (const cb of subscribers) cb(currentState);
|
||||
}
|
||||
|
||||
async function fetchOnce() {
|
||||
try {
|
||||
const res = await fetch(RUNTIME_URL);
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
"Runtime metrics fetch failed:",
|
||||
res.status,
|
||||
res.statusText,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as RuntimeMetricsSample | null;
|
||||
if (!json) {
|
||||
// backend returned null (no sample yet)
|
||||
return;
|
||||
}
|
||||
|
||||
const current = json;
|
||||
const last = lastSample;
|
||||
|
||||
let cpuPercent: number | null = null;
|
||||
let preemptionRate: number | null = null;
|
||||
|
||||
if (last) {
|
||||
const dtWallSec = (current.timestamp_ms - last.timestamp_ms) / 1000.0;
|
||||
if (dtWallSec > 0) {
|
||||
const dtCpu = current.cpu_time_seconds - last.cpu_time_seconds;
|
||||
cpuPercent = (dtCpu / dtWallSec) * 100.0;
|
||||
|
||||
if (
|
||||
current.rt_nr_involuntary_switches != null &&
|
||||
last.rt_nr_involuntary_switches != null
|
||||
) {
|
||||
const dtPreempt =
|
||||
current.rt_nr_involuntary_switches -
|
||||
last.rt_nr_involuntary_switches;
|
||||
preemptionRate = dtPreempt / dtWallSec;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastSample = current;
|
||||
|
||||
currentState = {
|
||||
sample: current,
|
||||
cpuPercent,
|
||||
preemptionRate,
|
||||
};
|
||||
|
||||
// feed into shared time-series for graphs
|
||||
pushRuntimeSample(current, cpuPercent, preemptionRate);
|
||||
|
||||
notifySubscribers();
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch runtime metrics:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer != null) return;
|
||||
void fetchOnce();
|
||||
pollTimer = setInterval(fetchOnce, pollIntervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared runtime metrics hook.
|
||||
*
|
||||
* - One global poller per app (module-level).
|
||||
* - Polling continues even if no components are mounted.
|
||||
* - Components just subscribe to the latest state.
|
||||
*/
|
||||
export function useRuntimeMetrics(
|
||||
enabled: boolean,
|
||||
intervalMs: number = 5000,
|
||||
): MetricsState {
|
||||
const [state, setState] = useState<MetricsState>(currentState);
|
||||
|
||||
// Subscribe to shared state
|
||||
useEffect(() => {
|
||||
const cb = (s: MetricsState) => setState(s);
|
||||
subscribers.add(cb);
|
||||
cb(currentState);
|
||||
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
// do NOT stop polling here; recording continues in background
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Control poller configuration
|
||||
useEffect(() => {
|
||||
pollIntervalMs = intervalMs;
|
||||
|
||||
if (enabled) {
|
||||
if (pollTimer != null) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
startPolling();
|
||||
}
|
||||
}, [enabled, intervalMs]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export default useRuntimeMetrics;
|
||||
@@ -1,3 +1,4 @@
|
||||
import exposeContexts from "./helpers/ipc/context-exposer";
|
||||
|
||||
console.log("preload.js loaded");
|
||||
exposeContexts();
|
||||
@@ -4,7 +4,6 @@ import React from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ChooseVersionPage } from "@/setup/ChooseVersionPage";
|
||||
import { fallback, zodValidator } from "@tanstack/zod-adapter";
|
||||
import { githubSourceSchema } from "@/setup/GithubSourceDialog";
|
||||
import { SidebarLayout } from "@/components/SidebarLayout";
|
||||
import { SetupPage } from "@/setup/SetupPage";
|
||||
@@ -42,6 +41,7 @@ import { Buffer1SettingsPage } from "@/machines/buffer/buffer1/Buffer1Settings";
|
||||
import { Laser1ControlPage } from "@/machines/laser/laser1/Laser1ControlPage";
|
||||
import { Laser1GraphsPage } from "@/machines/laser/laser1/Laser1Graph";
|
||||
import { Laser1Page } from "@/machines/laser/laser1/Laser1Page";
|
||||
import { Laser1PresetsPage } from "@/machines/laser/laser1/Laser1PresetsPage";
|
||||
|
||||
import { Mock1ControlPage } from "@/machines/mock/mock1/Mock1ControlPage";
|
||||
import { Mock1GraphPage } from "@/machines/mock/mock1/Mock1Graph";
|
||||
@@ -55,7 +55,17 @@ import { Aquapath1GraphPage } from "@/machines/aquapath/aquapath1/Aquapath1Graph
|
||||
|
||||
import { TestMachinePage } from "@/machines/testmachine/TestMachinePage";
|
||||
import { TestMachineControlPage } from "@/machines/testmachine/TestMachineControlPage";
|
||||
import { Laser1PresetsPage } from "@/machines/laser/laser1/Laser1PresetsPage";
|
||||
|
||||
import { AnalogInputTestMachine } from "@/machines/analoginputtestmachine/AnalogInputTestMachinePage";
|
||||
import { AnalogInputTestMachineControl } from "@/machines/analoginputtestmachine/AnalogInputTestMachineControlPage";
|
||||
import { IP20TestMachinePage } from "@/machines/ip20testmachine/IP20TestMachinePage";
|
||||
import { IP20TestMachineControlPage } from "@/machines/ip20testmachine/IP20TestMachineControlPage";
|
||||
|
||||
import { MetricsGraphsPage } from "@/metrics/MetricsGraphsPage";
|
||||
import { MetricsControlPage } from "@/metrics/MetricsControlPage";
|
||||
|
||||
import { WagoPower1Page } from "@/machines/wago_power/wago_power1/WagoPower1Page";
|
||||
import { WagoPower1ControlPage } from "@/machines/wago_power/wago_power1/WagoPower1ControlPage";
|
||||
|
||||
// make a route tree like this
|
||||
// _mainNavigation/machines/winder2/$serial/control
|
||||
@@ -77,6 +87,30 @@ export const testMachineControlRoute = createRoute({
|
||||
component: () => <TestMachineControlPage />,
|
||||
});
|
||||
|
||||
export const analogInputTestMachineSerialRoute = createRoute({
|
||||
getParentRoute: () => machinesRoute,
|
||||
path: "analogInputTestMachine/$serial",
|
||||
component: () => <AnalogInputTestMachine />,
|
||||
});
|
||||
|
||||
export const analogInputTestMachineControlRoute = createRoute({
|
||||
getParentRoute: () => analogInputTestMachineSerialRoute,
|
||||
path: "control",
|
||||
component: () => <AnalogInputTestMachineControl />,
|
||||
});
|
||||
|
||||
export const ip20TestMachineSerialRoute = createRoute({
|
||||
getParentRoute: () => machinesRoute,
|
||||
path: "ip20testmachine/$serial",
|
||||
component: () => <IP20TestMachinePage />,
|
||||
});
|
||||
|
||||
export const ip20TestMachineControlRoute = createRoute({
|
||||
getParentRoute: () => ip20TestMachineSerialRoute,
|
||||
path: "control",
|
||||
component: () => <IP20TestMachineControlPage />,
|
||||
});
|
||||
|
||||
export const sidebarRoute = createRoute({
|
||||
getParentRoute: () => RootRoute,
|
||||
path: "_sidebar",
|
||||
@@ -286,6 +320,18 @@ export const buffer1SettingsRoute = createRoute({
|
||||
component: () => <Buffer1SettingsPage />,
|
||||
});
|
||||
|
||||
export const wagoPower1SerialRoute = createRoute({
|
||||
getParentRoute: () => machinesRoute,
|
||||
path: "wago_power1/$serial",
|
||||
component: () => <WagoPower1Page />,
|
||||
});
|
||||
|
||||
export const wagoPower1ControlRoute = createRoute({
|
||||
getParentRoute: () => wagoPower1SerialRoute,
|
||||
path: "control",
|
||||
component: () => <WagoPower1ControlPage />,
|
||||
});
|
||||
|
||||
export const setupRoute = createRoute({
|
||||
getParentRoute: () => sidebarRoute,
|
||||
path: "setup",
|
||||
@@ -321,12 +367,22 @@ export const updateChooseVersionRoute = createRoute({
|
||||
path: "choose-version",
|
||||
component: () => <ChooseVersionPage />,
|
||||
});
|
||||
export const metricsRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "metrics",
|
||||
component: () => (
|
||||
<>
|
||||
<MetricsControlPage />
|
||||
<MetricsGraphsPage />
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
export const versionSearchSchema = z
|
||||
.object({
|
||||
branch: fallback(z.string().optional(), undefined),
|
||||
commit: fallback(z.string().optional(), undefined),
|
||||
tag: fallback(z.string().optional(), undefined),
|
||||
branch: z.string().optional(),
|
||||
commit: z.string().optional(),
|
||||
tag: z.string().optional(),
|
||||
})
|
||||
.merge(githubSourceSchema)
|
||||
.refine(
|
||||
@@ -348,14 +404,14 @@ export const updateChangelogRoute = createRoute({
|
||||
getParentRoute: () => updateRoute,
|
||||
path: "changelog",
|
||||
component: () => <ChangelogPage />,
|
||||
validateSearch: zodValidator(versionSearchSchema),
|
||||
validateSearch: versionSearchSchema,
|
||||
});
|
||||
|
||||
export const updateExecuteRoute = createRoute({
|
||||
getParentRoute: () => updateRoute,
|
||||
path: "execute",
|
||||
component: () => <UpdateExecutePage />,
|
||||
validateSearch: zodValidator(versionSearchSchema),
|
||||
validateSearch: versionSearchSchema,
|
||||
});
|
||||
|
||||
export const rootTree = RootRoute.addChildren([
|
||||
@@ -369,8 +425,8 @@ export const rootTree = RootRoute.addChildren([
|
||||
updateExecuteRoute,
|
||||
]),
|
||||
troubleshootRoute,
|
||||
metricsRoute,
|
||||
]),
|
||||
|
||||
machinesRoute.addChildren([
|
||||
laser1SerialRoute.addChildren([
|
||||
laser1ControlRoute,
|
||||
@@ -379,6 +435,12 @@ export const rootTree = RootRoute.addChildren([
|
||||
]),
|
||||
testMachineSerialRoute.addChildren([testMachineControlRoute]),
|
||||
|
||||
analogInputTestMachineSerialRoute.addChildren([
|
||||
analogInputTestMachineControlRoute,
|
||||
]),
|
||||
|
||||
ip20TestMachineSerialRoute.addChildren([ip20TestMachineControlRoute]),
|
||||
|
||||
aquapath1SerialRoute.addChildren([
|
||||
aquapath1ControlRoute,
|
||||
aquapath1GraphRoute,
|
||||
@@ -415,6 +477,8 @@ export const rootTree = RootRoute.addChildren([
|
||||
mock1PresetsRoute,
|
||||
]),
|
||||
|
||||
wagoPower1SerialRoute.addChildren([wagoPower1ControlRoute]),
|
||||
|
||||
buffer1SerialRoute.addChildren([buffer1ControlRoute]),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
machineProperties,
|
||||
VENDOR_QITECH,
|
||||
} from "@/machines/properties";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useFormValues } from "@/lib/useFormValues";
|
||||
import { validateU16 } from "@/lib/validation";
|
||||
import { DeviceRoleComponent } from "@/components/DeviceRole";
|
||||
import { Alert } from "@/components/Alert";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -45,6 +44,7 @@ import { Icon } from "@/components/Icon";
|
||||
import { toast } from "sonner";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { EthercatDevicesEventData } from "@/client/mainNamespace";
|
||||
import { TouchNumpad } from "@/components/touch/TouchNumpad";
|
||||
|
||||
type Device = NonNullable<EthercatDevicesEventData["Done"]>["devices"][number];
|
||||
|
||||
@@ -53,9 +53,15 @@ type Props = {
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
machine: z.string().superRefine(validateU16),
|
||||
serial: z.string().superRefine(validateU16),
|
||||
role: z.string().superRefine(validateU16),
|
||||
machine: z
|
||||
.string()
|
||||
.refine((v) => parseInt(v) < 0xffff, { error: "Value too big" }),
|
||||
serial: z
|
||||
.string()
|
||||
.refine((v) => parseInt(v) < 0xffff, { error: "Value too big" }),
|
||||
role: z
|
||||
.string()
|
||||
.refine((v) => parseInt(v) < 0xffff, { error: "Value too big" }),
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
@@ -63,15 +69,22 @@ type FormSchema = z.infer<typeof formSchema>;
|
||||
export function DeviceEepromDialog({ device }: Props) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const key = useMemo(() => Math.random(), [open]);
|
||||
const onClose = () => setOpen(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
// Prevent closing via Escape to keep numpad open while interacting
|
||||
modal
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Icon name="lu:Pencil" />
|
||||
Assign
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DeviceEeepromDialogContent device={device} key={key} setOpen={setOpen} />
|
||||
<DeviceEeepromDialogContent device={device} key={key} setOpen={onClose} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -79,11 +92,17 @@ export function DeviceEepromDialog({ device }: Props) {
|
||||
// (Empty comment block removed)
|
||||
type ContentProps = {
|
||||
device: Device;
|
||||
setOpen: (open: boolean) => void;
|
||||
setOpen: () => void;
|
||||
};
|
||||
|
||||
export function DeviceEeepromDialogContent({ device, setOpen }: ContentProps) {
|
||||
const client = useClient();
|
||||
const serialInputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const numpadRef = useRef<HTMLDivElement>(null);
|
||||
const [numpadOpen, setNumpadOpen] = useState(false);
|
||||
const [numpadPosition, setNumpadPosition] = useState({ left: 0, top: 0 });
|
||||
const serialContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const form = useForm<FormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -126,7 +145,7 @@ export function DeviceEeepromDialogContent({ device, setOpen }: ContentProps) {
|
||||
Machine assignment written successfully.
|
||||
</Toast>,
|
||||
);
|
||||
setOpen(false);
|
||||
setOpen();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -166,104 +185,328 @@ export function DeviceEeepromDialogContent({ device, setOpen }: ContentProps) {
|
||||
}
|
||||
}, [filteredAllowedDevices]);
|
||||
|
||||
// Position numpad once when it opens
|
||||
useEffect(() => {
|
||||
if (!numpadOpen || !dialogRef.current) return;
|
||||
const rect = dialogRef.current.getBoundingClientRect();
|
||||
setNumpadPosition({
|
||||
left: rect.right + 20,
|
||||
top: rect.top + rect.height / 2,
|
||||
});
|
||||
}, [numpadOpen]);
|
||||
|
||||
// Close numpad when clicking outside input/numpad
|
||||
useEffect(() => {
|
||||
if (!numpadOpen) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
const insideSerial = serialContainerRef.current?.contains(target);
|
||||
const insideNumpad = numpadRef.current?.contains(target);
|
||||
if (!insideSerial && !insideNumpad) {
|
||||
setNumpadOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
};
|
||||
}, [numpadOpen]);
|
||||
|
||||
// Keep focus on input field when numpad is opened
|
||||
useEffect(() => {
|
||||
if (numpadOpen && serialInputRef.current) {
|
||||
// Use setTimeout to ensure this runs after any other focus changes
|
||||
setTimeout(() => {
|
||||
if (serialInputRef.current) {
|
||||
serialInputRef.current.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, [numpadOpen]);
|
||||
|
||||
// Numpad handlers for serial input
|
||||
const numpadHandlers = useMemo(() => {
|
||||
const ensureFocus = () => {
|
||||
if (
|
||||
serialInputRef.current &&
|
||||
document.activeElement !== serialInputRef.current
|
||||
) {
|
||||
serialInputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const updateCursorPosition = (position: number) => {
|
||||
setTimeout(() => {
|
||||
if (serialInputRef.current) {
|
||||
serialInputRef.current.setSelectionRange(position, position);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const getCurrentValue = () => {
|
||||
return form.getValues("serial") || "";
|
||||
};
|
||||
|
||||
return {
|
||||
appendDigit: (digit: string) => {
|
||||
if (!serialInputRef.current) return;
|
||||
|
||||
ensureFocus();
|
||||
const input = serialInputRef.current;
|
||||
const start = input.selectionStart || 0;
|
||||
const end = input.selectionEnd || 0;
|
||||
const currentValue = getCurrentValue();
|
||||
const newValue =
|
||||
currentValue.slice(0, start) + digit + currentValue.slice(end);
|
||||
|
||||
form.setValue("serial", newValue, { shouldValidate: true });
|
||||
updateCursorPosition(start + 1);
|
||||
},
|
||||
|
||||
addDecimal: () => {
|
||||
// Not needed for serial (U16 integer), but keeping for consistency
|
||||
// Could be used for other numeric inputs if needed
|
||||
},
|
||||
|
||||
deleteChar: () => {
|
||||
if (!serialInputRef.current) return;
|
||||
|
||||
ensureFocus();
|
||||
const input = serialInputRef.current;
|
||||
const start = input.selectionStart || 0;
|
||||
const end = input.selectionEnd || 0;
|
||||
const currentValue = getCurrentValue();
|
||||
|
||||
let newValue: string;
|
||||
let newPosition: number;
|
||||
|
||||
if (start !== end) {
|
||||
// Delete selection
|
||||
newValue = currentValue.slice(0, start) + currentValue.slice(end);
|
||||
newPosition = start;
|
||||
} else if (start > 0) {
|
||||
// Backspace
|
||||
newValue =
|
||||
currentValue.slice(0, start - 1) + currentValue.slice(start);
|
||||
newPosition = start - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue("serial", newValue, { shouldValidate: true });
|
||||
updateCursorPosition(newPosition);
|
||||
},
|
||||
|
||||
toggleSign: () => {
|
||||
// Not needed for U16 (unsigned), but keeping for consistency
|
||||
},
|
||||
|
||||
moveCursorLeft: () => {
|
||||
if (!serialInputRef.current) return;
|
||||
|
||||
ensureFocus();
|
||||
const currentPos = serialInputRef.current.selectionStart || 0;
|
||||
if (currentPos > 0) {
|
||||
serialInputRef.current.setSelectionRange(
|
||||
currentPos - 1,
|
||||
currentPos - 1,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
moveCursorRight: () => {
|
||||
if (!serialInputRef.current) return;
|
||||
|
||||
ensureFocus();
|
||||
const currentPos = serialInputRef.current.selectionStart || 0;
|
||||
const currentValue = getCurrentValue();
|
||||
if (currentPos < currentValue.length) {
|
||||
serialInputRef.current.setSelectionRange(
|
||||
currentPos + 1,
|
||||
currentPos + 1,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Machine Assignment</DialogTitle>
|
||||
<p>
|
||||
for {device.name}
|
||||
<Hex value={device.configured_address} />
|
||||
</p>
|
||||
<DialogDescription>
|
||||
To assign the device to a machine, select the machine, serial number &
|
||||
device role.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Separator />
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* machine type dropdown */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="machine"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Maschine</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="w-min">
|
||||
<SelectValue placeholder="Machine" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machineProperties.map((machine) => (
|
||||
<SelectItem
|
||||
key={machine.machine_identification.machine}
|
||||
value={machine.machine_identification.machine.toString()}
|
||||
>
|
||||
{machine.name} {machine.version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
<>
|
||||
<DialogContent
|
||||
ref={dialogRef}
|
||||
// Keep dialog open on any outside interaction; closing is manual via controls
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Machine Assignment</DialogTitle>
|
||||
<p>
|
||||
for {device.name}
|
||||
<Hex value={device.configured_address} />
|
||||
</p>
|
||||
<DialogDescription>
|
||||
To assign the device to a machine, select the machine, serial number
|
||||
& device role.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Separator />
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* machine type dropdown */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="machine"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Maschine</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="w-min">
|
||||
<SelectValue placeholder="Machine" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machineProperties.map((machine) => (
|
||||
<SelectItem
|
||||
key={machine.machine_identification.machine}
|
||||
value={machine.machine_identification.machine.toString()}
|
||||
>
|
||||
{machine.name} {machine.version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Serial Number */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serial"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Serial</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
ref={serialContainerRef}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
ref={(e) => {
|
||||
field.ref(e);
|
||||
serialInputRef.current = e;
|
||||
}}
|
||||
placeholder="1234"
|
||||
onFocus={() => setNumpadOpen(true)}
|
||||
onClick={() => setNumpadOpen(true)}
|
||||
onBlur={(event) => {
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (
|
||||
serialContainerRef.current?.contains(next) ||
|
||||
numpadRef.current?.contains(next)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setNumpadOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Serial number of the machine.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Device Role */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
disabled={!machinePreset}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Device Role</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field}>
|
||||
<SelectTrigger className="w-min">
|
||||
<SelectValue placeholder="Device Role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machinePreset?.device_roles.map((device_role, i) => (
|
||||
<SelectItem
|
||||
key={device_role.role}
|
||||
value={device_role.role.toString()}
|
||||
disabled={!filteredAllowedDevices[i]}
|
||||
>
|
||||
<DeviceRoleComponent device_role={device_role} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Separator />
|
||||
<Button type="submit" disabled={!form.formState.isValid}>
|
||||
<Icon name="lu:Save" /> Write
|
||||
</Button>
|
||||
<Alert title="Restart mandatory" variant="info">
|
||||
The device must be restarted for the changes to take effect
|
||||
</Alert>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
{/* Numpad as separate window right of dialog */}
|
||||
{numpadOpen && (
|
||||
<div
|
||||
ref={numpadRef}
|
||||
data-numpad
|
||||
className="fixed z-[100] w-auto rounded-md border border-neutral-200 bg-white p-4 shadow-md dark:border-neutral-800 dark:bg-neutral-950"
|
||||
style={{
|
||||
left: `${numpadPosition.left}px`,
|
||||
top: `${numpadPosition.top}px`,
|
||||
transform: "translateY(-50%)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
tabIndex={-1}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent clicks on numpad from closing the dialog and stealing focus from input
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Ensure input field keeps focus
|
||||
if (serialInputRef.current) {
|
||||
serialInputRef.current.focus();
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Prevent clicks on numpad from closing the dialog
|
||||
e.stopPropagation();
|
||||
// Ensure input field keeps focus
|
||||
if (serialInputRef.current) {
|
||||
serialInputRef.current.focus();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Prevent Escape or other keys from bubbling and closing the dialog
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<TouchNumpad
|
||||
onDigit={numpadHandlers.appendDigit}
|
||||
onDelete={numpadHandlers.deleteChar}
|
||||
onCursorLeft={numpadHandlers.moveCursorLeft}
|
||||
onCursorRight={numpadHandlers.moveCursorRight}
|
||||
/>
|
||||
{/* Serial Number */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serial"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Serial</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="1234" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Serial number of the machine.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Device Role */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
disabled={!machinePreset}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Device Role</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field}>
|
||||
<SelectTrigger className="w-min">
|
||||
<SelectValue placeholder="Device Role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machinePreset?.device_roles.map((device_role, i) => (
|
||||
<SelectItem
|
||||
key={device_role.role}
|
||||
value={device_role.role.toString()}
|
||||
disabled={!filteredAllowedDevices[i]}
|
||||
>
|
||||
<DeviceRoleComponent device_role={device_role} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Separator />
|
||||
<Button type="submit" disabled={!form.formState.isValid}>
|
||||
<Icon name="lu:Save" /> Write
|
||||
</Button>
|
||||
<Alert title="Restart mandatory" variant="info">
|
||||
The device must be restarted for the changes to take effect
|
||||
</Alert>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { TouchButton } from "@/components/touch/TouchButton";
|
||||
import { fallback } from "@tanstack/zod-adapter";
|
||||
|
||||
type Props = {
|
||||
value: GithubSource;
|
||||
@@ -32,9 +31,9 @@ type Props = {
|
||||
};
|
||||
|
||||
export const githubSourceSchema = z.object({
|
||||
githubRepoOwner: fallback(z.string(), "qitechgmbh"),
|
||||
githubRepoName: fallback(z.string(), "control"),
|
||||
githubToken: fallback(z.string().optional().nullable(), undefined),
|
||||
githubRepoOwner: z.string().catch("qitechgmbh"),
|
||||
githubRepoName: z.string().catch("control"),
|
||||
githubToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export type GithubSource = z.infer<typeof githubSourceSchema>;
|
||||
@@ -91,7 +90,7 @@ export function GithubSourceDialogContent({
|
||||
const onSubmit = (values: GithubSource) => {
|
||||
onChange({
|
||||
...values,
|
||||
githubToken: values.githubToken === "" ? null : values.githubToken,
|
||||
githubToken: values.githubToken === "" ? undefined : values.githubToken,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useMainNamespace } from "@/client/mainNamespace";
|
||||
import { useLaser1Namespace } from "@/machines/laser/laser1/laser1Namespace";
|
||||
import { laser1 } from "@/machines/properties";
|
||||
import type { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { Icon } from "@/components/Icon";
|
||||
|
||||
/**
|
||||
* Global manager that toasts laser StateEvent changes.
|
||||
* If machineIdentification is omitted it will attempt to discover the laser via the main namespace.
|
||||
*
|
||||
*/
|
||||
export function GlobalLaserToastManager({
|
||||
machineIdentification,
|
||||
}: {
|
||||
machineIdentification?: MachineIdentificationUnique;
|
||||
}) {
|
||||
const main = useMainNamespace();
|
||||
const [discovered, setDiscovered] =
|
||||
useState<MachineIdentificationUnique | null>(null);
|
||||
|
||||
// Prefer explicit prop, otherwise use discovered id
|
||||
const effectiveId = machineIdentification ?? discovered;
|
||||
|
||||
// Attempt to discover the single laser machine from the main namespace
|
||||
useEffect(() => {
|
||||
if (machineIdentification) return;
|
||||
const machinesEvent = (main as any)?.machines;
|
||||
if (!machinesEvent) {
|
||||
setDiscovered(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Defensive shapes: try common structures
|
||||
let list: any[] = [];
|
||||
if (Array.isArray(machinesEvent?.data?.machines))
|
||||
list = machinesEvent.data.machines;
|
||||
else if (Array.isArray(machinesEvent?.data)) list = machinesEvent.data;
|
||||
else if (Array.isArray(machinesEvent?.machines))
|
||||
list = machinesEvent.machines;
|
||||
else if (machinesEvent && typeof machinesEvent === "object") {
|
||||
list = Object.values(machinesEvent).filter(
|
||||
(v) => v && typeof v === "object",
|
||||
);
|
||||
}
|
||||
|
||||
const match = list.find((entry) => {
|
||||
const mi =
|
||||
entry?.machine_identification ||
|
||||
entry?.machine_identification_unique?.machine_identification ||
|
||||
entry?.device_machine_identification?.machine_identification;
|
||||
if (!mi) return false;
|
||||
return (
|
||||
mi.vendor === (laser1.machine_identification as any).vendor &&
|
||||
mi.machine === (laser1.machine_identification as any).machine
|
||||
);
|
||||
});
|
||||
|
||||
if (match) {
|
||||
const serial =
|
||||
match?.serial ??
|
||||
match?.machine_identification_unique?.serial ??
|
||||
match?.device_machine_identification?.machine_identification_unique
|
||||
?.serial ??
|
||||
null;
|
||||
if (serial != null && !Number.isNaN(Number(serial))) {
|
||||
setDiscovered({
|
||||
machine_identification: laser1.machine_identification,
|
||||
serial: Number(serial),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDiscovered(null);
|
||||
}, [main, machineIdentification]);
|
||||
|
||||
return effectiveId ? (
|
||||
<LaserToastWatcher machineIdentification={effectiveId} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches StateEvent for the laser namespace and shows toasts for non-default states.
|
||||
* Component is non-visual.
|
||||
*/
|
||||
function LaserToastWatcher({
|
||||
machineIdentification,
|
||||
}: {
|
||||
machineIdentification: MachineIdentificationUnique;
|
||||
}) {
|
||||
const { state } = useLaser1Namespace(machineIdentification);
|
||||
|
||||
// Deduplicate toasts by event timestamp
|
||||
const lastToastTs = useRef<number | string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
|
||||
const eventTs = (state as any)?.ts ?? null;
|
||||
const inTolerance = (state as any)?.data?.laser_state.in_tolerance;
|
||||
const isDefault = !!(state as any)?.data?.is_default_state;
|
||||
const toastId = eventTs?.toString() ?? String(Date.now());
|
||||
|
||||
if (isDefault) return;
|
||||
|
||||
// Only show toast if laser is out of tolerance and not already shown for this timestamp
|
||||
if (!inTolerance && lastToastTs.current !== eventTs) {
|
||||
lastToastTs.current = eventTs;
|
||||
// Sonner toast call
|
||||
toast(
|
||||
<div className="flex w-100 flex-col gap-3 rounded-xl border border-red-400 bg-red-600 p-4 text-white shadow-xl backdrop-blur-sm transition-all duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
name="lu:TriangleAlert"
|
||||
className="h-5 w-5 text-yellow-200"
|
||||
/>
|
||||
<strong className="text-lg font-semibold tracking-wide">
|
||||
Warning
|
||||
</strong>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-2xl font-bold text-white/80 hover:bg-red-500 hover:text-white focus:ring-2 focus:ring-white/30 focus:outline-none"
|
||||
onClick={() => {
|
||||
toast.dismiss(toastId);
|
||||
lastToastTs.current = null;
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-base leading-snug text-red-50">
|
||||
Laser diameter is <strong>out of tolerance</strong>.<br />
|
||||
Please check the filament immediately.
|
||||
</p>
|
||||
</div>,
|
||||
{
|
||||
id: toastId,
|
||||
duration: Infinity,
|
||||
position: "top-center",
|
||||
style: {
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
boxShadow: "none",
|
||||
border: "none",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if (inTolerance) {
|
||||
lastToastTs.current = null;
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -30,6 +30,12 @@ export function SetupPage() {
|
||||
title: "Troubleshoot",
|
||||
icon: "lu:MessageSquareWarning",
|
||||
},
|
||||
{
|
||||
link: "metrics",
|
||||
activeLink: "metrics",
|
||||
title: "Metrics",
|
||||
icon: "lu:Activity",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ export function TroubleshootPage() {
|
||||
// Get backend logs for display
|
||||
const backendLogs = getLogsBySource("qitech-control-server");
|
||||
const logLines = backendLogs.map((log) => log.raw);
|
||||
console.log(backendLogs);
|
||||
const handleRebootHmi = async () => {
|
||||
setIsRebootLoading(true);
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { builtinModules } from "node:module";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { ConfigEnv, Plugin, UserConfig } from "vite";
|
||||
import pkg from "./package.json";
|
||||
|
||||
export const builtins = [
|
||||
"electron",
|
||||
...builtinModules.map((m) => [m, `node:${m}`]).flat(),
|
||||
];
|
||||
|
||||
export const external = [
|
||||
...builtins,
|
||||
...Object.keys(
|
||||
"dependencies" in pkg ? (pkg.dependencies as Record<string, unknown>) : {},
|
||||
),
|
||||
];
|
||||
|
||||
export function getBuildConfig(env: ConfigEnv<"build">): UserConfig {
|
||||
const { root, mode, command } = env;
|
||||
|
||||
return {
|
||||
root,
|
||||
mode,
|
||||
build: {
|
||||
// Prevent multiple builds from interfering with each other.
|
||||
emptyOutDir: false,
|
||||
// 🚧 Multiple builds may conflict.
|
||||
outDir: ".vite/build",
|
||||
watch: command === "serve" ? {} : null,
|
||||
minify: command === "build",
|
||||
},
|
||||
clearScreen: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefineKeys(names: string[]) {
|
||||
const define: { [name: string]: VitePluginRuntimeKeys } = {};
|
||||
|
||||
return names.reduce((acc, name) => {
|
||||
const NAME = name.toUpperCase();
|
||||
const keys: VitePluginRuntimeKeys = {
|
||||
VITE_DEV_SERVER_URL: `${NAME}_VITE_DEV_SERVER_URL`,
|
||||
VITE_NAME: `${NAME}_VITE_NAME`,
|
||||
};
|
||||
|
||||
return { ...acc, [name]: keys };
|
||||
}, define);
|
||||
}
|
||||
|
||||
export function getBuildDefine(env: ConfigEnv<"build">) {
|
||||
const { command, forgeConfig } = env;
|
||||
const names = forgeConfig.renderer
|
||||
.filter(({ name }) => name != null)
|
||||
.map(({ name }) => name!);
|
||||
const defineKeys = getDefineKeys(names);
|
||||
const define = Object.entries(defineKeys).reduce(
|
||||
(acc, [name, keys]) => {
|
||||
const { VITE_DEV_SERVER_URL, VITE_NAME } = keys;
|
||||
const def = {
|
||||
[VITE_DEV_SERVER_URL]:
|
||||
command === "serve"
|
||||
? JSON.stringify(process.env[VITE_DEV_SERVER_URL])
|
||||
: undefined,
|
||||
[VITE_NAME]: JSON.stringify(name),
|
||||
};
|
||||
return { ...acc, ...def };
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
return define;
|
||||
}
|
||||
|
||||
export function pluginExposeRenderer(name: string): Plugin {
|
||||
const { VITE_DEV_SERVER_URL } = getDefineKeys([name])[name];
|
||||
|
||||
return {
|
||||
name: "@electron-forge/plugin-vite:expose-renderer",
|
||||
configureServer(server) {
|
||||
process.viteDevServers ??= {};
|
||||
// Expose server for preload scripts hot reload.
|
||||
process.viteDevServers[name] = server;
|
||||
|
||||
server.httpServer?.once("listening", () => {
|
||||
const addressInfo = server.httpServer!.address() as AddressInfo;
|
||||
// Expose env constant for main process use.
|
||||
process.env[VITE_DEV_SERVER_URL] =
|
||||
`http://localhost:${addressInfo?.port}`;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginHotRestart(command: "reload" | "restart"): Plugin {
|
||||
return {
|
||||
name: "@electron-forge/plugin-vite:hot-restart",
|
||||
closeBundle() {
|
||||
if (command === "reload") {
|
||||
for (const server of Object.values(process.viteDevServers)) {
|
||||
// Preload scripts hot reload.
|
||||
server.ws.send({ type: "full-reload" });
|
||||
}
|
||||
} else {
|
||||
// Main process hot restart.
|
||||
// https://github.com/electron/forge/blob/v7.2.0/packages/api/core/src/api/start.ts#L216-L223
|
||||
process.stdin.emit("data", "rs");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import electron from "vite-plugin-electron/simple";
|
||||
import path from "path";
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default {
|
||||
publicDir: path.resolve(__dirname, "public"),
|
||||
resolve: {
|
||||
preserveSymlinks: true,
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@root": path.resolve(__dirname, ".."),
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
electron({
|
||||
main: {
|
||||
entry: "src/main.ts",
|
||||
onstart: ({ startup }) => {
|
||||
startup(["./dist-electron/main.js"]);
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
// Shortcut of `build.rollupOptions.input`
|
||||
input: "src/preload.ts",
|
||||
},
|
||||
// Optional: Use Node.js API in the Renderer process
|
||||
renderer: {},
|
||||
}),
|
||||
react({
|
||||
babel: {
|
||||
plugins: [["babel-plugin-react-compiler"]],
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { ConfigEnv, UserConfig } from "vite";
|
||||
import { defineConfig, mergeConfig } from "vite";
|
||||
import {
|
||||
getBuildConfig,
|
||||
getBuildDefine,
|
||||
external,
|
||||
pluginHotRestart,
|
||||
} from "./vite.base.config";
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig((env) => {
|
||||
const forgeEnv = env as ConfigEnv<"build">;
|
||||
const { forgeConfigSelf } = forgeEnv;
|
||||
const define = getBuildDefine(forgeEnv);
|
||||
const config: UserConfig = {
|
||||
build: {
|
||||
lib: {
|
||||
entry: forgeConfigSelf.entry!,
|
||||
fileName: () => "[name].js",
|
||||
formats: ["cjs"],
|
||||
},
|
||||
rollupOptions: {
|
||||
external,
|
||||
},
|
||||
},
|
||||
plugins: [pluginHotRestart("restart")],
|
||||
define,
|
||||
resolve: {
|
||||
// Load the Node.js entry.
|
||||
mainFields: ["module", "jsnext:main", "jsnext"],
|
||||
},
|
||||
};
|
||||
|
||||
return mergeConfig(getBuildConfig(forgeEnv), config);
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { ConfigEnv, UserConfig } from "vite";
|
||||
import { defineConfig, mergeConfig } from "vite";
|
||||
import { getBuildConfig, external, pluginHotRestart } from "./vite.base.config";
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig((env) => {
|
||||
const forgeEnv = env as ConfigEnv<"build">;
|
||||
const { forgeConfigSelf } = forgeEnv;
|
||||
const config: UserConfig = {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external,
|
||||
// Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`.
|
||||
input: forgeConfigSelf.entry!,
|
||||
output: {
|
||||
format: "cjs",
|
||||
// It should not be split chunks.
|
||||
inlineDynamicImports: true,
|
||||
entryFileNames: "[name].js",
|
||||
chunkFileNames: "[name].js",
|
||||
assetFileNames: "[name].[ext]",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [pluginHotRestart("reload")],
|
||||
};
|
||||
|
||||
return mergeConfig(getBuildConfig(forgeEnv), config);
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import path from "path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import type { ConfigEnv, UserConfig } from "vite";
|
||||
import { defineConfig } from "vite";
|
||||
import { pluginExposeRenderer } from "./vite.base.config";
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig((env) => {
|
||||
const forgeEnv = env as ConfigEnv<"renderer">;
|
||||
const { root, mode, forgeConfigSelf } = forgeEnv;
|
||||
const name = forgeConfigSelf.name ?? "";
|
||||
|
||||
return {
|
||||
root,
|
||||
mode,
|
||||
base: "./",
|
||||
publicDir: path.resolve(__dirname, "public"),
|
||||
build: {
|
||||
outDir: `.vite/renderer/${name}`,
|
||||
},
|
||||
plugins: [
|
||||
pluginExposeRenderer(name),
|
||||
react({
|
||||
babel: {
|
||||
plugins: [["babel-plugin-react-compiler"]],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
preserveSymlinks: true,
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
} as UserConfig;
|
||||
});
|
||||
@@ -14,7 +14,7 @@ units = { path = "../units" }
|
||||
bitvec = { version = "1.0.1", features = ["alloc"] }
|
||||
smol = "2.0.2"
|
||||
rand = "0.9.2"
|
||||
tracing = "0.1.41"
|
||||
tracing = "0.1.44"
|
||||
|
||||
[dev-dependencies]
|
||||
approx = "0.5.1"
|
||||
@@ -328,6 +328,8 @@ impl TxPdoObject for Standard22ByteMdp600Input {
|
||||
self.status.overrun_error = bits[6];
|
||||
// Bit7 is reserved/unused
|
||||
self.length = bits[8..8 + 8].load_le::<u8>();
|
||||
// the serial bytes start at bit 16, then we calculate the offset of when the serial bytes buffer is over, which for Standard22ByteMdp600Input is 22bytes
|
||||
// to get the size in bits we do 22 * 8 -> 176bits
|
||||
let serial_bytes = bits[16..(16 + 22 * 8_usize)].chunks_exact(8);
|
||||
for (i, val) in serial_bytes.enumerate() {
|
||||
self.data[i] = val.load_le();
|
||||
|
||||
@@ -54,6 +54,7 @@ use ethercrab::{MainDevice, SubDeviceIdentity};
|
||||
use smol::lock::RwLock;
|
||||
use std::{any::Any, fmt::Debug, sync::Arc};
|
||||
use wago_750_354::{WAGO_750_354_IDENTITY_A, Wago750_354};
|
||||
use wago_modules::ip20_ec_di8_do8::{IP20_EC_DI8_DO8_IDENTITY, IP20EcDi8Do8};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Module {
|
||||
@@ -214,6 +215,7 @@ pub fn device_from_subdevice_identity_tuple(
|
||||
) -> Result<Arc<RwLock<dyn EthercatDevice>>, anyhow::Error> {
|
||||
match subdevice_identity_tuple {
|
||||
WAGO_750_354_IDENTITY_A => Ok(Arc::new(RwLock::new(Wago750_354::new()))),
|
||||
IP20_EC_DI8_DO8_IDENTITY => Ok(Arc::new(RwLock::new(IP20EcDi8Do8::new()))),
|
||||
EK1100_IDENTITY_A => Ok(Arc::new(RwLock::new(EK1100::new()))),
|
||||
EL1002_IDENTITY_A => Ok(Arc::new(RwLock::new(EL1002::new()))),
|
||||
EL1008_IDENTITY_A => Ok(Arc::new(RwLock::new(EL1008::new()))),
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::{
|
||||
DynamicEthercatDevice, Module,
|
||||
wago_modules::{
|
||||
wago_750_501::{WAGO_750_501_MODULE_IDENT, WAGO_750_501_PRODUCT_ID},
|
||||
wago_750_530::{WAGO_750_530_MODULE_IDENT, WAGO_750_530_PRODUCT_ID},
|
||||
wago_750_652::{WAGO_750_652_MODULE_IDENT, WAGO_750_652_PRODUCT_ID},
|
||||
wago_750_1506::{WAGO_750_1506_MODULE_IDENT, WAGO_750_1506_PRODUCT_ID},
|
||||
},
|
||||
@@ -231,6 +232,10 @@ impl Wago750_354 {
|
||||
module.has_tx = false;
|
||||
module.has_rx = true;
|
||||
}
|
||||
WAGO_750_530_PRODUCT_ID => {
|
||||
module.has_tx = false;
|
||||
module.has_rx = true;
|
||||
}
|
||||
WAGO_750_652_PRODUCT_ID => {
|
||||
module.has_tx = true;
|
||||
module.has_rx = true;
|
||||
@@ -259,6 +264,8 @@ impl Wago750_354 {
|
||||
let _ = self.get_pdo_offsets(device, false).await;
|
||||
});
|
||||
|
||||
println!("{:?}\n\n{:?}", self.tx_offsets, self.rx_offsets);
|
||||
|
||||
for module in self.slots {
|
||||
match module {
|
||||
Some(m) => {
|
||||
@@ -270,6 +277,9 @@ impl Wago750_354 {
|
||||
WAGO_750_501_MODULE_IDENT => {
|
||||
Arc::new(RwLock::new(wago_750_501::Wago750_501::new()))
|
||||
}
|
||||
WAGO_750_530_MODULE_IDENT => {
|
||||
Arc::new(RwLock::new(wago_750_530::Wago750_530::new()))
|
||||
}
|
||||
WAGO_750_1506_MODULE_IDENT => {
|
||||
Arc::new(RwLock::new(wago_750_1506::Wago750_1506::new()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
use super::*;
|
||||
use crate::devices::{
|
||||
EthercatDevice, EthercatDeviceProcessing, EthercatDeviceUsed, NewEthercatDevice,
|
||||
SubDeviceIdentityTuple,
|
||||
};
|
||||
use crate::{
|
||||
devices::{DynamicEthercatDevice, Module},
|
||||
helpers::ethercrab_types::EthercrabSubDevicePreoperational,
|
||||
io::{
|
||||
digital_input::{DigitalInputDevice, DigitalInputInput},
|
||||
digital_output::{DigitalOutputDevice, DigitalOutputOutput},
|
||||
},
|
||||
};
|
||||
use anyhow::Error;
|
||||
use smol::lock::RwLock;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MODULE_COUNT_INDEX: (u16, u8) = (0xf050, 0x00);
|
||||
const TX_MAPPING_INDEX: (u16, u8) = (0x1c13, 0x00);
|
||||
const RX_MAPPING_INDEX: (u16, u8) = (0x1c12, 0x00);
|
||||
|
||||
/// Digital output port enumeration for the 8 outputs
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum IP20EcDi8Do8OutputPort {
|
||||
DO1,
|
||||
DO2,
|
||||
DO3,
|
||||
DO4,
|
||||
DO5,
|
||||
DO6,
|
||||
DO7,
|
||||
DO8,
|
||||
}
|
||||
|
||||
impl From<IP20EcDi8Do8OutputPort> for usize {
|
||||
fn from(value: IP20EcDi8Do8OutputPort) -> Self {
|
||||
match value {
|
||||
IP20EcDi8Do8OutputPort::DO1 => 0,
|
||||
IP20EcDi8Do8OutputPort::DO2 => 1,
|
||||
IP20EcDi8Do8OutputPort::DO3 => 2,
|
||||
IP20EcDi8Do8OutputPort::DO4 => 3,
|
||||
IP20EcDi8Do8OutputPort::DO5 => 4,
|
||||
IP20EcDi8Do8OutputPort::DO6 => 5,
|
||||
IP20EcDi8Do8OutputPort::DO7 => 6,
|
||||
IP20EcDi8Do8OutputPort::DO8 => 7,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Digital input port enumeration for the 8 inputs
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum IP20EcDi8Do8InputPort {
|
||||
DI1,
|
||||
DI2,
|
||||
DI3,
|
||||
DI4,
|
||||
DI5,
|
||||
DI6,
|
||||
DI7,
|
||||
DI8,
|
||||
}
|
||||
|
||||
impl From<IP20EcDi8Do8InputPort> for usize {
|
||||
fn from(value: IP20EcDi8Do8InputPort) -> Self {
|
||||
match value {
|
||||
IP20EcDi8Do8InputPort::DI1 => 0,
|
||||
IP20EcDi8Do8InputPort::DI2 => 1,
|
||||
IP20EcDi8Do8InputPort::DI3 => 2,
|
||||
IP20EcDi8Do8InputPort::DI4 => 3,
|
||||
IP20EcDi8Do8InputPort::DI5 => 4,
|
||||
IP20EcDi8Do8InputPort::DI6 => 5,
|
||||
IP20EcDi8Do8InputPort::DI7 => 6,
|
||||
IP20EcDi8Do8InputPort::DI8 => 7,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RX PDO structure (Master → Device, Outputs)
|
||||
/// Mapped at 0x7000:01-08, 8 bits total
|
||||
#[derive(Clone, Default)]
|
||||
pub struct IP20EcDi8Do8RxPdo {
|
||||
pub do1: bool,
|
||||
pub do2: bool,
|
||||
pub do3: bool,
|
||||
pub do4: bool,
|
||||
pub do5: bool,
|
||||
pub do6: bool,
|
||||
pub do7: bool,
|
||||
pub do8: bool,
|
||||
}
|
||||
|
||||
/// TX PDO structure (Device → Master, Inputs)
|
||||
/// Mapped at 0x6000:01-08, 8 bits total
|
||||
#[derive(Clone, Default)]
|
||||
pub struct IP20EcDi8Do8TxPdo {
|
||||
pub di1: bool,
|
||||
pub di2: bool,
|
||||
pub di3: bool,
|
||||
pub di4: bool,
|
||||
pub di5: bool,
|
||||
pub di6: bool,
|
||||
pub di7: bool,
|
||||
pub di8: bool,
|
||||
}
|
||||
|
||||
/// Wago 0x741:0x117b6722 bus coupler with digital I/O
|
||||
/// This device has 8x Digital Input and 8x Digital Output terminals
|
||||
pub struct IP20EcDi8Do8 {
|
||||
is_used: bool,
|
||||
pub slots: [Option<Module>; 64],
|
||||
pub slot_devices: [Option<Arc<RwLock<dyn DynamicEthercatDevice>>>; 64],
|
||||
pub dev_count: usize,
|
||||
pub module_count: usize,
|
||||
rx_offsets: Vec<usize>,
|
||||
tx_offsets: Vec<usize>,
|
||||
tx_size: usize,
|
||||
rx_size: usize,
|
||||
rx_pdo: IP20EcDi8Do8RxPdo,
|
||||
tx_pdo: IP20EcDi8Do8TxPdo,
|
||||
}
|
||||
|
||||
impl EthercatDevice for IP20EcDi8Do8 {
|
||||
fn input(
|
||||
&mut self,
|
||||
input: &bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
// Read the 8 digital inputs (bits 0-7)
|
||||
if input.len() >= 8 {
|
||||
self.tx_pdo.di1 = input[0];
|
||||
self.tx_pdo.di2 = input[1];
|
||||
self.tx_pdo.di3 = input[2];
|
||||
self.tx_pdo.di4 = input[3];
|
||||
self.tx_pdo.di5 = input[4];
|
||||
self.tx_pdo.di6 = input[5];
|
||||
self.tx_pdo.di7 = input[6];
|
||||
self.tx_pdo.di8 = input[7];
|
||||
}
|
||||
|
||||
// Handle slot devices if any
|
||||
for slot_device in &mut self.slot_devices {
|
||||
match slot_device {
|
||||
Some(device) => {
|
||||
let mut d = device.write_blocking();
|
||||
let _ = d.input(input);
|
||||
drop(d);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn input_len(&self) -> usize {
|
||||
if self.tx_size > 0 {
|
||||
self.tx_size
|
||||
} else {
|
||||
8 // 8 digital inputs
|
||||
}
|
||||
}
|
||||
|
||||
fn output(
|
||||
&self,
|
||||
output: &mut bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
// Write the 8 digital outputs (bits 0-7)
|
||||
if output.len() >= 8 {
|
||||
output.set(0, self.rx_pdo.do1);
|
||||
output.set(1, self.rx_pdo.do2);
|
||||
output.set(2, self.rx_pdo.do3);
|
||||
output.set(3, self.rx_pdo.do4);
|
||||
output.set(4, self.rx_pdo.do5);
|
||||
output.set(5, self.rx_pdo.do6);
|
||||
output.set(6, self.rx_pdo.do7);
|
||||
output.set(7, self.rx_pdo.do8);
|
||||
}
|
||||
|
||||
// Handle slot devices if any
|
||||
for slot_device in &self.slot_devices {
|
||||
match slot_device {
|
||||
Some(device) => {
|
||||
let d = device.read_blocking();
|
||||
let _ = d.output(output);
|
||||
drop(d);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn output_len(&self) -> usize {
|
||||
if self.rx_size > 0 {
|
||||
self.rx_size
|
||||
} else {
|
||||
8 // 8 digital outputs
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn is_module(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn get_module(&self) -> Option<Module> {
|
||||
None
|
||||
}
|
||||
|
||||
fn set_module(&mut self, module: Module) {
|
||||
self.slots[self.module_count] = Some(module);
|
||||
self.module_count += 1;
|
||||
self.tx_size += module.tx_offset;
|
||||
self.rx_size += module.rx_offset;
|
||||
}
|
||||
}
|
||||
|
||||
impl EthercatDeviceUsed for IP20EcDi8Do8 {
|
||||
fn is_used(&self) -> bool {
|
||||
self.is_used
|
||||
}
|
||||
|
||||
fn set_used(&mut self, used: bool) {
|
||||
self.is_used = used;
|
||||
}
|
||||
}
|
||||
|
||||
impl EthercatDeviceProcessing for IP20EcDi8Do8 {}
|
||||
|
||||
impl NewEthercatDevice for IP20EcDi8Do8 {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
is_used: false,
|
||||
slots: [const { None }; 64],
|
||||
slot_devices: [const { None }; 64],
|
||||
module_count: 0,
|
||||
dev_count: 0,
|
||||
tx_size: 0,
|
||||
rx_size: 0,
|
||||
rx_offsets: vec![],
|
||||
tx_offsets: vec![],
|
||||
rx_pdo: IP20EcDi8Do8RxPdo::default(),
|
||||
tx_pdo: IP20EcDi8Do8TxPdo::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for IP20EcDi8Do8 {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "WagoMaster_0x741_0x117b6722")
|
||||
}
|
||||
}
|
||||
|
||||
impl DigitalInputDevice<IP20EcDi8Do8InputPort> for IP20EcDi8Do8 {
|
||||
fn get_input(&self, port: IP20EcDi8Do8InputPort) -> Result<DigitalInputInput, anyhow::Error> {
|
||||
Ok(DigitalInputInput {
|
||||
value: match port {
|
||||
IP20EcDi8Do8InputPort::DI1 => self.tx_pdo.di1,
|
||||
IP20EcDi8Do8InputPort::DI2 => self.tx_pdo.di2,
|
||||
IP20EcDi8Do8InputPort::DI3 => self.tx_pdo.di3,
|
||||
IP20EcDi8Do8InputPort::DI4 => self.tx_pdo.di4,
|
||||
IP20EcDi8Do8InputPort::DI5 => self.tx_pdo.di5,
|
||||
IP20EcDi8Do8InputPort::DI6 => self.tx_pdo.di6,
|
||||
IP20EcDi8Do8InputPort::DI7 => self.tx_pdo.di7,
|
||||
IP20EcDi8Do8InputPort::DI8 => self.tx_pdo.di8,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl DigitalOutputDevice<IP20EcDi8Do8OutputPort> for IP20EcDi8Do8 {
|
||||
fn set_output(&mut self, port: IP20EcDi8Do8OutputPort, value: DigitalOutputOutput) {
|
||||
let output_value: bool = value.into();
|
||||
match port {
|
||||
IP20EcDi8Do8OutputPort::DO1 => self.rx_pdo.do1 = output_value,
|
||||
IP20EcDi8Do8OutputPort::DO2 => self.rx_pdo.do2 = output_value,
|
||||
IP20EcDi8Do8OutputPort::DO3 => self.rx_pdo.do3 = output_value,
|
||||
IP20EcDi8Do8OutputPort::DO4 => self.rx_pdo.do4 = output_value,
|
||||
IP20EcDi8Do8OutputPort::DO5 => self.rx_pdo.do5 = output_value,
|
||||
IP20EcDi8Do8OutputPort::DO6 => self.rx_pdo.do6 = output_value,
|
||||
IP20EcDi8Do8OutputPort::DO7 => self.rx_pdo.do7 = output_value,
|
||||
IP20EcDi8Do8OutputPort::DO8 => self.rx_pdo.do8 = output_value,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_output(&self, port: IP20EcDi8Do8OutputPort) -> DigitalOutputOutput {
|
||||
let current_value = match port {
|
||||
IP20EcDi8Do8OutputPort::DO1 => self.rx_pdo.do1,
|
||||
IP20EcDi8Do8OutputPort::DO2 => self.rx_pdo.do2,
|
||||
IP20EcDi8Do8OutputPort::DO3 => self.rx_pdo.do3,
|
||||
IP20EcDi8Do8OutputPort::DO4 => self.rx_pdo.do4,
|
||||
IP20EcDi8Do8OutputPort::DO5 => self.rx_pdo.do5,
|
||||
IP20EcDi8Do8OutputPort::DO6 => self.rx_pdo.do6,
|
||||
IP20EcDi8Do8OutputPort::DO7 => self.rx_pdo.do7,
|
||||
IP20EcDi8Do8OutputPort::DO8 => self.rx_pdo.do8,
|
||||
};
|
||||
DigitalOutputOutput(current_value)
|
||||
}
|
||||
}
|
||||
|
||||
impl IP20EcDi8Do8 {
|
||||
pub async fn get_pdo_offsets<'a>(
|
||||
&mut self,
|
||||
device: &EthercrabSubDevicePreoperational<'a>,
|
||||
get_tx: bool,
|
||||
) -> Result<(), Error> {
|
||||
let mut vec: Vec<usize> = vec![];
|
||||
let mut bit_offset = 0;
|
||||
let start_subindex = 0x1;
|
||||
let index = match get_tx {
|
||||
true => (TX_MAPPING_INDEX.0, TX_MAPPING_INDEX.1),
|
||||
false => (RX_MAPPING_INDEX.0, RX_MAPPING_INDEX.1),
|
||||
};
|
||||
let count = device.sdo_read::<u8>(index.0, index.1).await?;
|
||||
|
||||
for i in 0..count {
|
||||
vec.push(bit_offset);
|
||||
let pdo_index = device.sdo_read(index.0, start_subindex + i).await?;
|
||||
if pdo_index != 0 {
|
||||
let pdo_map_count = device.sdo_read::<u8>(pdo_index, 0).await?;
|
||||
// Iterate over every PDO Mapped entry and add it to the cumulative bit offset
|
||||
for j in 0..pdo_map_count {
|
||||
let pdo_mapping: u32 = device.sdo_read(pdo_index, 1 + j).await?;
|
||||
// We only need / Want the bit len, which we extract with a bitmask extracting the lsb
|
||||
let bit_length = (pdo_mapping & 0xFF) as u8;
|
||||
bit_offset += bit_length as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if get_tx {
|
||||
self.tx_offsets = vec;
|
||||
} else {
|
||||
self.rx_offsets = vec;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_module_count<'a>(
|
||||
device: &EthercrabSubDevicePreoperational<'a>,
|
||||
) -> Result<usize, Error> {
|
||||
match device
|
||||
.sdo_read::<u8>(MODULE_COUNT_INDEX.0, MODULE_COUNT_INDEX.1)
|
||||
.await
|
||||
{
|
||||
Ok(value) => Ok(value as usize),
|
||||
Err(_e) => Ok(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_modules<'a>(
|
||||
device: &EthercrabSubDevicePreoperational<'a>,
|
||||
module_count: usize,
|
||||
) -> Result<Vec<crate::devices::Module>, Error> {
|
||||
const MODULES_START_ADDR: u16 = 0x9000;
|
||||
const MODULE_IDENT_SUBINDEX: u8 = 0x0a;
|
||||
let mut modules: Vec<Module> = vec![];
|
||||
|
||||
for i in 0..module_count {
|
||||
let module_addr = MODULES_START_ADDR + (i * 0x10) as u16;
|
||||
let ident_iom = device
|
||||
.sdo_read::<u32>(module_addr, MODULE_IDENT_SUBINDEX)
|
||||
.await?;
|
||||
|
||||
// For Wago the IOM will be the product ID
|
||||
let mut module = Module {
|
||||
slot: i as u16,
|
||||
belongs_to_addr: device.configured_address(),
|
||||
vendor_id: device.identity().vendor_id,
|
||||
product_id: ident_iom,
|
||||
has_tx: false,
|
||||
has_rx: false,
|
||||
tx_offset: 0,
|
||||
rx_offset: 0,
|
||||
};
|
||||
|
||||
match ident_iom {
|
||||
// Add Module idents here when discovered
|
||||
wago_750_1506::WAGO_750_1506_PRODUCT_ID => {
|
||||
module.has_tx = true;
|
||||
module.has_rx = true;
|
||||
}
|
||||
wago_750_501::WAGO_750_501_PRODUCT_ID => {
|
||||
module.has_tx = false;
|
||||
module.has_rx = true;
|
||||
}
|
||||
wago_750_652::WAGO_750_652_PRODUCT_ID => {
|
||||
module.has_tx = true;
|
||||
module.has_rx = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
modules.push(module);
|
||||
}
|
||||
Ok(modules)
|
||||
}
|
||||
|
||||
/// Call after all modules have been added
|
||||
pub fn init_slot_modules<'a>(&mut self, device: &EthercrabSubDevicePreoperational<'a>) {
|
||||
// Already initialized
|
||||
if self.dev_count != 0 {
|
||||
return;
|
||||
}
|
||||
let mut tx_index = 1;
|
||||
let mut rx_index = 1;
|
||||
|
||||
smol::block_on(async {
|
||||
let _ = self.get_pdo_offsets(device, true).await;
|
||||
let _ = self.get_pdo_offsets(device, false).await;
|
||||
});
|
||||
|
||||
for module in self.slots {
|
||||
match module {
|
||||
Some(m) => {
|
||||
// Map ModuleIdent's to Terminals
|
||||
let dev: Arc<RwLock<dyn DynamicEthercatDevice>> =
|
||||
match (m.vendor_id, m.product_id) {
|
||||
wago_750_501::WAGO_750_501_MODULE_IDENT => {
|
||||
Arc::new(RwLock::new(wago_750_501::Wago750_501::new()))
|
||||
}
|
||||
wago_750_1506::WAGO_750_1506_MODULE_IDENT => {
|
||||
Arc::new(RwLock::new(wago_750_1506::Wago750_1506::new()))
|
||||
}
|
||||
wago_750_652::WAGO_750_652_MODULE_IDENT => {
|
||||
Arc::new(RwLock::new(wago_750_652::Wago750_652::new()))
|
||||
}
|
||||
_ => {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let tx_pdo_offset = self.tx_offsets.get(tx_index as usize);
|
||||
if m.has_tx {
|
||||
tx_index += 1;
|
||||
}
|
||||
|
||||
let rx_pdo_offset = self.rx_offsets.get(rx_index as usize);
|
||||
if m.has_rx {
|
||||
rx_index += 1;
|
||||
}
|
||||
|
||||
let mut dev_guard = dev.write_blocking();
|
||||
match tx_pdo_offset {
|
||||
Some(offset) => dev_guard.set_tx_offset(*offset),
|
||||
None => (),
|
||||
}
|
||||
|
||||
match rx_pdo_offset {
|
||||
Some(offset) => dev_guard.set_rx_offset(*offset),
|
||||
None => (),
|
||||
}
|
||||
drop(dev_guard);
|
||||
self.slot_devices[self.dev_count] = Some(dev);
|
||||
self.dev_count += 1;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize_modules<'a>(
|
||||
device: &EthercrabSubDevicePreoperational<'a>,
|
||||
) -> Result<Vec<Module>, Error> {
|
||||
let count = match IP20EcDi8Do8::get_module_count(device).await {
|
||||
Ok(count) => count,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if count == 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let modules = IP20EcDi8Do8::get_modules(device, count).await?;
|
||||
Ok(modules)
|
||||
}
|
||||
}
|
||||
|
||||
pub const IP20_EC_DI8_DO8_VENDOR_ID: u32 = 0x741;
|
||||
pub const IP20_EC_DI8_DO8_PRODUCT_ID: u32 = 0x117b6722;
|
||||
pub const IP20_EC_DI8_DO8_REVISION: u32 = 0x1;
|
||||
pub const IP20_EC_DI8_DO8_IDENTITY: SubDeviceIdentityTuple = (
|
||||
IP20_EC_DI8_DO8_VENDOR_ID,
|
||||
IP20_EC_DI8_DO8_PRODUCT_ID,
|
||||
IP20_EC_DI8_DO8_REVISION,
|
||||
);
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod ip20_ec_di8_do8;
|
||||
pub mod wago_750_1506;
|
||||
pub mod wago_750_501;
|
||||
pub mod wago_750_530;
|
||||
pub mod wago_750_652;
|
||||