From 0ee39f61fdb1c56df6ae75b0be08339361c364cc Mon Sep 17 00:00:00 2001 From: sparky8512 <76499194+sparky8512@users.noreply.github.com> Date: Fri, 8 Jan 2021 19:17:34 -0800 Subject: [PATCH 01/23] Small change to the interface between parser and calling scripts Move the "samples" stat out of the ping drop group of stats and into a new general stats group. This way, it will make more sense if/when additional stat groups are added, since that stat will apply to all of them. --- dishHistoryStats.py | 32 +++++++++++++++++--------------- parseJsonHistory.py | 34 ++++++++++++++++++---------------- starlink_grpc.py | 36 ++++++++++++++++++++++-------------- starlink_json.py | 22 +++++++++++++--------- 4 files changed, 70 insertions(+), 54 deletions(-) diff --git a/dishHistoryStats.py b/dishHistoryStats.py index b9aeb58..162fc6e 100644 --- a/dishHistoryStats.py +++ b/dishHistoryStats.py @@ -62,11 +62,12 @@ if print_usage or arg_error: print(" -H: print CSV header instead of parsing file") sys.exit(1 if arg_error else 0) -fields, rl_fields = starlink_grpc.history_ping_field_names() +g_fields, pd_fields, rl_fields = starlink_grpc.history_ping_field_names() if print_header: header = ["datetimestamp_utc"] - header.extend(fields) + header.extend(g_fields) + header.extend(pd_fields) if run_lengths: for field in rl_fields: if field.startswith("run_"): @@ -78,23 +79,23 @@ if print_header: timestamp = datetime.datetime.utcnow() -stats, rl_stats = starlink_grpc.history_ping_stats(-1 if parse_all else samples, - verbose) +g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(-1 if parse_all else samples, + verbose) -if stats is None or rl_stats is None: +if g_stats is None: # verbose output already happened, so just bail. sys.exit(1) if verbose: - print("Parsed samples: " + str(stats["samples"])) - print("Total ping drop: " + str(stats["total_ping_drop"])) - print("Count of drop == 1: " + str(stats["count_full_ping_drop"])) - print("Obstructed: " + str(stats["count_obstructed"])) - print("Obstructed ping drop: " + str(stats["total_obstructed_ping_drop"])) - print("Obstructed drop == 1: " + str(stats["count_full_obstructed_ping_drop"])) - print("Unscheduled: " + str(stats["count_unscheduled"])) - print("Unscheduled ping drop: " + str(stats["total_unscheduled_ping_drop"])) - print("Unscheduled drop == 1: " + str(stats["count_full_unscheduled_ping_drop"])) + print("Parsed samples: " + str(g_stats["samples"])) + print("Total ping drop: " + str(pd_stats["total_ping_drop"])) + print("Count of drop == 1: " + str(pd_stats["count_full_ping_drop"])) + print("Obstructed: " + str(pd_stats["count_obstructed"])) + print("Obstructed ping drop: " + str(pd_stats["total_obstructed_ping_drop"])) + print("Obstructed drop == 1: " + str(pd_stats["count_full_obstructed_ping_drop"])) + print("Unscheduled: " + str(pd_stats["count_unscheduled"])) + print("Unscheduled ping drop: " + str(pd_stats["total_unscheduled_ping_drop"])) + print("Unscheduled drop == 1: " + str(pd_stats["count_full_unscheduled_ping_drop"])) if run_lengths: print("Initial drop run fragment: " + str(rl_stats["init_run_fragment"])) print("Final drop run fragment: " + str(rl_stats["final_run_fragment"])) @@ -102,7 +103,8 @@ if verbose: print("Per-minute drop runs: " + ", ".join(str(x) for x in rl_stats["run_minutes"])) else: csv_data = [timestamp.replace(microsecond=0).isoformat()] - csv_data.extend(str(stats[field]) for field in fields) + csv_data.extend(str(g_stats[field]) for field in g_fields) + csv_data.extend(str(pd_stats[field]) for field in pd_fields) if run_lengths: for field in rl_fields: if field.startswith("run_"): diff --git a/parseJsonHistory.py b/parseJsonHistory.py index 9357847..33dfa28 100644 --- a/parseJsonHistory.py +++ b/parseJsonHistory.py @@ -66,11 +66,12 @@ if print_usage or arg_error: print(" -H: print CSV header instead of parsing file") sys.exit(1 if arg_error else 0) -fields, rl_fields = starlink_json.history_ping_field_names() +g_fields, pd_fields, rl_fields = starlink_json.history_ping_field_names() if print_header: header = ["datetimestamp_utc"] - header.extend(fields) + header.extend(g_fields) + header.extend(pd_fields) if run_lengths: for field in rl_fields: if field.startswith("run_"): @@ -82,24 +83,24 @@ if print_header: timestamp = datetime.datetime.utcnow() -stats, rl_stats = starlink_json.history_ping_stats(args[0] if args else "-", - -1 if parse_all else samples, - verbose) +g_stats, pd_stats, rl_stats = starlink_json.history_ping_stats(args[0] if args else "-", + -1 if parse_all else samples, + verbose) -if stats is None or rl_stats is None: +if g_stats is None: # verbose output already happened, so just bail. sys.exit(1) if verbose: - print("Parsed samples: " + str(stats["samples"])) - print("Total ping drop: " + str(stats["total_ping_drop"])) - print("Count of drop == 1: " + str(stats["count_full_ping_drop"])) - print("Obstructed: " + str(stats["count_obstructed"])) - print("Obstructed ping drop: " + str(stats["total_obstructed_ping_drop"])) - print("Obstructed drop == 1: " + str(stats["count_full_obstructed_ping_drop"])) - print("Unscheduled: " + str(stats["count_unscheduled"])) - print("Unscheduled ping drop: " + str(stats["total_unscheduled_ping_drop"])) - print("Unscheduled drop == 1: " + str(stats["count_full_unscheduled_ping_drop"])) + print("Parsed samples: " + str(g_stats["samples"])) + print("Total ping drop: " + str(pd_stats["total_ping_drop"])) + print("Count of drop == 1: " + str(pd_stats["count_full_ping_drop"])) + print("Obstructed: " + str(pd_stats["count_obstructed"])) + print("Obstructed ping drop: " + str(pd_stats["total_obstructed_ping_drop"])) + print("Obstructed drop == 1: " + str(pd_stats["count_full_obstructed_ping_drop"])) + print("Unscheduled: " + str(pd_stats["count_unscheduled"])) + print("Unscheduled ping drop: " + str(pd_stats["total_unscheduled_ping_drop"])) + print("Unscheduled drop == 1: " + str(pd_stats["count_full_unscheduled_ping_drop"])) if run_lengths: print("Initial drop run fragment: " + str(rl_stats["init_run_fragment"])) print("Final drop run fragment: " + str(rl_stats["final_run_fragment"])) @@ -107,7 +108,8 @@ if verbose: print("Per-minute drop runs: " + ", ".join(str(x) for x in rl_stats["run_minutes"])) else: csv_data = [timestamp.replace(microsecond=0).isoformat()] - csv_data.extend(str(stats[field]) for field in fields) + csv_data.extend(str(g_stats[field]) for field in g_fields) + csv_data.extend(str(pd_stats[field]) for field in pd_fields) if run_lengths: for field in rl_fields: if field.startswith("run_"): diff --git a/starlink_grpc.py b/starlink_grpc.py index 3401863..b4347c0 100644 --- a/starlink_grpc.py +++ b/starlink_grpc.py @@ -4,13 +4,17 @@ This module may eventually contain more expansive parsing logic, but for now it contains functions to parse the history data for some specific packet loss statistics. -General ping drop (packet loss) statistics: - This group of statistics characterize the packet loss (labeled "ping drop" - in the field names of the Starlink gRPC service protocol) in various ways. +General statistics: + This group of statistics contains data relevant to all the other groups. The sample interval is currently 1 second. samples: The number of valid samples analyzed. + +General ping drop (packet loss) statistics: + This group of statistics characterize the packet loss (labeled "ping drop" + in the field names of the Starlink gRPC service protocol) in various ways. + total_ping_drop: The total amount of time, in sample intervals, that experienced ping drop. count_full_ping_drop: The number of samples that experienced 100% @@ -62,13 +66,13 @@ Ping drop run length statistics: No sample should be counted in more than one of the run length stats or stat elements, so the total of all of them should be equal to - count_full_ping_drop from the general stats. + count_full_ping_drop from the ping drop stats. Samples that experience less than 100% ping drop are not counted in this group of stats, even if they happen at the beginning or end of a run of 100% ping drop samples. To compute the amount of time that experienced ping loss in less than a single run of 100% ping drop, use - (total_ping_drop - count_full_ping_drop) from the general stats. + (total_ping_drop - count_full_ping_drop) from the ping drop stats. """ from itertools import chain @@ -82,11 +86,13 @@ def history_ping_field_names(): """Return the field names of the packet loss stats. Returns: - A tuple with 2 lists, the first with general stat names and the - second with ping drop run length stat names. + A tuple with 3 lists, the first with general stat names, the second + with ping drop stat names, and the third with ping drop run length + stat names. """ return [ - "samples", + "samples" + ], [ "total_ping_drop", "count_full_ping_drop", "count_obstructed", @@ -122,11 +128,12 @@ def history_ping_stats(parse_samples, verbose=False): verbose (bool): Optionally produce verbose output. Returns: - On success, a tuple with 2 dicts, the first mapping general stat names - to their values and the second mapping ping drop run length stat names - to their values. + On success, a tuple with 3 dicts, the first mapping general stat names + to their values, the second mapping ping drop stat names to their + values and the third mapping ping drop run length stat names to their + values. - On failure, the tuple (None, None). + On failure, the tuple (None, None, None). """ try: history = get_history() @@ -134,7 +141,7 @@ def history_ping_stats(parse_samples, verbose=False): if verbose: # RpcError is too verbose to print the details. print("Failed getting history") - return None, None + return None, None, None # 'current' is the count of data samples written to the ring buffer, # irrespective of buffer wrap. @@ -218,7 +225,8 @@ def history_ping_stats(parse_samples, verbose=False): run_length = 0 return { - "samples": parse_samples, + "samples": parse_samples + }, { "total_ping_drop": tot, "count_full_ping_drop": count_full_drop, "count_obstructed": count_obstruct, diff --git a/starlink_json.py b/starlink_json.py index ca70547..383e875 100644 --- a/starlink_json.py +++ b/starlink_json.py @@ -18,11 +18,13 @@ def history_ping_field_names(): """Return the field names of the packet loss stats. Returns: - A tuple with 2 lists, the first with general stat names and the - second with ping drop run length stat names. + A tuple with 3 lists, the first with general stat names, the second + with ping drop stat names, and the third with ping drop run length + stat names. """ return [ - "samples", + "samples" + ], [ "total_ping_drop", "count_full_ping_drop", "count_obstructed", @@ -66,18 +68,19 @@ def history_ping_stats(filename, parse_samples, verbose=False): verbose (bool): Optionally produce verbose output. Returns: - On success, a tuple with 2 dicts, the first mapping general stat names - to their values and the second mapping ping drop run length stat names - to their values. + On success, a tuple with 3 dicts, the first mapping general stat names + to their values, the second mapping ping drop stat names to their + values and the third mapping ping drop run length stat names to their + values. - On failure, the tuple (None, None). + On failure, the tuple (None, None, None). """ try: history = get_history(filename) except Exception as e: if verbose: print("Failed getting history: " + str(e)) - return None, None + return None, None, None # "current" is the count of data samples written to the ring buffer, # irrespective of buffer wrap. @@ -161,7 +164,8 @@ def history_ping_stats(filename, parse_samples, verbose=False): run_length = 0 return { - "samples": parse_samples, + "samples": parse_samples + }, { "total_ping_drop": tot, "count_full_ping_drop": count_full_drop, "count_obstructed": count_obstruct, From 49cdcaa18c8bac5f5cc134560e43503fe940413a Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Fri, 8 Jan 2021 22:26:52 -0800 Subject: [PATCH 02/23] Create Dockerfile --- Dockerfile | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..58ae64a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +FROM python:3.9 +LABEL maintainer="neurocis " + +RUN true && \ +# Install package prerequisites +apt-get update && \ +apt-get install -qy cron && \ +apt-get clean && \ +rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \ +\ +# Install GRPCurl +wget https://github.com/fullstorydev/grpcurl/releases/download/v1.8.0/grpcurl_1.8.0_linux_x86_64.tar.gz && \ +tar -xvf grpcurl_1.8.0_linux_x86_64.tar.gz grpcurl && \ +chown root:root grpcurl && \ +chmod 755 grpcurl && \ +mv grpcurl /usr/bin/. && \ +rm grpcurl_1.8.0_linux_x86_64.tar.gz && \ +\ +# Install python prerequisites +pip3 install grpcio grpcio-tools paho-mqtt influxdb + +ADD . /app +WORKDIR /app + +# run crond as main process of container +CMD true && \ +printenv >> /etc/environment && \ +ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone && \ +#ntpd -p pool.ntp.org && \ +grpcurl -plaintext -protoset-out dish.protoset 192.168.100.1:9200 describe SpaceX.API.Device.Device && \ +python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/device.proto && \ +python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/common/status/status.proto && \ +python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/command.proto && \ +python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/common.proto && \ +python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/dish.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 && \ +echo "$CRON_ENTRY" | crontab - && cron -f + +# docker run -e INFLUXDB_HOST=192.168.1.34 -e INFLUXDB_PORT=8086 -e INFLUXDB_DB=starlink +# -e "CRON_ENTRY=* * * * * /usr/local/bin/python3 /app/dishStatusInflux_cron.py > /proc/1/fd/1 2>/proc/1/fd/2" +# --net='br0' --ip='192.168.1.39' neurocis/starlink-grpc-tools From fe3cf90612871fbb5f465a69efca9f2bed77113c Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Fri, 8 Jan 2021 22:27:54 -0800 Subject: [PATCH 03/23] Create dishStatusInflux_cron.py --- dishStatusInflux_cron.py | 93 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 dishStatusInflux_cron.py diff --git a/dishStatusInflux_cron.py b/dishStatusInflux_cron.py new file mode 100644 index 0000000..a4e7503 --- /dev/null +++ b/dishStatusInflux_cron.py @@ -0,0 +1,93 @@ +#!/usr/bin/python3 +###################################################################### +# +# Write get_status info to an InfluxDB database. +# +# This script will poll current status and write it to +# the specified InfluxDB database. +# +###################################################################### +import os +import grpc +import spacex.api.device.device_pb2 +import spacex.api.device.device_pb2_grpc + +from influxdb import InfluxDBClient +from influxdb import SeriesHelper + +influxdb_host = os.environ.get("INFLUXDB_HOST") +influxdb_port = os.environ.get("INFLUXDB_PORT") +influxdb_user = os.environ.get("INFLUXDB_USER") +influxdb_pwd = os.environ.get("INFLUXDB_PWD") +influxdb_db = os.environ.get("INFLUXDB_DB") + +class DeviceStatusSeries(SeriesHelper): + class Meta: + series_name = "spacex.starlink.user_terminal.status" + fields = [ + "hardware_version", + "software_version", + "state", + "alert_motors_stuck", + "alert_thermal_throttle", + "alert_thermal_shutdown", + "alert_unexpected_location", + "snr", + "seconds_to_first_nonempty_slot", + "pop_ping_drop_rate", + "downlink_throughput_bps", + "uplink_throughput_bps", + "pop_ping_latency_ms", + "currently_obstructed", + "fraction_obstructed"] + tags = ["id"] + +influx_client = InfluxDBClient(host=influxdb_host, port=influxdb_port, username=influxdb_user, password=influxdb_pwd, database=influxdb_db, ssl=False, retries=1, timeout=15) + +dish_channel = None +last_id = None +last_failed = False + +while True: + try: + if dish_channel is None: + dish_channel = grpc.insecure_channel("192.168.100.1:9200") + stub = spacex.api.device.device_pb2_grpc.DeviceStub(dish_channel) + response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) + status = response.dish_get_status + DeviceStatusSeries( + id=status.device_info.id, + hardware_version=status.device_info.hardware_version, + software_version=status.device_info.software_version, + state=spacex.api.device.dish_pb2.DishState.Name(status.state), + alert_motors_stuck=status.alerts.motors_stuck, + alert_thermal_throttle=status.alerts.thermal_throttle, + alert_thermal_shutdown=status.alerts.thermal_shutdown, + alert_unexpected_location=status.alerts.unexpected_location, + snr=status.snr, + seconds_to_first_nonempty_slot=status.seconds_to_first_nonempty_slot, + pop_ping_drop_rate=status.pop_ping_drop_rate, + downlink_throughput_bps=status.downlink_throughput_bps, + uplink_throughput_bps=status.uplink_throughput_bps, + pop_ping_latency_ms=status.pop_ping_latency_ms, + currently_obstructed=status.obstruction_stats.currently_obstructed, + fraction_obstructed=status.obstruction_stats.fraction_obstructed) + last_id = status.device_info.id + last_failed = False + except grpc.RpcError: + if dish_channel is not None: + dish_channel.close() + dish_channel = None + if last_failed: + if last_id is not None: + DeviceStatusSeries(id=last_id, state="DISH_UNREACHABLE") + else: + # Retry once, because the connection may have been lost while + # we were sleeping + last_failed = True + continue + try: + DeviceStatusSeries.commit(influx_client) + except Exception as e: + print("Failed to write: " + str(e)) + break From c28e0258939ab562f41afa983b436273796fc017 Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Fri, 8 Jan 2021 22:41:57 -0800 Subject: [PATCH 04/23] Update README.md --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index e655931..7a0813a 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,18 @@ There are `reboot` and `dish_stow` requests in the Device protocol, too, so it s 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 router also exposes a gRPC service, on ports 9000 (HTTP/2.0) and 9001 (HTTP/1.1). + +## Docker for InfluxDB ( & MQTT under development ) + +`dishStatusInflux_cron.py` is a docker-cron friendly script which will post status to an InfluxDB as specified by evironment variables passed to the container. Initialization of the container can be performed with the following command: + +``` +docker run -e INFLUXDB_HOST={InfluxDB Hostname} + -e INFLUXDB_PORT={Port, 8086 usually} + -e INFLUXDB_USER={Optional, InfluxDB Username} + -e INFLUXDB_PWD={Optional, InfluxDB Password} + -e INFLUXDB_DB={Pre-created DB name, starlinkstats works well} + -e "CRON_ENTRY=* * * * * /usr/local/bin/python3 /app/dishStatusInflux_cron.py > /proc/1/fd/1 2>/proc/1/fd/2" neurocis/starlink-grpc-tools +``` + +Adjust the `CRON_ENTRY` to your desired polling schedule. I (neurocis) will push a Graphana dashboard in the near future, or please create and share your own. From 253d6e9250f4d14e253d1747c40481c2f4c110a1 Mon Sep 17 00:00:00 2001 From: sparky8512 <76499194+sparky8512@users.noreply.github.com> Date: Sat, 9 Jan 2021 11:43:25 -0800 Subject: [PATCH 05/23] Fix spelling error --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7a0813a..2f6c9c5 100644 --- a/README.md +++ b/README.md @@ -81,4 +81,4 @@ docker run -e INFLUXDB_HOST={InfluxDB Hostname} -e "CRON_ENTRY=* * * * * /usr/local/bin/python3 /app/dishStatusInflux_cron.py > /proc/1/fd/1 2>/proc/1/fd/2" neurocis/starlink-grpc-tools ``` -Adjust the `CRON_ENTRY` to your desired polling schedule. I (neurocis) will push a Graphana dashboard in the near future, or please create and share your own. +Adjust the `CRON_ENTRY` to your desired polling schedule. I (neurocis) will push a Grafana dashboard in the near future, or please create and share your own. From f067f08952e98d7330a0f71f6f45605e1a2b6b90 Mon Sep 17 00:00:00 2001 From: sparky8512 <76499194+sparky8512@users.noreply.github.com> Date: Sat, 9 Jan 2021 12:03:37 -0800 Subject: [PATCH 06/23] Add InfluxDB and MQTT history stats scripts Unlike the status info scripts, these include support for setting host and database parameters via command line options. Still to be added is support for HTTPS/SSL. Add a get_id function to the grpc parser module, so it can be used for tagging purposes. Minor cleanups in some of the other scripts to make them consistent with the newly added scripts. --- README.md | 2 +- dishHistoryInflux.py | 124 +++++++++++++++++++++++++++++++++++++++++++ dishHistoryMqtt.py | 113 +++++++++++++++++++++++++++++++++++++++ dishHistoryStats.py | 8 ++- dishStatusInflux.py | 2 +- dishStatusMqtt.py | 2 +- parseJsonHistory.py | 8 ++- starlink_grpc.py | 24 +++++++++ 8 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 dishHistoryInflux.py create mode 100644 dishHistoryMqtt.py diff --git a/README.md b/README.md index 2f6c9c5..6d79491 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ For more usage options, run: 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 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: ``` diff --git a/dishHistoryInflux.py b/dishHistoryInflux.py new file mode 100644 index 0000000..966eca2 --- /dev/null +++ b/dishHistoryInflux.py @@ -0,0 +1,124 @@ +#!/usr/bin/python3 +###################################################################### +# +# Write Starlink user terminal packet loss statistics to an InfluxDB +# database. +# +# This script examines the most recent samples from the history data, +# computes several different metrics related to packet loss, and +# writes those to the specified InfluxDB database. +# +###################################################################### + +import datetime +import sys +import getopt + +from influxdb import InfluxDBClient + +import starlink_grpc + +arg_error = False + +try: + opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:vD:P:R:U:") +except getopt.GetoptError as err: + print(str(err)) + arg_error = True + +# Default to 1 hour worth of data samples. +samples_default = 3600 +samples = samples_default +print_usage = False +verbose = False +run_lengths = False +host_default = "localhost" +database_default = "dishstats" +icargs = {"host": host_default, "timeout": 5, "database": database_default} +rp = None + +if not arg_error: + if len(args) > 0: + arg_error = True + else: + for opt, arg in opts: + if opt == "-a": + samples = -1 + elif opt == "-h": + print_usage = True + elif opt == "-n": + icargs["host"] = arg + elif opt == "-p": + icargs["port"] = int(arg) + elif opt == "-r": + run_lengths = True + elif opt == "-s": + samples = int(arg) + elif opt == "-v": + verbose = True + elif opt == "-D": + icargs["database"] = arg + elif opt == "-P": + icargs["password"] = arg + elif opt == "-R": + rp = arg + elif opt == "-U": + icargs["username"] = arg + +if "password" in icargs and "username" not in icargs: + print("Password authentication requires username to be set") + arg_error = True + +if print_usage or arg_error: + print("Usage: " + sys.argv[0] + " [options...]") + print("Options:") + print(" -a: Parse all valid samples") + print(" -h: Be helpful") + print(" -n : Hostname of InfluxDB server, default: " + host_default) + print(" -p : Port number to use on InfluxDB server") + print(" -r: Include ping drop run length stats") + print(" -s : Number of data samples to parse, default: " + str(samples_default)) + print(" -v: Be verbose") + print(" -D : Database name to use, default: " + database_default) + print(" -P : Set password for authentication") + print(" -R : Retention policy name to use") + print(" -U : Set username for authentication") + sys.exit(1 if arg_error else 0) + +dish_id = starlink_grpc.get_id() + +if dish_id is None: + if verbose: + print("Unable to connect to Starlink user terminal") + sys.exit(1) + +timestamp = datetime.datetime.utcnow() + +g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) + +if g_stats is None: + # verbose output already happened, so just bail. + sys.exit(1) + +all_stats = g_stats.copy() +all_stats.update(pd_stats) +if run_lengths: + for k, v in rl_stats.items(): + if k.startswith("run_"): + for i, subv in enumerate(v, start=1): + all_stats[k + "_" + str(i)] = subv + else: + all_stats[k] = v + +points = [{ + "measurement": "spacex.starlink.user_terminal.ping_stats", + "tags": {"id": dish_id}, + "time": timestamp, + "fields": all_stats, +}] + +influx_client = InfluxDBClient(**icargs) +try: + influx_client.write_points(points, retention_policy=rp) +finally: + influx_client.close() diff --git a/dishHistoryMqtt.py b/dishHistoryMqtt.py new file mode 100644 index 0000000..0f819e3 --- /dev/null +++ b/dishHistoryMqtt.py @@ -0,0 +1,113 @@ +#!/usr/bin/python3 +###################################################################### +# +# Publish Starlink user terminal packet loss statistics to a MQTT +# broker. +# +# This script examines the most recent samples from the history data, +# computes several different metrics related to packet loss, and +# publishes those to the specified MQTT broker. +# +###################################################################### + +import sys +import getopt + +import paho.mqtt.publish + +import starlink_grpc + +arg_error = False + +try: + opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:vU:P:") +except getopt.GetoptError as err: + print(str(err)) + arg_error = True + +# Default to 1 hour worth of data samples. +samples_default = 3600 +samples = samples_default +print_usage = False +verbose = False +run_lengths = False +host_default = "localhost" +host = host_default +port = None +username = None +password = None + +if not arg_error: + if len(args) > 0: + arg_error = True + else: + for opt, arg in opts: + if opt == "-a": + samples = -1 + elif opt == "-h": + print_usage = True + elif opt == "-n": + host = arg + elif opt == "-p": + port = int(arg) + elif opt == "-r": + run_lengths = True + elif opt == "-s": + samples = int(arg) + elif opt == "-v": + verbose = True + elif opt == "-P": + password = arg + elif opt == "-U": + username = arg + +if username is None and password is not None: + print("Password authentication requires username to be set") + arg_error = True + +if print_usage or arg_error: + print("Usage: " + sys.argv[0] + " [options...]") + print("Options:") + print(" -a: Parse all valid samples") + print(" -h: Be helpful") + print(" -n : Hostname of MQTT broker, default: " + host_default) + print(" -p : Port number to use on MQTT broker") + print(" -r: Include ping drop run length stats") + print(" -s : Number of data samples to parse, default: " + str(samples_default)) + print(" -v: Be verbose") + print(" -P: Set password for username/password authentication") + print(" -U: Set username for authentication") + sys.exit(1 if arg_error else 0) + +dish_id = starlink_grpc.get_id() + +if dish_id is None: + if verbose: + print("Unable to connect to Starlink user terminal") + sys.exit(1) + +g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) + +if g_stats is None: + # verbose output already happened, so just bail. + sys.exit(1) + +topic_prefix = "starlink/dish_ping_stats/" + dish_id + "/" +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()]) +if run_lengths: + for k, v in rl_stats.items(): + if k.startswith("run_"): + msgs.append((topic_prefix + k, ",".join(str(x) for x in v), 0, False)) + else: + msgs.append((topic_prefix + k, v, 0, False)) + +optargs = {} +if username is not None: + auth = {"username": username} + if password is not None: + auth["password"] = password + optargs["auth"] = auth +if port is not None: + optargs["port"] = port +paho.mqtt.publish.multiple(msgs, hostname=host, client_id=dish_id, **optargs) diff --git a/dishHistoryStats.py b/dishHistoryStats.py index 162fc6e..ab2d9bb 100644 --- a/dishHistoryStats.py +++ b/dishHistoryStats.py @@ -29,7 +29,6 @@ samples_default = 3600 samples = samples_default print_usage = False verbose = False -parse_all = False print_header = False run_lengths = False @@ -39,7 +38,7 @@ if not arg_error: else: for opt, arg in opts: if opt == "-a": - parse_all = True + samples = -1 elif opt == "-h": print_usage = True elif opt == "-r": @@ -57,7 +56,7 @@ if print_usage or arg_error: print(" -a: Parse all valid samples") print(" -h: Be helpful") print(" -r: Include ping drop run length stats") - print(" -s : Parse data samples, default: " + str(samples_default)) + print(" -s : Number of data samples to parse, default: " + str(samples_default)) print(" -v: Be verbose") print(" -H: print CSV header instead of parsing file") sys.exit(1 if arg_error else 0) @@ -79,8 +78,7 @@ if print_header: timestamp = datetime.datetime.utcnow() -g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(-1 if parse_all else samples, - verbose) +g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) if g_stats is None: # verbose output already happened, so just bail. diff --git a/dishStatusInflux.py b/dishStatusInflux.py index b6ab371..f297c9b 100644 --- a/dishStatusInflux.py +++ b/dishStatusInflux.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 ###################################################################### # -# Write get_status info to an InfluxDB database. +# Write Starlink user terminal status info to an InfluxDB database. # # This script will periodically poll current status and write it to # the specified InfluxDB database in a loop. diff --git a/dishStatusMqtt.py b/dishStatusMqtt.py index 9baaddd..fff7101 100644 --- a/dishStatusMqtt.py +++ b/dishStatusMqtt.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 ###################################################################### # -# Publish get_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 # specified MQTT broker. diff --git a/parseJsonHistory.py b/parseJsonHistory.py index 33dfa28..3e8d1aa 100644 --- a/parseJsonHistory.py +++ b/parseJsonHistory.py @@ -32,7 +32,6 @@ samples_default = 3600 samples = samples_default print_usage = False verbose = False -parse_all = False print_header = False run_lengths = False @@ -42,7 +41,7 @@ if not arg_error: else: for opt, arg in opts: if opt == "-a": - parse_all = True + samples = -1 elif opt == "-h": print_usage = True elif opt == "-r": @@ -61,7 +60,7 @@ if print_usage or arg_error: print(" -a: Parse all valid samples") print(" -h: Be helpful") print(" -r: Include ping drop run length stats") - print(" -s : Parse data samples, default: " + str(samples_default)) + print(" -s : Number of data samples to parse, default: " + str(samples_default)) print(" -v: Be verbose") print(" -H: print CSV header instead of parsing file") sys.exit(1 if arg_error else 0) @@ -84,8 +83,7 @@ if print_header: timestamp = datetime.datetime.utcnow() g_stats, pd_stats, rl_stats = starlink_json.history_ping_stats(args[0] if args else "-", - -1 if parse_all else samples, - verbose) + samples, verbose) if g_stats is None: # verbose output already happened, so just bail. diff --git a/starlink_grpc.py b/starlink_grpc.py index b4347c0..10ceca9 100644 --- a/starlink_grpc.py +++ b/starlink_grpc.py @@ -82,6 +82,30 @@ import grpc import spacex.api.device.device_pb2 import spacex.api.device.device_pb2_grpc +def get_status(): + """Fetch status data and return it in grpc structure format. + + Raises: + grpc.RpcError: Communication or service error. + """ + with grpc.insecure_channel("192.168.100.1:9200") as channel: + stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) + response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) + return response.dish_get_status + +def get_id(): + """Return the ID from the dish status information. + + Returns: + A string identifying the Starlink user terminal reachable from the + local network, or None if no user terminal is currently reachable. + """ + try: + status = get_status() + return status.device_info.id + except grpc.RpcError: + return None + def history_ping_field_names(): """Return the field names of the packet loss stats. From 9e09b6488185d50f66f494faaa5e2012b65ce5a5 Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Sat, 9 Jan 2021 13:22:22 -0800 Subject: [PATCH 07/23] Update Dockerfile Name & run in daemon mode --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 58ae64a..4439c34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,6 @@ 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_config.proto && \ echo "$CRON_ENTRY" | crontab - && cron -f -# docker run -e INFLUXDB_HOST=192.168.1.34 -e INFLUXDB_PORT=8086 -e INFLUXDB_DB=starlink +# docker run -d --name='starlink-grpc-tools' -e INFLUXDB_HOST=192.168.1.34 -e INFLUXDB_PORT=8086 -e INFLUXDB_DB=starlink # -e "CRON_ENTRY=* * * * * /usr/local/bin/python3 /app/dishStatusInflux_cron.py > /proc/1/fd/1 2>/proc/1/fd/2" # --net='br0' --ip='192.168.1.39' neurocis/starlink-grpc-tools From 27ce46cb3cff7e74424f6e96a0849edb3b5b2b52 Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Sat, 9 Jan 2021 13:22:55 -0800 Subject: [PATCH 08/23] Update README.md Name & run docker in daemon mode. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d79491..30305f1 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The Starlink router also exposes a gRPC service, on ports 9000 (HTTP/2.0) and 90 `dishStatusInflux_cron.py` is a docker-cron friendly script which will post status to an InfluxDB as specified by evironment variables passed to the container. Initialization of the container can be performed with the following command: ``` -docker run -e INFLUXDB_HOST={InfluxDB Hostname} +docker run -d --name='starlink-grpc-tools' -e INFLUXDB_HOST={InfluxDB Hostname} -e INFLUXDB_PORT={Port, 8086 usually} -e INFLUXDB_USER={Optional, InfluxDB Username} -e INFLUXDB_PWD={Optional, InfluxDB Password} From ce44f3c021d7afa63a5cfcac78fb042f9e89ba1b Mon Sep 17 00:00:00 2001 From: sparky8512 <76499194+sparky8512@users.noreply.github.com> Date: Sun, 10 Jan 2021 21:36:44 -0800 Subject: [PATCH 09/23] SSL/TLS support for InfluxDB and MQTT scripts SSL/TLS support for InfluxDB and MQTT scripts Copy the command line option handling into the status scripts to facilitate this. Also copy the setting from env from dishStatusInflux_cron.py. Better error handling for failures while writing to the data backend. Error printing verbosity is now a bit inconsistent, but I'll address that separately. Still to be done is dishStatusInflux_cron.py, pending a decision on what to do with that script, given that dishStatusInflux.py can now be run in one-shot mode. This is related to issue #2. --- README.md | 8 +-- dishHistoryInflux.py | 54 ++++++++++++++++++- dishHistoryMqtt.py | 43 ++++++++++----- dishStatusCsv.py | 63 ++++++++++++++++++++-- dishStatusInflux.py | 123 ++++++++++++++++++++++++++++++++++++++++--- dishStatusMqtt.py | 88 +++++++++++++++++++++++++++++-- 6 files changed, 350 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 6d79491..cc071c5 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The scripts that use [InfluxDB](https://www.influxdata.com/products/influxdb/) f ## Usage -For `parseJsonHistory.py`, 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 ``` @@ -41,7 +41,7 @@ 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_config.proto ``` -Then move the resulting files to where the Python scripts can find them, such as in the same directory as the scripts themselves. +Then move the resulting files to where the Python scripts can find them. 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. @@ -50,13 +50,15 @@ To collect and record summary stats every hour, you can put something like the f 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. + `dishDumpStatus.py` is even simpler. Just run it as: ``` 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. -`dishStatusCsv.py`, `dishStatusInflux.py`, and `dishStatusMqtt.py` output the same status data, but to various data backends. These scripts currently lack any way to configure them, such as setting server host or authentication credentials, other than by changing the hard-coded values in the scripts. +`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. ## To Be Done (Maybe) diff --git a/dishHistoryInflux.py b/dishHistoryInflux.py index 966eca2..bdbb6e8 100644 --- a/dishHistoryInflux.py +++ b/dishHistoryInflux.py @@ -11,9 +11,11 @@ ###################################################################### import datetime +import os import sys import getopt +import warnings from influxdb import InfluxDBClient import starlink_grpc @@ -21,7 +23,7 @@ import starlink_grpc arg_error = False try: - opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:vD:P:R:U:") + opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:vC:D:IP:R:SU:") except getopt.GetoptError as err: print(str(err)) arg_error = True @@ -37,6 +39,35 @@ database_default = "dishstats" icargs = {"host": host_default, "timeout": 5, "database": database_default} rp = None +# For each of these check they are both set and not empty string +influxdb_host = os.environ.get("INFLUXDB_HOST") +if influxdb_host: + icargs["host"] = influxdb_host +influxdb_port = os.environ.get("INFLUXDB_PORT") +if influxdb_port: + icargs["port"] = int(influxdb_port) +influxdb_user = os.environ.get("INFLUXDB_USER") +if influxdb_user: + icargs["username"] = influxdb_user +influxdb_pwd = os.environ.get("INFLUXDB_PWD") +if influxdb_pwd: + icargs["password"] = influxdb_pwd +influxdb_db = os.environ.get("INFLUXDB_DB") +if influxdb_db: + icargs["database"] = influxdb_db +influxdb_rp = os.environ.get("INFLUXDB_RP") +if influxdb_rp: + rp = influxdb_rp +influxdb_ssl = os.environ.get("INFLUXDB_SSL") +if influxdb_ssl: + icargs["ssl"] = True + if influxdb_ssl.lower() == "secure": + icargs["verify_ssl"] = True + elif influxdb_ssl.lower() == "insecure": + icargs["verify_ssl"] = False + else: + icargs["verify_ssl"] = influxdb_ssl + if not arg_error: if len(args) > 0: arg_error = True @@ -56,12 +87,21 @@ if not arg_error: samples = int(arg) elif opt == "-v": verbose = True + elif opt == "-C": + icargs["ssl"] = True + icargs["verify_ssl"] = arg elif opt == "-D": icargs["database"] = arg + elif opt == "-I": + icargs["ssl"] = True + icargs["verify_ssl"] = False elif opt == "-P": icargs["password"] = arg elif opt == "-R": rp = arg + elif opt == "-S": + icargs["ssl"] = True + icargs["verify_ssl"] = True elif opt == "-U": icargs["username"] = arg @@ -79,9 +119,12 @@ if print_usage or arg_error: print(" -r: Include ping drop run length stats") print(" -s : Number of data samples to parse, default: " + str(samples_default)) print(" -v: Be verbose") + print(" -C : Enable SSL/TLS using specified CA cert to verify server") print(" -D : Database name to use, default: " + database_default) + print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") print(" -P : Set password for authentication") print(" -R : Retention policy name to use") + print(" -S: Enable SSL/TLS using default CA cert") print(" -U : Set username for authentication") sys.exit(1 if arg_error else 0) @@ -117,8 +160,17 @@ points = [{ "fields": all_stats, }] +if "verify_ssl" in icargs and not icargs["verify_ssl"]: + # user has explicitly said be insecure, so don't warn about it + warnings.filterwarnings("ignore", message="Unverified HTTPS request") + influx_client = InfluxDBClient(**icargs) try: influx_client.write_points(points, retention_policy=rp) + rc = 0 +except Exception as e: + print("Failed writing to InfluxDB database: " + str(e)) + rc = 1 finally: influx_client.close() +sys.exit(rc) diff --git a/dishHistoryMqtt.py b/dishHistoryMqtt.py index 0f819e3..2291150 100644 --- a/dishHistoryMqtt.py +++ b/dishHistoryMqtt.py @@ -13,6 +13,12 @@ import sys import getopt +try: + import ssl + ssl_ok = True +except ImportError: + ssl_ok = False + import paho.mqtt.publish import starlink_grpc @@ -20,7 +26,7 @@ import starlink_grpc arg_error = False try: - opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:vU:P:") + opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:vC:ISP:U:") except getopt.GetoptError as err: print(str(err)) arg_error = True @@ -32,8 +38,7 @@ print_usage = False verbose = False run_lengths = False host_default = "localhost" -host = host_default -port = None +mqargs = {"hostname": host_default} username = None password = None @@ -47,17 +52,27 @@ if not arg_error: elif opt == "-h": print_usage = True elif opt == "-n": - host = arg + mqargs["hostname"] = arg elif opt == "-p": - port = int(arg) + mqargs["port"] = int(arg) elif opt == "-r": run_lengths = True elif opt == "-s": samples = int(arg) elif opt == "-v": verbose = True + elif opt == "-C": + mqargs["tls"] = {"ca_certs": arg} + elif opt == "-I": + if ssl_ok: + mqargs["tls"] = {"cert_reqs": ssl.CERT_NONE} + else: + print("No SSL support found") + sys.exit(1) elif opt == "-P": password = arg + elif opt == "-S": + mqargs["tls"] = {} elif opt == "-U": username = arg @@ -75,7 +90,10 @@ if print_usage or arg_error: print(" -r: Include ping drop run length stats") print(" -s : Number of data samples to parse, default: " + str(samples_default)) print(" -v: Be verbose") + print(" -C : Enable SSL/TLS using specified CA cert to verify broker") + print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") print(" -P: Set password for username/password authentication") + print(" -S: Enable SSL/TLS using default CA cert") print(" -U: Set username for authentication") sys.exit(1 if arg_error else 0) @@ -102,12 +120,13 @@ if run_lengths: else: msgs.append((topic_prefix + k, v, 0, False)) -optargs = {} if username is not None: - auth = {"username": username} + mqargs["auth"] = {"username": username} if password is not None: - auth["password"] = password - optargs["auth"] = auth -if port is not None: - optargs["port"] = port -paho.mqtt.publish.multiple(msgs, hostname=host, client_id=dish_id, **optargs) + mqargs["auth"]["password"] = password + +try: + paho.mqtt.publish.multiple(msgs, client_id=dish_id, **mqargs) +except Exception as e: + print("Failed publishing to MQTT broker: " + str(e)) + sys.exit(1) diff --git a/dishStatusCsv.py b/dishStatusCsv.py index 98269e8..0c96587 100644 --- a/dishStatusCsv.py +++ b/dishStatusCsv.py @@ -6,16 +6,73 @@ # This script pulls the current status once and prints to stdout. # ###################################################################### + import datetime +import sys +import getopt import grpc import spacex.api.device.device_pb2 import spacex.api.device.device_pb2_grpc -with grpc.insecure_channel("192.168.100.1:9200") as channel: - stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) - response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) +arg_error = False + +try: + opts, args = getopt.getopt(sys.argv[1:], "hH") +except getopt.GetoptError as err: + print(str(err)) + arg_error = True + +print_usage = False +print_header = False + +if not arg_error: + if len(args) > 0: + arg_error = True + else: + for opt, arg in opts: + if opt == "-h": + print_usage = True + elif opt == "-H": + print_header = True + +if print_usage or arg_error: + print("Usage: " + sys.argv[0] + " [options...]") + print("Options:") + print(" -h: Be helpful") + print(" -H: print CSV header instead of parsing file") + sys.exit(1 if arg_error else 0) + +if print_header: + header = [ + "datetimestamp_utc", + "hardware_version", + "software_version", + "state", + "uptime", + "snr", + "seconds_to_first_nonempty_slot", + "pop_ping_drop_rate", + "downlink_throughput_bps", + "uplink_throughput_bps", + "pop_ping_latency_ms", + "alerts", + "fraction_obstructed", + "currently_obstructed", + "seconds_obstructed" + ] + header.extend("wedges_fraction_obstructed_" + str(x) for x in range(12)) + print(",".join(header)) + sys.exit(0) + +try: + with grpc.insecure_channel("192.168.100.1:9200") as channel: + stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) + response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) +except grpc.RpcError: + print("Failed getting status info") + sys.exit(1) timestamp = datetime.datetime.utcnow() diff --git a/dishStatusInflux.py b/dishStatusInflux.py index f297c9b..79c809c 100644 --- a/dishStatusInflux.py +++ b/dishStatusInflux.py @@ -3,12 +3,17 @@ # # Write Starlink user terminal status info to an InfluxDB database. # -# This script will periodically poll current status and write it to -# the specified InfluxDB database in a loop. +# This script will poll current status and write it to the specified +# InfluxDB database either once or in a periodic loop. # ###################################################################### -import time +import time +import os +import sys +import getopt + +import warnings from influxdb import InfluxDBClient from influxdb import SeriesHelper @@ -17,8 +22,106 @@ import grpc import spacex.api.device.device_pb2 import spacex.api.device.device_pb2_grpc -verbose = True -sleep_time = 30 +arg_error = False + +try: + opts, args = getopt.getopt(sys.argv[1:], "hn:p:t:vC:D:IP:R:SU:") +except getopt.GetoptError as err: + print(str(err)) + arg_error = True + +print_usage = False +verbose = False +host_default = "localhost" +database_default = "dishstats" +icargs = {"host": host_default, "timeout": 5, "database": database_default} +rp = None +default_sleep_time = 30 +sleep_time = default_sleep_time + +# For each of these check they are both set and not empty string +influxdb_host = os.environ.get("INFLUXDB_HOST") +if influxdb_host: + icargs["host"] = influxdb_host +influxdb_port = os.environ.get("INFLUXDB_PORT") +if influxdb_port: + icargs["port"] = int(influxdb_port) +influxdb_user = os.environ.get("INFLUXDB_USER") +if influxdb_user: + icargs["username"] = influxdb_user +influxdb_pwd = os.environ.get("INFLUXDB_PWD") +if influxdb_pwd: + icargs["password"] = influxdb_pwd +influxdb_db = os.environ.get("INFLUXDB_DB") +if influxdb_db: + icargs["database"] = influxdb_db +influxdb_rp = os.environ.get("INFLUXDB_RP") +if influxdb_rp: + rp = influxdb_rp +influxdb_ssl = os.environ.get("INFLUXDB_SSL") +if influxdb_ssl: + icargs["ssl"] = True + if influxdb_ssl.lower() == "secure": + icargs["verify_ssl"] = True + elif influxdb_ssl.lower() == "insecure": + icargs["verify_ssl"] = False + else: + icargs["verify_ssl"] = influxdb_ssl + +if not arg_error: + if len(args) > 0: + arg_error = True + else: + for opt, arg in opts: + if opt == "-h": + print_usage = True + elif opt == "-n": + icargs["host"] = arg + elif opt == "-p": + icargs["port"] = int(arg) + elif opt == "-t": + sleep_time = int(arg) + elif opt == "-v": + verbose = True + elif opt == "-C": + icargs["ssl"] = True + icargs["verify_ssl"] = arg + elif opt == "-D": + icargs["database"] = arg + elif opt == "-I": + icargs["ssl"] = True + icargs["verify_ssl"] = False + elif opt == "-P": + icargs["password"] = arg + elif opt == "-R": + rp = arg + elif opt == "-S": + icargs["ssl"] = True + icargs["verify_ssl"] = True + elif opt == "-U": + icargs["username"] = arg + +if "password" in icargs and "username" not in icargs: + print("Password authentication requires username to be set") + arg_error = True + +if print_usage or arg_error: + print("Usage: " + sys.argv[0] + " [options...]") + print("Options:") + print(" -h: Be helpful") + print(" -n : Hostname of InfluxDB server, default: " + host_default) + print(" -p : Port number to use on InfluxDB server") + print(" -t : Loop interval in seconds or 0 for no loop, default: " + + str(default_sleep_time)) + print(" -v: Be verbose") + print(" -C : Enable SSL/TLS using specified CA cert to verify server") + print(" -D : Database name to use, default: " + database_default) + print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") + print(" -P : Set password for authentication") + print(" -R : Retention policy name to use") + print(" -S: Enable SSL/TLS using default CA cert") + print(" -U : Set username for authentication") + sys.exit(1 if arg_error else 0) class DeviceStatusSeries(SeriesHelper): class Meta: @@ -40,8 +143,13 @@ class DeviceStatusSeries(SeriesHelper): "currently_obstructed", "fraction_obstructed"] tags = ["id"] + retention_policy = rp -influx_client = InfluxDBClient(host="localhost", port=8086, username="script-user", password="password", database="dishstats", ssl=False, retries=1, timeout=15) +if "verify_ssl" in icargs and not icargs["verify_ssl"]: + # user has explicitly said be insecure, so don't warn about it + warnings.filterwarnings("ignore", message="Unverified HTTPS request") + +influx_client = InfluxDBClient(**icargs) try: dish_channel = None @@ -111,8 +219,11 @@ finally: DeviceStatusSeries.commit(influx_client) if verbose: print("Wrote " + str(pending)) + rc = 0 except Exception as e: print("Failed to write: " + str(e)) + rc = 1 influx_client.close() if dish_channel is not None: dish_channel.close() + sys.exit(rc) diff --git a/dishStatusMqtt.py b/dishStatusMqtt.py index fff7101..ee4c667 100644 --- a/dishStatusMqtt.py +++ b/dishStatusMqtt.py @@ -7,6 +7,16 @@ # specified MQTT broker. # ###################################################################### + +import sys +import getopt + +try: + import ssl + ssl_ok = True +except ImportError: + ssl_ok = False + import paho.mqtt.publish import grpc @@ -14,9 +24,70 @@ import grpc import spacex.api.device.device_pb2 import spacex.api.device.device_pb2_grpc -with grpc.insecure_channel("192.168.100.1:9200") as channel: - stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) - response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) +arg_error = False + +try: + opts, args = getopt.getopt(sys.argv[1:], "hn:p:C:ISP:U:") +except getopt.GetoptError as err: + print(str(err)) + arg_error = True + +print_usage = False +host_default = "localhost" +mqargs = {"hostname": host_default} +username = None +password = None + +if not arg_error: + if len(args) > 0: + arg_error = True + else: + for opt, arg in opts: + if opt == "-h": + print_usage = True + elif opt == "-n": + mqargs["hostname"] = arg + elif opt == "-p": + mqargs["port"] = int(arg) + elif opt == "-C": + mqargs["tls"] = {"ca_certs": arg} + elif opt == "-I": + if ssl_ok: + mqargs["tls"] = {"cert_reqs": ssl.CERT_NONE} + else: + print("No SSL support found") + sys.exit(1) + elif opt == "-P": + password = arg + elif opt == "-S": + mqargs["tls"] = {} + elif opt == "-U": + username = arg + +if username is None and password is not None: + print("Password authentication requires username to be set") + arg_error = True + +if print_usage or arg_error: + print("Usage: " + sys.argv[0] + " [options...]") + print("Options:") + print(" -h: Be helpful") + print(" -n : Hostname of MQTT broker, default: " + host_default) + print(" -p : Port number to use on MQTT broker") + print(" -C : Enable SSL/TLS using specified CA cert to verify broker") + print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") + print(" -P: Set password for username/password authentication") + print(" -S: Enable SSL/TLS using default CA cert") + print(" -U: Set username for authentication") + sys.exit(1 if arg_error else 0) + +try: + with grpc.insecure_channel("192.168.100.1:9200") as channel: + stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) + response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) +except grpc.RpcError: + print("Failed getting status info") + sys.exit(1) status = response.dish_get_status @@ -47,4 +118,13 @@ msgs = [(topic_prefix + "hardware_version", status.device_info.hardware_version, (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)] -paho.mqtt.publish.multiple(msgs, hostname="localhost", client_id=status.device_info.id) +if username is not None: + mqargs["auth"] = {"username": username} + if password is not None: + mqargs["auth"]["password"] = password + +try: + paho.mqtt.publish.multiple(msgs, client_id=status.device_info.id, **mqargs) +except Exception as e: + print("Failed publishing to MQTT broker: " + str(e)) + sys.exit(1) From ee1d19ce35132140bd14c974bacb270b4311ee5e Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Sun, 10 Jan 2021 23:40:02 -0800 Subject: [PATCH 10/23] Delete dishStatusInflux_cron.py --- dishStatusInflux_cron.py | 93 ---------------------------------------- 1 file changed, 93 deletions(-) delete mode 100644 dishStatusInflux_cron.py diff --git a/dishStatusInflux_cron.py b/dishStatusInflux_cron.py deleted file mode 100644 index a4e7503..0000000 --- a/dishStatusInflux_cron.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/python3 -###################################################################### -# -# Write get_status info to an InfluxDB database. -# -# This script will poll current status and write it to -# the specified InfluxDB database. -# -###################################################################### -import os -import grpc -import spacex.api.device.device_pb2 -import spacex.api.device.device_pb2_grpc - -from influxdb import InfluxDBClient -from influxdb import SeriesHelper - -influxdb_host = os.environ.get("INFLUXDB_HOST") -influxdb_port = os.environ.get("INFLUXDB_PORT") -influxdb_user = os.environ.get("INFLUXDB_USER") -influxdb_pwd = os.environ.get("INFLUXDB_PWD") -influxdb_db = os.environ.get("INFLUXDB_DB") - -class DeviceStatusSeries(SeriesHelper): - class Meta: - series_name = "spacex.starlink.user_terminal.status" - fields = [ - "hardware_version", - "software_version", - "state", - "alert_motors_stuck", - "alert_thermal_throttle", - "alert_thermal_shutdown", - "alert_unexpected_location", - "snr", - "seconds_to_first_nonempty_slot", - "pop_ping_drop_rate", - "downlink_throughput_bps", - "uplink_throughput_bps", - "pop_ping_latency_ms", - "currently_obstructed", - "fraction_obstructed"] - tags = ["id"] - -influx_client = InfluxDBClient(host=influxdb_host, port=influxdb_port, username=influxdb_user, password=influxdb_pwd, database=influxdb_db, ssl=False, retries=1, timeout=15) - -dish_channel = None -last_id = None -last_failed = False - -while True: - try: - if dish_channel is None: - dish_channel = grpc.insecure_channel("192.168.100.1:9200") - stub = spacex.api.device.device_pb2_grpc.DeviceStub(dish_channel) - response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) - status = response.dish_get_status - DeviceStatusSeries( - id=status.device_info.id, - hardware_version=status.device_info.hardware_version, - software_version=status.device_info.software_version, - state=spacex.api.device.dish_pb2.DishState.Name(status.state), - alert_motors_stuck=status.alerts.motors_stuck, - alert_thermal_throttle=status.alerts.thermal_throttle, - alert_thermal_shutdown=status.alerts.thermal_shutdown, - alert_unexpected_location=status.alerts.unexpected_location, - snr=status.snr, - seconds_to_first_nonempty_slot=status.seconds_to_first_nonempty_slot, - pop_ping_drop_rate=status.pop_ping_drop_rate, - downlink_throughput_bps=status.downlink_throughput_bps, - uplink_throughput_bps=status.uplink_throughput_bps, - pop_ping_latency_ms=status.pop_ping_latency_ms, - currently_obstructed=status.obstruction_stats.currently_obstructed, - fraction_obstructed=status.obstruction_stats.fraction_obstructed) - last_id = status.device_info.id - last_failed = False - except grpc.RpcError: - if dish_channel is not None: - dish_channel.close() - dish_channel = None - if last_failed: - if last_id is not None: - DeviceStatusSeries(id=last_id, state="DISH_UNREACHABLE") - else: - # Retry once, because the connection may have been lost while - # we were sleeping - last_failed = True - continue - try: - DeviceStatusSeries.commit(influx_client) - except Exception as e: - print("Failed to write: " + str(e)) - break From 21e9c010e2475e4047d5fe38cf31eb6689ee8f1f Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Sun, 10 Jan 2021 23:41:05 -0800 Subject: [PATCH 11/23] Create entrypoint.sh --- entrypoint.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 entrypoint.sh diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..e2d91ef --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +printenv >> /etc/environment +ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +grpcurl -plaintext -protoset-out dish.protoset 192.168.100.1:9200 describe SpaceX.API.Device.Device +python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/device.proto +python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/common/status/status.proto +python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/command.proto +python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/common.proto +python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/dish.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 +/usr/local/bin/python3 $1 From 7fb595bbda87ba7d8dcc6c4f10628e56629779b5 Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Sun, 10 Jan 2021 23:41:44 -0800 Subject: [PATCH 12/23] Update Dockerfile --- Dockerfile | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4439c34..b909990 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,11 +2,6 @@ FROM python:3.9 LABEL maintainer="neurocis " RUN true && \ -# Install package prerequisites -apt-get update && \ -apt-get install -qy cron && \ -apt-get clean && \ -rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \ \ # Install GRPCurl wget https://github.com/fullstorydev/grpcurl/releases/download/v1.8.0/grpcurl_1.8.0_linux_x86_64.tar.gz && \ @@ -23,20 +18,8 @@ ADD . /app WORKDIR /app # run crond as main process of container -CMD true && \ -printenv >> /etc/environment && \ -ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone && \ -#ntpd -p pool.ntp.org && \ -grpcurl -plaintext -protoset-out dish.protoset 192.168.100.1:9200 describe SpaceX.API.Device.Device && \ -python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/device.proto && \ -python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/common/status/status.proto && \ -python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/command.proto && \ -python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/common.proto && \ -python3 -m grpc_tools.protoc --descriptor_set_in=dish.protoset --python_out=. --grpc_python_out=. spacex/api/device/dish.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 && \ -echo "$CRON_ENTRY" | crontab - && cron -f +ENTRYPOINT ["/bin/sh", "/app/entrypoint.sh"] +CMD ["dishStatusInflux.py"] -# docker run -d --name='starlink-grpc-tools' -e INFLUXDB_HOST=192.168.1.34 -e INFLUXDB_PORT=8086 -e INFLUXDB_DB=starlink -# -e "CRON_ENTRY=* * * * * /usr/local/bin/python3 /app/dishStatusInflux_cron.py > /proc/1/fd/1 2>/proc/1/fd/2" -# --net='br0' --ip='192.168.1.39' neurocis/starlink-grpc-tools +# docker run -d --name='starlink-grpc-tools' -e INFLUXDB_HOST=192.168.1.34 -e INFLUXDB_PORT=8086 -e INFLUXDB_DB=starlink +# --net='br0' --ip='192.168.1.39' neurocis/starlink-grpc-tools dishStatusInflux.py From d0fd5a0a2b53837e4ed0bb4b838ab20316963081 Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Sun, 10 Jan 2021 23:57:06 -0800 Subject: [PATCH 13/23] Update entrypoint.sh Change to passthrough all args. --- entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entrypoint.sh b/entrypoint.sh index e2d91ef..5cce7fe 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -10,4 +10,4 @@ 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/dish.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 -/usr/local/bin/python3 $1 +/usr/local/bin/python3 $@ From d21b196f7898a3d6ca91dbacefa38790add67afd Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Mon, 11 Jan 2021 00:00:49 -0800 Subject: [PATCH 14/23] Update README.md --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d1cdcae..2d2a3bc 100644 --- a/README.md +++ b/README.md @@ -72,15 +72,15 @@ The Starlink router also exposes a gRPC service, on ports 9000 (HTTP/2.0) and 90 ## Docker for InfluxDB ( & MQTT under development ) -`dishStatusInflux_cron.py` is a docker-cron friendly script which will post status to an InfluxDB as specified by evironment variables passed to the container. Initialization of the container can be performed with the following command: +Initialization of the container can be performed with the following command: ``` -docker run -d --name='starlink-grpc-tools' -e INFLUXDB_HOST={InfluxDB Hostname} - -e INFLUXDB_PORT={Port, 8086 usually} - -e INFLUXDB_USER={Optional, InfluxDB Username} - -e INFLUXDB_PWD={Optional, InfluxDB Password} - -e INFLUXDB_DB={Pre-created DB name, starlinkstats works well} - -e "CRON_ENTRY=* * * * * /usr/local/bin/python3 /app/dishStatusInflux_cron.py > /proc/1/fd/1 2>/proc/1/fd/2" neurocis/starlink-grpc-tools +docker run -d --name='starlink-grpc-tools' -e INFLUXDB_HOST={InfluxDB Hostname} \ + -e INFLUXDB_PORT={Port, 8086 usually} \ + -e INFLUXDB_USER={Optional, InfluxDB Username} \ + -e INFLUXDB_PWD={Optional, InfluxDB Password} \ + -e INFLUXDB_DB={Pre-created DB name, starlinkstats works well} \ + neurocis/starlink-grpc-tools dishStatusInflux.py -v ``` -Adjust the `CRON_ENTRY` to your desired polling schedule. I (neurocis) will push a Grafana dashboard in the near future, or please create and share your own. +`dishStatusInflux.py -v` is optional and will run same but not -verbose, or you can replace it with one the other scripts if you wish to run that instead. I (neurocis) will push a Grafana dashboard in the near future, or please create and share your own. From eecc65a5ba3538b3449bde804c90b4c25cf9b5eb Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Mon, 11 Jan 2021 00:01:50 -0800 Subject: [PATCH 15/23] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2d2a3bc..6832cb4 100644 --- a/README.md +++ b/README.md @@ -83,4 +83,4 @@ docker run -d --name='starlink-grpc-tools' -e INFLUXDB_HOST={InfluxDB Hostname} neurocis/starlink-grpc-tools dishStatusInflux.py -v ``` -`dishStatusInflux.py -v` is optional and will run same but not -verbose, or you can replace it with one the other scripts if you wish to run that instead. I (neurocis) will push a Grafana dashboard in the near future, or please create and share your own. +`dishStatusInflux.py -v` is optional and will run same but not -verbose, or you can replace it with one of the other scripts if you wish to run that instead. I (neurocis) will push a Grafana dashboard in the near future, or please create and share your own. From b06a5973c1da4a7ef00bb3c002aef859edd0f1f5 Mon Sep 17 00:00:00 2001 From: sparky8512 <76499194+sparky8512@users.noreply.github.com> Date: Mon, 11 Jan 2021 13:03:19 -0800 Subject: [PATCH 16/23] Change default database name to starlinkstats The README instructions @neurocis added for the Docker container recommend this name, and I like that better than dishstats, so now it's the default. "dish" can be useful to differentiate between the Starlink user terminal (dish) and the Starlink router, both of which expose gRPC services for polling status information, but that's more applicable to the measurement name (AKA series_name) and a hypothetical database that contained both would be more appropriately labelled "Starlink". --- dishHistoryInflux.py | 2 +- dishStatusInflux.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dishHistoryInflux.py b/dishHistoryInflux.py index bdbb6e8..9b8fe73 100644 --- a/dishHistoryInflux.py +++ b/dishHistoryInflux.py @@ -35,7 +35,7 @@ print_usage = False verbose = False run_lengths = False host_default = "localhost" -database_default = "dishstats" +database_default = "starlinkstats" icargs = {"host": host_default, "timeout": 5, "database": database_default} rp = None diff --git a/dishStatusInflux.py b/dishStatusInflux.py index 79c809c..2a4987d 100644 --- a/dishStatusInflux.py +++ b/dishStatusInflux.py @@ -33,7 +33,7 @@ except getopt.GetoptError as err: print_usage = False verbose = False host_default = "localhost" -database_default = "dishstats" +database_default = "starlinkstats" icargs = {"host": host_default, "timeout": 5, "database": database_default} rp = None default_sleep_time = 30 From 37988e0f4cd681c7cd6c964669eb37c2a4ef5ab9 Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Mon, 11 Jan 2021 17:15:09 -0800 Subject: [PATCH 17/23] Add Grafana dashboard for Starlink Stats. --- GrafanaDashboard - Starlink Statistics.json | 818 ++++++++++++++++++++ 1 file changed, 818 insertions(+) create mode 100644 GrafanaDashboard - Starlink Statistics.json diff --git a/GrafanaDashboard - Starlink Statistics.json b/GrafanaDashboard - Starlink Statistics.json new file mode 100644 index 0000000..94e6da3 --- /dev/null +++ b/GrafanaDashboard - Starlink Statistics.json @@ -0,0 +1,818 @@ +{ + "__inputs": [ + { + "name": "VAR_DS_INFLUXDB", + "type": "constant", + "label": "InfluxDB DataSource", + "value": "InfluxDB-starlinkstats", + "description": "" + }, + { + "name": "VAR_TBL_STATS", + "type": "constant", + "label": "Table name for Statistics", + "value": "spacex.starlink.user_terminal.status", + "description": "" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "7.3.6" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "datasource", + "id": "influxdb", + "name": "InfluxDB", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1610413551748, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$DS_INFLUXDB", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 12, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "hideZero": false, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.3.6", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "groupBy": [], + "measurement": "/^$TBL_STATS$/", + "orderByTime": "ASC", + "policy": "default", + "queryType": "randomWalk", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "downlink_throughput_bps" + ], + "type": "field" + }, + { + "params": [ + "bps Down" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "uplink_throughput_bps" + ], + "type": "field" + }, + { + "params": [ + "bps Up" + ], + "type": "alias" + } + ] + ], + "tags": [] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Actual Throughput", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:1099", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:1100", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$DS_INFLUXDB", + "description": "", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 12, + "x": 12, + "y": 0 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.3.6", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "groupBy": [], + "measurement": "/^$TBL_STATS$/", + "orderByTime": "ASC", + "policy": "default", + "queryType": "randomWalk", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "pop_ping_latency_ms" + ], + "type": "field" + }, + { + "params": [ + "Ping Latency" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "pop_ping_drop_rate" + ], + "type": "field" + }, + { + "params": [ + "Drop Rate" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "fraction_obstructed" + ], + "type": "field" + }, + { + "params": [ + "*100" + ], + "type": "math" + }, + { + "params": [ + "Percent Obstructed" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "snr" + ], + "type": "field" + }, + { + "params": [ + "*10" + ], + "type": "math" + }, + { + "params": [ + "SNR" + ], + "type": "alias" + } + ] + ], + "tags": [] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Ping Latency, Drop Rate, Percent Obstructed & SNR", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "cacheTimeout": null, + "datasource": "$DS_INFLUXDB", + "description": "", + "fieldConfig": { + "defaults": { + "custom": { + "align": null, + "filterable": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Obstructed" + }, + "properties": [ + { + "id": "custom.width", + "value": 105 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Wrong Location" + }, + "properties": [ + { + "id": "custom.width", + "value": 114 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Thermal Throttle" + }, + "properties": [ + { + "id": "custom.width", + "value": 121 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Thermal Shutdown" + }, + "properties": [ + { + "id": "custom.width", + "value": 136 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Motors Stuck" + }, + "properties": [ + { + "id": "custom.width", + "value": 116 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Time" + }, + "properties": [ + { + "id": "custom.width", + "value": 143 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "State" + }, + "properties": [ + { + "id": "custom.width", + "value": 118 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Bad Location" + }, + "properties": [ + { + "id": "custom.width", + "value": 122 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Temp Throttle" + }, + "properties": [ + { + "id": "custom.width", + "value": 118 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Temp Shutdown" + }, + "properties": [ + { + "id": "custom.width", + "value": 134 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Software Version" + }, + "properties": [ + { + "id": "custom.width", + "value": 369 + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 11 + }, + "id": 6, + "interval": null, + "links": [], + "options": { + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Time (last)" + } + ] + }, + "pluginVersion": "7.3.6", + "targets": [ + { + "groupBy": [], + "hide": false, + "measurement": "/^$TBL_STATS$/", + "orderByTime": "ASC", + "policy": "default", + "query": "SELECT \"currently_obstructed\" AS \"Obstructed\", \"alert_unexpected_location\" AS \"Wrong Location\", \"alert_thermal_throttle\" AS \"Thermal Throttle\", \"alert_thermal_shutdown\" AS \"Thermal Shutdown\", \"alert_motors_stuck\" AS \"Motors Stuck\", \"state\" AS \"State\" FROM \"spacex.starlink.user_terminal.status\" WHERE $timeFilter", + "queryType": "randomWalk", + "rawQuery": false, + "refId": "A", + "resultFormat": "table", + "select": [ + [ + { + "params": [ + "state" + ], + "type": "field" + }, + { + "params": [ + "State" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "currently_obstructed" + ], + "type": "field" + }, + { + "params": [ + "Obstructed" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "alert_unexpected_location" + ], + "type": "field" + }, + { + "params": [ + "Bad Location" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "alert_thermal_throttle" + ], + "type": "field" + }, + { + "params": [ + "Temp Throttled" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "alert_thermal_shutdown" + ], + "type": "field" + }, + { + "params": [ + "Temp Shutdown" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "alert_motors_stuck" + ], + "type": "field" + }, + { + "params": [ + "Motors Stuck" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "software_version" + ], + "type": "field" + }, + { + "params": [ + "Software Version" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "hardware_version" + ], + "type": "field" + }, + { + "params": [ + "Hardware Version" + ], + "type": "alias" + } + ] + ], + "tags": [] + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Alerts & Versions", + "transformations": [ + { + "id": "groupBy", + "options": { + "fields": { + "Bad Location": { + "aggregations": [], + "operation": "groupby" + }, + "Hardware Version": { + "aggregations": [], + "operation": "groupby" + }, + "Motors Stuck": { + "aggregations": [], + "operation": "groupby" + }, + "Obstructed": { + "aggregations": [], + "operation": "groupby" + }, + "Software Version": { + "aggregations": [], + "operation": "groupby" + }, + "State": { + "aggregations": [], + "operation": "groupby" + }, + "Temp Shutdown": { + "aggregations": [], + "operation": "groupby" + }, + "Temp Throttle": { + "aggregations": [], + "operation": "groupby" + }, + "Temp Throttled": { + "aggregations": [], + "operation": "groupby" + }, + "Thermal Shutdown": { + "aggregations": [], + "operation": "groupby" + }, + "Thermal Throttle": { + "aggregations": [], + "operation": "groupby" + }, + "Time": { + "aggregations": [ + "last" + ], + "operation": "aggregate" + }, + "Wrong Location": { + "aggregations": [], + "operation": "groupby" + } + } + } + } + ], + "type": "table" + } + ], + "refresh": false, + "schemaVersion": 26, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": { + "value": "${VAR_DS_INFLUXDB}", + "text": "${VAR_DS_INFLUXDB}", + "selected": false + }, + "error": null, + "hide": 2, + "label": "InfluxDB DataSource", + "name": "DS_INFLUXDB", + "options": [ + { + "value": "${VAR_DS_INFLUXDB}", + "text": "${VAR_DS_INFLUXDB}", + "selected": false + } + ], + "query": "${VAR_DS_INFLUXDB}", + "skipUrlSync": false, + "type": "constant" + }, + { + "current": { + "value": "${VAR_TBL_STATS}", + "text": "${VAR_TBL_STATS}", + "selected": false + }, + "error": null, + "hide": 2, + "label": "Table name for Statistics", + "name": "TBL_STATS", + "options": [ + { + "value": "${VAR_TBL_STATS}", + "text": "${VAR_TBL_STATS}", + "selected": false + } + ], + "query": "${VAR_TBL_STATS}", + "skipUrlSync": false, + "type": "constant" + } + ] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Starlink Statistics", + "uid": "ymkHwLaMz", + "version": 36 +} \ No newline at end of file From 0ed3e074db874a4220d8967aa54713df7d9fd19f Mon Sep 17 00:00:00 2001 From: Leigh Phillips Date: Mon, 11 Jan 2021 17:24:10 -0800 Subject: [PATCH 18/23] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6832cb4..427bb43 100644 --- a/README.md +++ b/README.md @@ -83,4 +83,6 @@ docker run -d --name='starlink-grpc-tools' -e INFLUXDB_HOST={InfluxDB Hostname} neurocis/starlink-grpc-tools dishStatusInflux.py -v ``` -`dishStatusInflux.py -v` is optional and will run same but not -verbose, or you can replace it with one of the other scripts if you wish to run that instead. I (neurocis) will push a Grafana dashboard in the near future, or please create and share your own. +`dishStatusInflux.py -v` is optional and will run same but not -verbose, or you can replace it with one of the other scripts if you wish to run that instead. There is also an `GrafanaDashboard - Starlink Statistics.json` which can be imported to get some charts like: + +![image](https://user-images.githubusercontent.com/945191/104257179-ae570000-5431-11eb-986e-3fedd04bfcfb.png) From fcbcbf4ef7698920006da23ecf0a6f18fa093352 Mon Sep 17 00:00:00 2001 From: sparky8512 <76499194+sparky8512@users.noreply.github.com> Date: Mon, 11 Jan 2021 22:04:39 -0800 Subject: [PATCH 19/23] Make sure ints stay ints and floats stay floats Specific history data patterns would sometimes lead to some of the stats switching between int and float type even if they were always whole numbers. This should ensure that doesn't happen. I think this will fix #12, but will likely require deleting all the spacex.starlink.user_terminal.ping_stats data points from the database before the type conflict failure will go away. --- starlink_grpc.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/starlink_grpc.py b/starlink_grpc.py index 10ceca9..09ec8d0 100644 --- a/starlink_grpc.py +++ b/starlink_grpc.py @@ -185,13 +185,13 @@ def history_ping_stats(parse_samples, verbose=False): # index to next data sample after the newest one. offset = current % samples - tot = 0 + tot = 0.0 count_full_drop = 0 count_unsched = 0 - total_unsched_drop = 0 + total_unsched_drop = 0.0 count_full_unsched = 0 count_obstruct = 0 - total_obstruct_drop = 0 + total_obstruct_drop = 0.0 count_full_obstruct = 0 second_runs = [0] * 60 @@ -211,9 +211,10 @@ def history_ping_stats(parse_samples, verbose=False): for i in sample_range: d = history.pop_ping_drop_rate[i] - tot += d if d >= 1: - count_full_drop += d + # just in case... + d = 1 + count_full_drop += 1 run_length += 1 elif run_length > 0: if init_run_length is None: @@ -238,7 +239,8 @@ def history_ping_stats(parse_samples, verbose=False): count_obstruct += 1 total_obstruct_drop += d if d >= 1: - count_full_obstruct += d + count_full_obstruct += 1 + tot += d # If the entire sample set is one big drop run, it will be both initial # fragment (continued from prior sample range) and final one (continued From 9ccfeb8181932492467cc1b9bd144da0b6ad73b3 Mon Sep 17 00:00:00 2001 From: sparky8512 <76499194+sparky8512@users.noreply.github.com> Date: Tue, 12 Jan 2021 11:23:37 -0800 Subject: [PATCH 20/23] Correct one more int/float mixage Also, pull the change into the JSON parser for consistency. Related to issue #12 --- starlink_grpc.py | 2 +- starlink_json.py | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/starlink_grpc.py b/starlink_grpc.py index 09ec8d0..d4f8e04 100644 --- a/starlink_grpc.py +++ b/starlink_grpc.py @@ -231,7 +231,7 @@ def history_ping_stats(parse_samples, verbose=False): count_unsched += 1 total_unsched_drop += d if d >= 1: - count_full_unsched += d + count_full_unsched += 1 # scheduled=false and obstructed=true do not ever appear to overlap, # but in case they do in the future, treat that as just unscheduled # in order to avoid double-counting it. diff --git a/starlink_json.py b/starlink_json.py index 383e875..4365040 100644 --- a/starlink_json.py +++ b/starlink_json.py @@ -100,13 +100,13 @@ def history_ping_stats(filename, parse_samples, verbose=False): # index to next data sample after the newest one. offset = current % samples - tot = 0 + tot = 0.0 count_full_drop = 0 count_unsched = 0 - total_unsched_drop = 0 + total_unsched_drop = 0.0 count_full_unsched = 0 count_obstruct = 0 - total_obstruct_drop = 0 + total_obstruct_drop = 0.0 count_full_obstruct = 0 second_runs = [0] * 60 @@ -126,9 +126,10 @@ def history_ping_stats(filename, parse_samples, verbose=False): for i in sample_range: d = history["popPingDropRate"][i] - tot += d if d >= 1: - count_full_drop += d + # just in case... + d = 1 + count_full_drop += 1 run_length += 1 elif run_length > 0: if init_run_length is None: @@ -145,7 +146,7 @@ def history_ping_stats(filename, parse_samples, verbose=False): count_unsched += 1 total_unsched_drop += d if d >= 1: - count_full_unsched += d + count_full_unsched += 1 # scheduled=false and obstructed=true do not ever appear to overlap, # but in case they do in the future, treat that as just unscheduled # in order to avoid double-counting it. @@ -153,7 +154,8 @@ def history_ping_stats(filename, parse_samples, verbose=False): count_obstruct += 1 total_obstruct_drop += d if d >= 1: - count_full_obstruct += d + count_full_obstruct += 1 + tot += d # If the entire sample set is one big drop run, it will be both initial # fragment (continued from prior sample range) and final one (continued From a589a75ce52e3f6d46429ed199a718e0a14678c0 Mon Sep 17 00:00:00 2001 From: sparky8512 <76499194+sparky8512@users.noreply.github.com> Date: Tue, 12 Jan 2021 19:51:38 -0800 Subject: [PATCH 21/23] Revamp error printing Closes #8 --- dishHistoryInflux.py | 20 +++++++++-------- dishHistoryMqtt.py | 20 +++++++++-------- dishHistoryStats.py | 11 ++++++---- dishStatusCsv.py | 5 ++++- dishStatusInflux.py | 51 +++++++++++++++++++++++++++++++++----------- dishStatusMqtt.py | 7 ++++-- parseJsonHistory.py | 13 ++++++----- starlink_grpc.py | 42 +++++++++++++++++++++++++----------- starlink_json.py | 30 +++++++++++++++----------- 9 files changed, 132 insertions(+), 67 deletions(-) diff --git a/dishHistoryInflux.py b/dishHistoryInflux.py index 9b8fe73..d757901 100644 --- a/dishHistoryInflux.py +++ b/dishHistoryInflux.py @@ -14,6 +14,7 @@ import datetime import os import sys import getopt +import logging import warnings from influxdb import InfluxDBClient @@ -128,19 +129,20 @@ if print_usage or arg_error: print(" -U : Set username for authentication") sys.exit(1 if arg_error else 0) -dish_id = starlink_grpc.get_id() +logging.basicConfig(format="%(levelname)s: %(message)s") -if dish_id is None: - if verbose: - print("Unable to connect to Starlink user terminal") +try: + dish_id = starlink_grpc.get_id() +except starlink_grpc.GrpcError as e: + logging.error("Failure getting dish ID: " + str(e)) sys.exit(1) timestamp = datetime.datetime.utcnow() -g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) - -if g_stats is None: - # verbose output already happened, so just bail. +try: + g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) +except starlink_grpc.GrpcError as e: + logging.error("Failure getting ping stats: " + str(e)) sys.exit(1) all_stats = g_stats.copy() @@ -169,7 +171,7 @@ try: influx_client.write_points(points, retention_policy=rp) rc = 0 except Exception as e: - print("Failed writing to InfluxDB database: " + str(e)) + logging.error("Failed writing to InfluxDB database: " + str(e)) rc = 1 finally: influx_client.close() diff --git a/dishHistoryMqtt.py b/dishHistoryMqtt.py index 2291150..e9267cc 100644 --- a/dishHistoryMqtt.py +++ b/dishHistoryMqtt.py @@ -12,6 +12,7 @@ import sys import getopt +import logging try: import ssl @@ -97,17 +98,18 @@ if print_usage or arg_error: print(" -U: Set username for authentication") sys.exit(1 if arg_error else 0) -dish_id = starlink_grpc.get_id() +logging.basicConfig(format="%(levelname)s: %(message)s") -if dish_id is None: - if verbose: - print("Unable to connect to Starlink user terminal") +try: + dish_id = starlink_grpc.get_id() +except starlink_grpc.GrpcError as e: + logging.error("Failure getting dish ID: " + str(e)) sys.exit(1) -g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) - -if g_stats is None: - # verbose output already happened, so just bail. +try: + g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) +except starlink_grpc.GrpcError as e: + logging.error("Failure getting ping stats: " + str(e)) sys.exit(1) topic_prefix = "starlink/dish_ping_stats/" + dish_id + "/" @@ -128,5 +130,5 @@ if username is not None: try: paho.mqtt.publish.multiple(msgs, client_id=dish_id, **mqargs) except Exception as e: - print("Failed publishing to MQTT broker: " + str(e)) + logging.error("Failed publishing to MQTT broker: " + str(e)) sys.exit(1) diff --git a/dishHistoryStats.py b/dishHistoryStats.py index ab2d9bb..683f490 100644 --- a/dishHistoryStats.py +++ b/dishHistoryStats.py @@ -13,6 +13,7 @@ import datetime import sys import getopt +import logging import starlink_grpc @@ -61,6 +62,8 @@ if print_usage or arg_error: print(" -H: print CSV header instead of parsing file") sys.exit(1 if arg_error else 0) +logging.basicConfig(format="%(levelname)s: %(message)s") + g_fields, pd_fields, rl_fields = starlink_grpc.history_ping_field_names() if print_header: @@ -78,10 +81,10 @@ if print_header: timestamp = datetime.datetime.utcnow() -g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) - -if g_stats is None: - # verbose output already happened, so just bail. +try: + g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) +except starlink_grpc.GrpcError as e: + logging.error("Failure getting ping stats: " + str(e)) sys.exit(1) if verbose: diff --git a/dishStatusCsv.py b/dishStatusCsv.py index 0c96587..c8b7968 100644 --- a/dishStatusCsv.py +++ b/dishStatusCsv.py @@ -10,6 +10,7 @@ import datetime import sys import getopt +import logging import grpc @@ -44,6 +45,8 @@ if print_usage or arg_error: print(" -H: print CSV header instead of parsing file") sys.exit(1 if arg_error else 0) +logging.basicConfig(format="%(levelname)s: %(message)s") + if print_header: header = [ "datetimestamp_utc", @@ -71,7 +74,7 @@ try: stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) except grpc.RpcError: - print("Failed getting status info") + logging.error("Failed getting status info") sys.exit(1) timestamp = datetime.datetime.utcnow() diff --git a/dishStatusInflux.py b/dishStatusInflux.py index 2a4987d..98c5d01 100644 --- a/dishStatusInflux.py +++ b/dishStatusInflux.py @@ -12,8 +12,9 @@ import time import os import sys import getopt - +import logging import warnings + from influxdb import InfluxDBClient from influxdb import SeriesHelper @@ -123,6 +124,18 @@ if print_usage or arg_error: print(" -U : Set username for authentication") sys.exit(1 if arg_error else 0) +logging.basicConfig(format="%(levelname)s: %(message)s") + +def conn_error(msg): + # Connection errors that happen while running in an interval loop are + # not critical failures, because they can (usually) be retried, or + # because they will be recorded as dish state unavailable. They're still + # interesting, though, so print them even in non-verbose mode. + if sleep_time > 0: + print(msg) + else: + logging.error(msg) + class DeviceStatusSeries(SeriesHelper): class Meta: series_name = "spacex.starlink.user_terminal.status" @@ -151,6 +164,7 @@ if "verify_ssl" in icargs and not icargs["verify_ssl"]: influx_client = InfluxDBClient(**icargs) +rc = 0 try: dish_channel = None last_id = None @@ -182,6 +196,7 @@ try: pop_ping_latency_ms=status.pop_ping_latency_ms, currently_obstructed=status.obstruction_stats.currently_obstructed, fraction_obstructed=status.obstruction_stats.fraction_obstructed) + pending += 1 last_id = status.device_info.id last_failed = False except grpc.RpcError: @@ -189,25 +204,36 @@ try: dish_channel.close() dish_channel = None if last_failed: - if last_id is not None: + if last_id is None: + conn_error("Dish unreachable and ID unknown, so not recording state") + # When not looping, report this as failure exit status + rc = 1 + else: + if verbose: + print("Dish unreachable") DeviceStatusSeries(id=last_id, state="DISH_UNREACHABLE") + pending += 1 else: + if verbose: + print("Dish RPC channel error") # Retry once, because the connection may have been lost while # we were sleeping last_failed = True continue - pending = pending + 1 if verbose: - print("Samples: " + str(pending)) - count = count + 1 + print("Samples queued: " + str(pending)) + count += 1 if count > 5: try: - DeviceStatusSeries.commit(influx_client) + if pending: + DeviceStatusSeries.commit(influx_client) + rc = 0 if verbose: - print("Wrote " + str(pending)) + print("Samples written: " + str(pending)) pending = 0 except Exception as e: - print("Failed to write: " + str(e)) + conn_error("Failed to write: " + str(e)) + rc = 1 count = 0 if sleep_time > 0: time.sleep(sleep_time) @@ -216,12 +242,13 @@ try: finally: # Flush on error/exit try: - DeviceStatusSeries.commit(influx_client) + if pending: + DeviceStatusSeries.commit(influx_client) + rc = 0 if verbose: - print("Wrote " + str(pending)) - rc = 0 + print("Samples written: " + str(pending)) except Exception as e: - print("Failed to write: " + str(e)) + conn_error("Failed to write: " + str(e)) rc = 1 influx_client.close() if dish_channel is not None: diff --git a/dishStatusMqtt.py b/dishStatusMqtt.py index ee4c667..e91763f 100644 --- a/dishStatusMqtt.py +++ b/dishStatusMqtt.py @@ -10,6 +10,7 @@ import sys import getopt +import logging try: import ssl @@ -81,12 +82,14 @@ if print_usage or arg_error: print(" -U: Set username for authentication") sys.exit(1 if arg_error else 0) +logging.basicConfig(format="%(levelname)s: %(message)s") + try: with grpc.insecure_channel("192.168.100.1:9200") as channel: stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) except grpc.RpcError: - print("Failed getting status info") + logging.error("Failed getting status info") sys.exit(1) status = response.dish_get_status @@ -126,5 +129,5 @@ if username is not None: try: paho.mqtt.publish.multiple(msgs, client_id=status.device_info.id, **mqargs) except Exception as e: - print("Failed publishing to MQTT broker: " + str(e)) + logging.error("Failed publishing to MQTT broker: " + str(e)) sys.exit(1) diff --git a/parseJsonHistory.py b/parseJsonHistory.py index 3e8d1aa..50fe1ff 100644 --- a/parseJsonHistory.py +++ b/parseJsonHistory.py @@ -16,6 +16,7 @@ import datetime import sys import getopt +import logging import starlink_json @@ -65,6 +66,8 @@ if print_usage or arg_error: print(" -H: print CSV header instead of parsing file") sys.exit(1 if arg_error else 0) +logging.basicConfig(format="%(levelname)s: %(message)s") + g_fields, pd_fields, rl_fields = starlink_json.history_ping_field_names() if print_header: @@ -82,11 +85,11 @@ if print_header: timestamp = datetime.datetime.utcnow() -g_stats, pd_stats, rl_stats = starlink_json.history_ping_stats(args[0] if args else "-", - samples, verbose) - -if g_stats is None: - # verbose output already happened, so just bail. +try: + g_stats, pd_stats, rl_stats = starlink_json.history_ping_stats(args[0] if args else "-", + samples, verbose) +except starlink_json.JsonError as e: + logging.error("Failure getting ping stats: " + str(e)) sys.exit(1) if verbose: diff --git a/starlink_grpc.py b/starlink_grpc.py index d4f8e04..ec65b14 100644 --- a/starlink_grpc.py +++ b/starlink_grpc.py @@ -82,6 +82,21 @@ import grpc import spacex.api.device.device_pb2 import spacex.api.device.device_pb2_grpc + +class GrpcError(Exception): + """Provides error info when something went wrong with a gRPC call.""" + def __init__(self, e, *args, **kwargs): + # grpc.RpcError is too verbose to print in whole, but it may also be + # a Call object, and that class has some minimally useful info. + if isinstance(e, grpc.Call): + msg = e.details() + elif isinstance(e, grpc.RpcError): + msg = "Unknown communication or service error" + else: + msg = str(e) + super().__init__(msg, *args, **kwargs) + + def get_status(): """Fetch status data and return it in grpc structure format. @@ -98,13 +113,16 @@ def get_id(): Returns: A string identifying the Starlink user terminal reachable from the - local network, or None if no user terminal is currently reachable. + local network. + + Raises: + GrpcError: No user terminal is currently reachable. """ try: status = get_status() return status.device_info.id - except grpc.RpcError: - return None + except grpc.RpcError as e: + raise GrpcError(e) def history_ping_field_names(): """Return the field names of the packet loss stats. @@ -152,20 +170,18 @@ def history_ping_stats(parse_samples, verbose=False): verbose (bool): Optionally produce verbose output. Returns: - On success, a tuple with 3 dicts, the first mapping general stat names - to their values, the second mapping ping drop stat names to their - values and the third mapping ping drop run length stat names to their - values. + A tuple with 3 dicts, the first mapping general stat names to their + values, the second mapping ping drop stat names to their values and + the third mapping ping drop run length stat names to their values. - On failure, the tuple (None, None, None). + Raises: + GrpcError: Failed getting history info from the Starlink user + terminal. """ try: history = get_history() - except grpc.RpcError: - if verbose: - # RpcError is too verbose to print the details. - print("Failed getting history") - return None, None, None + except grpc.RpcError as e: + raise GrpcError(e) # 'current' is the count of data samples written to the ring buffer, # irrespective of buffer wrap. diff --git a/starlink_json.py b/starlink_json.py index 4365040..7396c5a 100644 --- a/starlink_json.py +++ b/starlink_json.py @@ -14,6 +14,11 @@ import sys from itertools import chain + +class JsonError(Exception): + """Provides error info when something went wrong with JSON parsing.""" + + def history_ping_field_names(): """Return the field names of the packet loss stats. @@ -46,15 +51,16 @@ def get_history(filename): Args: filename (str): Filename from which to read JSON data, or "-" to read from standard input. + + Raises: + Various exceptions depending on Python version: Failure to open or + read input or invalid JSON read on input. """ if filename == "-": json_data = json.load(sys.stdin) else: - json_file = open(filename) - try: + with open(filename) as json_file: json_data = json.load(json_file) - finally: - json_file.close() return json_data["dishGetHistory"] def history_ping_stats(filename, parse_samples, verbose=False): @@ -68,19 +74,19 @@ def history_ping_stats(filename, parse_samples, verbose=False): verbose (bool): Optionally produce verbose output. Returns: - On success, a tuple with 3 dicts, the first mapping general stat names - to their values, the second mapping ping drop stat names to their - values and the third mapping ping drop run length stat names to their - values. + A tuple with 3 dicts, the first mapping general stat names to their + values, the second mapping ping drop stat names to their values and + the third mapping ping drop run length stat names to their values. - On failure, the tuple (None, None, None). + Raises: + JsonError: Failure to open, read, or parse JSON on input. """ try: history = get_history(filename) + except ValueError as e: + raise JsonError("Failed to parse JSON: " + str(e)) except Exception as e: - if verbose: - print("Failed getting history: " + str(e)) - return None, None, None + raise JsonError(e) # "current" is the count of data samples written to the ring buffer, # irrespective of buffer wrap. From 46f65a62144b783af1c1857ae6b4380adbebf80d Mon Sep 17 00:00:00 2001 From: sparky8512 <76499194+sparky8512@users.noreply.github.com> Date: Fri, 15 Jan 2021 18:39:33 -0800 Subject: [PATCH 22/23] 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 --- README.md | 42 +++- dishHistoryInflux.py | 363 +++++++++++++++++++++-------------- dishHistoryMqtt.py | 253 ++++++++++++++---------- dishHistoryStats.py | 210 +++++++++++--------- dishStatusCsv.py | 204 ++++++++++++-------- dishStatusInflux.py | 448 ++++++++++++++++++++++--------------------- dishStatusMqtt.py | 253 ++++++++++++++---------- 7 files changed, 1028 insertions(+), 745 deletions(-) diff --git a/README.md b/README.md index 427bb43..6cee089 100644 --- a/README.md +++ b/README.md @@ -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. -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/ @@ -17,6 +17,10 @@ The scripts that use [InfluxDB](https://www.influxdata.com/products/influxdb/) f ## 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: ``` 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. -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 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_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 ``` `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 ``` -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) 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 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. diff --git a/dishHistoryInflux.py b/dishHistoryInflux.py index d757901..b1a9b3e 100644 --- a/dishHistoryInflux.py +++ b/dishHistoryInflux.py @@ -10,169 +10,234 @@ # ###################################################################### +import getopt import datetime +import logging import os import sys -import getopt -import logging - +import time import warnings + from influxdb import InfluxDBClient import starlink_grpc -arg_error = False -try: - opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:vC:D:IP:R:SU:") -except getopt.GetoptError as err: - print(str(err)) - arg_error = True +def main(): + arg_error = False -# Default to 1 hour worth of data samples. -samples_default = 3600 -samples = samples_default -print_usage = False -verbose = False -run_lengths = False -host_default = "localhost" -database_default = "starlinkstats" -icargs = {"host": host_default, "timeout": 5, "database": database_default} -rp = None - -# For each of these check they are both set and not empty string -influxdb_host = os.environ.get("INFLUXDB_HOST") -if influxdb_host: - icargs["host"] = influxdb_host -influxdb_port = os.environ.get("INFLUXDB_PORT") -if influxdb_port: - icargs["port"] = int(influxdb_port) -influxdb_user = os.environ.get("INFLUXDB_USER") -if influxdb_user: - icargs["username"] = influxdb_user -influxdb_pwd = os.environ.get("INFLUXDB_PWD") -if influxdb_pwd: - icargs["password"] = influxdb_pwd -influxdb_db = os.environ.get("INFLUXDB_DB") -if influxdb_db: - icargs["database"] = influxdb_db -influxdb_rp = os.environ.get("INFLUXDB_RP") -if influxdb_rp: - rp = influxdb_rp -influxdb_ssl = os.environ.get("INFLUXDB_SSL") -if influxdb_ssl: - icargs["ssl"] = True - if influxdb_ssl.lower() == "secure": - icargs["verify_ssl"] = True - elif influxdb_ssl.lower() == "insecure": - icargs["verify_ssl"] = False - else: - icargs["verify_ssl"] = influxdb_ssl - -if not arg_error: - if len(args) > 0: + try: + opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:t:vC:D:IP:R:SU:") + except getopt.GetoptError as err: + print(str(err)) arg_error = True - else: - for opt, arg in opts: - if opt == "-a": - samples = -1 - elif opt == "-h": - print_usage = True - elif opt == "-n": - icargs["host"] = arg - elif opt == "-p": - icargs["port"] = int(arg) - elif opt == "-r": - run_lengths = True - elif opt == "-s": - samples = int(arg) - elif opt == "-v": - verbose = True - elif opt == "-C": - icargs["ssl"] = True - icargs["verify_ssl"] = arg - elif opt == "-D": - icargs["database"] = arg - elif opt == "-I": - icargs["ssl"] = True - icargs["verify_ssl"] = False - elif opt == "-P": - icargs["password"] = arg - elif opt == "-R": - rp = arg - elif opt == "-S": - icargs["ssl"] = True - icargs["verify_ssl"] = True - elif opt == "-U": - icargs["username"] = arg -if "password" in icargs and "username" not in icargs: - print("Password authentication requires username to be set") - arg_error = True + # Default to 1 hour worth of data samples. + samples_default = 3600 + samples = None + print_usage = False + verbose = False + default_loop_time = 0 + loop_time = default_loop_time + run_lengths = False + host_default = "localhost" + database_default = "starlinkstats" + icargs = {"host": host_default, "timeout": 5, "database": database_default} + rp = None + flush_limit = 6 -if print_usage or arg_error: - print("Usage: " + sys.argv[0] + " [options...]") - print("Options:") - print(" -a: Parse all valid samples") - print(" -h: Be helpful") - print(" -n : Hostname of InfluxDB server, default: " + host_default) - print(" -p : Port number to use on InfluxDB server") - print(" -r: Include ping drop run length stats") - print(" -s : Number of data samples to parse, default: " + str(samples_default)) - print(" -v: Be verbose") - print(" -C : Enable SSL/TLS using specified CA cert to verify server") - print(" -D : Database name to use, default: " + database_default) - print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") - print(" -P : Set password for authentication") - print(" -R : Retention policy name to use") - print(" -S: Enable SSL/TLS using default CA cert") - print(" -U : Set username for authentication") - sys.exit(1 if arg_error else 0) - -logging.basicConfig(format="%(levelname)s: %(message)s") - -try: - dish_id = starlink_grpc.get_id() -except starlink_grpc.GrpcError as e: - logging.error("Failure getting dish ID: " + str(e)) - sys.exit(1) - -timestamp = datetime.datetime.utcnow() - -try: - g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) -except starlink_grpc.GrpcError as e: - logging.error("Failure getting ping stats: " + str(e)) - sys.exit(1) - -all_stats = g_stats.copy() -all_stats.update(pd_stats) -if run_lengths: - for k, v in rl_stats.items(): - if k.startswith("run_"): - for i, subv in enumerate(v, start=1): - all_stats[k + "_" + str(i)] = subv + # For each of these check they are both set and not empty string + influxdb_host = os.environ.get("INFLUXDB_HOST") + if influxdb_host: + icargs["host"] = influxdb_host + influxdb_port = os.environ.get("INFLUXDB_PORT") + if influxdb_port: + icargs["port"] = int(influxdb_port) + influxdb_user = os.environ.get("INFLUXDB_USER") + if influxdb_user: + icargs["username"] = influxdb_user + influxdb_pwd = os.environ.get("INFLUXDB_PWD") + if influxdb_pwd: + icargs["password"] = influxdb_pwd + influxdb_db = os.environ.get("INFLUXDB_DB") + if influxdb_db: + icargs["database"] = influxdb_db + influxdb_rp = os.environ.get("INFLUXDB_RP") + if influxdb_rp: + rp = influxdb_rp + influxdb_ssl = os.environ.get("INFLUXDB_SSL") + if influxdb_ssl: + icargs["ssl"] = True + if influxdb_ssl.lower() == "secure": + icargs["verify_ssl"] = True + elif influxdb_ssl.lower() == "insecure": + icargs["verify_ssl"] = False else: - all_stats[k] = v + icargs["verify_ssl"] = influxdb_ssl -points = [{ - "measurement": "spacex.starlink.user_terminal.ping_stats", - "tags": {"id": dish_id}, - "time": timestamp, - "fields": all_stats, -}] + if not arg_error: + if len(args) > 0: + arg_error = True + else: + for opt, arg in opts: + if opt == "-a": + samples = -1 + elif opt == "-h": + print_usage = True + elif opt == "-n": + icargs["host"] = arg + elif opt == "-p": + icargs["port"] = int(arg) + elif opt == "-r": + run_lengths = True + elif opt == "-s": + samples = int(arg) + elif opt == "-t": + loop_time = float(arg) + elif opt == "-v": + verbose = True + elif opt == "-C": + icargs["ssl"] = True + icargs["verify_ssl"] = arg + elif opt == "-D": + icargs["database"] = arg + elif opt == "-I": + icargs["ssl"] = True + icargs["verify_ssl"] = False + elif opt == "-P": + icargs["password"] = arg + elif opt == "-R": + rp = arg + elif opt == "-S": + icargs["ssl"] = True + icargs["verify_ssl"] = True + elif opt == "-U": + icargs["username"] = arg -if "verify_ssl" in icargs and not icargs["verify_ssl"]: - # user has explicitly said be insecure, so don't warn about it - warnings.filterwarnings("ignore", message="Unverified HTTPS request") + if "password" in icargs and "username" not in icargs: + print("Password authentication requires username to be set") + arg_error = True -influx_client = InfluxDBClient(**icargs) -try: - influx_client.write_points(points, retention_policy=rp) - rc = 0 -except Exception as e: - logging.error("Failed writing to InfluxDB database: " + str(e)) - rc = 1 -finally: - influx_client.close() -sys.exit(rc) + if print_usage or arg_error: + print("Usage: " + sys.argv[0] + " [options...]") + print("Options:") + print(" -a: Parse all valid samples") + print(" -h: Be helpful") + print(" -n : Hostname of InfluxDB server, default: " + host_default) + print(" -p : Port number to use on InfluxDB server") + print(" -r: Include ping drop run length stats") + print(" -s : Number of data samples to parse, default: loop interval,") + print(" if set, else " + str(samples_default)) + print(" -t : Loop interval in seconds or 0 for no loop, default: " + + str(default_loop_time)) + print(" -v: Be verbose") + print(" -C : Enable SSL/TLS using specified CA cert to verify server") + print(" -D : Database name to use, default: " + database_default) + print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") + print(" -P : Set password for authentication") + print(" -R : Retention policy name to use") + print(" -S: Enable SSL/TLS using default CA cert") + print(" -U : Set username for authentication") + 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") + + 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: + 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: + conn_error("Failure getting dish ID: " + str(e)) + return 1 + + timestamp = datetime.datetime.utcnow() + + try: + g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) + except starlink_grpc.GrpcError as e: + conn_error("Failure getting ping stats: " + str(e)) + return 1 + + all_stats = g_stats.copy() + all_stats.update(pd_stats) + if run_lengths: + for k, v in rl_stats.items(): + if k.startswith("run_"): + for i, subv in enumerate(v, start=1): + all_stats[k + "_" + str(i)] = subv + else: + all_stats[k] = v + + gstate.points.append({ + "measurement": "spacex.starlink.user_terminal.ping_stats", + "tags": { + "id": gstate.dish_id + }, + "time": timestamp, + "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"]: + # user has explicitly said be insecure, so don't warn about it + warnings.filterwarnings("ignore", message="Unverified HTTPS request") + + influx_client = InfluxDBClient(**icargs) + try: + next_loop = time.monotonic() + while True: + rc = loop_body(influx_client) + if loop_time > 0: + now = time.monotonic() + next_loop = max(next_loop + loop_time, now) + time.sleep(next_loop - now) + else: + break + finally: + if gstate.points: + rc = flush_points(influx_client) + influx_client.close() + + sys.exit(rc) + + +if __name__ == '__main__': + main() diff --git a/dishHistoryMqtt.py b/dishHistoryMqtt.py index e9267cc..1e7b855 100644 --- a/dishHistoryMqtt.py +++ b/dishHistoryMqtt.py @@ -10,9 +10,10 @@ # ###################################################################### -import sys import getopt import logging +import sys +import time try: import ssl @@ -24,111 +25,161 @@ import paho.mqtt.publish import starlink_grpc -arg_error = False -try: - opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:vC:ISP:U:") -except getopt.GetoptError as err: - print(str(err)) - arg_error = True +def main(): + arg_error = False -# Default to 1 hour worth of data samples. -samples_default = 3600 -samples = samples_default -print_usage = False -verbose = False -run_lengths = False -host_default = "localhost" -mqargs = {"hostname": host_default} -username = None -password = None - -if not arg_error: - if len(args) > 0: + try: + opts, args = getopt.getopt(sys.argv[1:], "ahn:p:rs:t:vC:ISP:U:") + except getopt.GetoptError as err: + print(str(err)) arg_error = True - else: - for opt, arg in opts: - if opt == "-a": - samples = -1 - elif opt == "-h": - print_usage = True - elif opt == "-n": - mqargs["hostname"] = arg - elif opt == "-p": - mqargs["port"] = int(arg) - elif opt == "-r": - run_lengths = True - elif opt == "-s": - samples = int(arg) - elif opt == "-v": - verbose = True - elif opt == "-C": - mqargs["tls"] = {"ca_certs": arg} - elif opt == "-I": - if ssl_ok: - mqargs["tls"] = {"cert_reqs": ssl.CERT_NONE} - else: - print("No SSL support found") - sys.exit(1) - elif opt == "-P": - password = arg - elif opt == "-S": - mqargs["tls"] = {} - elif opt == "-U": - username = arg -if username is None and password is not None: - print("Password authentication requires username to be set") - arg_error = True + # Default to 1 hour worth of data samples. + samples_default = 3600 + samples = None + print_usage = False + verbose = False + default_loop_time = 0 + loop_time = default_loop_time + run_lengths = False + host_default = "localhost" + mqargs = {"hostname": host_default} + username = None + password = None -if print_usage or arg_error: - print("Usage: " + sys.argv[0] + " [options...]") - print("Options:") - print(" -a: Parse all valid samples") - print(" -h: Be helpful") - print(" -n : Hostname of MQTT broker, default: " + host_default) - print(" -p : Port number to use on MQTT broker") - print(" -r: Include ping drop run length stats") - print(" -s : Number of data samples to parse, default: " + str(samples_default)) - print(" -v: Be verbose") - print(" -C : Enable SSL/TLS using specified CA cert to verify broker") - print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") - print(" -P: Set password for username/password authentication") - print(" -S: Enable SSL/TLS using default CA cert") - print(" -U: Set username for authentication") - sys.exit(1 if arg_error else 0) - -logging.basicConfig(format="%(levelname)s: %(message)s") - -try: - dish_id = starlink_grpc.get_id() -except starlink_grpc.GrpcError as e: - logging.error("Failure getting dish ID: " + str(e)) - sys.exit(1) - -try: - g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) -except starlink_grpc.GrpcError as e: - logging.error("Failure getting ping stats: " + str(e)) - sys.exit(1) - -topic_prefix = "starlink/dish_ping_stats/" + dish_id + "/" -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()]) -if run_lengths: - for k, v in rl_stats.items(): - if k.startswith("run_"): - msgs.append((topic_prefix + k, ",".join(str(x) for x in v), 0, False)) + if not arg_error: + if len(args) > 0: + arg_error = True else: - msgs.append((topic_prefix + k, v, 0, False)) + for opt, arg in opts: + if opt == "-a": + samples = -1 + elif opt == "-h": + print_usage = True + elif opt == "-n": + mqargs["hostname"] = arg + elif opt == "-p": + mqargs["port"] = int(arg) + elif opt == "-r": + run_lengths = True + elif opt == "-s": + samples = int(arg) + elif opt == "-t": + loop_time = float(arg) + elif opt == "-v": + verbose = True + elif opt == "-C": + mqargs["tls"] = {"ca_certs": arg} + elif opt == "-I": + if ssl_ok: + mqargs["tls"] = {"cert_reqs": ssl.CERT_NONE} + else: + print("No SSL support found") + sys.exit(1) + elif opt == "-P": + password = arg + elif opt == "-S": + mqargs["tls"] = {} + elif opt == "-U": + username = arg -if username is not None: - mqargs["auth"] = {"username": username} - if password is not None: - mqargs["auth"]["password"] = password + if username is None and password is not None: + print("Password authentication requires username to be set") + arg_error = True -try: - paho.mqtt.publish.multiple(msgs, client_id=dish_id, **mqargs) -except Exception as e: - logging.error("Failed publishing to MQTT broker: " + str(e)) - sys.exit(1) + if print_usage or arg_error: + print("Usage: " + sys.argv[0] + " [options...]") + print("Options:") + print(" -a: Parse all valid samples") + print(" -h: Be helpful") + print(" -n : Hostname of MQTT broker, default: " + host_default) + print(" -p : Port number to use on MQTT broker") + print(" -r: Include ping drop run length stats") + print(" -s : Number of data samples to parse, default: loop interval,") + print(" if set, else " + str(samples_default)) + print(" -t : Loop interval in seconds or 0 for no loop, default: " + + str(default_loop_time)) + print(" -v: Be verbose") + print(" -C : Enable SSL/TLS using specified CA cert to verify broker") + print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") + print(" -P: Set password for username/password authentication") + print(" -S: Enable SSL/TLS using default CA cert") + print(" -U: Set username for authentication") + 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") + + 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: + gstate.dish_id = starlink_grpc.get_id() + if verbose: + print("Using dish ID: " + gstate.dish_id) + except starlink_grpc.GrpcError as e: + conn_error("Failure getting dish ID: " + str(e)) + return 1 + + try: + g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) + except starlink_grpc.GrpcError as e: + conn_error("Failure getting ping stats: " + str(e)) + return 1 + + topic_prefix = "starlink/dish_ping_stats/" + gstate.dish_id + "/" + 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()]) + if run_lengths: + for k, v in rl_stats.items(): + if k.startswith("run_"): + msgs.append((topic_prefix + k, ",".join(str(x) for x in v), 0, False)) + else: + msgs.append((topic_prefix + k, v, 0, False)) + + try: + paho.mqtt.publish.multiple(msgs, client_id=gstate.dish_id, **mqargs) + if verbose: + print("Successfully published to MQTT broker") + except Exception as e: + conn_error("Failed publishing to MQTT broker: " + str(e)) + 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() diff --git a/dishHistoryStats.py b/dishHistoryStats.py index 683f490..f08ac84 100644 --- a/dishHistoryStats.py +++ b/dishHistoryStats.py @@ -11,105 +11,141 @@ ###################################################################### import datetime -import sys import getopt import logging +import sys +import time import starlink_grpc -arg_error = False -try: - opts, args = getopt.getopt(sys.argv[1:], "ahrs:vH") -except getopt.GetoptError as err: - print(str(err)) - arg_error = True +def main(): + arg_error = False -# Default to 1 hour worth of data samples. -samples_default = 3600 -samples = samples_default -print_usage = False -verbose = False -print_header = False -run_lengths = False - -if not arg_error: - if len(args) > 0: + try: + opts, args = getopt.getopt(sys.argv[1:], "ahrs:t:vH") + except getopt.GetoptError as err: + print(str(err)) arg_error = True - else: - for opt, arg in opts: - if opt == "-a": - samples = -1 - elif opt == "-h": - print_usage = True - elif opt == "-r": - run_lengths = True - elif opt == "-s": - samples = int(arg) - elif opt == "-v": - verbose = True - elif opt == "-H": - print_header = True -if print_usage or arg_error: - print("Usage: " + sys.argv[0] + " [options...]") - print("Options:") - print(" -a: Parse all valid samples") - print(" -h: Be helpful") - print(" -r: Include ping drop run length stats") - print(" -s : Number of data samples to parse, default: " + str(samples_default)) - print(" -v: Be verbose") - print(" -H: print CSV header instead of parsing file") - sys.exit(1 if arg_error else 0) + # Default to 1 hour worth of data samples. + samples_default = 3600 + samples = None + print_usage = False + verbose = False + default_loop_time = 0 + loop_time = default_loop_time + run_lengths = False + print_header = False -logging.basicConfig(format="%(levelname)s: %(message)s") + if not arg_error: + if len(args) > 0: + arg_error = True + else: + for opt, arg in opts: + if opt == "-a": + samples = -1 + elif opt == "-h": + print_usage = True + elif opt == "-r": + run_lengths = True + elif opt == "-s": + samples = int(arg) + elif opt == "-t": + loop_time = float(arg) + elif opt == "-v": + verbose = True + elif opt == "-H": + print_header = True -g_fields, pd_fields, rl_fields = starlink_grpc.history_ping_field_names() + if print_usage or arg_error: + print("Usage: " + sys.argv[0] + " [options...]") + print("Options:") + print(" -a: Parse all valid samples") + print(" -h: Be helpful") + print(" -r: Include ping drop run length stats") + print(" -s : Number of data samples to parse, default: loop interval,") + print(" if set, else " + str(samples_default)) + print(" -t : Loop interval in seconds or 0 for no loop, default: " + + str(default_loop_time)) + print(" -v: Be verbose") + print(" -H: print CSV header instead of parsing history data") + sys.exit(1 if arg_error else 0) -if print_header: - header = ["datetimestamp_utc"] - header.extend(g_fields) - header.extend(pd_fields) - if run_lengths: - for field in rl_fields: - if field.startswith("run_"): - header.extend(field + "_" + str(x) for x in range(1, 61)) - else: - header.append(field) - print(",".join(header)) - sys.exit(0) + if samples is None: + samples = int(loop_time) if loop_time > 0 else samples_default -timestamp = datetime.datetime.utcnow() + logging.basicConfig(format="%(levelname)s: %(message)s") -try: - g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) -except starlink_grpc.GrpcError as e: - logging.error("Failure getting ping stats: " + str(e)) - sys.exit(1) + g_fields, pd_fields, rl_fields = starlink_grpc.history_ping_field_names() -if verbose: - print("Parsed samples: " + str(g_stats["samples"])) - print("Total ping drop: " + str(pd_stats["total_ping_drop"])) - print("Count of drop == 1: " + str(pd_stats["count_full_ping_drop"])) - print("Obstructed: " + str(pd_stats["count_obstructed"])) - print("Obstructed ping drop: " + str(pd_stats["total_obstructed_ping_drop"])) - print("Obstructed drop == 1: " + str(pd_stats["count_full_obstructed_ping_drop"])) - print("Unscheduled: " + str(pd_stats["count_unscheduled"])) - print("Unscheduled ping drop: " + str(pd_stats["total_unscheduled_ping_drop"])) - print("Unscheduled drop == 1: " + str(pd_stats["count_full_unscheduled_ping_drop"])) - if run_lengths: - print("Initial drop run fragment: " + str(rl_stats["init_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-minute drop runs: " + ", ".join(str(x) for x in rl_stats["run_minutes"])) -else: - csv_data = [timestamp.replace(microsecond=0).isoformat()] - csv_data.extend(str(g_stats[field]) for field in g_fields) - csv_data.extend(str(pd_stats[field]) for field in pd_fields) - if run_lengths: - for field in rl_fields: - if field.startswith("run_"): - csv_data.extend(str(substat) for substat in rl_stats[field]) - else: - csv_data.append(str(rl_stats[field])) - print(",".join(csv_data)) + if print_header: + header = ["datetimestamp_utc"] + header.extend(g_fields) + header.extend(pd_fields) + if run_lengths: + for field in rl_fields: + if field.startswith("run_"): + header.extend(field + "_" + str(x) for x in range(1, 61)) + else: + header.append(field) + print(",".join(header)) + sys.exit(0) + + def loop_body(): + timestamp = datetime.datetime.utcnow() + + try: + g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) + except starlink_grpc.GrpcError as e: + logging.error("Failure getting ping stats: " + str(e)) + return 1 + + if verbose: + print("Parsed samples: " + str(g_stats["samples"])) + print("Total ping drop: " + str(pd_stats["total_ping_drop"])) + print("Count of drop == 1: " + str(pd_stats["count_full_ping_drop"])) + print("Obstructed: " + str(pd_stats["count_obstructed"])) + print("Obstructed ping drop: " + str(pd_stats["total_obstructed_ping_drop"])) + print("Obstructed drop == 1: " + str(pd_stats["count_full_obstructed_ping_drop"])) + print("Unscheduled: " + str(pd_stats["count_unscheduled"])) + print("Unscheduled ping drop: " + str(pd_stats["total_unscheduled_ping_drop"])) + print("Unscheduled drop == 1: " + str(pd_stats["count_full_unscheduled_ping_drop"])) + if run_lengths: + print("Initial drop run fragment: " + str(rl_stats["init_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-minute drop runs: " + + ", ".join(str(x) for x in rl_stats["run_minutes"])) + if loop_time > 0: + print() + else: + csv_data = [timestamp.replace(microsecond=0).isoformat()] + csv_data.extend(str(g_stats[field]) for field in g_fields) + csv_data.extend(str(pd_stats[field]) for field in pd_fields) + if run_lengths: + for field in rl_fields: + if field.startswith("run_"): + csv_data.extend(str(substat) for substat in rl_stats[field]) + else: + csv_data.append(str(rl_stats[field])) + 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() diff --git a/dishStatusCsv.py b/dishStatusCsv.py index c8b7968..55443b5 100644 --- a/dishStatusCsv.py +++ b/dishStatusCsv.py @@ -1,111 +1,147 @@ #!/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 sys import getopt import logging +import sys +import time import grpc import spacex.api.device.device_pb2 import spacex.api.device.device_pb2_grpc -arg_error = False -try: - opts, args = getopt.getopt(sys.argv[1:], "hH") -except getopt.GetoptError as err: - print(str(err)) - arg_error = True +def main(): + arg_error = False -print_usage = False -print_header = False - -if not arg_error: - if len(args) > 0: + try: + opts, args = getopt.getopt(sys.argv[1:], "ht:H") + except getopt.GetoptError as err: + print(str(err)) arg_error = True - else: - for opt, arg in opts: - if opt == "-h": - print_usage = True - elif opt == "-H": - print_header = True -if print_usage or arg_error: - print("Usage: " + sys.argv[0] + " [options...]") - print("Options:") - print(" -h: Be helpful") - print(" -H: print CSV header instead of parsing file") - sys.exit(1 if arg_error else 0) + print_usage = False + default_loop_time = 0 + loop_time = default_loop_time + print_header = False -logging.basicConfig(format="%(levelname)s: %(message)s") + if not arg_error: + if len(args) > 0: + arg_error = True + else: + for opt, arg in opts: + if opt == "-h": + print_usage = True + elif opt == "-t": + loop_time = float(arg) + elif opt == "-H": + print_header = True -if print_header: - header = [ - "datetimestamp_utc", - "hardware_version", - "software_version", - "state", - "uptime", - "snr", - "seconds_to_first_nonempty_slot", - "pop_ping_drop_rate", - "downlink_throughput_bps", - "uplink_throughput_bps", - "pop_ping_latency_ms", - "alerts", - "fraction_obstructed", - "currently_obstructed", - "seconds_obstructed" - ] - header.extend("wedges_fraction_obstructed_" + str(x) for x in range(12)) - print(",".join(header)) - sys.exit(0) + if print_usage or arg_error: + print("Usage: " + sys.argv[0] + " [options...]") + print("Options:") + print(" -h: Be helpful") + print(" -t : Loop interval in seconds or 0 for no loop, default: " + + str(default_loop_time)) + print(" -H: print CSV header instead of parsing file") + sys.exit(1 if arg_error else 0) -try: - with grpc.insecure_channel("192.168.100.1:9200") as channel: - stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) - response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) -except grpc.RpcError: - logging.error("Failed getting status info") - sys.exit(1) + logging.basicConfig(format="%(levelname)s: %(message)s") -timestamp = datetime.datetime.utcnow() + if print_header: + header = [ + "datetimestamp_utc", + "hardware_version", + "software_version", + "state", + "uptime", + "snr", + "seconds_to_first_nonempty_slot", + "pop_ping_drop_rate", + "downlink_throughput_bps", + "uplink_throughput_bps", + "pop_ping_latency_ms", + "alerts", + "fraction_obstructed", + "currently_obstructed", + "seconds_obstructed", + ] + header.extend("wedges_fraction_obstructed_" + str(x) for x in range(12)) + print(",".join(header)) + sys.exit(0) -status = response.dish_get_status + def loop_body(): + timestamp = datetime.datetime.utcnow() -# More alerts may be added in future, so rather than list them individually, -# build a bit field based on field numbers of the DishAlerts message. -alert_bits = 0 -for alert in status.alerts.ListFields(): - alert_bits |= (1 if alert[1] else 0) << (alert[0].number - 1) + try: + with grpc.insecure_channel("192.168.100.1:9200") as channel: + stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) + response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) -csv_data = [ - timestamp.replace(microsecond=0).isoformat(), - status.device_info.id, - status.device_info.hardware_version, - status.device_info.software_version, - spacex.api.device.dish_pb2.DishState.Name(status.state) -] -csv_data.extend(str(x) for x in [ - status.device_state.uptime_s, - status.snr, - status.seconds_to_first_nonempty_slot, - status.pop_ping_drop_rate, - status.downlink_throughput_bps, - status.uplink_throughput_bps, - status.pop_ping_latency_ms, - alert_bits, - status.obstruction_stats.fraction_obstructed, - status.obstruction_stats.currently_obstructed, - status.obstruction_stats.last_24h_obstructed_s -]) -csv_data.extend(str(x) for x in status.obstruction_stats.wedge_abs_fraction_obstructed) -print(",".join(csv_data)) + status = response.dish_get_status + + # More alerts may be added in future, so rather than list them individually, + # build a bit field based on field numbers of the DishAlerts message. + alert_bits = 0 + for alert in status.alerts.ListFields(): + alert_bits |= (1 if alert[1] else 0) << (alert[0].number - 1) + + csv_data = [ + timestamp.replace(microsecond=0).isoformat(), + status.device_info.id, + status.device_info.hardware_version, + status.device_info.software_version, + spacex.api.device.dish_pb2.DishState.Name(status.state), + ] + csv_data.extend( + str(x) for x in [ + status.device_state.uptime_s, + status.snr, + status.seconds_to_first_nonempty_slot, + status.pop_ping_drop_rate, + status.downlink_throughput_bps, + status.uplink_throughput_bps, + status.pop_ping_latency_ms, + alert_bits, + status.obstruction_stats.fraction_obstructed, + status.obstruction_stats.currently_obstructed, + status.obstruction_stats.last_24h_obstructed_s, + ]) + 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)) + + 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() diff --git a/dishStatusInflux.py b/dishStatusInflux.py index 98c5d01..0c00b41 100644 --- a/dishStatusInflux.py +++ b/dishStatusInflux.py @@ -8,249 +8,263 @@ # ###################################################################### -import time -import os -import sys import getopt import logging +import os +import sys +import time import warnings +import grpc from influxdb import InfluxDBClient from influxdb import SeriesHelper -import grpc - import spacex.api.device.device_pb2 import spacex.api.device.device_pb2_grpc -arg_error = False -try: - opts, args = getopt.getopt(sys.argv[1:], "hn:p:t:vC:D:IP:R:SU:") -except getopt.GetoptError as err: - print(str(err)) - arg_error = True +def main(): + arg_error = False -print_usage = False -verbose = False -host_default = "localhost" -database_default = "starlinkstats" -icargs = {"host": host_default, "timeout": 5, "database": database_default} -rp = None -default_sleep_time = 30 -sleep_time = default_sleep_time - -# For each of these check they are both set and not empty string -influxdb_host = os.environ.get("INFLUXDB_HOST") -if influxdb_host: - icargs["host"] = influxdb_host -influxdb_port = os.environ.get("INFLUXDB_PORT") -if influxdb_port: - icargs["port"] = int(influxdb_port) -influxdb_user = os.environ.get("INFLUXDB_USER") -if influxdb_user: - icargs["username"] = influxdb_user -influxdb_pwd = os.environ.get("INFLUXDB_PWD") -if influxdb_pwd: - icargs["password"] = influxdb_pwd -influxdb_db = os.environ.get("INFLUXDB_DB") -if influxdb_db: - icargs["database"] = influxdb_db -influxdb_rp = os.environ.get("INFLUXDB_RP") -if influxdb_rp: - rp = influxdb_rp -influxdb_ssl = os.environ.get("INFLUXDB_SSL") -if influxdb_ssl: - icargs["ssl"] = True - if influxdb_ssl.lower() == "secure": - icargs["verify_ssl"] = True - elif influxdb_ssl.lower() == "insecure": - icargs["verify_ssl"] = False - else: - icargs["verify_ssl"] = influxdb_ssl - -if not arg_error: - if len(args) > 0: + try: + opts, args = getopt.getopt(sys.argv[1:], "hn:p:t:vC:D:IP:R:SU:") + except getopt.GetoptError as err: + print(str(err)) arg_error = True - else: - for opt, arg in opts: - if opt == "-h": - print_usage = True - elif opt == "-n": - icargs["host"] = arg - elif opt == "-p": - icargs["port"] = int(arg) - elif opt == "-t": - sleep_time = int(arg) - elif opt == "-v": - verbose = True - elif opt == "-C": - icargs["ssl"] = True - icargs["verify_ssl"] = arg - elif opt == "-D": - icargs["database"] = arg - elif opt == "-I": - icargs["ssl"] = True - icargs["verify_ssl"] = False - elif opt == "-P": - icargs["password"] = arg - elif opt == "-R": - rp = arg - elif opt == "-S": - icargs["ssl"] = True - icargs["verify_ssl"] = True - elif opt == "-U": - icargs["username"] = arg -if "password" in icargs and "username" not in icargs: - print("Password authentication requires username to be set") - arg_error = True + print_usage = False + verbose = False + default_loop_time = 0 + loop_time = default_loop_time + host_default = "localhost" + database_default = "starlinkstats" + icargs = {"host": host_default, "timeout": 5, "database": database_default} + rp = None + flush_limit = 6 -if print_usage or arg_error: - print("Usage: " + sys.argv[0] + " [options...]") - print("Options:") - print(" -h: Be helpful") - print(" -n : Hostname of InfluxDB server, default: " + host_default) - print(" -p : Port number to use on InfluxDB server") - print(" -t : Loop interval in seconds or 0 for no loop, default: " + - str(default_sleep_time)) - print(" -v: Be verbose") - print(" -C : Enable SSL/TLS using specified CA cert to verify server") - print(" -D : Database name to use, default: " + database_default) - print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") - print(" -P : Set password for authentication") - print(" -R : Retention policy name to use") - print(" -S: Enable SSL/TLS using default CA cert") - print(" -U : Set username for authentication") - sys.exit(1 if arg_error else 0) + # For each of these check they are both set and not empty string + influxdb_host = os.environ.get("INFLUXDB_HOST") + if influxdb_host: + icargs["host"] = influxdb_host + influxdb_port = os.environ.get("INFLUXDB_PORT") + if influxdb_port: + icargs["port"] = int(influxdb_port) + influxdb_user = os.environ.get("INFLUXDB_USER") + if influxdb_user: + icargs["username"] = influxdb_user + influxdb_pwd = os.environ.get("INFLUXDB_PWD") + if influxdb_pwd: + icargs["password"] = influxdb_pwd + influxdb_db = os.environ.get("INFLUXDB_DB") + if influxdb_db: + icargs["database"] = influxdb_db + influxdb_rp = os.environ.get("INFLUXDB_RP") + if influxdb_rp: + rp = influxdb_rp + influxdb_ssl = os.environ.get("INFLUXDB_SSL") + if influxdb_ssl: + icargs["ssl"] = True + if influxdb_ssl.lower() == "secure": + icargs["verify_ssl"] = True + elif influxdb_ssl.lower() == "insecure": + icargs["verify_ssl"] = False + else: + icargs["verify_ssl"] = influxdb_ssl -logging.basicConfig(format="%(levelname)s: %(message)s") + if not arg_error: + if len(args) > 0: + arg_error = True + else: + for opt, arg in opts: + if opt == "-h": + print_usage = True + elif opt == "-n": + icargs["host"] = arg + elif opt == "-p": + icargs["port"] = int(arg) + elif opt == "-t": + loop_time = int(arg) + elif opt == "-v": + verbose = True + elif opt == "-C": + icargs["ssl"] = True + icargs["verify_ssl"] = arg + elif opt == "-D": + icargs["database"] = arg + elif opt == "-I": + icargs["ssl"] = True + icargs["verify_ssl"] = False + elif opt == "-P": + icargs["password"] = arg + elif opt == "-R": + rp = arg + elif opt == "-S": + icargs["ssl"] = True + icargs["verify_ssl"] = True + elif opt == "-U": + icargs["username"] = arg -def conn_error(msg): - # Connection errors that happen while running in an interval loop are - # not critical failures, because they can (usually) be retried, or - # because they will be recorded as dish state unavailable. They're still - # interesting, though, so print them even in non-verbose mode. - if sleep_time > 0: - print(msg) - else: - logging.error(msg) + if "password" in icargs and "username" not in icargs: + print("Password authentication requires username to be set") + arg_error = True -class DeviceStatusSeries(SeriesHelper): - class Meta: - series_name = "spacex.starlink.user_terminal.status" - fields = [ - "hardware_version", - "software_version", - "state", - "alert_motors_stuck", - "alert_thermal_throttle", - "alert_thermal_shutdown", - "alert_unexpected_location", - "snr", - "seconds_to_first_nonempty_slot", - "pop_ping_drop_rate", - "downlink_throughput_bps", - "uplink_throughput_bps", - "pop_ping_latency_ms", - "currently_obstructed", - "fraction_obstructed"] - tags = ["id"] - retention_policy = rp + if print_usage or arg_error: + print("Usage: " + sys.argv[0] + " [options...]") + print("Options:") + print(" -h: Be helpful") + print(" -n : Hostname of InfluxDB server, default: " + host_default) + print(" -p : Port number to use on InfluxDB server") + print(" -t : Loop interval in seconds or 0 for no loop, default: " + + str(default_loop_time)) + print(" -v: Be verbose") + print(" -C : Enable SSL/TLS using specified CA cert to verify server") + print(" -D : Database name to use, default: " + database_default) + print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") + print(" -P : Set password for authentication") + print(" -R : Retention policy name to use") + print(" -S: Enable SSL/TLS using default CA cert") + print(" -U : Set username for authentication") + sys.exit(1 if arg_error else 0) -if "verify_ssl" in icargs and not icargs["verify_ssl"]: - # user has explicitly said be insecure, so don't warn about it - warnings.filterwarnings("ignore", message="Unverified HTTPS request") + logging.basicConfig(format="%(levelname)s: %(message)s") -influx_client = InfluxDBClient(**icargs) + class GlobalState: + pass -rc = 0 -try: - dish_channel = None - last_id = None - last_failed = False + gstate = GlobalState() + gstate.dish_channel = None + gstate.dish_id = None + gstate.pending = 0 - pending = 0 - count = 0 - while True: + class DeviceStatusSeries(SeriesHelper): + class Meta: + series_name = "spacex.starlink.user_terminal.status" + fields = [ + "hardware_version", + "software_version", + "state", + "alert_motors_stuck", + "alert_thermal_throttle", + "alert_thermal_shutdown", + "alert_unexpected_location", + "snr", + "seconds_to_first_nonempty_slot", + "pop_ping_drop_rate", + "downlink_throughput_bps", + "uplink_throughput_bps", + "pop_ping_latency_ms", + "currently_obstructed", + "fraction_obstructed", + ] + tags = ["id"] + retention_policy = rp + + 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_pending(client): try: - if dish_channel is None: - dish_channel = grpc.insecure_channel("192.168.100.1:9200") - stub = spacex.api.device.device_pb2_grpc.DeviceStub(dish_channel) - response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) - status = response.dish_get_status - DeviceStatusSeries( - id=status.device_info.id, - hardware_version=status.device_info.hardware_version, - software_version=status.device_info.software_version, - state=spacex.api.device.dish_pb2.DishState.Name(status.state), - alert_motors_stuck=status.alerts.motors_stuck, - alert_thermal_throttle=status.alerts.thermal_throttle, - alert_thermal_shutdown=status.alerts.thermal_shutdown, - alert_unexpected_location=status.alerts.unexpected_location, - snr=status.snr, - seconds_to_first_nonempty_slot=status.seconds_to_first_nonempty_slot, - pop_ping_drop_rate=status.pop_ping_drop_rate, - downlink_throughput_bps=status.downlink_throughput_bps, - uplink_throughput_bps=status.uplink_throughput_bps, - pop_ping_latency_ms=status.pop_ping_latency_ms, - currently_obstructed=status.obstruction_stats.currently_obstructed, - fraction_obstructed=status.obstruction_stats.fraction_obstructed) - pending += 1 - last_id = status.device_info.id - last_failed = False - except grpc.RpcError: - if dish_channel is not 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") - # When not looping, report this as failure exit status - rc = 1 - else: + DeviceStatusSeries.commit(client) + if verbose: + 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 + + return 0 + + def get_status_retry(): + """Try getting the status at most twice""" + + channel_reused = True + while True: + try: + if gstate.dish_channel is None: + gstate.dish_channel = grpc.insecure_channel("192.168.100.1:9200") + 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={})) + return response.dish_get_status + except grpc.RpcError: + 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 unreachable") - DeviceStatusSeries(id=last_id, state="DISH_UNREACHABLE") - pending += 1 + 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, + software_version=status.device_info.software_version, + state=spacex.api.device.dish_pb2.DishState.Name(status.state), + alert_motors_stuck=status.alerts.motors_stuck, + alert_thermal_throttle=status.alerts.thermal_throttle, + alert_thermal_shutdown=status.alerts.thermal_shutdown, + alert_unexpected_location=status.alerts.unexpected_location, + snr=status.snr, + seconds_to_first_nonempty_slot=status.seconds_to_first_nonempty_slot, + pop_ping_drop_rate=status.pop_ping_drop_rate, + downlink_throughput_bps=status.downlink_throughput_bps, + uplink_throughput_bps=status.uplink_throughput_bps, + pop_ping_latency_ms=status.pop_ping_latency_ms, + currently_obstructed=status.obstruction_stats.currently_obstructed, + fraction_obstructed=status.obstruction_stats.fraction_obstructed) + gstate.dish_id = status.device_info.id + except grpc.RpcError: + if gstate.dish_id is None: + conn_error("Dish unreachable and ID unknown, so not recording state") + return 1 else: if verbose: - print("Dish RPC channel error") - # Retry once, because the connection may have been lost while - # we were sleeping - last_failed = True - continue + print("Dish unreachable") + DeviceStatusSeries(id=gstate.dish_id, state="DISH_UNREACHABLE") + + gstate.pending += 1 if verbose: - print("Samples queued: " + str(pending)) - count += 1 - if count > 5: - try: - if pending: - DeviceStatusSeries.commit(influx_client) - rc = 0 - if verbose: - print("Samples written: " + str(pending)) - pending = 0 - except Exception as e: - conn_error("Failed to write: " + str(e)) - rc = 1 - count = 0 - if sleep_time > 0: - time.sleep(sleep_time) - else: - break -finally: - # Flush on error/exit + print("Data points queued: " + str(gstate.pending)) + if gstate.pending >= flush_limit: + return flush_pending(client) + + return 0 + + if "verify_ssl" in icargs and not icargs["verify_ssl"]: + # user has explicitly said be insecure, so don't warn about it + warnings.filterwarnings("ignore", message="Unverified HTTPS request") + + influx_client = InfluxDBClient(**icargs) try: - if pending: - 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() - if dish_channel is not None: - dish_channel.close() + next_loop = time.monotonic() + while True: + rc = loop_body(influx_client) + if loop_time > 0: + now = time.monotonic() + next_loop = max(next_loop + loop_time, now) + time.sleep(next_loop - now) + else: + break + finally: + # Flush on error/exit + if gstate.pending: + rc = flush_pending(influx_client) + influx_client.close() + if gstate.dish_channel is not None: + gstate.dish_channel.close() + sys.exit(rc) + + +if __name__ == '__main__': + main() diff --git a/dishStatusMqtt.py b/dishStatusMqtt.py index e91763f..ce84ab6 100644 --- a/dishStatusMqtt.py +++ b/dishStatusMqtt.py @@ -3,14 +3,15 @@ # # Publish Starlink user terminal status info to a MQTT broker. # -# This script pulls the current status once and publishes it to the -# specified MQTT broker. +# This script pulls the current status and publishes it to the +# specified MQTT broker either once or in a periodic loop. # ###################################################################### -import sys import getopt import logging +import sys +import time try: import ssl @@ -18,116 +19,170 @@ try: except ImportError: ssl_ok = False -import paho.mqtt.publish - import grpc +import paho.mqtt.publish import spacex.api.device.device_pb2 import spacex.api.device.device_pb2_grpc -arg_error = False -try: - opts, args = getopt.getopt(sys.argv[1:], "hn:p:C:ISP:U:") -except getopt.GetoptError as err: - print(str(err)) - arg_error = True +def main(): + arg_error = False -print_usage = False -host_default = "localhost" -mqargs = {"hostname": host_default} -username = None -password = None - -if not arg_error: - if len(args) > 0: + try: + opts, args = getopt.getopt(sys.argv[1:], "hn:p:t:vC:ISP:U:") + except getopt.GetoptError as err: + print(str(err)) arg_error = True - else: - for opt, arg in opts: - if opt == "-h": - print_usage = True - elif opt == "-n": - mqargs["hostname"] = arg - elif opt == "-p": - mqargs["port"] = int(arg) - elif opt == "-C": - mqargs["tls"] = {"ca_certs": arg} - elif opt == "-I": - if ssl_ok: - mqargs["tls"] = {"cert_reqs": ssl.CERT_NONE} - else: - print("No SSL support found") - sys.exit(1) - elif opt == "-P": - password = arg - elif opt == "-S": - mqargs["tls"] = {} - elif opt == "-U": - username = arg -if username is None and password is not None: - print("Password authentication requires username to be set") - arg_error = True + print_usage = False + verbose = False + default_loop_time = 0 + loop_time = default_loop_time + host_default = "localhost" + mqargs = {"hostname": host_default} + username = None + password = None -if print_usage or arg_error: - print("Usage: " + sys.argv[0] + " [options...]") - print("Options:") - print(" -h: Be helpful") - print(" -n : Hostname of MQTT broker, default: " + host_default) - print(" -p : Port number to use on MQTT broker") - print(" -C : Enable SSL/TLS using specified CA cert to verify broker") - print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") - print(" -P: Set password for username/password authentication") - print(" -S: Enable SSL/TLS using default CA cert") - print(" -U: Set username for authentication") - sys.exit(1 if arg_error else 0) + if not arg_error: + if len(args) > 0: + arg_error = True + else: + for opt, arg in opts: + if opt == "-h": + print_usage = True + elif opt == "-n": + mqargs["hostname"] = arg + elif opt == "-p": + mqargs["port"] = int(arg) + elif opt == "-t": + loop_time = float(arg) + elif opt == "-v": + verbose = True + elif opt == "-C": + mqargs["tls"] = {"ca_certs": arg} + elif opt == "-I": + if ssl_ok: + mqargs["tls"] = {"cert_reqs": ssl.CERT_NONE} + else: + print("No SSL support found") + sys.exit(1) + elif opt == "-P": + password = arg + elif opt == "-S": + mqargs["tls"] = {} + elif opt == "-U": + username = arg -logging.basicConfig(format="%(levelname)s: %(message)s") + if username is None and password is not None: + print("Password authentication requires username to be set") + arg_error = True -try: - with grpc.insecure_channel("192.168.100.1:9200") as channel: - stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) - response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) -except grpc.RpcError: - logging.error("Failed getting status info") - sys.exit(1) + if print_usage or arg_error: + print("Usage: " + sys.argv[0] + " [options...]") + print("Options:") + print(" -h: Be helpful") + print(" -n : Hostname of MQTT broker, default: " + host_default) + print(" -p : Port number to use on MQTT broker") + print(" -t : Loop interval in seconds or 0 for no loop, default: " + + str(default_loop_time)) + print(" -v: Be verbose") + print(" -C : Enable SSL/TLS using specified CA cert to verify broker") + print(" -I: Enable SSL/TLS but disable certificate verification (INSECURE!)") + print(" -P: Set password for username/password authentication") + print(" -S: Enable SSL/TLS using default CA cert") + print(" -U: Set username for authentication") + sys.exit(1 if arg_error else 0) -status = response.dish_get_status + if username is not None: + mqargs["auth"] = {"username": username} + if password is not None: + mqargs["auth"]["password"] = password -# More alerts may be added in future, so rather than list them individually, -# build a bit field based on field numbers of the DishAlerts message. -alert_bits = 0 -for alert in status.alerts.ListFields(): - alert_bits |= (1 if alert[1] else 0) << (alert[0].number - 1) + logging.basicConfig(format="%(levelname)s: %(message)s") -topic_prefix = "starlink/dish_status/" + status.device_info.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 + "state", spacex.api.device.dish_pb2.DishState.Name(status.state), 0, False), - (topic_prefix + "uptime", status.device_state.uptime_s, 0, False), - (topic_prefix + "snr", status.snr, 0, False), - (topic_prefix + "seconds_to_first_nonempty_slot", status.seconds_to_first_nonempty_slot, 0, False), - (topic_prefix + "pop_ping_drop_rate", status.pop_ping_drop_rate, 0, False), - (topic_prefix + "downlink_throughput_bps", status.downlink_throughput_bps, 0, False), - (topic_prefix + "uplink_throughput_bps", status.uplink_throughput_bps, 0, False), - (topic_prefix + "pop_ping_latency_ms", status.pop_ping_latency_ms, 0, False), - (topic_prefix + "alerts", alert_bits, 0, False), - (topic_prefix + "fraction_obstructed", status.obstruction_stats.fraction_obstructed, 0, False), - (topic_prefix + "currently_obstructed", status.obstruction_stats.currently_obstructed, 0, False), - # While the field name for this one implies it covers 24 hours, the - # empirical evidence suggests it only covers 12 hours. It also resets - # 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: - (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)] + class GlobalState: + pass -if username is not None: - mqargs["auth"] = {"username": username} - if password is not None: - mqargs["auth"]["password"] = password + gstate = GlobalState() + gstate.dish_id = None -try: - paho.mqtt.publish.multiple(msgs, client_id=status.device_info.id, **mqargs) -except Exception as e: - logging.error("Failed publishing to MQTT broker: " + str(e)) - sys.exit(1) + 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: + with grpc.insecure_channel("192.168.100.1:9200") as channel: + stub = spacex.api.device.device_pb2_grpc.DeviceStub(channel) + response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) + + status = response.dish_get_status + + # More alerts may be added in future, so rather than list them individually, + # build a bit field based on field numbers of the DishAlerts message. + alert_bits = 0 + for alert in status.alerts.ListFields(): + alert_bits |= (1 if alert[1] else 0) << (alert[0].number - 1) + + gstate.dish_id = status.device_info.id + 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 + "state", spacex.api.device.dish_pb2.DishState.Name(status.state), 0, False), + (topic_prefix + "uptime", status.device_state.uptime_s, 0, False), + (topic_prefix + "snr", status.snr, 0, False), + (topic_prefix + "seconds_to_first_nonempty_slot", status.seconds_to_first_nonempty_slot, 0, False), + (topic_prefix + "pop_ping_drop_rate", status.pop_ping_drop_rate, 0, False), + (topic_prefix + "downlink_throughput_bps", status.downlink_throughput_bps, 0, False), + (topic_prefix + "uplink_throughput_bps", status.uplink_throughput_bps, 0, False), + (topic_prefix + "pop_ping_latency_ms", status.pop_ping_latency_ms, 0, False), + (topic_prefix + "alerts", alert_bits, 0, False), + (topic_prefix + "fraction_obstructed", status.obstruction_stats.fraction_obstructed, 0, False), + (topic_prefix + "currently_obstructed", status.obstruction_stats.currently_obstructed, 0, False), + # While the field name for this one implies it covers 24 hours, the + # empirical evidence suggests it only covers 12 hours. It also resets + # 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: + (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), + ] + except grpc.RpcError: + if gstate.dish_id is None: + conn_error("Dish unreachable and ID unknown, so not recording state") + return 1 + if verbose: + print("Dish unreachable") + topic_prefix = "starlink/dish_status/" + gstate.dish_id + "/" + msgs = [(topic_prefix + "state", "DISH_UNREACHABLE", 0, False)] + + try: + paho.mqtt.publish.multiple(msgs, client_id=gstate.dish_id, **mqargs) + if verbose: + print("Successfully published to MQTT broker") + except Exception as e: + conn_error("Failed publishing to MQTT broker: " + str(e)) + 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() From 3fafcea8829bb0fb584f52771f1bc6cdbad5a118 Mon Sep 17 00:00:00 2001 From: sparky8512 <76499194+sparky8512@users.noreply.github.com> Date: Fri, 15 Jan 2021 19:27:10 -0800 Subject: [PATCH 23/23] Fix remaining pylint and yapf nits --- dishHistoryInflux.py | 12 ++++++------ dishHistoryMqtt.py | 12 ++++++------ dishHistoryStats.py | 2 +- dishStatusInflux.py | 8 ++++---- dishStatusMqtt.py | 8 ++++---- parseJsonHistory.py | 2 +- starlink_grpc.py | 18 +++++++++++------- starlink_json.py | 16 +++++++++------- 8 files changed, 42 insertions(+), 36 deletions(-) diff --git a/dishHistoryInflux.py b/dishHistoryInflux.py index b1a9b3e..07e43f7 100644 --- a/dishHistoryInflux.py +++ b/dishHistoryInflux.py @@ -152,13 +152,13 @@ def main(): gstate.dish_id = None gstate.points = [] - def conn_error(msg): + def conn_error(msg, *args): # 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) + print(msg % args) else: - logging.error(msg) + logging.error(msg, *args) def flush_points(client): try: @@ -167,7 +167,7 @@ def main(): print("Data points written: " + str(len(gstate.points))) gstate.points.clear() except Exception as e: - conn_error("Failed writing to InfluxDB database: " + str(e)) + conn_error("Failed writing to InfluxDB database: %s", str(e)) return 1 return 0 @@ -179,7 +179,7 @@ def main(): if verbose: print("Using dish ID: " + gstate.dish_id) except starlink_grpc.GrpcError as e: - conn_error("Failure getting dish ID: " + str(e)) + conn_error("Failure getting dish ID: %s", str(e)) return 1 timestamp = datetime.datetime.utcnow() @@ -187,7 +187,7 @@ def main(): try: g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) except starlink_grpc.GrpcError as e: - conn_error("Failure getting ping stats: " + str(e)) + conn_error("Failure getting ping stats: %s", str(e)) return 1 all_stats = g_stats.copy() diff --git a/dishHistoryMqtt.py b/dishHistoryMqtt.py index 1e7b855..a4349d4 100644 --- a/dishHistoryMqtt.py +++ b/dishHistoryMqtt.py @@ -124,13 +124,13 @@ def main(): gstate = GlobalState() gstate.dish_id = None - def conn_error(msg): + def conn_error(msg, *args): # 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) + print(msg % args) else: - logging.error(msg) + logging.error(msg, *args) def loop_body(): if gstate.dish_id is None: @@ -139,13 +139,13 @@ def main(): if verbose: print("Using dish ID: " + gstate.dish_id) except starlink_grpc.GrpcError as e: - conn_error("Failure getting dish ID: " + str(e)) + conn_error("Failure getting dish ID: %s", str(e)) return 1 try: g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) except starlink_grpc.GrpcError as e: - conn_error("Failure getting ping stats: " + str(e)) + conn_error("Failure getting ping stats: %s", str(e)) return 1 topic_prefix = "starlink/dish_ping_stats/" + gstate.dish_id + "/" @@ -163,7 +163,7 @@ def main(): if verbose: print("Successfully published to MQTT broker") except Exception as e: - conn_error("Failed publishing to MQTT broker: " + str(e)) + conn_error("Failed publishing to MQTT broker: %s", str(e)) return 1 return 0 diff --git a/dishHistoryStats.py b/dishHistoryStats.py index f08ac84..45a4ee1 100644 --- a/dishHistoryStats.py +++ b/dishHistoryStats.py @@ -98,7 +98,7 @@ def main(): try: g_stats, pd_stats, rl_stats = starlink_grpc.history_ping_stats(samples, verbose) except starlink_grpc.GrpcError as e: - logging.error("Failure getting ping stats: " + str(e)) + logging.error("Failure getting ping stats: %s", str(e)) return 1 if verbose: diff --git a/dishStatusInflux.py b/dishStatusInflux.py index 0c00b41..6a708f3 100644 --- a/dishStatusInflux.py +++ b/dishStatusInflux.py @@ -159,13 +159,13 @@ def main(): tags = ["id"] retention_policy = rp - def conn_error(msg): + def conn_error(msg, *args): # 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) + print(msg % args) else: - logging.error(msg) + logging.error(msg, *args) def flush_pending(client): try: @@ -174,7 +174,7 @@ def main(): print("Data points written: " + str(gstate.pending)) gstate.pending = 0 except Exception as e: - conn_error("Failed writing to InfluxDB database: " + str(e)) + conn_error("Failed writing to InfluxDB database: %s", str(e)) return 1 return 0 diff --git a/dishStatusMqtt.py b/dishStatusMqtt.py index ce84ab6..06a1324 100644 --- a/dishStatusMqtt.py +++ b/dishStatusMqtt.py @@ -107,13 +107,13 @@ def main(): gstate = GlobalState() gstate.dish_id = None - def conn_error(msg): + def conn_error(msg, *args): # 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) + print(msg % args) else: - logging.error(msg) + logging.error(msg, *args) def loop_body(): try: @@ -166,7 +166,7 @@ def main(): if verbose: print("Successfully published to MQTT broker") except Exception as e: - conn_error("Failed publishing to MQTT broker: " + str(e)) + conn_error("Failed publishing to MQTT broker: %s", str(e)) return 1 return 0 diff --git a/parseJsonHistory.py b/parseJsonHistory.py index 50fe1ff..e12d676 100644 --- a/parseJsonHistory.py +++ b/parseJsonHistory.py @@ -89,7 +89,7 @@ try: g_stats, pd_stats, rl_stats = starlink_json.history_ping_stats(args[0] if args else "-", samples, verbose) except starlink_json.JsonError as e: - logging.error("Failure getting ping stats: " + str(e)) + logging.error("Failure getting ping stats: %s", str(e)) sys.exit(1) if verbose: diff --git a/starlink_grpc.py b/starlink_grpc.py index ec65b14..40e3572 100644 --- a/starlink_grpc.py +++ b/starlink_grpc.py @@ -108,6 +108,7 @@ def get_status(): response = stub.Handle(spacex.api.device.device_pb2.Request(get_status={})) return response.dish_get_status + def get_id(): """Return the ID from the dish status information. @@ -124,6 +125,7 @@ def get_id(): except grpc.RpcError as e: raise GrpcError(e) + def history_ping_field_names(): """Return the field names of the packet loss stats. @@ -133,7 +135,7 @@ def history_ping_field_names(): stat names. """ return [ - "samples" + "samples", ], [ "total_ping_drop", "count_full_ping_drop", @@ -142,14 +144,15 @@ def history_ping_field_names(): "count_full_obstructed_ping_drop", "count_unscheduled", "total_unscheduled_ping_drop", - "count_full_unscheduled_ping_drop" + "count_full_unscheduled_ping_drop", ], [ "init_run_fragment", "final_run_fragment", "run_seconds", - "run_minutes" + "run_minutes", ] + def get_history(): """Fetch history data and return it in grpc structure format. @@ -161,6 +164,7 @@ def get_history(): response = stub.Handle(spacex.api.device.device_pb2.Request(get_history={})) return response.dish_get_history + def history_ping_stats(parse_samples, verbose=False): """Fetch, parse, and compute the packet loss stats. @@ -239,7 +243,7 @@ def history_ping_stats(parse_samples, verbose=False): if run_length <= 60: second_runs[run_length - 1] += run_length else: - minute_runs[min((run_length - 1)//60 - 1, 59)] += run_length + minute_runs[min((run_length-1) // 60 - 1, 59)] += run_length run_length = 0 elif init_run_length is None: init_run_length = 0 @@ -267,7 +271,7 @@ def history_ping_stats(parse_samples, verbose=False): run_length = 0 return { - "samples": parse_samples + "samples": parse_samples, }, { "total_ping_drop": tot, "count_full_ping_drop": count_full_drop, @@ -276,10 +280,10 @@ def history_ping_stats(parse_samples, verbose=False): "count_full_obstructed_ping_drop": count_full_obstruct, "count_unscheduled": count_unsched, "total_unscheduled_ping_drop": total_unsched_drop, - "count_full_unscheduled_ping_drop": count_full_unsched + "count_full_unscheduled_ping_drop": count_full_unsched, }, { "init_run_fragment": init_run_length, "final_run_fragment": run_length, "run_seconds": second_runs, - "run_minutes": minute_runs + "run_minutes": minute_runs, } diff --git a/starlink_json.py b/starlink_json.py index 7396c5a..7365430 100644 --- a/starlink_json.py +++ b/starlink_json.py @@ -28,7 +28,7 @@ def history_ping_field_names(): stat names. """ return [ - "samples" + "samples", ], [ "total_ping_drop", "count_full_ping_drop", @@ -37,14 +37,15 @@ def history_ping_field_names(): "count_full_obstructed_ping_drop", "count_unscheduled", "total_unscheduled_ping_drop", - "count_full_unscheduled_ping_drop" + "count_full_unscheduled_ping_drop", ], [ "init_run_fragment", "final_run_fragment", "run_seconds", - "run_minutes" + "run_minutes", ] + def get_history(filename): """Read JSON data and return the raw history in dict format. @@ -63,6 +64,7 @@ def get_history(filename): json_data = json.load(json_file) return json_data["dishGetHistory"] + def history_ping_stats(filename, parse_samples, verbose=False): """Fetch, parse, and compute the packet loss stats. @@ -144,7 +146,7 @@ def history_ping_stats(filename, parse_samples, verbose=False): if run_length <= 60: second_runs[run_length - 1] += run_length else: - minute_runs[min((run_length - 1)//60 - 1, 59)] += run_length + minute_runs[min((run_length-1) // 60 - 1, 59)] += run_length run_length = 0 elif init_run_length is None: init_run_length = 0 @@ -172,7 +174,7 @@ def history_ping_stats(filename, parse_samples, verbose=False): run_length = 0 return { - "samples": parse_samples + "samples": parse_samples, }, { "total_ping_drop": tot, "count_full_ping_drop": count_full_drop, @@ -181,10 +183,10 @@ def history_ping_stats(filename, parse_samples, verbose=False): "count_full_obstructed_ping_drop": count_full_obstruct, "count_unscheduled": count_unsched, "total_unscheduled_ping_drop": total_unsched_drop, - "count_full_unscheduled_ping_drop": count_full_unsched + "count_full_unscheduled_ping_drop": count_full_unsched, }, { "init_run_fragment": init_run_length, "final_run_fragment": run_length, "run_seconds": second_runs, - "run_minutes": minute_runs + "run_minutes": minute_runs, }