Compare commits

...
Author SHA1 Message Date
Hans Sebastian Pöhlmann 45f8d5070e vibe-coded mock machine frontend in solidjs 2026-02-24 18:37:05 +01:00
Christian af0b5deff0 Merge pull request #1135 from qitechgmbh/991-graph-tab-in-top-navigation-bar-not-connecting-to-graph-page-2
fixed the graph page not not connecting to navigation bar  bug
2026-02-13 08:06:53 +01:00
Christian 743a589c1d Update README.md to enhance video descriptions and add additional video section 2026-02-12 17:04:35 +01:00
Achraf Jalleli ee7550d89f Merge pull request #1127 from qitechgmbh/1042-implement-minimal-machine-example-for-wago-750-530
1042 implement minimal machine example for wago 750 530
2026-02-12 13:37:26 +01:00
Achraf JalleliandCopilot ec13f715aa slug fix
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-12 13:22:14 +01:00
Achraf Jalleli 2af746e7a4 Add files via upload 2026-02-12 13:14:04 +01:00
JalleliAchraf 3af3fb9d79 used array.fill 2026-02-12 11:22:11 +01:00
Achraf Jalleli ff3103bec2 Merge branch 'master' into 1042-implement-minimal-machine-example-for-wago-750-530 2026-02-12 11:00:23 +01:00
Hans Sebastian Pöhlmann 5eb3c39f84 1043 implement minimal machine example for wago 750 1506 (#1106)
* wago 750_1506 minimal machine backend apparently done, frontend still unfinished

* wago 750_1506 frontend apparently done, but untested

* added bus coupler to device roles
2026-02-12 10:54:06 +01:00
Robin Krämer 2c715d6a8e fixed initialization by raising timeouts for eeprom,eeprom write lock (#1136)
* fixed initialization by raising timeouts for eeprom,eeprom write lock

* fixed formatting
2026-02-11 14:24:48 +01:00
JalleliAchraf fb12001721 fixed the graph page not not connecting to navigation bar bug 2026-02-10 18:29:04 +01:00
Robin Krämer ad94754fcf Added missing revision for Watercooling (#1130) 2026-02-09 15:14:30 +01:00
Achraf Jalleli b919721c3a Update npmDepsHash for electron package 2026-02-06 16:20:26 +01:00
JalleliAchraf a428a2aa02 formatting 2026-02-06 16:12:25 +01:00
JalleliAchraf 7b479c6021 Merge branch '1042-implement-minimal-machine-example-for-wago-750-530' of github.com:qitechgmbh/control into 1042-implement-minimal-machine-example-for-wago-750-530 2026-02-06 16:09:03 +01:00
JalleliAchraf 17ac24c79d minor improvements 2026-02-06 16:08:25 +01:00
JalleliAchraf 8c7a04b616 formatting fix 2026-02-06 15:57:55 +01:00
github-actions[bot] fe248c79ae chore: update npmDepsHash to sha256-/7zfLof91S97OmW8dBeaVajYEv3r2eYOZ8Zb2ahykaQ= 2026-02-06 14:54:58 +00:00
JalleliAchraf 038df09611 removed unecessary differences between this branch and master 2026-02-06 15:49:21 +01:00
JalleliAchraf 9e43d8d431 several small fixes 2026-02-06 15:44:13 +01:00
JalleliAchraf d364263d6e wago digital output minimal machine 2026-02-06 15:22:00 +01:00
67 changed files with 7619 additions and 37 deletions

No files matched your search

+10 -1
View File
@@ -22,7 +22,7 @@ QiTech Control combines the modularity and reliability of standard EtherCAT term
| Software Demo | Full Explainer |
|---------------|----------------|
| [![](https://img.youtube.com/vi/KI3YeBwfV-s/maxresdefault.jpg)](https://www.youtube.com/watch?v=KI3YeBwfV-s)<br/>*Click to watch a demo of the control software in action* | [![](https://img.youtube.com/vi/55egCAkQgyM/maxresdefault.jpg)](https://youtu.be/55egCAkQgyM)<br/>*Click to watch a complete overview of QiTech Control* |
| [![](https://img.youtube.com/vi/KI3YeBwfV-s/maxresdefault.jpg)](https://www.youtube.com/watch?v=KI3YeBwfV-s)<br/>*Watch a demo of the control software in action* | [![](https://img.youtube.com/vi/55egCAkQgyM/maxresdefault.jpg)](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://img.youtube.com/vi/U2wNN6sF4to/maxresdefault.jpg)](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://img.youtube.com/vi/R5WGAY3WRWA/maxresdefault.jpg)](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://img.youtube.com/vi/xyaHwxXguw4/maxresdefault.jpg)](https://www.youtube.com/watch?v=xyaHwxXguw4&t=32s)<br/>**First Client**<br/>*A milestone week building the first real client project.* | [![](https://img.youtube.com/vi/moI7z2PVdF4/maxresdefault.jpg)](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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

@@ -18,7 +18,7 @@ export function Aquapath1Page() {
link: "graph",
title: "Graph",
icon: "lu:ChartSpline",
activeLink: "graphs",
activeLink: "graph",
},
{
link: "settings",
+54
View File
@@ -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,
};
}
+34
View File
@@ -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(())
}
+71
View File
@@ -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(())
}
+4 -1
View File
@@ -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;
+3
View File
@@ -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 {
+7
View File
@@ -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 &params.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)
})
}
}
+46
View File
@@ -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();
}
}
}
}
+84
View File
@@ -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()
}
}
+69
View File
@@ -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();
}
}
+101
View File
@@ -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 &params.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)
})
}
}
+28
View File
@@ -0,0 +1,28 @@
dist
.wrangler
.output
.vercel
.netlify
.vinxi
app.config.timestamp_*.js
# Environment
.env
.env*.local
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
# Temp
gitignore
# System Files
.DS_Store
Thumbs.db
+298
View File
@@ -0,0 +1,298 @@
# Electron → SolidJS Migration Analysis
## Why SolidJS is a Good Fit
The electron frontend has two fundamental issues that SolidJS directly solves:
1. **React's reconciler triggers re-renders on any state change** — the codebase added an entire `ThrottledStoreUpdater` class (100+ lines) to cap re-renders at 30 FPS and prevent UI jank from high-frequency socket events.
In SolidJS, reactive updates are surgical: only the DOM nodes that actually depend on a changed signal update. No reconciliation pass, no virtual DOM diff. The `batch()` primitive coalesces multiple signal writes into one reactive pass — replaces `ThrottledStoreUpdater` entirely with zero boilerplate.
2. **React's hook rules and closure gotchas** force state management up into libraries (Zustand) and create a deep abstraction stack. SolidJS primitives (`createSignal`, `createStore`, `onCleanup`) live exactly where the components live and have no closure staleness issues.
---
## Abstraction Comparison: WebSocket Layer
### Electron (React)
Adding a machine namespace requires touching **5+ files**:
| File | Lines | Purpose |
|------|-------|---------|
| `socketioStore.ts` | 515 | Base infrastructure: ThrottledStoreUpdater, createNamespaceHookImplementation, namespace lifecycle |
| `{machine}Namespace.ts` | ~77 | Zod schemas, Zustand store, message handler, hook wrapper |
| `use{Machine}.ts` | ~95 | Route param parsing, optimistic state, mutation helpers |
| `routes.tsx` | +~15 | Route definitions |
| `properties.ts` | +~20 | Machine properties entry |
**Total per machine: ~220+ lines of mandatory boilerplate before writing any UI.**
### SolidJS (this PoC)
Adding a machine namespace requires:
```ts
// namespaces/myMachine.ts — ~25 lines total
export function createMyMachineNamespace(vendor, machine, serial) {
return createNamespace(
machineNamespacePath(vendor, machine, serial),
(event, set) => {
if (event.name === "StateEvent") set("data", event.data);
},
{ data: null },
);
}
```
Then in the page component:
```tsx
const [state] = createMyMachineNamespace(VENDOR, MACHINE, serial());
// state().data is now reactive — that's it
```
**The socket connects on component mount, disconnects on unmount, updates are reactive.
No store, no hook factory, no ThrottledStoreUpdater, no ref-counting.**
---
## The Core Primitive (`src/lib/socketio.ts`)
```
createNamespace(path, handler, initialState) → [signal, socket]
```
- `batch()` replaces `ThrottledStoreUpdater` — SolidJS batches reactive updates automatically
- `onCleanup()` replaces manual `useEffect` teardown — socket disconnects when scope disposed
- The signal IS the store — no Zustand, no Immer needed for simple machine state
This single 80-line file replaces `socketioStore.ts` (515 lines) for new machines.
---
## State Management
### Electron
- Zustand (global singletons + per-namespace stores)
- Immer (immutable updates)
- `useSyncExternalStore` (bridge Zustand → React)
- `ThrottledStoreUpdater` (performance workaround)
- `useStateOptimistic` (dual optimistic/real state)
### SolidJS
- `createSignal` / `createStore` — SolidJS built-ins
- `batch()` — built-in, no library
- Optimistic state: just another `createSignal` in the component
The optimistic pattern from `useStateOptimistic.tsx` (~80 lines) collapses to two lines in a SolidJS component:
```ts
const [optimistic, setOptimistic] = createSignal<State | null>(null);
const effective = () => optimistic() ?? serverState().data;
```
---
## Things That Don't Exist in Electron That SolidJS Gets for Free
| Feature | Electron workaround | SolidJS |
|---------|-------------------|---------|
| Fine-grained reactivity | `ThrottledStoreUpdater` (100 LOC) | `batch()` built-in |
| Cleanup on unmount | `useEffect` return + ref counting | `onCleanup()` |
| Derived state | `useMemo` | `createMemo()` or just `() => expr` |
| Conditional render | `&&` / ternary (JSX pitfalls) | `<Show>` (handles null/undefined safely) |
| List render | `.map()` | `<For>` (keyed, no full re-render on list change) |
---
## What the Full Migration Looks Like
### Things to keep / port directly
- The Zod event schemas (optional — validation is still useful)
- The REST API mutation pattern (`mutateMachine()`)
- The uplot chart components (framework-agnostic, just need a SolidJS wrapper)
- The unit/value display logic
### Things to discard
- `ThrottledStoreUpdater` — not needed
- `createNamespaceHookImplementation` — replaced by `createNamespace()`
- Zustand + Immer — replaced by SolidJS primitives
- `useStateOptimistic` — two lines in a component
- `useSyncExternalStore` bridge — not needed
- All the `useEffect` lifecycle boilerplate for socket management
### Things to re-evaluate
- **Electron itself** — this PoC runs as a plain browser app (`npm run dev`), served from the Rust backend. No Electron needed if the app runs in the browser. Consider just serving the built SolidJS app as static files from the Axum server.
- **i18next** — if the app is only used in one language, skip it
- **TanStack Router** → `@solidjs/router` handles the use case with less ceremony
- **react-hook-form + Zod** → SolidJS has no equivalent but the forms here are simple enough for bare `createSignal` + validation on submit
---
## Migration Scope Estimate
| Area | Effort | Notes |
|------|--------|-------|
| WebSocket infrastructure | Done (80 lines) | `src/lib/socketio.ts` |
| Main namespace | Done (~55 lines) | `src/namespaces/main.ts` |
| Per-machine namespace | ~25 lines each | See testMachine.ts as template |
| Machine pages (control/settings) | Medium | Mostly UI translation, logic simplifies |
| Graph components (uplot) | Medium | uplot is framework-agnostic; wrap in `onMount`/`onCleanup` |
| Sidebar + routing | Done | See App.tsx + Sidebar.tsx |
| Setup/EtherCAT pages | Small | Just display logic, no complex state |
| Presets | Medium | Local storage + fetch, straightforward |
| Electron IPC (update, theme, NixOS) | Low if dropped | Not needed if switching to browser app |
**Rough estimate: 2–3 weeks for a complete working port, 1 week for a fully functional PoC covering all machine types.**
The biggest time investment is translating the 15+ machine-specific UI pages, not the infrastructure — the infrastructure is already done and is dramatically simpler.
---
## Proof of Concept Structure
```
src/
├── lib/
│ ├── socketio.ts # Core: createNamespace() primitive (80 lines)
│ └── api.ts # REST mutations
├── namespaces/
│ ├── main.ts # Main namespace (~55 lines, vs ~170 in electron)
│ ├── testMachine.ts # Test machine namespace (~25 lines, vs ~77 in electron)
│ └── mock1.ts # Mock machine namespace (~60 lines incl. types)
├── pages/
│ ├── MachinesPage.tsx # Machine list
│ ├── TestMachineControlPage.tsx # LED control with optimistic updates
│ └── Mock1ControlPage.tsx # Mock machine: live values + freq/mode controls
├── components/
│ └── Sidebar.tsx # Navigation sidebar
└── App.tsx # Router + context provider
```
Run with: `npm run dev` (vite dev server, proxies to backend at localhost:3001)
---
## How Other Machines Would Be Implemented
Each machine follows the same two-step pattern:
### Step 1: Namespace file (`src/namespaces/{machine}.ts`)
Define a state type that mirrors the Rust `StateEvent` and `LiveValuesEvent` structs, then call `createNamespace()`:
```ts
export type MyMachineState = {
// fields from StateEvent
some_field: number | null;
mode: "Standby" | "Running" | null;
// fields from LiveValuesEvent (high-frequency, set separately)
live_measurement: number | null;
};
export function createMyMachineNamespace(serial: number) {
return createNamespace<MyMachineState>(
machineNamespacePath(VENDOR, MACHINE_ID, serial),
(event, set) => {
if (event.name === "StateEvent") {
set("some_field", event.data.some_field);
set("mode", event.data.mode);
} else if (event.name === "LiveValuesEvent") {
set("live_measurement", event.data.measurement);
}
},
{ some_field: null, mode: null, live_measurement: null },
);
}
```
Machine IDs (from `machines/src/lib.rs`):
| Machine | Vendor | Machine ID |
|---------|--------|-----------|
| TestMachine | 1 | 0x0033 (51) |
| Winder v2 | 1 | 0x0002 (2) |
| Extruder v2 | 1 | 0x0004 (4) |
| Laser v1 | 1 | 0x0006 (6) |
| Mock | 1 | 0x0007 (7) |
| Buffer v1 | 1 | 0x0008 (8) |
| Aquapath v1 | 1 | 0x0009 (9) |
| Wago Power v1 | 1 | 0x000A (10) |
| Extruder v3 | 1 | 0x0016 (22) |
### Step 2: Page component (`src/pages/{Machine}ControlPage.tsx`)
```tsx
export default function MyMachineControlPage() {
const params = useParams<{ serial: string }>();
const serial = () => parseInt(params.serial);
const [state] = createMyMachineNamespace(serial());
// Optimistic state per mutable field
const [optimisticMode, setOptimisticMode] = createSignal<Mode | null>(null);
const mode = () => optimisticMode() ?? state().mode ?? "Standby";
async function setMode(newMode: Mode) {
setOptimisticMode(newMode);
try {
await mutateMachine(
{ machine_identification: { vendor: VENDOR, machine: MACHINE_ID }, serial: serial() },
{ SetMode: newMode },
);
} catch { setOptimisticMode(null); }
}
return (/* JSX */);
}
```
### Step 3: Register route + sidebar entry
In `App.tsx`:
```tsx
<Route path="/machines/mymachine/:serial/control" component={MyMachineControlPage} />
<Route path="/machines/mymachine/:serial" component={...} />
```
In `Sidebar.tsx` and `MachinesPage.tsx`, add to the slug/label maps:
```ts
"1_X": "mymachine" // vendor_machine → slug
```
### Pages needed per machine (from electron reference)
| Machine | Pages in electron | Notes |
|---------|------------------|-------|
| TestMachine | Control | LED toggle (done) |
| Mock | Control | Frequencies + mode + live values (done) |
| Winder v2 | Control, Settings, Graphs, Presets, Manual | Complex: spool/traverse/puller motors |
| Extruder v2/v3 | Control, Settings, Graphs, Presets, Manual | Heating zones, inverter speed |
| Laser v1 | Control, Graphs, Presets, Manual | Diameter measurement, tolerance bands |
| Buffer v1 | Control, Settings | Motor control |
| Aquapath v1 | Control, Settings, Graphs | Temperature + analog output |
| Wago Power v1 | Control | Power channel display |
### Shared UI components still to build
The electron frontend has these reusable control components that would benefit from SolidJS ports:
- **`ControlCard`** — titled card wrapper (partially done with `.control-card` CSS)
- **`EditValue`** — numeric input with min/max/step/unit/default display
- **`SelectionGroup`** — segmented button toggle (done with `.mode-selector` pattern)
- **`TimeSeriesValueNumeric`** — displays latest value from a live stream
- **Graph wrapper** — uplot integration in SolidJS using `onMount`/`onCleanup`
- **`Topbar`** — tab navigation within a machine (Control / Graph / Manual / Presets)
- **`Page`** — page container with consistent padding/width
- **Preset system** — load/save named parameter sets via REST API
---
## Machine-Specific Complexity Notes
### Winder v2
Most complex machine. Has three motors (spool, traverse, puller), a tension arm, and a diameter visualisation component. The electron page (`Winder2ControlPage.tsx`) uses custom SVG components (`Spool.tsx`, `TraverseBar.tsx`, `TensionArm.tsx`). These are pure rendering components that can be ported as-is to SolidJS.
### Extruder v2 / v3
Has heating zones (up to 8 zones, each with temperature setpoint + PID state). The `HeatingZone.tsx` component in electron is shared between extruder2 and extruder3. The graph page plots temperature history via uplot.
### Laser v1
Has `DiameterVisualisation.tsx` — a custom SVG component showing the wire cross-section with tolerance bands. The control page shows live diameter + tolerance settings.
### Mock Machine
Simplest "real" machine with meaningful state: three sine wave generators with frequency controls and a running/standby mode. Ideal for testing the frontend with the mock backend (`cargo run --features mock-machine`).
+36
View File
@@ -0,0 +1,36 @@
## Usage
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev` or `npm start`
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
+156
View File
@@ -0,0 +1,156 @@
# SolidJS Frontend — Feature Parity Roadmap
This document tracks what still needs to be implemented to reach feature parity with the
existing Electron/React frontend. Checked items are complete in this PoC.
---
## Infrastructure
- [x] Socket.IO connection primitive (`createNamespace`)
- [x] Main namespace (machine discovery, EtherCAT status)
- [x] REST mutation helper (`mutateMachine`)
- [x] Sidebar with live machine list
- [x] Basic routing (`@solidjs/router`)
- [x] Dark theme CSS variables
- [ ] Topbar / tab navigation per machine (Control / Graph / Manual / Presets)
- [ ] Toast notifications (success/error feedback on mutations)
- [ ] Global error boundary
- [ ] Vite proxy config for production build (serve from Axum)
---
## Shared UI Components
- [x] Control card wrapper (`.control-card`)
- [x] Live value display (numeric, tabular-nums)
- [x] Frequency / numeric input with reset-to-default button
- [x] Mode selector (segmented button)
- [ ] `EditValue` — generalised numeric input with unit label, min/max/step, inline validation
- [ ] `SelectionGroup` — generalised segmented button for arbitrary enum values
- [ ] `TimeSeriesValueNumeric` — last-value display from a streaming signal
- [ ] uplot graph wrapper — `onMount` creates chart, reactive signals push data, `onCleanup` destroys
- [ ] Preset system UI — list presets, apply preset, save as preset (uses REST API)
- [ ] Manual/PDF viewer page
- [ ] EtherCAT setup page (device list, address assignments)
- [ ] Setup / configuration page (EtherCAT interface picker)
---
## Machine Pages
### Test Machine (vendor=1, machine=0x33/51)
- [x] Control page — LED toggle grid
### Mock Machine (vendor=1, machine=0x07/7)
- [x] Control page — live sine wave values, frequency inputs, mode selector
- [ ] Graph page — uplot with amplitude_sum + individual waves over time
### Winder v2 (vendor=1, machine=0x02/2)
- [ ] Namespace (`winder2Namespace.ts`)
- [ ] Control page — spool speed, traverse, puller, tension arm state
- [ ] Settings page — traverse limits, spool diameter config
- [ ] Graph page — speed/tension over time
- [ ] Presets page
- [ ] Manual page
### Extruder v2 (vendor=1, machine=0x04/4) / Extruder v3 (machine=0x16/22)
- [ ] Namespace
- [ ] Control page — heating zones (setpoints + actual temps), inverter speed
- [ ] Settings page — PID parameters per zone
- [ ] Graph page — temperature history
- [ ] Presets page
- [ ] Manual page
### Laser v1 (vendor=1, machine=0x06/6)
- [ ] Namespace
- [ ] Control page — live diameter, target diameter, tolerance band, in-tolerance indicator
- [ ] Graph page — diameter over time with tolerance lines
- [ ] Presets page
- [ ] Manual page
### Buffer v1 (vendor=1, machine=0x08/8)
- [ ] Namespace
- [ ] Control page — motor speed setpoints, actual speeds
- [ ] Settings page
### Aquapath v1 (vendor=1, machine=0x09/9)
- [ ] Namespace
- [ ] Control page — temperature setpoints, analog outputs, encoder position
- [ ] Settings page
- [ ] Graph page
### Wago Power v1 (vendor=1, machine=0x0A/10)
- [ ] Namespace
- [ ] Control page — power channel on/off display
### Digital Input Test Machine (vendor=1, machine=0x40/64)
- [ ] Namespace + Control page — digital input state display
### Wago 8ch DIO Test (vendor=1, machine=0x41/65)
- [ ] Namespace + Control page
### Wago DO Test (vendor=1, machine=0x0E/14)
- [ ] Namespace + Control page
### Wago AI Test (vendor=1, machine=0x36/54)
- [ ] Namespace + Control page
### IP20 Test Machine (vendor=1, machine=0x34/52)
- [ ] Namespace + Control page
### Motor Test Machine (vendor=1, machine=0x11/17)
- [ ] Namespace + Control page
### Analog Input Test Machine (vendor=1, machine=0x35/53)
- [ ] Namespace + Control page
---
## Pages Outside Machine Control
- [ ] Machines overview — currently functional, but uses `unknown-*` slug for unregistered machines
- [ ] EtherCAT status page — show `ethercatDevices` and `ethercatInterface` from main namespace
- [ ] Setup page — EtherCAT interface picker (currently hardcoded in backend)
- [ ] About / version page
---
## Nice-to-Have / Polish
- [ ] Loading skeleton states (instead of plain "Waiting…" text)
- [ ] Error state display when machine.error is set
- [ ] Reconnect indicator (socket disconnect/reconnect visual feedback)
- [ ] Responsive layout for smaller screens
- [ ] Keyboard shortcuts (already exists in electron via keybindings)
---
## What to Drop from Electron
These exist in the Electron app but are **not needed** in the SolidJS version:
- Electron IPC (auto-update, NixOS config, system tray) — not applicable in browser
- `ThrottledStoreUpdater` — replaced by SolidJS `batch()`
- Zustand + Immer — replaced by `createSignal`/`createStore`
- `useStateOptimistic` — two lines inline in each component
- `createNamespaceHookImplementation` — replaced by `createNamespace()`
- `react-hook-form` — bare signals are enough for the simple forms here
- i18next — skip unless multi-language is required
---
## Testing the PoC with Mock Mode
Start the backend in mock mode:
```sh
cargo run --features mock-machine
```
Start the frontend dev server:
```sh
cd qitech-control-gui && npm run dev
```
Open http://localhost:3000 — the mock machine should appear in the sidebar.
Navigate to `/machines/mock1/{serial}/control` to see the control page.
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
<title>Solid App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "vite-template-solid",
"version": "0.0.0",
"description": "",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"license": "MIT",
"devDependencies": {
"solid-devtools": "^0.34.3",
"typescript": "^5.7.2",
"vite": "^7.1.4",
"vite-plugin-solid": "^2.11.8"
},
"dependencies": {
"@solidjs/router": "^0.15.4",
"socket.io-client": "^4.8.3",
"socket.io-msgpack-parser": "^3.0.2",
"solid-js": "^1.9.5",
"uplot": "^1.6.32",
"zod": "^4.3.6"
}
}
+1346
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
.App {
text-align: center;
}
.logo {
animation: logo-spin infinite 20s linear;
height: 40vmin;
pointer-events: none;
}
.header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.link {
color: #b318f0;
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+81
View File
@@ -0,0 +1,81 @@
/**
* App root — sets up routing and creates the main namespace socket once.
*
* The main namespace signal is created here (at app scope) and provided via
* context so all pages can access it without prop drilling.
*
* Machine-specific namespaces are created inside their pages so they
* connect/disconnect with the page lifecycle automatically.
*
* No global store. No Zustand. No Immer. No ThrottledStoreUpdater.
* State lives in SolidJS signals, reactive updates are handled by batch().
*/
import { Router, Route } from "@solidjs/router";
import { createContext, useContext } from "solid-js";
import type { JSX } from "solid-js";
import Sidebar from "./components/Sidebar";
import MachinesPage from "./pages/MachinesPage";
import TestMachineControlPage from "./pages/TestMachineControlPage";
import Mock1ControlPage from "./pages/Mock1ControlPage";
import Mock1GraphPage from "./pages/Mock1GraphPage";
import { createMainNamespace, type MainState } from "./namespaces/main";
import "./styles.css";
// Context so nested pages can read main state without prop drilling
export const MainStateContext = createContext<() => MainState>(() => ({
machines: null,
ethercatDevices: null,
ethercatInterface: null,
}));
export function useMainState() {
return useContext(MainStateContext);
}
// Shared layout wrapper passed to Router as `root`
function AppLayout(props: { children?: JSX.Element }) {
const mainState = useMainState();
return (
<div class="app-layout">
<Sidebar mainState={mainState} />
<main class="app-content">{props.children}</main>
</div>
);
}
export default function App() {
// One socket for the main namespace, alive for the entire app lifetime.
// createMainNamespace() calls createNamespace() which calls onCleanup()
// — so the socket disconnects if App ever unmounts.
const [mainState] = createMainNamespace();
return (
<MainStateContext.Provider value={mainState}>
<Router root={AppLayout}>
<Route path="/" component={() => <MachinesPage mainState={mainState} />} />
<Route path="/machines/testmachine/:serial/control" component={TestMachineControlPage} />
<Route
path="/machines/testmachine/:serial"
component={() => (
<div class="page">
<h1>Test Machine</h1>
<p class="muted">Select Control from the sidebar.</p>
</div>
)}
/>
<Route path="/machines/mock1/:serial/control" component={Mock1ControlPage} />
<Route path="/machines/mock1/:serial/graph" component={Mock1GraphPage} />
<Route
path="/machines/mock1/:serial"
component={() => (
<div class="page">
<h1>Mock Machine</h1>
<p class="muted">Select Control from the sidebar.</p>
</div>
)}
/>
</Router>
</MainStateContext.Provider>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,143 @@
/**
* MiniGraph — compact inline sparkline for control page cards.
*
* 64 px tall, no cursor, no legend, no x-axis.
* Y-axis grid shows on the right side.
* Driven by a rAF loop reading directly from a SeriesBuffer — no reactive
* subscriptions, no intermediate array copies on every event.
*
* Props:
* - getBuffer: () => SeriesBuffer called each frame to read the ring buffer
* - timeWindow: number seconds of history to display (default: 10)
* - stroke: CSS colour string (default: var(--accent) / #6c8cff)
*/
import uPlot from "uplot";
import { onMount, onCleanup } from "solid-js";
import type { SeriesBuffer } from "../namespaces/mock1";
const HEIGHT = 64;
type Props = {
getBuffer: () => SeriesBuffer;
timeWindow?: number;
stroke?: string;
};
export default function MiniGraph(props: Props) {
let container!: HTMLDivElement;
let uplot: uPlot | null = null;
let rafId = 0;
let pendingWidth = 0;
function buildOpts(width: number): uPlot.Options {
return {
width,
height: HEIGHT,
padding: [4, 0, 4, 0],
cursor: { show: false },
legend: { show: false },
scales: {
x: { time: true },
y: { auto: false },
},
axes: [
{ show: false },
{
side: 1, // right side
size: 40,
stroke: "#717898",
grid: { stroke: "#2e3148", width: 0.5 },
ticks: { stroke: "#2e3148", width: 0.5 },
values: (_u, ticks) => ticks.map((v) => v.toFixed(2)),
},
],
series: [
{},
{
stroke: props.stroke ?? "#6c8cff",
width: 1.5,
spanGaps: true,
points: { show: false },
},
],
};
}
function frame() {
if (!uplot) { rafId = requestAnimationFrame(frame); return; }
if (pendingWidth > 0) {
uplot.setSize({ width: pendingWidth, height: HEIGHT });
pendingWidth = 0;
}
const buf = props.getBuffer();
const { times, values, length, head } = buf;
if (length === 0) { rafId = requestAnimationFrame(frame); return; }
const bufSize = times.length;
const isFull = length === bufSize;
const startIdx = isFull ? head : 0;
const tMax = times[(head - 1 + bufSize) % bufSize];
const tMin = tMax - (props.timeWindow ?? 10);
// Count entries in window
let count = 0;
for (let i = 0; i < length; i++) {
if (times[(startIdx + i) % bufSize] >= tMin) count++;
}
const outT = new Float64Array(count);
const outV = new Float64Array(count);
let out = 0;
let minY = Infinity, maxY = -Infinity;
for (let i = 0; i < length; i++) {
const idx = (startIdx + i) % bufSize;
const t = times[idx];
if (t >= tMin) {
outT[out] = t;
outV[out] = values[idx];
if (values[idx] < minY) minY = values[idx];
if (values[idx] > maxY) maxY = values[idx];
out++;
}
}
if (count === 0) { rafId = requestAnimationFrame(frame); return; }
const range = maxY - minY || 1;
const pad = range * 0.15;
uplot.batch(() => {
uplot!.setData([outT, outV], false);
uplot!.setScale("x", { min: tMin, max: tMax });
uplot!.setScale("y", { min: minY - pad, max: maxY + pad });
});
rafId = requestAnimationFrame(frame);
}
onMount(() => {
const ro = new ResizeObserver(([entry]) => {
const w = Math.floor(entry.contentRect.width);
if (w === 0) return;
if (!uplot) {
uplot = new uPlot(buildOpts(w), [new Float64Array(0), new Float64Array(0)], container);
rafId = requestAnimationFrame(frame);
} else {
pendingWidth = w;
}
});
ro.observe(container);
onCleanup(() => {
cancelAnimationFrame(rafId);
ro.disconnect();
uplot?.destroy();
uplot = null;
});
});
return <div ref={container} class="minigraph-wrap" />;
}
@@ -0,0 +1,92 @@
import { A } from "@solidjs/router";
import { For, Show, createMemo } from "solid-js";
import type { MainState, MachineObj } from "../namespaces/main";
type Props = {
mainState: () => MainState;
};
function machineLabel(m: MachineObj): string {
const { vendor, machine } = m.machine_identification_unique.machine_identification;
const labels: Record<string, string> = {
"1_51": "Test Machine", // 0x0033
"1_2": "Winder v2", // 0x0002
"1_4": "Extruder v2", // 0x0004
"1_6": "Laser v1", // 0x0006
"1_7": "Mock", // 0x0007
"1_8": "Buffer v1", // 0x0008
"1_9": "Aquapath v1", // 0x0009
"1_10": "Wago Power", // 0x000A
"1_22": "Extruder v3", // 0x0016
};
return labels[`${vendor}_${machine}`] ?? `Machine ${vendor}/${machine}`;
}
function machineSlug(m: MachineObj): string {
const { vendor, machine } = m.machine_identification_unique.machine_identification;
const slugs: Record<string, string> = {
"1_51": "testmachine", // 0x0033
"1_2": "winder2", // 0x0002
"1_4": "extruder2", // 0x0004
"1_6": "laser1", // 0x0006
"1_7": "mock1", // 0x0007
"1_8": "buffer1", // 0x0008
"1_9": "aquapath1", // 0x0009
"1_10": "wago_power1", // 0x000A
"1_22": "extruder3", // 0x0016
};
return slugs[`${vendor}_${machine}`] ?? `unknown-${vendor}-${machine}`;
}
// Slugs that have a graph page implemented
const SLUGS_WITH_GRAPH = new Set(["mock1"]);
export default function Sidebar(props: Props) {
const machines = createMemo(() => props.mainState().machines ?? []);
return (
<nav class="sidebar">
<div class="sidebar-brand">
<span>QiTech Control</span>
</div>
<div class="sidebar-section">
<A href="/" class="sidebar-link" end>
Machines
</A>
</div>
<Show when={machines().length > 0}>
<div class="sidebar-section">
<div class="sidebar-section-label">Active Machines</div>
<For each={machines()}>
{(m) => {
const slug = machineSlug(m);
const serial = m.machine_identification_unique.serial;
return (
<div class="sidebar-machine">
<A href={`/machines/${slug}/${serial}`} class="sidebar-link" end>
{machineLabel(m)} <span class="sidebar-serial">#{serial}</span>
</A>
<A href={`/machines/${slug}/${serial}/control`} class="sidebar-sublink">
Control
</A>
<Show when={SLUGS_WITH_GRAPH.has(slug)}>
<A href={`/machines/${slug}/${serial}/graph`} class="sidebar-sublink">
Graph
</A>
</Show>
</div>
);
}}
</For>
</div>
</Show>
<div class="sidebar-footer">
<div class={`connection-dot ${props.mainState().machines !== null ? "connected" : "disconnected"}`} />
<span>{props.mainState().machines !== null ? "Connected" : "Connecting…"}</span>
</div>
</nav>
);
}
@@ -0,0 +1,147 @@
/**
* UPlotGraph — SolidJS wrapper around uPlot with a rolling time window.
*
* Design principles:
* - The uPlot instance is driven by a rAF loop, NOT by reactive effects.
* This caps redraws at the display refresh rate (~60 fps) regardless of
* how fast incoming data arrives (e.g. 10 Hz socket events).
* - The caller provides a `getSeriesData` callback that returns the current
* ring-buffer slices. The callback is called once per animation frame so
* it must be cheap (just a typed-array slice of the visible window).
* - `timeWindow` (seconds) controls how much history is visible. The x-axis
* scale is set to [latestTimestamp - timeWindow, latestTimestamp] every
* frame, giving the rolling-window effect.
* - Resize is handled by ResizeObserver → setSize on next rAF frame.
*/
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
import { onMount, onCleanup } from "solid-js";
export type HorizLine = {
value: number;
color: string;
dash?: boolean;
label?: string;
};
type Props = {
/** Called each animation frame. Return aligned data arrays (timestamps in seconds). */
getSeriesData: () => uPlot.AlignedData;
series: uPlot.Series[];
unit?: string;
height?: number;
/** Visible time window in seconds (default: 30 s). */
timeWindow?: number;
lines?: HorizLine[];
};
export default function UPlotGraph(props: Props) {
let container!: HTMLDivElement;
let uplot: uPlot | null = null;
let rafId = 0;
let pendingWidth = 0;
function buildOpts(width: number): uPlot.Options {
const height = props.height ?? 240;
const lines = props.lines ?? [];
return {
width,
height,
cursor: { sync: { key: "graphs" } },
scales: {
x: { time: true },
y: { auto: true },
},
axes: [
{
stroke: "#717898",
grid: { stroke: "#2e3148", width: 1 },
ticks: { stroke: "#2e3148" },
},
{
label: props.unit,
stroke: "#717898",
grid: { stroke: "#2e3148", width: 1 },
ticks: { stroke: "#2e3148" },
},
],
series: [
{},
...props.series,
],
hooks: lines.length > 0 ? {
drawSeries: [
(u) => {
const ctx = u.ctx;
ctx.save();
for (const line of lines) {
const yPos = Math.round(u.valToPos(line.value, "y", true));
ctx.beginPath();
ctx.strokeStyle = line.color;
ctx.lineWidth = 1.5;
if (line.dash) ctx.setLineDash([6, 3]);
ctx.moveTo(u.bbox.left, yPos);
ctx.lineTo(u.bbox.left + u.bbox.width, yPos);
ctx.stroke();
ctx.setLineDash([]);
if (line.label) {
ctx.fillStyle = line.color;
ctx.font = "11px Inter, system-ui, sans-serif";
ctx.fillText(line.label, u.bbox.left + 4, yPos - 4);
}
}
ctx.restore();
},
],
} : {},
};
}
function frame() {
if (!uplot) { rafId = requestAnimationFrame(frame); return; }
// Apply any pending resize first
if (pendingWidth > 0) {
uplot.setSize({ width: pendingWidth, height: props.height ?? 240 });
pendingWidth = 0;
}
const data = props.getSeriesData();
const times = data[0] as Float64Array | number[];
const n = times.length;
if (n > 0) {
const tMax = times[n - 1];
const tMin = tMax - (props.timeWindow ?? 30);
uplot.setData(data, false); // false = don't auto-scale x
uplot.setScale("x", { min: tMin, max: tMax });
}
rafId = requestAnimationFrame(frame);
}
onMount(() => {
const ro = new ResizeObserver(([entry]) => {
const w = Math.floor(entry.contentRect.width);
if (w === 0) return;
if (!uplot) {
uplot = new uPlot(buildOpts(w), props.getSeriesData(), container);
rafId = requestAnimationFrame(frame);
} else {
// Defer resize to the rAF loop to avoid mid-paint artifacts
pendingWidth = w;
}
});
ro.observe(container);
onCleanup(() => {
cancelAnimationFrame(rafId);
ro.disconnect();
uplot?.destroy();
uplot = null;
});
});
return <div ref={container} class="uplot-wrap" />;
}
+13
View File
@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+16
View File
@@ -0,0 +1,16 @@
/* @refresh reload */
import { render } from 'solid-js/web';
import 'solid-devtools';
import './index.css';
import App from './App';
const root = document.getElementById('root');
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
'Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?',
);
}
render(() => <App />, root!);
+27
View File
@@ -0,0 +1,27 @@
/**
* REST API client
*
* Thin wrapper — no react-query or custom hooks needed for simple mutations.
* SolidJS's createResource() handles data fetching with built-in loading/error states.
*/
export const BASE_URL = "http://localhost:3001";
export type MachineIdentificationUnique = {
machine_identification: { vendor: number; machine: number };
serial: number;
};
export async function mutateMachine(
machine_identification_unique: MachineIdentificationUnique,
data: unknown,
): Promise<void> {
const res = await fetch(`${BASE_URL}/api/v1/machine/mutate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machine_identification_unique, data }),
});
if (!res.ok) {
throw new Error(`Mutation failed: ${res.status} ${res.statusText}`);
}
}
+114
View File
@@ -0,0 +1,114 @@
/**
* SolidJS Socket.IO layer
*
* Key insight: SolidJS signals replace both Zustand + ThrottledStoreUpdater.
* - createSignal() is the store
* - batch() coalesces multiple updates into one DOM pass (no manual 30fps timer needed)
* - onCleanup() replaces useEffect cleanup
* - No lifecycle boilerplate per machine type
*
* The entire pattern collapses to: connect → on("event") → setSignal(data)
*/
import { io, Socket } from "socket.io-client";
import MsgPackParser from "socket.io-msgpack-parser";
import { createSignal, onCleanup, batch } from "solid-js";
import { z } from "zod";
export const BASE_URL = "http://localhost:3001";
// ---------------------------------------------------------------------------
// Generic event envelope (mirrors the Rust GenericEvent on the server)
// ---------------------------------------------------------------------------
export const genericEventSchema = z.object({
name: z.string(),
data: z.any(),
ts: z.number().int().positive(),
});
export type GenericEvent = z.infer<typeof genericEventSchema>;
// ---------------------------------------------------------------------------
// Namespace path helpers
// ---------------------------------------------------------------------------
export type NamespacePath = string; // e.g. "/main" or "/machine/1/2/100"
export function machineNamespacePath(
vendor: number,
machine: number,
serial: number,
): NamespacePath {
return `/machine/${vendor}/${machine}/${serial}`;
}
// ---------------------------------------------------------------------------
// createNamespace — the single primitive for all WebSocket connections
//
// Returns a reactive [data, socket] tuple. The caller provides:
// - path: the Socket.IO namespace path
// - handler: a function (event, setData) called on each incoming event
//
// Usage inside a SolidJS component or createRoot:
//
// const [state, _socket] = createNamespace("/main", (event, set) => {
// if (event.name === "MachinesEvent") set("machines", event.data);
// }, { machines: null });
//
// The socket disconnects automatically when the reactive scope is disposed
// (e.g. component unmounts, createRoot cleanup).
// ---------------------------------------------------------------------------
export function createNamespace<S extends object>(
path: NamespacePath,
handler: (event: GenericEvent, set: (key: keyof S, value: S[keyof S]) => void, setAll: (updater: (prev: S) => S) => void) => void,
initialState: S,
): [() => S, Socket] {
const [state, setState] = createSignal<S>(initialState);
const socket = io(BASE_URL + path, {
autoConnect: false,
parser: MsgPackParser,
});
// Helper: update a single key — uses batch internally for multi-key updates
const set = (key: keyof S, value: S[keyof S]) => {
setState((prev) => ({ ...prev, [key]: value }));
};
const setAll = (updater: (prev: S) => S) => {
setState((prev) => updater(prev));
};
socket.on("connect", () => {
console.log(`[socket.io] connected: ${path}`);
});
socket.on("disconnect", (reason) => {
console.warn(`[socket.io] disconnected: ${path} — ${reason}`);
// Reset state on disconnect so UI reflects stale/loading state
batch(() => setState(initialState));
});
socket.on("event", (raw: unknown) => {
const parsed = genericEventSchema.safeParse(raw);
if (!parsed.success) {
console.error("[socket.io] invalid event envelope:", parsed.error);
return;
}
// batch() ensures multiple set() calls in one handler only trigger one
// reactive update pass — replaces the ThrottledStoreUpdater entirely
batch(() => handler(parsed.data, set, setAll));
});
socket.connect();
// Automatically disconnect when the reactive scope is torn down
onCleanup(() => {
socket.disconnect();
console.log(`[socket.io] cleaned up: ${path}`);
});
return [state, socket];
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+65
View File
@@ -0,0 +1,65 @@
/**
* Main namespace — machine discovery & EtherCAT status
*
* Compare to electron/src/client/mainNamespace.ts (~170 lines + socketioStore boilerplate).
* This entire file is ~60 lines and contains the same information.
*/
import { createNamespace } from "../lib/socketio";
// ---------------------------------------------------------------------------
// Types (mirrors the Rust structs)
// ---------------------------------------------------------------------------
export type MachineObj = {
machine_identification_unique: {
machine_identification: { vendor: number; machine: number };
serial: number;
};
error: string | null;
};
export type EthercatDevicesEventData =
| { Initializing: true }
| { Done: { devices: EthercatDevice[] } }
| { Error: string };
export type EthercatDevice = {
configured_address: number;
name: string;
vendor_id: number;
product_id: number;
revision: number;
};
export type MainState = {
machines: MachineObj[] | null;
ethercatDevices: EthercatDevicesEventData | null;
ethercatInterface: string | null;
};
// ---------------------------------------------------------------------------
// Hook: useMainNamespace
//
// In React this required:
// mainNamespace.ts (~170 lines, Zod schemas, Zustand store, handler, hook)
// socketioStore.ts (createNamespaceHookImplementation, ThrottledStoreUpdater, etc.)
//
// In SolidJS: one createNamespace() call does it all.
// ---------------------------------------------------------------------------
export function createMainNamespace() {
return createNamespace<MainState>(
"/main",
(event, set) => {
if (event.name === "MachinesEvent") {
set("machines", event.data.machines);
} else if (event.name === "EthercatDevicesEvent") {
set("ethercatDevices", event.data);
} else if (event.name === "EthercatInterfaceDiscoveryEvent") {
if ("Done" in event.data) set("ethercatInterface", event.data.Done);
}
},
{ machines: null, ethercatDevices: null, ethercatInterface: null },
);
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Mock1 machine namespace
*
* Mock machine is vendor=1, machine=7 (MACHINE_MOCK = 0x0007).
* Events: StateEvent, LiveValuesEvent
* Mutations: SetFrequency1/2/3(f64), SetMode("Standby"|"Running")
*/
import { createSignal, onCleanup, batch } from "solid-js";
import { io } from "socket.io-client";
import MsgPackParser from "socket.io-msgpack-parser";
import { createNamespace, machineNamespacePath, BASE_URL, genericEventSchema } from "../lib/socketio";
export type Mode = "Standby" | "Running";
export type ModeState = {
mode: Mode;
};
export type Mock1State = {
// From StateEvent
is_default_state: boolean | null;
frequency1: number | null; // millihertz
frequency2: number | null;
frequency3: number | null;
mode_state: ModeState | null;
// From LiveValuesEvent (updated at high frequency)
amplitude_sum: number | null;
amplitude1: number | null;
amplitude2: number | null;
amplitude3: number | null;
};
// Time-series buffer for graphing: parallel arrays of timestamps (s) and values
export type SeriesBuffer = {
times: Float64Array;
values: Float64Array;
length: number; // number of valid entries (wraps in ring)
head: number; // index of next write position
};
export type Mock1Series = {
amplitude_sum: SeriesBuffer;
amplitude1: SeriesBuffer;
amplitude2: SeriesBuffer;
amplitude3: SeriesBuffer;
};
export const VENDOR_QITECH = 1;
export const MACHINE_MOCK = 7;
// Maximum number of samples retained per series (~30 min at 10 Hz = 18 000)
const BUFFER_SIZE = 20_000;
function makeBuffer(): SeriesBuffer {
return {
times: new Float64Array(BUFFER_SIZE),
values: new Float64Array(BUFFER_SIZE),
length: 0,
head: 0,
};
}
function pushSample(buf: SeriesBuffer, t: number, v: number): SeriesBuffer {
buf.times[buf.head] = t;
buf.values[buf.head] = v;
buf.head = (buf.head + 1) % BUFFER_SIZE;
if (buf.length < BUFFER_SIZE) buf.length++;
return buf;
}
/** Returns sorted [times, values] arrays suitable for uPlot (oldest → newest). */
export function sortedSeries(buf: SeriesBuffer): [Float64Array, Float64Array] {
if (buf.length === 0) return [new Float64Array(0), new Float64Array(0)];
if (buf.length < BUFFER_SIZE) {
// Buffer not yet full — data runs from index 0 to length-1
return [buf.times.slice(0, buf.length), buf.values.slice(0, buf.length)];
}
// Ring buffer full — head points to oldest entry
const times = new Float64Array(BUFFER_SIZE);
const values = new Float64Array(BUFFER_SIZE);
const tail = buf.head; // oldest index
const firstLen = BUFFER_SIZE - tail;
times.set(buf.times.subarray(tail), 0);
times.set(buf.times.subarray(0, tail), firstLen);
values.set(buf.values.subarray(tail), 0);
values.set(buf.values.subarray(0, tail), firstLen);
return [times, values];
}
export function createMock1Namespace(serial: number) {
return createNamespace<Mock1State>(
machineNamespacePath(VENDOR_QITECH, MACHINE_MOCK, serial),
(event, set) => {
if (event.name === "StateEvent") {
set("is_default_state", event.data.is_default_state);
set("frequency1", event.data.frequency1);
set("frequency2", event.data.frequency2);
set("frequency3", event.data.frequency3);
set("mode_state", event.data.mode_state);
} else if (event.name === "LiveValuesEvent") {
set("amplitude_sum", event.data.amplitude_sum);
set("amplitude1", event.data.amplitude1);
set("amplitude2", event.data.amplitude2);
set("amplitude3", event.data.amplitude3);
}
},
{
is_default_state: null,
frequency1: null,
frequency2: null,
frequency3: null,
mode_state: null,
amplitude_sum: null,
amplitude1: null,
amplitude2: null,
amplitude3: null,
},
);
}
/**
* Creates a second socket connection to the same machine namespace but only
* buffers LiveValuesEvent data into ring buffers for graphing.
*
* Kept separate from createMock1Namespace so the control page doesn't pay the
* memory cost of the buffers, and the graph page manages its own connection
* lifecycle independently.
*
* Returns a reactive signal: each LiveValuesEvent triggers a new object so
* graph components can react. The buffers are mutated in-place for efficiency.
*/
export function createMock1SeriesBuffer(serial: number): [() => Mock1Series] {
const series: Mock1Series = {
amplitude_sum: makeBuffer(),
amplitude1: makeBuffer(),
amplitude2: makeBuffer(),
amplitude3: makeBuffer(),
};
const [tick, setTick] = createSignal<Mock1Series>(series, { equals: false });
const path = machineNamespacePath(VENDOR_QITECH, MACHINE_MOCK, serial);
const socket = io(BASE_URL + path, { autoConnect: false, parser: MsgPackParser });
socket.on("event", (raw: unknown) => {
const parsed = genericEventSchema.safeParse(raw);
if (!parsed.success) return;
const event = parsed.data;
if (event.name !== "LiveValuesEvent") return;
const t = event.ts / 1000; // backend sends ms → convert to seconds for uPlot
batch(() => {
pushSample(series.amplitude_sum, t, event.data.amplitude_sum ?? 0);
pushSample(series.amplitude1, t, event.data.amplitude1 ?? 0);
pushSample(series.amplitude2, t, event.data.amplitude2 ?? 0);
pushSample(series.amplitude3, t, event.data.amplitude3 ?? 0);
setTick(series);
});
});
socket.connect();
onCleanup(() => socket.disconnect());
return [tick];
}
@@ -0,0 +1,28 @@
/**
* Test machine namespace
*
* Compare to electron/src/machines/testmachine/testMachineNamespace.ts (~77 lines)
* This is ~25 lines including the types.
*/
import { createNamespace, machineNamespacePath } from "../lib/socketio";
export type TestMachineState = {
led_on: boolean[] | null;
};
export function createTestMachineNamespace(
vendor: number,
machine: number,
serial: number,
) {
return createNamespace<TestMachineState>(
machineNamespacePath(vendor, machine, serial),
(event, set) => {
if (event.name === "StateEvent") {
set("led_on", event.data.led_on);
}
},
{ led_on: null },
);
}
@@ -0,0 +1,72 @@
/**
* Machines overview page — lists all detected machines with navigation links.
* Uses the main namespace signal directly; no intermediate store or hook.
*/
import { For, Show, createMemo } from "solid-js";
import { A } from "@solidjs/router";
import type { MachineObj } from "../namespaces/main";
import type { MainState } from "../namespaces/main";
type Props = {
mainState: () => MainState;
};
function machineSlug(m: MachineObj): string {
const { vendor, machine } = m.machine_identification_unique.machine_identification;
// Map vendor_machine → URL slug (matches routes in App.tsx)
const knownMachines: Record<string, string> = {
"1_51": "testmachine", // 0x0033
"1_2": "winder2", // 0x0002
"1_4": "extruder2", // 0x0004
"1_6": "laser1", // 0x0006
"1_7": "mock1", // 0x0007
"1_8": "buffer1", // 0x0008
"1_9": "aquapath1", // 0x0009
"1_10": "wago_power1", // 0x000A
"1_22": "extruder3", // 0x0016
};
return knownMachines[`${vendor}_${machine}`] ?? `unknown-${vendor}-${machine}`;
}
export default function MachinesPage(props: Props) {
const machines = createMemo(() => props.mainState().machines);
return (
<div class="page">
<h1>Machines</h1>
<Show when={machines() === null} fallback={
<Show
when={(machines()?.length ?? 0) > 0}
fallback={<p class="muted">No machines detected.</p>}
>
<div class="machine-list">
<For each={machines()!}>
{(m) => {
const slug = machineSlug(m);
const serial = m.machine_identification_unique.serial;
return (
<div class={`machine-card ${m.error ? "error" : "ok"}`}>
<div class="machine-info">
<strong>{slug}</strong>
<span class="serial">#{serial}</span>
<Show when={m.error}>
<span class="error-badge">{m.error}</span>
</Show>
</div>
<div class="machine-links">
<A href={`/machines/${slug}/${serial}`}>Overview</A>
<A href={`/machines/${slug}/${serial}/control`}>Control</A>
</div>
</div>
);
}}
</For>
</div>
</Show>
}>
<p class="muted">Connecting to server…</p>
</Show>
</div>
);
}
@@ -0,0 +1,236 @@
/**
* Mock1 machine control page
*
* Demonstrates:
* - High-frequency live values with inline sparklines (MiniGraph)
* - Optimistic state updates for frequency + mode controls
* - Mode toggle (Standby / Running)
* - Numeric input with default value indicator
*
* Compare to electron/src/machines/mock/mock1/Mock1ControlPage.tsx +
* useMock.ts (~200 lines combined). This file replaces both.
*/
import { Show, createSignal, createMemo } from "solid-js";
import { useParams } from "@solidjs/router";
import { createMock1Namespace, createMock1SeriesBuffer, VENDOR_QITECH, MACHINE_MOCK } from "../namespaces/mock1";
import type { Mode, SeriesBuffer } from "../namespaces/mock1";
import { mutateMachine } from "../lib/api";
import MiniGraph from "../components/MiniGraph";
export default function Mock1ControlPage() {
const params = useParams<{ serial: string }>();
const serial = () => parseInt(params.serial);
const [state] = createMock1Namespace(serial());
const [getSeries] = createMock1SeriesBuffer(serial());
// Optimistic state: prefer local update until server confirms
const [optimisticFreq1, setOptimisticFreq1] = createSignal<number | null>(null);
const [optimisticFreq2, setOptimisticFreq2] = createSignal<number | null>(null);
const [optimisticFreq3, setOptimisticFreq3] = createSignal<number | null>(null);
const [optimisticMode, setOptimisticMode] = createSignal<Mode | null>(null);
const freq1 = () => optimisticFreq1() ?? state().frequency1 ?? 1000;
const freq2 = () => optimisticFreq2() ?? state().frequency2 ?? 2000;
const freq3 = () => optimisticFreq3() ?? state().frequency3 ?? 5000;
const mode = () => optimisticMode() ?? state().mode_state?.mode ?? "Standby";
const isLoaded = createMemo(() => state().frequency1 !== null);
const machineId = () => ({
machine_identification: { vendor: VENDOR_QITECH, machine: MACHINE_MOCK },
serial: serial(),
});
async function setFrequency(
index: 1 | 2 | 3,
value: number,
setOptimistic: (v: number) => void,
resetOptimistic: () => void,
) {
setOptimistic(value);
try {
await mutateMachine(machineId(), { [`SetFrequency${index}`]: value });
} catch (e) {
console.error(`SetFrequency${index} failed:`, e);
resetOptimistic();
}
}
async function setMode(newMode: Mode) {
setOptimisticMode(newMode);
try {
await mutateMachine(machineId(), { SetMode: newMode });
} catch (e) {
console.error("SetMode failed:", e);
setOptimisticMode(null);
}
}
return (
<div class="page">
<h1>Mock Machine — Control</h1>
<p class="serial">Serial: #{serial()}</p>
<Show when={!isLoaded()} fallback={null}>
<p class="muted">Waiting for machine state…</p>
</Show>
<Show when={isLoaded()}>
<div class="control-grid">
{/* Live values with sparklines */}
<div class="control-card live-values-card">
<div class="control-card-title">Live Values</div>
<div class="live-values">
<LiveValueRow
label="Sum"
value={state().amplitude_sum}
getBuffer={() => getSeries().amplitude_sum}
stroke="#6c8cff"
/>
<LiveValueRow
label="Wave 1"
value={state().amplitude1}
getBuffer={() => getSeries().amplitude1}
stroke="#6c8cff"
/>
<LiveValueRow
label="Wave 2"
value={state().amplitude2}
getBuffer={() => getSeries().amplitude2}
stroke="#f472b6"
/>
<LiveValueRow
label="Wave 3"
value={state().amplitude3}
getBuffer={() => getSeries().amplitude3}
stroke="#34d399"
/>
</div>
</div>
{/* Frequencies */}
<div class="control-card">
<div class="control-card-title">Frequencies (mHz)</div>
<div class="freq-grid">
<FrequencyInput
label="Frequency 1"
value={freq1()}
defaultValue={state().is_default_state ? null : state().frequency1}
onChange={(v) => setFrequency(1, v, setOptimisticFreq1, () => setOptimisticFreq1(null))}
/>
<FrequencyInput
label="Frequency 2"
value={freq2()}
defaultValue={state().is_default_state ? null : state().frequency2}
onChange={(v) => setFrequency(2, v, setOptimisticFreq2, () => setOptimisticFreq2(null))}
/>
<FrequencyInput
label="Frequency 3"
value={freq3()}
defaultValue={state().is_default_state ? null : state().frequency3}
onChange={(v) => setFrequency(3, v, setOptimisticFreq3, () => setOptimisticFreq3(null))}
/>
</div>
</div>
{/* Mode */}
<div class="control-card">
<div class="control-card-title">Mode</div>
<div class="mode-selector">
<button
class={`mode-btn ${mode() === "Standby" ? "active" : ""}`}
onClick={() => setMode("Standby")}
>
Standby
</button>
<button
class={`mode-btn ${mode() === "Running" ? "active running" : ""}`}
onClick={() => setMode("Running")}
>
Running
</button>
</div>
</div>
</div>
</Show>
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function LiveValueRow(props: {
label: string;
value: number | null;
getBuffer: () => SeriesBuffer;
stroke: string;
}) {
return (
<div class="live-value-row-spark">
<div class="live-value-left">
<span class="live-value-label">{props.label}</span>
<span class="live-value-number">
<Show when={props.value !== null} fallback={<span class="muted">–</span>}>
{props.value!.toFixed(3)}
</Show>
</span>
</div>
<div class="live-value-spark">
<MiniGraph getBuffer={props.getBuffer} stroke={props.stroke} timeWindow={10} />
</div>
</div>
);
}
function FrequencyInput(props: {
label: string;
value: number;
defaultValue: number | null | undefined;
onChange: (v: number) => void;
}) {
const [editValue, setEditValue] = createSignal<string | null>(null);
const displayValue = () => editValue() ?? props.value.toFixed(0);
function commit() {
const v = parseFloat(editValue() ?? "");
if (!isNaN(v) && v >= 0 && v <= 100_000) {
props.onChange(v);
}
setEditValue(null);
}
return (
<div class="freq-input-group">
<label class="freq-label">{props.label}</label>
<div class="freq-input-row">
<input
class="freq-input"
type="number"
min="0"
max="100000"
step="100"
value={displayValue()}
onInput={(e) => setEditValue(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
/>
<Show when={props.defaultValue != null && props.defaultValue !== props.value}>
<button
class="freq-reset-btn"
title={`Reset to default (${props.defaultValue!.toFixed(0)})`}
onClick={() => props.onChange(props.defaultValue!)}
>
↺
</button>
</Show>
</div>
</div>
);
}
@@ -0,0 +1,156 @@
/**
* Mock1 graph page — rolling time-window uPlot charts.
*
* Two charts:
* 1. Amplitude Sum — single series + ±0.8 threshold lines
* 2. Individual Waves — amplitude1/2/3 overlaid
*
* Performance design:
* - UPlotGraph drives itself from a rAF loop; no reactive memos.
* - getSeriesData() slices only the visible window from the ring buffer on
* each frame rather than copying the entire buffer reactively.
* - The ring buffer itself is mutated in-place by the socket handler.
*/
import { createSignal } from "solid-js";
import { useParams } from "@solidjs/router";
import { createMock1SeriesBuffer } from "../namespaces/mock1";
import type { SeriesBuffer } from "../namespaces/mock1";
import UPlotGraph from "../components/UPlotGraph";
import type { HorizLine } from "../components/UPlotGraph";
import type uPlot from "uplot";
// Available time windows in seconds
const TIME_WINDOWS = [
{ label: "30 s", value: 30 },
{ label: "2 min", value: 120 },
{ label: "5 min", value: 300 },
{ label: "30 min", value: 1800 },
] as const;
/**
* Slice the ring buffer to only the entries within [tMax - windowSecs, tMax].
* Returns typed arrays already in chronological order.
* O(n) scan but n is at most BUFFER_SIZE and only called once per rAF frame.
*/
function windowedSlice(buf: SeriesBuffer, windowSecs: number): [Float64Array, Float64Array] {
const { times, values, length, head } = buf;
if (length === 0) return [new Float64Array(0), new Float64Array(0)];
const bufSize = times.length; // BUFFER_SIZE
const isFull = length === bufSize;
// Determine chronological start index in the ring
const startIdx = isFull ? head : 0;
const tMax = times[(head - 1 + bufSize) % bufSize]; // last written entry
const tMin = tMax - windowSecs;
// Count how many entries fall inside [tMin, tMax]
let count = 0;
for (let i = 0; i < length; i++) {
const t = times[(startIdx + i) % bufSize];
if (t >= tMin) count++;
}
const outTimes = new Float64Array(count);
const outValues = new Float64Array(count);
let out = 0;
for (let i = 0; i < length; i++) {
const idx = (startIdx + i) % bufSize;
const t = times[idx];
if (t >= tMin) {
outTimes[out] = t;
outValues[out] = values[idx];
out++;
}
}
return [outTimes, outValues];
}
export default function Mock1GraphPage() {
const params = useParams<{ serial: string }>();
const serial = () => parseInt(params.serial);
// createMock1SeriesBuffer opens its own socket; it disconnects on page unmount.
// We hold a plain JS reference to the series object — no signal dependency here.
// The rAF loop inside UPlotGraph reads from it directly every frame.
const [getSeriesTick] = createMock1SeriesBuffer(serial());
const [timeWindow, setTimeWindow] = createSignal(30); // seconds
// These callbacks are called once per animation frame by UPlotGraph.
// They capture `getSeriesTick` to always read from the latest ring buffers.
function getSumData(): uPlot.AlignedData {
const s = getSeriesTick();
const [t, v] = windowedSlice(s.amplitude_sum, timeWindow());
return [t, v];
}
function getAllWavesData(): uPlot.AlignedData {
const s = getSeriesTick();
const tw = timeWindow();
const [t, v1] = windowedSlice(s.amplitude1, tw);
const [, v2] = windowedSlice(s.amplitude2, tw);
const [, v3] = windowedSlice(s.amplitude3, tw);
return [t, v1, v2, v3];
}
const sumSeries: uPlot.Series[] = [
{ label: "Sum", stroke: "#6c8cff", width: 2 },
];
const allWavesSeries: uPlot.Series[] = [
{ label: "Wave 1", stroke: "#6c8cff", width: 1.5 },
{ label: "Wave 2", stroke: "#f472b6", width: 1.5 },
{ label: "Wave 3", stroke: "#34d399", width: 1.5 },
];
const sumLines: HorizLine[] = [
{ value: 0.8, color: "#6c8cff", dash: true, label: "threshold" },
{ value: -0.8, color: "#6c8cff", dash: true },
];
return (
<div class="page page-wide">
<h1>Mock Machine — Graph</h1>
<p class="serial">Serial: #{serial()}</p>
<div class="graph-toolbar">
<span class="graph-toolbar-label">Window</span>
{TIME_WINDOWS.map((w) => (
<button
class={`graph-window-btn${timeWindow() === w.value ? " active" : ""}`}
onClick={() => setTimeWindow(w.value)}
>
{w.label}
</button>
))}
</div>
<div class="graph-stack">
<div class="graph-card">
<div class="graph-card-title">Amplitude Sum</div>
<UPlotGraph
getSeriesData={getSumData}
series={sumSeries}
unit="amp"
height={260}
timeWindow={timeWindow()}
lines={sumLines}
/>
</div>
<div class="graph-card">
<div class="graph-card-title">Individual Waves</div>
<UPlotGraph
getSeriesData={getAllWavesData}
series={allWavesSeries}
unit="amp"
height={260}
timeWindow={timeWindow()}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
/**
* Test machine control page — LED toggle proof of concept
*
* This replaces:
* electron/src/machines/testmachine/TestMachineControlPage.tsx
* electron/src/machines/testmachine/useTestMachine.ts (~95 lines)
* electron/src/machines/testmachine/testMachineNamespace.ts (~77 lines)
*
* In SolidJS the entire machine data + mutation logic lives here or in a
* thin helper — no Zustand, no ThrottledStoreUpdater, no lifecycle hooks.
*
* Optimistic updates: SolidJS signals update synchronously, so the UI
* reflects the change immediately. The server will confirm via the next
* StateEvent pushed over WebSocket.
*/
import { For, Show, createSignal } from "solid-js";
import { useParams } from "@solidjs/router";
import { createTestMachineNamespace } from "../namespaces/testMachine";
import { mutateMachine } from "../lib/api";
// machine_identification for testmachine: vendor=1, machine=0x0033 (51)
const VENDOR = 1;
const MACHINE = 0x0033;
export default function TestMachineControlPage() {
const params = useParams<{ serial: string }>();
const serial = () => parseInt(params.serial);
// createNamespace() creates the socket and returns a reactive signal.
// The signal is updated whenever a StateEvent arrives.
// The socket is disconnected automatically when this component unmounts.
const [machineState] = createTestMachineNamespace(VENDOR, MACHINE, serial());
// Optimistic LED state — start from server state, update immediately on click
const [optimisticLeds, setOptimisticLeds] = createSignal<boolean[] | null>(null);
// Derive: prefer optimistic state, fall back to server state
const leds = () => optimisticLeds() ?? machineState().led_on;
const setLed = async (index: number, on: boolean) => {
// Optimistic update — instant UI feedback
const current = leds();
if (current) {
const next = [...current];
next[index] = on;
setOptimisticLeds(next);
}
try {
await mutateMachine(
{
machine_identification: { vendor: VENDOR, machine: MACHINE },
serial: serial(),
},
{ action: "SetLed", value: { index, on } },
);
} catch (e) {
console.error("SetLed failed:", e);
// Revert optimistic update on error
setOptimisticLeds(null);
}
};
const setAllLeds = async (on: boolean) => {
const current = leds();
if (current) {
setOptimisticLeds(current.map(() => on));
}
try {
await mutateMachine(
{
machine_identification: { vendor: VENDOR, machine: MACHINE },
serial: serial(),
},
{ action: "SetAllLeds", value: { on } },
);
} catch (e) {
console.error("SetAllLeds failed:", e);
setOptimisticLeds(null);
}
};
return (
<div class="page">
<h1>Test Machine — Control</h1>
<p class="serial">Serial: #{serial()}</p>
<Show when={leds() !== null} fallback={<p class="muted">Waiting for machine state…</p>}>
<div class="led-grid">
<For each={leds()!}>
{(on, i) => (
<button
class={`led-btn ${on ? "led-on" : "led-off"}`}
onClick={() => setLed(i(), !on)}
>
LED {i() + 1}
<span class="led-indicator">{on ? "●" : "○"}</span>
</button>
)}
</For>
</div>
<div class="led-actions">
<button onClick={() => setAllLeds(true)}>All On</button>
<button onClick={() => setAllLeds(false)}>All Off</button>
</div>
</Show>
</div>
);
}
+537
View File
@@ -0,0 +1,537 @@
/* ── Reset & base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #242736;
--border: #2e3148;
--accent: #6c8cff;
--accent-dim: #3b4fa8;
--text: #e2e4f0;
--text-muted: #717898;
--ok: #4ade80;
--error: #f87171;
--font: 'Inter', system-ui, sans-serif;
--radius: 8px;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
height: 100vh;
overflow: hidden;
}
#root { height: 100vh; }
/* ── App layout ── */
.app-layout {
display: flex;
height: 100vh;
}
.app-content {
flex: 1;
overflow-y: auto;
padding: 32px;
}
/* ── Sidebar ── */
.sidebar {
width: 220px;
min-width: 220px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 16px 0;
}
.sidebar-brand {
padding: 8px 16px 16px;
font-weight: 600;
font-size: 15px;
color: var(--accent);
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
.sidebar-section {
padding: 4px 0;
}
.sidebar-section-label {
padding: 8px 16px 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.sidebar-link {
display: block;
padding: 7px 16px;
color: var(--text);
text-decoration: none;
border-radius: 0;
transition: background 0.1s;
}
.sidebar-link:hover { background: var(--surface2); }
.sidebar-link.active { color: var(--accent); background: var(--surface2); }
.sidebar-sublink {
display: block;
padding: 4px 16px 4px 28px;
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
}
.sidebar-sublink:hover { color: var(--text); }
.sidebar-sublink.active { color: var(--accent); }
.sidebar-serial { color: var(--text-muted); font-size: 12px; }
.sidebar-footer {
margin-top: auto;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
border-top: 1px solid var(--border);
}
.connection-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.connection-dot.connected { background: var(--ok); }
.connection-dot.disconnected { background: var(--error); }
/* ── Pages ── */
.page {
max-width: 800px;
}
.page h1 {
font-size: 22px;
font-weight: 600;
margin-bottom: 20px;
}
.muted { color: var(--text-muted); }
.serial { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
/* ── Machine list ── */
.machine-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.machine-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.machine-card.error { border-color: var(--error); }
.machine-info {
display: flex;
align-items: center;
gap: 10px;
}
.machine-info strong { font-weight: 500; }
.error-badge {
background: #3b1c1c;
color: var(--error);
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
}
.machine-links {
display: flex;
gap: 8px;
}
.machine-links a {
padding: 5px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
text-decoration: none;
font-size: 13px;
}
.machine-links a:hover { background: var(--accent-dim); border-color: var(--accent); }
/* ── LED control ── */
.led-grid {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.led-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 24px;
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius);
color: var(--text);
cursor: pointer;
font-size: 14px;
transition: all 0.12s;
min-width: 90px;
}
.led-btn:hover { border-color: var(--accent-dim); }
.led-btn.led-on {
border-color: var(--ok);
background: #0d2b1a;
}
.led-indicator {
font-size: 20px;
}
.led-btn.led-on .led-indicator { color: var(--ok); }
.led-btn.led-off .led-indicator { color: var(--text-muted); }
.led-actions {
display: flex;
gap: 8px;
}
.led-actions button {
padding: 8px 20px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
font-size: 13px;
}
.led-actions button:hover {
background: var(--accent-dim);
border-color: var(--accent);
}
/* ── Control grid (used by machine control pages) ── */
.control-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.control-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.control-card-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 14px;
}
/* ── Live values ── */
.live-values {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Row with sparkline: [label + number] [graph] */
.live-value-row-spark {
display: flex;
align-items: center;
gap: 12px;
min-height: 64px;
}
.live-value-left {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 68px;
flex-shrink: 0;
}
.live-value-spark {
flex: 1;
min-width: 0;
height: 64px;
overflow: hidden;
}
.live-value-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.live-value-number {
font-variant-numeric: tabular-nums;
font-size: 15px;
font-weight: 500;
color: var(--text);
}
/* Card wide enough to show the sparklines nicely */
.live-values-card {
grid-column: span 2;
}
/* MiniGraph container */
.minigraph-wrap {
width: 100%;
display: block;
height: 64px;
overflow: hidden;
}
/* ── Frequency inputs ── */
.freq-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.freq-input-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.freq-label {
font-size: 12px;
color: var(--text-muted);
}
.freq-input-row {
display: flex;
gap: 6px;
align-items: center;
}
.freq-input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
color: var(--text);
font-size: 14px;
font-family: var(--font);
}
.freq-input:focus {
outline: none;
border-color: var(--accent);
}
.freq-reset-btn {
padding: 5px 8px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.freq-reset-btn:hover {
color: var(--text);
border-color: var(--accent-dim);
}
/* ── Mode selector ── */
.mode-selector {
display: flex;
gap: 8px;
}
.mode-btn {
flex: 1;
padding: 8px 16px;
background: var(--surface2);
border: 2px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
font-family: var(--font);
transition: all 0.12s;
}
.mode-btn:hover {
border-color: var(--accent-dim);
color: var(--text);
}
.mode-btn.active {
border-color: var(--accent);
color: var(--accent);
background: #1a2040;
}
.mode-btn.active.running {
border-color: var(--ok);
color: var(--ok);
background: #0d2b1a;
}
/* ── Graph page ── */
.page-wide {
max-width: 1100px;
}
.graph-stack {
display: flex;
flex-direction: column;
gap: 20px;
}
.graph-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.graph-card-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 12px;
}
/* ── Graph toolbar (time-window selector) ── */
.graph-toolbar {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 16px;
}
.graph-toolbar-label {
font-size: 12px;
color: var(--text-muted);
margin-right: 2px;
}
.graph-window-btn {
padding: 4px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
font-family: var(--font);
transition: all 0.1s;
}
.graph-window-btn:hover {
color: var(--text);
border-color: var(--accent-dim);
}
.graph-window-btn.active {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--text);
}
/* uPlot container — must be a block that fills its parent */
.uplot-wrap {
width: 100%;
display: block;
}
/* Hide the built-in title (we use .graph-card-title instead) */
.uplot-wrap .u-title {
display: none;
}
/* ── Legend table ── */
/* uPlot renders the legend as a <table>; our CSS reset strips table layout,
so we need to restore it explicitly. */
.uplot-wrap .u-legend {
display: table;
font-family: var(--font);
font-size: 12px;
color: var(--text-muted);
padding-top: 8px;
border-collapse: collapse;
width: 100%;
}
.uplot-wrap .u-legend tr {
display: table-row;
}
.uplot-wrap .u-legend th,
.uplot-wrap .u-legend td {
display: table-cell;
padding: 2px 8px 2px 0;
vertical-align: middle;
white-space: nowrap;
}
/* First row is the header (Time / series names) */
.uplot-wrap .u-legend .u-thead tr {
display: table-row;
}
/* Series colour swatch */
.uplot-wrap .u-legend .u-marker {
width: 12px;
height: 3px;
display: inline-block;
border-radius: 2px;
vertical-align: middle;
margin-right: 4px;
}
/* Cursor crosshair colour */
.uplot-wrap .u-cursor-x,
.uplot-wrap .u-cursor-y {
border-color: var(--text-muted) !important;
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
// General
"jsx": "preserve",
"jsxImportSource": "solid-js",
"target": "ESNext",
// Modules
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"isolatedModules": true,
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
// Type Checking & Safety
"strict": true,
"types": ["vite/client"]
}
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
import devtools from 'solid-devtools/vite';
export default defineConfig({
plugins: [devtools(), solidPlugin()],
server: {
port: 3000,
},
build: {
target: 'esnext',
},
});
+11 -2
View File
@@ -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