Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
658f524eff | ||
|
|
88b0c87a51 | ||
|
|
4178c90769 | ||
|
|
2c99231f1f | ||
|
|
26b417c0ec | ||
|
|
7df303bdf4 | ||
|
|
93c5e8d599 | ||
|
|
c5fbdd0a0f | ||
|
|
45e03b9c93 | ||
|
|
5562cf5a53 | ||
|
|
3a24c24836 | ||
|
|
26e4c6ab6c | ||
|
|
60f95ec9b6 | ||
|
|
af0b5deff0 | ||
|
|
743a589c1d | ||
|
|
ee7550d89f | ||
|
|
ec13f715aa | ||
|
|
2af746e7a4 | ||
|
|
3af3fb9d79 | ||
|
|
ff3103bec2 | ||
|
|
5eb3c39f84 | ||
|
|
2c715d6a8e | ||
|
|
fb12001721 | ||
|
|
ad94754fcf | ||
|
|
b919721c3a | ||
|
|
a428a2aa02 | ||
|
|
7b479c6021 | ||
|
|
17ac24c79d | ||
|
|
8c7a04b616 | ||
|
|
fe248c79ae | ||
|
|
038df09611 | ||
|
|
9e43d8d431 | ||
|
|
d364263d6e | ||
|
|
2c7f65dc78 | ||
|
|
d3d2ab4828 | ||
|
|
a839a374a3 | ||
|
|
6049737955 | ||
|
|
0c4a1eec39 |
No files matched your search
@@ -0,0 +1,213 @@
|
||||
name: Stale Assigned Issue Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every Sunday at 9:00 AM UTC
|
||||
- cron: '0 9 * * 0'
|
||||
workflow_dispatch: # Allow manual triggering for testing
|
||||
|
||||
jobs:
|
||||
check-stale-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Check assigned issues for status updates
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
|
||||
// Helper function to check rate limits
|
||||
async function checkRateLimit() {
|
||||
try {
|
||||
const { data: rateLimit } = await github.rest.rateLimit.get();
|
||||
const remaining = rateLimit.resources.core.remaining;
|
||||
const reset = new Date(rateLimit.resources.core.reset * 1000);
|
||||
console.log(`Rate limit: ${remaining} requests remaining (resets at ${reset.toISOString()})`);
|
||||
return remaining > 10; // Stop if fewer than 10 requests remaining
|
||||
} catch (error) {
|
||||
console.log(`Could not check rate limit: ${error.message}`);
|
||||
return true; // Continue on error
|
||||
}
|
||||
}
|
||||
|
||||
// Get all open issues
|
||||
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
console.log(`Found ${issues.length} open issues`);
|
||||
|
||||
// Helper function to check if issue has linked PRs using GraphQL
|
||||
async function hasLinkedPRs(issueNumber) {
|
||||
try {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $issueNumber: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issue(number: $issueNumber) {
|
||||
timelineItems(first: 100, itemTypes: CONNECTED_EVENT) {
|
||||
nodes {
|
||||
... on ConnectedEvent {
|
||||
subject {
|
||||
... on PullRequest {
|
||||
number
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const result = await github.graphql(query, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issueNumber: issueNumber
|
||||
});
|
||||
|
||||
const linkedPRs = result.repository.issue.timelineItems.nodes
|
||||
.filter(node => node.subject && node.subject.state === 'OPEN')
|
||||
.map(node => node.subject.number);
|
||||
|
||||
return linkedPRs.length > 0;
|
||||
} catch (error) {
|
||||
console.log(`Could not check linked PRs for issue #${issueNumber}: ${error.message}`);
|
||||
return false; // Assume no linked PRs on error to avoid false positives
|
||||
}
|
||||
}
|
||||
|
||||
let processedCount = 0;
|
||||
let commentedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const issue of issues) {
|
||||
try {
|
||||
// Check rate limit periodically
|
||||
if (processedCount % 10 === 0 && !(await checkRateLimit())) {
|
||||
console.log('Rate limit nearly exhausted, stopping to avoid hitting limit');
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip pull requests (they appear in issues list too)
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if not assigned
|
||||
if (!issue.assignees || issue.assignees.length === 0) {
|
||||
console.log(`Issue #${issue.number}: Not assigned, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Checking issue #${issue.number}: ${issue.title}`);
|
||||
processedCount++;
|
||||
|
||||
// Check if issue has linked PRs
|
||||
const hasOpenLinkedPR = await hasLinkedPRs(issue.number);
|
||||
|
||||
if (hasOpenLinkedPR) {
|
||||
console.log(`Issue #${issue.number}: Has linked open PR, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get all comments on the issue
|
||||
let comments = [];
|
||||
try {
|
||||
comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 100
|
||||
});
|
||||
} catch (commentError) {
|
||||
console.error(`Issue #${issue.number}: Could not fetch comments - ${commentError.message}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the most recent comment or update
|
||||
let lastActivity = new Date(issue.created_at);
|
||||
|
||||
if (issue.updated_at) {
|
||||
lastActivity = new Date(issue.updated_at);
|
||||
}
|
||||
|
||||
if (comments.length > 0) {
|
||||
const lastComment = comments[comments.length - 1];
|
||||
const lastCommentDate = new Date(lastComment.created_at);
|
||||
if (lastCommentDate > lastActivity) {
|
||||
lastActivity = lastCommentDate;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if last activity was more than a week ago
|
||||
const daysSinceActivity = Math.floor((now - lastActivity) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysSinceActivity >= 7) {
|
||||
console.log(`Issue #${issue.number}: No activity for ${daysSinceActivity} days`);
|
||||
|
||||
// Check if we already posted a status request in the last 7 days
|
||||
const recentComments = comments.filter(comment => {
|
||||
const commentDate = new Date(comment.created_at);
|
||||
return (now - commentDate) < ONE_WEEK_MS;
|
||||
});
|
||||
|
||||
const alreadyAsked = recentComments.some(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('status update')
|
||||
);
|
||||
|
||||
if (alreadyAsked) {
|
||||
console.log(`Issue #${issue.number}: Already requested status this week, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Post a status request comment
|
||||
try {
|
||||
const assigneeNames = issue.assignees.map(a => `@${a.login}`).join(', ');
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `Hi ${assigneeNames}! 👋\n\n` +
|
||||
`This issue has been assigned to you for ${daysSinceActivity} days with no recent activity or linked pull requests.\n\n` +
|
||||
`Could you please provide a status update? If you're:\n` +
|
||||
`- ✅ Working on it: Let us know your progress\n` +
|
||||
`- 🔄 Need help: Tag someone or describe what's blocking you\n` +
|
||||
`- 🚫 No longer working on it: Let us know so we can reassign\n\n` +
|
||||
`Thank you!`
|
||||
});
|
||||
|
||||
console.log(`Issue #${issue.number}: Posted status request comment`);
|
||||
commentedCount++;
|
||||
} catch (commentError) {
|
||||
console.error(`Issue #${issue.number}: Could not post comment - ${commentError.message}`);
|
||||
errorCount++;
|
||||
// Continue to next issue
|
||||
}
|
||||
} else {
|
||||
console.log(`Issue #${issue.number}: Activity ${daysSinceActivity} days ago, still fresh`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Issue #${issue.number}: Error processing - ${error.message}`);
|
||||
errorCount++;
|
||||
// Continue to next issue instead of failing the entire workflow
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nStale issue check complete!`);
|
||||
console.log(`Processed: ${processedCount} issues`);
|
||||
console.log(`Commented on: ${commentedCount} issues`);
|
||||
console.log(`Errors: ${errorCount}`);
|
||||
@@ -22,7 +22,7 @@ QiTech Control combines the modularity and reliability of standard EtherCAT term
|
||||
|
||||
| Software Demo | Full Explainer |
|
||||
|---------------|----------------|
|
||||
| [](https://www.youtube.com/watch?v=KI3YeBwfV-s)<br/>*Click to watch a demo of the control software in action* | [](https://youtu.be/55egCAkQgyM)<br/>*Click to watch a complete overview of QiTech Control* |
|
||||
| [](https://www.youtube.com/watch?v=KI3YeBwfV-s)<br/>*Watch a demo of the control software in action* | [](https://youtu.be/55egCAkQgyM)<br/>*Watch a complete overview of QiTech Control* |
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -63,6 +63,15 @@ QiTech Control powers 10+ production machines for filament extrusion, winding, m
|
||||
|
||||
This is an open-source project. Contributions are welcome! Please see the [wiki](https://github.com/qitechgmbh/control/wiki) for development guidelines.
|
||||
|
||||
|
||||
|
||||
## Additional Videos
|
||||
|
||||
| Episode Highlights | |
|
||||
|--------------------|--------------------|
|
||||
| [](https://www.youtube.com/watch?v=U2wNN6sF4to&t=163s)<br/>**Day in the Software Team**<br/>*Behind the scenes with Robin and how the team ships control software.* | [](https://www.youtube.com/watch?v=R5WGAY3WRWA&t=1s)<br/>**Software Stack Day**<br/>*A fast tour through the tools, stack, and daily workflow.* |
|
||||
| [](https://www.youtube.com/watch?v=xyaHwxXguw4&t=32s)<br/>**First Client**<br/>*A milestone week building the first real client project.* | [](https://www.youtube.com/watch?v=moI7z2PVdF4)<br/>**Steelworks Week**<br/>*A deep-dive week on a demanding steelworks order.* |
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE) file for details.
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 687 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 985 KiB |
@@ -5,8 +5,23 @@ import {
|
||||
AnimationRefs,
|
||||
AnimationState,
|
||||
BigGraphProps,
|
||||
GraphLine,
|
||||
SeriesData,
|
||||
} from "./types";
|
||||
import { alignTargetSeriesToTimestamps } from "@/lib/timeseries";
|
||||
|
||||
function hasHistoricalDashedTargets(
|
||||
lines?: Array<Partial<GraphLine> & { show?: boolean; value: number }>,
|
||||
): boolean {
|
||||
return (
|
||||
lines?.some(
|
||||
(line) =>
|
||||
line.show !== false &&
|
||||
!!line.targetSeries &&
|
||||
(line.dash?.length ?? 0) > 0,
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
export function useAnimationRefs(): AnimationRefs {
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
@@ -24,12 +39,14 @@ export function useAnimationRefs(): AnimationRefs {
|
||||
values: number[];
|
||||
}>({ timestamps: [], values: [] });
|
||||
const realPointsCountRef = useRef(0);
|
||||
const targetLineCacheRef = useRef<Map<number, number[]>>(new Map());
|
||||
|
||||
return {
|
||||
animationFrame: animationFrameRef,
|
||||
animationState: animationStateRef,
|
||||
lastRenderedData: lastRenderedDataRef,
|
||||
realPointsCount: realPointsCountRef,
|
||||
targetLineCache: targetLineCacheRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,8 +67,12 @@ 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[][],
|
||||
targetLineCache?: React.RefObject<Map<number, number[]>>,
|
||||
freezeTargetTail: boolean = false,
|
||||
): uPlot.AlignedData {
|
||||
const uData: uPlot.AlignedData = [timestamps];
|
||||
|
||||
@@ -70,9 +91,55 @@ export function buildUPlotData(
|
||||
}
|
||||
|
||||
// Add config lines
|
||||
let lineIndex = 0;
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
uData.push(timestamps.map(() => line.value));
|
||||
let lineData: number[];
|
||||
|
||||
if (line.targetSeries && targetLineCache) {
|
||||
// Target line with history - use cache to prevent wiggle
|
||||
const cached = targetLineCache.current.get(lineIndex);
|
||||
|
||||
if (cached && cached.length <= timestamps.length) {
|
||||
// Reuse cached data for existing points, extend with current value
|
||||
const extension = timestamps.length - cached.length;
|
||||
if (extension > 0) {
|
||||
const stableTailValue = cached[cached.length - 1] ?? line.value;
|
||||
lineData = [
|
||||
...cached,
|
||||
...new Array(extension).fill(
|
||||
freezeTargetTail ? stableTailValue : line.value,
|
||||
),
|
||||
];
|
||||
} else {
|
||||
lineData = [...cached];
|
||||
}
|
||||
if (!freezeTargetTail) {
|
||||
targetLineCache.current.set(lineIndex, lineData);
|
||||
}
|
||||
} else {
|
||||
// Full recalculation (first time or chart was rebuilt)
|
||||
lineData = alignTargetSeriesToTimestamps(
|
||||
line.targetSeries,
|
||||
timestamps,
|
||||
line.value,
|
||||
);
|
||||
targetLineCache.current.set(lineIndex, lineData);
|
||||
}
|
||||
} else if (line.targetSeries) {
|
||||
// targetSeries but no cache available - full calculation
|
||||
lineData = alignTargetSeriesToTimestamps(
|
||||
line.targetSeries,
|
||||
timestamps,
|
||||
line.value,
|
||||
);
|
||||
} else {
|
||||
// Constant value line (original behavior)
|
||||
lineData = timestamps.map(() => line.value);
|
||||
}
|
||||
|
||||
uData.push(lineData);
|
||||
lineIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,8 +155,10 @@ export function animateNewPoint(
|
||||
viewMode: string,
|
||||
selectedTimeWindow: number | "all",
|
||||
startTimeRef: React.RefObject<number | null>,
|
||||
config: { lines?: Array<{ show?: boolean; value: number }> },
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void, // Updated signature
|
||||
config: {
|
||||
lines?: Array<Partial<GraphLine> & { show?: boolean; value: number }>;
|
||||
},
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void,
|
||||
getAllSeriesData?: () => number[][],
|
||||
): void {
|
||||
if (targetData.timestamps.length <= currentData.timestamps.length) {
|
||||
@@ -123,6 +192,8 @@ export function animateNewPoint(
|
||||
targetIndex: newIndex,
|
||||
};
|
||||
|
||||
const freezeTargetTail = hasHistoricalDashedTargets(config.lines);
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (
|
||||
!uplotRef.current ||
|
||||
@@ -173,6 +244,8 @@ export function animateNewPoint(
|
||||
refs.realPointsCount,
|
||||
config,
|
||||
allSeriesData,
|
||||
refs.targetLineCache,
|
||||
freezeTargetTail,
|
||||
);
|
||||
|
||||
uplotRef.current.setData(animatedUData);
|
||||
|
||||
@@ -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,13 @@ import {
|
||||
import { BigGraphProps, CreateChartParams } from "./types";
|
||||
import { normalizeDataSeries } from "./animation";
|
||||
|
||||
type CustomDashedTargetLine = {
|
||||
dataIndex: number;
|
||||
dash: number[];
|
||||
color: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export function createChart({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
@@ -56,11 +67,28 @@ export function createChart({
|
||||
uPlotData.push(values as any);
|
||||
});
|
||||
|
||||
// Add config lines data
|
||||
// Add config lines data and populate target line cache
|
||||
animationRefs.targetLineCache.current.clear();
|
||||
let lineIndex = 0;
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
const lineData = new Array(timestamps.length).fill(line.value);
|
||||
let lineData: number[];
|
||||
if (line.targetSeries) {
|
||||
// Use historical target values aligned with data timestamps
|
||||
lineData = alignTargetSeriesToTimestamps(
|
||||
line.targetSeries,
|
||||
timestamps,
|
||||
line.value,
|
||||
);
|
||||
// Cache the computed target line data so subsequent updates
|
||||
// can extend it instead of recalculating (prevents wiggle)
|
||||
animationRefs.targetLineCache.current.set(lineIndex, [...lineData]);
|
||||
} else {
|
||||
// Use constant value (original behavior)
|
||||
lineData = new Array(timestamps.length).fill(line.value);
|
||||
}
|
||||
uPlotData.push(lineData as any);
|
||||
lineIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -142,6 +170,7 @@ export function createChart({
|
||||
|
||||
// Build series configuration for ALL series (but control visibility)
|
||||
const seriesConfig: uPlot.Series[] = [{ label: "Time" }];
|
||||
const customDashedTargetLines: CustomDashedTargetLine[] = [];
|
||||
|
||||
// Add ALL data series with visibility control
|
||||
const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"];
|
||||
@@ -167,16 +196,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 custom rendered in draw hook to avoid
|
||||
// canvas dash shimmer during smooth live updates.
|
||||
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 +354,83 @@ export function createChart({
|
||||
}
|
||||
},
|
||||
],
|
||||
draw: [
|
||||
(u) => {
|
||||
if (customDashedTargetLines.length === 0) return;
|
||||
const xData = u.data[0] as number[] | undefined;
|
||||
if (!xData || xData.length === 0) return;
|
||||
|
||||
const xMin = u.scales.x?.min;
|
||||
const xMax = u.scales.x?.max;
|
||||
if (xMin === undefined || xMax === undefined) return;
|
||||
|
||||
const pxPerX = u.bbox.width / Math.max(xMax - xMin, 1e-9);
|
||||
const ctx = u.ctx;
|
||||
const left = u.bbox.left;
|
||||
const top = u.bbox.top;
|
||||
const right = left + u.bbox.width;
|
||||
const bottom = top + u.bbox.height;
|
||||
|
||||
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 === 0) return;
|
||||
|
||||
const dashPeriod = dash.reduce((acc, curr) => acc + curr, 0);
|
||||
ctx.setLineDash(dash);
|
||||
// Anchor dash phase to world-X so dashes remain stable while axis slides.
|
||||
ctx.lineDashOffset =
|
||||
dashPeriod > 0 ? -((xMin * pxPerX) % dashPeriod) : 0;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.beginPath();
|
||||
|
||||
let started = false;
|
||||
let prevX = 0;
|
||||
let prevY = 0;
|
||||
|
||||
for (let i = 0; i < xData.length; i++) {
|
||||
const value = yData[i];
|
||||
if (value === null || value === undefined) continue;
|
||||
|
||||
const x = u.valToPos(xData[i], "x", true);
|
||||
const y = u.valToPos(value, "y", true);
|
||||
if (x < left || x > right || y < top || y > bottom) continue;
|
||||
|
||||
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: {
|
||||
|
||||
@@ -14,6 +14,17 @@ import {
|
||||
AnimationRefs,
|
||||
} from "./types";
|
||||
|
||||
function hasHistoricalDashedTargets(config: GraphConfig): boolean {
|
||||
return (
|
||||
config.lines?.some(
|
||||
(line) =>
|
||||
line.show !== false &&
|
||||
!!line.targetSeries &&
|
||||
(line.dash?.length ?? 0) > 0,
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
// Retrieve all series data as an array of number arrays
|
||||
function getAllSeriesData(data: BigGraphProps["newData"]): number[][] {
|
||||
const normalized = normalizeDataSeries(data);
|
||||
@@ -77,10 +88,16 @@ export function useLiveMode({
|
||||
return;
|
||||
|
||||
try {
|
||||
const hasDashedHistoricalTargets = hasHistoricalDashedTargets(config);
|
||||
const [timestamps, values] = seriesToUPlotData(primaryData.long);
|
||||
const cur = primaryData.current;
|
||||
const liveTimestamps = [...timestamps, cur.timestamp];
|
||||
const liveValues = [...values, cur.value];
|
||||
const lastHistoricalTimestamp =
|
||||
timestamps[timestamps.length - 1] ?? cur.timestamp;
|
||||
const shouldAppendCurrent = cur.timestamp > lastHistoricalTimestamp;
|
||||
const liveTimestamps = shouldAppendCurrent
|
||||
? [...timestamps, cur.timestamp]
|
||||
: timestamps;
|
||||
const liveValues = shouldAppendCurrent ? [...values, cur.value] : values;
|
||||
|
||||
const allSeriesValues = getAllSeriesData(newData);
|
||||
const normalized = normalizeDataSeries(newData);
|
||||
@@ -96,7 +113,9 @@ export function useLiveMode({
|
||||
(seriesValues.length > 0
|
||||
? seriesValues[seriesValues.length - 1]
|
||||
: 0);
|
||||
return [...seriesValues, currentValue];
|
||||
return shouldAppendCurrent
|
||||
? [...seriesValues, currentValue]
|
||||
: seriesValues;
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -107,6 +126,8 @@ export function useLiveMode({
|
||||
animationRefs.realPointsCount,
|
||||
config,
|
||||
additionalSeriesValues,
|
||||
animationRefs.targetLineCache,
|
||||
hasDashedHistoricalTargets,
|
||||
);
|
||||
|
||||
uplotRef.current.setData(liveData);
|
||||
@@ -283,6 +304,7 @@ export function useLiveMode({
|
||||
animationRefs.realPointsCount,
|
||||
config,
|
||||
additionalSeriesValues,
|
||||
animationRefs.targetLineCache,
|
||||
);
|
||||
|
||||
uplotRef.current.setData(uData);
|
||||
|
||||
@@ -26,7 +26,10 @@ export type PropGraphSync = {
|
||||
// Configuration types for additional lines
|
||||
export type GraphLine = {
|
||||
type: "threshold" | "target";
|
||||
/** Current value (used as fallback and for Y-axis scaling) */
|
||||
value: number;
|
||||
/** Optional TimeSeries for historical target values. When provided, the line shows the target history over time instead of a constant horizontal line */
|
||||
targetSeries?: TimeSeries;
|
||||
color: string;
|
||||
label?: string;
|
||||
width?: number;
|
||||
@@ -158,6 +161,7 @@ export interface AnimationRefs {
|
||||
values: number[];
|
||||
}>;
|
||||
realPointsCount: React.RefObject<number>;
|
||||
targetLineCache: React.RefObject<Map<number, number[]>>;
|
||||
}
|
||||
|
||||
export interface HandlerRefs {
|
||||
|
||||
@@ -4,7 +4,11 @@ import uPlot from "uplot";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { BigGraphProps, SeriesData, AnimationRefs, HandlerRefs } from "./types";
|
||||
import { GraphExportData } from "./excelExport";
|
||||
import { getPrimarySeries, stopAnimations } from "./animation";
|
||||
import {
|
||||
getPrimarySeries,
|
||||
stopAnimations,
|
||||
normalizeDataSeries,
|
||||
} from "./animation";
|
||||
import { createChart } from "./createChart";
|
||||
import { LiveModeHandlers } from "./liveMode";
|
||||
import { HistoricalModeHandlers } from "./historicalMode";
|
||||
@@ -106,6 +110,10 @@ export function useBigGraphEffects({
|
||||
handleTimeWindowChangeInternal,
|
||||
}: UseBigGraphEffectsProps) {
|
||||
const [isChartCreated, setIsChartCreated] = useState(false);
|
||||
const setChartCreatedState = (created: boolean) => {
|
||||
setIsChartCreated(created);
|
||||
chartCreatedRef.current = created;
|
||||
};
|
||||
|
||||
// Track the last synchronized state for comparison
|
||||
const lastSyncStateRef = useRef({
|
||||
@@ -239,18 +247,25 @@ export function useBigGraphEffects({
|
||||
updateYAxisScale,
|
||||
]);
|
||||
|
||||
// Create and initialize the chart when data becomes available
|
||||
// Compute stable structural keys for chart creation.
|
||||
// These only change when series count or visible line count changes,
|
||||
// NOT on every data update (which would destroy/recreate the chart every ~1s).
|
||||
const seriesWithDataCount = normalizeDataSeries(newData).filter(
|
||||
(s) => s.newData !== null,
|
||||
).length;
|
||||
const visibleLineCount =
|
||||
config.lines?.filter((l) => l.show !== false)?.length ?? 0;
|
||||
|
||||
// Create and initialize the chart when data becomes available or structure changes
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !primarySeries?.newData?.long) {
|
||||
setIsChartCreated(false);
|
||||
chartCreatedRef.current = false;
|
||||
setChartCreatedState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const [timestamps] = seriesToUPlotData(primarySeries.newData.long);
|
||||
if (timestamps.length === 0) {
|
||||
setIsChartCreated(false);
|
||||
chartCreatedRef.current = false;
|
||||
setChartCreatedState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -280,8 +295,7 @@ export function useBigGraphEffects({
|
||||
showFromTimestamp,
|
||||
});
|
||||
|
||||
setIsChartCreated(true);
|
||||
chartCreatedRef.current = true;
|
||||
setChartCreatedState(true);
|
||||
|
||||
if (isLiveMode) {
|
||||
lastProcessedCountRef.current = timestamps.length;
|
||||
@@ -292,10 +306,9 @@ export function useBigGraphEffects({
|
||||
uplotRef.current?.destroy();
|
||||
uplotRef.current = null;
|
||||
stopAnimations(animationRefs);
|
||||
setIsChartCreated(false);
|
||||
chartCreatedRef.current = false;
|
||||
setChartCreatedState(false);
|
||||
};
|
||||
}, [primarySeries?.newData?.long, containerRef.current]);
|
||||
}, [seriesWithDataCount, visibleLineCount, containerRef.current]);
|
||||
|
||||
// Process new historical data in live mode
|
||||
useEffect(() => {
|
||||
|
||||
@@ -304,3 +304,56 @@ 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).
|
||||
*
|
||||
* @param targetSeries - The TimeSeries containing historical target values
|
||||
* @param dataTimestamps - Array of timestamps to align the target values to
|
||||
* @param fallbackValue - Value to use when no target was set before a timestamp
|
||||
* @returns Array of values aligned with dataTimestamps
|
||||
*/
|
||||
export function alignTargetSeriesToTimestamps(
|
||||
targetSeries: TimeSeries,
|
||||
dataTimestamps: number[],
|
||||
fallbackValue: number,
|
||||
): number[] {
|
||||
if (dataTimestamps.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract target series data in chronological order
|
||||
const [targetTimestamps, targetValues] = extractDataFromSeries(
|
||||
targetSeries.long,
|
||||
);
|
||||
|
||||
if (targetTimestamps.length === 0) {
|
||||
// No target history, use fallback for all timestamps
|
||||
return new Array(dataTimestamps.length).fill(fallbackValue);
|
||||
}
|
||||
|
||||
const result: number[] = [];
|
||||
let targetIndex = 0;
|
||||
|
||||
for (const dataTs of dataTimestamps) {
|
||||
// Find the most recent target value at or before this timestamp
|
||||
// Advance targetIndex while the next target timestamp is still <= dataTs
|
||||
while (
|
||||
targetIndex < targetTimestamps.length - 1 &&
|
||||
targetTimestamps[targetIndex + 1] <= dataTs
|
||||
) {
|
||||
targetIndex++;
|
||||
}
|
||||
|
||||
// If the current target timestamp is > dataTs, we haven't reached the first target yet
|
||||
if (targetTimestamps[targetIndex] > dataTs) {
|
||||
result.push(fallbackValue);
|
||||
} else {
|
||||
result.push(targetValues[targetIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function Aquapath1Page() {
|
||||
link: "graph",
|
||||
title: "Graph",
|
||||
icon: "lu:ChartSpline",
|
||||
activeLink: "graphs",
|
||||
activeLink: "graph",
|
||||
},
|
||||
{
|
||||
link: "settings",
|
||||
|
||||
@@ -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,7 @@ export function extruder2MessageHandler(
|
||||
if (eventName === "StateEvent") {
|
||||
console.log(event);
|
||||
const stateEvent = stateEventSchema.parse(event);
|
||||
const timestamp = event.ts;
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
state: stateEvent,
|
||||
@@ -278,6 +309,43 @@ export function extruder2MessageHandler(
|
||||
defaultState: stateEvent.data.is_default_state
|
||||
? stateEvent
|
||||
: state.defaultState,
|
||||
// Update target value history
|
||||
targetPressure: addTargetPressure(state.targetPressure, {
|
||||
value: stateEvent.data.pressure_state.target_bar,
|
||||
timestamp,
|
||||
}),
|
||||
targetScrewRpm: addTargetScrewRpm(state.targetScrewRpm, {
|
||||
value: stateEvent.data.screw_state.target_rpm,
|
||||
timestamp,
|
||||
}),
|
||||
targetNozzleTemperature: addTargetNozzleTemperature(
|
||||
state.targetNozzleTemperature,
|
||||
{
|
||||
value: stateEvent.data.heating_states.nozzle.target_temperature,
|
||||
timestamp,
|
||||
},
|
||||
),
|
||||
targetFrontTemperature: addTargetFrontTemperature(
|
||||
state.targetFrontTemperature,
|
||||
{
|
||||
value: stateEvent.data.heating_states.front.target_temperature,
|
||||
timestamp,
|
||||
},
|
||||
),
|
||||
targetMiddleTemperature: addTargetMiddleTemperature(
|
||||
state.targetMiddleTemperature,
|
||||
{
|
||||
value: stateEvent.data.heating_states.middle.target_temperature,
|
||||
timestamp,
|
||||
},
|
||||
),
|
||||
targetBackTemperature: addTargetBackTemperature(
|
||||
state.targetBackTemperature,
|
||||
{
|
||||
value: stateEvent.data.heating_states.back.target_temperature,
|
||||
timestamp,
|
||||
},
|
||||
),
|
||||
}));
|
||||
} else if (eventName === "LiveValuesEvent") {
|
||||
const liveValuesEvent = liveValuesEventSchema.parse(event);
|
||||
@@ -378,6 +446,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,7 @@ export function extruder3MessageHandler(
|
||||
if (eventName === "StateEvent") {
|
||||
console.log(event);
|
||||
const stateEvent = stateEventSchema.parse(event);
|
||||
const timestamp = event.ts;
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
state: stateEvent,
|
||||
@@ -278,6 +309,43 @@ export function extruder3MessageHandler(
|
||||
defaultState: stateEvent.data.is_default_state
|
||||
? stateEvent
|
||||
: state.defaultState,
|
||||
// Update target value history
|
||||
targetPressure: addTargetPressure(state.targetPressure, {
|
||||
value: stateEvent.data.pressure_state.target_bar,
|
||||
timestamp,
|
||||
}),
|
||||
targetScrewRpm: addTargetScrewRpm(state.targetScrewRpm, {
|
||||
value: stateEvent.data.screw_state.target_rpm,
|
||||
timestamp,
|
||||
}),
|
||||
targetNozzleTemperature: addTargetNozzleTemperature(
|
||||
state.targetNozzleTemperature,
|
||||
{
|
||||
value: stateEvent.data.heating_states.nozzle.target_temperature,
|
||||
timestamp,
|
||||
},
|
||||
),
|
||||
targetFrontTemperature: addTargetFrontTemperature(
|
||||
state.targetFrontTemperature,
|
||||
{
|
||||
value: stateEvent.data.heating_states.front.target_temperature,
|
||||
timestamp,
|
||||
},
|
||||
),
|
||||
targetMiddleTemperature: addTargetMiddleTemperature(
|
||||
state.targetMiddleTemperature,
|
||||
{
|
||||
value: stateEvent.data.heating_states.middle.target_temperature,
|
||||
timestamp,
|
||||
},
|
||||
),
|
||||
targetBackTemperature: addTargetBackTemperature(
|
||||
state.targetBackTemperature,
|
||||
{
|
||||
value: stateEvent.data.heating_states.back.target_temperature,
|
||||
timestamp,
|
||||
},
|
||||
),
|
||||
}));
|
||||
} else if (eventName === "LiveValuesEvent") {
|
||||
const liveValuesEvent = liveValuesEventSchema.parse(event);
|
||||
@@ -378,6 +446,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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -117,6 +121,10 @@ export function laser1MessageHandler(
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
state: stateEvent,
|
||||
targetDiameter: addTargetDiameter(state.targetDiameter, {
|
||||
value: stateEvent.data.laser_state.target_diameter,
|
||||
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,
|
||||
|
||||
@@ -390,6 +390,11 @@ export const aquapath1: MachineProperties = {
|
||||
product_id: 0x7d83052,
|
||||
revision: 0x110000,
|
||||
},
|
||||
{
|
||||
vendor_id: 2,
|
||||
product_id: 0x7d83052,
|
||||
revision: 0x120000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -473,6 +478,30 @@ export const digitalInputTestMachine: MachineProperties = {
|
||||
},
|
||||
device_roles: [],
|
||||
};
|
||||
|
||||
export const wago8chDioTestMachine: MachineProperties = {
|
||||
name: "WAGO 8ch DIO Test",
|
||||
version: "V1",
|
||||
slug: "wago8chdiotestmachine",
|
||||
icon: "lu:Disc3",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x0041,
|
||||
},
|
||||
device_roles: [
|
||||
{
|
||||
role: 0,
|
||||
role_label: "Wago 750-354 Bus Coupler",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 0x21,
|
||||
product_id: 0x07500354,
|
||||
revision: 0x2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
export const wagoPower1: MachineProperties = {
|
||||
name: "WAGO Power",
|
||||
version: "V1",
|
||||
@@ -579,6 +608,29 @@ export const TestMotor: MachineProperties = {
|
||||
],
|
||||
};
|
||||
|
||||
export const wagoDoTestMachine: MachineProperties = {
|
||||
name: "Wago DO Test",
|
||||
version: "V1",
|
||||
slug: "wagodotestmachine",
|
||||
icon: "lu:ToggleRight",
|
||||
machine_identification: {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: 0x000e,
|
||||
},
|
||||
device_roles: [
|
||||
{
|
||||
role: 0,
|
||||
role_label: "Wago 750-354 Bus Coupler",
|
||||
allowed_devices: [
|
||||
{
|
||||
vendor_id: 0x21,
|
||||
product_id: 0x07500354,
|
||||
revision: 0x2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
export const wagoAiTestMachine: MachineProperties = {
|
||||
name: "Wago AI Test",
|
||||
version: "V1",
|
||||
@@ -616,7 +668,9 @@ export const machineProperties: MachineProperties[] = [
|
||||
analogInputTestMachine,
|
||||
wagoAiTestMachine,
|
||||
digitalInputTestMachine,
|
||||
wago8chDioTestMachine,
|
||||
ip20TestMachine,
|
||||
wagoDoTestMachine,
|
||||
TestMotor,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { wago8chDioTestMachineRoute } from "@/routes/routes";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import { useMachineMutate } from "@/client/useClient";
|
||||
import { produce } from "immer";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { wago8chDioTestMachine } from "@/machines/properties";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
StateEvent,
|
||||
useWago8chDioTestMachineNamespace,
|
||||
} from "./wago8chDioTestMachineNameSpace";
|
||||
export function useWago8chDioTestMachine() {
|
||||
const { serial: serialString } = wago8chDioTestMachineRoute.useParams();
|
||||
|
||||
// Memoize machine identification
|
||||
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
|
||||
const serial = parseInt(serialString);
|
||||
|
||||
if (isNaN(serial)) {
|
||||
toastError(
|
||||
"Invalid Serial Number",
|
||||
`"${serialString}" is not a valid serial number.`,
|
||||
);
|
||||
|
||||
return {
|
||||
machine_identification: { vendor: 0, machine: 0 },
|
||||
serial: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
machine_identification: wago8chDioTestMachine.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]);
|
||||
|
||||
// Namespace state from backend
|
||||
const { state } = useWago8chDioTestMachineNamespace(machineIdentification);
|
||||
|
||||
// Optimistic state
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
|
||||
useEffect(() => {
|
||||
if (state) stateOptimistic.setReal(state);
|
||||
}, [state, stateOptimistic]);
|
||||
|
||||
// Generic mutation sender
|
||||
const { request: sendMutation } = useMachineMutate(
|
||||
z.object({
|
||||
action: z.string(),
|
||||
value: z.any(),
|
||||
}),
|
||||
);
|
||||
|
||||
const updateStateOptimistically = (
|
||||
producer: (current: StateEvent) => void,
|
||||
serverRequest?: () => void,
|
||||
) => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState)
|
||||
stateOptimistic.setOptimistic(produce(currentState, producer));
|
||||
serverRequest?.();
|
||||
};
|
||||
|
||||
const setOutputAtIndex = (index: number, value: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.digital_output[index] = value;
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetOutput", value: { index, value } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
state: stateOptimistic.value,
|
||||
setOutputAtIndex: setOutputAtIndex,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { Page } from "@/components/Page";
|
||||
import React from "react";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { SelectionGroup } from "@/control/SelectionGroup";
|
||||
import { Label } from "@/control/Label";
|
||||
import { useWago8chDioTestMachine } from "./useWago8chDioTestMachine";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export function Wago8chDioTestMachineControlRoute() {
|
||||
const { state, setOutputAtIndex } = useWago8chDioTestMachine();
|
||||
|
||||
const safeState = state ?? {
|
||||
digital_input: [false, false, false, false, false, false, false, false],
|
||||
digital_output: [false, false, false, false, false, false, false, false],
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
{/* Digital Inputs */}
|
||||
<ControlCard title="Digital Inputs">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{safeState.digital_input.map((input, index) => (
|
||||
<Label key={index} label={`Input ${index + 1}`}>
|
||||
<Badge variant={input ? "outline" : "destructive"}>
|
||||
{input ? "ON" : "OFF"}
|
||||
</Badge>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</ControlCard>
|
||||
|
||||
{/* Digital Outputs */}
|
||||
<ControlCard title="Digital Outputs">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{safeState.digital_output.map((output, index) => (
|
||||
<Label key={index} label={`Output ${index + 1}`}>
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={output ? "On" : "Off"}
|
||||
orientation="vertical"
|
||||
className="grid h-full grid-cols-2 gap-2"
|
||||
options={{
|
||||
Off: {
|
||||
children: "Off",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-red-600",
|
||||
className: "h-full",
|
||||
},
|
||||
On: {
|
||||
children: "On",
|
||||
icon: "lu:CirclePlay",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "h-full",
|
||||
},
|
||||
}}
|
||||
onChange={(value) => setOutputAtIndex(index, value === "On")}
|
||||
/>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { StoreApi } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
EventHandler,
|
||||
eventSchema,
|
||||
Event,
|
||||
handleUnhandledEventError,
|
||||
NamespaceId,
|
||||
createNamespaceHookImplementation,
|
||||
ThrottledStoreUpdater,
|
||||
} from "@/client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
|
||||
// ========== Event Schema ==========
|
||||
|
||||
export const stateEventDataSchema = z.object({
|
||||
digital_input: z.array(z.boolean()).length(8),
|
||||
digital_output: z.array(z.boolean()).length(8),
|
||||
});
|
||||
|
||||
export const stateEventSchema = eventSchema(stateEventDataSchema);
|
||||
|
||||
export type StateEvent = z.infer<typeof stateEventDataSchema>;
|
||||
|
||||
// ========== Store ==========
|
||||
export type Wago8chDioTestMachineNamespaceStore = {
|
||||
state: StateEvent | null;
|
||||
};
|
||||
|
||||
export const createWago8chDioTestMachineNamespaceStore =
|
||||
(): StoreApi<Wago8chDioTestMachineNamespaceStore> =>
|
||||
create<Wago8chDioTestMachineNamespaceStore>(() => ({
|
||||
state: null,
|
||||
}));
|
||||
|
||||
// ========== Message Handler ==========
|
||||
export function wago8chDioTestMachineMessageHndler(
|
||||
store: StoreApi<Wago8chDioTestMachineNamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<Wago8chDioTestMachineNamespaceStore>,
|
||||
): EventHandler {
|
||||
return (event: Event<any>) => {
|
||||
const updateStore = (
|
||||
updater: (
|
||||
state: Wago8chDioTestMachineNamespaceStore,
|
||||
) => Wago8chDioTestMachineNamespaceStore,
|
||||
) => throttledUpdater.updateWith(updater);
|
||||
|
||||
try {
|
||||
if (event.name === "StateEvent") {
|
||||
const parsed = stateEventSchema.parse(event);
|
||||
updateStore(() => ({ state: parsed.data }));
|
||||
} else {
|
||||
handleUnhandledEventError(event.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${event.name}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Namespace Hook ==========
|
||||
const useWago8chDioTestMachineImplementation =
|
||||
createNamespaceHookImplementation<Wago8chDioTestMachineNamespaceStore>({
|
||||
createStore: createWago8chDioTestMachineNamespaceStore,
|
||||
createEventHandler: wago8chDioTestMachineMessageHndler,
|
||||
});
|
||||
|
||||
export function useWago8chDioTestMachineNamespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): Wago8chDioTestMachineNamespaceStore {
|
||||
const namespaceId: NamespaceId = {
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
};
|
||||
|
||||
return useWago8chDioTestMachineImplementation(namespaceId);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Topbar } from "@/components/Topbar";
|
||||
import { wago8chDioTestMachineRoute } from "@/routes/routes";
|
||||
import React from "react";
|
||||
|
||||
export function Wago8chDioTestMachinePage() {
|
||||
const { serial } = wago8chDioTestMachineRoute.useParams();
|
||||
return (
|
||||
<Topbar
|
||||
pathname={`/_sidebar/machines/wago8chdiotestmachine/${serial}`}
|
||||
items={[
|
||||
{
|
||||
link: "control",
|
||||
activeLink: "control",
|
||||
title: "Control",
|
||||
icon: "lu:CirclePlay",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { Page } from "@/components/Page";
|
||||
import React from "react";
|
||||
import { ControlGrid } from "@/control/ControlGrid";
|
||||
import { SelectionGroup } from "@/control/SelectionGroup";
|
||||
import { Label } from "@/control/Label";
|
||||
import { useWagoDoTestMachine } from "./useWagoDoTestMachine";
|
||||
|
||||
export function WagoDoTestMachineControlPage() {
|
||||
const { state, setLed, setAllLeds } = useWagoDoTestMachine();
|
||||
|
||||
const safeState = state ?? {
|
||||
led_on: Array(8).fill(false),
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<ControlGrid columns={2}>
|
||||
{/* Digital Output Controls */}
|
||||
<ControlCard title="Digital Outputs (Do1-Do8)">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{safeState.led_on.map((output, index) => (
|
||||
<Label key={index} label={`Do${index + 1}`}>
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={output ? "On" : "Off"}
|
||||
orientation="vertical"
|
||||
className="flex flex-col gap-3"
|
||||
options={{
|
||||
Off: {
|
||||
children: "Off",
|
||||
icon: "lu:CirclePause",
|
||||
isActiveClassName: "bg-red-600",
|
||||
className: "flex-1",
|
||||
},
|
||||
On: {
|
||||
children: "On",
|
||||
icon: "lu:CirclePlay",
|
||||
isActiveClassName: "bg-green-600",
|
||||
className: "flex-1",
|
||||
},
|
||||
}}
|
||||
onChange={(value) => setLed(index, value === "On")}
|
||||
/>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</ControlCard>
|
||||
|
||||
{/* Master Output Control */}
|
||||
<ControlCard title="Master Output Control">
|
||||
<SelectionGroup<"On" | "Off">
|
||||
value={safeState.led_on.every(Boolean) ? "On" : "Off"}
|
||||
orientation="horizontal"
|
||||
options={{
|
||||
Off: { children: "Turn All Off" },
|
||||
On: { children: "Turn All On" },
|
||||
}}
|
||||
onChange={(value) => setAllLeds(value === "On")}
|
||||
/>
|
||||
</ControlCard>
|
||||
</ControlGrid>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { StoreApi } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
EventHandler,
|
||||
eventSchema,
|
||||
Event,
|
||||
handleUnhandledEventError,
|
||||
NamespaceId,
|
||||
createNamespaceHookImplementation,
|
||||
ThrottledStoreUpdater,
|
||||
} from "@/client/socketioStore";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
|
||||
// ========== Event Schema ==========
|
||||
|
||||
export const stateEventDataSchema = z.object({
|
||||
led_on: z.array(z.boolean()).length(8),
|
||||
});
|
||||
|
||||
export const stateEventSchema = eventSchema(stateEventDataSchema);
|
||||
|
||||
export type StateEvent = z.infer<typeof stateEventDataSchema>;
|
||||
|
||||
// ========== Store ==========
|
||||
export type WagoDoTestMachineNamespaceStore = {
|
||||
state: StateEvent | null;
|
||||
};
|
||||
|
||||
export const createWagoDoTestMachineNamespaceStore =
|
||||
(): StoreApi<WagoDoTestMachineNamespaceStore> =>
|
||||
create<WagoDoTestMachineNamespaceStore>(() => ({
|
||||
state: null,
|
||||
}));
|
||||
|
||||
// ========== Message Handler ==========
|
||||
export function wagoDoTestMachineMessageHandler(
|
||||
store: StoreApi<WagoDoTestMachineNamespaceStore>,
|
||||
throttledUpdater: ThrottledStoreUpdater<WagoDoTestMachineNamespaceStore>,
|
||||
): EventHandler {
|
||||
return (event: Event<any>) => {
|
||||
const updateStore = (
|
||||
updater: (
|
||||
state: WagoDoTestMachineNamespaceStore,
|
||||
) => WagoDoTestMachineNamespaceStore,
|
||||
) => throttledUpdater.updateWith(updater);
|
||||
|
||||
try {
|
||||
if (event.name === "StateEvent") {
|
||||
const parsed = stateEventSchema.parse(event);
|
||||
updateStore(() => ({ state: parsed.data }));
|
||||
} else {
|
||||
handleUnhandledEventError(event.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${event.name}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Namespace Hook ==========
|
||||
const useWagoDoTestMachineNamespaceImplementation =
|
||||
createNamespaceHookImplementation<WagoDoTestMachineNamespaceStore>({
|
||||
createStore: createWagoDoTestMachineNamespaceStore,
|
||||
createEventHandler: wagoDoTestMachineMessageHandler,
|
||||
});
|
||||
|
||||
export function useWagoDoTestMachineNamespace(
|
||||
machine_identification_unique: MachineIdentificationUnique,
|
||||
): WagoDoTestMachineNamespaceStore {
|
||||
const namespaceId: NamespaceId = {
|
||||
type: "machine",
|
||||
machine_identification_unique,
|
||||
};
|
||||
|
||||
return useWagoDoTestMachineNamespaceImplementation(namespaceId);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Topbar } from "@/components/Topbar";
|
||||
import { wagoDoTestMachineSerialRoute } from "@/routes/routes";
|
||||
import React from "react";
|
||||
|
||||
export function WagoDoTestMachinePage() {
|
||||
const { serial } = wagoDoTestMachineSerialRoute.useParams();
|
||||
return (
|
||||
<Topbar
|
||||
pathname={`/_sidebar/machines/wagodotestmachine/${serial}`}
|
||||
items={[
|
||||
{
|
||||
link: "control",
|
||||
activeLink: "control",
|
||||
title: "Control",
|
||||
icon: "lu:CirclePlay",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { toastError } from "@/components/Toast";
|
||||
import { useStateOptimistic } from "@/lib/useStateOptimistic";
|
||||
import { wagoDoTestMachineSerialRoute } from "@/routes/routes";
|
||||
import { MachineIdentificationUnique } from "@/machines/types";
|
||||
import {
|
||||
useWagoDoTestMachineNamespace,
|
||||
StateEvent,
|
||||
} from "./WagoDoTestMachineNamespace";
|
||||
import { useMachineMutate } from "@/client/useClient";
|
||||
import { produce } from "immer";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { wagoDoTestMachine } from "@/machines/properties";
|
||||
import { z } from "zod";
|
||||
|
||||
export function useWagoDoTestMachine() {
|
||||
const { serial: serialString } = wagoDoTestMachineSerialRoute.useParams();
|
||||
|
||||
// Memoize machine identification
|
||||
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
|
||||
const serial = parseInt(serialString);
|
||||
|
||||
if (isNaN(serial)) {
|
||||
toastError(
|
||||
"Invalid Serial Number",
|
||||
`"${serialString}" is not a valid serial number.`,
|
||||
);
|
||||
|
||||
return {
|
||||
machine_identification: { vendor: 0, machine: 0 },
|
||||
serial: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
machine_identification: wagoDoTestMachine.machine_identification,
|
||||
serial,
|
||||
};
|
||||
}, [serialString]);
|
||||
|
||||
// Namespace state from backend
|
||||
const { state } = useWagoDoTestMachineNamespace(machineIdentification);
|
||||
|
||||
// Optimistic state
|
||||
const stateOptimistic = useStateOptimistic<StateEvent>();
|
||||
|
||||
useEffect(() => {
|
||||
if (state) stateOptimistic.setReal(state);
|
||||
}, [state, stateOptimistic]);
|
||||
|
||||
// Generic mutation sender
|
||||
const { request: sendMutation } = useMachineMutate(
|
||||
z.object({
|
||||
action: z.string(),
|
||||
value: z.any(),
|
||||
}),
|
||||
);
|
||||
|
||||
const updateStateOptimistically = (
|
||||
producer: (current: StateEvent) => void,
|
||||
serverRequest?: () => void,
|
||||
) => {
|
||||
const currentState = stateOptimistic.value;
|
||||
if (currentState)
|
||||
stateOptimistic.setOptimistic(produce(currentState, producer));
|
||||
serverRequest?.();
|
||||
};
|
||||
|
||||
const setLed = (index: number, on: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.led_on[index] = on;
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetLed", value: { index, on } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setAllLeds = (on: boolean) => {
|
||||
updateStateOptimistically(
|
||||
(current) => {
|
||||
current.led_on = Array(8).fill(on);
|
||||
},
|
||||
() =>
|
||||
sendMutation({
|
||||
machine_identification_unique: machineIdentification,
|
||||
data: { action: "SetAllLeds", value: { on } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
state: stateOptimistic.value,
|
||||
setLed,
|
||||
setAllLeds,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function Winder2GraphsPage() {
|
||||
traversePosition,
|
||||
tensionArmAngle,
|
||||
pullerSpeed,
|
||||
targetPullerSpeed,
|
||||
spoolProgress,
|
||||
} = useWinder2();
|
||||
|
||||
@@ -31,6 +32,7 @@ export function Winder2GraphsPage() {
|
||||
<PullerSpeedGraph
|
||||
syncHook={syncHook}
|
||||
newData={pullerSpeed}
|
||||
targetSeries={targetPullerSpeed}
|
||||
targetSpeed={state?.puller_state?.target_speed}
|
||||
unit="m/min"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
@@ -243,12 +245,14 @@ export function SpoolProgressGraph({
|
||||
export function PullerSpeedGraph({
|
||||
syncHook,
|
||||
newData,
|
||||
targetSeries,
|
||||
targetSpeed,
|
||||
unit,
|
||||
renderValue,
|
||||
}: {
|
||||
syncHook: ReturnType<typeof useGraphSync>;
|
||||
newData: TimeSeries | null;
|
||||
targetSeries?: TimeSeries;
|
||||
targetSpeed?: number;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
@@ -259,6 +263,7 @@ export function PullerSpeedGraph({
|
||||
lines.push({
|
||||
type: "target",
|
||||
value: targetSpeed,
|
||||
targetSeries,
|
||||
label: "Target Speed",
|
||||
color: "#06b6d4", // Match series color
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ export function useWinder2() {
|
||||
defaultState,
|
||||
traversePosition,
|
||||
pullerSpeed,
|
||||
targetPullerSpeed,
|
||||
spoolRpm,
|
||||
tensionArmAngle,
|
||||
spoolProgress,
|
||||
@@ -638,6 +639,7 @@ export function useWinder2() {
|
||||
// Individual live values (TimeSeries)
|
||||
traversePosition,
|
||||
pullerSpeed,
|
||||
targetPullerSpeed,
|
||||
spoolRpm,
|
||||
tensionArmAngle,
|
||||
spoolProgress,
|
||||
|
||||
@@ -211,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,
|
||||
@@ -277,6 +281,10 @@ export function winder2MessageHandler(
|
||||
updateStore((state) => ({
|
||||
...state,
|
||||
state: stateEvent,
|
||||
targetPullerSpeed: addTargetPullerSpeed(state.targetPullerSpeed, {
|
||||
value: stateEvent.data.puller_state.target_speed,
|
||||
timestamp: event.ts,
|
||||
}),
|
||||
// only set default state if is_default_state is true
|
||||
defaultState: stateEvent.data.is_default_state
|
||||
? stateEvent
|
||||
|
||||
@@ -75,6 +75,10 @@ import { MetricsControlPage } from "@/metrics/MetricsControlPage";
|
||||
|
||||
import { WagoPower1Page } from "@/machines/wago_power/wago_power1/WagoPower1Page";
|
||||
import { WagoPower1ControlPage } from "@/machines/wago_power/wago_power1/WagoPower1ControlPage";
|
||||
import { WagoDoTestMachinePage } from "@/machines/wagodotestmachine/WagoDoTestMachinePage";
|
||||
import { WagoDoTestMachineControlPage } from "@/machines/wagodotestmachine/WagoDoTestMachineControlPage";
|
||||
import { Wago8chDioTestMachinePage } from "@/machines/wago8chdiotestmachine/wago8chDioTestMachinePage";
|
||||
import { Wago8chDioTestMachineControlRoute } from "@/machines/wago8chdiotestmachine/wago8chDioTestMachineControlPage";
|
||||
|
||||
// make a route tree like this
|
||||
// _mainNavigation/machines/winder2/$serial/control
|
||||
@@ -89,6 +93,18 @@ export const testMachineSerialRoute = createRoute({
|
||||
component: () => <TestMachinePage />,
|
||||
});
|
||||
|
||||
export const wago8chDioTestMachineRoute = createRoute({
|
||||
getParentRoute: () => machinesRoute,
|
||||
path: "wago8chdiotestmachine/$serial",
|
||||
component: () => <Wago8chDioTestMachinePage />,
|
||||
});
|
||||
|
||||
export const wago8chDioTestMachineControlRoute = createRoute({
|
||||
getParentRoute: () => wago8chDioTestMachineRoute,
|
||||
path: "control",
|
||||
component: () => <Wago8chDioTestMachineControlRoute />,
|
||||
});
|
||||
|
||||
// Leaf route: control page
|
||||
export const testMachineControlRoute = createRoute({
|
||||
getParentRoute: () => testMachineSerialRoute,
|
||||
@@ -383,6 +399,18 @@ export const wagoPower1ControlRoute = createRoute({
|
||||
component: () => <WagoPower1ControlPage />,
|
||||
});
|
||||
|
||||
export const wagoDoTestMachineSerialRoute = createRoute({
|
||||
getParentRoute: () => machinesRoute,
|
||||
path: "wagodotestmachine/$serial",
|
||||
component: () => <WagoDoTestMachinePage />,
|
||||
});
|
||||
|
||||
export const wagoDoTestMachineControlRoute = createRoute({
|
||||
getParentRoute: () => wagoDoTestMachineSerialRoute,
|
||||
path: "control",
|
||||
component: () => <WagoDoTestMachineControlPage />,
|
||||
});
|
||||
|
||||
export const setupRoute = createRoute({
|
||||
getParentRoute: () => sidebarRoute,
|
||||
path: "setup",
|
||||
@@ -491,6 +519,10 @@ export const rootTree = RootRoute.addChildren([
|
||||
analogInputTestMachineControlRoute,
|
||||
]),
|
||||
|
||||
wago8chDioTestMachineRoute.addChildren([
|
||||
wago8chDioTestMachineControlRoute,
|
||||
]),
|
||||
|
||||
wagoAiTestMachineSerialRoute.addChildren([wagoAiTestMachineControlRoute]),
|
||||
|
||||
digitalInputTestMachineSerialRoute.addChildren([
|
||||
@@ -541,6 +573,8 @@ export const rootTree = RootRoute.addChildren([
|
||||
wagoPower1SerialRoute.addChildren([wagoPower1ControlRoute]),
|
||||
|
||||
buffer1SerialRoute.addChildren([buffer1ControlRoute]),
|
||||
|
||||
wagoDoTestMachineSerialRoute.addChildren([wagoDoTestMachineControlRoute]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
@@ -38,27 +38,27 @@ impl From<Wago750_1506OutputPort> for usize {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Wago750_1506InputPort {
|
||||
DO1,
|
||||
DO2,
|
||||
DO3,
|
||||
DO4,
|
||||
DO5,
|
||||
DO6,
|
||||
DO7,
|
||||
DO8,
|
||||
DI1,
|
||||
DI2,
|
||||
DI3,
|
||||
DI4,
|
||||
DI5,
|
||||
DI6,
|
||||
DI7,
|
||||
DI8,
|
||||
}
|
||||
|
||||
impl From<Wago750_1506InputPort> for usize {
|
||||
fn from(value: Wago750_1506InputPort) -> Self {
|
||||
match value {
|
||||
Wago750_1506InputPort::DO1 => 0,
|
||||
Wago750_1506InputPort::DO2 => 1,
|
||||
Wago750_1506InputPort::DO3 => 2,
|
||||
Wago750_1506InputPort::DO4 => 3,
|
||||
Wago750_1506InputPort::DO5 => 4,
|
||||
Wago750_1506InputPort::DO6 => 5,
|
||||
Wago750_1506InputPort::DO7 => 6,
|
||||
Wago750_1506InputPort::DO8 => 7,
|
||||
Wago750_1506InputPort::DI1 => 0,
|
||||
Wago750_1506InputPort::DI2 => 1,
|
||||
Wago750_1506InputPort::DI3 => 2,
|
||||
Wago750_1506InputPort::DI4 => 3,
|
||||
Wago750_1506InputPort::DI5 => 4,
|
||||
Wago750_1506InputPort::DI6 => 5,
|
||||
Wago750_1506InputPort::DI7 => 6,
|
||||
Wago750_1506InputPort::DI8 => 7,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,14 +91,14 @@ impl DigitalInputDevice<Wago750_1506InputPort> for Wago750_1506 {
|
||||
fn get_input(&self, port: Wago750_1506InputPort) -> Result<DigitalInputInput, anyhow::Error> {
|
||||
Ok(DigitalInputInput {
|
||||
value: match port {
|
||||
Wago750_1506InputPort::DO1 => self.tx_pdo.port1,
|
||||
Wago750_1506InputPort::DO2 => self.tx_pdo.port2,
|
||||
Wago750_1506InputPort::DO3 => self.tx_pdo.port3,
|
||||
Wago750_1506InputPort::DO4 => self.tx_pdo.port4,
|
||||
Wago750_1506InputPort::DO5 => self.tx_pdo.port5,
|
||||
Wago750_1506InputPort::DO6 => self.tx_pdo.port6,
|
||||
Wago750_1506InputPort::DO7 => self.tx_pdo.port7,
|
||||
Wago750_1506InputPort::DO8 => self.tx_pdo.port8,
|
||||
Wago750_1506InputPort::DI1 => self.tx_pdo.port1,
|
||||
Wago750_1506InputPort::DI2 => self.tx_pdo.port2,
|
||||
Wago750_1506InputPort::DI3 => self.tx_pdo.port3,
|
||||
Wago750_1506InputPort::DI4 => self.tx_pdo.port4,
|
||||
Wago750_1506InputPort::DI5 => self.tx_pdo.port5,
|
||||
Wago750_1506InputPort::DI6 => self.tx_pdo.port6,
|
||||
Wago750_1506InputPort::DI7 => self.tx_pdo.port7,
|
||||
Wago750_1506InputPort::DI8 => self.tx_pdo.port8,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -189,28 +189,28 @@ impl EthercatDevice for Wago750_1506 {
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let base = self.tx_bit_offset;
|
||||
|
||||
let idx1 = base + Into::<usize>::into(Wago750_1506InputPort::DO1);
|
||||
let idx1 = base + Into::<usize>::into(Wago750_1506InputPort::DI1);
|
||||
self.tx_pdo.port1 = *input.get(idx1).expect("Bit 1 out of bounds");
|
||||
|
||||
let idx2 = base + Into::<usize>::into(Wago750_1506InputPort::DO2);
|
||||
let idx2 = base + Into::<usize>::into(Wago750_1506InputPort::DI2);
|
||||
self.tx_pdo.port2 = *input.get(idx2).expect("Bit 2 out of bounds");
|
||||
|
||||
let idx3 = base + Into::<usize>::into(Wago750_1506InputPort::DO3);
|
||||
let idx3 = base + Into::<usize>::into(Wago750_1506InputPort::DI3);
|
||||
self.tx_pdo.port3 = *input.get(idx3).expect("Bit 3 out of bounds");
|
||||
|
||||
let idx4 = base + Into::<usize>::into(Wago750_1506InputPort::DO4);
|
||||
let idx4 = base + Into::<usize>::into(Wago750_1506InputPort::DI4);
|
||||
self.tx_pdo.port4 = *input.get(idx4).expect("Bit 4 out of bounds");
|
||||
|
||||
let idx5 = base + Into::<usize>::into(Wago750_1506InputPort::DO5);
|
||||
let idx5 = base + Into::<usize>::into(Wago750_1506InputPort::DI5);
|
||||
self.tx_pdo.port5 = *input.get(idx5).expect("Bit 5 out of bounds");
|
||||
|
||||
let idx6 = base + Into::<usize>::into(Wago750_1506InputPort::DO6);
|
||||
let idx6 = base + Into::<usize>::into(Wago750_1506InputPort::DI6);
|
||||
self.tx_pdo.port6 = *input.get(idx6).expect("Bit 6 out of bounds");
|
||||
|
||||
let idx7 = base + Into::<usize>::into(Wago750_1506InputPort::DO7);
|
||||
let idx7 = base + Into::<usize>::into(Wago750_1506InputPort::DI7);
|
||||
self.tx_pdo.port7 = *input.get(idx7).expect("Bit 7 out of bounds");
|
||||
|
||||
let idx8 = base + Into::<usize>::into(Wago750_1506InputPort::DO8);
|
||||
let idx8 = base + Into::<usize>::into(Wago750_1506InputPort::DI8);
|
||||
self.tx_pdo.port8 = *input.get(idx8).expect("Bit 8 out of bounds");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,3 +2,74 @@ pub mod counter_wrapper_u16_i128;
|
||||
pub mod el70xx_velocity_converter;
|
||||
pub mod ethercrab_types;
|
||||
pub mod signing_converter_u16;
|
||||
|
||||
use ethercrab::{SubDevice, SubDeviceRef};
|
||||
// This is the Codeword for disabling EEPROM writes on sdo writes
|
||||
const BECKHOFF_EEPROM_LOCK_CODEWORD: u32 = 0x12345678;
|
||||
// In this index Codewords can be written for Beckhoff Terminals
|
||||
const BECKHOFF_CODEWORD_INDEX: u16 = 0xF008;
|
||||
|
||||
// This code locks the eeprom and protects it from writes
|
||||
// When the toggle is in effect no writes propagate to the eeprom
|
||||
// This is useful if you have a usecase where you very often write into CoE registers, as writes to EEPROM after a long time degrage the EEPROM
|
||||
pub async fn set_mut_beckhoff_eeprom_lock_active<'a>(
|
||||
subdevice: &SubDeviceRef<'a, &mut SubDevice>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let code_word = match subdevice.sdo_read::<u32>(BECKHOFF_CODEWORD_INDEX, 0).await {
|
||||
Ok(code_word) => code_word,
|
||||
// This happens when the subdevice has no mailbox
|
||||
// There is NO check in ethercrab for Mailbox presence, so we just have to pray that it has one and send a request
|
||||
// If there is no Mailbox to write Coe Request to, then there is also no EEPROM meaning we achieved our goal in a sense
|
||||
// This is why it returns OK on an error for sdo_read
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let eeprom_lock_toggled = match code_word {
|
||||
BECKHOFF_EEPROM_LOCK_CODEWORD => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !eeprom_lock_toggled {
|
||||
subdevice
|
||||
.sdo_write(BECKHOFF_CODEWORD_INDEX, 0, BECKHOFF_EEPROM_LOCK_CODEWORD)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
"Activated EEPROM Lock for {} {}",
|
||||
subdevice.name(),
|
||||
subdevice.name()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_beckhoff_eeprom_lock_active<'a>(
|
||||
subdevice: &SubDeviceRef<'a, &SubDevice>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let code_word = match subdevice.sdo_read::<u32>(BECKHOFF_CODEWORD_INDEX, 0).await {
|
||||
Ok(code_word) => code_word,
|
||||
// This happens when the subdevice has no mailbox
|
||||
// There is NO check in ethercrab for Mailbox presence, so we just have to pray that it has one and send a request
|
||||
// If there is no Mailbox to write Coe Request to, then there is also no EEPROM meaning we achieved our goal in a sense
|
||||
// This is why it returns OK on an error for sdo_read
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let eeprom_lock_toggled = match code_word {
|
||||
BECKHOFF_EEPROM_LOCK_CODEWORD => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !eeprom_lock_toggled {
|
||||
subdevice
|
||||
.sdo_write(BECKHOFF_CODEWORD_INDEX, 0, BECKHOFF_EEPROM_LOCK_CODEWORD)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
"Activated EEPROM Lock for {} {}",
|
||||
subdevice.name(),
|
||||
subdevice.name()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -32,7 +32,9 @@ pub mod motor_test_machine;
|
||||
pub mod registry;
|
||||
pub mod serial;
|
||||
pub mod test_machine;
|
||||
pub mod wago_8ch_dio_test_machine;
|
||||
pub mod wago_ai_test_machine;
|
||||
pub mod wago_do_test_machine;
|
||||
pub mod wago_power;
|
||||
pub mod winder2;
|
||||
|
||||
@@ -50,9 +52,10 @@ pub const IP20_TEST_MACHINE: u16 = 0x0034;
|
||||
pub const ANALOG_INPUT_TEST_MACHINE: u16 = 0x0035;
|
||||
pub const WAGO_AI_TEST_MACHINE: u16 = 0x0036;
|
||||
pub const DIGITAL_INPUT_TEST_MACHINE: u16 = 0x0040;
|
||||
pub const WAGO_8CH_IO_TEST_MACHINE: u16 = 0x0041;
|
||||
|
||||
pub const MOTOR_TEST_MACHINE: u16 = 0x0011;
|
||||
|
||||
pub const WAGO_DO_TEST_MACHINE: u16 = 0x000E;
|
||||
use serde_json::Value;
|
||||
use smol::lock::RwLock;
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ impl MachineIdentification {
|
||||
x if x == IP20_TEST_MACHINE => "ip20_test_machine".to_string(),
|
||||
x if x == ANALOG_INPUT_TEST_MACHINE => "analog_input_test_machine".to_string(),
|
||||
x if x == WAGO_AI_TEST_MACHINE => "wago_ai_test_machine".to_string(),
|
||||
x if x == WAGO_DO_TEST_MACHINE => "wago_do_test_machine".to_string(),
|
||||
|
||||
_ => unreachable!("Unknown machine id"),
|
||||
}
|
||||
}
|
||||
@@ -187,6 +189,7 @@ use crate::MACHINE_WINDER_V1;
|
||||
use crate::TEST_MACHINE;
|
||||
use crate::VENDOR_QITECH;
|
||||
use crate::WAGO_AI_TEST_MACHINE;
|
||||
use crate::WAGO_DO_TEST_MACHINE;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MachineIdentificationAddresses {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::analog_input_test_machine::AnalogInputTestMachine;
|
||||
use crate::digital_input_test_machine::DigitalInputTestMachine;
|
||||
use crate::ip20_test_machine::IP20TestMachine;
|
||||
use crate::wago_8ch_dio_test_machine::Wago8chDigitalIOTestMachine;
|
||||
use crate::wago_ai_test_machine::WagoAiTestMachine;
|
||||
use crate::wago_do_test_machine::WagoDOTestMachine;
|
||||
#[cfg(feature = "mock-machine")]
|
||||
use crate::{
|
||||
extruder1::mock::ExtruderV2 as ExtruderV2Mock1, extruder2::mock::ExtruderV2 as ExtruderV2Mock2,
|
||||
@@ -137,6 +139,11 @@ lazy_static! {
|
||||
|
||||
mc.register::<DigitalInputTestMachine>(DigitalInputTestMachine::MACHINE_IDENTIFICATION);
|
||||
|
||||
mc.register::<WagoDOTestMachine>(WagoDOTestMachine::MACHINE_IDENTIFICATION);
|
||||
mc.register::<Wago8chDigitalIOTestMachine>(
|
||||
Wago8chDigitalIOTestMachine::MACHINE_IDENTIFICATION,
|
||||
);
|
||||
|
||||
mc
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use super::Wago8chDigitalIOTestMachine;
|
||||
use crate::{MachineAct, MachineMessage, MachineValues};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
impl MachineAct for Wago8chDigitalIOTestMachine {
|
||||
fn act(&mut self, now: Instant) {
|
||||
if let Ok(msg) = self.api_receiver.try_recv() {
|
||||
self.act_machine_message(msg);
|
||||
}
|
||||
|
||||
if now.duration_since(self.last_state_emit) > Duration::from_secs_f64(1.0 / 30.0) {
|
||||
self.emit_state();
|
||||
self.last_state_emit = now;
|
||||
}
|
||||
}
|
||||
|
||||
fn act_machine_message(&mut self, msg: MachineMessage) {
|
||||
match msg {
|
||||
MachineMessage::SubscribeNamespace(namespace) => {
|
||||
self.namespace.namespace = Some(namespace);
|
||||
self.emit_state();
|
||||
}
|
||||
MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
|
||||
MachineMessage::HttpApiJsonRequest(value) => {
|
||||
use crate::MachineApi;
|
||||
let _res = self.api_mutate(value);
|
||||
}
|
||||
MachineMessage::ConnectToMachine(_machine_connection) => {
|
||||
// Does not connect to any Machine; do nothing
|
||||
}
|
||||
MachineMessage::DisconnectMachine(_machine_connection) => {
|
||||
// Does not connect to any Machine; do nothing
|
||||
}
|
||||
MachineMessage::RequestValues(sender) => {
|
||||
sender
|
||||
.send_blocking(MachineValues {
|
||||
state: serde_json::to_value(self.get_state())
|
||||
.expect("Failed to serialize state"),
|
||||
live_values: serde_json::Value::Null,
|
||||
})
|
||||
.expect("Failed to send values");
|
||||
sender.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use control_core::socketio::{
|
||||
event::{Event, GenericEvent},
|
||||
namespace::{
|
||||
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_first_and_last_event,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{MachineApi, wago_8ch_dio_test_machine::Wago8chDigitalIOTestMachine};
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct StateEvent {
|
||||
pub digital_input: [bool; 8],
|
||||
pub digital_output: [bool; 8],
|
||||
}
|
||||
|
||||
impl StateEvent {
|
||||
pub fn build(&self) -> Event<Self> {
|
||||
Event::new("StateEvent", self.clone())
|
||||
}
|
||||
}
|
||||
pub enum Wago8chDigitalIOTestMachineEvents {
|
||||
State(Event<StateEvent>),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "action", content = "value")]
|
||||
pub enum Mutation {
|
||||
SetOutput { index: usize, value: bool },
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Wago8chDigitalIOTestMachineNamespace {
|
||||
pub namespace: Option<Namespace>,
|
||||
}
|
||||
|
||||
impl NamespaceCacheingLogic<Wago8chDigitalIOTestMachineEvents>
|
||||
for Wago8chDigitalIOTestMachineNamespace
|
||||
{
|
||||
fn emit(&mut self, events: Wago8chDigitalIOTestMachineEvents) {
|
||||
let event = Arc::new(events.event_value());
|
||||
let buffer_fn = events.event_cache_fn();
|
||||
if let Some(ns) = &mut self.namespace {
|
||||
ns.emit(event, &buffer_fn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheableEvents<Wago8chDigitalIOTestMachineEvents> for Wago8chDigitalIOTestMachineEvents {
|
||||
fn event_value(&self) -> GenericEvent {
|
||||
match self {
|
||||
Wago8chDigitalIOTestMachineEvents::State(event) => event.clone().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_cache_fn(&self) -> CacheFn {
|
||||
cache_first_and_last_event()
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineApi for Wago8chDigitalIOTestMachine {
|
||||
fn api_get_sender(&self) -> smol::channel::Sender<crate::MachineMessage> {
|
||||
self.api_sender.clone()
|
||||
}
|
||||
|
||||
fn api_mutate(&mut self, value: serde_json::Value) -> Result<(), anyhow::Error> {
|
||||
let mutation: Mutation = serde_json::from_value(value)?;
|
||||
match mutation {
|
||||
Mutation::SetOutput { index, value } => self.set_output(index, value),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn api_event_namespace(&mut self) -> Option<Namespace> {
|
||||
self.namespace.namespace.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use control_core::socketio::namespace::NamespaceCacheingLogic;
|
||||
use ethercat_hal::io::{digital_input::DigitalInput, digital_output::DigitalOutput};
|
||||
use smol::channel::{Receiver, Sender};
|
||||
|
||||
use crate::{
|
||||
AsyncThreadMessage, Machine, MachineMessage, VENDOR_QITECH, WAGO_8CH_IO_TEST_MACHINE,
|
||||
machine_identification::{MachineIdentification, MachineIdentificationUnique},
|
||||
wago_8ch_dio_test_machine::api::{
|
||||
StateEvent, Wago8chDigitalIOTestMachineEvents, Wago8chDigitalIOTestMachineNamespace,
|
||||
},
|
||||
};
|
||||
|
||||
pub mod act;
|
||||
pub mod api;
|
||||
pub mod new;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Wago8chDigitalIOTestMachine {
|
||||
pub api_receiver: Receiver<MachineMessage>,
|
||||
pub api_sender: Sender<MachineMessage>,
|
||||
pub machine_identification_unique: MachineIdentificationUnique,
|
||||
pub main_sender: Option<Sender<AsyncThreadMessage>>,
|
||||
pub namespace: Wago8chDigitalIOTestMachineNamespace,
|
||||
pub last_state_emit: Instant,
|
||||
pub digital_output: [DigitalOutput; 8],
|
||||
pub digital_input: [DigitalInput; 8],
|
||||
}
|
||||
|
||||
impl Machine for Wago8chDigitalIOTestMachine {
|
||||
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
|
||||
self.machine_identification_unique.clone()
|
||||
}
|
||||
|
||||
fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>> {
|
||||
self.main_sender.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Wago8chDigitalIOTestMachine {
|
||||
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: WAGO_8CH_IO_TEST_MACHINE,
|
||||
};
|
||||
|
||||
pub fn get_state(&self) -> StateEvent {
|
||||
StateEvent {
|
||||
digital_input: (0..8)
|
||||
.map(|i| {
|
||||
self.digital_input[i]
|
||||
.get_value()
|
||||
.expect("digital input value should be available")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.try_into()
|
||||
.expect("bool vector into array[8] should work"),
|
||||
digital_output: [
|
||||
self.digital_output[0].get(),
|
||||
self.digital_output[1].get(),
|
||||
self.digital_output[2].get(),
|
||||
self.digital_output[3].get(),
|
||||
self.digital_output[4].get(),
|
||||
self.digital_output[5].get(),
|
||||
self.digital_output[6].get(),
|
||||
self.digital_output[7].get(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit_state(&mut self) {
|
||||
let event = self.get_state().build();
|
||||
self.namespace
|
||||
.emit(Wago8chDigitalIOTestMachineEvents::State(event));
|
||||
}
|
||||
|
||||
pub fn set_output(&mut self, i: usize, value: bool) {
|
||||
self.digital_output[i].set(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use anyhow::Error;
|
||||
use ethercat_hal::{
|
||||
devices::{
|
||||
EthercatDevice, downcast_device,
|
||||
wago_750_354::{WAGO_750_354_IDENTITY_A, Wago750_354},
|
||||
wago_modules::wago_750_1506::{
|
||||
Wago750_1506, Wago750_1506InputPort, Wago750_1506OutputPort,
|
||||
},
|
||||
},
|
||||
io::{digital_input::DigitalInput, digital_output::DigitalOutput},
|
||||
};
|
||||
use smol::{block_on, lock::RwLock};
|
||||
|
||||
use crate::{
|
||||
MachineNewHardware, MachineNewParams, MachineNewTrait, get_ethercat_device,
|
||||
validate_no_role_dublicates, validate_same_machine_identification_unique,
|
||||
wago_8ch_dio_test_machine::{
|
||||
Wago8chDigitalIOTestMachine, api::Wago8chDigitalIOTestMachineNamespace,
|
||||
},
|
||||
};
|
||||
|
||||
impl MachineNewTrait for Wago8chDigitalIOTestMachine {
|
||||
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
|
||||
// validate general stuff
|
||||
let device_identification = params
|
||||
.device_group
|
||||
.iter()
|
||||
.map(|device_identification| device_identification.clone())
|
||||
.collect::<Vec<_>>();
|
||||
validate_same_machine_identification_unique(&device_identification)?;
|
||||
validate_no_role_dublicates(&device_identification)?;
|
||||
|
||||
let hardware = match ¶ms.hardware {
|
||||
MachineNewHardware::Ethercat(x) => x,
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"[{}::EtherCATMachine/TestMachine::new] MachineNewHardware is not Ethercat",
|
||||
module_path!()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
block_on(async {
|
||||
// Example usage of a Wago Coupler and a 750-402 in the first slot, where the Output Port 1,2,3,4 is used
|
||||
let _wago_750_354 = get_ethercat_device::<Wago750_354>(
|
||||
hardware,
|
||||
params,
|
||||
0,
|
||||
[WAGO_750_354_IDENTITY_A].to_vec(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let modules = Wago750_354::initialize_modules(_wago_750_354.1).await?;
|
||||
|
||||
let mut coupler = _wago_750_354.0.write().await;
|
||||
|
||||
for module in modules {
|
||||
coupler.set_module(module);
|
||||
}
|
||||
|
||||
coupler.init_slot_modules(_wago_750_354.1);
|
||||
let dev = coupler.slot_devices.get(0).unwrap().clone().unwrap();
|
||||
let wago750_1506: Arc<RwLock<Wago750_1506>> =
|
||||
downcast_device::<Wago750_1506>(dev).await?;
|
||||
|
||||
let di1 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI1);
|
||||
let di2 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI2);
|
||||
let di3 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI3);
|
||||
let di4 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI4);
|
||||
let di5 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI5);
|
||||
let di6 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI6);
|
||||
let di7 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI7);
|
||||
let di8 = DigitalInput::new(wago750_1506.clone(), Wago750_1506InputPort::DI8);
|
||||
|
||||
let do1 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO1);
|
||||
let do2 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO2);
|
||||
let do3 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO3);
|
||||
let do4 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO4);
|
||||
let do5 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO5);
|
||||
let do6 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO6);
|
||||
let do7 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO7);
|
||||
let do8 = DigitalOutput::new(wago750_1506.clone(), Wago750_1506OutputPort::DO8);
|
||||
|
||||
drop(coupler);
|
||||
|
||||
let (sender, receiver) = smol::channel::unbounded();
|
||||
let mut my_test = Self {
|
||||
api_receiver: receiver,
|
||||
api_sender: sender,
|
||||
machine_identification_unique: params.get_machine_identification_unique(),
|
||||
namespace: Wago8chDigitalIOTestMachineNamespace {
|
||||
namespace: params.namespace.clone(),
|
||||
},
|
||||
last_state_emit: Instant::now(),
|
||||
main_sender: params.main_thread_channel.clone(),
|
||||
digital_output: [do1, do2, do3, do4, do5, do6, do7, do8],
|
||||
digital_input: [di1, di2, di3, di4, di5, di6, di7, di8],
|
||||
};
|
||||
my_test.emit_state();
|
||||
Ok(my_test)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use super::WagoDOTestMachine;
|
||||
use crate::{MachineAct, MachineMessage, MachineValues};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
impl MachineAct for WagoDOTestMachine {
|
||||
fn act(&mut self, now: Instant) {
|
||||
if let Ok(msg) = self.api_receiver.try_recv() {
|
||||
self.act_machine_message(msg);
|
||||
}
|
||||
|
||||
if now.duration_since(self.last_state_emit) > Duration::from_secs_f64(1.0 / 30.0) {
|
||||
self.emit_state();
|
||||
self.last_state_emit = now;
|
||||
}
|
||||
}
|
||||
|
||||
fn act_machine_message(&mut self, msg: MachineMessage) {
|
||||
match msg {
|
||||
MachineMessage::SubscribeNamespace(namespace) => {
|
||||
self.namespace.namespace = Some(namespace);
|
||||
self.emit_state();
|
||||
}
|
||||
MachineMessage::UnsubscribeNamespace => self.namespace.namespace = None,
|
||||
MachineMessage::HttpApiJsonRequest(value) => {
|
||||
use crate::MachineApi;
|
||||
let _res = self.api_mutate(value);
|
||||
}
|
||||
MachineMessage::ConnectToMachine(_machine_connection) => {
|
||||
// Does not connect to any Machine; do nothing
|
||||
}
|
||||
MachineMessage::DisconnectMachine(_machine_connection) => {
|
||||
// Does not connect to any Machine; do nothing
|
||||
}
|
||||
MachineMessage::RequestValues(sender) => {
|
||||
sender
|
||||
.send_blocking(MachineValues {
|
||||
state: serde_json::to_value(self.get_state())
|
||||
.expect("Failed to serialize state"),
|
||||
live_values: serde_json::Value::Null,
|
||||
})
|
||||
.expect("Failed to send values");
|
||||
sender.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::WagoDOTestMachine;
|
||||
use crate::{MachineApi, MachineMessage};
|
||||
use control_core::socketio::{
|
||||
event::{Event, GenericEvent},
|
||||
namespace::{
|
||||
CacheFn, CacheableEvents, Namespace, NamespaceCacheingLogic, cache_first_and_last_event,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct StateEvent {
|
||||
pub led_on: [bool; 8],
|
||||
}
|
||||
|
||||
impl StateEvent {
|
||||
pub fn build(&self) -> Event<Self> {
|
||||
Event::new("StateEvent", self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub enum WagoDOTestMachineEvents {
|
||||
State(Event<StateEvent>),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "action", content = "value")]
|
||||
pub enum Mutation {
|
||||
SetLed { index: usize, on: bool },
|
||||
SetAllLeds { on: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WagoDOTestMachineNamespace {
|
||||
pub namespace: Option<Namespace>,
|
||||
}
|
||||
|
||||
impl NamespaceCacheingLogic<WagoDOTestMachineEvents> for WagoDOTestMachineNamespace {
|
||||
fn emit(&mut self, events: WagoDOTestMachineEvents) {
|
||||
let event = Arc::new(events.event_value());
|
||||
let buffer_fn = events.event_cache_fn();
|
||||
if let Some(ns) = &mut self.namespace {
|
||||
ns.emit(event, &buffer_fn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheableEvents<WagoDOTestMachineEvents> for WagoDOTestMachineEvents {
|
||||
fn event_value(&self) -> GenericEvent {
|
||||
match self {
|
||||
WagoDOTestMachineEvents::State(event) => event.clone().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_cache_fn(&self) -> CacheFn {
|
||||
cache_first_and_last_event()
|
||||
}
|
||||
}
|
||||
|
||||
impl MachineApi for WagoDOTestMachine {
|
||||
fn api_get_sender(&self) -> smol::channel::Sender<MachineMessage> {
|
||||
self.api_sender.clone()
|
||||
}
|
||||
|
||||
fn api_mutate(&mut self, request_body: Value) -> Result<(), anyhow::Error> {
|
||||
let mutation: Mutation = serde_json::from_value(request_body)?;
|
||||
match mutation {
|
||||
Mutation::SetLed { index, on } => self.set_led(index, on),
|
||||
Mutation::SetAllLeds { on } => self.set_all_leds(on),
|
||||
}
|
||||
|
||||
for (led, &on) in self.douts.iter().zip(self.led_on.iter()) {
|
||||
led.set(on);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn api_event_namespace(&mut self) -> Option<Namespace> {
|
||||
self.namespace.namespace.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use control_core::socketio::namespace::NamespaceCacheingLogic;
|
||||
use ethercat_hal::io::digital_output::DigitalOutput;
|
||||
use smol::channel::{Receiver, Sender};
|
||||
|
||||
use crate::{
|
||||
AsyncThreadMessage, Machine, MachineMessage, VENDOR_QITECH, WAGO_DO_TEST_MACHINE,
|
||||
machine_identification::{MachineIdentification, MachineIdentificationUnique},
|
||||
wago_do_test_machine::api::{StateEvent, WagoDOTestMachineEvents, WagoDOTestMachineNamespace},
|
||||
};
|
||||
|
||||
pub mod act;
|
||||
pub mod api;
|
||||
pub mod new;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WagoDOTestMachine {
|
||||
pub api_receiver: Receiver<MachineMessage>,
|
||||
pub api_sender: Sender<MachineMessage>,
|
||||
pub machine_identification_unique: MachineIdentificationUnique,
|
||||
pub main_sender: Option<Sender<AsyncThreadMessage>>,
|
||||
pub namespace: WagoDOTestMachineNamespace,
|
||||
pub last_state_emit: Instant,
|
||||
pub led_on: [bool; 8],
|
||||
pub douts: [DigitalOutput; 8],
|
||||
}
|
||||
|
||||
impl Machine for WagoDOTestMachine {
|
||||
fn get_machine_identification_unique(&self) -> MachineIdentificationUnique {
|
||||
self.machine_identification_unique.clone()
|
||||
}
|
||||
|
||||
fn get_main_sender(&self) -> Option<Sender<AsyncThreadMessage>> {
|
||||
self.main_sender.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl WagoDOTestMachine {
|
||||
pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification {
|
||||
vendor: VENDOR_QITECH,
|
||||
machine: WAGO_DO_TEST_MACHINE,
|
||||
};
|
||||
|
||||
pub fn get_state(&self) -> StateEvent {
|
||||
StateEvent {
|
||||
led_on: self.led_on,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit_state(&mut self) {
|
||||
let event = self.get_state().build();
|
||||
self.namespace.emit(WagoDOTestMachineEvents::State(event));
|
||||
}
|
||||
|
||||
/// Set the state of a specific digital output
|
||||
pub fn set_led(&mut self, index: usize, on: bool) {
|
||||
if index < self.led_on.len() {
|
||||
self.led_on[index] = on;
|
||||
self.emit_state();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set all digital outputs at once
|
||||
pub fn set_all_leds(&mut self, on: bool) {
|
||||
self.led_on = [on; 8];
|
||||
self.emit_state();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use crate::wago_do_test_machine::WagoDOTestMachine;
|
||||
use crate::wago_do_test_machine::api::WagoDOTestMachineNamespace;
|
||||
use smol::block_on;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::{
|
||||
MachineNewHardware, MachineNewParams, MachineNewTrait, get_ethercat_device,
|
||||
validate_no_role_dublicates, validate_same_machine_identification_unique,
|
||||
};
|
||||
|
||||
use anyhow::Error;
|
||||
use ethercat_hal::devices::wago_750_354::{WAGO_750_354_IDENTITY_A, Wago750_354};
|
||||
use ethercat_hal::devices::wago_modules::wago_750_530::{Wago750_530, Wago750_530Port};
|
||||
use ethercat_hal::devices::{EthercatDevice, downcast_device};
|
||||
use ethercat_hal::io::digital_output::DigitalOutput;
|
||||
use smol::lock::RwLock;
|
||||
use std::sync::Arc;
|
||||
|
||||
impl MachineNewTrait for WagoDOTestMachine {
|
||||
fn new<'maindevice>(params: &MachineNewParams) -> Result<Self, Error> {
|
||||
let device_identification = params
|
||||
.device_group
|
||||
.iter()
|
||||
.map(|device_identification| device_identification.clone())
|
||||
.collect::<Vec<_>>();
|
||||
validate_same_machine_identification_unique(&device_identification)?;
|
||||
validate_no_role_dublicates(&device_identification)?;
|
||||
|
||||
let hardware = match ¶ms.hardware {
|
||||
MachineNewHardware::Ethercat(x) => x,
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"[{}::MachineNewTrait/WagoDOTestMachine::new] MachineNewHardware is not Ethercat",
|
||||
module_path!()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
block_on(async {
|
||||
let _wago_750_354 = get_ethercat_device::<Wago750_354>(
|
||||
hardware,
|
||||
params,
|
||||
0,
|
||||
[WAGO_750_354_IDENTITY_A].to_vec(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let modules = Wago750_354::initialize_modules(_wago_750_354.1).await?;
|
||||
let mut coupler = _wago_750_354.0.write().await;
|
||||
|
||||
for module in modules {
|
||||
coupler.set_module(module);
|
||||
}
|
||||
|
||||
coupler.init_slot_modules(_wago_750_354.1);
|
||||
let dev = coupler
|
||||
.slot_devices
|
||||
.get(0)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"[{}::MachineNewTrait/WagoDOTestMachine::new] Expected Wago 750-530 module in slot 0, but slot 0 is not configured",
|
||||
module_path!()
|
||||
)
|
||||
})?
|
||||
.clone()
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"[{}::MachineNewTrait/WagoDOTestMachine::new] Expected Wago 750-530 module in slot 0, but slot 0 is empty or no device is present",
|
||||
module_path!()
|
||||
)
|
||||
})?;
|
||||
let wago750_530: Arc<RwLock<Wago750_530>> = downcast_device::<Wago750_530>(dev).await?;
|
||||
|
||||
let do1 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port1);
|
||||
let do2 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port2);
|
||||
let do3 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port3);
|
||||
let do4 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port4);
|
||||
let do5 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port5);
|
||||
let do6 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port6);
|
||||
let do7 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port7);
|
||||
let do8 = DigitalOutput::new(wago750_530.clone(), Wago750_530Port::Port8);
|
||||
drop(coupler);
|
||||
|
||||
let (sender, receiver) = smol::channel::unbounded();
|
||||
let mut machine = Self {
|
||||
api_receiver: receiver,
|
||||
api_sender: sender,
|
||||
machine_identification_unique: params.get_machine_identification_unique(),
|
||||
namespace: WagoDOTestMachineNamespace {
|
||||
namespace: params.namespace.clone(),
|
||||
},
|
||||
last_state_emit: Instant::now(),
|
||||
led_on: [false; 8],
|
||||
main_sender: params.main_thread_channel.clone(),
|
||||
douts: [do1, do2, do3, do4, do5, do6, do7, do8],
|
||||
};
|
||||
machine.emit_state();
|
||||
Ok(machine)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,16 @@ use control_core::socketio::namespace::NamespaceCacheingLogic;
|
||||
use control_core::{irq_handling::set_irq_affinity, realtime::set_realtime_priority};
|
||||
use ethercat_hal::debugging::diagnosis_history::get_most_recent_diagnosis_message;
|
||||
use ethercat_hal::devices::devices_from_subdevices;
|
||||
|
||||
use ethercat_hal::devices::wago_750_354::{
|
||||
WAGO_750_354_PRODUCT_ID, WAGO_750_354_VENDOR_ID, Wago750_354,
|
||||
};
|
||||
|
||||
use ethercat_hal::devices::wago_modules::ip20_ec_di8_do8::{
|
||||
IP20_EC_DI8_DO8_PRODUCT_ID, IP20_EC_DI8_DO8_VENDOR_ID, IP20EcDi8Do8,
|
||||
};
|
||||
|
||||
use ethercat_hal::helpers::set_mut_beckhoff_eeprom_lock_active;
|
||||
use ethercrab::error::Error;
|
||||
use ethercrab::subdevice_group::{DcConfiguration, HasDc, PreOpPdi, SafeOp};
|
||||
use smol::stream::StreamExt;
|
||||
@@ -242,11 +246,11 @@ pub async fn setup_loop(
|
||||
pdu,
|
||||
Timeouts {
|
||||
// Default 5000ms
|
||||
state_transition: Duration::from_millis(10000),
|
||||
state_transition: Duration::from_millis(20000),
|
||||
// Default 30_000us
|
||||
pdu: Duration::from_micros(30_000),
|
||||
// Default 10ms
|
||||
eeprom: Duration::from_millis(10),
|
||||
eeprom: Duration::from_millis(100),
|
||||
// Default 0ms
|
||||
wait_loop_delay: Duration::from_millis(0),
|
||||
// Default 100ms
|
||||
@@ -292,6 +296,11 @@ pub async fn setup_loop(
|
||||
};
|
||||
|
||||
for mut subdevice in group_preop.iter_mut(&maindevice) {
|
||||
// 0x2 vendor id is Beckhoff
|
||||
if subdevice.identity().vendor_id == 0x2 {
|
||||
let _res = set_mut_beckhoff_eeprom_lock_active(&subdevice).await;
|
||||
}
|
||||
|
||||
if subdevice.name() == "EL4002" {
|
||||
// Sync mode 01 = SM Synchronous, says it does dc but actually doesnt, thx?
|
||||
subdevice
|
||||
|
||||