Skip to main content

ciplink_server/
cli.rs

1//! The command line: `ciplink-server run | dump-tags | types | service`.
2
3use std::path::{Path, PathBuf};
4
5use clap::{Parser, Subcommand, ValueEnum};
6
7use crate::{codegen, config, logging, service};
8
9#[derive(Parser)]
10#[command(
11    name = "ciplink-server",
12    version,
13    about = "Serves PLC tag values to web apps over a WebSocket"
14)]
15struct Cli {
16    #[command(subcommand)]
17    command: Command,
18}
19
20#[derive(Subcommand)]
21enum Command {
22    /// Serve tag values over a WebSocket.
23    Run {
24        #[arg(long, default_value = "tagserver.toml")]
25        config: PathBuf,
26        /// Serve fake values from each PLC's mock_tags file.
27        #[arg(long)]
28        mock: bool,
29        /// Write logs to this file (rotated at 10 MB, 5 kept) instead of the console.
30        #[arg(long)]
31        log_file: Option<PathBuf>,
32    },
33    /// Run at Windows startup (install and remove need an administrator terminal).
34    Service {
35        #[arg(value_enum)]
36        action: ServiceAction,
37        #[arg(long, default_value = "tagserver.toml")]
38        config: PathBuf,
39        /// Task Scheduler task name.
40        #[arg(long, default_value = "ciplink-server")]
41        name: String,
42        /// Default: tagserver.log next to the config.
43        #[arg(long)]
44        log_file: Option<PathBuf>,
45    },
46    /// Write a PLC's tag list as a mock_tags file.
47    DumpTags {
48        #[arg(long, default_value = "tagserver.toml")]
49        config: PathBuf,
50        #[arg(long)]
51        plc: String,
52        #[arg(long)]
53        out: PathBuf,
54    },
55    /// Write the typed tag tree of a running server as a TypeScript module.
56    Types {
57        #[arg(long, default_value = "http://127.0.0.1:8765")]
58        url: String,
59        #[arg(long)]
60        token: String,
61        /// Repeat for several PLCs; names are then qualified as PLC/tag.
62        #[arg(long)]
63        plc: Vec<String>,
64        /// tree: interfaces and a tagTree const per PLC; names: the 0.2 union of tag names.
65        #[arg(long, value_enum, default_value_t = Format::Tree)]
66        format: Format,
67        #[arg(long)]
68        out: PathBuf,
69    },
70}
71
72#[derive(Clone, Copy, ValueEnum)]
73enum ServiceAction {
74    Install,
75    Remove,
76    Status,
77    Run,
78}
79
80#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
81enum Format {
82    Tree,
83    Names,
84}
85
86/// The CLI on these arguments (the program name first), as the `ciplink-server` executable.
87pub fn main(args: impl IntoIterator<Item = std::ffi::OsString>) -> Result<u8, String> {
88    let exe = std::env::current_exe().map_err(|e| e.to_string())?;
89    main_as(args, &[exe.display().to_string()])
90}
91
92/// The CLI on these arguments; `launcher` is how a Task Scheduler task starts this CLI again (the program and its
93/// first arguments, e.g. `python -m ciplink.server` for the Python package).
94pub fn main_as(
95    args: impl IntoIterator<Item = std::ffi::OsString>,
96    launcher: &[String],
97) -> Result<u8, String> {
98    let cli = match Cli::try_parse_from(args) {
99        Ok(cli) => cli,
100        Err(err) => {
101            let _ = err.print();
102            return Ok(if err.use_stderr() { 2 } else { 0 });
103        }
104    };
105    match cli.command {
106        Command::Run {
107            config,
108            mock,
109            log_file,
110        } => {
111            logging::init(log_file.as_deref())?;
112            crate::run(&config, mock)?;
113        }
114        Command::Service {
115            action,
116            config,
117            name,
118            log_file,
119        } => {
120            let (config, log_file) = service_paths(&config, log_file.as_deref())?;
121            return Ok(match action {
122                ServiceAction::Install => service::install(&name, launcher, &config, &log_file),
123                ServiceAction::Remove => service::remove(&name),
124                ServiceAction::Status => service::status(&name),
125                ServiceAction::Run => {
126                    logging::init(Some(&log_file))?;
127                    service::supervise(|| crate::run(&config, false));
128                    0
129                }
130            });
131        }
132        Command::DumpTags { config, plc, out } => {
133            logging::init(None)?;
134            dump_tags(&config, &plc, &out)?;
135        }
136        Command::Types {
137            url,
138            token,
139            plc,
140            format,
141            out,
142        } => {
143            let plcs: Vec<Option<String>> = if plc.is_empty() {
144                vec![None]
145            } else {
146                plc.into_iter().map(Some).collect()
147            };
148            let bodies = plcs
149                .iter()
150                .map(|p| codegen::fetch_tags(&url, &token, p.as_deref()))
151                .collect::<Result<Vec<_>, _>>()?;
152            let (text, summary) = match format {
153                Format::Tree => {
154                    let names: Vec<&str> = bodies
155                        .iter()
156                        .map(|b| {
157                            b.get("plc")
158                                .and_then(serde_json::Value::as_str)
159                                .unwrap_or("?")
160                        })
161                        .collect();
162                    (
163                        codegen::tree_module(&bodies, plcs.len() > 1)?,
164                        format!("wrote the tag tree of {}", names.join(", ")),
165                    )
166                }
167                Format::Names => {
168                    let text = codegen::names_module(&bodies, &plcs);
169                    let count = text.matches("\n  | ").count();
170                    (text, format!("wrote {count} tag names"))
171                }
172            };
173            std::fs::write(&out, text).map_err(|e| format!("{}: {e}", out.display()))?;
174            println!("{summary} to {}", out.display());
175        }
176    }
177    Ok(0)
178}
179
180/// The absolute config and log file paths of `service`; the log is tagserver.log next to the config by default.
181fn service_paths(config: &Path, log_file: Option<&Path>) -> Result<(PathBuf, PathBuf), String> {
182    let config = std::path::absolute(config).map_err(|e| e.to_string())?;
183    let log_file = match log_file {
184        Some(f) => std::path::absolute(f).map_err(|e| e.to_string())?,
185        None => config.with_file_name("tagserver.log"),
186    };
187    Ok((config, log_file))
188}
189
190/// Uploads the tag list of one PLC (reads only) and writes it in the mock_tags format.
191fn dump_tags(config_path: &Path, plc: &str, out: &Path) -> Result<(), String> {
192    use crate::plc::{LogixPlc, Plc};
193    let config = config::load_config(config_path)?;
194    let cfg = config
195        .plcs
196        .get(plc)
197        .ok_or_else(|| format!("no PLC {plc} in {}", config_path.display()))?;
198    let mut driver = LogixPlc::new(cfg)?;
199    driver.open()?;
200    let catalog = driver.catalog();
201    driver.close();
202    let tags: serde_json::Map<String, serde_json::Value> = catalog
203        .tags
204        .iter()
205        .map(|(name, node)| {
206            let dims = node.client_dims();
207            let type_ = match dims.as_slice() {
208                [n] => format!("{}[{n}]", node.client_type()),
209                _ => node.client_type().to_owned(),
210            };
211            (name.clone(), serde_json::Value::String(type_))
212        })
213        .collect();
214    let text = serde_json::to_string_pretty(&tags).map_err(|e| e.to_string())?;
215    std::fs::write(out, text).map_err(|e| format!("{}: {e}", out.display()))?;
216    println!("wrote {} tags to {}", tags.len(), out.display());
217    Ok(())
218}
219
220#[cfg(test)]
221#[allow(clippy::unwrap_used)]
222mod tests {
223    use super::*;
224
225    /// test_service.py's `service run` test: the task's command line resolves to absolute paths, the log next to
226    /// the config.
227    #[test]
228    fn service_run_paths() {
229        let cwd = std::env::current_dir().unwrap();
230        let (config, log_file) = service_paths(Path::new("tagserver.toml"), None).unwrap();
231        assert_eq!(config, cwd.join("tagserver.toml"));
232        assert_eq!(log_file, cwd.join("tagserver.log"));
233        let (_, log_file) =
234            service_paths(Path::new("tagserver.toml"), Some(Path::new("logs/x.log"))).unwrap();
235        assert_eq!(log_file, cwd.join("logs/x.log"));
236    }
237}