Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- [Documentation](https://pymc-dev.github.io/pyMC_core/)
- [Issues](https://github.com/pymc-dev/pyMC_Core/issues)
- [Discussions](https://github.com/pymc-dev/pyMC_Core/discussions)
- [pyMC Discord](https://discord.gg/SMHkUDwf)
- [Meshcore Discord](https://discord.gg/fThwBrRc3Q)
- [pyMC Discord](https://discord.gg/3s8MMaSTzq)
- [Meshcore Discord](https://meshcore.gg/)

---

Expand Down
43 changes: 43 additions & 0 deletions docs/docs/companion.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,10 +345,27 @@ companion.set_flood_scope(key)
companion.set_flood_region(None)
```

For MeshCore v1.15 parity, the frame protocol also supports a persisted default flood scope:

- `CMD_SET_DEFAULT_FLOOD_SCOPE` (`63`) stores `name(31)` + `key(16)`
- `CMD_GET_DEFAULT_FLOOD_SCOPE` (`64`) returns the stored value (or empty if unset)

pyMC_core resolves effective flood scope as:

1. transient key set by `CMD_SET_FLOOD_SCOPE` / `set_flood_scope()`
2. otherwise persisted default key (if configured)
3. otherwise unscoped flood

When a flood scope is active, all flood packets are tagged with a 16-bit transport code
(HMAC-SHA256 derived) and sent as `ROUTE_TYPE_TRANSPORT_FLOOD`. Direct-routed packets
are unaffected.

### Firmware v1.15 Note (nRF BLE DFU)

MeshCore v1.15 may expose Nordic DFU service on the normal nRF companion BLE stack.
This does not change pyMC_core TCP frame protocol behavior, but BLE clients should not
assume only UART service is present on companion devices.

### Cryptographic Signing

```python
Expand Down Expand Up @@ -613,6 +630,17 @@ The frame server handles the following companion radio protocol commands:
| `CMD_GET_STATS` | 56 | Get statistics |
| `CMD_SET_AUTOADD_CONFIG` | 58 | Set auto-add configuration |
| `CMD_GET_AUTOADD_CONFIG` | 59 | Get auto-add configuration |
| `CMD_SEND_CHANNEL_DATA` | 62 | Send binary channel datagram (`data_type + payload`) |
| `CMD_SET_DEFAULT_FLOOD_SCOPE` | 63 | Persist default flood scope (`name(31) + key(16)`) |
| `CMD_GET_DEFAULT_FLOOD_SCOPE` | 64 | Get persisted default flood scope |

`CMD_SEND_CHANNEL_DATA` payload after command byte matches firmware (`MyMesh.cpp`):

`[channel_idx][path_len][path...][data_type_le16][payload...]`

- `path_len=0xFF` means flood (no path bytes present)
- otherwise `path_len` uses normal encoded path-length semantics (1/2/3-byte path hashes)
- `data_type=0` is reserved and rejected

### Push Notifications

Expand All @@ -636,6 +664,19 @@ The frame server sends unsolicited push frames to the companion app when events
| `PUSH_CODE_BINARY_RESPONSE` | 0x95 | Binary request response |
| `PUSH_CODE_PATH_DISCOVERY_RESPONSE` | 0x96 | Path discovery response |

### Sync Message Responses

`CMD_SYNC_NEXT_MESSAGE` can return:

- `RESP_CODE_CONTACT_MSG_RECV` / `RESP_CODE_CONTACT_MSG_RECV_V3` for direct text messages
- `RESP_CODE_CHANNEL_MSG_RECV` / `RESP_CODE_CHANNEL_MSG_RECV_V3` for channel text messages
- `RESP_CODE_CHANNEL_DATA_RECV` (`27`) for channel binary datagrams
- `RESP_CODE_NO_MORE_MESSAGES` when the queue is empty

`RESP_CODE_CHANNEL_DATA_RECV` payload layout:

`[code=27][snr][0][0][channel_idx][path_len][data_type_le16][data_len][data...]`

### Host-Callable Push Methods

The frame server exposes methods for the host application to push data to the connected companion app:
Expand Down Expand Up @@ -926,6 +967,8 @@ class NodePrefs:
airtime_factor: float = 0.0
client_repeat: int = 0 # reported in CMD_DEVICE_QUERY device info frame (byte 80)
path_hash_mode: int = 0 # 0=1-byte, 1=2-byte, 2=3-byte path hashes for flood packets (byte 81)
default_scope_name: str = "" # persisted default scope label (v1.15)
default_scope_key: bytes = b"" # persisted default scope key (16 bytes)
```

---
Expand Down
91 changes: 84 additions & 7 deletions examples/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,16 @@ def create_radio(
"""Create a radio instance with configuration for specified hardware.

Args:
radio_type: Type of radio hardware ("waveshare", "uconsole", "meshadv-mini",
"kiss-tnc", "kiss-modem", or "ch341")
serial_port: Serial port for KISS devices (only used with "kiss-tnc" or "kiss-modem")
radio_type: Type of radio hardware. Supported values:
"waveshare" — Waveshare SX1262 HAT (SPI)
"uconsole" — uConsole LoRa module (SPI)
"meshadv-mini" — MeshAdv Mini (SPI)
"kiss-tnc" — KISS TNC over serial
"kiss-modem" — MeshCore KISS modem over serial
"ch341" — SX1262 via CH341 USB-to-SPI adapter
"pymc_usb" — pymc_usb firmware over USB-CDC
"pymc_tcp" — pymc_usb firmware over Wi-Fi/TCP
serial_port: Serial port path. Used by "kiss-tnc", "kiss-modem", and "pymc_usb".

Returns:
Radio instance configured for the specified hardware
Expand Down Expand Up @@ -157,6 +164,73 @@ def create_radio(
)
return radio

# ── pymc_tcp (pymc_usb firmware over Wi-Fi/TCP) ─────────
if radio_type == "pymc_tcp":
from pymc_core.hardware.tcp_radio import TCPLoRaRadio

logger.debug("Using TCP LoRa Radio (pymc_usb firmware over Wi-Fi)")

tcp_config = {
"host": os.environ.get("PYMC_TCP_HOST", ""),
"port": int(os.environ.get("PYMC_TCP_PORT", 5055)),
"token": os.environ.get("PYMC_TCP_TOKEN", ""),
"connect_timeout": float(
os.environ.get("PYMC_TCP_CONNECT_TIMEOUT", 5.0)
),
"frequency": int(os.environ.get("LORA_FREQ", 869618000)),
"bandwidth": int(os.environ.get("LORA_BW", 62500)),
"spreading_factor": int(os.environ.get("LORA_SF", 8)),
"coding_rate": int(os.environ.get("LORA_CR", 8)),
"tx_power": int(os.environ.get("LORA_POWER", 22)),
"sync_word": int(os.environ.get("LORA_SYNCWORD", "0x12"), 0),
"preamble_length": int(os.environ.get("LORA_PREAMBLE", 16)),
"lbt_enabled": True,
"lbt_max_attempts": 5,
}

if not tcp_config["host"]:
raise ValueError(
"pymc_tcp radio requires PYMC_TCP_HOST env var — "
"modem hostname or LAN IP."
)

radio = TCPLoRaRadio(**tcp_config)
logger.info(
f"pymc_tcp radio created at {tcp_config['host']}:{tcp_config['port']}: "
f"{tcp_config['frequency']/1e6:.1f}MHz SF{tcp_config['spreading_factor']} "
f"BW{tcp_config['bandwidth']/1000:.0f}kHz {tcp_config['tx_power']}dBm"
)
return radio

# ── pymc_usb (pymc_usb firmware over USB-CDC) ───────────
if radio_type == "pymc_usb":
from pymc_core.hardware.usb_radio import USBLoRaRadio

logger.debug("Using USB LoRa Radio (pymc_usb firmware)")

# Default: EU/UK (Narrow), Switzerland preset
usb_config = {
"port": serial_port, # e.g. /dev/ttyACM0
"baudrate": 921600,
"frequency": int(os.environ.get("LORA_FREQ", 869618000)),
"bandwidth": int(os.environ.get("LORA_BW", 62500)),
"spreading_factor": int(os.environ.get("LORA_SF", 8)),
"coding_rate": int(os.environ.get("LORA_CR", 8)),
"tx_power": int(os.environ.get("LORA_POWER", 22)),
"sync_word": int(os.environ.get("LORA_SYNCWORD", "0x12"), 0),
"preamble_length": int(os.environ.get("LORA_PREAMBLE", 16)),
"lbt_enabled": True,
"lbt_max_attempts": 5,
}

radio = USBLoRaRadio(**usb_config)
logger.info(
f"pymc_usb radio created on {serial_port}: "
f"{usb_config['frequency']/1e6:.1f}MHz SF{usb_config['spreading_factor']} "
f"BW{usb_config['bandwidth']/1000:.0f}kHz {usb_config['tx_power']}dBm"
)
return radio

# Direct SX1262 radio for other types
from pymc_core.hardware.sx1262_wrapper import SX1262Radio

Expand Down Expand Up @@ -218,7 +292,8 @@ def create_radio(
if radio_type not in configs:
raise ValueError(
f"Unknown radio type: {radio_type}. "
"Use 'waveshare', 'meshadv-mini', 'uconsole', 'kiss-tnc', 'kiss-modem', or 'ch341'"
"Use 'waveshare', 'meshadv-mini', 'uconsole', 'kiss-tnc', "
"'kiss-modem', 'ch341', 'pymc_usb', or 'pymc_tcp'"
)

radio_kwargs = configs[radio_type]
Expand Down Expand Up @@ -250,8 +325,9 @@ def create_mesh_node(
Args:
node_name: Name for the mesh node
radio_type: Type of radio hardware ("waveshare", "uconsole", "meshadv-mini",
"kiss-tnc", "kiss-modem", or "ch341")
serial_port: Serial port for KISS devices (only used with "kiss-tnc" or "kiss-modem")
"kiss-tnc", "kiss-modem", "ch341", "pymc_usb", or "pymc_tcp")
serial_port: Serial port for KISS devices or pymc_usb
(e.g. "/dev/ttyUSB0" for KISS, "/dev/ttyACM0" for pymc_usb)
use_modem_identity: If True and radio_type is "kiss-modem", use the modem's
cryptographic identity instead of generating a local one.
This keeps the private key secure on the modem hardware.
Expand Down Expand Up @@ -301,10 +377,11 @@ def create_mesh_node(
logger.info("CH341 radio initialized successfully")
print("CH341 USB adapter radio initialized")
else:
# waveshare/uconsole/meshadv-mini/pymc_usb/pymc_tcp all use begin()
logger.debug("Calling radio.begin()...")
ok = radio.begin()
if ok is False:
raise RuntimeError("SX1262 radio begin() returned False")
raise RuntimeError("Radio begin() returned False")
logger.info("Radio initialized successfully")

# Create identity - use modem identity if requested and available
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pymc_core"
version = "1.0.10"
version = "1.0.11"
authors = [
{name = "Lloyd Newton", email = "lloyd@rightup.co.uk"},
]
Expand Down
2 changes: 1 addition & 1 deletion src/pymc_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Clean, simple API for building mesh network applications.
"""

__version__ = "1.0.10"
__version__ = "1.0.11"

# Core mesh functionality
from .node.node import MeshNode
Expand Down
Loading
Loading