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, 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        };
229        match req.timeout {
230            Timeout::Default => r = r.timeout(self.default_timeout),
231            Timeout::After(d) => r = r.timeout(d),
232            Timeout::Unbounded => {}
233        }
234        if let Some(token) = token {
235            r = r.header("Authorization", format!("Bearer {token}"));
236        }
237        if let Some(body) = &req.body {
238            r = r.json(body);
239        }
240        let resp = r.send().await.map_err(|e| {
241            if e.is_timeout() {
242                Error::Timeout(e.to_string())
243            } else {
244                Error::Connection(e.to_string())
245            }
246        })?;
247        let status = resp.status().as_u16();
248        let gateway = if auth::is_denial(status) {
249            resp.headers()
250                .get(auth::DISCOVERY_HEADER)
251                .and_then(|v| v.to_str().ok())
252                .map(str::to_string)
253        } else {
254            None
255        };
256        let body: Value = match resp.json().await {
257            Ok(body) => body,
258            Err(e) if e.is_timeout() => return Err(Error::Timeout(e.to_string())),
259            Err(e) if status < 400 => {
260                return Err(Error::Decode(format!("non-JSON response: {e}")))
261            }
262            // Error responses (incl. gateway denials) may have no JSON body.
263            Err(_) => Value::Null,
264        };
265        Ok((status, gateway, body))
266    }
267
268    /// Execute one typed operation against a command endpoint.
269    pub(crate) async fn run<T>(&self, op: Op<T>) -> Result<T> {
270        let (status, body) = self.execute(&op.req).await?;
271        let resp = wire::parse_command(status, body)?;
272        (op.parse)(resp)
273    }
274
275    /// Resolve a debug net's full saved record for the debug service.
276    pub(crate) async fn debug_net_record(&self, name: &str) -> Result<Value> {
277        let records = self.nets_raw().await?;
278        crate::nets::debug::find_debug_record(records, name)
279    }
280
281    /// Raw saved-net records (untyped), with the `/nets/list` ->
282    /// `/uart/nets/list` fallback.
283    async fn nets_raw(&self) -> Result<Vec<Value>> {
284        let body = match self.get_json("/nets/list").await {
285            Ok(body) => body,
286            Err(primary) => self.get_json("/uart/nets/list").await.map_err(|_| primary)?,
287        };
288        Ok(wire::nets_list_values(body))
289    }
290
291    async fn get_json(&self, path: &str) -> Result<Value> {
292        let (status, body) = self.execute(&wire::get(path)).await?;
293        if status != 200 {
294            return Err(Error::Box {
295                status,
296                message: body
297                    .get("error")
298                    .and_then(Value::as_str)
299                    .unwrap_or("request failed")
300                    .to_string(),
301            });
302        }
303        Ok(body)
304    }
305
306    // -- box-level queries --------------------------------------------------
307
308    /// List every net configured on the box (full saved records).
309    ///
310    /// Falls back to the older `/uart/nets/list` shape for box images that
311    /// predate `/nets/list`, like the Lager CLI does.
312    pub async fn nets(&self) -> Result<Vec<NetRecord>> {
313        match self.get_json("/nets/list").await {
314            Ok(body) => wire::nets_from_body(body),
315            Err(primary) => match self.get_json("/uart/nets/list").await {
316                Ok(body) => wire::nets_from_body(body),
317                Err(_) => Err(primary),
318            },
319        }
320    }
321
322    /// Check that the box HTTP server is up.
323    pub async fn health(&self) -> Result<Health> {
324        let body = self.get_json("/health").await?;
325        serde_json::from_value(body).map_err(Into::into)
326    }
327
328    /// Box status: version, configured nets, and endpoint capabilities.
329    pub async fn status(&self) -> Result<BoxStatus> {
330        let body = self.get_json("/status").await?;
331        serde_json::from_value(body).map_err(Into::into)
332    }
333
334    /// Enumerate USB devices on the box's bus from sysfs (lsusb-like).
335    ///
336    /// A few milliseconds per call with no exclusive device access, so it
337    /// is safe to poll frequently — e.g. reading the DUT's iSerial to see
338    /// what it re-enumerated as after a hub power-cycle or DFU detach.
339    ///
340    /// Requires box software >= 0.33.0; older boxes fail with
341    /// [`Error::UnsupportedByBox`].
342    pub async fn usb_devices(&self) -> Result<Vec<UsbDeviceInfo>> {
343        self.usb_devices_matching(&UsbDeviceFilter::default()).await
344    }
345
346    /// Like [`AsyncLagerBox::usb_devices`], with box-side vid/pid/serial
347    /// filters.
348    pub async fn usb_devices_matching(
349        &self,
350        filter: &UsbDeviceFilter,
351    ) -> Result<Vec<UsbDeviceInfo>> {
352        match self.execute(&wire::usb_devices(filter)).await {
353            Ok((status, body)) => wire::parse_usb_devices(status, body),
354            Err(e) => Err(wire::map_route_missing(e, wire::usb_devices_unsupported)),
355        }
356    }
357
358    // -- box lock / reservation ----------------------------------------------
359
360    async fn lock_call(&self, req: &HttpRequest) -> Result<BoxLock> {
361        match self.execute(req).await {
362            Ok((status, body)) => wire::parse_lock(status, body),
363            Err(e) => Err(wire::map_route_missing(e, wire::lock_unsupported)),
364        }
365    }
366
367    /// Current box lock state (`GET /lock`); `locked: false` when free.
368    pub async fn lock_status(&self) -> Result<BoxLock> {
369        self.lock_call(&wire::lock_status()).await
370    }
371
372    /// Claim the box for `user` (an eternal `holder_type: "user"` lock,
373    /// exactly like `lager boxes lock`). Re-acquiring your own lock
374    /// refreshes it; a box held by someone else fails with
375    /// [`Error::Box`] (HTTP 409) naming the holder.
376    pub async fn lock(&self, user: &str) -> Result<BoxLock> {
377        self.lock_call(&wire::lock_acquire(user, "user", None)).await
378    }
379
380    /// Claim the box with an explicit holder type and TTL.
381    /// `ttl_seconds: None` means the lock never auto-expires; with a TTL,
382    /// keep the lock alive via [`AsyncLagerBox::lock_heartbeat`].
383    pub async fn lock_with(
384        &self,
385        user: &str,
386        holder_type: &str,
387        ttl_seconds: Option<u64>,
388    ) -> Result<BoxLock> {
389        self.lock_call(&wire::lock_acquire(user, holder_type, ttl_seconds))
390            .await
391    }
392
393    /// Refresh a TTL lock's heartbeat. Fails with [`Error::Box`] when the
394    /// box is not locked (HTTP 404) or held by someone else (HTTP 403).
395    pub async fn lock_heartbeat(&self, user: &str) -> Result<BoxLock> {
396        self.lock_call(&wire::lock_heartbeat(user)).await
397    }
398
399    /// Release `user`'s box lock. Releasing an already-unlocked box
400    /// succeeds; a box held by someone else fails with [`Error::Box`]
401    /// (HTTP 403).
402    pub async fn unlock(&self, user: &str) -> Result<()> {
403        self.lock_call(&wire::unlock(user, false)).await.map(|_| ())
404    }
405
406    /// Release the box lock even when held by another user
407    /// (`lager boxes unlock --force`).
408    pub async fn unlock_force(&self, user: &str) -> Result<()> {
409        self.lock_call(&wire::unlock(user, true)).await.map(|_| ())
410    }
411
412    // -- net handle constructors ---------------------------------------------
413
414    /// Handle for a power-supply net.
415    pub fn supply(&self, name: impl Into<String>) -> AsyncSupply<'_> {
416        AsyncSupply { client: self, name: name.into() }
417    }
418
419    /// Handle for a battery-simulator net.
420    pub fn battery(&self, name: impl Into<String>) -> AsyncBattery<'_> {
421        AsyncBattery { client: self, name: name.into() }
422    }
423
424    /// Handle for an electronic-load net.
425    pub fn eload(&self, name: impl Into<String>) -> AsyncEload<'_> {
426        AsyncEload { client: self, name: name.into() }
427    }
428
429    /// Handle for a solar-simulator net (EA PSB photovoltaic mode).
430    pub fn solar(&self, name: impl Into<String>) -> AsyncSolar<'_> {
431        AsyncSolar { client: self, name: name.into() }
432    }
433
434    /// Handle for a GPIO net.
435    pub fn gpio(&self, name: impl Into<String>) -> AsyncGpio<'_> {
436        AsyncGpio { client: self, name: name.into() }
437    }
438
439    /// Handle for an ADC net.
440    pub fn adc(&self, name: impl Into<String>) -> AsyncAdc<'_> {
441        AsyncAdc { client: self, name: name.into() }
442    }
443
444    /// Handle for a DAC net.
445    pub fn dac(&self, name: impl Into<String>) -> AsyncDac<'_> {
446        AsyncDac { client: self, name: name.into() }
447    }
448
449    /// Handle for a thermocouple net.
450    pub fn thermocouple(&self, name: impl Into<String>) -> AsyncThermocouple<'_> {
451        AsyncThermocouple { client: self, name: name.into() }
452    }
453
454    /// Handle for a watt-meter net.
455    pub fn watt_meter(&self, name: impl Into<String>) -> AsyncWattMeter<'_> {
456        AsyncWattMeter { client: self, name: name.into() }
457    }
458
459    /// Handle for an energy-analyzer net.
460    pub fn energy_analyzer(&self, name: impl Into<String>) -> AsyncEnergyAnalyzer<'_> {
461        AsyncEnergyAnalyzer { client: self, name: name.into() }
462    }
463
464    /// Handle for an SPI bus net.
465    pub fn spi(&self, name: impl Into<String>) -> AsyncSpi<'_> {
466        AsyncSpi { client: self, name: name.into() }
467    }
468
469    /// Handle for an I2C bus net.
470    pub fn i2c(&self, name: impl Into<String>) -> AsyncI2c<'_> {
471        AsyncI2c { client: self, name: name.into() }
472    }
473
474    /// Handle for a USB hub port net.
475    pub fn usb(&self, name: impl Into<String>) -> AsyncUsbPort<'_> {
476        AsyncUsbPort { client: self, name: name.into() }
477    }
478
479    /// Handle for a robot-arm net (Rotrics Dexarm).
480    pub fn arm(&self, name: impl Into<String>) -> AsyncArm<'_> {
481        AsyncArm { client: self, name: name.into() }
482    }
483
484    /// Handle for a webcam net (MJPEG streaming).
485    pub fn webcam(&self, name: impl Into<String>) -> AsyncWebcam<'_> {
486        AsyncWebcam { client: self, name: name.into() }
487    }
488
489    /// Handle for a router net (MikroTik RouterOS).
490    pub fn router(&self, name: impl Into<String>) -> AsyncRouter<'_> {
491        AsyncRouter { client: self, name: name.into() }
492    }
493
494    /// Handle for the box's BLE adapter (box-level, not a saved net).
495    pub fn ble(&self) -> AsyncBle<'_> {
496        AsyncBle { client: self }
497    }
498
499    /// Handle for the box's WiFi interface (box-level, not a saved net).
500    pub fn wifi(&self) -> AsyncWifi<'_> {
501        AsyncWifi { client: self }
502    }
503
504    /// Handle for BluFi (ESP32 WiFi provisioning over BLE; box-level).
505    pub fn blufi(&self) -> AsyncBlufi<'_> {
506        AsyncBlufi { client: self }
507    }
508
509    /// Handle for box-side DFU via dfu-util (box-level, not a saved net).
510    pub fn dfu(&self) -> AsyncDfu<'_> {
511        AsyncDfu { client: self }
512    }
513
514    /// Handle for a debug-probe net (flash/erase/reset/read_memory).
515    /// Talks to the box debug service on port 8765.
516    pub fn debug(&self, name: impl Into<String>) -> AsyncDebugNet<'_> {
517        AsyncDebugNet { client: self, name: name.into(), record: Default::default() }
518    }
519
520    /// Handle for an oscilloscope net. **Stub:** see [`Scope`].
521    pub fn scope(&self, name: impl Into<String>) -> Scope {
522        Scope::new(name)
523    }
524}