Implement periodic loop option

Add an interval timing loop for all the grpc scripts that did not already have one. Organized some of the code into functions in order to facilitate this, which caused some problems with local variables vs global ones, so moved the script code into a proper main() function. Which didn't really solve the access to globals issue, so also moved the mutable state into a class instance.

The interval timer should be relatively robust against time drift due to the loop function running time and/or OS scheduler delay, but is far from perfect.

Retry logic is now in place for both InfluxDB scripts. Retry for dishStatusInflux.py is slightly changed in that a failed write to InfluxDB server will be retried on every interval, rather than waiting for another batch full of data points to write, but this only happens once there is at least a full batch (currently 6) of data points pending. This new behavior matches how the autocommit functionality on SeriesHelper works.

Changed the default behavior of dishStatusInflux.py to not loop, in order to match the other scripts. To get the old behavior, add a '-t 30' option to the command line.

Closes #9
This commit is contained in:
sparky8512 2021-01-15 18:39:33 -08:00
parent a589a75ce5
commit 46f65a6214
7 changed files with 1028 additions and 745 deletions

View file

@ -7,7 +7,7 @@ For more information on what Starlink is, see [starlink.com](https://www.starlin
`parseJsonHistory.py` operates on a JSON format data representation of the protocol buffer messages, such as that output by [gRPCurl](https://github.com/fullstorydev/grpcurl). The command lines below assume `grpcurl` is installed in the runtime PATH. If that's not the case, just substitute in the full path to the command. `parseJsonHistory.py` operates on a JSON format data representation of the protocol buffer messages, such as that output by [gRPCurl](https://github.com/fullstorydev/grpcurl). The command lines below assume `grpcurl` is installed in the runtime PATH. If that's not the case, just substitute in the full path to the command.
All the tools that pull data from the dish expect to be able to reach it at the dish's fixed IP address of 192.168.100.1, as do the Starlink [Android app](https://play.google.com/store/apps/details?id=com.starlink.mobile) and [iOS app](https://apps.apple.com/us/app/starlink/id1537177988). When using a router other than the one included with the Starlink installation kit, this usually requires some additional router configuration to make it work. That configuration is beyond the scope of this document, but if the Starlink app doesn't work on your home network, then neither will these scripts. That being said, you do not need the Starlink app installed to make use of these scripts. All the tools that pull data from the dish expect to be able to reach it at the dish's fixed IP address of 192.168.100.1, as do the Starlink [Android app](https://play.google.com/store/apps/details?id=com.starlink.mobile), [iOS app](https://apps.apple.com/us/app/starlink/id1537177988), and the browser app you can run directly from http://192.168.100.1. When using a router other than the one included with the Starlink installation kit, this usually requires some additional router configuration to make it work. That configuration is beyond the scope of this document, but if the Starlink app doesn't work on your home network, then neither will these scripts. That being said, you do not need the Starlink app installed to make use of these scripts.
The scripts that don't use `grpcurl` to pull data require the `grpcio` Python package at runtime and generating the necessary gRPC protocol code requires the `grpcio-tools` package. Information about how to install both can be found at https://grpc.io/docs/languages/python/quickstart/ The scripts that don't use `grpcurl` to pull data require the `grpcio` Python package at runtime and generating the necessary gRPC protocol code requires the `grpcio-tools` package. Information about how to install both can be found at https://grpc.io/docs/languages/python/quickstart/
@ -17,6 +17,10 @@ The scripts that use [InfluxDB](https://www.influxdata.com/products/influxdb/) f
## Usage ## Usage
Of the 3 groups below, the grpc scripts are really the only ones being actively developed. The others are mostly by way of example of what could be done with the underlying data.
### The JSON parser script
`parseJsonHistory.py` takes input from a file and writes its output to standard output. The easiest way to use it is to pipe the `grpcurl` command directly into it. For example: `parseJsonHistory.py` takes input from a file and writes its output to standard output. The easiest way to use it is to pipe the `grpcurl` command directly into it. For example:
``` ```
grpcurl -plaintext -d {\"get_history\":{}} 192.168.100.1:9200 SpaceX.API.Device.Device/Handle | python parseJsonHistory.py grpcurl -plaintext -d {\"get_history\":{}} 192.168.100.1:9200 SpaceX.API.Device.Device/Handle | python parseJsonHistory.py
@ -28,7 +32,11 @@ python parseJsonHistory.py -h
When used as-is, `parseJsonHistory.py` will summarize packet loss information from the data the dish records. There's other bits of data in there, though, so that script (or more likely the parsing logic it uses, which now resides in `starlink_json.py`) could be used as a starting point or example of how to iterate through it. Most of the data displayed in the Statistics page of the Starlink app appears to come from this same `get_history` gRPC response. See the file `get_history_notes.txt` for some ramblings on how to interpret it. When used as-is, `parseJsonHistory.py` will summarize packet loss information from the data the dish records. There's other bits of data in there, though, so that script (or more likely the parsing logic it uses, which now resides in `starlink_json.py`) could be used as a starting point or example of how to iterate through it. Most of the data displayed in the Statistics page of the Starlink app appears to come from this same `get_history` gRPC response. See the file `get_history_notes.txt` for some ramblings on how to interpret it.
The other scripts can do the gRPC communication directly, but they require some generated code to support the specific gRPC protocol messages used. These would normally be generated from .proto files that specify those messages, but to date (2020-Dec), SpaceX has not publicly released such files. The gRPC service running on the dish appears to have [server reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) enabled, though. `grpcurl` can use that to extract a protoset file, and the `protoc` compiler can use that to make the necessary generated code: The one bit of functionality this script has over the grpc scripts is that it supports capturing the grpcurl output to a file and reading from that, which may be useful if you're collecting data in one place but analyzing it in another. Otherwise, it's probably better to use `dishHistoryStats.py`, described below.
### The grpc scripts
This set of scripts can do the gRPC communication directly, but they require some generated code to support the specific gRPC protocol messages used. These would normally be generated from .proto files that specify those messages, but to date (2020-Dec), SpaceX has not publicly released such files. The gRPC service running on the dish appears to have [server reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) enabled, though. `grpcurl` can use that to extract a protoset file, and the `protoc` compiler can use that to make the necessary generated code:
``` ```
grpcurl -plaintext -protoset-out dish.protoset 192.168.100.1:9200 describe SpaceX.API.Device.Device grpcurl -plaintext -protoset-out dish.protoset 192.168.100.1:9200 describe SpaceX.API.Device.Device
mkdir src mkdir src
@ -41,29 +49,47 @@ python3 -m grpc_tools.protoc --descriptor_set_in=../dish.protoset --python_out=.
python3 -m grpc_tools.protoc --descriptor_set_in=../dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/wifi.proto python3 -m grpc_tools.protoc --descriptor_set_in=../dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/wifi.proto
python3 -m grpc_tools.protoc --descriptor_set_in=../dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/wifi_config.proto python3 -m grpc_tools.protoc --descriptor_set_in=../dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/wifi_config.proto
``` ```
Then move the resulting files to where the Python scripts can find them. Then move the resulting files to where the Python scripts can find them in its import path, such as in the same directory as the scripts themselves.
Once those are available, the `dishHistoryStats.py` script can be used in place of the `grpcurl | parseJsonHistory.py` pipeline, with most of the same command line options. Once those are available, the `dishHistoryStats.py` script can be used in place of the `grpcurl | parseJsonHistory.py` pipeline, with most of the same command line options. For example:
```
python3 parseHistoryStats.py
```
To collect and record summary stats every hour, you can put something like the following in your user crontab: By default, `parseHistoryStats.py` (and `parseJsonHistory.py`) will output the stats in CSV format. You can use the `-v` option to instead output in a (slightly) more human-readable format.
To collect and record summary stats at the top of every hour, you could put something like the following in your user crontab (assuming you have moved the scripts to ~/bin and made them executable):
``` ```
00 * * * * [ -e ~/dishStats.csv ] || ~/bin/dishHistoryStats.py -H >~/dishStats.csv; ~/bin/dishHistoryStats.py >>~/dishStats.csv 00 * * * * [ -e ~/dishStats.csv ] || ~/bin/dishHistoryStats.py -H >~/dishStats.csv; ~/bin/dishHistoryStats.py >>~/dishStats.csv
``` ```
`dishHistoryInflux.py` and `dishHistoryMqtt.py` are similar, but they send their output to an InfluxDB server and a MQTT broker, respectively. Run them with `-h` command line option for details on how to specify server and/or database options. `dishHistoryInflux.py` and `dishHistoryMqtt.py` are similar, but they send their output to an InfluxDB server and a MQTT broker, respectively. Run them with `-h` command line option for details on how to specify server and/or database options.
`dishDumpStatus.py` is even simpler. Just run it as: `dishStatusCsv.py`, `dishStatusInflux.py`, and `dishStatusMqtt.py` output the status data instead of history data, to various data backends. The information they pull is mostly what appears related to the dish in the Debug Data section of the Starlink app. As with the corresponding history scripts, run them with `-h` command line option for usage details.
By default, all of these scripts will pull data once, send it off to the specified data backend, and then exit. They can instead be made to run in a periodic loop by passing a `-t` option to specify loop interval, in seconds. For example, to capture status information to a InfluxDB server every 30 seconds, you could do something like this:
```
python3 dishStatusInflux.py -t 30 [... probably other args to specifiy server options ...]
```
Some of the scripts (currently only the InfluxDB ones) also support specifying options through environment variables. See details in the scripts for the environment variables that map to options.
### Other scripts
`dishDumpStatus.py` is a simple example of how to use the grpc modules (the ones generated by protoc, not `starlink_grpc.py`) directly. Just run it as:
``` ```
python3 dishDumpStatus.py python3 dishDumpStatus.py
``` ```
and revel in copious amounts of dish status information. OK, maybe it's not as impressive as all that. This one is really just meant to be a starting point for real functionality to be added to it. The information this script pulls is mostly what appears related to the dish in the Debug Data section of the Starlink app. and revel in copious amounts of dish status information. OK, maybe it's not as impressive as all that. This one is really just meant to be a starting point for real functionality to be added to it.
`dishStatusCsv.py`, `dishStatusInflux.py`, and `dishStatusMqtt.py` output the same status data, but to various data backends. As with the corresponding history scripts, run them with `-h` command line option for usage details. Possibly more simple examples to come, as the other scripts have started getting a bit complicated.
## To Be Done (Maybe) ## To Be Done (Maybe)
There are `reboot` and `dish_stow` requests in the Device protocol, too, so it should be trivial to write a command that initiates dish reboot and stow operations. These are easy enough to do with `grpcurl`, though, as there is no need to parse through the response data. For that matter, they're easy enough to do with the Starlink app. There are `reboot` and `dish_stow` requests in the Device protocol, too, so it should be trivial to write a command that initiates dish reboot and stow operations. These are easy enough to do with `grpcurl`, though, as there is no need to parse through the response data. For that matter, they're easy enough to do with the Starlink app.
Proper Python packaging, since some of the scripts are no longer self-contained.
## Other Tidbits ## Other Tidbits
The Starlink Android app actually uses port 9201 instead of 9200. Both appear to expose the same gRPC service, but the one on port 9201 uses an HTTP/1.1 wrapper, whereas the one on port 9200 uses HTTP/2.0, which is what most gRPC tools expect. The Starlink Android app actually uses port 9201 instead of 9200. Both appear to expose the same gRPC service, but the one on port 9201 uses an HTTP/1.1 wrapper, whereas the one on port 9200 uses HTTP/2.0, which is what most gRPC tools expect.

View file

@ -10,35 +10,41 @@
# #
###################################################################### ######################################################################
import getopt
import datetime import datetime
import logging
import os import os
import sys import sys
import getopt import time
import logging
import warnings import warnings
from influxdb import InfluxDBClient from influxdb import InfluxDBClient
import starlink_grpc import starlink_grpc
def main():
arg_error = False arg_error = False
try: try:
opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:vC:D:IP:R:SU:") opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:t:vC:D:IP:R:SU:")
except getopt.GetoptError as err: except getopt.GetoptError as err:
print(str(err)) print(str(err))
arg_error = True arg_error = True
# Default to 1 hour worth of data samples. # Default to 1 hour worth of data samples.
samples_default = 3600 samples_default = 3600
samples = samples_default samples = None
print_usage = False print_usage = False
verbose = False verbose = False
default_loop_time = 0
loop_time = default_loop_time
run_lengths = False run_lengths = False
host_default = "localhost" host_default = "localhost"
database_default = "starlinkstats" database_default = "starlinkstats"
icargs = {"host": host_default, "timeout": 5, "database": database_default} icargs = {"host": host_default, "timeout": 5, "database": database_default}
rp = None rp = None
flush_limit = 6
# For each of these check they are both set and not empty string # For each of these check they are both set and not empty string
influxdb_host = os.environ.get("INFLUXDB_HOST") influxdb_host = os.environ.get("INFLUXDB_HOST")
@ -86,6 +92,8 @@ if not arg_error:
run_lengths = True run_lengths = True
elif opt == "-s": elif opt == "-s":
samples = int(arg) samples = int(arg)
elif opt == "-t":
loop_time = float(arg)
elif opt == "-v": elif opt == "-v":
verbose = True verbose = True
elif opt == "-C": elif opt == "-C":
@ -118,7 +126,10 @@ if print_usage or arg_error:
print(" -n <name>: Hostname of InfluxDB server, default: " + host_default) print(" -n <name>: Hostname of InfluxDB server, default: " + host_default)
print(" -p <num>: Port number to use on InfluxDB server") print(" -p <num>: Port number to use on InfluxDB server")
print(" -r: Include ping drop run length stats") print(" -r: Include ping drop run length stats")
print(" -s <num>: Number of data samples to parse, default: " + str(samples_default)) print(" -s <num>: Number of data samples to parse, default: loop interval,")
print(" if set, else " + str(samples_default))
print(" -t <num>: Loop interval in seconds or 0 for no loop, default: " +
str(default_loop_time))
print(" -v: Be verbose") print(" -v: Be verbose")
print(" -C <filename>: Enable SSL/TLS using specified CA cert to verify server") print(" -C <filename>: Enable SSL/TLS using specified CA cert to verify server")
print(" -D <name>: Database name to use, default: " + database_default) print(" -D <name>: Database name to use, default: " + database_default)
@ -129,21 +140,55 @@ if print_usage or arg_error:
print(" -U <name>: Set username for authentication") print(" -U <name>: Set username for authentication")
sys.exit(1 if arg_error else 0) sys.exit(1 if arg_error else 0)
if samples is None:
samples = int(loop_time) if loop_time > 0 else samples_default
logging.basicConfig(format="%(levelname)s: %(message)s") logging.basicConfig(format="%(levelname)s: %(message)s")
class GlobalState:
pass
gstate = GlobalState()
gstate.dish_id = None
gstate.points = []
def conn_error(msg):
# Connection errors that happen in an interval loop are not critical
# failures, but are interesting enough to print in non-verbose mode.
if loop_time > 0:
print(msg)
else:
logging.error(msg)
def flush_points(client):
try: try:
dish_id = starlink_grpc.get_id() client.write_points(gstate.points, retention_policy=rp)
if verbose:
print("Data points written: " + str(len(gstate.points)))
gstate.points.clear()
except Exception as e:
conn_error("Failed writing to InfluxDB database: " + str(e))
return 1
return 0
def loop_body(client):
if gstate.dish_id is None:
try:
gstate.dish_id = starlink_grpc.get_id()
if verbose:
print("Using dish ID: " + gstate.dish_id)
except starlink_grpc.GrpcError as e: except starlink_grpc.GrpcError as e:
logging.error("Failure getting dish ID: " + str(e)) conn_error("Failure getting dish ID: " + str(e))
sys.exit(1) return 1
timestamp = datetime.datetime.utcnow() timestamp = datetime.datetime.utcnow()
try: try:
g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose)
except starlink_grpc.GrpcError as e: except starlink_grpc.GrpcError as e:
logging.error("Failure getting ping stats: " + str(e)) conn_error("Failure getting ping stats: " + str(e))
sys.exit(1) return 1
all_stats = g_stats.copy() all_stats = g_stats.copy()
all_stats.update(pd_stats) all_stats.update(pd_stats)
@ -155,12 +200,21 @@ if run_lengths:
else: else:
all_stats[k] = v all_stats[k] = v
points = [{ gstate.points.append({
"measurement": "spacex.starlink.user_terminal.ping_stats", "measurement": "spacex.starlink.user_terminal.ping_stats",
"tags": {"id": dish_id}, "tags": {
"id": gstate.dish_id
},
"time": timestamp, "time": timestamp,
"fields": all_stats, "fields": all_stats,
}] })
if verbose:
print("Data points queued: " + str(len(gstate.points)))
if len(gstate.points) >= flush_limit:
return flush_points(client)
return 0
if "verify_ssl" in icargs and not icargs["verify_ssl"]: if "verify_ssl" in icargs and not icargs["verify_ssl"]:
# user has explicitly said be insecure, so don't warn about it # user has explicitly said be insecure, so don't warn about it
@ -168,11 +222,22 @@ if "verify_ssl" in icargs and not icargs["verify_ssl"]:
influx_client = InfluxDBClient(**icargs) influx_client = InfluxDBClient(**icargs)
try: try:
influx_client.write_points(points, retention_policy=rp) next_loop = time.monotonic()
rc = 0 while True:
except Exception as e: rc = loop_body(influx_client)
logging.error("Failed writing to InfluxDB database: " + str(e)) if loop_time > 0:
rc = 1 now = time.monotonic()
next_loop = max(next_loop + loop_time, now)
time.sleep(next_loop - now)
else:
break
finally: finally:
if gstate.points:
rc = flush_points(influx_client)
influx_client.close() influx_client.close()
sys.exit(rc) sys.exit(rc)
if __name__ == '__main__':
main()

View file

@ -10,9 +10,10 @@
# #
###################################################################### ######################################################################
import sys
import getopt import getopt
import logging import logging
import sys
import time
try: try:
import ssl import ssl
@ -24,19 +25,23 @@ import paho.mqtt.publish
import starlink_grpc import starlink_grpc
def main():
arg_error = False arg_error = False
try: try:
opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:vC:ISP:U:") opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:t:vC:ISP:U:")
except getopt.GetoptError as err: except getopt.GetoptError as err:
print(str(err)) print(str(err))
arg_error = True arg_error = True
# Default to 1 hour worth of data samples. # Default to 1 hour worth of data samples.
samples_default = 3600 samples_default = 3600
samples = samples_default samples = None
print_usage = False print_usage = False
verbose = False verbose = False
default_loop_time = 0
loop_time = default_loop_time
run_lengths = False run_lengths = False
host_default = "localhost" host_default = "localhost"
mqargs = {"hostname": host_default} mqargs = {"hostname": host_default}
@ -60,6 +65,8 @@ if not arg_error:
run_lengths = True run_lengths = True
elif opt == "-s": elif opt == "-s":
samples = int(arg) samples = int(arg)
elif opt == "-t":
loop_time = float(arg)
elif opt == "-v": elif opt == "-v":
verbose = True verbose = True
elif opt == "-C": elif opt == "-C":
@ -89,7 +96,10 @@ if print_usage or arg_error:
print(" -n <name>: Hostname of MQTT broker, default: " + host_default) print(" -n <name>: Hostname of MQTT broker, default: " + host_default)
print(" -p <num>: Port number to use on MQTT broker") print(" -p <num>: Port number to use on MQTT broker")
print(" -r: Include ping drop run length stats") print(" -r: Include ping drop run length stats")
print(" -s <num>: Number of data samples to parse, default: " + str(samples_default)) print(" -s <num>: Number of data samples to parse, default: loop interval,")
print(" if set, else " + str(samples_default))
print(" -t <num>: Loop interval in seconds or 0 for no loop, default: " +
str(default_loop_time))
print(" -v: Be verbose") print(" -v: Be verbose")
print(" -C <filename>: Enable SSL/TLS using specified CA cert to verify broker") print(" -C <filename>: Enable SSL/TLS using specified CA cert to verify broker")
print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)")
@ -98,21 +108,47 @@ if print_usage or arg_error:
print(" -U: Set username for authentication") print(" -U: Set username for authentication")
sys.exit(1 if arg_error else 0) sys.exit(1 if arg_error else 0)
if samples is None:
samples = int(loop_time) if loop_time > 0 else samples_default
if username is not None:
mqargs["auth"] = {"username": username}
if password is not None:
mqargs["auth"]["password"] = password
logging.basicConfig(format="%(levelname)s: %(message)s") logging.basicConfig(format="%(levelname)s: %(message)s")
class GlobalState:
pass
gstate = GlobalState()
gstate.dish_id = None
def conn_error(msg):
# Connection errors that happen in an interval loop are not critical
# failures, but are interesting enough to print in non-verbose mode.
if loop_time > 0:
print(msg)
else:
logging.error(msg)
def loop_body():
if gstate.dish_id is None:
try: try:
dish_id = starlink_grpc.get_id() gstate.dish_id = starlink_grpc.get_id()
if verbose:
print("Using dish ID: " + gstate.dish_id)
except starlink_grpc.GrpcError as e: except starlink_grpc.GrpcError as e:
logging.error("Failure getting dish ID: " + str(e)) conn_error("Failure getting dish ID: " + str(e))
sys.exit(1) return 1
try: try:
g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose)
except starlink_grpc.GrpcError as e: except starlink_grpc.GrpcError as e:
logging.error("Failure getting ping stats: " + str(e)) conn_error("Failure getting ping stats: " + str(e))
sys.exit(1) return 1
topic_prefix = "starlink/dish_ping_stats/" + dish_id + "/" topic_prefix = "starlink/dish_ping_stats/" + gstate.dish_id + "/"
msgs = [(topic_prefix + k, v, 0, False) for k, v in g_stats.items()] msgs = [(topic_prefix + k, v, 0, False) for k, v in g_stats.items()]
msgs.extend([(topic_prefix + k, v, 0, False) for k, v in pd_stats.items()]) msgs.extend([(topic_prefix + k, v, 0, False) for k, v in pd_stats.items()])
if run_lengths: if run_lengths:
@ -122,13 +158,28 @@ if run_lengths:
else: else:
msgs.append((topic_prefix + k, v, 0, False)) msgs.append((topic_prefix + k, v, 0, False))
if username is not None:
mqargs["auth"] = {"username": username}
if password is not None:
mqargs["auth"]["password"] = password
try: try:
paho.mqtt.publish.multiple(msgs, client_id=dish_id, **mqargs) paho.mqtt.publish.multiple(msgs, client_id=gstate.dish_id, **mqargs)
if verbose:
print("Successfully published to MQTT broker")
except Exception as e: except Exception as e:
logging.error("Failed publishing to MQTT broker: " + str(e)) conn_error("Failed publishing to MQTT broker: " + str(e))
sys.exit(1) return 1
return 0
next_loop = time.monotonic()
while True:
rc = loop_body()
if loop_time > 0:
now = time.monotonic()
next_loop = max(next_loop + loop_time, now)
time.sleep(next_loop - now)
else:
break
sys.exit(rc)
if __name__ == '__main__':
main()

