Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8673c2113 | ||
|
|
ba91f2266d | ||
|
|
d6dccc1b1a | ||
|
|
5f636a6f15 | ||
|
|
ac8a8c37ed | ||
|
|
03e057f6a9 | ||
|
|
fb195eb7d4 | ||
|
|
874766633a | ||
|
|
047b0fbe55 | ||
|
|
e3e9412578 | ||
|
|
92ead0e775 | ||
|
|
06a6c81b55 | ||
|
|
7b858251ea | ||
|
|
04700d690c | ||
|
|
e4d950ae9b | ||
|
|
5562cf5a53 |
No files matched your search
@@ -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}`);
|
||||
+2
-2
@@ -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/
|
||||
Executable
+24
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Generated
+13
-96
@@ -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",
|
||||
@@ -5783,7 +5714,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 +5774,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -5946,7 +5875,6 @@
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -6905,7 +6833,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -6939,7 +6866,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 +7468,6 @@
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -8069,6 +7994,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -9489,7 +9415,6 @@
|
||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -9606,6 +9531,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 +9547,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -9710,7 +9637,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 +9646,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 +9658,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 +9701,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 +10216,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 +10757,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 +10849,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 +11092,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11507,7 +11428,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 +11517,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 +11555,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 +11994,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"
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function GraphControls({
|
||||
)}
|
||||
|
||||
<TouchButton
|
||||
onClick={onSwitchToHistorical}
|
||||
onClick={() => onSwitchToHistorical("button")}
|
||||
variant="outline"
|
||||
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
|
||||
!isLiveMode
|
||||
@@ -197,7 +197,7 @@ export function FloatingControlPanel({
|
||||
)}
|
||||
|
||||
<TouchButton
|
||||
onClick={onSwitchToHistorical}
|
||||
onClick={() => onSwitchToHistorical("button")}
|
||||
variant="outline"
|
||||
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
|
||||
!isLiveMode
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
AnimationRefs,
|
||||
AnimationState,
|
||||
BigGraphProps,
|
||||
GraphLine,
|
||||
SeriesData,
|
||||
} from "./types";
|
||||
import { alignTargetSeriesToTimestamps } from "@/lib/timeseries";
|
||||
|
||||
export function useAnimationRefs(): AnimationRefs {
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
@@ -50,7 +52,9 @@ export function buildUPlotData(
|
||||
values: number[],
|
||||
realPointsCount: number | undefined,
|
||||
realPointsCountRef: React.RefObject<number>,
|
||||
config: { lines?: Array<{ show?: boolean; value: number }> },
|
||||
config: {
|
||||
lines?: Array<Partial<GraphLine> & { show?: boolean; value: number }>;
|
||||
},
|
||||
allSeriesData?: number[][],
|
||||
): uPlot.AlignedData {
|
||||
const uData: uPlot.AlignedData = [timestamps];
|
||||
@@ -72,7 +76,14 @@ export function buildUPlotData(
|
||||
// Add config lines
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
uData.push(timestamps.map(() => line.value));
|
||||
const lineData = line.targetSeries
|
||||
? alignTargetSeriesToTimestamps(
|
||||
line.targetSeries,
|
||||
timestamps,
|
||||
line.value,
|
||||
)
|
||||
: timestamps.map(() => line.value);
|
||||
uData.push(lineData);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,7 +99,9 @@ export function animateNewPoint(
|
||||
viewMode: string,
|
||||
selectedTimeWindow: number | "all",
|
||||
startTimeRef: React.RefObject<number | null>,
|
||||
config: { lines?: Array<{ show?: boolean; value: number }> },
|
||||
config: {
|
||||
lines?: Array<Partial<GraphLine> & { show?: boolean; value: number }>;
|
||||
},
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void, // Updated signature
|
||||
getAllSeriesData?: () => number[][],
|
||||
): void {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData, TimeSeries } from "@/lib/timeseries";
|
||||
import {
|
||||
seriesToUPlotData,
|
||||
TimeSeries,
|
||||
alignTargetSeriesToTimestamps,
|
||||
} from "@/lib/timeseries";
|
||||
import {
|
||||
createEventHandlers,
|
||||
attachEventHandlers,
|
||||
@@ -8,6 +12,15 @@ import {
|
||||
import { BigGraphProps, CreateChartParams } from "./types";
|
||||
import { normalizeDataSeries } from "./animation";
|
||||
|
||||
type CustomDashedTargetLine = {
|
||||
dataIndex: number;
|
||||
dash: number[];
|
||||
color: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
const DASH_FLOW_PX_PER_SEC = 180;
|
||||
|
||||
export function createChart({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
@@ -59,7 +72,13 @@ export function createChart({
|
||||
// Add config lines data
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
const lineData = new Array(timestamps.length).fill(line.value);
|
||||
const lineData = line.targetSeries
|
||||
? alignTargetSeriesToTimestamps(
|
||||
line.targetSeries,
|
||||
timestamps,
|
||||
line.value,
|
||||
)
|
||||
: new Array(timestamps.length).fill(line.value);
|
||||
uPlotData.push(lineData as any);
|
||||
}
|
||||
});
|
||||
@@ -142,6 +161,8 @@ export function createChart({
|
||||
|
||||
// Build series configuration for ALL series (but control visibility)
|
||||
const seriesConfig: uPlot.Series[] = [{ label: "Time" }];
|
||||
const customDashedTargetLines: CustomDashedTargetLine[] = [];
|
||||
const dashFlowStartTime = performance.now();
|
||||
|
||||
// Add ALL data series with visibility control
|
||||
const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"];
|
||||
@@ -167,16 +188,35 @@ export function createChart({
|
||||
});
|
||||
|
||||
// Add config lines
|
||||
let visibleLineIndex = 0;
|
||||
const firstConfigLineDataIndex = 1 + allOriginalSeries.length;
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
const dash =
|
||||
line.dash ?? (line.type === "threshold" ? [5, 5] : undefined);
|
||||
const lineDataIndex = firstConfigLineDataIndex + visibleLineIndex;
|
||||
const isHistoricalDashedTarget = !!line.targetSeries && !!dash?.length;
|
||||
|
||||
if (isHistoricalDashedTarget) {
|
||||
customDashedTargetLines.push({
|
||||
dataIndex: lineDataIndex,
|
||||
dash: dash!,
|
||||
color: line.color,
|
||||
width: line.width ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
seriesConfig.push({
|
||||
label: line.label,
|
||||
stroke: line.color,
|
||||
// Historical dashed targets are rendered manually in draw hook to
|
||||
// avoid dash shimmer while the live x-axis slides.
|
||||
stroke: isHistoricalDashedTarget ? "rgba(0,0,0,0)" : line.color,
|
||||
width: line.width ?? 1,
|
||||
dash: line.dash ?? (line.type === "threshold" ? [5, 5] : undefined),
|
||||
dash: isHistoricalDashedTarget ? undefined : dash,
|
||||
show: true,
|
||||
points: { show: false },
|
||||
});
|
||||
visibleLineIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -306,6 +346,119 @@ export function createChart({
|
||||
}
|
||||
},
|
||||
],
|
||||
draw: [
|
||||
(u) => {
|
||||
if (customDashedTargetLines.length === 0) return;
|
||||
const xData = u.data[0] as number[] | undefined;
|
||||
if (!xData || xData.length < 2) return;
|
||||
|
||||
const xMin = u.scales.x?.min;
|
||||
const xMax = u.scales.x?.max;
|
||||
if (xMin === undefined || xMax === undefined) return;
|
||||
|
||||
const ctx = u.ctx;
|
||||
const left = u.bbox.left;
|
||||
const top = u.bbox.top;
|
||||
const snap = (v: number) => Math.round(v) + 0.5;
|
||||
const findFirstGe = (arr: number[], value: number): number => {
|
||||
let lo = 0;
|
||||
let hi = arr.length - 1;
|
||||
let ans = arr.length;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arr[mid] >= value) {
|
||||
ans = mid;
|
||||
hi = mid - 1;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
return ans;
|
||||
};
|
||||
const findLastLe = (arr: number[], value: number): number => {
|
||||
let lo = 0;
|
||||
let hi = arr.length - 1;
|
||||
let ans = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arr[mid] <= value) {
|
||||
ans = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return ans;
|
||||
};
|
||||
const firstVisibleIdx = Math.max(0, findFirstGe(xData, xMin) - 1);
|
||||
const lastVisibleIdx = Math.min(
|
||||
xData.length - 1,
|
||||
findLastLe(xData, xMax) + 1,
|
||||
);
|
||||
if (lastVisibleIdx < firstVisibleIdx) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(left, top, u.bbox.width, u.bbox.height);
|
||||
ctx.clip();
|
||||
ctx.lineCap = "butt";
|
||||
|
||||
customDashedTargetLines.forEach(
|
||||
({ dataIndex, dash, color, width }) => {
|
||||
const yData = u.data[dataIndex] as
|
||||
| Array<number | null>
|
||||
| undefined;
|
||||
if (!yData || yData.length < 2) return;
|
||||
|
||||
const dashPeriod = dash.reduce((acc, curr) => acc + curr, 0);
|
||||
ctx.setLineDash(dash);
|
||||
const elapsedMs = performance.now() - dashFlowStartTime;
|
||||
const dashTravelPx = (elapsedMs * DASH_FLOW_PX_PER_SEC) / 1000;
|
||||
ctx.lineDashOffset =
|
||||
dashPeriod > 0 ? -(dashTravelPx % dashPeriod) : 0;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.beginPath();
|
||||
|
||||
let started = false;
|
||||
let prevX = 0;
|
||||
let prevY = 0;
|
||||
|
||||
for (let i = firstVisibleIdx; i <= lastVisibleIdx; i++) {
|
||||
const value = yData[i];
|
||||
if (value === null || value === undefined) continue;
|
||||
|
||||
const x = snap(u.valToPos(xData[i], "x", true));
|
||||
const y = snap(u.valToPos(value, "y", true));
|
||||
|
||||
if (!started) {
|
||||
ctx.moveTo(x, y);
|
||||
started = true;
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (x !== prevX) {
|
||||
ctx.lineTo(x, prevY);
|
||||
}
|
||||
if (y !== prevY) {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
}
|
||||
|
||||
if (started) {
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
],
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
@@ -507,12 +660,27 @@ export function createChart({
|
||||
|
||||
const cleanup = attachEventHandlers(containerRef.current, handlers);
|
||||
|
||||
let dashFlowAnimationFrame: number | null = null;
|
||||
if (customDashedTargetLines.length > 0) {
|
||||
const animateDashFlow = () => {
|
||||
if (!uplotRef.current) return;
|
||||
uplotRef.current.redraw();
|
||||
dashFlowAnimationFrame = requestAnimationFrame(animateDashFlow);
|
||||
};
|
||||
dashFlowAnimationFrame = requestAnimationFrame(animateDashFlow);
|
||||
}
|
||||
|
||||
animationRefs.lastRenderedData.current = {
|
||||
timestamps: [...timestamps],
|
||||
values: [...primaryValues],
|
||||
};
|
||||
|
||||
return cleanup;
|
||||
return () => {
|
||||
if (dashFlowAnimationFrame !== null) {
|
||||
cancelAnimationFrame(dashFlowAnimationFrame);
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to get all valid TimeSeries from DataSeries
|
||||
|
||||
@@ -3,7 +3,12 @@ import { useRef, useCallback } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { getPrimarySeriesData, stopAnimations } from "./animation";
|
||||
import { BigGraphProps, HistoricalModeHandlers, AnimationRefs } from "./types";
|
||||
import {
|
||||
BigGraphProps,
|
||||
HistoricalModeHandlers,
|
||||
AnimationRefs,
|
||||
SwitchOrigin,
|
||||
} from "./types";
|
||||
|
||||
export function useHistoricalMode({
|
||||
newData,
|
||||
@@ -120,12 +125,43 @@ export function useHistoricalMode({
|
||||
);
|
||||
|
||||
// Switch to historical mode
|
||||
const switchToHistoricalMode = useCallback(() => {
|
||||
isInHistoricalModeRef.current = true;
|
||||
captureHistoricalFreezeTimestamp();
|
||||
stopAnimations(animationRefs);
|
||||
lastProcessedCountRef.current = 0; // Reset processed count
|
||||
}, [captureHistoricalFreezeTimestamp, animationRefs, lastProcessedCountRef]);
|
||||
const switchToHistoricalMode = useCallback(
|
||||
(origin?: SwitchOrigin) => {
|
||||
isInHistoricalModeRef.current = true;
|
||||
|
||||
// Preserve current scale
|
||||
if (origin === "gesture") {
|
||||
try {
|
||||
if (uplotRef.current) {
|
||||
const xScale = uplotRef.current.scales.x;
|
||||
const yScale = uplotRef.current.scales.y;
|
||||
manualScaleRef.current = {
|
||||
x: {
|
||||
min: xScale?.min ?? 0,
|
||||
max: xScale?.max ?? 0,
|
||||
},
|
||||
y: {
|
||||
min: yScale?.min ?? 0,
|
||||
max: yScale?.max ?? 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"Error preserving chart scales when switching to historical mode:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
manualScaleRef.current = null;
|
||||
}
|
||||
|
||||
captureHistoricalFreezeTimestamp();
|
||||
stopAnimations(animationRefs);
|
||||
lastProcessedCountRef.current = 0; // Reset processed count
|
||||
},
|
||||
[captureHistoricalFreezeTimestamp, animationRefs, lastProcessedCountRef],
|
||||
);
|
||||
|
||||
// Switch back to live mode
|
||||
const switchToLiveMode = useCallback(() => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Unit } from "@/control/units";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
import { RefObject } from "react";
|
||||
|
||||
export type SwitchOrigin = "button" | "gesture";
|
||||
|
||||
// Prop-based sync types
|
||||
export type PropGraphSync = {
|
||||
timeWindow: number | "all";
|
||||
@@ -11,6 +13,7 @@ export type PropGraphSync = {
|
||||
xRange?: { min: number; max: number };
|
||||
historicalFreezeTimestamp?: number | null;
|
||||
showFromTimestamp?: number | null;
|
||||
historicalSwitchOrigin: SwitchOrigin;
|
||||
onTimeWindowChange?: (graphId: string, timeWindow: number | "all") => void;
|
||||
onViewModeChange?: (
|
||||
graphId: string,
|
||||
@@ -27,6 +30,7 @@ export type PropGraphSync = {
|
||||
export type GraphLine = {
|
||||
type: "threshold" | "target";
|
||||
value: number;
|
||||
targetSeries?: TimeSeries;
|
||||
color: string;
|
||||
label?: string;
|
||||
width?: number;
|
||||
@@ -83,7 +87,7 @@ export type ControlProps = {
|
||||
isLiveMode: boolean;
|
||||
onTimeWindowChange: (timeWindow: number | "all") => void;
|
||||
onSwitchToLive: () => void;
|
||||
onSwitchToHistorical: () => void;
|
||||
onSwitchToHistorical: (origin: SwitchOrigin) => void;
|
||||
onExport?: () => void;
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
showFromTimestamp?: number | null;
|
||||
@@ -100,7 +104,7 @@ export interface HistoricalModeHandlers {
|
||||
captureHistoricalFreezeTimestamp: () => number;
|
||||
getHistoricalEndTimestamp: () => number;
|
||||
handleHistoricalTimeWindow: (timeWindow: number | "all") => void;
|
||||
switchToHistoricalMode: () => void;
|
||||
switchToHistoricalMode: (origin?: SwitchOrigin) => void;
|
||||
switchToLiveMode: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ export function useBigGraphEffects({
|
||||
const newIsLiveMode = syncGraph.isLiveMode;
|
||||
|
||||
if (lastState.isLiveMode && !newIsLiveMode) {
|
||||
historicalMode.switchToHistoricalMode();
|
||||
historicalMode.switchToHistoricalMode(syncGraph.historicalSwitchOrigin);
|
||||
} else if (!lastState.isLiveMode && newIsLiveMode) {
|
||||
historicalMode.switchToLiveMode();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { PropGraphSync } from "./types";
|
||||
import { PropGraphSync, SwitchOrigin } from "./types";
|
||||
import { GraphExportData, exportGraphsToExcel } from "./excelExport";
|
||||
import { useGraphSettingsStore } from "@/stores/graphSettingsStore";
|
||||
|
||||
@@ -19,6 +19,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
null,
|
||||
);
|
||||
|
||||
const historicalSwitchOriginRef = useRef<SwitchOrigin>("gesture");
|
||||
const historicalFreezeTimestampRef = useRef<number | null>(null);
|
||||
const graphDataRef = useRef<Map<string, () => GraphExportData | null>>(
|
||||
new Map(),
|
||||
@@ -65,6 +66,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
showFromTimestamp?: number | null;
|
||||
clearHistoricalFreeze?: boolean;
|
||||
setHistoricalFreeze?: boolean;
|
||||
historicalSwitchOrigin?: SwitchOrigin;
|
||||
},
|
||||
requestId?: number,
|
||||
) => {
|
||||
@@ -104,6 +106,10 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
historicalFreezeTimestampRef.current = Date.now();
|
||||
}
|
||||
|
||||
if (updates.historicalSwitchOrigin !== undefined) {
|
||||
historicalSwitchOriginRef.current = updates.historicalSwitchOrigin;
|
||||
}
|
||||
|
||||
clearChangeSource();
|
||||
});
|
||||
},
|
||||
@@ -164,6 +170,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
viewMode: "manual",
|
||||
isLiveMode: false,
|
||||
setHistoricalFreeze: historicalFreezeTimestampRef.current === null,
|
||||
historicalSwitchOrigin: "gesture",
|
||||
});
|
||||
},
|
||||
[updateSyncState],
|
||||
@@ -210,6 +217,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
isLiveMode: false,
|
||||
viewMode: "manual",
|
||||
setHistoricalFreeze: true,
|
||||
historicalSwitchOrigin: "button",
|
||||
});
|
||||
}, [updateSyncState]);
|
||||
|
||||
@@ -242,6 +250,7 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
xRange,
|
||||
historicalFreezeTimestamp: historicalFreezeTimestampRef.current,
|
||||
showFromTimestamp,
|
||||
historicalSwitchOrigin: historicalSwitchOriginRef.current,
|
||||
onTimeWindowChange: handleTimeWindowChange,
|
||||
onViewModeChange: handleViewModeChange,
|
||||
onZoomChange: handleZoomChangeThrottled,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { Page } from "@/components/Page";
|
||||
import React from "react";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue";
|
||||
import { EditValue } from "@/control/EditValue";
|
||||
import { useMock1 } from "./use";
|
||||
import { SelectionGroup } from "@/control/SelectionGroup";
|
||||
import { Mode } from "./namespace";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
|
||||
type SineWaveCardProps = {
|
||||
title: string;
|
||||
timeseries: TimeSeries;
|
||||
};
|
||||
|
||||
function SineWaveCard({ title, timeseries }: SineWaveCardProps) {
|
||||
return (
|
||||
<ControlCard title={title}>
|
||||
<TimeSeriesValueNumeric
|
||||
label="Current Value"
|
||||
timeseries={timeseries}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
/>
|
||||
</ControlCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function Mock1ControlPage() {
|
||||
const {
|
||||
state,
|
||||
defaultState,
|
||||
sineWave1,
|
||||
sineWave2,
|
||||
sineWave3,
|
||||
sineWaveSum,
|
||||
setFrequency1,
|
||||
setFrequency2,
|
||||
setFrequency3,
|
||||
setMode,
|
||||
isDisabled,
|
||||
} = useMock1();
|
||||
|
||||
// Controlled local states synced with consolidated state
|
||||
const frequency1 = state?.frequency1 ?? 1.0;
|
||||
const frequency2 = state?.frequency2 ?? 2.0;
|
||||
const frequency3 = state?.frequency3 ?? 5.0;
|
||||
const mode = state?.mode_state?.mode ?? "Standby";
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
<SineWaveCard title="Sine Wave Sum" timeseries={sineWaveSum} />
|
||||
<SineWaveCard title="Sine Wave 1" timeseries={sineWave1} />
|
||||
<SineWaveCard title="Sine Wave 2" timeseries={sineWave2} />
|
||||
<SineWaveCard title="Sine Wave 3" timeseries={sineWave3} />
|
||||
|
||||
<ControlCard title="Frequency">
|
||||
<div className="flex flex-row gap-2">
|
||||
<EditValue
|
||||
title="Frequency 1"
|
||||
unit="mHz"
|
||||
value={frequency1}
|
||||
defaultValue={defaultState?.frequency1}
|
||||
min={0.0}
|
||||
max={1000}
|
||||
step={0.1}
|
||||
renderValue={(value) => value.toFixed(0)}
|
||||
onChange={setFrequency1}
|
||||
/>
|
||||
<EditValue
|
||||
title="Frequency 2"
|
||||
unit="mHz"
|
||||
value={frequency2}
|
||||
defaultValue={defaultState?.frequency2}
|
||||
min={0.0}
|
||||
max={1000}
|
||||
step={0.1}
|
||||
renderValue={(value) => value.toFixed(0)}
|
||||
onChange={setFrequency2}
|
||||
/>
|
||||
<EditValue
|
||||
title="Frequency 3"
|
||||
unit="mHz"
|
||||
value={frequency3}
|
||||
defaultValue={defaultState?.frequency3}
|
||||
min={0.0}
|
||||
max={1000}
|
||||
step={0.1}
|
||||
renderValue={(value) => value.toFixed(0)}
|
||||
onChange={setFrequency3}
|
||||
/>
|
||||
</div>
|
||||
</ControlCard>
|
||||
|
||||
<ControlCard title="Mode">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm font-medium">Mode</div>
|
||||
<SelectionGroup
|
||||
value={mode}
|
||||
onChange={(newMode: Mode) => setMode(newMode)}
|
||||
disabled={isDisabled}
|
||||
options={{
|
||||
Standby: { children: "Standby" },
|
||||
Running: { children: "Running" },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Topbar } from "@/components/Topbar";
|
||||
import { mock1SerialRoute } from "@/routes/routes";
|
||||
import React from "react";
|
||||
|
||||
export function Mock1Page() {
|
||||
const { serial } = mock1SerialRoute.useParams();
|
||||
return (
|
||||
<Topbar
|
||||
pathname={`/_sidebar/machines/mock1/${serial}`}
|
||||
items={[
|
||||
{
|
||||
link: "control",
|
||||
activeLink: "control",
|
||||
title: "Control",
|
||||
icon: "lu:CirclePlay",
|
||||
},
|
||||
{
|
||||
link: "graph",
|
||||
activeLink: "graph",
|
||||
title: "Graph",
|
||||
icon: "lu:CirclePlay",
|
||||
},
|
||||
{
|
||||
link: "manual",
|
||||
activeLink: "manual",
|
||||
title: "Manual",
|
||||
icon: "lu:BookOpen",
|
||||
},
|
||||
{
|
||||
link: "presets",
|
||||
activeLink: "presets",
|
||||
title: "Presets",
|
||||
icon: "lu:Save",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import { useMock1 } from "./use";
|
||||
import { mock1 } from "@/machines/properties";
|
||||
|
||||
import { PresetsPage } from "@/components/preset/PresetsPage";
|
||||
import { Preset } from "@/lib/preset/preset";
|
||||
import { PresetPreviewEntries } from "@/components/preset/PresetPreviewTable";
|
||||
import { z } from "zod";
|
||||
|
||||
const mock1PresetDataSchema = z
|
||||
.object({
|
||||
frequency1: z.number(),
|
||||
frequency2: z.number(),
|
||||
frequency3: z.number(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
type Mock1 = z.infer<typeof mock1PresetDataSchema>;
|
||||
|
||||
const schemas = new Map([[1, mock1PresetDataSchema]]);
|
||||
|
||||
const previewEntries: PresetPreviewEntries<Mock1> = [
|
||||
{
|
||||
name: "Frequency 1",
|
||||
unit: "mHz",
|
||||
renderValue: (data: Mock1) => data?.frequency1?.toFixed(3),
|
||||
},
|
||||
{
|
||||
name: "Frequency 2",
|
||||
unit: "mHz",
|
||||
renderValue: (data: Mock1) => data?.frequency2?.toFixed(3),
|
||||
},
|
||||
{
|
||||
name: "Frequency 3",
|
||||
unit: "mHz",
|
||||
renderValue: (data: Mock1) => data?.frequency3?.toFixed(3),
|
||||
},
|
||||
];
|
||||
|
||||
export function Mock1PresetsPage() {
|
||||
const { setFrequency1, setFrequency2, setFrequency3, defaultState, state } =
|
||||
useMock1();
|
||||
|
||||
const applyPreset = (preset: Preset<Mock1>) => {
|
||||
const frequency1 = preset.data?.frequency1 ?? 100;
|
||||
const frequency2 = preset.data?.frequency2 ?? 200;
|
||||
const frequency3 = preset.data?.frequency3 ?? 500;
|
||||
|
||||
setFrequency1(frequency1);
|
||||
setFrequency2(frequency2);
|
||||
setFrequency3(frequency3);
|
||||
};
|
||||
|
||||
const toPresetData = (s: typeof state): Mock1 => ({
|
||||
frequency1: s?.frequency1 ?? defaultState?.frequency1,
|
||||
frequency2: s?.frequency2 ?? defaultState?.frequency2,
|
||||
frequency3: s?.frequency3 ?? defaultState?.frequency3,
|
||||
});
|
||||
|
||||
return (
|
||||
<PresetsPage
|
||||
machine_identification={mock1.machine_identification}
|
||||
currentState={toPresetData(state)}
|
||||
schemas={schemas}
|
||||
schemaVersion={1}
|
||||
applyPreset={applyPreset}
|
||||
previewEntries={previewEntries}
|
||||
defaultState={toPresetData(defaultState)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @file mock1Namespace.ts
|
||||
* @description TypeScript implementation of Mock1 namespace with Zod schema validation.
|
||||
*/
|
||||
|
||||
import { StoreApi } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
EventHandler,
|
||||
eventSchema,
|
||||
Event,
|
||||
handleUnhandledEventError,
|
||||
NamespaceId,
|
||||
createNamespaceHookImplementation,
|
||||
ThrottledStoreUpdater,
|
||||
} from "../../../../client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import {
|
||||
createTimeSeries,
|
||||
TimeSeries,
|
||||
TimeSeriesValue,
|
||||
} from "@/lib/timeseries";
|
||||
|
||||
// ========== Event Schema Definitions ==========
|
||||
/**
|
||||
* Mode enum for Mock Machine
|
||||
*/
|
||||
export const modeSchema = z.enum(["Standby", "Running"]);
|
||||
|
||||
/**
|
||||
* Mode state schema
|
||||
*/
|
||||
export const modeStateSchema = z.object({
|
||||
mode: modeSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Live values event schema (time-series data)
|
||||
*/
|
||||
export const liveValuesEventDataSchema = z.object({
|
||||
amplitude_sum: z.number(),
|
||||
amplitude1: z.number(),
|
||||
amplitude2: z.number(),
|
||||
amplitude3: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* State event schema (consolidated state)
|
||||
*/
|
||||
export const stateEventDataSchema = z.object({
|
||||
is_default_state: z.boolean(),
|
||||
frequency1: z.number(),
|
||||
frequency2: z.number(),
|
||||
frequency3: z.number(),
|
||||
mode_state: modeStateSchema,
|
||||
});
|
||||
|
||||
// ========== Event Schemas with Wrappers ==========
|
||||
export const liveValuesEventSchema = eventSchema(liveValuesEventDataSchema);
|
||||
export const stateEventSchema = eventSchema(stateEventDataSchema);
|
||||
|
||||
// ========== Type Inferences ==========
|
||||
export type Mode = z.infer<typeof modeSchema>;
|
||||
export type ModeState = z.infer<typeof modeStateSchema>;
|
||||
export type LiveValuesEvent = z.infer<typeof liveValuesEventSchema>;
|
||||
export type StateEvent = z.infer<typeof stateEventSchema>;
|
||||
|
||||
export type Mock1NamespaceStore = {
|
||||
// Single state event from server
|
||||
state: StateEvent | null;
|
||||
defaultState: StateEvent | null;
|
||||
|
||||
// Time series data for live values
|
||||
sineWaveSum: TimeSeries;
|
||||
sineWave1: TimeSeries;
|
||||
sineWave2: TimeSeries;
|
||||
sineWave3: TimeSeries;
|
||||
};
|
||||
|
||||
const { initialTimeSeries: sineWave, insert: addSineWave } = createTimeSeries();
|
||||
|
||||
/**
|
||||
* Factory function to create a new Mock1 namespace store
|
||||
* @returns A new Zustand store instance for Mock1 namespace
|
||||
*/
|
||||
export const createMock1NamespaceStore = (): StoreApi<Mock1NamespaceStore> => {
|
||||
return create<Mock1NamespaceStore>(() => {
|
||||
return {
|
||||
state: null,
|
||||
defaultState: null,
|
||||
sineWaveSum: sineWave,
|
||||
sineWave1: sineWave,
|
||||
sineWave2: sineWave,
|
||||
sineWave3: sineWave,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a message handler for Mock1 namespace events with validation and appropriate caching strategies
|
||||
* @param store The store to update when messages are received
|
||||
* @param throttledUpdater Throttled updater for batching updates at 30 FPS
|
||||
* @returns A message handler function
|
||||
*/
|
||||
export function mock1MessageHandler(
|
||||
store: StoreApi<Mock1NamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<Mock1NamespaceStore>,
|
||||
): EventHandler {
|
||||
return (event: Event<any>) => {
|
||||
const eventName = event.name;
|
||||
|
||||
// Helper function to update store through buffer
|
||||
const updateStore = (
|
||||
updater: (state: Mock1NamespaceStore) => Mock1NamespaceStore,
|
||||
) => {
|
||||
throttledUpdater.updateWith(updater);
|
||||
};
|
||||
|
||||
try {
|
||||
// State events (latest only)
|
||||
if (eventName === "StateEvent") {
|
||||
const stateEvent = stateEventSchema.parse(event);
|
||||
console.log("StateEvent", stateEvent);
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
state: stateEvent,
|
||||
// only set default state if is_default_state is true
|
||||
defaultState: stateEvent.data.is_default_state
|
||||
? stateEvent
|
||||
: state.defaultState,
|
||||
}));
|
||||
}
|
||||
// Live values events (time-series data)
|
||||
else if (eventName === "LiveValuesEvent") {
|
||||
const liveValuesEvent = liveValuesEventSchema.parse(event);
|
||||
const wave1Value: TimeSeriesValue = {
|
||||
value: liveValuesEvent.data.amplitude1 ?? 0,
|
||||
timestamp: liveValuesEvent.ts,
|
||||
};
|
||||
const wave2Value: TimeSeriesValue = {
|
||||
value: liveValuesEvent.data.amplitude2 ?? 0,
|
||||
timestamp: liveValuesEvent.ts,
|
||||
};
|
||||
const wave3Value: TimeSeriesValue = {
|
||||
value: liveValuesEvent.data.amplitude3 ?? 0,
|
||||
timestamp: liveValuesEvent.ts,
|
||||
};
|
||||
const waveSumValue: TimeSeriesValue = {
|
||||
value: liveValuesEvent.data.amplitude_sum ?? 0,
|
||||
timestamp: liveValuesEvent.ts,
|
||||
};
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
sineWaveSum: addSineWave(state.sineWaveSum, waveSumValue),
|
||||
sineWave1: addSineWave(state.sineWave1, wave1Value),
|
||||
sineWave2: addSineWave(state.sineWave2, wave2Value),
|
||||
sineWave3: addSineWave(state.sineWave3, wave3Value),
|
||||
}));
|
||||
} else {
|
||||
handleUnhandledEventError(eventName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error processing ${eventName} event:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Mock1 namespace implementation
|
||||
*/
|
||||
const useMock1NamespaceImplementation =
|
||||
createNamespaceHookImplementation<Mock1NamespaceStore>({
|
||||
createStore: createMock1NamespaceStore,
|
||||
createEventHandler: mock1MessageHandler,
|
||||
});
|
||||
|
||||
export function useMock1Namespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): Mock1NamespaceStore {
|
||||
// Generate namespace ID from validated machine ID
|
||||
const namespaceId: NamespaceId = {
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
};
|
||||
|
||||
// Use the implementation with validated namespace ID
|
||||
return useMock1NamespaceImplementation(namespaceId);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { useMachineMutate as useMachineMutation } from "@/client/useClient";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { mock1 } from "@/machines/properties";
|
||||
import { mock1SerialRoute } from "@/routes/routes";
|
||||
import { z } from "zod";
|
||||
import { useMock1Namespace, Mode, StateEvent } from "./namespace";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { produce } from "immer";
|
||||
|
||||
function useMock(machine_identification_unique: MachineIdentificationUnique) {
|
||||
// Get consolidated state and live values from namespace
|
||||
const { state, defaultState, sineWaveSum, sineWave1, sineWave2, sineWave3 } =
|
||||
useMock1Namespace(machine_identification_unique);
|
||||
|
||||
// Single optimistic state for all state management
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
|
||||
useEffect(() => {
|
||||
if (state) {
|
||||
stateOptimistic.setReal(state);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// Helper function for optimistic updates using produce
|
||||
const updateStateOptimistically = (
|
||||
producer: (current: StateEvent) => void,
|
||||
serverRequest: () => Promise<any>,
|
||||
) => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState && !stateOptimistic.isOptimistic) {
|
||||
stateOptimistic.setOptimistic(produce(currentState, producer));
|
||||
}
|
||||
serverRequest()
|
||||
.then((response) => {
|
||||
if (!response.success) stateOptimistic.resetToReal();
|
||||
})
|
||||
.catch(() => stateOptimistic.resetToReal());
|
||||
};
|
||||
|
||||
// Action functions with verb-first names
|
||||
const setSchemaFrequency1 = z.object({ SetFrequency1: z.number() });
|
||||
const { request: requestSetFrequency1 } =
|
||||
useMachineMutation(setSchemaFrequency1);
|
||||
const setFrequency1 = (frequency: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.frequency1 = frequency;
|
||||
},
|
||||
() =>
|
||||
requestSetFrequency1({
|
||||
machine_identification_unique,
|
||||
data: {
|
||||
SetFrequency1: frequency,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setSchemaFrequency2 = z.object({ SetFrequency2: z.number() });
|
||||
const { request: requestSetFrequency2 } =
|
||||
useMachineMutation(setSchemaFrequency2);
|
||||
const setFrequency2 = (frequency: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.frequency2 = frequency;
|
||||
},
|
||||
() =>
|
||||
requestSetFrequency2({
|
||||
machine_identification_unique,
|
||||
data: {
|
||||
SetFrequency2: frequency,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setSchemaFrequency3 = z.object({ SetFrequency3: z.number() });
|
||||
const { request: requestSetFrequency3 } =
|
||||
useMachineMutation(setSchemaFrequency3);
|
||||
const setFrequency3 = (frequency: number) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.frequency3 = frequency;
|
||||
},
|
||||
() =>
|
||||
requestSetFrequency3({
|
||||
machine_identification_unique,
|
||||
data: {
|
||||
SetFrequency3: frequency,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const schemaSetMode = z.object({ SetMode: z.enum(["Standby", "Running"]) });
|
||||
const { request: requestSetMode } = useMachineMutation(schemaSetMode);
|
||||
const setMode = (mode: Mode) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.data.mode_state.mode = mode;
|
||||
},
|
||||
() =>
|
||||
requestSetMode({
|
||||
machine_identification_unique,
|
||||
data: {
|
||||
SetMode: mode,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
// Consolidated state
|
||||
state: stateOptimistic.value?.data,
|
||||
|
||||
// Default state for initial values
|
||||
defaultState: defaultState?.data,
|
||||
|
||||
// Individual live values (TimeSeries)
|
||||
sineWave1,
|
||||
sineWave2,
|
||||
sineWave3,
|
||||
sineWaveSum,
|
||||
|
||||
// Loading states
|
||||
isLoading: stateOptimistic.isOptimistic,
|
||||
isDisabled: !stateOptimistic.isInitialized,
|
||||
|
||||
// Action functions (verb-first)
|
||||
setFrequency1,
|
||||
setFrequency2,
|
||||
setFrequency3,
|
||||
setMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function useMock1() {
|
||||
const { serial: serialString } = mock1SerialRoute.useParams();
|
||||
|
||||
// Memoize the machine identification to keep it stable between renders
|
||||
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
|
||||
const serial = parseInt(serialString); // Use 0 as fallback if NaN
|
||||
|
||||
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: mock1.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]); // Only recreate when serialString changes
|
||||
|
||||
const mock = useMock(machineIdentification);
|
||||
|
||||
return {
|
||||
...mock,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { VENDOR_QITECH } from "../../machines/properties";
|
||||
import { MachineProperties } from "../../machines/types";
|
||||
|
||||
export const ff01_mock: MachineProperties =
|
||||
{
|
||||
name: "FF01",
|
||||
version: "V1",
|
||||
slug: "stahlwerk_ff01",
|
||||
icon: "lu:FlaskConical",
|
||||
machine_identification:
|
||||
{
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x0007,
|
||||
},
|
||||
device_roles: [],
|
||||
};
|
||||
|
||||
export const machineProperties: MachineProperties[] = [
|
||||
ff01_mock
|
||||
];
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Laser1ControlPage } from "@/extensions/stahlwerk/machines/ff01_mock/Page";
|
||||
import { Laser1GraphsPage } from "@/machines/laser/laser1/Laser1Graph";
|
||||
import { Laser1Page } from "@/machines/laser/laser1/Laser1Page";
|
||||
import { Laser1PresetsPage } from "@/machines/laser/laser1/Laser1PresetsPage";
|
||||
import { machinesRoute } from "@/routes/routes";
|
||||
|
||||
export const mock1SerialRoute = createRoute({
|
||||
getParentRoute: () => machinesRoute,
|
||||
path: "mock1/$serial",
|
||||
component: () => <Mock1Page />,
|
||||
});
|
||||
|
||||
export const mock1ControlRoute = createRoute({
|
||||
getParentRoute: () => mock1SerialRoute,
|
||||
path: "control",
|
||||
component: () => <Mock1ControlPage />,
|
||||
});
|
||||
|
||||
export const mock1GraphRoute = createRoute({
|
||||
getParentRoute: () => mock1SerialRoute,
|
||||
path: "graph",
|
||||
component: () => <Mock1GraphPage />,
|
||||
});
|
||||
|
||||
export const mock1ManualRoute = createRoute({
|
||||
getParentRoute: () => mock1SerialRoute,
|
||||
path: "manual",
|
||||
component: () => <Mock1ManualPage />,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -24,6 +24,13 @@ export function Extruder2GraphsPage() {
|
||||
motorCurrent,
|
||||
motorPower,
|
||||
combinedPower,
|
||||
// Target value history for graph target lines
|
||||
targetPressure,
|
||||
targetScrewRpm,
|
||||
targetNozzleTemperature,
|
||||
targetFrontTemperature,
|
||||
targetMiddleTemperature,
|
||||
targetBackTemperature,
|
||||
} = useExtruder2();
|
||||
|
||||
const syncHook = useGraphSync("extruder-graphs");
|
||||
@@ -49,6 +56,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.nozzle.target_temperature,
|
||||
targetSeries: targetNozzleTemperature,
|
||||
color: "#ef4444",
|
||||
show: true,
|
||||
},
|
||||
@@ -69,6 +77,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.front.target_temperature,
|
||||
targetSeries: targetFrontTemperature,
|
||||
color: "#f59e0b",
|
||||
show: true,
|
||||
},
|
||||
@@ -89,6 +98,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.middle.target_temperature,
|
||||
targetSeries: targetMiddleTemperature,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
@@ -109,6 +119,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.back.target_temperature,
|
||||
targetSeries: targetBackTemperature,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
@@ -253,6 +264,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
targetSeries: targetPressure,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
@@ -306,6 +318,7 @@ export function Extruder2GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
targetSeries: targetScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
|
||||
@@ -221,6 +221,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 +261,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 +301,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 +319,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 +466,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
|
||||
@@ -423,6 +431,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,
|
||||
|
||||
@@ -24,6 +24,13 @@ export function Extruder3GraphsPage() {
|
||||
motorCurrent,
|
||||
motorPower,
|
||||
combinedPower,
|
||||
// Target value history for graph target lines
|
||||
targetPressure,
|
||||
targetScrewRpm,
|
||||
targetNozzleTemperature,
|
||||
targetFrontTemperature,
|
||||
targetMiddleTemperature,
|
||||
targetBackTemperature,
|
||||
} = useExtruder3();
|
||||
|
||||
const syncHook = useGraphSync("extruder-graphs");
|
||||
@@ -49,6 +56,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.nozzle.target_temperature,
|
||||
targetSeries: targetNozzleTemperature,
|
||||
color: "#ef4444",
|
||||
show: true,
|
||||
},
|
||||
@@ -69,6 +77,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.front.target_temperature,
|
||||
targetSeries: targetFrontTemperature,
|
||||
color: "#f59e0b",
|
||||
show: true,
|
||||
},
|
||||
@@ -89,6 +98,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.middle.target_temperature,
|
||||
targetSeries: targetMiddleTemperature,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
@@ -109,6 +119,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.heating_states.back.target_temperature,
|
||||
targetSeries: targetBackTemperature,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
@@ -253,6 +264,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
targetSeries: targetPressure,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
@@ -306,6 +318,7 @@ export function Extruder3GraphsPage() {
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
targetSeries: targetScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
|
||||
@@ -221,6 +221,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 +261,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 +301,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 +319,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 +466,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
|
||||
@@ -423,6 +431,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
machineIdentificationEquals,
|
||||
} from "./types";
|
||||
|
||||
import {
|
||||
machineProperties as stahlwerkMachineProperties
|
||||
} from "../extensions/stahlwerk/properties";
|
||||
|
||||
export const VENDOR_QITECH = 0x0001;
|
||||
|
||||
export type VendorProperties = {
|
||||
@@ -655,6 +659,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,
|
||||
@@ -672,6 +711,8 @@ export const machineProperties: MachineProperties[] = [
|
||||
ip20TestMachine,
|
||||
wagoDoTestMachine,
|
||||
TestMotor,
|
||||
testmachinestepper,
|
||||
...stahlwerkMachineProperties
|
||||
];
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function Winder2GraphsPage() {
|
||||
traversePosition,
|
||||
tensionArmAngle,
|
||||
pullerSpeed,
|
||||
targetPullerSpeed,
|
||||
spoolProgress,
|
||||
} = useWinder2();
|
||||
|
||||
@@ -31,6 +32,7 @@ export function Winder2GraphsPage() {
|
||||
<PullerSpeedGraph
|
||||
syncHook={syncHook}
|
||||
newData={pullerSpeed}
|
||||
targetSeries={targetPullerSpeed}
|
||||
targetSpeed={state?.puller_state?.target_speed}
|
||||
unit="m/min"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
@@ -243,12 +245,14 @@ export function SpoolProgressGraph({
|
||||
export function PullerSpeedGraph({
|
||||
syncHook,
|
||||
newData,
|
||||
targetSeries,
|
||||
targetSpeed,
|
||||
unit,
|
||||
renderValue,
|
||||
}: {
|
||||
syncHook: ReturnType<typeof useGraphSync>;
|
||||
newData: TimeSeries | null;
|
||||
targetSeries?: TimeSeries;
|
||||
targetSpeed?: number;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
@@ -259,6 +263,7 @@ export function PullerSpeedGraph({
|
||||
lines.push({
|
||||
type: "target",
|
||||
value: targetSpeed,
|
||||
targetSeries,
|
||||
label: "Target Speed",
|
||||
color: "#06b6d4", // Match series color
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ export function useWinder2() {
|
||||
defaultState,
|
||||
traversePosition,
|
||||
pullerSpeed,
|
||||
targetPullerSpeed,
|
||||
spoolRpm,
|
||||
tensionArmAngle,
|
||||
spoolProgress,
|
||||
@@ -638,6 +639,7 @@ export function useWinder2() {
|
||||
// Individual live values (TimeSeries)
|
||||
traversePosition,
|
||||
pullerSpeed,
|
||||
targetPullerSpeed,
|
||||
spoolRpm,
|
||||
tensionArmAngle,
|
||||
spoolProgress,
|
||||
|
||||
@@ -211,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";
|
||||
@@ -172,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",
|
||||
@@ -515,6 +530,10 @@ export const rootTree = RootRoute.addChildren([
|
||||
|
||||
testMachineSerialRoute.addChildren([testMachineControlRoute]),
|
||||
|
||||
testMachineStepperSerialRoute.addChildren([
|
||||
testMachineStepperControlRoute,
|
||||
]),
|
||||
|
||||
analogInputTestMachineSerialRoute.addChildren([
|
||||
analogInputTestMachineControlRoute,
|
||||
]),
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -32,12 +32,15 @@ pub mod motor_test_machine;
|
||||
pub mod registry;
|
||||
pub mod serial;
|
||||
pub mod test_machine;
|
||||
pub mod test_machine_stepper;
|
||||
pub mod wago_8ch_dio_test_machine;
|
||||
pub mod wago_ai_test_machine;
|
||||
pub mod wago_do_test_machine;
|
||||
pub mod wago_power;
|
||||
pub mod winder2;
|
||||
|
||||
pub mod stahlwerk;
|
||||
|
||||
pub const VENDOR_QITECH: u16 = 0x0001;
|
||||
pub const MACHINE_WINDER_V1: u16 = 0x0002;
|
||||
pub const MACHINE_EXTRUDER_V1: u16 = 0x0004;
|
||||
@@ -53,6 +56,7 @@ pub const ANALOG_INPUT_TEST_MACHINE: u16 = 0x0035;
|
||||
pub const WAGO_AI_TEST_MACHINE: u16 = 0x0036;
|
||||
pub const DIGITAL_INPUT_TEST_MACHINE: u16 = 0x0040;
|
||||
pub const WAGO_8CH_IO_TEST_MACHINE: u16 = 0x0041;
|
||||
pub const TEST_MACHINE_STEPPER: u16 = 0x0037;
|
||||
|
||||
pub const MOTOR_TEST_MACHINE: u16 = 0x0011;
|
||||
pub const WAGO_DO_TEST_MACHINE: u16 = 0x000E;
|
||||
|
||||
@@ -49,7 +49,13 @@ impl MachineIdentification {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slug(&self) -> String {
|
||||
pub fn slug(&self) -> String
|
||||
{
|
||||
if let Some(slug) = stahlwerk::get_slug(self.machine)
|
||||
{
|
||||
return slug.to_string();
|
||||
}
|
||||
|
||||
match self.machine {
|
||||
x if x == MACHINE_WINDER_V1 => "winder_v1".to_string(),
|
||||
x if x == MACHINE_EXTRUDER_V1 => "extruder_v1".to_string(),
|
||||
@@ -190,6 +196,7 @@ use crate::TEST_MACHINE;
|
||||
use crate::VENDOR_QITECH;
|
||||
use crate::WAGO_AI_TEST_MACHINE;
|
||||
use crate::WAGO_DO_TEST_MACHINE;
|
||||
use crate::stahlwerk;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MachineIdentificationAddresses {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::analog_input_test_machine::AnalogInputTestMachine;
|
||||
use crate::digital_input_test_machine::DigitalInputTestMachine;
|
||||
use crate::ip20_test_machine::IP20TestMachine;
|
||||
use crate::stahlwerk::{self, register_machines};
|
||||
use crate::wago_8ch_dio_test_machine::Wago8chDigitalIOTestMachine;
|
||||
use crate::wago_ai_test_machine::WagoAiTestMachine;
|
||||
use crate::wago_do_test_machine::WagoDOTestMachine;
|
||||
@@ -22,7 +23,7 @@ use crate::{
|
||||
winder2::Winder2,
|
||||
};
|
||||
|
||||
use crate::test_machine::TestMachine;
|
||||
use crate::{test_machine::TestMachine, test_machine_stepper::TestMachineStepper};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
@@ -144,6 +145,11 @@ lazy_static! {
|
||||
Wago8chDigitalIOTestMachine::MACHINE_IDENTIFICATION,
|
||||
);
|
||||
|
||||
mc.register::<TestMachineStepper>(TestMachineStepper::MACHINE_IDENTIFICATION);
|
||||
|
||||
// register all machines from stahlwerk
|
||||
stahlwerk::register_machines(&mut mc);
|
||||
|
||||
mc
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use std::{fmt::Debug, sync::Arc, time::Instant};
|
||||
|
||||
use crate::{MachineMessage, machine_identification::MachineIdentificationUnique};
|
||||
use smol::channel::{Receiver, Sender};
|
||||
use tracing::instrument;
|
||||
use crate::AsyncThreadMessage;
|
||||
|
||||
use control_core::socketio::namespace::{
|
||||
CacheableEvents,
|
||||
Namespace,
|
||||
NamespaceCacheingLogic
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BaseMachine
|
||||
{
|
||||
pub machine_uid: MachineIdentificationUnique,
|
||||
pub main_sender: Option<Sender<AsyncThreadMessage>>,
|
||||
|
||||
pub namespace: NamespaceImpl,
|
||||
|
||||
// socketio
|
||||
pub last_measurement_emit: Instant,
|
||||
|
||||
// api
|
||||
pub api_sender: Sender<MachineMessage>,
|
||||
pub api_receiver: Receiver<MachineMessage>,
|
||||
|
||||
pub emitted_default_state: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NamespaceImpl
|
||||
{
|
||||
pub namespace: Option<Namespace>,
|
||||
}
|
||||
|
||||
impl NamespaceImpl
|
||||
{
|
||||
pub fn new(namespace: Option<Namespace>) -> Self
|
||||
{
|
||||
Self { namespace }
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: CacheableEvents<E>> NamespaceCacheingLogic<E> for NamespaceImpl
|
||||
{
|
||||
#[instrument(skip_all)]
|
||||
fn emit(&mut self, events: E)
|
||||
{
|
||||
let event = Arc::new(events.event_value());
|
||||
let buffer_fn = events.event_cache_fn();
|
||||
|
||||
match &mut self.namespace
|
||||
{
|
||||
Some(ns) => ns.emit(event, &buffer_fn),
|
||||
None => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use super::DerivedMachine;
|
||||
|
||||
impl DerivedMachine
|
||||
{
|
||||
pub fn act_ext(&mut self, now: Instant)
|
||||
{
|
||||
_ = now;
|
||||
// So quiet in here
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use control_core_derive::BuildEvent;
|
||||
use serde::{ Deserialize, Serialize };
|
||||
|
||||
use super::DerivedMachine;
|
||||
|
||||
#[derive(Serialize, Debug, Clone, Default)]
|
||||
pub struct LiveValuesEvent
|
||||
{
|
||||
pub counter: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone, PartialEq, BuildEvent)]
|
||||
pub struct StateEvent
|
||||
{
|
||||
pub is_default_state: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub enum Mutation
|
||||
{
|
||||
Increment,
|
||||
}
|
||||
|
||||
impl DerivedMachine
|
||||
{
|
||||
pub fn get_live_values(&self) -> LiveValuesEvent
|
||||
{
|
||||
LiveValuesEvent { counter: self.counter }
|
||||
}
|
||||
|
||||
pub fn get_state(&self) -> StateEvent
|
||||
{
|
||||
StateEvent { is_default_state: true }
|
||||
}
|
||||
|
||||
pub fn mutate(&mut self, mutation: Mutation)
|
||||
{
|
||||
match mutation
|
||||
{
|
||||
Mutation::Increment => self.increment(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// external deps
|
||||
use anyhow::{Error};
|
||||
use serde_json::Value;
|
||||
use smol::channel::Sender;
|
||||
|
||||
// local deps
|
||||
use control_core::socketio::{
|
||||
event::{
|
||||
BuildEvent,
|
||||
Event,
|
||||
GenericEvent
|
||||
},
|
||||
namespace::{
|
||||
CacheFn,
|
||||
CacheableEvents,
|
||||
Namespace,
|
||||
NamespaceCacheingLogic,
|
||||
cache_first_and_last_event
|
||||
}
|
||||
};
|
||||
|
||||
use crate::{
|
||||
AsyncThreadMessage,
|
||||
Machine,
|
||||
MachineAct,
|
||||
MachineApi,
|
||||
MachineMessage,
|
||||
MachineNewParams,
|
||||
MachineNewTrait,
|
||||
MachineValues,
|
||||
machine_identification::MachineIdentificationUnique,
|
||||
};
|
||||
|
||||
use crate::stahlwerk::{
|
||||
r#abstract::{
|
||||
BaseMachine,
|
||||
NamespaceImpl
|
||||
}
|
||||
};
|
||||
|
||||
use super::{
|
||||
DerivedMachine,
|
||||
api::{
|
||||
LiveValuesEvent,
|
||||
StateEvent,
|
||||
Mutation,
|
||||
}
|
||||
};
|
||||
|
||||
impl DerivedMachine
|
||||
{
|
||||
/// Emit live values data event with the current sine wave amplitude
|
||||
pub fn emit_live_values(&mut self)
|
||||
{
|
||||
let event = self.get_live_values().build();
|
||||
self.base.namespace.emit(Events::LiveValues(event));
|
||||
}
|
||||
|
||||
/// Emit the current state of the mock machine only if values have changed
|
||||
pub fn emit_state(&mut self)
|
||||
{
|
||||
let state = self.get_state();
|
||||
let event = state.build();
|
||||
self.base.namespace.emit(Events::State(event));
|
||||
self.base.emitted_default_state = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineNewTrait for DerivedMachine
|
||||
{
|
||||
fn new(params: &MachineNewParams<'_, '_, '_, '_, '_, '_, '_>) -> Result<Self, Error>
|
||||
where
|
||||
Self: Sized
|
||||
{
|
||||
let (api_sender, api_receiver) = smol::channel::unbounded();
|
||||
|
||||
let base = BaseMachine {
|
||||
machine_uid: params.get_machine_identification_unique(),
|
||||
main_sender: params.main_thread_channel.clone(),
|
||||
namespace: NamespaceImpl::new(params.namespace.clone()),
|
||||
last_measurement_emit: Instant::now(),
|
||||
api_sender,
|
||||
api_receiver,
|
||||
emitted_default_state: false,
|
||||
};
|
||||
|
||||
let mut instance = Self::new(base);
|
||||
|
||||
instance.emit_state();
|
||||
|
||||
Ok(instance)
|
||||
}
|
||||
}
|
||||
|
||||
impl Machine for DerivedMachine
|
||||
{
|
||||
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique
|
||||
{
|
||||
self.base.machine_uid.clone()
|
||||
}
|
||||
|
||||
fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>>
|
||||
{
|
||||
self.base.main_sender.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineApi for DerivedMachine
|
||||
{
|
||||
fn api_get_sender(&self) -> Sender<MachineMessage>
|
||||
{
|
||||
self.base.api_sender.clone()
|
||||
}
|
||||
|
||||
fn api_event_namespace(&mut self) -> Option<Namespace>
|
||||
{
|
||||
self.base.namespace.namespace.clone()
|
||||
}
|
||||
|
||||
fn api_mutate(&mut self, value: Value) -> Result<(), Error>
|
||||
{
|
||||
let mutation: Mutation = serde_json::from_value(value)?;
|
||||
self.mutate(mutation);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineAct for DerivedMachine
|
||||
{
|
||||
fn act_machine_message(&mut self, msg: MachineMessage)
|
||||
{
|
||||
use MachineMessage::*;
|
||||
use crate::MachineApi;
|
||||
|
||||
match msg
|
||||
{
|
||||
SubscribeNamespace(namespace) =>
|
||||
{
|
||||
self.base.namespace.namespace = Some(namespace);
|
||||
self.emit_state();
|
||||
}
|
||||
UnsubscribeNamespace => self.base.namespace.namespace = None,
|
||||
HttpApiJsonRequest(value) => _ = self.api_mutate(value),
|
||||
ConnectToMachine(_) => {}
|
||||
DisconnectMachine(_) => {}
|
||||
RequestValues(sender) =>
|
||||
{
|
||||
sender
|
||||
.send_blocking(MachineValues {
|
||||
state: serde_json::to_value(self.get_state())
|
||||
.expect("Failed to serialize state"),
|
||||
live_values: serde_json::to_value(self.get_live_values())
|
||||
.expect("Failed to serialize live values"),
|
||||
})
|
||||
.expect("Failed to send values");
|
||||
|
||||
sender.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn act(&mut self, now: Instant)
|
||||
{
|
||||
let target_duration: Duration = Duration::from_secs_f64(1.0 / 30.0);
|
||||
|
||||
match self.base.api_receiver.try_recv()
|
||||
{
|
||||
Ok(msg) => _ = self.act_machine_message(msg),
|
||||
Err(_) => (),
|
||||
};
|
||||
|
||||
self.act_ext(now);
|
||||
|
||||
let now = Instant::now();
|
||||
|
||||
// more than 33ms have passed since last emit (30 "fps" target)
|
||||
if now.duration_since(self.base.last_measurement_emit) > target_duration
|
||||
{
|
||||
self.emit_state();
|
||||
self.emit_live_values();
|
||||
self.base.last_measurement_emit = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveValuesEvent
|
||||
{
|
||||
pub fn build(&self) -> Event<Self>
|
||||
{
|
||||
Event::new("LiveValuesEvent", self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Events
|
||||
{
|
||||
LiveValues(Event<LiveValuesEvent>),
|
||||
State(Event<StateEvent>),
|
||||
}
|
||||
|
||||
impl CacheableEvents<Self> for Events
|
||||
{
|
||||
fn event_value(&self) -> GenericEvent
|
||||
{
|
||||
match self
|
||||
{
|
||||
Self::LiveValues(event) => event.into(),
|
||||
Self::State(event) => event.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_cache_fn(&self) -> CacheFn
|
||||
{
|
||||
cache_first_and_last_event()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use crate::stahlwerk::{ r#abstract::BaseMachine };
|
||||
|
||||
pub mod common;
|
||||
pub mod api;
|
||||
pub mod act;
|
||||
|
||||
pub(super) use FF01MachineMock as DerivedMachine;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FF01MachineMock
|
||||
{
|
||||
base: BaseMachine,
|
||||
|
||||
counter: u64,
|
||||
|
||||
// todo: current processed entry HERE
|
||||
}
|
||||
|
||||
impl FF01MachineMock
|
||||
{
|
||||
pub fn new(base: BaseMachine) -> Self
|
||||
{
|
||||
Self { base, counter: 0 }
|
||||
}
|
||||
|
||||
pub fn increment(&mut self)
|
||||
{
|
||||
self.counter += 1;
|
||||
self.emit_state();
|
||||
}
|
||||
|
||||
pub fn finalize(&mut self)
|
||||
{
|
||||
todo!("Submit backflush")
|
||||
// self.emit_state();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MachineId
|
||||
{
|
||||
pub category_id: u8,
|
||||
pub index: u8,
|
||||
pub slug: &'static str,
|
||||
}
|
||||
|
||||
impl MachineId
|
||||
{
|
||||
pub const fn new(category_id: u8, index: u8, slug: &'static str) -> Self
|
||||
{
|
||||
Self { category_id, index, slug }
|
||||
}
|
||||
|
||||
pub const fn to_u16(&self) -> u16
|
||||
{
|
||||
((self.category_id as u16) << 8) | (self.index as u16)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use crate::{
|
||||
VENDOR_QITECH,
|
||||
machine_identification::MachineIdentification,
|
||||
registry::MachineRegistry,
|
||||
stahlwerk::{ machine_id::MachineId }
|
||||
};
|
||||
|
||||
pub mod r#abstract;
|
||||
pub mod template;
|
||||
pub mod machine_id;
|
||||
|
||||
pub mod ff01_mock;
|
||||
|
||||
pub const CATEGORY_ID: u8 = 0x02; // stahlwerk category
|
||||
|
||||
pub const FF01_MOCK: MachineId = MachineId::new(CATEGORY_ID, 0, "stahlwerk_ff01");
|
||||
|
||||
pub const fn get_slug(id: u16) -> Option<&'static str>
|
||||
{
|
||||
match id
|
||||
{
|
||||
id if id == FF01_MOCK.to_u16() => Some(FF01_MOCK.slug),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_machines(registry: &mut MachineRegistry)
|
||||
{
|
||||
#[cfg(feature = "mock-machine")]
|
||||
registry.register::<FF01MachineMock>(new_identification(FF01_MOCK));
|
||||
}
|
||||
|
||||
fn new_identification(id: MachineId) -> MachineIdentification
|
||||
{
|
||||
MachineIdentification {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: id.to_u16(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use super::DerivedMachine;
|
||||
|
||||
impl DerivedMachine
|
||||
{
|
||||
pub fn act_ext(&mut self, now: Instant)
|
||||
{
|
||||
_ = now;
|
||||
// So quiet in here
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use control_core_derive::BuildEvent;
|
||||
use serde::{ Deserialize, Serialize };
|
||||
|
||||
use super::DerivedMachine;
|
||||
|
||||
#[derive(Serialize, Debug, Clone, Default)]
|
||||
pub struct LiveValuesEvent {}
|
||||
|
||||
#[derive(Serialize, Debug, Clone, PartialEq, BuildEvent)]
|
||||
pub struct StateEvent
|
||||
{
|
||||
pub is_default_state: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub enum Mutation {}
|
||||
|
||||
impl DerivedMachine
|
||||
{
|
||||
pub fn get_live_values(&self) -> LiveValuesEvent
|
||||
{
|
||||
LiveValuesEvent {}
|
||||
}
|
||||
|
||||
pub fn get_state(&self) -> StateEvent
|
||||
{
|
||||
StateEvent { is_default_state: true }
|
||||
}
|
||||
|
||||
pub fn mutate(&mut self, mutation: Mutation)
|
||||
{
|
||||
match mutation {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// external deps
|
||||
use anyhow::{Error};
|
||||
use serde_json::Value;
|
||||
use smol::channel::Sender;
|
||||
|
||||
// local deps
|
||||
use control_core::socketio::{
|
||||
event::{
|
||||
BuildEvent,
|
||||
Event,
|
||||
GenericEvent
|
||||
},
|
||||
namespace::{
|
||||
CacheFn,
|
||||
CacheableEvents,
|
||||
Namespace,
|
||||
NamespaceCacheingLogic,
|
||||
cache_first_and_last_event
|
||||
}
|
||||
};
|
||||
|
||||
use crate::{
|
||||
AsyncThreadMessage,
|
||||
Machine,
|
||||
MachineAct,
|
||||
MachineApi,
|
||||
MachineMessage,
|
||||
MachineNewParams,
|
||||
MachineNewTrait,
|
||||
MachineValues,
|
||||
machine_identification::MachineIdentificationUnique,
|
||||
};
|
||||
|
||||
use crate::stahlwerk::{
|
||||
r#abstract::{
|
||||
BaseMachine,
|
||||
NamespaceImpl
|
||||
}
|
||||
};
|
||||
|
||||
use super::{
|
||||
DerivedMachine,
|
||||
api::{
|
||||
LiveValuesEvent,
|
||||
StateEvent,
|
||||
Mutation,
|
||||
}
|
||||
};
|
||||
|
||||
impl DerivedMachine
|
||||
{
|
||||
/// Emit live values data event with the current sine wave amplitude
|
||||
pub fn emit_live_values(&mut self)
|
||||
{
|
||||
let event = self.get_live_values().build();
|
||||
self.base.namespace.emit(Events::LiveValues(event));
|
||||
}
|
||||
|
||||
/// Emit the current state of the mock machine only if values have changed
|
||||
pub fn emit_state(&mut self)
|
||||
{
|
||||
let state = self.get_state();
|
||||
let event = state.build();
|
||||
self.base.namespace.emit(Events::State(event));
|
||||
self.base.emitted_default_state = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineNewTrait for DerivedMachine
|
||||
{
|
||||
fn new(params: &MachineNewParams<'_, '_, '_, '_, '_, '_, '_>) -> Result<Self, Error>
|
||||
where
|
||||
Self: Sized
|
||||
{
|
||||
let (api_sender, api_receiver) = smol::channel::unbounded();
|
||||
|
||||
let base = BaseMachine {
|
||||
machine_uid: params.get_machine_identification_unique(),
|
||||
main_sender: params.main_thread_channel.clone(),
|
||||
namespace: NamespaceImpl::new(params.namespace.clone()),
|
||||
last_measurement_emit: Instant::now(),
|
||||
api_sender,
|
||||
api_receiver,
|
||||
emitted_default_state: false,
|
||||
};
|
||||
|
||||
let mut instance = Self::new(base);
|
||||
|
||||
instance.emit_state();
|
||||
|
||||
Ok(instance)
|
||||
}
|
||||
}
|
||||
|
||||
impl Machine for DerivedMachine
|
||||
{
|
||||
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique
|
||||
{
|
||||
self.base.machine_uid.clone()
|
||||
}
|
||||
|
||||
fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>>
|
||||
{
|
||||
self.base.main_sender.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineApi for DerivedMachine
|
||||
{
|
||||
fn api_get_sender(&self) -> Sender<MachineMessage>
|
||||
{
|
||||
self.base.api_sender.clone()
|
||||
}
|
||||
|
||||
fn api_event_namespace(&mut self) -> Option<Namespace>
|
||||
{
|
||||
self.base.namespace.namespace.clone()
|
||||
}
|
||||
|
||||
fn api_mutate(&mut self, value: Value) -> Result<(), Error>
|
||||
{
|
||||
let mutation: Mutation = serde_json::from_value(value)?;
|
||||
self.mutate(mutation);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineAct for DerivedMachine
|
||||
{
|
||||
fn act_machine_message(&mut self, msg: MachineMessage)
|
||||
{
|
||||
use MachineMessage::*;
|
||||
use crate::MachineApi;
|
||||
|
||||
match msg
|
||||
{
|
||||
SubscribeNamespace(namespace) =>
|
||||
{
|
||||
self.base.namespace.namespace = Some(namespace);
|
||||
self.emit_state();
|
||||
}
|
||||
UnsubscribeNamespace => self.base.namespace.namespace = None,
|
||||
HttpApiJsonRequest(value) => _ = self.api_mutate(value),
|
||||
ConnectToMachine(_) => {}
|
||||
DisconnectMachine(_) => {}
|
||||
RequestValues(sender) =>
|
||||
{
|
||||
sender
|
||||
.send_blocking(MachineValues {
|
||||
state: serde_json::to_value(self.get_state())
|
||||
.expect("Failed to serialize state"),
|
||||
live_values: serde_json::to_value(self.get_live_values())
|
||||
.expect("Failed to serialize live values"),
|
||||
})
|
||||
.expect("Failed to send values");
|
||||
|
||||
sender.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn act(&mut self, now: Instant)
|
||||
{
|
||||
let target_duration: Duration = Duration::from_secs_f64(1.0 / 30.0);
|
||||
|
||||
match self.base.api_receiver.try_recv()
|
||||
{
|
||||
Ok(msg) => _ = self.act_machine_message(msg),
|
||||
Err(_) => (),
|
||||
};
|
||||
|
||||
self.act_ext(now);
|
||||
|
||||
let now = Instant::now();
|
||||
|
||||
// more than 33ms have passed since last emit (30 "fps" target)
|
||||
if now.duration_since(self.base.last_measurement_emit) > target_duration
|
||||
{
|
||||
self.emit_state();
|
||||
self.emit_live_values();
|
||||
self.base.last_measurement_emit = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveValuesEvent
|
||||
{
|
||||
pub fn build(&self) -> Event<Self>
|
||||
{
|
||||
Event::new("LiveValuesEvent", self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Events
|
||||
{
|
||||
LiveValues(Event<LiveValuesEvent>),
|
||||
State(Event<StateEvent>),
|
||||
}
|
||||
|
||||
impl CacheableEvents<Self> for Events
|
||||
{
|
||||
fn event_value(&self) -> GenericEvent
|
||||
{
|
||||
match self
|
||||
{
|
||||
Self::LiveValues(event) => event.into(),
|
||||
Self::State(event) => event.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_cache_fn(&self) -> CacheFn
|
||||
{
|
||||
cache_first_and_last_event()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::stahlwerk::{ r#abstract::BaseMachine };
|
||||
|
||||
pub mod common;
|
||||
pub mod api;
|
||||
pub mod act;
|
||||
|
||||
pub(super) use TemplateMachine as DerivedMachine;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TemplateMachine
|
||||
{
|
||||
base: BaseMachine,
|
||||
}
|
||||
|
||||
impl TemplateMachine
|
||||
{
|
||||
pub fn new(base: BaseMachine) -> Self
|
||||
{
|
||||
Self { base }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use super::TestMachineStepper;
|
||||
use crate::{MachineAct, MachineMessage};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
impl MachineAct for TestMachineStepper {
|
||||
fn act(&mut self, now: Instant) {
|
||||
if let Ok(msg) = self.api_receiver.try_recv() {
|
||||
self.act_machine_message(msg);
|
||||
}
|
||||
|
||||
if now.duration_since(self.last_state_emit) > Duration::from_secs_f64(1.0 / 30.0) {
|
||||
self.emit_state();
|
||||
self.last_state_emit = now;
|
||||
}
|
||||
}
|
||||
|
||||
fn act_machine_message(&mut self, msg: MachineMessage) {
|
||||
match msg {
|
||||
MachineMessage::SubscribeNamespace(namespace) => {
|
||||
self.namespace.namespace = Some(namespace);
|
||||
self.emit_state();
|
||||
}
|
||||
MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
|
||||
MachineMessage::HttpApiJsonRequest(value) => {
|
||||
use crate::MachineApi;
|
||||
let _res = self.api_mutate(value);
|
||||
}
|
||||
MachineMessage::ConnectToMachine(_machine_connection) => {}
|
||||
MachineMessage::DisconnectMachine(_machine_connection) => {}
|
||||
MachineMessage::RequestValues(_sender) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use super::TestMachineStepper;
|
||||
use crate::{MachineApi, MachineMessage};
|
||||
use control_core::socketio::{
|
||||
event::{Event, GenericEvent},
|
||||
namespace::{
|
||||
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_first_and_last_event,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct StateEvent {
|
||||
pub target_speed: i16,
|
||||
pub enabled: bool,
|
||||
pub freq: u8,
|
||||
pub acc_freq: u8,
|
||||
}
|
||||
|
||||
impl StateEvent {
|
||||
pub fn build(&self) -> Event<Self> {
|
||||
Event::new("StateEvent", self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TestMachineStepperEvents {
|
||||
State(Event<StateEvent>),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "action", content = "value")]
|
||||
pub enum Mutation {
|
||||
SetTargetSpeed { target: i16 },
|
||||
SetEnabled { enabled: bool },
|
||||
SetFreq { factor: u8 },
|
||||
SetAccFreq { factor: u8 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestMachineStepperNamespace {
|
||||
pub namespace: Option<Namespace>,
|
||||
}
|
||||
|
||||
impl NamespaceCacheingLogic<TestMachineStepperEvents> for TestMachineStepperNamespace {
|
||||
fn emit(&mut self, events: TestMachineStepperEvents) {
|
||||
let event = Arc::new(events.event_value());
|
||||
let buffer_fn = events.event_cache_fn();
|
||||
if let Some(ns) = &mut self.namespace {
|
||||
ns.emit(event, &buffer_fn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheableEvents<TestMachineStepperEvents> for TestMachineStepperEvents {
|
||||
fn event_value(&self) -> GenericEvent {
|
||||
match self {
|
||||
TestMachineStepperEvents::State(event) => event.clone().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_cache_fn(&self) -> CacheFn {
|
||||
cache_first_and_last_event()
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineApi for TestMachineStepper {
|
||||
fn api_get_sender(&self) -> smol::channel::Sender<MachineMessage> {
|
||||
self.api_sender.clone()
|
||||
}
|
||||
|
||||
fn api_mutate(&mut self, request_body: Value) -> Result<(), anyhow::Error> {
|
||||
let mutation: Mutation = serde_json::from_value(request_body)?;
|
||||
match mutation {
|
||||
Mutation::SetTargetSpeed { target } => self.set_target_speed(target),
|
||||
Mutation::SetEnabled { enabled } => self.set_enabled(enabled),
|
||||
Mutation::SetFreq { factor } => self.set_freq(factor),
|
||||
Mutation::SetAccFreq { factor } => self.set_acc_freq(factor),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn api_event_namespace(&mut self) -> Option<Namespace> {
|
||||
self.namespace.namespace.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use crate::machine_identification::{MachineIdentification, MachineIdentificationUnique};
|
||||
use crate::test_machine_stepper::api::{StateEvent, TestMachineStepperEvents};
|
||||
use crate::{AsyncThreadMessage, Machine, MachineMessage};
|
||||
use control_core::socketio::namespace::NamespaceCacheingLogic;
|
||||
use ethercat_hal::io::stepper_velocity_wago_750_672::StepperVelocityWago750672;
|
||||
use smol::channel::{Receiver, Sender};
|
||||
use std::time::Instant;
|
||||
pub mod act;
|
||||
pub mod api;
|
||||
pub mod new;
|
||||
use crate::test_machine_stepper::api::TestMachineStepperNamespace;
|
||||
use crate::{TEST_MACHINE_STEPPER, VENDOR_QITECH};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TestMachineStepper {
|
||||
pub api_receiver: Receiver<MachineMessage>,
|
||||
pub api_sender: Sender<MachineMessage>,
|
||||
pub machine_identification_unique: MachineIdentificationUnique,
|
||||
pub namespace: TestMachineStepperNamespace,
|
||||
pub last_state_emit: Instant,
|
||||
pub main_sender: Option<Sender<AsyncThreadMessage>>,
|
||||
pub stepper: StepperVelocityWago750672,
|
||||
}
|
||||
|
||||
impl Machine for TestMachineStepper {
|
||||
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
|
||||
self.machine_identification_unique.clone()
|
||||
}
|
||||
|
||||
fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>> {
|
||||
self.main_sender.clone()
|
||||
}
|
||||
}
|
||||
impl TestMachineStepper {
|
||||
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: TEST_MACHINE_STEPPER,
|
||||
};
|
||||
}
|
||||
|
||||
impl TestMachineStepper {
|
||||
pub fn get_state(&self) -> StateEvent {
|
||||
StateEvent {
|
||||
target_speed: self.stepper.target_velocity,
|
||||
enabled: self.stepper.enabled,
|
||||
freq: self.stepper.freq_range_sel,
|
||||
acc_freq: self.stepper.acc_range_sel,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit_state(&mut self) {
|
||||
let event = self.get_state().build();
|
||||
|
||||
self.namespace.emit(TestMachineStepperEvents::State(event));
|
||||
}
|
||||
|
||||
pub fn set_target_speed(&mut self, speed: i16) {
|
||||
self.stepper.set_velocity(speed);
|
||||
self.emit_state();
|
||||
}
|
||||
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.stepper.set_enabled(enabled);
|
||||
self.emit_state();
|
||||
}
|
||||
|
||||
pub fn set_freq(&mut self, factor: u8) {
|
||||
self.stepper.set_freq_range_sel(factor);
|
||||
self.emit_state();
|
||||
}
|
||||
|
||||
pub fn set_acc_freq(&mut self, factor: u8) {
|
||||
self.stepper.set_acc_range_sel(factor);
|
||||
self.emit_state();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use crate::test_machine_stepper::{TestMachineStepper, api::TestMachineStepperNamespace};
|
||||
use ethercat_hal::{
|
||||
devices::{
|
||||
EthercatDevice, downcast_device,
|
||||
wago_750_354::{WAGO_750_354_IDENTITY_A, Wago750_354},
|
||||
wago_modules::wago_750_672::Wago750_672,
|
||||
},
|
||||
io::stepper_velocity_wago_750_672::StepperVelocityWago750672,
|
||||
};
|
||||
use smol::{block_on, lock::RwLock};
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use crate::{
|
||||
MachineNewHardware, MachineNewParams, MachineNewTrait, get_ethercat_device,
|
||||
validate_no_role_dublicates, validate_same_machine_identification_unique,
|
||||
};
|
||||
|
||||
use anyhow::Error;
|
||||
|
||||
impl MachineNewTrait for TestMachineStepper {
|
||||
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
|
||||
// validate general stuff
|
||||
let device_identification = params
|
||||
.device_group
|
||||
.iter()
|
||||
.map(|device_identification| device_identification.clone())
|
||||
.collect::<Vec<_>>();
|
||||
validate_same_machine_identification_unique(&device_identification)?;
|
||||
validate_no_role_dublicates(&device_identification)?;
|
||||
|
||||
let hardware = match ¶ms.hardware {
|
||||
MachineNewHardware::Ethercat(x) => x,
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"[{}::MachineNewTrait/TestMachineStepper::new] MachineNewHardware is not Ethercat",
|
||||
module_path!()
|
||||
));
|
||||
}
|
||||
};
|
||||
block_on(async {
|
||||
let _wago_750_354 = get_ethercat_device::<Wago750_354>(
|
||||
hardware,
|
||||
params,
|
||||
0,
|
||||
[WAGO_750_354_IDENTITY_A].to_vec(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let modules = Wago750_354::initialize_modules(_wago_750_354.1).await?;
|
||||
|
||||
let mut coupler = _wago_750_354.0.write().await;
|
||||
|
||||
for module in modules {
|
||||
coupler.set_module(module);
|
||||
}
|
||||
|
||||
coupler.init_slot_modules(_wago_750_354.1);
|
||||
let dev = coupler.slot_devices.first().unwrap().clone().unwrap();
|
||||
|
||||
// change uncomment this to change to different stepper driver
|
||||
//
|
||||
// let wago_750_671: Arc<RwLock<Wago750_671>> =
|
||||
// downcast_device::<Wago750_671>(dev).await?;
|
||||
let wago_750_672: Arc<RwLock<Wago750_672>> =
|
||||
downcast_device::<Wago750_672>(dev).await?;
|
||||
drop(coupler);
|
||||
|
||||
let stepper = StepperVelocityWago750672::new(wago_750_672);
|
||||
|
||||
let (sender, receiver) = smol::channel::unbounded();
|
||||
let mut my_test = Self {
|
||||
api_receiver: receiver,
|
||||
api_sender: sender,
|
||||
machine_identification_unique: params.get_machine_identification_unique(),
|
||||
namespace: TestMachineStepperNamespace {
|
||||
namespace: params.namespace.clone(),
|
||||
},
|
||||
last_state_emit: Instant::now(),
|
||||
main_sender: params.main_thread_channel.clone(),
|
||||
stepper,
|
||||
};
|
||||
my_test.emit_state();
|
||||
Ok(my_test)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,16 @@ in {
|
||||
options.services.qitech = {
|
||||
enable = mkEnableOption "QiTech Control";
|
||||
|
||||
|
||||
openFirewall = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description =
|
||||
"Whether to open ports in the firewall for the QiTech server";
|
||||
};
|
||||
|
||||
networking.firewall = mkIf cfg.openFirewall { allowedTCPPorts = [ 443 ]; };
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "qitech-service";
|
||||
|
||||
+36
-60
@@ -64,7 +64,6 @@ in {
|
||||
|
||||
];
|
||||
|
||||
# Add these system settings for a more comprehensive kiosk setup
|
||||
boot.kernel.sysctl = {
|
||||
"kernel.panic_on_oops" = 1; # Reboot on kernel oops
|
||||
"kernel.panic" = 10; # Reboot after 10 seconds on panic
|
||||
@@ -105,15 +104,7 @@ in {
|
||||
}
|
||||
];
|
||||
|
||||
networking.hostName = "nixos"; # Define your hostname.
|
||||
# networking.wireless.enable = true; # Enables wireless support via wpa_supplicant.
|
||||
|
||||
networking.interfaces.enp1s0.ipv4.addresses = [
|
||||
{
|
||||
address = "10.10.10.1";
|
||||
prefixLength = 24;
|
||||
}
|
||||
];
|
||||
networking.hostName = "nixos";
|
||||
|
||||
# Configure network proxy if necessary
|
||||
# networking.proxy.default = "http://user:password@proxy:port/";
|
||||
@@ -149,9 +140,38 @@ in {
|
||||
autoSuspend = false;
|
||||
wayland = true;
|
||||
};
|
||||
|
||||
services.xserver.desktopManager.gnome.enable = true;
|
||||
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
# This puts the import at the TOP of the Caddyfile (Global Scope)
|
||||
extraConfig = ''
|
||||
import /var/lib/caddy/auth_snippet.conf
|
||||
'';
|
||||
|
||||
virtualHosts.":443" = {
|
||||
extraConfig = ''
|
||||
import machine_basic_auth
|
||||
|
||||
reverse_proxy localhost:3001
|
||||
|
||||
tls internal {
|
||||
on_demand
|
||||
}
|
||||
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.caddy.serviceConfig.ReadOnlyPaths = [ "/var/lib/caddy/auth_snippet.conf" ];
|
||||
#services.caddy = {
|
||||
# enable = true;
|
||||
# virtualHosts."localhost".extraConfig = ''
|
||||
# respond "Hello, world!"
|
||||
# '';
|
||||
#};
|
||||
|
||||
|
||||
# Disable sleep/suspend
|
||||
systemd.targets.sleep.enable = false;
|
||||
systemd.targets.suspend.enable = false;
|
||||
@@ -198,12 +218,6 @@ in {
|
||||
alsa.enable = true;
|
||||
alsa.support32Bit = true;
|
||||
pulse.enable = true;
|
||||
# If you want to use JACK applications, uncomment this
|
||||
#jack.enable = true;
|
||||
|
||||
# use the example session manager (no others are packaged yet so this is enabled by default,
|
||||
# no need to redefine it in your config for now)
|
||||
#media-session.enable = true;
|
||||
};
|
||||
|
||||
# Enable graphics acceleration
|
||||
@@ -264,6 +278,7 @@ in {
|
||||
wireshark
|
||||
pciutils
|
||||
neofetch
|
||||
caddy
|
||||
];
|
||||
|
||||
xdg.portal.enable = true;
|
||||
@@ -304,51 +319,12 @@ in {
|
||||
QITECH_OS_GIT_URL = gitInfo.gitUrl;
|
||||
};
|
||||
|
||||
# Set revision labe;
|
||||
# Set revision label
|
||||
system.nixos.label = "${gitInfo.gitAbbreviationEscaped}_${gitInfo.gitCommit}";
|
||||
|
||||
networking.firewall.allowedUDPPorts = [ 53 67 69 ];
|
||||
networking.firewall.allowedTCPPorts = [ 443 ];
|
||||
|
||||
# Some programs need SUID wrappers, can be configured further or are
|
||||
# started in user sessions.
|
||||
# programs.mtr.enable = true;
|
||||
# programs.gnupg.agent = {
|
||||
# enable = true;
|
||||
# enableSSHSupport = true;
|
||||
# };
|
||||
|
||||
# List services that you want to enable:
|
||||
|
||||
# Enable the OpenSSH daemon.
|
||||
# services.openssh.enable = true;
|
||||
|
||||
services.dnsmasq = {
|
||||
enable = true;
|
||||
resolveLocalQueries = false;
|
||||
|
||||
settings = {
|
||||
interface = "enp1s0"; # only this interface
|
||||
bind-interfaces = true;
|
||||
server = [ "1.1.1.1" "8.8.8.8" ];
|
||||
|
||||
# DHCP subnet + pool
|
||||
dhcp-range = "10.10.10.50,10.10.10.250,255.255.255.0,12h";
|
||||
|
||||
# Default gateway (router)
|
||||
dhcp-option = [
|
||||
"3,10.10.10.1" # option 3 = router
|
||||
"6,1.1.1.1,8.8.8.8" # option 6 = DNS servers
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.enable = true;
|
||||
networking.firewall.trustedInterfaces = [ "enp1s0" ];
|
||||
|
||||
# This value determines the NixOS release from which the default
|
||||
# settings for stateful data, like file locations and database versions
|
||||
# on your system were taken. It‘s perfectly fine and recommended to leave
|
||||
# this value at the release version of the first install of this system.
|
||||
# Before changing this value read the documentation for this option
|
||||
# (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
|
||||
system.stateVersion = "24.11"; # Did you read the comment?
|
||||
|
||||
}
|
||||
@@ -10,7 +10,7 @@ buildNpmPackage rec {
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
|
||||
|
||||
makeCacheWritable = true;
|
||||
npmDepsHash = "sha256-rnaAsSkoTRoBgLCTF8ZFpi7uUQ+8W+u3qoMxsptX6NU=";
|
||||
npmDepsHash = "sha256-N2kdS9IKkEMNOPPnRNQ9GvCenR/k88fVyAFzXMJyxs4=";
|
||||
npmFlags = [ "--no-audit" "--no-fund" ];
|
||||
|
||||
installPhase = ''
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
dist
|
||||
.wrangler
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.vinxi
|
||||
app.config.timestamp_*.js
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
*.launch
|
||||
.settings/
|
||||
|
||||
# Temp
|
||||
gitignore
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,298 +0,0 @@
|
||||
# Electron → SolidJS Migration Analysis
|
||||
|
||||
## Why SolidJS is a Good Fit
|
||||
|
||||
The electron frontend has two fundamental issues that SolidJS directly solves:
|
||||
|
||||
1. **React's reconciler triggers re-renders on any state change** — the codebase added an entire `ThrottledStoreUpdater` class (100+ lines) to cap re-renders at 30 FPS and prevent UI jank from high-frequency socket events.
|
||||
In SolidJS, reactive updates are surgical: only the DOM nodes that actually depend on a changed signal update. No reconciliation pass, no virtual DOM diff. The `batch()` primitive coalesces multiple signal writes into one reactive pass — replaces `ThrottledStoreUpdater` entirely with zero boilerplate.
|
||||
|
||||
2. **React's hook rules and closure gotchas** force state management up into libraries (Zustand) and create a deep abstraction stack. SolidJS primitives (`createSignal`, `createStore`, `onCleanup`) live exactly where the components live and have no closure staleness issues.
|
||||
|
||||
---
|
||||
|
||||
## Abstraction Comparison: WebSocket Layer
|
||||
|
||||
### Electron (React)
|
||||
Adding a machine namespace requires touching **5+ files**:
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `socketioStore.ts` | 515 | Base infrastructure: ThrottledStoreUpdater, createNamespaceHookImplementation, namespace lifecycle |
|
||||
| `{machine}Namespace.ts` | ~77 | Zod schemas, Zustand store, message handler, hook wrapper |
|
||||
| `use{Machine}.ts` | ~95 | Route param parsing, optimistic state, mutation helpers |
|
||||
| `routes.tsx` | +~15 | Route definitions |
|
||||
| `properties.ts` | +~20 | Machine properties entry |
|
||||
|
||||
**Total per machine: ~220+ lines of mandatory boilerplate before writing any UI.**
|
||||
|
||||
### SolidJS (this PoC)
|
||||
Adding a machine namespace requires:
|
||||
|
||||
```ts
|
||||
// namespaces/myMachine.ts — ~25 lines total
|
||||
export function createMyMachineNamespace(vendor, machine, serial) {
|
||||
return createNamespace(
|
||||
machineNamespacePath(vendor, machine, serial),
|
||||
(event, set) => {
|
||||
if (event.name === "StateEvent") set("data", event.data);
|
||||
},
|
||||
{ data: null },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Then in the page component:
|
||||
```tsx
|
||||
const [state] = createMyMachineNamespace(VENDOR, MACHINE, serial());
|
||||
// state().data is now reactive — that's it
|
||||
```
|
||||
|
||||
**The socket connects on component mount, disconnects on unmount, updates are reactive.
|
||||
No store, no hook factory, no ThrottledStoreUpdater, no ref-counting.**
|
||||
|
||||
---
|
||||
|
||||
## The Core Primitive (`src/lib/socketio.ts`)
|
||||
|
||||
```
|
||||
createNamespace(path, handler, initialState) → [signal, socket]
|
||||
```
|
||||
|
||||
- `batch()` replaces `ThrottledStoreUpdater` — SolidJS batches reactive updates automatically
|
||||
- `onCleanup()` replaces manual `useEffect` teardown — socket disconnects when scope disposed
|
||||
- The signal IS the store — no Zustand, no Immer needed for simple machine state
|
||||
|
||||
This single 80-line file replaces `socketioStore.ts` (515 lines) for new machines.
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
### Electron
|
||||
- Zustand (global singletons + per-namespace stores)
|
||||
- Immer (immutable updates)
|
||||
- `useSyncExternalStore` (bridge Zustand → React)
|
||||
- `ThrottledStoreUpdater` (performance workaround)
|
||||
- `useStateOptimistic` (dual optimistic/real state)
|
||||
|
||||
### SolidJS
|
||||
- `createSignal` / `createStore` — SolidJS built-ins
|
||||
- `batch()` — built-in, no library
|
||||
- Optimistic state: just another `createSignal` in the component
|
||||
|
||||
The optimistic pattern from `useStateOptimistic.tsx` (~80 lines) collapses to two lines in a SolidJS component:
|
||||
```ts
|
||||
const [optimistic, setOptimistic] = createSignal<State | null>(null);
|
||||
const effective = () => optimistic() ?? serverState().data;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Things That Don't Exist in Electron That SolidJS Gets for Free
|
||||
|
||||
| Feature | Electron workaround | SolidJS |
|
||||
|---------|-------------------|---------|
|
||||
| Fine-grained reactivity | `ThrottledStoreUpdater` (100 LOC) | `batch()` built-in |
|
||||
| Cleanup on unmount | `useEffect` return + ref counting | `onCleanup()` |
|
||||
| Derived state | `useMemo` | `createMemo()` or just `() => expr` |
|
||||
| Conditional render | `&&` / ternary (JSX pitfalls) | `<Show>` (handles null/undefined safely) |
|
||||
| List render | `.map()` | `<For>` (keyed, no full re-render on list change) |
|
||||
|
||||
---
|
||||
|
||||
## What the Full Migration Looks Like
|
||||
|
||||
### Things to keep / port directly
|
||||
- The Zod event schemas (optional — validation is still useful)
|
||||
- The REST API mutation pattern (`mutateMachine()`)
|
||||
- The uplot chart components (framework-agnostic, just need a SolidJS wrapper)
|
||||
- The unit/value display logic
|
||||
|
||||
### Things to discard
|
||||
- `ThrottledStoreUpdater` — not needed
|
||||
- `createNamespaceHookImplementation` — replaced by `createNamespace()`
|
||||
- Zustand + Immer — replaced by SolidJS primitives
|
||||
- `useStateOptimistic` — two lines in a component
|
||||
- `useSyncExternalStore` bridge — not needed
|
||||
- All the `useEffect` lifecycle boilerplate for socket management
|
||||
|
||||
### Things to re-evaluate
|
||||
- **Electron itself** — this PoC runs as a plain browser app (`npm run dev`), served from the Rust backend. No Electron needed if the app runs in the browser. Consider just serving the built SolidJS app as static files from the Axum server.
|
||||
- **i18next** — if the app is only used in one language, skip it
|
||||
- **TanStack Router** → `@solidjs/router` handles the use case with less ceremony
|
||||
- **react-hook-form + Zod** → SolidJS has no equivalent but the forms here are simple enough for bare `createSignal` + validation on submit
|
||||
|
||||
---
|
||||
|
||||
## Migration Scope Estimate
|
||||
|
||||
| Area | Effort | Notes |
|
||||
|------|--------|-------|
|
||||
| WebSocket infrastructure | Done (80 lines) | `src/lib/socketio.ts` |
|
||||
| Main namespace | Done (~55 lines) | `src/namespaces/main.ts` |
|
||||
| Per-machine namespace | ~25 lines each | See testMachine.ts as template |
|
||||
| Machine pages (control/settings) | Medium | Mostly UI translation, logic simplifies |
|
||||
| Graph components (uplot) | Medium | uplot is framework-agnostic; wrap in `onMount`/`onCleanup` |
|
||||
| Sidebar + routing | Done | See App.tsx + Sidebar.tsx |
|
||||
| Setup/EtherCAT pages | Small | Just display logic, no complex state |
|
||||
| Presets | Medium | Local storage + fetch, straightforward |
|
||||
| Electron IPC (update, theme, NixOS) | Low if dropped | Not needed if switching to browser app |
|
||||
|
||||
**Rough estimate: 2–3 weeks for a complete working port, 1 week for a fully functional PoC covering all machine types.**
|
||||
|
||||
The biggest time investment is translating the 15+ machine-specific UI pages, not the infrastructure — the infrastructure is already done and is dramatically simpler.
|
||||
|
||||
---
|
||||
|
||||
## Proof of Concept Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── socketio.ts # Core: createNamespace() primitive (80 lines)
|
||||
│ └── api.ts # REST mutations
|
||||
├── namespaces/
|
||||
│ ├── main.ts # Main namespace (~55 lines, vs ~170 in electron)
|
||||
│ ├── testMachine.ts # Test machine namespace (~25 lines, vs ~77 in electron)
|
||||
│ └── mock1.ts # Mock machine namespace (~60 lines incl. types)
|
||||
├── pages/
|
||||
│ ├── MachinesPage.tsx # Machine list
|
||||
│ ├── TestMachineControlPage.tsx # LED control with optimistic updates
|
||||
│ └── Mock1ControlPage.tsx # Mock machine: live values + freq/mode controls
|
||||
├── components/
|
||||
│ └── Sidebar.tsx # Navigation sidebar
|
||||
└── App.tsx # Router + context provider
|
||||
```
|
||||
|
||||
Run with: `npm run dev` (vite dev server, proxies to backend at localhost:3001)
|
||||
|
||||
---
|
||||
|
||||
## How Other Machines Would Be Implemented
|
||||
|
||||
Each machine follows the same two-step pattern:
|
||||
|
||||
### Step 1: Namespace file (`src/namespaces/{machine}.ts`)
|
||||
|
||||
Define a state type that mirrors the Rust `StateEvent` and `LiveValuesEvent` structs, then call `createNamespace()`:
|
||||
|
||||
```ts
|
||||
export type MyMachineState = {
|
||||
// fields from StateEvent
|
||||
some_field: number | null;
|
||||
mode: "Standby" | "Running" | null;
|
||||
// fields from LiveValuesEvent (high-frequency, set separately)
|
||||
live_measurement: number | null;
|
||||
};
|
||||
|
||||
export function createMyMachineNamespace(serial: number) {
|
||||
return createNamespace<MyMachineState>(
|
||||
machineNamespacePath(VENDOR, MACHINE_ID, serial),
|
||||
(event, set) => {
|
||||
if (event.name === "StateEvent") {
|
||||
set("some_field", event.data.some_field);
|
||||
set("mode", event.data.mode);
|
||||
} else if (event.name === "LiveValuesEvent") {
|
||||
set("live_measurement", event.data.measurement);
|
||||
}
|
||||
},
|
||||
{ some_field: null, mode: null, live_measurement: null },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Machine IDs (from `machines/src/lib.rs`):
|
||||
| Machine | Vendor | Machine ID |
|
||||
|---------|--------|-----------|
|
||||
| TestMachine | 1 | 0x0033 (51) |
|
||||
| Winder v2 | 1 | 0x0002 (2) |
|
||||
| Extruder v2 | 1 | 0x0004 (4) |
|
||||
| Laser v1 | 1 | 0x0006 (6) |
|
||||
| Mock | 1 | 0x0007 (7) |
|
||||
| Buffer v1 | 1 | 0x0008 (8) |
|
||||
| Aquapath v1 | 1 | 0x0009 (9) |
|
||||
| Wago Power v1 | 1 | 0x000A (10) |
|
||||
| Extruder v3 | 1 | 0x0016 (22) |
|
||||
|
||||
### Step 2: Page component (`src/pages/{Machine}ControlPage.tsx`)
|
||||
|
||||
```tsx
|
||||
export default function MyMachineControlPage() {
|
||||
const params = useParams<{ serial: string }>();
|
||||
const serial = () => parseInt(params.serial);
|
||||
const [state] = createMyMachineNamespace(serial());
|
||||
|
||||
// Optimistic state per mutable field
|
||||
const [optimisticMode, setOptimisticMode] = createSignal<Mode | null>(null);
|
||||
const mode = () => optimisticMode() ?? state().mode ?? "Standby";
|
||||
|
||||
async function setMode(newMode: Mode) {
|
||||
setOptimisticMode(newMode);
|
||||
try {
|
||||
await mutateMachine(
|
||||
{ machine_identification: { vendor: VENDOR, machine: MACHINE_ID }, serial: serial() },
|
||||
{ SetMode: newMode },
|
||||
);
|
||||
} catch { setOptimisticMode(null); }
|
||||
}
|
||||
|
||||
return (/* JSX */);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Register route + sidebar entry
|
||||
|
||||
In `App.tsx`:
|
||||
```tsx
|
||||
<Route path="/machines/mymachine/:serial/control" component={MyMachineControlPage} />
|
||||
<Route path="/machines/mymachine/:serial" component={...} />
|
||||
```
|
||||
|
||||
In `Sidebar.tsx` and `MachinesPage.tsx`, add to the slug/label maps:
|
||||
```ts
|
||||
"1_X": "mymachine" // vendor_machine → slug
|
||||
```
|
||||
|
||||
### Pages needed per machine (from electron reference)
|
||||
|
||||
| Machine | Pages in electron | Notes |
|
||||
|---------|------------------|-------|
|
||||
| TestMachine | Control | LED toggle (done) |
|
||||
| Mock | Control | Frequencies + mode + live values (done) |
|
||||
| Winder v2 | Control, Settings, Graphs, Presets, Manual | Complex: spool/traverse/puller motors |
|
||||
| Extruder v2/v3 | Control, Settings, Graphs, Presets, Manual | Heating zones, inverter speed |
|
||||
| Laser v1 | Control, Graphs, Presets, Manual | Diameter measurement, tolerance bands |
|
||||
| Buffer v1 | Control, Settings | Motor control |
|
||||
| Aquapath v1 | Control, Settings, Graphs | Temperature + analog output |
|
||||
| Wago Power v1 | Control | Power channel display |
|
||||
|
||||
### Shared UI components still to build
|
||||
|
||||
The electron frontend has these reusable control components that would benefit from SolidJS ports:
|
||||
|
||||
- **`ControlCard`** — titled card wrapper (partially done with `.control-card` CSS)
|
||||
- **`EditValue`** — numeric input with min/max/step/unit/default display
|
||||
- **`SelectionGroup`** — segmented button toggle (done with `.mode-selector` pattern)
|
||||
- **`TimeSeriesValueNumeric`** — displays latest value from a live stream
|
||||
- **Graph wrapper** — uplot integration in SolidJS using `onMount`/`onCleanup`
|
||||
- **`Topbar`** — tab navigation within a machine (Control / Graph / Manual / Presets)
|
||||
- **`Page`** — page container with consistent padding/width
|
||||
- **Preset system** — load/save named parameter sets via REST API
|
||||
|
||||
---
|
||||
|
||||
## Machine-Specific Complexity Notes
|
||||
|
||||
### Winder v2
|
||||
Most complex machine. Has three motors (spool, traverse, puller), a tension arm, and a diameter visualisation component. The electron page (`Winder2ControlPage.tsx`) uses custom SVG components (`Spool.tsx`, `TraverseBar.tsx`, `TensionArm.tsx`). These are pure rendering components that can be ported as-is to SolidJS.
|
||||
|
||||
### Extruder v2 / v3
|
||||
Has heating zones (up to 8 zones, each with temperature setpoint + PID state). The `HeatingZone.tsx` component in electron is shared between extruder2 and extruder3. The graph page plots temperature history via uplot.
|
||||
|
||||
### Laser v1
|
||||
Has `DiameterVisualisation.tsx` — a custom SVG component showing the wire cross-section with tolerance bands. The control page shows live diameter + tolerance settings.
|
||||
|
||||
### Mock Machine
|
||||
Simplest "real" machine with meaningful state: three sine wave generators with frequency controls and a running/standby mode. Ideal for testing the frontend with the mock backend (`cargo run --features mock-machine`).
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
## Usage
|
||||
|
||||
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
|
||||
|
||||
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
|
||||
|
||||
```bash
|
||||
$ npm install # or pnpm install or yarn install
|
||||
```
|
||||
|
||||
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm run dev` or `npm start`
|
||||
|
||||
Runs the app in the development mode.<br>
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br>
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `dist` folder.<br>
|
||||
It correctly bundles Solid in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.<br>
|
||||
Your app is ready to be deployed!
|
||||
|
||||
## Deployment
|
||||
|
||||
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
|
||||
|
||||
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
|
||||
@@ -1,156 +0,0 @@
|
||||
# SolidJS Frontend — Feature Parity Roadmap
|
||||
|
||||
This document tracks what still needs to be implemented to reach feature parity with the
|
||||
existing Electron/React frontend. Checked items are complete in this PoC.
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- [x] Socket.IO connection primitive (`createNamespace`)
|
||||
- [x] Main namespace (machine discovery, EtherCAT status)
|
||||
- [x] REST mutation helper (`mutateMachine`)
|
||||
- [x] Sidebar with live machine list
|
||||
- [x] Basic routing (`@solidjs/router`)
|
||||
- [x] Dark theme CSS variables
|
||||
- [ ] Topbar / tab navigation per machine (Control / Graph / Manual / Presets)
|
||||
- [ ] Toast notifications (success/error feedback on mutations)
|
||||
- [ ] Global error boundary
|
||||
- [ ] Vite proxy config for production build (serve from Axum)
|
||||
|
||||
---
|
||||
|
||||
## Shared UI Components
|
||||
|
||||
- [x] Control card wrapper (`.control-card`)
|
||||
- [x] Live value display (numeric, tabular-nums)
|
||||
- [x] Frequency / numeric input with reset-to-default button
|
||||
- [x] Mode selector (segmented button)
|
||||
- [ ] `EditValue` — generalised numeric input with unit label, min/max/step, inline validation
|
||||
- [ ] `SelectionGroup` — generalised segmented button for arbitrary enum values
|
||||
- [ ] `TimeSeriesValueNumeric` — last-value display from a streaming signal
|
||||
- [ ] uplot graph wrapper — `onMount` creates chart, reactive signals push data, `onCleanup` destroys
|
||||
- [ ] Preset system UI — list presets, apply preset, save as preset (uses REST API)
|
||||
- [ ] Manual/PDF viewer page
|
||||
- [ ] EtherCAT setup page (device list, address assignments)
|
||||
- [ ] Setup / configuration page (EtherCAT interface picker)
|
||||
|
||||
---
|
||||
|
||||
## Machine Pages
|
||||
|
||||
### Test Machine (vendor=1, machine=0x33/51)
|
||||
- [x] Control page — LED toggle grid
|
||||
|
||||
### Mock Machine (vendor=1, machine=0x07/7)
|
||||
- [x] Control page — live sine wave values, frequency inputs, mode selector
|
||||
- [ ] Graph page — uplot with amplitude_sum + individual waves over time
|
||||
|
||||
### Winder v2 (vendor=1, machine=0x02/2)
|
||||
- [ ] Namespace (`winder2Namespace.ts`)
|
||||
- [ ] Control page — spool speed, traverse, puller, tension arm state
|
||||
- [ ] Settings page — traverse limits, spool diameter config
|
||||
- [ ] Graph page — speed/tension over time
|
||||
- [ ] Presets page
|
||||
- [ ] Manual page
|
||||
|
||||
### Extruder v2 (vendor=1, machine=0x04/4) / Extruder v3 (machine=0x16/22)
|
||||
- [ ] Namespace
|
||||
- [ ] Control page — heating zones (setpoints + actual temps), inverter speed
|
||||
- [ ] Settings page — PID parameters per zone
|
||||
- [ ] Graph page — temperature history
|
||||
- [ ] Presets page
|
||||
- [ ] Manual page
|
||||
|
||||
### Laser v1 (vendor=1, machine=0x06/6)
|
||||
- [ ] Namespace
|
||||
- [ ] Control page — live diameter, target diameter, tolerance band, in-tolerance indicator
|
||||
- [ ] Graph page — diameter over time with tolerance lines
|
||||
- [ ] Presets page
|
||||
- [ ] Manual page
|
||||
|
||||
### Buffer v1 (vendor=1, machine=0x08/8)
|
||||
- [ ] Namespace
|
||||
- [ ] Control page — motor speed setpoints, actual speeds
|
||||
- [ ] Settings page
|
||||
|
||||
### Aquapath v1 (vendor=1, machine=0x09/9)
|
||||
- [ ] Namespace
|
||||
- [ ] Control page — temperature setpoints, analog outputs, encoder position
|
||||
- [ ] Settings page
|
||||
- [ ] Graph page
|
||||
|
||||
### Wago Power v1 (vendor=1, machine=0x0A/10)
|
||||
- [ ] Namespace
|
||||
- [ ] Control page — power channel on/off display
|
||||
|
||||
### Digital Input Test Machine (vendor=1, machine=0x40/64)
|
||||
- [ ] Namespace + Control page — digital input state display
|
||||
|
||||
### Wago 8ch DIO Test (vendor=1, machine=0x41/65)
|
||||
- [ ] Namespace + Control page
|
||||
|
||||
### Wago DO Test (vendor=1, machine=0x0E/14)
|
||||
- [ ] Namespace + Control page
|
||||
|
||||
### Wago AI Test (vendor=1, machine=0x36/54)
|
||||
- [ ] Namespace + Control page
|
||||
|
||||
### IP20 Test Machine (vendor=1, machine=0x34/52)
|
||||
- [ ] Namespace + Control page
|
||||
|
||||
### Motor Test Machine (vendor=1, machine=0x11/17)
|
||||
- [ ] Namespace + Control page
|
||||
|
||||
### Analog Input Test Machine (vendor=1, machine=0x35/53)
|
||||
- [ ] Namespace + Control page
|
||||
|
||||
---
|
||||
|
||||
## Pages Outside Machine Control
|
||||
|
||||
- [ ] Machines overview — currently functional, but uses `unknown-*` slug for unregistered machines
|
||||
- [ ] EtherCAT status page — show `ethercatDevices` and `ethercatInterface` from main namespace
|
||||
- [ ] Setup page — EtherCAT interface picker (currently hardcoded in backend)
|
||||
- [ ] About / version page
|
||||
|
||||
---
|
||||
|
||||
## Nice-to-Have / Polish
|
||||
|
||||
- [ ] Loading skeleton states (instead of plain "Waiting…" text)
|
||||
- [ ] Error state display when machine.error is set
|
||||
- [ ] Reconnect indicator (socket disconnect/reconnect visual feedback)
|
||||
- [ ] Responsive layout for smaller screens
|
||||
- [ ] Keyboard shortcuts (already exists in electron via keybindings)
|
||||
|
||||
---
|
||||
|
||||
## What to Drop from Electron
|
||||
|
||||
These exist in the Electron app but are **not needed** in the SolidJS version:
|
||||
|
||||
- Electron IPC (auto-update, NixOS config, system tray) — not applicable in browser
|
||||
- `ThrottledStoreUpdater` — replaced by SolidJS `batch()`
|
||||
- Zustand + Immer — replaced by `createSignal`/`createStore`
|
||||
- `useStateOptimistic` — two lines inline in each component
|
||||
- `createNamespaceHookImplementation` — replaced by `createNamespace()`
|
||||
- `react-hook-form` — bare signals are enough for the simple forms here
|
||||
- i18next — skip unless multi-language is required
|
||||
|
||||
---
|
||||
|
||||
## Testing the PoC with Mock Mode
|
||||
|
||||
Start the backend in mock mode:
|
||||
```sh
|
||||
cargo run --features mock-machine
|
||||
```
|
||||
|
||||
Start the frontend dev server:
|
||||
```sh
|
||||
cd qitech-control-gui && npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 — the mock machine should appear in the sidebar.
|
||||
Navigate to `/machines/mock1/{serial}/control` to see the control page.
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
|
||||
<title>Solid App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-2294
File diff suppressed because it is too large.
Load diff
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "vite-template-solid",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"solid-devtools": "^0.34.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.1.4",
|
||||
"vite-plugin-solid": "^2.11.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solidjs/router": "^0.15.4",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"socket.io-msgpack-parser": "^3.0.2",
|
||||
"solid-js": "^1.9.5",
|
||||
"uplot": "^1.6.32",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
Generated
-1346
File diff suppressed because it is too large.
Load diff
@@ -1,33 +0,0 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #b318f0;
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* App root — sets up routing and creates the main namespace socket once.
|
||||
*
|
||||
* The main namespace signal is created here (at app scope) and provided via
|
||||
* context so all pages can access it without prop drilling.
|
||||
*
|
||||
* Machine-specific namespaces are created inside their pages so they
|
||||
* connect/disconnect with the page lifecycle automatically.
|
||||
*
|
||||
* No global store. No Zustand. No Immer. No ThrottledStoreUpdater.
|
||||
* State lives in SolidJS signals, reactive updates are handled by batch().
|
||||
*/
|
||||
|
||||
import { Router, Route } from "@solidjs/router";
|
||||
import { createContext, useContext } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import MachinesPage from "./pages/MachinesPage";
|
||||
import TestMachineControlPage from "./pages/TestMachineControlPage";
|
||||
import Mock1ControlPage from "./pages/Mock1ControlPage";
|
||||
import Mock1GraphPage from "./pages/Mock1GraphPage";
|
||||
import { createMainNamespace, type MainState } from "./namespaces/main";
|
||||
import "./styles.css";
|
||||
|
||||
// Context so nested pages can read main state without prop drilling
|
||||
export const MainStateContext = createContext<() => MainState>(() => ({
|
||||
machines: null,
|
||||
ethercatDevices: null,
|
||||
ethercatInterface: null,
|
||||
}));
|
||||
|
||||
export function useMainState() {
|
||||
return useContext(MainStateContext);
|
||||
}
|
||||
|
||||
// Shared layout wrapper passed to Router as `root`
|
||||
function AppLayout(props: { children?: JSX.Element }) {
|
||||
const mainState = useMainState();
|
||||
return (
|
||||
<div class="app-layout">
|
||||
<Sidebar mainState={mainState} />
|
||||
<main class="app-content">{props.children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// One socket for the main namespace, alive for the entire app lifetime.
|
||||
// createMainNamespace() calls createNamespace() which calls onCleanup()
|
||||
// — so the socket disconnects if App ever unmounts.
|
||||
const [mainState] = createMainNamespace();
|
||||
|
||||
return (
|
||||
<MainStateContext.Provider value={mainState}>
|
||||
<Router root={AppLayout}>
|
||||
<Route path="/" component={() => <MachinesPage mainState={mainState} />} />
|
||||
<Route path="/machines/testmachine/:serial/control" component={TestMachineControlPage} />
|
||||
<Route
|
||||
path="/machines/testmachine/:serial"
|
||||
component={() => (
|
||||
<div class="page">
|
||||
<h1>Test Machine</h1>
|
||||
<p class="muted">Select Control from the sidebar.</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Route path="/machines/mock1/:serial/control" component={Mock1ControlPage} />
|
||||
<Route path="/machines/mock1/:serial/graph" component={Mock1GraphPage} />
|
||||
<Route
|
||||
path="/machines/mock1/:serial"
|
||||
component={() => (
|
||||
<div class="page">
|
||||
<h1>Mock Machine</h1>
|
||||
<p class="muted">Select Control from the sidebar.</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Router>
|
||||
</MainStateContext.Provider>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* MiniGraph — compact inline sparkline for control page cards.
|
||||
*
|
||||
* 64 px tall, no cursor, no legend, no x-axis.
|
||||
* Y-axis grid shows on the right side.
|
||||
* Driven by a rAF loop reading directly from a SeriesBuffer — no reactive
|
||||
* subscriptions, no intermediate array copies on every event.
|
||||
*
|
||||
* Props:
|
||||
* - getBuffer: () => SeriesBuffer called each frame to read the ring buffer
|
||||
* - timeWindow: number seconds of history to display (default: 10)
|
||||
* - stroke: CSS colour string (default: var(--accent) / #6c8cff)
|
||||
*/
|
||||
|
||||
import uPlot from "uplot";
|
||||
import { onMount, onCleanup } from "solid-js";
|
||||
import type { SeriesBuffer } from "../namespaces/mock1";
|
||||
|
||||
const HEIGHT = 64;
|
||||
|
||||
type Props = {
|
||||
getBuffer: () => SeriesBuffer;
|
||||
timeWindow?: number;
|
||||
stroke?: string;
|
||||
};
|
||||
|
||||
export default function MiniGraph(props: Props) {
|
||||
let container!: HTMLDivElement;
|
||||
let uplot: uPlot | null = null;
|
||||
let rafId = 0;
|
||||
let pendingWidth = 0;
|
||||
|
||||
function buildOpts(width: number): uPlot.Options {
|
||||
return {
|
||||
width,
|
||||
height: HEIGHT,
|
||||
padding: [4, 0, 4, 0],
|
||||
cursor: { show: false },
|
||||
legend: { show: false },
|
||||
scales: {
|
||||
x: { time: true },
|
||||
y: { auto: false },
|
||||
},
|
||||
axes: [
|
||||
{ show: false },
|
||||
{
|
||||
side: 1, // right side
|
||||
size: 40,
|
||||
stroke: "#717898",
|
||||
grid: { stroke: "#2e3148", width: 0.5 },
|
||||
ticks: { stroke: "#2e3148", width: 0.5 },
|
||||
values: (_u, ticks) => ticks.map((v) => v.toFixed(2)),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
stroke: props.stroke ?? "#6c8cff",
|
||||
width: 1.5,
|
||||
spanGaps: true,
|
||||
points: { show: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function frame() {
|
||||
if (!uplot) { rafId = requestAnimationFrame(frame); return; }
|
||||
|
||||
if (pendingWidth > 0) {
|
||||
uplot.setSize({ width: pendingWidth, height: HEIGHT });
|
||||
pendingWidth = 0;
|
||||
}
|
||||
|
||||
const buf = props.getBuffer();
|
||||
const { times, values, length, head } = buf;
|
||||
if (length === 0) { rafId = requestAnimationFrame(frame); return; }
|
||||
|
||||
const bufSize = times.length;
|
||||
const isFull = length === bufSize;
|
||||
const startIdx = isFull ? head : 0;
|
||||
const tMax = times[(head - 1 + bufSize) % bufSize];
|
||||
const tMin = tMax - (props.timeWindow ?? 10);
|
||||
|
||||
// Count entries in window
|
||||
let count = 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (times[(startIdx + i) % bufSize] >= tMin) count++;
|
||||
}
|
||||
|
||||
const outT = new Float64Array(count);
|
||||
const outV = new Float64Array(count);
|
||||
let out = 0;
|
||||
let minY = Infinity, maxY = -Infinity;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const idx = (startIdx + i) % bufSize;
|
||||
const t = times[idx];
|
||||
if (t >= tMin) {
|
||||
outT[out] = t;
|
||||
outV[out] = values[idx];
|
||||
if (values[idx] < minY) minY = values[idx];
|
||||
if (values[idx] > maxY) maxY = values[idx];
|
||||
out++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0) { rafId = requestAnimationFrame(frame); return; }
|
||||
|
||||
const range = maxY - minY || 1;
|
||||
const pad = range * 0.15;
|
||||
|
||||
uplot.batch(() => {
|
||||
uplot!.setData([outT, outV], false);
|
||||
uplot!.setScale("x", { min: tMin, max: tMax });
|
||||
uplot!.setScale("y", { min: minY - pad, max: maxY + pad });
|
||||
});
|
||||
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const w = Math.floor(entry.contentRect.width);
|
||||
if (w === 0) return;
|
||||
if (!uplot) {
|
||||
uplot = new uPlot(buildOpts(w), [new Float64Array(0), new Float64Array(0)], container);
|
||||
rafId = requestAnimationFrame(frame);
|
||||
} else {
|
||||
pendingWidth = w;
|
||||
}
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
onCleanup(() => {
|
||||
cancelAnimationFrame(rafId);
|
||||
ro.disconnect();
|
||||
uplot?.destroy();
|
||||
uplot = null;
|
||||
});
|
||||
});
|
||||
|
||||
return <div ref={container} class="minigraph-wrap" />;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { For, Show, createMemo } from "solid-js";
|
||||
import type { MainState, MachineObj } from "../namespaces/main";
|
||||
|
||||
type Props = {
|
||||
mainState: () => MainState;
|
||||
};
|
||||
|
||||
function machineLabel(m: MachineObj): string {
|
||||
const { vendor, machine } = m.machine_identification_unique.machine_identification;
|
||||
const labels: Record<string, string> = {
|
||||
"1_51": "Test Machine", // 0x0033
|
||||
"1_2": "Winder v2", // 0x0002
|
||||
"1_4": "Extruder v2", // 0x0004
|
||||
"1_6": "Laser v1", // 0x0006
|
||||
"1_7": "Mock", // 0x0007
|
||||
"1_8": "Buffer v1", // 0x0008
|
||||
"1_9": "Aquapath v1", // 0x0009
|
||||
"1_10": "Wago Power", // 0x000A
|
||||
"1_22": "Extruder v3", // 0x0016
|
||||
};
|
||||
return labels[`${vendor}_${machine}`] ?? `Machine ${vendor}/${machine}`;
|
||||
}
|
||||
|
||||
function machineSlug(m: MachineObj): string {
|
||||
const { vendor, machine } = m.machine_identification_unique.machine_identification;
|
||||
const slugs: Record<string, string> = {
|
||||
"1_51": "testmachine", // 0x0033
|
||||
"1_2": "winder2", // 0x0002
|
||||
"1_4": "extruder2", // 0x0004
|
||||
"1_6": "laser1", // 0x0006
|
||||
"1_7": "mock1", // 0x0007
|
||||
"1_8": "buffer1", // 0x0008
|
||||
"1_9": "aquapath1", // 0x0009
|
||||
"1_10": "wago_power1", // 0x000A
|
||||
"1_22": "extruder3", // 0x0016
|
||||
};
|
||||
return slugs[`${vendor}_${machine}`] ?? `unknown-${vendor}-${machine}`;
|
||||
}
|
||||
|
||||
// Slugs that have a graph page implemented
|
||||
const SLUGS_WITH_GRAPH = new Set(["mock1"]);
|
||||
|
||||
export default function Sidebar(props: Props) {
|
||||
const machines = createMemo(() => props.mainState().machines ?? []);
|
||||
|
||||
return (
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<span>QiTech Control</span>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<A href="/" class="sidebar-link" end>
|
||||
Machines
|
||||
</A>
|
||||
</div>
|
||||
|
||||
<Show when={machines().length > 0}>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Active Machines</div>
|
||||
<For each={machines()}>
|
||||
{(m) => {
|
||||
const slug = machineSlug(m);
|
||||
const serial = m.machine_identification_unique.serial;
|
||||
return (
|
||||
<div class="sidebar-machine">
|
||||
<A href={`/machines/${slug}/${serial}`} class="sidebar-link" end>
|
||||
{machineLabel(m)} <span class="sidebar-serial">#{serial}</span>
|
||||
</A>
|
||||
<A href={`/machines/${slug}/${serial}/control`} class="sidebar-sublink">
|
||||
Control
|
||||
</A>
|
||||
<Show when={SLUGS_WITH_GRAPH.has(slug)}>
|
||||
<A href={`/machines/${slug}/${serial}/graph`} class="sidebar-sublink">
|
||||
Graph
|
||||
</A>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class={`connection-dot ${props.mainState().machines !== null ? "connected" : "disconnected"}`} />
|
||||
<span>{props.mainState().machines !== null ? "Connected" : "Connecting…"}</span>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* UPlotGraph — SolidJS wrapper around uPlot with a rolling time window.
|
||||
*
|
||||
* Design principles:
|
||||
* - The uPlot instance is driven by a rAF loop, NOT by reactive effects.
|
||||
* This caps redraws at the display refresh rate (~60 fps) regardless of
|
||||
* how fast incoming data arrives (e.g. 10 Hz socket events).
|
||||
* - The caller provides a `getSeriesData` callback that returns the current
|
||||
* ring-buffer slices. The callback is called once per animation frame so
|
||||
* it must be cheap (just a typed-array slice of the visible window).
|
||||
* - `timeWindow` (seconds) controls how much history is visible. The x-axis
|
||||
* scale is set to [latestTimestamp - timeWindow, latestTimestamp] every
|
||||
* frame, giving the rolling-window effect.
|
||||
* - Resize is handled by ResizeObserver → setSize on next rAF frame.
|
||||
*/
|
||||
|
||||
import uPlot from "uplot";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import { onMount, onCleanup } from "solid-js";
|
||||
|
||||
export type HorizLine = {
|
||||
value: number;
|
||||
color: string;
|
||||
dash?: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
/** Called each animation frame. Return aligned data arrays (timestamps in seconds). */
|
||||
getSeriesData: () => uPlot.AlignedData;
|
||||
series: uPlot.Series[];
|
||||
unit?: string;
|
||||
height?: number;
|
||||
/** Visible time window in seconds (default: 30 s). */
|
||||
timeWindow?: number;
|
||||
lines?: HorizLine[];
|
||||
};
|
||||
|
||||
export default function UPlotGraph(props: Props) {
|
||||
let container!: HTMLDivElement;
|
||||
let uplot: uPlot | null = null;
|
||||
let rafId = 0;
|
||||
let pendingWidth = 0;
|
||||
|
||||
function buildOpts(width: number): uPlot.Options {
|
||||
const height = props.height ?? 240;
|
||||
const lines = props.lines ?? [];
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
cursor: { sync: { key: "graphs" } },
|
||||
scales: {
|
||||
x: { time: true },
|
||||
y: { auto: true },
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
stroke: "#717898",
|
||||
grid: { stroke: "#2e3148", width: 1 },
|
||||
ticks: { stroke: "#2e3148" },
|
||||
},
|
||||
{
|
||||
label: props.unit,
|
||||
stroke: "#717898",
|
||||
grid: { stroke: "#2e3148", width: 1 },
|
||||
ticks: { stroke: "#2e3148" },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
...props.series,
|
||||
],
|
||||
hooks: lines.length > 0 ? {
|
||||
drawSeries: [
|
||||
(u) => {
|
||||
const ctx = u.ctx;
|
||||
ctx.save();
|
||||
for (const line of lines) {
|
||||
const yPos = Math.round(u.valToPos(line.value, "y", true));
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = line.color;
|
||||
ctx.lineWidth = 1.5;
|
||||
if (line.dash) ctx.setLineDash([6, 3]);
|
||||
ctx.moveTo(u.bbox.left, yPos);
|
||||
ctx.lineTo(u.bbox.left + u.bbox.width, yPos);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
if (line.label) {
|
||||
ctx.fillStyle = line.color;
|
||||
ctx.font = "11px Inter, system-ui, sans-serif";
|
||||
ctx.fillText(line.label, u.bbox.left + 4, yPos - 4);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
],
|
||||
} : {},
|
||||
};
|
||||
}
|
||||
|
||||
function frame() {
|
||||
if (!uplot) { rafId = requestAnimationFrame(frame); return; }
|
||||
|
||||
// Apply any pending resize first
|
||||
if (pendingWidth > 0) {
|
||||
uplot.setSize({ width: pendingWidth, height: props.height ?? 240 });
|
||||
pendingWidth = 0;
|
||||
}
|
||||
|
||||
const data = props.getSeriesData();
|
||||
const times = data[0] as Float64Array | number[];
|
||||
const n = times.length;
|
||||
|
||||
if (n > 0) {
|
||||
const tMax = times[n - 1];
|
||||
const tMin = tMax - (props.timeWindow ?? 30);
|
||||
uplot.setData(data, false); // false = don't auto-scale x
|
||||
uplot.setScale("x", { min: tMin, max: tMax });
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const w = Math.floor(entry.contentRect.width);
|
||||
if (w === 0) return;
|
||||
if (!uplot) {
|
||||
uplot = new uPlot(buildOpts(w), props.getSeriesData(), container);
|
||||
rafId = requestAnimationFrame(frame);
|
||||
} else {
|
||||
// Defer resize to the rAF loop to avoid mid-paint artifacts
|
||||
pendingWidth = w;
|
||||
}
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
onCleanup(() => {
|
||||
cancelAnimationFrame(rafId);
|
||||
ro.disconnect();
|
||||
uplot?.destroy();
|
||||
uplot = null;
|
||||
});
|
||||
});
|
||||
|
||||
return <div ref={container} class="uplot-wrap" />;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
|
||||
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/* @refresh reload */
|
||||
import { render } from 'solid-js/web';
|
||||
import 'solid-devtools';
|
||||
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
throw new Error(
|
||||
'Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?',
|
||||
);
|
||||
}
|
||||
|
||||
render(() => <App />, root!);
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* REST API client
|
||||
*
|
||||
* Thin wrapper — no react-query or custom hooks needed for simple mutations.
|
||||
* SolidJS's createResource() handles data fetching with built-in loading/error states.
|
||||
*/
|
||||
|
||||
export const BASE_URL = "http://localhost:3001";
|
||||
|
||||
export type MachineIdentificationUnique = {
|
||||
machine_identification: { vendor: number; machine: number };
|
||||
serial: number;
|
||||
};
|
||||
|
||||
export async function mutateMachine(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE_URL}/api/v1/machine/mutate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ machine_identification_unique, data }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Mutation failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* SolidJS Socket.IO layer
|
||||
*
|
||||
* Key insight: SolidJS signals replace both Zustand + ThrottledStoreUpdater.
|
||||
* - createSignal() is the store
|
||||
* - batch() coalesces multiple updates into one DOM pass (no manual 30fps timer needed)
|
||||
* - onCleanup() replaces useEffect cleanup
|
||||
* - No lifecycle boilerplate per machine type
|
||||
*
|
||||
* The entire pattern collapses to: connect → on("event") → setSignal(data)
|
||||
*/
|
||||
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import MsgPackParser from "socket.io-msgpack-parser";
|
||||
import { createSignal, onCleanup, batch } from "solid-js";
|
||||
import { z } from "zod";
|
||||
|
||||
export const BASE_URL = "http://localhost:3001";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic event envelope (mirrors the Rust GenericEvent on the server)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const genericEventSchema = z.object({
|
||||
name: z.string(),
|
||||
data: z.any(),
|
||||
ts: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export type GenericEvent = z.infer<typeof genericEventSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Namespace path helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type NamespacePath = string; // e.g. "/main" or "/machine/1/2/100"
|
||||
|
||||
export function machineNamespacePath(
|
||||
vendor: number,
|
||||
machine: number,
|
||||
serial: number,
|
||||
): NamespacePath {
|
||||
return `/machine/${vendor}/${machine}/${serial}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createNamespace — the single primitive for all WebSocket connections
|
||||
//
|
||||
// Returns a reactive [data, socket] tuple. The caller provides:
|
||||
// - path: the Socket.IO namespace path
|
||||
// - handler: a function (event, setData) called on each incoming event
|
||||
//
|
||||
// Usage inside a SolidJS component or createRoot:
|
||||
//
|
||||
// const [state, _socket] = createNamespace("/main", (event, set) => {
|
||||
// if (event.name === "MachinesEvent") set("machines", event.data);
|
||||
// }, { machines: null });
|
||||
//
|
||||
// The socket disconnects automatically when the reactive scope is disposed
|
||||
// (e.g. component unmounts, createRoot cleanup).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createNamespace<S extends object>(
|
||||
path: NamespacePath,
|
||||
handler: (event: GenericEvent, set: (key: keyof S, value: S[keyof S]) => void, setAll: (updater: (prev: S) => S) => void) => void,
|
||||
initialState: S,
|
||||
): [() => S, Socket] {
|
||||
const [state, setState] = createSignal<S>(initialState);
|
||||
|
||||
const socket = io(BASE_URL + path, {
|
||||
autoConnect: false,
|
||||
parser: MsgPackParser,
|
||||
});
|
||||
|
||||
// Helper: update a single key — uses batch internally for multi-key updates
|
||||
const set = (key: keyof S, value: S[keyof S]) => {
|
||||
setState((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const setAll = (updater: (prev: S) => S) => {
|
||||
setState((prev) => updater(prev));
|
||||
};
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log(`[socket.io] connected: ${path}`);
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.warn(`[socket.io] disconnected: ${path} — ${reason}`);
|
||||
// Reset state on disconnect so UI reflects stale/loading state
|
||||
batch(() => setState(initialState));
|
||||
});
|
||||
|
||||
socket.on("event", (raw: unknown) => {
|
||||
const parsed = genericEventSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
console.error("[socket.io] invalid event envelope:", parsed.error);
|
||||
return;
|
||||
}
|
||||
// batch() ensures multiple set() calls in one handler only trigger one
|
||||
// reactive update pass — replaces the ThrottledStoreUpdater entirely
|
||||
batch(() => handler(parsed.data, set, setAll));
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
|
||||
// Automatically disconnect when the reactive scope is torn down
|
||||
onCleanup(() => {
|
||||
socket.disconnect();
|
||||
console.log(`[socket.io] cleaned up: ${path}`);
|
||||
});
|
||||
|
||||
return [state, socket];
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Main namespace — machine discovery & EtherCAT status
|
||||
*
|
||||
* Compare to electron/src/client/mainNamespace.ts (~170 lines + socketioStore boilerplate).
|
||||
* This entire file is ~60 lines and contains the same information.
|
||||
*/
|
||||
|
||||
import { createNamespace } from "../lib/socketio";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types (mirrors the Rust structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MachineObj = {
|
||||
machine_identification_unique: {
|
||||
machine_identification: { vendor: number; machine: number };
|
||||
serial: number;
|
||||
};
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type EthercatDevicesEventData =
|
||||
| { Initializing: true }
|
||||
| { Done: { devices: EthercatDevice[] } }
|
||||
| { Error: string };
|
||||
|
||||
export type EthercatDevice = {
|
||||
configured_address: number;
|
||||
name: string;
|
||||
vendor_id: number;
|
||||
product_id: number;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
export type MainState = {
|
||||
machines: MachineObj[] | null;
|
||||
ethercatDevices: EthercatDevicesEventData | null;
|
||||
ethercatInterface: string | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: useMainNamespace
|
||||
//
|
||||
// In React this required:
|
||||
// mainNamespace.ts (~170 lines, Zod schemas, Zustand store, handler, hook)
|
||||
// socketioStore.ts (createNamespaceHookImplementation, ThrottledStoreUpdater, etc.)
|
||||
//
|
||||
// In SolidJS: one createNamespace() call does it all.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createMainNamespace() {
|
||||
return createNamespace<MainState>(
|
||||
"/main",
|
||||
(event, set) => {
|
||||
if (event.name === "MachinesEvent") {
|
||||
set("machines", event.data.machines);
|
||||
} else if (event.name === "EthercatDevicesEvent") {
|
||||
set("ethercatDevices", event.data);
|
||||
} else if (event.name === "EthercatInterfaceDiscoveryEvent") {
|
||||
if ("Done" in event.data) set("ethercatInterface", event.data.Done);
|
||||
}
|
||||
},
|
||||
{ machines: null, ethercatDevices: null, ethercatInterface: null },
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* Mock1 machine namespace
|
||||
*
|
||||
* Mock machine is vendor=1, machine=7 (MACHINE_MOCK = 0x0007).
|
||||
* Events: StateEvent, LiveValuesEvent
|
||||
* Mutations: SetFrequency1/2/3(f64), SetMode("Standby"|"Running")
|
||||
*/
|
||||
|
||||
import { createSignal, onCleanup, batch } from "solid-js";
|
||||
import { io } from "socket.io-client";
|
||||
import MsgPackParser from "socket.io-msgpack-parser";
|
||||
import { createNamespace, machineNamespacePath, BASE_URL, genericEventSchema } from "../lib/socketio";
|
||||
|
||||
export type Mode = "Standby" | "Running";
|
||||
|
||||
export type ModeState = {
|
||||
mode: Mode;
|
||||
};
|
||||
|
||||
export type Mock1State = {
|
||||
// From StateEvent
|
||||
is_default_state: boolean | null;
|
||||
frequency1: number | null; // millihertz
|
||||
frequency2: number | null;
|
||||
frequency3: number | null;
|
||||
mode_state: ModeState | null;
|
||||
// From LiveValuesEvent (updated at high frequency)
|
||||
amplitude_sum: number | null;
|
||||
amplitude1: number | null;
|
||||
amplitude2: number | null;
|
||||
amplitude3: number | null;
|
||||
};
|
||||
|
||||
// Time-series buffer for graphing: parallel arrays of timestamps (s) and values
|
||||
export type SeriesBuffer = {
|
||||
times: Float64Array;
|
||||
values: Float64Array;
|
||||
length: number; // number of valid entries (wraps in ring)
|
||||
head: number; // index of next write position
|
||||
};
|
||||
|
||||
export type Mock1Series = {
|
||||
amplitude_sum: SeriesBuffer;
|
||||
amplitude1: SeriesBuffer;
|
||||
amplitude2: SeriesBuffer;
|
||||
amplitude3: SeriesBuffer;
|
||||
};
|
||||
|
||||
export const VENDOR_QITECH = 1;
|
||||
export const MACHINE_MOCK = 7;
|
||||
|
||||
// Maximum number of samples retained per series (~30 min at 10 Hz = 18 000)
|
||||
const BUFFER_SIZE = 20_000;
|
||||
|
||||
function makeBuffer(): SeriesBuffer {
|
||||
return {
|
||||
times: new Float64Array(BUFFER_SIZE),
|
||||
values: new Float64Array(BUFFER_SIZE),
|
||||
length: 0,
|
||||
head: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function pushSample(buf: SeriesBuffer, t: number, v: number): SeriesBuffer {
|
||||
buf.times[buf.head] = t;
|
||||
buf.values[buf.head] = v;
|
||||
buf.head = (buf.head + 1) % BUFFER_SIZE;
|
||||
if (buf.length < BUFFER_SIZE) buf.length++;
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Returns sorted [times, values] arrays suitable for uPlot (oldest → newest). */
|
||||
export function sortedSeries(buf: SeriesBuffer): [Float64Array, Float64Array] {
|
||||
if (buf.length === 0) return [new Float64Array(0), new Float64Array(0)];
|
||||
if (buf.length < BUFFER_SIZE) {
|
||||
// Buffer not yet full — data runs from index 0 to length-1
|
||||
return [buf.times.slice(0, buf.length), buf.values.slice(0, buf.length)];
|
||||
}
|
||||
// Ring buffer full — head points to oldest entry
|
||||
const times = new Float64Array(BUFFER_SIZE);
|
||||
const values = new Float64Array(BUFFER_SIZE);
|
||||
const tail = buf.head; // oldest index
|
||||
const firstLen = BUFFER_SIZE - tail;
|
||||
times.set(buf.times.subarray(tail), 0);
|
||||
times.set(buf.times.subarray(0, tail), firstLen);
|
||||
values.set(buf.values.subarray(tail), 0);
|
||||
values.set(buf.values.subarray(0, tail), firstLen);
|
||||
return [times, values];
|
||||
}
|
||||
|
||||
export function createMock1Namespace(serial: number) {
|
||||
return createNamespace<Mock1State>(
|
||||
machineNamespacePath(VENDOR_QITECH, MACHINE_MOCK, serial),
|
||||
(event, set) => {
|
||||
if (event.name === "StateEvent") {
|
||||
set("is_default_state", event.data.is_default_state);
|
||||
set("frequency1", event.data.frequency1);
|
||||
set("frequency2", event.data.frequency2);
|
||||
set("frequency3", event.data.frequency3);
|
||||
set("mode_state", event.data.mode_state);
|
||||
} else if (event.name === "LiveValuesEvent") {
|
||||
set("amplitude_sum", event.data.amplitude_sum);
|
||||
set("amplitude1", event.data.amplitude1);
|
||||
set("amplitude2", event.data.amplitude2);
|
||||
set("amplitude3", event.data.amplitude3);
|
||||
}
|
||||
},
|
||||
{
|
||||
is_default_state: null,
|
||||
frequency1: null,
|
||||
frequency2: null,
|
||||
frequency3: null,
|
||||
mode_state: null,
|
||||
amplitude_sum: null,
|
||||
amplitude1: null,
|
||||
amplitude2: null,
|
||||
amplitude3: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a second socket connection to the same machine namespace but only
|
||||
* buffers LiveValuesEvent data into ring buffers for graphing.
|
||||
*
|
||||
* Kept separate from createMock1Namespace so the control page doesn't pay the
|
||||
* memory cost of the buffers, and the graph page manages its own connection
|
||||
* lifecycle independently.
|
||||
*
|
||||
* Returns a reactive signal: each LiveValuesEvent triggers a new object so
|
||||
* graph components can react. The buffers are mutated in-place for efficiency.
|
||||
*/
|
||||
export function createMock1SeriesBuffer(serial: number): [() => Mock1Series] {
|
||||
const series: Mock1Series = {
|
||||
amplitude_sum: makeBuffer(),
|
||||
amplitude1: makeBuffer(),
|
||||
amplitude2: makeBuffer(),
|
||||
amplitude3: makeBuffer(),
|
||||
};
|
||||
|
||||
const [tick, setTick] = createSignal<Mock1Series>(series, { equals: false });
|
||||
|
||||
const path = machineNamespacePath(VENDOR_QITECH, MACHINE_MOCK, serial);
|
||||
const socket = io(BASE_URL + path, { autoConnect: false, parser: MsgPackParser });
|
||||
|
||||
socket.on("event", (raw: unknown) => {
|
||||
const parsed = genericEventSchema.safeParse(raw);
|
||||
if (!parsed.success) return;
|
||||
const event = parsed.data;
|
||||
if (event.name !== "LiveValuesEvent") return;
|
||||
const t = event.ts / 1000; // backend sends ms → convert to seconds for uPlot
|
||||
batch(() => {
|
||||
pushSample(series.amplitude_sum, t, event.data.amplitude_sum ?? 0);
|
||||
pushSample(series.amplitude1, t, event.data.amplitude1 ?? 0);
|
||||
pushSample(series.amplitude2, t, event.data.amplitude2 ?? 0);
|
||||
pushSample(series.amplitude3, t, event.data.amplitude3 ?? 0);
|
||||
setTick(series);
|
||||
});
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
onCleanup(() => socket.disconnect());
|
||||
|
||||
return [tick];
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Test machine namespace
|
||||
*
|
||||
* Compare to electron/src/machines/testmachine/testMachineNamespace.ts (~77 lines)
|
||||
* This is ~25 lines including the types.
|
||||
*/
|
||||
|
||||
import { createNamespace, machineNamespacePath } from "../lib/socketio";
|
||||
|
||||
export type TestMachineState = {
|
||||
led_on: boolean[] | null;
|
||||
};
|
||||
|
||||
export function createTestMachineNamespace(
|
||||
vendor: number,
|
||||
machine: number,
|
||||
serial: number,
|
||||
) {
|
||||
return createNamespace<TestMachineState>(
|
||||
machineNamespacePath(vendor, machine, serial),
|
||||
(event, set) => {
|
||||
if (event.name === "StateEvent") {
|
||||
set("led_on", event.data.led_on);
|
||||
}
|
||||
},
|
||||
{ led_on: null },
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Machines overview page — lists all detected machines with navigation links.
|
||||
* Uses the main namespace signal directly; no intermediate store or hook.
|
||||
*/
|
||||
|
||||
import { For, Show, createMemo } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import type { MachineObj } from "../namespaces/main";
|
||||
import type { MainState } from "../namespaces/main";
|
||||
|
||||
type Props = {
|
||||
mainState: () => MainState;
|
||||
};
|
||||
|
||||
function machineSlug(m: MachineObj): string {
|
||||
const { vendor, machine } = m.machine_identification_unique.machine_identification;
|
||||
// Map vendor_machine → URL slug (matches routes in App.tsx)
|
||||
const knownMachines: Record<string, string> = {
|
||||
"1_51": "testmachine", // 0x0033
|
||||
"1_2": "winder2", // 0x0002
|
||||
"1_4": "extruder2", // 0x0004
|
||||
"1_6": "laser1", // 0x0006
|
||||
"1_7": "mock1", // 0x0007
|
||||
"1_8": "buffer1", // 0x0008
|
||||
"1_9": "aquapath1", // 0x0009
|
||||
"1_10": "wago_power1", // 0x000A
|
||||
"1_22": "extruder3", // 0x0016
|
||||
};
|
||||
return knownMachines[`${vendor}_${machine}`] ?? `unknown-${vendor}-${machine}`;
|
||||
}
|
||||
|
||||
export default function MachinesPage(props: Props) {
|
||||
const machines = createMemo(() => props.mainState().machines);
|
||||
|
||||
return (
|
||||
<div class="page">
|
||||
<h1>Machines</h1>
|
||||
<Show when={machines() === null} fallback={
|
||||
<Show
|
||||
when={(machines()?.length ?? 0) > 0}
|
||||
fallback={<p class="muted">No machines detected.</p>}
|
||||
>
|
||||
<div class="machine-list">
|
||||
<For each={machines()!}>
|
||||
{(m) => {
|
||||
const slug = machineSlug(m);
|
||||
const serial = m.machine_identification_unique.serial;
|
||||
return (
|
||||
<div class={`machine-card ${m.error ? "error" : "ok"}`}>
|
||||
<div class="machine-info">
|
||||
<strong>{slug}</strong>
|
||||
<span class="serial">#{serial}</span>
|
||||
<Show when={m.error}>
|
||||
<span class="error-badge">{m.error}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="machine-links">
|
||||
<A href={`/machines/${slug}/${serial}`}>Overview</A>
|
||||
<A href={`/machines/${slug}/${serial}/control`}>Control</A>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
}>
|
||||
<p class="muted">Connecting to server…</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* Mock1 machine control page
|
||||
*
|
||||
* Demonstrates:
|
||||
* - High-frequency live values with inline sparklines (MiniGraph)
|
||||
* - Optimistic state updates for frequency + mode controls
|
||||
* - Mode toggle (Standby / Running)
|
||||
* - Numeric input with default value indicator
|
||||
*
|
||||
* Compare to electron/src/machines/mock/mock1/Mock1ControlPage.tsx +
|
||||
* useMock.ts (~200 lines combined). This file replaces both.
|
||||
*/
|
||||
|
||||
import { Show, createSignal, createMemo } from "solid-js";
|
||||
import { useParams } from "@solidjs/router";
|
||||
import { createMock1Namespace, createMock1SeriesBuffer, VENDOR_QITECH, MACHINE_MOCK } from "../namespaces/mock1";
|
||||
import type { Mode, SeriesBuffer } from "../namespaces/mock1";
|
||||
import { mutateMachine } from "../lib/api";
|
||||
import MiniGraph from "../components/MiniGraph";
|
||||
|
||||
export default function Mock1ControlPage() {
|
||||
const params = useParams<{ serial: string }>();
|
||||
const serial = () => parseInt(params.serial);
|
||||
|
||||
const [state] = createMock1Namespace(serial());
|
||||
const [getSeries] = createMock1SeriesBuffer(serial());
|
||||
|
||||
// Optimistic state: prefer local update until server confirms
|
||||
const [optimisticFreq1, setOptimisticFreq1] = createSignal<number | null>(null);
|
||||
const [optimisticFreq2, setOptimisticFreq2] = createSignal<number | null>(null);
|
||||
const [optimisticFreq3, setOptimisticFreq3] = createSignal<number | null>(null);
|
||||
const [optimisticMode, setOptimisticMode] = createSignal<Mode | null>(null);
|
||||
|
||||
const freq1 = () => optimisticFreq1() ?? state().frequency1 ?? 1000;
|
||||
const freq2 = () => optimisticFreq2() ?? state().frequency2 ?? 2000;
|
||||
const freq3 = () => optimisticFreq3() ?? state().frequency3 ?? 5000;
|
||||
const mode = () => optimisticMode() ?? state().mode_state?.mode ?? "Standby";
|
||||
|
||||
const isLoaded = createMemo(() => state().frequency1 !== null);
|
||||
|
||||
const machineId = () => ({
|
||||
machine_identification: { vendor: VENDOR_QITECH, machine: MACHINE_MOCK },
|
||||
serial: serial(),
|
||||
});
|
||||
|
||||
async function setFrequency(
|
||||
index: 1 | 2 | 3,
|
||||
value: number,
|
||||
setOptimistic: (v: number) => void,
|
||||
resetOptimistic: () => void,
|
||||
) {
|
||||
setOptimistic(value);
|
||||
try {
|
||||
await mutateMachine(machineId(), { [`SetFrequency${index}`]: value });
|
||||
} catch (e) {
|
||||
console.error(`SetFrequency${index} failed:`, e);
|
||||
resetOptimistic();
|
||||
}
|
||||
}
|
||||
|
||||
async function setMode(newMode: Mode) {
|
||||
setOptimisticMode(newMode);
|
||||
try {
|
||||
await mutateMachine(machineId(), { SetMode: newMode });
|
||||
} catch (e) {
|
||||
console.error("SetMode failed:", e);
|
||||
setOptimisticMode(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="page">
|
||||
<h1>Mock Machine — Control</h1>
|
||||
<p class="serial">Serial: #{serial()}</p>
|
||||
|
||||
<Show when={!isLoaded()} fallback={null}>
|
||||
<p class="muted">Waiting for machine state…</p>
|
||||
</Show>
|
||||
|
||||
<Show when={isLoaded()}>
|
||||
<div class="control-grid">
|
||||
|
||||
{/* Live values with sparklines */}
|
||||
<div class="control-card live-values-card">
|
||||
<div class="control-card-title">Live Values</div>
|
||||
<div class="live-values">
|
||||
<LiveValueRow
|
||||
label="Sum"
|
||||
value={state().amplitude_sum}
|
||||
getBuffer={() => getSeries().amplitude_sum}
|
||||
stroke="#6c8cff"
|
||||
/>
|
||||
<LiveValueRow
|
||||
label="Wave 1"
|
||||
value={state().amplitude1}
|
||||
getBuffer={() => getSeries().amplitude1}
|
||||
stroke="#6c8cff"
|
||||
/>
|
||||
<LiveValueRow
|
||||
label="Wave 2"
|
||||
value={state().amplitude2}
|
||||
getBuffer={() => getSeries().amplitude2}
|
||||
stroke="#f472b6"
|
||||
/>
|
||||
<LiveValueRow
|
||||
label="Wave 3"
|
||||
value={state().amplitude3}
|
||||
getBuffer={() => getSeries().amplitude3}
|
||||
stroke="#34d399"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frequencies */}
|
||||
<div class="control-card">
|
||||
<div class="control-card-title">Frequencies (mHz)</div>
|
||||
<div class="freq-grid">
|
||||
<FrequencyInput
|
||||
label="Frequency 1"
|
||||
value={freq1()}
|
||||
defaultValue={state().is_default_state ? null : state().frequency1}
|
||||
onChange={(v) => setFrequency(1, v, setOptimisticFreq1, () => setOptimisticFreq1(null))}
|
||||
/>
|
||||
<FrequencyInput
|
||||
label="Frequency 2"
|
||||
value={freq2()}
|
||||
defaultValue={state().is_default_state ? null : state().frequency2}
|
||||
onChange={(v) => setFrequency(2, v, setOptimisticFreq2, () => setOptimisticFreq2(null))}
|
||||
/>
|
||||
<FrequencyInput
|
||||
label="Frequency 3"
|
||||
value={freq3()}
|
||||
defaultValue={state().is_default_state ? null : state().frequency3}
|
||||
onChange={(v) => setFrequency(3, v, setOptimisticFreq3, () => setOptimisticFreq3(null))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div class="control-card">
|
||||
<div class="control-card-title">Mode</div>
|
||||
<div class="mode-selector">
|
||||
<button
|
||||
class={`mode-btn ${mode() === "Standby" ? "active" : ""}`}
|
||||
onClick={() => setMode("Standby")}
|
||||
>
|
||||
Standby
|
||||
</button>
|
||||
<button
|
||||
class={`mode-btn ${mode() === "Running" ? "active running" : ""}`}
|
||||
onClick={() => setMode("Running")}
|
||||
>
|
||||
Running
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LiveValueRow(props: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
getBuffer: () => SeriesBuffer;
|
||||
stroke: string;
|
||||
}) {
|
||||
return (
|
||||
<div class="live-value-row-spark">
|
||||
<div class="live-value-left">
|
||||
<span class="live-value-label">{props.label}</span>
|
||||
<span class="live-value-number">
|
||||
<Show when={props.value !== null} fallback={<span class="muted">–</span>}>
|
||||
{props.value!.toFixed(3)}
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
<div class="live-value-spark">
|
||||
<MiniGraph getBuffer={props.getBuffer} stroke={props.stroke} timeWindow={10} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FrequencyInput(props: {
|
||||
label: string;
|
||||
value: number;
|
||||
defaultValue: number | null | undefined;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
const [editValue, setEditValue] = createSignal<string | null>(null);
|
||||
|
||||
const displayValue = () => editValue() ?? props.value.toFixed(0);
|
||||
|
||||
function commit() {
|
||||
const v = parseFloat(editValue() ?? "");
|
||||
if (!isNaN(v) && v >= 0 && v <= 100_000) {
|
||||
props.onChange(v);
|
||||
}
|
||||
setEditValue(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="freq-input-group">
|
||||
<label class="freq-label">{props.label}</label>
|
||||
<div class="freq-input-row">
|
||||
<input
|
||||
class="freq-input"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100000"
|
||||
step="100"
|
||||
value={displayValue()}
|
||||
onInput={(e) => setEditValue(e.currentTarget.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||||
/>
|
||||
<Show when={props.defaultValue != null && props.defaultValue !== props.value}>
|
||||
<button
|
||||
class="freq-reset-btn"
|
||||
title={`Reset to default (${props.defaultValue!.toFixed(0)})`}
|
||||
onClick={() => props.onChange(props.defaultValue!)}
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Mock1 graph page — rolling time-window uPlot charts.
|
||||
*
|
||||
* Two charts:
|
||||
* 1. Amplitude Sum — single series + ±0.8 threshold lines
|
||||
* 2. Individual Waves — amplitude1/2/3 overlaid
|
||||
*
|
||||
* Performance design:
|
||||
* - UPlotGraph drives itself from a rAF loop; no reactive memos.
|
||||
* - getSeriesData() slices only the visible window from the ring buffer on
|
||||
* each frame rather than copying the entire buffer reactively.
|
||||
* - The ring buffer itself is mutated in-place by the socket handler.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { useParams } from "@solidjs/router";
|
||||
import { createMock1SeriesBuffer } from "../namespaces/mock1";
|
||||
import type { SeriesBuffer } from "../namespaces/mock1";
|
||||
import UPlotGraph from "../components/UPlotGraph";
|
||||
import type { HorizLine } from "../components/UPlotGraph";
|
||||
import type uPlot from "uplot";
|
||||
|
||||
// Available time windows in seconds
|
||||
const TIME_WINDOWS = [
|
||||
{ label: "30 s", value: 30 },
|
||||
{ label: "2 min", value: 120 },
|
||||
{ label: "5 min", value: 300 },
|
||||
{ label: "30 min", value: 1800 },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Slice the ring buffer to only the entries within [tMax - windowSecs, tMax].
|
||||
* Returns typed arrays already in chronological order.
|
||||
* O(n) scan but n is at most BUFFER_SIZE and only called once per rAF frame.
|
||||
*/
|
||||
function windowedSlice(buf: SeriesBuffer, windowSecs: number): [Float64Array, Float64Array] {
|
||||
const { times, values, length, head } = buf;
|
||||
if (length === 0) return [new Float64Array(0), new Float64Array(0)];
|
||||
|
||||
const bufSize = times.length; // BUFFER_SIZE
|
||||
const isFull = length === bufSize;
|
||||
|
||||
// Determine chronological start index in the ring
|
||||
const startIdx = isFull ? head : 0;
|
||||
const tMax = times[(head - 1 + bufSize) % bufSize]; // last written entry
|
||||
const tMin = tMax - windowSecs;
|
||||
|
||||
// Count how many entries fall inside [tMin, tMax]
|
||||
let count = 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const t = times[(startIdx + i) % bufSize];
|
||||
if (t >= tMin) count++;
|
||||
}
|
||||
|
||||
const outTimes = new Float64Array(count);
|
||||
const outValues = new Float64Array(count);
|
||||
let out = 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const idx = (startIdx + i) % bufSize;
|
||||
const t = times[idx];
|
||||
if (t >= tMin) {
|
||||
outTimes[out] = t;
|
||||
outValues[out] = values[idx];
|
||||
out++;
|
||||
}
|
||||
}
|
||||
return [outTimes, outValues];
|
||||
}
|
||||
|
||||
export default function Mock1GraphPage() {
|
||||
const params = useParams<{ serial: string }>();
|
||||
const serial = () => parseInt(params.serial);
|
||||
|
||||
// createMock1SeriesBuffer opens its own socket; it disconnects on page unmount.
|
||||
// We hold a plain JS reference to the series object — no signal dependency here.
|
||||
// The rAF loop inside UPlotGraph reads from it directly every frame.
|
||||
const [getSeriesTick] = createMock1SeriesBuffer(serial());
|
||||
|
||||
const [timeWindow, setTimeWindow] = createSignal(30); // seconds
|
||||
|
||||
// These callbacks are called once per animation frame by UPlotGraph.
|
||||
// They capture `getSeriesTick` to always read from the latest ring buffers.
|
||||
function getSumData(): uPlot.AlignedData {
|
||||
const s = getSeriesTick();
|
||||
const [t, v] = windowedSlice(s.amplitude_sum, timeWindow());
|
||||
return [t, v];
|
||||
}
|
||||
|
||||
function getAllWavesData(): uPlot.AlignedData {
|
||||
const s = getSeriesTick();
|
||||
const tw = timeWindow();
|
||||
const [t, v1] = windowedSlice(s.amplitude1, tw);
|
||||
const [, v2] = windowedSlice(s.amplitude2, tw);
|
||||
const [, v3] = windowedSlice(s.amplitude3, tw);
|
||||
return [t, v1, v2, v3];
|
||||
}
|
||||
|
||||
const sumSeries: uPlot.Series[] = [
|
||||
{ label: "Sum", stroke: "#6c8cff", width: 2 },
|
||||
];
|
||||
|
||||
const allWavesSeries: uPlot.Series[] = [
|
||||
{ label: "Wave 1", stroke: "#6c8cff", width: 1.5 },
|
||||
{ label: "Wave 2", stroke: "#f472b6", width: 1.5 },
|
||||
{ label: "Wave 3", stroke: "#34d399", width: 1.5 },
|
||||
];
|
||||
|
||||
const sumLines: HorizLine[] = [
|
||||
{ value: 0.8, color: "#6c8cff", dash: true, label: "threshold" },
|
||||
{ value: -0.8, color: "#6c8cff", dash: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div class="page page-wide">
|
||||
<h1>Mock Machine — Graph</h1>
|
||||
<p class="serial">Serial: #{serial()}</p>
|
||||
|
||||
<div class="graph-toolbar">
|
||||
<span class="graph-toolbar-label">Window</span>
|
||||
{TIME_WINDOWS.map((w) => (
|
||||
<button
|
||||
class={`graph-window-btn${timeWindow() === w.value ? " active" : ""}`}
|
||||
onClick={() => setTimeWindow(w.value)}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="graph-stack">
|
||||
<div class="graph-card">
|
||||
<div class="graph-card-title">Amplitude Sum</div>
|
||||
<UPlotGraph
|
||||
getSeriesData={getSumData}
|
||||
series={sumSeries}
|
||||
unit="amp"
|
||||
height={260}
|
||||
timeWindow={timeWindow()}
|
||||
lines={sumLines}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="graph-card">
|
||||
<div class="graph-card-title">Individual Waves</div>
|
||||
<UPlotGraph
|
||||
getSeriesData={getAllWavesData}
|
||||
series={allWavesSeries}
|
||||
unit="amp"
|
||||
height={260}
|
||||
timeWindow={timeWindow()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Test machine control page — LED toggle proof of concept
|
||||
*
|
||||
* This replaces:
|
||||
* electron/src/machines/testmachine/TestMachineControlPage.tsx
|
||||
* electron/src/machines/testmachine/useTestMachine.ts (~95 lines)
|
||||
* electron/src/machines/testmachine/testMachineNamespace.ts (~77 lines)
|
||||
*
|
||||
* In SolidJS the entire machine data + mutation logic lives here or in a
|
||||
* thin helper — no Zustand, no ThrottledStoreUpdater, no lifecycle hooks.
|
||||
*
|
||||
* Optimistic updates: SolidJS signals update synchronously, so the UI
|
||||
* reflects the change immediately. The server will confirm via the next
|
||||
* StateEvent pushed over WebSocket.
|
||||
*/
|
||||
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import { useParams } from "@solidjs/router";
|
||||
import { createTestMachineNamespace } from "../namespaces/testMachine";
|
||||
import { mutateMachine } from "../lib/api";
|
||||
|
||||
// machine_identification for testmachine: vendor=1, machine=0x0033 (51)
|
||||
const VENDOR = 1;
|
||||
const MACHINE = 0x0033;
|
||||
|
||||
export default function TestMachineControlPage() {
|
||||
const params = useParams<{ serial: string }>();
|
||||
const serial = () => parseInt(params.serial);
|
||||
|
||||
// createNamespace() creates the socket and returns a reactive signal.
|
||||
// The signal is updated whenever a StateEvent arrives.
|
||||
// The socket is disconnected automatically when this component unmounts.
|
||||
const [machineState] = createTestMachineNamespace(VENDOR, MACHINE, serial());
|
||||
|
||||
// Optimistic LED state — start from server state, update immediately on click
|
||||
const [optimisticLeds, setOptimisticLeds] = createSignal<boolean[] | null>(null);
|
||||
|
||||
// Derive: prefer optimistic state, fall back to server state
|
||||
const leds = () => optimisticLeds() ?? machineState().led_on;
|
||||
|
||||
const setLed = async (index: number, on: boolean) => {
|
||||
// Optimistic update — instant UI feedback
|
||||
const current = leds();
|
||||
if (current) {
|
||||
const next = [...current];
|
||||
next[index] = on;
|
||||
setOptimisticLeds(next);
|
||||
}
|
||||
|
||||
try {
|
||||
await mutateMachine(
|
||||
{
|
||||
machine_identification: { vendor: VENDOR, machine: MACHINE },
|
||||
serial: serial(),
|
||||
},
|
||||
{ action: "SetLed", value: { index, on } },
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("SetLed failed:", e);
|
||||
// Revert optimistic update on error
|
||||
setOptimisticLeds(null);
|
||||
}
|
||||
};
|
||||
|
||||
const setAllLeds = async (on: boolean) => {
|
||||
const current = leds();
|
||||
if (current) {
|
||||
setOptimisticLeds(current.map(() => on));
|
||||
}
|
||||
|
||||
try {
|
||||
await mutateMachine(
|
||||
{
|
||||
machine_identification: { vendor: VENDOR, machine: MACHINE },
|
||||
serial: serial(),
|
||||
},
|
||||
{ action: "SetAllLeds", value: { on } },
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("SetAllLeds failed:", e);
|
||||
setOptimisticLeds(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="page">
|
||||
<h1>Test Machine — Control</h1>
|
||||
<p class="serial">Serial: #{serial()}</p>
|
||||
|
||||
<Show when={leds() !== null} fallback={<p class="muted">Waiting for machine state…</p>}>
|
||||
<div class="led-grid">
|
||||
<For each={leds()!}>
|
||||
{(on, i) => (
|
||||
<button
|
||||
class={`led-btn ${on ? "led-on" : "led-off"}`}
|
||||
onClick={() => setLed(i(), !on)}
|
||||
>
|
||||
LED {i() + 1}
|
||||
<span class="led-indicator">{on ? "●" : "○"}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="led-actions">
|
||||
<button onClick={() => setAllLeds(true)}>All On</button>
|
||||
<button onClick={() => setAllLeds(false)}>All Off</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
/* ── Reset & base ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--surface2: #242736;
|
||||
--border: #2e3148;
|
||||
--accent: #6c8cff;
|
||||
--accent-dim: #3b4fa8;
|
||||
--text: #e2e4f0;
|
||||
--text-muted: #717898;
|
||||
--ok: #4ade80;
|
||||
--error: #f87171;
|
||||
--font: 'Inter', system-ui, sans-serif;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root { height: 100vh; }
|
||||
|
||||
/* ── App layout ── */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 8px 16px 16px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--accent);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.sidebar-section-label {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: block;
|
||||
padding: 7px 16px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
border-radius: 0;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-link:hover { background: var(--surface2); }
|
||||
.sidebar-link.active { color: var(--accent); background: var(--surface2); }
|
||||
|
||||
.sidebar-sublink {
|
||||
display: block;
|
||||
padding: 4px 16px 4px 28px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sidebar-sublink:hover { color: var(--text); }
|
||||
.sidebar-sublink.active { color: var(--accent); }
|
||||
|
||||
.sidebar-serial { color: var(--text-muted); font-size: 12px; }
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connection-dot.connected { background: var(--ok); }
|
||||
.connection-dot.disconnected { background: var(--error); }
|
||||
|
||||
/* ── Pages ── */
|
||||
.page {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.page h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.muted { color: var(--text-muted); }
|
||||
|
||||
.serial { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
|
||||
|
||||
/* ── Machine list ── */
|
||||
.machine-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.machine-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.machine-card.error { border-color: var(--error); }
|
||||
|
||||
.machine-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.machine-info strong { font-weight: 500; }
|
||||
|
||||
.error-badge {
|
||||
background: #3b1c1c;
|
||||
color: var(--error);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.machine-links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.machine-links a {
|
||||
padding: 5px 12px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.machine-links a:hover { background: var(--accent-dim); border-color: var(--accent); }
|
||||
|
||||
/* ── LED control ── */
|
||||
.led-grid {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.led-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.12s;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.led-btn:hover { border-color: var(--accent-dim); }
|
||||
|
||||
.led-btn.led-on {
|
||||
border-color: var(--ok);
|
||||
background: #0d2b1a;
|
||||
}
|
||||
|
||||
.led-indicator {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.led-btn.led-on .led-indicator { color: var(--ok); }
|
||||
.led-btn.led-off .led-indicator { color: var(--text-muted); }
|
||||
|
||||
.led-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.led-actions button {
|
||||
padding: 8px 20px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.led-actions button:hover {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Control grid (used by machine control pages) ── */
|
||||
.control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.control-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.control-card-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* ── Live values ── */
|
||||
.live-values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Row with sparkline: [label + number] [graph] */
|
||||
.live-value-row-spark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.live-value-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 68px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.live-value-spark {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.live-value-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.live-value-number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Card wide enough to show the sparklines nicely */
|
||||
.live-values-card {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* MiniGraph container */
|
||||
.minigraph-wrap {
|
||||
width: 100%;
|
||||
display: block;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Frequency inputs ── */
|
||||
.freq-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.freq-input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.freq-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.freq-input-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.freq-input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
.freq-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.freq-reset-btn {
|
||||
padding: 5px 8px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.freq-reset-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ── Mode selector ── */
|
||||
.mode-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
background: var(--surface2);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-family: var(--font);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: #1a2040;
|
||||
}
|
||||
|
||||
.mode-btn.active.running {
|
||||
border-color: var(--ok);
|
||||
color: var(--ok);
|
||||
background: #0d2b1a;
|
||||
}
|
||||
|
||||
/* ── Graph page ── */
|
||||
.page-wide {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.graph-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.graph-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.graph-card-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Graph toolbar (time-window selector) ── */
|
||||
.graph-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.graph-toolbar-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.graph-window-btn {
|
||||
padding: 4px 12px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: var(--font);
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.graph-window-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.graph-window-btn.active {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* uPlot container — must be a block that fills its parent */
|
||||
.uplot-wrap {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Hide the built-in title (we use .graph-card-title instead) */
|
||||
.uplot-wrap .u-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Legend table ── */
|
||||
/* uPlot renders the legend as a <table>; our CSS reset strips table layout,
|
||||
so we need to restore it explicitly. */
|
||||
.uplot-wrap .u-legend {
|
||||
display: table;
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding-top: 8px;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.uplot-wrap .u-legend tr {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.uplot-wrap .u-legend th,
|
||||
.uplot-wrap .u-legend td {
|
||||
display: table-cell;
|
||||
padding: 2px 8px 2px 0;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* First row is the header (Time / series names) */
|
||||
.uplot-wrap .u-legend .u-thead tr {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
/* Series colour swatch */
|
||||
.uplot-wrap .u-legend .u-marker {
|
||||
width: 12px;
|
||||
height: 3px;
|
||||
display: inline-block;
|
||||
border-radius: 2px;
|
||||
vertical-align: middle;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Cursor crosshair colour */
|
||||
.uplot-wrap .u-cursor-x,
|
||||
.uplot-wrap .u-cursor-y {
|
||||
border-color: var(--text-muted) !important;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// General
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"target": "ESNext",
|
||||
|
||||
// Modules
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
|
||||
// Type Checking & Safety
|
||||
"strict": true,
|
||||
"types": ["vite/client"]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import solidPlugin from 'vite-plugin-solid';
|
||||
import devtools from 'solid-devtools/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [devtools(), solidPlugin()],
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user