Compare commits

..
Author SHA1 Message Date
kraemr f9eea67743 test 2025-10-29 19:00:15 +01:00
kraemr 70adc3a36c fixed ident 2025-10-29 15:38:48 +01:00
kraemr 6639d11593 Wago is now identified 2025-10-29 15:38:48 +01:00
353 changed files with 11303 additions and 19855 deletions

No files matched your search

+28 -51
View File
@@ -3,73 +3,50 @@ name: Electron
on:
push:
branches:
- master
- "**"
pull_request:
branches:
- master
defaults:
run:
working-directory: electron
- "*"
jobs:
build:
build-electron:
runs-on: ubuntu-latest
defaults:
run:
working-directory: electron
steps:
- &checkout
name: Checkout
uses: actions/checkout@v4
- &setup-node
name: Setup Node.js
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
cache-dependency-path: |
electron/package-lock.json
node-version: "18" # or '20' for latest LTS
- &install
name: Install dependencies
run: npm ci
- uses: actions/checkout@v4
- name: Compile
run: tsc
- 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
run: npm run build
test:
runs-on: ubuntu-latest
needs: build
steps:
- *checkout
- *setup-node
- *install
- name: Test
run: npm run test
format:
runs-on: ubuntu-latest
steps:
- *checkout
- *setup-node
- *install
run: tsc
- name: Format
run: npm run format
lint:
runs-on: ubuntu-latest
steps:
- *checkout
- *setup-node
- *install
- name: Lint
run: npm run lint
- name: Test
run: npm run test
- name: Package
run: npm run package
-96
View File
@@ -1,96 +0,0 @@
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
+9 -48
View File
@@ -3,10 +3,9 @@ name: Rust
on:
push:
branches:
- master
- "**"
pull_request:
branches:
- master
branches: "*"
env:
CARGO_TERM_COLOR: always
@@ -17,24 +16,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- &udev-install
name: Install libudev development package
- name: Install libudev development package
run: sudo apt-get update && sudo apt-get install -y libudev-dev
- &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/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
uses: actions/cache@v3
with:
path: |
@@ -52,36 +41,8 @@ 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"
+1 -4
View File
@@ -4,7 +4,4 @@ dist/
Cargo.lock
dhat-heap.json
build.sh
shell.nix
node_modules/
runtime_metrics.csv
compile_metrics.csv
-37
View File
@@ -1,40 +1,3 @@
# `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_
Generated
+1060 -507
View File
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -4,11 +4,8 @@ members = [
"ethercat-hal",
"ethercat-hal-derive",
"control-core",
"machines",
#"ethercat-eeprom-dump",
"ethercat-eeprom-dump",
"control-core-derive",
"units",
"utils",
]
exclude = []
resolver = "2"
+2 -16
View File
@@ -2,12 +2,7 @@
# QiTech Control
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.
QiTech Control is an industrial control panel software for the next generation of QiTech recycling machines built on top of Beckhoff Automation hardware.
[![](https://img.youtube.com/vi/KI3YeBwfV-s/maxresdefault.jpg)](https://www.youtube.com/watch?v=KI3YeBwfV-s)
*Click here to watch a video demo of our software.*
@@ -40,7 +35,7 @@ Other
## Backend
To interface with Beckhoff and other EtherCAT devices we need an EtherCAT master software. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
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.
[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.
@@ -79,15 +74,6 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
| Laser | V1 | ??? | Diameter Measuring Laser | - | 1 (Qitech Industries GmbH) | 6 | Yes | |
| Mock | - | ??? | Mock Machine for Testing | - | 1 (Qitech Industries GmbH) | 7 | Yes | - |
# Minimal Hardware Examples
For complete step-by-step tutorials on setting up your first hardware, including wiring diagrams and software configuration, see the [Getting Started Guide](./docs/developer-docs/getting-started.md#minimal-hardware-examples).
Available examples:
- **LED Control with EL2004** - Digital output control (simplest setup)
- **Analog Input with EL3021** - Reading analog current measurements
# More Docs
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
-67
View File
@@ -1,67 +0,0 @@
#!/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"
+4 -2
View File
@@ -12,16 +12,18 @@ 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"
tracing = "0.1.44"
serial = "0.4.0"
tracing = "0.1.41"
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 units::{
use uom::si::{
angular_acceleration::radian_per_second_squared,
angular_velocity::radian_per_second,
f64::{AngularAcceleration, AngularVelocity},
@@ -1,6 +1,6 @@
use std::time::Instant;
use units::{
use uom::si::{
acceleration::meter_per_second_squared,
f64::{Acceleration, Velocity},
velocity::meter_per_second,
@@ -1,6 +1,6 @@
use std::time::Instant;
use units::{
use uom::si::{
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 units::f64::Angle;
/// use units::angle::degree;
/// use uom::si::f64::Angle;
/// use uom::si::angle::degree;
///
/// let mut controller = AngularAccelerationPositionController::new_simple(
/// Some(Angle::new::<degree>(180.0)),
@@ -1,6 +1,6 @@
use std::time::Instant;
use units::{
use uom::si::{
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 units::{
/// use uom::si::{
/// 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 units::{
/// use uom::si::{
/// 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 units::{
/// use uom::si::{
/// 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 units::{angular_velocity::revolution_per_minute, f64::AngularVelocity};
/// use uom::si::{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 units::{
use uom::si::{
acceleration::meter_per_second_squared,
f64::{Acceleration, Length, Velocity},
length::meter,
@@ -22,8 +22,8 @@ use super::acceleration_position_controller::{
///
/// # Example
/// ```ignore
/// use units::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
/// use units::f64::{Length, Velocity, Acceleration};
/// use uom::si::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
/// use uom::si::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 units::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
/// use units::f64::{Length, Velocity, Acceleration};
/// use uom::si::{length::meter, velocity::meter_per_second, acceleration::meter_per_second_squared};
/// use uom::si::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 units::{length::meter, f64::Length};
/// use uom::si::{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 units::{length::meter, velocity::meter_per_second, f64::{Length, Velocity}};
/// use uom::si::{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 units::{
use uom::si::{
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 units::{
/// use uom::si::{
/// velocity::meter_per_second,
/// acceleration::meter_per_second_squared,
/// jerk::meter_per_second_cubed,
@@ -65,7 +65,7 @@ impl LinearJerkSpeedController {
///
/// # Example
/// ```ignore
/// use units::{
/// use uom::si::{
/// velocity::meter_per_second,
/// acceleration::meter_per_second_squared,
/// jerk::meter_per_second_cubed,
@@ -127,7 +127,7 @@ impl LinearJerkSpeedController {
///
/// # Example
/// ```ignore
/// use units::{
/// use uom::si::{
/// velocity::meter_per_second,
/// acceleration::meter_per_second_squared,
/// jerk::meter_per_second_cubed,
@@ -1,4 +1,4 @@
use units::{angle::radian, f64::Angle};
use uom::si::{angle::radian, f64::Angle};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AngleConverter {
@@ -1,4 +1,4 @@
use units::{
use uom::si::{
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 units::{
use uom::si::{
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 units::{f64::{Length, Velocity}, length::meter, velocity::meter_per_second};
//! use uom::si::{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 units::{
use uom::si::{
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 units::length::centimeter;
use uom::si::length::centimeter;
#[test]
fn test_constructors() {
@@ -1,4 +1,4 @@
use units::{
use uom::si::{
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 units::{
use uom::si::{
acceleration::meter_per_second_squared, angular_acceleration::radian_per_second_squared,
length::meter, velocity::meter_per_second,
};
@@ -1,119 +1,3 @@
use std::fmt::Display;
use ethercat_hal::devices::wago_750_354::WAGO_750_354_IDENTITY_A;
use ethercat_hal::devices::wago_modules::ip20_ec_di8_do8::IP20_EC_DI8_DO8_IDENTITY;
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,
}
use anyhow::Error;
use anyhow::anyhow;
use ethercat_hal::devices::ek1100::EK1100_IDENTITY_A;
@@ -143,12 +27,17 @@ use ethercat_hal::devices::el7031::{EL7031_IDENTITY_A, EL7031_IDENTITY_B};
use ethercat_hal::devices::el7031_0030::EL7031_0030_IDENTITY_A;
use ethercat_hal::devices::el7041_0052::EL7041_0052_IDENTITY_A;
use ethercat_hal::devices::subdevice_identity_to_tuple;
use ethercat_hal::devices::wago_750_354::WAGO_750_354_IDENTITY;
use ethercat_hal::helpers::ethercrab_types::{
EthercrabSubDeviceOperational, EthercrabSubDevicePreoperational,
};
use ethercrab::MainDevice;
use ethercrab::SubDeviceIdentity;
use crate::machines::identification::DeviceMachineIdentification;
use crate::machines::identification::MachineIdentification;
use crate::machines::identification::MachineIdentificationUnique;
#[derive(Debug)]
pub struct MachineIdentificationAddresses {
pub vendor_word: u16,
@@ -207,7 +96,7 @@ pub async fn machine_device_identification<'maindevice>(
let addresses = match get_identification_addresses(&subdevice.identity(), subdevice.name()) {
Ok(x) => x,
Err(e) => {
// u16dump(subdevice, maindevice, 0, 128).await?;
u16dump(subdevice, maindevice, 0, 128).await?;
return Err(e);
}
};
@@ -347,8 +236,6 @@ pub fn get_identification_addresses(
let identity_tuple = subdevice_identity_to_tuple(subdevice_identity);
Ok(match identity_tuple {
WAGO_750_354_IDENTITY_A => MachineIdentificationAddresses::default(),
IP20_EC_DI8_DO8_IDENTITY => MachineIdentificationAddresses::default(),
EK1100_IDENTITY_A => MachineIdentificationAddresses::default(),
EL1002_IDENTITY_A => MachineIdentificationAddresses::default(),
EL1008_IDENTITY_A => MachineIdentificationAddresses::default(),
@@ -372,6 +259,7 @@ pub fn get_identification_addresses(
EL7031_IDENTITY_A | EL7031_IDENTITY_B => MachineIdentificationAddresses::default(),
EL7031_0030_IDENTITY_A => MachineIdentificationAddresses::default(),
EL7041_0052_IDENTITY_A => MachineIdentificationAddresses::default(),
WAGO_750_354_IDENTITY => MachineIdentificationAddresses::default(),
_ => {
// block_on(u16dump(&subdevice, maindevice, 0x00, 0xff))?;
@@ -387,7 +275,61 @@ pub fn get_identification_addresses(
})
}
async fn u16dump<'maindevice>(
subdevice: &'maindevice EthercrabSubDevicePreoperational<'maindevice>,
maindevice: &MainDevice<'maindevice>,
start_byte: u16,
end_byte: u16,
) -> Result<(), Error> {
let mut words: Vec<u16> = Vec::new();
for word in start_byte..end_byte {
words.push(subdevice.eeprom_read(maindevice, word).await?);
}
println!(
"EEPROM dump for {} from 0x{:04x} to 0x{:04x}",
subdevice.name(),
start_byte / 2,
end_byte / 2
);
u16print(start_byte, end_byte, words);
Ok(())
}
fn u16print(start_byte: u16, end_byte: u16, data: Vec<u16>) {
let table_start_word = start_byte & 0xfff0;
let table_end_word = (end_byte & 0xfff0_u16) + 0x10_u16;
let rows = (table_end_word - table_start_word) >> 4;
for row in 0..rows {
print!("0x{:04x} | ", (table_start_word + row * 0x10) / 2);
for word in 0..8 {
let word_address = row * 8 + word;
if word_address < start_byte {
print!(" ");
} else {
let i = (word_address - start_byte) as usize;
if i > data.len() - 1 {
print!(" ");
} else {
print!("{:04x} ", data[i]);
}
}
}
println!();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hexprint() {
let data = vec![0x0000, 0x1ced];
u16print(0x01, 0x40, data);
}
}
@@ -1,9 +1,10 @@
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
@@ -91,6 +92,9 @@ 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
@@ -103,7 +107,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 {
set_interface_managed(&interfaces[i].name, false);
continue;
} else {
set_interface_managed(&interfaces[i].name, true);
}
@@ -149,13 +153,13 @@ fn test_interface(interface: &str) -> Result<(), anyhow::Error> {
// Default 0ms
wait_loop_delay: Duration::from_millis(0),
// Default 100ms
mailbox_echo: Duration::from_millis(100),
mailbox_echo: Duration::from_millis(1000),
// Default 1000ms
mailbox_response: Duration::from_millis(1000),
mailbox_response: Duration::from_millis(2000),
},
MainDeviceConfig {
// Default 10000
dc_static_sync_iterations: 10000,
dc_static_sync_iterations: 0,
// Default None
retry_behaviour: ethercrab::RetryBehaviour::Count(3),
},
+1
View File
@@ -1 +1,2 @@
pub mod eeprom_identification;
pub mod interface_discovery;
-65
View File
@@ -1,65 +0,0 @@
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())
}
@@ -1,78 +0,0 @@
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
(200..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,
})
}
-26
View File
@@ -1,26 +0,0 @@
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
}
}
+6 -2
View File
@@ -2,15 +2,19 @@ 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;
+27
View File
@@ -0,0 +1,27 @@
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
}
}
+208
View File
@@ -0,0 +1,208 @@
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,
}
}
}
+113
View File
@@ -0,0 +1,113 @@
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,
}
+297
View File
@@ -0,0 +1,297 @@
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>,
}
+84
View File
@@ -0,0 +1,84 @@
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,
}
}
}
+23
View File
@@ -0,0 +1,23 @@
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;
}
+279
View File
@@ -0,0 +1,279 @@
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,);
}
}
+74
View File
@@ -0,0 +1,74 @@
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
View File
@@ -1,5 +1,4 @@
pub mod modbus_serial_interface;
pub mod tcp;
use anyhow::Error;
use crc::{CRC_16_MODBUS, Crc};
-263
View File
@@ -1,263 +0,0 @@
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)
}
}
+2 -2
View File
@@ -52,7 +52,7 @@ pub fn set_realtime_priority() -> Result<(), anyhow::Error> {
use anyhow::anyhow;
use libc::{SCHED_FIFO, pthread_setschedparam, sched_param};
use std::mem;
/*
unsafe {
// Get the pthread_t of the current thread using pthread_self()
let pthread_self = libc::pthread_self();
@@ -80,7 +80,7 @@ pub fn set_realtime_priority() -> Result<(), anyhow::Error> {
std::thread::current().name().unwrap_or("unknown")
);
}
}
}*/
Ok(())
}
+1
View File
@@ -0,0 +1 @@
pub mod mutation;
@@ -2,7 +2,7 @@ use std::fmt::Debug;
use serde::Serialize;
use machines::machine_identification::MachineIdentificationUnique;
use crate::machines::identification::MachineIdentificationUnique;
#[derive(Debug, Serialize, Clone)]
pub struct MutationResponse {
+2 -2
View File
@@ -1,10 +1,11 @@
/*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 {}
@@ -30,4 +31,3 @@ pub struct SerialDeviceIdentification {
pub vendor_id: u16,
pub product_id: u16,
}
*/
@@ -1,14 +1,8 @@
use lazy_static::lazy_static;
use super::SerialDeviceNewParams;
use crate::{
SerialDevice, SerialDeviceIdentification, machine_identification::DeviceIdentification,
serial::devices::laser::Laser,
machines::identification::DeviceIdentification,
serial::{SerialDevice, SerialDeviceIdentification},
};
#[cfg(feature = "mock-machine")]
use crate::serial::devices::mock::MockSerialDevice;
use crate::SerialDeviceNewParams;
use anyhow::{Error, Result};
use smol::lock::RwLock;
use std::{any::TypeId, collections::HashMap, sync::Arc};
@@ -100,22 +94,3 @@ impl SerialDeviceRegistry {
}
}
}
lazy_static! {
pub static ref SERIAL_DEVICE_REGISTRY: SerialDeviceRegistry = {
let mut sdr = SerialDeviceRegistry::new();
sdr.register::<Laser>(SerialDeviceIdentification {
vendor_id: 0x0403,
product_id: 0x6001,
});
// Register MockSerialDevice when mock-machine feature is enabled
#[cfg(feature = "mock-machine")]
sdr.register::<MockSerialDevice>(SerialDeviceIdentification {
vendor_id: 0x0001, // VENDOR_QITECH
product_id: 0x0007, // MACHINE_MOCK
});
sdr
};
}
+1
View File
@@ -1,2 +1,3 @@
pub mod event;
pub mod namespace;
pub mod namespace_id;
+2 -2
View File
@@ -4,7 +4,7 @@ use socketioxide::extract::SocketRef;
use std::{collections::HashMap, sync::Arc, time::Duration};
use tracing::instrument;
#[derive(Debug, Clone)]
#[derive(Debug)]
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();
}
}
@@ -3,7 +3,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::str::FromStr;
use machines::machine_identification::{MachineIdentification, MachineIdentificationUnique};
use crate::machines::identification::{MachineIdentification, MachineIdentificationUnique};
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub enum NamespaceId {
+1 -1
View File
@@ -1,4 +1,4 @@
use units::{
use uom::si::{
angle::radian,
angular_velocity::radian_per_second,
f64::{Angle, AngularVelocity, Length, Velocity},
@@ -0,0 +1,7 @@
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";
}
@@ -0,0 +1,7 @@
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";
}
@@ -0,0 +1,7 @@
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";
}
+7
View File
@@ -0,0 +1,7 @@
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";
}
+5
View File
@@ -0,0 +1,5 @@
pub mod acceleration;
pub mod angular_acceleration;
pub mod angular_jerk;
pub mod jerk;
pub mod velocity;
@@ -0,0 +1,7 @@
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";
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

-139
View File
@@ -1,139 +0,0 @@
# 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**
-9
View File
@@ -82,12 +82,3 @@ 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.
@@ -1,224 +0,0 @@
# 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
![](../assets/schematic.png)
---
### 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.
![](../assets/wiring.png)
---
#### 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
![](../assets/discovery.png)
Steps:
- Click Assign on the EK1100
- Select TestMachine V1
![](../assets/setmachine.png)
- Enter a serial number (use the same for EK1100 + EL2004)
![](../assets/serserial.png)
- Click Write
- Repeat for the EL2004
### 5.2 Testing LED Control
Navigate to:
Machines → TestMachine
![](../assets/machinedetected.png)
You will see this interface:
![](../assets/machinecontrol.png)
**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.
@@ -1,178 +0,0 @@
# 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
![](../assets/EL3021_schematic.png)
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.
![](../assets/wiring.png)
---
### 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:
![](../assets/EL3021_overview.png)
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**
![](../assets/EL3021_assign.png)
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**
![](../assets/EL3021_assign2.png)
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:
![](../assets/EL3021_control.png)
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)
Binary file not shown.
-69
View File
@@ -1,69 +0,0 @@
# Laser DRE Device Documentation
## Overview
The **Laser DRE** is a precision measurement device designed for real-time diameter measurement of rotating objects. It communicates over a **USB Serial interface** using the **Modbus RTU** protocol.
Key features include:
- High-speed diameter measurement
- Dual-axis capability
- USB Serial communication
- Modbus RTU protocol support
---
## Technical Specifications
- **Communication Interface:** USB Serial
- **Protocol:** Modbus RTU
- **Axes:** 2-axis measurement
- **Polling Rate:** Every 16 ms
- **Measurement Rate:** 320 measurements per second (manufacturer specification)
- **Measurement Type:** Diameter measurement | X/Y Axis width
---
## Operation
### Polling
The device **polls the diameter every 16 milliseconds**, ensuring near real-time data acquisition for precise monitoring.
### Measurement
According to the manufacturer, the Laser DRE can measure the diameter **320 times per second**, providing high-resolution data for process control and analysis.
### Axes
The device is capable of measuring across **two axes**, allowing for comprehensive profiling of the object’s diameter and shape.
---
## Communication Protocol
The device uses **Modbus RTU** over the USB Serial connection. Key points for communication:
- **Master/Slave Model:** The Laser DRE acts as a Modbus slave device.
- **Function Codes:** Supports standard Modbus RTU function codes for reading measurements.
- **Data Format:** Diameter measurements are transmitted in the format specified by the manufacturer (consult the device manual for exact register addresses and scaling).
---
## Settings
The device includes a small display that allows changing several operational settings:
- **Unit:**
The laser can operate in **millimeters (mm)** or **inches (in)**.
Our software uses **millimeters**.
- **Alarm:**
The laser can emit an audible alarm if the measured diameter is out of tolerance.
⚠️ **Note:** The tolerance settings on the laser may differ from the tolerance configured in our software.
- **Communication:**
Several communication parameters can be configured:
- **Set UART:** Configure **parity (8N1)** and **baud rate (38400)**.
- **Set Protocol:** Choose the communication protocol. *(We use **Modbus RTU**.)*
- **Set Slave Address:** The slave address of our laser is **1**.
- **Calibration:**
The laser can be calibrated using **4 mm** and **8 mm** reference rods.
+2 -2
View File
@@ -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. Information like the commit hash, branch, tag, timestamp etc. is passed to the nix build process as environment variables `QITECH_OS...`
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...`
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 -2
View File
@@ -73,7 +73,7 @@ typings/
# vuepress build output
.vuepress/dist
# Serverless directoriesProcessMetrics
# Serverless directories
.serverless/
# FuseBox cache
@@ -88,7 +88,6 @@ typings/
# Vite
.vite/
dist/
dist-electron/
# Electron-Forge
out/
+59
View File
@@ -0,0 +1,59 @@
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;
+35
View File
@@ -0,0 +1,35 @@
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];
}
}
+5155 -2038
View File
File diff suppressed because it is too large. Load diff
+16 -7
View File
@@ -6,8 +6,10 @@
"main": ".vite/build/main.js",
"private": true,
"scripts": {
"start": "vite",
"build": "vite build",
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "eslint .",
"lint:write": "eslint . --fix",
"format": "prettier --check .",
@@ -20,7 +22,16 @@
},
"author": "",
"devDependencies": {
"@electron/fuses": "^1.8.0",
"@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.8.0",
"@electron-forge/plugin-vite": "^7.6.1",
"@electron-forge/shared-types": "^7.4.0",
"@electron/fuses": "^2.0.0",
"@eslint/compat": "^1.2.6",
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.56.1",
@@ -44,19 +55,17 @@
"globals": "^16.2.0",
"jsdom": "^26.0.0",
"postcss": "^8.5.2",
"prettier": "^3.7.3",
"prettier": "^3.6.2",
"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": {
"@hookform/resolvers": "^5.2.2",
"@hookform/resolvers": "^4.1.2",
"@icons-pack/react-simple-icons": "^11.2.0",
"@jub3i/tree-kill": "^0.0.15",
"@radix-ui/react-collapsible": "^1.1.12",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

+1 -7
View File
@@ -9,7 +9,6 @@ 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();
@@ -22,12 +21,7 @@ export default function App() {
updateAppLanguage(i18n);
}, [i18n]);
return (
<>
<GlobalLaserToastManager />
<RouterProvider router={router} />
</>
);
return <RouterProvider router={router} />;
}
// enable immer MapSet plugin
@@ -720,7 +720,3 @@ The following persons must not work on the machine:
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 8](images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_8-008.png)
![QiTech Pro Extruder Electrical Diagram 2025 - Sheet 9](images/manuals/extruder/QiTech_Pro_Extruder_Electrical_Diagram_2025_9-009.png)
## Mechanical Diagrams
![QiTech Pro Extruder Nozzle 2025](images/manuals/extruder/Nozzle.png)
+1 -10
View File
@@ -145,17 +145,8 @@ 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: () => mainNamespaceStore, // use the exported store
createStore: createMainNamespaceStore,
createEventHandler: mainMessageHandler,
});
+23 -25
View File
@@ -8,7 +8,6 @@ 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
@@ -306,13 +305,27 @@ 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);
// Its hacky but i do not care, electron + react is annoying ...
window.location.reload();
// 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);
});
socket.on("event", (event: unknown) => {
@@ -321,9 +334,11 @@ 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) => {
@@ -341,25 +356,8 @@ const useSocketioStore = create<SocketioStore>()((set, get) => ({
}),
);
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();
}
// finally connect
socket.connect();
},
incrementNamespace: (namespaceId: NamespaceId) => {
const namespace_path = serializeNamespaceId(namespaceId);
@@ -384,11 +382,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
-17
View File
@@ -1,17 +0,0 @@
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");
}
+2 -52
View File
@@ -1,16 +1,10 @@
import { OutsideCorner } from "@/components/OutsideCorner";
import { useMachines } from "@/client/useMachines";
import { useOnSubpath } from "@/lib/useOnSubpath";
import {
Link,
Outlet,
useRouter,
useRouterState,
} from "@tanstack/react-router";
import { Fragment, useEffect } from "react";
import { Link, Outlet } from "@tanstack/react-router";
import { Fragment } from "react";
import React from "react";
import { Icon, IconName } from "./Icon";
import { useMainNamespace } from "@/client/mainNamespace";
type SidebarItemContent = {
link: string;
@@ -63,51 +57,7 @@ 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`,
@@ -1,137 +0,0 @@
import React from "react";
import { Icon } from "./Icon";
import { cn } from "@/lib/utils";
import type { UpdateStep } from "@/stores/updateStore";
interface UpdateProgressBarProps {
steps: UpdateStep[];
overallProgress: number;
className?: string;
}
export function UpdateProgressBar({
steps,
overallProgress,
className,
}: UpdateProgressBarProps) {
return (
<div className={cn("space-y-4", className)}>
{/* Overall Progress Bar */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Overall Progress</span>
<span className="text-sm font-semibold tabular-nums">
{overallProgress}%
</span>
</div>
<div className="relative h-2.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800">
<div
className="h-full rounded-full bg-gray-900 transition-all duration-500 ease-out dark:bg-gray-100"
style={{ width: `${overallProgress}%` }}
/>
</div>
</div>
{/* Steps List */}
<div className="space-y-2">
{steps.map((step) => (
<div
key={step.id}
className={cn(
"flex items-center gap-3 rounded border px-3 py-2.5 transition-all duration-200",
step.status === "in-progress" &&
"border-gray-400 bg-gray-50 dark:border-gray-600 dark:bg-gray-900",
step.status === "completed" &&
"border-gray-300 bg-white dark:border-gray-700 dark:bg-gray-950",
step.status === "error" &&
"border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950/30",
step.status === "pending" &&
"border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-950",
)}
>
{/* Status Icon */}
<div className="flex-shrink-0">
{step.status === "pending" && (
<div className="size-5 rounded-full border-2 border-gray-300 dark:border-gray-700" />
)}
{step.status === "in-progress" && (
<Icon
name="lu:Loader"
className="size-5 animate-spin text-gray-600 dark:text-gray-400"
/>
)}
{step.status === "completed" && (
<div className="flex size-5 items-center justify-center rounded-full bg-gray-700 dark:bg-gray-300">
<Icon
name="lu:Check"
className="size-3 text-white dark:text-gray-900"
/>
</div>
)}
{step.status === "error" && (
<div className="flex size-5 items-center justify-center rounded-full bg-red-600 dark:bg-red-500">
<Icon name="lu:X" className="size-3 text-white" />
</div>
)}
</div>
{/* Step Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm",
step.status === "completed" &&
"text-gray-600 dark:text-gray-400",
step.status === "error" &&
"font-medium text-red-700 dark:text-red-400",
step.status === "in-progress" &&
"font-medium text-gray-900 dark:text-gray-100",
step.status === "pending" &&
"text-gray-500 dark:text-gray-500",
)}
>
{step.label}
</span>
{/* Subsector Badge - minimal inline display */}
<span
className={cn("text-xs text-gray-400 dark:text-gray-600")}
>
(
{step.id === "rust-build"
? "rust & electron"
: step.subsector}
)
</span>
</div>
{/* Progress percentage for in-progress steps */}
{step.status === "in-progress" &&
step.progress !== undefined &&
step.progress > 0 && (
<span className="text-xs font-medium text-gray-600 tabular-nums dark:text-gray-400">
{Math.round(step.progress)}%
</span>
)}
</div>
{/* Progress bar for steps with detailed tracking */}
{step.status === "in-progress" &&
step.progress !== undefined &&
step.progress > 0 && (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-gray-700 transition-all duration-500 dark:bg-gray-300"
style={{ width: `${step.progress}%` }}
/>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
);
}
+1 -5
View File
@@ -63,11 +63,7 @@ export type DataSeries = SeriesData | SeriesData[];
export type BigGraphProps = {
newData: DataSeries;
unit?: Unit;
renderValue?: (
value: number,
seriesIndex?: number,
seriesTitle?: string,
) => string;
renderValue?: (value: number) => string;
config: GraphConfig;
graphId: string;
syncGraph?: PropGraphSync;
+1 -1
View File
@@ -109,7 +109,7 @@ export function EditValue({
type FormSchema = z.infer<typeof formSchema>;
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
resolver: zodResolver<FormSchema>(formSchema),
values: { value: value ?? defaultOrZero },
defaultValues: { value: defaultValue },
mode: "all",
-173
View File
@@ -1,173 +0,0 @@
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>
);
}
+6 -59
View File
@@ -25,29 +25,16 @@ 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";
}
@@ -75,42 +62,26 @@ 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
// const f = 3.1415;
// renderUnitSyntax(f.toFixed(2), "deg")
// result: "3.14°"
// value: 10.0
// -> valueString: "10" (custom input)
// this function will add prefix/suffix to the valueString but without unit symbol
// -> "10°"
export function renderUnitSyntax(
valueString: string | undefined,
unit: Unit | undefined,
@@ -150,32 +121,16 @@ 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 "";
}
@@ -192,19 +147,11 @@ 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];
@@ -2,4 +2,3 @@ export const UPDATE_EXECUTE = "update:execute";
export const UPDATE_LOG = "update:log";
export const UPDATE_END = "update:end";
export const UPDATE_CANCEL = "update:cancel";
export const UPDATE_STEP = "update:step";
@@ -3,7 +3,6 @@ import {
UPDATE_END,
UPDATE_EXECUTE,
UPDATE_LOG,
UPDATE_STEP,
} from "./update-channels";
type UpdateExecuteInvokeParams = {
@@ -17,20 +16,11 @@ type UpdateExecuteInvokeParams = {
};
};
type UpdateStepParams = {
stepId: string;
status: "pending" | "in-progress" | "completed" | "error";
progress?: number;
};
export function exposeUpdateContext() {
const { contextBridge, ipcRenderer } = window.require("electron");
let currentLogListener: ((event: any, log: string) => void) | null = null;
let currentEndListener: ((event: any, params: any) => void) | null = null;
let currentStepListener:
| ((event: any, params: UpdateStepParams) => void)
| null = null;
contextBridge.exposeInMainWorld("update", {
execute: (params: UpdateExecuteInvokeParams) =>
@@ -62,17 +52,5 @@ export function exposeUpdateContext() {
ipcRenderer.on(UPDATE_END, currentEndListener);
},
onStep: (callback: (params: UpdateStepParams) => void) => {
if (currentStepListener) {
ipcRenderer.removeListener(UPDATE_STEP, currentStepListener);
}
currentStepListener = (_event, params) => {
callback(params);
};
ipcRenderer.on(UPDATE_STEP, currentStepListener);
},
});
}
@@ -4,7 +4,6 @@ import {
UPDATE_END,
UPDATE_EXECUTE,
UPDATE_LOG,
UPDATE_STEP,
} from "./update-channels";
import { spawn, ChildProcess } from "child_process";
import tkill from "@jub3i/tree-kill";
@@ -117,13 +116,6 @@ async function update(
commit,
});
// Reset Rust build progress tracking
rustBuildProgress = {
totalDerivations: 0,
builtDerivations: 0,
maxPercent: 0,
};
const qitechControlEnv = process.env.QITECH_CONTROL_ENV;
const homeDir =
qitechControlEnv === "control-os" ? "/home/qitech" : process.env.HOME;
@@ -137,8 +129,6 @@ async function update(
// 1. first make sure the clone path is empty by deleting it if it containsa .git folder
const repoDir = `${homeDir}/${githubRepoName}`;
// Clear repo (not tracked in progress UI)
const clearResult = await clearRepoDirectory(
`${homeDir}/${githubRepoName}`,
event,
@@ -149,10 +139,6 @@ async function update(
}
// 2. clone the repository
event.sender.send(UPDATE_STEP, {
stepId: "clone-repo",
status: "in-progress",
});
const cloneResult = await cloneRepository(
{
githubRepoOwner,
@@ -165,19 +151,11 @@ async function update(
event,
);
if (!cloneResult.success) {
event.sender.send(UPDATE_STEP, {
stepId: "clone-repo",
status: "error",
});
event.sender.send(UPDATE_LOG, cloneResult.error);
return;
}
event.sender.send(UPDATE_STEP, {
stepId: "clone-repo",
status: "completed",
});
// 3. make the nixos-install.sh script executable (not tracked in progress UI)
// 3. make the nixos-install.sh script executable
const chmodResult = await runCommand(
"chmod",
["+x", "nixos-install.sh"],
@@ -190,35 +168,17 @@ async function update(
}
// 4. run the nixos-install.sh script
// This script will handle rust-build, electron-build, and system-install
// Start with rust-build (cargo builds)
event.sender.send(UPDATE_STEP, {
stepId: "rust-build",
status: "in-progress",
});
const installResult = await runCommandWithStepTracking(
const installResult = await runCommand(
"./nixos-install.sh",
[],
repoDir,
event,
);
if (!installResult.success) {
// Mark current and remaining steps as error
event.sender.send(UPDATE_STEP, {
stepId: "rust-build",
status: "error",
});
event.sender.send(UPDATE_STEP, {
stepId: "system-install",
status: "error",
});
event.sender.send(UPDATE_LOG, installResult.error);
return;
}
// Success - steps are already marked as completed by runCommandWithStepTracking
resolve();
} catch (error: any) {
reject(error);
@@ -284,7 +244,7 @@ async function cloneRepository(
: `https://github.com/${githubRepoOwner}/${githubRepoName}.git`;
// Determine clone arguments based on whether tag, branch, or commit is specified
const cloneArgs = ["clone", "--progress", repoUrl];
const cloneArgs = ["clone", repoUrl];
if (tag) {
// Clone a specific tag
@@ -373,11 +333,6 @@ async function runCommand(
const log = data.toString();
console.error(log);
event.sender.send(UPDATE_LOG, log);
// Git outputs progress to stderr
if (cmd === "git" && args.includes("--progress")) {
parseGitProgress(log, event);
}
});
// Handle process completion
@@ -431,249 +386,6 @@ async function runCommand(
}
}
// Parse Git clone progress
function parseGitProgress(
log: string,
event: Electron.IpcMainInvokeEvent,
): void {
// Git progress format: "Receiving objects: 45% (234/520)"
// or "Resolving deltas: 100% (150/150)"
const receivingMatch = log.match(/Receiving objects:\s*(\d+)%/);
const resolvingMatch = log.match(/Resolving deltas:\s*(\d+)%/);
if (receivingMatch) {
const percent = parseInt(receivingMatch[1], 10);
event.sender.send(UPDATE_STEP, {
stepId: "clone-repo",
status: "in-progress",
progress: Math.floor(percent * 0.8), // Receiving is 80% of clone
});
} else if (resolvingMatch) {
const percent = parseInt(resolvingMatch[1], 10);
event.sender.send(UPDATE_STEP, {
stepId: "clone-repo",
status: "in-progress",
progress: Math.floor(80 + percent * 0.2), // Resolving is last 20%
});
}
}
// Track Rust build progress
let rustBuildProgress = {
totalDerivations: 0,
builtDerivations: 0,
maxPercent: 0, // Track max to prevent backward movement
};
// Parse Rust build output for progress
function parseRustBuildOutput(
log: string,
event: Electron.IpcMainInvokeEvent,
): void {
// Track derivations to build
const derivationsMatch = log.match(/these (\d+) derivations? will be built/i);
if (derivationsMatch) {
rustBuildProgress.totalDerivations = parseInt(derivationsMatch[1], 10);
rustBuildProgress.builtDerivations = 0;
rustBuildProgress.maxPercent = 0;
event.sender.send(UPDATE_STEP, {
stepId: "rust-build",
status: "in-progress",
progress: 0,
});
return;
}
// Track building packages
if (
log.includes("building '/nix/store/") ||
log.includes("building /nix/store/")
) {
rustBuildProgress.builtDerivations++;
// Check if this is the server-deps package (one of the last builds)
const isServerDeps = log.includes("-server-deps");
let percent = 15;
if (isServerDeps) {
// server-deps indicates we're at 85%
percent = 85;
} else if (rustBuildProgress.totalDerivations > 0) {
const derivationProgress =
rustBuildProgress.builtDerivations / rustBuildProgress.totalDerivations;
percent = 15 + Math.floor(derivationProgress * 70); // Map to 15-85%
}
// Only move forward
percent = Math.max(percent, rustBuildProgress.maxPercent);
rustBuildProgress.maxPercent = percent;
event.sender.send(UPDATE_STEP, {
stepId: "rust-build",
status: "in-progress",
progress: percent,
});
}
// Track installing phase - go up to 90%
if (log.includes("installing") || log.includes("Installing")) {
const percent = Math.max(90, rustBuildProgress.maxPercent);
rustBuildProgress.maxPercent = percent;
event.sender.send(UPDATE_STEP, {
stepId: "rust-build",
status: "in-progress",
progress: percent,
});
}
}
// Enhanced version of runCommand that tracks build steps based on log output
async function runCommandWithStepTracking(
cmd: string,
args: string[],
workingDir: string,
event: Electron.IpcMainInvokeEvent,
): Promise<{ success: boolean; error?: string }> {
try {
const completeCommand = `${cmd} ${args.join(" ")}`;
const workingDirText = terminalGray(workingDir);
event.sender.send(
UPDATE_LOG,
`🚀 ${workingDirText} ${terminalColor("blue", completeCommand)}`,
);
const childProcess = spawn(cmd, args, {
cwd: workingDir,
});
// Store reference to current process for cancellation
currentUpdateProcess = childProcess;
// Track which steps have been marked as in-progress
// Note: rust-build is already marked as in-progress before calling this function
let systemInstallStarted = false;
// Function to process log output and update steps
const processLogForSteps = (log: string) => {
const logLower = log.toLowerCase();
// Parse Rust build progress
parseRustBuildOutput(log, event);
// Check for system install indicators
// System install happens after Rust build completes (at ~90%)
// Look for bootloader/activation messages that indicate final system installation
if (
!systemInstallStarted &&
rustBuildProgress.maxPercent >= 90 &&
(logLower.includes("updating grub") ||
logLower.includes("installing bootloader") ||
logLower.includes("updating bootloader") ||
logLower.includes("activating the configuration") ||
logLower.includes("building the system configuration") ||
logLower.includes("these 0 derivations"))
) {
console.log("🟢 Detected system install start:", log.substring(0, 100));
// Mark rust as complete, start system install
event.sender.send(UPDATE_STEP, {
stepId: "rust-build",
status: "completed",
});
event.sender.send(UPDATE_STEP, {
stepId: "system-install",
status: "in-progress",
});
systemInstallStarted = true;
}
};
// Stream stdout logs back to renderer
childProcess.stdout.on("data", (data) => {
const log = data.toString();
console.log(log);
event.sender.send(UPDATE_LOG, log);
processLogForSteps(log);
});
// Stream stderr logs back to renderer
childProcess.stderr.on("data", (data) => {
const log = data.toString();
console.error(log);
event.sender.send(UPDATE_LOG, log);
processLogForSteps(log);
});
// Handle process completion
return new Promise((resolve, reject) => {
childProcess.on("close", (code, signal) => {
// Clear process reference when completed
if (currentUpdateProcess === childProcess) {
currentUpdateProcess = null;
}
if (signal === "SIGTERM" || signal === "SIGKILL") {
event.sender.send(UPDATE_LOG, terminalInfo("Command was cancelled"));
reject({
success: false,
error: "Command was cancelled",
});
} else if (code === 0) {
// Mark all remaining steps as completed on success
if (!systemInstallStarted) {
event.sender.send(UPDATE_STEP, {
stepId: "rust-build",
status: "completed",
});
event.sender.send(UPDATE_STEP, {
stepId: "system-install",
status: "completed",
});
} else {
event.sender.send(UPDATE_STEP, {
stepId: "system-install",
status: "completed",
});
}
event.sender.send(
UPDATE_LOG,
terminalSuccess("Command completed successfully"),
);
resolve({ success: true, error: undefined });
} else {
event.sender.send(
UPDATE_LOG,
terminalError(`Command failed with code ${code}`),
);
reject({
success: false,
error: terminalError(code?.toString() ?? "NO_CODE"),
});
}
});
childProcess.on("error", (err) => {
// Clear process reference on error
if (currentUpdateProcess === childProcess) {
currentUpdateProcess = null;
}
event.sender.send(
UPDATE_LOG,
terminalError(`Command error: ${err.message}`),
);
reject({
success: false,
error: terminalError(err.message),
});
});
});
} catch (error: any) {
event.sender.send(UPDATE_LOG, terminalError(`Error: ${error.toString()}`));
return { success: false, error: error.toString() };
}
}
function terminalColor(
color: "blue" | "green" | "red" | "cyan" | "gray",
text: string,
-25
View File
@@ -2,13 +2,6 @@ import type { UpdateInfo } from "@/stores/updateStore";
import { useUpdateStore } from "@/stores/updateStore";
let currentLogListener: ((line: string) => void) | null = null;
let currentStepListener:
| ((params: {
stepId: string;
status: "pending" | "in-progress" | "completed" | "error";
progress?: number;
}) => void)
| null = null;
export async function updateExecute(
source: UpdateInfo,
@@ -21,32 +14,15 @@ export async function updateExecute(
currentLogListener = null;
}
if (currentStepListener) {
window.update.onStep(() => {}); // clear previous
currentStepListener = null;
}
currentLogListener = onLog;
window.update.onLog(currentLogListener);
// Set up step listener
currentStepListener = (params) => {
const { setStepStatus, setStepProgress } = useUpdateStore.getState();
setStepStatus(params.stepId, params.status);
if (params.progress !== undefined) {
setStepProgress(params.stepId, params.progress);
}
};
window.update.onStep(currentStepListener);
window.update.execute(source);
window.update.onEnd((params) => {
window.update.onLog(() => {}); // remove listener
window.update.onEnd(() => {});
window.update.onStep(() => {}); // remove step listener
currentLogListener = null;
currentStepListener = null;
resolve(params);
});
});
@@ -79,7 +55,6 @@ export async function updateCancel(): Promise<{
const result = await window.update.cancel();
window.update.onLog(() => {}); // remove listener
window.update.onEnd(() => {}); // remove listener
window.update.onStep(() => {}); // remove step listener
return result;
}
Loaded 100 of 353 files, more files were not shown because too many files have changed in this diff. Show more