View file

@ -11,27 +11,32 @@
###################################################################### ######################################################################
import datetime import datetime
import sys
import getopt import getopt
import logging import logging
import sys
import time
import starlink_grpc import starlink_grpc
def main():
arg_error = False arg_error = False
try: try:
opts, args = getopt.getopt(sys.argv[1:], "ahrs:vH") opts, args = getopt.getopt(sys.argv[1:], "ahrs:t:vH")
except getopt.GetoptError as err: except getopt.GetoptError as err:
print(str(err)) print(str(err))
arg_error = True arg_error = True
# Default to 1 hour worth of data samples. # Default to 1 hour worth of data samples.
samples_default = 3600 samples_default = 3600
samples = samples_default samples = None
print_usage = False print_usage = False
verbose = False verbose = False
print_header = False default_loop_time = 0
loop_time = default_loop_time
run_lengths = False run_lengths = False
print_header = False
if not arg_error: if not arg_error:
if len(args) > 0: if len(args) > 0:
@ -46,6 +51,8 @@ if not arg_error:
run_lengths = True run_lengths = True
elif opt == "-s": elif opt == "-s":
samples = int(arg) samples = int(arg)
elif opt == "-t":
loop_time = float(arg)
elif opt == "-v": elif opt == "-v":
verbose = True verbose = True
elif opt == "-H": elif opt == "-H":
@ -57,11 +64,17 @@ if print_usage or arg_error:
print(" -a: Parse all valid samples") print(" -a: Parse all valid samples")
print(" -h: Be helpful") print(" -h: Be helpful")
print(" -r: Include ping drop run length stats") print(" -r: Include ping drop run length stats")
print(" -s <num>: Number of data samples to parse, default: " + str(samples_default)) print(" -s <num>: Number of data samples to parse, default: loop interval,")
print(" if set, else " + str(samples_default))
print(" -t <num>: Loop interval in seconds or 0 for no loop, default: " +
str(default_loop_time))
print(" -v: Be verbose") print(" -v: Be verbose")
print(" -H: print CSV header instead of parsing file") print(" -H: print CSV header instead of parsing history data")
sys.exit(1 if arg_error else 0) sys.exit(1 if arg_error else 0)
if samples is None:
samples = int(loop_time) if loop_time > 0 else samples_default
logging.basicConfig(format="%(levelname)s: %(message)s") logging.basicConfig(format="%(levelname)s: %(message)s")
g_fields, pd_fields, rl_fields = starlink_grpc.history_ping_field_names() g_fields, pd_fields, rl_fields = starlink_grpc.history_ping_field_names()
@ -79,13 +92,14 @@ if print_header:
print(",".join(header)) print(",".join(header))
sys.exit(0) sys.exit(0)
def loop_body():
timestamp = datetime.datetime.utcnow() timestamp = datetime.datetime.utcnow()
try: try:
g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose)
except starlink_grpc.GrpcError as e: except starlink_grpc.GrpcError as e:
logging.error("Failure getting ping stats: " + str(e)) logging.error("Failure getting ping stats: " + str(e))
sys.exit(1) return 1
if verbose: if verbose:
print("Parsed samples: " + str(g_stats["samples"])) print("Parsed samples: " + str(g_stats["samples"]))
@ -100,8 +114,12 @@ if verbose:
if run_lengths: if run_lengths:
print("Initial drop run fragment: " + str(rl_stats["init_run_fragment"])) print("Initial drop run fragment: " + str(rl_stats["init_run_fragment"]))
print("Final drop run fragment: " + str(rl_stats["final_run_fragment"])) print("Final drop run fragment: " + str(rl_stats["final_run_fragment"]))
print("Per-second drop runs: " + ", ".join(str(x) for x in rl_stats["run_seconds"])) print("Per-second drop runs: " +
print("Per-minute drop runs: " + ", ".join(str(x) for x in rl_stats["run_minutes"])) ", ".join(str(x) for x in rl_stats["run_seconds"]))
print("Per-minute drop runs: " +
", ".join(str(x) for x in rl_stats["run_minutes"]))
if loop_time > 0:
print()
else: else:
csv_data = [timestamp.replace(microsecond=0).isoformat()] csv_data = [timestamp.replace(microsecond=0).isoformat()]
csv_data.extend(str(g_stats[field]) for field in g_fields) csv_data.extend(str(g_stats[field]) for field in g_fields)
@ -113,3 +131,21 @@ else:
else: else:
csv_data.append(str(rl_stats[field])) csv_data.append(str(rl_stats[field]))
print(",".join(csv_data)) print(",".join(csv_data))
return 0
next_loop = time.monotonic()
while True:
rc = loop_body()
if loop_time > 0:
now = time.monotonic()
next_loop = max(next_loop + loop_time, now)
time.sleep(next_loop - now)
else:
break
sys.exit(rc)
if __name__ == '__main__':
main()

