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