diff options
author | cos <cos> | 2022-06-12 08:31:20 +0200 |
---|---|---|
committer | cos <cos> | 2022-06-12 17:16:26 +0200 |
commit | 2b399b43ba88713eef4e2983f434025d8c6c9e7b (patch) | |
tree | b5c35512a1ef9db18c7d2a668fde337fc7cf4d3b | |
download | lctns-cli-main.zip |
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | Cargo.toml | 8 | ||||
-rw-r--r-- | LICENSE | 25 | ||||
-rw-r--r-- | README.md | 55 | ||||
-rw-r--r-- | src/main.rs | 459 |
5 files changed, 548 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..48af9c0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "lctns-cli" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.57" +getopts = "0.2.21" @@ -0,0 +1,25 @@ +Copyright (c) 2022 cos <https://www.netizen.se/> + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..998868b --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +lctns-edit +========== +A quickly prototyped tool to edit name, latitude and longitude in LCTNS.FIT as +found on Garmin watches. Works for me, but no guarantees are given. If it +bricks your device you're responsible, not me. + +How to use +---------- +Example use (list all entries in LCTNS.FIT by default): + + % ./lctns-cli + 0: Garmin (N 38°51'19.976", W 94°47'56.468") + 1: Garmin Europe (N 50°58'58.367", W 1°27'50.040") + 2: Garmin Taiwan (N 25°3'42.418", E 121°38'24.959") + +Example use (replace first entry with a decent café): + + % ./lctns-cli --input LCTNS.FIT --output EDITED.FIT --entry 0 \ + --name 'Tjili pop' --latitude "N 55°41.184'" --longitude "E 12°33.014'" + Tjili pop (N 55°41'11.045", E 12°33'0.838") + +New entries should appear after copying the generated output file to the +`GARMIN/NEWFILES/LCTNS.FIT` on your watch after having mounted it as a usb +storage device. + +It is only possible to edit existing entries. It is not possible to add new +ones or remove from the file. It seems the firmware on the device will add all +new entries without overwriting any. + +Install +------- +You'll need [`git`][] and [`cargo`][rustup]. Cloning with git from +<https://git.netizen.se/lctns-cli/> and running `cargo install --path .` should +place `lctns-cli` in `~/.cargo/bin/` by default. + +The code is still too much of a prototype, and far from sufficiently tested, +for it to be suitable to distribute to non-developers. + +I would consider publishing to <https://crates.io/>, but likely not until it +becomes [possible to use crates.io without relying on GitHub][msgh_required]. + +License and disclaimer +---------------------- +MIT. Garmin makes great products, but this tool has nothing to do with them. +They own their trademarks et cetera and would unlikely recommend using anything +with their products not developed by them. You are using this at your own risk. + +Feedback +-------- +Constructive feedback is always welcome. The same goes for patches. Contact +details are easily found at: <https://www.netizen.se/>. + +[git]: https://git-scm.com/ +[rustup]: https://rustup.rs/ +[msgh_required]: https://github.com/rust-lang/crates.io/issues/326 diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9ec26d8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,459 @@ +// © 2022 cos <https://www.netizen.se/>. Please see LICENSE file for details. +// +//! lctns-edit is a quickly prototyped tool to edit name, latitude and longitude in LCTNS.FIT as +//! found on Garmin watches. works for me, but no guarantees are given. if it bricks your device +//! you're responsible, not me. + +use { + anyhow::{ + Context, + Result, + anyhow, + }, + getopts::Options, + std::{ + env::args, + error::Error, + fmt::{ + Display, + Error as FmtError, + Formatter, + Result as FmtResult, + }, + io::prelude::*, + iter::repeat, + fs::File, + path::Path, + str::FromStr, + }, +}; + +/* LCTNS.FIT seems to start with 109 bytes, which we do not know how to parse. After those initial + * bytes follows the location data in entries of 37 bytes each. One example of such an entry reads + * as follows: + * + * 0 |3 |19 |23 |27 |29 |31 |33 36| + * ffffff|4761726d696e2054616977616e000000|c25bd211|4def7f56|0200|e101|820a|ffff02ff| + * G a r m i n T a i w a n 25°3.70' 121°38.' + * + * We do not know know how to interpret all of those, but the name and the position are the + * important ones which this tool cares about. + */ + +const MAGIC_INITIAL_BYTES: usize = 109; +const MAGIC_ENTRY_LEN: usize = 37; +const MAGIC_INDEX_NAME: usize = 3; +const MAGIC_LEN_NAME: usize = 16; +const MAGIC_INDEX_LAT: usize = MAGIC_INDEX_NAME + MAGIC_LEN_NAME; +const MAGIC_LEN_LAT: usize = 4; +const MAGIC_INDEX_LONG: usize = MAGIC_INDEX_LAT + MAGIC_LEN_LAT; +const MAGIC_LEN_LONG: usize = 4; +// 29-30 is likely the icon and 31-32 might be the altitude, but those are merely my guesses. + +// TODO Latitude and Longitude are almost identical. It would be a good idea to refactor them to +// reuse the common code. +struct Latitude { + inner: i32, +} + +impl Latitude { + /// Creates a [Latitude] using `val` in the format used in the FIT file. + fn from_fit(val: i32) -> Self { + Self { + inner: val, + } + } +} + +impl Display for Latitude { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> { + // TODO Output localized strings. + let div = ((1i64 << 32) as f32) / 360f32; + let deg = self.inner as f32 / div; + let min = deg.fract()*60f32; + let sec = min.fract()*60f32; + + if deg >= 0f32 { + write!(f, "N {}°{}'{:.3}\"", deg.trunc(), min.trunc(), sec) + } else { + write!(f, "S {}°{}'{:.3}\"", -deg.trunc(), -min.trunc(), -sec) + } + } +} + +#[derive(Debug)] +struct ParseError { } + +impl Error for ParseError { } + +impl Display for ParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + write!(f, "Parse error") + } +} + +// FIXME This function gets the parsing done, but it is horribly written and lacking in e.g. error +// checking. There is a latlon crate which claims to do this kind of parsing but with +// datatypes generally used for geo. Any attempt to interface with the greater eco-system +// pulls in quite some heavy dependences though. +impl TryFrom<&str> for Latitude { + type Error = ParseError; + + fn try_from(s: &str) -> Result<Self, Self::Error> { + let mul = ((1i64 << 32) as f32) / 360f32; + + let mut degrees = None; + let mut minutes = None; + let mut seconds = None; + let mut index = 0; + + let sign = if s.contains('N') { + 1f32 + } else if s.contains('S') { + -1f32 + } else { + return Err(ParseError { }); + }; + + if let Some(deg_sign_pos) = s.find('°') { + while index < deg_sign_pos { + if let Ok(val) = f32::from_str(&s[index..deg_sign_pos]) { + degrees = Some(val); + index = deg_sign_pos; + break; + } + index += 1; + } + } + index += 2; // Degree character is dual-byte. + if let Some(min_sign) = s.find('\'') { + while index < min_sign { + if let Ok(val) = f32::from_str(&s[index..min_sign]) { + minutes = Some(val); + index = min_sign; + break; + } + index += 1; + } + } + if let Some(sec_sign) = s.find('"') { + while index < sec_sign { + if let Ok(val) = f32::from_str(&s[index..sec_sign]) { + seconds = Some(val); + break; + } + index += 1; + } + } + match (degrees, minutes, seconds) { + (Some(deg), None, None) => Ok(Latitude { inner: (sign * mul * deg) as i32 }), + (Some(deg), Some(min), None) => + Ok(Latitude { inner: (sign * mul * (deg + min / 60f32)) as i32 }), + (Some(deg), Some(min), Some(sec)) => + Ok(Latitude { inner: (sign * mul * (deg + min / 60f32 + sec / 3600f32)) as i32 }), + _ => Err(ParseError { }), + } + } +} + +struct Longitude { + inner: i32, +} + +impl Longitude { + fn from_fit(val: i32) -> Self { + Self { + inner: val, + } + } +} + +impl Display for Longitude { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> { + let div = ((1i64 << 32) as f32) / 360f32; + let deg = self.inner as f32 / div; + let min = deg.fract()*60f32; + let sec = min.fract()*60f32; + + if deg >= 0f32 { + write!(f, "E {}°{}'{:.3}\"", deg.trunc(), min.trunc(), sec) + } else { + write!(f, "W {}°{}'{:.3}\"", -deg.trunc(), -min.trunc(), -sec) + } + } +} + +impl TryFrom<&str> for Longitude { + type Error = ParseError; + + fn try_from(s: &str) -> Result<Self, Self::Error> { + let mul = ((1i64 << 32) as f32) / 360f32; + + let mut degrees = None; + let mut minutes = None; + let mut seconds = None; + let mut index = 0; + + let sign = if s.contains('E') { + 1f32 + } else if s.contains('W') { + -1f32 + } else { + return Err(ParseError { }); + }; + + if let Some(deg_sign_pos) = s.find('°') { + while index < deg_sign_pos { + if let Ok(val) = f32::from_str(&s[index..deg_sign_pos]) { + degrees = Some(val); + index = deg_sign_pos; + break; + } + index += 1; + } + } + index += 2; // Degree character is dual-byte. + if let Some(min_sign) = s.find('\'') { + while index < min_sign { + if let Ok(val) = f32::from_str(&s[index..min_sign]) { + minutes = Some(val); + index = min_sign; + break; + } + index += 1; + } + } + if let Some(sec_sign) = s.find('"') { + while index < sec_sign { + if let Ok(val) = f32::from_str(&s[index..sec_sign]) { + seconds = Some(val); + break; + } + index += 1; + } + } + match (degrees, minutes, seconds) { + (Some(deg), None, None) => Ok(Longitude { inner: (sign * mul * deg) as i32 }), + (Some(deg), Some(min), None) => + Ok(Longitude { inner: (sign * mul * (deg + min / 60f32)) as i32 }), + (Some(deg), Some(min), Some(sec)) => + Ok(Longitude { inner: (sign * mul * (deg + min / 60f32 + sec / 3600f32)) as i32 }), + _ => Err(ParseError { }), + } + } +} + +struct LctnEntry<'lctns> { + raw_slice: &'lctns [u8], +} + +impl<'lctns> LctnEntry<'lctns> { + fn new(raw_slice: &'lctns [u8]) -> Self { + Self { + raw_slice, + } + } + + fn name(&self) -> String { + let slice = &self.raw_slice[MAGIC_INDEX_NAME..MAGIC_INDEX_NAME + MAGIC_LEN_NAME]; + String::from_utf8_lossy(slice).to_string() + } + + fn lat(&self) -> i32 { + let slice = &self.raw_slice[MAGIC_INDEX_LAT..MAGIC_INDEX_LAT + MAGIC_LEN_LAT]; + i32::from_le_bytes(slice.try_into().expect("Latitude error")) + } + + fn long(&self) -> i32 { + let slice = &self.raw_slice[MAGIC_INDEX_LONG..MAGIC_INDEX_LONG + MAGIC_LEN_LONG]; + i32::from_le_bytes(slice.try_into().expect("Longitude error")) + } +} + +impl Display for LctnEntry<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> { + let latitude = Latitude::from_fit(self.lat()); + let longitude = Longitude::from_fit(self.long()); + write!(f, "{} ({}, {})", self.name(), latitude, longitude) + } +} + +struct LctnEntryMut<'lctns> { + raw_slice: &'lctns mut [u8], +} + +impl<'lctns> LctnEntryMut<'lctns> { + fn set_name(&mut self, name: String) { + let iter = name.as_bytes().iter().chain(repeat(&b'\0')).enumerate(); + for (index, byte) in iter { + if index > MAGIC_LEN_NAME { + break; + } + self.raw_slice[MAGIC_INDEX_NAME + index] = *byte; + } + } + + fn set_lat(&mut self, lat: i32) { + let slice = i32::to_le_bytes(lat); + for (index, byte) in slice.into_iter().enumerate() { + self.raw_slice[MAGIC_INDEX_LAT + index] = byte; + } + } + + fn set_long(&mut self, long: i32) { + let slice = i32::to_le_bytes(long); + for (index, byte) in slice.into_iter().enumerate() { + self.raw_slice[MAGIC_INDEX_LONG + index] = byte; + } + } +} + +impl Display for LctnEntryMut<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> { + let entry = LctnEntry::new(self.raw_slice); + write!(f, "{}", &entry) + } +} + +struct Lctns { + raw_data: Vec<u8>, +} + +impl Lctns { + fn new<F: AsRef<Path>>(filename: F) -> Result<Self> { + let mut lctns = Lctns { + raw_data: Vec::new(), + }; + + let mut f = File::open(filename)?; + lctns.raw_data = Vec::new(); + f.read_to_end(&mut lctns.raw_data)?; + Ok(lctns) + } + + fn save<F: AsRef<Path>>(&self, filename: F) -> Result<()> { + let mut f = File::create(filename)?; + f.write(&self.raw_data).map(|_| ()).context("Save failed") + } + + fn len(&self) -> usize { + (self.raw_data.len() - MAGIC_INITIAL_BYTES) / MAGIC_ENTRY_LEN + } + + fn get(&self, index: usize) -> Option<LctnEntry> { + let start = MAGIC_INITIAL_BYTES + index * MAGIC_ENTRY_LEN; + let end = MAGIC_INITIAL_BYTES + (index + 1) * MAGIC_ENTRY_LEN; + Some(LctnEntry { + raw_slice: &self.raw_data[start..end], + }) + } + + fn get_mut(&mut self, index: usize) -> Option<LctnEntryMut> { + let start = MAGIC_INITIAL_BYTES + index * MAGIC_ENTRY_LEN; + let end = MAGIC_INITIAL_BYTES + (index + 1) * MAGIC_ENTRY_LEN; + Some(LctnEntryMut { + raw_slice: &mut self.raw_data[start..end], + }) + } + + fn show_all(&self) -> Result<()> { + for index in 0..self.len() { + println!("{:4}: {}", index, self.get(index).unwrap()); + } + Ok(()) + } + + fn _hexdump(&self) -> Result<()> { + let mut left = String::default(); + let mut right = String::default(); + let buffer = &self.raw_data[MAGIC_INITIAL_BYTES..]; // Ignore stuff at start of file. + + for (index, byte) in buffer.iter().enumerate() { + if index % MAGIC_ENTRY_LEN == 0 { + println!("{left} {right}\n"); + left = format!("{:04} ", index); + right = String::default(); + println!("{}", self.get(index/MAGIC_ENTRY_LEN).unwrap()); + } + left += &format!("{:02x} ", byte); + if byte > &32 && byte < &127 { + right += std::str::from_utf8(&buffer[index..index+1]).unwrap(); + } else { + right += "_"; + } + } + println!("{left} {right}"); + Ok(()) + } +} + +fn print_usage(program: &str, opts: Options) { + let brief = format!("Usage: {}", program); + print!("{}", opts.usage(&brief)); +} + +fn main() -> Result<()> { + let args: Vec<String> = args().collect(); + let program = args[0].clone(); + let mut index = None; + + let mut opts = Options::new(); + opts.optopt("i", "input", "FILE", ""); + opts.optopt("o", "output", "FILE", ""); + opts.optopt("e", "entry", "NUM", ""); + opts.optopt("n", "name", "NAME", ""); + // While "λ" and "φ" would be reasonable for "latitude" and "longitude", the getopts crate + // seems to only allow ascii for the short name. Thus they end up with only their long ones. + opts.optopt("", "latitude", "N|S deg°[min'sec\"]", ""); + opts.optopt("", "longitude", "E|W deg°[min'sec\"]", ""); + opts.optflag("h", "help", "Print this help text"); + let matches = opts.parse(&args[1..])?; + if matches.opt_present("help") { + print_usage(&program, opts); + return Ok(()); + } + + let mut lctns = Lctns::new(matches.opt_str("input") + .unwrap_or_else(|| String::from("LCTNS.FIT")))?; + let output = matches.opt_str("output"); + let mut save = false; + if let Some(string) = matches.opt_str("entry") { + let highest = lctns.len(); + let i = string.parse()?; + if i < highest { + index = Some(i); + } else { + Err(anyhow!("{i} is too large. There are only {highest} number of records in file."))?; + } + } + if let Some(i) = index { + let mut entry = lctns.get_mut(i).ok_or_else(|| anyhow!("No entry at {i}"))?; + if let Some(name) = matches.opt_str("name") { + save = true; + entry.set_name(name); + } + if let Some(lat) = matches.opt_str("latitude") { + save = true; + let raw: Latitude = lat.as_str().try_into()?; + entry.set_lat(raw.inner); + } + if let Some(long) = matches.opt_str("longitude") { + save = true; + let raw: Longitude = long.as_str().try_into()?; + entry.set_long(raw.inner); + } + println!("{entry}"); + } else if matches.opt_str("name").is_some() || matches.opt_str("latitude") .is_some() || + matches.opt_str("longitude").is_some() || matches.opt_str("output").is_some() + { + Err(anyhow!("Updates requires selecting an entry. (Using: --entry NUM)"))?; + } else { + lctns.show_all()?; + } + + if save && output.is_some() { + lctns.save(output.unwrap())?; // Ok to unwrap(), previous line checks for is_some() + } + + Ok(()) +} |