mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
742 lines
25 KiB
Python
742 lines
25 KiB
Python
# Copyright © 2025-26 l5yth & contributors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Synthetic MeshCore companion frames and an offline radio for tests (#765).
|
|
|
|
Builds the byte frames a companion radio writes to its serial link the way the
|
|
firmware does - ``CHANNEL_INFO``, the ``LOG_RX_DATA`` push (``0x88``,
|
|
``MyMesh::logRxRaw``) and ``CHANNEL_MSG_RECV_V3`` (``0x11``,
|
|
``MyMesh::onChannelMessageRecv``) - so a test can drive the real ``meshcore``
|
|
reader without a radio, and :func:`offline_meshcore` runs the real
|
|
``MeshCore`` class through the ingestor's runner without a link. The
|
|
flood-scope helpers are an independent oracle of the firmware rules
|
|
(``TransportKey::calcTransportCode`` and ``RegionMap::getTransportKeysFor``),
|
|
deliberately not shared with the code under test. Seeded from the #765
|
|
scoping probes.
|
|
|
|
The advert helpers build ``ADVERT`` payloads the way ``Mesh::createAdvert`` and
|
|
``AdvertDataBuilder::encodeTo`` do, signed with Ed25519 keys derived from fixed
|
|
seeds when a test runs, and parse them with the pinned library's packet parser
|
|
as the reader does for an RX-log push (SPEC SG1). They are a second
|
|
independent oracle: the signed bytes are rebuilt here, not taken from the code
|
|
under test.
|
|
|
|
The roster helpers build the companion frames of the contact book: a
|
|
``CMD_GET_CONTACTS`` answer, the ``PUSH_CODE_ADVERT`` and
|
|
``PUSH_CODE_NEW_ADVERT`` pushes, and the ``SELF_INFO`` answer to the
|
|
handshake (SPEC CU1-CU3).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import struct
|
|
import types
|
|
|
|
import meshcore
|
|
from Crypto.Cipher import AES
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
from meshcore.meshcore_parser import MeshcorePacketParser
|
|
|
|
CHANNEL_NAME = "#test"
|
|
"""Hashtag channel the frames are encrypted for (index 0)."""
|
|
|
|
SENDER_TS = 1_791_280_000
|
|
"""Sender-side timestamp carried inside every synthetic message."""
|
|
|
|
TEXT = "Alice: hello path"
|
|
"""Default channel-message text (MeshCore embeds the sender name)."""
|
|
|
|
PAYLOAD_TYPE_GRP_TXT = 0x05
|
|
"""Firmware ``PAYLOAD_TYPE_GRP_TXT``: a group-channel text message."""
|
|
|
|
PAYLOAD_TYPE_ADVERT = 0x04
|
|
"""Firmware ``PAYLOAD_TYPE_ADVERT``: a node advertisement."""
|
|
|
|
ROUTE_TRANSPORT_FLOOD = 0
|
|
"""Firmware ``ROUTE_TYPE_TRANSPORT_FLOOD``: a scoped flood."""
|
|
|
|
ROUTE_FLOOD = 1
|
|
"""Firmware ``ROUTE_TYPE_FLOOD``: a plain, unscoped flood."""
|
|
|
|
ROUTE_DIRECT = 2
|
|
"""Firmware ``ROUTE_TYPE_DIRECT``."""
|
|
|
|
RESP_CODE_CONTACT = 0x03
|
|
"""Companion ``RESP_CODE_CONTACT``: one contact of a ``CMD_GET_CONTACTS`` answer."""
|
|
|
|
PUSH_CODE_NEW_ADVERT = 0x8A
|
|
"""Companion ``PUSH_CODE_NEW_ADVERT``: an advert from a key the radio did not add."""
|
|
|
|
|
|
def channel_secret(name: str = CHANNEL_NAME) -> bytes:
|
|
"""Return the 16-byte secret of a hashtag channel (``SHA256(name)[:16]``).
|
|
|
|
Parameters:
|
|
name: Hashtag channel name including the ``#``.
|
|
|
|
Returns:
|
|
The channel's AES-128 key.
|
|
"""
|
|
return hashlib.sha256(name.encode("utf-8")).digest()[:16]
|
|
|
|
|
|
def grp_txt_payload(
|
|
secret: bytes, sender_ts: int = SENDER_TS, text: str = TEXT
|
|
) -> bytes:
|
|
"""Encrypt one channel message into a ``GRP_TXT`` packet payload.
|
|
|
|
Parameters:
|
|
secret: Channel secret from :func:`channel_secret`.
|
|
sender_ts: Sender timestamp written into the plaintext.
|
|
text: Message text, sender name included.
|
|
|
|
Returns:
|
|
``channel_hash(1) + mac(2) + AES-ECB ciphertext``.
|
|
"""
|
|
plain = sender_ts.to_bytes(4, "little") + b"\x00" + text.encode("utf-8")
|
|
plain += b"\x00" * (-len(plain) % 16)
|
|
cipher = AES.new(secret, AES.MODE_ECB).encrypt(plain)
|
|
mac = hmac.new(secret, cipher, hashlib.sha256).digest()[:2]
|
|
return hashlib.sha256(secret).digest()[:1] + mac + cipher
|
|
|
|
|
|
def region_key(name: str) -> bytes:
|
|
"""Return a hashtag region's transport key: ``SHA256("#" + name)[:16]``.
|
|
|
|
Parameters:
|
|
name: Region name with or without its leading ``#``.
|
|
|
|
Returns:
|
|
The 16-byte region key.
|
|
"""
|
|
if not name.startswith("#"):
|
|
name = "#" + name
|
|
return hashlib.sha256(name.encode("utf-8")).digest()[:16]
|
|
|
|
|
|
def transport_code(key: bytes, payload_type: int, payload: bytes) -> bytes:
|
|
"""Return the on-air bytes of ``transport_codes[0]`` for *key*.
|
|
|
|
The first two bytes of ``HMAC-SHA256(key, payload_type || payload)``; the
|
|
reserved codes ``0000`` and ``FFFF`` map to ``0001`` and ``FFFE``.
|
|
|
|
Parameters:
|
|
key: Region key from :func:`region_key`.
|
|
payload_type: Packet payload type.
|
|
payload: Packet payload bytes.
|
|
|
|
Returns:
|
|
Two little-endian bytes.
|
|
"""
|
|
code = hmac.new(key, bytes([payload_type]) + payload, hashlib.sha256).digest()
|
|
value = int.from_bytes(code[:2], "little")
|
|
if value == 0:
|
|
value = 1
|
|
elif value == 0xFFFF:
|
|
value = 0xFFFE
|
|
return value.to_bytes(2, "little")
|
|
|
|
|
|
def raw_packet(
|
|
payload: bytes,
|
|
*,
|
|
route_type: int = ROUTE_FLOOD,
|
|
path: bytes = b"",
|
|
hash_size: int = 1,
|
|
code0: bytes | None = None,
|
|
payload_type: int = PAYLOAD_TYPE_GRP_TXT,
|
|
) -> bytes:
|
|
"""Assemble one over-air MeshCore packet.
|
|
|
|
Parameters:
|
|
payload: Packet payload (for example from :func:`grp_txt_payload`).
|
|
route_type: Two-bit route type.
|
|
path: Concatenated repeater hashes in travel order.
|
|
hash_size: Bytes per repeater hash (1-3).
|
|
code0: ``transport_codes[0]`` bytes; required for the transport route
|
|
types, where ``transport_codes[1]`` is written as zero.
|
|
payload_type: Four-bit payload type.
|
|
|
|
Returns:
|
|
``header + [transport codes] + path_len + path + payload``.
|
|
"""
|
|
header = bytes([(payload_type << 2) | route_type])
|
|
transport = b""
|
|
if route_type in (ROUTE_TRANSPORT_FLOOD, 3):
|
|
transport = (code0 or b"\x00\x00") + b"\x00\x00"
|
|
hops = len(path) // hash_size
|
|
path_len = ((hash_size - 1) << 6) | hops
|
|
return header + transport + bytes([path_len]) + path + payload
|
|
|
|
|
|
def rx_log_frame(raw: bytes, *, snr: float, rssi: int) -> bytes:
|
|
"""Wrap a raw packet into a ``LOG_RX_DATA`` push frame.
|
|
|
|
Parameters:
|
|
raw: Packet from :func:`raw_packet`.
|
|
snr: Reception SNR in dB (sent as ``int8(snr * 4)``).
|
|
rssi: Reception RSSI in dBm (sent as ``int8``).
|
|
|
|
Returns:
|
|
``0x88 + snr + rssi + raw``.
|
|
"""
|
|
return bytes([0x88, int(snr * 4) & 0xFF, rssi & 0xFF]) + raw
|
|
|
|
|
|
def channel_info_frame(
|
|
idx: int = 0, name: str = CHANNEL_NAME, secret: bytes | None = None
|
|
) -> bytes:
|
|
"""Build a ``CHANNEL_INFO`` reply, which registers the secret in the reader.
|
|
|
|
Parameters:
|
|
idx: Channel slot index.
|
|
name: Channel name.
|
|
secret: Channel secret; derived from *name* when omitted.
|
|
|
|
Returns:
|
|
``0x12 + idx + name(32) + secret(16)``.
|
|
"""
|
|
key = secret if secret is not None else channel_secret(name)
|
|
return bytes([0x12, idx]) + name.encode("utf-8").ljust(32, b"\x00") + key
|
|
|
|
|
|
def channel_msg_v3_frame(
|
|
*,
|
|
path_len: int,
|
|
snr: float = 10.0,
|
|
channel_idx: int = 0,
|
|
sender_ts: int = SENDER_TS,
|
|
text: str = TEXT,
|
|
) -> bytes:
|
|
"""Build a ``CHANNEL_MSG_RECV_V3`` sync reply.
|
|
|
|
Parameters:
|
|
path_len: The raw path-length byte: the hop count of the delivered
|
|
flood copy, or ``0xFF`` for a direct route.
|
|
snr: SNR of the delivered copy in dB.
|
|
channel_idx: Channel slot index.
|
|
sender_ts: Sender timestamp.
|
|
text: Message text.
|
|
|
|
Returns:
|
|
``0x11 + snr + reserved(2) + channel + path_len + txt_type + ts + text``.
|
|
"""
|
|
head = bytes([0x11, int(snr * 4) & 0xFF, 0, 0, channel_idx, path_len, 0])
|
|
return head + sender_ts.to_bytes(4, "little") + text.encode("utf-8")
|
|
|
|
|
|
def message_hash(sender_ts: int = SENDER_TS, text: str = TEXT) -> int:
|
|
"""Return the library's message hash: ``SHA256(ts + text)[:4]``, little-endian.
|
|
|
|
Parameters:
|
|
sender_ts: Sender timestamp.
|
|
text: Message text.
|
|
|
|
Returns:
|
|
The 32-bit hash the reader stores as ``txt_hash`` / ``msg_hash``.
|
|
"""
|
|
digest = hashlib.sha256(sender_ts.to_bytes(4, "little") + text.encode("utf-8"))
|
|
return int.from_bytes(digest.digest()[:4], "little")
|
|
|
|
|
|
ADV_TYPE_NONE = 0
|
|
"""Firmware ``ADV_TYPE_NONE``: an advert that names no node type."""
|
|
|
|
ADV_TYPE_CHAT = 1
|
|
"""Firmware ``ADV_TYPE_CHAT``: a companion (chat) node."""
|
|
|
|
ADV_TYPE_REPEATER = 2
|
|
"""Firmware ``ADV_TYPE_REPEATER``: a repeater."""
|
|
|
|
ADV_LATLON_MASK = 0x10
|
|
"""Firmware ``ADV_LATLON_MASK``: the app data carries a latitude and longitude."""
|
|
|
|
ADV_NAME_MASK = 0x80
|
|
"""Firmware ``ADV_NAME_MASK``: the app data carries a node name."""
|
|
|
|
MAX_ADVERT_DATA_SIZE = 32
|
|
"""Firmware ``MAX_ADVERT_DATA_SIZE``: the most app-data bytes an advert signs."""
|
|
|
|
|
|
def advert_key(seed: str) -> Ed25519PrivateKey:
|
|
"""Return a deterministic Ed25519 key for one test advertiser.
|
|
|
|
The key is derived when the test runs, with ``SHA256(seed)`` as the
|
|
RFC 8032 private-key seed, so no private key is stored in a fixture.
|
|
|
|
Parameters:
|
|
seed: Fixed label naming the advertiser.
|
|
|
|
Returns:
|
|
The advertiser's private key.
|
|
"""
|
|
return Ed25519PrivateKey.from_private_bytes(
|
|
hashlib.sha256(seed.encode("utf-8")).digest()
|
|
)
|
|
|
|
|
|
def advert_public_key(key: Ed25519PrivateKey) -> bytes:
|
|
"""Return the 32 raw public-key bytes an advert carries for *key*.
|
|
|
|
Parameters:
|
|
key: Advertiser key from :func:`advert_key`.
|
|
|
|
Returns:
|
|
The raw Ed25519 public key.
|
|
"""
|
|
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
|
|
|
|
def advert_node_id(key: Ed25519PrivateKey) -> str:
|
|
"""Return the node id the ingestor posts *key* under.
|
|
|
|
Parameters:
|
|
key: Advertiser key from :func:`advert_key`.
|
|
|
|
Returns:
|
|
``!`` followed by the public key's first four bytes in lowercase hex.
|
|
"""
|
|
return "!" + advert_public_key(key)[:4].hex()
|
|
|
|
|
|
def advert_app_data(
|
|
adv_type: int = ADV_TYPE_NONE,
|
|
*,
|
|
name: str | None = None,
|
|
lat_e6: int | None = None,
|
|
lon_e6: int | None = None,
|
|
) -> bytes:
|
|
"""Encode an advert's app data as ``AdvertDataBuilder::encodeTo`` does.
|
|
|
|
Parameters:
|
|
adv_type: ``ADV_TYPE_*`` value for the low four flag bits.
|
|
name: Node name, cut to fit the 32-byte app data as the firmware cuts
|
|
it; ``None`` or empty leaves the name out.
|
|
lat_e6: Latitude in millionths of a degree; used with *lon_e6*.
|
|
lon_e6: Longitude in millionths of a degree; used with *lat_e6*.
|
|
|
|
Returns:
|
|
``flags(1) + [lat(4) + lon(4)] + [name]``, integers little-endian.
|
|
"""
|
|
flags = adv_type
|
|
body = b""
|
|
if lat_e6 is not None and lon_e6 is not None:
|
|
flags |= ADV_LATLON_MASK
|
|
body += struct.pack("<ii", lat_e6, lon_e6)
|
|
if name:
|
|
flags |= ADV_NAME_MASK
|
|
body += name.encode("utf-8")[: MAX_ADVERT_DATA_SIZE - 1 - len(body)]
|
|
return bytes([flags]) + body
|
|
|
|
|
|
def advert_signed_message(public_key: bytes, timestamp: int, app_data: bytes) -> bytes:
|
|
"""Return the bytes ``Mesh::createAdvert`` signs for one advert.
|
|
|
|
Parameters:
|
|
public_key: The advertiser's raw public key (:func:`advert_public_key`).
|
|
timestamp: Sender-side advert time.
|
|
app_data: Encoded app data from :func:`advert_app_data`.
|
|
|
|
Returns:
|
|
``pub_key(32) + timestamp(4) + app_data``.
|
|
"""
|
|
return public_key + struct.pack("<I", timestamp) + app_data
|
|
|
|
|
|
def advert_payload_bytes(
|
|
public_key: bytes, timestamp: int, signature: bytes, app_data: bytes
|
|
) -> bytes:
|
|
"""Lay out an ``ADVERT`` packet payload for any public-key bytes.
|
|
|
|
For keys no private key stands behind, such as the small-order encodings;
|
|
:func:`advert_payload` signs with a test key instead.
|
|
|
|
Parameters:
|
|
public_key: The 32 bytes to send as the advertiser's key.
|
|
timestamp: Sender-side advert time, sent as a little-endian ``uint32``.
|
|
signature: The 64 bytes to send as the signature.
|
|
app_data: Encoded app data from :func:`advert_app_data`.
|
|
|
|
Returns:
|
|
``pub_key(32) + timestamp(4) + signature(64) + app_data``.
|
|
"""
|
|
return public_key + struct.pack("<I", timestamp) + signature + app_data
|
|
|
|
|
|
def advert_payload(
|
|
key: Ed25519PrivateKey,
|
|
timestamp: int,
|
|
app_data: bytes,
|
|
*,
|
|
signature: bytes | None = None,
|
|
) -> bytes:
|
|
"""Build an ``ADVERT`` packet payload as ``Mesh::createAdvert`` does.
|
|
|
|
Parameters:
|
|
key: Advertiser key from :func:`advert_key`.
|
|
timestamp: Sender-side advert time, sent as a little-endian ``uint32``.
|
|
app_data: Encoded app data from :func:`advert_app_data`.
|
|
signature: Bytes to send in the signature's place, to forge or cut
|
|
one; by default *key* signs :func:`advert_signed_message`.
|
|
|
|
Returns:
|
|
``pub_key(32) + timestamp(4) + signature(64) + app_data``.
|
|
"""
|
|
public_key = advert_public_key(key)
|
|
if signature is None:
|
|
signature = key.sign(advert_signed_message(public_key, timestamp, app_data))
|
|
return advert_payload_bytes(public_key, timestamp, signature, app_data)
|
|
|
|
|
|
def rx_log_advert(
|
|
payload: bytes,
|
|
*,
|
|
recv_time: int,
|
|
snr: float | None = None,
|
|
rssi: int | None = None,
|
|
hops: int = 0,
|
|
) -> dict:
|
|
"""Return the ``RX_LOG_DATA`` event payload the reader dispatches for an advert.
|
|
|
|
Wraps *payload* in a flood packet that travelled *hops* repeaters and
|
|
parses it with the pinned library's ``MeshcorePacketParser``, as
|
|
``MessageReader`` does for a ``LOG_RX_DATA`` push. The reader's
|
|
``raw_hex`` and ``payload`` hex copies are left out: the ingestor reads
|
|
the parsed fields and ``pkt_payload`` only.
|
|
|
|
Parameters:
|
|
payload: Advert payload from :func:`advert_payload`.
|
|
recv_time: Receiver-side reception time.
|
|
snr: Reception SNR in dB, or ``None`` to leave it out.
|
|
rssi: Reception RSSI in dBm, or ``None`` to leave it out.
|
|
hops: Repeaters the copy travelled, as one-byte hashes ``01``, ``02``...
|
|
|
|
Returns:
|
|
The parsed RX-log frame; ``pkt_payload`` holds *payload*.
|
|
"""
|
|
log: dict = {"recv_time": recv_time}
|
|
if snr is not None:
|
|
log["snr"] = snr
|
|
if rssi is not None:
|
|
log["rssi"] = rssi
|
|
raw = raw_packet(
|
|
payload, path=bytes(range(1, hops + 1)), payload_type=PAYLOAD_TYPE_ADVERT
|
|
)
|
|
return asyncio.run(MeshcorePacketParser().parsePacketPayload(raw, log))
|
|
|
|
|
|
def contact_frame(
|
|
public_key: bytes,
|
|
name: str,
|
|
*,
|
|
last_advert: int,
|
|
lat_e6: int = 0,
|
|
lon_e6: int = 0,
|
|
code: int = RESP_CODE_CONTACT,
|
|
) -> bytes:
|
|
"""Build one contact the way ``MyMesh::writeContactRespFrame`` writes it.
|
|
|
|
The layout is the one the pinned reader parses (``reader.py:100-136``): a
|
|
chat node with no out path, whose ``last_advert`` doubles as ``lastmod``.
|
|
|
|
Parameters:
|
|
public_key: The contact's 32-byte public key.
|
|
name: Advertised name, at most 32 bytes.
|
|
last_advert: The contact's ``last_advert``, also sent as ``lastmod``.
|
|
lat_e6: Latitude in millionths of a degree; ``0`` with a ``0``
|
|
*lon_e6* is no position.
|
|
lon_e6: Longitude in millionths of a degree.
|
|
code: :data:`RESP_CODE_CONTACT` for a listed contact, or
|
|
:data:`PUSH_CODE_NEW_ADVERT` for the push of an advert from a key
|
|
the radio did not add.
|
|
|
|
Returns:
|
|
``code + key(32) + type + flags + out_path_len + out_path(64) +
|
|
name(32) + last_advert + lat + lon + lastmod``.
|
|
"""
|
|
return (
|
|
bytes([code])
|
|
+ public_key
|
|
+ bytes([1, 0, 0xFF]) # ADV_TYPE_CHAT, no flags, flood (no out path)
|
|
+ bytes(64)
|
|
+ name.encode("utf-8").ljust(32, b"\x00")
|
|
+ struct.pack("<Iii", last_advert, lat_e6, lon_e6)
|
|
+ struct.pack("<I", last_advert)
|
|
)
|
|
|
|
|
|
def contacts_listing(contacts: list[bytes], *, lastmod: int) -> list[bytes]:
|
|
"""Build a radio's answer to ``CMD_GET_CONTACTS``: start, contacts, end.
|
|
|
|
Parameters:
|
|
contacts: :func:`contact_frame` frames in roster order.
|
|
lastmod: Newest ``lastmod`` of the listing, which ``CONTACT_END``
|
|
carries and the library asks its next re-fetch with.
|
|
|
|
Returns:
|
|
``CONTACT_START`` (``0x02`` + count), the contacts, then
|
|
``CONTACT_END`` (``0x04`` + *lastmod*).
|
|
"""
|
|
start = bytes([0x02]) + struct.pack("<I", len(contacts))
|
|
return [start, *contacts, bytes([0x04]) + struct.pack("<I", lastmod)]
|
|
|
|
|
|
def advert_push(public_key: bytes) -> bytes:
|
|
"""Build the ``PUSH_CODE_ADVERT`` push for an advert from a roster contact.
|
|
|
|
The radio sends it once it has updated that contact; with auto-update on,
|
|
the library answers it by re-fetching the changed contacts
|
|
(``meshcore.py:330-333``).
|
|
|
|
Parameters:
|
|
public_key: The advertiser's 32-byte public key.
|
|
|
|
Returns:
|
|
``0x80 + key(32)``.
|
|
"""
|
|
return bytes([0x80]) + public_key
|
|
|
|
|
|
def self_info_frame(
|
|
public_key: bytes, name: str, *, lat_e6: int = 0, lon_e6: int = 0
|
|
) -> bytes:
|
|
"""Build the ``SELF_INFO`` answer to ``CMD_APP_START``.
|
|
|
|
A chat node on 869.525 MHz, 250 kHz, SF11, CR5, laid out as the pinned
|
|
reader parses it (``reader.py:166-192``).
|
|
|
|
Parameters:
|
|
public_key: The host radio's 32-byte public key.
|
|
name: The host radio's advertised name.
|
|
lat_e6: Latitude in millionths of a degree.
|
|
lon_e6: Longitude in millionths of a degree.
|
|
|
|
Returns:
|
|
``0x05 + adv_type + tx_power + max_tx_power + key(32) + lat + lon +
|
|
multi_acks + advert_loc_policy + telemetry_modes + manual_add +
|
|
freq + bw + sf + cr + name``.
|
|
"""
|
|
return (
|
|
bytes([0x05, 1, 22, 22])
|
|
+ public_key
|
|
+ struct.pack("<ii", lat_e6, lon_e6)
|
|
+ bytes(4)
|
|
+ struct.pack("<II", 869_525, 250_000)
|
|
+ bytes([11, 5])
|
|
+ name.encode("utf-8")
|
|
)
|
|
|
|
|
|
class FakeConnection:
|
|
"""Serial-link stand-in for ``meshcore.MeshCore``; it never sends a byte."""
|
|
|
|
def set_disconnect_callback(self, callback) -> None:
|
|
"""Accept and ignore the library's disconnect callback.
|
|
|
|
Parameters:
|
|
callback: Callback the connection manager registers.
|
|
"""
|
|
|
|
def set_reader(self, reader) -> None:
|
|
"""Accept and ignore the library's frame reader.
|
|
|
|
Parameters:
|
|
reader: The ``MessageReader`` the connection would feed.
|
|
"""
|
|
|
|
|
|
class QuietCommands:
|
|
"""Companion-command stand-in that answers without a radio.
|
|
|
|
A command named in *replies* answers with that event (or raises it, when
|
|
it is an exception); every other command answers ``ERROR`` at once.
|
|
"""
|
|
|
|
def __init__(self, replies: dict | None = None) -> None:
|
|
"""Store the canned replies.
|
|
|
|
Parameters:
|
|
replies: Mapping of command name to reply event or exception.
|
|
"""
|
|
self.replies = dict(replies or {})
|
|
self.calls: list[str] = []
|
|
|
|
def __getattr__(self, name: str):
|
|
"""Return an async command answering from the canned replies.
|
|
|
|
Parameters:
|
|
name: Command name the runner looks up.
|
|
|
|
Returns:
|
|
Coroutine function for the command.
|
|
"""
|
|
if name.startswith("_"):
|
|
raise AttributeError(name)
|
|
reply = self.replies.get(name)
|
|
|
|
async def _answer(*_args, **_kwargs):
|
|
self.calls.append(name)
|
|
if isinstance(reply, BaseException):
|
|
raise reply
|
|
if reply is not None:
|
|
return reply
|
|
return types.SimpleNamespace(type=meshcore.EventType.ERROR, payload={})
|
|
|
|
return _answer
|
|
|
|
|
|
def offline_meshcore(replies: dict | None = None) -> type:
|
|
"""Return the real ``MeshCore`` class with its radio round-trips stubbed.
|
|
|
|
The reader, dispatcher and ``set_decrypt_channel_logs`` stay real;
|
|
``connect``/``ensure_contacts``/``start_auto_message_fetching``/
|
|
``disconnect`` return at once and ``commands`` is a :class:`QuietCommands`.
|
|
|
|
Parameters:
|
|
replies: Canned command replies for :class:`QuietCommands`.
|
|
|
|
Returns:
|
|
A ``meshcore.MeshCore`` subclass taking the runner's ``cx`` argument.
|
|
"""
|
|
|
|
class OfflineMeshCore(meshcore.MeshCore):
|
|
"""Real ``MeshCore`` over a fake link."""
|
|
|
|
def __init__(self, cx) -> None:
|
|
"""Build the real instance over :class:`FakeConnection`.
|
|
|
|
Parameters:
|
|
cx: The runner's connection; replaced by a fake.
|
|
"""
|
|
super().__init__(FakeConnection())
|
|
self.commands = QuietCommands(replies)
|
|
|
|
async def connect(self):
|
|
"""Answer the appstart handshake like a responsive radio.
|
|
|
|
Returns:
|
|
A non-``None`` result.
|
|
"""
|
|
return "ok"
|
|
|
|
async def ensure_contacts(self, follow=False):
|
|
"""Skip the roster fetch.
|
|
|
|
Parameters:
|
|
follow: Ignored.
|
|
|
|
Returns:
|
|
``True``.
|
|
"""
|
|
return True
|
|
|
|
async def start_auto_message_fetching(self):
|
|
"""Skip the message sync loop."""
|
|
|
|
async def disconnect(self):
|
|
"""Skip the link teardown."""
|
|
|
|
return OfflineMeshCore
|
|
|
|
|
|
def stub_handlers(captured: list) -> types.SimpleNamespace:
|
|
"""Return a ``data.mesh_ingestor.handlers`` stand-in that captures packets.
|
|
|
|
Parameters:
|
|
captured: List each ``store_packet_dict`` packet is appended to.
|
|
|
|
Returns:
|
|
Namespace with the handler functions the MeshCore handlers call.
|
|
"""
|
|
return types.SimpleNamespace(
|
|
upsert_node=lambda *_a, **_k: None,
|
|
register_host_node_id=lambda *_a, **_k: None,
|
|
host_node_id=lambda: None,
|
|
_mark_packet_seen=lambda: None,
|
|
_mark_packet_activity=lambda: None,
|
|
store_packet_dict=captured.append,
|
|
)
|
|
|
|
|
|
def install_stub_handlers(monkeypatch, captured: list) -> None:
|
|
"""Route the MeshCore handlers' ``handlers`` module to :func:`stub_handlers`.
|
|
|
|
``data.mesh_ingestor`` is imported here, at call time, because
|
|
``tests/test_mesh.py`` pops the package from ``sys.modules`` after each
|
|
test; the handlers resolve whichever package object is current, so a
|
|
reference taken at collection time could patch a stale one.
|
|
|
|
Parameters:
|
|
monkeypatch: pytest fixture.
|
|
captured: List each ``store_packet_dict`` packet is appended to.
|
|
"""
|
|
import data.mesh_ingestor as mesh_pkg
|
|
|
|
monkeypatch.setattr(mesh_pkg, "handlers", stub_handlers(captured))
|
|
|
|
|
|
async def feed_reader(raw_frames: list, hmap: dict) -> None:
|
|
"""Push companion frames through the real reader with the join enabled.
|
|
|
|
Subscribes the ingestor's ``CHANNEL_INFO``, ``RX_LOG_DATA`` and
|
|
``CHANNEL_MSG_RECV`` handlers to a real ``MeshCore`` dispatcher, enables
|
|
RX-log decryption with the library's own switch, and lets each frame's
|
|
handlers finish before the next frame arrives - as on the serial link,
|
|
where the RX-log pushes precede the message sync.
|
|
|
|
Parameters:
|
|
raw_frames: Companion frames in arrival order.
|
|
hmap: The ingestor's event-handler map.
|
|
"""
|
|
mc = meshcore.MeshCore(FakeConnection())
|
|
await mc.dispatcher.start()
|
|
mc.set_decrypt_channel_logs(True)
|
|
for name in ("CHANNEL_INFO", "RX_LOG_DATA", "CHANNEL_MSG_RECV"):
|
|
mc.subscribe(meshcore.EventType[name], hmap[name])
|
|
try:
|
|
for raw in raw_frames:
|
|
await mc._reader.handle_rx(bytearray(raw))
|
|
await mc.dispatcher.queue.join()
|
|
await asyncio.gather(*list(mc.dispatcher._background_tasks))
|
|
finally:
|
|
await mc.dispatcher.stop()
|
|
|
|
|
|
async def settle(mc) -> None:
|
|
"""Wait until a live ``MeshCore``'s dispatcher has run every handler.
|
|
|
|
The dispatcher runs each coroutine handler as a background task, and a
|
|
handler can raise further events (the library's auto-update re-fetch
|
|
does), so the wait repeats until no event is queued and no handler but
|
|
the caller is left. Leaving the caller out lets a handler settle the
|
|
dispatcher it runs on.
|
|
|
|
Parameters:
|
|
mc: A ``MeshCore`` whose dispatcher is running.
|
|
"""
|
|
current = asyncio.current_task()
|
|
while True:
|
|
await mc.dispatcher.queue.join()
|
|
pending = [
|
|
task
|
|
for task in mc.dispatcher._background_tasks
|
|
if task is not current and not task.done()
|
|
]
|
|
if not pending:
|
|
return
|
|
await asyncio.gather(*pending)
|