diff --git a/.gitignore b/.gitignore index d35455a9b..d3bf97f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ build.sh shell.nix node_modules/ runtime_metrics.csv -compile_metrics.csv \ No newline at end of file +compile_metrics.csv +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index cc8bf1aab..6b2873255 100644 --- a/README.md +++ b/README.md @@ -165,4 +165,4 @@ Available examples: - [x] [Ethercat Basics](./docs/ethercat-basics.md) -- [x] [NixOS Operating System](./docs/nixos/README.md) +- [x] [NixOS Operating System](./docs/nixos/README.md) \ No newline at end of file diff --git a/docs/assets/EL7031_0030.jpg b/docs/assets/EL7031_0030.jpg new file mode 100755 index 000000000..5d014e3cb Binary files /dev/null and b/docs/assets/EL7031_0030.jpg differ diff --git a/docs/assets/EL7031_0030_connected.jpg b/docs/assets/EL7031_0030_connected.jpg new file mode 100755 index 000000000..ad3efc00b Binary files /dev/null and b/docs/assets/EL7031_0030_connected.jpg differ diff --git a/docs/assets/UI_motor.png b/docs/assets/UI_motor.png new file mode 100644 index 000000000..349fec02e Binary files /dev/null and b/docs/assets/UI_motor.png differ diff --git a/docs/assets/UI_start_motor.png b/docs/assets/UI_start_motor.png new file mode 100644 index 000000000..c8ef53e04 Binary files /dev/null and b/docs/assets/UI_start_motor.png differ diff --git a/docs/assets/ethercat_beckhofff_ek110.jpg b/docs/assets/ethercat_beckhofff_ek110.jpg new file mode 100755 index 000000000..e07b941b3 Binary files /dev/null and b/docs/assets/ethercat_beckhofff_ek110.jpg differ diff --git a/docs/assets/machine_assigment_motor.png b/docs/assets/machine_assigment_motor.png new file mode 100644 index 000000000..a45ac0e05 Binary files /dev/null and b/docs/assets/machine_assigment_motor.png differ diff --git a/docs/assets/motor.jpg b/docs/assets/motor.jpg new file mode 100755 index 000000000..390f9b6e4 Binary files /dev/null and b/docs/assets/motor.jpg differ diff --git a/docs/assets/motor_wiring.jpg b/docs/assets/motor_wiring.jpg new file mode 100755 index 000000000..be0b0b21f Binary files /dev/null and b/docs/assets/motor_wiring.jpg differ diff --git a/docs/developer-docs/getting-started.md b/docs/developer-docs/getting-started.md index 7edfc4848..e5fa6b9b4 100644 --- a/docs/developer-docs/getting-started.md +++ b/docs/developer-docs/getting-started.md @@ -89,5 +89,6 @@ To get started with actual hardware, check out these step-by-step tutorials: - **[LED Control with EL2004](./minimal-example-el2004.md)** - Digital output control, the simplest possible hardware setup - **[Analog Input with EL3021](./minimal-example-el3021.md)** - Reading analog current measurements +- **[Stepper Motor Control with EL7031-0030](./minimal-example-el7031-motor.md)** - Complete motor control integration with velocity control These examples provide complete hardware wiring diagrams and software setup instructions. diff --git a/docs/developer-docs/minimal-example-el7031-motor.md b/docs/developer-docs/minimal-example-el7031-motor.md new file mode 100644 index 000000000..bed44fba1 --- /dev/null +++ b/docs/developer-docs/minimal-example-el7031-motor.md @@ -0,0 +1,236 @@ +# Minimal Example — Beckhoff EL7031-0030 Stepper Motor Integration +A complete hardware + software walkthrough + +--- + +## Table of Contents +1. [Introduction](#1-introduction) +2. [Requirements](#2-requirements) +3. [Hardware Setup](#3-hardware-setup) + - [3.2 EK1100 Wiring](#32-ek1100-wiring) + - [3.2.1 Safe Wiring Procedure](#321-safe-wiring-procedure-beckhoff-recommended) + - [3.2.2 Wiring (EL7031-0030)](#322-wiring-el7031-0030) + - [3.3 Safety Warning](#33--safety-warning) +4. [Software Setup](#4-software-setup) + - [4.1 Installing on Linux](#41-installing-on-linux-this-depends-on-your-distro) + - [4.2 Running the Backend](#42-running-the-backend) + - [4.3 Running the Frontend](#43-running-the-frontend) +5. [Demo](#5-demo) + - [5.1 Assigning Devices in the Dashboard](#51-assigning-devices-in-the-dashboard) +6. [Documentation](#6-documentation) +7. [Software Architecture](#7-software-architecture) + +--- + +## 1. Introduction + +This project documents the successful integration of a **Beckhoff EL7031-0030 Stepper Motor Terminal** into the QiTech control software stack. +The goal was to control a 24V stepper motor via EtherCAT using a custom Rust backend and a React/Electron frontend. + +--- + +## 2. Requirements + +### Software +- Rust toolchain +- Node.js + npm +- Git +- QiTech Control repository +- EtherCAT HAL (included inside repo) + +### Hardware +- **EtherCAT Master:** Linux PC +- **Bus Coupler:** EtherCAT Beckhoff EK1100 +- **Stepper Driver:** Beckhoff EL7031-0030 (THIS IS DIFFERENT FROM EL7031) +- **Motor:** Standard 4-wire Stepper Motor +- **Power Supply:** 24V DC +- **Ethernet Cable:** Standard Ethernet cable +- **Wiring Tools:** Screwdriver, wires + +--- + +## 3. Hardware Setup + +### 3.2 EK1100 Wiring + +This wiring configuration powers the EL7031-0030. +It is not the only possible wiring but is the **simplest functional setup**. + +#### ⚠️ Safety Warning +Always disconnect power before wiring. +Working on live EtherCAT terminals can cause serious damage or electrical shock. + +--- + +#### 3.2.1 Safe Wiring Procedure (Beckhoff Recommended) + +1. Insert a screwdriver **straight** into the square release hole. +2. Insert the stripped wire into the round opening. +3. Remove the screwdriver — the spring clamp locks the wire. + +![](../assets/wiring.png) + +--- + +We supply power using a **DC hollow-plug adapter**, like this one: +https://www.amazon.de/dp/B093FTFZ8Q + +Perform the following wiring on the EK1100: + +1. Red wire **(+24 V)** → Terminal **2** +2. Black wire **(0 V)** → Terminal **3** +3. Jumper wire from **Terminal 1 → Terminal 6** +4. Jumper wire from **Terminal 5 → Terminal 7** + +After wiring, your module should look like **Figure 1**. + +--- + +#### **Figure 1 — EK1100 Minimal Wiring** + + +--- + +#### **Figure 2 — EL7031-0030 Terminal** + + + +Slide the EL7031-0030 onto the right side of the EK1100 until it locks. +The EtherCAT E-Bus and power contacts connect automatically — **no wiring required**. + +--- + +#### **Figure 3 — Motor** + + +--- + +#### **Figure 4 — Motor Wiring** + + +Now the motor is wired via the pins 4, 5, 12, 13 on the EL7031-0030. + +--- + +#### **Figure 5 — EL7031 Integration Connected** + + + +That's what the pin should look like. + +--- + +### 3.2.2 Wiring (EL7031-0030) + +**Crucial:** The EL7031 requires two power sources: E-Bus (side contacts) for logic, and Front Terminal (6 & 14) for motor power. + +| Terminal Point | Function | Cable Color (Example) | +| :------------- | :--------------- | :-------------------- | +| **4** | Motor Coil A1 | Red | +| **12** | Motor Coil A2 | Blue | +| **5** | Motor Coil B1 | Green | +| **13** | Motor Coil B2 | Black | +| **6** | **Power +24V** | PSU Red (+) | +| **14** | **Power 0V/GND** | PSU Black (-) | + +--- + +### 3.3 ⚠️ Safety Warning + +- **Mandatory Power Supply:** Without 24V connected to **Pin 6 (+24V)** and **Pin 14 (GND)**, the terminal will remain in `PREOP` state or show a "Warning" LED (No Power). The motor will **not** move without this external supply. + +#### Risk of Destruction (Short Circuit): +- **Never** connect the 24V Power Supply to the Motor Output pins (**4, 5, 12, 13**). This will instantly destroy the terminal (causing pins 12 & 13 to glow red/burn). +- Ensure strict separation: **Pins 4/5/12/13 are for the MOTOR ONLY**. +- **Pins 6/14 are for POWER ONLY**. + +Also, see the documentation of the EtherCAT terminal ([EL7031-0030](https://download.beckhoff.com/download/document/io/ethercat-terminals/el7031-0030de.pdf)) for the different power modes (page 46 ff.) + +--- + +## 4. Software Setup + +### 4.1 Installing on Linux (this depends on your distro) + +Paste this into your terminal: + +```bash +# Press Enter when prompted +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +sudo apt update +sudo apt install -y npm nodejs git + +git clone git@github.com:qitechgmbh/control.git +cd control/electron +npm install +``` + +### 4.2 Running the Backend + +```bash +./cargo_run_linux.sh +``` + +This script: +- Builds the backend +- Grants required system capabilities (raw sockets) +- Starts EtherCAT communication + +### 4.3 Running the Frontend + +```bash +# move to the control directory +cd electron +npm run start +``` + +This launches the QiTech Control dashboard. + +--- + +## 5. Demo + +### 5.1 Assigning Devices in the Dashboard + +Once the backend + frontend are running, you should see: + +![](../assets/UI_start_motor.png) + +Make sure that under "Assign" → "Machine Assignment" +the correct serial number is selected (each device should have the same one, here it is 1). +Under "Machine" select your new Machine however you named it (I named it "TestMotor V1"), else it will detect that there is something connected to it but the connection won't work. + +![](../assets/machine_assigment_motor.png) + +**NOW THE MOTOR SHOULD TURN!** + +In the interface "TestMotor" on the left side, you can now control the motor (its state and its speed). + +![](../assets/UI_motor.png) + +--- + +## 6. Documentation + +Use the official documentation of the EL7031-0030 for more information: +[Beckhoff EL7031-0030 Documentation](https://download.beckhoff.com/download/document/io/ethercat-terminals/el7031-0030de.pdf) + +--- + +## 7. Software Architecture + +### Backend (Rust) +Located in [machines/src/motor_test_machine/](/machines/src/motor_test_machine/). + +1. **`mod.rs`**: Defines the `MotorTestMachine` struct and holds the state (driver wrapper, enabled state, target velocity). +2. **`api.rs`**: Handles incoming JSON commands from the frontend (Enable/Disable, Set Velocity) via WebSockets/SocketIO. +3. **`act.rs`**: The real-time control loop. It updates the `StepperVelocityEL70x1` driver wrapper in every cycle based on the current state. +4. **`new.rs`**: Initializes the hardware. + +### Frontend (TypeScript/React) +Located in [electron/src/machines/motor_test_machine/](/electron/src/machines/motor_test_machine/) and [electron/src/routes/routes.tsx](/electron/src/routes/routes.tsx). + +1. **`useTestMotor.ts`**: Custom hook managing the optimistic state and communication with the backend. +2. **`TestMotorControlPage.tsx`**: The UI using QiTech UI components (`ControlCard`, `EditValue`, `SelectionGroupBoolean`) to match the look and feel of the Winder2. +3. **Routing**: Integrated into `routes.tsx` using TanStack Router, ensuring the machine appears in the sidebar and navigation works correctly. diff --git a/electron/src/machines/laser/laser1/laser1Namespace.ts b/electron/src/machines/laser/laser1/laser1Namespace.ts index 31f68e14b..4e05fd9ea 100644 --- a/electron/src/machines/laser/laser1/laser1Namespace.ts +++ b/electron/src/machines/laser/laser1/laser1Namespace.ts @@ -14,7 +14,7 @@ import { NamespaceId, createNamespaceHookImplementation, ThrottledStoreUpdater, -} from "../../../client/socketioStore"; +} from "@/client/socketioStore"; import { MachineIdentificationUnique } from "@/machines/types"; import { createTimeSeries, diff --git a/electron/src/machines/motor_test_machine/TestMotorControlPage.tsx b/electron/src/machines/motor_test_machine/TestMotorControlPage.tsx new file mode 100644 index 000000000..792e208ce --- /dev/null +++ b/electron/src/machines/motor_test_machine/TestMotorControlPage.tsx @@ -0,0 +1,53 @@ +import { ControlCard } from "@/control/ControlCard"; +import { Page } from "@/components/Page"; +import React from "react"; +import { ControlGrid } from "@/control/ControlGrid"; +import { SelectionGroupBoolean } from "@/control/SelectionGroup"; +import { EditValue } from "@/control/EditValue"; +import { Label } from "@/control/Label"; +import { useTestMotor } from "./useTestMotor"; + +export function TestMotorControlPage() { + const { state, setMotorOn, setVelocity } = useTestMotor(); + + // Fallback in case state is still null + const safeState = state ?? { motor_enabled: false, motor_velocity: 0 }; + + return ( + + + {/* Basic control */} + + {/* On/Off switch */} + + + + {/* Velocity */} + + {/* Velocity input with unit */} + + + + + ); +} diff --git a/electron/src/machines/motor_test_machine/TestMotorPage.tsx b/electron/src/machines/motor_test_machine/TestMotorPage.tsx new file mode 100644 index 000000000..1d4d0cfb1 --- /dev/null +++ b/electron/src/machines/motor_test_machine/TestMotorPage.tsx @@ -0,0 +1,22 @@ +import { Topbar } from "@/components/Topbar"; +import { testMotorSerialRoute } from "@/routes/routes"; +import React from "react"; + +export function TestMotorPage() { + // Access is allowed here because this function is called later + const { serial } = testMotorSerialRoute.useParams(); + + return ( + + ); +} diff --git a/electron/src/machines/motor_test_machine/testMotorNamespace.ts b/electron/src/machines/motor_test_machine/testMotorNamespace.ts new file mode 100644 index 000000000..92bf038df --- /dev/null +++ b/electron/src/machines/motor_test_machine/testMotorNamespace.ts @@ -0,0 +1,73 @@ +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 (Must match StateEvent) ========== +export const stateEventDataSchema = z.object({ + motor_enabled: z.boolean(), + motor_velocity: z.number(), +}); + +export const stateEventSchema = eventSchema(stateEventDataSchema); +export type StateEvent = z.infer; + +// ========== Store ========== +export type TestMotorNamespaceStore = { + state: StateEvent | null; +}; + +export const createTestMotorNamespaceStore = + (): StoreApi => + create(() => ({ + state: null, + })); + +// ========== Message Handler ========== +export function testMotorMessageHandler( + chstore: StoreApi, + throttledUpdater: ThrottledStoreUpdater, +): EventHandler { + return (event: Event) => { + const updateStore = ( + updater: (state: TestMotorNamespaceStore) => TestMotorNamespaceStore, + ) => 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 useTestMotorNamespaceImplementation = + createNamespaceHookImplementation({ + createStore: createTestMotorNamespaceStore, + createEventHandler: testMotorMessageHandler, + }); + +export function useTestMotorNamespace( + machine_identification_unique: MachineIdentificationUnique, +): TestMotorNamespaceStore { + const namespaceId: NamespaceId = { + type: "machine", + machine_identification_unique, + }; + return useTestMotorNamespaceImplementation(namespaceId); +} diff --git a/electron/src/machines/motor_test_machine/useTestMotor.ts b/electron/src/machines/motor_test_machine/useTestMotor.ts new file mode 100644 index 000000000..568afea74 --- /dev/null +++ b/electron/src/machines/motor_test_machine/useTestMotor.ts @@ -0,0 +1,96 @@ +import { toastError } from "@/components/Toast"; +import { useStateOptimistic } from "@/lib/useStateOptimistic"; +import { testMotorSerialRoute } from "@/routes/routes"; +import { MachineIdentificationUnique } from "@/machines/types"; +import { useTestMotorNamespace, StateEvent } from "./testMotorNamespace"; +import { useMachineMutate } from "@/client/useClient"; +import { produce } from "immer"; +import { useEffect, useMemo } from "react"; +import { TestMotor } from "@/machines/properties"; +import { z } from "zod"; + +export function useTestMotor() { + const { serial: serialString } = testMotorSerialRoute.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: TestMotor.machine_identification, + serial, + }; + }, [serialString]); + + // Namespace state from backend + const { state } = useTestMotorNamespace(machineIdentification); + + // Optimistic state + const stateOptimistic = useStateOptimistic(); + + useEffect(() => { + if (state) stateOptimistic.setReal(state); + }, [state, stateOptimistic]); + + // Generic mutation sender + const { request: sendMutation } = useMachineMutate( + z.object({ + type: z.string(), + payload: z.any(), + }), + ); + + const updateStateOptimistically = ( + producer: (current: StateEvent) => void, + serverRequest?: () => void, + ) => { + const currentState = stateOptimistic.value; + if (currentState) + stateOptimistic.setOptimistic(produce(currentState, producer)); + serverRequest?.(); + }; + + // --- ACTIONS --- + + const setMotorOn = (on: boolean) => { + updateStateOptimistically( + (current) => { + current.motor_enabled = on; + }, + () => + sendMutation({ + machine_identification_unique: machineIdentification, + // Must exactly match the 'Mutation' enum! + data: { type: "SetMotorOn", payload: on }, + }), + ); + }; + + const setVelocity = (velocity: number) => { + updateStateOptimistically( + (current) => { + current.motor_velocity = velocity; + }, + () => + sendMutation({ + machine_identification_unique: machineIdentification, + data: { type: "SetMotorVelocity", payload: velocity }, + }), + ); + }; + + return { + state: stateOptimistic.value, + setMotorOn, + setVelocity, + }; +} diff --git a/electron/src/machines/properties.ts b/electron/src/machines/properties.ts index 0e48e5f43..ef55d38e9 100644 --- a/electron/src/machines/properties.ts +++ b/electron/src/machines/properties.ts @@ -533,6 +533,41 @@ export const ip20TestMachine: MachineProperties = { ], }; +export const TestMotor: MachineProperties = { + name: "TestMotor", + version: "V1", + slug: "testmotor", + icon: "lu:Disc3", + machine_identification: { + vendor: VENDOR_QITECH, + machine: 0x0011, + }, + device_roles: [ + { + role: 0, + role_label: "Bus Coupler", + allowed_devices: [ + { + vendor_id: 0x2, + product_id: 0x44c2c52, + revision: 0x120000, + }, + ], + }, + { + role: 1, + role_label: "Stepper Motor", + allowed_devices: [ + { + vendor_id: 0x2, + product_id: 0x1b773052, + revision: 0x10001e, + }, + ], + }, + ], +}; + export const machineProperties: MachineProperties[] = [ winder2, extruder3, @@ -545,6 +580,7 @@ export const machineProperties: MachineProperties[] = [ testmachine, analogInputTestMachine, ip20TestMachine, + TestMotor, ]; export const getMachineProperties = ( diff --git a/electron/src/routes/routes.tsx b/electron/src/routes/routes.tsx index 5486f2bf4..7eb8f979c 100644 --- a/electron/src/routes/routes.tsx +++ b/electron/src/routes/routes.tsx @@ -60,6 +60,8 @@ import { AnalogInputTestMachine } from "@/machines/analoginputtestmachine/Analog import { AnalogInputTestMachineControl } from "@/machines/analoginputtestmachine/AnalogInputTestMachineControlPage"; import { IP20TestMachinePage } from "@/machines/ip20testmachine/IP20TestMachinePage"; import { IP20TestMachineControlPage } from "@/machines/ip20testmachine/IP20TestMachineControlPage"; +import { TestMotorPage } from "@/machines/motor_test_machine/TestMotorPage"; +import { TestMotorControlPage } from "@/machines/motor_test_machine/TestMotorControlPage"; import { MetricsGraphsPage } from "@/metrics/MetricsGraphsPage"; import { MetricsControlPage } from "@/metrics/MetricsControlPage"; @@ -111,6 +113,18 @@ export const ip20TestMachineControlRoute = createRoute({ component: () => , }); +export const testMotorSerialRoute = createRoute({ + getParentRoute: () => machinesRoute, + path: "testmotor/$serial", + component: () => , +}); + +export const testMotorControlRoute = createRoute({ + getParentRoute: () => testMotorSerialRoute, + path: "control", + component: () => , +}); + export const sidebarRoute = createRoute({ getParentRoute: () => RootRoute, path: "_sidebar", @@ -433,6 +447,7 @@ export const rootTree = RootRoute.addChildren([ laser1GraphsRoute, laser1PresetsRoute, ]), + testMachineSerialRoute.addChildren([testMachineControlRoute]), analogInputTestMachineSerialRoute.addChildren([ @@ -441,6 +456,8 @@ export const rootTree = RootRoute.addChildren([ ip20TestMachineSerialRoute.addChildren([ip20TestMachineControlRoute]), + testMotorSerialRoute.addChildren([testMotorControlRoute]), + aquapath1SerialRoute.addChildren([ aquapath1ControlRoute, aquapath1GraphRoute, diff --git a/ethercat-hal/src/devices/el7031_0030/mod.rs b/ethercat-hal/src/devices/el7031_0030/mod.rs index f2aa4d0ab..2707ecf67 100644 --- a/ethercat-hal/src/devices/el7031_0030/mod.rs +++ b/ethercat-hal/src/devices/el7031_0030/mod.rs @@ -351,6 +351,7 @@ pub enum EL7031_0030AnalogInputPort { pub const EL7031_0030_VENDOR_ID: u32 = 0x2; pub const EL7031_0030_PRODUCT_ID: u32 = 0x1b773052; pub const EL7031_0030_REVISION_A: u32 = 0x10001E; + pub const EL7031_0030_IDENTITY_A: SubDeviceIdentityTuple = ( EL7031_0030_VENDOR_ID, EL7031_0030_PRODUCT_ID, diff --git a/machines/src/lib.rs b/machines/src/lib.rs index 2fd00c8e6..28e1064f3 100644 --- a/machines/src/lib.rs +++ b/machines/src/lib.rs @@ -27,6 +27,7 @@ pub mod ip20_test_machine; pub mod laser; pub mod machine_identification; pub mod mock; +pub mod motor_test_machine; pub mod registry; pub mod serial; pub mod test_machine; @@ -46,6 +47,8 @@ pub const TEST_MACHINE: u16 = 0x0033; pub const IP20_TEST_MACHINE: u16 = 0x0034; pub const ANALOG_INPUT_TEST_MACHINE: u16 = 0x0035; +pub const MOTOR_TEST_MACHINE: u16 = 0x0011; + use serde_json::Value; use smol::lock::RwLock; diff --git a/machines/src/motor_test_machine/act.rs b/machines/src/motor_test_machine/act.rs new file mode 100644 index 000000000..4ade65ddf --- /dev/null +++ b/machines/src/motor_test_machine/act.rs @@ -0,0 +1,40 @@ +use super::MotorTestMachine; +use crate::{MachineAct, MachineMessage}; +use std::time::Instant; + +impl MachineAct for MotorTestMachine { + fn act(&mut self, _now_ts: Instant) { + // println!("[{}::act] Running act", module_path!()); + if let Ok(msg) = self.api_receiver.try_recv() { + self.act_machine_message(msg); + } + + self.motor_driver.set_enabled(self.motor_state.enabled); + + if self.motor_state.enabled { + let steps_per_rev = 200.0; // Adjust to match motor + let steps_per_second = (self.motor_state.target_velocity as f64) * steps_per_rev / 60.0; + + let _ = self.motor_driver.set_speed(steps_per_second); + } else { + let _ = self.motor_driver.set_speed(0.0); + } + } + + fn act_machine_message(&mut self, msg: MachineMessage) { + match msg { + MachineMessage::SubscribeNamespace(ns) => { + self.namespace.namespace = Some(ns); + self.emit_state(); + } + MachineMessage::UnsubscribeNamespace => { + self.namespace.namespace = None; + } + MachineMessage::HttpApiJsonRequest(value) => { + use crate::MachineApi; + let _ = self.api_mutate(value); + } + _ => {} + } + } +} diff --git a/machines/src/motor_test_machine/api.rs b/machines/src/motor_test_machine/api.rs new file mode 100644 index 000000000..fc7a6a069 --- /dev/null +++ b/machines/src/motor_test_machine/api.rs @@ -0,0 +1,92 @@ +use super::MotorTestMachine; +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 smol::channel::Sender; +use std::sync::Arc; +use tracing::instrument; + +#[derive(Serialize, Debug, Clone)] +pub struct StateEvent { + pub motor_enabled: bool, + pub motor_velocity: i32, +} + +impl StateEvent { + pub fn build(&self) -> Event { + Event::new("StateEvent", self.clone()) + } +} + +pub enum BeckhoffEvents { + State(Event), +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(tag = "type", content = "payload")] +enum Mutation { + SetMotorOn(bool), + SetMotorVelocity(i32), + SetMotorOff(bool), +} + +#[derive(Debug, Clone)] +pub struct BeckhoffNamespace { + pub namespace: Option, +} + +impl NamespaceCacheingLogic for BeckhoffNamespace { + #[instrument(skip_all)] + fn emit(&mut self, events: BeckhoffEvents) { + 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 for BeckhoffEvents { + fn event_value(&self) -> GenericEvent { + match self { + BeckhoffEvents::State(event) => event.into(), + } + } + fn event_cache_fn(&self) -> CacheFn { + cache_first_and_last_event() + } +} + +impl MachineApi for MotorTestMachine { + fn api_get_sender(&self) -> Sender { + self.api_sender.clone() + } + fn api_event_namespace(&mut self) -> Option { + self.namespace.namespace.clone() + } + fn api_mutate(&mut self, request_body: Value) -> Result<(), anyhow::Error> { + let mutation: Mutation = serde_json::from_value(request_body)?; + match mutation { + Mutation::SetMotorOn(t) => { + if t { + self.turn_motor_on(); + } else { + self.turn_motor_off(); + } + } + Mutation::SetMotorVelocity(vel) => { + self.motor_state.target_velocity = vel; + self.emit_state(); + } + + _ => {} + } + Ok(()) + } +} diff --git a/machines/src/motor_test_machine/mod.rs b/machines/src/motor_test_machine/mod.rs new file mode 100644 index 000000000..81d131671 --- /dev/null +++ b/machines/src/motor_test_machine/mod.rs @@ -0,0 +1,64 @@ +use crate::machine_identification::{MachineIdentification, MachineIdentificationUnique}; +use crate::{AsyncThreadMessage, Machine, MachineMessage}; +use crate::{MOTOR_TEST_MACHINE, VENDOR_QITECH}; +use control_core::socketio::namespace::NamespaceCacheingLogic; +use ethercat_hal::io::stepper_velocity_el70x1::StepperVelocityEL70x1; +use smol::channel::{Receiver, Sender}; + +pub mod act; +pub mod api; +pub mod new; + +#[derive(Debug, Clone, PartialEq)] +pub struct MotorState { + pub enabled: bool, + pub target_velocity: i32, +} + +#[derive(Debug)] +pub struct MotorTestMachine { + api_receiver: Receiver, + api_sender: Sender, + main_sender: Option>, + machine_identification_unique: MachineIdentificationUnique, + namespace: api::BeckhoffNamespace, + + pub motor_driver: StepperVelocityEL70x1, + pub motor_state: MotorState, +} + +impl Machine for MotorTestMachine { + fn get_machine_identification_unique(&self) -> MachineIdentificationUnique { + self.machine_identification_unique.clone() + } + + fn get_main_sender(&self) -> Option> { + self.main_sender.clone() + } +} + +impl MotorTestMachine { + pub const MACHINE_IDENTIFICATION: MachineIdentification = MachineIdentification { + vendor: VENDOR_QITECH, + machine: MOTOR_TEST_MACHINE, + }; + + pub fn emit_state(&mut self) { + let event = api::StateEvent { + motor_enabled: self.motor_state.enabled, + motor_velocity: self.motor_state.target_velocity, + }; + self.namespace + .emit(api::BeckhoffEvents::State(event.build())); + } + + pub fn turn_motor_on(&mut self) { + self.motor_state.enabled = true; + self.emit_state(); + } + + pub fn turn_motor_off(&mut self) { + self.motor_state.enabled = false; + self.emit_state(); + } +} diff --git a/machines/src/motor_test_machine/new.rs b/machines/src/motor_test_machine/new.rs new file mode 100644 index 000000000..def41d0d4 --- /dev/null +++ b/machines/src/motor_test_machine/new.rs @@ -0,0 +1,91 @@ +use super::{MotorState, MotorTestMachine, api::BeckhoffNamespace}; +use crate::{ + MachineNewHardware, MachineNewParams, MachineNewTrait, get_ethercat_device, + validate_no_role_dublicates, validate_same_machine_identification_unique, +}; +use anyhow::Error; + +use ethercat_hal::coe::ConfigurableDevice; +use ethercat_hal::devices::ek1100::{EK1100, EK1100_IDENTITY_A}; +use ethercat_hal::devices::el7031_0030::coe::EL7031_0030Configuration; +use ethercat_hal::devices::el7031_0030::pdo::EL7031_0030PredefinedPdoAssignment; +use ethercat_hal::devices::el7031_0030::{ + EL7031_0030, EL7031_0030_IDENTITY_A, EL7031_0030StepperPort, +}; +use ethercat_hal::io::stepper_velocity_el70x1::StepperVelocityEL70x1; +use ethercat_hal::shared_config; +use ethercat_hal::shared_config::el70x1::{EL70x1OperationMode, StmMotorConfiguration}; + +impl MachineNewTrait for MotorTestMachine { + fn new<'maindevice>(params: &MachineNewParams) -> Result { + println!("[{}::new] Creating new MotorTestMachine", module_path!()); + let device_identification = params.device_group.iter().cloned().collect::>(); + validate_same_machine_identification_unique(&device_identification)?; + validate_no_role_dublicates(&device_identification)?; + + let hardware = match ¶ms.hardware { + MachineNewHardware::Ethercat(x) => x, + _ => return Err(anyhow::anyhow!("Hardware is not EtherCAT")), + }; + + smol::block_on(async { + // Role 0: EK1100 (Koppler) + let _ek1100 = + get_ethercat_device::(hardware, params, 0, vec![EK1100_IDENTITY_A]).await?; + + // Role 1: EL7031 (Stepper Motor) + let el7031 = { + let device = get_ethercat_device::( + hardware, + params, + 1, + vec![EL7031_0030_IDENTITY_A], + ) + .await?; + + let el7031_config = EL7031_0030Configuration { + stm_features: ethercat_hal::devices::el7031_0030::coe::StmFeatures { + operation_mode: EL70x1OperationMode::DirectVelocity, + speed_range: shared_config::el70x1::EL70x1SpeedRange::Steps1000, + ..Default::default() + }, + stm_motor: StmMotorConfiguration { + max_current: 1500, + ..Default::default() + }, + pdo_assignment: EL7031_0030PredefinedPdoAssignment::VelocityControlCompact, + ..Default::default() + }; + + device + .0 + .write() + .await + .write_config(&device.1, &el7031_config) + .await?; + + device.0 + }; + + let motor_driver = + StepperVelocityEL70x1::new(el7031.clone(), EL7031_0030StepperPort::STM1); + + let (sender, receiver) = smol::channel::unbounded(); + + Ok(Self { + main_sender: params.main_thread_channel.clone(), + api_receiver: receiver, + api_sender: sender, + machine_identification_unique: params.get_machine_identification_unique(), + namespace: BeckhoffNamespace { + namespace: params.namespace.clone(), + }, + motor_driver, + motor_state: MotorState { + enabled: true, + target_velocity: 100, + }, + }) + }) + } +} diff --git a/machines/src/registry.rs b/machines/src/registry.rs index e9c391ed2..19843e838 100644 --- a/machines/src/registry.rs +++ b/machines/src/registry.rs @@ -22,6 +22,7 @@ use crate::test_machine::TestMachine; use lazy_static::lazy_static; +use crate::motor_test_machine::MotorTestMachine; use anyhow::Error; use std::{any::TypeId, collections::HashMap}; @@ -128,6 +129,7 @@ lazy_static! { mc.register::(AnalogInputTestMachine::MACHINE_IDENTIFICATION); + mc.register::(MotorTestMachine::MACHINE_IDENTIFICATION); mc }; }