apps: Add GPSD app.
Once enabled, running a GPSD client (for example, cgps) on the node can obtain the current position and motion information of the node.
Change-Id: Ib88424cc7fe07898de5b37c1699e170bf89e58a5
diff --git a/minindn/apps/gpsd.py b/minindn/apps/gpsd.py
new file mode 100644
index 0000000..7ec79d0
--- /dev/null
+++ b/minindn/apps/gpsd.py
@@ -0,0 +1,237 @@
+# -*- Mode:python; c-file-style:"gnu"; indent-tabs-mode:nil -*- */
+#
+# Copyright (C) 2015-2025, The University of Memphis,
+# Arizona Board of Regents,
+# Regents of the University of California.
+#
+# This file is part of Mini-NDN.
+# See AUTHORS.md for a complete list of Mini-NDN authors and contributors.
+#
+# Mini-NDN is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Mini-NDN is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Mini-NDN, e.g., in COPYING.md file.
+# If not, see <http://www.gnu.org/licenses/>.
+
+import threading
+import math
+import time
+import datetime
+from typing import Optional, Tuple
+
+from minindn.apps.application import Application
+
+
+class Gpsd(Application):
+ """
+ A class that simulates a GPS device and provides GPS data in NMEA format.
+ This class can generate NMEA GGA and VTG sentences and feeds the GPS data to a GPSD server.
+
+ To use this class, you need to install gpsd and netcat (nc) for communication.
+ """
+
+ def __init__(self, node, lat: float = 0.0, lon: float = 0.0, altitude: float = 0.0, update_interval: float = 0.2) -> None:
+ """
+ Initialize the GPSd application for a node.
+
+ :param node: The node for which the GPS data will be provided.
+ :param lat: Latitude of the point (0, 0, 0).
+ :param lon: Longitude of the point (0, 0, 0).
+ :param altitude: Altitude of the point (0, 0, 0).
+ :param update_interval: The time interval in seconds to send gps data to gpsd, and it should be more than 0.2
+ """
+ Application.__init__(self, node)
+ self.lat = lat
+ self.lon = lon
+ self.altitude = altitude
+ self.update_interval = update_interval
+ self.stop_event = threading.Event()
+ self.location_thread = None
+
+
+ def calculate_coordinates(self, offset_lat: float, offset_lon: float, altitude_offset: float) -> Tuple[float, float, float]:
+ """
+ Calculate the coordinates of the target point.
+
+ :param offset_lat: Latitude offset (unit: meters)
+ :param offset_lon: Longitude offset (unit: meters)
+ :param altitude_offset: Altitude offset (unit: meters)
+ :return: New GPS coordinates (latitude, longitude, altitude)
+ """
+ # Each degree of latitude is approximately 111 kilometers
+ lat_offset_deg = offset_lat / 111000.0 # Convert to degrees
+ # Distance per degree of longitude changes based on latitude
+ lon_offset_deg = offset_lon / (111000.0 * math.cos(math.radians(self.lat))) # Convert to degrees
+ # Calculate the latitude, longitude, and altitude of the target point
+ lat2 = self.lat + lat_offset_deg
+ lon2 = self.lon + lon_offset_deg
+ alt2 = self.altitude + altitude_offset
+
+ return lat2, lon2, alt2
+
+ @staticmethod
+ def nmea_checksum(sentence: str) -> str:
+ """
+ Calculate the checksum for an NMEA sentence.
+ :param sentence: The NMEA sentence to calculate the checksum for.
+ :return: The checksum for the sentence.
+ """
+ checksum = 0
+ for char in sentence:
+ checksum ^= ord(char)
+ return f"{checksum:02X}"
+
+ @staticmethod
+ def generate_vtg_sentence(vx: float, vy: float) -> str:
+ """
+ Generate a NMEA VTG sentence based on a 2D velocity vector.
+
+ Parameters:
+ vx (float): Velocity in the x-direction (eastward, in m/s)
+ vy (float): Velocity in the y-direction (northward, in m/s)
+
+ Returns:
+ str: The corresponding NMEA VTG sentence
+ """
+ # Calculate ground speed (horizontal speed)
+ ground_speed = math.sqrt(vx**2 + vy**2) # Ground speed in m/s
+ ground_speed_knots = ground_speed * 1.94384 # Convert speed to knots
+ ground_speed_kmh = ground_speed * 3.6 # Convert speed to km/h
+ # Calculate heading angle (relative to true north, clockwise)
+ angle = math.atan2(vx, vy) # atan2(y, x)
+ true_course = (math.degrees(angle) + 360) % 360 # Normalize angle to 0° - 360°
+ # Create the VTG NMEA sentence
+ nmea_sentence = f"GPVTG,{true_course:.1f},T,,M,{ground_speed_knots:.2f},N,{ground_speed_kmh:.2f},K"
+
+ nmea_sentence_with_checksum = f"${nmea_sentence}*{Gpsd.nmea_checksum(nmea_sentence)}"
+
+ return nmea_sentence_with_checksum
+
+ @staticmethod
+ def generate_gga_sentence(lat: float, lon: float, altitude: float, utc_time: Optional[str] = None) -> str:
+ """
+ Convert latitude, longitude, and altitude into NMEA format data.
+
+ Parameters:
+ lat (float): Latitude (unit: degrees)
+ lon (float): Longitude (unit: degrees)
+ altitude (float): Altitude (unit: meters)
+ utc_time (str): UTC Time in 'hhmmss.sss' format, optional
+
+ Returns:
+ str: Formatted NMEA data sentence
+ """
+ lat_direction = 'N' if lat >= 0 else 'S'
+ lon_direction = 'E' if lon >= 0 else 'W'
+
+ lat_deg = int(abs(lat))
+ lat_min = (abs(lat) - lat_deg) * 60
+ lon_deg = int(abs(lon))
+ lon_min = (abs(lon) - lon_deg) * 60
+
+ if utc_time is None:
+ utc_time = datetime.datetime.now(datetime.timezone.utc).strftime("%H%M%S.%f")[:-3]# Generate UTC time
+
+ nmea_sentence = f"GPGGA,{utc_time},{lat_deg:02d}{lat_min:07.4f},{lat_direction},{lon_deg:03d}{lon_min:07.4f},{lon_direction},1,12,1.0,{altitude:.1f},M,0.0,M,,"
+
+ nmea_sentence = f"${nmea_sentence}*{Gpsd.nmea_checksum(nmea_sentence)}"
+
+ return nmea_sentence
+
+ @staticmethod
+ def generate_rmc_sentence(lat: float, lon: float, vx: float, vy: float, utc_time: Optional[str] = None, date: Optional[str] = None) -> str:
+ """
+ Generate an NMEA GPRMC sentence using vx and vy (speed components along X and Y axes).
+
+ Parameters:
+ lat (float): Latitude (degrees)
+ lon (float): Longitude (degrees)
+ vx (float): Speed along the X-axis (knots)
+ vy (float): Speed along the Y-axis (knots)
+ utc_time (str, optional): UTC time in 'hhmmss.sss' format
+ date (str, optional): UTC date in 'ddmmyy' format
+
+ Returns:
+ str: Formatted NMEA GPRMC sentence
+ """
+ # Calculate speed and course
+ speed = math.sqrt(vx**2 + vy**2) # Speed (ground speed)
+ course = math.degrees(math.atan2(vy, vx)) # Course (direction in degrees)
+
+ # Normalize course to be within 0 to 360 degrees
+ if course < 0:
+ course += 360
+
+ # Convert latitude and longitude to NMEA format
+ lat_direction = 'N' if lat >= 0 else 'S'
+ lon_direction = 'E' if lon >= 0 else 'W'
+
+ lat_deg = int(abs(lat))
+ lat_min = (abs(lat) - lat_deg) * 60
+ lon_deg = int(abs(lon))
+ lon_min = (abs(lon) - lon_deg) * 60
+
+ # Use current utc_time and date if not provided
+ if utc_time is None or date is None:
+ now = datetime.datetime.now(datetime.timezone.utc)
+ if utc_time is None:
+ utc_time = now.strftime("%H%M%S.%f")[:-3] # hhmmss.sss
+ if date is None:
+ date = now.strftime("%d%m%y") # ddmmyy
+
+ # Construct NMEA sentence
+ status = "A" # A = Active, V = Void (No fix)
+ nmea_sentence = f"GPRMC,{utc_time},{status},{lat_deg:02d}{lat_min:07.4f},{lat_direction},{lon_deg:03d}{lon_min:07.4f},{lon_direction},{speed:.1f},{course:.1f},{date},,,A"
+
+ nmea_sentence = f"${nmea_sentence}*{Gpsd.nmea_checksum(nmea_sentence)}"
+
+ return nmea_sentence
+
+ def __feedGPStoGPSD(self, node) -> None:
+ """
+ Continuously feed GPS data to the GPSD server.
+ """
+ current_position = node.position
+ current_time_seconds = time.monotonic()
+ while not self.stop_event.is_set():
+ time.sleep(self.update_interval)
+ lat, lon, altitude = self.calculate_coordinates(node.position[0], node.position[1], node.position[2])
+ gga_sentence = self.generate_gga_sentence(lat, lon, altitude)
+
+ tmp_position = node.position
+ tmp_time_seconds = time.monotonic()
+ vx = (tmp_position[0] - current_position[0]) / (tmp_time_seconds - current_time_seconds)
+ vy = (tmp_position[1] - current_position[1]) / (tmp_time_seconds - current_time_seconds)
+ current_position = tmp_position
+ current_time_seconds = tmp_time_seconds
+ rmc_sentence = self.generate_rmc_sentence(lat, lon, vx, vy)
+ vtg_sentence = self.generate_vtg_sentence(vx, vy)
+
+ cmd = f"echo '{gga_sentence}\n{rmc_sentence}\n{vtg_sentence}\n' | nc -u -w 1 127.0.0.1 7150"
+ process = node.popen(cmd, shell=True)
+
+ def start(self) -> None:
+ """
+ Start a thread to periodically send GPS data for the node.
+ """
+ Application.start(self, command="gpsd -n udp://127.0.0.1:7150")
+
+ self.location_thread = threading.Thread(target=self.__feedGPStoGPSD, args=(self.node,))
+ self.location_thread.start()
+
+ def stop(self) -> None:
+ """
+ Stop all the __feedGPStoGPSD threads
+ """
+ if not self.stop_event.is_set():
+ self.stop_event.set()
+ self.location_thread.join()
+ Application.stop(self)
\ No newline at end of file