Add more tests and parse array/string reg in config
parent
01db290c42
commit
78d08623a6
56
src/main.rs
56
src/main.rs
|
@ -396,13 +396,13 @@ async fn watch_registers(
|
||||||
|
|
||||||
let values = rx.await.unwrap().unwrap();
|
let values = rx.await.unwrap().unwrap();
|
||||||
|
|
||||||
let swapped_values = if r.parse.swap_bytes {
|
let swapped_values = if r.parse.swap_bytes.0 {
|
||||||
values.iter().map(|v| v.swap_bytes()).collect()
|
values.iter().map(|v| v.swap_bytes()).collect()
|
||||||
} else {
|
} else {
|
||||||
values.clone()
|
values.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let swapped_values = if r.parse.swap_words {
|
let swapped_values = if r.parse.swap_words.0 {
|
||||||
swapped_values
|
swapped_values
|
||||||
.chunks_exact(2)
|
.chunks_exact(2)
|
||||||
.flat_map(|chunk| vec![chunk[1], chunk[0]])
|
.flat_map(|chunk| vec![chunk[1], chunk[0]])
|
||||||
|
@ -416,29 +416,45 @@ async fn watch_registers(
|
||||||
.flat_map(|v| v.to_ne_bytes())
|
.flat_map(|v| v.to_ne_bytes())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
use crate::modbus::config::RegisterFixedValueType::*;
|
use crate::modbus::config::RegisterValueType as T;
|
||||||
use crate::modbus::config::RegisterValueType::*;
|
use crate::modbus::config::{RegisterArray, RegisterNumeric as N, RegisterString};
|
||||||
use crate::modbus::config::RegisterVariableValueType as Var;
|
|
||||||
|
|
||||||
let value = match r.parse.value_type {
|
let value = match r.parse.value_type {
|
||||||
Fixed(ref fixed) => match fixed {
|
T::Numeric {
|
||||||
U8 => json!(bytes[1]), // or is it 0?
|
ref of,
|
||||||
U16 => json!(swapped_values[0]),
|
adjust: ref _adjust,
|
||||||
U32 => json!(bytes.try_into().map(|bytes| u32::from_le_bytes(bytes)).ok()),
|
} => match of {
|
||||||
U64 => json!(bytes.try_into().map(|bytes| u64::from_le_bytes(bytes)).ok()),
|
N::U8 => json!(bytes[1]), // or is it 0?
|
||||||
I8 => json!(vec![bytes[1]]
|
N::U16 => json!(swapped_values[0]),
|
||||||
|
N::U32 => {
|
||||||
|
json!(bytes.try_into().map(|bytes| u32::from_le_bytes(bytes)).ok())
|
||||||
|
}
|
||||||
|
N::U64 => {
|
||||||
|
json!(bytes.try_into().map(|bytes| u64::from_le_bytes(bytes)).ok())
|
||||||
|
}
|
||||||
|
N::I8 => json!(vec![bytes[1]]
|
||||||
.try_into()
|
.try_into()
|
||||||
.map(|bytes| i8::from_le_bytes(bytes))),
|
.map(|bytes| i8::from_le_bytes(bytes))),
|
||||||
I16 => json!(bytes.try_into().map(|bytes| i16::from_le_bytes(bytes)).ok()),
|
N::I16 => {
|
||||||
I32 => json!(bytes.try_into().map(|bytes| i32::from_le_bytes(bytes)).ok()),
|
json!(bytes.try_into().map(|bytes| i16::from_le_bytes(bytes)).ok())
|
||||||
I64 => json!(bytes.try_into().map(|bytes| i64::from_le_bytes(bytes)).ok()),
|
}
|
||||||
F32 => json!(bytes.try_into().map(|bytes| f32::from_le_bytes(bytes)).ok()),
|
N::I32 => {
|
||||||
F64 => json!(bytes.try_into().map(|bytes| f64::from_le_bytes(bytes)).ok()),
|
json!(bytes.try_into().map(|bytes| i32::from_le_bytes(bytes)).ok())
|
||||||
},
|
}
|
||||||
Variable(ref var, _count) => match var {
|
N::I64 => {
|
||||||
Var::String => json!(String::from_utf16_lossy(&swapped_values)),
|
json!(bytes.try_into().map(|bytes| i64::from_le_bytes(bytes)).ok())
|
||||||
Var::Array(_) => todo!(),
|
}
|
||||||
|
N::F32 => {
|
||||||
|
json!(bytes.try_into().map(|bytes| f32::from_le_bytes(bytes)).ok())
|
||||||
|
}
|
||||||
|
N::F64 => {
|
||||||
|
json!(bytes.try_into().map(|bytes| f64::from_le_bytes(bytes)).ok())
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
T::String(RegisterString { .. }) => {
|
||||||
|
json!(String::from_utf16_lossy(&swapped_values))
|
||||||
|
}
|
||||||
|
T::Array(RegisterArray { .. }) => todo!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = serde_json::to_vec(
|
let payload = serde_json::to_vec(
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[cfg(test)]
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum ModbusProto {
|
pub enum ModbusProto {
|
||||||
Tcp {
|
Tcp {
|
||||||
|
@ -51,11 +53,31 @@ fn default_modbus_parity() -> tokio_serial::Parity {
|
||||||
tokio_serial::Parity::None
|
tokio_serial::Parity::None
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
// TODO: `scale`, `offset`, `precision`
|
pub struct RegisterNumericAdjustment {
|
||||||
pub enum RegisterFixedValueType {
|
#[serde(default)]
|
||||||
|
scale: i8, // powers of 10 (0 = no adjustment, 1 = x10, -1 = /10)
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
offset: i8,
|
||||||
|
// precision: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RegisterNumericAdjustment {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
scale: 1,
|
||||||
|
offset: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum RegisterNumeric {
|
||||||
U8,
|
U8,
|
||||||
|
#[default]
|
||||||
U16,
|
U16,
|
||||||
U32,
|
U32,
|
||||||
U64,
|
U64,
|
||||||
|
@ -73,76 +95,118 @@ pub enum RegisterFixedValueType {
|
||||||
F64,
|
F64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RegisterFixedValueType {
|
impl RegisterNumeric {
|
||||||
// Modbus limits sequential reads to 125 apparently, so 8-bit should be fine - https://github.com/slowtec/tokio-modbus/issues/112#issuecomment-1095316069=
|
// Modbus limits sequential reads to 125 apparently, so 8-bit should be fine - https://github.com/slowtec/tokio-modbus/issues/112#issuecomment-1095316069=
|
||||||
fn size(&self) -> u8 {
|
fn size(&self) -> u8 {
|
||||||
use RegisterFixedValueType::*;
|
use RegisterNumeric::*;
|
||||||
// Each Modbus register holds 16-bits, so count is half what the byte count would be
|
// Each Modbus register holds 16-bits, so count is half what the byte count would be
|
||||||
match self {
|
match self {
|
||||||
U8 => 1,
|
U8 | I8 => 1,
|
||||||
U16 => 1,
|
U16 | I16 => 1,
|
||||||
U32 => 2,
|
U32 | I32 | F32 => 2,
|
||||||
U64 => 4,
|
U64 | I64 | F64 => 4,
|
||||||
I8 => 1,
|
|
||||||
I16 => 1,
|
|
||||||
I32 => 2,
|
|
||||||
I64 => 4,
|
|
||||||
F32 => 2,
|
|
||||||
F64 => 4,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(tag = "type", rename = "string")]
|
||||||
pub enum RegisterVariableValueType {
|
pub struct RegisterString {
|
||||||
String,
|
length: u8,
|
||||||
Array(RegisterFixedValueType),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(untagged, rename_all = "lowercase")]
|
#[serde(tag = "type", rename = "array")]
|
||||||
|
pub struct RegisterArray {
|
||||||
|
count: u8,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
of: RegisterNumeric,
|
||||||
|
|
||||||
|
// Arrays are only of numeric types, so we can apply an adjustment here
|
||||||
|
#[serde(flatten, skip_serializing_if = "IsDefault::is_default")]
|
||||||
|
adjust: RegisterNumericAdjustment,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RegisterArray {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
count: 1,
|
||||||
|
of: Default::default(),
|
||||||
|
adjust: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
pub enum RegisterValueType {
|
pub enum RegisterValueType {
|
||||||
Fixed(RegisterFixedValueType),
|
Numeric {
|
||||||
Variable(RegisterVariableValueType, u8),
|
#[serde(rename = "type", default)]
|
||||||
|
of: RegisterNumeric,
|
||||||
|
|
||||||
|
#[serde(flatten, skip_serializing_if = "IsDefault::is_default")]
|
||||||
|
adjust: RegisterNumericAdjustment,
|
||||||
|
},
|
||||||
|
Array(RegisterArray),
|
||||||
|
String(RegisterString),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RegisterValueType {
|
||||||
|
fn default() -> Self {
|
||||||
|
RegisterValueType::Numeric {
|
||||||
|
of: Default::default(),
|
||||||
|
adjust: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RegisterValueType {
|
impl RegisterValueType {
|
||||||
// Modbus limits sequential reads to 125 apparently, so 8-bit should be fine - https://github.com/slowtec/tokio-modbus/issues/112#issuecomment-1095316069=
|
// Modbus limits sequential reads to 125 apparently, so 8-bit should be fine - https://github.com/slowtec/tokio-modbus/issues/112#issuecomment-1095316069=
|
||||||
pub fn size(&self) -> u8 {
|
pub fn size(&self) -> u8 {
|
||||||
use RegisterValueType::*;
|
use RegisterValueType::*;
|
||||||
use RegisterVariableValueType::*;
|
|
||||||
|
|
||||||
match self {
|
match self {
|
||||||
Fixed(fixed) => fixed.size(),
|
Numeric { of, .. } => of.size(),
|
||||||
Variable(variable, count) => match variable {
|
String(RegisterString { length }) => *length,
|
||||||
String => *count,
|
Array(RegisterArray { of, count, .. }) => of.size() * count,
|
||||||
Array(fixed) => *count * fixed.size(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Swap(pub bool);
|
||||||
|
|
||||||
|
impl Default for Swap {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait IsDefault {
|
||||||
|
fn is_default(&self) -> bool;
|
||||||
|
}
|
||||||
|
impl<T> IsDefault for T
|
||||||
|
where
|
||||||
|
T: Default + PartialEq,
|
||||||
|
{
|
||||||
|
fn is_default(&self) -> bool {
|
||||||
|
*self == Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||||
pub struct RegisterParse {
|
pub struct RegisterParse {
|
||||||
#[serde(default = "default_swap")]
|
#[serde(default, skip_serializing_if = "IsDefault::is_default")]
|
||||||
pub swap_bytes: bool,
|
pub swap_bytes: Swap,
|
||||||
|
|
||||||
#[serde(default = "default_swap")]
|
#[serde(default, skip_serializing_if = "IsDefault::is_default")]
|
||||||
pub swap_words: bool,
|
pub swap_words: Swap,
|
||||||
|
|
||||||
#[serde(rename = "type", default = "default_value_type")]
|
#[serde(flatten, skip_serializing_if = "IsDefault::is_default")]
|
||||||
pub value_type: RegisterValueType,
|
pub value_type: RegisterValueType,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_swap() -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_value_type() -> RegisterValueType {
|
|
||||||
RegisterValueType::Fixed(RegisterFixedValueType::U16)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct Register {
|
pub struct Register {
|
||||||
pub address: u16,
|
pub address: u16,
|
||||||
|
@ -150,7 +214,7 @@ pub struct Register {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
|
||||||
#[serde(flatten, default = "default_register_parse")]
|
#[serde(flatten, default, skip_serializing_if = "IsDefault::is_default")]
|
||||||
pub parse: RegisterParse,
|
pub parse: RegisterParse,
|
||||||
|
|
||||||
#[serde(
|
#[serde(
|
||||||
|
@ -166,14 +230,6 @@ fn default_register_interval() -> Duration {
|
||||||
Duration::from_secs(60)
|
Duration::from_secs(60)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_register_parse() -> RegisterParse {
|
|
||||||
RegisterParse {
|
|
||||||
swap_bytes: default_swap(),
|
|
||||||
swap_words: default_swap(),
|
|
||||||
value_type: default_value_type(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct Connect {
|
pub struct Connect {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
|
@ -209,7 +265,6 @@ fn parse_minimal_tcp_connect_config() {
|
||||||
let result = serde_json::from_value::<Connect>(json!({
|
let result = serde_json::from_value::<Connect>(json!({
|
||||||
"host": "1.1.1.1"
|
"host": "1.1.1.1"
|
||||||
}));
|
}));
|
||||||
assert!(result.is_ok());
|
|
||||||
|
|
||||||
let connect = result.unwrap();
|
let connect = result.unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
@ -223,7 +278,7 @@ fn parse_minimal_tcp_connect_config() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_full_tcp_connect_config() {
|
fn parse_full_tcp_connect_config() {
|
||||||
let result = serde_json::from_value::<Connect>(json!({
|
let _ = serde_json::from_value::<Connect>(json!({
|
||||||
"host": "10.10.10.219",
|
"host": "10.10.10.219",
|
||||||
"unit": 1,
|
"unit": 1,
|
||||||
"address_offset": -1,
|
"address_offset": -1,
|
||||||
|
@ -283,9 +338,8 @@ fn parse_full_tcp_connect_config() {
|
||||||
"period": "90s"
|
"period": "90s"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}));
|
}))
|
||||||
|
.unwrap();
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -294,7 +348,6 @@ fn parse_minimal_rtu_connect_config() {
|
||||||
"tty": "/dev/ttyUSB0",
|
"tty": "/dev/ttyUSB0",
|
||||||
"baud_rate": 9600,
|
"baud_rate": 9600,
|
||||||
}));
|
}));
|
||||||
assert!(result.is_ok());
|
|
||||||
|
|
||||||
let connect = result.unwrap();
|
let connect = result.unwrap();
|
||||||
use tokio_serial::*;
|
use tokio_serial::*;
|
||||||
|
@ -311,3 +364,138 @@ fn parse_minimal_rtu_connect_config() {
|
||||||
} if tty == "/dev/ttyUSB0"
|
} if tty == "/dev/ttyUSB0"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_complete_rtu_connect_config() {
|
||||||
|
let result = serde_json::from_value::<Connect>(json!({
|
||||||
|
"tty": "/dev/ttyUSB0",
|
||||||
|
"baud_rate": 12800,
|
||||||
|
|
||||||
|
// TODO: make lowercase words work
|
||||||
|
"data_bits": "Seven", // TODO: make 7 work
|
||||||
|
"stop_bits": "Two", // TODO: make 2 work
|
||||||
|
"flow_control": "Software",
|
||||||
|
"parity": "Even",
|
||||||
|
}));
|
||||||
|
|
||||||
|
let connect = result.unwrap();
|
||||||
|
use tokio_serial::*;
|
||||||
|
assert!(matches!(
|
||||||
|
connect.settings,
|
||||||
|
ModbusProto::Rtu {
|
||||||
|
ref tty,
|
||||||
|
baud_rate: 12800,
|
||||||
|
data_bits: DataBits::Seven,
|
||||||
|
stop_bits: StopBits::Two,
|
||||||
|
flow_control: FlowControl::Software,
|
||||||
|
parity: Parity::Even,
|
||||||
|
..
|
||||||
|
} if tty == "/dev/ttyUSB0"
|
||||||
|
),);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_register_parser_defaults() {
|
||||||
|
let empty = serde_json::from_value::<RegisterParse>(json!({}));
|
||||||
|
assert!(matches!(
|
||||||
|
empty.unwrap(),
|
||||||
|
RegisterParse {
|
||||||
|
swap_bytes: Swap(false),
|
||||||
|
swap_words: Swap(false),
|
||||||
|
value_type: RegisterValueType::Numeric {
|
||||||
|
of: RegisterNumeric::U16,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_register_parser_type() {
|
||||||
|
let result = serde_json::from_value::<RegisterParse>(json!({
|
||||||
|
"type": "s32"
|
||||||
|
}));
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap().value_type,
|
||||||
|
RegisterValueType::Numeric {
|
||||||
|
of: RegisterNumeric::I32,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_register_parser_array() {
|
||||||
|
let result = serde_json::from_value::<RegisterParse>(json!({
|
||||||
|
"type": "array",
|
||||||
|
"of": "s32",
|
||||||
|
"count": 10,
|
||||||
|
}));
|
||||||
|
let payload = result.unwrap();
|
||||||
|
// println!("{:?}", payload);
|
||||||
|
// println!("{}", serde_json::to_string_pretty(&payload).unwrap());
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
payload.value_type,
|
||||||
|
RegisterValueType::Array(RegisterArray {
|
||||||
|
of: RegisterNumeric::I32,
|
||||||
|
count: 10,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_register_parser_array_implicit_u16() {
|
||||||
|
let result = serde_json::from_value::<RegisterParse>(json!({
|
||||||
|
"type": "array",
|
||||||
|
"count": 10,
|
||||||
|
}));
|
||||||
|
let payload = result.unwrap();
|
||||||
|
// println!("{:?}", payload);
|
||||||
|
// println!("{}", serde_json::to_string_pretty(&payload).unwrap());
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
payload.value_type,
|
||||||
|
RegisterValueType::Array(RegisterArray {
|
||||||
|
of: RegisterNumeric::U16,
|
||||||
|
count: 10,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_register_parser_string() {
|
||||||
|
let result = serde_json::from_value::<RegisterParse>(json!({
|
||||||
|
"type": "string",
|
||||||
|
"length": 10,
|
||||||
|
}));
|
||||||
|
let payload = result.unwrap();
|
||||||
|
// println!("{:?}", payload);
|
||||||
|
// println!("{}", serde_json::to_string_pretty(&payload).unwrap());
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
payload.value_type,
|
||||||
|
RegisterValueType::String(RegisterString { length: 10, .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_register_parser_scale_etc() {
|
||||||
|
let result = serde_json::from_value::<RegisterParse>(json!({
|
||||||
|
"type": "s32",
|
||||||
|
"scale": -1,
|
||||||
|
"offset": 20,
|
||||||
|
}));
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap().value_type,
|
||||||
|
RegisterValueType::Numeric {
|
||||||
|
of: RegisterNumeric::I32,
|
||||||
|
adjust: RegisterNumericAdjustment {
|
||||||
|
scale: -1,
|
||||||
|
offset: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue