-gps303 (1.02) experimental; urgency=medium
+loctrkd (1.02) experimental; urgency=medium
* collector: prevent two active clients share IMEI
-- Eugene Crosser <crosser@average.org> 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
-- Eugene Crosser <crosser@average.org> 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
-- Eugene Crosser <crosser@average.org> 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 <crosser@average.org> 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
* 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 <crosser@average.org> 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 <crosser@average.org> 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
-- Eugene Crosser <crosser@average.org> 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
-- Eugene Crosser <crosser@average.org> 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."
-- Eugene Crosser <crosser@average.org> 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 <crosser@average.org> Tue, 10 May 2022 09:42:30 +0200
-gps303 (0.90) experimental; urgency=low
+loctrkd (0.90) experimental; urgency=low
[ Eugene Crosser ]
* Expand README
-- Eugene Crosser <crosser@average.org> 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
* 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
* 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
-Source: gps303
+Source: loctrkd
Maintainer: Eugene Crosser <crosser@average.org>
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,
python3-wsproto,
python3-zmq,
-Package: python3-gps303
+Package: python3-loctrkd
Architecture: all
Section: python
Depends: adduser,
+++ /dev/null
-[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
+++ /dev/null
-[collector]
-# configure your gps terminal with this SMS:
-# "server#<your_server_ip_or_fqdn>#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 <your_dir>` and use this:
-# downloadurl = http://localhost:8000/<your_mcc>.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 =
- ""
- ""
- ""
+++ /dev/null
-# 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
+++ /dev/null
-[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
+++ /dev/null
-[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
+++ /dev/null
-[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
+++ /dev/null
-[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
+++ /dev/null
-[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
+++ /dev/null
-[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
+++ /dev/null
-[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
-debian/gps303.conf etc/
-webdemo/index.html var/lib/gps303/
+debian/loctrkd.conf etc/
+webdemo/index.html var/lib/loctrkd/
-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]
--- /dev/null
+[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
--- /dev/null
+[collector]
+# configure your gps terminal with this SMS:
+# "server#<your_server_ip_or_fqdn>#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 <your_dir>` and use this:
+# downloadurl = http://localhost:8000/<your_mcc>.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 =
+ ""
+ ""
+ ""
--- /dev/null
+# 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
--- /dev/null
+[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
--- /dev/null
+[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
--- /dev/null
+[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
--- /dev/null
+[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
--- /dev/null
+[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
--- /dev/null
+[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
--- /dev/null
+[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
-docs/gps303.1
-docs/gps303.conf.5
+docs/loctrkd.1
+docs/loctrkd.conf.5
#!/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#
#!/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
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
+++ /dev/null
-.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 <crosser@average.org>
-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)
+++ /dev/null
-.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 <crosser@average.org>
-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)
--- /dev/null
+.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 <crosser@average.org>
+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)
--- /dev/null
+.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 <crosser@average.org>
+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)
+++ /dev/null
-""" 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)
+++ /dev/null
-""" 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))
+++ /dev/null
-""" 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 = "<local>"
-
-
-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"]))
+++ /dev/null
-""" 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))
+++ /dev/null
-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
+++ /dev/null
-""" 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))
+++ /dev/null
-""" Example that produces gpx from events in evstore """
-
-# run as:
-# python -m gps303.mkgpx <sqlite-file> <IMEI>
-# 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(
- """<?xml version="1.0"?>
-<gpx version="1.1"
-creator="gps303"
-xmlns="http://www.topografix.com/GPX/1/1">
- <name>Location Data</name>
- <trk>
- <name>Location Data</name>
- <trkseg>
-"""
-)
-
-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 = """ <trkpt lat="{}" lon="{}">
- <time>{}</time>
- </trkpt>""".format(
- lat, lon, isotime
- )
- print(trkpt)
- if False:
- print(
- datetime.fromtimestamp(tstamp)
- .astimezone(tz=timezone.utc)
- .isoformat(),
- msg,
- )
-print(
- """ </trkseg>
- </trk>
-</gpx>"""
-)
+++ /dev/null
-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 = "<unspecified>"
- 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))
+++ /dev/null
-"""
-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
- )
- )
+++ /dev/null
-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,
- )
+++ /dev/null
-""" 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))
+++ /dev/null
-""" 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))
+++ /dev/null
-""" 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))
+++ /dev/null
-""" 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))
+++ /dev/null
-""" 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:]
+++ /dev/null
-"""
-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
--- /dev/null
+""" 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)
--- /dev/null
+""" 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))
--- /dev/null
+""" 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 = "<local>"
+
+
+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"]))
--- /dev/null
+""" 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))
--- /dev/null
+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
--- /dev/null
+""" 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))
--- /dev/null
+""" Example that produces gpx from events in evstore """
+
+# run as:
+# python -m loctrkd.mkgpx <sqlite-file> <IMEI>
+# 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(
+ """<?xml version="1.0"?>
+<gpx version="1.1"
+creator="loctrkd"
+xmlns="http://www.topografix.com/GPX/1/1">
+ <name>Location Data</name>
+ <trk>
+ <name>Location Data</name>
+ <trkseg>
+"""
+)
+
+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 = """ <trkpt lat="{}" lon="{}">
+ <time>{}</time>
+ </trkpt>""".format(
+ lat, lon, isotime
+ )
+ print(trkpt)
+ if False:
+ print(
+ datetime.fromtimestamp(tstamp)
+ .astimezone(tz=timezone.utc)
+ .isoformat(),
+ msg,
+ )
+print(
+ """ </trkseg>
+ </trk>
+</gpx>"""
+)
--- /dev/null
+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 = "<unspecified>"
+ 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))
--- /dev/null
+"""
+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
+ )
+ )
--- /dev/null
+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,
+ )
--- /dev/null
+""" 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))
--- /dev/null
+""" 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))
--- /dev/null
+""" 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))
--- /dev/null
+""" 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))
--- /dev/null
+""" 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:]
--- /dev/null
+"""
+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
+++ /dev/null
-#!/bin/sh
-
-exec python3 -m gps303 $*
--- /dev/null
+#!/bin/sh
+
+exec python3 -m loctrkd $*
)[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(),
)
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)
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)
"mypy",
"--strict",
"--ignore-missing-imports",
- "gps303",
+ "loctrkd",
"test",
]
self.assertEqual(call(cmd), 0, "mypy typecheck")
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):
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):