Peter Hoffmann

Garmin Inreach Mini 2 Leaflet checkin map

We will be trekking the eastern part of the Great Himalaya Trail in Nepal in March/April. Details on the route and our plans can be found at https://greathimalayatrail.de. Our intent is to keep friends and family updated on our progress. Given that we'll be hiking in quite remote areas, a satellite phone/pager will be our sole means of communication.

After the Garmin inReach Mini 3 was released recently, the Inreach Mini 2 was on heavy sale. The inReach Mini 2 has all the features I need: satellite messaging, check-ins, offline mode with navigation, and track recording.

Plans

I'm on the Garmin Essential plan for 18 euros per month. It includes 50 free text messages or weather requests each month, plus unlimited check-in messages. The smaller Enabled plan (10 Euros) is missing the unlimited checkins, while the the Standard plan (34 Euros) gives you 150 free messages and unlimited live tracking. More details are on the Garmin page

Messaging

There are three different type of messages that you can send:

Check-In Messages: There are three preset messages. You can configure the recipients at explore.garmin.com. Depending on your Garmin subscription, sending check-in messages are free of charge. In the configuration section, you can enable the option to include your latitude/longitude and a link to the Garmin map in each SMS message. This information is always included for email recipients

Quick Messages: You can create up to 20 predefined messages so you don’t have to type them while you’re on the trail. The number of free messages you get depends on your Garmin subscription; any additional messages are billed per use. You can create or edit these messages at explore.garmin.com.

Normal Messages: In the Garmin Messenger iPhone app, you can type any custom message and send it to both SMS and email recipients. These messages are billed the same way as quick messages.

You can configure the system to send all messages to any email/sms recipients. The great thing is that the unlimited check-in messages also include latitude/longitude information. Here is a sample message.

Arrived at Camp

View the location or send a reply to Peter Hoffmann:
https://inreachlink.com/<unique_code>

Peter Hoffmann sent this message from: Lat 48.996386 Lon 8.468849

Do not reply directly to this message.

This message was sent to you using the inReach two-way satellite communicator with GPS. To learn more, visit http://explore.garmin.com/inreach.

As we do not want to spam all our friends with daily checkins I have build a little leaflet-checkin plugin and an imap scraper to pull and visualize the checkin/messages.

Build your own Tracking with Check-In Messages

For battery life reasons, we are not interested in real-time live tracking.
Instead, I’ve created a small script that checks a dedicated IMAP email account for check-in messages and publishes them to a server, which then displays the location of our most recent check-in. Sending a check-in once a day or during each break when we are in more remote areas—should give our friends enough information in case any problems arise.

A straightforward Python script connects to my IMAP server, retrieves all emails from the Garmin InReach service, parses the message, timestamp, and latitude/longitude, and then updates a positions.json file on my webserver.

Then a simple static html file with a leaflet map pulls the positions.json file and displays the messages/checkins on the map.

A demo of the map is available at:

https://hoffmann.github.io/garmin-inreach-checkin-map/html/map.html

and you can checkout the code

https://github.com/hoffmann/garmin-inreach-checkin-map

#!/usr/bin/env python3
"""Poll IMAP inbox for Garmin inReach emails and extract positions into positions.json."""

import email
import email.utils
import imaplib
import json
import os
import re
import sys
from datetime import datetime, timezone

BOILERPLATE_PREFIXES = (
    "View the location",
    "Do not reply",
    "This message was sent",
)

POSITIONS_FILE = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "positions.json"
)


def connect(host, user, password):
    imap = imaplib.IMAP4_SSL(host)
    imap.login(user, password)
    return imap


def search_inreach_emails(imap):
    imap.select("INBOX")
    status, data = imap.search(
        None, '(OR FROM "no.reply.inreach@garmin.com" SUBJECT "inReach message")'
    )
    if status != "OK":
        return []
    msg_ids = data[0].split()
    return msg_ids


def get_text_body(msg):
    if msg.is_multipart():
        for part in msg.walk():
            if part.get_content_type() == "text/plain":
                charset = part.get_content_charset() or "utf-8"
                return part.get_payload(decode=True).decode(charset)
    else:
        charset = msg.get_content_charset() or "utf-8"
        return msg.get_payload(decode=True).decode(charset)
    return ""


def parse_timestamp(msg):
    date_str = msg.get("Date")
    if not date_str:
        return None
    dt = email.utils.parsedate_to_datetime(date_str)
    dt_utc = dt.astimezone(timezone.utc)
    return dt_utc.strftime("%Y-%m-%dT%H:%M:%SZ")


def parse_body(body):
    lines = body.strip().splitlines()

    # Extract message: first non-empty line
    message = ""
    for line in lines:
        stripped = line.strip()
        if stripped:
            message = stripped
            break

    # Check if the message is boilerplate
    if any(message.startswith(prefix) for prefix in BOILERPLATE_PREFIXES):
        message = ""

    # Extract lat/lon
    lat, lon = None, None
    m = re.search(r"Lat\s+([-\d.]+)\s+Lon\s+([-\d.]+)", body)
    if m:
        lat = float(m.group(1))
        lon = float(m.group(2))

    return message, lat, lon


def parse_email(msg_data):
    msg = email.message_from_bytes(msg_data)

    timestamp = parse_timestamp(msg)
    if not timestamp:
        return None

    body = get_text_body(msg)
    if not body:
        return None

    message, lat, lon = parse_body(body)
    if lat is None or lon is None:
        return None

    entry = {
        "timestamp": timestamp,
        "lat": lat,
        "lon": lon,
    }
    if message:
        entry["msg"] = message
    

    return entry


def load_positions():
    if os.path.exists(POSITIONS_FILE):
        with open(POSITIONS_FILE) as f:
            return json.load(f)
    return []


def save_positions(positions):
    with open(POSITIONS_FILE, "w") as f:
        json.dump(positions, f, indent=2)
        f.write("\n")


def main():
    host = os.environ.get("IMAP_HOST")
    user = os.environ.get("IMAP_USER")
    password = os.environ.get("IMAP_PASSWORD")

    if not all([host, user, password]):
        print("Error: Set IMAP_HOST, IMAP_USER, and IMAP_PASSWORD environment variables.")
        sys.exit(1)

    imap = connect(host, user, password)
    try:
        msg_ids = search_inreach_emails(imap)
        print(f"Found {len(msg_ids)} inReach email(s)")

        new_entries = []
        for msg_id in msg_ids:
            status, data = imap.fetch(msg_id, "(RFC822)")
            if status != "OK":
                continue
            entry = parse_email(data[0][1])
            if entry:
                new_entries.append(entry)
    finally:
        imap.logout()

    existing = load_positions()
    existing_timestamps = {p["timestamp"] for p in existing}

    added = 0
    for entry in new_entries:
        if entry["timestamp"] not in existing_timestamps:
            existing.append(entry)
            existing_timestamps.add(entry["timestamp"])
            added += 1

    existing.sort(key=lambda p: p["timestamp"])
    save_positions(existing)

    print(f"Added {added} new position(s) ({len(existing)} total)")


if __name__ == "__main__":
    main()