├── tests ├── __init__.py ├── test_gpsdclient.py └── _fake_server.py ├── gpsdclient ├── py.typed ├── __init__.py ├── __main__.py ├── cli.py └── client.py ├── .github ├── dependabot.yml ├── FUNDING.yml └── workflows │ └── tests.yml ├── poetry.lock ├── pyproject.toml ├── CHANGELOG.md ├── LICENSE.txt ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gpsdclient/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gpsdclient/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import GPSDClient 2 | -------------------------------------------------------------------------------- /gpsdclient/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if __name__ == "__main__": 4 | from .cli import main 5 | 6 | sys.exit(main()) 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: pip 5 | directory: / 6 | schedule: 7 | interval: monthly 8 | 9 | - package-ecosystem: github-actions 10 | directory: / 11 | schedule: 12 | interval: monthly 13 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry and should not be changed by hand. 2 | package = [] 3 | 4 | [metadata] 5 | lock-version = "2.0" 6 | python-versions = "^3.6" 7 | content-hash = "ae1f216c71b9b712a5c479d19bf075b718c35d9248fd89cb1eb7624528ec5ad1" 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: tfeldmann 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: tfeldmann 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ["paypal.me/tfeldmann42"] 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "gpsdclient" 3 | version = "1.3.2" 4 | description = "A simple gpsd client." 5 | authors = ["Thomas Feldmann "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/tfeldmann/gpsdclient" 9 | keywords = ["gpsd", "gps", "client", "standalone", "socket", "stream"] 10 | classifiers = [ 11 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 12 | "Development Status :: 5 - Production/Stable", 13 | "Operating System :: OS Independent", 14 | "Topic :: Software Development :: Libraries", 15 | "Topic :: Utilities", 16 | ] 17 | 18 | [tool.poetry.scripts] 19 | gpsdclient = "gpsdclient.cli:main" 20 | 21 | [tool.poetry.dependencies] 22 | python = "^3.6" 23 | 24 | [tool.poetry.dev-dependencies] 25 | 26 | [build-system] 27 | requires = ["poetry-core>=1.0.0"] 28 | build-backend = "poetry.core.masonry.api" 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 16 | fail-fast: false 17 | 18 | env: 19 | USING_COVERAGE: "3.9" 20 | 21 | steps: 22 | - name: Checkout sources 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | python -m pip install pytest mypy 34 | 35 | - name: Run pytest 36 | run: | 37 | pytest 38 | 39 | - name: Run mypy 40 | run: | 41 | mypy . 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.3.2 (2023-01-09) 4 | 5 | - Remove timezone information from CLI output (it's always UTC). 6 | 7 | ## v1.3.1 (2022-10-31) 8 | 9 | - Add `py.typed` for full mypy type hint support. 10 | 11 | ## v1.3.0 (2022-09-02) 12 | 13 | - GPSDClient now supports a `timeout` param 14 | - GPSDClient now can be used as a context manager 15 | - Code cleanup, added more tests 16 | - parsed datetimes now contain the UTC timezone info 17 | 18 | ## v1.2.1 (2022-01-11) 19 | 20 | - Improved type hints 21 | 22 | ## v1.2.0 (2021-10-26) 23 | 24 | - add `filter` argument to return only the given report classes 25 | - Fixes json parsing (gpsd emits invalid json with trailing commas in some cases) 26 | 27 | ## v1.1.0 (2021-08-1) 28 | 29 | - Add "Climb"-column to readable output 30 | - Standalone client code cleanups 31 | - Updated tests 32 | 33 | ## v1.0.0 (2021-08-13) 34 | 35 | - Tabular data output in readable mode 36 | 37 | ## v0.2.0 (2021-08-13) 38 | 39 | - Check whether we really connect to a gpsd daemon 40 | - Improved error messages 41 | 42 | ## v0.1.0 (2021-08-13) 43 | 44 | - Initial release 45 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Thomas Feldmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | 139 | # pytype static type analyzer 140 | .pytype/ 141 | 142 | # Cython debug symbols 143 | cython_debug/ 144 | 145 | # End of https://www.toptal.com/developers/gitignore/api/python 146 | -------------------------------------------------------------------------------- /gpsdclient/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Connect to a running gpsd instance and show human readable output. 3 | """ 4 | 5 | from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser 6 | from collections import namedtuple 7 | 8 | from . import GPSDClient 9 | 10 | Column = namedtuple("Column", "key width formatter") 11 | 12 | TPV_COLUMNS = ( 13 | Column("mode", width=4, formatter=str), 14 | Column( 15 | "time", 16 | width=20, 17 | formatter=lambda x: x.strftime("%Y-%m-%d %H:%M:%S"), 18 | ), 19 | Column("lat", width=12, formatter=str), 20 | Column("lon", width=12, formatter=str), 21 | Column("track", width=6, formatter=str), 22 | Column("speed", width=6, formatter=str), 23 | Column("alt", width=9, formatter=str), 24 | Column("climb", width=9, formatter=str), 25 | ) 26 | NA = "n/a" 27 | 28 | 29 | def print_version(data): 30 | print("Connected to gpsd v%s" % data.get("release", NA)) 31 | 32 | 33 | def print_devices(data): 34 | output = ", ".join(x.get("path", NA) for x in data["devices"]) 35 | print("Devices: %s" % output) 36 | 37 | 38 | def print_tpv_header(): 39 | titles = (col.key.title().ljust(col.width) for col in TPV_COLUMNS) 40 | lines = ("-" * col.width for col in TPV_COLUMNS) 41 | print() 42 | print(" | ".join(titles)) 43 | print("-+-".join(lines)) 44 | 45 | 46 | def print_tpv_row(data): 47 | values = [] 48 | for key, width, formatter in TPV_COLUMNS: 49 | value = formatter(data[key]) if key in data else NA 50 | values.append(value.ljust(width)) 51 | print(" | ".join(values)) 52 | 53 | 54 | def stream_readable(client): 55 | needs_tpv_header = True 56 | for x in client.dict_stream(convert_datetime=True): 57 | if x["class"] == "VERSION": 58 | print_version(x) 59 | needs_tpv_header = True 60 | elif x["class"] == "DEVICES": 61 | print_devices(x) 62 | needs_tpv_header = True 63 | elif x["class"] == "TPV": 64 | if needs_tpv_header: 65 | print_tpv_header() 66 | needs_tpv_header = False 67 | print_tpv_row(x) 68 | 69 | 70 | def stream_json(client): 71 | for x in client.json_stream(): 72 | print(x) 73 | 74 | 75 | def main(): 76 | parser = ArgumentParser( 77 | formatter_class=ArgumentDefaultsHelpFormatter, 78 | description=__doc__, 79 | ) 80 | parser.add_argument( 81 | "--host", 82 | default="127.0.0.1", 83 | help="The host running GPSD", 84 | ) 85 | parser.add_argument( 86 | "--port", 87 | default="2947", 88 | help="GPSD port", 89 | ) 90 | parser.add_argument( 91 | "--json", 92 | action="store_true", 93 | help="Output as JSON strings", 94 | ) 95 | parser.add_argument( 96 | "--timeout", 97 | default=5.0, 98 | help="Socket timeout in seconds", 99 | ) 100 | args = parser.parse_args() 101 | 102 | try: 103 | client = GPSDClient(host=args.host, port=args.port, timeout=float(args.timeout)) 104 | if args.json: 105 | stream_json(client) 106 | else: 107 | stream_readable(client) 108 | except KeyboardInterrupt: 109 | print() 110 | return 0 111 | except (ConnectionError, EnvironmentError) as e: 112 | print(e) 113 | return 1 114 | except Exception: 115 | return 2 116 | -------------------------------------------------------------------------------- /tests/test_gpsdclient.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | from collections import Counter 4 | from datetime import datetime, timezone 5 | from typing import Any 6 | from unittest import mock 7 | 8 | import pytest 9 | from gpsdclient import GPSDClient 10 | from gpsdclient.client import parse_datetime 11 | 12 | from ._fake_server import GPSD_OUTPUT, VERSION_HEADER, fake_server 13 | 14 | 15 | @pytest.fixture 16 | def mock_client(): 17 | with mock.patch.object( 18 | GPSDClient, 19 | "gpsd_lines", 20 | return_value=(VERSION_HEADER + GPSD_OUTPUT).splitlines(), 21 | ): 22 | with GPSDClient() as client: 23 | yield client 24 | 25 | 26 | @pytest.fixture 27 | def server_client(): 28 | server = threading.Thread(target=fake_server) 29 | server.start() 30 | 31 | # wait for server thread coming alive 32 | time.sleep(1.0) 33 | while not server.is_alive(): 34 | time.sleep(0.1) 35 | 36 | with GPSDClient(port=20000) as client: 37 | yield client 38 | 39 | 40 | FILTER_TESTCASES: Any = ( 41 | ([], 9), 42 | (["TPV", "SKY"], 6), 43 | ("TPV,SKY", 6), 44 | (["TPV"], 3), 45 | ("TPV", 3), 46 | (["SKY"], 3), 47 | ("SKY", 3), 48 | ) 49 | 50 | 51 | @pytest.mark.parametrize("filter,count", FILTER_TESTCASES) 52 | def test_json_stream_filter(mock_client: GPSDClient, filter, count): 53 | lines = list(mock_client.json_stream(filter=filter)) 54 | assert len(lines) == count 55 | 56 | 57 | @pytest.mark.parametrize("filter,count", FILTER_TESTCASES) 58 | def test_dict_stream_filter(mock_client: GPSDClient, filter, count): 59 | lines = list(mock_client.dict_stream(filter=filter)) 60 | assert len(lines) == count 61 | 62 | 63 | @pytest.mark.parametrize( 64 | "input,output", 65 | ( 66 | ("2021-08-13T09:12:42.000Z", datetime(2021, 8, 13, 9, 12, 42, 0, timezone.utc)), 67 | (1662215327.967219, datetime(2022, 9, 3, 14, 28, 47, 967219, timezone.utc)), 68 | ("yesterday", "yesterday"), 69 | (object, object), 70 | ), 71 | ) 72 | def test_parse_datetime(input, output): 73 | assert output == parse_datetime(input) 74 | 75 | 76 | @pytest.mark.parametrize( 77 | "convert,timetype", 78 | ( 79 | (True, datetime), 80 | (False, str), 81 | ), 82 | ) 83 | def test_dict_time_conversion(mock_client: GPSDClient, convert, timetype): 84 | for line in mock_client.dict_stream(filter="TPV", convert_datetime=convert): 85 | if "time" in line: 86 | assert isinstance(line["time"], timetype) 87 | 88 | 89 | def test_json_stream(server_client): 90 | expected = (VERSION_HEADER + GPSD_OUTPUT).replace("\n\n", "\n") 91 | output = "" 92 | for row in server_client.json_stream(): 93 | output += row + "\n" 94 | assert output == expected 95 | 96 | 97 | def test_dict_stream(server_client): 98 | count = 0 99 | for row in server_client.dict_stream(): 100 | if row["class"] == "TPV": 101 | count += 1 102 | assert count == 3 103 | 104 | 105 | def test_dict_filter(server_client): 106 | counter = Counter() 107 | for row in server_client.dict_stream(filter=["SKY"]): 108 | counter[row["class"]] += 1 109 | assert counter["TPV"] == 0 110 | assert counter["SKY"] == 3 111 | 112 | 113 | def test_dict_filter_multiple(server_client): 114 | counter = Counter() 115 | for row in server_client.dict_stream(filter=["SKY", "TPV"]): 116 | counter[row["class"]] += 1 117 | assert counter["TPV"] == 3 118 | assert counter["SKY"] == 3 119 | -------------------------------------------------------------------------------- /gpsdclient/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple and lightweight GPSD client. 3 | """ 4 | import json 5 | import re 6 | import socket 7 | from datetime import datetime, timezone 8 | from typing import Any, Dict, Iterable, Union 9 | 10 | # old versions of gpsd with NTRIP sources emit invalid json which contains trailing 11 | # commas. As the json strings emitted by gpsd are well known to not contain structures 12 | # like `{"foo": ",}"}` it should be safe to remove all commas directly before curly 13 | # braces. (https://github.com/tfeldmann/gpsdclient/issues/1) 14 | REGEX_TRAILING_COMMAS = re.compile(r"\s*,\s*}") 15 | 16 | FilterType = Union[str, Iterable[str]] 17 | 18 | 19 | def parse_datetime(x: Any) -> Union[Any, datetime]: 20 | """ 21 | tries to convert the input into a `datetime` object if possible. 22 | """ 23 | try: 24 | if isinstance(x, float): 25 | return datetime.utcfromtimestamp(x).replace(tzinfo=timezone.utc) 26 | elif isinstance(x, str): 27 | # time zone information can be omitted because gps always sends UTC. 28 | result = datetime.strptime(x, "%Y-%m-%dT%H:%M:%S.%fZ") 29 | result = result.replace(tzinfo=timezone.utc) 30 | return result 31 | except ValueError: 32 | pass 33 | return x 34 | 35 | 36 | def create_filter_regex(reports: Union[str, Iterable[str]] = set()) -> str: 37 | """ 38 | Dynamically assemble a regular expression to match the given report classes. 39 | This way we don't need to parse the json to filter by report. 40 | """ 41 | if isinstance(reports, str): 42 | reports = reports.split(",") 43 | if reports: 44 | classes = set(x.strip().upper() for x in reports) 45 | return r'"class":\s?"(%s)"' % "|".join(classes) 46 | return r".*" 47 | 48 | 49 | class GPSDClient: 50 | def __init__( 51 | self, 52 | host: str = "127.0.0.1", 53 | port: Union[str, int] = "2947", 54 | timeout: Union[float, int, None] = None, 55 | ): 56 | self.host = host 57 | self.port = port 58 | self.timeout = timeout 59 | self.sock = None # type: Any 60 | 61 | def gpsd_lines(self): 62 | self.close() 63 | self.sock = socket.create_connection( 64 | address=(self.host, int(self.port)), 65 | timeout=self.timeout, 66 | ) 67 | self.sock.send(b'?WATCH={"enable":true,"json":true}\n') 68 | yield from self.sock.makefile("r", encoding="utf-8") 69 | 70 | def json_stream(self, filter: FilterType = set()) -> Iterable[str]: 71 | filter_regex = re.compile(create_filter_regex(filter)) 72 | 73 | expect_version_header = True 74 | for line in self.gpsd_lines(): 75 | answ = line.strip() 76 | if answ: 77 | if expect_version_header and not answ.startswith('{"class":"VERSION"'): 78 | raise EnvironmentError( 79 | "No valid gpsd version header received. Instead received:\n" 80 | "%s...\n" 81 | "Are you sure you are connecting to gpsd?" % answ[:100] 82 | ) 83 | expect_version_header = False 84 | 85 | if not filter or filter_regex.search(answ): 86 | cleaned_json = REGEX_TRAILING_COMMAS.sub("}", answ) 87 | yield cleaned_json 88 | 89 | def dict_stream( 90 | self, *, convert_datetime: bool = True, filter: FilterType = set() 91 | ) -> Iterable[Dict[str, Any]]: 92 | for line in self.json_stream(filter=filter): 93 | result = json.loads(line) 94 | if convert_datetime and "time" in result: 95 | result["time"] = parse_datetime(result["time"]) 96 | yield result 97 | 98 | def close(self): 99 | if self.sock: 100 | self.sock.close() 101 | self.sock = None 102 | 103 | def __str__(self): 104 | return "" % (self.host, self.port) 105 | 106 | def __enter__(self): 107 | return self 108 | 109 | def __exit__(self, exc_type, exc_value, exc_traceback): 110 | self.close() 111 | 112 | def __del__(self): 113 | self.close() 114 | -------------------------------------------------------------------------------- /tests/_fake_server.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import threading 3 | import time 4 | 5 | VERSION_HEADER = '{"class":"VERSION","release":"3.17","rev":"3.17","proto_major":3,"proto_minor":12}\n' 6 | WATCH_COMMAND = '?WATCH={"enable":true,"json":true}\n' 7 | GPSD_OUTPUT = """\ 8 | {"class":"DEVICES","devices":[{"class":"DEVICE","path":"/dev/ttyO4","driver":"NMEA0183","activated":"2021-08-13T09:12:40.028Z","flags":1,"native":0,"bps":9600,"parity":"N","stopbits":1,"cycle":1.00}]} 9 | {"class":"WATCH","enable":true,"json":true,"nmea":false,"raw":0,"scaled":false,"timing":false,"split24":false,"pps":false} 10 | {"class":"SKY","device":"/dev/ttyO4","xdop":0.54,"ydop":0.77,"vdop":0.85,"tdop":1.00,"hdop":0.89,"gdop":2.12,"pdop":1.23,"satellites":[{"PRN":27,"el":84,"az":141,"ss":0,"used":false},{"PRN":8,"el":60,"az":294,"ss":16,"used":true},{"PRN":10,"el":60,"az":109,"ss":16,"used":true},{"PRN":23,"el":40,"az":59,"ss":17,"used":false},{"PRN":16,"el":33,"az":188,"ss":26,"used":true},{"PRN":21,"el":28,"az":256,"ss":16,"used":false},{"PRN":18,"el":12,"az":69,"ss":26,"used":true},{"PRN":7,"el":9,"az":288,"ss":0,"used":false},{"PRN":30,"el":9,"az":321,"ss":32,"used":true},{"PRN":15,"el":8,"az":28,"ss":0,"used":false},{"PRN":26,"el":6,"az":175,"ss":0,"used":false},{"PRN":1,"el":3,"az":251,"ss":0,"used":false},{"PRN":32,"el":2,"az":133,"ss":0,"used":false},{"PRN":13,"el":1,"az":2,"ss":0,"used":false},{"PRN":138,"el":0,"az":0,"ss":0,"used":false},{"PRN":83,"el":66,"az":321,"ss":0,"used":false},{"PRN":82,"el":50,"az":68,"ss":0,"used":false},{"PRN":67,"el":43,"az":98,"ss":19,"used":true},{"PRN":73,"el":35,"az":261,"ss":17,"used":true},{"PRN":74,"el":29,"az":320,"ss":21,"used":true},{"PRN":66,"el":27,"az":33,"ss":30,"used":true},{"PRN":68,"el":17,"az":150,"ss":0,"used":false},{"PRN":84,"el":12,"az":279,"ss":0,"used":false},{"PRN":80,"el":11,"az":215,"ss":0,"used":false},{"PRN":81,"el":5,"az":88,"ss":0,"used":false}]} 11 | {"class":"TPV","device":"/dev/ttyO4","mode":3,"ept":0.005,"lat":51.813280233,"lon":6.550214200,"alt":30.393,"epx":8.171,"epy":11.499,"epv":19.550,"track":12.4500,"speed":0.000,"climb":0.000,"eps":23.00,"epc":39.10} 12 | {"class":"SKY","device":"/dev/ttyO4","xdop":0.54,"ydop":0.77,"vdop":0.85,"tdop":1.00,"hdop":0.89,"gdop":2.12,"pdop":1.23,"satellites":[{"PRN":27,"el":84,"az":141,"ss":0,"used":false},{"PRN":8,"el":60,"az":294,"ss":16,"used":true},{"PRN":10,"el":60,"az":109,"ss":16,"used":true},{"PRN":23,"el":40,"az":59,"ss":17,"used":false},{"PRN":16,"el":33,"az":188,"ss":26,"used":true},{"PRN":21,"el":28,"az":256,"ss":16,"used":false},{"PRN":18,"el":12,"az":69,"ss":26,"used":true},{"PRN":7,"el":9,"az":288,"ss":0,"used":false},{"PRN":30,"el":9,"az":321,"ss":33,"used":true},{"PRN":15,"el":8,"az":28,"ss":0,"used":false},{"PRN":26,"el":6,"az":175,"ss":0,"used":false},{"PRN":1,"el":3,"az":251,"ss":0,"used":false},{"PRN":32,"el":2,"az":133,"ss":0,"used":false},{"PRN":13,"el":1,"az":2,"ss":0,"used":false},{"PRN":138,"el":0,"az":0,"ss":0,"used":false},{"PRN":83,"el":66,"az":321,"ss":0,"used":false},{"PRN":82,"el":50,"az":68,"ss":0,"used":false},{"PRN":67,"el":43,"az":98,"ss":19,"used":true},{"PRN":73,"el":35,"az":261,"ss":16,"used":true},{"PRN":74,"el":29,"az":320,"ss":21,"used":true},{"PRN":66,"el":27,"az":33,"ss":30,"used":true},{"PRN":68,"el":17,"az":150,"ss":0,"used":false},{"PRN":84,"el":12,"az":279,"ss":0,"used":false},{"PRN":80,"el":11,"az":215,"ss":0,"used":false},{"PRN":81,"el":5,"az":88,"ss":0,"used":false}]} 13 | {"class":"TPV","device":"/dev/ttyO4","mode":3,"time":"2021-08-13T09:12:41.000Z","ept":0.005,"lat":51.813280233,"lon":6.550214200,"alt":30.393,"epx":8.171,"epy":11.499,"epv":19.550,"track":12.4500,"speed":0.000,"climb":0.000,"eps":23.00,"epc":39.10} 14 | {"class":"SKY","device":"/dev/ttyO4","xdop":0.54,"ydop":0.77,"vdop":0.85,"tdop":1.00,"hdop":0.89,"gdop":2.12,"pdop":1.22,"satellites":[{"PRN":27,"el":84,"az":141,"ss":0,"used":false},{"PRN":8,"el":60,"az":294,"ss":16,"used":true},{"PRN":10,"el":60,"az":109,"ss":17,"used":true},{"PRN":23,"el":40,"az":59,"ss":17,"used":false},{"PRN":16,"el":33,"az":188,"ss":26,"used":true},{"PRN":21,"el":28,"az":256,"ss":16,"used":false},{"PRN":18,"el":12,"az":69,"ss":26,"used":true},{"PRN":7,"el":9,"az":288,"ss":0,"used":false},{"PRN":30,"el":9,"az":321,"ss":33,"used":true},{"PRN":15,"el":8,"az":28,"ss":0,"used":false},{"PRN":26,"el":6,"az":175,"ss":0,"used":false},{"PRN":1,"el":3,"az":251,"ss":0,"used":false},{"PRN":32,"el":2,"az":133,"ss":0,"used":false},{"PRN":13,"el":1,"az":2,"ss":0,"used":false},{"PRN":138,"el":0,"az":0,"ss":0,"used":false},{"PRN":83,"el":66,"az":321,"ss":0,"used":false},{"PRN":82,"el":50,"az":68,"ss":0,"used":false},{"PRN":67,"el":43,"az":98,"ss":19,"used":true},{"PRN":73,"el":35,"az":261,"ss":15,"used":true},{"PRN":74,"el":29,"az":320,"ss":21,"used":true},{"PRN":66,"el":27,"az":33,"ss":30,"used":true},{"PRN":68,"el":17,"az":150,"ss":0,"used":false},{"PRN":84,"el":12,"az":279,"ss":0,"used":false},{"PRN":80,"el":11,"az":215,"ss":0,"used":false},{"PRN":81,"el":5,"az":88,"ss":0,"used":false}]} 15 | {"class":"TPV","device":"/dev/ttyO4","mode":3,"time":"2021-08-13T09:12:42.000Z","ept":0.005,"lat":51.813280233,"lon":6.550214200,"alt":30.393,"epx":8.171,"epy":11.499,"epv":19.550,"track":12.4500,"speed":0.000,"climb":0.000,"eps":23.00,"epc":39.10} 16 | """ 17 | 18 | 19 | def fake_server(): 20 | addr = ("127.0.0.1", 20000) 21 | if hasattr(socket, "create_server"): 22 | sock = socket.create_server(address=addr, reuse_port=True) 23 | else: 24 | sock = socket.socket() 25 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 26 | sock.bind(addr) 27 | sock.listen(1) 28 | client, _ = sock.accept() 29 | client.send(VERSION_HEADER.encode("utf-8")) 30 | if client.recv(100) == WATCH_COMMAND.encode("utf-8"): 31 | n = 120 32 | chunks = [GPSD_OUTPUT[i : i + n] for i in range(0, len(GPSD_OUTPUT), n)] 33 | for chunk in chunks: 34 | client.send(chunk.encode("utf-8")) 35 | time.sleep(0.001) 36 | 37 | 38 | def main(): 39 | server = threading.Thread(target=fake_server) 40 | server.start() 41 | 42 | 43 | if __name__ == "__main__": 44 | main() 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gpsdclient 2 | 3 | [![PyPI Version][pypi-version]][pypi-url] 4 | [![PyPI License][pypi-license]][mit-license] 5 | [![tests][test-badge]][test-url] 6 | 7 | > A simple and lightweight [gpsd](https://gpsd.gitlab.io/gpsd) client and library 8 | 9 | ## Installation 10 | 11 | Needs Python 3 (no other dependencies). 12 | If you want to use the library, use pip: 13 | 14 | ``` 15 | pip3 install gpsdclient 16 | ``` 17 | 18 | If you want to use only the standalone gpsd viewer, I recommend to use pipx: 19 | 20 | ``` 21 | pipx install gpsdclient 22 | ``` 23 | 24 | ## Library usage 25 | 26 | ```python 27 | from gpsdclient import GPSDClient 28 | 29 | # get your data as json strings: 30 | with GPSDClient(host="127.0.0.1") as client: 31 | for result in client.json_stream(): 32 | print(result) 33 | 34 | # or as python dicts (optionally convert time information to `datetime` objects) 35 | with GPSDClient() as client: 36 | for result in client.dict_stream(convert_datetime=True, filter=["TPV"]): 37 | print("Latitude: %s" % result.get("lat", "n/a")) 38 | print("Longitude: %s" % result.get("lon", "n/a")) 39 | 40 | # you can optionally filter by report class 41 | with GPSDClient() as client: 42 | for result in client.dict_stream(filter=["TPV", "SKY"]): 43 | print(result) 44 | ``` 45 | 46 | You can find the documentation for the available data and JSON fields in the 47 | [gpsd_json(5) manpage](https://www.mankier.com/5/gpsd_json). 48 | 49 | ## Command line usage 50 | 51 | You can use the `gpsdclient` standalone program or execute the module with 52 | `python3 -m gpsdclient`. 53 | 54 | ``` 55 | $ gpsdclient 56 | Connected to gpsd v3.17 57 | Devices: /dev/ttyO4 58 | 59 | Mode | Time | Lat | Lon | Speed | Track | Alt | Climb 60 | -----+----------------------+--------------+--------------+--------+--------+-----------+----------- 61 | 1 | n/a | n/a | n/a | n/a | n/a | n/a | n/a 62 | 1 | n/a | n/a | n/a | n/a | n/a | n/a | n/a 63 | 1 | n/a | n/a | n/a | n/a | n/a | n/a | n/a 64 | 3 | n/a | 51.813360383 | 6.550329033 | n/a | n/a | 46.518 | 0.0 65 | 3 | n/a | 51.813360383 | 6.550329033 | n/a | n/a | 46.518 | 0.0 66 | 3 | 2021-08-13 14:06:25 | 51.813360383 | 6.550329033 | 0.674 | 260.53 | 46.518 | 0.0 67 | 3 | 2021-08-13 14:06:27 | 51.81335905 | 6.550316283 | 0.54 | 245.71 | 46.002 | 0.0 68 | 3 | 2021-08-13 14:06:28 | 51.8133673 | 6.55033345 | 0.422 | 241.88 | 46.476 | 0.0 69 | 3 | 2021-08-13 14:06:29 | 51.813365833 | 6.5503352 | 0.34 | 246.35 | 46.868 | 0.0 70 | 3 | 2021-08-13 14:06:30 | 51.81336285 | 6.550339117 | 0.242 | 246.35 | 47.22 | 0.0 71 | 3 | 2021-08-13 14:06:31 | 51.8133614 | 6.550350367 | 0.273 | 246.35 | 46.846 | 0.0 72 | 3 | 2021-08-13 14:06:32 | 51.813359233 | 6.550353767 | 0.226 | 246.35 | 46.635 | 0.0 73 | 3 | 2021-08-13 14:06:33 | 51.8133574 | 6.550349817 | 0.221 | 246.35 | 46.52 | 0.0 74 | 3 | 2021-08-13 14:06:34 | 51.813356733 | 6.550345917 | 0.319 | 274.21 | 46.453 | 0.0 75 | 3 | 2021-08-13 14:06:35 | 51.813357917 | 6.5503521 | 0.149 | 274.21 | 46.529 | 0.0 76 | ^C 77 | ``` 78 | 79 | Or use the raw json mode: 80 | 81 | ```json 82 | $ gpsdclient --json 83 | {"class":"VERSION","release":"3.17","rev":"3.17","proto_major":3,"proto_minor":12} 84 | {"class":"DEVICES","devices":[{"class":"DEVICE","path":"/dev/ttyO4","driver":"NMEA0183","activated":"2021-08-13T12:25:00.896Z","flags":1,"native":0,"bps":9600,"parity":"N","stopbits":1,"cycle":1.00}]} 85 | {"class":"WATCH","enable":true,"json":true,"nmea":false,"raw":0,"scaled":false,"timing":false,"split24":false,"pps":false} 86 | {"class":"SKY","device":"/dev/ttyO4","xdop":0.87,"ydop":1.86,"vdop":0.93,"tdop":2.26,"hdop":1.36,"gdop":3.96,"pdop":1.65,"satellites":[{"PRN":1,"el":84,"az":318,"ss":22,"used":true},{"PRN":22,"el":78,"az":234,"ss":16,"used":true},{"PRN":21,"el":72,"az":115,"ss":0,"used":false},{"PRN":3,"el":55,"az":239,"ss":19,"used":true},{"PRN":17,"el":34,"az":309,"ss":20,"used":true},{"PRN":32,"el":32,"az":53,"ss":32,"used":true},{"PRN":8,"el":21,"az":172,"ss":13,"used":false},{"PRN":14,"el":18,"az":274,"ss":13,"used":false},{"PRN":131,"el":10,"az":115,"ss":0,"used":false},{"PRN":19,"el":9,"az":321,"ss":33,"used":true},{"PRN":4,"el":4,"az":187,"ss":0,"used":false},{"PRN":31,"el":1,"az":106,"ss":0,"used":false},{"PRN":69,"el":80,"az":115,"ss":17,"used":true},{"PRN":84,"el":73,"az":123,"ss":0,"used":false},{"PRN":85,"el":42,"az":318,"ss":26,"used":true},{"PRN":68,"el":33,"az":39,"ss":0,"used":false},{"PRN":70,"el":27,"az":208,"ss":0,"used":false},{"PRN":76,"el":12,"az":330,"ss":19,"used":true},{"PRN":83,"el":12,"az":133,"ss":16,"used":false},{"PRN":77,"el":9,"az":18,"ss":0,"used":false}]} 87 | {"class":"TPV","device":"/dev/ttyO4","mode":3,"time":"2021-08-13T12:25:01.000Z","ept":0.005,"lat":51.813525983,"lon":6.550081367,"alt":63.037,"epx":13.150,"epy":27.967,"epv":21.390,"track":211.3400,"speed":0.000,"climb":0.000,"eps":62.58,"epc":42.78} 88 | ^C 89 | ``` 90 | 91 | All command line options: 92 | 93 | ``` 94 | $ gpsdclient -h 95 | usage: gpsdclient [-h] [--host HOST] [--port PORT] [--json] 96 | 97 | Connect to a running gpsd instance and show human readable output. 98 | 99 | optional arguments: 100 | -h, --help show this help message and exit 101 | --host HOST The host running GPSD (default: 127.0.0.1) 102 | --port PORT GPSD port (default: 2947) 103 | --json Output as JSON strings (default: False) 104 | ``` 105 | 106 | ## Why 107 | 108 | I made this because I just needed a simple client library to read the json data gpsd is 109 | sending. 110 | The other python libraries have various problems, like 100 % cpu usage, missing python 3 111 | support, license problems, lots of dependencies or they aren't available on PyPI. 112 | I also wanted a simple gpsd client to check if everything is working. 113 | 114 | This client is as simple as possible with one exception: It supports the automatic 115 | conversion of "time" data into `datetime.datetime` objects. 116 | 117 | Have fun, hope you like it. 118 | 119 | ## License 120 | 121 | [MIT][mit-license] 122 | 123 | 124 | 125 | [pypi-version]: https://img.shields.io/pypi/v/gpsdclient 126 | [pypi-license]: https://img.shields.io/pypi/l/gpsdclient 127 | [pypi-url]: https://pypi.org/project/gpsdclient/ 128 | [mit-license]: https://choosealicense.com/licenses/mit/ 129 | [test-badge]: https://github.com/tfeldmann/gpsdclient/actions/workflows/tests.yml/badge.svg?branch=main 130 | [test-url]: https://github.com/tfeldmann/gpsdclient/actions/workflows/tests.yml 131 | --------------------------------------------------------------------------------