diff --git a/AGENTS.md b/AGENTS.md index 72d9d04..336ef1e 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) ↓ @@ -157,6 +157,42 @@ 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 (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 + +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. ... +``` + +### 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, +from kernel edge interrupts to a polling thread, which is a workaround for +kernels that reject the edge request outright. ### Initial Setup (macOS) @@ -192,7 +228,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..af76c29 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 @@ -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/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/config.py b/src/meshcore_console/meshcore/config.py index 76a7534..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 @@ -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)}" ) @@ -52,43 +57,98 @@ def load_runtime_config(node_name: str) -> RuntimeRadioConfig: ) -def _env_int(name: str, default: int) -> int: +def _env_bool(name: str, default: bool) -> bool: value = os.environ.get(name) if value is None: return default + return _parse_bool(value) + + +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 _parse_int(raw: str) -> int | None: try: - return int(value) + return int(raw) 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 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), - ) + """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: @@ -110,7 +170,11 @@ 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), ) + hardware = apply_hardware_env_overrides(hardware) return RuntimeRadioConfig( node_name=settings.node_name, share_public_key=True, 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..1d49e41 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] @@ -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) @@ -94,7 +97,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/meshcore/settings.py b/src/meshcore_console/meshcore/settings.py index 342d64f..23c2112 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) @@ -77,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, @@ -90,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, @@ -103,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, @@ -116,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/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..838da4b 100644 --- a/src/meshcore_console/platform/conflicts.py +++ b/src/meshcore_console/platform/conflicts.py @@ -1,12 +1,13 @@ """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. """ 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 c1faaa4..2ed0cbe 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") @@ -70,7 +70,41 @@ def register_subcommands(sub: argparse._SubParsersAction) -> None: # type: igno ) +def _doctor_hardware_config() -> Any: + """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, + ) + + 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.meshcore.config import hardware_env_overrides + 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,16 +114,28 @@ 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") - ) + + 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}{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}{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)." + ) + checks.append(("gpiochip", os.path.exists(chip_path), gpiochip_detail)) 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 +152,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 +174,7 @@ async def _run_listen( async def _run_send( - session: PyMCCoreSession, + session: OpenHopCoreSession, peer: str, message: str, debug: bool, @@ -153,7 +199,7 @@ async def _run_send( async def _run_advert( - session: PyMCCoreSession, + session: OpenHopCoreSession, *, name: str | None, lat: float, @@ -241,7 +287,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/src/meshcore_console/ui_gtk/views/settings.py b/src/meshcore_console/ui_gtk/views/settings.py index 1a5b5c5..806e331 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 hardware_env_overrides, parse_pin_list from meshcore_console.meshcore.logging_setup import ( VALID_LEVELS, export_logs_to_path, @@ -253,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") @@ -300,9 +302,54 @@ 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" + 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) panel.add_css_class("panel-card") @@ -465,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) @@ -536,11 +586,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 +638,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 +649,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/src/meshcore_console/ui_gtk/windows/main_window.py b/src/meshcore_console/ui_gtk/windows/main_window.py index 52e8093..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: @@ -563,7 +565,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 +575,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/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/tests/unit/test_gpio_chip_config.py b/tests/unit/test_gpio_chip_config.py new file mode 100644 index 0000000..ffb463f --- /dev/null +++ b/tests/unit/test_gpio_chip_config.py @@ -0,0 +1,254 @@ +"""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, + apply_hardware_env_overrides, + hardware_env_overrides, + 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. + + 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 + + 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 + + +# --- 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() + + +# --- 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) 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 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"