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::eload::AsyncEload;
20use crate::nets::energy::AsyncEnergyAnalyzer;
21use crate::nets::gpio::AsyncGpio;
22use crate::nets::i2c::AsyncI2c;
23use crate::nets::router::AsyncRouter;
24use crate::nets::scope::Scope;
25use crate::nets::solar::AsyncSolar;
26use crate::nets::spi::AsyncSpi;
27use crate::nets::supply::AsyncSupply;
28use crate::nets::thermocouple::AsyncThermocouple;
29use crate::nets::usb::AsyncUsbPort;
30use crate::nets::watt::AsyncWattMeter;
31use crate::nets::webcam::AsyncWebcam;
32use crate::nets::wifi::AsyncWifi;
33use crate::wire::{self, BoxStatus, Health, HttpRequest, Method, NetRecord, Op, Timeout};
34
35/// A connection to one Lager box, over async HTTP (reqwest/tokio).
36///
37/// ```no_run
38/// use lager::AsyncLagerBox;
39///
40/// #[tokio::main]
41/// async fn main() -> lager::Result<()> {
42///     let lager = AsyncLagerBox::connect("192.168.1.42")?;
43///     let supply = lager.supply("supply1");
44///     supply.set_voltage(3.3).await?;
45///     supply.enable().await?;
46///     let v = lager.adc("vbat_sense").read().await?;
47///     assert!((v - 3.3).abs() < 0.1);
48///     supply.disable().await
49/// }
50/// ```
51pub struct AsyncLagerBox {
52    base: String,
53    debug_base: String,
54    http: reqwest::Client,
55    default_timeout: Duration,
56    auth: GatewayAuth,
57}
58
59/// Builder for [`AsyncLagerBox`], for overriding the default timeout, the
60/// debug-service URL, and gateway auth.
61pub struct AsyncLagerBoxBuilder {
62    host: String,
63    debug_url: Option<String>,
64    default_timeout: Duration,
65    bearer_token: Option<String>,
66}
67
68impl AsyncLagerBoxBuilder {
69    /// Override the default HTTP timeout for quick commands (10s unless
70    /// changed). Long-running actions still compute their own wider budgets.
71    pub fn timeout(mut self, timeout: Duration) -> Self {
72        self.default_timeout = timeout;
73        self
74    }
75
76    /// Override the debug-service base URL (default: the box host on port
77    /// 8765). Also settable via `LAGER_DEBUG_SERVICE_URL`.
78    pub fn debug_service_url(mut self, url: impl Into<String>) -> Self {
79        self.debug_url = Some(url.into());
80        self
81    }
82
83    /// Attach `Authorization: Bearer <token>` to every request, for boxes
84    /// behind an authenticating gateway. Also settable via the
85    /// `LAGER_GATEWAY_TOKEN` environment variable.
86    ///
87    /// Without this, the crate reuses the Lager CLI's session
88    /// (`lager login <auth_url>`, stored in `~/.lager_gateway_auth`)
89    /// automatically when a gateway asks for auth, including transparent
90    /// refresh of expired access tokens. Plain (ungated) boxes are
91    /// unaffected either way.
92    pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
93        self.bearer_token = Some(token.into());
94        self
95    }
96
97    /// Build the client.
98    pub fn build(self) -> Result<AsyncLagerBox> {
99        let base = wire::base_url(&self.host)?;
100        let debug_base = match self.debug_url {
101            Some(url) => wire::base_url_with_port(&url, wire::DEBUG_SERVICE_PORT)?,
102            None => wire::service_base(&base, wire::DEBUG_SERVICE_PORT),
103        };
104        let auth = GatewayAuth::new(&base, self.bearer_token);
105        Ok(AsyncLagerBox {
106            base,
107            debug_base,
108            http: reqwest::Client::new(),
109            default_timeout: self.default_timeout,
110            auth,
111        })
112    }
113}
114
115impl AsyncLagerBox {
116    /// Connect to a box by host name, IP, `host:port`, or full URL.
117    /// The port defaults to 9000 (the box HTTP server).
118    pub fn connect(host: impl Into<String>) -> Result<Self> {
119        Self::builder(host).build()
120    }
121
122    /// Connect to the box named by the `LAGER_BOX_HOST` environment
123    /// variable.
124    pub fn from_env() -> Result<Self> {
125        let host = std::env::var(crate::BOX_HOST_ENV)
126            .map_err(|_| Error::Config(format!("{} is not set", crate::BOX_HOST_ENV)))?;
127        Self::connect(host)
128    }
129
130    /// Start building a client with non-default settings.
131    pub fn builder(host: impl Into<String>) -> AsyncLagerBoxBuilder {
132        AsyncLagerBoxBuilder {
133            host: host.into(),
134            debug_url: std::env::var(crate::DEBUG_SERVICE_URL_ENV).ok(),
135            default_timeout: wire::DEFAULT_TIMEOUT,
136            bearer_token: None,
137        }
138    }
139
140    /// The base URL this client talks to, e.g. `http://192.168.1.42:9000`.
141    pub fn base_url(&self) -> &str {
142        &self.base
143    }
144
145    // -- transport ---------------------------------------------------------
146
147    /// Send one request against the port-9000 server.
148    pub(crate) async fn execute(&self, req: &HttpRequest) -> Result<(u16, Value)> {
149        self.execute_at(&self.base, req).await
150    }
151
152    /// Send one request against the debug service (port 8765).
153    pub(crate) async fn execute_debug(&self, req: &HttpRequest) -> Result<(u16, Value)> {
154        self.execute_at(&self.debug_base, req).await
155    }
156
157    /// Send one request against an arbitrary base URL. Attaches gateway
158    /// auth when known, and handles a gateway denial by resolving
159    /// credentials (CLI session store, with transparent refresh) and
160    /// retrying once. Mirrors the blocking client exactly.
161    async fn execute_at(&self, base: &str, req: &HttpRequest) -> Result<(u16, Value)> {
162        let token = self.current_token().await;
163        let (status, gateway, resp_body) = self.send_once(base, req, token.as_deref()).await?;
164
165        let Some(auth_url) = gateway else {
166            return Ok((status, resp_body));
167        };
168        // Gateway denial: learn the box→auth-server mapping (like the CLI),
169        // then retry once with a credential the gateway has not just seen.
170        self.auth.learn_auth_server(&auth_url);
171        if status == 401 && !self.auth.has_static_token() {
172            if let Some(fresh) = self
173                .auth
174                .resolve_token_async(&auth_url, token.as_deref())
175                .await
176            {
177                let (status, gateway, resp_body) =
178                    self.send_once(base, req, Some(&fresh)).await?;
179                let Some(auth_url) = gateway else {
180                    return Ok((status, resp_body));
181                };
182                return Err(auth::denial_error(
183                    status,
184                    self.auth.box_host(),
185                    &auth_url,
186                    true,
187                ));
188            }
189        }
190        Err(auth::denial_error(
191            status,
192            self.auth.box_host(),
193            &auth_url,
194            token.is_some(),
195        ))
196    }
197
198    /// Token to attach right now: builder/env token, cached session token,
199    /// or a store lookup when the box is already known to be gated.
200    async fn current_token(&self) -> Option<String> {
201        if let Some(token) = self.auth.cached_token() {
202            return Some(token);
203        }
204        if self.auth.wants_store_token() {
205            let auth_url = self.auth.auth_url()?;
206            return self.auth.resolve_token_async(&auth_url, None).await;
207        }
208        None
209    }
210
211    /// One HTTP round-trip. Returns `(status, gateway_denial_auth_url,
212    /// body)`; the auth URL is `Some` only for a gateway denial (401/403/
213    /// 503 carrying the discovery header).
214    async fn send_once(
215        &self,
216        base: &str,
217        req: &HttpRequest,
218        token: Option<&str>,
219    ) -> Result<(u16, Option<String>, Value)> {
220        let url = format!("{}{}", base, req.path);
221        let mut r = match req.method {
222            Method::Get => self.http.get(&url),
223            Method::Post => self.http.post(&url),
224        };
225        match req.timeout {
226            Timeout::Default => r = r.timeout(self.default_timeout),
227            Timeout::After(d) => r = r.timeout(d),
228            Timeout::Unbounded => {}
229        }
230        if let Some(token) = token {
231            r = r.header("Authorization", format!("Bearer {token}"));
232        }
233        if let Some(body) = &req.body {
234            r = r.json(body);
235        }
236        let resp = r.send().await.map_err(|e| {
237            if e.is_timeout() {
238                Error::Timeout(e.to_string())
239            } else {
240                Error::Connection(e.to_string())
241            }
242        })?;
243        let status = resp.status().as_u16();
244        let gateway = if auth::is_denial(status) {
245            resp.headers()
246                .get(auth::DISCOVERY_HEADER)
247                .and_then(|v| v.to_str().ok())
248                .map(str::to_string)
249        } else {
250            None
251        };
252        let body: Value = match resp.json().await {
253            Ok(body) => body,
254            Err(e) if e.is_timeout() => return Err(Error::Timeout(e.to_string())),
255            Err(e) if status < 400 => {
256                return Err(Error::Decode(format!("non-JSON response: {e}")))
257            }
258            // Error responses (incl. gateway denials) may have no JSON body.
259            Err(_) => Value::Null,
260        };
261        Ok((status, gateway, body))
262    }
263
264    /// Execute one typed operation against a command endpoint.
265    pub(crate) async fn run<T>(&self, op: Op<T>) -> Result<T> {
266        let (status, body) = self.execute(&op.req).await?;
267        let resp = wire::parse_command(status, body)?;
268        (op.parse)(resp)
269    }
270
271    /// Resolve a debug net's full saved record for the debug service.
272    pub(crate) async fn debug_net_record(&self, name: &str) -> Result<Value> {
273        let records = self.nets_raw().await?;
274        crate::nets::debug::find_debug_record(records, name)
275    }
276
277    /// Raw saved-net records (untyped), with the `/nets/list` ->
278    /// `/uart/nets/list` fallback.
279    async fn nets_raw(&self) -> Result<Vec<Value>> {
280        let body = match self.get_json("/nets/list").await {
281            Ok(body) => body,
282            Err(primary) => self.get_json("/uart/nets/list").await.map_err(|_| primary)?,
283        };
284        Ok(wire::nets_list_values(body))
285    }
286
287    async fn get_json(&self, path: &str) -> Result<Value> {
288        let (status, body) = self.execute(&wire::get(path)).await?;
289        if status != 200 {
290            return Err(Error::Box {
291                status,
292                message: body
293                    .get("error")
294                    .and_then(Value::as_str)
295                    .unwrap_or("request failed")
296                    .to_string(),
297            });
298        }
299        Ok(body)
300    }
301
302    // -- box-level queries --------------------------------------------------
303
304    /// List every net configured on the box (full saved records).
305    ///
306    /// Falls back to the older `/uart/nets/list` shape for box images that
307    /// predate `/nets/list`, like the Lager CLI does.
308    pub async fn nets(&self) -> Result<Vec<NetRecord>> {
309        match self.get_json("/nets/list").await {
310            Ok(body) => wire::nets_from_body(body),
311            Err(primary) => match self.get_json("/uart/nets/list").await {
312                Ok(body) => wire::nets_from_body(body),
313                Err(_) => Err(primary),
314            },
315        }
316    }
317
318    /// Check that the box HTTP server is up.
319    pub async fn health(&self) -> Result<Health> {
320        let body = self.get_json("/health").await?;
321        serde_json::from_value(body).map_err(Into::into)
322    }
323
324    /// Box status: version, configured nets, and endpoint capabilities.
325    pub async fn status(&self) -> Result<BoxStatus> {
326        let body = self.get_json("/status").await?;
327        serde_json::from_value(body).map_err(Into::into)
328    }
329
330    // -- net handle constructors ---------------------------------------------
331
332    /// Handle for a power-supply net.
333    pub fn supply(&self, name: impl Into<String>) -> AsyncSupply<'_> {
334        AsyncSupply { client: self, name: name.into() }
335    }
336
337    /// Handle for a battery-simulator net.
338    pub fn battery(&self, name: impl Into<String>) -> AsyncBattery<'_> {
339        AsyncBattery { client: self, name: name.into() }
340    }
341
342    /// Handle for an electronic-load net.
343    pub fn eload(&self, name: impl Into<String>) -> AsyncEload<'_> {
344        AsyncEload { client: self, name: name.into() }
345    }
346
347    /// Handle for a solar-simulator net (EA PSB photovoltaic mode).
348    pub fn solar(&self, name: impl Into<String>) -> AsyncSolar<'_> {
349        AsyncSolar { client: self, name: name.into() }
350    }
351
352    /// Handle for a GPIO net.
353    pub fn gpio(&self, name: impl Into<String>) -> AsyncGpio<'_> {
354        AsyncGpio { client: self, name: name.into() }
355    }
356
357    /// Handle for an ADC net.
358    pub fn adc(&self, name: impl Into<String>) -> AsyncAdc<'_> {
359        AsyncAdc { client: self, name: name.into() }
360    }
361
362    /// Handle for a DAC net.
363    pub fn dac(&self, name: impl Into<String>) -> AsyncDac<'_> {
364        AsyncDac { client: self, name: name.into() }
365    }
366
367    /// Handle for a thermocouple net.
368    pub fn thermocouple(&self, name: impl Into<String>) -> AsyncThermocouple<'_> {
369        AsyncThermocouple { client: self, name: name.into() }
370    }
371
372    /// Handle for a watt-meter net.
373    pub fn watt_meter(&self, name: impl Into<String>) -> AsyncWattMeter<'_> {
374        AsyncWattMeter { client: self, name: name.into() }
375    }
376
377    /// Handle for an energy-analyzer net.
378    pub fn energy_analyzer(&self, name: impl Into<String>) -> AsyncEnergyAnalyzer<'_> {
379        AsyncEnergyAnalyzer { client: self, name: name.into() }
380    }
381
382    /// Handle for an SPI bus net.
383    pub fn spi(&self, name: impl Into<String>) -> AsyncSpi<'_> {
384        AsyncSpi { client: self, name: name.into() }
385    }
386
387    /// Handle for an I2C bus net.
388    pub fn i2c(&self, name: impl Into<String>) -> AsyncI2c<'_> {
389        AsyncI2c { client: self, name: name.into() }
390    }
391
392    /// Handle for a USB hub port net.
393    pub fn usb(&self, name: impl Into<String>) -> AsyncUsbPort<'_> {
394        AsyncUsbPort { client: self, name: name.into() }
395    }
396
397    /// Handle for a robot-arm net (Rotrics Dexarm).
398    pub fn arm(&self, name: impl Into<String>) -> AsyncArm<'_> {
399        AsyncArm { client: self, name: name.into() }
400    }
401
402    /// Handle for a webcam net (MJPEG streaming).
403    pub fn webcam(&self, name: impl Into<String>) -> AsyncWebcam<'_> {
404        AsyncWebcam { client: self, name: name.into() }
405    }
406
407    /// Handle for a router net (MikroTik RouterOS).
408    pub fn router(&self, name: impl Into<String>) -> AsyncRouter<'_> {
409        AsyncRouter { client: self, name: name.into() }
410    }
411
412    /// Handle for the box's BLE adapter (box-level, not a saved net).
413    pub fn ble(&self) -> AsyncBle<'_> {
414        AsyncBle { client: self }
415    }
416
417    /// Handle for the box's WiFi interface (box-level, not a saved net).
418    pub fn wifi(&self) -> AsyncWifi<'_> {
419        AsyncWifi { client: self }
420    }
421
422    /// Handle for BluFi (ESP32 WiFi provisioning over BLE; box-level).
423    pub fn blufi(&self) -> AsyncBlufi<'_> {
424        AsyncBlufi { client: self }
425    }
426
427    /// Handle for a debug-probe net (flash/erase/reset/read_memory).
428    /// Talks to the box debug service on port 8765.
429    pub fn debug(&self, name: impl Into<String>) -> AsyncDebugNet<'_> {
430        AsyncDebugNet { client: self, name: name.into() }
431    }
432
433    /// Handle for an oscilloscope net. **Stub:** see [`Scope`].
434    pub fn scope(&self, name: impl Into<String>) -> Scope {
435        Scope::new(name)
436    }
437}