Skip to main content

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;