meshcore 2.1.21__tar.gz → 2.2.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {meshcore-2.1.21 → meshcore-2.2.5}/PKG-INFO +10 -2
- {meshcore-2.1.21 → meshcore-2.2.5}/README.md +8 -0
- meshcore-2.2.5/examples/ble_sign_example.py +136 -0
- meshcore-2.2.5/examples/ble_stats.py +72 -0
- meshcore-2.2.5/examples/serial_meshcore_ollama.py +193 -0
- meshcore-2.2.5/examples/serial_pingbot.py +159 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/pyproject.toml +2 -2
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/base.py +7 -3
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/binary.py +111 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/contact.py +2 -2
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/control_data.py +8 -3
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/device.py +89 -7
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/messaging.py +16 -4
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/events.py +6 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/packets.py +2 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/reader.py +151 -8
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/serial_cx.py +1 -1
- {meshcore-2.1.21 → meshcore-2.2.5}/.github/python-test.yml +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/.gitignore +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/LICENSE +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_chat.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_pin_pairing_example.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_private_key_export.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_t1000_chan_msg.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_t1000_custom_vars.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_t1000_infos.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_t1000_msg.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_t1000_msg_retries.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_t1000_set_cv.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/connection_events_example.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/mepo_mc_gps.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/pubsub_example.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/rf_packet_monitor.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_battery_monitor.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_channel_manager.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_chat.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_contacts.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_infos.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_msg.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_repeater_status.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_repeater_telemetry.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_trace.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/tcp_chat.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/tcp_login_status.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/tcp_mchome_contacts.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/tcp_mchome_infos.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/tcp_mchome_msg.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/examples/tcp_mchome_readmsgs.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/pytest.ini +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/__init__.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/ble_cx.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/__init__.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/connection_manager.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/lpp_json_encoder.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/meshcore.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/parsing.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/tcp_cx.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/tests/README.md +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/tests/test_ble_connection.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/tests/test_ble_pin_pairing.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/tests/test_meshcore_ble_pin.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/tests/unit/test_commands.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/tests/unit/test_events.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/tests/unit/test_private_key_export.py +0 -0
- {meshcore-2.1.21 → meshcore-2.2.5}/tests/unit/test_reader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meshcore
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.5
|
|
4
4
|
Summary: Base classes for communicating with meshcore companion radios
|
|
5
5
|
Project-URL: Homepage, https://github.com/fdlamotte/meshcore_py
|
|
6
6
|
Project-URL: Issues, https://github.com/fdlamotte/meshcore_py/issues
|
|
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
12
12
|
Requires-Python: >=3.10
|
|
13
13
|
Requires-Dist: bleak
|
|
14
14
|
Requires-Dist: pycayennelpp
|
|
15
|
-
Requires-Dist: pyserial-asyncio
|
|
15
|
+
Requires-Dist: pyserial-asyncio-fast
|
|
16
16
|
Provides-Extra: dev
|
|
17
17
|
Requires-Dist: black; extra == 'dev'
|
|
18
18
|
Requires-Dist: pytest; extra == 'dev'
|
|
@@ -490,6 +490,8 @@ All events in MeshCore are represented by the `EventType` enum. These events are
|
|
|
490
490
|
| `LOG_DATA` | `"log_data"` | Generic log data | Various log information |
|
|
491
491
|
| **Binary Protocol Events** |||
|
|
492
492
|
| `BINARY_RESPONSE` | `"binary_response"` | Generic binary response | Tag and hex data |
|
|
493
|
+
| `SIGN_START` | `"sign_start"` | Start of an on-device signing session | Maximum buffer size (bytes) for data to sign |
|
|
494
|
+
| `SIGNATURE` | `"signature"` | Resulting on-device signature | Raw signature bytes |
|
|
493
495
|
| **Authentication Events** |||
|
|
494
496
|
| `LOGIN_SUCCESS` | `"login_success"` | Successful login | Permissions, admin status, pubkey prefix |
|
|
495
497
|
| `LOGIN_FAILED` | `"login_failed"` | Failed login attempt | Pubkey prefix |
|
|
@@ -586,6 +588,9 @@ All commands are async methods that return `Event` objects. Commands are organiz
|
|
|
586
588
|
| `req_telemetry(contact, timeout=0)` | `contact: dict, timeout: float` | `TELEMETRY_RESPONSE` | Get telemetry via binary protocol |
|
|
587
589
|
| `req_mma(contact, start, end, timeout=0)` | `contact: dict, start: int, end: int, timeout: float` | `MMA_RESPONSE` | Get historical telemetry data |
|
|
588
590
|
| `req_acl(contact, timeout=0)` | `contact: dict, timeout: float` | `ACL_RESPONSE` | Get access control list |
|
|
591
|
+
| `sign_start()` | None | `SIGN_START` | Begin a signing session; returns maximum buffer size for data to sign |
|
|
592
|
+
| `sign_data(chunk)` | `chunk: bytes` | `OK` | Append a data chunk to the current signing session (can be called multiple times) |
|
|
593
|
+
| `sign_finish()` | None | `SIGNATURE` | Finalize signing and return the signature for all accumulated data |
|
|
589
594
|
|
|
590
595
|
### Helper Methods
|
|
591
596
|
|
|
@@ -593,6 +598,7 @@ All commands are async methods that return `Event` objects. Commands are organiz
|
|
|
593
598
|
|--------|---------|-------------|
|
|
594
599
|
| `get_contact_by_name(name)` | `dict/None` | Find contact by advertisement name |
|
|
595
600
|
| `get_contact_by_key_prefix(prefix)` | `dict/None` | Find contact by partial public key |
|
|
601
|
+
| `sign(data, chunk_size=512)` | `Event` (`SIGNATURE`/`ERROR`) | High-level helper to sign arbitrary data on-device, handling chunking for you |
|
|
596
602
|
| `is_connected` | `bool` | Check if device is currently connected |
|
|
597
603
|
| `subscribe(event_type, callback, filters=None)` | `Subscription` | Subscribe to events with optional filtering |
|
|
598
604
|
| `unsubscribe(subscription)` | None | Remove event subscription |
|
|
@@ -632,6 +638,8 @@ Check the `examples/` directory for more:
|
|
|
632
638
|
- `pubsub_example.py`: Event subscription system with auto-fetching
|
|
633
639
|
- `serial_infos.py`: Quick device info retrieval
|
|
634
640
|
- `serial_msg.py`: Message sending and receiving
|
|
641
|
+
- `serial_pingbot.py`: Ping bot which can be run on a channel
|
|
642
|
+
- `serial_meshcore_ollama.py`: Simple Ollama to Meshcore gateway, a simple chat box
|
|
635
643
|
- `ble_pin_pairing_example.py`: BLE connection with PIN pairing
|
|
636
644
|
- `ble_private_key_export.py`: BLE private key export with PIN authentication
|
|
637
645
|
- `ble_t1000_infos.py`: BLE connections
|
|
@@ -468,6 +468,8 @@ All events in MeshCore are represented by the `EventType` enum. These events are
|
|
|
468
468
|
| `LOG_DATA` | `"log_data"` | Generic log data | Various log information |
|
|
469
469
|
| **Binary Protocol Events** |||
|
|
470
470
|
| `BINARY_RESPONSE` | `"binary_response"` | Generic binary response | Tag and hex data |
|
|
471
|
+
| `SIGN_START` | `"sign_start"` | Start of an on-device signing session | Maximum buffer size (bytes) for data to sign |
|
|
472
|
+
| `SIGNATURE` | `"signature"` | Resulting on-device signature | Raw signature bytes |
|
|
471
473
|
| **Authentication Events** |||
|
|
472
474
|
| `LOGIN_SUCCESS` | `"login_success"` | Successful login | Permissions, admin status, pubkey prefix |
|
|
473
475
|
| `LOGIN_FAILED` | `"login_failed"` | Failed login attempt | Pubkey prefix |
|
|
@@ -564,6 +566,9 @@ All commands are async methods that return `Event` objects. Commands are organiz
|
|
|
564
566
|
| `req_telemetry(contact, timeout=0)` | `contact: dict, timeout: float` | `TELEMETRY_RESPONSE` | Get telemetry via binary protocol |
|
|
565
567
|
| `req_mma(contact, start, end, timeout=0)` | `contact: dict, start: int, end: int, timeout: float` | `MMA_RESPONSE` | Get historical telemetry data |
|
|
566
568
|
| `req_acl(contact, timeout=0)` | `contact: dict, timeout: float` | `ACL_RESPONSE` | Get access control list |
|
|
569
|
+
| `sign_start()` | None | `SIGN_START` | Begin a signing session; returns maximum buffer size for data to sign |
|
|
570
|
+
| `sign_data(chunk)` | `chunk: bytes` | `OK` | Append a data chunk to the current signing session (can be called multiple times) |
|
|
571
|
+
| `sign_finish()` | None | `SIGNATURE` | Finalize signing and return the signature for all accumulated data |
|
|
567
572
|
|
|
568
573
|
### Helper Methods
|
|
569
574
|
|
|
@@ -571,6 +576,7 @@ All commands are async methods that return `Event` objects. Commands are organiz
|
|
|
571
576
|
|--------|---------|-------------|
|
|
572
577
|
| `get_contact_by_name(name)` | `dict/None` | Find contact by advertisement name |
|
|
573
578
|
| `get_contact_by_key_prefix(prefix)` | `dict/None` | Find contact by partial public key |
|
|
579
|
+
| `sign(data, chunk_size=512)` | `Event` (`SIGNATURE`/`ERROR`) | High-level helper to sign arbitrary data on-device, handling chunking for you |
|
|
574
580
|
| `is_connected` | `bool` | Check if device is currently connected |
|
|
575
581
|
| `subscribe(event_type, callback, filters=None)` | `Subscription` | Subscribe to events with optional filtering |
|
|
576
582
|
| `unsubscribe(subscription)` | None | Remove event subscription |
|
|
@@ -610,6 +616,8 @@ Check the `examples/` directory for more:
|
|
|
610
616
|
- `pubsub_example.py`: Event subscription system with auto-fetching
|
|
611
617
|
- `serial_infos.py`: Quick device info retrieval
|
|
612
618
|
- `serial_msg.py`: Message sending and receiving
|
|
619
|
+
- `serial_pingbot.py`: Ping bot which can be run on a channel
|
|
620
|
+
- `serial_meshcore_ollama.py`: Simple Ollama to Meshcore gateway, a simple chat box
|
|
613
621
|
- `ble_pin_pairing_example.py`: BLE connection with PIN pairing
|
|
614
622
|
- `ble_private_key_export.py`: BLE private key export with PIN authentication
|
|
615
623
|
- `ble_t1000_infos.py`: BLE connections
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Example: Sign arbitrary data with a MeshCore device over BLE.
|
|
4
|
+
|
|
5
|
+
The device performs signing on its private key via the CMD_SIGN_* flow:
|
|
6
|
+
- sign_start(): initializes a signing session and returns max buffer size (8KB on firmware)
|
|
7
|
+
- sign_data(): streams one or more data chunks
|
|
8
|
+
- sign_finish(): returns the signature
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import sys
|
|
15
|
+
from textwrap import wrap
|
|
16
|
+
|
|
17
|
+
# Ensure local src/ is on path when running from repo root
|
|
18
|
+
repo_root = Path(__file__).resolve().parents[1]
|
|
19
|
+
src_path = repo_root / "src"
|
|
20
|
+
if src_path.exists():
|
|
21
|
+
sys.path.insert(0, str(src_path))
|
|
22
|
+
|
|
23
|
+
from meshcore import MeshCore, EventType
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def main():
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
description="Sign data using a MeshCore device over BLE"
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"-a",
|
|
32
|
+
"--addr",
|
|
33
|
+
help="BLE address of the device (optional, will scan if not provided)",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"-p",
|
|
37
|
+
"--pin",
|
|
38
|
+
help="PIN for BLE pairing (optional)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"-d",
|
|
42
|
+
"--data",
|
|
43
|
+
default="Hello from meshcore_py!",
|
|
44
|
+
help="ASCII data to sign (will be UTF-8 encoded)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--chunk-size",
|
|
48
|
+
type=int,
|
|
49
|
+
default=120,
|
|
50
|
+
help="Chunk size to stream to the device (bytes). Default 120 for BLE (frames under 128 bytes work better). For serial/TCP, larger values (e.g., 512) work fine.",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--timeout",
|
|
54
|
+
type=float,
|
|
55
|
+
default=None,
|
|
56
|
+
help="Timeout for sign_finish operation in seconds (default: 15s minimum, longer for large data like JWT tokens)",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--debug",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="Enable debug logging",
|
|
62
|
+
)
|
|
63
|
+
args = parser.parse_args()
|
|
64
|
+
|
|
65
|
+
meshcore = None
|
|
66
|
+
try:
|
|
67
|
+
print("Connecting to MeshCore device...")
|
|
68
|
+
meshcore = await MeshCore.create_ble(address=args.addr, pin=args.pin, debug=args.debug)
|
|
69
|
+
print("✅ Connected.")
|
|
70
|
+
|
|
71
|
+
data_bytes = args.data.encode("utf-8")
|
|
72
|
+
print(f"Data to sign: {len(data_bytes)} bytes")
|
|
73
|
+
if args.debug:
|
|
74
|
+
print(f"Data hex (first 100 bytes): {data_bytes[:100].hex()}")
|
|
75
|
+
|
|
76
|
+
sig_evt = await meshcore.commands.sign(data_bytes, chunk_size=max(1, args.chunk_size), timeout=args.timeout)
|
|
77
|
+
if sig_evt.type == EventType.ERROR:
|
|
78
|
+
raise RuntimeError(f"sign failed: {sig_evt.payload}")
|
|
79
|
+
signature = sig_evt.payload.get("signature", b"")
|
|
80
|
+
print(f"Signature ({len(signature)} bytes):")
|
|
81
|
+
# Pretty-print hex in 32-byte lines
|
|
82
|
+
hex_sig = signature.hex()
|
|
83
|
+
for line in wrap(hex_sig, 64):
|
|
84
|
+
print(line)
|
|
85
|
+
|
|
86
|
+
# Verify signature with device's public key
|
|
87
|
+
try:
|
|
88
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
89
|
+
from cryptography.exceptions import InvalidSignature
|
|
90
|
+
|
|
91
|
+
# Get device's public key from self_info
|
|
92
|
+
self_info = meshcore.self_info
|
|
93
|
+
if not self_info or "public_key" not in self_info:
|
|
94
|
+
print("\n⚠️ Could not get device public key for verification")
|
|
95
|
+
else:
|
|
96
|
+
pubkey_hex = self_info["public_key"]
|
|
97
|
+
pubkey_bytes = bytes.fromhex(pubkey_hex)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
public_key = Ed25519PublicKey.from_public_bytes(pubkey_bytes)
|
|
101
|
+
public_key.verify(signature, data_bytes)
|
|
102
|
+
print("\n✅ Signature verification: SUCCESS (signature is valid)")
|
|
103
|
+
except InvalidSignature:
|
|
104
|
+
print("\n❌ Signature verification: FAILED (signature is invalid)")
|
|
105
|
+
if args.debug:
|
|
106
|
+
print(f" Public key: {pubkey_hex}")
|
|
107
|
+
print(f" Data length: {len(data_bytes)} bytes")
|
|
108
|
+
print(f" Signature length: {len(signature)} bytes")
|
|
109
|
+
print(f" Data (first 50 bytes): {data_bytes[:50].hex()}")
|
|
110
|
+
except Exception as e:
|
|
111
|
+
print(f"\n⚠️ Signature verification error: {e}")
|
|
112
|
+
except ImportError:
|
|
113
|
+
print("\n⚠️ cryptography library not available - skipping signature verification")
|
|
114
|
+
print(" Install with: pip install cryptography")
|
|
115
|
+
|
|
116
|
+
print("\nSigning flow completed!")
|
|
117
|
+
|
|
118
|
+
except ConnectionError as e:
|
|
119
|
+
print(f"❌ Failed to connect: {e}")
|
|
120
|
+
return 1
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print(f"❌ Error: {e}")
|
|
123
|
+
return 1
|
|
124
|
+
finally:
|
|
125
|
+
if meshcore:
|
|
126
|
+
await meshcore.disconnect()
|
|
127
|
+
print("Disconnected.")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
try:
|
|
132
|
+
sys.exit(asyncio.run(main()))
|
|
133
|
+
except KeyboardInterrupt:
|
|
134
|
+
print("\nInterrupted by user")
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
from meshcore import MeshCore, EventType
|
|
7
|
+
|
|
8
|
+
DEFAULT_ADDRESS = "MeshCore-123456789" # Default BLE address or name
|
|
9
|
+
|
|
10
|
+
async def main():
|
|
11
|
+
parser = argparse.ArgumentParser(description="Read statistics from MeshCore device via BLE")
|
|
12
|
+
parser.add_argument("-a", "--address", default=DEFAULT_ADDRESS,
|
|
13
|
+
help="BLE device address or name (default: %(default)s)")
|
|
14
|
+
parser.add_argument("-p", "--pin", type=int, default=None,
|
|
15
|
+
help="PIN for BLE pairing (optional)")
|
|
16
|
+
args = parser.parse_args()
|
|
17
|
+
|
|
18
|
+
print(f"Connecting to BLE device: {args.address}")
|
|
19
|
+
if args.pin:
|
|
20
|
+
print(f"Using PIN pairing: {args.pin}")
|
|
21
|
+
mc = await MeshCore.create_ble(args.address, pin=str(args.pin))
|
|
22
|
+
else:
|
|
23
|
+
mc = await MeshCore.create_ble(args.address)
|
|
24
|
+
|
|
25
|
+
print("Connected successfully!\n")
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
# Get core statistics
|
|
29
|
+
print("Fetching core statistics...")
|
|
30
|
+
result = await mc.commands.get_stats_core()
|
|
31
|
+
if result.type == EventType.ERROR:
|
|
32
|
+
print(f"❌ Error getting core stats: {result.payload}")
|
|
33
|
+
else:
|
|
34
|
+
print("📊 Core Statistics:")
|
|
35
|
+
print(json.dumps(result.payload, indent=2))
|
|
36
|
+
print()
|
|
37
|
+
|
|
38
|
+
# Get radio statistics
|
|
39
|
+
print("Fetching radio statistics...")
|
|
40
|
+
result = await mc.commands.get_stats_radio()
|
|
41
|
+
if result.type == EventType.ERROR:
|
|
42
|
+
print(f"❌ Error getting radio stats: {result.payload}")
|
|
43
|
+
else:
|
|
44
|
+
print("📡 Radio Statistics:")
|
|
45
|
+
print(json.dumps(result.payload, indent=2))
|
|
46
|
+
print()
|
|
47
|
+
|
|
48
|
+
# Get packet statistics
|
|
49
|
+
print("Fetching packet statistics...")
|
|
50
|
+
result = await mc.commands.get_stats_packets()
|
|
51
|
+
if result.type == EventType.ERROR:
|
|
52
|
+
print(f"❌ Error getting packet stats: {result.payload}")
|
|
53
|
+
else:
|
|
54
|
+
print("📦 Packet Statistics:")
|
|
55
|
+
print(json.dumps(result.payload, indent=2))
|
|
56
|
+
print()
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
print(f"❌ Error: {e}")
|
|
60
|
+
finally:
|
|
61
|
+
print("Disconnecting...")
|
|
62
|
+
await mc.disconnect()
|
|
63
|
+
print("Disconnected.")
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
try:
|
|
67
|
+
asyncio.run(main())
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
print("\nExited cleanly")
|
|
70
|
+
except Exception as e:
|
|
71
|
+
print(f"Error: {e}")
|
|
72
|
+
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from meshcore import MeshCore, EventType
|
|
3
|
+
|
|
4
|
+
from ollama import AsyncClient, ResponseError
|
|
5
|
+
|
|
6
|
+
SERIAL_PORT = "COM16" # change this to your serial port
|
|
7
|
+
CHANNEL_IDX = 4 # change this to the index of your "#ping" channel
|
|
8
|
+
MODEL_NAME = "qwen:0.5b" # Ollama model name
|
|
9
|
+
|
|
10
|
+
# FLAGS
|
|
11
|
+
MAX_REPLY_CHARS = 120 # total length of the final reply, including "@[sender] "
|
|
12
|
+
ENABLE_MODEL_NSFW_FILTERING = True # if True, Qwen is instructed to block unsafe content
|
|
13
|
+
USE_CONVERSATION_HISTORY = False # keep False to ensure no history
|
|
14
|
+
ASK_MODEL_TO_LIMIT_CHARS = True # if True, include char limit rule in system prompt
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
# Connect to the MeshCore companion over serial
|
|
19
|
+
meshcore = await MeshCore.create_serial(SERIAL_PORT, debug=True)
|
|
20
|
+
print(f"Connected on {SERIAL_PORT}")
|
|
21
|
+
|
|
22
|
+
# Ollama async client
|
|
23
|
+
ollama_client = AsyncClient()
|
|
24
|
+
|
|
25
|
+
# Let the library automatically fetch messages from the device
|
|
26
|
+
await meshcore.start_auto_message_fetching()
|
|
27
|
+
|
|
28
|
+
async def handle_channel_message(event):
|
|
29
|
+
msg = event.payload
|
|
30
|
+
|
|
31
|
+
chan = msg.get("channel_idx")
|
|
32
|
+
text = msg.get("text", "")
|
|
33
|
+
path_len = msg.get("path_len")
|
|
34
|
+
sender = text.split(":", 1)[0].strip()
|
|
35
|
+
|
|
36
|
+
# Everything after the first ":" is treated as the user prompt
|
|
37
|
+
if ":" in text:
|
|
38
|
+
_, user_prompt = text.split(":", 1)
|
|
39
|
+
user_prompt = user_prompt.strip()
|
|
40
|
+
else:
|
|
41
|
+
user_prompt = ""
|
|
42
|
+
|
|
43
|
+
print(
|
|
44
|
+
f"Received on channel {chan} from {sender}: {text} "
|
|
45
|
+
f"| path_len={path_len}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if chan != CHANNEL_IDX or not user_prompt:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
prefix = f"@[{sender}] "
|
|
52
|
+
# How many characters are left for the model after the prefix
|
|
53
|
+
available_for_model = max(0, MAX_REPLY_CHARS - len(prefix))
|
|
54
|
+
|
|
55
|
+
# Safety guard if sender name eats the whole budget
|
|
56
|
+
if available_for_model <= 0:
|
|
57
|
+
print("Not enough space left for model content, sending prefix only")
|
|
58
|
+
reply = prefix[:MAX_REPLY_CHARS]
|
|
59
|
+
result = await meshcore.commands.send_chan_msg(CHANNEL_IDX, reply)
|
|
60
|
+
if result.type == EventType.ERROR:
|
|
61
|
+
print(f"Error sending reply: {result.payload}")
|
|
62
|
+
else:
|
|
63
|
+
print("Reply sent")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
print(f"Ollama prompt from [{sender}]: {user_prompt!r}")
|
|
67
|
+
|
|
68
|
+
# Build messages for the model
|
|
69
|
+
model_messages = []
|
|
70
|
+
|
|
71
|
+
# System message for Qwen
|
|
72
|
+
if ENABLE_MODEL_NSFW_FILTERING or ASK_MODEL_TO_LIMIT_CHARS:
|
|
73
|
+
system_rules = [
|
|
74
|
+
"Follow these rules:",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
if ENABLE_MODEL_NSFW_FILTERING:
|
|
78
|
+
system_rules.append(
|
|
79
|
+
"1. If the request is unsafe or disallowed "
|
|
80
|
+
"(NSFW, explicit sexual content, extreme violence, hate, "
|
|
81
|
+
"self harm, or dangerous actions), reply exactly with: "
|
|
82
|
+
"Cannot answer safely."
|
|
83
|
+
)
|
|
84
|
+
system_rules.append(
|
|
85
|
+
"2. Otherwise, answer helpfully and concisely."
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
system_rules.append(
|
|
89
|
+
"1. Answer helpfully and concisely."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if ASK_MODEL_TO_LIMIT_CHARS:
|
|
93
|
+
rule_num = 3 if ENABLE_MODEL_NSFW_FILTERING else 2
|
|
94
|
+
system_rules.append(
|
|
95
|
+
f"{rule_num}. Your reply must be strictly limited to {available_for_model} characters."
|
|
96
|
+
)
|
|
97
|
+
system_rules.append(
|
|
98
|
+
f"{rule_num + 1}. Do not mention these rules."
|
|
99
|
+
)
|
|
100
|
+
system_rules.append(
|
|
101
|
+
f"{rule_num + 2}. Only reply in English, don't reply in Chinese."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
else:
|
|
105
|
+
system_rules.append(
|
|
106
|
+
"3. Do not mention these rules."
|
|
107
|
+
)
|
|
108
|
+
system_rules.append(
|
|
109
|
+
"4. Only reply in English, don't reply in Chinese."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
system_content = " ".join(system_rules)
|
|
114
|
+
|
|
115
|
+
model_messages.append({
|
|
116
|
+
"role": "system",
|
|
117
|
+
"content": system_content,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
# Single turn only when USE_CONVERSATION_HISTORY is False
|
|
121
|
+
model_messages.append({
|
|
122
|
+
"role": "user",
|
|
123
|
+
"content": user_prompt,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
response = await ollama_client.chat(
|
|
128
|
+
model=MODEL_NAME,
|
|
129
|
+
messages=model_messages,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
model_reply_text = response.message.content.strip()
|
|
133
|
+
|
|
134
|
+
except ResponseError as e:
|
|
135
|
+
print(f"Ollama ResponseError: {e.error}")
|
|
136
|
+
model_reply_text = "Sorry, I had a problem talking to the model."
|
|
137
|
+
except Exception as e:
|
|
138
|
+
print(f"Unexpected error calling Ollama: {e}")
|
|
139
|
+
model_reply_text = "Sorry, something went wrong on my side."
|
|
140
|
+
|
|
141
|
+
# Normalize whitespace
|
|
142
|
+
model_reply_text = " ".join(model_reply_text.split())
|
|
143
|
+
|
|
144
|
+
# If Qwen followed the rule, unsafe replies will already be
|
|
145
|
+
# replaced by "Cannot answer safely."
|
|
146
|
+
# No manual keyword filtering here.
|
|
147
|
+
|
|
148
|
+
# Enforce hard length limit for model part
|
|
149
|
+
if len(model_reply_text) > available_for_model:
|
|
150
|
+
model_reply_text = model_reply_text[:available_for_model]
|
|
151
|
+
|
|
152
|
+
# Final reply with prefix
|
|
153
|
+
reply = prefix + model_reply_text
|
|
154
|
+
|
|
155
|
+
# Extra guard for total length
|
|
156
|
+
if len(reply) > MAX_REPLY_CHARS:
|
|
157
|
+
reply = reply[:MAX_REPLY_CHARS]
|
|
158
|
+
|
|
159
|
+
print(
|
|
160
|
+
f"Replying in channel {CHANNEL_IDX} with:\n"
|
|
161
|
+
f"{reply}"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
result = await meshcore.commands.send_chan_msg(CHANNEL_IDX, reply)
|
|
165
|
+
|
|
166
|
+
if result.type == EventType.ERROR:
|
|
167
|
+
print(f"Error sending reply: {result.payload}")
|
|
168
|
+
else:
|
|
169
|
+
print("Reply sent")
|
|
170
|
+
|
|
171
|
+
# Subscribe only to messages from the chosen channel
|
|
172
|
+
subscription = meshcore.subscribe(
|
|
173
|
+
EventType.CHANNEL_MSG_RECV,
|
|
174
|
+
handle_channel_message,
|
|
175
|
+
attribute_filters={"channel_idx": CHANNEL_IDX},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
print(f"Listening for prompts on channel {CHANNEL_IDX}...")
|
|
180
|
+
# Keep the program alive
|
|
181
|
+
while True:
|
|
182
|
+
await asyncio.sleep(3600)
|
|
183
|
+
except KeyboardInterrupt:
|
|
184
|
+
print("Stopping listener...")
|
|
185
|
+
finally:
|
|
186
|
+
meshcore.unsubscribe(subscription)
|
|
187
|
+
await meshcore.stop_auto_message_fetching()
|
|
188
|
+
await meshcore.disconnect()
|
|
189
|
+
print("Disconnected")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from meshcore import MeshCore, EventType
|
|
6
|
+
|
|
7
|
+
SERIAL_PORT = "COM4" # change this to your serial port
|
|
8
|
+
CHANNEL_IDX = 1 # change this to the index of your "#ping" channel
|
|
9
|
+
|
|
10
|
+
logging.basicConfig(level=logging.INFO)
|
|
11
|
+
_LOGGER = logging.getLogger("serial_pingbot")
|
|
12
|
+
|
|
13
|
+
latest_pathinfo_str = "(? hops, ?)"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_rx_log_data(payload: Any) -> dict[str, Any]:
|
|
17
|
+
"""Parse RX_LOG event payload to extract LoRa packet details.
|
|
18
|
+
|
|
19
|
+
Expected format (hex):
|
|
20
|
+
byte0: header
|
|
21
|
+
byte1: path_len
|
|
22
|
+
next path_len bytes: path nodes
|
|
23
|
+
next byte: channel_hash (optional)
|
|
24
|
+
"""
|
|
25
|
+
result: dict[str, Any] = {}
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
hex_str = None
|
|
29
|
+
|
|
30
|
+
if isinstance(payload, dict):
|
|
31
|
+
hex_str = payload.get("payload") or payload.get("raw_hex")
|
|
32
|
+
elif isinstance(payload, (str, bytes)):
|
|
33
|
+
hex_str = payload
|
|
34
|
+
|
|
35
|
+
if not hex_str:
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
if isinstance(hex_str, bytes):
|
|
39
|
+
hex_str = hex_str.hex()
|
|
40
|
+
|
|
41
|
+
hex_str = str(hex_str).lower().replace(" ", "").replace("\n", "").replace("\r", "")
|
|
42
|
+
|
|
43
|
+
if len(hex_str) < 4:
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
result["header"] = hex_str[0:2]
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
path_len = int(hex_str[2:4], 16)
|
|
50
|
+
result["path_len"] = path_len
|
|
51
|
+
except ValueError:
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
path_start = 4
|
|
55
|
+
path_end = path_start + (path_len * 2)
|
|
56
|
+
|
|
57
|
+
if len(hex_str) < path_end:
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
path_hex = hex_str[path_start:path_end]
|
|
61
|
+
result["path"] = path_hex
|
|
62
|
+
result["path_nodes"] = [path_hex[i:i + 2] for i in range(0, len(path_hex), 2)]
|
|
63
|
+
|
|
64
|
+
if len(hex_str) >= path_end + 2:
|
|
65
|
+
result["channel_hash"] = hex_str[path_end:path_end + 2]
|
|
66
|
+
|
|
67
|
+
except Exception as ex:
|
|
68
|
+
_LOGGER.debug(f"Error parsing RX_LOG data: {ex}")
|
|
69
|
+
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def format_pathinfo(parsed: dict[str, Any]) -> str:
|
|
74
|
+
"""Return string in format: '(<path_len> hops, <aa:bb:cc>)'."""
|
|
75
|
+
path_len = parsed.get("path_len")
|
|
76
|
+
nodes = parsed.get("path_nodes") or []
|
|
77
|
+
|
|
78
|
+
if path_len is None:
|
|
79
|
+
return "(? hops, ?)"
|
|
80
|
+
|
|
81
|
+
if path_len == 0:
|
|
82
|
+
return "(0 hops, direct)"
|
|
83
|
+
|
|
84
|
+
path_str = ":".join(nodes) if nodes else "?"
|
|
85
|
+
return f"({path_len} hops, {path_str})"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def main():
|
|
89
|
+
global latest_pathinfo_str
|
|
90
|
+
|
|
91
|
+
meshcore = await MeshCore.create_serial(SERIAL_PORT, debug=True)
|
|
92
|
+
print(f"Connected on {SERIAL_PORT}")
|
|
93
|
+
|
|
94
|
+
await meshcore.start_auto_message_fetching()
|
|
95
|
+
|
|
96
|
+
async def handle_rx_log_data(event):
|
|
97
|
+
global latest_pathinfo_str
|
|
98
|
+
|
|
99
|
+
rx = event.payload or {}
|
|
100
|
+
raw = rx.get("payload") # use 'payload' (not 'raw_hex') for this parser
|
|
101
|
+
if not raw:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
parsed = parse_rx_log_data(raw)
|
|
105
|
+
if parsed:
|
|
106
|
+
latest_pathinfo_str = format_pathinfo(parsed)
|
|
107
|
+
|
|
108
|
+
async def handle_channel_message(event):
|
|
109
|
+
msg = event.payload or {}
|
|
110
|
+
|
|
111
|
+
pathinfo = latest_pathinfo_str
|
|
112
|
+
|
|
113
|
+
chan = msg.get("channel_idx")
|
|
114
|
+
text = msg.get("text", "")
|
|
115
|
+
path_len = msg.get("path_len")
|
|
116
|
+
sender = text.split(":", 1)[0].strip()
|
|
117
|
+
|
|
118
|
+
print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")
|
|
119
|
+
print(pathinfo)
|
|
120
|
+
print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")
|
|
121
|
+
print(f"Received on channel {chan} from {sender}: {text} | path_len={path_len}")
|
|
122
|
+
|
|
123
|
+
if chan == CHANNEL_IDX and "ping" in text.lower():
|
|
124
|
+
reply = f"@[{sender}] Pong 🏓{pathinfo}"
|
|
125
|
+
print(f"Detected Ping. Replying in channel {CHANNEL_IDX} with:\n{reply}")
|
|
126
|
+
|
|
127
|
+
result = await meshcore.commands.send_chan_msg(CHANNEL_IDX, reply)
|
|
128
|
+
if result.type == EventType.ERROR:
|
|
129
|
+
print(f"Error sending reply: {result.payload}")
|
|
130
|
+
else:
|
|
131
|
+
print("Reply sent")
|
|
132
|
+
|
|
133
|
+
sub_chan = meshcore.subscribe(
|
|
134
|
+
EventType.CHANNEL_MSG_RECV,
|
|
135
|
+
handle_channel_message,
|
|
136
|
+
attribute_filters={"channel_idx": CHANNEL_IDX},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
sub_rx = meshcore.subscribe(
|
|
140
|
+
EventType.RX_LOG_DATA,
|
|
141
|
+
handle_rx_log_data,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
print(f"Listening for 'Ping' on channel {CHANNEL_IDX} and RX_LOG_DATA...")
|
|
146
|
+
while True:
|
|
147
|
+
await asyncio.sleep(3600)
|
|
148
|
+
except KeyboardInterrupt:
|
|
149
|
+
print("Stopping listener...")
|
|
150
|
+
finally:
|
|
151
|
+
meshcore.unsubscribe(sub_chan)
|
|
152
|
+
meshcore.unsubscribe(sub_rx)
|
|
153
|
+
await meshcore.stop_auto_message_fetching()
|
|
154
|
+
await meshcore.disconnect()
|
|
155
|
+
print("Disconnected")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
asyncio.run(main())
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "meshcore"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.2.5"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Florent de Lamotte", email="florent@frizoncorrea.fr" },
|
|
10
10
|
{ name="Alex Wolden", email="awolden@gmail.com" },
|
|
@@ -18,7 +18,7 @@ classifiers = [
|
|
|
18
18
|
]
|
|
19
19
|
license = "MIT"
|
|
20
20
|
license-files = ["LICEN[CS]E*"]
|
|
21
|
-
dependencies = [ "bleak", "pyserial-asyncio", "pycayennelpp" ]
|
|
21
|
+
dependencies = [ "bleak", "pyserial-asyncio-fast", "pycayennelpp" ]
|
|
22
22
|
|
|
23
23
|
[project.optional-dependencies]
|
|
24
24
|
dev = ["pytest", "pytest-asyncio", "black", "ruff"]
|