Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.)
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion src/meshcore_console/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/meshcore_console/core/packets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
22 changes: 11 additions & 11 deletions src/meshcore_console/core/types.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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.
"""
Expand All @@ -122,7 +122,7 @@ class LocalIdentityProtocol(Protocol):


class DispatcherProtocol(Protocol):
"""Protocol for pyMC_core dispatcher."""
"""Protocol for openhop_core dispatcher."""

protocol_response_handler: Any

Expand All @@ -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.
"""
Expand All @@ -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."""
Expand Down
4 changes: 2 additions & 2 deletions src/meshcore_console/meshcore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
apply_hardware_preset,
apply_preset,
)
from .session import PyMCCoreSession
from .session import OpenHopCoreSession

__all__ = [
"MeshcoreClient",
"PyMCCoreSession",
"OpenHopCoreSession",
"HardwareRadioConfig",
"RuntimeRadioConfig",
"MeshcoreSettings",
Expand Down
4 changes: 2 additions & 2 deletions src/meshcore_console/meshcore/cayenne_lpp.py
Original file line number Diff line number Diff line change
@@ -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``.
"""

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/meshcore_console/meshcore/channel_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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]

Expand Down
Loading
Loading