Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea4e0f78c6 | ||
|
|
c85a4d3ebe | ||
|
|
635c08cfc5 | ||
|
|
1ecdf33d29 | ||
|
|
d88a87c8a2 | ||
|
|
94950c3c38 | ||
|
|
ebc6cb9c40 | ||
|
|
921f0fa445 | ||
|
|
2ab668b2f1 | ||
|
|
3a9801b97c | ||
|
|
612b6ecb25 | ||
|
|
677f51ae33 | ||
|
|
bf28b41afd | ||
|
|
723072c5bc | ||
|
|
3813ce70bf | ||
|
|
b523b3de21 | ||
|
|
09b1eb9433 | ||
|
|
c7feef6bcd | ||
|
|
84d2766ca9 | ||
|
|
b4d2974f45 | ||
|
|
082d5dc952 | ||
|
|
55af1120bf | ||
|
|
35864b4de7 | ||
|
|
621cb1913f | ||
|
|
3e3e3f1c0b | ||
|
|
cdf0374020 | ||
|
|
ec406889f7 | ||
|
|
b731a3bc36 | ||
|
|
27e15820f7 | ||
|
|
bb307bb9d7 | ||
|
|
39b05b056f | ||
|
|
8449045efb | ||
|
|
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 | ||
|
|
9a4d41bf48 | ||
|
|
cbffeff52d | ||
|
|
9d5e5cc7d0 | ||
|
|
c82d7347e2 | ||
|
|
06a2284145 | ||
|
|
1b8b56fd02 | ||
|
|
fef727983e | ||
|
|
40239e0463 | ||
|
|
9c579a9fd4 | ||
|
|
a3a343bbb8 | ||
|
|
373d97d12d | ||
|
|
e1842fa9d5 | ||
|
|
4fd8ddef51 |
No files matched your search
@@ -0,0 +1,14 @@
|
||||
## What does this PR do / add / change?
|
||||
|
||||
<!-- Describe the purpose of this PR. What problem does it solve or what feature does it add? -->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- Include screenshots for any UI changes. Remove this section if not applicable. -->
|
||||
|
||||
## Checklist before opening the pr
|
||||
|
||||
- [ ] Tested locally
|
||||
- [ ] Tested on the machine (if hardware interaction is involved)
|
||||
- [ ] No format errors (`cargo fmt` / `npm run format`)
|
||||
- [ ] No build errors (`cargo build` / `npm run build`)
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: electron
|
||||
|
||||
@@ -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}`);
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NIX_PATH: nixpkgs=channel:nixos-25.05
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_VERSION: "1.86"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
target/
|
||||
dist/
|
||||
.DS_Store
|
||||
Cargo.lock
|
||||
dhat-heap.json
|
||||
build.sh
|
||||
shell.nix
|
||||
node_modules/
|
||||
runtime_metrics.csv
|
||||
compile_metrics.csv
|
||||
.idea/
|
||||
.idea/
|
||||
electron/dist-electron/
|
||||
@@ -22,7 +22,7 @@ QiTech Control combines the modularity and reliability of standard EtherCAT term
|
||||
|
||||
| Software Demo | Full Explainer |
|
||||
|---------------|----------------|
|
||||
| [](https://www.youtube.com/watch?v=KI3YeBwfV-s)<br/>*Click to watch a demo of the control software in action* | [](https://youtu.be/55egCAkQgyM)<br/>*Click to watch a complete overview of QiTech Control* |
|
||||
| [](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* |
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -63,6 +63,15 @@ QiTech Control powers 10+ production machines for filament extrusion, winding, m
|
||||
|
||||
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.
|
||||
@@ -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: 2.2 MiB |
|
After Width: | Height: | Size: 687 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 985 KiB |
@@ -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.
|
||||
@@ -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,8 +1,8 @@
|
||||
# REST API `/api/v2`
|
||||
|
||||
The Qitech Control Panel exposes a small HTTP interface for discovering machines and reading their current values. All examples below assume you are connected to the panel’s Ethernet subnet and talk directly to the panel at:
|
||||
The panel behaves like any other NetworkManager-managed linux and is able to either receive an IP-Address over DHCP or a statically defined one. Where the static IP is most likely the easiest to work with.
|
||||
|
||||
- **Base URL:** `http://10.10.10.1:3001`
|
||||
- **Base URL:** `https://IP_ADDRESS`
|
||||
|
||||
> **Schema note:** This documentation intentionally stays light on field details beyond what is shown in the examples. For the exact datatypes and complete payload shapes, refer to the corresponding Rust types (linked below).
|
||||
|
||||
@@ -10,17 +10,57 @@ The Qitech Control Panel exposes a small HTTP interface for discovering machines
|
||||
|
||||
## Authentication
|
||||
|
||||
The panel does **not** perform HTTP authentication. The expected security model is **network-level isolation**: anything that can send packets to the panel’s Ethernet interface is treated as trusted. This matches common security assumptions in EtherCAT-style control networks. It is therefore suggested to run the entire production line in isolated operation.
|
||||
The panel performs Basic-Auth authentication.
|
||||
For EVERY request made over HTTPS, Basic Auth is required.
|
||||
|
||||
The panel is configured to administer its own subnet `10.10.10.0/24` via DHCP, while Wi-Fi can be used for upstream internet connectivity. To let the outside world communicate with the production line, a router should be placed at the network bondary bridging the production line's subnet and the rest of the network. The router can run in **client/bridge mode** on the line's subnet and expose the panel's API via **port forwading** or through a **reverse proxy** where one can add authentication, logging, rate limiting, etc.
|
||||
To set the password on the Panel for Basic Auth, do the following:
|
||||
1. Open a Terminal
|
||||
2. cd /home/qitech/control
|
||||
3. ./credentials.sh
|
||||
|
||||
The following graphic depicts the suggested network topology for a single production line. Many lines could potentially share the same router.
|
||||
The credentials.sh script sets and prints the username and password for the api ONCE in the console.
|
||||
Afterwards its accessible in machine_password in /tmp/ until the machine shuts down.
|
||||
If you ever forget your password you can simply run ./credentials.sh again, resetting the password used, however the username will always be machine
|
||||
|
||||
<p align="center">
|
||||
<img width="769" height="528" alt="Suggested network topology" src="./assets/subnet.png" />
|
||||
</p>
|
||||
Example request with curl:
|
||||
```
|
||||
# username=machine
|
||||
# password=VoMZKZK4K4ZIvON+OU2ZcCXy
|
||||
# With -k you can ignore curl's self signed certificate error
|
||||
curl https://192.168.130.195/api/v2/machine -k -u machine:VoMZKZK4K4ZIvON+OU2ZcCXy
|
||||
```
|
||||
|
||||
|
||||
## Handling Self signed Certificate Warnings
|
||||
|
||||
For HTTPS we use self-signed certificates.
|
||||
These are untrusted by default in browsers,tools like curl and https libraries.
|
||||
|
||||
For most programs you can either ignore this warning or you can manually trust the certificate on your system.
|
||||
How these are installed depends on your system.
|
||||
|
||||
On the panel pc, the root Certificate is located at: /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt
|
||||
|
||||
You need to copy this file to an usb drive or copy it some other way and add it as a trusted root certificate in your OS.
|
||||
|
||||
Please refer to your OS' documentation or search the Internet about how to install a trusted SSL certificate.
|
||||
|
||||
|
||||
## Bypassing HTTPS and Authentication
|
||||
If you have an isolated network and dont wish to perform Authentication, then you can achieve this by:
|
||||
1. Forking our repository
|
||||
2. Changing the Open Ports in nixos/os/configuration.nix:
|
||||
```
|
||||
# All the way at the Bottom
|
||||
networking.firewall.allowedUDPPorts = [ 53 67 69 ];
|
||||
# Change this
|
||||
networking.firewall.allowedTCPPorts = [ 443 ];
|
||||
# To this
|
||||
networking.firewall.allowedTCPPorts = [ 3001 ];
|
||||
```
|
||||
|
||||
Again, you should only ever do this if you have your own reverse proxy with authentication or you have an isolated network.
|
||||
|
||||
If DNS is available on the subnet, you may be able to resolve `qitech.control`; otherwise use the static address `10.10.10.1`.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +78,7 @@ Each machine also includes a `legacy_id` to support older **v1** workflows. If a
|
||||
### Example request
|
||||
|
||||
```bash
|
||||
curl -X GET "http://10.10.10.1:3001/api/v2/machine"
|
||||
curl -X GET "https://IP_ADDRESS/api/v2/machine" -u machine:YOUR_PASSWORD
|
||||
```
|
||||
|
||||
### Example response
|
||||
@@ -119,7 +159,7 @@ To receive continuous updates (via WebSockets), subscribe to the machine namespa
|
||||
### Example request (mock machine)
|
||||
|
||||
```bash
|
||||
curl -X GET "http://10.10.10.1:3001/api/v2/machine/mock/57922"
|
||||
curl -X GET "https://IP_ADDRESS/api/v2/machine/mock/57922" -u machine:YOUR_PASSWORD
|
||||
```
|
||||
|
||||
### Example response
|
||||
@@ -174,7 +214,7 @@ The API does **not** return the newly-applied state in the POST response. The pa
|
||||
curl -X POST \
|
||||
-d \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://10.10.10.1:3001/api/v1/machine/mock/57922"
|
||||
"https://IP_ADDRESS/api/v1/machine/mock/57922" -u machine:YOUR_PASSWORD
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-virtualized": "^9.22.2",
|
||||
"electron": "^37.3.1",
|
||||
"electron": "^40.6.1",
|
||||
"electron-playwright-helpers": "^1.7.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
@@ -162,7 +162,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -659,7 +658,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -683,7 +681,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3138,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",
|
||||
@@ -3290,7 +3227,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.1.tgz",
|
||||
"integrity": "sha512-pLQ6ZFCh5s86ewZIAu2wQc2svf+DqttD7CFd1NPSxdEU20KvMMj9RPQdDHj/pbf2VvB3i1nEDxjli/Z0PBvnCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/history": "1.141.0",
|
||||
"@tanstack/react-store": "^0.8.0",
|
||||
@@ -3381,7 +3317,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.141.1.tgz",
|
||||
"integrity": "sha512-fR1GGpp6v3dVKu4KIAjEh+Sd0qGLQd/wvCOVHeopSY6aFidXKCzwrS5cBOBqoPPWTKmn6CdW1a0CzFr5Furdog==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/history": "1.141.0",
|
||||
"@tanstack/store": "^0.8.0",
|
||||
@@ -3602,7 +3537,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -3768,7 +3704,6 @@
|
||||
"integrity": "sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -3785,7 +3720,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -3796,7 +3730,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -3884,7 +3817,6 @@
|
||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
@@ -4239,7 +4171,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4303,6 +4234,7 @@
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -4617,7 +4549,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4973,8 +4904,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
@@ -5237,7 +5167,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
@@ -5265,15 +5196,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "37.10.3",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-37.10.3.tgz",
|
||||
"integrity": "sha512-3IjCGSjQmH50IbW2PFveaTzK+KwcFX9PEhE7KXb9v5IT8cLAiryAN7qezm/XzODhDRlLu0xKG1j8xWBtZ/bx/g==",
|
||||
"version": "40.6.1",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-40.6.1.tgz",
|
||||
"integrity": "sha512-u9YfoixttdauciHV9Ut9Zf3YipJoU093kR1GSYTTXTAXqhiXI0G1A0NnL/f0O2m2UULCXaXMf2W71PloR6V9pQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^22.7.7",
|
||||
"@types/node": "^24.9.0",
|
||||
"extract-zip": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
@@ -5354,16 +5285,6 @@
|
||||
"global-agent": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/@types/node": {
|
||||
"version": "22.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
|
||||
"integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
@@ -5399,13 +5320,6 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron/node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
@@ -5783,7 +5697,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -5844,7 +5757,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -5946,7 +5858,6 @@
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -6905,7 +6816,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -6939,7 +6849,6 @@
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
@@ -7542,7 +7451,6 @@
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -8069,6 +7977,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -9489,7 +9398,6 @@
|
||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -9606,6 +9514,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -9621,6 +9530,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -9710,7 +9620,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -9720,7 +9629,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -9733,7 +9641,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz",
|
||||
"integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -9777,7 +9684,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
@@ -10291,7 +10199,6 @@
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz",
|
||||
"integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -10833,8 +10740,7 @@
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
@@ -10926,7 +10832,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11170,7 +11075,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11507,7 +11411,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -11597,8 +11500,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
||||
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite/node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
@@ -11636,7 +11538,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12076,7 +11977,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-virtualized": "^9.22.2",
|
||||
"electron": "^37.3.1",
|
||||
"electron": "^40.6.1",
|
||||
"electron-playwright-helpers": "^1.7.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { TouchButton } from "@/components/touch/TouchButton";
|
||||
import { TimeInput } from "@/components/time/TimeInput";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Icon } from "@/components/Icon";
|
||||
|
||||
type AddMarkerDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAddMarker: (name: string, timestamp: number, color?: string) => void;
|
||||
currentTimestamp: number | null;
|
||||
defaultName?: string;
|
||||
// Existing marker names to prevent duplicates (case-insensitive)
|
||||
existingNames?: string[];
|
||||
};
|
||||
|
||||
export function AddMarkerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAddMarker,
|
||||
currentTimestamp,
|
||||
defaultName = "",
|
||||
existingNames = [],
|
||||
}: AddMarkerDialogProps) {
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [color, setColor] = useState("#000000");
|
||||
// Shown when user tries to add a marker whose name already exists
|
||||
const [duplicateNameError, setDuplicateNameError] = useState(false);
|
||||
|
||||
// Reset form only when dialog opens or closes; do not reset when currentTimestamp
|
||||
// updates while open (e.g. from graph) or we overwrite the user's time input
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(defaultName);
|
||||
setSelectedTimestamp(currentTimestamp ?? Date.now());
|
||||
setDuplicateNameError(false);
|
||||
} else {
|
||||
setName("");
|
||||
setSelectedTimestamp(null);
|
||||
setDuplicateNameError(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
// Reject duplicate names (compare trimmed, case-insensitive)
|
||||
const trimmedName = name.trim();
|
||||
const isDuplicate = existingNames.some(
|
||||
(existing) => existing.trim().toLowerCase() === trimmedName.toLowerCase(),
|
||||
);
|
||||
if (isDuplicate) {
|
||||
setDuplicateNameError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setDuplicateNameError(false);
|
||||
|
||||
// Use selected time if set, else context/graph time, else now
|
||||
const timestamp = selectedTimestamp ?? currentTimestamp ?? Date.now();
|
||||
if (!timestamp) return;
|
||||
|
||||
onAddMarker(trimmedName, timestamp, color);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex flex-row items-center gap-2">
|
||||
<Icon name="lu:Bookmark" />
|
||||
Add Marker
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a marker for all graphs of this machine at the current time.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Name Input */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="marker-name">Marker Name</Label>
|
||||
<Input
|
||||
id="marker-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setDuplicateNameError(false);
|
||||
}}
|
||||
placeholder="Enter marker name"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleAdd();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{duplicateNameError && (
|
||||
<p className="text-destructive text-sm">
|
||||
A marker with this name already exists.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time Input (optional) */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Time (optional)</Label>
|
||||
<TimeInput
|
||||
timestamp={selectedTimestamp}
|
||||
onTimeChange={setSelectedTimestamp}
|
||||
onClear={() =>
|
||||
setSelectedTimestamp(currentTimestamp ?? Date.now())
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Leave empty to use current time
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Color Input */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="marker-color">Color</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="marker-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="border-input h-9 w-20 cursor-pointer rounded-md border"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<div className="flex flex-row gap-4">
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
icon="lu:X"
|
||||
className="h-21 flex-1"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Abort
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
className="h-21 flex-1 flex-shrink-0"
|
||||
onClick={handleAdd}
|
||||
icon="lu:Bookmark"
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
Add Marker
|
||||
</TouchButton>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { TouchButton } from "../touch/TouchButton";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { getAllTimeSeries } from "./createChart";
|
||||
import { TargetDashOverlay } from "./TargetDashOverlay";
|
||||
|
||||
// Collects lines connected to visible series for rendering
|
||||
const getVisibleLines = (data: any, visibleSeries: boolean[]) => {
|
||||
@@ -92,6 +93,7 @@ export function BigGraph({
|
||||
config,
|
||||
graphId,
|
||||
syncGraph,
|
||||
uplotRefOut,
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
}: BigGraphProps & {
|
||||
@@ -456,6 +458,7 @@ export function BigGraph({
|
||||
useBigGraphEffects({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
uplotRefOut,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
lastProcessedCountRef,
|
||||
@@ -494,8 +497,8 @@ export function BigGraph({
|
||||
return (
|
||||
<div className="h-[50vh] w-full">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-3xl border border-gray-200 bg-white shadow">
|
||||
<div className="flex items-center justify-between pt-4 pr-5 pb-6 pl-6">
|
||||
<div className="mt-1 flex items-center gap-4">
|
||||
<div className="flex shrink-0 items-center justify-between gap-4 pt-4 pr-5 pb-6 pl-6">
|
||||
<div className="mt-1 flex min-w-0 flex-1 items-center gap-4">
|
||||
<Icon
|
||||
name={unit ? getUnitIcon(unit) : "lu:TrendingUp"}
|
||||
className="size-6 text-gray-600"
|
||||
@@ -506,9 +509,9 @@ export function BigGraph({
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex shrink-0 items-center gap-4">
|
||||
{normalizedSeries.length === 1 && (
|
||||
<ControlCard className="rounded-md px-4 py-3">
|
||||
<ControlCard className="rounded-lg border-gray-200/80 bg-gray-50/80 px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-base text-gray-600">
|
||||
<span className="font-mono leading-none font-bold text-gray-900">
|
||||
{formatDisplayValue(displayValue, renderValue)}
|
||||
@@ -595,10 +598,18 @@ export function BigGraph({
|
||||
|
||||
<div className="flex-1 overflow-hidden rounded-b-3xl pt-4">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`h-full w-full overflow-hidden transition-opacity duration-100`}
|
||||
className={`relative h-full w-full overflow-hidden transition-opacity duration-100`}
|
||||
style={{ backgroundColor: colors.background }}
|
||||
/>
|
||||
>
|
||||
<div ref={containerRef} className="h-full w-full overflow-hidden" />
|
||||
<TargetDashOverlay
|
||||
uplotRef={uplotRef}
|
||||
newData={newData}
|
||||
config={enhancedConfig}
|
||||
selectedTimeWindow={selectedTimeWindow}
|
||||
isLiveMode={isLiveMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ export function GraphControls({
|
||||
onSwitchToLive,
|
||||
onSwitchToHistorical,
|
||||
onExport,
|
||||
onAddMarker,
|
||||
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
||||
showFromTimestamp,
|
||||
onShowFromChange,
|
||||
@@ -80,7 +81,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
|
||||
@@ -102,16 +103,27 @@ export function GraphControls({
|
||||
Live
|
||||
</TouchButton>
|
||||
|
||||
{onExport && (
|
||||
{(onExport || onAddMarker) && (
|
||||
<>
|
||||
<div className="mx-2 h-8 w-px bg-gray-200"></div>
|
||||
<TouchButton
|
||||
onClick={onExport}
|
||||
variant="outline"
|
||||
className="h-auto bg-green-600 px-3 py-3 text-base font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
Export
|
||||
</TouchButton>
|
||||
{onAddMarker && (
|
||||
<TouchButton
|
||||
onClick={onAddMarker}
|
||||
variant="outline"
|
||||
className="h-auto bg-blue-600 px-3 py-3 text-base font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Add Marker
|
||||
</TouchButton>
|
||||
)}
|
||||
{onExport && (
|
||||
<TouchButton
|
||||
onClick={onExport}
|
||||
variant="outline"
|
||||
className="h-auto bg-green-600 px-3 py-3 text-base font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
Export
|
||||
</TouchButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -127,6 +139,7 @@ export function FloatingControlPanel({
|
||||
onSwitchToLive,
|
||||
onSwitchToHistorical,
|
||||
onExport,
|
||||
onAddMarker,
|
||||
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
||||
showFromTimestamp,
|
||||
onShowFromChange,
|
||||
@@ -197,7 +210,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
|
||||
@@ -218,10 +231,19 @@ export function FloatingControlPanel({
|
||||
>
|
||||
Live
|
||||
</TouchButton>
|
||||
{isExpanded && onExport && (
|
||||
{isExpanded && (onExport || onAddMarker) && (
|
||||
<div className="h-8 w-px bg-gray-200"></div>
|
||||
)}
|
||||
{onExport && (
|
||||
{isExpanded && onAddMarker && (
|
||||
<TouchButton
|
||||
onClick={onAddMarker}
|
||||
variant="outline"
|
||||
className="h-auto bg-blue-600 px-3 py-3 text-base font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Add Marker
|
||||
</TouchButton>
|
||||
)}
|
||||
{isExpanded && onExport && (
|
||||
<TouchButton
|
||||
onClick={onExport}
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import type uPlot from "uplot";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
} from "@/components/graph";
|
||||
import { TimeSeries, TimeSeriesValue } from "@/lib/timeseries";
|
||||
import { Unit } from "@/control/units";
|
||||
import { useMarkerManager } from "./useMarkerManager";
|
||||
import { useMarkerContext } from "./MarkerContext";
|
||||
|
||||
type TimeSeriesData = {
|
||||
newData: TimeSeries | null;
|
||||
title?: string;
|
||||
color?: string;
|
||||
lines?: any[];
|
||||
};
|
||||
|
||||
type GraphWithMarkerControlsProps = {
|
||||
syncHook: ReturnType<typeof useGraphSync>;
|
||||
newData: TimeSeriesData | TimeSeriesData[];
|
||||
config: GraphConfig;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
graphId: string;
|
||||
currentTimeSeries: TimeSeries | null;
|
||||
machineId?: string;
|
||||
markers?: Array<{
|
||||
timestamp: number;
|
||||
name: string;
|
||||
value?: number;
|
||||
color?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const MARKER_HIT_WIDTH = 28;
|
||||
|
||||
const LABEL_TOP_OFFSET = 30;
|
||||
|
||||
function applyLabelHighlight(label: HTMLDivElement, highlighted: boolean) {
|
||||
if (highlighted) {
|
||||
label.style.zIndex = "20";
|
||||
label.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
|
||||
label.style.borderColor = "rgb(59 130 246)";
|
||||
} else {
|
||||
label.style.zIndex = "";
|
||||
label.style.boxShadow = "0 2px 6px rgba(0,0,0,0.15)";
|
||||
label.style.borderColor = "rgba(0,0,0,0.12)";
|
||||
}
|
||||
}
|
||||
|
||||
function unhighlightAllLabels(container: HTMLElement) {
|
||||
container.querySelectorAll<HTMLDivElement>(".marker-label").forEach((el) => {
|
||||
applyLabelHighlight(el, false);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply or restore overlay styles (avoids React Compiler "mutates immutable" on inline DOM writes)
|
||||
function setOverlayOverflow(
|
||||
container: HTMLElement,
|
||||
parent: HTMLElement | null,
|
||||
visible: boolean,
|
||||
previous: { overflow: string; position: string; parentOverflow: string },
|
||||
) {
|
||||
if (visible) {
|
||||
container.style.overflow = "visible";
|
||||
container.style.position = "relative";
|
||||
if (parent) parent.style.overflow = "visible";
|
||||
} else {
|
||||
container.style.overflow = previous.overflow;
|
||||
container.style.position = previous.position;
|
||||
if (parent) parent.style.overflow = previous.parentOverflow;
|
||||
}
|
||||
}
|
||||
|
||||
// Duration (ms) to hold pointer on marker before it is deleted
|
||||
const LONG_PRESS_DELETE_MS = 5000;
|
||||
|
||||
// Build marker DOM: one wrapper (hit area) for hover/tap, contains line, point, label.
|
||||
// Optional onLongPress: called after LONG_PRESS_DELETE_MS hold (e.g. to delete marker).
|
||||
function createMarkerElement(
|
||||
u: uPlot,
|
||||
overlayRect: DOMRect,
|
||||
timestamp: number,
|
||||
name: string,
|
||||
value: number,
|
||||
color?: string,
|
||||
onLongPress?: () => void,
|
||||
): HTMLDivElement {
|
||||
const plotRect = u.rect;
|
||||
const plotLeftInOverlay = plotRect.left - overlayRect.left;
|
||||
const plotTopInOverlay = plotRect.top - overlayRect.top;
|
||||
const plotHeight = plotRect.height;
|
||||
const plotWidth = plotRect.width;
|
||||
|
||||
let xInPlot = u.valToPos(timestamp, "x", false);
|
||||
xInPlot = Math.max(0, Math.min(plotWidth, xInPlot));
|
||||
const xPos = plotLeftInOverlay + xInPlot;
|
||||
let yInPlot = u.valToPos(value, "y", false);
|
||||
yInPlot = Math.max(0, Math.min(plotHeight, yInPlot));
|
||||
|
||||
const lineColor = color || "rgba(0, 0, 0, 0.5)";
|
||||
const pointColor = color || "rgba(0, 0, 0, 0.8)";
|
||||
const half = MARKER_HIT_WIDTH / 2;
|
||||
|
||||
const wrapperTop = plotTopInOverlay - LABEL_TOP_OFFSET;
|
||||
const plotStartInWrapper = LABEL_TOP_OFFSET;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.position = "absolute";
|
||||
wrapper.style.left = `${xPos - half}px`;
|
||||
wrapper.style.top = `${wrapperTop}px`;
|
||||
wrapper.style.width = `${MARKER_HIT_WIDTH}px`;
|
||||
wrapper.style.height = `${plotHeight + 50 + plotStartInWrapper}px`;
|
||||
wrapper.style.cursor = "pointer";
|
||||
wrapper.style.touchAction = "manipulation";
|
||||
wrapper.style.zIndex = "10";
|
||||
wrapper.className = "marker-wrapper";
|
||||
|
||||
const line = document.createElement("div");
|
||||
line.style.position = "absolute";
|
||||
line.style.left = `${half - 1}px`;
|
||||
line.style.top = `${plotStartInWrapper}px`;
|
||||
line.style.height = `${plotHeight}px`;
|
||||
line.style.width = "2px";
|
||||
line.style.background = lineColor;
|
||||
line.style.pointerEvents = "none";
|
||||
line.title = name;
|
||||
line.className = "vertical-marker";
|
||||
|
||||
const point = document.createElement("div");
|
||||
point.style.position = "absolute";
|
||||
point.style.left = `${half}px`;
|
||||
point.style.top = `${plotStartInWrapper + yInPlot}px`;
|
||||
point.style.width = "8px";
|
||||
point.style.height = "8px";
|
||||
point.style.borderRadius = "50%";
|
||||
point.style.background = pointColor;
|
||||
point.style.transform = "translate(-50%, -50%)";
|
||||
point.style.border = "2px solid white";
|
||||
point.style.pointerEvents = "none";
|
||||
point.title = name;
|
||||
point.className = "marker-point";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.textContent = name;
|
||||
label.title = name;
|
||||
label.style.position = "absolute";
|
||||
label.style.left = `${half}px`;
|
||||
label.style.top = "6px";
|
||||
label.style.transform = "translateX(-50%)";
|
||||
label.style.color = "rgb(30 41 59)";
|
||||
label.style.padding = "4px 8px";
|
||||
label.style.fontSize = "12px";
|
||||
label.style.fontWeight = "600";
|
||||
label.style.whiteSpace = "nowrap";
|
||||
label.style.maxWidth = "160px";
|
||||
label.style.overflow = "hidden";
|
||||
label.style.textOverflow = "ellipsis";
|
||||
label.style.background = "rgba(255, 255, 255, 1)";
|
||||
label.style.borderRadius = "6px";
|
||||
label.style.boxShadow = "0 2px 8px rgba(0,0,0,0.2)";
|
||||
label.style.border = "1px solid rgba(0,0,0,0.2)";
|
||||
label.style.pointerEvents = "none";
|
||||
label.style.transition = "box-shadow 0.15s ease, border-color 0.15s ease";
|
||||
label.style.zIndex = "1";
|
||||
label.className = "marker-label";
|
||||
|
||||
const highlight = () => applyLabelHighlight(label, true);
|
||||
const unhighlight = () => applyLabelHighlight(label, false);
|
||||
|
||||
wrapper.addEventListener("mouseenter", highlight);
|
||||
wrapper.addEventListener("mouseleave", unhighlight);
|
||||
wrapper.addEventListener("click", () => highlight());
|
||||
|
||||
// Long-press: hold 5s on marker to delete it (timer on pointerdown, clear on up/leave/cancel)
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const clearLongPressTimer = () => {
|
||||
if (longPressTimer !== null) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
};
|
||||
wrapper.addEventListener("pointerdown", () => {
|
||||
if (!onLongPress) return;
|
||||
clearLongPressTimer();
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = null;
|
||||
onLongPress(); // removes this marker
|
||||
}, LONG_PRESS_DELETE_MS);
|
||||
});
|
||||
wrapper.addEventListener("pointerup", clearLongPressTimer);
|
||||
wrapper.addEventListener("pointerleave", clearLongPressTimer);
|
||||
wrapper.addEventListener("pointercancel", clearLongPressTimer);
|
||||
|
||||
wrapper.appendChild(line);
|
||||
wrapper.appendChild(point);
|
||||
wrapper.appendChild(label);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function GraphWithMarkerControlsContent({
|
||||
syncHook,
|
||||
newData,
|
||||
config,
|
||||
unit,
|
||||
renderValue,
|
||||
graphId,
|
||||
currentTimeSeries,
|
||||
machineId: providedMachineId,
|
||||
markers: providedMarkers,
|
||||
graphWrapperRef,
|
||||
uplotRefOut,
|
||||
}: GraphWithMarkerControlsProps & {
|
||||
graphWrapperRef: React.RefObject<HTMLDivElement | null>;
|
||||
uplotRefOut: React.MutableRefObject<uPlot | null>;
|
||||
}) {
|
||||
const { setMachineId, setCurrentTimestamp, setCurrentValue } =
|
||||
useMarkerContext();
|
||||
|
||||
// Auto-detect machineId from graphId if not provided (extract base name)
|
||||
// e.g., "pressure-graph" -> "pressure", "extruder-graphs" -> "extruder-graphs"
|
||||
const machineId = providedMachineId || graphId.split("-")[0] || "default";
|
||||
|
||||
// Update context with machineId, current timestamp and value for marker creation
|
||||
useEffect(() => {
|
||||
setMachineId(machineId);
|
||||
}, [machineId, setMachineId]);
|
||||
|
||||
useEffect(() => {
|
||||
const curr = currentTimeSeries?.current;
|
||||
if (curr?.timestamp != null) {
|
||||
setCurrentTimestamp(curr.timestamp);
|
||||
setCurrentValue(curr.value);
|
||||
} else {
|
||||
setCurrentValue(null);
|
||||
}
|
||||
}, [
|
||||
currentTimeSeries?.current?.timestamp,
|
||||
currentTimeSeries?.current?.value,
|
||||
setCurrentTimestamp,
|
||||
setCurrentValue,
|
||||
]);
|
||||
|
||||
// Use provided markers or load from marker manager
|
||||
const markerManager = useMarkerManager(machineId);
|
||||
const markers = providedMarkers || markerManager.markers;
|
||||
|
||||
// Time Tick for forcing marker redraw
|
||||
const [timeTick, setTimeTick] = useState(0);
|
||||
|
||||
// Set interval to force redraw the marker effect frequently (e.g., every 50ms)
|
||||
useEffect(() => {
|
||||
if (!currentTimeSeries?.current) return;
|
||||
const intervalId = setInterval(() => {
|
||||
setTimeTick((prev) => prev + 1);
|
||||
}, 50);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [currentTimeSeries?.current]);
|
||||
|
||||
// Marker Drawing Effect: use uPlot instance for exact valToPos so point sits on the curve
|
||||
useEffect(() => {
|
||||
const u = uplotRefOut.current;
|
||||
if (!u?.root?.parentElement) return;
|
||||
|
||||
u.syncRect();
|
||||
|
||||
const overlayContainer = u.root.parentElement;
|
||||
const overlayParent = overlayContainer.parentElement;
|
||||
|
||||
const previous = {
|
||||
overflow: overlayContainer.style.overflow,
|
||||
position: overlayContainer.style.position,
|
||||
parentOverflow: overlayParent?.style.overflow ?? "",
|
||||
};
|
||||
setOverlayOverflow(overlayContainer, overlayParent, true, previous);
|
||||
|
||||
const overlayRect = overlayContainer.getBoundingClientRect();
|
||||
|
||||
// Only show markers within the visible time window (saved markers stay in store for export)
|
||||
const xMin = u.scales.x?.min ?? -Infinity;
|
||||
const xMax = u.scales.x?.max ?? Infinity;
|
||||
const visibleMarkers = markers.filter(
|
||||
(m) => m.timestamp >= xMin && m.timestamp <= xMax,
|
||||
);
|
||||
|
||||
const wrappers: HTMLDivElement[] = visibleMarkers.map(
|
||||
({ timestamp, name, value, color }) => {
|
||||
let markerValue = value;
|
||||
if (markerValue === undefined && currentTimeSeries) {
|
||||
const validValues = currentTimeSeries.long.values.filter(
|
||||
(v): v is TimeSeriesValue => v !== null,
|
||||
);
|
||||
if (validValues.length > 0) {
|
||||
// Use linear interpolation between surrounding points for stable positioning
|
||||
// (avoids "closest" flipping between adjacent points which causes jumping)
|
||||
const sorted = [...validValues].sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
const after = sorted.find((p) => p.timestamp >= timestamp);
|
||||
const before = [...sorted]
|
||||
.reverse()
|
||||
.find((p) => p.timestamp <= timestamp);
|
||||
if (after && before) {
|
||||
if (after.timestamp === before.timestamp) {
|
||||
markerValue = after.value;
|
||||
} else {
|
||||
const t =
|
||||
(timestamp - before.timestamp) /
|
||||
(after.timestamp - before.timestamp);
|
||||
markerValue = before.value + t * (after.value - before.value);
|
||||
}
|
||||
} else if (after) {
|
||||
markerValue = after.value;
|
||||
} else if (before) {
|
||||
markerValue = before.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (markerValue === undefined) {
|
||||
const yScale = u.scales.y;
|
||||
markerValue =
|
||||
yScale?.min != null && yScale?.max != null
|
||||
? (yScale.min + yScale.max) / 2
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Pass remove callback so long-press (5s) on this marker deletes it
|
||||
return createMarkerElement(
|
||||
u,
|
||||
overlayRect,
|
||||
timestamp,
|
||||
name,
|
||||
markerValue,
|
||||
color,
|
||||
() => markerManager.removeMarker(timestamp),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
overlayContainer
|
||||
.querySelectorAll(".marker-wrapper")
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
wrappers.forEach((wrapper) => overlayContainer.appendChild(wrapper));
|
||||
|
||||
const onOverlayClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest(".marker-wrapper")) {
|
||||
unhighlightAllLabels(overlayContainer);
|
||||
}
|
||||
};
|
||||
overlayContainer.addEventListener("click", onOverlayClick, true);
|
||||
|
||||
return () => {
|
||||
overlayContainer.removeEventListener("click", onOverlayClick, true);
|
||||
setOverlayOverflow(overlayContainer, overlayParent, false, previous);
|
||||
};
|
||||
}, [markers, currentTimeSeries, timeTick, uplotRefOut, graphWrapperRef]);
|
||||
|
||||
// Use original config without adding marker lines (markers are overlay elements)
|
||||
const finalConfig = config;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div ref={graphWrapperRef} className="relative">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={newData}
|
||||
config={finalConfig}
|
||||
unit={unit}
|
||||
renderValue={renderValue}
|
||||
graphId={graphId}
|
||||
uplotRefOut={uplotRefOut}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphWithMarkerControls(props: GraphWithMarkerControlsProps) {
|
||||
const graphWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const uplotRefOut = useRef<uPlot | null>(null);
|
||||
|
||||
return (
|
||||
<GraphWithMarkerControlsContent
|
||||
{...props}
|
||||
graphWrapperRef={graphWrapperRef}
|
||||
uplotRefOut={uplotRefOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
|
||||
type MarkerContextType = {
|
||||
machineId: string | null;
|
||||
setMachineId: (id: string) => void;
|
||||
currentTimestamp: number | null;
|
||||
setCurrentTimestamp: (timestamp: number) => void;
|
||||
currentValue: number | null;
|
||||
setCurrentValue: (value: number | null) => void;
|
||||
};
|
||||
|
||||
const MarkerContext = createContext<MarkerContextType | null>(null);
|
||||
|
||||
export function MarkerProvider({ children }: { children: React.ReactNode }) {
|
||||
const [machineId, setMachineId] = useState<string | null>(null);
|
||||
const [currentTimestamp, setCurrentTimestamp] = useState<number | null>(null);
|
||||
const [currentValue, setCurrentValue] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<MarkerContext.Provider
|
||||
value={{
|
||||
machineId,
|
||||
setMachineId,
|
||||
currentTimestamp,
|
||||
setCurrentTimestamp,
|
||||
currentValue,
|
||||
setCurrentValue,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MarkerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMarkerContext() {
|
||||
const context = useContext(MarkerContext);
|
||||
// Return a default context if not within a provider (for non-graph pages)
|
||||
if (!context) {
|
||||
return {
|
||||
machineId: null,
|
||||
setMachineId: () => {},
|
||||
currentTimestamp: null,
|
||||
setCurrentTimestamp: () => {},
|
||||
currentValue: null,
|
||||
setCurrentValue: () => {},
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { BigGraph } from "./BigGraph";
|
||||
import { GraphControls, FloatingControlPanel } from "./GraphControls";
|
||||
import { useGraphSync } from "./useGraphSync";
|
||||
import { BigGraphProps, PropGraphSync, TimeWindowOption } from "./types";
|
||||
import { GraphExportData } from "./excelExport";
|
||||
import { useMarkerManager } from "./useMarkerManager";
|
||||
import { AddMarkerDialog } from "./AddMarkerDialog";
|
||||
import { useMarkerContext } from "./MarkerContext";
|
||||
|
||||
export function SyncedBigGraph({
|
||||
syncGraph: externalSyncGraph,
|
||||
@@ -50,21 +53,71 @@ export function SyncedGraphControls({
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncedFloatingControlPanel({
|
||||
function SyncedFloatingControlPanelInner({
|
||||
controlProps,
|
||||
timeWindowOptions,
|
||||
machineId: machineIdProp,
|
||||
...props
|
||||
}: {
|
||||
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
machineId?: string;
|
||||
}) {
|
||||
const defaultSync = useGraphSync();
|
||||
const finalProps = controlProps || defaultSync.controlProps;
|
||||
const {
|
||||
machineId: machineIdContext,
|
||||
currentTimestamp,
|
||||
currentValue,
|
||||
} = useMarkerContext();
|
||||
|
||||
// Prefer explicit prop so panel and graphs always use the same store
|
||||
const detectedMachineId = machineIdProp ?? machineIdContext ?? "default";
|
||||
const { addMarker, markers } = useMarkerManager(detectedMachineId);
|
||||
const [isMarkerDialogOpen, setIsMarkerDialogOpen] = useState(false);
|
||||
|
||||
// Always use current timestamp from context (live time from graphs) or current time
|
||||
// As per requirement: "always use the current time"
|
||||
const markerTimestamp = currentTimestamp || Date.now();
|
||||
|
||||
const handleAddMarker = (name: string, timestamp: number, color?: string) => {
|
||||
addMarker(name, timestamp, color, currentValue ?? undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<FloatingControlPanel
|
||||
{...finalProps}
|
||||
<>
|
||||
<FloatingControlPanel
|
||||
{...finalProps}
|
||||
timeWindowOptions={timeWindowOptions}
|
||||
onAddMarker={() => setIsMarkerDialogOpen(true)}
|
||||
{...props}
|
||||
/>
|
||||
<AddMarkerDialog
|
||||
open={isMarkerDialogOpen}
|
||||
onOpenChange={setIsMarkerDialogOpen}
|
||||
onAddMarker={handleAddMarker}
|
||||
currentTimestamp={markerTimestamp}
|
||||
existingNames={markers.map((m) => m.name)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncedFloatingControlPanel({
|
||||
controlProps,
|
||||
timeWindowOptions,
|
||||
machineId,
|
||||
...props
|
||||
}: {
|
||||
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
machineId?: string;
|
||||
}) {
|
||||
return (
|
||||
<SyncedFloatingControlPanelInner
|
||||
controlProps={controlProps}
|
||||
timeWindowOptions={timeWindowOptions}
|
||||
machineId={machineId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import React, { useEffect, useMemo, useState, useId } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { BigGraphProps, GraphConfig } from "./types";
|
||||
import { getAllTimeSeries } from "./createChart";
|
||||
|
||||
type OverlayLine = {
|
||||
key: string;
|
||||
color: string;
|
||||
width: number;
|
||||
dash: number[];
|
||||
d: string;
|
||||
};
|
||||
|
||||
type ClipRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function 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;
|
||||
}
|
||||
|
||||
function 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;
|
||||
}
|
||||
|
||||
function buildSteppedPath(
|
||||
u: uPlot,
|
||||
xData: number[],
|
||||
yData: Array<number | null>,
|
||||
): string {
|
||||
const xMin = u.scales.x?.min;
|
||||
const xMax = u.scales.x?.max;
|
||||
|
||||
if (xData.length < 2 || xMin === undefined || xMax === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const firstVisibleIdx = Math.max(0, findFirstGe(xData, xMin) - 1);
|
||||
const lastVisibleIdx = Math.min(
|
||||
xData.length - 1,
|
||||
findLastLe(xData, xMax) + 1,
|
||||
);
|
||||
|
||||
if (lastVisibleIdx < firstVisibleIdx) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
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 = u.valToPos(xData[i], "x", true);
|
||||
const y = u.valToPos(value, "y", true);
|
||||
|
||||
if (!started) {
|
||||
parts.push(`M ${x} ${y}`);
|
||||
started = true;
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (x !== prevX) {
|
||||
parts.push(`L ${x} ${prevY}`);
|
||||
}
|
||||
if (y !== prevY) {
|
||||
parts.push(`L ${x} ${y}`);
|
||||
}
|
||||
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function buildFlatLatestValuePath(
|
||||
u: uPlot,
|
||||
yData: Array<number | null>,
|
||||
): string {
|
||||
let latestValue: number | null = null;
|
||||
|
||||
for (let i = yData.length - 1; i >= 0; i--) {
|
||||
const value = yData[i];
|
||||
if (value !== null && value !== undefined) {
|
||||
latestValue = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (latestValue === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const y = u.valToPos(latestValue, "y", true);
|
||||
const left = u.bbox.left;
|
||||
const right = u.bbox.left + u.bbox.width;
|
||||
|
||||
return `M ${left} ${y} L ${right} ${y}`;
|
||||
}
|
||||
|
||||
function getHistoricalDashTargets(
|
||||
data: BigGraphProps["newData"],
|
||||
config: GraphConfig,
|
||||
): Array<{
|
||||
dataIndex: number;
|
||||
dash: number[];
|
||||
color: string;
|
||||
width: number;
|
||||
}> {
|
||||
const allOriginalSeries = getAllTimeSeries(data);
|
||||
const firstConfigLineDataIndex = 1 + allOriginalSeries.length;
|
||||
let visibleLineIndex = 0;
|
||||
|
||||
const targets: Array<{
|
||||
dataIndex: number;
|
||||
dash: number[];
|
||||
color: string;
|
||||
width: number;
|
||||
}> = [];
|
||||
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show === false) return;
|
||||
|
||||
const dash = line.dash ?? (line.type === "threshold" ? [5, 5] : undefined);
|
||||
const isHistoricalDashedTarget = !!line.targetSeries && !!dash?.length;
|
||||
|
||||
if (isHistoricalDashedTarget) {
|
||||
targets.push({
|
||||
dataIndex: firstConfigLineDataIndex + visibleLineIndex,
|
||||
dash: dash!,
|
||||
color: line.color,
|
||||
width: line.width ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
visibleLineIndex++;
|
||||
});
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function areDashArraysEqual(a: number[], b: number[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function areOverlayLinesEqual(a: OverlayLine[], b: OverlayLine[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (
|
||||
a[i].key !== b[i].key ||
|
||||
a[i].color !== b[i].color ||
|
||||
a[i].width !== b[i].width ||
|
||||
a[i].d !== b[i].d ||
|
||||
!areDashArraysEqual(a[i].dash, b[i].dash)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSameClipRect(a: ClipRect | null, b: ClipRect): boolean {
|
||||
return (
|
||||
!!a &&
|
||||
a.x === b.x &&
|
||||
a.y === b.y &&
|
||||
a.width === b.width &&
|
||||
a.height === b.height
|
||||
);
|
||||
}
|
||||
|
||||
export function TargetDashOverlay({
|
||||
uplotRef,
|
||||
newData,
|
||||
config,
|
||||
selectedTimeWindow,
|
||||
isLiveMode,
|
||||
}: {
|
||||
uplotRef: React.RefObject<uPlot | null>;
|
||||
newData: BigGraphProps["newData"];
|
||||
config: GraphConfig;
|
||||
selectedTimeWindow: number | "all";
|
||||
isLiveMode: boolean;
|
||||
}) {
|
||||
const targetMeta = useMemo(
|
||||
() => getHistoricalDashTargets(newData, config),
|
||||
[newData, config],
|
||||
);
|
||||
const clipPathId = useId().replace(/:/g, "");
|
||||
|
||||
const [lines, setLines] = useState<OverlayLine[]>([]);
|
||||
const [clipRect, setClipRect] = useState<ClipRect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetMeta.length === 0) {
|
||||
setLines([]);
|
||||
setClipRect(null);
|
||||
}
|
||||
}, [targetMeta]);
|
||||
|
||||
useEffect(() => {
|
||||
let rafId: number | null = null;
|
||||
let bootstrapRafId: number | null = null;
|
||||
let recalcScheduled = false;
|
||||
let hooksAttached = false;
|
||||
const removeHookFns: Array<() => void> = [];
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const recalc = () => {
|
||||
recalcScheduled = false;
|
||||
const u = uplotRef.current;
|
||||
if (!u || targetMeta.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const xData = u.data[0] as number[] | undefined;
|
||||
if (!xData || xData.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLines = targetMeta
|
||||
.map((meta, index) => {
|
||||
const yData = u.data[meta.dataIndex] as
|
||||
| Array<number | null>
|
||||
| undefined;
|
||||
if (!yData || yData.length < 2) return null;
|
||||
|
||||
const useFlatShortWindowPath =
|
||||
isLiveMode &&
|
||||
selectedTimeWindow !== "all" &&
|
||||
selectedTimeWindow <= 60_000;
|
||||
|
||||
const d = useFlatShortWindowPath
|
||||
? buildFlatLatestValuePath(u, yData)
|
||||
: buildSteppedPath(u, xData, yData);
|
||||
if (!d) return null;
|
||||
|
||||
return {
|
||||
key: `${meta.dataIndex}-${index}`,
|
||||
color: meta.color,
|
||||
width: meta.width,
|
||||
dash: meta.dash,
|
||||
d,
|
||||
} as OverlayLine;
|
||||
})
|
||||
.filter((line): line is OverlayLine => !!line);
|
||||
|
||||
if (nextLines.length > 0) {
|
||||
setLines((prev) =>
|
||||
areOverlayLinesEqual(prev, nextLines) ? prev : nextLines,
|
||||
);
|
||||
const nextClipRect: ClipRect = {
|
||||
x: u.bbox.left,
|
||||
y: u.bbox.top,
|
||||
width: u.bbox.width,
|
||||
height: u.bbox.height,
|
||||
};
|
||||
setClipRect((prev) =>
|
||||
isSameClipRect(prev, nextClipRect) ? prev : nextClipRect,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRecalc = () => {
|
||||
if (recalcScheduled) return;
|
||||
recalcScheduled = true;
|
||||
rafId = window.requestAnimationFrame(recalc);
|
||||
};
|
||||
|
||||
scheduleRecalc();
|
||||
|
||||
if (targetMeta.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addHook = (u: any, hookName: string, fn: () => void) => {
|
||||
const hooks = u.hooks?.[hookName];
|
||||
if (!Array.isArray(hooks)) return;
|
||||
hooks.push(fn);
|
||||
removeHookFns.push(() => {
|
||||
const idx = hooks.indexOf(fn);
|
||||
if (idx >= 0) hooks.splice(idx, 1);
|
||||
});
|
||||
};
|
||||
|
||||
const onWindowResize = () => scheduleRecalc();
|
||||
window.addEventListener("resize", onWindowResize);
|
||||
|
||||
const attachHooks = (): boolean => {
|
||||
if (hooksAttached) return true;
|
||||
const u = uplotRef.current as any;
|
||||
if (!u) return false;
|
||||
|
||||
addHook(u, "draw", scheduleRecalc);
|
||||
addHook(u, "setScale", scheduleRecalc);
|
||||
addHook(u, "setData", scheduleRecalc);
|
||||
|
||||
const rootElement = u.root as HTMLElement | undefined;
|
||||
if (rootElement && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(() => scheduleRecalc());
|
||||
resizeObserver.observe(rootElement);
|
||||
}
|
||||
|
||||
hooksAttached = true;
|
||||
scheduleRecalc();
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!attachHooks()) {
|
||||
const tryAttach = () => {
|
||||
if (attachHooks()) return;
|
||||
bootstrapRafId = window.requestAnimationFrame(tryAttach);
|
||||
};
|
||||
bootstrapRafId = window.requestAnimationFrame(tryAttach);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
if (bootstrapRafId !== null) {
|
||||
window.cancelAnimationFrame(bootstrapRafId);
|
||||
}
|
||||
removeHookFns.forEach((fn) => fn());
|
||||
window.removeEventListener("resize", onWindowResize);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [uplotRef, targetMeta, isLiveMode, selectedTimeWindow]);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
aria-hidden
|
||||
>
|
||||
{clipRect && (
|
||||
<defs>
|
||||
<clipPath id={clipPathId}>
|
||||
<rect
|
||||
x={clipRect.x}
|
||||
y={clipRect.y}
|
||||
width={clipRect.width}
|
||||
height={clipRect.height}
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
)}
|
||||
|
||||
<g clipPath={clipRect ? `url(#${clipPathId})` : undefined}>
|
||||
{lines.map((line) => {
|
||||
return (
|
||||
<path
|
||||
key={line.key}
|
||||
d={line.d}
|
||||
fill="none"
|
||||
stroke={line.color}
|
||||
strokeWidth={line.width}
|
||||
strokeLinecap="butt"
|
||||
strokeDasharray={line.dash.join(" ")}
|
||||
strokeDashoffset={0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -11,6 +15,7 @@ import { normalizeDataSeries } from "./animation";
|
||||
export function createChart({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
uplotRefOut,
|
||||
newData,
|
||||
config,
|
||||
colors,
|
||||
@@ -59,7 +64,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);
|
||||
}
|
||||
});
|
||||
@@ -169,11 +180,16 @@ export function createChart({
|
||||
// Add config lines
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
const dash =
|
||||
line.dash ?? (line.type === "threshold" ? [5, 5] : undefined);
|
||||
const isHistoricalDashedTarget = !!line.targetSeries && !!dash?.length;
|
||||
|
||||
seriesConfig.push({
|
||||
label: line.label,
|
||||
stroke: line.color,
|
||||
// Historical dashed targets are rendered in an SVG overlay.
|
||||
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 },
|
||||
});
|
||||
@@ -182,6 +198,9 @@ export function createChart({
|
||||
|
||||
// Always destroy existing chart before creating new one
|
||||
if (uplotRef.current) {
|
||||
if (uplotRefOut?.current != null) {
|
||||
uplotRefOut.current = null;
|
||||
}
|
||||
uplotRef.current.destroy();
|
||||
uplotRef.current = null;
|
||||
}
|
||||
@@ -483,6 +502,10 @@ export function createChart({
|
||||
containerRef.current,
|
||||
);
|
||||
|
||||
if (uplotRefOut) {
|
||||
uplotRefOut.current = uplotRef.current;
|
||||
}
|
||||
|
||||
// Create handler callbacks
|
||||
const handlerCallbacks: HandlerCallbacks = {
|
||||
updateYAxisScale,
|
||||
@@ -512,7 +535,9 @@ export function createChart({
|
||||
values: [...primaryValues],
|
||||
};
|
||||
|
||||
return cleanup;
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to get all valid TimeSeries from DataSeries
|
||||
|
||||
@@ -84,6 +84,27 @@ export function exportGraphsToExcel(
|
||||
XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName);
|
||||
}
|
||||
|
||||
// Excel worksheet for timestamps and timestamp markers
|
||||
if (graphLineData.targetLines.length > 0) {
|
||||
const markerReportData =
|
||||
createGraphLineMarkerReportSheet(graphLineData);
|
||||
const markerReportWorksheet = XLSX.utils.aoa_to_sheet(markerReportData);
|
||||
// Set column widths here (e.g., Column A = 15, Column B = 25)
|
||||
markerReportWorksheet["!cols"] = [
|
||||
{ wch: 20 }, // Column A (Labels: 'Timestamp', 'Value', etc.)
|
||||
{ wch: 30 }, // Column B (Values, where the Date object resides)
|
||||
];
|
||||
const markerReportSheetName = generateUniqueSheetName(
|
||||
`${seriesTitle} Marker Report`,
|
||||
usedSheetNames,
|
||||
);
|
||||
XLSX.utils.book_append_sheet(
|
||||
workbook,
|
||||
markerReportWorksheet,
|
||||
markerReportSheetName,
|
||||
);
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
});
|
||||
|
||||
@@ -290,6 +311,119 @@ function createGraphLineDataSheet(graphLine: {
|
||||
});
|
||||
}
|
||||
|
||||
function createGraphLineMarkerReportSheet(graphLine: {
|
||||
graphTitle: string;
|
||||
lineTitle: string;
|
||||
series: TimeSeries;
|
||||
color?: string;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
config: GraphConfig;
|
||||
targetLines: GraphLine[];
|
||||
}): any[][] {
|
||||
const [timestamps, values] = seriesToUPlotData(graphLine.series.long);
|
||||
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
|
||||
// Initialize Report Data and Header
|
||||
const reportData: any[][] = [
|
||||
[`Marker Report: ${graphLine.lineTitle}`],
|
||||
["Graph", graphLine.graphTitle],
|
||||
["Line Name", graphLine.lineTitle],
|
||||
["", ""],
|
||||
["--- Data Point Marker Status ---", ""],
|
||||
["", ""],
|
||||
];
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
reportData.push(["No data points to report"]);
|
||||
return reportData;
|
||||
}
|
||||
|
||||
// Filter User Markers
|
||||
const allTargetLines = graphLine.targetLines.filter(
|
||||
(line) => line.show !== false,
|
||||
);
|
||||
const userMarkers = allTargetLines.filter(
|
||||
(line) => line.type === "user_marker" && line.label,
|
||||
);
|
||||
|
||||
// Map Markers to Closest Data Point Index
|
||||
const markerIndexMap = new Map<
|
||||
number,
|
||||
{ label: string; originalTimestamp: number }
|
||||
>();
|
||||
|
||||
userMarkers.forEach((line) => {
|
||||
const markerTime = line.markerTimestamp || line.value; // Use the correct high-precision timestamp
|
||||
let closestDataPointIndex = -1;
|
||||
let minTimeDifference = Infinity;
|
||||
|
||||
// Find the data point with the closest timestamp
|
||||
timestamps.forEach((ts, index) => {
|
||||
const difference = Math.abs(ts - markerTime);
|
||||
if (difference < minTimeDifference) {
|
||||
minTimeDifference = difference;
|
||||
closestDataPointIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
// Store the marker data at the index of the closest data point
|
||||
if (closestDataPointIndex !== -1) {
|
||||
markerIndexMap.set(closestDataPointIndex, {
|
||||
label: line.label || "User Marker",
|
||||
originalTimestamp: markerTime,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add the final header before the timestamp report starts
|
||||
reportData.push(["--- BEGIN DETAILED REPORT ---", ""], ["", ""]);
|
||||
|
||||
// Handle case where no user markers were created
|
||||
if (userMarkers.length === 0) {
|
||||
reportData.push(["No user-created markers found.", ""]);
|
||||
}
|
||||
|
||||
timestamps.forEach((dataPointTimestamp, index) => {
|
||||
const value = values[index];
|
||||
const markerData = markerIndexMap.get(index);
|
||||
|
||||
let finalMarkerLabel = "";
|
||||
let timeToDisplay = dataPointTimestamp; // Default to data sample time
|
||||
|
||||
if (markerData) {
|
||||
finalMarkerLabel = `${markerData.label}`;
|
||||
timeToDisplay = markerData.originalTimestamp;
|
||||
}
|
||||
|
||||
// Format the time (using timeToDisplay)
|
||||
const formattedTime = new Date(timeToDisplay)
|
||||
.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
.replace(/ /g, "");
|
||||
|
||||
// Row 1: Timestamp
|
||||
reportData.push(["Timestamp", formattedTime]);
|
||||
|
||||
// Row 2: Value
|
||||
const formattedValue = graphLine.renderValue
|
||||
? graphLine.renderValue(value)
|
||||
: value?.toFixed(3) || "";
|
||||
reportData.push([`Value (${unitSymbol})`, formattedValue]);
|
||||
|
||||
// Row 3: Marker Name
|
||||
reportData.push(["Marker", finalMarkerLabel]);
|
||||
|
||||
// Separator
|
||||
reportData.push(["", ""]);
|
||||
});
|
||||
|
||||
return reportData;
|
||||
}
|
||||
|
||||
// Ensure sheet names are unique and valid for Excel
|
||||
function generateUniqueSheetName(
|
||||
name: string,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -10,6 +10,9 @@ export {
|
||||
AutoSyncedBigGraph,
|
||||
} from "./SyncedComponents";
|
||||
|
||||
// Context
|
||||
export { MarkerProvider } from "./MarkerContext";
|
||||
|
||||
// Hooks
|
||||
export { useGraphSync } from "./useGraphSync";
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type uPlot from "uplot";
|
||||
import { IconName } from "@/components/Icon";
|
||||
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 +14,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,
|
||||
@@ -25,13 +29,15 @@ export type PropGraphSync = {
|
||||
|
||||
// Configuration types for additional lines
|
||||
export type GraphLine = {
|
||||
type: "threshold" | "target";
|
||||
type: "threshold" | "target" | "user_marker"; // TODO: redundant or not?
|
||||
value: number;
|
||||
targetSeries?: TimeSeries;
|
||||
color: string;
|
||||
label?: string;
|
||||
width?: number;
|
||||
dash?: number[];
|
||||
show?: boolean;
|
||||
markerTimestamp?: number;
|
||||
};
|
||||
|
||||
export type GraphConfig = {
|
||||
@@ -71,6 +77,8 @@ export type BigGraphProps = {
|
||||
config: GraphConfig;
|
||||
graphId: string;
|
||||
syncGraph?: PropGraphSync;
|
||||
/** Optional ref to receive the uPlot instance when chart is created (e.g. for marker positioning) */
|
||||
uplotRefOut?: React.MutableRefObject<uPlot | null>;
|
||||
};
|
||||
|
||||
export type TimeWindowOption = {
|
||||
@@ -83,8 +91,9 @@ export type ControlProps = {
|
||||
isLiveMode: boolean;
|
||||
onTimeWindowChange: (timeWindow: number | "all") => void;
|
||||
onSwitchToLive: () => void;
|
||||
onSwitchToHistorical: () => void;
|
||||
onSwitchToHistorical: (origin: SwitchOrigin) => void;
|
||||
onExport?: () => void;
|
||||
onAddMarker?: () => void;
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
showFromTimestamp?: number | null;
|
||||
onShowFromChange?: (timestamp: number | null) => void;
|
||||
@@ -100,13 +109,15 @@ export interface HistoricalModeHandlers {
|
||||
captureHistoricalFreezeTimestamp: () => number;
|
||||
getHistoricalEndTimestamp: () => number;
|
||||
handleHistoricalTimeWindow: (timeWindow: number | "all") => void;
|
||||
switchToHistoricalMode: () => void;
|
||||
switchToHistoricalMode: (origin?: SwitchOrigin) => void;
|
||||
switchToLiveMode: () => void;
|
||||
}
|
||||
|
||||
export interface CreateChartParams {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
uplotRef: React.RefObject<uPlot | null>;
|
||||
/** Optional ref to expose uPlot instance (e.g. for marker overlay positioning) */
|
||||
uplotRefOut?: React.MutableRefObject<uPlot | null>;
|
||||
newData: BigGraphProps["newData"];
|
||||
config: BigGraphProps["config"];
|
||||
colors: {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/* eslint-disable react-compiler/react-compiler */
|
||||
import { useEffect, RefObject, useRef, useState } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { BigGraphProps, SeriesData, AnimationRefs, HandlerRefs } from "./types";
|
||||
@@ -13,6 +19,7 @@ interface UseBigGraphEffectsProps {
|
||||
// Refs
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
uplotRef: RefObject<uPlot | null>;
|
||||
uplotRefOut?: MutableRefObject<uPlot | null>;
|
||||
startTimeRef: RefObject<number | null>;
|
||||
manualScaleRef: RefObject<{
|
||||
x: { min: number; max: number };
|
||||
@@ -71,6 +78,7 @@ interface UseBigGraphEffectsProps {
|
||||
export function useBigGraphEffects({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
uplotRefOut,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
lastProcessedCountRef,
|
||||
@@ -175,7 +183,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();
|
||||
|
||||
@@ -257,6 +265,7 @@ export function useBigGraphEffects({
|
||||
const cleanup = createChart({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
uplotRefOut,
|
||||
newData,
|
||||
config,
|
||||
colors,
|
||||
@@ -291,6 +300,7 @@ export function useBigGraphEffects({
|
||||
cleanup?.();
|
||||
uplotRef.current?.destroy();
|
||||
uplotRef.current = null;
|
||||
if (uplotRefOut) uplotRefOut.current = null;
|
||||
stopAnimations(animationRefs);
|
||||
setIsChartCreated(false);
|
||||
chartCreatedRef.current = false;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMarkerStore, type Marker } from "@/stores/markerStore";
|
||||
|
||||
// Stable empty array to avoid "getSnapshot should be cached" infinite loop
|
||||
const EMPTY_MARKERS: Marker[] = [];
|
||||
|
||||
export function useMarkerManager(machineId: string) {
|
||||
const markers = useMarkerStore((state) => {
|
||||
const list = state.markersByMachine[machineId];
|
||||
return list ?? EMPTY_MARKERS;
|
||||
});
|
||||
const addMarkerAction = useMarkerStore((state) => state.addMarker);
|
||||
const removeMarkerAction = useMarkerStore((state) => state.removeMarker);
|
||||
const clearMarkersAction = useMarkerStore((state) => state.clearMarkers);
|
||||
|
||||
const addMarker = useCallback(
|
||||
(name: string, timestamp: number, color?: string, value?: number) => {
|
||||
addMarkerAction(machineId, { name, timestamp, color, value });
|
||||
return { name, timestamp, color, value } as Marker;
|
||||
},
|
||||
[machineId, addMarkerAction],
|
||||
);
|
||||
|
||||
const removeMarker = useCallback(
|
||||
(timestamp: number) => {
|
||||
removeMarkerAction(machineId, timestamp);
|
||||
},
|
||||
[machineId, removeMarkerAction],
|
||||
);
|
||||
|
||||
const clearMarkers = useCallback(() => {
|
||||
clearMarkersAction(machineId);
|
||||
}, [machineId, clearMarkersAction]);
|
||||
|
||||
return {
|
||||
markers,
|
||||
addMarker,
|
||||
removeMarker,
|
||||
clearMarkers,
|
||||
};
|
||||
}
|
||||
@@ -36,7 +36,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[100] bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -55,7 +55,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[100] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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,7 @@ type Props = {
|
||||
min: number;
|
||||
max: number;
|
||||
onChangeTargetTemp?: (temperature: number) => void;
|
||||
targetTemperatureEnabled: boolean;
|
||||
};
|
||||
export function HeatingZone({
|
||||
title,
|
||||
@@ -25,6 +26,7 @@ export function HeatingZone({
|
||||
heatingTimeSeries,
|
||||
heatingPower,
|
||||
onChangeTargetTemp,
|
||||
targetTemperatureEnabled,
|
||||
}: Props) {
|
||||
const targetTemperature = heatingState?.target_temperature ?? 150;
|
||||
|
||||
@@ -41,19 +43,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,7 +68,7 @@ export function HeatingZone({
|
||||
timeseries={heatingPower}
|
||||
/>
|
||||
|
||||
{heatingState?.wiring_error && (
|
||||
{heatingState?.wiring_error && targetTemperatureEnabled && (
|
||||
<StatusBadge variant="error">
|
||||
Cant Read Temperature! Check Temperature Sensor Wiring!
|
||||
</StatusBadge>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
MarkerProvider,
|
||||
SyncedFloatingControlPanel,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
} from "@/components/graph";
|
||||
import React from "react";
|
||||
import { useExtruder2 } from "./useExtruder";
|
||||
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
|
||||
|
||||
export function Extruder2GraphsPage() {
|
||||
const {
|
||||
@@ -24,6 +25,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 +57,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.nozzle.target_temperature,
|
||||
targetSeries: targetNozzleTemperature,
|
||||
color: "#ef4444",
|
||||
show: true,
|
||||
},
|
||||
@@ -69,6 +78,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.front.target_temperature,
|
||||
targetSeries: targetFrontTemperature,
|
||||
color: "#f59e0b",
|
||||
show: true,
|
||||
},
|
||||
@@ -89,6 +99,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.middle.target_temperature,
|
||||
targetSeries: targetMiddleTemperature,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
@@ -109,6 +120,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.back.target_temperature,
|
||||
targetSeries: targetBackTemperature,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
@@ -241,85 +253,102 @@ export function Extruder2GraphsPage() {
|
||||
|
||||
return (
|
||||
<Page className="pb-25">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: pressure,
|
||||
color: "#3b82f6",
|
||||
lines:
|
||||
state?.pressure_state.target_bar !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={pressureConfig}
|
||||
unit="bar"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="pressure-graph"
|
||||
/>
|
||||
<MarkerProvider>
|
||||
<div className="flex flex-col gap-4">
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: pressure,
|
||||
color: "#3b82f6",
|
||||
lines:
|
||||
state?.pressure_state.target_bar !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
targetSeries: targetPressure,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={pressureConfig}
|
||||
unit="bar"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="pressure-graph"
|
||||
currentTimeSeries={pressure}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={temperatureData}
|
||||
config={temperatureConfig}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-temperatures"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={temperatureData}
|
||||
config={temperatureConfig}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-temperatures"
|
||||
currentTimeSeries={nozzleTemperature}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={powerData}
|
||||
config={powerConfig}
|
||||
unit="W"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-power"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={powerData}
|
||||
config={powerConfig}
|
||||
unit="W"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-power"
|
||||
currentTimeSeries={combinedPower}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorCurrent,
|
||||
color: "#3b82f6",
|
||||
}}
|
||||
config={currentConfig}
|
||||
unit="A"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="motor-current"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorCurrent,
|
||||
color: "#3b82f6",
|
||||
}}
|
||||
config={currentConfig}
|
||||
unit="A"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="motor-current"
|
||||
currentTimeSeries={motorCurrent}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
lines:
|
||||
state?.screw_state.target_rpm !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={rpmConfig}
|
||||
unit="rpm"
|
||||
renderValue={(value) => value.toFixed(0)}
|
||||
graphId="rpm-graph"
|
||||
/>
|
||||
</div>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
lines:
|
||||
state?.screw_state.target_rpm !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
targetSeries: targetScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={rpmConfig}
|
||||
unit="rpm"
|
||||
renderValue={(value) => value.toFixed(0)}
|
||||
graphId="rpm-graph"
|
||||
currentTimeSeries={motorScrewRpm}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
<SyncedFloatingControlPanel
|
||||
controlProps={syncHook.controlProps}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
</MarkerProvider>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -68,10 +68,12 @@ 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;
|
||||
};
|
||||
@@ -87,6 +89,7 @@ export function Extruder3ControlPage() {
|
||||
onChangeTargetTemp={setFrontHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
targetTemperatureEnabled={true}
|
||||
/>
|
||||
<HeatingZone
|
||||
title={"Heating Middle"}
|
||||
@@ -96,6 +99,7 @@ export function Extruder3ControlPage() {
|
||||
onChangeTargetTemp={setMiddleHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
targetTemperatureEnabled={true}
|
||||
/>
|
||||
<HeatingZone
|
||||
title={"Heating Back"}
|
||||
@@ -105,6 +109,7 @@ export function Extruder3ControlPage() {
|
||||
onChangeTargetTemp={setBackHeatingTemperature}
|
||||
min={0}
|
||||
max={300}
|
||||
targetTemperatureEnabled={true}
|
||||
/>
|
||||
<HeatingZone
|
||||
title={"Heating Nozzle"}
|
||||
@@ -114,6 +119,10 @@ export function Extruder3ControlPage() {
|
||||
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 ? (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
MarkerProvider,
|
||||
SyncedFloatingControlPanel,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
} from "@/components/graph";
|
||||
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
|
||||
import React from "react";
|
||||
import { useExtruder3 } from "./useExtruder";
|
||||
|
||||
@@ -24,6 +25,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 +57,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.nozzle.target_temperature,
|
||||
targetSeries: targetNozzleTemperature,
|
||||
color: "#ef4444",
|
||||
show: true,
|
||||
},
|
||||
@@ -69,6 +78,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.front.target_temperature,
|
||||
targetSeries: targetFrontTemperature,
|
||||
color: "#f59e0b",
|
||||
show: true,
|
||||
},
|
||||
@@ -89,6 +99,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.middle.target_temperature,
|
||||
targetSeries: targetMiddleTemperature,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
@@ -109,6 +120,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.back.target_temperature,
|
||||
targetSeries: targetBackTemperature,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
@@ -241,85 +253,102 @@ export function Extruder3GraphsPage() {
|
||||
|
||||
return (
|
||||
<Page className="pb-25">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: pressure,
|
||||
color: "#3b82f6",
|
||||
lines:
|
||||
state?.pressure_state.target_bar !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={pressureConfig}
|
||||
unit="bar"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="pressure-graph"
|
||||
/>
|
||||
<MarkerProvider>
|
||||
<div className="flex flex-col gap-4">
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: pressure,
|
||||
color: "#3b82f6",
|
||||
lines:
|
||||
state?.pressure_state.target_bar !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
targetSeries: targetPressure,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={pressureConfig}
|
||||
unit="bar"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="pressure-graph"
|
||||
currentTimeSeries={pressure}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={temperatureData}
|
||||
config={temperatureConfig}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-temperatures"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={temperatureData}
|
||||
config={temperatureConfig}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-temperatures"
|
||||
currentTimeSeries={nozzleTemperature ?? null}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={powerData}
|
||||
config={powerConfig}
|
||||
unit="W"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-power"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={powerData}
|
||||
config={powerConfig}
|
||||
unit="W"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-power"
|
||||
currentTimeSeries={combinedPower ?? null}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorCurrent,
|
||||
color: "#3b82f6",
|
||||
}}
|
||||
config={currentConfig}
|
||||
unit="A"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="motor-current"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorCurrent,
|
||||
color: "#3b82f6",
|
||||
}}
|
||||
config={currentConfig}
|
||||
unit="A"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="motor-current"
|
||||
currentTimeSeries={motorCurrent}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
lines:
|
||||
state?.screw_state.target_rpm !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={rpmConfig}
|
||||
unit="rpm"
|
||||
renderValue={(value) => value.toFixed(0)}
|
||||
graphId="rpm-graph"
|
||||
/>
|
||||
</div>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
lines:
|
||||
state?.screw_state.target_rpm !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
targetSeries: targetScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={rpmConfig}
|
||||
unit="rpm"
|
||||
renderValue={(value) => value.toFixed(0)}
|
||||
graphId="rpm-graph"
|
||||
currentTimeSeries={motorScrewRpm}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
<SyncedFloatingControlPanel
|
||||
controlProps={syncHook.controlProps}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
</MarkerProvider>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export function Extruder3SettingsPage() {
|
||||
setPressurePidKi,
|
||||
setPressurePidKd,
|
||||
setTemperaturePidValue,
|
||||
setTemperatureTargetEnabled,
|
||||
} = useExtruder3();
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
@@ -68,6 +69,16 @@ export function Extruder3SettingsPage() {
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 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 +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 extruder3MessageHandler(
|
||||
store: StoreApi<Extruder3NamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<Extruder3NamespaceStore>,
|
||||
@@ -271,6 +302,17 @@ export function extruder3MessageHandler(
|
||||
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 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 +467,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,28 @@ export function useExtruder3() {
|
||||
);
|
||||
};
|
||||
|
||||
const setTemperatureTargetEnabled = (enabled: boolean) => {
|
||||
const res = enabled
|
||||
? true
|
||||
: confirm(
|
||||
"This will disable the ability to set a temperature target for the nozzle heating element and will any wiring error message. Only proceed if you understand the risks.",
|
||||
);
|
||||
|
||||
if (res) {
|
||||
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 +403,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({
|
||||
@@ -423,6 +457,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,6 +481,7 @@ export function useExtruder3() {
|
||||
setMiddleHeatingTemperature,
|
||||
setExtruderPressureLimit,
|
||||
setExtruderPressureLimitEnabled,
|
||||
setTemperatureTargetEnabled,
|
||||
setPressurePidKp,
|
||||
setPressurePidKi,
|
||||
setPressurePidKd,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -64,6 +64,7 @@ export type Laser1NamespaceStore = {
|
||||
x_diameter: TimeSeries;
|
||||
y_diameter: TimeSeries;
|
||||
roundness: TimeSeries;
|
||||
targetDiameter: TimeSeries;
|
||||
};
|
||||
|
||||
const { initialTimeSeries: diameter, insert: addDiameter } = createTimeSeries();
|
||||
@@ -73,6 +74,8 @@ const { initialTimeSeries: y_diameter, insert: addYDiameter } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: roundness, insert: addRoundness } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: targetDiameter, insert: addTargetDiameter } =
|
||||
createTimeSeries();
|
||||
|
||||
/**
|
||||
* Factory function to create a new Laser1 namespace store
|
||||
@@ -87,6 +90,7 @@ export const createLaser1NamespaceStore = (): StoreApi<Laser1NamespaceStore> =>
|
||||
x_diameter,
|
||||
y_diameter,
|
||||
roundness,
|
||||
targetDiameter,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -114,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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
MarkerProvider,
|
||||
SyncedFloatingControlPanel,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
import React from "react";
|
||||
import { useMock1 } from "./useMock";
|
||||
import { TimeSeriesValue, type Series, TimeSeries } from "@/lib/timeseries";
|
||||
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
|
||||
import { Unit } from "@/control/units";
|
||||
|
||||
export function Mock1GraphPage() {
|
||||
const { sineWaveSum } = useMock1();
|
||||
@@ -97,45 +100,58 @@ export function Mock1GraphPage() {
|
||||
|
||||
return (
|
||||
<Page className="pb-27">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={singleData}
|
||||
config={singleGraphConfig}
|
||||
unit={"mm"}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="single-graph1"
|
||||
/>
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={combinedData}
|
||||
config={{
|
||||
...config,
|
||||
title: "Combined Sine Waves",
|
||||
}}
|
||||
unit={"mm"}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="combined-graph"
|
||||
/>
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={singleData}
|
||||
config={singleGraphConfig}
|
||||
unit={"mm"}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="single-graph2"
|
||||
/>
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={singleData}
|
||||
config={singleGraphConfig}
|
||||
unit={"mm"}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="single-graph"
|
||||
/>
|
||||
</div>
|
||||
<MarkerProvider>
|
||||
<div className="flex flex-col gap-4">
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={singleData}
|
||||
config={singleGraphConfig}
|
||||
unit={"mm" as Unit}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="single-graph1"
|
||||
currentTimeSeries={sineWaveSum}
|
||||
machineId="mock-graphs"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={combinedData}
|
||||
config={{
|
||||
...config,
|
||||
title: "Combined Sine Waves",
|
||||
}}
|
||||
unit={"mm" as Unit}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="combined-graph"
|
||||
currentTimeSeries={sineWaveSum}
|
||||
machineId="mock-graphs"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={singleData}
|
||||
config={singleGraphConfig}
|
||||
unit={"mm" as Unit}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="single-graph2"
|
||||
currentTimeSeries={sineWaveSum}
|
||||
machineId="mock-graphs"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={singleData}
|
||||
config={singleGraphConfig}
|
||||
unit={"mm" as Unit}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="single-graph"
|
||||
currentTimeSeries={sineWaveSum}
|
||||
machineId="mock-graphs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
<SyncedFloatingControlPanel
|
||||
controlProps={syncHook.controlProps}
|
||||
machineId="mock-graphs"
|
||||
/>
|
||||
</MarkerProvider>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -390,6 +390,11 @@ export const aquapath1: MachineProperties = {
|
||||
product_id: 0x7d83052,
|
||||
revision: 0x110000,
|
||||
},
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0x7d83052,
|
||||
revision: 0x120000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -473,6 +478,30 @@ export const digitalInputTestMachine: MachineProperties = {
|
||||
},
|
||||
device_roles: [],
|
||||
};
|
||||
|
||||
export const wago8chDioTestMachine: MachineProperties = {
|
||||
name: "WAGO 8ch DIO Test",
|
||||
version: "V1",
|
||||
slug: "wago8chdiotestmachine",
|
||||
icon: "lu:Disc3",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x0041,
|
||||
},
|
||||
device_roles: [
|
||||
{
|
||||
role: 0,
|
||||
role_label: "Wago 750-354 Bus Coupler",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 0x21,
|
||||
product_id: 0x07500354,
|
||||
revision: 0x2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
export const wagoPower1: MachineProperties = {
|
||||
name: "WAGO Power",
|
||||
version: "V1",
|
||||
@@ -579,6 +608,29 @@ export const TestMotor: MachineProperties = {
|
||||
],
|
||||
};
|
||||
|
||||
export const wagoDoTestMachine: MachineProperties = {
|
||||
name: "Wago DO Test",
|
||||
version: "V1",
|
||||
slug: "wagodotestmachine",
|
||||
icon: "lu:ToggleRight",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x000e,
|
||||
},
|
||||
device_roles: [
|
||||
{
|
||||
role: 0,
|
||||
role_label: "Wago 750-354 Bus Coupler",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 0x21,
|
||||
product_id: 0x07500354,
|
||||
revision: 0x2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
export const wagoAiTestMachine: MachineProperties = {
|
||||
name: "Wago AI Test",
|
||||
version: "V1",
|
||||
@@ -603,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,
|
||||
@@ -616,8 +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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
MarkerProvider,
|
||||
SyncedFloatingControlPanel,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
@@ -11,6 +11,7 @@ import { useWinder2 } from "./useWinder";
|
||||
import { roundDegreesToDecimals, roundToDecimals } from "@/lib/decimal";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
import { Unit } from "@/control/units";
|
||||
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
|
||||
|
||||
export function Winder2GraphsPage() {
|
||||
const {
|
||||
@@ -19,6 +20,7 @@ export function Winder2GraphsPage() {
|
||||
traversePosition,
|
||||
tensionArmAngle,
|
||||
pullerSpeed,
|
||||
targetPullerSpeed,
|
||||
spoolProgress,
|
||||
} = useWinder2();
|
||||
|
||||
@@ -26,49 +28,55 @@ export function Winder2GraphsPage() {
|
||||
|
||||
return (
|
||||
<Page className="pb-27">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-4">
|
||||
<PullerSpeedGraph
|
||||
syncHook={syncHook}
|
||||
newData={pullerSpeed}
|
||||
targetSpeed={state?.puller_state?.target_speed}
|
||||
unit="m/min"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
/>
|
||||
<MarkerProvider>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-4">
|
||||
<PullerSpeedGraph
|
||||
syncHook={syncHook}
|
||||
newData={pullerSpeed}
|
||||
targetSeries={targetPullerSpeed}
|
||||
targetSpeed={state?.puller_state?.target_speed}
|
||||
unit="m/min"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
/>
|
||||
|
||||
<TensionArmAngleGraph
|
||||
syncHook={syncHook}
|
||||
newData={tensionArmAngle}
|
||||
unit="deg"
|
||||
renderValue={(value) => roundDegreesToDecimals(value, 0)}
|
||||
/>
|
||||
<TensionArmAngleGraph
|
||||
syncHook={syncHook}
|
||||
newData={tensionArmAngle}
|
||||
unit="deg"
|
||||
renderValue={(value) => roundDegreesToDecimals(value, 0)}
|
||||
/>
|
||||
|
||||
<SpoolRpmGraph
|
||||
syncHook={syncHook}
|
||||
newData={spoolRpm}
|
||||
unit="rpm"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
/>
|
||||
<SpoolRpmGraph
|
||||
syncHook={syncHook}
|
||||
newData={spoolRpm}
|
||||
unit="rpm"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
/>
|
||||
|
||||
<TraversePositionGraph
|
||||
syncHook={syncHook}
|
||||
newData={traversePosition}
|
||||
limitInner={state?.traverse_state?.limit_inner}
|
||||
limitOuter={state?.traverse_state?.limit_outer}
|
||||
unit="mm"
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
/>
|
||||
<TraversePositionGraph
|
||||
syncHook={syncHook}
|
||||
newData={traversePosition}
|
||||
limitInner={state?.traverse_state?.limit_inner}
|
||||
limitOuter={state?.traverse_state?.limit_outer}
|
||||
unit="mm"
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
/>
|
||||
|
||||
<SpoolProgressGraph
|
||||
syncHook={syncHook}
|
||||
newData={spoolProgress}
|
||||
unit="m"
|
||||
renderValue={(value) => roundToDecimals(value, 2)}
|
||||
/>
|
||||
<SpoolProgressGraph
|
||||
syncHook={syncHook}
|
||||
newData={spoolProgress}
|
||||
unit="m"
|
||||
renderValue={(value) => roundToDecimals(value, 2)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
<SyncedFloatingControlPanel
|
||||
controlProps={syncHook.controlProps}
|
||||
machineId="winder2"
|
||||
/>
|
||||
</MarkerProvider>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +104,7 @@ export function SpoolRpmGraph({
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData,
|
||||
@@ -106,6 +114,8 @@ export function SpoolRpmGraph({
|
||||
renderValue={renderValue}
|
||||
config={config}
|
||||
graphId="spool-rpm"
|
||||
currentTimeSeries={newData}
|
||||
machineId="winder2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -155,7 +165,7 @@ export function TraversePositionGraph({
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData,
|
||||
@@ -166,6 +176,8 @@ export function TraversePositionGraph({
|
||||
renderValue={renderValue}
|
||||
config={config}
|
||||
graphId="traverse-position"
|
||||
currentTimeSeries={newData}
|
||||
machineId="winder2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -191,7 +203,7 @@ export function TensionArmAngleGraph({
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData,
|
||||
@@ -201,6 +213,8 @@ export function TensionArmAngleGraph({
|
||||
renderValue={renderValue}
|
||||
config={config}
|
||||
graphId="tension-arm-angle"
|
||||
currentTimeSeries={newData}
|
||||
machineId="winder2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -225,8 +239,9 @@ export function SpoolProgressGraph({
|
||||
exportFilename: "spool_progress",
|
||||
};
|
||||
|
||||
// NOTE: Assuming this graph starts at 0, and the max is the total capacity.
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData,
|
||||
@@ -236,6 +251,8 @@ export function SpoolProgressGraph({
|
||||
renderValue={renderValue}
|
||||
config={config}
|
||||
graphId="spool-progress"
|
||||
currentTimeSeries={newData}
|
||||
machineId="winder2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -243,12 +260,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 +278,7 @@ export function PullerSpeedGraph({
|
||||
lines.push({
|
||||
type: "target",
|
||||
value: targetSpeed,
|
||||
targetSeries,
|
||||
label: "Target Speed",
|
||||
color: "#06b6d4", // Match series color
|
||||
});
|
||||
@@ -274,7 +294,7 @@ export function PullerSpeedGraph({
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData,
|
||||
@@ -285,6 +305,8 @@ export function PullerSpeedGraph({
|
||||
renderValue={renderValue}
|
||||
config={config}
|
||||
graphId="puller-speed"
|
||||
currentTimeSeries={newData}
|
||||
machineId="winder2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +211,7 @@ export type Winder2NamespaceStore = {
|
||||
spoolRpm: TimeSeries;
|
||||
tensionArmAngle: TimeSeries;
|
||||
spoolProgress: TimeSeries;
|
||||
targetPullerSpeed: TimeSeries;
|
||||
};
|
||||
|
||||
//Store Factory and Message Handler -> no param, so default values
|
||||
@@ -220,6 +221,8 @@ const { initialTimeSeries: traversePosition, insert: addTraversePosition } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: pullerSpeed, insert: addPullerSpeed } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: targetPullerSpeed, insert: addTargetPullerSpeed } =
|
||||
createTimeSeries();
|
||||
const { initialTimeSeries: spoolRpm, insert: addSpoolRpm } = createTimeSeries();
|
||||
const { initialTimeSeries: tensionArmAngle, insert: addTensionArmAngle } =
|
||||
createTimeSeries();
|
||||
@@ -239,6 +242,7 @@ export const createWinder2NamespaceStore =
|
||||
// Time series data for live values
|
||||
traversePosition,
|
||||
pullerSpeed,
|
||||
targetPullerSpeed,
|
||||
spoolRpm,
|
||||
tensionArmAngle,
|
||||
spoolProgress,
|
||||
@@ -273,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";
|
||||
@@ -75,6 +77,10 @@ import { MetricsControlPage } from "@/metrics/MetricsControlPage";
|
||||
|
||||
import { WagoPower1Page } from "@/machines/wago_power/wago_power1/WagoPower1Page";
|
||||
import { WagoPower1ControlPage } from "@/machines/wago_power/wago_power1/WagoPower1ControlPage";
|
||||
import { WagoDoTestMachinePage } from "@/machines/wagodotestmachine/WagoDoTestMachinePage";
|
||||
import { WagoDoTestMachineControlPage } from "@/machines/wagodotestmachine/WagoDoTestMachineControlPage";
|
||||
import { Wago8chDioTestMachinePage } from "@/machines/wago8chdiotestmachine/wago8chDioTestMachinePage";
|
||||
import { Wago8chDioTestMachineControlRoute } from "@/machines/wago8chdiotestmachine/wago8chDioTestMachineControlPage";
|
||||
|
||||
// make a route tree like this
|
||||
// _mainNavigation/machines/winder2/$serial/control
|
||||
@@ -89,6 +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,
|
||||
@@ -156,6 +174,19 @@ export const testMotorControlRoute = createRoute({
|
||||
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",
|
||||
@@ -383,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",
|
||||
@@ -487,10 +530,18 @@ export const rootTree = RootRoute.addChildren([
|
||||
|
||||
testMachineSerialRoute.addChildren([testMachineControlRoute]),
|
||||
|
||||
testMachineStepperSerialRoute.addChildren([
|
||||
testMachineStepperControlRoute,
|
||||
]),
|
||||
|
||||
analogInputTestMachineSerialRoute.addChildren([
|
||||
analogInputTestMachineControlRoute,
|
||||
]),
|
||||
|
||||
wago8chDioTestMachineRoute.addChildren([
|
||||
wago8chDioTestMachineControlRoute,
|
||||
]),
|
||||
|
||||
wagoAiTestMachineSerialRoute.addChildren([wagoAiTestMachineControlRoute]),
|
||||
|
||||
digitalInputTestMachineSerialRoute.addChildren([
|
||||
@@ -541,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>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
export type Marker = {
|
||||
timestamp: number;
|
||||
name: string;
|
||||
value?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const MAX_MARKERS_PER_MACHINE = 200;
|
||||
const STORAGE_NAME = "machine-markers-storage";
|
||||
const OLD_STORAGE_PREFIX = "machine-markers-";
|
||||
|
||||
export type MarkerStoreState = {
|
||||
markersByMachine: Record<string, Marker[]>;
|
||||
};
|
||||
|
||||
export type MarkerStoreActions = {
|
||||
getMarkers: (machineId: string) => Marker[];
|
||||
addMarker: (machineId: string, marker: Marker) => void;
|
||||
removeMarker: (machineId: string, timestamp: number) => void;
|
||||
clearMarkers: (machineId: string) => void;
|
||||
};
|
||||
|
||||
export type MarkerStore = MarkerStoreState & MarkerStoreActions;
|
||||
|
||||
function trimToMax(markers: Marker[]): Marker[] {
|
||||
return markers.length > MAX_MARKERS_PER_MACHINE
|
||||
? markers.slice(-MAX_MARKERS_PER_MACHINE)
|
||||
: markers;
|
||||
}
|
||||
|
||||
/** Migrate from legacy per-machine keys (machine-markers-{id}) to unified store */
|
||||
function migrateFromLegacyStorage(): Partial<MarkerStoreState> | null {
|
||||
try {
|
||||
const markersByMachine: Record<string, Marker[]> = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(OLD_STORAGE_PREFIX) && key !== STORAGE_NAME) {
|
||||
const machineId = key.slice(OLD_STORAGE_PREFIX.length);
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Marker[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
markersByMachine[machineId] = parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed entries
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(markersByMachine).length > 0) {
|
||||
return { markersByMachine };
|
||||
}
|
||||
} catch {
|
||||
// Ignore migration errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const markerStorage = createJSONStorage<MarkerStoreState>(() => ({
|
||||
getItem: (name: string): string | null => {
|
||||
const next = localStorage.getItem(name);
|
||||
if (next) return next;
|
||||
const migrated = migrateFromLegacyStorage();
|
||||
if (migrated) {
|
||||
return JSON.stringify({ state: migrated, version: 0 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setItem: (name: string, value: string): void => {
|
||||
localStorage.setItem(name, value);
|
||||
},
|
||||
removeItem: (name: string): void => {
|
||||
localStorage.removeItem(name);
|
||||
},
|
||||
}));
|
||||
|
||||
export const useMarkerStore = create<MarkerStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
markersByMachine: {},
|
||||
|
||||
getMarkers: (machineId: string) => {
|
||||
return get().markersByMachine[machineId] ?? [];
|
||||
},
|
||||
|
||||
addMarker: (machineId: string, marker: Marker) => {
|
||||
set((state) => {
|
||||
const current = state.markersByMachine[machineId] ?? [];
|
||||
const updated = trimToMax([...current, marker]);
|
||||
return {
|
||||
markersByMachine: {
|
||||
...state.markersByMachine,
|
||||
[machineId]: updated,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeMarker: (machineId: string, timestamp: number) => {
|
||||
set((state) => {
|
||||
const current = state.markersByMachine[machineId] ?? [];
|
||||
const updated = current.filter((m) => m.timestamp !== timestamp);
|
||||
return {
|
||||
markersByMachine: {
|
||||
...state.markersByMachine,
|
||||
[machineId]: updated,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearMarkers: (machineId: string) => {
|
||||
set((state) => ({
|
||||
markersByMachine: {
|
||||
...state.markersByMachine,
|
||||
[machineId]: [],
|
||||
},
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: STORAGE_NAME,
|
||||
storage: markerStorage,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,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},
|
||||
},
|
||||
},
|
||||
@@ -62,7 +63,6 @@ impl EthercatDevice for Wago750_354 {
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -153,14 +153,22 @@ impl std::fmt::Debug for Wago750_354 {
|
||||
impl Wago750_354 {
|
||||
pub fn calculate_module_index(pdo_mapping: u32, is_tx: bool) -> u32 {
|
||||
let start_index = match is_tx {
|
||||
true => 0x6000 as u32,
|
||||
false => 0x7000 as u32,
|
||||
true => 0x6000,
|
||||
false => 0x7000,
|
||||
};
|
||||
let index_in_hex = ((pdo_mapping & 0xFFFF0000) >> 16) - start_index;
|
||||
if index_in_hex < 16 {
|
||||
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 {
|
||||
return index_in_hex / 16;
|
||||
index_in_hex / 16
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +199,7 @@ impl Wago750_354 {
|
||||
}
|
||||
|
||||
let mut mappings_without_coupler: Vec<u32> = vec![];
|
||||
for i in start_subindex..count_mappings {
|
||||
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 {
|
||||
@@ -303,6 +311,11 @@ impl Wago750_354 {
|
||||
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: {}",
|
||||
ident_iom
|
||||
@@ -383,7 +396,9 @@ impl Wago750_354 {
|
||||
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: {:?} !",
|
||||
|
||||
@@ -6,3 +6,4 @@ 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;
|
||||
@@ -38,27 +38,27 @@ impl From<Wago750_1506OutputPort> for usize {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Wago750_1506InputPort {
|
||||
DO1,
|
||||
DO2,
|
||||
DO3,
|
||||
DO4,
|
||||
DO5,
|
||||
DO6,
|
||||
DO7,
|
||||
DO8,
|
||||
DI1,
|
||||
DI2,
|
||||
DI3,
|
||||
DI4,
|
||||
DI5,
|
||||
DI6,
|
||||
DI7,
|
||||
DI8,
|
||||
}
|
||||
|
||||
impl From<Wago750_1506InputPort> for usize {
|
||||
fn from(value: Wago750_1506InputPort) -> Self {
|
||||
match value {
|
||||
Wago750_1506InputPort::DO1 => 0,
|
||||
Wago750_1506InputPort::DO2 => 1,
|
||||
Wago750_1506InputPort::DO3 => 2,
|
||||
Wago750_1506InputPort::DO4 => 3,
|
||||
Wago750_1506InputPort::DO5 => 4,
|
||||
Wago750_1506InputPort::DO6 => 5,
|
||||
Wago750_1506InputPort::DO7 => 6,
|
||||
Wago750_1506InputPort::DO8 => 7,
|
||||
Wago750_1506InputPort::DI1 => 0,
|
||||
Wago750_1506InputPort::DI2 => 1,
|
||||
Wago750_1506InputPort::DI3 => 2,
|
||||
Wago750_1506InputPort::DI4 => 3,
|
||||
Wago750_1506InputPort::DI5 => 4,
|
||||
Wago750_1506InputPort::DI6 => 5,
|
||||
Wago750_1506InputPort::DI7 => 6,
|
||||
Wago750_1506InputPort::DI8 => 7,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,14 +91,14 @@ impl DigitalInputDevice<Wago750_1506InputPort> for Wago750_1506 {
|
||||
fn get_input(&self, port: Wago750_1506InputPort) -> Result<DigitalInputInput, anyhow::Error> {
|
||||
Ok(DigitalInputInput {
|
||||
value: match port {
|
||||
Wago750_1506InputPort::DO1 => self.tx_pdo.port1,
|
||||
Wago750_1506InputPort::DO2 => self.tx_pdo.port2,
|
||||
Wago750_1506InputPort::DO3 => self.tx_pdo.port3,
|
||||
Wago750_1506InputPort::DO4 => self.tx_pdo.port4,
|
||||
Wago750_1506InputPort::DO5 => self.tx_pdo.port5,
|
||||
Wago750_1506InputPort::DO6 => self.tx_pdo.port6,
|
||||
Wago750_1506InputPort::DO7 => self.tx_pdo.port7,
|
||||
Wago750_1506InputPort::DO8 => self.tx_pdo.port8,
|
||||
Wago750_1506InputPort::DI1 => self.tx_pdo.port1,
|
||||
Wago750_1506InputPort::DI2 => self.tx_pdo.port2,
|
||||
Wago750_1506InputPort::DI3 => self.tx_pdo.port3,
|
||||
Wago750_1506InputPort::DI4 => self.tx_pdo.port4,
|
||||
Wago750_1506InputPort::DI5 => self.tx_pdo.port5,
|
||||
Wago750_1506InputPort::DI6 => self.tx_pdo.port6,
|
||||
Wago750_1506InputPort::DI7 => self.tx_pdo.port7,
|
||||
Wago750_1506InputPort::DI8 => self.tx_pdo.port8,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -143,7 +143,7 @@ pub struct Wago750_1506 {
|
||||
is_used: bool,
|
||||
tx_bit_offset: usize,
|
||||
rx_bit_offset: usize,
|
||||
// Should always have on but not when calling constructor
|
||||
// Should always have one but not when calling constructor
|
||||
module: Option<Module>,
|
||||
rx_pdo: Wago750_1506RxPdo,
|
||||
tx_pdo: Wago750_1506TxPdo,
|
||||
@@ -189,28 +189,28 @@ impl EthercatDevice for Wago750_1506 {
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let base = self.tx_bit_offset;
|
||||
|
||||
let idx1 = base + Into::<usize>::into(Wago750_1506InputPort::DO1);
|
||||
let idx1 = base + Into::<usize>::into(Wago750_1506InputPort::DI1);
|
||||
self.tx_pdo.port1 = *input.get(idx1).expect("Bit 1 out of bounds");
|
||||
|
||||
let idx2 = base + Into::<usize>::into(Wago750_1506InputPort::DO2);
|
||||
let idx2 = base + Into::<usize>::into(Wago750_1506InputPort::DI2);
|
||||
self.tx_pdo.port2 = *input.get(idx2).expect("Bit 2 out of bounds");
|
||||
|
||||
let idx3 = base + Into::<usize>::into(Wago750_1506InputPort::DO3);
|
||||
let idx3 = base + Into::<usize>::into(Wago750_1506InputPort::DI3);
|
||||
self.tx_pdo.port3 = *input.get(idx3).expect("Bit 3 out of bounds");
|
||||
|
||||
let idx4 = base + Into::<usize>::into(Wago750_1506InputPort::DO4);
|
||||
let idx4 = base + Into::<usize>::into(Wago750_1506InputPort::DI4);
|
||||
self.tx_pdo.port4 = *input.get(idx4).expect("Bit 4 out of bounds");
|
||||
|
||||
let idx5 = base + Into::<usize>::into(Wago750_1506InputPort::DO5);
|
||||
let idx5 = base + Into::<usize>::into(Wago750_1506InputPort::DI5);
|
||||
self.tx_pdo.port5 = *input.get(idx5).expect("Bit 5 out of bounds");
|
||||
|
||||
let idx6 = base + Into::<usize>::into(Wago750_1506InputPort::DO6);
|
||||
let idx6 = base + Into::<usize>::into(Wago750_1506InputPort::DI6);
|
||||
self.tx_pdo.port6 = *input.get(idx6).expect("Bit 6 out of bounds");
|
||||
|
||||
let idx7 = base + Into::<usize>::into(Wago750_1506InputPort::DO7);
|
||||
let idx7 = base + Into::<usize>::into(Wago750_1506InputPort::DI7);
|
||||
self.tx_pdo.port7 = *input.get(idx7).expect("Bit 7 out of bounds");
|
||||
|
||||
let idx8 = base + Into::<usize>::into(Wago750_1506InputPort::DO8);
|
||||
let idx8 = base + Into::<usize>::into(Wago750_1506InputPort::DI8);
|
||||
self.tx_pdo.port8 = *input.get(idx8).expect("Bit 8 out of bounds");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
* Wago Stepper Controller 750-672
|
||||
* 70 VDC / 7.5 A
|
||||
*/
|
||||
|
||||
use anyhow::Ok;
|
||||
use bitvec::field::BitField;
|
||||
|
||||
use crate::{
|
||||
devices::{
|
||||
DynamicEthercatDevice, EthercatDevice, EthercatDeviceProcessing, EthercatDeviceUsed,
|
||||
EthercatDynamicPDO, Module, NewEthercatDevice, SubDeviceProductTuple,
|
||||
},
|
||||
io::{
|
||||
digital_input::{DigitalInputDevice, DigitalInputInput},
|
||||
stepper_velocity_wago_750_672::{
|
||||
C1Command, C1Flag, C2Flag, C3Flag, ControlByteC1, ControlByteC2, ControlByteC3, S3Flag,
|
||||
StatusByteS3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Wago750_672 {
|
||||
is_used: bool,
|
||||
tx_bit_offset: usize,
|
||||
rx_bit_offset: usize,
|
||||
pub rxpdo: Wago750_672RxPdo,
|
||||
pub txpdo: Wago750_672TxPdo,
|
||||
module: Option<Module>,
|
||||
pub state: InitState,
|
||||
pub initialized: bool,
|
||||
}
|
||||
|
||||
// unfortunately we need to track and set bits over multiple cycles
|
||||
// and wait for their acknowledgement so we have to use a state machine
|
||||
// inside of our loop.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InitState {
|
||||
Off,
|
||||
Enable,
|
||||
SetMode,
|
||||
StartPulseStart,
|
||||
StartPulseEnd,
|
||||
Running,
|
||||
ErrorQuit,
|
||||
ResetQuit,
|
||||
}
|
||||
|
||||
/*
|
||||
* Wago has two different kind of applications when it comes to
|
||||
* running the Stepper Controller.
|
||||
* - Positioning
|
||||
* - Frequency/Speed Control
|
||||
* !!!! IMPORTANT !!!!
|
||||
* It seems like this is only true for the 750 671.
|
||||
* The 750 672 has differnt commands inside of ControlByteC1
|
||||
* where w can choose the Speed control mode - just like that
|
||||
* we always want to use the latter one so the following
|
||||
* process images are for that usecase.
|
||||
*/
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Wago750_672RxPdo {
|
||||
pub control_byte: u8, // C0
|
||||
pub velocity: i16, // D0/D1
|
||||
pub acceleration: u16, // D2/D3
|
||||
pub control_byte3: u8, // C3
|
||||
pub control_byte2: u8, // C2
|
||||
pub control_byte1: u8, // C1
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Wago750_672TxPdo {
|
||||
pub status_byte0: u8, // S0
|
||||
pub actual_velocity: i16, // D0/D1
|
||||
pub position_l: u8, // D4
|
||||
pub position_m: u8, // D5
|
||||
pub position_h: u8, // D6
|
||||
pub status_byte3: u8, // S3
|
||||
pub status_byte2: u8, // S2
|
||||
pub status_byte1: u8, // S1
|
||||
}
|
||||
|
||||
/// Digital input port enumeration for the 6 inputs
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Wago750_672InputPort {
|
||||
DI1,
|
||||
DI2,
|
||||
DI3,
|
||||
DI4,
|
||||
DI5,
|
||||
DI6,
|
||||
}
|
||||
|
||||
// Get the Digital Inputs from the Status Byte S3
|
||||
impl DigitalInputDevice<Wago750_672InputPort> for Wago750_672 {
|
||||
fn get_input(&self, port: Wago750_672InputPort) -> Result<DigitalInputInput, anyhow::Error> {
|
||||
let s3 = StatusByteS3::from_bits(self.txpdo.status_byte3);
|
||||
Ok(DigitalInputInput {
|
||||
value: match port {
|
||||
Wago750_672InputPort::DI1 => s3.has_flag(S3Flag::Input1),
|
||||
Wago750_672InputPort::DI2 => s3.has_flag(S3Flag::Input2),
|
||||
Wago750_672InputPort::DI3 => s3.has_flag(S3Flag::Input3),
|
||||
Wago750_672InputPort::DI4 => s3.has_flag(S3Flag::Input4),
|
||||
Wago750_672InputPort::DI5 => s3.has_flag(S3Flag::Input5),
|
||||
Wago750_672InputPort::DI6 => s3.has_flag(S3Flag::Input6),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl EthercatDeviceUsed for Wago750_672 {
|
||||
fn is_used(&self) -> bool {
|
||||
self.is_used
|
||||
}
|
||||
fn set_used(&mut self, used: bool) {
|
||||
self.is_used = used;
|
||||
}
|
||||
}
|
||||
|
||||
impl DynamicEthercatDevice for Wago750_672 {}
|
||||
|
||||
impl EthercatDynamicPDO for Wago750_672 {
|
||||
fn get_tx_offset(&self) -> usize {
|
||||
self.tx_bit_offset
|
||||
}
|
||||
fn get_rx_offset(&self) -> usize {
|
||||
self.rx_bit_offset
|
||||
}
|
||||
fn set_tx_offset(&mut self, offset: usize) {
|
||||
self.tx_bit_offset = offset
|
||||
}
|
||||
fn set_rx_offset(&mut self, offset: usize) {
|
||||
self.rx_bit_offset = offset
|
||||
}
|
||||
}
|
||||
|
||||
impl EthercatDeviceProcessing for Wago750_672 {
|
||||
fn input_post_process(&mut self) -> Result<(), anyhow::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn output_pre_process(&mut self) -> Result<(), anyhow::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl EthercatDevice for Wago750_672 {
|
||||
fn input(
|
||||
&mut self,
|
||||
input: &bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
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 {
|
||||
status_byte0: b[0],
|
||||
actual_velocity: i16::from_le_bytes([b[2], b[3]]),
|
||||
position_l: b[6],
|
||||
position_m: b[7],
|
||||
position_h: b[8],
|
||||
status_byte3: b[9],
|
||||
status_byte2: b[10],
|
||||
status_byte1: b[11],
|
||||
};
|
||||
|
||||
// Yes this statemachine is unfortunately needed in here to make sure
|
||||
// bits are correctly set and reset in the correct cycle.
|
||||
match self.state {
|
||||
InitState::Off => {
|
||||
// Do nothing
|
||||
self.initialized = false;
|
||||
}
|
||||
InitState::Enable => {
|
||||
let c1 = ControlByteC1::new()
|
||||
.with_flag(C1Flag::Enable)
|
||||
.with_flag(C1Flag::Stop2N)
|
||||
.bits();
|
||||
self.rxpdo.control_byte1 = c1;
|
||||
|
||||
// Switch state if ENABLE and STOP2_N is acknowledged
|
||||
if self.txpdo.status_byte1 == c1 {
|
||||
self.state = InitState::SetMode;
|
||||
}
|
||||
}
|
||||
InitState::SetMode => {
|
||||
let c1 = ControlByteC1::new()
|
||||
.with_flag(C1Flag::Enable)
|
||||
.with_flag(C1Flag::Stop2N)
|
||||
.with_command(C1Command::SpeedControl)
|
||||
.bits();
|
||||
self.rxpdo.control_byte1 = c1;
|
||||
|
||||
// Switch state if SPEED MODE is acknowledged
|
||||
if self.txpdo.status_byte1 == c1 {
|
||||
self.initialized = true;
|
||||
self.state = InitState::StartPulseStart;
|
||||
}
|
||||
}
|
||||
InitState::StartPulseStart => {
|
||||
let c1 = ControlByteC1::new()
|
||||
.with_flag(C1Flag::Enable)
|
||||
.with_flag(C1Flag::Stop2N)
|
||||
.with_flag(C1Flag::Start)
|
||||
.with_command(C1Command::SpeedControl)
|
||||
.bits();
|
||||
self.rxpdo.control_byte1 = c1;
|
||||
|
||||
// Switch state after StartPulse is acknowledged
|
||||
if self.txpdo.status_byte1 == c1 {
|
||||
self.state = InitState::StartPulseEnd;
|
||||
}
|
||||
}
|
||||
InitState::StartPulseEnd => {
|
||||
let c1 = ControlByteC1::new()
|
||||
.with_flag(C1Flag::Enable)
|
||||
.with_flag(C1Flag::Stop2N)
|
||||
.with_command(C1Command::SpeedControl)
|
||||
.bits();
|
||||
self.rxpdo.control_byte1 = c1;
|
||||
|
||||
// Switch state after StartPulse is over
|
||||
if self.txpdo.status_byte1 == c1 {
|
||||
self.state = InitState::Running;
|
||||
}
|
||||
}
|
||||
InitState::Running => {
|
||||
let c2 = ControlByteC2::new().with_flag(C2Flag::ErrorQuit).bits();
|
||||
let c3 = ControlByteC3::new().with_flag(C3Flag::ResetQuit).bits();
|
||||
|
||||
// Check for Error
|
||||
if self.txpdo.status_byte2 & c2 != 0 {
|
||||
self.state = InitState::ErrorQuit;
|
||||
} else {
|
||||
self.rxpdo.control_byte2 |= c2;
|
||||
}
|
||||
// Check for Reset
|
||||
if self.txpdo.status_byte3 & c3 != 0 {
|
||||
self.state = InitState::ResetQuit;
|
||||
} else {
|
||||
self.rxpdo.control_byte3 |= c3;
|
||||
}
|
||||
}
|
||||
InitState::ErrorQuit => {
|
||||
self.rxpdo.control_byte2 |=
|
||||
ControlByteC2::new().with_flag(C2Flag::ErrorQuit).bits();
|
||||
tracing::error!("Stepper Controller Errored. Trying to reenable...");
|
||||
self.state = InitState::Enable;
|
||||
}
|
||||
InitState::ResetQuit => {
|
||||
self.rxpdo.control_byte3 |=
|
||||
ControlByteC3::new().with_flag(C3Flag::ResetQuit).bits();
|
||||
tracing::error!("Stepper Controller Reset not Quit. Trying to reenable...");
|
||||
self.state = InitState::Enable;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn input_len(&self) -> usize {
|
||||
12 * 8
|
||||
}
|
||||
|
||||
fn output(
|
||||
&self,
|
||||
output: &mut bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let base = self.rx_bit_offset;
|
||||
|
||||
let output_bytes = [
|
||||
self.rxpdo.control_byte,
|
||||
0,
|
||||
self.rxpdo.velocity.to_le_bytes()[0],
|
||||
self.rxpdo.velocity.to_le_bytes()[1],
|
||||
self.rxpdo.acceleration.to_le_bytes()[0],
|
||||
self.rxpdo.acceleration.to_le_bytes()[1],
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
self.rxpdo.control_byte3,
|
||||
self.rxpdo.control_byte2,
|
||||
self.rxpdo.control_byte1,
|
||||
];
|
||||
|
||||
for i in 0..12 {
|
||||
output[base + i * 8..base + (i + 1) * 8].store_le(output_bytes[i]);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn output_len(&self) -> usize {
|
||||
12 * 8
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn is_module(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn input_checked(
|
||||
&mut self,
|
||||
input: &bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let expected = self.input_len();
|
||||
let actual = input.len();
|
||||
if actual != expected {
|
||||
return Err(anyhow::anyhow!(
|
||||
"[{}::Device::input_checked] Input length is {} ({} bytes) and must be {} bits ({} bytes)",
|
||||
module_path!(),
|
||||
actual,
|
||||
actual / 8,
|
||||
expected,
|
||||
expected / 8
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn output_checked(
|
||||
&self,
|
||||
output: &mut bitvec::prelude::BitSlice<u8, bitvec::prelude::Lsb0>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let expected = self.output_len();
|
||||
let actual = output.len();
|
||||
if actual != expected {
|
||||
return Err(anyhow::anyhow!(
|
||||
"[{}::Device::output_checked] Output length is {} ({} bytes) and must be {} bits ({} bytes)",
|
||||
module_path!(),
|
||||
actual,
|
||||
actual / 8,
|
||||
expected,
|
||||
expected / 8
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_module(&self) -> Option<crate::devices::Module> {
|
||||
self.module.clone()
|
||||
}
|
||||
|
||||
fn set_module(&mut self, module: crate::devices::Module) {
|
||||
self.tx_bit_offset = module.tx_offset;
|
||||
self.rx_bit_offset = module.rx_offset;
|
||||
self.module = Some(module)
|
||||
}
|
||||
}
|
||||
|
||||
impl NewEthercatDevice for Wago750_672 {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
is_used: false,
|
||||
tx_bit_offset: 0,
|
||||
rx_bit_offset: 0,
|
||||
module: None,
|
||||
rxpdo: Wago750_672RxPdo::default(),
|
||||
txpdo: Wago750_672TxPdo::default(),
|
||||
state: InitState::Off,
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Wago750_672 {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Wago750_672")
|
||||
}
|
||||
}
|
||||
|
||||
pub const WAGO_750_672_VENDOR_ID: u32 = 0x00000021;
|
||||
pub const WAGO_750_672_PRODUCT_ID: u32 = 108139752;
|
||||
pub const WAGO_750_672_MODULE_IDENT: SubDeviceProductTuple =
|
||||
(WAGO_750_672_VENDOR_ID, WAGO_750_672_PRODUCT_ID);
|
||||
@@ -5,7 +5,7 @@ const U16_MAX: i128 = std::u16::MAX as i128;
|
||||
/// We convert the overflows and underflows to an i128 value for easier calculations.
|
||||
///
|
||||
/// When overriding the counter we don't set the valeu direclty but schedula it wiht the [`push_override`] so it can be synced with [`pop_override`] to an EtherCAT device.
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CounterWrapperU16U128 {
|
||||
counter: i128,
|
||||
last_counter: u16,
|
||||
|
||||
@@ -2,3 +2,74 @@ pub mod counter_wrapper_u16_i128;
|
||||
pub mod el70xx_velocity_converter;
|
||||
pub mod ethercrab_types;
|
||||
pub mod signing_converter_u16;
|
||||
|
||||
use ethercrab::{SubDevice, SubDeviceRef};
|
||||
// This is the Codeword for disabling EEPROM writes on sdo writes
|
||||
const BECKHOFF_EEPROM_LOCK_CODEWORD: u32 = 0x12345678;
|
||||
// In this index Codewords can be written for Beckhoff Terminals
|
||||
const BECKHOFF_CODEWORD_INDEX: u16 = 0xF008;
|
||||
|
||||
// This code locks the eeprom and protects it from writes
|
||||
// When the toggle is in effect no writes propagate to the eeprom
|
||||
// This is useful if you have a usecase where you very often write into CoE registers, as writes to EEPROM after a long time degrage the EEPROM
|
||||
pub async fn set_mut_beckhoff_eeprom_lock_active<'a>(
|
||||
subdevice: &SubDeviceRef<'a, &mut SubDevice>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let code_word = match subdevice.sdo_read::<u32>(BECKHOFF_CODEWORD_INDEX, 0).await {
|
||||
Ok(code_word) => code_word,
|
||||
// This happens when the subdevice has no mailbox
|
||||
// There is NO check in ethercrab for Mailbox presence, so we just have to pray that it has one and send a request
|
||||
// If there is no Mailbox to write Coe Request to, then there is also no EEPROM meaning we achieved our goal in a sense
|
||||
// This is why it returns OK on an error for sdo_read
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let eeprom_lock_toggled = match code_word {
|
||||
BECKHOFF_EEPROM_LOCK_CODEWORD => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !eeprom_lock_toggled {
|
||||
subdevice
|
||||
.sdo_write(BECKHOFF_CODEWORD_INDEX, 0, BECKHOFF_EEPROM_LOCK_CODEWORD)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
"Activated EEPROM Lock for {} {}",
|
||||
subdevice.name(),
|
||||
subdevice.name()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_beckhoff_eeprom_lock_active<'a>(
|
||||
subdevice: &SubDeviceRef<'a, &SubDevice>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let code_word = match subdevice.sdo_read::<u32>(BECKHOFF_CODEWORD_INDEX, 0).await {
|
||||
Ok(code_word) => code_word,
|
||||
// This happens when the subdevice has no mailbox
|
||||
// There is NO check in ethercrab for Mailbox presence, so we just have to pray that it has one and send a request
|
||||
// If there is no Mailbox to write Coe Request to, then there is also no EEPROM meaning we achieved our goal in a sense
|
||||
// This is why it returns OK on an error for sdo_read
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let eeprom_lock_toggled = match code_word {
|
||||
BECKHOFF_EEPROM_LOCK_CODEWORD => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !eeprom_lock_toggled {
|
||||
subdevice
|
||||
.sdo_write(BECKHOFF_CODEWORD_INDEX, 0, BECKHOFF_EEPROM_LOCK_CODEWORD)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
"Activated EEPROM Lock for {} {}",
|
||||
subdevice.name(),
|
||||
subdevice.name()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,4 +7,5 @@ pub mod encoder_input;
|
||||
pub mod pulse_train_output;
|
||||
pub mod serial_interface;
|
||||
pub mod stepper_velocity_el70x1;
|
||||
pub mod stepper_velocity_wago_750_672;
|
||||
pub mod temperature_input;
|
||||
@@ -0,0 +1,376 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use smol::{block_on, lock::RwLock};
|
||||
|
||||
use crate::devices::wago_modules::wago_750_672::{InitState, Wago750_672};
|
||||
|
||||
/*
|
||||
* Wago Stepper Velocity Wrapper around Stepper Controller
|
||||
*
|
||||
*/
|
||||
#[derive(Debug)]
|
||||
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,
|
||||
}
|
||||
|
||||
impl StepperVelocityWago750672 {
|
||||
pub fn new(device: Arc<RwLock<Wago750_672>>) -> Self {
|
||||
Self {
|
||||
device,
|
||||
state: InitState::Off,
|
||||
target_velocity: 0,
|
||||
target_acceleration: 10000,
|
||||
enabled: false,
|
||||
freq_range_sel: 0,
|
||||
acc_range_sel: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
if self.enabled && enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
// this needs to be set before the stepper controller
|
||||
// is enabled because it will generate an error otherwise
|
||||
self.set_acceleration(self.target_acceleration);
|
||||
|
||||
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 velocity to -25000 - 25000 :: because this is
|
||||
// the min max for the controller
|
||||
dev.rxpdo.velocity = velocity.clamp(-25000, 25000);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn get_actual_velocity(&self) -> i16 {
|
||||
let dev = block_on(self.device.read());
|
||||
dev.txpdo.actual_velocity
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn get_target_acceleration(&self) -> u16 {
|
||||
let dev = block_on(self.device.read());
|
||||
dev.rxpdo.acceleration
|
||||
}
|
||||
|
||||
pub fn set_freq_range_sel(&mut self, factor: u8) {
|
||||
if self.enabled || factor > 3 {
|
||||
return;
|
||||
}
|
||||
self.freq_range_sel = factor;
|
||||
let mut dev = block_on(self.device.write());
|
||||
let c2 = ControlByteC2::from_bits(dev.rxpdo.control_byte2)
|
||||
.with_freq_range(factor)
|
||||
.bits();
|
||||
dev.rxpdo.control_byte2 = 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.control_byte2)
|
||||
.with_acc_range(factor)
|
||||
.bits();
|
||||
dev.rxpdo.control_byte2 = c2;
|
||||
}
|
||||
|
||||
fn change_init_state(&mut self, state: InitState) {
|
||||
self.state = state.clone();
|
||||
let mut dev = block_on(self.device.write());
|
||||
dev.state = state;
|
||||
}
|
||||
|
||||
fn write_control_byte(&self, control_byte: ControlByte, value: u8) {
|
||||
let mut dev = block_on(self.device.write());
|
||||
|
||||
match control_byte {
|
||||
ControlByte::C0 => dev.rxpdo.control_byte = value,
|
||||
ControlByte::C1 => dev.rxpdo.control_byte1 = value,
|
||||
ControlByte::C2 => dev.rxpdo.control_byte2 = value,
|
||||
ControlByte::C3 => dev.rxpdo.control_byte3 = value,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn read_status_byte(&self, status_byte: StatusByte) -> u8 {
|
||||
let dev = block_on(self.device.read());
|
||||
|
||||
match status_byte {
|
||||
StatusByte::S0 => dev.txpdo.status_byte0,
|
||||
StatusByte::S1 => dev.txpdo.status_byte1,
|
||||
StatusByte::S2 => dev.txpdo.status_byte2,
|
||||
StatusByte::S3 => dev.txpdo.status_byte3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Different Control Bytes set control the stepper controller
|
||||
// by setting and resetting specific bits.
|
||||
//
|
||||
// There are some construction functions provided to create the
|
||||
// control bytes correctly.
|
||||
|
||||
/// Control Byte C1
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct ControlByteC1(u8);
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum C1Flag {
|
||||
Enable = 0b0000_0001,
|
||||
Stop2N = 0b0000_0010,
|
||||
Start = 0b0000_0100,
|
||||
}
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum C1Command {
|
||||
Idle = 0b0000_0000,
|
||||
SinglePosition = 0b0000_1000,
|
||||
RunProgram = 0b0001_0000,
|
||||
SpeedControl = 0b0001_1000,
|
||||
Reference = 0b0010_0000,
|
||||
JogMode = 0b0010_1000,
|
||||
Mailbox = 0b0011_0000,
|
||||
}
|
||||
|
||||
impl ControlByteC1 {
|
||||
pub const fn new() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
|
||||
pub const fn with_flag(mut self, flag: C1Flag) -> Self {
|
||||
self.0 |= flag as u8;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_command(mut self, cmd: C1Command) -> Self {
|
||||
self.0 = (self.0 & 0b0000_0111) | (cmd as u8);
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn bits(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Control Byte C2
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct ControlByteC2(u8);
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum C2Flag {
|
||||
FreqRangeSelL = 0b0000_0001,
|
||||
FreqRangeSelH = 0b0000_0010,
|
||||
AccRangeSelL = 0b0000_0100,
|
||||
AccRangeSelH = 0b0000_1000,
|
||||
PreCalc = 0b0100_0000,
|
||||
ErrorQuit = 0b1000_0000,
|
||||
}
|
||||
|
||||
impl ControlByteC2 {
|
||||
pub const fn new() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
|
||||
pub const fn from_bits(bits: u8) -> Self {
|
||||
Self(bits)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub const fn with_flag(mut self, flag: C2Flag) -> Self {
|
||||
self.0 |= flag as u8;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn bits(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Control Byte C3
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct ControlByteC3(u8);
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum C3Flag {
|
||||
SetActualPos = 0b0000_0001,
|
||||
DirectionPos = 0b0000_0010,
|
||||
DirectionNeg = 0b0000_0100,
|
||||
ResetQuit = 0b1000_0000,
|
||||
}
|
||||
|
||||
impl ControlByteC3 {
|
||||
pub const fn new() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
|
||||
pub const fn with_flag(mut self, flag: C3Flag) -> Self {
|
||||
self.0 |= flag as u8;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn bits(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// The status bytes are similar to the control bytes but are only readable
|
||||
// and most of the time provide corresponding acknoledgements to the diffrent
|
||||
// Control bytes.
|
||||
//
|
||||
// There are also helper functions to construct the diffrent Status bytes for
|
||||
// compoarison.
|
||||
|
||||
/// Status Byte S1
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct StatusByteS1(u8);
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum S1Flag {
|
||||
Ready = 0b0000_0001,
|
||||
Stop2NAck = 0b0000_0010,
|
||||
StartAck = 0b0000_0100,
|
||||
}
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum S1CommandAck {
|
||||
Idle = 0b0000_0000,
|
||||
SinglePosition = 0b0000_1000,
|
||||
RunProgram = 0b0001_0000,
|
||||
SpeedControl = 0b0001_1000,
|
||||
Reference = 0b0010_0000,
|
||||
JogMode = 0b0010_1000,
|
||||
Mailbox = 0b0011_0000,
|
||||
}
|
||||
|
||||
impl StatusByteS1 {
|
||||
pub const fn from_bits(bits: u8) -> Self {
|
||||
Self(bits)
|
||||
}
|
||||
|
||||
pub const fn has_flag(self, flag: S1Flag) -> bool {
|
||||
(self.0 & flag as u8) != 0
|
||||
}
|
||||
|
||||
pub const fn bits(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Status Byte S2
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct StatusByteS2(u8);
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum S2Flag {
|
||||
OnTarget = 0b0000_0001,
|
||||
Busy = 0b0000_0010,
|
||||
StandStill = 0b0000_0100,
|
||||
OnSpeed = 0b0000_1000,
|
||||
Direction = 0b0001_0000,
|
||||
ReferenceOk = 0b0010_0000,
|
||||
PreCalcAck = 0b0100_0000,
|
||||
Error = 0b1000_0000,
|
||||
}
|
||||
|
||||
impl StatusByteS2 {
|
||||
pub const fn from_bits(bits: u8) -> Self {
|
||||
Self(bits)
|
||||
}
|
||||
|
||||
pub const fn has_flag(self, flag: S2Flag) -> bool {
|
||||
(self.0 & flag as u8) != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Status Byte S3
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct StatusByteS3(u8);
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum S3Flag {
|
||||
Input1 = 0b0000_0001,
|
||||
Input2 = 0b0000_0010,
|
||||
Input3 = 0b0000_0100,
|
||||
Input4 = 0b0000_1000,
|
||||
Input5 = 0b0001_0000,
|
||||
Input6 = 0b0010_0000,
|
||||
Warning = 0b0100_0000,
|
||||
Reset = 0b1000_0000,
|
||||
}
|
||||
|
||||
impl StatusByteS3 {
|
||||
pub const fn from_bits(bits: u8) -> Self {
|
||||
Self(bits)
|
||||
}
|
||||
|
||||
pub const fn has_flag(self, flag: S3Flag) -> bool {
|
||||
(self.0 & flag as u8) != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ControlByte {
|
||||
C0,
|
||||
C1,
|
||||
C2,
|
||||
C3,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum StatusByte {
|
||||
S0,
|
||||
S1,
|
||||
S2,
|
||||
S3,
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1771796463,
|
||||
"narHash": "sha256-9bCDuUzpwJXcHMQYMS1yNuzYMmKO/CCwCexpjWOl62I=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "3d3de3313e263e04894f284ac18177bd26169bad",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crane_2": {
|
||||
"locked": {
|
||||
"lastModified": 1750266157,
|
||||
"narHash": "sha256-tL42YoNg9y30u7zAqtoGDNdTyXTi8EALDeCB13FtbQA=",
|
||||
@@ -15,7 +30,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crane_2": {
|
||||
"crane_3": {
|
||||
"locked": {
|
||||
"lastModified": 1750266157,
|
||||
"narHash": "sha256-tL42YoNg9y30u7zAqtoGDNdTyXTi8EALDeCB13FtbQA=",
|
||||
@@ -102,12 +117,51 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_5": {
|
||||
"inputs": {
|
||||
"systems": "systems_5"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771851181,
|
||||
"narHash": "sha256-gFgE6mGUftwseV3DUENMb0k0EiHd739lZexPo5O/sdQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "9a4b494b1aa1b93d8edf167f46dc8e0c0011280c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1750973805,
|
||||
"narHash": "sha256-BZXgag7I0rnL/HMHAsBz3tQrfKAibpY2vovexl2lS+Y=",
|
||||
@@ -122,9 +176,10 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager_2": {
|
||||
"home-manager_3": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
]
|
||||
@@ -143,9 +198,10 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager_3": {
|
||||
"home-manager_4": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
@@ -167,11 +223,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1750969886,
|
||||
"narHash": "sha256-zW/OFnotiz/ndPFdebpo3X0CrbVNf22n4DjN2vxlb58=",
|
||||
"lastModified": 1767313136,
|
||||
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a676066377a2fe7457369dd37c31fd2263b662f4",
|
||||
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -207,6 +263,31 @@
|
||||
],
|
||||
"qitech-control": "qitech-control_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772023797,
|
||||
"narHash": "sha256-RipiUlH5UKiofTRquREluUFOwEfNjLFEvyDol/VWDRg=",
|
||||
"owner": "qitechgmbh",
|
||||
"repo": "control",
|
||||
"rev": "3e3e3f1c0bc2b26fe0fc1e7d49b65e3fd3d7f368",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "qitechgmbh",
|
||||
"repo": "control",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"qitech-control_2": {
|
||||
"inputs": {
|
||||
"crane": "crane_3",
|
||||
"flake-utils": "flake-utils_3",
|
||||
"home-manager": "home-manager_3",
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
],
|
||||
"qitech-control": "qitech-control_3"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1751047510,
|
||||
"narHash": "sha256-SZ+DxQvFJKJOro1AVUwWltLQO1L9TIc6AI4tnM9V4tk=",
|
||||
@@ -221,15 +302,16 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"qitech-control_2": {
|
||||
"qitech-control_3": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_3",
|
||||
"home-manager": "home-manager_3",
|
||||
"flake-utils": "flake-utils_4",
|
||||
"home-manager": "home-manager_4",
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
],
|
||||
"qitech-control": "qitech-control_3",
|
||||
"qitech-control": "qitech-control_4",
|
||||
"rust-overlay": "rust-overlay_2"
|
||||
},
|
||||
"locked": {
|
||||
@@ -246,10 +328,11 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"qitech-control_3": {
|
||||
"qitech-control_4": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_4",
|
||||
"flake-utils": "flake-utils_5",
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
@@ -300,6 +383,7 @@
|
||||
"rust-overlay_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
@@ -378,6 +462,21 @@
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_5": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description = "QiTech Control";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
|
||||
|
||||
# Crane for Rust builds with dependency caching
|
||||
crane = { url = "github:ipetkov/crane"; };
|
||||
|
||||
@@ -152,6 +152,7 @@ pub struct HeatingState {
|
||||
pub struct ExtruderSettingsState {
|
||||
pub pressure_limit: f64,
|
||||
pub pressure_limit_enabled: bool,
|
||||
pub nozzle_temperature_target_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
|
||||
@@ -237,6 +238,9 @@ pub enum Mutation {
|
||||
|
||||
// Reset
|
||||
ResetInverter(bool),
|
||||
|
||||
// Toggle nozzle temperature target
|
||||
SetNozzleTemperatureTargetEnabled(bool),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -317,6 +321,9 @@ impl MachineApi for ExtruderV2 {
|
||||
Mutation::SetTemperaturePidSettings(settings) => {
|
||||
self.configure_temperature_pid(settings);
|
||||
}
|
||||
Mutation::SetNozzleTemperatureTargetEnabled(enabled) => {
|
||||
self.set_nozzle_temperature_target_is_enabled(enabled);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ impl ExtruderV2 {
|
||||
pressure_limit_enabled: self
|
||||
.screw_speed_controller
|
||||
.get_nozzle_pressure_limit_enabled(),
|
||||
nozzle_temperature_target_enabled: self
|
||||
.temperature_controller_nozzle
|
||||
.get_temperature_target_enabled(),
|
||||
},
|
||||
inverter_status_state: InverterStatusState {
|
||||
running: self.screw_speed_controller.inverter.status.running,
|
||||
@@ -296,6 +299,12 @@ impl ExtruderV2 {
|
||||
self.emit_state();
|
||||
}
|
||||
|
||||
pub fn set_nozzle_temperature_target_is_enabled(&mut self, enabled: bool) {
|
||||
self.temperature_controller_nozzle
|
||||
.set_temperature_target_enabled(enabled);
|
||||
self.emit_state();
|
||||
}
|
||||
|
||||
pub fn configure_pressure_pid(&mut self, settings: PidSettings) {
|
||||
self.screw_speed_controller
|
||||
.pid
|
||||
|
||||
@@ -41,6 +41,9 @@ impl MachineApi for ExtruderV2 {
|
||||
Mutation::SetTemperaturePidSettings(settings) => {
|
||||
self.configure_temperature_pid(settings);
|
||||
}
|
||||
Mutation::SetNozzleTemperatureTargetEnabled(enabled) => {
|
||||
self.set_nozzle_temperature_target_is_enabled(enabled);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||