Skip to main content

livekit_telemetry/
device.rs

1// Copyright 2026 LiveKit, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::TelemetryEvent;
16
17/// Thermal pressure as the OS reports it (`ProcessInfo.thermalState`, `PowerManager` thermal
18/// status, the web Compute Pressure API — all four share this vocabulary).
19#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum ThermalState {
22    /// The platform has no thermal source: no pressure assumed, no change event.
23    #[default]
24    Unknown,
25    /// No thermal pressure; sent as `nominal`.
26    Nominal,
27    /// Slightly raised thermal pressure; sent as `fair`.
28    Fair,
29    /// High thermal pressure; sent as `serious`.
30    Serious,
31    /// Pressure high enough to cut performance until the device cools; sent as `critical`.
32    Critical,
33}
34
35/// Whether the app is in front of the user.
36#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum AppState {
39    /// The app is in front of the user; sent as `foreground`.
40    #[default]
41    Foreground,
42    /// The app runs in the background; sent as `background`.
43    Background,
44}
45
46/// Memory pressure as the OS reports it (`DISPATCH_MEMORYPRESSURE_*`, `onTrimMemory` levels:
47/// `RUNNING_LOW`/`BACKGROUND` → warning, `RUNNING_CRITICAL`/`COMPLETE` → critical).
48#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum MemoryPressure {
51    /// No memory pressure; sent as `normal`.
52    #[default]
53    Normal,
54    /// The OS warns that memory runs low; sent as `warning`.
55    Warning,
56    /// Memory is critically low; sent as `critical`.
57    Critical,
58}
59
60/// The active network path, in OTel `network.connection.type` terms.
61#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum NetworkType {
64    /// The platform cannot tell the network type; sent as `unknown`.
65    #[default]
66    Unknown,
67    /// Wi-Fi; sent as `wifi`.
68    Wifi,
69    /// A cellular network; sent as `cell`.
70    Cell,
71    /// Ethernet or another wired link; sent as `wired`.
72    Wired,
73    /// A VPN tunnel is the active interface (Android `TRANSPORT_VPN`, WebRTC candidate `vpn`).
74    Vpn,
75    /// Bluetooth tethering (Android `TRANSPORT_BLUETOOTH`, WebRTC candidate `bluetooth`).
76    Bluetooth,
77    /// Connected over something the platform does not name (`NWInterface.InterfaceType.other`,
78    /// Android `TRANSPORT_USB` / `LOWPAN`, …).
79    Other,
80    /// No network path: the device is offline; sent as `unavailable`.
81    Unavailable,
82}
83
84/// What the host observes about the device; pushed with `Telemetry::set_device_state` whenever
85/// anything changes.
86///
87/// The host owns the OS APIs (thermal, power, memory, battery, network path, lifecycle) — they
88/// are not reachable from Rust without a JVM/ObjC bridge — and the core owns what to do with
89/// them: emit the change events from `SPEC.md`, stretch its own cadence and hold uploads so
90/// telemetry never competes with the call. CPU is deliberately not measured here (measuring CPU
91/// costs CPU): thermal state is the OS's judgement, and WebRTC's own `qualityLimitationReason`
92/// (see [`RtcStatsSample`](crate::RtcStatsSample)) says when the encoder is CPU-starved.
93#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub struct DeviceState {
96    /// The OS's thermal state; `Unknown` when the platform has no source.
97    pub thermal: ThermalState,
98    /// Low Power Mode / Battery Saver; `None` when the platform has no source (no change event,
99    /// no stretch).
100    #[cfg_attr(feature = "uniffi", uniffi(default))]
101    pub low_power_mode: Option<bool>,
102    /// Whether the app is in front of the user.
103    pub app_state: AppState,
104    // No `uniffi(default)` on enum fields: uniffi 0.31's Swift generator rejects `Default` there.
105    /// The OS's memory pressure.
106    pub memory: MemoryPressure,
107    /// The active network path.
108    pub network: NetworkType,
109    /// Cellular / personal hotspot (`NWPath.isExpensive`, metered on Android).
110    #[cfg_attr(feature = "uniffi", uniffi(default = false))]
111    pub network_expensive: bool,
112    /// The user asked for less traffic: Low Data Mode (`NWPath.isConstrained`), Data Saver,
113    /// `navigator.connection.saveData`.
114    #[cfg_attr(feature = "uniffi", uniffi(default = false))]
115    pub network_constrained: bool,
116    /// Battery charge in percent; `None` when unknown (desktops, tvOS, monitoring disabled).
117    #[cfg_attr(feature = "uniffi", uniffi(default))]
118    pub battery_level: Option<u32>,
119    /// Plugged in and charging: a charging battery is never low.
120    #[cfg_attr(feature = "uniffi", uniffi(default = false))]
121    pub battery_charging: bool,
122}
123
124/// The largest cadence factor: the flush interval and stats window stretch at most this much.
125pub(crate) const MAX_CADENCE_FACTOR: u32 = 4;
126
127/// How much the battery level matters; per-percent updates only count when they cross a bucket.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129enum BatteryBucket {
130    /// Charging, unknown, or above 20 %.
131    Fine,
132    /// At most 20 % and unplugged: the cadence doubles.
133    Low,
134    /// At most 10 % and unplugged: uploads hold.
135    NearlyEmpty,
136}
137
138impl DeviceState {
139    /// Below this, unplugged, the pipeline still records but stops uploading (a soft hold).
140    const BATTERY_HOLD_PERCENT: u32 = 10;
141    /// Below this, unplugged, the cadence doubles.
142    const BATTERY_LOW_PERCENT: u32 = 20;
143
144    /// Multiplier on the flush interval and stats window: 1× at rest, up to
145    /// [`MAX_CADENCE_FACTOR`] under pressure (60 s → 4 min at the default cadence). Serious
146    /// thermal, memory warning, low-power mode, background, low battery and a constrained network
147    /// each double it; critical thermal or memory quadruple it.
148    pub(crate) fn cadence_factor(&self) -> u32 {
149        let thermal = match self.thermal {
150            ThermalState::Unknown | ThermalState::Nominal | ThermalState::Fair => 1,
151            ThermalState::Serious => 2,
152            ThermalState::Critical => 4,
153        };
154        let memory = match self.memory {
155            MemoryPressure::Normal => 1,
156            MemoryPressure::Warning => 2,
157            MemoryPressure::Critical => 4,
158        };
159        let doubled = [
160            self.low_power_mode == Some(true),
161            self.app_state == AppState::Background,
162            self.battery_bucket() != BatteryBucket::Fine,
163            self.network_constrained,
164        ]
165        .iter()
166        .filter(|&&on| on)
167        .count() as u32;
168        ((thermal * memory) << doubled).min(MAX_CADENCE_FACTOR)
169    }
170
171    /// A soft hold: the user asked for less traffic (Low Data Mode / Data Saver) or the battery
172    /// is nearly empty and unplugged. Uploads wait (at most a minute at a time); data keeps
173    /// flowing into the cache meanwhile. Being offline (`network: Unavailable`) is a hard hold:
174    /// nothing is attempted until the network is back.
175    pub(crate) fn holds_uploads(&self) -> bool {
176        self.network_constrained || self.battery_bucket() == BatteryBucket::NearlyEmpty
177    }
178
179    /// Where the battery level stands; always [`BatteryBucket::Fine`] on charge.
180    fn battery_bucket(&self) -> BatteryBucket {
181        match self.battery_level {
182            Some(level) if !self.battery_charging && level <= Self::BATTERY_HOLD_PERCENT => {
183                BatteryBucket::NearlyEmpty
184            }
185            Some(level) if !self.battery_charging && level <= Self::BATTERY_LOW_PERCENT => {
186                BatteryBucket::Low
187            }
188            _ => BatteryBucket::Fine,
189        }
190    }
191
192    /// The `SPEC.md` events describing what changed between `previous` and `self` (every known
193    /// field, when there is no previous state: "initial value at session start"). Each event
194    /// carries a display body that repeats its values: the body takes the place of the name a
195    /// body-less event would show (see `SPEC.md`, Events), so it costs about what that name copy
196    /// would.
197    pub(crate) fn change_events(&self, previous: Option<&DeviceState>) -> Vec<TelemetryEvent> {
198        let mut events = Vec::new();
199        // An unknown reading is no reading: it never makes a change event.
200        if self.thermal != ThermalState::Unknown
201            && previous.is_none_or(|p| p.thermal != self.thermal)
202        {
203            events.push(
204                TelemetryEvent::new("lk.device.thermal.changed")
205                    .with_body(format!("thermal: {}", self.thermal.as_str()))
206                    .with_attribute("lk.device.thermal.state", self.thermal.as_str()),
207            );
208        }
209        if let Some(low_power) = self
210            .low_power_mode
211            .filter(|_| previous.is_none_or(|p| p.low_power_mode != self.low_power_mode))
212        {
213            events.push(
214                TelemetryEvent::new("lk.device.low_power.changed")
215                    .with_body(format!("low power mode: {}", if low_power { "on" } else { "off" }))
216                    .with_attribute("lk.device.low_power.enabled", low_power),
217            );
218        }
219        if previous.is_none_or(|p| p.app_state != self.app_state) {
220            events.push(
221                TelemetryEvent::new("lk.device.app_state.changed")
222                    .with_body(format!("app: {}", self.app_state.as_str()))
223                    .with_attribute("lk.device.app_state", self.app_state.as_str()),
224            );
225        }
226        if previous.is_none_or(|p| p.memory != self.memory) {
227            events.push(
228                TelemetryEvent::new("lk.device.memory.changed")
229                    .with_body(format!("memory pressure: {}", self.memory.as_str()))
230                    .with_attribute("lk.device.memory.pressure", self.memory.as_str()),
231            );
232        }
233        if previous.is_none_or(|p| {
234            (p.network, p.network_expensive, p.network_constrained)
235                != (self.network, self.network_expensive, self.network_constrained)
236        }) {
237            events.push(
238                TelemetryEvent::new("lk.device.network.changed")
239                    .with_body(format!(
240                        "network: {}{}{}",
241                        self.network.as_str(),
242                        if self.network_expensive { ", expensive" } else { "" },
243                        if self.network_constrained { ", constrained" } else { "" }
244                    ))
245                    .with_attribute("network.connection.type", self.network.as_str())
246                    .with_attribute("lk.device.network.expensive", self.network_expensive)
247                    .with_attribute("lk.device.network.constrained", self.network_constrained),
248            );
249        }
250        // Per-percent updates are noise; report charging flips and bucket crossings.
251        if let Some(level) = self.battery_level.filter(|_| {
252            previous.is_none_or(|p| {
253                p.battery_charging != self.battery_charging
254                    || p.battery_bucket() != self.battery_bucket()
255                    || p.battery_level.is_none()
256            })
257        }) {
258            events.push(
259                TelemetryEvent::new("lk.device.battery.changed")
260                    .with_body(format!(
261                        "battery: {level}%{}",
262                        if self.battery_charging { ", charging" } else { "" }
263                    ))
264                    .with_attribute("lk.device.battery.level", i64::from(level))
265                    .with_attribute("lk.device.battery.charging", self.battery_charging),
266            );
267        }
268        events
269    }
270}
271
272impl ThermalState {
273    fn as_str(self) -> &'static str {
274        match self {
275            ThermalState::Unknown => "unknown",
276            ThermalState::Nominal => "nominal",
277            ThermalState::Fair => "fair",
278            ThermalState::Serious => "serious",
279            ThermalState::Critical => "critical",
280        }
281    }
282}
283
284impl AppState {
285    fn as_str(self) -> &'static str {
286        match self {
287            AppState::Foreground => "foreground",
288            AppState::Background => "background",
289        }
290    }
291}
292
293impl MemoryPressure {
294    fn as_str(self) -> &'static str {
295        match self {
296            MemoryPressure::Normal => "normal",
297            MemoryPressure::Warning => "warning",
298            MemoryPressure::Critical => "critical",
299        }
300    }
301}
302
303impl NetworkType {
304    fn as_str(self) -> &'static str {
305        match self {
306            NetworkType::Unknown => "unknown",
307            NetworkType::Wifi => "wifi",
308            NetworkType::Cell => "cell",
309            NetworkType::Wired => "wired",
310            NetworkType::Vpn => "vpn",
311            NetworkType::Bluetooth => "bluetooth",
312            NetworkType::Other => "other",
313            NetworkType::Unavailable => "unavailable",
314        }
315    }
316}
317
318/// Why the audio route changed, in platform-neutral terms (AVAudioSession reasons; Android's
319/// device added/removed callbacks map to `NewDevice` / `OldDeviceUnavailable`).
320#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum AudioRouteReason {
323    /// An audio device became available, such as plugged-in headphones; sent as `new_device`.
324    NewDevice,
325    /// The audio device in use went away; sent as `old_device_unavailable`.
326    OldDeviceUnavailable,
327    /// The audio session category changed; sent as `category_change`.
328    CategoryChange,
329    /// The app overrode the route, such as forcing the speaker; sent as `override`.
330    Override,
331    /// The device woke from sleep; sent as `wake_from_sleep`.
332    WakeFromSleep,
333    /// No route suits the audio session category; sent as `no_suitable_route`.
334    NoSuitableRoute,
335    /// The configuration of the route's ports changed; sent as `route_configuration_change`.
336    RouteConfigurationChange,
337    /// The platform gives no reason; sent as `unknown`.
338    Unknown,
339}
340
341/// Where the audio goes, in platform-neutral terms (AVAudioSession port types, Android's
342/// `AudioDevice`; the web's `MediaDeviceInfo.kind` only ever gives `other`).
343#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum AudioOutput {
346    /// The built-in loudspeaker; sent as `speaker`.
347    Speaker,
348    /// The phone earpiece (`builtInReceiver`, Android `Earpiece`).
349    Receiver,
350    /// Wired headphones or a wired headset; sent as `wired_headset`.
351    WiredHeadset,
352    /// A Bluetooth headset, headphones or speaker; sent as `bluetooth`.
353    Bluetooth,
354    /// A car's audio system; sent as `car_audio`.
355    CarAudio,
356    /// An AirPlay receiver; sent as `air_play`.
357    AirPlay,
358    /// An HDMI display or receiver; sent as `hdmi`.
359    Hdmi,
360    /// A USB audio device; sent as `usb`.
361    Usb,
362    /// An output the platform does not name; sent as `other`.
363    Other,
364}
365
366/// Which capture device a failure is about.
367#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369pub enum CaptureDevice {
370    /// A camera; sent as `camera`.
371    Camera,
372    /// A microphone; sent as `microphone`.
373    Microphone,
374    /// Screen capture; sent as `screen_share`.
375    ScreenShare,
376}
377
378/// Why a capture device could not be used: the `getUserMedia` failure taxonomy, which every
379/// platform maps onto (authorization status, `onCameraDisconnected`, `NotReadableError`, …).
380#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382pub enum CaptureFailure {
383    /// The user (or MDM) said no: the most common pre-connect failure on mobile.
384    PermissionDenied,
385    /// No such device exists (`NotFoundError`); sent as `not_found`.
386    NotFound,
387    /// Another app holds the device (`NotReadableError`, `TrackStartError`).
388    InUse,
389    /// The device went away mid-capture (unplugged, `onCameraDisconnected`).
390    Disconnected,
391    /// Any other failure; sent as `other`.
392    Other,
393}
394
395/// Things that happen to the device mid-call and explain what the media did next. Not state
396/// (see [`DeviceState`]): each one is a record with a display body.
397#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub enum DeviceEvent {
400    /// The audio output route changed.
401    AudioRouteChanged {
402        /// The outputs now in use, in platform order; sent in `lk.device.audio_route.outputs`.
403        outputs: Vec<AudioOutput>,
404        /// Why the route changed; sent in `lk.device.audio_route.reason`.
405        reason: AudioRouteReason,
406    },
407    /// A phone call, Siri, another app taking audio focus: `began`, then the matching `ended`.
408    AudioInterruption {
409        /// Began (`true`) or ended (`false`); sent in `lk.device.audio.interruption`.
410        began: bool,
411    },
412    /// A capture device could not be, or stay, used.
413    CaptureFailed {
414        /// The device that failed; sent in `lk.device.capture.device`.
415        device: CaptureDevice,
416        /// Why it failed; sent in `lk.device.capture.reason`.
417        reason: CaptureFailure,
418    },
419}
420
421/// A variant's wire value: its `Debug` name in snake case (`OldDeviceUnavailable` →
422/// `old_device_unavailable`). Variant names are therefore the wire contract: renaming a variant
423/// changes what ships, and the tests pin every value to `SPEC.md`.
424pub(crate) fn snake_case(debug: impl std::fmt::Debug) -> String {
425    let mut out = String::new();
426    for (i, c) in format!("{debug:?}").chars().enumerate() {
427        if c.is_uppercase() && i > 0 {
428            out.push('_');
429        }
430        out.push(c.to_ascii_lowercase());
431    }
432    out
433}
434
435impl From<DeviceEvent> for TelemetryEvent {
436    /// The `SPEC.md` event for a device event, with its display body.
437    fn from(event: DeviceEvent) -> Self {
438        match event {
439            DeviceEvent::AudioRouteChanged { outputs, reason } => {
440                let outputs = outputs.into_iter().map(snake_case).collect::<Vec<_>>().join(",");
441                let reason = snake_case(reason);
442                TelemetryEvent::new("lk.device.audio_route.changed")
443                    .with_body(format!("audio route: {outputs} ({reason})"))
444                    .with_attribute("lk.device.audio_route.outputs", outputs)
445                    .with_attribute("lk.device.audio_route.reason", reason)
446            }
447            DeviceEvent::AudioInterruption { began } => {
448                let phase = if began { "began" } else { "ended" };
449                TelemetryEvent::new("lk.device.audio.interruption")
450                    .with_body(format!("audio interruption {phase}"))
451                    .with_attribute("lk.device.audio.interruption", phase)
452            }
453            DeviceEvent::CaptureFailed { device, reason } => {
454                let (device, reason) = (snake_case(device), snake_case(reason));
455                TelemetryEvent::new("lk.device.capture.failed")
456                    .with_severity(crate::Severity::Warn)
457                    .with_body(format!("capture failed: {device} ({reason})"))
458                    .with_attribute("lk.device.capture.device", device)
459                    .with_attribute("lk.device.capture.reason", reason)
460            }
461        }
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn cadence_stretches_under_pressure_and_caps_at_four() {
471        assert_eq!(DeviceState::default().cadence_factor(), 1);
472        let serious = DeviceState { thermal: ThermalState::Serious, ..Default::default() };
473        assert_eq!(serious.cadence_factor(), 2);
474        let low_power = DeviceState { low_power_mode: Some(true), ..Default::default() };
475        assert_eq!(low_power.cadence_factor(), 2);
476        let tight = DeviceState { memory: MemoryPressure::Warning, ..Default::default() };
477        assert_eq!(tight.cadence_factor(), 2);
478        let low_battery = DeviceState { battery_level: Some(15), ..Default::default() };
479        assert_eq!(low_battery.cadence_factor(), 2);
480        let charging = DeviceState { battery_charging: true, ..low_battery };
481        assert_eq!(charging.cadence_factor(), 1, "a charging battery is not low");
482        let everything = DeviceState {
483            thermal: ThermalState::Critical,
484            low_power_mode: Some(true),
485            app_state: AppState::Background,
486            memory: MemoryPressure::Critical,
487            network_constrained: true,
488            ..Default::default()
489        };
490        assert_eq!(everything.cadence_factor(), 4);
491    }
492
493    #[test]
494    fn uploads_hold_on_constrained_network_or_empty_battery() {
495        assert!(!DeviceState::default().holds_uploads());
496        let saver = DeviceState { network_constrained: true, ..Default::default() };
497        assert!(saver.holds_uploads());
498        let empty = DeviceState { battery_level: Some(8), ..Default::default() };
499        assert!(empty.holds_uploads());
500        let plugged = DeviceState { battery_charging: true, ..empty };
501        assert!(!plugged.holds_uploads());
502        let low = DeviceState { battery_level: Some(15), ..Default::default() };
503        assert!(!low.holds_uploads(), "low only stretches the cadence");
504    }
505
506    #[test]
507    fn only_changed_fields_produce_events() {
508        let initial = DeviceState {
509            thermal: ThermalState::Nominal,
510            low_power_mode: Some(false),
511            ..Default::default()
512        };
513        let names: Vec<_> = initial.change_events(None).into_iter().map(|e| e.name).collect();
514        assert_eq!(
515            names,
516            [
517                "lk.device.thermal.changed",
518                "lk.device.low_power.changed",
519                "lk.device.app_state.changed",
520                "lk.device.memory.changed",
521                "lk.device.network.changed",
522            ],
523            "initial values at session start; battery is unknown so it stays silent"
524        );
525        let hot = DeviceState { thermal: ThermalState::Critical, ..initial };
526        let events = hot.change_events(Some(&initial));
527        assert_eq!(events.len(), 1);
528        assert_eq!(events[0].name, "lk.device.thermal.changed");
529        assert!(hot.change_events(Some(&hot)).is_empty());
530    }
531
532    /// A platform without a thermal or low-power source reports them unknown — never a made-up
533    /// nominal/off: no change event, no stretch, no hold.
534    #[test]
535    fn unknown_thermal_and_low_power_are_silent_and_neutral() {
536        let unknown = DeviceState::default();
537        assert_eq!((unknown.thermal, unknown.low_power_mode), (ThermalState::Unknown, None));
538        let names: Vec<_> = unknown.change_events(None).into_iter().map(|e| e.name).collect();
539        assert_eq!(
540            names,
541            [
542                "lk.device.app_state.changed",
543                "lk.device.memory.changed",
544                "lk.device.network.changed"
545            ]
546        );
547        assert_eq!(unknown.cadence_factor(), 1);
548        assert!(!unknown.holds_uploads());
549        let known = DeviceState {
550            thermal: ThermalState::Serious,
551            low_power_mode: Some(true),
552            ..Default::default()
553        };
554        assert!(unknown.change_events(Some(&known)).is_empty(), "losing the source is silent");
555        assert_eq!(known.change_events(Some(&unknown)).len(), 2, "a first real reading reports");
556    }
557
558    #[test]
559    fn battery_reports_bucket_crossings_not_every_percent() {
560        let at = |level| DeviceState { battery_level: Some(level), ..Default::default() };
561        assert_eq!(at(80).change_events(Some(&DeviceState::default())).len(), 1, "first reading");
562        assert!(at(79).change_events(Some(&at(80))).is_empty());
563        assert_eq!(at(20).change_events(Some(&at(21))).len(), 1, "crossed into low");
564        assert_eq!(at(10).change_events(Some(&at(11))).len(), 1, "crossed into hold");
565        let charging = DeviceState { battery_charging: true, ..at(10) };
566        assert_eq!(charging.change_events(Some(&at(10))).len(), 1, "plugged in");
567    }
568
569    #[test]
570    fn battery_events_carry_the_level_and_the_charging_flag() {
571        let low = DeviceState { battery_level: Some(15), ..Default::default() };
572        let events = low.change_events(Some(&DeviceState::default()));
573        let [event] = &events[..] else { panic!("one battery event: {events:?}") };
574        let value = |key: &str| event.attributes.iter().find(|a| a.key == key).map(|a| &a.value);
575        assert_eq!(value("lk.device.battery.level"), Some(&crate::AttributeValue::Int(15)));
576        assert_eq!(value("lk.device.battery.charging"), Some(&crate::AttributeValue::Bool(false)));
577    }
578
579    #[test]
580    fn the_core_owns_the_network_connection_type() {
581        assert!(!crate::event::valid_custom("network.connection.type", None));
582        assert!(crate::event::valid_custom("network.peer.address", None), "other `network.*`");
583    }
584
585    /// Every enum value that ships, pinned to the lists in `SPEC.md`.
586    #[test]
587    fn wire_values_match_the_spec() {
588        let names = |values: Vec<String>| values.join(" | ");
589        use AudioRouteReason as R;
590        let reasons = [
591            R::NewDevice,
592            R::OldDeviceUnavailable,
593            R::CategoryChange,
594            R::Override,
595            R::WakeFromSleep,
596            R::NoSuitableRoute,
597            R::RouteConfigurationChange,
598            R::Unknown,
599        ];
600        assert_eq!(
601            names(reasons.into_iter().map(snake_case).collect()),
602            "new_device | old_device_unavailable | category_change | override | wake_from_sleep \
603             | no_suitable_route | route_configuration_change | unknown"
604        );
605        use AudioOutput as O;
606        let outputs = [
607            O::Speaker,
608            O::Receiver,
609            O::WiredHeadset,
610            O::Bluetooth,
611            O::CarAudio,
612            O::AirPlay,
613            O::Hdmi,
614            O::Usb,
615            O::Other,
616        ];
617        assert_eq!(
618            names(outputs.into_iter().map(snake_case).collect()),
619            "speaker | receiver | wired_headset | bluetooth | car_audio | air_play | hdmi | usb \
620             | other"
621        );
622        let devices =
623            [CaptureDevice::Camera, CaptureDevice::Microphone, CaptureDevice::ScreenShare];
624        assert_eq!(
625            names(devices.into_iter().map(snake_case).collect()),
626            "camera | microphone | screen_share"
627        );
628        use CaptureFailure as F;
629        let failures = [F::PermissionDenied, F::NotFound, F::InUse, F::Disconnected, F::Other];
630        assert_eq!(
631            names(failures.into_iter().map(snake_case).collect()),
632            "permission_denied | not_found | in_use | disconnected | other"
633        );
634    }
635
636    #[test]
637    fn device_events_have_bodies_and_snake_case_values() {
638        let event: TelemetryEvent = DeviceEvent::AudioRouteChanged {
639            outputs: vec![AudioOutput::Speaker],
640            reason: AudioRouteReason::OldDeviceUnavailable,
641        }
642        .into();
643        assert_eq!(event.body.as_deref(), Some("audio route: speaker (old_device_unavailable)"));
644        let denied: TelemetryEvent = DeviceEvent::CaptureFailed {
645            device: CaptureDevice::ScreenShare,
646            reason: CaptureFailure::PermissionDenied,
647        }
648        .into();
649        assert_eq!(
650            denied.body.as_deref(),
651            Some("capture failed: screen_share (permission_denied)")
652        );
653        assert_eq!(denied.severity, crate::Severity::Warn);
654    }
655}