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