Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d090a4d4b | ||
|
|
e23c673ac8 | ||
|
|
625497fbb5 | ||
|
|
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 | ||
|
|
f6c0744efa | ||
|
|
8c7e810359 | ||
|
|
f0fd9e8ce6 | ||
|
|
c5579a4471 | ||
|
|
de7a65bf12 | ||
|
|
e4d0b3c5be | ||
|
|
dad029ab0d | ||
|
|
9c780799d1 | ||
|
|
27a33f9def | ||
|
|
f44cd029f9 | ||
|
|
9abb7629fb | ||
|
|
a161d85a63 | ||
|
|
9df9e6df4a | ||
|
|
29d465c13e | ||
|
|
622c94f2d9 | ||
|
|
f31a2789ca | ||
|
|
e7632e8d1b | ||
|
|
2f4f3de32a | ||
|
|
c9f49198c7 | ||
|
|
e2c12a0168 | ||
|
|
cb6e066512 | ||
|
|
1dd0421f96 | ||
|
|
64855f2e7a | ||
|
|
855ae7b3cf | ||
|
|
5f9a8605c9 | ||
|
|
567730a8d4 | ||
|
|
6b2164b88b | ||
|
|
930ea10409 | ||
|
|
24b703e262 | ||
|
|
152dd3efa0 | ||
|
|
ebc5bae55f | ||
|
|
88214c46a8 | ||
|
|
c9485a92e9 | ||
|
|
9df42c4d13 | ||
|
|
fc9a439ae8 | ||
|
|
e451153406 | ||
|
|
500e2fe481 | ||
|
|
a5884d93e7 | ||
|
|
d2e32cdd78 | ||
|
|
f66c6d4f65 | ||
|
|
fb85cc6db7 | ||
|
|
4e128ded36 | ||
|
|
f50388b141 | ||
|
|
4025a68fe1 | ||
|
|
7a05fa206f | ||
|
|
8594b06c3b | ||
|
|
17523574d8 | ||
|
|
7971797178 | ||
|
|
a8b0837e58 | ||
|
|
e419974844 | ||
|
|
773d0f6c15 | ||
|
|
afc4e0d8b4 | ||
|
|
5e86988ac4 | ||
|
|
2d28c18b1a | ||
|
|
b19764281a | ||
|
|
c25d63f781 | ||
|
|
d79efc412f | ||
|
|
7ad74cf089 | ||
|
|
6073d37892 | ||
|
|
c04937553f | ||
|
|
e08bdfa3c7 | ||
|
|
d282678b19 | ||
|
|
9ff4243350 | ||
|
|
6c79b33752 | ||
|
|
861b5da7a3 | ||
|
|
7dd3d50653 | ||
|
|
ba8e04c641 | ||
|
|
cece245a55 | ||
|
|
fab4d2810e | ||
|
|
5bff3ca929 | ||
|
|
afe1d3b80e | ||
|
|
6c8005dba9 | ||
|
|
9f399ca6f3 | ||
|
|
a7574dd348 | ||
|
|
b39f3597c8 |
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"
|
||||
@@ -4,4 +4,7 @@ dist/
|
||||
Cargo.lock
|
||||
dhat-heap.json
|
||||
build.sh
|
||||
|
||||
shell.nix
|
||||
node_modules/
|
||||
runtime_metrics.csv
|
||||
compile_metrics.csv
|
||||
@@ -1,3 +1,40 @@
|
||||
# `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_
|
||||
|
||||
## General
|
||||
- [#847](https://github.com/qitechgmbh/control/pull/847) Improved discovery and initialization
|
||||
- [#859](https://github.com/qitechgmbh/control/pull/859) Improved update speed
|
||||
- [#897](https://github.com/qitechgmbh/control/pull/897), [#899](https://github.com/qitechgmbh/control/pull/899), [#852](https://github.com/qitechgmbh/control/pull/852) Improved system stability
|
||||
- [#817](https://github.com/qitechgmbh/control/pull/817) Added an update bar
|
||||
|
||||
## Laser
|
||||
- [#857](https://github.com/qitechgmbh/control/pull/857) Fixed potential issues with 2 Axis Laser
|
||||
|
||||
## Extruder V2
|
||||
- [#881](https://github.com/qitechgmbh/control/pull/881) Extended Temperature PID Configuration
|
||||
|
||||
|
||||
# `2.11.0`
|
||||
_21.10.2025_
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ members = [
|
||||
#"ethercat-eeprom-dump",
|
||||
"control-core-derive",
|
||||
"units",
|
||||
"utils",
|
||||
]
|
||||
exclude = []
|
||||
resolver = "2"
|
||||
|
||||
@@ -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,6 +79,15 @@ 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 | - |
|
||||
|
||||
# Minimal Hardware Examples
|
||||
|
||||
For complete step-by-step tutorials on setting up your first hardware, including wiring diagrams and software configuration, see the [Getting Started Guide](./docs/developer-docs/getting-started.md#minimal-hardware-examples).
|
||||
|
||||
Available examples:
|
||||
- **LED Control with EL2004** - Digital output control (simplest setup)
|
||||
- **Analog Input with EL3021** - Reading analog current measurements
|
||||
|
||||
|
||||
# More Docs
|
||||
|
||||
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 74 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)
|
||||
@@ -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",
|
||||
@@ -55,13 +44,15 @@
|
||||
"globals": "^16.2.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"postcss": "^8.5.2",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier": "^3.7.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"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": {
|
||||
|
||||
|
After Width: | Height: | Size: 74 KiB |
@@ -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
|
||||
|
||||
@@ -720,3 +720,7 @@ The following persons must not work on the machine:
|
||||

|
||||
|
||||

|
||||
|
||||
## Mechanical Diagrams
|
||||
|
||||

|
||||
@@ -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
|
||||
@@ -353,7 +352,6 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
namespaceId.machine_identification_unique.serial,
|
||||
);
|
||||
//console.log(machineExists);
|
||||
socket.connect();
|
||||
if (machineExists && !socket.connected) {
|
||||
socket.connect();
|
||||
clearInterval(intervalId); // stop polling
|
||||
@@ -386,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,10 +1,16 @@
|
||||
import { OutsideCorner } from "@/components/OutsideCorner";
|
||||
import { useMachines } from "@/client/useMachines";
|
||||
import { useOnSubpath } from "@/lib/useOnSubpath";
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { Fragment } from "react";
|
||||
import {
|
||||
Link,
|
||||
Outlet,
|
||||
useRouter,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import React from "react";
|
||||
import { Icon, IconName } from "./Icon";
|
||||
import { useMainNamespace } from "@/client/mainNamespace";
|
||||
|
||||
type SidebarItemContent = {
|
||||
link: string;
|
||||
@@ -57,7 +63,51 @@ export function useSidebarlessWidth() {
|
||||
|
||||
export function SidebarLayout() {
|
||||
const machines = useMachines();
|
||||
const router = useRouter();
|
||||
const routerState = useRouterState();
|
||||
const { machines: machinesEvent } = useMainNamespace();
|
||||
const [contentWidth, setContentWidth] = React.useState<number>(0);
|
||||
|
||||
// Machine connection guard: redirect to setup if selected machine disconnects
|
||||
useEffect(() => {
|
||||
// Wait for machines data to be loaded
|
||||
if (!machinesEvent?.data) return;
|
||||
|
||||
// Extract serial from current path (e.g., /machines/laser1/12345/control)
|
||||
const pathMatch = routerState.location.pathname.match(
|
||||
/\/machines\/[^/]+\/(\d+)/,
|
||||
);
|
||||
if (!pathMatch) return; // Not on a machine page
|
||||
|
||||
// Safety check: ensure pathMatch has at least 2 elements (full match + capture group)
|
||||
if (pathMatch.length < 2) {
|
||||
console.warn("[ConnectionGuard] Invalid pathMatch structure:", pathMatch);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse serial number with error handling
|
||||
const serialString = pathMatch[1];
|
||||
const serialNumber = parseInt(serialString, 10);
|
||||
|
||||
// Check if parseInt succeeded (NaN means parsing failed)
|
||||
if (isNaN(serialNumber)) {
|
||||
console.warn(
|
||||
`[ConnectionGuard] Failed to parse serial number from "${serialString}"`,
|
||||
{ pathname: routerState.location.pathname },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if machine still exists in the raw machines data
|
||||
const machineExists = machinesEvent.data.machines.some(
|
||||
(m) => m.machine_identification_unique.serial === serialNumber,
|
||||
);
|
||||
|
||||
if (!machineExists) {
|
||||
router.navigate({ to: "/_sidebar/setup/machines" });
|
||||
}
|
||||
}, [machines, machinesEvent, routerState.location.pathname, router]);
|
||||
|
||||
const items: SidebarItemContent[] = [
|
||||
...machines.map((machine) => ({
|
||||
link: `/_sidebar/machines/${machine.slug}/${machine.machine_identification_unique.serial}/control`,
|
||||
|
||||
@@ -63,7 +63,11 @@ export type DataSeries = SeriesData | SeriesData[];
|
||||
export type BigGraphProps = {
|
||||
newData: DataSeries;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
renderValue?: (
|
||||
value: number,
|
||||
seriesIndex?: number,
|
||||
seriesTitle?: string,
|
||||
) => string;
|
||||
config: GraphConfig;
|
||||
graphId: string;
|
||||
syncGraph?: PropGraphSync;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Icon, IconName } from "@/components/Icon";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import {
|
||||
getUnitIcon,
|
||||
renderValueToReactNode,
|
||||
renderUnitSymbol,
|
||||
Unit,
|
||||
} from "./units";
|
||||
import { Label } from "./Label";
|
||||
import { TimeSeries, TimeSeriesValue } from "@/lib/timeseries";
|
||||
|
||||
type TimeframeOption = {
|
||||
label: string;
|
||||
value: number; // in milliseconds
|
||||
};
|
||||
|
||||
export const TIMEFRAME_OPTIONS: TimeframeOption[] = [
|
||||
{ label: "1 min", value: 1 * 60 * 1000 },
|
||||
{ label: "5 min", value: 5 * 60 * 1000 },
|
||||
{ label: "10 min", value: 10 * 60 * 1000 },
|
||||
{ label: "30 min", value: 30 * 60 * 1000 },
|
||||
{ label: "1 hour", value: 60 * 60 * 1000 },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
unit?: Unit;
|
||||
timeseries: TimeSeries;
|
||||
icon?: IconName;
|
||||
renderValue?: (value: number) => string;
|
||||
defaultTimeframe?: number; // in milliseconds, defaults to 5 minutes
|
||||
/** Optional externally-controlled timeframe (ms). When provided, component becomes controlled. */
|
||||
timeframe?: number;
|
||||
/** Optional callback when timeframe is changed via this component's selector */
|
||||
onTimeframeChange?: (t: number) => void;
|
||||
/** When true, hide the built-in timeframe selector (useful when sharing a selector outside) */
|
||||
hideSelector?: boolean;
|
||||
};
|
||||
|
||||
export function MinMaxValue({
|
||||
unit,
|
||||
timeseries,
|
||||
icon,
|
||||
label,
|
||||
renderValue,
|
||||
defaultTimeframe = 5 * 60 * 1000, // 5 minutes default
|
||||
timeframe,
|
||||
onTimeframeChange,
|
||||
hideSelector = false,
|
||||
}: Props) {
|
||||
const [internalTimeframe, setInternalTimeframe] = useState(defaultTimeframe);
|
||||
const selectedTimeframe = timeframe ?? internalTimeframe;
|
||||
|
||||
// Calculate min/max from the timeseries using the selected timeframe
|
||||
const { min, max, hasData } = useMemo(() => {
|
||||
// Use short series for timeframes <= 5 seconds, otherwise use long series
|
||||
// Short series typically has ~5 second retention with high resolution
|
||||
// Long series typically has ~1 hour retention with lower resolution
|
||||
const series =
|
||||
selectedTimeframe <= 5000 ? timeseries.short : timeseries.long;
|
||||
|
||||
// Manually calculate min/max to exclude zero values (which might be invalid readings)
|
||||
const cutoffTime = series.lastTimestamp - selectedTimeframe;
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
let hasValidData = false;
|
||||
let dataCount = 0;
|
||||
|
||||
const { values: raw, index, size, validCount } = series;
|
||||
|
||||
const startIdx = validCount < size ? 0 : index;
|
||||
|
||||
for (let i = 0; i < validCount; i++) {
|
||||
const idx = (startIdx + i) % size;
|
||||
const val = raw[idx];
|
||||
|
||||
if (
|
||||
val &&
|
||||
val.timestamp > 0 &&
|
||||
val.timestamp >= cutoffTime &&
|
||||
val.value > 0
|
||||
) {
|
||||
hasValidData = true;
|
||||
dataCount++;
|
||||
if (val.value < min) min = val.value;
|
||||
if (val.value > max) max = val.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidData) {
|
||||
return { min: 0, max: 0, hasData: false };
|
||||
}
|
||||
|
||||
return { min, max, hasData: true };
|
||||
}, [
|
||||
timeseries.short,
|
||||
timeseries.long,
|
||||
timeseries.current,
|
||||
selectedTimeframe,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<Label label={label}>
|
||||
<div className="flex flex-row items-center gap-6">
|
||||
{/* Min Value */}
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Icon
|
||||
name={icon ?? (unit ? getUnitIcon(unit) : undefined)}
|
||||
className="size-5 text-blue-500"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-500">Min</span>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<span className="font-mono text-xl font-bold">
|
||||
{hasData
|
||||
? renderValueToReactNode(min, unit, renderValue)
|
||||
: "-"}
|
||||
</span>
|
||||
{hasData && (
|
||||
<span className="text-sm">{renderUnitSymbol(unit)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Max Value */}
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Icon
|
||||
name={icon ?? (unit ? getUnitIcon(unit) : undefined)}
|
||||
className="size-5 text-red-500"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-500">Max</span>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<span className="font-mono text-xl font-bold">
|
||||
{hasData
|
||||
? renderValueToReactNode(max, unit, renderValue)
|
||||
: "-"}
|
||||
</span>
|
||||
{hasData && (
|
||||
<span className="text-sm">{renderUnitSymbol(unit)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
{/* Timeframe Selector (hidden when sharing selector externally) */}
|
||||
{!hideSelector && (
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
{TIMEFRAME_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (onTimeframeChange) onTimeframeChange(option.value);
|
||||
else setInternalTimeframe(option.value);
|
||||
}}
|
||||
className={`rounded-md px-3 py-1 text-sm transition-colors ${
|
||||
selectedTimeframe === option.value
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,16 +25,29 @@ 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";
|
||||
case "l/min":
|
||||
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";
|
||||
}
|
||||
@@ -62,26 +75,42 @@ 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":
|
||||
return "kWh";
|
||||
case "l/min":
|
||||
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,
|
||||
@@ -121,16 +150,32 @@ 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":
|
||||
return "kilowatt-hours";
|
||||
case "l/min":
|
||||
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 "";
|
||||
}
|
||||
@@ -147,11 +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,3 +1,4 @@
|
||||
export const NIXOS_LIST_GENERATIONS = "nixos:list-generations";
|
||||
export const NIXOS_SET_GENERATION = "nixos:set-generation";
|
||||
export const NIXOS_DELETE_GENERATION = "nixos:delete-generation";
|
||||
export const NIXOS_DELETE_ALL_OLD_GENERATIONS = "nixos:delete-all-old-generations";
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
NIXOS_LIST_GENERATIONS,
|
||||
NIXOS_SET_GENERATION,
|
||||
NIXOS_DELETE_GENERATION,
|
||||
NIXOS_DELETE_ALL_OLD_GENERATIONS,
|
||||
} from "./nixos-channels";
|
||||
|
||||
export type NixOSGeneration = {
|
||||
@@ -23,5 +24,7 @@ export function exposeNixOSContext() {
|
||||
ipcRenderer.invoke(NIXOS_SET_GENERATION, generationId),
|
||||
deleteGeneration: (generationId: string) =>
|
||||
ipcRenderer.invoke(NIXOS_DELETE_GENERATION, generationId),
|
||||
deleteAllOldGenerations: () =>
|
||||
ipcRenderer.invoke(NIXOS_DELETE_ALL_OLD_GENERATIONS),
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
NIXOS_LIST_GENERATIONS,
|
||||
NIXOS_SET_GENERATION,
|
||||
NIXOS_DELETE_GENERATION,
|
||||
NIXOS_DELETE_ALL_OLD_GENERATIONS,
|
||||
} from "./nixos-channels";
|
||||
import { NixOSGeneration } from "./nixos-context";
|
||||
|
||||
@@ -46,6 +47,18 @@ export function addNixOSEventListeners() {
|
||||
};
|
||||
}
|
||||
});
|
||||
ipcMain.handle(NIXOS_DELETE_ALL_OLD_GENERATIONS, async (_) => {
|
||||
try {
|
||||
const result = await deleteAllOldNixOSGeneration();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete all NixOS generations:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function listNixOSGenerations(): Promise<{
|
||||
@@ -179,6 +192,48 @@ async function deleteNixOSGeneration(generationId: string): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteAllOldNixOSGeneration(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
const process = spawn("sudo", [
|
||||
"sh",
|
||||
"-c",
|
||||
`nix-collect-garbage --delete-old`,
|
||||
]);
|
||||
|
||||
let stderr = "";
|
||||
let stdout = "";
|
||||
|
||||
process.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
process.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
process.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
error: stderr || stdout || `Process exited with code ${code}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
process.on("error", (error) => {
|
||||
resolve({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseNixOSGenerations(output: string): NixOSGeneration[] {
|
||||
const lines = output.trim().split("\n");
|
||||
const generations: NixOSGeneration[] = [];
|
||||
|
||||
@@ -59,6 +59,19 @@ export async function deleteNixOSGeneration(generationId: string): Promise<{
|
||||
};
|
||||
}
|
||||
}
|
||||
export async function deleteAllOldNixOSGeneration(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return await window.nixos.deleteAllOldGenerations();
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if NixOS context is available
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -152,7 +152,7 @@ export function Extruder2ControlPage() {
|
||||
unit="rpm"
|
||||
title="Target Output RPM"
|
||||
min={0}
|
||||
max={106}
|
||||
max={44}
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
onChange={setInverterTargetRpm}
|
||||
/>
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { Page } from "@/components/Page";
|
||||
import React from "react";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import {
|
||||
SelectionGroup,
|
||||
SelectionGroupBoolean,
|
||||
} from "@/control/SelectionGroup";
|
||||
import { HeatingZone } from "../HeatingZone";
|
||||
import { Label } from "@/control/Label";
|
||||
import { EditValue } from "@/control/EditValue";
|
||||
import { roundToDecimals } from "@/lib/decimal";
|
||||
import { useExtruder3 } from "./useExtruder";
|
||||
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
|
||||
import { StatusBadge } from "@/control/StatusBadge";
|
||||
|
||||
export function Extruder3ControlPage() {
|
||||
const {
|
||||
state,
|
||||
defaultState,
|
||||
nozzleTemperature,
|
||||
nozzlePower,
|
||||
frontTemperature,
|
||||
frontPower,
|
||||
backTemperature,
|
||||
backPower,
|
||||
middleTemperature,
|
||||
middlePower,
|
||||
pressure,
|
||||
|
||||
motorScrewRpm,
|
||||
motorPower,
|
||||
motorCurrent,
|
||||
totalEnergyKWh,
|
||||
combinedPower,
|
||||
|
||||
setExtruderMode,
|
||||
setBackHeatingTemperature,
|
||||
setFrontHeatingTemperature,
|
||||
setMiddleHeatingTemperature,
|
||||
setNozzleHeatingTemperature,
|
||||
setInverterRegulation,
|
||||
setInverterTargetPressure,
|
||||
setInverterTargetRpm,
|
||||
|
||||
isLoading,
|
||||
isDisabled,
|
||||
} = useExtruder3();
|
||||
|
||||
function isZoneReadyForExtrusion(
|
||||
temperature: number,
|
||||
targetTemperature: number,
|
||||
) {
|
||||
// if temperature is 90% of the target temperature, then we are ready for extrusion
|
||||
return temperature >= 0.9 * targetTemperature && targetTemperature > 0.0;
|
||||
}
|
||||
|
||||
const allZonesReadyForExtrude = () => {
|
||||
const frontReady = isZoneReadyForExtrusion(
|
||||
frontTemperature.current?.value ?? 0,
|
||||
state?.heating_states.front.target_temperature ?? 1,
|
||||
);
|
||||
const middleReady = isZoneReadyForExtrusion(
|
||||
middleTemperature.current?.value ?? 0,
|
||||
state?.heating_states.middle.target_temperature ?? 1,
|
||||
);
|
||||
const backReady = isZoneReadyForExtrusion(
|
||||
backTemperature.current?.value ?? 0,
|
||||
state?.heating_states.back.target_temperature ?? 1,
|
||||
);
|
||||
const nozzleReady = isZoneReadyForExtrusion(
|
||||
nozzleTemperature.current?.value ?? 0,
|
||||
state?.heating_states.nozzle.target_temperature ?? 1,
|
||||
);
|
||||
|
||||
return frontReady && middleReady && backReady && nozzleReady;
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid>
|
||||
<HeatingZone
|
||||
title={"Heating Front"}
|
||||
heatingState={state?.heating_states.front}
|
||||
heatingTimeSeries={frontTemperature}
|
||||
heatingPower={frontPower}
|
||||
onChangeTargetTemp={setFrontHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
/>
|
||||
<HeatingZone
|
||||
title={"Heating Middle"}
|
||||
heatingState={state?.heating_states.middle}
|
||||
heatingTimeSeries={middleTemperature}
|
||||
heatingPower={middlePower}
|
||||
onChangeTargetTemp={setMiddleHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
/>
|
||||
<HeatingZone
|
||||
title={"Heating Back"}
|
||||
heatingState={state?.heating_states.back}
|
||||
heatingTimeSeries={backTemperature}
|
||||
heatingPower={backPower}
|
||||
onChangeTargetTemp={setBackHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
/>
|
||||
<HeatingZone
|
||||
title={"Heating Nozzle"}
|
||||
heatingState={state?.heating_states.nozzle}
|
||||
heatingTimeSeries={nozzleTemperature}
|
||||
heatingPower={nozzlePower}
|
||||
onChangeTargetTemp={setNozzleHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
/>
|
||||
<ControlCard className="bg-red" title="Screw Drive">
|
||||
{state?.inverter_status_state.overload_warning == true ? (
|
||||
<StatusBadge variant="error">
|
||||
Inverter is overloaded! Please check the extruder and reduce load
|
||||
if necessary.
|
||||
</StatusBadge>
|
||||
) : state?.inverter_status_state.fault_occurence == true ? (
|
||||
<StatusBadge variant="error">
|
||||
Inverter encountered an error!! Press the restart button in Config
|
||||
</StatusBadge>
|
||||
) : state?.inverter_status_state.running == true &&
|
||||
state.inverter_status_state.fault_occurence == false ? (
|
||||
<StatusBadge variant="success">Running</StatusBadge>
|
||||
) : null}
|
||||
{state?.inverter_status_state.running == false &&
|
||||
state.inverter_status_state.fault_occurence == false && (
|
||||
<StatusBadge variant="success">Healthy</StatusBadge>
|
||||
)}
|
||||
|
||||
<Label label="Regulation">
|
||||
<SelectionGroupBoolean
|
||||
value={state?.regulation_state.uses_rpm}
|
||||
optionTrue={{ children: "RPM" }}
|
||||
optionFalse={{ children: "Pressure" }}
|
||||
onChange={setInverterRegulation}
|
||||
disabled={isDisabled}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</Label>
|
||||
<div className="flex flex-row flex-wrap gap-4">
|
||||
<Label label="Target Output RPM">
|
||||
<EditValue
|
||||
value={state?.screw_state.target_rpm}
|
||||
defaultValue={defaultState?.screw_state.target_rpm}
|
||||
unit="rpm"
|
||||
title="Target Output RPM"
|
||||
min={0}
|
||||
max={100}
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
onChange={setInverterTargetRpm}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Target Pressure">
|
||||
<EditValue
|
||||
value={state?.pressure_state.target_bar}
|
||||
defaultValue={defaultState?.pressure_state.target_bar}
|
||||
unit="bar"
|
||||
title="Target Pressure"
|
||||
min={0.0}
|
||||
max={270.0}
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
onChange={setInverterTargetPressure}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap gap-4">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Rpm"
|
||||
unit="rpm"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
timeseries={motorScrewRpm}
|
||||
/>
|
||||
|
||||
{state?.pressure_state?.wiring_error && (
|
||||
<StatusBadge variant="error">
|
||||
Cant Measure Pressure! Check Pressure Sensor Wiring!
|
||||
</StatusBadge>
|
||||
)}
|
||||
<TimeSeriesValueNumeric
|
||||
label="Pressure"
|
||||
unit="bar"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
timeseries={pressure}
|
||||
/>
|
||||
</div>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard className="bg-red" title="Mode">
|
||||
<SelectionGroup<"Standby" | "Heat" | "Extrude">
|
||||
value={state?.mode_state.mode}
|
||||
orientation="vertical"
|
||||
className="grid h-full grid-cols-2 gap-2"
|
||||
options={{
|
||||
Standby: {
|
||||
children: "Standby",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
Heat: {
|
||||
children: "Heat",
|
||||
icon: "lu:Flame",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
Extrude: {
|
||||
children: "Extrude",
|
||||
icon: "lu:ArrowBigLeftDash",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
confirmation: allZonesReadyForExtrude()
|
||||
? undefined
|
||||
: "Temperature is too low. Are you sure you want to extrude?",
|
||||
},
|
||||
}}
|
||||
onChange={setExtruderMode}
|
||||
disabled={isDisabled}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard className="bg-blue" title="Power Consumption">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Total Power"
|
||||
unit="W"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
timeseries={combinedPower}
|
||||
/>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Motor Power"
|
||||
unit="W"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
timeseries={motorPower}
|
||||
/>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Motor Current"
|
||||
unit="A"
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
timeseries={motorCurrent}
|
||||
/>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Total Energy Consumption"
|
||||
unit="kWh"
|
||||
renderValue={(value) => roundToDecimals(value, 3)}
|
||||
timeseries={totalEnergyKWh}
|
||||
/>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
SyncedFloatingControlPanel,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
} from "@/components/graph";
|
||||
import React from "react";
|
||||
import { useExtruder3 } from "./useExtruder";
|
||||
|
||||
export function Extruder3GraphsPage() {
|
||||
const {
|
||||
state,
|
||||
nozzleTemperature,
|
||||
nozzlePower,
|
||||
frontTemperature,
|
||||
frontPower,
|
||||
backTemperature,
|
||||
backPower,
|
||||
middleTemperature,
|
||||
middlePower,
|
||||
pressure,
|
||||
motorScrewRpm,
|
||||
motorCurrent,
|
||||
motorPower,
|
||||
combinedPower,
|
||||
} = useExtruder3();
|
||||
|
||||
const syncHook = useGraphSync("extruder-graphs");
|
||||
|
||||
// Base config
|
||||
const baseConfig: GraphConfig = {
|
||||
defaultTimeWindow: 30 * 60 * 1000, // 30 minutes
|
||||
exportFilename: "extruder_data",
|
||||
showLegend: true,
|
||||
title: "extruder data",
|
||||
};
|
||||
|
||||
const temperatureData = [
|
||||
...(nozzleTemperature
|
||||
? [
|
||||
{
|
||||
newData: nozzleTemperature,
|
||||
title: "Nozzle",
|
||||
color: "#ef4444",
|
||||
lines:
|
||||
state?.heating_states.nozzle?.target_temperature !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.nozzle.target_temperature,
|
||||
color: "#ef4444",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(frontTemperature
|
||||
? [
|
||||
{
|
||||
newData: frontTemperature,
|
||||
title: "Front",
|
||||
color: "#f59e0b",
|
||||
lines:
|
||||
state?.heating_states.front?.target_temperature !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.front.target_temperature,
|
||||
color: "#f59e0b",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(middleTemperature
|
||||
? [
|
||||
{
|
||||
newData: middleTemperature,
|
||||
title: "Middle",
|
||||
color: "#8b5cf6",
|
||||
lines:
|
||||
state?.heating_states.middle?.target_temperature !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.middle.target_temperature,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(backTemperature
|
||||
? [
|
||||
{
|
||||
newData: backTemperature,
|
||||
title: "Back",
|
||||
color: "#3b82f6",
|
||||
lines:
|
||||
state?.heating_states.back?.target_temperature !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.back.target_temperature,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const temperatureConfig: GraphConfig = {
|
||||
...baseConfig,
|
||||
title: "Temperatures",
|
||||
exportFilename: "temperatures_data",
|
||||
colors: {
|
||||
primary: "#ef4444",
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
background: "#ffffff",
|
||||
},
|
||||
};
|
||||
|
||||
// Combined Power Graph (no target lines for power)
|
||||
const powerData = [
|
||||
...(nozzlePower
|
||||
? [
|
||||
{
|
||||
newData: nozzlePower,
|
||||
title: "Nozzle",
|
||||
color: "#ef4444",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(frontPower
|
||||
? [
|
||||
{
|
||||
newData: frontPower,
|
||||
title: "Front",
|
||||
color: "#f59e0b",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(middlePower
|
||||
? [
|
||||
{
|
||||
newData: middlePower,
|
||||
title: "Middle",
|
||||
color: "#8b5cf6",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(backPower
|
||||
? [
|
||||
{
|
||||
newData: backPower,
|
||||
title: "Back",
|
||||
color: "#3b82f6",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(motorPower
|
||||
? [
|
||||
{
|
||||
newData: motorPower,
|
||||
title: "Motor",
|
||||
color: "#10b981",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(combinedPower
|
||||
? [
|
||||
{
|
||||
newData: combinedPower,
|
||||
title: "Total",
|
||||
color: "#000000",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const powerConfig: GraphConfig = {
|
||||
...baseConfig,
|
||||
title: "Power Outputs",
|
||||
exportFilename: "power_data",
|
||||
colors: {
|
||||
primary: "#10b981",
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
background: "#ffffff",
|
||||
},
|
||||
};
|
||||
|
||||
const currentConfig: GraphConfig = {
|
||||
...baseConfig,
|
||||
title: "Motor Current",
|
||||
exportFilename: "motor_current_data",
|
||||
colors: {
|
||||
primary: "#3b82f6",
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
background: "#ffffff",
|
||||
},
|
||||
};
|
||||
|
||||
// Pressure Graph with connected target line
|
||||
const pressureConfig: GraphConfig = {
|
||||
...baseConfig,
|
||||
title: "Pressure",
|
||||
exportFilename: "pressure_data",
|
||||
colors: {
|
||||
primary: "#3b82f6",
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
background: "#ffffff",
|
||||
},
|
||||
};
|
||||
|
||||
// RPM Graph with connected target line
|
||||
const rpmConfig: GraphConfig = {
|
||||
...baseConfig,
|
||||
title: "RPM",
|
||||
exportFilename: "rpm_data",
|
||||
colors: {
|
||||
primary: "#8b5cf6",
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
background: "#ffffff",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Page className="pb-25">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: pressure,
|
||||
color: "#3b82f6",
|
||||
lines:
|
||||
state?.pressure_state.target_bar !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={pressureConfig}
|
||||
unit="bar"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="pressure-graph"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={temperatureData}
|
||||
config={temperatureConfig}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-temperatures"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={powerData}
|
||||
config={powerConfig}
|
||||
unit="W"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-power"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorCurrent,
|
||||
color: "#3b82f6",
|
||||
}}
|
||||
config={currentConfig}
|
||||
unit="A"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="motor-current"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
lines:
|
||||
state?.screw_state.target_rpm !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={rpmConfig}
|
||||
unit="rpm"
|
||||
renderValue={(value) => value.toFixed(0)}
|
||||
graphId="rpm-graph"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import { MarkdownWithToc } from "@/components/MarkdownWithToc";
|
||||
import React from "react";
|
||||
import extruderManualContent from "@/assets/markdown/extruder/manual.md?raw";
|
||||
|
||||
export function ExtruderV3ManualPage() {
|
||||
return (
|
||||
<Page>
|
||||
<MarkdownWithToc markdownContent={extruderManualContent} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Topbar } from "@/components/Topbar";
|
||||
import { extruder3Route } from "@/routes/routes";
|
||||
import React from "react";
|
||||
|
||||
export function Extruder3Page() {
|
||||
const { serial } = extruder3Route.useParams();
|
||||
return (
|
||||
<Topbar
|
||||
pathname={`/_sidebar/machines/extruder3/${serial}`}
|
||||
items={[
|
||||
{
|
||||
link: "control",
|
||||
title: "Control",
|
||||
icon: "lu:CirclePlay",
|
||||
activeLink: "control",
|
||||
},
|
||||
{
|
||||
link: "graphs",
|
||||
title: "Graphs",
|
||||
icon: "lu:ChartSpline",
|
||||
activeLink: "graphs",
|
||||
},
|
||||
{
|
||||
link: "settings",
|
||||
title: "Config",
|
||||
icon: "lu:Settings",
|
||||
activeLink: "settings",
|
||||
},
|
||||
{
|
||||
link: "manual",
|
||||
title: "Manual",
|
||||
icon: "lu:BookOpen",
|
||||
activeLink: "manual",
|
||||
},
|
||||
{
|
||||
link: "presets",
|
||||
title: "Presets",
|
||||
icon: "lu:Save",
|
||||
activeLink: "presets",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import React from "react";
|
||||
import { PresetsPage } from "@/components/preset/PresetsPage";
|
||||
import { extruder3 } from "@/machines/properties";
|
||||
import { useExtruder3 } from "./useExtruder";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
PresetPreviewEntries,
|
||||
previewSeparator,
|
||||
} from "@/components/preset/PresetPreviewTable";
|
||||
import { Preset, PresetData } from "@/lib/preset/preset";
|
||||
|
||||
const extruder3PresetDataSchema = z
|
||||
.object({
|
||||
targetFrontHeatingTemperature: z.number(),
|
||||
targetMiddleHeatingTemperature: z.number(),
|
||||
targetBackHeatingTemperature: z.number(),
|
||||
targetNozzleHeatingTemperature: z.number(),
|
||||
|
||||
targetInverterRpm: z.number(),
|
||||
targetInverterPressure: z.number(),
|
||||
inverterRegulation: z.enum(["RPM", "Pressure"]),
|
||||
|
||||
inverterRotationDirection: z.enum(["Forward", "Backward"]),
|
||||
|
||||
pressureLimit: z.number(),
|
||||
pressureLimitEnabled: z.boolean(),
|
||||
|
||||
pidPressureKp: z.number(),
|
||||
pidPressureKi: z.number(),
|
||||
pidPressureKd: z.number(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
type Extruder3 = typeof extruder3PresetDataSchema;
|
||||
|
||||
const schemas = new Map([[1, extruder3PresetDataSchema]]);
|
||||
|
||||
const previewEntries: PresetPreviewEntries<Extruder3> = [
|
||||
{
|
||||
name: "Target Front Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.targetFrontHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Target Middle Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.targetMiddleHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Target Back Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.targetBackHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Target Nozzle Temperature",
|
||||
unit: "C",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.targetNozzleHeatingTemperature?.toFixed(1),
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "Inverter Regulation",
|
||||
renderValue: (data: PresetData<Extruder3>) => data.inverterRegulation,
|
||||
},
|
||||
{
|
||||
name: "Target Inverter RPM",
|
||||
unit: "rpm",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.targetInverterRpm?.toFixed(2),
|
||||
},
|
||||
{
|
||||
name: "Target Inverter Pressure",
|
||||
unit: "bar",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.targetInverterPressure?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Inverter Direction",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.inverterRotationDirection,
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "Pressure Limit",
|
||||
unit: "bar",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.pressureLimit?.toFixed(1),
|
||||
},
|
||||
{
|
||||
name: "Enable Pressure Limit",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.pressureLimitEnabled ? "on" : "off",
|
||||
},
|
||||
previewSeparator,
|
||||
{
|
||||
name: "PID Pressue Kp",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.pidPressureKp?.toFixed(4),
|
||||
},
|
||||
{
|
||||
name: "PID Pressue Ki",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.pidPressureKi?.toFixed(4),
|
||||
},
|
||||
{
|
||||
name: "PID Pressue Kd",
|
||||
renderValue: (data: PresetData<Extruder3>) =>
|
||||
data.pidPressureKd?.toFixed(4),
|
||||
},
|
||||
];
|
||||
|
||||
export function Extruder3PresetsPage() {
|
||||
const {
|
||||
state,
|
||||
defaultState,
|
||||
|
||||
setFrontHeatingTemperature,
|
||||
setMiddleHeatingTemperature,
|
||||
setBackHeatingTemperature,
|
||||
setNozzleHeatingTemperature,
|
||||
|
||||
setInverterTargetRpm,
|
||||
setInverterTargetPressure,
|
||||
setInverterRegulation,
|
||||
setInverterRotationDirection,
|
||||
|
||||
setExtruderPressureLimit,
|
||||
setExtruderPressureLimitEnabled,
|
||||
|
||||
setPressurePidKd,
|
||||
setPressurePidKi,
|
||||
setPressurePidKp,
|
||||
} = useExtruder3();
|
||||
|
||||
const toPresetData = (s?: typeof state): PresetData<Extruder3> => ({
|
||||
targetFrontHeatingTemperature: s?.heating_states.front.target_temperature,
|
||||
targetMiddleHeatingTemperature: s?.heating_states.middle.target_temperature,
|
||||
targetBackHeatingTemperature: s?.heating_states.back.target_temperature,
|
||||
targetNozzleHeatingTemperature: s?.heating_states.nozzle.target_temperature,
|
||||
|
||||
targetInverterRpm: s?.screw_state.target_rpm,
|
||||
targetInverterPressure: s?.pressure_state.target_bar,
|
||||
inverterRegulation: s?.regulation_state.uses_rpm ? "RPM" : "Pressure",
|
||||
|
||||
inverterRotationDirection: s?.rotation_state?.forward
|
||||
? "Forward"
|
||||
: "Backward",
|
||||
|
||||
pressureLimit: s?.extruder_settings_state.pressure_limit,
|
||||
pressureLimitEnabled: s?.extruder_settings_state.pressure_limit_enabled,
|
||||
|
||||
pidPressureKp: s?.pid_settings.pressure.kp,
|
||||
pidPressureKi: s?.pid_settings.pressure.ki,
|
||||
pidPressureKd: s?.pid_settings.pressure.kd,
|
||||
});
|
||||
|
||||
const defaults = toPresetData(defaultState);
|
||||
|
||||
const applyPreset = (preset: Preset<Extruder3>) => {
|
||||
setFrontHeatingTemperature(
|
||||
preset.data.targetFrontHeatingTemperature ?? 150.0,
|
||||
);
|
||||
setMiddleHeatingTemperature(
|
||||
preset.data.targetMiddleHeatingTemperature ?? 150.0,
|
||||
);
|
||||
setBackHeatingTemperature(
|
||||
preset.data.targetBackHeatingTemperature ?? 150.0,
|
||||
);
|
||||
setNozzleHeatingTemperature(
|
||||
preset.data.targetNozzleHeatingTemperature ?? 150.0,
|
||||
);
|
||||
|
||||
setInverterTargetRpm(preset.data.targetInverterRpm ?? 0);
|
||||
setInverterTargetPressure(preset.data.targetInverterPressure ?? 0);
|
||||
setInverterRegulation(preset.data.inverterRegulation === "RPM");
|
||||
|
||||
setInverterRotationDirection(
|
||||
preset.data.inverterRotationDirection === "Forward",
|
||||
);
|
||||
|
||||
setExtruderPressureLimit(preset.data.pressureLimit ?? 100.0);
|
||||
setExtruderPressureLimitEnabled(preset.data.pressureLimitEnabled ?? true);
|
||||
|
||||
setPressurePidKp(preset.data.pidPressureKp ?? 0.16);
|
||||
setPressurePidKi(preset.data.pidPressureKi ?? 0.0);
|
||||
setPressurePidKd(preset.data.pidPressureKd ?? 0.008);
|
||||
};
|
||||
|
||||
return (
|
||||
<PresetsPage
|
||||
machine_identification={extruder3.machine_identification}
|
||||
currentState={toPresetData(state)}
|
||||
schemas={schemas}
|
||||
schemaVersion={1}
|
||||
applyPreset={applyPreset}
|
||||
previewEntries={previewEntries}
|
||||
defaultState={defaults}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import React, { useState } from "react";
|
||||
import { Page } from "@/components/Page";
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { Label } from "@/control/Label";
|
||||
import { SelectionGroupBoolean } from "@/control/SelectionGroup";
|
||||
import { EditValue } from "@/control/EditValue";
|
||||
import { roundToDecimals } from "@/lib/decimal";
|
||||
import { useExtruder3 } from "./useExtruder";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
|
||||
export function Extruder3SettingsPage() {
|
||||
const {
|
||||
state,
|
||||
defaultState,
|
||||
setInverterRotationDirection,
|
||||
resetInverter,
|
||||
setExtruderPressureLimit,
|
||||
setExtruderPressureLimitEnabled,
|
||||
setPressurePidKp,
|
||||
setPressurePidKi,
|
||||
setPressurePidKd,
|
||||
setTemperaturePidValue,
|
||||
} = useExtruder3();
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlCard className="bg-red" title="Inverter Settings">
|
||||
<Label label="Rotation Direction">
|
||||
<SelectionGroupBoolean
|
||||
value={state?.rotation_state.forward}
|
||||
optionTrue={{ children: "Forward" }}
|
||||
optionFalse={{ children: "Backward" }}
|
||||
onChange={setInverterRotationDirection}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label label="Reset Inverter">
|
||||
<button
|
||||
onClick={resetInverter}
|
||||
className="inline-block w-fit max-w-max rounded bg-red-600 px-4 py-4 text-base whitespace-nowrap text-white hover:bg-red-700"
|
||||
style={{ minWidth: "auto", width: "fit-content" }}
|
||||
>
|
||||
Reset Inverter
|
||||
</button>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard className="bg-red" title="Extruder Settings">
|
||||
<Label label="Nozzle Pressure Limit">
|
||||
<EditValue
|
||||
value={state?.extruder_settings_state.pressure_limit}
|
||||
defaultValue={defaultState?.extruder_settings_state.pressure_limit}
|
||||
unit="bar"
|
||||
title="Nozzle Pressure Limit"
|
||||
min={0}
|
||||
max={350}
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
onChange={setExtruderPressureLimit}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Nozzle Pressure Limit Enabled">
|
||||
<SelectionGroupBoolean
|
||||
value={state?.extruder_settings_state.pressure_limit_enabled}
|
||||
optionTrue={{ children: "Enabled" }}
|
||||
optionFalse={{ children: "Disabled" }}
|
||||
onChange={setExtruderPressureLimitEnabled}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Show Advanced PID Settings">
|
||||
<SelectionGroupBoolean
|
||||
value={showAdvanced}
|
||||
optionTrue={{ children: "Show" }}
|
||||
optionFalse={{ children: "Hide" }}
|
||||
onChange={setShowAdvanced}
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<ControlCard title="Pressure PID Settings ">
|
||||
<Label label="Kp">
|
||||
<EditValue
|
||||
value={state?.pid_settings.pressure.kp}
|
||||
defaultValue={defaultState?.pid_settings.pressure.kp}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
renderValue={(v) => roundToDecimals(v, 2)}
|
||||
onChange={setPressurePidKp}
|
||||
title="Pressure PID KP"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Ki">
|
||||
<EditValue
|
||||
value={state?.pid_settings.pressure.ki}
|
||||
defaultValue={defaultState?.pid_settings.pressure.ki}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
renderValue={(v) => roundToDecimals(v, 2)}
|
||||
onChange={setPressurePidKi}
|
||||
title="Pressure PID KI"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Kd">
|
||||
<EditValue
|
||||
value={state?.pid_settings.pressure.kd}
|
||||
defaultValue={defaultState?.pid_settings.pressure.kd}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
renderValue={(v) => roundToDecimals(v, 2)}
|
||||
onChange={setPressurePidKd}
|
||||
title="Pressure PID KD"
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
<ControlGrid>
|
||||
<ControlCard title="Temperature PID Settings (Front) ">
|
||||
<Label label="Kp">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.front.kp}
|
||||
defaultValue={defaultState?.pid_settings.temperature.front.kp}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("front", "kp", v)}
|
||||
title="Temperature PID KP"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Ki">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.front.ki}
|
||||
defaultValue={defaultState?.pid_settings.temperature.front.ki}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("front", "ki", v)}
|
||||
title="Temperature PID KI"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Kd">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.front.kd}
|
||||
defaultValue={defaultState?.pid_settings.temperature.front.kd}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("front", "kd", v)}
|
||||
title="Temperature PID KD"
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
<ControlCard title="Temperature PID Settings (Middle) ">
|
||||
<Label label="Kp">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.middle.kp}
|
||||
defaultValue={
|
||||
defaultState?.pid_settings.temperature.middle.kp
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("middle", "kp", v)}
|
||||
title="Temperature PID KP"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Ki">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.middle.ki}
|
||||
defaultValue={
|
||||
defaultState?.pid_settings.temperature.middle.ki
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("middle", "ki", v)}
|
||||
title="Temperature PID KI"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Kd">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.middle.kd}
|
||||
defaultValue={
|
||||
defaultState?.pid_settings.temperature.middle.kd
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("middle", "kd", v)}
|
||||
title="Temperature PID KD"
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
<ControlCard title="Temperature PID Settings (Back) ">
|
||||
<Label label="Kp">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.back.kp}
|
||||
defaultValue={defaultState?.pid_settings.temperature.back.kp}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("back", "kp", v)}
|
||||
title="Temperature PID KP"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Ki">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.back.ki}
|
||||
defaultValue={defaultState?.pid_settings.temperature.back.ki}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("back", "ki", v)}
|
||||
title="Temperature PID KI"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Kd">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.back.kd}
|
||||
defaultValue={defaultState?.pid_settings.temperature.back.kd}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("back", "kd", v)}
|
||||
title="Temperature PID KD"
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
<ControlCard title="Temperature PID Settings (Nozzle) ">
|
||||
<Label label="Kp">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.nozzle.kp}
|
||||
defaultValue={
|
||||
defaultState?.pid_settings.temperature.nozzle.kp
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("nozzle", "kp", v)}
|
||||
title="Temperature PID KP"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Ki">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.nozzle.ki}
|
||||
defaultValue={
|
||||
defaultState?.pid_settings.temperature.nozzle.ki
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("nozzle", "ki", v)}
|
||||
title="Temperature PID KI"
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Kd">
|
||||
<EditValue
|
||||
value={state?.pid_settings.temperature.nozzle.kd}
|
||||
defaultValue={
|
||||
defaultState?.pid_settings.temperature.nozzle.kd
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.001}
|
||||
renderValue={(v) => roundToDecimals(v, 3)}
|
||||
onChange={(v) => setTemperaturePidValue("nozzle", "kd", v)}
|
||||
title="Temperature PID KD"
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { StoreApi } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
EventHandler,
|
||||
eventSchema,
|
||||
Event,
|
||||
NamespaceId,
|
||||
createNamespaceHookImplementation,
|
||||
ThrottledStoreUpdater,
|
||||
handleUnhandledEventError,
|
||||
} from "../../../client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { createTimeSeries, TimeSeries } from "@/lib/timeseries";
|
||||
import { useMemo } from "react";
|
||||
|
||||
// ========== Event Schema Definitions ==========
|
||||
|
||||
/**
|
||||
* Machine operation mode enum
|
||||
*/
|
||||
export const modeSchema = z.enum(["Standby", "Heat", "Extrude"]);
|
||||
export type Mode = z.infer<typeof modeSchema>;
|
||||
|
||||
export const liveMotorStatusDataSchema = z.object({
|
||||
screw_rpm: z.number(),
|
||||
frequency: z.number(),
|
||||
current: z.number(),
|
||||
power: z.number(),
|
||||
});
|
||||
export type MotorStatus = z.infer<typeof liveMotorStatusDataSchema>;
|
||||
/**
|
||||
* Consolidated live values event schema (30FPS data)
|
||||
*/
|
||||
export const liveValuesEventDataSchema = z.object({
|
||||
motor_status: liveMotorStatusDataSchema,
|
||||
pressure: z.number(),
|
||||
nozzle_temperature: z.number(),
|
||||
front_temperature: z.number(),
|
||||
back_temperature: z.number(),
|
||||
middle_temperature: z.number(),
|
||||
nozzle_power: z.number(),
|
||||
front_power: z.number(),
|
||||
back_power: z.number(),
|
||||
middle_power: z.number(),
|
||||
combined_power: z.number(),
|
||||
total_energy_kwh: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Rotation state schema
|
||||
*/
|
||||
export const rotationStateSchema = z.object({
|
||||
forward: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mode state schema
|
||||
*/
|
||||
export const modeStateSchema = z.object({
|
||||
mode: modeSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Regulation state schema
|
||||
*/
|
||||
export const regulationStateSchema = z.object({
|
||||
uses_rpm: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Pressure state schema
|
||||
*/
|
||||
export const pressureStateSchema = z.object({
|
||||
target_bar: z.number(),
|
||||
wiring_error: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Screw state schema
|
||||
*/
|
||||
export const screwStateSchema = z.object({
|
||||
target_rpm: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Heating state schema
|
||||
*/
|
||||
export const heatingStateSchema = z.object({
|
||||
target_temperature: z.number(),
|
||||
wiring_error: z.boolean(),
|
||||
});
|
||||
|
||||
export type HeatingState = z.infer<typeof heatingStateSchema>;
|
||||
|
||||
/**
|
||||
* Heating states schema
|
||||
*/
|
||||
export const heatingStatesSchema = z.object({
|
||||
nozzle: heatingStateSchema,
|
||||
front: heatingStateSchema,
|
||||
back: heatingStateSchema,
|
||||
middle: heatingStateSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Extruder settings state schema
|
||||
*/
|
||||
export const extruderSettingsStateSchema = z.object({
|
||||
pressure_limit: z.number(),
|
||||
pressure_limit_enabled: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Inverter status state schema
|
||||
*/
|
||||
export const inverterStatusStateSchema = z.object({
|
||||
running: z.boolean(),
|
||||
forward_running: z.boolean(),
|
||||
reverse_running: z.boolean(),
|
||||
up_to_frequency: z.boolean(),
|
||||
overload_warning: z.boolean(),
|
||||
no_function: z.boolean(),
|
||||
output_frequency_detection: z.boolean(),
|
||||
abc_fault: z.boolean(),
|
||||
fault_occurence: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* PID settings schema
|
||||
*/
|
||||
export const pidSettingsSchema = z.object({
|
||||
temperature: z.object({
|
||||
front: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
zone: z.string(),
|
||||
}),
|
||||
middle: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
zone: z.string(),
|
||||
}),
|
||||
back: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
zone: z.string(),
|
||||
}),
|
||||
nozzle: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
zone: z.string(),
|
||||
}),
|
||||
}),
|
||||
pressure: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Consolidated state event schema (state changes only)
|
||||
*/
|
||||
export const stateEventDataSchema = z.object({
|
||||
is_default_state: z.boolean(),
|
||||
rotation_state: rotationStateSchema,
|
||||
mode_state: modeStateSchema,
|
||||
regulation_state: regulationStateSchema,
|
||||
pressure_state: pressureStateSchema,
|
||||
screw_state: screwStateSchema,
|
||||
heating_states: heatingStatesSchema,
|
||||
extruder_settings_state: extruderSettingsStateSchema,
|
||||
inverter_status_state: inverterStatusStateSchema,
|
||||
pid_settings: pidSettingsSchema,
|
||||
});
|
||||
|
||||
// ========== Event Schemas with Wrappers ==========
|
||||
|
||||
export const liveValuesEventSchema = eventSchema(liveValuesEventDataSchema);
|
||||
export const stateEventSchema = eventSchema(stateEventDataSchema);
|
||||
|
||||
// ========== Type Inferences ==========
|
||||
|
||||
export type StateEvent = z.infer<typeof stateEventSchema>;
|
||||
|
||||
// Additional exports for backward compatibility
|
||||
export const SetRegulationSchema = z.object({
|
||||
uses_rpm: z.boolean(),
|
||||
});
|
||||
|
||||
export const mode = z.object({
|
||||
mode: modeSchema,
|
||||
});
|
||||
|
||||
export type Extruder3NamespaceStore = {
|
||||
// Single state event from server
|
||||
state: StateEvent | null;
|
||||
defaultState: StateEvent | null;
|
||||
|
||||
// Time series data for live values
|
||||
motorFrequency: TimeSeries;
|
||||
motorCurrent: TimeSeries;
|
||||
motorScrewRpm: TimeSeries;
|
||||
motorPower: TimeSeries;
|
||||
|
||||
pressure: TimeSeries;
|
||||
nozzleTemperature: TimeSeries;
|
||||
frontTemperature: TimeSeries;
|
||||
backTemperature: TimeSeries;
|
||||
middleTemperature: TimeSeries;
|
||||
nozzlePower: TimeSeries;
|
||||
frontPower: TimeSeries;
|
||||
middlePower: TimeSeries;
|
||||
backPower: TimeSeries;
|
||||
|
||||
// Combined power consumption and energy
|
||||
combinedPower: TimeSeries;
|
||||
totalEnergyKWh: TimeSeries;
|
||||
};
|
||||
|
||||
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries();
|
||||
const { initialTimeSeries: backTemperature, insert: addBackTemperature } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: frontTemperature, insert: addFrontTemperature } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: middleTemperature, insert: addMiddleTemperature } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: nozzleTemperature, insert: addNozzleTemperature } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: nozzlePower, insert: addNozzlePower } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: frontPower, insert: addFrontPower } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: middlePower, insert: addMiddlePower } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: backPower, insert: addBackPower } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: combinedPower, insert: addCombinedPower } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: totalEnergyKWh, insert: addTotalEnergyKWh } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorCurrent, insert: addMotorCurrent } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorFrequency, insert: addMotorFrequency } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorScrewRpm, insert: addMotorScrewRpm } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: motorPower, insert: addMotorPower } =
|
||||
createTimeSeries();
|
||||
|
||||
export function extruder3MessageHandler(
|
||||
store: StoreApi<Extruder3NamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<Extruder3NamespaceStore>,
|
||||
): EventHandler {
|
||||
return (event: Event<any>) => {
|
||||
const eventName = event.name;
|
||||
|
||||
// Helper function to update store through buffer
|
||||
const updateStore = (
|
||||
updater: (state: Extruder3NamespaceStore) => Extruder3NamespaceStore,
|
||||
) => {
|
||||
throttledUpdater.updateWith(updater);
|
||||
};
|
||||
|
||||
try {
|
||||
if (eventName === "StateEvent") {
|
||||
console.log(event);
|
||||
const stateEvent = stateEventSchema.parse(event);
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
state: stateEvent,
|
||||
// only set default state if is_default_state is true
|
||||
defaultState: stateEvent.data.is_default_state
|
||||
? stateEvent
|
||||
: state.defaultState,
|
||||
}));
|
||||
} else if (eventName === "LiveValuesEvent") {
|
||||
const liveValuesEvent = liveValuesEventSchema.parse(event);
|
||||
const timestamp = event.ts;
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
motorScrewRpm: addMotorScrewRpm(state.motorScrewRpm, {
|
||||
value: liveValuesEvent.data.motor_status.screw_rpm,
|
||||
timestamp,
|
||||
}),
|
||||
motorCurrent: addMotorCurrent(state.motorCurrent, {
|
||||
value: liveValuesEvent.data.motor_status.current,
|
||||
timestamp,
|
||||
}),
|
||||
motorFrequency: addMotorFrequency(state.motorFrequency, {
|
||||
value: liveValuesEvent.data.motor_status.frequency,
|
||||
timestamp,
|
||||
}),
|
||||
motorPower: addMotorPower(state.motorPower, {
|
||||
value: liveValuesEvent.data.motor_status.power,
|
||||
timestamp,
|
||||
}),
|
||||
pressure: addPressure(state.pressure, {
|
||||
value: liveValuesEvent.data.pressure,
|
||||
timestamp,
|
||||
}),
|
||||
nozzleTemperature: addNozzleTemperature(state.nozzleTemperature, {
|
||||
value: liveValuesEvent.data.nozzle_temperature,
|
||||
timestamp,
|
||||
}),
|
||||
frontTemperature: addFrontTemperature(state.frontTemperature, {
|
||||
value: liveValuesEvent.data.front_temperature,
|
||||
timestamp,
|
||||
}),
|
||||
backTemperature: addBackTemperature(state.backTemperature, {
|
||||
value: liveValuesEvent.data.back_temperature,
|
||||
timestamp,
|
||||
}),
|
||||
middleTemperature: addMiddleTemperature(state.middleTemperature, {
|
||||
value: liveValuesEvent.data.middle_temperature,
|
||||
timestamp,
|
||||
}),
|
||||
nozzlePower: addNozzlePower(state.nozzlePower, {
|
||||
value: liveValuesEvent.data.nozzle_power,
|
||||
timestamp,
|
||||
}),
|
||||
frontPower: addFrontPower(state.frontPower, {
|
||||
value: liveValuesEvent.data.front_power,
|
||||
timestamp,
|
||||
}),
|
||||
middlePower: addMiddlePower(state.middlePower, {
|
||||
value: liveValuesEvent.data.middle_power,
|
||||
timestamp,
|
||||
}),
|
||||
backPower: addBackPower(state.backPower, {
|
||||
value: liveValuesEvent.data.back_power,
|
||||
timestamp,
|
||||
}),
|
||||
combinedPower: addCombinedPower(state.combinedPower, {
|
||||
value: liveValuesEvent.data.combined_power,
|
||||
timestamp,
|
||||
}),
|
||||
totalEnergyKWh: addTotalEnergyKWh(state.totalEnergyKWh, {
|
||||
value: liveValuesEvent.data.total_energy_kwh,
|
||||
timestamp,
|
||||
}),
|
||||
}));
|
||||
} else {
|
||||
handleUnhandledEventError(eventName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error processing ${eventName} event:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const createExtruder3NamespaceStore =
|
||||
(): StoreApi<Extruder3NamespaceStore> =>
|
||||
create<Extruder3NamespaceStore>(() => {
|
||||
return {
|
||||
state: null,
|
||||
defaultState: null,
|
||||
|
||||
motorCurrent,
|
||||
motorFrequency,
|
||||
motorScrewRpm,
|
||||
motorPower,
|
||||
|
||||
pressure,
|
||||
nozzleTemperature,
|
||||
frontTemperature,
|
||||
backTemperature,
|
||||
middleTemperature,
|
||||
nozzlePower,
|
||||
frontPower,
|
||||
backPower,
|
||||
middlePower,
|
||||
combinedPower,
|
||||
totalEnergyKWh,
|
||||
};
|
||||
});
|
||||
|
||||
const useExtruder3NamespaceImplementation =
|
||||
createNamespaceHookImplementation<Extruder3NamespaceStore>({
|
||||
createStore: createExtruder3NamespaceStore,
|
||||
createEventHandler: extruder3MessageHandler,
|
||||
});
|
||||
|
||||
export function useExtruder3Namespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): Extruder3NamespaceStore {
|
||||
// Generate namespace ID from validated machine ID
|
||||
const namespaceId = useMemo<NamespaceId>(
|
||||
() => ({
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
}),
|
||||
[machine_identification_unique],
|
||||
);
|
||||
|
||||
// Use the implementation with validated namespace ID
|
||||
return useExtruder3NamespaceImplementation(namespaceId);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { useMachineMutate as useMachineMutation } from "@/client/useClient";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { extruder3 } from "@/machines/properties";
|
||||
import { extruder3Route } from "@/routes/routes";
|
||||
import { z } from "zod";
|
||||
import { StateEvent, Mode, useExtruder3Namespace } from "./extruder3Namespace";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { produce } from "immer";
|
||||
|
||||
export function useExtruder3() {
|
||||
const { serial: serialString } = extruder3Route.useParams();
|
||||
|
||||
// Memoize the machine identification to keep it stable between renders
|
||||
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
|
||||
const serial = parseInt(serialString);
|
||||
|
||||
if (isNaN(serial)) {
|
||||
toastError(
|
||||
"Invalid Serial Number",
|
||||
`"${serialString}" is not a valid serial number.`,
|
||||
);
|
||||
|
||||
return {
|
||||
machine_identification: {
|
||||
vendor: 0,
|
||||
machine: 0,
|
||||
},
|
||||
serial: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
machine_identification: extruder3.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]);
|
||||
|
||||
// Get consolidated state and live values from namespace
|
||||
const {
|
||||
state,
|
||||
defaultState,
|
||||
motorCurrent,
|
||||
motorFrequency,
|
||||
motorScrewRpm,
|
||||
motorPower,
|
||||
pressure,
|
||||
|
||||
nozzleTemperature,
|
||||
frontTemperature,
|
||||
backTemperature,
|
||||
middleTemperature,
|
||||
nozzlePower,
|
||||
frontPower,
|
||||
middlePower,
|
||||
backPower,
|
||||
combinedPower,
|
||||
totalEnergyKWh,
|
||||
} = useExtruder3Namespace(machineIdentification);
|
||||
|
||||
// Single optimistic state for all state management
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
|
||||
useEffect(() => {
|
||||
if (state) {
|
||||
stateOptimistic.setReal(state);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// Helper function for optimistic updates using produce
|
||||
const updateStateOptimistically = (
|
||||
producer: (current: StateEvent) => void,
|
||||
serverRequest: () => void,
|
||||
) => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState && !stateOptimistic.isOptimistic) {
|
||||
stateOptimistic.setOptimistic(produce(currentState, producer));
|
||||
}
|
||||
serverRequest();
|
||||
};
|
||||
|
||||
// Action functions with verb-first names
|
||||
const setInverterRotationDirection = (forward: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.rotation_state.forward = forward;
|
||||
},
|
||||
() =>
|
||||
requestInverterRotationDirection({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetInverterRotationDirection: forward },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setExtruderMode = (mode: Mode) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.mode_state.mode = mode;
|
||||
},
|
||||
() =>
|
||||
requestExtruderMode({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetExtruderMode: mode },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setInverterRegulation = (usesRpm: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.regulation_state.uses_rpm = usesRpm;
|
||||
},
|
||||
() =>
|
||||
requestInverterRegulation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetInverterRegulation: usesRpm },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setInverterTargetRpm = (rpm: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.screw_state.target_rpm = rpm;
|
||||
},
|
||||
() =>
|
||||
requestInverterTargetRpm({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetInverterTargetRpm: rpm },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setInverterTargetPressure = (pressure: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.pressure_state.target_bar = pressure;
|
||||
},
|
||||
() =>
|
||||
requestInverterTargetPressure({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetInverterTargetPressure: pressure },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setNozzleHeatingTemperature = (temperature: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.heating_states.nozzle.target_temperature = temperature;
|
||||
},
|
||||
() =>
|
||||
requestNozzleHeatingTemperature({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetNozzleHeatingTemperature: temperature },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setFrontHeatingTemperature = (temperature: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.heating_states.front.target_temperature = temperature;
|
||||
},
|
||||
() =>
|
||||
requestFrontHeatingTemperature({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetFrontHeatingTargetTemperature: temperature },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setBackHeatingTemperature = (temperature: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.heating_states.back.target_temperature = temperature;
|
||||
},
|
||||
() =>
|
||||
requestBackHeatingTemperature({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetBackHeatingTargetTemperature: temperature },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setMiddleHeatingTemperature = (temperature: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.heating_states.middle.target_temperature = temperature;
|
||||
},
|
||||
() =>
|
||||
requestMiddleHeatingTemperature({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetMiddleHeatingTemperature: temperature },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setExtruderPressureLimit = (pressureLimit: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.extruder_settings_state.pressure_limit = pressureLimit;
|
||||
},
|
||||
() =>
|
||||
requestExtruderPressureLimit({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetExtruderPressureLimit: pressureLimit },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setExtruderPressureLimitEnabled = (enabled: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.extruder_settings_state.pressure_limit_enabled = enabled;
|
||||
},
|
||||
() =>
|
||||
requestExtruderPressureLimitEnabled({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetExtruderPressureLimitIsEnabled: enabled },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setPressurePidKp = (kp: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.pid_settings.pressure.kp = kp;
|
||||
},
|
||||
() => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState) {
|
||||
const settings = produce(
|
||||
currentState.data.pid_settings.pressure,
|
||||
(draft) => {
|
||||
draft.kp = kp;
|
||||
},
|
||||
);
|
||||
requestPressurePidSettings({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetPressurePidSettings: settings },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setPressurePidKi = (ki: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.pid_settings.pressure.ki = ki;
|
||||
},
|
||||
() => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState) {
|
||||
const settings = produce(
|
||||
currentState.data.pid_settings.pressure,
|
||||
(draft) => {
|
||||
draft.ki = ki;
|
||||
},
|
||||
);
|
||||
requestPressurePidSettings({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetPressurePidSettings: settings },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setPressurePidKd = (kd: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.pid_settings.pressure.kd = kd;
|
||||
},
|
||||
() => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState) {
|
||||
const settings = produce(
|
||||
currentState.data.pid_settings.pressure,
|
||||
(draft) => {
|
||||
draft.kd = kd;
|
||||
},
|
||||
);
|
||||
requestPressurePidSettings({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetPressurePidSettings: settings },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setTemperaturePidValue = (
|
||||
zone: "front" | "middle" | "back" | "nozzle",
|
||||
key: "kp" | "ki" | "kd",
|
||||
value: number,
|
||||
) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.pid_settings.temperature[zone][key] = value;
|
||||
},
|
||||
() => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState) {
|
||||
const settings = produce(
|
||||
currentState.data.pid_settings.temperature[zone],
|
||||
(draft) => {
|
||||
draft[key] = value;
|
||||
},
|
||||
);
|
||||
requestTemperaturePidSettings({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetTemperaturePidSettings: settings },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const resetInverter = () => {
|
||||
// No optimistic update needed for reset
|
||||
requestResetInverter({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { ResetInverter: true },
|
||||
});
|
||||
};
|
||||
|
||||
// Mutation hooks
|
||||
const { request: requestInverterRotationDirection } = useMachineMutation(
|
||||
z.object({ SetInverterRotationDirection: z.boolean() }),
|
||||
);
|
||||
|
||||
const { request: requestExtruderMode } = useMachineMutation(
|
||||
z.object({ SetExtruderMode: z.enum(["Heat", "Extrude", "Standby"]) }),
|
||||
);
|
||||
|
||||
const { request: requestInverterRegulation } = useMachineMutation(
|
||||
z.object({ SetInverterRegulation: z.boolean() }),
|
||||
);
|
||||
|
||||
const { request: requestInverterTargetRpm } = useMachineMutation(
|
||||
z.object({ SetInverterTargetRpm: z.number() }),
|
||||
);
|
||||
|
||||
const { request: requestInverterTargetPressure } = useMachineMutation(
|
||||
z.object({ SetInverterTargetPressure: z.number() }),
|
||||
);
|
||||
|
||||
const { request: requestNozzleHeatingTemperature } = useMachineMutation(
|
||||
z.object({ SetNozzleHeatingTemperature: z.number() }),
|
||||
);
|
||||
|
||||
const { request: requestFrontHeatingTemperature } = useMachineMutation(
|
||||
z.object({ SetFrontHeatingTargetTemperature: z.number() }),
|
||||
);
|
||||
|
||||
const { request: requestBackHeatingTemperature } = useMachineMutation(
|
||||
z.object({ SetBackHeatingTargetTemperature: z.number() }),
|
||||
);
|
||||
|
||||
const { request: requestMiddleHeatingTemperature } = useMachineMutation(
|
||||
z.object({ SetMiddleHeatingTemperature: z.number() }),
|
||||
);
|
||||
|
||||
const { request: requestExtruderPressureLimit } = useMachineMutation(
|
||||
z.object({ SetExtruderPressureLimit: z.number() }),
|
||||
);
|
||||
|
||||
const { request: requestExtruderPressureLimitEnabled } = useMachineMutation(
|
||||
z.object({ SetExtruderPressureLimitIsEnabled: z.boolean() }),
|
||||
);
|
||||
|
||||
const { request: requestPressurePidSettings } = useMachineMutation(
|
||||
z.object({
|
||||
SetPressurePidSettings: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { request: requestTemperaturePidSettings } = useMachineMutation(
|
||||
z.object({
|
||||
SetTemperaturePidSettings: z.object({
|
||||
ki: z.number(),
|
||||
kp: z.number(),
|
||||
kd: z.number(),
|
||||
zone: z.string(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { request: requestResetInverter } = useMachineMutation(
|
||||
z.object({ ResetInverter: z.boolean() }),
|
||||
);
|
||||
|
||||
return {
|
||||
// Consolidated state
|
||||
state: stateOptimistic.value?.data,
|
||||
|
||||
// Default state for initial values
|
||||
defaultState: defaultState?.data,
|
||||
|
||||
// Individual live values (TimeSeries)
|
||||
motorCurrent,
|
||||
motorFrequency,
|
||||
motorScrewRpm,
|
||||
motorPower,
|
||||
|
||||
pressure,
|
||||
nozzleTemperature,
|
||||
frontTemperature,
|
||||
backTemperature,
|
||||
middleTemperature,
|
||||
nozzlePower,
|
||||
frontPower,
|
||||
middlePower,
|
||||
backPower,
|
||||
combinedPower,
|
||||
totalEnergyKWh,
|
||||
|
||||
// Loading states
|
||||
isLoading: stateOptimistic.isOptimistic,
|
||||
isDisabled: !stateOptimistic.isInitialized,
|
||||
|
||||
// Action functions (verb-first)
|
||||
setInverterRotationDirection,
|
||||
setExtruderMode,
|
||||
setInverterRegulation,
|
||||
setInverterTargetRpm,
|
||||
setInverterTargetPressure,
|
||||
setNozzleHeatingTemperature,
|
||||
setFrontHeatingTemperature,
|
||||
setBackHeatingTemperature,
|
||||
setMiddleHeatingTemperature,
|
||||
setExtruderPressureLimit,
|
||||
setExtruderPressureLimitEnabled,
|
||||
setPressurePidKp,
|
||||
setPressurePidKi,
|
||||
setPressurePidKd,
|
||||
setTemperaturePidValue,
|
||||
resetInverter,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Page } from "@/components/Page";
|
||||
import React from "react";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
|
||||
import { MinMaxValue, TIMEFRAME_OPTIONS } from "@/control/MinMaxValue";
|
||||
|
||||
import { EditValue } from "@/control/EditValue";
|
||||
import { Label } from "@/control/Label";
|
||||
@@ -28,6 +29,12 @@ export function Laser1ControlPage() {
|
||||
const targetDiameter = state?.laser_state?.target_diameter ?? 0;
|
||||
const lowerTolerance = state?.laser_state?.lower_tolerance ?? 0;
|
||||
const higherTolerance = state?.laser_state?.higher_tolerance ?? 0;
|
||||
|
||||
// Detect if this is a 2-axis laser
|
||||
const isTwoAxis = !!x_diameter?.current || !!y_diameter?.current;
|
||||
// Shared timeframe state (default 5 minutes)
|
||||
const [timeframe, setTimeframe] = React.useState<number>(5 * 60 * 1000);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
@@ -72,11 +79,64 @@ export function Laser1ControlPage() {
|
||||
<div className="flex flex-row items-center gap-6">
|
||||
<TimeSeriesValueNumeric
|
||||
label="Roundness"
|
||||
unit="%"
|
||||
timeseries={roundness}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
renderValue={(value) => (value * 100).toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 border-t pt-4">
|
||||
{isTwoAxis ? (
|
||||
// For 2-axis lasers: show diameter and roundness min/max side by side
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<MinMaxValue
|
||||
label="Diameter Range"
|
||||
unit="mm"
|
||||
timeseries={diameter}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
timeframe={timeframe}
|
||||
hideSelector
|
||||
/>
|
||||
{roundness?.current && (
|
||||
<MinMaxValue
|
||||
label="Roundness Range"
|
||||
unit="%"
|
||||
timeseries={roundness}
|
||||
renderValue={(value) => (value * 100).toFixed(2)}
|
||||
timeframe={timeframe}
|
||||
hideSelector
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// For single-axis lasers: show only diameter min/max with shared selector
|
||||
<MinMaxValue
|
||||
label="Diameter Range"
|
||||
unit="mm"
|
||||
timeseries={diameter}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
timeframe={timeframe}
|
||||
hideSelector
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Shared timeframe selector for diameter/roundness */}
|
||||
<div className="mt-3 flex flex-row flex-wrap gap-2">
|
||||
{TIMEFRAME_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setTimeframe(option.value)}
|
||||
className={`rounded-md px-3 py-1 text-sm transition-colors ${
|
||||
timeframe === option.value
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ControlCard>
|
||||
<ControlCard title="Settings">
|
||||
<Label label="Set Target Diameter">
|
||||
|
||||
@@ -12,128 +12,143 @@ 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;
|
||||
|
||||
const diameterColor = "#3b82f6";
|
||||
const xDiameterColor = "#ef4444";
|
||||
const yDiameterColor = "#22c55e";
|
||||
const roundnessColor = "#eab308";
|
||||
// Convert roundness from ratio (0-1) to percentage (0-100)
|
||||
const roundnessPercent = React.useMemo(() => {
|
||||
if (!roundness) return null;
|
||||
|
||||
const config: GraphConfig = {
|
||||
title: "Diameter",
|
||||
defaultTimeWindow: 30 * 60 * 1000, // 30 minutes
|
||||
exportFilename: "diameter_data",
|
||||
const transformValue = (
|
||||
val: { value: number; timestamp: number } | null,
|
||||
) => {
|
||||
if (!val) return null;
|
||||
return { value: val.value * 100, timestamp: val.timestamp };
|
||||
};
|
||||
|
||||
const transformSeries = (series: any) => {
|
||||
if (!series || !series.values) return series;
|
||||
return {
|
||||
...series,
|
||||
values: series.values.map(transformValue),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
current: roundness.current ? transformValue(roundness.current) : null,
|
||||
long: transformSeries(roundness.long),
|
||||
short: transformSeries(roundness.short),
|
||||
};
|
||||
}, [roundness]);
|
||||
|
||||
const diameterColor = "#3b82f6"; // blue
|
||||
const xDiameterColor = "#ef4444"; // red
|
||||
const yDiameterColor = "#22c55e"; // green
|
||||
const roundnessColor = "#eab308"; // yellow
|
||||
|
||||
// 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,
|
||||
},
|
||||
],
|
||||
newData={roundnessGraphData}
|
||||
unit="%"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
config={{
|
||||
...baseGraphConfig,
|
||||
title: "Roundness (%)",
|
||||
exportFilename: "roundness_data",
|
||||
colors: {
|
||||
...baseGraphConfig.colors,
|
||||
primary: roundnessColor,
|
||||
},
|
||||
}}
|
||||
unit="mm"
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
config={config}
|
||||
graphId="diameter-graph"
|
||||
graphId="roundness-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: roundness,
|
||||
color: roundnessColor,
|
||||
title: "Roundness",
|
||||
},
|
||||
]}
|
||||
unit="mm"
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
config={config}
|
||||
graphId="diameter-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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -103,6 +103,94 @@ export const winder2: MachineProperties = {
|
||||
],
|
||||
};
|
||||
|
||||
export const extruder3: MachineProperties = {
|
||||
name: "Extruder",
|
||||
version: "V3",
|
||||
slug: "extruder3",
|
||||
icon: "qi:Extruder",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x0016,
|
||||
},
|
||||
device_roles: [
|
||||
{
|
||||
role: 0,
|
||||
role_label: "Bus Coupler",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0x44c2c52,
|
||||
revision: 0x120000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 1,
|
||||
role_label: "Inverter Interface",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 394604626,
|
||||
revision: 1376256,
|
||||
},
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 394604626,
|
||||
revision: 0x140000,
|
||||
},
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 394604626,
|
||||
revision: 0x160000,
|
||||
},
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 394604626,
|
||||
revision: 0x100000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 2,
|
||||
role_label: "Heating Elements",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 131346514,
|
||||
revision: 1179648,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 3,
|
||||
role_label: "Pressure Sensor",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 197996626,
|
||||
revision: 1310720,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 4,
|
||||
role_label: "Thermometers",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0xc843052,
|
||||
revision: 1441792,
|
||||
},
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0xc843052,
|
||||
revision: 0x150000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const extruder2: MachineProperties = {
|
||||
name: "Extruder",
|
||||
version: "V2",
|
||||
@@ -340,13 +428,123 @@ export const aquapath1: MachineProperties = {
|
||||
],
|
||||
};
|
||||
|
||||
export const testmachine: MachineProperties = {
|
||||
name: "TestMachine",
|
||||
version: "V1",
|
||||
slug: "testmachine",
|
||||
icon: "lu:Disc3",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x0033,
|
||||
},
|
||||
device_roles: [
|
||||
{
|
||||
role: 0,
|
||||
role_label: "Bus Coupler",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0x44c2c52,
|
||||
revision: 0x120000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 1,
|
||||
role_label: "EL2004",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0x7d43052,
|
||||
revision: 0x120000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
export const 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,
|
||||
extruder3,
|
||||
extruder2,
|
||||
laser1,
|
||||
mock1,
|
||||
buffer1,
|
||||
wagoPower1,
|
||||
aquapath1,
|
||||
testmachine,
|
||||
analogInputTestMachine,
|
||||
ip20TestMachine,
|
||||
];
|
||||
|
||||
export const getMachineProperties = (
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { Page } from "@/components/Page";
|
||||
import React from "react";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { SelectionGroup } from "@/control/SelectionGroup";
|
||||
import { Label } from "@/control/Label";
|
||||
import { useTestMachine } from "./useTestMachine";
|
||||
|
||||
export function TestMachineControlPage() {
|
||||
const { state, setLed, setAllLeds } = useTestMachine();
|
||||
|
||||
const safeState = state ?? { led_on: [false, false, false, false] };
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
{/* LED Controls */}
|
||||
<ControlCard title="Machine LEDs">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{safeState.led_on.map((led, index) => (
|
||||
<Label key={index} label={`LED ${index + 1}`}>
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={led ? "On" : "Off"}
|
||||
orientation="vertical"
|
||||
className="grid h-full grid-cols-2 gap-2"
|
||||
options={{
|
||||
Off: {
|
||||
children: "Off",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-red-600",
|
||||
className: "h-full",
|
||||
},
|
||||
On: {
|
||||
children: "On",
|
||||
icon: "lu:CirclePlay",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
}}
|
||||
onChange={(value) => setLed(index, value === "On")}
|
||||
/>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</ControlCard>
|
||||
|
||||
{/* Master LED Control */}
|
||||
<ControlCard title="Master LED Control">
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={safeState.led_on.every(Boolean) ? "On" : "Off"}
|
||||
orientation="horizontal"
|
||||
options={{
|
||||
Off: { children: "Turn All Off" },
|
||||
On: { children: "Turn All On" },
|
||||
}}
|
||||
onChange={(value) => setAllLeds(value === "On")}
|
||||
/>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Topbar } from "@/components/Topbar";
|
||||
import { testMachineSerialRoute } from "@/routes/routes";
|
||||
import React from "react";
|
||||
|
||||
export function TestMachinePage() {
|
||||
const { serial } = testMachineSerialRoute.useParams();
|
||||
return (
|
||||
<Topbar
|
||||
pathname={`/_sidebar/machines/testmachine/${serial}`}
|
||||
items={[
|
||||
{
|
||||
link: "control",
|
||||
activeLink: "control",
|
||||
title: "Control",
|
||||
icon: "lu:CirclePlay",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { StoreApi } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
EventHandler,
|
||||
eventSchema,
|
||||
Event,
|
||||
handleUnhandledEventError,
|
||||
NamespaceId,
|
||||
createNamespaceHookImplementation,
|
||||
ThrottledStoreUpdater,
|
||||
} from "@/client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
|
||||
// ========== Event Schema ==========
|
||||
|
||||
export const stateEventDataSchema = z.object({
|
||||
led_on: z.array(z.boolean()).length(4),
|
||||
});
|
||||
|
||||
export const stateEventSchema = eventSchema(stateEventDataSchema);
|
||||
|
||||
export type StateEvent = z.infer<typeof stateEventDataSchema>;
|
||||
|
||||
// ========== Store ==========
|
||||
export type TestMachineNamespaceStore = {
|
||||
state: StateEvent | null;
|
||||
};
|
||||
|
||||
export const createTestMachineNamespaceStore =
|
||||
(): StoreApi<TestMachineNamespaceStore> =>
|
||||
create<TestMachineNamespaceStore>(() => ({
|
||||
state: null,
|
||||
}));
|
||||
|
||||
// ========== Message Handler ==========
|
||||
export function testMachineMessageHandler(
|
||||
store: StoreApi<TestMachineNamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<TestMachineNamespaceStore>,
|
||||
): EventHandler {
|
||||
return (event: Event<any>) => {
|
||||
const updateStore = (
|
||||
updater: (state: TestMachineNamespaceStore) => TestMachineNamespaceStore,
|
||||
) => throttledUpdater.updateWith(updater);
|
||||
|
||||
try {
|
||||
if (event.name === "StateEvent") {
|
||||
const parsed = stateEventSchema.parse(event);
|
||||
updateStore(() => ({ state: parsed.data }));
|
||||
} else {
|
||||
handleUnhandledEventError(event.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${event.name}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Namespace Hook ==========
|
||||
const useTestMachineNamespaceImplementation =
|
||||
createNamespaceHookImplementation<TestMachineNamespaceStore>({
|
||||
createStore: createTestMachineNamespaceStore,
|
||||
createEventHandler: testMachineMessageHandler,
|
||||
});
|
||||
|
||||
export function useTestMachineNamespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): TestMachineNamespaceStore {
|
||||
const namespaceId: NamespaceId = {
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
};
|
||||
|
||||
return useTestMachineNamespaceImplementation(namespaceId);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { testMachineSerialRoute } from "@/routes/routes";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { useTestMachineNamespace, StateEvent } from "./testMachineNamespace";
|
||||
import { useMachineMutate } from "@/client/useClient";
|
||||
import { produce } from "immer";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { testmachine } from "@/machines/properties";
|
||||
import { z } from "zod";
|
||||
export function useTestMachine() {
|
||||
const { serial: serialString } = testMachineSerialRoute.useParams();
|
||||
|
||||
// Memoize machine identification
|
||||
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
|
||||
const serial = parseInt(serialString);
|
||||
|
||||
if (isNaN(serial)) {
|
||||
toastError(
|
||||
"Invalid Serial Number",
|
||||
`"${serialString}" is not a valid serial number.`,
|
||||
);
|
||||
|
||||
return {
|
||||
machine_identification: { vendor: 0, machine: 0 },
|
||||
serial: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
machine_identification: testmachine.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]);
|
||||
|
||||
// Namespace state from backend
|
||||
const { state } = useTestMachineNamespace(machineIdentification);
|
||||
|
||||
// Optimistic state
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
|
||||
useEffect(() => {
|
||||
if (state) stateOptimistic.setReal(state);
|
||||
}, [state, stateOptimistic]);
|
||||
|
||||
// Generic mutation sender
|
||||
const { request: sendMutation } = useMachineMutate(
|
||||
z.object({
|
||||
action: z.string(),
|
||||
value: z.any(),
|
||||
}),
|
||||
);
|
||||
|
||||
const updateStateOptimistically = (
|
||||
producer: (current: StateEvent) => void,
|
||||
serverRequest?: () => void,
|
||||
) => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState)
|
||||
stateOptimistic.setOptimistic(produce(currentState, producer));
|
||||
serverRequest?.();
|
||||
};
|
||||
|
||||
const setLed = (index: number, on: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.led_on[index] = on;
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetLed", value: { index, on } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setAllLeds = (on: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.led_on = [on, on, on, on];
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetAllLeds", value: { on } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
state: stateOptimistic.value,
|
||||
setLed,
|
||||
setAllLeds,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||