archiver/lib.rs
1//! A client for the EPICS Archiver Appliance retrieval API.
2//!
3//! The pieces are split so that a browser build could reuse everything but
4//! the HTTP: [`ArchiverClient`] builds the request URLs (`*_url`), the
5//! `parse_*` functions decode the bodies, and the blocking
6//! `get_data`/`search`/`metadata` methods do both over `ureq` on native
7//! targets. In a browser those methods fail at once.
8//!
9//! Times are UTC seconds since the Unix epoch as `f64` (sub-microsecond
10//! resolution for current dates), the same as bobview's trend and
11//! `archiveview`'s plot.
12//!
13//! See `DESIGN.md` at the repository root for the wire format and its
14//! surprises (bare `NaN` tokens, string `PREC`, `[ ]` for no data, binned
15//! `[mean, std, min, max, count]` values from `optimized_N`).
16
17use std::collections::BTreeMap;
18use std::fmt;
19
20mod decode;
21#[cfg(not(target_arch = "wasm32"))]
22mod http;
23
24/// No blocking HTTP in a browser: every request fails at once.
25#[cfg(target_arch = "wasm32")]
26impl ArchiverClient {
27 fn get(&self, _url: &str) -> Result<String> {
28 let _ = self.timeout_secs;
29 Err(Error::Transport(format!(
30 "{}: no blocking HTTP in a browser",
31 self.base()
32 )))
33 }
34}
35mod json;
36mod time;
37
38pub use json::sanitize_json;
39pub use time::{format_time, parse_time};
40
41/// The default number of PV names a search asks for.
42pub const DEFAULT_SEARCH_LIMIT: i32 = 500;
43
44/// The most bins an `optimized_N` request asks for, whatever the plot width.
45pub const MAX_BINS: u32 = 4000;
46
47// ---------------------------------------------------------------------------
48// Errors
49// ---------------------------------------------------------------------------
50
51/// Why a request failed.
52#[derive(Clone, Debug, PartialEq)]
53pub enum Error {
54 /// The archiver URL given could not be used.
55 BadUrl(String),
56 /// The server answered with a non-2xx status. `message` is the first
57 /// non-empty line of the body, with HTML tags stripped.
58 Http { status: u16, message: String },
59 /// No answer: connection refused, DNS, timeout, TLS, ...
60 Transport(String),
61 /// A 2xx answer that is not the expected JSON.
62 Parse(String),
63}
64
65impl Error {
66 /// True for the archiver's "PV is not archived" answer (HTTP 404).
67 pub fn is_not_archived(&self) -> bool {
68 matches!(self, Error::Http { status: 404, .. })
69 }
70}
71
72impl fmt::Display for Error {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 match self {
75 Error::BadUrl(m) => write!(f, "bad archiver URL: {m}"),
76 Error::Http { status, message } if message.is_empty() => write!(f, "HTTP {status}"),
77 Error::Http { status, message } => write!(f, "HTTP {status}: {message}"),
78 Error::Transport(m) => write!(f, "{m}"),
79 Error::Parse(m) => write!(f, "bad response: {m}"),
80 }
81 }
82}
83
84impl std::error::Error for Error {}
85
86pub type Result<T> = std::result::Result<T, Error>;
87
88// ---------------------------------------------------------------------------
89// Data
90// ---------------------------------------------------------------------------
91
92/// An archived alarm severity (the `severity` field: 0..=3).
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
94pub enum Severity {
95 #[default]
96 NoAlarm,
97 Minor,
98 Major,
99 Invalid,
100}
101
102impl Severity {
103 /// From the archiver's number; anything above 3 counts as `Invalid`.
104 pub fn from_code(code: i64) -> Self {
105 match code {
106 ..=0 => Severity::NoAlarm,
107 1 => Severity::Minor,
108 2 => Severity::Major,
109 _ => Severity::Invalid,
110 }
111 }
112}
113
114/// One plotted point of archived data.
115///
116/// A raw sample has `min == max == val`. A bin from `optimized_N` has `val`
117/// = the bin mean, `min`/`max` its extremes and `count` its sample count;
118/// `t` is then the bin centre. `val` is NaN at a gap (a disconnect, a
119/// severity-INVALID connection-lost event, or an archived NaN), where the
120/// plot breaks the line.
121#[derive(Clone, Copy, Debug, PartialEq)]
122pub struct Sample {
123 /// UTC seconds since the epoch (`secs + nanos * 1e-9`).
124 pub t: f64,
125 pub val: f64,
126 pub min: f64,
127 pub max: f64,
128 /// Samples in the bin; 1 for a raw sample.
129 pub count: u32,
130 pub severity: Severity,
131 pub status: u16,
132}
133
134impl Sample {
135 /// A raw sample.
136 pub fn raw(t: f64, val: f64) -> Self {
137 Self {
138 t,
139 val,
140 min: val,
141 max: val,
142 count: 1,
143 severity: Severity::NoAlarm,
144 status: 0,
145 }
146 }
147
148 /// A line break at `t`.
149 pub fn gap(t: f64) -> Self {
150 Self {
151 severity: Severity::Invalid,
152 ..Self::raw(t, f64::NAN)
153 }
154 }
155
156 pub fn is_gap(&self) -> bool {
157 self.val.is_nan()
158 }
159}
160
161/// What the archived values were, as seen in the response.
162#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
163pub enum ValueKind {
164 /// Scalar numbers (doubles, integers or enum indices): plottable.
165 #[default]
166 Scalar,
167 /// Arrays (waveforms). Always fetch these raw. Samples carry element
168 /// 0; archiveview does not plot them.
169 Waveform,
170 /// Strings: not plotted in v1 (samples carry NaN).
171 Text,
172 /// PVA structures and anything else: not plotted.
173 Other,
174}
175
176/// The `meta` object of a response, with the common fields decoded. Values
177/// may arrive as strings or numbers; both are accepted.
178#[derive(Clone, Debug, Default, PartialEq)]
179pub struct Meta {
180 pub name: String,
181 /// `EGU`, absent when the PV has no units.
182 pub egu: Option<String>,
183 /// `PREC`.
184 pub prec: Option<u32>,
185 /// `LOPR`/`HOPR` (only with `fetchLatestMetadata=true`).
186 pub lopr: Option<f64>,
187 pub hopr: Option<f64>,
188 /// `DESC` (only with `fetchLatestMetadata=true`).
189 pub desc: Option<String>,
190 /// Every other key, as text.
191 pub extra: BTreeMap<String, String>,
192}
193
194/// One PV's archived data for a request.
195#[derive(Clone, Debug, Default, PartialEq)]
196pub struct TimeSeries {
197 pub meta: Meta,
198 pub kind: ValueKind,
199 /// True when the samples are `optimized_N` bins (`min`/`max` envelope
200 /// meaningful), false for raw samples.
201 pub binned: bool,
202 /// In time order. The first may be before the requested `from`: it is
203 /// the value that held at `from`.
204 pub samples: Vec<Sample>,
205}
206
207// ---------------------------------------------------------------------------
208// Requests
209// ---------------------------------------------------------------------------
210
211/// Server-side processing for a data request.
212#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
213pub enum Operator {
214 /// Every archived sample.
215 #[default]
216 Raw,
217 /// `optimized_N`: about N points; raw when there are at most N samples,
218 /// else `[mean, std, min, max, count]` bins.
219 Optimized(u32),
220 /// `mean_N`: the mean of each N-second bin.
221 Mean(u32),
222 /// `nth_N`: every Nth raw sample.
223 Nth(u32),
224}
225
226impl Operator {
227 /// The `pv` parameter for `pv`: `optimized_1200(SR-DI-DCCT-01:SIGNAL)`.
228 pub fn wrap(&self, pv: &str) -> String {
229 match self {
230 Operator::Raw => pv.to_string(),
231 Operator::Optimized(n) => format!("optimized_{n}({pv})"),
232 Operator::Mean(n) => format!("mean_{n}({pv})"),
233 Operator::Nth(n) => format!("nth_{n}({pv})"),
234 }
235 }
236
237 /// The operator to fetch for a plot `width_px` device pixels wide:
238 /// `optimized` with one bin per pixel, at most [`MAX_BINS`]. Pass the
239 /// width of the whole window fetched: when it is padded beyond the
240 /// part shown, scale the plot width by the padding (`archiveview`
241 /// fetches 1.5 times the visible span, so 1.5 times the width). Finer
242 /// bins only alias: their means zig-zag into a solid block where the
243 /// min/max envelope already shows the extremes.
244 pub fn for_width(width_px: f32) -> Self {
245 let n = width_px.max(1.0).round() as u32;
246 Operator::Optimized(n.clamp(1, MAX_BINS))
247 }
248}
249
250/// A `getData.json` request.
251#[derive(Clone, Debug, PartialEq)]
252pub struct Query {
253 /// The PV name, without any `ca://`/`pva://` prefix.
254 pub pv: String,
255 pub from: f64,
256 pub to: f64,
257 pub op: Operator,
258 /// Ask for `DESC`, `LOPR`, `HOPR`, ... in `meta`.
259 pub fetch_latest_metadata: bool,
260}
261
262impl Query {
263 pub fn new(pv: impl Into<String>, from: f64, to: f64, op: Operator) -> Self {
264 Self {
265 pv: pv.into(),
266 from,
267 to,
268 op,
269 fetch_latest_metadata: false,
270 }
271 }
272}
273
274/// How a search pattern is matched.
275#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
276pub enum SearchMode {
277 /// `*` and `?` wildcards, sent as `pv=`.
278 #[default]
279 Glob,
280 /// A Java regex matching the whole name, sent as `regex=`.
281 Regex,
282}
283
284/// A `getMatchingPVs` request.
285#[derive(Clone, Debug, PartialEq)]
286pub struct Search {
287 pub pattern: String,
288 pub mode: SearchMode,
289 /// Names wanted at most; -1 for all.
290 pub limit: i32,
291}
292
293impl Search {
294 /// What the search box sends for `text` (surrounding whitespace
295 /// trimmed), always as a regex so that names holding regex
296 /// metacharacters (`XF:23ID1-ES{Dif-Ax:TH}Mtr`) match literally: the
297 /// appliance turns a `pv=` glob into a regex without escaping them.
298 ///
299 /// - Plain text matches anywhere in the name, ignoring case:
300 /// `sine` finds `MOCK:SINE`.
301 /// - Text with `*` or `?` is a glob matching the whole name, with case:
302 /// `*` any run of characters, `?` any one, everything else literal.
303 pub fn from_user(text: &str, limit: i32) -> Self {
304 let text = text.trim();
305 let pattern = if text.contains(['*', '?']) {
306 let mut re = String::new();
307 for c in text.chars() {
308 match c {
309 '*' => re.push_str(".*"),
310 '?' => re.push('.'),
311 c => push_escaped(&mut re, c),
312 }
313 }
314 re
315 } else {
316 format!("(?i).*{}.*", regex_escape(text))
317 };
318 Self::regex(pattern, limit)
319 }
320
321 /// A search for exactly the name `pv`.
322 pub fn exact(pv: &str, limit: i32) -> Self {
323 Self::regex(regex_escape(pv), limit)
324 }
325
326 pub fn regex(re: impl Into<String>, limit: i32) -> Self {
327 Self {
328 pattern: re.into(),
329 mode: SearchMode::Regex,
330 limit,
331 }
332 }
333}
334
335/// `text` with every regex metacharacter backslash-escaped, so it matches
336/// itself (in Java's regex syntax, which the appliance uses, and in Rust's).
337pub fn regex_escape(text: &str) -> String {
338 let mut out = String::with_capacity(text.len());
339 for c in text.chars() {
340 push_escaped(&mut out, c);
341 }
342 out
343}
344
345fn push_escaped(out: &mut String, c: char) {
346 if matches!(
347 c,
348 '\\' | '^' | '$' | '.' | '|' | '?' | '*' | '+' | '(' | ')' | '[' | ']' | '{' | '}'
349 ) {
350 out.push('\\');
351 }
352 out.push(c);
353}
354
355/// What `getMetadata` says about a PV (all optional: servers differ).
356#[derive(Clone, Debug, Default, PartialEq)]
357pub struct PvInfo {
358 /// e.g. `DBR_SCALAR_DOUBLE`, `DBR_WAVEFORM_DOUBLE`, `DBR_SCALAR_ENUM`.
359 pub dbr_type: Option<String>,
360 pub element_count: Option<u32>,
361 pub units: Option<String>,
362 pub precision: Option<u32>,
363 pub lower_display_limit: Option<f64>,
364 pub upper_display_limit: Option<f64>,
365 /// Every key as returned.
366 pub raw: BTreeMap<String, String>,
367}
368
369impl PvInfo {
370 /// True when `optimized_N` makes sense: a scalar number (or enum).
371 /// Unknown types count as numeric.
372 pub fn is_scalar_numeric(&self) -> bool {
373 let scalar = self.element_count.is_none_or(|n| n <= 1);
374 let numeric = self
375 .dbr_type
376 .as_deref()
377 .is_none_or(|t| !t.contains("STRING") && !t.contains("V4"));
378 scalar && numeric
379 }
380
381 /// True when the plot should ask for `optimized_N` bins: a scalar
382 /// number that is not an enum. Bin means of enum state indices (1.63)
383 /// name no state, so enums are fetched raw, as are waveforms and text.
384 pub fn binnable(&self) -> bool {
385 self.is_scalar_numeric() && self.dbr_type.as_deref().is_none_or(|t| !t.contains("ENUM"))
386 }
387}
388
389// ---------------------------------------------------------------------------
390// Client
391// ---------------------------------------------------------------------------
392
393/// An Archiver Appliance retrieval service. Cheap to clone; holds no
394/// connection state, so it can be shared freely between threads and
395/// between several plots.
396#[derive(Clone, Debug, PartialEq, Eq)]
397pub struct ArchiverClient {
398 /// The normalised base, ending in `/retrieval` with no trailing slash.
399 base: String,
400 /// Per-request timeout in seconds (native only).
401 timeout_secs: u64,
402}
403
404impl ArchiverClient {
405 /// From the URL the user gave, e.g. `http://host:17668` or
406 /// `http://host:17668/retrieval/`: strips trailing slashes and appends
407 /// `/retrieval` unless it already ends so. Only `http` and `https` are
408 /// accepted, plus Phoebus's `pbraw://` (the same service over plain
409 /// HTTP); a bare `host:port` gets `http://`.
410 pub fn new(url: &str) -> Result<Self> {
411 let mut url = url.trim().trim_end_matches('/').to_string();
412 if url.is_empty() {
413 return Err(Error::BadUrl("empty".into()));
414 }
415 if let Some(rest) = url.strip_prefix("pbraw://") {
416 url = format!("http://{rest}");
417 } else if !url.contains("://") {
418 url = format!("http://{url}");
419 }
420 if !(url.starts_with("http://") || url.starts_with("https://")) {
421 return Err(Error::BadUrl(format!(
422 "{url}: only http and https are supported"
423 )));
424 }
425 if !url.ends_with("/retrieval") {
426 url.push_str("/retrieval");
427 }
428 Ok(Self {
429 base: url,
430 timeout_secs: 30,
431 })
432 }
433
434 pub fn with_timeout_secs(mut self, secs: u64) -> Self {
435 self.timeout_secs = secs;
436 self
437 }
438
439 /// The normalised retrieval base, e.g. `http://host:17668/retrieval`.
440 pub fn base(&self) -> &str {
441 &self.base
442 }
443
444 /// `getData.json` with `from`/`to` as `YYYY-MM-DDTHH:MM:SS.mmmZ` and
445 /// every parameter percent-encoded.
446 pub fn data_url(&self, q: &Query) -> String {
447 let mut url = format!(
448 "{}/data/getData.json?pv={}&from={}&to={}",
449 self.base,
450 encode(&q.op.wrap(&q.pv)),
451 encode(&format_time(q.from)),
452 encode(&format_time(q.to)),
453 );
454 if q.fetch_latest_metadata {
455 url.push_str("&fetchLatestMetadata=true");
456 }
457 url
458 }
459
460 /// `bpl/getMatchingPVs?pv=...&limit=N` or `?regex=...&limit=N`.
461 pub fn search_url(&self, s: &Search) -> String {
462 let key = match s.mode {
463 SearchMode::Glob => "pv",
464 SearchMode::Regex => "regex",
465 };
466 format!(
467 "{}/bpl/getMatchingPVs?{key}={}&limit={}",
468 self.base,
469 encode(&s.pattern),
470 s.limit
471 )
472 }
473
474 /// `bpl/getMetadata?pv=...`.
475 pub fn metadata_url(&self, pv: &str) -> String {
476 format!("{}/bpl/getMetadata?pv={}", self.base, encode(pv))
477 }
478
479 /// `bpl/getVersion`: the appliance's version, a cheap "are you there".
480 pub fn version_url(&self) -> String {
481 format!("{}/bpl/getVersion", self.base)
482 }
483
484 /// The host (and port, when given) of the base URL, for display:
485 /// `archappl.diamond.ac.uk` or `127.0.0.1:17668`.
486 pub fn host(&self) -> &str {
487 let rest = self.base.split_once("://").map_or(&*self.base, |(_, r)| r);
488 rest.split('/').next().unwrap_or(rest)
489 }
490}
491
492// The requests block, so call them from a worker thread. In a browser
493// (wasm32) there is no blocking HTTP: they fail at once with
494// `Error::Transport`, and a browser build would fetch the `*_url` URLs
495// itself and hand the bodies to `parse_*`.
496impl ArchiverClient {
497 /// Fetch and decode one PV's data. `Ok(None)` when the archiver has no
498 /// data in the range (a `[ ]` response); `Err(Http{404})` when the PV is
499 /// not archived. Blocking: call from a worker thread.
500 pub fn get_data(&self, q: &Query) -> Result<Option<TimeSeries>> {
501 let body = self.get(&self.data_url(q))?;
502 let bins = matches!(q.op, Operator::Optimized(_));
503 Ok(decode::data(&body, bins)?.into_iter().next())
504 }
505
506 /// The archived PV names matching `s`, sorted. Blocking.
507 pub fn search(&self, s: &Search) -> Result<Vec<String>> {
508 let mut names = parse_names(&self.get(&self.search_url(s))?)?;
509 names.sort();
510 names.dedup();
511 Ok(names)
512 }
513
514 /// The PV's archive type info. Blocking.
515 pub fn metadata(&self, pv: &str) -> Result<PvInfo> {
516 parse_metadata(&self.get(&self.metadata_url(pv))?)
517 }
518
519 /// The appliance's version (`bpl/getVersion`), e.g. "Archiver
520 /// Appliance Version 1.1.0". Blocking.
521 pub fn version(&self) -> Result<String> {
522 parse_version(&self.get(&self.version_url())?)
523 }
524}
525
526// ---------------------------------------------------------------------------
527// Decoding (usable without HTTP, e.g. by a browser build)
528// ---------------------------------------------------------------------------
529
530/// Decode a `getData.json` body: one [`TimeSeries`] per PV, empty for
531/// `[ ]`. Tolerates bare `NaN`/`Infinity`/`-Infinity`, string or numeric
532/// `PREC`/`LOPR`/`HOPR`, unknown keys, and `fields` objects. A severity-3
533/// sample whose `fields` hold `cnxlostepsecs` becomes a [`Sample::gap`].
534/// A 5-element array `val` on a scalar PV is an `optimized` bin
535/// (`binned = true`); other arrays are waveforms (`val` holds element 0).
536///
537/// Without knowing the request, a series whose every array value has five
538/// elements with a whole, non-negative count is taken as bins;
539/// [`ArchiverClient::get_data`] only does that for `optimized` requests.
540pub fn parse_data(body: &str) -> Result<Vec<TimeSeries>> {
541 decode::data(body, true)
542}
543
544/// Decode a JSON array of PV names (`getMatchingPVs`, `getAllPVs`).
545pub fn parse_names(body: &str) -> Result<Vec<String>> {
546 decode::names(body)
547}
548
549/// Decode a `getMetadata` body.
550pub fn parse_metadata(body: &str) -> Result<PvInfo> {
551 decode::metadata(body)
552}
553
554/// Decode a `getVersion` body: `{"version": "..."}`.
555pub fn parse_version(body: &str) -> Result<String> {
556 decode::version(body)
557}
558
559/// Percent-encode a query parameter value (everything but RFC 3986
560/// unreserved characters).
561fn encode(s: &str) -> String {
562 let mut out = String::with_capacity(s.len() * 3 / 2);
563 for b in s.bytes() {
564 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
565 out.push(b as char);
566 } else {
567 out.push_str(&format!("%{b:02X}"));
568 }
569 }
570 out
571}
572
573#[cfg(test)]
574mod tests;