kopia lustrzana https://github.com/projecthorus/wenet
380 wiersze
12 KiB
Python
380 wiersze
12 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Wenet Web GUI
|
|
#
|
|
# Copyright (C) 2019 Mark Jessop <vk5qi@rfhead.net>
|
|
# Released under GNU GPL v3 or later
|
|
#
|
|
# A really hacky first attempt at a live-updating web interface that displays wenet imagery.
|
|
#
|
|
# Run this instead of rx_gui in the startup scripts, and then access at http://localhost:5003/
|
|
#
|
|
# TODO:
|
|
# [ ] Automatic re-scaling of images in web browser.
|
|
# [ ] Add Display of GPS telemetry and text messages.
|
|
#
|
|
import json
|
|
import logging
|
|
import flask
|
|
from flask_socketio import SocketIO
|
|
import time
|
|
import traceback
|
|
import socket
|
|
import sys
|
|
import datetime
|
|
from threading import Thread, Lock
|
|
from io import BytesIO
|
|
|
|
from WenetPackets import *
|
|
|
|
from sondehub.amateur import Uploader
|
|
|
|
# Define Flask Application, and allow automatic reloading of templates for dev
|
|
app = flask.Flask(__name__)
|
|
app.config['SECRET_KEY'] = 'secret!'
|
|
app.config['TEMPLATES_AUTO_RELOAD'] = True
|
|
app.jinja_env.auto_reload = True
|
|
|
|
# SocketIO instance
|
|
socketio = SocketIO(app)
|
|
|
|
# PySondeHub Uploader, instantiated later.
|
|
sondehub = None
|
|
|
|
# UDP port for Payload Summary emit
|
|
udp_emit_port = 0
|
|
|
|
# Latest Image
|
|
latest_image = None
|
|
latest_image_lock = Lock()
|
|
|
|
|
|
# Data we need for uploading telemetry to SondeHub-Amateur
|
|
my_callsign = "N0CALL"
|
|
current_callsign = None
|
|
current_modem_stats = None
|
|
|
|
|
|
#
|
|
# Flask Routes
|
|
#
|
|
|
|
@app.route("/")
|
|
def flask_index():
|
|
""" Render main index page """
|
|
return flask.render_template('index.html')
|
|
|
|
|
|
@app.route("/latest.jpg")
|
|
def serve_latest_image():
|
|
global latest_image, latest_image_lock
|
|
if latest_image == None:
|
|
flask.abort(404)
|
|
else:
|
|
# Grab image bytes.
|
|
latest_image_lock.acquire()
|
|
_temp_image = bytes(latest_image)
|
|
latest_image_lock.release()
|
|
|
|
return flask.send_file(
|
|
BytesIO(_temp_image),
|
|
mimetype='image/jpeg',
|
|
as_attachment=False)
|
|
|
|
|
|
def flask_emit_event(event_name="none", data={}):
|
|
""" Emit a socketio event to any clients. """
|
|
socketio.emit(event_name, data, namespace='/update_status')
|
|
|
|
|
|
# SocketIO Handlers
|
|
@socketio.on('client_connected', namespace='/update_status')
|
|
def update_client_display(data):
|
|
pass
|
|
|
|
|
|
|
|
def update_image(filename, description):
|
|
global latest_image, latest_image_lock
|
|
try:
|
|
with open(filename, 'rb') as _new_image:
|
|
_data = _new_image.read()
|
|
|
|
latest_image_lock.acquire()
|
|
latest_image = bytes(_data)
|
|
latest_image_lock.release()
|
|
|
|
# Trigger the clients to update.
|
|
flask_emit_event('image_update', data={'text':description})
|
|
|
|
logging.debug("Loaded new image: %s" % filename)
|
|
|
|
except Exception as e:
|
|
logging.error("Error loading new image %s - %s" % (filename, str(e)))
|
|
|
|
|
|
|
|
def handle_gps_telemetry(gps_data):
|
|
global current_callsign, current_modem_stats
|
|
|
|
if current_callsign is None:
|
|
# No callsign yet, can't do anything with the GPS data
|
|
return
|
|
|
|
if current_modem_stats is None:
|
|
# No modem stats, don't want to upload without that info.
|
|
return
|
|
|
|
# Only upload telemetry if we have GPS lock.
|
|
if gps_data['gpsFix'] != 3:
|
|
logging.debug("No GPS lock - discarding GPS telemetry.")
|
|
return
|
|
|
|
|
|
if sondehub:
|
|
# Add to the SondeHub-Amateur uploader!
|
|
|
|
_extra_fields = {
|
|
'ascent_rate': round(gps_data['ascent_rate'],1),
|
|
'speed': round(gps_data['ground_speed'],1)
|
|
}
|
|
# Add in new fields from 2024-09 if they exist and are valid
|
|
if 'radio_temp' in gps_data:
|
|
if gps_data['radio_temp'] > -999.0:
|
|
_extra_fields['radio_temp'] = gps_data['radio_temp']
|
|
|
|
if gps_data['cpu_temp'] > -999.0:
|
|
_extra_fields['cpu_temp'] = gps_data['cpu_temp']
|
|
|
|
_extra_fields['cpu_speed'] = gps_data['cpu_speed']
|
|
_extra_fields['load_avg_1'] = gps_data['load_avg_1']
|
|
_extra_fields['load_avg_5'] = gps_data['load_avg_5']
|
|
_extra_fields['load_avg_15'] = gps_data['load_avg_15']
|
|
_extra_fields['disk_percent'] = gps_data['disk_percent']
|
|
|
|
if gps_data['lens_position'] > -999.0:
|
|
_extra_fields['lens_position'] = gps_data['lens_position']
|
|
|
|
sondehub.add_telemetry(
|
|
current_callsign + "-Wenet",
|
|
gps_data['timestamp'] + "Z",
|
|
round(gps_data['latitude'],6),
|
|
round(gps_data['longitude'],6),
|
|
round(gps_data['altitude'],1),
|
|
sats = gps_data['numSV'],
|
|
heading = round(gps_data['heading'],1),
|
|
extra_fields = _extra_fields,
|
|
modulation = "Wenet",
|
|
frequency = round(current_modem_stats['fcentre']/1e6, 5),
|
|
snr = round(current_modem_stats['snr'],1)
|
|
)
|
|
|
|
|
|
# Emit as a Horus UDP Payload Summary packet.
|
|
if udp_emit_port > 0:
|
|
try:
|
|
# Prepare heading & speed fields, if they are provided in the incoming telemetry blob.
|
|
|
|
# Generate 'short' time field.
|
|
_short_time = gps_data['timestamp'].split('T')[1] + 'Z'
|
|
|
|
packet = {
|
|
"type": "PAYLOAD_SUMMARY",
|
|
"station": my_callsign,
|
|
"callsign": current_callsign + "-Wenet",
|
|
"latitude": round(gps_data['latitude'],6),
|
|
"longitude": round(gps_data['longitude'],6),
|
|
"altitude": round(gps_data['altitude'],1),
|
|
"sats": gps_data['numSV'],
|
|
"speed": round(gps_data['ground_speed'],1),
|
|
"heading": round(gps_data['heading'],1),
|
|
"time": _short_time,
|
|
"frequency": round(current_modem_stats['fcentre']/1e6, 5),
|
|
"snr": round(current_modem_stats['snr'],1),
|
|
"comment": "Wenet",
|
|
}
|
|
|
|
# Set up our UDP socket
|
|
_s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
_s.settimeout(1)
|
|
# Set up socket for broadcast, and allow re-use of the address
|
|
_s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
_s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
# Under OSX we also need to set SO_REUSEPORT to 1
|
|
try:
|
|
_s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
except:
|
|
pass
|
|
|
|
try:
|
|
_s.sendto(
|
|
json.dumps(packet).encode("ascii"),
|
|
("<broadcast>", udp_emit_port),
|
|
)
|
|
# Catch any socket errors, that may occur when attempting to send to a broadcast address
|
|
# when there is no network connected. In this case, re-try and send to localhost instead.
|
|
except socket.error as e:
|
|
logging.debug(
|
|
"Send to broadcast address failed, sending to localhost instead."
|
|
)
|
|
_s.sendto(
|
|
json.dumps(packet).encode("ascii"),
|
|
("127.0.0.1", udp_emit_port),
|
|
)
|
|
|
|
_s.close()
|
|
|
|
except Exception as e:
|
|
logging.error("Error sending Payload Summary: %s" % str(e))
|
|
|
|
def handle_telemetry(packet):
|
|
""" Handle GPS and Text message packets from the wenet receiver """
|
|
|
|
# Decode GPS and IMU packets, and pass onto their respective GUI update functions.
|
|
packet_type = decode_packet_type(packet)
|
|
|
|
if packet_type == WENET_PACKET_TYPES.GPS_TELEMETRY:
|
|
# GPS data from the payload
|
|
gps_data = gps_telemetry_decoder(packet)
|
|
if gps_data['error'] == 'None':
|
|
flask_emit_event('gps_update', data=gps_data)
|
|
|
|
handle_gps_telemetry(gps_data)
|
|
|
|
elif packet_type == WENET_PACKET_TYPES.TEXT_MESSAGE:
|
|
# A text message from the payload.
|
|
text_data = decode_text_message(packet)
|
|
# Add some received timestamp info
|
|
text_data['timestamp'] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
if text_data['error'] == 'None':
|
|
flask_emit_event('text_update', data=text_data)
|
|
|
|
elif packet_type == WENET_PACKET_TYPES.ORIENTATION_TELEMETRY:
|
|
# Orientation data from the payload
|
|
orientation_data = orientation_telemetry_decoder(packet)
|
|
if orientation_data['error'] == 'None':
|
|
flask_emit_event('orientation_update', data=orientation_data)
|
|
|
|
elif packet_type == WENET_PACKET_TYPES.IMAGE_TELEMETRY:
|
|
# image data from the payload
|
|
image_data = image_telemetry_decoder(packet)
|
|
if image_data['error'] == 'None':
|
|
flask_emit_event('image_telem_update', data=image_data)
|
|
|
|
else:
|
|
# Discard any other packet type.
|
|
pass
|
|
|
|
|
|
def process_udp(packet):
|
|
global current_callsign, current_modem_stats
|
|
|
|
packet_dict = json.loads(packet.decode('ascii'))
|
|
|
|
if 'filename' in packet_dict:
|
|
# New image to load
|
|
update_image(packet_dict['filename'], packet_dict['text'])
|
|
|
|
new_callsign = packet_dict['metadata']['callsign']
|
|
if current_callsign != new_callsign:
|
|
logging.info(f"Received new payload callsign data: {new_callsign}")
|
|
current_callsign = new_callsign
|
|
|
|
elif 'uploader_status' in packet_dict:
|
|
# Information from the uploader process.
|
|
flask_emit_event('uploader_update', data=packet_dict)
|
|
|
|
elif 'snr' in packet_dict:
|
|
# Modem statistics packet
|
|
flask_emit_event('modem_stats', data=packet_dict)
|
|
current_modem_stats = packet_dict
|
|
|
|
elif 'type' in packet_dict:
|
|
# Generic telemetry packet from the wenet RX.
|
|
# This could be GPS telemetry, text data, or something else..
|
|
if packet_dict['type'] == 'WENET':
|
|
handle_telemetry(packet_dict['packet'])
|
|
|
|
|
|
|
|
udp_listener_running = False
|
|
def udp_rx_thread():
|
|
""" Listen on a port for UDP broadcast packets, and pass them onto process_udp()"""
|
|
global udp_listener_running
|
|
s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
|
|
s.settimeout(1)
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
except:
|
|
pass
|
|
s.bind(('',WENET_IMAGE_UDP_PORT))
|
|
logging.info("Started UDP Listener Thread.")
|
|
udp_listener_running = True
|
|
while udp_listener_running:
|
|
try:
|
|
m = s.recvfrom(8192)
|
|
except socket.timeout:
|
|
m = None
|
|
|
|
if m != None:
|
|
try:
|
|
process_udp(m[0])
|
|
except:
|
|
traceback.print_exc()
|
|
pass
|
|
|
|
logging.info("Closing UDP Listener")
|
|
s.close()
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("callsign", help="SondeHub-Amateur Uploader Callsign")
|
|
parser.add_argument("-l", "--listen_port", default=5003, help="Port to run Web Server on. (Default: 5003)")
|
|
parser.add_argument("-v", "--verbose", action='store_true', help="Enable debug output.")
|
|
parser.add_argument("--no_sondehub", default=False, action='store_true', help="Disable SondeHub-Amateur position upload.")
|
|
parser.add_argument("--image_port", type=int, default=None, help="UDP port used for communication between Wenet decoder processes. Default: 7890")
|
|
parser.add_argument("-u", "--udp_port", default=None, type=int, help="Port to emit Horus UDP packets on. (Default: 0 (disabled), Typical: 55673)")
|
|
args = parser.parse_args()
|
|
|
|
|
|
if args.verbose:
|
|
log_level = logging.DEBUG
|
|
else:
|
|
log_level = logging.ERROR
|
|
|
|
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=log_level)
|
|
|
|
my_callsign = args.callsign
|
|
|
|
# Instantiate the SondeHub-Amateur Uploader
|
|
if not args.no_sondehub:
|
|
sondehub = Uploader(my_callsign, software_name="pysondehub-wenet", software_version=WENET_VERSION)
|
|
|
|
if args.udp_port:
|
|
udp_emit_port = args.udp_port
|
|
|
|
# Overwrite the image UDP port if it has been provided
|
|
if args.image_port:
|
|
WENET_IMAGE_UDP_PORT = args.image_port
|
|
|
|
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
|
logging.getLogger("socketio").setLevel(logging.ERROR)
|
|
logging.getLogger("engineio").setLevel(logging.ERROR)
|
|
logging.getLogger("geventwebsocket").setLevel(logging.ERROR)
|
|
|
|
t = Thread(target=udp_rx_thread)
|
|
t.start()
|
|
|
|
# Run the Flask app, which will block until CTRL-C'd.
|
|
socketio.run(app, host='0.0.0.0', port=args.listen_port, allow_unsafe_werkzeug=True)
|
|
|
|
udp_listener_running = False
|
|
|
|
|
|
|