View file

@ -1,31 +1,37 @@
#!/usr/bin/python3 #!/usr/bin/python3
###################################################################### ######################################################################
# #
# Output get_status info in CSV format. # Output Starlink user terminal status info in CSV format.
# #
# This script pulls the current status once and prints to stdout. # This script pulls the current status and prints to stdout either
# once or in a periodic loop.
# #
###################################################################### ######################################################################
import datetime import datetime
import sys
import getopt import getopt
import logging import logging
import sys
import time
import grpc import grpc
import spacex.api.device.device_pb2 import spacex.api.device.device_pb2
import spacex.api.device.device_pb2_grpc import spacex.api.device.device_pb2_grpc
def main():
arg_error = False arg_error = False
try: try:
opts, args = getopt.getopt(sys.argv[1:], "hH") opts, args = getopt.getopt(sys.argv[1:], "ht:H")
except getopt.GetoptError as err: except getopt.GetoptError as err:
print(str(err)) print(str(err))
arg_error = True arg_error = True
print_usage = False print_usage = False
default_loop_time = 0
loop_time = default_loop_time
print_header = False print_header = False
if not arg_error: if not arg_error:
@ -35,6 +41,8 @@ if not arg_error:
for opt, arg in opts: for opt, arg in opts:
if opt == "-h": if opt == "-h":
print_usage = True print_usage = True
elif opt == "-t":
loop_time = float(arg)
elif opt == "-H": elif opt == "-H":
print_header = True print_header = True
@ -42,6 +50,8 @@ if print_usage or arg_error:
print("Usage: " + sys.argv[0] + " [options...]") print("Usage: " + sys.argv[0] + " [options...]")
print("Options:") print("Options:")
print(" -h: Be helpful") print(" -h: Be helpful")
print(" -t <num>: Loop interval in seconds or 0 for no loop, default: " +
str(default_loop_time))
print(" -H: print CSV header instead of parsing file") print(" -H: print CSV header instead of parsing file")
sys.exit(1 if arg_error else 0) sys.exit(1 if arg_error else 0)
@ -63,21 +73,19 @@ if print_header:
"alerts", "alerts",
"fraction_obstructed", "fraction_obstructed",
"currently_obstructed", "currently_obstructed",
"seconds_obstructed" "seconds_obstructed",
] ]
header.extend("wedges_fraction_obstructed_" + str(x) for x in range(12)) header.extend("wedges_fraction_obstructed_" + str(x) for x in range(12))
print(",".join(header)) print(",".join(header))
sys.exit(0) sys.exit(0)
def loop_body():
timestamp = datetime.datetime.utcnow()
try: try:
with grpc.insecure_channel("192.168.100.1:9200") as channel: with grpc.insecure_channel("192.168.100.1:9200") as channel:
stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel)
response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={}))
except grpc.RpcError:
logging.error("Failed getting status info")
sys.exit(1)
timestamp = datetime.datetime.utcnow()
status = response.dish_get_status status = response.dish_get_status
@ -92,9 +100,10 @@ csv_data = [
status.device_info.id, status.device_info.id,
status.device_info.hardware_version, status.device_info.hardware_version,
status.device_info.software_version, status.device_info.software_version,
spacex.api.device.dish_pb2.DishState.Name(status.state) spacex.api.device.dish_pb2.DishState.Name(status.state),
] ]
csv_data.extend(str(x) for x in [ csv_data.extend(
str(x) for x in [
status.device_state.uptime_s, status.device_state.uptime_s,
status.snr, status.snr,
status.seconds_to_first_nonempty_slot, status.seconds_to_first_nonempty_slot,
@ -105,7 +114,34 @@ csv_data.extend(str(x) for x in [
alert_bits, alert_bits,
status.obstruction_stats.fraction_obstructed, status.obstruction_stats.fraction_obstructed,
status.obstruction_stats.currently_obstructed, status.obstruction_stats.currently_obstructed,
status.obstruction_stats.last_24h_obstructed_s status.obstruction_stats.last_24h_obstructed_s,
]) ])
csv_data.extend(str(x) for x in status.obstruction_stats.wedge_abs_fraction_obstructed) csv_data.extend(str(x) for x in status.obstruction_stats.wedge_abs_fraction_obstructed)
rc = 0
except grpc.RpcError:
if loop_time <= 0:
logging.error("Failed getting status info")
csv_data = [
timestamp.replace(microsecond=0).isoformat(), "", "", "", "DISH_UNREACHABLE"
]
rc = 1
print(",".join(csv_data)) print(",".join(csv_data))
return rc
next_loop = time.monotonic()
while True:
rc = loop_body()
if loop_time > 0:
now = time.monotonic()
next_loop = max(next_loop + loop_time, now)
time.sleep(next_loop - now)
else:
break
sys.exit(rc)
if __name__ == '__main__':
main()

View file

@ -8,21 +8,22 @@
# #
###################################################################### ######################################################################
import time
import os
import sys
import getopt import getopt
import logging import logging
import os
import sys
import time
import warnings import warnings
import grpc
from influxdb import InfluxDBClient from influxdb import InfluxDBClient
from influxdb import SeriesHelper from influxdb import SeriesHelper
import grpc
import spacex.api.device.device_pb2 import spacex.api.device.device_pb2
import spacex.api.device.device_pb2_grpc import spacex.api.device.device_pb2_grpc
def main():
arg_error = False arg_error = False
try: try:
@ -33,12 +34,13 @@ except getopt.GetoptError as err:
print_usage = False print_usage = False
verbose = False verbose = False
default_loop_time = 0
loop_time = default_loop_time
host_default = "localhost" host_default = "localhost"
database_default = "starlinkstats" database_default = "starlinkstats"
icargs = {"host": host_default, "timeout": 5, "database": database_default} icargs = {"host": host_default, "timeout": 5, "database": database_default}
rp = None rp = None
default_sleep_time = 30 flush_limit = 6
sleep_time = default_sleep_time
# For each of these check they are both set and not empty string # For each of these check they are both set and not empty string
influxdb_host = os.environ.get("INFLUXDB_HOST") influxdb_host = os.environ.get("INFLUXDB_HOST")
@ -81,7 +83,7 @@ if not arg_error:
elif opt == "-p": elif opt == "-p":
icargs["port"] = int(arg) icargs["port"] = int(arg)
elif opt == "-t": elif opt == "-t":
sleep_time = int(arg) loop_time = int(arg)
elif opt == "-v": elif opt == "-v":
verbose = True verbose = True
elif opt == "-C": elif opt == "-C":
@ -113,7 +115,7 @@ if print_usage or arg_error:
print(" -n <name>: Hostname of InfluxDB server, default: " + host_default) print(" -n <name>: Hostname of InfluxDB server, default: " + host_default)
print(" -p <num>: Port number to use on InfluxDB server") print(" -p <num>: Port number to use on InfluxDB server")
print(" -t <num>: Loop interval in seconds or 0 for no loop, default: " + print(" -t <num>: Loop interval in seconds or 0 for no loop, default: " +
str(default_sleep_time)) str(default_loop_time))
print(" -v: Be verbose") print(" -v: Be verbose")
print(" -C <filename>: Enable SSL/TLS using specified CA cert to verify server") print(" -C <filename>: Enable SSL/TLS using specified CA cert to verify server")
print(" -D <name>: Database name to use, default: " + database_default) print(" -D <name>: Database name to use, default: " + database_default)
@ -126,15 +128,13 @@ if print_usage or arg_error:
logging.basicConfig(format="%(levelname)s: %(message)s") logging.basicConfig(format="%(levelname)s: %(message)s")
def conn_error(msg): class GlobalState:
# Connection errors that happen while running in an interval loop are pass
# not critical failures, because they can (usually) be retried, or
# because they will be recorded as dish state unavailable. They're still gstate = GlobalState()
# interesting, though, so print them even in non-verbose mode. gstate.dish_channel = None
if sleep_time > 0: gstate.dish_id = None
print(msg) gstate.pending = 0
else:
logging.error(msg)
class DeviceStatusSeries(SeriesHelper): class DeviceStatusSeries(SeriesHelper):
class Meta: class Meta:
@ -154,33 +154,59 @@ class DeviceStatusSeries(SeriesHelper):
"uplink_throughput_bps", "uplink_throughput_bps",
"pop_ping_latency_ms", "pop_ping_latency_ms",
"currently_obstructed", "currently_obstructed",
"fraction_obstructed"] "fraction_obstructed",
]
tags = ["id"] tags = ["id"]
retention_policy = rp retention_policy = rp
if "verify_ssl" in icargs and not icargs["verify_ssl"]: def conn_error(msg):
# user has explicitly said be insecure, so don't warn about it # Connection errors that happen in an interval loop are not critical
warnings.filterwarnings("ignore", message="Unverified HTTPS request") # failures, but are interesting enough to print in non-verbose mode.
if loop_time > 0:
print(msg)
else:
logging.error(msg)
influx_client = InfluxDBClient(**icargs) def flush_pending(client):
rc = 0
try: try:
dish_channel = None DeviceStatusSeries.commit(client)
last_id = None if verbose:
last_failed = False print("Data points written: " + str(gstate.pending))
gstate.pending = 0
except Exception as e:
conn_error("Failed writing to InfluxDB database: " + str(e))
return 1
pending = 0 return 0
count = 0
def get_status_retry():
"""Try getting the status at most twice"""
channel_reused = True
while True: while True:
try: try:
if dish_channel is None: if gstate.dish_channel is None:
dish_channel = grpc.insecure_channel("192.168.100.1:9200") gstate.dish_channel = grpc.insecure_channel("192.168.100.1:9200")
stub = spacex.api.device.device_pb2_grpc.DeviceStub(dish_channel) channel_reused = False
stub = spacex.api.device.device_pb2_grpc.DeviceStub(gstate.dish_channel)
response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={}))
status = response.dish_get_status return response.dish_get_status
DeviceStatusSeries( except grpc.RpcError:
id=status.device_info.id, gstate.dish_channel.close()
gstate.dish_channel = None
if channel_reused:
# If the channel was open already, the connection may have
# been lost in the time since prior loop iteration, so after
# closing it, retry once, in case the dish is now reachable.
if verbose:
print("Dish RPC channel error")
else:
raise
def loop_body(client):
try:
status = get_status_retry()
DeviceStatusSeries(id=status.device_info.id,
hardware_version=status.device_info.hardware_version, hardware_version=status.device_info.hardware_version,
software_version=status.device_info.software_version, software_version=status.device_info.software_version,
state=spacex.api.device.dish_pb2.DishState.Name(status.state), state=spacex.api.device.dish_pb2.DishState.Name(status.state),
@ -196,61 +222,49 @@ try:
pop_ping_latency_ms=status.pop_ping_latency_ms, pop_ping_latency_ms=status.pop_ping_latency_ms,
currently_obstructed=status.obstruction_stats.currently_obstructed, currently_obstructed=status.obstruction_stats.currently_obstructed,
fraction_obstructed=status.obstruction_stats.fraction_obstructed) fraction_obstructed=status.obstruction_stats.fraction_obstructed)
pending += 1 gstate.dish_id = status.device_info.id
last_id = status.device_info.id
last_failed = False
except grpc.RpcError: except grpc.RpcError:
if dish_channel is not None: if gstate.dish_id is None:
dish_channel.close()
dish_channel = None
if last_failed:
if last_id is None:
conn_error("Dish unreachable and ID unknown, so not recording state") conn_error("Dish unreachable and ID unknown, so not recording state")
# When not looping, report this as failure exit status return 1
rc = 1
else: else:
if verbose: if verbose:
print("Dish unreachable") print("Dish unreachable")
DeviceStatusSeries(id=last_id, state="DISH_UNREACHABLE") DeviceStatusSeries(id=gstate.dish_id, state="DISH_UNREACHABLE")
pending += 1
else: gstate.pending += 1
if verbose: if verbose:
print("Dish RPC channel error") print("Data points queued: " + str(gstate.pending))
# Retry once, because the connection may have been lost while if gstate.pending >= flush_limit:
# we were sleeping return flush_pending(client)
last_failed = True
continue return 0
if verbose:
print("Samples queued: " + str(pending)) if "verify_ssl" in icargs and not icargs["verify_ssl"]:
count += 1 # user has explicitly said be insecure, so don't warn about it
if count > 5: warnings.filterwarnings("ignore", message="Unverified HTTPS request")
influx_client = InfluxDBClient(**icargs)
try: try:
if pending: next_loop = time.monotonic()
DeviceStatusSeries.commit(influx_client) while True:
rc = 0 rc = loop_body(influx_client)
if verbose: if loop_time > 0:
print("Samples written: " + str(pending)) now = time.monotonic()
pending = 0 next_loop = max(next_loop + loop_time, now)
except Exception as e: time.sleep(next_loop - now)
conn_error("Failed to write: " + str(e))
rc = 1
count = 0
if sleep_time > 0:
time.sleep(sleep_time)
else: else:
break break
finally: finally:
# Flush on error/exit # Flush on error/exit
try: if gstate.pending:
if pending: rc = flush_pending(influx_client)
DeviceStatusSeries.commit(influx_client)
rc = 0
if verbose:
print("Samples written: " + str(pending))
except Exception as e:
conn_error("Failed to write: " + str(e))
rc = 1
influx_client.close() influx_client.close()
if dish_channel is not None: if gstate.dish_channel is not None:
dish_channel.close() gstate.dish_channel.close()
sys.exit(rc) sys.exit(rc)
if __name__ == '__main__':
main()

