diff --git a/control-core/src/ethernet/mod.rs b/control-core/src/ethernet/mod.rs new file mode 100644 index 00000000..39ecd1e6 --- /dev/null +++ b/control-core/src/ethernet/mod.rs @@ -0,0 +1,65 @@ +use std::process::Command; + +use anyhow::Result; +use interfaces::Interface; + +pub mod modbus_tcp_discovery; + +/// Returns true if the given network interface is Ethernet. +/// Prevents testing wlan,loopback and other non ethernet devices +fn is_ethernet(interface: &str) -> std::io::Result { + #[cfg(target_os = "linux")] + { + let base_path = format!("/sys/class/net/{}", interface); + let type_path = std::path::Path::new(&base_path).join("type"); + let iface_type = std::fs::read_to_string(&type_path)?.trim().to_string(); + // 1 means Ethernet + let reports_as_ethernet = iface_type == "1"; + // Double-check that it's not a wireless interface + let uevent_path = std::path::Path::new(&base_path).join("uevent"); + let uevent = std::fs::read_to_string(&uevent_path).unwrap_or_default(); + // If "DEVTYPE=wlan" appears, it's lying + let actually_wifi = uevent.contains("DEVTYPE=wlan"); + Ok(reports_as_ethernet && !actually_wifi) + } + #[cfg(not(target_os = "linux"))] + return Ok(true); +} + +pub fn get_interfaces() -> Result> { + let vec = Interface::get_all() + .map_err(|e| anyhow::anyhow!("Failed to get network interfaces: {}", e))? + .into_iter() + .filter(|iface| { + iface.is_up() + && iface.is_running() + && !iface.is_loopback() + && is_ethernet(&iface.name).unwrap_or(false) + && !iface.name.starts_with("bridge") + && !iface.name.starts_with("utun") // Exclude tunnel interfaces + && !iface.name.starts_with("awdl") // Exclude Apple Wireless Direct Link + && !iface.name.starts_with("anpi") // Exclude Apple Network Privacy Interface + && !iface.name.starts_with("llw") // Exclude Apple low-latency WLAN + }) + .collect::>(); + Ok(vec) +} + +/// Sets a network interface to unmanaged by NetworkManager. +/// Returns true if the command succeeded. +pub fn set_interface_managed(interface: &str, managed: bool) -> bool { + let managed_str = match managed { + true => "yes", + false => "no", + }; + tracing::info!( + "set_interface_managed for {} managed was set to: {}", + interface, + managed_str + ); + let status = Command::new("nmcli") + .args(["dev", "set", interface, "managed", managed_str]) + .status(); + + matches!(status, Ok(s) if s.success()) +} diff --git a/control-core/src/ethernet/modbus_tcp_discovery.rs b/control-core/src/ethernet/modbus_tcp_discovery.rs new file mode 100644 index 00000000..54531d28 --- /dev/null +++ b/control-core/src/ethernet/modbus_tcp_discovery.rs @@ -0,0 +1,78 @@ +use crate::ethernet::get_interfaces; +use crate::{futures::FutureIteratorExt, modbus::tcp::ModbusTcpDevice}; +use anyhow::{Result, bail}; +use interfaces::Interface; +use std::cmp::min; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +pub struct ModbusTcpProbe { + pub addr: SocketAddr, + pub serial: u16, +} + +pub async fn probe_modbus_tcp() -> Vec { + let interfaces = match get_interfaces() { + Ok(x) => x, + Err(_) => return vec![], + }; + + interfaces + .into_iter() + .map(probe_modbus_tcp_addresses) + .join_all() + .await + .into_iter() + .flatten() + .collect() +} + +async fn probe_modbus_tcp_addresses(interface: Interface) -> Vec { + let out: Vec = interface + .addresses + .iter() + .filter_map(|addr| { + let a = addr.addr.map(|a| a.ip()); + let m = addr.mask.map(|a| a.ip()); + + match (a, m) { + (Some(IpAddr::V4(addr)), Some(IpAddr::V4(mask))) => Some((addr, mask)), + _ => None, + } + }) + .flat_map(|(addr, mask)| { + let a = u32::from(addr); + let m = u32::from(mask); + let network = a & m; + + let prefix = min(24, m.count_ones()); // mask → prefix length + let size = 1u32 << (32 - prefix); // number of addresses + + (200..size).map(move |i| SocketAddr::new(Ipv4Addr::from(network + i).into(), 502)) + }) + .map(|addr| smol::spawn(ping_modbus_device(addr))) + .join_all() + .await + .into_iter() + .filter_map(|x| x.ok()) + .collect(); + + out +} + +async fn ping_modbus_device(addr: SocketAddr) -> Result { + let mut device = ModbusTcpDevice::new(addr).await?; + + let module_number1 = device.get_u32(0x2).await?; + let module_number2 = device.get_u32(0x4).await?; + + if module_number1 != 0x2787_2144 || module_number2 != 0x0000_0000 { + bail!("Unknown modbus tcp device!"); + } + + let serial = device.get_u32(0x000A).await?; + + Ok(ModbusTcpProbe { + addr, + serial: serial as u16, + }) +} diff --git a/control-core/src/futures.rs b/control-core/src/futures.rs new file mode 100644 index 00000000..d94dbd13 --- /dev/null +++ b/control-core/src/futures.rs @@ -0,0 +1,26 @@ +use core::future::Future; + +// Extension trait for iterators of futures +pub trait FutureIteratorExt: Iterator + Sized +where + F: Future, +{ + /// Await all futures and return their outputs + fn join_all(self) -> impl Future>; +} + +impl FutureIteratorExt for I +where + I: Iterator + Sized, + F: Future, +{ + async fn join_all(self) -> Vec { + let mut out = Vec::new(); + + for fut in self { + out.push(fut.await); + } + + out + } +} diff --git a/control-core/src/lib.rs b/control-core/src/lib.rs index 1b1a5926..ed4ca884 100644 --- a/control-core/src/lib.rs +++ b/control-core/src/lib.rs @@ -2,6 +2,8 @@ pub mod controllers; pub mod converters; pub mod downcast; pub mod ethercat; +pub mod ethernet; +pub mod futures; pub mod helpers; pub mod irq_handling; pub mod modbus; diff --git a/control-core/src/modbus/mod.rs b/control-core/src/modbus/mod.rs index 2c4a1c4c..a753f3bd 100644 --- a/control-core/src/modbus/mod.rs +++ b/control-core/src/modbus/mod.rs @@ -1,4 +1,5 @@ pub mod modbus_serial_interface; +pub mod tcp; use anyhow::Error; use crc::{CRC_16_MODBUS, Crc}; diff --git a/control-core/src/modbus/tcp.rs b/control-core/src/modbus/tcp.rs new file mode 100644 index 00000000..adf14905 --- /dev/null +++ b/control-core/src/modbus/tcp.rs @@ -0,0 +1,263 @@ +use std::fmt::Debug; +use std::io::ErrorKind; +use std::net::SocketAddr; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use smol::Timer; +use smol::future::FutureExt; +use smol::io; +use smol::io::AsyncReadExt; +use smol::io::AsyncWriteExt; +use smol::net::TcpStream; + +const PROTOCOL_ID: u16 = 0; +const UNIT_ID: u8 = 0; +const READ_HOLDING_FUNCTION_CODE: u8 = 3; +const WRITE_HOLDING_FUNCTION_CODE: u8 = 16; +const READ_HOLDING_LENGTH: u16 = 6; // Unit ID 00 + Func Code 03 + Start Address XXXX + Quantity XXXX = 6 bytes +const WRITE_HOLDING_LENGTH_WITHOUT_DATA: u16 = 7; // Unit ID 00 + Func Code 16 + Start Address XXXX + Quantity XXXX + Num Data Bytes XX = 7 bytes + +struct Packet { + buf: Vec, +} + +impl Packet { + pub const fn new() -> Self { + Self { buf: Vec::new() } + } + + pub fn add_u8(&mut self, x: u8) { + self.buf.push(x); + } + + pub fn add_u16(&mut self, x: u16) { + let buf = x.to_be_bytes(); + self.buf.push(buf[0]); + self.buf.push(buf[1]); + } + + pub fn as_bytes(&self) -> &[u8] { + &self.buf + } +} + +pub struct ModbusTcpDevice { + stream: TcpStream, + transactions: u16, +} + +impl Debug for ModbusTcpDevice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.stream.peer_addr() { + Ok(addr) => write!(f, "ModbusTcpDevice({:?})", addr), + Err(_) => std::fmt::Result::Err(std::fmt::Error), + } + } +} + +impl ModbusTcpDevice { + pub async fn new(addr: SocketAddr) -> Result { + let timeout = async { + Timer::after(Duration::from_millis(10)).await; + Err(io::Error::from(ErrorKind::TimedOut)) + }; + + let stream = TcpStream::connect(addr) + .or(timeout) + .await + .context("Could not connect to modbus device!")?; + + Ok(Self { + stream, + transactions: 0, + }) + } + + async fn read_and_check_responce_header(&mut self, func_code: u8) -> Result<()> { + if self.read_u16().await? != self.transactions { + bail!("Modbus device sent unexpected transaction id!"); + } + + if self.read_u16().await? != PROTOCOL_ID { + bail!("Modbus device sent unexpected protocol id!"); + } + + let length = self.read_u16().await?; + if length < 2 { + bail!("Modbus device will send too few bytes to understand the response!"); + } + + if self.read_u8().await? != UNIT_ID { + bail!("Modbus device sent unexpected unit id!"); + } + + let func_code_res = self.read_u8().await?; + if func_code != func_code_res { + let error_code = self.read_u8().await?; + bail!( + "Modbus device sent unexpected function code 0x{:x}! It probably encountered an error. Error code: 0x{:x}", + func_code_res, + error_code + ); + } + + Ok(()) + } + + async fn send_read_holding_request(&mut self, addr: u16, count: u16) -> Result { + let mut packet = Packet::new(); + + self.transactions += 1; + packet.add_u16(self.transactions); + packet.add_u16(PROTOCOL_ID); + packet.add_u16(READ_HOLDING_LENGTH); + + packet.add_u8(UNIT_ID); + packet.add_u8(READ_HOLDING_FUNCTION_CODE); + + packet.add_u16(addr); + packet.add_u16(count); + + self.send(packet).await?; + self.read_and_check_responce_header(READ_HOLDING_FUNCTION_CODE) + .await?; + + let num_data_bytes = self.read_u8().await? as u16; + if count * 2 != num_data_bytes { + bail!( + "Modbus device wants to send only a portion of the requested range - UNIMPLEMENTED!" + ); + } + + Ok(num_data_bytes) + } + + pub async fn get_holding_registers(&mut self, addr: u16, count: u16) -> Result> { + self.send_read_holding_request(addr, count).await?; + + let mut res: Vec = Vec::new(); + while res.len() < count as usize { + res.push(self.read_u16().await?); + } + + Ok(res) + } + + pub async fn set_holding_registers(&mut self, addr: u16, values: &[u16]) -> Result<()> { + let count = values.len(); + let num_bytes = count * 2; + assert!( + count <= 128, + "Cannot send that many bytes to modbus device in one go! Trying to send {} bytes of data.", + num_bytes + ); + + let mut packet = Packet::new(); + + self.transactions += 1; + packet.add_u16(self.transactions); + packet.add_u16(PROTOCOL_ID); + packet.add_u16(WRITE_HOLDING_LENGTH_WITHOUT_DATA + num_bytes as u16); + + packet.add_u8(UNIT_ID); + packet.add_u8(WRITE_HOLDING_FUNCTION_CODE); + + packet.add_u16(addr); + packet.add_u16(count as u16); + + packet.add_u8(num_bytes as u8); + + for value in values { + packet.add_u16(value.to_owned()); + } + + self.send(packet).await?; + self.read_and_check_responce_header(WRITE_HOLDING_FUNCTION_CODE) + .await?; + + if self.read_u16().await? != addr { + bail!("Modbus device wrote to wrong register address!"); + } + + if self.read_u16().await? != count as u16 { + bail!("Modbus device wrote wrong number of registers!"); + } + + Ok(()) + } + + pub async fn get_string(&mut self, addr: u16) -> Result { + assert!(N % 2 == 0, "Strings are always of even length!"); + assert!(N <= 256, "Cannot read long strings in a single swoop!"); + let count = N / 2; + + self.send_read_holding_request(addr, count as u16).await?; + let buf = self.read_bytes::().await?; + + let s = String::from_utf8_lossy(&buf); + let s = match s.split_once('\0') { + None => s.to_string(), + Some((l, _)) => l.to_string(), + }; + + Ok(s) + } + + pub async fn set_string(&mut self, _addr: u16, s: &str) -> Result<()> { + assert!(s.len() <= N, "String is too long to fit!"); + + todo!() + } + + pub async fn get_u16(&mut self, addr: u16) -> Result { + let length = self.send_read_holding_request(addr, 1).await?; + assert_eq!( + length, 2, + "Expected modbus device to send exactly 2 bytes of data!" + ); + + self.read_u16().await + } + + pub async fn get_u32(&mut self, addr: u16) -> Result { + let length = self.send_read_holding_request(addr, 2).await?; + assert_eq!( + length, 4, + "Expected modbus device to send exactly 4 bytes of data!" + ); + + let buf = self.read_bytes::<4>().await?; + Ok(u32::from_be_bytes(buf)) + } + + // We have to send the request in a single packet, + // otherwise the device may fail to respond correctly. + async fn send(&mut self, packet: Packet) -> Result<()> { + self.stream + .write_all(packet.as_bytes()) + .await + .context("Could write to modbus device!") + } + + async fn read_u8(&mut self) -> Result { + let buf = self.read_bytes::<1>().await?; + Ok(u8::from_be_bytes(buf)) + } + + async fn read_u16(&mut self) -> Result { + let buf = self.read_bytes::<2>().await?; + Ok(u16::from_be_bytes(buf)) + } + + async fn read_bytes(&mut self) -> Result<[u8; N]> { + let mut buf = [0; N]; + self.stream + .read_exact(&mut buf) + .await + .context("Could not read from modbus device!")?; + Ok(buf) + } +} diff --git a/electron/src/control/units.tsx b/electron/src/control/units.tsx index fe2ba4d1..6af7a586 100644 --- a/electron/src/control/units.tsx +++ b/electron/src/control/units.tsx @@ -28,10 +28,9 @@ export function getUnitIcon(unit: Unit): IconName { case "Hz": return "lu:AudioWaveform"; case "W": - return "lu:Zap"; case "V": - return "lu:Zap"; case "A": + case "mA": return "lu:Zap"; case "kWh": return "lu:BatteryFull"; @@ -82,6 +81,8 @@ export function renderUnitSymbol(unit: Unit | undefined): string { return "W"; case "V": return "V"; + case "mA": + return "mA"; case "A": return "A"; case "kWh": @@ -155,6 +156,8 @@ export function renderUnitSymbolLong(unit: Unit): string { return "watts"; case "V": return "volts"; + case "mA": + return "milliamperes"; case "A": return "amperes"; case "kWh": @@ -192,6 +195,7 @@ export const units = [ "Hz", "W", "V", + "mA", "A", "kWh", "l/min", diff --git a/electron/src/machines/properties.ts b/electron/src/machines/properties.ts index 9186028e..0e48e5f4 100644 --- a/electron/src/machines/properties.ts +++ b/electron/src/machines/properties.ts @@ -462,6 +462,17 @@ export const testmachine: MachineProperties = { }, ], }; +export const wagoPower1: MachineProperties = { + name: "WAGO Power", + version: "V1", + slug: "wago_power1", + icon: "lu:PlugZap", + machine_identification: { + vendor: VENDOR_QITECH, + machine: 0x000a, + }, + device_roles: [], +}; export const analogInputTestMachine: MachineProperties = { name: "AnalogTest", @@ -529,6 +540,7 @@ export const machineProperties: MachineProperties[] = [ laser1, mock1, buffer1, + wagoPower1, aquapath1, testmachine, analogInputTestMachine, diff --git a/electron/src/machines/wago_power/wago_power1/WagoPower1ControlPage.tsx b/electron/src/machines/wago_power/wago_power1/WagoPower1ControlPage.tsx new file mode 100644 index 00000000..4bf9f406 --- /dev/null +++ b/electron/src/machines/wago_power/wago_power1/WagoPower1ControlPage.tsx @@ -0,0 +1,67 @@ +import { Page } from "@/components/Page"; +import React from "react"; +import { useWagoPower1 } from "./useWagoPower1"; +import { ControlGrid } from "@/control/ControlGrid"; +import { ControlCard } from "@/control/ControlCard"; +import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue"; +import { SelectionGroup } from "@/control/SelectionGroup"; +import { Mode } from "./wagoPower1Namespace"; + +export function WagoPower1ControlPage() { + const { + state, + + voltage, + current, + + setMode, + + isLoading, + isDisabled, + } = useWagoPower1(); + + return ( + + + + value.toFixed(2)} + /> + value.toFixed(2)} + /> + + + + value={state?.mode} + disabled={isDisabled} + loading={isLoading} + onChange={setMode} + orientation="vertical" + className="grid h-full grid-cols-2 gap-2" + options={{ + Off: { + children: "Off", + icon: "lu:PowerOff", + isActiveClassName: "bg-gray-600", + className: "h-full", + }, + On24V: { + children: "On", + icon: "lu:Power", + isActiveClassName: "bg-green-600", + className: "h-full", + }, + }} + /> + + + + ); +} diff --git a/electron/src/machines/wago_power/wago_power1/WagoPower1Page.tsx b/electron/src/machines/wago_power/wago_power1/WagoPower1Page.tsx new file mode 100644 index 00000000..3df7d301 --- /dev/null +++ b/electron/src/machines/wago_power/wago_power1/WagoPower1Page.tsx @@ -0,0 +1,20 @@ +import { Topbar } from "@/components/Topbar"; +import { wagoPower1SerialRoute } from "@/routes/routes"; +import React from "react"; + +export function WagoPower1Page() { + const { serial } = wagoPower1SerialRoute.useParams(); + return ( + + ); +} diff --git a/electron/src/machines/wago_power/wago_power1/useWagoPower1.ts b/electron/src/machines/wago_power/wago_power1/useWagoPower1.ts new file mode 100644 index 00000000..0cfe72b3 --- /dev/null +++ b/electron/src/machines/wago_power/wago_power1/useWagoPower1.ts @@ -0,0 +1,104 @@ +import { toastError } from "@/components/Toast"; +import { wagoPower1 } from "@/machines/properties"; +import { MachineIdentificationUnique } from "@/machines/types"; +import { wagoPower1SerialRoute } from "@/routes/routes"; +import { useEffect, useMemo } from "react"; +import { + Mode, + modeSchema, + StateEvent, + useWagoPower1Namespace, +} from "./wagoPower1Namespace"; +import { useStateOptimistic } from "@/lib/useStateOptimistic"; +import { produce } from "immer"; +import z from "zod"; +import { useMachineMutate } from "@/client/useClient"; + +export function useWagoPower1() { + const { serial: serialString } = wagoPower1SerialRoute.useParams(); + + // Memoize the machine identification to keep it stable between renders + 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: wagoPower1.machine_identification, + serial, + }; + }, [serialString]); + + const { request: requestModeSet } = useMachineMutate( + z.object({ SetMode: modeSchema }), + ); + + const { state, defaultState, voltage, current } = useWagoPower1Namespace( + machineIdentification, + ); + // + // Single optimistic state for all state management + const stateOptimistic = useStateOptimistic(); + + // Update optimistic state when real state changes + useEffect(() => { + if (state) { + stateOptimistic.setReal(state); + } + }, [state]); + + // Helper function for optimistic updates using produce + const updateStateOptimistically = ( + producer: (current: StateEvent) => void, + serverRequest: () => void, + ) => { + const currentState = stateOptimistic.value; + if (currentState && !stateOptimistic.isOptimistic) { + stateOptimistic.setOptimistic(produce(currentState, producer)); + } + serverRequest(); + }; + + const setMode = (mode: Mode) => { + updateStateOptimistically( + (current) => { + current.data.mode = mode; + }, + () => + requestModeSet({ + machine_identification_unique: machineIdentification, + data: { SetMode: mode }, + }), + ); + }; + + // Calculate loading states + const isLoading = stateOptimistic.isOptimistic; + const isDisabled = !stateOptimistic.isInitialized; + + return { + state: stateOptimistic.value?.data, + defaultState: defaultState?.data, + + current, + voltage, + + setMode, + + isLoading, + isDisabled, + }; +} diff --git a/electron/src/machines/wago_power/wago_power1/wagoPower1Namespace.ts b/electron/src/machines/wago_power/wago_power1/wagoPower1Namespace.ts new file mode 100644 index 00000000..623f30aa --- /dev/null +++ b/electron/src/machines/wago_power/wago_power1/wagoPower1Namespace.ts @@ -0,0 +1,135 @@ +import { StoreApi } from "zustand"; +import { create } from "zustand"; +import { z } from "zod"; +import { + EventHandler, + eventSchema, + Event, + handleUnhandledEventError, + NamespaceId, + createNamespaceHookImplementation, + ThrottledStoreUpdater, +} from "../../../client/socketioStore"; +import { MachineIdentificationUnique } from "@/machines/types"; +import { useMemo } from "react"; +import { + createTimeSeries, + TimeSeries, + TimeSeriesValue, +} from "@/lib/timeseries"; + +export const liveValuesEventSchema = eventSchema( + z.object({ + voltage: z.number(), + current: z.number(), + }), +); + +export type LiveValuesEvent = z.infer; + +export const modeSchema = z.enum(["Off", "On24V"]); +export type Mode = z.infer; + +export const stateEventSchema = eventSchema( + z.object({ + mode: modeSchema, + is_default_state: z.boolean(), + }), +); + +export type StateEvent = z.infer; + +export type WagoPower1NamespaceStore = { + state: StateEvent | null; + defaultState: StateEvent | null; + + current: TimeSeries; + voltage: TimeSeries; +}; + +const { initialTimeSeries: voltage, insert: addVoltage } = createTimeSeries(); +const { initialTimeSeries: current, insert: addCurrent } = createTimeSeries(); + +const createWagoPower1NamespaceStore = (): StoreApi => + create(() => { + return { + state: null, + defaultState: null, + voltage, + current, + }; + }); + +function wagoPower1MessageHandler( + store: StoreApi, + throttledUpdater: ThrottledStoreUpdater, +): EventHandler { + return (event: Event) => { + const eventName = event.name; + + // Helper function to update store through buffer + const updateStore = ( + updater: (state: WagoPower1NamespaceStore) => WagoPower1NamespaceStore, + ) => { + throttledUpdater.updateWith(updater); + }; + + try { + if (eventName === "StateEvent") { + const stateEvent = stateEventSchema.parse(event); + + updateStore((state) => ({ + ...state, + state: stateEvent, + // only set default state if is_default_state is true + defaultState: stateEvent.data.is_default_state + ? stateEvent + : state.defaultState, + })); + } else if (eventName === "LiveValuesEvent") { + console.log(event); + const liveValues = liveValuesEventSchema.parse(event); + const { voltage, current } = liveValues.data; + const voltageValue: TimeSeriesValue = { + value: voltage, + timestamp: liveValues.ts, + }; + const currentValue: TimeSeriesValue = { + value: current, + timestamp: liveValues.ts, + }; + updateStore((store: WagoPower1NamespaceStore) => ({ + ...store, + voltage: addVoltage(store.voltage, voltageValue), + current: addCurrent(store.current, currentValue), + })); + } else { + handleUnhandledEventError(eventName); + } + } catch (e) { + console.error(e); + } + }; +} + +const useWagoPower1NamespaceImplementation = + createNamespaceHookImplementation({ + createStore: createWagoPower1NamespaceStore, + createEventHandler: wagoPower1MessageHandler, + }); + +export function useWagoPower1Namespace( + machine_identification_unique: MachineIdentificationUnique, +): WagoPower1NamespaceStore { + // Generate namespace ID from validated machine ID + const namespaceId = useMemo( + () => ({ + type: "machine", + machine_identification_unique, + }), + [machine_identification_unique], + ); + + // Use the implementation with validated namespace ID + return useWagoPower1NamespaceImplementation(namespaceId); +} diff --git a/electron/src/machines/winder/winder2/winder2Namespace.ts b/electron/src/machines/winder/winder2/winder2Namespace.ts index 40454d0a..995656a5 100644 --- a/electron/src/machines/winder/winder2/winder2Namespace.ts +++ b/electron/src/machines/winder/winder2/winder2Namespace.ts @@ -1,8 +1,3 @@ -/** - * @file winder2Namespace.ts - * @description TypeScript implementation of Winder2 namespace with Zod schema validation. - */ - import { StoreApi } from "zustand"; import { create } from "zustand"; import { z } from "zod"; diff --git a/electron/src/routes/routes.tsx b/electron/src/routes/routes.tsx index e3a33209..c3e678b7 100644 --- a/electron/src/routes/routes.tsx +++ b/electron/src/routes/routes.tsx @@ -42,6 +42,7 @@ import { Buffer1SettingsPage } from "@/machines/buffer/buffer1/Buffer1Settings"; import { Laser1ControlPage } from "@/machines/laser/laser1/Laser1ControlPage"; import { Laser1GraphsPage } from "@/machines/laser/laser1/Laser1Graph"; import { Laser1Page } from "@/machines/laser/laser1/Laser1Page"; +import { Laser1PresetsPage } from "@/machines/laser/laser1/Laser1PresetsPage"; import { Mock1ControlPage } from "@/machines/mock/mock1/Mock1ControlPage"; import { Mock1GraphPage } from "@/machines/mock/mock1/Mock1Graph"; @@ -55,7 +56,7 @@ import { Aquapath1GraphPage } from "@/machines/aquapath/aquapath1/Aquapath1Graph import { TestMachinePage } from "@/machines/testmachine/TestMachinePage"; import { TestMachineControlPage } from "@/machines/testmachine/TestMachineControlPage"; -import { Laser1PresetsPage } from "@/machines/laser/laser1/Laser1PresetsPage"; + import { AnalogInputTestMachine } from "@/machines/analoginputtestmachine/AnalogInputTestMachinePage"; import { AnalogInputTestMachineControl } from "@/machines/analoginputtestmachine/AnalogInputTestMachineControlPage"; import { IP20TestMachinePage } from "@/machines/ip20testmachine/IP20TestMachinePage"; @@ -64,6 +65,9 @@ import { IP20TestMachineControlPage } from "@/machines/ip20testmachine/IP20TestM import { MetricsGraphsPage } from "@/metrics/MetricsGraphsPage"; import { MetricsControlPage } from "@/metrics/MetricsControlPage"; +import { WagoPower1Page } from "@/machines/wago_power/wago_power1/WagoPower1Page"; +import { WagoPower1ControlPage } from "@/machines/wago_power/wago_power1/WagoPower1ControlPage"; + // make a route tree like this // _mainNavigation/machines/winder2/$serial/control // _mainNavigation/configuration/a @@ -317,6 +321,18 @@ export const buffer1SettingsRoute = createRoute({ component: () => , }); +export const wagoPower1SerialRoute = createRoute({ + getParentRoute: () => machinesRoute, + path: "wago_power1/$serial", + component: () => , +}); + +export const wagoPower1ControlRoute = createRoute({ + getParentRoute: () => wagoPower1SerialRoute, + path: "control", + component: () => , +}); + export const setupRoute = createRoute({ getParentRoute: () => sidebarRoute, path: "setup", @@ -462,6 +478,8 @@ export const rootTree = RootRoute.addChildren([ mock1PresetsRoute, ]), + wagoPower1SerialRoute.addChildren([wagoPower1ControlRoute]), + buffer1SerialRoute.addChildren([buffer1ControlRoute]), ]), ]), diff --git a/machines/src/analog_input_test_machine/new.rs b/machines/src/analog_input_test_machine/new.rs index 7b46def5..c95278af 100644 --- a/machines/src/analog_input_test_machine/new.rs +++ b/machines/src/analog_input_test_machine/new.rs @@ -28,7 +28,7 @@ impl MachineNewTrait for AnalogInputTestMachine { MachineNewHardware::Ethercat(x) => x, _ => { return Err(anyhow::anyhow!( - "[{}::MachineNewTrait/TestMachine::new] MachineNewHardware is not Ethercat", + "[{}::EtherCATMachine/TestMachine::new] MachineNewHardware is not Ethercat", module_path!() )); } @@ -51,7 +51,7 @@ impl MachineNewTrait for AnalogInputTestMachine { api_sender: sender, machine_identification_unique: params.get_machine_identification_unique(), main_sender: params.main_thread_channel.clone(), - namespace: namespace, + namespace, last_measurement: Instant::now(), measurement_rate_hz: 1.0, diff --git a/machines/src/ip20_test_machine/new.rs b/machines/src/ip20_test_machine/new.rs index c04aeaac..0de3112f 100644 --- a/machines/src/ip20_test_machine/new.rs +++ b/machines/src/ip20_test_machine/new.rs @@ -30,7 +30,7 @@ impl MachineNewTrait for IP20TestMachine { MachineNewHardware::Ethercat(x) => x, _ => { return Err(anyhow::anyhow!( - "[{}::MachineNewTrait/IP20TestMachine::new] MachineNewHardware is not Ethercat", + "[{}::EtherCATMachine/IP20TestMachine::new] MachineNewHardware is not Ethercat", module_path!() )); } diff --git a/machines/src/lib.rs b/machines/src/lib.rs index 47a2e62d..29d39a5e 100644 --- a/machines/src/lib.rs +++ b/machines/src/lib.rs @@ -1,6 +1,6 @@ -use anyhow::Error; +use anyhow::{Error, Result}; use control_core::socketio::event::GenericEvent; -use control_core::socketio::namespace::Namespace; +use control_core::socketio::namespace::{CacheableEvents, Namespace, NamespaceCacheingLogic}; use ethercat_hal::devices::{ EthercatDevice, SubDeviceIdentityTuple, downcast_device, subdevice_identity_to_tuple, }; @@ -11,7 +11,7 @@ use machine_identification::{ DeviceIdentificationIdentified, MachineIdentificationUnique, }; use serde::Serialize; -use smol::channel::Sender; +use smol::channel::{Receiver, Sender}; use socketioxide::extract::SocketRef; use std::fmt::Debug; use std::{any::Any, sync::Arc, time::Instant}; @@ -28,6 +28,7 @@ pub mod mock; pub mod registry; pub mod serial; pub mod test_machine; +pub mod wago_power; pub mod winder2; pub const VENDOR_QITECH: u16 = 0x0001; @@ -35,9 +36,10 @@ pub const MACHINE_WINDER_V1: u16 = 0x0002; pub const MACHINE_EXTRUDER_V1: u16 = 0x0004; pub const MACHINE_LASER_V1: u16 = 0x0006; pub const MACHINE_MOCK: u16 = 0x0007; -pub const MACHINE_AQUAPATH_V1: u16 = 0x0009; #[cfg(not(feature = "mock-machine"))] pub const MACHINE_BUFFER_V1: u16 = 0x0008; +pub const MACHINE_AQUAPATH_V1: u16 = 0x0009; +pub const MACHINE_WAGO_POWER_V1: u16 = 0x000A; pub const MACHINE_EXTRUDER_V2: u16 = 0x0016; pub const TEST_MACHINE: u16 = 0x0033; pub const IP20_TEST_MACHINE: u16 = 0x0034; @@ -257,7 +259,7 @@ pub fn get_ethercat_device_by_index<'maindevice>( .clone()) } -pub trait MachineNewTrait { +pub trait MachineNewTrait: Machine { fn new(params: &MachineNewParams<'_, '_, '_, '_, '_, '_, '_>) -> Result where Self: Sized; @@ -293,7 +295,7 @@ pub trait MachineApi { fn api_event_namespace(&mut self) -> Option; } -pub trait Machine: MachineAct + MachineNewTrait + MachineApi + Any + Debug + Send + Sync { +pub trait Machine: MachineAct + MachineApi + Any + Debug + Send + Sync { fn get_machine_identification_unique(&self) -> MachineIdentificationUnique; fn get_main_sender(&self) -> Option>; } @@ -406,3 +408,126 @@ where Ok((device, subdevice)) } + +#[derive(Debug)] +pub struct MachineChannel { + api_receiver: Receiver, + api_sender: Sender, + machine_identification_unique: MachineIdentificationUnique, + main_sender: Option>, + namespace: Option, +} + +impl MachineChannel { + pub fn new(machine_identification_unique: MachineIdentificationUnique) -> Self { + let (sender, receiver) = smol::channel::unbounded(); + + Self { + api_sender: sender, + api_receiver: receiver, + machine_identification_unique, + main_sender: None, + namespace: None, + } + } +} + +impl NamespaceCacheingLogic for MachineChannel +where + E: CacheableEvents, +{ + fn emit(&mut self, events: E) { + let event = Arc::new(events.event_value()); + let cache_fn = events.event_cache_fn(); + + if let Some(ns) = &mut self.namespace { + ns.emit(event, &cache_fn); + } + } +} + +pub trait MachineWithChannel: Send + Debug + Sync { + fn get_machine_channel(&self) -> &MachineChannel; + fn get_machine_channel_mut(&mut self) -> &mut MachineChannel; + + fn on_namespace(&mut self) {} + + fn update(&mut self, now: std::time::Instant) -> Result<()>; + fn mutate(&mut self, value: Value) -> Result<()>; +} + +impl MachineApi for C +where + C: MachineWithChannel, +{ + fn api_get_sender(&self) -> Sender { + self.get_machine_channel().api_sender.clone() + } + + fn api_mutate(&mut self, value: Value) -> Result<()> { + let res = self.mutate(value); + + if let Err(ref e) = res { + tracing::error!("Machine errored while mutating: {}, ", e); + } + + res + } + + fn api_event_namespace(&mut self) -> Option { + self.get_machine_channel().namespace.clone() + } +} + +impl MachineAct for C +where + C: MachineWithChannel, +{ + fn act(&mut self, now: Instant) { + while let Ok(msg) = self.get_machine_channel_mut().api_receiver.try_recv() { + self.act_machine_message(msg); + } + + if let Err(e) = self.update(now) { + tracing::error!("Machine errored while updating: {}, ", e); + } + } + + fn act_machine_message(&mut self, msg: MachineMessage) { + let channel = self.get_machine_channel_mut(); + + match msg { + MachineMessage::SubscribeNamespace(namespace) => { + channel.namespace = Some(namespace); + self.on_namespace(); + } + MachineMessage::UnsubscribeNamespace => { + channel.namespace = None; + } + MachineMessage::HttpApiJsonRequest(value) => { + let _ = self.api_mutate(value); + } + MachineMessage::ConnectToMachine(_machine_connection) => { + todo!(); + } + MachineMessage::DisconnectMachine(_machine_connection) => { + todo!(); + } + } + } +} + +impl Machine for C +where + C: MachineWithChannel + 'static, +{ + fn get_machine_identification_unique(&self) -> MachineIdentificationUnique { + self.get_machine_channel() + .machine_identification_unique + .clone() + } + + fn get_main_sender(&self) -> Option> { + self.get_machine_channel().main_sender.clone() + } +} diff --git a/machines/src/registry.rs b/machines/src/registry.rs index 99d91808..e9c391ed 100644 --- a/machines/src/registry.rs +++ b/machines/src/registry.rs @@ -1,3 +1,5 @@ +use crate::analog_input_test_machine::AnalogInputTestMachine; +use crate::ip20_test_machine::IP20TestMachine; #[cfg(feature = "mock-machine")] use crate::{ extruder1::mock::ExtruderV2 as ExtruderV2Mock1, extruder2::mock::ExtruderV2 as ExtruderV2Mock2, @@ -5,8 +7,7 @@ use crate::{ }; use crate::{ - Machine, MachineNewParams, analog_input_test_machine::AnalogInputTestMachine, - ip20_test_machine::IP20TestMachine, machine_identification::MachineIdentification, + Machine, MachineNewParams, MachineNewTrait, machine_identification::MachineIdentification, }; #[cfg(not(feature = "mock-machine"))] @@ -44,7 +45,7 @@ impl MachineRegistry { } } - pub fn register( + pub fn register( &mut self, machine_identficiation: MachineIdentification, ) { @@ -122,7 +123,9 @@ lazy_static! { mc.register::(AquaPathV1::MACHINE_IDENTIFICATION); mc.register::(TestMachine::MACHINE_IDENTIFICATION); + mc.register::(IP20TestMachine::MACHINE_IDENTIFICATION); + mc.register::(AnalogInputTestMachine::MACHINE_IDENTIFICATION); mc diff --git a/machines/src/wago_power/mod.rs b/machines/src/wago_power/mod.rs new file mode 100644 index 00000000..3bb25c63 --- /dev/null +++ b/machines/src/wago_power/mod.rs @@ -0,0 +1,226 @@ +use crate::{MachineChannel, MachineWithChannel}; +use anyhow::Result; +use control_core::{ + modbus::tcp::ModbusTcpDevice, + socketio::{ + event::{BuildEvent, GenericEvent}, + namespace::{ + CacheFn, CacheableEvents, NamespaceCacheingLogic, cache_duration, + cache_first_and_last_event, + }, + }, +}; +use control_core_derive::BuildEvent; +use serde::*; +use smol::lock::Mutex; +use std::{ + net::SocketAddr, + time::{Duration, Instant}, +}; +use units::{ + electric_current::milliampere, + electric_potential::{millivolt, volt}, + *, +}; + +const MODBUS_DC_OFF: u16 = 0; +const MODBUS_DC_ON: u16 = 1; +const MODBUS_HICCUP_POWER: u16 = 1 << 8; + +#[derive(Serialize, Debug, Clone, BuildEvent)] +pub struct LiveValuesEvent { + voltage: f64, + current: f64, +} + +impl CacheableEvents for LiveValuesEvent { + fn event_value(&self) -> GenericEvent { + self.build().into() + } + + fn event_cache_fn(&self) -> CacheFn { + cache_duration(Duration::from_secs(60 * 60), Duration::from_secs(1)) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum Mode { + Off, + On24V, +} + +impl Mode { + pub fn as_u16(&self) -> u16 { + match self { + Mode::Off => MODBUS_DC_OFF, + Mode::On24V => MODBUS_HICCUP_POWER | MODBUS_DC_ON, + } + } +} + +#[derive(Serialize, Debug, Clone, BuildEvent)] +pub struct StateEvent { + mode: Mode, + is_default_state: bool, +} + +impl CacheableEvents for StateEvent { + fn event_value(&self) -> GenericEvent { + self.build().into() + } + + fn event_cache_fn(&self) -> CacheFn { + cache_first_and_last_event() + } +} + +#[derive(Deserialize, Serialize)] +pub enum Mutation { + SetMode(Mode), +} + +#[derive(Debug)] +pub struct WagoPower { + mode: Mode, + channel: MachineChannel, + #[cfg(not(feature = "mock-machine"))] + device: Mutex, + last_emit: Instant, + emitted_default_state: bool, +} + +impl WagoPower { + pub async fn new( + channel: MachineChannel, + #[cfg(not(feature = "mock-machine"))] addr: SocketAddr, + ) -> Result { + Ok(Self { + mode: Mode::Off, + channel, + #[cfg(not(feature = "mock-machine"))] + device: Mutex::new(ModbusTcpDevice::new(addr).await?), + last_emit: Instant::now(), + emitted_default_state: false, + }) + } + + #[cfg(feature = "mock-machine")] + fn get_live_values(&mut self) -> Result { + match self.mode { + Mode::Off => Ok(LiveValuesEvent { + voltage: 0.0, + current: 0.0, + }), + Mode::On24V => Ok(LiveValuesEvent { + voltage: 24.0, + current: 5000.0, + }), + } + } + + #[cfg(not(feature = "mock-machine"))] + fn get_live_values(&mut self) -> Result { + let electric = smol::block_on(async { + let mut dev = self.device.lock().await; + dev.get_holding_registers(0x0500, 2).await + })?; + + let voltage = ElectricPotential::new::(f64::from(electric[0])); + let current = ElectricCurrent::new::(f64::from(electric[1])); + + Ok(LiveValuesEvent { + voltage: voltage.get::(), + current: current.get::(), + }) + } + + fn emit_state(&mut self) { + let event = StateEvent { + mode: self.mode.clone(), + is_default_state: !self.emitted_default_state, + }; + self.channel.emit(event); + } + + fn set_mode(&mut self, mode: Mode) -> Result<()> { + self.mode = mode; + + #[cfg(not(feature = "mock-machine"))] + smol::block_on(self.transmit_voltage())?; + + self.emit_state(); + Ok(()) + } + + #[cfg(not(feature = "mock-machine"))] + async fn transmit_voltage(&mut self) -> Result<()> { + let mut dev = self.device.lock().await; + + let voltage = 24000; + let warning_threshold = 5000; // For now + let control_bits = self.mode.as_u16(); + let delay_ms = 100; // For now + + dev.set_holding_registers( + 0x0088, + &[voltage, warning_threshold, control_bits, delay_ms], + ) + .await?; + + Ok(()) + } + + #[cfg(feature = "mock-machine")] + pub async fn get_serial(&mut self) -> Result { + Ok(0xbeef) + } + + #[cfg(not(feature = "mock-machine"))] + pub async fn get_serial(&mut self) -> Result { + let mut dev = self.device.lock().await; + dev.get_u16(0x000B).await + } +} + +impl MachineWithChannel for WagoPower { + fn get_machine_channel(&self) -> &MachineChannel { + &self.channel + } + + fn get_machine_channel_mut(&mut self) -> &mut MachineChannel { + &mut self.channel + } + + fn mutate(&mut self, value: serde_json::Value) -> Result<()> { + let mutation: Mutation = serde_json::from_value(value)?; + + match mutation { + Mutation::SetMode(mode) => self.set_mode(mode)?, + } + + Ok(()) + } + + fn on_namespace(&mut self) { + self.emit_state(); + } + + fn update(&mut self, now: Instant) -> Result<()> { + if !self.emitted_default_state { + self.set_mode(Mode::Off)?; + + self.emit_state(); + self.emitted_default_state = true; + } + + if now.duration_since(self.last_emit) > Duration::from_secs_f64(1.0 / 30.0) { + if let Ok(event) = self.get_live_values() { + self.channel.emit(event); + } + + self.last_emit = now; + } + + Ok(()) + } +} diff --git a/machines/src/winder2/act.rs b/machines/src/winder2/act.rs index 899b48da..0033102b 100644 --- a/machines/src/winder2/act.rs +++ b/machines/src/winder2/act.rs @@ -4,8 +4,8 @@ use super::Winder2; use crate::{MachineAct, MachineMessage}; #[cfg(not(feature = "mock-machine"))] use std::time::{Duration, Instant}; -#[cfg(not(feature = "mock-machine"))] +#[cfg(not(feature = "mock-machine"))] impl MachineAct for Winder2 { fn act(&mut self, now: Instant) { let machine_message = self.api_receiver.try_recv(); diff --git a/nixos/os/configuration.nix b/nixos/os/configuration.nix index 69d21db5..c63bd745 100755 --- a/nixos/os/configuration.nix +++ b/nixos/os/configuration.nix @@ -108,6 +108,13 @@ in { networking.hostName = "nixos"; # Define your hostname. # networking.wireless.enable = true; # Enables wireless support via wpa_supplicant. + networking.interfaces.enp1s0.ipv4.addresses = [ + { + address = "10.10.10.1"; + prefixLength = 24; + } + ]; + # Configure network proxy if necessary # networking.proxy.default = "http://user:password@proxy:port/"; # networking.proxy.noProxy = "127.0.0.1,localhost,internal.domain"; @@ -314,9 +321,27 @@ in { # Enable the OpenSSH daemon. # services.openssh.enable = true; + services.dnsmasq = { + enable = true; + + settings = { + interface = "enp1s0"; # only this interface + bind-interfaces = true; + + # DHCP subnet + pool + dhcp-range = "10.10.10.50,10.10.10.250,255.255.255.0,12h"; + + # Default gateway (router) + dhcp-option = [ + "3,10.10.10.1" # option 3 = router + "6,1.1.1.1,8.8.8.8" # option 6 = DNS servers + ]; + }; + }; + # Open ports in the firewall. # networking.firewall.allowedTCPPorts = [ ... ]; - # networking.firewall.allowedUDPPorts = [ ... ]; + networking.firewall.allowedUDPPorts = [ 53 67 69 ]; # Or disable the firewall altogether. # networking.firewall.enable = false; diff --git a/server/src/app_state.rs b/server/src/app_state.rs index 98886650..c6820a2f 100644 --- a/server/src/app_state.rs +++ b/server/src/app_state.rs @@ -133,6 +133,8 @@ impl SharedState { let mut current_machines = self.current_machines_meta.lock().await; // Retain only machines that do not match the given ID current_machines.retain(|m| &m.machine_identification_unique != machine_id); + drop(current_machines); + tracing::info!( "remove_machine {:?} {:?}", self.current_machines_meta, @@ -154,6 +156,56 @@ impl SharedState { current_machines.push(machine); } } + drop(current_machines); + + self.send_machines_event().await; + } + + pub async fn report_machine_error( + &self, + machine_identification_unique: MachineIdentificationUnique, + error: String, + ) { + let mut current_machines = self.current_machines_meta.lock().await; + + for machine in current_machines.iter_mut() { + if machine.machine_identification_unique == machine_identification_unique { + machine.error = Some(error); + return; + } + } + + current_machines.push(MachineObj { + machine_identification_unique, + error: Some(error), + }); + } + + pub async fn add_machines(&self, machines: Vec>) { + let mut api_machines = self.api_machines.lock().await; + for machine in machines.iter() { + api_machines.insert( + machine.get_machine_identification_unique(), + machine.api_get_sender(), + ); + } + drop(api_machines); + + let objs = machines + .iter() + .map(|m| MachineObj { + machine_identification_unique: m.get_machine_identification_unique(), + error: None, + }) + .collect(); + self.add_machines_if_not_exists(objs).await; + + self.rt_machine_creation_channel + .send(HotThreadMessage::AddMachines(machines)) + .await + .expect("Could not send to real-time channel"); + + self.send_machines_event().await; } pub fn new( diff --git a/server/src/ethercat/setup.rs b/server/src/ethercat/setup.rs index 586cce8c..9826794c 100644 --- a/server/src/ethercat/setup.rs +++ b/server/src/ethercat/setup.rs @@ -481,9 +481,9 @@ pub async fn setup_loop( main_namespace.emit(MainNamespaceEvents::EthercatDevicesEvent(event)); } - return Ok(EthercatSetup { + Ok(EthercatSetup { devices, group: group_op, maindevice, - }); + }) } diff --git a/server/src/main.rs b/server/src/main.rs index 50a8a797..5a6668fe 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,4 +1,7 @@ -use crate::metrics::collector::{RuntimeMetricsConfig, spawn_runtime_metrics_sampler}; +use crate::{ + metrics::collector::{RuntimeMetricsConfig, spawn_runtime_metrics_sampler}, + socketio::main_namespace::machines_event::MachineObj, +}; use machines::{ AsyncThreadMessage, MachineConnection, MachineNewHardware, MachineNewHardwareSerial, MachineNewParams, SerialDevice, SerialDeviceIdentification, SerialDeviceNew, @@ -26,7 +29,6 @@ use smol::{ future, lock::RwLock, }; -use socketio::main_namespace::machines_event::MachineObj; use socketioxide::extract::SocketRef; use std::{collections::HashMap, sync::Arc, time::Duration}; @@ -38,6 +40,7 @@ use crate::{ ethercat_discovery_info::send_ethercat_found, init::find_ethercat_interface, setup::setup_loop, }, + modbus_tcp::start_modbus_tcp_discovery, socketio::queue::socketio_queue_worker, }; @@ -49,6 +52,7 @@ pub mod ethercat; pub mod logging; pub mod r#loop; pub mod metrics; +pub mod modbus_tcp; pub mod panic; pub mod performance_metrics; pub mod rest; @@ -80,7 +84,7 @@ pub async fn add_serial_device( .clone(); let new_machine = machine_registry.new_machine(&MachineNewParams { - device_group: &vec![device_identification_identified.clone()], + device_group: &vec![device_identification_identified], hardware: &MachineNewHardware::Serial(&hardware), socket_queue_tx, namespace: None, @@ -350,6 +354,8 @@ fn main() { #[cfg(not(feature = "mock-machine"))] smol::spawn(start_interface_discovery(app_state.clone(), sender)).detach(); + smol::spawn(start_modbus_tcp_discovery(app_state.clone())).detach(); + smol::block_on(async { send_empty_machines_event(app_state.clone()).await; send_ethercat_discovering(app_state.clone()).await; diff --git a/server/src/modbus_tcp/mod.rs b/server/src/modbus_tcp/mod.rs new file mode 100644 index 00000000..5c69d30a --- /dev/null +++ b/server/src/modbus_tcp/mod.rs @@ -0,0 +1,69 @@ +use crate::app_state::SharedState; +use control_core::ethernet::modbus_tcp_discovery::probe_modbus_tcp; +use control_core::futures::FutureIteratorExt; +use machines::{ + MACHINE_WAGO_POWER_V1, Machine, MachineChannel, VENDOR_QITECH, + machine_identification::{MachineIdentification, MachineIdentificationUnique}, + wago_power::WagoPower, +}; +use smol::Timer; +use std::sync::Arc; +use std::time::Duration; + +#[cfg(not(feature = "mock-machine"))] +pub async fn start_modbus_tcp_discovery(shared_state: Arc) { + loop { + let addresses = probe_modbus_tcp().await; + + if addresses.is_empty() { + Timer::after(Duration::from_secs(1)).await; + continue; + } + + let machines: Vec> = addresses + .into_iter() + .map(|probe| { + smol::spawn(async move { + let machine_identification_unique = MachineIdentificationUnique { + machine_identification: MachineIdentification { + vendor: VENDOR_QITECH, + machine: MACHINE_WAGO_POWER_V1, + }, + serial: probe.serial, + }; + + let channel = MachineChannel::new(machine_identification_unique); + let power = WagoPower::new(channel, probe.addr) + .await + .expect("Failed to initialize wago power supply"); + + Box::new(power) as Box + }) + }) + .join_all() + .await; + + shared_state.add_machines(machines).await; + return; + } +} + +#[cfg(feature = "mock-machine")] +pub async fn start_modbus_tcp_discovery(shared_state: Arc) { + let machine_identification_unique = MachineIdentificationUnique { + machine_identification: MachineIdentification { + vendor: VENDOR_QITECH, + machine: MACHINE_WAGO_POWER_V1, + }, + serial: 0xbeef, + }; + + let channel = MachineChannel::new(machine_identification_unique); + let power = WagoPower::new(channel) + .await + .expect("Failed to initialize wago power supply"); + + let machines: Vec> = vec![Box::new(power)]; + + shared_state.add_machines(machines).await; +} diff --git a/server/src/socketio/init.rs b/server/src/socketio/init.rs index 4940990a..7abf874a 100644 --- a/server/src/socketio/init.rs +++ b/server/src/socketio/init.rs @@ -98,7 +98,6 @@ fn setup_disconnection(socket: SocketRef, namespace_id: NamespaceId, app_state: None => tracing::info!("sender doesnt exist for: {}",ident), }; }else{ - } }) .detach(); @@ -147,11 +146,10 @@ fn setup_connection(socket: SocketRef, namespace_id: NamespaceId, app_state: Arc }, None => tracing::info!("sender doesnt exist for: {}",ident), }; - }else{ + } - } - } - Err(err) => { + } + Err(err) => { tracing::warn!( "Couldn't subscribe socket to namespace, disconnecting socket={:?} namespace={} error={:?}", socket_clone.id, diff --git a/units/src/electric_potential.rs b/units/src/electric_potential.rs index b2f008e4..19d98cba 100644 --- a/units/src/electric_potential.rs +++ b/units/src/electric_potential.rs @@ -11,6 +11,7 @@ quantity! { Z0, // amount of substance Z0>; // luminous intensity units { + @millivolt: 1.0e-3; "mV", "millivolt", "millivolts"; @centivolt: 1.0e-2; "cV", "centivolt", "centivolts"; @volt: 1.0; "V", "volt", "volts"; }