Skip to main content

ciplink_server/
plc.rs

1//! The PLC side of the server: the driver calls it makes ([`Plc`], a real controller or the mock) and the
2//! controller structure it serves ([`Catalog`]).
3
4use std::collections::HashMap;
5
6use ciplink::Value;
7use ciplink::cip::TagResult;
8use ciplink::logix::driver::{LogixDriver, LogixOptions};
9use ciplink::logix::metadata::TextEncoding;
10use ciplink::logix::types::TypeRef;
11use indexmap::IndexMap;
12use serde_json::{Map, Value as Json, json};
13
14use crate::config::PlcConfig;
15
16/// The driver calls the server makes. `Err` is a lost connection (the worker reconnects); a tag that failed
17/// is its result's error.
18pub trait Plc: Send {
19    /// Connects (and uploads the tag definitions).
20    fn open(&mut self) -> Result<(), String>;
21    /// Disconnects; errors are ignored.
22    fn close(&mut self);
23    /// Reads tags in one call, one result per tag in order.
24    fn read(&mut self, tags: &[&str]) -> Result<Vec<TagResult>, String>;
25    /// Writes one tag; a value that doesn't fit is the result's `bad_value` error, nothing sent.
26    fn write(&mut self, tag: &str, value: &Value) -> Result<TagResult, String>;
27    /// The controller structure, after a connect and after the tag table version changed.
28    fn catalog(&mut self) -> Catalog;
29    /// The tag table version (null when unknown); it changes after an online edit or a download.
30    fn version(&mut self) -> Json;
31    /// Request and reply bytes per packet.
32    fn connection_size(&self) -> usize;
33    /// Whether the driver reads controller descriptions; without them no tag gets one (the mock has none).
34    fn describes(&self) -> bool {
35        false
36    }
37    /// The controller descriptions of tags, None for each the controller has none of (or can't tell).
38    fn descriptions(&mut self, tags: &[&str]) -> Result<Vec<Option<String>>, String> {
39        Ok(vec![None; tags.len()])
40    }
41}
42
43/// Makes the (not yet opened) driver of a PLC.
44pub type Factory = std::sync::Arc<dyn Fn(&PlcConfig) -> Result<Box<dyn Plc>, String> + Send + Sync>;
45
46/// What kind of value a tag or member holds.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum Kind {
49    /// An atomic type (BOOL, DINT, REAL, DWORD, ...).
50    Atomic,
51    /// A structure: UDT, AOI or predefined type.
52    Struct,
53    /// A string type (a structure the driver reads as a string).
54    String,
55}
56
57/// A tag or a structure member.
58#[derive(Clone, Debug, PartialEq)]
59pub struct Node {
60    /// The controller's type name: `DINT`, a UDT name, `DWORD` for a BOOL array; empty when unknown.
61    pub type_name: String,
62    /// Atomic, structure or string.
63    pub kind: Kind,
64    /// Array dims: a tag's dimensions, a member's `[array]`; empty for a scalar. A BOOL array (tag or member)
65    /// counts the 32-bit words that hold it, as controllers report it.
66    pub dims: Vec<u64>,
67    /// True for a structure member.
68    pub member: bool,
69    /// The symbol instance ID of a tag.
70    pub instance_id: Option<u32>,
71    /// Bytes of a value of a structure or string type.
72    pub structure_size: Option<u64>,
73}
74
75impl Node {
76    /// A BOOL array: a DWORD array.
77    pub fn bool_array(&self) -> bool {
78        self.type_name == "DWORD" && !self.dims.is_empty()
79    }
80
81    /// The type as clients see it: `BOOL` for a BOOL array, `STRING` for any string type.
82    pub fn client_type(&self) -> &str {
83        if self.kind == Kind::String {
84            "STRING"
85        } else if self.bool_array() {
86            "BOOL"
87        } else {
88            &self.type_name
89        }
90    }
91
92    /// The dims as clients see them: a BOOL array's words as BOOLs.
93    pub fn client_dims(&self) -> Vec<u64> {
94        let mut dims = self.dims.clone();
95        if self.bool_array() {
96            if let Some(last) = dims.last_mut() {
97                *last *= 32;
98            }
99        }
100        dims
101    }
102}
103
104/// What an AOI probe found.
105#[derive(Clone, Debug, Default, PartialEq, Eq)]
106pub struct Aoi {
107    /// A whole instance can be read.
108    pub whole_read: bool,
109    /// Members that can be read from outside, in template order.
110    pub readable: Vec<String>,
111}
112
113/// A structure type.
114#[derive(Clone, Debug, Default, PartialEq)]
115pub struct TypeDef {
116    /// Every member, private ones included.
117    pub members: IndexMap<String, Node>,
118    /// The members clients see, in template order.
119    pub public: Vec<String>,
120    /// Bytes of a value.
121    pub structure_size: u64,
122    /// For an AOI, what its probe found.
123    pub aoi: Option<Aoi>,
124    /// False if this type, or a structure inside it, is an AOI the controller refuses to read whole.
125    pub whole_readable: bool,
126}
127
128/// The controller structure: tags and structure types (string types left out).
129#[derive(Clone, Debug, Default, PartialEq)]
130pub struct Catalog {
131    /// The tag table version.
132    pub version: Json,
133    /// Tags by name, in upload order.
134    pub tags: IndexMap<String, Node>,
135    /// Structure types by name.
136    pub types: IndexMap<String, TypeDef>,
137    index: HashMap<String, String>,
138}
139
140/// Bytes of an atomic type, None when unknown.
141pub fn atomic_size(type_name: &str) -> Option<u64> {
142    Some(match type_name {
143        "BOOL" | "SINT" | "USINT" | "BYTE" => 1,
144        "INT" | "UINT" | "WORD" => 2,
145        "DINT" | "UDINT" | "DWORD" | "REAL" => 4,
146        "LINT" | "ULINT" | "LWORD" | "LREAL" => 8,
147        _ => return None,
148    })
149}
150
151impl Catalog {
152    /// A catalog of these tags and types; computes the lookup index and which types read whole.
153    pub fn new(
154        version: Json,
155        tags: IndexMap<String, Node>,
156        mut types: IndexMap<String, TypeDef>,
157    ) -> Self {
158        let mut memo = HashMap::new();
159        let names: Vec<String> = types.keys().cloned().collect();
160        for name in &names {
161            whole_readable(name, &types, &mut memo);
162        }
163        for (name, t) in &mut types {
164            t.whole_readable = memo.get(name).copied().unwrap_or(true);
165        }
166        let index = tags.keys().map(|k| (k.to_lowercase(), k.clone())).collect();
167        Catalog {
168            version,
169            tags,
170            types,
171            index,
172        }
173    }
174
175    /// A tag by name, ignoring case.
176    pub fn tag(&self, name: &str) -> Option<&Node> {
177        self.tags
178            .get(name)
179            .or_else(|| self.tags.get(self.index.get(&name.to_lowercase())?))
180    }
181
182    /// A structure type by name.
183    pub fn type_def(&self, name: &str) -> Option<&TypeDef> {
184        self.types.get(name)
185    }
186
187    /// The definition of a tag or member path, indexes and element counts ignored (as ciplink's
188    /// `get_tag_info`); None for unknown names and bits.
189    pub fn info(&self, name: &str) -> Option<&Node> {
190        if let Some(node) = self.tag(name) {
191            return Some(node); // a flat mock file may list dotted names
192        }
193        let (prefix, rest) = match name.find('.') {
194            Some(dot)
195                if name.len() >= 8
196                    && name.is_char_boundary(8)
197                    && name[..8].eq_ignore_ascii_case("program:") =>
198            {
199                (&name[..=dot], &name[dot + 1..])
200            }
201            _ => ("", name),
202        };
203        let stripped = strip(rest);
204        let mut attrs = stripped.split('.');
205        let base = attrs.next()?;
206        let mut node = self.tag(&format!("{prefix}{base}"))?;
207        for attr in attrs {
208            let t = self.types.get(&node.type_name)?;
209            node = t.members.get(attr).or_else(|| {
210                t.members
211                    .iter()
212                    .find(|(k, _)| k.eq_ignore_ascii_case(attr))
213                    .map(|(_, v)| v)
214            })?;
215        }
216        Some(node)
217    }
218
219    /// The structure size of a struct node that can be read whole, else None.
220    pub fn struct_size(&self, node: &Node) -> Option<u64> {
221        if node.kind != Kind::Struct {
222            return None;
223        }
224        let t = self.types.get(&node.type_name)?;
225        t.whole_readable.then_some(t.structure_size)
226    }
227
228    /// `GET /tags` `tags`: name, type, dims of arrays and, for AOIs, the readable members.
229    pub fn tag_list(&self) -> Vec<Json> {
230        self.tags
231            .iter()
232            .map(|(name, node)| {
233                let mut entry = Map::new();
234                entry.insert("name".into(), json!(name));
235                entry.insert("type".into(), json!(node.client_type()));
236                if !node.dims.is_empty() {
237                    entry.insert("dims".into(), json!(node.client_dims()));
238                }
239                if node.kind == Kind::Struct {
240                    if let Some(aoi) = self.types.get(&node.type_name).and_then(|t| t.aoi.as_ref())
241                    {
242                        entry.insert("aoi_readable".into(), json!(aoi.readable));
243                    }
244                }
245                Json::Object(entry)
246            })
247            .collect()
248    }
249
250    /// `GET /tags` `types`: UDT and AOI name -> `{"members": {m: {type, dims?}}, "aoi"?: true}`; private members
251    /// hidden.
252    pub fn type_map(&self) -> Map<String, Json> {
253        self.types
254            .iter()
255            .map(|(name, t)| {
256                let names = t.aoi.as_ref().map_or(&t.public, |a| &a.readable);
257                let members: Map<String, Json> = names
258                    .iter()
259                    .map(|m| {
260                        let mut member = Map::new();
261                        match t.members.get(m) {
262                            Some(node) => {
263                                member.insert("type".into(), json!(node.client_type()));
264                                if !node.dims.is_empty() {
265                                    member.insert("dims".into(), json!(node.client_dims()));
266                                }
267                            }
268                            None => {
269                                member.insert("type".into(), json!(""));
270                            }
271                        }
272                        (m.clone(), Json::Object(member))
273                    })
274                    .collect();
275                let mut entry = Map::new();
276                entry.insert("members".into(), Json::Object(members));
277                if t.aoi.is_some() {
278                    entry.insert("aoi".into(), json!(true));
279                }
280                (name.clone(), Json::Object(entry))
281            })
282            .collect()
283    }
284}
285
286fn whole_readable(
287    name: &str,
288    types: &IndexMap<String, TypeDef>,
289    memo: &mut HashMap<String, bool>,
290) -> bool {
291    if let Some(ok) = memo.get(name) {
292        return *ok;
293    }
294    memo.insert(name.to_owned(), true); // guards recursive definitions
295    let Some(t) = types.get(name) else {
296        return true;
297    };
298    let mut ok = !t.aoi.as_ref().is_some_and(|a| !a.whole_read);
299    for member in t.members.values() {
300        if ok && member.kind == Kind::Struct {
301            ok = whole_readable(&member.type_name, types, memo);
302        }
303    }
304    memo.insert(name.to_owned(), ok);
305    ok
306}
307
308/// The path without `[...]` indexes and `{n}` element counts.
309fn strip(path: &str) -> String {
310    let mut out = String::with_capacity(path.len());
311    let mut skip = None;
312    for c in path.chars() {
313        match (skip, c) {
314            (None, '[') => skip = Some(']'),
315            (None, '{') => skip = Some('}'),
316            (Some(end), c) if c == end => skip = None,
317            (Some(_), _) => {}
318            (None, c) => out.push(c),
319        }
320    }
321    out
322}
323
324// ------------------------------------------------------------------------------------------------ real PLCs
325
326/// A real controller through ciplink's [`LogixDriver`].
327pub struct LogixPlc {
328    driver: LogixDriver,
329}
330
331impl LogixPlc {
332    /// The driver of a configured PLC, not yet opened.
333    pub fn new(cfg: &PlcConfig) -> Result<Self, String> {
334        let mut options = LogixOptions::default();
335        if let Some(cache) = &cfg.tag_cache_file {
336            if let Some(dir) = cache.parent() {
337                let _ = std::fs::create_dir_all(dir);
338            }
339            options.tag_cache_file = Some(cache.clone());
340        }
341        let driver = LogixDriver::new(&cfg.path, options).map_err(|e| e.to_string())?;
342        Ok(LogixPlc { driver })
343    }
344
345    /// The factory of real drivers.
346    pub fn factory() -> Factory {
347        std::sync::Arc::new(|cfg: &PlcConfig| Ok(Box::new(LogixPlc::new(cfg)?) as Box<dyn Plc>))
348    }
349}
350
351fn node_of(data_type: &TypeRef, dims: Vec<u64>, member: bool, instance_id: Option<u32>) -> Node {
352    let (kind, structure_size) = match data_type {
353        TypeRef::Atomic(_) => (Kind::Atomic, None),
354        TypeRef::Struct(dt) => (
355            if dt.string.is_some() {
356                Kind::String
357            } else {
358                Kind::Struct
359            },
360            Some(u64::from(dt.template.structure_size)),
361        ),
362    };
363    Node {
364        type_name: data_type.name().unwrap_or_default().to_owned(),
365        kind,
366        dims,
367        member,
368        instance_id,
369        structure_size,
370    }
371}
372
373impl Plc for LogixPlc {
374    fn open(&mut self) -> Result<(), String> {
375        self.driver.open().map(|_| ()).map_err(|e| e.to_string())
376    }
377
378    fn close(&mut self) {
379        let _ = self.driver.close();
380    }
381
382    fn read(&mut self, tags: &[&str]) -> Result<Vec<TagResult>, String> {
383        self.driver.read(tags).map_err(|e| e.to_string())
384    }
385
386    fn write(&mut self, tag: &str, value: &Value) -> Result<TagResult, String> {
387        let mut results = self
388            .driver
389            .write(&[(tag, value.clone())])
390            .map_err(|e| e.to_string())?;
391        results.pop().ok_or_else(|| "no write result".to_owned())
392    }
393
394    fn catalog(&mut self) -> Catalog {
395        let defs = self.driver.definitions();
396        let probes: HashMap<String, Aoi> = defs
397            .probes
398            .iter()
399            .filter_map(|(dt, probe)| {
400                Some((
401                    dt.name.clone()?,
402                    Aoi {
403                        whole_read: probe.whole_read,
404                        readable: probe.readable.clone(),
405                    },
406                ))
407            })
408            .collect();
409        let tags = defs
410            .tags
411            .iter()
412            .map(|(name, t)| {
413                let dims = t.dimensions[..usize::from(t.dim.min(3))]
414                    .iter()
415                    .map(|&d| u64::from(d))
416                    .collect();
417                (
418                    name.clone(),
419                    node_of(&t.data_type, dims, false, Some(t.instance_id)),
420                )
421            })
422            .collect();
423        let mut types = IndexMap::new();
424        for (name, dt) in &defs.data_types {
425            let Some(name) = name else { continue };
426            if dt.string.is_some() {
427                continue;
428            }
429            let members = dt
430                .internal_tags
431                .iter()
432                .map(|(m, info)| {
433                    let dims = info
434                        .array
435                        .filter(|&a| a > 0)
436                        .map(|a| vec![u64::from(a)])
437                        .unwrap_or_default();
438                    (m.clone(), node_of(&info.data_type, dims, true, None))
439                })
440                .collect();
441            types.insert(
442                name.clone(),
443                TypeDef {
444                    members,
445                    public: dt.attributes.clone(),
446                    structure_size: u64::from(dt.template.structure_size),
447                    aoi: probes.get(name).cloned(),
448                    whole_readable: true,
449                },
450            );
451        }
452        Catalog::new(self.version(), tags, types)
453    }
454
455    fn version(&mut self) -> Json {
456        self.driver
457            .tag_table_version()
458            .map_or(Json::Null, |v| json!([v.0, v.1, v.2, v.3, v.4]))
459    }
460
461    fn connection_size(&self) -> usize {
462        usize::from(self.driver.connection_size())
463    }
464
465    fn describes(&self) -> bool {
466        true
467    }
468
469    fn descriptions(&mut self, tags: &[&str]) -> Result<Vec<Option<String>>, String> {
470        let results = self
471            .driver
472            .get_tag_descriptions(tags, 0x007F, TextEncoding::Utf8)
473            .map_err(|e| e.to_string())?;
474        Ok(results
475            .into_iter()
476            .map(|r| match (r.error, r.value) {
477                (None, Some(Value::String(s))) => Some(s),
478                _ => None,
479            })
480            .collect())
481    }
482}
483
484#[cfg(test)]
485#[allow(clippy::unwrap_used)]
486mod tests {
487    use super::*;
488
489    fn atomic(t: &str, dims: Vec<u64>, member: bool) -> Node {
490        Node {
491            type_name: t.into(),
492            kind: Kind::Atomic,
493            dims,
494            member,
495            instance_id: None,
496            structure_size: None,
497        }
498    }
499
500    fn catalog() -> Catalog {
501        let mut udt = TypeDef {
502            public: vec!["Flags".into(), "Speed".into()],
503            structure_size: 8,
504            ..TypeDef::default()
505        };
506        udt.members
507            .insert("Flags".into(), atomic("DWORD", vec![1], true));
508        udt.members
509            .insert("Speed".into(), atomic("REAL", vec![], true));
510        let mut tags = IndexMap::new();
511        tags.insert("Bits".into(), atomic("DWORD", vec![3], false)); // BOOL[96]
512        tags.insert("Word".into(), atomic("DWORD", vec![], false));
513        tags.insert(
514            "Motor".into(),
515            Node {
516                type_name: "MotorData".into(),
517                kind: Kind::Struct,
518                dims: vec![],
519                member: false,
520                instance_id: Some(5),
521                structure_size: Some(8),
522            },
523        );
524        let mut types = IndexMap::new();
525        types.insert("MotorData".into(), udt);
526        Catalog::new(Json::Null, tags, types)
527    }
528
529    #[test]
530    fn bool_arrays_and_bit_strings() {
531        let c = catalog();
532        assert_eq!(
533            c.tag_list(),
534            vec![
535                json!({"name": "Bits", "type": "BOOL", "dims": [96]}),
536                json!({"name": "Word", "type": "DWORD"}),
537                json!({"name": "Motor", "type": "MotorData"}),
538            ]
539        );
540        assert_eq!(
541            Json::Object(c.type_map()),
542            json!({"MotorData": {"members": {"Flags": {"type": "BOOL", "dims": [32]}, "Speed": {"type": "REAL"}}}})
543        );
544    }
545
546    #[test]
547    fn info_ignores_case_and_indexes() {
548        let c = catalog();
549        assert_eq!(
550            c.info("motor.SPEED").map(|n| n.type_name.as_str()),
551            Some("REAL")
552        );
553        assert_eq!(
554            c.info("Motor.Flags[3]").map(|n| n.type_name.as_str()),
555            Some("DWORD")
556        );
557        assert_eq!(c.info("Bits{4}").map(|n| n.dims.clone()), Some(vec![3]));
558        assert!(c.info("Motor.Nope").is_none());
559        assert_eq!(c.struct_size(c.info("Motor").unwrap()), Some(8));
560    }
561    // ---- ports of ciplink-tagserver's tests/test_config_values.py (values.tag_list and values.type_map)
562
563    fn strct(t: &str, dims: Vec<u64>) -> Node {
564        Node {
565            kind: Kind::Struct,
566            structure_size: Some(8),
567            ..atomic(t, dims, false)
568        }
569    }
570
571    fn type_def(members: Vec<(&str, Node)>, public: &[&str], aoi: Option<Aoi>) -> TypeDef {
572        TypeDef {
573            members: members
574                .into_iter()
575                .map(|(m, n)| (m.to_owned(), Node { member: true, ..n }))
576                .collect(),
577            public: public.iter().map(|p| (*p).to_owned()).collect(),
578            structure_size: 8,
579            aoi,
580            whole_readable: true,
581        }
582    }
583
584    #[test]
585    fn tag_list_with_aoi() {
586        let mut tags = IndexMap::new();
587        tags.insert("Speed".to_owned(), atomic("REAL", vec![], false));
588        tags.insert("Valve1".to_owned(), strct("MyAOI", vec![]));
589        tags.insert("Recipe".to_owned(), strct("UDT_R", vec![]));
590        let mut types = IndexMap::new();
591        let aoi = Aoi {
592            whole_read: false,
593            readable: vec!["Open".into(), "Cmd".into()],
594        };
595        let bools = vec![
596            ("Open", atomic("BOOL", vec![], true)),
597            ("Cmd", atomic("BOOL", vec![], true)),
598            ("Local1", atomic("BOOL", vec![], true)),
599        ];
600        types.insert(
601            "MyAOI".to_owned(),
602            type_def(bools, &["Open", "Cmd", "Local1"], Some(aoi)),
603        );
604        types.insert("UDT_R".to_owned(), type_def(vec![], &[], None));
605        let c = Catalog::new(Json::Null, tags, types);
606        assert_eq!(
607            c.tag_list(),
608            [
609                json!({"name": "Speed", "type": "REAL"}),
610                json!({"name": "Valve1", "type": "MyAOI", "aoi_readable": ["Open", "Cmd"]}),
611                json!({"name": "Recipe", "type": "UDT_R"}),
612            ]
613        );
614    }
615
616    #[test]
617    fn tag_list_dims_and_strings_and_type_map() {
618        let string = Node {
619            kind: Kind::String,
620            ..strct("STR20", vec![])
621        };
622        let mut tags = IndexMap::new();
623        tags.insert("Tanks".to_owned(), strct("Tank", vec![4]));
624        tags.insert("Grid".to_owned(), atomic("DINT", vec![3, 5], false));
625        tags.insert("Msg".to_owned(), string.clone());
626        // ciplink leaves string types out of the types, and hidden members out of `public`
627        let mut types = IndexMap::new();
628        let tank = vec![
629            ("ZZZZZZZZZZTank0", atomic("SINT", vec![], true)),
630            ("Level", atomic("REAL", vec![], true)),
631            (
632                "Name",
633                Node {
634                    type_name: "STRING".into(),
635                    ..string
636                },
637            ),
638            ("Hist", atomic("REAL", vec![8], true)),
639        ];
640        types.insert(
641            "Tank".to_owned(),
642            type_def(tank, &["Level", "Name", "Hist"], None),
643        );
644        let old = vec![
645            ("__pad", atomic("SINT", vec![], true)),
646            ("A", atomic("DINT", vec![], true)),
647        ];
648        types.insert("Old".to_owned(), type_def(old, &["A"], None));
649        let valve = vec![
650            ("Open", atomic("BOOL", vec![], true)),
651            ("Cmd", atomic("BOOL", vec![], true)),
652        ];
653        let aoi = Aoi {
654            whole_read: true,
655            readable: vec!["Open".into()],
656        };
657        types.insert(
658            "Valve_AOI".to_owned(),
659            type_def(valve, &["Open", "Cmd"], Some(aoi)),
660        );
661        let c = Catalog::new(Json::Null, tags, types);
662        assert_eq!(
663            c.tag_list(),
664            [
665                json!({"name": "Tanks", "type": "Tank", "dims": [4]}),
666                json!({"name": "Grid", "type": "DINT", "dims": [3, 5]}),
667                json!({"name": "Msg", "type": "STRING"}),
668            ]
669        );
670        assert_eq!(
671            Json::Object(c.type_map()),
672            json!({
673                "Tank": {"members": {"Level": {"type": "REAL"}, "Name": {"type": "STRING"},
674                                     "Hist": {"type": "REAL", "dims": [8]}}},
675                "Old": {"members": {"A": {"type": "DINT"}}},
676                "Valve_AOI": {"members": {"Open": {"type": "BOOL"}}, "aoi": true},
677            })
678        );
679    }
680}