View file

@ -3,14 +3,15 @@
# #
# Publish Starlink user terminal status info to a MQTT broker. # Publish Starlink user terminal status info to a MQTT broker.
# #
# This script pulls the current status once and publishes it to the # This script pulls the current status and publishes it to the
# specified MQTT broker. # specified MQTT broker either once or in a periodic loop.
# #
###################################################################### ######################################################################
import sys
import getopt import getopt
import logging import logging
import sys
import time
try: try:
import ssl import ssl
@ -18,22 +19,26 @@ try:
except ImportError: except ImportError:
ssl_ok = False ssl_ok = False
import paho.mqtt.publish
import grpc import grpc
import paho.mqtt.publish
import spacex.api.device.device_pb2 import spacex.api.device.device_pb2
import spacex.api.device.device_pb2_grpc import spacex.api.device.device_pb2_grpc
def main():
arg_error = False arg_error = False
try: try:
opts, args = getopt.getopt(sys.argv[1:], "hn:p:C:ISP:U:") opts, args = getopt.getopt(sys.argv[1:], "hn:p:t:vC:ISP:U:")
except getopt.GetoptError as err: except getopt.GetoptError as err:
print(str(err)) print(str(err))
arg_error = True arg_error = True
print_usage = False print_usage = False
verbose = False
default_loop_time = 0
loop_time = default_loop_time
host_default = "localhost" host_default = "localhost"
mqargs = {"hostname": host_default} mqargs = {"hostname": host_default}
username = None username = None
@ -50,6 +55,10 @@ if not arg_error:
mqargs["hostname"] = arg mqargs["hostname"] = arg
elif opt == "-p": elif opt == "-p":
mqargs["port"] = int(arg) mqargs["port"] = int(arg)
elif opt == "-t":
loop_time = float(arg)
elif opt == "-v":
verbose = True
elif opt == "-C": elif opt == "-C":
mqargs["tls"] = {"ca_certs": arg} mqargs["tls"] = {"ca_certs": arg}
elif opt == "-I": elif opt == "-I":
@ -75,6 +84,9 @@ if print_usage or arg_error:
print(" -h: Be helpful") print(" -h: Be helpful")
print(" -n <name>: Hostname of MQTT broker, default: " + host_default) print(" -n <name>: Hostname of MQTT broker, default: " + host_default)
print(" -p <num>: Port number to use on MQTT broker") print(" -p <num>: Port number to use on MQTT broker")
print(" -t <num>: Loop interval in seconds or 0 for no loop, default: " +
str(default_loop_time))
print(" -v: Be verbose")
print(" -C <filename>: Enable SSL/TLS using specified CA cert to verify broker") print(" -C <filename>: Enable SSL/TLS using specified CA cert to verify broker")
print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)")
print(" -P: Set password for username/password authentication") print(" -P: Set password for username/password authentication")
@ -82,15 +94,32 @@ if print_usage or arg_error:
print(" -U: Set username for authentication") print(" -U: Set username for authentication")
sys.exit(1 if arg_error else 0) sys.exit(1 if arg_error else 0)
if username is not None:
mqargs["auth"] = {"username": username}
if password is not None:
mqargs["auth"]["password"] = password
logging.basicConfig(format="%(levelname)s: %(message)s") logging.basicConfig(format="%(levelname)s: %(message)s")
class GlobalState:
pass
gstate = GlobalState()
gstate.dish_id = None
def conn_error(msg):
# Connection errors that happen in an interval loop are not critical
# failures, but are interesting enough to print in non-verbose mode.
if loop_time > 0:
print(msg)
else:
logging.error(msg)
def loop_body():
try: try:
with grpc.insecure_channel("192.168.100.1:9200") as channel: with grpc.insecure_channel("192.168.100.1:9200") as channel:
stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel)
response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={}))
except grpc.RpcError:
logging.error("Failed getting status info")
sys.exit(1)
status = response.dish_get_status status = response.dish_get_status
@ -100,8 +129,10 @@ alert_bits = 0
for alert in status.alerts.ListFields(): for alert in status.alerts.ListFields():
alert_bits |= (1 if alert[1] else 0) << (alert[0].number - 1) alert_bits |= (1 if alert[1] else 0) << (alert[0].number - 1)
topic_prefix = "starlink/dish_status/" + status.device_info.id + "/" gstate.dish_id = status.device_info.id
msgs = [(topic_prefix + "hardware_version", status.device_info.hardware_version, 0, False), topic_prefix = "starlink/dish_status/" + gstate.dish_id + "/"
msgs = [
(topic_prefix + "hardware_version", status.device_info.hardware_version, 0, False),
(topic_prefix + "software_version", status.device_info.software_version, 0, False), (topic_prefix + "software_version", status.device_info.software_version, 0, False),
(topic_prefix + "state", spacex.api.device.dish_pb2.DishState.Name(status.state), 0, False), (topic_prefix + "state", spacex.api.device.dish_pb2.DishState.Name(status.state), 0, False),
(topic_prefix + "uptime", status.device_state.uptime_s, 0, False), (topic_prefix + "uptime", status.device_state.uptime_s, 0, False),
@ -119,15 +150,39 @@ msgs = [(topic_prefix + "hardware_version", status.device_info.hardware_version,
# on dish reboot, so may not cover that whole period. Rather than try # on dish reboot, so may not cover that whole period. Rather than try
# to convey that complexity in the topic label, just be a bit vague: # to convey that complexity in the topic label, just be a bit vague:
(topic_prefix + "seconds_obstructed", status.obstruction_stats.last_24h_obstructed_s, 0, False), (topic_prefix + "seconds_obstructed", status.obstruction_stats.last_24h_obstructed_s, 0, False),
(topic_prefix + "wedges_fraction_obstructed", ",".join(str(x) for x in status.obstruction_stats.wedge_abs_fraction_obstructed), 0, False)] (topic_prefix + "wedges_fraction_obstructed", ",".join(str(x) for x in status.obstruction_stats.wedge_abs_fraction_obstructed), 0, False),
]
if username is not None: except grpc.RpcError:
mqargs["auth"] = {"username": username} if gstate.dish_id is None:
if password is not None: conn_error("Dish unreachable and ID unknown, so not recording state")
mqargs["auth"]["password"] = password return 1
if verbose:
print("Dish unreachable")
topic_prefix = "starlink/dish_status/" + gstate.dish_id + "/"
msgs = [(topic_prefix + "state", "DISH_UNREACHABLE", 0, False)]
try: try:
paho.mqtt.publish.multiple(msgs, client_id=status.device_info.id, **mqargs) paho.mqtt.publish.multiple(msgs, client_id=gstate.dish_id, **mqargs)
if verbose:
print("Successfully published to MQTT broker")
except Exception as e: except Exception as e:
logging.error("Failed publishing to MQTT broker: " + str(e)) conn_error("Failed publishing to MQTT broker: " + str(e))
sys.exit(1) return 1
return 0
next_loop = time.monotonic()
while True:
rc = loop_body()
if loop_time > 0:
now = time.monotonic()
next_loop = max(next_loop + loop_time, now)
time.sleep(next_loop - now)
else:
break
sys.exit(rc)
if __name__ == '__main__':
main()