From a47be6317bba7a2458688de05624b2391f755b1c Mon Sep 17 00:00:00 2001 From: Cameron Will Date: Sun, 9 Aug 2026 14:15:39 -0400 Subject: [PATCH 1/4] chore: Migrate from pymc-core to openhop-core Upstream renamed both the GitHub repo (rightup/pyMC_core -> openhop-dev/openhop_core) and the PyPI distribution (pymc-core -> openhop-core). pymc-core is frozen at 1.0.12; all further releases land under the new name, starting at 1.1.1. The rename is near-mechanical: comparing the 1.0.12 and 1.1.1 wheels, the module tree is identical apart from three new files, SX1262Radio has an unchanged __init__ signature, and protocol/identity.py is byte-identical. The [hardware] extra survives the rename. Renamed alongside the imports: - PyMCCoreSession -> OpenHopCoreSession - MockPyMCCoreSession -> MockOpenHopCoreSession - import_pymc_core -> import_openhop_core - require_pymc -> require_openhop Also fixes a dead entry in _RADIO_LOGGER_SUBSTRINGS: the "pyMC" substring never matched anything, since the library's module loggers were named "pymc_core.*" (lowercase) and no logger was literally named "pyMC". It is now "openhop_core", which does match. CHANGELOG entries are left referring to pymc-core, as they were accurate when written. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 10 ++-- README.md | 4 +- pyproject.toml | 6 +-- src/meshcore_console/core/enums.py | 2 +- src/meshcore_console/core/packets.py | 2 +- src/meshcore_console/core/types.py | 22 ++++---- src/meshcore_console/meshcore/__init__.py | 4 +- src/meshcore_console/meshcore/cayenne_lpp.py | 4 +- src/meshcore_console/meshcore/channel_db.py | 4 +- src/meshcore_console/meshcore/client.py | 48 +++++++++-------- src/meshcore_console/meshcore/contact_book.py | 10 ++-- src/meshcore_console/meshcore/db.py | 2 +- src/meshcore_console/meshcore/event_bridge.py | 2 +- .../meshcore/logging_setup.py | 2 +- src/meshcore_console/meshcore/operations.py | 14 ++--- src/meshcore_console/meshcore/packet_codec.py | 26 +++++----- src/meshcore_console/meshcore/runtime.py | 14 ++--- src/meshcore_console/meshcore/session.py | 42 +++++++-------- src/meshcore_console/mock/__init__.py | 4 +- src/meshcore_console/mock/client.py | 6 +-- src/meshcore_console/mock/data.py | 2 +- src/meshcore_console/mock/session.py | 10 ++-- src/meshcore_console/platform/conflicts.py | 2 +- src/meshcore_console/radio_cli.py | 18 +++---- tests/integration/test_settings_apply.py | 6 +-- tests/unit/test_channel_secrets.py | 14 ++--- tests/unit/test_contact_book.py | 10 ++-- uv.lock | 52 +++++++++---------- 28 files changed, 172 insertions(+), 170 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 72d9d04..d2d32cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,9 +8,9 @@ GTK4/Libadwaita mesh radio console application targeting Raspberry Pi (uConsole **Target hardware:** Raspberry Pi CM4 in uConsole, 1280x720 display, Wayland compositor. -**Stack:** Python 3.11+, GTK4, Libadwaita, PyGObject, pyMC_core (radio driver). +**Stack:** Python 3.11+, GTK4, Libadwaita, PyGObject, openhop_core (radio driver). -**pyMC_core API reference:** https://rightup.github.io/pyMC_core/api/core/ +**openhop_core API reference:** https://openhop-dev.github.io/openhop_core/api/core/ ## Repository Layout @@ -19,7 +19,7 @@ src/meshcore_console/ app.py # GTK application composition root main.py # Console entrypoint core/ # Domain models + service interfaces - meshcore/ # pyMC_core integration adapter (event_bridge, packet_codec) + meshcore/ # openhop_core integration adapter (event_bridge, packet_codec) platform/ # Platform helpers (GPIO/SPI/device info) ui_gtk/ # GTK views, windows, widgets, CSS views/ # Main UI panels (analyzer, messages, settings, etc.) @@ -38,7 +38,7 @@ packaging/deb/ # Debian package metadata ``` Radio Hardware ↓ -pyMC_core dispatcher callbacks +openhop_core dispatcher callbacks ↓ event_bridge.py (attach_dispatcher_callbacks) ↓ @@ -192,7 +192,7 @@ uv sync 5. **Wayland-specific issues** - Test on actual Pi hardware. Some behaviors differ between XWayland (macOS) and native Wayland. -6. **pyMC_core API calls** - pyMC_core is a known dependency. Call its APIs directly without defensive `getattr`/`hasattr` fallbacks or manual reimplementations. If a pyMC_core method exists (e.g. `packet.get_raw_length()`), call it and let exceptions propagate naturally. Do not duplicate its logic as a fallback — if the API breaks, we want to know immediately, not silently use a stale copy. +6. **openhop_core API calls** - openhop_core is a known dependency. Call its APIs directly without defensive `getattr`/`hasattr` fallbacks or manual reimplementations. If an openhop_core method exists (e.g. `packet.get_raw_length()`), call it and let exceptions propagate naturally. Do not duplicate its logic as a fallback — if the API breaks, we want to know immediately, not silently use a stale copy. ## UI Framework Assessment diff --git a/README.md b/README.md index 1f8c573..1f902c6 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ A GTK-based native desktop application for interacting with [MeshCore](https://meshcore.co.uk/) radios on Raspberry Pi. Supports all -hardware that [pyMC-core](https://github.com/rightup/pyMC_core) supports, +hardware that [openhop-core](https://github.com/openhop-dev/openhop_core) supports, including the [HackerGadgets AIO](https://hackergadgets.com/products/uconsole-aio-v2) (uConsole), Waveshare LoRa HATs, and meshadv-mini boards. Built-in hardware presets let you switch between boards without manually configuring every pin. Inspired by [YAMPA](https://github.com/guax/YAMPA), and built on top of the -great [pyMC-core](https://github.com/rightup/pyMC_core) library. +great [openhop-core](https://github.com/openhop-dev/openhop_core) library. You can run a Mock version of the application on anything that supports Nix, and then you can run the real application on the uConsole either by cloning the repo diff --git a/pyproject.toml b/pyproject.toml index f5871b8..18c5f50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,8 @@ readme = "README.md" license = "MIT" requires-python = ">=3.11" dependencies = [ - "pymc-core>=1.0.10", - "pymc-core[hardware]>=1.0.10; platform_system == 'Linux'", + "openhop-core>=1.1.1", + "openhop-core[hardware]>=1.1.1; platform_system == 'Linux'", "pynmea2>=1.18.0", "segno>=1.6.0", # QR code generation "gpsdclient>=1.3", @@ -77,7 +77,7 @@ no_implicit_optional = true check_untyped_defs = true # PyGObject stubs are incomplete, ignore missing imports for gi [[tool.mypy.overrides]] -module = ["gi.*", "pynmea2", "serial", "RPi.*", "pymc_core.*", "spidev", "gpsdclient"] +module = ["gi.*", "pynmea2", "serial", "RPi.*", "openhop_core.*", "spidev", "gpsdclient"] ignore_missing_imports = true [tool.commitizen] diff --git a/src/meshcore_console/core/enums.py b/src/meshcore_console/core/enums.py index 8ce3881..1a7a46f 100644 --- a/src/meshcore_console/core/enums.py +++ b/src/meshcore_console/core/enums.py @@ -6,7 +6,7 @@ class PayloadType(StrEnum): """Payload types for mesh packets. - From pyMC_core protocol: + From openhop_core protocol: 0: REQ - Request 1: RESPONSE - Response to REQ or ANON_REQ 2: TXT_MSG - Plain text message (encrypted) diff --git a/src/meshcore_console/core/packets.py b/src/meshcore_console/core/packets.py index 3937c41..44a73c3 100644 --- a/src/meshcore_console/core/packets.py +++ b/src/meshcore_console/core/packets.py @@ -278,7 +278,7 @@ class UnknownHandler(PacketTypeHandler): _BY_NAME: dict[str, PacketTypeHandler] = {h.name.value: h for h in _ALL_HANDLERS} -# Numeric payload type -> handler (from pyMC_core protocol) +# Numeric payload type -> handler (from openhop_core protocol) _NUMERIC_MAP: dict[int, PacketTypeHandler] = { 0: _BY_NAME["REQ"], 1: _BY_NAME["RESPONSE"], diff --git a/src/meshcore_console/core/types.py b/src/meshcore_console/core/types.py index 5c3a00b..7b05ee5 100644 --- a/src/meshcore_console/core/types.py +++ b/src/meshcore_console/core/types.py @@ -1,7 +1,7 @@ """Type definitions for meshcore_console. This module provides TypedDicts for packet/event data and Protocol stubs -for pyMC_core types to enable static typing without runtime dependencies. +for openhop_core types to enable static typing without runtime dependencies. """ from __future__ import annotations @@ -87,21 +87,21 @@ class SessionStatus: connected: bool node_name: str board: str - pymc_core_version: str + openhop_core_version: str SessionStatusDict = dict[str, Any] # ============================================================================= -# Protocol stubs for pyMC_core types +# Protocol stubs for openhop_core types # ============================================================================= -# These protocols define the interface we use from pyMC_core without +# These protocols define the interface we use from openhop_core without # requiring the actual library to be installed (for mock mode, macOS dev). class SX1262RadioProtocol(Protocol): - """Protocol for pyMC_core SX1262Radio.""" + """Protocol for openhop_core SX1262Radio.""" def begin(self) -> bool: """Initialize the radio hardware. Returns True on success.""" @@ -113,7 +113,7 @@ def cleanup(self) -> None: class LocalIdentityProtocol(Protocol): - """Protocol for pyMC_core LocalIdentity. + """Protocol for openhop_core LocalIdentity. Opaque handle representing the node's identity. """ @@ -122,7 +122,7 @@ class LocalIdentityProtocol(Protocol): class DispatcherProtocol(Protocol): - """Protocol for pyMC_core dispatcher.""" + """Protocol for openhop_core dispatcher.""" protocol_response_handler: Any @@ -138,9 +138,9 @@ async def send_packet( class MeshNodeProtocol(Protocol): - """Protocol for pyMC_core MeshNode. + """Protocol for openhop_core MeshNode. - As of pyMC_core 1.0.10, MeshNode exposes low-level primitives + MeshNode exposes low-level primitives (send_packet, dispatcher, identity, contacts, etc.) and packet construction is done via PacketBuilder. """ @@ -165,13 +165,13 @@ def stop(self) -> object: class EventSubscriberProtocol(Protocol): - """Protocol for pyMC_core EventSubscriber.""" + """Protocol for openhop_core EventSubscriber.""" pass class EventServiceProtocol(Protocol): - """Protocol for pyMC_core EventService.""" + """Protocol for openhop_core EventService.""" def subscribe_all(self, subscriber: EventSubscriberProtocol) -> None: """Subscribe to all events.""" diff --git a/src/meshcore_console/meshcore/__init__.py b/src/meshcore_console/meshcore/__init__.py index 8e99511..77d3ab6 100644 --- a/src/meshcore_console/meshcore/__init__.py +++ b/src/meshcore_console/meshcore/__init__.py @@ -12,11 +12,11 @@ apply_hardware_preset, apply_preset, ) -from .session import PyMCCoreSession +from .session import OpenHopCoreSession __all__ = [ "MeshcoreClient", - "PyMCCoreSession", + "OpenHopCoreSession", "HardwareRadioConfig", "RuntimeRadioConfig", "MeshcoreSettings", diff --git a/src/meshcore_console/meshcore/cayenne_lpp.py b/src/meshcore_console/meshcore/cayenne_lpp.py index 7e07b74..5999a49 100644 --- a/src/meshcore_console/meshcore/cayenne_lpp.py +++ b/src/meshcore_console/meshcore/cayenne_lpp.py @@ -1,7 +1,7 @@ """CayenneLPP encoder/decoder for telemetry payloads. Uses the pycayennelpp library for encoding/decoding, and provides -``decode_cayenne_lpp_payload`` matching the interface pymc_core's +``decode_cayenne_lpp_payload`` matching the interface openhop_core's ProtocolResponseHandler expects from ``utils.cayenne_lpp_helpers``. """ @@ -37,7 +37,7 @@ def encode_telemetry( def decode_cayenne_lpp_payload(hex_string: str) -> dict: """Decode a CayenneLPP hex payload into structured sensor data. - Matches the signature expected by pymc_core's + Matches the signature expected by openhop_core's ``utils.cayenne_lpp_helpers.decode_cayenne_lpp_payload``. Returns: diff --git a/src/meshcore_console/meshcore/channel_db.py b/src/meshcore_console/meshcore/channel_db.py index 1f21daf..1126fbb 100644 --- a/src/meshcore_console/meshcore/channel_db.py +++ b/src/meshcore_console/meshcore/channel_db.py @@ -75,7 +75,7 @@ def ensure_channel_secret(self, name: str) -> str: def resolve_name(self, name: str) -> str | None: """Return the stored name for a channel, matched case-insensitively. - pyMC_core matches ``channels_config`` entries by exact name, so senders + openhop_core matches ``channels_config`` entries by exact name, so senders must use the name as stored here ("Public"), not whatever casing the UI happens to display. """ @@ -108,7 +108,7 @@ def remove_derived_secret(self, name: str) -> bool: return True def get_channels(self) -> list[dict[str, str]]: - """Return channels in the format expected by pymc_core GroupTextHandler.""" + """Return channels in the format expected by openhop_core GroupTextHandler.""" rows = self._conn.execute("SELECT name, secret FROM channel_secrets").fetchall() return [{"name": row[0], "secret": row[1]} for row in rows] diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index bf0b567..62c2016 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -20,7 +20,7 @@ from meshcore_console.meshcore.packet_codec import repair_utf8 from meshcore_console.meshcore.packet_store import PacketStore from meshcore_console.meshcore.repeater_store import RepeaterPasswordStore -from meshcore_console.meshcore.session import PyMCCoreSession +from meshcore_console.meshcore.session import OpenHopCoreSession from meshcore_console.meshcore.settings import MeshcoreSettings from meshcore_console.meshcore.settings_store import SettingsStore from meshcore_console.meshcore.state_store import MessageStore, PeerStore, UIChannelStore @@ -39,14 +39,14 @@ def _propagate_routing_fields(packet_data: dict, handler_data: dict) -> None: class MeshcoreClient(MeshcoreService): - """pyMC_core-backed adapter for the UI layer.""" + """openhop_core-backed adapter for the UI layer.""" def __init__( self, node_name: str = "uconsole-node", - session: PyMCCoreSession | None = None, + session: OpenHopCoreSession | None = None, *, - require_pymc: bool = True, + require_openhop: bool = True, settings_store: SettingsStore | None = None, packet_store: PacketStore | None = None, message_store: MessageStore | None = None, @@ -91,22 +91,22 @@ def __init__( self._loop: asyncio.AbstractEventLoop | None = None self._loop_thread: threading.Thread | None = None - if require_pymc: + if require_openhop: try: - import pymc_core # noqa: F401 + import openhop_core # noqa: F401 - self._pymc_available = True + self._openhop_available = True except ImportError: - self._pymc_available = False + self._openhop_available = False else: - self._pymc_available = True + self._openhop_available = True self._radio_error_handler = install_radio_error_handler(self._on_radio_error) def _sync_channel_secrets_to_ui(self) -> None: """Ensure every channel secret has a corresponding UI channel entry.""" for row in self._channel_secrets.get_channels(): - original_name = row["name"] # Preserve original case for pyMC_core + original_name = row["name"] # Preserve original case for openhop_core channel_id = original_name.lower() if channel_id not in self._channels: channel = Channel( @@ -148,8 +148,10 @@ def _shutdown_loop(self) -> None: self._loop_thread = None def connect(self) -> None: - if not self._pymc_available: - raise RuntimeError("pyMC_core is not installed. Run in mock mode or install pyMC_core.") + if not self._openhop_available: + raise RuntimeError( + "openhop_core is not installed. Run in mock mode or install openhop_core." + ) runtime_connected = bool(self._session.status().get("connected")) if self._connected and runtime_connected: return @@ -157,7 +159,7 @@ def connect(self) -> None: self._connected = False # Pre-flight: detect conflicting services / busy hardware before - # touching pyMC_core (which calls sys.exit on GPIO failures). + # touching openhop_core (which calls sys.exit on GPIO failures). from meshcore_console.platform.conflicts import ConflictError, run_preflight_checks hardware = self._config.hardware @@ -169,7 +171,7 @@ def connect(self) -> None: try: self._run_async(self._session.start(), timeout=8.0) except SystemExit as exc: - # pyMC_core calls sys.exit() on fatal GPIO errors. Convert to + # openhop_core calls sys.exit() on fatal GPIO errors. Convert to # RuntimeError so the UI can show the failure instead of crashing. self._session = self._new_session() self._connected = False @@ -253,7 +255,7 @@ def ensure_channel(self, channel_id: str, display_name: str | None = None) -> Ch self._channels[normalized_id] = channel self._channel_store.add_or_update(channel) if is_group: - # Hashtag channels need a row in channel_secrets or pyMC_core cannot + # Hashtag channels need a row in channel_secrets or openhop_core cannot # encrypt for them at send time and cannot match them at receive # time (issue #81). self._ensure_channel_secret(channel) @@ -263,7 +265,7 @@ def _ensure_channel_secret(self, channel: Channel) -> str: """Ensure a group channel has a secret, and return its on-the-wire name.""" name = channel.display_name.lstrip("#") or channel.channel_id self._channel_secrets.ensure_channel_secret(name) - # pyMC_core matches channels_config by exact name, so send with the name + # openhop_core matches channels_config by exact name, so send with the name # as stored ("Public"), not the UI display name ("#public"). return self._channel_secrets.resolve_name(name) or name @@ -316,13 +318,13 @@ def send_message(self, peer_id: str, body: str) -> Message: self._channel_store.add_or_update(channel) if is_group: - # Ensure a secret exists and resolve the name pyMC_core knows the + # Ensure a secret exists and resolve the name openhop_core knows the # channel by. Done on every send, not just for newly created # channels, so a channel that predates issue #81 is repaired too. channel_name = self._ensure_channel_secret(self._channels[channel_id]) self._run_async(self._session.send_group_text(channel_name=channel_name, message=body)) else: - # Use the original-case peer name from the channel so pyMC_core + # Use the original-case peer name from the channel so openhop_core # can find the contact in the contact book (case-sensitive lookup). channel = self._channels.get(channel_id) resolved_name = (channel.peer_name or channel.display_name) if channel else peer_id @@ -420,7 +422,7 @@ def _build_peer_lookup(self) -> dict[str, str]: def _enrich_sender_names(self, events: list[MeshEventDict]) -> None: """Best-effort enrichment of packet events for the analyzer display. - pymc_core's raw packet callback fires *before* handler processing, so + openhop_core's raw packet callback fires *before* handler processing, so ``packet`` events often lack sender_name (and GRP_TXT/TXT_MSG lack channel_name / payload_text). We try two strategies: @@ -593,7 +595,7 @@ def _process_advert_event(self, data: MeshEventDict) -> None: signal = rssi_to_signal_percent(rssi) if rssi is not None else None # Determine repeater status from advert_type (lower nibble of ADVERT flags byte). - # ADV_TYPE_REPEATER = 2 per pyMC_core. + # ADV_TYPE_REPEATER = 2 per openhop_core. advert_type = data.get("advert_type") or data.get("contact_type") is_repeater = int(advert_type) == 2 if advert_type is not None else False @@ -747,7 +749,7 @@ def _process_message_event(self, data: MeshEventDict, event_type: str = "") -> N peer_display_name = sender_name if is_direct else None # Deduplicate by message_id — handles radio retransmissions of the - # same packet (pyMC_core derives a deterministic id from the decrypted + # same packet (openhop_core derives a deterministic id from the decrypted # timestamp + content hash, so copies of the same packet share an id). msg_id = data.get("message_id") or str(uuid4()) existing_ids = {m.message_id for m in self._messages[-100:]} @@ -1076,9 +1078,9 @@ def _append_history(self, event: MeshEventDict) -> None: if len(self._event_history) > 500: self._event_history = self._event_history[-500:] - def _new_session(self) -> PyMCCoreSession: + def _new_session(self) -> OpenHopCoreSession: runtime = runtime_config_from_settings(self._settings) - session = PyMCCoreSession(runtime) + session = OpenHopCoreSession(runtime) if self._event_notify is not None: session.set_event_notify(self._event_notify) return session diff --git a/src/meshcore_console/meshcore/contact_book.py b/src/meshcore_console/meshcore/contact_book.py index dc96575..970380b 100644 --- a/src/meshcore_console/meshcore/contact_book.py +++ b/src/meshcore_console/meshcore/contact_book.py @@ -1,6 +1,6 @@ -"""Contact book adapter for pyMC_core. +"""Contact book adapter for openhop_core. -pyMC_core handlers (TextMessageHandler, LoginResponseHandler, etc.) expect +openhop_core handlers (TextMessageHandler, LoginResponseHandler, etc.) expect a contact book object with: - .contacts — iterable of objects with .public_key (hex str) and .name - .get_by_name(name) — return a contact or None @@ -15,9 +15,9 @@ @dataclass class Contact: - """Single contact entry compatible with pyMC_core handler expectations. + """Single contact entry compatible with openhop_core handler expectations. - Note: slots=False is intentional — pyMC_core sets dynamic attributes + Note: slots=False is intentional — openhop_core sets dynamic attributes (e.g. out_path) on contacts when processing adverts. """ @@ -28,7 +28,7 @@ class Contact: class ContactBook: - """In-memory contact book that satisfies the pyMC_core contacts interface.""" + """In-memory contact book that satisfies the openhop_core contacts interface.""" def __init__(self) -> None: self.contacts: list[Contact] = [] diff --git a/src/meshcore_console/meshcore/db.py b/src/meshcore_console/meshcore/db.py index d1773b2..8b6bf93 100644 --- a/src/meshcore_console/meshcore/db.py +++ b/src/meshcore_console/meshcore/db.py @@ -180,7 +180,7 @@ def open_db(path: str | None = None) -> sqlite3.Connection: """Open (and migrate if needed) the application database.""" db = db_path() if path is None else __import__("pathlib").Path(path) db.parent.mkdir(parents=True, exist_ok=True) - # check_same_thread=False is required because pyMC_core reads the + # check_same_thread=False is required because openhop_core reads the # channel_secrets table from the meshcore-aio thread while the connection # is created on the main thread. WAL mode makes concurrent access safe. conn = sqlite3.connect(str(db), check_same_thread=False) diff --git a/src/meshcore_console/meshcore/event_bridge.py b/src/meshcore_console/meshcore/event_bridge.py index 334bc9e..d2abfef 100644 --- a/src/meshcore_console/meshcore/event_bridge.py +++ b/src/meshcore_console/meshcore/event_bridge.py @@ -70,7 +70,7 @@ async def on_raw_packet( } ) - # NOTE: set_packet_received_callback does not exist in pyMC_core. + # NOTE: set_packet_received_callback does not exist in openhop_core. # Use set_raw_packet_callback (the real API) and emit both event types # so downstream consumers (e.g. AnalyzerView) receive "packet" events. diff --git a/src/meshcore_console/meshcore/logging_setup.py b/src/meshcore_console/meshcore/logging_setup.py index fb7c34f..bc34cac 100644 --- a/src/meshcore_console/meshcore/logging_setup.py +++ b/src/meshcore_console/meshcore/logging_setup.py @@ -112,7 +112,7 @@ def export_logs_to_stdout() -> None: # Radio error interception # --------------------------------------------------------------------------- -_RADIO_LOGGER_SUBSTRINGS = ("SX1262", "pyMC", "meshcore_console.meshcore.session") +_RADIO_LOGGER_SUBSTRINGS = ("SX1262", "openhop_core", "meshcore_console.meshcore.session") class RadioErrorHandler(logging.Handler): diff --git a/src/meshcore_console/meshcore/operations.py b/src/meshcore_console/meshcore/operations.py index 6dcd9e7..e25c0bc 100644 --- a/src/meshcore_console/meshcore/operations.py +++ b/src/meshcore_console/meshcore/operations.py @@ -25,7 +25,7 @@ def _resolve_contact(node: MeshNodeProtocol, peer_name: str) -> object: async def send_text(*, node: MeshNodeProtocol, peer_name: str, message: str) -> dict: """Send a direct text message to a peer via PacketBuilder.""" - from pymc_core.protocol.packet_builder import PacketBuilder + from openhop_core.protocol.packet_builder import PacketBuilder contact = _resolve_contact(node, peer_name) @@ -40,7 +40,7 @@ async def send_text(*, node: MeshNodeProtocol, peer_name: str, message: str) -> async def send_group_text(*, node: MeshNodeProtocol, channel_name: str, message: str) -> dict: """Broadcast a text message to a group/public channel via PacketBuilder.""" - from pymc_core.protocol.packet_builder import PacketBuilder + from openhop_core.protocol.packet_builder import PacketBuilder if node.channel_db is None: raise RuntimeError("No channel database configured") @@ -65,7 +65,7 @@ async def request_telemetry( timeout: float = 10.0, ) -> dict: """Request telemetry data from a remote peer via PacketBuilder.""" - from pymc_core.protocol.packet_builder import PacketBuilder + from openhop_core.protocol.packet_builder import PacketBuilder contact = _resolve_contact(node, contact_name) @@ -134,7 +134,7 @@ async def send_login( timeout: float = 10.0, ) -> dict: """Send a login request to a repeater and wait for the response.""" - from pymc_core.protocol.packet_builder import PacketBuilder + from openhop_core.protocol.packet_builder import PacketBuilder contact = _resolve_contact(node, peer_name) @@ -179,7 +179,7 @@ def _on_login(success: bool, data: dict) -> None: async def send_logout(*, node: MeshNodeProtocol, peer_name: str) -> dict: """Send a logout/disconnect to a repeater.""" - from pymc_core.protocol.packet_builder import PacketBuilder + from openhop_core.protocol.packet_builder import PacketBuilder contact = _resolve_contact(node, peer_name) @@ -199,7 +199,7 @@ async def send_repeater_command( timeout: float = 15.0, ) -> dict: """Send a CLI command to a repeater and wait for the response.""" - from pymc_core.protocol.packet_builder import PacketBuilder + from openhop_core.protocol.packet_builder import PacketBuilder contact = _resolve_contact(node, peer_name) @@ -256,7 +256,7 @@ async def send_advert( lon: float = 0.0, route_type: str = "flood", ) -> SendResultDict: - from pymc_core.protocol.packet_builder import PacketBuilder + from openhop_core.protocol.packet_builder import PacketBuilder advert_name = name or default_name packet = PacketBuilder.create_self_advert( diff --git a/src/meshcore_console/meshcore/packet_codec.py b/src/meshcore_console/meshcore/packet_codec.py index 60a2764..ec9787a 100644 --- a/src/meshcore_console/meshcore/packet_codec.py +++ b/src/meshcore_console/meshcore/packet_codec.py @@ -33,15 +33,15 @@ def repair_utf8(text: str) -> str: try: - from pymc_core.protocol.utils import PAYLOAD_TYPES, ROUTE_TYPES + from openhop_core.protocol.utils import PAYLOAD_TYPES, ROUTE_TYPES except ImportError: PAYLOAD_TYPES = {} ROUTE_TYPES = {} try: - from pymc_core.protocol.utils import ( - decode_appdata as _pymc_decode_appdata, - parse_advert_payload as _pymc_parse_advert, + from openhop_core.protocol.utils import ( + decode_appdata as _openhop_decode_appdata, + parse_advert_payload as _openhop_parse_advert, ) _HAS_PYMC_PARSER = True @@ -50,7 +50,7 @@ def repair_utf8(text: str) -> str: def _extract_sender_name(packet: Any) -> str | None: - """Extract sender name from packet.decrypted (the only source on pyMC_core Packet).""" + """Extract sender name from packet.decrypted (the only source on openhop_core Packet).""" decrypted = getattr(packet, "decrypted", None) if not decrypted: return None @@ -62,21 +62,21 @@ def _extract_sender_name(packet: Any) -> str | None: def _extract_sender_id(packet: Any) -> str | None: - """Extract sender ID from packet — not available on pyMC_core Packet directly.""" + """Extract sender ID from packet — not available on openhop_core Packet directly.""" return None def _parse_advert_payload(payload_bytes: bytes) -> dict[str, Any]: """Parse an ADVERT payload to extract name and location. - Delegates to pyMC_core's canonical parser when available, with a - built-in fallback for environments where pyMC_core is not installed. + Delegates to openhop_core's canonical parser when available, with a + built-in fallback for environments where openhop_core is not installed. """ - # --- Primary path: use pyMC_core's parser (authoritative) ---------- + # --- Primary path: use openhop_core's parser (authoritative) ---------- if _HAS_PYMC_PARSER: try: - parsed = _pymc_parse_advert(payload_bytes) - decoded = _pymc_decode_appdata(parsed["appdata"]) + parsed = _openhop_parse_advert(payload_bytes) + decoded = _openhop_decode_appdata(parsed["appdata"]) result: dict[str, Any] = {"sender_pubkey": parsed["pubkey"]} flags = decoded.get("flags", 0) result["advert_type"] = flags & 0x0F @@ -92,7 +92,7 @@ def _parse_advert_payload(payload_bytes: bytes) -> dict[str, Any]: except ValueError: pass # malformed advert payload — fall through to manual parser - # --- Fallback: manual parser (for mock / no pyMC_core) ------------- + # --- Fallback: manual parser (for mock / no openhop_core) ------------- result = {} PUB_KEY_SIZE = 32 @@ -191,7 +191,7 @@ def packet_to_dict(packet: Any) -> PacketDataDict: sender_id = _extract_sender_id(packet) # For ADVERT packets, try to parse the payload for name/location - # Check both name and numeric type (ADVERT is type 4 in pymc_core) + # Check both name and numeric type (ADVERT is type 4 in openhop_core) advert_info: dict[str, Any] = {} is_advert = payload_type_name == "ADVERT" or payload_type == 4 if is_advert and payload_bytes_val: diff --git a/src/meshcore_console/meshcore/runtime.py b/src/meshcore_console/meshcore/runtime.py index 98375b1..3e74ce2 100644 --- a/src/meshcore_console/meshcore/runtime.py +++ b/src/meshcore_console/meshcore/runtime.py @@ -16,7 +16,7 @@ from .paths import identity_key_path -def import_pymc_core() -> tuple[ +def import_openhop_core() -> tuple[ type[SX1262RadioProtocol], type[EventServiceProtocol], type[EventSubscriberProtocol], @@ -24,12 +24,12 @@ def import_pymc_core() -> tuple[ type[LocalIdentityProtocol], ]: try: - from pymc_core.hardware.sx1262_wrapper import SX1262Radio - from pymc_core.node.events import EventService, EventSubscriber - from pymc_core.node.node import MeshNode - from pymc_core.protocol.identity import LocalIdentity + from openhop_core.hardware.sx1262_wrapper import SX1262Radio + from openhop_core.node.events import EventService, EventSubscriber + from openhop_core.node.node import MeshNode + from openhop_core.protocol.identity import LocalIdentity except ImportError as exc: - raise RuntimeError("pymc_core is not available. Run `uv sync` in this project.") from exc + raise RuntimeError("openhop_core is not available. Run `uv sync` in this project.") from exc return SX1262Radio, EventService, EventSubscriber, MeshNode, LocalIdentity # type: ignore[return-value] @@ -94,7 +94,7 @@ def create_mesh_node( config_payload = {"node": {"name": node_name}} if node_config: config_payload["node"].update(node_config) - # pyMC_core constructor kwargs not in Protocol (which only defines methods) + # openhop_core constructor kwargs not in Protocol (which only defines methods) node = mesh_node_type( # type: ignore[call-arg] radio=radio, local_identity=identity, diff --git a/src/meshcore_console/meshcore/session.py b/src/meshcore_console/meshcore/session.py index 467d4a3..cd75547 100644 --- a/src/meshcore_console/meshcore/session.py +++ b/src/meshcore_console/meshcore/session.py @@ -26,7 +26,7 @@ from .cayenne_lpp import encode_telemetry from . import cayenne_lpp as _cayenne_lpp_mod -# pymc_core's ProtocolResponseHandler tries ``from utils.cayenne_lpp_helpers +# openhop_core's ProtocolResponseHandler tries ``from utils.cayenne_lpp_helpers # import decode_cayenne_lpp_payload`` which isn't shipped. Register our # module under that name so the import succeeds. import sys as _sys @@ -52,11 +52,11 @@ send_repeater_command, send_text, ) -from .runtime import create_mesh_node, create_radio, import_pymc_core +from .runtime import create_mesh_node, create_radio, import_openhop_core -class PyMCCoreSession: - """Async wrapper around pymc_core MeshNode lifecycle.""" +class OpenHopCoreSession: + """Async wrapper around openhop_core MeshNode lifecycle.""" def __init__(self, config: RuntimeRadioConfig, logger: LoggerCallback | None = None) -> None: self.config = config @@ -107,8 +107,8 @@ def _register_discovery_handler(self) -> None: When another node sends a CONTROL discovery request, we reply with our public key and node type so they can discover us. """ - from pymc_core.protocol.constants import ADVERT_FLAG_IS_CHAT_NODE - from pymc_core.protocol.packet_builder import PacketBuilder + from openhop_core.protocol.constants import ADVERT_FLAG_IS_CHAT_NODE + from openhop_core.protocol.packet_builder import PacketBuilder assert self._node is not None assert self._identity is not None @@ -195,7 +195,7 @@ def _handle_telemetry(client: Any, _timestamp: int, req_data: bytes) -> bytes | def _register_req_handler(self) -> None: """Register handlers for incoming REQ and ANON_REQ packets. - pyMC_core's register_default_handlers() does not register a handler + openhop_core's register_default_handlers() does not register a handler for PAYLOAD_TYPE_REQ (0x00), so incoming requests are silently dropped. We register ProtocolRequestHandler here and wrap it so that any generated RESPONSE packet is actually transmitted. @@ -207,9 +207,9 @@ def _register_req_handler(self) -> None: """ import struct - from pymc_core.node.handlers.protocol_request import ProtocolRequestHandler - from pymc_core.protocol import CryptoUtils, Identity, PacketBuilder - from pymc_core.protocol.constants import ( + from openhop_core.node.handlers.protocol_request import ProtocolRequestHandler + from openhop_core.protocol import CryptoUtils, Identity, PacketBuilder + from openhop_core.protocol.constants import ( PAYLOAD_TYPE_ANON_REQ, PAYLOAD_TYPE_REQ, PAYLOAD_TYPE_RESPONSE, @@ -408,7 +408,7 @@ async def _call_maybe_async(self, target: Any, name: str) -> None: async def _poll_hw_threads(self, timeout: float = 5.0) -> None: """Wait for hardware threads spawned during start() to exit. - pyMC_core's GPIOPinManager creates OS threads for edge detection and + openhop_core's GPIOPinManager creates OS threads for edge detection and IRQ handling. These threads hold GPIO line file descriptors; the kernel only releases the lines once the threads (and their fds) are gone. @@ -442,14 +442,14 @@ async def start(self) -> None: if self._node is not None and self._node_task is not None and not self._node_task.done(): return - self._log("importing pymc_core modules") - SX1262Radio, EventService, EventSubscriber, MeshNode, LocalIdentity = import_pymc_core() + self._log("importing openhop_core modules") + SX1262Radio, EventService, EventSubscriber, MeshNode, LocalIdentity = import_openhop_core() hardware_config = self.config.hardware or load_hardware_config_from_env() self._log(f"radio config {hardware_config.to_log_string()}") # Snapshot threads before hardware init so we can track GPIO/IRQ threads - # spawned by pyMC_core and wait for them during stop(). + # spawned by openhop_core and wait for them during stop(). pre_threads = {t.ident for t in threading.enumerate()} self._log("creating SX1262Radio") @@ -459,7 +459,7 @@ async def start(self) -> None: self._log, ) - # Retry begin() with backoff — pymc_core calls sys.exit(1) when a + # Retry begin() with backoff — openhop_core calls sys.exit(1) when a # GPIO pin is still held by the previous session's edge-detection # thread (stuck in gpio.poll(30s)). Retrying gives the kernel time # to release the line after the old fd is closed. @@ -553,7 +553,7 @@ async def stop(self) -> None: # Best-effort radio cleanup for reconnect stability on Linux GPIO/SPI. # - # pymc_core's GPIOPinManager.cleanup_all() joins edge-detection threads + # openhop_core's GPIOPinManager.cleanup_all() joins edge-detection threads # (2.0s timeout) BEFORE closing pin file descriptors. Since those # threads block in gpio.poll(30.0), the join always times out. # Pre-closing the fds unblocks the threads so cleanup_all()'s join @@ -690,15 +690,15 @@ def status(self) -> SessionStatusDict: "connected": self._node is not None, "node_name": self.config.node_name, "board": "hackergadgets-aio", - "pymc_core_version": self._get_pymc_version(), + "openhop_core_version": self._get_openhop_version(), } @staticmethod - def _get_pymc_version() -> str: + def _get_openhop_version() -> str: try: - import pymc_core + import openhop_core - return pymc_core.__version__ + return openhop_core.__version__ except Exception: return "unknown" @@ -710,7 +710,7 @@ def get_public_key(self) -> str | None: """Return this node's public key as a hex string, or None if unavailable.""" if self._identity is None: return None - # pyMC_core LocalIdentity exposes get_shared_public_key() + # openhop_core LocalIdentity exposes get_shared_public_key() get_pk = getattr(self._identity, "get_shared_public_key", None) pk = get_pk() if callable(get_pk) else None if pk is None: diff --git a/src/meshcore_console/mock/__init__.py b/src/meshcore_console/mock/__init__.py index 7dd3b34..8c16dcf 100644 --- a/src/meshcore_console/mock/__init__.py +++ b/src/meshcore_console/mock/__init__.py @@ -2,6 +2,6 @@ from .client import MockMeshcoreClient from .gps import MockGps -from .session import MockPyMCCoreSession +from .session import MockOpenHopCoreSession -__all__ = ["MockMeshcoreClient", "MockPyMCCoreSession", "MockGps"] +__all__ = ["MockMeshcoreClient", "MockOpenHopCoreSession", "MockGps"] diff --git a/src/meshcore_console/mock/client.py b/src/meshcore_console/mock/client.py index ff0c742..7d0f2bc 100644 --- a/src/meshcore_console/mock/client.py +++ b/src/meshcore_console/mock/client.py @@ -20,7 +20,7 @@ create_mock_peers, ) from .gps import MockGps -from .session import MockPyMCCoreSession +from .session import MockOpenHopCoreSession class MockMeshcoreClient(MeshcoreService): @@ -29,7 +29,7 @@ class MockMeshcoreClient(MeshcoreService): def __init__(self, node_name: str = "uconsole-node") -> None: self._settings = MeshcoreSettings(node_name=node_name) self._config = runtime_config_from_settings(self._settings) - self._session = MockPyMCCoreSession(self._config) + self._session = MockOpenHopCoreSession(self._config) self._gps_provider = MockGps() self._connected = True self._event_notify: Callable[[], None] | None = None @@ -239,7 +239,7 @@ def set_favorite(self, peer_id: str, favorite: bool) -> None: return def request_telemetry(self, peer_name: str) -> dict: - """Return synthetic telemetry data matching pymc_core's format.""" + """Return synthetic telemetry data matching openhop_core's format.""" import time loc = self._gps_provider.get_location() diff --git a/src/meshcore_console/mock/data.py b/src/meshcore_console/mock/data.py index b247915..daa04da 100644 --- a/src/meshcore_console/mock/data.py +++ b/src/meshcore_console/mock/data.py @@ -244,7 +244,7 @@ def create_mock_boot_events() -> list[dict]: def create_mock_packet_events() -> list[dict]: """Create mock packet events for the analyzer view. - Packet types from pyMC_core (numeric ID -> name): + Packet types from openhop_core (numeric ID -> name): 0: REQ - Request 1: RESPONSE - Response to REQ or ANON_REQ 2: TXT_MSG - Plain text message (encrypted) diff --git a/src/meshcore_console/mock/session.py b/src/meshcore_console/mock/session.py index 63079b0..44da7bb 100644 --- a/src/meshcore_console/mock/session.py +++ b/src/meshcore_console/mock/session.py @@ -1,4 +1,4 @@ -"""Mock pyMC_core session for UI development.""" +"""Mock openhop_core session for UI development.""" from __future__ import annotations @@ -14,8 +14,8 @@ from .data import MOCK_PEER_LOCATIONS, create_mock_packet_events -class MockPyMCCoreSession: - """Low-level mock of the pyMC session API for UI development.""" +class MockOpenHopCoreSession: + """Low-level mock of the openhop_core session API for UI development.""" def __init__(self, config: RuntimeRadioConfig) -> None: self.config = config @@ -129,7 +129,7 @@ def status(self) -> SessionStatusDict: "connected": self._connected, "node_name": self.config.node_name, "board": "mock", - "pymc_core_version": "mock", + "openhop_core_version": "mock", } def get_public_key(self) -> str | None: @@ -156,7 +156,7 @@ def _queue_mock_advert( snr = random.uniform(-5.0, 12.0) path_hops = [] if is_repeater else ["relay-001"] - # ADV_TYPE_REPEATER = 2 per pyMC_core; client nodes are 0. + # ADV_TYPE_REPEATER = 2 per openhop_core; client nodes are 0. advert_type = 2 if is_repeater else 0 event = { diff --git a/src/meshcore_console/platform/conflicts.py b/src/meshcore_console/platform/conflicts.py index b8bba59..ba0959f 100644 --- a/src/meshcore_console/platform/conflicts.py +++ b/src/meshcore_console/platform/conflicts.py @@ -1,7 +1,7 @@ """Pre-flight conflict detection for radio hardware. Detects processes (e.g. meshtasticd) or permission issues that would prevent -pyMC_core from initialising SPI/GPIO. Runs *before* any radio access so the +openhop_core from initialising SPI/GPIO. Runs *before* any radio access so the UI can show actionable guidance instead of a cryptic exit-code toast. """ diff --git a/src/meshcore_console/radio_cli.py b/src/meshcore_console/radio_cli.py index c1faaa4..d947848 100644 --- a/src/meshcore_console/radio_cli.py +++ b/src/meshcore_console/radio_cli.py @@ -8,7 +8,7 @@ from typing import Any from meshcore_console.meshcore.config import load_runtime_config -from meshcore_console.meshcore.session import PyMCCoreSession +from meshcore_console.meshcore.session import OpenHopCoreSession def _add_global_args(p: argparse.ArgumentParser) -> None: @@ -29,7 +29,7 @@ def _add_global_args(p: argparse.ArgumentParser) -> None: def register_subcommands(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg] """Register all CLI subcommands on an existing subparsers action.""" - doctor = sub.add_parser("doctor", help="Check host prerequisites for pyMC_core radio access") + doctor = sub.add_parser("doctor", help="Check host prerequisites for openhop_core radio access") _add_global_args(doctor) listen = sub.add_parser("listen", help="Start node and print incoming events") @@ -85,11 +85,11 @@ def _doctor() -> int: ) try: - import pymc_core # noqa: F401 + import openhop_core # noqa: F401 - checks.append(("pymc_core", True, "Python module import succeeded")) + checks.append(("openhop_core", True, "Python module import succeeded")) except Exception as exc: # noqa: BLE001 - checks.append(("pymc_core", False, f"Import failed: {exc}")) + checks.append(("openhop_core", False, f"Import failed: {exc}")) ok = True for name, passed, detail in checks: @@ -106,7 +106,7 @@ def _debug(enabled: bool, message: str) -> None: async def _run_listen( - session: PyMCCoreSession, duration: int, debug: bool, start_timeout: float + session: OpenHopCoreSession, duration: int, debug: bool, start_timeout: float ) -> int: _debug(debug, "starting mesh node") await asyncio.wait_for(session.start(), timeout=start_timeout) @@ -128,7 +128,7 @@ async def _run_listen( async def _run_send( - session: PyMCCoreSession, + session: OpenHopCoreSession, peer: str, message: str, debug: bool, @@ -153,7 +153,7 @@ async def _run_send( async def _run_advert( - session: PyMCCoreSession, + session: OpenHopCoreSession, *, name: str | None, lat: float, @@ -241,7 +241,7 @@ async def _async_main(args: argparse.Namespace) -> int: return _doctor() config = load_runtime_config(node_name=args.node_name) - session = PyMCCoreSession(config, logger=lambda msg: _debug(args.debug, f"session: {msg}")) + session = OpenHopCoreSession(config, logger=lambda msg: _debug(args.debug, f"session: {msg}")) if args.command == "listen": return await _run_listen(session, args.duration, args.debug, args.start_timeout) diff --git a/tests/integration/test_settings_apply.py b/tests/integration/test_settings_apply.py index 82c24be..3c1589c 100644 --- a/tests/integration/test_settings_apply.py +++ b/tests/integration/test_settings_apply.py @@ -3,7 +3,7 @@ from meshcore_console.meshcore.db import open_db from meshcore_console.meshcore.settings import MeshcoreSettings from meshcore_console.meshcore.settings_store import SettingsStore -from meshcore_console.mock import MockPyMCCoreSession +from meshcore_console.mock import MockOpenHopCoreSession def test_client_updates_and_persists_settings(tmp_path) -> None: @@ -11,8 +11,8 @@ def test_client_updates_and_persists_settings(tmp_path) -> None: store = SettingsStore(conn) base_settings = MeshcoreSettings() client = MeshcoreClient( - session=MockPyMCCoreSession(runtime_config_from_settings(base_settings)), - require_pymc=False, + session=MockOpenHopCoreSession(runtime_config_from_settings(base_settings)), + require_openhop=False, settings_store=store, ) diff --git a/tests/unit/test_channel_secrets.py b/tests/unit/test_channel_secrets.py index add05ff..88ee2f5 100644 --- a/tests/unit/test_channel_secrets.py +++ b/tests/unit/test_channel_secrets.py @@ -70,14 +70,14 @@ def test_client_ensure_channel_stores_secret(tmp_path, monkeypatch) -> None: from meshcore_console.meshcore.client import MeshcoreClient from meshcore_console.meshcore.config import runtime_config_from_settings from meshcore_console.meshcore.settings import MeshcoreSettings - from meshcore_console.mock import MockPyMCCoreSession + from meshcore_console.mock import MockOpenHopCoreSession db_conn = open_db(str(tmp_path / "client.db")) monkeypatch.setattr(client_mod, "open_db", lambda *a, **k: db_conn) client = MeshcoreClient( - session=MockPyMCCoreSession(runtime_config_from_settings(MeshcoreSettings())), - require_pymc=False, + session=MockOpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())), + require_openhop=False, ) # Mirrors the UI '+ Add Channel' handler (messages.py). @@ -102,13 +102,13 @@ def _client(tmp_path, monkeypatch, name): from meshcore_console.meshcore.client import MeshcoreClient from meshcore_console.meshcore.config import runtime_config_from_settings from meshcore_console.meshcore.settings import MeshcoreSettings - from meshcore_console.mock import MockPyMCCoreSession + from meshcore_console.mock import MockOpenHopCoreSession db_conn = open_db(str(tmp_path / name)) monkeypatch.setattr(client_mod, "open_db", lambda *a, **k: db_conn) client = MeshcoreClient( - session=MockPyMCCoreSession(runtime_config_from_settings(MeshcoreSettings())), - require_pymc=False, + session=MockOpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())), + require_openhop=False, ) return client, db_conn @@ -137,7 +137,7 @@ def test_remove_channel_keeps_imported_secret(tmp_path, monkeypatch) -> None: def test_send_uses_the_name_stored_in_channel_secrets( tmp_path, monkeypatch, channel_id, display_name ) -> None: - """pyMC_core matches channels_config by exact name, so the name passed to + """openhop_core matches channels_config by exact name, so the name passed to send_group_text must appear verbatim in channel_secrets (issue #81).""" client, db_conn = _client(tmp_path, monkeypatch, f"send-{channel_id}.db") client.ensure_channel(channel_id, display_name) diff --git a/tests/unit/test_contact_book.py b/tests/unit/test_contact_book.py index bf8b5b1..6ec4573 100644 --- a/tests/unit/test_contact_book.py +++ b/tests/unit/test_contact_book.py @@ -1,19 +1,19 @@ -"""Tests for ContactBook and Contact compatibility with pyMC_core.""" +"""Tests for ContactBook and Contact compatibility with openhop_core.""" from meshcore_console.meshcore.contact_book import Contact, ContactBook def test_contact_has_out_path_default() -> None: - """Contact.out_path defaults to None so pyMC_core can read it before an advert arrives.""" + """Contact.out_path defaults to None so openhop_core can read it before an advert arrives.""" contact = Contact(name="Alice", public_key="ab" * 32) assert contact.out_path is None assert contact.out_path_len == -1 def test_contact_allows_dynamic_attributes() -> None: - """pyMC_core sets dynamic attributes on contacts during advert processing. + """openhop_core sets dynamic attributes on contacts during advert processing. - Contact must NOT use slots=True or pyMC_core will crash with AttributeError. + Contact must NOT use slots=True or openhop_core will crash with AttributeError. """ contact = Contact(name="Alice", public_key="ab" * 32) @@ -22,7 +22,7 @@ def test_contact_allows_dynamic_attributes() -> None: assert contact.out_path == b"\xa2\xb3" assert contact.out_path_len == 2 - # pyMC_core may also set other dynamic attributes we don't declare + # openhop_core may also set other dynamic attributes we don't declare contact.last_rssi = -72 # type: ignore[attr-defined] assert contact.last_rssi == -72 # type: ignore[attr-defined] diff --git a/uv.lock b/uv.lock index b600edf..266f294 100644 --- a/uv.lock +++ b/uv.lock @@ -534,9 +534,9 @@ version = "1.11.0" source = { editable = "." } dependencies = [ { name = "gpsdclient" }, + { name = "openhop-core" }, + { name = "openhop-core", extra = ["hardware"], marker = "sys_platform == 'linux'" }, { name = "pycayennelpp" }, - { name = "pymc-core" }, - { name = "pymc-core", extra = ["hardware"], marker = "sys_platform == 'linux'" }, { name = "pynmea2" }, { name = "segno" }, ] @@ -560,10 +560,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "gpsdclient", specifier = ">=1.3" }, + { name = "openhop-core", specifier = ">=1.1.1" }, + { name = "openhop-core", extras = ["hardware"], marker = "sys_platform == 'linux'", specifier = ">=1.1.1" }, { name = "pycayennelpp", specifier = ">=2.4.0" }, { name = "pygobject", marker = "extra == 'gtk'", specifier = ">=3.48" }, - { name = "pymc-core", specifier = ">=1.0.10" }, - { name = "pymc-core", extras = ["hardware"], marker = "sys_platform == 'linux'", specifier = ">=1.0.10" }, { name = "pynmea2", specifier = ">=1.18.0" }, { name = "segno", specifier = ">=1.6.0" }, ] @@ -637,6 +637,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "openhop-core" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycryptodome" }, + { name = "pynacl" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/60/4a81a09a69fe374f85e45f00443c8ef2798ab5d502fdb1af3e0aacc0c815/openhop_core-1.1.1.tar.gz", hash = "sha256:bcf363248d116d590e62dbe88e8aafccf38d1271e7d44adfca1be8c40f43b46c", size = 356450, upload-time = "2026-06-24T21:26:17.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/79/085fc9ac0dea9db646b3b1eda47b05394eb6d2d948dd7d5a0da54fa6185d/openhop_core-1.1.1-py3-none-any.whl", hash = "sha256:dec24bdd39c2134e0ba721c77fb6c05996c5aa1c0357c687b5a9574c6728a5da", size = 287401, upload-time = "2026-06-24T21:26:15.856Z" }, +] + +[package.optional-dependencies] +hardware = [ + { name = "pyserial", marker = "sys_platform == 'linux'" }, + { name = "python-periphery", marker = "sys_platform == 'linux'" }, + { name = "pyusb", marker = "sys_platform == 'linux'" }, + { name = "spidev", marker = "sys_platform == 'linux'" }, +] + [[package]] name = "packaging" version = "26.0" @@ -789,28 +811,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/d3/a5/68f883df1d8442e3b267cb92105a4b2f0de819bd64ac9981c2d680d3f49f/pygobject-3.54.5.tar.gz", hash = "sha256:b6656f6348f5245606cf15ea48c384c7f05156c75ead206c1b246c80a22fb585", size = 1274658, upload-time = "2025-10-18T13:45:03.121Z" } -[[package]] -name = "pymc-core" -version = "1.0.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycryptodome" }, - { name = "pynacl" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/72/d4747766cd6f3858382f838da47c51f27dc22c7fa879c6be4b105fd79782/pymc_core-1.0.10.tar.gz", hash = "sha256:e135fe7d6d918ae83a30ae1a915ebd5a270dd87a053a9dc8e9df8c670a31d1a5", size = 263681, upload-time = "2026-04-24T15:00:57.821Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/5f/7b99f0266ea38b92c327070ad07a71ce0acaead243976dec7ee6c0c6d9ea/pymc_core-1.0.10-py3-none-any.whl", hash = "sha256:6a50670d8e37ab13927c19d406d212fe0e2ecf1c3187f0555591b2fd245b9003", size = 233519, upload-time = "2026-04-24T15:00:55.742Z" }, -] - -[package.optional-dependencies] -hardware = [ - { name = "pyserial", marker = "sys_platform == 'linux'" }, - { name = "python-periphery", marker = "sys_platform == 'linux'" }, - { name = "pyusb", marker = "sys_platform == 'linux'" }, - { name = "spidev", marker = "sys_platform == 'linux'" }, -] - [[package]] name = "pynacl" version = "1.6.2" From 2bd420e0b05d3d368ff20a544432374edc4f10b7 Mon Sep 17 00:00:00 2001 From: Cameron Will Date: Sun, 9 Aug 2026 14:29:31 -0400 Subject: [PATCH 2/4] fix: Make the GPIO chip, IRQ backend, and enable pins configurable (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 40-pin header sits on /dev/gpiochip0 for CM4, but on /dev/gpiochip15 for CM5 and Pi 5 kernels, where it hangs off the RP1. We hardcoded chip 0 in three places and never passed openhop_core's gpio_chip kwarg, so affected users had no supported way to point at the right chip — the only workaround was a udev symlink. Adds three hardware settings, each with an env override, a Settings > Hardware control, and a passthrough in create_radio(): gpio_chip MESHCORE_GPIO_CHIP /dev/gpiochipN use_gpiod_backend MESHCORE_USE_GPIOD_BACKEND polled IRQ edges en_pins MESHCORE_EN_PINS radio power-enable pins These are deliberately *not* part of HARDWARE_PRESETS: the chip number and IRQ backend are properties of the SoC and kernel, not of the radio board, so picking a board preset must not reset them. Note use_gpiod_backend does not actually select libgpiod while python-periphery is installed — openhop_core only swaps in its libgpiod wrapper when periphery is absent, and its [hardware] extra always installs periphery. What the flag really changes is edge detection, from kernel edge interrupts to a polling thread, which is a workaround for kernels that reject the edge request and leave the radio connected but deaf. It is documented and labelled ("Poll IRQ") accordingly, and we do not depend on the gpiod extra, which would never be imported and needs libgpiod headers to build on the Pi. Diagnostics, so the next reporter does not have to guess: - doctor reports the *configured* chip and lists the chips the host actually has, instead of asserting /dev/gpiochip0. - pre-flight adds a GPIO_CHIP conflict naming the available chips. A missing chip short-circuits the pin probes, which would otherwise emit one redundant conflict per pin for the same root cause. - conflicts probe pins against the configured chip, and now include the configured enable pins. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 20 +++ src/meshcore_console/meshcore/config.py | 36 +++- src/meshcore_console/meshcore/runtime.py | 3 + src/meshcore_console/meshcore/settings.py | 13 ++ src/meshcore_console/platform/conflicts.py | 63 ++++++- src/meshcore_console/radio_cli.py | 44 ++++- src/meshcore_console/ui_gtk/views/settings.py | 39 +++++ tests/unit/test_gpio_chip_config.py | 159 ++++++++++++++++++ tests/unit/test_gpio_chip_conflicts.py | 88 ++++++++++ 9 files changed, 455 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_gpio_chip_config.py create mode 100644 tests/unit/test_gpio_chip_conflicts.py diff --git a/AGENTS.md b/AGENTS.md index d2d32cb..65212a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,26 @@ All commands below assume you are inside `nix develop`. | `MESHCORE_GPSD_HOST` | gpsd hostname (default: 127.0.0.1) | | `MESHCORE_GPSD_PORT` | gpsd port (default: 2947) | | `MESHCORE_GPS_DEVICE` | GPS serial port; overrides auto-detection and gpsd (also in Settings > GPS Device) | +| `MESHCORE_GPIO_CHIP` | `/dev/gpiochipN` number (default: 0; also in Settings > Hardware) | +| `MESHCORE_USE_GPIOD_BACKEND=1` | Poll for IRQ edges instead of using kernel edge interrupts | +| `MESHCORE_EN_PINS` | Comma-separated GPIO pins driven HIGH at init to power the radio | + +### GPIO Chip Selection + +The 40-pin header is `/dev/gpiochip0` on CM4, but **`/dev/gpiochip15` on CM5 and +Pi 5 kernels**, where the header hangs off the RP1 (issue #85). A wrong chip +number means the radio never initialises. `meshcore-console doctor` reports the +configured chip and lists what the host actually has: + +```text +[FAIL] gpiochip: Configured GPIO chip /dev/gpiochip0 not found — available: 11, 12, 13, 14, 15. ... +``` + +Note that `use_gpiod_backend` does **not** switch openhop_core to libgpiod while +python-periphery is installed — the library only swaps in its libgpiod wrapper +when periphery is absent. What the flag actually changes is edge detection, +from kernel edge interrupts to a polling thread, which is a workaround for +kernels that reject the edge request outright. ### Initial Setup (macOS) diff --git a/src/meshcore_console/meshcore/config.py b/src/meshcore_console/meshcore/config.py index 76a7534..d69f99c 100644 --- a/src/meshcore_console/meshcore/config.py +++ b/src/meshcore_console/meshcore/config.py @@ -33,6 +33,9 @@ class HardwareRadioConfig: is_waveshare: bool = False use_dio2_rf: bool = True use_dio3_tcxo: bool = True + gpio_chip: int = 0 + use_gpiod_backend: bool = False + en_pins: tuple[int, ...] = () def to_log_string(self) -> str: return ( @@ -40,7 +43,9 @@ def to_log_string(self) -> str: f"reset_pin={self.reset_pin} busy_pin={self.busy_pin} irq_pin={self.irq_pin} " f"txen_pin={self.txen_pin} rxen_pin={self.rxen_pin} " f"is_waveshare={self.is_waveshare} " - f"use_dio2_rf={self.use_dio2_rf} use_dio3_tcxo={self.use_dio3_tcxo}" + f"use_dio2_rf={self.use_dio2_rf} use_dio3_tcxo={self.use_dio3_tcxo} " + f"gpio_chip={self.gpio_chip} use_gpiod_backend={self.use_gpiod_backend} " + f"en_pins={list(self.en_pins)}" ) @@ -69,6 +74,29 @@ def _env_bool(name: str, default: bool) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} +def parse_pin_list(raw: str) -> tuple[int, ...]: + """Parse a comma-separated GPIO pin list, dropping blanks and non-numbers.""" + pins: list[int] = [] + for part in raw.split(","): + part = part.strip() + if not part: + continue + try: + pin = int(part) + except ValueError: + continue + if pin >= 0 and pin not in pins: + pins.append(pin) + return tuple(pins) + + +def _env_pins(name: str, default: tuple[int, ...]) -> tuple[int, ...]: + value = os.environ.get(name) + if value is None: + return default + return parse_pin_list(value) + + def load_hardware_config_from_env() -> HardwareRadioConfig: return HardwareRadioConfig( bus_id=_env_int("MESHCORE_BUS_ID", 1), @@ -88,6 +116,9 @@ def load_hardware_config_from_env() -> HardwareRadioConfig: is_waveshare=_env_bool("MESHCORE_IS_WAVESHARE", False), use_dio2_rf=_env_bool("MESHCORE_USE_DIO2_RF", True), use_dio3_tcxo=_env_bool("MESHCORE_USE_DIO3_TCXO", True), + gpio_chip=_env_int("MESHCORE_GPIO_CHIP", 0), + use_gpiod_backend=_env_bool("MESHCORE_USE_GPIOD_BACKEND", False), + en_pins=_env_pins("MESHCORE_EN_PINS", ()), ) @@ -110,6 +141,9 @@ def runtime_config_from_settings(settings: MeshcoreSettings) -> RuntimeRadioConf is_waveshare=settings.is_waveshare, use_dio2_rf=settings.use_dio2_rf, use_dio3_tcxo=settings.use_dio3_tcxo, + gpio_chip=settings.gpio_chip, + use_gpiod_backend=settings.use_gpiod_backend, + en_pins=parse_pin_list(settings.en_pins), ) return RuntimeRadioConfig( node_name=settings.node_name, diff --git a/src/meshcore_console/meshcore/runtime.py b/src/meshcore_console/meshcore/runtime.py index 3e74ce2..1d49e41 100644 --- a/src/meshcore_console/meshcore/runtime.py +++ b/src/meshcore_console/meshcore/runtime.py @@ -54,6 +54,9 @@ def create_radio( "coding_rate": config.coding_rate, "preamble_length": config.preamble_length, "is_waveshare": config.is_waveshare, + "gpio_chip": config.gpio_chip, + "use_gpiod_backend": config.use_gpiod_backend, + "en_pins": list(config.en_pins), } signature = inspect.signature(sx1262_radio_type) diff --git a/src/meshcore_console/meshcore/settings.py b/src/meshcore_console/meshcore/settings.py index 342d64f..d557e38 100644 --- a/src/meshcore_console/meshcore/settings.py +++ b/src/meshcore_console/meshcore/settings.py @@ -41,6 +41,19 @@ class MeshcoreSettings: is_waveshare: bool = False use_dio2_rf: bool = True use_dio3_tcxo: bool = True + # Which /dev/gpiochipN carries the 40-pin header. 0 on CM4, but 15 on CM5 + # and Pi 5 kernels where the header hangs off the RP1 (#85). + gpio_chip: int = 0 + # Selects openhop_core's "gpiod" GPIO backend. Despite the name this does + # not switch to libgpiod while python-periphery is installed (the library + # only swaps in its libgpiod wrapper when periphery is *absent*); what it + # actually changes is edge detection, from kernel edge interrupts to a + # polling thread. That is the useful part: some kernels reject the edge + # request outright, leaving the radio connected but deaf (#85). + use_gpiod_backend: bool = False + # Comma-separated GPIO pins driven HIGH at init to power the radio, for + # boards with a LoRa power-enable line (e.g. HG AIOv2). Empty = none (#85). + en_pins: str = "" def clone(self) -> "MeshcoreSettings": return replace(self) diff --git a/src/meshcore_console/platform/conflicts.py b/src/meshcore_console/platform/conflicts.py index ba0959f..838da4b 100644 --- a/src/meshcore_console/platform/conflicts.py +++ b/src/meshcore_console/platform/conflicts.py @@ -7,6 +7,7 @@ from __future__ import annotations +import glob import logging import os import subprocess @@ -25,6 +26,7 @@ class ConflictType(Enum): SERVICE = auto() GPIO_PIN = auto() + GPIO_CHIP = auto() SPI_DEVICE = auto() PERMISSION = auto() @@ -160,13 +162,14 @@ def _check_spi_device(bus_id: int, cs_id: int) -> Conflict | None: return None -def _check_gpio_pin(pin: int) -> Conflict | None: +def _check_gpio_pin(pin: int, gpio_chip: int = 0) -> Conflict | None: """Probe a GPIO pin for availability using periphery.""" try: from periphery import GPIO, GPIOError # type: ignore[import-not-found] + chip_path = f"/dev/gpiochip{gpio_chip}" try: - gpio = GPIO("/dev/gpiochip0", pin, "in") + gpio = GPIO(chip_path, pin, "in") gpio.close() except GPIOError as exc: if "Device or resource busy" in str(exc): @@ -198,6 +201,44 @@ def _check_gpio_pin(pin: int) -> Conflict | None: return None +def available_gpio_chips() -> list[int]: + """Return the numbers of the GPIO chips present on this host, ascending.""" + chips: list[int] = [] + for path in glob.glob("/dev/gpiochip*"): + suffix = path[len("/dev/gpiochip") :] + if suffix.isdigit(): + chips.append(int(suffix)) + return sorted(chips) + + +def _check_gpio_chip(gpio_chip: int) -> Conflict | None: + """Verify the configured GPIO chip device exists.""" + chip_path = f"/dev/gpiochip{gpio_chip}" + if os.path.exists(chip_path): + return None + + found = available_gpio_chips() + if found: + available = ", ".join(str(c) for c in found) + remediation = ( + f"Set the GPIO chip in Settings > Hardware to one of: {available} " + f"(or set MESHCORE_GPIO_CHIP). On CM5/Pi 5 kernels the 40-pin " + f"header is usually {found[-1]}, not 0." + ) + else: + available = "none" + remediation = "No GPIO chips found — check that the kernel exposes /dev/gpiochip*." + + return Conflict( + kind=ConflictType.GPIO_CHIP, + summary=f"GPIO chip {chip_path} not found", + detail=( + f"The configured GPIO chip {chip_path} does not exist. Available chips: {available}." + ), + remediation=remediation, + ) + + # --------------------------------------------------------------------------- # Orchestrator # --------------------------------------------------------------------------- @@ -228,14 +269,24 @@ def run_preflight_checks(hardware: object) -> ConflictReport: if conflict is not None: report.conflicts.append(conflict) - # 3. GPIO pin checks — only probe pins that are actually configured + # 3. GPIO chip check — a missing chip makes every pin probe below + # meaningless, so report it alone and skip them (#85) + gpio_chip = getattr(hardware, "gpio_chip", 0) + conflict = _check_gpio_chip(gpio_chip) + if conflict is not None: + report.conflicts.append(conflict) + logger.warning("Pre-flight: %s", conflict.summary) + return report + + # 4. GPIO pin checks — only probe pins that are actually configured # (pins set to -1 are unused and should not be probed) pin_attrs = ["reset_pin", "busy_pin", "irq_pin", "cs_pin", "txen_pin", "rxen_pin"] - for attr in pin_attrs: - pin = getattr(hardware, attr, -1) + pins = [getattr(hardware, attr, -1) for attr in pin_attrs] + pins.extend(getattr(hardware, "en_pins", ())) + for pin in pins: if pin == -1: continue - conflict = _check_gpio_pin(pin) + conflict = _check_gpio_pin(pin, gpio_chip) if conflict is not None: report.conflicts.append(conflict) diff --git a/src/meshcore_console/radio_cli.py b/src/meshcore_console/radio_cli.py index d947848..fab020d 100644 --- a/src/meshcore_console/radio_cli.py +++ b/src/meshcore_console/radio_cli.py @@ -70,7 +70,35 @@ def register_subcommands(sub: argparse._SubParsersAction) -> None: # type: igno ) +def _doctor_hardware_config() -> Any: + """Load the persisted hardware config, falling back to env defaults.""" + from meshcore_console.meshcore.config import ( + load_hardware_config_from_env, + runtime_config_from_settings, + ) + + try: + from meshcore_console.meshcore.db import open_db + from meshcore_console.meshcore.settings_store import SettingsStore + + conn = open_db() + try: + settings = SettingsStore(conn).load() + finally: + conn.close() + hardware = runtime_config_from_settings(settings).hardware + if hardware is not None: + return hardware + except Exception: # noqa: BLE001 + pass + return load_hardware_config_from_env() + + def _doctor() -> int: + from meshcore_console.platform.conflicts import available_gpio_chips + + hardware = _doctor_hardware_config() + checks: list[tuple[str, bool, str]] = [] checks.append(("linux", os.uname().sysname == "Linux", "Expected Linux host")) checks.append( @@ -80,9 +108,19 @@ def _doctor() -> int: "Expected SPI1 device /dev/spidev1.0 — ensure dtoverlay=spi1-1cs is in /boot/firmware/config.txt", ) ) - checks.append( - ("gpiochip", os.path.exists("/dev/gpiochip0"), "Expected GPIO chip /dev/gpiochip0") - ) + + chip_path = f"/dev/gpiochip{hardware.gpio_chip}" + if os.path.exists(chip_path): + gpiochip_detail = f"Expected GPIO chip {chip_path}" + else: + found = available_gpio_chips() + available = ", ".join(str(c) for c in found) if found else "none" + gpiochip_detail = ( + f"Configured GPIO chip {chip_path} not found — available: {available}. " + f"Set it in Settings > Hardware or via MESHCORE_GPIO_CHIP " + f"(CM5/Pi 5 kernels usually put the 40-pin header on 15, not 0)." + ) + checks.append(("gpiochip", os.path.exists(chip_path), gpiochip_detail)) try: import openhop_core # noqa: F401 diff --git a/src/meshcore_console/ui_gtk/views/settings.py b/src/meshcore_console/ui_gtk/views/settings.py index 1a5b5c5..50a8d52 100644 --- a/src/meshcore_console/ui_gtk/views/settings.py +++ b/src/meshcore_console/ui_gtk/views/settings.py @@ -9,6 +9,7 @@ from gi.repository import Gio, Gtk from meshcore_console.core.services import MeshcoreService +from meshcore_console.meshcore.config import parse_pin_list from meshcore_console.meshcore.logging_setup import ( VALID_LEVELS, export_logs_to_path, @@ -300,9 +301,41 @@ def _build_hardware_panel(self) -> Gtk.Box: grid.attach(self._grid_label("DIO3 TCXO"), 4, 3, 1, 1) grid.attach(self._grid_switch("use_dio3_tcxo"), 5, 3, 1, 1) + # GPIO chip / backend row — the header is on gpiochip0 for CM4 but + # gpiochip15 on CM5 and Pi 5 kernels (#85) + grid.attach(self._grid_label("GPIO Chip"), 0, 4, 1, 1) + grid.attach(self._grid_entry("gpio_chip", 3), 1, 4, 1, 1) + grid.attach(self._grid_label("EN Pins"), 2, 4, 1, 1) + grid.attach(self._grid_entry("en_pins", 7), 3, 4, 1, 1) + grid.attach(self._grid_label("Poll IRQ"), 4, 4, 1, 1) + grid.attach(self._grid_switch("use_gpiod_backend"), 5, 4, 1, 1) + panel.append(grid) + + hint = Gtk.Label(label=self._gpio_chip_hint()) + hint.add_css_class("panel-muted") + hint.set_halign(Gtk.Align.START) + hint.set_wrap(True) + hint.set_max_width_chars(48) + hint.set_size_request(360, -1) + panel.append(hint) + return panel + @staticmethod + def _gpio_chip_hint() -> str: + """Show which GPIO chips this host actually has, to make #85 self-service.""" + from meshcore_console.platform.conflicts import available_gpio_chips + + found = available_gpio_chips() + available = ", ".join(str(c) for c in found) if found else "none found" + return ( + f"GPIO chips on this host: {available}. " + f"EN Pins is a comma-separated list, blank if unused. " + f"Poll IRQ swaps edge interrupts for a polling thread, for kernels " + f"that reject edge requests." + ) + def _build_logging_panel(self) -> Gtk.Box: panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) panel.add_css_class("panel-card") @@ -536,11 +569,14 @@ def _load_from_service(self) -> None: "irq_pin", "txen_pin", "rxen_pin", + "gpio_chip", ): self._set_entry_int(key, getattr(settings, key)) + self._set_entry("en_pins", settings.en_pins) self._set_switch("is_waveshare", settings.is_waveshare) self._set_switch("use_dio2_rf", settings.use_dio2_rf) self._set_switch("use_dio3_tcxo", settings.use_dio3_tcxo) + self._set_switch("use_gpiod_backend", settings.use_gpiod_backend) # Logging self._log_level_combo.set_active_id(settings.log_level) @@ -585,6 +621,7 @@ def _collect_settings(self, allow_partial: bool = False) -> MeshcoreSettings: "irq_pin", "txen_pin", "rxen_pin", + "gpio_chip", ): val = self._parse_int(key, allow_partial) if val is not None: @@ -595,6 +632,8 @@ def _collect_settings(self, allow_partial: bool = False) -> MeshcoreSettings: out.is_waveshare = self._switches["is_waveshare"].get_active() out.use_dio2_rf = self._switches["use_dio2_rf"].get_active() out.use_dio3_tcxo = self._switches["use_dio3_tcxo"].get_active() + out.use_gpiod_backend = self._switches["use_gpiod_backend"].get_active() + out.en_pins = ",".join(str(p) for p in parse_pin_list(self._entries["en_pins"].get_text())) # Logging out.log_level = self._log_level_combo.get_active_id() or "INFO" diff --git a/tests/unit/test_gpio_chip_config.py b/tests/unit/test_gpio_chip_config.py new file mode 100644 index 0000000..4b02dd7 --- /dev/null +++ b/tests/unit/test_gpio_chip_config.py @@ -0,0 +1,159 @@ +"""GPIO chip / backend / enable-pin plumbing (#85). + +The 40-pin header is on /dev/gpiochip0 for CM4, but /dev/gpiochip15 on CM5 and +Pi 5 kernels, so the chip number has to be configurable end to end. +""" + +from __future__ import annotations + +from meshcore_console.meshcore.config import ( + HardwareRadioConfig, + load_hardware_config_from_env, + parse_pin_list, + runtime_config_from_settings, +) +from meshcore_console.meshcore.runtime import create_radio +from meshcore_console.meshcore.settings import MeshcoreSettings, apply_hardware_preset +from meshcore_console.meshcore.settings_store import SettingsStore + + +class _FakeRadio: + """Stands in for SX1262Radio, capturing constructor kwargs.""" + + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + + +def _make_radio(config: HardwareRadioConfig) -> _FakeRadio: + return create_radio(_FakeRadio, config, lambda _msg: None) # type: ignore[arg-type] + + +# --- parse_pin_list -------------------------------------------------------- + + +def test_parse_pin_list_handles_spacing_blanks_and_dupes() -> None: + assert parse_pin_list("16, 17") == (16, 17) + assert parse_pin_list("16,,17,") == (16, 17) + assert parse_pin_list("17,16,17") == (17, 16) + assert parse_pin_list("") == () + assert parse_pin_list(" ") == () + + +def test_parse_pin_list_drops_junk_and_negatives() -> None: + assert parse_pin_list("16,abc,17") == (16, 17) + assert parse_pin_list("-1,16") == (16,) + + +# --- defaults preserve existing behaviour ---------------------------------- + + +def test_defaults_are_unchanged_for_cm4() -> None: + config = HardwareRadioConfig() + assert config.gpio_chip == 0 + assert config.use_gpiod_backend is False + assert config.en_pins == () + + +def test_hardware_presets_do_not_clobber_gpio_chip() -> None: + """gpio_chip is a property of the SoC/kernel, not of the radio board.""" + settings = MeshcoreSettings() + settings.gpio_chip = 15 + settings.use_gpiod_backend = True + settings.en_pins = "16" + + for preset in ("uconsole", "waveshare", "meshadv-mini"): + updated = apply_hardware_preset(settings, preset) + assert updated.gpio_chip == 15, preset + assert updated.use_gpiod_backend is True, preset + assert updated.en_pins == "16", preset + + +# --- settings -> radio kwargs ---------------------------------------------- + + +def test_create_radio_passes_gpio_chip_through() -> None: + radio = _make_radio(HardwareRadioConfig(gpio_chip=15)) + assert radio.kwargs["gpio_chip"] == 15 + + +def test_create_radio_passes_backend_and_en_pins() -> None: + radio = _make_radio( + HardwareRadioConfig(use_gpiod_backend=True, en_pins=(16, 17)), + ) + assert radio.kwargs["use_gpiod_backend"] is True + assert radio.kwargs["en_pins"] == [16, 17] + + +def test_create_radio_sends_empty_en_pins_when_unset() -> None: + radio = _make_radio(HardwareRadioConfig()) + assert radio.kwargs["en_pins"] == [] + + +def test_settings_reach_the_radio_config() -> None: + settings = MeshcoreSettings() + settings.gpio_chip = 15 + settings.use_gpiod_backend = True + settings.en_pins = "16, 17" + + hardware = runtime_config_from_settings(settings).hardware + assert hardware is not None + assert hardware.gpio_chip == 15 + assert hardware.use_gpiod_backend is True + assert hardware.en_pins == (16, 17) + + radio = _make_radio(hardware) + assert radio.kwargs["gpio_chip"] == 15 + assert radio.kwargs["en_pins"] == [16, 17] + + +def test_gpio_chip_appears_in_log_string() -> None: + line = HardwareRadioConfig(gpio_chip=15, en_pins=(16,)).to_log_string() + assert "gpio_chip=15" in line + assert "en_pins=[16]" in line + + +# --- env overrides --------------------------------------------------------- + + +def test_env_overrides(monkeypatch) -> None: + monkeypatch.setenv("MESHCORE_GPIO_CHIP", "15") + monkeypatch.setenv("MESHCORE_USE_GPIOD_BACKEND", "1") + monkeypatch.setenv("MESHCORE_EN_PINS", "16,17") + + config = load_hardware_config_from_env() + assert config.gpio_chip == 15 + assert config.use_gpiod_backend is True + assert config.en_pins == (16, 17) + + +def test_env_defaults_when_unset(monkeypatch) -> None: + monkeypatch.delenv("MESHCORE_GPIO_CHIP", raising=False) + monkeypatch.delenv("MESHCORE_USE_GPIOD_BACKEND", raising=False) + monkeypatch.delenv("MESHCORE_EN_PINS", raising=False) + + config = load_hardware_config_from_env() + assert config.gpio_chip == 0 + assert config.use_gpiod_backend is False + assert config.en_pins == () + + +# --- persistence ----------------------------------------------------------- + + +def test_settings_round_trip_through_store(tmp_path) -> None: + from meshcore_console.meshcore.db import open_db + + conn = open_db(str(tmp_path / "test.db")) + store = SettingsStore(conn) + + settings = MeshcoreSettings() + settings.gpio_chip = 15 + settings.use_gpiod_backend = True + settings.en_pins = "16,17" + store.save(settings) + + loaded = store.load() + assert loaded.gpio_chip == 15 + assert loaded.use_gpiod_backend is True + assert loaded.en_pins == "16,17" + conn.close() diff --git a/tests/unit/test_gpio_chip_conflicts.py b/tests/unit/test_gpio_chip_conflicts.py new file mode 100644 index 0000000..fc037b6 --- /dev/null +++ b/tests/unit/test_gpio_chip_conflicts.py @@ -0,0 +1,88 @@ +"""Pre-flight detection of a missing/misconfigured GPIO chip (#85).""" + +from __future__ import annotations + +from meshcore_console.meshcore.config import HardwareRadioConfig +from meshcore_console.platform import conflicts as mod +from meshcore_console.platform.conflicts import ConflictType, run_preflight_checks + + +def _fake_chips(monkeypatch, present: list[int]) -> None: + """Pretend /dev holds exactly the given gpiochip devices.""" + paths = {f"/dev/gpiochip{n}" for n in present} + monkeypatch.setattr(mod.os.path, "exists", lambda p: p in paths) + monkeypatch.setattr(mod, "available_gpio_chips", lambda: sorted(present)) + + +def test_missing_chip_is_reported_with_available_alternatives(monkeypatch) -> None: + monkeypatch.setattr(mod.sys, "platform", "linux") + monkeypatch.setattr(mod, "_check_service", lambda _name: None) + monkeypatch.setattr(mod, "_check_spi_device", lambda _b, _c: None) + _fake_chips(monkeypatch, [11, 12, 13, 14, 15]) + + report = run_preflight_checks(HardwareRadioConfig(gpio_chip=0)) + + assert report.has_conflicts + conflict = report.conflicts[0] + assert conflict.kind is ConflictType.GPIO_CHIP + assert "/dev/gpiochip0" in conflict.summary + # The reporter needs to be told what to switch to. + assert "15" in conflict.remediation + + +def test_missing_chip_suppresses_noisy_pin_probes(monkeypatch) -> None: + """Every pin probe would fail for the same reason; report the cause once.""" + monkeypatch.setattr(mod.sys, "platform", "linux") + monkeypatch.setattr(mod, "_check_service", lambda _name: None) + monkeypatch.setattr(mod, "_check_spi_device", lambda _b, _c: None) + _fake_chips(monkeypatch, [15]) + + probed: list[int] = [] + monkeypatch.setattr(mod, "_check_gpio_pin", lambda pin, chip=0: probed.append(pin)) + + report = run_preflight_checks(HardwareRadioConfig(gpio_chip=0)) + + assert len(report.conflicts) == 1 + assert probed == [] + + +def test_configured_chip_present_allows_pin_probes(monkeypatch) -> None: + monkeypatch.setattr(mod.sys, "platform", "linux") + monkeypatch.setattr(mod, "_check_service", lambda _name: None) + monkeypatch.setattr(mod, "_check_spi_device", lambda _b, _c: None) + _fake_chips(monkeypatch, [15]) + + probed: list[tuple[int, int]] = [] + monkeypatch.setattr( + mod, "_check_gpio_pin", lambda pin, chip=0: probed.append((pin, chip)) or None + ) + + config = HardwareRadioConfig(gpio_chip=15, en_pins=(16,)) + report = run_preflight_checks(config) + + assert not report.has_conflicts + # Pins are probed against the configured chip, not a hardcoded 0. + assert probed, "expected pin probes to run" + assert {chip for _pin, chip in probed} == {15} + # Unused pins (-1) are skipped; configured enable pins are included. + assert -1 not in [pin for pin, _chip in probed] + assert 16 in [pin for pin, _chip in probed] + + +def test_no_chips_at_all_reports_kernel_problem(monkeypatch) -> None: + monkeypatch.setattr(mod.sys, "platform", "linux") + monkeypatch.setattr(mod, "_check_service", lambda _name: None) + monkeypatch.setattr(mod, "_check_spi_device", lambda _b, _c: None) + _fake_chips(monkeypatch, []) + + report = run_preflight_checks(HardwareRadioConfig(gpio_chip=0)) + + conflict = report.conflicts[0] + assert conflict.kind is ConflictType.GPIO_CHIP + assert "No GPIO chips found" in conflict.remediation + + +def test_non_linux_hosts_skip_all_checks(monkeypatch) -> None: + monkeypatch.setattr(mod.sys, "platform", "darwin") + report = run_preflight_checks(HardwareRadioConfig(gpio_chip=99)) + assert not report.has_conflicts From 9c875967861b4ce8534341baceac4f2e13dabe73 Mon Sep 17 00:00:00 2001 From: Cameron Will Date: Tue, 11 Aug 2026 19:43:35 -0400 Subject: [PATCH 3/4] fix: Let the environment override the saved hardware settings (#85) The GTK app built its radio configuration from the settings database only, so MESHCORE_GPIO_CHIP did nothing there. `doctor` had the same gap: it read the persisted chip and ignored the variable. A tester on CM5 hardware could therefore not point the app at /dev/gpiochip15 without the udev symlink. - Add one `_HARDWARE_ENV_OVERRIDES` table in `meshcore/config.py`. The CLI, the GTK app and `doctor` now apply it through `runtime_config_from_settings()`, so all three agree on the values that reach the radio. The environment wins, which keeps a broken saved configuration recoverable from the command line. - Say in Settings > Hardware which variables are set, because an edit there cannot beat them. - Report the source of the chip number in `doctor`. The "Settings" button on the conflict screen did nothing. `navigate_to` switches the inner page stack, but the conflict screen sits in the outer content stack, so the page changed behind a screen the user never left. The button now leaves that stack first. Add the "uConsole HG AIOv2" board preset, which is the uConsole pinout plus LoRa enable pin 27. Every preset now states `en_pins`, so a switch between boards clears a stale pin. `gpio_chip` stays out of the presets: it follows the kernel, not the board. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 18 ++- README.md | 15 ++- src/meshcore_console/meshcore/config.py | 108 +++++++++++------- src/meshcore_console/meshcore/settings.py | 21 +++- src/meshcore_console/radio_cli.py | 14 ++- src/meshcore_console/ui_gtk/views/settings.py | 28 +++-- .../ui_gtk/windows/main_window.py | 12 +- tests/conftest.py | 19 +++ tests/unit/test_gpio_chip_config.py | 103 ++++++++++++++++- 9 files changed, 280 insertions(+), 58 deletions(-) create mode 100644 tests/conftest.py diff --git a/AGENTS.md b/AGENTS.md index 65212a5..336ef1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,7 +159,13 @@ All commands below assume you are inside `nix develop`. | `MESHCORE_GPS_DEVICE` | GPS serial port; overrides auto-detection and gpsd (also in Settings > GPS Device) | | `MESHCORE_GPIO_CHIP` | `/dev/gpiochipN` number (default: 0; also in Settings > Hardware) | | `MESHCORE_USE_GPIOD_BACKEND=1` | Poll for IRQ edges instead of using kernel edge interrupts | -| `MESHCORE_EN_PINS` | Comma-separated GPIO pins driven HIGH at init to power the radio | +| `MESHCORE_EN_PINS` | Comma-separated GPIO pins driven HIGH at init to power the radio (HG AIOv2: `27`) | + +Every hardware variable above overrides the matching value in Settings > +Hardware. `_HARDWARE_ENV_OVERRIDES` in `meshcore/config.py` holds the whole +table, and `runtime_config_from_settings()` applies it, so the CLI, the GTK app +and `doctor` all agree on the values the radio gets. The settings screen says +which variables are set, because an edit there cannot beat them (#85). ### GPIO Chip Selection @@ -172,6 +178,16 @@ configured chip and lists what the host actually has: [FAIL] gpiochip: Configured GPIO chip /dev/gpiochip0 not found — available: 11, 12, 13, 14, 15. ... ``` +### Board Presets + +`HARDWARE_PRESETS` in `meshcore/settings.py` owns the per-board pinout, +including `en_pins`: the LoRa power-enable line belongs to the board, so every +preset states it, and a switch between boards clears a stale pin. The +`hg-aiov2` preset is the uConsole pinout plus enable pin 27. + +`gpio_chip` is deliberately *not* in any preset. It follows the SoC and the +kernel (CM4 vs CM5), not the radio board, so a preset must never clobber it. + Note that `use_gpiod_backend` does **not** switch openhop_core to libgpiod while python-periphery is installed — the library only swaps in its libgpiod wrapper when periphery is absent. What the flag actually changes is edge detection, diff --git a/README.md b/README.md index 1f902c6..af76c29 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,18 @@ If `doctor` fails on SPI/GPIO, confirm these before retrying: > uConsole internal display. If this has happened, remove `dtparam=spi=on` > from `/boot/firmware/config.txt` via SSH and reboot. -Hardware overrides can be supplied via env vars when running `meshcore-console`. -Notable radio bring-up flags: +Hardware overrides can be supplied via env vars when running `meshcore-console` +or the GTK app. An env var wins over the value saved in Settings > Hardware, so +a saved setting that stops the radio from starting stays recoverable from the +command line. Notable radio bring-up flags: - `MESHCORE_USE_DIO2_RF=1` (default in this repo) - `MESHCORE_USE_DIO3_TCXO=1` (default in this repo) +- `MESHCORE_GPIO_CHIP=15` — which `/dev/gpiochipN` carries the 40-pin header. + It is 0 on CM4, but 15 on CM5 and Pi 5 kernels, where the header hangs off + the RP1. `doctor` lists the chips your host actually has. +- `MESHCORE_EN_PINS=27` — GPIO pins driven HIGH at init to power the radio. + The HackerGadgets AIOv2 enable pin is 27; the **uConsole HG AIOv2** board + preset sets this for you. +- `MESHCORE_USE_GPIOD_BACKEND=1` — poll for IRQ edges instead of asking the + kernel for edge interrupts. Try this if the radio connects but receives + nothing, which happens on kernels that reject the edge request. diff --git a/src/meshcore_console/meshcore/config.py b/src/meshcore_console/meshcore/config.py index d69f99c..59f09ca 100644 --- a/src/meshcore_console/meshcore/config.py +++ b/src/meshcore_console/meshcore/config.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from dataclasses import dataclass +from dataclasses import dataclass, replace from .settings import MeshcoreSettings @@ -57,21 +57,11 @@ def load_runtime_config(node_name: str) -> RuntimeRadioConfig: ) -def _env_int(name: str, default: int) -> int: - value = os.environ.get(name) - if value is None: - return default - try: - return int(value) - except ValueError: - return default - - def _env_bool(name: str, default: bool) -> bool: value = os.environ.get(name) if value is None: return default - return value.strip().lower() in {"1", "true", "yes", "on"} + return _parse_bool(value) def parse_pin_list(raw: str) -> tuple[int, ...]: @@ -90,36 +80,75 @@ def parse_pin_list(raw: str) -> tuple[int, ...]: return tuple(pins) -def _env_pins(name: str, default: tuple[int, ...]) -> tuple[int, ...]: - value = os.environ.get(name) - if value is None: - return default - return parse_pin_list(value) +def _parse_int(raw: str) -> int | None: + try: + return int(raw) + except ValueError: + return None + + +def _parse_bool(raw: str) -> bool: + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +# Every hardware field the environment can override, with the parser for it. +# One table keeps the CLI, the GTK app and `doctor` on the same values (#85). +_HARDWARE_ENV_OVERRIDES: tuple[tuple[str, str, object], ...] = ( + ("bus_id", "MESHCORE_BUS_ID", _parse_int), + ("cs_id", "MESHCORE_CS_ID", _parse_int), + ("cs_pin", "MESHCORE_CS_PIN", _parse_int), + ("reset_pin", "MESHCORE_RESET_PIN", _parse_int), + ("busy_pin", "MESHCORE_BUSY_PIN", _parse_int), + ("irq_pin", "MESHCORE_IRQ_PIN", _parse_int), + ("txen_pin", "MESHCORE_TXEN_PIN", _parse_int), + ("rxen_pin", "MESHCORE_RXEN_PIN", _parse_int), + ("frequency", "MESHCORE_FREQUENCY", _parse_int), + ("tx_power", "MESHCORE_TX_POWER", _parse_int), + ("spreading_factor", "MESHCORE_SPREADING_FACTOR", _parse_int), + ("bandwidth", "MESHCORE_BANDWIDTH", _parse_int), + ("coding_rate", "MESHCORE_CODING_RATE", _parse_int), + ("preamble_length", "MESHCORE_PREAMBLE_LENGTH", _parse_int), + ("is_waveshare", "MESHCORE_IS_WAVESHARE", _parse_bool), + ("use_dio2_rf", "MESHCORE_USE_DIO2_RF", _parse_bool), + ("use_dio3_tcxo", "MESHCORE_USE_DIO3_TCXO", _parse_bool), + ("gpio_chip", "MESHCORE_GPIO_CHIP", _parse_int), + ("use_gpiod_backend", "MESHCORE_USE_GPIOD_BACKEND", _parse_bool), + ("en_pins", "MESHCORE_EN_PINS", parse_pin_list), +) + + +def hardware_env_overrides() -> dict[str, str]: + """Return the hardware env vars that are set, as {field name: env var}.""" + return { + field: name + for field, name, _parse in _HARDWARE_ENV_OVERRIDES + if os.environ.get(name) is not None + } + + +def apply_hardware_env_overrides(base: HardwareRadioConfig) -> HardwareRadioConfig: + """Return *base* with the MESHCORE_* environment overrides applied. + + The environment wins over the persisted settings. A saved configuration + that stops the radio from starting — the wrong GPIO chip, for example — + must stay recoverable from the command line, because the settings screen + is behind the radio connection (#85). + """ + out = replace(base) + for field, name, parse in _HARDWARE_ENV_OVERRIDES: + raw = os.environ.get(name) + if raw is None: + continue + value = parse(raw) # type: ignore[operator] + if value is None: + continue + setattr(out, field, value) + return out def load_hardware_config_from_env() -> HardwareRadioConfig: - return HardwareRadioConfig( - bus_id=_env_int("MESHCORE_BUS_ID", 1), - cs_id=_env_int("MESHCORE_CS_ID", 0), - cs_pin=_env_int("MESHCORE_CS_PIN", -1), - reset_pin=_env_int("MESHCORE_RESET_PIN", 25), - busy_pin=_env_int("MESHCORE_BUSY_PIN", 24), - irq_pin=_env_int("MESHCORE_IRQ_PIN", 26), - txen_pin=_env_int("MESHCORE_TXEN_PIN", -1), - rxen_pin=_env_int("MESHCORE_RXEN_PIN", -1), - frequency=_env_int("MESHCORE_FREQUENCY", 910_525_000), - tx_power=_env_int("MESHCORE_TX_POWER", 22), - spreading_factor=_env_int("MESHCORE_SPREADING_FACTOR", 7), - bandwidth=_env_int("MESHCORE_BANDWIDTH", 62_500), - coding_rate=_env_int("MESHCORE_CODING_RATE", 5), - preamble_length=_env_int("MESHCORE_PREAMBLE_LENGTH", 17), - is_waveshare=_env_bool("MESHCORE_IS_WAVESHARE", False), - use_dio2_rf=_env_bool("MESHCORE_USE_DIO2_RF", True), - use_dio3_tcxo=_env_bool("MESHCORE_USE_DIO3_TCXO", True), - gpio_chip=_env_int("MESHCORE_GPIO_CHIP", 0), - use_gpiod_backend=_env_bool("MESHCORE_USE_GPIOD_BACKEND", False), - en_pins=_env_pins("MESHCORE_EN_PINS", ()), - ) + """Build a hardware config from the environment, over the built-in defaults.""" + return apply_hardware_env_overrides(HardwareRadioConfig()) def runtime_config_from_settings(settings: MeshcoreSettings) -> RuntimeRadioConfig: @@ -145,6 +174,7 @@ def runtime_config_from_settings(settings: MeshcoreSettings) -> RuntimeRadioConf use_gpiod_backend=settings.use_gpiod_backend, en_pins=parse_pin_list(settings.en_pins), ) + hardware = apply_hardware_env_overrides(hardware) return RuntimeRadioConfig( node_name=settings.node_name, share_public_key=True, diff --git a/src/meshcore_console/meshcore/settings.py b/src/meshcore_console/meshcore/settings.py index d557e38..23c2112 100644 --- a/src/meshcore_console/meshcore/settings.py +++ b/src/meshcore_console/meshcore/settings.py @@ -90,7 +90,7 @@ def apply_preset(settings: MeshcoreSettings, preset: str) -> MeshcoreSettings: return updated -HARDWARE_PRESETS: dict[str, dict[str, int | bool]] = { +HARDWARE_PRESETS: dict[str, dict[str, int | bool | str]] = { "uconsole": { "bus_id": 1, "cs_id": 0, @@ -103,6 +103,23 @@ def apply_preset(settings: MeshcoreSettings, preset: str) -> MeshcoreSettings: "is_waveshare": False, "use_dio2_rf": True, "use_dio3_tcxo": True, + "en_pins": "", + }, + # Same wiring as the plain uConsole board, plus the LoRa power-enable pin + # that the HackerGadgets AIOv2 puts on GPIO 27 (#85). + "hg-aiov2": { + "bus_id": 1, + "cs_id": 0, + "cs_pin": -1, + "reset_pin": 25, + "busy_pin": 24, + "irq_pin": 26, + "txen_pin": -1, + "rxen_pin": -1, + "is_waveshare": False, + "use_dio2_rf": True, + "use_dio3_tcxo": True, + "en_pins": "27", }, "waveshare": { "bus_id": 0, @@ -116,6 +133,7 @@ def apply_preset(settings: MeshcoreSettings, preset: str) -> MeshcoreSettings: "is_waveshare": True, "use_dio2_rf": False, "use_dio3_tcxo": False, + "en_pins": "", }, "meshadv-mini": { "bus_id": 0, @@ -129,6 +147,7 @@ def apply_preset(settings: MeshcoreSettings, preset: str) -> MeshcoreSettings: "is_waveshare": False, "use_dio2_rf": False, "use_dio3_tcxo": False, + "en_pins": "", }, } diff --git a/src/meshcore_console/radio_cli.py b/src/meshcore_console/radio_cli.py index fab020d..2ed0cbe 100644 --- a/src/meshcore_console/radio_cli.py +++ b/src/meshcore_console/radio_cli.py @@ -71,7 +71,12 @@ def register_subcommands(sub: argparse._SubParsersAction) -> None: # type: igno def _doctor_hardware_config() -> Any: - """Load the persisted hardware config, falling back to env defaults.""" + """Return the hardware config that the radio will actually use. + + This is the persisted configuration with the environment applied on top, + the same combination that ``runtime_config_from_settings`` gives the GTK + app, so ``doctor`` never reports a chip that the radio will not open. + """ from meshcore_console.meshcore.config import ( load_hardware_config_from_env, runtime_config_from_settings, @@ -95,6 +100,7 @@ def _doctor_hardware_config() -> Any: def _doctor() -> int: + from meshcore_console.meshcore.config import hardware_env_overrides from meshcore_console.platform.conflicts import available_gpio_chips hardware = _doctor_hardware_config() @@ -109,14 +115,16 @@ def _doctor() -> int: ) ) + overrides = hardware_env_overrides() + chip_source = " (from MESHCORE_GPIO_CHIP)" if "gpio_chip" in overrides else "" chip_path = f"/dev/gpiochip{hardware.gpio_chip}" if os.path.exists(chip_path): - gpiochip_detail = f"Expected GPIO chip {chip_path}" + gpiochip_detail = f"Expected GPIO chip {chip_path}{chip_source}" else: found = available_gpio_chips() available = ", ".join(str(c) for c in found) if found else "none" gpiochip_detail = ( - f"Configured GPIO chip {chip_path} not found — available: {available}. " + f"Configured GPIO chip {chip_path}{chip_source} not found — available: {available}. " f"Set it in Settings > Hardware or via MESHCORE_GPIO_CHIP " f"(CM5/Pi 5 kernels usually put the 40-pin header on 15, not 0)." ) diff --git a/src/meshcore_console/ui_gtk/views/settings.py b/src/meshcore_console/ui_gtk/views/settings.py index 50a8d52..1c54eb7 100644 --- a/src/meshcore_console/ui_gtk/views/settings.py +++ b/src/meshcore_console/ui_gtk/views/settings.py @@ -9,7 +9,7 @@ from gi.repository import Gio, Gtk from meshcore_console.core.services import MeshcoreService -from meshcore_console.meshcore.config import parse_pin_list +from meshcore_console.meshcore.config import hardware_env_overrides, parse_pin_list from meshcore_console.meshcore.logging_setup import ( VALID_LEVELS, export_logs_to_path, @@ -254,6 +254,7 @@ def _build_hardware_panel(self) -> Gtk.Box: preset_box.append(preset_label) self._hw_preset = Gtk.ComboBoxText.new() self._hw_preset.append("uconsole", "uConsole (AIO)") + self._hw_preset.append("hg-aiov2", "uConsole HG AIOv2") self._hw_preset.append("waveshare", "Waveshare") self._hw_preset.append("meshadv-mini", "meshadv-mini") self._hw_preset.append("custom", "Custom") @@ -329,12 +330,25 @@ def _gpio_chip_hint() -> str: found = available_gpio_chips() available = ", ".join(str(c) for c in found) if found else "none found" - return ( - f"GPIO chips on this host: {available}. " - f"EN Pins is a comma-separated list, blank if unused. " - f"Poll IRQ swaps edge interrupts for a polling thread, for kernels " - f"that reject edge requests." - ) + lines = [ + f"GPIO chips on this host: {available}.", + "EN Pins is a comma-separated list of pins driven HIGH at init, " + "blank if unused. The HackerGadgets AIOv2 LoRa enable pin is 27.", + "Poll IRQ swaps edge interrupts for a polling thread, for kernels " + "that reject edge requests.", + ] + + # An env var wins over these fields, so say so. Otherwise an edit here + # looks like it does nothing (#85). + overrides = sorted(set(hardware_env_overrides().values())) + if overrides: + names = ", ".join(overrides) + verb = "is" if len(overrides) == 1 else "are" + lines.append( + f"Note: {names} {verb} set in the environment. The environment " + f"overrides the values saved here." + ) + return " ".join(lines) def _build_logging_panel(self) -> Gtk.Box: panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) diff --git a/src/meshcore_console/ui_gtk/windows/main_window.py b/src/meshcore_console/ui_gtk/windows/main_window.py index 52e8093..8fffdfc 100644 --- a/src/meshcore_console/ui_gtk/windows/main_window.py +++ b/src/meshcore_console/ui_gtk/windows/main_window.py @@ -563,7 +563,7 @@ def _show_conflict_screen(self, report: ConflictReport) -> None: report, on_retry=self._on_conflict_retry, on_stop_service=self._on_conflict_stop_service, - on_settings=lambda: self.navigate_to("settings"), + on_settings=self._on_conflict_settings, ) self._content_stack.add_named(screen, "conflict") self._content_stack.set_visible_child_name("conflict") @@ -573,6 +573,16 @@ def _show_conflict_screen(self, report: ConflictReport) -> None: if report.has_service_conflict and not settings.suppress_service_dialog: self._show_service_conflict_dialog(report) + def _on_conflict_settings(self) -> None: + """Open the settings screen from the conflict screen. + + ``navigate_to`` switches the inner page stack only. The conflict + screen sits in the outer content stack, so this must leave that stack + as well or the button looks dead (#85). + """ + self._content_stack.set_visible_child_name("main") + self.navigate_to("settings") + def _on_conflict_retry(self) -> None: """Retry connection from the conflict screen.""" # Switch back to main UI and trigger connect diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..223aa83 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +"""Shared test fixtures.""" + +from __future__ import annotations + +import pytest + +from meshcore_console.meshcore.config import _HARDWARE_ENV_OVERRIDES + + +@pytest.fixture(autouse=True) +def _clear_hardware_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Remove the MESHCORE_* hardware overrides for every test. + + The environment now wins over the persisted settings (#85), so a developer + shell that exports MESHCORE_GPIO_CHIP would otherwise change the result of + any test that builds a radio config. + """ + for _field, name, _parse in _HARDWARE_ENV_OVERRIDES: + monkeypatch.delenv(name, raising=False) diff --git a/tests/unit/test_gpio_chip_config.py b/tests/unit/test_gpio_chip_config.py index 4b02dd7..ffb463f 100644 --- a/tests/unit/test_gpio_chip_config.py +++ b/tests/unit/test_gpio_chip_config.py @@ -8,6 +8,8 @@ from meshcore_console.meshcore.config import ( HardwareRadioConfig, + apply_hardware_env_overrides, + hardware_env_overrides, load_hardware_config_from_env, parse_pin_list, runtime_config_from_settings, @@ -55,17 +57,19 @@ def test_defaults_are_unchanged_for_cm4() -> None: def test_hardware_presets_do_not_clobber_gpio_chip() -> None: - """gpio_chip is a property of the SoC/kernel, not of the radio board.""" + """gpio_chip is a property of the SoC/kernel, not of the radio board. + + en_pins is the opposite: the enable line belongs to the board, so each + preset states it (#85). + """ settings = MeshcoreSettings() settings.gpio_chip = 15 settings.use_gpiod_backend = True - settings.en_pins = "16" - for preset in ("uconsole", "waveshare", "meshadv-mini"): + for preset in ("uconsole", "hg-aiov2", "waveshare", "meshadv-mini"): updated = apply_hardware_preset(settings, preset) assert updated.gpio_chip == 15, preset assert updated.use_gpiod_backend is True, preset - assert updated.en_pins == "16", preset # --- settings -> radio kwargs ---------------------------------------------- @@ -157,3 +161,94 @@ def test_settings_round_trip_through_store(tmp_path) -> None: assert loaded.use_gpiod_backend is True assert loaded.en_pins == "16,17" conn.close() + + +# --- env overrides beat the persisted settings (#85) ----------------------- + + +def test_env_overrides_the_persisted_settings(monkeypatch) -> None: + """The GTK app reads the settings DB, so the env layer has to apply there.""" + monkeypatch.setenv("MESHCORE_GPIO_CHIP", "15") + monkeypatch.setenv("MESHCORE_USE_GPIOD_BACKEND", "1") + monkeypatch.setenv("MESHCORE_EN_PINS", "27") + + settings = MeshcoreSettings() + settings.gpio_chip = 0 + settings.use_gpiod_backend = False + settings.en_pins = "" + + hardware = runtime_config_from_settings(settings).hardware + assert hardware is not None + assert hardware.gpio_chip == 15 + assert hardware.use_gpiod_backend is True + assert hardware.en_pins == (27,) + + +def test_settings_win_when_the_env_is_unset() -> None: + settings = MeshcoreSettings() + settings.gpio_chip = 15 + + hardware = runtime_config_from_settings(settings).hardware + assert hardware is not None + assert hardware.gpio_chip == 15 + + +def test_unparsable_env_value_keeps_the_settings_value(monkeypatch) -> None: + monkeypatch.setenv("MESHCORE_GPIO_CHIP", "not-a-number") + + settings = MeshcoreSettings() + settings.gpio_chip = 15 + + hardware = runtime_config_from_settings(settings).hardware + assert hardware is not None + assert hardware.gpio_chip == 15 + + +def test_apply_hardware_env_overrides_leaves_untouched_fields(monkeypatch) -> None: + monkeypatch.setenv("MESHCORE_GPIO_CHIP", "15") + + base = HardwareRadioConfig(gpio_chip=0, reset_pin=25, en_pins=(27,)) + out = apply_hardware_env_overrides(base) + + assert out.gpio_chip == 15 + assert out.reset_pin == 25 + assert out.en_pins == (27,) + assert base.gpio_chip == 0 # the caller's config is not mutated + + +def test_hardware_env_overrides_reports_what_is_set(monkeypatch) -> None: + assert hardware_env_overrides() == {} + + monkeypatch.setenv("MESHCORE_GPIO_CHIP", "15") + monkeypatch.setenv("MESHCORE_EN_PINS", "27") + + assert hardware_env_overrides() == { + "gpio_chip": "MESHCORE_GPIO_CHIP", + "en_pins": "MESHCORE_EN_PINS", + } + + +# --- board presets --------------------------------------------------------- + + +def test_aiov2_preset_sets_the_lora_enable_pin() -> None: + """The HackerGadgets AIOv2 powers its LoRa module from GPIO 27 (#85).""" + settings = apply_hardware_preset(MeshcoreSettings(), "hg-aiov2") + assert settings.en_pins == "27" + + hardware = runtime_config_from_settings(settings).hardware + assert hardware is not None + assert hardware.en_pins == (27,) + + +def test_switching_away_from_the_aiov2_preset_clears_the_enable_pin() -> None: + settings = apply_hardware_preset(MeshcoreSettings(), "hg-aiov2") + settings = apply_hardware_preset(settings, "uconsole") + assert settings.en_pins == "" + + +def test_aiov2_preset_keeps_the_uconsole_pinout() -> None: + aiov2 = apply_hardware_preset(MeshcoreSettings(), "hg-aiov2") + uconsole = apply_hardware_preset(MeshcoreSettings(), "uconsole") + for pin in ("bus_id", "cs_id", "cs_pin", "reset_pin", "busy_pin", "irq_pin"): + assert getattr(aiov2, pin) == getattr(uconsole, pin) From 30e73438bcc97c0f63c7148f1ac50dca2efd573c Mon Sep 17 00:00:00 2001 From: Cameron Will Date: Mon, 17 Aug 2026 16:46:31 -0400 Subject: [PATCH 4/4] fix: Make the settings screen reachable and the board preset effective (#85) Driving the GTK app under Xvfb found two faults that code inspection missed. `navigate_to("settings")` switched the page and then deactivated the nav buttons. Deactivating the last active button makes `_on_nav_button_toggled` switch it back on, which switches the page back with it, so the settings page never appeared. Update the buttons first, as `_on_settings_clicked` already does. This is what made the conflict screen's "Settings" button look dead: the outer stack fix alone landed the user back on the previous page. `_on_hw_preset_changed` wrote every preset field back to its entry except `en_pins`. `_collect_settings` reads the entries, so the AIOv2 preset looked right but saved an empty enable pin. Co-Authored-By: Claude Opus 5 --- src/meshcore_console/ui_gtk/views/settings.py | 3 +++ src/meshcore_console/ui_gtk/windows/main_window.py | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/meshcore_console/ui_gtk/views/settings.py b/src/meshcore_console/ui_gtk/views/settings.py index 1c54eb7..806e331 100644 --- a/src/meshcore_console/ui_gtk/views/settings.py +++ b/src/meshcore_console/ui_gtk/views/settings.py @@ -512,6 +512,9 @@ def _on_hw_preset_changed(self, _combo: Gtk.ComboBoxText) -> None: "rxen_pin", ): self._set_entry_int(key, getattr(updated, key)) + # The enable pin belongs to the board, so the preset owns it too. Write + # it back to the entry, because _collect_settings reads the entry (#85). + self._set_entry("en_pins", updated.en_pins) self._set_switch("is_waveshare", updated.is_waveshare) self._set_switch("use_dio2_rf", updated.use_dio2_rf) self._set_switch("use_dio3_tcxo", updated.use_dio3_tcxo) diff --git a/src/meshcore_console/ui_gtk/windows/main_window.py b/src/meshcore_console/ui_gtk/windows/main_window.py index 8fffdfc..426b7aa 100644 --- a/src/meshcore_console/ui_gtk/windows/main_window.py +++ b/src/meshcore_console/ui_gtk/windows/main_window.py @@ -270,14 +270,16 @@ def navigate_to(self, page_name: str, then: object = None) -> None: *then* should be a ``(method_name, arg)`` tuple — e.g. ``("select_peer", peer_id)`` — or ``None`` to just switch pages. """ - self._switch_to_page(page_name) - # Update nav buttons + # Update the nav buttons first. Deactivating the last active button + # makes _on_nav_button_toggled switch it back on, which switches the + # page with it, so a page change before this one gets undone (#85). if page_name == "settings": for btn in self._nav_buttons.values(): btn.set_active(False) elif page_name in self._nav_buttons: for name, btn in self._nav_buttons.items(): btn.set_active(name == page_name) + self._switch_to_page(page_name) self._focus_current_view() # Call target method if requested if then is not None: