Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e233ae718 | ||
|
|
f35ba7862e | ||
|
|
80048c0bbb | ||
|
|
bb307bb9d7 | ||
|
|
39b05b056f | ||
|
|
8449045efb | ||
|
|
b8bb8053c7 | ||
|
|
0305867394 | ||
|
|
09f56ed990 | ||
|
|
8beb24cef0 | ||
|
|
ed84982c8e | ||
|
|
e792ed1b4d | ||
|
|
132ee383ee | ||
|
|
34a8c54098 | ||
|
|
8a3de40501 | ||
|
|
853d0a4478 | ||
|
|
d6dccc1b1a | ||
|
|
5f636a6f15 | ||
|
|
ac8a8c37ed | ||
|
|
03e057f6a9 | ||
|
|
fb195eb7d4 | ||
|
|
874766633a | ||
|
|
047b0fbe55 | ||
|
|
e3e9412578 | ||
|
|
92ead0e775 | ||
|
|
06a6c81b55 | ||
|
|
7b858251ea | ||
|
|
04700d690c | ||
|
|
e4d950ae9b | ||
|
|
7e03148d88 | ||
|
|
5562cf5a53 | ||
|
|
8c77ea41fd | ||
|
|
ef1a2c3ca8 | ||
|
|
66e30e2085 | ||
|
|
6ca15dbebc | ||
|
|
7e5d44c329 | ||
|
|
dc287d61cc | ||
|
|
9aed1193a3 | ||
|
|
5fae6b628f | ||
|
|
88c6195d6c | ||
|
|
af0b5deff0 | ||
|
|
743a589c1d | ||
|
|
ee7550d89f | ||
|
|
ec13f715aa | ||
|
|
2af746e7a4 | ||
|
|
3af3fb9d79 | ||
|
|
ff3103bec2 | ||
|
|
5eb3c39f84 | ||
|
|
2c715d6a8e | ||
|
|
fb12001721 | ||
|
|
ad94754fcf | ||
|
|
1c0e06d4e1 | ||
|
|
b919721c3a | ||
|
|
a428a2aa02 | ||
|
|
7b479c6021 | ||
|
|
17ac24c79d | ||
|
|
8c7a04b616 | ||
|
|
fe248c79ae | ||
|
|
038df09611 | ||
|
|
9e43d8d431 | ||
|
|
d364263d6e | ||
|
|
376e4a4ea2 | ||
|
|
0b30ae9a85 | ||
|
|
528952238d | ||
|
|
8c32ce2a2b | ||
|
|
579ef0d2e2 | ||
|
|
b6263e90d6 | ||
|
|
f67f098ff3 | ||
|
|
1a3c26b789 | ||
|
|
49f0c9f289 | ||
|
|
e60daed274 | ||
|
|
9a4d41bf48 | ||
|
|
9a9120398e | ||
|
|
dec342498c | ||
|
|
bc8cc5dd3d | ||
|
|
4a1f3d125d | ||
|
|
1d1eeb3d48 | ||
|
|
c395d4e172 | ||
|
|
5009c5ca75 | ||
|
|
50dbc46b8c | ||
|
|
774e7e2f8b | ||
|
|
270872fe81 | ||
|
|
a749f59186 | ||
|
|
bae1de3c54 | ||
|
|
7681dae32c | ||
|
|
2d98d369e1 | ||
|
|
310ac42a88 | ||
|
|
cbffeff52d | ||
|
|
182df9c569 | ||
|
|
153a719d64 | ||
|
|
84603d1594 | ||
|
|
17b2e3e297 | ||
|
|
fb1cf33c7f | ||
|
|
ae2d0fa8b1 | ||
|
|
ed06b63b77 | ||
|
|
9f0f45d19c | ||
|
|
a75c0ff08a | ||
|
|
205432c926 | ||
|
|
781783464b | ||
|
|
4fa2a1a221 | ||
|
|
b734468741 | ||
|
|
1ed4ae3d2c | ||
|
|
8095222cad | ||
|
|
d19af0923c | ||
|
|
08ce5a37bd | ||
|
|
9d5e5cc7d0 | ||
|
|
78c7667d96 | ||
|
|
bef3389874 | ||
|
|
985937c227 | ||
|
|
c82d7347e2 | ||
|
|
22be4b79f0 | ||
|
|
be84edf0fd | ||
|
|
ff3e787a03 | ||
|
|
093663c4e6 | ||
|
|
1b2d6df459 | ||
|
|
cd2e15cb29 | ||
|
|
06a2284145 | ||
|
|
f1571c0daa | ||
|
|
96e0fb83ef | ||
|
|
94cffbf717 | ||
|
|
38b2e907f9 | ||
|
|
7e5498751a | ||
|
|
eef9d63de2 | ||
|
|
d9d9b9d344 | ||
|
|
2ff3d8df56 | ||
|
|
462672bb6a | ||
|
|
84d539a4fa | ||
|
|
6a6c09e283 | ||
|
|
1b8b56fd02 | ||
|
|
fef727983e | ||
|
|
40239e0463 | ||
|
|
9c579a9fd4 | ||
|
|
a3a343bbb8 | ||
|
|
373d97d12d | ||
|
|
e1842fa9d5 | ||
|
|
4fd8ddef51 | ||
|
|
61b9ac8a39 | ||
|
|
bb169e3b33 | ||
|
|
3d735f8abd | ||
|
|
a9a973e336 | ||
|
|
b66a59dba9 | ||
|
|
9dbc962dbc | ||
|
|
93e2558803 | ||
|
|
8cf7480833 | ||
|
|
08d655effc | ||
|
|
c1c867fca1 | ||
|
|
1378c00e1a | ||
|
|
85f17befbe | ||
|
|
f2e764f93a | ||
|
|
9adeeda490 | ||
|
|
68bf075172 | ||
|
|
7d23190939 | ||
|
|
5c68e2c78a | ||
|
|
980e14a96e | ||
|
|
d5fb45e4e3 | ||
|
|
cdb7ab1e0c | ||
|
|
da934e0bb3 |
No files matched your search
@@ -1,3 +0,0 @@
|
||||
.png filter=lfs diff=lfs merge=lfs -text
|
||||
.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: 'BUG: '
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Navigate to '...'
|
||||
2. Select/Press '....'
|
||||
3. Execute operation '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots/Photos**
|
||||
If applicable, add screenshots from the HMI display or photos of the machine screen to help explain your problem.
|
||||
|
||||
**Machine Information (please complete the following):**
|
||||
- Machine Type: [Winder2, Laser, Aquapath...]
|
||||
- Control Software Version: [e.g. 2.4.1]
|
||||
- Settings: [e.g. Different Gear Ratios, PID Settings, …]
|
||||
|
||||
**Device Information (if issue occurs on external device):**
|
||||
- Device Type: [e.g. Tablet, PLC, Remote Control Panel]
|
||||
- OS: [e.g. Windows 10, Linux, etc.]
|
||||
- Connection Type: [e.g. Ethernet, USB]
|
||||
|
||||
**Error Messages/Codes**
|
||||
If applicable, provide exact error codes or messages displayed or found in the troubleshooting terminal.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here (e.g., recent software updates, environmental factors).
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: 'Feat: '
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. During operation, I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions, workarounds, or features you've considered.
|
||||
|
||||
**Use Case/Application:**
|
||||
- Machine Type(s): [e.g. All machines, Winder, Aquapath, ...]
|
||||
- Frequency of Use: [e.g. Daily, During setup, Occasionally]
|
||||
|
||||
**Expected Benefits:**
|
||||
- Efficiency Improvement: [e.g. Reduces setup time by X minutes, Eliminates manual steps]
|
||||
- Safety Enhancement: [e.g. Prevents operator error, Adds safety checks]
|
||||
- Production Impact: [e.g. Increases throughput, Reduces downtime, Improves quality]
|
||||
- User Experience: [e.g. Simplifies workflow, Reduces training time]
|
||||
|
||||
**Mockups/Sketches**
|
||||
If applicable, add sketches, mockups, or photos showing how the feature would appear on the HMI display or integrate with the machine.
|
||||
|
||||
**Additional context**
|
||||
Add any other context, industry standards, or examples from other control systems about the feature request here.
|
||||
@@ -0,0 +1,213 @@
|
||||
name: Stale Assigned Issue Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every Sunday at 9:00 AM UTC
|
||||
- cron: '0 9 * * 0'
|
||||
workflow_dispatch: # Allow manual triggering for testing
|
||||
|
||||
jobs:
|
||||
check-stale-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Check assigned issues for status updates
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
|
||||
// Helper function to check rate limits
|
||||
async function checkRateLimit() {
|
||||
try {
|
||||
const { data: rateLimit } = await github.rest.rateLimit.get();
|
||||
const remaining = rateLimit.resources.core.remaining;
|
||||
const reset = new Date(rateLimit.resources.core.reset * 1000);
|
||||
console.log(`Rate limit: ${remaining} requests remaining (resets at ${reset.toISOString()})`);
|
||||
return remaining > 10; // Stop if fewer than 10 requests remaining
|
||||
} catch (error) {
|
||||
console.log(`Could not check rate limit: ${error.message}`);
|
||||
return true; // Continue on error
|
||||
}
|
||||
}
|
||||
|
||||
// Get all open issues
|
||||
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
console.log(`Found ${issues.length} open issues`);
|
||||
|
||||
// Helper function to check if issue has linked PRs using GraphQL
|
||||
async function hasLinkedPRs(issueNumber) {
|
||||
try {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $issueNumber: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issue(number: $issueNumber) {
|
||||
timelineItems(first: 100, itemTypes: CONNECTED_EVENT) {
|
||||
nodes {
|
||||
... on ConnectedEvent {
|
||||
subject {
|
||||
... on PullRequest {
|
||||
number
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const result = await github.graphql(query, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issueNumber: issueNumber
|
||||
});
|
||||
|
||||
const linkedPRs = result.repository.issue.timelineItems.nodes
|
||||
.filter(node => node.subject && node.subject.state === 'OPEN')
|
||||
.map(node => node.subject.number);
|
||||
|
||||
return linkedPRs.length > 0;
|
||||
} catch (error) {
|
||||
console.log(`Could not check linked PRs for issue #${issueNumber}: ${error.message}`);
|
||||
return false; // Assume no linked PRs on error to avoid false positives
|
||||
}
|
||||
}
|
||||
|
||||
let processedCount = 0;
|
||||
let commentedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const issue of issues) {
|
||||
try {
|
||||
// Check rate limit periodically
|
||||
if (processedCount % 10 === 0 && !(await checkRateLimit())) {
|
||||
console.log('Rate limit nearly exhausted, stopping to avoid hitting limit');
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip pull requests (they appear in issues list too)
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if not assigned
|
||||
if (!issue.assignees || issue.assignees.length === 0) {
|
||||
console.log(`Issue #${issue.number}: Not assigned, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Checking issue #${issue.number}: ${issue.title}`);
|
||||
processedCount++;
|
||||
|
||||
// Check if issue has linked PRs
|
||||
const hasOpenLinkedPR = await hasLinkedPRs(issue.number);
|
||||
|
||||
if (hasOpenLinkedPR) {
|
||||
console.log(`Issue #${issue.number}: Has linked open PR, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get all comments on the issue
|
||||
let comments = [];
|
||||
try {
|
||||
comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 100
|
||||
});
|
||||
} catch (commentError) {
|
||||
console.error(`Issue #${issue.number}: Could not fetch comments - ${commentError.message}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the most recent comment or update
|
||||
let lastActivity = new Date(issue.created_at);
|
||||
|
||||
if (issue.updated_at) {
|
||||
lastActivity = new Date(issue.updated_at);
|
||||
}
|
||||
|
||||
if (comments.length > 0) {
|
||||
const lastComment = comments[comments.length - 1];
|
||||
const lastCommentDate = new Date(lastComment.created_at);
|
||||
if (lastCommentDate > lastActivity) {
|
||||
lastActivity = lastCommentDate;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if last activity was more than a week ago
|
||||
const daysSinceActivity = Math.floor((now - lastActivity) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysSinceActivity >= 7) {
|
||||
console.log(`Issue #${issue.number}: No activity for ${daysSinceActivity} days`);
|
||||
|
||||
// Check if we already posted a status request in the last 7 days
|
||||
const recentComments = comments.filter(comment => {
|
||||
const commentDate = new Date(comment.created_at);
|
||||
return (now - commentDate) < ONE_WEEK_MS;
|
||||
});
|
||||
|
||||
const alreadyAsked = recentComments.some(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('status update')
|
||||
);
|
||||
|
||||
if (alreadyAsked) {
|
||||
console.log(`Issue #${issue.number}: Already requested status this week, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Post a status request comment
|
||||
try {
|
||||
const assigneeNames = issue.assignees.map(a => `@${a.login}`).join(', ');
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `Hi ${assigneeNames}! 👋\n\n` +
|
||||
`This issue has been assigned to you for ${daysSinceActivity} days with no recent activity or linked pull requests.\n\n` +
|
||||
`Could you please provide a status update? If you're:\n` +
|
||||
`- ✅ Working on it: Let us know your progress\n` +
|
||||
`- 🔄 Need help: Tag someone or describe what's blocking you\n` +
|
||||
`- 🚫 No longer working on it: Let us know so we can reassign\n\n` +
|
||||
`Thank you!`
|
||||
});
|
||||
|
||||
console.log(`Issue #${issue.number}: Posted status request comment`);
|
||||
commentedCount++;
|
||||
} catch (commentError) {
|
||||
console.error(`Issue #${issue.number}: Could not post comment - ${commentError.message}`);
|
||||
errorCount++;
|
||||
// Continue to next issue
|
||||
}
|
||||
} else {
|
||||
console.log(`Issue #${issue.number}: Activity ${daysSinceActivity} days ago, still fresh`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Issue #${issue.number}: Error processing - ${error.message}`);
|
||||
errorCount++;
|
||||
// Continue to next issue instead of failing the entire workflow
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nStale issue check complete!`);
|
||||
console.log(`Processed: ${processedCount} issues`);
|
||||
console.log(`Commented on: ${commentedCount} issues`);
|
||||
console.log(`Errors: ${errorCount}`);
|
||||
@@ -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```'
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
target/
|
||||
dist/
|
||||
.DS_Store
|
||||
Cargo.lock
|
||||
dhat-heap.json
|
||||
build.sh
|
||||
shell.nix
|
||||
node_modules/
|
||||
runtime_metrics.csv
|
||||
compile_metrics.csv
|
||||
compile_metrics.csv
|
||||
.idea/
|
||||
electron/dist-electron/
|
||||
@@ -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,14 +962,13 @@ dependencies = [
|
||||
"deluxe",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ethercrab"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbb13becfa15713adb09b27fdb74468b827cdc78aa99aea638a89c1b0db3ce94"
|
||||
source = "git+https://github.com/v-morlock/ethercrab?branch=main#d297d13389d85a4edb4e69cc98f9dd7f12626cac"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"atomic-waker",
|
||||
@@ -998,8 +997,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ethercrab-wire"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d15828cb020fb44c92ca67e1ee3c122c9621c2e00d383d066e488f0619023ba2"
|
||||
source = "git+https://github.com/v-morlock/ethercrab?branch=main#d297d13389d85a4edb4e69cc98f9dd7f12626cac"
|
||||
dependencies = [
|
||||
"ethercrab-wire-derive",
|
||||
"heapless",
|
||||
@@ -1008,20 +1006,19 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ethercrab-wire-derive"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e790b8011211ed70babfcacbd4277b91ee21402ad5ba6a2c293db02e2f12919"
|
||||
source = "git+https://github.com/v-morlock/ethercrab?branch=main#d297d13389d85a4edb4e69cc98f9dd7f12626cac"
|
||||
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",
|
||||
]
|
||||
@@ -1878,7 +1875,7 @@ dependencies = [
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1908,7 +1905,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2221,7 +2218,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2251,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]]
|
||||
@@ -2332,6 +2329,7 @@ dependencies = [
|
||||
"smol",
|
||||
"socketioxide",
|
||||
"spin_sleep",
|
||||
"ta",
|
||||
"textplots",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
@@ -2573,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",
|
||||
@@ -2588,6 +2586,12 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "ta"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
@@ -2621,7 +2625,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2681,7 +2685,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2843,7 +2847,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2880,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",
|
||||
@@ -3074,7 +3078,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -3139,7 +3143,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3150,7 +3154,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3426,5 +3430,11 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
|
||||
@@ -2,109 +2,76 @@
|
||||
|
||||
# QiTech Control
|
||||
|
||||
QiTech Control is an open-source framework designed to bring modern software paradigms to certified industrial hardware.
|
||||
QiTech Control is an open-source framework designed to bring modern software development practices to certified industrial hardware.
|
||||
|
||||
It frees developers from proprietary, license-heavy PLC ecosystems and rigid "point-and-click" workflows that are no longer adequate for today's complex automation challenges.
|
||||
|
||||
QiTech Control combines the modularity and reliability of standard EtherCAT terminals (e.g., WAGO, Beckhoff) with the power of a modern Rust & React stack.
|
||||
|
||||
## Documentation
|
||||
|
||||
[](https://www.youtube.com/watch?v=KI3YeBwfV-s)
|
||||
*Click here to watch a video demo of our software.*
|
||||
**[View Full Documentation Wiki](https://github.com/qitechgmbh/control/wiki)**
|
||||
|
||||
[](https://youtu.be/55egCAkQgyM)
|
||||
*Click here to watch a full explainer Video of our Software.*
|
||||
- [Getting Started](https://github.com/qitechgmbh/control/wiki/Getting-Started)
|
||||
- [Architecture Overview](https://github.com/qitechgmbh/control/wiki/Architecture-Overview)
|
||||
- [EtherCAT Basics](https://github.com/qitechgmbh/control/wiki/Ethercat-Basics)
|
||||
- [Device Examples](https://github.com/qitechgmbh/control/wiki/Hardware-Examples)
|
||||
- [REST API Reference](https://github.com/qitechgmbh/control/wiki/Rest-Api)
|
||||
|
||||
# Repo Structure
|
||||
## Videos
|
||||
|
||||
**Frontend**
|
||||
| Software Demo | Full Explainer |
|
||||
|---------------|----------------|
|
||||
| [](https://www.youtube.com/watch?v=KI3YeBwfV-s)<br/>*Watch a demo of the control software in action* | [](https://youtu.be/55egCAkQgyM)<br/>*Watch a complete overview of QiTech Control* |
|
||||
|
||||
- `/electron`: Frontend code for the control software built with React and Electron.
|
||||
## Repository Structure
|
||||
|
||||
**Backend**
|
||||
- **`/electron`** - React + Electron frontend
|
||||
- **`/server`** - Rust backend implementing machine logic
|
||||
- **`/ethercat-hal`** - Hardware abstraction layer for EtherCAT devices
|
||||
- **`/control-core`** - Core control logic
|
||||
- **`/nixos`** - Custom Linux OS with realtime kernel
|
||||
|
||||
- `/server`: Glue between Beckhoff and Electron. Implements machine logic.
|
||||
- `/ethercat-hal`: Hardware abstraction layer for Beckhoff (and possibly other EtherCat) devices and protocols.
|
||||
- `/ethercat-hal-derive`: Macros for `ethercat-hal`
|
||||
- `/control-core`: Core control logic for the server.
|
||||
## Technology Stack
|
||||
|
||||
**Operating System**
|
||||
**Backend:** Rust with [Ethercrab](https://github.com/ethercrab-rs/ethercrab) for EtherCAT, [SocketIO](https://socket.io/) for real-time communication, [axum](https://docs.rs/axum/latest/axum/) for REST API
|
||||
|
||||
- `/nixos`: Custom Linux with realtime kernel & preconfigured for touchscreens.
|
||||
|
||||
**Other**
|
||||
|
||||
- `/docs`: Documentation for the project.
|
||||
|
||||
# Technology Choices
|
||||
|
||||
## Backend
|
||||
|
||||
To interface with Beckhoff and other EtherCAT devices we need an EtherCAT master software. Possibilities are [PySOEM](https://github.com/bnjmnp/pysoem) (Python), [SOEM](https://github.com/OpenEtherCATsociety/SOEM) (C) and [Ethercrab](https://github.com/ethercrab-rs/ethercrab) (Rust). For realtime operation only C and Rust are suitable. We chose Rust because of safety and confidence in the written code.
|
||||
|
||||
[SocketIO](https://socket.io/) was chosen for performant event driven communication from the backend to the server. But we still use REST with [axum](https://docs.rs/axum/latest/axum/) for the communication thet benefits from the request/response model.
|
||||
|
||||
We use [Smol](https://github.com/smol-rs/smol) for EtherCAT IO in the control loop for it's performance and [Tokio](https://tokio.rs/) for server IO because of it's ecosystem and maturity.
|
||||
|
||||
## Frontend
|
||||
|
||||
We could combine the code of the frontend and backend using [Doxius](https://dioxuslabs.com/) but it lacks good Linux support. We chose [Electron](https://www.electronjs.org/) with [React](https://react.dev/) for it's maturity and ecosystem. For the UI we use [Shadcn](https://ui.shadcn.com/) components and [Tailwind](https://tailwindcss.com/) for styling. For routing we use [TanStack Router](https://tanstack.com/router/v1).
|
||||
|
||||
# Dev Setup
|
||||
|
||||
[Developer Documentation](./docs/developer-docs/)
|
||||
|
||||
[API Documentation](https://github.com/qitechgmbh/control/blob/master/docs/rest-api.md)
|
||||
|
||||
-> Backend
|
||||
- Rust stable 1.86^ toolchain (install via [rustup](https://rustup.rs/))
|
||||
- `rust-analyzer` extension for VSCode
|
||||
- Set your interface in `server/src/ethercat/init.rs` like `en10`
|
||||
- Connect a Beckhoff EK1100 to your interface
|
||||
- run `cd server && cargo run` to start the server (localhost:3001)
|
||||
|
||||
-> Frontend
|
||||
- nodejs and npm installed
|
||||
- run `cd electron && npm i && npm run start` to start the frontend
|
||||
**Frontend:** [Electron](https://www.electronjs.org/) + [React](https://react.dev/) with [Shadcn](https://ui.shadcn.com/) components and [Tailwind](https://tailwindcss.com/) styling
|
||||
|
||||
|
||||
## Minimal Hardware Examples
|
||||
## Hardware Examples
|
||||
|
||||
For complete step-by-step tutorials on setting up your first hardware, including wiring diagrams and software configuration, see the [Getting Started Guide](./docs/developer-docs/getting-started.md#minimal-hardware-examples).
|
||||
QiTech Control supports a wide range of EtherCAT hardware from WAGO and Beckhoff. We provide complete step-by-step tutorials with wiring diagrams and software configuration for many common modules.
|
||||
|
||||
| Example | Vendor | Hardware | Description | How-To Video | Docs |
|
||||
| :----------------- | :------- | :------------ | :-------------------------------------- | :----------- | :---------------------------- |
|
||||
| **Digital Input** | WAGO | 750-402 | 4-Channel Digital Input | [ ] | - |
|
||||
| **Digital Output** | WAGO | 750-753 | 8-Channel Digital Output | [ ] | - |
|
||||
| **Analog Input** | WAGO | 750-455 | 4-Channel Analog Input | [ ] | - |
|
||||
| **Analog Output** | WAGO | 750-553 | 4-Channel Analog Output | [ ] | - |
|
||||
| **Serial Comms** | WAGO | 750-652 | RS-485 Modbus Module | [ ] | - |
|
||||
| **Stepper Drive** | WAGO | 750-67x | Stepper Controller | [ ] | - |
|
||||
| **Power Supply** | WAGO | 2787-214 | Power Supply Unit | [ ] | - |
|
||||
| **LED Control** | Beckhoff | EL2004 | Digital output control (simplest setup) | [ ] | [](./docs/hardware/el2004.md) |
|
||||
| **Analog Input** | Beckhoff | EL3021 | Reading analog current measurements | [ ] | [](./docs/hardware/el3021.md) |
|
||||
**[View all hardware examples and tutorials on the wiki](https://github.com/qitechgmbh/control/wiki/Hardware-Examples)**
|
||||
|
||||
# QiTech Machines
|
||||
Quick links to popular examples:
|
||||
- [EL2004 - LED Control](https://github.com/qitechgmbh/control/wiki/Minimal-Example-El2004) (simplest setup for beginners)
|
||||
- [EL3021 - Analog Input](https://github.com/qitechgmbh/control/wiki/Minimal-Example-El3021)
|
||||
- [EL7031 - Stepper Motor Control](https://github.com/qitechgmbh/control/wiki/Minimal-Example-El7031-Motor)
|
||||
- [WAGO 750-402 - Digital Input](https://github.com/qitechgmbh/control/wiki/Minimal-Example-Wago750-402)
|
||||
- [WAGO 750-455 - Analog Input](https://github.com/qitechgmbh/control/wiki/Minimal-Example-Wago-750-455)
|
||||
|
||||
| Machine Name | Description | Machine ID | Implemented | Video | Docs |
|
||||
| :---------------- | :-------------------------- | :--------- | :---------- | :-------------------------------------------------------------- | :------------------------------ |
|
||||
| Winder V1 | Winding Filaments & Similar | 1 | Legacy | [Video](https://youtu.be/4aE4cFhioKA?si=Xdj_LVnFrAYWnLm6) | - |
|
||||
| Winder V2 | Winding Filaments & Similar | 2 | Yes | [Video](https://youtu.be/f2kzh6kpQWE?si=HEYcLOaC9gWSp2Wo) | [](./docs/machines/winder-1.md) |
|
||||
| XL Winder V1 | Large Scale Winder | 3 | Yes | [Video](https://youtu.be/ynI6ioWIQQY?si=4EDVKXbgIqrvGGtY) | - |
|
||||
| Buffer V1 | Filament buffering system | 4 | In Progress | [Video](https://youtu.be/VR5mEdZDPA0?si=eip7dQXU4zaXK7Ev) | - |
|
||||
| Extruder V1 | Single Screw Extruder | 5 | Legacy | [Video](https://youtu.be/gchoG-yGczI?si=bEM4daf5eOVaU5_2) | - |
|
||||
| Extruder V2 | Single Screw Extruder | 6 | Yes | [Video](https://youtu.be/mexRYGDNWa4?si=h7JBz_XKMwKLPenG) | |
|
||||
| Waterway V1 | Filament Water Cooling | 7 | In Progress | [Video](https://youtu.be/_T5z1J8bl_k?si=uOC1hIQ1EAP0cHIF) | |
|
||||
| Laser V1 | Diameter Measuring Laser | 8 | Yes | [Video](https://youtu.be/WDM34lj4afM?si=MZzUKkHCrzH4P0aA) | |
|
||||
| 2-Axis-Laser V1 | Dual Axis Laser | 9 | Yes | [Video](https://youtu.be/WDM34lj4afM?si=MZzUKkHCrzH4P0aA) | - |
|
||||
| Mock | Mock Machine for Testing | 10 | Yes | | - |
|
||||
| Extruder V3 | Single Screw Extruder | 11 | Yes | [Video](https://youtu.be/ipHHuPzCvn4?si=hkZ1b93rVuwDxhFD) | |
|
||||
| Mini Schredder V1 | mini plastic crusher | 12 | Legacy | [Video](https://youtu.be/m8NplNqdu2Q?si=x6zjDktJtpbSMSmu) | - |
|
||||
| Pro Schredder V1 | large plastic crusher | 13 | Legacy | [Video](https://youtu.be/pSaVMqp06pU?si=y61enutRpxKscBm4) | - |
|
||||
| Dryer V1 | polymer dryer | 14 | Reserved | [Video](https://youtu.be/6hdmUUAdZp0?si=eKOW1WlgkqTpnfdS) | - |
|
||||
| Pelletizier V1 | Filament chopper | 15 | Reserved | | - |
|
||||
## QiTech Machines
|
||||
|
||||
# Current Restructuring Efforts
|
||||
QiTech Control powers 10+ production machines for filament extrusion, winding, measurement, and material processing.
|
||||
|
||||
[](https://www.youtube.com/live/UlbnSVIhfLI?si=ZTotC5B7gd87tUim)
|
||||
*Video: Live stream of software dev meeting*
|
||||
**[View complete machine catalog on the wiki](https://github.com/qitechgmbh/control/wiki/QiTech-Machines)**
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
This is an open-source project. Contributions are welcome! Please see the [wiki](https://github.com/qitechgmbh/control/wiki) for development guidelines.
|
||||
|
||||
|
||||
|
||||
## Additional Videos
|
||||
|
||||
| Episode Highlights | |
|
||||
|--------------------|--------------------|
|
||||
| [](https://www.youtube.com/watch?v=U2wNN6sF4to&t=163s)<br/>**Day in the Software Team**<br/>*Behind the scenes with Robin and how the team ships control software.* | [](https://www.youtube.com/watch?v=R5WGAY3WRWA&t=1s)<br/>**Software Stack Day**<br/>*A fast tour through the tools, stack, and daily workflow.* |
|
||||
| [](https://www.youtube.com/watch?v=xyaHwxXguw4&t=32s)<br/>**First Client**<br/>*A milestone week building the first real client project.* | [](https://www.youtube.com/watch?v=moI7z2PVdF4)<br/>**Steelworks Week**<br/>*A deep-dive week on a demanding steelworks order.* |
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE) file for details.
|
||||
@@ -9,11 +9,11 @@ workspace = true
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
|
||||
axum = "0.8.6"
|
||||
axum = "0.8.8"
|
||||
|
||||
ethercat_hal = { path = "../ethercat-hal" }
|
||||
units = { path = "../units" }
|
||||
ethercrab = "0.6"
|
||||
ethercrab = { git = "https://github.com/v-morlock/ethercrab", branch = "main" }
|
||||
interfaces = "0.0.9"
|
||||
serde = "1.0.219"
|
||||
serialport = "4.7.3"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Generate 18 random alphanumeric characters base64 encode It
|
||||
password=$(head -c 18 /dev/urandom | base64)
|
||||
|
||||
echo "Basic Authentication; user=machine password=$password"
|
||||
echo $password > /tmp/machine_password
|
||||
|
||||
caddy_hash=$(caddy hash-password --plaintext $password)
|
||||
|
||||
echo "(machine_basic_auth) {
|
||||
basic_auth {
|
||||
machine $caddy_hash
|
||||
}
|
||||
}" | sudo tee /var/lib/caddy/auth_snippet.conf
|
||||
|
||||
# Transfer ownership to caddy user
|
||||
sudo chown caddy:caddy /var/lib/caddy/auth_snippet.conf
|
||||
|
||||
# Tigther permissions (Only readable by root or caddy user)
|
||||
sudo chmod 600 /var/lib/caddy/auth_snippet.conf
|
||||
|
||||
sudo systemctl restart caddy.service
|
||||
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 687 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 985 KiB |
@@ -0,0 +1,931 @@
|
||||
# Wago Stepper Controller - Developer Documentation
|
||||
|
||||
**Models:** 750-671 & 750-672
|
||||
**Last Updated:** February 2026
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Hardware Quick Reference](#hardware-quick-reference)
|
||||
3. [Architecture & Code Organization](#architecture--code-organization)
|
||||
4. [Control & Status Bytes](#control--status-bytes)
|
||||
5. [Implementation Details](#implementation-details)
|
||||
6. [Prescalers & Calculations](#prescalers--calculations)
|
||||
7. [Common Pitfalls & Debug Tips](#common-pitfalls--debug-tips)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Wago Stepper Controllers (750-671 and 750-672) drive stepper motors via EtherCAT. This documentation focuses on **velocity/speed control mode** implementation.
|
||||
|
||||
### Key Differences Between Models
|
||||
|
||||
| Feature | 750-671 | 750-672 |
|
||||
| --------------- | ------------------------------------------ | ------------------------------------ |
|
||||
| Max Voltage | 50 VDC | 70 VDC |
|
||||
| Max Current | 5 A | 7.5 A |
|
||||
| Speed Control | Via Application Selector in config | Via Control Byte C1 command |
|
||||
| Control Byte C1 | Different bit layout (M_Speed_Control bit) | Command field (0x18 = Speed Control) |
|
||||
|
||||
**For 750-671:** Must set `Application_Selector = 2` and `PWM_Period = 0` in WAGO I/O CHECK before using speed control.
|
||||
|
||||
**For 750-672:** Speed control is selected directly via command in Control Byte C1 (no config needed).
|
||||
|
||||
### Process Image
|
||||
|
||||
Both models use **12 bytes bidirectional** (96 bits each way):
|
||||
|
||||
```
|
||||
Input (TxPDO - Controller → PLC):
|
||||
[0] S0 (status)
|
||||
[1] Reserved
|
||||
[2-3] Actual Velocity (i16)
|
||||
[4-5] Reserved
|
||||
[6-8] Actual Position (24-bit: L, M, H)
|
||||
[9] S3 (digital inputs, warnings)
|
||||
[10] S2 (busy, error, on_speed, etc.)
|
||||
[11] S1 (acknowledgments)
|
||||
|
||||
Output (RxPDO - PLC → Controller):
|
||||
[0] C0 (control)
|
||||
[1] Reserved
|
||||
[2-3] Target Velocity (i16, clamped to ±25000)
|
||||
[4-5] Acceleration (u16)
|
||||
[6-8] Position (24-bit: L, M, H)
|
||||
[9] C3 (position/reset control)
|
||||
[10] C2 (prescalers, error quit)
|
||||
[11] C1 (main control: enable, start, mode)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hardware Quick Reference
|
||||
|
||||
### Connections
|
||||
|
||||
| Pin | Name | Purpose |
|
||||
|-----|------|---------|
|
||||
| DI1+ | Digital Input 1 | Enable input (connect to 24V or control via software) |
|
||||
| DI2+ | Digital Input 2 | Referencing input |
|
||||
| DI- | Ground | Reference potential for DI1/DI2 |
|
||||
| A1, A2 | Motor Winding A | Connect to motor phase A |
|
||||
| B1, B2 | Motor Winding B | Connect to motor phase B |
|
||||
|
||||
### Status LEDs
|
||||
|
||||
- **LED H:** Shows error codes via blink patterns (see Wago manual section 3.3 for error codes)
|
||||
- Other LEDs show Ready, Enable, Busy states (see manual section 2.1.2.3)
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Code Organization
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
devices/
|
||||
wago_modules/
|
||||
wago_750_672.rs # Device struct, EtherCAT interface
|
||||
|
||||
io/
|
||||
stepper_velocity_wago_750_672.rs # High-level wrapper & byte helpers
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
#### 1. `Wago750_672` Struct (EtherCAT Device)
|
||||
|
||||
Located in `devices/wago_modules/wago_750_672.rs`
|
||||
|
||||
```rust
|
||||
pub struct Wago750_672 {
|
||||
is_used: bool,
|
||||
tx_bit_offset: usize, // Input data offset
|
||||
rx_bit_offset: usize, // Output data offset
|
||||
pub rxpdo: Wago750_672RxPdo, // Data we send
|
||||
pub txpdo: Wago750_672TxPdo, // Data we receive
|
||||
module: Option<Module>,
|
||||
pub state: InitState,
|
||||
pub initialized: bool,
|
||||
}
|
||||
```
|
||||
|
||||
**Responsibilities:**
|
||||
- Implements EtherCAT device traits
|
||||
- Handles bit-level I/O with process image
|
||||
- Runs state machine in `input()` method
|
||||
- Thread-safe (used via `Arc<RwLock<>>`)
|
||||
|
||||
#### 2. `StepperVelocityWago750672` Wrapper
|
||||
|
||||
Located in `io/stepper_velocity_wago_750_672.rs`
|
||||
|
||||
```rust
|
||||
pub struct StepperVelocityWago750672 {
|
||||
pub device: Arc<RwLock<Wago750_672>>,
|
||||
pub state: InitState,
|
||||
pub target_velocity: i16,
|
||||
pub target_acceleration: u16,
|
||||
pub enabled: bool,
|
||||
pub freq_range_sel: u8,
|
||||
pub acc_range_sel: u8,
|
||||
}
|
||||
```
|
||||
|
||||
**Responsibilities:**
|
||||
- Provides high-level API (`set_velocity()`, `set_enabled()`)
|
||||
- Manages prescaler settings
|
||||
- Abstracts away state machine complexity
|
||||
|
||||
### Why Two Layers?
|
||||
|
||||
1. **`Wago750_672`:** Low-level EtherCAT device that handles cycle-by-cycle I/O
|
||||
2. **`StepperVelocityWago750672`:** High-level wrapper for application logic
|
||||
|
||||
This separation allows:
|
||||
- EtherCAT device to run in tight real-time loop
|
||||
- Application code to use simple API without worrying about cycles/acknowledgments
|
||||
- Easy testing of each layer independently
|
||||
|
||||
---
|
||||
|
||||
## Control & Status Bytes
|
||||
|
||||
### Design Pattern: Type-Safe Byte Construction
|
||||
|
||||
Instead of hardcoding hex values like `0x1B`, we use builder pattern with enums:
|
||||
|
||||
```rust
|
||||
// ❌ BAD - magic numbers, error-prone
|
||||
dev.rxpdo.c1 = 0x1B;
|
||||
|
||||
// ✅ GOOD - explicit, self-documenting
|
||||
let c1 = ControlByteC1::new()
|
||||
.with_flag(C1Flag::Enable)
|
||||
.with_flag(C1Flag::Stop2N)
|
||||
.with_command(C1Command::SpeedControl)
|
||||
.bits();
|
||||
dev.rxpdo.c1 = c1;
|
||||
```
|
||||
|
||||
### Wago 750-672 Control Bytes
|
||||
|
||||
#### Control Byte C1 (Offset 11) - Main Control
|
||||
|
||||
```rust
|
||||
pub struct ControlByteC1(u8);
|
||||
|
||||
pub enum C1Flag {
|
||||
Enable = 0b0000_0001, // Bit 0: Must be 1 to enable
|
||||
Stop2N = 0b0000_0010, // Bit 1: Must be 1 for normal operation
|
||||
Start = 0b0000_0100, // Bit 2: Rising edge triggers action
|
||||
}
|
||||
|
||||
pub enum C1Command {
|
||||
Idle = 0b0000_0000, // Bits 3-7: Operating mode
|
||||
SinglePosition = 0b0000_1000,
|
||||
RunProgram = 0b0001_0000,
|
||||
SpeedControl = 0b0001_1000, // ← We use this
|
||||
Reference = 0b0010_0000,
|
||||
JogMode = 0b0010_1000,
|
||||
Mailbox = 0b0011_0000,
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```rust
|
||||
impl ControlByteC1 {
|
||||
pub const fn new() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
|
||||
pub const fn with_flag(mut self, flag: C1Flag) -> Self {
|
||||
self.0 |= flag as u8; // OR in the bit
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_command(mut self, cmd: C1Command) -> Self {
|
||||
self.0 = (self.0 & 0b0000_0111) | (cmd as u8); // Mask lower 3 bits, set upper 5
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn bits(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- `const fn` allows compile-time evaluation
|
||||
- Chainable builder pattern
|
||||
- Command overwrites bits 3-7 while preserving 0-2
|
||||
- Flags are additive (multiple can be set)
|
||||
|
||||
#### Control Byte C2 (Offset 10) - Prescalers & Error Handling
|
||||
|
||||
```rust
|
||||
pub struct ControlByteC2(u8);
|
||||
|
||||
pub enum C2Flag {
|
||||
FreqRangeSelL = 0b0000_0001, // Bit 0: Freq prescaler LSB
|
||||
FreqRangeSelH = 0b0000_0010, // Bit 1: Freq prescaler MSB
|
||||
AccRangeSelL = 0b0000_0100, // Bit 2: Acc prescaler LSB
|
||||
AccRangeSelH = 0b0000_1000, // Bit 3: Acc prescaler MSB
|
||||
PreCalc = 0b0100_0000, // Bit 6: Pre-calculation
|
||||
ErrorQuit = 0b1000_0000, // Bit 7: Acknowledge error
|
||||
}
|
||||
|
||||
impl ControlByteC2 {
|
||||
pub const fn with_freq_range(mut self, sel: u8) -> Self {
|
||||
self.0 = (self.0 & 0b1111_1100) | (sel & 0b0000_0011);
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_acc_range(mut self, sel: u8) -> Self {
|
||||
self.0 = (self.0 & 0b1111_0011) | ((sel & 0b0000_0011) << 2);
|
||||
self
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** Prescalers can only be changed when controller is **disabled** (before Enable state).
|
||||
|
||||
#### Control Byte C3 (Offset 9) - Position & Reset
|
||||
|
||||
```rust
|
||||
pub struct ControlByteC3(u8);
|
||||
|
||||
pub enum C3Flag {
|
||||
SetActualPos = 0b0000_0001, // Bit 0: Set current position
|
||||
DirectionPos = 0b0000_0010, // Bit 2: Force positive direction
|
||||
DirectionNeg = 0b0000_0100, // Bit 3: Force negative direction
|
||||
ResetQuit = 0b1000_0000, // Bit 7: Acknowledge reset
|
||||
}
|
||||
```
|
||||
|
||||
### Status Bytes (Read-Only)
|
||||
|
||||
#### Status Byte S1 (Offset 11) - Acknowledgments
|
||||
|
||||
```rust
|
||||
pub struct StatusByteS1(u8);
|
||||
|
||||
pub enum S1Flag {
|
||||
Ready = 0b0000_0001, // Bit 0: Acknowledges Enable
|
||||
Stop2NAck = 0b0000_0010, // Bit 1: Acknowledges Stop2_N
|
||||
StartAck = 0b0000_0100, // Bit 2: Acknowledges Start
|
||||
}
|
||||
// Bits 3-7: Mirrors C1 command when acknowledged
|
||||
|
||||
impl StatusByteS1 {
|
||||
pub const fn has_flag(self, flag: S1Flag) -> bool {
|
||||
(self.0 & flag as u8) != 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage Pattern:**
|
||||
```rust
|
||||
// Wait for acknowledgment
|
||||
if self.txpdo.s1 == c1 {
|
||||
// All bits acknowledged, proceed
|
||||
}
|
||||
```
|
||||
|
||||
#### Status Byte S2 (Offset 10) - Operation Status
|
||||
|
||||
```rust
|
||||
pub struct StatusByteS2(u8);
|
||||
|
||||
pub enum S2Flag {
|
||||
OnTarget = 0b0000_0001, // Bit 0: Position reached (positioning mode)
|
||||
Busy = 0b0000_0010, // Bit 1: Executing command
|
||||
StandStill = 0b0000_0100, // Bit 2: Motor not moving
|
||||
OnSpeed = 0b0000_1000, // Bit 3: Target velocity reached
|
||||
Direction = 0b0001_0000, // Bit 4: Rotation direction (0=neg, 1=pos)
|
||||
ReferenceOk = 0b0010_0000, // Bit 5: Referencing complete
|
||||
PreCalcAck = 0b0100_0000, // Bit 6: Pre-calc acknowledged
|
||||
Error = 0b1000_0000, // Bit 7: Error occurred (check LED H)
|
||||
}
|
||||
```
|
||||
|
||||
**Critical for velocity control:** Monitor `OnSpeed` bit to know when target velocity is reached.
|
||||
|
||||
#### Status Byte S3 (Offset 9) - Digital Inputs & Warnings
|
||||
|
||||
```rust
|
||||
pub struct StatusByteS3(u8);
|
||||
|
||||
pub enum S3Flag {
|
||||
Input1 = 0b0000_0001, // Bits 0-5: Digital input states
|
||||
Input2 = 0b0000_0010,
|
||||
Input3 = 0b0000_0100,
|
||||
Input4 = 0b0000_1000,
|
||||
Input5 = 0b0001_0000,
|
||||
Input6 = 0b0010_0000,
|
||||
Warning = 0b0100_0000, // Bit 6: Warning condition
|
||||
Reset = 0b1000_0000, // Bit 7: Reset requested
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Critical Initialization Sequence
|
||||
|
||||
⚠️ **This sequence MUST be followed exactly or the controller won't work:**
|
||||
|
||||
1. **Set non-zero acceleration** (before enabling)
|
||||
2. **Set Enable + Stop2_N** (wait for ack)
|
||||
3. **Set operating mode** (0x18 for speed control, wait for ack)
|
||||
4. **Issue Start pulse** (set to 1, wait for ack, set to 0, wait for ack clear)
|
||||
5. **Running** (repeat step 4 every time velocity changes)
|
||||
|
||||
### State Machine (Why It Exists)
|
||||
|
||||
The state machine exists because **acknowledgments happen in different EtherCAT cycles**. You can't just set everything in one cycle.
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InitState {
|
||||
Off, // Controller disabled
|
||||
Enable, // Setting Enable + Stop2_N, waiting for ack
|
||||
SetMode, // Setting speed control mode, waiting for ack
|
||||
StartPulseStart, // Setting Start bit, waiting for ack
|
||||
StartPulseEnd, // Clearing Start bit, waiting for ack clear
|
||||
Running, // Normal operation
|
||||
ErrorQuit, // Handling error
|
||||
ResetQuit, // Handling reset
|
||||
}
|
||||
```
|
||||
|
||||
### State Machine Implementation
|
||||
|
||||
Located in `Wago750_672::input()` method (runs every EtherCAT cycle):
|
||||
|
||||
```rust
|
||||
fn input(&mut self, input: &BitSlice) -> Result<(), anyhow::Error> {
|
||||
// 1. Read process image from controller
|
||||
let base = self.tx_bit_offset;
|
||||
let mut b = [0u8; 12];
|
||||
for i in 0..12 {
|
||||
b[i] = input[base + i * 8..base + (i + 1) * 8].load_le();
|
||||
}
|
||||
|
||||
self.txpdo = Wago750_672TxPdo {
|
||||
s0: b[0],
|
||||
actual_velocity: i16::from_le_bytes([b[2], b[3]]),
|
||||
position_l: b[6],
|
||||
position_m: b[7],
|
||||
position_h: b[8],
|
||||
s3: b[9],
|
||||
s2: b[10],
|
||||
s1: b[11],
|
||||
};
|
||||
|
||||
// 2. State machine logic
|
||||
match self.state {
|
||||
InitState::Off => {
|
||||
self.initialized = false;
|
||||
// Do nothing until external enable request
|
||||
}
|
||||
|
||||
InitState::Enable => {
|
||||
// Build C1 byte with Enable and Stop2_N
|
||||
let c1 = ControlByteC1::new()
|
||||
.with_flag(C1Flag::Enable)
|
||||
.with_flag(C1Flag::Stop2N)
|
||||
.bits();
|
||||
self.rxpdo.c1 = c1;
|
||||
|
||||
// Wait for acknowledgment
|
||||
if self.txpdo.s1 == c1 {
|
||||
self.state = InitState::SetMode;
|
||||
}
|
||||
}
|
||||
|
||||
InitState::SetMode => {
|
||||
// Add speed control command
|
||||
let c1 = ControlByteC1::new()
|
||||
.with_flag(C1Flag::Enable)
|
||||
.with_flag(C1Flag::Stop2N)
|
||||
.with_command(C1Command::SpeedControl)
|
||||
.bits();
|
||||
self.rxpdo.c1 = c1;
|
||||
|
||||
// Wait for mode acknowledgment
|
||||
if self.txpdo.s1 == c1 {
|
||||
self.initialized = true;
|
||||
self.state = InitState::StartPulseStart;
|
||||
}
|
||||
}
|
||||
|
||||
InitState::StartPulseStart => {
|
||||
// Set Start bit
|
||||
let c1 = ControlByteC1::new()
|
||||
.with_flag(C1Flag::Enable)
|
||||
.with_flag(C1Flag::Stop2N)
|
||||
.with_flag(C1Flag::Start) // ← Rising edge
|
||||
.with_command(C1Command::SpeedControl)
|
||||
.bits();
|
||||
self.rxpdo.c1 = c1;
|
||||
|
||||
// Wait for Start acknowledgment
|
||||
if self.txpdo.s1 == c1 {
|
||||
self.state = InitState::StartPulseEnd;
|
||||
}
|
||||
}
|
||||
|
||||
InitState::StartPulseEnd => {
|
||||
// Clear Start bit
|
||||
let c1 = ControlByteC1::new()
|
||||
.with_flag(C1Flag::Enable)
|
||||
.with_flag(C1Flag::Stop2N)
|
||||
.with_command(C1Command::SpeedControl)
|
||||
.bits();
|
||||
self.rxpdo.c1 = c1;
|
||||
|
||||
// Wait for acknowledgment to clear
|
||||
if self.txpdo.s1 == c1 {
|
||||
self.state = InitState::Running;
|
||||
}
|
||||
}
|
||||
|
||||
InitState::Running => {
|
||||
// Monitor for errors and resets
|
||||
let c2_error_mask = ControlByteC2::new().with_flag(C2Flag::ErrorQuit).bits();
|
||||
let c3_reset_mask = ControlByteC3::new().with_flag(C3Flag::ResetQuit).bits();
|
||||
|
||||
if self.txpdo.s2 & c2_error_mask != 0 {
|
||||
self.state = InitState::ErrorQuit;
|
||||
} else if self.txpdo.s3 & c3_reset_mask != 0 {
|
||||
self.state = InitState::ResetQuit;
|
||||
}
|
||||
}
|
||||
|
||||
InitState::ErrorQuit => {
|
||||
// Set Error_Quit bit to acknowledge
|
||||
self.rxpdo.c2 |= ControlByteC2::new().with_flag(C2Flag::ErrorQuit).bits();
|
||||
tracing::error!("Stepper Controller Errored. Trying to reenable...");
|
||||
self.state = InitState::Enable; // Restart initialization
|
||||
}
|
||||
|
||||
InitState::ResetQuit => {
|
||||
// Set Reset_Quit bit to acknowledge
|
||||
self.rxpdo.c3 |= ControlByteC3::new().with_flag(C3Flag::ResetQuit).bits();
|
||||
tracing::error!("Stepper Controller Reset. Trying to reenable...");
|
||||
self.state = InitState::Enable; // Restart initialization
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**Key Implementation Notes:**
|
||||
|
||||
1. **State changes happen AFTER acknowledgment:** We wait for `self.txpdo.s1 == c1` before moving to next state
|
||||
2. **Enable and Stop2_N stay high:** They're included in every C1 byte after Enable state
|
||||
3. **Start pulse is a rising edge:** Must go 1 → 0, not just stay at 1
|
||||
4. **Error recovery:** Automatically attempts to re-enable on error/reset
|
||||
|
||||
### High-Level API Implementation
|
||||
|
||||
Located in `StepperVelocityWago750672`:
|
||||
|
||||
```rust
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
// Prevent redundant enables
|
||||
if self.enabled && enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety check: acceleration must be non-zero
|
||||
if self.get_target_acceleration() == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.enabled = enabled;
|
||||
if enabled {
|
||||
self.change_init_state(InitState::Enable);
|
||||
} else {
|
||||
self.change_init_state(InitState::Off);
|
||||
self.write_control_byte(ControlByte::C1, 0b00000000);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_velocity(&mut self, velocity: i16) {
|
||||
self.target_velocity = velocity;
|
||||
|
||||
let mut dev = block_on(self.device.write());
|
||||
|
||||
// Clamp to valid range
|
||||
dev.rxpdo.velocity = velocity.clamp(-25000, 25000);
|
||||
|
||||
// Trigger Start pulse if already initialized
|
||||
if dev.initialized {
|
||||
dev.state = InitState::StartPulseStart;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_acceleration(&mut self, acceleration: u16) {
|
||||
self.target_acceleration = acceleration;
|
||||
|
||||
let mut dev = block_on(self.device.write());
|
||||
dev.rxpdo.acceleration = acceleration;
|
||||
}
|
||||
```
|
||||
|
||||
**Why the wrapper?**
|
||||
- Application code doesn't need to know about state machine
|
||||
- Handles common errors (zero acceleration check)
|
||||
- Thread-safe access to device
|
||||
- Clamping and validation in one place
|
||||
|
||||
### Changing Velocity at Runtime
|
||||
|
||||
⚠️ **Critical:** Must issue a **new Start pulse** for every velocity change:
|
||||
|
||||
```rust
|
||||
// User calls this
|
||||
stepper.set_velocity(15000);
|
||||
|
||||
// Internally, set_velocity() does:
|
||||
dev.rxpdo.velocity = 15000;
|
||||
if dev.initialized {
|
||||
dev.state = InitState::StartPulseStart; // ← Triggers new pulse
|
||||
}
|
||||
|
||||
// State machine then handles:
|
||||
// StartPulseStart → StartPulseEnd → Running
|
||||
// (Takes 2-3 EtherCAT cycles)
|
||||
```
|
||||
|
||||
**Common mistake:** Setting velocity without triggering Start pulse - motor won't change speed.
|
||||
|
||||
---
|
||||
|
||||
## Prescalers & Calculations
|
||||
|
||||
Prescalers scale the velocity and acceleration values. They **can only be changed when disabled**.
|
||||
|
||||
### Frequency Prescaler (Bits 0-1 of C2)
|
||||
|
||||
Controls velocity scaling:
|
||||
|
||||
| Freq_Range_Sel | Prescaler | Max Frequency | Resolution |
|
||||
|----------------|-----------|---------------|------------|
|
||||
| 00 | 200 (default) | 10 kHz | Configurable |
|
||||
| 01 | 80 | 25 kHz | 1 Hz |
|
||||
| 10 | 20 | 100 kHz | 20 Hz |
|
||||
| 11 | 4 | 500 kHz | 20 Hz |
|
||||
|
||||
**Formula:**
|
||||
```
|
||||
Output Frequency (fp) = Velocity × 80 / Freq_Prescaler [Hz]
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```rust
|
||||
// Freq_Range_Sel = 01 (prescaler = 80)
|
||||
// Velocity = 10000
|
||||
// Output: 10000 × 80 / 80 = 10000 Hz
|
||||
|
||||
stepper.set_freq_range_sel(1); // Must be done BEFORE enable
|
||||
stepper.set_velocity(10000);
|
||||
```
|
||||
|
||||
### Acceleration Prescaler (Bits 2-3 of C2)
|
||||
|
||||
Controls acceleration scaling:
|
||||
|
||||
| Acc_Range_Sel | Multiplier | Time to Max Speed |
|
||||
|---------------|------------|-------------------|
|
||||
| 00 | 8 (default) | 7600 ms |
|
||||
| 01 | 80 | 760 ms |
|
||||
| 10 | 800 | 76 ms |
|
||||
| 11 | 8000 | 7.6 ms |
|
||||
|
||||
**Formula:**
|
||||
```
|
||||
Acceleration (a) = Acceleration_Value × Acc_Multiplier / Freq_Prescaler [Hz/s]
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```rust
|
||||
// Acc_Range_Sel = 01 (multiplier = 80)
|
||||
// Freq_Range_Sel = 01 (prescaler = 80)
|
||||
// Acceleration_Value = 10000
|
||||
// Output: 10000 × 80 / 80 = 10000 Hz/s
|
||||
|
||||
stepper.set_acc_range_sel(1); // Must be done BEFORE enable
|
||||
stepper.set_acceleration(10000);
|
||||
```
|
||||
|
||||
### Setting Prescalers (Implementation)
|
||||
|
||||
```rust
|
||||
pub fn set_freq_range_sel(&mut self, factor: u8) {
|
||||
// Safety checks
|
||||
if self.enabled || factor > 3 {
|
||||
return; // Can't change while enabled
|
||||
}
|
||||
|
||||
self.freq_range_sel = factor;
|
||||
|
||||
let mut dev = block_on(self.device.write());
|
||||
let c2 = ControlByteC2::from_bits(dev.rxpdo.c2)
|
||||
.with_freq_range(factor)
|
||||
.bits();
|
||||
dev.rxpdo.c2 = c2;
|
||||
}
|
||||
|
||||
pub fn set_acc_range_sel(&mut self, factor: u8) {
|
||||
if self.enabled || factor > 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.acc_range_sel = factor;
|
||||
|
||||
let mut dev = block_on(self.device.write());
|
||||
let c2 = ControlByteC2::from_bits(dev.rxpdo.c2)
|
||||
.with_acc_range(factor)
|
||||
.bits();
|
||||
dev.rxpdo.c2 = c2;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```rust
|
||||
let mut stepper = StepperVelocityWago750672::new(device);
|
||||
|
||||
// Set prescalers FIRST (before enabling)
|
||||
stepper.set_freq_range_sel(1);
|
||||
stepper.set_acc_range_sel(1);
|
||||
stepper.set_acceleration(10000);
|
||||
|
||||
// Then enable
|
||||
stepper.set_enabled(true);
|
||||
|
||||
// Now can set velocity
|
||||
stepper.set_velocity(15000);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls & Debug Tips
|
||||
|
||||
### 1. Controller Won't Enable
|
||||
|
||||
**Symptoms:**
|
||||
- State stuck in `Enable`
|
||||
- `initialized` never becomes true
|
||||
- Motor doesn't move
|
||||
|
||||
**Debug checklist:**
|
||||
```rust
|
||||
// Check acceleration is non-zero
|
||||
println!("Acceleration: {}", dev.rxpdo.acceleration); // Must be > 0
|
||||
|
||||
// Check DI1 if hardware enable is configured
|
||||
println!("DI1 state: {}", (dev.txpdo.s3 & 0x01) != 0); // Should be 1
|
||||
|
||||
// Check status byte acknowledgment
|
||||
println!("C1: {:#010b}", dev.rxpdo.c1);
|
||||
println!("S1: {:#010b}", dev.txpdo.s1); // Should match C1
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
```rust
|
||||
// ❌ Forgot to set acceleration
|
||||
stepper.set_acceleration(0); // Won't enable!
|
||||
|
||||
// ✅ Always set non-zero acceleration
|
||||
stepper.set_acceleration(10000);
|
||||
stepper.set_enabled(true);
|
||||
```
|
||||
|
||||
### 2. Velocity Won't Change
|
||||
|
||||
**Symptoms:**
|
||||
- Motor running but won't change speed
|
||||
- `set_velocity()` called but no effect
|
||||
|
||||
**Debug:**
|
||||
```rust
|
||||
// Check initialized flag
|
||||
println!("Initialized: {}", dev.initialized);
|
||||
|
||||
// Check state after velocity change
|
||||
println!("State: {:?}", dev.state); // Should become StartPulseStart
|
||||
|
||||
// Manually trigger Start pulse (bypass wrapper)
|
||||
dev.state = InitState::StartPulseStart;
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
```rust
|
||||
// ❌ Setting velocity before initialization
|
||||
stepper.set_velocity(10000); // Controller not ready yet
|
||||
|
||||
// ✅ Check initialized first
|
||||
if stepper.device.read().initialized {
|
||||
stepper.set_velocity(10000);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Prescaler Not Working
|
||||
|
||||
**Symptoms:**
|
||||
- Velocity calculation seems wrong
|
||||
- Can't reach expected frequency
|
||||
|
||||
**Debug:**
|
||||
```rust
|
||||
// Check prescalers were set BEFORE enabling
|
||||
println!("Freq prescaler: {}", stepper.freq_range_sel);
|
||||
println!("Acc prescaler: {}", stepper.acc_range_sel);
|
||||
println!("Enabled: {}", stepper.enabled);
|
||||
|
||||
// Check C2 byte
|
||||
let dev = stepper.device.read();
|
||||
println!("C2: {:#010b}", dev.rxpdo.c2);
|
||||
// Bits 0-1: freq, Bits 2-3: acc
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
```rust
|
||||
// ❌ Setting prescaler after enable
|
||||
stepper.set_enabled(true);
|
||||
stepper.set_freq_range_sel(1); // Too late! Ignored.
|
||||
|
||||
// ✅ Set prescalers FIRST
|
||||
stepper.set_freq_range_sel(1);
|
||||
stepper.set_acc_range_sel(1);
|
||||
stepper.set_enabled(true);
|
||||
```
|
||||
|
||||
### 4. State Machine Stuck
|
||||
|
||||
**Symptoms:**
|
||||
- State doesn't progress
|
||||
- Acknowledgments not received
|
||||
|
||||
**Debug:**
|
||||
```rust
|
||||
// Add logging to state machine
|
||||
match self.state {
|
||||
InitState::Enable => {
|
||||
println!("Enable state: C1={:#010b}, S1={:#010b}", self.rxpdo.c1, self.txpdo.s1);
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// Check EtherCAT communication
|
||||
// - Is cycle running?
|
||||
// - Are other devices working?
|
||||
// - Check bus topology
|
||||
```
|
||||
|
||||
### 5. Error Bit Set
|
||||
|
||||
**Symptoms:**
|
||||
- Error bit (S2 bit 7) is 1
|
||||
- LED H blinking
|
||||
- Controller stops
|
||||
|
||||
**Debug:**
|
||||
```rust
|
||||
// Check error state
|
||||
let s2 = StatusByteS2::from_bits(dev.txpdo.s2);
|
||||
if s2.has_flag(S2Flag::Error) {
|
||||
println!("ERROR DETECTED");
|
||||
|
||||
// Count LED H blinks for error code
|
||||
// See Wago manual section 3.3
|
||||
|
||||
// Check other status flags for clues
|
||||
println!("Busy: {}", s2.has_flag(S2Flag::Busy));
|
||||
println!("StandStill: {}", s2.has_flag(S2Flag::StandStill));
|
||||
}
|
||||
```
|
||||
|
||||
**Common error codes:**
|
||||
- **2 blinks:** Overcurrent
|
||||
- **3 blinks:** Position error
|
||||
- **4 blinks:** Encoder fault
|
||||
- See Wago manual for complete list
|
||||
|
||||
---
|
||||
|
||||
### Supporting Wago 750-671
|
||||
|
||||
The 750-671 uses different C1 bit layout:
|
||||
|
||||
1. **Create separate enum:**
|
||||
```rust
|
||||
// For 750-671
|
||||
pub enum C1Flag671 {
|
||||
Enable = 0b0000_0001, // Bit 0
|
||||
Stop2N = 0b0000_0010, // Bit 1
|
||||
Start = 0b0000_0100, // Bit 2
|
||||
MSpeedControl = 0b0000_1000, // Bit 3 (instead of command field)
|
||||
MProgram = 0b0001_0000, // Bit 4
|
||||
MReference = 0b0010_0000, // Bit 5
|
||||
MJog = 0b0100_0000, // Bit 6
|
||||
MDriveByMBX = 0b1000_0000, // Bit 7
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference Card
|
||||
|
||||
### Initialization Checklist
|
||||
```rust
|
||||
// 1. Create and configure
|
||||
let stepper = StepperVelocityWago750672::new(device);
|
||||
|
||||
// 2. Set prescalers (optional, before enable)
|
||||
stepper.set_freq_range_sel(1); // 0-3
|
||||
stepper.set_acc_range_sel(1); // 0-3
|
||||
|
||||
// 3. Set non-zero acceleration (REQUIRED)
|
||||
stepper.set_acceleration(10000);
|
||||
|
||||
// 4. Enable
|
||||
stepper.set_enabled(true);
|
||||
|
||||
// 5. Set velocity (after initialized)
|
||||
stepper.set_velocity(15000); // ±25000 max
|
||||
```
|
||||
|
||||
### Key Constants
|
||||
```rust
|
||||
const SPEED_CONTROL_CMD: u8 = 0x18; // C1 command for 750-672
|
||||
const MAX_VELOCITY: i16 = 25000;
|
||||
const MIN_VELOCITY: i16 = -25000;
|
||||
const DEFAULT_ACCELERATION: u16 = 10000;
|
||||
```
|
||||
|
||||
### Critical Rules
|
||||
1. ⚠️ **Never enable with zero acceleration**
|
||||
2. ⚠️ **Always wait for acknowledgments**
|
||||
3. ⚠️ **Issue Start pulse for every velocity change**
|
||||
4. ⚠️ **Prescalers can only be changed when disabled**
|
||||
5. ⚠️ **Keep Enable and Stop2_N high during operation**
|
||||
|
||||
### Common Bit Patterns
|
||||
```rust
|
||||
// Enable + Stop2_N
|
||||
0b0000_0011
|
||||
|
||||
// Enable + Stop2_N + Speed Control
|
||||
0b0001_1011
|
||||
|
||||
// Enable + Stop2_N + Speed Control + Start
|
||||
0b0001_1111
|
||||
```
|
||||
|
||||
### Debug Commands
|
||||
```bash
|
||||
# View byte values
|
||||
println!("C1: {:#010b}", dev.rxpdo.c1);
|
||||
println!("S1: {:#010b}", dev.txpdo.s1);
|
||||
|
||||
# Check specific flags
|
||||
let s2 = StatusByteS2::from_bits(dev.txpdo.s2);
|
||||
println!("Error: {}", s2.has_flag(S2Flag::Error));
|
||||
println!("OnSpeed: {}", s2.has_flag(S2Flag::OnSpeed));
|
||||
|
||||
# Monitor state
|
||||
println!("State: {:?}", dev.state);
|
||||
println!("Initialized: {}", dev.initialized);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **Wago Manual:** Section 2.1.2.3 (LEDs), 3.3 (Error Codes)
|
||||
- **WAGO I/O CHECK:** Configuration software for 750-671 application selector
|
||||
- **EtherCAT Specification:** www.ethercat.org
|
||||
- **Code Location:**
|
||||
- Device: `devices/wago_modules/wago_750_672.rs`
|
||||
- Wrapper: `io/stepper_velocity_wago_750_672.rs`
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Version | Date | Changes |
|
||||
| ------- | ------- | ------------------------------------------- |
|
||||
| 1.0 | 2025 | Initial implementation with state machine |
|
||||
| 2.0 | 2026-02 | Added comprehensive developer documentation |
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Check the troubleshooting section or add debug logging using the template above.
|
||||
@@ -89,6 +89,7 @@ To get started with actual hardware, check out these step-by-step tutorials:
|
||||
|
||||
- **[LED Control with EL2004](./minimal-example-el2004.md)** - Digital output control, the simplest possible hardware setup
|
||||
- **[Analog Input with EL3021](./minimal-example-el3021.md)** - Reading analog current measurements
|
||||
- **[Stepper Motor Control with EL7031-0030](./minimal-example-el7031-motor.md)** - Complete motor control integration with velocity control
|
||||
|
||||
These examples provide complete hardware wiring diagrams and software setup instructions.
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
# Minimal Example — Beckhoff EL7031-0030 Stepper Motor Integration
|
||||
A complete hardware + software walkthrough
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
1. [Introduction](#1-introduction)
|
||||
2. [Requirements](#2-requirements)
|
||||
3. [Hardware Setup](#3-hardware-setup)
|
||||
- [3.2 EK1100 Wiring](#32-ek1100-wiring)
|
||||
- [3.2.1 Safe Wiring Procedure](#321-safe-wiring-procedure-beckhoff-recommended)
|
||||
- [3.2.2 Wiring (EL7031-0030)](#322-wiring-el7031-0030)
|
||||
- [3.3 Safety Warning](#33--safety-warning)
|
||||
4. [Software Setup](#4-software-setup)
|
||||
- [4.1 Installing on Linux](#41-installing-on-linux-this-depends-on-your-distro)
|
||||
- [4.2 Running the Backend](#42-running-the-backend)
|
||||
- [4.3 Running the Frontend](#43-running-the-frontend)
|
||||
5. [Demo](#5-demo)
|
||||
- [5.1 Assigning Devices in the Dashboard](#51-assigning-devices-in-the-dashboard)
|
||||
6. [Documentation](#6-documentation)
|
||||
7. [Software Architecture](#7-software-architecture)
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
This project documents the successful integration of a **Beckhoff EL7031-0030 Stepper Motor Terminal** into the QiTech control software stack.
|
||||
The goal was to control a 24V stepper motor via EtherCAT using a custom Rust backend and a React/Electron frontend.
|
||||
|
||||
---
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
### Software
|
||||
- Rust toolchain
|
||||
- Node.js + npm
|
||||
- Git
|
||||
- QiTech Control repository
|
||||
- EtherCAT HAL (included inside repo)
|
||||
|
||||
### Hardware
|
||||
- **EtherCAT Master:** Linux PC
|
||||
- **Bus Coupler:** EtherCAT Beckhoff EK1100
|
||||
- **Stepper Driver:** Beckhoff EL7031-0030 (THIS IS DIFFERENT FROM EL7031)
|
||||
- **Motor:** Standard 4-wire Stepper Motor
|
||||
- **Power Supply:** 24V DC
|
||||
- **Ethernet Cable:** Standard Ethernet cable
|
||||
- **Wiring Tools:** Screwdriver, wires
|
||||
|
||||
---
|
||||
|
||||
## 3. Hardware Setup
|
||||
|
||||
### 3.2 EK1100 Wiring
|
||||
|
||||
This wiring configuration powers the EL7031-0030.
|
||||
It is not the only possible wiring but is the **simplest functional setup**.
|
||||
|
||||
#### ⚠️ Safety Warning
|
||||
Always disconnect power before wiring.
|
||||
Working on live EtherCAT terminals can cause serious damage or electrical shock.
|
||||
|
||||
---
|
||||
|
||||
#### 3.2.1 Safe Wiring Procedure (Beckhoff Recommended)
|
||||
|
||||
1. Insert a screwdriver **straight** into the square release hole.
|
||||
2. Insert the stripped wire into the round opening.
|
||||
3. Remove the screwdriver — the spring clamp locks the wire.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
We supply power using a **DC hollow-plug adapter**, like this one:
|
||||
https://www.amazon.de/dp/B093FTFZ8Q
|
||||
|
||||
Perform the following wiring on the EK1100:
|
||||
|
||||
1. Red wire **(+24 V)** → Terminal **2**
|
||||
2. Black wire **(0 V)** → Terminal **3**
|
||||
3. Jumper wire from **Terminal 1 → Terminal 6**
|
||||
4. Jumper wire from **Terminal 5 → Terminal 7**
|
||||
|
||||
After wiring, your module should look like **Figure 1**.
|
||||
|
||||
---
|
||||
|
||||
#### **Figure 1 — EK1100 Minimal Wiring**
|
||||
<img src="../assets/ek1100.jpeg" width="400">
|
||||
|
||||
---
|
||||
|
||||
#### **Figure 2 — EL7031-0030 Terminal**
|
||||
|
||||
<img src="../assets/EL7031_0030.jpg" width="300">
|
||||
|
||||
Slide the EL7031-0030 onto the right side of the EK1100 until it locks.
|
||||
The EtherCAT E-Bus and power contacts connect automatically — **no wiring required**.
|
||||
|
||||
---
|
||||
|
||||
#### **Figure 3 — Motor**
|
||||
<img src="../assets/motor.jpg" width="300">
|
||||
|
||||
---
|
||||
|
||||
#### **Figure 4 — Motor Wiring**
|
||||
<img src="../assets/motor_wiring.jpg" width="300">
|
||||
|
||||
Now the motor is wired via the pins 4, 5, 12, 13 on the EL7031-0030.
|
||||
|
||||
---
|
||||
|
||||
#### **Figure 5 — EL7031 Integration Connected**
|
||||
|
||||
<img src="../assets/EL7031_0030_connected.jpg" width="400">
|
||||
|
||||
That's what the pin should look like.
|
||||
|
||||
---
|
||||
|
||||
### 3.2.2 Wiring (EL7031-0030)
|
||||
|
||||
**Crucial:** The EL7031 requires two power sources: E-Bus (side contacts) for logic, and Front Terminal (6 & 14) for motor power.
|
||||
|
||||
| Terminal Point | Function | Cable Color (Example) |
|
||||
| :------------- | :--------------- | :-------------------- |
|
||||
| **4** | Motor Coil A1 | Red |
|
||||
| **12** | Motor Coil A2 | Blue |
|
||||
| **5** | Motor Coil B1 | Green |
|
||||
| **13** | Motor Coil B2 | Black |
|
||||
| **6** | **Power +24V** | PSU Red (+) |
|
||||
| **14** | **Power 0V/GND** | PSU Black (-) |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 ⚠️ Safety Warning
|
||||
|
||||
- **Mandatory Power Supply:** Without 24V connected to **Pin 6 (+24V)** and **Pin 14 (GND)**, the terminal will remain in `PREOP` state or show a "Warning" LED (No Power). The motor will **not** move without this external supply.
|
||||
|
||||
#### Risk of Destruction (Short Circuit):
|
||||
- **Never** connect the 24V Power Supply to the Motor Output pins (**4, 5, 12, 13**). This will instantly destroy the terminal (causing pins 12 & 13 to glow red/burn).
|
||||
- Ensure strict separation: **Pins 4/5/12/13 are for the MOTOR ONLY**.
|
||||
- **Pins 6/14 are for POWER ONLY**.
|
||||
|
||||
Also, see the documentation of the EtherCAT terminal ([EL7031-0030](https://download.beckhoff.com/download/document/io/ethercat-terminals/el7031-0030de.pdf)) for the different power modes (page 46 ff.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Software Setup
|
||||
|
||||
### 4.1 Installing on Linux (this depends on your distro)
|
||||
|
||||
Paste this into your terminal:
|
||||
|
||||
```bash
|
||||
# Press Enter when prompted
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
sudo apt update
|
||||
sudo apt install -y npm nodejs git
|
||||
|
||||
git clone git@github.com:qitechgmbh/control.git
|
||||
cd control/electron
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4.2 Running the Backend
|
||||
|
||||
```bash
|
||||
./cargo_run_linux.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
- Builds the backend
|
||||
- Grants required system capabilities (raw sockets)
|
||||
- Starts EtherCAT communication
|
||||
|
||||
### 4.3 Running the Frontend
|
||||
|
||||
```bash
|
||||
# move to the control directory
|
||||
cd electron
|
||||
npm run start
|
||||
```
|
||||
|
||||
This launches the QiTech Control dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 5. Demo
|
||||
|
||||
### 5.1 Assigning Devices in the Dashboard
|
||||
|
||||
Once the backend + frontend are running, you should see:
|
||||
|
||||

|
||||
|
||||
Make sure that under "Assign" → "Machine Assignment"
|
||||
the correct serial number is selected (each device should have the same one, here it is 1).
|
||||
Under "Machine" select your new Machine however you named it (I named it "TestMotor V1"), else it will detect that there is something connected to it but the connection won't work.
|
||||
|
||||

|
||||
|
||||
**NOW THE MOTOR SHOULD TURN!**
|
||||
|
||||
In the interface "TestMotor" on the left side, you can now control the motor (its state and its speed).
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 6. Documentation
|
||||
|
||||
Use the official documentation of the EL7031-0030 for more information:
|
||||
[Beckhoff EL7031-0030 Documentation](https://download.beckhoff.com/download/document/io/ethercat-terminals/el7031-0030de.pdf)
|
||||
|
||||
---
|
||||
|
||||
## 7. Software Architecture
|
||||
|
||||
### Backend (Rust)
|
||||
Located in [machines/src/motor_test_machine/](/machines/src/motor_test_machine/).
|
||||
|
||||
1. **`mod.rs`**: Defines the `MotorTestMachine` struct and holds the state (driver wrapper, enabled state, target velocity).
|
||||
2. **`api.rs`**: Handles incoming JSON commands from the frontend (Enable/Disable, Set Velocity) via WebSockets/SocketIO.
|
||||
3. **`act.rs`**: The real-time control loop. It updates the `StepperVelocityEL70x1` driver wrapper in every cycle based on the current state.
|
||||
4. **`new.rs`**: Initializes the hardware.
|
||||
|
||||
### Frontend (TypeScript/React)
|
||||
Located in [electron/src/machines/motor_test_machine/](/electron/src/machines/motor_test_machine/) and [electron/src/routes/routes.tsx](/electron/src/routes/routes.tsx).
|
||||
|
||||
1. **`useTestMotor.ts`**: Custom hook managing the optimistic state and communication with the backend.
|
||||
2. **`TestMotorControlPage.tsx`**: The UI using QiTech UI components (`ControlCard`, `EditValue`, `SelectionGroupBoolean`) to match the look and feel of the Winder2.
|
||||
3. **Routing**: Integrated into `routes.tsx` using TanStack Router, ensuring the machine appears in the sidebar and navigation works correctly.
|
||||
@@ -136,7 +136,7 @@ A slave recognizes message data as one message when a **3.5 character long no-da
|
||||
|
||||
# Inverter settings
|
||||
|
||||
## Motor Constants
|
||||
## Motor Constants for ExtruderV2:
|
||||
|
||||
The following is a table that shows which Registers need to be set on the Inverter for the safe operation of the Motor.
|
||||
|
||||
@@ -151,6 +151,22 @@ The following is a table that shows which Registers need to be set on the Invert
|
||||
| `3` | Base frequency | 5000 (50 Hz) |
|
||||
| `19` | Base frequency voltage | 400 (Volt) |
|
||||
|
||||
|
||||
## Motor Constants for ExtruderV3:
|
||||
|
||||
The following is a table that shows which Registers need to be set on the Inverter for the safe operation of the Motor.
|
||||
|
||||
| Parameter number (Pr) | Name | Value |
|
||||
| --------------------- | ---------------------------- | ------------- |
|
||||
| `71` | Applied Motor | 0 |
|
||||
| `80` | Motor Capacity | 22 (2.2 kw) |
|
||||
| `96` | Auto tuning setting/status | 1 |
|
||||
| `1` | Max Frequency | 5000 |
|
||||
| `2` | Min Frequency | 0 |
|
||||
| `9` | Electronic Thermal O/L Relay | 4.40 (Ampere) |
|
||||
| `3` | Base frequency | 5000 (50 Hz) |
|
||||
| `19` | Base frequency voltage | 400 (Volt) |
|
||||
|
||||
## All Settings needed for the Inverter Communication/Operation with Extruder
|
||||
|
||||
The Following settings are required to get the inverter Communication and Operation working properly
|
||||
|
||||
@@ -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": {
|
||||
@@ -1302,9 +1302,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": {
|
||||
@@ -2886,6 +2886,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",
|
||||
@@ -3128,66 +3135,6 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
@@ -4108,39 +4055,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": {
|
||||
@@ -4152,42 +4100,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": {
|
||||
@@ -4195,28 +4142,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"
|
||||
@@ -4630,16 +4573,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",
|
||||
@@ -4760,18 +4693,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"
|
||||
}
|
||||
@@ -4833,16 +4759,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",
|
||||
@@ -5124,16 +5040,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",
|
||||
@@ -5803,9 +5709,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",
|
||||
"dependencies": {
|
||||
@@ -5815,7 +5721,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",
|
||||
@@ -8054,13 +7960,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",
|
||||
@@ -9218,6 +9117,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",
|
||||
@@ -9406,16 +9316,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",
|
||||
@@ -9770,16 +9670,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"
|
||||
},
|
||||
@@ -10311,18 +10212,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/seroval": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.2.tgz",
|
||||
"integrity": "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz",
|
||||
"integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/seroval-plugins": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.4.0.tgz",
|
||||
"integrity": "sha512-zir1aWzoiax6pbBVjoYVd0O1QQXgIL3eVGBMsBsNmM8Ukq90yGaWlfx0AB9dTS8GPqrOrbXn79vmItCUP9U3BQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.0.tgz",
|
||||
"integrity": "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -10493,13 +10394,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"
|
||||
},
|
||||
@@ -10507,23 +10408,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",
|
||||
@@ -10565,38 +10449,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/solid-js": {
|
||||
"version": "1.9.10",
|
||||
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz",
|
||||
"integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==",
|
||||
"version": "1.9.11",
|
||||
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz",
|
||||
"integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.0",
|
||||
"seroval": "~1.3.0",
|
||||
"seroval-plugins": "~1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/solid-js/node_modules/seroval": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
|
||||
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/solid-js/node_modules/seroval-plugins": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz",
|
||||
"integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"seroval": "^1.0"
|
||||
"seroval": "~1.5.0",
|
||||
"seroval-plugins": "~1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
@@ -10802,26 +10663,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",
|
||||
@@ -10961,11 +10802,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",
|
||||
@@ -11012,30 +10856,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": {
|
||||
@@ -11673,29 +11497,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",
|
||||
@@ -11762,51 +11563,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": "*"
|
||||
},
|
||||
@@ -11814,13 +11614,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 +12011,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
||||
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "qitech-control-electron",
|
||||
"productName": "QiTech Control",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"version": "2.4.1",
|
||||
"description": "Next Generation Machine Interface ",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -36,7 +36,7 @@
|
||||
"@types/react-virtualized": "^9.22.2",
|
||||
"electron": "^37.3.1",
|
||||
"electron-playwright-helpers": "^1.7.1",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
@@ -53,7 +53,7 @@
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-electron": "^0.29.0",
|
||||
"vite-plugin-electron-renderer": "^0.14.6",
|
||||
"vitest": "^3.0.6"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
@@ -88,12 +88,12 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"socket.io-msgpack-parser": "^3.0.2",
|
||||
"sonner": "^2.0.1",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
@@ -102,6 +102,6 @@
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.3.5",
|
||||
"zod-validation-error": "^5.0.0",
|
||||
"zustand": "^5.0.8"
|
||||
"zustand": "^5.0.11"
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,11 @@ import { router } from "./routes/router";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { enableMapSet } from "immer";
|
||||
import { useGlobalLogStreaming } from "./hooks/useGlobalLogStreaming";
|
||||
import { GlobalLaserToastManager } from "./setup/GlobalLaserToastManager";
|
||||
|
||||
export default function App() {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
// Start global log streaming
|
||||
useGlobalLogStreaming();
|
||||
|
||||
useEffect(() => {
|
||||
syncThemeWithLocal();
|
||||
updateAppLanguage(i18n);
|
||||
|
||||
@@ -80,7 +80,7 @@ export function GraphControls({
|
||||
)}
|
||||
|
||||
<TouchButton
|
||||
onClick={onSwitchToHistorical}
|
||||
onClick={() => onSwitchToHistorical("button")}
|
||||
variant="outline"
|
||||
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
|
||||
!isLiveMode
|
||||
@@ -197,7 +197,7 @@ export function FloatingControlPanel({
|
||||
)}
|
||||
|
||||
<TouchButton
|
||||
onClick={onSwitchToHistorical}
|
||||
onClick={() => onSwitchToHistorical("button")}
|
||||
variant="outline"
|
||||
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
|
||||
!isLiveMode
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
AnimationRefs,
|
||||
AnimationState,
|
||||
BigGraphProps,
|
||||
GraphLine,
|
||||
SeriesData,
|
||||
} from "./types";
|
||||
import { alignTargetSeriesToTimestamps } from "@/lib/timeseries";
|
||||
|
||||
export function useAnimationRefs(): AnimationRefs {
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
@@ -50,7 +52,9 @@ export function buildUPlotData(
|
||||
values: number[],
|
||||
realPointsCount: number | undefined,
|
||||
realPointsCountRef: React.RefObject<number>,
|
||||
config: { lines?: Array<{ show?: boolean; value: number }> },
|
||||
config: {
|
||||
lines?: Array<Partial<GraphLine> & { show?: boolean; value: number }>;
|
||||
},
|
||||
allSeriesData?: number[][],
|
||||
): uPlot.AlignedData {
|
||||
const uData: uPlot.AlignedData = [timestamps];
|
||||
@@ -72,7 +76,14 @@ export function buildUPlotData(
|
||||
// Add config lines
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
uData.push(timestamps.map(() => line.value));
|
||||
const lineData = line.targetSeries
|
||||
? alignTargetSeriesToTimestamps(
|
||||
line.targetSeries,
|
||||
timestamps,
|
||||
line.value,
|
||||
)
|
||||
: timestamps.map(() => line.value);
|
||||
uData.push(lineData);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,7 +99,9 @@ export function animateNewPoint(
|
||||
viewMode: string,
|
||||
selectedTimeWindow: number | "all",
|
||||
startTimeRef: React.RefObject<number | null>,
|
||||
config: { lines?: Array<{ show?: boolean; value: number }> },
|
||||
config: {
|
||||
lines?: Array<Partial<GraphLine> & { show?: boolean; value: number }>;
|
||||
},
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void, // Updated signature
|
||||
getAllSeriesData?: () => number[][],
|
||||
): void {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData, TimeSeries } from "@/lib/timeseries";
|
||||
import {
|
||||
seriesToUPlotData,
|
||||
TimeSeries,
|
||||
alignTargetSeriesToTimestamps,
|
||||
} from "@/lib/timeseries";
|
||||
import {
|
||||
createEventHandlers,
|
||||
attachEventHandlers,
|
||||
@@ -8,6 +12,15 @@ import {
|
||||
import { BigGraphProps, CreateChartParams } from "./types";
|
||||
import { normalizeDataSeries } from "./animation";
|
||||
|
||||
type CustomDashedTargetLine = {
|
||||
dataIndex: number;
|
||||
dash: number[];
|
||||
color: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
const DASH_FLOW_PX_PER_SEC = 180;
|
||||
|
||||
export function createChart({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
@@ -59,7 +72,13 @@ export function createChart({
|
||||
// Add config lines data
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
const lineData = new Array(timestamps.length).fill(line.value);
|
||||
const lineData = line.targetSeries
|
||||
? alignTargetSeriesToTimestamps(
|
||||
line.targetSeries,
|
||||
timestamps,
|
||||
line.value,
|
||||
)
|
||||
: new Array(timestamps.length).fill(line.value);
|
||||
uPlotData.push(lineData as any);
|
||||
}
|
||||
});
|
||||
@@ -142,6 +161,8 @@ export function createChart({
|
||||
|
||||
// Build series configuration for ALL series (but control visibility)
|
||||
const seriesConfig: uPlot.Series[] = [{ label: "Time" }];
|
||||
const customDashedTargetLines: CustomDashedTargetLine[] = [];
|
||||
const dashFlowStartTime = performance.now();
|
||||
|
||||
// Add ALL data series with visibility control
|
||||
const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"];
|
||||
@@ -167,16 +188,35 @@ export function createChart({
|
||||
});
|
||||
|
||||
// Add config lines
|
||||
let visibleLineIndex = 0;
|
||||
const firstConfigLineDataIndex = 1 + allOriginalSeries.length;
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
const dash =
|
||||
line.dash ?? (line.type === "threshold" ? [5, 5] : undefined);
|
||||
const lineDataIndex = firstConfigLineDataIndex + visibleLineIndex;
|
||||
const isHistoricalDashedTarget = !!line.targetSeries && !!dash?.length;
|
||||
|
||||
if (isHistoricalDashedTarget) {
|
||||
customDashedTargetLines.push({
|
||||
dataIndex: lineDataIndex,
|
||||
dash: dash!,
|
||||
color: line.color,
|
||||
width: line.width ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
seriesConfig.push({
|
||||
label: line.label,
|
||||
stroke: line.color,
|
||||
// Historical dashed targets are rendered manually in draw hook to
|
||||
// avoid dash shimmer while the live x-axis slides.
|
||||
stroke: isHistoricalDashedTarget ? "rgba(0,0,0,0)" : line.color,
|
||||
width: line.width ?? 1,
|
||||
dash: line.dash ?? (line.type === "threshold" ? [5, 5] : undefined),
|
||||
dash: isHistoricalDashedTarget ? undefined : dash,
|
||||
show: true,
|
||||
points: { show: false },
|
||||
});
|
||||
visibleLineIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -306,6 +346,119 @@ export function createChart({
|
||||
}
|
||||
},
|
||||
],
|
||||
draw: [
|
||||
(u) => {
|
||||
if (customDashedTargetLines.length === 0) return;
|
||||
const xData = u.data[0] as number[] | undefined;
|
||||
if (!xData || xData.length < 2) return;
|
||||
|
||||
const xMin = u.scales.x?.min;
|
||||
const xMax = u.scales.x?.max;
|
||||
if (xMin === undefined || xMax === undefined) return;
|
||||
|
||||
const ctx = u.ctx;
|
||||
const left = u.bbox.left;
|
||||
const top = u.bbox.top;
|
||||
const snap = (v: number) => Math.round(v) + 0.5;
|
||||
const findFirstGe = (arr: number[], value: number): number => {
|
||||
let lo = 0;
|
||||
let hi = arr.length - 1;
|
||||
let ans = arr.length;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arr[mid] >= value) {
|
||||
ans = mid;
|
||||
hi = mid - 1;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
return ans;
|
||||
};
|
||||
const findLastLe = (arr: number[], value: number): number => {
|
||||
let lo = 0;
|
||||
let hi = arr.length - 1;
|
||||
let ans = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arr[mid] <= value) {
|
||||
ans = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return ans;
|
||||
};
|
||||
const firstVisibleIdx = Math.max(0, findFirstGe(xData, xMin) - 1);
|
||||
const lastVisibleIdx = Math.min(
|
||||
xData.length - 1,
|
||||
findLastLe(xData, xMax) + 1,
|
||||
);
|
||||
if (lastVisibleIdx < firstVisibleIdx) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(left, top, u.bbox.width, u.bbox.height);
|
||||
ctx.clip();
|
||||
ctx.lineCap = "butt";
|
||||
|
||||
customDashedTargetLines.forEach(
|
||||
({ dataIndex, dash, color, width }) => {
|
||||
const yData = u.data[dataIndex] as
|
||||
| Array<number | null>
|
||||
| undefined;
|
||||
if (!yData || yData.length < 2) return;
|
||||
|
||||
const dashPeriod = dash.reduce((acc, curr) => acc + curr, 0);
|
||||
ctx.setLineDash(dash);
|
||||
const elapsedMs = performance.now() - dashFlowStartTime;
|
||||
const dashTravelPx = (elapsedMs * DASH_FLOW_PX_PER_SEC) / 1000;
|
||||
ctx.lineDashOffset =
|
||||
dashPeriod > 0 ? -(dashTravelPx % dashPeriod) : 0;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.beginPath();
|
||||
|
||||
let started = false;
|
||||
let prevX = 0;
|
||||
let prevY = 0;
|
||||
|
||||
for (let i = firstVisibleIdx; i <= lastVisibleIdx; i++) {
|
||||
const value = yData[i];
|
||||
if (value === null || value === undefined) continue;
|
||||
|
||||
const x = snap(u.valToPos(xData[i], "x", true));
|
||||
const y = snap(u.valToPos(value, "y", true));
|
||||
|
||||
if (!started) {
|
||||
ctx.moveTo(x, y);
|
||||
started = true;
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (x !== prevX) {
|
||||
ctx.lineTo(x, prevY);
|
||||
}
|
||||
if (y !== prevY) {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
}
|
||||
|
||||
if (started) {
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
],
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
@@ -507,12 +660,27 @@ export function createChart({
|
||||
|
||||
const cleanup = attachEventHandlers(containerRef.current, handlers);
|
||||
|
||||
let dashFlowAnimationFrame: number | null = null;
|
||||
if (customDashedTargetLines.length > 0) {
|
||||
const animateDashFlow = () => {
|
||||
if (!uplotRef.current) return;
|
||||
uplotRef.current.redraw();
|
||||
dashFlowAnimationFrame = requestAnimationFrame(animateDashFlow);
|
||||
};
|
||||
dashFlowAnimationFrame = requestAnimationFrame(animateDashFlow);
|
||||
}
|
||||
|
||||
animationRefs.lastRenderedData.current = {
|
||||
timestamps: [...timestamps],
|
||||
values: [...primaryValues],
|
||||
};
|
||||
|
||||
return cleanup;
|
||||
return () => {
|
||||
if (dashFlowAnimationFrame !== null) {
|
||||
cancelAnimationFrame(dashFlowAnimationFrame);
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to get all valid TimeSeries from DataSeries
|
||||
|
||||
@@ -3,7 +3,12 @@ import { useRef, useCallback } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { getPrimarySeriesData, stopAnimations } from "./animation";
|
||||
import { BigGraphProps, HistoricalModeHandlers, AnimationRefs } from "./types";
|
||||
import {
|
||||
BigGraphProps,
|
||||
HistoricalModeHandlers,
|
||||
AnimationRefs,
|
||||
SwitchOrigin,
|
||||
} from "./types";
|
||||
|
||||
export function useHistoricalMode({
|
||||
newData,
|
||||
@@ -120,12 +125,43 @@ export function useHistoricalMode({
|
||||
);
|
||||
|
||||
// Switch to historical mode
|
||||
const switchToHistoricalMode = useCallback(() => {
|
||||
isInHistoricalModeRef.current = true;
|
||||
captureHistoricalFreezeTimestamp();
|
||||
stopAnimations(animationRefs);
|
||||
lastProcessedCountRef.current = 0; // Reset processed count
|
||||
}, [captureHistoricalFreezeTimestamp, animationRefs, lastProcessedCountRef]);
|
||||
const switchToHistoricalMode = useCallback(
|
||||
(origin?: SwitchOrigin) => {
|
||||
isInHistoricalModeRef.current = true;
|
||||
|
||||
// Preserve current scale
|
||||
if (origin === "gesture") {
|
||||
try {
|
||||
if (uplotRef.current) {
|
||||
const xScale = uplotRef.current.scales.x;
|
||||
const yScale = uplotRef.current.scales.y;
|
||||
manualScaleRef.current = {
|
||||
x: {
|
||||
min: xScale?.min ?? 0,
|
||||
max: xScale?.max ?? 0,
|
||||
},
|
||||
y: {
|
||||
min: yScale?.min ?? 0,
|
||||
max: yScale?.max ?? 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"Error preserving chart scales when switching to historical mode:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
manualScaleRef.current = null;
|
||||
}
|
||||
|
||||
captureHistoricalFreezeTimestamp();
|
||||
stopAnimations(animationRefs);
|
||||
lastProcessedCountRef.current = 0; // Reset processed count
|
||||
},
|
||||
[captureHistoricalFreezeTimestamp, animationRefs, lastProcessedCountRef],
|
||||
);
|
||||
|
||||
// Switch back to live mode
|
||||
const switchToLiveMode = useCallback(() => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Unit } from "@/control/units";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
import { RefObject } from "react";
|
||||
|
||||
export type SwitchOrigin = "button" | "gesture";
|
||||
|
||||
// Prop-based sync types
|
||||
export type PropGraphSync = {
|
||||
timeWindow: number | "all";
|
||||
@@ -11,6 +13,7 @@ export type PropGraphSync = {
|
||||
xRange?: { min: number; max: number };
|
||||
historicalFreezeTimestamp?: number | null;
|
||||
showFromTimestamp?: number | null;
|
||||
historicalSwitchOrigin: SwitchOrigin;
|
||||
onTimeWindowChange?: (graphId: string, timeWindow: number | "all") => void;
|
||||
onViewModeChange?: (
|
||||
graphId: string,
|
||||
@@ -27,6 +30,7 @@ export type PropGraphSync = {
|
||||
export type GraphLine = {
|
||||
type: "threshold" | "target";
|
||||
value: number;
|
||||
targetSeries?: TimeSeries;
|
||||
color: string;
|
||||
label?: string;
|
||||
width?: number;
|
||||
@@ -83,7 +87,7 @@ export type ControlProps = {
|
||||
isLiveMode: boolean;
|
||||
onTimeWindowChange: (timeWindow: number | "all") => void;
|
||||
onSwitchToLive: () => void;
|
||||
onSwitchToHistorical: () => void;
|
||||
onSwitchToHistorical: (origin: SwitchOrigin) => void;
|
||||
onExport?: () => void;
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
showFromTimestamp?: number | null;
|
||||
@@ -100,7 +104,7 @@ export interface HistoricalModeHandlers {
|
||||
captureHistoricalFreezeTimestamp: () => number;
|
||||
getHistoricalEndTimestamp: () => number;
|
||||
handleHistoricalTimeWindow: (timeWindow: number | "all") => void;
|
||||
switchToHistoricalMode: () => void;
|
||||
switchToHistoricalMode: (origin?: SwitchOrigin) => void;
|
||||
switchToLiveMode: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ export function useBigGraphEffects({
|
||||
const newIsLiveMode = syncGraph.isLiveMode;
|
||||
|
||||
if (lastState.isLiveMode && !newIsLiveMode) {
|
||||
historicalMode.switchToHistoricalMode();
|
||||
historicalMode.switchToHistoricalMode(syncGraph.historicalSwitchOrigin);
|
||||
} else if (!lastState.isLiveMode && newIsLiveMode) {
|
||||
historicalMode.switchToLiveMode();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { PropGraphSync } from "./types";
|
||||
import { PropGraphSync, SwitchOrigin } from "./types";
|
||||
import { GraphExportData, exportGraphsToExcel } from "./excelExport";
|
||||
import { useGraphSettingsStore } from "@/stores/graphSettingsStore";
|
||||
|
||||
@@ -19,6 +19,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
null,
|
||||
);
|
||||
|
||||
const historicalSwitchOriginRef = useRef<SwitchOrigin>("gesture");
|
||||
const historicalFreezeTimestampRef = useRef<number | null>(null);
|
||||
const graphDataRef = useRef<Map<string, () => GraphExportData | null>>(
|
||||
new Map(),
|
||||
@@ -65,6 +66,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
showFromTimestamp?: number | null;
|
||||
clearHistoricalFreeze?: boolean;
|
||||
setHistoricalFreeze?: boolean;
|
||||
historicalSwitchOrigin?: SwitchOrigin;
|
||||
},
|
||||
requestId?: number,
|
||||
) => {
|
||||
@@ -104,6 +106,10 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
historicalFreezeTimestampRef.current = Date.now();
|
||||
}
|
||||
|
||||
if (updates.historicalSwitchOrigin !== undefined) {
|
||||
historicalSwitchOriginRef.current = updates.historicalSwitchOrigin;
|
||||
}
|
||||
|
||||
clearChangeSource();
|
||||
});
|
||||
},
|
||||
@@ -164,6 +170,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
viewMode: "manual",
|
||||
isLiveMode: false,
|
||||
setHistoricalFreeze: historicalFreezeTimestampRef.current === null,
|
||||
historicalSwitchOrigin: "gesture",
|
||||
});
|
||||
},
|
||||
[updateSyncState],
|
||||
@@ -210,6 +217,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
isLiveMode: false,
|
||||
viewMode: "manual",
|
||||
setHistoricalFreeze: true,
|
||||
historicalSwitchOrigin: "button",
|
||||
});
|
||||
}, [updateSyncState]);
|
||||
|
||||
@@ -242,6 +250,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
xRange,
|
||||
historicalFreezeTimestamp: historicalFreezeTimestampRef.current,
|
||||
showFromTimestamp,
|
||||
historicalSwitchOrigin: historicalSwitchOriginRef.current,
|
||||
onTimeWindowChange: handleTimeWindowChange,
|
||||
onViewModeChange: handleViewModeChange,
|
||||
onZoomChange: handleZoomChangeThrottled,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const TROUBLESHOOT_REBOOT_HMI = "troubleshoot:reboot-hmi";
|
||||
export const TROUBLESHOOT_RESTART_BACKEND = "troubleshoot:restart-backend";
|
||||
export const TROUBLESHOOT_START_LOG_STREAM = "troubleshoot:start-log-stream";
|
||||
export const TROUBLESHOOT_STOP_LOG_STREAM = "troubleshoot:stop-log-stream";
|
||||
export const TROUBLESHOOT_LOG_DATA = "troubleshoot:log-data";
|
||||
export const TROUBLESHOOT_EXPORT_LOGS = "troubleshoot:export-logs";
|
||||
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
TROUBLESHOOT_REBOOT_HMI,
|
||||
TROUBLESHOOT_RESTART_BACKEND,
|
||||
TROUBLESHOOT_START_LOG_STREAM,
|
||||
TROUBLESHOOT_STOP_LOG_STREAM,
|
||||
TROUBLESHOOT_LOG_DATA,
|
||||
TROUBLESHOOT_EXPORT_LOGS,
|
||||
} from "./troubleshoot-channels";
|
||||
|
||||
export function exposeTroubleshootContext() {
|
||||
@@ -11,11 +9,6 @@ export function exposeTroubleshootContext() {
|
||||
contextBridge.exposeInMainWorld("troubleshoot", {
|
||||
rebootHmi: () => ipcRenderer.invoke(TROUBLESHOOT_REBOOT_HMI),
|
||||
restartBackend: () => ipcRenderer.invoke(TROUBLESHOOT_RESTART_BACKEND),
|
||||
startLogStream: () => ipcRenderer.invoke(TROUBLESHOOT_START_LOG_STREAM),
|
||||
stopLogStream: () => ipcRenderer.invoke(TROUBLESHOOT_STOP_LOG_STREAM),
|
||||
onLogData: (callback: (log: string) => void) =>
|
||||
ipcRenderer.on(TROUBLESHOOT_LOG_DATA, (_event, log: string) => {
|
||||
callback(log);
|
||||
}),
|
||||
exportLogs: () => ipcRenderer.invoke(TROUBLESHOOT_EXPORT_LOGS),
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { ipcMain, dialog } from "electron";
|
||||
import { spawn, exec, ChildProcess } from "child_process";
|
||||
import {
|
||||
TROUBLESHOOT_REBOOT_HMI,
|
||||
TROUBLESHOOT_RESTART_BACKEND,
|
||||
TROUBLESHOOT_START_LOG_STREAM,
|
||||
TROUBLESHOOT_STOP_LOG_STREAM,
|
||||
TROUBLESHOOT_LOG_DATA,
|
||||
TROUBLESHOOT_EXPORT_LOGS,
|
||||
} from "./troubleshoot-channels";
|
||||
|
||||
let logStreamProcess: ChildProcess | null = null;
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export function addTroubleshootEventListeners() {
|
||||
ipcMain.handle(TROUBLESHOOT_REBOOT_HMI, async () => {
|
||||
@@ -60,62 +59,45 @@ export function addTroubleshootEventListeners() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(TROUBLESHOOT_START_LOG_STREAM, async (event) => {
|
||||
ipcMain.handle(TROUBLESHOOT_EXPORT_LOGS, async () => {
|
||||
try {
|
||||
// Stop any existing log stream
|
||||
if (logStreamProcess) {
|
||||
logStreamProcess.kill();
|
||||
logStreamProcess = null;
|
||||
const now = new Date();
|
||||
const datePart = now.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
const timePart = now.toTimeString().split(" ")[0].replace(/:/g, "-"); // HH-mm-ss
|
||||
const fileName = `journal_${datePart}_${timePart}.log`;
|
||||
|
||||
// 1. Open a Save Dialog so the user can choose the location
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
title: "Export System Logs",
|
||||
defaultPath: fileName,
|
||||
filters: [{ name: "Log Files", extensions: ["log"] }],
|
||||
});
|
||||
|
||||
if (canceled || !filePath) {
|
||||
return { success: false, error: "Export cancelled by user" };
|
||||
}
|
||||
|
||||
logStreamProcess = spawn(
|
||||
"journalctl",
|
||||
["-u", "qitech-control-server", "-n", "10000", "-f"],
|
||||
{
|
||||
shell: true,
|
||||
// 2. Wrap the exec in a typed Promise to match the backend restart pattern
|
||||
// This resolves the TS2794 error by explicitly defining the return type
|
||||
return await new Promise<{ success: boolean; error?: string }>(
|
||||
(resolve) => {
|
||||
// Note: journalctl -xb usually requires sudo or journal group membership
|
||||
exec(`journalctl -xb > "${filePath}"`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error("Exec error:", error);
|
||||
resolve({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ success: true });
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
logStreamProcess.stdout?.on("data", (data) => {
|
||||
const logLine = data.toString();
|
||||
event.sender.send(TROUBLESHOOT_LOG_DATA, logLine);
|
||||
});
|
||||
|
||||
logStreamProcess.stderr?.on("data", (data) => {
|
||||
const logLine = data.toString();
|
||||
event.sender.send(TROUBLESHOOT_LOG_DATA, logLine);
|
||||
});
|
||||
|
||||
logStreamProcess.on("close", (code) => {
|
||||
console.log(`Log stream process exited with code ${code}`);
|
||||
logStreamProcess = null;
|
||||
});
|
||||
|
||||
logStreamProcess.on("error", (error) => {
|
||||
console.error("Log stream process error:", error);
|
||||
logStreamProcess = null;
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to start log stream:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(TROUBLESHOOT_STOP_LOG_STREAM, async () => {
|
||||
try {
|
||||
if (logStreamProcess) {
|
||||
logStreamProcess.kill();
|
||||
logStreamProcess = null;
|
||||
console.log("Log stream stopped");
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to stop log stream:", error);
|
||||
console.error("Failed to export logs: ", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -39,12 +39,12 @@ export async function restartBackend(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
export async function startLogStream(): Promise<{
|
||||
export async function exportLogs(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return await window.troubleshoot.startLogStream();
|
||||
return await window.troubleshoot.exportLogs();
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -53,35 +53,6 @@ export async function startLogStream(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopLogStream(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return await window.troubleshoot.stopLogStream();
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup log data streaming with callback
|
||||
* @param onLogData Callback function to handle incoming log data
|
||||
*/
|
||||
export function setupLogDataListener(onLogData: (log: string) => void): void {
|
||||
window.troubleshoot.onLogData(onLogData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup log data listener
|
||||
*/
|
||||
export function cleanupLogDataListener(): void {
|
||||
window.troubleshoot.onLogData(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined function to restart backend and show appropriate toast messages
|
||||
*/
|
||||
@@ -99,10 +70,10 @@ export async function restartBackendWithToast(): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Combined function to reboot HMI and show appropriate toast messages
|
||||
* ?????? How would you see this?
|
||||
*/
|
||||
export async function rebootHmiWithToast(): Promise<boolean> {
|
||||
const result = await rebootHmi();
|
||||
|
||||
if (result.success) {
|
||||
return true;
|
||||
} else {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLogsStore } from "@/stores/logsStore";
|
||||
import {
|
||||
startLogStream,
|
||||
stopLogStream,
|
||||
setupLogDataListener,
|
||||
isTroubleshootAvailable,
|
||||
} from "@/helpers/troubleshoot_helpers";
|
||||
|
||||
/**
|
||||
* Hook to manage global log streaming
|
||||
* This should be used once at the app level to start log streaming
|
||||
*/
|
||||
export function useGlobalLogStreaming() {
|
||||
const { isStreaming, addLogEntry, setStreaming } = useLogsStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Only proceed if troubleshoot context is available
|
||||
if (!isTroubleshootAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isStreaming) {
|
||||
startLogStreamHelper();
|
||||
}
|
||||
|
||||
// Setup log data listener
|
||||
const handleLogData = (logData: string) => {
|
||||
// Parse systemd journal log format
|
||||
const lines = logData.split("\n").filter((line) => line.trim());
|
||||
|
||||
lines.forEach((line) => {
|
||||
if (line.trim()) {
|
||||
// Simple log level detection
|
||||
let level: "info" | "warn" | "error" | "debug" = "info";
|
||||
if (line.includes("ERROR") || line.includes("error")) level = "error";
|
||||
else if (line.includes("WARN") || line.includes("warn"))
|
||||
level = "warn";
|
||||
else if (line.includes("DEBUG") || line.includes("debug"))
|
||||
level = "debug";
|
||||
|
||||
addLogEntry({
|
||||
source: "qitech-control-server",
|
||||
level,
|
||||
message: line.trim(),
|
||||
raw: line,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Setup log data listener using helper
|
||||
setupLogDataListener(handleLogData);
|
||||
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
if (isStreaming && isTroubleshootAvailable()) {
|
||||
stopLogStream();
|
||||
setStreaming(false);
|
||||
}
|
||||
};
|
||||
}, [isStreaming, addLogEntry, setStreaming]);
|
||||
|
||||
const startLogStreamHelper = async () => {
|
||||
try {
|
||||
const result = await startLogStream();
|
||||
if (result.success) {
|
||||
setStreaming(true);
|
||||
} else {
|
||||
console.warn("Failed to start log stream:", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to start log stream:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { isStreaming };
|
||||
}
|
||||
@@ -304,3 +304,46 @@ export function resetSeries(series: Series): void {
|
||||
series.lastTimestamp = 0;
|
||||
series.validCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligns target series values with a given set of timestamps.
|
||||
* For each timestamp in dataTimestamps, finds the most recent target value
|
||||
* that was set before or at that timestamp (step function interpolation).
|
||||
*/
|
||||
export function alignTargetSeriesToTimestamps(
|
||||
targetSeries: TimeSeries,
|
||||
dataTimestamps: number[],
|
||||
fallbackValue: number,
|
||||
): number[] {
|
||||
if (dataTimestamps.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [targetTimestamps, targetValues] = extractDataFromSeries(
|
||||
targetSeries.long,
|
||||
);
|
||||
|
||||
if (targetTimestamps.length === 0) {
|
||||
return new Array(dataTimestamps.length).fill(fallbackValue);
|
||||
}
|
||||
|
||||
const result: number[] = [];
|
||||
let targetIndex = 0;
|
||||
|
||||
for (const dataTs of dataTimestamps) {
|
||||
while (
|
||||
targetIndex < targetTimestamps.length - 1 &&
|
||||
targetTimestamps[targetIndex + 1] <= dataTs
|
||||
) {
|
||||
targetIndex++;
|
||||
}
|
||||
|
||||
if (targetTimestamps[targetIndex] > dataTs) {
|
||||
result.push(fallbackValue);
|
||||
} else {
|
||||
result.push(targetValues[targetIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function Aquapath1Page() {
|
||||
link: "graph",
|
||||
title: "Graph",
|
||||
icon: "lu:ChartSpline",
|
||||
activeLink: "graphs",
|
||||
activeLink: "graph",
|
||||
},
|
||||
{
|
||||
link: "settings",
|
||||
|
||||
@@ -16,6 +16,9 @@ type Props = {
|
||||
min: number;
|
||||
max: number;
|
||||
onChangeTargetTemp?: (temperature: number) => void;
|
||||
heatingFault?: boolean;
|
||||
onRetryHeating?: () => void;
|
||||
targetTemperatureEnabled?: boolean;
|
||||
};
|
||||
export function HeatingZone({
|
||||
title,
|
||||
@@ -25,6 +28,9 @@ export function HeatingZone({
|
||||
heatingTimeSeries,
|
||||
heatingPower,
|
||||
onChangeTargetTemp,
|
||||
heatingFault,
|
||||
onRetryHeating,
|
||||
targetTemperatureEnabled = true,
|
||||
}: Props) {
|
||||
const targetTemperature = heatingState?.target_temperature ?? 150;
|
||||
|
||||
@@ -41,19 +47,20 @@ export function HeatingZone({
|
||||
timeseries={heatingTimeSeries}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Label label="Target Temperature">
|
||||
<EditValue
|
||||
value={targetTemperature}
|
||||
defaultValue={150}
|
||||
min={min}
|
||||
max={max}
|
||||
unit="C"
|
||||
title="Target Temperature"
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
onChange={onChangeTargetTemp}
|
||||
/>
|
||||
</Label>
|
||||
{targetTemperatureEnabled && (
|
||||
<Label label="Target Temperature">
|
||||
<EditValue
|
||||
value={targetTemperature}
|
||||
defaultValue={150}
|
||||
min={min}
|
||||
max={max}
|
||||
unit="C"
|
||||
title="Target Temperature"
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
onChange={onChangeTargetTemp}
|
||||
/>
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TimeSeriesValueNumeric
|
||||
@@ -65,11 +72,28 @@ export function HeatingZone({
|
||||
timeseries={heatingPower}
|
||||
/>
|
||||
|
||||
{heatingState?.wiring_error && (
|
||||
{heatingState?.wiring_error && targetTemperatureEnabled && (
|
||||
<StatusBadge variant="error">
|
||||
Cant Read Temperature! Check Temperature Sensor Wiring!
|
||||
</StatusBadge>
|
||||
)}
|
||||
|
||||
{heatingFault && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<StatusBadge variant="error">
|
||||
Heating Fault: Temperature not increasing. Extruder set to standby.
|
||||
</StatusBadge>
|
||||
{onRetryHeating && (
|
||||
<button
|
||||
onClick={onRetryHeating}
|
||||
className="inline-block w-fit max-w-max rounded bg-blue-600 px-4 py-2 text-base whitespace-nowrap text-white hover:bg-blue-700"
|
||||
style={{ minWidth: "auto", width: "fit-content" }}
|
||||
>
|
||||
Retry Heating
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ControlCard>
|
||||
);
|
||||
}
|
||||
@@ -68,10 +68,12 @@ export function Extruder2ControlPage() {
|
||||
backTemperature.current?.value ?? 0,
|
||||
state?.heating_states.back.target_temperature ?? 1,
|
||||
);
|
||||
const nozzleReady = isZoneReadyForExtrusion(
|
||||
nozzleTemperature.current?.value ?? 0,
|
||||
state?.heating_states.nozzle.target_temperature ?? 1,
|
||||
);
|
||||
const nozzleReady =
|
||||
!state?.extruder_settings_state.nozzle_temperature_target_enabled ||
|
||||
isZoneReadyForExtrusion(
|
||||
nozzleTemperature.current?.value ?? 0,
|
||||
state?.heating_states.nozzle.target_temperature ?? 1,
|
||||
);
|
||||
|
||||
return frontReady && middleReady && backReady && nozzleReady;
|
||||
};
|
||||
@@ -87,6 +89,7 @@ export function Extruder2ControlPage() {
|
||||
onChangeTargetTemp={setFrontHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
targetTemperatureEnabled={true}
|
||||
/>
|
||||
<HeatingZone
|
||||
title={"Heating Middle"}
|
||||
@@ -96,6 +99,7 @@ export function Extruder2ControlPage() {
|
||||
onChangeTargetTemp={setMiddleHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
targetTemperatureEnabled={true}
|
||||
/>
|
||||
<HeatingZone
|
||||
title={"Heating Back"}
|
||||
@@ -105,6 +109,7 @@ export function Extruder2ControlPage() {
|
||||
onChangeTargetTemp={setBackHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
targetTemperatureEnabled={true}
|
||||
/>
|
||||
<HeatingZone
|
||||
title={"Heating Nozzle"}
|
||||
@@ -114,6 +119,10 @@ export function Extruder2ControlPage() {
|
||||
onChangeTargetTemp={setNozzleHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
targetTemperatureEnabled={
|
||||
state?.extruder_settings_state.nozzle_temperature_target_enabled ??
|
||||
true
|
||||
}
|
||||
/>
|
||||
<ControlCard className="bg-red" title="Screw Drive">
|
||||
{state?.inverter_status_state.overload_warning == true ? (
|
||||
|
||||
@@ -24,6 +24,13 @@ export function Extruder2GraphsPage() {
|
||||
motorCurrent,
|
||||
motorPower,
|
||||
combinedPower,
|
||||
// Target value history for graph target lines
|
||||
targetPressure,
|
||||
targetScrewRpm,
|
||||
targetNozzleTemperature,
|
||||
targetFrontTemperature,
|
||||
targetMiddleTemperature,
|
||||
targetBackTemperature,
|
||||
} = useExtruder2();
|
||||
|
||||
const syncHook = useGraphSync("extruder-graphs");
|
||||
@@ -49,6 +56,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.nozzle.target_temperature,
|
||||
targetSeries: targetNozzleTemperature,
|
||||
color: "#ef4444",
|
||||
show: true,
|
||||
},
|
||||
@@ -69,6 +77,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.front.target_temperature,
|
||||
targetSeries: targetFrontTemperature,
|
||||
color: "#f59e0b",
|
||||
show: true,
|
||||
},
|
||||
@@ -89,6 +98,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.middle.target_temperature,
|
||||
targetSeries: targetMiddleTemperature,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
@@ -109,6 +119,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.back.target_temperature,
|
||||
targetSeries: targetBackTemperature,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
@@ -253,6 +264,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
targetSeries: targetPressure,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
@@ -306,6 +318,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
targetSeries: targetScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ export function Extruder2SettingsPage() {
|
||||
setPressurePidKi,
|
||||
setPressurePidKd,
|
||||
setTemperaturePidValue,
|
||||
setTemperatureTargetEnabled,
|
||||
} = useExtruder2();
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
@@ -68,6 +69,16 @@ export function Extruder2SettingsPage() {
|
||||
onChange={setExtruderPressureLimitEnabled}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Nozzle Temperature Target Enabled">
|
||||
<SelectionGroupBoolean
|
||||
value={
|
||||
state?.extruder_settings_state.nozzle_temperature_target_enabled
|
||||
}
|
||||
optionTrue={{ children: "Enabled" }}
|
||||
optionFalse={{ children: "Disabled" }}
|
||||
onChange={setTemperatureTargetEnabled}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Show Advanced PID Settings">
|
||||
<SelectionGroupBoolean
|
||||
value={showAdvanced}
|
||||
|
||||
@@ -109,6 +109,7 @@ export const heatingStatesSchema = z.object({
|
||||
export const extruderSettingsStateSchema = z.object({
|
||||
pressure_limit: z.number(),
|
||||
pressure_limit_enabled: z.boolean(),
|
||||
nozzle_temperature_target_enabled: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -221,6 +222,14 @@ export type Extruder2NamespaceStore = {
|
||||
// Combined power consumption and energy
|
||||
combinedPower: TimeSeries;
|
||||
totalEnergyKWh: TimeSeries;
|
||||
|
||||
// Target value history (for graph target lines)
|
||||
targetPressure: TimeSeries;
|
||||
targetScrewRpm: TimeSeries;
|
||||
targetNozzleTemperature: TimeSeries;
|
||||
targetFrontTemperature: TimeSeries;
|
||||
targetMiddleTemperature: TimeSeries;
|
||||
targetBackTemperature: TimeSeries;
|
||||
};
|
||||
|
||||
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries();
|
||||
@@ -253,6 +262,28 @@ const { initialTimeSeries: motorScrewRpm, insert: addMotorScrewRpm } =
|
||||
const { initialTimeSeries: motorPower, insert: addMotorPower } =
|
||||
createTimeSeries();
|
||||
|
||||
// Target value time series (for historical target lines on graphs)
|
||||
const { initialTimeSeries: targetPressure, insert: addTargetPressure } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: targetScrewRpm, insert: addTargetScrewRpm } =
|
||||
createTimeSeries();
|
||||
const {
|
||||
initialTimeSeries: targetNozzleTemperature,
|
||||
insert: addTargetNozzleTemperature,
|
||||
} = createTimeSeries();
|
||||
const {
|
||||
initialTimeSeries: targetFrontTemperature,
|
||||
insert: addTargetFrontTemperature,
|
||||
} = createTimeSeries();
|
||||
const {
|
||||
initialTimeSeries: targetMiddleTemperature,
|
||||
insert: addTargetMiddleTemperature,
|
||||
} = createTimeSeries();
|
||||
const {
|
||||
initialTimeSeries: targetBackTemperature,
|
||||
insert: addTargetBackTemperature,
|
||||
} = createTimeSeries();
|
||||
|
||||
export function extruder2MessageHandler(
|
||||
store: StoreApi<Extruder2NamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<Extruder2NamespaceStore>,
|
||||
@@ -271,6 +302,17 @@ export function extruder2MessageHandler(
|
||||
if (eventName === "StateEvent") {
|
||||
console.log(event);
|
||||
const stateEvent = stateEventSchema.parse(event);
|
||||
const timestamp = event.ts;
|
||||
const nextTargetPressure = stateEvent.data.pressure_state.target_bar;
|
||||
const nextTargetScrewRpm = stateEvent.data.screw_state.target_rpm;
|
||||
const nextTargetNozzleTemperature =
|
||||
stateEvent.data.heating_states.nozzle.target_temperature;
|
||||
const nextTargetFrontTemperature =
|
||||
stateEvent.data.heating_states.front.target_temperature;
|
||||
const nextTargetMiddleTemperature =
|
||||
stateEvent.data.heating_states.middle.target_temperature;
|
||||
const nextTargetBackTemperature =
|
||||
stateEvent.data.heating_states.back.target_temperature;
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
state: stateEvent,
|
||||
@@ -278,6 +320,53 @@ export function extruder2MessageHandler(
|
||||
defaultState: stateEvent.data.is_default_state
|
||||
? stateEvent
|
||||
: state.defaultState,
|
||||
// Update target value history
|
||||
targetPressure:
|
||||
state.targetPressure.current?.value === nextTargetPressure
|
||||
? state.targetPressure
|
||||
: addTargetPressure(state.targetPressure, {
|
||||
value: nextTargetPressure,
|
||||
timestamp,
|
||||
}),
|
||||
targetScrewRpm:
|
||||
state.targetScrewRpm.current?.value === nextTargetScrewRpm
|
||||
? state.targetScrewRpm
|
||||
: addTargetScrewRpm(state.targetScrewRpm, {
|
||||
value: nextTargetScrewRpm,
|
||||
timestamp,
|
||||
}),
|
||||
targetNozzleTemperature:
|
||||
state.targetNozzleTemperature.current?.value ===
|
||||
nextTargetNozzleTemperature
|
||||
? state.targetNozzleTemperature
|
||||
: addTargetNozzleTemperature(state.targetNozzleTemperature, {
|
||||
value: nextTargetNozzleTemperature,
|
||||
timestamp,
|
||||
}),
|
||||
targetFrontTemperature:
|
||||
state.targetFrontTemperature.current?.value ===
|
||||
nextTargetFrontTemperature
|
||||
? state.targetFrontTemperature
|
||||
: addTargetFrontTemperature(state.targetFrontTemperature, {
|
||||
value: nextTargetFrontTemperature,
|
||||
timestamp,
|
||||
}),
|
||||
targetMiddleTemperature:
|
||||
state.targetMiddleTemperature.current?.value ===
|
||||
nextTargetMiddleTemperature
|
||||
? state.targetMiddleTemperature
|
||||
: addTargetMiddleTemperature(state.targetMiddleTemperature, {
|
||||
value: nextTargetMiddleTemperature,
|
||||
timestamp,
|
||||
}),
|
||||
targetBackTemperature:
|
||||
state.targetBackTemperature.current?.value ===
|
||||
nextTargetBackTemperature
|
||||
? state.targetBackTemperature
|
||||
: addTargetBackTemperature(state.targetBackTemperature, {
|
||||
value: nextTargetBackTemperature,
|
||||
timestamp,
|
||||
}),
|
||||
}));
|
||||
} else if (eventName === "LiveValuesEvent") {
|
||||
const liveValuesEvent = liveValuesEventSchema.parse(event);
|
||||
@@ -378,6 +467,14 @@ export const createExtruder2NamespaceStore =
|
||||
middlePower,
|
||||
combinedPower,
|
||||
totalEnergyKWh,
|
||||
|
||||
// Target value history
|
||||
targetPressure,
|
||||
targetScrewRpm,
|
||||
targetNozzleTemperature,
|
||||
targetFrontTemperature,
|
||||
targetMiddleTemperature,
|
||||
targetBackTemperature,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,14 @@ export function useExtruder2() {
|
||||
backPower,
|
||||
combinedPower,
|
||||
totalEnergyKWh,
|
||||
|
||||
// Target value history for graph target lines
|
||||
targetPressure,
|
||||
targetScrewRpm,
|
||||
targetNozzleTemperature,
|
||||
targetFrontTemperature,
|
||||
targetMiddleTemperature,
|
||||
targetBackTemperature,
|
||||
} = useExtruder2Namespace(machineIdentification);
|
||||
|
||||
// Single optimistic state for all state management
|
||||
@@ -224,6 +232,20 @@ export function useExtruder2() {
|
||||
);
|
||||
};
|
||||
|
||||
const setTemperatureTargetEnabled = (enabled: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.extruder_settings_state.nozzle_temperature_target_enabled =
|
||||
enabled;
|
||||
},
|
||||
() =>
|
||||
requestNozzleTemperatureTargetEnabled({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetNozzleTemperatureTargetEnabled: enabled },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setPressurePidKp = (kp: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
@@ -373,6 +395,10 @@ export function useExtruder2() {
|
||||
z.object({ SetExtruderPressureLimitIsEnabled: z.boolean() }),
|
||||
);
|
||||
|
||||
const { request: requestNozzleTemperatureTargetEnabled } = useMachineMutation(
|
||||
z.object({ SetNozzleTemperatureTargetEnabled: z.boolean() }),
|
||||
);
|
||||
|
||||
const { request: requestPressurePidSettings } = useMachineMutation(
|
||||
z.object({
|
||||
SetPressurePidSettings: z.object({
|
||||
@@ -423,6 +449,14 @@ export function useExtruder2() {
|
||||
combinedPower,
|
||||
totalEnergyKWh,
|
||||
|
||||
// Target value history for graph target lines
|
||||
targetPressure,
|
||||
targetScrewRpm,
|
||||
targetNozzleTemperature,
|
||||
targetFrontTemperature,
|
||||
targetMiddleTemperature,
|
||||
targetBackTemperature,
|
||||
|
||||
// Loading states
|
||||
isLoading: stateOptimistic.isOptimistic,
|
||||
isDisabled: !stateOptimistic.isInitialized,
|
||||
@@ -443,6 +477,7 @@ export function useExtruder2() {
|
||||
setPressurePidKi,
|
||||
setPressurePidKd,
|
||||
setTemperaturePidValue,
|
||||
setTemperatureTargetEnabled,
|
||||
resetInverter,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { roundToDecimals } from "@/lib/decimal";
|
||||
import { useExtruder3 } from "./useExtruder";
|
||||
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
|
||||
import { StatusBadge } from "@/control/StatusBadge";
|
||||
import { GlobalHeatingFaultToastManager } from "./GlobalHeatingFaultToastManager";
|
||||
|
||||
export function Extruder3ControlPage() {
|
||||
const {
|
||||
@@ -45,8 +46,17 @@ export function Extruder3ControlPage() {
|
||||
|
||||
isLoading,
|
||||
isDisabled,
|
||||
retryHeating,
|
||||
acknowledgeHeatingFault,
|
||||
} = useExtruder3();
|
||||
|
||||
// Helper to check if a zone has a heating fault
|
||||
const getZoneFault = (zone: "front" | "middle" | "back" | "nozzle") => {
|
||||
const faultZone = state?.heating_fault_state?.fault_zone;
|
||||
const faultAcknowledged = state?.heating_fault_state?.fault_acknowledged;
|
||||
return faultZone === zone && !faultAcknowledged;
|
||||
};
|
||||
|
||||
function isZoneReadyForExtrusion(
|
||||
temperature: number,
|
||||
targetTemperature: number,
|
||||
@@ -68,16 +78,22 @@ export function Extruder3ControlPage() {
|
||||
backTemperature.current?.value ?? 0,
|
||||
state?.heating_states.back.target_temperature ?? 1,
|
||||
);
|
||||
const nozzleReady = isZoneReadyForExtrusion(
|
||||
nozzleTemperature.current?.value ?? 0,
|
||||
state?.heating_states.nozzle.target_temperature ?? 1,
|
||||
);
|
||||
const nozzleReady =
|
||||
!state?.extruder_settings_state.nozzle_temperature_target_enabled ||
|
||||
isZoneReadyForExtrusion(
|
||||
nozzleTemperature.current?.value ?? 0,
|
||||
state?.heating_states.nozzle.target_temperature ?? 1,
|
||||
);
|
||||
|
||||
return frontReady && middleReady && backReady && nozzleReady;
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<GlobalHeatingFaultToastManager
|
||||
state={state}
|
||||
onAcknowledgeHeatingFault={acknowledgeHeatingFault}
|
||||
/>
|
||||
<ControlGrid>
|
||||
<HeatingZone
|
||||
title={"Heating Front"}
|
||||
@@ -87,6 +103,9 @@ export function Extruder3ControlPage() {
|
||||
onChangeTargetTemp={setFrontHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
heatingFault={getZoneFault("front")}
|
||||
onRetryHeating={retryHeating}
|
||||
targetTemperatureEnabled
|
||||
/>
|
||||
<HeatingZone
|
||||
title={"Heating Middle"}
|
||||
@@ -96,6 +115,9 @@ export function Extruder3ControlPage() {
|
||||
onChangeTargetTemp={setMiddleHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
heatingFault={getZoneFault("middle")}
|
||||
onRetryHeating={retryHeating}
|
||||
targetTemperatureEnabled
|
||||
/>
|
||||
<HeatingZone
|
||||
title={"Heating Back"}
|
||||
@@ -105,6 +127,9 @@ export function Extruder3ControlPage() {
|
||||
onChangeTargetTemp={setBackHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
heatingFault={getZoneFault("back")}
|
||||
onRetryHeating={retryHeating}
|
||||
targetTemperatureEnabled
|
||||
/>
|
||||
<HeatingZone
|
||||
title={"Heating Nozzle"}
|
||||
@@ -114,6 +139,12 @@ export function Extruder3ControlPage() {
|
||||
onChangeTargetTemp={setNozzleHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
heatingFault={getZoneFault("nozzle")}
|
||||
onRetryHeating={retryHeating}
|
||||
targetTemperatureEnabled={
|
||||
state?.extruder_settings_state.nozzle_temperature_target_enabled ??
|
||||
true
|
||||
}
|
||||
/>
|
||||
<ControlCard className="bg-red" title="Screw Drive">
|
||||
{state?.inverter_status_state.overload_warning == true ? (
|
||||
|
||||
@@ -24,6 +24,13 @@ export function Extruder3GraphsPage() {
|
||||
motorCurrent,
|
||||
motorPower,
|
||||
combinedPower,
|
||||
// Target value history for graph target lines
|
||||
targetPressure,
|
||||
targetScrewRpm,
|
||||
targetNozzleTemperature,
|
||||
targetFrontTemperature,
|
||||
targetMiddleTemperature,
|
||||
targetBackTemperature,
|
||||
} = useExtruder3();
|
||||
|
||||
const syncHook = useGraphSync("extruder-graphs");
|
||||
@@ -49,6 +56,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.nozzle.target_temperature,
|
||||
targetSeries: targetNozzleTemperature,
|
||||
color: "#ef4444",
|
||||
show: true,
|
||||
},
|
||||
@@ -69,6 +77,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.front.target_temperature,
|
||||
targetSeries: targetFrontTemperature,
|
||||
color: "#f59e0b",
|
||||
show: true,
|
||||
},
|
||||
@@ -89,6 +98,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.middle.target_temperature,
|
||||
targetSeries: targetMiddleTemperature,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
@@ -109,6 +119,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.back.target_temperature,
|
||||
targetSeries: targetBackTemperature,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
@@ -253,6 +264,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
targetSeries: targetPressure,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
@@ -306,6 +318,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
targetSeries: targetScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
|
||||
@@ -16,6 +16,8 @@ export function Extruder3SettingsPage() {
|
||||
resetInverter,
|
||||
setExtruderPressureLimit,
|
||||
setExtruderPressureLimitEnabled,
|
||||
setHeatingSafeguardEnabled,
|
||||
setTemperatureTargetEnabled,
|
||||
setPressurePidKp,
|
||||
setPressurePidKi,
|
||||
setPressurePidKd,
|
||||
@@ -68,6 +70,32 @@ export function Extruder3SettingsPage() {
|
||||
onChange={setExtruderPressureLimitEnabled}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Heating Safeguard">
|
||||
<SelectionGroupBoolean
|
||||
value={
|
||||
state?.extruder_settings_state.heating_safeguard_enabled ?? true
|
||||
}
|
||||
optionTrue={{ children: "Enabled" }}
|
||||
optionFalse={{ children: "Disabled" }}
|
||||
onChange={setHeatingSafeguardEnabled}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
When enabled, monitors heating progress and automatically sets to
|
||||
standby if temperature doesn't increase by at least 5°C within
|
||||
60 seconds.
|
||||
</p>
|
||||
</Label>
|
||||
<Label label="Nozzle Temperature Target Enabled">
|
||||
<SelectionGroupBoolean
|
||||
value={
|
||||
state?.extruder_settings_state
|
||||
.nozzle_temperature_target_enabled ?? true
|
||||
}
|
||||
optionTrue={{ children: "Enabled" }}
|
||||
optionFalse={{ children: "Disabled" }}
|
||||
onChange={setTemperatureTargetEnabled}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Show Advanced PID Settings">
|
||||
<SelectionGroupBoolean
|
||||
value={showAdvanced}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Icon } from "@/components/Icon";
|
||||
|
||||
type HeatingFaultState = {
|
||||
heating_fault_state?: {
|
||||
fault_zone?: string | null;
|
||||
fault_acknowledged?: boolean;
|
||||
};
|
||||
is_default_state?: boolean;
|
||||
};
|
||||
|
||||
type HeatingFaultToastProps = {
|
||||
zoneName: string;
|
||||
onAcknowledge: () => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
function HeatingFaultToast({
|
||||
zoneName,
|
||||
onAcknowledge,
|
||||
children,
|
||||
}: HeatingFaultToastProps) {
|
||||
return (
|
||||
<div className="flex w-100 flex-col gap-3 rounded-xl border border-red-400 bg-red-600 p-4 text-white shadow-xl backdrop-blur-sm transition-all duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="lu:TriangleAlert" className="h-5 w-5 text-yellow-200" />
|
||||
<strong className="text-lg font-semibold tracking-wide">
|
||||
Heating Fault
|
||||
</strong>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-2xl font-bold text-white/80 hover:bg-red-500 hover:text-white focus:ring-2 focus:ring-white/30 focus:outline-none"
|
||||
onClick={onAcknowledge}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{children ?? (
|
||||
<p className="text-base leading-snug text-red-50">
|
||||
The <strong>{zoneName}</strong> heating zone did not increase in
|
||||
temperature by at least 5°C within 60 seconds. The extruder has been
|
||||
automatically set to standby mode.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toZoneName(faultZone: string | null): string {
|
||||
switch (faultZone) {
|
||||
case "front":
|
||||
return "Front";
|
||||
case "middle":
|
||||
return "Middle";
|
||||
case "back":
|
||||
return "Back";
|
||||
case "nozzle":
|
||||
return "Nozzle";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function useHeatingFaultToastEffect({
|
||||
state,
|
||||
onAcknowledgeHeatingFault,
|
||||
}: {
|
||||
state: HeatingFaultState | null | undefined;
|
||||
onAcknowledgeHeatingFault: () => void;
|
||||
}) {
|
||||
// Deduplicate by active fault zone
|
||||
const activeFaultZone = useRef<string | null>(null);
|
||||
const currentToastId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
const faultZone = state.heating_fault_state?.fault_zone ?? null;
|
||||
const faultAcknowledged =
|
||||
state.heating_fault_state?.fault_acknowledged ?? false;
|
||||
const isDefault = !!state.is_default_state;
|
||||
|
||||
if (isDefault) return;
|
||||
|
||||
const faultZoneStr = faultZone ?? "unknown";
|
||||
const zoneName = toZoneName(faultZone);
|
||||
|
||||
if (
|
||||
faultZone &&
|
||||
!faultAcknowledged &&
|
||||
activeFaultZone.current !== faultZone
|
||||
) {
|
||||
activeFaultZone.current = faultZone;
|
||||
const toastId = `heating-fault-${faultZoneStr}`;
|
||||
currentToastId.current = toastId;
|
||||
|
||||
toast(
|
||||
<HeatingFaultToast
|
||||
zoneName={zoneName}
|
||||
onAcknowledge={() => {
|
||||
onAcknowledgeHeatingFault();
|
||||
toast.dismiss(toastId);
|
||||
activeFaultZone.current = null;
|
||||
currentToastId.current = null;
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
id: toastId,
|
||||
duration: Infinity,
|
||||
position: "top-center",
|
||||
style: {
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
boxShadow: "none",
|
||||
border: "none",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (faultAcknowledged || !faultZone) {
|
||||
if (currentToastId.current) {
|
||||
toast.dismiss(currentToastId.current);
|
||||
currentToastId.current = null;
|
||||
}
|
||||
activeFaultZone.current = null;
|
||||
}
|
||||
}, [state, onAcknowledgeHeatingFault]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches StateEvent for heating faults and shows toasts.
|
||||
* Component is non-visual.
|
||||
*/
|
||||
export function GlobalHeatingFaultToastManager({
|
||||
state,
|
||||
onAcknowledgeHeatingFault,
|
||||
}: {
|
||||
state:
|
||||
| {
|
||||
heating_fault_state?: {
|
||||
fault_zone?: string | null;
|
||||
fault_acknowledged?: boolean;
|
||||
};
|
||||
is_default_state?: boolean;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
onAcknowledgeHeatingFault: () => void;
|
||||
}) {
|
||||
useHeatingFaultToastEffect({ state, onAcknowledgeHeatingFault });
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -103,12 +103,22 @@ export const heatingStatesSchema = z.object({
|
||||
middle: heatingStateSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Heating fault state schema
|
||||
*/
|
||||
export const heatingFaultStateSchema = z.object({
|
||||
fault_zone: z.string().nullable(),
|
||||
fault_acknowledged: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Extruder settings state schema
|
||||
*/
|
||||
export const extruderSettingsStateSchema = z.object({
|
||||
pressure_limit: z.number(),
|
||||
pressure_limit_enabled: z.boolean(),
|
||||
heating_safeguard_enabled: z.boolean(),
|
||||
nozzle_temperature_target_enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -177,6 +187,7 @@ export const stateEventDataSchema = z.object({
|
||||
extruder_settings_state: extruderSettingsStateSchema,
|
||||
inverter_status_state: inverterStatusStateSchema,
|
||||
pid_settings: pidSettingsSchema,
|
||||
heating_fault_state: heatingFaultStateSchema,
|
||||
});
|
||||
|
||||
// ========== Event Schemas with Wrappers ==========
|
||||
@@ -221,6 +232,14 @@ export type Extruder3NamespaceStore = {
|
||||
// Combined power consumption and energy
|
||||
combinedPower: TimeSeries;
|
||||
totalEnergyKWh: TimeSeries;
|
||||
|
||||
// Target value history (for graph target lines)
|
||||
targetPressure: TimeSeries;
|
||||
targetScrewRpm: TimeSeries;
|
||||
targetNozzleTemperature: TimeSeries;
|
||||
targetFrontTemperature: TimeSeries;
|
||||
targetMiddleTemperature: TimeSeries;
|
||||
targetBackTemperature: TimeSeries;
|
||||
};
|
||||
|
||||
const { initialTimeSeries: pressure, insert: addPressure } = createTimeSeries();
|
||||
@@ -253,6 +272,28 @@ const { initialTimeSeries: motorScrewRpm, insert: addMotorScrewRpm } =
|
||||
const { initialTimeSeries: motorPower, insert: addMotorPower } =
|
||||
createTimeSeries();
|
||||
|
||||
// Target value time series (for historical target lines on graphs)
|
||||
const { initialTimeSeries: targetPressure, insert: addTargetPressure } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: targetScrewRpm, insert: addTargetScrewRpm } =
|
||||
createTimeSeries();
|
||||
const {
|
||||
initialTimeSeries: targetNozzleTemperature,
|
||||
insert: addTargetNozzleTemperature,
|
||||
} = createTimeSeries();
|
||||
const {
|
||||
initialTimeSeries: targetFrontTemperature,
|
||||
insert: addTargetFrontTemperature,
|
||||
} = createTimeSeries();
|
||||
const {
|
||||
initialTimeSeries: targetMiddleTemperature,
|
||||
insert: addTargetMiddleTemperature,
|
||||
} = createTimeSeries();
|
||||
const {
|
||||
initialTimeSeries: targetBackTemperature,
|
||||
insert: addTargetBackTemperature,
|
||||
} = createTimeSeries();
|
||||
|
||||
export function extruder3MessageHandler(
|
||||
store: StoreApi<Extruder3NamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<Extruder3NamespaceStore>,
|
||||
@@ -269,8 +310,18 @@ export function extruder3MessageHandler(
|
||||
|
||||
try {
|
||||
if (eventName === "StateEvent") {
|
||||
console.log(event);
|
||||
const stateEvent = stateEventSchema.parse(event);
|
||||
const timestamp = event.ts;
|
||||
const nextTargetPressure = stateEvent.data.pressure_state.target_bar;
|
||||
const nextTargetScrewRpm = stateEvent.data.screw_state.target_rpm;
|
||||
const nextTargetNozzleTemperature =
|
||||
stateEvent.data.heating_states.nozzle.target_temperature;
|
||||
const nextTargetFrontTemperature =
|
||||
stateEvent.data.heating_states.front.target_temperature;
|
||||
const nextTargetMiddleTemperature =
|
||||
stateEvent.data.heating_states.middle.target_temperature;
|
||||
const nextTargetBackTemperature =
|
||||
stateEvent.data.heating_states.back.target_temperature;
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
state: stateEvent,
|
||||
@@ -278,6 +329,53 @@ export function extruder3MessageHandler(
|
||||
defaultState: stateEvent.data.is_default_state
|
||||
? stateEvent
|
||||
: state.defaultState,
|
||||
// Update target value history
|
||||
targetPressure:
|
||||
state.targetPressure.current?.value === nextTargetPressure
|
||||
? state.targetPressure
|
||||
: addTargetPressure(state.targetPressure, {
|
||||
value: nextTargetPressure,
|
||||
timestamp,
|
||||
}),
|
||||
targetScrewRpm:
|
||||
state.targetScrewRpm.current?.value === nextTargetScrewRpm
|
||||
? state.targetScrewRpm
|
||||
: addTargetScrewRpm(state.targetScrewRpm, {
|
||||
value: nextTargetScrewRpm,
|
||||
timestamp,
|
||||
}),
|
||||
targetNozzleTemperature:
|
||||
state.targetNozzleTemperature.current?.value ===
|
||||
nextTargetNozzleTemperature
|
||||
? state.targetNozzleTemperature
|
||||
: addTargetNozzleTemperature(state.targetNozzleTemperature, {
|
||||
value: nextTargetNozzleTemperature,
|
||||
timestamp,
|
||||
}),
|
||||
targetFrontTemperature:
|
||||
state.targetFrontTemperature.current?.value ===
|
||||
nextTargetFrontTemperature
|
||||
? state.targetFrontTemperature
|
||||
: addTargetFrontTemperature(state.targetFrontTemperature, {
|
||||
value: nextTargetFrontTemperature,
|
||||
timestamp,
|
||||
}),
|
||||
targetMiddleTemperature:
|
||||
state.targetMiddleTemperature.current?.value ===
|
||||
nextTargetMiddleTemperature
|
||||
? state.targetMiddleTemperature
|
||||
: addTargetMiddleTemperature(state.targetMiddleTemperature, {
|
||||
value: nextTargetMiddleTemperature,
|
||||
timestamp,
|
||||
}),
|
||||
targetBackTemperature:
|
||||
state.targetBackTemperature.current?.value ===
|
||||
nextTargetBackTemperature
|
||||
? state.targetBackTemperature
|
||||
: addTargetBackTemperature(state.targetBackTemperature, {
|
||||
value: nextTargetBackTemperature,
|
||||
timestamp,
|
||||
}),
|
||||
}));
|
||||
} else if (eventName === "LiveValuesEvent") {
|
||||
const liveValuesEvent = liveValuesEventSchema.parse(event);
|
||||
@@ -378,6 +476,14 @@ export const createExtruder3NamespaceStore =
|
||||
middlePower,
|
||||
combinedPower,
|
||||
totalEnergyKWh,
|
||||
|
||||
// Target value history
|
||||
targetPressure,
|
||||
targetScrewRpm,
|
||||
targetNozzleTemperature,
|
||||
targetFrontTemperature,
|
||||
targetMiddleTemperature,
|
||||
targetBackTemperature,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,14 @@ export function useExtruder3() {
|
||||
backPower,
|
||||
combinedPower,
|
||||
totalEnergyKWh,
|
||||
|
||||
// Target value history for graph target lines
|
||||
targetPressure,
|
||||
targetScrewRpm,
|
||||
targetNozzleTemperature,
|
||||
targetFrontTemperature,
|
||||
targetMiddleTemperature,
|
||||
targetBackTemperature,
|
||||
} = useExtruder3Namespace(machineIdentification);
|
||||
|
||||
// Single optimistic state for all state management
|
||||
@@ -224,6 +232,49 @@ export function useExtruder3() {
|
||||
);
|
||||
};
|
||||
|
||||
const { request: requestHeatingSafeguardEnabled } = useMachineMutation(
|
||||
z.object({ SetHeatingSafeguardEnabled: z.boolean() }),
|
||||
);
|
||||
|
||||
const setHeatingSafeguardEnabled = (enabled: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.extruder_settings_state.heating_safeguard_enabled =
|
||||
enabled;
|
||||
},
|
||||
() =>
|
||||
requestHeatingSafeguardEnabled({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetHeatingSafeguardEnabled: enabled },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setTemperatureTargetEnabled = (enabled: boolean) => {
|
||||
const res = enabled
|
||||
? true
|
||||
: confirm(
|
||||
"This will disable the ability to set a temperature target for the nozzle heating element. Only proceed if you understand the risks.",
|
||||
);
|
||||
|
||||
if (res) {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
(
|
||||
current.data.extruder_settings_state as {
|
||||
nozzle_temperature_target_enabled?: boolean;
|
||||
}
|
||||
).nozzle_temperature_target_enabled = enabled;
|
||||
},
|
||||
() =>
|
||||
requestNozzleTemperatureTargetEnabled({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { SetNozzleTemperatureTargetEnabled: enabled },
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const setPressurePidKp = (kp: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
@@ -373,6 +424,10 @@ export function useExtruder3() {
|
||||
z.object({ SetExtruderPressureLimitIsEnabled: z.boolean() }),
|
||||
);
|
||||
|
||||
const { request: requestNozzleTemperatureTargetEnabled } = useMachineMutation(
|
||||
z.object({ SetNozzleTemperatureTargetEnabled: z.boolean() }),
|
||||
);
|
||||
|
||||
const { request: requestPressurePidSettings } = useMachineMutation(
|
||||
z.object({
|
||||
SetPressurePidSettings: z.object({
|
||||
@@ -398,6 +453,28 @@ export function useExtruder3() {
|
||||
z.object({ ResetInverter: z.boolean() }),
|
||||
);
|
||||
|
||||
const { request: requestRetryHeating } = useMachineMutation(
|
||||
z.object({ RetryHeating: z.literal(true) }),
|
||||
);
|
||||
|
||||
const retryHeating = () => {
|
||||
requestRetryHeating({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { RetryHeating: true },
|
||||
});
|
||||
};
|
||||
|
||||
const { request: requestAcknowledgeHeatingFault } = useMachineMutation(
|
||||
z.object({ AcknowledgeHeatingFault: z.literal(true) }),
|
||||
);
|
||||
|
||||
const acknowledgeHeatingFault = () => {
|
||||
requestAcknowledgeHeatingFault({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { AcknowledgeHeatingFault: true },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
// Consolidated state
|
||||
state: stateOptimistic.value?.data,
|
||||
@@ -423,6 +500,14 @@ export function useExtruder3() {
|
||||
combinedPower,
|
||||
totalEnergyKWh,
|
||||
|
||||
// Target value history for graph target lines
|
||||
targetPressure,
|
||||
targetScrewRpm,
|
||||
targetNozzleTemperature,
|
||||
targetFrontTemperature,
|
||||
targetMiddleTemperature,
|
||||
targetBackTemperature,
|
||||
|
||||
// Loading states
|
||||
isLoading: stateOptimistic.isOptimistic,
|
||||
isDisabled: !stateOptimistic.isInitialized,
|
||||
@@ -439,10 +524,14 @@ export function useExtruder3() {
|
||||
setMiddleHeatingTemperature,
|
||||
setExtruderPressureLimit,
|
||||
setExtruderPressureLimitEnabled,
|
||||
setHeatingSafeguardEnabled,
|
||||
setTemperatureTargetEnabled,
|
||||
setPressurePidKp,
|
||||
setPressurePidKi,
|
||||
setPressurePidKd,
|
||||
setTemperaturePidValue,
|
||||
resetInverter,
|
||||
retryHeating,
|
||||
acknowledgeHeatingFault,
|
||||
};
|
||||
}
|
||||
@@ -10,10 +10,17 @@ import React from "react";
|
||||
import { useLaser1 } from "./useLaser1";
|
||||
|
||||
export function Laser1GraphsPage() {
|
||||
const { diameter, x_diameter, y_diameter, roundness, state } = useLaser1();
|
||||
const {
|
||||
diameter,
|
||||
x_diameter,
|
||||
y_diameter,
|
||||
roundness,
|
||||
state,
|
||||
targetDiameter: targetDiameterSeries,
|
||||
} = useLaser1();
|
||||
|
||||
const syncHook = useGraphSync("diameter-roundness-group");
|
||||
const targetDiameter = state?.laser_state?.target_diameter ?? 0;
|
||||
const targetDiameterValue = state?.laser_state?.target_diameter ?? 0;
|
||||
const lowerTolerance = state?.laser_state?.lower_tolerance ?? 0;
|
||||
const higherTolerance = state?.laser_state?.higher_tolerance ?? 0;
|
||||
|
||||
@@ -71,21 +78,22 @@ export function Laser1GraphsPage() {
|
||||
// Tolerance for diameter
|
||||
{
|
||||
type: "threshold" as const,
|
||||
value: targetDiameter + higherTolerance,
|
||||
value: targetDiameterValue + higherTolerance,
|
||||
label: "Upper Tolerance",
|
||||
color: diameterColor,
|
||||
dash: [5, 5],
|
||||
},
|
||||
{
|
||||
type: "threshold" as const,
|
||||
value: targetDiameter - lowerTolerance,
|
||||
value: targetDiameterValue - lowerTolerance,
|
||||
label: "Lower Tolerance",
|
||||
color: diameterColor,
|
||||
dash: [5, 5],
|
||||
},
|
||||
{
|
||||
type: "target" as const,
|
||||
value: targetDiameter,
|
||||
value: targetDiameterValue,
|
||||
targetSeries: targetDiameterSeries,
|
||||
label: "Target",
|
||||
color: diameterColor,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
NamespaceId,
|
||||
createNamespaceHookImplementation,
|
||||
ThrottledStoreUpdater,
|
||||
} from "../../../client/socketioStore";
|
||||
} from "@/client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import {
|
||||
createTimeSeries,
|
||||
@@ -64,16 +64,18 @@ export type Laser1NamespaceStore = {
|
||||
x_diameter: TimeSeries;
|
||||
y_diameter: TimeSeries;
|
||||
roundness: TimeSeries;
|
||||
targetDiameter: TimeSeries;
|
||||
};
|
||||
|
||||
const { initialTimeSeries: diameter, insert: addDiameter } =
|
||||
createTimeSeries({ sampleIntervalLong: 500 });
|
||||
const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries();
|
||||
const { initialTimeSeries: x_diameter, insert: addXDiameter } =
|
||||
createTimeSeries({ sampleIntervalLong: 500 });
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: y_diameter, insert: addYDiameter } =
|
||||
createTimeSeries({ sampleIntervalLong: 500 });
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: roundness, insert: addRoundness } =
|
||||
createTimeSeries({ sampleIntervalLong: 500 });
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: targetDiameter, insert: addTargetDiameter } =
|
||||
createTimeSeries();
|
||||
|
||||
/**
|
||||
* Factory function to create a new Laser1 namespace store
|
||||
@@ -88,6 +90,7 @@ export const createLaser1NamespaceStore = (): StoreApi<Laser1NamespaceStore> =>
|
||||
x_diameter,
|
||||
y_diameter,
|
||||
roundness,
|
||||
targetDiameter,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -115,9 +118,17 @@ export function laser1MessageHandler(
|
||||
// Apply appropriate caching strategy based on event type
|
||||
if (eventName === "StateEvent") {
|
||||
const stateEvent = stateEventSchema.parse(event);
|
||||
const nextTargetDiameter = stateEvent.data.laser_state.target_diameter;
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
state: stateEvent,
|
||||
targetDiameter:
|
||||
state.targetDiameter.current?.value === nextTargetDiameter
|
||||
? state.targetDiameter
|
||||
: addTargetDiameter(state.targetDiameter, {
|
||||
value: nextTargetDiameter,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
// only set default state if is_default_state is true
|
||||
defaultState: stateEvent.data.is_default_state
|
||||
? stateEvent
|
||||
|
||||
@@ -11,8 +11,15 @@ import { produce } from "immer";
|
||||
|
||||
function useLaser(machine_identification_unique: MachineIdentificationUnique) {
|
||||
// Get consolidated state and live values from namespace
|
||||
const { state, defaultState, diameter, x_diameter, y_diameter, roundness } =
|
||||
useLaser1Namespace(machine_identification_unique);
|
||||
const {
|
||||
state,
|
||||
defaultState,
|
||||
diameter,
|
||||
x_diameter,
|
||||
y_diameter,
|
||||
roundness,
|
||||
targetDiameter,
|
||||
} = useLaser1Namespace(machine_identification_unique);
|
||||
|
||||
// Single optimistic state for all state management
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
@@ -113,6 +120,7 @@ function useLaser(machine_identification_unique: MachineIdentificationUnique) {
|
||||
x_diameter,
|
||||
y_diameter,
|
||||
roundness,
|
||||
targetDiameter,
|
||||
|
||||
// Loading states
|
||||
isLoading: stateOptimistic.isOptimistic,
|
||||
|
||||
@@ -78,7 +78,7 @@ export type Mock1NamespaceStore = {
|
||||
sineWave3: TimeSeries;
|
||||
};
|
||||
|
||||
const { initialTimeSeries: sineWave, insert: addSineWave } = createTimeSeries({ retentionDurationShort: 300, sampleIntervalLong: 500 });
|
||||
const { initialTimeSeries: sineWave, insert: addSineWave } = createTimeSeries();
|
||||
|
||||
/**
|
||||
* Factory function to create a new Mock1 namespace store
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { Page } from "@/components/Page";
|
||||
import React from "react";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { SelectionGroupBoolean } from "@/control/SelectionGroup";
|
||||
import { EditValue } from "@/control/EditValue";
|
||||
import { Label } from "@/control/Label";
|
||||
import { useTestMotor } from "./useTestMotor";
|
||||
|
||||
export function TestMotorControlPage() {
|
||||
const { state, setMotorOn, setVelocity } = useTestMotor();
|
||||
|
||||
// Fallback in case state is still null
|
||||
const safeState = state ?? { motor_enabled: false, motor_velocity: 0 };
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
{/* Basic control */}
|
||||
<ControlCard title="Motor Status">
|
||||
{/* On/Off switch */}
|
||||
<Label label="Power State">
|
||||
<SelectionGroupBoolean
|
||||
value={safeState.motor_enabled}
|
||||
// Icon mapping for True/False
|
||||
optionTrue={{ children: "Enabled", icon: "lu:Play" }}
|
||||
optionFalse={{ children: "Disabled", icon: "lu:CirclePause" }}
|
||||
onChange={(val) => setMotorOn(val)}
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
|
||||
{/* Velocity */}
|
||||
<ControlCard title="Settings">
|
||||
{/* Velocity input with unit */}
|
||||
<Label label="Target Velocity">
|
||||
<EditValue
|
||||
title="Velocity"
|
||||
value={safeState.motor_velocity}
|
||||
unit="rpm"
|
||||
min={0}
|
||||
max={1000} // Limit
|
||||
step={1}
|
||||
onChange={(val) => setVelocity(val)}
|
||||
// Displays the value (as integer)
|
||||
renderValue={(v) => v.toFixed(0)}
|
||||
/>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Topbar } from "@/components/Topbar";
|
||||
import { testMotorSerialRoute } from "@/routes/routes";
|
||||
import React from "react";
|
||||
|
||||
export function TestMotorPage() {
|
||||
// Access is allowed here because this function is called later
|
||||
const { serial } = testMotorSerialRoute.useParams();
|
||||
|
||||
return (
|
||||
<Topbar
|
||||
pathname={`/_sidebar/machines/testmotor/${serial}`}
|
||||
items={[
|
||||
{
|
||||
link: "control",
|
||||
activeLink: "control",
|
||||
title: "Control",
|
||||
icon: "lu:CirclePlay",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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 (Must match StateEvent) ==========
|
||||
export const stateEventDataSchema = z.object({
|
||||
motor_enabled: z.boolean(),
|
||||
motor_velocity: z.number(),
|
||||
});
|
||||
|
||||
export const stateEventSchema = eventSchema(stateEventDataSchema);
|
||||
export type StateEvent = z.infer<typeof stateEventDataSchema>;
|
||||
|
||||
// ========== Store ==========
|
||||
export type TestMotorNamespaceStore = {
|
||||
state: StateEvent | null;
|
||||
};
|
||||
|
||||
export const createTestMotorNamespaceStore =
|
||||
(): StoreApi<TestMotorNamespaceStore> =>
|
||||
create<TestMotorNamespaceStore>(() => ({
|
||||
state: null,
|
||||
}));
|
||||
|
||||
// ========== Message Handler ==========
|
||||
export function testMotorMessageHandler(
|
||||
chstore: StoreApi<TestMotorNamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<TestMotorNamespaceStore>,
|
||||
): EventHandler {
|
||||
return (event: Event<any>) => {
|
||||
const updateStore = (
|
||||
updater: (state: TestMotorNamespaceStore) => TestMotorNamespaceStore,
|
||||
) => 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 useTestMotorNamespaceImplementation =
|
||||
createNamespaceHookImplementation<TestMotorNamespaceStore>({
|
||||
createStore: createTestMotorNamespaceStore,
|
||||
createEventHandler: testMotorMessageHandler,
|
||||
});
|
||||
|
||||
export function useTestMotorNamespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): TestMotorNamespaceStore {
|
||||
const namespaceId: NamespaceId = {
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
};
|
||||
return useTestMotorNamespaceImplementation(namespaceId);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { testMotorSerialRoute } from "@/routes/routes";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { useTestMotorNamespace, StateEvent } from "./testMotorNamespace";
|
||||
import { useMachineMutate } from "@/client/useClient";
|
||||
import { produce } from "immer";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TestMotor } from "@/machines/properties";
|
||||
import { z } from "zod";
|
||||
|
||||
export function useTestMotor() {
|
||||
const { serial: serialString } = testMotorSerialRoute.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: TestMotor.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]);
|
||||
|
||||
// Namespace state from backend
|
||||
const { state } = useTestMotorNamespace(machineIdentification);
|
||||
|
||||
// Optimistic state
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
|
||||
useEffect(() => {
|
||||
if (state) stateOptimistic.setReal(state);
|
||||
}, [state, stateOptimistic]);
|
||||
|
||||
// Generic mutation sender
|
||||
const { request: sendMutation } = useMachineMutate(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
payload: z.any(),
|
||||
}),
|
||||
);
|
||||
|
||||
const updateStateOptimistically = (
|
||||
producer: (current: StateEvent) => void,
|
||||
serverRequest?: () => void,
|
||||
) => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState)
|
||||
stateOptimistic.setOptimistic(produce(currentState, producer));
|
||||
serverRequest?.();
|
||||
};
|
||||
|
||||
// --- ACTIONS ---
|
||||
|
||||
const setMotorOn = (on: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.motor_enabled = on;
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
// Must exactly match the 'Mutation' enum!
|
||||
data: { type: "SetMotorOn", payload: on },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setVelocity = (velocity: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.motor_velocity = velocity;
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { type: "SetMotorVelocity", payload: velocity },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
state: stateOptimistic.value,
|
||||
setMotorOn,
|
||||
setVelocity,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
@@ -544,6 +573,64 @@ export const ip20TestMachine: MachineProperties = {
|
||||
],
|
||||
};
|
||||
|
||||
export const TestMotor: MachineProperties = {
|
||||
name: "TestMotor",
|
||||
version: "V1",
|
||||
slug: "testmotor",
|
||||
icon: "lu:Disc3",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x0011,
|
||||
},
|
||||
device_roles: [
|
||||
{
|
||||
role: 0,
|
||||
role_label: "Bus Coupler",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 0x2,
|
||||
product_id: 0x44c2c52,
|
||||
revision: 0x120000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 1,
|
||||
role_label: "Stepper Motor",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 0x2,
|
||||
product_id: 0x1b773052,
|
||||
revision: 0x10001e,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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",
|
||||
@@ -568,6 +655,41 @@ export const wagoAiTestMachine: MachineProperties = {
|
||||
],
|
||||
};
|
||||
|
||||
export const testmachinestepper: MachineProperties = {
|
||||
name: "TestMachineStepper",
|
||||
version: "V1",
|
||||
slug: "testmachinestepper",
|
||||
icon: "lu:Disc3",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x0037,
|
||||
},
|
||||
device_roles: [
|
||||
{
|
||||
role: 0,
|
||||
role_label: "Bus Coupler",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 0x00000021,
|
||||
product_id: 0x07500354,
|
||||
revision: 0x2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 1,
|
||||
role_label: "Stepper Motor",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 0x00000021,
|
||||
product_id: 0x67214e8,
|
||||
revision: 0x2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const machineProperties: MachineProperties[] = [
|
||||
winder2,
|
||||
extruder3,
|
||||
@@ -581,7 +703,11 @@ export const machineProperties: MachineProperties[] = [
|
||||
analogInputTestMachine,
|
||||
wagoAiTestMachine,
|
||||
digitalInputTestMachine,
|
||||
wago8chDioTestMachine,
|
||||
ip20TestMachine,
|
||||
wagoDoTestMachine,
|
||||
TestMotor,
|
||||
testmachinestepper,
|
||||
];
|
||||
|
||||
export const getMachineProperties = (
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { Page } from "@/components/Page";
|
||||
import React from "react";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { useTestMachineStepper } from "./useTestMachineStepper";
|
||||
import { Label } from "@/control/Label";
|
||||
import { EditValue } from "@/control/EditValue";
|
||||
import { roundToDecimals } from "@/lib/decimal";
|
||||
import { TouchButton } from "@/components/touch/TouchButton";
|
||||
|
||||
export function TestMachineStepperControlPage() {
|
||||
const { state, setTargetSpeed, setEnabled, setFreq, setAccFreq } =
|
||||
useTestMachineStepper();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
<ControlCard title="Stepper Speed">
|
||||
<Label label="Target Speed">
|
||||
<EditValue
|
||||
value={state?.target_speed}
|
||||
unit="rpm"
|
||||
title="Target Speed"
|
||||
defaultValue={0}
|
||||
min={-25000}
|
||||
max={25000}
|
||||
step={1}
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
onChange={setTargetSpeed}
|
||||
/>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:CirclePower"
|
||||
onClick={() => setEnabled(true)}
|
||||
disabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
Enable
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:CirclePower"
|
||||
onClick={() => setEnabled(false)}
|
||||
disabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
Disable
|
||||
</TouchButton>
|
||||
</Label>
|
||||
</ControlCard>
|
||||
<ControlCard height={2} title="Frequency Prescaler">
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:CirclePower"
|
||||
onClick={() => setFreq(0)}
|
||||
disabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
Default (00)
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:CirclePower"
|
||||
onClick={() => setFreq(1)}
|
||||
disabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
Low (01)
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:CirclePower"
|
||||
onClick={() => setFreq(2)}
|
||||
disabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
Mid (10)
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:CirclePower"
|
||||
onClick={() => setFreq(3)}
|
||||
disabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
High (11)
|
||||
</TouchButton>
|
||||
</ControlCard>
|
||||
<ControlCard height={2} title="Acceleration Factor">
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:CirclePower"
|
||||
onClick={() => setAccFreq(0)}
|
||||
disabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
Default (00)
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:CirclePower"
|
||||
onClick={() => setAccFreq(1)}
|
||||
disabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
Low (01)
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:CirclePower"
|
||||
onClick={() => setAccFreq(2)}
|
||||
disabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
Mid (10)
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:CirclePower"
|
||||
onClick={() => setAccFreq(3)}
|
||||
disabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
High (11)
|
||||
</TouchButton>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Topbar } from "@/components/Topbar";
|
||||
import { testMachineStepperSerialRoute } from "@/routes/routes";
|
||||
import React from "react";
|
||||
|
||||
export function TestMachineStepperPage() {
|
||||
const { serial } = testMachineStepperSerialRoute.useParams();
|
||||
return (
|
||||
<Topbar
|
||||
pathname={`/_sidebar/machines/testmachinestepper/${serial}`}
|
||||
items={[
|
||||
{
|
||||
link: "control",
|
||||
activeLink: "control",
|
||||
title: "Control",
|
||||
icon: "lu:CirclePlay",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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({
|
||||
target_speed: z.number(),
|
||||
enabled: z.boolean(),
|
||||
freq: z.number(),
|
||||
acc_freq: z.number(),
|
||||
});
|
||||
|
||||
export const stateEventSchema = eventSchema(stateEventDataSchema);
|
||||
|
||||
export type StateEvent = z.infer<typeof stateEventDataSchema>;
|
||||
|
||||
// ========== Store ==========
|
||||
export type TestMachineStepperNamespaceStore = {
|
||||
state: StateEvent | null;
|
||||
};
|
||||
|
||||
export const createTestMachineStepperNamespaceStore =
|
||||
(): StoreApi<TestMachineStepperNamespaceStore> =>
|
||||
create<TestMachineStepperNamespaceStore>(() => ({
|
||||
state: null,
|
||||
}));
|
||||
|
||||
// ========== Message Handler ==========
|
||||
export function testMachineStepperMessageHandler(
|
||||
store: StoreApi<TestMachineStepperNamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<TestMachineStepperNamespaceStore>,
|
||||
): EventHandler {
|
||||
return (event: Event<any>) => {
|
||||
const updateStore = (
|
||||
updater: (
|
||||
state: TestMachineStepperNamespaceStore,
|
||||
) => TestMachineStepperNamespaceStore,
|
||||
) => 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 useTestMachineStepperNamespaceImplementation =
|
||||
createNamespaceHookImplementation<TestMachineStepperNamespaceStore>({
|
||||
createStore: createTestMachineStepperNamespaceStore,
|
||||
createEventHandler: testMachineStepperMessageHandler,
|
||||
});
|
||||
|
||||
export function useTestMachineStepperNamespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): TestMachineStepperNamespaceStore {
|
||||
const namespaceId: NamespaceId = {
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
};
|
||||
|
||||
return useTestMachineStepperNamespaceImplementation(namespaceId);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { testMachineStepperSerialRoute } from "@/routes/routes";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import {
|
||||
useTestMachineStepperNamespace,
|
||||
StateEvent,
|
||||
} from "./testMachineStepperNamespace";
|
||||
import { useMachineMutate } from "@/client/useClient";
|
||||
import { produce } from "immer";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { testmachinestepper } from "@/machines/properties";
|
||||
import { z } from "zod";
|
||||
export function useTestMachineStepper() {
|
||||
const { serial: serialString } = testMachineStepperSerialRoute.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: testmachinestepper.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]);
|
||||
|
||||
// Namespace state from backend
|
||||
const { state } = useTestMachineStepperNamespace(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 setTargetSpeed = (target: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.target_speed = target;
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetTargetSpeed", value: { target } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setEnabled = (enabled: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.enabled = enabled;
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetEnabled", value: { enabled } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setFreq = (factor: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.freq = factor;
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetFreq", value: { factor } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setAccFreq = (factor: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.acc_freq = factor;
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetAccFreq", value: { factor } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
state: stateOptimistic.value,
|
||||
setTargetSpeed,
|
||||
setEnabled,
|
||||
setFreq,
|
||||
setAccFreq,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function Winder2GraphsPage() {
|
||||
traversePosition,
|
||||
tensionArmAngle,
|
||||
pullerSpeed,
|
||||
targetPullerSpeed,
|
||||
spoolProgress,
|
||||
} = useWinder2();
|
||||
|
||||
@@ -31,6 +32,7 @@ export function Winder2GraphsPage() {
|
||||
<PullerSpeedGraph
|
||||
syncHook={syncHook}
|
||||
newData={pullerSpeed}
|
||||
targetSeries={targetPullerSpeed}
|
||||
targetSpeed={state?.puller_state?.target_speed}
|
||||
unit="m/min"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
@@ -243,12 +245,14 @@ export function SpoolProgressGraph({
|
||||
export function PullerSpeedGraph({
|
||||
syncHook,
|
||||
newData,
|
||||
targetSeries,
|
||||
targetSpeed,
|
||||
unit,
|
||||
renderValue,
|
||||
}: {
|
||||
syncHook: ReturnType<typeof useGraphSync>;
|
||||
newData: TimeSeries | null;
|
||||
targetSeries?: TimeSeries;
|
||||
targetSpeed?: number;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
@@ -259,6 +263,7 @@ export function PullerSpeedGraph({
|
||||
lines.push({
|
||||
type: "target",
|
||||
value: targetSpeed,
|
||||
targetSeries,
|
||||
label: "Target Speed",
|
||||
color: "#06b6d4", // Match series color
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ export function useWinder2() {
|
||||
defaultState,
|
||||
traversePosition,
|
||||
pullerSpeed,
|
||||
targetPullerSpeed,
|
||||
spoolRpm,
|
||||
tensionArmAngle,
|
||||
spoolProgress,
|
||||
@@ -638,6 +639,7 @@ export function useWinder2() {
|
||||
// Individual live values (TimeSeries)
|
||||
traversePosition,
|
||||
pullerSpeed,
|
||||
targetPullerSpeed,
|
||||
spoolRpm,
|
||||
tensionArmAngle,
|
||||
spoolProgress,
|
||||
|
||||
@@ -211,19 +211,21 @@ export type Winder2NamespaceStore = {
|
||||
spoolRpm: TimeSeries;
|
||||
tensionArmAngle: TimeSeries;
|
||||
spoolProgress: TimeSeries;
|
||||
targetPullerSpeed: TimeSeries;
|
||||
};
|
||||
|
||||
//Store Factory and Message Handler -> no param, so default values
|
||||
const { initialTimeSeries: spoolProgress, insert: addSpoolProgress } =
|
||||
createTimeSeries({ sampleIntervalLong: 500 });
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: traversePosition, insert: addTraversePosition } =
|
||||
createTimeSeries({ sampleIntervalLong: 500 });
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: pullerSpeed, insert: addPullerSpeed } =
|
||||
createTimeSeries({ sampleIntervalLong: 500 });
|
||||
const { initialTimeSeries: spoolRpm, insert: addSpoolRpm } =
|
||||
createTimeSeries({ sampleIntervalLong: 500 });
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: targetPullerSpeed, insert: addTargetPullerSpeed } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: spoolRpm, insert: addSpoolRpm } = createTimeSeries();
|
||||
const { initialTimeSeries: tensionArmAngle, insert: addTensionArmAngle } =
|
||||
createTimeSeries({ sampleIntervalLong: 500 });
|
||||
createTimeSeries();
|
||||
|
||||
/**
|
||||
* Factory function to create a new Winder2 namespace store
|
||||
@@ -240,6 +242,7 @@ export const createWinder2NamespaceStore =
|
||||
// Time series data for live values
|
||||
traversePosition,
|
||||
pullerSpeed,
|
||||
targetPullerSpeed,
|
||||
spoolRpm,
|
||||
tensionArmAngle,
|
||||
spoolProgress,
|
||||
@@ -274,10 +277,18 @@ export function winder2MessageHandler(
|
||||
console.log(event);
|
||||
// Parse and validate the state event
|
||||
const stateEvent = stateEventSchema.parse(event);
|
||||
const nextTargetPullerSpeed = stateEvent.data.puller_state.target_speed;
|
||||
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
state: stateEvent,
|
||||
targetPullerSpeed:
|
||||
state.targetPullerSpeed.current?.value === nextTargetPullerSpeed
|
||||
? state.targetPullerSpeed
|
||||
: addTargetPullerSpeed(state.targetPullerSpeed, {
|
||||
value: nextTargetPullerSpeed,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
// only set default state if is_default_state is true
|
||||
defaultState: stateEvent.data.is_default_state
|
||||
? stateEvent
|
||||
|
||||
@@ -57,6 +57,8 @@ import { Aquapath1SettingsPage } from "@/machines/aquapath/aquapath1/Aquapath1Se
|
||||
import { TestMachinePage } from "@/machines/testmachine/TestMachinePage";
|
||||
import { TestMachineControlPage } from "@/machines/testmachine/TestMachineControlPage";
|
||||
|
||||
import { TestMachineStepperPage } from "@/machines/testmachinestepper/TestMachineStepperPage";
|
||||
import { TestMachineStepperControlPage } from "@/machines/testmachinestepper/TestMachineStepperControlPage";
|
||||
import { AnalogInputTestMachine } from "@/machines/analoginputtestmachine/AnalogInputTestMachinePage";
|
||||
import { AnalogInputTestMachineControl } from "@/machines/analoginputtestmachine/AnalogInputTestMachineControlPage";
|
||||
import { WagoAiTestMachine } from "@/machines/wagoaitestmachine/WagoAiTestMachinePage";
|
||||
@@ -67,12 +69,18 @@ import { DigitalInputTestMachineControlPage } from "@/machines/digitalinputtestm
|
||||
|
||||
import { IP20TestMachinePage } from "@/machines/ip20testmachine/IP20TestMachinePage";
|
||||
import { IP20TestMachineControlPage } from "@/machines/ip20testmachine/IP20TestMachineControlPage";
|
||||
import { TestMotorPage } from "@/machines/motor_test_machine/TestMotorPage";
|
||||
import { TestMotorControlPage } from "@/machines/motor_test_machine/TestMotorControlPage";
|
||||
|
||||
import { MetricsGraphsPage } from "@/metrics/MetricsGraphsPage";
|
||||
import { MetricsControlPage } from "@/metrics/MetricsControlPage";
|
||||
|
||||
import { WagoPower1Page } from "@/machines/wago_power/wago_power1/WagoPower1Page";
|
||||
import { WagoPower1ControlPage } from "@/machines/wago_power/wago_power1/WagoPower1ControlPage";
|
||||
import { WagoDoTestMachinePage } from "@/machines/wagodotestmachine/WagoDoTestMachinePage";
|
||||
import { WagoDoTestMachineControlPage } from "@/machines/wagodotestmachine/WagoDoTestMachineControlPage";
|
||||
import { Wago8chDioTestMachinePage } from "@/machines/wago8chdiotestmachine/wago8chDioTestMachinePage";
|
||||
import { Wago8chDioTestMachineControlRoute } from "@/machines/wago8chdiotestmachine/wago8chDioTestMachineControlPage";
|
||||
|
||||
// make a route tree like this
|
||||
// _mainNavigation/machines/winder2/$serial/control
|
||||
@@ -87,6 +95,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,
|
||||
@@ -142,6 +162,31 @@ export const ip20TestMachineControlRoute = createRoute({
|
||||
component: () => <IP20TestMachineControlPage />,
|
||||
});
|
||||
|
||||
export const testMotorSerialRoute = createRoute({
|
||||
getParentRoute: () => machinesRoute,
|
||||
path: "testmotor/$serial",
|
||||
component: () => <TestMotorPage />,
|
||||
});
|
||||
|
||||
export const testMotorControlRoute = createRoute({
|
||||
getParentRoute: () => testMotorSerialRoute,
|
||||
path: "control",
|
||||
component: () => <TestMotorControlPage />,
|
||||
});
|
||||
|
||||
export const testMachineStepperSerialRoute = createRoute({
|
||||
getParentRoute: () => machinesRoute,
|
||||
path: "testmachinestepper/$serial",
|
||||
component: () => <TestMachineStepperPage />,
|
||||
});
|
||||
|
||||
export const testMachineStepperControlRoute = createRoute({
|
||||
getParentRoute: () => testMachineStepperSerialRoute,
|
||||
path: "control",
|
||||
component: () => <TestMachineStepperControlPage />,
|
||||
});
|
||||
|
||||
// Leaf route: control page
|
||||
export const sidebarRoute = createRoute({
|
||||
getParentRoute: () => RootRoute,
|
||||
path: "_sidebar",
|
||||
@@ -369,6 +414,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",
|
||||
@@ -470,12 +527,21 @@ export const rootTree = RootRoute.addChildren([
|
||||
laser1GraphsRoute,
|
||||
laser1PresetsRoute,
|
||||
]),
|
||||
|
||||
testMachineSerialRoute.addChildren([testMachineControlRoute]),
|
||||
|
||||
testMachineStepperSerialRoute.addChildren([
|
||||
testMachineStepperControlRoute,
|
||||
]),
|
||||
|
||||
analogInputTestMachineSerialRoute.addChildren([
|
||||
analogInputTestMachineControlRoute,
|
||||
]),
|
||||
|
||||
wago8chDioTestMachineRoute.addChildren([
|
||||
wago8chDioTestMachineControlRoute,
|
||||
]),
|
||||
|
||||
wagoAiTestMachineSerialRoute.addChildren([wagoAiTestMachineControlRoute]),
|
||||
|
||||
digitalInputTestMachineSerialRoute.addChildren([
|
||||
@@ -484,6 +550,8 @@ export const rootTree = RootRoute.addChildren([
|
||||
|
||||
ip20TestMachineSerialRoute.addChildren([ip20TestMachineControlRoute]),
|
||||
|
||||
testMotorSerialRoute.addChildren([testMotorControlRoute]),
|
||||
|
||||
aquapath1SerialRoute.addChildren([
|
||||
aquapath1ControlRoute,
|
||||
aquapath1GraphRoute,
|
||||
@@ -524,6 +592,8 @@ export const rootTree = RootRoute.addChildren([
|
||||
wagoPower1SerialRoute.addChildren([wagoPower1ControlRoute]),
|
||||
|
||||
buffer1SerialRoute.addChildren([buffer1ControlRoute]),
|
||||
|
||||
wagoDoTestMachineSerialRoute.addChildren([wagoDoTestMachineControlRoute]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
@@ -1,25 +1,22 @@
|
||||
import { Alert } from "@/components/Alert";
|
||||
import { Page } from "@/components/Page";
|
||||
import { SectionTitle } from "@/components/SectionTitle";
|
||||
import { Terminal } from "@/components/Terminal";
|
||||
import { TouchButton } from "@/components/touch/TouchButton";
|
||||
import { useLogsStore } from "@/stores/logsStore";
|
||||
import { rebootHmi, restartBackend } from "@/helpers/troubleshoot_helpers";
|
||||
import {
|
||||
rebootHmi,
|
||||
restartBackend,
|
||||
exportLogs,
|
||||
} from "@/helpers/troubleshoot_helpers";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function TroubleshootPage() {
|
||||
const [isRebootLoading, setIsRebootLoading] = useState(false);
|
||||
const [isRestartLoading, setIsRestartLoading] = useState(false);
|
||||
const { getLogsBySource, clearLogs } = useLogsStore();
|
||||
const [isExportLoading, setIsExportLoading] = useState(false);
|
||||
|
||||
// Perhaps we just need to clear the logs ?
|
||||
// Get backend logs for display
|
||||
const backendLogs = getLogsBySource("qitech-control-server");
|
||||
const logLines = backendLogs.map((log) => log.raw);
|
||||
const handleRebootHmi = async () => {
|
||||
setIsRebootLoading(true);
|
||||
|
||||
try {
|
||||
const result = await rebootHmi();
|
||||
if (result.success) {
|
||||
@@ -50,10 +47,32 @@ export function TroubleshootPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportLogs = async () => {
|
||||
setIsExportLoading(true);
|
||||
try {
|
||||
const result = await exportLogs();
|
||||
if (result.success) {
|
||||
toast.success("Export Logs initiated");
|
||||
} else {
|
||||
toast.error(`Failed to export Logs: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to export Logs: ${error}`);
|
||||
} finally {
|
||||
setIsExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<SectionTitle title="System Troubleshoot" />
|
||||
|
||||
<Alert title="Troubleshoot Actions Info" variant="warning">
|
||||
These actions will temporarily interrupt system operations. The HMI
|
||||
reboot will restart the entire panel, while the backend restart will
|
||||
only restart the control service. Use with caution during production.
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<TouchButton
|
||||
variant="destructive"
|
||||
@@ -74,23 +93,17 @@ export function TroubleshootPage() {
|
||||
>
|
||||
Restart Backend Process
|
||||
</TouchButton>
|
||||
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:Power"
|
||||
isLoading={isExportLoading}
|
||||
onClick={exportLogs}
|
||||
className="w-max"
|
||||
>
|
||||
Export Backend Service Logs
|
||||
</TouchButton>
|
||||
</div>
|
||||
|
||||
<Alert title="Troubleshoot Actions Info" variant="warning">
|
||||
These actions will temporarily interrupt system operations. The HMI
|
||||
reboot will restart the entire panel, while the backend restart will
|
||||
only restart the control service. Use with caution during production.
|
||||
</Alert>
|
||||
|
||||
<h2 className="text-lg font-semibold">Backend Service Logs</h2>
|
||||
|
||||
<Terminal
|
||||
lines={logLines}
|
||||
autoScroll={true}
|
||||
className="h-160"
|
||||
title="qitech-control-server"
|
||||
exportPrefix="qitech_control_server_journald"
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { SectionTitle } from "@/components/SectionTitle";
|
||||
import { Terminal } from "@/components/Terminal";
|
||||
import { TouchButton } from "@/components/touch/TouchButton";
|
||||
import { UpdateProgressBar } from "@/components/UpdateProgressBar";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { updateExecute, updateCancelWithStore } from "@/helpers/update_helpers";
|
||||
import { useUpdateStore } from "@/stores/updateStore";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
@@ -88,7 +89,7 @@ export function UpdateExecutePage() {
|
||||
<Page>
|
||||
<SectionTitle title="Apply Update" />
|
||||
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<TouchButton
|
||||
className="w-max"
|
||||
icon="lu:CircleFadingArrowUp"
|
||||
@@ -108,6 +109,14 @@ export function UpdateExecutePage() {
|
||||
Cancel Update
|
||||
</TouchButton>
|
||||
)}
|
||||
{isUpdating && (
|
||||
<div className="ml-auto flex items-center gap-2 rounded-xl border border-blue-400 bg-blue-600 px-4 py-2.5 text-white shadow-xl backdrop-blur-sm transition-all duration-300">
|
||||
<Icon name="lu:Info" className="h-5 w-5 text-blue-100" />
|
||||
<span className="text-base leading-snug text-blue-50">
|
||||
Updates typically take approximately <strong>5 minutes</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{currentUpdateInfo && (
|
||||
<Alert title="Update Information" variant="info">
|
||||
@@ -136,8 +145,7 @@ export function UpdateExecutePage() {
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
Please stay connected to the internet and leave the power on. The
|
||||
update procedure takes a couple of minutes and reboots the machine
|
||||
afterwards.
|
||||
machine will reboot after the update completes.
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
@@ -58,9 +58,7 @@ interface UpdateContext {
|
||||
interface TroubleshootContext {
|
||||
rebootHmi: () => Promise<{ success: boolean; error?: string }>;
|
||||
restartBackend: () => Promise<{ success: boolean; error?: string }>;
|
||||
startLogStream: () => Promise<{ success: boolean; error?: string }>;
|
||||
stopLogStream: () => Promise<{ success: boolean; error?: string }>;
|
||||
onLogData: (callback: (log: string) => void) => void;
|
||||
exportLogs: () => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
interface NixOSGeneration {
|
||||
|
||||
@@ -8,7 +8,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
ethercrab = "0.6"
|
||||
ethercrab = { git = "https://github.com/v-morlock/ethercrab", branch = "main" }
|
||||
ethercat_hal_derive = { version = "0.1.0", path = "../ethercat-hal-derive" }
|
||||
units = { path = "../units" }
|
||||
bitvec = { version = "1.0.1", features = ["alloc"] }
|
||||
|
||||
@@ -180,7 +180,6 @@ impl Configuration for EL5152Configuration {
|
||||
) -> Result<(), anyhow::Error> {
|
||||
// Configure channel 1
|
||||
self.channel1.write_channel_config(device, 0x8000).await?;
|
||||
|
||||
// Configure channel 2
|
||||
self.channel2.write_channel_config(device, 0x8010).await?;
|
||||
// Write PDO assignments
|
||||
@@ -304,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?;
|
||||
|
||||
@@ -351,6 +351,7 @@ pub enum EL7031_0030AnalogInputPort {
|
||||
pub const EL7031_0030_VENDOR_ID: u32 = 0x2;
|
||||
pub const EL7031_0030_PRODUCT_ID: u32 = 0x1b773052;
|
||||
pub const EL7031_0030_REVISION_A: u32 = 0x10001E;
|
||||
|
||||
pub const EL7031_0030_IDENTITY_A: SubDeviceIdentityTuple = (
|
||||
EL7031_0030_VENDOR_ID,
|
||||
EL7031_0030_PRODUCT_ID,
|
||||
|
||||
@@ -56,8 +56,9 @@ use std::{any::Any, fmt::Debug, sync::Arc};
|
||||
use wago_750_354::{WAGO_750_354_IDENTITY_A, Wago750_354};
|
||||
use wago_modules::ip20_ec_di8_do8::{IP20_EC_DI8_DO8_IDENTITY, IP20EcDi8Do8};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Module {
|
||||
pub name: String,
|
||||
pub slot: u16,
|
||||
pub belongs_to_addr: u16,
|
||||
pub has_tx: bool,
|
||||
|
||||
@@ -2,6 +2,9 @@ use super::{
|
||||
EthercatDevice, EthercatDeviceProcessing, EthercatDeviceUsed, NewEthercatDevice,
|
||||
SubDeviceIdentityTuple,
|
||||
};
|
||||
use crate::devices::wago_modules::wago_750_430::{
|
||||
WAGO_750_430_MODULE_IDENT, WAGO_750_430_PRODUCT_ID,
|
||||
};
|
||||
use crate::devices::wago_modules::*;
|
||||
use crate::{
|
||||
devices::{
|
||||
@@ -12,6 +15,7 @@ use crate::{
|
||||
wago_750_501::{WAGO_750_501_MODULE_IDENT, WAGO_750_501_PRODUCT_ID},
|
||||
wago_750_530::{WAGO_750_530_MODULE_IDENT, WAGO_750_530_PRODUCT_ID},
|
||||
wago_750_652::{WAGO_750_652_MODULE_IDENT, WAGO_750_652_PRODUCT_ID},
|
||||
wago_750_672::{WAGO_750_672_MODULE_IDENT, WAGO_750_672_PRODUCT_ID},
|
||||
wago_750_1506::{WAGO_750_1506_MODULE_IDENT, WAGO_750_1506_PRODUCT_ID},
|
||||
},
|
||||
},
|
||||
@@ -20,24 +24,25 @@ use crate::{
|
||||
use anyhow::Error;
|
||||
use smol::lock::RwLock;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MODULE_COUNT_INDEX: (u16, u8) = (0xf050, 0x00);
|
||||
const TX_MAPPING_INDEX: (u16, u8) = (0x1c13, 0x00);
|
||||
const RX_MAPPING_INDEX: (u16, u8) = (0x1c12, 0x00);
|
||||
// For both the rx and tx The Wago Coupler has 4 bytes, which we dont care about and skip
|
||||
|
||||
/// Wago750_354 bus coupler
|
||||
/*
|
||||
The "Modules" simply write at an offset into rx and read at an offset in tx
|
||||
*/
|
||||
#[derive(Clone, Debug)]
|
||||
struct ModulePdoMapping {
|
||||
pub offset: usize,
|
||||
pub module_i: u32,
|
||||
}
|
||||
|
||||
// For both the rx and tx The Wago Coupler has 4 bytes, which we dont care about and skip
|
||||
pub struct Wago750_354 {
|
||||
is_used: bool,
|
||||
pub slots: [Option<Module>; 64],
|
||||
pub slot_devices: [Option<Arc<RwLock<dyn DynamicEthercatDevice>>>; 64],
|
||||
pub dev_count: usize,
|
||||
pub module_count: usize,
|
||||
rx_offsets: Vec<usize>,
|
||||
tx_offsets: Vec<usize>,
|
||||
rx_pdo_mappings: Vec<ModulePdoMapping>,
|
||||
tx_pdo_mappings: Vec<ModulePdoMapping>,
|
||||
tx_size: usize,
|
||||
rx_size: usize,
|
||||
}
|
||||
@@ -58,7 +63,6 @@ impl EthercatDevice for Wago750_354 {
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -105,7 +109,7 @@ impl EthercatDevice for Wago750_354 {
|
||||
}
|
||||
|
||||
fn set_module(&mut self, module: Module) {
|
||||
self.slots[self.module_count] = Some(module);
|
||||
self.slots[self.module_count] = Some(module.clone());
|
||||
self.module_count += 1;
|
||||
self.tx_size += module.tx_offset;
|
||||
self.rx_size += module.rx_offset;
|
||||
@@ -134,8 +138,8 @@ impl NewEthercatDevice for Wago750_354 {
|
||||
dev_count: 0,
|
||||
tx_size: 0,
|
||||
rx_size: 0,
|
||||
rx_offsets: vec![],
|
||||
tx_offsets: vec![],
|
||||
rx_pdo_mappings: vec![],
|
||||
tx_pdo_mappings: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,39 +151,84 @@ impl std::fmt::Debug for Wago750_354 {
|
||||
}
|
||||
|
||||
impl Wago750_354 {
|
||||
pub fn calculate_module_index(pdo_mapping: u32, is_tx: bool) -> u32 {
|
||||
let start_index = match is_tx {
|
||||
true => 0x6000,
|
||||
false => 0x7000,
|
||||
};
|
||||
let pdo_index = (pdo_mapping & 0xFFFF0000) >> 16;
|
||||
|
||||
if pdo_index < start_index {
|
||||
// Treat as coupler/module 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
let index_in_hex = pdo_index - start_index;
|
||||
|
||||
if index_in_hex < 16 {
|
||||
0
|
||||
} else {
|
||||
index_in_hex / 16
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_pdo_offsets<'a>(
|
||||
&mut self,
|
||||
device: &EthercrabSubDevicePreoperational<'a>,
|
||||
get_tx: bool,
|
||||
) -> Result<(), Error> {
|
||||
let mut vec: Vec<usize> = vec![];
|
||||
let mut vec: Vec<ModulePdoMapping> = vec![];
|
||||
let mut bit_offset = 0;
|
||||
let start_subindex = 0x1;
|
||||
|
||||
let mut module_i;
|
||||
let start_subindex = 0x2;
|
||||
|
||||
let index = match get_tx {
|
||||
true => (TX_MAPPING_INDEX.0, TX_MAPPING_INDEX.1),
|
||||
false => (RX_MAPPING_INDEX.0, RX_MAPPING_INDEX.1),
|
||||
};
|
||||
let count = device.sdo_read::<u8>(index.0, index.1).await?;
|
||||
|
||||
for i in 0..count {
|
||||
vec.push(bit_offset);
|
||||
let pdo_index = device.sdo_read(index.0, start_subindex + i).await?;
|
||||
if pdo_index != 0 {
|
||||
let pdo_map_count = device.sdo_read::<u8>(pdo_index, 0).await?;
|
||||
// Iterate over every PDO Mapped entry and add it to the cumulative bit offset
|
||||
for j in 0..pdo_map_count {
|
||||
let pdo_mapping: u32 = device.sdo_read(pdo_index, 1 + j).await?;
|
||||
// We only need / Want the bit len, which we extract with a bitmask extracting the lsb
|
||||
let bit_length = (pdo_mapping & 0xFF) as u8;
|
||||
bit_offset += bit_length as usize;
|
||||
}
|
||||
}
|
||||
let count_mappings = device.sdo_read::<u8>(index.0, index.1).await?;
|
||||
let pdo_index = device.sdo_read::<u16>(index.0, 1).await?;
|
||||
let pdo_map_count = device.sdo_read::<u8>(pdo_index, 0).await?;
|
||||
|
||||
for i in 0..pdo_map_count {
|
||||
let pdo_mapping: u32 = device.sdo_read(pdo_index, 1 + i).await?;
|
||||
let bit_length = (pdo_mapping & 0xFF) as u8;
|
||||
bit_offset += bit_length as usize;
|
||||
}
|
||||
|
||||
let mut mappings_without_coupler: Vec<u32> = vec![];
|
||||
for i in start_subindex..=count_mappings {
|
||||
let pdo_index = device.sdo_read(index.0, i).await?;
|
||||
let pdo_map_count = device.sdo_read::<u8>(pdo_index, 0).await?;
|
||||
for j in 0..pdo_map_count {
|
||||
let pdo_mapping: u32 = device.sdo_read(pdo_index, 1 + j).await?;
|
||||
mappings_without_coupler.push(pdo_mapping);
|
||||
}
|
||||
}
|
||||
mappings_without_coupler.sort();
|
||||
|
||||
for pdo_mapping in mappings_without_coupler {
|
||||
module_i = Wago750_354::calculate_module_index(pdo_mapping, get_tx);
|
||||
let bit_length = (pdo_mapping & 0xFF) as u8;
|
||||
if module_i < 64 {
|
||||
vec.push(ModulePdoMapping {
|
||||
offset: bit_offset,
|
||||
module_i,
|
||||
});
|
||||
}
|
||||
bit_offset += bit_length as usize;
|
||||
}
|
||||
|
||||
vec.sort_by_key(|e| (e.module_i, e.offset));
|
||||
// deduplicate by module_i, so we only have the offset to the start of inputs/outputs
|
||||
vec.dedup_by(|a, b| a.module_i == b.module_i);
|
||||
|
||||
if get_tx {
|
||||
self.tx_offsets = vec;
|
||||
self.tx_pdo_mappings = vec;
|
||||
} else {
|
||||
self.rx_offsets = vec;
|
||||
self.rx_pdo_mappings = vec;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -222,6 +271,7 @@ impl Wago750_354 {
|
||||
has_rx: false,
|
||||
tx_offset: 0,
|
||||
rx_offset: 0,
|
||||
name: "".to_string(),
|
||||
};
|
||||
|
||||
match ident_iom {
|
||||
@@ -229,26 +279,42 @@ impl Wago750_354 {
|
||||
WAGO_750_1506_PRODUCT_ID => {
|
||||
module.has_tx = true;
|
||||
module.has_rx = true;
|
||||
module.name = "750-1506".to_string();
|
||||
}
|
||||
WAGO_750_455_PRODUCT_ID => {
|
||||
module.has_tx = true;
|
||||
module.has_rx = false;
|
||||
module.name = "750-455".to_string();
|
||||
}
|
||||
WAGO_750_501_PRODUCT_ID => {
|
||||
module.has_tx = false;
|
||||
module.has_rx = true;
|
||||
module.name = "750-501".to_string();
|
||||
}
|
||||
WAGO_750_530_PRODUCT_ID => {
|
||||
module.has_tx = false;
|
||||
module.has_rx = true;
|
||||
module.name = "750-530".to_string();
|
||||
}
|
||||
WAGO_750_652_PRODUCT_ID => {
|
||||
module.has_tx = true;
|
||||
module.has_rx = true;
|
||||
module.name = "750-652".to_string();
|
||||
}
|
||||
WAGO_750_402_PRODUCT_ID => {
|
||||
module.has_tx = true;
|
||||
module.has_rx = false;
|
||||
module.name = "750-402".to_string();
|
||||
}
|
||||
WAGO_750_430_PRODUCT_ID => {
|
||||
module.has_tx = true;
|
||||
module.has_rx = false;
|
||||
module.name = "750-430".to_string();
|
||||
}
|
||||
WAGO_750_672_PRODUCT_ID => {
|
||||
module.has_tx = true;
|
||||
module.has_rx = true;
|
||||
module.name = "750-672".to_string();
|
||||
}
|
||||
_ => println!(
|
||||
"Wago-750-354 found Unknown/Unimplemented Module: {}",
|
||||
@@ -266,17 +332,42 @@ impl Wago750_354 {
|
||||
if self.dev_count != 0 {
|
||||
return;
|
||||
}
|
||||
let mut tx_index = 1;
|
||||
let mut rx_index = 1;
|
||||
|
||||
smol::block_on(async {
|
||||
let _ = self.get_pdo_offsets(device, true).await;
|
||||
let _ = self.get_pdo_offsets(device, false).await;
|
||||
});
|
||||
|
||||
println!("{:?}\n\n{:?}", self.tx_offsets, self.rx_offsets);
|
||||
for module in &mut self.slots {
|
||||
match module {
|
||||
Some(m) => {
|
||||
let tx_pdo_mapping = self
|
||||
.tx_pdo_mappings
|
||||
.iter()
|
||||
.find(|map| map.module_i == m.slot.into());
|
||||
if m.has_tx {
|
||||
m.tx_offset = match tx_pdo_mapping {
|
||||
Some(map) => map.offset,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
for module in self.slots {
|
||||
let rx_pdo_mapping = self
|
||||
.rx_pdo_mappings
|
||||
.iter()
|
||||
.find(|map| map.module_i == m.slot.into());
|
||||
if m.has_rx {
|
||||
m.rx_offset = match rx_pdo_mapping {
|
||||
Some(map) => map.offset,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
for module in &self.slots {
|
||||
match module {
|
||||
Some(m) => {
|
||||
// Map ModuleIdent's to Terminals
|
||||
@@ -302,6 +393,12 @@ impl Wago750_354 {
|
||||
WAGO_750_402_MODULE_IDENT => {
|
||||
Arc::new(RwLock::new(wago_750_402::Wago750_402::new()))
|
||||
}
|
||||
WAGO_750_430_MODULE_IDENT => {
|
||||
Arc::new(RwLock::new(wago_750_430::Wago750_430::new()))
|
||||
}
|
||||
WAGO_750_672_MODULE_IDENT => {
|
||||
Arc::new(RwLock::new(wago_750_672::Wago750_672::new()))
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
"{} Missing Implementation for Module Identification: vendor_id: {:?}, module ident: {:?} !",
|
||||
@@ -312,27 +409,10 @@ impl Wago750_354 {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let tx_pdo_offset = self.tx_offsets.get(tx_index as usize);
|
||||
if m.has_tx {
|
||||
tx_index += 1;
|
||||
}
|
||||
|
||||
let rx_pdo_offset = self.rx_offsets.get(rx_index as usize);
|
||||
if m.has_rx {
|
||||
rx_index += 1;
|
||||
}
|
||||
|
||||
let mut dev_guard = dev.write_blocking();
|
||||
match tx_pdo_offset {
|
||||
Some(offset) => dev_guard.set_tx_offset(*offset),
|
||||
None => (),
|
||||
}
|
||||
|
||||
match rx_pdo_offset {
|
||||
Some(offset) => dev_guard.set_rx_offset(*offset),
|
||||
None => (),
|
||||
}
|
||||
//println!("For {:?} setting tx: {} rx: {}",m.name,m.tx_offset,m.rx_offset);
|
||||
dev_guard.set_tx_offset(m.tx_offset);
|
||||
dev_guard.set_rx_offset(m.rx_offset);
|
||||
drop(dev_guard);
|
||||
self.slot_devices[self.dev_count] = Some(dev);
|
||||
self.dev_count += 1;
|
||||
|
||||
@@ -214,7 +214,7 @@ impl EthercatDevice for IP20EcDi8Do8 {
|
||||
}
|
||||
|
||||
fn set_module(&mut self, module: Module) {
|
||||
self.slots[self.module_count] = Some(module);
|
||||
self.slots[self.module_count] = Some(module.clone());
|
||||
self.module_count += 1;
|
||||
self.tx_size += module.tx_offset;
|
||||
self.rx_size += module.rx_offset;
|
||||
@@ -378,6 +378,7 @@ impl IP20EcDi8Do8 {
|
||||
has_rx: false,
|
||||
tx_offset: 0,
|
||||
rx_offset: 0,
|
||||
name: "".to_string(),
|
||||
};
|
||||
|
||||
match ident_iom {
|
||||
@@ -415,7 +416,7 @@ impl IP20EcDi8Do8 {
|
||||
let _ = self.get_pdo_offsets(device, false).await;
|
||||
});
|
||||
|
||||
for module in self.slots {
|
||||
for module in &self.slots {
|
||||
match module {
|
||||
Some(m) => {
|
||||
// Map ModuleIdent's to Terminals
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod ip20_ec_di8_do8;
|
||||
pub mod wago_750_1506;
|
||||
pub mod wago_750_402;
|
||||
pub mod wago_750_430;
|
||||
pub mod wago_750_455;
|
||||
pub mod wago_750_501;
|
||||
pub mod wago_750_530;
|
||||
pub mod wago_750_652;
|
||||
pub mod wago_750_672;
|
||||