summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcos <cos>2022-06-12 08:31:20 +0200
committercos <cos>2022-06-12 17:16:26 +0200
commit2b399b43ba88713eef4e2983f434025d8c6c9e7b (patch)
treeb5c35512a1ef9db18c7d2a668fde337fc7cf4d3b
downloadlctns-cli-2b399b43ba88713eef4e2983f434025d8c6c9e7b.zip
Initial commitHEADmain
-rw-r--r--.gitignore1
-rw-r--r--Cargo.toml8
-rw-r--r--LICENSE25
-rw-r--r--README.md55
-rw-r--r--src/main.rs459
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"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ef00546
--- /dev/null
+++ b/LICENSE
@@ -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(())
+}