]> www.average.org Git - loctrkd.git/commitdiff
rename gps303 -> loctrkd
authorEugene Crosser <crosser@average.org>
Fri, 8 Jul 2022 10:36:33 +0000 (12:36 +0200)
committerEugene Crosser <crosser@average.org>
Thu, 14 Jul 2022 20:39:55 +0000 (22:39 +0200)
73 files changed:
debian/changelog
debian/control
debian/gps303.collector.service [deleted file]
debian/gps303.conf [deleted file]
debian/gps303.default [deleted file]
debian/gps303.lookaside.service [deleted file]
debian/gps303.ocid-dload.service [deleted file]
debian/gps303.ocid-dload.timer [deleted file]
debian/gps303.storage.service [deleted file]
debian/gps303.target [deleted file]
debian/gps303.termconfig.service [deleted file]
debian/gps303.wsgateway.service [deleted file]
debian/install
debian/lintian-overrides
debian/loctrkd.collector.service [new file with mode: 0644]
debian/loctrkd.conf [new file with mode: 0644]
debian/loctrkd.default [new file with mode: 0644]
debian/loctrkd.lookaside.service [new file with mode: 0644]
debian/loctrkd.ocid-dload.service [new file with mode: 0644]
debian/loctrkd.ocid-dload.timer [new file with mode: 0644]
debian/loctrkd.storage.service [new file with mode: 0644]
debian/loctrkd.target [new file with mode: 0644]
debian/loctrkd.termconfig.service [new file with mode: 0644]
debian/loctrkd.wsgateway.service [new file with mode: 0644]
debian/manpages
debian/postinst
debian/rules
docs/gps303.1 [deleted file]
docs/gps303.conf.5 [deleted file]
docs/loctrkd.1 [new file with mode: 0644]
docs/loctrkd.conf.5 [new file with mode: 0644]
gps303/__init__.py [deleted file]
gps303/__main__.py [deleted file]
gps303/collector.py [deleted file]
gps303/common.py [deleted file]
gps303/evstore.py [deleted file]
gps303/googlemaps.py [deleted file]
gps303/lookaside.py [deleted file]
gps303/mkgpx.py [deleted file]
gps303/ocid_dload.py [deleted file]
gps303/opencellid.py [deleted file]
gps303/qry.py [deleted file]
gps303/storage.py [deleted file]
gps303/termconfig.py [deleted file]
gps303/watch.py [deleted file]
gps303/wsgateway.py [deleted file]
gps303/zmsg.py [deleted file]
gps303/zx303proto.py [deleted file]
loctrkd/__init__.py [new file with mode: 0644]
loctrkd/__main__.py [new file with mode: 0644]
loctrkd/collector.py [new file with mode: 0644]
loctrkd/common.py [new file with mode: 0644]
loctrkd/evstore.py [new file with mode: 0644]
loctrkd/googlemaps.py [new file with mode: 0644]
loctrkd/lookaside.py [new file with mode: 0644]
loctrkd/mkgpx.py [new file with mode: 0644]
loctrkd/ocid_dload.py [new file with mode: 0644]
loctrkd/opencellid.py [new file with mode: 0644]
loctrkd/qry.py [new file with mode: 0644]
loctrkd/storage.py [new file with mode: 0644]
loctrkd/termconfig.py [new file with mode: 0644]
loctrkd/watch.py [new file with mode: 0644]
loctrkd/wsgateway.py [new file with mode: 0644]
loctrkd/zmsg.py [new file with mode: 0644]
loctrkd/zx303proto.py [new file with mode: 0755]
scripts/gps303 [deleted file]
scripts/loctrkd [new file with mode: 0755]
setup.py
test/common.py
test/test_black.py
test/test_mypy.py
test/test_ocid_dload.py
test/test_storage.py

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