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.
Files changed (65) hide show
  1. {meshcore-2.1.21 → meshcore-2.2.5}/PKG-INFO +10 -2
  2. {meshcore-2.1.21 → meshcore-2.2.5}/README.md +8 -0
  3. meshcore-2.2.5/examples/ble_sign_example.py +136 -0
  4. meshcore-2.2.5/examples/ble_stats.py +72 -0
  5. meshcore-2.2.5/examples/serial_meshcore_ollama.py +193 -0
  6. meshcore-2.2.5/examples/serial_pingbot.py +159 -0
  7. {meshcore-2.1.21 → meshcore-2.2.5}/pyproject.toml +2 -2
  8. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/base.py +7 -3
  9. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/binary.py +111 -0
  10. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/contact.py +2 -2
  11. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/control_data.py +8 -3
  12. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/device.py +89 -7
  13. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/messaging.py +16 -4
  14. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/events.py +6 -0
  15. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/packets.py +2 -0
  16. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/reader.py +151 -8
  17. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/serial_cx.py +1 -1
  18. {meshcore-2.1.21 → meshcore-2.2.5}/.github/python-test.yml +0 -0
  19. {meshcore-2.1.21 → meshcore-2.2.5}/.gitignore +0 -0
  20. {meshcore-2.1.21 → meshcore-2.2.5}/LICENSE +0 -0
  21. {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_chat.py +0 -0
  22. {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_pin_pairing_example.py +0 -0
  23. {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_private_key_export.py +0 -0
  24. {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_t1000_chan_msg.py +0 -0
  25. {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_t1000_custom_vars.py +0 -0
  26. {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_t1000_infos.py +0 -0
  27. {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_t1000_msg.py +0 -0
  28. {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_t1000_msg_retries.py +0 -0
  29. {meshcore-2.1.21 → meshcore-2.2.5}/examples/ble_t1000_set_cv.py +0 -0
  30. {meshcore-2.1.21 → meshcore-2.2.5}/examples/connection_events_example.py +0 -0
  31. {meshcore-2.1.21 → meshcore-2.2.5}/examples/mepo_mc_gps.py +0 -0
  32. {meshcore-2.1.21 → meshcore-2.2.5}/examples/pubsub_example.py +0 -0
  33. {meshcore-2.1.21 → meshcore-2.2.5}/examples/rf_packet_monitor.py +0 -0
  34. {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_battery_monitor.py +0 -0
  35. {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_channel_manager.py +0 -0
  36. {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_chat.py +0 -0
  37. {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_contacts.py +0 -0
  38. {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_infos.py +0 -0
  39. {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_msg.py +0 -0
  40. {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_repeater_status.py +0 -0
  41. {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_repeater_telemetry.py +0 -0
  42. {meshcore-2.1.21 → meshcore-2.2.5}/examples/serial_trace.py +0 -0
  43. {meshcore-2.1.21 → meshcore-2.2.5}/examples/tcp_chat.py +0 -0
  44. {meshcore-2.1.21 → meshcore-2.2.5}/examples/tcp_login_status.py +0 -0
  45. {meshcore-2.1.21 → meshcore-2.2.5}/examples/tcp_mchome_contacts.py +0 -0
  46. {meshcore-2.1.21 → meshcore-2.2.5}/examples/tcp_mchome_infos.py +0 -0
  47. {meshcore-2.1.21 → meshcore-2.2.5}/examples/tcp_mchome_msg.py +0 -0
  48. {meshcore-2.1.21 → meshcore-2.2.5}/examples/tcp_mchome_readmsgs.py +0 -0
  49. {meshcore-2.1.21 → meshcore-2.2.5}/pytest.ini +0 -0
  50. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/__init__.py +0 -0
  51. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/ble_cx.py +0 -0
  52. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/commands/__init__.py +0 -0
  53. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/connection_manager.py +0 -0
  54. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/lpp_json_encoder.py +0 -0
  55. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/meshcore.py +0 -0
  56. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/parsing.py +0 -0
  57. {meshcore-2.1.21 → meshcore-2.2.5}/src/meshcore/tcp_cx.py +0 -0
  58. {meshcore-2.1.21 → meshcore-2.2.5}/tests/README.md +0 -0
  59. {meshcore-2.1.21 → meshcore-2.2.5}/tests/test_ble_connection.py +0 -0
  60. {meshcore-2.1.21 → meshcore-2.2.5}/tests/test_ble_pin_pairing.py +0 -0
  61. {meshcore-2.1.21 → meshcore-2.2.5}/tests/test_meshcore_ble_pin.py +0 -0
  62. {meshcore-2.1.21 → meshcore-2.2.5}/tests/unit/test_commands.py +0 -0
  63. {meshcore-2.1.21 → meshcore-2.2.5}/tests/unit/test_events.py +0 -0
  64. {meshcore-2.1.21 → meshcore-2.2.5}/tests/unit/test_private_key_export.py +0 -0
  65. {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.1.21
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.1.21"
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"]