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::dfu::Dfu;
17use crate::nets::eload::Eload;
18use crate::nets::energy::EnergyAnalyzer;
19use crate::nets::gpio::Gpio;
20use crate::nets::i2c::I2c;
21use crate::nets::router::Router;
22use crate::nets::scope::Scope;
23use crate::nets::solar::Solar;
24use crate::nets::spi::Spi;
25use crate::nets::supply::Supply;
26use crate::nets::thermocouple::Thermocouple;
27use crate::nets::usb::UsbPort;
28use crate::nets::watt::WattMeter;
29use crate::nets::webcam::Webcam;
30use crate::nets::wifi::Wifi;
31use crate::wire::{
32    self, BoxLock, BoxStatus, Health, HttpRequest, Method, NetRecord, Op, SafetyLimits, Timeout,
33    UsbDeviceFilter, UsbDeviceInfo,
34};
35use crate::BOX_HOST_ENV;
36
37/// A connection to one Lager box, over blocking HTTP.
38///
39/// Cheap to construct: no network traffic happens until the first call.
40/// Net handles borrow the client, so a typical test creates one `LagerBox`
41/// and any number of handles from it.
42///
43/// ```no_run
44/// use lager::LagerBox;
45///
46/// fn main() -> lager::Result<()> {
47///     let lager = LagerBox::connect("192.168.1.42")?;
48///     let supply = lager.supply("supply1");
49///     supply.set_voltage(3.3)?;
50///     supply.enable()?;
51///     let v = lager.adc("vbat_sense").read()?;
52///     assert!((v - 3.3).abs() < 0.1);
53///     supply.disable()
54/// }
55/// ```
56pub struct LagerBox {
57    base: String,
58    debug_base: String,
59    agent: ureq::Agent,
60    default_timeout: Duration,
61    auth: GatewayAuth,
62}
63
64/// Builder for [`LagerBox`], for overriding the default timeout, the
65/// debug-service URL, and gateway auth.
66pub struct LagerBoxBuilder {
67    host: String,
68    debug_url: Option<String>,
69    default_timeout: Duration,
70    bearer_token: Option<String>,
71}
72
73impl LagerBoxBuilder {
74    /// Override the default HTTP timeout for quick commands (10s unless
75    /// changed). Long-running actions (watt/energy windows,
76    /// `wait_for_level`) still compute their own wider budgets.
77    pub fn timeout(mut self, timeout: Duration) -> Self {
78        self.default_timeout = timeout;
79        self
80    }
81
82    /// Override the debug-service base URL (default: the box host on port
83    /// 8765). Use this when the debug service is reached through an SSH
84    /// tunnel (e.g. `http://127.0.0.1:8765`). Also settable via the
85    /// `LAGER_DEBUG_SERVICE_URL` environment variable.
86    pub fn debug_service_url(mut self, url: impl Into<String>) -> Self {
87        self.debug_url = Some(url.into());
88        self
89    }
90
91    /// Attach `Authorization: Bearer <token>` to every request, for boxes
92    /// behind an authenticating gateway. Also settable via the
93    /// `LAGER_GATEWAY_TOKEN` environment variable.
94    ///
95    /// Without this, the crate reuses the Lager CLI's session
96    /// (`lager login <auth_url>`, stored in `~/.lager_gateway_auth`)
97    /// automatically when a gateway asks for auth, including transparent
98    /// refresh of expired access tokens. Plain (ungated) boxes are
99    /// unaffected either way.
100    pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
101        self.bearer_token = Some(token.into());
102        self
103    }
104
105    /// Build the client.
106    pub fn build(self) -> Result<LagerBox> {
107        let base = wire::base_url(&self.host)?;
108        let debug_base = match self.debug_url {
109            Some(url) => wire::base_url_with_port(&url, wire::DEBUG_SERVICE_PORT)?,
110            None => wire::service_base(&base, wire::DEBUG_SERVICE_PORT),
111        };
112        let auth = GatewayAuth::new(&base, self.bearer_token);
113        Ok(LagerBox {
114            base,
115            debug_base,
116            agent: ureq::AgentBuilder::new().build(),
117            default_timeout: self.default_timeout,
118            auth,
119        })
120    }
121}
122
123impl LagerBox {
124    /// Connect to a box by host name, IP, `host:port`, or full URL.
125    /// The port defaults to 9000 (the box HTTP server).
126    pub fn connect(host: impl Into<String>) -> Result<Self> {
127        Self::builder(host).build()
128    }
129
130    /// Connect to the box named by the `LAGER_BOX_HOST` environment
131    /// variable.
132    pub fn from_env() -> Result<Self> {
133        let host = std::env::var(BOX_HOST_ENV)
134            .map_err(|_| Error::Config(format!("{BOX_HOST_ENV} is not set")))?;
135        Self::connect(host)
136    }
137
138    /// Start building a client with non-default settings.
139    pub fn builder(host: impl Into<String>) -> LagerBoxBuilder {
140        LagerBoxBuilder {
141            host: host.into(),
142            debug_url: std::env::var(crate::DEBUG_SERVICE_URL_ENV).ok(),
143            default_timeout: wire::DEFAULT_TIMEOUT,
144            bearer_token: None,
145        }
146    }
147
148    /// The base URL this client talks to, e.g. `http://192.168.1.42:9000`.
149    pub fn base_url(&self) -> &str {
150        &self.base
151    }
152
153    // -- transport ---------------------------------------------------------
154
155    /// Send one request against the port-9000 server.
156    pub(crate) fn execute(&self, req: &HttpRequest) -> Result<(u16, Value)> {
157        self.execute_at(&self.base, req)
158    }
159
160    /// Send one request against the debug service (port 8765).
161    pub(crate) fn execute_debug(&self, req: &HttpRequest) -> Result<(u16, Value)> {
162        self.execute_at(&self.debug_base, req)
163    }
164
165    /// Send one request against an arbitrary base URL and return
166    /// `(status, parsed JSON body)`. Attaches gateway auth when known, and
167    /// handles a gateway denial by resolving credentials (CLI session
168    /// store, with transparent refresh) and retrying once.
169    fn execute_at(&self, base: &str, req: &HttpRequest) -> Result<(u16, Value)> {
170        let token = self.current_token();
171        let (status, gateway, resp_body) = self.send_once(base, req, token.as_deref())?;
172
173        let Some(auth_url) = gateway else {
174            return Ok((status, resp_body));
175        };
176        // Gateway denial: learn the box→auth-server mapping (like the CLI),
177        // then retry once with a credential the gateway has not just seen.
178        self.auth.learn_auth_server(&auth_url);
179        if status == 401 && !self.auth.has_static_token() {
180            if let Some(fresh) = self
181                .auth
182                .resolve_token_blocking(&auth_url, token.as_deref())
183            {
184                let (status, gateway, resp_body) =
185                    self.send_once(base, req, Some(&fresh))?;
186                let Some(auth_url) = gateway else {
187                    return Ok((status, resp_body));
188                };
189                return Err(auth::denial_error(
190                    status,
191                    self.auth.box_host(),
192                    &auth_url,
193                    true,
194                ));
195            }
196        }
197        Err(auth::denial_error(
198            status,
199            self.auth.box_host(),
200            &auth_url,
201            token.is_some(),
202        ))
203    }
204
205    /// Token to attach right now: builder/env token, cached session token,
206    /// or a store lookup when the box is already known to be gated.
207    /// `pub(crate)`: the Socket.IO session openers (uart, rtt) attach it to
208    /// their handshake too.
209    pub(crate) fn current_token(&self) -> Option<String> {
210        if let Some(token) = self.auth.cached_token() {
211            return Some(token);
212        }
213        if self.auth.wants_store_token() {
214            let auth_url = self.auth.auth_url()?;
215            return self.auth.resolve_token_blocking(&auth_url, None);
216        }
217        None
218    }
219
220    /// One HTTP round-trip. Returns `(status, gateway_denial_auth_url,
221    /// body)`; the auth URL is `Some` only for a gateway denial (401/403/
222    /// 503 carrying the discovery header).
223    fn send_once(
224        &self,
225        base: &str,
226        req: &HttpRequest,
227        token: Option<&str>,
228    ) -> Result<(u16, Option<String>, Value)> {
229        let url = format!("{}{}", base, req.path);
230        let mut r = match req.method {
231            Method::Get => self.agent.request("GET", &url),
232            Method::Post => self.agent.request("POST", &url),
233            Method::Put => self.agent.request("PUT", &url),
234        };
235        match req.timeout {
236            Timeout::Default => r = r.timeout(self.default_timeout),
237            Timeout::After(d) => r = r.timeout(d),
238            Timeout::Unbounded => {}
239        }
240        if let Some(token) = token {
241            r = r.set("Authorization", &format!("Bearer {token}"));
242        }
243        let outcome = match (&req.method, &req.body) {
244            (Method::Post | Method::Put, Some(body)) => r.send_json(body.clone()),
245            _ => r.call(),
246        };
247        let resp = match outcome {
248            Ok(resp) => resp,
249            // ureq reports 4xx/5xx as Err(Status); the box still sends a
250            // JSON error body we need to surface.
251            Err(ureq::Error::Status(_, resp)) => resp,
252            Err(ureq::Error::Transport(t)) => {
253                let msg = t.to_string();
254                return if msg.to_ascii_lowercase().contains("timed out")
255                    || msg.to_ascii_lowercase().contains("timeout")
256                {
257                    Err(Error::Timeout(msg))
258                } else {
259                    Err(Error::Connection(msg))
260                };
261            }
262        };
263        let status = resp.status();
264        let gateway = if auth::is_denial(status) {
265            resp.header(auth::DISCOVERY_HEADER).map(str::to_string)
266        } else {
267            None
268        };
269        let body: Value = match resp.into_json() {
270            Ok(body) => body,
271            Err(e) if status < 400 => {
272                return Err(Error::Decode(format!("non-JSON response: {e}")))
273            }
274            // Error responses (incl. gateway denials) may have no JSON body.
275            Err(_) => Value::Null,
276        };
277        if gateway.is_none() && status >= 400 && body.is_null() {
278            return Err(Error::Box {
279                status,
280                message: format!("HTTP {status} (non-JSON body)"),
281            });
282        }
283        Ok((status, gateway, body))
284    }
285
286    /// Execute one typed operation against a command endpoint.
287    pub(crate) fn run<T>(&self, op: Op<T>) -> Result<T> {
288        let (status, body) = self.execute(&op.req)?;
289        let resp = wire::parse_command(status, body)?;
290        (op.parse)(resp)
291    }
292
293    /// Open a streaming response body against the debug service (used by
294    /// RTT). Returns the raw byte reader.
295    pub(crate) fn stream_debug(
296        &self,
297        req: &HttpRequest,
298    ) -> Result<Box<dyn std::io::Read + Send + Sync>> {
299        let token = self.current_token();
300        match self.stream_debug_once(req, token.as_deref()) {
301            // Gateway denial (only a 401 maps to AuthRequired): resolve
302            // credentials from the CLI session store — avoiding the token
303            // the gateway just rejected — and retry once, like execute_at.
304            Err(Error::AuthRequired {
305                box_host,
306                auth_url,
307                message,
308            }) if !self.auth.has_static_token() => {
309                match self
310                    .auth
311                    .resolve_token_blocking(&auth_url, token.as_deref())
312                {
313                    Some(fresh) => self.stream_debug_once(req, Some(&fresh)),
314                    None => Err(Error::AuthRequired {
315                        box_host,
316                        auth_url,
317                        message,
318                    }),
319                }
320            }
321            other => other,
322        }
323    }
324
325    fn stream_debug_once(
326        &self,
327        req: &HttpRequest,
328        token: Option<&str>,
329    ) -> Result<Box<dyn std::io::Read + Send + Sync>> {
330        let url = format!("{}{}", self.debug_base, req.path);
331        let mut r = self.agent.request("POST", &url);
332        match req.timeout {
333            Timeout::Default => r = r.timeout(self.default_timeout),
334            Timeout::After(d) => r = r.timeout(d),
335            Timeout::Unbounded => {}
336        }
337        if let Some(token) = token {
338            r = r.set("Authorization", &format!("Bearer {token}"));
339        }
340        let outcome = match &req.body {
341            Some(body) => r.send_json(body.clone()),
342            None => r.call(),
343        };
344        match outcome {
345            Ok(resp) => Ok(resp.into_reader()),
346            Err(ureq::Error::Status(status, resp)) => {
347                if auth::is_denial(status) {
348                    if let Some(auth_url) = resp.header(auth::DISCOVERY_HEADER) {
349                        let auth_url = auth_url.to_string();
350                        self.auth.learn_auth_server(&auth_url);
351                        return Err(auth::denial_error(
352                            status,
353                            self.auth.box_host(),
354                            &auth_url,
355                            token.is_some(),
356                        ));
357                    }
358                }
359                let body: Value = resp.into_json().unwrap_or(Value::Null);
360                Err(wire::parse_debug(status, body).unwrap_err())
361            }
362            Err(ureq::Error::Transport(t)) => Err(Error::Connection(t.to_string())),
363        }
364    }
365
366    /// Resolve a debug net's full saved record (needed by the debug service,
367    /// which reads probe fields out of the record).
368    pub(crate) fn debug_net_record(&self, name: &str) -> Result<Value> {
369        let records = self.nets_raw()?;
370        crate::nets::debug::find_debug_record(records, name)
371    }
372
373    /// Raw saved-net records (untyped), with the same `/nets/list` ->
374    /// `/uart/nets/list` fallback as [`LagerBox::nets`].
375    fn nets_raw(&self) -> Result<Vec<Value>> {
376        let body = match self.get_json("/nets/list") {
377            Ok(body) => body,
378            Err(primary) => self.get_json("/uart/nets/list").map_err(|_| primary)?,
379        };
380        Ok(wire::nets_list_values(body))
381    }
382
383    fn get_json(&self, path: &str) -> Result<Value> {
384        let (status, body) = self.execute(&wire::get(path))?;
385        if status != 200 {
386            return Err(Error::Box {
387                status,
388                message: body
389                    .get("error")
390                    .and_then(Value::as_str)
391                    .unwrap_or("request failed")
392                    .to_string(),
393            });
394        }
395        Ok(body)
396    }
397
398    // -- box-level queries --------------------------------------------------
399
400    /// List every net configured on the box (full saved records).
401    ///
402    /// Falls back to the older `/uart/nets/list` shape for box images that
403    /// predate `/nets/list`, like the Lager CLI does.
404    pub fn nets(&self) -> Result<Vec<NetRecord>> {
405        match self.get_json("/nets/list") {
406            Ok(body) => wire::nets_from_body(body),
407            Err(primary) => match self.get_json("/uart/nets/list") {
408                Ok(body) => wire::nets_from_body(body),
409                Err(_) => Err(primary),
410            },
411        }
412    }
413
414    /// Check that the box HTTP server is up.
415    pub fn health(&self) -> Result<Health> {
416        let body = self.get_json("/health")?;
417        serde_json::from_value(body).map_err(Into::into)
418    }
419
420    /// Box status: version, configured nets, and endpoint capabilities.
421    pub fn status(&self) -> Result<BoxStatus> {
422        let body = self.get_json("/status")?;
423        serde_json::from_value(body).map_err(Into::into)
424    }
425
426    /// Enumerate USB devices on the box's bus from sysfs (lsusb-like).
427    ///
428    /// A few milliseconds per call with no exclusive device access, so it
429    /// is safe to poll frequently — e.g. reading the DUT's iSerial to see
430    /// what it re-enumerated as after a hub power-cycle or DFU detach.
431    ///
432    /// Requires box software >= 0.33.0; older boxes fail with
433    /// [`Error::UnsupportedByBox`].
434    pub fn usb_devices(&self) -> Result<Vec<UsbDeviceInfo>> {
435        self.usb_devices_matching(&UsbDeviceFilter::default())
436    }
437
438    /// Like [`LagerBox::usb_devices`], with box-side vid/pid/serial
439    /// filters.
440    pub fn usb_devices_matching(&self, filter: &UsbDeviceFilter) -> Result<Vec<UsbDeviceInfo>> {
441        match self.execute(&wire::usb_devices(filter)) {
442            Ok((status, body)) => wire::parse_usb_devices(status, body),
443            Err(e) => Err(wire::map_route_missing(e, wire::usb_devices_unsupported)),
444        }
445    }
446
447    // -- box lock / reservation ----------------------------------------------
448
449    fn lock_call(&self, req: &HttpRequest) -> Result<BoxLock> {
450        match self.execute(req) {
451            Ok((status, body)) => wire::parse_lock(status, body),
452            Err(e) => Err(wire::map_route_missing(e, wire::lock_unsupported)),
453        }
454    }
455
456    /// Current box lock state (`GET /lock`); `locked: false` when free.
457    pub fn lock_status(&self) -> Result<BoxLock> {
458        self.lock_call(&wire::lock_status())
459    }
460
461    /// Claim the box for `user` (an eternal `holder_type: "user"` lock,
462    /// exactly like `lager boxes lock`). Re-acquiring your own lock
463    /// refreshes it; a box held by someone else fails with
464    /// [`Error::Box`] (HTTP 409) naming the holder.
465    pub fn lock(&self, user: &str) -> Result<BoxLock> {
466        self.lock_call(&wire::lock_acquire(user, "user", None))
467    }
468
469    /// Claim the box with an explicit holder type and TTL.
470    /// `ttl_seconds: None` means the lock never auto-expires; with a TTL,
471    /// keep the lock alive via [`LagerBox::lock_heartbeat`].
472    pub fn lock_with(
473        &self,
474        user: &str,
475        holder_type: &str,
476        ttl_seconds: Option<u64>,
477    ) -> Result<BoxLock> {
478        self.lock_call(&wire::lock_acquire(user, holder_type, ttl_seconds))
479    }
480
481    /// Refresh a TTL lock's heartbeat. Fails with [`Error::Box`] when the
482    /// box is not locked (HTTP 404) or held by someone else (HTTP 403).
483    pub fn lock_heartbeat(&self, user: &str) -> Result<BoxLock> {
484        self.lock_call(&wire::lock_heartbeat(user))
485    }
486
487    /// Release `user`'s box lock. Releasing an already-unlocked box
488    /// succeeds; a box held by someone else fails with [`Error::Box`]
489    /// (HTTP 403).
490    pub fn unlock(&self, user: &str) -> Result<()> {
491        self.lock_call(&wire::unlock(user, false)).map(|_| ())
492    }
493
494    /// Release the box lock even when held by another user
495    /// (`lager boxes unlock --force`).
496    pub fn unlock_force(&self, user: &str) -> Result<()> {
497        self.lock_call(&wire::unlock(user, true)).map(|_| ())
498    }
499
500    /// Claim the box for `user` and release the claim when the returned
501    /// guard drops (best-effort; call [`BoxLockGuard::unlock`] to surface
502    /// release errors).
503    pub fn lock_guard(&self, user: impl Into<String>) -> Result<BoxLockGuard<'_>> {
504        let user = user.into();
505        self.lock(&user)?;
506        Ok(BoxLockGuard { client: self, user, released: false })
507    }
508
509    // -- per-net safety limits ------------------------------------------------
510
511    /// Set the safety limits on a saved net (`PUT /nets/<name>/safety-limits`,
512    /// box >= 0.35.0). Returns the limits the box applied.
513    ///
514    /// The PUT **replaces** the net's whole limits record: fields left `None`
515    /// in `limits` are removed from the net, not preserved. Read the current
516    /// limits first ([`LagerBox::safety_limits`]) if you mean to change one
517    /// ceiling and keep the rest. An all-`None` `limits` clears the record,
518    /// same as [`LagerBox::clear_safety_limits`].
519    ///
520    /// The ceilings are enforced by the box's hardware service, out of reach
521    /// of test scripts; a setpoint (or inline `ovp=`/`ocp=` trip) above a
522    /// ceiling is refused before it touches the instrument. Older boxes fail
523    /// with [`Error::UnsupportedByBox`]; validation refusals (`max_power`,
524    /// non-positive ceilings) and an unknown net come back as [`Error::Box`].
525    pub fn set_safety_limits(
526        &self,
527        name: &str,
528        limits: &SafetyLimits,
529    ) -> Result<Option<SafetyLimits>> {
530        match self.execute(&wire::safety_limits_set(name, limits)) {
531            Ok((status, body)) => wire::parse_safety_limits(status, body),
532            Err(e) => Err(wire::map_route_missing(e, wire::safety_limits_unsupported)),
533        }
534    }
535
536    /// Remove a net's safety limits, returning it to unrestricted.
537    pub fn clear_safety_limits(&self, name: &str) -> Result<()> {
538        self.set_safety_limits(name, &SafetyLimits::default())
539            .map(|_| ())
540    }
541
542    /// Read the safety limits configured on a saved net, via `/nets/list`.
543    /// `Ok(None)` means the net exists and is unrestricted; a missing net is
544    /// an [`Error::Box`] with status 404.
545    pub fn safety_limits(&self, name: &str) -> Result<Option<SafetyLimits>> {
546        let nets = self.nets()?;
547        nets.iter()
548            .find(|rec| rec.name == name)
549            .map(|rec| rec.safety_limits)
550            .ok_or_else(|| Error::Box {
551                status: 404,
552                message: format!("no saved net named '{name}' on this box"),
553            })
554    }
555
556    // -- net handle constructors ---------------------------------------------
557
558    /// Handle for a power-supply net.
559    pub fn supply(&self, name: impl Into<String>) -> Supply<'_> {
560        Supply { client: self, name: name.into() }
561    }
562
563    /// Handle for a battery-simulator net.
564    pub fn battery(&self, name: impl Into<String>) -> Battery<'_> {
565        Battery { client: self, name: name.into() }
566    }
567
568    /// Handle for an electronic-load net.
569    pub fn eload(&self, name: impl Into<String>) -> Eload<'_> {
570        Eload { client: self, name: name.into() }
571    }
572
573    /// Handle for a solar-simulator net (EA PSB photovoltaic mode).
574    pub fn solar(&self, name: impl Into<String>) -> Solar<'_> {
575        Solar { client: self, name: name.into() }
576    }
577
578    /// Handle for a GPIO net.
579    pub fn gpio(&self, name: impl Into<String>) -> Gpio<'_> {
580        Gpio { client: self, name: name.into() }
581    }
582
583    /// Handle for an ADC net.
584    pub fn adc(&self, name: impl Into<String>) -> Adc<'_> {
585        Adc { client: self, name: name.into() }
586    }
587
588    /// Handle for a DAC net.
589    pub fn dac(&self, name: impl Into<String>) -> Dac<'_> {
590        Dac { client: self, name: name.into() }
591    }
592
593    /// Handle for a thermocouple net.
594    pub fn thermocouple(&self, name: impl Into<String>) -> Thermocouple<'_> {
595        Thermocouple { client: self, name: name.into() }
596    }
597
598    /// Handle for a watt-meter net.
599    pub fn watt_meter(&self, name: impl Into<String>) -> WattMeter<'_> {
600        WattMeter { client: self, name: name.into() }
601    }
602
603    /// Handle for an energy-analyzer net.
604    pub fn energy_analyzer(&self, name: impl Into<String>) -> EnergyAnalyzer<'_> {
605        EnergyAnalyzer { client: self, name: name.into() }
606    }
607
608    /// Handle for an SPI bus net.
609    pub fn spi(&self, name: impl Into<String>) -> Spi<'_> {
610        Spi { client: self, name: name.into() }
611    }
612
613    /// Handle for an I2C bus net.
614    pub fn i2c(&self, name: impl Into<String>) -> I2c<'_> {
615        I2c { client: self, name: name.into() }
616    }
617
618    /// Handle for a USB hub port net.
619    pub fn usb(&self, name: impl Into<String>) -> UsbPort<'_> {
620        UsbPort { client: self, name: name.into() }
621    }
622
623    /// Handle for a robot-arm net (Rotrics Dexarm).
624    pub fn arm(&self, name: impl Into<String>) -> Arm<'_> {
625        Arm { client: self, name: name.into() }
626    }
627
628    /// Handle for a webcam net (MJPEG streaming).
629    pub fn webcam(&self, name: impl Into<String>) -> Webcam<'_> {
630        Webcam { client: self, name: name.into() }
631    }
632
633    /// Handle for a router net (MikroTik RouterOS).
634    pub fn router(&self, name: impl Into<String>) -> Router<'_> {
635        Router { client: self, name: name.into() }
636    }
637
638    /// Handle for the box's BLE adapter (box-level, not a saved net).
639    pub fn ble(&self) -> Ble<'_> {
640        Ble { client: self }
641    }
642
643    /// Handle for the box's WiFi interface (box-level, not a saved net).
644    pub fn wifi(&self) -> Wifi<'_> {
645        Wifi { client: self }
646    }
647
648    /// Handle for BluFi (ESP32 WiFi provisioning over BLE; box-level).
649    pub fn blufi(&self) -> Blufi<'_> {
650        Blufi { client: self }
651    }
652
653    /// Handle for box-side DFU via dfu-util (box-level, not a saved net).
654    pub fn dfu(&self) -> Dfu<'_> {
655        Dfu { client: self }
656    }
657
658    /// Handle for a debug-probe net (flash/erase/reset/read_memory/RTT).
659    /// Talks to the box debug service on port 8765.
660    pub fn debug(&self, name: impl Into<String>) -> DebugNet<'_> {
661        DebugNet { client: self, name: name.into(), record: Default::default() }
662    }
663
664    /// Handle for an oscilloscope net. **Stub:** see [`Scope`].
665    pub fn scope(&self, name: impl Into<String>) -> Scope {
666        Scope::new(name)
667    }
668
669    /// Open a streaming UART session on a UART net.
670    ///
671    /// Connects a Socket.IO session to the box's `/uart` namespace and
672    /// starts streaming. Only one session per net (box-enforced).
673    #[cfg(feature = "uart")]
674    pub fn uart(&self, name: impl Into<String>) -> Result<crate::nets::uart::Uart> {
675        crate::nets::uart::Uart::open(&self.base, name.into(), self.current_token())
676    }
677}
678
679/// RAII box-lock claim from [`LagerBox::lock_guard`]: releases the lock on
680/// drop (best-effort — a drop cannot surface errors, so tests that must
681/// know the release worked should call [`BoxLockGuard::unlock`]).
682pub struct BoxLockGuard<'a> {
683    client: &'a LagerBox,
684    user: String,
685    released: bool,
686}
687
688impl BoxLockGuard<'_> {
689    /// The user holding this claim.
690    pub fn user(&self) -> &str {
691        &self.user
692    }
693
694    /// Release the lock now, surfacing any error.
695    pub fn unlock(mut self) -> Result<()> {
696        self.released = true;
697        self.client.unlock(&self.user)
698    }
699}
700
701impl Drop for BoxLockGuard<'_> {
702    fn drop(&mut self) {
703        if !self.released {
704            let _ = self.client.unlock(&self.user);
705        }
706    }
707}