cadmus_core/device/
metadata.rs

1//! Device metadata from the Kobo version file.
2//!
3//! # Version File
4//!
5//! The version file at `/mnt/onboard/.kobo/version` is a single
6//! comma-separated line:
7//!
8//! ```text
9//! NXXXXXXXXXXXXX,4.9.77,4.45.23640,4.9.77,4.9.77,00000000-0000-0000-0000-000000000390
10//! ```
11//!
12//! | Index | Field            | Example                                         |
13//! |-------|------------------|-------------------------------------------------|
14//! | 0     | Serial number    | `NXXXXXXXXXXXXX`                                |
15//! | 1     | Kernel version   | `4.9.77`                                        |
16//! | 2     | Firmware version | `4.45.23640`                                    |
17//! | 3     | Kernel version   | `4.9.77`                                        |
18//! | 4     | Kernel version   | `4.9.77`                                        |
19//! | 5     | Model number     | `390` or `00000000-0000-0000-0000-000000000390` |
20//!
21//! # Model Number to Product ID Mapping
22//!
23//! The model number from field 5 maps to a USB Product ID and a
24//! [`Model`](crate::device::Model) variant:
25//!
26//! | Model number(s) | Product ID | [`Model`](crate::device::Model)                                        |
27//! |-----------------|------------|------------------------------------------------------------------------|
28//! | 310, 320        | `0x4163`   | [`TouchAB`](crate::device::Model::TouchAB), [`TouchC`](crate::device::Model::TouchC) |
29//! | 330             | `0x4173`   | [`Glo`](crate::device::Model::Glo)                                     |
30//! | 340             | `0x4183`   | [`Mini`](crate::device::Model::Mini)                                   |
31//! | 350             | `0x4193`   | [`AuraHD`](crate::device::Model::AuraHD)                               |
32//! | 360             | `0x4203`   | [`Aura`](crate::device::Model::Aura)                                   |
33//! | 370             | `0x4213`   | [`AuraH2O`](crate::device::Model::AuraH2O)                             |
34//! | 371             | `0x4223`   | [`GloHD`](crate::device::Model::GloHD)                                 |
35//! | 372             | `0x4224`   | [`Touch2`](crate::device::Model::Touch2)                               |
36//! | 373, 381        | `0x4225`   | [`AuraONE`](crate::device::Model::AuraONE), [`AuraONELimEd`](crate::device::Model::AuraONELimEd) |
37//! | 374             | `0x4227`   | [`AuraH2OEd2V1`](crate::device::Model::AuraH2OEd2V1), [`AuraH2OEd2V2`](crate::device::Model::AuraH2OEd2V2) |
38//! | 375             | `0x4226`   | [`AuraEd2V1`](crate::device::Model::AuraEd2V1), [`AuraEd2V2`](crate::device::Model::AuraEd2V2) |
39//! | 376             | `0x4228`   | [`ClaraHD`](crate::device::Model::ClaraHD)                             |
40//! | 377, 380        | `0x4229`   | [`Forma`](crate::device::Model::Forma), [`Forma32GB`](crate::device::Model::Forma32GB) |
41//! | 378             | `0x4227`   | [`AuraH2OEd2V2`](crate::device::Model::AuraH2OEd2V2) (V2 hardware)    |
42//! | 379             | `0x4226`   | [`AuraEd2V2`](crate::device::Model::AuraEd2V2) (V2 hardware)          |
43//! | 382             | `0x4230`   | [`Nia`](crate::device::Model::Nia)                                     |
44//! | 383             | `0x4231`   | [`Sage`](crate::device::Model::Sage)                                   |
45//! | 384             | `0x4232`   | [`LibraH2O`](crate::device::Model::LibraH2O)                           |
46//! | 387             | `0x4233`   | [`Elipsa`](crate::device::Model::Elipsa)                               |
47//! | 386             | `0x4235`   | [`Clara2E`](crate::device::Model::Clara2E)                             |
48//! | 388             | `0x4234`   | [`Libra2`](crate::device::Model::Libra2)                               |
49//! | 389             | `0x4236`   | [`Elipsa2E`](crate::device::Model::Elipsa2E)                           |
50//! | 390             | `0x4237`   | [`LibraColour`](crate::device::Model::LibraColour)                     |
51//! | 391, 395        | `0x4239`   | [`ClaraBW`](crate::device::Model::ClaraBW)                             |
52//! | 393             | `0x4238`   | [`ClaraColour`](crate::device::Model::ClaraColour)                     |
53
54use crate::device::error::DeviceError;
55use std::env;
56use std::fs;
57use tracing::{debug, error, info, warn};
58
59/// Represents the hardware platform of the device.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub(crate) enum Platform {
62    /// MediaTek MT8113T NTX platform (e.g. Kobo Libra Colour).
63    MT8113TNTX,
64    /// Freescale i.MX 6SLL NTX platform.
65    MX6SLLNTX,
66    /// Freescale i.MX 6ULL NTX platform.
67    MX6ULLNTX,
68    /// Freescale i.MX 6UL NTX platform.
69    MX6SULNTX,
70    /// Freescale i.MX 6SL NTX platform.
71    MX6SLNTX,
72    /// Any other platform, with the raw identifier string preserved.
73    Other(String),
74}
75
76impl From<String> for Platform {
77    fn from(s: String) -> Self {
78        match s.as_str() {
79            "mt8113t-ntx" => Platform::MT8113TNTX,
80            "mx6sll-ntx" => Platform::MX6SLLNTX,
81            "mx6ull-ntx" => Platform::MX6ULLNTX,
82            "mx6sul-ntx" => Platform::MX6SULNTX,
83            "mx6sl-ntx" => Platform::MX6SLNTX,
84            _ => Platform::Other(s),
85        }
86    }
87}
88
89impl std::fmt::Display for Platform {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            Platform::MT8113TNTX => write!(f, "mt8113t-ntx"),
93            Platform::MX6SLLNTX => write!(f, "mx6sll-ntx"),
94            Platform::MX6ULLNTX => write!(f, "mx6ull-ntx"),
95            Platform::MX6SULNTX => write!(f, "mx6sul-ntx"),
96            Platform::MX6SLNTX => write!(f, "mx6sl-ntx"),
97            Platform::Other(s) => write!(f, "{}", s),
98        }
99    }
100}
101
102const VERSION_PATH: &str = "/mnt/onboard/.kobo/version";
103const VENDOR_ID: u16 = 0x2237;
104
105/// Device metadata read from Kobo version file.
106#[derive(Debug, Clone)]
107pub struct DeviceMetadata {
108    pub vendor_id: u16,
109    pub product_id: u16,
110    pub serial_number: String,
111    pub firmware_version: String,
112    pub partition: String,
113    pub manufacturer: String,
114    pub product: String,
115}
116
117impl DeviceMetadata {
118    /// Reads device metadata from `/mnt/onboard/.kobo/version`.
119    ///
120    /// The version file is a single comma-separated line with the following fields:
121    ///
122    /// | Index | Field            | Example                                  |
123    /// |-------|------------------|------------------------------------------|
124    /// | 0     | Serial number    | `NXXXXXXXXXXXXX`                         |
125    /// | 1     | Kernel Version   | `4.9.77`                                 |
126    /// | 2     | Firmware version | `4.45.23640`                             |
127    /// | 3     | Kernel Version   | `4.9.77`                                 |
128    /// | 4     | Kernel Version   | `4.9.77`                                 |
129    /// | 5     | Model number     | `390` or `00000000-0000-0000-0000-000000000390` |
130    ///
131    /// Example file contents (Libra Colour):
132    /// ```text
133    /// NXXXXXXXXXXXXX,4.9.77,4.45.23640,4.9.77,4.9.77,00000000-0000-0000-0000-000000000390
134    /// ```
135    ///
136    /// # Errors
137    ///
138    /// Returns [`DeviceError`] if:
139    /// - the version file cannot be read or has fewer than 6 fields, or
140    /// - the `PLATFORM` environment variable is not set.
141    pub fn read() -> Result<Self, DeviceError> {
142        let content = fs::read_to_string(VERSION_PATH).map_err(|e| {
143            error!(path = VERSION_PATH, error = %e, "Failed to read Kobo version file");
144            DeviceError::Metadata(format!("Cannot read version file: {}", e))
145        })?;
146
147        let platform = detect_platform()?;
148        Self::parse(&content, &platform)
149    }
150
151    fn parse(content: &str, platform: &Platform) -> Result<Self, DeviceError> {
152        let fields: Vec<&str> = content.trim().split(',').collect();
153
154        if fields.len() < 6 {
155            error!(
156                field_count = fields.len(),
157                "Kobo version file has insufficient fields"
158            );
159            return Err(DeviceError::Metadata(
160                "Version file format unexpected: insufficient fields".to_string(),
161            ));
162        }
163
164        let serial_number = fields[0].to_string();
165        let firmware_version = fields[2].to_string();
166
167        let raw = fields[5].trim();
168        let model_number = raw
169            .rsplit('-')
170            .next()
171            .unwrap_or(raw)
172            .trim_start_matches('0');
173
174        let product_id = model_to_product_id(model_number);
175        let partition = platform_to_partition(platform).to_string();
176        let product = format!("eReader-{}", firmware_version);
177
178        info!(
179            serial_number = %serial_number,
180            firmware_version = %firmware_version,
181            model_number = %model_number,
182            product_id,
183            product_id_hex = %format_args!("{product_id:#06X}"),
184            partition = %partition,
185            "Device metadata read successfully"
186        );
187
188        Ok(Self {
189            vendor_id: VENDOR_ID,
190            product_id,
191            serial_number,
192            firmware_version,
193            partition,
194            manufacturer: "Kobo".to_string(),
195            product,
196        })
197    }
198}
199
200/// Maps a Kobo model number to its USB Product ID.
201fn model_to_product_id(model_number: &str) -> u16 {
202    let product_id = match model_number {
203        "320" | "310" => 0x4163,
204        "330" => 0x4173,
205        "340" => 0x4183,
206        "350" => 0x4193,
207        "360" => 0x4203,
208        "370" => 0x4213,
209        "371" => 0x4223,
210        "372" => 0x4224,
211        "373" | "381" => 0x4225,
212        "374" => 0x4227,
213        "375" => 0x4226,
214        "376" => 0x4228,
215        "377" | "380" => 0x4229,
216        "378" => 0x4227,
217        "379" => 0x4226,
218        "384" => 0x4232,
219        "382" => 0x4230,
220        "387" => 0x4233,
221        "383" => 0x4231,
222        "388" => 0x4234,
223        "386" => 0x4235,
224        "389" => 0x4236,
225        "390" => 0x4237,
226        "393" => 0x4238,
227        "391" | "395" => 0x4239,
228        _ => 0x6666,
229    };
230
231    if product_id == 0x6666 {
232        warn!(model_number = %model_number, "Unknown model number, using default Product ID");
233    } else {
234        debug!(model_number = %model_number, product_id, "Mapped model to Product ID");
235    }
236
237    product_id
238}
239
240/// Detects the platform type from the PLATFORM environment variable.
241pub(crate) fn detect_platform() -> Result<Platform, DeviceError> {
242    env::var("PLATFORM")
243        .map(Platform::from)
244        .map_err(|_| DeviceError::Metadata("PLATFORM environment variable not set".to_string()))
245}
246
247fn platform_to_partition(platform: &Platform) -> &'static str {
248    match platform {
249        Platform::MT8113TNTX => "/dev/mmcblk0p12",
250        _ => "/dev/mmcblk0p3",
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_model_to_product_id() {
260        assert_eq!(model_to_product_id("376"), 0x4228);
261        assert_eq!(model_to_product_id("377"), 0x4229);
262        assert_eq!(model_to_product_id("378"), 0x4227);
263        assert_eq!(model_to_product_id("379"), 0x4226);
264        assert_eq!(model_to_product_id("380"), 0x4229);
265        assert_eq!(model_to_product_id("390"), 0x4237);
266        assert_eq!(model_to_product_id("999"), 0x6666);
267    }
268
269    #[test]
270    fn test_platform_to_partition() {
271        assert_eq!(
272            platform_to_partition(&Platform::MT8113TNTX),
273            "/dev/mmcblk0p12"
274        );
275        assert_eq!(
276            platform_to_partition(&Platform::MX6SLLNTX),
277            "/dev/mmcblk0p3"
278        );
279        assert_eq!(
280            platform_to_partition(&Platform::MX6SULNTX),
281            "/dev/mmcblk0p3"
282        );
283        assert_eq!(
284            platform_to_partition(&Platform::Other("freescale".to_string())),
285            "/dev/mmcblk0p3"
286        );
287    }
288
289    #[test]
290    fn test_parse_libra_colour() {
291        let content = "SERIALPLACEHOLDER,4.9.77,4.45.23640,4.9.77,4.9.77,00000000-0000-0000-0000-000000000390";
292        let metadata = DeviceMetadata::parse(content, &Platform::MT8113TNTX).expect("parse failed");
293
294        assert_eq!(metadata.serial_number, "SERIALPLACEHOLDER");
295        assert_eq!(metadata.firmware_version, "4.45.23640");
296        assert_eq!(metadata.product_id, 0x4237);
297        assert_eq!(metadata.partition, "/dev/mmcblk0p12");
298        assert_eq!(metadata.manufacturer, "Kobo");
299        assert_eq!(metadata.product, "eReader-4.45.23640");
300        assert_eq!(metadata.vendor_id, 0x2237);
301    }
302
303    #[test]
304    fn test_parse_insufficient_fields() {
305        let result = DeviceMetadata::parse("field1,field2", &Platform::MT8113TNTX);
306        assert!(result.is_err());
307    }
308}