mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-07 09:22:53 +02:00
Document Python and Ruby source modules (#94)
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user