mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
* feat: Mesh-Ingestor: Ability to provide already-existing interface instance * Prevent Signal-Registration if not main thread (causes exception) * fix redundant ternary operator --------- Co-authored-by: Ken Ahr <ken.a.iphone@googlemail.com>
521 lines
19 KiB
Python
521 lines
19 KiB
Python
# Copyright (C) 2025 l5yth
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# 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.
|
|
|
|
"""Runtime entry point for the mesh ingestor."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import signal
|
|
import threading
|
|
import time
|
|
|
|
from pubsub import pub
|
|
|
|
from . import config, handlers, interfaces
|
|
|
|
_RECEIVE_TOPICS = (
|
|
"meshtastic.receive",
|
|
"meshtastic.receive.text",
|
|
"meshtastic.receive.position",
|
|
"meshtastic.receive.user",
|
|
"meshtastic.receive.POSITION_APP",
|
|
"meshtastic.receive.NODEINFO_APP",
|
|
"meshtastic.receive.NEIGHBORINFO_APP",
|
|
"meshtastic.receive.TEXT_MESSAGE_APP",
|
|
"meshtastic.receive.TELEMETRY_APP",
|
|
)
|
|
|
|
|
|
def _event_wait_allows_default_timeout() -> bool:
|
|
"""Return ``True`` when :meth:`threading.Event.wait` accepts ``timeout``.
|
|
|
|
The behaviour changed between Python versions; this helper shields the
|
|
daemon from ``TypeError`` when the default timeout parameter is absent.
|
|
"""
|
|
|
|
try:
|
|
wait_signature = inspect.signature(threading.Event.wait)
|
|
except (TypeError, ValueError): # pragma: no cover
|
|
return True
|
|
|
|
parameters = list(wait_signature.parameters.values())
|
|
if len(parameters) <= 1:
|
|
return True
|
|
|
|
timeout_parameter = parameters[1]
|
|
if timeout_parameter.kind in (
|
|
inspect.Parameter.VAR_POSITIONAL,
|
|
inspect.Parameter.VAR_KEYWORD,
|
|
):
|
|
return True
|
|
|
|
return timeout_parameter.default is not inspect._empty
|
|
|
|
|
|
def _subscribe_receive_topics() -> list[str]:
|
|
"""Subscribe the packet handler to all receive-related pubsub topics."""
|
|
|
|
subscribed = []
|
|
for topic in _RECEIVE_TOPICS:
|
|
try:
|
|
pub.subscribe(handlers.on_receive, topic)
|
|
subscribed.append(topic)
|
|
except Exception as exc: # pragma: no cover
|
|
config._debug_log(f"failed to subscribe to {topic!r}: {exc}")
|
|
return subscribed
|
|
|
|
|
|
def _node_items_snapshot(
|
|
nodes_obj, retries: int = 3
|
|
) -> list[tuple[str, object]] | None:
|
|
"""Snapshot ``nodes_obj`` to avoid iteration errors during updates.
|
|
|
|
Parameters:
|
|
nodes_obj: Meshtastic nodes mapping or iterable.
|
|
retries: Number of attempts when encountering "dictionary changed"
|
|
runtime errors.
|
|
|
|
Returns:
|
|
A list of ``(node_id, node)`` tuples, ``None`` when retries are
|
|
exhausted, or an empty list when no nodes exist.
|
|
"""
|
|
|
|
if not nodes_obj:
|
|
return []
|
|
|
|
items_callable = getattr(nodes_obj, "items", None)
|
|
if callable(items_callable):
|
|
for _ in range(max(1, retries)):
|
|
try:
|
|
return list(items_callable())
|
|
except RuntimeError as err:
|
|
if "dictionary changed size during iteration" not in str(err):
|
|
raise
|
|
time.sleep(0)
|
|
return None
|
|
|
|
if hasattr(nodes_obj, "__iter__") and hasattr(nodes_obj, "__getitem__"):
|
|
for _ in range(max(1, retries)):
|
|
try:
|
|
keys = list(nodes_obj)
|
|
return [(key, nodes_obj[key]) for key in keys]
|
|
except RuntimeError as err:
|
|
if "dictionary changed size during iteration" not in str(err):
|
|
raise
|
|
time.sleep(0)
|
|
return None
|
|
|
|
return []
|
|
|
|
|
|
def _close_interface(iface_obj) -> None:
|
|
"""Close ``iface_obj`` while respecting configured timeouts."""
|
|
|
|
if iface_obj is None:
|
|
return
|
|
|
|
def _do_close() -> None:
|
|
try:
|
|
iface_obj.close()
|
|
except Exception as exc: # pragma: no cover
|
|
if config.DEBUG:
|
|
config._debug_log(
|
|
"Error closing mesh interface",
|
|
context="daemon.close",
|
|
severity="warn",
|
|
error_class=exc.__class__.__name__,
|
|
error_message=str(exc),
|
|
)
|
|
|
|
if config._CLOSE_TIMEOUT_SECS <= 0 or not _event_wait_allows_default_timeout():
|
|
_do_close()
|
|
return
|
|
|
|
close_thread = threading.Thread(target=_do_close, name="mesh-close", daemon=True)
|
|
close_thread.start()
|
|
close_thread.join(config._CLOSE_TIMEOUT_SECS)
|
|
if close_thread.is_alive():
|
|
config._debug_log(
|
|
"Mesh interface close timed out",
|
|
context="daemon.close",
|
|
severity="warn",
|
|
timeout_seconds=config._CLOSE_TIMEOUT_SECS,
|
|
)
|
|
|
|
|
|
def _is_ble_interface(iface_obj) -> bool:
|
|
"""Return ``True`` when ``iface_obj`` appears to be a BLE interface."""
|
|
|
|
if iface_obj is None:
|
|
return False
|
|
iface_cls = getattr(iface_obj, "__class__", None)
|
|
if iface_cls is None:
|
|
return False
|
|
module_name = getattr(iface_cls, "__module__", "") or ""
|
|
return "ble_interface" in module_name
|
|
|
|
|
|
def _connected_state(candidate) -> bool | None:
|
|
"""Return the connection state advertised by ``candidate``.
|
|
|
|
Parameters:
|
|
candidate: Attribute returned from ``iface.isConnected`` on a
|
|
Meshtastic interface. The value may be a boolean, a callable that
|
|
yields a boolean, or a :class:`threading.Event` instance.
|
|
|
|
Returns:
|
|
``True`` when the interface is believed to be connected, ``False``
|
|
when it appears disconnected, and ``None`` when the state cannot be
|
|
determined from the provided attribute.
|
|
"""
|
|
|
|
if candidate is None:
|
|
return None
|
|
|
|
if isinstance(candidate, threading.Event):
|
|
return candidate.is_set()
|
|
|
|
is_set_method = getattr(candidate, "is_set", None)
|
|
if callable(is_set_method):
|
|
try:
|
|
return bool(is_set_method())
|
|
except Exception:
|
|
return None
|
|
|
|
if callable(candidate):
|
|
try:
|
|
return bool(candidate())
|
|
except Exception:
|
|
return None
|
|
|
|
try:
|
|
return bool(candidate)
|
|
except Exception: # pragma: no cover - defensive guard
|
|
return None
|
|
|
|
|
|
def main(existing_interface=None) -> None:
|
|
"""Run the mesh ingestion daemon until interrupted."""
|
|
|
|
subscribed = _subscribe_receive_topics()
|
|
if subscribed:
|
|
config._debug_log(
|
|
"Subscribed to receive topics",
|
|
context="daemon.subscribe",
|
|
severity="info",
|
|
topics=subscribed,
|
|
)
|
|
|
|
iface = existing_interface
|
|
resolved_target = None
|
|
retry_delay = max(0.0, config._RECONNECT_INITIAL_DELAY_SECS)
|
|
|
|
stop = threading.Event()
|
|
initial_snapshot_sent = False
|
|
energy_session_deadline = None
|
|
iface_connected_at: float | None = None
|
|
last_seen_packet_monotonic = handlers.last_packet_monotonic()
|
|
last_inactivity_reconnect: float | None = None
|
|
inactivity_reconnect_secs = max(
|
|
0.0, getattr(config, "_INACTIVITY_RECONNECT_SECS", 0.0)
|
|
)
|
|
|
|
energy_saving_enabled = config.ENERGY_SAVING
|
|
energy_online_secs = max(0.0, config._ENERGY_ONLINE_DURATION_SECS)
|
|
energy_sleep_secs = max(0.0, config._ENERGY_SLEEP_SECS)
|
|
|
|
def _energy_sleep(reason: str) -> None:
|
|
if not energy_saving_enabled or energy_sleep_secs <= 0:
|
|
return
|
|
if config.DEBUG:
|
|
config._debug_log(
|
|
f"energy saving: {reason}; sleeping for {energy_sleep_secs:g}s"
|
|
)
|
|
stop.wait(energy_sleep_secs)
|
|
|
|
def handle_sigterm(*_args) -> None:
|
|
stop.set()
|
|
|
|
def handle_sigint(signum, frame) -> None:
|
|
if stop.is_set():
|
|
signal.default_int_handler(signum, frame)
|
|
return
|
|
stop.set()
|
|
|
|
if threading.current_thread() == threading.main_thread():
|
|
signal.signal(signal.SIGINT, handle_sigint)
|
|
signal.signal(signal.SIGTERM, handle_sigterm)
|
|
|
|
target = config.INSTANCE or "(no POTATOMESH_INSTANCE)"
|
|
configured_port = config.CONNECTION
|
|
active_candidate = configured_port
|
|
announced_target = False
|
|
config._debug_log(
|
|
"Mesh daemon starting",
|
|
context="daemon.main",
|
|
severity="info",
|
|
target=target,
|
|
port=configured_port or "auto",
|
|
channel=config.CHANNEL_INDEX,
|
|
)
|
|
try:
|
|
while not stop.is_set():
|
|
if iface is None:
|
|
try:
|
|
if active_candidate:
|
|
iface, resolved_target = interfaces._create_serial_interface(
|
|
active_candidate
|
|
)
|
|
else:
|
|
iface, resolved_target = interfaces._create_default_interface()
|
|
active_candidate = resolved_target
|
|
interfaces._ensure_radio_metadata(iface)
|
|
interfaces._ensure_channel_metadata(iface)
|
|
retry_delay = max(0.0, config._RECONNECT_INITIAL_DELAY_SECS)
|
|
initial_snapshot_sent = False
|
|
if not announced_target and resolved_target:
|
|
config._debug_log(
|
|
"Using mesh interface",
|
|
context="daemon.interface",
|
|
severity="info",
|
|
target=resolved_target,
|
|
)
|
|
announced_target = True
|
|
if energy_saving_enabled and energy_online_secs > 0:
|
|
energy_session_deadline = time.monotonic() + energy_online_secs
|
|
else:
|
|
energy_session_deadline = None
|
|
iface_connected_at = time.monotonic()
|
|
# Seed the inactivity tracking from the connection time so a
|
|
# reconnect is given a full inactivity window even when the
|
|
# handler still reports the previous packet timestamp.
|
|
last_seen_packet_monotonic = iface_connected_at
|
|
last_inactivity_reconnect = None
|
|
except interfaces.NoAvailableMeshInterface as exc:
|
|
config._debug_log(
|
|
"No mesh interface available",
|
|
context="daemon.interface",
|
|
severity="error",
|
|
error_message=str(exc),
|
|
)
|
|
_close_interface(iface)
|
|
raise SystemExit(1) from exc
|
|
except Exception as exc:
|
|
candidate_desc = active_candidate or "auto"
|
|
config._debug_log(
|
|
"Failed to create mesh interface",
|
|
context="daemon.interface",
|
|
severity="warn",
|
|
candidate=candidate_desc,
|
|
error_class=exc.__class__.__name__,
|
|
error_message=str(exc),
|
|
)
|
|
if configured_port is None:
|
|
active_candidate = None
|
|
announced_target = False
|
|
stop.wait(retry_delay)
|
|
if config._RECONNECT_MAX_DELAY_SECS > 0:
|
|
retry_delay = min(
|
|
(
|
|
retry_delay * 2
|
|
if retry_delay
|
|
else config._RECONNECT_INITIAL_DELAY_SECS
|
|
),
|
|
config._RECONNECT_MAX_DELAY_SECS,
|
|
)
|
|
continue
|
|
|
|
if energy_saving_enabled and iface is not None:
|
|
if (
|
|
energy_session_deadline is not None
|
|
and time.monotonic() >= energy_session_deadline
|
|
):
|
|
config._debug_log(
|
|
"Energy saving disconnect",
|
|
context="daemon.energy",
|
|
severity="info",
|
|
)
|
|
_close_interface(iface)
|
|
iface = None
|
|
announced_target = False
|
|
initial_snapshot_sent = False
|
|
energy_session_deadline = None
|
|
_energy_sleep("disconnected after session")
|
|
continue
|
|
if (
|
|
_is_ble_interface(iface)
|
|
and getattr(iface, "client", object()) is None
|
|
):
|
|
config._debug_log(
|
|
"Energy saving BLE disconnect",
|
|
context="daemon.energy",
|
|
severity="info",
|
|
)
|
|
_close_interface(iface)
|
|
iface = None
|
|
announced_target = False
|
|
initial_snapshot_sent = False
|
|
energy_session_deadline = None
|
|
_energy_sleep("BLE client disconnected")
|
|
continue
|
|
|
|
if not initial_snapshot_sent:
|
|
try:
|
|
nodes = getattr(iface, "nodes", {}) or {}
|
|
node_items = _node_items_snapshot(nodes)
|
|
if node_items is None:
|
|
config._debug_log(
|
|
"Skipping node snapshot due to concurrent modification",
|
|
context="daemon.snapshot",
|
|
)
|
|
else:
|
|
processed_snapshot_item = False
|
|
for node_id, node in node_items:
|
|
processed_snapshot_item = True
|
|
try:
|
|
handlers.upsert_node(node_id, node)
|
|
except Exception as exc:
|
|
config._debug_log(
|
|
"Failed to update node snapshot",
|
|
context="daemon.snapshot",
|
|
severity="warn",
|
|
node_id=node_id,
|
|
error_class=exc.__class__.__name__,
|
|
error_message=str(exc),
|
|
)
|
|
if config.DEBUG:
|
|
config._debug_log(
|
|
"Snapshot node payload",
|
|
context="daemon.snapshot",
|
|
node=node,
|
|
)
|
|
if processed_snapshot_item:
|
|
initial_snapshot_sent = True
|
|
except Exception as exc:
|
|
config._debug_log(
|
|
"Snapshot refresh failed",
|
|
context="daemon.snapshot",
|
|
severity="warn",
|
|
error_class=exc.__class__.__name__,
|
|
error_message=str(exc),
|
|
)
|
|
_close_interface(iface)
|
|
iface = None
|
|
stop.wait(retry_delay)
|
|
if config._RECONNECT_MAX_DELAY_SECS > 0:
|
|
retry_delay = min(
|
|
(
|
|
retry_delay * 2
|
|
if retry_delay
|
|
else config._RECONNECT_INITIAL_DELAY_SECS
|
|
),
|
|
config._RECONNECT_MAX_DELAY_SECS,
|
|
)
|
|
continue
|
|
|
|
if iface is not None and inactivity_reconnect_secs > 0:
|
|
now_monotonic = time.monotonic()
|
|
iface_activity = handlers.last_packet_monotonic()
|
|
if (
|
|
iface_activity is not None
|
|
and iface_connected_at is not None
|
|
and iface_activity < iface_connected_at
|
|
):
|
|
iface_activity = iface_connected_at
|
|
if iface_activity is not None and (
|
|
last_seen_packet_monotonic is None
|
|
or iface_activity > last_seen_packet_monotonic
|
|
):
|
|
last_seen_packet_monotonic = iface_activity
|
|
last_inactivity_reconnect = None
|
|
|
|
latest_activity = iface_activity
|
|
if latest_activity is None and iface_connected_at is not None:
|
|
latest_activity = iface_connected_at
|
|
if latest_activity is None:
|
|
latest_activity = now_monotonic
|
|
|
|
inactivity_elapsed = now_monotonic - latest_activity
|
|
|
|
connected_attr = getattr(iface, "isConnected", None)
|
|
believed_disconnected = False
|
|
connected_state = _connected_state(connected_attr)
|
|
if connected_state is None:
|
|
if callable(connected_attr):
|
|
try:
|
|
believed_disconnected = not bool(connected_attr())
|
|
except Exception:
|
|
believed_disconnected = False
|
|
elif connected_attr is not None:
|
|
try:
|
|
believed_disconnected = not bool(connected_attr)
|
|
except Exception: # pragma: no cover - defensive guard
|
|
believed_disconnected = False
|
|
else:
|
|
believed_disconnected = not connected_state
|
|
|
|
should_reconnect = believed_disconnected or (
|
|
inactivity_elapsed >= inactivity_reconnect_secs
|
|
)
|
|
|
|
if should_reconnect:
|
|
if (
|
|
last_inactivity_reconnect is None
|
|
or now_monotonic - last_inactivity_reconnect
|
|
>= inactivity_reconnect_secs
|
|
):
|
|
reason = (
|
|
"disconnected"
|
|
if believed_disconnected
|
|
else f"no data for {inactivity_elapsed:.0f}s"
|
|
)
|
|
config._debug_log(
|
|
"Mesh interface inactivity detected",
|
|
context="daemon.interface",
|
|
severity="warn",
|
|
reason=reason,
|
|
)
|
|
last_inactivity_reconnect = now_monotonic
|
|
_close_interface(iface)
|
|
iface = None
|
|
announced_target = False
|
|
initial_snapshot_sent = False
|
|
energy_session_deadline = None
|
|
iface_connected_at = None
|
|
continue
|
|
|
|
retry_delay = max(0.0, config._RECONNECT_INITIAL_DELAY_SECS)
|
|
stop.wait(config.SNAPSHOT_SECS)
|
|
except KeyboardInterrupt: # pragma: no cover - interactive only
|
|
config._debug_log(
|
|
"Received KeyboardInterrupt; shutting down",
|
|
context="daemon.main",
|
|
severity="info",
|
|
)
|
|
stop.set()
|
|
finally:
|
|
_close_interface(iface)
|
|
|
|
|
|
__all__ = [
|
|
"_RECEIVE_TOPICS",
|
|
"_event_wait_allows_default_timeout",
|
|
"_node_items_snapshot",
|
|
"_subscribe_receive_topics",
|
|
"_is_ble_interface",
|
|
"_connected_state",
|
|
"main",
|
|
]
|