summaryrefslogtreecommitdiff
path: root/lib/postrunner/EPO_Downloader.rb
blob: 0aa3fd991af17d1782910f0f2bfca2348d468975 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
#!/usr/bin/env ruby -w
# encoding: UTF-8
#
# = EPO_Downloader.rb -- PostRunner - Manage the data from your Garmin sport devices.
#
# Copyright (c) 2015 by Chris Schlaeger <cs@taskjuggler.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of version 2 of the GNU General Public License as
# published by the Free Software Foundation.
#

require 'uri'
require 'net/http'

module PostRunner

  # This class can download the current set of Extended Prediction Orbit data
  # for GPS satellites and store them in the EPO.BIN file format. Some Garmin
  # devices pick up this file under GARMIN/GARMIN/REMOTESW/EPO.BIN.
  class EPO_Downloader

    @@URI = URI('http://omt.garmin.com/Rce/ProtobufApi/EphemerisService/GetEphemerisData')
    # This is the payload of the POST request. It was taken from
    # http://www.kluenter.de/garmin-ephemeris-files-and-linux/. It may contain
    # a product ID or serial number.
    @@POST_DATA = "\n-\n\aexpress\u0012\u0005de_DE\u001A\aWindows\"\u0012601 Service Pack 1\u0012\n\b\x8C\xB4\x93\xB8\u000E\u0012\u0000\u0018\u0000\u0018\u001C\"\u0000"
    @@HEADER = {
      'Garmin-Client-Name' => 'CoreService',
      'Content-Type' => 'application/octet-stream',
      'Content-Length' => '63'
    }

    # Create an EPO_Downloader object.
    def initialize
      @http = Net::HTTP.new(@@URI.host, @@URI.port)
      @request = Net::HTTP::Post.new(@@URI.path, initheader = @@HEADER)
      @request.body = @@POST_DATA
    end

    # Download the current EPO data from the Garmin server and write it into
    # the specified output file.
    # @param output_file [String] The name of the output file. Usually this is
    #        'EPO.BIN'.
    def download(output_file)
      return false unless (epo = get_epo_from_server)
      return false unless (epo = fix(epo))
      return false unless check_epo_data(epo)
      write_file(output_file, epo)
      Log.info "Extended Prediction Orbit (EPO) data has been downloaded " +
               "from Garmin site."

      true
    end

    private

    def get_epo_from_server
      res = @http.request(@request)
      if res.code.to_i != 200
        Log.error "Extended Orbit Prediction (EPO) data download failed: #{res}"
        return nil
      end
      res.body
    end

    # The downloaded data contains Extended Prediction Orbit data for 6 hour
    # windows for 7 days. Each EPO set is 2307 bytes long, but the first 3
    # bytes must be removed for the FR620 to understand it.
    # https://forums.garmin.com/showthread.php?79555-when-will-garmin-express-mac-be-able-to-sync-GPS-EPO-bin-file-on-fenix-2&p=277398#post277398
    # The 2304 bytes consist of 32 sets of 72 byte GPS satellite data.
    # http://www.vis-plus.ee/pdf/SIM28_SIM68R_SIM68V_EPO-II_Protocol_V1.00.pdf
    def fix(epo)
      unless epo.length == 28 * 2307
        Log.error "GPS data has unexpected length of #{epo.length} bytes"
        return nil
      end

      epo_fixed = ''
      0.upto(27) do |i|
        offset = i * 2307
        # The fill bytes always seem to be 0. Let's issue a warning in case
        # this ever changes.
        unless epo[offset].to_i == 0 &&
               epo[offset + 1].to_i == 0 &&
               epo[offset + 2].to_i == 0
          Log.warning "EPO fill bytes are not 0 bytes"
        end
        epo_fixed += epo[offset + 3, 2304]
      end

      epo_fixed
    end

    def check_epo_data(epo)
      # Convert EPO string into Array of bytes.
      epo = epo.bytes.to_a
      unless epo.length == 28 * 72 * 32
        Log.error "EPO file has wrong length (#{epo.length})"
        return false
      end
      date_1980_01_01 = Time.parse("1980-01-01T00:00:00+00:00")
      now = Time.now
      largest_date = nil
      # Split the EPO data into Arrays of 32 * 72 bytes.
      epo.each_slice(32 * 72).to_a.each do |epo_set|
        # For each of the 32 satellites we have 72 bytes of data.
        epo_set.each_slice(72).to_a.each do |sat|
          # The last byte is an XOR checksum of the first 71 bytes.
          xor = 0
          0.upto(70) { |i| xor ^= sat[i] }
          unless xor == sat[71]
            Log.error "Checksum error in EPO file"
            return false
          end
          # The first 3 bytes of every satellite record look like a timestamp.
          # I assume they are hours after January 1st, 1980 UTC. They probably
          # indicate the start of the 6 hour window that the data is for.
          hours_after_1980_01_01 = sat[0] | (sat[1] << 8) | (sat[2] << 16)
          date = date_1980_01_01 + hours_after_1980_01_01 * 60 * 60
          # Either the start point (1980-01-01) is not correct or Garmin is
          # publishing data that can be up to 5 days old. We check the date
          # with some more relaxed ranges.
          if date > now + 8 * 24 * 60 * 60
            Log.warn "EPO timestamp (#{date}) is in the future"
          elsif date < now - 8 * 24 * 60 * 60
            Log.warn "EPO timestamp (#{date}) is too old"
          end
          largest_date = date if largest_date.nil? || date > largest_date
        end
      end
      Log.info "EPO data is valid until #{largest_date + 6 * 60 * 60}."

      true
    end

    def write_file(output_file, data)
      begin
        File.write(output_file, data)
      rescue IOError
        Log.fatal "Cannot write EPO file '#{output_file}': #{$!}"
      end
    end

  end

end