merge
BIN
backup/5c121efc-b8b0-42e0-b985-4b2c577fe26a.jpg
Normal file
|
After Width: | Height: | Size: 9.3 MiB |
BIN
backup/64141f44-a825-493a-b9ea-54878de56f03.jpg
Normal file
|
After Width: | Height: | Size: 10 MiB |
500
gpx2owntracks.py
Normal file
|
|
@ -0,0 +1,500 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
GPX → OwnTracks (Recorder) importer
|
||||||
|
|
||||||
|
Converts .gpx tracks (trk/seg/pt) to OwnTracks Location JSON messages and either:
|
||||||
|
• writes a JSONL file (one message per line), or
|
||||||
|
• writes individual .json files in a Recorder-like folder structure, or
|
||||||
|
• POSTs messages to an OwnTracks Recorder HTTP endpoint (/pub), optionally throttled.
|
||||||
|
|
||||||
|
Features
|
||||||
|
- Config via YAML + CLI flags that override config
|
||||||
|
- Robust error handling & validation
|
||||||
|
- Optional speed calculation (m/s) and altitude inclusion
|
||||||
|
- Time window filtering
|
||||||
|
- Dry-run mode
|
||||||
|
- Logging with levels
|
||||||
|
|
||||||
|
OwnTracks message fields used
|
||||||
|
_type: "location"
|
||||||
|
tst: Unix timestamp (UTC)
|
||||||
|
lat, lon, alt (optional), acc (accuracy meters), vel (optional m/s), tid (2-char), conn ("w")
|
||||||
|
|
||||||
|
Dependencies
|
||||||
|
pip install gpxpy pyyaml requests
|
||||||
|
|
||||||
|
Usage examples
|
||||||
|
# Basic: create JSONL from GPX
|
||||||
|
python gpx2owntracks.py input.gpx --jsonl-path out/track.jsonl --user alice --device pixel --tid AL
|
||||||
|
|
||||||
|
# Using YAML config (CLI overrides YAML)
|
||||||
|
python gpx2owntracks.py input.gpx --config config.yaml --mode http --rate 5
|
||||||
|
|
||||||
|
# Write Recorder-like file tree
|
||||||
|
python gpx2owntracks.py input.gpx --mode files --files-base-dir /var/lib/ot-recorder/store/recorder \
|
||||||
|
--user alice --device pixel
|
||||||
|
|
||||||
|
# Post directly to Recorder (HTTP /pub)
|
||||||
|
python gpx2owntracks.py input.gpx --mode http --http-url https://rec.example.com/pub \
|
||||||
|
--http-user user --http-pass pass --no-verify
|
||||||
|
|
||||||
|
Example config.yaml
|
||||||
|
-------------------
|
||||||
|
user: alice
|
||||||
|
device: pixel
|
||||||
|
Tid: AL # two characters (case-sensitive)
|
||||||
|
default_acc: 10
|
||||||
|
include_alt: true
|
||||||
|
calc_speed: true
|
||||||
|
assume_timezone: UTC # used only if GPX points lack timezone info
|
||||||
|
mode: jsonl # jsonl | files | http
|
||||||
|
jsonl_path: ./out/track.jsonl
|
||||||
|
files_base_dir: /var/lib/ot-recorder/store/recorder
|
||||||
|
http:
|
||||||
|
url: https://rec.example.com/pub
|
||||||
|
username: user
|
||||||
|
password: pass
|
||||||
|
tls_verify: true
|
||||||
|
rate_limit_per_sec: 5 # for http mode
|
||||||
|
start_time: null # ISO8601 filter (inclusive), e.g. "2025-08-01T00:00:00Z"
|
||||||
|
end_time: null # ISO8601 filter (exclusive)
|
||||||
|
log_level: INFO
|
||||||
|
|
||||||
|
Recorder file mode layout
|
||||||
|
<files_base_dir>/<user>/<device>/<YYYY>/<MM>/<DD>/<tst>.json
|
||||||
|
|
||||||
|
Note: When writing files, Recorder will pick them up if they are placed under its store path.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import dataclasses
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
import gpxpy
|
||||||
|
import gpxpy.gpx
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# Optional imports for HTTP mode
|
||||||
|
try:
|
||||||
|
import requests # type: ignore
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
requests = None
|
||||||
|
|
||||||
|
ISO8601 = "%Y-%m-%dT%H:%M:%S%z"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Config:
|
||||||
|
user: Optional[str] = None
|
||||||
|
device: Optional[str] = None
|
||||||
|
tid: Optional[str] = None
|
||||||
|
default_acc: float = 10.0
|
||||||
|
include_alt: bool = True
|
||||||
|
calc_speed: bool = False
|
||||||
|
assume_timezone: str = "UTC"
|
||||||
|
mode: str = "jsonl" # jsonl | files | http
|
||||||
|
jsonl_path: Optional[str] = None
|
||||||
|
files_base_dir: Optional[str] = None
|
||||||
|
http_url: Optional[str] = None
|
||||||
|
http_username: Optional[str] = None
|
||||||
|
http_password: Optional[str] = None
|
||||||
|
http_tls_verify: bool = True
|
||||||
|
rate_limit_per_sec: Optional[float] = None
|
||||||
|
start_time: Optional[str] = None # ISO8601
|
||||||
|
end_time: Optional[str] = None # ISO8601
|
||||||
|
log_level: str = "INFO"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Optional[str]) -> Config:
|
||||||
|
if not path:
|
||||||
|
return Config()
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
raise ConfigError(f"Config file not found: {p}")
|
||||||
|
try:
|
||||||
|
with p.open("r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
except Exception as e:
|
||||||
|
raise ConfigError(f"Failed to parse YAML: {e}")
|
||||||
|
|
||||||
|
def get_bool(d, k, default):
|
||||||
|
v = d.get(k, default)
|
||||||
|
return bool(v)
|
||||||
|
|
||||||
|
http = data.get("http", {}) or {}
|
||||||
|
|
||||||
|
cfg = Config(
|
||||||
|
user=data.get("user"),
|
||||||
|
device=data.get("device"),
|
||||||
|
tid=data.get("Tid") or data.get("tid"),
|
||||||
|
default_acc=float(data.get("default_acc", 10.0)),
|
||||||
|
include_alt=get_bool(data, "include_alt", True),
|
||||||
|
calc_speed=get_bool(data, "calc_speed", False),
|
||||||
|
assume_timezone=str(data.get("assume_timezone", "UTC")),
|
||||||
|
mode=str(data.get("mode", "jsonl")),
|
||||||
|
jsonl_path=data.get("jsonl_path"),
|
||||||
|
files_base_dir=data.get("files_base_dir"),
|
||||||
|
http_url=http.get("url") or data.get("http_url"),
|
||||||
|
http_username=http.get("username") or data.get("http_username"),
|
||||||
|
http_password=http.get("password") or data.get("http_password"),
|
||||||
|
http_tls_verify=bool(http.get("tls_verify", data.get("http_tls_verify", True))),
|
||||||
|
rate_limit_per_sec=(float(data.get("rate_limit_per_sec")) if data.get("rate_limit_per_sec") is not None else None),
|
||||||
|
start_time=data.get("start_time"),
|
||||||
|
end_time=data.get("end_time"),
|
||||||
|
log_level=str(data.get("log_level", "INFO")).upper(),
|
||||||
|
)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def merge_cli_over_config(cfg: Config, args: argparse.Namespace) -> Config:
|
||||||
|
# Helper to prefer CLI when provided
|
||||||
|
def override(val, cli_val):
|
||||||
|
return cli_val if cli_val is not None else val
|
||||||
|
|
||||||
|
cfg.user = override(cfg.user, args.user)
|
||||||
|
cfg.device = override(cfg.device, args.device)
|
||||||
|
cfg.tid = override(cfg.tid, args.tid)
|
||||||
|
cfg.default_acc = override(cfg.default_acc, args.acc)
|
||||||
|
if args.include_alt is not None:
|
||||||
|
cfg.include_alt = args.include_alt
|
||||||
|
if args.calc_speed is not None:
|
||||||
|
cfg.calc_speed = args.calc_speed
|
||||||
|
cfg.assume_timezone = override(cfg.assume_timezone, args.assume_timezone)
|
||||||
|
cfg.mode = override(cfg.mode, args.mode)
|
||||||
|
cfg.jsonl_path = override(cfg.jsonl_path, args.jsonl_path)
|
||||||
|
cfg.files_base_dir = override(cfg.files_base_dir, args.files_base_dir)
|
||||||
|
cfg.http_url = override(cfg.http_url, args.http_url)
|
||||||
|
cfg.http_username = override(cfg.http_username, args.http_user)
|
||||||
|
cfg.http_password = override(cfg.http_password, args.http_pass)
|
||||||
|
if args.verify is not None:
|
||||||
|
cfg.http_tls_verify = args.verify
|
||||||
|
cfg.rate_limit_per_sec = override(cfg.rate_limit_per_sec, args.rate)
|
||||||
|
cfg.start_time = override(cfg.start_time, args.start_time)
|
||||||
|
cfg.end_time = override(cfg.end_time, args.end_time)
|
||||||
|
cfg.log_level = override(cfg.log_level, args.log_level.upper() if args.log_level else None)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
class OwnTracksMessage(dict):
|
||||||
|
"""Typed helper for OwnTracks location message."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_point(point: gpxpy.gpx.GPXTrackPoint, *,
|
||||||
|
default_acc: float = 10.0,
|
||||||
|
include_alt: bool = True,
|
||||||
|
vel: Optional[float] = None,
|
||||||
|
tid: Optional[str] = None) -> "OwnTracksMessage":
|
||||||
|
if not point.time:
|
||||||
|
raise ValueError("GPX point missing timestamp")
|
||||||
|
# Ensure timezone-aware UTC
|
||||||
|
if point.time.tzinfo is None:
|
||||||
|
# assume input is UTC naive
|
||||||
|
t_utc = point.time.replace(tzinfo=dt.timezone.utc)
|
||||||
|
else:
|
||||||
|
t_utc = point.time.astimezone(dt.timezone.utc)
|
||||||
|
tst = int(t_utc.timestamp())
|
||||||
|
|
||||||
|
msg: OwnTracksMessage = OwnTracksMessage({
|
||||||
|
"_type": "location",
|
||||||
|
"tst": tst,
|
||||||
|
"lat": float(point.latitude),
|
||||||
|
"lon": float(point.longitude),
|
||||||
|
"acc": float(default_acc),
|
||||||
|
"conn": "w",
|
||||||
|
})
|
||||||
|
if include_alt and point.elevation is not None and not math.isnan(point.elevation):
|
||||||
|
msg["alt"] = float(point.elevation)
|
||||||
|
if vel is not None and not math.isnan(vel):
|
||||||
|
msg["vel"] = float(max(0.0, vel))
|
||||||
|
if tid:
|
||||||
|
if len(tid) != 2:
|
||||||
|
raise ValueError("tid must be exactly 2 characters, e.g. 'AL'")
|
||||||
|
msg["tid"] = tid
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class OutputSink:
|
||||||
|
mode: str
|
||||||
|
jsonl_path: Optional[Path] = None
|
||||||
|
base_dir: Optional[Path] = None
|
||||||
|
http_url: Optional[str] = None
|
||||||
|
http_auth: Optional[Tuple[str, str]] = None
|
||||||
|
http_verify: bool = True
|
||||||
|
rate_limit_per_sec: Optional[float] = None
|
||||||
|
user: Optional[str] = None
|
||||||
|
device: Optional[str] = None
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
if self.mode == "jsonl" and self.jsonl_path and not self.dry_run:
|
||||||
|
self.jsonl_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._jsonl_file = self.jsonl_path.open("w", encoding="utf-8")
|
||||||
|
else:
|
||||||
|
self._jsonl_file = None
|
||||||
|
|
||||||
|
if self.mode == "http":
|
||||||
|
if requests is None:
|
||||||
|
raise ConfigError("requests not available; install it or use jsonl/files mode")
|
||||||
|
if not self.http_url:
|
||||||
|
raise ConfigError("HTTP mode requires http_url")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if hasattr(self, "_jsonl_file") and self._jsonl_file:
|
||||||
|
self._jsonl_file.close()
|
||||||
|
|
||||||
|
def _rate_limit(self):
|
||||||
|
if self.rate_limit_per_sec and self.rate_limit_per_sec > 0:
|
||||||
|
time.sleep(1.0 / self.rate_limit_per_sec)
|
||||||
|
|
||||||
|
def write(self, msg: OwnTracksMessage):
|
||||||
|
if self.mode == "jsonl":
|
||||||
|
line = json.dumps(msg, separators=(",", ":"))
|
||||||
|
if self.dry_run:
|
||||||
|
logging.debug(line)
|
||||||
|
else:
|
||||||
|
assert self._jsonl_file is not None
|
||||||
|
self._jsonl_file.write(line + "\n")
|
||||||
|
|
||||||
|
elif self.mode == "files":
|
||||||
|
if not (self.base_dir and self.user and self.device):
|
||||||
|
raise ConfigError("files mode requires files_base_dir, user, and device")
|
||||||
|
if "tst" not in msg:
|
||||||
|
raise ValueError("message missing tst")
|
||||||
|
t = dt.datetime.fromtimestamp(int(msg["tst"]), tz=dt.timezone.utc)
|
||||||
|
out_dir = self.base_dir / self.user / self.device / t.strftime("%Y/%m/%d")
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path = out_dir / f"{int(msg['tst'])}.json"
|
||||||
|
if self.dry_run:
|
||||||
|
logging.info("Would write %s", out_path)
|
||||||
|
else:
|
||||||
|
with out_path.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(msg, f, separators=(",", ":"))
|
||||||
|
|
||||||
|
elif self.mode == "http":
|
||||||
|
if not self.http_url:
|
||||||
|
raise ConfigError("HTTP mode requires http_url")
|
||||||
|
if self.dry_run:
|
||||||
|
logging.info("Would POST to %s: %s", self.http_url, msg)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
self.http_url,
|
||||||
|
json=msg,
|
||||||
|
auth=self.http_auth,
|
||||||
|
timeout=15,
|
||||||
|
verify=self.http_verify,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("HTTP POST failed: %s", e)
|
||||||
|
raise
|
||||||
|
self._rate_limit()
|
||||||
|
else:
|
||||||
|
raise ConfigError(f"Unknown mode: {self.mode}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time(s: Optional[str]) -> Optional[dt.datetime]:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Allow 'Z' suffix
|
||||||
|
if s.endswith("Z"):
|
||||||
|
s = s.replace("Z", "+00:00")
|
||||||
|
return dt.datetime.fromisoformat(s)
|
||||||
|
except Exception as e:
|
||||||
|
raise ConfigError(f"Invalid ISO8601 time '{s}': {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def compute_speed_m_s(prev: gpxpy.gpx.GPXTrackPoint, cur: gpxpy.gpx.GPXTrackPoint) -> Optional[float]:
|
||||||
|
if not prev.time or not cur.time:
|
||||||
|
return None
|
||||||
|
t1 = prev.time
|
||||||
|
t2 = cur.time
|
||||||
|
if t1.tzinfo is None:
|
||||||
|
t1 = t1.replace(tzinfo=dt.timezone.utc)
|
||||||
|
if t2.tzinfo is None:
|
||||||
|
t2 = t2.replace(tzinfo=dt.timezone.utc)
|
||||||
|
dt_s = (t2 - t1).total_seconds()
|
||||||
|
if dt_s <= 0:
|
||||||
|
return 0.0
|
||||||
|
# Haversine distance in meters
|
||||||
|
R = 6371000.0
|
||||||
|
phi1 = math.radians(prev.latitude)
|
||||||
|
phi2 = math.radians(cur.latitude)
|
||||||
|
dphi = phi2 - phi1
|
||||||
|
dlambda = math.radians(cur.longitude - prev.longitude)
|
||||||
|
a = math.sin(dphi/2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda/2)**2
|
||||||
|
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||||
|
d_m = R * c
|
||||||
|
return d_m / dt_s
|
||||||
|
|
||||||
|
|
||||||
|
def iter_gpx_points(gpx: gpxpy.gpx.GPX, start: Optional[dt.datetime], end: Optional[dt.datetime]) -> Iterable[gpxpy.gpx.GPXTrackPoint]:
|
||||||
|
for trk in gpx.tracks:
|
||||||
|
for seg in trk.segments:
|
||||||
|
for pt in seg.points:
|
||||||
|
if start and pt.time and (pt.time if pt.time.tzinfo else pt.time.replace(tzinfo=dt.timezone.utc)) < start:
|
||||||
|
continue
|
||||||
|
if end and pt.time and (pt.time if pt.time.tzinfo else pt.time.replace(tzinfo=dt.timezone.utc)) >= end:
|
||||||
|
continue
|
||||||
|
yield pt
|
||||||
|
|
||||||
|
|
||||||
|
def build_arg_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Convert GPX track to OwnTracks messages and output to JSONL, files, or HTTP.",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
)
|
||||||
|
p.add_argument("gpx", help="Input .gpx file")
|
||||||
|
p.add_argument("--config", help="Path to YAML config")
|
||||||
|
|
||||||
|
p.add_argument("--user", help="OwnTracks user")
|
||||||
|
p.add_argument("--device", help="OwnTracks device")
|
||||||
|
p.add_argument("--tid", help="2-character tracker id (e.g. AL)")
|
||||||
|
p.add_argument("--acc", type=float, help="Default accuracy (meters)")
|
||||||
|
p.add_argument("--include-alt", dest="include_alt", action=argparse.BooleanOptionalAction, default=None,
|
||||||
|
help="Include altitude if available")
|
||||||
|
p.add_argument("--calc-speed", dest="calc_speed", action=argparse.BooleanOptionalAction, default=None,
|
||||||
|
help="Calculate speed between points (m/s)")
|
||||||
|
p.add_argument("--assume-timezone", help="Timezone to assume if GPX times are naive (e.g. UTC, Europe/Warsaw)")
|
||||||
|
|
||||||
|
p.add_argument("--mode", choices=["jsonl", "files", "http"], help="Output mode")
|
||||||
|
p.add_argument("--jsonl-path", help="Output JSONL path")
|
||||||
|
p.add_argument("--files-base-dir", help="Recorder store base dir for files mode")
|
||||||
|
|
||||||
|
p.add_argument("--http-url", help="Recorder /pub URL for HTTP mode")
|
||||||
|
p.add_argument("--http-user", help="HTTP basic auth username")
|
||||||
|
p.add_argument("--http-pass", help="HTTP basic auth password")
|
||||||
|
p.add_argument("--verify", dest="verify", action=argparse.BooleanOptionalAction, default=None,
|
||||||
|
help="Verify TLS certificate in HTTP mode")
|
||||||
|
p.add_argument("--rate", type=float, help="Max posts per second (HTTP mode)")
|
||||||
|
|
||||||
|
p.add_argument("--start-time", help="Filter: start time ISO8601 inclusive (e.g. 2025-08-01T00:00:00Z)")
|
||||||
|
p.add_argument("--end-time", help="Filter: end time ISO8601 exclusive (e.g. 2025-09-01T00:00:00Z)")
|
||||||
|
|
||||||
|
p.add_argument("--dry-run", action="store_true", help="Do not write/POST, just validate and log actions")
|
||||||
|
p.add_argument("--log-level", default=None, help="Logging level (DEBUG, INFO, WARNING, ERROR)")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
|
args = build_arg_parser().parse_args(argv)
|
||||||
|
|
||||||
|
base_cfg = load_config(args.config)
|
||||||
|
cfg = merge_cli_over_config(base_cfg, args)
|
||||||
|
|
||||||
|
log_level = getattr(logging, cfg.log_level.upper(), logging.INFO)
|
||||||
|
logging.basicConfig(level=log_level, format="%(levelname)s %(message)s")
|
||||||
|
|
||||||
|
# Basic validations
|
||||||
|
in_path = Path(args.gpx)
|
||||||
|
if not in_path.exists():
|
||||||
|
logging.error("Input GPX not found: %s", in_path)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if cfg.mode not in {"jsonl", "files", "http"}:
|
||||||
|
logging.error("Invalid mode: %s", cfg.mode)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if cfg.mode == "jsonl" and not cfg.jsonl_path:
|
||||||
|
logging.error("jsonl mode requires --jsonl-path or jsonl_path in config")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if cfg.mode == "files" and (not cfg.files_base_dir or not cfg.user or not cfg.device):
|
||||||
|
logging.error("files mode requires --files-base-dir, --user, and --device")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if cfg.mode == "http" and not cfg.http_url:
|
||||||
|
logging.error("http mode requires --http-url or http.url in config")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if cfg.tid and len(cfg.tid) != 2:
|
||||||
|
logging.error("--tid must be exactly 2 characters")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Parse time filters
|
||||||
|
start = parse_time(cfg.start_time)
|
||||||
|
end = parse_time(cfg.end_time)
|
||||||
|
|
||||||
|
# Load GPX
|
||||||
|
try:
|
||||||
|
with in_path.open("r", encoding="utf-8") as f:
|
||||||
|
gpx = gpxpy.parse(f)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Failed to parse GPX: %s", e)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Prepare sink
|
||||||
|
sink = OutputSink(
|
||||||
|
mode=cfg.mode,
|
||||||
|
jsonl_path=Path(cfg.jsonl_path) if cfg.jsonl_path else None,
|
||||||
|
base_dir=Path(cfg.files_base_dir) if cfg.files_base_dir else None,
|
||||||
|
http_url=cfg.http_url,
|
||||||
|
http_auth=(cfg.http_username, cfg.http_password) if cfg.http_username or cfg.http_password else None,
|
||||||
|
http_verify=cfg.http_tls_verify,
|
||||||
|
rate_limit_per_sec=cfg.rate_limit_per_sec,
|
||||||
|
user=cfg.user,
|
||||||
|
device=cfg.device,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sink.open()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Failed to open output sink: %s", e)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Iterate points and emit
|
||||||
|
count = 0
|
||||||
|
prev_pt: Optional[gpxpy.gpx.GPXTrackPoint] = None
|
||||||
|
try:
|
||||||
|
for pt in iter_gpx_points(gpx, start, end):
|
||||||
|
try:
|
||||||
|
vel = compute_speed_m_s(prev_pt, pt) if (cfg.calc_speed and prev_pt) else None
|
||||||
|
msg = OwnTracksMessage.from_point(
|
||||||
|
pt,
|
||||||
|
default_acc=cfg.default_acc,
|
||||||
|
include_alt=cfg.include_alt,
|
||||||
|
vel=vel,
|
||||||
|
tid=cfg.tid,
|
||||||
|
)
|
||||||
|
sink.write(msg)
|
||||||
|
count += 1
|
||||||
|
prev_pt = pt
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning("Skipping point due to error: %s", e)
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
sink.close()
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
logging.warning("No points exported.")
|
||||||
|
else:
|
||||||
|
logging.info("Exported %d points via mode='%s'", count, cfg.mode)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Interrupted", file=sys.stderr)
|
||||||
|
sys.exit(130)
|
||||||
6
gpx2owntracks.sh
Executable file
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
python gpx2owntracks.py $1 \
|
||||||
|
--jsonl-path test/rec/$(basename $1 | cut -d '.' -f 1).jsonl \
|
||||||
|
--user owntracksusr --device oskar --tid AL
|
||||||
|
|
||||||
25
gpx2owntracks_simple.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import gpxpy
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
with open("track.gpx", "r") as f:
|
||||||
|
gpx = gpxpy.parse(f)
|
||||||
|
|
||||||
|
points = []
|
||||||
|
for track in gpx.tracks:
|
||||||
|
for segment in track.segments:
|
||||||
|
for point in segment.points:
|
||||||
|
ot_point = {
|
||||||
|
"_type": "location",
|
||||||
|
"tst": int(time.mktime(point.time.timetuple())),
|
||||||
|
"lat": point.latitude,
|
||||||
|
"lon": point.longitude,
|
||||||
|
"alt": point.elevation,
|
||||||
|
"acc": 10
|
||||||
|
}
|
||||||
|
points.append(ot_point)
|
||||||
|
|
||||||
|
with open("track.json", "w") as out:
|
||||||
|
for p in points:
|
||||||
|
out.write(json.dumps(p) + "\n")
|
||||||
|
|
||||||
29
immich_geotag_from_owntracks.sh
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#/bin/bash
|
||||||
|
|
||||||
|
# set -x
|
||||||
|
|
||||||
|
photoDir="$1"
|
||||||
|
runCond=${2:-'no'}
|
||||||
|
|
||||||
|
# Najpierw test bez zapisu:
|
||||||
|
python3 immich_geotag_from_owntracks.py \
|
||||||
|
--photos $photoDir \
|
||||||
|
--json /home/pi/own-tracks/owntracks/store/rec/owntracksusr/oskar \
|
||||||
|
--json-glob "*.rec" \
|
||||||
|
--json-max-age-secs 1000 \
|
||||||
|
--recursive --dry-run
|
||||||
|
|
||||||
|
if [ $runCond != 'run' ]; then
|
||||||
|
echo "Dry run done, exiting"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
# JSON -> GPX -> geotag
|
||||||
|
python3 immich_geotag_from_owntracks.py \
|
||||||
|
--photos $photoDir \
|
||||||
|
--json /home/pi/own-tracks/owntracks/store/rec/owntracksusr/oskar/ \
|
||||||
|
--recursive \
|
||||||
|
--interpolate-secs 180 \
|
||||||
|
--time-offset "+00:00" \
|
||||||
|
--write inplace
|
||||||
|
fi
|
||||||
|
|
||||||
15
immich_geotag_from_owntracks_final.sh
Executable file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#/bin/bash
|
||||||
|
|
||||||
|
set -x
|
||||||
|
|
||||||
|
photoDir="$1"
|
||||||
|
|
||||||
|
python3 immich_geotag_from_owntracks_final.py \
|
||||||
|
--photos $photoDir \
|
||||||
|
--json /home/pi/own-tracks/owntracks/store/rec/owntracksusr/oskar \
|
||||||
|
--json-glob "*.rec" \
|
||||||
|
--recursive --dry-run --verbose
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
11
publich2owntracks.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -x
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
curl -sS -X POST \
|
||||||
|
-u "owntracksusr:X0Hpj5Sh0XXDTn9q-VlY1zQ5kQcHQZZO" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data "$line" \
|
||||||
|
'https://owntracks.okit.pl/pub?u=owntracksusr&d=oskar'
|
||||||
|
done < $1
|
||||||
21
readme.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#1
|
||||||
|
|
||||||
|
get gpx
|
||||||
|
|
||||||
|
|
||||||
|
#2
|
||||||
|
|
||||||
|
make jsonl:
|
||||||
|
➜ immich_owntracks_enricher ./gpx2owntracks.sh test/gpx/activity_20019940630.gpx
|
||||||
|
|
||||||
|
|
||||||
|
#3
|
||||||
|
|
||||||
|
➜ immich_owntracks_enricher ./publich2owntracks.sh test/rec/activity_20019940630.jsonl
|
||||||
|
|
||||||
|
|
||||||
|
#4
|
||||||
|
|
||||||
|
tag photos
|
||||||
|
./immich_geotag_from_owntracks_final.sh test/photos
|
||||||
|
|
||||||
39835
test/gpx/activity_20019929984.gpx
Normal file
13159
test/gpx/activity_20019940630.gpx
Normal file
88179
test/gpx/activity_20041233195.gpx
Normal file
26029
test/gpx/activity_20048683771.gpx
Normal file
59789
test/gpx/activity_20048770620.gpx
Normal file
71841
test/gpx/activity_20064166866.gpx
Normal file
17819
test/gpx/activity_20064167357.gpx
Normal file
2243
test/gpx/activity_20094406083.gpx
Normal file
14549
test/gpx/activity_20095434537.gpx
Normal file
1091
test/gpx/activity_20105416875.gpx
Normal file
|
Before Width: | Height: | Size: 9.3 MiB After Width: | Height: | Size: 9.3 MiB |
BIN
test/photos/5c121efc-b8b0-42e0-b985-4b2c577fe26a.jpg_original
Normal file
|
After Width: | Height: | Size: 9.3 MiB |
1314
test/rec/.jsonl
Normal file
3982
test/rec/activity_20019929984.jsonl
Normal file
BIN
test_local/photos/5c121efc-b8b0-42e0-b985-4b2c577fe26a.jpg
Normal file
|
After Width: | Height: | Size: 9.3 MiB |
BIN
test_local/photos/64141f44-a825-493a-b9ea-54878de56f03.jpg
Normal file
|
After Width: | Height: | Size: 10 MiB |