use std::collections::HashMap;
use ciplink::Value;
use ciplink::cip::TagResult;
use ciplink::logix::driver::{LogixDriver, LogixOptions};
use ciplink::logix::metadata::TextEncoding;
use ciplink::logix::types::TypeRef;
use indexmap::IndexMap;
use serde_json::{Map, Value as Json, json};
use crate::config::PlcConfig;
pub trait Plc: Send {
fn open(&mut self) -> Result<(), String>;
fn close(&mut self);
fn read(&mut self, tags: &[&str]) -> Result<Vec<TagResult>, String>;
fn write(&mut self, tag: &str, value: &Value) -> Result<TagResult, String>;
fn catalog(&mut self) -> Catalog;
fn version(&mut self) -> Json;
fn connection_size(&self) -> usize;
fn describes(&self) -> bool {
false
}
fn descriptions(&mut self, tags: &[&str]) -> Result<Vec<Option<String>>, String> {
Ok(vec![None; tags.len()])
}
}
pub type Factory = std::sync::Arc<dyn Fn(&PlcConfig) -> Result<Box<dyn Plc>, String> + Send + Sync>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Atomic,
Struct,
String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Node {
pub type_name: String,
pub kind: Kind,
pub dims: Vec<u64>,
pub member: bool,
pub instance_id: Option<u32>,
pub structure_size: Option<u64>,
}
impl Node {
pub fn bool_array(&self) -> bool {
self.type_name == "DWORD" && !self.dims.is_empty()
}
pub fn client_type(&self) -> &str {
if self.kind == Kind::String {
"STRING"
} else if self.bool_array() {
"BOOL"
} else {
&self.type_name
}
}
pub fn client_dims(&self) -> Vec<u64> {
let mut dims = self.dims.clone();
if self.bool_array() {
if let Some(last) = dims.last_mut() {
*last *= 32;
}
}
dims
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Aoi {
pub whole_read: bool,
pub readable: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TypeDef {
pub members: IndexMap<String, Node>,
pub public: Vec<String>,
pub structure_size: u64,
pub aoi: Option<Aoi>,
pub whole_readable: bool,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Catalog {
pub version: Json,
pub tags: IndexMap<String, Node>,
pub types: IndexMap<String, TypeDef>,
index: HashMap<String, String>,
}
pub fn atomic_size(type_name: &str) -> Option<u64> {
Some(match type_name {
"BOOL" | "SINT" | "USINT" | "BYTE" => 1,
"INT" | "UINT" | "WORD" => 2,
"DINT" | "UDINT" | "DWORD" | "REAL" => 4,
"LINT" | "ULINT" | "LWORD" | "LREAL" => 8,
_ => return None,
})
}
impl Catalog {
pub fn new(
version: Json,
tags: IndexMap<String, Node>,
mut types: IndexMap<String, TypeDef>,
) -> Self {
let mut memo = HashMap::new();
let names: Vec<String> = types.keys().cloned().collect();
for name in &names {
whole_readable(name, &types, &mut memo);
}
for (name, t) in &mut types {
t.whole_readable = memo.get(name).copied().unwrap_or(true);
}
let index = tags.keys().map(|k| (k.to_lowercase(), k.clone())).collect();
Catalog {
version,
tags,
types,
index,
}
}
pub fn tag(&self, name: &str) -> Option<&Node> {
self.tags
.get(name)
.or_else(|| self.tags.get(self.index.get(&name.to_lowercase())?))
}
pub fn type_def(&self, name: &str) -> Option<&TypeDef> {
self.types.get(name)
}
pub fn info(&self, name: &str) -> Option<&Node> {
if let Some(node) = self.tag(name) {
return Some(node); }
let (prefix, rest) = match name.find('.') {
Some(dot)
if name.len() >= 8
&& name.is_char_boundary(8)
&& name[..8].eq_ignore_ascii_case("program:") =>
{
(&name[..=dot], &name[dot + 1..])
}
_ => ("", name),
};
let stripped = strip(rest);
let mut attrs = stripped.split('.');
let base = attrs.next()?;
let mut node = self.tag(&format!("{prefix}{base}"))?;
for attr in attrs {
let t = self.types.get(&node.type_name)?;
node = t.members.get(attr).or_else(|| {
t.members
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(attr))
.map(|(_, v)| v)
})?;
}
Some(node)
}
pub fn struct_size(&self, node: &Node) -> Option<u64> {
if node.kind != Kind::Struct {
return None;
}
let t = self.types.get(&node.type_name)?;
t.whole_readable.then_some(t.structure_size)
}
pub fn tag_list(&self) -> Vec<Json> {
self.tags
.iter()
.map(|(name, node)| {
let mut entry = Map::new();
entry.insert("name".into(), json!(name));
entry.insert("type".into(), json!(node.client_type()));
if !node.dims.is_empty() {
entry.insert("dims".into(), json!(node.client_dims()));
}
if node.kind == Kind::Struct {
if let Some(aoi) = self.types.get(&node.type_name).and_then(|t| t.aoi.as_ref())
{
entry.insert("aoi_readable".into(), json!(aoi.readable));
}
}
Json::Object(entry)
})
.collect()
}
pub fn type_map(&self) -> Map<String, Json> {
self.types
.iter()
.map(|(name, t)| {
let names = t.aoi.as_ref().map_or(&t.public, |a| &a.readable);
let members: Map<String, Json> = names
.iter()
.map(|m| {
let mut member = Map::new();
match t.members.get(m) {
Some(node) => {
member.insert("type".into(), json!(node.client_type()));
if !node.dims.is_empty() {
member.insert("dims".into(), json!(node.client_dims()));
}
}
None => {
member.insert("type".into(), json!(""));
}
}
(m.clone(), Json::Object(member))
})
.collect();
let mut entry = Map::new();
entry.insert("members".into(), Json::Object(members));
if t.aoi.is_some() {
entry.insert("aoi".into(), json!(true));
}
(name.clone(), Json::Object(entry))
})
.collect()
}
}
fn whole_readable(
name: &str,
types: &IndexMap<String, TypeDef>,
memo: &mut HashMap<String, bool>,
) -> bool {
if let Some(ok) = memo.get(name) {
return *ok;
}
memo.insert(name.to_owned(), true); let Some(t) = types.get(name) else {
return true;
};
let mut ok = !t.aoi.as_ref().is_some_and(|a| !a.whole_read);
for member in t.members.values() {
if ok && member.kind == Kind::Struct {
ok = whole_readable(&member.type_name, types, memo);
}
}
memo.insert(name.to_owned(), ok);
ok
}
fn strip(path: &str) -> String {
let mut out = String::with_capacity(path.len());
let mut skip = None;
for c in path.chars() {
match (skip, c) {
(None, '[') => skip = Some(']'),
(None, '{') => skip = Some('}'),
(Some(end), c) if c == end => skip = None,
(Some(_), _) => {}
(None, c) => out.push(c),
}
}
out
}
pub struct LogixPlc {
driver: LogixDriver,
}
impl LogixPlc {
pub fn new(cfg: &PlcConfig) -> Result<Self, String> {
let mut options = LogixOptions::default();
if let Some(cache) = &cfg.tag_cache_file {
if let Some(dir) = cache.parent() {
let _ = std::fs::create_dir_all(dir);
}
options.tag_cache_file = Some(cache.clone());
}
let driver = LogixDriver::new(&cfg.path, options).map_err(|e| e.to_string())?;
Ok(LogixPlc { driver })
}
pub fn factory() -> Factory {
std::sync::Arc::new(|cfg: &PlcConfig| Ok(Box::new(LogixPlc::new(cfg)?) as Box<dyn Plc>))
}
}
fn node_of(data_type: &TypeRef, dims: Vec<u64>, member: bool, instance_id: Option<u32>) -> Node {
let (kind, structure_size) = match data_type {
TypeRef::Atomic(_) => (Kind::Atomic, None),
TypeRef::Struct(dt) => (
if dt.string.is_some() {
Kind::String
} else {
Kind::Struct
},
Some(u64::from(dt.template.structure_size)),
),
};
Node {
type_name: data_type.name().unwrap_or_default().to_owned(),
kind,
dims,
member,
instance_id,
structure_size,
}
}
impl Plc for LogixPlc {
fn open(&mut self) -> Result<(), String> {
self.driver.open().map(|_| ()).map_err(|e| e.to_string())
}
fn close(&mut self) {
let _ = self.driver.close();
}
fn read(&mut self, tags: &[&str]) -> Result<Vec<TagResult>, String> {
self.driver.read(tags).map_err(|e| e.to_string())
}
fn write(&mut self, tag: &str, value: &Value) -> Result<TagResult, String> {
let mut results = self
.driver
.write(&[(tag, value.clone())])
.map_err(|e| e.to_string())?;
results.pop().ok_or_else(|| "no write result".to_owned())
}
fn catalog(&mut self) -> Catalog {
let defs = self.driver.definitions();
let probes: HashMap<String, Aoi> = defs
.probes
.iter()
.filter_map(|(dt, probe)| {
Some((
dt.name.clone()?,
Aoi {
whole_read: probe.whole_read,
readable: probe.readable.clone(),
},
))
})
.collect();
let tags = defs
.tags
.iter()
.map(|(name, t)| {
let dims = t.dimensions[..usize::from(t.dim.min(3))]
.iter()
.map(|&d| u64::from(d))
.collect();
(
name.clone(),
node_of(&t.data_type, dims, false, Some(t.instance_id)),
)
})
.collect();
let mut types = IndexMap::new();
for (name, dt) in &defs.data_types {
let Some(name) = name else { continue };
if dt.string.is_some() {
continue;
}
let members = dt
.internal_tags
.iter()
.map(|(m, info)| {
let dims = info
.array
.filter(|&a| a > 0)
.map(|a| vec![u64::from(a)])
.unwrap_or_default();
(m.clone(), node_of(&info.data_type, dims, true, None))
})
.collect();
types.insert(
name.clone(),
TypeDef {
members,
public: dt.attributes.clone(),
structure_size: u64::from(dt.template.structure_size),
aoi: probes.get(name).cloned(),
whole_readable: true,
},
);
}
Catalog::new(self.version(), tags, types)
}
fn version(&mut self) -> Json {
self.driver
.tag_table_version()
.map_or(Json::Null, |v| json!([v.0, v.1, v.2, v.3, v.4]))
}
fn connection_size(&self) -> usize {
usize::from(self.driver.connection_size())
}
fn describes(&self) -> bool {
true
}
fn descriptions(&mut self, tags: &[&str]) -> Result<Vec<Option<String>>, String> {
let results = self
.driver
.get_tag_descriptions(tags, 0x007F, TextEncoding::Utf8)
.map_err(|e| e.to_string())?;
Ok(results
.into_iter()
.map(|r| match (r.error, r.value) {
(None, Some(Value::String(s))) => Some(s),
_ => None,
})
.collect())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn atomic(t: &str, dims: Vec<u64>, member: bool) -> Node {
Node {
type_name: t.into(),
kind: Kind::Atomic,
dims,
member,
instance_id: None,
structure_size: None,
}
}
fn catalog() -> Catalog {
let mut udt = TypeDef {
public: vec!["Flags".into(), "Speed".into()],
structure_size: 8,
..TypeDef::default()
};
udt.members
.insert("Flags".into(), atomic("DWORD", vec![1], true));
udt.members
.insert("Speed".into(), atomic("REAL", vec![], true));
let mut tags = IndexMap::new();
tags.insert("Bits".into(), atomic("DWORD", vec![3], false)); tags.insert("Word".into(), atomic("DWORD", vec![], false));
tags.insert(
"Motor".into(),
Node {
type_name: "MotorData".into(),
kind: Kind::Struct,
dims: vec![],
member: false,
instance_id: Some(5),
structure_size: Some(8),
},
);
let mut types = IndexMap::new();
types.insert("MotorData".into(), udt);
Catalog::new(Json::Null, tags, types)
}
#[test]
fn bool_arrays_and_bit_strings() {
let c = catalog();
assert_eq!(
c.tag_list(),
vec![
json!({"name": "Bits", "type": "BOOL", "dims": [96]}),
json!({"name": "Word", "type": "DWORD"}),
json!({"name": "Motor", "type": "MotorData"}),
]
);
assert_eq!(
Json::Object(c.type_map()),
json!({"MotorData": {"members": {"Flags": {"type": "BOOL", "dims": [32]}, "Speed": {"type": "REAL"}}}})
);
}
#[test]
fn info_ignores_case_and_indexes() {
let c = catalog();
assert_eq!(
c.info("motor.SPEED").map(|n| n.type_name.as_str()),
Some("REAL")
);
assert_eq!(
c.info("Motor.Flags[3]").map(|n| n.type_name.as_str()),
Some("DWORD")
);
assert_eq!(c.info("Bits{4}").map(|n| n.dims.clone()), Some(vec![3]));
assert!(c.info("Motor.Nope").is_none());
assert_eq!(c.struct_size(c.info("Motor").unwrap()), Some(8));
}
fn strct(t: &str, dims: Vec<u64>) -> Node {
Node {
kind: Kind::Struct,
structure_size: Some(8),
..atomic(t, dims, false)
}
}
fn type_def(members: Vec<(&str, Node)>, public: &[&str], aoi: Option<Aoi>) -> TypeDef {
TypeDef {
members: members
.into_iter()
.map(|(m, n)| (m.to_owned(), Node { member: true, ..n }))
.collect(),
public: public.iter().map(|p| (*p).to_owned()).collect(),
structure_size: 8,
aoi,
whole_readable: true,
}
}
#[test]
fn tag_list_with_aoi() {
let mut tags = IndexMap::new();
tags.insert("Speed".to_owned(), atomic("REAL", vec![], false));
tags.insert("Valve1".to_owned(), strct("MyAOI", vec![]));
tags.insert("Recipe".to_owned(), strct("UDT_R", vec![]));
let mut types = IndexMap::new();
let aoi = Aoi {
whole_read: false,
readable: vec!["Open".into(), "Cmd".into()],
};
let bools = vec![
("Open", atomic("BOOL", vec![], true)),
("Cmd", atomic("BOOL", vec![], true)),
("Local1", atomic("BOOL", vec![], true)),
];
types.insert(
"MyAOI".to_owned(),
type_def(bools, &["Open", "Cmd", "Local1"], Some(aoi)),
);
types.insert("UDT_R".to_owned(), type_def(vec![], &[], None));
let c = Catalog::new(Json::Null, tags, types);
assert_eq!(
c.tag_list(),
[
json!({"name": "Speed", "type": "REAL"}),
json!({"name": "Valve1", "type": "MyAOI", "aoi_readable": ["Open", "Cmd"]}),
json!({"name": "Recipe", "type": "UDT_R"}),
]
);
}
#[test]
fn tag_list_dims_and_strings_and_type_map() {
let string = Node {
kind: Kind::String,
..strct("STR20", vec![])
};
let mut tags = IndexMap::new();
tags.insert("Tanks".to_owned(), strct("Tank", vec![4]));
tags.insert("Grid".to_owned(), atomic("DINT", vec![3, 5], false));
tags.insert("Msg".to_owned(), string.clone());
let mut types = IndexMap::new();
let tank = vec![
("ZZZZZZZZZZTank0", atomic("SINT", vec![], true)),
("Level", atomic("REAL", vec![], true)),
(
"Name",
Node {
type_name: "STRING".into(),
..string
},
),
("Hist", atomic("REAL", vec![8], true)),
];
types.insert(
"Tank".to_owned(),
type_def(tank, &["Level", "Name", "Hist"], None),
);
let old = vec![
("__pad", atomic("SINT", vec![], true)),
("A", atomic("DINT", vec![], true)),
];
types.insert("Old".to_owned(), type_def(old, &["A"], None));
let valve = vec![
("Open", atomic("BOOL", vec![], true)),
("Cmd", atomic("BOOL", vec![], true)),
];
let aoi = Aoi {
whole_read: true,
readable: vec!["Open".into()],
};
types.insert(
"Valve_AOI".to_owned(),
type_def(valve, &["Open", "Cmd"], Some(aoi)),
);
let c = Catalog::new(Json::Null, tags, types);
assert_eq!(
c.tag_list(),
[
json!({"name": "Tanks", "type": "Tank", "dims": [4]}),
json!({"name": "Grid", "type": "DINT", "dims": [3, 5]}),
json!({"name": "Msg", "type": "STRING"}),
]
);
assert_eq!(
Json::Object(c.type_map()),
json!({
"Tank": {"members": {"Level": {"type": "REAL"}, "Name": {"type": "STRING"},
"Hist": {"type": "REAL", "dims": [8]}}},
"Old": {"members": {"A": {"type": "DINT"}}},
"Valve_AOI": {"members": {"Open": {"type": "BOOL"}}, "aoi": true},
})
);
}
}