Implement Wago Power Supply Machine (#915)

* Add timeout to Modbus TCP connection

* Remove machine new trait from Machine trait

Machines should not need to know, how they where created.
Only in the machine registry do we assume we know the constructor.

* Initialize very basic WAGO machine

* Most basic WAGO power page

* Basic machine now known its namespace

* Display values from namespace

* Read live values from wago power supply

* Allow turning power on and off

* Allow setting mode

* Actually set registers correctly

* Show error if mutation fails

* Turn power on/off in UI

* Formatting

* Use actual serial number from device

* Fix loop logic

* Enable dhcp and dns server on lan interface

* Loop until modbus device has been found

* Format

* Fix time series creation

* Convert asserts into Err values

This is to make sure that the device cannot panic our backend.

---------

Co-authored-by: nixos-install.sh <nixos-install.sh@localhost>
Co-authored-by: Robin Krämer <93094049+kraemr@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-01-08 12:01:18 +01:00
1 parent 1406d6c724
commit 4d796f8422
27 files changed
+1327 -32

No files matched your search

+65
View File
@@ -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<bool> {
#[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<Vec<Interface>> {
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::<Vec<_>>();
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())
}
@@ -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<ModbusTcpProbe> {
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<ModbusTcpProbe> {
let out: Vec<ModbusTcpProbe> = 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<ModbusTcpProbe> {
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,
})
}
+26
View File
@@ -0,0 +1,26 @@
use core::future::Future;
// Extension trait for iterators of futures
pub trait FutureIteratorExt<F>: Iterator<Item = F> + Sized
where
F: Future,
{
/// Await all futures and return their outputs
fn join_all(self) -> impl Future<Output = Vec<F::Output>>;
}
impl<I, F> FutureIteratorExt<F> for I
where
I: Iterator<Item = F> + Sized,
F: Future,
{
async fn join_all(self) -> Vec<F::Output> {
let mut out = Vec::new();
for fut in self {
out.push(fut.await);
}
out
}
}
+2
View File
@@ -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;
+1
View File
@@ -1,4 +1,5 @@
pub mod modbus_serial_interface;
pub mod tcp;
use anyhow::Error;
use crc::{CRC_16_MODBUS, Crc};
+263
View File
@@ -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<u8>,
}
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<Self> {
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<u16> {
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<Vec<u16>> {
self.send_read_holding_request(addr, count).await?;
let mut res: Vec<u16> = 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<const N: usize>(&mut self, addr: u16) -> Result<String> {
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::<N>().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<const N: usize>(&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<u16> {
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<u32> {
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<u8> {
let buf = self.read_bytes::<1>().await?;
Ok(u8::from_be_bytes(buf))
}
async fn read_u16(&mut self) -> Result<u16> {
let buf = self.read_bytes::<2>().await?;
Ok(u16::from_be_bytes(buf))
}
async fn read_bytes<const N: usize>(&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)
}
}
+6 -2
View File
@@ -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",
+12
View File
@@ -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,
@@ -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 (
<Page>
<ControlGrid columns={2}>
<ControlCard title="Power">
<TimeSeriesValueNumeric
label="Voltage"
unit="V"
timeseries={voltage}
renderValue={(value) => value.toFixed(2)}
/>
<TimeSeriesValueNumeric
label="Current"
unit="mA"
timeseries={current}
renderValue={(value) => value.toFixed(2)}
/>
</ControlCard>
<ControlCard title="Mode">
<SelectionGroup<Mode>
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",
},
}}
/>
</ControlCard>
</ControlGrid>
</Page>
);
}
@@ -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 (
<Topbar
pathname={`/_sidebar/machines/wago_power1/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:PlugZap",
},
]}
/>
);
}
@@ -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<StateEvent>();
// 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,
};
}
@@ -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<typeof liveValuesEventSchema>;
export const modeSchema = z.enum(["Off", "On24V"]);
export type Mode = z.infer<typeof modeSchema>;
export const stateEventSchema = eventSchema(
z.object({
mode: modeSchema,
is_default_state: z.boolean(),
}),
);
export type StateEvent = z.infer<typeof stateEventSchema>;
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<WagoPower1NamespaceStore> =>
create<WagoPower1NamespaceStore>(() => {
return {
state: null,
defaultState: null,
voltage,
current,
};
});
function wagoPower1MessageHandler(
store: StoreApi<WagoPower1NamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<WagoPower1NamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
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<WagoPower1NamespaceStore>({
createStore: createWagoPower1NamespaceStore,
createEventHandler: wagoPower1MessageHandler,
});
export function useWagoPower1Namespace(
machine_identification_unique: MachineIdentificationUnique,
): WagoPower1NamespaceStore {
// Generate namespace ID from validated machine ID
const namespaceId = useMemo<NamespaceId>(
() => ({
type: "machine",
machine_identification_unique,
}),
[machine_identification_unique],
);
// Use the implementation with validated namespace ID
return useWagoPower1NamespaceImplementation(namespaceId);
}
@@ -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";
+19 -1
View File
@@ -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: () => <Buffer1SettingsPage />,
});
export const wagoPower1SerialRoute = createRoute({
getParentRoute: () => machinesRoute,
path: "wago_power1/$serial",
component: () => <WagoPower1Page />,
});
export const wagoPower1ControlRoute = createRoute({
getParentRoute: () => wagoPower1SerialRoute,
path: "control",
component: () => <WagoPower1ControlPage />,
});
export const setupRoute = createRoute({
getParentRoute: () => sidebarRoute,
path: "setup",
@@ -462,6 +478,8 @@ export const rootTree = RootRoute.addChildren([
mock1PresetsRoute,
]),
wagoPower1SerialRoute.addChildren([wagoPower1ControlRoute]),
buffer1SerialRoute.addChildren([buffer1ControlRoute]),
]),
]),
@@ -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,
+1 -1
View File
@@ -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!()
));
}
+131 -6
View File
@@ -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<Self, anyhow::Error>
where
Self: Sized;
@@ -293,7 +295,7 @@ pub trait MachineApi {
fn api_event_namespace(&mut self) -> Option<Namespace>;
}
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<Sender<AsyncThreadMessage>>;
}
@@ -406,3 +408,126 @@ where
Ok((device, subdevice))
}
#[derive(Debug)]
pub struct MachineChannel {
api_receiver: Receiver<MachineMessage>,
api_sender: Sender<MachineMessage>,
machine_identification_unique: MachineIdentificationUnique,
main_sender: Option<Sender<AsyncThreadMessage>>,
namespace: Option<Namespace>,
}
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<E> NamespaceCacheingLogic<E> for MachineChannel
where
E: CacheableEvents<E>,
{
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<C> MachineApi for C
where
C: MachineWithChannel,
{
fn api_get_sender(&self) -> Sender<MachineMessage> {
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<Namespace> {
self.get_machine_channel().namespace.clone()
}
}
impl<C> 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<C> 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<Sender<AsyncThreadMessage>> {
self.get_machine_channel().main_sender.clone()
}
}
+6 -3
View File
@@ -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<T: Machine + 'static>(
pub fn register<T: MachineNewTrait + 'static>(
&mut self,
machine_identficiation: MachineIdentification,
) {
@@ -122,7 +123,9 @@ lazy_static! {
mc.register::<AquaPathV1>(AquaPathV1::MACHINE_IDENTIFICATION);
mc.register::<TestMachine>(TestMachine::MACHINE_IDENTIFICATION);
mc.register::<IP20TestMachine>(IP20TestMachine::MACHINE_IDENTIFICATION);
mc.register::<AnalogInputTestMachine>(AnalogInputTestMachine::MACHINE_IDENTIFICATION);
mc
+226
View File
@@ -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<Self> 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<Self> 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<ModbusTcpDevice>,
last_emit: Instant,
emitted_default_state: bool,
}
impl WagoPower {
pub async fn new(
channel: MachineChannel,
#[cfg(not(feature = "mock-machine"))] addr: SocketAddr,
) -> Result<Self> {
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<LiveValuesEvent> {
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<LiveValuesEvent> {
let electric = smol::block_on(async {
let mut dev = self.device.lock().await;
dev.get_holding_registers(0x0500, 2).await
})?;
let voltage = ElectricPotential::new::<millivolt>(f64::from(electric[0]));
let current = ElectricCurrent::new::<milliampere>(f64::from(electric[1]));
Ok(LiveValuesEvent {
voltage: voltage.get::<volt>(),
current: current.get::<milliampere>(),
})
}
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<u16> {
Ok(0xbeef)
}
#[cfg(not(feature = "mock-machine"))]
pub async fn get_serial(&mut self) -> Result<u16> {
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(())
}
}
+1 -1
View File
@@ -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();
+26 -1
View File
@@ -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;
+52
View File
@@ -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<Box<dyn Machine>>) {
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(
+2 -2
View File
@@ -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,
});
})
}
+9 -3
View File
@@ -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;
+69
View File
@@ -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<SharedState>) {
loop {
let addresses = probe_modbus_tcp().await;
if addresses.is_empty() {
Timer::after(Duration::from_secs(1)).await;
continue;
}
let machines: Vec<Box<dyn Machine>> = 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<dyn Machine>
})
})
.join_all()
.await;
shared_state.add_machines(machines).await;
return;
}
}
#[cfg(feature = "mock-machine")]
pub async fn start_modbus_tcp_discovery(shared_state: Arc<SharedState>) {
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<Box<dyn Machine>> = vec![Box::new(power)];
shared_state.add_machines(machines).await;
}
+3 -5
View File
@@ -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,
+1
View File
@@ -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";
}