├── pwnagotchi.jpeg
├── .gitignore
├── gpsd-ng.html
├── README.md
├── ntrip-selector.py
├── LICENSE
└── gpsd-ng.py
/pwnagotchi.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fmatray/pwnagotchi_GPSD-ng/HEAD/pwnagotchi.jpeg
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # UV
98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | #uv.lock
102 |
103 | # poetry
104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105 | # This is especially recommended for binary packages to ensure reproducibility, and is more
106 | # commonly ignored for libraries.
107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108 | #poetry.lock
109 |
110 | # pdm
111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112 | #pdm.lock
113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114 | # in version control.
115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116 | .pdm.toml
117 | .pdm-python
118 | .pdm-build/
119 |
120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121 | __pypackages__/
122 |
123 | # Celery stuff
124 | celerybeat-schedule
125 | celerybeat.pid
126 |
127 | # SageMath parsed files
128 | *.sage.py
129 |
130 | # Environments
131 | .env
132 | .venv
133 | env/
134 | venv/
135 | ENV/
136 | env.bak/
137 | venv.bak/
138 |
139 | # Spyder project settings
140 | .spyderproject
141 | .spyproject
142 |
143 | # Rope project settings
144 | .ropeproject
145 |
146 | # mkdocs documentation
147 | /site
148 |
149 | # mypy
150 | .mypy_cache/
151 | .dmypy.json
152 | dmypy.json
153 |
154 | # Pyre type checker
155 | .pyre/
156 |
157 | # pytype static type analyzer
158 | .pytype/
159 |
160 | # Cython debug symbols
161 | cython_debug/
162 |
163 | # PyCharm
164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166 | # and can be added to the global gitignore or merged into this file. For a more nuclear
167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168 | #.idea/
169 |
170 | # PyPI configuration file
171 | .pypirc
172 |
--------------------------------------------------------------------------------
/gpsd-ng.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% set active_page = "GPSD-ng" %}
3 | {% block title %}
4 | {{ title }}
5 | {% endblock %}
6 | {% block meta %}
7 |
8 |
9 | {% endblock %}
10 | {% block styles %}
11 | {{ super() }}
12 |
38 | {% endblock %}
39 | {% block script %}
40 | // Add events
41 | document.addEventListener("DOMContentLoaded", () => {
42 | const refresh_images = document.querySelector('#refresh_images');
43 |
44 | refresh_images.addEventListener("click", async () => {
45 | await refreshPolarImage(refresh_images);
46 | });
47 |
48 |
49 | const restart_gpsd = document.querySelector('#restart_gpsd');
50 | restart_gpsd.addEventListener("click", async () => {
51 | await restartGPSD(restart_gpsd);
52 | });
53 | });
54 |
55 |
56 | // Refresh polar Image
57 | async function refreshPolarImage(button) {
58 | const images = document.querySelectorAll('img[id^="polar_"]');
59 |
60 | for (const image of images) {
61 | try {
62 | const response = await fetch(`${window.location.origin}/plugins/gpsd-ng/polar?device=${image.dataset.device}`);
63 | if (response.ok) {
64 | const base64Image = await response.text();
65 | image.src = `data:image/png;base64,${base64Image}`;
66 | button.classList.add('ui-btn-success');
67 | } else {
68 | console.error("Failed to fetch the image");
69 | button.classList.add('ui-btn-error');
70 | }
71 | } catch (error) {
72 | console.error("An error occurred while fetching the image:", error);
73 | button.classList.add('ui-btn-error');
74 | }
75 | }
76 |
77 | setTimeout(() => {
78 | button.classList.remove('ui-btn-success');
79 | button.classList.remove('ui-btn-error');
80 | }, 5000);
81 | }
82 |
83 | // Restart GPSD
84 | async function restartGPSD(button) {
85 | try {
86 | const response = await fetch(`${window.location.origin}/plugins/gpsd-ng/restart_gpsd`);
87 | if (response.ok) {
88 | console.info("GPSD restarted");
89 | button.classList.add('ui-btn-success');
90 |
91 | } else {
92 | console.error("Failed to restart GPSD");
93 | button.classList.add('ui-btn-error');
94 | }
95 | } catch (error) {
96 | console.error("An error occurred while retstarting GPSD:", error);
97 | button.classList.add('ui-btn-error');
98 | }
99 |
100 | setTimeout(() => {
101 | button.classList.remove('ui-btn-success');
102 | button.classList.remove('ui-btn-error');
103 | }, 5000);
104 | };
105 |
106 | {% endblock %}
107 | {% block content %}
108 |
109 |
GPS Statistics
110 |
111 |
112 |
113 | {{statistics["nb_devices"]}} {% if statistics["nb_devices"] <= 1 %}device{% else %}devices{%endif%} found {% if
114 | not device in positions %}(No device used){% endif %}
115 | {{statistics["completeness"]}}% of completeness
116 | ({{statistics["nb_position_files"]}} position files for {{statistics["nb_pcap_files"]}} pcap files)
117 | {{statistics["nb_cached_elevation"]}} cached elevations
118 |
119 | {% if current_position %}
120 |
121 | Current position: {% set lat_long = current_position.format_lat_long() %}
122 |
124 | {{lat_long[0]}}, {{lat_long[1]}} ({{current_position.fix}})
125 |
126 |
127 | {% endif %}
128 |
129 | {% for dkey in positions %}
130 |
131 |
132 |
133 |
134 | {% if positions[dkey] %}
135 |
136 | {% if device == dkey %}
137 | {% set header_class="used" %}
138 | {% set usage_msg="used" %}
139 | {% elif positions[dkey].is_fixed() %}
140 | {% set header_class="notused" %}
141 | {% set usage_msg="not used" %}
142 | {% else %}
143 | {% set header_class="nofix" %}
144 | {% set usage_msg="no fix" %}
145 | {% endif %}
146 |
147 |
148 | {% set lat_long = positions[dkey].format_lat_long() %}
149 |
150 | {% if positions[dkey].is_fixed() %}
151 |
153 | {{lat_long[0]}}, {{lat_long[1]}} ({{positions[dkey].fix}})
154 |
155 | {% else %}
156 | {{lat_long[0]}}, {{lat_long[1]}} ({{positions[dkey].fix}})
157 | {% endif %}
158 |
159 |
160 |
Altitude: {{positions[dkey].format_altitude(units)}},
Speed:
161 | {{positions[dkey].format_speed(units)}}
162 |
Sattelites: {{positions[dkey].used_satellites}}
163 | used/{{positions[dkey].seen_satellites}}
164 | seen
165 |
Last fix:
166 | {% if positions[dkey].last_fix %}{{positions[dkey].last_fix.strftime('%d/%m/%Y %H:%M:%S %Z')}}
167 | ({{positions[dkey].last_fix_ago}}s ago)
168 | {% else %}No date{% endif %}
169 |
170 |
Last update:
171 | {% if positions[dkey].last_update %}{{positions[dkey].last_update.strftime('%d/%m/%Y %H:%M:%S %Z')}}
172 | ({{positions[dkey].last_update_ago}}s ago)
173 | {% else %}No date{% endif %}
174 |
175 |
Accuracy: {{positions[dkey].accuracy}}
176 | {% else %}
177 |
No data
178 | {% endif %}
179 |
180 |
181 |
}})
184 |
185 |
186 |
187 | {% endfor %}
188 |
196 | {% endblock %}
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GPSD-ng
2 | Use GPSD server to retreive and save coordinates on handshake. Can use mutiple gps device (gps modules, USB dongle, phone, etc.)
3 |
4 | 
5 |
6 | __Advantages with gpsd server__:
7 | - GPS configuration independant from pwnagotchi
8 | - Early position polling
9 | - No position lost on bettercap/pwnagotchi restarts
10 | - High compatibility (device, protocol, vendor, version): NMEA/ublox modules (Serial), USB modules, Android/IPhone
11 | - Non blocking access to GPS information
12 | - GPS hotplugin
13 | - Compatibility with other applications like chrony
14 | - Compatible with NTRIP/RTK/RTCM
15 |
16 | __Exemple__:\
17 | GPS module/dongle and/or Phone (IOS/Android) ------> GPSD ------> GPSD-ng ------> Pwnagotchi
18 |
19 | # Features
20 | - Client to GPSD server with multi device management
21 | - Several customable UI modes and a Web UI
22 | - Save position on handshake and fallback for pcap without position
23 | - Unit option: metric or imperial
24 | - Use open elevation and cache for 2D fix.
25 | - Show completeness statistic (percentage of pcap files with a valid position file)
26 | - Two hooks: ```on_position_available(coords)``` and ```on_position_lost()```
27 | - Non blocking plugin
28 |
29 | # Install GPSD server
30 | - Install binaries:
31 | - __version 3.22__: APT method: ```apt-get install gpsd gpsd-clients python3-gps```
32 | - __version 3.24__:
33 | - Download in a folder the following packages from https://archive.raspberrypi.org/debian/pool/untested/g/gpsd/:
34 | - gpsd_3.24-1~rpt1_arm64.deb
35 | - gpsd-clients_3.24-1~rpt1_arm64.deb
36 | - gpsd-tools_3.24-1~rpt1_arm64.deb
37 | - libgps29_3.24-1~rpt1_arm64.deb
38 | - python3-gps_3.24-1~rpt1_arm64.deb
39 | - Install with ```dpkg -i *.deb```
40 | - __version 3.25__: Build from source (https://gpsd.gitlab.io/gpsd/building.html)
41 | - Configure GPSD (/etc/default/gpsd) and uncomment one DEVICES:
42 | ```
43 | # Default settings for the gpsd init script and the hotplug wrapper.
44 |
45 | # Start the gpsd daemon automatically at boot time
46 | START_DAEMON="true"
47 |
48 | # Use USB hotplugging to add new USB devices automatically to the daemon
49 | USBAUTO="false"
50 |
51 | # Devices gpsd should collect to at boot time.
52 | # They need to be read/writeable, either by user gpsd or the group dialout.
53 | DEVICES=""
54 |
55 | # Other options you want to pass to gpsd
56 | GPSD_OPTIONS="-n" # add -D3 if you need to debug
57 | ```
58 |
59 | ## Serial GPS
60 | - Check in raspi-config -> Interface Options -> Serial Port:
61 | - __Disable__ Serial Port login
62 | - __Enable__ Serial Port
63 | - Check your gps baudrate.
64 | - Set ```DEVICES="-s BAUDRATE /dev/ttyS0"``` in /etc/default/gpsd
65 |
66 | ## USB GPS
67 | - Set ```USBAUTO="true"``` in /etc/default/gpsd
68 | - No need to set DEVICES
69 |
70 | ## Phone GPS
71 | - On your phone:
72 | - Setup the plugin bt-tether and check you can ping your phone
73 | - Install a GPS app:
74 | - __Android__(not tested):
75 | - BlueNMEA: https://github.com/MaxKellermann/BlueNMEA
76 | - gpsdRelay: https://github.com/project-kaat/gpsdRelay
77 | - __IOS__: GPS2IP (tested but paid app)
78 | - Set "operate in background mode"
79 | - Set "Connection Method" -> "Socket" -> "Port Number" -> 4352
80 | - Set "Network selection" -> "Hotspot"
81 | - Both cases activate GGA messages to have "3D fix"
82 | - Check your gpsd configuration with gpsmon or cgps
83 | - Set ```DEVICES="tcp://PHONEIP:4352"``` in /etc/default/gpsd
84 |
85 | ## Multiple devices
86 | You can configure several devices in DEVICES
87 | Ex: ```DEVICES="-s BAUDRATE /dev/ttyS0 tcp://PHONEIP:4352"```
88 |
89 | # Install plugin
90 | - Install GEOPY: ```apt-get install python3-geopy```
91 | - Copy gpsd-ng.py and gpsd-ng.html to your custom plugins directory
92 |
93 | # Configure plugin (Config.toml)
94 | ```
95 | [main.plugins.gpsd]
96 | enabled = true
97 |
98 | # Options with default settings.
99 | # Add only if you need customisation
100 | gpsdhost = "127.0.0.1"
101 | gpsdport = 2947
102 | main_device = "/dev/ttyS0" # if not provided, the puglin will try to retreive the most accurate position
103 | wifi_positioning = false # Onlly in AUTO mode, tries
104 | update_timeout = 120 # default 120, Delay without update before deleting the position. 0 = no timeout
105 | fix_timeout = 120 # default 120, Delay without fix before deleting the position. 0 = no timeout
106 | use_open_elevation = true # if true, use open-elevation API to retreive missing altitudes. Use it if you have a poor GPS signal.
107 | save_elevations = true # if true, elevations cache will be saved to disk. Be carefull as it can grow fast if move a lot.
108 | view_mode = "compact" # "compact", "full", "status", "none"
109 | fields = "info,speed,altitude" # list or string of fields to display
110 | units = "metric" # "metric" or "imperial"
111 | display_precision = 6 # display precision for latitude and longitude
112 | position = "127,64"
113 | show_faces = true # if false, doesn't show face. Ex if you use PNG faces
114 | lost_face_1 = "(O_o )"
115 | lost_face_2 = "( o_O)"
116 | face_1 = "(•_• )"
117 | face_2 = "( •_•)"
118 | ```
119 |
120 | # Usage
121 | ## Retreive GPS Position
122 | This plugin can be used for wardriving with the wigle plugin, for example.
123 | - __Outdoor__: GPS module/dongle works fine.
124 | - __Indoor__: is the GPS module/dongle doesn't work, you can use your phone.
125 |
126 | If main_device is not set (default), the device with the most accurate (base on fix information) and most recent position, will be selected.
127 |
128 | If main_device is set, the plugin will use that main device position, if available.
129 | If the main device is not available, it will fallback to other devices.
130 |
131 | If the device can only get 2D positions for some reason (poor signal, wrong device orientation, bad luck, etc.), the plugin can use open-elevation API to try to ask current altitude.
132 | To avoid many call to the API, each request asks for points every ~10m around you, in a diameter of 200m. This cache can be saved to disk.
133 |
134 | After a delay (set by update_timeout) without data update for a device, the position will be deleted.
135 | If update_timeout is set to 0, positions never expire.
136 |
137 | After a delay (set by fix_timeout) without data fix for a device, the last position will be deleted.
138 | If fix_timeout is set to 0, positions fix never expire. Usefull for keeping last position when goind indoor.
139 |
140 | ## Wifi positioning
141 | Only available with automode.
142 | The plugin tries to guess the current position by calculation a median location of surrounding wifis.
143 | This location is only used, if a gps module is not available and will only be saved on handshake, if the no previous position was saved.
144 |
145 | ## Improve positioning with RTCM (need gpsd > 3.24)
146 | If you have a GPs module or dongle with RTCM capabilities, you can activate with GPSD.
147 | Exemple with a Ublox (firmware 34.10) and GPSD 3.25:
148 | - ublox setup:
149 | - ubxtool -p MON-VER | grep PROT -> retreive ublox version XX.YY (34.10 for me)
150 | - export UBXOPTS="-P XX.YY -v 2"
151 | - ubxtool -e RTCM3
152 | - GPSD setup:
153 | - Find a local (< 30km) RTK provider (https://rtkdata.online/network)
154 | - You need host/port and mountpoint information
155 | - Check with the following command. It should stream binary data.\
156 | curl -v -H "Ntrip-Version: Ntrip/2.0" -H "User-Agent: NTRIP theSoftware/theRevision" http://[user:pwd@]host:2101/mountpoint -o -
157 | - Add "ntrip://[user:pwd@]host:2101/mountpoint" to DEVICES in GPSD configuration
158 | - Now GPSD command should look like with ps: 'gpsd -n ntrip://host:2101/MOUNTPOINT -s 38400 /dev/ttyS0'
159 |
160 | Of course, you can still append your phone 'gpsd -N -D3 ntrip://host:2101/MOUNTPOINT -s 38400 /dev/ttyS0 tcp://172.20.10.1:4352'
161 | More info on: https://gpsd.gitlab.io/gpsd/ubxtool-examples.html#_survey_in_and_rtcm
162 |
163 | ## UI views
164 | The "compact" view mode (default) option show gps informations, on one line, in rotation:
165 | - Latitude,Longitude
166 | - Info: Device source + Fix information
167 | - Speed, Altitude
168 |
169 | If you prefer a more traditionnal view, use "full" mode:
170 | - Latitude
171 | - Longitude
172 | - Altitude
173 | - Speed
174 |
175 | You can show or not with the fields option. by default, it will display all.
176 | If you want a clear display, use "none", nothing will be display.
177 |
178 | If you like it very brief, try the "status" mode, only 4 letters in status bar.
179 |
180 | ## Web views
181 | You should take a look at the Web UI with fancy graphs ;-)
182 |
183 | # Units
184 | You can use metric or imperial units for altitude(m or ft) and speed (m/s or ft/s).
185 | This only changes on display, not gps.json files, as Wigle needs metric units.
186 | Default is metric because it's the International System.
187 |
188 | ## Handshake
189 | - Set gps position to bettercap (it's also done on internet_available() and on_unfiltered_ap_list())
190 | - Saves position informations into "gps.json" (compatible with Wigle and webgpsmap)
191 |
192 | Note: During on_unfiltered_ap_list(), if an access point whith pcap files but without gps file is detected, this plugin will save the current position for that AP. This is a fallback, if the position was not available during handshake().
193 |
194 | ## Bettercap
195 | Gps option is set to off. Position is update by the plugin to Bettercap, on handshake, internet_available and on_unfiltered_ap_list.
196 |
197 | ## Developpers
198 | This plugin adds two plugin hooks, triggered every 10 seconds:
199 | - If a position is available, the hook ```on_position_available(coords)``` is called with a dictionnary (see below)
200 | - If no position is available, the hook ```on_position_lost() is called
201 |
202 | The coords dictionnary:
203 | - Latitude, Longitude (float)
204 | - Altitude (float): Sea elevation
205 | - Speed (float): Horizontal speed
206 | - Date, Updated (datetime): Last fix
207 | - Mode (int 2 or 3), Fix (str): Fix mode (2D or 3D). 0 and 1 are removec
208 | - Sats (int), Sats_used(int): Nb of seen satellites and used
209 | - Device (str): GPS device (ex: /dev/ttyS0)
210 | - Accuracy (int): default 50
211 | All data are metric only.
212 |
213 | ## Troubleshooting: Have you tried to turn it off and on again?
214 | ### "[GPSD-ng] Error while importing matplotlib for generate_polar_plot()"
215 | matplotlib is not up to date in /home/pi/.pwn:
216 | - su -
217 | - cd /home/pi/.pwn
218 | - source bin/activate
219 | - pip install scipy numpy matplotlib --upgrade
220 |
221 | ### "[Errno 2] No such file or directory: '/usr/local/share/pwnagotchi/custom-plugins/gpsd-ng.html'"
222 | gpsd-ng.html is missing, just copy :-)
223 |
224 | ### "TypeError: JSONDecoder.init() got an unexpected keyword argument 'encoding'"
225 | The gpsd python library, called "gps", is old (around 2020).
226 | An update will do the trick.
227 |
228 | ### "[GPSD-ng] Error while connecting to GPSD: [Errno 111] Connection refused"
229 | - GPSD server is not running:
230 | - Try to restart gpsd: sudo systemctl restart gpsd
231 | - Check status: sudo systemctl status gpsd
232 | - Check logs
233 | - GPSD server is not configured. Check install section.
234 | - GPSD configuration is wrong:
235 | - Try "cgps" or "gpsmon" to check if you have readings
236 |
237 | ### GPSD server is running and the plugin is connected but I have no position
238 | - Check with "cgps" if gpsd can retreive data from gps modules
239 | - The plugin filters data without fix. You can check on the plugin's webpage.
240 |
241 | # TODO
242 | - [ ] Run around the World!
243 |
244 | # Based on:
245 | - Pwnagotchi:
246 | - https://github.com/evilsocket
247 | - https://github.com/jayofelony/pwnagotchi
248 | - GPSD: https://gpsd.gitlab.io/gpsd/index.html
249 | - Original plugin and fork:
250 | - https://github.com/kellertk/pwnagotchi-plugin-gpsd
251 | - https://github.com/nothingbutlucas/pwnagotchi-plugin-gpsd
252 | - Polar graph: https://github.com/rai68/gpsd-easy/blob/main/gpsdeasy.py
253 |
254 | Have fun !
255 |
--------------------------------------------------------------------------------
/ntrip-selector.py:
--------------------------------------------------------------------------------
1 | import requests
2 | import logging
3 | import csv
4 | import json
5 | import os
6 | import glob
7 | import re
8 | import math
9 | from datetime import datetime, UTC
10 | from dataclasses import dataclass, field, asdict
11 | import subprocess
12 | from typing import Union, Dict, Tuple, Optional
13 | from threading import Lock
14 | import geopy.distance
15 | import pwnagotchi.plugins as plugins
16 |
17 |
18 | @dataclass
19 | class Caster:
20 | host: str
21 | port: str
22 | identifier: str
23 | operator: str
24 | nmea: bool
25 | country: str
26 | latitude: float
27 | longitude: float
28 | fallback_host: str
29 | fallback_port: str
30 |
31 |
32 | @dataclass
33 | class Network:
34 | identifier: str
35 | operator: str
36 |
37 |
38 | @dataclass
39 | class Stream:
40 | mountpoint: str
41 | identifier: str
42 | format: str
43 | carrier: str
44 | network: str
45 | country: str
46 | latitude: float
47 | longitude: float
48 | nmea: bool
49 | auth: str
50 |
51 |
52 | @dataclass
53 | class SourceTable:
54 | url: str
55 | casters: dict[str, Caster] = field(default_factory=dict)
56 | networks: dict[str, Network] = field(default_factory=dict)
57 | streams: dict[str, Stream] = field(default_factory=dict)
58 |
59 | def add_caster(self, caster: Caster):
60 | self.casters[caster.operator] = caster
61 |
62 | def add_network(self, network: Network):
63 | self.networks[network.operator] = network
64 |
65 | def add_stream(self, stream: Stream):
66 | self.streams[stream.mountpoint] = stream
67 |
68 | @staticmethod
69 | def find_closest(
70 | objects: Dict[str, Union[Caster, Stream]], current_position: Tuple[float, float]
71 | ) -> Tuple[Optional[Union[Caster, Stream]], float]:
72 | nearest_object, nearest = None, float("inf")
73 | for object in objects:
74 | if not (abs(objects[object].latitude) <= 90 and abs(objects[object].longitude) <= 180):
75 | continue
76 | object_point = (
77 | objects[object].latitude,
78 | objects[object].longitude,
79 | )
80 | dist = geopy.distance.distance(current_position, object_point)
81 | if dist < nearest:
82 | nearest_object, nearest = object, dist
83 | if nearest_object:
84 | return objects[nearest_object], nearest
85 | return None, float("inf")
86 |
87 | def find_closest_caster(
88 | self, current_position: Tuple[float, float]
89 | ) -> Tuple[Optional[Caster], float]:
90 | return self.find_closest(self.casters, current_position)
91 |
92 | def find_closest_stream(
93 | self, current_position: Tuple[float, float]
94 | ) -> Tuple[Optional[Stream], float]:
95 | return self.find_closest(self.streams, current_position)
96 |
97 | def find_closest_ntrip_url(
98 | self, current_position: tuple[float, float]
99 | ) -> tuple[Optional[str], float]:
100 | stream, dist = self.find_closest_stream(current_position)
101 | url = None
102 | if stream:
103 | caster, _ = self.find_closest_caster(current_position)
104 | if caster:
105 | url = f"ntrip://{caster.host}:{caster.port}/{stream.mountpoint}"
106 | else:
107 | url = f"{self.url}/{stream.mountpoint}".replace("http://", "ntrip://")
108 | return (url, dist)
109 |
110 |
111 | @dataclass(slots=True)
112 | class Ntrip(plugins.Plugin):
113 | __author__: str = "fmatray"
114 | __version__: str = "1.0.0"
115 | __license__: str = "GPL3"
116 | __description__: str = "Manage NTRIP for GPSD."
117 | broadcasters: list[str] = field(
118 | default_factory=lambda: [
119 | # "DE": ["http://euref-ip.net:2101"], AUTH
120 | "http://crtk.net:2101", # FR
121 | # "IT": ["http://euref-ip.asi.it:2101"], AUTH
122 | "http://gnss1.tudelft.nl:2101", # NL
123 | ]
124 | )
125 | handshake_dir: str = ""
126 | sourcetables: dict[str, SourceTable] = field(default_factory=dict)
127 | latitude: float = float("inf")
128 | longitude: float = float("inf")
129 | MAX_DIST: float = 30
130 | gpsd_pid: int = 0
131 | current_url: Optional[str] = None
132 | gpsd_positioning: bool = False
133 | ready: bool = False
134 | last_update: datetime = field(default_factory=lambda: datetime.now(tz=UTC))
135 | lock: Lock = field(default_factory=Lock)
136 |
137 | @property
138 | def position(self):
139 | return (self.latitude, self.longitude)
140 |
141 | def __post_init__(self) -> None:
142 | super(plugins.Plugin, self).__init__()
143 | self.gpsd_pid = self.get_gpsd_pid()
144 |
145 | def set_position(self, lat: float, long: float):
146 | if lat == None or long == None:
147 | self.latitude = float("inf")
148 | self.longitude = float("inf")
149 | return
150 | self.latitude = lat
151 | self.longitude = long
152 |
153 | def position_iset(self):
154 | return math.isfinite(self.latitude) and math.isfinite(self.longitude)
155 |
156 | def get_gpsd_pid(self) -> int:
157 | try:
158 | return int(subprocess.check_output(["pidof", "-s", "gpsd"]))
159 | except subprocess.CalledProcessError:
160 | return 0
161 |
162 | def on_loaded(self) -> None:
163 | logging.info("[NTRIP-selector] Plugin loaded")
164 |
165 | def on_config_changed(self, config: dict) -> None:
166 | self.handshake_dir = config["bettercap"].get("handshakes")
167 | if extra_broadcasters := self.options.get("extra_broadcasters", None):
168 | if isinstance(extra_broadcasters, list):
169 | self.broadcasters.extend(extra_broadcasters)
170 |
171 | self.retreive_initial_position()
172 | # Try to retreive the last saved position
173 | if not self.position_iset() and (
174 | position_files := glob.glob(os.path.join(self.handshake_dir, "*.g*.json"))
175 | ):
176 | self.set_position_from_file(max(position_files, key=os.path.getctime))
177 |
178 | self.ready = True
179 | logging.info("[NTRIP-selector] Plugin configured")
180 |
181 | def on_unload(self, ui):
182 | self.unset_ntrip_server()
183 | logging.info("[NTRIP-selector] Plugin unloaded")
184 |
185 | @staticmethod
186 | def read_caster(line: list) -> Caster:
187 | return Caster(
188 | host=line[1],
189 | port=line[2],
190 | identifier=line[3],
191 | operator=line[4],
192 | nmea=line[5],
193 | country=line[6],
194 | latitude=float(line[7]),
195 | longitude=float(line[8]),
196 | fallback_host=line[9],
197 | fallback_port=line[10],
198 | )
199 |
200 | @staticmethod
201 | def read_network(line: list) -> Network:
202 | return Network(identifier=line[1], operator=line[2])
203 |
204 | @staticmethod
205 | def read_stream(line: list) -> Stream:
206 | return Stream(
207 | mountpoint=line[1],
208 | identifier=line[2],
209 | format=line[3],
210 | carrier=line[5],
211 | network=line[7],
212 | country=line[8],
213 | latitude=float(line[9]),
214 | longitude=float(line[10]),
215 | nmea=line[11],
216 | auth=line[15],
217 | )
218 |
219 | def create_sourcetable(self, url: str, data: str) -> SourceTable:
220 | sourcetable = SourceTable(url=url)
221 | for line in csv.reader(data.split("\r\n"), delimiter=";"):
222 | try:
223 | line_type = line[0]
224 | except IndexError:
225 | continue
226 | match line_type:
227 | case "CAS":
228 | sourcetable.add_caster(self.read_caster(line))
229 | case "NET":
230 | sourcetable.add_network(self.read_network(line))
231 | case "STR":
232 | sourcetable.add_stream(self.read_stream(line))
233 | case "ENDSOURCETABLE":
234 | pass
235 | case _:
236 | logging.error(f"[NTRIP-selector] Unkown type: {line_type}")
237 | return sourcetable
238 |
239 | def retrieve_source_tables(self):
240 | """Retrieve source tables from broadcasters in the specified region."""
241 | session = requests.Session()
242 | for broadcaster in self.broadcasters:
243 | try:
244 | response = session.get(broadcaster)
245 | response.raise_for_status()
246 | self.sourcetables[broadcaster] = self.create_sourcetable(
247 | broadcaster, response.content.decode()
248 | )
249 | except requests.RequestException as e:
250 | logging.error(
251 | f"[NTRIP-selector] Cannot retrieve sourcetables from {broadcaster}: {e}"
252 | )
253 |
254 | def retreive_initial_position(self):
255 | try:
256 | response = requests.get("http://ip-api.com/json/?fields=status,message,lat,lon,query")
257 | response.raise_for_status()
258 | position = response.json()
259 | if position["status"] == "success":
260 | logging.info(f"{position} {self.position}")
261 | else:
262 | logging.error(
263 | f"[NTRIP-selector] Cannot retrieve actual position: {position['message']}"
264 | )
265 | except requests.RequestException as e:
266 | logging.error(f"[NTRIP-selector] Cannot retrieve actual position: {e}")
267 |
268 | def set_position_from_file(self, file: str) -> bool:
269 | try:
270 | with open(file, "r") as fb:
271 | position = json.load(fb)
272 | self.set_position(position["Latitude"], position["Longitude"])
273 | logging.info(f"[NTRIP-selector] Position set from file ({file})")
274 | except Exception as e:
275 | logging.error(f"[NTRIP-selector] Error while reading file {file}: {e}")
276 | return False
277 | return True
278 |
279 | def on_unfiltered_ap_list(self, agent, aps) -> None:
280 | if not self.ready or self.lock.locked():
281 | return
282 | if self.gpsd_positioning:
283 | return
284 | with self.lock:
285 | for ap in aps: # Complete pcap files with missing gps.json
286 | try:
287 | mac = ap["mac"].replace(":", "")
288 | hostname = re.sub(r"[^a-zA-Z0-9]", "", ap["hostname"])
289 | except KeyError:
290 | continue
291 |
292 | pcap_filename = os.path.join(self.handshake_dir, f"{hostname}_{mac}.pcap")
293 | if not os.path.exists(pcap_filename): # Pcap file doesn't exist => next
294 | continue
295 |
296 | gps_filename = os.path.join(self.handshake_dir, f"{hostname}_{mac}.gps.json")
297 | # gps.json exist with size>0 => next
298 | if (
299 | os.path.exists(gps_filename)
300 | and os.path.getsize(gps_filename)
301 | and self.set_position_from_file(gps_filename)
302 | ):
303 | return
304 |
305 | geo_filename = os.path.join(self.handshake_dir, f"{hostname}_{mac}.geo.json")
306 | # geo.json exist with size>0 => next
307 | if (
308 | os.path.exists(geo_filename)
309 | and os.path.getsize(geo_filename)
310 | and self.set_position_from_file(gps_filename)
311 | ):
312 | return
313 |
314 | def on_internet_available(self, agent):
315 | if not self.ready or self.lock.locked():
316 | return
317 | with self.lock:
318 | if not self.sourcetables:
319 | self.retrieve_source_tables()
320 | if not self.position_iset():
321 | self.retreive_initial_position()
322 |
323 | def on_position_available(self, position: dict):
324 | with self.lock:
325 | self.set_position(
326 | position.get("Latitude", float("inf")), position.get("Longitude", float("inf"))
327 | )
328 | self.gpsd_positioning = True
329 |
330 | def on_position_lost(self):
331 | with self.lock:
332 | self.set_position(float("inf"), float("inf"))
333 | self.gpsd_positioning = False
334 |
335 | def select_ntrip_server(self) -> Optional[str]:
336 | if not self.position_iset():
337 | return None
338 | nearest_url, nearest = None, float("inf")
339 | if self.position_iset() and self.sourcetables:
340 | for key in self.sourcetables:
341 | url, dist = self.sourcetables[key].find_closest_ntrip_url(self.position)
342 | if dist <= self.MAX_DIST and dist < nearest:
343 | nearest_url, nearest = url, dist
344 | return nearest_url
345 |
346 | def unset_ntrip_server(self):
347 | try:
348 | if self.current_url:
349 | logging.info(f"[NTRIP-selector] Unsetting NTRIP server: {self.current_url}")
350 | subprocess.run(["gpsdctl", "remove", self.current_url], check=True, timeout=10)
351 | self.current_url = None
352 | except subprocess.CalledProcessError as e:
353 | logging.error(f"[NTRIP-selector] error while unsetting ntrip: {e}")
354 |
355 | def set_ntrip_server(self, url: str):
356 | try:
357 | logging.info(f"[NTRIP-selector] Setting NTRIP server: {url}")
358 | subprocess.run(["gpsdctl", "add", url], check=True, timeout=10)
359 | self.current_url = url
360 | except subprocess.CalledProcessError as e:
361 | logging.error(f"[NTRIP-selector] error while setting ntrip: {e}")
362 |
363 | def on_ui_update(self, ui):
364 | if not self.ready or self.lock.locked():
365 | return
366 | if ((now := datetime.now(tz=UTC)) - self.last_update).total_seconds() < 60:
367 | return
368 | self.last_update = now
369 | with self.lock:
370 | if (gpsd_pid := self.get_gpsd_pid()) != self.gpsd_pid:
371 | logging.info(f"[NTRIP-selector] GPSD restarted.")
372 | self.gpsd_pid = gpsd_pid
373 | self.set_ntrip_server(self.current_url)
374 | elif (new_url := self.select_ntrip_server()) != self.current_url:
375 | logging.info(f"[NTRIP-selector] Setting new ntrip server")
376 | self.unset_ntrip_server()
377 | self.set_ntrip_server(new_url)
378 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/gpsd-ng.py:
--------------------------------------------------------------------------------
1 | # Based on the GPS/GPSD plugin from:
2 | # - https://github.com/evilsocket
3 | # - https://github.com/kellertk/pwnagotchi-plugin-gpsd
4 | # - https://github.com/nothingbutlucas/pwnagotchi-plugin-gpsd
5 | # - https://gpsd.gitlab.io/gpsd/index.html
6 | #
7 | # Install :
8 | # - Install and configure gpsd
9 | # - copy this plugin to custom plugin
10 | #
11 | # Config.toml:
12 | # [main.plugins.gpsd-ng]
13 | # enabled = true
14 |
15 | # Options with default settings.
16 | # Don't add if you don't need customisation
17 | # [main.plugins.gpsd-ng]
18 | # gpsdhost = "127.0.0.1"
19 | # gpsdport = 2947
20 | # main_device = "/dev/ttyS0" # default None
21 | # use_open_elevation = true
22 | # save_elevations = true
23 | # view_mode = "compact" # "compact", "full", "status", "none"
24 | # fields = "info,speed,altitude" # list or string of fields to display
25 | # units = "metric" # "metric", "imperial"
26 | # display_precision = 6 # display precision for latitude and longitude
27 | # position = "127,64"
28 | # show_faces = true # if false, doesn't show face. Ex if you use PNG faces
29 | # lost_face_1 = "(O_o )"
30 | # lost_face_2 = "( o_O)"
31 | # face_1 = "(•_• )"
32 | # face_2 = "( •_•)"
33 |
34 | import base64
35 | import io
36 | import threading
37 | import json
38 | import logging
39 | import re
40 | import os
41 | import subprocess
42 | from glob import glob
43 | from dataclasses import dataclass, field
44 | from typing import Any, Self, Optional
45 | from copy import deepcopy
46 | import math
47 | import statistics
48 | from datetime import datetime, UTC
49 | import gps
50 | import json
51 | import geopy.distance
52 | import geopy.units
53 | import requests
54 | from flask import render_template_string, render_template
55 |
56 | import pwnagotchi.plugins as plugins
57 | import pwnagotchi.ui.fonts as fonts
58 | from pwnagotchi.ui.components import LabeledValue, Text
59 | from pwnagotchi.ui.view import BLACK
60 | from pwnagotchi.utils import StatusFile
61 |
62 |
63 | def now() -> datetime:
64 | return datetime.now(tz=UTC)
65 |
66 |
67 | def extract_stripped_mac(ap: dict[str, Any]) -> str:
68 | return ap["mac"].replace(":", "").strip()
69 |
70 |
71 | @dataclass(slots=True)
72 | class Position:
73 | """
74 | Keeps data from a GPS device
75 | """
76 |
77 | device: str = field(init=True) # Device name
78 | dummy: bool = False # Wifi position is a dummy Position as it's not a real GPS device
79 | last_update: Optional[datetime] = None
80 | DATE_FORMAT: str = "%Y-%m-%dT%H:%M:%S.%fZ"
81 | # Position attributes
82 | latitude: float = field(default=float("NaN"))
83 | longitude: float = field(default=float("NaN"))
84 | altitude: float = field(default=float("NaN"))
85 | speed: float = field(default=float("NaN"))
86 | accuracy: float = field(default=float("NaN"))
87 | # Fix attributes
88 | FIXES: dict[int, str] = field(
89 | default_factory=lambda: {0: "No data", 1: "No fix", 2: "2D fix", 3: "3D fix"}
90 | )
91 | mode: int = 0
92 | last_fix: Optional[datetime] = None
93 | satellites: list = field(default_factory=list)
94 |
95 | # for logs
96 | header: str = ""
97 |
98 | def __post_init__(self) -> None:
99 | self.header = f"[GPSD-NG][{self.device}]"
100 |
101 | @property
102 | def seen_satellites(self) -> int:
103 | return len(self.satellites)
104 |
105 | @property
106 | def used_satellites(self) -> int:
107 | return sum(1 for s in self.satellites if s.used)
108 |
109 | @property
110 | def fix(self) -> str:
111 | return self.FIXES.get(self.mode, "Mode error")
112 |
113 | @property
114 | def last_update_ago(self) -> Optional[int]:
115 | if not self.last_update:
116 | return None
117 | return round((now() - self.last_update).total_seconds())
118 |
119 | @property
120 | def last_fix_ago(self) -> Optional[int]:
121 | if not self.last_fix:
122 | return None
123 | return round((now() - self.last_fix).total_seconds())
124 |
125 | def __lt__(self, other: Self) -> bool:
126 | if self.last_fix and other.last_fix:
127 | return (not self.dummy, self.mode, self.last_fix) < (
128 | not other.dummy,
129 | other.mode,
130 | other.last_fix,
131 | )
132 | return True
133 |
134 | # ---------- UPDATES ----------
135 | def set_attr(self, attr: str, value: Any, valid: int, flag: int) -> None:
136 | """
137 | Set an attribute only if valid contains the related flag
138 | """
139 | if flag & valid:
140 | setattr(self, attr, value)
141 | self.last_update = now() # Don't use fix.time cause it's not reliable
142 |
143 | def update_fix(self, fix: gps.gpsfix, valid: int) -> None:
144 | """
145 | Update a Postion with the fix data
146 | """
147 | if not gps.MODE_SET & valid:
148 | return # not a valid data
149 | if fix.mode >= 2: # 2D and 3D fix
150 | self.last_fix = now() # Don't use fix.time cause it's not reliable
151 | self.set_attr("latitude", fix.latitude, valid, gps.LATLON_SET)
152 | self.set_attr("longitude", fix.longitude, valid, gps.LATLON_SET)
153 | self.set_attr("speed", fix.speed, valid, gps.SPEED_SET)
154 | self.set_attr("mode", fix.mode, valid, gps.MODE_SET)
155 | self.accuracy = 50
156 | return
157 | # reset fix after 10s without fix
158 | if self.last_fix and (now() - self.last_fix).total_seconds() < 10:
159 | return
160 | self.latitude = float("NaN")
161 | self.longitude = float("NaN")
162 | self.altitude = float("NaN")
163 | self.speed = float("NaN")
164 | self.accuracy = float("NaN")
165 |
166 | def update_satellites(self, satellites: list[gps.gpsdata.satellite], valid: int) -> None:
167 | self.set_attr("satellites", satellites, valid, gps.SATELLITE_SET)
168 |
169 | def update_altitude(self, altitude: int) -> None:
170 | self.altitude = altitude
171 |
172 | # ---------- VALIDATION AND TIME ----------
173 | def is_valid(self) -> bool:
174 | return gps.isfinite(self.latitude) and gps.isfinite(self.longitude) and self.mode >= 2
175 |
176 | def is_old(self, date: Optional[datetime], max_seconds: int) -> Optional[bool]:
177 | if not date:
178 | return None
179 | return (now() - date).total_seconds() > max_seconds
180 |
181 | def is_update_old(self, max_seconds: int) -> Optional[bool]:
182 | return self.is_old(self.last_update, max_seconds)
183 |
184 | def is_fix_old(self, max_seconds: int) -> Optional[bool]:
185 | return self.is_old(self.last_fix, max_seconds)
186 |
187 | def is_fixed(self) -> bool:
188 | return self.mode >= 2
189 |
190 | # ---------- JSON DUMP ----------
191 | def to_dict(self) -> dict[str, int | float | datetime | Optional[str]]:
192 | """
193 | Used to save to .gps.json files
194 | """
195 | if self.last_fix:
196 | last_fix = self.last_fix.strftime(self.DATE_FORMAT)
197 | else:
198 | last_fix = None
199 | return dict(
200 | Latitude=self.latitude,
201 | Longitude=self.longitude,
202 | Altitude=self.altitude,
203 | Speed=self.speed * gps.KNOTS_TO_MPS,
204 | Accuracy=self.accuracy,
205 | Date=last_fix,
206 | Updated=last_fix, # Wigle plugin
207 | Mode=self.mode,
208 | Fix=self.fix,
209 | Sats=self.seen_satellites,
210 | Sats_used=self.used_satellites,
211 | Device=self.device,
212 | Dummy=self.dummy,
213 | )
214 |
215 | # ---------- FORMAT for eink and Web UI----------
216 | def format_info(self) -> str:
217 | device = re.search(r"(^tcp|^udp|tty.*|rfcomm\d*|wifi)", self.device, re.IGNORECASE)
218 | dev = f"{device[0]}:" if device else ""
219 | return f"{dev}{self.fix} ({self.used_satellites}/{self.seen_satellites} Sats)"
220 |
221 | def format_lat_long(self, display_precision: int = 9) -> tuple[str, str]:
222 | if not (gps.isfinite(self.latitude) and gps.isfinite(self.longitude)):
223 | return ("-", "-")
224 | if self.latitude < 0:
225 | lat = f"{-self.latitude:4.{display_precision}f}S"
226 | else:
227 | lat = f"{self.latitude:4.{display_precision}f}N"
228 | if self.longitude < 0:
229 | long = f"{-self.longitude:4.{display_precision}f}W"
230 | else:
231 | long = f"{self.longitude:4.{display_precision}f}E"
232 | return lat, long
233 |
234 | def format_altitude(self, units: str) -> str:
235 | if not gps.isfinite(self.altitude):
236 | return "-"
237 | match units:
238 | case "imperial":
239 | return f"{round(geopy.units.feet(meters=self.altitude))}ft"
240 | case "metric":
241 | return f"{round(self.altitude)}m"
242 | return "error"
243 |
244 | def format_speed(self, units: str) -> str:
245 | if not gps.isfinite(self.speed):
246 | return "-"
247 | match units:
248 | case "imperial":
249 | return f"{round(self.speed * 1.68781)}ft/s"
250 | case "metric":
251 | return f"{round(self.speed * gps.KNOTS_TO_MPS)}m/s"
252 | return "error"
253 |
254 | def format(self, units: str, display_precision: int) -> tuple[str, str, str, str, str]:
255 | info = self.format_info()
256 | lat, long = self.format_lat_long(display_precision)
257 | alt = self.format_altitude(units)
258 | spd = self.format_speed(units)
259 | return info, lat, long, alt, spd
260 |
261 | def generate_polar_plot(self) -> str:
262 | """
263 | Return a polar image (base64) of seen satellites.
264 | Thanks to https://github.com/rai68/gpsd-easy/blob/main/gpsdeasy.py
265 | """
266 | try:
267 | from matplotlib.pyplot import rc, grid, figure, rcParams, savefig, close
268 | except ImportError:
269 | logging.error(
270 | f"{self.header} Error while importing matplotlib for generate_polar_plot()"
271 | )
272 | return ""
273 |
274 | try:
275 | rc("grid", color="#316931", linewidth=1, linestyle="-")
276 | rc("xtick", labelsize=10)
277 | rc("ytick", labelsize=10)
278 |
279 | # force square figure and square axes looks better for polar, IMO
280 | width, height = rcParams["figure.figsize"]
281 | size = min(width, height)
282 | # make a square figure
283 | fig = figure(figsize=(size, size))
284 | fig.patch.set_alpha(0)
285 |
286 | ax = fig.add_axes((0.1, 0.1, 0.8, 0.8), polar=True, facecolor="#d5de9c")
287 | ax.patch.set_alpha(1)
288 | ax.set_theta_zero_location("N")
289 | ax.set_theta_direction(-1)
290 | for sat in self.satellites:
291 | fc = "green" if sat.used else "red"
292 | ax.annotate(
293 | str(sat.PRN),
294 | xy=(math.radians(sat.azimuth), 90 - sat.elevation), # theta, radius
295 | bbox=dict(boxstyle="round", fc=fc, alpha=0.4),
296 | horizontalalignment="center",
297 | verticalalignment="center",
298 | )
299 |
300 | ax.set_yticks(range(0, 90 + 10, 15)) # Define the yticks
301 | ax.set_yticklabels(["90", "", "60", "", "30", "", "0"])
302 | grid(True)
303 |
304 | image = io.BytesIO()
305 | savefig(image, format="png")
306 | close(fig)
307 | return base64.b64encode(image.getvalue()).decode("utf-8")
308 | except Exception as e:
309 | logging.error(e)
310 | return ""
311 |
312 |
313 | @dataclass(slots=True)
314 | class GPSD(threading.Thread):
315 | """
316 | Main thread:
317 | - Connect to gpsd server
318 | - Read and update/clean positions from gpsd and wifi positioning
319 | - cache elevations
320 | """
321 |
322 | # gpsd connection
323 | gpsdhost: Optional[str] = None
324 | gpsdport: Optional[int] = None
325 | session: gps.gps = None
326 | # Data reading
327 | fix_timeout: int = 120
328 | update_timeout: int = 120
329 | main_device: Optional[str] = None
330 | last_clean: datetime = field(default_factory=lambda: now())
331 | positions: dict = field(default_factory=dict) # Device:Position dictionnary
332 | last_position: Optional[Position] = None
333 | # Wifi potisioning
334 | wifi_positions: dict[str, dict[str, float]] = field(default_factory=dict)
335 | last_wifi_positioning_save: datetime = field(default_factory=lambda: now())
336 | wifi_positioning_report: Optional[StatusFile] = None
337 | wifi_positioning_dirty: bool = False
338 | # Open Elevation
339 | elevation_data: dict = field(default_factory=dict)
340 | elevation_report: Optional[StatusFile] = None
341 | last_elevation: datetime = field(default_factory=lambda: datetime(2025, 1, 1, 0, 0, tzinfo=UTC))
342 | # hook
343 | last_hook: datetime = field(default_factory=lambda: now())
344 | lost_position_sent: bool = False
345 |
346 | # Thread and logs
347 | lock: threading.Lock = field(default_factory=threading.Lock)
348 | exit: threading.Event = field(default_factory=lambda: threading.Event())
349 | header: str = "[GPSD-ng][Thread]"
350 |
351 | def __post_init__(self) -> None:
352 | super(GPSD, self).__init__()
353 |
354 | def __hash__(self) -> int:
355 | return super(GPSD, self).__hash__()
356 |
357 | # ---------- CONFIGURE AND CONNECTION ----------
358 | def configure(
359 | self,
360 | *,
361 | gpsdhost: str,
362 | gpsdport: int,
363 | fix_timeout: int,
364 | update_timeout: int,
365 | main_device: str,
366 | cache_filename: str,
367 | save_elevations: bool,
368 | wifi_positioning_filename: Optional[str],
369 | ) -> None:
370 | self.gpsdhost, self.gpsdport = gpsdhost, gpsdport
371 | self.fix_timeout, self.update_timeout = fix_timeout, update_timeout
372 | self.main_device = main_device
373 | if save_elevations:
374 | logging.info(f"{self.header} Reading elevation cache")
375 | self.elevation_report = StatusFile(cache_filename, data_format="json")
376 | self.elevation_data = self.elevation_report.data_field_or("elevations", default=dict())
377 | logging.info(f"{self.header} {len(self.elevation_data)} locations already in cache")
378 |
379 | if wifi_positioning_filename:
380 | logging.info(f"{self.header} Reading wifi position cache")
381 | self.wifi_positioning_report = StatusFile(wifi_positioning_filename, data_format="json")
382 | self.wifi_positions = self.wifi_positioning_report.data_field_or(
383 | "wifi_positions", default=dict()
384 | )
385 | logging.info(f"{self.header} {len(self.wifi_positions)} wifi locations in cache")
386 | logging.info(f"{self.header} Thread configured")
387 |
388 | def is_configured(self) -> bool:
389 | return (self.gpsdhost, self.gpsdport) != (None, None)
390 |
391 | def connect(self) -> bool:
392 | with self.lock:
393 | logging.info(f"{self.header} Trying to connect to {self.gpsdhost}:{self.gpsdport}")
394 | try:
395 | self.session = gps.gps(
396 | host=self.gpsdhost,
397 | port=self.gpsdport,
398 | mode=gps.WATCH_ENABLE | gps.WATCH_NEWSTYLE,
399 | )
400 | logging.info(f"{self.header} Connected to {self.gpsdhost}:{self.gpsdport}")
401 | except Exception as e:
402 | logging.error(f"{self.header} Error while connecting: {e}")
403 | self.session = None
404 | return False
405 | return True
406 |
407 | def is_connected(self) -> bool:
408 | return self.session is not None
409 |
410 | def close(self) -> None:
411 | self.session.close()
412 | self.session = None
413 | logging.info(f"{self.header} GPSD connection closed")
414 |
415 | # ---------- RELOAD/ RESTART GPSD SERVER ----------
416 | def reload_or_restart_gpsd(self) -> None:
417 | try:
418 | logging.info(f"{self.header} Trying to reload gpsd server")
419 | subprocess.run(
420 | ["killall", "-SIGHUP", "gpsd"],
421 | check=True,
422 | timeout=5,
423 | )
424 | self.exit.wait(2)
425 | logging.info(f"{self.header} GPSD reloaded")
426 | return
427 | except subprocess.CalledProcessError as exp:
428 | logging.error(f"{self.header} Error while reloading gpsd: {exp}")
429 | self.restart_gpsd()
430 |
431 | def restart_gpsd(self) -> None:
432 | try:
433 | logging.info(f"{self.header} Trying to restart gpsd server")
434 | subprocess.run(
435 | ["systemctl", "restart", "gpsd"],
436 | check=True,
437 | timeout=20,
438 | )
439 | self.exit.wait(2)
440 | logging.info(f"{self.header} GPSD restarted")
441 | except subprocess.CalledProcessError as exp:
442 | logging.error(f"{self.header} Error while restarting gpsd: {exp}")
443 |
444 | # ---------- UPDATE AND CLEAN ----------
445 | def update(self) -> None:
446 | with self.lock:
447 | if not ((gps.ONLINE_SET & self.session.valid) and (device := self.session.device)):
448 | return # not a TPV or SKY
449 | if not device in self.positions:
450 | self.positions[device] = Position(device=device)
451 | logging.info(f"{self.header} New device: {device}")
452 |
453 | # Update fix
454 | self.positions[device].update_fix(self.session.fix, self.session.valid)
455 | if gps.ALTITUDE_SET & self.session.valid: # cache altitude
456 | self.positions[device].update_altitude(self.session.fix.altMSL)
457 | self.cache_elevation(
458 | self.session.fix.latitude,
459 | self.session.fix.longitude,
460 | self.session.fix.altMSL,
461 | )
462 | self.save_wifi_positions()
463 | else: # retreive altitude
464 | altitude = self.get_elevation(self.session.fix.latitude, self.session.fix.longitude)
465 | self.positions[device].update_altitude(altitude)
466 |
467 | # update satellites
468 | self.positions[device].update_satellites(self.session.satellites, self.session.valid)
469 |
470 | # Soft reset session after reading
471 | self.session.valid = 0
472 | self.session.device = None
473 | self.session.fix = gps.gpsfix()
474 | self.session.satellites = []
475 |
476 | def clean(self) -> None:
477 | if not self.update_timeout:
478 | return # keep positions forever
479 | if (now() - self.last_clean).total_seconds() < 10:
480 | return
481 | self.last_clean = now()
482 | with self.lock:
483 | for device in list(self.positions.keys()):
484 | if self.positions[device].is_update_old(self.update_timeout):
485 | del self.positions[device]
486 | logging.info(f"{self.header} Cleaning {device}")
487 |
488 | # ---------- WIFI POSITIONNING ----------
489 | def save_wifi_positions(self) -> None:
490 | if not self.wifi_positioning_report or not self.wifi_positions:
491 | return # nothing to save
492 | if not self.wifi_positioning_dirty: # Save only if dirty
493 | return
494 | if (now() - self.last_wifi_positioning_save).total_seconds() < 60:
495 | return
496 | self.last_wifi_positioning_save = now()
497 | logging.info(f"{self.header}[wifi] Saving wifi positions")
498 | self.wifi_positioning_report.update(data={"wifi_positions": self.wifi_positions})
499 | self.wifi_positioning_dirty = False
500 |
501 | def update_wifi_positions(self, bssid: str, lat: float, long: float, alt: float) -> None:
502 | if math.isnan(lat) or math.isnan(long):
503 | return
504 | if alt is None or math.isnan(alt):
505 | alt = self.get_elevation(lat, long)
506 | pos = dict(latitude=lat, longitude=long, altitude=alt)
507 | if bssid not in self.wifi_positions or self.wifi_positions[bssid] != pos:
508 | self.wifi_positions[bssid] = pos
509 | self.wifi_positioning_dirty = True
510 |
511 | def update_wifi(self, bssids: list[str]) -> None:
512 | def extract(attr: str) -> list[float]: # Filter and extract from the list of dict
513 | return list(filter(math.isfinite, filter(None, [p[attr] for p in points])))
514 |
515 | points = [self.wifi_positions[bssid] for bssid in bssids if bssid in self.wifi_positions]
516 |
517 | if len(points) < 3: # skip if not enought points
518 | return
519 |
520 | latitudes, longitudes = extract("latitude"), extract("longitude")
521 | if len(latitudes) != len(longitudes): # skip if doesn't have not the same length
522 | logging.error(f"{self.header}[wifi] Latitudes and longitudes have not the same length")
523 | return
524 | # Calculate the box containing all points
525 | box_min, box_max = (min(latitudes), min(longitudes)), (max(latitudes), max(longitudes))
526 | if geopy.distance.distance(box_min, box_max).meters > 50: # skip if the box is too large
527 | return
528 | try: # Using median rather than mean to be more representative
529 | latitude, longitude = statistics.median(latitudes), statistics.median(longitudes)
530 | except statistics.StatisticsError:
531 | return
532 |
533 | try:
534 | altitude = statistics.median(extract("altitude"))
535 | except statistics.StatisticsError:
536 | altitude = float("NaN")
537 | if math.isnan(altitude):
538 | altitude = self.get_elevation(latitude, longitude) # try to use cache if no altitude
539 |
540 | with self.lock:
541 | if "wifi" not in self.positions:
542 | self.positions["wifi"] = Position(accuracy=50, device="wifi", dummy=True)
543 | logging.info(f"{self.header} New device: wifi")
544 |
545 | self.positions["wifi"].latitude = latitude
546 | self.positions["wifi"].longitude = longitude
547 | self.positions["wifi"].altitude = altitude
548 | self.positions["wifi"].last_update = now()
549 | self.positions["wifi"].last_fix = now()
550 | self.positions["wifi"].mode = 3 if math.isfinite(altitude) else 2
551 |
552 | # ---------- MAIN LOOP ----------
553 | def plugin_hook(self) -> None:
554 | """
555 | Trigger position_available() evry 10s if a position is else position_lost() is called once
556 | """
557 | if (now() - self.last_hook).total_seconds() < 10:
558 | return
559 | self.last_hook = now()
560 | if coords := self.get_position():
561 | plugins.on("position_available", coords.to_dict())
562 | self.lost_position_sent = False
563 | elif not self.lost_position_sent:
564 | plugins.on("position_lost")
565 | self.lost_position_sent = True
566 |
567 | def loop(self) -> None:
568 | """
569 | Main thread loo. Handles gpsd connection and raw reading
570 | """
571 | logging.info(f"{self.header} Starting gpsd thread loop")
572 | connection_errors = 0
573 |
574 | while not self.exit.is_set():
575 | try:
576 | self.clean()
577 | if not self.session and not self.connect():
578 | connection_errors += 1
579 | if self.session.waiting(timeout=2) and self.session.read() == 0:
580 | self.update()
581 | connection_errors = 0
582 | else:
583 | self.close()
584 | connection_errors += 1
585 |
586 | if connection_errors >= 3:
587 | logging.error(f"{self.header} {connection_errors} connection errors")
588 | self.restart_gpsd()
589 | connection_errors = 0
590 | self.plugin_hook()
591 | self.save_wifi_positions()
592 | except ConnectionError as exp:
593 | logging.error(f"{self.header} Connection Error: {exp}")
594 | self.restart_gpsd()
595 | connection_errors = 0
596 |
597 | def run(self) -> None:
598 | """
599 | Called by GPSD.start()
600 | """
601 | if not self.is_configured():
602 | logging.critical(f"{self.header} GPSD thread not configured.")
603 | return
604 | self.reload_or_restart_gpsd()
605 | try:
606 | self.loop()
607 | except Exception as exp:
608 | logging.critical(f"{self.header} Critical error during loop: {exp}")
609 |
610 | def join(self, timeout=None) -> None:
611 | """
612 | End the thread
613 | """
614 | self.exit.set() # end loop
615 | try:
616 | super(GPSD, self).join(timeout)
617 | except Exception as e:
618 | logging.error(f"{self.header} Error on join(): {e}")
619 |
620 | # ---------- POSITION ----------
621 | def get_position_device(self) -> Optional[str]:
622 | """
623 | Returns the device with the best position
624 | """
625 | if not self.is_configured():
626 | return None
627 | with self.lock:
628 | if self.main_device:
629 | try:
630 | if self.positions[self.main_device].is_valid():
631 | return self.main_device
632 | except KeyError:
633 | pass
634 |
635 | # Fallback
636 | try:
637 | # Filter devices without coords and sort by best positionning/most recent
638 | dev_pos = list(filter(lambda x: x[1].is_valid(), self.positions.items()))
639 | dev_pos = sorted(dev_pos, key=lambda x: x[1], reverse=True)
640 | return dev_pos[0][0] # Get first and best element
641 | except IndexError:
642 | logging.debug(f"{self.header} No valid position")
643 | return None
644 |
645 | def get_position(self) -> Optional[Position]:
646 | """
647 | Returns the best position. If no position available, send the last postition within fix timout.
648 | """
649 | try:
650 | if device := self.get_position_device():
651 | self.last_position = self.positions[device]
652 | return self.positions[device]
653 | except KeyError:
654 | pass
655 | if (
656 | self.fix_timeout
657 | and self.last_position
658 | and self.last_position.is_fix_old(self.fix_timeout)
659 | ):
660 | self.last_position = None
661 | return self.last_position
662 |
663 | # ---------- OPEN ELEVATION CACHE ----------
664 | @staticmethod
665 | def round_position(latitude: float, longitude: float) -> tuple[float, float]:
666 | return (round(latitude, 4), round(longitude, 4))
667 |
668 | def elevation_key(self, latitude: float, longitude: float) -> str:
669 | return str(self.round_position(latitude, longitude))
670 |
671 | def cache_elevation(self, latitude: float, longitude: float, elevation: float) -> None:
672 | key = self.elevation_key(latitude, longitude)
673 | if not key in self.elevation_data:
674 | self.elevation_data[key] = elevation
675 |
676 | def get_elevation(self, latitude: float, longitude: float) -> float:
677 | key = self.elevation_key(latitude, longitude)
678 | try:
679 | return self.elevation_data[key]
680 | except KeyError:
681 | return float("NaN")
682 |
683 | def save_elevation_cache(self) -> None:
684 | if self.elevation_report:
685 | logging.info(f"{self.header}[Elevation] Saving elevation cache")
686 | self.elevation_report.update(data={"elevations": self.elevation_data})
687 |
688 | def calculate_locations(self, max_dist: int = 100) -> list[dict[str, float]]:
689 | """
690 | Calculates gps points for circles every 10m and up to 100m.
691 | Rounding is an efficiant way to decrease the number of points.
692 | """
693 | locations = list()
694 |
695 | def append_location(latitude: float, longitude: float) -> None:
696 | if not self.elevation_key(latitude, longitude) in self.elevation_data:
697 | lat, long = self.round_position(latitude, longitude)
698 | locations.append({"latitude": lat, "longitude": long})
699 |
700 | # Retreive wifi_positions with None or NaN altitudes
701 | for wifi_point in filter(
702 | lambda p: p["altitude"] is None or math.isnan(p["altitude"]),
703 | self.wifi_positions.values(),
704 | ):
705 | append_location(wifi_point["latitude"], wifi_point["longitude"])
706 | if not (coords := self.get_position()): # No current position
707 | return locations
708 | if coords.mode != 2: # No cache if we have a no fix or good Fix
709 | return locations
710 | append_location(coords.latitude, coords.longitude) # Add current position
711 | center = self.round_position(coords.latitude, coords.longitude)
712 | for dist in range(10, max_dist + 1, 10):
713 | for degree in range(0, 360):
714 | point = geopy.distance.distance(meters=dist).destination(center, bearing=degree)
715 | append_location(point.latitude, point.longitude)
716 | seen = []
717 | for l in locations: # Filter duplicates
718 | if not l in seen:
719 | seen.append(l)
720 | return seen
721 |
722 | def fetch_open_elevation(self, locations: list[dict[str, float]]) -> dict:
723 | """
724 | Retreive elevations from open-elevation
725 | """
726 | try:
727 | response = requests.post(
728 | url="https://api.open-elevation.com/api/v1/lookup",
729 | headers={"Accept": "application/json", "content-type": "application/json"},
730 | data=json.dumps(dict(locations=locations)),
731 | timeout=10,
732 | )
733 | response.raise_for_status()
734 | return response.json()["results"]
735 | except requests.RequestException as e:
736 | logging.error(f"{self.header}[Elevation] Error with open-elevation: {e}")
737 | except json.JSONDecodeError:
738 | logging.error(f"{self.header}[Elevation] Error while reading json")
739 | return {}
740 |
741 | def update_cache_elevation(self) -> None:
742 | """
743 | Use open-elevation API to cache surrounding GPS points.
744 | """
745 | if not self.is_configured() or (now() - self.last_elevation).total_seconds() < 60:
746 | return
747 | self.last_elevation = now()
748 | if not (locations := self.calculate_locations()):
749 | return
750 | logging.info(f"{self.header}[Elevation] {len(self.elevation_data)} elevations available")
751 | logging.info(f"{self.header}[Elevation] Trying to cache {len(locations)} locations")
752 | if not (results := self.fetch_open_elevation(locations)):
753 | return
754 | with self.lock:
755 | for item in results:
756 | self.cache_elevation(item["latitude"], item["longitude"], item["elevation"])
757 | self.save_elevation_cache()
758 | logging.info(f"{self.header}[Elevation] {len(self.elevation_data)} elevations in cache")
759 |
760 |
761 | @dataclass(slots=True)
762 | class GPSD_ng(plugins.Plugin):
763 | # GPSD Thread and configuration
764 | gpsd: GPSD = field(default_factory=lambda: GPSD())
765 | use_open_elevation: bool = True
766 | wifi_positioning: bool = False
767 | handshake_dir: str = ""
768 | # e-ink display
769 | last_ui_update: datetime = field(default_factory=lambda: now())
770 | ui_counter: int = 0
771 | view_mode: str = "compact"
772 | display_fields: list[str] = field(default_factory=list)
773 | units: str = "metric"
774 | display_precision: int = 6
775 | position: str = "127,64"
776 | linespacing: int = 10
777 | show_faces: bool = True
778 | lost_face_1: str = "(O_o )"
779 | lost_face_2: str = "( o_O)"
780 | face_1: str = "(•_• )"
781 | face_2: str = "( •_•)"
782 | # Web UI
783 | template: str = "Loading error"
784 |
785 | ready: bool = False
786 |
787 | __name__: str = "GPSD-ng"
788 | __GitHub__: str = "https://github.com/fmatray/pwnagotchi_GPSD-ng"
789 | __author__: str = "@fmatray"
790 | __version__: str = "1.9.8"
791 | __license__: str = "GPL3"
792 | __description__: str = (
793 | "Use GPSD server to save position on handshake. Can use mutiple gps device (serial, USB dongle, phone, etc.)"
794 | )
795 | __help__: str = (
796 | "Use GPSD server to save position on handshake. Can use mutiple gps device (serial, USB dongle, phone, etc.)"
797 | )
798 | __dependencies__: dict[str, Any] = field(default_factory=lambda: dict(apt=["gpsd python3-gps"]))
799 | __defaults__: dict[str, Any] = field(default_factory=lambda: dict(enabled=False))
800 | header: str = "[GPSD-ng][Plugin]"
801 |
802 | def __post_init__(self) -> None:
803 | super(plugins.Plugin, self).__init__()
804 | template_filename = os.path.dirname(os.path.realpath(__file__)) + "/" + "gpsd-ng.html"
805 | try:
806 | with open(template_filename, "r") as fb:
807 | self.template = fb.read()
808 | except IOError as e:
809 | logging.error(f"{self.header} Cannot read template file {template_filename}: {e}")
810 |
811 | # ----------LOAD AND CONFIGURE ----------
812 | def on_loaded(self) -> None:
813 | logging.info(f"{self.header} Plugin loaded. Version {self.__version__}")
814 |
815 | def on_config_changed(self, config: dict) -> None:
816 | logging.info(f"{self.header} Reading configuration")
817 |
818 | # GPSD Thread
819 | gpsdhost = self.options.get("gpsdhost", "127.0.0.1")
820 | gpsdport = int(self.options.get("gpsdport", 2947))
821 | main_device = self.options.get("main_device", None)
822 | fix_timeout = self.options.get("fix_timeout", 120)
823 | update_timeout = self.options.get("update_timeout", 120)
824 | if update_timeout < fix_timeout:
825 | logging.error(f"{self.header} 'update_timeout' cannot be lesser than 'fix_timeout'.")
826 | logging.error(f"{self.header} Setting 'update_timeout' to 'fix_timeout'.")
827 | update_timeout = fix_timeout
828 | # open-elevation
829 | self.handshake_dir = config["bettercap"].get("handshakes")
830 | self.use_open_elevation = self.options.get("use_open_elevation", self.use_open_elevation)
831 | save_elevations = self.options.get("save_elevations", True)
832 | # wifi positioning
833 | self.wifi_positioning = self.options.get("wifi_positioning", self.wifi_positioning)
834 | wifi_positioning_filename = None
835 | if self.wifi_positioning:
836 | wifi_positioning_filename = os.path.join(self.handshake_dir, ".wifi_positioning")
837 |
838 | self.gpsd.configure(
839 | gpsdhost=gpsdhost,
840 | gpsdport=gpsdport,
841 | fix_timeout=fix_timeout,
842 | update_timeout=update_timeout,
843 | main_device=main_device,
844 | cache_filename=os.path.join(self.handshake_dir, ".elevations"),
845 | save_elevations=save_elevations,
846 | wifi_positioning_filename=wifi_positioning_filename,
847 | )
848 | if self.wifi_positioning:
849 | self.read_position_files()
850 |
851 | try: # Start gpsd thread
852 | self.gpsd.start()
853 | except Exception as e:
854 | logging.critical(f"{self.header} Error with GPSD Thread: {e}")
855 | logging.critical(f"{self.header} Stop plugin")
856 | return
857 |
858 | # view mode
859 | self.view_mode = self.options.get("view_mode", self.view_mode).lower()
860 | if not self.view_mode in ["compact", "full", "status", "none"]:
861 | logging.error(
862 | f"{self.header} Wrong setting for view_mode: {self.view_mode}. Using compact"
863 | )
864 | self.view_mode = "compact"
865 | # fields ton display
866 | DISPLAY_FIELDS = ["info", "altitude", "speed"]
867 | display_fields = self.options.get("fields", DISPLAY_FIELDS)
868 | if isinstance(display_fields, str):
869 | self.display_fields = list(map(str.strip, display_fields.split(",")))
870 | elif isinstance(display_fields, list):
871 | self.display_fields = list(map(str.strip, display_fields))
872 | else:
873 | logging.error(
874 | f"{self.header} Wrong setting for fields: must be a string or list. Using default"
875 | )
876 | self.display_fields = DISPLAY_FIELDS
877 |
878 | if "longitude" not in self.display_fields:
879 | self.display_fields.insert(0, "longitude")
880 | if "latitude" not in self.display_fields:
881 | self.display_fields.insert(0, "latitude")
882 | # units and precision. only for display
883 | self.units = self.options.get("units", self.units).lower()
884 | if not self.units in ["metric", "imperial"]:
885 | logging.error(f"{self.header} Wrong setting for units: {self.units}. Using metric")
886 | self.units = "metric"
887 | self.display_precision = int(self.options.get("display_precision", self.display_precision))
888 | # UI items
889 | self.position = self.options.get("position", self.position)
890 | self.linespacing = self.options.get("linespacing", self.linespacing)
891 | self.show_faces = self.options.get("show_faces", self.show_faces)
892 | self.lost_face_1 = self.options.get("lost_face_1", self.lost_face_1)
893 | self.lost_face_2 = self.options.get("lost_face_1", self.lost_face_2)
894 | self.face_1 = self.options.get("face_1", self.face_1)
895 | self.face_2 = self.options.get("face_2", self.face_2)
896 |
897 | logging.info(f"{self.header} Configuration done")
898 | self.ready = True
899 |
900 | def on_ready(self, agent) -> None:
901 | try:
902 | logging.info(f"{self.header} Disabling bettercap's gps module")
903 | agent.run("gps off")
904 | except Exception as e:
905 | logging.info(f"{self.header} Bettercap gps was already off.")
906 |
907 | # ---------- UNLOAD ----------
908 | def on_unload(self, ui) -> None:
909 | if not self.ready:
910 | return
911 | try:
912 | self.gpsd.join()
913 | except Exception:
914 | pass
915 | with ui._lock:
916 | for element in ["latitude", "longitude", "altitude", "speed", "gps", "gps_status"]:
917 | try:
918 | ui.remove_element(element)
919 | except KeyError:
920 | pass
921 |
922 | # ---------- BLUETOOTH ----------
923 | def on_bluetooth_up(self, phone: dict) -> None:
924 | """
925 | Restart gpsd server on bluetooth reconnection
926 | """
927 | self.gpsd.reload_or_restart_gpsd()
928 |
929 | # ---------- WIFI POSITIONING ----------
930 | def read_position_files(self) -> None:
931 | """
932 | Read gps.json and geo.json files for wifi poistioning
933 | """
934 | files = glob(os.path.join(self.handshake_dir, "*.g*.json"))
935 | logging.info(f"{self.header} Reading gps/geo files ({len(files)}) for wifi positionning")
936 | nb_files = 0
937 | for file in files:
938 | if not self.is_gpsfile_valid(file):
939 | continue # continue if the file is not valid
940 | try:
941 | bssid = re.findall(r".*_([0-9a-f]{12})\.", file)[0]
942 | except IndexError:
943 | continue
944 | try:
945 | with open(file, "r") as fb:
946 | data = json.load(fb)
947 | if data.get("Device", None) == "wifi":
948 | continue # remove wifi based positions
949 | self.gpsd.update_wifi_positions(
950 | bssid=bssid,
951 | lat=data.get("Latitude", float("NaN")),
952 | long=data.get("Longitude", float("NaN")),
953 | alt=data.get("Altitude", float("NaN")),
954 | )
955 | nb_files += 1
956 | except (IOError, TypeError, KeyError) as e:
957 | logging.error(f"{self.header} Error on reading file {file}: {e}")
958 | logging.info(f"{self.header} {nb_files} initial files used for wifi positioning")
959 |
960 | def update_wifi_positions(self, aps, coords: Position) -> None:
961 | """
962 | Update wifi position based on a list for access points
963 | """
964 | if coords.device == "wifi":
965 | return
966 | for ap in aps:
967 | try:
968 | mac = extract_stripped_mac(ap)
969 | except KeyError:
970 | continue
971 | self.gpsd.update_wifi_positions(mac, coords.latitude, coords.longitude, coords.altitude)
972 |
973 | # ---------- UPDATES ----------
974 | def update_bettercap_gps(self, agent, coords: Position) -> None:
975 | try:
976 | agent.run(f"set gps.set {coords.latitude} {coords.longitude}")
977 | except Exception as e:
978 | logging.error(f"{self.header} Cannot set bettercap GPS: {e}")
979 |
980 | def on_internet_available(self, agent) -> None:
981 | if not self.ready:
982 | return
983 | if self.use_open_elevation:
984 | self.gpsd.update_cache_elevation()
985 |
986 | if not (coords := self.gpsd.get_position()):
987 | return
988 | self.update_bettercap_gps(agent, coords)
989 |
990 | # ---------- WIFI HOOKS ----------
991 | def save_gps_file(self, gps_filename: str, coords: Position) -> None:
992 | logging.info(f"{self.header} Saving GPS to {gps_filename}")
993 | try:
994 | with open(gps_filename, "w+t") as fp:
995 | json.dump(coords.to_dict(), fp, indent=4)
996 | except (IOError, TypeError) as e:
997 | logging.error(f"{self.header} Error on saving gps coordinates: {e}")
998 |
999 | @staticmethod
1000 | def is_gpsfile_valid(gps_filename: str) -> bool:
1001 | return os.path.exists(gps_filename) and os.path.getsize(gps_filename) > 0
1002 |
1003 | def complete_missings(self, aps, coords: Position) -> None:
1004 | for ap in aps:
1005 | try:
1006 | mac = extract_stripped_mac(ap)
1007 | hostname = re.sub(r"[^a-zA-Z0-9]", "", ap["hostname"])
1008 | except KeyError:
1009 | continue
1010 |
1011 | pcap_filename = os.path.join(self.handshake_dir, f"{hostname}_{mac}.pcap")
1012 | if not os.path.exists(pcap_filename): # Pcap file doesn't exist => next
1013 | continue
1014 |
1015 | gps_filename = os.path.join(self.handshake_dir, f"{hostname}_{mac}.gps.json")
1016 | # gps.json exist with size>0 => next
1017 | if self.is_gpsfile_valid(gps_filename):
1018 | continue
1019 |
1020 | geo_filename = os.path.join(self.handshake_dir, f"{hostname}_{mac}.geo.json")
1021 | # geo.json exist with size>0 => next
1022 | if self.is_gpsfile_valid(geo_filename):
1023 | continue
1024 | logging.info(
1025 | f"{self.header} Found pcap without gps file {os.path.basename(pcap_filename)}"
1026 | )
1027 | self.save_gps_file(gps_filename, coords)
1028 |
1029 | def on_unfiltered_ap_list(self, agent, aps) -> None:
1030 | if not self.ready:
1031 | return
1032 | if coords := self.gpsd.get_position():
1033 | self.update_bettercap_gps(agent, coords)
1034 | self.complete_missings(aps, coords)
1035 | if self.wifi_positioning:
1036 | self.update_wifi_positions(aps, coords)
1037 | if self.wifi_positioning:
1038 | bssids = list(map(extract_stripped_mac, aps))
1039 | self.gpsd.update_wifi(bssids)
1040 |
1041 | def on_handshake(self, agent, filename: str, access_point, client_station) -> None:
1042 | if not self.ready:
1043 | return
1044 | if not (coords := self.gpsd.get_position()):
1045 | logging.info(f"{self.header} Not saving GPS: no fix")
1046 | return
1047 | self.update_bettercap_gps(agent, coords)
1048 | gps_filename = filename.replace(".pcap", ".gps.json")
1049 | if self.is_gpsfile_valid(gps_filename) and coords.device == "wifi":
1050 | return # not saving wifi positioning if a file already exists and is valid
1051 | self.save_gps_file(gps_filename, coords)
1052 |
1053 | # ---------- UI ----------
1054 | def on_ui_setup(self, ui) -> None:
1055 | if self.view_mode == "none":
1056 | return
1057 | try:
1058 | pos = list(map(int, self.position.split(",")))
1059 | lat_pos = (pos[0] + 5, pos[1])
1060 | lon_pos = (pos[0], pos[1] + self.linespacing)
1061 | alt_pos = (pos[0] + 5, pos[1] + (2 * self.linespacing))
1062 | spd_pos = (pos[0] + 5, pos[1] + (3 * self.linespacing))
1063 | except KeyError:
1064 | if ui.is_waveshare_v2() or ui.is_waveshare_v3() or ui.is_waveshare_v4():
1065 | lat_pos = (127, 64)
1066 | lon_pos = (122, 74)
1067 | alt_pos = (127, 84)
1068 | spd_pos = (127, 94)
1069 | elif ui.is_waveshare_v1():
1070 | lat_pos = (130, 60)
1071 | lon_pos = (130, 70)
1072 | alt_pos = (130, 80)
1073 | spd_pos = (130, 90)
1074 | elif ui.is_inky():
1075 | lat_pos = (127, 50)
1076 | lon_pos = (122, 60)
1077 | alt_pos = (127, 70)
1078 | spd_pos = (127, 80)
1079 | elif ui.is_waveshare144lcd():
1080 | lat_pos = (67, 63)
1081 | lon_pos = (67, 73)
1082 | alt_pos = (67, 83)
1083 | spd_pos = (67, 93)
1084 | elif ui.is_dfrobot_v2():
1085 | lat_pos = (127, 64)
1086 | lon_pos = (122, 74)
1087 | alt_pos = (127, 84)
1088 | spd_pos = (127, 94)
1089 | elif ui.is_waveshare2in7():
1090 | lat_pos = (6, 120)
1091 | lon_pos = (1, 135)
1092 | alt_pos = (6, 150)
1093 | spd_pos = (1, 165)
1094 | else:
1095 | lat_pos = (127, 41)
1096 | lon_pos = (122, 51)
1097 | alt_pos = (127, 61)
1098 | spd_pos = (127, 71)
1099 |
1100 | match self.view_mode:
1101 | case "compact":
1102 | ui.add_element(
1103 | "gps",
1104 | Text(
1105 | value="Waiting for GPS",
1106 | color=BLACK,
1107 | position=lat_pos,
1108 | font=fonts.Small,
1109 | ),
1110 | )
1111 | case "full":
1112 | for key, label, label_pos in [
1113 | ("latitude", "lat:", lat_pos),
1114 | ("longitude", "long:", lon_pos),
1115 | ("altitude", "alt:", alt_pos),
1116 | ("speed", "spd:", spd_pos),
1117 | ]:
1118 | if key in self.display_fields:
1119 | ui.add_element(
1120 | key,
1121 | LabeledValue(
1122 | color=BLACK,
1123 | label=label,
1124 | value="-",
1125 | position=label_pos,
1126 | label_font=fonts.Small,
1127 | text_font=fonts.Small,
1128 | label_spacing=0,
1129 | ),
1130 | )
1131 | case "status":
1132 | ui.add_element(
1133 | "gps_status",
1134 | Text(
1135 | value="----",
1136 | color=BLACK,
1137 | position=lat_pos,
1138 | font=fonts.Small,
1139 | ),
1140 | )
1141 | case _:
1142 | pass
1143 |
1144 | def get_statistics(self) -> Optional[dict[str, int | float]]:
1145 | if not self.ready:
1146 | return None
1147 |
1148 | pcap_filenames = glob(os.path.join(self.handshake_dir, "*.pcap"))
1149 | nb_pcap_files = len(pcap_filenames)
1150 | nb_position_files = 0
1151 | for pcap_filename in pcap_filenames:
1152 | gps_filename = pcap_filename.replace(".pcap", ".gps.json")
1153 | geo_filename = pcap_filename.replace(".pcap", ".geo.json")
1154 | if self.is_gpsfile_valid(gps_filename) or self.is_gpsfile_valid(geo_filename):
1155 | nb_position_files += 1
1156 | try:
1157 | completeness = round(nb_position_files / nb_pcap_files * 100, 1)
1158 | except ZeroDivisionError:
1159 | completeness = 0.0
1160 | return dict(
1161 | nb_devices=len(self.gpsd.positions),
1162 | nb_pcap_files=nb_pcap_files,
1163 | nb_position_files=nb_position_files,
1164 | completeness=completeness,
1165 | nb_cached_elevation=len(self.gpsd.elevation_data),
1166 | )
1167 |
1168 | def display_face(self, ui, face_1: str, face_2: str) -> None:
1169 | if not self.show_faces:
1170 | return
1171 | match self.ui_counter:
1172 | case 1:
1173 | ui.set("face", face_1)
1174 | case 2:
1175 | ui.set("face", face_2)
1176 | case _:
1177 | pass
1178 |
1179 | def lost_mode(self, ui) -> None:
1180 | if not self.ready:
1181 | return
1182 |
1183 | self.display_face(ui, self.lost_face_1, self.lost_face_2)
1184 |
1185 | if not (statistics := self.get_statistics()):
1186 | return
1187 | if not self.gpsd.is_configured():
1188 | status = "GPSD not configured"
1189 | elif not self.gpsd.is_connected():
1190 | status = "GPSD not connected"
1191 | elif statistics["nb_devices"] == 0:
1192 | status = "No GPS device found"
1193 | else:
1194 | status = "Can't get a position"
1195 | ui.set("status", status)
1196 |
1197 | match self.view_mode:
1198 | case "compact":
1199 | if statistics["nb_devices"] == 0:
1200 | ui.set("gps", f"No GPS Device")
1201 | else:
1202 | ui.set("gps", f"No GPS Fix: {statistics['nb_devices']} dev.")
1203 | case "full":
1204 | for i in ["latitude", "longitude", "altitude", "speed"]:
1205 | try:
1206 | ui.set(i, "-")
1207 | except KeyError:
1208 | pass
1209 | case "status":
1210 | ui.set("gps_status", "Lost")
1211 | case _:
1212 | pass
1213 |
1214 | def compact_view_mode(self, ui, coords: Position) -> None:
1215 | info, lat, long, alt, spd = coords.format(self.units, self.display_precision)
1216 | match self.ui_counter:
1217 | case 0 if "info" in self.display_fields:
1218 | ui.set("gps", info)
1219 | case 1:
1220 | msg = []
1221 | if "speed" in self.display_fields:
1222 | msg.append(f"Spd:{spd}")
1223 | if "altitude" in self.display_fields:
1224 | msg.append(f"Alt:{alt}")
1225 | if msg:
1226 | ui.set("gps", " ".join(msg))
1227 | case 2:
1228 | if statistics := self.get_statistics():
1229 | ui.set("gps", f"Complet.:{statistics['completeness']}%")
1230 | case _:
1231 | ui.set("gps", f"{lat},{long}")
1232 |
1233 | def full_view_mode(self, ui, coords: Position) -> None:
1234 | _, lat, long, alt, spd = coords.format(self.units, self.display_precision)
1235 | ui.set("latitude", f"{lat} ")
1236 | ui.set("longitude", f"{long} ")
1237 | if "altitude" in self.display_fields:
1238 | ui.set("altitude", f"{alt} ")
1239 | if "speed" in self.display_fields:
1240 | ui.set("speed", f"{spd} ")
1241 |
1242 | def status_view_mode(self, ui, coords: Position) -> None:
1243 | if coords:
1244 | ui.set("gps_status", f" {coords.mode}D ")
1245 | return
1246 | ui.set("gps_status", "Err.")
1247 |
1248 | def on_ui_update(self, ui) -> None:
1249 | if not self.ready or self.view_mode == "none":
1250 | return
1251 | if (now() - self.last_ui_update).total_seconds() < 10:
1252 | return
1253 | self.last_ui_update = now()
1254 |
1255 | self.ui_counter = (self.ui_counter + 1) % 5
1256 | with ui._lock:
1257 | if not (coords := self.gpsd.get_position()):
1258 | self.lost_mode(ui)
1259 | return
1260 | self.display_face(ui, self.face_1, self.face_2)
1261 | match self.view_mode:
1262 | case "compact":
1263 | self.compact_view_mode(ui, coords)
1264 | case "full":
1265 | self.full_view_mode(ui, coords)
1266 | case "status":
1267 | self.status_view_mode(ui, coords)
1268 | case _:
1269 | pass
1270 |
1271 | def on_webhook(self, path: str, request) -> str:
1272 | def error(message) -> str:
1273 | return render_template("status.html", title="Error", go_back_after=10, message=message)
1274 |
1275 | if not self.ready:
1276 | return error("Plugin not ready")
1277 | match path:
1278 | case None | "/":
1279 | try:
1280 | return render_template_string(
1281 | self.template,
1282 | device=self.gpsd.get_position_device(),
1283 | current_position=deepcopy(self.gpsd.get_position()),
1284 | positions=deepcopy(self.gpsd.positions),
1285 | units=self.units,
1286 | statistics=self.get_statistics(),
1287 | )
1288 | except Exception as e:
1289 | logging.error(f"{self.header} Error while rendering template: {e}")
1290 | return error("Rendering error")
1291 | case "polar":
1292 | try:
1293 | device = request.args["device"]
1294 | return self.gpsd.positions[device].generate_polar_plot()
1295 | except KeyError:
1296 | return error("{self.header} Rendering with polar image")
1297 | case "restart_gpsd":
1298 | self.gpsd.reload_or_restart_gpsd()
1299 | return "Done"
1300 | case _:
1301 | return error("{self.header} Unkown path")
1302 |
--------------------------------------------------------------------------------