From 63e1f2e41a27a79b4190568310b47267124acb2c Mon Sep 17 00:00:00 2001 From: Paul Bouchier Date: Mon, 8 Jun 2026 16:31:12 -0500 Subject: [PATCH 1/2] fix rtk2go issue --- CHANGELOG.rst | 9 +++ README.md | 114 ++++++++++++++++++++++--------- package.xml | 2 +- scripts/ntrip_ros.py | 31 +++++++-- scripts/ntrip_ros_base.py | 30 +++++--- src/ntrip_client/nmea_parser.py | 2 +- src/ntrip_client/ntrip_client.py | 105 +++++++++++++++++++++++----- 7 files changed, 228 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 07b06af..ec7ca6a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,15 @@ Changelog for package ntrip_client ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +1.5.0 (2026-06-08) +------------------ +* Fixes to make rtk2go work: configurable and lower default rate (1 Hz) for querying caster +* Support persistent reconnect attempts to cope with extended caster outages +* Change the user agent name to one that's not blocked by rtk2go +* Allow setting the default user agent name +* Python3 support +* Change NMEA_DEFAULT_MAX_LENGTH to 150 per ponderbotics + 1.4.1 (2025-04-15) ------------------ * Fix log message (`#61 `_) diff --git a/README.md b/README.md index 48b53bf..3da449d 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,19 @@ ## Description -ROS node that will communicate with an NTRP caster to receive RTCM connections and publish them on a ROS topic. Also works with virtual NTRIP servers by subscribing to NMEA -messages and sending them to the NTRIP server - -#### Important Branches -There are two important branches that you may want to checkout: - -* [ros](https://github.com/LORD-MicroStrain/ntrip_client/tree/ros) -- Contains ROS1 implementation for this node. -* [ros2](https://github.com/LORD-MicroStrain/ntrip_client/tree/ros2) -- Contains ROS2 implementation for this node. +ROS node that will communicate with an NTRIP server to receive RTCM corrections and publish them on a ROS topic. Also works with virtual/relayed NTRIP servers by subscribing to NMEA +messages and sending them to the NTRIP server. ## Build Instructions -#### Building from source -1. Install ROS2 and create a workspace: [Installing and Configuring Your ROS2 Environment](https://docs.ros.org/en/foxy/Tutorials/Configuring-ROS2-Environment.html) +It is assumed you have already installed ROS. +Build this package from source as follows: -2. Move the entire ntrip_client folder to the your_workspace/src directory. +1. Clone this repo into the your_workspace/src directory. -3. Install rosdeps for this package: `rosdep install --from-paths ~/your_workspace/src --ignore-src -r -y` +2. Install rosdeps for this package: `rosdep install --from-paths ~/your_workspace/src --ignore-src -r -y` -4. Build your workspace: +3. Build your workspace: ```bash cd ~/your_workspace colcon build @@ -28,7 +22,7 @@ There are two important branches that you may want to checkout: ``` The source command may need to be run in each terminal prior to launching a ROS node. -#### Connect to a NTRIP caster or server +## Connect to a NTRIP caster or server This is useful if you have access to an NTRIP caster or server that you want to connect to over the internet. @@ -36,21 +30,41 @@ This is useful if you have access to an NTRIP caster or server that you want to ros2 launch ntrip_client ntrip_client_launch.py ``` -Optional launch parameters: -- **host**: Hostname or IP address of the NTRIP server to connect to. +-- or override defaults from cmd line (example for rtk2go) -- + +```bash +ros2 launch ntrip_client ntrip_client_launch.py host:=rtk2go.com mountpoint:=MyRealMtPt ntrip_server_hz:=1 authenticate:=true username:=myrealemail@provider.com password:=none +``` + +Launch arguments (all overridable as `name:=value`; defaults shown): + +Connection: +- **host**: Hostname or IP address of the NTRIP server to connect to and receive corrections from - **port**: Port to connect to on the server. Default: `2101` -- **mountpoint**: Mountpoint to connect to on the NTRIP server. -- **ntrip_version**: Value to use for the `Ntrip-Version` header in the initial HTTP request to the caster. -- **authenticate**: Whether to authenticate with the server, or send an unauthenticated request. If set to true, `username`, and `password` must be supplied. -- **username**: Username to use when authenticating with the NTRIP server. Only used if `authenticate` is true -- **password**: Password to use when authenticating with the NTRIP server. Only used if `authenticate` is true -- **ssl**: Whether to connect with SSL. cert, key, and ca_cert options will only take effect if this is true -- **cert**: If the NTRIP caster is configured to use cert based authentication, you can use this option to specify the client certificate -- **key**: If the NTRIP caster is configured to use cert based authentication, you can use this option to specify the private key -- **ca_cert**: If the NTRIP caster uses self signed certs, or you need to use a different CA chain, this option can be used to specify a CA file -- **rtcm_message_packege**: Changes the type of ROS RTCM message published by this node. Defaults to `mavros_msgs`, but also supports `rtcm_msgs` +- **mountpoint**: Mountpoint to connect to on the NTRIP server +- **ntrip_version**: Value sent in the `Ntrip-Version` request header. Default: `None` (header omitted; NTRIP rev1/ICY request) +- **user_agent**: HTTP `User-Agent` sent to the caster. **Must start with `NTRIP `.** Default: `NTRIP ponderbotics_ntrip_client`. Do not use the stock `NTRIP ntrip_client_ros` — rtk2go blocks it (see [rtk2go notes](#rtk2go-and-reconnect-behavior)). + +Authentication: +- **authenticate**: Whether to authenticate with the server, or send an unauthenticated request. If `true`, `username` and `password` must be supplied. +- **username**: Username used when authenticating. For rtk2go this is your registered email. Only used if `authenticate` is true. +- **password**: Password used when authenticating. For rtk2go use `none`. Default: `none`. Only used if `authenticate` is true. + +Rate & reconnect: +- **ntrip_server_hz**: Frequency (Hz) to communicate with the NTRIP server. Some servers, like rtk2go.com, will ban you if you request data too frequently — for rtk2go use `ntrip_server_hz:=1`. Default: `10`. +- **reconnect_attempt_wait_max_seconds**: Ceiling for the exponential reconnect backoff. Reconnects are persistent (the node never gives up); raise this (e.g. `:=600`) to reduce footprint during long caster outages. Default: `120` (2-minute steady-state cadence). The starting wait (`reconnect_attempt_wait_seconds`, 10s) and `rtcm_timeout_seconds` (10s) are set in the launch file's parameter block. + +SSL (only used if `ssl:=true`): +- **ssl**: Connect to the caster over TLS. Default: `False` +- **cert** / **key**: Client certificate and key for cert-based auth. Default: `None` +- **ca_cert**: CA chain to use for self-signed casters. Default: `None` + +Output / namespacing: +- **rtcm_message_package**: ROS message package used for published RTCM. `rtcm_msgs` (publishes `rtcm_msgs/msg/Message`) or `mavros_msgs` (publishes `mavros_msgs/msg/RTCM`). Default: `rtcm_msgs`. +- **namespace** / **group** / **node_name**: Namespace, optional sub-group, and node name. +- **debug**: Enable debug-level logging. Default: `false` -#### Connect to a NTRIP "device" +## Connect to a NTRIP "device" This is useful if you do not have an internet connection, but do have an NTRIP "device" that you want to receive connections from, such as the [MicroStrain 3DM-RTK](https://www.microstrain.com/inertial-sensors/3dm-rtk). @@ -63,14 +77,50 @@ Optional launch parameters: - **baudrate**: Baudrate to connect to the serial port at. Default 115200 - **rtcm_message_packege**: Changes the type of ROS RTCM message published by this node. Defaults to `mavros_msgs`, but also supports `rtcm_msgs` -#### Topics +## Topics -This node currently only has three topics of interest: - -* **/rtcm**: This node will publish the RTCM corrections received from the server to this topic as [RTCM messages](http://docs.ros.org/en/noetic/api/mavros_msgs/html/msg/RTCM.html). These messages can be consumed by nodes such as the [microstrain_inertial_driver](https://github.com/LORD-MicroStrain/microstrain_inertial) +* **/rtcm** (publish): RTCM corrections received from the server. Message type depends on `rtcm_message_package` — `rtcm_msgs/msg/Message` by default, or `mavros_msgs/msg/RTCM`. Consumed by GNSS drivers (e.g. ublox_gps, [microstrain_inertial_driver](https://github.com/LORD-MicroStrain/microstrain_inertial)). * **NOTE**: The type of message can be switched between [`mavros_msgs/RTCM`](https://github.com/mavlink/mavros/blob/ros2/mavros_msgs/msg/RTCM.msg) and [`rtcm_msgs/Message`](https://github.com/tilk/rtcm_msgs/blob/master/msg/Message.msg) using the `rtcm_message_package` parameter -* **/nmea**: This node will subscribe on this topic and receive [NMEA sentence messages](http://docs.ros.org/en/api/nmea_msgs/html/msg/Sentence.html) which it will forward to the NTRIP server. This is always needed when using a virtual NTRIP server or an NTRIP device +* **/nmea** (subscribe): [NMEA sentence messages](http://docs.ros.org/en/api/nmea_msgs/html/msg/Sentence.html) forwarded to the NTRIP server. Needed for virtual/relayed (VRS) mountpoints. The node subscribes to the topic `/nmea`; remap it (e.g. in the launch file's `remappings`) to your NMEA source if it differs. Note: there is no `nmea_topic` launch argument — passing one has no effect. * **/fix**: This serves the same exact purpose as `/nmea`, but facilitates receiving global position that is not in NMEA format +* **/ntrip_server_hz** (publish): A `std_msgs/String` confirmation published each communication cycle, to help verify compliance with caster usage policies. + +## rtk2go and reconnect behavior + +[rtk2go.com](http://rtk2go.com) runs the SNIP caster software and enforces usage policies that this fork is tuned for: + +* **User-Agent blocking.** rtk2go maintains a block list of client signatures. The stock LORD-MicroStrain `User-Agent: NTRIP ntrip_client_ros` is blocked. A blocked client does **not** get a clear error — the caster returns a `SOURCETABLE 200 OK` response instead of the data stream, which the node logs as a sourcetable/invalid-response error. If you see a sourcetable response for a mountpoint you know is valid, suspect a client-side block, not a bad mountpoint. The default `user_agent` (`NTRIP ponderbotics_ntrip_client`) is accepted; if you change it, keep the mandatory `NTRIP ` prefix and avoid the stock string. +* **Request rate.** Use `ntrip_server_hz:=1` for rtk2go. Higher rates can get you banned. +* **Persistent reconnect.** On any connection loss or failed initial connect, the node schedules a non-blocking reconnect and retries indefinitely. The wait starts at `reconnect_attempt_wait_seconds` (10s) and doubles on each failure up to `reconnect_attempt_wait_max_seconds` (default 120s), then holds at that ceiling. It never gives up, so the node recovers on its own from extended rtk2go outages (DDoS) or a mountpoint taken down for maintenance. To shrink your footprint during long outages, raise the ceiling (e.g. `reconnect_attempt_wait_max_seconds:=600`). +* **First-connect timeout is normal.** With rtk2go the very first connect attempt frequently times out and then succeeds on the first backoff retry, even on healthy connections. This is expected. + +## Docker Integration + +### VSCode + +The easiest way to use docker while still using an IDE is to use VSCode as an IDE. Follow the steps below to develop on this repo in a docker container + +1. Install the following dependencies: + 1. [VSCode](https://code.visualstudio.com/) + 1. [Docker](https://docs.docker.com/get-docker/) +1. Open VSCode and install the following [plugins](https://code.visualstudio.com/docs/editor/extension-marketplace): + 1. [VSCode Remote Containers plugin](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) +1. Open this directory in a container by following [this guide](https://code.visualstudio.com/docs/remote/containers#_quick-start-open-an-existing-folder-in-a-container) + +### Make + +If you are comfortable working from the command line, the [Makefile](./devcontainer/Makefile) in the [.devcontainer](./devcontainer) directory +can be used to build a development image, and run a shell inside the docker image. Follow the steps below to setup your environment to use the `Makefile` + +1. Install the following dependencies: + 1. [Make](https://www.gnu.org/software/make/) + 1. [Docker](https://docs.docker.com/get-docker/) + 1. [qemu-user-static](https://packages.ubuntu.com/bionic/qemu-user-static) (for multiarch builds) + 1. Run the following command to register the qemu binaries with docker: `docker run --rm --privileged multiarch/qemu-user-static:register` + +The `Makefile` exposes the following tasks. They can all be run from the `.devcontainer` directory: +* `make build-shell` - Builds the docker image and starts a shell session in the image allowing the user to develop and build the ROS project using common commands such as `catkin_make` +* `make clean` - Cleans up after the above two tasks ## License ntrip_client is released under the MIT License - see the `LICENSE` file in the source distribution. diff --git a/package.xml b/package.xml index 922b4e7..70fb26b 100644 --- a/package.xml +++ b/package.xml @@ -1,7 +1,7 @@ ntrip_client - 1.4.1 + 1.5.0 NTRIP client that will publish RTCM corrections to a ROS topic, and optionally subscribe to NMEA messages to send to an NTRIP server Parker Hannifin Corp Rob Fisher diff --git a/scripts/ntrip_ros.py b/scripts/ntrip_ros.py index 38927e2..d53856f 100755 --- a/scripts/ntrip_ros.py +++ b/scripts/ntrip_ros.py @@ -1,13 +1,15 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import os import sys import json import rclpy +from std_msgs.msg import String -from ntrip_ros_base import NTRIPRosBase +from ntrip_ros_base import NTRIPRosBase, _RTCM_MSGS_NAME from ntrip_client.ntrip_client import NTRIPClient +from ntrip_client.nmea_parser import NMEA_DEFAULT_MAX_LENGTH, NMEA_DEFAULT_MIN_LENGTH class NTRIPRos(NTRIPRosBase): def __init__(self): @@ -20,6 +22,7 @@ def __init__(self): ('port', 2101), ('mountpoint', 'mount'), ('ntrip_version', 'None'), + ('user_agent', NTRIPClient.DEFAULT_USER_AGENT), ('authenticate', False), ('username', ''), ('password', ''), @@ -27,6 +30,7 @@ def __init__(self): ('cert', 'None'), ('key', 'None'), ('ca_cert', 'None'), + ('ntrip_server_hz', 1), # set to 1hz for rtk2go, override if needed ('rtcm_timeout_seconds', NTRIPClient.DEFAULT_RTCM_TIMEOUT_SECONDS), ] ) @@ -41,6 +45,22 @@ def __init__(self): if ntrip_version == 'None': ntrip_version = None + # User-Agent presented to the caster. rtk2go blocks the stock signature, so this + # is configurable; an empty/'None' value falls back to the client default. + user_agent = self.get_parameter('user_agent').value + if not user_agent or user_agent == 'None': + user_agent = NTRIPClient.DEFAULT_USER_AGENT + + # Set the rate at which RTCM requests and NMEA messages are sent + self.rtcm_request_rate = 1.0 / self.get_parameter('ntrip_server_hz').value + + # Initialize variables to store the most recent NMEA message + self._latest_nmea = None + + # Set the log level to debug if debug is true + if self._debug: + rclpy.logging.set_logger_level(self.get_logger().name, rclpy.logging.LoggingSeverity.DEBUG) + # If we were asked to authenticate, read the username and password username = None password = None @@ -54,6 +74,9 @@ def __init__(self): self.get_logger().error('Requested to authenticate, but param "password" was not set') sys.exit(1) + # Setup a server frequency confirmation publisher + self._rate_confirm_pub = self.create_publisher(String, 'ntrip_server_hz', 10) + # Initialize the client self._client = NTRIPClient( host=host, @@ -91,7 +114,7 @@ def __init__(self): # Start the node rclpy.init() node = NTRIPRos() - if not node.run(): + if not node.run(node.rtcm_request_rate): sys.exit(1) try: # Spin until we are shut down @@ -104,4 +127,4 @@ def __init__(self): node.stop() # Shutdown the node and stop rclpy - rclpy.shutdown() \ No newline at end of file + rclpy.shutdown() diff --git a/scripts/ntrip_ros_base.py b/scripts/ntrip_ros_base.py index aba74b6..b5d9cf4 100755 --- a/scripts/ntrip_ros_base.py +++ b/scripts/ntrip_ros_base.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import os import json @@ -7,7 +7,7 @@ import rclpy from rclpy.node import Node -from std_msgs.msg import Header +from std_msgs.msg import Header, String from nmea_msgs.msg import Sentence from sensor_msgs.msg import NavSatFix from sensor_msgs.msg import NavSatStatus @@ -91,17 +91,18 @@ def __init__(self, name): self._reconnect_attempt_max = self.get_parameter('reconnect_attempt_max').value self._reconnect_attempt_wait_seconds = self.get_parameter('reconnect_attempt_wait_seconds').value - def run(self): + def run(self, rtcm_request_rate): # Connect the client if not self._client.connect(): - self.get_logger().error('Unable to connect') - return False + self.get_logger().warning('Initial connection to NTRIP server failed, will retry with backoff') + self._client.request_reconnect(reason='Initial connection failed') + # Setup our subscribers self._nmea_sub = self.create_subscription(Sentence, 'nmea', self.subscribe_nmea, 10) self._fix_sub = self.create_subscription(NavSatFix, 'fix', self.subscribe_fix, 10) # Start the timer that will check for RTCM data - self._rtcm_timer = self.create_timer(0.1, self.publish_rtcm) + self._rtcm_timer = self.create_timer(rtcm_request_rate, self.publish_rtcm_and_nmea) return True def stop(self): @@ -115,8 +116,8 @@ def stop(self): self.destroy_node() def subscribe_nmea(self, nmea): - # Just extract the NMEA from the message, and send it right to the server - self._client.send_nmea(nmea.sentence) + # Cache the latest NMEA sentence + self._latest_nmea = nmea.sentence def subscribe_fix(self, fix: NavSatFix): # Calculate the timestamp of the message @@ -160,10 +161,19 @@ def subscribe_fix(self, fix: NavSatFix): # Send the sentence to the client self._client.send_nmea(nmea_sentence) - def publish_rtcm(self): + def publish_rtcm_and_nmea(self): for raw_rtcm in self._client.recv_rtcm(): self._rtcm_pub.publish(self._create_rtcm_message(raw_rtcm)) + # Send cached NMEA data if connected (skip during reconnect to avoid log spam) + if self._latest_nmea is not None and not self._client.reconnecting: + self._client.send_nmea(self._latest_nmea) + + # Publish a confirmation message to indicate the send_rtcm_and_nmea call + confirmation_msg = String() + confirmation_msg.data = "RTCM and NMEA sent at rate: {} Hz".format(1.0 / self.rtcm_request_rate) + self._rate_confirm_pub.publish(confirmation_msg) + def _create_mavros_msgs_rtcm_message(self, rtcm): return mavros_msgs_RTCM( header=Header( @@ -180,4 +190,4 @@ def _create_rtcm_msgs_rtcm_message(self, rtcm): frame_id=self._rtcm_frame_id ), message=rtcm - ) \ No newline at end of file + ) diff --git a/src/ntrip_client/nmea_parser.py b/src/ntrip_client/nmea_parser.py index d02ce66..f700481 100644 --- a/src/ntrip_client/nmea_parser.py +++ b/src/ntrip_client/nmea_parser.py @@ -1,7 +1,7 @@ import math import logging -NMEA_DEFAULT_MAX_LENGTH = 82 +NMEA_DEFAULT_MAX_LENGTH = 150 NMEA_DEFAULT_MIN_LENGTH = 3 _NMEA_CHECKSUM_SEPERATOR = "*" diff --git a/src/ntrip_client/ntrip_client.py b/src/ntrip_client/ntrip_client.py index c439e9b..a8c6f60 100644 --- a/src/ntrip_client/ntrip_client.py +++ b/src/ntrip_client/ntrip_client.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import ssl import time @@ -25,9 +25,17 @@ class NTRIPClient(NTRIPBase): # Public constants - DEFAULT_RTCM_TIMEOUT_SECONDS = 4 + DEFAULT_RECONNECT_ATEMPT_WAIT_SECONDS = 5 + DEFAULT_RECONNECT_ATTEMPT_WAIT_MAX_SECONDS = 120 + DEFAULT_RTCM_TIMEOUT_SECONDS = 10 - def __init__(self, host, port, mountpoint, ntrip_version, username, password, logerr=logging.error, logwarn=logging.warning, loginfo=logging.info, logdebug=logging.debug): + # Default User-Agent. NOTE: rtk2go.com (SNIP) blocks the stock LORD Microstrain + # signature 'NTRIP ntrip_client_ros' and refuses such clients by returning the + # sourcetable instead of the stream. Per NTRIP it must begin with 'NTRIP '. Use a + # unique string that identifies this robot so we don't share a blocked signature. + DEFAULT_USER_AGENT = 'NTRIP ros_ntrip_client' + + def __init__(self, host, port, mountpoint, ntrip_version, username, password, user_agent=DEFAULT_USER_AGENT, logerr=logging.error, logwarn=logging.warning, loginfo=logging.info, logdebug=logging.debug): # Call the parent constructor super().__init__(logerr, logwarn, loginfo, logdebug) @@ -36,6 +44,7 @@ def __init__(self, host, port, mountpoint, ntrip_version, username, password, lo self._port = port self._mountpoint = mountpoint self._ntrip_version = ntrip_version + self._user_agent = user_agent if user_agent else self.DEFAULT_USER_AGENT if username is not None and password is not None: self._basic_credentials = base64.b64encode('{}:{}'.format( username, password).encode('utf-8')).decode('utf-8') @@ -52,6 +61,10 @@ def __init__(self, host, port, mountpoint, ntrip_version, username, password, lo self.key = None self.ca_cert = None + # Setup some state + self._shutdown = False + self._connected = False + # Private reconnect info self._reconnect_attempt_count = 0 self._nmea_send_failed_count = 0 @@ -61,7 +74,14 @@ def __init__(self, host, port, mountpoint, ntrip_version, username, password, lo self._first_rtcm_received = False self._recv_rtcm_last_packet_timestamp = 0 + # Reconnect scheduling (non-blocking) + self._reconnect_pending = False + self._reconnect_next_time = 0 + self._current_backoff = 0 + # Public reconnect info + self.reconnect_attempt_wait_seconds = self.DEFAULT_RECONNECT_ATEMPT_WAIT_SECONDS + self.reconnect_attempt_wait_max_seconds = self.DEFAULT_RECONNECT_ATTEMPT_WAIT_MAX_SECONDS self.rtcm_timeout_seconds = self.DEFAULT_RTCM_TIMEOUT_SECONDS def connect(self): @@ -114,12 +134,13 @@ def connect(self): # Some debugging hints about the kind of error we received known_error = False if any(sourcetable in response for sourcetable in _SOURCETABLE_RESPONSES): - self._logwarn('Received sourcetable response from the server. This probably means the mountpoint specified is not valid') + self._logwarn('Received sourcetable response from the server instead of the stream. This means the caster refused the stream request: either the mountpoint is not valid, or the caster is blocking this client (rtk2go blocks the stock "NTRIP ntrip_client_ros" User-Agent). Current User-Agent: {}'.format(self._user_agent)) known_error = True elif any(unauthorized in response for unauthorized in _UNAUTHORIZED_RESPONSES): self._logwarn('Received unauthorized response from the server. Check your username, password, and mountpoint to make sure they are correct.') known_error = True - elif not self._connected and (self._ntrip_version == None or self._ntrip_version == ''): + elif not self._connected: # and (self._ntrip_version == None or self._ntrip_version == ''): + self._logwarn(response) self._logwarn('Received unknown error from the server. Note that the NTRIP version was not specified in the launch file. This is not necesarilly the cause of this error, but it may be worth checking your NTRIP casters documentation to see if the NTRIP version needs to be specified.') known_error = True @@ -146,7 +167,7 @@ def disconnect(self): self._raw_socket.shutdown(socket.SHUT_RDWR) except Exception as e: self._logdebug('Encountered exception when shutting down the socket. This can likely be ignored') - self._logdebug('Exception: {}'.format(str(e))) + self._logdebug('Exception: {}'.format(e)) try: if self._server_socket: self._server_socket.close() @@ -154,7 +175,47 @@ def disconnect(self): self._raw_socket.close() except Exception as e: self._logdebug('Encountered exception when closing the socket. This can likely be ignored') - self._logdebug('Exception: {}'.format(str(e))) + self._logdebug('Exception: {}'.format(e)) + + def request_reconnect(self, reason='Connection lost'): + """Schedule a non-blocking reconnect. The actual attempt happens in try_reconnect().""" + if self._reconnect_pending: + return + self.disconnect() + self._reconnect_pending = True + self._reconnect_attempt_count = 0 + self._current_backoff = self.reconnect_attempt_wait_seconds + self._reconnect_next_time = time.time() + self._current_backoff + self._logwarn('{}. Will retry in {} seconds'.format(reason, self._current_backoff)) + + def try_reconnect(self): + """Attempt one reconnect if the backoff timer has elapsed. Returns True if connected.""" + if not self._reconnect_pending: + return self._connected + + now = time.time() + if now < self._reconnect_next_time: + return False + + self._reconnect_attempt_count += 1 + connect_success = self.connect() + if connect_success: + self._loginfo('Reconnected after {} attempts'.format(self._reconnect_attempt_count)) + self._reconnect_pending = False + self._reconnect_attempt_count = 0 + self._first_rtcm_received = False + return True + + # Exponential backoff: double the wait, capped at max + self._current_backoff = min(self._current_backoff * 2, self.reconnect_attempt_wait_max_seconds) + self._reconnect_next_time = now + self._current_backoff + self._logerr('Reconnect attempt {} to http://{}:{} failed. Retrying in {} seconds'.format( + self._reconnect_attempt_count, self._host, self._port, self._current_backoff)) + return False + + @property + def reconnecting(self): + return self._reconnect_pending def send_nmea(self, sentence): if not self._connected: @@ -180,13 +241,18 @@ def send_nmea(self, sentence): self._logwarn('Exception: {}'.format(str(e))) self._nmea_send_failed_count += 1 if self._nmea_send_failed_count >= self._nmea_send_failed_max: - self._logwarn("NMEA sentence failed to send to server {} times, restarting".format(self._nmea_send_failed_count)) - self.reconnect() + self._logwarn("NMEA sentence failed to send to server {} times, reconnecting".format(self._nmea_send_failed_count)) + self.request_reconnect() self._nmea_send_failed_count = 0 self.send_nmea(sentence) # Try sending the NMEA sentence again def recv_rtcm(self): + # If a reconnect is in progress, try it and return empty until connected + if self._reconnect_pending: + self.try_reconnect() + return [] + if not self._connected: self._logwarn('RTCM requested before client was connected, returning empty list') return [] @@ -194,8 +260,8 @@ def recv_rtcm(self): # If it has been too long since we received an RTCM packet, reconnect if time.time() - self.rtcm_timeout_seconds >= self._recv_rtcm_last_packet_timestamp and self._first_rtcm_received: self._logerr('RTCM data not received for {} seconds, reconnecting'.format(self.rtcm_timeout_seconds)) - self.reconnect() - self._first_rtcm_received = False + self.request_reconnect() + return [] # Check if there is any data available on the socket read_sockets, _, _ = select.select([self._server_socket], [], [], 0) @@ -215,7 +281,7 @@ def recv_rtcm(self): self._logerr('Error while reading {} bytes from socket'.format(_CHUNK_SIZE)) if not self._socket_is_open(): self._logerr('Socket appears to be closed. Reconnecting') - self.reconnect() + self.request_reconnect() return [] break self._logdebug('Read {} bytes'.format(len(data))) @@ -226,7 +292,7 @@ def recv_rtcm(self): self._read_zero_bytes_count += 1 if self._read_zero_bytes_count >= self._read_zero_bytes_max: self._logwarn('Reconnecting because we received 0 bytes from the socket even though it said there was data available {} times'.format(self._read_zero_bytes_count)) - self.reconnect() + self.request_reconnect() self._read_zero_bytes_count = 0 return [] else: @@ -237,13 +303,18 @@ def recv_rtcm(self): # Send the data to the RTCM parser to parse it return self.rtcm_parser.parse(data) if data else [] + def shutdown(self): + # Set some state, and then disconnect + self._shutdown = True + self.disconnect() + def _form_request(self): if self._ntrip_version != None and self._ntrip_version != '': - request_str = 'GET /{} HTTP/1.0\r\nNtrip-Version: {}\r\nUser-Agent: NTRIP ntrip_client_ros\r\n'.format( - self._mountpoint, self._ntrip_version) + request_str = 'GET /{} HTTP/1.0\r\nNtrip-Version: {}\r\nUser-Agent: {}\r\n'.format( + self._mountpoint, self._ntrip_version, self._user_agent) else: - request_str = 'GET /{} HTTP/1.0\r\nUser-Agent: NTRIP ntrip_client_ros\r\n'.format( - self._mountpoint) + request_str = 'GET /{} HTTP/1.0\r\nUser-Agent: {}\r\n'.format( + self._mountpoint, self._user_agent) if self._basic_credentials is not None: request_str += 'Authorization: Basic {}\r\n'.format( self._basic_credentials) From 9c483c2dc2050f464c6fb2c65ba7fda94cd2da5e Mon Sep 17 00:00:00 2001 From: Paul Bouchier Date: Sat, 13 Jun 2026 13:38:43 -0500 Subject: [PATCH 2/2] Fix issues with first push related to serial device support - Back out changes to ntrip_ros_base class, so serial device operation is unchanged - Fix error in retry with exponential delay logic - Add send_nmea boolean launch arg, controlling whether to send NMEA to caster - Exit with error if host is rtk2go and send_nmea is true, to avoid the user IPs and the ntrip_client getting banned - Make network connection retry behavior different than serial connection retry behavior (avoid changes to serial) --- README.md | 30 ++++++++++------ launch/ntrip_client_launch.py | 26 ++++++++++++-- scripts/ntrip_ros.py | 59 ++++++++++++++++++++++++++++++-- scripts/ntrip_ros_base.py | 28 +++++---------- src/ntrip_client/ntrip_client.py | 13 +++++-- 5 files changed, 120 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 3da449d..61f227c 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,15 @@ ## Description -ROS node that will communicate with an NTRIP server to receive RTCM corrections and publish them on a ROS topic. Also works with virtual/relayed NTRIP servers by subscribing to NMEA +ROS node that will communicate with an NTRIP caster to receive RTCM corrections and publish them on a ROS topic. Also works with virtual/relayed NTRIP servers by subscribing to NMEA messages and sending them to the NTRIP server. ## Build Instructions -It is assumed you have already installed ROS. +It is assumed you have already installed ROS2. This package is not tied to any particular release of ROS2. Build this package from source as follows: -1. Clone this repo into the your_workspace/src directory. +1. Clone this repo into the your_workspace/src directory and check out the ros2 branch. 2. Install rosdeps for this package: `rosdep install --from-paths ~/your_workspace/src --ignore-src -r -y` @@ -33,7 +33,7 @@ ros2 launch ntrip_client ntrip_client_launch.py -- or override defaults from cmd line (example for rtk2go) -- ```bash -ros2 launch ntrip_client ntrip_client_launch.py host:=rtk2go.com mountpoint:=MyRealMtPt ntrip_server_hz:=1 authenticate:=true username:=myrealemail@provider.com password:=none +ros2 launch ntrip_client ntrip_client_launch.py host:=rtk2go.com mountpoint:=MyRealMtPt authenticate:=true username:=myrealemail@provider.com password:=none send_nmea:=false ``` Launch arguments (all overridable as `name:=value`; defaults shown): @@ -44,6 +44,7 @@ Connection: - **mountpoint**: Mountpoint to connect to on the NTRIP server - **ntrip_version**: Value sent in the `Ntrip-Version` request header. Default: `None` (header omitted; NTRIP rev1/ICY request) - **user_agent**: HTTP `User-Agent` sent to the caster. **Must start with `NTRIP `.** Default: `NTRIP ponderbotics_ntrip_client`. Do not use the stock `NTRIP ntrip_client_ros` — rtk2go blocks it (see [rtk2go notes](#rtk2go-and-reconnect-behavior)). +- **send_nmea**: Whether to forward NMEA from the `nmea` topic up to the caster. Needed for virtual/relayed (VRS) mountpoints. For a plain base station (e.g. rtk2go's fixed mountpoints) set `send_nmea:=false` — the node then skips the `nmea` subscription entirely and never uploads your position. Default: `true`. Authentication: - **authenticate**: Whether to authenticate with the server, or send an unauthenticated request. If `true`, `username` and `password` must be supplied. @@ -81,19 +82,28 @@ Optional launch parameters: * **/rtcm** (publish): RTCM corrections received from the server. Message type depends on `rtcm_message_package` — `rtcm_msgs/msg/Message` by default, or `mavros_msgs/msg/RTCM`. Consumed by GNSS drivers (e.g. ublox_gps, [microstrain_inertial_driver](https://github.com/LORD-MicroStrain/microstrain_inertial)). * **NOTE**: The type of message can be switched between [`mavros_msgs/RTCM`](https://github.com/mavlink/mavros/blob/ros2/mavros_msgs/msg/RTCM.msg) and [`rtcm_msgs/Message`](https://github.com/tilk/rtcm_msgs/blob/master/msg/Message.msg) using the `rtcm_message_package` parameter -* **/nmea** (subscribe): [NMEA sentence messages](http://docs.ros.org/en/api/nmea_msgs/html/msg/Sentence.html) forwarded to the NTRIP server. Needed for virtual/relayed (VRS) mountpoints. The node subscribes to the topic `/nmea`; remap it (e.g. in the launch file's `remappings`) to your NMEA source if it differs. Note: there is no `nmea_topic` launch argument — passing one has no effect. -* **/fix**: This serves the same exact purpose as `/nmea`, but facilitates receiving global position that is not in NMEA format +* **/nmea** (subscribe): [NMEA sentence messages](http://docs.ros.org/en/api/nmea_msgs/html/msg/Sentence.html) forwarded to the NTRIP server. Needed for virtual/relayed (VRS) mountpoints. The node subscribes to the topic `/nmea`; remap it (e.g. in the launch file's `remappings`) to your NMEA source if it differs. Set `send_nmea:=false` to disable forwarding entirely (the subscription is then not created). Note: there is no `nmea_topic` launch argument — passing one has no effect. +* **/fix**: This serves the same exact purpose as `/nmea`, but facilitates receiving global position that is not in NMEA format by monitoring NavSatFix mesages on the /fix topic. * **/ntrip_server_hz** (publish): A `std_msgs/String` confirmation published each communication cycle, to help verify compliance with caster usage policies. ## rtk2go and reconnect behavior -[rtk2go.com](http://rtk2go.com) runs the SNIP caster software and enforces usage policies that this fork is tuned for: +[rtk2go.com](http://rtk2go.com) runs the SNIP caster software and enforces usage policies that this release complies with: -* **User-Agent blocking.** rtk2go maintains a block list of client signatures. The stock LORD-MicroStrain `User-Agent: NTRIP ntrip_client_ros` is blocked. A blocked client does **not** get a clear error — the caster returns a `SOURCETABLE 200 OK` response instead of the data stream, which the node logs as a sourcetable/invalid-response error. If you see a sourcetable response for a mountpoint you know is valid, suspect a client-side block, not a bad mountpoint. The default `user_agent` (`NTRIP ponderbotics_ntrip_client`) is accepted; if you change it, keep the mandatory `NTRIP ` prefix and avoid the stock string. -* **Request rate.** Use `ntrip_server_hz:=1` for rtk2go. Higher rates can get you banned. -* **Persistent reconnect.** On any connection loss or failed initial connect, the node schedules a non-blocking reconnect and retries indefinitely. The wait starts at `reconnect_attempt_wait_seconds` (10s) and doubles on each failure up to `reconnect_attempt_wait_max_seconds` (default 120s), then holds at that ceiling. It never gives up, so the node recovers on its own from extended rtk2go outages (DDoS) or a mountpoint taken down for maintenance. To shrink your footprint during long outages, raise the ceiling (e.g. `reconnect_attempt_wait_max_seconds:=600`). +* **User-Agent blocking.** rtk2go maintains a block list of client signatures. The stock LORD-MicroStrain `User-Agent: NTRIP ntrip_client_ros` is blocked. A blocked client does **not** get a clear error — the caster returns a `SOURCETABLE 200 OK` response instead of the data stream, which the node logs as a sourcetable/invalid-response error. If you see a sourcetable response for a mountpoint you know is valid, suspect a client-side block, not a bad mountpoint. The default `user_agent` (`NTRIP ros_ntrip_client`) is accepted; if you change it, keep the mandatory `NTRIP ` prefix and avoid the stock string. +* **Request rate.** Use `ntrip_server_hz:=1` (the default) for rtk2go. Higher rates can get you banned. +* **Persistent reconnect.** On any connection loss or failed initial connect, the node schedules a non-blocking reconnect and retries indefinitely. The wait starts at `reconnect_attempt_wait_seconds` (10s) and doubles on each failure up to `reconnect_attempt_wait_max_seconds` (default 120s), then holds at that ceiling. It never gives up, so the node recovers on its own from extended rtk2go outages (DDoS) or a mountpoint taken down for maintenance. To shrink your footprint during long outages, raise the ceiling (e.g. `reconnect_attempt_wait_max_seconds:=600`). Note: only the network client retries forever; the serial client retries a fixed number of times then exits. * **First-connect timeout is normal.** With rtk2go the very first connect attempt frequently times out and then succeeds on the first backoff retry, even on healthy connections. This is expected. +### Known behavior: NMEA upload to a connected-but-silent mountpoint + +The dead-connection watchdog (`rtcm_timeout_seconds`) only arms **after the first RTCM packet arrives**. If a caster *accepts* the connection but then never delivers RTCM — e.g. a mountpoint that is **down for maintenance** while the caster still completes the GET request — the node believes it is connected and, if `send_nmea` is `true`, keeps uploading NMEA every cycle to a stream that is dead. On rtk2go this continuous upload to a silent mountpoint can trigger a ban. + +Mitigations by deployment type: + +* **Fixed-base mountpoints (e.g. rtk2go):** set `send_nmea:=false`. These mountpoints don't use your position, so no NMEA should be sent in the first place — this removes the problem at the source. +* **VRS / virtual mountpoints:** NMEA is required (the network needs your position to synthesize the virtual base), so `send_nmea:=false` is not an option. The proper fix is to baseline the watchdog off the connect time so a connected-but-silent stream triggers reconnect even before the first packet. This is **not yet implemented** — left as documented behavior pending a VRS caster to validate against. + ## Docker Integration ### VSCode diff --git a/launch/ntrip_client_launch.py b/launch/ntrip_client_launch.py index 5bcb97d..6dc5ed6 100644 --- a/launch/ntrip_client_launch.py +++ b/launch/ntrip_client_launch.py @@ -10,10 +10,13 @@ def generate_launch_description(): DeclareLaunchArgument('namespace', default_value='/'), DeclareLaunchArgument('node_name', default_value='ntrip_client'), DeclareLaunchArgument('debug', default_value='false'), - DeclareLaunchArgument('host', default_value='20.185.11.35'), + DeclareLaunchArgument('host', default_value='20.185.11.35'), DeclareLaunchArgument('port', default_value='2101'), DeclareLaunchArgument('mountpoint', default_value='VRS_RTCM3'), DeclareLaunchArgument('ntrip_version', default_value='None'), + DeclareLaunchArgument('user_agent', default_value='NTRIP ros_ntrip_client', description='HTTP User-Agent sent to the caster. Must start with "NTRIP ". rtk2go blocks the stock "NTRIP ntrip_client_ros".'), + DeclareLaunchArgument('ntrip_server_hz', default_value='1'), # set this to 1 for rtk2go + DeclareLaunchArgument('send_nmea', default_value='true', description='Forward NMEA from the "nmea" topic up to the caster. Needed for virtual/relayed (VRS) mountpoints; set false for plain base stations to skip the subscription and avoid uploading position.'), DeclareLaunchArgument('authenticate', default_value='True'), DeclareLaunchArgument('username', default_value='user'), DeclareLaunchArgument('password', default_value='pass'), @@ -22,10 +25,12 @@ def generate_launch_description(): DeclareLaunchArgument('key', default_value='None'), DeclareLaunchArgument('ca_cert', default_value='None'), DeclareLaunchArgument('rtcm_message_package', default_value='rtcm_msgs'), + DeclareLaunchArgument('reconnect_attempt_wait_max_seconds', default_value='120', description='Ceiling for the exponential reconnect backoff. Retries are persistent (never give up); raise this to reduce footprint during long caster outages (e.g. 600 for rtk2go DDoS/maintenance downtime).'), # Pass an environment variable to the node SetEnvironmentVariable(name='NTRIP_CLIENT_DEBUG', value=LaunchConfiguration('debug')), + # ****************************************************************** # NTRIP Client Node # ****************************************************************** @@ -44,6 +49,13 @@ def generate_launch_description(): # Optional parameter that will set the NTRIP version in the initial HTTP request to the NTRIP caster. 'ntrip_version': LaunchConfiguration('ntrip_version'), + # User-Agent presented to the caster. Must start with "NTRIP ". + # rtk2go blocks the stock "NTRIP ntrip_client_ros" and refuses such clients with a sourcetable response. + 'user_agent': LaunchConfiguration('user_agent'), + + # Rate to request correction messages. Some servers will sandbox clients that request too often + 'ntrip_server_hz': LaunchConfiguration('ntrip_server_hz'), + # If this is set to true, we will read the username and password and attempt to authenticate. If not, we will attempt to connect unauthenticated 'authenticate': LaunchConfiguration('authenticate'), @@ -64,6 +76,10 @@ def generate_launch_description(): # Not sure if this will be looked at by other ndoes, but this frame ID will be added to the RTCM messages published by this node 'rtcm_frame_id': 'odom', + # Whether to forward NMEA from the "nmea" topic up to the caster. + # Needed for virtual/relayed (VRS) mountpoints; disable for plain base stations. + 'send_nmea': LaunchConfiguration('send_nmea'), + # Optional parameters that will allow for longer or shorter NMEA messages. Standard max length for NMEA is 82 'nmea_max_length': 128, 'nmea_min_length': 3, @@ -73,10 +89,14 @@ def generate_launch_description(): # Will affect how many times the node will attempt to reconnect before exiting, and how long it will wait in between attempts when a reconnect occurs 'reconnect_attempt_max': 10, - 'reconnect_attempt_wait_seconds': 5, + 'reconnect_attempt_wait_seconds': 10, + # Reconnect backoff: wait doubles from reconnect_attempt_wait_seconds up to + # reconnect_attempt_wait_max_seconds, then holds at that ceiling. Retries are + # persistent (no give-up) so the node recovers from long caster outages on its own. + 'reconnect_attempt_wait_max_seconds': LaunchConfiguration('reconnect_attempt_wait_max_seconds'), # How many seconds is acceptable in between receiving RTCM. If RTCM is not received for this duration, the node will attempt to reconnect - 'rtcm_timeout_seconds': 4 + 'rtcm_timeout_seconds': 10 #was 4 changed for rtk2go reqs } ], # Uncomment the following section and replace "/gx5/nmea/sentence" with the topic you are sending NMEA on if it is not the one we requested diff --git a/scripts/ntrip_ros.py b/scripts/ntrip_ros.py index d53856f..0e45ba7 100755 --- a/scripts/ntrip_ros.py +++ b/scripts/ntrip_ros.py @@ -30,7 +30,9 @@ def __init__(self): ('cert', 'None'), ('key', 'None'), ('ca_cert', 'None'), - ('ntrip_server_hz', 1), # set to 1hz for rtk2go, override if needed + ('ntrip_server_hz', 1), # set send_nmea() to 1hz + ('send_nmea', True), + ('reconnect_attempt_wait_max_seconds', NTRIPClient.DEFAULT_RECONNECT_ATTEMPT_WAIT_MAX_SECONDS), ('rtcm_timeout_seconds', NTRIPClient.DEFAULT_RTCM_TIMEOUT_SECONDS), ] ) @@ -54,6 +56,16 @@ def __init__(self): # Set the rate at which RTCM requests and NMEA messages are sent self.rtcm_request_rate = 1.0 / self.get_parameter('ntrip_server_hz').value + # Whether to forward NMEA from the 'nmea' topic up to the caster. Only needed for + # virtual/relayed (VRS) mountpoints; disable for plain base stations to avoid the + # idle subscriber cost and uploading position to the caster. If the caster is rtk2go, + # exit with an error if send_nmea is true, since rtk2go will ban the IP and possibly + # this client if it receives persistent NMEA during error conditions. + self._send_nmea = self.get_parameter('send_nmea').value + if self._send_nmea and ((host == "rtk2go.com") or (host == "3.143.243.81")): + self.get_logger().error('rtk2go blocks clients that send NMEA excessively, but send_nmea is true and host is rtk2go; exiting to avoid IP ban. Set send_nmea to false to fix this.') + sys.exit(1) + # Initialize variables to store the most recent NMEA message self._latest_nmea = None @@ -85,6 +97,7 @@ def __init__(self): ntrip_version=ntrip_version, username=username, password=password, + user_agent=user_agent, logerr=self.get_logger().error, logwarn=self.get_logger().warning, loginfo=self.get_logger().info, @@ -108,13 +121,55 @@ def __init__(self): self._client.nmea_parser.nmea_min_length = self._nmea_min_length self._client.reconnect_attempt_max = self._reconnect_attempt_max self._client.reconnect_attempt_wait_seconds = self._reconnect_attempt_wait_seconds + self._client.reconnect_attempt_wait_max_seconds = self.get_parameter('reconnect_attempt_wait_max_seconds').value self._client.rtcm_timeout_seconds = self.get_parameter('rtcm_timeout_seconds').value + # override run() in the base class with a version that retries reconnect and that only + # subscribes to nmea and fix if needed + def run(self): + # Attempt initial connection; if it fails, enter backoff retry instead of exiting + if not self._client.connect(): + self.get_logger().warning('Initial connection to NTRIP server failed, will retry with backoff') + self._client.request_reconnect(reason='Initial connection failed') + + # Setup the subscriber for NMEA and fix data, unless NMEA forwarding is disabled + self._nmea_sub = None + self._fix_sub = None + if self._send_nmea: + self._nmea_sub = self.create_subscription(Sentence, 'nmea', self.subscribe_nmea, 10) + self._fix_sub = self.create_subscription(NavSatFix, 'fix', self.subscribe_fix, 10) + else: + self.get_logger().info('send_nmea is false; not subscribing to NMEA or fix or forwarding nmea to the caster') + + # Start the timer that will send both RTCM and NMEA data at the configured rate + self._rtcm_timer = self.create_timer(self.rtcm_request_rate, self.send_rtcm_and_nmea) + + return True + + # override subscribe_nmea() with version that works with reconnects + def subscribe_nmea(self, nmea): + # Cache the latest NMEA sentence + self._latest_nmea = nmea.sentence + + def send_rtcm_and_nmea(self): + # Request and publish RTCM data (also drives reconnect attempts) + for raw_rtcm in self._client.recv_rtcm(): + self._rtcm_pub.publish(self._create_rtcm_message(raw_rtcm)) + + # Send cached NMEA data if enabled and connected (skip during reconnect to avoid log spam) + if self._send_nmea and self._latest_nmea is not None and not self._client.reconnecting: + self._client.send_nmea(self._latest_nmea) + + # Publish a confirmation message to indicate the send_rtcm_and_nmea call + confirmation_msg = String() + confirmation_msg.data = "RTCM and NMEA sent at rate: {} Hz".format(1.0 / self.rtcm_request_rate) + self._rate_confirm_pub.publish(confirmation_msg) + if __name__ == '__main__': # Start the node rclpy.init() node = NTRIPRos() - if not node.run(node.rtcm_request_rate): + if not node.run(): sys.exit(1) try: # Spin until we are shut down diff --git a/scripts/ntrip_ros_base.py b/scripts/ntrip_ros_base.py index b5d9cf4..4aa896f 100755 --- a/scripts/ntrip_ros_base.py +++ b/scripts/ntrip_ros_base.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python import os import json @@ -7,7 +7,7 @@ import rclpy from rclpy.node import Node -from std_msgs.msg import Header, String +from std_msgs.msg import Header from nmea_msgs.msg import Sentence from sensor_msgs.msg import NavSatFix from sensor_msgs.msg import NavSatStatus @@ -91,18 +91,17 @@ def __init__(self, name): self._reconnect_attempt_max = self.get_parameter('reconnect_attempt_max').value self._reconnect_attempt_wait_seconds = self.get_parameter('reconnect_attempt_wait_seconds').value - def run(self, rtcm_request_rate): + def run(self): # Connect the client if not self._client.connect(): - self.get_logger().warning('Initial connection to NTRIP server failed, will retry with backoff') - self._client.request_reconnect(reason='Initial connection failed') - + self.get_logger().error('Unable to connect') + return False # Setup our subscribers self._nmea_sub = self.create_subscription(Sentence, 'nmea', self.subscribe_nmea, 10) self._fix_sub = self.create_subscription(NavSatFix, 'fix', self.subscribe_fix, 10) # Start the timer that will check for RTCM data - self._rtcm_timer = self.create_timer(rtcm_request_rate, self.publish_rtcm_and_nmea) + self._rtcm_timer = self.create_timer(0.1, self.publish_rtcm) return True def stop(self): @@ -116,8 +115,8 @@ def stop(self): self.destroy_node() def subscribe_nmea(self, nmea): - # Cache the latest NMEA sentence - self._latest_nmea = nmea.sentence + # Just extract the NMEA from the message, and send it right to the server + self._client.send_nmea(nmea.sentence) def subscribe_fix(self, fix: NavSatFix): # Calculate the timestamp of the message @@ -161,19 +160,10 @@ def subscribe_fix(self, fix: NavSatFix): # Send the sentence to the client self._client.send_nmea(nmea_sentence) - def publish_rtcm_and_nmea(self): + def publish_rtcm(self): for raw_rtcm in self._client.recv_rtcm(): self._rtcm_pub.publish(self._create_rtcm_message(raw_rtcm)) - # Send cached NMEA data if connected (skip during reconnect to avoid log spam) - if self._latest_nmea is not None and not self._client.reconnecting: - self._client.send_nmea(self._latest_nmea) - - # Publish a confirmation message to indicate the send_rtcm_and_nmea call - confirmation_msg = String() - confirmation_msg.data = "RTCM and NMEA sent at rate: {} Hz".format(1.0 / self.rtcm_request_rate) - self._rate_confirm_pub.publish(confirmation_msg) - def _create_mavros_msgs_rtcm_message(self, rtcm): return mavros_msgs_RTCM( header=Header( diff --git a/src/ntrip_client/ntrip_client.py b/src/ntrip_client/ntrip_client.py index a8c6f60..ba641e0 100644 --- a/src/ntrip_client/ntrip_client.py +++ b/src/ntrip_client/ntrip_client.py @@ -244,7 +244,6 @@ def send_nmea(self, sentence): self._logwarn("NMEA sentence failed to send to server {} times, reconnecting".format(self._nmea_send_failed_count)) self.request_reconnect() self._nmea_send_failed_count = 0 - self.send_nmea(sentence) # Try sending the NMEA sentence again def recv_rtcm(self): @@ -257,7 +256,17 @@ def recv_rtcm(self): self._logwarn('RTCM requested before client was connected, returning empty list') return [] - # If it has been too long since we received an RTCM packet, reconnect + # If it has been too long since we received an RTCM packet, reconnect. + # KNOWN LIMITATION: this watchdog only arms after the first RTCM packet ever + # arrives (_first_rtcm_received). If the caster accepts the connection + # (_connected=True) but never delivers any RTCM -- e.g. a mountpoint that is + # down for maintenance while the caster still completes the GET -- this never + # fires, the node believes it is connected, and (when send_nmea is true) keeps + # uploading NMEA every cycle to a dead stream. On rtk2go that can earn a ban. + # For fixed-base mountpoints the correct fix is send_nmea:=false (no NMEA at + # all). VRS mountpoints require NMEA and would need this watchdog to instead + # baseline off the connect time; left as documented behavior pending a VRS to + # test against. See ntrip_client README "rtk2go and reconnect behavior". if time.time() - self.rtcm_timeout_seconds >= self._recv_rtcm_last_packet_timestamp and self._first_rtcm_received: self._logerr('RTCM data not received for {} seconds, reconnecting'.format(self.rtcm_timeout_seconds)) self.request_reconnect()