use std::collections::HashMap;
use crate::node_configuration::deserialize_pdo_map;
use crate::objects::{AccessType, ObjectCode, PdoMappable};
use crate::pdo::PdoMapping;
use serde::{de::Error, Deserialize};
use snafu::ResultExt as _;
use snafu::Snafu;
#[derive(Debug, Snafu)]
pub enum LoadError {
#[snafu(display("IO error: {source}"))]
Io {
source: std::io::Error,
},
#[snafu(display("Toml parse error: {source}"))]
TomlParsing {
source: toml::de::Error,
},
#[snafu(display("Multiple definitions for object with index 0x{id:x}"))]
DuplicateObjectIds {
id: u16,
},
#[snafu(display("Multiple definitions of sub index {sub} on object 0x{index:x}"))]
DuplicateSubObjects {
index: u16,
sub: u8,
},
}
fn mandatory_objects(config: &DeviceConfig) -> Vec<ObjectDefinition> {
let mut objects = vec![
ObjectDefinition {
index: 0x1000,
parameter_name: "Device Type".to_string(),
application_callback: false,
object: Object::Var(VarDefinition {
data_type: DataType::UInt32,
access_type: AccessType::Const.into(),
default_value: Some(DefaultValue::Integer(0x00000000)),
pdo_mapping: PdoMappable::None,
..Default::default()
}),
},
ObjectDefinition {
index: 0x1001,
parameter_name: "Error Register".to_string(),
application_callback: false,
object: Object::Var(VarDefinition {
data_type: DataType::UInt8,
access_type: AccessType::Ro.into(),
default_value: Some(DefaultValue::Integer(0x00000000)),
pdo_mapping: PdoMappable::None,
..Default::default()
}),
},
ObjectDefinition {
index: 0x1008,
parameter_name: "Manufacturer Device Name".to_string(),
application_callback: false,
object: Object::Var(VarDefinition {
data_type: DataType::VisibleString(config.device_name.len()),
access_type: AccessType::Const.into(),
default_value: Some(DefaultValue::String(config.device_name.clone())),
pdo_mapping: PdoMappable::None,
..Default::default()
}),
},
ObjectDefinition {
index: 0x1009,
parameter_name: "Manufacturer Hardware Version".to_string(),
application_callback: false,
object: Object::Var(VarDefinition {
data_type: DataType::VisibleString(config.hardware_version.len()),
access_type: AccessType::Const.into(),
default_value: Some(DefaultValue::String(config.hardware_version.clone())),
pdo_mapping: PdoMappable::None,
..Default::default()
}),
},
ObjectDefinition {
index: 0x100A,
parameter_name: "Manufacturer Software Version".to_string(),
application_callback: false,
object: Object::Var(VarDefinition {
data_type: DataType::VisibleString(config.software_version.len()),
access_type: AccessType::Const.into(),
default_value: Some(DefaultValue::String(config.software_version.clone())),
pdo_mapping: PdoMappable::None,
..Default::default()
}),
},
ObjectDefinition {
index: 0x1017,
parameter_name: "Heartbeat Producer Time (ms)".to_string(),
application_callback: false,
object: Object::Var(VarDefinition {
data_type: DataType::UInt16,
access_type: AccessType::Const.into(),
default_value: Some(DefaultValue::Integer(config.heartbeat_period as i64)),
pdo_mapping: PdoMappable::None,
persist: false,
}),
},
ObjectDefinition {
index: 0x1018,
parameter_name: "Identity".to_string(),
application_callback: false,
object: Object::Record(RecordDefinition {
subs: vec![
SubDefinition {
sub_index: 1,
parameter_name: "Vendor ID".to_string(),
field_name: Some("vendor_id".into()),
data_type: DataType::UInt32,
access_type: AccessType::Const.into(),
default_value: Some(DefaultValue::Integer(
config.identity.vendor_id as i64,
)),
pdo_mapping: PdoMappable::None,
..Default::default()
},
SubDefinition {
sub_index: 2,
parameter_name: "Product Code".to_string(),
field_name: Some("product_code".into()),
data_type: DataType::UInt32,
access_type: AccessType::Const.into(),
default_value: Some(DefaultValue::Integer(
config.identity.product_code as i64,
)),
pdo_mapping: PdoMappable::None,
..Default::default()
},
SubDefinition {
sub_index: 3,
parameter_name: "Revision Number".to_string(),
field_name: Some("revision".into()),
data_type: DataType::UInt32,
access_type: AccessType::Const.into(),
default_value: Some(DefaultValue::Integer(
config.identity.revision_number as i64,
)),
pdo_mapping: PdoMappable::None,
..Default::default()
},
SubDefinition {
sub_index: 4,
parameter_name: "Serial Number".to_string(),
field_name: Some("serial".into()),
data_type: DataType::UInt32,
access_type: AccessType::Const.into(),
default_value: Some(DefaultValue::Integer(0)),
pdo_mapping: PdoMappable::None,
..Default::default()
},
],
}),
},
];
let (create_autostart, default) = match config.autostart {
AutoStartConfig::Disabled => (true, 0),
AutoStartConfig::Enabled => (true, 1),
AutoStartConfig::Unsupported => (false, 0),
};
if create_autostart {
objects.push(ObjectDefinition {
index: 0x5000,
parameter_name: "Auto Start".to_string(),
application_callback: false,
object: Object::Var(VarDefinition {
data_type: DataType::UInt8,
access_type: AccessType::Rw.into(),
default_value: Some(DefaultValue::Integer(default)),
pdo_mapping: PdoMappable::None,
persist: true,
}),
});
}
objects
}
fn pdo_objects(num_rpdo: usize, num_tpdo: usize) -> Vec<ObjectDefinition> {
let mut objects = Vec::new();
fn add_objects(objects: &mut Vec<ObjectDefinition>, i: usize, tx: bool) {
let pdo_type = if tx { "TPDO" } else { "RPDO" };
let comm_index = if tx { 0x1800 } else { 0x1400 };
let mapping_index = if tx { 0x1A00 } else { 0x1600 };
objects.push(ObjectDefinition {
index: comm_index + i as u16,
parameter_name: format!("{}{} Communication Parameter", pdo_type, i),
application_callback: true,
object: Object::Record(RecordDefinition {
subs: vec![
SubDefinition {
sub_index: 1,
parameter_name: format!("COB-ID for {}{}", pdo_type, i),
field_name: None,
data_type: DataType::UInt32,
access_type: AccessType::Rw.into(),
default_value: None,
pdo_mapping: PdoMappable::None,
persist: true,
},
SubDefinition {
sub_index: 2,
parameter_name: format!("Transmission type for {}{}", pdo_type, i),
field_name: None,
data_type: DataType::UInt8,
access_type: AccessType::Rw.into(),
default_value: None,
pdo_mapping: PdoMappable::None,
persist: true,
},
],
}),
});
let mut mapping_subs = vec![SubDefinition {
sub_index: 0,
parameter_name: "Valid Mappings".to_string(),
field_name: None,
data_type: DataType::UInt8,
access_type: AccessType::Rw.into(),
default_value: Some(DefaultValue::Integer(0)),
pdo_mapping: PdoMappable::None,
persist: true,
}];
for sub in 1..65 {
mapping_subs.push(SubDefinition {
sub_index: sub,
parameter_name: format!("{}{} Mapping App Object {}", pdo_type, i, sub),
field_name: None,
data_type: DataType::UInt32,
access_type: AccessType::Rw.into(),
default_value: None,
pdo_mapping: PdoMappable::None,
persist: true,
});
}
objects.push(ObjectDefinition {
index: mapping_index + i as u16,
parameter_name: format!("{}{} Mapping Parameters", pdo_type, i),
application_callback: true,
object: Object::Record(RecordDefinition { subs: mapping_subs }),
});
}
for i in 0..num_rpdo {
add_objects(&mut objects, i, false);
}
for i in 0..num_tpdo {
add_objects(&mut objects, i, true);
}
objects
}
fn bootloader_objects(cfg: &BootloaderConfig) -> Vec<ObjectDefinition> {
let mut objects = Vec::new();
if cfg.sections.is_empty() {
return objects;
}
objects.push(ObjectDefinition {
index: 0x5500,
parameter_name: "Bootloader Info".into(),
application_callback: false,
object: Object::Record(RecordDefinition {
subs: vec![
SubDefinition {
sub_index: 1,
parameter_name: "Bootloader Config".into(),
field_name: Some("config".into()),
data_type: DataType::UInt32,
access_type: AccessType::Ro.into(),
default_value: Some(0.into()),
pdo_mapping: PdoMappable::None,
persist: false,
},
SubDefinition {
sub_index: 2,
parameter_name: "Number of Section".into(),
field_name: Some("num_sections".into()),
data_type: DataType::UInt8,
access_type: AccessType::Ro.into(),
default_value: Some(cfg.sections.len().into()),
pdo_mapping: PdoMappable::None,
persist: false,
},
SubDefinition {
sub_index: 3,
parameter_name: "Reset to Bootloader Command".into(),
field_name: None,
data_type: DataType::UInt32,
access_type: AccessType::Wo.into(),
default_value: None,
pdo_mapping: PdoMappable::None,
persist: false,
},
],
}),
});
for (i, section) in cfg.sections.iter().enumerate() {
objects.push(ObjectDefinition {
index: 0x5510 + i as u16,
parameter_name: format!("Bootloader Section {i}"),
application_callback: true,
object: Object::Record(RecordDefinition {
subs: vec![
SubDefinition {
sub_index: 1,
parameter_name: "Mode bits".into(),
data_type: DataType::UInt8,
access_type: AccessType::Const.into(),
..Default::default()
},
SubDefinition {
sub_index: 2,
parameter_name: "Section Name".into(),
data_type: DataType::VisibleString(0),
access_type: AccessType::Const.into(),
default_value: Some(section.name.as_str().into()),
..Default::default()
},
SubDefinition {
sub_index: 3,
parameter_name: "Section Size".into(),
data_type: DataType::UInt32,
access_type: AccessType::Const.into(),
default_value: Some((section.size as i64).into()),
..Default::default()
},
SubDefinition {
sub_index: 4,
parameter_name: "Erase Command".into(),
data_type: DataType::UInt8,
access_type: AccessType::Wo.into(),
..Default::default()
},
SubDefinition {
sub_index: 5,
parameter_name: "Data".into(),
data_type: DataType::Domain,
access_type: AccessType::Rw.into(),
..Default::default()
},
],
}),
});
}
objects
}
fn object_storage_objects(dev: &DeviceConfig) -> Vec<ObjectDefinition> {
if dev.support_storage {
vec![ObjectDefinition {
index: 0x1010,
parameter_name: "Object Save Command".to_string(),
application_callback: false,
object: Object::Array(ArrayDefinition {
data_type: DataType::UInt32,
access_type: AccessType::Rw.into(),
array_size: 1,
persist: false,
..Default::default()
}),
}]
} else {
vec![]
}
}
fn default_num_rpdo() -> u8 {
4
}
fn default_num_tpdo() -> u8 {
4
}
fn default_true() -> bool {
true
}
#[derive(Clone, Copy, Debug, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AutoStartConfig {
#[default]
Disabled,
Enabled,
Unsupported,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PdoDefaultConfig {
pub cob_id: u32,
#[serde(default)]
pub extended: bool,
pub add_node_id: bool,
pub enabled: bool,
#[serde(default)]
pub rtr_disabled: bool,
pub mappings: Vec<PdoMapping>,
pub transmission_type: u8,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub(crate) struct PdoDefaultConfigMapSerializer(
#[serde(deserialize_with = "deserialize_pdo_map", default)] pub HashMap<usize, PdoDefaultConfig>,
);
impl From<PdoDefaultConfigMapSerializer> for HashMap<usize, PdoDefaultConfig> {
fn from(value: PdoDefaultConfigMapSerializer) -> Self {
value.0
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DevicePdoConfigSerializer {
#[serde(default = "default_num_rpdo")]
pub num_tpdo: u8,
#[serde(default = "default_num_tpdo")]
pub num_rpdo: u8,
#[serde(default)]
pub tpdo: PdoDefaultConfigMapSerializer,
#[serde(default)]
pub rpdo: PdoDefaultConfigMapSerializer,
}
impl From<DevicePdoConfigSerializer> for DevicePdoConfig {
fn from(value: DevicePdoConfigSerializer) -> Self {
Self {
num_tpdo: value.num_tpdo,
num_rpdo: value.num_rpdo,
tpdo_defaults: value.tpdo.0,
rpdo_defaults: value.rpdo.0,
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(try_from = "DevicePdoConfigSerializer")]
pub struct DevicePdoConfig {
pub num_tpdo: u8,
pub num_rpdo: u8,
pub tpdo_defaults: HashMap<usize, PdoDefaultConfig>,
pub rpdo_defaults: HashMap<usize, PdoDefaultConfig>,
}
impl Default for DevicePdoConfig {
fn default() -> Self {
Self {
num_tpdo: default_num_tpdo(),
num_rpdo: default_num_rpdo(),
tpdo_defaults: HashMap::new(),
rpdo_defaults: HashMap::new(),
}
}
}
#[derive(Deserialize, Debug, Default, Clone, Copy)]
#[serde(deny_unknown_fields)]
pub struct IdentityConfig {
pub vendor_id: u32,
pub product_code: u32,
pub revision_number: u32,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BootloaderSection {
pub name: String,
pub size: u32,
}
#[derive(Clone, Deserialize, Debug, Default)]
#[serde(deny_unknown_fields)]
pub struct BootloaderConfig {
#[serde(default)]
pub application: bool,
#[serde(default)]
pub sections: Vec<BootloaderSection>,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct DeviceConfig {
pub device_name: String,
#[serde(default)]
pub autostart: AutoStartConfig,
#[serde(default = "default_true")]
pub support_storage: bool,
#[serde(default)]
pub hardware_version: String,
#[serde(default)]
pub software_version: String,
#[serde(default)]
pub heartbeat_period: u16,
pub identity: IdentityConfig,
#[serde(default)]
pub pdos: DevicePdoConfig,
#[serde(default)]
pub bootloader: BootloaderConfig,
#[serde(default)]
pub objects: Vec<ObjectDefinition>,
}
#[derive(Deserialize, Debug, Default, Clone)]
#[serde(deny_unknown_fields)]
pub struct SubDefinition {
pub sub_index: u8,
#[serde(default)]
pub parameter_name: String,
#[serde(default)]
pub field_name: Option<String>,
pub data_type: DataType,
#[serde(default)]
pub access_type: AccessTypeDeser,
#[serde(default)]
pub default_value: Option<DefaultValue>,
#[serde(default)]
pub pdo_mapping: PdoMappable,
#[serde(default)]
pub persist: bool,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum DefaultValue {
Integer(i64),
Float(f64),
String(String),
}
impl From<i64> for DefaultValue {
fn from(value: i64) -> Self {
Self::Integer(value)
}
}
impl From<i32> for DefaultValue {
fn from(value: i32) -> Self {
Self::Integer(value as i64)
}
}
impl From<usize> for DefaultValue {
fn from(value: usize) -> Self {
Self::Integer(value as i64)
}
}
impl From<f64> for DefaultValue {
fn from(value: f64) -> Self {
Self::Float(value)
}
}
impl From<&str> for DefaultValue {
fn from(value: &str) -> Self {
Self::String(value.to_string())
}
}
#[derive(Deserialize, Debug, Clone)]
#[serde(tag = "object_type", rename_all = "lowercase")]
pub enum Object {
Var(VarDefinition),
Array(ArrayDefinition),
Record(RecordDefinition),
}
#[derive(Default, Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct VarDefinition {
pub data_type: DataType,
pub access_type: AccessTypeDeser,
pub default_value: Option<DefaultValue>,
#[serde(default)]
pub pdo_mapping: PdoMappable,
#[serde(default)]
pub persist: bool,
}
#[derive(Default, Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct ArrayDefinition {
pub data_type: DataType,
pub access_type: AccessTypeDeser,
pub array_size: usize,
pub default_value: Option<Vec<DefaultValue>>,
#[serde(default)]
pub pdo_mapping: PdoMappable,
#[serde(default)]
pub persist: bool,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct RecordDefinition {
#[serde(default)]
pub subs: Vec<SubDefinition>,
}
#[derive(Clone, Copy, Deserialize, Debug)]
pub struct DomainDefinition {}
#[derive(Deserialize, Debug, Clone)]
pub struct ObjectDefinition {
pub index: u16,
#[serde(default)]
pub parameter_name: String,
#[serde(default)]
pub application_callback: bool,
#[serde(flatten)]
pub object: Object,
}
impl ObjectDefinition {
pub fn object_code(&self) -> ObjectCode {
match self.object {
Object::Var(_) => ObjectCode::Var,
Object::Array(_) => ObjectCode::Array,
Object::Record(_) => ObjectCode::Record,
}
}
}
impl DeviceConfig {
pub fn load(config_path: impl AsRef<std::path::Path>) -> Result<Self, LoadError> {
let config_str = std::fs::read_to_string(&config_path).context(IoSnafu)?;
Self::load_from_str(&config_str)
}
pub fn load_from_str(config_str: &str) -> Result<Self, LoadError> {
let mut config: DeviceConfig = toml::from_str(config_str).context(TomlParsingSnafu)?;
config.objects.extend(mandatory_objects(&config));
config
.objects
.extend(bootloader_objects(&config.bootloader));
config.objects.extend(pdo_objects(
config.pdos.num_rpdo as usize,
config.pdos.num_tpdo as usize,
));
config.objects.extend(object_storage_objects(&config));
Self::validate_unique_indices(&config.objects)?;
Ok(config)
}
fn validate_unique_indices(objects: &[ObjectDefinition]) -> Result<(), LoadError> {
let mut found_indices = HashMap::new();
for obj in objects {
if found_indices.contains_key(&obj.index) {
return DuplicateObjectIdsSnafu { id: obj.index }.fail();
}
found_indices.insert(&obj.index, ());
if let Object::Record(record) = &obj.object {
let mut found_subs = HashMap::new();
for sub in &record.subs {
if found_subs.contains_key(&sub.sub_index) {
return DuplicateSubObjectsSnafu {
index: obj.index,
sub: sub.sub_index,
}
.fail();
}
found_subs.insert(&sub.sub_index, ());
}
}
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct AccessTypeDeser(pub AccessType);
impl<'de> serde::Deserialize<'de> for AccessTypeDeser {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.to_lowercase().as_str() {
"ro" => Ok(AccessTypeDeser(AccessType::Ro)),
"rw" => Ok(AccessTypeDeser(AccessType::Rw)),
"wo" => Ok(AccessTypeDeser(AccessType::Wo)),
"const" => Ok(AccessTypeDeser(AccessType::Const)),
_ => Err(D::Error::custom(format!(
"Invalid access type: {} (allowed: 'ro', 'rw', 'wo', or 'const')",
s
))),
}
}
}
impl From<AccessType> for AccessTypeDeser {
fn from(access_type: AccessType) -> Self {
AccessTypeDeser(access_type)
}
}
#[derive(Clone, Copy, Debug, Default)]
#[allow(missing_docs)]
pub enum DataType {
Boolean,
Int8,
Int16,
Int24,
Int32,
Int64,
#[default]
UInt8,
UInt16,
UInt24,
UInt32,
UInt64,
Real32,
Real64,
VisibleString(usize),
OctetString(usize),
UnicodeString(usize),
TimeOfDay,
TimeDifference,
Domain,
}
impl DataType {
pub fn is_str(&self) -> bool {
matches!(
self,
DataType::VisibleString(_) | DataType::OctetString(_) | DataType::UnicodeString(_)
)
}
pub fn size(&self) -> usize {
match self {
DataType::Boolean => 1,
DataType::Int8 => 1,
DataType::Int16 => 2,
DataType::Int24 => 3,
DataType::Int32 => 4,
DataType::Int64 => 8,
DataType::UInt8 => 1,
DataType::UInt16 => 2,
DataType::UInt24 => 3,
DataType::UInt32 => 4,
DataType::UInt64 => 8,
DataType::Real32 => 4,
DataType::Real64 => 8,
DataType::VisibleString(size) => *size,
DataType::OctetString(size) => *size,
DataType::UnicodeString(size) => *size,
DataType::TimeOfDay => 4,
DataType::TimeDifference => 4,
DataType::Domain => 0, }
}
}
impl<'de> serde::Deserialize<'de> for DataType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let re_visiblestring = regex::Regex::new(r"^visiblestring\((\d+)\)$").unwrap();
let re_octetstring = regex::Regex::new(r"^octetstring\((\d+)\)$").unwrap();
let re_unicodestring = regex::Regex::new(r"^unicodestring\((\d+)\)$").unwrap();
let s = String::deserialize(deserializer)?.to_lowercase();
if s == "boolean" {
Ok(DataType::Boolean)
} else if s == "int8" {
Ok(DataType::Int8)
} else if s == "int16" {
Ok(DataType::Int16)
} else if s == "int24" {
Ok(DataType::Int24)
} else if s == "int32" {
Ok(DataType::Int32)
} else if s == "int64" {
Ok(DataType::Int64)
} else if s == "uint8" {
Ok(DataType::UInt8)
} else if s == "uint16" {
Ok(DataType::UInt16)
} else if s == "uint24" {
Ok(DataType::UInt24)
} else if s == "uint32" {
Ok(DataType::UInt32)
} else if s == "uint64" {
Ok(DataType::UInt64)
} else if s == "real32" {
Ok(DataType::Real32)
} else if s == "real64" {
Ok(DataType::Real64)
} else if let Some(caps) = re_visiblestring.captures(&s) {
let size: usize = caps[1].parse().map_err(|_| {
D::Error::custom(format!("Invalid size for VisibleString: {}", &caps[1]))
})?;
Ok(DataType::VisibleString(size))
} else if let Some(caps) = re_octetstring.captures(&s) {
let size: usize = caps[1].parse().map_err(|_| {
D::Error::custom(format!("Invalid size for OctetString: {}", &caps[1]))
})?;
Ok(DataType::OctetString(size))
} else if let Some(caps) = re_unicodestring.captures(&s) {
let size: usize = caps[1].parse().map_err(|_| {
D::Error::custom(format!("Invalid size for UnicodeString: {}", &caps[1]))
})?;
Ok(DataType::UnicodeString(size))
} else if s == "timeofday" {
Ok(DataType::TimeOfDay)
} else if s == "timedifference" {
Ok(DataType::TimeDifference)
} else if s == "domain" {
Ok(DataType::Domain)
} else {
Err(D::Error::custom(format!("Invalid data type: {}", s)))
}
}
}
#[cfg(test)]
mod tests {
use crate::device_config::{DeviceConfig, LoadError};
use assertables::assert_contains;
#[test]
fn test_duplicate_objects_errors() {
const TOML: &str = r#"
device_name = "test"
[identity]
vendor_id = 0
product_code = 1
revision_number = 2
[[objects]]
index = 0x2000
parameter_name = "Test1"
object_type = "var"
data_type = "int16"
access_type = "rw"
[[objects]]
index = 0x2000
parameter_name = "Duplicate"
object_type = "record"
"#;
let result = DeviceConfig::load_from_str(TOML);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, LoadError::DuplicateObjectIds { id: 0x2000 }));
assert_contains!(
"Multiple definitions for object with index 0x2000",
err.to_string().as_str()
);
}
#[test]
fn test_duplicate_sub_object_errors() {
const TOML: &str = r#"
device_name = "test"
[identity]
vendor_id = 0
product_code = 1
revision_number = 2
[[objects]]
index = 0x2000
parameter_name = "Duplicate"
object_type = "record"
[[objects.subs]]
sub_index = 1
parameter_name = "Test1"
data_type = "int16"
access_type = "rw"
[[objects.subs]]
sub_index = 1
parameter_name = "RepeatedTest1"
data_type = "int16"
access_type = "rw"
"#;
let result = DeviceConfig::load_from_str(TOML);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(
err,
LoadError::DuplicateSubObjects {
index: 0x2000,
sub: 1
}
));
assert_contains!(
"Multiple definitions of sub index 1 on object 0x2000",
err.to_string().as_str()
);
}
}