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}