├── .gitignore ├── COPYING ├── README.md ├── _modes.c ├── _modes.h ├── debian ├── changelog ├── compat ├── config-template ├── control ├── copyright ├── mlat-client.config ├── mlat-client.init ├── mlat-client.install ├── mlat-client.logrotate ├── mlat-client.postinst ├── mlat-client.postrm ├── mlat-client.templates └── rules ├── flightaware ├── __init__.py └── client │ ├── __init__.py │ ├── adeptclient.py │ └── cli.py ├── mlat ├── __init__.py ├── client │ ├── __init__.py │ ├── cli.py │ ├── coordinator.py │ ├── jsonclient.py │ ├── net.py │ ├── options.py │ ├── output.py │ ├── receiver.py │ ├── stats.py │ ├── synthetic_es.py │ ├── util.py │ └── version.py ├── constants.py ├── geodesy.py └── profile.py ├── modes_crc.c ├── modes_message.c ├── modes_reader.c ├── pyproject.toml ├── run-flake8.sh ├── setup.py ├── tools └── compare-message-timing.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | build 3 | dist 4 | *.so 5 | test*.sh 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mlat-client 2 | 3 | This is a client that selectively forwards Mode S messages to a 4 | server that resolves the transmitter position by multilateration of the same 5 | message received by multiple clients. 6 | 7 | The corresponding server code is available at 8 | https://github.com/mutability/mlat-server. 9 | 10 | ## Building 11 | 12 | To build a Debian (or Ubuntu, Raspbian, etc) package that includes config 13 | and startup scripts: 14 | 15 | $ sudo apt-get install build-essential debhelper python3-dev 16 | $ dpkg-buildpackage -b -uc 17 | 18 | This will build a .deb package in the parent directory. Install it with dpkg: 19 | 20 | $ sudo dpkg -i ../mlat-client_(version)_(architecture).deb 21 | 22 | To build/install (client only) on other systems using pip (you might want 23 | to do this inside a virtualenv): 24 | 25 | $ pip install . 26 | 27 | Or using the legacy setup.py: 28 | 29 | $ ./setup.py install 30 | 31 | ## Running 32 | 33 | If you are connecting to a third party multilateration server, contact the 34 | server's administrator for configuration instructions. 35 | 36 | ## Supported receivers 37 | 38 | * Anything that produces Beast-format output with a 12MHz clock: 39 | * dump1090_mr, dump1090-mutability, FlightAware's dump1090 40 | * modesdeco (probably?) 41 | * an actual Mode-S Beast 42 | * airspy_adsb in Beast output mode 43 | * SBS receivers 44 | * Radarcape in 12MHz mode 45 | * Radarcape in GPS mode 46 | 47 | ## Unsupported receivers 48 | 49 | * The FlightRadar24 radarcape-based receiver. This produces a deliberately 50 | crippled timestamp in its output, making it useless for multilateration. 51 | If you have one of these, you should ask FR24 to fix this. 52 | 53 | ## License 54 | 55 | Copyright 2015, [Oliver Jowett](mailto:oliver@mutability.co.uk). 56 | 57 | This program is free software: you can redistribute it and/or modify 58 | it under the terms of the GNU General Public License as published by 59 | the Free Software Foundation, either version 3 of the License, or 60 | (at your option) any later version. 61 | 62 | This program is distributed in the hope that it will be useful, 63 | but WITHOUT ANY WARRANTY; without even the implied warranty of 64 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 65 | GNU General Public License for more details. 66 | 67 | You should have received [a copy of the GNU General Public License](COPYING) 68 | along with this program. If not, see . 69 | -------------------------------------------------------------------------------- /_modes.c: -------------------------------------------------------------------------------- 1 | /* 2 | * Part of mlat-client - an ADS-B multilateration client. 3 | * Copyright 2015, Oliver Jowett 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | #include "_modes.h" 20 | 21 | /* would be nice to move this into the submodule init, but that needs python 3.5 for PyModule_AddFunctions */ 22 | static PyMethodDef methods[] = { 23 | { "crc", modescrc_crc, METH_VARARGS, "Calculate the Mode S CRC over a buffer. Don't include the message's trailing CRC bytes in the provided buffer." }, 24 | { "EventMessage", (PyCFunction)modesmessage_eventmessage, METH_VARARGS|METH_KEYWORDS, "Constructs a new event message with a given type, timestamp, and event data." }, 25 | { NULL, NULL, 0, NULL } 26 | }; 27 | 28 | static void free_modes(PyObject *); 29 | 30 | PyDoc_STRVAR(docstr, "C helpers to speed up ModeS message processing"); 31 | static PyModuleDef module = { 32 | PyModuleDef_HEAD_INIT, 33 | "_modes", /* m_name */ 34 | docstr, /* m_doc */ 35 | -1, /* m_size */ 36 | methods, /* m_methods */ 37 | NULL, /* m_slots / m_reload */ 38 | NULL, /* m_traverse */ 39 | NULL, /* m_clear */ 40 | (freefunc)free_modes /* m_free */ 41 | }; 42 | 43 | PyMODINIT_FUNC 44 | PyInit__modes(void) 45 | { 46 | PyObject *m = NULL; 47 | 48 | m = PyModule_Create(&module); 49 | if (m == NULL) 50 | return NULL; 51 | 52 | if (modescrc_module_init(m) < 0) { 53 | goto error; 54 | } 55 | 56 | if (modesmessage_module_init(m) < 0) { 57 | goto error; 58 | } 59 | 60 | if (modesreader_module_init(m) < 0) { 61 | goto error; 62 | } 63 | 64 | return m; 65 | 66 | error: 67 | Py_DECREF(m); 68 | return NULL; 69 | } 70 | 71 | void free_modes(PyObject *m) 72 | { 73 | modesreader_module_free(m); 74 | modesmessage_module_free(m); 75 | modescrc_module_free(m); 76 | } 77 | -------------------------------------------------------------------------------- /_modes.h: -------------------------------------------------------------------------------- 1 | #ifndef _MODES_H 2 | #define _MODES_H 3 | 4 | /* 5 | * Part of mlat-client - an ADS-B multilateration client. 6 | * Copyright 2015, Oliver Jowett 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | #include 23 | #include 24 | 25 | #include 26 | 27 | /* a modesmessage object */ 28 | typedef struct { 29 | PyObject_HEAD 30 | 31 | unsigned long long timestamp; 32 | unsigned int signal; 33 | 34 | unsigned int df; 35 | unsigned int nuc; 36 | char even_cpr; 37 | char odd_cpr; 38 | char valid; 39 | PyObject *crc; 40 | PyObject *address; 41 | PyObject *altitude; 42 | 43 | uint8_t *data; 44 | int datalen; 45 | 46 | PyObject *eventdata; 47 | } modesmessage; 48 | 49 | /* special DF types for non-Mode-S messages */ 50 | #define DF_MODEAC 32 51 | #define DF_EVENT_TIMESTAMP_JUMP 33 52 | #define DF_EVENT_MODE_CHANGE 34 53 | #define DF_EVENT_EPOCH_ROLLOVER 35 54 | #define DF_EVENT_RADARCAPE_STATUS 36 55 | #define DF_EVENT_RADARCAPE_POSITION 37 56 | 57 | /* factory function to build a modesmessage from a provided buffer */ 58 | PyObject *modesmessage_from_buffer(unsigned long long timestamp, unsigned signal, uint8_t *data, int datalen); 59 | /* factory function to build an event message */ 60 | PyObject *modesmessage_new_eventmessage(int type, unsigned long long timestamp, PyObject *eventdata); 61 | /* python entry point */ 62 | PyObject *modesmessage_eventmessage(PyObject *self, PyObject *args, PyObject *kwds); 63 | 64 | /* crc helpers */ 65 | uint32_t modescrc_buffer_crc(uint8_t *buf, Py_ssize_t len); /* internal interface */ 66 | PyObject *modescrc_crc(PyObject *self, PyObject *args); /* external interface */ 67 | 68 | /* submodule init/cleanup */ 69 | int modescrc_module_init(PyObject *m); 70 | void modescrc_module_free(PyObject *m); 71 | int modesreader_module_init(PyObject *m); 72 | void modesreader_module_free(PyObject *m); 73 | int modesmessage_module_init(PyObject *m); 74 | void modesmessage_module_free(PyObject *m); 75 | 76 | #endif 77 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | mlat-client (0.2.13) stable; urgency=medium 2 | 3 | * Support for Python 3.11 PyFloat_Unpack4 API change 4 | * Move the CLI scripts {fa-,}mlat-client into their respective packages; 5 | replace the existing scripts with setuptools APIs 6 | * Add a pyproject.toml with most of the build metadata. setup.py is still needed 7 | for building with older Python and for the extension module. 8 | * Switch package builds to use pybuild, since everything should be recent enough 9 | for that now. 10 | 11 | -- Oliver Jowett Mon, 23 Oct 2023 19:52:21 +0800 12 | 13 | mlat-client (0.2.12) stable; urgency=medium 14 | 15 | * Treat a wider range of timestamps as synthetic 16 | * Fix radarcape position sanity check (broken for 2+ years!) 17 | 18 | -- Oliver Jowett Mon, 20 Dec 2021 14:25:08 +0800 19 | 20 | mlat-client (0.2.11) stable; urgency=medium 21 | 22 | * Do a sanity check on reported radarcape positions 23 | * Adjust UDP datagram size to follow reported route MTU 24 | * Make setup.py use env to find python3 interpreter (PR #17) 25 | 26 | -- Oliver Jowett Mon, 30 Dec 2019 22:14:59 +0800 27 | 28 | mlat-client (0.2.10) stable; urgency=medium 29 | 30 | * Fix handling of special (synthetic mlat / zero) message timestamps 31 | * Fix handling of Radarcape UTC midnight timestamp rollover 32 | * Fix typo in feet/meters conversion 33 | * Increase listen queue size on listening output sockets 34 | * Add a message timing correlation script 35 | 36 | -- Oliver Jowett Thu, 02 Nov 2017 13:11:26 +0000 37 | 38 | mlat-client (0.2.9) stable; urgency=medium 39 | 40 | * Fix jsonclient result path (github issue #18) 41 | 42 | -- Oliver Jowett Mon, 03 Apr 2017 13:29:01 +0100 43 | 44 | mlat-client (0.2.8) stable; urgency=medium 45 | 46 | * Mode A/C support 47 | * DF18 trackfile identifier support for Mode A/C results 48 | * Radarcape position message support 49 | * Update Beast-format timestamp adjustments to match the behavior of 50 | the Beast/Radarcape. This effectively only changes Mode A/C. 51 | 52 | -- Oliver Jowett Mon, 16 Jan 2017 14:14:29 +0000 53 | 54 | mlat-client (0.2.7) stable; urgency=medium 55 | 56 | * Add --no-anon-results option 57 | 58 | -- Oliver Jowett Tue, 25 Oct 2016 23:42:40 +0100 59 | 60 | mlat-client (0.2.6) stable; urgency=medium 61 | 62 | * Fix missing METH_KEYWORDS that would cause a crash on amd64 63 | * Use sendto, not connect/send, for the UDP transport so that the 64 | UDP source address follows any routing changes that happen 65 | 66 | -- Oliver Jowett Wed, 27 Jul 2016 17:09:36 +0100 67 | 68 | mlat-client (0.2.5) stable; urgency=medium 69 | 70 | * Overhaul of the extension module to allow much more filtering to happen 71 | before messages are passed to the Python layer 72 | * Autodetect input format where possible 73 | * Generate results as DF18 TIS-B, not DF17 74 | * Support generating DF18 "anonymous address" results 75 | * Don't fail entirely if a result output listener can't be created 76 | * IPv6 support 77 | * Interpret Radarcape status messages and change clock mode based on GPS 78 | status 79 | * Send a settings message on Beast-format connections so that (if the 80 | other end understands them) we get only the specific messages we care 81 | about 82 | 83 | -- Oliver Jowett Fri, 15 Jul 2016 13:13:03 +0100 84 | 85 | mlat-client (0.2.4) unstable; urgency=medium 86 | 87 | * Better periodic stats logging. 88 | * Grab CLIENT_VERSION from the source in setup.py so it doesn't need to be 89 | in two places. 90 | * fa-mlat-client: explicitly log exceptions on exit to try to fix cxfreeze 91 | weirdness. 92 | * fa-mlat-client: log server status messages. 93 | * fa-mlat-client: periodically report UDP stats so the server can notice 94 | message loss. 95 | 96 | -- Oliver Jowett Sun, 27 Sep 2015 23:27:16 +0100 97 | 98 | mlat-client (0.2.3) unstable; urgency=medium 99 | 100 | * Produce integer speed/heading/vrate values in Basestation output, maybe 101 | this will make VRS happier. 102 | 103 | -- Oliver Jowett Thu, 09 Jul 2015 12:27:20 +0100 104 | 105 | mlat-client (0.2.2) unstable; urgency=medium 106 | 107 | * Big overhaul of option handling. Existing configs should be upgraded 108 | automatically. See new config questions or mlat-client --help for details. 109 | 110 | * Support both listen and connect ("push") modes for result connections. 111 | 112 | * Add Beast-format result connections that generate synthetic DF17 messages 113 | for result position and velocity. The synthetic messages have a special 114 | timestamps (FF 00 MLAT) to indicate they are mlat-generated. mlat-client 115 | will ignore any messages with this timestamp that it receives. Input 116 | messages from the receiver are _not_ looped through to result connections. 117 | 118 | * Support receiving mlat results via a Flightaware-style connections in 119 | fa-mlat-client. 120 | 121 | * Support the full range of input / result options in fa-mlat-client. 122 | 123 | -- Oliver Jowett Thu, 02 Jul 2015 16:48:45 +0100 124 | 125 | mlat-client (0.2.1) unstable; urgency=medium 126 | 127 | * Add Breaks/Replaces for piaware versions that also provide 128 | /usr/bin/fa-mlat-client (piaware 2.0-5 stops providing this) 129 | 130 | -- Oliver Jowett Sun, 28 Jun 2015 12:05:22 +0100 131 | 132 | mlat-client (0.2.0) unstable; urgency=medium 133 | 134 | * Integrate the Flightaware version of the client into the main release. 135 | 136 | -- Oliver Jowett Thu, 18 Jun 2015 20:33:17 +0100 137 | 138 | mlat-client (0.1.17) unstable; urgency=medium 139 | 140 | * Handle keepalives and other messages that have a timestamp of zero. 141 | * Log a better explanation when a ClockResetError is seen. 142 | 143 | -- Oliver Jowett Mon, 15 Jun 2015 20:17:11 +0100 144 | 145 | mlat-client (0.1.16) unstable; urgency=medium 146 | 147 | * Workaround for dump1090-mutability issue #47 (a bug when using --modeac that would 148 | trigger ClockResetErrors) 149 | 150 | -- Oliver Jowett Mon, 08 Jun 2015 17:13:03 +0100 151 | 152 | mlat-client (0.1.15) unstable; urgency=medium 153 | 154 | * Fix "TypeError: expected an object with the buffer interface" and 155 | timestamp problems when configured for a SBS receiver. 156 | * Added some debugging info to ClockResetError. 157 | 158 | -- Oliver Jowett Sun, 07 Jun 2015 11:16:30 +0100 159 | 160 | mlat-client (0.1.14) unstable; urgency=medium 161 | 162 | * Fix looping "_modes.ClockResetError" errors after dump1090 restart. 163 | * Skip 0.1.13 since the 0.1.12 release had the wrong client version 164 | embedded in the code. 165 | 166 | -- Oliver Jowett Sun, 31 May 2015 14:04:28 +0100 167 | 168 | mlat-client (0.1.12) unstable; urgency=medium 169 | 170 | * Log client version on startup. 171 | * Add an inactivity timeout to server connections. 172 | In particular, this should help with connections that silently die 173 | during the handshaking phase, where we would otherwise wait forever 174 | without writing. 175 | * Convert to Python 3. 176 | * Use time.monotonic() if available; emulate it if not available. This should 177 | help with cases where system time goes backwards. 178 | * More processing moved into the extension module. This seems to reduce the 179 | client CPU by around 20% overall. 180 | 181 | -- Oliver Jowett Wed, 27 May 2015 14:28:16 +0100 182 | 183 | mlat-client (0.1.11) unstable; urgency=medium 184 | 185 | * Bugfixes for SBS support. 186 | * Entirely clean up UDP state on server disconnect. 187 | 188 | -- Oliver Jowett Sun, 03 May 2015 23:52:01 +0100 189 | 190 | mlat-client (0.1.10) unstable; urgency=medium 191 | 192 | * Rename --clock-type to --input-type. 193 | * Add support for SBS "raw socket" inputs (--input-type sbs). 194 | * Clean up the Radarcape GPS rollover code. 195 | * Improvements to options help. 196 | * Remove --random-drop as it's no longer useful. 197 | * Added configuration-script support for --input-type. 198 | 199 | -- Oliver Jowett Tue, 28 Apr 2015 14:09:21 +0100 200 | 201 | mlat-client (0.1.9) unstable; urgency=medium 202 | 203 | * Support for Radarcape when in GPS timestamp mode. 204 | * Add --privacy command-line option. 205 | 206 | -- Oliver Jowett Sat, 25 Apr 2015 18:29:31 +0100 207 | 208 | mlat-client (0.1.8) unstable; urgency=medium 209 | 210 | * Don't explode if there is no covariance matrix in an ECEF result. 211 | * Log server message parsing failures with a bit more detail. 212 | * Fix server->client decompression in zlib2 when a partial packet is received. 213 | * Fix server->client path for zlib and "no" compression (oops). 214 | 215 | -- Oliver Jowett Fri, 24 Apr 2015 10:09:50 +0100 216 | 217 | mlat-client (0.1.7) unstable; urgency=medium 218 | 219 | * Remove --byteswap-timestamp option: the timestamps generated by a 220 | big-endian dump1090 are irretrievably broken. 221 | * Futureproofing: add support for "split sync" sync message 222 | * Futureproofing: add a sequence number to the UDP header, bump UDP version 223 | * Futureproofing: restructure the heartbeat message so more fields can be 224 | added later 225 | * Add support for ECEF-style return results. 226 | 227 | -- Oliver Jowett Thu, 23 Apr 2015 00:10:45 +0100 228 | 229 | mlat-client (0.1.6) unstable; urgency=medium 230 | 231 | * Raise better exceptions on input error to try to diagnose Radarcape problems. 232 | * Handle (and skip) the mystery '4' message that Radarcape systems generate. 233 | * Add --byteswap-timestamp for dealing with dump1090s that (incorrectly) 234 | generate little-endian timestamp values. 235 | * Use the normal reconnect delay when reconnecting after a timestamp problem is 236 | seen. 237 | * Don't forward DF17 sync messages with NUC<6. 238 | * Use UDP transport for mlat/sync messages if requested by the server. Can 239 | be disabled with --no-udp. 240 | * Send ADS-B position rate reports if requested by the server. 241 | 242 | -- Oliver Jowett Sat, 18 Apr 2015 09:31:50 +0100 243 | 244 | mlat-client (0.1.5) unstable; urgency=medium 245 | 246 | * Fix exception logging. 247 | * Fix server vs client confusion in the shutdown message. 248 | * Escape CSV fields that might have commas/quotes. 249 | * Add support for extended Basestation-like output format on a separate port via --sbs-ext-port. 250 | 251 | -- Oliver Jowett Sat, 04 Apr 2015 13:06:41 +0100 252 | 253 | mlat-client (0.1.4) unstable; urgency=medium 254 | 255 | * Emit ICAO addresses in SBS output in all-caps to make VRS happier. 256 | * If no Beast data (not even keepalives) is seen for 2.5 minutes, reconnect. 257 | * Clean up connection logging a little. 258 | * Re-add some periodic stats. 259 | * Increase the allowable backwards-timestamp value a little. 260 | 261 | -- Oliver Jowett Mon, 30 Mar 2015 22:59:10 +0100 262 | 263 | mlat-client (0.1.3) unstable; urgency=medium 264 | 265 | * If message timestamps go significantly backwards, drop the input connection 266 | and reconnect. 267 | * Ignore heartbeat messages with a zero timestamp. 268 | * Expire all current aircraft state if the input connetion is lost. 269 | * Handle connection errors a bit more reliably. 270 | 271 | -- Oliver Jowett Mon, 23 Mar 2015 10:44:51 +0000 272 | 273 | mlat-client (0.1.2) unstable; urgency=medium 274 | 275 | * Reset last-message-timestamp when reconnecting to input. 276 | Should fix the client getting stuck if dump1090 restarts. 277 | 278 | -- Oliver Jowett Wed, 18 Mar 2015 14:20:02 +0000 279 | 280 | mlat-client (0.1.1) unstable; urgency=low 281 | 282 | * Fix BaseStation time format to make VRS happier. 283 | 284 | -- Oliver Jowett Wed, 18 Mar 2015 10:59:30 +0000 285 | 286 | mlat-client (0.1) unstable; urgency=medium 287 | 288 | * Initial release. 289 | 290 | -- Oliver Jowett Tue, 17 Mar 2015 21:59:40 +0000 291 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /debian/config-template: -------------------------------------------------------------------------------- 1 | ## TEMPLATE FILE - This is used to create /etc/default/mlat-client ## 2 | ## The first three lines will be discarded ## 3 | 4 | # mlat-client configuration file 5 | # This is a POSIX shell fragment. 6 | # You can edit this file directly, or use 7 | # "dpkg-reconfigure mlat-client" 8 | 9 | # Start the client? 10 | START_CLIENT= 11 | 12 | # System user to run as. 13 | RUN_AS_USER= 14 | 15 | # User to log into the server as 16 | SERVER_USER= 17 | 18 | # Logfile to log to 19 | LOGFILE= 20 | 21 | # Input receiver type (dump1090, beast, radarcape_12mhz, radarcape_gps, sbs) 22 | INPUT_TYPE= 23 | 24 | # Input host:port to connect to for Beast-format messages 25 | INPUT_HOSTPORT= 26 | 27 | # Multilateration server host:port to provide data to 28 | SERVER_HOSTPORT= 29 | 30 | # Latitude of the receiver, in decimal degrees 31 | LAT= 32 | 33 | # Longitude of the receiver, in decimal degrees 34 | LON= 35 | 36 | # Altitude of the receiver, in metres 37 | ALT= 38 | 39 | # List of result connections/listeners to establish. 40 | # This should be a space-separated list of values suitable for passing to 41 | # the --results option (see mlat-client --help for syntax) 42 | RESULTS= 43 | 44 | # Other arguments to pass to mlat-client 45 | EXTRA_ARGS= 46 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: mlat-client 2 | Section: embedded 3 | Priority: extra 4 | Maintainer: Oliver Jowett 5 | Build-Depends: debhelper(>=10), dh-python, python3-dev 6 | Standards-Version: 3.9.3 7 | Homepage: https://github.com/mutability/mlat-client 8 | Vcs-Git: https://github.com/mutability/mlat-client.git 9 | 10 | Package: mlat-client 11 | Architecture: any 12 | Depends: ${shlibs:Depends}, ${python3:Depends}, ${misc:Depends}, adduser 13 | Breaks: piaware (= 2.0-1), piaware (= 2.0-2), piaware (= 2.0-3), piaware (= 2.0-4) 14 | Replaces: piaware (= 2.0-1), piaware (= 2.0-2), piaware (= 2.0-3), piaware (= 2.0-4) 15 | Description: ADS-B multilateration client. 16 | mlat-client feeds selected messages from an ADS-B receiver 17 | to a multilateration server, and provides access to the results 18 | generated by the server. 19 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: mlat-client 3 | Upstream-Contact: Oliver Jowett 4 | Source: https://github.com/mutability/mlat-client 5 | 6 | Files: * 7 | Copyright: Copyright 2015, Oliver Jowett 8 | License: GPL-3+ 9 | 10 | License: GPL-3+ 11 | This program is free software: you can redistribute it and/or modify 12 | it under the terms of the GNU General Public License as published by 13 | the Free Software Foundation, either version 3 of the License, or 14 | (at your option) any later version. 15 | . 16 | This program is distributed in the hope that it will be useful, 17 | but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | GNU General Public License for more details. 20 | . 21 | You should have received a copy of the GNU General Public License 22 | along with this program. If not, see . 23 | . 24 | On Debian systems, the full text of the GNU General Public 25 | License version 3 can be found in the file 26 | `/usr/share/common-licenses/GPL-3'. 27 | -------------------------------------------------------------------------------- /debian/mlat-client.config: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | NAME=mlat-client 4 | CONFIGFILE=/etc/default/$NAME 5 | set -e 6 | . /usr/share/debconf/confmodule 7 | 8 | db_set_yn() { 9 | # $1 = db key 10 | # $2 = config file yes/no value 11 | if [ "x$2" = "xyes" ]; then 12 | db_set $1 true 13 | else 14 | db_set $1 false 15 | fi 16 | } 17 | 18 | # Load config file, if it exists. 19 | if [ -e $CONFIGFILE ]; then 20 | . $CONFIGFILE || true 21 | 22 | # upgrade old config 23 | if [ -z "$INPUT_HOSTPORT" ] 24 | then 25 | if [ -n "$INPUT_HOST" ] && [ -n "$INPUT_PORT" ] 26 | then 27 | INPUT_HOSTPORT=$INPUT_HOST:$INPUT_PORT 28 | fi 29 | fi 30 | 31 | if [ -z "$RESULTS" ] 32 | then 33 | if [ -n "$SBS_PORT" -a "$SBS_PORT" != 0 ] 34 | then 35 | RESULTS="$RESULTS basestation,listen,$SBS_PORT" 36 | fi 37 | 38 | if [ -n "$SBS_EXT_PORT" -a "$SBS_EXT_PORT" != 0 ] 39 | then 40 | RESULTS="$RESULTS basestation_ext,listen,$SBS_EXT_PORT" 41 | fi 42 | 43 | if [ -z "$RESULTS" ] 44 | then 45 | RESULTS="none" 46 | fi 47 | fi 48 | 49 | # Store values from config file into 50 | # debconf db. 51 | 52 | [ -n "$START_CLIENT" ] && db_set_yn $NAME/start-client "$START_CLIENT" 53 | [ -n "$RUN_AS_USER" ] && db_set $NAME/run-as-user "$RUN_AS_USER" 54 | [ -n "$SERVER_USER" ] && db_set $NAME/server-user "$SERVER_USER" 55 | [ -n "$LOGFILE" ] && db_set $NAME/log-file "$LOGFILE" 56 | [ -n "$INPUT_TYPE" ] && db_set $NAME/input-type "$INPUT_TYPE" 57 | [ -n "$INPUT_HOSTPORT" ] && db_set $NAME/input-hostport "$INPUT_HOSTPORT" 58 | [ -n "$SERVER_HOSTPORT" ] && db_set $NAME/server-hostport "$SERVER_HOSTPORT" 59 | [ -n "$LAT" ] && db_set $NAME/receiver-lat "$LAT" 60 | [ -n "$LON" ] && db_set $NAME/receiver-lon "$LON" 61 | [ -n "$ALT" ] && db_set $NAME/receiver-alt "$ALT" 62 | [ -n "$RESULTS" ] && db_set $NAME/results "$RESULTS" 63 | 64 | # this can be legitimately empty: 65 | db_set $NAME/extra-args "$EXTRA_ARGS" 66 | fi 67 | 68 | # Ask questions. 69 | 70 | db_input_verify() { 71 | # $1 = priority 72 | # $2 = db key 73 | # $3 = verification function, should return 0 if OK 74 | PRI=$1; KEY=$2; VERIFY=$3 75 | 76 | set +e 77 | db_input $PRI $KEY; RESULT=$? 78 | db_go 79 | set -e 80 | ASKED=0 81 | while : 82 | do 83 | db_get $KEY 84 | if $VERIFY "$RET"; then return 0; fi 85 | if [ $RESULT -ne 0 ]; then 86 | # db_input failed, and the existing value does not validate 87 | if [ $RESULT = 30 ] && [ $ASKED = 0 ] 88 | then 89 | # question was skipped, but existing value is invalid 90 | # bump priority and try again (once) 91 | PRI=high 92 | ASKED=1 93 | else 94 | # give up, use the default value 95 | db_reset $KEY 96 | return 0 97 | fi 98 | else 99 | # db_input was OK, but the value did not verify. 100 | # show an error message 101 | db_input high mlat-client/invalid-$VERIFY || true 102 | fi 103 | 104 | # try again 105 | set +e 106 | db_fset $KEY seen false 107 | db_input high $KEY; RESULT=$? 108 | db_go 109 | set -e 110 | done 111 | } 112 | 113 | is_unsigned_int() { 114 | if echo "$1" | grep -Eq '^(0|+?[1-9][0-9]*)$'; then return 0; else return 1; fi 115 | } 116 | 117 | is_number() { 118 | if echo "$1" | grep -Eq '^([+-]?[0-9][0-9]*)(\.[0-9]+)?$'; then return 0; else return 1; fi 119 | } 120 | 121 | is_not_empty() { 122 | if [ -z "$1" ]; then return 1; else return 0; fi 123 | } 124 | 125 | # "adduser: To avoid problems, the username should consist only of 126 | # letters, digits, underscores, full stops, at signs and dashes, and not start with 127 | # a dash (as defined by IEEE Std 1003.1-2001). For compatibility with Samba 128 | # machine accounts $ is also supported at the end of the username" 129 | is_non_root_user() { 130 | if [ -z "$1" ]; then return 1; 131 | elif [ "$1" = "root" ]; then return 1; 132 | elif echo "$1" | grep -Eq '^[a-zA-Z0-9_.@-]+\$?$'; then return 0; 133 | else return 1; fi 134 | } 135 | 136 | is_port_number() { 137 | if is_unsigned_int "$1"; then 138 | if [ "$1" -eq 0 ]; then return 0; fi 139 | if [ "$1" -lt 1024 ]; then return 1; fi 140 | if [ "$1" -gt 65535 ]; then return 1; fi 141 | return 0 142 | else 143 | return 1 144 | fi 145 | } 146 | 147 | db_input medium $NAME/start-client || true 148 | db_go || true 149 | 150 | db_get $NAME/start-client 151 | if [ "$RET" = "true" ] 152 | then 153 | db_input_verify low $NAME/run-as-user is_non_root_user || true 154 | db_input_verify medium $NAME/server-hostport is_not_empty || true 155 | db_input_verify high $NAME/server-user is_not_empty || true 156 | db_input high $NAME/input-type || true 157 | db_input_verify high $NAME/input-hostport is_not_empty || true 158 | db_input_verify high $NAME/receiver-lat is_number || true 159 | db_input_verify high $NAME/receiver-lon is_number || true 160 | db_input_verify high $NAME/receiver-alt is_number || true 161 | db_input medium $NAME/results || true 162 | db_input_verify low $NAME/log-file is_not_empty || true 163 | db_input low $NAME/extra-args || true 164 | db_go || true 165 | fi 166 | 167 | # Done. 168 | db_stop 169 | -------------------------------------------------------------------------------- /debian/mlat-client.init: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ### BEGIN INIT INFO 3 | # Provides: mlat-client 4 | # Required-Start: $remote_fs 5 | # Required-Stop: $remote_fs 6 | # Default-Start: 2 3 4 5 7 | # Default-Stop: 0 1 6 8 | # Short-Description: Multilateration client 9 | # Description: Receives ADS-B messages from a receiver 10 | # and forwards them to a server for multilateration of 11 | # the aircraft position. 12 | ### END INIT INFO 13 | 14 | # Do NOT "set -e" 15 | 16 | # PATH should only include /usr/* if it runs after the mountnfs.sh script 17 | PATH=/sbin:/usr/sbin:/bin:/usr/bin 18 | DESC="mlat-client daemon" 19 | NAME=mlat-client 20 | DAEMON=/usr/bin/mlat-client 21 | ARGS="" 22 | PIDFILE=/var/run/$NAME.pid 23 | SCRIPTNAME=/etc/init.d/$NAME 24 | 25 | # Exit if the package is not installed 26 | [ -x "$DAEMON" ] || exit 0 27 | 28 | # Read configuration variable file if it is present 29 | [ -r /etc/default/$NAME ] && . /etc/default/$NAME 30 | 31 | # work out daemon args 32 | 33 | # sanitize missing settings 34 | badconfig="" 35 | [ -z "$START_CLIENT" ] && START_CLIENT=no 36 | [ -z "$RUN_AS_USER" ] && RUN_AS_USER="missing-RUN_AS_USER-setting-in-config" 37 | [ -z "$SERVER_USER" ] && badconfig="$badconfig SERVER_USER" 38 | [ -z "$LAT" ] && badconfig="$badconfig LAT" 39 | [ -z "$LON" ] && badconfig="$badconfig LON" 40 | [ -z "$ALT" ] && badconfig="$badconfig ALT" 41 | [ -z "$INPUT_TYPE" ] && INPUT_TYPE=dump1090 42 | [ -z "$INPUT_HOSTPORT" ] && badconfig="$badconfig INPUT_HOSTPORT" 43 | [ -z "$SERVER_HOSTPORT" ] && badconfig="$badconfig SERVER_HOSTPORT" 44 | 45 | ARGS="--user $SERVER_USER --lat $LAT --lon $LON --alt $ALT --input-type $INPUT_TYPE --input-connect $INPUT_HOSTPORT --server $SERVER_HOSTPORT" 46 | 47 | for r in $RESULTS 48 | do 49 | if [ "$r" != "none" ] 50 | then 51 | ARGS="$ARGS --results $r" 52 | fi 53 | done 54 | 55 | ARGS="$ARGS $EXTRA_ARGS" 56 | 57 | # Load the VERBOSE setting and other rcS variables 58 | . /lib/init/vars.sh 59 | 60 | # Define LSB log_* functions. 61 | # Depend on lsb-base (>= 3.2-14) to ensure that this file is present 62 | # and status_of_proc is working. 63 | . /lib/lsb/init-functions 64 | 65 | # 66 | # Function that starts the daemon/service 67 | # 68 | do_start() 69 | { 70 | # Return 71 | # 0 if daemon has been started 72 | # 1 if daemon was already running 73 | # 2 if daemon could not be started 74 | 75 | if [ "x$START_CLIENT" != "xyes" ]; then 76 | log_warning_msg "Not starting $NAME daemon, disabled via /etc/default/$NAME" 77 | return 2 78 | fi 79 | 80 | if [ -n "$badconfig" ]; then 81 | log_warning_msg "Not starting $NAME daemon, missing configuration options ($badconfig)" 82 | return 2 83 | fi 84 | 85 | start-stop-daemon --start --quiet --pidfile $PIDFILE --user "$RUN_AS_USER" --startas $DAEMON --test > /dev/null \ 86 | || return 1 87 | 88 | start-stop-daemon --start --nicelevel 5 --quiet --pidfile $PIDFILE --user "$RUN_AS_USER" --chuid "$RUN_AS_USER" --make-pidfile --background --no-close --startas $DAEMON -- \ 89 | $ARGS >>$LOGFILE 2>&1 \ 90 | || return 2 91 | sleep 1 92 | } 93 | 94 | # 95 | # Function that stops the daemon/service 96 | # 97 | do_stop() 98 | { 99 | # Return 100 | # 0 if daemon has been stopped 101 | # 1 if daemon was already stopped 102 | # 2 if daemon could not be stopped 103 | # other if a failure occurred 104 | start-stop-daemon --stop --retry=TERM/30/KILL/5 --pidfile $PIDFILE --user "$RUN_AS_USER" 105 | RETVAL="$?" 106 | [ "$RETVAL" = 2 ] && return 2 107 | sleep 1 108 | # Many daemons don't delete their pidfiles when they exit. 109 | rm -f $PIDFILE 110 | return "$RETVAL" 111 | } 112 | 113 | case "$1" in 114 | start) 115 | [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME" 116 | do_start 117 | case "$?" in 118 | 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 119 | 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 120 | esac 121 | ;; 122 | stop) 123 | [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" 124 | do_stop 125 | case "$?" in 126 | 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 127 | 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 128 | esac 129 | ;; 130 | status) 131 | status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? 132 | ;; 133 | restart|force-reload) 134 | log_daemon_msg "Restarting $DESC" "$NAME" 135 | do_stop 136 | case "$?" in 137 | 0|1) 138 | do_start 139 | case "$?" in 140 | 0) log_end_msg 0 ;; 141 | 1) log_end_msg 1 ;; # Old process is still running 142 | *) log_end_msg 1 ;; # Failed to start 143 | esac 144 | ;; 145 | *) 146 | # Failed to stop 147 | log_end_msg 1 148 | ;; 149 | esac 150 | ;; 151 | *) 152 | echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 153 | exit 3 154 | ;; 155 | esac 156 | 157 | : 158 | -------------------------------------------------------------------------------- /debian/mlat-client.install: -------------------------------------------------------------------------------- 1 | debian/config-template usr/share/mlat-client 2 | -------------------------------------------------------------------------------- /debian/mlat-client.logrotate: -------------------------------------------------------------------------------- 1 | /var/log/mlat-client.log { 2 | daily 3 | rotate 4 4 | copytruncate 5 | } 6 | -------------------------------------------------------------------------------- /debian/mlat-client.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # postinst script for mlat-client 3 | # 4 | # see: dh_installdeb(1) 5 | 6 | set -e 7 | 8 | # summary of how this script can be called: 9 | # * `configure' 10 | # * `abort-upgrade' 11 | # * `abort-remove' `in-favour' 12 | # 13 | # * `abort-remove' 14 | # * `abort-deconfigure' `in-favour' 15 | # `removing' 16 | # 17 | # for details, see http://www.debian.org/doc/debian-policy/ or 18 | # the debian-policy package 19 | 20 | NAME=mlat-client 21 | CONFIGFILE=/etc/default/$NAME 22 | TEMPLATECONFIG=/usr/share/$NAME/config-template 23 | SEDSCRIPT=$CONFIGFILE.sed.tmp 24 | 25 | subvar_raw() { 26 | # $1 = db var value 27 | # $2 = config var name 28 | 29 | # if not present in the config file, add it 30 | if grep -Eq "^ *$2=" $CONFIGFILE 31 | then 32 | # Already present 33 | true 34 | else 35 | echo "# $2 added automatically on upgrade" >> $CONFIGFILE 36 | echo "$2=" >> $CONFIGFILE 37 | fi 38 | 39 | # add to the sedscript 40 | echo "s@^ *$2=.*@$2=\"$1\"@" >>$SEDSCRIPT 41 | } 42 | 43 | subvar() { 44 | # $1 = db var name 45 | # $2 = config var name 46 | db_get $NAME/$1 47 | subvar_raw "$RET" "$2" 48 | } 49 | 50 | subvar_yn() { 51 | # $1 = db var name 52 | # $2 = config var name 53 | db_get $NAME/$1 54 | if [ "$RET" = "true" ]; then subvar_raw "yes" "$2"; else subvar_raw "no" "$2"; fi 55 | } 56 | 57 | case "$1" in 58 | configure) 59 | . /usr/share/debconf/confmodule 60 | 61 | # Generate config file, if it doesn't exist. 62 | if [ ! -e $CONFIGFILE ]; then 63 | tail -n +4 $TEMPLATECONFIG >$CONFIGFILE 64 | fi 65 | 66 | rm -f $SEDSCRIPT 67 | 68 | subvar_yn start-client START_CLIENT 69 | subvar run-as-user RUN_AS_USER 70 | subvar server-hostport SERVER_HOSTPORT 71 | subvar server-user SERVER_USER 72 | subvar input-type INPUT_TYPE 73 | subvar input-hostport INPUT_HOSTPORT 74 | subvar receiver-lat LAT 75 | subvar receiver-lon LON 76 | subvar receiver-alt ALT 77 | subvar results RESULTS 78 | subvar log-file LOGFILE 79 | subvar extra-args EXTRA_ARGS 80 | 81 | cp -a -f $CONFIGFILE $CONFIGFILE.tmp 82 | sed -f $SEDSCRIPT < $CONFIGFILE > $CONFIGFILE.tmp 83 | mv -f $CONFIGFILE.tmp $CONFIGFILE 84 | rm $SEDSCRIPT 85 | 86 | db_get $NAME/run-as-user 87 | RUNAS="$RET" 88 | if ! getent passwd "$RUNAS" >/dev/null 89 | then 90 | adduser --system --home /usr/share/$NAME --no-create-home --quiet "$RUNAS" 91 | fi 92 | 93 | ;; 94 | 95 | abort-upgrade|abort-remove|abort-deconfigure) 96 | ;; 97 | 98 | *) 99 | echo "postinst called with unknown argument \`$1'" >&2 100 | exit 1 101 | ;; 102 | esac 103 | 104 | # dh_installdeb will replace this with shell code automatically 105 | # generated by other debhelper scripts. 106 | 107 | #DEBHELPER# 108 | 109 | if [ "$1" = "configure" ]; then db_stop; fi 110 | exit 0 111 | -------------------------------------------------------------------------------- /debian/mlat-client.postrm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # postrm script for #PACKAGE# 3 | # 4 | # see: dh_installdeb(1) 5 | 6 | set -e 7 | 8 | # summary of how this script can be called: 9 | # * `remove' 10 | # * `purge' 11 | # * `upgrade' 12 | # * `failed-upgrade' 13 | # * `abort-install' 14 | # * `abort-install' 15 | # * `abort-upgrade' 16 | # * `disappear' 17 | # 18 | # for details, see http://www.debian.org/doc/debian-policy/ or 19 | # the debian-policy package 20 | 21 | 22 | case "$1" in 23 | purge) 24 | rm -f /etc/default/mlat-client 25 | ;; 26 | 27 | remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear) 28 | ;; 29 | 30 | *) 31 | echo "postrm called with unknown argument \`$1'" >&2 32 | exit 1 33 | ;; 34 | esac 35 | 36 | # dh_installdeb will replace this with shell code automatically 37 | # generated by other debhelper scripts. 38 | 39 | #DEBHELPER# 40 | 41 | exit 0 42 | -------------------------------------------------------------------------------- /debian/mlat-client.templates: -------------------------------------------------------------------------------- 1 | Template: mlat-client/start-client 2 | Description: Start the standalone multilateration client? 3 | The multilateration client can be started automatically in standalone mode 4 | based on the answers to these configuration questions. 5 | . 6 | Enable this if you are connecting to an independent mlat server and you 7 | have configuration details for that server. 8 | . 9 | Disable this if you are providing multilateration data to Flightaware 10 | via piaware, or if you will start mlat-client via some other mechanism. 11 | Type: boolean 12 | Default: false 13 | 14 | Template: mlat-client/run-as-user 15 | Description: User to run mlat-client as: 16 | When started automatically, mlat-client runs as an unprivileged system user. 17 | This user will be created if it does not yet exist. 18 | Type: string 19 | Default: mlat 20 | 21 | Template: mlat-client/server-hostport 22 | Description: Multilateration server (host:port) to connect to: 23 | The multilateration client connects to a server that processes the 24 | collected data. This setting controls which server the client will 25 | connect to. The administrator of the server will be able to provide 26 | this information. 27 | Type: string 28 | Default: mlat.mutability.co.uk:40147 29 | 30 | Template: mlat-client/server-user 31 | Description: User to log into the server as: 32 | The multilateration client identifies itself to the server via a short 33 | username. This can be anything you like, it's just used to identify who is 34 | connecting. Each receiver should have a different value. 35 | Type: string 36 | Default: 37 | 38 | Template: mlat-client/log-file 39 | Description: Path to log to: 40 | When started automatically, mlat-client will log its output somewhere. This 41 | log mostly contains not very interesting connection info. 42 | Type: string 43 | Default: /var/log/mlat-client.log 44 | 45 | Template: mlat-client/receiver-lat 46 | Description: Latitude of receiver, in decimal degrees: 47 | The multilateration server must know the location of the receiver. This should 48 | be the position of the antenna, ideally to within about 25m. A four-decimal- 49 | place GPS location is fine. 50 | Type: string 51 | Default: 52 | 53 | Template: mlat-client/receiver-lon 54 | Description: Longitude of receiver, in decimal degrees: 55 | The multilateration server must know the location of the receiver. This should 56 | be the position of the antenna, ideally to within about 25m. A four-decimal- 57 | place GPS location is fine. 58 | Type: string 59 | Default: 60 | 61 | Template: mlat-client/receiver-alt 62 | Description: Altitude of receiver, in metres (height above ellipsoid): 63 | The multilateration server must know the location of the receiver. This should 64 | be the position of the antenna, ideally to within about 25m. A GPS-derived 65 | altitude is fine; use the WGS84 uncorrected altitude, not the corrected AMSL 66 | altitude. This value should be in metres. 67 | Type: string 68 | Default: 69 | 70 | Template: mlat-client/input-type 71 | Description: Receiver type: 72 | This setting sets the type of receiver that Mode S messages will 73 | be read from. 74 | . 75 | dump1090: 76 | dump1090 (MalcolmRobb or mutability fork), or anything else 77 | that can generate Beast-format messages with a 12MHz clock. 78 | beast: 79 | The Mode-S Beast. 80 | radarcape_gps: 81 | Radarcape with GPS timestamps (preferred). 82 | radarcape_12mhz: 83 | Radarcape with legacy 12MHz timestamps. 84 | sbs: 85 | Kinetic Avionics SBS-1/SBS-3 systems. 86 | Type: select 87 | Choices: dump1090, beast, radarcape_gps, radarcape_12mhz, sbs 88 | Default: dump1090 89 | 90 | Template: mlat-client/input-hostport 91 | Description: Input host:port for Mode S traffic: 92 | The multilateration client needs to read Mode S messages from a 93 | receiver such as dump1090. Here you can configure the host and port that the 94 | receiver is running on. 95 | . 96 | For dump1090, you need Beast-format output. This is usually available on port 30005. 97 | For Radarcapes, use port 10002. 98 | For SBS, use port 30006. 99 | Type: string 100 | Default: localhost:30005 101 | 102 | Template: mlat-client/results 103 | Description: List of result connections/listeners: 104 | The multilateration client can return the calculated aircraft positions in 105 | various forms. This settings accepts a list of connections or listeners to 106 | establish. See the mlat-client --help option for the exact format. 107 | . 108 | A value of "none" disables the output of results. 109 | Type: string 110 | Default: basestation,listen,31003 111 | 112 | Template: mlat-client/extra-args 113 | Description: Extra arguments to pass to mlat-client: 114 | Here you can add any extra arguments you want to pass to mlat-client. 115 | Type: string 116 | Default: 117 | 118 | Template: mlat-client/invalid-is_not_empty 119 | Description: Value cannot be empty. 120 | Type: error 121 | 122 | Template: mlat-client/invalid-is_port_number 123 | Description: Value must be a valid port number (1024-65535), or zero to disable. 124 | Type: error 125 | 126 | Template: mlat-client/invalid-is_number 127 | Description: Value must be a decimal number 128 | Type: error 129 | 130 | Template: mlat-client/invalid-is_non_root_user 131 | Description: Value must be a username (without spaces) that isn't root. 132 | Type: error 133 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # -*- makefile -*- 3 | 4 | 5 | # We use pybuild now. 6 | export DH_VERBOSE=1 7 | export PYBUILD_NAME=foo 8 | 9 | %: 10 | dh $@ --with python3 --buildsystem=pybuild 11 | -------------------------------------------------------------------------------- /flightaware/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mutability/mlat-client/fe70767be859100176983b948140046b6ecdd34a/flightaware/__init__.py -------------------------------------------------------------------------------- /flightaware/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mutability/mlat-client/fe70767be859100176983b948140046b6ecdd34a/flightaware/client/__init__.py -------------------------------------------------------------------------------- /flightaware/client/adeptclient.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python; indent-tabs-mode: nil -*- 2 | 3 | """ 4 | The FlightAware adept protocol, client side. 5 | """ 6 | 7 | import asyncore 8 | import socket 9 | import errno 10 | import sys 11 | import itertools 12 | import struct 13 | 14 | from mlat.client import net, util, stats, version 15 | 16 | # UDP protocol submessages 17 | # TODO: This needs merging with mlat-client's variant 18 | # (they are not quite identical so it'll need a new 19 | # udp protocol version - this version has the decoded 20 | # ICAO address at the start of MLAT/SYNC to ease the 21 | # work of the server doing fan-out) 22 | 23 | TYPE_SYNC = 1 24 | TYPE_MLAT_SHORT = 2 25 | TYPE_MLAT_LONG = 3 26 | # TYPE_SSYNC = 4 27 | TYPE_REBASE = 5 28 | TYPE_ABS_SYNC = 6 29 | TYPE_MLAT_MODEAC = 7 30 | 31 | STRUCT_HEADER = struct.Struct(">IHQ") 32 | STRUCT_SYNC = struct.Struct(">B3Bii14s14s") 33 | # STRUCT_SSYNC = struct.Struct(">Bi14s") 34 | STRUCT_MLAT_SHORT = struct.Struct(">B3Bi7s") 35 | STRUCT_MLAT_LONG = struct.Struct(">B3Bi14s") 36 | STRUCT_REBASE = struct.Struct(">BQ") 37 | STRUCT_ABS_SYNC = struct.Struct(">B3BQQ14s14s") 38 | STRUCT_MLAT_MODEAC = struct.Struct(">Bi2s") 39 | 40 | 41 | if sys.platform == 'linux': 42 | IP_MTU = 14 # not defined in the socket module, unfortunately 43 | 44 | def get_mtu(s): 45 | try: 46 | return s.getsockopt(socket.SOL_IP, IP_MTU) 47 | except OSError: 48 | return None 49 | except socket.error: 50 | return None 51 | else: 52 | def get_mtu(s): 53 | return None 54 | 55 | 56 | class UdpServerConnection: 57 | def __init__(self, host, port, key): 58 | self.host = host 59 | self.port = port 60 | self.key = key 61 | 62 | self.base_timestamp = None 63 | self.header_timestamp = None 64 | self.buf = bytearray(1500) 65 | self.used = 0 66 | self.seq = 0 67 | self.count = 0 68 | self.sock = None 69 | self.mtu = 1400 70 | self.route_mtu = -1 71 | 72 | def start(self): 73 | addrlist = socket.getaddrinfo(host=self.host, 74 | port=self.port, 75 | family=socket.AF_UNSPEC, 76 | type=socket.SOCK_DGRAM, 77 | proto=0, 78 | flags=socket.AI_NUMERICHOST) 79 | 80 | if len(addrlist) != 1: 81 | # expect exactly one result since we specify AI_NUMERICHOST 82 | raise IOError('unexpectedly got {0} results when resolving {1}'.format(len(addrlist), self.host)) 83 | a_family, a_type, a_proto, a_canonname, a_sockaddr = addrlist[0] 84 | self.sock = socket.socket(a_family, a_type, a_proto) 85 | self.remote_address = a_sockaddr 86 | self.refresh_socket() 87 | 88 | def refresh_socket(self): 89 | try: 90 | self.sock.connect(self.remote_address) 91 | except OSError: 92 | pass 93 | except socket.error: 94 | pass 95 | 96 | new_mtu = get_mtu(self.sock) 97 | if new_mtu is not None and new_mtu != self.route_mtu: 98 | util.log('Route MTU changed to {0}', new_mtu) 99 | self.route_mtu = new_mtu 100 | self.mtu = max(100, self.route_mtu - 100) 101 | 102 | def prepare_header(self, timestamp): 103 | self.base_timestamp = timestamp 104 | STRUCT_HEADER.pack_into(self.buf, 0, 105 | self.key, self.seq, self.base_timestamp) 106 | self.used += STRUCT_HEADER.size 107 | 108 | def rebase(self, timestamp): 109 | self.base_timestamp = timestamp 110 | STRUCT_REBASE.pack_into(self.buf, self.used, 111 | TYPE_REBASE, 112 | self.base_timestamp) 113 | self.used += STRUCT_REBASE.size 114 | 115 | def send_mlat(self, message): 116 | if not self.used: 117 | self.prepare_header(message.timestamp) 118 | 119 | delta = message.timestamp - self.base_timestamp 120 | if abs(delta) > 0x7FFFFFF0: 121 | self.rebase(message.timestamp) 122 | delta = 0 123 | 124 | if len(message) == 2: 125 | STRUCT_MLAT_MODEAC.pack_into(self.buf, self.used, 126 | TYPE_MLAT_MODEAC, 127 | delta, bytes(message)) 128 | self.used += STRUCT_MLAT_MODEAC.size 129 | elif len(message) == 7: 130 | STRUCT_MLAT_SHORT.pack_into(self.buf, self.used, 131 | TYPE_MLAT_SHORT, 132 | message.address >> 16, 133 | (message.address >> 8) & 255, 134 | message.address & 255, 135 | delta, bytes(message)) 136 | self.used += STRUCT_MLAT_SHORT.size 137 | 138 | elif len(message) == 14: 139 | STRUCT_MLAT_LONG.pack_into(self.buf, self.used, 140 | TYPE_MLAT_LONG, 141 | message.address >> 16, 142 | (message.address >> 8) & 255, 143 | message.address & 255, 144 | delta, bytes(message)) 145 | self.used += STRUCT_MLAT_LONG.size 146 | 147 | if self.used > self.mtu: 148 | self.flush() 149 | 150 | def send_sync(self, em, om): 151 | if not self.used: 152 | self.prepare_header(int((em.timestamp + om.timestamp) / 2)) 153 | 154 | if abs(em.timestamp - om.timestamp) > 0xFFFFFFF0: 155 | # use abs sync 156 | STRUCT_ABS_SYNC.pack_into(self.buf, self.used, 157 | TYPE_ABS_SYNC, 158 | em.address >> 16, 159 | (em.address >> 8) & 255, 160 | em.address & 255, 161 | em.timestamp, om.timestamp, bytes(em), bytes(om)) 162 | self.used += STRUCT_ABS_SYNC.size 163 | else: 164 | edelta = em.timestamp - self.base_timestamp 165 | odelta = om.timestamp - self.base_timestamp 166 | if abs(edelta) > 0x7FFFFFF0 or abs(odelta) > 0x7FFFFFF0: 167 | self.rebase(int((em.timestamp + om.timestamp) / 2)) 168 | edelta = em.timestamp - self.base_timestamp 169 | odelta = om.timestamp - self.base_timestamp 170 | 171 | STRUCT_SYNC.pack_into(self.buf, self.used, 172 | TYPE_SYNC, 173 | em.address >> 16, 174 | (em.address >> 8) & 255, 175 | em.address & 255, 176 | edelta, odelta, bytes(em), bytes(om)) 177 | self.used += STRUCT_SYNC.size 178 | 179 | if self.used > self.mtu: 180 | self.flush() 181 | 182 | def flush(self): 183 | if not self.used: 184 | return 185 | 186 | try: 187 | self.sock.send(memoryview(self.buf)[0:self.used]) 188 | except socket.error: 189 | pass 190 | 191 | stats.global_stats.server_udp_bytes += self.used 192 | 193 | self.used = 0 194 | self.base_timestamp = None 195 | self.seq = (self.seq + 1) & 0xffff 196 | self.count += 1 197 | 198 | if self.count % 50 == 0: 199 | self.refresh_socket() 200 | 201 | def close(self): 202 | self.used = 0 203 | if self.sock: 204 | self.sock.close() 205 | 206 | def __str__(self): 207 | return '{0}:{1}'.format(self.host, self.port) 208 | 209 | 210 | class AdeptReader(asyncore.file_dispatcher, net.LoggingMixin): 211 | """Reads tab-separated key-value messages from stdin and dispatches them.""" 212 | 213 | def __init__(self, connection, coordinator): 214 | super().__init__(sys.stdin) 215 | 216 | self.connection = connection 217 | self.coordinator = coordinator 218 | self.partial_line = b'' 219 | self.closed = False 220 | 221 | self.handlers = { 222 | 'mlat_wanted': self.process_wanted_message, 223 | 'mlat_unwanted': self.process_unwanted_message, 224 | 'mlat_result': self.process_result_message, 225 | 'mlat_status': self.process_status_message 226 | } 227 | 228 | def readable(self): 229 | return True 230 | 231 | def writable(self): 232 | return False 233 | 234 | def handle_read(self): 235 | try: 236 | moredata = self.recv(16384) 237 | except socket.error as e: 238 | if e.errno == errno.EAGAIN: 239 | return 240 | raise 241 | 242 | if not moredata: 243 | self.close() 244 | return 245 | 246 | stats.global_stats.server_rx_bytes += len(moredata) 247 | 248 | data = self.partial_line + moredata 249 | lines = data.split(b'\n') 250 | for line in lines[:-1]: 251 | try: 252 | self.process_line(line.decode('ascii')) 253 | except IOError: 254 | raise 255 | except Exception: 256 | util.log_exc('Unexpected exception processing adept message') 257 | 258 | self.partial_line = lines[-1] 259 | 260 | def handle_close(self): 261 | self.close() 262 | 263 | def close(self): 264 | if not self.closed: 265 | self.closed = True 266 | super().close() 267 | self.connection.disconnect() 268 | 269 | def process_line(self, line): 270 | fields = line.split('\t') 271 | message = dict(zip(fields[0::2], fields[1::2])) 272 | 273 | handler = self.handlers.get(message['type']) 274 | if handler: 275 | handler(message) 276 | 277 | def parse_hexid_list(self, s): 278 | icao = set() 279 | modeac = set() 280 | if s != '': 281 | for x in s.split(' '): 282 | if x[0] == '@': 283 | modeac.add(int(x[1:], 16)) 284 | else: 285 | icao.add(int(x, 16)) 286 | return icao, modeac 287 | 288 | def process_wanted_message(self, message): 289 | wanted_icao, wanted_modeac = self.parse_hexid_list(message['hexids']) 290 | self.coordinator.server_start_sending(wanted_icao, wanted_modeac) 291 | 292 | def process_unwanted_message(self, message): 293 | unwanted_icao, unwanted_modeac = self.parse_hexid_list(message['hexids']) 294 | self.coordinator.server_stop_sending(unwanted_icao, unwanted_modeac) 295 | 296 | def process_result_message(self, message): 297 | self.coordinator.server_mlat_result(timestamp=None, 298 | addr=int(message['hexid'], 16), 299 | lat=float(message['lat']), 300 | lon=float(message['lon']), 301 | alt=float(message['alt']), 302 | nsvel=float(message['nsvel']), 303 | ewvel=float(message['ewvel']), 304 | vrate=float(message['fpm']), 305 | callsign=None, 306 | squawk=None, 307 | error_est=None, 308 | nstations=None, 309 | anon=bool(message.get('anon', 0)), 310 | modeac=bool(message.get('modeac', 0))) 311 | 312 | def process_status_message(self, message): 313 | s = message.get('status', 'unknown') 314 | r = int(message.get('receiver_sync_count', 0)) 315 | 316 | if s == 'ok': 317 | self.connection.state = "synchronized with {} nearby receivers".format(r) 318 | elif s == 'unstable': 319 | self.connection.state = "clock unstable" 320 | elif s == 'no_sync': 321 | self.connection.state = "not synchronized with any nearby receivers" 322 | else: 323 | self.connection.state = "{} {}".format(s, r) 324 | 325 | 326 | class AdeptWriter(asyncore.file_dispatcher, net.LoggingMixin): 327 | """Writes tab-separated key-value messages to stdout.""" 328 | 329 | def __init__(self, connection): 330 | super().__init__(sys.stdout) 331 | self.connection = connection 332 | self.writebuf = bytearray() 333 | self.closed = False 334 | self.last_position = None 335 | 336 | def readable(self): 337 | return False 338 | 339 | def writable(self): 340 | return len(self.writebuf) > 0 341 | 342 | def handle_write(self): 343 | if self.writebuf: 344 | sent = self.send(self.writebuf) 345 | del self.writebuf[:sent] 346 | stats.global_stats.server_tx_bytes += sent 347 | if len(self.writebuf) > 65536: 348 | raise IOError('Server write buffer overflow (too much unsent data)') 349 | 350 | def handle_close(self): 351 | self.close() 352 | 353 | def close(self): 354 | if not self.closed: 355 | self.closed = True 356 | super().close() 357 | self.connection.disconnect() 358 | 359 | def send_message(self, **kwargs): 360 | line = '\t'.join(itertools.chain.from_iterable(kwargs.items())) + '\n' 361 | self.writebuf += line.encode('ascii') 362 | 363 | def send_seen(self, aclist): 364 | self.send_message(type='mlat_seen', 365 | hexids=' '.join('{0:06X}'.format(icao) for icao in aclist)) 366 | 367 | def send_lost(self, aclist): 368 | self.send_message(type='mlat_lost', 369 | hexids=' '.join('{0:06X}'.format(icao) for icao in aclist)) 370 | 371 | def send_rate_report(self, report): 372 | self.send_message(type='mlat_rates', 373 | rates=' '.join('{0:06X} {1:.2f}'.format(icao, rate) for icao, rate in report.items())) 374 | 375 | def send_ready(self, allow_anon, allow_modeac): 376 | capabilities = [] 377 | if allow_anon: 378 | capabilities.append('anon') 379 | if allow_modeac: 380 | capabilities.append('modeac') 381 | self.send_message(type='mlat_event', event='ready', mlat_client_version=version.CLIENT_VERSION, 382 | capabilities=' '.join(capabilities)) 383 | 384 | def send_input_connected(self): 385 | self.send_message(type='mlat_event', event='connected') 386 | 387 | def send_input_disconnected(self): 388 | self.send_message(type='mlat_event', event='disconnected') 389 | 390 | def send_clock_reset(self, reason, frequency=None, epoch=None, mode=None): 391 | message = { 392 | 'type': 'mlat_event', 393 | 'event': 'clock_reset', 394 | 'reason': reason 395 | } 396 | 397 | if frequency is not None: 398 | message['frequency'] = str(frequency) 399 | message['epoch'] = 'none' if epoch is None else epoch 400 | message['mode'] = mode 401 | 402 | self.send_message(**message) 403 | 404 | def send_position_update(self, lat, lon, alt, altref): 405 | new_pos = (lat, lon, alt, altref) 406 | if self.last_position is None or self.last_position != new_pos: 407 | self.send_message(type='mlat_location_update', 408 | lat='{0:.5f}'.format(lat), 409 | lon='{0:.5f}'.format(lon), 410 | alt='{0:.0f}'.format(alt), 411 | altref=altref) 412 | self.last_position = new_pos 413 | 414 | def send_udp_report(self, count): 415 | self.send_message(type='mlat_udp_report', messages_sent=str(count)) 416 | 417 | 418 | class AdeptConnection: 419 | UDP_REPORT_INTERVAL = 60.0 420 | 421 | def __init__(self, udp_transport=None, allow_anon=True, allow_modeac=True): 422 | if udp_transport is None: 423 | raise NotImplementedError('non-UDP transport not supported') 424 | 425 | self.reader = None 426 | self.writer = None 427 | self.coordinator = None 428 | self.closed = False 429 | self.udp_transport = udp_transport 430 | self.allow_anon = allow_anon 431 | self.allow_modeac = allow_modeac 432 | self.state = 'init' 433 | 434 | def start(self, coordinator): 435 | self.coordinator = coordinator 436 | 437 | self.reader = AdeptReader(self, coordinator) 438 | self.writer = AdeptWriter(self) 439 | 440 | self.udp_transport.start() 441 | self.send_mlat = self.udp_transport.send_mlat 442 | self.send_sync = self.udp_transport.send_sync 443 | self.send_split_sync = None 444 | self.send_seen = self.writer.send_seen 445 | self.send_lost = self.writer.send_lost 446 | self.send_rate_report = self.writer.send_rate_report 447 | self.send_clock_reset = self.writer.send_clock_reset 448 | self.send_input_connected = self.writer.send_input_connected 449 | self.send_input_disconnected = self.writer.send_input_disconnected 450 | self.send_position_update = self.writer.send_position_update 451 | 452 | self.state = 'connected' 453 | self.writer.send_ready(allow_anon=self.allow_anon, allow_modeac=self.allow_modeac) 454 | self.next_udp_report = util.monotonic_time() + self.UDP_REPORT_INTERVAL 455 | self.coordinator.server_connected() 456 | 457 | def disconnect(self, why=None): 458 | if not self.closed: 459 | self.closed = True 460 | self.state = 'closed' 461 | if self.reader: 462 | self.reader.close() 463 | if self.writer: 464 | self.writer.close() 465 | if self.udp_transport: 466 | self.udp_transport.close() 467 | if self.coordinator: 468 | self.coordinator.server_disconnected() 469 | 470 | def heartbeat(self, now): 471 | if self.udp_transport: 472 | self.udp_transport.flush() 473 | 474 | if now > self.next_udp_report: 475 | self.next_udp_report = now + self.UDP_REPORT_INTERVAL 476 | self.writer.send_udp_report(self.udp_transport.count) 477 | -------------------------------------------------------------------------------- /flightaware/client/cli.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python; indent-tabs-mode: nil -*- 2 | 3 | # FlightAware multilateration client 4 | 5 | import argparse 6 | 7 | import mlat.client.version 8 | from flightaware.client.adeptclient import AdeptConnection, UdpServerConnection 9 | from mlat.client.coordinator import Coordinator 10 | from mlat.client.util import log, log_exc 11 | from mlat.client import options 12 | 13 | 14 | def _main(): 15 | # piaware will timestamp our log messages itself, suppress the normal logging timestamps 16 | mlat.client.util.suppress_log_timestamps = True 17 | 18 | parser = argparse.ArgumentParser(description="Client for multilateration.") 19 | 20 | options.make_inputs_group(parser) 21 | options.make_results_group(parser) 22 | 23 | parser.add_argument('--udp-transport', 24 | help="Provide UDP transport information. Expects an IP:port:key argument.", 25 | required=True) 26 | 27 | args = parser.parse_args() 28 | 29 | log("fa-mlat-client {version} starting up", version=mlat.client.version.CLIENT_VERSION) 30 | 31 | # udp_transport is IP:port:key 32 | # split backwards to handle IPv6 addresses in the host part, which themselves contain colons. 33 | parts = args.udp_transport.split(':') 34 | udp_key = int(parts[-1]) 35 | udp_port = int(parts[-2]) 36 | udp_host = ':'.join(parts[:-2]) 37 | udp_transport = UdpServerConnection(udp_host, udp_port, udp_key) 38 | log("Using UDP transport to {host} port {port}", host=udp_host, port=udp_port) 39 | 40 | receiver = options.build_receiver_connection(args) 41 | adept = AdeptConnection(udp_transport, allow_anon=args.allow_anon_results, allow_modeac=args.allow_modeac_results) 42 | outputs = options.build_outputs(args) 43 | 44 | coordinator = Coordinator(receiver=receiver, server=adept, outputs=outputs, freq=options.clock_frequency(args), 45 | allow_anon=args.allow_anon_results, allow_modeac=args.allow_modeac_results) 46 | adept.start(coordinator) 47 | coordinator.run_until(lambda: adept.closed) 48 | 49 | 50 | def main(): 51 | try: 52 | _main() 53 | except KeyboardInterrupt: 54 | log("Exiting on SIGINT") 55 | except Exception: 56 | log_exc("Exiting on exception") 57 | else: 58 | log("Exiting on connection loss") 59 | 60 | if __name__ == '__main__': 61 | main() 62 | -------------------------------------------------------------------------------- /mlat/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mutability/mlat-client/fe70767be859100176983b948140046b6ecdd34a/mlat/__init__.py -------------------------------------------------------------------------------- /mlat/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mutability/mlat-client/fe70767be859100176983b948140046b6ecdd34a/mlat/client/__init__.py -------------------------------------------------------------------------------- /mlat/client/cli.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python; indent-tabs-mode: nil -*- 2 | 3 | # Part of mlat-client - an ADS-B multilateration client. 4 | # Copyright 2015, Oliver Jowett 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | import argparse 20 | 21 | import mlat.client.version 22 | 23 | from mlat.client.util import log 24 | from mlat.client.receiver import ReceiverConnection 25 | from mlat.client.jsonclient import JsonServerConnection 26 | from mlat.client.coordinator import Coordinator 27 | from mlat.client import options 28 | 29 | 30 | def main(): 31 | parser = argparse.ArgumentParser(description="Client for multilateration.") 32 | 33 | options.make_inputs_group(parser) 34 | options.make_results_group(parser) 35 | 36 | location = parser.add_argument_group('Receiver location') 37 | location.add_argument('--lat', 38 | type=options.latitude, 39 | help="Latitude of the receiver, in decimal degrees. Required.", 40 | required=True) 41 | location.add_argument('--lon', 42 | type=options.longitude, 43 | help="Longitude of the receiver, in decimal degrees. Required.", 44 | required=True) 45 | location.add_argument('--alt', 46 | type=options.altitude, 47 | help=""" 48 | Altitude of the receiver (height above ellipsoid). Required. Defaults to metres, but units may 49 | specified with a 'ft' or 'm' suffix. (Except if they're negative due to option 50 | parser weirdness. Sorry!)""", 51 | required=True) 52 | location.add_argument('--privacy', 53 | help=""" 54 | Sets the privacy flag for this receiver. Currently, this removes the receiver 55 | location pin from the coverage maps.""", 56 | action='store_true', 57 | default=False) 58 | 59 | server = parser.add_argument_group('Multilateration server connection') 60 | server.add_argument('--user', 61 | help="User information to give to the server. Used to get in touch if there are problems.", 62 | required=True) 63 | server.add_argument('--server', 64 | help="host:port of the multilateration server to connect to", 65 | type=options.hostport, 66 | default=('mlat.mutability.co.uk', 40147)) 67 | server.add_argument('--no-udp', 68 | dest='udp', 69 | help="Don't offer to use UDP transport for sync/mlat messages", 70 | action='store_false', 71 | default=True) 72 | 73 | args = parser.parse_args() 74 | 75 | log("mlat-client {version} starting up", version=mlat.client.version.CLIENT_VERSION) 76 | 77 | outputs = options.build_outputs(args) 78 | 79 | receiver = ReceiverConnection(host=args.input_connect[0], port=args.input_connect[1], 80 | mode=options.connection_mode(args)) 81 | server = JsonServerConnection(host=args.server[0], port=args.server[1], 82 | handshake_data={'lat': args.lat, 83 | 'lon': args.lon, 84 | 'alt': args.alt, 85 | 'user': args.user, 86 | 'clock_type': options.clock_type(args), 87 | 'clock_frequency': options.clock_frequency(args), 88 | 'clock_epoch': options.clock_epoch(args), 89 | 'privacy': args.privacy}, 90 | offer_zlib=True, 91 | offer_udp=args.udp, 92 | return_results=(len(outputs) > 0)) 93 | 94 | coordinator = Coordinator(receiver=receiver, server=server, outputs=outputs, freq=options.clock_frequency(args), 95 | allow_anon=args.allow_anon_results, allow_modeac=args.allow_modeac_results) 96 | 97 | server.start() 98 | coordinator.run_forever() 99 | 100 | 101 | if __name__ == '__main__': 102 | main() 103 | -------------------------------------------------------------------------------- /mlat/client/coordinator.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python; indent-tabs-mode: nil -*- 2 | 3 | # Part of mlat-client - an ADS-B multilateration client. 4 | # Copyright 2015, Oliver Jowett 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Core of the client: track aircraft and send data to the server as needed. 21 | """ 22 | 23 | import asyncore 24 | import time 25 | 26 | import _modes 27 | import mlat.profile 28 | from mlat.client.util import monotonic_time, log 29 | from mlat.client.stats import global_stats 30 | 31 | 32 | class Aircraft: 33 | """One tracked aircraft.""" 34 | 35 | def __init__(self, icao): 36 | self.icao = icao 37 | self.messages = 0 38 | self.last_message_time = 0 39 | self.last_position_time = 0 40 | self.even_message = None 41 | self.odd_message = None 42 | self.reported = False 43 | self.requested = True 44 | self.measurement_start = None 45 | self.rate_measurement_start = 0 46 | self.recent_adsb_positions = 0 47 | 48 | 49 | class Coordinator: 50 | update_interval = 5.0 51 | report_interval = 30.0 52 | stats_interval = 900.0 53 | position_expiry_age = 30.0 54 | expiry_age = 60.0 55 | 56 | def __init__(self, receiver, server, outputs, freq, allow_anon, allow_modeac): 57 | self.receiver = receiver 58 | self.server = server 59 | self.outputs = outputs 60 | self.freq = freq 61 | self.allow_anon = allow_anon 62 | self.allow_modeac = allow_modeac 63 | 64 | self.aircraft = {} 65 | self.requested_traffic = set() 66 | self.requested_modeac = set() 67 | self.reported = set() 68 | self.df_handlers = { 69 | _modes.DF_EVENT_MODE_CHANGE: self.received_mode_change_event, 70 | _modes.DF_EVENT_EPOCH_ROLLOVER: self.received_epoch_rollover_event, 71 | _modes.DF_EVENT_TIMESTAMP_JUMP: self.received_timestamp_jump_event, 72 | _modes.DF_EVENT_RADARCAPE_POSITION: self.received_radarcape_position_event, 73 | 0: self.received_df_misc, 74 | 4: self.received_df_misc, 75 | 5: self.received_df_misc, 76 | 16: self.received_df_misc, 77 | 20: self.received_df_misc, 78 | 21: self.received_df_misc, 79 | 11: self.received_df11, 80 | 17: self.received_df17, 81 | _modes.DF_MODEAC: self.received_modeac 82 | } 83 | self.next_report = None 84 | self.next_stats = monotonic_time() + self.stats_interval 85 | self.next_profile = monotonic_time() 86 | self.next_aircraft_update = self.last_aircraft_update = monotonic_time() 87 | self.recent_jumps = 0 88 | 89 | receiver.coordinator = self 90 | server.coordinator = self 91 | 92 | # internals 93 | 94 | def run_forever(self): 95 | self.run_until(lambda: False) 96 | 97 | def run_until(self, termination_condition): 98 | try: 99 | next_heartbeat = monotonic_time() + 0.5 100 | while not termination_condition(): 101 | # maybe there are no active sockets and 102 | # we're just waiting on a timeout 103 | if asyncore.socket_map: 104 | asyncore.loop(timeout=0.1, count=5) 105 | else: 106 | time.sleep(0.5) 107 | 108 | now = monotonic_time() 109 | if now >= next_heartbeat: 110 | next_heartbeat = now + 0.5 111 | self.heartbeat(now) 112 | 113 | finally: 114 | self.receiver.disconnect('Client shutting down') 115 | self.server.disconnect('Client shutting down') 116 | for o in self.outputs: 117 | o.disconnect('Client shutting down') 118 | 119 | def heartbeat(self, now): 120 | self.receiver.heartbeat(now) 121 | self.server.heartbeat(now) 122 | for o in self.outputs: 123 | o.heartbeat(now) 124 | 125 | if now >= self.next_profile: 126 | self.next_profile = now + 30.0 127 | mlat.profile.dump_cpu_profiles() 128 | 129 | if now >= self.next_aircraft_update: 130 | self.next_aircraft_update = now + self.update_interval 131 | self.update_aircraft(now) 132 | 133 | # piggyback reporting on regular updates 134 | # as the reporting uses data produced by the update 135 | if self.next_report and now >= self.next_report: 136 | self.next_report = now + self.report_interval 137 | self.send_aircraft_report() 138 | self.send_rate_report(now) 139 | 140 | if now >= self.next_stats: 141 | self.next_stats = now + self.stats_interval 142 | self.periodic_stats(now) 143 | 144 | def update_aircraft(self, now): 145 | # process aircraft the receiver has seen 146 | # (we have not necessarily seen any messages, 147 | # due to the receiver filter) 148 | for icao in self.receiver.recent_aircraft(): 149 | ac = self.aircraft.get(icao) 150 | if not ac: 151 | ac = Aircraft(icao) 152 | ac.requested = (icao in self.requested_traffic) 153 | ac.rate_measurement_start = now 154 | self.aircraft[icao] = ac 155 | 156 | if ac.last_message_time <= self.last_aircraft_update: 157 | # fudge it a bit, receiver has seen messages 158 | # but they were all filtered 159 | ac.messages += 1 160 | ac.last_message_time = now 161 | 162 | # expire aircraft we have not seen for a while 163 | for ac in list(self.aircraft.values()): 164 | if (now - ac.last_message_time) > self.expiry_age: 165 | del self.aircraft[ac.icao] 166 | 167 | self.last_aircraft_update = now 168 | 169 | def send_aircraft_report(self): 170 | all_aircraft = {x.icao for x in self.aircraft.values() if x.messages > 1} 171 | seen_ac = all_aircraft.difference(self.reported) 172 | lost_ac = self.reported.difference(all_aircraft) 173 | 174 | if seen_ac: 175 | self.server.send_seen(seen_ac) 176 | if lost_ac: 177 | self.server.send_lost(lost_ac) 178 | 179 | self.reported = all_aircraft 180 | 181 | def send_rate_report(self, now): 182 | # report ADS-B position rate stats 183 | rate_report = {} 184 | for ac in self.aircraft.values(): 185 | interval = now - ac.rate_measurement_start 186 | if interval > 0 and ac.recent_adsb_positions > 0: 187 | rate = 1.0 * ac.recent_adsb_positions / interval 188 | ac.rate_measurement_start = now 189 | ac.recent_adsb_positions = 0 190 | rate_report[ac.icao] = rate 191 | 192 | if rate_report: 193 | self.server.send_rate_report(rate_report) 194 | 195 | def periodic_stats(self, now): 196 | log('Receiver status: {0}', self.receiver.state) 197 | log('Server status: {0}', self.server.state) 198 | global_stats.log_and_reset() 199 | 200 | adsb_req = adsb_total = modes_req = modes_total = 0 201 | now = monotonic_time() 202 | for ac in self.aircraft.values(): 203 | if ac.messages < 2: 204 | continue 205 | 206 | if now - ac.last_position_time < self.position_expiry_age: 207 | adsb_total += 1 208 | if ac.requested: 209 | adsb_req += 1 210 | else: 211 | modes_total += 1 212 | if ac.requested: 213 | modes_req += 1 214 | 215 | log('Aircraft: {modes_req} of {modes_total} Mode S, {adsb_req} of {adsb_total} ADS-B used', 216 | modes_req=modes_req, 217 | modes_total=modes_total, 218 | adsb_req=adsb_req, 219 | adsb_total=adsb_total) 220 | 221 | if self.recent_jumps > 0: 222 | log('Out-of-order timestamps: {recent}', recent=self.recent_jumps) 223 | self.recent_jumps = 0 224 | 225 | # callbacks from server connection 226 | 227 | def server_connected(self): 228 | self.requested_traffic = set() 229 | self.requested_modeac = set() 230 | self.newly_seen = set() 231 | self.aircraft = {} 232 | self.reported = set() 233 | self.next_report = monotonic_time() + self.report_interval 234 | if self.receiver.state != 'ready': 235 | self.receiver.reconnect() 236 | 237 | def server_disconnected(self): 238 | self.receiver.disconnect('Lost connection to multilateration server, no need for input data') 239 | self.next_report = None 240 | self.next_rate_report = None 241 | self.next_expiry = None 242 | 243 | def server_mlat_result(self, timestamp, addr, lat, lon, alt, nsvel, ewvel, vrate, 244 | callsign, squawk, error_est, nstations, anon, modeac): 245 | global_stats.mlat_positions += 1 246 | 247 | if anon and not self.allow_anon: 248 | return 249 | 250 | if modeac and not self.allow_modeac: 251 | return 252 | 253 | for o in self.outputs: 254 | o.send_position(timestamp, addr, lat, lon, alt, nsvel, ewvel, vrate, 255 | callsign, squawk, error_est, nstations, anon, modeac) 256 | 257 | def server_start_sending(self, icao_set, modeac_set=set()): 258 | for icao in icao_set: 259 | ac = self.aircraft.get(icao) 260 | if ac: 261 | ac.requested = True 262 | self.requested_traffic.update(icao_set) 263 | if self.allow_modeac: 264 | self.requested_modeac.update(modeac_set) 265 | self.update_receiver_filter() 266 | 267 | def server_stop_sending(self, icao_set, modeac_set=set()): 268 | for icao in icao_set: 269 | ac = self.aircraft.get(icao) 270 | if ac: 271 | ac.requested = False 272 | self.requested_traffic.difference_update(icao_set) 273 | if self.allow_modeac: 274 | self.requested_modeac.difference_update(modeac_set) 275 | self.update_receiver_filter() 276 | 277 | def update_receiver_filter(self): 278 | now = monotonic_time() 279 | 280 | mlat = set() 281 | for icao in self.requested_traffic: 282 | ac = self.aircraft.get(icao) 283 | if not ac or (now - ac.last_position_time > self.position_expiry_age): 284 | # requested, and we have not seen a recent ADS-B message from it 285 | mlat.add(icao) 286 | 287 | self.receiver.update_filter(mlat) 288 | self.receiver.update_modeac_filter(self.requested_modeac) 289 | 290 | # callbacks from receiver input 291 | 292 | def input_connected(self): 293 | self.server.send_input_connected() 294 | 295 | def input_disconnected(self): 296 | self.server.send_input_disconnected() 297 | # expire everything 298 | self.aircraft.clear() 299 | self.server.send_lost(self.reported) 300 | self.reported.clear() 301 | 302 | @mlat.profile.trackcpu 303 | def input_received_messages(self, messages): 304 | now = monotonic_time() 305 | for message in messages: 306 | handler = self.df_handlers.get(message.df) 307 | if handler: 308 | handler(message, now) 309 | 310 | # handlers for input messages 311 | 312 | def received_mode_change_event(self, message, now): 313 | # decoder mode changed, clock parameters possibly changed 314 | self.freq = message.eventdata['frequency'] 315 | self.recent_jumps = 0 316 | self.server.send_clock_reset(reason='Decoder mode changed to {mode}'.format(mode=message.eventdata['mode']), 317 | frequency=message.eventdata['frequency'], 318 | epoch=message.eventdata['epoch'], 319 | mode=message.eventdata['mode']) 320 | log("Input format changed to {mode}, {freq:.0f}MHz clock", 321 | mode=message.eventdata['mode'], 322 | freq=message.eventdata['frequency']/1e6) 323 | 324 | def received_epoch_rollover_event(self, message, now): 325 | # epoch rollover, reset clock 326 | self.server.send_clock_reset('Epoch rollover detected') 327 | 328 | def received_timestamp_jump_event(self, message, now): 329 | self.recent_jumps += 1 330 | if self.recent_jumps == 10: 331 | log("Warning: the timestamps provided by your receiver do not seem to be self-consistent. " 332 | "This can happen if you feed data from multiple receivers to a single mlat-client, which " 333 | "is not supported; use a separate mlat-client for each receiver.") 334 | 335 | def received_radarcape_position_event(self, message, now): 336 | lat, lon = message.eventdata['lat'], message.eventdata['lon'] 337 | if lat >= -90 and lat <= 90 and lon >= -180 and lon <= 180: 338 | self.server.send_position_update(lat, lon, 339 | message.eventdata['alt'], 340 | 'egm96_meters') 341 | 342 | def received_df_misc(self, message, now): 343 | ac = self.aircraft.get(message.address) 344 | if not ac: 345 | return False # not a known ICAO 346 | 347 | ac.messages += 1 348 | ac.last_message_time = now 349 | 350 | if ac.messages < 10: 351 | return # wait for more messages 352 | if not ac.requested: 353 | return 354 | 355 | # Candidate for MLAT 356 | if now - ac.last_position_time < self.position_expiry_age: 357 | return # reported position recently, no need for mlat 358 | self.server.send_mlat(message) 359 | 360 | def received_df11(self, message, now): 361 | ac = self.aircraft.get(message.address) 362 | if not ac: 363 | ac = Aircraft(message.address) 364 | ac.requested = (message.address in self.requested_traffic) 365 | ac.messages += 1 366 | ac.last_message_time = now 367 | ac.rate_measurement_start = now 368 | self.aircraft[message.address] = ac 369 | return # will need some more messages.. 370 | 371 | ac.messages += 1 372 | ac.last_message_time = now 373 | 374 | if ac.messages < 10: 375 | return # wait for more messages 376 | if not ac.requested: 377 | return 378 | 379 | # Candidate for MLAT 380 | if now - ac.last_position_time < self.position_expiry_age: 381 | return # reported position recently, no need for mlat 382 | self.server.send_mlat(message) 383 | 384 | def received_df17(self, message, now): 385 | ac = self.aircraft.get(message.address) 386 | if not ac: 387 | ac = Aircraft(message.address) 388 | ac.requested = (message.address in self.requested_traffic) 389 | ac.messages += 1 390 | ac.last_message_time = now 391 | ac.rate_measurement_start = now 392 | self.aircraft[message.address] = ac 393 | return # wait for more messages 394 | 395 | ac.messages += 1 396 | ac.last_message_time = now 397 | if ac.messages < 10: 398 | return 399 | 400 | if not message.even_cpr and not message.odd_cpr: 401 | # not a position message 402 | return 403 | 404 | ac.last_position_time = now 405 | 406 | if message.altitude is None: 407 | return # need an altitude 408 | if message.nuc < 6: 409 | return # need NUCp >= 6 410 | 411 | ac.recent_adsb_positions += 1 412 | 413 | if self.server.send_split_sync: 414 | if not ac.requested: 415 | return 416 | 417 | # this is a useful reference message 418 | self.server.send_split_sync(message) 419 | else: 420 | if message.even_cpr: 421 | ac.even_message = message 422 | else: 423 | ac.odd_message = message 424 | 425 | if not ac.requested: 426 | return 427 | if not ac.even_message or not ac.odd_message: 428 | return 429 | if abs(ac.even_message.timestamp - ac.odd_message.timestamp) > 5 * self.freq: 430 | return 431 | 432 | # this is a useful reference message pair 433 | self.server.send_sync(ac.even_message, ac.odd_message) 434 | 435 | def received_modeac(self, message, now): 436 | if message.address not in self.requested_modeac: 437 | return 438 | 439 | self.server.send_mlat(message) 440 | -------------------------------------------------------------------------------- /mlat/client/jsonclient.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python; indent-tabs-mode: nil -*- 2 | 3 | # Part of mlat-client - an ADS-B multilateration client. 4 | # Copyright 2015, Oliver Jowett 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | The JSON client/server protocol, client side. 21 | """ 22 | 23 | import math 24 | import time 25 | import struct 26 | import zlib 27 | import socket 28 | import errno 29 | import json 30 | 31 | import mlat.client.version 32 | import mlat.client.net 33 | import mlat.profile 34 | import mlat.geodesy 35 | 36 | from mlat.client.util import log, monotonic_time 37 | from mlat.client.stats import global_stats 38 | 39 | DEBUG = False 40 | 41 | # UDP protocol submessages 42 | 43 | TYPE_SYNC = 1 44 | TYPE_MLAT_SHORT = 2 45 | TYPE_MLAT_LONG = 3 46 | TYPE_SSYNC = 4 47 | TYPE_REBASE = 5 48 | TYPE_ABS_SYNC = 6 49 | 50 | STRUCT_HEADER = struct.Struct(">IHQ") 51 | STRUCT_SYNC = struct.Struct(">Bii14s14s") 52 | STRUCT_SSYNC = struct.Struct(">Bi14s") 53 | STRUCT_MLAT_SHORT = struct.Struct(">Bi7s") 54 | STRUCT_MLAT_LONG = struct.Struct(">Bi14s") 55 | STRUCT_REBASE = struct.Struct(">BQ") 56 | STRUCT_ABS_SYNC = struct.Struct(">BQQ14s14s") 57 | 58 | 59 | class UdpServerConnection: 60 | def __init__(self, host, port, key): 61 | self.host = host 62 | self.port = port 63 | self.key = key 64 | 65 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 66 | self.sock.connect((host, port)) 67 | 68 | self.base_timestamp = None 69 | self.header_timestamp = None 70 | self.buf = bytearray(1500) 71 | self.used = 0 72 | self.seq = 0 73 | 74 | def prepare_header(self, timestamp): 75 | self.base_timestamp = timestamp 76 | STRUCT_HEADER.pack_into(self.buf, 0, 77 | self.key, self.seq, self.base_timestamp) 78 | self.used += STRUCT_HEADER.size 79 | 80 | def rebase(self, timestamp): 81 | self.base_timestamp = timestamp 82 | STRUCT_REBASE.pack_into(self.buf, self.used, 83 | TYPE_REBASE, 84 | self.base_timestamp) 85 | self.used += STRUCT_REBASE.size 86 | 87 | def send_mlat(self, message): 88 | if not self.used: 89 | self.prepare_header(message.timestamp) 90 | 91 | delta = message.timestamp - self.base_timestamp 92 | if abs(delta) > 0x7FFFFFF0: 93 | self.rebase(message.timestamp) 94 | delta = 0 95 | 96 | if len(message) == 7: 97 | STRUCT_MLAT_SHORT.pack_into(self.buf, self.used, 98 | TYPE_MLAT_SHORT, 99 | delta, bytes(message)) 100 | self.used += STRUCT_MLAT_SHORT.size 101 | 102 | else: 103 | STRUCT_MLAT_LONG.pack_into(self.buf, self.used, 104 | TYPE_MLAT_LONG, 105 | delta, bytes(message)) 106 | self.used += STRUCT_MLAT_LONG.size 107 | 108 | if self.used > 1400: 109 | self.flush() 110 | 111 | def send_sync(self, em, om): 112 | if not self.used: 113 | self.prepare_header(int((em.timestamp + om.timestamp) / 2)) 114 | 115 | if abs(em.timestamp - om.timestamp) > 0xFFFFFFF0: 116 | # use abs sync 117 | STRUCT_ABS_SYNC.pack_into(self.buf, self.used, 118 | TYPE_ABS_SYNC, 119 | em.timestamp, om.timestamp, bytes(em), bytes(om)) 120 | self.used += STRUCT_ABS_SYNC.size 121 | else: 122 | edelta = em.timestamp - self.base_timestamp 123 | odelta = om.timestamp - self.base_timestamp 124 | if abs(edelta) > 0x7FFFFFF0 or abs(odelta) > 0x7FFFFFF0: 125 | self.rebase(int((em.timestamp + om.timestamp) / 2)) 126 | edelta = em.timestamp - self.base_timestamp 127 | odelta = om.timestamp - self.base_timestamp 128 | 129 | STRUCT_SYNC.pack_into(self.buf, self.used, 130 | TYPE_SYNC, 131 | edelta, odelta, bytes(em), bytes(om)) 132 | self.used += STRUCT_SYNC.size 133 | 134 | if self.used > 1400: 135 | self.flush() 136 | 137 | def send_split_sync(self, m): 138 | if not self.used: 139 | self.prepare_header(m.timestamp) 140 | 141 | delta = m.timestamp - self.base_timestamp 142 | if abs(delta) > 0x7FFFFFF0: 143 | self.rebase(m.timestamp) 144 | delta = 0 145 | 146 | STRUCT_SSYNC.pack_into(self.buf, self.used, 147 | TYPE_SSYNC, 148 | delta, bytes(m)) 149 | self.used += STRUCT_SSYNC.size 150 | 151 | if self.used > 1400: 152 | self.flush() 153 | 154 | def flush(self): 155 | if not self.used: 156 | return 157 | 158 | try: 159 | self.sock.send(memoryview(self.buf)[0:self.used]) 160 | except socket.error: 161 | pass 162 | 163 | global_stats.server_udp_bytes += self.used 164 | 165 | self.used = 0 166 | self.base_timestamp = None 167 | self.seq = (self.seq + 1) & 0xffff 168 | 169 | def close(self): 170 | self.used = 0 171 | self.sock.close() 172 | 173 | def __str__(self): 174 | return '{0}:{1}'.format(self.host, self.port) 175 | 176 | 177 | class JsonServerConnection(mlat.client.net.ReconnectingConnection): 178 | reconnect_interval = 30.0 179 | heartbeat_interval = 120.0 180 | inactivity_timeout = 60.0 181 | 182 | def __init__(self, host, port, handshake_data, offer_zlib, offer_udp, return_results): 183 | super().__init__(host, port) 184 | self.handshake_data = handshake_data 185 | self.offer_zlib = offer_zlib 186 | self.offer_udp = offer_udp 187 | self.return_results = return_results 188 | self.coordinator = None 189 | self.udp_transport = None 190 | 191 | self.reset_connection() 192 | 193 | def start(self): 194 | self.reconnect() 195 | 196 | def reset_connection(self): 197 | self.readbuf = bytearray() 198 | self.writebuf = bytearray() 199 | self.linebuf = [] 200 | self.fill_writebuf = None 201 | self.handle_server_line = None 202 | self.server_heartbeat_at = None 203 | self.last_data_received = None 204 | 205 | if self.udp_transport: 206 | self.udp_transport.close() 207 | self.udp_transport = None 208 | 209 | def lost_connection(self): 210 | self.coordinator.server_disconnected() 211 | 212 | def readable(self): 213 | return self.handle_server_line is not None 214 | 215 | def writable(self): 216 | return self.connecting or self.writebuf or (self.fill_writebuf and self.linebuf) 217 | 218 | @mlat.profile.trackcpu 219 | def handle_write(self): 220 | if self.fill_writebuf: 221 | self.fill_writebuf() 222 | 223 | if self.writebuf: 224 | sent = self.send(self.writebuf) 225 | del self.writebuf[:sent] 226 | global_stats.server_tx_bytes += sent 227 | if len(self.writebuf) > 65536: 228 | raise IOError('Server write buffer overflow (too much unsent data)') 229 | 230 | def fill_uncompressed(self): 231 | if not self.linebuf: 232 | return 233 | 234 | lines = '\n'.join(self.linebuf) 235 | self.writebuf.extend(lines.encode('ascii')) 236 | self.writebuf.extend(b'\n') 237 | self.linebuf = [] 238 | 239 | def fill_zlib(self): 240 | if not self.linebuf: 241 | return 242 | 243 | data = bytearray() 244 | pending = False 245 | for line in self.linebuf: 246 | data.extend(self.compressor.compress((line + '\n').encode('ascii'))) 247 | pending = True 248 | 249 | if len(data) >= 32768: 250 | data.extend(self.compressor.flush(zlib.Z_SYNC_FLUSH)) 251 | assert len(data) < 65540 252 | assert data[-4:] == b'\x00\x00\xff\xff' 253 | del data[-4:] 254 | self.writebuf.extend(struct.pack('!H', len(data))) 255 | self.writebuf.extend(data) 256 | pending = False 257 | 258 | if pending: 259 | data.extend(self.compressor.flush(zlib.Z_SYNC_FLUSH)) 260 | assert len(data) < 65540 261 | assert data[-4:] == b'\x00\x00\xff\xff' 262 | del data[-4:] 263 | self.writebuf.extend(struct.pack('!H', len(data))) 264 | self.writebuf.extend(data) 265 | 266 | self.linebuf = [] 267 | 268 | def _send_json(self, o): 269 | if DEBUG: 270 | log('Send: {0}', o) 271 | self.linebuf.append(json.dumps(o, separators=(',', ':'))) 272 | 273 | # 274 | # TCP transport 275 | # 276 | 277 | def send_tcp_mlat(self, message): 278 | self.linebuf.append('{{"mlat":{{"t":{0},"m":"{1}"}}}}'.format( 279 | message.timestamp, 280 | str(message))) 281 | 282 | def send_tcp_sync(self, em, om): 283 | self.linebuf.append('{{"sync":{{"et":{0},"em":"{1}","ot":{2},"om":"{3}"}}}}'.format( 284 | em.timestamp, 285 | str(em), 286 | om.timestamp, 287 | str(om))) 288 | 289 | def send_tcp_split_sync(self, m): 290 | self.linebuf.append('{{"ssync":{{"t":{0},"m":"{1}"}}}}'.format( 291 | m.timestamp, 292 | str(m))) 293 | 294 | def send_seen(self, aclist): 295 | self._send_json({'seen': ['{0:06x}'.format(icao) for icao in aclist]}) 296 | 297 | def send_lost(self, aclist): 298 | self._send_json({'lost': ['{0:06x}'.format(icao) for icao in aclist]}) 299 | 300 | def send_rate_report(self, report): 301 | r2 = dict([('{0:06X}'.format(k), round(v, 2)) for k, v in report.items()]) 302 | self._send_json({'rate_report': r2}) 303 | 304 | def send_input_connected(self): 305 | self._send_json({'input_connected': 'connected'}) 306 | 307 | def send_input_disconnected(self): 308 | self._send_json({'input_disconnected': 'disconnected'}) 309 | 310 | def send_clock_reset(self, reason, frequency=None, epoch=None, mode=None): 311 | details = { 312 | 'reason': reason 313 | } 314 | 315 | if frequency is not None: 316 | details['frequency'] = frequency 317 | details['epoch'] = epoch 318 | details['mode'] = mode 319 | 320 | self._send_json({'clock_reset': details}) 321 | 322 | def send_position_update(self, lat, lon, alt, altref): 323 | pass 324 | 325 | def start_connection(self): 326 | log('Connected to multilateration server at {0}:{1}, handshaking', self.host, self.port) 327 | self.state = 'handshaking' 328 | self.last_data_received = monotonic_time() 329 | 330 | compress_methods = ['none'] 331 | if self.offer_zlib: 332 | compress_methods.append('zlib') 333 | compress_methods.append('zlib2') 334 | 335 | handshake_msg = {'version': 3, 336 | 'client_version': mlat.client.version.CLIENT_VERSION, 337 | 'compress': compress_methods, 338 | 'selective_traffic': True, 339 | 'heartbeat': True, 340 | 'return_results': self.return_results, 341 | 'udp_transport': 2 if self.offer_udp else False, 342 | 'return_result_format': 'ecef'} 343 | handshake_msg.update(self.handshake_data) 344 | if DEBUG: 345 | log("Handshake: {0}", handshake_msg) 346 | self.writebuf += (json.dumps(handshake_msg) + '\n').encode('ascii') # linebuf not used yet 347 | self.consume_readbuf = self.consume_readbuf_uncompressed 348 | self.handle_server_line = self.handle_handshake_response 349 | 350 | def heartbeat(self, now): 351 | super().heartbeat(now) 352 | 353 | if self.state in ('ready', 'handshaking') and (now - self.last_data_received) > self.inactivity_timeout: 354 | self.disconnect('No data (not even keepalives) received for {0:.0f} seconds'.format( 355 | self.inactivity_timeout)) 356 | self.reconnect() 357 | return 358 | 359 | if self.udp_transport: 360 | self.udp_transport.flush() 361 | 362 | if self.server_heartbeat_at is not None and self.server_heartbeat_at < now: 363 | self.server_heartbeat_at = now + self.heartbeat_interval 364 | self._send_json({'heartbeat': {'client_time': round(time.time(), 3)}}) 365 | 366 | def handle_read(self): 367 | try: 368 | moredata = self.recv(16384) 369 | except socket.error as e: 370 | if e.errno == errno.EAGAIN: 371 | return 372 | raise 373 | 374 | if not moredata: 375 | self.close() 376 | self.schedule_reconnect() 377 | return 378 | 379 | self.last_data_received = monotonic_time() 380 | self.readbuf += moredata 381 | global_stats.server_rx_bytes += len(moredata) 382 | self.consume_readbuf() 383 | 384 | def consume_readbuf_uncompressed(self): 385 | lines = self.readbuf.split(b'\n') 386 | self.readbuf = lines[-1] 387 | for line in lines[:-1]: 388 | try: 389 | msg = json.loads(line.decode('ascii')) 390 | except ValueError: 391 | log("json parsing problem, line: >>{line}<<", line=line) 392 | raise 393 | 394 | if DEBUG: 395 | log('Receive: {0}', msg) 396 | self.handle_server_line(msg) 397 | 398 | def consume_readbuf_zlib(self): 399 | i = 0 400 | while i + 2 < len(self.readbuf): 401 | hlen, = struct.unpack_from('!H', self.readbuf, i) 402 | end = i + 2 + hlen 403 | if end > len(self.readbuf): 404 | break 405 | 406 | packet = self.readbuf[i + 2:end] + b'\x00\x00\xff\xff' 407 | linebuf = self.decompressor.decompress(packet) 408 | lines = linebuf.split(b'\n') 409 | for line in lines[:-1]: 410 | try: 411 | msg = json.loads(line.decode('ascii')) 412 | except ValueError: 413 | log("json parsing problem, line: >>{line}<<", line=line) 414 | raise 415 | 416 | self.handle_server_line(msg) 417 | 418 | i = end 419 | 420 | del self.readbuf[:i] 421 | 422 | def handle_handshake_response(self, response): 423 | if 'reconnect_in' in response: 424 | self.reconnect_interval = response['reconnect_in'] 425 | 426 | if 'deny' in response: 427 | log('Server explicitly rejected our connection, saying:') 428 | for reason in response['deny']: 429 | log(' {0}', reason) 430 | raise IOError('Server rejected our connection attempt') 431 | 432 | if 'motd' in response: 433 | log('Server says: {0}', response['motd']) 434 | 435 | compress = response.get('compress', 'none') 436 | if response['compress'] == 'none': 437 | self.fill_writebuf = self.fill_uncompressed 438 | self.consume_readbuf = self.consume_readbuf_uncompressed 439 | elif response['compress'] == 'zlib' and self.offer_zlib: 440 | self.compressor = zlib.compressobj(1) 441 | self.fill_writebuf = self.fill_zlib 442 | self.consume_readbuf = self.consume_readbuf_uncompressed 443 | elif response['compress'] == 'zlib2' and self.offer_zlib: 444 | self.compressor = zlib.compressobj(1) 445 | self.decompressor = zlib.decompressobj() 446 | self.fill_writebuf = self.fill_zlib 447 | self.consume_readbuf = self.consume_readbuf_zlib 448 | else: 449 | raise IOError('Server response asked for a compression method {0}, which we do not support'.format( 450 | response['compress'])) 451 | 452 | self.server_heartbeat_at = monotonic_time() + self.heartbeat_interval 453 | 454 | if 'udp_transport' in response: 455 | host, port, key = response['udp_transport'] 456 | if not host: 457 | host = self.host 458 | 459 | self.udp_transport = UdpServerConnection(host, port, key) 460 | 461 | self.send_mlat = self.udp_transport.send_mlat 462 | self.send_sync = self.udp_transport.send_sync 463 | self.send_split_sync = self.udp_transport.send_split_sync 464 | else: 465 | self.udp_transport = None 466 | self.send_mlat = self.send_tcp_mlat 467 | self.send_sync = self.send_tcp_sync 468 | self.send_split_sync = self.send_tcp_split_sync 469 | 470 | # turn off the sync method we don't want 471 | if response.get('split_sync', False): 472 | self.send_sync = None 473 | else: 474 | self.send_split_sync = None 475 | 476 | log('Handshake complete.') 477 | log(' Compression: {0}', compress) 478 | log(' UDP transport: {0}', self.udp_transport and str(self.udp_transport) or 'disabled') 479 | log(' Split sync: {0}', self.send_split_sync and 'enabled' or 'disabled') 480 | 481 | self.state = 'ready' 482 | self.handle_server_line = self.handle_connected_request 483 | self.coordinator.server_connected() 484 | 485 | # dummy rate report to indicate we'll be sending them 486 | self.send_rate_report({}) 487 | 488 | def handle_connected_request(self, request): 489 | if DEBUG: 490 | log('Receive: {0}', request) 491 | if 'start_sending' in request: 492 | self.coordinator.server_start_sending([int(x, 16) for x in request['start_sending']]) 493 | elif 'stop_sending' in request: 494 | self.coordinator.server_stop_sending([int(x, 16) for x in request['stop_sending']]) 495 | elif 'heartbeat' in request: 496 | pass 497 | elif 'result' in request: 498 | result = request['result'] 499 | ecef = result.get('ecef') 500 | if ecef is not None: 501 | # new format 502 | lat, lon, alt = mlat.geodesy.ecef2llh(ecef) 503 | alt = alt / 0.3038 # convert meters to feet 504 | ecef_cov = result.get('cov') 505 | if ecef_cov: 506 | var_est = ecef_cov[0] + ecef_cov[3] + ecef_cov[5] 507 | if var_est >= 0: 508 | error_est = math.sqrt(var_est) 509 | else: 510 | error_est = -1 511 | else: 512 | error_est = -1 513 | nstations = result['nd'] 514 | callsign = None 515 | squawk = None 516 | else: 517 | lat = result['lat'] 518 | lon = result['lon'] 519 | alt = result['alt'] 520 | error_est = result['gdop'] * 300 # make a guess 521 | nstations = result['nstations'] 522 | callsign = result['callsign'] 523 | squawk = result['squawk'] 524 | 525 | self.coordinator.server_mlat_result(timestamp=result['@'], 526 | addr=int(result['addr'], 16), 527 | lat=lat, 528 | lon=lon, 529 | alt=alt, 530 | nsvel=None, 531 | ewvel=None, 532 | vrate=None, 533 | callsign=callsign, 534 | squawk=squawk, 535 | error_est=error_est, 536 | nstations=nstations, 537 | anon=False, 538 | modeac=False) 539 | else: 540 | log('ignoring request from server: {0}', request) 541 | -------------------------------------------------------------------------------- /mlat/client/net.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python; indent-tabs-mode: nil -*- 2 | 3 | # Part of mlat-client - an ADS-B multilateration client. 4 | # Copyright 2015, Oliver Jowett 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Common networking bits, based on asyncore 21 | """ 22 | 23 | import sys 24 | import socket 25 | import asyncore 26 | from mlat.client.util import log, log_exc, monotonic_time 27 | 28 | 29 | __all__ = ('LoggingMixin', 'ReconnectingConnection') 30 | 31 | 32 | class LoggingMixin: 33 | """A mixin that redirects asyncore's logging to the client's 34 | global logging.""" 35 | 36 | def log(self, message): 37 | log('{0}', message) 38 | 39 | def log_info(self, message, type='info'): 40 | log('{0}: {1}', message, type) 41 | 42 | 43 | class ReconnectingConnection(LoggingMixin, asyncore.dispatcher): 44 | """ 45 | An asyncore connection that maintains a TCP connection to a particular 46 | host/port, reconnecting on connection loss. 47 | """ 48 | 49 | reconnect_interval = 30.0 50 | 51 | def __init__(self, host, port): 52 | asyncore.dispatcher.__init__(self) 53 | self.host = host 54 | self.port = port 55 | self.addrlist = [] 56 | self.state = 'disconnected' 57 | self.reconnect_at = None 58 | 59 | def heartbeat(self, now): 60 | if self.reconnect_at is None or self.reconnect_at > now: 61 | return 62 | if self.state == 'ready': 63 | return 64 | self.reconnect_at = None 65 | self.reconnect() 66 | 67 | def close(self, manual_close=False): 68 | try: 69 | asyncore.dispatcher.close(self) 70 | except AttributeError: 71 | # blarg, try to eat asyncore bugs 72 | pass 73 | 74 | if self.state != 'disconnected': 75 | if not manual_close: 76 | log('Lost connection to {host}:{port}', host=self.host, port=self.port) 77 | 78 | self.state = 'disconnected' 79 | self.reset_connection() 80 | self.lost_connection() 81 | 82 | if not manual_close: 83 | self.schedule_reconnect() 84 | 85 | def disconnect(self, reason): 86 | if self.state != 'disconnected': 87 | log('Disconnecting from {host}:{port}: {reason}', host=self.host, port=self.port, reason=reason) 88 | self.close(True) 89 | 90 | def writable(self): 91 | return self.connecting 92 | 93 | def schedule_reconnect(self): 94 | if self.reconnect_at is None: 95 | if len(self.addrlist) > 0: 96 | # we still have more addresses to try 97 | # nb: asyncore breaks in odd ways if you try 98 | # to reconnect immediately at this point 99 | # (pending events for the old socket go to 100 | # the new socket) so do it in 0.5s time 101 | # so the caller can clean up the old 102 | # socket and discard the events. 103 | interval = 0.5 104 | else: 105 | interval = self.reconnect_interval 106 | 107 | log('Reconnecting in {0} seconds', interval) 108 | self.reconnect_at = monotonic_time() + interval 109 | 110 | def refresh_address_list(self): 111 | self.address 112 | 113 | def reconnect(self): 114 | if self.state != 'disconnected': 115 | self.disconnect('About to reconnect') 116 | 117 | try: 118 | self.reset_connection() 119 | 120 | if len(self.addrlist) == 0: 121 | # ran out of addresses to try, resolve it again 122 | self.addrlist = socket.getaddrinfo(host=self.host, 123 | port=self.port, 124 | family=socket.AF_UNSPEC, 125 | type=socket.SOCK_STREAM, 126 | proto=0, 127 | flags=0) 128 | 129 | # try the next available address 130 | a_family, a_type, a_proto, a_canonname, a_sockaddr = self.addrlist[0] 131 | del self.addrlist[0] 132 | 133 | self.create_socket(a_family, a_type) 134 | self.connect(a_sockaddr) 135 | except socket.error as e: 136 | log('Connection to {host}:{port} failed: {ex!s}', host=self.host, port=self.port, ex=e) 137 | self.close() 138 | 139 | def handle_connect(self): 140 | self.state = 'connected' 141 | self.addrlist = [] # connect was OK, re-resolve next time 142 | self.start_connection() 143 | 144 | def handle_read(self): 145 | pass 146 | 147 | def handle_write(self): 148 | pass 149 | 150 | def handle_close(self): 151 | self.close() 152 | 153 | def handle_error(self): 154 | t, v, tb = sys.exc_info() 155 | if isinstance(v, IOError): 156 | log('Connection to {host}:{port} lost: {ex!s}', 157 | host=self.host, 158 | port=self.port, 159 | ex=v) 160 | else: 161 | log_exc('Unexpected exception on connection to {host}:{port}', 162 | host=self.host, 163 | port=self.port) 164 | 165 | self.handle_close() 166 | 167 | def reset_connection(self): 168 | pass 169 | 170 | def start_connection(self): 171 | pass 172 | 173 | def lost_connection(self): 174 | pass 175 | -------------------------------------------------------------------------------- /mlat/client/options.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- mode: python; indent-tabs-mode: nil -*- 3 | 4 | # Part of mlat-client - an ADS-B multilateration client. 5 | # Copyright 2015, Oliver Jowett 6 | # 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program. If not, see . 19 | 20 | import argparse 21 | import functools 22 | 23 | import _modes 24 | from mlat.client.receiver import ReceiverConnection 25 | from mlat.client.output import OutputListener, OutputConnector 26 | from mlat.client.output import BasestationConnection, ExtBasestationConnection, BeastConnection 27 | from mlat.client.util import log 28 | 29 | _receiver_types = { 30 | # input type -> decoder mode, server clock type 31 | # the server clock type is used by the server to set 32 | # the clock jitter etc; clock frequency and 33 | # epoch are provided by the client. 34 | 35 | 'auto': (None, 'unknown'), 36 | 'dump1090': (_modes.BEAST, 'dump1090'), 37 | 'beast': (_modes.BEAST, 'beast'), 38 | 'radarcape_12mhz': (_modes.BEAST, 'radarcape_12mhz'), # compat 39 | 'radarcape_gps': (_modes.RADARCAPE, 'radarcape_gps'), # compat 40 | 'radarcape': (_modes.BEAST, 'radarcape'), # autodetects gps if present 41 | 'sbs': (_modes.SBS, 'sbs'), 42 | 'avrmlat': (_modes.AVRMLAT, 'unknown'), 43 | } 44 | 45 | 46 | def latitude(s): 47 | lat = float(s) 48 | if lat < -90 or lat > 90: 49 | raise argparse.ArgumentTypeError('Latitude %s must be in the range -90 to 90' % s) 50 | return lat 51 | 52 | 53 | def longitude(s): 54 | lon = float(s) 55 | if lon < -180 or lon > 360: 56 | raise argparse.ArgumentTypeError('Longitude %s must be in the range -180 to 360' % s) 57 | if lon > 180: 58 | lon -= 360 59 | return lon 60 | 61 | 62 | def altitude(s): 63 | if s.endswith('m'): 64 | alt = float(s[:-1]) 65 | elif s.endswith('ft'): 66 | alt = float(s[:-2]) * 0.3048 67 | else: 68 | alt = float(s) 69 | 70 | # Wikipedia to the rescue! 71 | # "The lowest point on dry land is the shore of the Dead Sea [...] 72 | # 418m below sea level". Perhaps not the best spot for a receiver? 73 | # La Rinconada, Peru, pop. 30,000, is at 5100m. 74 | if alt < -420 or alt > 5100: 75 | raise argparse.ArgumentTypeError('Altitude %s must be in the range -420m to 6000m' % s) 76 | return alt 77 | 78 | 79 | def port(s): 80 | port = int(s) 81 | if port < 1 or port > 65535: 82 | raise argparse.ArgumentTypeError('Port %s must be in the range 1 to 65535' % s) 83 | return port 84 | 85 | 86 | def hostport(s): 87 | parts = s.split(':') 88 | if len(parts) != 2: 89 | raise argparse.ArgumentTypeError("{} should be in 'host:port' format".format(s)) 90 | return (parts[0], int(parts[1])) 91 | 92 | 93 | def make_inputs_group(parser): 94 | inputs = parser.add_argument_group('Mode S receiver input connection') 95 | inputs.add_argument('--input-type', 96 | help="Sets the input receiver type.", 97 | choices=_receiver_types.keys(), 98 | default='dump1090') 99 | inputs.add_argument('--input-connect', 100 | help="host:port to connect to for Mode S traffic. Required.", 101 | required=True, 102 | type=hostport, 103 | default=('localhost', 30005)) 104 | 105 | 106 | def clock_frequency(args): 107 | return _modes.Reader(_receiver_types[args.input_type][0]).frequency 108 | 109 | 110 | def clock_epoch(args): 111 | return _modes.Reader(_receiver_types[args.input_type][0]).epoch 112 | 113 | 114 | def clock_type(args): 115 | return _receiver_types[args.input_type][1] 116 | 117 | 118 | def connection_mode(args): 119 | return _receiver_types[args.input_type][0] 120 | 121 | 122 | def make_results_group(parser): 123 | results = parser.add_argument_group('Results output') 124 | results.add_argument('--results', 125 | help=""" 126 | ,connect,host:port or ,listen,port. 127 | Protocol may be 'basestation', 'ext_basestation', or 'beast'. Can be specified multiple times.""", 128 | action='append', 129 | default=[]) 130 | results.add_argument("--no-anon-results", 131 | help="Do not generate results for anonymized aircraft", 132 | action='store_false', 133 | dest='allow_anon_results', 134 | default=True) 135 | results.add_argument("--no-modeac-results", 136 | help="Do not generate results for Mode A/C tracks", 137 | action='store_false', 138 | dest='allow_modeac_results', 139 | default=True) 140 | 141 | return results 142 | 143 | 144 | def output_factory(s): 145 | parts = s.split(',') 146 | if len(parts) != 3: 147 | raise ValueError('exactly three comma-separated values are needed (see help)') 148 | 149 | ctype, cmode, addr = parts 150 | 151 | connections = { 152 | 'basestation': BasestationConnection, 153 | 'ext_basestation': ExtBasestationConnection, 154 | 'beast': BeastConnection 155 | } 156 | 157 | c = connections.get(ctype) 158 | if c is None: 159 | raise ValueError("connection type '{0}' is not supported; options are: '{1}'".format( 160 | ctype, "','".join(connections.keys()))) 161 | 162 | if cmode == 'listen': 163 | return functools.partial(OutputListener, port=int(addr), connection_factory=c) 164 | elif cmode == 'connect': 165 | return functools.partial(OutputConnector, addr=hostport(addr), connection_factory=c) 166 | else: 167 | raise ValueError("connection mode '{0}' is not supported; options are: 'connect','listen'".format(cmode)) 168 | 169 | 170 | def build_outputs(args): 171 | outputs = [] 172 | for s in args.results: 173 | try: 174 | factory = output_factory(s) 175 | except ValueError as e: 176 | log("Warning: Ignoring bad results output option '{0}': {1}", 177 | s, str(e)) 178 | continue 179 | 180 | try: 181 | output = factory() 182 | except Exception as e: 183 | log("Warning: Could not create results output '{0}': {1}", 184 | s, str(e)) 185 | continue 186 | 187 | outputs.append(output) 188 | 189 | return outputs 190 | 191 | 192 | def build_receiver_connection(args): 193 | return ReceiverConnection(host=args.input_connect[0], 194 | port=args.input_connect[1], 195 | mode=connection_mode(args)) 196 | -------------------------------------------------------------------------------- /mlat/client/output.py: -------------------------------------------------------------------------------- 1 | # -*- python -*- 2 | 3 | # Part of mlat-client - an ADS-B multilateration client. 4 | # Copyright 2015, Oliver Jowett 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | import sys 20 | import asyncore 21 | import socket 22 | import time 23 | import math 24 | import errno 25 | 26 | from mlat.client.net import LoggingMixin 27 | from mlat.client.util import log, monotonic_time 28 | from mlat.client.synthetic_es import make_altitude_only_frame, \ 29 | make_position_frame_pair, make_velocity_frame, DF18, DF18ANON, DF18TRACK 30 | 31 | 32 | class OutputListener(LoggingMixin, asyncore.dispatcher): 33 | def __init__(self, port, connection_factory): 34 | asyncore.dispatcher.__init__(self) 35 | self.port = port 36 | 37 | self.a_type = socket.SOCK_STREAM 38 | try: 39 | # bind to V6 so we can accept both V4 and V6 40 | # (asyncore makes it a hassle to bind to more than 41 | # one address here) 42 | self.a_family = socket.AF_INET6 43 | self.create_socket(self.a_family, self.a_type) 44 | except socket.error: 45 | # maybe no v6 support? 46 | self.a_family = socket.AF_INET 47 | self.create_socket(self.a_family, self.a_type) 48 | 49 | try: 50 | self.set_reuse_addr() 51 | self.bind(('', port)) 52 | self.listen(5) 53 | except Exception: 54 | self.close() 55 | raise 56 | 57 | self.output_channels = set() 58 | self.connection_factory = connection_factory 59 | log('Listening for {0} on port {1}', connection_factory.describe(), port) 60 | 61 | def handle_accept(self): 62 | accepted = self.accept() 63 | if not accepted: 64 | return 65 | 66 | new_socket, address = accepted 67 | log('Accepted {0} from {1}:{2}', self.connection_factory.describe(), address[0], address[1]) 68 | 69 | self.output_channels.add(self.connection_factory(self, new_socket, self.a_type, self.a_family, address)) 70 | 71 | def send_position(self, timestamp, addr, lat, lon, alt, nsvel, ewvel, vrate, 72 | callsign, squawk, error_est, nstations, anon, modeac): 73 | for channel in list(self.output_channels): 74 | channel.send_position(timestamp, addr, lat, lon, alt, nsvel, ewvel, vrate, 75 | callsign, squawk, error_est, nstations, anon, modeac) 76 | 77 | def heartbeat(self, now): 78 | for channel in list(self.output_channels): 79 | channel.heartbeat(now) 80 | 81 | def disconnect(self, reason=None): 82 | for channel in list(self.output_channels): 83 | channel.close() 84 | self.close() 85 | 86 | def connection_lost(self, child): 87 | self.output_channels.discard(child) 88 | 89 | 90 | class OutputConnector: 91 | reconnect_interval = 30.0 92 | 93 | def __init__(self, addr, connection_factory): 94 | self.addr = addr 95 | self.connection_factory = connection_factory 96 | 97 | self.output_channel = None 98 | self.next_reconnect = monotonic_time() 99 | self.addrlist = [] 100 | 101 | def log(self, fmt, *args, **kwargs): 102 | log('{what} with {host}:{port}: ' + fmt, 103 | *args, 104 | what=self.describe(), host=self.addr[0], port=self.addr[1], 105 | **kwargs) 106 | 107 | def reconnect(self): 108 | if len(self.addrlist) == 0: 109 | try: 110 | self.addrlist = socket.getaddrinfo(host=self.addr[0], 111 | port=self.addr[1], 112 | family=socket.AF_UNSPEC, 113 | type=socket.SOCK_STREAM, 114 | proto=0, 115 | flags=0) 116 | except socket.error as e: 117 | self.log('{ex!s}', ex=e) 118 | self.next_reconnect = monotonic_time() + self.reconnect_interval 119 | return 120 | 121 | # try the next available address 122 | a_family, a_type, a_proto, a_canonname, a_sockaddr = self.addrlist[0] 123 | del self.addrlist[0] 124 | 125 | self.output_channel = self.connection_factory(self, None, a_family, a_type, a_sockaddr) 126 | self.output_channel.connect_now() 127 | 128 | def send_position(self, timestamp, addr, lat, lon, alt, nsvel, ewvel, vrate, 129 | callsign, squawk, error_est, nstations, anon, modeac): 130 | if self.output_channel: 131 | self.output_channel.send_position(timestamp, addr, lat, lon, alt, nsvel, ewvel, vrate, 132 | callsign, squawk, error_est, nstations, anon, modeac) 133 | 134 | def heartbeat(self, now): 135 | if self.output_channel: 136 | self.output_channel.heartbeat(now) 137 | elif now > self.next_reconnect: 138 | self.reconnect() 139 | 140 | def disconnect(self, reason=None): 141 | if self.output_channel: 142 | self.output_channel.close() 143 | 144 | def connection_lost(self, child): 145 | if self.output_channel is child: 146 | self.output_channel = None 147 | self.next_reconnect = monotonic_time() + self.reconnect_interval 148 | 149 | 150 | def format_time(timestamp): 151 | return time.strftime("%H:%M:%S", time.gmtime(timestamp)) + ".{0:03.0f}".format(math.modf(timestamp)[0] * 1000) 152 | 153 | 154 | def format_date(timestamp): 155 | return time.strftime("%Y/%m/%d", time.gmtime(timestamp)) 156 | 157 | 158 | def csv_quote(s): 159 | if s is None: 160 | return '' 161 | if s.find('\n') == -1 and s.find('"') == -1 and s.find(',') == -1: 162 | return s 163 | else: 164 | return '"' + s.replace('"', '""') + '"' 165 | 166 | 167 | class BasicConnection(LoggingMixin, asyncore.dispatcher): 168 | def __init__(self, listener, socket, s_family, s_type, addr): 169 | super().__init__(sock=socket) 170 | self.listener = listener 171 | self.s_family = s_family 172 | self.s_type = s_type 173 | self.addr = addr 174 | self.writebuf = bytearray() 175 | 176 | def log(self, fmt, *args, **kwargs): 177 | log('{what} with {addr[0]}:{addr[1]}: ' + fmt, *args, what=self.describe(), addr=self.addr, **kwargs) 178 | 179 | def readable(self): 180 | return True 181 | 182 | def handle_connect(self): 183 | self.log('connection established') 184 | 185 | def handle_read(self): 186 | try: 187 | self.recv(1024) # discarded 188 | except socket.error as e: 189 | self.log('{ex!s}', ex=e) 190 | self.close() 191 | 192 | def writable(self): 193 | return self.connecting or self.writebuf 194 | 195 | def handle_write(self): 196 | try: 197 | sent = super().send(self.writebuf) 198 | del self.writebuf[0:sent] 199 | except socket.error as e: 200 | if e.errno == errno.EAGAIN: 201 | return 202 | self.log('{ex!s}', ex=e) 203 | self.close() 204 | 205 | def handle_close(self): 206 | if self.connected: 207 | self.log('connection lost') 208 | self.close() 209 | 210 | def close(self): 211 | try: 212 | super().close() 213 | except AttributeError: 214 | # blarg, try to eat asyncore bugs 215 | pass 216 | 217 | self.listener.connection_lost(self) 218 | 219 | def handle_error(self): 220 | t, v, tb = sys.exc_info() 221 | self.log('{ex!s}', ex=v) 222 | self.handle_close() 223 | 224 | def connect_now(self): 225 | if self.socket: 226 | return 227 | 228 | try: 229 | self.create_socket(self.s_family, self.s_type) 230 | self.connect(self.addr) 231 | except socket.error as e: 232 | self.log('{ex!s}', ex=e) 233 | self.close() 234 | 235 | def send(self, data): 236 | self.writebuf.extend(data) 237 | 238 | 239 | class BasestationConnection(BasicConnection): 240 | heartbeat_interval = 30.0 241 | template = 'MSG,3,1,1,{addrtype}{addr:06X},1,{rcv_date},{rcv_time},{now_date},{now_time},{callsign},{altitude},{speed},{heading},{lat},{lon},{vrate},{squawk},{fs},{emerg},{ident},{aog}' # noqa 242 | 243 | def __init__(self, listener, socket, s_family, s_type, addr): 244 | super().__init__(listener, socket, s_family, s_type, addr) 245 | self.next_heartbeat = monotonic_time() + self.heartbeat_interval 246 | 247 | @staticmethod 248 | def describe(): 249 | return 'Basestation-format results connection' 250 | 251 | def heartbeat(self, now): 252 | if now > self.next_heartbeat: 253 | self.next_heartbeat = now + self.heartbeat_interval 254 | try: 255 | self.send('\n'.encode('ascii')) 256 | except socket.error: 257 | self.handle_error() 258 | 259 | def send_position(self, timestamp, addr, lat, lon, alt, nsvel, ewvel, vrate, 260 | callsign, squawk, error_est, nstations, anon, modeac): 261 | if not self.connected: 262 | return 263 | 264 | now = time.time() 265 | if timestamp is None: 266 | timestamp = now 267 | 268 | if nsvel is not None and ewvel is not None: 269 | speed = math.sqrt(nsvel ** 2 + ewvel ** 2) 270 | heading = math.degrees(math.atan2(ewvel, nsvel)) 271 | if heading < 0: 272 | heading += 360 273 | else: 274 | speed = None 275 | heading = None 276 | 277 | if modeac: 278 | addrtype = '@' 279 | elif anon: 280 | addrtype = '~' 281 | else: 282 | addrtype = '' 283 | 284 | line = self.template.format(addr=addr, 285 | addrtype=addrtype, 286 | rcv_date=format_date(timestamp), 287 | rcv_time=format_time(timestamp), 288 | now_date=format_date(now), 289 | now_time=format_time(now), 290 | callsign=csv_quote(callsign) if callsign else '', 291 | altitude=int(alt), 292 | speed=int(speed) if (speed is not None) else '', 293 | heading=int(heading) if (heading is not None) else '', 294 | lat=round(lat, 4), 295 | lon=round(lon, 4), 296 | vrate=int(vrate) if (vrate is not None) else '', 297 | squawk=csv_quote(squawk) if (squawk is not None) else '', 298 | fs='', 299 | emerg='', 300 | ident='', 301 | aog='', 302 | error_est=round(error_est, 0) if (error_est is not None) else '', 303 | nstations=nstations if (nstations is not None) else '') 304 | 305 | self.send((line + '\n').encode('ascii')) 306 | self.next_heartbeat = monotonic_time() + self.heartbeat_interval 307 | 308 | 309 | class ExtBasestationConnection(BasestationConnection): 310 | template = 'MLAT,3,1,1,{addrtype}{addr:06X},1,{rcv_date},{rcv_time},{now_date},{now_time},{callsign},{altitude},{speed},{heading},{lat},{lon},{vrate},{squawk},{fs},{emerg},{ident},{aog},{nstations},,{error_est}' # noqa 311 | 312 | @staticmethod 313 | def describe(): 314 | return 'Extended Basestation-format results connection' 315 | 316 | 317 | class BeastConnection(BasicConnection): 318 | heartbeat_interval = 30.0 319 | 320 | @staticmethod 321 | def describe(): 322 | return 'Beast-format results connection' 323 | 324 | def __init__(self, listener, socket, s_family, s_type, addr): 325 | super().__init__(listener, socket, s_family, s_type, addr) 326 | self.writebuf = bytearray() 327 | self.last_write = monotonic_time() 328 | 329 | def heartbeat(self, now): 330 | if (now - self.last_write) > 60.0: 331 | # write a keepalive frame 332 | self.send(b'\x1A1\x00\x00\x00\x00\x00\x00\x00\x00\x00') 333 | self.last_write = now 334 | 335 | def send_frame(self, frame): 336 | """Send a 14-byte message in the Beast binary format, using the magic mlat timestamp""" 337 | 338 | # format: 339 | # 1A '3' long frame follows 340 | # FF 00 'MLAT' 6-byte timestamp, this is the magic MLAT timestamp 341 | # 00 signal level 342 | # ... 14 bytes of frame data, with 1A bytes doubled 343 | 344 | self.writebuf.extend(b'\x1A3\xFF\x00MLAT\x00') 345 | if b'\x1a' not in frame: 346 | self.writebuf.extend(frame) 347 | else: 348 | for b in frame: 349 | if b == 0x1A: 350 | self.writebuf.append(b) 351 | self.writebuf.append(b) 352 | 353 | self.last_write = monotonic_time() 354 | 355 | def send_position(self, timestamp, addr, lat, lon, alt, nsvel, ewvel, vrate, 356 | callsign, squawk, error_est, nstations, anon, modeac): 357 | if not self.connected: 358 | return 359 | 360 | if modeac: 361 | df = DF18TRACK 362 | elif anon: 363 | df = DF18ANON 364 | else: 365 | df = DF18 366 | 367 | if lat is None or lon is None: 368 | if alt is not None: 369 | self.send_frame(make_altitude_only_frame(addr, alt, df=df)) 370 | else: 371 | even, odd = make_position_frame_pair(addr, lat, lon, alt, df=df) 372 | self.send_frame(even) 373 | self.send_frame(odd) 374 | 375 | if nsvel is not None or ewvel is not None or vrate is not None: 376 | self.send_frame(make_velocity_frame(addr, nsvel, ewvel, vrate, df=df)) 377 | -------------------------------------------------------------------------------- /mlat/client/receiver.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python; indent-tabs-mode: nil -*- 2 | 3 | # Part of mlat-client - an ADS-B multilateration client. 4 | # Copyright 2015, Oliver Jowett 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Handles receiving Mode S messages from receivers using various formats. 21 | """ 22 | 23 | import socket 24 | import errno 25 | 26 | import _modes 27 | import mlat.profile 28 | from mlat.client.stats import global_stats 29 | from mlat.client.net import ReconnectingConnection 30 | from mlat.client.util import log, monotonic_time 31 | 32 | 33 | class ReceiverConnection(ReconnectingConnection): 34 | inactivity_timeout = 150.0 35 | 36 | def __init__(self, host, port, mode): 37 | ReconnectingConnection.__init__(self, host, port) 38 | self.coordinator = None 39 | self.last_data_received = None 40 | self.mode = mode 41 | 42 | # set up filters 43 | 44 | # this set gets put into specific_filter in 45 | # multiple places, so we can just add an address 46 | # to this set when we want mlat data. 47 | self.interested_mlat = set() 48 | 49 | self.default_filter = [False] * 32 50 | self.specific_filter = [None] * 32 51 | 52 | # specific filters for mlat 53 | for df in (0, 4, 5, 11, 16, 20, 21): 54 | self.specific_filter[df] = self.interested_mlat 55 | 56 | # we want all DF17 messages so we can report position rates 57 | # and distinguish ADS-B from Mode-S-only aircraft 58 | self.default_filter[17] = True 59 | 60 | self.modeac_filter = set() 61 | 62 | self.reset_connection() 63 | 64 | def detect(self, data): 65 | n, detected_mode = detect_data_format(data) 66 | if detected_mode is not None: 67 | log("Detected {mode} format input".format(mode=detected_mode)) 68 | if detected_mode == _modes.AVR: 69 | log("Input format is AVR with no timestamps. " 70 | "This format does not contain enough information for multilateration. " 71 | "Please enable mlat timestamps on your receiver.") 72 | self.close() 73 | return (0, (), False) 74 | 75 | self.reader.mode = detected_mode 76 | self.feed = self.reader.feed 77 | 78 | # synthesize a mode-change event before the real messages 79 | mode_change = (mode_change_event(self.reader), ) 80 | 81 | try: 82 | m, messages, pending_error = self.feed(data[n:]) 83 | except ValueError: 84 | # return just the mode change and keep the error pending 85 | return (n, mode_change, True) 86 | 87 | # put the mode change on the front of the message list 88 | return (n + m, mode_change + messages, pending_error) 89 | else: 90 | if len(data) > 512: 91 | raise ValueError('Unable to autodetect input message format') 92 | return (0, (), False) 93 | 94 | def reset_connection(self): 95 | self.residual = None 96 | self.reader = _modes.Reader(self.mode) 97 | if self.mode is None: 98 | self.feed = self.detect 99 | else: 100 | self.feed = self.reader.feed 101 | # configure filter, seen-tracking 102 | self.reader.seen = set() 103 | self.reader.default_filter = self.default_filter 104 | self.reader.specific_filter = self.specific_filter 105 | self.reader.modeac_filter = self.modeac_filter 106 | 107 | def start_connection(self): 108 | log('Input connected to {0}:{1}', self.host, self.port) 109 | self.last_data_received = monotonic_time() 110 | self.state = 'connected' 111 | self.coordinator.input_connected() 112 | 113 | # synthesize a mode change immediately if we are not autodetecting 114 | if self.reader.mode is not None: 115 | self.coordinator.input_received_messages((mode_change_event(self.reader),)) 116 | 117 | self.send_settings_message() 118 | 119 | def send_settings_message(self): 120 | # if we are connected to something that is Beast-like (or autodetecting), send a beast settings message 121 | if self.state != 'connected': 122 | return 123 | 124 | if self.reader.mode not in (None, _modes.BEAST, _modes.RADARCAPE, _modes.RADARCAPE_EMULATED): 125 | return 126 | 127 | if not self.modeac_filter: 128 | # Binary format, no filters, CRC checks enabled, mode A/C disabled 129 | settings_message = b'\x1a1C\x1a1d\x1a1f\x1a1j' 130 | else: 131 | # Binary format, no filters, CRC checks enabled, mode A/C enabled 132 | settings_message = b'\x1a1C\x1a1d\x1a1f\x1a1J' 133 | 134 | self.send(settings_message) 135 | 136 | def lost_connection(self): 137 | self.coordinator.input_disconnected() 138 | 139 | def heartbeat(self, now): 140 | ReconnectingConnection.heartbeat(self, now) 141 | 142 | if self.state == 'connected' and (now - self.last_data_received) > self.inactivity_timeout: 143 | self.disconnect('No data (not even keepalives) received for {0:.0f} seconds'.format( 144 | self.inactivity_timeout)) 145 | self.reconnect() 146 | 147 | def recent_aircraft(self): 148 | """Return the set of aircraft seen from the receiver since the 149 | last call to recent_aircraft(). This includes aircraft where no 150 | messages were forwarded due to filtering.""" 151 | recent = set(self.reader.seen) 152 | self.reader.seen.clear() 153 | return recent 154 | 155 | def update_filter(self, wanted_mlat): 156 | """Update the receiver filters so we receive mlat-relevant messages 157 | (basically, anything that's not DF17) for the given addresses only.""" 158 | # do this in place, because self.interested_mlat is referenced 159 | # from the filters installed on the reader; updating the set in 160 | # place automatically updates all the DF-specific filters. 161 | self.interested_mlat.clear() 162 | self.interested_mlat.update(wanted_mlat) 163 | 164 | def update_modeac_filter(self, wanted_modeac): 165 | """Update the receiver filters so that we receive mode A/C messages 166 | for the given Mode A codes""" 167 | 168 | changed = (self.modeac_filter and not wanted_modeac) or (not self.modeac_filter and wanted_modeac) 169 | self.modeac_filter.clear() 170 | self.modeac_filter.update(wanted_modeac) 171 | if changed: 172 | self.send_settings_message() 173 | 174 | @mlat.profile.trackcpu 175 | def handle_read(self): 176 | try: 177 | moredata = self.recv(16384) 178 | except socket.error as e: 179 | if e.errno == errno.EAGAIN: 180 | return 181 | raise 182 | 183 | if not moredata: 184 | self.close() 185 | return 186 | 187 | global_stats.receiver_rx_bytes += len(moredata) 188 | 189 | if self.residual: 190 | moredata = self.residual + moredata 191 | 192 | self.last_data_received = monotonic_time() 193 | 194 | try: 195 | consumed, messages, pending_error = self.feed(moredata) 196 | except ValueError as e: 197 | log("Parsing receiver data failed: {e}", e=str(e)) 198 | self.close() 199 | return 200 | 201 | if consumed < len(moredata): 202 | self.residual = moredata[consumed:] 203 | if len(self.residual) > 5120: 204 | raise RuntimeError('parser broken - buffer not being consumed') 205 | else: 206 | self.residual = None 207 | 208 | global_stats.receiver_rx_messages += self.reader.received_messages 209 | global_stats.receiver_rx_filtered += self.reader.suppressed_messages 210 | self.reader.received_messages = self.reader.suppressed_messages = 0 211 | 212 | if messages: 213 | self.coordinator.input_received_messages(messages) 214 | 215 | if pending_error: 216 | # call it again to get the exception 217 | # now that we've handled all the messages 218 | try: 219 | if self.residual is None: 220 | self.feed(b'') 221 | else: 222 | self.feed(self.residual) 223 | except ValueError as e: 224 | log("Parsing receiver data failed: {e}", e=str(e)) 225 | self.close() 226 | return 227 | 228 | 229 | def mode_change_event(reader): 230 | return _modes.EventMessage(_modes.DF_EVENT_MODE_CHANGE, 0, { 231 | "mode": reader.mode, 232 | "frequency": reader.frequency, 233 | "epoch": reader.epoch}) 234 | 235 | 236 | def detect_data_format(data): 237 | """Try to work out what sort of data format this is. 238 | 239 | Returns (offset, mode) where offset is the byte offset 240 | to start at and mode is the decoder mode to use, 241 | or None if detection failed.""" 242 | 243 | for i in range(len(data)-4): 244 | mode = None 245 | 246 | if data[i] != b'\x1a' and data[i+1:i+3] in (b'\x1a1', b'\x1a2', b'\x1a3', b'\x1a4'): 247 | mode = _modes.BEAST 248 | offset = 1 249 | 250 | elif data[i:i+4] == b'\x10\0x03\x10\0x02': 251 | mode = _modes.SBS 252 | offset = 2 253 | 254 | else: 255 | if data[i:i+3] in (b';\n\r', b';\r\n'): 256 | avr_prefix = 3 257 | elif data[i:i+2] in (b';\n', b';\r'): 258 | avr_prefix = 2 259 | else: 260 | avr_prefix = None 261 | 262 | if avr_prefix: 263 | firstbyte = data[i + avr_prefix] 264 | if firstbyte in (ord('@'), ord('%'), ord('<')): 265 | mode = _modes.AVRMLAT 266 | offset = avr_prefix 267 | elif firstbyte in (ord('*'), ord('.')): 268 | mode = _modes.AVR 269 | offset = avr_prefix 270 | 271 | if mode: 272 | reader = _modes.Reader(mode) 273 | # don't actually want any data, just parse it 274 | reader.want_events = False 275 | reader.default_filter = [False] * 32 276 | try: 277 | n, _, pending_error = reader.feed(data[i + offset:]) 278 | if n > 0 and not pending_error: 279 | # consumed some data without problems 280 | return (i + offset, mode) 281 | except ValueError: 282 | # parse error, ignore it 283 | pass 284 | 285 | return (0, None) 286 | -------------------------------------------------------------------------------- /mlat/client/stats.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python; indent-tabs-mode: nil -*- 2 | 3 | # Part of mlat-client - an ADS-B multilateration client. 4 | # Copyright 2015, Oliver Jowett 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Global stats gathering. 21 | """ 22 | 23 | from mlat.client.util import monotonic_time, log 24 | 25 | 26 | class Stats: 27 | def __init__(self): 28 | self.reset() 29 | 30 | def reset(self, now=None): 31 | if now is None: 32 | now = monotonic_time() 33 | self.start = now 34 | self.server_tx_bytes = 0 35 | self.server_rx_bytes = 0 36 | self.server_udp_bytes = 0 37 | self.receiver_rx_bytes = 0 38 | self.receiver_rx_messages = 0 39 | self.receiver_rx_filtered = 0 40 | self.mlat_positions = 0 41 | 42 | def log_and_reset(self): 43 | now = monotonic_time() 44 | elapsed = now - self.start 45 | 46 | processed = self.receiver_rx_messages - self.receiver_rx_filtered 47 | log('Receiver: {0:6.1f} msg/s received {1:6.1f} msg/s processed ({2:.0f}%)', 48 | self.receiver_rx_messages / elapsed, 49 | processed / elapsed, 50 | 0 if self.receiver_rx_messages == 0 else 100.0 * processed / self.receiver_rx_messages) 51 | log('Server: {0:6.1f} kB/s from server {1:4.1f}kB/s TCP to server {2:6.1f}kB/s UDP to server', 52 | self.server_rx_bytes / elapsed / 1000.0, 53 | self.server_tx_bytes / elapsed / 1000.0, 54 | self.server_udp_bytes / elapsed / 1000.0) 55 | if self.mlat_positions: 56 | log('Results: {0:3.1f} positions/minute', 57 | self.mlat_positions / elapsed * 60.0) 58 | self.reset(now) 59 | 60 | 61 | global_stats = Stats() 62 | -------------------------------------------------------------------------------- /mlat/client/synthetic_es.py: -------------------------------------------------------------------------------- 1 | # -*- python -*- 2 | 3 | # Part of mlat-client - an ADS-B multilateration client. 4 | # Copyright 2015, Oliver Jowett 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | import math 20 | import _modes 21 | import bisect 22 | 23 | # It would be nice to merge this into a proper all-singing 24 | # all-dancing Mode S module that combined _modes, this code, 25 | # and the server side Mode S decoder. A task for another day.. 26 | 27 | __all__ = ('make_altitude_only_frame', 'make_position_frame_pair', 'make_velocity_frame', 'DF17', 'DF18') 28 | 29 | # types of frame we can build 30 | DF17 = 'DF17' 31 | DF18 = 'DF18' 32 | DF18ANON = 'DF18ANON' 33 | DF18TRACK = 'DF18TRACK' 34 | 35 | # lookup table for CPR_NL 36 | nl_table = ( 37 | (10.47047130, 59), 38 | (14.82817437, 58), 39 | (18.18626357, 57), 40 | (21.02939493, 56), 41 | (23.54504487, 55), 42 | (25.82924707, 54), 43 | (27.93898710, 53), 44 | (29.91135686, 52), 45 | (31.77209708, 51), 46 | (33.53993436, 50), 47 | (35.22899598, 49), 48 | (36.85025108, 48), 49 | (38.41241892, 47), 50 | (39.92256684, 46), 51 | (41.38651832, 45), 52 | (42.80914012, 44), 53 | (44.19454951, 43), 54 | (45.54626723, 42), 55 | (46.86733252, 41), 56 | (48.16039128, 40), 57 | (49.42776439, 39), 58 | (50.67150166, 38), 59 | (51.89342469, 37), 60 | (53.09516153, 36), 61 | (54.27817472, 35), 62 | (55.44378444, 34), 63 | (56.59318756, 33), 64 | (57.72747354, 32), 65 | (58.84763776, 31), 66 | (59.95459277, 30), 67 | (61.04917774, 29), 68 | (62.13216659, 28), 69 | (63.20427479, 27), 70 | (64.26616523, 26), 71 | (65.31845310, 25), 72 | (66.36171008, 24), 73 | (67.39646774, 23), 74 | (68.42322022, 22), 75 | (69.44242631, 21), 76 | (70.45451075, 20), 77 | (71.45986473, 19), 78 | (72.45884545, 18), 79 | (73.45177442, 17), 80 | (74.43893416, 16), 81 | (75.42056257, 15), 82 | (76.39684391, 14), 83 | (77.36789461, 13), 84 | (78.33374083, 12), 85 | (79.29428225, 11), 86 | (80.24923213, 10), 87 | 88 | (81.19801349, 9), 89 | (82.13956981, 8), 90 | (83.07199445, 7), 91 | (83.99173563, 6), 92 | (84.89166191, 5), 93 | (85.75541621, 4), 94 | (86.53536998, 3), 95 | (87.00000000, 2), 96 | (90.00000000, 1) 97 | ) 98 | 99 | nl_lats = [x[0] for x in nl_table] 100 | nl_vals = [x[1] for x in nl_table] 101 | 102 | 103 | def CPR_NL(lat): 104 | """The NL function referenced in the CPR calculations: the number of longitude zones at a given latitude""" 105 | if lat < 0: 106 | lat = -lat 107 | 108 | nl = nl_vals[bisect.bisect_left(nl_lats, lat)] 109 | return nl 110 | 111 | 112 | def CPR_N(lat, odd): 113 | """The N function referenced in the CPR calculations: the number of longitude zones at a given latitude / oddness""" 114 | nl = CPR_NL(lat) - (odd and 1 or 0) 115 | if nl < 1: 116 | nl = 1 117 | return nl 118 | 119 | 120 | def cpr_encode(lat, lon, odd): 121 | """Encode an airborne position using a CPR encoding with the given odd flag value""" 122 | 123 | NbPow = 2**17 124 | Dlat = 360.0 / (odd and 59 or 60) 125 | YZ = int(math.floor(NbPow * (lat % Dlat) / Dlat + 0.5)) 126 | 127 | Rlat = Dlat * (1.0 * YZ / NbPow + math.floor(lat / Dlat)) 128 | Dlon = (360.0 / CPR_N(Rlat, odd)) 129 | XZ = int(math.floor(NbPow * (lon % Dlon) / Dlon + 0.5)) 130 | 131 | return (YZ & 0x1FFFF), (XZ & 0x1FFFF) 132 | 133 | 134 | def encode_altitude(ft): 135 | """Encode an altitude in feet using the representation expected in DF17 messages""" 136 | if ft is None: 137 | return 0 138 | 139 | i = int((ft + 1012.5) / 25) 140 | if i < 0: 141 | i = 0 142 | elif i > 0x7ff: 143 | i = 0x7ff 144 | 145 | # insert Q=1 in bit 4 146 | return ((i & 0x7F0) << 1) | 0x010 | (i & 0x00F) 147 | 148 | 149 | def encode_velocity(kts, supersonic): 150 | """Encode a groundspeed in kts using the representation expected in DF17 messages""" 151 | if kts is None: 152 | return 0 153 | 154 | if kts < 0: 155 | signbit = 0x400 156 | kts = 0 - kts 157 | else: 158 | signbit = 0 159 | 160 | if supersonic: 161 | kts /= 4 162 | 163 | kts = int(kts + 1.5) 164 | if kts > 1023: 165 | return 1023 | signbit 166 | else: 167 | return kts | signbit 168 | 169 | 170 | def encode_vrate(vr): 171 | """Encode a vertical rate in fpm using the representation expected in DF17 messages""" 172 | if vr is None: 173 | return 0 174 | 175 | if vr < 0: 176 | signbit = 0x200 177 | vr = 0 - vr 178 | else: 179 | signbit = 0 180 | 181 | vr = int(vr / 64 + 1.5) 182 | if vr > 511: 183 | return 511 | signbit 184 | else: 185 | return vr | signbit 186 | 187 | 188 | def make_altitude_only_frame(addr, lat, lon, alt, df=DF18): 189 | """Create an altitude-only DF17 frame""" 190 | # ME type 0: airborne position, horizontal position unavailable 191 | return make_position_frame(0, addr, 0, 0, encode_altitude(alt), False, df) 192 | 193 | 194 | def make_position_frame_pair(addr, lat, lon, alt, df=DF18): 195 | """Create a pair of DF17 frames - one odd, one even - for the given position""" 196 | ealt = encode_altitude(alt) 197 | even_lat, even_lon = cpr_encode(lat, lon, False) 198 | odd_lat, odd_lon = cpr_encode(lat, lon, True) 199 | 200 | # ME type 18: airborne position, baro alt, NUCp=0 201 | eframe = make_position_frame(18, addr, even_lat, even_lon, ealt, False, df) 202 | oframe = make_position_frame(18, addr, odd_lat, odd_lon, ealt, True, df) 203 | 204 | return eframe, oframe 205 | 206 | 207 | def make_position_frame(metype, addr, elat, elon, ealt, oddflag, df): 208 | """Create single DF17/DF18 position frame""" 209 | 210 | frame = bytearray(14) 211 | 212 | if df is DF17: 213 | # DF=17, CA=6 (ES, Level 2 or above transponder and ability 214 | # to set CA code 7 and either airborne or on the ground) 215 | frame[0] = (17 << 3) | (6) 216 | imf = 0 217 | elif df is DF18: 218 | # DF=18, CF=2, IMF=0 (ES/NT, fine TIS-B message with 24-bit address) 219 | frame[0] = (18 << 3) | (2) 220 | imf = 0 221 | elif df is DF18ANON: 222 | # DF=18, CF=5, IMF=0 (ES/NT, fine TIS-B message with anonymous 24-bit address) 223 | frame[0] = (18 << 3) | (5) 224 | imf = 0 225 | elif df is DF18TRACK: 226 | # DF=18, CF=2, IMF=1 (ES/NT, fine TIS-B message with track file number) 227 | frame[0] = (18 << 3) | (2) 228 | imf = 1 229 | else: 230 | raise ValueError('df must be DF17 or DF18 or DF18ANON or DF18TRACK') 231 | 232 | frame[1] = (addr >> 16) & 255 # AA 233 | frame[2] = (addr >> 8) & 255 # AA 234 | frame[3] = addr & 255 # AA 235 | frame[4] = (metype << 3) # ME type, status 0 236 | frame[4] |= imf # SAF (DF17) / IMF (DF 18) 237 | frame[5] = (ealt >> 4) & 255 # Altitude (MSB) 238 | frame[6] = (ealt & 15) << 4 # Altitude (LSB) 239 | if oddflag: 240 | frame[6] |= 4 # CPR format 241 | frame[6] |= (elat >> 15) & 3 # CPR latitude (top bits) 242 | frame[7] = (elat >> 7) & 255 # CPR latitude (middle bits) 243 | frame[8] = (elat & 127) << 1 # CPR latitude (low bits) 244 | frame[8] |= (elon >> 16) & 1 # CPR longitude (high bit) 245 | frame[9] = (elon >> 8) & 255 # CPR longitude (middle bits) 246 | frame[10] = elon & 255 # CPR longitude (low bits) 247 | 248 | # CRC 249 | c = _modes.crc(frame[0:11]) 250 | frame[11] = (c >> 16) & 255 251 | frame[12] = (c >> 8) & 255 252 | frame[13] = c & 255 253 | 254 | return frame 255 | 256 | 257 | def make_velocity_frame(addr, nsvel, ewvel, vrate, df=DF18): 258 | """Create a DF17/DF18 airborne velocity frame""" 259 | 260 | supersonic = (nsvel is not None and abs(nsvel) > 1000) or (ewvel is not None and abs(ewvel) > 1000) 261 | 262 | e_ns = encode_velocity(nsvel, supersonic) 263 | e_ew = encode_velocity(ewvel, supersonic) 264 | e_vr = encode_vrate(vrate) 265 | 266 | frame = bytearray(14) 267 | 268 | if df is DF17: 269 | # DF=17, CA=6 (ES, Level 2 or above transponder and ability 270 | # to set CA code 7 and either airborne or on the ground) 271 | frame[0] = (17 << 3) | (6) 272 | imf = 0 273 | elif df is DF18: 274 | # DF=18, CF=2, IMF=0 (ES/NT, fine TIS-B message with 24-bit address) 275 | frame[0] = (18 << 3) | (2) 276 | imf = 0 277 | elif df is DF18ANON: 278 | # DF=18, CF=5, IMF=1 (ES/NT, fine TIS-B message with anonymous 24-bit address) 279 | frame[0] = (18 << 3) | (5) 280 | imf = 0 281 | elif df is DF18TRACK: 282 | # DF=18, CF=2, IMF=1 (ES/NT, fine TIS-B message with track file number) 283 | frame[0] = (18 << 3) | (2) 284 | imf = 1 285 | else: 286 | raise ValueError('df must be DF17 or DF18 or DF18ANON or DF18TRACK') 287 | 288 | frame[1] = (addr >> 16) & 255 # AA 289 | frame[2] = (addr >> 8) & 255 # AA 290 | frame[3] = addr & 255 # AA 291 | frame[4] = (19 << 3) # ES type 19, airborne velocity 292 | if supersonic: 293 | frame[4] |= 2 # subtype 2, ground speed, supersonic 294 | else: 295 | frame[4] |= 1 # subtype 1, ground speed, subsonic 296 | 297 | frame[5] = (imf << 7) # IMF, NACp 0 298 | frame[5] |= (e_ew >> 8) & 7 # E/W velocity sign and top bits 299 | frame[6] = (e_ew & 255) # E/W velocity low bits 300 | frame[7] = (e_ns >> 3) & 255 # N/S velocity top bits 301 | frame[8] = (e_ns & 7) << 5 # N/S velocity low bits 302 | frame[8] |= 16 # vertical rate source = baro 303 | frame[8] |= (e_vr >> 6) & 15 # vertical rate top bits 304 | frame[9] = (e_vr & 63) << 2 # vertical rate low bits 305 | frame[10] = 0 # GNSS/Baro alt offset, no data 306 | 307 | # CRC 308 | c = _modes.crc(frame[0:11]) 309 | frame[11] = (c >> 16) & 255 310 | frame[12] = (c >> 8) & 255 311 | frame[13] = c & 255 312 | 313 | return frame 314 | -------------------------------------------------------------------------------- /mlat/client/util.py: -------------------------------------------------------------------------------- 1 | # -*- python -*- 2 | 3 | # Part of mlat-client - an ADS-B multilateration client. 4 | # Copyright 2015, Oliver Jowett 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | import sys 20 | import time 21 | import traceback 22 | 23 | 24 | __all__ = ('log', 'log_exc', 'monotonic_time') 25 | 26 | 27 | suppress_log_timestamps = False 28 | 29 | 30 | def log(msg, *args, **kwargs): 31 | if suppress_log_timestamps: 32 | print(msg.format(*args, **kwargs), file=sys.stderr) 33 | else: 34 | print(time.ctime(), msg.format(*args, **kwargs), file=sys.stderr) 35 | sys.stderr.flush() 36 | 37 | 38 | def log_exc(msg, *args, **kwargs): 39 | if suppress_log_timestamps: 40 | print(msg.format(*args, **kwargs), file=sys.stderr) 41 | else: 42 | print(time.ctime(), msg.format(*args, **kwargs), file=sys.stderr) 43 | traceback.print_exc(file=sys.stderr) 44 | sys.stderr.flush() 45 | 46 | 47 | _adjust = 0 48 | _last = 0 49 | 50 | 51 | def monotonic_time(): 52 | """Emulates time.monotonic() if not available.""" 53 | global _adjust, _last 54 | 55 | now = time.time() 56 | if now < _last: 57 | # system clock went backwards, add in a 58 | # fudge factor so our monotonic clock 59 | # does not. 60 | _adjust = _adjust + (_last - now) 61 | 62 | _last = now 63 | return now + _adjust 64 | 65 | 66 | try: 67 | # try to use the 3.3+ version when available 68 | from time import monotonic as monotonic_time # noqa 69 | except ImportError: 70 | pass 71 | -------------------------------------------------------------------------------- /mlat/client/version.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python; indent-tabs-mode: nil -*- 2 | 3 | # Part of mlat-client - an ADS-B multilateration client. 4 | # Copyright 2015, Oliver Jowett 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """Just a version constant!""" 20 | 21 | CLIENT_VERSION = "0.2.13" 22 | -------------------------------------------------------------------------------- /mlat/constants.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python; indent-tabs-mode: nil -*- 2 | 3 | # Part of mlat-server: a Mode S multilateration server 4 | # Copyright (C) 2015 Oliver Jowett 5 | 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version. 10 | 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Useful constants for unit conversion. 21 | """ 22 | 23 | import math 24 | 25 | # signal propagation speed in metres per second 26 | Cair = 299792458 / 1.0003 27 | 28 | # degrees to radians 29 | DTOR = math.pi / 180.0 30 | # radians to degrees 31 | RTOD = 180.0 / math.pi 32 | 33 | # feet to metres 34 | FTOM = 0.3048 35 | # metres to feet 36 | MTOF = 1.0/FTOM 37 | 38 | # m/s to knots 39 | MS_TO_KTS = 1.9438 40 | 41 | # m/s to fpm 42 | MS_TO_FPM = MTOF * 60 43 | -------------------------------------------------------------------------------- /mlat/geodesy.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python; indent-tabs-mode: nil -*- 2 | 3 | # Part of mlat-server: a Mode S multilateration server 4 | # Copyright (C) 2015 Oliver Jowett 5 | 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version. 10 | 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Utility functions to convert between coordinate systems and calculate distances. 21 | """ 22 | 23 | import math 24 | from .constants import DTOR, RTOD 25 | 26 | # WGS84 ellipsoid Earth parameters 27 | WGS84_A = 6378137.0 28 | WGS84_F = 1.0/298.257223563 29 | WGS84_B = WGS84_A * (1 - WGS84_F) 30 | WGS84_ECC_SQ = 1 - WGS84_B * WGS84_B / (WGS84_A * WGS84_A) 31 | WGS84_ECC = math.sqrt(WGS84_ECC_SQ) 32 | 33 | # Average radius for a spherical Earth 34 | SPHERICAL_R = 6371e3 35 | 36 | # Some derived values 37 | _wgs84_ep = math.sqrt((WGS84_A**2 - WGS84_B**2) / WGS84_B**2) 38 | _wgs84_ep2_b = _wgs84_ep**2 * WGS84_B 39 | _wgs84_e2_a = WGS84_ECC_SQ * WGS84_A 40 | 41 | 42 | def llh2ecef(llh): 43 | """Converts from WGS84 lat/lon/height to ellipsoid-earth ECEF""" 44 | 45 | lat = llh[0] * DTOR 46 | lng = llh[1] * DTOR 47 | alt = llh[2] 48 | 49 | slat = math.sin(lat) 50 | slng = math.sin(lng) 51 | clat = math.cos(lat) 52 | clng = math.cos(lng) 53 | 54 | d = math.sqrt(1 - (slat * slat * WGS84_ECC_SQ)) 55 | rn = WGS84_A / d 56 | 57 | x = (rn + alt) * clat * clng 58 | y = (rn + alt) * clat * slng 59 | z = (rn * (1 - WGS84_ECC_SQ) + alt) * slat 60 | 61 | return (x, y, z) 62 | 63 | 64 | def ecef2llh(ecef): 65 | "Converts from ECEF to WGS84 lat/lon/height" 66 | 67 | x, y, z = ecef 68 | 69 | lon = math.atan2(y, x) 70 | 71 | p = math.sqrt(x**2 + y**2) 72 | th = math.atan2(WGS84_A * z, WGS84_B * p) 73 | lat = math.atan2(z + _wgs84_ep2_b * math.sin(th)**3, 74 | p - _wgs84_e2_a * math.cos(th)**3) 75 | 76 | N = WGS84_A / math.sqrt(1 - WGS84_ECC_SQ * math.sin(lat)**2) 77 | alt = p / math.cos(lat) - N 78 | 79 | return (lat*RTOD, lon*RTOD, alt) 80 | 81 | 82 | def greatcircle(p0, p1): 83 | """Returns a great-circle distance in metres between two LLH points, 84 | _assuming spherical earth_ and _ignoring altitude_. Don't use this if you 85 | need a distance accurate to better than 1%.""" 86 | 87 | lat0 = p0[0] * DTOR 88 | lon0 = p0[1] * DTOR 89 | lat1 = p1[0] * DTOR 90 | lon1 = p1[1] * DTOR 91 | return SPHERICAL_R * math.acos( 92 | math.sin(lat0) * math.sin(lat1) + 93 | math.cos(lat0) * math.cos(lat1) * math.cos(abs(lon0 - lon1))) 94 | 95 | 96 | # direct implementation here turns out to be _much_ faster (10-20x) compared to 97 | # scipy.spatial.distance.euclidean or numpy-based approaches 98 | def ecef_distance(p0, p1): 99 | """Returns the straight-line distance in metres between two ECEF points.""" 100 | return math.sqrt((p0[0] - p1[0])**2 + (p0[1] - p1[1])**2 + (p0[2] - p1[2])**2) 101 | -------------------------------------------------------------------------------- /mlat/profile.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python; indent-tabs-mode: nil -*- 2 | 3 | # Part of mlat-client - an ADS-B multilateration client. 4 | # Copyright 2015, Oliver Jowett 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | import os 20 | 21 | # NB: This requires Python 3.3 when MLAT_CPU_PROFILE is set. 22 | 23 | 24 | if not int(os.environ.get('MLAT_CPU_PROFILE', '0')): 25 | def trackcpu(f, **kwargs): 26 | return f 27 | 28 | def dump_cpu_profiles(): 29 | pass 30 | else: 31 | import sys 32 | import time 33 | import operator 34 | import functools 35 | 36 | _cpu_tracking = [] 37 | print('CPU profiling enabled', file=sys.stderr) 38 | 39 | def trackcpu(f, name=None, **kwargs): 40 | if name is None: 41 | name = f.__module__ + '.' + f.__qualname__ 42 | 43 | print('Profiling:', name, file=sys.stderr) 44 | tracking = [name, 0, 0.0] 45 | _cpu_tracking.append(tracking) 46 | 47 | @functools.wraps(f) 48 | def cpu_measurement_wrapper(*args, **kwargs): 49 | start = time.clock_gettime(time.CLOCK_THREAD_CPUTIME_ID) 50 | try: 51 | return f(*args, **kwargs) 52 | finally: 53 | end = time.clock_gettime(time.CLOCK_THREAD_CPUTIME_ID) 54 | tracking[1] += 1 55 | tracking[2] += (end - start) 56 | 57 | return cpu_measurement_wrapper 58 | 59 | def dump_cpu_profiles(): 60 | print('{rank:4s} {name:60s} {count:6s} {total:8s} {each:8s}'.format( 61 | rank='#', 62 | name='Function', 63 | count='Calls', 64 | total='Total(s)', 65 | each='Each(us)'), file=sys.stderr) 66 | 67 | rank = 1 68 | for name, count, total in sorted(_cpu_tracking, key=operator.itemgetter(2), reverse=True): 69 | if count == 0: 70 | break 71 | 72 | print('{rank:4d} {name:60s} {count:6d} {total:8.3f} {each:8.0f}'.format( 73 | rank=rank, 74 | name=name, 75 | count=count, 76 | total=total, 77 | each=total * 1e6 / count), file=sys.stderr) 78 | rank += 1 79 | 80 | sys.stderr.flush() 81 | -------------------------------------------------------------------------------- /modes_crc.c: -------------------------------------------------------------------------------- 1 | /* 2 | * Part of mlat-client - an ADS-B multilateration client. 3 | * Copyright 2015, Oliver Jowett 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | #include "_modes.h" 20 | 21 | 22 | /********** CRC ****************/ 23 | 24 | /* Generator polynomial for the Mode S CRC */ 25 | #define MODES_GENERATOR_POLY 0xfff409U 26 | 27 | /* CRC values for all single-byte messages; used to speed up CRC calculation. */ 28 | static uint32_t crc_table[256]; 29 | 30 | int modescrc_module_init(PyObject *m) 31 | { 32 | int i; 33 | 34 | for (i = 0; i < 256; ++i) { 35 | uint32_t c = i << 16; 36 | int j; 37 | for (j = 0; j < 8; ++j) { 38 | if (c & 0x800000) 39 | c = (c<<1) ^ MODES_GENERATOR_POLY; 40 | else 41 | c = (c<<1); 42 | } 43 | 44 | crc_table[i] = c & 0x00ffffff; 45 | } 46 | 47 | return 0; 48 | } 49 | 50 | void modescrc_module_free(PyObject *m) 51 | { 52 | } 53 | 54 | uint32_t modescrc_buffer_crc(uint8_t *buf, Py_ssize_t len) 55 | { 56 | uint32_t rem; 57 | Py_ssize_t i; 58 | for (rem = 0, i = len; i > 0; --i) { 59 | rem = ((rem & 0x00ffff) << 8) ^ crc_table[*buf++ ^ ((rem & 0xff0000) >> 16)]; 60 | } 61 | 62 | return rem; 63 | } 64 | 65 | PyObject *modescrc_crc(PyObject *self, PyObject *args) 66 | { 67 | Py_buffer buffer; 68 | PyObject *rv = NULL; 69 | 70 | if (!PyArg_ParseTuple(args, "s*", &buffer)) 71 | return NULL; 72 | 73 | if (buffer.itemsize != 1) { 74 | PyErr_SetString(PyExc_ValueError, "buffer itemsize is not 1"); 75 | goto out; 76 | } 77 | 78 | if (!PyBuffer_IsContiguous(&buffer, 'C')) { 79 | PyErr_SetString(PyExc_ValueError, "buffer is not contiguous"); 80 | goto out; 81 | } 82 | 83 | rv = PyLong_FromLong(modescrc_buffer_crc(buffer.buf, buffer.len)); 84 | 85 | out: 86 | PyBuffer_Release(&buffer); 87 | return rv; 88 | } 89 | -------------------------------------------------------------------------------- /modes_message.c: -------------------------------------------------------------------------------- 1 | /* 2 | * Part of mlat-client - an ADS-B multilateration client. 3 | * Copyright 2015, Oliver Jowett 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | #include "_modes.h" 20 | 21 | /* methods / type behaviour */ 22 | static PyObject *modesmessage_new(PyTypeObject *type, PyObject *args, PyObject *kwds); 23 | static void modesmessage_dealloc(modesmessage *self); 24 | static int modesmessage_init(modesmessage *self, PyObject *args, PyObject *kwds); 25 | static int modesmessage_bf_getbuffer(PyObject *self, Py_buffer *view, int flags); 26 | static Py_ssize_t modesmessage_sq_length(modesmessage *self); 27 | static PyObject *modesmessage_sq_item(modesmessage *self, Py_ssize_t i); 28 | static long modesmessage_hash(PyObject *self); 29 | static PyObject *modesmessage_richcompare(PyObject *self, PyObject *other, int op); 30 | static PyObject *modesmessage_repr(PyObject *self); 31 | static PyObject *modesmessage_str(PyObject *self); 32 | 33 | /* internal helpers */ 34 | static PyObject *decode_ac13(unsigned ac13); 35 | static uint32_t crc_residual(uint8_t *message, int len); 36 | static int decode(modesmessage *self); 37 | 38 | /* modesmessage fields */ 39 | /* todo: these can probably all be read/write */ 40 | static PyMemberDef modesmessageMembers[] = { 41 | { "timestamp", T_ULONGLONG, offsetof(modesmessage, timestamp), 0, "12MHz timestamp" }, /* read/write */ 42 | { "signal", T_UINT, offsetof(modesmessage, signal), READONLY, "signal level" }, 43 | { "df", T_UINT, offsetof(modesmessage, df), READONLY, "downlink format or a special DF_* value" }, 44 | { "nuc", T_UINT, offsetof(modesmessage, nuc), READONLY, "NUCp value" }, 45 | { "even_cpr", T_BOOL, offsetof(modesmessage, even_cpr), READONLY, "CPR even-format flag" }, 46 | { "odd_cpr", T_BOOL, offsetof(modesmessage, odd_cpr), READONLY, "CPR odd-format flag" }, 47 | { "valid", T_BOOL, offsetof(modesmessage, valid), READONLY, "Does the message look OK?" }, 48 | { "crc_residual", T_OBJECT, offsetof(modesmessage, crc), READONLY, "CRC residual" }, 49 | { "address", T_OBJECT, offsetof(modesmessage, address), READONLY, "ICAO address" }, 50 | { "altitude", T_OBJECT, offsetof(modesmessage, altitude), READONLY, "altitude" }, 51 | { "eventdata", T_OBJECT, offsetof(modesmessage, eventdata), READONLY, "event data dictionary for special event messages" }, 52 | { NULL, 0, 0, 0, NULL } 53 | }; 54 | 55 | /* modesmessage buffer protocol */ 56 | static PyBufferProcs modesmessageBufferProcs = { 57 | modesmessage_bf_getbuffer, /* bf_getbuffer */ 58 | NULL /* bf_releasebuffer */ 59 | }; 60 | 61 | /* modesmessage sequence protocol */ 62 | static PySequenceMethods modesmessageSequenceMethods = { 63 | (lenfunc)modesmessage_sq_length, /* sq_length */ 64 | 0, /* sq_concat */ 65 | 0, /* sq_repeat */ 66 | (ssizeargfunc)modesmessage_sq_item, /* sq_item */ 67 | 0, /* sq_ass_item */ 68 | 0, /* sq_contains */ 69 | 0, /* sq_inplace_concat */ 70 | 0, /* sq_inplace_repeat */ 71 | }; 72 | 73 | /* modesmessage type object */ 74 | static PyTypeObject modesmessageType = { 75 | PyVarObject_HEAD_INIT(NULL, 0) 76 | "_modes.Message", /* tp_name */ 77 | sizeof(modesmessage), /* tp_basicsize */ 78 | 0, /* tp_itemsize */ 79 | (destructor)modesmessage_dealloc, /* tp_dealloc */ 80 | 0, /* tp_print */ 81 | 0, /* tp_getattr */ 82 | 0, /* tp_setattr */ 83 | 0, /* tp_reserved */ 84 | (reprfunc)modesmessage_repr, /* tp_repr */ 85 | 0, /* tp_as_number */ 86 | &modesmessageSequenceMethods, /* tp_as_sequence */ 87 | 0, /* tp_as_mapping */ 88 | (hashfunc)modesmessage_hash, /* tp_hash */ 89 | 0, /* tp_call */ 90 | (reprfunc)modesmessage_str, /* tp_str */ 91 | PyObject_GenericGetAttr, /* tp_getattro */ 92 | PyObject_GenericSetAttr, /* tp_setattro */ 93 | &modesmessageBufferProcs, /* tp_as_buffer */ 94 | Py_TPFLAGS_DEFAULT||Py_TPFLAGS_BASETYPE, /* tp_flags */ 95 | "A ModeS message.", /* tp_doc */ 96 | 0, /* tp_traverse */ 97 | 0, /* tp_clear */ 98 | modesmessage_richcompare, /* tp_richcompare */ 99 | 0, /* tp_weaklistoffset */ 100 | 0, /* tp_iter */ 101 | 0, /* tp_iternext */ 102 | 0, /* tp_methods */ 103 | modesmessageMembers, /* tp_members */ 104 | 0, /* tp_getset */ 105 | 0, /* tp_base */ 106 | 0, /* tp_dict */ 107 | 0, /* tp_descr_get */ 108 | 0, /* tp_descr_set */ 109 | 0, /* tp_dictoffset */ 110 | (initproc)modesmessage_init, /* tp_init */ 111 | 0, /* tp_alloc */ 112 | modesmessage_new, /* tp_new */ 113 | }; 114 | 115 | /* 116 | * module setup 117 | */ 118 | int modesmessage_module_init(PyObject *m) 119 | { 120 | if (PyType_Ready(&modesmessageType) < 0) 121 | return -1; 122 | 123 | Py_INCREF(&modesmessageType); 124 | if (PyModule_AddObject(m, "Message", (PyObject *)&modesmessageType) < 0) { 125 | Py_DECREF(&modesmessageType); 126 | return -1; 127 | } 128 | 129 | /* Add DF_* constants */ 130 | if (PyModule_AddIntMacro(m, DF_MODEAC) < 0) 131 | return -1; 132 | 133 | if (PyModule_AddIntMacro(m, DF_EVENT_TIMESTAMP_JUMP) < 0) 134 | return -1; 135 | 136 | if (PyModule_AddIntMacro(m, DF_EVENT_MODE_CHANGE) < 0) 137 | return -1; 138 | 139 | if (PyModule_AddIntMacro(m, DF_EVENT_EPOCH_ROLLOVER) < 0) 140 | return -1; 141 | 142 | if (PyModule_AddIntMacro(m, DF_EVENT_RADARCAPE_STATUS) < 0) 143 | return -1; 144 | 145 | if (PyModule_AddIntMacro(m, DF_EVENT_RADARCAPE_POSITION) < 0) 146 | return -1; 147 | 148 | return 0; 149 | } 150 | 151 | void modesmessage_module_free(PyObject *m) 152 | { 153 | } 154 | 155 | static PyObject *modesmessage_new(PyTypeObject *type, PyObject *args, PyObject *kwds) 156 | { 157 | modesmessage *self; 158 | 159 | self = (modesmessage *)type->tp_alloc(type, 0); 160 | if (!self) 161 | return NULL; 162 | 163 | /* minimal init */ 164 | self->timestamp = 0; 165 | self->signal = 0; 166 | self->df = 0; 167 | self->nuc = 0; 168 | self->even_cpr = self->odd_cpr = 0; 169 | self->valid = 0; 170 | self->crc = NULL; 171 | self->address = NULL; 172 | self->altitude = NULL; 173 | self->data = NULL; 174 | self->datalen = 0; 175 | self->eventdata = NULL; 176 | 177 | return (PyObject *)self; 178 | } 179 | 180 | static void modesmessage_dealloc(modesmessage *self) 181 | { 182 | Py_XDECREF(self->crc); 183 | Py_XDECREF(self->address); 184 | Py_XDECREF(self->altitude); 185 | Py_XDECREF(self->eventdata); 186 | free(self->data); 187 | Py_TYPE(self)->tp_free((PyObject*)self); 188 | } 189 | 190 | /* internal entry point to build a new message from a buffer */ 191 | PyObject *modesmessage_from_buffer(unsigned long long timestamp, unsigned signal, uint8_t *data, int datalen) 192 | { 193 | modesmessage *message; 194 | uint8_t *copydata; 195 | 196 | if (! (message = (modesmessage*)modesmessage_new(&modesmessageType, NULL, NULL))) 197 | goto err; 198 | 199 | /* minimal init so deallocation works */ 200 | message->data = NULL; 201 | 202 | copydata = malloc(datalen); 203 | if (!copydata) { 204 | PyErr_NoMemory(); 205 | goto err; 206 | } 207 | memcpy(copydata, data, datalen); 208 | 209 | message->timestamp = timestamp; 210 | message->signal = signal; 211 | message->data = copydata; 212 | message->datalen = datalen; 213 | 214 | if (decode(message) < 0) 215 | goto err; 216 | 217 | return (PyObject*)message; 218 | 219 | err: 220 | Py_XDECREF(message); 221 | return NULL; 222 | } 223 | 224 | /* internal entry point to build a new event message 225 | * steals a reference from eventdata 226 | */ 227 | PyObject *modesmessage_new_eventmessage(int type, unsigned long long timestamp, PyObject *eventdata) 228 | { 229 | modesmessage *message; 230 | 231 | if (! (message = (modesmessage*)modesmessage_new(&modesmessageType, NULL, NULL))) 232 | return NULL; 233 | 234 | message->df = type; 235 | message->timestamp = timestamp; 236 | message->eventdata = eventdata; 237 | return (PyObject *)message; 238 | } 239 | 240 | /* external entry point to build a new event message from python i.e. _modes.EventMessage(...) */ 241 | PyObject *modesmessage_eventmessage(PyObject *self, PyObject *args, PyObject *kwds) 242 | { 243 | static char *kwlist[] = { "type", "timestamp", "eventdata", NULL }; 244 | int type; 245 | unsigned long long timestamp; 246 | PyObject *eventdata = NULL; 247 | PyObject *rv = NULL; 248 | 249 | if (!PyArg_ParseTupleAndKeywords(args, kwds, "iKO", kwlist, &type, ×tamp, &eventdata)) 250 | return NULL; 251 | 252 | Py_INCREF(eventdata); 253 | if (! (rv = modesmessage_new_eventmessage(type, timestamp, eventdata))) { 254 | Py_DECREF(eventdata); 255 | return NULL; 256 | } 257 | 258 | return rv; 259 | } 260 | 261 | /* external entry point to build a new message from python (i.e. _modes.Message(...)) */ 262 | static int modesmessage_init(modesmessage *self, PyObject *args, PyObject *kwds) 263 | { 264 | static char *kwlist[] = { "data", "timestamp", "signal", NULL }; 265 | Py_buffer data; 266 | int rv = -1; 267 | 268 | if (!PyArg_ParseTupleAndKeywords(args, kwds, "s*|KI", kwlist, &data, &self->timestamp, &self->signal)) 269 | return -1; 270 | 271 | if (data.itemsize != 1) { 272 | PyErr_SetString(PyExc_ValueError, "buffer itemsize is not 1"); 273 | goto out; 274 | } 275 | 276 | if (!PyBuffer_IsContiguous(&data, 'C')) { 277 | PyErr_SetString(PyExc_ValueError, "buffer is not contiguous"); 278 | goto out; 279 | } 280 | 281 | self->datalen = 0; 282 | free(self->data); 283 | 284 | self->data = malloc(data.len); 285 | if (!self->data) { 286 | PyErr_NoMemory(); 287 | goto out; 288 | } 289 | 290 | memcpy(self->data, data.buf, data.len); 291 | self->datalen = data.len; 292 | 293 | rv = decode(self); 294 | 295 | out: 296 | PyBuffer_Release(&data); 297 | return rv; 298 | } 299 | 300 | static PyObject *decode_ac13(unsigned ac13) 301 | { 302 | int h, f, a; 303 | 304 | if (ac13 == 0) 305 | Py_RETURN_NONE; 306 | 307 | if (ac13 & 0x0040) /* M bit */ 308 | Py_RETURN_NONE; 309 | 310 | if (ac13 & 0x0010) { /* Q bit */ 311 | int n = ((ac13 & 0x1f80) >> 2) | ((ac13 & 0x0020) >> 1) | (ac13 & 0x000f); 312 | return PyLong_FromLong(n * 25 - 1000); 313 | } 314 | 315 | /* convert from Gillham code */ 316 | if (! ((ac13 & 0x1500))) { 317 | /* illegal gillham code */ 318 | Py_RETURN_NONE; 319 | } 320 | 321 | h = 0; 322 | if (ac13 & 0x1000) h ^= 7; /* C1 */ 323 | if (ac13 & 0x0400) h ^= 3; /* C2 */ 324 | if (ac13 & 0x0100) h ^= 1; /* C4 */ 325 | 326 | if (h & 5) 327 | h ^= 5; 328 | 329 | if (h > 5) 330 | Py_RETURN_NONE; /* illegal */ 331 | 332 | f = 0; 333 | if (ac13 & 0x0010) f ^= 0x1ff; /* D1 */ 334 | if (ac13 & 0x0004) f ^= 0x0ff; /* D2 */ 335 | if (ac13 & 0x0001) f ^= 0x07f; /* D4 */ 336 | if (ac13 & 0x0800) f ^= 0x03f; /* A1 */ 337 | if (ac13 & 0x0200) f ^= 0x01f; /* A2 */ 338 | if (ac13 & 0x0080) f ^= 0x00f; /* A4 */ 339 | if (ac13 & 0x0020) f ^= 0x007; /* B1 */ 340 | if (ac13 & 0x0008) f ^= 0x003; /* B2 */ 341 | if (ac13 & 0x0002) f ^= 0x001; /* B4 */ 342 | 343 | if (f & 1) 344 | h = (6 - h); 345 | 346 | a = 500 * f + 100 * h - 1300; 347 | if (a < -1200) 348 | Py_RETURN_NONE; /* illegal */ 349 | 350 | return PyLong_FromLong(a); 351 | } 352 | 353 | static PyObject *decode_ac12(unsigned ac12) 354 | { 355 | return decode_ac13(((ac12 & 0x0fc0) << 1) | (ac12 & 0x003f)); 356 | } 357 | 358 | static uint32_t crc_residual(uint8_t *message, int len) 359 | { 360 | uint32_t crc; 361 | 362 | if (len < 3) 363 | return 0; 364 | 365 | crc = modescrc_buffer_crc(message, len - 3); 366 | crc = crc ^ (message[len-3] << 16); 367 | crc = crc ^ (message[len-2] << 8); 368 | crc = crc ^ (message[len-1]); 369 | return crc; 370 | } 371 | 372 | static int decode(modesmessage *self) 373 | { 374 | uint32_t crc; 375 | 376 | /* clear state */ 377 | self->valid = 0; 378 | self->nuc = 0; 379 | self->odd_cpr = self->even_cpr = 0; 380 | Py_CLEAR(self->crc); 381 | Py_CLEAR(self->address); 382 | Py_CLEAR(self->altitude); 383 | 384 | if (self->datalen == 2) { 385 | self->df = DF_MODEAC; 386 | self->address = PyLong_FromLong((self->data[0] << 8) | self->data[1]); 387 | self->valid = 1; 388 | return 0; 389 | } 390 | 391 | self->df = (self->data[0] >> 3) & 31; 392 | 393 | if ((self->df < 16 && self->datalen != 7) || (self->df >= 16 && self->datalen != 14)) { 394 | /* wrong length, no further processing */ 395 | return 0; 396 | } 397 | 398 | if (self->df != 0 && self->df != 4 && self->df != 5 && self->df != 11 && 399 | self->df != 16 && self->df != 17 && self->df != 18 && self->df != 20 && self->df != 21) { 400 | /* we do not know how to handle this message type, no further processing */ 401 | return 0; 402 | } 403 | 404 | crc = crc_residual(self->data, self->datalen); 405 | if (!(self->crc = PyLong_FromLong(crc))) 406 | return -1; 407 | 408 | switch (self->df) { 409 | case 0: 410 | case 4: 411 | case 16: 412 | case 20: 413 | self->address = (Py_INCREF(self->crc), self->crc); 414 | if (! (self->altitude = decode_ac13((self->data[2] & 0x1f) << 8 | (self->data[3])))) 415 | return -1; 416 | self->valid = 1; 417 | break; 418 | 419 | case 5: 420 | case 21: 421 | case 24: 422 | self->address = (Py_INCREF(self->crc), self->crc); 423 | self->valid = 1; 424 | break; 425 | 426 | case 11: 427 | self->valid = ((crc & ~0x7f) == 0); 428 | if (self->valid) { 429 | if (! (self->address = PyLong_FromLong( (self->data[1] << 16) | (self->data[2] << 8) | (self->data[3]) ))) 430 | return -1; 431 | } 432 | break; 433 | 434 | case 17: 435 | case 18: 436 | self->valid = (crc == 0); 437 | if (self->valid) { 438 | unsigned metype; 439 | 440 | if (! (self->address = PyLong_FromLong( (self->data[1] << 16) | (self->data[2] << 8) | (self->data[3]) ))) 441 | return -1; 442 | 443 | metype = self->data[4] >> 3; 444 | if ((metype >= 9 && metype <= 18) || (metype >= 20 && metype < 22)) { 445 | if (metype == 22) 446 | self->nuc = 0; 447 | else if (metype <= 18) 448 | self->nuc = 18 - metype; 449 | else 450 | self->nuc = 29 - metype; 451 | 452 | if (self->data[6] & 0x04) 453 | self->odd_cpr = 1; 454 | else 455 | self->even_cpr = 1; 456 | 457 | if (! (self->altitude = decode_ac12((self->data[5] << 4) | ((self->data[6] & 0xF0) >> 4)))) 458 | return -1; 459 | } 460 | } 461 | break; 462 | 463 | default: 464 | break; 465 | } 466 | 467 | return 0; 468 | } 469 | 470 | static int modesmessage_bf_getbuffer(PyObject *self, Py_buffer *view, int flags) 471 | { 472 | return PyBuffer_FillInfo(view, self, ((modesmessage*)self)->data, ((modesmessage*)self)->datalen, 1, flags); 473 | } 474 | 475 | static Py_ssize_t modesmessage_sq_length(modesmessage *self) 476 | { 477 | return self->datalen; 478 | } 479 | 480 | static PyObject *modesmessage_sq_item(modesmessage *self, Py_ssize_t i) 481 | { 482 | if (i < 0 || i >= self->datalen) { 483 | PyErr_SetString(PyExc_IndexError, "byte index out of range"); 484 | return NULL; 485 | } 486 | 487 | return PyLong_FromLong(self->data[i]); 488 | } 489 | 490 | static long modesmessage_hash(PyObject *self) 491 | { 492 | modesmessage *msg = (modesmessage*)self; 493 | uint32_t hash = 0; 494 | int i; 495 | 496 | /* Jenkins one-at-a-time hash */ 497 | for (i = 0; i < 4 && i < msg->datalen; ++i) { 498 | hash += msg->data[i] & 0xff; 499 | hash += hash << 10; 500 | hash ^= hash >> 6; 501 | } 502 | 503 | hash += (hash << 3); 504 | hash ^= (hash >> 11); 505 | hash += (hash << 15); 506 | 507 | return (long)hash; 508 | } 509 | 510 | static PyObject *modesmessage_richcompare(PyObject *self, PyObject *other, int op) 511 | { 512 | PyObject *result = NULL; 513 | 514 | if (! PyObject_TypeCheck(self, &modesmessageType) || 515 | ! PyObject_TypeCheck(other, &modesmessageType)) { 516 | result = Py_NotImplemented; 517 | } else { 518 | modesmessage *message1 = (modesmessage*)self; 519 | modesmessage *message2 = (modesmessage*)other; 520 | int c = 0; 521 | 522 | switch (op) { 523 | case Py_EQ: 524 | c = (message1->datalen == message2->datalen) && (memcmp(message1->data, message2->data, message1->datalen) == 0); 525 | break; 526 | case Py_NE: 527 | c = (message1->datalen != message2->datalen) || (memcmp(message1->data, message2->data, message1->datalen) != 0); 528 | break; 529 | case Py_LT: 530 | c = (message1->datalen < message2->datalen) || 531 | (message1->datalen == message2->datalen && memcmp(message1->data, message2->data, message1->datalen) < 0); 532 | break; 533 | case Py_LE: 534 | c = (message1->datalen < message2->datalen) || 535 | (message1->datalen == message2->datalen && memcmp(message1->data, message2->data, message1->datalen) <= 0); 536 | break; 537 | case Py_GT: 538 | c = (message1->datalen > message2->datalen) || 539 | (message1->datalen == message2->datalen && memcmp(message1->data, message2->data, message1->datalen) > 0); 540 | break; 541 | case Py_GE: 542 | c = (message1->datalen > message2->datalen) || 543 | (message1->datalen == message2->datalen && memcmp(message1->data, message2->data, message1->datalen) >= 0); 544 | break; 545 | default: 546 | result = Py_NotImplemented; 547 | break; 548 | } 549 | 550 | if (!result) 551 | result = (c ? Py_True : Py_False); 552 | } 553 | 554 | Py_INCREF(result); 555 | return result; 556 | } 557 | 558 | static const char *df_event_name(int df) 559 | { 560 | switch (df) { 561 | case DF_EVENT_TIMESTAMP_JUMP: 562 | return "DF_EVENT_TIMESTAMP_JUMP"; 563 | case DF_EVENT_MODE_CHANGE: 564 | return "DF_EVENT_MODE_CHANGE"; 565 | case DF_EVENT_EPOCH_ROLLOVER: 566 | return "DF_EVENT_EPOCH_ROLLOVER"; 567 | case DF_EVENT_RADARCAPE_STATUS: 568 | return "DF_EVENT_RADARCAPE_STATUS"; 569 | default: 570 | return NULL; 571 | } 572 | } 573 | 574 | static char hexdigit[16] = "0123456789abcdef"; 575 | static PyObject *modesmessage_repr(PyObject *self) 576 | { 577 | modesmessage *message = (modesmessage *)self; 578 | if (message->data) { 579 | char buf[256]; 580 | char *p = buf; 581 | int i; 582 | 583 | for (i = 0; i < message->datalen; ++i) { 584 | *p++ = '\\'; 585 | *p++ = 'x'; 586 | *p++ = hexdigit[(message->data[i] >> 4) & 15]; 587 | *p++ = hexdigit[message->data[i] & 15]; 588 | } 589 | *p++ = 0; 590 | 591 | return PyUnicode_FromFormat("_modes.Message(b'%s',%llu,%u)", buf, (unsigned long long)message->timestamp, (unsigned)message->signal); 592 | } else { 593 | const char *eventname = df_event_name(message->df); 594 | if (eventname) { 595 | return PyUnicode_FromFormat("_modes.EventMessage(_modes.%s,%llu,%R)", 596 | df_event_name(message->df), 597 | (unsigned long long)message->timestamp, 598 | message->eventdata); 599 | } else { 600 | return PyUnicode_FromFormat("_modes.EventMessage(%d,%llu,%R)", 601 | message->df, 602 | (unsigned long long)message->timestamp, 603 | message->eventdata); 604 | } 605 | } 606 | } 607 | 608 | static PyObject *modesmessage_str(PyObject *self) 609 | { 610 | modesmessage *message = (modesmessage *)self; 611 | if (message->data) { 612 | char buf[256]; 613 | char *p = buf; 614 | int i; 615 | 616 | for (i = 0; i < message->datalen; ++i) { 617 | *p++ = hexdigit[(message->data[i] >> 4) & 15]; 618 | *p++ = hexdigit[message->data[i] & 15]; 619 | } 620 | *p++ = 0; 621 | 622 | return PyUnicode_FromString(buf); 623 | } else { 624 | const char *eventname = df_event_name(message->df); 625 | if (eventname) { 626 | return PyUnicode_FromFormat("%s@%llu:%R", 627 | eventname, 628 | (unsigned long long)message->timestamp, 629 | message->eventdata); 630 | } else { 631 | return PyUnicode_FromFormat("DF%d@%llu:%R", 632 | message->df, 633 | (unsigned long long)message->timestamp, 634 | message->eventdata); 635 | } 636 | } 637 | } 638 | 639 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=40.8.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "MlatClient" 7 | authors = [ 8 | {name = "Oliver Jowett", email = "oliver.jowett@flightaware.com"}, 9 | ] 10 | description = "Multilateration client package" 11 | dependencies = [] 12 | # entry-points is only dynamic to make setuptools complain less; 13 | # the setup.py values are identical to the project.scripts values below 14 | dynamic = ["version", "entry-points"] 15 | 16 | [project.scripts] 17 | mlat-client = "mlat.client.cli:main" 18 | fa-mlat-client = "flightaware.client.cli:main" 19 | 20 | [tool.setuptools] 21 | packages = ['mlat', 'mlat.client', 'flightaware', 'flightaware.client'] 22 | 23 | [tool.setuptools.dynamic] 24 | version = { attr = "mlat.client.version.CLIENT_VERSION" } 25 | -------------------------------------------------------------------------------- /run-flake8.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | D=`dirname $0` 3 | flake8 --exclude=.git,__pycache__,build,debian,tools mlat-client fa-mlat-client $D 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Part of mlat-client - an ADS-B multilateration client. 4 | # Copyright 2015, Oliver Jowett 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | from setuptools import setup, Extension 20 | 21 | CLIENT_VERSION = "unknown" 22 | exec(open('mlat/client/version.py').read()) 23 | 24 | setup(name='MlatClient', 25 | description='Multilateration client package', 26 | version=CLIENT_VERSION, 27 | author='Oliver Jowett', 28 | author_email='oliver.jowett@flightaware.com', 29 | packages=['mlat', 'mlat.client', 'flightaware', 'flightaware.client'], 30 | ext_modules=[ 31 | Extension('_modes', 32 | include_dirs=["."], 33 | sources=['_modes.c', 'modes_reader.c', 'modes_message.c', 'modes_crc.c'])], 34 | entry_points={ 35 | 'console_scripts': [ 36 | 'mlat-client = mlat.client.cli:main', 37 | 'fa-mlat-client = flightaware.client.cli:main' 38 | ]} 39 | ) 40 | -------------------------------------------------------------------------------- /tools/compare-message-timing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # build/install the _modes module: 4 | # $ python3 ./setup.py install --user 5 | # 6 | # run the script, passing hostnames for 2 or more receivers: 7 | # $ ./compare-message-timing.py host1 host2 8 | # 9 | # output should start after around 20 seconds; 10 | # the output format is TSV with a pair of columns for each 11 | # receiver: time (seconds) and offset from the first receiver time 12 | # (nanoseconds) 13 | 14 | import sys 15 | import traceback 16 | import asyncio 17 | import concurrent.futures 18 | 19 | import _modes 20 | 21 | class Receiver(object): 22 | def __init__(self, *, loop, id, host, port, correlator): 23 | self.loop = loop 24 | self.id = id 25 | self.host = host 26 | self.port = port 27 | 28 | self.parser = _modes.Reader(_modes.BEAST) 29 | self.parser.default_filter = [False] * 32 30 | self.parser.default_filter[17] = True 31 | 32 | self.frequency = None 33 | self.task = asyncio.async(self.handle_connection()) 34 | self.correlator = correlator 35 | 36 | def __str__(self): 37 | return 'client #{0} ({1}:{2})'.format(self.id, self.host, self.port) 38 | 39 | @asyncio.coroutine 40 | def handle_connection(self): 41 | try: 42 | reader, writer = yield from asyncio.open_connection(self.host, self.port) 43 | print(self, 'connected', file=sys.stderr) 44 | 45 | # Binary format, no filters, CRC checks enabled, mode A/C disabled 46 | writer.write(b'\x1a1C\x1a1d\x1a1f\x1a1j') 47 | 48 | self.loop.call_later(5.0, self.set_default_freq) 49 | 50 | data = b'' 51 | while True: 52 | moredata = yield from reader.read(4096) 53 | if len(moredata) == 0: 54 | break 55 | 56 | data += moredata 57 | consumed, messages, pending_error = self.parser.feed(data) 58 | data = data[consumed:] 59 | self.handle_messages(messages) 60 | 61 | if pending_error: 62 | self.parser.feed(self.data) 63 | 64 | print(self, 'connection closed', file=sys.stderr) 65 | writer.close() 66 | except concurrent.futures.CancelledError: 67 | pass 68 | except Exception: 69 | print(self, 'unexpected exception', file=sys.stderr) 70 | traceback.print_exc(file=sys.stderr) 71 | 72 | def set_default_freq(self): 73 | if self.frequency is None: 74 | print(self, 'assuming 12MHz clock frequency', file=sys.stderr) 75 | self.frequency = 12e6 76 | 77 | def handle_messages(self, messages): 78 | for message in messages: 79 | if message.df == _modes.DF_EVENT_MODE_CHANGE: 80 | self.frequency = message.eventdata['frequency'] 81 | print(self, 'clock frequency changed to {0:.0f}MHz'.format(self.frequency/1e6), file=sys.stderr) 82 | elif message.df == 17 and (message.even_cpr or message.odd_cpr) and self.frequency is not None: 83 | self.correlator(self.id, self.frequency, message) 84 | 85 | 86 | class Correlator(object): 87 | def __init__(self, loop): 88 | self.loop = loop 89 | self.pending = {} 90 | self.clients = set() 91 | self.sorted_clients = [] 92 | self.base_times = None 93 | 94 | def add_client(self, client_id): 95 | self.clients.add(client_id) 96 | self.sorted_clients = sorted(self.clients) 97 | 98 | def correlate(self, client_id, frequency, message): 99 | if message in self.pending: 100 | self.pending[message].append((client_id, frequency, message)) 101 | else: 102 | self.pending[message] = [ (client_id, frequency, message) ] 103 | self.loop.call_later(10.0, self.resolve, message) 104 | 105 | def resolve(self, message): 106 | copies = self.pending.pop(message) 107 | client_times = {} 108 | for client_id, frequency, message_copy in copies: 109 | if client_id in client_times: 110 | # occurs multiple times, ambiguous, skip it 111 | return 112 | client_times[client_id] = float(message_copy.timestamp) / frequency 113 | 114 | if set(client_times.keys()) == self.clients: 115 | # all clients saw this message 116 | 117 | if self.base_times is None: 118 | # first matching message, record baseline timestamps 119 | self.base_times = client_times 120 | 121 | line = '' 122 | ref_time = client_times[self.sorted_clients[0]] - self.base_times[self.sorted_clients[0]] 123 | for client_id in self.sorted_clients: 124 | this_time = client_times[client_id] - self.base_times[client_id] 125 | offset = this_time - ref_time 126 | line += '{t:.9f}\t{o:.0f}\t'.format(t = this_time, o = offset * 1e9) 127 | print(line[:-1]) 128 | sys.stdout.flush() 129 | 130 | 131 | if __name__ == '__main__': 132 | if len(sys.argv) < 3: 133 | print('usage: {0} host1 host2 ...'.format(sys.argv[0])) 134 | sys.exit(1) 135 | 136 | loop = asyncio.get_event_loop() 137 | correlator = Correlator(loop) 138 | 139 | clients = [] 140 | for i in range(1, len(sys.argv)): 141 | client_id = str(i) 142 | clients.append(Receiver(loop = loop, id = client_id, host = sys.argv[i], port = 30005, correlator = correlator.correlate)) 143 | correlator.add_client(client_id) 144 | 145 | tasks = [client.task for client in clients] 146 | try: 147 | done, pending = loop.run_until_complete(asyncio.wait(tasks, return_when = asyncio.FIRST_COMPLETED)) 148 | except KeyboardInterrupt: 149 | pending = tasks 150 | 151 | for task in pending: 152 | task.cancel() 153 | loop.run_until_complete(asyncio.gather(*pending)) 154 | 155 | loop.close() 156 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=120 3 | --------------------------------------------------------------------------------