From 6fdd5c9c0b8092a9fd564b313cd428b50fa75966 Mon Sep 17 00:00:00 2001 From: Kevin Elliott Date: Mon, 8 Jul 2024 16:34:22 -0700 Subject: [PATCH 01/20] update config sample --- config.json.sample | 1 + 1 file changed, 1 insertion(+) diff --git a/config.json.sample b/config.json.sample index f6f9f36..a2ce89e 100644 --- a/config.json.sample +++ b/config.json.sample @@ -1,5 +1,6 @@ { "broker": { + "enabled": true, "host": "mqtt.meshtastic.org", "port": 1883, "client_id_prefix": "meshinfo-dev", From f851d903940da8609aef49e134f26667049baccd Mon Sep 17 00:00:00 2001 From: Kevin Elliott Date: Mon, 8 Jul 2024 16:35:00 -0700 Subject: [PATCH 02/20] core refactoring --- data_renderer.py | 40 +++ main.py | 590 ++++------------------------------------ memory_data_store.py | 64 +++++ mqtt.py | 244 +++++++++++++++++ static_html_renderer.py | 294 ++++++++++++++++++++ utils.py | 23 ++ 6 files changed, 713 insertions(+), 542 deletions(-) create mode 100644 data_renderer.py create mode 100644 memory_data_store.py create mode 100644 mqtt.py create mode 100644 static_html_renderer.py create mode 100644 utils.py diff --git a/data_renderer.py b/data_renderer.py new file mode 100644 index 0000000..0d160bb --- /dev/null +++ b/data_renderer.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +import datetime +import json +from zoneinfo import ZoneInfo +from jinja2 import Environment, FileSystemLoader + +from encoders import _JSONEncoder + +class DataRenderer: + def __init__(self, config, nodes, chat, telemetry, traceroutes): + self.config = config + self.nodes = nodes + self.chat = chat + self.telemetry = telemetry + self.traceroutes = traceroutes + + def render(self): + self.save_file(self.chat, "chat.json") + print(f"Saved {len(self.chat['channels']['0']['messages'])} chat messages to file ({self.config['paths']['data']}/chat.json)") + + old_nodes = nodes + nodes = {} + for id, node in old_nodes.items(): + if id.startswith('!'): + id = id.replace('!', '') + nodes[id] = node + + self.save_file(nodes, "nodes.json") + print(f"Saved {len(nodes)} nodes to file ({self.config['paths']['data']}/nodes.json)") + + self.save_file(self.telemetry, "telemetry.json") + print(f"Saved {len(self.telemetry)} telemetry to file ({self.config['paths']['data']}/telemetry.json)") + + self.save_file(self.traceroutes, "traceroutes.json") + print(f"Saved {len(self.traceroutes)} traceroutes to file ({self.config['paths']['data']}/traceroutes.json)") + + def save_file(self, filename, data): + with open(f"{self.config['paths']['data']}/{filename}", "w", encoding='utf-8') as f: + json.dump(data, f, indent=2, sort_keys=True, cls=_JSONEncoder) diff --git a/main.py b/main.py index a367044..222f9d7 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,6 @@ import datetime import json from zoneinfo import ZoneInfo -import paho.mqtt.client as mqtt_client import os from jinja2 import Environment, FileSystemLoader from dotenv import load_dotenv @@ -11,89 +10,21 @@ from config import Config from encoders import _JSONDecoder, _JSONEncoder -from geo import distance_between_two_points +from memory_data_store import MemoryDataStore from meshtastic import HardwareModel from models.node import Node +import geo +from mqtt import MQTT +import utils load_dotenv() config = Config.load() -chat: dict = {} -chat['channels'] = { - '0': { - 'name': 'General', - 'messages': [] - } -} -messages: list = [] -mqtt_messages: list = [] -mqtt_connect_time = datetime.datetime.now(ZoneInfo(config['server']['timezone'])) -nodes: dict = {} -telemetry: list = [] -telemetry_by_node: dict = {} -traceroutes: list = [] -traceroutes_by_node: dict = {} - -def connect_mqtt(broker, port, client_id, username, password): - def on_connect(client, userdata, flags, rc, properties=None): - global mqtt_connect_time - if rc == 0: - mqtt_connect_time = datetime.datetime.now(ZoneInfo(config['server']['timezone'])) - print("Connected to MQTT broker at %s:%d (as client_id %s)" % (broker, port, client_id)) - else: - print("Failed to connect, error: %s\n" % rc) - - client = mqtt_client.Client(client_id=client_id, callback_api_version=mqtt_client.CallbackAPIVersion.VERSION2) - client.on_connect = on_connect - client.username_pw_set(username, password) - client.connect(broker, port) - return client - -def publish(client, topic, msg): - result = client.publish(topic, msg) - - status = result[0] - - if status == 0: - print(f"Send `{msg}` to topic `{topic}`") - print("Done!") - return True - else: - print(f"Failed to send message to topic {topic}") - return False - -def subscribe(client, topic): - def on_message(client, userdata, msg, properties=None): - try: - decoded = msg.payload.decode("utf-8") - j = json.loads(decoded, cls=_JSONDecoder) - handle_log(msg) - if j['type'] == "neighborinfo": - handle_neighborinfo(client, userdata, j) - if j['type'] == "nodeinfo": - handle_nodeinfo(client, userdata, j) - if j['type'] == "position": - handle_position(client, userdata, j) - if j['type'] == "telemetry": - handle_telemetry(client, userdata, j) - if j['type'] == "text": - handle_text(client, userdata, j) - if j['type'] == "traceroute": - handle_traceroute(client, userdata, j) - prune_expired_nodes() - except Exception as e: - print(e) - - client.subscribe(topic) - client.on_message = on_message - -def update_node(id: str, node): - node['active'] = True - node['last_seen'] = datetime.datetime.now(ZoneInfo(config['server']['timezone'])) - nodes[id] = node +data = MemoryDataStore(config) +data.update('mqtt_connect_time', datetime.datetime.now(ZoneInfo(config['server']['timezone']))) def find_node_by_int_id(id: int): - return nodes.get(convert_node_id_from_int_to_hex(id), None) + return nodes.get(utils.convert_node_id_from_int_to_hex(id), None) def find_node_by_hex_id(id: str): return nodes.get(id, None) @@ -104,26 +35,6 @@ def find_node_by_short_name(sn: str): return node return None -def convert_node_id_from_int_to_hex(id: int): - return f'{id:x}' - -def convert_node_id_from_hex_to_int(id: str): - if id.startswith('!'): - id = id.replace('!', '') - return int(id, 16) - -def calculate_distance_between_nodes(node1, node2): - if node1 is None or node2 is None: - return None - if node1["position"] is None or node2["position"] is None: - return None - return round(distance_between_two_points( - node1["position"]["latitude_i"] / 10000000, - node1["position"]["longitude_i"] / 10000000, - node2["position"]["latitude_i"] / 10000000, - node2["position"]["longitude_i"] / 10000000 - ), 2) - def prune_expired_nodes(): global config global nodes @@ -139,150 +50,6 @@ def prune_expired_nodes(): for id in ids_to_delete: nodes[id]['active'] = False -def handle_log(msg): - global messages - global mqtt_messages - messages.append(msg.payload.decode("utf-8")) - mqtt_messages.append(msg) - print(f"MQTT >> {msg.topic} -- {msg.payload.decode("utf-8")}") - with open(f'{config["paths"]["data"]}/message-log.jsonl', 'a', encoding='utf-8') as f: - f.write(f"{msg.payload.decode("utf-8")}\n") - -def handle_neighborinfo(client, userdata, msg): - global nodes - - msg['from'] = f'{msg["from"]:x}' - msg['to'] = f'{msg["to"]:x}' - if msg['sender'] and isinstance(msg['sender'], str): - msg['sender'] = msg['sender'].replace('!', '') - - id = msg['from'] - if id in nodes: - node = nodes[id] - node['neighborinfo'] = msg['payload'] - update_node(id, node) - print(f"Node {id} updated with neighborinfo") - else: - node = Node.default_node(id) - node['neighborinfo'] = msg['payload'] - update_node(id, node) - print(f"Node {id} skeleton added with neighborinfo") - save() - -def handle_nodeinfo(client, userdata, msg): - global nodes - - msg['from'] = f'{msg["from"]:x}' - msg['to'] = f'{msg["to"]:x}' - if msg['sender'] and isinstance(msg['sender'], str): - msg['sender'] = msg['sender'].replace('!', '') - - id = msg['payload']['id'] - if id in nodes: - node = nodes[id] - node['hardware'] = msg['payload']['hardware'] - node['longname'] = msg['payload']['longname'] - node['shortname'] = msg['payload']['shortname'] - update_node(id, node) - print(f"Node {id} updated") - else: - node = Node.default_node(id) - node['hardware'] = msg['payload']['hardware'] - node['longname'] = msg['payload']['longname'] - node['shortname'] = msg['payload']['shortname'] - update_node(id, node) - print(f"Node {id} added") - sort_nodes_by_shortname() - save() - -def handle_position(client, userdata, msg): - global nodes - - msg['from'] = f'{msg["from"]:x}' - msg['to'] = f'{msg["to"]:x}' - if msg['sender'] and isinstance(msg['sender'], str): - msg['sender'] = msg['sender'].replace('!', '') - - id = msg['from'] - if id in nodes: - node = nodes[id] - node['position'] = msg['payload'] if 'payload' in msg else None - update_node(id, node) - print(f"Node {id} updated with position") - else: - node = Node.default_node(id) - node['position'] = msg['payload'] if 'payload' in msg else None - update_node(id, node) - print(f"Node {id} skeleton added with position") - save() - -def handle_telemetry(client, userdata, msg): - global nodes - global telemetry - - msg['from'] = f'{msg["from"]:x}' - msg['to'] = f'{msg["to"]:x}' - if msg['sender'] and isinstance(msg['sender'], str): - msg['sender'] = msg['sender'].replace('!', '') - - id = msg['from'] - if id in nodes: - node = nodes[id] - node['telemetry'] = msg['payload'] if 'payload' in msg else None - update_node(id, node) - print(f"Node {id} updated with telemetry") - else: - node = Node.default_node(id) - node['telemetry'] = msg['payload'] if 'payload' in msg else None - update_node(id, node) - print(f"Node {id} skeleton added with telemetry") - - if id not in telemetry_by_node: - telemetry_by_node[id] = [] - if 'payload' in msg: - telemetry.insert(0, msg) - telemetry_by_node[id].insert(0, msg) - - save() - -def handle_text(client, userdata, msg): - global chat - - msg['from'] = f'{msg["from"]:x}' - msg['to'] = f'{msg["to"]:x}' - if msg['sender'] and isinstance(msg['sender'], str): - msg['sender'] = msg['sender'].replace('!', '') - - chat['channels'][str(msg['channel'])]['messages'].insert(0, { - 'id': msg['id'], - 'sender': msg['sender'], - 'from': msg['from'], - 'to': msg['to'], - 'channel': str(msg['channel']), - 'text': msg['payload']['text'], - 'timestamp': msg['timestamp'], - 'hops_away': msg['hops_away'] if 'hops_away' in msg else None, - 'rssi': msg['rssi'] if 'rssi' in msg else None, - 'snr': msg['snr'] if 'snr' in msg else None, - }) - save() - -def handle_traceroute(client, userdata, msg): - global traceroutes - global traceroutes_by_node - - msg['from'] = f'{msg["from"]:x}' - msg['to'] = f'{msg["to"]:x}' - if msg['sender'] and isinstance(msg['sender'], str): - msg['sender'] = msg['sender'].replace('!', '') - msg['route'] = msg['payload']['route'] - if id in traceroutes_by_node: - traceroutes_by_node[id].insert(0, msg) - else: - traceroutes_by_node[id] = [msg] - traceroutes.insert(0, msg) - save() - def load_nodes_from_file(): n = {} if os.path.exists("output/data/nodes.json"): @@ -290,216 +57,6 @@ def load_nodes_from_file(): n = json.load(f, cls=_JSONDecoder) return n -def _serialize_node(node): - """ - Serialize a node object to a format suitable for saving to an HTML file. - """ - global config - global nodes - - last_seen = node["last_seen"] if isinstance(node["last_seen"], datetime.datetime) else datetime.datetime.fromisoformat(node["last_seen"]) - id = node["id"].replace("!","") if isinstance(node["id"], str) else node["id"] - serialized = { - "id": id, - "shortname": node["shortname"], - "longname": node["longname"], - "hardware": node["hardware"], - "position": _serialize_position(node["position"]) if node["position"] else None, - "neighborinfo": _serialize_neighborinfo(node) if node['neighborinfo'] else None, - "telemetry": node["telemetry"], - "last_seen_human": last_seen.astimezone().isoformat(), - "last_seen": last_seen, - "since": datetime.datetime.now(ZoneInfo(config['server']['timezone'])) - last_seen, - } - server_node = nodes[f'{config["server"]["node_id"]}'] - if server_node and 'position' in server_node and server_node["position"] and server_node["position"]["latitude_i"] != 0 and server_node["position"]["longitude_i"] != 0 and node["position"] and node["position"]["latitude_i"] != 0 and node["position"]["longitude_i"] != 0: - serialized["distance_from_host_node"] = round(distance_between_two_points( - node["position"]["latitude_i"] / 10000000, - node["position"]["longitude_i"] / 10000000, - server_node["position"]["latitude_i"] / 10000000, - server_node["position"]["longitude_i"] / 10000000 - ), 2) - return serialized - -def _serialize_neighborinfo(node): - """ - Serialize a neighborinfo object to a format suitable for saving to an HTML file. - """ - ni = node['neighborinfo'].copy() - ni['neighbors'] = _serialize_neighborinfo_neighbors(node) if 'neighbors' in ni else None - return ni - -def _serialize_neighborinfo_neighbors(node): - """ - Serialize a neighborinfo object to a format suitable for saving to an HTML file. - """ - global nodes - - from_node = nodes[node['id']] - ns = [] - for n in node['neighborinfo']['neighbors']: - id = convert_node_id_from_int_to_hex(n["node_id"]) - neighbor = { - "node_id": id, - "snr": n["snr"], - } - if id in nodes: - ni = nodes[id] - if from_node['position'] and ni['position']: - neighbor["distance"] = round(distance_between_two_points( - from_node["position"]["latitude_i"] / 10000000, - from_node["position"]["longitude_i"] / 10000000, - ni["position"]["latitude_i"] / 10000000, - ni["position"]["longitude_i"] / 10000000 - ), 2) - ns.append(neighbor) - return ns - -def _serialize_position(position): - """ - Serialize a position object to a format suitable for saving to an HTML file. - """ - if "altitude" in position: - altitude = position["altitude"] - else: - altitude = None - - return { - "altitude": altitude, - "latitude": position["latitude_i"] / 10000000, - "longitude": position["longitude_i"] / 10000000 - } - -def sort_nodes_by_shortname(): - global nodes - nodes = dict(sorted(nodes.items(), key=lambda item: item[1]["shortname"])) - -def render_static_html_files(): - global config - global chat - global nodes - global telemetry - global traceroutes - - print("Rendering static HTML files") - - # index.html - print("Rendering index.html") - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/index.html.j2') - rendered_html = template.render(config=config, nodes=nodes, active_nodes=nodes, datetime=datetime.datetime, zoneinfo=ZoneInfo(config['server']['timezone']), timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(f"{config['paths']['output']}/index.html", "w", encoding='utf-8') as f: - f.write(rendered_html) - - # chat.html - print("Rendering chat.html") - save_chat_to_file(chat, "html", f"{config['paths']['output']}/chat.html") - - # map.html - print("Rendering map.html") - server_node = nodes[f'{config["server"]["node_id"]}'] - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/map.html.j2') - rendered_html = template.render(config=config, server_node=server_node, nodes=nodes, calculate_distance_between_nodes=calculate_distance_between_nodes, convert_node_id_from_hex_to_int=convert_node_id_from_hex_to_int, convert_node_id_from_int_to_hex=convert_node_id_from_int_to_hex, datetime=datetime, zoneinfo=ZoneInfo(config['server']['timezone']), timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(f"{config['paths']['output']}/map.html", "w", encoding='utf-8') as f: - f.write(rendered_html) - - # mesh_log.html - print("Rendering mesh_log.html") - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/mesh_log.html.j2') - rendered_html = template.render(config=config, messages=messages, json=json, datetime=datetime.datetime, zoneinfo=ZoneInfo(config['server']['timezone']), timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(f"{config['paths']['output']}/mesh_log.html", "w", encoding='utf-8') as f: - f.write(rendered_html) - - # mqtt_log.html - print("Rendering mqtt_log.html") - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/mqtt_log.html.j2') - rendered_html = template.render(config=config, messages=mqtt_messages, mqtt_connect_time=mqtt_connect_time, json=json, datetime=datetime.datetime, zoneinfo=ZoneInfo(config['server']['timezone']), timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(f"{config['paths']['output']}/mqtt_log.html", "w", encoding='utf-8') as f: - f.write(rendered_html) - - # node_{{id}}.html - for id, node in nodes.items(): - id = id.replace('!', '') - print(f"Rendering node_{id}.html") - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/node.html.j2') - rendered_html = template.render(config=config, nodes=nodes, node=node, hardware=HardwareModel, datetime=datetime.datetime, zoneinfo=ZoneInfo(config['server']['timezone']), timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(f"{config['paths']['output']}/node_{id}.html", "w", encoding='utf-8') as f: - f.write(rendered_html) - - # nodes.html - print("Rendering nodes.html") - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/nodes.html.j2') - active_nodes = {} - for id, node in nodes.items(): - if 'active' in node and node['active']: - active_nodes[id] = _serialize_node(node) - rendered_html = template.render(config=config, nodes=nodes, active_nodes=active_nodes, hardware=HardwareModel, datetime=datetime.datetime, zoneinfo=ZoneInfo(config['server']['timezone']), timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(f"{config['paths']['output']}/nodes.html", "w", encoding='utf-8') as f: - f.write(rendered_html) - - # neighbors.html - print("Rendering neighbors.html") - active_nodes_with_neighbors = {} - for id, node in nodes.items(): - if 'active' in node and node['active'] and 'neighborinfo' in node and node['neighborinfo']: - active_nodes_with_neighbors[id] = _serialize_node(node) - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/neighbors.html.j2') - rendered_html = template.render(config=config, nodes=nodes, active_nodes=active_nodes, active_nodes_with_neighbors=active_nodes_with_neighbors, calculate_distance_between_nodes=calculate_distance_between_nodes, convert_node_id_from_int_to_hex=convert_node_id_from_int_to_hex, datetime=datetime.datetime, zoneinfo=ZoneInfo(config['server']['timezone']), timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(f"{config['paths']['output']}/neighbors.html", "w", encoding='utf-8') as f: - f.write(rendered_html) - - # routes.html - print("Rendering routes.html") - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/routes.html.j2') - rendered_html = template.render(config=config, nodes=nodes, datetime=datetime.datetime, zoneinfo=ZoneInfo(config['server']['timezone']), timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(f"{config['paths']['output']}/routes.html", "w", encoding='utf-8') as f: - f.write(rendered_html) - - # stats.html - print("Rendering stats.html") - stats = { - 'active_nodes': 0, - 'total_chat': len(chat['channels']['0']['messages']), - 'total_nodes': len(nodes), - 'total_messages': len(messages), - 'total_mqtt_messages': len(mqtt_messages), - 'total_telemetry': len(telemetry), - 'total_traceroutes': len(traceroutes), - } - for _, node in nodes.items(): - if 'active' in node and node['active']: - stats['active_nodes'] += 1 - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/stats.html.j2') - rendered_html = template.render(config=config, stats=stats, nodes=nodes, datetime=datetime.datetime, zoneinfo=ZoneInfo(config['server']['timezone']), timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(f"{config['paths']['output']}/stats.html", "w", encoding='utf-8') as f: - f.write(rendered_html) - - # telemetry.html - print("Rendering telemetry.html") - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/telemetry.html.j2') - rendered_html = template.render(config=config, nodes=nodes, telemetry=telemetry, datetime=datetime.datetime, zoneinfo=ZoneInfo(config['server']['timezone']), timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(f"{config['paths']['output']}/telemetry.html", "w", encoding='utf-8') as f: - f.write(rendered_html) - - # traceroutes.html - print("Rendering traceroutes.html") - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/traceroutes.html.j2') - rendered_html = template.render(config=config, nodes=nodes, traceroutes=traceroutes, datetime=datetime.datetime, zoneinfo=ZoneInfo(config['server']['timezone']), timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(f"{config['paths']['output']}/traceroutes.html", "w", encoding='utf-8') as f: - f.write(rendered_html) - - print("Done rendering static files") - def backfill_node_infos(): global nodes @@ -568,60 +125,24 @@ def save(): print(f"Rendered in {round(end.timestamp() - save_start.timestamp(), 3)} seconds") config['server']['last_render'] = end -def save_nodes_to_file(): - global config - global chat - global nodes - - old_nodes = nodes - nodes = {} - for id, node in old_nodes.items(): - if id.startswith('!'): - id = id.replace('!', '') - nodes[id] = node - save_chat_to_file(chat, "json", f"{config['paths']['data']}/chat.json") - print(f"Saved {len(chat['channels']['0']['messages'])} chat messages to file ({config['paths']['data']}/chat.json)") - save_to_json_file(nodes, f"{config['paths']['data']}/nodes.json") - print(f"Saved {len(nodes)} nodes to file ({config['paths']['data']}/nodes.json)") - save_to_json_file(telemetry, f"{config['paths']['data']}/telemetry.json") - print(f"Saved {len(telemetry)} telemetry to file ({config['paths']['data']}/telemetry.json)") - save_to_json_file(traceroutes, f"{config['paths']['data']}/traceroutes.json") - print(f"Saved {len(traceroutes)} traceroutes to file ({config['paths']['data']}/traceroutes.json)") - -def save_node_infos_to_file(node_infos, type,path, config=config): - if type == "json": - with open(path, "w", encoding='utf-8') as f: - json.dump(node_infos, f, indent=2, sort_keys=True, cls=_JSONEncoder) - if type == "html": - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/node_infos.html.j2') - rendered_html = template.render(node_infos=node_infos, datetime=datetime.datetime, timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(path, "w", encoding='utf-8') as f: - f.write(rendered_html) - def load(): global config - global chat - global nodes - global telemetry - global telemetry_by_node - global traceroutes - global traceroutes_by_node + global data try: - nodes = load_nodes_from_file() - print(f"Loaded {len(nodes)} existing nodes from file ({config['paths']['data']}/nodes.json)") + data.update('nodes', load_nodes_from_file()) + print(f"Loaded {len(data.nodes)} existing nodes from file ({config['paths']['data']}/nodes.json)") except FileNotFoundError: - nodes = {} - if config['server']['node_id'] not in nodes: - nodes[config['server']['node_id']] = Node.default_node(config['server']['node_id']) - nodes['ffffffff'] = Node.default_node('ffffffff') + data.update('nodes', {}) + if config['server']['node_id'] not in data.nodes: + data.nodes[config['server']['node_id']] = Node.default_node(config['server']['node_id']) + data.nodes['ffffffff'] = Node.default_node('ffffffff') try: - chat = load_chat_from_file("json", f"{config['paths']['data']}/chat.json") - print(f"Loaded {len(chat['channels']['0']['messages'])} chat messages from file ({config['paths']['data']}/chat.json)") + data.chat = load_chat_from_file("json", f"{config['paths']['data']}/chat.json") + print(f"Loaded {len(data.chat['channels']['0']['messages'])} chat messages from file ({config['paths']['data']}/chat.json)") except FileNotFoundError: - chat = { + data.chat = { 'channels': { '0': { 'name': 'General', @@ -631,38 +152,38 @@ def load(): } try: - telemetry = load_from_json_file(f"{config['paths']['data']}/telemetry.json") - if telemetry is None or len(telemetry) == 0: - telemetry = [] - if telemetry_by_node is None or len(telemetry_by_node) == 0: - telemetry_by_node = {} - for msg in telemetry: + data.telemetry = load_from_json_file(f"{config['paths']['data']}/telemetry.json") + if data.telemetry is None or len(data.telemetry) == 0: + data.telemetry = [] + if data.telemetry_by_node is None or len(data.telemetry_by_node) == 0: + data.telemetry_by_node = {} + for msg in data.telemetry: id = msg['from'] - if id not in telemetry_by_node: - telemetry_by_node[id] = [] - telemetry_by_node[id].insert(0, msg) - print(f"Loaded {len(telemetry)} telemetry messages from file ({config['paths']['data']}/telemetry.json)") - print(f"Loaded telemetry data for {len(telemetry_by_node)} nodes") + if id not in data.telemetry_by_node: + data.telemetry_by_node[id] = [] + data.telemetry_by_node[id].insert(0, msg) + print(f"Loaded {len(data.telemetry)} telemetry messages from file ({config['paths']['data']}/telemetry.json)") + print(f"Loaded telemetry data for {len(data.telemetry_by_node)} nodes") except FileNotFoundError: - telemetry = [] - telemetry_by_node = {} + data.telemetry = [] + data.telemetry_by_node = {} try: - traceroutes = load_from_json_file(f"{config['paths']['data']}/traceroutes.json") - if traceroutes is None or len(traceroutes) == 0: - traceroutes = [] - if traceroutes_by_node is None or len(traceroutes_by_node) == 0: - traceroutes_by_node = {} - for msg in traceroutes: + data.traceroutes = load_from_json_file(f"{config['paths']['data']}/traceroutes.json") + if data.traceroutes is None or len(data.traceroutes) == 0: + data.traceroutes = [] + if data.traceroutes_by_node is None or len(data.traceroutes_by_node) == 0: + data.traceroutes_by_node = {} + for msg in data.traceroutes: id = msg['from'] - if id not in traceroutes_by_node: - traceroutes_by_node[id] = [] - traceroutes_by_node[id].insert(0, msg) - print(f"Loaded {len(traceroutes)} traceroutes from file ({config['paths']['data']}/traceroutes.json)") - print(f"Loaded traceroutes data for {len(traceroutes_by_node)} nodes") + if id not in data.traceroutes_by_node: + data.traceroutes_by_node[id] = [] + data.traceroutes_by_node[id].insert(0, msg) + print(f"Loaded {len(data.traceroutes)} traceroutes from file ({config['paths']['data']}/traceroutes.json)") + print(f"Loaded traceroutes data for {len(data.traceroutes_by_node)} nodes") except FileNotFoundError: - traceroutes = [] - traceroutes_by_node = {} + data.traceroutes = [] + data.traceroutes_by_node = {} def load_chat_from_file(type, path, config=config): global chat @@ -676,22 +197,6 @@ def load_from_json_file(path, config=config): with open(path, "r", encoding='utf-8') as f: return json.load(f, cls=_JSONDecoder) -def save_to_json_file(data, path, config=config): - with open(path, "w", encoding='utf-8') as f: - json.dump(data, f, indent=2, sort_keys=True, cls=_JSONEncoder) - -def save_chat_to_file(chat, type, path, config=config): - global nodes - if type == "json": - with open(path, "w", encoding='utf-8') as f: - json.dump(chat, f, indent=2, sort_keys=True, cls=_JSONEncoder) - if type == "html": - env = Environment(loader=FileSystemLoader('.'), autoescape=True) - template = env.get_template(f'{config["paths"]["templates"]}/static/chat.html.j2') - rendered_html = template.render(config=config, nodes=nodes, chat=chat, calculate_distance_between_nodes=calculate_distance_between_nodes, datetime=datetime.datetime, zoneinfo=ZoneInfo(config['server']['timezone']), timestamp=datetime.datetime.now(ZoneInfo(config['server']['timezone']))) - with open(path, "w", encoding='utf-8') as f: - f.write(rendered_html) - def run(): global chat global config @@ -716,9 +221,12 @@ def run(): if os.environ.get('MQTT_TOPIC') is not None: config['broker']['topic'] = os.environ['MQTT_TOPIC'] - client = connect_mqtt(config['broker']['host'], config['broker']['port'], config['broker']['client_id'], config['broker']['username'], config['broker']['password']) - subscribe(client, config['broker']['topic']) + if config['broker']['enabled'] is True: + print("Connecting to MQTT broker") + mqtt = MQTT(config, data) + mqtt.subscribe(config['broker']['topic']) + # discord # if os.environ.get('DISCORD_TOKEN') is not None: # config['integrations']['discord']['token'] = os.environ['DISCORD_TOKEN'] # config['integrations']['discord']['channel_id'] = os.environ['DISCORD_CHANNEL_ID'] @@ -752,7 +260,5 @@ def run(): # discord_client.run(config['integrations']['discord']['token']) - client.loop_forever() - if __name__ == "__main__": run() diff --git a/memory_data_store.py b/memory_data_store.py new file mode 100644 index 0000000..8674d78 --- /dev/null +++ b/memory_data_store.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 + +from datetime import datetime +from zoneinfo import ZoneInfo + +from static_html_renderer import StaticHTMLRenderer + + +class MemoryDataStore: + def __init__(self, config): + self.config = config + self.chat: dict = {} + self.chat['channels'] = { + '0': { + 'name': 'General', + 'messages': [] + } + } + self.messages: list = [] + self.mqtt_messages: list = [] + self.mqtt_connect_time: datetime = self.config['server']['start_time'] + self.nodes: dict = {} + self.telemetry: list = [] + self.telemetry_by_node: dict = {} + self.traceroutes: list = [] + self.traceroutes_by_node: dict = {} + + self.static_html_renderer = StaticHTMLRenderer(config, self) + + def update(self, key, value): + self.__dict__[key] = value + + def update_node(self, id: str, node): + node['active'] = True + node['last_seen'] = datetime.now().astimezone(ZoneInfo(self.config['server']['timezone'])) + self.nodes[id] = node + + def save(self): + save_start = datetime.now(ZoneInfo(self.config['server']['timezone'])) + last_data = self.config['server']['last_data_save'] if 'last_data_save' in self.config['server'] else self.config['server']['start_time'] + since_last_data = (save_start - last_data).total_seconds() + last_render = self.config['server']['last_render'] if 'last_render' in self.config['server'] else self.config['server']['start_time'] + since_last_render = (save_start - last_render).total_seconds() + last_backfill = self.config['server']['last_backfill'] if 'last_backfill' in self.config['server'] else self.config['server']['start_time'] + since_last_backfill = (save_start - last_backfill).total_seconds() + print(f"Since last - data save: {since_last_data}, render: {since_last_render}, backfill: {since_last_backfill}") + + # if since_last_backfill >= 900: + # self.backfill_node_infos() + # end = datetime.now(ZoneInfo(self.config['server']['timezone'])) + # print(f"Backfilled in {round(end.timestamp() - save_start.timestamp(), 3)} seconds") + # self.config['server']['last_backfill'] = end + + # if since_last_data >= self.config['server']['intervals']['data_save']: + # self.save_nodes_to_file() + # end = datetime.now(ZoneInfo(self.config['server']['timezone'])) + # print(f"Saved json data in {round(end.timestamp() - save_start.timestamp(), 3)} seconds") + # self.config['server']['last_data_save'] = end + + if since_last_render >= self.config['server']['intervals']['render']: + self.static_html_renderer.render() + end = datetime.now(ZoneInfo(self.config['server']['timezone'])) + print(f"Rendered in {round(end.timestamp() - save_start.timestamp(), 3)} seconds") + self.config['server']['last_render'] = end diff --git a/mqtt.py b/mqtt.py new file mode 100644 index 0000000..109b05c --- /dev/null +++ b/mqtt.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 + +import datetime +import json +import traceback +from zoneinfo import ZoneInfo +import paho.mqtt.client as mqtt_client + +from encoders import _JSONDecoder +from models.node import Node + +class MQTT: + def __init__(self, config, data): + self.config = config + self.data = data + + self.host = config['broker']['host'] + self.port = config['broker']['port'] + self.client_id = config['broker']['client_id'] + self.username = config['broker']['username'] + self.password = config['broker']['password'] + + self.client = self.connect() + self.client.loop_forever() + + ### actions + + def connect(self): + ### paho callbacks + + def on_connect(client, userdata, flags, rc, properties=None): + if rc == 0: + self.data.mqtt_connect_time = datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + print("Connected to MQTT broker at %s:%d (as client_id %s)" % (self.host, self.port, self.client_id)) + self.subscribe(self.config['broker']['topic']) + else: + print("Failed to connect, error: %s\n" % rc) + + def on_message(client, userdata, msg, properties=None): + try: + decoded = msg.payload.decode("utf-8") + j = json.loads(decoded, cls=_JSONDecoder) + self.handle_log(msg) + if j['type'] == "neighborinfo": + self.handle_neighborinfo(j) + if j['type'] == "nodeinfo": + self.handle_nodeinfo(j) + if j['type'] == "position": + self.handle_position(j) + if j['type'] == "telemetry": + self.handle_telemetry(j) + if j['type'] == "text": + self.handle_text(j) + if j['type'] == "traceroute": + self.handle_traceroute(j) + self.prune_expired_nodes() + except Exception as e: + print(e) + traceback.print_exc() + + client = mqtt_client.Client(client_id=self.client_id, callback_api_version=mqtt_client.CallbackAPIVersion.VERSION2) + client.on_connect = on_connect + client.on_message = on_message + client.username_pw_set(self.username, self.password) + client.connect(self.host, self.port) + return client + + def publish(self, topic, msg): + result = self.client.publish(topic, msg) + status = result[0] + if status == 0: + print(f"Send `{msg}` to topic `{topic}`") + print("Done!") + return True + else: + print(f"Failed to send message to topic {topic}") + return False + + def subscribe(self, topic): + self.client.subscribe(topic) + print(f"Subscribed to topic `{topic}`") + + def unsubscribe(self, topic): + self.client.unsubscribe(topic) + + ### message handlers + + def handle_log(self, msg): + print(f"MQTT >> {msg.topic} -- {msg.payload.decode("utf-8")}") + self.data.messages.append(msg.payload.decode("utf-8")) + self.data.mqtt_messages.append(msg) + with open(f'{self.config["paths"]["data"]}/message-log.jsonl', 'a', encoding='utf-8') as f: + f.write(f"{msg.payload.decode("utf-8")}\n") + + def handle_neighborinfo(self, msg): + msg['from'] = f'{msg["from"]:x}' + msg['to'] = f'{msg["to"]:x}' + if msg['sender'] and isinstance(msg['sender'], str): + msg['sender'] = msg['sender'].replace('!', '') + + id = msg['from'] + if id in self.data.nodes: + node = self.data.nodes[id] + node['neighborinfo'] = msg['payload'] + self.data.update_node(id, node) + print(f"Node {id} updated with neighborinfo") + else: + node = Node.default_node(id) + node['neighborinfo'] = msg['payload'] + self.data.update_node(id, node) + print(f"Node {id} skeleton added with neighborinfo") + self.data.save() + + def handle_nodeinfo(self, msg): + msg['from'] = f'{msg["from"]:x}' + msg['to'] = f'{msg["to"]:x}' + if msg['sender'] and isinstance(msg['sender'], str): + msg['sender'] = msg['sender'].replace('!', '') + + id = msg['payload']['id'] + if id in self.data.nodes: + node = self.data.nodes[id] + node['hardware'] = msg['payload']['hardware'] + node['longname'] = msg['payload']['longname'] + node['shortname'] = msg['payload']['shortname'] + self.data.update_node(id, node) + print(f"Node {id} updated") + else: + node = Node.default_node(id) + node['hardware'] = msg['payload']['hardware'] + node['longname'] = msg['payload']['longname'] + node['shortname'] = msg['payload']['shortname'] + self.data.update_node(id, node) + print(f"Node {id} added") + self.sort_nodes_by_shortname() + self.data.save() + + def handle_position(self, msg): + msg['from'] = f'{msg["from"]:x}' + msg['to'] = f'{msg["to"]:x}' + if msg['sender'] and isinstance(msg['sender'], str): + msg['sender'] = msg['sender'].replace('!', '') + + id = msg['from'] + if id in self.data.nodes: + node = self.data.nodes[id] + node['position'] = msg['payload'] if 'payload' in msg else None + self.data.update_node(id, node) + print(f"Node {id} updated with position") + else: + node = Node.default_node(id) + node['position'] = msg['payload'] if 'payload' in msg else None + self.data.update_node(id, node) + print(f"Node {id} skeleton added with position") + self.data.save() + + def handle_telemetry(self, msg): + msg['from'] = f'{msg["from"]:x}' + msg['to'] = f'{msg["to"]:x}' + if msg['sender'] and isinstance(msg['sender'], str): + msg['sender'] = msg['sender'].replace('!', '') + + id = msg['from'] + if id in self.data.nodes: + node = self.data.nodes[id] + node['telemetry'] = msg['payload'] if 'payload' in msg else None + self.data.update_node(id, node) + print(f"Node {id} updated with telemetry") + else: + node = Node.default_node(id) + node['telemetry'] = msg['payload'] if 'payload' in msg else None + self.data.update_node(id, node) + print(f"Node {id} skeleton added with telemetry") + + if id not in self.data.telemetry_by_node: + self.data.telemetry_by_node[id] = [] + + if 'payload' in msg: + self.data.telemetry.insert(0, msg) + self.data.telemetry_by_node[id].insert(0, msg) + + self.data.save() + + def handle_text(self, msg): + msg['from'] = f'{msg["from"]:x}' + msg['to'] = f'{msg["to"]:x}' + if msg['sender'] and isinstance(msg['sender'], str): + msg['sender'] = msg['sender'].replace('!', '') + + self.data.chat['channels'][str(msg['channel'])]['messages'].insert(0, { + 'id': msg['id'], + 'sender': msg['sender'], + 'from': msg['from'], + 'to': msg['to'], + 'channel': str(msg['channel']), + 'text': msg['payload']['text'], + 'timestamp': msg['timestamp'], + 'hops_away': msg['hops_away'] if 'hops_away' in msg else None, + 'rssi': msg['rssi'] if 'rssi' in msg else None, + 'snr': msg['snr'] if 'snr' in msg else None, + }) + self.data.save() + + def handle_traceroute(self, msg): + msg['from'] = f'{msg["from"]:x}' + msg['to'] = f'{msg["to"]:x}' + if msg['sender'] and isinstance(msg['sender'], str): + msg['sender'] = msg['sender'].replace('!', '') + msg['route'] = msg['payload']['route'] + + if id in self.data.traceroutes_by_node: + self.data.traceroutes_by_node[id].insert(0, msg) + else: + self.data.traceroutes_by_node[id] = [msg] + self.data.traceroutes.insert(0, msg) + self.data.save() + + ### helpers + + # TODO: where should this really live? + def prune_expired_nodes(self): + now = datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ids_to_delete: list[str] = [] + for id, node in self.data.nodes.items(): + if node['last_seen'] is None: + ids_to_delete.append(node['id']) + continue + last_seen = datetime.datetime.fromisoformat(node['last_seen']).astimezone() if isinstance(node['last_seen'], str) else node['last_seen'] + try: + since = (now - last_seen).seconds + except Exception: + print(f"Node {id} has invalid last_seen: {node['last_seen']}") + self.data.nodes[id]['last_seen'] = None + self.data.nodes[id]['active'] = False + if node['active'] and since >= self.config['server']['node_activity_prune_threshold']: + ids_to_delete.append(node['id']) + print(f"Node {id} pruned (last heard {since} seconds ago)") + + for id in ids_to_delete: + self.data.nodes[id]['active'] = False + + # TODO: where should this really live? + def sort_nodes_by_shortname(self): + self.data.nodes = dict(sorted(self.data.nodes.items(), key=lambda item: item[1]["shortname"])) diff --git a/static_html_renderer.py b/static_html_renderer.py new file mode 100644 index 0000000..4d04d58 --- /dev/null +++ b/static_html_renderer.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 + +import datetime +import json +from zoneinfo import ZoneInfo +from jinja2 import Environment, FileSystemLoader + +import geo +import meshtastic +import utils + +class StaticHTMLRenderer: + def __init__(self, config, data): + self.config = config + self.data = data + self.output_path = self.config['paths']['output'] + self.template_path = f"{self.config['paths']['templates']}/static" + + def render(self): + self.render_index() + self.render_chat() + self.render_map() + self.render_mesh_log() + self.render_mqtt_log() + self.render_neighbors() + self.render_nodes_each() + self.render_nodes() + self.render_routes() + self.render_stats() + self.render_telemetry() + self.render_traceroutes() + + def save_file(self, filename, content): + with open(f"{self.output_path}/{filename}", "w", encoding='utf-8') as f: + f.write(content) + + def render_html(self, template_file, **kwargs): + env = Environment(loader=FileSystemLoader('.'), autoescape=True) + template_file = 'node.html' if template_file.startswith('node_') else f'{template_file}' + template = env.get_template(f'{self.template_path}/{template_file}.j2') + html = template.render(**kwargs) + return html + + def render_html_and_save(self, filename, **kwargs): + print(f"Rendering {filename}") + html = self.render_html(filename, **kwargs) + self.save_file(filename, html) + + + ### Page Renderers + + def render_chat(self): + self.render_html_and_save( + 'chat.html', + config=self.config, + nodes=self.data.nodes, + chat=self.data.chat, + utils=utils, + datetime=datetime.datetime, + zoneinfo=ZoneInfo(self.config['server']['timezone']), + timestamp=datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ) + + def render_index(self): + self.render_html_and_save( + 'index.html', + config=self.config, + nodes=self.data.nodes, + active_nodes=self.data.nodes, + datetime=datetime.datetime, + zoneinfo=ZoneInfo(self.config['server']['timezone']), + timestamp=datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ) + + def render_map(self): + server_node = self.data.nodes[self.config['server']['node_id']] + self.render_html_and_save( + 'map.html', + config=self.config, + server_node=server_node, + nodes=self.data.nodes, + utils=utils, + datetime=datetime, + zoneinfo=ZoneInfo(self.config['server']['timezone']), + timestamp=datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ) + + def render_mesh_log(self): + self.render_html_and_save( + 'mesh_log.html', + config=self.config, + messages=self.data.messages, + json=json, + datetime=datetime.datetime, + zoneinfo=ZoneInfo(self.config['server']['timezone']), + timestamp=datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ) + + def render_mqtt_log(self): + self.render_html_and_save( + 'mqtt_log.html', + config=self.config, + messages=self.data.mqtt_messages, + mqtt_connect_time=self.data.mqtt_connect_time, + json=json, + datetime=datetime.datetime, + zoneinfo=ZoneInfo(self.config['server']['timezone']), + timestamp=datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ) + + def render_neighbors(self): + active_nodes_with_neighbors = {} + for id, node in self.data.nodes.items(): + if 'active' in node and node['active'] and 'neighborinfo' in node and node['neighborinfo']: + active_nodes_with_neighbors[id] = self._serialize_node(node) + + self.render_html_and_save( + 'neighbors.html', + config=self.config, + nodes=self.data.nodes, + active_nodes_with_neighbors=active_nodes_with_neighbors, + geo=geo, + utils=utils, + datetime=datetime.datetime, + zoneinfo=ZoneInfo(self.config['server']['timezone']), + timestamp=datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ) + + def render_nodes(self): + active_nodes = {} + for id, node in self.data.nodes.items(): + if 'active' in node and node['active']: + active_nodes[id] = self._serialize_node(node) + + self.render_html_and_save( + 'nodes.html', + config=self.config, + nodes=self.data.nodes, + active_nodes=active_nodes, + hardware=meshtastic.HardwareModel, + datetime=datetime.datetime, + zoneinfo=ZoneInfo(self.config['server']['timezone']), + timestamp=datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ) + + def render_nodes_each(self): + for id, node in self.data.nodes.items(): + id = id.replace('!', '') # todo: remove this line + self.render_html_and_save( + f"node_{id}.html", + config=self.config, + node=node, + nodes=self.data.nodes, + hardware=meshtastic.HardwareModel, + datetime=datetime.datetime, + zoneinfo=ZoneInfo(self.config['server']['timezone']), + timestamp=datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ) + + def render_routes(self): + self.render_html_and_save( + 'routes.html', + config=self.config, + nodes=self.data.nodes, + active_nodes=self.data.nodes, + datetime=datetime.datetime, + zoneinfo=ZoneInfo(self.config['server']['timezone']), + timestamp=datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ) + + def render_stats(self): + stats = { + 'active_nodes': 0, + 'total_chat': len(self.data.chat['channels']['0']['messages']), + 'total_nodes': len(self.data.nodes), + 'total_messages': len(self.data.messages), + 'total_mqtt_messages': len(self.data.mqtt_messages), + 'total_telemetry': len(self.data.telemetry), + 'total_traceroutes': len(self.data.traceroutes), + } + for _, node in self.data.nodes.items(): + if 'active' in node and node['active']: + stats['active_nodes'] += 1 + + self.render_html_and_save( + 'stats.html', + config=self.config, + stats=stats, + nodes=self.data.nodes, + datetime=datetime.datetime, + zoneinfo=ZoneInfo(self.config['server']['timezone']), + timestamp=datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ) + + def render_telemetry(self): + self.render_html_and_save( + 'telemetry.html', + config=self.config, + nodes=self.data.nodes, + telemetry=self.data.telemetry, + datetime=datetime.datetime, + zoneinfo=ZoneInfo(self.config['server']['timezone']), + timestamp=datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ) + + def render_traceroutes(self): + self.render_html_and_save( + 'traceroutes.html', + config=self.config, + nodes=self.data.nodes, + traceroutes=self.data.traceroutes, + datetime=datetime.datetime, + zoneinfo=ZoneInfo(self.config['server']['timezone']), + timestamp=datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) + ) + + # TODO: move to models + def _serialize_node(self, node): + """ + Serialize a node object to a format suitable for saving to an HTML file. + """ + + last_seen = node["last_seen"] if isinstance(node["last_seen"], datetime.datetime) else datetime.datetime.fromisoformat(node["last_seen"]) + id = node["id"].replace("!","") if isinstance(node["id"], str) else node["id"] + serialized = { + "id": id, + "shortname": node["shortname"], + "longname": node["longname"], + "hardware": node["hardware"], + "position": self._serialize_position(node["position"]) if node["position"] else None, + "neighborinfo": self._serialize_neighborinfo(node) if node['neighborinfo'] else None, + "telemetry": node["telemetry"], + "last_seen_human": last_seen.astimezone().isoformat(), + "last_seen": last_seen, + "since": datetime.datetime.now(ZoneInfo(self.config['server']['timezone'])) - last_seen, + } + server_node = self.data.nodes[f'{self.config["server"]["node_id"]}'] + if server_node and 'position' in server_node and server_node["position"] and server_node["position"]["latitude_i"] != 0 and server_node["position"]["longitude_i"] != 0 and node["position"] and node["position"]["latitude_i"] != 0 and node["position"]["longitude_i"] != 0: + serialized["distance_from_host_node"] = round(geo.distance_between_two_points( + node["position"]["latitude_i"] / 10000000, + node["position"]["longitude_i"] / 10000000, + server_node["position"]["latitude_i"] / 10000000, + server_node["position"]["longitude_i"] / 10000000 + ), 2) + return serialized + + def _serialize_neighborinfo(self, node): + """ + Serialize a neighborinfo object to a format suitable for saving to an HTML file. + """ + ni = node['neighborinfo'].copy() + ni['neighbors'] = self._serialize_neighborinfo_neighbors(node) if 'neighbors' in ni else None + return ni + + def _serialize_neighborinfo_neighbors(self, node): + """ + Serialize a neighborinfo object to a format suitable for saving to an HTML file. + """ + global nodes + + from_node = self.data.nodes[node['id']] + ns = [] + for n in node['neighborinfo']['neighbors']: + id = utils.convert_node_id_from_int_to_hex(n["node_id"]) + neighbor = { + "node_id": id, + "snr": n["snr"], + } + if id in self.data.nodes: + ni = self.data.nodes[id] + if from_node['position'] and ni['position']: + neighbor["distance"] = round(geo.distance_between_two_points( + from_node["position"]["latitude_i"] / 10000000, + from_node["position"]["longitude_i"] / 10000000, + ni["position"]["latitude_i"] / 10000000, + ni["position"]["longitude_i"] / 10000000 + ), 2) + ns.append(neighbor) + return ns + + def _serialize_position(self, position): + """ + Serialize a position object to a format suitable for saving to an HTML file. + """ + if "altitude" in position: + altitude = position["altitude"] + else: + altitude = None + + return { + "altitude": altitude, + "latitude": position["latitude_i"] / 10000000, + "longitude": position["longitude_i"] / 10000000 + } diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..fc1c9ac --- /dev/null +++ b/utils.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 + +from geo import distance_between_two_points + +def calculate_distance_between_nodes(node1, node2): + if node1 is None or node2 is None: + return None + if node1["position"] is None or node2["position"] is None: + return None + return round(distance_between_two_points( + node1["position"]["latitude_i"] / 10000000, + node1["position"]["longitude_i"] / 10000000, + node2["position"]["latitude_i"] / 10000000, + node2["position"]["longitude_i"] / 10000000 + ), 2) + +def convert_node_id_from_int_to_hex(id: int): + return f'{id:x}' + +def convert_node_id_from_hex_to_int(id: str): + if id.startswith('!'): + id = id.replace('!', '') + return int(id, 16) From 4363987ad136613eac9f6bb1845ef9dffde7da4f Mon Sep 17 00:00:00 2001 From: Kevin Elliott Date: Mon, 8 Jul 2024 16:35:45 -0700 Subject: [PATCH 03/20] update templates with new designs and support for refactor --- templates/static/chat.html.j2 | 62 ++++++------ templates/static/layout.html.j2 | 68 ++++++++------ templates/static/map.html.j2 | 2 +- templates/static/neighbors.html.j2 | 135 +++++++++++++++------------ templates/static/node.html.j2 | 6 +- templates/static/telemetry.html.j2 | 9 +- templates/static/traceroutes.html.j2 | 9 +- 7 files changed, 163 insertions(+), 128 deletions(-) diff --git a/templates/static/chat.html.j2 b/templates/static/chat.html.j2 index 60f1248..7a4a084 100644 --- a/templates/static/chat.html.j2 +++ b/templates/static/chat.html.j2 @@ -3,54 +3,64 @@ {% block title %}Chat{% endblock %} {% block content %} -

Chat

-

+

Chat
+

Chat

+

There are {{ chat['channels']['0']['messages']|count }} messages on channel 0 that have been heard by the mesh by KE-R (!4355f528).

-

Last updated: {{ timestamp.astimezone(zoneinfo).strftime('%Y-%m-%d %H:%M:%S %z') }}

-

Channel 0

- +

Channel 0

+
+ - - - - - - - + + + + + + + + + {% for message in chat['channels']['0']['messages'] %} {% set node_from = nodes[message.from] if message.from in nodes else None %} {% set node_sender = nodes[message.sender] if message.sender in nodes else None %} {% set node_to = nodes[message.to] if message.to in nodes else None %} - {% set distance_from_sender = calculate_distance_between_nodes(node_from, node_sender) if node_from and node_sender else None %} + {% set distance_from_sender = utils.calculate_distance_between_nodes(node_from, node_sender) if node_from and node_sender else None %} - - + - - - - + - + {% endfor %} +
TimeFromViaToHopsDXMessageTimeFromViaToHopsDXMessage
{{ datetime.fromtimestamp(message.timestamp).astimezone(zoneinfo).strftime('%Y-%m-%d %H:%M:%S %z') }} - + {{ datetime.fromtimestamp(message.timestamp).astimezone(zoneinfo).strftime('%Y-%m-%d %H:%M:%S %z') }} + {{ nodes[message.from].shortname if message.from in nodes else 'UNK' }} - + - + + {{ nodes[message.sender].shortname if message.sender in nodes else 'UNK' }} - + - + + {% if node_to and node_to.id != 'ffffffff' %} + {{ nodes[message.to].shortname if message.to in nodes else 'UNK' }} - + + {% else %} + ALL + {% endif %} {{ message.hops_away }} + {{ message.hops_away }} {% if distance_from_sender %} {{ distance_from_sender }} km {% endif %} {{ message.text }} + {{ message.text }} +
{% endblock %} diff --git a/templates/static/layout.html.j2 b/templates/static/layout.html.j2 index d9a4e39..3f300c5 100644 --- a/templates/static/layout.html.j2 +++ b/templates/static/layout.html.j2 @@ -7,7 +7,7 @@ @@ -38,81 +38,89 @@ {% endif %}
+ +
+
Data Updated
+
{{ timestamp.astimezone(zoneinfo).strftime('%Y-%m-%d %H:%M:%S %z') }}
+
+ +
+
Powered by MeshInfo
@@ -124,7 +132,7 @@
-
+
{% block content %}{% endblock %}
@@ -133,10 +141,10 @@ {% endblock %} diff --git a/templates/static/stats.html.j2 b/templates/static/stats.html.j2 index 77e0615..265b0be 100644 --- a/templates/static/stats.html.j2 +++ b/templates/static/stats.html.j2 @@ -3,62 +3,63 @@ {% block title %}Stats{% endblock %} {% block content %} -

Stats

-

- Some revelations based on messages that have - been heard by the mesh by KE-R (!4355f528). -

-

Last updated: {{ timestamp.astimezone(zoneinfo).strftime('%Y-%m-%d %H:%M:%S %z') }}

+
Stats
+

Stats

+

+ Some revelations based on messages that have + been heard by the mesh by KE-R (!4355f528). +

-

Current

+

Current

+
+

Active Nodes

-

Active Nodes

-
-
{{ stats.active_nodes }}
-
+
{{ stats.active_nodes }}
+
-

Persisted

-
+
+

Persisted

+

Known Nodes

{{ stats.total_nodes }}
-
+

Chat Messages

{{ stats.total_chat }}
-
+

Telemetry

{{ stats.total_telemetry }}
-
+

Traceroutes

{{ stats.total_traceroutes }}
+
-

Since Last Restart

-
+
+

Since Last Restart

+

Messages (Session Total)

{{ stats.total_messages }}
- -
+

Messages (Session MQTT)

{{ stats.total_mqtt_messages }}
- - +
{% endblock %} diff --git a/templates/static/telemetry.html.j2 b/templates/static/telemetry.html.j2 index 152314c..3a22873 100644 --- a/templates/static/telemetry.html.j2 +++ b/templates/static/telemetry.html.j2 @@ -3,138 +3,169 @@ {% block title %}Telemetry{% endblock %} {% block content %} -
Telemetry
-

Telemetry

-

- Telemetry as seen by {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). -

+
Telemetry
+

Telemetry

+

+ Telemetry as seen by {{ nodes[config['server']['node_id']].shortname }} ({{ config['server']['node_id'] }}). +

- +
+ - - - - - - - - - - - - + + + + + + + + + + + + + + + + {% for item in telemetry[0:1000] %} + {% set inode = nodes[item.from] %} + + + + + + + + + + + + + - {% for item in telemetry[0:10000] %} - - - - - - - - - - - - - - {% endfor %} -
TimestampNodeAir Util TXBattery LevelChannel UtilizationUptimeVoltageCurrentBarometric PressureRelative HumidityTemperatureGas ResistanceTimestampNode + Air Util TX + + Channel Util + + Battery + Uptime + Voltage +
+ {{ datetime.fromtimestamp(item.timestamp).astimezone(zoneinfo) }} + + {% if inode %} + {{ inode.shortname }} + {% else %} + UNK + {% endif %} + + {% if item.payload.air_util_tx is defined %} + {{ item.payload.air_util_tx | round(2) }}% + {% endif %} + + {% if item.payload.channel_utilization is defined %} + {{ item.payload.channel_utilization | round(1) }}% + {% endif %} + + {% if item.payload.battery_level is defined %} + {{ item.payload.battery_level | round(2) }}% + {% endif %} + + {% if item.payload.uptime_seconds is defined %} + {{ item.payload.uptime_seconds }} + {% endif %} + + {% if item.payload.voltage is defined %} + {{ item.payload.voltage | round(2) }} V + {% endif %} + {% if item.payload.voltage_ch1 is defined and item.payload.voltage_ch2 is defined and item.payload.voltage_ch3 is defined %} + + + + + + + + + + + + + +
+ Ch1 + + {{ item.payload.voltage_ch1 | round(2) }} V
+
+ Ch2 + + {{ item.payload.voltage_ch2 | round(2) }} V
+
+ Ch3 + + {{ item.payload.voltage_ch3 | round(2) }} V +
+ {% endif %} +
{{ datetime.fromtimestamp(item.timestamp).astimezone(zoneinfo) }}{{ item.from }} - {% if item.payload.air_util_tx is defined %} - {{ item.payload.air_util_tx | round(2) }}% - {% endif %} - - {% if item.payload.battery_level is defined %} - {{ item.payload.battery_level | round(2) }}% - {% endif %} - - {% if item.payload.channel_utilization is defined %} - {{ item.payload.channel_utilization | round(1) }}% - {% endif %} - - {% if item.payload.uptime_seconds is defined %} - {{ item.payload.uptime_seconds }} - {% endif %} - - {% if item.payload.voltage is defined %} - {{ item.payload.voltage | round(2) }} V - {% endif %} - {% if item.payload.voltage_ch1 is defined and item.payload.voltage_ch2 is defined and item.payload.voltage_ch3 is defined %} - - - - - - - - - - - - - -
- Ch1 - - {{ item.payload.voltage_ch1 | round(2) }} V
-
- Ch2 - - {{ item.payload.voltage_ch2 | round(2) }} V
-
- Ch3 - - {{ item.payload.voltage_ch3 | round(2) }} V -
- {% endif %} -
- {% if item.payload.current is defined %} - {{ item.payload.current | round(2) }} mA - {% endif %} - {% if item.payload.current_ch1 is defined and item.payload.current_ch2 is defined and item.payload.current_ch3 is defined %} - - - - - - - - - - - - - -
- Ch1 - - {{ item.payload.current_ch1 | round(2) }} mA
-
- Ch2 - - {{ item.payload.current_ch2 | round(2) }} mA
-
- Ch3 - - {{ item.payload.current_ch3 | round(2) }} mA -
- {% endif %} -
- {% if item.payload.barometric_pressure is defined %} - {{ item.payload.barometric_pressure | round(2) }} hPa - {% endif %} - - {% if item.payload.relative_humidity is defined and item.payload.relative_humidity %} - {{ item.payload.relative_humidity | round(2) }}% - {% endif %} - - {% if item.payload.temperature is defined %} - {{ item.payload.temperature | round(2) }} °C - {% endif %} - - {% if item.payload.gas_resistance is defined %} - {{ item.payload.gas_resistance | round(2) }} Ohm - {% endif %} -
+ + {% endblock %} diff --git a/templates/static/traceroutes.html.j2 b/templates/static/traceroutes.html.j2 index a6e6b6e..9b35050 100644 --- a/templates/static/traceroutes.html.j2 +++ b/templates/static/traceroutes.html.j2 @@ -3,30 +3,60 @@ {% block title %}Traceroutes{% endblock %} {% block content %} -
Traceroutes
-

Traceroutes

-

- Traceroutes as seen by {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). -

+
Traceroutes
+

Traceroutes

+

+ Traceroutes as seen by {{ nodes[config['server']['node_id']].shortname }} ({{ config['server']['node_id'] }}). +

- +
+ - - - - - - + + + + + + + + {% for item in traceroutes %} - - - - - - + + + + + + {% endfor %} -
TimestampFromToHopsRouteRoute HopsTimestampFromToHopsRouteRoute Hops
{{ datetime.fromtimestamp(item.timestamp).astimezone(zoneinfo) }}{{ item.from }}{{ item.to }}{{ item.hops_away }}{{ item.route | join(' > ') | safe }}{{ item.route | length }}{{ datetime.fromtimestamp(item.timestamp).astimezone(zoneinfo) }} + {% set fnode = nodes[item.from] %} + {% if fnode %} + {{ fnode.shortname }} + {% else %} + UNK + {% endif %} + + {% set tnode = nodes[item.to] %} + {% if tnode %} + {{ tnode.shortname }} + {% else %} + UNK + {% endif %} + {{ item.hops_away }} + {% for hop in item.route_ids %} + {% set hnode = nodes[hop] %} + {% if hnode %} + {{ hnode.shortname }} + {% else %} + UNK + {% endif %} + {% if not loop.last %} + > + {% endif %} + {% endfor %} + {{ item.route | length }}
+ + {% endblock %} From b36c7686caab0093434e7fb0bc5585db10931ec7 Mon Sep 17 00:00:00 2001 From: Kevin Elliott Date: Tue, 9 Jul 2024 20:44:25 -0700 Subject: [PATCH 20/20] removed partial sidebar template as it was a mistake to include --- .../static/layout-partial-sidebar.html.j2 | 72 ------------------- 1 file changed, 72 deletions(-) delete mode 100644 templates/static/layout-partial-sidebar.html.j2 diff --git a/templates/static/layout-partial-sidebar.html.j2 b/templates/static/layout-partial-sidebar.html.j2 deleted file mode 100644 index 20d03c1..0000000 --- a/templates/static/layout-partial-sidebar.html.j2 +++ /dev/null @@ -1,72 +0,0 @@ - -