Compare commits

..
Author SHA1 Message Date
Hans Sebastian Pöhlmann 45f8d5070e vibe-coded mock machine frontend in solidjs 2026-02-24 18:37:05 +01:00
Christian af0b5deff0 Merge pull request #1135 from qitechgmbh/991-graph-tab-in-top-navigation-bar-not-connecting-to-graph-page-2
fixed the graph page not not connecting to navigation bar  bug
2026-02-13 08:06:53 +01:00
Christian 743a589c1d Update README.md to enhance video descriptions and add additional video section 2026-02-12 17:04:35 +01:00
Achraf Jalleli ee7550d89f Merge pull request #1127 from qitechgmbh/1042-implement-minimal-machine-example-for-wago-750-530
1042 implement minimal machine example for wago 750 530
2026-02-12 13:37:26 +01:00
Achraf JalleliandCopilot ec13f715aa slug fix
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-12 13:22:14 +01:00
Achraf Jalleli 2af746e7a4 Add files via upload 2026-02-12 13:14:04 +01:00
JalleliAchraf 3af3fb9d79 used array.fill 2026-02-12 11:22:11 +01:00
Achraf Jalleli ff3103bec2 Merge branch 'master' into 1042-implement-minimal-machine-example-for-wago-750-530 2026-02-12 11:00:23 +01:00
Hans Sebastian Pöhlmann 5eb3c39f84 1043 implement minimal machine example for wago 750 1506 (#1106)
* wago 750_1506 minimal machine backend apparently done, frontend still unfinished

* wago 750_1506 frontend apparently done, but untested

* added bus coupler to device roles
2026-02-12 10:54:06 +01:00
Robin Krämer 2c715d6a8e fixed initialization by raising timeouts for eeprom,eeprom write lock (#1136)
* fixed initialization by raising timeouts for eeprom,eeprom write lock

* fixed formatting
2026-02-11 14:24:48 +01:00
JalleliAchraf fb12001721 fixed the graph page not not connecting to navigation bar bug 2026-02-10 18:29:04 +01:00
Robin Krämer ad94754fcf Added missing revision for Watercooling (#1130) 2026-02-09 15:14:30 +01:00
Achraf Jalleli b919721c3a Update npmDepsHash for electron package 2026-02-06 16:20:26 +01:00
JalleliAchraf a428a2aa02 formatting 2026-02-06 16:12:25 +01:00
JalleliAchraf 7b479c6021 Merge branch '1042-implement-minimal-machine-example-for-wago-750-530' of github.com:qitechgmbh/control into 1042-implement-minimal-machine-example-for-wago-750-530 2026-02-06 16:09:03 +01:00
JalleliAchraf 17ac24c79d minor improvements 2026-02-06 16:08:25 +01:00
JalleliAchraf 8c7a04b616 formatting fix 2026-02-06 15:57:55 +01:00
github-actions[bot] fe248c79ae chore: update npmDepsHash to sha256-/7zfLof91S97OmW8dBeaVajYEv3r2eYOZ8Zb2ahykaQ= 2026-02-06 14:54:58 +00:00
JalleliAchraf 038df09611 removed unecessary differences between this branch and master 2026-02-06 15:49:21 +01:00
JalleliAchraf 9e43d8d431 several small fixes 2026-02-06 15:44:13 +01:00
JalleliAchraf d364263d6e wago digital output minimal machine 2026-02-06 15:22:00 +01:00
Christian 376e4a4ea2 Merge pull request #1117 from qitechgmbh/1100-update-the-main-readme
1100 update the main readme
2026-02-05 21:39:54 +01:00
Robin Krämer 0b30ae9a85 removed excessive logging, simplified socketio task with actual error printed … (#1122)
* removed excessive logging, simplified socketio task with actual error message

* fixed formatting
2026-02-05 17:34:48 +01:00
Christian 528952238d Update issue templates 2026-02-05 14:54:10 +01:00
Robin Krämer 8c32ce2a2b loop now uses Different Cycle time when development-build is set (#1112)
* loop now uses Different Cycle time when development-build is set

* fixed formatting

* hid stop_dnsmasq import behind feature flag

* added feature gate for start_dnsmasq in main
2026-02-05 11:52:56 +01:00
Christian 579ef0d2e2 Enhance video section in README.md with descriptive captions for better user engagement 2026-02-04 19:39:34 +01:00
Christian b6263e90d6 Refactor video section in README.md for improved layout and clarity 2026-02-04 19:32:33 +01:00
Christian f67f098ff3 better wording 2026-02-04 19:30:21 +01:00
Christian 1a3c26b789 Update issue templates 2026-02-04 18:30:07 +01:00
Oshgnacknak 49f0c9f289 Merge pull request #1113 from qitechgmbh/add-issue-templates
Add issue templates
2026-02-04 15:40:33 +01:00
Oshgnacknak e60daed274 Merge pull request #1116 from qitechgmbh/1115-remove-gitattributes-and-gitmodules
1115 Remove .gitattributes and .gitmodules files
2026-02-04 15:35:35 +01:00
Oshgnacknak 9a9120398e Merge pull request #1097 from qitechgmbh/dependabot/cargo/serde_json-1.0.149
Bump serde_json from 1.0.145 to 1.0.149
2026-02-04 15:22:07 +01:00
Christian dec342498c Fix link to Device Examples in README.md for improved navigation 2026-02-04 15:21:06 +01:00
Christian bc8cc5dd3d Update README.md to enhance documentation structure and clarity 2026-02-04 15:18:19 +01:00
Oshgnacknak 4a1f3d125d Merge pull request #1096 from qitechgmbh/dependabot/cargo/syn-2.0.114
Bump syn from 2.0.111 to 2.0.114
2026-02-04 15:07:29 +01:00
dependabot[bot] 1d1eeb3d48 Bump serde_json from 1.0.145 to 1.0.149
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.145 to 1.0.149.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.145...v1.0.149)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.149
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-04 14:06:09 +00:00
Oshgnacknak c395d4e172 Merge pull request #1094 from qitechgmbh/dependabot/cargo/euclid-0.22.13
Bump euclid from 0.22.11 to 0.22.13
2026-02-04 15:05:33 +01:00
Oshgnacknak 5009c5ca75 Merge pull request #1092 from qitechgmbh/dependabot/cargo/axum-0.8.8
Bump axum from 0.8.7 to 0.8.8
2026-02-04 15:03:41 +01:00
Oshgnacknak 50dbc46b8c Merge pull request #1089 from qitechgmbh/dependabot/cargo/tracing-subscriber-0.3.22
Bump tracing-subscriber from 0.3.20 to 0.3.22
2026-02-04 15:01:40 +01:00
Oshgnacknak 774e7e2f8b Merge pull request #1108 from qitechgmbh/dependabot/cargo/bytes-1.11.1
Bump bytes from 1.11.0 to 1.11.1
2026-02-04 14:59:55 +01:00
Oshgnacknak 270872fe81 Merge pull request #1114 from qitechgmbh/1081-automate-npm-deps-hash-for-electronnix
feat: automate npm deps hash update for Electron
2026-02-04 14:33:01 +01:00
Oshgnacknak a749f59186 Merge pull request #1110 from qitechgmbh/1109-remove-hooks
1109 remove hooks folder
2026-02-04 14:28:37 +01:00
Christian bae1de3c54 Remove .gitattributes and .gitmodules files 2026-02-04 13:52:33 +01:00
Christian 7681dae32c feat: automate npmDepsHash updates for Electron
- Add GitHub Actions workflow to automatically calculate and update npmDepsHash
- Workflow triggers on package-lock.json changes in PRs
- Uses nix prefetch-npm-deps to calculate correct hash
- Automatically commits hash updates to the PR
- Supports manual triggering via workflow_dispatch
2026-02-04 13:40:58 +01:00
ChristianandCopilot 2d98d369e1 Update .github/ISSUE_TEMPLATE/feature_request.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-04 13:04:29 +01:00
Christian 310ac42a88 Update issue templates
Add issue templates for better feature requests or bug reports.
2026-02-04 12:57:35 +01:00
Christian 182df9c569 Merge pull request #1107 from qitechgmbh/electron-deps
electron deps
2026-02-03 22:05:39 +01:00
Christian 153a719d64 remove Git hooks and pre-commit script 2026-02-03 21:56:06 +01:00
Christian 84603d1594 update hash 2026-02-03 21:53:28 +01:00
Christian 17b2e3e297 clear hash 2026-02-03 21:52:02 +01:00
Christian fb1cf33c7f Update package lock 2026-02-03 21:44:19 +01:00
dependabot[bot] ae2d0fa8b1 Bump bytes from 1.11.0 to 1.11.1
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.11.0 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.11.0...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 19:43:19 +00:00
ᴏxᴏ.ᴍɪᴋᴀ ed06b63b77 Merge pull request #1083 from qitechgmbh/1076-minimum-hardware-documentation-links
1076 minimum hardware documentation links
2026-02-03 17:15:21 +01:00
Oshgnacknak 9f0f45d19c Fix hash 2026-02-03 16:26:07 +01:00
dependabot[bot] a75c0ff08a Bump zustand from 5.0.9 to 5.0.11 in /electron
Bumps [zustand](https://github.com/pmndrs/zustand) from 5.0.9 to 5.0.11.
- [Release notes](https://github.com/pmndrs/zustand/releases)
- [Commits](https://github.com/pmndrs/zustand/compare/v5.0.9...v5.0.11)

---
updated-dependencies:
- dependency-name: zustand
  dependency-version: 5.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 16:19:11 +01:00
dependabot[bot] 205432c926 Bump vitest from 3.2.4 to 4.0.18 in /electron
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.4 to 4.0.18.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.18/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.0.18
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 16:18:11 +01:00
dependabot[bot] 781783464b Bump eslint from 9.39.1 to 9.39.2 in /electron
Bumps [eslint](https://github.com/eslint/eslint) from 9.39.1 to 9.39.2.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.1...v9.39.2)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.39.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 16:16:43 +01:00
dependabot[bot] 4fa2a1a221 Bump react-i18next from 15.7.4 to 16.5.4 in /electron
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 15.7.4 to 16.5.4.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v15.7.4...v16.5.4)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 16.5.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 16:14:36 +01:00
Oshgnacknak b734468741 Fix event names in wago power machine 2026-02-03 16:14:02 +01:00
dependabot[bot] 1ed4ae3d2c Bump socket.io-client from 4.8.1 to 4.8.3 in /electron
Bumps [socket.io-client](https://github.com/socketio/socket.io) from 4.8.1 to 4.8.3.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-client@4.8.1...socket.io-client@4.8.3)

---
updated-dependencies:
- dependency-name: socket.io-client
  dependency-version: 4.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 15:57:02 +01:00
dependabot[bot] 8095222cad Bump syn from 2.0.111 to 2.0.114
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.111 to 2.0.114.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.111...2.0.114)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 2.0.114
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 12:37:25 +00:00
dependabot[bot] d19af0923c Bump axum from 0.8.7 to 0.8.8
Bumps [axum](https://github.com/tokio-rs/axum) from 0.8.7 to 0.8.8.
- [Release notes](https://github.com/tokio-rs/axum/releases)
- [Changelog](https://github.com/tokio-rs/axum/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/axum/compare/axum-v0.8.7...axum-v0.8.8)

---
updated-dependencies:
- dependency-name: axum
  dependency-version: 0.8.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 12:37:25 +00:00
Robin Krämer 08ce5a37bd aquapath now works with all the other machines connected (#1103)
* aquapath now works with all the other machines connected

* fixed formatting

* removed unused import

* fixed formatting AGAIN
2026-02-03 13:36:07 +01:00
dependabot[bot] bef3389874 Bump euclid from 0.22.11 to 0.22.13
Bumps [euclid](https://github.com/servo/euclid) from 0.22.11 to 0.22.13.
- [Release notes](https://github.com/servo/euclid/releases)
- [Commits](https://github.com/servo/euclid/commits)

---
updated-dependencies:
- dependency-name: euclid
  dependency-version: 0.22.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-01 02:25:36 +00:00
dependabot[bot] 985937c227 Bump tracing-subscriber from 0.3.20 to 0.3.22
Bumps [tracing-subscriber](https://github.com/tokio-rs/tracing) from 0.3.20 to 0.3.22.
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-subscriber-0.3.20...tracing-subscriber-0.3.22)

---
updated-dependencies:
- dependency-name: tracing-subscriber
  dependency-version: 0.3.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-01 02:25:04 +00:00
87 changed files with 8067 additions and 595 deletions

No files matched your search

-3
View File
@@ -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
+40
View File
@@ -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).
+33
View File
@@ -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.
+80
View File
@@ -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```'
})
View File
Whitespace-only changes.
Generated
+40 -34
View File
@@ -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"
+51 -84
View File
@@ -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://img.youtube.com/vi/KI3YeBwfV-s/maxresdefault.jpg)](https://www.youtube.com/watch?v=KI3YeBwfV-s)
*Click here to watch a video demo of our software.*
**[View Full Documentation Wiki](https://github.com/qitechgmbh/control/wiki)**
[![](https://img.youtube.com/vi/55egCAkQgyM/maxresdefault.jpg)](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://img.youtube.com/vi/KI3YeBwfV-s/maxresdefault.jpg)](https://www.youtube.com/watch?v=KI3YeBwfV-s)<br/>*Watch a demo of the control software in action* | [![](https://img.youtube.com/vi/55egCAkQgyM/maxresdefault.jpg)](https://youtu.be/55egCAkQgyM)<br/>*Watch a complete overview of QiTech Control* |
- `/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://img.youtube.com/vi/UlbnSVIhfLI/maxresdefault.jpg)](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://img.youtube.com/vi/U2wNN6sF4to/maxresdefault.jpg)](https://www.youtube.com/watch?v=U2wNN6sF4to&t=163s)<br/>**Day in the Software Team**<br/>*Behind the scenes with Robin and how the team ships control software.* | [![](https://img.youtube.com/vi/R5WGAY3WRWA/maxresdefault.jpg)](https://www.youtube.com/watch?v=R5WGAY3WRWA&t=1s)<br/>**Software Stack Day**<br/>*A fast tour through the tools, stack, and daily workflow.* |
| [![](https://img.youtube.com/vi/xyaHwxXguw4/maxresdefault.jpg)](https://www.youtube.com/watch?v=xyaHwxXguw4&t=32s)<br/>**First Client**<br/>*A milestone week building the first real client project.* | [![](https://img.youtube.com/vi/moI7z2PVdF4/maxresdefault.jpg)](https://www.youtube.com/watch?v=moI7z2PVdF4)<br/>**Steelworks Week**<br/>*A deep-dive week on a demanding steelworks order.* |
## License
See [LICENSE](LICENSE) file for details.
+1 -1
View File
@@ -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" }
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

+130 -241
View File
@@ -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"
+7 -7
View File
@@ -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",
+54
View File
@@ -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,
};
}
+34
View File
@@ -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]),
]),
]),
]);
+1
View File
@@ -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(())
}
+71
View File
@@ -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(())
}
-13
View File
@@ -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
```
-49
View File
@@ -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
+2 -2
View File
@@ -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
+4 -1
View File
@@ -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;
+3
View File
@@ -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 {
+7
View File
@@ -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 &params.hardware {
MachineNewHardware::Ethercat(x) => x,
_ => {
return Err(anyhow::anyhow!(
"[{}::EtherCATMachine/TestMachine::new] MachineNewHardware is not Ethercat",
module_path!()
));
}
};
block_on(async {
// Example usage of a Wago Coupler and a 750-402 in the first slot, where the Output Port 1,2,3,4 is used
let _wago_750_354 = get_ethercat_device::<Wago750_354>(
hardware,
params,
0,
[WAGO_750_354_IDENTITY_A].to_vec(),
)
.await?;
let modules = Wago750_354::initialize_modules(_wago_750_354.1).await?;
let mut coupler = _wago_750_354.0.write().await;
for module in modules {
coupler.set_module(module);
}
coupler.init_slot_modules(_wago_750_354.1);
let dev = coupler.slot_devices.get(0).unwrap().clone().unwrap();
let wago750_1506: Arc<RwLock<Wago750_1506>> =
downcast_device::<Wago750_1506>(dev).await?;
let di1 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI1);
let di2 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI2);
let di3 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI3);
let di4 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI4);
let di5 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI5);
let di6 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI6);
let di7 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI7);
let di8 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI8);
let do1 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO1);
let do2 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO2);
let do3 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO3);
let do4 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO4);
let do5 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO5);
let do6 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO6);
let do7 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO7);
let do8 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO8);
drop(coupler);
let (sender, receiver) = smol::channel::unbounded();
let mut my_test = Self {
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
namespace: Wago8chDigitalIOTestMachineNamespace {
namespace: params.namespace.clone(),
},
last_state_emit: Instant::now(),
main_sender: params.main_thread_channel.clone(),
digital_output: [do1, do2, do3, do4, do5, do6, do7, do8],
digital_input: [di1, di2, di3, di4, di5, di6, di7, di8],
};
my_test.emit_state();
Ok(my_test)
})
}
}
+46
View File
@@ -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();
}
}
}
}
+84
View File
@@ -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()
}
}
+69
View File
@@ -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();
}
}
+101
View File
@@ -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 &params.hardware {
MachineNewHardware::Ethercat(x) => x,
_ => {
return Err(anyhow::anyhow!(
"[{}::MachineNewTrait/WagoDOTestMachine::new] MachineNewHardware is not Ethercat",
module_path!()
));
}
};
block_on(async {
let _wago_750_354 = get_ethercat_device::<Wago750_354>(
hardware,
params,
0,
[WAGO_750_354_IDENTITY_A].to_vec(),
)
.await?;
let modules = Wago750_354::initialize_modules(_wago_750_354.1).await?;
let mut coupler = _wago_750_354.0.write().await;
for module in modules {
coupler.set_module(module);
}
coupler.init_slot_modules(_wago_750_354.1);
let dev = coupler
.slot_devices
.get(0)
.ok_or_else(|| {
anyhow::anyhow!(
"[{}::MachineNewTrait/WagoDOTestMachine::new] Expected Wago 750-530 module in slot 0, but slot 0 is not configured",
module_path!()
)
})?
.clone()
.ok_or_else(|| {
anyhow::anyhow!(
"[{}::MachineNewTrait/WagoDOTestMachine::new] Expected Wago 750-530 module in slot 0, but slot 0 is empty or no device is present",
module_path!()
)
})?;
let wago750_530: Arc<RwLock<Wago750_530>> = downcast_device::<Wago750_530>(dev).await?;
let do1 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port1);
let do2 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port2);
let do3 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port3);
let do4 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port4);
let do5 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port5);
let do6 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port6);
let do7 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port7);
let do8 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port8);
drop(coupler);
let (sender, receiver) = smol::channel::unbounded();
let mut machine = Self {
api_receiver: receiver,
api_sender: sender,
machine_identification_unique: params.get_machine_identification_unique(),
namespace: WagoDOTestMachineNamespace {
namespace: params.namespace.clone(),
},
last_state_emit: Instant::now(),
led_on: [false; 8],
main_sender: params.main_thread_channel.clone(),
douts: [do1, do2, do3, do4, do5, do6, do7, do8],
};
machine.emit_state();
Ok(machine)
})
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ buildNpmPackage rec {
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
makeCacheWritable = true;
npmDepsHash = "sha256-wzVjsgcZsViPOyQKlKTb5gJIeY416KyCSiheSXSRZoc=";
npmDepsHash = "sha256-rnaAsSkoTRoBgLCTF8ZFpi7uUQ+8W+u3qoMxsptX6NU=";
npmFlags = [ "--no-audit" "--no-fund" ];
installPhase = ''
+28
View File
@@ -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
+298
View File
@@ -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`).
+36
View File
@@ -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)
+156
View File
@@ -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.
+16
View File
@@ -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>
File diff suppressed because it is too large. Load diff
+27
View File
@@ -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"
}
}
+1346
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -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);
}
}
+81
View File
@@ -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>
);
}
Binary file not shown.

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" />;
}
+13
View File
@@ -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;
}
+16
View File
@@ -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!);
+27
View File
@@ -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}`);
}
}
+114
View File
@@ -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];
}
+1
View File
@@ -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

+65
View File
@@ -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 },
);
}
+165
View File
@@ -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>
);
}
+537
View File
@@ -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;
}
+20
View File
@@ -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"]
}
}
+13
View File
@@ -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',
},
});
+3 -3
View File
@@ -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",
+2 -26
View File
@@ -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;
}
+31 -19
View File
@@ -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?;
-2
View File
@@ -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
+32 -37
View File
@@ -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 {
+13 -37
View File
@@ -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;
}
}
}
}