Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3dd01cc65 | ||
|
|
5fbdf7b9ab | ||
|
|
85bd4b0d96 | ||
|
|
dfce760521 | ||
|
|
30c97bf9aa | ||
|
|
2c8373adcf | ||
|
|
d08972254c | ||
|
|
a76d39e0cd | ||
|
|
a74d0dbb80 | ||
|
|
17ab24739d | ||
|
|
09f0ea3454 | ||
|
|
0dd2494ea0 | ||
|
|
57c1cda229 | ||
|
|
8c2b20b148 | ||
|
|
12e7f245ce | ||
|
|
671e36fa0f | ||
|
|
1ffd7eade6 | ||
|
|
703bbfc9e6 | ||
|
|
057afb0b2e | ||
|
|
94bd29c734 | ||
|
|
d078b7b143 | ||
|
|
acbeb98f4f | ||
|
|
cab8bf062d | ||
|
|
9e04a7fa33 | ||
|
|
c92d4847ca | ||
|
|
aceeaa08c2 | ||
|
|
35946d0c88 | ||
|
|
a5b9a0b1d5 | ||
|
|
412d625138 | ||
|
|
1ec20074e9 | ||
|
|
4d796f8422 | ||
|
|
1406d6c724 | ||
|
|
33441b95a5 | ||
|
|
8a3f312675 | ||
|
|
5c327428d6 | ||
|
|
4e4d31cad1 | ||
|
|
031fdf25a9 | ||
|
|
857cda1455 | ||
|
|
de5f3a5cfc | ||
|
|
2da7af522c | ||
|
|
712284e2f0 | ||
|
|
402c47a81b | ||
|
|
54a000a280 | ||
|
|
41fe118144 | ||
|
|
aaa6ff604f | ||
|
|
a350bb378f | ||
|
|
aa2212820e | ||
|
|
578e70119d | ||
|
|
e8796e00af | ||
|
|
735f27770a | ||
|
|
700a255157 | ||
|
|
ec06275c08 | ||
|
|
11d0c8bb53 | ||
|
|
535f24f1d1 | ||
|
|
172913d8ec | ||
|
|
0f99c54587 | ||
|
|
f89d70c5f6 | ||
|
|
0fc070be69 | ||
|
|
b93eaf530e | ||
|
|
76d8504651 | ||
|
|
06acd43ff9 | ||
|
|
870090c950 | ||
|
|
65777f10ab | ||
|
|
67d145de56 | ||
|
|
d45d1ed01a | ||
|
|
0d6c7bc0a5 | ||
|
|
15f013f710 | ||
|
|
a32e26a041 | ||
|
|
41c7e1f74e | ||
|
|
5a856e6e2b | ||
|
|
8ca16d447e | ||
|
|
b99549895b | ||
|
|
07cbaa4518 | ||
|
|
c78f5ff5a0 | ||
|
|
33946a6fa3 | ||
|
|
02dc67bb85 | ||
|
|
b5ae86b19c | ||
|
|
35567b967f | ||
|
|
c45718e670 | ||
|
|
74dcd027e0 | ||
|
|
c793d3fa50 | ||
|
|
04fdc8a9d1 | ||
|
|
fd702ff0bc | ||
|
|
f25bf71753 | ||
|
|
4c33873095 | ||
|
|
8dbcb5efc3 | ||
|
|
36426aafe0 | ||
|
|
21cf9314be | ||
|
|
4f2af7b90d | ||
|
|
06795dcf78 | ||
|
|
1241d0a4a7 | ||
|
|
f36723f435 | ||
|
|
e96854a6bc | ||
|
|
04c5a673da | ||
|
|
40e17ef5f1 | ||
|
|
39200aac12 | ||
|
|
43a405eec4 | ||
|
|
0206a4ddca | ||
|
|
34da7178f8 | ||
|
|
2726c47280 | ||
|
|
63cf25b3d6 | ||
|
|
eae13207e4 | ||
|
|
f65d100cd5 | ||
|
|
13e4ea1ac7 | ||
|
|
49788246c8 | ||
|
|
d4e849d588 | ||
|
|
2689d19928 | ||
|
|
968e9327f6 | ||
|
|
3cbb74b3b0 | ||
|
|
50fcfd6e30 | ||
|
|
ce0f27787c | ||
|
|
08ab7fd404 | ||
|
|
f138b61a45 | ||
|
|
0ad0a58d46 | ||
|
|
3d13e6cee2 | ||
|
|
86915c2987 | ||
|
|
ada8d0f12c | ||
|
|
735456b6eb | ||
|
|
b46a528adb | ||
|
|
80f2343a60 | ||
|
|
e030fbe23e | ||
|
|
1ef0cb608c | ||
|
|
789a84e511 | ||
|
|
28620b6a23 | ||
|
|
01609513fb | ||
|
|
379a4ec417 | ||
|
|
28d898e780 | ||
|
|
daabb170f5 | ||
|
|
c4803e63dd | ||
|
|
0022d86035 | ||
|
|
dc711a820f | ||
|
|
59cf2cea2a | ||
|
|
da69ad1c3e | ||
|
|
6ff434fb47 | ||
|
|
a63c57a0b9 | ||
|
|
21c43adcb4 | ||
|
|
afa23666e2 | ||
|
|
245ad64426 | ||
|
|
9665971150 | ||
|
|
3b056c8f26 | ||
|
|
62b35e0237 | ||
|
|
11bc62f40e | ||
|
|
116c76d218 | ||
|
|
4dcedb537d | ||
|
|
09022c9c86 | ||
|
|
80cf98b11d | ||
|
|
644e750353 | ||
|
|
aecabfde43 | ||
|
|
686678a1fe | ||
|
|
7ac0b788c2 | ||
|
|
3791ee785d | ||
|
|
7341dee976 | ||
|
|
b8168c6b43 | ||
|
|
6026d2a658 | ||
|
|
a301f3e228 | ||
|
|
0b6bd68a24 | ||
|
|
e7fd390263 | ||
|
|
eab675795a | ||
|
|
8c2dc9a507 | ||
|
|
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 | ||
|
|
bb9a86d52e | ||
|
|
e5329e40c6 | ||
|
|
319611f69d | ||
|
|
4b16253c8c | ||
|
|
b60423fde6 | ||
|
|
5d3399c72e | ||
|
|
d247bafcbb | ||
|
|
5227aad4c6 | ||
|
|
da4c54a99e | ||
|
|
8d42bd93f7 | ||
|
|
37e467272b | ||
|
|
ed0b9cd5ae | ||
|
|
8045f799f0 | ||
|
|
ca6ea16f89 | ||
|
|
452614abf1 | ||
|
|
f63c797cd1 | ||
|
|
de791f6ea8 |
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_
|
||||
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
[workspace]
|
||||
members = [
|
||||
# Hardware Abstraction Layer (Framework)
|
||||
"server",
|
||||
"ethercat-hal",
|
||||
"ethercat-hal-derive",
|
||||
|
||||
# Control Framework (Framework)
|
||||
"control-core",
|
||||
"machines",
|
||||
#"ethercat-eeprom-dump",
|
||||
"control-core-derive",
|
||||
|
||||
# Machine Implementations (QiTech-Specific)
|
||||
"server",
|
||||
|
||||
# Utilities
|
||||
"ethercat-eeprom-dump",
|
||||
"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.*
|
||||
@@ -12,43 +17,30 @@ QiTech Control is an industrial control panel software for the next generation o
|
||||
|
||||
# Repo Structure
|
||||
|
||||
This repository is organized as a monorepo with clear separation of concerns. See [Repository Organization](./docs/repository-organization.md) for detailed architecture information.
|
||||
Frontend
|
||||
|
||||
## Hardware Abstraction Layer (Framework)
|
||||
- `/electron`: Frontend code for the control software built with React and Electron.
|
||||
|
||||
- `/ethercat-hal`: Hardware abstraction layer for Beckhoff and other EtherCAT devices and protocols
|
||||
- `/ethercat-hal-derive`: Derive macros for `ethercat-hal`
|
||||
Backend
|
||||
|
||||
## Control Framework (Framework)
|
||||
- `/server`: Glue between Beckhoff and Electron. Implements machine logic.
|
||||
- `/ethercat-hal`: Hardware abstraction layer for Beckhoff (and possibly other EtherCat) devices and protocols.
|
||||
- `/ethercat-hal-derive`: Macros for `ethercat-hal`
|
||||
- `/control-core`: Core control logic for the server.
|
||||
|
||||
- `/control-core`: Generic control logic, actor system, and API abstractions
|
||||
- `/control-core-derive`: Derive macros for `control-core`
|
||||
Operating System
|
||||
|
||||
## Machine Implementations (QiTech-Specific)
|
||||
- `/nixos`: Custom Linux with realtime kernel & preconfigured for touchscreens.
|
||||
|
||||
- `/server`: QiTech-specific machine implementations and glue code that ties everything together
|
||||
Other
|
||||
|
||||
## User Interface
|
||||
|
||||
- `/electron`: Electron-based control panel UI built with React and TypeScript
|
||||
|
||||
## Operating System
|
||||
|
||||
- `/nixos`: Custom NixOS configuration with real-time kernel & touchscreen support
|
||||
|
||||
## Utilities
|
||||
|
||||
- `/ethercat-eeprom-dump`: CLI tool for dumping EtherCAT device EEPROM data
|
||||
|
||||
## Documentation
|
||||
|
||||
- `/docs`: Project documentation, architecture guides, and developer resources
|
||||
- `/docs`: Documentation for the project.
|
||||
|
||||
# Technology Choices
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -87,17 +79,20 @@ 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
|
||||
|
||||
## Architecture & Organization
|
||||
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
|
||||
|
||||
- [x] [**Organization Summary**](./docs/organization-summary.md) - **Quick reference** for understanding the repository structure
|
||||
- [x] [Architecture & Data Flow](./docs/architecture-overview.md) - System architecture overview with diagrams
|
||||
- [x] [Repository Organization](./docs/repository-organization.md) - Monorepo structure, component boundaries, and modularization options
|
||||
- [x] [Component Interfaces](./docs/component-interfaces.md) - How components interact and interface boundaries
|
||||
- [x] [Migration Guide for Splitting](./docs/migration-guide-splitting.md) - Step-by-step guide for splitting into multiple repositories (if needed)
|
||||
|
||||
## Component Documentation
|
||||
- [x] Example Winder V2
|
||||
|
||||
- [ ] Electron
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -12,18 +12,16 @@ anyhow = "1.0.100"
|
||||
axum = "0.8.6"
|
||||
|
||||
ethercat_hal = { path = "../ethercat-hal" }
|
||||
units = { path = "../units" }
|
||||
ethercrab = "0.6"
|
||||
interfaces = "0.0.9"
|
||||
serde = "1.0.219"
|
||||
serde_json = "1.0.143"
|
||||
serialport = "4.7.3"
|
||||
smol = "2.0.2"
|
||||
socketioxide = "0.17.2"
|
||||
uom = { version = "0.36.0", default-features = false, features = ["f64"] }
|
||||
bitvec = { version = "1.0.1", features = ["alloc"] }
|
||||
crc = "3.3.0"
|
||||
serial = "0.4.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" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
angular_velocity::radian_per_second,
|
||||
f64::{AngularAcceleration, AngularVelocity},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
acceleration::meter_per_second_squared,
|
||||
f64::{Acceleration, Velocity},
|
||||
velocity::meter_per_second,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angle::radian,
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
angular_velocity::radian_per_second,
|
||||
@@ -199,8 +199,8 @@ impl AngularAccelerationPositionController {
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use control_core::controllers::second_degree_motion::angular_acceleration_position_controller::AngularAccelerationPositionController;
|
||||
/// use uom::si::f64::Angle;
|
||||
/// use uom::si::angle::degree;
|
||||
/// use units::f64::Angle;
|
||||
/// use units::angle::degree;
|
||||
///
|
||||
/// let mut controller = AngularAccelerationPositionController::new_simple(
|
||||
/// Some(Angle::new::<degree>(180.0)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
angular_jerk::radian_per_second_cubed,
|
||||
angular_velocity::radian_per_second,
|
||||
@@ -23,7 +23,7 @@ use super::jerk_speed_controller::JerkSpeedController;
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// use units::{
|
||||
/// angular_velocity::revolution_per_minute,
|
||||
/// angular_acceleration::revolution_per_minute_per_second,
|
||||
/// angular_jerk::revolution_per_minute_per_second_squared,
|
||||
@@ -65,7 +65,7 @@ impl AngularJerkSpeedController {
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// use units::{
|
||||
/// angular_velocity::revolution_per_minute,
|
||||
/// angular_acceleration::revolution_per_minute_per_second,
|
||||
/// angular_jerk::revolution_per_minute_per_second_squared,
|
||||
@@ -127,7 +127,7 @@ impl AngularJerkSpeedController {
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// use units::{
|
||||
/// angular_velocity::revolution_per_minute,
|
||||
/// angular_acceleration::revolution_per_minute_per_second,
|
||||
/// angular_jerk::revolution_per_minute_per_second_squared,
|
||||
@@ -185,7 +185,7 @@ impl AngularJerkSpeedController {
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use std::time::Instant;
|
||||
/// use uom::si::{angular_velocity::revolution_per_minute, f64::AngularVelocity};
|
||||
/// use units::{angular_velocity::revolution_per_minute, f64::AngularVelocity};
|
||||
///
|
||||
/// let mut controller = AngularJerkSpeedController::new_simple(/* ... */);
|
||||
/// let target = AngularVelocity::new::<revolution_per_minute>(1500.0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
acceleration::meter_per_second_squared,
|
||||
f64::{Acceleration, Length, Velocity},
|
||||
length::meter,
|
||||
@@ -22,8 +22,8 @@ use super::acceleration_position_controller::{
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
|
||||
/// use uom::si::f64::{Length, Velocity, Acceleration};
|
||||
/// use units::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
|
||||
/// use units::f64::{Length, Velocity, Acceleration};
|
||||
///
|
||||
/// let position_limit = Length::new::<meter>(10.0);
|
||||
/// let max_speed = Velocity::new::<meter_per_second>(2.0);
|
||||
@@ -101,8 +101,8 @@ impl LinearAccelerationPositionController {
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
|
||||
/// use uom::si::f64::{Length, Velocity, Acceleration};
|
||||
/// use units::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
|
||||
/// use units::f64::{Length, Velocity, Acceleration};
|
||||
///
|
||||
/// let position_limit = Length::new::<meter>(5.0); // Creates [-5.0, +5.0] meter range
|
||||
/// let max_speed = Velocity::new::<meter_per_second>(1.0); // Creates [-1.0, +1.0] m/s range
|
||||
@@ -150,7 +150,7 @@ impl LinearAccelerationPositionController {
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use std::time::Instant;
|
||||
/// use uom::si::{length::meter, f64::Length};
|
||||
/// use units::{length::meter, f64::Length};
|
||||
///
|
||||
/// let mut controller = LinearAccelerationPositionController::new_simple(
|
||||
/// Some(Length::new::<meter>(10.0)),
|
||||
@@ -375,7 +375,7 @@ impl LinearAccelerationPositionController {
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{length::meter, velocity::meter_per_second, f64::{Length, Velocity}};
|
||||
/// use units::{length::meter, velocity::meter_per_second, f64::{Length, Velocity}};
|
||||
///
|
||||
/// let new_position = Length::new::<meter>(0.0);
|
||||
///
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
acceleration::meter_per_second_squared,
|
||||
f64::{Acceleration, Jerk, Velocity},
|
||||
jerk::meter_per_second_cubed,
|
||||
@@ -23,7 +23,7 @@ use super::jerk_speed_controller::JerkSpeedController;
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// use units::{
|
||||
/// velocity::meter_per_second,
|
||||
/// acceleration::meter_per_second_squared,
|
||||
/// jerk::meter_per_second_cubed,
|
||||
@@ -65,7 +65,7 @@ impl LinearJerkSpeedController {
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// use units::{
|
||||
/// velocity::meter_per_second,
|
||||
/// acceleration::meter_per_second_squared,
|
||||
/// jerk::meter_per_second_cubed,
|
||||
@@ -127,7 +127,7 @@ impl LinearJerkSpeedController {
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use uom::si::{
|
||||
/// use units::{
|
||||
/// velocity::meter_per_second,
|
||||
/// acceleration::meter_per_second_squared,
|
||||
/// jerk::meter_per_second_cubed,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use uom::si::{angle::radian, f64::Angle};
|
||||
use units::{angle::radian, f64::Angle};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AngleConverter {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angle::revolution,
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
angular_velocity::revolution_per_second,
|
||||
@@ -90,7 +90,7 @@ mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
use std::f64::EPSILON;
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angle::degree, angular_acceleration::degree_per_second_squared,
|
||||
angular_velocity::degree_per_second,
|
||||
};
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
//!
|
||||
//! ```rust
|
||||
//! use control_core::converters::circular_converter::CircularConverter;
|
||||
//! use uom::si::{f64::{Length, Velocity}, length::meter, velocity::meter_per_second};
|
||||
//! use units::{f64::{Length, Velocity}, length::meter, velocity::meter_per_second};
|
||||
//!
|
||||
//! let converter = CircularConverter::from_radius(Length::new::<meter>(0.1));
|
||||
//! let velocity = Velocity::new::<meter_per_second>(1.0);
|
||||
//! let angular_velocity = converter.linear_to_angular_velocity(velocity);
|
||||
//! ```
|
||||
|
||||
use uom::si::{
|
||||
use units::{
|
||||
acceleration::meter_per_second_squared,
|
||||
angle::radian,
|
||||
angular_acceleration::radian_per_second_squared,
|
||||
@@ -199,7 +199,7 @@ impl CircularConverter {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uom::si::length::centimeter;
|
||||
use units::length::centimeter;
|
||||
|
||||
#[test]
|
||||
fn test_constructors() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angle::revolution,
|
||||
angular_velocity::revolution_per_second,
|
||||
f64::{Acceleration, Angle, AngularAcceleration, AngularVelocity, Length, Velocity},
|
||||
@@ -251,7 +251,7 @@ mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
use std::f64::EPSILON;
|
||||
use uom::si::{
|
||||
use units::{
|
||||
acceleration::meter_per_second_squared, angular_acceleration::radian_per_second_squared,
|
||||
length::meter, velocity::meter_per_second,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use std::{process::Command, sync::Arc, time::Duration};
|
||||
|
||||
use ethercrab::{
|
||||
MainDevice, MainDeviceConfig, PduStorage, Timeouts,
|
||||
std::{ethercat_now, tx_rx_task},
|
||||
};
|
||||
use interfaces::Interface;
|
||||
use std::{process::Command, sync::Arc, time::Duration};
|
||||
|
||||
// Constants for EtherCAT configuration
|
||||
const IFACE_DISCOVERY_MAX_SUBDEVICES: usize = 16; // Must be power of 2 > 1
|
||||
@@ -92,9 +91,6 @@ pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
|
||||
let mut interface: Option<&str> = None;
|
||||
|
||||
for i in 0..interfaces.len() {
|
||||
// Avoid NetworkManager hangups
|
||||
set_interface_managed(&interfaces[i].name, false);
|
||||
|
||||
match test_interface(&interfaces[i].name) {
|
||||
Ok(_) => {
|
||||
// if interface found with ethercat Exit early, we expect only one interface with ethercat
|
||||
@@ -107,7 +103,7 @@ pub async fn discover_ethercat_interface() -> Result<String, anyhow::Error> {
|
||||
|
||||
for i in 0..interfaces.len() {
|
||||
if interface.is_some() && interface.unwrap() == &interfaces[i].name {
|
||||
continue;
|
||||
set_interface_managed(&interfaces[i].name, false);
|
||||
} else {
|
||||
set_interface_managed(&interfaces[i].name, true);
|
||||
}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
pub mod eeprom_identification;
|
||||
pub mod interface_discovery;
|
||||
@@ -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,19 +2,15 @@ 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 machines;
|
||||
pub mod modbus;
|
||||
pub mod realtime;
|
||||
pub mod rest;
|
||||
pub mod serial;
|
||||
pub mod socketio;
|
||||
pub mod transmission;
|
||||
pub mod uom_extensions;
|
||||
|
||||
#[cfg(feature = "video-streaming")]
|
||||
pub mod video_streaming;
|
||||
|
||||
#[macro_use]
|
||||
extern crate uom;
|
||||
@@ -1,27 +0,0 @@
|
||||
use serde_json::Value;
|
||||
use smol::lock::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::socketio::namespace::Namespace;
|
||||
|
||||
pub trait MachineApi {
|
||||
fn api_mutate(&mut self, value: Value) -> Result<(), anyhow::Error>;
|
||||
fn api_event_namespace(&mut self) -> Arc<Mutex<Namespace>>;
|
||||
|
||||
/// Returns a list of available video stream identifiers for this machine
|
||||
#[cfg(feature = "video-streaming")]
|
||||
fn api_video_streams(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Get a video stream receiver for the specified stream ID
|
||||
/// Returns None if the stream doesn't exist
|
||||
#[cfg(feature = "video-streaming")]
|
||||
fn api_get_video_stream(
|
||||
&mut self,
|
||||
stream_id: &str,
|
||||
) -> Option<tokio::sync::broadcast::Receiver<Vec<u8>>> {
|
||||
let _ = stream_id;
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
use crate::downcast::Downcast;
|
||||
use crate::machines::identification::MachineIdentificationUnique;
|
||||
use crate::machines::manager::MachineManager;
|
||||
use crate::socketio::{event::GenericEvent, namespace::Namespace};
|
||||
use serde::Serialize;
|
||||
use smol::block_on;
|
||||
use smol::lock::RwLock;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Weak;
|
||||
|
||||
use super::Machine;
|
||||
use anyhow::anyhow;
|
||||
use smol::{channel::Sender, lock::Mutex};
|
||||
use socketioxide::extract::SocketRef;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MachineConnection<M: Machine + ?Sized>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Error(anyhow::Error),
|
||||
Disconnected,
|
||||
Connected(Arc<Mutex<M>>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MachineSlot<M: Machine + ?Sized>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
pub machine_connection: MachineConnection<M>,
|
||||
pub namespace: Arc<Mutex<Namespace>>,
|
||||
}
|
||||
|
||||
pub type MachineConnectionGeneric = MachineConnection<dyn Machine>;
|
||||
|
||||
pub type MachineSlotGeneric = MachineSlot<dyn Machine>;
|
||||
|
||||
impl<M: Machine> Clone for MachineConnection<M> {
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
Self::Connected(m) => Self::Connected(m.clone()),
|
||||
Self::Error(e) => Self::Error(anyhow!(e.to_string())),
|
||||
Self::Disconnected => Self::Disconnected,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Machine + ?Sized> MachineConnection<M> {
|
||||
pub fn to_error(&self) -> Option<anyhow::Error> {
|
||||
match self {
|
||||
MachineConnection::Error(err) => Some(anyhow!(err.to_string())),
|
||||
Self::Disconnected => Some(anyhow!("Machine is disconnected")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_machine(&self) -> Option<Arc<Mutex<M>>> {
|
||||
match self {
|
||||
Self::Connected(machine) => Some(machine.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Machine + ?Sized> MachineSlot<M> {
|
||||
pub fn new(socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>) -> Self {
|
||||
let namespace = Namespace::new(socket_queue_tx);
|
||||
Self {
|
||||
machine_connection: MachineConnection::Disconnected,
|
||||
namespace: Arc::new(Mutex::new(namespace)),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn is_connected(&self) -> bool {
|
||||
matches!(self.machine_connection, MachineConnection::Connected(_))
|
||||
}
|
||||
}
|
||||
|
||||
pub trait CrossConnectableMachine<
|
||||
F: CrossConnectableMachine<F, T>,
|
||||
T: CrossConnectableMachine<T, F>,
|
||||
>: Machine
|
||||
{
|
||||
fn get_cross_connection(&mut self) -> &mut MachineCrossConnection<F, T>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MachineCrossConnection<
|
||||
F: CrossConnectableMachine<F, T>,
|
||||
T: CrossConnectableMachine<T, F>,
|
||||
> {
|
||||
machine_manager: Weak<RwLock<MachineManager>>,
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
connected_machine: Weak<Mutex<MachineSlot<T>>>,
|
||||
_make_compiler_happy: std::marker::PhantomData<F>, // This marks tells the typechecker, that we
|
||||
// need F in our type arguemnts. We are ok with
|
||||
// it being used only in a fake field. Without
|
||||
// F being fixed, we would not be anble to implement
|
||||
// reverse connecting and disconnecting properly,
|
||||
// as the type on the other side would decay
|
||||
// to an unknown type. See also https://github.com/qitechgmbh/control/pull/625#discussion_r2379566315
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone, Default)]
|
||||
pub struct MachineCrossConnectionState {
|
||||
/// Connected Machine
|
||||
pub machine_identification_unique: Option<MachineIdentificationUnique>,
|
||||
pub is_available: bool,
|
||||
}
|
||||
|
||||
impl<F: CrossConnectableMachine<F, T>, T: CrossConnectableMachine<T, F>>
|
||||
MachineCrossConnection<F, T>
|
||||
{
|
||||
pub fn new(
|
||||
manager: Weak<RwLock<MachineManager>>,
|
||||
machine_identification_unique: &MachineIdentificationUnique,
|
||||
) -> Self {
|
||||
Self {
|
||||
machine_manager: manager,
|
||||
connected_machine: Weak::new(),
|
||||
machine_identification_unique: machine_identification_unique.clone(),
|
||||
_make_compiler_happy: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_connected_machine(
|
||||
&mut self,
|
||||
machine_identification_unique: &MachineIdentificationUnique,
|
||||
) {
|
||||
if let Some(machine_manager) = self.machine_manager.upgrade() {
|
||||
let machine_manager = block_on(machine_manager.read());
|
||||
|
||||
let slot: Arc<Mutex<MachineSlotGeneric>> =
|
||||
match machine_manager.get(&machine_identification_unique) {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let slot: Arc<Mutex<MachineSlot<T>>> = match Downcast::downcast(&slot) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
self.connected_machine = Arc::downgrade(&slot);
|
||||
} else {
|
||||
panic!("The machine manager has died, cannot do anything at this point");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reverse_connect(&self) {
|
||||
if let Some(slot) = self.connected_machine.upgrade() {
|
||||
let slot = &mut slot.lock_blocking();
|
||||
|
||||
if let MachineConnection::Connected(machine) = &slot.machine_connection {
|
||||
let mut machine = machine.lock_blocking();
|
||||
let cross_connection = machine.get_cross_connection();
|
||||
|
||||
if !cross_connection.is_connected() {
|
||||
cross_connection.set_connected_machine(&self.machine_identification_unique);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disconnect(&mut self) {
|
||||
self.connected_machine = Weak::new();
|
||||
}
|
||||
|
||||
pub fn reverse_disconnect(&mut self) {
|
||||
if let Some(slot) = self.connected_machine.upgrade() {
|
||||
let slot = slot.lock_blocking();
|
||||
|
||||
if let MachineConnection::Connected(machine) = &slot.machine_connection {
|
||||
let mut machine = machine.lock_blocking();
|
||||
let cross_connection = machine.get_cross_connection();
|
||||
cross_connection.disconnect();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> bool {
|
||||
let arc = self.connected_machine.upgrade();
|
||||
arc.is_some()
|
||||
}
|
||||
|
||||
pub fn to_state(&self) -> MachineCrossConnectionState {
|
||||
if let Some(slot) = self.connected_machine.upgrade() {
|
||||
let connection = &slot.lock_blocking().machine_connection;
|
||||
|
||||
if let MachineConnection::Connected(machine) = connection {
|
||||
let machine = machine.lock_blocking();
|
||||
|
||||
return MachineCrossConnectionState {
|
||||
machine_identification_unique: Some(
|
||||
machine.get_machine_identification_unique(),
|
||||
),
|
||||
is_available: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
MachineCrossConnectionState {
|
||||
machine_identification_unique: None,
|
||||
is_available: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Identifies a spacifi machine
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub struct MachineIdentificationUnique {
|
||||
pub machine_identification: MachineIdentification,
|
||||
pub serial: u16,
|
||||
}
|
||||
|
||||
impl MachineIdentificationUnique {
|
||||
/// Check if values are non-zero
|
||||
pub const fn is_valid(&self) -> bool {
|
||||
self.machine_identification.is_valid() && self.serial != 0
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for MachineIdentificationUnique {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}/{}/{}",
|
||||
self.machine_identification.vendor, self.machine_identification.machine, self.serial
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies a machine
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub struct MachineIdentification {
|
||||
pub vendor: u16,
|
||||
pub machine: u16,
|
||||
}
|
||||
|
||||
impl MachineIdentification {
|
||||
/// Check if values are non-zero
|
||||
pub const fn is_valid(&self) -> bool {
|
||||
self.vendor != 0 && self.machine != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeviceMachineIdentification {
|
||||
pub machine_identification_unique: MachineIdentificationUnique,
|
||||
pub role: u16,
|
||||
}
|
||||
|
||||
impl DeviceMachineIdentification {
|
||||
/// Check if values are non-zero
|
||||
pub const fn is_valid(&self) -> bool {
|
||||
self.machine_identification_unique.is_valid()
|
||||
&& self
|
||||
.machine_identification_unique
|
||||
.machine_identification
|
||||
.machine
|
||||
!= 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeviceIdentification {
|
||||
pub device_machine_identification: Option<DeviceMachineIdentification>,
|
||||
pub device_hardware_identification: DeviceHardwareIdentification,
|
||||
}
|
||||
|
||||
impl From<DeviceIdentificationIdentified> for DeviceIdentification {
|
||||
fn from(value: DeviceIdentificationIdentified) -> Self {
|
||||
Self {
|
||||
device_machine_identification: Some(value.device_machine_identification),
|
||||
device_hardware_identification: value.device_hardware_identification,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeviceIdentificationIdentified {
|
||||
pub device_machine_identification: DeviceMachineIdentification,
|
||||
pub device_hardware_identification: DeviceHardwareIdentification,
|
||||
}
|
||||
|
||||
impl TryFrom<DeviceIdentification> for DeviceIdentificationIdentified {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: DeviceIdentification) -> Result<Self, Self::Error> {
|
||||
let device_machine_identification =
|
||||
value.device_machine_identification.ok_or(anyhow::anyhow!(
|
||||
"[{}::DeviceIdentificationIdentified::try_from] No device machine identification",
|
||||
module_path!()
|
||||
))?;
|
||||
|
||||
Ok(Self {
|
||||
device_machine_identification,
|
||||
device_hardware_identification: value.device_hardware_identification,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum DeviceHardwareIdentification {
|
||||
Ethercat(DeviceHardwareIdentificationEthercat),
|
||||
Serial(DeviceHardwareIdentificationSerial),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeviceHardwareIdentificationEthercat {
|
||||
pub subdevice_index: usize,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeviceHardwareIdentificationSerial {
|
||||
pub path: String,
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
use super::{
|
||||
Machine,
|
||||
identification::{DeviceIdentification, DeviceIdentificationIdentified},
|
||||
new::MachineNewHardwareSerial,
|
||||
registry::MachineRegistry,
|
||||
};
|
||||
use smol::{
|
||||
channel::Sender,
|
||||
lock::{Mutex, RwLock},
|
||||
};
|
||||
use socketioxide::extract::SocketRef;
|
||||
|
||||
use crate::{
|
||||
machines::{
|
||||
connection::{
|
||||
MachineConnection, MachineConnectionGeneric, MachineSlot, MachineSlotGeneric,
|
||||
},
|
||||
identification::MachineIdentificationUnique,
|
||||
new::{MachineNewHardware, MachineNewHardwareEthercat, MachineNewParams},
|
||||
},
|
||||
serial::SerialDevice,
|
||||
socketio::event::GenericEvent,
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Weak},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MachineManager {
|
||||
pub ethercat_machines: HashMap<MachineIdentificationUnique, Arc<Mutex<MachineSlotGeneric>>>,
|
||||
pub serial_machines: HashMap<MachineIdentificationUnique, Arc<Mutex<MachineSlotGeneric>>>,
|
||||
}
|
||||
|
||||
impl Default for MachineManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
ethercat_machines: HashMap::new(),
|
||||
serial_machines: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ethercat_devices<const MAX_SUBDEVICES: usize, const MAX_PDI: usize>(
|
||||
&mut self,
|
||||
device_identifications: &Vec<DeviceIdentification>,
|
||||
machine_registry: &MachineRegistry,
|
||||
hardware: &MachineNewHardwareEthercat,
|
||||
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
machine_manager: Weak<RwLock<Self>>,
|
||||
) {
|
||||
// group devices by machine device identification
|
||||
let device_grouping_result = group_devices_by_identification(device_identifications);
|
||||
|
||||
tracing::info!(
|
||||
"Device Groups {}",
|
||||
device_grouping_result.device_groups.len()
|
||||
);
|
||||
|
||||
let machine_new_hardware = MachineNewHardware::Ethercat(hardware);
|
||||
|
||||
// iterate over all identified device groups but ignore unaffected machines
|
||||
for device_group in device_grouping_result.device_groups.iter() {
|
||||
// get the machine identification
|
||||
let machine_identification_unique: MachineIdentificationUnique =
|
||||
match device_group.first() {
|
||||
Some(device_identification) => device_identification
|
||||
.device_machine_identification
|
||||
.machine_identification_unique
|
||||
.clone(),
|
||||
None => continue, // Skip this group if empty
|
||||
};
|
||||
|
||||
let slot =
|
||||
self.get_or_create_slot(socket_queue_tx.clone(), machine_identification_unique);
|
||||
let mut slot = slot.lock_blocking();
|
||||
|
||||
// create the machine
|
||||
let new_machine = machine_registry.new_machine(&MachineNewParams {
|
||||
device_group,
|
||||
hardware: &machine_new_hardware,
|
||||
socket_queue_tx: socket_queue_tx.clone(),
|
||||
machine_manager: machine_manager.clone(),
|
||||
namespace: slot.namespace.clone(),
|
||||
});
|
||||
|
||||
slot.machine_connection = match new_machine {
|
||||
Err(err) => MachineConnectionGeneric::Error(err),
|
||||
Ok(machine) => MachineConnectionGeneric::Connected(machine),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn get_or_create_slot(
|
||||
&mut self,
|
||||
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
machine_identification: MachineIdentificationUnique,
|
||||
) -> Arc<Mutex<MachineSlotGeneric>> {
|
||||
if let Some(slot) = self.get(&machine_identification) {
|
||||
return slot;
|
||||
}
|
||||
|
||||
let slot = Arc::new(Mutex::new(MachineSlot::new(socket_queue_tx)));
|
||||
self.ethercat_machines
|
||||
.insert(machine_identification, slot.clone());
|
||||
|
||||
return slot;
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
&self,
|
||||
machine_identification: &MachineIdentificationUnique,
|
||||
) -> Option<Arc<Mutex<MachineSlotGeneric>>> {
|
||||
self.ethercat_machines
|
||||
.get(machine_identification)
|
||||
.or_else(|| self.serial_machines.get(machine_identification))
|
||||
.map(|x| x.clone())
|
||||
}
|
||||
|
||||
pub fn add_serial_device(
|
||||
&mut self,
|
||||
device_identification: &DeviceIdentification,
|
||||
device: Arc<RwLock<dyn SerialDevice>>,
|
||||
machine_registry: &MachineRegistry,
|
||||
socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
machine_manager: Weak<RwLock<Self>>,
|
||||
) {
|
||||
let hardware = MachineNewHardwareSerial { device };
|
||||
|
||||
let device_identification_identified: DeviceIdentificationIdentified =
|
||||
device_identification
|
||||
.clone()
|
||||
.try_into()
|
||||
.expect("Serial devices always have machine identification");
|
||||
|
||||
let machine_identification: MachineIdentificationUnique = device_identification_identified
|
||||
.device_machine_identification
|
||||
.machine_identification_unique
|
||||
.clone();
|
||||
|
||||
let slot = self.get_or_create_slot(socket_queue_tx.clone(), machine_identification);
|
||||
let mut slot = slot.lock_blocking();
|
||||
|
||||
let new_machine = machine_registry.new_machine(&MachineNewParams {
|
||||
device_group: &vec![device_identification_identified.clone()],
|
||||
hardware: &MachineNewHardware::Serial(&hardware),
|
||||
socket_queue_tx,
|
||||
machine_manager: machine_manager.clone(),
|
||||
namespace: slot.namespace.clone(),
|
||||
});
|
||||
|
||||
slot.machine_connection = match new_machine {
|
||||
Err(err) => MachineConnectionGeneric::Error(err),
|
||||
Ok(machine) => MachineConnectionGeneric::Connected(machine),
|
||||
};
|
||||
|
||||
tracing::info!("Adding serial machine {:?}", slot);
|
||||
}
|
||||
|
||||
pub fn remove_serial_device(&mut self, device_identification: &DeviceIdentification) {
|
||||
let device_identification_identified: DeviceIdentificationIdentified =
|
||||
device_identification
|
||||
.clone()
|
||||
.try_into()
|
||||
.expect("Serial devices always have machine identification");
|
||||
|
||||
tracing::info!(
|
||||
"Removing serial machine {:?}",
|
||||
device_identification_identified
|
||||
);
|
||||
|
||||
let machine_identification_unique = &device_identification_identified
|
||||
.device_machine_identification
|
||||
.machine_identification_unique;
|
||||
|
||||
if let Some(slot) = self.serial_machines.get(machine_identification_unique) {
|
||||
let mut slot = slot.lock_blocking();
|
||||
slot.machine_connection = MachineConnection::Disconnected;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ethercat_weak(
|
||||
&self,
|
||||
machine_identification: &MachineIdentificationUnique,
|
||||
) -> Option<Weak<Mutex<dyn Machine>>> {
|
||||
self.get_weak(&self.ethercat_machines, machine_identification)
|
||||
}
|
||||
|
||||
pub fn get_serial_weak(
|
||||
&self,
|
||||
machine_identification: &MachineIdentificationUnique,
|
||||
) -> Option<Weak<Mutex<dyn Machine>>> {
|
||||
self.get_weak(&self.serial_machines, machine_identification)
|
||||
}
|
||||
|
||||
fn get_weak(
|
||||
&self,
|
||||
machines: &HashMap<MachineIdentificationUnique, Arc<Mutex<MachineSlotGeneric>>>,
|
||||
machine_identification: &MachineIdentificationUnique,
|
||||
) -> Option<Weak<Mutex<dyn Machine>>> {
|
||||
let slot = machines.get(machine_identification).clone();
|
||||
|
||||
return slot.and_then(|slot| {
|
||||
let connection = &slot.lock_blocking().machine_connection;
|
||||
match connection {
|
||||
MachineConnectionGeneric::Error(_) => None,
|
||||
MachineConnectionGeneric::Disconnected => None,
|
||||
MachineConnectionGeneric::Connected(machine) => Some(Arc::downgrade(&machine)),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups devices by machine identification
|
||||
///
|
||||
/// Returns a DeviceGroupingResult containing:
|
||||
/// - device_groups: a vector of devices grouped by machine identification
|
||||
/// - unidentified_devices: a vector of devices that could not be identified
|
||||
pub fn group_devices_by_identification(
|
||||
device_identifications: &Vec<DeviceIdentification>,
|
||||
) -> DeviceGroupingResult {
|
||||
let mut device_groups: Vec<Vec<DeviceIdentificationIdentified>> = Vec::new();
|
||||
let mut unidentified_devices: Vec<DeviceIdentification> = Vec::new();
|
||||
|
||||
for device_identification in device_identifications {
|
||||
// if vendor or serial or machine is 0, it is not a valid machine device
|
||||
if let Some(device_machine_identification) =
|
||||
device_identification.device_machine_identification.as_ref()
|
||||
{
|
||||
if !device_machine_identification.is_valid() {
|
||||
unidentified_devices.push(device_identification.clone());
|
||||
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
unidentified_devices.push(device_identification.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
// scan over all deice groups
|
||||
// get the first DeviceMachineIdentification
|
||||
// compare and append to the group
|
||||
let mut found = false;
|
||||
for check_group in device_groups.iter_mut() {
|
||||
// get first device in group
|
||||
let first_device = check_group.first().expect("group to not be empty");
|
||||
let first_device_machine_identification = &first_device
|
||||
.device_machine_identification
|
||||
.machine_identification_unique;
|
||||
|
||||
// chek if it has machine identification
|
||||
if let Some(device_machine_identification) =
|
||||
device_identification.device_machine_identification.as_ref()
|
||||
{
|
||||
// compare with the current device
|
||||
if first_device_machine_identification
|
||||
== &device_machine_identification.machine_identification_unique
|
||||
{
|
||||
let device_identification_identified = device_identification
|
||||
.clone()
|
||||
.try_into()
|
||||
.expect("should have Some(DeviceMachineIdentification)");
|
||||
check_group.push(device_identification_identified);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
let device_identification_identified = device_identification
|
||||
.clone()
|
||||
.try_into()
|
||||
.expect("should have Some(DeviceMachineIdentification)");
|
||||
device_groups.push(vec![device_identification_identified]);
|
||||
}
|
||||
}
|
||||
|
||||
DeviceGroupingResult {
|
||||
device_groups,
|
||||
unidentified_devices,
|
||||
}
|
||||
}
|
||||
|
||||
/// Structure to hold the result of grouping devices by identification
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceGroupingResult {
|
||||
/// Devices grouped by machine identification
|
||||
pub device_groups: Vec<Vec<DeviceIdentificationIdentified>>,
|
||||
/// Devices that could not be identified
|
||||
pub unidentified_devices: Vec<DeviceIdentification>,
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use smol::lock::Mutex;
|
||||
|
||||
use crate::machines::connection::MachineSlotGeneric;
|
||||
|
||||
use super::{identification::MachineIdentificationUnique, manager::MachineManager};
|
||||
|
||||
pub enum MachineManagerIter<'a> {
|
||||
EthercatMachines {
|
||||
iter: std::collections::hash_map::Iter<
|
||||
'a,
|
||||
MachineIdentificationUnique,
|
||||
Arc<Mutex<MachineSlotGeneric>>,
|
||||
>,
|
||||
},
|
||||
SerialMachines {
|
||||
iter: std::collections::hash_map::Iter<
|
||||
'a,
|
||||
MachineIdentificationUnique,
|
||||
Arc<Mutex<MachineSlotGeneric>>,
|
||||
>,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
pub struct MachineManagerIterator<'a> {
|
||||
iter: MachineManagerIter<'a>,
|
||||
serial_machines: &'a HashMap<MachineIdentificationUnique, Arc<Mutex<MachineSlotGeneric>>>,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for MachineManagerIterator<'a> {
|
||||
type Item = (
|
||||
&'a MachineIdentificationUnique,
|
||||
&'a Arc<Mutex<MachineSlotGeneric>>,
|
||||
);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
match &mut self.iter {
|
||||
MachineManagerIter::EthercatMachines { iter } => {
|
||||
// Try to get the next item from ethercat_machines
|
||||
if let Some(item) = iter.next() {
|
||||
return Some(item);
|
||||
} else {
|
||||
// If ethercat_machines is exhausted, move to serial_machines
|
||||
self.iter = MachineManagerIter::SerialMachines {
|
||||
iter: self.serial_machines.iter(),
|
||||
};
|
||||
// Continue to the next iteration of the loop
|
||||
continue;
|
||||
}
|
||||
}
|
||||
MachineManagerIter::SerialMachines { iter } => {
|
||||
// Return the next item from serial_machines or None if exhausted
|
||||
if let Some(item) = iter.next() {
|
||||
return Some(item);
|
||||
} else {
|
||||
// If serial_machines is also exhausted, we're done
|
||||
self.iter = MachineManagerIter::Done;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
MachineManagerIter::Done => {
|
||||
// We've exhausted both collections
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineManager {
|
||||
// Returns an iterator over all machines (both ethercat and serial)
|
||||
pub fn iter(&self) -> MachineManagerIterator<'_> {
|
||||
MachineManagerIterator {
|
||||
iter: MachineManagerIter::EthercatMachines {
|
||||
iter: self.ethercat_machines.iter(),
|
||||
},
|
||||
serial_machines: &self.serial_machines,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
use new::MachineNewTrait;
|
||||
use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::machines::{
|
||||
api::MachineApi, identification::MachineIdentificationUnique, new::MachineAct,
|
||||
};
|
||||
pub mod api;
|
||||
pub mod connection;
|
||||
pub mod identification;
|
||||
pub mod manager;
|
||||
pub mod manager_iter;
|
||||
pub mod new;
|
||||
pub mod registry;
|
||||
|
||||
pub trait Machine: MachineAct + MachineNewTrait + MachineApi + Any + Debug + Send + Sync {
|
||||
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique;
|
||||
}
|
||||
|
||||
pub trait AnyGetters: Any {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
use crate::{
|
||||
machines::{identification::MachineIdentificationUnique, manager::MachineManager},
|
||||
serial::SerialDevice,
|
||||
socketio::namespace::Namespace,
|
||||
};
|
||||
|
||||
use super::identification::DeviceIdentificationIdentified;
|
||||
use anyhow::Error;
|
||||
use ethercat_hal::{
|
||||
devices::EthercatDevice, helpers::ethercrab_types::EthercrabSubDevicePreoperational,
|
||||
};
|
||||
use ethercrab::{SubDevice, SubDeviceRef};
|
||||
use smol::{
|
||||
channel::Sender,
|
||||
lock::{Mutex, RwLock},
|
||||
};
|
||||
use socketioxide::extract::SocketRef;
|
||||
use std::{
|
||||
sync::{Arc, Weak},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use crate::socketio::event::GenericEvent;
|
||||
|
||||
pub trait MachineNewTrait {
|
||||
fn new(params: &MachineNewParams<'_, '_, '_, '_, '_, '_, '_>) -> Result<Self, Error>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
pub trait MachineAct {
|
||||
fn act(&mut self, now: Instant);
|
||||
}
|
||||
|
||||
pub struct MachineNewParams<
|
||||
'maindevice,
|
||||
'subdevices,
|
||||
'device_identifications_identified,
|
||||
'ethercat_devices,
|
||||
'machine_new_hardware_etehrcat,
|
||||
'machine_new_hardware_serial,
|
||||
'machine_new_hardware,
|
||||
> where
|
||||
'maindevice: 'machine_new_hardware,
|
||||
'subdevices: 'machine_new_hardware,
|
||||
'ethercat_devices: 'machine_new_hardware,
|
||||
'machine_new_hardware_etehrcat: 'machine_new_hardware,
|
||||
{
|
||||
pub device_group: &'device_identifications_identified Vec<DeviceIdentificationIdentified>,
|
||||
pub hardware: &'machine_new_hardware MachineNewHardware<
|
||||
'maindevice,
|
||||
'subdevices,
|
||||
'ethercat_devices,
|
||||
'machine_new_hardware_etehrcat,
|
||||
'machine_new_hardware_serial,
|
||||
>,
|
||||
pub socket_queue_tx: Sender<(SocketRef, Arc<GenericEvent>)>,
|
||||
pub machine_manager: Weak<RwLock<MachineManager>>,
|
||||
|
||||
pub namespace: Arc<Mutex<Namespace>>,
|
||||
}
|
||||
|
||||
impl MachineNewParams<'_, '_, '_, '_, '_, '_, '_> {
|
||||
pub fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
|
||||
self.device_group
|
||||
.first()
|
||||
.expect("device group must have at least one device")
|
||||
.device_machine_identification
|
||||
.machine_identification_unique
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum MachineNewHardware<
|
||||
'maindevice,
|
||||
'subdevices,
|
||||
'ethercat_devices,
|
||||
'machine_new_hardware_etehrcat,
|
||||
'machine_new_hardware_serial,
|
||||
> where
|
||||
'maindevice: 'machine_new_hardware_etehrcat,
|
||||
'subdevices: 'machine_new_hardware_etehrcat,
|
||||
'ethercat_devices: 'machine_new_hardware_etehrcat,
|
||||
{
|
||||
Ethercat(
|
||||
&'machine_new_hardware_etehrcat MachineNewHardwareEthercat<
|
||||
'maindevice,
|
||||
'subdevices,
|
||||
'ethercat_devices,
|
||||
>,
|
||||
),
|
||||
Serial(&'machine_new_hardware_serial MachineNewHardwareSerial),
|
||||
}
|
||||
|
||||
pub struct MachineNewHardwareEthercat<'maindevice, 'subdevices, 'ethercat_devices> {
|
||||
pub subdevices:
|
||||
&'subdevices Vec<&'subdevices SubDeviceRef<'maindevice, &'subdevices SubDevice>>,
|
||||
pub ethercat_devices: &'ethercat_devices Vec<Arc<RwLock<dyn EthercatDevice>>>,
|
||||
}
|
||||
|
||||
pub struct MachineNewHardwareSerial {
|
||||
pub device: Arc<RwLock<dyn SerialDevice>>,
|
||||
}
|
||||
|
||||
// validates that all devices in the group have the same machine identification
|
||||
pub fn validate_same_machine_identification_unique(
|
||||
identified_device_group: &Vec<DeviceIdentificationIdentified>,
|
||||
) -> Result<(), Error> {
|
||||
let machine_identification_unique = &identified_device_group
|
||||
.first()
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"[{}::validate_same_machine_identification] No devices in group",
|
||||
module_path!()
|
||||
)
|
||||
})?
|
||||
.device_machine_identification
|
||||
.machine_identification_unique;
|
||||
for device in identified_device_group.iter() {
|
||||
if device
|
||||
.device_machine_identification
|
||||
.machine_identification_unique
|
||||
!= *machine_identification_unique
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"[{}::validate_same_machine_identification] Different machine identifications",
|
||||
module_path!()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// validates that every role is unique
|
||||
pub fn validate_no_role_dublicates(
|
||||
identified_device_group: &Vec<DeviceIdentificationIdentified>,
|
||||
) -> Result<(), Error> {
|
||||
let mut roles = vec![];
|
||||
for device in identified_device_group.iter() {
|
||||
if roles.contains(&device.device_machine_identification.role) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"[{}::validate_no_role_dublicates] Role dublicate",
|
||||
module_path!(),
|
||||
));
|
||||
}
|
||||
roles.push(device.device_machine_identification.role);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Inside control_core::machines::new module:
|
||||
pub fn get_device_identification_by_role(
|
||||
identified_device_group: &Vec<DeviceIdentificationIdentified>,
|
||||
role: u16,
|
||||
) -> Result<&DeviceIdentificationIdentified, Error> {
|
||||
for device in identified_device_group.iter() {
|
||||
if device.device_machine_identification.role == role {
|
||||
return Ok(device);
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"[{}::get_device_identification_by_role] Role {} not found",
|
||||
module_path!(),
|
||||
role
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_device_by_index<'maindevice>(
|
||||
devices: &Vec<Arc<RwLock<dyn EthercatDevice>>>,
|
||||
subdevice_index: usize,
|
||||
) -> Result<Arc<RwLock<dyn EthercatDevice>>, Error> {
|
||||
Ok(devices
|
||||
.get(subdevice_index)
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"[{}::get_device_by_index] Index {} out of bounds for devices",
|
||||
module_path!(),
|
||||
subdevice_index
|
||||
))?
|
||||
.clone())
|
||||
}
|
||||
|
||||
pub fn get_subdevice_by_index<'subdevices, 'maindevice>(
|
||||
subdevices: &'subdevices Vec<&EthercrabSubDevicePreoperational<'maindevice>>,
|
||||
subdevice_index: usize,
|
||||
) -> Result<&'subdevices EthercrabSubDevicePreoperational<'maindevice>, Error> {
|
||||
Ok(subdevices.get(subdevice_index).ok_or(anyhow::anyhow!(
|
||||
"Index {} out of bounds for subdevices",
|
||||
subdevice_index
|
||||
))?)
|
||||
}
|
||||
|
||||
pub fn get_ethercat_device_by_index<'maindevice>(
|
||||
ethercat_devices: &Vec<Arc<RwLock<dyn EthercatDevice>>>,
|
||||
subdevice_index: usize,
|
||||
) -> Result<Arc<RwLock<dyn EthercatDevice>>, Error> {
|
||||
Ok(ethercat_devices
|
||||
.get(subdevice_index)
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"[{}::get_ethercat_device_by_index] Index {} out of bounds for ethercat devices",
|
||||
module_path!(),
|
||||
subdevice_index
|
||||
))?
|
||||
.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::machines::identification::{
|
||||
DeviceHardwareIdentification, DeviceHardwareIdentificationEthercat,
|
||||
DeviceMachineIdentification, MachineIdentification, MachineIdentificationUnique,
|
||||
};
|
||||
|
||||
pub use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_device_identification_by_role() {
|
||||
let device_identifications = vec![
|
||||
// role 0
|
||||
DeviceIdentificationIdentified {
|
||||
device_machine_identification: DeviceMachineIdentification {
|
||||
machine_identification_unique: MachineIdentificationUnique {
|
||||
machine_identification: MachineIdentification {
|
||||
vendor: 1,
|
||||
machine: 1,
|
||||
},
|
||||
serial: 1,
|
||||
},
|
||||
role: 0,
|
||||
},
|
||||
device_hardware_identification: DeviceHardwareIdentification::Ethercat(
|
||||
DeviceHardwareIdentificationEthercat { subdevice_index: 0 },
|
||||
),
|
||||
},
|
||||
// role 1
|
||||
DeviceIdentificationIdentified {
|
||||
device_machine_identification: DeviceMachineIdentification {
|
||||
machine_identification_unique: MachineIdentificationUnique {
|
||||
machine_identification: MachineIdentification {
|
||||
vendor: 2,
|
||||
machine: 2,
|
||||
},
|
||||
serial: 2,
|
||||
},
|
||||
role: 2,
|
||||
},
|
||||
device_hardware_identification: DeviceHardwareIdentification::Ethercat(
|
||||
DeviceHardwareIdentificationEthercat { subdevice_index: 1 },
|
||||
),
|
||||
},
|
||||
// role 2
|
||||
DeviceIdentificationIdentified {
|
||||
device_machine_identification: DeviceMachineIdentification {
|
||||
machine_identification_unique: MachineIdentificationUnique {
|
||||
machine_identification: MachineIdentification {
|
||||
vendor: 3,
|
||||
machine: 3,
|
||||
},
|
||||
serial: 3,
|
||||
},
|
||||
role: 1,
|
||||
},
|
||||
device_hardware_identification: DeviceHardwareIdentification::Ethercat(
|
||||
DeviceHardwareIdentificationEthercat { subdevice_index: 2 },
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// search for role 0
|
||||
let result = get_device_identification_by_role(&device_identifications, 0);
|
||||
assert_eq!(result.unwrap().device_machine_identification.role, 0,);
|
||||
|
||||
// search for role 1
|
||||
let result = get_device_identification_by_role(&device_identifications, 1);
|
||||
assert_eq!(result.unwrap().device_machine_identification.role, 1,);
|
||||
// search for role 2
|
||||
let result = get_device_identification_by_role(&device_identifications, 2);
|
||||
assert_eq!(result.unwrap().device_machine_identification.role, 2,);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
use super::{Machine, identification::MachineIdentification, new::MachineNewParams};
|
||||
use anyhow::Error;
|
||||
use smol::lock::Mutex;
|
||||
use std::{any::TypeId, collections::HashMap, sync::Arc};
|
||||
|
||||
pub type MachineNewClosure =
|
||||
Box<dyn Fn(&MachineNewParams) -> Result<Arc<Mutex<dyn Machine>>, Error> + Send + Sync>;
|
||||
|
||||
pub struct MachineRegistry {
|
||||
type_map: HashMap<TypeId, (MachineIdentification, MachineNewClosure)>,
|
||||
}
|
||||
|
||||
impl Default for MachineRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
type_map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register<T: Machine + 'static>(
|
||||
&mut self,
|
||||
machine_identficiation: MachineIdentification,
|
||||
) {
|
||||
self.type_map.insert(
|
||||
TypeId::of::<T>(),
|
||||
(
|
||||
machine_identficiation.clone(),
|
||||
// create a machine construction closure
|
||||
Box::new(|machine_new_params| {
|
||||
Ok(Arc::new(Mutex::new(T::new(machine_new_params)?)))
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn new_machine(
|
||||
&self,
|
||||
machine_new_params: &MachineNewParams,
|
||||
) -> Result<Arc<Mutex<dyn Machine>>, anyhow::Error> {
|
||||
// get machiine identification
|
||||
let device_identification =
|
||||
&machine_new_params
|
||||
.device_group
|
||||
.first()
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"[{}::MachineConstructor::new_machine] No device in group",
|
||||
module_path!()
|
||||
))?;
|
||||
|
||||
// find machine new function by comparing MachineIdentification
|
||||
let (_, machine_new_closure) = self
|
||||
.type_map
|
||||
.values()
|
||||
.find(|(mi, _)| {
|
||||
mi == &device_identification
|
||||
.device_machine_identification
|
||||
.machine_identification_unique
|
||||
.machine_identification
|
||||
})
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"[{}::MachineConstructor::new_machine] Machine not found",
|
||||
module_path!()
|
||||
))?;
|
||||
|
||||
// call machine new function by reference
|
||||
(machine_new_closure)(machine_new_params)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod mutation;
|
||||
@@ -1,11 +1,10 @@
|
||||
use std::{any::Any, sync::Arc};
|
||||
/*use std::{any::Any, sync::Arc};
|
||||
|
||||
use smol::lock::RwLock;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::machines::identification::DeviceIdentification;
|
||||
|
||||
pub mod registry;
|
||||
pub mod serial_detection;
|
||||
|
||||
pub trait SerialDevice: Any + Send + Sync + SerialDeviceNew + Debug {}
|
||||
@@ -31,3 +30,4 @@ pub struct SerialDeviceIdentification {
|
||||
pub vendor_id: u16,
|
||||
pub product_id: u16,
|
||||
}
|
||||
*/
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod event;
|
||||
pub mod namespace;
|
||||
pub mod namespace_id;
|
||||
@@ -4,7 +4,7 @@ use socketioxide::extract::SocketRef;
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use tracing::instrument;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Namespace {
|
||||
pub sockets: Vec<SocketRef>,
|
||||
pub events: HashMap<String, Vec<Arc<GenericEvent>>>,
|
||||
@@ -167,7 +167,7 @@ impl Namespace {
|
||||
|
||||
impl Drop for Namespace {
|
||||
fn drop(&mut self) {
|
||||
self.disconnect_all();
|
||||
//self.disconnect_all();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use uom::si::{
|
||||
use units::{
|
||||
angle::radian,
|
||||
angular_velocity::radian_per_second,
|
||||
f64::{Angle, AngularVelocity, Length, Velocity},
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
unit! {
|
||||
system: uom::si;
|
||||
quantity: uom::si::acceleration;
|
||||
|
||||
@meter_per_minute_per_second: 0.016_666_666_666_666_67; "m/min/s", "meters per minute per second",
|
||||
"meters per minute per second";
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
unit! {
|
||||
system: uom::si;
|
||||
quantity: uom::si::angular_acceleration;
|
||||
|
||||
@revolution_per_minute_per_second: 0.104_719_755_119_659_77; "rev/min/s", "revolutions per minute per second",
|
||||
"revolutions per minute per second";
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
unit! {
|
||||
system: uom::si;
|
||||
quantity: uom::si::angular_jerk;
|
||||
|
||||
@revolution_per_minute_per_second_squared: 0.104_719_755_119_659_77; "rev/min/s²", "revolution per minute per second squared",
|
||||
"revolution per minute per second squared";
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
unit! {
|
||||
system: uom::si;
|
||||
quantity: uom::si::jerk;
|
||||
|
||||
@meter_per_minute_per_second_squared: 0.016_666_666_666_666_666_67; "m/min/s²", "meters per minute per second squared",
|
||||
"meters per minute per second squared";
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod acceleration;
|
||||
pub mod angular_acceleration;
|
||||
pub mod angular_jerk;
|
||||
pub mod jerk;
|
||||
pub mod velocity;
|
||||
@@ -1,7 +0,0 @@
|
||||
unit! {
|
||||
system: uom::si;
|
||||
quantity: uom::si::velocity;
|
||||
|
||||
@meter_per_minute: 0.016_666_666_666_666_67; "m/min", "meters per minute",
|
||||
"meters per minute";
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
# Architecture Overview
|
||||
|
||||
For detailed information about repository organization and component separation, see:
|
||||
- [Repository Organization](./repository-organization.md) - Monorepo structure and modularization options
|
||||
- [Component Interfaces](./component-interfaces.md) - How components interact and interface boundaries
|
||||
|
||||

|
||||
|
||||
## The Application is Split Into 3 Main Layers
|
||||
|
||||
|
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 |
@@ -1,346 +0,0 @@
|
||||
# Component Interfaces and Boundaries
|
||||
|
||||
This document describes the interfaces between major components in the QiTech Control system and how they interact with each other.
|
||||
|
||||
## Component Layering
|
||||
|
||||
The system follows a clear layered architecture:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ User Interface │
|
||||
│ (Electron) │
|
||||
└────────────────────┬────────────────────────────────┘
|
||||
│ REST API / SocketIO
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Machine Implementations │
|
||||
│ (server) │
|
||||
└────────────────────┬────────────────────────────────┘
|
||||
│ Trait implementations
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Control Framework │
|
||||
│ (control-core) │
|
||||
└────────────────────┬────────────────────────────────┘
|
||||
│ Device traits
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Hardware Abstraction Layer │
|
||||
│ (ethercat-hal) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 1. Hardware Abstraction Layer (ethercat-hal)
|
||||
|
||||
### Public Interface
|
||||
|
||||
The HAL provides traits and types for working with EtherCAT devices:
|
||||
|
||||
**Core Traits:**
|
||||
- `Device`: Base trait for all EtherCAT devices
|
||||
- `Group`: Trait for grouping related devices
|
||||
- `PDO`: Process Data Object handling
|
||||
- `CoE`: CANopen over EtherCAT configuration
|
||||
|
||||
**Key Types:**
|
||||
- Input/Output primitives (digital, analog)
|
||||
- Device configurations
|
||||
- Error types
|
||||
|
||||
**Dependencies:**
|
||||
- External: `ethercrab`, `smol`, `uom`, `bitvec`
|
||||
- Internal: None (lowest layer)
|
||||
|
||||
**Usage Example:**
|
||||
```rust
|
||||
use ethercat_hal::{Device, Group};
|
||||
|
||||
// Implement Device trait for a custom EtherCAT device
|
||||
impl Device for MyCustomDevice {
|
||||
// Implementation details
|
||||
}
|
||||
```
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Generic**: No knowledge of specific machines or control logic
|
||||
- **Stateless**: Devices are primarily data structures for I/O
|
||||
- **Type-safe**: Uses Rust's type system for safe I/O operations
|
||||
- **Zero-cost abstractions**: Minimal runtime overhead
|
||||
|
||||
## 2. Control Framework (control-core)
|
||||
|
||||
### Public Interface
|
||||
|
||||
The control framework provides abstractions for building control systems:
|
||||
|
||||
**Core Traits:**
|
||||
- `Actor`: Trait for control logic components that run in the control loop
|
||||
- `Machine`: Trait for machine implementations
|
||||
- `MachineValidation`: Trait for validating machine configurations
|
||||
|
||||
**Key Features:**
|
||||
- Actor system for modular control logic
|
||||
- SocketIO namespace abstractions
|
||||
- REST API helpers
|
||||
- Device identification and grouping
|
||||
- Caching mechanisms
|
||||
|
||||
**Dependencies:**
|
||||
- Internal: `ethercat-hal` (for device types)
|
||||
- External: `socketioxide`, `axum`, `serde`, `smol`
|
||||
|
||||
**Usage Example:**
|
||||
```rust
|
||||
use control_core::{Actor, Machine, Group};
|
||||
|
||||
// Implement Actor for a control component
|
||||
struct PIDController {
|
||||
// ... fields
|
||||
}
|
||||
|
||||
impl Actor for PIDController {
|
||||
fn act(&mut self, group: &Group) -> anyhow::Result<()> {
|
||||
// Control logic that runs each cycle
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Implement Machine for a specific machine type
|
||||
struct MyMachine {
|
||||
actors: Vec<Box<dyn Actor>>,
|
||||
}
|
||||
|
||||
impl Machine for MyMachine {
|
||||
// Implementation details
|
||||
}
|
||||
```
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Generic**: Machine-agnostic, works for any industrial control application
|
||||
- **Composable**: Actors can be combined to build complex control logic
|
||||
- **Real-time capable**: Designed for deterministic execution
|
||||
- **API-ready**: Built-in SocketIO and REST abstractions
|
||||
|
||||
## 3. Machine Implementations (server)
|
||||
|
||||
### Responsibilities
|
||||
|
||||
The server crate contains QiTech-specific implementations:
|
||||
|
||||
1. **Machine Implementations** (`src/machines/`)
|
||||
- Winder, Extruder, Laser, etc.
|
||||
- Each machine implements the `Machine` trait
|
||||
- Contains machine-specific actors and logic
|
||||
|
||||
2. **EtherCAT Setup** (`src/ethercat/`)
|
||||
- Interface discovery
|
||||
- Device enumeration
|
||||
- Group creation
|
||||
|
||||
3. **Serial Devices** (`src/serial/`)
|
||||
- Non-EtherCAT devices (e.g., laser sensors)
|
||||
- Serial protocol implementations
|
||||
|
||||
4. **API Handlers** (`src/rest/`, `src/socketio/`)
|
||||
- Machine mutation endpoints
|
||||
- Real-time status updates
|
||||
- Device identification management
|
||||
|
||||
5. **Control Loop** (`src/loop.rs`)
|
||||
- Main control loop execution
|
||||
- Thread management
|
||||
- Actor coordination
|
||||
|
||||
**Dependencies:**
|
||||
- Internal: `control-core`, `ethercat-hal`
|
||||
- External: `axum`, `socketioxide`, `tokio`, `smol`
|
||||
|
||||
**Example Machine Implementation:**
|
||||
```rust
|
||||
use control_core::{Machine, Actor, Group};
|
||||
use ethercat_hal::Device;
|
||||
|
||||
pub struct WinderV2 {
|
||||
// Actors
|
||||
puller: Puller,
|
||||
traverse: Traverse,
|
||||
// ... other components
|
||||
}
|
||||
|
||||
impl Machine for WinderV2 {
|
||||
fn act(&mut self, group: &Group) -> anyhow::Result<()> {
|
||||
// Coordinate all actors
|
||||
self.puller.act(group)?;
|
||||
self.traverse.act(group)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Machine-specific**: Contains QiTech business logic
|
||||
- **Uses framework**: Builds on `control-core` and `ethercat-hal`
|
||||
- **API implementation**: Provides REST and SocketIO endpoints
|
||||
- **Integration point**: Ties together hardware, framework, and UI
|
||||
|
||||
## 4. User Interface (electron)
|
||||
|
||||
### Interface with Backend
|
||||
|
||||
The Electron UI communicates with the server through two channels:
|
||||
|
||||
#### REST API
|
||||
|
||||
Used for request/response operations:
|
||||
|
||||
- **Machine mutations**: Start, stop, change parameters
|
||||
- **Device identification**: Read/write device IDs
|
||||
- **Configuration**: System settings
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// POST /machine/mutation
|
||||
const response = await fetch('http://localhost:3001/machine/mutation', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mutation: 'Start',
|
||||
parameters: { /* ... */ }
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
#### SocketIO
|
||||
|
||||
Used for real-time updates:
|
||||
|
||||
- **Main namespace** (`/`): System-wide events (machine discovery, errors)
|
||||
- **Machine namespaces** (`/machine/<vendor>/<id>`): Machine-specific state updates
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
const socket = io('http://localhost:3001/machine/1/2');
|
||||
|
||||
socket.on('state', (machineState) => {
|
||||
// Update UI with new machine state
|
||||
updateDisplay(machineState);
|
||||
});
|
||||
```
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Decoupled**: Communicates via APIs, not tight coupling
|
||||
- **Reactive**: Uses SocketIO for real-time updates
|
||||
- **Machine-aware**: Contains UI for each machine type
|
||||
- **Reusable**: Built with standard web technologies
|
||||
|
||||
## 5. Operating System (nixos)
|
||||
|
||||
### Role
|
||||
|
||||
Provides the runtime environment:
|
||||
|
||||
- Real-time kernel for deterministic control loop execution
|
||||
- System services for the server
|
||||
- Touchscreen configuration
|
||||
- Package management
|
||||
|
||||
### Integration
|
||||
|
||||
- Builds and packages the `server` binary
|
||||
- Configures systemd service for automatic startup
|
||||
- Provides system dependencies (libraries, drivers)
|
||||
|
||||
## Interface Guidelines
|
||||
|
||||
### For Framework Development (ethercat-hal, control-core)
|
||||
|
||||
When working on framework crates:
|
||||
|
||||
1. **No QiTech-specific code**: Keep implementations generic
|
||||
2. **Document public APIs**: Assume external users
|
||||
3. **Semantic versioning**: Changes should follow semver
|
||||
4. **Minimal dependencies**: Only add well-maintained, stable crates
|
||||
5. **Examples**: Provide usage examples in documentation
|
||||
6. **Testing**: Write tests that don't depend on specific machines
|
||||
|
||||
### For Machine Development (server)
|
||||
|
||||
When implementing machines:
|
||||
|
||||
1. **Use framework traits**: Implement `Machine` and `Actor` traits
|
||||
2. **Isolate machine code**: Keep machine-specific code in `src/machines/<machine_name>/`
|
||||
3. **API design**: Design REST/SocketIO APIs with UI in mind
|
||||
4. **Error handling**: Use `anyhow::Result` for error propagation
|
||||
5. **Documentation**: Document machine-specific behavior
|
||||
|
||||
### For UI Development (electron)
|
||||
|
||||
When working on the UI:
|
||||
|
||||
1. **API contracts**: Respect REST/SocketIO API contracts
|
||||
2. **Type safety**: Use TypeScript types for API responses
|
||||
3. **Machine components**: Keep machine-specific UI in `src/machines/<machine_name>/`
|
||||
4. **Reusable components**: Extract common patterns to shared components
|
||||
5. **Error handling**: Handle network errors gracefully
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **HAL**: Returns `anyhow::Result` for device operations
|
||||
- **Framework**: Uses `anyhow::Result` with context
|
||||
- **Server**: Propagates errors, logs with `tracing`
|
||||
- **UI**: Displays user-friendly error messages
|
||||
|
||||
### Logging and Tracing
|
||||
|
||||
- **Backend**: Uses `tracing` crate with structured logging
|
||||
- **UI**: Uses console logging and optional telemetry
|
||||
|
||||
### Testing
|
||||
|
||||
- **Unit tests**: Each crate has unit tests for internal logic
|
||||
- **Integration tests**: Server has tests that span multiple components
|
||||
- **E2E tests**: Electron has Playwright tests for UI workflows
|
||||
|
||||
### Configuration
|
||||
|
||||
- **Server**: TOML configuration file (`config.toml`)
|
||||
- **UI**: Electron store for user preferences
|
||||
- **NixOS**: System configuration in Nix files
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### If Splitting into Multiple Repositories
|
||||
|
||||
1. **Version Pinning**: Server would depend on specific versions of framework crates
|
||||
2. **Release Coordination**: Framework releases need stability guarantees
|
||||
3. **Breaking Changes**: Would require careful migration planning
|
||||
4. **Documentation**: Each repository needs comprehensive docs
|
||||
5. **CI/CD**: Separate pipelines for each repository
|
||||
|
||||
### Maintaining Current Structure
|
||||
|
||||
1. **Clean Boundaries**: Continue enforcing layer separation
|
||||
2. **Interface Stability**: Treat framework interfaces as if they were versioned APIs
|
||||
3. **Documentation**: Keep this document updated
|
||||
4. **Testing**: Ensure framework crates can be tested independently
|
||||
|
||||
## Summary
|
||||
|
||||
The QiTech Control system has a well-defined layered architecture:
|
||||
|
||||
- **ethercat-hal**: Provides hardware abstraction (reusable)
|
||||
- **control-core**: Provides control framework (reusable)
|
||||
- **server**: Implements QiTech machines (specific)
|
||||
- **electron**: Provides user interface (adaptable)
|
||||
- **nixos**: Provides runtime environment (adaptable)
|
||||
|
||||
Each layer depends only on layers below it, creating clear separation of concerns and enabling potential future modularization.
|
||||
@@ -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**
|
||||
@@ -1,16 +1,4 @@
|
||||
# Getting Started
|
||||
|
||||
This guide will help you set up your development environment for QiTech Control.
|
||||
|
||||
## Understanding the Repository
|
||||
|
||||
Before starting, it's recommended to understand the repository organization:
|
||||
|
||||
- [Repository Organization](../repository-organization.md) - Monorepo structure and component boundaries
|
||||
- [Architecture Overview](../architecture-overview.md) - System architecture and data flow
|
||||
- [Component Interfaces](../component-interfaces.md) - How components interact
|
||||
|
||||
## Environment Setup
|
||||
# Environment Setup
|
||||
|
||||
Working on this repository requires you to use cargo and npm. In the following document the installation process will be shown
|
||||
|
||||
@@ -94,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)
|
||||
@@ -1,498 +0,0 @@
|
||||
# Migration Guide: Splitting the Monorepo
|
||||
|
||||
This guide provides a step-by-step approach for splitting the QiTech Control monorepo into multiple repositories, should that become necessary in the future.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before considering a split:
|
||||
|
||||
1. **Framework Stability**: Ensure `ethercat-hal` and `control-core` APIs are stable
|
||||
2. **Documentation**: All framework crates have comprehensive documentation
|
||||
3. **Tests**: Framework crates have thorough test coverage
|
||||
4. **Versioning Strategy**: Decide on semantic versioning approach
|
||||
5. **Team Consensus**: All stakeholders agree on the split
|
||||
|
||||
## Recommended Approach: Gradual Migration
|
||||
|
||||
### Phase 1: Prepare Framework for External Use
|
||||
|
||||
**Goal**: Make framework crates publishable while keeping monorepo structure.
|
||||
|
||||
#### Step 1.1: Clean Up Framework Dependencies
|
||||
|
||||
Ensure framework crates only depend on:
|
||||
- Each other (via path dependencies)
|
||||
- Well-maintained external crates
|
||||
- No `server` or machine-specific code
|
||||
|
||||
```bash
|
||||
# Check dependency tree
|
||||
cd control
|
||||
cargo tree -p ethercat-hal
|
||||
cargo tree -p control-core
|
||||
```
|
||||
|
||||
#### Step 1.2: Add Package Metadata
|
||||
|
||||
Update `ethercat-hal/Cargo.toml` and `control-core/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "ethercat-hal"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors = ["QiTech Industries GmbH"]
|
||||
license = "MIT OR Apache-2.0" # Choose appropriate license
|
||||
description = "Hardware abstraction layer for EtherCAT devices"
|
||||
documentation = "https://docs.rs/ethercat-hal"
|
||||
repository = "https://github.com/qitechgmbh/control-framework"
|
||||
keywords = ["ethercat", "industrial", "automation", "beckhoff"]
|
||||
categories = ["embedded", "hardware-support"]
|
||||
readme = "README.md"
|
||||
```
|
||||
|
||||
#### Step 1.3: Write Framework Documentation
|
||||
|
||||
For each framework crate:
|
||||
|
||||
1. Create `README.md` with:
|
||||
- Purpose and features
|
||||
- Installation instructions
|
||||
- Quick start example
|
||||
- Link to full documentation
|
||||
|
||||
2. Add crate-level documentation to `src/lib.rs`:
|
||||
```rust
|
||||
//! # Ethercat HAL
|
||||
//!
|
||||
//! Hardware abstraction layer for EtherCAT devices.
|
||||
//!
|
||||
//! ## Quick Start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use ethercat_hal::{Device, Group};
|
||||
//! // ... example code
|
||||
//! ```
|
||||
```
|
||||
|
||||
3. Document all public APIs with examples
|
||||
|
||||
#### Step 1.4: Test Framework Isolation
|
||||
|
||||
```bash
|
||||
# Try building framework crates independently
|
||||
cd ethercat-hal && cargo build
|
||||
cd ../control-core && cargo build
|
||||
|
||||
# Run tests
|
||||
cd ../ethercat-hal && cargo test
|
||||
cd ../control-core && cargo test
|
||||
```
|
||||
|
||||
#### Step 1.5: Publish to crates.io (Optional Dry Run)
|
||||
|
||||
```bash
|
||||
cd ethercat-hal
|
||||
cargo publish --dry-run
|
||||
|
||||
cd ../control-core
|
||||
cargo publish --dry-run
|
||||
```
|
||||
|
||||
### Phase 2: Extract Framework to Separate Repository
|
||||
|
||||
**Goal**: Move framework crates to a new repository.
|
||||
|
||||
#### Step 2.1: Create Framework Repository
|
||||
|
||||
```bash
|
||||
# Create new repository
|
||||
git clone https://github.com/qitechgmbh/control-framework.git
|
||||
cd control-framework
|
||||
|
||||
# Set up structure
|
||||
mkdir -p ethercat-hal ethercat-hal-derive control-core control-core-derive
|
||||
touch Cargo.toml README.md LICENSE
|
||||
```
|
||||
|
||||
#### Step 2.2: Copy Framework Code with History
|
||||
|
||||
Use `git filter-repo` to preserve commit history:
|
||||
|
||||
```bash
|
||||
# Install git-filter-repo
|
||||
pip install git-filter-repo
|
||||
|
||||
# Clone original repo
|
||||
git clone https://github.com/qitechgmbh/control.git control-temp
|
||||
cd control-temp
|
||||
|
||||
# Extract ethercat-hal with history
|
||||
git filter-repo --subdirectory-filter ethercat-hal --force
|
||||
|
||||
# Push to framework repo
|
||||
git remote add framework https://github.com/qitechgmbh/control-framework.git
|
||||
git push framework main:ethercat-hal-history
|
||||
|
||||
# Repeat for other framework crates
|
||||
```
|
||||
|
||||
#### Step 2.3: Set Up Framework Workspace
|
||||
|
||||
In `control-framework/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[workspace]
|
||||
members = [
|
||||
"ethercat-hal",
|
||||
"ethercat-hal-derive",
|
||||
"control-core",
|
||||
"control-core-derive",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors = ["QiTech Industries GmbH"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/qitechgmbh/control-framework"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
# ... copy from original
|
||||
```
|
||||
|
||||
#### Step 2.4: Update Internal Dependencies
|
||||
|
||||
Update `control-core/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ethercat_hal = { version = "0.1.0", path = "../ethercat-hal" }
|
||||
# ... other deps
|
||||
```
|
||||
|
||||
#### Step 2.5: Add CI/CD
|
||||
|
||||
Create `.github/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo test --all-features
|
||||
- run: cargo clippy -- -D warnings
|
||||
- run: cargo doc --no-deps
|
||||
|
||||
publish:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo publish --token ${CRATES_TOKEN}
|
||||
env:
|
||||
CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }}
|
||||
```
|
||||
|
||||
#### Step 2.6: Publish Framework to crates.io
|
||||
|
||||
```bash
|
||||
cd control-framework
|
||||
|
||||
# Publish in dependency order
|
||||
cd ethercat-hal-derive
|
||||
cargo publish
|
||||
|
||||
cd ../ethercat-hal
|
||||
cargo publish
|
||||
|
||||
cd ../control-core-derive
|
||||
cargo publish
|
||||
|
||||
cd ../control-core
|
||||
cargo publish
|
||||
```
|
||||
|
||||
### Phase 3: Update QiTech Repository
|
||||
|
||||
**Goal**: Make QiTech repo depend on published framework crates.
|
||||
|
||||
#### Step 3.1: Remove Framework Code
|
||||
|
||||
In the original `control` repository:
|
||||
|
||||
```bash
|
||||
cd control
|
||||
git rm -r ethercat-hal ethercat-hal-derive control-core control-core-derive
|
||||
```
|
||||
|
||||
#### Step 3.2: Update server/Cargo.toml
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
# Replace path dependencies with crates.io dependencies
|
||||
ethercat_hal = "0.1.0"
|
||||
control_core = "0.1.0"
|
||||
control_core_derive = "0.1.0"
|
||||
|
||||
# ... other deps remain same
|
||||
```
|
||||
|
||||
#### Step 3.3: Update Workspace
|
||||
|
||||
Update root `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[workspace]
|
||||
members = [
|
||||
"server",
|
||||
"ethercat-eeprom-dump",
|
||||
]
|
||||
```
|
||||
|
||||
#### Step 3.4: Test Migration
|
||||
|
||||
```bash
|
||||
cd server
|
||||
cargo build
|
||||
cargo test
|
||||
```
|
||||
|
||||
#### Step 3.5: Update Documentation
|
||||
|
||||
Update `README.md`:
|
||||
|
||||
```markdown
|
||||
# QiTech Control
|
||||
|
||||
This repository contains QiTech-specific machine implementations using the
|
||||
[control-framework](https://github.com/qitechgmbh/control-framework).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [ethercat-hal](https://crates.io/crates/ethercat-hal) - Hardware abstraction layer
|
||||
- [control-core](https://crates.io/crates/control-core) - Control framework
|
||||
```
|
||||
|
||||
### Phase 4: Extract UI (Optional)
|
||||
|
||||
**Goal**: Separate Electron UI into its own repository.
|
||||
|
||||
#### Step 4.1: Create UI Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qitechgmbh/control-ui.git
|
||||
cd control-ui
|
||||
|
||||
# Copy electron directory
|
||||
cp -r ../control/electron/* .
|
||||
```
|
||||
|
||||
#### Step 4.2: Make UI Generic
|
||||
|
||||
1. Remove QiTech-specific machine UIs or make them plugins
|
||||
2. Create configuration for API endpoints
|
||||
3. Add documentation for customization
|
||||
|
||||
#### Step 4.3: Publish as npm Package
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@qitech/control-ui",
|
||||
"version": "1.0.0",
|
||||
"description": "Electron UI for industrial control systems",
|
||||
"main": "dist/main.js",
|
||||
"repository": "github:qitechgmbh/control-ui"
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
#### Step 4.4: Use UI in QiTech Repo
|
||||
|
||||
Option A: Git Submodule:
|
||||
```bash
|
||||
cd control
|
||||
git rm -r electron
|
||||
git submodule add https://github.com/qitechgmbh/control-ui.git electron
|
||||
```
|
||||
|
||||
Option B: npm Package:
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@qitech/control-ui": "^1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Alternative: Gradual Separation without Multiple Repositories
|
||||
|
||||
If full split is not necessary, maintain logical separation within monorepo:
|
||||
|
||||
### Use Cargo Workspaces Effectively
|
||||
|
||||
```toml
|
||||
[workspace]
|
||||
members = [
|
||||
# Framework (don't depend on server)
|
||||
"framework/ethercat-hal",
|
||||
"framework/ethercat-hal-derive",
|
||||
"framework/control-core",
|
||||
"framework/control-core-derive",
|
||||
|
||||
# Application (depends on framework)
|
||||
"app/server",
|
||||
|
||||
# UI
|
||||
"ui/electron",
|
||||
|
||||
# Utils
|
||||
"utils/ethercat-eeprom-dump",
|
||||
]
|
||||
```
|
||||
|
||||
### Enforce Boundaries with Linting
|
||||
|
||||
Add a CI check to ensure framework doesn't depend on application:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# check-dependencies.sh
|
||||
|
||||
if grep -r "server" framework/*/Cargo.toml; then
|
||||
echo "Error: Framework should not depend on server!"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If migration causes issues:
|
||||
|
||||
### Roll Back Phase 3
|
||||
|
||||
```bash
|
||||
cd control
|
||||
git revert <migration-commit>
|
||||
git push
|
||||
```
|
||||
|
||||
### Revert to Path Dependencies
|
||||
|
||||
Update `server/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ethercat_hal = { path = "../ethercat-hal" }
|
||||
control_core = { path = "../control-core" }
|
||||
```
|
||||
|
||||
### Keep Framework Repo
|
||||
|
||||
The framework repository can remain as a mirror/backup even if not actively used.
|
||||
|
||||
## Post-Migration Maintenance
|
||||
|
||||
### Version Updates
|
||||
|
||||
When updating framework:
|
||||
|
||||
1. **Framework Repository**:
|
||||
```bash
|
||||
cd control-framework
|
||||
# Make changes
|
||||
cargo test
|
||||
# Update version in Cargo.toml
|
||||
git tag v0.2.0
|
||||
git push --tags
|
||||
cargo publish
|
||||
```
|
||||
|
||||
2. **QiTech Repository**:
|
||||
```bash
|
||||
cd control/server
|
||||
# Update Cargo.toml
|
||||
ethercat_hal = "0.2.0"
|
||||
cargo update
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
When framework has breaking changes:
|
||||
|
||||
1. Bump major version (0.1.0 → 0.2.0 or 1.0.0)
|
||||
2. Write migration guide
|
||||
3. Support old version for transition period
|
||||
4. Update QiTech repo when ready
|
||||
|
||||
### Documentation
|
||||
|
||||
Keep both repositories' documentation in sync:
|
||||
- Framework: API documentation
|
||||
- QiTech: Machine implementation examples using framework
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- **Phase 1** (Preparation): 2-4 weeks
|
||||
- Documentation and cleanup
|
||||
- Testing isolation
|
||||
|
||||
- **Phase 2** (Framework Extraction): 1-2 weeks
|
||||
- Repository setup
|
||||
- History migration
|
||||
- Publishing to crates.io
|
||||
|
||||
- **Phase 3** (QiTech Update): 1 week
|
||||
- Update dependencies
|
||||
- Testing
|
||||
- Documentation updates
|
||||
|
||||
- **Phase 4** (UI Extraction, Optional): 2-3 weeks
|
||||
- Repository setup
|
||||
- Generalization
|
||||
- Integration testing
|
||||
|
||||
**Total: 6-10 weeks** depending on scope
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ Framework crates build independently
|
||||
- ✅ All tests pass in both repositories
|
||||
- ✅ Documentation is comprehensive
|
||||
- ✅ CI/CD pipelines are working
|
||||
- ✅ QiTech machines work with published framework
|
||||
- ✅ Version management is clear
|
||||
- ✅ Team is trained on new workflow
|
||||
|
||||
## Conclusion
|
||||
|
||||
Splitting the monorepo is a significant undertaking that should only be done when:
|
||||
1. Framework is stable and well-documented
|
||||
2. There's clear need for independent versioning
|
||||
3. External users want to use the framework
|
||||
4. Team has bandwidth to maintain multiple repositories
|
||||
|
||||
The gradual approach outlined here minimizes risk by:
|
||||
- Preparing framework first (Phase 1 can be done immediately)
|
||||
- Publishing framework while keeping monorepo (reversible)
|
||||
- Only removing framework code once proven stable
|
||||
- Maintaining clear rollback options
|
||||
|
||||
For now, continue with the monorepo approach while following best practices that keep the option open for future splitting.
|
||||
@@ -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)
|
||||
@@ -1,157 +0,0 @@
|
||||
# Repository Organization Summary
|
||||
|
||||
This document provides a quick reference for understanding the QiTech Control repository structure.
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
- **[Repository Organization](./repository-organization.md)** - Complete guide to the monorepo structure
|
||||
- **[Component Interfaces](./component-interfaces.md)** - How components interact
|
||||
- **[Migration Guide](./migration-guide-splitting.md)** - How to split into multiple repos (if needed)
|
||||
- **[Architecture Overview](./architecture-overview.md)** - System architecture diagrams
|
||||
|
||||
## At a Glance
|
||||
|
||||
### Framework Layer (Reusable)
|
||||
|
||||
These components form a generic control framework that could be used by other projects:
|
||||
|
||||
```
|
||||
ethercat-hal + ethercat-hal-derive
|
||||
↓
|
||||
control-core + control-core-derive
|
||||
```
|
||||
|
||||
- **Purpose**: Provide abstractions for building industrial control systems
|
||||
- **Audience**: Could be published to crates.io for external use
|
||||
- **Dependencies**: Only on external crates and each other
|
||||
|
||||
### Application Layer (QiTech-Specific)
|
||||
|
||||
```
|
||||
server/
|
||||
├── machines/ # Machine implementations (winder, extruder, etc.)
|
||||
├── ethercat/ # EtherCAT setup
|
||||
├── serial/ # Serial device handling
|
||||
├── rest/ # REST API handlers
|
||||
└── socketio/ # SocketIO handlers
|
||||
```
|
||||
|
||||
- **Purpose**: QiTech-specific machine implementations
|
||||
- **Audience**: QiTech internal use
|
||||
- **Dependencies**: Uses framework + external crates
|
||||
|
||||
### User Interface
|
||||
|
||||
```
|
||||
electron/
|
||||
├── src/machines/ # Machine-specific UIs
|
||||
├── components/ # Reusable UI components
|
||||
├── routes/ # Application routing
|
||||
└── ...
|
||||
```
|
||||
|
||||
- **Purpose**: Control panel user interface
|
||||
- **Technology**: Electron, React, TypeScript
|
||||
- **Communication**: REST + SocketIO APIs
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **Clean Layering**: Framework doesn't depend on application code
|
||||
2. **Type Safety**: Rust's type system ensures safe hardware access
|
||||
3. **Real-time Capable**: Designed for deterministic control loops
|
||||
4. **API-First**: UI communicates through well-defined APIs
|
||||
5. **Testable**: Each layer can be tested independently
|
||||
|
||||
## When to Use What
|
||||
|
||||
### Working on Hardware Support
|
||||
- Edit: `ethercat-hal/`
|
||||
- Purpose: Adding new EtherCAT devices or improving HAL
|
||||
|
||||
### Working on Framework Features
|
||||
- Edit: `control-core/`
|
||||
- Purpose: Adding actors, improving control logic abstractions
|
||||
|
||||
### Working on Machines
|
||||
- Edit: `server/src/machines/`
|
||||
- Purpose: Implementing or modifying machine behavior
|
||||
|
||||
### Working on UI
|
||||
- Edit: `electron/`
|
||||
- Purpose: UI changes, new screens, UX improvements
|
||||
|
||||
### Working on System Configuration
|
||||
- Edit: `nixos/`
|
||||
- Purpose: OS configuration, deployment setup
|
||||
|
||||
## Common Questions
|
||||
|
||||
### Q: Can I depend on `server` from `control-core`?
|
||||
**A: No.** Framework crates should never depend on application code. This maintains reusability.
|
||||
|
||||
### Q: Where should I put reusable control logic?
|
||||
**A: In `control-core`.** If it's generic and could be used by multiple machines, it belongs in the framework.
|
||||
|
||||
### Q: Where should I put QiTech-specific logic?
|
||||
**A: In `server`.** Machine-specific implementations go in `server/src/machines/`.
|
||||
|
||||
### Q: Should we split into multiple repositories?
|
||||
**A: Not yet.** The current monorepo structure works well for the current development phase. See the [Migration Guide](./migration-guide-splitting.md) for when and how to split if needed.
|
||||
|
||||
### Q: How do I ensure my changes maintain clean boundaries?
|
||||
**A: Follow these guidelines:**
|
||||
1. Framework code should be generic (no QiTech-specific assumptions)
|
||||
2. Document public APIs as if for external users
|
||||
3. Keep machine code isolated in appropriate directories
|
||||
4. Write tests that verify isolation
|
||||
|
||||
## Quick Reference: Dependency Rules
|
||||
|
||||
```
|
||||
✅ Allowed:
|
||||
- server → control-core → ethercat-hal
|
||||
- control-core → ethercat-hal
|
||||
- electron → server (via APIs only)
|
||||
|
||||
❌ Not Allowed:
|
||||
- ethercat-hal → control-core
|
||||
- control-core → server
|
||||
- ethercat-hal → server
|
||||
- server → electron
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
New to the project? Start here:
|
||||
|
||||
1. Read [Getting Started](./developer-docs/getting-started.md)
|
||||
2. Review [Architecture Overview](./architecture-overview.md)
|
||||
3. Read [Repository Organization](./repository-organization.md)
|
||||
4. Look at [Component Interfaces](./component-interfaces.md) for detailed API information
|
||||
|
||||
## Contributing
|
||||
|
||||
When making changes:
|
||||
|
||||
1. **Understand the layer** you're working in
|
||||
2. **Follow the dependencies** - only depend on lower layers
|
||||
3. **Document public APIs** - especially in framework crates
|
||||
4. **Write tests** - unit tests for each layer, integration tests across layers
|
||||
5. **Keep it clean** - maintain separation of concerns
|
||||
|
||||
## Future Considerations
|
||||
|
||||
The repository is structured to allow future splitting if needed:
|
||||
|
||||
- **Short term**: Continue with monorepo, maintain clean boundaries
|
||||
- **Medium term**: Prepare framework crates for publication (add docs, examples)
|
||||
- **Long term**: Consider splitting when framework is stable and there are external users
|
||||
|
||||
See [Migration Guide](./migration-guide-splitting.md) for the full plan.
|
||||
|
||||
---
|
||||
|
||||
For more detailed information, see the complete documentation:
|
||||
- [Repository Organization](./repository-organization.md)
|
||||
- [Component Interfaces](./component-interfaces.md)
|
||||
- [Migration Guide](./migration-guide-splitting.md)
|
||||
@@ -1,282 +0,0 @@
|
||||
# Repository Organization
|
||||
|
||||
This document explains the current repository structure and provides guidance on how the codebase is organized for maintainability and potential future modularization.
|
||||
|
||||
## Overview
|
||||
|
||||
The QiTech Control repository is currently a monorepo that contains multiple logical components:
|
||||
|
||||
1. **Hardware Abstraction Layer (HAL)** - Low-level EtherCAT device interfaces
|
||||
2. **Control Framework** - Generic control logic and abstractions
|
||||
3. **Machine Implementations** - QiTech-specific machine logic
|
||||
4. **User Interface** - Electron-based control panel UI
|
||||
5. **Operating System** - NixOS configuration for deployment
|
||||
|
||||
## Current Structure
|
||||
|
||||
### 1. Hardware Abstraction Layer
|
||||
|
||||
**Crates:** `ethercat-hal`, `ethercat-hal-derive`
|
||||
|
||||
**Purpose:** Provides low-level abstractions for interfacing with Beckhoff and other EtherCAT devices.
|
||||
|
||||
**Key Features:**
|
||||
- Device trait definitions
|
||||
- PDO (Process Data Object) handling
|
||||
- CoE (CANopen over EtherCAT) configuration
|
||||
- I/O primitives
|
||||
- Built on top of [ethercrab](https://github.com/ethercrab-rs/ethercrab)
|
||||
|
||||
**Dependencies:** External crates only (ethercrab, smol, uom, etc.)
|
||||
|
||||
**Reusability:** Highly reusable for any EtherCAT-based control system
|
||||
|
||||
### 2. Control Framework
|
||||
|
||||
**Crates:** `control-core`, `control-core-derive`
|
||||
|
||||
**Purpose:** Provides generic control logic abstractions that can be used across different machine types.
|
||||
|
||||
**Key Features:**
|
||||
- Actor system for control logic components
|
||||
- SocketIO and REST API abstractions
|
||||
- Machine trait definitions
|
||||
- Device identification and validation
|
||||
- Group management
|
||||
- Video streaming support (optional)
|
||||
|
||||
**Dependencies:** `ethercat-hal`, external crates
|
||||
|
||||
**Reusability:** Reusable for building various industrial control systems
|
||||
|
||||
### 3. Machine Implementations (QiTech-Specific)
|
||||
|
||||
**Crate:** `server`
|
||||
|
||||
**Purpose:** Contains QiTech-specific machine implementations and glue code that ties everything together.
|
||||
|
||||
**Key Components:**
|
||||
- **Machine implementations** (`src/machines/`):
|
||||
- Winder V2
|
||||
- Extruder V1/V2
|
||||
- Aquapath (Waterway) V1
|
||||
- Laser V1
|
||||
- Buffer V1
|
||||
- Mock machines for testing
|
||||
- **EtherCAT setup** (`src/ethercat/`): Interface discovery, configuration
|
||||
- **Serial device handling** (`src/serial/`): Non-EtherCAT devices
|
||||
- **REST API handlers** (`src/rest/`): Machine mutations, device identification
|
||||
- **SocketIO handlers** (`src/socketio/`): Real-time communication
|
||||
- **Control loop** (`src/loop.rs`): Main control loop execution
|
||||
|
||||
**Dependencies:** `control-core`, `ethercat-hal`, external crates
|
||||
|
||||
**Reusability:** QiTech-specific, but demonstrates how to use the framework
|
||||
|
||||
### 4. User Interface
|
||||
|
||||
**Directory:** `electron/`
|
||||
|
||||
**Purpose:** Electron-based user interface for controlling and monitoring machines.
|
||||
|
||||
**Key Features:**
|
||||
- React-based UI with TypeScript
|
||||
- Real-time updates via SocketIO
|
||||
- REST API client for machine control
|
||||
- Machine-specific UI components (`src/machines/`)
|
||||
- Shadcn/UI component library
|
||||
- TanStack Router for navigation
|
||||
- Theming and internationalization support
|
||||
|
||||
**Dependencies:** Node.js, Electron, React ecosystem
|
||||
|
||||
**Reusability:** Could be reused with modifications for similar control systems
|
||||
|
||||
### 5. Operating System Configuration
|
||||
|
||||
**Directory:** `nixos/`
|
||||
|
||||
**Purpose:** NixOS configuration for deployment on control panels.
|
||||
|
||||
**Key Features:**
|
||||
- Real-time kernel configuration
|
||||
- Touchscreen support
|
||||
- System service definitions
|
||||
- Package management
|
||||
|
||||
**Reusability:** Reusable for similar embedded Linux deployments
|
||||
|
||||
### 6. Utility Crates
|
||||
|
||||
**Crate:** `ethercat-eeprom-dump`
|
||||
|
||||
**Purpose:** Command-line utility for dumping EtherCAT device EEPROM data.
|
||||
|
||||
**Dependencies:** `ethercat-hal`, `control-core`
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ ethercat-hal │◄─── Low-level HAL
|
||||
│ + derive macros │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
│ depends on
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ control-core │◄─── Generic framework
|
||||
│ + derive macros │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
│ depends on
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ server │◄─── QiTech-specific
|
||||
│ (machine impls) │ implementations
|
||||
└─────────────────────┘
|
||||
|
||||
┌─────────────────────┐
|
||||
│ electron │◄─── UI (communicates via
|
||||
│ (UI) │ REST/SocketIO APIs)
|
||||
└─────────────────────┘
|
||||
|
||||
┌─────────────────────┐
|
||||
│ nixos │◄─── OS configuration
|
||||
│ (deployment) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## Conceptual Separation
|
||||
|
||||
If this repository were to be split into separate repositories, a logical separation would be:
|
||||
|
||||
### Option A: Three Repository Split
|
||||
|
||||
1. **`control-framework`** (or `industrial-control-rs`)
|
||||
- Contains: `ethercat-hal`, `ethercat-hal-derive`, `control-core`, `control-core-derive`
|
||||
- Purpose: Reusable Rust framework for building industrial control systems
|
||||
- Audience: Other companies/projects building EtherCAT control systems
|
||||
- Published to: crates.io
|
||||
|
||||
2. **`control-ui`** (or `control-electron-ui`)
|
||||
- Contains: `electron/` directory
|
||||
- Purpose: Reusable Electron-based UI for control systems
|
||||
- Audience: Projects that want a pre-built control panel UI
|
||||
- Could be: npm package or standalone repository
|
||||
|
||||
3. **`control-qitech`**
|
||||
- Contains: `server/` (machine implementations), `nixos/` (deployment config)
|
||||
- Dependencies: Uses `control-framework` crates, includes `control-ui` as git submodule
|
||||
- Purpose: QiTech-specific machine implementations and deployment
|
||||
- Audience: QiTech internal use
|
||||
|
||||
### Option B: Two Repository Split (Simpler)
|
||||
|
||||
1. **`control-framework`**
|
||||
- Contains: `ethercat-hal`, `control-core`, derive macros, `electron/`, `nixos/`
|
||||
- Purpose: Complete reusable control system framework (backend + UI + OS)
|
||||
- Published: Core crates to crates.io, UI could be npm package
|
||||
|
||||
2. **`control-qitech`**
|
||||
- Contains: `server/` machine implementations
|
||||
- Dependencies: Uses `control-framework` crates and UI
|
||||
- Purpose: QiTech-specific implementations
|
||||
|
||||
## Advantages of Current Monorepo Structure
|
||||
|
||||
1. **Faster Development**: No need to coordinate versions across repositories
|
||||
2. **Atomic Changes**: Changes that span multiple components can be done in a single commit
|
||||
3. **Simplified CI/CD**: Single build pipeline for all components
|
||||
4. **Easier Testing**: Integration tests can span all components
|
||||
5. **No Submodule Complexity**: Git submodules can be challenging to work with
|
||||
6. **Unified Documentation**: All docs in one place
|
||||
7. **Consistent Versioning**: Everything versioned together
|
||||
|
||||
## Advantages of Multi-Repository Structure
|
||||
|
||||
1. **Clear Boundaries**: Enforces separation of concerns at repository level
|
||||
2. **Independent Versioning**: Framework can have stable releases while QiTech-specific code evolves
|
||||
3. **Access Control**: Different teams can have different permissions
|
||||
4. **Selective Cloning**: Users only clone what they need
|
||||
5. **Public/Private Split**: Framework could be open-source while keeping QiTech-specific code private
|
||||
6. **Clearer Licensing**: Different licenses for different components
|
||||
7. **Smaller Repositories**: Faster clone times, less clutter
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For Current Development (Monorepo)
|
||||
|
||||
The monorepo structure is appropriate for the current stage of development because:
|
||||
|
||||
1. The framework is still evolving rapidly alongside machine implementations
|
||||
2. The team is small and benefits from unified development workflow
|
||||
3. Breaking changes across layers can be handled atomically
|
||||
4. There's no immediate need to share the framework externally
|
||||
|
||||
**Best Practices to Maintain:**
|
||||
- Keep dependencies clean (framework crates should not depend on `server`)
|
||||
- Document public APIs in framework crates as if they were standalone
|
||||
- Write framework code generically without QiTech-specific assumptions
|
||||
- Keep machine-specific code isolated in `server/src/machines/`
|
||||
|
||||
### For Future (Multi-Repository)
|
||||
|
||||
Consider splitting when:
|
||||
|
||||
1. **Framework Stabilization**: When `ethercat-hal` and `control-core` APIs are stable enough for versioned releases
|
||||
2. **External Users**: When other companies/projects want to use the framework
|
||||
3. **Team Growth**: When separate teams are working on framework vs. machines
|
||||
4. **Open Source Plans**: If planning to open-source the framework while keeping machine implementations private
|
||||
|
||||
**Migration Path:**
|
||||
1. Extract `ethercat-hal` and `control-core` to `control-framework` repository
|
||||
2. Publish to crates.io with semantic versioning
|
||||
3. Update `server/Cargo.toml` to use published crates instead of path dependencies
|
||||
4. Optionally extract `electron/` as a separate UI package
|
||||
5. Keep `server/` as QiTech-specific implementation repository
|
||||
|
||||
## Current Approach: Virtual Separation
|
||||
|
||||
To get benefits of both approaches:
|
||||
|
||||
1. **Maintain Clean Boundaries**: Continue enforcing that framework crates don't depend on `server`
|
||||
2. **Documentation**: Document framework crates as standalone libraries
|
||||
3. **Testing**: Write tests for framework crates that don't depend on machine implementations
|
||||
4. **Cargo Workspaces**: Use workspace organization to group related crates
|
||||
5. **Future-Ready**: Write code assuming eventual split, making future extraction easier
|
||||
|
||||
## Workspace Organization
|
||||
|
||||
The workspace is organized to reflect logical grouping:
|
||||
|
||||
```toml
|
||||
[workspace]
|
||||
members = [
|
||||
# Framework crates (HAL layer)
|
||||
"ethercat-hal",
|
||||
"ethercat-hal-derive",
|
||||
|
||||
# Framework crates (Core layer)
|
||||
"control-core",
|
||||
"control-core-derive",
|
||||
|
||||
# Machine implementations
|
||||
"server",
|
||||
|
||||
# Utilities
|
||||
"ethercat-eeprom-dump",
|
||||
]
|
||||
```
|
||||
|
||||
This organization makes it clear which crates are part of the reusable framework and which are application-specific.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The current monorepo structure is well-suited for QiTech's needs at this stage. The codebase already has good logical separation between:
|
||||
- Reusable framework components (`ethercat-hal`, `control-core`)
|
||||
- Machine-specific implementations (`server`)
|
||||
- User interface (`electron`)
|
||||
- Deployment configuration (`nixos`)
|
||||
|
||||
The key is to maintain clean boundaries and follow best practices that would make a future split straightforward if needed. This gives QiTech the flexibility to evolve the architecture as requirements change without prematurely optimizing for a multi-repository structure.
|
||||
@@ -0,0 +1,235 @@
|
||||
# REST API `/api/v2`
|
||||
|
||||
The Qitech Control Panel exposes a small HTTP interface for discovering machines and reading their current values. All examples below assume you are connected to the panel’s Ethernet subnet and talk directly to the panel at:
|
||||
|
||||
- **Base URL:** `http://10.10.10.1:3001`
|
||||
|
||||
> **Schema note:** This documentation intentionally stays light on field details beyond what is shown in the examples. For the exact datatypes and complete payload shapes, refer to the corresponding Rust types (linked below).
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
The panel does **not** perform HTTP authentication. The expected security model is **network-level isolation**: anything that can send packets to the panel’s Ethernet interface is treated as trusted. This matches common security assumptions in EtherCAT-style control networks.
|
||||
|
||||
The panel is configured to administer its own subnet `10.10.10.0/24` via DHCP, while Wi-Fi can be used for upstream internet connectivity (if configured). If you need authentication or access from outside the isolated subnet, place a router/device in **client/bridge mode** on the panel network and expose the panel through a **reverse proxy** where you can add authentication, logging, rate limiting, etc.
|
||||
|
||||
If DNS is available on the subnet, you may be able to resolve `qitech.control`; otherwise use the static address `10.10.10.1`.
|
||||
|
||||
---
|
||||
|
||||
## List machines `GET /api/v2/machine`
|
||||
|
||||
Returns the set of machines currently known/connected to the panel.
|
||||
|
||||
Machines are identified by:
|
||||
|
||||
- `slug`: the machine type / model identifier (string)
|
||||
- `serial`: the specific machine instance identifier (int)
|
||||
|
||||
Each machine also includes a `legacy_id` to support older **v1** workflows. If a machine reports an issue, the `error` field may be present and non-null (containing an error message).
|
||||
|
||||
### Example request
|
||||
|
||||
```bash
|
||||
curl -X GET "http://10.10.10.1:3001/api/v2/machine"
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
```json
|
||||
{
|
||||
"machines": [
|
||||
{
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 7
|
||||
},
|
||||
"serial": 57922
|
||||
},
|
||||
"serial": 57922,
|
||||
"vendor": "QiTech",
|
||||
"slug": "mock",
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 4
|
||||
},
|
||||
"serial": 57922
|
||||
},
|
||||
"serial": 57922,
|
||||
"vendor": "QiTech",
|
||||
"slug": "extruder_v1",
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 2
|
||||
},
|
||||
"serial": 57922
|
||||
},
|
||||
"serial": 57922,
|
||||
"vendor": "QiTech",
|
||||
"slug": "winder_v1",
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 10
|
||||
},
|
||||
"serial": 48879
|
||||
},
|
||||
"serial": 48879,
|
||||
"vendor": "QiTech",
|
||||
"slug": "wago_power_v1",
|
||||
"error": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get current values `GET /api/v2/machine/<slug>/<serial>`
|
||||
|
||||
Returns all currently known values for a single machine.
|
||||
|
||||
Values are categorized into two groups:
|
||||
|
||||
- **State**: requested/commanded values (these typically change only after a state-change request, or if another controller updates them)
|
||||
- **Live Values**: measured/observed values coming from the machine and potentially changing quickly
|
||||
|
||||
This REST endpoint returns **only the current snapshot**, not a stream of live values.
|
||||
To receive continuous updates (via WebSockets), subscribe to the machine namespace (see **WebSockets** below).
|
||||
|
||||
### Example request (mock machine)
|
||||
|
||||
```bash
|
||||
curl -X GET "http://10.10.10.1:3001/api/v2/machine/mock/57922"
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
```json
|
||||
{
|
||||
"machine": {
|
||||
"legacy_id": {
|
||||
"machine_identification": {
|
||||
"vendor": 1,
|
||||
"machine": 7
|
||||
},
|
||||
"serial": 57922
|
||||
},
|
||||
"serial": 57922,
|
||||
"vendor": "QiTech",
|
||||
"slug": "mock",
|
||||
"error": null
|
||||
},
|
||||
"state": {
|
||||
"frequency1": 100.0,
|
||||
"frequency2": 200.0,
|
||||
"frequency3": 500.0,
|
||||
"is_default_state": false,
|
||||
"mode_state": {
|
||||
"mode": "Running"
|
||||
}
|
||||
},
|
||||
"live_values": {
|
||||
"amplitude1": -0.03438523433309566,
|
||||
"amplitude2": -0.06872980145477608,
|
||||
"amplitude3": -0.1711138370170743,
|
||||
"amplitude_sum": -0.27422887280494607
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Change machine state `POST /api/v1/machine/<slug>/<serial>`
|
||||
|
||||
State changes are submitted as **mutations**. The mutation payload is defined per machine type in Rust. Conceptually, each item in the mutation list represents a setter-style operation that is applied by the real-time control loop.
|
||||
|
||||
The API does **not** return the newly-applied state in the POST response. The panel runs a real-time loop and generally won’t block waiting for the physical system to converge. Instead:
|
||||
|
||||
- Submit the mutation via `POST`
|
||||
- Poll `GET /api/v2/machine/<slug>/<serial>` to observe the updated state and/or any reported errors
|
||||
|
||||
### Example request (mock machine)
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-d \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://10.10.10.1:3001/api/v1/machine/mock/57922"
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
```json
|
||||
null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSockets
|
||||
|
||||
For continuous updates, subscribe to a machine-specific namespace derived from its `legacy_id`:
|
||||
|
||||
- Namespace: `/machine/<vendor>/<id>/<serial>`
|
||||
|
||||
The stream emits events for:
|
||||
|
||||
- state changes (`StateEvent`)
|
||||
- live value updates (`LiveValuesEvent`)
|
||||
|
||||
Both event payloads use the same machine-specific schema as the `/api/v2` REST responses.
|
||||
|
||||
---
|
||||
|
||||
## List of all machines
|
||||
|
||||
Below is a template you can fill with links to the relevant Rust types (mutations + state/live structs).
|
||||
For each machine, link to:
|
||||
|
||||
- **Mutations:** the request payload type used by `POST /api/v1/machine/<slug>/<serial>`
|
||||
- **State / Live Values:** the response payload types returned by `GET /api/v2/machine/<slug>/<serial>`
|
||||
|
||||
### Machines
|
||||
|
||||
- **winder_v1**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L87>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L163>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/winder2/api.rs#L143>
|
||||
|
||||
- **extruder_v1**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L214>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L89>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder1/api.rs#L55>
|
||||
|
||||
- **laser_v1**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L88>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L32>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/laser/api.rs#L17>
|
||||
|
||||
- **mock**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L84>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L38>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/mock/api.rs#L24>
|
||||
|
||||
- **extruder_v2**
|
||||
|
||||
- Mutations: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L126>
|
||||
- State: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L93>
|
||||
- Live Values: <https://github.com/qitechgmbh/control/blob/1ec20074e9030a0ed1739ca9d9a77e298a2652a3/machines/src/extruder2/api.rs#L59>
|
||||
@@ -73,7 +73,7 @@ typings/
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
# Serverless directoriesProcessMetrics
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
@@ -88,6 +88,7 @@ typings/
|
||||
# Vite
|
||||
.vite/
|
||||
dist/
|
||||
dist-electron/
|
||||
|
||||
# Electron-Forge
|
||||
out/
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
|
||||
import { MakerZIP } from "@electron-forge/maker-zip";
|
||||
import { MakerDeb } from "@electron-forge/maker-deb";
|
||||
import { MakerRpm } from "@electron-forge/maker-rpm";
|
||||
import { VitePlugin } from "@electron-forge/plugin-vite";
|
||||
import { FusesPlugin } from "@electron-forge/plugin-fuses";
|
||||
import { FuseV1Options, FuseVersion } from "@electron/fuses";
|
||||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
executableName: "electron-shadcn",
|
||||
asar: true,
|
||||
icon: "src/assets/icon",
|
||||
},
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
new MakerSquirrel({}),
|
||||
new MakerZIP({}, ["darwin"]),
|
||||
new MakerRpm({}),
|
||||
new MakerDeb({}),
|
||||
],
|
||||
plugins: [
|
||||
new VitePlugin({
|
||||
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
|
||||
// If you are familiar with Vite configuration, it will look really familiar.
|
||||
build: [
|
||||
{
|
||||
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
|
||||
entry: "src/main.ts",
|
||||
config: "vite.main.config.ts",
|
||||
},
|
||||
{
|
||||
entry: "src/preload.ts",
|
||||
config: "vite.preload.config.ts",
|
||||
},
|
||||
],
|
||||
renderer: [
|
||||
{
|
||||
name: "main_window",
|
||||
config: "vite.renderer.config.ts",
|
||||
},
|
||||
],
|
||||
}),
|
||||
// Fuses are used to enable/disable various Electron functionality
|
||||
// at package time, before code signing the application
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,35 +0,0 @@
|
||||
export {}; // Make this a module
|
||||
|
||||
declare global {
|
||||
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Vite
|
||||
// plugin that tells the Electron app where to look for the Vite-bundled app code (depending on
|
||||
// whether you're running in development or production).
|
||||
const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
|
||||
const MAIN_WINDOW_VITE_NAME: string;
|
||||
|
||||
namespace NodeJS {
|
||||
interface Process {
|
||||
// Used for hot reload after preload scripts.
|
||||
viteDevServers: Record<string, import("vite").ViteDevServer>;
|
||||
}
|
||||
}
|
||||
|
||||
type VitePluginConfig = ConstructorParameters<
|
||||
typeof import("@electron-forge/plugin-vite").VitePlugin
|
||||
>[0];
|
||||
|
||||
interface VitePluginRuntimeKeys {
|
||||
VITE_DEV_SERVER_URL: `${string}_VITE_DEV_SERVER_URL`;
|
||||
VITE_NAME: `${string}_VITE_NAME`;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "vite" {
|
||||
interface ConfigEnv<
|
||||
K extends keyof VitePluginConfig = keyof VitePluginConfig,
|
||||
> {
|
||||
root: string;
|
||||
forgeConfig: VitePluginConfig;
|
||||
forgeConfigSelf: VitePluginConfig[K][number];
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,8 @@
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"lint:write": "eslint . --fix",
|
||||
"format": "prettier --check .",
|
||||
@@ -22,15 +20,6 @@
|
||||
},
|
||||
"author": "",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.6.1",
|
||||
"@electron-forge/maker-deb": "^7.8.0",
|
||||
"@electron-forge/maker-rpm": "^7.8.3",
|
||||
"@electron-forge/maker-squirrel": "^7.6.1",
|
||||
"@electron-forge/maker-zip": "^7.6.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.6.1",
|
||||
"@electron-forge/plugin-fuses": "^7.10.2",
|
||||
"@electron-forge/plugin-vite": "^7.6.1",
|
||||
"@electron-forge/shared-types": "^7.4.0",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@eslint/compat": "^1.2.6",
|
||||
"@eslint/js": "^9.18.0",
|
||||
@@ -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": {
|
||||
@@ -84,7 +75,6 @@
|
||||
"@tanstack/react-router": "^1.130.17",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tanstack/router-devtools": "^1.105.0",
|
||||
"@tanstack/zod-adapter": "^1.116.0",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"babel-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -109,9 +99,9 @@
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uplot": "^1.6.32",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.2",
|
||||
"zod-validation-error": "^3.4.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.3.5",
|
||||
"zod-validation-error": "^5.0.0",
|
||||
"zustand": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
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
|
||||
|
||||

|
||||
@@ -15,25 +15,31 @@ import {
|
||||
machineIdentificationUnique,
|
||||
} from "@/machines/types";
|
||||
import { useRef } from "react";
|
||||
import { rustEnumSchema } from "@/lib/types";
|
||||
import { rustEnum } from "@/lib/types";
|
||||
|
||||
export type EthercatDevices = z.infer<typeof ethercatDevicesSchema>;
|
||||
|
||||
export const ethercatDevicesSchema = z.object({
|
||||
devices: z.array(
|
||||
z.object({
|
||||
configured_address: z.number().int(),
|
||||
name: z.string(),
|
||||
vendor_id: z.number().int(),
|
||||
product_id: z.number().int(),
|
||||
revision: z.number().int(),
|
||||
device_identification: deviceIdentification,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Update the EthercatDevicesEventData schema
|
||||
export const ethercatDevicesEventDataSchema = rustEnumSchema({
|
||||
Initializing: z.boolean(),
|
||||
Done: z.object({
|
||||
devices: z.array(
|
||||
z.object({
|
||||
configured_address: z.number().int(),
|
||||
name: z.string(),
|
||||
vendor_id: z.number().int(),
|
||||
product_id: z.number().int(),
|
||||
revision: z.number().int(),
|
||||
device_identification: deviceIdentification,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Error: z.string(),
|
||||
});
|
||||
export const ethercatDevicesEventDataSchema = z
|
||||
.object({
|
||||
Initializing: z.boolean(),
|
||||
Done: ethercatDevicesSchema,
|
||||
Error: z.string(),
|
||||
})
|
||||
.check(rustEnum);
|
||||
|
||||
export type EthercatDevicesEventData = z.infer<
|
||||
typeof ethercatDevicesEventDataSchema
|
||||
@@ -62,10 +68,12 @@ export const machinesEventSchema = eventSchema(machinesEventDataSchema);
|
||||
export type MachinesEvent = z.infer<typeof machinesEventSchema>;
|
||||
|
||||
// Keep the EthercatInterfaceDiscovery event
|
||||
export const ethercatInterfaceDiscoveryEventDataSchema = rustEnumSchema({
|
||||
Discovering: z.boolean(),
|
||||
Done: z.string(),
|
||||
});
|
||||
export const ethercatInterfaceDiscoveryEventDataSchema = z
|
||||
.object({
|
||||
Discovering: z.boolean(),
|
||||
Done: z.string(),
|
||||
})
|
||||
.check(rustEnum);
|
||||
|
||||
export type EthercatInterfaceDiscoveryEventData = z.infer<
|
||||
typeof ethercatInterfaceDiscoveryEventDataSchema
|
||||
@@ -110,23 +118,19 @@ export function mainMessageHandler(
|
||||
try {
|
||||
// Apply appropriate caching strategy based on event type
|
||||
if (eventName === "EthercatDevicesEvent") {
|
||||
const validatedEvent = ethercatDevicesEventSchema.parse(event);
|
||||
console.log("EthercatDevicesEvent", validatedEvent);
|
||||
const validatedEvent = event;
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
ethercatDevices: validatedEvent,
|
||||
}));
|
||||
} else if (eventName === "MachinesEvent") {
|
||||
const validatedEvent = machinesEventSchema.parse(event);
|
||||
console.log("MachinesEvent", validatedEvent);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
machines: validatedEvent,
|
||||
}));
|
||||
} else if (eventName === "EthercatInterfaceDiscoveryEvent") {
|
||||
const validatedEvent =
|
||||
ethercatInterfaceDiscoveryEventSchema.parse(event);
|
||||
console.log("EthercatInterfaceDiscoveryEvent", validatedEvent);
|
||||
const validatedEvent = event;
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
ethercatInterfaceDiscovery: validatedEvent,
|
||||
@@ -145,8 +149,17 @@ export function mainMessageHandler(
|
||||
};
|
||||
}
|
||||
|
||||
// Create the store instance once and export it
|
||||
export const mainNamespaceStore: StoreApi<MainNamespaceStore> =
|
||||
createMainNamespaceStore();
|
||||
|
||||
// Message handler stays the same
|
||||
export const mainMessageHandlerInstance: EventHandler =
|
||||
mainMessageHandler(mainNamespaceStore);
|
||||
|
||||
// Hook that uses the exported store
|
||||
const mainRoomImplementation = createNamespaceHookImplementation({
|
||||
createStore: createMainNamespaceStore,
|
||||
createStore: () => mainNamespaceStore, // use the exported store
|
||||
createEventHandler: mainMessageHandler,
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +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 } from "./mainNamespace";
|
||||
|
||||
/**
|
||||
* Simple buffer-based store updater to limit React re-renders to ~30 FPS
|
||||
@@ -162,6 +163,7 @@ export const handleEventValidationError = (
|
||||
error: z.ZodError,
|
||||
eventName: string,
|
||||
) => {
|
||||
console.log(error);
|
||||
toastZodError(error, `Event Validation Error for ${eventName}`);
|
||||
throw new Error(`Event validation failed for ${eventName}`);
|
||||
};
|
||||
@@ -305,27 +307,13 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
// add handlers
|
||||
socket.on("connect", () => {
|
||||
console.log(`Connected to ${namespace_path}`);
|
||||
|
||||
// reset the store
|
||||
resetStore(set);
|
||||
});
|
||||
socket.on("disconnect", (reason) => {
|
||||
socket.disconnect();
|
||||
console.warn(`Disconnected from ${namespace_path}, reason: ${reason}`);
|
||||
|
||||
// reset the store
|
||||
resetStore(set);
|
||||
|
||||
// Attempt to reconnect after a short delay for any disconnect reason
|
||||
console.log(
|
||||
`Disconnected from ${namespace_path}, attempting reconnect in 1s...`,
|
||||
);
|
||||
setTimeout(() => {
|
||||
if (get().hasNamespace(namespaceId) && !socket.connected) {
|
||||
console.log(`Reconnecting to ${namespace_path}...`);
|
||||
socket.connect();
|
||||
}
|
||||
}, 1000);
|
||||
// Its hacky but i do not care, electron + react is annoying ...
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
socket.on("event", (event: unknown) => {
|
||||
@@ -334,11 +322,9 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
if (!event_parsed.success) {
|
||||
toastZodError(event_parsed.error, "Invalid event");
|
||||
return;
|
||||
}
|
||||
// handle the event
|
||||
} // handle the event
|
||||
get().namespaces[namespace_path].handler(event_parsed.data);
|
||||
});
|
||||
|
||||
// store the namespace initally
|
||||
set(
|
||||
produce((state: SocketioStore) => {
|
||||
@@ -356,8 +342,25 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
|
||||
}),
|
||||
);
|
||||
|
||||
// finally connect
|
||||
socket.connect();
|
||||
if (namespace_path !== "/main") {
|
||||
const intervalId = setInterval(() => {
|
||||
const mainState = mainNamespaceStore.getState();
|
||||
const machineExists =
|
||||
namespaceId.type === "machine" &&
|
||||
mainState.machines?.data?.machines.some(
|
||||
(m) =>
|
||||
m.machine_identification_unique.serial ===
|
||||
namespaceId.machine_identification_unique.serial,
|
||||
);
|
||||
//console.log(machineExists);
|
||||
if (machineExists && !socket.connected) {
|
||||
socket.connect();
|
||||
clearInterval(intervalId); // stop polling
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
socket.connect();
|
||||
}
|
||||
},
|
||||
incrementNamespace: (namespaceId: NamespaceId) => {
|
||||
const namespace_path = serializeNamespaceId(namespaceId);
|
||||
@@ -382,11 +385,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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { mainNamespaceStore, mainMessageHandler } from "./mainNamespace";
|
||||
|
||||
// Soft reset function
|
||||
export function softReloadMainNamespaceStore() {
|
||||
// 1️⃣ Reset the store to initial state
|
||||
mainNamespaceStore.setState({
|
||||
ethercatDevices: null,
|
||||
machines: null,
|
||||
ethercatInterfaceDiscovery: null,
|
||||
});
|
||||
|
||||
// 2️⃣ Re-attach the message handler if needed
|
||||
// This ensures future socket events still update the store
|
||||
mainMessageHandler(mainNamespaceStore);
|
||||
|
||||
console.log("MainNamespaceStore soft reloaded");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PresetData, PresetSchema } from "@/lib/preset/preset";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,13 +13,13 @@ import { Separator } from "../ui/separator";
|
||||
import { PresetPreviewEntries, PresetPreviewTable } from "./PresetPreviewTable";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
export type NewPresetDialogProps<T extends PresetSchema> = {
|
||||
currentState?: PresetData<T>;
|
||||
export type NewPresetDialogProps<T> = {
|
||||
currentState?: T;
|
||||
previewEntries: PresetPreviewEntries<T>;
|
||||
onSave: (name: string) => void;
|
||||
};
|
||||
|
||||
export function NewPresetDialog<T extends PresetSchema>({
|
||||
export function NewPresetDialog<T>({
|
||||
currentState,
|
||||
onSave,
|
||||
previewEntries,
|
||||
|
||||
@@ -60,7 +60,7 @@ export function PresetCardMenu({
|
||||
);
|
||||
}
|
||||
|
||||
export type PresetCardProps<T extends PresetSchema> = {
|
||||
export type PresetCardProps<T> = {
|
||||
preset: Preset<T>;
|
||||
onApply: (preset: Preset<T>) => void;
|
||||
onOverwrite: (preset: Preset<T>) => void;
|
||||
@@ -72,7 +72,7 @@ export type PresetCardProps<T extends PresetSchema> = {
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
export function PresetCard<T extends PresetSchema>({
|
||||
export function PresetCard<T>({
|
||||
preset,
|
||||
onApply,
|
||||
onOverwrite,
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
import React, { Fragment } from "react";
|
||||
import { PresetData, PresetSchema } from "@/lib/preset/preset";
|
||||
import { renderUnitSymbol, renderUnitSyntax, Unit } from "@/control/units";
|
||||
|
||||
export const previewSeparator = undefined;
|
||||
|
||||
type PreviewSeparator = typeof previewSeparator;
|
||||
|
||||
type PresetPreviewEntry<T extends PresetSchema> = {
|
||||
type PresetPreviewEntry<T> = {
|
||||
name: string;
|
||||
unit?: Unit;
|
||||
renderValue: (data: PresetData<T>) => string | undefined;
|
||||
renderValue: (data: T) => string | undefined;
|
||||
};
|
||||
|
||||
export type PresetPreviewEntries<T extends PresetSchema> = (
|
||||
export type PresetPreviewEntries<T> = (
|
||||
| PresetPreviewEntry<T>
|
||||
| PreviewSeparator
|
||||
)[];
|
||||
|
||||
export type PresetPreviewTableProps<T extends PresetSchema> = {
|
||||
data: PresetData<T>;
|
||||
export type PresetPreviewTableProps<T> = {
|
||||
data?: T;
|
||||
entries: PresetPreviewEntries<T>;
|
||||
};
|
||||
|
||||
export function PresetPreviewTable<T extends PresetSchema>({
|
||||
export function PresetPreviewTable<T>({
|
||||
data,
|
||||
entries,
|
||||
}: PresetPreviewTableProps<T>) {
|
||||
@@ -39,7 +38,7 @@ export function PresetPreviewTable<T extends PresetSchema>({
|
||||
);
|
||||
}
|
||||
|
||||
const value = entry.renderValue(data);
|
||||
const value = data !== undefined ? entry.renderValue(data) : undefined;
|
||||
return (
|
||||
<Fragment key={entry.name}>
|
||||
<div>{entry.name}</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Preset, PresetSchema } from "@/lib/preset/preset";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,14 +13,14 @@ import { Icon } from "../Icon";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { PresetPreviewEntries, PresetPreviewTable } from "./PresetPreviewTable";
|
||||
|
||||
export type PresetShowDialogProps<T extends PresetSchema> = {
|
||||
export type PresetShowDialogProps<T> = {
|
||||
preset: Preset<T>;
|
||||
previewEntries: PresetPreviewEntries<T>;
|
||||
onApply: (preset: Preset<T>) => void;
|
||||
hideDate?: boolean;
|
||||
};
|
||||
|
||||
export function PresetShowDialog<T extends PresetSchema>({
|
||||
export function PresetShowDialog<T>({
|
||||
preset,
|
||||
onApply,
|
||||
previewEntries,
|
||||
|
||||
@@ -2,19 +2,19 @@ import React from "react";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { Page } from "@/components/Page";
|
||||
import { usePresets, UsePresetsParams } from "@/lib/preset/usePresets";
|
||||
import { Preset, PresetSchema } from "@/lib/preset/preset";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
import { PresetCard } from "./PresetCard";
|
||||
import { PresetPreviewEntries } from "./PresetPreviewTable";
|
||||
import { NewPresetDialog } from "./NewPresetDialog";
|
||||
import { downloadJson } from "@/lib/download";
|
||||
import { JsonFileInput } from "../FileInput";
|
||||
|
||||
type PresetsPageProps<T extends PresetSchema> = UsePresetsParams<T> & {
|
||||
type PresetsPageProps<T> = UsePresetsParams<T> & {
|
||||
applyPreset: (preset: Preset<T>) => void;
|
||||
previewEntries: PresetPreviewEntries<T>;
|
||||
};
|
||||
|
||||
export function PresetsPage<T extends PresetSchema>({
|
||||
export function PresetsPage<T>({
|
||||
applyPreset,
|
||||
machine_identification,
|
||||
currentState,
|
||||
|
||||
@@ -36,7 +36,6 @@ type Props = {
|
||||
maxSlider?: number; // Override the slider max value
|
||||
maxLabel?: string;
|
||||
step?: number;
|
||||
valueSchema?: z.ZodType<number>;
|
||||
inverted?: boolean;
|
||||
confirmation?: string;
|
||||
renderValue: (value: number) => string;
|
||||
@@ -89,7 +88,6 @@ export function EditValue({
|
||||
description,
|
||||
defaultValue,
|
||||
step = 1,
|
||||
valueSchema: schema,
|
||||
inverted,
|
||||
min,
|
||||
max,
|
||||
@@ -104,7 +102,7 @@ export function EditValue({
|
||||
|
||||
// Form setup
|
||||
const formSchema = z.object({
|
||||
value: schema ?? z.number(),
|
||||
value: z.number(),
|
||||
});
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -191,7 +189,6 @@ export function EditValue({
|
||||
const numericValue = parseFloat(normalizedInput);
|
||||
const hasValidationError =
|
||||
isNaN(numericValue) ||
|
||||
(schema && !schema.safeParse(numericValue).success) ||
|
||||
(max !== undefined && numericValue > max) ||
|
||||
(min !== undefined && numericValue < min);
|
||||
|
||||
@@ -222,15 +219,7 @@ export function EditValue({
|
||||
setValueStringError(false);
|
||||
preventFormSyncRef.current = false;
|
||||
}
|
||||
}, [
|
||||
valueString,
|
||||
valueStringDirty,
|
||||
schema,
|
||||
max,
|
||||
min,
|
||||
form,
|
||||
roundToStepDecimals,
|
||||
]);
|
||||
}, [valueString, valueStringDirty, max, min, form, roundToStepDecimals]);
|
||||
|
||||
// Reset input state to clean form value
|
||||
const resetInput = React.useCallback(() => {
|
||||
|
||||
@@ -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];
|
||||
|
||||