├── Decoder.ipynb ├── README.md ├── emailfunctions.py └── main.py /Decoder.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "id": "9264912d", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "def decoder(x,shift):\n", 11 | " chars = \"\"\"!\"#$%\\'()*+,-./:;<=>?_¡£¥¿&¤0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÄÅÆÇÉÑØøÜßÖàäåæèéìñòöùüΔΦΓΛΩΠΨΣΘΞ\"\"\"\n", 12 | " extrachars = {'@!':122,\n", 13 | " '@@':123,\n", 14 | " '@#':124,\n", 15 | " '@$':125,\n", 16 | " '@%':126,\n", 17 | " '@?':127}\n", 18 | " new_chars = chars[shift:] + chars[:shift]\n", 19 | " \n", 20 | " decoded = []\n", 21 | " counter = 0\n", 22 | " while(counter < len(x)):\n", 23 | " if x[counter] == '@':\n", 24 | " decoded.append(\"{0:07b}\".format(extrachars[x[counter:counter+2]]))\n", 25 | " counter = counter + 2\n", 26 | " else:\n", 27 | " decoded.append(\"{0:07b}\".format(new_chars.index(x[counter])))\n", 28 | " counter = counter + 1\n", 29 | " \n", 30 | " decoded = ''.join(decoded)\n", 31 | " decoded = [decoded[i:i+4] for i in range(0, len(decoded), 4)]\n", 32 | " if len(decoded[-1]) < 4:\n", 33 | " del decoded[-1]\n", 34 | " decoded = [int(i,2) for i in decoded]\n", 35 | " mag = pd.Series(decoded[:int(len(decoded)/2)])\n", 36 | " dirs = pd.Series(decoded[int(len(decoded)/2):]).reset_index(drop=True)\n", 37 | " \n", 38 | " mag = mag*5\n", 39 | " v10 = np.sin(2*np.pi*dirs/16)*mag\n", 40 | " u10 = np.cos(2*np.pi*dirs/16)*mag\n", 41 | " \n", 42 | " idx = []\n", 43 | " for h in hours:\n", 44 | " for lat in np.arange(float(maxmin[0]),float(maxmin[1]) + float(interval[0]),float(interval[0])):\n", 45 | " for lon in np.arange(float(maxmin[2]),float(maxmin[3]) + float(interval[1]),float(interval[1])):\n", 46 | " idx.append({'hour':h,'lat':lat,'lon':lon})\n", 47 | " decoded = pd.DataFrame(idx)\n", 48 | " decoded['u10'] = u10\n", 49 | " decoded['v10'] = v10\n", 50 | " \n", 51 | " return decoded.set_index(['hour','lat','lon'])" 52 | ] 53 | }, 54 | { 55 | "cell_type": "code", 56 | "execution_count": null, 57 | "id": "20d80c10", 58 | "metadata": {}, 59 | "outputs": [], 60 | "source": [ 61 | "import pandas as pd\n", 62 | "import numpy as np\n", 63 | "FILE_LOCATION = ''\n", 64 | "with open(FILE_LOCATION, encoding='utf-8') as f:\n", 65 | " received = f.read()\n", 66 | "hours = received.split('\\n')[0].split(',')\n", 67 | "gribtime = received.split('\\n')[1]\n", 68 | "maxmin = received.split('\\n')[2].split(',')\n", 69 | "interval = received.split('\\n')[3].split(',')\n", 70 | "shift = received.split('\\n')[4]\n", 71 | "df = decoder(''.join(received.split('\\n')[5:-2][::3]),1)" 72 | ] 73 | }, 74 | { 75 | "cell_type": "code", 76 | "execution_count": null, 77 | "id": "03326c38", 78 | "metadata": { 79 | "scrolled": false 80 | }, 81 | "outputs": [], 82 | "source": [ 83 | "%matplotlib notebook\n", 84 | "#import cartopy.crs as ccrs\n", 85 | "import matplotlib.pyplot as plt\n", 86 | "import geopandas\n", 87 | "world = geopandas.read_file(geopandas.datasets.get_path('naturalearth_lowres'))\n", 88 | "OUR_LOCATION = (-62.93,20.8219)\n", 89 | "WORLD_COUNTRIES_FILE_LOCATION = '' # If you want a more high resolution map\n", 90 | "#world = geopandas.read_file(\"WORLD_COUNTRIES_FILE_LOCATION\")\n", 91 | "# world = world[world['CNTRY_NAME'].isin(['Anguilla', 'Antigua & Barbuda', 'Barbados', 'Belize', 'Bermuda',\n", 92 | "# 'Canada', 'Cayman Is.', 'Clipperton Island (France)', 'Colombia',\n", 93 | "# 'Costa Rica', 'Cuba', 'Dominica', 'Dominican Republic',\n", 94 | "# 'El Salvador', 'Greenland', 'Grenada', 'Guadeloupe', 'Guatemala',\n", 95 | "# 'Haiti', 'Honduras', 'In dispute Belize/Honduras',\n", 96 | "# 'In dispute Canada/Denmark', 'In dispute El Salvador/Honduras',\n", 97 | "# 'Jamaica', 'Martinique', 'Mexico', 'Montserrat',\n", 98 | "# 'Navassa Island (US)', 'Nicaragua', 'Jarvis I.', 'Panama',\n", 99 | "# 'Puerto Rico', 'Saba (Neth)', 'Saint Barthelemy (France)',\n", 100 | "# 'St. Kitts & Nevis', 'St. Lucia', 'Saint Martin (France)',\n", 101 | "# 'St. Pierre & Miquelon', 'St. Vincent & the Grenadines',\n", 102 | "# 'Sint Eustatius (Neth)', 'Sint Maarten (Neth)', 'The Bahamas',\n", 103 | "# 'Turks & Caicos Is.', 'United States', 'British Virgin Is.',\n", 104 | "# 'Virgin Is.'])]\n", 105 | "\n", 106 | "for timepoint in df.index.get_level_values(0).unique():\n", 107 | " g = df.loc[timepoint].reset_index()\n", 108 | " #fig = plt.figure()\n", 109 | " \n", 110 | " #fig = plt.figure(figsize=(15,10))\n", 111 | " world.plot(figsize=(8,5))\n", 112 | " #ax = fig.add_subplot(1, 1, 1, projection=ccrs.LambertConformal())\n", 113 | " plt.barbs(g['lon'], g['lat'], g['u10'], g['v10'], length=6)\n", 114 | " plt.plot(OUR_LOCATION[0], OUR_LOCATION[1], marker='D',c='red')\n", 115 | " #plt.plot(-(63+0/60), 27+40/60, marker='D',c='red')\n", 116 | "\n", 117 | " plt.xlim([-64.5,-60])\n", 118 | " plt.ylim([17.5,22])\n", 119 | " plt.title((pd.to_datetime(gribtime) + pd.Timedelta(hours=int(timepoint))).tz_localize('UTC').tz_convert('US/Eastern'))\n", 120 | " #plt.show()\n", 121 | " plt.grid(True)\n", 122 | " #, np.sqrt(g['u10']**2 + g['v10']**2), cmap='rainbow'" 123 | ] 124 | } 125 | ], 126 | "metadata": { 127 | "kernelspec": { 128 | "display_name": "Python 3 (ipykernel)", 129 | "language": "python", 130 | "name": "python3" 131 | }, 132 | "language_info": { 133 | "codemirror_mode": { 134 | "name": "ipython", 135 | "version": 3 136 | }, 137 | "file_extension": ".py", 138 | "mimetype": "text/x-python", 139 | "name": "python", 140 | "nbconvert_exporter": "python", 141 | "pygments_lexer": "ipython3", 142 | "version": "3.9.13" 143 | } 144 | }, 145 | "nbformat": 4, 146 | "nbformat_minor": 5 147 | } 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GRIB-via-inReach 2 | I wrote this code to send GRIB wind data over a Garmin inReach during a 14-day passage at sea. It worked great, and I had a comfortable 1600 mile passage from Norfolk to St. Martin. While there are definitely advantages to WeatherFax and IridiumGo, this method for receiving weather data was effective and relatively cheap. 3 | 4 | I have received a lot of interest in this, so I am hoping to share my code so that it may be used by fellow sailors and improved by more skilled programmers! 5 | 6 | If you'd like to buy me a beer, my venmo is @FinbarC. 7 | 8 | ### DISCLAIMER 9 | 10 | I tested and wrote this code within a matter of a few days, thus there may be BUGS and INEFFICIENCIES which I have not resolved. I do not have a software engineering background, so consider this hodgepodge coding! If you use this software, there are some quirks which you need to be aware of. Please read this entire document and walk through the code. I haven't tested this since November 2022. 11 | 12 | **OFFSHORE SAILING IS A SERIOUS AND DANGEROUS UNDERTAKING AND ACCURATE WEATHER DATA IS CRITICALLY IMPORTANT. YOU USE THIS SOFTWARE ARE YOUR OWN RISK.** 13 | 14 | ## OVERVIEW 15 | 16 | This system allows users of the Garmin inReach satellite SMS device to receive on-demand geospatial wind forecasts. Over the inReach, the user sends a request to their email address and specifies the desired location, times, and weather model. This is done according to the Sail Docs GRIB format. The code of main.py is constantly checking the aforementioned email for GRIB requests, using the Gmail API. This could be done on a local computer or a cloud-based python service (I used Python Anywhere). When a request is received, it is forwarded to the Sail Docs service, which replies with a GRIB file. The GRIB file is parsed, compressed, and encoded so that it may be sent over multiple SMS messages. These messages are sent over Garmin's online reply service. When they arrive on the inReach, the user copies the texts to their computer or phone, and uses the Decoder iPython notebook to render the geospatial wind data on a map. 17 | 18 | ## REQUESTING GRIB 19 | 20 | To request a GRIB, the inReach user sends a properly formatted string to the email address associated with the service. This string is formatted according to the Sail Docs specification (http://www.saildocs.com/gribinfo). Here is an example string: 21 | 22 | ``` 23 | gfs:24n,34n,72w,60w|2,2|12,24,36,48|wind 24 | ``` 25 | 26 | This would request data according to the GFS model (the ECWMF model can also be used, but I found Sail Docs responded cleaner with GFS), lattitudes 24N - 34N, longitudes 72W - 60W, each at 2 degree intervals, with points in time 12, 24, 36, and 48 hours, and only wind data (all this is designed for). 27 | 28 | It is important to not request too big of an area or too many time points. The above string (4 points in time, 5 x 6 grid) was sent over 3 texts. Thus, to be practical, this system needs to be used with the unlimited Garmin inReach subscription. 29 | 30 | ## RECEIVING SERVICE 31 | 32 | I used Python Anywhere (http://www.pythonanywhere.com) to run main.py. Unfortunately, I lost information about how I created the Python environment, but I believe it used the following dependencies, which can be installed via pip. 33 | 34 | - xarray (0.20.2) 35 | - cfgrib (0.9.10.2) 36 | - Gmail API according to: https://developers.google.com/people/quickstart/python 37 | 38 | The Gmail API needs to be setup and a credentials.json file downloaded. This file location must be specified in emailfunctions.py. The path to save the token file and any attachments needs to be specified there as well, in addition to the email address. 39 | 40 | In main.py, the email also needs to be specified as well as the filepath for tracking message IDs. 41 | 42 | With main.py running constantly on a local or cloud-based service, when a properly formatted request is received, it is forwarded to Sail Docs. Sail Docs should respond promptly with the appropriate GRIB file. Occassionally, it will respond with slightly different lattitude/longitudes (35N - 30N instead of 34N - 30N, or 24.999999N instead of 25N). This is why the lattitude and longitude of the RECEIVED GRIB (not what was requested) needs to be sent back to the user, as a contingency for it being wildly different. 43 | 44 | The GRIB file is processed and compressed. The wind speed magnitude is compressed to 5kt intervals, and maxes out at 75kts. The wind direction is compressed to 16 directions. In my opinion, this is granular enough to make accurate route planning decisions. This data is then converted to binary and encoded according to allowed SMS characters. 45 | 46 | **NOTE:** For some reason, which I was unable to figure out, Garmin has trouble sending certain (seemingly random) character combinations. For example, I could not send ">f" (if I recall correctly) anywhere in the message. I did not determine all of the forbidden combinations. As a work around, if a message fails to send, the encoding scheme is shifted by one character and the entire process is restarted. This is the SHIFT parameter. Occassionally, it would fail a few times. Thus, if the GRIB is sent over 4 texts, 3 may be received before the last one fails. Then, the process is restarted and another 4 messages are sent with the encoding shifted. Once the message with "END" is received, the user knows the sending process was completed successfully. 47 | 48 | **NOTE:** There was an additional unresolved problem where Garmin would cut out the messages randomly around the 130-140 character mark. As a work around, I've limited each message to 120 characters and marked each message with a beginning signifyer and end signifyer (both are the message number), to indicate to the user that the entire message was sent and not cut off. 49 | 50 | To send the data, the Python requests module is used with Garmin's web based replying service. I had no trouble reusing the same messageID over and over. Maybe some Garmin data engineer will be cursing my name in a few months. I do not know if they've updated their website or replying service since then. 51 | 52 | Here is an example of what would be sent from the aforementioned request (explanatory comments denoted with <---): 53 | 54 | **Message 1** 55 | ``` 56 | 12,24,36,48 <--- Time points 57 | 2022-11-17 12:00:00 <--- Model run time (Zulu time) 58 | 24.0,36.0,-72.0,-58.0 <--- Latitude and longitude 59 | 2.0,2.0 <--- Lat/lon intervals 60 | 1 <--- The encoding shift 61 | >+g9&g8>>'6>"'ä7>+CP¤oäP¤t+Q8>-x>+æO&gA8>'8>+gæ>,+g8&gi8 <--- Data 62 | 0 <--- Indicates end of the first message 63 | ``` 64 | 65 | **Message 2** 66 | ``` 67 | 1 <--- Indicates beginning of second message 68 | >'eO>+8?>+æP7+A8¤g8>,+æ6+gA7=gA8+g6_¤oe#>/æg¤+gP¤gA8>,)7+gg8>"A>+ke8>+æ8g8>¤pÑΔ+o@@AOQ54">>1o"kèØΣcgZΘ@@É8XÑΓ+fÑöΘgFñhH0 69 | 1 <--- Indicates end of second message 70 | ``` 71 | 72 | **Message 3** 73 | ``` 74 | 2 75 | zΛ>:¤ÑøgjÑΣSØ5ΘbÇÑg8>¤o7ΦΞoC?ΘY8xM>:zXågjüèLaM+"'Sg8>¤g4>*gåüΘfØP&8¤xHÑ'èoæP0<@?c5@? 76 | END <--- Indicates end of the transmission 77 | 2 78 | ``` 79 | 80 | ## DECODER 81 | 82 | There is no easy way to access recieved inReach texts from a computer. What I did was I connected my Android phone to the inReach via the EarthMate app. Then, I accessed my Android screen from my computer with Scrcpy (https://github.com/Genymobile/scrcpy). The text in the EarthMate app is not copyable, but if you attempt to "Forward" each text, it is then copyable in the text box (a ridiculous work around, I hope there is a better way to do it!). I copied each message into a .txt file, which might then look like: 83 | 84 | ``` 85 | 12,24,36,48 86 | 2022-11-17 12:00:00 87 | 24.0,36.0,-72.0,-58.0 88 | 2.0,2.0 89 | 1 90 | >+g9&g8>>'6>"'ä7>+CP¤oäP¤t+Q8>-x>+æO&gA8>'8>+gæ>,+g8&gi8 91 | 0 92 | FW: 1 93 | >'eO>+8?>+æP7+A8¤g8>,+æ6+gA7=gA8+g6_¤oe#>/æg¤+gP¤gA8>,)7+gg8>"A>+ke8>+æ8g8>¤pÑΔ+o@@AOQ54">>1o"kèØΣcgZΘ@@É8XÑΓ+fÑöΘgFñhH0 94 | 1 95 | FW: 2 96 | zΛ>:¤ÑøgjÑΣSØ5ΘbÇÑg8>¤o7ΦΞoC?ΘY8xM>:zXågjüèLaM+"'Sg8>¤g4>*gåüΘfØP&8¤xHÑ'èoæP0<@?c5@? 97 | END 98 | 2 99 | ``` 100 | 101 | This .txt file is specified and processed through the Decoder.ipynb notebook and the wind data is rendered on a map. The user's current location can be marked. Higher resolution maps can also be downloaded and used. For each point in time, a map will be displayed as follows: 102 | 103 | ![image](https://user-images.githubusercontent.com/41167102/235323713-8fc52550-401d-4bbf-b5bd-ec1af6ec1059.png) 104 | -------------------------------------------------------------------------------- /emailfunctions.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pickle 3 | # Gmail API utils 4 | from googleapiclient.discovery import build 5 | from google_auth_oauthlib.flow import InstalledAppFlow 6 | from google.auth.transport.requests import Request 7 | # for encoding/decoding messages in base64 8 | from base64 import urlsafe_b64decode, urlsafe_b64encode 9 | # for dealing with attachement MIME types 10 | from email.mime.text import MIMEText 11 | from email.mime.multipart import MIMEMultipart 12 | from email.mime.image import MIMEImage 13 | from email.mime.audio import MIMEAudio 14 | from email.mime.base import MIMEBase 15 | from mimetypes import guess_type as guess_mime_type 16 | 17 | # Request all access (permission to read/send/receive emails, manage the inbox, and more) 18 | SCOPES = ['https://mail.google.com/'] 19 | our_email = '' # Your email 20 | TOKEN_PATH = '' # Path where you want to save token file. 21 | CREDENTIALS_PATH = '' # This is the location of the downloaded Gmail credentials. 22 | FILE_PATH = '' # Where you want to save attachment files. 23 | 24 | def gmail_authenticate(): 25 | creds = None 26 | # the file token.pickle stores the user's access and refresh tokens, and is 27 | # created automatically when the authorization flow completes for the first time 28 | if os.path.exists(TOKEN_PATH): 29 | with open(TOKEN_PATH, "rb") as token: 30 | creds = pickle.load(token) 31 | # if there are no (valid) credentials availablle, let the user log in. 32 | if not creds or not creds.valid: 33 | if creds and creds.expired and creds.refresh_token: 34 | creds.refresh(Request()) 35 | else: 36 | flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_PATH, SCOPES) 37 | creds = flow.run_local_server(port=0) 38 | # save the credentials for the next run 39 | with open(TOKEN_PATH, "wb") as token: 40 | pickle.dump(creds, token) 41 | return build('gmail', 'v1', credentials=creds) 42 | 43 | def build_message(destination, obj, body, attachments=[]): 44 | if not attachments: # no attachments given 45 | message = MIMEText(body) 46 | message['to'] = destination 47 | message['from'] = our_email 48 | message['subject'] = obj 49 | else: 50 | message = MIMEMultipart() 51 | message['to'] = destination 52 | message['from'] = our_email 53 | message['subject'] = obj 54 | message.attach(MIMEText(body)) 55 | for filename in attachments: 56 | add_attachment(message, filename) 57 | return {'raw': urlsafe_b64encode(message.as_bytes()).decode()} 58 | 59 | def send_message(service, destination, obj, body, attachments=[]): 60 | return service.users().messages().send( 61 | userId="me", 62 | body=build_message(destination, obj, body, attachments) 63 | ).execute() 64 | 65 | def search_messages(service, query): 66 | result = service.users().messages().list(userId='me',q=query).execute() 67 | messages = [ ] 68 | if 'messages' in result: 69 | messages.extend(result['messages']) 70 | while 'nextPageToken' in result: 71 | page_token = result['nextPageToken'] 72 | result = service.users().messages().list(userId='me',q=query, pageToken=page_token).execute() 73 | if 'messages' in result: 74 | messages.extend(result['messages']) 75 | return messages 76 | 77 | import base64 78 | 79 | def GetAttachments(service, msg_id, user_id='me'): 80 | """Get and store attachment from Message with given id. 81 | 82 | :param service: Authorized Gmail API service instance. 83 | :param user_id: User's email address. The special value "me" can be used to indicate the authenticated user. 84 | :param msg_id: ID of Message containing attachment. 85 | """ 86 | try: 87 | message = service.users().messages().get(userId=user_id, id=msg_id).execute() 88 | 89 | for part in message['payload']['parts']: 90 | if part['filename']: 91 | if 'data' in part['body']: 92 | data = part['body']['data'] 93 | else: 94 | att_id = part['body']['attachmentId'] 95 | att = service.users().messages().attachments().get(userId=user_id, messageId=msg_id,id=att_id).execute() 96 | data = att['data'] 97 | file_data = base64.urlsafe_b64decode(data.encode('UTF-8')) 98 | path = part['filename'] 99 | 100 | with open(FILE_PATH + path, 'wb') as f: 101 | f.write(file_data) 102 | return FILE_PATH + path 103 | 104 | except: 105 | print('An error occurred: %s' % error) 106 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | print('Starting import', flush=True) 2 | import emailfunctions 3 | from base64 import urlsafe_b64decode, urlsafe_b64encode 4 | import time 5 | from datetime import datetime 6 | import pandas as pd 7 | import xarray as xr 8 | import cfgrib 9 | import pandas as pd 10 | import numpy as np 11 | import time 12 | print('Import finished', flush=True) 13 | 14 | LIST_OF_PREVIOUS_MESSAGES_FILE_LOCATION = "" 15 | YOUR_EMAIL = "" 16 | 17 | 18 | def processGrib(path): 19 | grib = xr.open_dataset(path).to_dataframe() # Was easiest for me to process the grib as a pandas dataframe. 20 | 21 | timepoints = grib.index.get_level_values(0).unique() 22 | latmin = grib.index.get_level_values(1).unique().min() 23 | latmax = grib.index.get_level_values(1).unique().max() 24 | 25 | lonmin = grib.index.get_level_values(2).unique().min() 26 | lonmax = grib.index.get_level_values(2).unique().max() 27 | latdiff = pd.Series(grib.index.get_level_values(1).unique()).diff().dropna().round(6).unique() # This is the difference between each lat/lon point. It was mainly used for debugging. 28 | londiff = pd.Series(grib.index.get_level_values(2).unique()).diff().dropna().round(6).unique() 29 | 30 | if len(latdiff) > 1 or len(londiff) > 1: 31 | print('Irregular point separations!', flush=True) 32 | 33 | gribtime = grib['time'].iloc[0] 34 | 35 | mag = (np.sqrt(grib['u10']**2 + grib['v10']**2)*1.94384/5).round().astype('int').clip(upper=15).apply(lambda x: "{0:04b}".format(x)).str.cat() 36 | # This grabs the U-component and V-component of wind speed, calculates the magnitude in kts, rounds to the nearest 5kt speed, and converts to binary. 37 | dirs = (((round(np.arctan2(grib['v10'],grib['u10']) / (2 * np.pi / 16))) + 16) % 16).astype('int').apply(lambda x: "{0:04b}".format(x)).str.cat() 38 | # This encodes the wind direction into 16 cardinal directions and converts to vinary. 39 | 40 | import os 41 | os.remove(path) 42 | return mag + dirs, timepoints, latmin, latmax, lonmin, lonmax, latdiff, londiff, gribtime 43 | 44 | def messageCreator(bin_data, timepoints, latmin, latmax, lonmin, lonmax, latdiff, londiff, gribtime, shift): 45 | # This function encodes the grib binary data into characters that can be sent over the inReach. 46 | chars = """!"#$%\'()*+,-./:;<=>?_¡£¥¿&¤0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÄÅÆÇÉÑØøÜßÖàäåæèéìñòöùüΔΦΓΛΩΠΨΣΘΞ""" # Allowed inReach characters. 47 | extrachars = {122:'@!', 48 | 123:'@@', 49 | 124:'@#', 50 | 125:'@$', 51 | 126:'@%', 52 | 127:'@?'} # To get a full range of 128 code possibilities, these are extra two character codes. 53 | 54 | def encoder(x, shift): # This encodes the binary 8-bit chunks into the coding scheme based on the SHIFT. 55 | if len(x) < 7: 56 | x = x + '0'*(7-len(x)) 57 | new_chars = chars[shift:] + chars[:shift] 58 | dec = int(x,2) 59 | if dec < 122: 60 | return new_chars[dec] 61 | else: 62 | return extrachars[dec] 63 | 64 | encoded = '' 65 | for piece in [bin_data[i:i+7] for i in range(0, len(bin_data), 7)]: # This sends the binary chunks to the encoder. 66 | encoded = encoded + encoder(piece,shift) 67 | 68 | # This forms the message that will be sent. I wanted times and lat/long to be explicitly written for debugging purposes but these could improved. 69 | gribmessage = """{times} 70 | {iss} 71 | {minmax} 72 | {diff} 73 | {shift} 74 | {data} 75 | END""".format(times=",".join((timepoints/ np.timedelta64(1, 'h')).astype('int').astype('str').to_list()), 76 | iss=str(gribtime), 77 | minmax=','.join(str(x) for x in [latmin,latmax,lonmin,lonmax]), 78 | diff=str(latdiff[0])+","+str(londiff[0]), 79 | shift = shift, 80 | data=encoded) 81 | msg_len = 120 # Had problems with the messages being cutoff, even though they shouldn't have been according to Garmin's specifications. 120 character messages seemed like a safe bet. 82 | message_parts = [gribmessage[i:i+msg_len] for i in range(0, len(gribmessage), msg_len)] # Breaks up the big message into individual parts to send. 83 | return [str(i) + '\n' + message_parts[i] + '\n' + str(i) if i > 0 else message_parts[i] + '\n' + str(i) for i in range(len(message_parts))] 84 | 85 | def inreachReply(url,message_str): 86 | # This uses the requests module to send a spoofed response to Garmin. I found no trouble reusing the MessageId over and over again but I do not know if there are risks with this. 87 | # I tried to use the same GUID from the specific incoming garmin email. 88 | import requests 89 | 90 | cookies = { 91 | 'BrowsingMode': 'Desktop', 92 | } 93 | 94 | headers = { 95 | 'authority': 'explore.garmin.com', 96 | 'accept': '*/*', 97 | 'accept-language': 'en-US,en;q=0.9', 98 | 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', 99 | # 'cookie': 'BrowsingMode=Desktop', 100 | 'origin': 'https://explore.garmin.com', 101 | 'referer': url, 102 | 'sec-ch-ua': '"Chromium";v="106", "Not;A=Brand";v="99", "Google Chrome";v="106.0.5249.119"', 103 | 'sec-ch-ua-mobile': '?0', 104 | 'sec-ch-ua-platform': '"Windows"', 105 | 'sec-fetch-dest': 'empty', 106 | 'sec-fetch-mode': 'cors', 107 | 'sec-fetch-site': 'same-origin', 108 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36', 109 | 'x-requested-with': 'XMLHttpRequest', 110 | } 111 | 112 | data = { 113 | 'ReplyAddress': YOUR_EMAIL, 114 | 'ReplyMessage': message_str, 115 | 'MessageId': '479947347', 116 | 'Guid': url.split('extId=')[1].split('&adr')[0], 117 | } 118 | 119 | response = requests.post('https://explore.garmin.com/TextMessage/TxtMsg', cookies=cookies, headers=headers, data=data) 120 | if response.status_code != 200: 121 | print('Could not send!', flush=True) 122 | else: 123 | print('Sent', flush=True) 124 | return response 125 | 126 | def answerService(message_id): 127 | msg = service.users().messages().get(userId='me', id=message_id).execute() 128 | msg_text = urlsafe_b64decode(msg['payload']['body']['data']).decode().split('\r')[0].lower() 129 | url = [x.replace('\r','') for x in urlsafe_b64decode(msg['payload']['body']['data']).decode().split('\n') if 'https://explore.garmin.com' in x][0] # Grabs the unique Garmin URL for answering. 130 | 131 | if msg_text[:5] == 'ecmwf' or msg_text[:3] == 'gfs': # Only allows for ECMWF or GFS model 132 | emailfunctions.send_message(service, "query@saildocs.com", "", "send " + msg_text) # Sends message to saildocs according to their formatting. 133 | time_sent = datetime.utcnow() 134 | valid_response = False 135 | 136 | for i in range(60): # Waits for reply and makes sure reply received aligns with request (there's probably a better way to do this). 137 | time.sleep(10) 138 | last_response = emailfunctions.search_messages(service,"query-reply@saildocs.com")[0] 139 | time_received = pd.to_datetime(service.users().messages().get(userId='me', id=last_response['id']).execute()['payload']['headers'][-1]['value'].split('(UTC)')[0]) 140 | if time_received > time_sent: 141 | valid_response = True 142 | break 143 | 144 | if valid_response: 145 | try: 146 | grib_path = emailfunctions.GetAttachments(service, last_response['id']) 147 | except: 148 | inreachReply(url, "Could not download attachment") 149 | return 150 | bin_data, timepoints, latmin, latmax, lonmin, lonmax, latdiff, londiff, gribtime = processGrib(grib_path) 151 | 152 | for shift in range(1,10): # Due to Garmin's inability to send certain character combinations (such as ">f" if I recall), this shift attempts to try different encoding schemes. 153 | # If the message fails to send, the characters are shifted over by one and it's attempted again. 154 | message_parts = messageCreator(bin_data, timepoints, latmin, latmax, lonmin, lonmax, latdiff, londiff, gribtime, shift) 155 | for i in message_parts: 156 | print(i, flush=True) 157 | for part in message_parts: 158 | res = inreachReply(url, part) # Attempt to send each part of the message. 159 | if res.status_code != 200: 160 | time.sleep(10) 161 | if part == message_parts[0]: 162 | break 163 | else: 164 | inreachReply(url, 'Message failed attempting shift') # If it couldn't be sent, entire process is restarted. 165 | # This could be improved, by maybe not restarting the entire process and indicating that the shift has changed. 166 | break 167 | time.sleep(10) 168 | if res.status_code == 200: 169 | break 170 | else: 171 | inreachReply(url, "Saildocs timeout") 172 | return False 173 | else: 174 | inreachReply(url, "Invalid model") 175 | return False 176 | 177 | def checkMail(): 178 | ### This function checks the email inbox for Garmin inReach messages. I tried to account for multiple messages. 179 | global service 180 | service = emailfunctions.gmail_authenticate() 181 | results = emailfunctions.search_messages(service,"no.reply.inreach@garmin.com") 182 | 183 | inreach_msgs = [] 184 | for result in results: 185 | inreach_msgs.append(result['id']) 186 | 187 | with open(LIST_OF_PREVIOUS_MESSAGES_FILE_LOCATION) as f: # This is a running list of previous inReach messages that have already been responded to. 188 | previous = f.read() 189 | 190 | unanswered = [message for message in inreach_msgs if message not in previous.split('\n')] 191 | for message_id in unanswered: 192 | try: 193 | answerService(message_id) 194 | except Exception as e: 195 | print(e, flush=True) 196 | with open(LIST_OF_PREVIOUS_MESSAGES_FILE_LOCATION, 'a') as file: # Whether answering was a success or failure, add message to list. 197 | file.write('\n'+message_id) 198 | 199 | print('Starting loop') 200 | while(True): 201 | time.sleep(60) 202 | print('Checking...', flush=True) 203 | checkMail() 204 | time.sleep(240) 205 | --------------------------------------------------------------------------------