236 lines
6.7 KiB
Python
236 lines
6.7 KiB
Python
#!/usr/bin/python3
|
|
"""Publish Starlink user terminal data to a MQTT broker.
|
|
|
|
This script pulls the current status info and/or metrics computed from the
|
|
history data and publishes them to the specified MQTT broker either once or
|
|
in a periodic loop.
|
|
|
|
Data will be published to the following topic names:
|
|
|
|
: starlink/dish_status/*id_value*/*field_name* : Current status data
|
|
: starlink/dish_ping_stats/*id_value*/*field_name* : Ping history statistics
|
|
: starlink/dish_usage/*id_value*/*field_name* : Usage history statistics
|
|
|
|
Where *id_value* is the *id* value from the dish status information.
|
|
|
|
Unless the --json command line option is used, in which case, JSON-formatted
|
|
data will be published to topic name:
|
|
|
|
: starlink/*id_value*
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
|
|
try:
|
|
import ssl
|
|
|
|
ssl_ok = True
|
|
except ImportError:
|
|
ssl_ok = False
|
|
|
|
import paho.mqtt.publish
|
|
|
|
import starlink_grpc_tools.dish_common as dish_common
|
|
|
|
HOST_DEFAULT = "localhost"
|
|
|
|
|
|
class Terminated(Exception):
|
|
pass
|
|
|
|
|
|
def handle_sigterm(signum, frame):
|
|
# Turn SIGTERM into an exception so main loop can clean up
|
|
raise Terminated
|
|
|
|
|
|
def parse_args():
|
|
parser = dish_common.create_arg_parser(
|
|
output_description="publish it to a MQTT broker", bulk_history=False
|
|
)
|
|
|
|
group = parser.add_argument_group(title="MQTT broker options")
|
|
group.add_argument(
|
|
"-n",
|
|
"--hostname",
|
|
default=HOST_DEFAULT,
|
|
help="Hostname of MQTT broker, default: " + HOST_DEFAULT,
|
|
)
|
|
group.add_argument(
|
|
"-p", "--port", type=int, help="Port number to use on MQTT broker"
|
|
)
|
|
group.add_argument(
|
|
"-P", "--password", help="Set password for username/password authentication"
|
|
)
|
|
group.add_argument("-U", "--username", help="Set username for authentication")
|
|
group.add_argument("-J", "--json", action="store_true", help="Publish data as JSON")
|
|
if ssl_ok:
|
|
|
|
def wrap_ca_arg(arg):
|
|
return {"ca_certs": arg}
|
|
|
|
group.add_argument(
|
|
"-C",
|
|
"--ca-cert",
|
|
type=wrap_ca_arg,
|
|
dest="tls",
|
|
help="Enable SSL/TLS using specified CA cert to verify broker",
|
|
metavar="FILENAME",
|
|
)
|
|
group.add_argument(
|
|
"-I",
|
|
"--insecure",
|
|
action="store_const",
|
|
const={"cert_reqs": ssl.CERT_NONE},
|
|
dest="tls",
|
|
help="Enable SSL/TLS but disable certificate verification (INSECURE!)",
|
|
)
|
|
group.add_argument(
|
|
"-S",
|
|
"--secure",
|
|
action="store_const",
|
|
const={},
|
|
dest="tls",
|
|
help="Enable SSL/TLS using default CA cert",
|
|
)
|
|
else:
|
|
parser.epilog += "\nSSL support options not available due to missing ssl module"
|
|
|
|
env_map = (
|
|
("MQTT_HOST", "hostname"),
|
|
("MQTT_PORT", "port"),
|
|
("MQTT_USERNAME", "username"),
|
|
("MQTT_PASSWORD", "password"),
|
|
("MQTT_SSL", "tls"),
|
|
)
|
|
env_defaults = {}
|
|
for var, opt in env_map:
|
|
# check both set and not empty string
|
|
val = os.environ.get(var)
|
|
if val:
|
|
if var == "MQTT_SSL":
|
|
if ssl_ok and val != "false":
|
|
if val == "insecure":
|
|
env_defaults[opt] = {"cert_reqs": ssl.CERT_NONE}
|
|
elif val == "secure":
|
|
env_defaults[opt] = {}
|
|
else:
|
|
env_defaults[opt] = {"ca_certs": val}
|
|
else:
|
|
env_defaults[opt] = val
|
|
parser.set_defaults(**env_defaults)
|
|
|
|
opts = dish_common.run_arg_parser(parser, need_id=True)
|
|
|
|
if opts.username is None and opts.password is not None:
|
|
parser.error("Password authentication requires username to be set")
|
|
|
|
opts.mqargs = {}
|
|
for key in ["hostname", "port", "tls"]:
|
|
val = getattr(opts, key)
|
|
if val is not None:
|
|
opts.mqargs[key] = val
|
|
|
|
if opts.username is not None:
|
|
opts.mqargs["auth"] = {"username": opts.username}
|
|
if opts.password is not None:
|
|
opts.mqargs["auth"]["password"] = opts.password
|
|
|
|
return opts
|
|
|
|
|
|
def loop_body(opts, gstate):
|
|
msgs = []
|
|
|
|
if opts.json:
|
|
data = {}
|
|
|
|
def cb_add_item(key, val, category):
|
|
if not "dish_{0}".format(category) in data:
|
|
data["dish_{0}".format(category)] = {}
|
|
|
|
# Skip NaN values that occur on startup because they can upset Javascript JSON parsers
|
|
if not (isinstance(val, float) and math.isnan(val)):
|
|
data["dish_{0}".format(category)].update({key: val})
|
|
|
|
def cb_add_sequence(key, val, category, _):
|
|
if not "dish_{0}".format(category) in data:
|
|
data["dish_{0}".format(category)] = {}
|
|
|
|
data["dish_{0}".format(category)].update({key: list(val)})
|
|
|
|
else:
|
|
|
|
def cb_add_item(key, val, category):
|
|
msgs.append(
|
|
(
|
|
"starlink/dish_{0}/{1}/{2}".format(category, gstate.dish_id, key),
|
|
val,
|
|
0,
|
|
False,
|
|
)
|
|
)
|
|
|
|
def cb_add_sequence(key, val, category, _):
|
|
msgs.append(
|
|
(
|
|
"starlink/dish_{0}/{1}/{2}".format(category, gstate.dish_id, key),
|
|
",".join("" if x is None else str(x) for x in val),
|
|
0,
|
|
False,
|
|
)
|
|
)
|
|
|
|
rc = dish_common.get_data(opts, gstate, cb_add_item, cb_add_sequence)[0]
|
|
|
|
if opts.json:
|
|
msgs.append(("starlink/{0}".format(gstate.dish_id), json.dumps(data), 0, False))
|
|
|
|
if msgs:
|
|
try:
|
|
paho.mqtt.publish.multiple(msgs, client_id=gstate.dish_id, **opts.mqargs)
|
|
if opts.verbose:
|
|
print("Successfully published to MQTT broker")
|
|
except Exception as e:
|
|
dish_common.conn_error(opts, "Failed publishing to MQTT broker: %s", str(e))
|
|
rc = 1
|
|
|
|
return rc
|
|
|
|
|
|
def main():
|
|
opts = parse_args()
|
|
|
|
logging.basicConfig(format="%(levelname)s: %(message)s")
|
|
|
|
gstate = dish_common.GlobalState(target=opts.target)
|
|
|
|
signal.signal(signal.SIGTERM, handle_sigterm)
|
|
|
|
rc = 0
|
|
try:
|
|
next_loop = time.monotonic()
|
|
while True:
|
|
rc = loop_body(opts, gstate)
|
|
if opts.loop_interval > 0.0:
|
|
now = time.monotonic()
|
|
next_loop = max(next_loop + opts.loop_interval, now)
|
|
time.sleep(next_loop - now)
|
|
else:
|
|
break
|
|
except (KeyboardInterrupt, Terminated):
|
|
pass
|
|
finally:
|
|
gstate.shutdown()
|
|
|
|
sys.exit(rc)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|