Document Python and Ruby source modules (#94)

This commit is contained in:
l5y
2025-09-16 13:13:12 +02:00
committed by GitHub
parent eb900aecb6
commit 051d09dcaf
3 changed files with 156 additions and 13 deletions
+6
View File
@@ -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.
"""
+98 -13
View File
@@ -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)
+52
View File
@@ -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<Hash>] 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<Hash>] 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,