Compare commits

..
Author SHA1 Message Date
just-some-entity cae322f312 set correct page for config 2026-01-30 16:13:12 +00:00
just-some-entity 7eeb18af94 copy from broken branch 2026-01-30 16:00:09 +00:00
104 changed files with 1719 additions and 8577 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
.png filter=lfs diff=lfs merge=lfs -text
.jpg filter=lfs diff=lfs merge=lfs -text
.jpeg filter=lfs diff=lfs merge=lfs -text
-40
View File
@@ -1,40 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: 'BUG: '
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Navigate to '...'
2. Select/Press '....'
3. Execute operation '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots/Photos**
If applicable, add screenshots from the HMI display or photos of the machine screen to help explain your problem.
**Machine Information (please complete the following):**
- Machine Type: [Winder2, Laser, Aquapath...]
- Control Software Version: [e.g. 2.4.1]
- Settings: [e.g. Different Gear Ratios, PID Settings, …]
**Device Information (if issue occurs on external device):**
- Device Type: [e.g. Tablet, PLC, Remote Control Panel]
- OS: [e.g. Windows 10, Linux, etc.]
- Connection Type: [e.g. Ethernet, USB]
**Error Messages/Codes**
If applicable, provide exact error codes or messages displayed or found in the troubleshooting terminal.
**Additional context**
Add any other context about the problem here (e.g., recent software updates, environmental factors).
-33
View File
@@ -1,33 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: 'Feat: '
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. During operation, I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions, workarounds, or features you've considered.
**Use Case/Application:**
- Machine Type(s): [e.g. All machines, Winder, Aquapath, ...]
- Frequency of Use: [e.g. Daily, During setup, Occasionally]
**Expected Benefits:**
- Efficiency Improvement: [e.g. Reduces setup time by X minutes, Eliminates manual steps]
- Safety Enhancement: [e.g. Prevents operator error, Adds safety checks]
- Production Impact: [e.g. Increases throughput, Reduces downtime, Improves quality]
- User Experience: [e.g. Simplifies workflow, Reduces training time]
**Mockups/Sketches**
If applicable, add sketches, mockups, or photos showing how the feature would appear on the HMI display or integrate with the machine.
**Additional context**
Add any other context, industry standards, or examples from other control systems about the feature request here.
-80
View File
@@ -1,80 +0,0 @@
name: Update npm deps hash
on:
pull_request:
paths:
- 'electron/package-lock.json'
- 'electron/package.json'
workflow_dispatch:
env:
NIX_PATH: nixpkgs=channel:nixos-25.05
permissions:
contents: write
pull-requests: write
actions: write
jobs:
update-hash:
runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Nix
uses: nixbuild/nix-quick-install-action@v34
with:
nix_conf: |
keep-env-derivations = true
keep-outputs = true
- name: Calculate new npm deps hash
id: calc-hash
run: |
# Calculate the hash using nix run with nixpkgs
NEW_HASH=$(nix run 'nixpkgs#prefetch-npm-deps' electron/package-lock.json)
echo "hash=$NEW_HASH" >> $GITHUB_OUTPUT
echo "Calculated hash: $NEW_HASH"
- name: Update npmDepsHash in electron.nix
run: |
NEW_HASH="${{ steps.calc-hash.outputs.hash }}"
sed -i "s|npmDepsHash = \".*\";|npmDepsHash = \"$NEW_HASH\";|" nixos/packages/electron.nix
- name: Check if hash changed
id: check-changes
run: |
if git diff --quiet nixos/packages/electron.nix; then
echo "changed=false" >> $GITHUB_OUTPUT
echo "No changes needed - hash is already correct"
else
echo "changed=true" >> $GITHUB_OUTPUT
echo "Hash updated in electron.nix"
fi
- name: Commit and push if changed
if: steps.check-changes.outputs.changed == 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add nixos/packages/electron.nix
git commit -m "chore: update npmDepsHash to ${{ steps.calc-hash.outputs.hash }}"
git push
- name: Comment on PR
if: steps.check-changes.outputs.changed == 'true'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Automatically updated `npmDepsHash` to `${{ steps.calc-hash.outputs.hash }}`\n\n**Next step:** Trigger the checks by pushing an empty commit:\n```bash\ngit commit --allow-empty -m "trigger ci" && git push\n```'
})
View File
Whitespace-only changes.
Generated
+40 -50
View File
@@ -204,7 +204,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -221,7 +221,7 @@ checksum = "99e1aca718ea7b89985790c94aad72d77533063fe00bc497bb79a7c2dae6a661"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -232,9 +232,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.8.8"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425"
dependencies = [
"axum-core",
"axum-macros",
@@ -291,7 +291,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -390,9 +390,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
dependencies = [
"serde",
]
@@ -539,7 +539,7 @@ version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -682,7 +682,7 @@ dependencies = [
"deluxe-macros",
"once_cell",
"proc-macro2",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -695,7 +695,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -710,7 +710,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -721,7 +721,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -962,13 +962,14 @@ dependencies = [
"deluxe",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
name = "ethercrab"
version = "0.6.0"
source = "git+https://github.com/v-morlock/ethercrab?branch=main#d297d13389d85a4edb4e69cc98f9dd7f12626cac"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbb13becfa15713adb09b27fdb74468b827cdc78aa99aea638a89c1b0db3ce94"
dependencies = [
"async-io",
"atomic-waker",
@@ -997,7 +998,8 @@ dependencies = [
[[package]]
name = "ethercrab-wire"
version = "0.2.0"
source = "git+https://github.com/v-morlock/ethercrab?branch=main#d297d13389d85a4edb4e69cc98f9dd7f12626cac"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d15828cb020fb44c92ca67e1ee3c122c9621c2e00d383d066e488f0619023ba2"
dependencies = [
"ethercrab-wire-derive",
"heapless",
@@ -1006,19 +1008,20 @@ dependencies = [
[[package]]
name = "ethercrab-wire-derive"
version = "0.2.0"
source = "git+https://github.com/v-morlock/ethercrab?branch=main#d297d13389d85a4edb4e69cc98f9dd7f12626cac"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e790b8011211ed70babfcacbd4277b91ee21402ad5ba6a2c293db02e2f12919"
dependencies = [
"criterion",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
name = "euclid"
version = "0.22.13"
version = "0.22.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48"
dependencies = [
"num-traits",
]
@@ -1875,7 +1878,7 @@ dependencies = [
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -1905,7 +1908,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -2218,7 +2221,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -2248,20 +2251,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@@ -2329,7 +2332,6 @@ dependencies = [
"smol",
"socketioxide",
"spin_sleep",
"ta",
"textplots",
"tokio",
"tower-http",
@@ -2571,9 +2573,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.114"
version = "2.0.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
dependencies = [
"proc-macro2",
"quote",
@@ -2586,12 +2588,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "ta"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
[[package]]
name = "tap"
version = "1.0.1"
@@ -2625,7 +2621,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -2685,7 +2681,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -2847,7 +2843,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -2884,9 +2880,9 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
dependencies = [
"chrono",
"matchers",
@@ -3078,7 +3074,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
"wasm-bindgen-shared",
]
@@ -3143,7 +3139,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -3154,7 +3150,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
@@ -3430,11 +3426,5 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.111",
]
[[package]]
name = "zmij"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
+84 -51
View File
@@ -2,76 +2,109 @@
# QiTech Control
QiTech Control is an open-source framework designed to bring modern software development practices to certified industrial hardware.
QiTech Control is an open-source framework designed to bring modern software paradigms to certified industrial hardware.
It frees developers from proprietary, license-heavy PLC ecosystems and rigid "point-and-click" workflows that are no longer adequate for today's complex automation challenges.
QiTech Control combines the modularity and reliability of standard EtherCAT terminals (e.g., WAGO, Beckhoff) with the power of a modern Rust & React stack.
## Documentation
**[View Full Documentation Wiki](https://github.com/qitechgmbh/control/wiki)**
[![](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.*
- [Getting Started](https://github.com/qitechgmbh/control/wiki/Getting-Started)
- [Architecture Overview](https://github.com/qitechgmbh/control/wiki/Architecture-Overview)
- [EtherCAT Basics](https://github.com/qitechgmbh/control/wiki/Ethercat-Basics)
- [Device Examples](https://github.com/qitechgmbh/control/wiki/Hardware-Examples)
- [REST API Reference](https://github.com/qitechgmbh/control/wiki/Rest-Api)
[![](https://img.youtube.com/vi/55egCAkQgyM/maxresdefault.jpg)](https://youtu.be/55egCAkQgyM)
*Click here to watch a full explainer Video of our Software.*
## Videos
# Repo Structure
| Software Demo | Full Explainer |
|---------------|----------------|
| [![](https://img.youtube.com/vi/KI3YeBwfV-s/maxresdefault.jpg)](https://www.youtube.com/watch?v=KI3YeBwfV-s)<br/>*Watch a demo of the control software in action* | [![](https://img.youtube.com/vi/55egCAkQgyM/maxresdefault.jpg)](https://youtu.be/55egCAkQgyM)<br/>*Watch a complete overview of QiTech Control* |
**Frontend**
## Repository Structure
- `/electron`: Frontend code for the control software built with React and Electron.
- **`/electron`** - React + Electron frontend
- **`/server`** - Rust backend implementing machine logic
- **`/ethercat-hal`** - Hardware abstraction layer for EtherCAT devices
- **`/control-core`** - Core control logic
- **`/nixos`** - Custom Linux OS with realtime kernel
**Backend**
## Technology Stack
- `/server`: Glue between Beckhoff and Electron. Implements machine logic.
- `/ethercat-hal`: Hardware abstraction layer for Beckhoff (and possibly other EtherCat) devices and protocols.
- `/ethercat-hal-derive`: Macros for `ethercat-hal`
- `/control-core`: Core control logic for the server.
**Backend:** Rust with [Ethercrab](https://github.com/ethercrab-rs/ethercrab) for EtherCAT, [SocketIO](https://socket.io/) for real-time communication, [axum](https://docs.rs/axum/latest/axum/) for REST API
**Operating System**
**Frontend:** [Electron](https://www.electronjs.org/) + [React](https://react.dev/) with [Shadcn](https://ui.shadcn.com/) components and [Tailwind](https://tailwindcss.com/) styling
- `/nixos`: Custom Linux with realtime kernel & preconfigured for touchscreens.
**Other**
- `/docs`: Documentation for the project.
# Technology Choices
## Backend
To interface with Beckhoff and other EtherCAT devices we need an EtherCAT master software. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
[SocketIO](https://socket.io/) was chosen for performant event driven communication from the backend to the server. But we still use REST with [axum](https://docs.rs/axum/latest/axum/) for the communication thet benefits from the request/response model.
We use [Smol](https://github.com/smol-rs/smol) for EtherCAT IO in the control loop for it's performance and [Tokio](https://tokio.rs/) for server IO because of it's ecosystem and maturity.
## Frontend
We could combine the code of the frontend and backend using [Doxius](https://dioxuslabs.com/) but it lacks good Linux support. We chose [Electron](https://www.electronjs.org/) with [React](https://react.dev/) for it's maturity and ecosystem. For the UI we use [Shadcn](https://ui.shadcn.com/) components and [Tailwind](https://tailwindcss.com/) for styling. For routing we use [TanStack Router](https://tanstack.com/router/v1).
# Dev Setup
[Developer Documentation](./docs/developer-docs/)
[API Documentation](https://github.com/qitechgmbh/control/blob/master/docs/rest-api.md)
-> Backend
- Rust stable 1.86^ toolchain (install via [rustup](https://rustup.rs/))
- `rust-analyzer` extension for VSCode
- Set your interface in `server/src/ethercat/init.rs` like `en10`
- Connect a Beckhoff EK1100 to your interface
- run `cd server && cargo run` to start the server (localhost:3001)
-> Frontend
- nodejs and npm installed
- run `cd electron && npm i && npm run start` to start the frontend
## Hardware Examples
## Minimal Hardware Examples
QiTech Control supports a wide range of EtherCAT hardware from WAGO and Beckhoff. We provide complete step-by-step tutorials with wiring diagrams and software configuration for many common modules.
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).
**[View all hardware examples and tutorials on the wiki](https://github.com/qitechgmbh/control/wiki/Hardware-Examples)**
| Example | Vendor | Hardware | Description | How-To Video | Docs |
| :----------------- | :------- | :------------ | :-------------------------------------- | :----------- | :---------------------------- |
| **Digital Input** | WAGO | 750-402 | 4-Channel Digital Input | [ ] | - |
| **Digital Output** | WAGO | 750-753 | 8-Channel Digital Output | [ ] | - |
| **Analog Input** | WAGO | 750-455 | 4-Channel Analog Input | [ ] | - |
| **Analog Output** | WAGO | 750-553 | 4-Channel Analog Output | [ ] | - |
| **Serial Comms** | WAGO | 750-652 | RS-485 Modbus Module | [ ] | - |
| **Stepper Drive** | WAGO | 750-67x | Stepper Controller | [ ] | - |
| **Power Supply** | WAGO | 2787-214 | Power Supply Unit | [ ] | - |
| **LED Control** | Beckhoff | EL2004 | Digital output control (simplest setup) | [ ] | [](./docs/hardware/el2004.md) |
| **Analog Input** | Beckhoff | EL3021 | Reading analog current measurements | [ ] | [](./docs/hardware/el3021.md) |
Quick links to popular examples:
- [EL2004 - LED Control](https://github.com/qitechgmbh/control/wiki/Minimal-Example-El2004) (simplest setup for beginners)
- [EL3021 - Analog Input](https://github.com/qitechgmbh/control/wiki/Minimal-Example-El3021)
- [EL7031 - Stepper Motor Control](https://github.com/qitechgmbh/control/wiki/Minimal-Example-El7031-Motor)
- [WAGO 750-402 - Digital Input](https://github.com/qitechgmbh/control/wiki/Minimal-Example-Wago750-402)
- [WAGO 750-455 - Analog Input](https://github.com/qitechgmbh/control/wiki/Minimal-Example-Wago-750-455)
# QiTech Machines
## QiTech Machines
| Machine Name | Description | Machine ID | Implemented | Video | Docs |
| :---------------- | :-------------------------- | :--------- | :---------- | :-------------------------------------------------------------- | :------------------------------ |
| Winder V1 | Winding Filaments & Similar | 1 | Legacy | [Video](https://youtu.be/4aE4cFhioKA?si=Xdj_LVnFrAYWnLm6) | - |
| Winder V2 | Winding Filaments & Similar | 2 | Yes | [Video](https://youtu.be/f2kzh6kpQWE?si=HEYcLOaC9gWSp2Wo) | [](./docs/machines/winder-1.md) |
| XL Winder V1 | Large Scale Winder | 3 | Yes | [Video](https://youtu.be/ynI6ioWIQQY?si=4EDVKXbgIqrvGGtY) | - |
| Buffer V1 | Filament buffering system | 4 | In Progress | [Video](https://youtu.be/VR5mEdZDPA0?si=eip7dQXU4zaXK7Ev) | - |
| Extruder V1 | Single Screw Extruder | 5 | Legacy | [Video](https://youtu.be/gchoG-yGczI?si=bEM4daf5eOVaU5_2) | - |
| Extruder V2 | Single Screw Extruder | 6 | Yes | [Video](https://youtu.be/mexRYGDNWa4?si=h7JBz_XKMwKLPenG) | |
| Waterway V1 | Filament Water Cooling | 7 | In Progress | [Video](https://youtu.be/_T5z1J8bl_k?si=uOC1hIQ1EAP0cHIF) | |
| Laser V1 | Diameter Measuring Laser | 8 | Yes | [Video](https://youtu.be/WDM34lj4afM?si=MZzUKkHCrzH4P0aA) | |
| 2-Axis-Laser V1 | Dual Axis Laser | 9 | Yes | [Video](https://youtu.be/WDM34lj4afM?si=MZzUKkHCrzH4P0aA) | - |
| Mock | Mock Machine for Testing | 10 | Yes | | - |
| Extruder V3 | Single Screw Extruder | 11 | Yes | [Video](https://youtu.be/ipHHuPzCvn4?si=hkZ1b93rVuwDxhFD) | |
| Mini Schredder V1 | mini plastic crusher | 12 | Legacy | [Video](https://youtu.be/m8NplNqdu2Q?si=x6zjDktJtpbSMSmu) | - |
| Pro Schredder V1 | large plastic crusher | 13 | Legacy | [Video](https://youtu.be/pSaVMqp06pU?si=y61enutRpxKscBm4) | - |
| Dryer V1 | polymer dryer | 14 | Reserved | [Video](https://youtu.be/6hdmUUAdZp0?si=eKOW1WlgkqTpnfdS) | - |
| Pelletizier V1 | Filament chopper | 15 | Reserved | | - |
QiTech Control powers 10+ production machines for filament extrusion, winding, measurement, and material processing.
# Current Restructuring Efforts
**[View complete machine catalog on the wiki](https://github.com/qitechgmbh/control/wiki/QiTech-Machines)**
## Contributing
This is an open-source project. Contributions are welcome! Please see the [wiki](https://github.com/qitechgmbh/control/wiki) for development guidelines.
## Additional Videos
| Episode Highlights | |
|--------------------|--------------------|
| [![](https://img.youtube.com/vi/U2wNN6sF4to/maxresdefault.jpg)](https://www.youtube.com/watch?v=U2wNN6sF4to&t=163s)<br/>**Day in the Software Team**<br/>*Behind the scenes with Robin and how the team ships control software.* | [![](https://img.youtube.com/vi/R5WGAY3WRWA/maxresdefault.jpg)](https://www.youtube.com/watch?v=R5WGAY3WRWA&t=1s)<br/>**Software Stack Day**<br/>*A fast tour through the tools, stack, and daily workflow.* |
| [![](https://img.youtube.com/vi/xyaHwxXguw4/maxresdefault.jpg)](https://www.youtube.com/watch?v=xyaHwxXguw4&t=32s)<br/>**First Client**<br/>*A milestone week building the first real client project.* | [![](https://img.youtube.com/vi/moI7z2PVdF4/maxresdefault.jpg)](https://www.youtube.com/watch?v=moI7z2PVdF4)<br/>**Steelworks Week**<br/>*A deep-dive week on a demanding steelworks order.* |
## License
See [LICENSE](LICENSE) file for details.
[![](https://img.youtube.com/vi/UlbnSVIhfLI/maxresdefault.jpg)](https://www.youtube.com/live/UlbnSVIhfLI?si=ZTotC5B7gd87tUim)
*Video: Live stream of software dev meeting*
+2 -2
View File
@@ -9,11 +9,11 @@ workspace = true
[dependencies]
anyhow = "1.0.100"
axum = "0.8.8"
axum = "0.8.6"
ethercat_hal = { path = "../ethercat-hal" }
units = { path = "../units" }
ethercrab = { git = "https://github.com/v-morlock/ethercrab", branch = "main" }
ethercrab = "0.6"
interfaces = "0.0.9"
serde = "1.0.219"
serialport = "4.7.3"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

+241 -130
View File
@@ -1,12 +1,12 @@
{
"name": "qitech-control-electron",
"version": "2.4.1",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qitech-control-electron",
"version": "2.4.1",
"version": "1.0.0",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@icons-pack/react-simple-icons": "^11.2.0",
@@ -40,12 +40,12 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
"react-i18next": "^16.5.4",
"react-i18next": "^15.4.1",
"react-markdown": "^10.1.0",
"react-virtualized": "^9.22.6",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.3",
"socket.io-client": "^4.8.1",
"socket.io-msgpack-parser": "^3.0.2",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.1",
@@ -54,7 +54,7 @@
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^4.3.5",
"zod-validation-error": "^5.0.0",
"zustand": "^5.0.11"
"zustand": "^5.0.8"
},
"devDependencies": {
"@electron/fuses": "^1.8.0",
@@ -73,7 +73,7 @@
"@types/react-virtualized": "^9.22.2",
"electron": "^37.3.1",
"electron-playwright-helpers": "^1.7.1",
"eslint": "^9.39.2",
"eslint": "^9.20.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.4",
@@ -90,7 +90,7 @@
"vite": "^6.4.1",
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6",
"vitest": "^4.0.18"
"vitest": "^3.0.6"
}
},
"node_modules/@adobe/css-tools": {
@@ -1305,9 +1305,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2889,13 +2889,6 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -4123,40 +4116,39 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
@@ -4168,41 +4160,42 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
@@ -4210,24 +4203,28 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
"@vitest/pretty-format": "3.2.4",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -4642,6 +4639,16 @@
"node": "*"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cacheable-lookup": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
@@ -4762,11 +4769,18 @@
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
@@ -4828,6 +4842,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/check-error": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -5110,6 +5134,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -5778,9 +5812,9 @@
}
},
"node_modules/eslint": {
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -5791,7 +5825,7 @@
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.2",
"@eslint/js": "9.39.1",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@@ -8035,6 +8069,13 @@
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@@ -9191,17 +9232,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -9390,6 +9420,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -9746,17 +9786,16 @@
}
},
"node_modules/react-i18next": {
"version": "16.5.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz",
"integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==",
"version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"i18next": ">= 23.4.0",
"react": ">= 16.8.0",
"typescript": "^5"
},
@@ -10470,13 +10509,13 @@
"license": "ISC"
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
@@ -10484,6 +10523,23 @@
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-msgpack-parser": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/socket.io-msgpack-parser/-/socket.io-msgpack-parser-3.0.2.tgz",
@@ -10739,6 +10795,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -10879,14 +10955,11 @@
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
@@ -10934,10 +11007,30 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11577,6 +11670,29 @@
}
}
},
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-plugin-electron": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.29.0.tgz",
@@ -11645,50 +11761,51 @@
}
},
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"happy-dom": "*",
"jsdom": "*"
},
@@ -11696,19 +11813,13 @@
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"@types/debug": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
@@ -12094,9 +12205,9 @@
}
},
"node_modules/zustand": {
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
+7 -7
View File
@@ -1,8 +1,8 @@
{
"name": "qitech-control-electron",
"productName": "QiTech Control",
"version": "2.4.1",
"description": "Next Generation Machine Interface ",
"version": "1.0.0",
"description": "",
"main": ".vite/build/main.js",
"private": true,
"scripts": {
@@ -36,7 +36,7 @@
"@types/react-virtualized": "^9.22.2",
"electron": "^37.3.1",
"electron-playwright-helpers": "^1.7.1",
"eslint": "^9.39.2",
"eslint": "^9.20.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.4",
@@ -53,7 +53,7 @@
"vite": "^6.4.1",
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6",
"vitest": "^4.0.18"
"vitest": "^3.0.6"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@@ -88,12 +88,12 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
"react-i18next": "^16.5.4",
"react-i18next": "^15.4.1",
"react-markdown": "^10.1.0",
"react-virtualized": "^9.22.6",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.3",
"socket.io-client": "^4.8.1",
"socket.io-msgpack-parser": "^3.0.2",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.1",
@@ -102,6 +102,6 @@
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^4.3.5",
"zod-validation-error": "^5.0.0",
"zustand": "^5.0.11"
"zustand": "^5.0.8"
}
}
@@ -18,7 +18,7 @@ export function Aquapath1Page() {
link: "graph",
title: "Graph",
icon: "lu:ChartSpline",
activeLink: "graph",
activeLink: "graphs",
},
{
link: "settings",
+25 -54
View File
@@ -390,11 +390,6 @@ export const aquapath1: MachineProperties = {
product_id: 0x7d83052,
revision: 0x110000,
},
{
vendor_id: 2,
product_id: 0x7d83052,
revision: 0x120000,
},
],
},
{
@@ -478,30 +473,6 @@ export const digitalInputTestMachine: MachineProperties = {
},
device_roles: [],
};
export const wago8chDioTestMachine: MachineProperties = {
name: "WAGO 8ch DIO Test",
version: "V1",
slug: "wago8chdiotestmachine",
icon: "lu:Disc3",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0041,
},
device_roles: [
{
role: 0,
role_label: "Wago 750-354 Bus Coupler",
allowed_devices: [
{
vendor_id: 0x21,
product_id: 0x07500354,
revision: 0x2,
},
],
},
],
};
export const wagoPower1: MachineProperties = {
name: "WAGO Power",
version: "V1",
@@ -573,6 +544,30 @@ export const ip20TestMachine: MachineProperties = {
],
};
export const vacuum: MachineProperties = {
name: "Vacuum",
version: "V1",
slug: "vacuum",
icon: "lu:ToggleLeft",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x0037,
},
device_roles: [
{
role: 0,
role_label: "IP20-EC-DI8-DO8",
allowed_devices: [
{
vendor_id: 0x741,
product_id: 0x117b6722,
revision: 0x1,
},
],
},
],
};
export const TestMotor: MachineProperties = {
name: "TestMotor",
version: "V1",
@@ -608,29 +603,6 @@ export const TestMotor: MachineProperties = {
],
};
export const wagoDoTestMachine: MachineProperties = {
name: "Wago DO Test",
version: "V1",
slug: "wagodotestmachine",
icon: "lu:ToggleRight",
machine_identification: {
vendor: VENDOR_QITECH,
machine: 0x000e,
},
device_roles: [
{
role: 0,
role_label: "Wago 750-354 Bus Coupler",
allowed_devices: [
{
vendor_id: 0x21,
product_id: 0x07500354,
revision: 0x2,
},
],
},
],
};
export const wagoAiTestMachine: MachineProperties = {
name: "Wago AI Test",
version: "V1",
@@ -668,10 +640,9 @@ export const machineProperties: MachineProperties[] = [
analogInputTestMachine,
wagoAiTestMachine,
digitalInputTestMachine,
wago8chDioTestMachine,
ip20TestMachine,
wagoDoTestMachine,
TestMotor,
vacuum
];
export const getMachineProperties = (
@@ -0,0 +1,52 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Label } from "@/control/Label";
import { Badge } from "@/components/ui/badge";
import { useVacuum } from "./use";
import { EditValue } from "@/control/EditValue";
import { roundToDecimals } from "@/lib/decimal";
export function VacuumConfigPage() {
const { state, liveValues, setIntervalTimeOff, setIntervalTimeOn } = useVacuum();
const safeState = state ?? { mode: "Standby", interval_time_off: 0, interval_time_on: 0 };
const safeLiveValues = liveValues ?? {};
return (
<Page>
<ControlGrid columns={2}>
<ControlCard className="bg-red" title="Interval Settings">
<Label label="Interval (OFF) duration">
<EditValue
value={safeState.interval_time_off}
defaultValue={30}
unit="s"
title="Interval (OFF) duration"
min={0}
max={60}
renderValue={(value) => roundToDecimals(value, 2)}
onChange={setIntervalTimeOff}
/>
</Label>
<Label label="Interval (ON) duration">
<EditValue
value={safeState.interval_time_on}
defaultValue={30}
unit="s"
title="Interval (ON) duration"
min={0}
max={60}
renderValue={(value) => roundToDecimals(value, 2)}
onChange={setIntervalTimeOn}
/>
</Label>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,75 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Label } from "@/control/Label";
import { Badge } from "@/components/ui/badge";
import { useVacuum } from "./use";
import { Spool } from "../winder/Spool";
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
import { roundToDecimals } from "@/lib/decimal";
export function VacuumControlPage() {
const { state, liveValues, remaining_time, spin_shitter, setMode } = useVacuum();
const safeState = state ?? { mode: "Idle", interval_time_off: 0, interval_time_on: 0, running: false };
const safeLiveValues = liveValues ?? { remaining_time: 0 };
return (
<Page>
<ControlGrid columns={2}>
<ControlCard className="bg-red" title="Mode">
<SelectionGroup<"Idle" | "On" | "Auto" | "Interval">
value={ safeState.mode }
onChange={setMode}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Idle: {
children: "Idle",
icon: "lu:Power",
isActiveClassName: "bg-red-600",
className: "h-full",
},
On: {
children: "On",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Auto: {
children: "Auto",
icon: "lu:BrainCog",
isActiveClassName: "bg-green-600",
className: "h-full",
},
Interval: {
children: "Interval",
icon: "lu:Activity",
isActiveClassName: "bg-green-600",
className: "h-full",
},
}}
/>
</ControlCard>
<ControlCard title="Motor">
<Badge className={safeState.running ? "bg-green-500" : "bg-red-500"}>
{safeState.running ? "Running" : "Idle" }
</Badge>
<Spool rpm={spin_shitter.current?.value} />
<TimeSeriesValueNumeric
label="Remaining Time"
unit="s"
timeseries={remaining_time}
renderValue={(value) => roundToDecimals(value, 0)}
/>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -0,0 +1,169 @@
import { StoreApi } from "zustand";
import { create } from "zustand";
import { z } from "zod";
import {
EventHandler,
eventSchema,
Event,
handleUnhandledEventError,
NamespaceId,
createNamespaceHookImplementation,
ThrottledStoreUpdater,
} from "@/client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";
import { createTimeSeries, TimeSeries, TimeSeriesValue } from "@/lib/timeseries";
import { randomBytes } from "crypto";
// ========== Event Schema ==========
/**
* Machine operation mode enum
*/
export const modeSchema = z.enum(["Idle", "On", "Auto", "Interval"]);
export type Mode = z.infer<typeof modeSchema>;
export const stateEventDataSchema = z.object({
mode: modeSchema,
interval_time_off: z.number(),
interval_time_on: z.number(),
running: z.boolean(),
});
export const liveValuesEventDataSchema = z.object({
remaining_time: z.number(),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export const liveValuesEventSchema = eventSchema(liveValuesEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
export type LiveValuesEvent = z.infer<typeof liveValuesEventDataSchema>;
// ========== Store ==========
export type VacuumNamespaceStore = {
state: StateEvent | null;
liveValues: LiveValuesEvent | null;
// Timer series data for live values
remaining_time: TimeSeries,
spin_shitter: TimeSeries,
};
const { initialTimeSeries: remaining_time, insert: addRemainingTime } =
createTimeSeries({ sampleIntervalLong: 1000 });
const { initialTimeSeries: spin_shitter, insert: addSpinShitter } =
createTimeSeries({ sampleIntervalLong: 1000 });
export const createVacuumNamespaceStore =
(): StoreApi<VacuumNamespaceStore> =>
create<VacuumNamespaceStore>(() => ({
state: null,
liveValues: null,
remaining_time,
spin_shitter,
}));
// ========== Message Handler ==========
export function vacuumTestMachineMessageHandler(
store: StoreApi<VacuumNamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<VacuumNamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const updateStore = (
updater: (
state: VacuumNamespaceStore,
) => VacuumNamespaceStore,
) => throttledUpdater.updateWith(updater);
try {
if (event.name === "StateEvent")
{
console.log("STATE EVENT");
const stateEvent = stateEventSchema.parse(event);
const rpm = Math.floor(Math.random() * (125 - 115 + 1)) + 115;
const value: TimeSeriesValue = {
value: stateEvent.data.running ? rpm : 0,
timestamp: event.ts,
};
updateStore((state) => ({
...state,
state: stateEvent.data,
spin_shitter: addSpinShitter(state.spin_shitter, value)
}));
}
else if (event.name === "LiveValuesEvent")
{
console.log("LIVE VALUES EVENT");
const rpm = Math.floor(Math.random() * (125 - 115 + 1)) + 115;
const parsed = liveValuesEventSchema.parse(event);
const rpm_value: TimeSeriesValue = {
value: rpm,
timestamp: event.ts,
};
const rpm_value_0: TimeSeriesValue = {
value: 0,
timestamp: event.ts,
};
updateStore((liveValuesEvent) => ({
...liveValuesEvent,
liveValues: parsed.data,
spin_shitter: addSpinShitter(liveValuesEvent.spin_shitter, liveValuesEvent.state?.running ? rpm_value : rpm_value_0),
}));
if (parsed.data.remaining_time !== null)
{
const value: TimeSeriesValue = {
value: parsed.data.remaining_time,
timestamp: event.ts,
};
updateStore((state) => ({
...state,
remaining_time: addRemainingTime(state.remaining_time, value),
}));
}
}
else {
handleUnhandledEventError(event.name);
}
} catch (error) {
console.error(`Error processing ${event.name}:`, error);
throw error;
}
};
}
// ========== Namespace Hook ==========
const useVacuumNamespaceImplementation =
createNamespaceHookImplementation<VacuumNamespaceStore>({
createStore: createVacuumNamespaceStore,
createEventHandler: vacuumTestMachineMessageHandler,
});
export function useVacuumNamespace(
machine_identification_unique: MachineIdentificationUnique,
): VacuumNamespaceStore {
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};
return useVacuumNamespaceImplementation(namespaceId);
}
+27
View File
@@ -0,0 +1,27 @@
import { Topbar } from "@/components/Topbar";
import { vacuumSerialRoute } from "@/routes/routes";
import React from "react";
export function VacuumPage(): React.JSX.Element {
const { serial } = vacuumSerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/vacuum/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:ToggleLeft",
},
{
link: "config",
activeLink: "config",
title: "Config",
icon: "lu:Settings2",
},
]}
/>
);
}
@@ -1,19 +1,21 @@
import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { wagoDoTestMachineSerialRoute } from "@/routes/routes";
import { vacuumSerialRoute } from "@/routes/routes";
import { MachineIdentificationUnique } from "@/machines/types";
import {
useWagoDoTestMachineNamespace,
useVacuumNamespace,
StateEvent,
} from "./WagoDoTestMachineNamespace";
LiveValuesEvent,
Mode,
} from "./MachineNamespace";
import { useMachineMutate } from "@/client/useClient";
import { produce } from "immer";
import { useEffect, useMemo } from "react";
import { wagoDoTestMachine } from "@/machines/properties";
import { vacuum } from "@/machines/properties";
import { z } from "zod";
export function useWagoDoTestMachine() {
const { serial: serialString } = wagoDoTestMachineSerialRoute.useParams();
export function useVacuum() {
const { serial: serialString } = vacuumSerialRoute.useParams();
// Memoize machine identification
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
@@ -32,13 +34,15 @@ export function useWagoDoTestMachine() {
}
return {
machine_identification: wagoDoTestMachine.machine_identification,
machine_identification: vacuum.machine_identification,
serial,
};
}, [serialString]);
// Namespace state from backend
const { state } = useWagoDoTestMachineNamespace(machineIdentification);
const { state, liveValues, remaining_time, spin_shitter } = useVacuumNamespace(
machineIdentification,
);
// Optimistic state
const stateOptimistic = useStateOptimistic<StateEvent>();
@@ -51,7 +55,7 @@ export function useWagoDoTestMachine() {
const { request: sendMutation } = useMachineMutate(
z.object({
action: z.string(),
value: z.any(),
value: z.any(),
}),
);
@@ -65,35 +69,52 @@ export function useWagoDoTestMachine() {
serverRequest?.();
};
const setLed = (index: number, on: boolean) => {
const setMode = (mode: Mode) => {
updateStateOptimistically(
(current) => {
current.led_on[index] = on;
current.mode = mode;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetLed", value: { index, on } },
data: { action: "SetMode", value: mode },
}),
);
};
const setAllLeds = (on: boolean) => {
const setIntervalTimeOff = (value: number) => {
updateStateOptimistically(
(current) => {
current.led_on = Array(8).fill(on);
current.interval_time_off = value;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetAllLeds", value: { on } },
data: { action: "SetIntervalTimeOff", value: value },
}),
);
};
const setIntervalTimeOn = (value: number) => {
updateStateOptimistically(
(current) => {
current.interval_time_on = value;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetIntervalTimeOn", value: value },
}),
);
};
return {
state: stateOptimistic.value,
setLed,
setAllLeds,
liveValues,
remaining_time,
spin_shitter,
setMode,
setIntervalTimeOff,
setIntervalTimeOn
};
}
}
@@ -1,84 +0,0 @@
import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { wago8chDioTestMachineRoute } from "@/routes/routes";
import { MachineIdentificationUnique } from "@/machines/types";
import { useMachineMutate } from "@/client/useClient";
import { produce } from "immer";
import { useEffect, useMemo } from "react";
import { wago8chDioTestMachine } from "@/machines/properties";
import { z } from "zod";
import {
StateEvent,
useWago8chDioTestMachineNamespace,
} from "./wago8chDioTestMachineNameSpace";
export function useWago8chDioTestMachine() {
const { serial: serialString } = wago8chDioTestMachineRoute.useParams();
// Memoize machine identification
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
const serial = parseInt(serialString);
if (isNaN(serial)) {
toastError(
"Invalid Serial Number",
`"${serialString}" is not a valid serial number.`,
);
return {
machine_identification: { vendor: 0, machine: 0 },
serial: 0,
};
}
return {
machine_identification: wago8chDioTestMachine.machine_identification,
serial,
};
}, [serialString]);
// Namespace state from backend
const { state } = useWago8chDioTestMachineNamespace(machineIdentification);
// Optimistic state
const stateOptimistic = useStateOptimistic<StateEvent>();
useEffect(() => {
if (state) stateOptimistic.setReal(state);
}, [state, stateOptimistic]);
// Generic mutation sender
const { request: sendMutation } = useMachineMutate(
z.object({
action: z.string(),
value: z.any(),
}),
);
const updateStateOptimistically = (
producer: (current: StateEvent) => void,
serverRequest?: () => void,
) => {
const currentState = stateOptimistic.value;
if (currentState)
stateOptimistic.setOptimistic(produce(currentState, producer));
serverRequest?.();
};
const setOutputAtIndex = (index: number, value: boolean) => {
updateStateOptimistically(
(current) => {
current.digital_output[index] = value;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetOutput", value: { index, value } },
}),
);
};
return {
state: stateOptimistic.value,
setOutputAtIndex: setOutputAtIndex,
};
}
@@ -1,66 +0,0 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Label } from "@/control/Label";
import { useWago8chDioTestMachine } from "./useWago8chDioTestMachine";
import { Badge } from "@/components/ui/badge";
export function Wago8chDioTestMachineControlRoute() {
const { state, setOutputAtIndex } = useWago8chDioTestMachine();
const safeState = state ?? {
digital_input: [false, false, false, false, false, false, false, false],
digital_output: [false, false, false, false, false, false, false, false],
};
return (
<Page>
<ControlGrid columns={2}>
{/* Digital Inputs */}
<ControlCard title="Digital Inputs">
<div className="grid grid-cols-2 gap-4">
{safeState.digital_input.map((input, index) => (
<Label key={index} label={`Input ${index + 1}`}>
<Badge variant={input ? "outline" : "destructive"}>
{input ? "ON" : "OFF"}
</Badge>
</Label>
))}
</div>
</ControlCard>
{/* Digital Outputs */}
<ControlCard title="Digital Outputs">
<div className="grid grid-cols-2 gap-4">
{safeState.digital_output.map((output, index) => (
<Label key={index} label={`Output ${index + 1}`}>
<SelectionGroup<"On" | "Off">
value={output ? "On" : "Off"}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Off: {
children: "Off",
icon: "lu:CirclePause",
isActiveClassName: "bg-red-600",
className: "h-full",
},
On: {
children: "On",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
className: "h-full",
},
}}
onChange={(value) => setOutputAtIndex(index, value === "On")}
/>
</Label>
))}
</div>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -1,79 +0,0 @@
import { StoreApi } from "zustand";
import { create } from "zustand";
import { z } from "zod";
import {
EventHandler,
eventSchema,
Event,
handleUnhandledEventError,
NamespaceId,
createNamespaceHookImplementation,
ThrottledStoreUpdater,
} from "@/client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";
// ========== Event Schema ==========
export const stateEventDataSchema = z.object({
digital_input: z.array(z.boolean()).length(8),
digital_output: z.array(z.boolean()).length(8),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
// ========== Store ==========
export type Wago8chDioTestMachineNamespaceStore = {
state: StateEvent | null;
};
export const createWago8chDioTestMachineNamespaceStore =
(): StoreApi<Wago8chDioTestMachineNamespaceStore> =>
create<Wago8chDioTestMachineNamespaceStore>(() => ({
state: null,
}));
// ========== Message Handler ==========
export function wago8chDioTestMachineMessageHndler(
store: StoreApi<Wago8chDioTestMachineNamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<Wago8chDioTestMachineNamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const updateStore = (
updater: (
state: Wago8chDioTestMachineNamespaceStore,
) => Wago8chDioTestMachineNamespaceStore,
) => throttledUpdater.updateWith(updater);
try {
if (event.name === "StateEvent") {
const parsed = stateEventSchema.parse(event);
updateStore(() => ({ state: parsed.data }));
} else {
handleUnhandledEventError(event.name);
}
} catch (error) {
console.error(`Error processing ${event.name}:`, error);
throw error;
}
};
}
// ========== Namespace Hook ==========
const useWago8chDioTestMachineImplementation =
createNamespaceHookImplementation<Wago8chDioTestMachineNamespaceStore>({
createStore: createWago8chDioTestMachineNamespaceStore,
createEventHandler: wago8chDioTestMachineMessageHndler,
});
export function useWago8chDioTestMachineNamespace(
machine_identification_unique: MachineIdentificationUnique,
): Wago8chDioTestMachineNamespaceStore {
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};
return useWago8chDioTestMachineImplementation(namespaceId);
}
@@ -1,20 +0,0 @@
import { Topbar } from "@/components/Topbar";
import { wago8chDioTestMachineRoute } from "@/routes/routes";
import React from "react";
export function Wago8chDioTestMachinePage() {
const { serial } = wago8chDioTestMachineRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/wago8chdiotestmachine/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:CirclePlay",
},
]}
/>
);
}
@@ -75,7 +75,7 @@ function wagoPower1MessageHandler(
};
try {
if (eventName === "State") {
if (eventName === "StateEvent") {
const stateEvent = stateEventSchema.parse(event);
updateStore((state) => ({
@@ -86,7 +86,7 @@ function wagoPower1MessageHandler(
? stateEvent
: state.defaultState,
}));
} else if (eventName === "LiveValues") {
} else if (eventName === "LiveValuesEvent") {
console.log(event);
const liveValues = liveValuesEventSchema.parse(event);
const { voltage, current } = liveValues.data;
@@ -1,64 +0,0 @@
import { ControlCard } from "@/control/ControlCard";
import { Page } from "@/components/Page";
import React from "react";
import { ControlGrid } from "@/control/ControlGrid";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Label } from "@/control/Label";
import { useWagoDoTestMachine } from "./useWagoDoTestMachine";
export function WagoDoTestMachineControlPage() {
const { state, setLed, setAllLeds } = useWagoDoTestMachine();
const safeState = state ?? {
led_on: Array(8).fill(false),
};
return (
<Page>
<ControlGrid columns={2}>
{/* Digital Output Controls */}
<ControlCard title="Digital Outputs (Do1-Do8)">
<div className="grid grid-cols-2 gap-6">
{safeState.led_on.map((output, index) => (
<Label key={index} label={`Do${index + 1}`}>
<SelectionGroup<"On" | "Off">
value={output ? "On" : "Off"}
orientation="vertical"
className="flex flex-col gap-3"
options={{
Off: {
children: "Off",
icon: "lu:CirclePause",
isActiveClassName: "bg-red-600",
className: "flex-1",
},
On: {
children: "On",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
className: "flex-1",
},
}}
onChange={(value) => setLed(index, value === "On")}
/>
</Label>
))}
</div>
</ControlCard>
{/* Master Output Control */}
<ControlCard title="Master Output Control">
<SelectionGroup<"On" | "Off">
value={safeState.led_on.every(Boolean) ? "On" : "Off"}
orientation="horizontal"
options={{
Off: { children: "Turn All Off" },
On: { children: "Turn All On" },
}}
onChange={(value) => setAllLeds(value === "On")}
/>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -1,78 +0,0 @@
import { StoreApi } from "zustand";
import { create } from "zustand";
import { z } from "zod";
import {
EventHandler,
eventSchema,
Event,
handleUnhandledEventError,
NamespaceId,
createNamespaceHookImplementation,
ThrottledStoreUpdater,
} from "@/client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";
// ========== Event Schema ==========
export const stateEventDataSchema = z.object({
led_on: z.array(z.boolean()).length(8),
});
export const stateEventSchema = eventSchema(stateEventDataSchema);
export type StateEvent = z.infer<typeof stateEventDataSchema>;
// ========== Store ==========
export type WagoDoTestMachineNamespaceStore = {
state: StateEvent | null;
};
export const createWagoDoTestMachineNamespaceStore =
(): StoreApi<WagoDoTestMachineNamespaceStore> =>
create<WagoDoTestMachineNamespaceStore>(() => ({
state: null,
}));
// ========== Message Handler ==========
export function wagoDoTestMachineMessageHandler(
store: StoreApi<WagoDoTestMachineNamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<WagoDoTestMachineNamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const updateStore = (
updater: (
state: WagoDoTestMachineNamespaceStore,
) => WagoDoTestMachineNamespaceStore,
) => throttledUpdater.updateWith(updater);
try {
if (event.name === "StateEvent") {
const parsed = stateEventSchema.parse(event);
updateStore(() => ({ state: parsed.data }));
} else {
handleUnhandledEventError(event.name);
}
} catch (error) {
console.error(`Error processing ${event.name}:`, error);
throw error;
}
};
}
// ========== Namespace Hook ==========
const useWagoDoTestMachineNamespaceImplementation =
createNamespaceHookImplementation<WagoDoTestMachineNamespaceStore>({
createStore: createWagoDoTestMachineNamespaceStore,
createEventHandler: wagoDoTestMachineMessageHandler,
});
export function useWagoDoTestMachineNamespace(
machine_identification_unique: MachineIdentificationUnique,
): WagoDoTestMachineNamespaceStore {
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};
return useWagoDoTestMachineNamespaceImplementation(namespaceId);
}
@@ -1,20 +0,0 @@
import { Topbar } from "@/components/Topbar";
import { wagoDoTestMachineSerialRoute } from "@/routes/routes";
import React from "react";
export function WagoDoTestMachinePage() {
const { serial } = wagoDoTestMachineSerialRoute.useParams();
return (
<Topbar
pathname={`/_sidebar/machines/wagodotestmachine/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:CirclePlay",
},
]}
/>
);
}
+32 -34
View File
@@ -70,15 +70,18 @@ import { IP20TestMachineControlPage } from "@/machines/ip20testmachine/IP20TestM
import { TestMotorPage } from "@/machines/motor_test_machine/TestMotorPage";
import { TestMotorControlPage } from "@/machines/motor_test_machine/TestMotorControlPage";
// vacuum
import { VacuumPage } from "@/machines/vacuum/Page";
import { VacuumControlPage } from "@/machines/vacuum/ControlPage";
// ---
import { MetricsGraphsPage } from "@/metrics/MetricsGraphsPage";
import { MetricsControlPage } from "@/metrics/MetricsControlPage";
import { WagoPower1Page } from "@/machines/wago_power/wago_power1/WagoPower1Page";
import { WagoPower1ControlPage } from "@/machines/wago_power/wago_power1/WagoPower1ControlPage";
import { WagoDoTestMachinePage } from "@/machines/wagodotestmachine/WagoDoTestMachinePage";
import { WagoDoTestMachineControlPage } from "@/machines/wagodotestmachine/WagoDoTestMachineControlPage";
import { Wago8chDioTestMachinePage } from "@/machines/wago8chdiotestmachine/wago8chDioTestMachinePage";
import { Wago8chDioTestMachineControlRoute } from "@/machines/wago8chdiotestmachine/wago8chDioTestMachineControlPage";
import { VacuumConfigPage } from "@/machines/vacuum/ConfigPage";
// make a route tree like this
// _mainNavigation/machines/winder2/$serial/control
@@ -93,18 +96,6 @@ export const testMachineSerialRoute = createRoute({
component: () => <TestMachinePage />,
});
export const wago8chDioTestMachineRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "wago8chdiotestmachine/$serial",
component: () => <Wago8chDioTestMachinePage />,
});
export const wago8chDioTestMachineControlRoute = createRoute({
getParentRoute: () => wago8chDioTestMachineRoute,
path: "control",
component: () => <Wago8chDioTestMachineControlRoute />,
});
// Leaf route: control page
export const testMachineControlRoute = createRoute({
getParentRoute: () => testMachineSerialRoute,
@@ -160,6 +151,26 @@ export const ip20TestMachineControlRoute = createRoute({
component: () => <IP20TestMachineControlPage />,
});
// vacuum
export const vacuumSerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "vacuum/$serial",
component: () => <VacuumPage />,
});
export const vacuumControlRoute = createRoute({
getParentRoute: () => vacuumSerialRoute,
path: "control",
component: () => <VacuumControlPage />,
});
export const vacuumConfigRoute = createRoute({
getParentRoute: () => vacuumSerialRoute,
path: "config",
component: () => <VacuumConfigPage />,
});
// ---
export const testMotorSerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "testmotor/$serial",
@@ -399,18 +410,6 @@ export const wagoPower1ControlRoute = createRoute({
component: () => <WagoPower1ControlPage />,
});
export const wagoDoTestMachineSerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "wagodotestmachine/$serial",
component: () => <WagoDoTestMachinePage />,
});
export const wagoDoTestMachineControlRoute = createRoute({
getParentRoute: () => wagoDoTestMachineSerialRoute,
path: "control",
component: () => <WagoDoTestMachineControlPage />,
});
export const setupRoute = createRoute({
getParentRoute: () => sidebarRoute,
path: "setup",
@@ -519,10 +518,6 @@ export const rootTree = RootRoute.addChildren([
analogInputTestMachineControlRoute,
]),
wago8chDioTestMachineRoute.addChildren([
wago8chDioTestMachineControlRoute,
]),
wagoAiTestMachineSerialRoute.addChildren([wagoAiTestMachineControlRoute]),
digitalInputTestMachineSerialRoute.addChildren([
@@ -531,6 +526,11 @@ export const rootTree = RootRoute.addChildren([
ip20TestMachineSerialRoute.addChildren([ip20TestMachineControlRoute]),
vacuumSerialRoute.addChildren([
vacuumControlRoute,
vacuumConfigRoute,
]),
testMotorSerialRoute.addChildren([testMotorControlRoute]),
aquapath1SerialRoute.addChildren([
@@ -573,8 +573,6 @@ export const rootTree = RootRoute.addChildren([
wagoPower1SerialRoute.addChildren([wagoPower1ControlRoute]),
buffer1SerialRoute.addChildren([buffer1ControlRoute]),
wagoDoTestMachineSerialRoute.addChildren([wagoDoTestMachineControlRoute]),
]),
]),
]);
+1 -1
View File
@@ -8,7 +8,7 @@ workspace = true
[dependencies]
anyhow = "1.0.100"
ethercrab = { git = "https://github.com/v-morlock/ethercrab", branch = "main" }
ethercrab = "0.6"
ethercat_hal_derive = { version = "0.1.0", path = "../ethercat-hal-derive" }
units = { path = "../units" }
bitvec = { version = "1.0.1", features = ["alloc"] }
+1 -1
View File
@@ -180,6 +180,7 @@ impl Configuration for EL5152Configuration {
) -> Result<(), anyhow::Error> {
// Configure channel 1
self.channel1.write_channel_config(device, 0x8000).await?;
// Configure channel 2
self.channel2.write_channel_config(device, 0x8010).await?;
// Write PDO assignments
@@ -303,7 +304,6 @@ impl EL5152ChannelConfiguration {
device
.sdo_write(base_index, 0x11, self.frequency_window)
.await?;
device
.sdo_write(base_index, 0x13, self.frequency_scaling)
.await?;
+1 -2
View File
@@ -56,9 +56,8 @@ use std::{any::Any, fmt::Debug, sync::Arc};
use wago_750_354::{WAGO_750_354_IDENTITY_A, Wago750_354};
use wago_modules::ip20_ec_di8_do8::{IP20_EC_DI8_DO8_IDENTITY, IP20EcDi8Do8};
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Copy)]
pub struct Module {
pub name: String,
pub slot: u16,
pub belongs_to_addr: u16,
pub has_tx: bool,
+52 -117
View File
@@ -2,9 +2,6 @@ use super::{
EthercatDevice, EthercatDeviceProcessing, EthercatDeviceUsed, NewEthercatDevice,
SubDeviceIdentityTuple,
};
use crate::devices::wago_modules::wago_750_430::{
WAGO_750_430_MODULE_IDENT, WAGO_750_430_PRODUCT_ID,
};
use crate::devices::wago_modules::*;
use crate::{
devices::{
@@ -23,25 +20,24 @@ use crate::{
use anyhow::Error;
use smol::lock::RwLock;
use std::sync::Arc;
const MODULE_COUNT_INDEX: (u16, u8) = (0xf050, 0x00);
const TX_MAPPING_INDEX: (u16, u8) = (0x1c13, 0x00);
const RX_MAPPING_INDEX: (u16, u8) = (0x1c12, 0x00);
#[derive(Clone, Debug)]
struct ModulePdoMapping {
pub offset: usize,
pub module_i: u32,
}
// For both the rx and tx The Wago Coupler has 4 bytes, which we dont care about and skip
/// Wago750_354 bus coupler
/*
The "Modules" simply write at an offset into rx and read at an offset in tx
*/
pub struct Wago750_354 {
is_used: bool,
pub slots: [Option<Module>; 64],
pub slot_devices: [Option<Arc<RwLock<dyn DynamicEthercatDevice>>>; 64],
pub dev_count: usize,
pub module_count: usize,
rx_pdo_mappings: Vec<ModulePdoMapping>,
tx_pdo_mappings: Vec<ModulePdoMapping>,
rx_offsets: Vec<usize>,
tx_offsets: Vec<usize>,
tx_size: usize,
rx_size: usize,
}
@@ -109,7 +105,7 @@ impl EthercatDevice for Wago750_354 {
}
fn set_module(&mut self, module: Module) {
self.slots[self.module_count] = Some(module.clone());
self.slots[self.module_count] = Some(module);
self.module_count += 1;
self.tx_size += module.tx_offset;
self.rx_size += module.rx_offset;
@@ -138,8 +134,8 @@ impl NewEthercatDevice for Wago750_354 {
dev_count: 0,
tx_size: 0,
rx_size: 0,
rx_pdo_mappings: vec![],
tx_pdo_mappings: vec![],
rx_offsets: vec![],
tx_offsets: vec![],
}
}
}
@@ -151,76 +147,39 @@ impl std::fmt::Debug for Wago750_354 {
}
impl Wago750_354 {
pub fn calculate_module_index(pdo_mapping: u32, is_tx: bool) -> u32 {
let start_index = match is_tx {
true => 0x6000 as u32,
false => 0x7000 as u32,
};
let index_in_hex = ((pdo_mapping & 0xFFFF0000) >> 16) - start_index;
if index_in_hex < 16 {
return 0;
} else {
return index_in_hex / 16;
}
}
pub async fn get_pdo_offsets<'a>(
&mut self,
device: &EthercrabSubDevicePreoperational<'a>,
get_tx: bool,
) -> Result<(), Error> {
let mut vec: Vec<ModulePdoMapping> = vec![];
let mut vec: Vec<usize> = vec![];
let mut bit_offset = 0;
let mut module_i;
let start_subindex = 0x2;
let start_subindex = 0x1;
let index = match get_tx {
true => (TX_MAPPING_INDEX.0, TX_MAPPING_INDEX.1),
false => (RX_MAPPING_INDEX.0, RX_MAPPING_INDEX.1),
};
let count = device.sdo_read::<u8>(index.0, index.1).await?;
let count_mappings = device.sdo_read::<u8>(index.0, index.1).await?;
let pdo_index = device.sdo_read::<u16>(index.0, 1).await?;
let pdo_map_count = device.sdo_read::<u8>(pdo_index, 0).await?;
for i in 0..pdo_map_count {
let pdo_mapping: u32 = device.sdo_read(pdo_index, 1 + i).await?;
let bit_length = (pdo_mapping & 0xFF) as u8;
bit_offset += bit_length as usize;
}
let mut mappings_without_coupler: Vec<u32> = vec![];
for i in start_subindex..count_mappings {
let pdo_index = device.sdo_read(index.0, i).await?;
let pdo_map_count = device.sdo_read::<u8>(pdo_index, 0).await?;
for j in 0..pdo_map_count {
let pdo_mapping: u32 = device.sdo_read(pdo_index, 1 + j).await?;
mappings_without_coupler.push(pdo_mapping);
for i in 0..count {
vec.push(bit_offset);
let pdo_index = device.sdo_read(index.0, start_subindex + i).await?;
if pdo_index != 0 {
let pdo_map_count = device.sdo_read::<u8>(pdo_index, 0).await?;
// Iterate over every PDO Mapped entry and add it to the cumulative bit offset
for j in 0..pdo_map_count {
let pdo_mapping: u32 = device.sdo_read(pdo_index, 1 + j).await?;
// We only need / Want the bit len, which we extract with a bitmask extracting the lsb
let bit_length = (pdo_mapping & 0xFF) as u8;
bit_offset += bit_length as usize;
}
}
}
mappings_without_coupler.sort();
for pdo_mapping in mappings_without_coupler {
module_i = Wago750_354::calculate_module_index(pdo_mapping, get_tx);
let bit_length = (pdo_mapping & 0xFF) as u8;
if module_i < 64 {
vec.push(ModulePdoMapping {
offset: bit_offset,
module_i,
});
}
bit_offset += bit_length as usize;
}
vec.sort_by_key(|e| (e.module_i, e.offset));
// deduplicate by module_i, so we only have the offset to the start of inputs/outputs
vec.dedup_by(|a, b| a.module_i == b.module_i);
if get_tx {
self.tx_pdo_mappings = vec;
self.tx_offsets = vec;
} else {
self.rx_pdo_mappings = vec;
self.rx_offsets = vec;
}
Ok(())
}
@@ -263,7 +222,6 @@ impl Wago750_354 {
has_rx: false,
tx_offset: 0,
rx_offset: 0,
name: "".to_string(),
};
match ident_iom {
@@ -271,37 +229,26 @@ impl Wago750_354 {
WAGO_750_1506_PRODUCT_ID => {
module.has_tx = true;
module.has_rx = true;
module.name = "750-1506".to_string();
}
WAGO_750_455_PRODUCT_ID => {
module.has_tx = true;
module.has_rx = false;
module.name = "750-455".to_string();
}
WAGO_750_501_PRODUCT_ID => {
module.has_tx = false;
module.has_rx = true;
module.name = "750-501".to_string();
}
WAGO_750_530_PRODUCT_ID => {
module.has_tx = false;
module.has_rx = true;
module.name = "750-530".to_string();
}
WAGO_750_652_PRODUCT_ID => {
module.has_tx = true;
module.has_rx = true;
module.name = "750-652".to_string();
}
WAGO_750_402_PRODUCT_ID => {
module.has_tx = true;
module.has_rx = false;
module.name = "750-402".to_string();
}
WAGO_750_430_PRODUCT_ID => {
module.has_tx = true;
module.has_rx = false;
module.name = "750-430".to_string();
}
_ => println!(
"Wago-750-354 found Unknown/Unimplemented Module: {}",
@@ -319,42 +266,17 @@ impl Wago750_354 {
if self.dev_count != 0 {
return;
}
let mut tx_index = 1;
let mut rx_index = 1;
smol::block_on(async {
let _ = self.get_pdo_offsets(device, true).await;
let _ = self.get_pdo_offsets(device, false).await;
});
for module in &mut self.slots {
match module {
Some(m) => {
let tx_pdo_mapping = self
.tx_pdo_mappings
.iter()
.find(|map| map.module_i == m.slot.into());
if m.has_tx {
m.tx_offset = match tx_pdo_mapping {
Some(map) => map.offset,
None => 0,
}
}
println!("{:?}\n\n{:?}", self.tx_offsets, self.rx_offsets);
let rx_pdo_mapping = self
.rx_pdo_mappings
.iter()
.find(|map| map.module_i == m.slot.into());
if m.has_rx {
m.rx_offset = match rx_pdo_mapping {
Some(map) => map.offset,
None => 0,
}
}
}
None => break,
}
}
for module in &self.slots {
for module in self.slots {
match module {
Some(m) => {
// Map ModuleIdent's to Terminals
@@ -380,10 +302,6 @@ impl Wago750_354 {
WAGO_750_402_MODULE_IDENT => {
Arc::new(RwLock::new(wago_750_402::Wago750_402::new()))
}
WAGO_750_430_MODULE_IDENT => {
Arc::new(RwLock::new(wago_750_430::Wago750_430::new()))
}
_ => {
println!(
"{} Missing Implementation for Module Identification: vendor_id: {:?}, module ident: {:?} !",
@@ -394,10 +312,27 @@ impl Wago750_354 {
return;
}
};
let tx_pdo_offset = self.tx_offsets.get(tx_index as usize);
if m.has_tx {
tx_index += 1;
}
let rx_pdo_offset = self.rx_offsets.get(rx_index as usize);
if m.has_rx {
rx_index += 1;
}
let mut dev_guard = dev.write_blocking();
//println!("For {:?} setting tx: {} rx: {}",m.name,m.tx_offset,m.rx_offset);
dev_guard.set_tx_offset(m.tx_offset);
dev_guard.set_rx_offset(m.rx_offset);
match tx_pdo_offset {
Some(offset) => dev_guard.set_tx_offset(*offset),
None => (),
}
match rx_pdo_offset {
Some(offset) => dev_guard.set_rx_offset(*offset),
None => (),
}
drop(dev_guard);
self.slot_devices[self.dev_count] = Some(dev);
self.dev_count += 1;
@@ -214,7 +214,7 @@ impl EthercatDevice for IP20EcDi8Do8 {
}
fn set_module(&mut self, module: Module) {
self.slots[self.module_count] = Some(module.clone());
self.slots[self.module_count] = Some(module);
self.module_count += 1;
self.tx_size += module.tx_offset;
self.rx_size += module.rx_offset;
@@ -378,7 +378,6 @@ impl IP20EcDi8Do8 {
has_rx: false,
tx_offset: 0,
rx_offset: 0,
name: "".to_string(),
};
match ident_iom {
@@ -416,7 +415,7 @@ impl IP20EcDi8Do8 {
let _ = self.get_pdo_offsets(device, false).await;
});
for module in &self.slots {
for module in self.slots {
match module {
Some(m) => {
// Map ModuleIdent's to Terminals
@@ -1,7 +1,6 @@
pub mod ip20_ec_di8_do8;
pub mod wago_750_1506;
pub mod wago_750_402;
pub mod wago_750_430;
pub mod wago_750_455;
pub mod wago_750_501;
pub mod wago_750_530;
@@ -38,27 +38,27 @@ impl From<Wago750_1506OutputPort> for usize {
#[derive(Debug, Clone)]
pub enum Wago750_1506InputPort {
DI1,
DI2,
DI3,
DI4,
DI5,
DI6,
DI7,
DI8,
DO1,
DO2,
DO3,
DO4,
DO5,
DO6,
DO7,
DO8,
}
impl From<Wago750_1506InputPort> for usize {
fn from(value: Wago750_1506InputPort) -> Self {
match value {
Wago750_1506InputPort::DI1 => 0,
Wago750_1506InputPort::DI2 => 1,
Wago750_1506InputPort::DI3 => 2,
Wago750_1506InputPort::DI4 => 3,
Wago750_1506InputPort::DI5 => 4,
Wago750_1506InputPort::DI6 => 5,
Wago750_1506InputPort::DI7 => 6,
Wago750_1506InputPort::DI8 => 7,
Wago750_1506InputPort::DO1 => 0,
Wago750_1506InputPort::DO2 => 1,
Wago750_1506InputPort::DO3 => 2,
Wago750_1506InputPort::DO4 => 3,
Wago750_1506InputPort::DO5 => 4,
Wago750_1506InputPort::DO6 => 5,
Wago750_1506InputPort::DO7 => 6,
Wago750_1506InputPort::DO8 => 7,
}
}
}
@@ -91,14 +91,14 @@ impl DigitalInputDevice<Wago750_1506InputPort> for Wago750_1506 {
fn get_input(&self, port: Wago750_1506InputPort) -> Result<DigitalInputInput, anyhow::Error> {
Ok(DigitalInputInput {
value: match port {
Wago750_1506InputPort::DI1 => self.tx_pdo.port1,
Wago750_1506InputPort::DI2 => self.tx_pdo.port2,
Wago750_1506InputPort::DI3 => self.tx_pdo.port3,
Wago750_1506InputPort::DI4 => self.tx_pdo.port4,
Wago750_1506InputPort::DI5 => self.tx_pdo.port5,
Wago750_1506InputPort::DI6 => self.tx_pdo.port6,
Wago750_1506InputPort::DI7 => self.tx_pdo.port7,
Wago750_1506InputPort::DI8 => self.tx_pdo.port8,
Wago750_1506InputPort::DO1 => self.tx_pdo.port1,
Wago750_1506InputPort::DO2 => self.tx_pdo.port2,
Wago750_1506InputPort::DO3 => self.tx_pdo.port3,
Wago750_1506InputPort::DO4 => self.tx_pdo.port4,
Wago750_1506InputPort::DO5 => self.tx_pdo.port5,
Wago750_1506InputPort::DO6 => self.tx_pdo.port6,
Wago750_1506InputPort::DO7 => self.tx_pdo.port7,
Wago750_1506InputPort::DO8 => self.tx_pdo.port8,
},
})
}
@@ -189,28 +189,28 @@ impl EthercatDevice for Wago750_1506 {
) -> Result<(), anyhow::Error> {
let base = self.tx_bit_offset;
let idx1 = base + Into::<usize>::into(Wago750_1506InputPort::DI1);
let idx1 = base + Into::<usize>::into(Wago750_1506InputPort::DO1);
self.tx_pdo.port1 = *input.get(idx1).expect("Bit 1 out of bounds");
let idx2 = base + Into::<usize>::into(Wago750_1506InputPort::DI2);
let idx2 = base + Into::<usize>::into(Wago750_1506InputPort::DO2);
self.tx_pdo.port2 = *input.get(idx2).expect("Bit 2 out of bounds");
let idx3 = base + Into::<usize>::into(Wago750_1506InputPort::DI3);
let idx3 = base + Into::<usize>::into(Wago750_1506InputPort::DO3);
self.tx_pdo.port3 = *input.get(idx3).expect("Bit 3 out of bounds");
let idx4 = base + Into::<usize>::into(Wago750_1506InputPort::DI4);
let idx4 = base + Into::<usize>::into(Wago750_1506InputPort::DO4);
self.tx_pdo.port4 = *input.get(idx4).expect("Bit 4 out of bounds");
let idx5 = base + Into::<usize>::into(Wago750_1506InputPort::DI5);
let idx5 = base + Into::<usize>::into(Wago750_1506InputPort::DO5);
self.tx_pdo.port5 = *input.get(idx5).expect("Bit 5 out of bounds");
let idx6 = base + Into::<usize>::into(Wago750_1506InputPort::DI6);
let idx6 = base + Into::<usize>::into(Wago750_1506InputPort::DO6);
self.tx_pdo.port6 = *input.get(idx6).expect("Bit 6 out of bounds");
let idx7 = base + Into::<usize>::into(Wago750_1506InputPort::DI7);
let idx7 = base + Into::<usize>::into(Wago750_1506InputPort::DO7);
self.tx_pdo.port7 = *input.get(idx7).expect("Bit 7 out of bounds");
let idx8 = base + Into::<usize>::into(Wago750_1506InputPort::DI8);
let idx8 = base + Into::<usize>::into(Wago750_1506InputPort::DO8);
self.tx_pdo.port8 = *input.get(idx8).expect("Bit 8 out of bounds");
Ok(())
}
@@ -1,211 +0,0 @@
use crate::devices::{
DynamicEthercatDevice, EthercatDevice, EthercatDeviceUsed, EthercatDynamicPDO, Module,
SubDeviceProductTuple,
};
use crate::devices::{EthercatDeviceProcessing, NewEthercatDevice};
use crate::io::digital_input::{DigitalInputDevice, DigitalInputInput};
#[derive(Clone)]
pub struct Wago750_430 {
is_used: bool,
tx_bit_offset: usize,
rx_bit_offset: usize,
pub txpdo: Wago750_430TxPdo,
module: Option<Module>,
}
#[derive(Debug, Clone)]
pub enum Wago750_430Port {
Port1,
Port2,
Port3,
Port4,
Port5,
Port6,
Port7,
Port8,
}
impl From<Wago750_430Port> for usize {
fn from(value: Wago750_430Port) -> Self {
match value {
Wago750_430Port::Port1 => 0,
Wago750_430Port::Port2 => 1,
Wago750_430Port::Port3 => 2,
Wago750_430Port::Port4 => 3,
Wago750_430Port::Port5 => 4,
Wago750_430Port::Port6 => 5,
Wago750_430Port::Port7 => 6,
Wago750_430Port::Port8 => 7,
}
}
}
#[derive(Clone, Default)]
pub struct Wago750_430TxPdo {
pub port1: bool,
pub port2: bool,
pub port3: bool,
pub port4: bool,
pub port5: bool,
pub port6: bool,
pub port7: bool,
pub port8: bool,
}
impl DigitalInputDevice<Wago750_430Port> for Wago750_430 {
fn get_input(
&self,
port: Wago750_430Port,
) -> Result<crate::io::digital_input::DigitalInputInput, anyhow::Error> {
match port {
Wago750_430Port::Port1 => Ok(DigitalInputInput {
value: self.txpdo.port1,
}),
Wago750_430Port::Port2 => Ok(DigitalInputInput {
value: self.txpdo.port2,
}),
Wago750_430Port::Port3 => Ok(DigitalInputInput {
value: self.txpdo.port3,
}),
Wago750_430Port::Port4 => Ok(DigitalInputInput {
value: self.txpdo.port4,
}),
Wago750_430Port::Port5 => Ok(DigitalInputInput {
value: self.txpdo.port5,
}),
Wago750_430Port::Port6 => Ok(DigitalInputInput {
value: self.txpdo.port6,
}),
Wago750_430Port::Port7 => Ok(DigitalInputInput {
value: self.txpdo.port7,
}),
Wago750_430Port::Port8 => Ok(DigitalInputInput {
value: self.txpdo.port8,
}),
}
}
}
impl EthercatDeviceUsed for Wago750_430 {
fn is_used(&self) -> bool {
self.is_used
}
fn set_used(&mut self, used: bool) {
self.is_used = used;
}
}
impl DynamicEthercatDevice for Wago750_430 {}
impl EthercatDynamicPDO for Wago750_430 {
fn get_tx_offset(&self) -> usize {
self.tx_bit_offset
}
fn get_rx_offset(&self) -> usize {
self.rx_bit_offset
}
fn set_tx_offset(&mut self, offset: usize) {
self.tx_bit_offset = offset
}
fn set_rx_offset(&mut self, offset: usize) {
self.rx_bit_offset = offset
}
}
impl EthercatDevice for Wago750_430 {
fn input(
&mut self,
input: &bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
) -> Result<(), anyhow::Error> {
self.txpdo.port1 = input[0];
self.txpdo.port2 = input[1];
self.txpdo.port3 = input[2];
self.txpdo.port4 = input[3];
self.txpdo.port5 = input[4];
self.txpdo.port6 = input[5];
self.txpdo.port7 = input[6];
self.txpdo.port8 = input[7];
Ok(())
}
fn input_len(&self) -> usize {
8
}
fn output(
&self,
_output: &mut bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
) -> Result<(), anyhow::Error> {
Ok(())
}
fn output_len(&self) -> usize {
0
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn is_module(&self) -> bool {
true
}
fn input_checked(
&mut self,
_input: &bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
) -> Result<(), anyhow::Error> {
Ok(())
}
fn output_checked(
&self,
_output: &mut bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
) -> Result<(), anyhow::Error> {
Ok(())
}
fn get_module(&self) -> Option<crate::devices::Module> {
self.module.clone()
}
fn set_module(&mut self, module: crate::devices::Module) {
self.tx_bit_offset = module.tx_offset;
self.rx_bit_offset = module.rx_offset;
self.module = Some(module)
}
}
impl EthercatDeviceProcessing for Wago750_430 {}
impl NewEthercatDevice for Wago750_430 {
fn new() -> Self {
Self {
is_used: false,
tx_bit_offset: 0,
rx_bit_offset: 0,
module: None,
txpdo: Wago750_430TxPdo::default(),
}
}
}
impl std::fmt::Debug for Wago750_430 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Wago750_430")
}
}
pub const WAGO_750_430_VENDOR_ID: u32 = 0x00000021;
pub const WAGO_750_430_PRODUCT_ID: u32 = 2147483777;
pub const WAGO_750_430_MODULE_IDENT: SubDeviceProductTuple =
(WAGO_750_430_VENDOR_ID, WAGO_750_430_PRODUCT_ID);
@@ -148,7 +148,8 @@ impl EthercatDevice for Wago750_652 {
&self,
output: &mut bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
) -> Result<(), anyhow::Error> {
let base = self.rx_bit_offset;
let base = 32;
let mut ol0_i = 5; // Ol0 starts at bit 5 and goes to bit 7 (inclusive)
let mut ol1_i = 8;
@@ -173,10 +174,12 @@ impl EthercatDevice for Wago750_652 {
);
ol1_i += 1;
}
println!("{}", self.rx_pdo.control.output_length);
let data_start = base + 16;
for (i, &byte) in self.rx_pdo.out_buffer.iter().take(22).enumerate() {
output[(data_start + i * 8)..(data_start + (i + 1) * 8)].store_le(byte);
}
//println!("{:?}",self.rx_pdo.out_buffer);
Ok(())
}
-71
View File
@@ -2,74 +2,3 @@ pub mod counter_wrapper_u16_i128;
pub mod el70xx_velocity_converter;
pub mod ethercrab_types;
pub mod signing_converter_u16;
use ethercrab::{SubDevice, SubDeviceRef};
// This is the Codeword for disabling EEPROM writes on sdo writes
const BECKHOFF_EEPROM_LOCK_CODEWORD: u32 = 0x12345678;
// In this index Codewords can be written for Beckhoff Terminals
const BECKHOFF_CODEWORD_INDEX: u16 = 0xF008;
// This code locks the eeprom and protects it from writes
// When the toggle is in effect no writes propagate to the eeprom
// This is useful if you have a usecase where you very often write into CoE registers, as writes to EEPROM after a long time degrage the EEPROM
pub async fn set_mut_beckhoff_eeprom_lock_active<'a>(
subdevice: &SubDeviceRef<'a, &mut SubDevice>,
) -> Result<(), anyhow::Error> {
let code_word = match subdevice.sdo_read::<u32>(BECKHOFF_CODEWORD_INDEX, 0).await {
Ok(code_word) => code_word,
// This happens when the subdevice has no mailbox
// There is NO check in ethercrab for Mailbox presence, so we just have to pray that it has one and send a request
// If there is no Mailbox to write Coe Request to, then there is also no EEPROM meaning we achieved our goal in a sense
// This is why it returns OK on an error for sdo_read
Err(_) => return Ok(()),
};
let eeprom_lock_toggled = match code_word {
BECKHOFF_EEPROM_LOCK_CODEWORD => true,
_ => false,
};
if !eeprom_lock_toggled {
subdevice
.sdo_write(BECKHOFF_CODEWORD_INDEX, 0, BECKHOFF_EEPROM_LOCK_CODEWORD)
.await?;
tracing::info!(
"Activated EEPROM Lock for {} {}",
subdevice.name(),
subdevice.name()
);
}
Ok(())
}
pub async fn set_beckhoff_eeprom_lock_active<'a>(
subdevice: &SubDeviceRef<'a, &SubDevice>,
) -> Result<(), anyhow::Error> {
let code_word = match subdevice.sdo_read::<u32>(BECKHOFF_CODEWORD_INDEX, 0).await {
Ok(code_word) => code_word,
// This happens when the subdevice has no mailbox
// There is NO check in ethercrab for Mailbox presence, so we just have to pray that it has one and send a request
// If there is no Mailbox to write Coe Request to, then there is also no EEPROM meaning we achieved our goal in a sense
// This is why it returns OK on an error for sdo_read
Err(_) => return Ok(()),
};
let eeprom_lock_toggled = match code_word {
BECKHOFF_EEPROM_LOCK_CODEWORD => true,
_ => false,
};
if !eeprom_lock_toggled {
subdevice
.sdo_write(BECKHOFF_CODEWORD_INDEX, 0, BECKHOFF_EEPROM_LOCK_CODEWORD)
.await?;
tracing::info!(
"Activated EEPROM Lock for {} {}",
subdevice.name(),
subdevice.name()
);
}
Ok(())
}
+13
View File
@@ -0,0 +1,13 @@
# Git Hooks
This directory contains Git hooks for the project.
## Installation
To install the hooks, run the following command from the repository root:
```bash
# Copy the pre-commit hook to your local .git/hooks directory
cp hooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
```
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
#
# Pre-commit hook to generate Rust documentation
#
# This hook runs the rustdoc.sh script to update documentation
# before each commit.
echo "Running rustdoc generation..."
# Get the repository root
REPO_ROOT="$(git rev-parse --show-toplevel)"
# iteralte all directories thst contain a Cargo.toml file
for dir in $(find "$REPO_ROOT" -name "Cargo.toml" -exec dirname {} \;); do
echo "Generating documentation in $dir"
# cargotoml conatains workspace members, skip this directory
# HAS_MEMBERS=$(grep -c '\[workspace\]' "$dir/Cargo.toml")
# if [ "$HAS_MEMBERS" -gt 0 ]; then
# echo "Skipping workspace directory $dir"
# continue
# fi
# get package name from Cargo.toml using cargo metadata
PACKAGE_NAME=$(cargo metadata --format-version 1 --no-deps --manifest-path "$dir/Cargo.toml" | jq -r '.packages[0].name')
if [ -z "$PACKAGE_NAME" ]; then
echo "Could not determine package name for $dir"
continue
fi
# go into the directory
cd "$dir" || continue
# generate the documentation using rustdoc-md
cargo install rustdoc-md --git https://github.com/qitechgmbh/rustdoc-md --rev 6dc34f9c9d878799d9c1b4f2d2bcbb1f2757ede7
RUSTC_BOOTSTRAP=1 RUSTDOCFLAGS="-Z unstable-options --output-format json" cargo doc --no-deps
rustdoc-md --path "${REPO_ROOT}/target/doc/${PACKAGE_NAME}.json" --output ./rustdoc.md
# stage the generated documentation file
git add "rustdoc.md"
# go back to the repository root
cd "$REPO_ROOT" || exit
done
git rm --cached rustdoc.md 2>/dev/null
rm rustdoc.md
exit 0
+3 -3
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
ethercrab = { git = "https://github.com/v-morlock/ethercrab", branch = "main" }
ethercrab = "0.6"
ethercat_hal = { path = "../ethercat-hal" }
control_core = { path = "../control-core" }
control_core_derive = { path = "../control-core-derive" }
@@ -15,13 +15,13 @@ anyhow = "1.0.100"
bitvec = "1.0.1"
lazy_static = "1.5.0"
euclid = "0.22.13"
euclid = "0.22.11"
# concurrency
smol = "2.0.2"
# web
serde_json = "1.0.149"
serde_json = "1.0.143"
socketioxide = { version = "0.17.2", features = ["msgpack"] }
# serial
+3 -7
View File
@@ -1,6 +1,7 @@
use crate::{
MachineNewHardware, MachineNewParams, MachineNewTrait, get_ethercat_device,
validate_no_role_dublicates, validate_same_machine_identification_unique,
get_subdevice_by_index, validate_no_role_dublicates,
validate_same_machine_identification_unique,
};
use super::{
@@ -91,12 +92,7 @@ impl MachineNewTrait for AquaPathV1 {
pdo_assignment: EL5152PredefinedPdoAssignment::Frequency,
..Default::default()
};
let subdevice =
get_ethercat_device::<EL5152>(hardware, params, 4, [EL5152_IDENTITY_A].to_vec())
.await?
.1;
let subdevice = get_subdevice_by_index(hardware.subdevices, 4)?;
el5152
.write()
.await
+3 -4
View File
@@ -32,11 +32,10 @@ pub mod motor_test_machine;
pub mod registry;
pub mod serial;
pub mod test_machine;
pub mod wago_8ch_dio_test_machine;
pub mod wago_ai_test_machine;
pub mod wago_do_test_machine;
pub mod wago_power;
pub mod winder2;
pub mod vacuum;
pub const VENDOR_QITECH: u16 = 0x0001;
pub const MACHINE_WINDER_V1: u16 = 0x0002;
@@ -52,10 +51,10 @@ pub const IP20_TEST_MACHINE: u16 = 0x0034;
pub const ANALOG_INPUT_TEST_MACHINE: u16 = 0x0035;
pub const WAGO_AI_TEST_MACHINE: u16 = 0x0036;
pub const DIGITAL_INPUT_TEST_MACHINE: u16 = 0x0040;
pub const WAGO_8CH_IO_TEST_MACHINE: u16 = 0x0041;
pub const MACHINE_VACUUM: u16 = 0x0037;
pub const MOTOR_TEST_MACHINE: u16 = 0x0011;
pub const WAGO_DO_TEST_MACHINE: u16 = 0x000E;
use serde_json::Value;
use smol::lock::RwLock;
+2 -3
View File
@@ -63,8 +63,7 @@ impl MachineIdentification {
x if x == IP20_TEST_MACHINE => "ip20_test_machine".to_string(),
x if x == ANALOG_INPUT_TEST_MACHINE => "analog_input_test_machine".to_string(),
x if x == WAGO_AI_TEST_MACHINE => "wago_ai_test_machine".to_string(),
x if x == WAGO_DO_TEST_MACHINE => "wago_do_test_machine".to_string(),
x if x == MACHINE_VACUUM => "vacuum".to_string(),
_ => unreachable!("Unknown machine id"),
}
}
@@ -184,12 +183,12 @@ use crate::MACHINE_EXTRUDER_V1;
use crate::MACHINE_EXTRUDER_V2;
use crate::MACHINE_LASER_V1;
use crate::MACHINE_MOCK;
use crate::MACHINE_VACUUM;
use crate::MACHINE_WAGO_POWER_V1;
use crate::MACHINE_WINDER_V1;
use crate::TEST_MACHINE;
use crate::VENDOR_QITECH;
use crate::WAGO_AI_TEST_MACHINE;
use crate::WAGO_DO_TEST_MACHINE;
#[derive(Debug)]
pub struct MachineIdentificationAddresses {
+5 -6
View File
@@ -1,9 +1,7 @@
use crate::analog_input_test_machine::AnalogInputTestMachine;
use crate::digital_input_test_machine::DigitalInputTestMachine;
use crate::ip20_test_machine::IP20TestMachine;
use crate::wago_8ch_dio_test_machine::Wago8chDigitalIOTestMachine;
use crate::wago_ai_test_machine::WagoAiTestMachine;
use crate::wago_do_test_machine::WagoDOTestMachine;
#[cfg(feature = "mock-machine")]
use crate::{
extruder1::mock::ExtruderV2 as ExtruderV2Mock1, extruder2::mock::ExtruderV2 as ExtruderV2Mock2,
@@ -22,6 +20,7 @@ use crate::{
winder2::Winder2,
};
use crate::vacuum::VacuumMachine;
use crate::test_machine::TestMachine;
use lazy_static::lazy_static;
@@ -139,10 +138,10 @@ lazy_static! {
mc.register::<DigitalInputTestMachine>(DigitalInputTestMachine::MACHINE_IDENTIFICATION);
mc.register::<WagoDOTestMachine>(WagoDOTestMachine::MACHINE_IDENTIFICATION);
mc.register::<Wago8chDigitalIOTestMachine>(
Wago8chDigitalIOTestMachine::MACHINE_IDENTIFICATION,
);
#[cfg(not(feature = "mock-machine"))]
mc.register::<VacuumMachine>(VacuumMachine::MACHINE_IDENTIFICATION);
mc
};
+103
View File
@@ -0,0 +1,103 @@
use super::VacuumMachine;
use crate::{MachineAct, MachineMessage, MachineValues};
use std::{ops::Add, time::{Duration, Instant}};
impl MachineAct for VacuumMachine
{
fn act(&mut self, now: Instant)
{
if let Ok(msg) = self.api_receiver.try_recv()
{
self.act_machine_message(msg);
}
// Emit state at 30 Hz
if now.duration_since(self.last_state_emit) > Duration::from_secs_f64(1.0 / 30.0)
{
match self.mode
{
super::Mode::Idle => {},
super::Mode::On => {},
super::Mode::Auto => {},
super::Mode::Interval =>
{
match self.interval_state
{
true =>
{
if Instant::now() >= self.interval_expiry
{
self.interval_expiry = Instant::now().add(Duration::from_secs_f64(self.interval_time_off));
self.interval_state = false;
self.set_running(false);
}
},
false =>
{
if Instant::now() >= self.interval_expiry
{
self.interval_expiry = Instant::now().add(Duration::from_secs_f64(self.interval_time_on));
self.interval_state = true;
self.set_running(true);
}
},
}
},
}
self.emit_state();
self.last_state_emit = now;
}
// Emit live values at 10 Hz
if now.duration_since(self.last_live_values_emit) > Duration::from_secs_f64(1.0 / 10.0)
{
self.emit_live_values();
self.last_live_values_emit = now;
}
}
fn act_machine_message(&mut self, msg: MachineMessage)
{
match msg
{
MachineMessage::SubscribeNamespace(namespace) =>
{
self.namespace.namespace = Some(namespace);
self.emit_state();
self.emit_live_values();
}
MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
MachineMessage::HttpApiJsonRequest(value) =>
{
use crate::MachineApi;
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(_machine_connection) =>
{
// Does not connect to any Machine; do nothing
}
MachineMessage::DisconnectMachine(_machine_connection) =>
{
// Does not connect to any Machine; do nothing
}
MachineMessage::RequestValues(sender) =>
{
sender
.send_blocking(MachineValues
{
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::to_value(self.get_live_values())
.expect("Failed to serialize live values"),
})
.expect("Failed to send values");
sender.close();
}
}
}
}
+122
View File
@@ -0,0 +1,122 @@
use super::VacuumMachine;
use crate::{MachineApi, MachineMessage, vacuum::Mode};
use anyhow::anyhow;
use control_core::socketio::{
event::{Event, GenericEvent},
namespace::{
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_first_and_last_event,
},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
#[derive(Serialize, Debug, Clone)]
pub struct StateEvent
{
pub mode: Mode,
pub interval_time_off: f64,
pub interval_time_on: f64,
pub running: bool,
}
impl StateEvent
{
pub fn build(&self) -> Event<Self>
{
Event::new("StateEvent", self.clone())
}
}
#[derive(Serialize, Debug, Clone)]
pub struct LiveValuesEvent
{
pub remaining_time: f64,
}
impl LiveValuesEvent
{
pub fn build(&self) -> Event<Self>
{
Event::new("LiveValuesEvent", self.clone())
}
}
pub enum VacuumMachineEvents
{
State(Event<StateEvent>),
LiveValues(Event<LiveValuesEvent>),
}
#[derive(Deserialize, Debug)]
#[serde(tag = "action", content = "value")]
pub enum Mutation
{
SetMode(Mode),
SetIntervalTimeOff(f64),
SetIntervalTimeOn(f64),
}
#[derive(Debug, Clone)]
pub struct VacuumMachineNamespace
{
pub namespace: Option<Namespace>,
}
impl NamespaceCacheingLogic<VacuumMachineEvents> for VacuumMachineNamespace
{
fn emit(&mut self, events: VacuumMachineEvents)
{
let event = Arc::new(events.event_value());
let buffer_fn = events.event_cache_fn();
if let Some(ns) = &mut self.namespace
{
ns.emit(event, &buffer_fn);
}
}
}
impl CacheableEvents<VacuumMachineEvents> for VacuumMachineEvents
{
fn event_value(&self) -> GenericEvent
{
match self
{
VacuumMachineEvents::State(event) => event.clone().into(),
VacuumMachineEvents::LiveValues(event) => event.clone().into(),
}
}
fn event_cache_fn(&self) -> CacheFn
{
cache_first_and_last_event()
}
}
impl MachineApi for VacuumMachine
{
fn api_get_sender(&self) -> smol::channel::Sender<MachineMessage>
{
self.api_sender.clone()
}
fn api_mutate(&mut self, request_body: Value) -> Result<(), anyhow::Error>
{
let mutation: Mutation = serde_json::from_value(request_body)?;
match mutation
{
Mutation::SetMode(value) => self.set_mode(value),
Mutation::SetIntervalTimeOff(value) => self.set_interval_time_off(value),
Mutation::SetIntervalTimeOn(value) => self.set_interval_time_on(value),
}
Ok(())
}
fn api_event_namespace(&mut self) -> Option<Namespace>
{
self.namespace.namespace.clone()
}
}
+209
View File
@@ -0,0 +1,209 @@
use crate::vacuum::api::{VacuumMachineEvents, LiveValuesEvent, StateEvent};
use crate::machine_identification::{MachineIdentification, MachineIdentificationUnique};
use crate::{AsyncThreadMessage, MACHINE_VACUUM, Machine, MachineMessage};
use control_core::socketio::namespace::NamespaceCacheingLogic;
use ethercat_hal::io::digital_input::DigitalInput;
use ethercat_hal::io::digital_output::DigitalOutput;
use serde::{Deserialize, Serialize};
use smol::channel::{Receiver, Sender};
use std::ops::Add;
use std::time::{Duration, Instant};
pub mod act;
pub mod api;
pub mod new;
use crate::vacuum::api::VacuumMachineNamespace;
use crate::{VENDOR_QITECH};
#[derive(Debug)]
pub struct VacuumMachine
{
pub api_receiver: Receiver<MachineMessage>,
pub api_sender: Sender<MachineMessage>,
pub machine_identification_unique: MachineIdentificationUnique,
pub namespace: VacuumMachineNamespace,
pub last_state_emit: Instant,
pub last_live_values_emit: Instant,
pub outputs: [bool; 8],
pub inputs: [bool; 8],
pub main_sender: Option<Sender<AsyncThreadMessage>>,
pub douts: [DigitalOutput; 8],
pub dins: [DigitalInput; 8],
pub mode: Mode,
pub interval_time_off: f64,
pub interval_time_on: f64,
// machine state:
pub interval_state: bool,
pub interval_expiry: Instant,
pub running: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[repr(u8)]
pub enum Mode
{
Idle,
On,
Auto,
Interval,
}
impl TryFrom<u8> for Mode
{
type Error = ();
fn try_from(v: u8) -> Result<Self, Self::Error>
{
match v
{
0 => Ok(Mode::Idle),
1 => Ok(Mode::On),
2 => Ok(Mode::Auto),
3 => Ok(Mode::Interval),
_ => Err(()),
}
}
}
impl Machine for VacuumMachine
{
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique
{
self.machine_identification_unique.clone()
}
fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>>
{
self.main_sender.clone()
}
}
impl VacuumMachine
{
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
vendor: VENDOR_QITECH,
machine: MACHINE_VACUUM,
};
}
impl VacuumMachine
{
// default functions
pub fn get_state(&self) -> StateEvent
{
StateEvent {
mode: self.mode.clone(),
interval_time_off: self.interval_time_off,
interval_time_on: self.interval_time_on,
running: self.running,
}
}
pub fn emit_state(&mut self)
{
let event = self.get_state().build();
self.namespace.emit(VacuumMachineEvents::State(event));
}
pub fn get_live_values(&self) -> LiveValuesEvent
{
LiveValuesEvent
{
remaining_time: match self.mode
{
Mode::Idle => 0.0,
Mode::On => 0.0,
Mode::Auto => 999.0,
Mode::Interval => (self.interval_expiry - Instant::now()).as_secs_f64(),
}
}
}
pub fn emit_live_values(&mut self)
{
let event = self.get_live_values().build();
self.namespace
.emit(VacuumMachineEvents::LiveValues(event));
}
fn set_running(&mut self, value: bool)
{
if self.running == value { return; }
self.douts[0].set(value);
self.running = value;
tracing::warn!("set running: {}", value);
}
// getter/setter for api
pub fn set_mode(&mut self, value: Mode)
{
if self.mode == value { return; }
self.mode = value;
match self.mode
{
Mode::Idle =>
{
self.set_running(false);
},
Mode::On =>
{
self.set_running(true);
},
Mode::Auto =>
{
//TODO: implement auto mode
// if self.running { self.douts[0].set(false); }
},
Mode::Interval =>
{
self.interval_state = true;
self.interval_expiry = Instant::now().add(Duration::from_secs_f64(self.interval_time_on));
self.set_running(true);
},
}
self.emit_state();
}
pub fn set_interval_time_off(&mut self, value: f64)
{
if self.interval_time_off == value { return; }
self.interval_time_off = value.clamp(1.0, 120.0);
let new_expiry = Instant::now().add(Duration::from_secs_f64(value));
if self.mode == Mode::Interval &&
!self.interval_state &&
new_expiry < self.interval_expiry
{
self.interval_expiry = new_expiry;
}
self.emit_state();
}
pub fn set_interval_time_on(&mut self, value: f64)
{
if self.interval_time_on == value { return; }
self.interval_time_on = value.clamp(1.0, 120.0);
let new_expiry = Instant::now().add(Duration::from_secs_f64(value));
if self.mode == Mode::Interval &&
self.interval_state &&
new_expiry < self.interval_expiry
{
self.interval_expiry = new_expiry;
}
self.emit_state();
}
}
+101
View File
@@ -0,0 +1,101 @@
use crate::vacuum::{Mode, VacuumMachine};
use crate::vacuum::api::VacuumMachineNamespace;
use smol::block_on;
use std::time::Instant;
use crate::{
MachineNewHardware, MachineNewParams, MachineNewTrait, get_ethercat_device,
validate_no_role_dublicates, validate_same_machine_identification_unique,
};
use anyhow::Error;
use ethercat_hal::devices::wago_modules::ip20_ec_di8_do8::{
IP20_EC_DI8_DO8_IDENTITY, IP20EcDi8Do8, IP20EcDi8Do8InputPort, IP20EcDi8Do8OutputPort,
};
use ethercat_hal::io::digital_input::DigitalInput;
use ethercat_hal::io::digital_output::DigitalOutput;
impl MachineNewTrait for VacuumMachine {
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
// validate general stuff
let device_identification = params
.device_group
.iter()
.map(|device_identification| device_identification.clone())
.collect::<Vec<_>>();
validate_same_machine_identification_unique(&device_identification)?;
validate_no_role_dublicates(&device_identification)?;
let hardware = match &params.hardware
{
MachineNewHardware::Ethercat(x) => x, _ => {
return Err(anyhow::anyhow!(
"[{}::VacuumMachine::new] MachineNewHardware is not Ethercat",
module_path!()
));
}
};
block_on(async
{
let ip20_device = get_ethercat_device::<IP20EcDi8Do8>(
hardware,
params,
0,
[IP20_EC_DI8_DO8_IDENTITY].to_vec(),
).await?.0;
// Create digital outputs
let do1 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO1);
let do2 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO2);
let do3 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO3);
let do4 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO4);
let do5 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO5);
let do6 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO6);
let do7 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO7);
let do8 = DigitalOutput::new(ip20_device.clone(), IP20EcDi8Do8OutputPort::DO8);
// Create digital inputs
let di1 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI1);
let di2 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI2);
let di3 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI3);
let di4 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI4);
let di5 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI5);
let di6 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI6);
let di7 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI7);
let di8 = DigitalInput::new(ip20_device.clone(), IP20EcDi8Do8InputPort::DI8);
let (sender, receiver) = smol::channel::unbounded();
let mut machine = Self
{
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
namespace: VacuumMachineNamespace
{
namespace: params.namespace.clone(),
},
last_state_emit: Instant::now(),
last_live_values_emit: Instant::now(),
outputs: [false; 8],
inputs: [false; 8],
main_sender: params.main_thread_channel.clone(),
douts: [do1, do2, do3, do4, do5, do6, do7, do8],
dins: [di1, di2, di3, di4, di5, di6, di7, di8],
running: false,
mode: Mode::Idle,
interval_time_off: 10.0,
interval_time_on: 10.0,
interval_state: false,
interval_expiry: Instant::now(),
};
machine.emit_state();
machine.emit_live_values();
Ok(machine)
})
}
}
@@ -1,46 +0,0 @@
use super::Wago8chDigitalIOTestMachine;
use crate::{MachineAct, MachineMessage, MachineValues};
use std::time::{Duration, Instant};
impl MachineAct for Wago8chDigitalIOTestMachine {
fn act(&mut self, now: Instant) {
if let Ok(msg) = self.api_receiver.try_recv() {
self.act_machine_message(msg);
}
if now.duration_since(self.last_state_emit) > Duration::from_secs_f64(1.0 / 30.0) {
self.emit_state();
self.last_state_emit = now;
}
}
fn act_machine_message(&mut self, msg: MachineMessage) {
match msg {
MachineMessage::SubscribeNamespace(namespace) => {
self.namespace.namespace = Some(namespace);
self.emit_state();
}
MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
MachineMessage::HttpApiJsonRequest(value) => {
use crate::MachineApi;
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(_machine_connection) => {
// Does not connect to any Machine; do nothing
}
MachineMessage::DisconnectMachine(_machine_connection) => {
// Does not connect to any Machine; do nothing
}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::Value::Null,
})
.expect("Failed to send values");
sender.close();
}
}
}
}
@@ -1,79 +0,0 @@
use std::sync::Arc;
use control_core::socketio::{
event::{Event, GenericEvent},
namespace::{
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_first_and_last_event,
},
};
use serde::{Deserialize, Serialize};
use crate::{MachineApi, wago_8ch_dio_test_machine::Wago8chDigitalIOTestMachine};
#[derive(Serialize, Debug, Clone)]
pub struct StateEvent {
pub digital_input: [bool; 8],
pub digital_output: [bool; 8],
}
impl StateEvent {
pub fn build(&self) -> Event<Self> {
Event::new("StateEvent", self.clone())
}
}
pub enum Wago8chDigitalIOTestMachineEvents {
State(Event<StateEvent>),
}
#[derive(Deserialize)]
#[serde(tag = "action", content = "value")]
pub enum Mutation {
SetOutput { index: usize, value: bool },
}
#[derive(Debug, Clone)]
pub struct Wago8chDigitalIOTestMachineNamespace {
pub namespace: Option<Namespace>,
}
impl NamespaceCacheingLogic<Wago8chDigitalIOTestMachineEvents>
for Wago8chDigitalIOTestMachineNamespace
{
fn emit(&mut self, events: Wago8chDigitalIOTestMachineEvents) {
let event = Arc::new(events.event_value());
let buffer_fn = events.event_cache_fn();
if let Some(ns) = &mut self.namespace {
ns.emit(event, &buffer_fn);
}
}
}
impl CacheableEvents<Wago8chDigitalIOTestMachineEvents> for Wago8chDigitalIOTestMachineEvents {
fn event_value(&self) -> GenericEvent {
match self {
Wago8chDigitalIOTestMachineEvents::State(event) => event.clone().into(),
}
}
fn event_cache_fn(&self) -> CacheFn {
cache_first_and_last_event()
}
}
impl MachineApi for Wago8chDigitalIOTestMachine {
fn api_get_sender(&self) -> smol::channel::Sender<crate::MachineMessage> {
self.api_sender.clone()
}
fn api_mutate(&mut self, value: serde_json::Value) -> Result<(), anyhow::Error> {
let mutation: Mutation = serde_json::from_value(value)?;
match mutation {
Mutation::SetOutput { index, value } => self.set_output(index, value),
}
Ok(())
}
fn api_event_namespace(&mut self) -> Option<Namespace> {
self.namespace.namespace.clone()
}
}
@@ -1,80 +0,0 @@
use std::time::Instant;
use control_core::socketio::namespace::NamespaceCacheingLogic;
use ethercat_hal::io::{digital_input::DigitalInput, digital_output::DigitalOutput};
use smol::channel::{Receiver, Sender};
use crate::{
AsyncThreadMessage, Machine, MachineMessage, VENDOR_QITECH, WAGO_8CH_IO_TEST_MACHINE,
machine_identification::{MachineIdentification, MachineIdentificationUnique},
wago_8ch_dio_test_machine::api::{
StateEvent, Wago8chDigitalIOTestMachineEvents, Wago8chDigitalIOTestMachineNamespace,
},
};
pub mod act;
pub mod api;
pub mod new;
#[derive(Debug)]
pub struct Wago8chDigitalIOTestMachine {
pub api_receiver: Receiver<MachineMessage>,
pub api_sender: Sender<MachineMessage>,
pub machine_identification_unique: MachineIdentificationUnique,
pub main_sender: Option<Sender<AsyncThreadMessage>>,
pub namespace: Wago8chDigitalIOTestMachineNamespace,
pub last_state_emit: Instant,
pub digital_output: [DigitalOutput; 8],
pub digital_input: [DigitalInput; 8],
}
impl Machine for Wago8chDigitalIOTestMachine {
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
self.machine_identification_unique.clone()
}
fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>> {
self.main_sender.clone()
}
}
impl Wago8chDigitalIOTestMachine {
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
vendor: VENDOR_QITECH,
machine: WAGO_8CH_IO_TEST_MACHINE,
};
pub fn get_state(&self) -> StateEvent {
StateEvent {
digital_input: (0..8)
.map(|i| {
self.digital_input[i]
.get_value()
.expect("digital input value should be available")
})
.collect::<Vec<_>>()
.try_into()
.expect("bool vector into array[8] should work"),
digital_output: [
self.digital_output[0].get(),
self.digital_output[1].get(),
self.digital_output[2].get(),
self.digital_output[3].get(),
self.digital_output[4].get(),
self.digital_output[5].get(),
self.digital_output[6].get(),
self.digital_output[7].get(),
],
}
}
pub fn emit_state(&mut self) {
let event = self.get_state().build();
self.namespace
.emit(Wago8chDigitalIOTestMachineEvents::State(event));
}
pub fn set_output(&mut self, i: usize, value: bool) {
self.digital_output[i].set(value);
}
}
@@ -1,105 +0,0 @@
use std::{sync::Arc, time::Instant};
use anyhow::Error;
use ethercat_hal::{
devices::{
EthercatDevice, downcast_device,
wago_750_354::{WAGO_750_354_IDENTITY_A, Wago750_354},
wago_modules::wago_750_1506::{
Wago750_1506, Wago750_1506InputPort, Wago750_1506OutputPort,
},
},
io::{digital_input::DigitalInput, digital_output::DigitalOutput},
};
use smol::{block_on, lock::RwLock};
use crate::{
MachineNewHardware, MachineNewParams, MachineNewTrait, get_ethercat_device,
validate_no_role_dublicates, validate_same_machine_identification_unique,
wago_8ch_dio_test_machine::{
Wago8chDigitalIOTestMachine, api::Wago8chDigitalIOTestMachineNamespace,
},
};
impl MachineNewTrait for Wago8chDigitalIOTestMachine {
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
// validate general stuff
let device_identification = params
.device_group
.iter()
.map(|device_identification| device_identification.clone())
.collect::<Vec<_>>();
validate_same_machine_identification_unique(&device_identification)?;
validate_no_role_dublicates(&device_identification)?;
let hardware = match &params.hardware {
MachineNewHardware::Ethercat(x) => x,
_ => {
return Err(anyhow::anyhow!(
"[{}::EtherCATMachine/TestMachine::new] MachineNewHardware is not Ethercat",
module_path!()
));
}
};
block_on(async {
// Example usage of a Wago Coupler and a 750-402 in the first slot, where the Output Port 1,2,3,4 is used
let _wago_750_354 = get_ethercat_device::<Wago750_354>(
hardware,
params,
0,
[WAGO_750_354_IDENTITY_A].to_vec(),
)
.await?;
let modules = Wago750_354::initialize_modules(_wago_750_354.1).await?;
let mut coupler = _wago_750_354.0.write().await;
for module in modules {
coupler.set_module(module);
}
coupler.init_slot_modules(_wago_750_354.1);
let dev = coupler.slot_devices.get(0).unwrap().clone().unwrap();
let wago750_1506: Arc<RwLock<Wago750_1506>> =
downcast_device::<Wago750_1506>(dev).await?;
let di1 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI1);
let di2 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI2);
let di3 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI3);
let di4 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI4);
let di5 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI5);
let di6 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI6);
let di7 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI7);
let di8 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI8);
let do1 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO1);
let do2 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO2);
let do3 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO3);
let do4 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO4);
let do5 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO5);
let do6 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO6);
let do7 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO7);
let do8 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO8);
drop(coupler);
let (sender, receiver) = smol::channel::unbounded();
let mut my_test = Self {
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
namespace: Wago8chDigitalIOTestMachineNamespace {
namespace: params.namespace.clone(),
},
last_state_emit: Instant::now(),
main_sender: params.main_thread_channel.clone(),
digital_output: [do1, do2, do3, do4, do5, do6, do7, do8],
digital_input: [di1, di2, di3, di4, di5, di6, di7, di8],
};
my_test.emit_state();
Ok(my_test)
})
}
}
-46
View File
@@ -1,46 +0,0 @@
use super::WagoDOTestMachine;
use crate::{MachineAct, MachineMessage, MachineValues};
use std::time::{Duration, Instant};
impl MachineAct for WagoDOTestMachine {
fn act(&mut self, now: Instant) {
if let Ok(msg) = self.api_receiver.try_recv() {
self.act_machine_message(msg);
}
if now.duration_since(self.last_state_emit) > Duration::from_secs_f64(1.0 / 30.0) {
self.emit_state();
self.last_state_emit = now;
}
}
fn act_machine_message(&mut self, msg: MachineMessage) {
match msg {
MachineMessage::SubscribeNamespace(namespace) => {
self.namespace.namespace = Some(namespace);
self.emit_state();
}
MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
MachineMessage::HttpApiJsonRequest(value) => {
use crate::MachineApi;
let _res = self.api_mutate(value);
}
MachineMessage::ConnectToMachine(_machine_connection) => {
// Does not connect to any Machine; do nothing
}
MachineMessage::DisconnectMachine(_machine_connection) => {
// Does not connect to any Machine; do nothing
}
MachineMessage::RequestValues(sender) => {
sender
.send_blocking(MachineValues {
state: serde_json::to_value(self.get_state())
.expect("Failed to serialize state"),
live_values: serde_json::Value::Null,
})
.expect("Failed to send values");
sender.close();
}
}
}
}
-84
View File
@@ -1,84 +0,0 @@
use super::WagoDOTestMachine;
use crate::{MachineApi, MachineMessage};
use control_core::socketio::{
event::{Event, GenericEvent},
namespace::{
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_first_and_last_event,
},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
#[derive(Serialize, Debug, Clone)]
pub struct StateEvent {
pub led_on: [bool; 8],
}
impl StateEvent {
pub fn build(&self) -> Event<Self> {
Event::new("StateEvent", self.clone())
}
}
pub enum WagoDOTestMachineEvents {
State(Event<StateEvent>),
}
#[derive(Deserialize)]
#[serde(tag = "action", content = "value")]
pub enum Mutation {
SetLed { index: usize, on: bool },
SetAllLeds { on: bool },
}
#[derive(Debug, Clone)]
pub struct WagoDOTestMachineNamespace {
pub namespace: Option<Namespace>,
}
impl NamespaceCacheingLogic<WagoDOTestMachineEvents> for WagoDOTestMachineNamespace {
fn emit(&mut self, events: WagoDOTestMachineEvents) {
let event = Arc::new(events.event_value());
let buffer_fn = events.event_cache_fn();
if let Some(ns) = &mut self.namespace {
ns.emit(event, &buffer_fn);
}
}
}
impl CacheableEvents<WagoDOTestMachineEvents> for WagoDOTestMachineEvents {
fn event_value(&self) -> GenericEvent {
match self {
WagoDOTestMachineEvents::State(event) => event.clone().into(),
}
}
fn event_cache_fn(&self) -> CacheFn {
cache_first_and_last_event()
}
}
impl MachineApi for WagoDOTestMachine {
fn api_get_sender(&self) -> smol::channel::Sender<MachineMessage> {
self.api_sender.clone()
}
fn api_mutate(&mut self, request_body: Value) -> Result<(), anyhow::Error> {
let mutation: Mutation = serde_json::from_value(request_body)?;
match mutation {
Mutation::SetLed { index, on } => self.set_led(index, on),
Mutation::SetAllLeds { on } => self.set_all_leds(on),
}
for (led, &on) in self.douts.iter().zip(self.led_on.iter()) {
led.set(on);
}
Ok(())
}
fn api_event_namespace(&mut self) -> Option<Namespace> {
self.namespace.namespace.clone()
}
}
-69
View File
@@ -1,69 +0,0 @@
use std::time::Instant;
use control_core::socketio::namespace::NamespaceCacheingLogic;
use ethercat_hal::io::digital_output::DigitalOutput;
use smol::channel::{Receiver, Sender};
use crate::{
AsyncThreadMessage, Machine, MachineMessage, VENDOR_QITECH, WAGO_DO_TEST_MACHINE,
machine_identification::{MachineIdentification, MachineIdentificationUnique},
wago_do_test_machine::api::{StateEvent, WagoDOTestMachineEvents, WagoDOTestMachineNamespace},
};
pub mod act;
pub mod api;
pub mod new;
#[derive(Debug)]
pub struct WagoDOTestMachine {
pub api_receiver: Receiver<MachineMessage>,
pub api_sender: Sender<MachineMessage>,
pub machine_identification_unique: MachineIdentificationUnique,
pub main_sender: Option<Sender<AsyncThreadMessage>>,
pub namespace: WagoDOTestMachineNamespace,
pub last_state_emit: Instant,
pub led_on: [bool; 8],
pub douts: [DigitalOutput; 8],
}
impl Machine for WagoDOTestMachine {
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
self.machine_identification_unique.clone()
}
fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>> {
self.main_sender.clone()
}
}
impl WagoDOTestMachine {
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
vendor: VENDOR_QITECH,
machine: WAGO_DO_TEST_MACHINE,
};
pub fn get_state(&self) -> StateEvent {
StateEvent {
led_on: self.led_on,
}
}
pub fn emit_state(&mut self) {
let event = self.get_state().build();
self.namespace.emit(WagoDOTestMachineEvents::State(event));
}
/// Set the state of a specific digital output
pub fn set_led(&mut self, index: usize, on: bool) {
if index < self.led_on.len() {
self.led_on[index] = on;
self.emit_state();
}
}
/// Set all digital outputs at once
pub fn set_all_leds(&mut self, on: bool) {
self.led_on = [on; 8];
self.emit_state();
}
}
-101
View File
@@ -1,101 +0,0 @@
use crate::wago_do_test_machine::WagoDOTestMachine;
use crate::wago_do_test_machine::api::WagoDOTestMachineNamespace;
use smol::block_on;
use std::time::Instant;
use crate::{
MachineNewHardware, MachineNewParams, MachineNewTrait, get_ethercat_device,
validate_no_role_dublicates, validate_same_machine_identification_unique,
};
use anyhow::Error;
use ethercat_hal::devices::wago_750_354::{WAGO_750_354_IDENTITY_A, Wago750_354};
use ethercat_hal::devices::wago_modules::wago_750_530::{Wago750_530, Wago750_530Port};
use ethercat_hal::devices::{EthercatDevice, downcast_device};
use ethercat_hal::io::digital_output::DigitalOutput;
use smol::lock::RwLock;
use std::sync::Arc;
impl MachineNewTrait for WagoDOTestMachine {
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
let device_identification = params
.device_group
.iter()
.map(|device_identification| device_identification.clone())
.collect::<Vec<_>>();
validate_same_machine_identification_unique(&device_identification)?;
validate_no_role_dublicates(&device_identification)?;
let hardware = match &params.hardware {
MachineNewHardware::Ethercat(x) => x,
_ => {
return Err(anyhow::anyhow!(
"[{}::MachineNewTrait/WagoDOTestMachine::new] MachineNewHardware is not Ethercat",
module_path!()
));
}
};
block_on(async {
let _wago_750_354 = get_ethercat_device::<Wago750_354>(
hardware,
params,
0,
[WAGO_750_354_IDENTITY_A].to_vec(),
)
.await?;
let modules = Wago750_354::initialize_modules(_wago_750_354.1).await?;
let mut coupler = _wago_750_354.0.write().await;
for module in modules {
coupler.set_module(module);
}
coupler.init_slot_modules(_wago_750_354.1);
let dev = coupler
.slot_devices
.get(0)
.ok_or_else(|| {
anyhow::anyhow!(
"[{}::MachineNewTrait/WagoDOTestMachine::new] Expected Wago 750-530 module in slot 0, but slot 0 is not configured",
module_path!()
)
})?
.clone()
.ok_or_else(|| {
anyhow::anyhow!(
"[{}::MachineNewTrait/WagoDOTestMachine::new] Expected Wago 750-530 module in slot 0, but slot 0 is empty or no device is present",
module_path!()
)
})?;
let wago750_530: Arc<RwLock<Wago750_530>> = downcast_device::<Wago750_530>(dev).await?;
let do1 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port1);
let do2 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port2);
let do3 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port3);
let do4 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port4);
let do5 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port5);
let do6 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port6);
let do7 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port7);
let do8 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port8);
drop(coupler);
let (sender, receiver) = smol::channel::unbounded();
let mut machine = Self {
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
namespace: WagoDOTestMachineNamespace {
namespace: params.namespace.clone(),
},
last_state_emit: Instant::now(),
led_on: [false; 8],
main_sender: params.main_thread_channel.clone(),
douts: [do1, do2, do3, do4, do5, do6, do7, do8],
};
machine.emit_state();
Ok(machine)
})
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ buildNpmPackage rec {
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
makeCacheWritable = true;
npmDepsHash = "sha256-rnaAsSkoTRoBgLCTF8ZFpi7uUQ+8W+u3qoMxsptX6NU=";
npmDepsHash = "sha256-wzVjsgcZsViPOyQKlKTb5gJIeY416KyCSiheSXSRZoc=";
npmFlags = [ "--no-audit" "--no-fund" ];
installPhase = ''
-28
View File
@@ -1,28 +0,0 @@
dist
.wrangler
.output
.vercel
.netlify
.vinxi
app.config.timestamp_*.js
# Environment
.env
.env*.local
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
# Temp
gitignore
# System Files
.DS_Store
Thumbs.db
-298
View File
@@ -1,298 +0,0 @@
# Electron → SolidJS Migration Analysis
## Why SolidJS is a Good Fit
The electron frontend has two fundamental issues that SolidJS directly solves:
1. **React's reconciler triggers re-renders on any state change** — the codebase added an entire `ThrottledStoreUpdater` class (100+ lines) to cap re-renders at 30 FPS and prevent UI jank from high-frequency socket events.
In SolidJS, reactive updates are surgical: only the DOM nodes that actually depend on a changed signal update. No reconciliation pass, no virtual DOM diff. The `batch()` primitive coalesces multiple signal writes into one reactive pass — replaces `ThrottledStoreUpdater` entirely with zero boilerplate.
2. **React's hook rules and closure gotchas** force state management up into libraries (Zustand) and create a deep abstraction stack. SolidJS primitives (`createSignal`, `createStore`, `onCleanup`) live exactly where the components live and have no closure staleness issues.
---
## Abstraction Comparison: WebSocket Layer
### Electron (React)
Adding a machine namespace requires touching **5+ files**:
| File | Lines | Purpose |
|------|-------|---------|
| `socketioStore.ts` | 515 | Base infrastructure: ThrottledStoreUpdater, createNamespaceHookImplementation, namespace lifecycle |
| `{machine}Namespace.ts` | ~77 | Zod schemas, Zustand store, message handler, hook wrapper |
| `use{Machine}.ts` | ~95 | Route param parsing, optimistic state, mutation helpers |
| `routes.tsx` | +~15 | Route definitions |
| `properties.ts` | +~20 | Machine properties entry |
**Total per machine: ~220+ lines of mandatory boilerplate before writing any UI.**
### SolidJS (this PoC)
Adding a machine namespace requires:
```ts
// namespaces/myMachine.ts — ~25 lines total
export function createMyMachineNamespace(vendor, machine, serial) {
return createNamespace(
machineNamespacePath(vendor, machine, serial),
(event, set) => {
if (event.name === "StateEvent") set("data", event.data);
},
{ data: null },
);
}
```
Then in the page component:
```tsx
const [state] = createMyMachineNamespace(VENDOR, MACHINE, serial());
// state().data is now reactive — that's it
```
**The socket connects on component mount, disconnects on unmount, updates are reactive.
No store, no hook factory, no ThrottledStoreUpdater, no ref-counting.**
---
## The Core Primitive (`src/lib/socketio.ts`)
```
createNamespace(path, handler, initialState) → [signal, socket]
```
- `batch()` replaces `ThrottledStoreUpdater` — SolidJS batches reactive updates automatically
- `onCleanup()` replaces manual `useEffect` teardown — socket disconnects when scope disposed
- The signal IS the store — no Zustand, no Immer needed for simple machine state
This single 80-line file replaces `socketioStore.ts` (515 lines) for new machines.
---
## State Management
### Electron
- Zustand (global singletons + per-namespace stores)
- Immer (immutable updates)
- `useSyncExternalStore` (bridge Zustand → React)
- `ThrottledStoreUpdater` (performance workaround)
- `useStateOptimistic` (dual optimistic/real state)
### SolidJS
- `createSignal` / `createStore` — SolidJS built-ins
- `batch()` — built-in, no library
- Optimistic state: just another `createSignal` in the component
The optimistic pattern from `useStateOptimistic.tsx` (~80 lines) collapses to two lines in a SolidJS component:
```ts
const [optimistic, setOptimistic] = createSignal<State | null>(null);
const effective = () => optimistic() ?? serverState().data;
```
---
## Things That Don't Exist in Electron That SolidJS Gets for Free
| Feature | Electron workaround | SolidJS |
|---------|-------------------|---------|
| Fine-grained reactivity | `ThrottledStoreUpdater` (100 LOC) | `batch()` built-in |
| Cleanup on unmount | `useEffect` return + ref counting | `onCleanup()` |
| Derived state | `useMemo` | `createMemo()` or just `() => expr` |
| Conditional render | `&&` / ternary (JSX pitfalls) | `<Show>` (handles null/undefined safely) |
| List render | `.map()` | `<For>` (keyed, no full re-render on list change) |
---
## What the Full Migration Looks Like
### Things to keep / port directly
- The Zod event schemas (optional — validation is still useful)
- The REST API mutation pattern (`mutateMachine()`)
- The uplot chart components (framework-agnostic, just need a SolidJS wrapper)
- The unit/value display logic
### Things to discard
- `ThrottledStoreUpdater` — not needed
- `createNamespaceHookImplementation` — replaced by `createNamespace()`
- Zustand + Immer — replaced by SolidJS primitives
- `useStateOptimistic` — two lines in a component
- `useSyncExternalStore` bridge — not needed
- All the `useEffect` lifecycle boilerplate for socket management
### Things to re-evaluate
- **Electron itself** — this PoC runs as a plain browser app (`npm run dev`), served from the Rust backend. No Electron needed if the app runs in the browser. Consider just serving the built SolidJS app as static files from the Axum server.
- **i18next** — if the app is only used in one language, skip it
- **TanStack Router** → `@solidjs/router` handles the use case with less ceremony
- **react-hook-form + Zod** → SolidJS has no equivalent but the forms here are simple enough for bare `createSignal` + validation on submit
---
## Migration Scope Estimate
| Area | Effort | Notes |
|------|--------|-------|
| WebSocket infrastructure | Done (80 lines) | `src/lib/socketio.ts` |
| Main namespace | Done (~55 lines) | `src/namespaces/main.ts` |
| Per-machine namespace | ~25 lines each | See testMachine.ts as template |
| Machine pages (control/settings) | Medium | Mostly UI translation, logic simplifies |
| Graph components (uplot) | Medium | uplot is framework-agnostic; wrap in `onMount`/`onCleanup` |
| Sidebar + routing | Done | See App.tsx + Sidebar.tsx |
| Setup/EtherCAT pages | Small | Just display logic, no complex state |
| Presets | Medium | Local storage + fetch, straightforward |
| Electron IPC (update, theme, NixOS) | Low if dropped | Not needed if switching to browser app |
**Rough estimate: 2–3 weeks for a complete working port, 1 week for a fully functional PoC covering all machine types.**
The biggest time investment is translating the 15+ machine-specific UI pages, not the infrastructure — the infrastructure is already done and is dramatically simpler.
---
## Proof of Concept Structure
```
src/
├── lib/
│ ├── socketio.ts # Core: createNamespace() primitive (80 lines)
│ └── api.ts # REST mutations
├── namespaces/
│ ├── main.ts # Main namespace (~55 lines, vs ~170 in electron)
│ ├── testMachine.ts # Test machine namespace (~25 lines, vs ~77 in electron)
│ └── mock1.ts # Mock machine namespace (~60 lines incl. types)
├── pages/
│ ├── MachinesPage.tsx # Machine list
│ ├── TestMachineControlPage.tsx # LED control with optimistic updates
│ └── Mock1ControlPage.tsx # Mock machine: live values + freq/mode controls
├── components/
│ └── Sidebar.tsx # Navigation sidebar
└── App.tsx # Router + context provider
```
Run with: `npm run dev` (vite dev server, proxies to backend at localhost:3001)
---
## How Other Machines Would Be Implemented
Each machine follows the same two-step pattern:
### Step 1: Namespace file (`src/namespaces/{machine}.ts`)
Define a state type that mirrors the Rust `StateEvent` and `LiveValuesEvent` structs, then call `createNamespace()`:
```ts
export type MyMachineState = {
// fields from StateEvent
some_field: number | null;
mode: "Standby" | "Running" | null;
// fields from LiveValuesEvent (high-frequency, set separately)
live_measurement: number | null;
};
export function createMyMachineNamespace(serial: number) {
return createNamespace<MyMachineState>(
machineNamespacePath(VENDOR, MACHINE_ID, serial),
(event, set) => {
if (event.name === "StateEvent") {
set("some_field", event.data.some_field);
set("mode", event.data.mode);
} else if (event.name === "LiveValuesEvent") {
set("live_measurement", event.data.measurement);
}
},
{ some_field: null, mode: null, live_measurement: null },
);
}
```
Machine IDs (from `machines/src/lib.rs`):
| Machine | Vendor | Machine ID |
|---------|--------|-----------|
| TestMachine | 1 | 0x0033 (51) |
| Winder v2 | 1 | 0x0002 (2) |
| Extruder v2 | 1 | 0x0004 (4) |
| Laser v1 | 1 | 0x0006 (6) |
| Mock | 1 | 0x0007 (7) |
| Buffer v1 | 1 | 0x0008 (8) |
| Aquapath v1 | 1 | 0x0009 (9) |
| Wago Power v1 | 1 | 0x000A (10) |
| Extruder v3 | 1 | 0x0016 (22) |
### Step 2: Page component (`src/pages/{Machine}ControlPage.tsx`)
```tsx
export default function MyMachineControlPage() {
const params = useParams<{ serial: string }>();
const serial = () => parseInt(params.serial);
const [state] = createMyMachineNamespace(serial());
// Optimistic state per mutable field
const [optimisticMode, setOptimisticMode] = createSignal<Mode | null>(null);
const mode = () => optimisticMode() ?? state().mode ?? "Standby";
async function setMode(newMode: Mode) {
setOptimisticMode(newMode);
try {
await mutateMachine(
{ machine_identification: { vendor: VENDOR, machine: MACHINE_ID }, serial: serial() },
{ SetMode: newMode },
);
} catch { setOptimisticMode(null); }
}
return (/* JSX */);
}
```
### Step 3: Register route + sidebar entry
In `App.tsx`:
```tsx
<Route path="/machines/mymachine/:serial/control" component={MyMachineControlPage} />
<Route path="/machines/mymachine/:serial" component={...} />
```
In `Sidebar.tsx` and `MachinesPage.tsx`, add to the slug/label maps:
```ts
"1_X": "mymachine" // vendor_machine → slug
```
### Pages needed per machine (from electron reference)
| Machine | Pages in electron | Notes |
|---------|------------------|-------|
| TestMachine | Control | LED toggle (done) |
| Mock | Control | Frequencies + mode + live values (done) |
| Winder v2 | Control, Settings, Graphs, Presets, Manual | Complex: spool/traverse/puller motors |
| Extruder v2/v3 | Control, Settings, Graphs, Presets, Manual | Heating zones, inverter speed |
| Laser v1 | Control, Graphs, Presets, Manual | Diameter measurement, tolerance bands |
| Buffer v1 | Control, Settings | Motor control |
| Aquapath v1 | Control, Settings, Graphs | Temperature + analog output |
| Wago Power v1 | Control | Power channel display |
### Shared UI components still to build
The electron frontend has these reusable control components that would benefit from SolidJS ports:
- **`ControlCard`** — titled card wrapper (partially done with `.control-card` CSS)
- **`EditValue`** — numeric input with min/max/step/unit/default display
- **`SelectionGroup`** — segmented button toggle (done with `.mode-selector` pattern)
- **`TimeSeriesValueNumeric`** — displays latest value from a live stream
- **Graph wrapper** — uplot integration in SolidJS using `onMount`/`onCleanup`
- **`Topbar`** — tab navigation within a machine (Control / Graph / Manual / Presets)
- **`Page`** — page container with consistent padding/width
- **Preset system** — load/save named parameter sets via REST API
---
## Machine-Specific Complexity Notes
### Winder v2
Most complex machine. Has three motors (spool, traverse, puller), a tension arm, and a diameter visualisation component. The electron page (`Winder2ControlPage.tsx`) uses custom SVG components (`Spool.tsx`, `TraverseBar.tsx`, `TensionArm.tsx`). These are pure rendering components that can be ported as-is to SolidJS.
### Extruder v2 / v3
Has heating zones (up to 8 zones, each with temperature setpoint + PID state). The `HeatingZone.tsx` component in electron is shared between extruder2 and extruder3. The graph page plots temperature history via uplot.
### Laser v1
Has `DiameterVisualisation.tsx` — a custom SVG component showing the wire cross-section with tolerance bands. The control page shows live diameter + tolerance settings.
### Mock Machine
Simplest "real" machine with meaningful state: three sine wave generators with frequency controls and a running/standby mode. Ideal for testing the frontend with the mock backend (`cargo run --features mock-machine`).
-36
View File
@@ -1,36 +0,0 @@
## Usage
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev` or `npm start`
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
-156
View File
@@ -1,156 +0,0 @@
# SolidJS Frontend — Feature Parity Roadmap
This document tracks what still needs to be implemented to reach feature parity with the
existing Electron/React frontend. Checked items are complete in this PoC.
---
## Infrastructure
- [x] Socket.IO connection primitive (`createNamespace`)
- [x] Main namespace (machine discovery, EtherCAT status)
- [x] REST mutation helper (`mutateMachine`)
- [x] Sidebar with live machine list
- [x] Basic routing (`@solidjs/router`)
- [x] Dark theme CSS variables
- [ ] Topbar / tab navigation per machine (Control / Graph / Manual / Presets)
- [ ] Toast notifications (success/error feedback on mutations)
- [ ] Global error boundary
- [ ] Vite proxy config for production build (serve from Axum)
---
## Shared UI Components
- [x] Control card wrapper (`.control-card`)
- [x] Live value display (numeric, tabular-nums)
- [x] Frequency / numeric input with reset-to-default button
- [x] Mode selector (segmented button)
- [ ] `EditValue` — generalised numeric input with unit label, min/max/step, inline validation
- [ ] `SelectionGroup` — generalised segmented button for arbitrary enum values
- [ ] `TimeSeriesValueNumeric` — last-value display from a streaming signal
- [ ] uplot graph wrapper — `onMount` creates chart, reactive signals push data, `onCleanup` destroys
- [ ] Preset system UI — list presets, apply preset, save as preset (uses REST API)
- [ ] Manual/PDF viewer page
- [ ] EtherCAT setup page (device list, address assignments)
- [ ] Setup / configuration page (EtherCAT interface picker)
---
## Machine Pages
### Test Machine (vendor=1, machine=0x33/51)
- [x] Control page — LED toggle grid
### Mock Machine (vendor=1, machine=0x07/7)
- [x] Control page — live sine wave values, frequency inputs, mode selector
- [ ] Graph page — uplot with amplitude_sum + individual waves over time
### Winder v2 (vendor=1, machine=0x02/2)
- [ ] Namespace (`winder2Namespace.ts`)
- [ ] Control page — spool speed, traverse, puller, tension arm state
- [ ] Settings page — traverse limits, spool diameter config
- [ ] Graph page — speed/tension over time
- [ ] Presets page
- [ ] Manual page
### Extruder v2 (vendor=1, machine=0x04/4) / Extruder v3 (machine=0x16/22)
- [ ] Namespace
- [ ] Control page — heating zones (setpoints + actual temps), inverter speed
- [ ] Settings page — PID parameters per zone
- [ ] Graph page — temperature history
- [ ] Presets page
- [ ] Manual page
### Laser v1 (vendor=1, machine=0x06/6)
- [ ] Namespace
- [ ] Control page — live diameter, target diameter, tolerance band, in-tolerance indicator
- [ ] Graph page — diameter over time with tolerance lines
- [ ] Presets page
- [ ] Manual page
### Buffer v1 (vendor=1, machine=0x08/8)
- [ ] Namespace
- [ ] Control page — motor speed setpoints, actual speeds
- [ ] Settings page
### Aquapath v1 (vendor=1, machine=0x09/9)
- [ ] Namespace
- [ ] Control page — temperature setpoints, analog outputs, encoder position
- [ ] Settings page
- [ ] Graph page
### Wago Power v1 (vendor=1, machine=0x0A/10)
- [ ] Namespace
- [ ] Control page — power channel on/off display
### Digital Input Test Machine (vendor=1, machine=0x40/64)
- [ ] Namespace + Control page — digital input state display
### Wago 8ch DIO Test (vendor=1, machine=0x41/65)
- [ ] Namespace + Control page
### Wago DO Test (vendor=1, machine=0x0E/14)
- [ ] Namespace + Control page
### Wago AI Test (vendor=1, machine=0x36/54)
- [ ] Namespace + Control page
### IP20 Test Machine (vendor=1, machine=0x34/52)
- [ ] Namespace + Control page
### Motor Test Machine (vendor=1, machine=0x11/17)
- [ ] Namespace + Control page
### Analog Input Test Machine (vendor=1, machine=0x35/53)
- [ ] Namespace + Control page
---
## Pages Outside Machine Control
- [ ] Machines overview — currently functional, but uses `unknown-*` slug for unregistered machines
- [ ] EtherCAT status page — show `ethercatDevices` and `ethercatInterface` from main namespace
- [ ] Setup page — EtherCAT interface picker (currently hardcoded in backend)
- [ ] About / version page
---
## Nice-to-Have / Polish
- [ ] Loading skeleton states (instead of plain "Waiting…" text)
- [ ] Error state display when machine.error is set
- [ ] Reconnect indicator (socket disconnect/reconnect visual feedback)
- [ ] Responsive layout for smaller screens
- [ ] Keyboard shortcuts (already exists in electron via keybindings)
---
## What to Drop from Electron
These exist in the Electron app but are **not needed** in the SolidJS version:
- Electron IPC (auto-update, NixOS config, system tray) — not applicable in browser
- `ThrottledStoreUpdater` — replaced by SolidJS `batch()`
- Zustand + Immer — replaced by `createSignal`/`createStore`
- `useStateOptimistic` — two lines inline in each component
- `createNamespaceHookImplementation` — replaced by `createNamespace()`
- `react-hook-form` — bare signals are enough for the simple forms here
- i18next — skip unless multi-language is required
---
## Testing the PoC with Mock Mode
Start the backend in mock mode:
```sh
cargo run --features mock-machine
```
Start the frontend dev server:
```sh
cd qitech-control-gui && npm run dev
```
Open http://localhost:3000 — the mock machine should appear in the sidebar.
Navigate to `/machines/mock1/{serial}/control` to see the control page.
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
<title>Solid App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
-27
View File
@@ -1,27 +0,0 @@
{
"name": "vite-template-solid",
"version": "0.0.0",
"description": "",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"license": "MIT",
"devDependencies": {
"solid-devtools": "^0.34.3",
"typescript": "^5.7.2",
"vite": "^7.1.4",
"vite-plugin-solid": "^2.11.8"
},
"dependencies": {
"@solidjs/router": "^0.15.4",
"socket.io-client": "^4.8.3",
"socket.io-msgpack-parser": "^3.0.2",
"solid-js": "^1.9.5",
"uplot": "^1.6.32",
"zod": "^4.3.6"
}
}
-1346
View File
File diff suppressed because it is too large. Load diff
-33
View File
@@ -1,33 +0,0 @@
.App {
text-align: center;
}
.logo {
animation: logo-spin infinite 20s linear;
height: 40vmin;
pointer-events: none;
}
.header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.link {
color: #b318f0;
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
-81
View File
@@ -1,81 +0,0 @@
/**
* App root — sets up routing and creates the main namespace socket once.
*
* The main namespace signal is created here (at app scope) and provided via
* context so all pages can access it without prop drilling.
*
* Machine-specific namespaces are created inside their pages so they
* connect/disconnect with the page lifecycle automatically.
*
* No global store. No Zustand. No Immer. No ThrottledStoreUpdater.
* State lives in SolidJS signals, reactive updates are handled by batch().
*/
import { Router, Route } from "@solidjs/router";
import { createContext, useContext } from "solid-js";
import type { JSX } from "solid-js";
import Sidebar from "./components/Sidebar";
import MachinesPage from "./pages/MachinesPage";
import TestMachineControlPage from "./pages/TestMachineControlPage";
import Mock1ControlPage from "./pages/Mock1ControlPage";
import Mock1GraphPage from "./pages/Mock1GraphPage";
import { createMainNamespace, type MainState } from "./namespaces/main";
import "./styles.css";
// Context so nested pages can read main state without prop drilling
export const MainStateContext = createContext<() => MainState>(() => ({
machines: null,
ethercatDevices: null,
ethercatInterface: null,
}));
export function useMainState() {
return useContext(MainStateContext);
}
// Shared layout wrapper passed to Router as `root`
function AppLayout(props: { children?: JSX.Element }) {
const mainState = useMainState();
return (
<div class="app-layout">
<Sidebar mainState={mainState} />
<main class="app-content">{props.children}</main>
</div>
);
}
export default function App() {
// One socket for the main namespace, alive for the entire app lifetime.
// createMainNamespace() calls createNamespace() which calls onCleanup()
// — so the socket disconnects if App ever unmounts.
const [mainState] = createMainNamespace();
return (
<MainStateContext.Provider value={mainState}>
<Router root={AppLayout}>
<Route path="/" component={() => <MachinesPage mainState={mainState} />} />
<Route path="/machines/testmachine/:serial/control" component={TestMachineControlPage} />
<Route
path="/machines/testmachine/:serial"
component={() => (
<div class="page">
<h1>Test Machine</h1>
<p class="muted">Select Control from the sidebar.</p>
</div>
)}
/>
<Route path="/machines/mock1/:serial/control" component={Mock1ControlPage} />
<Route path="/machines/mock1/:serial/graph" component={Mock1GraphPage} />
<Route
path="/machines/mock1/:serial"
component={() => (
<div class="page">
<h1>Mock Machine</h1>
<p class="muted">Select Control from the sidebar.</p>
</div>
)}
/>
</Router>
</MainStateContext.Provider>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

@@ -1,143 +0,0 @@
/**
* MiniGraph — compact inline sparkline for control page cards.
*
* 64 px tall, no cursor, no legend, no x-axis.
* Y-axis grid shows on the right side.
* Driven by a rAF loop reading directly from a SeriesBuffer — no reactive
* subscriptions, no intermediate array copies on every event.
*
* Props:
* - getBuffer: () => SeriesBuffer called each frame to read the ring buffer
* - timeWindow: number seconds of history to display (default: 10)
* - stroke: CSS colour string (default: var(--accent) / #6c8cff)
*/
import uPlot from "uplot";
import { onMount, onCleanup } from "solid-js";
import type { SeriesBuffer } from "../namespaces/mock1";
const HEIGHT = 64;
type Props = {
getBuffer: () => SeriesBuffer;
timeWindow?: number;
stroke?: string;
};
export default function MiniGraph(props: Props) {
let container!: HTMLDivElement;
let uplot: uPlot | null = null;
let rafId = 0;
let pendingWidth = 0;
function buildOpts(width: number): uPlot.Options {
return {
width,
height: HEIGHT,
padding: [4, 0, 4, 0],
cursor: { show: false },
legend: { show: false },
scales: {
x: { time: true },
y: { auto: false },
},
axes: [
{ show: false },
{
side: 1, // right side
size: 40,
stroke: "#717898",
grid: { stroke: "#2e3148", width: 0.5 },
ticks: { stroke: "#2e3148", width: 0.5 },
values: (_u, ticks) => ticks.map((v) => v.toFixed(2)),
},
],
series: [
{},
{
stroke: props.stroke ?? "#6c8cff",
width: 1.5,
spanGaps: true,
points: { show: false },
},
],
};
}
function frame() {
if (!uplot) { rafId = requestAnimationFrame(frame); return; }
if (pendingWidth > 0) {
uplot.setSize({ width: pendingWidth, height: HEIGHT });
pendingWidth = 0;
}
const buf = props.getBuffer();
const { times, values, length, head } = buf;
if (length === 0) { rafId = requestAnimationFrame(frame); return; }
const bufSize = times.length;
const isFull = length === bufSize;
const startIdx = isFull ? head : 0;
const tMax = times[(head - 1 + bufSize) % bufSize];
const tMin = tMax - (props.timeWindow ?? 10);
// Count entries in window
let count = 0;
for (let i = 0; i < length; i++) {
if (times[(startIdx + i) % bufSize] >= tMin) count++;
}
const outT = new Float64Array(count);
const outV = new Float64Array(count);
let out = 0;
let minY = Infinity, maxY = -Infinity;
for (let i = 0; i < length; i++) {
const idx = (startIdx + i) % bufSize;
const t = times[idx];
if (t >= tMin) {
outT[out] = t;
outV[out] = values[idx];
if (values[idx] < minY) minY = values[idx];
if (values[idx] > maxY) maxY = values[idx];
out++;
}
}
if (count === 0) { rafId = requestAnimationFrame(frame); return; }
const range = maxY - minY || 1;
const pad = range * 0.15;
uplot.batch(() => {
uplot!.setData([outT, outV], false);
uplot!.setScale("x", { min: tMin, max: tMax });
uplot!.setScale("y", { min: minY - pad, max: maxY + pad });
});
rafId = requestAnimationFrame(frame);
}
onMount(() => {
const ro = new ResizeObserver(([entry]) => {
const w = Math.floor(entry.contentRect.width);
if (w === 0) return;
if (!uplot) {
uplot = new uPlot(buildOpts(w), [new Float64Array(0), new Float64Array(0)], container);
rafId = requestAnimationFrame(frame);
} else {
pendingWidth = w;
}
});
ro.observe(container);
onCleanup(() => {
cancelAnimationFrame(rafId);
ro.disconnect();
uplot?.destroy();
uplot = null;
});
});
return <div ref={container} class="minigraph-wrap" />;
}
@@ -1,92 +0,0 @@
import { A } from "@solidjs/router";
import { For, Show, createMemo } from "solid-js";
import type { MainState, MachineObj } from "../namespaces/main";
type Props = {
mainState: () => MainState;
};
function machineLabel(m: MachineObj): string {
const { vendor, machine } = m.machine_identification_unique.machine_identification;
const labels: Record<string, string> = {
"1_51": "Test Machine", // 0x0033
"1_2": "Winder v2", // 0x0002
"1_4": "Extruder v2", // 0x0004
"1_6": "Laser v1", // 0x0006
"1_7": "Mock", // 0x0007
"1_8": "Buffer v1", // 0x0008
"1_9": "Aquapath v1", // 0x0009
"1_10": "Wago Power", // 0x000A
"1_22": "Extruder v3", // 0x0016
};
return labels[`${vendor}_${machine}`] ?? `Machine ${vendor}/${machine}`;
}
function machineSlug(m: MachineObj): string {
const { vendor, machine } = m.machine_identification_unique.machine_identification;
const slugs: Record<string, string> = {
"1_51": "testmachine", // 0x0033
"1_2": "winder2", // 0x0002
"1_4": "extruder2", // 0x0004
"1_6": "laser1", // 0x0006
"1_7": "mock1", // 0x0007
"1_8": "buffer1", // 0x0008
"1_9": "aquapath1", // 0x0009
"1_10": "wago_power1", // 0x000A
"1_22": "extruder3", // 0x0016
};
return slugs[`${vendor}_${machine}`] ?? `unknown-${vendor}-${machine}`;
}
// Slugs that have a graph page implemented
const SLUGS_WITH_GRAPH = new Set(["mock1"]);
export default function Sidebar(props: Props) {
const machines = createMemo(() => props.mainState().machines ?? []);
return (
<nav class="sidebar">
<div class="sidebar-brand">
<span>QiTech Control</span>
</div>
<div class="sidebar-section">
<A href="/" class="sidebar-link" end>
Machines
</A>
</div>
<Show when={machines().length > 0}>
<div class="sidebar-section">
<div class="sidebar-section-label">Active Machines</div>
<For each={machines()}>
{(m) => {
const slug = machineSlug(m);
const serial = m.machine_identification_unique.serial;
return (
<div class="sidebar-machine">
<A href={`/machines/${slug}/${serial}`} class="sidebar-link" end>
{machineLabel(m)} <span class="sidebar-serial">#{serial}</span>
</A>
<A href={`/machines/${slug}/${serial}/control`} class="sidebar-sublink">
Control
</A>
<Show when={SLUGS_WITH_GRAPH.has(slug)}>
<A href={`/machines/${slug}/${serial}/graph`} class="sidebar-sublink">
Graph
</A>
</Show>
</div>
);
}}
</For>
</div>
</Show>
<div class="sidebar-footer">
<div class={`connection-dot ${props.mainState().machines !== null ? "connected" : "disconnected"}`} />
<span>{props.mainState().machines !== null ? "Connected" : "Connecting…"}</span>
</div>
</nav>
);
}
@@ -1,147 +0,0 @@
/**
* UPlotGraph — SolidJS wrapper around uPlot with a rolling time window.
*
* Design principles:
* - The uPlot instance is driven by a rAF loop, NOT by reactive effects.
* This caps redraws at the display refresh rate (~60 fps) regardless of
* how fast incoming data arrives (e.g. 10 Hz socket events).
* - The caller provides a `getSeriesData` callback that returns the current
* ring-buffer slices. The callback is called once per animation frame so
* it must be cheap (just a typed-array slice of the visible window).
* - `timeWindow` (seconds) controls how much history is visible. The x-axis
* scale is set to [latestTimestamp - timeWindow, latestTimestamp] every
* frame, giving the rolling-window effect.
* - Resize is handled by ResizeObserver → setSize on next rAF frame.
*/
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
import { onMount, onCleanup } from "solid-js";
export type HorizLine = {
value: number;
color: string;
dash?: boolean;
label?: string;
};
type Props = {
/** Called each animation frame. Return aligned data arrays (timestamps in seconds). */
getSeriesData: () => uPlot.AlignedData;
series: uPlot.Series[];
unit?: string;
height?: number;
/** Visible time window in seconds (default: 30 s). */
timeWindow?: number;
lines?: HorizLine[];
};
export default function UPlotGraph(props: Props) {
let container!: HTMLDivElement;
let uplot: uPlot | null = null;
let rafId = 0;
let pendingWidth = 0;
function buildOpts(width: number): uPlot.Options {
const height = props.height ?? 240;
const lines = props.lines ?? [];
return {
width,
height,
cursor: { sync: { key: "graphs" } },
scales: {
x: { time: true },
y: { auto: true },
},
axes: [
{
stroke: "#717898",
grid: { stroke: "#2e3148", width: 1 },
ticks: { stroke: "#2e3148" },
},
{
label: props.unit,
stroke: "#717898",
grid: { stroke: "#2e3148", width: 1 },
ticks: { stroke: "#2e3148" },
},
],
series: [
{},
...props.series,
],
hooks: lines.length > 0 ? {
drawSeries: [
(u) => {
const ctx = u.ctx;
ctx.save();
for (const line of lines) {
const yPos = Math.round(u.valToPos(line.value, "y", true));
ctx.beginPath();
ctx.strokeStyle = line.color;
ctx.lineWidth = 1.5;
if (line.dash) ctx.setLineDash([6, 3]);
ctx.moveTo(u.bbox.left, yPos);
ctx.lineTo(u.bbox.left + u.bbox.width, yPos);
ctx.stroke();
ctx.setLineDash([]);
if (line.label) {
ctx.fillStyle = line.color;
ctx.font = "11px Inter, system-ui, sans-serif";
ctx.fillText(line.label, u.bbox.left + 4, yPos - 4);
}
}
ctx.restore();
},
],
} : {},
};
}
function frame() {
if (!uplot) { rafId = requestAnimationFrame(frame); return; }
// Apply any pending resize first
if (pendingWidth > 0) {
uplot.setSize({ width: pendingWidth, height: props.height ?? 240 });
pendingWidth = 0;
}
const data = props.getSeriesData();
const times = data[0] as Float64Array | number[];
const n = times.length;
if (n > 0) {
const tMax = times[n - 1];
const tMin = tMax - (props.timeWindow ?? 30);
uplot.setData(data, false); // false = don't auto-scale x
uplot.setScale("x", { min: tMin, max: tMax });
}
rafId = requestAnimationFrame(frame);
}
onMount(() => {
const ro = new ResizeObserver(([entry]) => {
const w = Math.floor(entry.contentRect.width);
if (w === 0) return;
if (!uplot) {
uplot = new uPlot(buildOpts(w), props.getSeriesData(), container);
rafId = requestAnimationFrame(frame);
} else {
// Defer resize to the rAF loop to avoid mid-paint artifacts
pendingWidth = w;
}
});
ro.observe(container);
onCleanup(() => {
cancelAnimationFrame(rafId);
ro.disconnect();
uplot?.destroy();
uplot = null;
});
});
return <div ref={container} class="uplot-wrap" />;
}
-13
View File
@@ -1,13 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
-16
View File
@@ -1,16 +0,0 @@
/* @refresh reload */
import { render } from 'solid-js/web';
import 'solid-devtools';
import './index.css';
import App from './App';
const root = document.getElementById('root');
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
'Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?',
);
}
render(() => <App />, root!);
-27
View File
@@ -1,27 +0,0 @@
/**
* REST API client
*
* Thin wrapper — no react-query or custom hooks needed for simple mutations.
* SolidJS's createResource() handles data fetching with built-in loading/error states.
*/
export const BASE_URL = "http://localhost:3001";
export type MachineIdentificationUnique = {
machine_identification: { vendor: number; machine: number };
serial: number;
};
export async function mutateMachine(
machine_identification_unique: MachineIdentificationUnique,
data: unknown,
): Promise<void> {
const res = await fetch(`${BASE_URL}/api/v1/machine/mutate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machine_identification_unique, data }),
});
if (!res.ok) {
throw new Error(`Mutation failed: ${res.status} ${res.statusText}`);
}
}
-114
View File
@@ -1,114 +0,0 @@
/**
* SolidJS Socket.IO layer
*
* Key insight: SolidJS signals replace both Zustand + ThrottledStoreUpdater.
* - createSignal() is the store
* - batch() coalesces multiple updates into one DOM pass (no manual 30fps timer needed)
* - onCleanup() replaces useEffect cleanup
* - No lifecycle boilerplate per machine type
*
* The entire pattern collapses to: connect → on("event") → setSignal(data)
*/
import { io, Socket } from "socket.io-client";
import MsgPackParser from "socket.io-msgpack-parser";
import { createSignal, onCleanup, batch } from "solid-js";
import { z } from "zod";
export const BASE_URL = "http://localhost:3001";
// ---------------------------------------------------------------------------
// Generic event envelope (mirrors the Rust GenericEvent on the server)
// ---------------------------------------------------------------------------
export const genericEventSchema = z.object({
name: z.string(),
data: z.any(),
ts: z.number().int().positive(),
});
export type GenericEvent = z.infer<typeof genericEventSchema>;
// ---------------------------------------------------------------------------
// Namespace path helpers
// ---------------------------------------------------------------------------
export type NamespacePath = string; // e.g. "/main" or "/machine/1/2/100"
export function machineNamespacePath(
vendor: number,
machine: number,
serial: number,
): NamespacePath {
return `/machine/${vendor}/${machine}/${serial}`;
}
// ---------------------------------------------------------------------------
// createNamespace — the single primitive for all WebSocket connections
//
// Returns a reactive [data, socket] tuple. The caller provides:
// - path: the Socket.IO namespace path
// - handler: a function (event, setData) called on each incoming event
//
// Usage inside a SolidJS component or createRoot:
//
// const [state, _socket] = createNamespace("/main", (event, set) => {
// if (event.name === "MachinesEvent") set("machines", event.data);
// }, { machines: null });
//
// The socket disconnects automatically when the reactive scope is disposed
// (e.g. component unmounts, createRoot cleanup).
// ---------------------------------------------------------------------------
export function createNamespace<S extends object>(
path: NamespacePath,
handler: (event: GenericEvent, set: (key: keyof S, value: S[keyof S]) => void, setAll: (updater: (prev: S) => S) => void) => void,
initialState: S,
): [() => S, Socket] {
const [state, setState] = createSignal<S>(initialState);
const socket = io(BASE_URL + path, {
autoConnect: false,
parser: MsgPackParser,
});
// Helper: update a single key — uses batch internally for multi-key updates
const set = (key: keyof S, value: S[keyof S]) => {
setState((prev) => ({ ...prev, [key]: value }));
};
const setAll = (updater: (prev: S) => S) => {
setState((prev) => updater(prev));
};
socket.on("connect", () => {
console.log(`[socket.io] connected: ${path}`);
});
socket.on("disconnect", (reason) => {
console.warn(`[socket.io] disconnected: ${path} — ${reason}`);
// Reset state on disconnect so UI reflects stale/loading state
batch(() => setState(initialState));
});
socket.on("event", (raw: unknown) => {
const parsed = genericEventSchema.safeParse(raw);
if (!parsed.success) {
console.error("[socket.io] invalid event envelope:", parsed.error);
return;
}
// batch() ensures multiple set() calls in one handler only trigger one
// reactive update pass — replaces the ThrottledStoreUpdater entirely
batch(() => handler(parsed.data, set, setAll));
});
socket.connect();
// Automatically disconnect when the reactive scope is torn down
onCleanup(() => {
socket.disconnect();
console.log(`[socket.io] cleaned up: ${path}`);
});
return [state, socket];
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-65
View File
@@ -1,65 +0,0 @@
/**
* Main namespace — machine discovery & EtherCAT status
*
* Compare to electron/src/client/mainNamespace.ts (~170 lines + socketioStore boilerplate).
* This entire file is ~60 lines and contains the same information.
*/
import { createNamespace } from "../lib/socketio";
// ---------------------------------------------------------------------------
// Types (mirrors the Rust structs)
// ---------------------------------------------------------------------------
export type MachineObj = {
machine_identification_unique: {
machine_identification: { vendor: number; machine: number };
serial: number;
};
error: string | null;
};
export type EthercatDevicesEventData =
| { Initializing: true }
| { Done: { devices: EthercatDevice[] } }
| { Error: string };
export type EthercatDevice = {
configured_address: number;
name: string;
vendor_id: number;
product_id: number;
revision: number;
};
export type MainState = {
machines: MachineObj[] | null;
ethercatDevices: EthercatDevicesEventData | null;
ethercatInterface: string | null;
};
// ---------------------------------------------------------------------------
// Hook: useMainNamespace
//
// In React this required:
// mainNamespace.ts (~170 lines, Zod schemas, Zustand store, handler, hook)
// socketioStore.ts (createNamespaceHookImplementation, ThrottledStoreUpdater, etc.)
//
// In SolidJS: one createNamespace() call does it all.
// ---------------------------------------------------------------------------
export function createMainNamespace() {
return createNamespace<MainState>(
"/main",
(event, set) => {
if (event.name === "MachinesEvent") {
set("machines", event.data.machines);
} else if (event.name === "EthercatDevicesEvent") {
set("ethercatDevices", event.data);
} else if (event.name === "EthercatInterfaceDiscoveryEvent") {
if ("Done" in event.data) set("ethercatInterface", event.data.Done);
}
},
{ machines: null, ethercatDevices: null, ethercatInterface: null },
);
}
-165
View File
@@ -1,165 +0,0 @@
/**
* Mock1 machine namespace
*
* Mock machine is vendor=1, machine=7 (MACHINE_MOCK = 0x0007).
* Events: StateEvent, LiveValuesEvent
* Mutations: SetFrequency1/2/3(f64), SetMode("Standby"|"Running")
*/
import { createSignal, onCleanup, batch } from "solid-js";
import { io } from "socket.io-client";
import MsgPackParser from "socket.io-msgpack-parser";
import { createNamespace, machineNamespacePath, BASE_URL, genericEventSchema } from "../lib/socketio";
export type Mode = "Standby" | "Running";
export type ModeState = {
mode: Mode;
};
export type Mock1State = {
// From StateEvent
is_default_state: boolean | null;
frequency1: number | null; // millihertz
frequency2: number | null;
frequency3: number | null;
mode_state: ModeState | null;
// From LiveValuesEvent (updated at high frequency)
amplitude_sum: number | null;
amplitude1: number | null;
amplitude2: number | null;
amplitude3: number | null;
};
// Time-series buffer for graphing: parallel arrays of timestamps (s) and values
export type SeriesBuffer = {
times: Float64Array;
values: Float64Array;
length: number; // number of valid entries (wraps in ring)
head: number; // index of next write position
};
export type Mock1Series = {
amplitude_sum: SeriesBuffer;
amplitude1: SeriesBuffer;
amplitude2: SeriesBuffer;
amplitude3: SeriesBuffer;
};
export const VENDOR_QITECH = 1;
export const MACHINE_MOCK = 7;
// Maximum number of samples retained per series (~30 min at 10 Hz = 18 000)
const BUFFER_SIZE = 20_000;
function makeBuffer(): SeriesBuffer {
return {
times: new Float64Array(BUFFER_SIZE),
values: new Float64Array(BUFFER_SIZE),
length: 0,
head: 0,
};
}
function pushSample(buf: SeriesBuffer, t: number, v: number): SeriesBuffer {
buf.times[buf.head] = t;
buf.values[buf.head] = v;
buf.head = (buf.head + 1) % BUFFER_SIZE;
if (buf.length < BUFFER_SIZE) buf.length++;
return buf;
}
/** Returns sorted [times, values] arrays suitable for uPlot (oldest → newest). */
export function sortedSeries(buf: SeriesBuffer): [Float64Array, Float64Array] {
if (buf.length === 0) return [new Float64Array(0), new Float64Array(0)];
if (buf.length < BUFFER_SIZE) {
// Buffer not yet full — data runs from index 0 to length-1
return [buf.times.slice(0, buf.length), buf.values.slice(0, buf.length)];
}
// Ring buffer full — head points to oldest entry
const times = new Float64Array(BUFFER_SIZE);
const values = new Float64Array(BUFFER_SIZE);
const tail = buf.head; // oldest index
const firstLen = BUFFER_SIZE - tail;
times.set(buf.times.subarray(tail), 0);
times.set(buf.times.subarray(0, tail), firstLen);
values.set(buf.values.subarray(tail), 0);
values.set(buf.values.subarray(0, tail), firstLen);
return [times, values];
}
export function createMock1Namespace(serial: number) {
return createNamespace<Mock1State>(
machineNamespacePath(VENDOR_QITECH, MACHINE_MOCK, serial),
(event, set) => {
if (event.name === "StateEvent") {
set("is_default_state", event.data.is_default_state);
set("frequency1", event.data.frequency1);
set("frequency2", event.data.frequency2);
set("frequency3", event.data.frequency3);
set("mode_state", event.data.mode_state);
} else if (event.name === "LiveValuesEvent") {
set("amplitude_sum", event.data.amplitude_sum);
set("amplitude1", event.data.amplitude1);
set("amplitude2", event.data.amplitude2);
set("amplitude3", event.data.amplitude3);
}
},
{
is_default_state: null,
frequency1: null,
frequency2: null,
frequency3: null,
mode_state: null,
amplitude_sum: null,
amplitude1: null,
amplitude2: null,
amplitude3: null,
},
);
}
/**
* Creates a second socket connection to the same machine namespace but only
* buffers LiveValuesEvent data into ring buffers for graphing.
*
* Kept separate from createMock1Namespace so the control page doesn't pay the
* memory cost of the buffers, and the graph page manages its own connection
* lifecycle independently.
*
* Returns a reactive signal: each LiveValuesEvent triggers a new object so
* graph components can react. The buffers are mutated in-place for efficiency.
*/
export function createMock1SeriesBuffer(serial: number): [() => Mock1Series] {
const series: Mock1Series = {
amplitude_sum: makeBuffer(),
amplitude1: makeBuffer(),
amplitude2: makeBuffer(),
amplitude3: makeBuffer(),
};
const [tick, setTick] = createSignal<Mock1Series>(series, { equals: false });
const path = machineNamespacePath(VENDOR_QITECH, MACHINE_MOCK, serial);
const socket = io(BASE_URL + path, { autoConnect: false, parser: MsgPackParser });
socket.on("event", (raw: unknown) => {
const parsed = genericEventSchema.safeParse(raw);
if (!parsed.success) return;
const event = parsed.data;
if (event.name !== "LiveValuesEvent") return;
const t = event.ts / 1000; // backend sends ms → convert to seconds for uPlot
batch(() => {
pushSample(series.amplitude_sum, t, event.data.amplitude_sum ?? 0);
pushSample(series.amplitude1, t, event.data.amplitude1 ?? 0);
pushSample(series.amplitude2, t, event.data.amplitude2 ?? 0);
pushSample(series.amplitude3, t, event.data.amplitude3 ?? 0);
setTick(series);
});
});
socket.connect();
onCleanup(() => socket.disconnect());
return [tick];
}
@@ -1,28 +0,0 @@
/**
* Test machine namespace
*
* Compare to electron/src/machines/testmachine/testMachineNamespace.ts (~77 lines)
* This is ~25 lines including the types.
*/
import { createNamespace, machineNamespacePath } from "../lib/socketio";
export type TestMachineState = {
led_on: boolean[] | null;
};
export function createTestMachineNamespace(
vendor: number,
machine: number,
serial: number,
) {
return createNamespace<TestMachineState>(
machineNamespacePath(vendor, machine, serial),
(event, set) => {
if (event.name === "StateEvent") {
set("led_on", event.data.led_on);
}
},
{ led_on: null },
);
}
@@ -1,72 +0,0 @@
/**
* Machines overview page — lists all detected machines with navigation links.
* Uses the main namespace signal directly; no intermediate store or hook.
*/
import { For, Show, createMemo } from "solid-js";
import { A } from "@solidjs/router";
import type { MachineObj } from "../namespaces/main";
import type { MainState } from "../namespaces/main";
type Props = {
mainState: () => MainState;
};
function machineSlug(m: MachineObj): string {
const { vendor, machine } = m.machine_identification_unique.machine_identification;
// Map vendor_machine → URL slug (matches routes in App.tsx)
const knownMachines: Record<string, string> = {
"1_51": "testmachine", // 0x0033
"1_2": "winder2", // 0x0002
"1_4": "extruder2", // 0x0004
"1_6": "laser1", // 0x0006
"1_7": "mock1", // 0x0007
"1_8": "buffer1", // 0x0008
"1_9": "aquapath1", // 0x0009
"1_10": "wago_power1", // 0x000A
"1_22": "extruder3", // 0x0016
};
return knownMachines[`${vendor}_${machine}`] ?? `unknown-${vendor}-${machine}`;
}
export default function MachinesPage(props: Props) {
const machines = createMemo(() => props.mainState().machines);
return (
<div class="page">
<h1>Machines</h1>
<Show when={machines() === null} fallback={
<Show
when={(machines()?.length ?? 0) > 0}
fallback={<p class="muted">No machines detected.</p>}
>
<div class="machine-list">
<For each={machines()!}>
{(m) => {
const slug = machineSlug(m);
const serial = m.machine_identification_unique.serial;
return (
<div class={`machine-card ${m.error ? "error" : "ok"}`}>
<div class="machine-info">
<strong>{slug}</strong>
<span class="serial">#{serial}</span>
<Show when={m.error}>
<span class="error-badge">{m.error}</span>
</Show>
</div>
<div class="machine-links">
<A href={`/machines/${slug}/${serial}`}>Overview</A>
<A href={`/machines/${slug}/${serial}/control`}>Control</A>
</div>
</div>
);
}}
</For>
</div>
</Show>
}>
<p class="muted">Connecting to server…</p>
</Show>
</div>
);
}
@@ -1,236 +0,0 @@
/**
* Mock1 machine control page
*
* Demonstrates:
* - High-frequency live values with inline sparklines (MiniGraph)
* - Optimistic state updates for frequency + mode controls
* - Mode toggle (Standby / Running)
* - Numeric input with default value indicator
*
* Compare to electron/src/machines/mock/mock1/Mock1ControlPage.tsx +
* useMock.ts (~200 lines combined). This file replaces both.
*/
import { Show, createSignal, createMemo } from "solid-js";
import { useParams } from "@solidjs/router";
import { createMock1Namespace, createMock1SeriesBuffer, VENDOR_QITECH, MACHINE_MOCK } from "../namespaces/mock1";
import type { Mode, SeriesBuffer } from "../namespaces/mock1";
import { mutateMachine } from "../lib/api";
import MiniGraph from "../components/MiniGraph";
export default function Mock1ControlPage() {
const params = useParams<{ serial: string }>();
const serial = () => parseInt(params.serial);
const [state] = createMock1Namespace(serial());
const [getSeries] = createMock1SeriesBuffer(serial());
// Optimistic state: prefer local update until server confirms
const [optimisticFreq1, setOptimisticFreq1] = createSignal<number | null>(null);
const [optimisticFreq2, setOptimisticFreq2] = createSignal<number | null>(null);
const [optimisticFreq3, setOptimisticFreq3] = createSignal<number | null>(null);
const [optimisticMode, setOptimisticMode] = createSignal<Mode | null>(null);
const freq1 = () => optimisticFreq1() ?? state().frequency1 ?? 1000;
const freq2 = () => optimisticFreq2() ?? state().frequency2 ?? 2000;
const freq3 = () => optimisticFreq3() ?? state().frequency3 ?? 5000;
const mode = () => optimisticMode() ?? state().mode_state?.mode ?? "Standby";
const isLoaded = createMemo(() => state().frequency1 !== null);
const machineId = () => ({
machine_identification: { vendor: VENDOR_QITECH, machine: MACHINE_MOCK },
serial: serial(),
});
async function setFrequency(
index: 1 | 2 | 3,
value: number,
setOptimistic: (v: number) => void,
resetOptimistic: () => void,
) {
setOptimistic(value);
try {
await mutateMachine(machineId(), { [`SetFrequency${index}`]: value });
} catch (e) {
console.error(`SetFrequency${index} failed:`, e);
resetOptimistic();
}
}
async function setMode(newMode: Mode) {
setOptimisticMode(newMode);
try {
await mutateMachine(machineId(), { SetMode: newMode });
} catch (e) {
console.error("SetMode failed:", e);
setOptimisticMode(null);
}
}
return (
<div class="page">
<h1>Mock Machine — Control</h1>
<p class="serial">Serial: #{serial()}</p>
<Show when={!isLoaded()} fallback={null}>
<p class="muted">Waiting for machine state…</p>
</Show>
<Show when={isLoaded()}>
<div class="control-grid">
{/* Live values with sparklines */}
<div class="control-card live-values-card">
<div class="control-card-title">Live Values</div>
<div class="live-values">
<LiveValueRow
label="Sum"
value={state().amplitude_sum}
getBuffer={() => getSeries().amplitude_sum}
stroke="#6c8cff"
/>
<LiveValueRow
label="Wave 1"
value={state().amplitude1}
getBuffer={() => getSeries().amplitude1}
stroke="#6c8cff"
/>
<LiveValueRow
label="Wave 2"
value={state().amplitude2}
getBuffer={() => getSeries().amplitude2}
stroke="#f472b6"
/>
<LiveValueRow
label="Wave 3"
value={state().amplitude3}
getBuffer={() => getSeries().amplitude3}
stroke="#34d399"
/>
</div>
</div>
{/* Frequencies */}
<div class="control-card">
<div class="control-card-title">Frequencies (mHz)</div>
<div class="freq-grid">
<FrequencyInput
label="Frequency 1"
value={freq1()}
defaultValue={state().is_default_state ? null : state().frequency1}
onChange={(v) => setFrequency(1, v, setOptimisticFreq1, () => setOptimisticFreq1(null))}
/>
<FrequencyInput
label="Frequency 2"
value={freq2()}
defaultValue={state().is_default_state ? null : state().frequency2}
onChange={(v) => setFrequency(2, v, setOptimisticFreq2, () => setOptimisticFreq2(null))}
/>
<FrequencyInput
label="Frequency 3"
value={freq3()}
defaultValue={state().is_default_state ? null : state().frequency3}
onChange={(v) => setFrequency(3, v, setOptimisticFreq3, () => setOptimisticFreq3(null))}
/>
</div>
</div>
{/* Mode */}
<div class="control-card">
<div class="control-card-title">Mode</div>
<div class="mode-selector">
<button
class={`mode-btn ${mode() === "Standby" ? "active" : ""}`}
onClick={() => setMode("Standby")}
>
Standby
</button>
<button
class={`mode-btn ${mode() === "Running" ? "active running" : ""}`}
onClick={() => setMode("Running")}
>
Running
</button>
</div>
</div>
</div>
</Show>
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function LiveValueRow(props: {
label: string;
value: number | null;
getBuffer: () => SeriesBuffer;
stroke: string;
}) {
return (
<div class="live-value-row-spark">
<div class="live-value-left">
<span class="live-value-label">{props.label}</span>
<span class="live-value-number">
<Show when={props.value !== null} fallback={<span class="muted">–</span>}>
{props.value!.toFixed(3)}
</Show>
</span>
</div>
<div class="live-value-spark">
<MiniGraph getBuffer={props.getBuffer} stroke={props.stroke} timeWindow={10} />
</div>
</div>
);
}
function FrequencyInput(props: {
label: string;
value: number;
defaultValue: number | null | undefined;
onChange: (v: number) => void;
}) {
const [editValue, setEditValue] = createSignal<string | null>(null);
const displayValue = () => editValue() ?? props.value.toFixed(0);
function commit() {
const v = parseFloat(editValue() ?? "");
if (!isNaN(v) && v >= 0 && v <= 100_000) {
props.onChange(v);
}
setEditValue(null);
}
return (
<div class="freq-input-group">
<label class="freq-label">{props.label}</label>
<div class="freq-input-row">
<input
class="freq-input"
type="number"
min="0"
max="100000"
step="100"
value={displayValue()}
onInput={(e) => setEditValue(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
/>
<Show when={props.defaultValue != null && props.defaultValue !== props.value}>
<button
class="freq-reset-btn"
title={`Reset to default (${props.defaultValue!.toFixed(0)})`}
onClick={() => props.onChange(props.defaultValue!)}
>
↺
</button>
</Show>
</div>
</div>
);
}
@@ -1,156 +0,0 @@
/**
* Mock1 graph page — rolling time-window uPlot charts.
*
* Two charts:
* 1. Amplitude Sum — single series + ±0.8 threshold lines
* 2. Individual Waves — amplitude1/2/3 overlaid
*
* Performance design:
* - UPlotGraph drives itself from a rAF loop; no reactive memos.
* - getSeriesData() slices only the visible window from the ring buffer on
* each frame rather than copying the entire buffer reactively.
* - The ring buffer itself is mutated in-place by the socket handler.
*/
import { createSignal } from "solid-js";
import { useParams } from "@solidjs/router";
import { createMock1SeriesBuffer } from "../namespaces/mock1";
import type { SeriesBuffer } from "../namespaces/mock1";
import UPlotGraph from "../components/UPlotGraph";
import type { HorizLine } from "../components/UPlotGraph";
import type uPlot from "uplot";
// Available time windows in seconds
const TIME_WINDOWS = [
{ label: "30 s", value: 30 },
{ label: "2 min", value: 120 },
{ label: "5 min", value: 300 },
{ label: "30 min", value: 1800 },
] as const;
/**
* Slice the ring buffer to only the entries within [tMax - windowSecs, tMax].
* Returns typed arrays already in chronological order.
* O(n) scan but n is at most BUFFER_SIZE and only called once per rAF frame.
*/
function windowedSlice(buf: SeriesBuffer, windowSecs: number): [Float64Array, Float64Array] {
const { times, values, length, head } = buf;
if (length === 0) return [new Float64Array(0), new Float64Array(0)];
const bufSize = times.length; // BUFFER_SIZE
const isFull = length === bufSize;
// Determine chronological start index in the ring
const startIdx = isFull ? head : 0;
const tMax = times[(head - 1 + bufSize) % bufSize]; // last written entry
const tMin = tMax - windowSecs;
// Count how many entries fall inside [tMin, tMax]
let count = 0;
for (let i = 0; i < length; i++) {
const t = times[(startIdx + i) % bufSize];
if (t >= tMin) count++;
}
const outTimes = new Float64Array(count);
const outValues = new Float64Array(count);
let out = 0;
for (let i = 0; i < length; i++) {
const idx = (startIdx + i) % bufSize;
const t = times[idx];
if (t >= tMin) {
outTimes[out] = t;
outValues[out] = values[idx];
out++;
}
}
return [outTimes, outValues];
}
export default function Mock1GraphPage() {
const params = useParams<{ serial: string }>();
const serial = () => parseInt(params.serial);
// createMock1SeriesBuffer opens its own socket; it disconnects on page unmount.
// We hold a plain JS reference to the series object — no signal dependency here.
// The rAF loop inside UPlotGraph reads from it directly every frame.
const [getSeriesTick] = createMock1SeriesBuffer(serial());
const [timeWindow, setTimeWindow] = createSignal(30); // seconds
// These callbacks are called once per animation frame by UPlotGraph.
// They capture `getSeriesTick` to always read from the latest ring buffers.
function getSumData(): uPlot.AlignedData {
const s = getSeriesTick();
const [t, v] = windowedSlice(s.amplitude_sum, timeWindow());
return [t, v];
}
function getAllWavesData(): uPlot.AlignedData {
const s = getSeriesTick();
const tw = timeWindow();
const [t, v1] = windowedSlice(s.amplitude1, tw);
const [, v2] = windowedSlice(s.amplitude2, tw);
const [, v3] = windowedSlice(s.amplitude3, tw);
return [t, v1, v2, v3];
}
const sumSeries: uPlot.Series[] = [
{ label: "Sum", stroke: "#6c8cff", width: 2 },
];
const allWavesSeries: uPlot.Series[] = [
{ label: "Wave 1", stroke: "#6c8cff", width: 1.5 },
{ label: "Wave 2", stroke: "#f472b6", width: 1.5 },
{ label: "Wave 3", stroke: "#34d399", width: 1.5 },
];
const sumLines: HorizLine[] = [
{ value: 0.8, color: "#6c8cff", dash: true, label: "threshold" },
{ value: -0.8, color: "#6c8cff", dash: true },
];
return (
<div class="page page-wide">
<h1>Mock Machine — Graph</h1>
<p class="serial">Serial: #{serial()}</p>
<div class="graph-toolbar">
<span class="graph-toolbar-label">Window</span>
{TIME_WINDOWS.map((w) => (
<button
class={`graph-window-btn${timeWindow() === w.value ? " active" : ""}`}
onClick={() => setTimeWindow(w.value)}
>
{w.label}
</button>
))}
</div>
<div class="graph-stack">
<div class="graph-card">
<div class="graph-card-title">Amplitude Sum</div>
<UPlotGraph
getSeriesData={getSumData}
series={sumSeries}
unit="amp"
height={260}
timeWindow={timeWindow()}
lines={sumLines}
/>
</div>
<div class="graph-card">
<div class="graph-card-title">Individual Waves</div>
<UPlotGraph
getSeriesData={getAllWavesData}
series={allWavesSeries}
unit="amp"
height={260}
timeWindow={timeWindow()}
/>
</div>
</div>
</div>
);
}
@@ -1,112 +0,0 @@
/**
* Test machine control page — LED toggle proof of concept
*
* This replaces:
* electron/src/machines/testmachine/TestMachineControlPage.tsx
* electron/src/machines/testmachine/useTestMachine.ts (~95 lines)
* electron/src/machines/testmachine/testMachineNamespace.ts (~77 lines)
*
* In SolidJS the entire machine data + mutation logic lives here or in a
* thin helper — no Zustand, no ThrottledStoreUpdater, no lifecycle hooks.
*
* Optimistic updates: SolidJS signals update synchronously, so the UI
* reflects the change immediately. The server will confirm via the next
* StateEvent pushed over WebSocket.
*/
import { For, Show, createSignal } from "solid-js";
import { useParams } from "@solidjs/router";
import { createTestMachineNamespace } from "../namespaces/testMachine";
import { mutateMachine } from "../lib/api";
// machine_identification for testmachine: vendor=1, machine=0x0033 (51)
const VENDOR = 1;
const MACHINE = 0x0033;
export default function TestMachineControlPage() {
const params = useParams<{ serial: string }>();
const serial = () => parseInt(params.serial);
// createNamespace() creates the socket and returns a reactive signal.
// The signal is updated whenever a StateEvent arrives.
// The socket is disconnected automatically when this component unmounts.
const [machineState] = createTestMachineNamespace(VENDOR, MACHINE, serial());
// Optimistic LED state — start from server state, update immediately on click
const [optimisticLeds, setOptimisticLeds] = createSignal<boolean[] | null>(null);
// Derive: prefer optimistic state, fall back to server state
const leds = () => optimisticLeds() ?? machineState().led_on;
const setLed = async (index: number, on: boolean) => {
// Optimistic update — instant UI feedback
const current = leds();
if (current) {
const next = [...current];
next[index] = on;
setOptimisticLeds(next);
}
try {
await mutateMachine(
{
machine_identification: { vendor: VENDOR, machine: MACHINE },
serial: serial(),
},
{ action: "SetLed", value: { index, on } },
);
} catch (e) {
console.error("SetLed failed:", e);
// Revert optimistic update on error
setOptimisticLeds(null);
}
};
const setAllLeds = async (on: boolean) => {
const current = leds();
if (current) {
setOptimisticLeds(current.map(() => on));
}
try {
await mutateMachine(
{
machine_identification: { vendor: VENDOR, machine: MACHINE },
serial: serial(),
},
{ action: "SetAllLeds", value: { on } },
);
} catch (e) {
console.error("SetAllLeds failed:", e);
setOptimisticLeds(null);
}
};
return (
<div class="page">
<h1>Test Machine — Control</h1>
<p class="serial">Serial: #{serial()}</p>
<Show when={leds() !== null} fallback={<p class="muted">Waiting for machine state…</p>}>
<div class="led-grid">
<For each={leds()!}>
{(on, i) => (
<button
class={`led-btn ${on ? "led-on" : "led-off"}`}
onClick={() => setLed(i(), !on)}
>
LED {i() + 1}
<span class="led-indicator">{on ? "●" : "○"}</span>
</button>
)}
</For>
</div>
<div class="led-actions">
<button onClick={() => setAllLeds(true)}>All On</button>
<button onClick={() => setAllLeds(false)}>All Off</button>
</div>
</Show>
</div>
);
}
-537
View File
@@ -1,537 +0,0 @@
/* ── Reset & base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #242736;
--border: #2e3148;
--accent: #6c8cff;
--accent-dim: #3b4fa8;
--text: #e2e4f0;
--text-muted: #717898;
--ok: #4ade80;
--error: #f87171;
--font: 'Inter', system-ui, sans-serif;
--radius: 8px;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
height: 100vh;
overflow: hidden;
}
#root { height: 100vh; }
/* ── App layout ── */
.app-layout {
display: flex;
height: 100vh;
}
.app-content {
flex: 1;
overflow-y: auto;
padding: 32px;
}
/* ── Sidebar ── */
.sidebar {
width: 220px;
min-width: 220px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 16px 0;
}
.sidebar-brand {
padding: 8px 16px 16px;
font-weight: 600;
font-size: 15px;
color: var(--accent);
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
.sidebar-section {
padding: 4px 0;
}
.sidebar-section-label {
padding: 8px 16px 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.sidebar-link {
display: block;
padding: 7px 16px;
color: var(--text);
text-decoration: none;
border-radius: 0;
transition: background 0.1s;
}
.sidebar-link:hover { background: var(--surface2); }
.sidebar-link.active { color: var(--accent); background: var(--surface2); }
.sidebar-sublink {
display: block;
padding: 4px 16px 4px 28px;
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
}
.sidebar-sublink:hover { color: var(--text); }
.sidebar-sublink.active { color: var(--accent); }
.sidebar-serial { color: var(--text-muted); font-size: 12px; }
.sidebar-footer {
margin-top: auto;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
border-top: 1px solid var(--border);
}
.connection-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.connection-dot.connected { background: var(--ok); }
.connection-dot.disconnected { background: var(--error); }
/* ── Pages ── */
.page {
max-width: 800px;
}
.page h1 {
font-size: 22px;
font-weight: 600;
margin-bottom: 20px;
}
.muted { color: var(--text-muted); }
.serial { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
/* ── Machine list ── */
.machine-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.machine-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.machine-card.error { border-color: var(--error); }
.machine-info {
display: flex;
align-items: center;
gap: 10px;
}
.machine-info strong { font-weight: 500; }
.error-badge {
background: #3b1c1c;
color: var(--error);
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
}
.machine-links {
display: flex;
gap: 8px;
}
.machine-links a {
padding: 5px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
text-decoration: none;
font-size: 13px;
}
.machine-links a:hover { background: var(--accent-dim); border-color: var(--accent); }
/* ── LED control ── */
.led-grid {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.led-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 24px;
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius);
color: var(--text);
cursor: pointer;
font-size: 14px;
transition: all 0.12s;
min-width: 90px;
}
.led-btn:hover { border-color: var(--accent-dim); }
.led-btn.led-on {
border-color: var(--ok);
background: #0d2b1a;
}
.led-indicator {
font-size: 20px;
}
.led-btn.led-on .led-indicator { color: var(--ok); }
.led-btn.led-off .led-indicator { color: var(--text-muted); }
.led-actions {
display: flex;
gap: 8px;
}
.led-actions button {
padding: 8px 20px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
font-size: 13px;
}
.led-actions button:hover {
background: var(--accent-dim);
border-color: var(--accent);
}
/* ── Control grid (used by machine control pages) ── */
.control-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.control-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.control-card-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 14px;
}
/* ── Live values ── */
.live-values {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Row with sparkline: [label + number] [graph] */
.live-value-row-spark {
display: flex;
align-items: center;
gap: 12px;
min-height: 64px;
}
.live-value-left {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 68px;
flex-shrink: 0;
}
.live-value-spark {
flex: 1;
min-width: 0;
height: 64px;
overflow: hidden;
}
.live-value-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.live-value-number {
font-variant-numeric: tabular-nums;
font-size: 15px;
font-weight: 500;
color: var(--text);
}
/* Card wide enough to show the sparklines nicely */
.live-values-card {
grid-column: span 2;
}
/* MiniGraph container */
.minigraph-wrap {
width: 100%;
display: block;
height: 64px;
overflow: hidden;
}
/* ── Frequency inputs ── */
.freq-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.freq-input-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.freq-label {
font-size: 12px;
color: var(--text-muted);
}
.freq-input-row {
display: flex;
gap: 6px;
align-items: center;
}
.freq-input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
color: var(--text);
font-size: 14px;
font-family: var(--font);
}
.freq-input:focus {
outline: none;
border-color: var(--accent);
}
.freq-reset-btn {
padding: 5px 8px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.freq-reset-btn:hover {
color: var(--text);
border-color: var(--accent-dim);
}
/* ── Mode selector ── */
.mode-selector {
display: flex;
gap: 8px;
}
.mode-btn {
flex: 1;
padding: 8px 16px;
background: var(--surface2);
border: 2px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
font-family: var(--font);
transition: all 0.12s;
}
.mode-btn:hover {
border-color: var(--accent-dim);
color: var(--text);
}
.mode-btn.active {
border-color: var(--accent);
color: var(--accent);
background: #1a2040;
}
.mode-btn.active.running {
border-color: var(--ok);
color: var(--ok);
background: #0d2b1a;
}
/* ── Graph page ── */
.page-wide {
max-width: 1100px;
}
.graph-stack {
display: flex;
flex-direction: column;
gap: 20px;
}
.graph-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.graph-card-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 12px;
}
/* ── Graph toolbar (time-window selector) ── */
.graph-toolbar {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 16px;
}
.graph-toolbar-label {
font-size: 12px;
color: var(--text-muted);
margin-right: 2px;
}
.graph-window-btn {
padding: 4px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
font-family: var(--font);
transition: all 0.1s;
}
.graph-window-btn:hover {
color: var(--text);
border-color: var(--accent-dim);
}
.graph-window-btn.active {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--text);
}
/* uPlot container — must be a block that fills its parent */
.uplot-wrap {
width: 100%;
display: block;
}
/* Hide the built-in title (we use .graph-card-title instead) */
.uplot-wrap .u-title {
display: none;
}
/* ── Legend table ── */
/* uPlot renders the legend as a <table>; our CSS reset strips table layout,
so we need to restore it explicitly. */
.uplot-wrap .u-legend {
display: table;
font-family: var(--font);
font-size: 12px;
color: var(--text-muted);
padding-top: 8px;
border-collapse: collapse;
width: 100%;
}
.uplot-wrap .u-legend tr {
display: table-row;
}
.uplot-wrap .u-legend th,
.uplot-wrap .u-legend td {
display: table-cell;
padding: 2px 8px 2px 0;
vertical-align: middle;
white-space: nowrap;
}
/* First row is the header (Time / series names) */
.uplot-wrap .u-legend .u-thead tr {
display: table-row;
}
/* Series colour swatch */
.uplot-wrap .u-legend .u-marker {
width: 12px;
height: 3px;
display: inline-block;
border-radius: 2px;
vertical-align: middle;
margin-right: 4px;
}
/* Cursor crosshair colour */
.uplot-wrap .u-cursor-x,
.uplot-wrap .u-cursor-y {
border-color: var(--text-muted) !important;
}
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
// General
"jsx": "preserve",
"jsxImportSource": "solid-js",
"target": "ESNext",
// Modules
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"isolatedModules": true,
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
// Type Checking & Safety
"strict": true,
"types": ["vite/client"]
}
}
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
import devtools from 'solid-devtools/vite';
export default defineConfig({
plugins: [devtools(), solidPlugin()],
server: {
port: 3000,
},
build: {
target: 'esnext',
},
});
+4 -5
View File
@@ -9,7 +9,7 @@ workspace = true
[dependencies]
ethercrab = { git = "https://github.com/v-morlock/ethercrab", branch = "main" }
ethercrab = "0.6"
ethercat_hal = { path = "../ethercat-hal" }
control_core = { path = "../control-core" }
@@ -27,10 +27,10 @@ tokio = { version = "1.45.1", features = ["rt-multi-thread"] }
regex = "1.11.3"
# web
serde_json = "1.0.149"
serde_json = "1.0.143"
socketioxide = { version = "0.17.2", features = ["msgpack"] }
tower-http = { version = "0.6.6", features = ["cors", "trace", "fs"] }
axum = { version = "0.8.8", features = ["macros"] }
axum = { version = "0.8.6", features = ["macros"] }
# serial
serialport = "4.7.3"
@@ -38,7 +38,7 @@ serialport = "4.7.3"
#tracing
tracing = { version = "0.1.44", features = ["attributes"] }
tracing-journald = { version = "0.3.1", optional = true }
tracing-subscriber = { version = "0.3.22", features = [
tracing-subscriber = { version = "0.3.20", features = [
"env-filter",
"chrono",
"registry",
@@ -47,7 +47,6 @@ utils = { version = "0.1.0", path="../utils" }
spin_sleep = "1.3.3"
dhat = { version = "0.3.3", optional = true }
ctrlc = { version = "3.3", optional = true }
ta = "0.5.0"
[dev-dependencies]
+2 -6
View File
@@ -7,7 +7,6 @@ use anyhow::{Result, bail};
use control_core::socketio::event::GenericEvent;
use ethercat_hal::devices::EthercatDevice;
use ethercrab::SubDeviceRef;
use ethercrab::subdevice_group::HasDc;
use ethercrab::{MainDevice, SubDeviceGroup, subdevice_group::Op};
use machines::machine_identification::{DeviceIdentification, MachineIdentificationUnique};
use machines::serial::registry::SERIAL_DEVICE_REGISTRY;
@@ -103,25 +102,22 @@ pub struct EthercatSetup {
/// All Ethercat devices
/// Generic interface for all devices
/// Needed to interface with the devices on an Ethercat level
pub group: SubDeviceGroup<MAX_SUBDEVICES, PDI_LEN, Op, HasDc>,
pub group: SubDeviceGroup<MAX_SUBDEVICES, PDI_LEN, Op>,
/// The Ethercat main device
/// Needed to interface with the devices
pub maindevice: MainDevice<'static>,
pub all_operational: bool,
}
impl EthercatSetup {
pub fn new(
devices: Vec<(DeviceIdentification, Arc<RwLock<dyn EthercatDevice>>)>,
group: SubDeviceGroup<MAX_SUBDEVICES, PDI_LEN, Op, HasDc>,
group: SubDeviceGroup<MAX_SUBDEVICES, PDI_LEN, Op>,
maindevice: MainDevice<'static>,
all_operational: bool,
) -> Self {
Self {
devices,
group,
maindevice,
all_operational,
}
}
}
+26 -2
View File
@@ -1,7 +1,12 @@
use crate::metrics::io::set_ethercat_iface;
use control_core::ethercat::interface_discovery::discover_ethercat_interface;
use smol::Timer;
use std::time::Duration;
use std::{sync::Arc, time::Duration};
use crate::{
app_state::SharedState,
ethercat::{ethercat_discovery_info::send_ethercat_found, setup::setup_loop},
metrics::io::set_ethercat_iface,
};
pub async fn find_ethercat_interface() -> String {
loop {
@@ -18,3 +23,22 @@ pub async fn find_ethercat_interface() -> String {
}
}
}
pub async fn start_interface_discovery(app_state: Arc<SharedState>) {
let interface = find_ethercat_interface().await;
tracing::info!("Calling setup_loop");
let res = setup_loop(&interface, app_state.clone()).await;
match res {
Ok(_) => tracing::info!("Successfully initialized EtherCAT network"),
Err(e) => {
tracing::error!(
"[{}::main] Failed to initialize EtherCAT network \n{:?}",
module_path!(),
e
);
}
}
send_ethercat_found(app_state, &interface).await;
}
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more