From: Eugene Crosser Date: Fri, 8 Jul 2022 10:36:33 +0000 (+0200) Subject: rename gps303 -> loctrkd X-Git-Tag: 1.90~37 X-Git-Url: http://www.average.org/gitweb/?p=loctrkd.git;a=commitdiff_plain;h=dbdf9d63af31770ad57302e16b17a2fdc526773f rename gps303 -> loctrkd --- diff --git a/debian/changelog b/debian/changelog index 31b26ed..006574e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -gps303 (1.02) experimental; urgency=medium +loctrkd (1.02) experimental; urgency=medium * collector: prevent two active clients share IMEI @@ -19,7 +19,7 @@ gps303 (1.00) experimental; urgency=medium -- Eugene Crosser Thu, 23 Jun 2022 22:58:35 +0200 -gps303 (0.99) experimental; urgency=medium +loctrkd (0.99) experimental; urgency=medium * Revive mkgpx example script * Drop data if we are receiving junk @@ -41,7 +41,7 @@ gps303 (0.99) experimental; urgency=medium -- Eugene Crosser Wed, 22 Jun 2022 18:04:10 +0200 -gps303 (0.98) experimental; urgency=medium +loctrkd (0.98) experimental; urgency=medium * include runtime deps as build-time for typecheck * fix l3str/l3int breakage provoked by typeckeck @@ -54,19 +54,19 @@ gps303 (0.98) experimental; urgency=medium -- Eugene Crosser Tue, 07 Jun 2022 00:17:55 +0200 -gps303 (0.97) experimental; urgency=medium +loctrkd (0.97) experimental; urgency=medium * adjust tests * typecheck: skip test if mypy verison < 0.942 -- Eugene Crosser Tue, 31 May 2022 01:05:39 +0200 -gps303 (0.96) experimental; urgency=medium +loctrkd (0.96) experimental; urgency=medium * Do not write startup message for command-line cmds * Add a (short) man page * typing: make zmsg.py typecheck - * typing: annotate gps303proto.py (mostly) + * typing: annotate loctrkdproto.py (mostly) * typechecking: less hacky deal with dynamic attrs * typing: annotate opencellid.py * typchecking: annotate googlemaps.py @@ -78,20 +78,20 @@ gps303 (0.96) experimental; urgency=medium * typeckecking: annotate storage.py * typing: annotate lookaside.py * typechecking: annotate collector.py - * typeckeck: annotate __main__ and fix gps303proto + * typeckeck: annotate __main__ and fix loctrkdproto * typeckecking: annotate wsgateway.py * formatting: revive black formatting * unittest: type checking and formatting -- Eugene Crosser Tue, 31 May 2022 00:36:33 +0200 -gps303 (0.95) experimental; urgency=medium +loctrkd (0.95) experimental; urgency=medium * Quick fix for a missing variable -- Eugene Crosser Thu, 26 May 2022 19:32:44 +0200 -gps303 (0.94) experimental; urgency=medium +loctrkd (0.94) experimental; urgency=medium * Opencellid download service * Improve error handling of the downloader @@ -101,7 +101,7 @@ gps303 (0.94) experimental; urgency=medium -- Eugene Crosser Thu, 26 May 2022 19:25:40 +0200 -gps303 (0.93) experimental; urgency=medium +loctrkd (0.93) experimental; urgency=medium * use fixed github action * try to make github actions work @@ -113,7 +113,7 @@ gps303 (0.93) experimental; urgency=medium -- Eugene Crosser Tue, 24 May 2022 22:53:13 +0200 -gps303 (0.92) experimental; urgency=low +loctrkd (0.92) experimental; urgency=low [ Eugene Crosser ] * "When you start a pattern with *, you must use quotes." @@ -125,14 +125,14 @@ gps303 (0.92) experimental; urgency=low -- Eugene Crosser Mon, 23 May 2022 23:55:46 +0200 -gps303 (0.91) experimental; urgency=low +loctrkd (0.91) experimental; urgency=low [ Eugene Crosser ] * fix log message using unknown variable -- Eugene Crosser Tue, 10 May 2022 09:42:30 +0200 -gps303 (0.90) experimental; urgency=low +loctrkd (0.90) experimental; urgency=low [ Eugene Crosser ] * Expand README @@ -150,7 +150,7 @@ gps303 (0.90) experimental; urgency=low -- Eugene Crosser Tue, 10 May 2022 09:09:08 +0200 -gps303 (0.01) experimental; urgency=low +loctrkd (0.01) experimental; urgency=low [ Eugene Crosser ] * adjust debianization to wsgateway @@ -181,7 +181,7 @@ gps303 (0.01) experimental; urgency=low * update gitignore debianization * debianize * add command line script - * cleanup of gps303proto + * cleanup of loctrkdproto * update mkgpx to the new api * export all classes * remove forgotten make_packet() call @@ -211,7 +211,7 @@ gps303 (0.01) experimental; urgency=low * initial storage service * make collector.py work * WIP to reorganise to microservices - * rename protocol module to "gps303proto" + * rename protocol module to "loctrkdproto" * Initial version of zmq based architecture * drop unresolvable points in mkgpx * add lookaside module and opencellid lookup diff --git a/debian/control b/debian/control index f9d0a55..329bf70 100644 --- a/debian/control +++ b/debian/control @@ -1,10 +1,10 @@ -Source: gps303 +Source: loctrkd Maintainer: Eugene Crosser Section: misc Priority: optional Standards-Version: 4.5.1 X-Python-Version: >= 3.6 -Homepage: http://www.average.org/gps303 +Homepage: http://www.average.org/loctrkd Build-Depends: black, debhelper-compat (= 12), dh-python, @@ -15,7 +15,7 @@ Build-Depends: black, python3-wsproto, python3-zmq, -Package: python3-gps303 +Package: python3-loctrkd Architecture: all Section: python Depends: adduser, diff --git a/debian/gps303.collector.service b/debian/gps303.collector.service deleted file mode 100644 index 0f70acd..0000000 --- a/debian/gps303.collector.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=GPS303 Collector Service -PartOf=gps303.target - -[Service] -Type=simple -EnvironmentFile=-/etc/default/gps303 -ExecStart=python3 -m gps303.collector $OPTIONS -KillSignal=INT -Restart=on-failure -StandardOutput=journal -StandardError=inherit -User=gps303 -Group=gps303 - -[Install] -WantedBy=gps303.target diff --git a/debian/gps303.conf b/debian/gps303.conf deleted file mode 100644 index 087d5eb..0000000 --- a/debian/gps303.conf +++ /dev/null @@ -1,50 +0,0 @@ -[collector] -# configure your gps terminal with this SMS: -# "server##4303#" -port = 4303 -publishurl = ipc:///var/lib/gps303/collected -listenurl = ipc:///var/lib/gps303/responses -# comma-separated list of tracker protocols to accept -protocols = zx303proto - -[wsgateway] -port = 5049 -htmlfile = /var/lib/gps303/index.html - -[storage] -dbfn = /var/lib/gps303/trkloc.sqlite - -[lookaside] -# "opencellid" and "googlemaps" can be here. Both require an access token, -# though googlemaps is only online, while opencellid backend looks up a -# local database, that can be updated once a week or once a month. -backend = opencellid - -[opencellid] -dbfn = /var/lib/opencellid/opencellid.sqlite -# for testing: run `python -m http.server --directory ` and use this: -# downloadurl = http://localhost:8000/.csv.gz -# then the next two statements will be ignored -downloadtoken = /var/lib/opencellid/opencellid.token -downloadmcc = 262 - -[termconfig] -statusIntervalMinutes = 25 -uploadIntervalSeconds = 0x0300 -binarySwitch = 0b00110001 -alarms = - 0 - 0 - 0 -dndTimeSwitch = 0 -dndTimes = - 0 - 0 - 0 -gpsTimeSwitch = 0 -gpsTimeStart = 0 -gpsTimeStop = 0 -phoneNumbers = - "" - "" - "" diff --git a/debian/gps303.default b/debian/gps303.default deleted file mode 100644 index ed0c0cc..0000000 --- a/debian/gps303.default +++ /dev/null @@ -1,5 +0,0 @@ -# Environment for gps303 suite -# Common command-line option for all daemons -#OPTIONS="-c /etc/gps303.conf -d" -# Which lookaside service to use: opencellid or googleloc -LOOKASIDE=opencellid diff --git a/debian/gps303.lookaside.service b/debian/gps303.lookaside.service deleted file mode 100644 index 058e048..0000000 --- a/debian/gps303.lookaside.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=GPS303 Lookaside Service -PartOf=gps303.target - -[Service] -Type=simple -EnvironmentFile=-/etc/default/gps303 -ExecStart=python3 -m gps303.lookaside $OPTIONS -KillSignal=INT -Restart=on-failure -StandardOutput=journal -StandardError=inherit -User=gps303 -Group=gps303 - -[Install] -WantedBy=gps303.target diff --git a/debian/gps303.ocid-dload.service b/debian/gps303.ocid-dload.service deleted file mode 100644 index 212fb49..0000000 --- a/debian/gps303.ocid-dload.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=GPS303 OpenCellID Download Service -Wants=gps303.ocid-dload.timer - -[Service] -Type=oneshot -EnvironmentFile=-/etc/default/gps303 -ExecStart=python3 -m gps303.ocid_dload $OPTIONS -StandardOutput=journal -StandardError=inherit -User=gps303 -Group=gps303 diff --git a/debian/gps303.ocid-dload.timer b/debian/gps303.ocid-dload.timer deleted file mode 100644 index 5730f65..0000000 --- a/debian/gps303.ocid-dload.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Timer For GPS303 OpenCellID Download Service -Requires=gps303.ocid-dload.service - -[Timer] -Unit=gps303.ocid-dload.service -OnCalendar=Weekly - -[Install] -WantedBy=timers.target diff --git a/debian/gps303.storage.service b/debian/gps303.storage.service deleted file mode 100644 index 7266b14..0000000 --- a/debian/gps303.storage.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=GPS303 Storage Service -PartOf=gps303.target - -[Service] -Type=simple -EnvironmentFile=-/etc/default/gps303 -ExecStart=python3 -m gps303.storage $OPTIONS -KillSignal=INT -Restart=on-failure -StandardOutput=journal -StandardError=inherit -User=gps303 -Group=gps303 - -[Install] -WantedBy=gps303.target diff --git a/debian/gps303.target b/debian/gps303.target deleted file mode 100644 index ac1de37..0000000 --- a/debian/gps303.target +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=GPS303 support suite -Requires=gps303.collector.service \ - gps303.storage.service \ - gps303.termconfig.service \ - gps303.lookaside.service \ - gps303.wsgateway.service -After=gps303.collector.service \ - gps303.storage.service \ - gps303.termconfig.service \ - gps303.lookaside.service \ - gps303.wsgateway.service - -[Install] -WantedBy=multi-user.target diff --git a/debian/gps303.termconfig.service b/debian/gps303.termconfig.service deleted file mode 100644 index 0e75f89..0000000 --- a/debian/gps303.termconfig.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=GPS303 Termconfig Service -PartOf=gps303.target - -[Service] -Type=simple -EnvironmentFile=-/etc/default/gps303 -ExecStart=python3 -m gps303.termconfig $OPTIONS -KillSignal=INT -Restart=on-failure -StandardOutput=journal -StandardError=inherit -User=gps303 -Group=gps303 - -[Install] -WantedBy=gps303.target diff --git a/debian/gps303.wsgateway.service b/debian/gps303.wsgateway.service deleted file mode 100644 index 3072f3b..0000000 --- a/debian/gps303.wsgateway.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=GPS303 Websocket Gateway Service -PartOf=gps303.target - -[Service] -Type=simple -EnvironmentFile=-/etc/default/gps303 -ExecStart=python3 -m gps303.wsgateway $OPTIONS -KillSignal=INT -Restart=on-failure -StandardOutput=journal -StandardError=inherit -User=gps303 -Group=gps303 - -[Install] -WantedBy=gps303.target diff --git a/debian/install b/debian/install index 19a4586..44b5dd1 100644 --- a/debian/install +++ b/debian/install @@ -1,2 +1,2 @@ -debian/gps303.conf etc/ -webdemo/index.html var/lib/gps303/ +debian/loctrkd.conf etc/ +webdemo/index.html var/lib/loctrkd/ diff --git a/debian/lintian-overrides b/debian/lintian-overrides index f349998..b14c41e 100644 --- a/debian/lintian-overrides +++ b/debian/lintian-overrides @@ -1,4 +1,4 @@ -python3-gps303: systemd-service-file-refers-to-unusual-wantedby-target gps303.target [lib/systemd/system/gps303.collector.service] -python3-gps303: systemd-service-file-refers-to-unusual-wantedby-target gps303.target [lib/systemd/system/gps303.lookaside.service] -python3-gps303: systemd-service-file-refers-to-unusual-wantedby-target gps303.target [lib/systemd/system/gps303.storage.service] -python3-gps303: systemd-service-file-refers-to-unusual-wantedby-target gps303.target [lib/systemd/system/gps303.termconfig.service] +python3-loctrkd: systemd-service-file-refers-to-unusual-wantedby-target loctrkd.target [lib/systemd/system/loctrkd.collector.service] +python3-loctrkd: systemd-service-file-refers-to-unusual-wantedby-target loctrkd.target [lib/systemd/system/loctrkd.lookaside.service] +python3-loctrkd: systemd-service-file-refers-to-unusual-wantedby-target loctrkd.target [lib/systemd/system/loctrkd.storage.service] +python3-loctrkd: systemd-service-file-refers-to-unusual-wantedby-target loctrkd.target [lib/systemd/system/loctrkd.termconfig.service] diff --git a/debian/loctrkd.collector.service b/debian/loctrkd.collector.service new file mode 100644 index 0000000..11d1311 --- /dev/null +++ b/debian/loctrkd.collector.service @@ -0,0 +1,17 @@ +[Unit] +Description=GPS303 Collector Service +PartOf=loctrkd.target + +[Service] +Type=simple +EnvironmentFile=-/etc/default/loctrkd +ExecStart=python3 -m loctrkd.collector $OPTIONS +KillSignal=INT +Restart=on-failure +StandardOutput=journal +StandardError=inherit +User=loctrkd +Group=loctrkd + +[Install] +WantedBy=loctrkd.target diff --git a/debian/loctrkd.conf b/debian/loctrkd.conf new file mode 100644 index 0000000..d22214d --- /dev/null +++ b/debian/loctrkd.conf @@ -0,0 +1,50 @@ +[collector] +# configure your gps terminal with this SMS: +# "server##4303#" +port = 4303 +publishurl = ipc:///var/lib/loctrkd/collected +listenurl = ipc:///var/lib/loctrkd/responses +# comma-separated list of tracker protocols to accept +protocols = zx303proto + +[wsgateway] +port = 5049 +htmlfile = /var/lib/loctrkd/index.html + +[storage] +dbfn = /var/lib/loctrkd/trkloc.sqlite + +[lookaside] +# "opencellid" and "googlemaps" can be here. Both require an access token, +# though googlemaps is only online, while opencellid backend looks up a +# local database, that can be updated once a week or once a month. +backend = opencellid + +[opencellid] +dbfn = /var/lib/opencellid/opencellid.sqlite +# for testing: run `python -m http.server --directory ` and use this: +# downloadurl = http://localhost:8000/.csv.gz +# then the next two statements will be ignored +downloadtoken = /var/lib/opencellid/opencellid.token +downloadmcc = 262 + +[termconfig] +statusIntervalMinutes = 25 +uploadIntervalSeconds = 0x0300 +binarySwitch = 0b00110001 +alarms = + 0 + 0 + 0 +dndTimeSwitch = 0 +dndTimes = + 0 + 0 + 0 +gpsTimeSwitch = 0 +gpsTimeStart = 0 +gpsTimeStop = 0 +phoneNumbers = + "" + "" + "" diff --git a/debian/loctrkd.default b/debian/loctrkd.default new file mode 100644 index 0000000..7b91fc7 --- /dev/null +++ b/debian/loctrkd.default @@ -0,0 +1,5 @@ +# Environment for loctrkd suite +# Common command-line option for all daemons +#OPTIONS="-c /etc/loctrkd.conf -d" +# Which lookaside service to use: opencellid or googleloc +LOOKASIDE=opencellid diff --git a/debian/loctrkd.lookaside.service b/debian/loctrkd.lookaside.service new file mode 100644 index 0000000..0be3186 --- /dev/null +++ b/debian/loctrkd.lookaside.service @@ -0,0 +1,17 @@ +[Unit] +Description=GPS303 Lookaside Service +PartOf=loctrkd.target + +[Service] +Type=simple +EnvironmentFile=-/etc/default/loctrkd +ExecStart=python3 -m loctrkd.lookaside $OPTIONS +KillSignal=INT +Restart=on-failure +StandardOutput=journal +StandardError=inherit +User=loctrkd +Group=loctrkd + +[Install] +WantedBy=loctrkd.target diff --git a/debian/loctrkd.ocid-dload.service b/debian/loctrkd.ocid-dload.service new file mode 100644 index 0000000..9741336 --- /dev/null +++ b/debian/loctrkd.ocid-dload.service @@ -0,0 +1,12 @@ +[Unit] +Description=GPS303 OpenCellID Download Service +Wants=loctrkd.ocid-dload.timer + +[Service] +Type=oneshot +EnvironmentFile=-/etc/default/loctrkd +ExecStart=python3 -m loctrkd.ocid_dload $OPTIONS +StandardOutput=journal +StandardError=inherit +User=loctrkd +Group=loctrkd diff --git a/debian/loctrkd.ocid-dload.timer b/debian/loctrkd.ocid-dload.timer new file mode 100644 index 0000000..34b0509 --- /dev/null +++ b/debian/loctrkd.ocid-dload.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Timer For GPS303 OpenCellID Download Service +Requires=loctrkd.ocid-dload.service + +[Timer] +Unit=loctrkd.ocid-dload.service +OnCalendar=Weekly + +[Install] +WantedBy=timers.target diff --git a/debian/loctrkd.storage.service b/debian/loctrkd.storage.service new file mode 100644 index 0000000..e890aa3 --- /dev/null +++ b/debian/loctrkd.storage.service @@ -0,0 +1,17 @@ +[Unit] +Description=GPS303 Storage Service +PartOf=loctrkd.target + +[Service] +Type=simple +EnvironmentFile=-/etc/default/loctrkd +ExecStart=python3 -m loctrkd.storage $OPTIONS +KillSignal=INT +Restart=on-failure +StandardOutput=journal +StandardError=inherit +User=loctrkd +Group=loctrkd + +[Install] +WantedBy=loctrkd.target diff --git a/debian/loctrkd.target b/debian/loctrkd.target new file mode 100644 index 0000000..50b71c0 --- /dev/null +++ b/debian/loctrkd.target @@ -0,0 +1,15 @@ +[Unit] +Description=GPS303 support suite +Requires=loctrkd.collector.service \ + loctrkd.storage.service \ + loctrkd.termconfig.service \ + loctrkd.lookaside.service \ + loctrkd.wsgateway.service +After=loctrkd.collector.service \ + loctrkd.storage.service \ + loctrkd.termconfig.service \ + loctrkd.lookaside.service \ + loctrkd.wsgateway.service + +[Install] +WantedBy=multi-user.target diff --git a/debian/loctrkd.termconfig.service b/debian/loctrkd.termconfig.service new file mode 100644 index 0000000..d91c985 --- /dev/null +++ b/debian/loctrkd.termconfig.service @@ -0,0 +1,17 @@ +[Unit] +Description=GPS303 Termconfig Service +PartOf=loctrkd.target + +[Service] +Type=simple +EnvironmentFile=-/etc/default/loctrkd +ExecStart=python3 -m loctrkd.termconfig $OPTIONS +KillSignal=INT +Restart=on-failure +StandardOutput=journal +StandardError=inherit +User=loctrkd +Group=loctrkd + +[Install] +WantedBy=loctrkd.target diff --git a/debian/loctrkd.wsgateway.service b/debian/loctrkd.wsgateway.service new file mode 100644 index 0000000..bf83da8 --- /dev/null +++ b/debian/loctrkd.wsgateway.service @@ -0,0 +1,17 @@ +[Unit] +Description=GPS303 Websocket Gateway Service +PartOf=loctrkd.target + +[Service] +Type=simple +EnvironmentFile=-/etc/default/loctrkd +ExecStart=python3 -m loctrkd.wsgateway $OPTIONS +KillSignal=INT +Restart=on-failure +StandardOutput=journal +StandardError=inherit +User=loctrkd +Group=loctrkd + +[Install] +WantedBy=loctrkd.target diff --git a/debian/manpages b/debian/manpages index ef6d95c..8276d2f 100644 --- a/debian/manpages +++ b/debian/manpages @@ -1,2 +1,2 @@ -docs/gps303.1 -docs/gps303.conf.5 +docs/loctrkd.1 +docs/loctrkd.conf.5 diff --git a/debian/postinst b/debian/postinst index 84624e5..7cfe04c 100644 --- a/debian/postinst +++ b/debian/postinst @@ -1,8 +1,8 @@ #!/bin/sh set -e -adduser --system --group --home /var/lib/gps303 gps303 -install --owner gps303 --group gps303 --directory /var/lib/opencellid +adduser --system --group --home /var/lib/loctrkd loctrkd +install --owner loctrkd --group loctrkd --directory /var/lib/opencellid #DEBHELPER# diff --git a/debian/rules b/debian/rules index e2f6e32..b35833a 100755 --- a/debian/rules +++ b/debian/rules @@ -1,6 +1,6 @@ #!/usr/bin/make -f -export PYBUILD_NAME=gps303 +export PYBUILD_NAME=loctrkd #export PYBUILD_BEFORE_TEST=cp -r mypystubs {build_dir} #export PYBUILD_AFTER_TEST=rm -rf {build_dir}/mypystubs @@ -9,10 +9,10 @@ export PYBUILD_NAME=gps303 override_dh_installsystemd: - dh_installsystemd --name=gps303 - dh_installsystemd --name=gps303.collector - dh_installsystemd --name=gps303.storage - dh_installsystemd --name=gps303.lookaside - dh_installsystemd --name=gps303.termconfig - dh_installsystemd --name=gps303.wsgateway - dh_installsystemd --name=gps303.ocid-dload + dh_installsystemd --name=loctrkd + dh_installsystemd --name=loctrkd.collector + dh_installsystemd --name=loctrkd.storage + dh_installsystemd --name=loctrkd.lookaside + dh_installsystemd --name=loctrkd.termconfig + dh_installsystemd --name=loctrkd.wsgateway + dh_installsystemd --name=loctrkd.ocid-dload diff --git a/docs/gps303.1 b/docs/gps303.1 deleted file mode 100644 index 4df4d31..0000000 --- a/docs/gps303.1 +++ /dev/null @@ -1,59 +0,0 @@ -.TH GPS303 1 2022-05-27 "GPS303 Manipulation Tool" "User Commands" - -.SH NAME - -gps303 \- utility to send control messages to the GPS tracker terminal - -.SH SYNOPSIS - -gps303 [-c /path/to/config/file] [-d] - -.SH DESCRIPTION - -Command line tool to send "unsolicited reply" messages to the tracker. -There are messages to configure settings in the terminal, or to request -actions, such as to start a monitoring call, or to power off. - -.SH OPTIONS - -.TP 0.5i -.B -d -Set "debug" level of logging - -.TP 0.5i -.B -c /path/to/config -Location of the configuration file (default /etc/gps303.conf) - -.SH COMMANDS - -After the options, IMEI (16 decimal characters) and the command verb -must be specified, optionally followed by a list of -.B key=value -pairs. The command verb is a case insensitive abbreviation that -uniquely identifies a class from the file gps303/gps303proto.py. -These classes correspond to message types recognized by the terminal. -Only a few of them are useful to construct "unsolicited replies". -.B key=value -pairs must specify kwargs for the constructor of the "Out" subclass -of the class. - -.SH KNOWN BUGS - -The command sends the message to the "collector" daemon for sending -to the terminal. There is no guarantee that the terminal is online -at the moment of execution, and there is no feedback. The only way -to verify that the command was successfully sent is to check the log -of the collector. - -.SH COPYRIGHT - -The program is copyrighted by Eugene G. Crosser -and freely distributable under the terms of MIT license. - -.SH CREDITS - -The program is inspired by the project petGPS by Thomas Obadia - -.SH SEE ALSO - -.BR gps303.conf (5) diff --git a/docs/gps303.conf.5 b/docs/gps303.conf.5 deleted file mode 100644 index 877088c..0000000 --- a/docs/gps303.conf.5 +++ /dev/null @@ -1,183 +0,0 @@ -.TH GPS303.CONF 5 2022-05-27 "GPS303 Manipulation Tool" "File Formats Manual" - -.SH NAME - -gps303.conf \- Configuration file for GPS303 server - -.SH DESCRIPTION - -Services that consitute -.BR gps303 (1) -suite read configuration data from -.B /etc/gps303\&.conf -.PP -The file in -.B .ini -format contains several service\&-specific sections and optional -device\&-specific sections. -.TP -.B [collector] -\- defines interface points of the -.B collector -daemon. -.TP -.B [wsgateway] -\- defines websockets listen port and optionally the location of the -.B .html -file to serve when a non-websocket request is received. -.TP -.B [storage] -\- defines location of -.BR sqlite3 (1) -database file where events are stored. -.TP -.B [lookaside] -\- defines which backend will be used. -.TP -.B [opencellid] -\- defines location of -.BR sqlite3 (1) -database file with cell tower coordinates and how to download it. -.TP -.B [googlemaps] -\- defines the location of google API access token. -.TP -.BR [termconfig] " and sections titled after terminals' IMEIs -\- defines parameters to be sent to configure the terminals. -.PP -Section contain the following parameters: -.SS [collector] -.TP -.B port -(integer) \- TCP port to listen for terminal connections. Default -.BR 4303 . -.TP -.B publishurl -(string) \- Zeromq "pub" socket where events are published. Default -.BR ipc:///var/lib/gps303/collected . -.TP -.B listenurl -(string) \- Zeromq "pull" socket for messages to be sent to the terminal. -Default -.BR ipc:///var/lib/gps303/responses . -.SS [wsgateway] -.TP -.B port -(integer) \- TCP port to listen for websocket connections. Default -.BR 5049 . -.TP -.B htmlfile -(string) \- path to the -.B .html -file to be served for -.IR non "-websocket requests. Default -.BR /var/lib/gps303/index.html . -.SS [storage] -.TP -.B dbfn -(string) \- location of the database file where events are stored. -.SS [lookaside] -.TP -.B backend -(string) \- either -.B opencellid -or -.B googlemaps -to select which location service to use. Googlemaps is a realtime service, -which means that you are sending location of your clients to Google. -Opencellid resolves location against a local database of cell towers, that -can be updated from time to time (e.g. once in a week or in a month). -This source does not contain WiFi access point locations, and therefore -may be less accurate. Default -.BR opencellid . -.SS [opencellid] -.TP -.B dbfn -(string) \- location of the database file with cell tower locations. -Default -.BR /var/lib/opencellid/opencellid.sqlite . -.TP -.B downloadtoken -(string) \- location of the file that contains opencellid authentication -token. Default -.BR /var/lib/opencellid/opencellid.token . -.TP -.B downloadmcc -(number or string) \- MCC of the region, or string "all" for the whole world. -Please set correct value for your country. -.TP -.B downloadurl -(string) \- if specified, download the file (that must be -.BR .csv.gz ) -from this URL instead of the official opencellid.org site. -.B downloadtoken -and -.B downloadmcc -are ignored when -.B downloadurl -is specified. -.SS [termconfig] and sections with numeric name -.TP -.B statusIntervalMinutes -(integer) \- terminal will report status this often. Default -.BR 25 . -.TP -.B uploadIntervalSeconds -(integer) \- terminal will report location this often. Default -.BR 0x0300 . -.TP -.B binarySwitch -(integer) \- see protocol description document. Note that all integer values -can be specified in decimal, hexadecimal, octal, or binary base. Binary -is useful for this value in particular. Default -.BR 0b00110001 . -.TP -.B alarms -(list of 3 elements) \- this value must be specified as three continuation -lines, with time in HHMM (four digit) format. -.TP -.B dndTimeSwitch -(0 or 1) \- enable or not enable "do not disturb" intervals. Default -.BR 0 . -.TP dndTimes -(list of 3 elements) \- three continuation lines with time intervals -in HHMMHHMM (start \- end) format. -.TP -.B gpsTimeSwitch -(0 or 1) \- enable or not enable location upload time interval. -.TP -.B gpsTimeStart -(HHMM) \- start of the interval to upload locations. -.TP -.B gpsTimeStop -(HHMM) \- end of the interval to upload locations. -.TP -.B phoneNumbers -(list of three elements) \- three -.I strings in quotes -as three continuation lines, with three phone numbers that the terminal -will use for various reports and calls. - -.PP -.B [termconfig] -section is used as a default fallback for terminals that have no section -in the configuration file named according to their IMEI. - -.SH KNOWN BUGS - -Keeping configuration for the terminals in this file is suboptimal, -and is suitable only for very small installations with one or few -served tracker terminals. - -.SH COPYRIGHT - -The program is copyrighted by Eugene G. Crosser -and freely distributable under the terms of MIT license. - -.SH CREDITS - -The program is inspired by the project petGPS by Thomas Obadia - -.SH SEE ALSO - -.BR gps303 (1) diff --git a/docs/loctrkd.1 b/docs/loctrkd.1 new file mode 100644 index 0000000..32b2543 --- /dev/null +++ b/docs/loctrkd.1 @@ -0,0 +1,59 @@ +.TH GPS303 1 2022-05-27 "GPS303 Manipulation Tool" "User Commands" + +.SH NAME + +loctrkd \- utility to send control messages to the GPS tracker terminal + +.SH SYNOPSIS + +loctrkd [-c /path/to/config/file] [-d] + +.SH DESCRIPTION + +Command line tool to send "unsolicited reply" messages to the tracker. +There are messages to configure settings in the terminal, or to request +actions, such as to start a monitoring call, or to power off. + +.SH OPTIONS + +.TP 0.5i +.B -d +Set "debug" level of logging + +.TP 0.5i +.B -c /path/to/config +Location of the configuration file (default /etc/loctrkd.conf) + +.SH COMMANDS + +After the options, IMEI (16 decimal characters) and the command verb +must be specified, optionally followed by a list of +.B key=value +pairs. The command verb is a case insensitive abbreviation that +uniquely identifies a class from the file loctrkd/loctrkdproto.py. +These classes correspond to message types recognized by the terminal. +Only a few of them are useful to construct "unsolicited replies". +.B key=value +pairs must specify kwargs for the constructor of the "Out" subclass +of the class. + +.SH KNOWN BUGS + +The command sends the message to the "collector" daemon for sending +to the terminal. There is no guarantee that the terminal is online +at the moment of execution, and there is no feedback. The only way +to verify that the command was successfully sent is to check the log +of the collector. + +.SH COPYRIGHT + +The program is copyrighted by Eugene G. Crosser +and freely distributable under the terms of MIT license. + +.SH CREDITS + +The program is inspired by the project petGPS by Thomas Obadia + +.SH SEE ALSO + +.BR loctrkd.conf (5) diff --git a/docs/loctrkd.conf.5 b/docs/loctrkd.conf.5 new file mode 100644 index 0000000..cc13771 --- /dev/null +++ b/docs/loctrkd.conf.5 @@ -0,0 +1,183 @@ +.TH GPS303.CONF 5 2022-05-27 "GPS303 Manipulation Tool" "File Formats Manual" + +.SH NAME + +loctrkd.conf \- Configuration file for GPS303 server + +.SH DESCRIPTION + +Services that consitute +.BR loctrkd (1) +suite read configuration data from +.B /etc/loctrkd\&.conf +.PP +The file in +.B .ini +format contains several service\&-specific sections and optional +device\&-specific sections. +.TP +.B [collector] +\- defines interface points of the +.B collector +daemon. +.TP +.B [wsgateway] +\- defines websockets listen port and optionally the location of the +.B .html +file to serve when a non-websocket request is received. +.TP +.B [storage] +\- defines location of +.BR sqlite3 (1) +database file where events are stored. +.TP +.B [lookaside] +\- defines which backend will be used. +.TP +.B [opencellid] +\- defines location of +.BR sqlite3 (1) +database file with cell tower coordinates and how to download it. +.TP +.B [googlemaps] +\- defines the location of google API access token. +.TP +.BR [termconfig] " and sections titled after terminals' IMEIs +\- defines parameters to be sent to configure the terminals. +.PP +Section contain the following parameters: +.SS [collector] +.TP +.B port +(integer) \- TCP port to listen for terminal connections. Default +.BR 4303 . +.TP +.B publishurl +(string) \- Zeromq "pub" socket where events are published. Default +.BR ipc:///var/lib/loctrkd/collected . +.TP +.B listenurl +(string) \- Zeromq "pull" socket for messages to be sent to the terminal. +Default +.BR ipc:///var/lib/loctrkd/responses . +.SS [wsgateway] +.TP +.B port +(integer) \- TCP port to listen for websocket connections. Default +.BR 5049 . +.TP +.B htmlfile +(string) \- path to the +.B .html +file to be served for +.IR non "-websocket requests. Default +.BR /var/lib/loctrkd/index.html . +.SS [storage] +.TP +.B dbfn +(string) \- location of the database file where events are stored. +.SS [lookaside] +.TP +.B backend +(string) \- either +.B opencellid +or +.B googlemaps +to select which location service to use. Googlemaps is a realtime service, +which means that you are sending location of your clients to Google. +Opencellid resolves location against a local database of cell towers, that +can be updated from time to time (e.g. once in a week or in a month). +This source does not contain WiFi access point locations, and therefore +may be less accurate. Default +.BR opencellid . +.SS [opencellid] +.TP +.B dbfn +(string) \- location of the database file with cell tower locations. +Default +.BR /var/lib/opencellid/opencellid.sqlite . +.TP +.B downloadtoken +(string) \- location of the file that contains opencellid authentication +token. Default +.BR /var/lib/opencellid/opencellid.token . +.TP +.B downloadmcc +(number or string) \- MCC of the region, or string "all" for the whole world. +Please set correct value for your country. +.TP +.B downloadurl +(string) \- if specified, download the file (that must be +.BR .csv.gz ) +from this URL instead of the official opencellid.org site. +.B downloadtoken +and +.B downloadmcc +are ignored when +.B downloadurl +is specified. +.SS [termconfig] and sections with numeric name +.TP +.B statusIntervalMinutes +(integer) \- terminal will report status this often. Default +.BR 25 . +.TP +.B uploadIntervalSeconds +(integer) \- terminal will report location this often. Default +.BR 0x0300 . +.TP +.B binarySwitch +(integer) \- see protocol description document. Note that all integer values +can be specified in decimal, hexadecimal, octal, or binary base. Binary +is useful for this value in particular. Default +.BR 0b00110001 . +.TP +.B alarms +(list of 3 elements) \- this value must be specified as three continuation +lines, with time in HHMM (four digit) format. +.TP +.B dndTimeSwitch +(0 or 1) \- enable or not enable "do not disturb" intervals. Default +.BR 0 . +.TP dndTimes +(list of 3 elements) \- three continuation lines with time intervals +in HHMMHHMM (start \- end) format. +.TP +.B gpsTimeSwitch +(0 or 1) \- enable or not enable location upload time interval. +.TP +.B gpsTimeStart +(HHMM) \- start of the interval to upload locations. +.TP +.B gpsTimeStop +(HHMM) \- end of the interval to upload locations. +.TP +.B phoneNumbers +(list of three elements) \- three +.I strings in quotes +as three continuation lines, with three phone numbers that the terminal +will use for various reports and calls. + +.PP +.B [termconfig] +section is used as a default fallback for terminals that have no section +in the configuration file named according to their IMEI. + +.SH KNOWN BUGS + +Keeping configuration for the terminals in this file is suboptimal, +and is suitable only for very small installations with one or few +served tracker terminals. + +.SH COPYRIGHT + +The program is copyrighted by Eugene G. Crosser +and freely distributable under the terms of MIT license. + +.SH CREDITS + +The program is inspired by the project petGPS by Thomas Obadia + +.SH SEE ALSO + +.BR loctrkd (1) diff --git a/gps303/__init__.py b/gps303/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/gps303/__main__.py b/gps303/__main__.py deleted file mode 100644 index fcd07a6..0000000 --- a/gps303/__main__.py +++ /dev/null @@ -1,48 +0,0 @@ -""" Command line tool for sending requests to the terminal """ - -from configparser import ConfigParser -from datetime import datetime, timezone -from getopt import getopt -from logging import getLogger -from sys import argv -from time import time -from typing import List, Tuple -import zmq - -from . import common -from .zx303proto import * -from .zmsg import Bcast, Resp - -log = getLogger("gps303") - - -def main( - conf: ConfigParser, opts: List[Tuple[str, str]], args: List[str] -) -> None: - # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! - zctx = zmq.Context() # type: ignore - zpush = zctx.socket(zmq.PUSH) # type: ignore - zpush.connect(conf.get("collector", "listenurl")) - - if len(args) < 2: - raise ValueError( - "Too few args, need IMEI and command min: " + str(args) - ) - imei = args[0] - cmd = args[1] - args = args[2:] - cls = class_by_prefix(cmd) - if isinstance(cls, list): - raise ValueError("Prefix does not select a single class: " + str(cls)) - kwargs = dict([arg.split("=") for arg in args]) - for arg in args: - k, v = arg.split("=") - kwargs[k] = v - resp = Resp(imei=imei, when=time(), packet=cls.Out(**kwargs).packed) - log.debug("Response: %s", resp) - zpush.send(resp.packed) - - -if __name__.endswith("__main__"): - opts, args = getopt(argv[1:], "c:d") - main(common.init(log, opts=opts), opts, args) diff --git a/gps303/collector.py b/gps303/collector.py deleted file mode 100644 index cb45d81..0000000 --- a/gps303/collector.py +++ /dev/null @@ -1,327 +0,0 @@ -""" TCP server that communicates with terminals """ - -from configparser import ConfigParser -from importlib import import_module -from logging import getLogger -from os import umask -from socket import ( - socket, - AF_INET6, - SOCK_STREAM, - SOL_SOCKET, - SO_KEEPALIVE, - SO_REUSEADDR, -) -from struct import pack -from time import time -from typing import Any, cast, Dict, List, Optional, Tuple, Union -import zmq - -from . import common -from .zmsg import Bcast, Resp - -log = getLogger("gps303/collector") - -MAXBUFFER: int = 4096 - - -class ProtoModule: - class Stream: - @staticmethod - def enframe(buffer: bytes) -> bytes: - ... - - def recv(self, segment: bytes) -> List[Union[bytes, str]]: - ... - - def close(self) -> bytes: - ... - - @staticmethod - def probe_buffer(buffer: bytes) -> bool: - ... - - @staticmethod - def parse_message(packet: bytes, is_incoming: bool = True) -> Any: - ... - - @staticmethod - def inline_response(packet: bytes) -> Optional[bytes]: - ... - - @staticmethod - def is_goodbye_packet(packet: bytes) -> bool: - ... - - @staticmethod - def imei_from_packet(packet: bytes) -> Optional[str]: - ... - - @staticmethod - def proto_of_message(packet: bytes) -> str: - ... - - @staticmethod - def proto_by_name(name: str) -> int: - ... - - -pmods: List[ProtoModule] = [] - - -class Client: - """Connected socket to the terminal plus buffer and metadata""" - - def __init__(self, sock: socket, addr: Tuple[str, int]) -> None: - self.sock = sock - self.addr = addr - self.pmod: Optional[ProtoModule] = None - self.stream: Optional[ProtoModule.Stream] = None - self.imei: Optional[str] = None - - def close(self) -> None: - log.debug("Closing fd %d (IMEI %s)", self.sock.fileno(), self.imei) - self.sock.close() - if self.stream: - rest = self.stream.close() - else: - rest = b"" - if rest: - log.warning( - "%d bytes in buffer on close: %s", len(rest), rest[:64].hex() - ) - - def recv(self) -> Optional[List[Tuple[float, Tuple[str, int], bytes]]]: - """Read from the socket and parse complete messages""" - try: - segment = self.sock.recv(MAXBUFFER) - except OSError as e: - log.warning( - "Reading from fd %d (IMEI %s): %s", - self.sock.fileno(), - self.imei, - e, - ) - return None - if not segment: # Terminal has closed connection - log.info( - "EOF reading from fd %d (IMEI %s)", - self.sock.fileno(), - self.imei, - ) - return None - if self.stream is None: - for pmod in pmods: - if pmod.probe_buffer(segment): - self.pmod = pmod - self.stream = pmod.Stream() - break - if self.stream is None: - log.info( - "unrecognizable %d bytes of data %s from fd %d", - len(segment), - segment[:32].hex(), - self.sock.fileno(), - ) - return [] - when = time() - msgs = [] - for elem in self.stream.recv(segment): - if isinstance(elem, bytes): - msgs.append((when, self.addr, elem)) - else: - log.warning( - "%s from fd %d (IMEI %s)", - elem, - self.sock.fileno(), - self.imei, - ) - return msgs - - def send(self, buffer: bytes) -> None: - assert self.stream is not None - try: - self.sock.send(self.stream.enframe(buffer)) - except OSError as e: - log.error( - "Sending to fd %d (IMEI %s): %s", - self.sock.fileno(), - self.imei, - e, - ) - - -class Clients: - def __init__(self) -> None: - self.by_fd: Dict[int, Client] = {} - self.by_imei: Dict[str, Client] = {} - - def add(self, clntsock: socket, clntaddr: Tuple[str, int]) -> int: - fd = clntsock.fileno() - log.info("Start serving fd %d from %s", fd, clntaddr) - self.by_fd[fd] = Client(clntsock, clntaddr) - return fd - - def stop(self, fd: int) -> None: - clnt = self.by_fd[fd] - log.info("Stop serving fd %d (IMEI %s)", clnt.sock.fileno(), clnt.imei) - clnt.close() - if clnt.imei: - del self.by_imei[clnt.imei] - del self.by_fd[fd] - - def recv( - self, fd: int - ) -> Optional[ - List[Tuple[ProtoModule, Optional[str], float, Tuple[str, int], bytes]] - ]: - clnt = self.by_fd[fd] - msgs = clnt.recv() - if msgs is None: - return None - result = [] - for when, peeraddr, packet in msgs: - assert clnt.pmod is not None - if clnt.imei is None: - imei = clnt.pmod.imei_from_packet(packet) - if imei is not None: - log.info("LOGIN from fd %d (IMEI %s)", fd, imei) - clnt.imei = imei - oldclnt = self.by_imei.get(clnt.imei) - if oldclnt is not None: - log.info( - "Orphaning fd %d with the same IMEI", - oldclnt.sock.fileno(), - ) - oldclnt.imei = None - self.by_imei[clnt.imei] = clnt - else: - log.warning( - "Login message from %s: %s, but client imei unfilled", - peeraddr, - packet, - ) - result.append((clnt.pmod, clnt.imei, when, peeraddr, packet)) - log.debug( - "Received from %s (IMEI %s): %s", - peeraddr, - clnt.imei, - packet.hex(), - ) - return result - - def response(self, resp: Resp) -> Optional[ProtoModule]: - if resp.imei in self.by_imei: - clnt = self.by_imei[resp.imei] - clnt.send(resp.packet) - return clnt.pmod - else: - log.info("Not connected (IMEI %s)", resp.imei) - return None - - -def runserver(conf: ConfigParser, handle_hibernate: bool = True) -> None: - global pmods - pmods = [ - cast(ProtoModule, import_module("." + modnm, __package__)) - for modnm in conf.get("collector", "protocols").split(",") - ] - # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! - zctx = zmq.Context() # type: ignore - zpub = zctx.socket(zmq.PUB) # type: ignore - zpull = zctx.socket(zmq.PULL) # type: ignore - oldmask = umask(0o117) - zpub.bind(conf.get("collector", "publishurl")) - zpull.bind(conf.get("collector", "listenurl")) - umask(oldmask) - tcpl = socket(AF_INET6, SOCK_STREAM) - tcpl.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) - tcpl.bind(("", conf.getint("collector", "port"))) - tcpl.listen(5) - tcpfd = tcpl.fileno() - poller = zmq.Poller() # type: ignore - poller.register(zpull, flags=zmq.POLLIN) - poller.register(tcpfd, flags=zmq.POLLIN) - clients = Clients() - try: - while True: - tosend = [] - topoll = [] - tostop = [] - events = poller.poll(1000) - for sk, fl in events: - if sk is zpull: - while True: - try: - msg = zpull.recv(zmq.NOBLOCK) - zmsg = Resp(msg) - tosend.append(zmsg) - except zmq.Again: - break - elif sk == tcpfd: - clntsock, clntaddr = tcpl.accept() - clntsock.setsockopt(SOL_SOCKET, SO_KEEPALIVE, 1) - topoll.append((clntsock, clntaddr)) - elif fl & zmq.POLLIN: - received = clients.recv(sk) - if received is None: - log.debug("Terminal gone from fd %d", sk) - tostop.append(sk) - else: - for pmod, imei, when, peeraddr, packet in received: - proto = pmod.proto_of_message(packet) - zpub.send( - Bcast( - proto=proto, - imei=imei, - when=when, - peeraddr=peeraddr, - packet=packet, - ).packed - ) - if ( - pmod.is_goodbye_packet(packet) - and handle_hibernate - ): - log.debug( - "Goodbye from fd %d (IMEI %s)", - sk, - imei, - ) - tostop.append(sk) - respmsg = pmod.inline_response(packet) - if respmsg is not None: - tosend.append( - Resp(imei=imei, when=when, packet=respmsg) - ) - else: - log.debug("Stray event: %s on socket %s", fl, sk) - # poll queue consumed, make changes now - for zmsg in tosend: - log.debug("Sending to the client: %s", zmsg) - rpmod = clients.response(zmsg) - if rpmod is not None: - zpub.send( - Bcast( - is_incoming=False, - proto=rpmod.proto_of_message(zmsg.packet), - when=zmsg.when, - imei=zmsg.imei, - packet=zmsg.packet, - ).packed - ) - for fd in tostop: - poller.unregister(fd) # type: ignore - clients.stop(fd) - for clntsock, clntaddr in topoll: - fd = clients.add(clntsock, clntaddr) - poller.register(fd, flags=zmq.POLLIN) - except KeyboardInterrupt: - zpub.close() - zpull.close() - zctx.destroy() # type: ignore - tcpl.close() - - -if __name__.endswith("__main__"): - runserver(common.init(log)) diff --git a/gps303/common.py b/gps303/common.py deleted file mode 100644 index 9e53f4b..0000000 --- a/gps303/common.py +++ /dev/null @@ -1,98 +0,0 @@ -""" Common housekeeping for all daemons """ - -from configparser import ConfigParser, SectionProxy -from getopt import getopt -from logging import Formatter, getLogger, Logger, StreamHandler, DEBUG, INFO -from logging.handlers import SysLogHandler -from pkg_resources import get_distribution, DistributionNotFound -from sys import argv, stderr, stdout -from typing import Any, Dict, List, Optional, Tuple, Union - -CONF = "/etc/gps303.conf" -PORT = 4303 -DBFN = "/var/lib/gps303/gps303.sqlite" - -try: - version = get_distribution("gps303").version -except DistributionNotFound: - version = "" - - -def init( - log: Logger, opts: Optional[List[Tuple[str, str]]] = None -) -> ConfigParser: - if opts is None: - opts, _ = getopt(argv[1:], "c:d") - dopts = dict(opts) - conf = readconfig(dopts["-c"] if "-c" in dopts else CONF) - log.setLevel(DEBUG if "-d" in dopts else INFO) - if stdout.isatty(): - fhdl = StreamHandler(stderr) - fhdl.setFormatter( - Formatter("%(asctime)s - %(levelname)s - %(message)s") - ) - log.addHandler(fhdl) - log.debug("%s starting with options: %s", version, dopts) - else: - lhdl = SysLogHandler(address="/dev/log") - lhdl.setFormatter( - Formatter("%(name)s[%(process)d]: %(levelname)s - %(message)s") - ) - log.addHandler(lhdl) - log.info("%s starting with options: %s", version, dopts) - return conf - - -def readconfig(fname: str) -> ConfigParser: - config = ConfigParser() - config["collector"] = { - "port": str(PORT), - } - config["storage"] = { - "dbfn": DBFN, - } - config["termconfig"] = {} - config.read(fname) - return config - - -def normconf(section: SectionProxy) -> Dict[str, Any]: - result: Dict[str, Any] = {} - for key, val in section.items(): - vals = val.split("\n") - if len(vals) > 1 and vals[0] == "": - vals = vals[1:] - lst: List[Union[str, int]] = [] - for el in vals: - try: - lst.append(int(el, 0)) - except ValueError: - if el[0] == '"' and el[-1] == '"': - el = el.strip('"').rstrip('"') - lst.append(el) - if not ( - all([isinstance(x, int) for x in lst]) - or all([isinstance(x, str) for x in lst]) - ): - raise ValueError( - "Values of %s - %s are of different type", key, vals - ) - if len(lst) == 1: - result[key] = lst[0] - else: - result[key] = lst - return result - - -if __name__ == "__main__": - from sys import argv - - def _print_config(conf: ConfigParser) -> None: - for section in conf.sections(): - print("section", section) - for option in conf.options(section): - print(" ", option, conf[section][option]) - - conf = readconfig(argv[1]) - _print_config(conf) - print(normconf(conf["termconfig"])) diff --git a/gps303/evstore.py b/gps303/evstore.py deleted file mode 100644 index 07b6dc4..0000000 --- a/gps303/evstore.py +++ /dev/null @@ -1,76 +0,0 @@ -""" sqlite event store """ - -from sqlite3 import connect, OperationalError -from typing import Any, List, Tuple - -__all__ = "fetch", "initdb", "stow" - -DB = None - -SCHEMA = """create table if not exists events ( - tstamp real not null, - imei text, - peeraddr text not null, - is_incoming int not null default TRUE, - proto text not null, - packet blob -)""" - - -def initdb(dbname: str) -> None: - global DB - DB = connect(dbname) - try: - DB.execute( - """alter table events add column - is_incoming int not null default TRUE""" - ) - except OperationalError: - DB.execute(SCHEMA) - - -def stow(**kwargs: Any) -> None: - assert DB is not None - parms = { - k: kwargs[k] if k in kwargs else v - for k, v in ( - ("is_incoming", True), - ("peeraddr", None), - ("when", 0.0), - ("imei", None), - ("proto", "UNKNOWN"), - ("packet", b""), - ) - } - assert len(kwargs) <= len(parms) - DB.execute( - """insert or ignore into events - (tstamp, imei, peeraddr, proto, packet, is_incoming) - values - (:when, :imei, :peeraddr, :proto, :packet, :is_incoming) - """, - parms, - ) - DB.commit() - - -def fetch( - imei: str, matchlist: List[Tuple[bool, str]], backlog: int -) -> List[Tuple[bool, float, bytes]]: - # matchlist is a list of tuples (is_incoming, proto) - # returns a list of tuples (is_incoming, timestamp, packet) - assert DB is not None - selector = " or ".join( - (f"(is_incoming = ? and proto = ?)" for _ in range(len(matchlist))) - ) - cur = DB.cursor() - cur.execute( - f"""select is_incoming, tstamp, packet from events - where ({selector}) and imei = ? - order by tstamp desc limit ?""", - tuple(item for sublist in matchlist for item in sublist) - + (imei, backlog), - ) - result = list(cur) - cur.close() - return list(reversed(result)) diff --git a/gps303/googlemaps.py b/gps303/googlemaps.py deleted file mode 100644 index 418523d..0000000 --- a/gps303/googlemaps.py +++ /dev/null @@ -1,76 +0,0 @@ -import googlemaps as gmaps -from typing import Any, Dict, List, Tuple - -gclient = None - - -def init(conf: Dict[str, Any]) -> None: - global gclient - with open(conf["googlemaps"]["accesstoken"], encoding="ascii") as fl: - token = fl.read().rstrip() - gclient = gmaps.Client(key=token) - - -def shut() -> None: - return - - -def lookup( - mcc: int, - mnc: int, - gsm_cells: List[Tuple[int, int, int]], - wifi_aps: List[Tuple[str, int]], -) -> Tuple[float, float]: - assert gclient is not None - kwargs = { - "home_mobile_country_code": mcc, - "home_mobile_network_code": mnc, - "radio_type": "gsm", - "carrier": "O2", - "consider_ip": False, - "cell_towers": [ - { - "locationAreaCode": loc, - "cellId": cellid, - "signalStrength": sig, - } - for loc, cellid, sig in gsm_cells - ], - "wifi_access_points": [ - {"macAddress": mac, "signalStrength": sig} for mac, sig in wifi_aps - ], - } - result = gclient.geolocate(**kwargs) - if "location" in result: - return result["location"]["lat"], result["location"]["lng"] - else: - raise ValueError("google geolocation: " + str(result)) - - -if __name__.endswith("__main__"): - from datetime import datetime, timezone - from sqlite3 import connect - import sys - from .zx303proto import * - - db = connect(sys.argv[1]) - c = db.cursor() - c.execute( - """select tstamp, packet from events - where proto in (?, ?)""", - (proto_name(WIFI_POSITIONING), proto_name(WIFI_OFFLINE_POSITIONING)), - ) - init({"googlemaps": {"accesstoken": sys.argv[2]}}) - count = 0 - for timestamp, packet in c: - obj = parse_message(packet) - print(obj) - avlat, avlon = lookup(obj.mcc, obj.mnc, obj.gsm_cells, obj.wifi_aps) - print( - "{} {:+#010.8g},{:+#010.8g}".format( - datetime.fromtimestamp(timestamp), avlat, avlon - ) - ) - count += 1 - if count > 10: - break diff --git a/gps303/lookaside.py b/gps303/lookaside.py deleted file mode 100644 index 2e759d1..0000000 --- a/gps303/lookaside.py +++ /dev/null @@ -1,62 +0,0 @@ -""" Estimate coordinates from WIFI_POSITIONING and send back """ - -from configparser import ConfigParser -from datetime import datetime, timezone -from importlib import import_module -from logging import getLogger -from os import umask -from struct import pack -import zmq - -from . import common -from .zx303proto import parse_message, proto_name, WIFI_POSITIONING -from .zmsg import Bcast, Resp, topic - -log = getLogger("gps303/lookaside") - - -def runserver(conf: ConfigParser) -> None: - qry = import_module("." + conf.get("lookaside", "backend"), __package__) - qry.init(conf) - # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! - zctx = zmq.Context() # type: ignore - zsub = zctx.socket(zmq.SUB) # type: ignore - zsub.connect(conf.get("collector", "publishurl")) - zsub.setsockopt(zmq.SUBSCRIBE, topic(proto_name(WIFI_POSITIONING))) - zpush = zctx.socket(zmq.PUSH) # type: ignore - zpush.connect(conf.get("collector", "listenurl")) - - try: - while True: - zmsg = Bcast(zsub.recv()) - msg = parse_message(zmsg.packet) - log.debug( - "IMEI %s from %s at %s: %s", - zmsg.imei, - zmsg.peeraddr, - datetime.fromtimestamp(zmsg.when).astimezone(tz=timezone.utc), - msg, - ) - try: - lat, lon = qry.lookup( - msg.mcc, msg.mnc, msg.gsm_cells, msg.wifi_aps - ) - resp = Resp( - imei=zmsg.imei, - when=zmsg.when, # not the current time, but the original! - packet=msg.Out(latitude=lat, longitude=lon).packed, - ) - log.debug("Response for lat=%s, lon=%s: %s", lat, lon, resp) - zpush.send(resp.packed) - except Exception as e: - log.warning("Lookup for %s resulted in %s", msg, e) - - except KeyboardInterrupt: - zsub.close() - zpush.close() - zctx.destroy() # type: ignore - qry.shut() - - -if __name__.endswith("__main__"): - runserver(common.init(log)) diff --git a/gps303/mkgpx.py b/gps303/mkgpx.py deleted file mode 100644 index 02a0825..0000000 --- a/gps303/mkgpx.py +++ /dev/null @@ -1,60 +0,0 @@ -""" Example that produces gpx from events in evstore """ - -# run as: -# python -m gps303.mkgpx -# Generated gpx is emitted to stdout - -from datetime import datetime, timezone -from sqlite3 import connect -import sys - -from .zx303proto import * - -db = connect(sys.argv[1]) -c = db.cursor() -c.execute( - """select tstamp, is_incoming, packet from events - where imei = ? - and ((is_incoming = false and proto = ?) - or (is_incoming = true and proto = ?)) - order by tstamp""", - (sys.argv[2], proto_name(WIFI_POSITIONING), proto_name(GPS_POSITIONING)), -) - -print( - """ - - Location Data - - Location Data - -""" -) - -for tstamp, is_incoming, packet in c: - msg = parse_message(packet, is_incoming=is_incoming) - lat, lon = msg.latitude, msg.longitude - isotime = ( - datetime.fromtimestamp(tstamp).astimezone(tz=timezone.utc).isoformat() - ) - isotime = isotime[: isotime.rfind(".")] + "Z" - trkpt = """ - - """.format( - lat, lon, isotime - ) - print(trkpt) - if False: - print( - datetime.fromtimestamp(tstamp) - .astimezone(tz=timezone.utc) - .isoformat(), - msg, - ) -print( - """ - -""" -) diff --git a/gps303/ocid_dload.py b/gps303/ocid_dload.py deleted file mode 100644 index dfd013f..0000000 --- a/gps303/ocid_dload.py +++ /dev/null @@ -1,135 +0,0 @@ -from configparser import ConfigParser, NoOptionError -import csv -from logging import getLogger -import requests -from sqlite3 import connect -from typing import Any, IO, Optional -from zlib import decompressobj, MAX_WBITS - -from . import common - -log = getLogger("gps303/ocid_dload") - -RURL = ( - "https://opencellid.org/ocid/downloads" - "?token={token}&type={dltype}&file={fname}.csv.gz" -) - -SCHEMA = """create table if not exists cells ( - "radio" text, - "mcc" int, - "net" int, - "area" int, - "cell" int, - "unit" int, - "lon" int, - "lat" int, - "range" int, - "samples" int, - "changeable" int, - "created" int, - "updated" int, - "averageSignal" int -)""" -DBINDEX = "create index if not exists cell_idx on cells (area, cell)" - - -class unzipped: - """ - File-like object that unzips http response body. - read(size) method returns chunks of binary data as bytes - When used as iterator, splits data to lines - and yelds them as strings. - """ - - def __init__(self, zstream: IO[bytes]) -> None: - self.zstream = zstream - self.decoder: Optional[Any] = decompressobj(16 + MAX_WBITS) - self.outdata = b"" - self.line = b"" - - def read(self, n: int = 1024) -> bytes: - if self.decoder is None: - return b"" - while len(self.outdata) < n: - raw_data = self.zstream.read(n) - self.outdata += self.decoder.decompress(raw_data) - if not raw_data: - self.decoder = None - break - if self.outdata: - data, self.outdata = self.outdata[:n], self.outdata[n:] - return data - return b"" - - def __next__(self) -> str: - while True: - splittry = self.line.split(b"\n", maxsplit=1) - if len(splittry) > 1: - break - moredata = self.read(256) - if not moredata: - raise StopIteration - self.line += moredata - line, rest = splittry - self.line = rest - return line.decode("utf-8") - - def __iter__(self) -> "unzipped": - return self - - -def main(conf: ConfigParser) -> None: - try: - url = conf.get("opencellid", "downloadurl") - mcc = "" - except NoOptionError: - try: - with open( - conf.get("opencellid", "downloadtoken"), encoding="ascii" - ) as fl: - token = fl.read().strip() - except FileNotFoundError: - log.warning( - "Opencellid access token not configured, cannot download" - ) - return - mcc = conf.get("opencellid", "downloadmcc") - if mcc == "full": - dltype = "full" - fname = "cell_towers" - else: - dltype = "mcc" - fname = mcc - url = RURL.format(token=token, dltype="mcc", fname=mcc) - dbfn = conf.get("opencellid", "dbfn") - count = 0 - with requests.get(url, stream=True) as resp, connect(dbfn) as db: - log.debug("Requested %s, result %s", url, resp) - if resp.status_code != 200: - log.error("Error getting %s: %s", url, resp) - return - db.execute("pragma journal_mode = wal") - db.execute(SCHEMA) - db.execute("delete from cells") - rows = csv.reader(unzipped(resp.raw)) - for row in rows: - db.execute( - """insert into cells - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - row, - ) - count += 1 - if count < 1: - db.rollback() - log.warning("Did not get any data for MCC %s, rollback", mcc) - else: - db.execute(DBINDEX) - db.commit() - log.info( - "repopulated %s with %d records for MCC %s", dbfn, count, mcc - ) - - -if __name__.endswith("__main__"): - main(common.init(log)) diff --git a/gps303/opencellid.py b/gps303/opencellid.py deleted file mode 100644 index 583d2e1..0000000 --- a/gps303/opencellid.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Lookaside backend to query local opencellid database -""" - -from sqlite3 import connect -from typing import Any, Dict, List, Tuple - -__all__ = "init", "lookup" - -ldb = None - - -def init(conf: Dict[str, Any]) -> None: - global ldb - ldb = connect(conf["opencellid"]["dbfn"]) - - -def shut() -> None: - if ldb is not None: - ldb.close() - - -def lookup( - mcc: int, mnc: int, gsm_cells: List[Tuple[int, int, int]], __: Any -) -> Tuple[float, float]: - assert ldb is not None - lc = ldb.cursor() - lc.execute("""attach database ":memory:" as mem""") - lc.execute("create table mem.seen (locac int, cellid int, signal int)") - lc.executemany( - """insert into mem.seen (locac, cellid, signal) - values (?, ?, ?)""", - gsm_cells, - ) - ldb.commit() - lc.execute( - """select c.lat, c.lon, s.signal - from main.cells c, mem.seen s - where c.mcc = ? - and c.area = s.locac - and c.cell = s.cellid""", - (mcc,), - ) - data = list(lc.fetchall()) - if not data: - return 0.0, 0.0 - sumsig = sum([1 / sig for _, _, sig in data]) - nsigs = [1 / sig / sumsig for _, _, sig in data] - avlat = sum([lat * nsig for (lat, _, _), nsig in zip(data, nsigs)]) - avlon = sum([lon * nsig for (_, lon, _), nsig in zip(data, nsigs)]) - # lc.execute("drop table mem.seen") - lc.execute("""detach database mem""") - lc.close() - return avlat, avlon - - -if __name__.endswith("__main__"): - from datetime import datetime, timezone - import sys - from .zx303proto import * - - db = connect(sys.argv[1]) - c = db.cursor() - c.execute( - """select tstamp, packet from events - where proto in (?, ?)""", - (proto_name(WIFI_POSITIONING), proto_name(WIFI_OFFLINE_POSITIONING)), - ) - init({"opencellid": {"dbfn": sys.argv[2]}}) - for timestamp, packet in c: - obj = parse_message(packet) - avlat, avlon = lookup(obj.mcc, obj.mnc, obj.gsm_cells, obj.wifi_aps) - print( - "{} {:+#010.8g},{:+#010.8g}".format( - datetime.fromtimestamp(timestamp), avlat, avlon - ) - ) diff --git a/gps303/qry.py b/gps303/qry.py deleted file mode 100644 index cde47ec..0000000 --- a/gps303/qry.py +++ /dev/null @@ -1,40 +0,0 @@ -from datetime import datetime, timezone -from sqlite3 import connect -import sys - -from .zx303proto import parse_message, proto_by_name - -db = connect(sys.argv[1]) -c = db.cursor() -if len(sys.argv) > 2: - proto = proto_by_name(sys.argv[2]) - if proto < 0: - raise ValueError("No protocol with name " + sys.argv[2]) - selector = " where proto = :proto" -else: - proto = -1 - selector = "" - -c.execute( - "select tstamp, imei, peeraddr, proto, packet from events" + selector, - {"proto": proto}, -) - -for tstamp, imei, peeraddr, proto, packet in c: - if len(packet) > packet[0] + 1: - print( - "proto", - packet[1], - "datalen", - len(packet), - "msg.length", - packet[0], - file=sys.stderr, - ) - msg = parse_message(packet) - print( - datetime.fromtimestamp(tstamp).astimezone(tz=timezone.utc).isoformat(), - imei, - peeraddr, - msg, - ) diff --git a/gps303/storage.py b/gps303/storage.py deleted file mode 100644 index c9ac603..0000000 --- a/gps303/storage.py +++ /dev/null @@ -1,51 +0,0 @@ -""" Store zmq broadcasts to sqlite """ - -from configparser import ConfigParser -from datetime import datetime, timezone -from logging import getLogger -import zmq - -from . import common -from .evstore import initdb, stow -from .zx303proto import proto_of_message -from .zmsg import Bcast - -log = getLogger("gps303/storage") - - -def runserver(conf: ConfigParser) -> None: - dbname = conf.get("storage", "dbfn") - log.info('Using Sqlite3 database "%s"', dbname) - initdb(dbname) - # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! - zctx = zmq.Context() # type: ignore - zsub = zctx.socket(zmq.SUB) # type: ignore - zsub.connect(conf.get("collector", "publishurl")) - zsub.setsockopt(zmq.SUBSCRIBE, b"") - - try: - while True: - zmsg = Bcast(zsub.recv()) - log.debug( - "%s IMEI %s from %s at %s: %s", - "I" if zmsg.is_incoming else "O", - zmsg.imei, - zmsg.peeraddr, - datetime.fromtimestamp(zmsg.when).astimezone(tz=timezone.utc), - zmsg.packet.hex(), - ) - stow( - is_incoming=zmsg.is_incoming, - peeraddr=str(zmsg.peeraddr), - when=zmsg.when, - imei=zmsg.imei, - proto=proto_of_message(zmsg.packet), - packet=zmsg.packet, - ) - except KeyboardInterrupt: - zsub.close() - zctx.destroy() # type: ignore - - -if __name__.endswith("__main__"): - runserver(common.init(log)) diff --git a/gps303/termconfig.py b/gps303/termconfig.py deleted file mode 100644 index 4599dc2..0000000 --- a/gps303/termconfig.py +++ /dev/null @@ -1,85 +0,0 @@ -""" For when responding to the terminal is not trivial """ - -from configparser import ConfigParser -from datetime import datetime, timezone -from logging import getLogger -from struct import pack -import zmq - -from . import common -from .zx303proto import * -from .zmsg import Bcast, Resp, topic - -log = getLogger("gps303/termconfig") - - -def runserver(conf: ConfigParser) -> None: - # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! - zctx = zmq.Context() # type: ignore - zsub = zctx.socket(zmq.SUB) # type: ignore - zsub.connect(conf.get("collector", "publishurl")) - for proto in ( - proto_name(STATUS), - proto_name(SETUP), - proto_name(POSITION_UPLOAD_INTERVAL), - ): - zsub.setsockopt(zmq.SUBSCRIBE, topic(proto)) - zpush = zctx.socket(zmq.PUSH) # type: ignore - zpush.connect(conf.get("collector", "listenurl")) - - try: - while True: - zmsg = Bcast(zsub.recv()) - msg = parse_message(zmsg.packet) - log.debug( - "IMEI %s from %s at %s: %s", - zmsg.imei, - zmsg.peeraddr, - datetime.fromtimestamp(zmsg.when).astimezone(tz=timezone.utc), - msg, - ) - if msg.RESPOND is not Respond.EXT: - log.error( - "%s does not expect externally provided response", msg - ) - if zmsg.imei is not None and conf.has_section(zmsg.imei): - termconfig = common.normconf(conf[zmsg.imei]) - elif conf.has_section("termconfig"): - termconfig = common.normconf(conf["termconfig"]) - else: - termconfig = {} - kwargs = {} - if isinstance(msg, STATUS): - kwargs = { - "upload_interval": termconfig.get( - "statusintervalminutes", 25 - ) - } - elif isinstance(msg, SETUP): - for key in ( - "uploadintervalseconds", - "binaryswitch", - "alarms", - "dndtimeswitch", - "dndtimes", - "gpstimeswitch", - "gpstimestart", - "gpstimestop", - "phonenumbers", - ): - if key in termconfig: - kwargs[key] = termconfig[key] - resp = Resp( - imei=zmsg.imei, when=zmsg.when, packet=msg.Out(**kwargs).packed - ) - log.debug("Response: %s", resp) - zpush.send(resp.packed) - - except KeyboardInterrupt: - zsub.close() - zpush.close() - zctx.destroy() # type: ignore - - -if __name__.endswith("__main__"): - runserver(common.init(log)) diff --git a/gps303/watch.py b/gps303/watch.py deleted file mode 100644 index 098209c..0000000 --- a/gps303/watch.py +++ /dev/null @@ -1,32 +0,0 @@ -""" Watch for locevt and print them """ - -from configparser import ConfigParser -from datetime import datetime, timezone -from logging import getLogger -import zmq - -from . import common -from .zx303proto import parse_message -from .zmsg import Bcast - -log = getLogger("gps303/watch") - - -def runserver(conf: ConfigParser) -> None: - # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! - zctx = zmq.Context() # type: ignore - zsub = zctx.socket(zmq.SUB) # type: ignore - zsub.connect(conf.get("collector", "publishurl")) - zsub.setsockopt(zmq.SUBSCRIBE, b"") - - try: - while True: - zmsg = Bcast(zsub.recv()) - msg = parse_message(zmsg.packet, zmsg.is_incoming) - print("I" if zmsg.is_incoming else "O", zmsg.imei, msg) - except KeyboardInterrupt: - pass - - -if __name__.endswith("__main__"): - runserver(common.init(log)) diff --git a/gps303/wsgateway.py b/gps303/wsgateway.py deleted file mode 100644 index 7dc2fcd..0000000 --- a/gps303/wsgateway.py +++ /dev/null @@ -1,415 +0,0 @@ -""" Websocket Gateway """ - -from configparser import ConfigParser -from datetime import datetime, timezone -from json import dumps, loads -from logging import getLogger -from socket import socket, AF_INET6, SOCK_STREAM, SOL_SOCKET, SO_REUSEADDR -from time import time -from typing import Any, cast, Dict, List, Optional, Set, Tuple -from wsproto import ConnectionType, WSConnection -from wsproto.events import ( - AcceptConnection, - CloseConnection, - Event, - Message, - Ping, - Request, - TextMessage, -) -from wsproto.utilities import RemoteProtocolError -import zmq - -from . import common -from .evstore import initdb, fetch -from .zx303proto import ( - GPS_POSITIONING, - STATUS, - WIFI_POSITIONING, - parse_message, - proto_name, -) -from .zmsg import Bcast, topic - -log = getLogger("gps303/wsgateway") -htmlfile = None - - -def backlog(imei: str, numback: int) -> List[Dict[str, Any]]: - result = [] - for is_incoming, timestamp, packet in fetch( - imei, - [ - (True, proto_name(GPS_POSITIONING)), - (False, proto_name(WIFI_POSITIONING)), - ], - numback, - ): - msg = parse_message(packet, is_incoming=is_incoming) - result.append( - { - "type": "location", - "imei": imei, - "timestamp": str( - datetime.fromtimestamp(timestamp).astimezone( - tz=timezone.utc - ) - ), - "longitude": msg.longitude, - "latitude": msg.latitude, - "accuracy": "gps" - if isinstance(msg, GPS_POSITIONING) - else "approximate", - } - ) - return result - - -def try_http(data: bytes, fd: int, e: Exception) -> bytes: - global htmlfile - try: - lines = data.decode().split("\r\n") - request = lines[0] - headers = lines[1:] - op, resource, proto = request.split(" ") - log.debug( - "HTTP %s for %s, proto %s from fd %d, headers: %s", - op, - resource, - proto, - fd, - headers, - ) - if op == "GET": - if htmlfile is None: - return ( - f"{proto} 500 No data configured\r\n" - f"Content-Type: text/plain\r\n\r\n" - f"HTML data not configured on the server\r\n".encode() - ) - else: - try: - with open(htmlfile, "rb") as fl: - htmldata = fl.read() - length = len(htmldata) - return ( - f"{proto} 200 Ok\r\n" - f"Content-Type: text/html; charset=utf-8\r\n" - f"Content-Length: {len(htmldata):d}\r\n\r\n" - ).encode("utf-8") + htmldata - except OSError: - return ( - f"{proto} 500 File not found\r\n" - f"Content-Type: text/plain\r\n\r\n" - f"HTML file could not be opened\r\n".encode() - ) - else: - return ( - f"{proto} 400 Bad request\r\n" - "Content-Type: text/plain\r\n\r\n" - "Bad request\r\n".encode() - ) - except ValueError: - log.warning("Unparseable data from fd %d: %s", fd, data) - raise e - - -class Client: - """Websocket connection to the client""" - - def __init__(self, sock: socket, addr: Tuple[str, int]) -> None: - self.sock = sock - self.addr = addr - self.ws = WSConnection(ConnectionType.SERVER) - self.ws_data = b"" - self.ready = False - self.imeis: Set[str] = set() - - def close(self) -> None: - log.debug("Closing fd %d", self.sock.fileno()) - self.sock.close() - - def recv(self) -> Optional[List[Dict[str, Any]]]: - try: - data = self.sock.recv(4096) - except OSError as e: - log.warning( - "Reading from fd %d: %s", - self.sock.fileno(), - e, - ) - self.ws.receive_data(None) - return None - if not data: # Client has closed connection - log.info( - "EOF reading from fd %d", - self.sock.fileno(), - ) - self.ws.receive_data(None) - return None - try: - self.ws.receive_data(data) - except RemoteProtocolError as e: - log.debug( - "Websocket error on fd %d, try plain http (%s)", - self.sock.fileno(), - e, - ) - self.ws_data = try_http(data, self.sock.fileno(), e) - # this `write` is a hack - writing _ought_ to be done at the - # stage when all other writes are performed. But I could not - # arrange it so in a logical way. Let it stay this way. The - # whole http server affair is a hack anyway. - self.write() - log.debug("Sending HTTP response to %d", self.sock.fileno()) - msgs = None - else: - msgs = [] - for event in self.ws.events(): - if isinstance(event, Request): - log.debug("WebSocket upgrade on fd %d", self.sock.fileno()) - # self.ws_data += self.ws.send(event.response()) # Why not?! - self.ws_data += self.ws.send(AcceptConnection()) - self.ready = True - elif isinstance(event, (CloseConnection, Ping)): - log.debug("%s on fd %d", event, self.sock.fileno()) - self.ws_data += self.ws.send(event.response()) - elif isinstance(event, TextMessage): - log.debug("%s on fd %d", event, self.sock.fileno()) - msg = loads(event.data) - msgs.append(msg) - if msg.get("type", None) == "subscribe": - self.imeis = set(msg.get("imei", [])) - log.debug( - "subs list on fd %s is %s", - self.sock.fileno(), - self.imeis, - ) - else: - log.warning("%s on fd %d", event, self.sock.fileno()) - return msgs - - def wants(self, imei: str) -> bool: - log.debug( - "wants %s? set is %s on fd %d", - imei, - self.imeis, - self.sock.fileno(), - ) - return imei in self.imeis - - def send(self, message: Dict[str, Any]) -> None: - if self.ready and message["imei"] in self.imeis: - self.ws_data += self.ws.send(Message(data=dumps(message))) - - def write(self) -> bool: - if self.ws_data: - try: - sent = self.sock.send(self.ws_data) - self.ws_data = self.ws_data[sent:] - except OSError as e: - log.error( - "Sending to fd %d: %s", - self.sock.fileno(), - e, - ) - self.ws_data = b"" - return bool(self.ws_data) - - -class Clients: - def __init__(self) -> None: - self.by_fd: Dict[int, Client] = {} - - def add(self, clntsock: socket, clntaddr: Tuple[str, int]) -> int: - fd = clntsock.fileno() - log.info("Start serving fd %d from %s", fd, clntaddr) - self.by_fd[fd] = Client(clntsock, clntaddr) - return fd - - def stop(self, fd: int) -> None: - clnt = self.by_fd[fd] - log.info("Stop serving fd %d", clnt.sock.fileno()) - clnt.close() - del self.by_fd[fd] - - def recv(self, fd: int) -> Optional[List[Dict[str, Any]]]: - clnt = self.by_fd[fd] - return clnt.recv() - - def send(self, msg: Dict[str, Any]) -> Set[int]: - towrite = set() - for fd, clnt in self.by_fd.items(): - if clnt.wants(msg["imei"]): - clnt.send(msg) - towrite.add(fd) - return towrite - - def write(self, towrite: Set[int]) -> Set[int]: - waiting = set() - for fd, clnt in [(fd, self.by_fd.get(fd)) for fd in towrite]: - if clnt and clnt.write(): - waiting.add(fd) - return waiting - - def subs(self) -> Set[str]: - result = set() - for clnt in self.by_fd.values(): - result |= clnt.imeis - return result - - -def runserver(conf: ConfigParser) -> None: - global htmlfile - - initdb(conf.get("storage", "dbfn")) - htmlfile = conf.get("wsgateway", "htmlfile", fallback=None) - # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! - zctx = zmq.Context() # type: ignore - zsub = zctx.socket(zmq.SUB) # type: ignore - zsub.connect(conf.get("collector", "publishurl")) - tcpl = socket(AF_INET6, SOCK_STREAM) - tcpl.setblocking(False) - tcpl.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) - tcpl.bind(("", conf.getint("wsgateway", "port"))) - tcpl.listen(5) - tcpfd = tcpl.fileno() - poller = zmq.Poller() # type: ignore - poller.register(zsub, flags=zmq.POLLIN) - poller.register(tcpfd, flags=zmq.POLLIN) - clients = Clients() - activesubs: Set[str] = set() - try: - towait: Set[int] = set() - while True: - neededsubs = clients.subs() - for imei in neededsubs - activesubs: - zsub.setsockopt( - zmq.SUBSCRIBE, - topic(proto_name(GPS_POSITIONING), True, imei), - ) - zsub.setsockopt( - zmq.SUBSCRIBE, - topic(proto_name(WIFI_POSITIONING), False, imei), - ) - zsub.setsockopt( - zmq.SUBSCRIBE, - topic(proto_name(STATUS), True, imei), - ) - for imei in activesubs - neededsubs: - zsub.setsockopt( - zmq.UNSUBSCRIBE, - topic(proto_name(GPS_POSITIONING), True, imei), - ) - zsub.setsockopt( - zmq.UNSUBSCRIBE, - topic(proto_name(WIFI_POSITIONING), False, imei), - ) - zsub.setsockopt( - zmq.UNSUBSCRIBE, - topic(proto_name(STATUS), True, imei), - ) - activesubs = neededsubs - log.debug("Subscribed to: %s", activesubs) - tosend = [] - topoll = [] - tostop = [] - towrite = set() - events = poller.poll() - for sk, fl in events: - if sk is zsub: - while True: - try: - zmsg = Bcast(zsub.recv(zmq.NOBLOCK)) - msg = parse_message(zmsg.packet, zmsg.is_incoming) - log.debug("Got %s with %s", zmsg, msg) - if isinstance(msg, STATUS): - tosend.append( - { - "type": "status", - "imei": zmsg.imei, - "timestamp": str( - datetime.fromtimestamp( - zmsg.when - ).astimezone(tz=timezone.utc) - ), - "battery": msg.batt, - } - ) - else: - tosend.append( - { - "type": "location", - "imei": zmsg.imei, - "timestamp": str( - datetime.fromtimestamp( - zmsg.when - ).astimezone(tz=timezone.utc) - ), - "longitude": msg.longitude, - "latitude": msg.latitude, - "accuracy": "gps" - if zmsg.is_incoming - else "approximate", - } - ) - except zmq.Again: - break - elif sk == tcpfd: - clntsock, clntaddr = tcpl.accept() - topoll.append((clntsock, clntaddr)) - elif fl & zmq.POLLIN: - received = clients.recv(sk) - if received is None: - log.debug("Client gone from fd %d", sk) - tostop.append(sk) - towait.discard(sk) - else: - for wsmsg in received: - log.debug("Received from %d: %s", sk, wsmsg) - if wsmsg.get("type", None) == "subscribe": - # Have to live w/o typeckeding from json - imeis = cast(List[str], wsmsg.get("imei")) - numback: int = wsmsg.get("backlog", 5) - for imei in imeis: - tosend.extend(backlog(imei, numback)) - towrite.add(sk) - elif fl & zmq.POLLOUT: - log.debug("Write now open for fd %d", sk) - towrite.add(sk) - towait.discard(sk) - else: - log.debug("Stray event: %s on socket %s", fl, sk) - # poll queue consumed, make changes now - for fd in tostop: - poller.unregister(fd) # type: ignore - clients.stop(fd) - for wsmsg in tosend: - log.debug("Sending to the clients: %s", wsmsg) - towrite |= clients.send(wsmsg) - for clntsock, clntaddr in topoll: - fd = clients.add(clntsock, clntaddr) - poller.register(fd, flags=zmq.POLLIN) - # Deal with actually writing the data out - trywrite = towrite - towait - morewait = clients.write(trywrite) - log.debug( - "towait %s, tried %s, still busy %s", - towait, - trywrite, - morewait, - ) - for fd in morewait - trywrite: # new fds waiting for write - poller.modify(fd, flags=zmq.POLLIN | zmq.POLLOUT) # type: ignore - for fd in trywrite - morewait: # no longer waiting for write - poller.modify(fd, flags=zmq.POLLIN) # type: ignore - towait &= trywrite - towait |= morewait - except KeyboardInterrupt: - zsub.close() - zctx.destroy() # type: ignore - tcpl.close() - - -if __name__.endswith("__main__"): - runserver(common.init(log)) diff --git a/gps303/zmsg.py b/gps303/zmsg.py deleted file mode 100644 index b6faa70..0000000 --- a/gps303/zmsg.py +++ /dev/null @@ -1,168 +0,0 @@ -""" Zeromq messages """ - -import ipaddress as ip -from struct import pack, unpack -from typing import Any, cast, Optional, Tuple, Type, Union - -__all__ = "Bcast", "Resp", "topic" - - -def pack_peer( # 18 bytes - peeraddr: Union[None, Tuple[str, int], Tuple[str, int, Any, Any]] -) -> bytes: - if peeraddr is None: - addr: Union[ip.IPv4Address, ip.IPv6Address] = ip.IPv6Address(0) - port = 0 - elif len(peeraddr) == 2: - peeraddr = cast(Tuple[str, int], peeraddr) - saddr, port = peeraddr - addr = ip.ip_address(saddr) - elif len(peeraddr) == 4: - peeraddr = cast(Tuple[str, int, Any, Any], peeraddr) - saddr, port, _x, _y = peeraddr - addr = ip.ip_address(saddr) - if isinstance(addr, ip.IPv4Address): - addr = ip.IPv6Address(b"\0\0\0\0\0\0\0\0\0\0\xff\xff" + addr.packed) - return addr.packed + pack("!H", port) - - -def unpack_peer( - buffer: bytes, -) -> Tuple[str, int]: - a6 = ip.IPv6Address(buffer[:16]) - port = unpack("!H", buffer[16:])[0] - a4 = a6.ipv4_mapped - if a4 is not None: - return (str(a4), port) - elif a6 == ip.IPv6Address("::"): - return ("", 0) - return (str(a6), port) - - -class _Zmsg: - KWARGS: Tuple[Tuple[str, Any], ...] - - def __init__(self, *args: Any, **kwargs: Any) -> None: - if len(args) == 1: - self.decode(args[0]) - elif bool(kwargs): - for k, v in self.KWARGS: - setattr(self, k, kwargs.get(k, v)) - else: - raise RuntimeError( - self.__class__.__name__ - + ": both args " - + str(args) - + " and kwargs " - + str(kwargs) - ) - - def __repr__(self) -> str: - return "{}({})".format( - self.__class__.__name__, - ", ".join( - [ - "{}={}".format( - k, - 'bytes.fromhex("{}")'.format(getattr(self, k).hex()) - if isinstance(getattr(self, k), bytes) - else getattr(self, k), - ) - for k, _ in self.KWARGS - ] - ), - ) - - def __eq__(self, other: object) -> bool: - if isinstance(other, self.__class__): - return all( - [getattr(self, k) == getattr(other, k) for k, _ in self.KWARGS] - ) - return NotImplemented - - def decode(self, buffer: bytes) -> None: - raise NotImplementedError( - self.__class__.__name__ + "must implement `decode()` method" - ) - - @property - def packed(self) -> bytes: - raise NotImplementedError( - self.__class__.__name__ + "must implement `packed()` property" - ) - - -def topic( - proto: str, is_incoming: bool = True, imei: Optional[str] = None -) -> bytes: - return pack("B16s", is_incoming, proto.encode()) + ( - b"" if imei is None else pack("16s", imei.encode()) - ) - - -class Bcast(_Zmsg): - """Zmq message to broadcast what was received from the terminal""" - - KWARGS = ( - ("is_incoming", True), - ("proto", "UNKNOWN"), - ("imei", None), - ("when", None), - ("peeraddr", None), - ("packet", b""), - ) - - @property - def packed(self) -> bytes: - return ( - pack( - "!B16s16sd", - int(self.is_incoming), - self.proto[:16].ljust(16, "\0").encode(), - b"0000000000000000" - if self.imei is None - else self.imei.encode(), - 0 if self.when is None else self.when, - ) - + pack_peer(self.peeraddr) - + self.packet - ) - - def decode(self, buffer: bytes) -> None: - is_incoming, proto, imei, when = unpack("!B16s16sd", buffer[:41]) - self.is_incoming = bool(is_incoming) - self.proto = proto.decode() - self.imei = ( - None if imei == b"0000000000000000" else imei.decode().strip("\0") - ) - self.when = when - self.peeraddr = unpack_peer(buffer[41:59]) - self.packet = buffer[59:] - - -class Resp(_Zmsg): - """Zmq message received from a third party to send to the terminal""" - - KWARGS = (("imei", None), ("when", None), ("packet", b"")) - - @property - def packed(self) -> bytes: - return ( - pack( - "!16sd", - "0000000000000000" - if self.imei is None - else self.imei.encode(), - 0 if self.when is None else self.when, - ) - + self.packet - ) - - def decode(self, buffer: bytes) -> None: - imei, when = unpack("!16sd", buffer[:24]) - self.imei = ( - None if imei == b"0000000000000000" else imei.decode().strip("\0") - ) - - self.when = when - self.packet = buffer[24:] diff --git a/gps303/zx303proto.py b/gps303/zx303proto.py deleted file mode 100755 index efb02d2..0000000 --- a/gps303/zx303proto.py +++ /dev/null @@ -1,950 +0,0 @@ -""" -Implementation of the protocol used by zx303 "ZhongXun Topin Locator" -GPS+GPRS module. Description lifted from this repository: -https://github.com/tobadia/petGPS/tree/master/resources - -Forewarnings: -1. There is no security whatsoever. If you know the module's IMEI, - you can feed fake data to the server, including fake location. -2. Ad-hoc choice of framing of messages (that are transferred over - the TCP stream) makes it vulnerable to coincidental appearance - of framing bytes in the middle of the message. Most of the time - the server will receive one message in one TCP segment (i.e. in - one `recv()` operation, but relying on that would break things - if the path has lower MTU than the size of a message. -""" - -from datetime import datetime, timezone -from enum import Enum -from inspect import isclass -from struct import error, pack, unpack -from time import time -from typing import ( - Any, - Callable, - Dict, - List, - Optional, - Tuple, - Type, - TYPE_CHECKING, - Union, -) - -__all__ = ( - "Stream", - "class_by_prefix", - "inline_response", - "parse_message", - "probe_buffer", - "proto_by_name", - "proto_name", - "DecodeError", - "Respond", - "GPS303Pkt", - "UNKNOWN", - "LOGIN", - "SUPERVISION", - "HEARTBEAT", - "GPS_POSITIONING", - "GPS_OFFLINE_POSITIONING", - "STATUS", - "HIBERNATION", - "RESET", - "WHITELIST_TOTAL", - "WIFI_OFFLINE_POSITIONING", - "TIME", - "PROHIBIT_LBS", - "GPS_LBS_SWITCH_TIMES", - "REMOTE_MONITOR_PHONE", - "SOS_PHONE", - "DAD_PHONE", - "MOM_PHONE", - "STOP_UPLOAD", - "GPS_OFF_PERIOD", - "DND_PERIOD", - "RESTART_SHUTDOWN", - "DEVICE", - "ALARM_CLOCK", - "STOP_ALARM", - "SETUP", - "SYNCHRONOUS_WHITELIST", - "RESTORE_PASSWORD", - "WIFI_POSITIONING", - "MANUAL_POSITIONING", - "BATTERY_CHARGE", - "CHARGER_CONNECTED", - "CHARGER_DISCONNECTED", - "VIBRATION_RECEIVED", - "POSITION_UPLOAD_INTERVAL", - "SOS_ALARM", - "UNKNOWN_B3", -) - -PROTO_PREFIX = "ZX" - -### Deframer ### - -MAXBUFFER: int = 4096 - - -class Stream: - def __init__(self) -> None: - self.buffer = b"" - - @staticmethod - def enframe(buffer: bytes) -> bytes: - return b"xx" + buffer + b"\r\n" - - def recv(self, segment: bytes) -> List[Union[bytes, str]]: - """ - Process next segment of the stream. Return successfully deframed - packets as `bytes` and error messages as `str`. - """ - when = time() - self.buffer += segment - if len(self.buffer) > MAXBUFFER: - # We are receiving junk. Let's drop it or we run out of memory. - self.buffer = b"" - return [f"More than {MAXBUFFER} unparseable data, dropping"] - msgs: List[Union[bytes, str]] = [] - while True: - framestart = self.buffer.find(b"xx") - if framestart == -1: # No frames, return whatever we have - break - if framestart > 0: # Should not happen, report - msgs.append( - f'Undecodable data ({framestart}) "{self.buffer[:framestart][:64].hex()}"' - ) - self.buffer = self.buffer[framestart:] - # At this point, buffer starts with a packet - if len(self.buffer) < 6: # no len and proto - cannot proceed - break - exp_end = self.buffer[2] + 3 # Expect '\r\n' here - frameend = 0 - # Length field can legitimeely be much less than the - # length of the packet (e.g. WiFi positioning), but - # it _should not_ be greater. Still sometimes it is. - # Luckily, not by too much: by maybe two or three bytes? - # Do this embarrassing hack to avoid accidental match - # of some binary data in the packet against '\r\n'. - while True: - frameend = self.buffer.find(b"\r\n", frameend + 1) - if frameend == -1 or frameend >= ( - exp_end - 3 - ): # Found realistic match or none - break - if frameend == -1: # Incomplete frame, return what we have - break - packet = self.buffer[2:frameend] - self.buffer = self.buffer[frameend + 2 :] - if len(packet) < 2: # frameend comes too early - msgs.append(f"Packet too short: {packet.hex()}") - else: - msgs.append(packet) - return msgs - - def close(self) -> bytes: - ret = self.buffer - self.buffer = b"" - return ret - - -### Parser/Constructor ### - - -class DecodeError(Exception): - def __init__(self, e: Exception, **kwargs: Any) -> None: - super().__init__(e) - for k, v in kwargs.items(): - setattr(self, k, v) - - -def maybe(typ: type) -> Callable[[Any], Any]: - return lambda x: None if x is None else typ(x) - - -def intx(x: Union[str, int]) -> int: - if isinstance(x, str): - x = int(x, 0) - return x - - -def boolx(x: Union[str, bool]) -> bool: - if isinstance(x, str): - if x.upper() in ("ON", "TRUE", "1"): - return True - if x.upper() in ("OFF", "FALSE", "0"): - return False - raise ValueError(str(x) + " could not be parsed as a Boolean") - return x - - -def hhmm(x: str) -> str: - """Check for the string that represents hours and minutes""" - if not isinstance(x, str) or len(x) != 4: - raise ValueError(str(x) + " is not a four-character string") - hh = int(x[:2]) - mm = int(x[2:]) - if hh < 0 or hh > 23 or mm < 0 or mm > 59: - raise ValueError(str(x) + " does not contain valid hours and minutes") - return x - - -def hhmmhhmm(x: str) -> str: - """Check for the string that represents hours and minutes twice""" - if not isinstance(x, str) or len(x) != 8: - raise ValueError(str(x) + " is not an eight-character string") - return hhmm(x[:4]) + hhmm(x[4:]) - - -def l3str(x: Union[str, List[str]]) -> List[str]: - if isinstance(x, str): - lx = x.split(",") - else: - lx = x - if len(lx) != 3 or not all(isinstance(el, str) for el in x): - raise ValueError(str(lx) + " is not a list of three strings") - return lx - - -def l3alarms(x: Union[str, List[Tuple[int, str]]]) -> List[Tuple[int, str]]: - def alrmspec(sub: str) -> Tuple[int, str]: - if len(sub) != 7: - raise ValueError(sub + " does not represent day and time") - return ( - { - "MON": 1, - "TUE": 2, - "WED": 3, - "THU": 4, - "FRI": 5, - "SAT": 6, - "SUN": 7, - }[sub[:3].upper()], - sub[3:], - ) - - if isinstance(x, str): - lx = [alrmspec(sub) for sub in x.split(",")] - else: - lx = x - lx.extend([(0, "0000") for _ in range(3 - len(lx))]) - if len(lx) != 3 or any(d < 0 or d > 7 for d, tm in lx): - raise ValueError(str(lx) + " is a wrong alarms specification") - return [(d, hhmm(tm)) for d, tm in lx] - - -def l3int(x: Union[str, List[int]]) -> List[int]: - if isinstance(x, str): - lx = [int(el) for el in x.split(",")] - else: - lx = x - if len(lx) != 3 or not all(isinstance(el, int) for el in lx): - raise ValueError(str(lx) + " is not a list of three integers") - return lx - - -class MetaPkt(type): - """ - For each class corresponding to a message, automatically create - two nested classes `In` and `Out` that also inherit from their - "nest". Class attribute `IN_KWARGS` defined in the "nest" is - copied to the `In` nested class under the name `KWARGS`, and - likewise, `OUT_KWARGS` of the nest class is copied as `KWARGS` - to the nested class `Out`. In addition, method `encode` is - defined in both classes equal to `in_encode()` and `out_encode()` - respectively. - """ - - if TYPE_CHECKING: - - def __getattr__(self, name: str) -> Any: - pass - - def __setattr__(self, name: str, value: Any) -> None: - pass - - def __new__( - cls: Type["MetaPkt"], - name: str, - bases: Tuple[type, ...], - attrs: Dict[str, Any], - ) -> "MetaPkt": - newcls = super().__new__(cls, name, bases, attrs) - newcls.In = super().__new__( - cls, - name + ".In", - (newcls,) + bases, - { - "KWARGS": newcls.IN_KWARGS, - "decode": newcls.in_decode, - "encode": newcls.in_encode, - }, - ) - newcls.Out = super().__new__( - cls, - name + ".Out", - (newcls,) + bases, - { - "KWARGS": newcls.OUT_KWARGS, - "decode": newcls.out_decode, - "encode": newcls.out_encode, - }, - ) - return newcls - - -class Respond(Enum): - NON = 0 # Incoming, no response needed - INL = 1 # Birirectional, use `inline_response()` - EXT = 2 # Birirectional, use external responder - - -class GPS303Pkt(metaclass=MetaPkt): - RESPOND = Respond.NON # Do not send anything back by default - PROTO: int - IN_KWARGS: Tuple[Tuple[str, Callable[[Any], Any], Any], ...] = () - OUT_KWARGS: Tuple[Tuple[str, Callable[[Any], Any], Any], ...] = () - KWARGS: Tuple[Tuple[str, Callable[[Any], Any], Any], ...] = () - In: Type["GPS303Pkt"] - Out: Type["GPS303Pkt"] - - if TYPE_CHECKING: - - def __getattr__(self, name: str) -> Any: - pass - - def __setattr__(self, name: str, value: Any) -> None: - pass - - def __init__(self, *args: Any, **kwargs: Any): - """ - Construct the object _either_ from (length, payload), - _or_ from the values of individual fields - """ - assert not args or (len(args) == 2 and not kwargs) - if args: # guaranteed to be two arguments at this point - self.length, self.payload = args - try: - self.decode(self.length, self.payload) - except error as e: - raise DecodeError(e, obj=self) - else: - for kw, typ, dfl in self.KWARGS: - setattr(self, kw, typ(kwargs.pop(kw, dfl))) - if kwargs: - raise ValueError( - self.__class__.__name__ + " stray kwargs " + str(kwargs) - ) - - def __repr__(self) -> str: - return "{}({})".format( - self.__class__.__name__, - ", ".join( - "{}={}".format( - k, - 'bytes.fromhex("{}")'.format(v.hex()) - if isinstance(v, bytes) - else v.__repr__(), - ) - for k, v in self.__dict__.items() - if not k.startswith("_") - ), - ) - - decode: Callable[["GPS303Pkt", int, bytes], None] - - def in_decode(self, length: int, packet: bytes) -> None: - # Overridden in subclasses, otherwise do not decode payload - return - - def out_decode(self, length: int, packet: bytes) -> None: - # Overridden in subclasses, otherwise do not decode payload - return - - encode: Callable[["GPS303Pkt"], bytes] - - def in_encode(self) -> bytes: - # Necessary to emulate terminal, which is not implemented - raise NotImplementedError( - self.__class__.__name__ + ".encode() not implemented" - ) - - def out_encode(self) -> bytes: - # Overridden in subclasses, otherwise make empty payload - return b"" - - @property - def packed(self) -> bytes: - payload = self.encode() - length = getattr(self, "length", len(payload) + 1) - return pack("BB", length, self.PROTO) + payload - - -class UNKNOWN(GPS303Pkt): - PROTO = 256 # > 255 is impossible in real packets - - -class LOGIN(GPS303Pkt): - PROTO = 0x01 - RESPOND = Respond.INL - # Default response for ACK, can also respond with STOP_UPLOAD - IN_KWARGS = (("imei", str, "0000000000000000"), ("ver", int, 0)) - - def in_decode(self, length: int, payload: bytes) -> None: - self.imei = payload[:8].ljust(8, b"\0").hex() - self.ver = payload[8] - - def in_encode(self) -> bytes: - return bytes.fromhex(self.imei).ljust(8, b"\0")[:8] + pack( - "B", self.ver - ) - - -class SUPERVISION(GPS303Pkt): - PROTO = 0x05 - OUT_KWARGS = (("status", int, 1),) - - def out_encode(self) -> bytes: - # 1: The device automatically answers Pickup effect - # 2: Automatically Answering Two-way Calls - # 3: Ring manually answer the two-way call - return pack("B", self.status) - - -class HEARTBEAT(GPS303Pkt): - PROTO = 0x08 - RESPOND = Respond.INL - - -class _GPS_POSITIONING(GPS303Pkt): - RESPOND = Respond.INL - - def in_decode(self, length: int, payload: bytes) -> None: - self.dtime = payload[:6] - if self.dtime == b"\0\0\0\0\0\0": - self.devtime = None - else: - yr, mo, da, hr, mi, se = unpack("BBBBBB", self.dtime) - self.devtime = datetime( - 2000 + yr, mo, da, hr, mi, se, tzinfo=timezone.utc - ) - self.gps_data_length = payload[6] >> 4 - self.gps_nb_sat = payload[6] & 0x0F - lat, lon, speed, flags = unpack("!IIBH", payload[7:18]) - self.gps_is_valid = bool(flags & 0b0001000000000000) # bit 3 - flip_lon = bool(flags & 0b0000100000000000) # bit 4 - flip_lat = not bool(flags & 0b0000010000000000) # bit 5 - self.heading = flags & 0b0000001111111111 # bits 6 - last - self.latitude = lat / (30000 * 60) * (-1 if flip_lat else 1) - self.longitude = lon / (30000 * 60) * (-1 if flip_lon else 1) - self.speed = speed - self.flags = flags - - def out_encode(self) -> bytes: - tup = datetime.utcnow().timetuple() - ttup = (tup[0] % 100,) + tup[1:6] - return pack("BBBBBB", *ttup) - - -class GPS_POSITIONING(_GPS_POSITIONING): - PROTO = 0x10 - - -class GPS_OFFLINE_POSITIONING(_GPS_POSITIONING): - PROTO = 0x11 - - -class STATUS(GPS303Pkt): - PROTO = 0x13 - RESPOND = Respond.EXT - IN_KWARGS = ( - ("batt", int, 100), - ("ver", int, 0), - ("timezone", int, 0), - ("intvl", int, 0), - ("signal", maybe(int), None), - ) - OUT_KWARGS = (("upload_interval", int, 25),) - - def in_decode(self, length: int, payload: bytes) -> None: - self.batt, self.ver, self.timezone, self.intvl = unpack( - "BBBB", payload[:4] - ) - if len(payload) > 4: - self.signal: Optional[int] = payload[4] - else: - self.signal = None - - def in_encode(self) -> bytes: - return pack("BBBB", self.batt, self.ver, self.timezone, self.intvl) + ( - b"" if self.signal is None else pack("B", self.signal) - ) - - def out_encode(self) -> bytes: # Set interval in minutes - return pack("B", self.upload_interval) - - -class HIBERNATION(GPS303Pkt): # Server can send to send devicee to sleep - PROTO = 0x14 - - def in_encode(self) -> bytes: - return b"" - - -class RESET(GPS303Pkt): - # Device sends when it got reset SMS - # Server can send to initiate factory reset - PROTO = 0x15 - - -class WHITELIST_TOTAL(GPS303Pkt): # Server sends to initiage sync (0x58) - PROTO = 0x16 - OUT_KWARGS = (("number", int, 3),) - - def out_encode(self) -> bytes: # Number of whitelist entries - return pack("B", self.number) - - -class _WIFI_POSITIONING(GPS303Pkt): - IN_KWARGS: Tuple[Tuple[str, Callable[[Any], Any], Any], ...] = ( - # IN_KWARGS = ( - ("dtime", bytes, b"\0\0\0\0\0\0"), - ("wifi_aps", list, []), - ("mcc", int, 0), - ("mnc", int, 0), - ("gsm_cells", list, []), - ) - - def in_decode(self, length: int, payload: bytes) -> None: - self.dtime = payload[:6] - if self.dtime == b"\0\0\0\0\0\0": - self.devtime = None - else: - self.devtime = datetime.strptime( - self.dtime.hex(), "%y%m%d%H%M%S" - ).astimezone(tz=timezone.utc) - self.wifi_aps = [] - for i in range(self.length): # length has special meaning here - slice = payload[6 + i * 7 : 13 + i * 7] - self.wifi_aps.append( - (":".join([format(b, "02X") for b in slice[:6]]), -slice[6]) - ) - gsm_slice = payload[6 + self.length * 7 :] - ncells, self.mcc, self.mnc = unpack("!BHB", gsm_slice[:4]) - self.gsm_cells = [] - for i in range(ncells): - slice = gsm_slice[4 + i * 5 : 9 + i * 5] - locac, cellid, sigstr = unpack( - "!HHB", gsm_slice[4 + i * 5 : 9 + i * 5] - ) - self.gsm_cells.append((locac, cellid, -sigstr)) - - def in_encode(self) -> bytes: - self.length = len(self.wifi_aps) - return b"".join( - [ - self.dtime, - b"".join( - [ - bytes.fromhex(mac.replace(":", "")).ljust(6, b"\0")[:6] - + pack("B", -sigstr) - for mac, sigstr in self.wifi_aps - ] - ), - pack("!BHB", len(self.gsm_cells), self.mcc, self.mnc), - b"".join( - [ - pack("!HHB", locac, cellid, -sigstr) - for locac, cellid, sigstr in self.gsm_cells - ] - ), - ] - ) - - -class WIFI_OFFLINE_POSITIONING(_WIFI_POSITIONING): - PROTO = 0x17 - RESPOND = Respond.INL - - def out_encode(self) -> bytes: - return bytes.fromhex(datetime.utcnow().strftime("%y%m%d%H%M%S")) - - -class TIME(GPS303Pkt): - PROTO = 0x30 - RESPOND = Respond.INL - - def out_encode(self) -> bytes: - return pack("!HBBBBB", *datetime.utcnow().timetuple()[:6]) - - -class PROHIBIT_LBS(GPS303Pkt): - PROTO = 0x33 - OUT_KWARGS = (("status", int, 1),) - - def out_encode(self) -> bytes: # Server sent, 0-off, 1-on - return pack("B", self.status) - - -class GPS_LBS_SWITCH_TIMES(GPS303Pkt): - PROTO = 0x34 - - OUT_KWARGS = ( - ("gps_off", boolx, False), # Clarify the meaning of 0/1 - ("gps_interval_set", boolx, False), - ("gps_interval", hhmmhhmm, "00000000"), - ("lbs_off", boolx, False), # Clarify the meaning of 0/1 - ("boot_time_set", boolx, False), - ("boot_time", hhmm, "0000"), - ("shut_time_set", boolx, False), - ("shut_time", hhmm, "0000"), - ) - - def out_encode(self) -> bytes: - return ( - pack("B", self.gps_off) - + pack("B", self.gps_interval_set) - + bytes.fromhex(self.gps_interval) - + pack("B", self.lbs_off) - + pack("B", self.boot_time_set) - + bytes.fromhex(self.boot_time) - + pack("B", self.shut_time_set) - + bytes.fromhex(self.shut_time) - ) - - -class _SET_PHONE(GPS303Pkt): - OUT_KWARGS = (("phone", str, ""),) - - def out_encode(self) -> bytes: - self.phone: str - return self.phone.encode("") - - -class REMOTE_MONITOR_PHONE(_SET_PHONE): - PROTO = 0x40 - - -class SOS_PHONE(_SET_PHONE): - PROTO = 0x41 - - -class DAD_PHONE(_SET_PHONE): - PROTO = 0x42 - - -class MOM_PHONE(_SET_PHONE): - PROTO = 0x43 - - -class STOP_UPLOAD(GPS303Pkt): # Server response to LOGIN to thwart the device - PROTO = 0x44 - - -class GPS_OFF_PERIOD(GPS303Pkt): - PROTO = 0x46 - OUT_KWARGS = ( - ("onoff", int, 0), - ("fm", hhmm, "0000"), - ("to", hhmm, "2359"), - ) - - def out_encode(self) -> bytes: - return ( - pack("B", self.onoff) - + bytes.fromhex(self.fm) - + bytes.fromhex(self.to) - ) - - -class DND_PERIOD(GPS303Pkt): - PROTO = 0x47 - OUT_KWARGS = ( - ("onoff", int, 0), - ("week", int, 3), - ("fm1", hhmm, "0000"), - ("to1", hhmm, "2359"), - ("fm2", hhmm, "0000"), - ("to2", hhmm, "2359"), - ) - - def out_encode(self) -> bytes: - return ( - pack("B", self.onoff) - + pack("B", self.week) - + bytes.fromhex(self.fm1) - + bytes.fromhex(self.to1) - + bytes.fromhex(self.fm2) - + bytes.fromhex(self.to2) - ) - - -class RESTART_SHUTDOWN(GPS303Pkt): - PROTO = 0x48 - OUT_KWARGS = (("flag", int, 0),) - - def out_encode(self) -> bytes: - # 1 - restart - # 2 - shutdown - return pack("B", self.flag) - - -class DEVICE(GPS303Pkt): - PROTO = 0x49 - OUT_KWARGS = (("flag", int, 0),) - - # 0 - Stop looking for equipment - # 1 - Start looking for equipment - def out_encode(self) -> bytes: - return pack("B", self.flag) - - -class ALARM_CLOCK(GPS303Pkt): - PROTO = 0x50 - OUT_KWARGS: Tuple[ - Tuple[str, Callable[[Any], Any], List[Tuple[int, str]]], ... - ] = ( - ("alarms", l3alarms, []), - ) - - def out_encode(self) -> bytes: - return b"".join( - pack("B", day) + bytes.fromhex(tm) for day, tm in self.alarms - ) - - -class STOP_ALARM(GPS303Pkt): - PROTO = 0x56 - - def in_decode(self, length: int, payload: bytes) -> None: - self.flag = payload[0] - - -class SETUP(GPS303Pkt): - PROTO = 0x57 - RESPOND = Respond.EXT - OUT_KWARGS = ( - ("uploadintervalseconds", intx, 0x0300), - ("binaryswitch", intx, 0b00110001), - ("alarms", l3int, [0, 0, 0]), - ("dndtimeswitch", int, 0), - ("dndtimes", l3int, [0, 0, 0]), - ("gpstimeswitch", int, 0), - ("gpstimestart", int, 0), - ("gpstimestop", int, 0), - ("phonenumbers", l3str, ["", "", ""]), - ) - - def out_encode(self) -> bytes: - def pack3b(x: int) -> bytes: - return pack("!I", x)[1:] - - return b"".join( - [ - pack("!H", self.uploadintervalseconds), - pack("B", self.binaryswitch), - ] - + [pack3b(el) for el in self.alarms] - + [ - pack("B", self.dndtimeswitch), - ] - + [pack3b(el) for el in self.dndtimes] - + [ - pack("B", self.gpstimeswitch), - pack("!H", self.gpstimestart), - pack("!H", self.gpstimestop), - ] - + [b";".join([el.encode() for el in self.phonenumbers])] - ) - - def in_encode(self) -> bytes: - return b"" - - -class SYNCHRONOUS_WHITELIST(GPS303Pkt): - PROTO = 0x58 - - -class RESTORE_PASSWORD(GPS303Pkt): - PROTO = 0x67 - - -class WIFI_POSITIONING(_WIFI_POSITIONING): - PROTO = 0x69 - RESPOND = Respond.EXT - OUT_KWARGS = (("latitude", float, None), ("longitude", float, None)) - - def out_encode(self) -> bytes: - if self.latitude is None or self.longitude is None: - return b"" - return "{:+#010.8g},{:+#010.8g}".format( - self.latitude, self.longitude - ).encode() - - def out_decode(self, length: int, payload: bytes) -> None: - lat, lon = payload.decode().split(",") - self.latitude = float(lat) - self.longitude = float(lon) - - -class MANUAL_POSITIONING(GPS303Pkt): - PROTO = 0x80 - - def in_decode(self, length: int, payload: bytes) -> None: - self.flag = payload[0] if len(payload) > 0 else -1 - self.reason = { - 1: "Incorrect time", - 2: "LBS less", - 3: "WiFi less", - 4: "LBS search > 3 times", - 5: "Same LBS and WiFi data", - 6: "LBS prohibited, WiFi absent", - 7: "GPS spacing < 50 m", - }.get(self.flag, "Unknown") - - -class BATTERY_CHARGE(GPS303Pkt): - PROTO = 0x81 - - -class CHARGER_CONNECTED(GPS303Pkt): - PROTO = 0x82 - - -class CHARGER_DISCONNECTED(GPS303Pkt): - PROTO = 0x83 - - -class VIBRATION_RECEIVED(GPS303Pkt): - PROTO = 0x94 - - -class POSITION_UPLOAD_INTERVAL(GPS303Pkt): - PROTO = 0x98 - RESPOND = Respond.EXT - OUT_KWARGS = (("interval", int, 10),) - - def in_decode(self, length: int, payload: bytes) -> None: - self.interval = unpack("!H", payload[:2]) - - def out_encode(self) -> bytes: - return pack("!H", self.interval) - - -class SOS_ALARM(GPS303Pkt): - PROTO = 0x99 - - -class UNKNOWN_B3(GPS303Pkt): - PROTO = 0xB3 - IN_KWARGS = (("asciidata", str, ""),) - - def in_decode(self, length: int, payload: bytes) -> None: - self.asciidata = payload.decode() - - -# Build dicts protocol number -> class and class name -> protocol number -CLASSES = {} -PROTOS = {} -if True: # just to indent the code, sorry! - for cls in [ - cls - for name, cls in globals().items() - if isclass(cls) - and issubclass(cls, GPS303Pkt) - and not name.startswith("_") - ]: - if hasattr(cls, "PROTO"): - CLASSES[cls.PROTO] = cls - PROTOS[cls.__name__] = cls.PROTO - - -def class_by_prefix( - prefix: str, -) -> Union[Type[GPS303Pkt], List[Tuple[str, int]]]: - lst = [ - (name, proto) - for name, proto in PROTOS.items() - if name.upper().startswith(prefix.upper()) - ] - if len(lst) != 1: - return lst - _, proto = lst[0] - return CLASSES[proto] - - -def proto_name(obj: Union[MetaPkt, GPS303Pkt]) -> str: - return ( - PROTO_PREFIX - + ":" - + ( - obj.__class__.__name__ - if isinstance(obj, GPS303Pkt) - else obj.__name__ - ) - ).ljust(16, "\0")[:16] - - -def proto_by_name(name: str) -> int: - return PROTOS.get(name, -1) - - -def proto_of_message(packet: bytes) -> str: - return proto_name(CLASSES.get(packet[1], UNKNOWN)) - - -def imei_from_packet(packet: bytes) -> Optional[str]: - if packet[1] == LOGIN.PROTO: - msg = parse_message(packet) - if isinstance(msg, LOGIN): - return msg.imei - return None - - -def is_goodbye_packet(packet: bytes) -> bool: - return packet[1] == HIBERNATION.PROTO - - -def inline_response(packet: bytes) -> Optional[bytes]: - proto = packet[1] - if proto in CLASSES: - cls = CLASSES[proto] - if cls.RESPOND is Respond.INL: - return cls.Out().packed - return None - - -def probe_buffer(buffer: bytes) -> bool: - framestart = buffer.find(b"xx") - if framestart < 0: - return False - if len(buffer) - framestart < 6: - return False - return True - - -def parse_message(packet: bytes, is_incoming: bool = True) -> GPS303Pkt: - """From a packet (without framing bytes) derive the XXX.In object""" - length, proto = unpack("BB", packet[:2]) - payload = packet[2:] - if proto not in CLASSES: - cause: Union[DecodeError, ValueError, IndexError] = ValueError( - f"Proto {proto} is unknown" - ) - else: - try: - if is_incoming: - return CLASSES[proto].In(length, payload) - else: - return CLASSES[proto].Out(length, payload) - except (DecodeError, ValueError, IndexError) as e: - cause = e - if is_incoming: - retobj = UNKNOWN.In(length, payload) - else: - retobj = UNKNOWN.Out(length, payload) - retobj.PROTO = proto # Override class attr with object attr - retobj.cause = cause - return retobj diff --git a/loctrkd/__init__.py b/loctrkd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/loctrkd/__main__.py b/loctrkd/__main__.py new file mode 100644 index 0000000..c3dcb4b --- /dev/null +++ b/loctrkd/__main__.py @@ -0,0 +1,48 @@ +""" Command line tool for sending requests to the terminal """ + +from configparser import ConfigParser +from datetime import datetime, timezone +from getopt import getopt +from logging import getLogger +from sys import argv +from time import time +from typing import List, Tuple +import zmq + +from . import common +from .zx303proto import * +from .zmsg import Bcast, Resp + +log = getLogger("loctrkd") + + +def main( + conf: ConfigParser, opts: List[Tuple[str, str]], args: List[str] +) -> None: + # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! + zctx = zmq.Context() # type: ignore + zpush = zctx.socket(zmq.PUSH) # type: ignore + zpush.connect(conf.get("collector", "listenurl")) + + if len(args) < 2: + raise ValueError( + "Too few args, need IMEI and command min: " + str(args) + ) + imei = args[0] + cmd = args[1] + args = args[2:] + cls = class_by_prefix(cmd) + if isinstance(cls, list): + raise ValueError("Prefix does not select a single class: " + str(cls)) + kwargs = dict([arg.split("=") for arg in args]) + for arg in args: + k, v = arg.split("=") + kwargs[k] = v + resp = Resp(imei=imei, when=time(), packet=cls.Out(**kwargs).packed) + log.debug("Response: %s", resp) + zpush.send(resp.packed) + + +if __name__.endswith("__main__"): + opts, args = getopt(argv[1:], "c:d") + main(common.init(log, opts=opts), opts, args) diff --git a/loctrkd/collector.py b/loctrkd/collector.py new file mode 100644 index 0000000..c6261d7 --- /dev/null +++ b/loctrkd/collector.py @@ -0,0 +1,327 @@ +""" TCP server that communicates with terminals """ + +from configparser import ConfigParser +from importlib import import_module +from logging import getLogger +from os import umask +from socket import ( + socket, + AF_INET6, + SOCK_STREAM, + SOL_SOCKET, + SO_KEEPALIVE, + SO_REUSEADDR, +) +from struct import pack +from time import time +from typing import Any, cast, Dict, List, Optional, Tuple, Union +import zmq + +from . import common +from .zmsg import Bcast, Resp + +log = getLogger("loctrkd/collector") + +MAXBUFFER: int = 4096 + + +class ProtoModule: + class Stream: + @staticmethod + def enframe(buffer: bytes) -> bytes: + ... + + def recv(self, segment: bytes) -> List[Union[bytes, str]]: + ... + + def close(self) -> bytes: + ... + + @staticmethod + def probe_buffer(buffer: bytes) -> bool: + ... + + @staticmethod + def parse_message(packet: bytes, is_incoming: bool = True) -> Any: + ... + + @staticmethod + def inline_response(packet: bytes) -> Optional[bytes]: + ... + + @staticmethod + def is_goodbye_packet(packet: bytes) -> bool: + ... + + @staticmethod + def imei_from_packet(packet: bytes) -> Optional[str]: + ... + + @staticmethod + def proto_of_message(packet: bytes) -> str: + ... + + @staticmethod + def proto_by_name(name: str) -> int: + ... + + +pmods: List[ProtoModule] = [] + + +class Client: + """Connected socket to the terminal plus buffer and metadata""" + + def __init__(self, sock: socket, addr: Tuple[str, int]) -> None: + self.sock = sock + self.addr = addr + self.pmod: Optional[ProtoModule] = None + self.stream: Optional[ProtoModule.Stream] = None + self.imei: Optional[str] = None + + def close(self) -> None: + log.debug("Closing fd %d (IMEI %s)", self.sock.fileno(), self.imei) + self.sock.close() + if self.stream: + rest = self.stream.close() + else: + rest = b"" + if rest: + log.warning( + "%d bytes in buffer on close: %s", len(rest), rest[:64].hex() + ) + + def recv(self) -> Optional[List[Tuple[float, Tuple[str, int], bytes]]]: + """Read from the socket and parse complete messages""" + try: + segment = self.sock.recv(MAXBUFFER) + except OSError as e: + log.warning( + "Reading from fd %d (IMEI %s): %s", + self.sock.fileno(), + self.imei, + e, + ) + return None + if not segment: # Terminal has closed connection + log.info( + "EOF reading from fd %d (IMEI %s)", + self.sock.fileno(), + self.imei, + ) + return None + if self.stream is None: + for pmod in pmods: + if pmod.probe_buffer(segment): + self.pmod = pmod + self.stream = pmod.Stream() + break + if self.stream is None: + log.info( + "unrecognizable %d bytes of data %s from fd %d", + len(segment), + segment[:32].hex(), + self.sock.fileno(), + ) + return [] + when = time() + msgs = [] + for elem in self.stream.recv(segment): + if isinstance(elem, bytes): + msgs.append((when, self.addr, elem)) + else: + log.warning( + "%s from fd %d (IMEI %s)", + elem, + self.sock.fileno(), + self.imei, + ) + return msgs + + def send(self, buffer: bytes) -> None: + assert self.stream is not None + try: + self.sock.send(self.stream.enframe(buffer)) + except OSError as e: + log.error( + "Sending to fd %d (IMEI %s): %s", + self.sock.fileno(), + self.imei, + e, + ) + + +class Clients: + def __init__(self) -> None: + self.by_fd: Dict[int, Client] = {} + self.by_imei: Dict[str, Client] = {} + + def add(self, clntsock: socket, clntaddr: Tuple[str, int]) -> int: + fd = clntsock.fileno() + log.info("Start serving fd %d from %s", fd, clntaddr) + self.by_fd[fd] = Client(clntsock, clntaddr) + return fd + + def stop(self, fd: int) -> None: + clnt = self.by_fd[fd] + log.info("Stop serving fd %d (IMEI %s)", clnt.sock.fileno(), clnt.imei) + clnt.close() + if clnt.imei: + del self.by_imei[clnt.imei] + del self.by_fd[fd] + + def recv( + self, fd: int + ) -> Optional[ + List[Tuple[ProtoModule, Optional[str], float, Tuple[str, int], bytes]] + ]: + clnt = self.by_fd[fd] + msgs = clnt.recv() + if msgs is None: + return None + result = [] + for when, peeraddr, packet in msgs: + assert clnt.pmod is not None + if clnt.imei is None: + imei = clnt.pmod.imei_from_packet(packet) + if imei is not None: + log.info("LOGIN from fd %d (IMEI %s)", fd, imei) + clnt.imei = imei + oldclnt = self.by_imei.get(clnt.imei) + if oldclnt is not None: + log.info( + "Orphaning fd %d with the same IMEI", + oldclnt.sock.fileno(), + ) + oldclnt.imei = None + self.by_imei[clnt.imei] = clnt + else: + log.warning( + "Login message from %s: %s, but client imei unfilled", + peeraddr, + packet, + ) + result.append((clnt.pmod, clnt.imei, when, peeraddr, packet)) + log.debug( + "Received from %s (IMEI %s): %s", + peeraddr, + clnt.imei, + packet.hex(), + ) + return result + + def response(self, resp: Resp) -> Optional[ProtoModule]: + if resp.imei in self.by_imei: + clnt = self.by_imei[resp.imei] + clnt.send(resp.packet) + return clnt.pmod + else: + log.info("Not connected (IMEI %s)", resp.imei) + return None + + +def runserver(conf: ConfigParser, handle_hibernate: bool = True) -> None: + global pmods + pmods = [ + cast(ProtoModule, import_module("." + modnm, __package__)) + for modnm in conf.get("collector", "protocols").split(",") + ] + # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! + zctx = zmq.Context() # type: ignore + zpub = zctx.socket(zmq.PUB) # type: ignore + zpull = zctx.socket(zmq.PULL) # type: ignore + oldmask = umask(0o117) + zpub.bind(conf.get("collector", "publishurl")) + zpull.bind(conf.get("collector", "listenurl")) + umask(oldmask) + tcpl = socket(AF_INET6, SOCK_STREAM) + tcpl.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) + tcpl.bind(("", conf.getint("collector", "port"))) + tcpl.listen(5) + tcpfd = tcpl.fileno() + poller = zmq.Poller() # type: ignore + poller.register(zpull, flags=zmq.POLLIN) + poller.register(tcpfd, flags=zmq.POLLIN) + clients = Clients() + try: + while True: + tosend = [] + topoll = [] + tostop = [] + events = poller.poll(1000) + for sk, fl in events: + if sk is zpull: + while True: + try: + msg = zpull.recv(zmq.NOBLOCK) + zmsg = Resp(msg) + tosend.append(zmsg) + except zmq.Again: + break + elif sk == tcpfd: + clntsock, clntaddr = tcpl.accept() + clntsock.setsockopt(SOL_SOCKET, SO_KEEPALIVE, 1) + topoll.append((clntsock, clntaddr)) + elif fl & zmq.POLLIN: + received = clients.recv(sk) + if received is None: + log.debug("Terminal gone from fd %d", sk) + tostop.append(sk) + else: + for pmod, imei, when, peeraddr, packet in received: + proto = pmod.proto_of_message(packet) + zpub.send( + Bcast( + proto=proto, + imei=imei, + when=when, + peeraddr=peeraddr, + packet=packet, + ).packed + ) + if ( + pmod.is_goodbye_packet(packet) + and handle_hibernate + ): + log.debug( + "Goodbye from fd %d (IMEI %s)", + sk, + imei, + ) + tostop.append(sk) + respmsg = pmod.inline_response(packet) + if respmsg is not None: + tosend.append( + Resp(imei=imei, when=when, packet=respmsg) + ) + else: + log.debug("Stray event: %s on socket %s", fl, sk) + # poll queue consumed, make changes now + for zmsg in tosend: + log.debug("Sending to the client: %s", zmsg) + rpmod = clients.response(zmsg) + if rpmod is not None: + zpub.send( + Bcast( + is_incoming=False, + proto=rpmod.proto_of_message(zmsg.packet), + when=zmsg.when, + imei=zmsg.imei, + packet=zmsg.packet, + ).packed + ) + for fd in tostop: + poller.unregister(fd) # type: ignore + clients.stop(fd) + for clntsock, clntaddr in topoll: + fd = clients.add(clntsock, clntaddr) + poller.register(fd, flags=zmq.POLLIN) + except KeyboardInterrupt: + zpub.close() + zpull.close() + zctx.destroy() # type: ignore + tcpl.close() + + +if __name__.endswith("__main__"): + runserver(common.init(log)) diff --git a/loctrkd/common.py b/loctrkd/common.py new file mode 100644 index 0000000..227611c --- /dev/null +++ b/loctrkd/common.py @@ -0,0 +1,98 @@ +""" Common housekeeping for all daemons """ + +from configparser import ConfigParser, SectionProxy +from getopt import getopt +from logging import Formatter, getLogger, Logger, StreamHandler, DEBUG, INFO +from logging.handlers import SysLogHandler +from pkg_resources import get_distribution, DistributionNotFound +from sys import argv, stderr, stdout +from typing import Any, Dict, List, Optional, Tuple, Union + +CONF = "/etc/loctrkd.conf" +PORT = 4303 +DBFN = "/var/lib/loctrkd/loctrkd.sqlite" + +try: + version = get_distribution("loctrkd").version +except DistributionNotFound: + version = "" + + +def init( + log: Logger, opts: Optional[List[Tuple[str, str]]] = None +) -> ConfigParser: + if opts is None: + opts, _ = getopt(argv[1:], "c:d") + dopts = dict(opts) + conf = readconfig(dopts["-c"] if "-c" in dopts else CONF) + log.setLevel(DEBUG if "-d" in dopts else INFO) + if stdout.isatty(): + fhdl = StreamHandler(stderr) + fhdl.setFormatter( + Formatter("%(asctime)s - %(levelname)s - %(message)s") + ) + log.addHandler(fhdl) + log.debug("%s starting with options: %s", version, dopts) + else: + lhdl = SysLogHandler(address="/dev/log") + lhdl.setFormatter( + Formatter("%(name)s[%(process)d]: %(levelname)s - %(message)s") + ) + log.addHandler(lhdl) + log.info("%s starting with options: %s", version, dopts) + return conf + + +def readconfig(fname: str) -> ConfigParser: + config = ConfigParser() + config["collector"] = { + "port": str(PORT), + } + config["storage"] = { + "dbfn": DBFN, + } + config["termconfig"] = {} + config.read(fname) + return config + + +def normconf(section: SectionProxy) -> Dict[str, Any]: + result: Dict[str, Any] = {} + for key, val in section.items(): + vals = val.split("\n") + if len(vals) > 1 and vals[0] == "": + vals = vals[1:] + lst: List[Union[str, int]] = [] + for el in vals: + try: + lst.append(int(el, 0)) + except ValueError: + if el[0] == '"' and el[-1] == '"': + el = el.strip('"').rstrip('"') + lst.append(el) + if not ( + all([isinstance(x, int) for x in lst]) + or all([isinstance(x, str) for x in lst]) + ): + raise ValueError( + "Values of %s - %s are of different type", key, vals + ) + if len(lst) == 1: + result[key] = lst[0] + else: + result[key] = lst + return result + + +if __name__ == "__main__": + from sys import argv + + def _print_config(conf: ConfigParser) -> None: + for section in conf.sections(): + print("section", section) + for option in conf.options(section): + print(" ", option, conf[section][option]) + + conf = readconfig(argv[1]) + _print_config(conf) + print(normconf(conf["termconfig"])) diff --git a/loctrkd/evstore.py b/loctrkd/evstore.py new file mode 100644 index 0000000..07b6dc4 --- /dev/null +++ b/loctrkd/evstore.py @@ -0,0 +1,76 @@ +""" sqlite event store """ + +from sqlite3 import connect, OperationalError +from typing import Any, List, Tuple + +__all__ = "fetch", "initdb", "stow" + +DB = None + +SCHEMA = """create table if not exists events ( + tstamp real not null, + imei text, + peeraddr text not null, + is_incoming int not null default TRUE, + proto text not null, + packet blob +)""" + + +def initdb(dbname: str) -> None: + global DB + DB = connect(dbname) + try: + DB.execute( + """alter table events add column + is_incoming int not null default TRUE""" + ) + except OperationalError: + DB.execute(SCHEMA) + + +def stow(**kwargs: Any) -> None: + assert DB is not None + parms = { + k: kwargs[k] if k in kwargs else v + for k, v in ( + ("is_incoming", True), + ("peeraddr", None), + ("when", 0.0), + ("imei", None), + ("proto", "UNKNOWN"), + ("packet", b""), + ) + } + assert len(kwargs) <= len(parms) + DB.execute( + """insert or ignore into events + (tstamp, imei, peeraddr, proto, packet, is_incoming) + values + (:when, :imei, :peeraddr, :proto, :packet, :is_incoming) + """, + parms, + ) + DB.commit() + + +def fetch( + imei: str, matchlist: List[Tuple[bool, str]], backlog: int +) -> List[Tuple[bool, float, bytes]]: + # matchlist is a list of tuples (is_incoming, proto) + # returns a list of tuples (is_incoming, timestamp, packet) + assert DB is not None + selector = " or ".join( + (f"(is_incoming = ? and proto = ?)" for _ in range(len(matchlist))) + ) + cur = DB.cursor() + cur.execute( + f"""select is_incoming, tstamp, packet from events + where ({selector}) and imei = ? + order by tstamp desc limit ?""", + tuple(item for sublist in matchlist for item in sublist) + + (imei, backlog), + ) + result = list(cur) + cur.close() + return list(reversed(result)) diff --git a/loctrkd/googlemaps.py b/loctrkd/googlemaps.py new file mode 100644 index 0000000..418523d --- /dev/null +++ b/loctrkd/googlemaps.py @@ -0,0 +1,76 @@ +import googlemaps as gmaps +from typing import Any, Dict, List, Tuple + +gclient = None + + +def init(conf: Dict[str, Any]) -> None: + global gclient + with open(conf["googlemaps"]["accesstoken"], encoding="ascii") as fl: + token = fl.read().rstrip() + gclient = gmaps.Client(key=token) + + +def shut() -> None: + return + + +def lookup( + mcc: int, + mnc: int, + gsm_cells: List[Tuple[int, int, int]], + wifi_aps: List[Tuple[str, int]], +) -> Tuple[float, float]: + assert gclient is not None + kwargs = { + "home_mobile_country_code": mcc, + "home_mobile_network_code": mnc, + "radio_type": "gsm", + "carrier": "O2", + "consider_ip": False, + "cell_towers": [ + { + "locationAreaCode": loc, + "cellId": cellid, + "signalStrength": sig, + } + for loc, cellid, sig in gsm_cells + ], + "wifi_access_points": [ + {"macAddress": mac, "signalStrength": sig} for mac, sig in wifi_aps + ], + } + result = gclient.geolocate(**kwargs) + if "location" in result: + return result["location"]["lat"], result["location"]["lng"] + else: + raise ValueError("google geolocation: " + str(result)) + + +if __name__.endswith("__main__"): + from datetime import datetime, timezone + from sqlite3 import connect + import sys + from .zx303proto import * + + db = connect(sys.argv[1]) + c = db.cursor() + c.execute( + """select tstamp, packet from events + where proto in (?, ?)""", + (proto_name(WIFI_POSITIONING), proto_name(WIFI_OFFLINE_POSITIONING)), + ) + init({"googlemaps": {"accesstoken": sys.argv[2]}}) + count = 0 + for timestamp, packet in c: + obj = parse_message(packet) + print(obj) + avlat, avlon = lookup(obj.mcc, obj.mnc, obj.gsm_cells, obj.wifi_aps) + print( + "{} {:+#010.8g},{:+#010.8g}".format( + datetime.fromtimestamp(timestamp), avlat, avlon + ) + ) + count += 1 + if count > 10: + break diff --git a/loctrkd/lookaside.py b/loctrkd/lookaside.py new file mode 100644 index 0000000..31c3105 --- /dev/null +++ b/loctrkd/lookaside.py @@ -0,0 +1,62 @@ +""" Estimate coordinates from WIFI_POSITIONING and send back """ + +from configparser import ConfigParser +from datetime import datetime, timezone +from importlib import import_module +from logging import getLogger +from os import umask +from struct import pack +import zmq + +from . import common +from .zx303proto import parse_message, proto_name, WIFI_POSITIONING +from .zmsg import Bcast, Resp, topic + +log = getLogger("loctrkd/lookaside") + + +def runserver(conf: ConfigParser) -> None: + qry = import_module("." + conf.get("lookaside", "backend"), __package__) + qry.init(conf) + # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! + zctx = zmq.Context() # type: ignore + zsub = zctx.socket(zmq.SUB) # type: ignore + zsub.connect(conf.get("collector", "publishurl")) + zsub.setsockopt(zmq.SUBSCRIBE, topic(proto_name(WIFI_POSITIONING))) + zpush = zctx.socket(zmq.PUSH) # type: ignore + zpush.connect(conf.get("collector", "listenurl")) + + try: + while True: + zmsg = Bcast(zsub.recv()) + msg = parse_message(zmsg.packet) + log.debug( + "IMEI %s from %s at %s: %s", + zmsg.imei, + zmsg.peeraddr, + datetime.fromtimestamp(zmsg.when).astimezone(tz=timezone.utc), + msg, + ) + try: + lat, lon = qry.lookup( + msg.mcc, msg.mnc, msg.gsm_cells, msg.wifi_aps + ) + resp = Resp( + imei=zmsg.imei, + when=zmsg.when, # not the current time, but the original! + packet=msg.Out(latitude=lat, longitude=lon).packed, + ) + log.debug("Response for lat=%s, lon=%s: %s", lat, lon, resp) + zpush.send(resp.packed) + except Exception as e: + log.warning("Lookup for %s resulted in %s", msg, e) + + except KeyboardInterrupt: + zsub.close() + zpush.close() + zctx.destroy() # type: ignore + qry.shut() + + +if __name__.endswith("__main__"): + runserver(common.init(log)) diff --git a/loctrkd/mkgpx.py b/loctrkd/mkgpx.py new file mode 100644 index 0000000..115dd80 --- /dev/null +++ b/loctrkd/mkgpx.py @@ -0,0 +1,60 @@ +""" Example that produces gpx from events in evstore """ + +# run as: +# python -m loctrkd.mkgpx +# Generated gpx is emitted to stdout + +from datetime import datetime, timezone +from sqlite3 import connect +import sys + +from .zx303proto import * + +db = connect(sys.argv[1]) +c = db.cursor() +c.execute( + """select tstamp, is_incoming, packet from events + where imei = ? + and ((is_incoming = false and proto = ?) + or (is_incoming = true and proto = ?)) + order by tstamp""", + (sys.argv[2], proto_name(WIFI_POSITIONING), proto_name(GPS_POSITIONING)), +) + +print( + """ + + Location Data + + Location Data + +""" +) + +for tstamp, is_incoming, packet in c: + msg = parse_message(packet, is_incoming=is_incoming) + lat, lon = msg.latitude, msg.longitude + isotime = ( + datetime.fromtimestamp(tstamp).astimezone(tz=timezone.utc).isoformat() + ) + isotime = isotime[: isotime.rfind(".")] + "Z" + trkpt = """ + + """.format( + lat, lon, isotime + ) + print(trkpt) + if False: + print( + datetime.fromtimestamp(tstamp) + .astimezone(tz=timezone.utc) + .isoformat(), + msg, + ) +print( + """ + +""" +) diff --git a/loctrkd/ocid_dload.py b/loctrkd/ocid_dload.py new file mode 100644 index 0000000..525a8b0 --- /dev/null +++ b/loctrkd/ocid_dload.py @@ -0,0 +1,135 @@ +from configparser import ConfigParser, NoOptionError +import csv +from logging import getLogger +import requests +from sqlite3 import connect +from typing import Any, IO, Optional +from zlib import decompressobj, MAX_WBITS + +from . import common + +log = getLogger("loctrkd/ocid_dload") + +RURL = ( + "https://opencellid.org/ocid/downloads" + "?token={token}&type={dltype}&file={fname}.csv.gz" +) + +SCHEMA = """create table if not exists cells ( + "radio" text, + "mcc" int, + "net" int, + "area" int, + "cell" int, + "unit" int, + "lon" int, + "lat" int, + "range" int, + "samples" int, + "changeable" int, + "created" int, + "updated" int, + "averageSignal" int +)""" +DBINDEX = "create index if not exists cell_idx on cells (area, cell)" + + +class unzipped: + """ + File-like object that unzips http response body. + read(size) method returns chunks of binary data as bytes + When used as iterator, splits data to lines + and yelds them as strings. + """ + + def __init__(self, zstream: IO[bytes]) -> None: + self.zstream = zstream + self.decoder: Optional[Any] = decompressobj(16 + MAX_WBITS) + self.outdata = b"" + self.line = b"" + + def read(self, n: int = 1024) -> bytes: + if self.decoder is None: + return b"" + while len(self.outdata) < n: + raw_data = self.zstream.read(n) + self.outdata += self.decoder.decompress(raw_data) + if not raw_data: + self.decoder = None + break + if self.outdata: + data, self.outdata = self.outdata[:n], self.outdata[n:] + return data + return b"" + + def __next__(self) -> str: + while True: + splittry = self.line.split(b"\n", maxsplit=1) + if len(splittry) > 1: + break + moredata = self.read(256) + if not moredata: + raise StopIteration + self.line += moredata + line, rest = splittry + self.line = rest + return line.decode("utf-8") + + def __iter__(self) -> "unzipped": + return self + + +def main(conf: ConfigParser) -> None: + try: + url = conf.get("opencellid", "downloadurl") + mcc = "" + except NoOptionError: + try: + with open( + conf.get("opencellid", "downloadtoken"), encoding="ascii" + ) as fl: + token = fl.read().strip() + except FileNotFoundError: + log.warning( + "Opencellid access token not configured, cannot download" + ) + return + mcc = conf.get("opencellid", "downloadmcc") + if mcc == "full": + dltype = "full" + fname = "cell_towers" + else: + dltype = "mcc" + fname = mcc + url = RURL.format(token=token, dltype="mcc", fname=mcc) + dbfn = conf.get("opencellid", "dbfn") + count = 0 + with requests.get(url, stream=True) as resp, connect(dbfn) as db: + log.debug("Requested %s, result %s", url, resp) + if resp.status_code != 200: + log.error("Error getting %s: %s", url, resp) + return + db.execute("pragma journal_mode = wal") + db.execute(SCHEMA) + db.execute("delete from cells") + rows = csv.reader(unzipped(resp.raw)) + for row in rows: + db.execute( + """insert into cells + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + row, + ) + count += 1 + if count < 1: + db.rollback() + log.warning("Did not get any data for MCC %s, rollback", mcc) + else: + db.execute(DBINDEX) + db.commit() + log.info( + "repopulated %s with %d records for MCC %s", dbfn, count, mcc + ) + + +if __name__.endswith("__main__"): + main(common.init(log)) diff --git a/loctrkd/opencellid.py b/loctrkd/opencellid.py new file mode 100644 index 0000000..583d2e1 --- /dev/null +++ b/loctrkd/opencellid.py @@ -0,0 +1,77 @@ +""" +Lookaside backend to query local opencellid database +""" + +from sqlite3 import connect +from typing import Any, Dict, List, Tuple + +__all__ = "init", "lookup" + +ldb = None + + +def init(conf: Dict[str, Any]) -> None: + global ldb + ldb = connect(conf["opencellid"]["dbfn"]) + + +def shut() -> None: + if ldb is not None: + ldb.close() + + +def lookup( + mcc: int, mnc: int, gsm_cells: List[Tuple[int, int, int]], __: Any +) -> Tuple[float, float]: + assert ldb is not None + lc = ldb.cursor() + lc.execute("""attach database ":memory:" as mem""") + lc.execute("create table mem.seen (locac int, cellid int, signal int)") + lc.executemany( + """insert into mem.seen (locac, cellid, signal) + values (?, ?, ?)""", + gsm_cells, + ) + ldb.commit() + lc.execute( + """select c.lat, c.lon, s.signal + from main.cells c, mem.seen s + where c.mcc = ? + and c.area = s.locac + and c.cell = s.cellid""", + (mcc,), + ) + data = list(lc.fetchall()) + if not data: + return 0.0, 0.0 + sumsig = sum([1 / sig for _, _, sig in data]) + nsigs = [1 / sig / sumsig for _, _, sig in data] + avlat = sum([lat * nsig for (lat, _, _), nsig in zip(data, nsigs)]) + avlon = sum([lon * nsig for (_, lon, _), nsig in zip(data, nsigs)]) + # lc.execute("drop table mem.seen") + lc.execute("""detach database mem""") + lc.close() + return avlat, avlon + + +if __name__.endswith("__main__"): + from datetime import datetime, timezone + import sys + from .zx303proto import * + + db = connect(sys.argv[1]) + c = db.cursor() + c.execute( + """select tstamp, packet from events + where proto in (?, ?)""", + (proto_name(WIFI_POSITIONING), proto_name(WIFI_OFFLINE_POSITIONING)), + ) + init({"opencellid": {"dbfn": sys.argv[2]}}) + for timestamp, packet in c: + obj = parse_message(packet) + avlat, avlon = lookup(obj.mcc, obj.mnc, obj.gsm_cells, obj.wifi_aps) + print( + "{} {:+#010.8g},{:+#010.8g}".format( + datetime.fromtimestamp(timestamp), avlat, avlon + ) + ) diff --git a/loctrkd/qry.py b/loctrkd/qry.py new file mode 100644 index 0000000..cde47ec --- /dev/null +++ b/loctrkd/qry.py @@ -0,0 +1,40 @@ +from datetime import datetime, timezone +from sqlite3 import connect +import sys + +from .zx303proto import parse_message, proto_by_name + +db = connect(sys.argv[1]) +c = db.cursor() +if len(sys.argv) > 2: + proto = proto_by_name(sys.argv[2]) + if proto < 0: + raise ValueError("No protocol with name " + sys.argv[2]) + selector = " where proto = :proto" +else: + proto = -1 + selector = "" + +c.execute( + "select tstamp, imei, peeraddr, proto, packet from events" + selector, + {"proto": proto}, +) + +for tstamp, imei, peeraddr, proto, packet in c: + if len(packet) > packet[0] + 1: + print( + "proto", + packet[1], + "datalen", + len(packet), + "msg.length", + packet[0], + file=sys.stderr, + ) + msg = parse_message(packet) + print( + datetime.fromtimestamp(tstamp).astimezone(tz=timezone.utc).isoformat(), + imei, + peeraddr, + msg, + ) diff --git a/loctrkd/storage.py b/loctrkd/storage.py new file mode 100644 index 0000000..473806e --- /dev/null +++ b/loctrkd/storage.py @@ -0,0 +1,51 @@ +""" Store zmq broadcasts to sqlite """ + +from configparser import ConfigParser +from datetime import datetime, timezone +from logging import getLogger +import zmq + +from . import common +from .evstore import initdb, stow +from .zx303proto import proto_of_message +from .zmsg import Bcast + +log = getLogger("loctrkd/storage") + + +def runserver(conf: ConfigParser) -> None: + dbname = conf.get("storage", "dbfn") + log.info('Using Sqlite3 database "%s"', dbname) + initdb(dbname) + # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! + zctx = zmq.Context() # type: ignore + zsub = zctx.socket(zmq.SUB) # type: ignore + zsub.connect(conf.get("collector", "publishurl")) + zsub.setsockopt(zmq.SUBSCRIBE, b"") + + try: + while True: + zmsg = Bcast(zsub.recv()) + log.debug( + "%s IMEI %s from %s at %s: %s", + "I" if zmsg.is_incoming else "O", + zmsg.imei, + zmsg.peeraddr, + datetime.fromtimestamp(zmsg.when).astimezone(tz=timezone.utc), + zmsg.packet.hex(), + ) + stow( + is_incoming=zmsg.is_incoming, + peeraddr=str(zmsg.peeraddr), + when=zmsg.when, + imei=zmsg.imei, + proto=proto_of_message(zmsg.packet), + packet=zmsg.packet, + ) + except KeyboardInterrupt: + zsub.close() + zctx.destroy() # type: ignore + + +if __name__.endswith("__main__"): + runserver(common.init(log)) diff --git a/loctrkd/termconfig.py b/loctrkd/termconfig.py new file mode 100644 index 0000000..f8f4b77 --- /dev/null +++ b/loctrkd/termconfig.py @@ -0,0 +1,85 @@ +""" For when responding to the terminal is not trivial """ + +from configparser import ConfigParser +from datetime import datetime, timezone +from logging import getLogger +from struct import pack +import zmq + +from . import common +from .zx303proto import * +from .zmsg import Bcast, Resp, topic + +log = getLogger("loctrkd/termconfig") + + +def runserver(conf: ConfigParser) -> None: + # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! + zctx = zmq.Context() # type: ignore + zsub = zctx.socket(zmq.SUB) # type: ignore + zsub.connect(conf.get("collector", "publishurl")) + for proto in ( + proto_name(STATUS), + proto_name(SETUP), + proto_name(POSITION_UPLOAD_INTERVAL), + ): + zsub.setsockopt(zmq.SUBSCRIBE, topic(proto)) + zpush = zctx.socket(zmq.PUSH) # type: ignore + zpush.connect(conf.get("collector", "listenurl")) + + try: + while True: + zmsg = Bcast(zsub.recv()) + msg = parse_message(zmsg.packet) + log.debug( + "IMEI %s from %s at %s: %s", + zmsg.imei, + zmsg.peeraddr, + datetime.fromtimestamp(zmsg.when).astimezone(tz=timezone.utc), + msg, + ) + if msg.RESPOND is not Respond.EXT: + log.error( + "%s does not expect externally provided response", msg + ) + if zmsg.imei is not None and conf.has_section(zmsg.imei): + termconfig = common.normconf(conf[zmsg.imei]) + elif conf.has_section("termconfig"): + termconfig = common.normconf(conf["termconfig"]) + else: + termconfig = {} + kwargs = {} + if isinstance(msg, STATUS): + kwargs = { + "upload_interval": termconfig.get( + "statusintervalminutes", 25 + ) + } + elif isinstance(msg, SETUP): + for key in ( + "uploadintervalseconds", + "binaryswitch", + "alarms", + "dndtimeswitch", + "dndtimes", + "gpstimeswitch", + "gpstimestart", + "gpstimestop", + "phonenumbers", + ): + if key in termconfig: + kwargs[key] = termconfig[key] + resp = Resp( + imei=zmsg.imei, when=zmsg.when, packet=msg.Out(**kwargs).packed + ) + log.debug("Response: %s", resp) + zpush.send(resp.packed) + + except KeyboardInterrupt: + zsub.close() + zpush.close() + zctx.destroy() # type: ignore + + +if __name__.endswith("__main__"): + runserver(common.init(log)) diff --git a/loctrkd/watch.py b/loctrkd/watch.py new file mode 100644 index 0000000..a8a53b0 --- /dev/null +++ b/loctrkd/watch.py @@ -0,0 +1,32 @@ +""" Watch for locevt and print them """ + +from configparser import ConfigParser +from datetime import datetime, timezone +from logging import getLogger +import zmq + +from . import common +from .zx303proto import parse_message +from .zmsg import Bcast + +log = getLogger("loctrkd/watch") + + +def runserver(conf: ConfigParser) -> None: + # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! + zctx = zmq.Context() # type: ignore + zsub = zctx.socket(zmq.SUB) # type: ignore + zsub.connect(conf.get("collector", "publishurl")) + zsub.setsockopt(zmq.SUBSCRIBE, b"") + + try: + while True: + zmsg = Bcast(zsub.recv()) + msg = parse_message(zmsg.packet, zmsg.is_incoming) + print("I" if zmsg.is_incoming else "O", zmsg.imei, msg) + except KeyboardInterrupt: + pass + + +if __name__.endswith("__main__"): + runserver(common.init(log)) diff --git a/loctrkd/wsgateway.py b/loctrkd/wsgateway.py new file mode 100644 index 0000000..522cd59 --- /dev/null +++ b/loctrkd/wsgateway.py @@ -0,0 +1,415 @@ +""" Websocket Gateway """ + +from configparser import ConfigParser +from datetime import datetime, timezone +from json import dumps, loads +from logging import getLogger +from socket import socket, AF_INET6, SOCK_STREAM, SOL_SOCKET, SO_REUSEADDR +from time import time +from typing import Any, cast, Dict, List, Optional, Set, Tuple +from wsproto import ConnectionType, WSConnection +from wsproto.events import ( + AcceptConnection, + CloseConnection, + Event, + Message, + Ping, + Request, + TextMessage, +) +from wsproto.utilities import RemoteProtocolError +import zmq + +from . import common +from .evstore import initdb, fetch +from .zx303proto import ( + GPS_POSITIONING, + STATUS, + WIFI_POSITIONING, + parse_message, + proto_name, +) +from .zmsg import Bcast, topic + +log = getLogger("loctrkd/wsgateway") +htmlfile = None + + +def backlog(imei: str, numback: int) -> List[Dict[str, Any]]: + result = [] + for is_incoming, timestamp, packet in fetch( + imei, + [ + (True, proto_name(GPS_POSITIONING)), + (False, proto_name(WIFI_POSITIONING)), + ], + numback, + ): + msg = parse_message(packet, is_incoming=is_incoming) + result.append( + { + "type": "location", + "imei": imei, + "timestamp": str( + datetime.fromtimestamp(timestamp).astimezone( + tz=timezone.utc + ) + ), + "longitude": msg.longitude, + "latitude": msg.latitude, + "accuracy": "gps" + if isinstance(msg, GPS_POSITIONING) + else "approximate", + } + ) + return result + + +def try_http(data: bytes, fd: int, e: Exception) -> bytes: + global htmlfile + try: + lines = data.decode().split("\r\n") + request = lines[0] + headers = lines[1:] + op, resource, proto = request.split(" ") + log.debug( + "HTTP %s for %s, proto %s from fd %d, headers: %s", + op, + resource, + proto, + fd, + headers, + ) + if op == "GET": + if htmlfile is None: + return ( + f"{proto} 500 No data configured\r\n" + f"Content-Type: text/plain\r\n\r\n" + f"HTML data not configured on the server\r\n".encode() + ) + else: + try: + with open(htmlfile, "rb") as fl: + htmldata = fl.read() + length = len(htmldata) + return ( + f"{proto} 200 Ok\r\n" + f"Content-Type: text/html; charset=utf-8\r\n" + f"Content-Length: {len(htmldata):d}\r\n\r\n" + ).encode("utf-8") + htmldata + except OSError: + return ( + f"{proto} 500 File not found\r\n" + f"Content-Type: text/plain\r\n\r\n" + f"HTML file could not be opened\r\n".encode() + ) + else: + return ( + f"{proto} 400 Bad request\r\n" + "Content-Type: text/plain\r\n\r\n" + "Bad request\r\n".encode() + ) + except ValueError: + log.warning("Unparseable data from fd %d: %s", fd, data) + raise e + + +class Client: + """Websocket connection to the client""" + + def __init__(self, sock: socket, addr: Tuple[str, int]) -> None: + self.sock = sock + self.addr = addr + self.ws = WSConnection(ConnectionType.SERVER) + self.ws_data = b"" + self.ready = False + self.imeis: Set[str] = set() + + def close(self) -> None: + log.debug("Closing fd %d", self.sock.fileno()) + self.sock.close() + + def recv(self) -> Optional[List[Dict[str, Any]]]: + try: + data = self.sock.recv(4096) + except OSError as e: + log.warning( + "Reading from fd %d: %s", + self.sock.fileno(), + e, + ) + self.ws.receive_data(None) + return None + if not data: # Client has closed connection + log.info( + "EOF reading from fd %d", + self.sock.fileno(), + ) + self.ws.receive_data(None) + return None + try: + self.ws.receive_data(data) + except RemoteProtocolError as e: + log.debug( + "Websocket error on fd %d, try plain http (%s)", + self.sock.fileno(), + e, + ) + self.ws_data = try_http(data, self.sock.fileno(), e) + # this `write` is a hack - writing _ought_ to be done at the + # stage when all other writes are performed. But I could not + # arrange it so in a logical way. Let it stay this way. The + # whole http server affair is a hack anyway. + self.write() + log.debug("Sending HTTP response to %d", self.sock.fileno()) + msgs = None + else: + msgs = [] + for event in self.ws.events(): + if isinstance(event, Request): + log.debug("WebSocket upgrade on fd %d", self.sock.fileno()) + # self.ws_data += self.ws.send(event.response()) # Why not?! + self.ws_data += self.ws.send(AcceptConnection()) + self.ready = True + elif isinstance(event, (CloseConnection, Ping)): + log.debug("%s on fd %d", event, self.sock.fileno()) + self.ws_data += self.ws.send(event.response()) + elif isinstance(event, TextMessage): + log.debug("%s on fd %d", event, self.sock.fileno()) + msg = loads(event.data) + msgs.append(msg) + if msg.get("type", None) == "subscribe": + self.imeis = set(msg.get("imei", [])) + log.debug( + "subs list on fd %s is %s", + self.sock.fileno(), + self.imeis, + ) + else: + log.warning("%s on fd %d", event, self.sock.fileno()) + return msgs + + def wants(self, imei: str) -> bool: + log.debug( + "wants %s? set is %s on fd %d", + imei, + self.imeis, + self.sock.fileno(), + ) + return imei in self.imeis + + def send(self, message: Dict[str, Any]) -> None: + if self.ready and message["imei"] in self.imeis: + self.ws_data += self.ws.send(Message(data=dumps(message))) + + def write(self) -> bool: + if self.ws_data: + try: + sent = self.sock.send(self.ws_data) + self.ws_data = self.ws_data[sent:] + except OSError as e: + log.error( + "Sending to fd %d: %s", + self.sock.fileno(), + e, + ) + self.ws_data = b"" + return bool(self.ws_data) + + +class Clients: + def __init__(self) -> None: + self.by_fd: Dict[int, Client] = {} + + def add(self, clntsock: socket, clntaddr: Tuple[str, int]) -> int: + fd = clntsock.fileno() + log.info("Start serving fd %d from %s", fd, clntaddr) + self.by_fd[fd] = Client(clntsock, clntaddr) + return fd + + def stop(self, fd: int) -> None: + clnt = self.by_fd[fd] + log.info("Stop serving fd %d", clnt.sock.fileno()) + clnt.close() + del self.by_fd[fd] + + def recv(self, fd: int) -> Optional[List[Dict[str, Any]]]: + clnt = self.by_fd[fd] + return clnt.recv() + + def send(self, msg: Dict[str, Any]) -> Set[int]: + towrite = set() + for fd, clnt in self.by_fd.items(): + if clnt.wants(msg["imei"]): + clnt.send(msg) + towrite.add(fd) + return towrite + + def write(self, towrite: Set[int]) -> Set[int]: + waiting = set() + for fd, clnt in [(fd, self.by_fd.get(fd)) for fd in towrite]: + if clnt and clnt.write(): + waiting.add(fd) + return waiting + + def subs(self) -> Set[str]: + result = set() + for clnt in self.by_fd.values(): + result |= clnt.imeis + return result + + +def runserver(conf: ConfigParser) -> None: + global htmlfile + + initdb(conf.get("storage", "dbfn")) + htmlfile = conf.get("wsgateway", "htmlfile", fallback=None) + # Is this https://github.com/zeromq/pyzmq/issues/1627 still not fixed?! + zctx = zmq.Context() # type: ignore + zsub = zctx.socket(zmq.SUB) # type: ignore + zsub.connect(conf.get("collector", "publishurl")) + tcpl = socket(AF_INET6, SOCK_STREAM) + tcpl.setblocking(False) + tcpl.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) + tcpl.bind(("", conf.getint("wsgateway", "port"))) + tcpl.listen(5) + tcpfd = tcpl.fileno() + poller = zmq.Poller() # type: ignore + poller.register(zsub, flags=zmq.POLLIN) + poller.register(tcpfd, flags=zmq.POLLIN) + clients = Clients() + activesubs: Set[str] = set() + try: + towait: Set[int] = set() + while True: + neededsubs = clients.subs() + for imei in neededsubs - activesubs: + zsub.setsockopt( + zmq.SUBSCRIBE, + topic(proto_name(GPS_POSITIONING), True, imei), + ) + zsub.setsockopt( + zmq.SUBSCRIBE, + topic(proto_name(WIFI_POSITIONING), False, imei), + ) + zsub.setsockopt( + zmq.SUBSCRIBE, + topic(proto_name(STATUS), True, imei), + ) + for imei in activesubs - neededsubs: + zsub.setsockopt( + zmq.UNSUBSCRIBE, + topic(proto_name(GPS_POSITIONING), True, imei), + ) + zsub.setsockopt( + zmq.UNSUBSCRIBE, + topic(proto_name(WIFI_POSITIONING), False, imei), + ) + zsub.setsockopt( + zmq.UNSUBSCRIBE, + topic(proto_name(STATUS), True, imei), + ) + activesubs = neededsubs + log.debug("Subscribed to: %s", activesubs) + tosend = [] + topoll = [] + tostop = [] + towrite = set() + events = poller.poll() + for sk, fl in events: + if sk is zsub: + while True: + try: + zmsg = Bcast(zsub.recv(zmq.NOBLOCK)) + msg = parse_message(zmsg.packet, zmsg.is_incoming) + log.debug("Got %s with %s", zmsg, msg) + if isinstance(msg, STATUS): + tosend.append( + { + "type": "status", + "imei": zmsg.imei, + "timestamp": str( + datetime.fromtimestamp( + zmsg.when + ).astimezone(tz=timezone.utc) + ), + "battery": msg.batt, + } + ) + else: + tosend.append( + { + "type": "location", + "imei": zmsg.imei, + "timestamp": str( + datetime.fromtimestamp( + zmsg.when + ).astimezone(tz=timezone.utc) + ), + "longitude": msg.longitude, + "latitude": msg.latitude, + "accuracy": "gps" + if zmsg.is_incoming + else "approximate", + } + ) + except zmq.Again: + break + elif sk == tcpfd: + clntsock, clntaddr = tcpl.accept() + topoll.append((clntsock, clntaddr)) + elif fl & zmq.POLLIN: + received = clients.recv(sk) + if received is None: + log.debug("Client gone from fd %d", sk) + tostop.append(sk) + towait.discard(sk) + else: + for wsmsg in received: + log.debug("Received from %d: %s", sk, wsmsg) + if wsmsg.get("type", None) == "subscribe": + # Have to live w/o typeckeding from json + imeis = cast(List[str], wsmsg.get("imei")) + numback: int = wsmsg.get("backlog", 5) + for imei in imeis: + tosend.extend(backlog(imei, numback)) + towrite.add(sk) + elif fl & zmq.POLLOUT: + log.debug("Write now open for fd %d", sk) + towrite.add(sk) + towait.discard(sk) + else: + log.debug("Stray event: %s on socket %s", fl, sk) + # poll queue consumed, make changes now + for fd in tostop: + poller.unregister(fd) # type: ignore + clients.stop(fd) + for wsmsg in tosend: + log.debug("Sending to the clients: %s", wsmsg) + towrite |= clients.send(wsmsg) + for clntsock, clntaddr in topoll: + fd = clients.add(clntsock, clntaddr) + poller.register(fd, flags=zmq.POLLIN) + # Deal with actually writing the data out + trywrite = towrite - towait + morewait = clients.write(trywrite) + log.debug( + "towait %s, tried %s, still busy %s", + towait, + trywrite, + morewait, + ) + for fd in morewait - trywrite: # new fds waiting for write + poller.modify(fd, flags=zmq.POLLIN | zmq.POLLOUT) # type: ignore + for fd in trywrite - morewait: # no longer waiting for write + poller.modify(fd, flags=zmq.POLLIN) # type: ignore + towait &= trywrite + towait |= morewait + except KeyboardInterrupt: + zsub.close() + zctx.destroy() # type: ignore + tcpl.close() + + +if __name__.endswith("__main__"): + runserver(common.init(log)) diff --git a/loctrkd/zmsg.py b/loctrkd/zmsg.py new file mode 100644 index 0000000..b6faa70 --- /dev/null +++ b/loctrkd/zmsg.py @@ -0,0 +1,168 @@ +""" Zeromq messages """ + +import ipaddress as ip +from struct import pack, unpack +from typing import Any, cast, Optional, Tuple, Type, Union + +__all__ = "Bcast", "Resp", "topic" + + +def pack_peer( # 18 bytes + peeraddr: Union[None, Tuple[str, int], Tuple[str, int, Any, Any]] +) -> bytes: + if peeraddr is None: + addr: Union[ip.IPv4Address, ip.IPv6Address] = ip.IPv6Address(0) + port = 0 + elif len(peeraddr) == 2: + peeraddr = cast(Tuple[str, int], peeraddr) + saddr, port = peeraddr + addr = ip.ip_address(saddr) + elif len(peeraddr) == 4: + peeraddr = cast(Tuple[str, int, Any, Any], peeraddr) + saddr, port, _x, _y = peeraddr + addr = ip.ip_address(saddr) + if isinstance(addr, ip.IPv4Address): + addr = ip.IPv6Address(b"\0\0\0\0\0\0\0\0\0\0\xff\xff" + addr.packed) + return addr.packed + pack("!H", port) + + +def unpack_peer( + buffer: bytes, +) -> Tuple[str, int]: + a6 = ip.IPv6Address(buffer[:16]) + port = unpack("!H", buffer[16:])[0] + a4 = a6.ipv4_mapped + if a4 is not None: + return (str(a4), port) + elif a6 == ip.IPv6Address("::"): + return ("", 0) + return (str(a6), port) + + +class _Zmsg: + KWARGS: Tuple[Tuple[str, Any], ...] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + if len(args) == 1: + self.decode(args[0]) + elif bool(kwargs): + for k, v in self.KWARGS: + setattr(self, k, kwargs.get(k, v)) + else: + raise RuntimeError( + self.__class__.__name__ + + ": both args " + + str(args) + + " and kwargs " + + str(kwargs) + ) + + def __repr__(self) -> str: + return "{}({})".format( + self.__class__.__name__, + ", ".join( + [ + "{}={}".format( + k, + 'bytes.fromhex("{}")'.format(getattr(self, k).hex()) + if isinstance(getattr(self, k), bytes) + else getattr(self, k), + ) + for k, _ in self.KWARGS + ] + ), + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, self.__class__): + return all( + [getattr(self, k) == getattr(other, k) for k, _ in self.KWARGS] + ) + return NotImplemented + + def decode(self, buffer: bytes) -> None: + raise NotImplementedError( + self.__class__.__name__ + "must implement `decode()` method" + ) + + @property + def packed(self) -> bytes: + raise NotImplementedError( + self.__class__.__name__ + "must implement `packed()` property" + ) + + +def topic( + proto: str, is_incoming: bool = True, imei: Optional[str] = None +) -> bytes: + return pack("B16s", is_incoming, proto.encode()) + ( + b"" if imei is None else pack("16s", imei.encode()) + ) + + +class Bcast(_Zmsg): + """Zmq message to broadcast what was received from the terminal""" + + KWARGS = ( + ("is_incoming", True), + ("proto", "UNKNOWN"), + ("imei", None), + ("when", None), + ("peeraddr", None), + ("packet", b""), + ) + + @property + def packed(self) -> bytes: + return ( + pack( + "!B16s16sd", + int(self.is_incoming), + self.proto[:16].ljust(16, "\0").encode(), + b"0000000000000000" + if self.imei is None + else self.imei.encode(), + 0 if self.when is None else self.when, + ) + + pack_peer(self.peeraddr) + + self.packet + ) + + def decode(self, buffer: bytes) -> None: + is_incoming, proto, imei, when = unpack("!B16s16sd", buffer[:41]) + self.is_incoming = bool(is_incoming) + self.proto = proto.decode() + self.imei = ( + None if imei == b"0000000000000000" else imei.decode().strip("\0") + ) + self.when = when + self.peeraddr = unpack_peer(buffer[41:59]) + self.packet = buffer[59:] + + +class Resp(_Zmsg): + """Zmq message received from a third party to send to the terminal""" + + KWARGS = (("imei", None), ("when", None), ("packet", b"")) + + @property + def packed(self) -> bytes: + return ( + pack( + "!16sd", + "0000000000000000" + if self.imei is None + else self.imei.encode(), + 0 if self.when is None else self.when, + ) + + self.packet + ) + + def decode(self, buffer: bytes) -> None: + imei, when = unpack("!16sd", buffer[:24]) + self.imei = ( + None if imei == b"0000000000000000" else imei.decode().strip("\0") + ) + + self.when = when + self.packet = buffer[24:] diff --git a/loctrkd/zx303proto.py b/loctrkd/zx303proto.py new file mode 100755 index 0000000..efb02d2 --- /dev/null +++ b/loctrkd/zx303proto.py @@ -0,0 +1,950 @@ +""" +Implementation of the protocol used by zx303 "ZhongXun Topin Locator" +GPS+GPRS module. Description lifted from this repository: +https://github.com/tobadia/petGPS/tree/master/resources + +Forewarnings: +1. There is no security whatsoever. If you know the module's IMEI, + you can feed fake data to the server, including fake location. +2. Ad-hoc choice of framing of messages (that are transferred over + the TCP stream) makes it vulnerable to coincidental appearance + of framing bytes in the middle of the message. Most of the time + the server will receive one message in one TCP segment (i.e. in + one `recv()` operation, but relying on that would break things + if the path has lower MTU than the size of a message. +""" + +from datetime import datetime, timezone +from enum import Enum +from inspect import isclass +from struct import error, pack, unpack +from time import time +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Tuple, + Type, + TYPE_CHECKING, + Union, +) + +__all__ = ( + "Stream", + "class_by_prefix", + "inline_response", + "parse_message", + "probe_buffer", + "proto_by_name", + "proto_name", + "DecodeError", + "Respond", + "GPS303Pkt", + "UNKNOWN", + "LOGIN", + "SUPERVISION", + "HEARTBEAT", + "GPS_POSITIONING", + "GPS_OFFLINE_POSITIONING", + "STATUS", + "HIBERNATION", + "RESET", + "WHITELIST_TOTAL", + "WIFI_OFFLINE_POSITIONING", + "TIME", + "PROHIBIT_LBS", + "GPS_LBS_SWITCH_TIMES", + "REMOTE_MONITOR_PHONE", + "SOS_PHONE", + "DAD_PHONE", + "MOM_PHONE", + "STOP_UPLOAD", + "GPS_OFF_PERIOD", + "DND_PERIOD", + "RESTART_SHUTDOWN", + "DEVICE", + "ALARM_CLOCK", + "STOP_ALARM", + "SETUP", + "SYNCHRONOUS_WHITELIST", + "RESTORE_PASSWORD", + "WIFI_POSITIONING", + "MANUAL_POSITIONING", + "BATTERY_CHARGE", + "CHARGER_CONNECTED", + "CHARGER_DISCONNECTED", + "VIBRATION_RECEIVED", + "POSITION_UPLOAD_INTERVAL", + "SOS_ALARM", + "UNKNOWN_B3", +) + +PROTO_PREFIX = "ZX" + +### Deframer ### + +MAXBUFFER: int = 4096 + + +class Stream: + def __init__(self) -> None: + self.buffer = b"" + + @staticmethod + def enframe(buffer: bytes) -> bytes: + return b"xx" + buffer + b"\r\n" + + def recv(self, segment: bytes) -> List[Union[bytes, str]]: + """ + Process next segment of the stream. Return successfully deframed + packets as `bytes` and error messages as `str`. + """ + when = time() + self.buffer += segment + if len(self.buffer) > MAXBUFFER: + # We are receiving junk. Let's drop it or we run out of memory. + self.buffer = b"" + return [f"More than {MAXBUFFER} unparseable data, dropping"] + msgs: List[Union[bytes, str]] = [] + while True: + framestart = self.buffer.find(b"xx") + if framestart == -1: # No frames, return whatever we have + break + if framestart > 0: # Should not happen, report + msgs.append( + f'Undecodable data ({framestart}) "{self.buffer[:framestart][:64].hex()}"' + ) + self.buffer = self.buffer[framestart:] + # At this point, buffer starts with a packet + if len(self.buffer) < 6: # no len and proto - cannot proceed + break + exp_end = self.buffer[2] + 3 # Expect '\r\n' here + frameend = 0 + # Length field can legitimeely be much less than the + # length of the packet (e.g. WiFi positioning), but + # it _should not_ be greater. Still sometimes it is. + # Luckily, not by too much: by maybe two or three bytes? + # Do this embarrassing hack to avoid accidental match + # of some binary data in the packet against '\r\n'. + while True: + frameend = self.buffer.find(b"\r\n", frameend + 1) + if frameend == -1 or frameend >= ( + exp_end - 3 + ): # Found realistic match or none + break + if frameend == -1: # Incomplete frame, return what we have + break + packet = self.buffer[2:frameend] + self.buffer = self.buffer[frameend + 2 :] + if len(packet) < 2: # frameend comes too early + msgs.append(f"Packet too short: {packet.hex()}") + else: + msgs.append(packet) + return msgs + + def close(self) -> bytes: + ret = self.buffer + self.buffer = b"" + return ret + + +### Parser/Constructor ### + + +class DecodeError(Exception): + def __init__(self, e: Exception, **kwargs: Any) -> None: + super().__init__(e) + for k, v in kwargs.items(): + setattr(self, k, v) + + +def maybe(typ: type) -> Callable[[Any], Any]: + return lambda x: None if x is None else typ(x) + + +def intx(x: Union[str, int]) -> int: + if isinstance(x, str): + x = int(x, 0) + return x + + +def boolx(x: Union[str, bool]) -> bool: + if isinstance(x, str): + if x.upper() in ("ON", "TRUE", "1"): + return True + if x.upper() in ("OFF", "FALSE", "0"): + return False + raise ValueError(str(x) + " could not be parsed as a Boolean") + return x + + +def hhmm(x: str) -> str: + """Check for the string that represents hours and minutes""" + if not isinstance(x, str) or len(x) != 4: + raise ValueError(str(x) + " is not a four-character string") + hh = int(x[:2]) + mm = int(x[2:]) + if hh < 0 or hh > 23 or mm < 0 or mm > 59: + raise ValueError(str(x) + " does not contain valid hours and minutes") + return x + + +def hhmmhhmm(x: str) -> str: + """Check for the string that represents hours and minutes twice""" + if not isinstance(x, str) or len(x) != 8: + raise ValueError(str(x) + " is not an eight-character string") + return hhmm(x[:4]) + hhmm(x[4:]) + + +def l3str(x: Union[str, List[str]]) -> List[str]: + if isinstance(x, str): + lx = x.split(",") + else: + lx = x + if len(lx) != 3 or not all(isinstance(el, str) for el in x): + raise ValueError(str(lx) + " is not a list of three strings") + return lx + + +def l3alarms(x: Union[str, List[Tuple[int, str]]]) -> List[Tuple[int, str]]: + def alrmspec(sub: str) -> Tuple[int, str]: + if len(sub) != 7: + raise ValueError(sub + " does not represent day and time") + return ( + { + "MON": 1, + "TUE": 2, + "WED": 3, + "THU": 4, + "FRI": 5, + "SAT": 6, + "SUN": 7, + }[sub[:3].upper()], + sub[3:], + ) + + if isinstance(x, str): + lx = [alrmspec(sub) for sub in x.split(",")] + else: + lx = x + lx.extend([(0, "0000") for _ in range(3 - len(lx))]) + if len(lx) != 3 or any(d < 0 or d > 7 for d, tm in lx): + raise ValueError(str(lx) + " is a wrong alarms specification") + return [(d, hhmm(tm)) for d, tm in lx] + + +def l3int(x: Union[str, List[int]]) -> List[int]: + if isinstance(x, str): + lx = [int(el) for el in x.split(",")] + else: + lx = x + if len(lx) != 3 or not all(isinstance(el, int) for el in lx): + raise ValueError(str(lx) + " is not a list of three integers") + return lx + + +class MetaPkt(type): + """ + For each class corresponding to a message, automatically create + two nested classes `In` and `Out` that also inherit from their + "nest". Class attribute `IN_KWARGS` defined in the "nest" is + copied to the `In` nested class under the name `KWARGS`, and + likewise, `OUT_KWARGS` of the nest class is copied as `KWARGS` + to the nested class `Out`. In addition, method `encode` is + defined in both classes equal to `in_encode()` and `out_encode()` + respectively. + """ + + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: + pass + + def __setattr__(self, name: str, value: Any) -> None: + pass + + def __new__( + cls: Type["MetaPkt"], + name: str, + bases: Tuple[type, ...], + attrs: Dict[str, Any], + ) -> "MetaPkt": + newcls = super().__new__(cls, name, bases, attrs) + newcls.In = super().__new__( + cls, + name + ".In", + (newcls,) + bases, + { + "KWARGS": newcls.IN_KWARGS, + "decode": newcls.in_decode, + "encode": newcls.in_encode, + }, + ) + newcls.Out = super().__new__( + cls, + name + ".Out", + (newcls,) + bases, + { + "KWARGS": newcls.OUT_KWARGS, + "decode": newcls.out_decode, + "encode": newcls.out_encode, + }, + ) + return newcls + + +class Respond(Enum): + NON = 0 # Incoming, no response needed + INL = 1 # Birirectional, use `inline_response()` + EXT = 2 # Birirectional, use external responder + + +class GPS303Pkt(metaclass=MetaPkt): + RESPOND = Respond.NON # Do not send anything back by default + PROTO: int + IN_KWARGS: Tuple[Tuple[str, Callable[[Any], Any], Any], ...] = () + OUT_KWARGS: Tuple[Tuple[str, Callable[[Any], Any], Any], ...] = () + KWARGS: Tuple[Tuple[str, Callable[[Any], Any], Any], ...] = () + In: Type["GPS303Pkt"] + Out: Type["GPS303Pkt"] + + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: + pass + + def __setattr__(self, name: str, value: Any) -> None: + pass + + def __init__(self, *args: Any, **kwargs: Any): + """ + Construct the object _either_ from (length, payload), + _or_ from the values of individual fields + """ + assert not args or (len(args) == 2 and not kwargs) + if args: # guaranteed to be two arguments at this point + self.length, self.payload = args + try: + self.decode(self.length, self.payload) + except error as e: + raise DecodeError(e, obj=self) + else: + for kw, typ, dfl in self.KWARGS: + setattr(self, kw, typ(kwargs.pop(kw, dfl))) + if kwargs: + raise ValueError( + self.__class__.__name__ + " stray kwargs " + str(kwargs) + ) + + def __repr__(self) -> str: + return "{}({})".format( + self.__class__.__name__, + ", ".join( + "{}={}".format( + k, + 'bytes.fromhex("{}")'.format(v.hex()) + if isinstance(v, bytes) + else v.__repr__(), + ) + for k, v in self.__dict__.items() + if not k.startswith("_") + ), + ) + + decode: Callable[["GPS303Pkt", int, bytes], None] + + def in_decode(self, length: int, packet: bytes) -> None: + # Overridden in subclasses, otherwise do not decode payload + return + + def out_decode(self, length: int, packet: bytes) -> None: + # Overridden in subclasses, otherwise do not decode payload + return + + encode: Callable[["GPS303Pkt"], bytes] + + def in_encode(self) -> bytes: + # Necessary to emulate terminal, which is not implemented + raise NotImplementedError( + self.__class__.__name__ + ".encode() not implemented" + ) + + def out_encode(self) -> bytes: + # Overridden in subclasses, otherwise make empty payload + return b"" + + @property + def packed(self) -> bytes: + payload = self.encode() + length = getattr(self, "length", len(payload) + 1) + return pack("BB", length, self.PROTO) + payload + + +class UNKNOWN(GPS303Pkt): + PROTO = 256 # > 255 is impossible in real packets + + +class LOGIN(GPS303Pkt): + PROTO = 0x01 + RESPOND = Respond.INL + # Default response for ACK, can also respond with STOP_UPLOAD + IN_KWARGS = (("imei", str, "0000000000000000"), ("ver", int, 0)) + + def in_decode(self, length: int, payload: bytes) -> None: + self.imei = payload[:8].ljust(8, b"\0").hex() + self.ver = payload[8] + + def in_encode(self) -> bytes: + return bytes.fromhex(self.imei).ljust(8, b"\0")[:8] + pack( + "B", self.ver + ) + + +class SUPERVISION(GPS303Pkt): + PROTO = 0x05 + OUT_KWARGS = (("status", int, 1),) + + def out_encode(self) -> bytes: + # 1: The device automatically answers Pickup effect + # 2: Automatically Answering Two-way Calls + # 3: Ring manually answer the two-way call + return pack("B", self.status) + + +class HEARTBEAT(GPS303Pkt): + PROTO = 0x08 + RESPOND = Respond.INL + + +class _GPS_POSITIONING(GPS303Pkt): + RESPOND = Respond.INL + + def in_decode(self, length: int, payload: bytes) -> None: + self.dtime = payload[:6] + if self.dtime == b"\0\0\0\0\0\0": + self.devtime = None + else: + yr, mo, da, hr, mi, se = unpack("BBBBBB", self.dtime) + self.devtime = datetime( + 2000 + yr, mo, da, hr, mi, se, tzinfo=timezone.utc + ) + self.gps_data_length = payload[6] >> 4 + self.gps_nb_sat = payload[6] & 0x0F + lat, lon, speed, flags = unpack("!IIBH", payload[7:18]) + self.gps_is_valid = bool(flags & 0b0001000000000000) # bit 3 + flip_lon = bool(flags & 0b0000100000000000) # bit 4 + flip_lat = not bool(flags & 0b0000010000000000) # bit 5 + self.heading = flags & 0b0000001111111111 # bits 6 - last + self.latitude = lat / (30000 * 60) * (-1 if flip_lat else 1) + self.longitude = lon / (30000 * 60) * (-1 if flip_lon else 1) + self.speed = speed + self.flags = flags + + def out_encode(self) -> bytes: + tup = datetime.utcnow().timetuple() + ttup = (tup[0] % 100,) + tup[1:6] + return pack("BBBBBB", *ttup) + + +class GPS_POSITIONING(_GPS_POSITIONING): + PROTO = 0x10 + + +class GPS_OFFLINE_POSITIONING(_GPS_POSITIONING): + PROTO = 0x11 + + +class STATUS(GPS303Pkt): + PROTO = 0x13 + RESPOND = Respond.EXT + IN_KWARGS = ( + ("batt", int, 100), + ("ver", int, 0), + ("timezone", int, 0), + ("intvl", int, 0), + ("signal", maybe(int), None), + ) + OUT_KWARGS = (("upload_interval", int, 25),) + + def in_decode(self, length: int, payload: bytes) -> None: + self.batt, self.ver, self.timezone, self.intvl = unpack( + "BBBB", payload[:4] + ) + if len(payload) > 4: + self.signal: Optional[int] = payload[4] + else: + self.signal = None + + def in_encode(self) -> bytes: + return pack("BBBB", self.batt, self.ver, self.timezone, self.intvl) + ( + b"" if self.signal is None else pack("B", self.signal) + ) + + def out_encode(self) -> bytes: # Set interval in minutes + return pack("B", self.upload_interval) + + +class HIBERNATION(GPS303Pkt): # Server can send to send devicee to sleep + PROTO = 0x14 + + def in_encode(self) -> bytes: + return b"" + + +class RESET(GPS303Pkt): + # Device sends when it got reset SMS + # Server can send to initiate factory reset + PROTO = 0x15 + + +class WHITELIST_TOTAL(GPS303Pkt): # Server sends to initiage sync (0x58) + PROTO = 0x16 + OUT_KWARGS = (("number", int, 3),) + + def out_encode(self) -> bytes: # Number of whitelist entries + return pack("B", self.number) + + +class _WIFI_POSITIONING(GPS303Pkt): + IN_KWARGS: Tuple[Tuple[str, Callable[[Any], Any], Any], ...] = ( + # IN_KWARGS = ( + ("dtime", bytes, b"\0\0\0\0\0\0"), + ("wifi_aps", list, []), + ("mcc", int, 0), + ("mnc", int, 0), + ("gsm_cells", list, []), + ) + + def in_decode(self, length: int, payload: bytes) -> None: + self.dtime = payload[:6] + if self.dtime == b"\0\0\0\0\0\0": + self.devtime = None + else: + self.devtime = datetime.strptime( + self.dtime.hex(), "%y%m%d%H%M%S" + ).astimezone(tz=timezone.utc) + self.wifi_aps = [] + for i in range(self.length): # length has special meaning here + slice = payload[6 + i * 7 : 13 + i * 7] + self.wifi_aps.append( + (":".join([format(b, "02X") for b in slice[:6]]), -slice[6]) + ) + gsm_slice = payload[6 + self.length * 7 :] + ncells, self.mcc, self.mnc = unpack("!BHB", gsm_slice[:4]) + self.gsm_cells = [] + for i in range(ncells): + slice = gsm_slice[4 + i * 5 : 9 + i * 5] + locac, cellid, sigstr = unpack( + "!HHB", gsm_slice[4 + i * 5 : 9 + i * 5] + ) + self.gsm_cells.append((locac, cellid, -sigstr)) + + def in_encode(self) -> bytes: + self.length = len(self.wifi_aps) + return b"".join( + [ + self.dtime, + b"".join( + [ + bytes.fromhex(mac.replace(":", "")).ljust(6, b"\0")[:6] + + pack("B", -sigstr) + for mac, sigstr in self.wifi_aps + ] + ), + pack("!BHB", len(self.gsm_cells), self.mcc, self.mnc), + b"".join( + [ + pack("!HHB", locac, cellid, -sigstr) + for locac, cellid, sigstr in self.gsm_cells + ] + ), + ] + ) + + +class WIFI_OFFLINE_POSITIONING(_WIFI_POSITIONING): + PROTO = 0x17 + RESPOND = Respond.INL + + def out_encode(self) -> bytes: + return bytes.fromhex(datetime.utcnow().strftime("%y%m%d%H%M%S")) + + +class TIME(GPS303Pkt): + PROTO = 0x30 + RESPOND = Respond.INL + + def out_encode(self) -> bytes: + return pack("!HBBBBB", *datetime.utcnow().timetuple()[:6]) + + +class PROHIBIT_LBS(GPS303Pkt): + PROTO = 0x33 + OUT_KWARGS = (("status", int, 1),) + + def out_encode(self) -> bytes: # Server sent, 0-off, 1-on + return pack("B", self.status) + + +class GPS_LBS_SWITCH_TIMES(GPS303Pkt): + PROTO = 0x34 + + OUT_KWARGS = ( + ("gps_off", boolx, False), # Clarify the meaning of 0/1 + ("gps_interval_set", boolx, False), + ("gps_interval", hhmmhhmm, "00000000"), + ("lbs_off", boolx, False), # Clarify the meaning of 0/1 + ("boot_time_set", boolx, False), + ("boot_time", hhmm, "0000"), + ("shut_time_set", boolx, False), + ("shut_time", hhmm, "0000"), + ) + + def out_encode(self) -> bytes: + return ( + pack("B", self.gps_off) + + pack("B", self.gps_interval_set) + + bytes.fromhex(self.gps_interval) + + pack("B", self.lbs_off) + + pack("B", self.boot_time_set) + + bytes.fromhex(self.boot_time) + + pack("B", self.shut_time_set) + + bytes.fromhex(self.shut_time) + ) + + +class _SET_PHONE(GPS303Pkt): + OUT_KWARGS = (("phone", str, ""),) + + def out_encode(self) -> bytes: + self.phone: str + return self.phone.encode("") + + +class REMOTE_MONITOR_PHONE(_SET_PHONE): + PROTO = 0x40 + + +class SOS_PHONE(_SET_PHONE): + PROTO = 0x41 + + +class DAD_PHONE(_SET_PHONE): + PROTO = 0x42 + + +class MOM_PHONE(_SET_PHONE): + PROTO = 0x43 + + +class STOP_UPLOAD(GPS303Pkt): # Server response to LOGIN to thwart the device + PROTO = 0x44 + + +class GPS_OFF_PERIOD(GPS303Pkt): + PROTO = 0x46 + OUT_KWARGS = ( + ("onoff", int, 0), + ("fm", hhmm, "0000"), + ("to", hhmm, "2359"), + ) + + def out_encode(self) -> bytes: + return ( + pack("B", self.onoff) + + bytes.fromhex(self.fm) + + bytes.fromhex(self.to) + ) + + +class DND_PERIOD(GPS303Pkt): + PROTO = 0x47 + OUT_KWARGS = ( + ("onoff", int, 0), + ("week", int, 3), + ("fm1", hhmm, "0000"), + ("to1", hhmm, "2359"), + ("fm2", hhmm, "0000"), + ("to2", hhmm, "2359"), + ) + + def out_encode(self) -> bytes: + return ( + pack("B", self.onoff) + + pack("B", self.week) + + bytes.fromhex(self.fm1) + + bytes.fromhex(self.to1) + + bytes.fromhex(self.fm2) + + bytes.fromhex(self.to2) + ) + + +class RESTART_SHUTDOWN(GPS303Pkt): + PROTO = 0x48 + OUT_KWARGS = (("flag", int, 0),) + + def out_encode(self) -> bytes: + # 1 - restart + # 2 - shutdown + return pack("B", self.flag) + + +class DEVICE(GPS303Pkt): + PROTO = 0x49 + OUT_KWARGS = (("flag", int, 0),) + + # 0 - Stop looking for equipment + # 1 - Start looking for equipment + def out_encode(self) -> bytes: + return pack("B", self.flag) + + +class ALARM_CLOCK(GPS303Pkt): + PROTO = 0x50 + OUT_KWARGS: Tuple[ + Tuple[str, Callable[[Any], Any], List[Tuple[int, str]]], ... + ] = ( + ("alarms", l3alarms, []), + ) + + def out_encode(self) -> bytes: + return b"".join( + pack("B", day) + bytes.fromhex(tm) for day, tm in self.alarms + ) + + +class STOP_ALARM(GPS303Pkt): + PROTO = 0x56 + + def in_decode(self, length: int, payload: bytes) -> None: + self.flag = payload[0] + + +class SETUP(GPS303Pkt): + PROTO = 0x57 + RESPOND = Respond.EXT + OUT_KWARGS = ( + ("uploadintervalseconds", intx, 0x0300), + ("binaryswitch", intx, 0b00110001), + ("alarms", l3int, [0, 0, 0]), + ("dndtimeswitch", int, 0), + ("dndtimes", l3int, [0, 0, 0]), + ("gpstimeswitch", int, 0), + ("gpstimestart", int, 0), + ("gpstimestop", int, 0), + ("phonenumbers", l3str, ["", "", ""]), + ) + + def out_encode(self) -> bytes: + def pack3b(x: int) -> bytes: + return pack("!I", x)[1:] + + return b"".join( + [ + pack("!H", self.uploadintervalseconds), + pack("B", self.binaryswitch), + ] + + [pack3b(el) for el in self.alarms] + + [ + pack("B", self.dndtimeswitch), + ] + + [pack3b(el) for el in self.dndtimes] + + [ + pack("B", self.gpstimeswitch), + pack("!H", self.gpstimestart), + pack("!H", self.gpstimestop), + ] + + [b";".join([el.encode() for el in self.phonenumbers])] + ) + + def in_encode(self) -> bytes: + return b"" + + +class SYNCHRONOUS_WHITELIST(GPS303Pkt): + PROTO = 0x58 + + +class RESTORE_PASSWORD(GPS303Pkt): + PROTO = 0x67 + + +class WIFI_POSITIONING(_WIFI_POSITIONING): + PROTO = 0x69 + RESPOND = Respond.EXT + OUT_KWARGS = (("latitude", float, None), ("longitude", float, None)) + + def out_encode(self) -> bytes: + if self.latitude is None or self.longitude is None: + return b"" + return "{:+#010.8g},{:+#010.8g}".format( + self.latitude, self.longitude + ).encode() + + def out_decode(self, length: int, payload: bytes) -> None: + lat, lon = payload.decode().split(",") + self.latitude = float(lat) + self.longitude = float(lon) + + +class MANUAL_POSITIONING(GPS303Pkt): + PROTO = 0x80 + + def in_decode(self, length: int, payload: bytes) -> None: + self.flag = payload[0] if len(payload) > 0 else -1 + self.reason = { + 1: "Incorrect time", + 2: "LBS less", + 3: "WiFi less", + 4: "LBS search > 3 times", + 5: "Same LBS and WiFi data", + 6: "LBS prohibited, WiFi absent", + 7: "GPS spacing < 50 m", + }.get(self.flag, "Unknown") + + +class BATTERY_CHARGE(GPS303Pkt): + PROTO = 0x81 + + +class CHARGER_CONNECTED(GPS303Pkt): + PROTO = 0x82 + + +class CHARGER_DISCONNECTED(GPS303Pkt): + PROTO = 0x83 + + +class VIBRATION_RECEIVED(GPS303Pkt): + PROTO = 0x94 + + +class POSITION_UPLOAD_INTERVAL(GPS303Pkt): + PROTO = 0x98 + RESPOND = Respond.EXT + OUT_KWARGS = (("interval", int, 10),) + + def in_decode(self, length: int, payload: bytes) -> None: + self.interval = unpack("!H", payload[:2]) + + def out_encode(self) -> bytes: + return pack("!H", self.interval) + + +class SOS_ALARM(GPS303Pkt): + PROTO = 0x99 + + +class UNKNOWN_B3(GPS303Pkt): + PROTO = 0xB3 + IN_KWARGS = (("asciidata", str, ""),) + + def in_decode(self, length: int, payload: bytes) -> None: + self.asciidata = payload.decode() + + +# Build dicts protocol number -> class and class name -> protocol number +CLASSES = {} +PROTOS = {} +if True: # just to indent the code, sorry! + for cls in [ + cls + for name, cls in globals().items() + if isclass(cls) + and issubclass(cls, GPS303Pkt) + and not name.startswith("_") + ]: + if hasattr(cls, "PROTO"): + CLASSES[cls.PROTO] = cls + PROTOS[cls.__name__] = cls.PROTO + + +def class_by_prefix( + prefix: str, +) -> Union[Type[GPS303Pkt], List[Tuple[str, int]]]: + lst = [ + (name, proto) + for name, proto in PROTOS.items() + if name.upper().startswith(prefix.upper()) + ] + if len(lst) != 1: + return lst + _, proto = lst[0] + return CLASSES[proto] + + +def proto_name(obj: Union[MetaPkt, GPS303Pkt]) -> str: + return ( + PROTO_PREFIX + + ":" + + ( + obj.__class__.__name__ + if isinstance(obj, GPS303Pkt) + else obj.__name__ + ) + ).ljust(16, "\0")[:16] + + +def proto_by_name(name: str) -> int: + return PROTOS.get(name, -1) + + +def proto_of_message(packet: bytes) -> str: + return proto_name(CLASSES.get(packet[1], UNKNOWN)) + + +def imei_from_packet(packet: bytes) -> Optional[str]: + if packet[1] == LOGIN.PROTO: + msg = parse_message(packet) + if isinstance(msg, LOGIN): + return msg.imei + return None + + +def is_goodbye_packet(packet: bytes) -> bool: + return packet[1] == HIBERNATION.PROTO + + +def inline_response(packet: bytes) -> Optional[bytes]: + proto = packet[1] + if proto in CLASSES: + cls = CLASSES[proto] + if cls.RESPOND is Respond.INL: + return cls.Out().packed + return None + + +def probe_buffer(buffer: bytes) -> bool: + framestart = buffer.find(b"xx") + if framestart < 0: + return False + if len(buffer) - framestart < 6: + return False + return True + + +def parse_message(packet: bytes, is_incoming: bool = True) -> GPS303Pkt: + """From a packet (without framing bytes) derive the XXX.In object""" + length, proto = unpack("BB", packet[:2]) + payload = packet[2:] + if proto not in CLASSES: + cause: Union[DecodeError, ValueError, IndexError] = ValueError( + f"Proto {proto} is unknown" + ) + else: + try: + if is_incoming: + return CLASSES[proto].In(length, payload) + else: + return CLASSES[proto].Out(length, payload) + except (DecodeError, ValueError, IndexError) as e: + cause = e + if is_incoming: + retobj = UNKNOWN.In(length, payload) + else: + retobj = UNKNOWN.Out(length, payload) + retobj.PROTO = proto # Override class attr with object attr + retobj.cause = cause + return retobj diff --git a/scripts/gps303 b/scripts/gps303 deleted file mode 100755 index d9876b7..0000000 --- a/scripts/gps303 +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -exec python3 -m gps303 $* diff --git a/scripts/loctrkd b/scripts/loctrkd new file mode 100755 index 0000000..5f932cc --- /dev/null +++ b/scripts/loctrkd @@ -0,0 +1,3 @@ +#!/bin/sh + +exec python3 -m loctrkd $* diff --git a/setup.py b/setup.py index c3f2736..7988154 100644 --- a/setup.py +++ b/setup.py @@ -8,17 +8,17 @@ with open("debian/changelog", "r") as clog: )[0] setup( - name="gps303", + name="loctrkd", version=version, description="Suite of daemons to collect reports from xz303 GPS trackers", - url="http://www.average.org/gps303/", + url="http://www.average.org/loctrkd/", author="Eugene Crosser", author_email="crosser@average.org", install_requires=["zeromq"], license="MIT", packages=[ - "gps303", + "loctrkd", ], - scripts=["scripts/gps303"], + scripts=["scripts/loctrkd"], long_description=open("README.md").read(), ) diff --git a/test/common.py b/test/common.py index 3bf8f08..df43023 100644 --- a/test/common.py +++ b/test/common.py @@ -64,7 +64,7 @@ class TestWithServers(TestCase): kwargs = {"handle_hibernate": False} else: kwargs = {} - cls = import_module("gps303." + srvname, package=".") + cls = import_module("loctrkd." + srvname, package=".") if verbose: cls.log.addHandler(StreamHandler(stderr)) cls.log.setLevel(DEBUG) diff --git a/test/test_black.py b/test/test_black.py index cc72eae..4922749 100644 --- a/test/test_black.py +++ b/test/test_black.py @@ -21,7 +21,7 @@ class BlackFormatter(TestCase): self.fail(f"black not installed.") cmd = ( ["black", "--check", "--diff", "-l", "79"] - + glob("gps303/**/*.py", recursive=True) + + glob("loctrkd/**/*.py", recursive=True) + glob("test/**/*.py", recursive=True) ) output = run(cmd, capture_output=True) diff --git a/test/test_mypy.py b/test/test_mypy.py index d93f568..e4bf8c6 100644 --- a/test/test_mypy.py +++ b/test/test_mypy.py @@ -19,7 +19,7 @@ class TypeCheck(TestCase): "mypy", "--strict", "--ignore-missing-imports", - "gps303", + "loctrkd", "test", ] self.assertEqual(call(cmd), 0, "mypy typecheck") diff --git a/test/test_ocid_dload.py b/test/test_ocid_dload.py index 0089ce0..d194b64 100644 --- a/test/test_ocid_dload.py +++ b/test/test_ocid_dload.py @@ -5,7 +5,7 @@ from time import sleep from typing import Any import unittest from .common import send_and_drain, TestWithServers -from gps303 import ocid_dload +from loctrkd import ocid_dload class Ocid_Dload(TestWithServers): diff --git a/test/test_storage.py b/test/test_storage.py index 869ab18..4737f52 100644 --- a/test/test_storage.py +++ b/test/test_storage.py @@ -7,8 +7,8 @@ from time import sleep from typing import Any import unittest from .common import send_and_drain, TestWithServers -from gps303.zx303proto import * -from gps303.ocid_dload import SCHEMA +from loctrkd.zx303proto import * +from loctrkd.ocid_dload import SCHEMA class Storage(TestWithServers):