1   /* Copyright 2002-2026 CS GROUP
2    * Licensed to CS GROUP (CS) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * CS licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *   http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.orekit.gnss.metric.ntrip;
18  
19  import java.io.BufferedReader;
20  import java.io.IOException;
21  import java.io.InputStream;
22  import java.io.InputStreamReader;
23  import java.net.Authenticator;
24  import java.net.HttpURLConnection;
25  import java.net.InetAddress;
26  import java.net.InetSocketAddress;
27  import java.net.Proxy;
28  import java.net.Proxy.Type;
29  import java.net.SocketAddress;
30  import java.net.URI;
31  import java.net.URISyntaxException;
32  import java.net.URL;
33  import java.net.URLConnection;
34  import java.net.UnknownHostException;
35  import java.nio.charset.StandardCharsets;
36  import java.util.ArrayList;
37  import java.util.Formatter;
38  import java.util.HashMap;
39  import java.util.List;
40  import java.util.Locale;
41  import java.util.Map;
42  import java.util.concurrent.ExecutorService;
43  import java.util.concurrent.Executors;
44  import java.util.concurrent.TimeUnit;
45  import java.util.concurrent.atomic.AtomicReference;
46  
47  import org.hipparchus.util.FastMath;
48  import org.orekit.errors.OrekitException;
49  import org.orekit.errors.OrekitMessages;
50  import org.orekit.frames.Frame;
51  import org.orekit.gnss.metric.messages.ParsedMessage;
52  import org.orekit.time.TimeScales;
53  
54  /** Source table for ntrip streams retrieval.
55   * <p>
56   * Note that all authentication is performed automatically by just
57   * calling the standard {@link Authenticator#setDefault(Authenticator)}
58   * method to set up an authenticator.
59   * </p>
60   * @author Luc Maisonobe
61   * @since 11.0
62   */
63  public class NtripClient {
64  
65      /** Default timeout for connections and reads (ms). */
66      public static final int DEFAULT_TIMEOUT = 10000;
67  
68      /** Default port for ntrip communication. */
69      public static final int DEFAULT_PORT = 2101;
70  
71      /** Default delay before we reconnect after connection close (s). */
72      public static final double DEFAULT_RECONNECT_DELAY = 1.0;
73  
74      /** Default factor by which reconnection delay is multiplied after each attempt. */
75      public static final double DEFAULT_RECONNECT_DELAY_FACTOR = 1.5;
76  
77      /** Default maximum number of reconnect a attempts without readin any data. */
78      public static final int DEFAULT_MAX_RECONNECT = 20;
79  
80      /** Host header. */
81      private static final String HOST_HEADER_KEY = "Host";
82  
83      /** User-agent header key. */
84      private static final String USER_AGENT_HEADER_KEY = "User-Agent";
85  
86      /** User-agent header value. */
87      private static final String USER_AGENT_HEADER_VALUE = "NTRIP orekit/11.0";
88  
89      /** Version header key. */
90      private static final String VERSION_HEADER_KEY = "Ntrip-Version";
91  
92      /** Version header value. */
93      private static final String VERSION_HEADER_VALUE = "Ntrip/2.0";
94  
95      /** Connection header key. */
96      private static final String CONNECTION_HEADER_KEY = "Connection";
97  
98      /** Connection header value. */
99      private static final String CONNECTION_HEADER_VALUE = "close";
100 
101     /** Flags header key. */
102     private static final String FLAGS_HEADER_KEY = "Ntrip-Flags";
103 
104     /** Content type for source table. */
105     private static final String SOURCETABLE_CONTENT_TYPE = "gnss/sourcetable";
106 
107     /** Degrees to arc minutes conversion factor. */
108     private static final double DEG_TO_MINUTES = 60.0;
109 
110     /** Caster host. */
111     private final String host;
112 
113     /** Caster port. */
114     private final int port;
115 
116     /** Delay before we reconnect after connection close. */
117     private double reconnectDelay;
118 
119     /** Multiplication factor for reconnection delay. */
120     private double reconnectDelayFactor;
121 
122     /** Max number of reconnections. */
123     private int maxRetries;
124 
125     /** Timeout for connections and reads. */
126     private int timeout;
127 
128     /** Proxy to use. */
129     private Proxy proxy;
130 
131     /** NMEA GGA sentence (may be null). */
132     private final AtomicReference<String> gga;
133 
134     /** Observers for encoded messages. */
135     private final List<ObserverHolder> observers;
136 
137     /** Monitors for data streams. */
138     private final Map<String, StreamMonitor> monitors;
139 
140     /** Source table. */
141     private SourceTable sourceTable;
142 
143     /** Executor for stream monitoring tasks. */
144     private ExecutorService executorService;
145 
146     /** Known time scales.
147      * @since 13.0
148      */
149     private final TimeScales timeScales;
150 
151     /** Reference inertial frame.
152      * @since 14.0
153      */
154     private final Frame inertial;
155 
156     /** Body fixed frame.
157      * @since 14.0
158      */
159     private final Frame bodyFixed;
160 
161     /** Build a client for NTRIP.
162      * <p>
163      * The default configuration uses default timeout, default reconnection
164      * parameters, no GPS fix and no proxy.
165      * </p>
166      * @param host caster host providing the source table
167      * @param port port to use for connection
168      * @param timeScales known time scales
169      * @param maxRetries maximum number of reconnect attempts without reading any data
170      * @param inertial reference inertial frame
171      * @param bodyFixed body fixed frame (will be frozen at {@code date} to build the orbital elements
172      * @since 14.0
173      * see {@link #DEFAULT_PORT}
174      */
175     public NtripClient(final String host, final int port, final TimeScales timeScales,
176                        final int maxRetries, final Frame inertial, final Frame bodyFixed) {
177         this.host         = host;
178         this.port         = port;
179         this.observers    = new ArrayList<>();
180         this.monitors     = new HashMap<>();
181         setTimeout(DEFAULT_TIMEOUT);
182         setReconnectParameters(DEFAULT_RECONNECT_DELAY,
183                                DEFAULT_RECONNECT_DELAY_FACTOR,
184                                maxRetries);
185         setProxy(Type.DIRECT, null, -1);
186         this.gga             = new AtomicReference<>(null);
187         this.sourceTable     = null;
188         this.executorService = null;
189         this.timeScales      = timeScales;
190         this.inertial        = inertial;
191         this.bodyFixed       = bodyFixed;
192     }
193 
194     /** Get the caster host.
195      * @return caster host
196      */
197     public String getHost() {
198         return host;
199     }
200 
201     /** Get the port to use for connection.
202      * @return port to use for connection
203      */
204     public int getPort() {
205         return port;
206     }
207 
208     /** Get the known time scales.
209      * @return known time scales
210      * @since 13.0
211      */
212     public TimeScales getTimeScales() {
213         return timeScales;
214     }
215 
216     /** Set timeout for connections and reads.
217      * @param timeout timeout for connections and reads (ms)
218      */
219     public void setTimeout(final int timeout) {
220         this.timeout = timeout;
221     }
222 
223     /** Set Reconnect parameters.
224      * @param delay delay before we reconnect after connection close
225      * @param delayFactor factor by which reconnection delay is multiplied after each attempt
226      * @param max max number of reconnect attempts without reading any data
227      */
228     public void setReconnectParameters(final double delay,
229                                        final double delayFactor,
230                                        final int max) {
231         this.reconnectDelay       = delay;
232         this.reconnectDelayFactor = delayFactor;
233         this.maxRetries           = max;
234     }
235 
236     /** Set proxy parameters.
237      * @param type proxy type
238      * @param proxyHost host name of the proxy (ignored if {@code type} is {@code Proxy.Type.DIRECT})
239      * @param proxyPort port number of the proxy (ignored if {@code type} is {@code Proxy.Type.DIRECT})
240      */
241     public void setProxy(final Proxy.Type type, final String proxyHost, final int proxyPort) {
242         try {
243             if (type == Proxy.Type.DIRECT) {
244                 // disable proxy
245                 proxy = Proxy.NO_PROXY;
246             } else {
247                 // enable proxy
248                 final InetAddress   hostAddress  = InetAddress.getByName(proxyHost);
249                 final SocketAddress proxyAddress = new InetSocketAddress(hostAddress, proxyPort);
250                 proxy = new Proxy(type, proxyAddress);
251             }
252         } catch (UnknownHostException uhe) {
253             throw new OrekitException(uhe, OrekitMessages.UNKNOWN_HOST, proxyHost);
254         }
255     }
256 
257     /** Get proxy.
258      * @return proxy to use
259      */
260     public Proxy getProxy() {
261         return proxy;
262     }
263 
264     /** Set GPS fix data to send as NMEA sentence to Ntrip caster if required.
265      * @param hour hour of the fix (UTC time)
266      * @param minute minute of the fix (UTC time)
267      * @param second second of the fix (UTC time)
268      * @param latitude latitude (radians)
269      * @param longitude longitude (radians)
270      * @param ellAltitude altitude above ellipsoid (m)
271      * @param undulation height of the geoid above ellipsoid (m)
272      */
273     public void setFix(final int hour, final int minute, final double second,
274                        final double latitude, final double longitude, final double ellAltitude,
275                        final double undulation) {
276 
277         // convert latitude
278         final double latDeg = FastMath.abs(FastMath.toDegrees(latitude));
279         final int    dLat   = (int) FastMath.floor(latDeg);
280         final double mLat   = DEG_TO_MINUTES * (latDeg - dLat);
281         final char   cLat   = latitude >= 0.0 ? 'N' : 'S';
282 
283         // convert longitude
284         final double lonDeg = FastMath.abs(FastMath.toDegrees(longitude));
285         final int    dLon   = (int) FastMath.floor(lonDeg);
286         final double mLon   = DEG_TO_MINUTES * (lonDeg - dLon);
287         final char   cLon   = longitude >= 0.0 ? 'E' : 'W';
288 
289         // build NMEA GGA sentence
290         final StringBuilder builder = new StringBuilder(82);
291         try (Formatter formatter = new Formatter(builder, Locale.US)) {
292 
293             // dummy values
294             final int    fixQuality = 1;
295             final int    nbSat      = 4;
296             final double hdop       = 1.0;
297 
298             // sentence body
299             formatter.format("$GPGGA,%02d%02d%06.3f,%02d%07.4f,%c,%02d%07.4f,%c,%1d,%02d,%3.1f,%.1f,M,%.1f,M,,",
300                              hour, minute, second,
301                              dLat, mLat, cLat, dLon, mLon, cLon,
302                              fixQuality, nbSat, hdop,
303                              ellAltitude, undulation);
304 
305             // checksum
306             byte sum = 0;
307             for (int i = 1; i < builder.length(); ++i) {
308                 sum ^= builder.charAt(i);
309             }
310             formatter.format("*%02X", sum);
311 
312         }
313         gga.set(builder.toString());
314 
315     }
316 
317     /** Get NMEA GGA sentence.
318      * @return NMEA GGA sentence (may be null)
319      */
320     String getGGA() {
321         return gga.get();
322     }
323 
324     /** Add an observer for an encoded messages.
325      * <p>
326      * If messages of the specified type have already been retrieved from
327      * a stream, the observer will be immediately notified with the last
328      * message from each mount point (in unspecified order) as a side effect
329      * of being added.
330      * </p>
331      * @param typeCode code for the message type (if set to 0, notification
332      * will be triggered regardless of message type)
333      * @param mountPoint mountPoint from which data must come (if null, notification
334      * will be triggered regardless of mount point)
335      * @param observer observer for this message type
336      */
337     public void addObserver(final int typeCode, final String mountPoint,
338                             final MessageObserver observer) {
339 
340         // store the observer for future monitored mount points
341         observers.add(new ObserverHolder(typeCode, mountPoint, observer));
342 
343         // check if we should also add it to already monitored mount points
344         for (Map.Entry<String, StreamMonitor> entry : monitors.entrySet()) {
345             if (mountPoint == null || mountPoint.equals(entry.getKey())) {
346                 entry.getValue().addObserver(typeCode, observer);
347             }
348         }
349 
350     }
351 
352     /** Get a sourcetable.
353      * @return source table from the caster
354      */
355     public SourceTable getSourceTable() {
356         if (sourceTable == null) {
357             try {
358 
359                 // perform request
360                 final HttpURLConnection connection = connect("");
361 
362                 final int responseCode = connection.getResponseCode();
363                 if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) {
364                     throw new OrekitException(OrekitMessages.FAILED_AUTHENTICATION, "caster");
365                 } else if (responseCode != HttpURLConnection.HTTP_OK) {
366                     throw new OrekitException(OrekitMessages.CONNECTION_ERROR, host, connection.getResponseMessage());
367                 }
368 
369                 // for this request, we MUST get a source table
370                 if (!SOURCETABLE_CONTENT_TYPE.equals(connection.getContentType())) {
371                     throw new OrekitException(OrekitMessages.UNEXPECTED_CONTENT_TYPE, connection.getContentType());
372                 }
373 
374                 final SourceTable table = new SourceTable(getHeaderValue(connection, FLAGS_HEADER_KEY));
375 
376                 // parse source table records
377                 try (InputStream is = connection.getInputStream();
378                      InputStreamReader isr = new InputStreamReader(is, StandardCharsets.UTF_8);
379                      BufferedReader br = new BufferedReader(isr)) {
380                     int lineNumber = 0;
381                     for (String line = br.readLine(); line != null; line = br.readLine()) {
382 
383                         ++lineNumber;
384                         line = line.trim();
385                         if (line.isEmpty()) {
386                             continue;
387                         }
388 
389                         if (line.startsWith(RecordType.CAS.toString())) {
390                             table.addCasterRecord(new CasterRecord(line));
391                         } else if (line.startsWith(RecordType.NET.toString())) {
392                             table.addNetworkRecord(new NetworkRecord(line));
393                         } else if (line.startsWith(RecordType.STR.toString())) {
394                             table.addDataStreamRecord(new DataStreamRecord(line));
395                         } else if (line.startsWith("ENDSOURCETABLE")) {
396                             // we have reached end of table
397                             break;
398                         } else {
399                             throw new OrekitException(OrekitMessages.SOURCETABLE_PARSE_ERROR,
400                                                       connection.getURL().getHost(), lineNumber, line);
401                         }
402 
403                     }
404                 }
405 
406                 sourceTable = table;
407                 return table;
408 
409             } catch (IOException | URISyntaxException e) {
410                 throw new OrekitException(e, OrekitMessages.CANNOT_PARSE_SOURCETABLE, host);
411             }
412         }
413 
414         return sourceTable;
415 
416     }
417 
418     /** Connect to a mount point and start streaming data from it.
419      * <p>
420      * This method sets up an internal dedicated thread for continuously
421      * monitoring data incoming from a mount point. When new complete
422      * {@link ParsedMessage parsed messages} becomes available, the
423      * {@link MessageObserver observers} that have been registered
424      * using {@link #addObserver(int, String, MessageObserver) addObserver()}
425      * method will be notified about the message.
426      * </p>
427      * <p>
428      * This method must be called once for each stream to monitor.
429      * </p>
430      * @param mountPoint mount point providing the stream
431      * @param type messages type of the mount point
432      * @param requiresNMEA if true, the mount point requires a NMEA GGA sentence in the request
433      * @param ignoreUnknownMessageTypes if true, unknown messages types are silently ignored
434      */
435     public void startStreaming(final String mountPoint, final org.orekit.gnss.metric.ntrip.Type type,
436                                final boolean requiresNMEA, final boolean ignoreUnknownMessageTypes) {
437 
438         if (executorService == null) {
439             // lazy creation of executor service, with one thread for each possible data stream
440             executorService = Executors.newFixedThreadPool(getSourceTable().getDataStreams().size());
441         }
442 
443         // safety check
444         if (monitors.containsKey(mountPoint)) {
445             throw new OrekitException(OrekitMessages.MOUNPOINT_ALREADY_CONNECTED, mountPoint);
446         }
447 
448         // create the monitor
449         final StreamMonitor monitor = new StreamMonitor(this, mountPoint, type, requiresNMEA, ignoreUnknownMessageTypes,
450                                                         reconnectDelay, reconnectDelayFactor, maxRetries,
451                                                         inertial, bodyFixed);
452         monitors.put(mountPoint, monitor);
453 
454         // set up the already known observers
455         for (final ObserverHolder observerHolder : observers) {
456             if (observerHolder.mountPoint == null ||
457                 observerHolder.mountPoint.equals(mountPoint)) {
458                 monitor.addObserver(observerHolder.typeCode, observerHolder.observer);
459             }
460         }
461 
462         // start streaming data
463         executorService.execute(monitor);
464 
465     }
466 
467     /** Check if any of the streaming thread has thrown an exception.
468      * <p>
469      * If a streaming thread has thrown an exception, it will be rethrown here
470      * </p>
471      */
472     public void checkException() {
473         // check if any of the stream got an exception
474         for (final  Map.Entry<String, StreamMonitor> entry : monitors.entrySet()) {
475             final OrekitException exception = entry.getValue().getException();
476             if (exception != null) {
477                 throw exception;
478             }
479         }
480     }
481 
482     /** Stop streaming data from all connected mount points.
483      * <p>
484      * If an exception was encountered during data streaming, it will be rethrown here
485      * </p>
486      * @param time timeout for waiting underlying threads termination (ms)
487      */
488     public void stopStreaming(final int time) {
489 
490         // ask all monitors to stop retrieving data
491         for (final  Map.Entry<String, StreamMonitor> entry : monitors.entrySet()) {
492             entry.getValue().stopMonitoring();
493         }
494 
495         try {
496             // wait for proper ending
497             executorService.shutdown();
498             executorService.awaitTermination(time, TimeUnit.MILLISECONDS);
499         } catch (InterruptedException ie) {
500             // Restore interrupted state...
501             Thread.currentThread().interrupt();
502         }
503 
504         checkException();
505 
506     }
507 
508     /** Connect to caster.
509      * @param mountPoint mount point (empty for getting sourcetable)
510      * @return performed connection
511      * @throws IOException if an I/O exception occurs during connection
512      * @throws URISyntaxException if the built URI is invalid
513      */
514     HttpURLConnection connect(final String mountPoint)
515         throws IOException, URISyntaxException {
516 
517         // set up connection
518         final String scheme = "http";
519         final URL casterURL = new URI(scheme, null, host, port, "/" + mountPoint, null, null).toURL();
520         final HttpURLConnection connection = (HttpURLConnection) casterURL.openConnection(proxy);
521         connection.setConnectTimeout(timeout);
522         connection.setReadTimeout(timeout);
523 
524         // common headers
525         connection.setRequestProperty(HOST_HEADER_KEY,       host);
526         connection.setRequestProperty(VERSION_HEADER_KEY,    VERSION_HEADER_VALUE);
527         connection.setRequestProperty(USER_AGENT_HEADER_KEY, USER_AGENT_HEADER_VALUE);
528         connection.setRequestProperty(CONNECTION_HEADER_KEY, CONNECTION_HEADER_VALUE);
529 
530         return connection;
531 
532     }
533 
534     /** Get an header from a response.
535      * @param connection connection to analyze
536      * @param key header key
537      * @return header value
538      */
539     private String getHeaderValue(final URLConnection connection, final String key) {
540         final String value = connection.getHeaderField(key);
541         if (value == null) {
542             throw new OrekitException(OrekitMessages.MISSING_HEADER,
543                                       connection.getURL().getHost(), key);
544         }
545         return value;
546     }
547 
548     /** Local holder for observers. */
549     private static class ObserverHolder {
550 
551         /** Code for the message type. */
552         private final int typeCode;
553 
554         /** Mount point. */
555         private final String mountPoint;
556 
557         /** Observer to notify. */
558         private final MessageObserver observer;
559 
560         /** Simple constructor.
561          * @param typeCode code for the message type
562          * @param mountPoint mountPoint from which data must come (if null, notification
563          * will be triggered regardless of mount point)
564          * @param observer observer for this message type
565          */
566         ObserverHolder(final int typeCode, final String mountPoint,
567                             final MessageObserver observer) {
568             this.typeCode   = typeCode;
569             this.mountPoint = mountPoint;
570             this.observer   = observer;
571         }
572 
573     }
574 
575 }