Skip to main content

lager/
async_client.rs

1//! Async client for the Lager box HTTP API (feature `async`).
2//!
3//! Runs the exact same request builders and response parsers as the
4//! blocking client — only the transport differs.
5
6use std::time::Duration;
7
8use serde_json::Value;
9
10use crate::auth::{self, GatewayAuth};
11use crate::error::{Error, Result};
12use crate::nets::adc::AsyncAdc;
13use crate::nets::arm::AsyncArm;
14use crate::nets::battery::AsyncBattery;
15use crate::nets::ble::AsyncBle;
16use crate::nets::blufi::AsyncBlufi;
17use crate::nets::dac::AsyncDac;
18use crate::nets::debug::AsyncDebugNet;
19use crate::nets::dfu::AsyncDfu;
20use crate::nets::eload::AsyncEload;
21use crate::nets::energy::AsyncEnergyAnalyzer;
22use crate::nets::gpio::AsyncGpio;
23use crate::nets::i2c::AsyncI2c;
24use crate::nets::router::AsyncRouter;
25use crate::nets::scope::Scope;
26use crate::nets::solar::AsyncSolar;
27use crate::nets::spi::AsyncSpi;
28use crate::nets::supply::AsyncSupply;
29use crate::nets::thermocouple::AsyncThermocouple;
30use crate::nets::usb::AsyncUsbPort;
31use crate::nets::watt::AsyncWattMeter;
32use crate::nets::webcam::AsyncWebcam;
33use crate::nets::wifi::AsyncWifi;
34use crate::wire::{
35    self, BoxLock, BoxStatus, Health, HttpRequest, Method, NetRecord, Op, SafetyLimits, Timeout,
36    UsbDeviceFilter, UsbDeviceInfo,
37};
38
39/// A connection to one Lager box, over async HTTP (reqwest/tokio).
40///
41/// ```no_run
42/// use lager::AsyncLagerBox;
43///
44/// #[tokio::main]
45/// async fn main() -> lager::Result<()> {
46///     let lager = AsyncLagerBox::connect("192.168.1.42")?;
47///     let supply = lager.supply("supply1");
48///     supply.set_voltage(3.3).await?;
49///     supply.enable().await?;
50///     let v = lager.adc("vbat_sense").read().await?;
51///     assert!((v - 3.3).abs() < 0.1);
52///     supply.disable().await
53/// }
54/// ```
55pub struct AsyncLagerBox {
56    base: String,
57    debug_base: String,
58    http: reqwest::Client,
59    default_timeout: Duration,
60    auth: GatewayAuth,
61}
62
63/// Builder for [`AsyncLagerBox`], for overriding the default timeout, the
64/// debug-service URL, and gateway auth.
65pub struct AsyncLagerBoxBuilder {
66    host: String,
67    debug_url: Option<String>,
68    default_timeout: Duration,
69    bearer_token: Option<String>,
70}
71
72impl AsyncLagerBoxBuilder {
73    /// Override the default HTTP timeout for quick commands (10s unless
74    /// changed). Long-running actions still compute their own wider budgets.
75    pub fn timeout(mut self, timeout: Duration) -> Self {
76        self.default_timeout = timeout;
77        self
78    }
79
80    /// Override the debug-service base URL (default: the box host on port
81    /// 8765). Also settable via `LAGER_DEBUG_SERVICE_URL`.
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<AsyncLagerBox> {
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(AsyncLagerBox {
110            base,
111            debug_base,
112            http: reqwest::Client::new(),
113            default_timeout: self.default_timeout,
114            auth,
115        })
116    }
117}
118
119impl AsyncLagerBox {
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(crate::BOX_HOST_ENV)
130            .map_err(|_| Error::Config(format!("{} is not set", crate::BOX_HOST_ENV)))?;
131        Self::connect(host)
132    }
133
134    /// Start building a client with non-default settings.
135    pub fn builder(host: impl Into<String>) -> AsyncLagerBoxBuilder {
136        AsyncLagerBoxBuilder {
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) async fn execute(&self, req: &HttpRequest) -> Result<(u16, Value)> {
153        self.execute_at(&self.base, req).await
154    }
155
156    /// Send one request against the debug service (port 8765).
157    pub(crate) async fn execute_debug(&self, req: &HttpRequest) -> Result<(u16, Value)> {
158        self.execute_at(&self.debug_base, req).await
159    }
160
161    /// Send one request against an arbitrary base URL. Attaches gateway
162    /// auth when known, and handles a gateway denial by resolving
163    /// credentials (CLI session store, with transparent refresh) and
164    /// retrying once. Mirrors the blocking client exactly.
165    async fn execute_at(&self, base: &str, req: &HttpRequest) -> Result<(u16, Value)> {
166        let token = self.current_token().await;
167        let (status, gateway, resp_body) = self.send_once(base, req, token.as_deref()).await?;
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_async(&auth_url, token.as_deref())
179                .await
180            {
181                let (status, gateway, resp_body) =
182                    self.send_once(base, req, Some(&fresh)).await?;
183                let Some(auth_url) = gateway else {
184                    return Ok((status, resp_body));
185                };
186                return Err(auth::denial_error(
187                    status,
188                    self.auth.box_host(),
189                    &auth_url,
190                    true,
191                ));
192            }
193        }
194        Err(auth::denial_error(
195            status,
196            self.auth.box_host(),
197            &auth_url,
198            token.is_some(),
199        ))
200    }
201
202    /// Token to attach right now: builder/env token, cached session token,
203    /// or a store lookup when the box is already known to be gated.
204    async fn current_token(&self) -> Option<String> {
205        if let Some(token) = self.auth.cached_token() {
206            return Some(token);
207        }
208        if self.auth.wants_store_token() {
209            let auth_url = self.auth.auth_url()?;
210            return self.auth.resolve_token_async(&auth_url, None).await;
211        }
212        None
213    }
214
215    /// One HTTP round-trip. Returns `(status, gateway_denial_auth_url,
216    /// body)`; the auth URL is `Some` only for a gateway denial (401/403/
217    /// 503 carrying the discovery header).
218    async fn send_once(
219        &self,
220        base: &str,
221        req: &HttpRequest,
222        token: Option<&str>,
223    ) -> Result<(u16, Option<String>, Value)> {
224        let url = format!("{}{}", base, req.path);
225        let mut r = match req.method {
226            Method::Get => self.http.get(&url),
227            Method::Post => self.http.post(&url),
228            Method::Put => self.http.put(&url),
229        };
230        match req.timeout {
231            Timeout::Default => r = r.timeout(self.default_timeout),
232            Timeout::After(d) => r = r.timeout(d),
233            Timeout::Unbounded => {}
234        }
235        if let Some(token) = token {
236            r = r.header("Authorization", format!("Bearer {token}"));
237        }
238        if let Some(body) = &req.body {
239            r = r.json(body);
240        }
241        let resp = r.send().await.map_err(|e| {
242            if e.is_timeout() {
243                Error::Timeout(e.to_string())
244            } else {
245                Error::Connection(e.to_string())
246            }
247        })?;
248        let status = resp.status().as_u16();
249        let gateway = if auth::is_denial(status) {
250            resp.headers()
251                .get(auth::DISCOVERY_HEADER)
252                .and_then(|v| v.to_str().ok())
253                .map(str::to_string)
254        } else {
255            None
256        };
257        let body: Value = match resp.json().await {
258            Ok(body) => body,
259            Err(e) if e.is_timeout() => return Err(Error::Timeout(e.to_string())),
260            Err(e) if status < 400 => {
261                return Err(Error::Decode(format!("non-JSON response: {e}")))
262            }
263            // Error responses (incl. gateway denials) may have no JSON body.
264            Err(_) => Value::Null,
265        };
266        Ok((status, gateway, body))
267    }
268
269    /// Execute one typed operation against a command endpoint.
270    pub(crate) async fn run<T>(&self, op: Op<T>) -> Result<T> {
271        let (status, body) = self.execute(&op.req).await?;
272        let resp = wire::parse_command(status, body)?;
273        (op.parse)(resp)
274    }
275
276    /// Resolve a debug net's full saved record for the debug service.
277    pub(crate) async fn debug_net_record(&self, name: &str) -> Result<Value> {
278        let records = self.nets_raw().await?;
279        crate::nets::debug::find_debug_record(records, name)
280    }
281
282    /// Raw saved-net records (untyped), with the `/nets/list` ->
283    /// `/uart/nets/list` fallback.
284    async fn nets_raw(&self) -> Result<Vec<Value>> {
285        let body = match self.get_json("/nets/list").await {
286            Ok(body) => body,
287            Err(primary) => self.get_json("/uart/nets/list").await.map_err(|_| primary)?,
288        };
289        Ok(wire::nets_list_values(body))
290    }
291
292    async fn get_json(&self, path: &str) -> Result<Value> {
293        let (status, body) = self.execute(&wire::get(path)).await?;
294        if status != 200 {
295            return Err(Error::Box {
296                status,
297                message: body
298                    .get("error")
299                    .and_then(Value::as_str)
300                    .unwrap_or("request failed")
301                    .to_string(),
302            });
303        }
304        Ok(body)
305    }
306
307    // -- box-level queries --------------------------------------------------
308
309    /// List every net configured on the box (full saved records).
310    ///
311    /// Falls back to the older `/uart/nets/list` shape for box images that
312    /// predate `/nets/list`, like the Lager CLI does.
313    pub async fn nets(&self) -> Result<Vec<NetRecord>> {
314        match self.get_json("/nets/list").await {
315            Ok(body) => wire::nets_from_body(body),
316            Err(primary) => match self.get_json("/uart/nets/list").await {
317                Ok(body) => wire::nets_from_body(body),
318                Err(_) => Err(primary),
319            },
320        }
321    }
322
323    /// Check that the box HTTP server is up.
324    pub async fn health(&self) -> Result<Health> {
325        let body = self.get_json("/health").await?;
326        serde_json::from_value(body).map_err(Into::into)
327    }
328
329    /// Box status: version, configured nets, and endpoint capabilities.
330    pub async fn status(&self) -> Result<BoxStatus> {
331        let body = self.get_json("/status").await?;
332        serde_json::from_value(body).map_err(Into::into)
333    }
334
335    /// Enumerate USB devices on the box's bus from sysfs (lsusb-like).
336    ///
337    /// A few milliseconds per call with no exclusive device access, so it
338    /// is safe to poll frequently — e.g. reading the DUT's iSerial to see
339    /// what it re-enumerated as after a hub power-cycle or DFU detach.
340    ///
341    /// Requires box software >= 0.33.0; older boxes fail with
342    /// [`Error::UnsupportedByBox`].
343    pub async fn usb_devices(&self) -> Result<Vec<UsbDeviceInfo>> {
344        self.usb_devices_matching(&UsbDeviceFilter::default()).await
345    }
346
347    /// Like [`AsyncLagerBox::usb_devices`], with box-side vid/pid/serial
348    /// filters.
349    pub async fn usb_devices_matching(
350        &self,
351        filter: &UsbDeviceFilter,
352    ) -> Result<Vec<UsbDeviceInfo>> {
353        match self.execute(&wire::usb_devices(filter)).await {
354            Ok((status, body)) => wire::parse_usb_devices(status, body),
355            Err(e) => Err(wire::map_route_missing(e, wire::usb_devices_unsupported)),
356        }
357    }
358
359    // -- box lock / reservation ----------------------------------------------
360
361    async fn lock_call(&self, req: &HttpRequest) -> Result<BoxLock> {
362        match self.execute(req).await {
363            Ok((status, body)) => wire::parse_lock(status, body),
364            Err(e) => Err(wire::map_route_missing(e, wire::lock_unsupported)),
365        }
366    }
367
368    /// Current box lock state (`GET /lock`); `locked: false` when free.
369    pub async fn lock_status(&self) -> Result<BoxLock> {
370        self.lock_call(&wire::lock_status()).await
371    }
372
373    /// Claim the box for `user` (an eternal `holder_type: "user"` lock,
374    /// exactly like `lager boxes lock`). Re-acquiring your own lock
375    /// refreshes it; a box held by someone else fails with
376    /// [`Error::Box`] (HTTP 409) naming the holder.
377    pub async fn lock(&self, user: &str) -> Result<BoxLock> {
378        self.lock_call(&wire::lock_acquire(user, "user", None)).await
379    }
380
381    /// Claim the box with an explicit holder type and TTL.
382    /// `ttl_seconds: None` means the lock never auto-expires; with a TTL,
383    /// keep the lock alive via [`AsyncLagerBox::lock_heartbeat`].
384    pub async fn lock_with(
385        &self,
386        user: &str,
387        holder_type: &str,
388        ttl_seconds: Option<u64>,
389    ) -> Result<BoxLock> {
390        self.lock_call(&wire::lock_acquire(user, holder_type, ttl_seconds))
391            .await
392    }
393
394    /// Refresh a TTL lock's heartbeat. Fails with [`Error::Box`] when the
395    /// box is not locked (HTTP 404) or held by someone else (HTTP 403).
396    pub async fn lock_heartbeat(&self, user: &str) -> Result<BoxLock> {
397        self.lock_call(&wire::lock_heartbeat(user)).await
398    }
399
400    /// Release `user`'s box lock. Releasing an already-unlocked box
401    /// succeeds; a box held by someone else fails with [`Error::Box`]
402    /// (HTTP 403).
403    pub async fn unlock(&self, user: &str) -> Result<()> {
404        self.lock_call(&wire::unlock(user, false)).await.map(|_| ())
405    }
406
407    /// Release the box lock even when held by another user
408    /// (`lager boxes unlock --force`).
409    pub async fn unlock_force(&self, user: &str) -> Result<()> {
410        self.lock_call(&wire::unlock(user, true)).await.map(|_| ())
411    }
412
413    // -- per-net safety limits ------------------------------------------------
414
415    /// Set the safety limits on a saved net (`PUT /nets/<name>/safety-limits`,
416    /// box >= 0.35.0). Returns the limits the box applied.
417    ///
418    /// The PUT **replaces** the net's whole limits record: fields left `None`
419    /// in `limits` are removed from the net, not preserved. Read the current
420    /// limits first ([`AsyncLagerBox::safety_limits`]) if you mean to change
421    /// one ceiling and keep the rest. An all-`None` `limits` clears the
422    /// record, same as [`AsyncLagerBox::clear_safety_limits`].
423    ///
424    /// The ceilings are enforced by the box's hardware service, out of reach
425    /// of test scripts; a setpoint (or inline `ovp=`/`ocp=` trip) above a
426    /// ceiling is refused before it touches the instrument. Older boxes fail
427    /// with [`Error::UnsupportedByBox`]; validation refusals (`max_power`,
428    /// non-positive ceilings) and an unknown net come back as [`Error::Box`].
429    pub async fn set_safety_limits(
430        &self,
431        name: &str,
432        limits: &SafetyLimits,
433    ) -> Result<Option<SafetyLimits>> {
434        match self.execute(&wire::safety_limits_set(name, limits)).await {
435            Ok((status, body)) => wire::parse_safety_limits(status, body),
436            Err(e) => Err(wire::map_route_missing(e, wire::safety_limits_unsupported)),
437        }
438    }
439
440    /// Remove a net's safety limits, returning it to unrestricted.
441    pub async fn clear_safety_limits(&self, name: &str) -> Result<()> {
442        self.set_safety_limits(name, &SafetyLimits::default())
443            .await
444            .map(|_| ())
445    }
446
447    /// Read the safety limits configured on a saved net, via `/nets/list`.
448    /// `Ok(None)` means the net exists and is unrestricted; a missing net is
449    /// an [`Error::Box`] with status 404.
450    pub async fn safety_limits(&self, name: &str) -> Result<Option<SafetyLimits>> {
451        let nets = self.nets().await?;
452        nets.iter()
453            .find(|rec| rec.name == name)
454            .map(|rec| rec.safety_limits)
455            .ok_or_else(|| Error::Box {
456                status: 404,
457                message: format!("no saved net named '{name}' on this box"),
458            })
459    }
460
461    // -- net handle constructors ---------------------------------------------
462
463    /// Handle for a power-supply net.
464    pub fn supply(&self, name: impl Into<String>) -> AsyncSupply<'_> {
465        AsyncSupply { client: self, name: name.into() }
466    }
467
468    /// Handle for a battery-simulator net.
469    pub fn battery(&self, name: impl Into<String>) -> AsyncBattery<'_> {
470        AsyncBattery { client: self, name: name.into() }
471    }
472
473    /// Handle for an electronic-load net.
474    pub fn eload(&self, name: impl Into<String>) -> AsyncEload<'_> {
475        AsyncEload { client: self, name: name.into() }
476    }
477
478    /// Handle for a solar-simulator net (EA PSB photovoltaic mode).
479    pub fn solar(&self, name: impl Into<String>) -> AsyncSolar<'_> {
480        AsyncSolar { client: self, name: name.into() }
481    }
482
483    /// Handle for a GPIO net.
484    pub fn gpio(&self, name: impl Into<String>) -> AsyncGpio<'_> {
485        AsyncGpio { client: self, name: name.into() }
486    }
487
488    /// Handle for an ADC net.
489    pub fn adc(&self, name: impl Into<String>) -> AsyncAdc<'_> {
490        AsyncAdc { client: self, name: name.into() }
491    }
492
493    /// Handle for a DAC net.
494    pub fn dac(&self, name: impl Into<String>) -> AsyncDac<'_> {
495        AsyncDac { client: self, name: name.into() }
496    }
497
498    /// Handle for a thermocouple net.
499    pub fn thermocouple(&self, name: impl Into<String>) -> AsyncThermocouple<'_> {
500        AsyncThermocouple { client: self, name: name.into() }
501    }
502
503    /// Handle for a watt-meter net.
504    pub fn watt_meter(&self, name: impl Into<String>) -> AsyncWattMeter<'_> {
505        AsyncWattMeter { client: self, name: name.into() }
506    }
507
508    /// Handle for an energy-analyzer net.
509    pub fn energy_analyzer(&self, name: impl Into<String>) -> AsyncEnergyAnalyzer<'_> {
510        AsyncEnergyAnalyzer { client: self, name: name.into() }
511    }
512
513    /// Handle for an SPI bus net.
514    pub fn spi(&self, name: impl Into<String>) -> AsyncSpi<'_> {
515        AsyncSpi { client: self, name: name.into() }
516    }
517
518    /// Handle for an I2C bus net.
519    pub fn i2c(&self, name: impl Into<String>) -> AsyncI2c<'_> {
520        AsyncI2c { client: self, name: name.into() }
521    }
522
523    /// Handle for a USB hub port net.
524    pub fn usb(&self, name: impl Into<String>) -> AsyncUsbPort<'_> {
525        AsyncUsbPort { client: self, name: name.into() }
526    }
527
528    /// Handle for a robot-arm net (Rotrics Dexarm).
529    pub fn arm(&self, name: impl Into<String>) -> AsyncArm<'_> {
530        AsyncArm { client: self, name: name.into() }
531    }
532
533    /// Handle for a webcam net (MJPEG streaming).
534    pub fn webcam(&self, name: impl Into<String>) -> AsyncWebcam<'_> {
535        AsyncWebcam { client: self, name: name.into() }
536    }
537
538    /// Handle for a router net (MikroTik RouterOS).
539    pub fn router(&self, name: impl Into<String>) -> AsyncRouter<'_> {
540        AsyncRouter { client: self, name: name.into() }
541    }
542
543    /// Handle for the box's BLE adapter (box-level, not a saved net).
544    pub fn ble(&self) -> AsyncBle<'_> {
545        AsyncBle { client: self }
546    }
547
548    /// Handle for the box's WiFi interface (box-level, not a saved net).
549    pub fn wifi(&self) -> AsyncWifi<'_> {
550        AsyncWifi { client: self }
551    }
552
553    /// Handle for BluFi (ESP32 WiFi provisioning over BLE; box-level).
554    pub fn blufi(&self) -> AsyncBlufi<'_> {
555        AsyncBlufi { client: self }
556    }
557
558    /// Handle for box-side DFU via dfu-util (box-level, not a saved net).
559    pub fn dfu(&self) -> AsyncDfu<'_> {
560        AsyncDfu { client: self }
561    }
562
563    /// Handle for a debug-probe net (flash/erase/reset/read_memory).
564    /// Talks to the box debug service on port 8765.
565    pub fn debug(&self, name: impl Into<String>) -> AsyncDebugNet<'_> {
566        AsyncDebugNet { client: self, name: name.into(), record: Default::default() }
567    }
568
569    /// Handle for an oscilloscope net. **Stub:** see [`Scope`].
570    pub fn scope(&self, name: impl Into<String>) -> Scope {
571        Scope::new(name)
572    }
573}