1use std::path::{Path, PathBuf};
4
5use indexmap::IndexMap;
6use serde_json::{Map, Value as Json};
7
8use crate::rules::Resolver;
9
10#[derive(Clone, Debug, Default)]
12pub struct PlcConfig {
13 pub name: String,
15 pub path: String,
17 pub tag_cache_file: Option<PathBuf>,
19 pub mock_tags: Option<PathBuf>,
21 pub rules: Vec<Map<String, Json>>,
23 pub groups: Vec<Map<String, Json>>,
25 pub udt: Map<String, Json>,
27 pub tags: Map<String, Json>,
29}
30
31#[derive(Clone, Debug, Default)]
33pub struct TokenConfig {
34 pub name: String,
36 pub secret: String,
38 pub write: Vec<String>,
40}
41
42#[derive(Clone, Debug)]
44pub struct Config {
45 pub host: String,
47 pub port: u16,
49 pub cycle_ms: u64,
51 pub audit_log: Option<PathBuf>,
53 pub tier2_period_s: f64,
55 pub grace_s: f64,
57 pub pages_file: Option<PathBuf>,
59 pub planner: bool,
61 pub whole_max_bytes: u64,
63 pub momentary_timeout_ms: u64,
65 pub plcs: IndexMap<String, PlcConfig>,
67 pub tokens: IndexMap<String, TokenConfig>,
69 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 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
116pub 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
125pub 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
133pub 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 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 #[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}