Compare commits

..
Author SHA1 Message Date
jsentity c8673c2113 create frontend components 2026-02-20 04:11:18 +01:00
jsentity ba91f2266d Create backend code for ff01 mock machine 2026-02-20 03:02:59 +01:00
Robin Krämer d6dccc1b1a 1143 simplify api and add pw protection (#1146)
* added way to generate credentials + config for caddy, added caddy as a reverse proxy
* opened up firewall properly again
2026-02-19 17:45:51 +01:00
Melvin Spieß 5f636a6f15 Merge pull request #1144 from qitechgmbh/1185-target-line-historical
1185 target line historical
2026-02-19 14:50:37 +01:00
JalleliAchraf ac8a8c37ed formatting 2026-02-19 14:11:46 +01:00
Melvin Spieß 03e057f6a9 Merge pull request #1118 from qitechgmbh/889-dre-keep-zoom-level-when-switich-from-live-view-to-historical-view
889 dre keep zoom level when switich from live view to historical view
2026-02-19 14:05:14 +01:00
JalleliAchraf fb195eb7d4 changed speed of the target line 2026-02-19 14:02:07 +01:00
Snacj 874766633a Changed Historical Button to work as before. Only keep scale on gesture
Fixed requested changes

Fixed formatting

Fixed compilation errors

Fixed Formatting

Refresh .gitignore

Refresh .gitignore
2026-02-19 14:00:21 +01:00
Snacj 047b0fbe55 Switch to Historical Mode will now preserve current scale. 2026-02-19 14:00:21 +01:00
JalleliAchraf e3e9412578 Smooth target-line dash flow animation 2026-02-19 13:51:50 +01:00
Melvin Spießandjust_some_entity 92ead0e775 1084 added wago stepper terminal 750 672 (#1138)
* Added base of wago Stepper Controller

Added Debug for testing rx_pdo

* Added Driver Architecture for Stepper Controller and samll testing machine

* Added driver to Minimal Machine

safing progress

* Added infrastructure to control speed

Motor Running

Refactor of Offsets

Refactor Control and Statusbytes into their own Structs.

* Added stepper velocity helper

refactor rxpdo txpdo

Fixed Compilation errors

Motor Running - now move tick() function somewhere else.

* Added Minimal Machine for Wago Stepper Controller

Changed starting Velocity and Acceleration for testing

* Refactor

fix

* adding more debug for testing

Converted 671 Stepper to 672 Stepper

WIP

* Added Statemachine back to driver

WIP

Should be working now

Rebased from Master

* Added Digital Inputs handling to the Driver

* Added Freq Prescaler and Acc Prescaler

Removed 750 671 from this branch

Removed unused file

Added correct productId for Bus coupler

* Fixed Wago Coupler

Fixed Formatting

chore: update npmDepsHash to sha256-N2kdS9IKkEMNOPPnRNQ9GvCenR/k88fVyAFzXMJyxs4=

Fixed naming, formatting

* Added documentation

* Fixed Minor issues

* Fixed Formatting

* Allow dead code

* Removed unused import

---------

Co-authored-by: just_some_entity <entity@jsentity.dev>
2026-02-19 13:14:01 +01:00
JalleliAchraf 06a6c81b55 Record target history only on value change 2026-02-18 12:49:36 +01:00
JalleliAchraf 7b858251ea Optimize dashed target draw to visible window only 2026-02-18 12:31:34 +01:00
JalleliAchraf 04700d690c Render historical dashed targets via custom draw hook 2026-02-18 12:31:34 +01:00
JalleliAchraf e4d950ae9b Add target history tracking on master baseline for extruder/laser/winder 2026-02-18 12:31:34 +01:00
Christian 5562cf5a53 Add GitHub Actions workflow for issue status checks
Refactor issue status check workflow to identify stale assigned issues and request updates

Refactor linked pull request check to use search API for improved reliability

Add comprehensive error handling and rate limit monitoring
2026-02-17 17:48:51 +01:00
95 changed files with 4686 additions and 6471 deletions

No files matched your search

+213
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+13 -96
View File
@@ -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
+16 -3
View File
@@ -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 {
+173 -5
View File
@@ -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(() => {
+6 -2
View File
@@ -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();
+10 -1
View File
@@ -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 />,
});
+43
View File
@@ -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,
+41
View File
@@ -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
+19
View File
@@ -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,
]),
+23 -8
View File
@@ -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,
+1
View File
@@ -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,
}
+4
View File
@@ -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;
+8 -1
View File
@@ -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 {
+7 -1
View File
@@ -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
};
}
+60
View File
@@ -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 => (),
}
}
}
+12
View File
@@ -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
}
}
+43
View File
@@ -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(),
}
}
}
+217
View File
@@ -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()
}
}
+37
View File
@@ -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();
}
}
+20
View File
@@ -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)
}
}
+39
View File
@@ -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(),
}
}
+12
View File
@@ -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
}
}
+34
View File
@@ -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 {}
}
}
+217
View File
@@ -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()
}
}
+21
View File
@@ -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 }
}
}
+33
View File
@@ -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) => {}
}
}
}
+87
View File
@@ -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()
}
}
+76
View File
@@ -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();
}
}
+86
View File
@@ -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 &params.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)
})
}
}
+10
View File
@@ -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
View File
@@ -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?
}
+1 -1
View File
@@ -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 = ''
-28
View File
@@ -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
-298
View File
@@ -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`).
-36
View File
@@ -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)
-156
View File
@@ -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.
-16
View File
@@ -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>
File diff suppressed because it is too large. Load diff
-27
View File
@@ -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"
}
}
-1346
View File
File diff suppressed because it is too large. Load diff
-33
View File
@@ -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);
}
}
-81
View File
@@ -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" />;
}
-13
View File
@@ -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;
}
-16
View File
@@ -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!);
-27
View File
@@ -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}`);
}
}
-114
View File
@@ -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
View File
@@ -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

-65
View File
@@ -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 },
);
}
-165
View File
@@ -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>
);
}
-537
View File
@@ -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;
}
-20
View File
@@ -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"]
}
}
-13
View File
@@ -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',
},
});