Skip to main content

lager/
client.rs

1//! Blocking client for the Lager box HTTP API (default feature).
2
3use std::time::Duration;
4
5use serde_json::Value;
6
7use crate::auth::{self, GatewayAuth};
8use crate::error::{Error, Result};
9use crate::nets::adc::Adc;
10use crate::nets::arm::Arm;
11use crate::nets::battery::Battery;
12use crate::nets::ble::Ble;
13use crate::nets::blufi::Blufi;
14use crate::nets::dac::Dac;
15use crate::nets::debug::DebugNet;
16use crate::nets::eload::Eload;
17use crate::nets::energy::EnergyAnalyzer;
18use crate::nets::gpio::Gpio;
19use crate::nets::i2c::I2c;
20use crate::nets::router::Router;
21use crate::nets::scope::Scope;
22use crate::nets::solar::Solar;
23use crate::nets::spi::Spi;
24use crate::nets::supply::Supply;
25use crate::nets::thermocouple::Thermocouple;
26use crate::nets::usb::UsbPort;
27use crate::nets::watt::WattMeter;
28use crate::nets::webcam::Webcam;
29use crate::nets::wifi::Wifi;
30use crate::wire::{self, BoxStatus, Health, HttpRequest, Method, NetRecord, Op, Timeout};
31use crate::BOX_HOST_ENV;
32
33/// A connection to one Lager box, over blocking HTTP.
34///
35/// Cheap to construct: no network traffic happens until the first call.
36/// Net handles borrow the client, so a typical test creates one `LagerBox`
37/// and any number of handles from it.
38///
39/// ```no_run
40/// use lager::LagerBox;
41///
42/// fn main() -> lager::Result<()> {
43///     let lager = LagerBox::connect("192.168.1.42")?;
44///     let supply = lager.supply("supply1");
45///     supply.set_voltage(3.3)?;
46///     supply.enable()?;
47///     let v = lager.adc("vbat_sense").read()?;
48///     assert!((v - 3.3).abs() < 0.1);
49///     supply.disable()
50/// }
51/// ```
52pub struct LagerBox {
53    base: String,
54    debug_base: String,
55    agent: ureq::Agent,
56    default_timeout: Duration,
57    auth: GatewayAuth,
58}
59
60/// Builder for [`LagerBox`], for overriding the default timeout, the
61/// debug-service URL, and gateway auth.
62pub struct LagerBoxBuilder {
63    host: String,
64    debug_url: Option<String>,
65    default_timeout: Duration,
66    bearer_token: Option<String>,
67}
68
69impl LagerBoxBuilder {
70    /// Override the default HTTP timeout for quick commands (10s unless
71    /// changed). Long-running actions (watt/energy windows,
72    /// `wait_for_level`) still compute their own wider budgets.
73    pub fn timeout(mut self, timeout: Duration) -> Self {
74        self.default_timeout = timeout;
75        self
76    }
77
78    /// Override the debug-service base URL (default: the box host on port
79    /// 8765). Use this when the debug service is reached through an SSH
80    /// tunnel (e.g. `http://127.0.0.1:8765`). Also settable via the
81    /// `LAGER_DEBUG_SERVICE_URL` environment variable.
82    pub fn debug_service_url(mut self, url: impl Into<String>) -> Self {
83        self.debug_url = Some(url.into());
84        self
85    }
86
87    /// Attach `Authorization: Bearer <token>` to every request, for boxes
88    /// behind an authenticating gateway. Also settable via the
89    /// `LAGER_GATEWAY_TOKEN` environment variable.
90    ///
91    /// Without this, the crate reuses the Lager CLI's session
92    /// (`lager login <auth_url>`, stored in `~/.lager_gateway_auth`)
93    /// automatically when a gateway asks for auth, including transparent
94    /// refresh of expired access tokens. Plain (ungated) boxes are
95    /// unaffected either way.
96    pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
97        self.bearer_token = Some(token.into());
98        self
99    }
100
101    /// Build the client.
102    pub fn build(self) -> Result<LagerBox> {
103        let base = wire::base_url(&self.host)?;
104        let debug_base = match self.debug_url {
105            Some(url) => wire::base_url_with_port(&url, wire::DEBUG_SERVICE_PORT)?,
106            None => wire::service_base(&base, wire::DEBUG_SERVICE_PORT),
107        };
108        let auth = GatewayAuth::new(&base, self.bearer_token);
109        Ok(LagerBox {
110            base,
111            debug_base,
112            agent: ureq::AgentBuilder::new().build(),
113            default_timeout: self.default_timeout,
114            auth,
115        })
116    }
117}
118
119impl LagerBox {
120    /// Connect to a box by host name, IP, `host:port`, or full URL.
121    /// The port defaults to 9000 (the box HTTP server).
122    pub fn connect(host: impl Into<String>) -> Result<Self> {
123        Self::builder(host).build()
124    }
125
126    /// Connect to the box named by the `LAGER_BOX_HOST` environment
127    /// variable.
128    pub fn from_env() -> Result<Self> {
129        let host = std::env::var(BOX_HOST_ENV)
130            .map_err(|_| Error::Config(format!("{BOX_HOST_ENV} is not set")))?;
131        Self::connect(host)
132    }
133
134    /// Start building a client with non-default settings.
135    pub fn builder(host: impl Into<String>) -> LagerBoxBuilder {
136        LagerBoxBuilder {
137            host: host.into(),
138            debug_url: std::env::var(crate::DEBUG_SERVICE_URL_ENV).ok(),
139            default_timeout: wire::DEFAULT_TIMEOUT,
140            bearer_token: None,
141        }
142    }
143
144    /// The base URL this client talks to, e.g. `http://192.168.1.42:9000`.
145    pub fn base_url(&self) -> &str {
146        &self.base
147    }
148
149    // -- transport ---------------------------------------------------------
150
151    /// Send one request against the port-9000 server.
152    pub(crate) fn execute(&self, req: &HttpRequest) -> Result<(u16, Value)> {
153        self.execute_at(&self.base, req)
154    }
155
156    /// Send one request against the debug service (port 8765).
157    pub(crate) fn execute_debug(&self, req: &HttpRequest) -> Result<(u16, Value)> {
158        self.execute_at(&self.debug_base, req)
159    }
160
161    /// Send one request against an arbitrary base URL and return
162    /// `(status, parsed JSON body)`. Attaches gateway auth when known, and
163    /// handles a gateway denial by resolving credentials (CLI session
164    /// store, with transparent refresh) and retrying once.
165    fn execute_at(&self, base: &str, req: &HttpRequest) -> Result<(u16, Value)> {
166        let token = self.current_token();
167        let (status, gateway, resp_body) = self.send_once(base, req, token.as_deref())?;
168
169        let Some(auth_url) = gateway else {
170            return Ok((status, resp_body));
171        };
172        // Gateway denial: learn the box→auth-server mapping (like the CLI),
173        // then retry once with a credential the gateway has not just seen.
174        self.auth.learn_auth_server(&auth_url);
175        if status == 401 && !self.auth.has_static_token() {
176            if let Some(fresh) = self
177                .auth
178                .resolve_token_blocking(&auth_url, token.as_deref())
179            {
180                let (status, gateway, resp_body) =
181                    self.send_once(base, req, Some(&fresh))?;
182                let Some(auth_url) = gateway else {
183                    return Ok((status, resp_body));
184                };
185                return Err(auth::denial_error(
186                    status,
187                    self.auth.box_host(),
188                    &auth_url,
189                    true,
190                ));
191            }
192        }
193        Err(auth::denial_error(
194            status,
195            self.auth.box_host(),
196            &auth_url,
197            token.is_some(),
198        ))
199    }
200
201    /// Token to attach right now: builder/env token, cached session token,
202    /// or a store lookup when the box is already known to be gated.
203    fn current_token(&self) -> Option<String> {
204        if let Some(token) = self.auth.cached_token() {
205            return Some(token);
206        }
207        if self.auth.wants_store_token() {
208            let auth_url = self.auth.auth_url()?;
209            return self.auth.resolve_token_blocking(&auth_url, None);
210        }
211        None
212    }
213
214    /// One HTTP round-trip. Returns `(status, gateway_denial_auth_url,
215    /// body)`; the auth URL is `Some` only for a gateway denial (401/403/
216    /// 503 carrying the discovery header).
217    fn send_once(
218        &self,
219        base: &str,
220        req: &HttpRequest,
221        token: Option<&str>,
222    ) -> Result<(u16, Option<String>, Value)> {
223        let url = format!("{}{}", base, req.path);
224        let mut r = match req.method {
225            Method::Get => self.agent.request("GET", &url),
226            Method::Post => self.agent.request("POST", &url),
227        };
228        match req.timeout {
229            Timeout::Default => r = r.timeout(self.default_timeout),
230            Timeout::After(d) => r = r.timeout(d),
231            Timeout::Unbounded => {}
232        }
233        if let Some(token) = token {
234            r = r.set("Authorization", &format!("Bearer {token}"));
235        }
236        let outcome = match (&req.method, &req.body) {
237            (Method::Post, Some(body)) => r.send_json(body.clone()),
238            _ => r.call(),
239        };
240        let resp = match outcome {
241            Ok(resp) => resp,
242            // ureq reports 4xx/5xx as Err(Status); the box still sends a
243            // JSON error body we need to surface.
244            Err(ureq::Error::Status(_, resp)) => resp,
245            Err(ureq::Error::Transport(t)) => {
246                let msg = t.to_string();
247                return if msg.to_ascii_lowercase().contains("timed out")
248                    || msg.to_ascii_lowercase().contains("timeout")
249                {
250                    Err(Error::Timeout(msg))
251                } else {
252                    Err(Error::Connection(msg))
253                };
254            }
255        };
256        let status = resp.status();
257        let gateway = if auth::is_denial(status) {
258            resp.header(auth::DISCOVERY_HEADER).map(str::to_string)
259        } else {
260            None
261        };
262        let body: Value = match resp.into_json() {
263            Ok(body) => body,
264            Err(e) if status < 400 => {
265                return Err(Error::Decode(format!("non-JSON response: {e}")))
266            }
267            // Error responses (incl. gateway denials) may have no JSON body.
268            Err(_) => Value::Null,
269        };
270        if gateway.is_none() && status >= 400 && body.is_null() {
271            return Err(Error::Box {
272                status,
273                message: format!("HTTP {status} (non-JSON body)"),
274            });
275        }
276        Ok((status, gateway, body))
277    }
278
279    /// Execute one typed operation against a command endpoint.
280    pub(crate) fn run<T>(&self, op: Op<T>) -> Result<T> {
281        let (status, body) = self.execute(&op.req)?;
282        let resp = wire::parse_command(status, body)?;
283        (op.parse)(resp)
284    }
285
286    /// Open a streaming response body against the debug service (used by
287    /// RTT). Returns the raw byte reader.
288    pub(crate) fn stream_debug(
289        &self,
290        req: &HttpRequest,
291    ) -> Result<Box<dyn std::io::Read + Send + Sync>> {
292        let token = self.current_token();
293        match self.stream_debug_once(req, token.as_deref()) {
294            // Gateway denial (only a 401 maps to AuthRequired): resolve
295            // credentials from the CLI session store — avoiding the token
296            // the gateway just rejected — and retry once, like execute_at.
297            Err(Error::AuthRequired {
298                box_host,
299                auth_url,
300                message,
301            }) if !self.auth.has_static_token() => {
302                match self
303                    .auth
304                    .resolve_token_blocking(&auth_url, token.as_deref())
305                {
306                    Some(fresh) => self.stream_debug_once(req, Some(&fresh)),
307                    None => Err(Error::AuthRequired {
308                        box_host,
309                        auth_url,
310                        message,
311                    }),
312                }
313            }
314            other => other,
315        }
316    }
317
318    fn stream_debug_once(
319        &self,
320        req: &HttpRequest,
321        token: Option<&str>,
322    ) -> Result<Box<dyn std::io::Read + Send + Sync>> {
323        let url = format!("{}{}", self.debug_base, req.path);
324        let mut r = self.agent.request("POST", &url);
325        match req.timeout {
326            Timeout::Default => r = r.timeout(self.default_timeout),
327            Timeout::After(d) => r = r.timeout(d),
328            Timeout::Unbounded => {}
329        }
330        if let Some(token) = token {
331            r = r.set("Authorization", &format!("Bearer {token}"));
332        }
333        let outcome = match &req.body {
334            Some(body) => r.send_json(body.clone()),
335            None => r.call(),
336        };
337        match outcome {
338            Ok(resp) => Ok(resp.into_reader()),
339            Err(ureq::Error::Status(status, resp)) => {
340                if auth::is_denial(status) {
341                    if let Some(auth_url) = resp.header(auth::DISCOVERY_HEADER) {
342                        let auth_url = auth_url.to_string();
343                        self.auth.learn_auth_server(&auth_url);
344                        return Err(auth::denial_error(
345                            status,
346                            self.auth.box_host(),
347                            &auth_url,
348                            token.is_some(),
349                        ));
350                    }
351                }
352                let body: Value = resp.into_json().unwrap_or(Value::Null);
353                Err(wire::parse_debug(status, body).unwrap_err())
354            }
355            Err(ureq::Error::Transport(t)) => Err(Error::Connection(t.to_string())),
356        }
357    }
358
359    /// Resolve a debug net's full saved record (needed by the debug service,
360    /// which reads probe fields out of the record).
361    pub(crate) fn debug_net_record(&self, name: &str) -> Result<Value> {
362        let records = self.nets_raw()?;
363        crate::nets::debug::find_debug_record(records, name)
364    }
365
366    /// Raw saved-net records (untyped), with the same `/nets/list` ->
367    /// `/uart/nets/list` fallback as [`LagerBox::nets`].
368    fn nets_raw(&self) -> Result<Vec<Value>> {
369        let body = match self.get_json("/nets/list") {
370            Ok(body) => body,
371            Err(primary) => self.get_json("/uart/nets/list").map_err(|_| primary)?,
372        };
373        Ok(wire::nets_list_values(body))
374    }
375
376    fn get_json(&self, path: &str) -> Result<Value> {
377        let (status, body) = self.execute(&wire::get(path))?;
378        if status != 200 {
379            return Err(Error::Box {
380                status,
381                message: body
382                    .get("error")
383                    .and_then(Value::as_str)
384                    .unwrap_or("request failed")
385                    .to_string(),
386            });
387        }
388        Ok(body)
389    }
390
391    // -- box-level queries --------------------------------------------------
392
393    /// List every net configured on the box (full saved records).
394    ///
395    /// Falls back to the older `/uart/nets/list` shape for box images that
396    /// predate `/nets/list`, like the Lager CLI does.
397    pub fn nets(&self) -> Result<Vec<NetRecord>> {
398        match self.get_json("/nets/list") {
399            Ok(body) => wire::nets_from_body(body),
400            Err(primary) => match self.get_json("/uart/nets/list") {
401                Ok(body) => wire::nets_from_body(body),
402                Err(_) => Err(primary),
403            },
404        }
405    }
406
407    /// Check that the box HTTP server is up.
408    pub fn health(&self) -> Result<Health> {
409        let body = self.get_json("/health")?;
410        serde_json::from_value(body).map_err(Into::into)
411    }
412
413    /// Box status: version, configured nets, and endpoint capabilities.
414    pub fn status(&self) -> Result<BoxStatus> {
415        let body = self.get_json("/status")?;
416        serde_json::from_value(body).map_err(Into::into)
417    }
418
419    // -- net handle constructors ---------------------------------------------
420
421    /// Handle for a power-supply net.
422    pub fn supply(&self, name: impl Into<String>) -> Supply<'_> {
423        Supply { client: self, name: name.into() }
424    }
425
426    /// Handle for a battery-simulator net.
427    pub fn battery(&self, name: impl Into<String>) -> Battery<'_> {
428        Battery { client: self, name: name.into() }
429    }
430
431    /// Handle for an electronic-load net.
432    pub fn eload(&self, name: impl Into<String>) -> Eload<'_> {
433        Eload { client: self, name: name.into() }
434    }
435
436    /// Handle for a solar-simulator net (EA PSB photovoltaic mode).
437    pub fn solar(&self, name: impl Into<String>) -> Solar<'_> {
438        Solar { client: self, name: name.into() }
439    }
440
441    /// Handle for a GPIO net.
442    pub fn gpio(&self, name: impl Into<String>) -> Gpio<'_> {
443        Gpio { client: self, name: name.into() }
444    }
445
446    /// Handle for an ADC net.
447    pub fn adc(&self, name: impl Into<String>) -> Adc<'_> {
448        Adc { client: self, name: name.into() }
449    }
450
451    /// Handle for a DAC net.
452    pub fn dac(&self, name: impl Into<String>) -> Dac<'_> {
453        Dac { client: self, name: name.into() }
454    }
455
456    /// Handle for a thermocouple net.
457    pub fn thermocouple(&self, name: impl Into<String>) -> Thermocouple<'_> {
458        Thermocouple { client: self, name: name.into() }
459    }
460
461    /// Handle for a watt-meter net.
462    pub fn watt_meter(&self, name: impl Into<String>) -> WattMeter<'_> {
463        WattMeter { client: self, name: name.into() }
464    }
465
466    /// Handle for an energy-analyzer net.
467    pub fn energy_analyzer(&self, name: impl Into<String>) -> EnergyAnalyzer<'_> {
468        EnergyAnalyzer { client: self, name: name.into() }
469    }
470
471    /// Handle for an SPI bus net.
472    pub fn spi(&self, name: impl Into<String>) -> Spi<'_> {
473        Spi { client: self, name: name.into() }
474    }
475
476    /// Handle for an I2C bus net.
477    pub fn i2c(&self, name: impl Into<String>) -> I2c<'_> {
478        I2c { client: self, name: name.into() }
479    }
480
481    /// Handle for a USB hub port net.
482    pub fn usb(&self, name: impl Into<String>) -> UsbPort<'_> {
483        UsbPort { client: self, name: name.into() }
484    }
485
486    /// Handle for a robot-arm net (Rotrics Dexarm).
487    pub fn arm(&self, name: impl Into<String>) -> Arm<'_> {
488        Arm { client: self, name: name.into() }
489    }
490
491    /// Handle for a webcam net (MJPEG streaming).
492    pub fn webcam(&self, name: impl Into<String>) -> Webcam<'_> {
493        Webcam { client: self, name: name.into() }
494    }
495
496    /// Handle for a router net (MikroTik RouterOS).
497    pub fn router(&self, name: impl Into<String>) -> Router<'_> {
498        Router { client: self, name: name.into() }
499    }
500
501    /// Handle for the box's BLE adapter (box-level, not a saved net).
502    pub fn ble(&self) -> Ble<'_> {
503        Ble { client: self }
504    }
505
506    /// Handle for the box's WiFi interface (box-level, not a saved net).
507    pub fn wifi(&self) -> Wifi<'_> {
508        Wifi { client: self }
509    }
510
511    /// Handle for BluFi (ESP32 WiFi provisioning over BLE; box-level).
512    pub fn blufi(&self) -> Blufi<'_> {
513        Blufi { client: self }
514    }
515
516    /// Handle for a debug-probe net (flash/erase/reset/read_memory/RTT).
517    /// Talks to the box debug service on port 8765.
518    pub fn debug(&self, name: impl Into<String>) -> DebugNet<'_> {
519        DebugNet { client: self, name: name.into() }
520    }
521
522    /// Handle for an oscilloscope net. **Stub:** see [`Scope`].
523    pub fn scope(&self, name: impl Into<String>) -> Scope {
524        Scope::new(name)
525    }
526
527    /// Open a streaming UART session on a UART net.
528    ///
529    /// Connects a Socket.IO session to the box's `/uart` namespace and
530    /// starts streaming. Only one session per net (box-enforced).
531    #[cfg(feature = "uart")]
532    pub fn uart(&self, name: impl Into<String>) -> Result<crate::nets::uart::Uart> {
533        crate::nets::uart::Uart::open(&self.base, name.into(), self.current_token())
534    }
535}