Skip to main content

ciplink_server/
config.rs

1//! Reads the TOML config file (the same format as the Python ciplink-tagserver).
2
3use std::path::{Path, PathBuf};
4
5use indexmap::IndexMap;
6use serde_json::{Map, Value as Json};
7
8use crate::rules::Resolver;
9
10/// One `[plc.NAME]` table.
11#[derive(Clone, Debug, Default)]
12pub struct PlcConfig {
13    /// The PLC's name in the config, the `<plc>` of qualified tag names.
14    pub name: String,
15    /// A ciplink path, e.g. `"10.0.0.5"` or `"10.0.0.5/1/3"`.
16    pub path: String,
17    /// Optional tag cache file for fast restarts.
18    pub tag_cache_file: Option<PathBuf>,
19    /// Optional mock tags file, used only with `--mock`.
20    pub mock_tags: Option<PathBuf>,
21    /// `[[plc.X.rule]]` tables in file order.
22    pub rules: Vec<Map<String, Json>>,
23    /// `[[plc.X.group]]` tables: name, rate_ms, match.
24    pub groups: Vec<Map<String, Json>>,
25    /// `[plc.X.udt.UDT]` tables: member -> entry.
26    pub udt: Map<String, Json>,
27    /// `[plc.X.tags]`: tag path -> entry.
28    pub tags: Map<String, Json>,
29}
30
31/// One `[token.NAME]` table.
32#[derive(Clone, Debug, Default)]
33pub struct TokenConfig {
34    /// The table name, written to the audit log.
35    pub name: String,
36    /// The secret a client sends in `hello` or as a Bearer token.
37    pub secret: String,
38    /// Match patterns on qualified names this token may write.
39    pub write: Vec<String>,
40}
41
42/// The whole config.
43#[derive(Clone, Debug)]
44pub struct Config {
45    /// Address the server binds to.
46    pub host: String,
47    /// Port the server binds to.
48    pub port: u16,
49    /// Poll period per PLC.
50    pub cycle_ms: u64,
51    /// JSON lines of every write attempt, None for none.
52    pub audit_log: Option<PathBuf>,
53    /// Each tier-2 tag is read about this often.
54    pub tier2_period_s: f64,
55    /// A tag leaving tier 1 stays at tier 2 this long.
56    pub grace_s: f64,
57    /// Learned page -> tags, None to keep them in memory only.
58    pub pages_file: Option<PathBuf>,
59    /// Read structs whole when that saves reads.
60    pub planner: bool,
61    /// Largest struct the planner reads whole.
62    pub whole_max_bytes: u64,
63    /// A pressed momentary tag with no hold this long is released.
64    pub momentary_timeout_ms: u64,
65    /// The PLCs in file order.
66    pub plcs: IndexMap<String, PlcConfig>,
67    /// The tokens in file order.
68    pub tokens: IndexMap<String, TokenConfig>,
69    /// `[decorator.NAME]` tables.
70    pub decorators: Map<String, Json>,
71}
72
73impl Default for Config {
74    fn default() -> Self {
75        Config {
76            host: "127.0.0.1".into(),
77            port: 8765,
78            cycle_ms: 250,
79            audit_log: Some("audit.log".into()),
80            tier2_period_s: 3.0,
81            grace_s: 10.0,
82            pages_file: Some("pages.json".into()),
83            planner: true,
84            whole_max_bytes: 1500,
85            momentary_timeout_ms: 1000,
86            plcs: IndexMap::new(),
87            tokens: IndexMap::new(),
88            decorators: Map::new(),
89        }
90    }
91}
92
93impl Config {
94    /// Splits `<plc>/<tag>` into (plc, tag); an unqualified name means the only PLC, if there is one.
95    pub fn qualify<'a>(&'a self, name: &'a str) -> Result<(&'a str, &'a str), String> {
96        let (plc, tag) = if let Some((plc, tag)) = name.split_once('/') {
97            if !self.plcs.contains_key(plc) {
98                return Err(format!("unknown PLC {}", py_repr(plc)));
99            }
100            (plc, tag)
101        } else if self.plcs.len() == 1 {
102            (self.plcs.keys().next().map_or("", String::as_str), name)
103        } else {
104            return Err(format!(
105                "tag {} must be qualified as <plc>/<tag>",
106                py_repr(name)
107            ));
108        };
109        if tag.is_empty() {
110            return Err(format!("empty tag name in {}", py_repr(name)));
111        }
112        Ok((plc, tag))
113    }
114}
115
116/// A string as Python's `repr()` shows it in error messages: in single quotes.
117pub fn py_repr(s: &str) -> String {
118    if s.contains('\'') && !s.contains('"') {
119        format!("\"{s}\"")
120    } else {
121        format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
122    }
123}
124
125/// Reads and checks a config file; errors name the file.
126pub fn load_config(path: &Path) -> Result<Config, String> {
127    let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
128    let data: Json = toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
129    let base = path.parent().unwrap_or(Path::new("."));
130    parse_config(&data, base).map_err(|e| format!("{}: {e}", path.display()))
131}
132
133/// Builds a Config from the parsed TOML; relative file paths are relative to `base_dir`.
134pub fn parse_config(data: &Json, base_dir: &Path) -> Result<Config, String> {
135    let resolve = |value: Option<&Json>| -> Option<PathBuf> {
136        match value.and_then(Json::as_str) {
137            Some(s) if !s.is_empty() => Some(base_dir.join(s)),
138            _ => None,
139        }
140    };
141    let empty = Map::new();
142    let server = data
143        .get("server")
144        .and_then(Json::as_object)
145        .unwrap_or(&empty);
146    let defaults = Config::default();
147    let int = |key: &str, default: u64| -> Result<u64, String> {
148        match server.get(key) {
149            None => Ok(default),
150            Some(v) => v
151                .as_u64()
152                .ok_or_else(|| format!("server.{key} must be a non-negative integer")),
153        }
154    };
155    let float = |key: &str, default: f64| -> Result<f64, String> {
156        match server.get(key) {
157            None => Ok(default),
158            Some(v) => v
159                .as_f64()
160                .ok_or_else(|| format!("server.{key} must be a number")),
161        }
162    };
163    let audit_default = Json::String("audit.log".into());
164    let pages_default = Json::String("pages.json".into());
165    let mut config = Config {
166        host: server
167            .get("host")
168            .and_then(Json::as_str)
169            .unwrap_or(&defaults.host)
170            .to_owned(),
171        port: u16::try_from(int("port", 8765)?).map_err(|_| "server.port is out of range")?,
172        cycle_ms: int("cycle_ms", defaults.cycle_ms)?,
173        audit_log: resolve(Some(server.get("audit_log").unwrap_or(&audit_default))),
174        tier2_period_s: float("tier2_period_s", defaults.tier2_period_s)?,
175        grace_s: float("grace_s", defaults.grace_s)?,
176        pages_file: resolve(Some(server.get("pages_file").unwrap_or(&pages_default))),
177        planner: server
178            .get("planner")
179            .and_then(Json::as_bool)
180            .unwrap_or(true),
181        whole_max_bytes: int("whole_max_bytes", defaults.whole_max_bytes)?,
182        momentary_timeout_ms: int("momentary_timeout_ms", defaults.momentary_timeout_ms)?,
183        decorators: object(data.get("decorator"), "decorator")?,
184        ..defaults
185    };
186    if config.cycle_ms == 0 {
187        return Err("server.cycle_ms must be > 0".into());
188    }
189    for (name, plc) in object(data.get("plc"), "plc")? {
190        let where_ = format!("plc.{name}");
191        let path = plc
192            .get("path")
193            .and_then(Json::as_str)
194            .ok_or_else(|| format!("{where_} needs a path"))?;
195        let list = |key: &str| -> Result<Vec<Map<String, Json>>, String> {
196            match plc.get(key) {
197                None => Ok(Vec::new()),
198                Some(Json::Array(items)) => items
199                    .iter()
200                    .map(|t| {
201                        t.as_object()
202                            .cloned()
203                            .ok_or_else(|| format!("{where_}.{key} entries must be tables"))
204                    })
205                    .collect(),
206                Some(_) => Err(format!("{where_}.{key} must be an array of tables")),
207            }
208        };
209        let cfg = PlcConfig {
210            name: name.clone(),
211            path: path.to_owned(),
212            tag_cache_file: resolve(plc.get("tag_cache_file")),
213            mock_tags: resolve(plc.get("mock_tags")),
214            rules: list("rule")?,
215            groups: list("group")?,
216            udt: object(plc.get("udt"), &format!("{where_}.udt"))?,
217            tags: object(plc.get("tags"), &format!("{where_}.tags"))?,
218        };
219        // unknown decorators, bad entries
220        Resolver::new(
221            &config.decorators,
222            &cfg.udt,
223            &cfg.tags,
224            Vec::new(),
225            Vec::new(),
226            &where_,
227        )?;
228        for group in &cfg.groups {
229            let rate_ok = group
230                .get("rate_ms")
231                .is_some_and(|r| r.as_u64().is_some_and(|r| r > 0));
232            if !rate_ok || !group.contains_key("name") {
233                return Err(format!(
234                    "{where_}.group needs a name and an integer rate_ms > 0: {}",
235                    Json::Object(group.clone())
236                ));
237            }
238        }
239        config.plcs.insert(name.clone(), cfg);
240    }
241    for (name, token) in object(data.get("token"), "token")? {
242        let secret = token
243            .get("secret")
244            .and_then(Json::as_str)
245            .ok_or_else(|| format!("token.{name} needs a secret"))?;
246        let write = match token.get("write") {
247            None => Vec::new(),
248            Some(Json::Array(items)) => items
249                .iter()
250                .map(|p| {
251                    p.as_str()
252                        .map(str::to_owned)
253                        .ok_or_else(|| format!("token.{name}.write must be a list of strings"))
254                })
255                .collect::<Result<_, _>>()?,
256            Some(Json::String(p)) => vec![p.clone()],
257            Some(_) => return Err(format!("token.{name}.write must be a list of strings")),
258        };
259        config.tokens.insert(
260            name.clone(),
261            TokenConfig {
262                name: name.clone(),
263                secret: secret.to_owned(),
264                write,
265            },
266        );
267    }
268    Ok(config)
269}
270
271fn object(value: Option<&Json>, what: &str) -> Result<Map<String, Json>, String> {
272    match value {
273        None => Ok(Map::new()),
274        Some(Json::Object(map)) => Ok(map.clone()),
275        Some(_) => Err(format!("{what} must be a table")),
276    }
277}
278
279#[cfg(test)]
280#[allow(clippy::unwrap_used)]
281mod tests {
282    use super::*;
283
284    fn parse(text: &str) -> Result<Config, String> {
285        parse_config(
286            &toml::from_str(text).map_err(|e| e.to_string())?,
287            Path::new("base"),
288        )
289    }
290
291    #[test]
292    fn defaults_and_relative_paths() {
293        let config = parse("[plc.P]\npath = \"1.2.3.4\"\nmock_tags = \"m.json\"\n").unwrap();
294        assert_eq!(config.port, 8765);
295        assert_eq!(config.cycle_ms, 250);
296        assert_eq!(config.audit_log, Some(Path::new("base").join("audit.log")));
297        assert_eq!(
298            config.pages_file,
299            Some(Path::new("base").join("pages.json"))
300        );
301        assert_eq!(
302            config.plcs["P"].mock_tags,
303            Some(Path::new("base").join("m.json"))
304        );
305        assert_eq!(config.plcs["P"].tag_cache_file, None);
306    }
307
308    #[test]
309    fn empty_paths_turn_files_off() {
310        let config = parse("[server]\naudit_log = \"\"\npages_file = \"\"\n").unwrap();
311        assert_eq!(config.audit_log, None);
312        assert_eq!(config.pages_file, None);
313    }
314
315    #[test]
316    fn qualify_names() {
317        let config = parse("[plc.P]\npath = \"x\"\n").unwrap();
318        assert_eq!(config.qualify("Tag").unwrap(), ("P", "Tag"));
319        assert_eq!(config.qualify("P/Tag").unwrap(), ("P", "Tag"));
320        assert_eq!(config.qualify("Q/Tag").unwrap_err(), "unknown PLC 'Q'");
321        assert_eq!(config.qualify("P/").unwrap_err(), "empty tag name in 'P/'");
322        let two = parse("[plc.P]\npath = \"x\"\n[plc.Q]\npath = \"y\"\n").unwrap();
323        assert_eq!(
324            two.qualify("Tag").unwrap_err(),
325            "tag 'Tag' must be qualified as <plc>/<tag>"
326        );
327    }
328
329    #[test]
330    fn bad_groups_and_decorators_are_errors() {
331        let bad_group =
332            "[plc.P]\npath = \"x\"\n[[plc.P.group]]\nname = \"g\"\nrate_ms = 0\nmatch = \"A\"\n";
333        assert!(parse(bad_group).unwrap_err().contains("rate_ms > 0"));
334        let unknown = "[plc.P]\npath = \"x\"\n[plc.P.tags]\nA = \"nope\"\n";
335        assert_eq!(
336            parse(unknown).unwrap_err(),
337            "plc.P.tags.A: unknown decorator 'nope'"
338        );
339    }
340
341    #[test]
342    fn tokens() {
343        let config =
344            parse("[token.hmi]\nsecret = \"s\"\nwrite = [\"P/A*\"]\n[token.v]\nsecret = \"t\"\n")
345                .unwrap();
346        assert_eq!(config.tokens["hmi"].write, vec!["P/A*"]);
347        assert!(config.tokens["v"].write.is_empty());
348    }
349
350    // ---- ports of ciplink-tagserver's tests/test_config_values.py
351
352    #[test]
353    fn load_config_file() {
354        let dir = crate::testing::TempDir::new();
355        let path = dir.path().join("tagserver.toml");
356        std::fs::write(
357            &path,
358            r#"
359[server]
360host = "0.0.0.0"
361port = 9000
362cycle_ms = 100
363
364[plc.PLC1]
365path = "10.0.0.1"
366tag_cache_file = "cache/PLC1.json"
367
368[plc.Other]
369path = "10.0.0.2/1/3"
370
371[[plc.PLC1.rule]]
372match = "Bench.SpeedSP"
373writable = true
374max = 1800
375
376[[plc.PLC1.rule]]
377match = ["Bench.Speed", "Bench.Rpm"]
378deadband = 0.5
379
380[[plc.PLC1.group]]
381name = "fast"
382rate_ms = 50
383match = ["Bench.Axis.*"]
384
385[token.hmi1]
386secret = "change-me"
387write = ["PLC1/Line1.*.SpeedSP"]
388
389[token.viewer]
390secret = "v"
391"#,
392        )
393        .unwrap();
394        let config = load_config(&path).unwrap();
395        assert_eq!(
396            (config.host.as_str(), config.port, config.cycle_ms),
397            ("0.0.0.0", 9000, 100)
398        );
399        assert_eq!(config.audit_log, Some(dir.path().join("audit.log")));
400        assert_eq!(config.plcs["PLC1"].path, "10.0.0.1");
401        assert_eq!(
402            config.plcs["PLC1"].tag_cache_file,
403            Some(dir.path().join("cache/PLC1.json"))
404        );
405        assert_eq!(config.plcs["Other"].tag_cache_file, None);
406        assert_eq!(config.tokens["hmi1"].write, ["PLC1/Line1.*.SpeedSP"]);
407        assert!(config.tokens["viewer"].write.is_empty());
408        let rows = |tables: &[Map<String, Json>]| -> Vec<Json> {
409            tables.iter().cloned().map(Json::Object).collect()
410        };
411        assert_eq!(
412            rows(&config.plcs["PLC1"].rules),
413            [
414                serde_json::json!({"match": "Bench.SpeedSP", "writable": true, "max": 1800}),
415                serde_json::json!({"match": ["Bench.Speed", "Bench.Rpm"], "deadband": 0.5}),
416            ]
417        );
418        assert_eq!(
419            rows(&config.plcs["PLC1"].groups),
420            [serde_json::json!({"name": "fast", "rate_ms": 50, "match": ["Bench.Axis.*"]})]
421        );
422        assert!(config.plcs["Other"].rules.is_empty() && config.plcs["Other"].groups.is_empty());
423        assert_eq!(config.momentary_timeout_ms, 1000);
424    }
425
426    #[test]
427    fn bad_group() {
428        for group in [
429            serde_json::json!({"name": "g", "rate_ms": 0}),
430            serde_json::json!({"name": "g", "rate_ms": 1.5}),
431            serde_json::json!({"rate_ms": 10}),
432        ] {
433            let data = serde_json::json!({"plc": {"A": {"path": "x", "group": [group]}}});
434            assert!(
435                parse_config(&data, Path::new("."))
436                    .unwrap_err()
437                    .contains("rate_ms")
438            );
439        }
440    }
441
442    #[test]
443    fn defaults() {
444        let config = parse_config(
445            &serde_json::json!({"plc": {"A": {"path": "1.2.3.4"}}}),
446            Path::new("."),
447        )
448        .unwrap();
449        assert_eq!(
450            (config.host.as_str(), config.port, config.cycle_ms),
451            ("127.0.0.1", 8765, 250)
452        );
453        assert!(config.tokens.is_empty());
454        let config = parse_config(
455            &serde_json::json!({"server": {"momentary_timeout_ms": 300}}),
456            Path::new("."),
457        )
458        .unwrap();
459        assert_eq!(config.momentary_timeout_ms, 300);
460    }
461
462    #[test]
463    fn qualify_one_plc() {
464        let config = parse("[plc.A]\npath = \"x\"\n").unwrap();
465        assert_eq!(config.qualify("Speed").unwrap(), ("A", "Speed"));
466        assert_eq!(
467            config.qualify("A/Program:Main.Speed").unwrap(),
468            ("A", "Program:Main.Speed")
469        );
470        assert!(config.qualify("B/Speed").is_err());
471    }
472
473    #[test]
474    fn qualify_several_plcs() {
475        let config = parse("[plc.A]\npath = \"x\"\n[plc.B]\npath = \"y\"\n").unwrap();
476        assert_eq!(config.qualify("B/Speed").unwrap(), ("B", "Speed"));
477        assert!(config.qualify("Speed").unwrap_err().contains("qualified"));
478        assert!(config.qualify("A/").is_err());
479    }
480}