From 051d09dcaf90574aafa2d5a9de01128bef169a10 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 16 Sep 2025 13:13:12 +0200 Subject: [PATCH] Document Python and Ruby source modules (#94) --- data/__init__.py | 6 +++ data/mesh.py | 111 +++++++++++++++++++++++++++++++++++++++++------ web/app.rb | 52 ++++++++++++++++++++++ 3 files changed, 156 insertions(+), 13 deletions(-) diff --git a/data/__init__.py b/data/__init__.py index f5fed2d..ed6eda9 100644 --- a/data/__init__.py +++ b/data/__init__.py @@ -11,3 +11,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +"""Data utilities for the Potato Mesh synchronisation daemon. + +The ``data.mesh`` module exposes helpers for reading Meshtastic node and +message information before forwarding it to the accompanying web application. +""" diff --git a/data/mesh.py b/data/mesh.py index f2afb16..b737f09 100644 --- a/data/mesh.py +++ b/data/mesh.py @@ -14,6 +14,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Mesh daemon helpers for synchronising Meshtastic data. + +This module wraps the Meshtastic serial interface and exposes helper +functions that serialise nodes and text messages to JSON before forwarding +them to the accompanying web API. It also provides the long-running daemon +entry point that performs these synchronisation tasks. +""" + import dataclasses import json, os, time, threading, signal, urllib.request, urllib.error from collections.abc import Mapping @@ -33,7 +41,16 @@ API_TOKEN = os.environ.get("API_TOKEN", "") def _get(obj, key, default=None): - """Return value for key/attribute from dicts or objects.""" + """Return a key or attribute value from ``obj``. + + Args: + obj: Mapping or object containing the desired value. + key: Key or attribute name to look up. + default: Value returned when the key is missing. + + Returns: + The resolved value if present, otherwise ``default``. + """ if isinstance(obj, dict): return obj.get(key, default) return getattr(obj, key, default) @@ -41,6 +58,13 @@ def _get(obj, key, default=None): # --- HTTP helpers ------------------------------------------------------------- def _post_json(path: str, payload: dict): + """Send a JSON payload to the configured web API. + + Args: + path: API path relative to the configured ``INSTANCE``. + payload: Mapping serialised to JSON for the request body. + """ + if not INSTANCE: return url = f"{INSTANCE}{path}" @@ -60,7 +84,14 @@ def _post_json(path: str, payload: dict): # --- Node upsert -------------------------------------------------------------- def _node_to_dict(n) -> dict: - """Convert Meshtastic node/user objects into plain dicts.""" + """Convert Meshtastic node or user structures into plain dictionaries. + + Args: + n: ``dict``, dataclass or protobuf message describing a node or user. + + Returns: + JSON serialisable representation of ``n``. + """ def _convert(value): """Recursively convert dataclasses and protobuf messages.""" @@ -90,6 +121,13 @@ def _node_to_dict(n) -> dict: def upsert_node(node_id, n): + """Forward a node snapshot to the web API. + + Args: + node_id: Unique identifier of the node in the mesh. + n: Node object obtained from the Meshtastic serial interface. + """ + ndict = _node_to_dict(n) _post_json("/api/nodes", {node_id: ndict}) @@ -101,6 +139,15 @@ def upsert_node(node_id, n): # --- Message logging via PubSub ----------------------------------------------- def _iso(ts: int | float) -> str: + """Return an ISO-8601 timestamp string for ``ts``. + + Args: + ts: POSIX timestamp as ``int`` or ``float``. + + Returns: + Timestamp formatted with a trailing ``Z`` to denote UTC. + """ + import datetime return ( @@ -111,11 +158,19 @@ def _iso(ts: int | float) -> str: def _first(d, *names, default=None): - """Return first non-empty key from names (supports nested 'a.b' lookups). + """Return the first non-empty key from ``names`` (supports nested lookups). Keys that resolve to ``None`` or an empty string are skipped so callers can provide multiple potential field names without accidentally capturing an explicit ``null`` value. + + Args: + d: Mapping or object to query. + *names: Candidate field names using dotted paths for nesting. + default: Value returned when all candidates are missing. + + Returns: + The first matching value or ``default`` if none resolve to content. """ def _mapping_get(obj, key): @@ -147,7 +202,14 @@ def _first(d, *names, default=None): def _pkt_to_dict(packet) -> dict: - """Convert protobuf MeshPacket or already-dict into a JSON-friendly dict.""" + """Normalise a received packet into a JSON-friendly dictionary. + + Args: + packet: Protobuf ``MeshPacket`` or dictionary received from the daemon. + + Returns: + Packet data ready for JSON serialisation. + """ if isinstance(packet, dict): return packet if isinstance(packet, ProtoMessage): @@ -162,9 +224,14 @@ def _pkt_to_dict(packet) -> dict: def store_packet_dict(p: dict): - """ - Store only TEXT messages (decoded.payload.text) by posting to the API. - Safe against snake/camel case differences. + """Persist text messages extracted from a decoded packet. + + Only packets from the ``TEXT_MESSAGE_APP`` port are forwarded to the + web API. Field lookups tolerate camelCase and snake_case variants for + compatibility across Meshtastic releases. + + Args: + p: Packet dictionary produced by ``_pkt_to_dict``. """ dec = p.get("decoded") or {} text = _first(dec, "payload.text", "text", default=None) @@ -229,6 +296,13 @@ def store_packet_dict(p: dict): # PubSub receive handler def on_receive(packet, interface): + """PubSub callback that stores inbound text messages. + + Args: + packet: Packet received from the Meshtastic interface. + interface: Serial interface instance (unused). + """ + p = None try: p = _pkt_to_dict(packet) @@ -240,13 +314,20 @@ def on_receive(packet, interface): # --- Main --------------------------------------------------------------------- def _node_items_snapshot(nodes_obj, retries: int = 3): - """Return a snapshot list of (node_id, node) pairs. + """Return a snapshot list of ``(node_id, node)`` pairs. - The SerialInterface updates ``iface.nodes`` from another thread. When that - happens while we iterate over the dictionary Python raises ``RuntimeError`` - because the dictionary changed size during iteration. To keep the daemon - quiet we retry a few times and, if it keeps changing, bail out for this loop - iteration. + The Meshtastic ``SerialInterface`` updates ``iface.nodes`` from another + thread. When that happens during iteration Python raises ``RuntimeError``. + To keep the daemon quiet we retry a few times and, if it keeps changing, + bail out for this loop. + + Args: + nodes_obj: Container mapping node IDs to node objects. + retries: Number of attempts performed before giving up. + + Returns: + Snapshot of node entries or ``None`` when retries were exhausted because + the container kept mutating. """ if not nodes_obj: @@ -278,6 +359,8 @@ def _node_items_snapshot(nodes_obj, retries: int = 3): def main(): + """Run the mesh synchronisation daemon.""" + # Subscribe to PubSub topics (reliable in current meshtastic) pub.subscribe(on_receive, "meshtastic.receive") @@ -286,6 +369,8 @@ def main(): stop = threading.Event() def handle_sig(*_): + """Stop the daemon when a termination signal is received.""" + stop.set() signal.signal(signal.SIGINT, handle_sig) diff --git a/web/app.rb b/web/app.rb index 2bed0eb..1ab7e23 100644 --- a/web/app.rb +++ b/web/app.rb @@ -13,6 +13,11 @@ # limitations under the License. # frozen_string_literal: true +# +# Main Sinatra application exposing the Meshtastic node and message archive. +# The daemon in +data/mesh.py+ pushes updates into the SQLite database that +# this web process reads from, providing JSON APIs and a rendered HTML index +# page for human visitors. require "sinatra" require "json" @@ -35,6 +40,9 @@ MAX_NODE_DISTANCE_KM = ENV.fetch("MAX_NODE_DISTANCE_KM", "137").to_f MATRIX_ROOM = ENV.fetch("MATRIX_ROOM", "#meshtastic-berlin:matrix.org") DEBUG = ENV["DEBUG"] == "1" +# Checks whether the SQLite database already contains the required tables. +# +# @return [Boolean] true when both +nodes+ and +messages+ tables exist. def db_schema_present? return false unless File.exist?(DB_PATH) db = SQLite3::Database.new(DB_PATH, readonly: true) @@ -46,6 +54,9 @@ ensure db&.close end +# Create the SQLite database and seed it with the node and message schemas. +# +# @return [void] def init_db FileUtils.mkdir_p(File.dirname(DB_PATH)) db = SQLite3::Database.new(DB_PATH) @@ -59,6 +70,10 @@ end init_db unless db_schema_present? +# Retrieve recently heard nodes ordered by their last contact time. +# +# @param limit [Integer] maximum number of rows returned. +# @return [Array] collection of node records formatted for the API. def query_nodes(limit) db = SQLite3::Database.new(DB_PATH, readonly: true, results_as_hash: true) now = Time.now.to_i @@ -89,12 +104,19 @@ ensure db&.close end +# GET /api/nodes +# +# Returns a JSON array of the most recently heard nodes. get "/api/nodes" do content_type :json limit = [params["limit"]&.to_i || 200, 1000].min query_nodes(limit).to_json end +# Retrieve recent text messages joined with related node information. +# +# @param limit [Integer] maximum number of rows returned. +# @return [Array] collection of message rows suitable for serialisation. def query_messages(limit) db = SQLite3::Database.new(DB_PATH, readonly: true) db.results_as_hash = true @@ -134,12 +156,20 @@ ensure db&.close end +# GET /api/messages +# +# Returns a JSON array of stored text messages including node metadata. get "/api/messages" do content_type :json limit = [params["limit"]&.to_i || 200, 1000].min query_messages(limit).to_json end +# Insert or update a node row with the most recent metrics. +# +# @param db [SQLite3::Database] open database handle. +# @param node_id [String] primary identifier for the node. +# @param n [Hash] node payload provided by the data daemon. def upsert_node(db, node_id, n) user = n["user"] || {} met = n["deviceMetrics"] || {} @@ -201,12 +231,20 @@ def upsert_node(db, node_id, n) SQL end +# Ensure the request includes the expected bearer token. +# +# @return [void] +# @raise [Sinatra::Halt] when authentication fails. def require_token! token = ENV["API_TOKEN"] provided = request.env["HTTP_AUTHORIZATION"].to_s.sub(/^Bearer\s+/i, "") halt 403, { error: "Forbidden" }.to_json unless token && !token.empty? && provided == token end +# Insert a text message if it does not already exist. +# +# @param db [SQLite3::Database] open database handle. +# @param m [Hash] message payload provided by the data daemon. def insert_message(db, m) msg_id = m["id"] || m["packet_id"] return unless msg_id @@ -234,6 +272,11 @@ def insert_message(db, m) SQL end +# Resolve a node reference to the canonical node ID when possible. +# +# @param db [SQLite3::Database] open database handle. +# @param node_ref [Object] raw node identifier or numeric reference. +# @return [String, nil] canonical node ID or nil if it cannot be resolved. def normalize_node_id(db, node_ref) return nil if node_ref.nil? ref_str = node_ref.to_s.strip @@ -251,6 +294,9 @@ def normalize_node_id(db, node_ref) db.get_first_value("SELECT node_id FROM nodes WHERE num = ?", [ref_num]) end +# POST /api/nodes +# +# Upserts one or more nodes provided as a JSON object keyed by node ID. post "/api/nodes" do require_token! content_type :json @@ -269,6 +315,9 @@ ensure db&.close end +# POST /api/messages +# +# Accepts an array or object describing text messages and stores each entry. post "/api/messages" do require_token! content_type :json @@ -288,6 +337,9 @@ ensure db&.close end +# GET / +# +# Renders the main site with configuration-driven defaults for the template. get "/" do erb :index, locals: { site_name: SITE_NAME,