Skip to main content

cadmus_core/device/
mod.rs

1//! Device detection and management.
2
3use crate::device::error::DeviceError;
4use crate::device::metadata::DeviceMetadata;
5use crate::input::TouchProto;
6use lazy_static::lazy_static;
7use once_cell::sync::OnceCell;
8use std::env;
9use std::fmt::Debug;
10use std::path::{Path, PathBuf};
11
12mod error;
13mod metadata;
14pub mod migration;
15mod model;
16mod power;
17mod types;
18mod usb;
19mod wifi;
20
21pub use model::Model;
22pub use types::{FrontlightKind, Orientation};
23
24pub struct Device {
25    pub model: Model,
26    pub proto: TouchProto,
27    pub dims: (u32, u32),
28    pub dpi: u16,
29    metadata: OnceCell<DeviceMetadata>,
30    wifi_manager: OnceCell<Box<dyn crate::device::wifi::WifiManager>>,
31    power_manager: OnceCell<Box<dyn crate::device::power::PowerManager>>,
32}
33
34impl Debug for Device {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("Device")
37            .field("model", &self.model)
38            .field("proto", &self.proto)
39            .field("dims", &self.dims)
40            .field("dpi", &self.dpi)
41            .finish()
42    }
43}
44impl Device {
45    /// Creates a new device from product and model number strings.
46    fn new(product: &str, model_number: &str) -> Device {
47        let (model, proto, dims, dpi) = match product {
48            "kraken" => (Model::Glo, TouchProto::Single, (758, 1024), 212),
49            "pixie" => (Model::Mini, TouchProto::Single, (600, 800), 200),
50            "dragon" => (Model::AuraHD, TouchProto::Single, (1080, 1440), 265),
51            "phoenix" => (Model::Aura, TouchProto::MultiA, (758, 1024), 212),
52            "dahlia" => (Model::AuraH2O, TouchProto::MultiA, (1080, 1440), 265),
53            "alyssum" => (Model::GloHD, TouchProto::MultiA, (1072, 1448), 300),
54            "pika" => (Model::Touch2, TouchProto::MultiA, (600, 800), 167),
55            "daylight" => {
56                let model = if model_number == "381" {
57                    Model::AuraONELimEd
58                } else {
59                    Model::AuraONE
60                };
61                (model, TouchProto::MultiA, (1404, 1872), 300)
62            }
63            "star" => {
64                let model = if model_number == "379" {
65                    Model::AuraEd2V2
66                } else {
67                    Model::AuraEd2V1
68                };
69                (model, TouchProto::MultiA, (758, 1024), 212)
70            }
71            "snow" => {
72                let model = if model_number == "378" {
73                    Model::AuraH2OEd2V2
74                } else {
75                    Model::AuraH2OEd2V1
76                };
77                (model, TouchProto::MultiB, (1080, 1440), 265)
78            }
79            "nova" => (Model::ClaraHD, TouchProto::MultiB, (1072, 1448), 300),
80            "frost" => {
81                let model = if model_number == "380" {
82                    Model::Forma32GB
83                } else {
84                    Model::Forma
85                };
86                (model, TouchProto::MultiB, (1440, 1920), 300)
87            }
88            "storm" => (Model::LibraH2O, TouchProto::MultiB, (1264, 1680), 300),
89            "luna" => (Model::Nia, TouchProto::MultiA, (758, 1024), 212),
90            "europa" => (Model::Elipsa, TouchProto::MultiC, (1404, 1872), 227),
91            "cadmus" => (Model::Sage, TouchProto::MultiC, (1440, 1920), 300),
92            "io" => (Model::Libra2, TouchProto::MultiC, (1264, 1680), 300),
93            "goldfinch" => (Model::Clara2E, TouchProto::MultiB, (1072, 1448), 300),
94            "condor" => (Model::Elipsa2E, TouchProto::MultiC, (1404, 1872), 227),
95            "spaBW" | "spaBWTPV" => (Model::ClaraBW, TouchProto::MultiB, (1072, 1448), 300),
96            "spaColour" => (Model::ClaraColour, TouchProto::MultiB, (1072, 1448), 300),
97            "monza" => (Model::LibraColour, TouchProto::MultiB, (1264, 1680), 300),
98            _ => {
99                let model = if model_number == "320" {
100                    Model::TouchC
101                } else {
102                    Model::TouchAB
103                };
104                (model, TouchProto::Single, (600, 800), 167)
105            }
106        };
107
108        Device {
109            model,
110            proto,
111            dims,
112            dpi,
113            metadata: OnceCell::new(),
114            wifi_manager: OnceCell::new(),
115            power_manager: OnceCell::new(),
116        }
117    }
118
119    /// Gets device metadata (lazy initialization).
120    pub fn metadata(&self) -> Result<&DeviceMetadata, DeviceError> {
121        self.metadata.get_or_try_init(DeviceMetadata::read)
122    }
123
124    /// Creates USB manager for this device.
125    pub fn usb_manager(
126        &self,
127    ) -> Result<Box<dyn crate::device::usb::UsbManager>, crate::device::usb::UsbError> {
128        cfg_select! {
129            feature = "kobo" => {
130               let metadata = self
131                   .metadata()
132                   .map_err(|e| crate::device::usb::UsbError::DeviceInfo(e.to_string()))?
133                   .clone();
134               crate::device::usb::create_usb_manager(metadata)
135            }
136            _ => {
137               Ok(Box::new(crate::device::usb::StubUsbManager))
138            }
139        }
140    }
141
142    /// Returns the WiFi manager for this device.
143    pub fn wifi_manager(
144        &self,
145    ) -> Result<&dyn crate::device::wifi::WifiManager, crate::device::wifi::WifiError> {
146        self.wifi_manager
147            .get_or_try_init(crate::device::wifi::create_wifi_manager)
148            .map(|b| b.as_ref())
149    }
150
151    /// Returns the Power manager for this device.
152    pub fn power_manager(
153        &self,
154    ) -> Result<&dyn crate::device::power::PowerManager, crate::device::power::PowerError> {
155        self.power_manager
156            .get_or_try_init(|| crate::device::power::create_power_manager(self.model))
157            .map(|b| b.as_ref())
158    }
159
160    /// Returns the install subdirectory for this build.
161    ///
162    /// Kobo devices install Cadmus under `.adds/` on the user-visible storage.
163    /// Test builds use a separate sibling directory so they can coexist with
164    /// stable builds.
165    pub fn install_subdir(&self) -> &'static str {
166        cfg_select! {
167            feature = "emulator" => {""}
168            feature = "test" => { ".adds/cadmus-tst" }
169            _ => { ".adds/cadmus" }
170        }
171    }
172
173    /// Returns the absolute install directory for this device.
174    ///
175    /// The path is determined at compile time and does not depend on the
176    /// process's current working directory, so it remains stable even when
177    /// callers change `cwd`.
178    ///
179    /// - Normal device builds: `/mnt/onboard/.adds/cadmus`
180    /// - Test device builds: `/mnt/onboard/.adds/cadmus-tst`
181    /// - Emulator builds: `/tmp/.adds/cadmus` (or `cadmus-tst` with `test`)
182    /// - Unit tests: `<temp_dir>/test-kobo-installation/.adds/cadmus-tst`
183    pub fn install_dir(&self) -> PathBuf {
184        cfg_select! {
185            test => {
186                std::env::temp_dir()
187                    .join("test-kobo-installation")
188                    .join(self.install_subdir())
189            }
190            feature = "emulator" => {
191                PathBuf::from(".").join(self.install_subdir())
192            }
193            _ => {
194                PathBuf::from(crate::settings::INTERNAL_CARD_ROOT).join(self.install_subdir())
195            }
196        }
197    }
198
199    /// Returns a path inside the device install directory.
200    ///
201    /// Use this for files and directories that Cadmus owns under its install
202    /// root, such as `tmp/` or `.github_token`.
203    pub fn install_path(&self, relative_path: impl AsRef<Path>) -> PathBuf {
204        self.install_dir().join(relative_path)
205    }
206
207    /// Returns the subdirectory name used for dynamic data on removable storage.
208    ///
209    /// Mirrors [`Device::install_subdir`] but for the SD card, using the
210    /// `.cadmus` prefix instead of `.adds/cadmus`.
211    pub fn data_subdir(&self) -> &'static str {
212        cfg_select! {
213            feature = "test" => { ".cadmus-tst" }
214            _ => { ".cadmus" }
215        }
216    }
217
218    /// Returns the directory where dynamic data files are stored.
219    ///
220    /// On device builds for models with removable storage and an SD card
221    /// currently mounted, this returns `/mnt/sd/.cadmus` (or `.cadmus-tst`
222    /// for test builds). Otherwise it falls back to [`Device::install_dir`].
223    ///
224    /// Dynamic files include the SQLite database, settings, logs, and
225    /// dictionaries. Static assets (fonts, icons, bundled resources) always
226    /// live under [`Device::install_dir`].
227    pub fn data_dir(&self) -> PathBuf {
228        cfg_select! {
229            test => { self.install_dir() }
230            feature = "emulator" => {
231                std::env::current_dir()
232                    .map(|cwd| cwd.join(self.install_dir()))
233                    .unwrap_or_else(|_| self.install_dir())
234            }
235            _ => {
236                if self.has_removable_storage()
237                    && Path::new(crate::settings::EXTERNAL_CARD_ROOT).is_dir()
238                {
239                    PathBuf::from(crate::settings::EXTERNAL_CARD_ROOT)
240                        .join(self.data_subdir())
241                } else {
242                    self.install_dir()
243                }
244            }
245        }
246    }
247
248    /// Returns a path inside the dynamic data directory.
249    ///
250    /// Use this for files and directories that Cadmus writes at runtime:
251    /// the SQLite database, versioned settings, log files, and downloaded
252    /// dictionaries.
253    pub fn data_path(&self, relative_path: impl AsRef<Path>) -> PathBuf {
254        self.data_dir().join(relative_path)
255    }
256
257    /// Resolves the path to the SQLite database.
258    ///
259    /// Lookup order:
260    /// 1. `data_dir/cadmus.sqlite` — preferred location (SD card when available).
261    /// 2. `install_dir/cadmus.sqlite` — legacy location; logs a warning so the
262    ///    user knows to copy the database manually to free internal storage.
263    /// 3. `data_dir/cadmus.sqlite` — new install; the file does not exist yet.
264    pub fn resolve_db_path(&self) -> PathBuf {
265        let data_path = self.data_path(crate::db::DB_FILENAME);
266        if data_path.exists() {
267            return data_path;
268        }
269
270        let install_path = self.install_path(crate::db::DB_FILENAME);
271        if install_path.exists() {
272            tracing::warn!(
273                path = %install_path.display(),
274                "sqlite db found in install dir, not data dir; \
275                 copy it to data dir"
276            );
277            return install_path;
278        }
279
280        data_path
281    }
282
283    /// Returns the path to the device-managed tmp directory.
284    ///
285    /// The returned path is rooted under [`Device::data_dir`], so large
286    /// temporary downloads (e.g. OTA bundles) consume SD card space rather
287    /// than internal storage when a card is present.
288    pub fn tmp_dir(&self) -> PathBuf {
289        self.data_path("tmp")
290    }
291
292    /// Removes stale contents left by a previous run and recreates the tmp
293    /// directory.
294    ///
295    /// `Device` owns the lifecycle of the tmp directory: callers may assume
296    /// the directory exists after this runs and should not create it
297    /// themselves. Call this once at startup before any feature that writes
298    /// to `tmp_dir()` to ensure a clean slate.
299    pub fn clean_tmp_dir(&self) {
300        let dir = self.tmp_dir();
301        if let Err(e) = std::fs::remove_dir_all(&dir) {
302            if e.kind() != std::io::ErrorKind::NotFound {
303                tracing::warn!(path = ?dir, error = %e, "Failed to clean tmp dir");
304            }
305        }
306        if let Err(e) = std::fs::create_dir_all(&dir) {
307            tracing::warn!(path = ?dir, error = %e, "Failed to create tmp dir");
308        }
309    }
310
311    /// Returns the number of color samples for the device screen.
312    pub fn color_samples(&self) -> usize {
313        match self.model {
314            Model::ClaraColour | Model::LibraColour => 3,
315            _ => 1,
316        }
317    }
318
319    /// Returns the frontlight kind for this device.
320    pub fn frontlight_kind(&self) -> FrontlightKind {
321        match self.model {
322            Model::ClaraHD
323            | Model::Forma
324            | Model::Forma32GB
325            | Model::LibraH2O
326            | Model::Sage
327            | Model::Libra2
328            | Model::Clara2E
329            | Model::Elipsa2E
330            | Model::ClaraBW
331            | Model::ClaraColour
332            | Model::LibraColour => FrontlightKind::Premixed,
333            Model::AuraONE | Model::AuraONELimEd | Model::AuraH2OEd2V1 | Model::AuraH2OEd2V2 => {
334                FrontlightKind::Natural
335            }
336            _ => FrontlightKind::Standard,
337        }
338    }
339
340    /// Returns true if the device has natural light capability.
341    pub fn has_natural_light(&self) -> bool {
342        self.frontlight_kind() != FrontlightKind::Standard
343    }
344
345    /// Returns true if the device has a light sensor.
346    pub fn has_lightsensor(&self) -> bool {
347        matches!(self.model, Model::AuraONE | Model::AuraONELimEd)
348    }
349
350    /// Returns true if the device has a gyroscope.
351    pub fn has_gyroscope(&self) -> bool {
352        matches!(
353            self.model,
354            Model::Forma
355                | Model::Forma32GB
356                | Model::LibraH2O
357                | Model::Elipsa
358                | Model::Sage
359                | Model::Libra2
360                | Model::Elipsa2E
361                | Model::LibraColour
362        )
363    }
364
365    /// Returns true if the device has page turn buttons.
366    pub fn has_page_turn_buttons(&self) -> bool {
367        matches!(
368            self.model,
369            Model::Forma
370                | Model::Forma32GB
371                | Model::LibraH2O
372                | Model::Sage
373                | Model::Libra2
374                | Model::LibraColour
375        )
376    }
377
378    /// Returns true if the device supports a power cover.
379    pub fn has_power_cover(&self) -> bool {
380        matches!(self.model, Model::Sage)
381    }
382
383    /// Returns true if the device has removable storage.
384    pub fn has_removable_storage(&self) -> bool {
385        matches!(
386            self.model,
387            Model::AuraH2O
388                | Model::Aura
389                | Model::AuraHD
390                | Model::Glo
391                | Model::TouchAB
392                | Model::TouchC
393        )
394    }
395
396    /// Returns true if buttons should be inverted for the given rotation.
397    pub fn should_invert_buttons(&self, rotation: i8) -> bool {
398        let sr = self.startup_rotation();
399        let (_, dir) = self.mirroring_scheme();
400
401        rotation == (4 + sr - dir) % 4 || rotation == (4 + sr - 2 * dir) % 4
402    }
403
404    /// Returns the orientation for the given rotation.
405    pub fn orientation(&self, rotation: i8) -> Orientation {
406        if self.should_swap_axes(rotation) {
407            Orientation::Portrait
408        } else {
409            Orientation::Landscape
410        }
411    }
412
413    /// Returns the device mark value.
414    pub fn mark(&self) -> u8 {
415        match self.model {
416            Model::LibraColour => 13,
417            Model::ClaraBW | Model::ClaraColour => 12,
418            Model::Elipsa2E => 11,
419            Model::Clara2E => 10,
420            Model::Libra2 => 9,
421            Model::Sage | Model::Elipsa => 8,
422            Model::Nia
423            | Model::LibraH2O
424            | Model::Forma32GB
425            | Model::Forma
426            | Model::ClaraHD
427            | Model::AuraH2OEd2V2
428            | Model::AuraEd2V2 => 7,
429            Model::AuraH2OEd2V1
430            | Model::AuraEd2V1
431            | Model::AuraONELimEd
432            | Model::AuraONE
433            | Model::Touch2
434            | Model::GloHD => 6,
435            Model::AuraH2O | Model::Aura => 5,
436            Model::AuraHD | Model::Mini | Model::Glo | Model::TouchC => 4,
437            Model::TouchAB => 3,
438        }
439    }
440
441    /// Returns whether axes should be mirrored for the given rotation.
442    pub fn should_mirror_axes(&self, rotation: i8) -> (bool, bool) {
443        let (mxy, dir) = self.mirroring_scheme();
444        let mx = (4 + (mxy + dir)) % 4;
445        let my = (4 + (mxy - dir)) % 4;
446        let mirror_x = mxy == rotation || mx == rotation;
447        let mirror_y = mxy == rotation || my == rotation;
448        (mirror_x, mirror_y)
449    }
450
451    /// Returns the center and direction of the mirroring pattern.
452    pub fn mirroring_scheme(&self) -> (i8, i8) {
453        match self.model {
454            Model::AuraH2OEd2V1 | Model::LibraH2O | Model::Libra2 => (3, 1),
455            Model::Sage => (0, 1),
456            Model::AuraH2OEd2V2 => (0, -1),
457            Model::Forma | Model::Forma32GB => (2, -1),
458            _ => (2, 1),
459        }
460    }
461
462    /// Returns true if axes should be swapped for the given rotation.
463    pub fn should_swap_axes(&self, rotation: i8) -> bool {
464        rotation % 2 == self.swapping_scheme()
465    }
466
467    /// Returns the swapping scheme value.
468    fn swapping_scheme(&self) -> i8 {
469        match self.model {
470            Model::LibraH2O => 0,
471            _ => 1,
472        }
473    }
474
475    /// Returns the startup rotation value.
476    pub fn startup_rotation(&self) -> i8 {
477        match self.model {
478            Model::LibraH2O => 0,
479            Model::AuraH2OEd2V1
480            | Model::Forma
481            | Model::Forma32GB
482            | Model::Sage
483            | Model::Libra2
484            | Model::Elipsa2E
485            | Model::LibraColour => 1,
486            _ => 3,
487        }
488    }
489
490    /// Returns a device independent rotation value.
491    pub fn to_canonical(&self, n: i8) -> i8 {
492        let (_, dir) = self.mirroring_scheme();
493        (4 + dir * (n - self.startup_rotation())) % 4
494    }
495
496    /// Returns a device dependent rotation value from canonical.
497    pub fn from_canonical(&self, n: i8) -> i8 {
498        let (_, dir) = self.mirroring_scheme();
499        (self.startup_rotation() + (4 + dir * n) % 4) % 4
500    }
501
502    /// Returns the transformed rotation value.
503    pub fn transformed_rotation(&self, n: i8) -> i8 {
504        match self.model {
505            Model::AuraHD | Model::AuraH2O => n ^ 2,
506            Model::AuraH2OEd2V2 | Model::Forma | Model::Forma32GB => (4 - n) % 4,
507            _ => n,
508        }
509    }
510
511    /// Returns the transformed gyroscope rotation value.
512    pub fn transformed_gyroscope_rotation(&self, n: i8) -> i8 {
513        match self.model {
514            Model::LibraH2O => n ^ 1,
515            Model::Libra2 | Model::Sage | Model::Elipsa2E | Model::LibraColour => (6 - n) % 4,
516            Model::Elipsa => (4 - n) % 4,
517            _ => n,
518        }
519    }
520}
521
522lazy_static! {
523    // TODO(OGKevin): we shan't rely on these env variables to construct the device, and instead
524    //                do discovery here instead of in the bash script.
525    /// Global singleton for the current device.
526    pub static ref CURRENT_DEVICE: Device = {
527        let product = env::var("PRODUCT").unwrap_or_default();
528        let model_number = env::var("MODEL_NUMBER").unwrap_or_default();
529
530        Device::new(&product, &model_number)
531    };
532}
533
534#[cfg(test)]
535mod tests {
536    use super::Device;
537
538    #[test]
539    fn test_device_canonical_rotation() {
540        let forma = Device::new("frost", "377");
541        let aura_one = Device::new("daylight", "373");
542        for n in 0..4 {
543            assert_eq!(forma.from_canonical(forma.to_canonical(n)), n);
544        }
545        assert_eq!(aura_one.from_canonical(0), aura_one.startup_rotation());
546        assert_eq!(
547            forma.from_canonical(1) - forma.from_canonical(0),
548            aura_one.from_canonical(2) - aura_one.from_canonical(3)
549        );
550    }
551}