Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f8d5070e | ||
|
|
af0b5deff0 | ||
|
|
743a589c1d | ||
|
|
ee7550d89f | ||
|
|
ec13f715aa | ||
|
|
2af746e7a4 | ||
|
|
3af3fb9d79 | ||
|
|
ff3103bec2 | ||
|
|
5eb3c39f84 | ||
|
|
2c715d6a8e | ||
|
|
fb12001721 | ||
|
|
ad94754fcf | ||
|
|
b919721c3a | ||
|
|
a428a2aa02 | ||
|
|
7b479c6021 | ||
|
|
17ac24c79d | ||
|
|
8c7a04b616 | ||
|
|
fe248c79ae | ||
|
|
038df09611 | ||
|
|
9e43d8d431 | ||
|
|
d364263d6e | ||
|
|
376e4a4ea2 | ||
|
|
0b30ae9a85 | ||
|
|
528952238d | ||
|
|
8c32ce2a2b | ||
|
|
579ef0d2e2 | ||
|
|
b6263e90d6 | ||
|
|
f67f098ff3 | ||
|
|
1a3c26b789 | ||
|
|
49f0c9f289 | ||
|
|
e60daed274 | ||
|
|
9a9120398e | ||
|
|
dec342498c | ||
|
|
bc8cc5dd3d | ||
|
|
4a1f3d125d | ||
|
|
1d1eeb3d48 | ||
|
|
c395d4e172 | ||
|
|
5009c5ca75 | ||
|
|
50dbc46b8c | ||
|
|
774e7e2f8b | ||
|
|
270872fe81 | ||
|
|
a749f59186 | ||
|
|
bae1de3c54 | ||
|
|
7681dae32c | ||
|
|
2d98d369e1 | ||
|
|
310ac42a88 | ||
|
|
182df9c569 | ||
|
|
153a719d64 | ||
|
|
84603d1594 | ||
|
|
17b2e3e297 | ||
|
|
fb1cf33c7f | ||
|
|
ae2d0fa8b1 | ||
|
|
ed06b63b77 | ||
|
|
9f0f45d19c | ||
|
|
a75c0ff08a | ||
|
|
205432c926 | ||
|
|
781783464b | ||
|
|
4fa2a1a221 | ||
|
|
b734468741 | ||
|
|
1ed4ae3d2c | ||
|
|
8095222cad | ||
|
|
d19af0923c | ||
|
|
08ce5a37bd | ||
|
|
bef3389874 | ||
|
|
985937c227 |
No files matched your search
@@ -1,3 +0,0 @@
|
||||
.png filter=lfs diff=lfs merge=lfs -text
|
||||
.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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).
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,80 @@
|
||||
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```'
|
||||
})
|
||||
@@ -204,7 +204,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -221,7 +221,7 @@ checksum = "99e1aca718ea7b89985790c94aad72d77533063fe00bc497bb79a7c2dae6a661"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -232,9 +232,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.7"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
@@ -291,7 +291,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -390,9 +390,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.0"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -539,7 +539,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -682,7 +682,7 @@ dependencies = [
|
||||
"deluxe-macros",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -695,7 +695,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -710,7 +710,7 @@ dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -721,7 +721,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -962,7 +962,7 @@ dependencies = [
|
||||
"deluxe",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1011,14 +1011,14 @@ dependencies = [
|
||||
"criterion",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "euclid"
|
||||
version = "0.22.11"
|
||||
version = "0.22.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48"
|
||||
checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
@@ -1875,7 +1875,7 @@ dependencies = [
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1905,7 +1905,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2218,7 +2218,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2248,20 +2248,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.145"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2571,9 +2571,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.111"
|
||||
version = "2.0.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2625,7 +2625,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2685,7 +2685,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2847,7 +2847,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2884,9 +2884,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.20"
|
||||
version = "0.3.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"matchers",
|
||||
@@ -3078,7 +3078,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -3143,7 +3143,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3154,7 +3154,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3430,5 +3430,11 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
|
||||
@@ -2,109 +2,76 @@
|
||||
|
||||
# QiTech Control
|
||||
|
||||
QiTech Control is an open-source framework designed to bring modern software paradigms to certified industrial hardware.
|
||||
QiTech Control is an open-source framework designed to bring modern software development practices 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
|
||||
|
||||
[](https://www.youtube.com/watch?v=KI3YeBwfV-s)
|
||||
*Click here to watch a video demo of our software.*
|
||||
**[View Full Documentation Wiki](https://github.com/qitechgmbh/control/wiki)**
|
||||
|
||||
[](https://youtu.be/55egCAkQgyM)
|
||||
*Click here to watch a full explainer Video 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)
|
||||
|
||||
# Repo Structure
|
||||
## Videos
|
||||
|
||||
**Frontend**
|
||||
| Software Demo | Full Explainer |
|
||||
|---------------|----------------|
|
||||
| [](https://www.youtube.com/watch?v=KI3YeBwfV-s)<br/>*Watch a demo of the control software in action* | [](https://youtu.be/55egCAkQgyM)<br/>*Watch a complete overview of QiTech Control* |
|
||||
|
||||
- `/electron`: Frontend code for the control software built with React and Electron.
|
||||
## Repository Structure
|
||||
|
||||
**Backend**
|
||||
- **`/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
|
||||
|
||||
- `/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.
|
||||
## Technology Stack
|
||||
|
||||
**Operating System**
|
||||
**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
|
||||
|
||||
- `/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
|
||||
**Frontend:** [Electron](https://www.electronjs.org/) + [React](https://react.dev/) with [Shadcn](https://ui.shadcn.com/) components and [Tailwind](https://tailwindcss.com/) styling
|
||||
|
||||
|
||||
## Minimal Hardware Examples
|
||||
## Hardware Examples
|
||||
|
||||
For complete step-by-step tutorials on setting up your first hardware, including wiring diagrams and software configuration, see the [Getting Started Guide](./docs/developer-docs/getting-started.md#minimal-hardware-examples).
|
||||
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.
|
||||
|
||||
| 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) |
|
||||
**[View all hardware examples and tutorials on the wiki](https://github.com/qitechgmbh/control/wiki/Hardware-Examples)**
|
||||
|
||||
# QiTech Machines
|
||||
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)
|
||||
|
||||
| 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 Machines
|
||||
|
||||
# Current Restructuring Efforts
|
||||
QiTech Control powers 10+ production machines for filament extrusion, winding, measurement, and material processing.
|
||||
|
||||
[](https://www.youtube.com/live/UlbnSVIhfLI?si=ZTotC5B7gd87tUim)
|
||||
*Video: Live stream of software dev meeting*
|
||||
**[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://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://www.youtube.com/watch?v=R5WGAY3WRWA&t=1s)<br/>**Software Stack Day**<br/>*A fast tour through the tools, stack, and daily workflow.* |
|
||||
| [](https://www.youtube.com/watch?v=xyaHwxXguw4&t=32s)<br/>**First Client**<br/>*A milestone week building the first real client project.* | [](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.
|
||||
@@ -9,7 +9,7 @@ workspace = true
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
|
||||
axum = "0.8.6"
|
||||
axum = "0.8.8"
|
||||
|
||||
ethercat_hal = { path = "../ethercat-hal" }
|
||||
units = { path = "../units" }
|
||||
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 687 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 985 KiB |
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "qitech-control-electron",
|
||||
"version": "1.0.0",
|
||||
"version": "2.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "qitech-control-electron",
|
||||
"version": "1.0.0",
|
||||
"version": "2.4.1",
|
||||
"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": "^15.4.1",
|
||||
"react-i18next": "^16.5.4",
|
||||
"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.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"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.8"
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"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.20.1",
|
||||
"eslint": "^9.39.2",
|
||||
"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": "^3.0.6"
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -1305,9 +1305,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
|
||||
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
|
||||
"version": "9.39.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
|
||||
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2889,6 +2889,13 @@
|
||||
"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",
|
||||
@@ -4116,39 +4123,40 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"chai": "^6.2.1",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
|
||||
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/spy": "4.0.18",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
@@ -4160,42 +4168,41 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
|
||||
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^2.0.0"
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
|
||||
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
"@vitest/utils": "4.0.18",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
|
||||
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"magic-string": "^0.30.17",
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -4203,28 +4210,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
|
||||
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^4.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
|
||||
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -4639,16 +4642,6 @@
|
||||
"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",
|
||||
@@ -4769,18 +4762,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"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"
|
||||
}
|
||||
@@ -4842,16 +4828,6 @@
|
||||
"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",
|
||||
@@ -5134,16 +5110,6 @@
|
||||
"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",
|
||||
@@ -5812,9 +5778,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.39.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"version": "9.39.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -5825,7 +5791,7 @@
|
||||
"@eslint/config-helpers": "^0.4.2",
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@eslint/js": "9.39.2",
|
||||
"@eslint/plugin-kit": "^0.4.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
@@ -8069,13 +8035,6 @@
|
||||
"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",
|
||||
@@ -9232,6 +9191,17 @@
|
||||
"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",
|
||||
@@ -9420,16 +9390,6 @@
|
||||
"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",
|
||||
@@ -9786,16 +9746,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "15.7.4",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
|
||||
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
|
||||
"version": "16.5.4",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz",
|
||||
"integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 23.4.0",
|
||||
"i18next": ">= 25.6.2",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
@@ -10509,13 +10470,13 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.2",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
@@ -10523,23 +10484,6 @@
|
||||
"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",
|
||||
@@ -10795,26 +10739,6 @@
|
||||
"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",
|
||||
@@ -10955,11 +10879,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
@@ -11007,30 +10934,10 @@
|
||||
"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": "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==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -11670,29 +11577,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -11761,51 +11645,50 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.2",
|
||||
"std-env": "^3.9.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^3.10.0",
|
||||
"tinybench": "^2.9.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",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"vite": "^6.0.0 || ^7.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@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",
|
||||
"@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",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
@@ -11813,13 +11696,19 @@
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/debug": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
@@ -12205,9 +12094,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
||||
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "qitech-control-electron",
|
||||
"productName": "QiTech Control",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"version": "2.4.1",
|
||||
"description": "Next Generation Machine Interface ",
|
||||
"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.20.1",
|
||||
"eslint": "^9.39.2",
|
||||
"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": "^3.0.6"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"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": "^15.4.1",
|
||||
"react-i18next": "^16.5.4",
|
||||
"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.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"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.8"
|
||||
"zustand": "^5.0.11"
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function Aquapath1Page() {
|
||||
link: "graph",
|
||||
title: "Graph",
|
||||
icon: "lu:ChartSpline",
|
||||
activeLink: "graphs",
|
||||
activeLink: "graph",
|
||||
},
|
||||
{
|
||||
link: "settings",
|
||||
|
||||
@@ -390,6 +390,11 @@ export const aquapath1: MachineProperties = {
|
||||
product_id: 0x7d83052,
|
||||
revision: 0x110000,
|
||||
},
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0x7d83052,
|
||||
revision: 0x120000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -473,6 +478,30 @@ 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",
|
||||
@@ -579,6 +608,29 @@ 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",
|
||||
@@ -616,7 +668,9 @@ export const machineProperties: MachineProperties[] = [
|
||||
analogInputTestMachine,
|
||||
wagoAiTestMachine,
|
||||
digitalInputTestMachine,
|
||||
wago8chDioTestMachine,
|
||||
ip20TestMachine,
|
||||
wagoDoTestMachine,
|
||||
TestMotor,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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 === "StateEvent") {
|
||||
if (eventName === "State") {
|
||||
const stateEvent = stateEventSchema.parse(event);
|
||||
|
||||
updateStore((state) => ({
|
||||
@@ -86,7 +86,7 @@ function wagoPower1MessageHandler(
|
||||
? stateEvent
|
||||
: state.defaultState,
|
||||
}));
|
||||
} else if (eventName === "LiveValuesEvent") {
|
||||
} else if (eventName === "LiveValues") {
|
||||
console.log(event);
|
||||
const liveValues = liveValuesEventSchema.parse(event);
|
||||
const { voltage, current } = liveValues.data;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { wagoDoTestMachineSerialRoute } from "@/routes/routes";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import {
|
||||
useWagoDoTestMachineNamespace,
|
||||
StateEvent,
|
||||
} from "./WagoDoTestMachineNamespace";
|
||||
import { useMachineMutate } from "@/client/useClient";
|
||||
import { produce } from "immer";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { wagoDoTestMachine } from "@/machines/properties";
|
||||
import { z } from "zod";
|
||||
|
||||
export function useWagoDoTestMachine() {
|
||||
const { serial: serialString } = wagoDoTestMachineSerialRoute.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: wagoDoTestMachine.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]);
|
||||
|
||||
// Namespace state from backend
|
||||
const { state } = useWagoDoTestMachineNamespace(machineIdentification);
|
||||
|
||||
// Optimistic state
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
|
||||
useEffect(() => {
|
||||
if (state) stateOptimistic.setReal(state);
|
||||
}, [state, stateOptimistic]);
|
||||
|
||||
// Generic mutation sender
|
||||
const { request: sendMutation } = useMachineMutate(
|
||||
z.object({
|
||||
action: z.string(),
|
||||
value: z.any(),
|
||||
}),
|
||||
);
|
||||
|
||||
const updateStateOptimistically = (
|
||||
producer: (current: StateEvent) => void,
|
||||
serverRequest?: () => void,
|
||||
) => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState)
|
||||
stateOptimistic.setOptimistic(produce(currentState, producer));
|
||||
serverRequest?.();
|
||||
};
|
||||
|
||||
const setLed = (index: number, on: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.led_on[index] = on;
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetLed", value: { index, on } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setAllLeds = (on: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.led_on = Array(8).fill(on);
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetAllLeds", value: { on } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
state: stateOptimistic.value,
|
||||
setLed,
|
||||
setAllLeds,
|
||||
};
|
||||
}
|
||||
@@ -75,6 +75,10 @@ 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";
|
||||
|
||||
// make a route tree like this
|
||||
// _mainNavigation/machines/winder2/$serial/control
|
||||
@@ -89,6 +93,18 @@ 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,
|
||||
@@ -383,6 +399,18 @@ 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",
|
||||
@@ -491,6 +519,10 @@ export const rootTree = RootRoute.addChildren([
|
||||
analogInputTestMachineControlRoute,
|
||||
]),
|
||||
|
||||
wago8chDioTestMachineRoute.addChildren([
|
||||
wago8chDioTestMachineControlRoute,
|
||||
]),
|
||||
|
||||
wagoAiTestMachineSerialRoute.addChildren([wagoAiTestMachineControlRoute]),
|
||||
|
||||
digitalInputTestMachineSerialRoute.addChildren([
|
||||
@@ -541,6 +573,8 @@ export const rootTree = RootRoute.addChildren([
|
||||
wagoPower1SerialRoute.addChildren([wagoPower1ControlRoute]),
|
||||
|
||||
buffer1SerialRoute.addChildren([buffer1ControlRoute]),
|
||||
|
||||
wagoDoTestMachineSerialRoute.addChildren([wagoDoTestMachineControlRoute]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
@@ -303,6 +303,7 @@ impl EL5152ChannelConfiguration {
|
||||
device
|
||||
.sdo_write(base_index, 0x11, self.frequency_window)
|
||||
.await?;
|
||||
|
||||
device
|
||||
.sdo_write(base_index, 0x13, self.frequency_scaling)
|
||||
.await?;
|
||||
|
||||
@@ -38,27 +38,27 @@ impl From<Wago750_1506OutputPort> for usize {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Wago750_1506InputPort {
|
||||
DO1,
|
||||
DO2,
|
||||
DO3,
|
||||
DO4,
|
||||
DO5,
|
||||
DO6,
|
||||
DO7,
|
||||
DO8,
|
||||
DI1,
|
||||
DI2,
|
||||
DI3,
|
||||
DI4,
|
||||
DI5,
|
||||
DI6,
|
||||
DI7,
|
||||
DI8,
|
||||
}
|
||||
|
||||
impl From<Wago750_1506InputPort> for usize {
|
||||
fn from(value: Wago750_1506InputPort) -> Self {
|
||||
match value {
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::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,
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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::DO1);
|
||||
let idx1 = base + Into::<usize>::into(Wago750_1506InputPort::DI1);
|
||||
self.tx_pdo.port1 = *input.get(idx1).expect("Bit 1 out of bounds");
|
||||
|
||||
let idx2 = base + Into::<usize>::into(Wago750_1506InputPort::DO2);
|
||||
let idx2 = base + Into::<usize>::into(Wago750_1506InputPort::DI2);
|
||||
self.tx_pdo.port2 = *input.get(idx2).expect("Bit 2 out of bounds");
|
||||
|
||||
let idx3 = base + Into::<usize>::into(Wago750_1506InputPort::DO3);
|
||||
let idx3 = base + Into::<usize>::into(Wago750_1506InputPort::DI3);
|
||||
self.tx_pdo.port3 = *input.get(idx3).expect("Bit 3 out of bounds");
|
||||
|
||||
let idx4 = base + Into::<usize>::into(Wago750_1506InputPort::DO4);
|
||||
let idx4 = base + Into::<usize>::into(Wago750_1506InputPort::DI4);
|
||||
self.tx_pdo.port4 = *input.get(idx4).expect("Bit 4 out of bounds");
|
||||
|
||||
let idx5 = base + Into::<usize>::into(Wago750_1506InputPort::DO5);
|
||||
let idx5 = base + Into::<usize>::into(Wago750_1506InputPort::DI5);
|
||||
self.tx_pdo.port5 = *input.get(idx5).expect("Bit 5 out of bounds");
|
||||
|
||||
let idx6 = base + Into::<usize>::into(Wago750_1506InputPort::DO6);
|
||||
let idx6 = base + Into::<usize>::into(Wago750_1506InputPort::DI6);
|
||||
self.tx_pdo.port6 = *input.get(idx6).expect("Bit 6 out of bounds");
|
||||
|
||||
let idx7 = base + Into::<usize>::into(Wago750_1506InputPort::DO7);
|
||||
let idx7 = base + Into::<usize>::into(Wago750_1506InputPort::DI7);
|
||||
self.tx_pdo.port7 = *input.get(idx7).expect("Bit 7 out of bounds");
|
||||
|
||||
let idx8 = base + Into::<usize>::into(Wago750_1506InputPort::DO8);
|
||||
let idx8 = base + Into::<usize>::into(Wago750_1506InputPort::DI8);
|
||||
self.tx_pdo.port8 = *input.get(idx8).expect("Bit 8 out of bounds");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,3 +2,74 @@ 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(())
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/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
|
||||
@@ -15,13 +15,13 @@ anyhow = "1.0.100"
|
||||
|
||||
bitvec = "1.0.1"
|
||||
lazy_static = "1.5.0"
|
||||
euclid = "0.22.11"
|
||||
euclid = "0.22.13"
|
||||
|
||||
# concurrency
|
||||
smol = "2.0.2"
|
||||
|
||||
# web
|
||||
serde_json = "1.0.143"
|
||||
serde_json = "1.0.149"
|
||||
socketioxide = { version = "0.17.2", features = ["msgpack"] }
|
||||
|
||||
# serial
|
||||
|
||||
@@ -32,7 +32,9 @@ 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;
|
||||
|
||||
@@ -50,9 +52,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 MOTOR_TEST_MACHINE: u16 = 0x0011;
|
||||
|
||||
pub const WAGO_DO_TEST_MACHINE: u16 = 0x000E;
|
||||
use serde_json::Value;
|
||||
use smol::lock::RwLock;
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ 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(),
|
||||
|
||||
_ => unreachable!("Unknown machine id"),
|
||||
}
|
||||
}
|
||||
@@ -187,6 +189,7 @@ 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 {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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,
|
||||
@@ -137,6 +139,11 @@ lazy_static! {
|
||||
|
||||
mc.register::<DigitalInputTestMachine>(DigitalInputTestMachine::MACHINE_IDENTIFICATION);
|
||||
|
||||
mc.register::<WagoDOTestMachine>(WagoDOTestMachine::MACHINE_IDENTIFICATION);
|
||||
mc.register::<Wago8chDigitalIOTestMachine>(
|
||||
Wago8chDigitalIOTestMachine::MACHINE_IDENTIFICATION,
|
||||
);
|
||||
|
||||
mc
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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 ¶ms.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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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 ¶ms.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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ buildNpmPackage rec {
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
|
||||
|
||||
makeCacheWritable = true;
|
||||
npmDepsHash = "sha256-wzVjsgcZsViPOyQKlKTb5gJIeY416KyCSiheSXSRZoc=";
|
||||
npmDepsHash = "sha256-rnaAsSkoTRoBgLCTF8ZFpi7uUQ+8W+u3qoMxsptX6NU=";
|
||||
npmFlags = [ "--no-audit" "--no-fund" ];
|
||||
|
||||
installPhase = ''
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
@@ -0,0 +1,298 @@
|
||||
# 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`).
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
## 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)
|
||||
@@ -0,0 +1,156 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,16 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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" />;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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" />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* @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!);
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
/* ── 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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -27,10 +27,10 @@ tokio = { version = "1.45.1", features = ["rt-multi-thread"] }
|
||||
regex = "1.11.3"
|
||||
|
||||
# web
|
||||
serde_json = "1.0.143"
|
||||
serde_json = "1.0.149"
|
||||
socketioxide = { version = "0.17.2", features = ["msgpack"] }
|
||||
tower-http = { version = "0.6.6", features = ["cors", "trace", "fs"] }
|
||||
axum = { version = "0.8.6", features = ["macros"] }
|
||||
axum = { version = "0.8.8", 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.20", features = [
|
||||
tracing-subscriber = { version = "0.3.22", features = [
|
||||
"env-filter",
|
||||
"chrono",
|
||||
"registry",
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
use crate::metrics::io::set_ethercat_iface;
|
||||
use control_core::ethercat::interface_discovery::discover_ethercat_interface;
|
||||
use smol::Timer;
|
||||
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,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
pub async fn find_ethercat_interface() -> String {
|
||||
loop {
|
||||
@@ -23,22 +18,3 @@ 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;
|
||||
}
|
||||
@@ -12,19 +12,25 @@ use control_core::socketio::namespace::NamespaceCacheingLogic;
|
||||
use control_core::{irq_handling::set_irq_affinity, realtime::set_realtime_priority};
|
||||
use ethercat_hal::debugging::diagnosis_history::get_most_recent_diagnosis_message;
|
||||
use ethercat_hal::devices::devices_from_subdevices;
|
||||
|
||||
use ethercat_hal::devices::wago_750_354::{
|
||||
WAGO_750_354_PRODUCT_ID, WAGO_750_354_VENDOR_ID, Wago750_354,
|
||||
};
|
||||
|
||||
use ethercat_hal::devices::wago_modules::ip20_ec_di8_do8::{
|
||||
IP20_EC_DI8_DO8_PRODUCT_ID, IP20_EC_DI8_DO8_VENDOR_ID, IP20EcDi8Do8,
|
||||
};
|
||||
|
||||
use ethercat_hal::helpers::set_mut_beckhoff_eeprom_lock_active;
|
||||
use ethercrab::error::Error;
|
||||
use ethercrab::subdevice_group::{DcConfiguration, HasDc, PreOpPdi, SafeOp};
|
||||
use smol::stream::StreamExt;
|
||||
use ta::Next;
|
||||
use ta::indicators::ExponentialMovingAverage;
|
||||
|
||||
#[cfg(not(feature = "development-build"))]
|
||||
use crate::utils::stop_dnsmasq;
|
||||
|
||||
use ethercrab::std::ethercat_now;
|
||||
use ethercrab::{
|
||||
DcSync, MainDevice, MainDeviceConfig, PduStorage, RegisterAddress, RetryBehaviour,
|
||||
@@ -178,17 +184,20 @@ pub async fn set_ethercat_devices<const MAX_SUBDEVICES: usize, const MAX_PDI: us
|
||||
|
||||
pub async fn setup_loop(
|
||||
interface: &str,
|
||||
cycle_target: Duration,
|
||||
app_state: Arc<SharedState>,
|
||||
) -> Result<EthercatSetup, anyhow::Error> {
|
||||
tracing::info!("Starting Ethercat PDU loop");
|
||||
const TICK_INTERVAL: Duration = Duration::from_micros(700);
|
||||
let res = stop_dnsmasq();
|
||||
match res {
|
||||
Ok(_) => (),
|
||||
Err(e) => tracing::error!("Failed to stop dnsmasq: {:?}", e),
|
||||
};
|
||||
// Small Timeout to ensure interfaces get released
|
||||
smol::Timer::after(Duration::from_millis(1500)).await;
|
||||
#[cfg(not(feature = "development-build"))]
|
||||
{
|
||||
let res = stop_dnsmasq();
|
||||
match res {
|
||||
Ok(_) => tracing::info!("Stopped dnsmasq"),
|
||||
Err(e) => tracing::error!("Failed to stop dnsmasq: {:?}", e),
|
||||
};
|
||||
// Small Timeout to ensure interfaces get released
|
||||
smol::Timer::after(Duration::from_millis(1500)).await;
|
||||
}
|
||||
|
||||
// Setup ethercrab tx/rx task
|
||||
let pdu_storage = Box::leak(Box::new(PduStorage::<MAX_FRAMES, MAX_PDU_DATA>::new()));
|
||||
@@ -237,11 +246,11 @@ pub async fn setup_loop(
|
||||
pdu,
|
||||
Timeouts {
|
||||
// Default 5000ms
|
||||
state_transition: Duration::from_millis(5000),
|
||||
state_transition: Duration::from_millis(20000),
|
||||
// Default 30_000us
|
||||
pdu: Duration::from_micros(30_000),
|
||||
// Default 10ms
|
||||
eeprom: Duration::from_millis(10),
|
||||
eeprom: Duration::from_millis(100),
|
||||
// Default 0ms
|
||||
wait_loop_delay: Duration::from_millis(0),
|
||||
// Default 100ms
|
||||
@@ -287,11 +296,11 @@ pub async fn setup_loop(
|
||||
};
|
||||
|
||||
for mut subdevice in group_preop.iter_mut(&maindevice) {
|
||||
println!(
|
||||
"subdevice {} {:X?}",
|
||||
subdevice.name(),
|
||||
subdevice.configured_address()
|
||||
);
|
||||
// 0x2 vendor id is Beckhoff
|
||||
if subdevice.identity().vendor_id == 0x2 {
|
||||
let _res = set_mut_beckhoff_eeprom_lock_active(&subdevice).await;
|
||||
}
|
||||
|
||||
if subdevice.name() == "EL4002" {
|
||||
// Sync mode 01 = SM Synchronous, says it does dc but actually doesnt, thx?
|
||||
subdevice
|
||||
@@ -308,7 +317,7 @@ pub async fn setup_loop(
|
||||
.expect("Set sync mode");
|
||||
|
||||
subdevice
|
||||
.sdo_write(0x1c32, 0x02, TICK_INTERVAL.as_nanos() as u32)
|
||||
.sdo_write(0x1c32, 0x02, cycle_target.as_nanos() as u32)
|
||||
.await
|
||||
.expect("Set cycle time");
|
||||
|
||||
@@ -472,14 +481,17 @@ pub async fn setup_loop(
|
||||
.await?;
|
||||
|
||||
// Avoid potential Sdo write Contention on the bus, by just waiting a bit
|
||||
// Timeout is not enough ... or rather a monstrous timeout would be required for all of the sdo writes we do ...
|
||||
// TODO: Keep small timeout here and add Complete Access configuration for the stepper terminals and complex trerminals like (el5152)
|
||||
smol::Timer::after(Duration::from_millis(4000)).await;
|
||||
|
||||
let mut now = Instant::now();
|
||||
let start = Instant::now();
|
||||
let mut averages = Vec::new();
|
||||
for _ in 0..group_preop.len() {
|
||||
averages.push(ExponentialMovingAverage::new(64).unwrap());
|
||||
}
|
||||
let mut tick_interval = smol::Timer::interval(TICK_INTERVAL);
|
||||
let mut tick_interval = smol::Timer::interval(cycle_target);
|
||||
|
||||
tracing::info!("Moving into PRE-OP with PDI");
|
||||
let group = group_preop.into_pre_op_pdi(&maindevice).await?;
|
||||
@@ -537,9 +549,9 @@ pub async fn setup_loop(
|
||||
// Start SYNC0 100ms in the future
|
||||
start_delay: Duration::from_millis(100),
|
||||
// SYNC0 period should be the same as the process data loop in most cases
|
||||
sync0_period: TICK_INTERVAL,
|
||||
sync0_period: cycle_target,
|
||||
// Send process data half way through cycle
|
||||
sync0_shift: TICK_INTERVAL / 2,
|
||||
sync0_shift: cycle_target / 2,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -90,7 +90,6 @@ pub fn start_loop_thread(
|
||||
match msg {
|
||||
HotThreadMessage::NoMsg => {}
|
||||
HotThreadMessage::AddEtherCatSetup(ethercat_setup) => {
|
||||
println!("EthercatSetup: {:?}", ethercat_setup.devices);
|
||||
rt_loop_inputs.ethercat_setup = Some(Box::new(ethercat_setup));
|
||||
}
|
||||
HotThreadMessage::WriteMachineDeviceInfo(info_request) => {
|
||||
@@ -115,7 +114,6 @@ pub fn start_loop_thread(
|
||||
.retain(|m| m.get_machine_identification_unique() != unique_id);
|
||||
}
|
||||
HotThreadMessage::AddMachines(machine_vec) => {
|
||||
tracing::info!("received machines{:?}", machine_vec);
|
||||
for new_machine in machine_vec {
|
||||
let id = new_machine.get_machine_identification_unique();
|
||||
if !rt_loop_inputs
|
||||
|
||||
@@ -15,9 +15,11 @@ use machines::{
|
||||
serial::{devices::laser::Laser, init::SerialDetection},
|
||||
winder2::api::GenericEvent,
|
||||
};
|
||||
use socketio::main_namespace::{
|
||||
MainNamespaceEvents, ethercat_devices_event::EthercatDevicesEventBuilder,
|
||||
use socketio::{
|
||||
main_namespace::{MainNamespaceEvents, ethercat_devices_event::EthercatDevicesEventBuilder},
|
||||
queue::start_socketio_queue,
|
||||
};
|
||||
|
||||
#[cfg(feature = "development-build")]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use utils::start_dnsmasq;
|
||||
@@ -37,6 +39,12 @@ use smol::{
|
||||
use socketioxide::extract::SocketRef;
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
#[cfg(feature = "development-build")]
|
||||
const CYCLE_TARGET_TIME: Duration = Duration::from_micros(2000);
|
||||
|
||||
#[cfg(not(feature = "development-build"))]
|
||||
const CYCLE_TARGET_TIME: Duration = Duration::from_micros(700);
|
||||
|
||||
#[cfg(feature = "mock-machine")]
|
||||
use mock_init::init_mock;
|
||||
|
||||
@@ -46,7 +54,6 @@ use crate::{
|
||||
setup::setup_loop,
|
||||
},
|
||||
modbus_tcp::start_modbus_tcp_discovery,
|
||||
socketio::queue::socketio_queue_worker,
|
||||
};
|
||||
|
||||
#[cfg(feature = "mock-machine")]
|
||||
@@ -128,10 +135,8 @@ pub async fn add_serial_device(
|
||||
pub async fn start_serial_discovery(app_state: Arc<SharedState>) {
|
||||
loop {
|
||||
let devices = SerialDetection::detect_devices();
|
||||
|
||||
// This allows detection of disconnected devices
|
||||
handle_serial_device_hotplug(app_state.clone(), devices).await;
|
||||
|
||||
smol::Timer::after(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
@@ -143,33 +148,34 @@ pub async fn start_interface_discovery(
|
||||
let interface = find_ethercat_interface().await;
|
||||
tracing::info!("Inferface found {}, setting up EtherCAT loop", interface);
|
||||
set_ethercat_iface(interface.clone());
|
||||
let res = setup_loop(&interface, app_state.clone()).await;
|
||||
let res = setup_loop(&interface, CYCLE_TARGET_TIME, app_state.clone()).await;
|
||||
|
||||
match res {
|
||||
Ok(setup) => {
|
||||
let _ = sender.send(HotThreadMessage::AddEtherCatSetup(setup)).await;
|
||||
tracing::info!("Successfully initialized EtherCAT devices");
|
||||
|
||||
{
|
||||
// Notify client via socketio
|
||||
let app_state_clone = app_state.clone();
|
||||
let main_namespace = &mut app_state_clone
|
||||
.socketio_setup
|
||||
.namespaces
|
||||
.write()
|
||||
.await
|
||||
.main_namespace;
|
||||
let event = EthercatDevicesEventBuilder()
|
||||
.build(app_state_clone.clone())
|
||||
.await;
|
||||
main_namespace.emit(MainNamespaceEvents::EthercatDevicesEvent(event));
|
||||
}
|
||||
// Notify client via socketio
|
||||
let app_state_clone = app_state.clone();
|
||||
let main_namespace = &mut app_state_clone
|
||||
.socketio_setup
|
||||
.namespaces
|
||||
.write()
|
||||
.await
|
||||
.main_namespace;
|
||||
let event = EthercatDevicesEventBuilder()
|
||||
.build(app_state_clone.clone())
|
||||
.await;
|
||||
main_namespace.emit(MainNamespaceEvents::EthercatDevicesEvent(event));
|
||||
|
||||
let res = start_dnsmasq();
|
||||
match res {
|
||||
Ok(o) => o,
|
||||
Err(e) => tracing::error!("Failed to start dnsmasq: {:?}", e),
|
||||
};
|
||||
#[cfg(not(feature = "development-build"))]
|
||||
{
|
||||
let res = start_dnsmasq();
|
||||
match res {
|
||||
Ok(o) => o,
|
||||
Err(e) => tracing::error!("Failed to start dnsmasq: {:?}", e),
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
@@ -178,6 +184,7 @@ pub async fn start_interface_discovery(
|
||||
e
|
||||
);
|
||||
// if this doesnt work, unlucky
|
||||
#[cfg(not(feature = "development-build"))]
|
||||
let _ = start_dnsmasq();
|
||||
}
|
||||
}
|
||||
@@ -317,15 +324,6 @@ async fn handle_async_requests(recv: Receiver<AsyncThreadMessage>, shared_state:
|
||||
tracing::warn!("Async handler task finished");
|
||||
}
|
||||
|
||||
pub async fn start_socketio_queue(app_state: Arc<SharedState>) {
|
||||
let app_state = app_state.as_ref();
|
||||
loop {
|
||||
let res = socketio_queue_worker(app_state).await;
|
||||
tracing::error!("SocketIO task finished, but should never finish: {:?}", res);
|
||||
tracing::error!("Restarting SocketIO...");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "development-build")]
|
||||
fn setup_ctrlc_handler() -> Arc<AtomicBool> {
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
@@ -355,8 +353,6 @@ fn main() {
|
||||
#[cfg(feature = "development-build")]
|
||||
let running = setup_ctrlc_handler();
|
||||
|
||||
const CYCLE_TARGET_TIME: Duration = Duration::from_micros(700);
|
||||
|
||||
// for the "hot thread"
|
||||
let (sender, receiver) = smol::channel::unbounded();
|
||||
let (main_sender, main_receiver) = smol::channel::unbounded();
|
||||
@@ -380,7 +376,6 @@ fn main() {
|
||||
|
||||
#[cfg(not(feature = "mock-machine"))]
|
||||
smol::spawn(start_interface_discovery(app_state.clone(), sender)).detach();
|
||||
|
||||
smol::spawn(start_modbus_tcp_discovery(app_state.clone())).detach();
|
||||
|
||||
smol::block_on(async {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::app_state::SharedState;
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use tracing::{debug, info, instrument, trace};
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, instrument, trace};
|
||||
|
||||
/// Send a single event with retry logic
|
||||
#[instrument(skip_all)]
|
||||
@@ -12,7 +12,7 @@ async fn send_event_with_retry(
|
||||
loop {
|
||||
// check if socket is still connected
|
||||
if !socket.connected() {
|
||||
trace!(
|
||||
info!(
|
||||
socket_id = ?socket.id,
|
||||
event = %event.name,
|
||||
"Socket disconnected, skipping event"
|
||||
@@ -32,7 +32,7 @@ async fn send_event_with_retry(
|
||||
}
|
||||
Err(e) => match e {
|
||||
socketioxide::SendError::Serialize(serialize_error) => {
|
||||
trace!(
|
||||
info!(
|
||||
socket_id = ?socket.id,
|
||||
event = %event.name,
|
||||
error = %serialize_error,
|
||||
@@ -45,7 +45,7 @@ async fn send_event_with_retry(
|
||||
continue; // Retry sending the event
|
||||
}
|
||||
socketioxide::SocketError::Closed => {
|
||||
trace!(
|
||||
info!(
|
||||
socket_id = ?socket.id,
|
||||
event = %event.name,
|
||||
"Socket closed, skipping event"
|
||||
@@ -58,40 +58,16 @@ async fn send_event_with_retry(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn socketio_queue_worker(app_state: &SharedState) {
|
||||
tracing::info!("SocketIO global queue listener started");
|
||||
let mut event_count = 0;
|
||||
let mut batch_start = Instant::now();
|
||||
|
||||
while let Ok((socket, event)) = app_state.socketio_setup.socket_queue_rx.recv().await {
|
||||
event_count += 1;
|
||||
|
||||
send_event_with_retry(&socket, &event).await;
|
||||
|
||||
if batch_start.elapsed().as_secs() >= 5 {
|
||||
let elapsed = batch_start.elapsed();
|
||||
if event_count > 0 {
|
||||
debug!(
|
||||
"[{}::socketio_queue_worker] Processed {} events in {:.2?} ({:.1} events/s)",
|
||||
module_path!(),
|
||||
event_count,
|
||||
elapsed,
|
||||
event_count as f64 / elapsed.as_secs_f64(),
|
||||
);
|
||||
}
|
||||
event_count = 0;
|
||||
batch_start = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
info!("SocketIO global queue listener stopped");
|
||||
}
|
||||
|
||||
pub async fn start_socketio_queue(app_state: Arc<SharedState>) {
|
||||
let app_state = app_state.as_ref();
|
||||
loop {
|
||||
let res = socketio_queue_worker(app_state).await;
|
||||
tracing::error!("SocketIO task finished, but should never finish: {:?}", res);
|
||||
tracing::error!("Restarting SocketIO...");
|
||||
let res = app_state.socketio_setup.socket_queue_rx.recv().await;
|
||||
match res {
|
||||
Ok((socket, event)) => send_event_with_retry(&socket, &event).await,
|
||||
Err(e) => {
|
||||
error!("SocketIO global queue listener stopped: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||