meshcore 2.2.5__tar.gz → 2.2.6__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.2.5 → meshcore-2.2.6}/PKG-INFO +13 -2
- {meshcore-2.2.5 → meshcore-2.2.6}/README.md +12 -1
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_stats.py +5 -0
- meshcore-2.2.6/examples/serial_rss_bot.py +284 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/pyproject.toml +1 -1
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/commands/base.py +22 -1
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/commands/binary.py +108 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/commands/contact.py +9 -0
- meshcore-2.2.6/src/meshcore/commands/control_data.py +96 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/commands/messaging.py +28 -11
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/events.py +2 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/packets.py +7 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/reader.py +80 -4
- {meshcore-2.2.5 → meshcore-2.2.6}/tests/unit/test_commands.py +23 -0
- meshcore-2.2.5/src/meshcore/commands/control_data.py +0 -50
- {meshcore-2.2.5 → meshcore-2.2.6}/.github/python-test.yml +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/.gitignore +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/LICENSE +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_chat.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_pin_pairing_example.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_private_key_export.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_sign_example.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_t1000_chan_msg.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_t1000_custom_vars.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_t1000_infos.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_t1000_msg.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_t1000_msg_retries.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_t1000_set_cv.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/connection_events_example.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/mepo_mc_gps.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/pubsub_example.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/rf_packet_monitor.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_battery_monitor.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_channel_manager.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_chat.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_contacts.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_infos.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_meshcore_ollama.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_msg.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_pingbot.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_repeater_status.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_repeater_telemetry.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_trace.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/tcp_chat.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/tcp_login_status.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/tcp_mchome_contacts.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/tcp_mchome_infos.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/tcp_mchome_msg.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/examples/tcp_mchome_readmsgs.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/pytest.ini +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/__init__.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/ble_cx.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/commands/__init__.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/commands/device.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/connection_manager.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/lpp_json_encoder.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/meshcore.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/parsing.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/serial_cx.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/tcp_cx.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/tests/README.md +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/tests/test_ble_connection.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/tests/test_ble_pin_pairing.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/tests/test_meshcore_ble_pin.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/tests/unit/test_events.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/tests/unit/test_private_key_export.py +0 -0
- {meshcore-2.2.5 → meshcore-2.2.6}/tests/unit/test_reader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meshcore
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.6
|
|
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
|
|
@@ -541,6 +541,13 @@ All commands are async methods that return `Event` objects. Commands are organiz
|
|
|
541
541
|
| `reboot()` | None | None | Reboot device (no response expected) |
|
|
542
542
|
| **Security** ||||
|
|
543
543
|
| `export_private_key()` | None | `PRIVATE_KEY/DISABLED` | Export device private key (requires PIN auth & enabled firmware) |
|
|
544
|
+
| `import_private_key(key)` | `key: bytes` | `OK` | Import private key to device |
|
|
545
|
+
| **Statistics** ||||
|
|
546
|
+
| `get_stats_core()` | None | `STATS_CORE` | Get core statistics (voltage, uptime, errors, queue length) |
|
|
547
|
+
| `get_stats_radio()` | None | `STATS_RADIO` | Get radio statistics (noise floor, last RSSI/SNR, tx/rx time stats) |
|
|
548
|
+
| `get_stats_packets()` | None | `STATS_PACKETS` | Get packet statistics (rx/tx totals, flood vs. direct, recv_errors when present) |
|
|
549
|
+
| **Advanced Configuration** ||||
|
|
550
|
+
| `set_multi_acks(multi_acks)` | `multi_acks: int` | `OK` | Set multi-acks mode (experimental ack repeats) |
|
|
544
551
|
|
|
545
552
|
#### Contact Commands (`meshcore.commands.*`)
|
|
546
553
|
|
|
@@ -568,7 +575,7 @@ All commands are async methods that return `Event` objects. Commands are organiz
|
|
|
568
575
|
| `get_msg(timeout=None)` | `timeout: float` | `CONTACT_MSG_RECV/CHANNEL_MSG_RECV/NO_MORE_MSGS` | Get next pending message |
|
|
569
576
|
| `send_msg(dst, msg, timestamp=None)` | `dst: contact/str/bytes, msg: str, timestamp: int` | `MSG_SENT` | Send direct message |
|
|
570
577
|
| `send_cmd(dst, cmd, timestamp=None)` | `dst: contact/str/bytes, cmd: str, timestamp: int` | `MSG_SENT` | Send command message |
|
|
571
|
-
| `send_chan_msg(chan, msg, timestamp=None)` | `chan: int, msg: str, timestamp: int` | `
|
|
578
|
+
| `send_chan_msg(chan, msg, timestamp=None)` | `chan: int, msg: str, timestamp: int` | `MSG_OK` | Send channel message |
|
|
572
579
|
| **Authentication** ||||
|
|
573
580
|
| `send_login(dst, pwd)` | `dst: contact/str/bytes, pwd: str` | `MSG_SENT` | Send login request |
|
|
574
581
|
| `send_logout(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Send logout request |
|
|
@@ -579,6 +586,9 @@ All commands are async methods that return `Event` objects. Commands are organiz
|
|
|
579
586
|
| `send_binary_req(dst, bin_data)` | `dst: contact/str/bytes, bin_data: bytes` | `MSG_SENT` | Send binary data request |
|
|
580
587
|
| `send_path_discovery(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Initiate path discovery |
|
|
581
588
|
| `send_trace(auth_code, tag, flags, path=None)` | `auth_code: int, tag: int, flags: int, path: list` | `MSG_SENT` | Send route trace packet |
|
|
589
|
+
| **Message Retry & Scope** ||||
|
|
590
|
+
| `send_msg_with_retry(dst, msg, ...)` | `dst, msg, timestamp, max_attempts, max_flood_attempts, flood_after, timeout, min_timeout` | `MSG_SENT/None` | Send message with automatic retry and ACK waiting |
|
|
591
|
+
| `set_flood_scope(scope)` | `scope: str` | `OK` | Set flood scope (hash like "#name", "0"/""/"*" to disable, or raw key) |
|
|
582
592
|
|
|
583
593
|
#### Binary Protocol Commands (`meshcore.commands.*`)
|
|
584
594
|
|
|
@@ -639,6 +649,7 @@ Check the `examples/` directory for more:
|
|
|
639
649
|
- `serial_infos.py`: Quick device info retrieval
|
|
640
650
|
- `serial_msg.py`: Message sending and receiving
|
|
641
651
|
- `serial_pingbot.py`: Ping bot which can be run on a channel
|
|
652
|
+
- `serial_rss_bot.py`: A RSS feed to Meshcore channel example, which broadcasts emergency bushfire warnings in VIC, AU
|
|
642
653
|
- `serial_meshcore_ollama.py`: Simple Ollama to Meshcore gateway, a simple chat box
|
|
643
654
|
- `ble_pin_pairing_example.py`: BLE connection with PIN pairing
|
|
644
655
|
- `ble_private_key_export.py`: BLE private key export with PIN authentication
|
|
@@ -519,6 +519,13 @@ All commands are async methods that return `Event` objects. Commands are organiz
|
|
|
519
519
|
| `reboot()` | None | None | Reboot device (no response expected) |
|
|
520
520
|
| **Security** ||||
|
|
521
521
|
| `export_private_key()` | None | `PRIVATE_KEY/DISABLED` | Export device private key (requires PIN auth & enabled firmware) |
|
|
522
|
+
| `import_private_key(key)` | `key: bytes` | `OK` | Import private key to device |
|
|
523
|
+
| **Statistics** ||||
|
|
524
|
+
| `get_stats_core()` | None | `STATS_CORE` | Get core statistics (voltage, uptime, errors, queue length) |
|
|
525
|
+
| `get_stats_radio()` | None | `STATS_RADIO` | Get radio statistics (noise floor, last RSSI/SNR, tx/rx time stats) |
|
|
526
|
+
| `get_stats_packets()` | None | `STATS_PACKETS` | Get packet statistics (rx/tx totals, flood vs. direct, recv_errors when present) |
|
|
527
|
+
| **Advanced Configuration** ||||
|
|
528
|
+
| `set_multi_acks(multi_acks)` | `multi_acks: int` | `OK` | Set multi-acks mode (experimental ack repeats) |
|
|
522
529
|
|
|
523
530
|
#### Contact Commands (`meshcore.commands.*`)
|
|
524
531
|
|
|
@@ -546,7 +553,7 @@ All commands are async methods that return `Event` objects. Commands are organiz
|
|
|
546
553
|
| `get_msg(timeout=None)` | `timeout: float` | `CONTACT_MSG_RECV/CHANNEL_MSG_RECV/NO_MORE_MSGS` | Get next pending message |
|
|
547
554
|
| `send_msg(dst, msg, timestamp=None)` | `dst: contact/str/bytes, msg: str, timestamp: int` | `MSG_SENT` | Send direct message |
|
|
548
555
|
| `send_cmd(dst, cmd, timestamp=None)` | `dst: contact/str/bytes, cmd: str, timestamp: int` | `MSG_SENT` | Send command message |
|
|
549
|
-
| `send_chan_msg(chan, msg, timestamp=None)` | `chan: int, msg: str, timestamp: int` | `
|
|
556
|
+
| `send_chan_msg(chan, msg, timestamp=None)` | `chan: int, msg: str, timestamp: int` | `MSG_OK` | Send channel message |
|
|
550
557
|
| **Authentication** ||||
|
|
551
558
|
| `send_login(dst, pwd)` | `dst: contact/str/bytes, pwd: str` | `MSG_SENT` | Send login request |
|
|
552
559
|
| `send_logout(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Send logout request |
|
|
@@ -557,6 +564,9 @@ All commands are async methods that return `Event` objects. Commands are organiz
|
|
|
557
564
|
| `send_binary_req(dst, bin_data)` | `dst: contact/str/bytes, bin_data: bytes` | `MSG_SENT` | Send binary data request |
|
|
558
565
|
| `send_path_discovery(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Initiate path discovery |
|
|
559
566
|
| `send_trace(auth_code, tag, flags, path=None)` | `auth_code: int, tag: int, flags: int, path: list` | `MSG_SENT` | Send route trace packet |
|
|
567
|
+
| **Message Retry & Scope** ||||
|
|
568
|
+
| `send_msg_with_retry(dst, msg, ...)` | `dst, msg, timestamp, max_attempts, max_flood_attempts, flood_after, timeout, min_timeout` | `MSG_SENT/None` | Send message with automatic retry and ACK waiting |
|
|
569
|
+
| `set_flood_scope(scope)` | `scope: str` | `OK` | Set flood scope (hash like "#name", "0"/""/"*" to disable, or raw key) |
|
|
560
570
|
|
|
561
571
|
#### Binary Protocol Commands (`meshcore.commands.*`)
|
|
562
572
|
|
|
@@ -617,6 +627,7 @@ Check the `examples/` directory for more:
|
|
|
617
627
|
- `serial_infos.py`: Quick device info retrieval
|
|
618
628
|
- `serial_msg.py`: Message sending and receiving
|
|
619
629
|
- `serial_pingbot.py`: Ping bot which can be run on a channel
|
|
630
|
+
- `serial_rss_bot.py`: A RSS feed to Meshcore channel example, which broadcasts emergency bushfire warnings in VIC, AU
|
|
620
631
|
- `serial_meshcore_ollama.py`: Simple Ollama to Meshcore gateway, a simple chat box
|
|
621
632
|
- `ble_pin_pairing_example.py`: BLE connection with PIN pairing
|
|
622
633
|
- `ble_private_key_export.py`: BLE private key export with PIN authentication
|
|
@@ -53,6 +53,11 @@ async def main():
|
|
|
53
53
|
else:
|
|
54
54
|
print("📦 Packet Statistics:")
|
|
55
55
|
print(json.dumps(result.payload, indent=2))
|
|
56
|
+
recv_errors = result.payload.get("recv_errors")
|
|
57
|
+
if recv_errors is not None:
|
|
58
|
+
print(f" Receive/CRC errors (RadioLib): {recv_errors}")
|
|
59
|
+
else:
|
|
60
|
+
print(" Receive/CRC errors (RadioLib): not reported (legacy 26-byte frame)")
|
|
56
61
|
print()
|
|
57
62
|
|
|
58
63
|
except Exception as e:
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import urllib.request
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from html import unescape
|
|
7
|
+
from html.parser import HTMLParser
|
|
8
|
+
import hashlib
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from meshcore import MeshCore, EventType
|
|
12
|
+
|
|
13
|
+
# =====================
|
|
14
|
+
# Config
|
|
15
|
+
# =====================
|
|
16
|
+
SERIAL_PORT = "COM4" # change this to your serial port
|
|
17
|
+
CHANNEL_IDXS = [6]#[5, 6] # change this to the index of your target channel
|
|
18
|
+
|
|
19
|
+
RSS_URL = "https://data.emergency.vic.gov.au/Show?pageId=getIncidentRSS"
|
|
20
|
+
POLL_INTERVAL_SEC = 300 # poll interval (seconds)
|
|
21
|
+
|
|
22
|
+
# Only send items whose description (plain text) contains this keyword (case-insensitive)
|
|
23
|
+
KEYWORDS_REQUIRED = ["BUSHFIRE"]
|
|
24
|
+
|
|
25
|
+
# Only send these fields from the RSS description
|
|
26
|
+
FIELDS_TO_SEND = [
|
|
27
|
+
"Type",
|
|
28
|
+
"Fire District",
|
|
29
|
+
"Location",
|
|
30
|
+
"Latitude",
|
|
31
|
+
"Longitude",
|
|
32
|
+
"Status",
|
|
33
|
+
"Size",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
BLOCKED_STATUSES = [
|
|
37
|
+
"Under Control",
|
|
38
|
+
"Safe",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
logging.basicConfig(level=logging.INFO)
|
|
42
|
+
_LOGGER = logging.getLogger("serial_rssbot")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _HTMLTextExtractor(HTMLParser):
|
|
46
|
+
def __init__(self):
|
|
47
|
+
super().__init__()
|
|
48
|
+
self._chunks: list[str] = []
|
|
49
|
+
|
|
50
|
+
def handle_data(self, data: str) -> None:
|
|
51
|
+
if data:
|
|
52
|
+
self._chunks.append(data)
|
|
53
|
+
|
|
54
|
+
def get_text(self) -> str:
|
|
55
|
+
return "".join(self._chunks)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _strip_html(html: str) -> str:
|
|
59
|
+
"""Convert HTML-ish fragments into plain text."""
|
|
60
|
+
if not html:
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
# Normalize <br> into newlines so field parsing is reliable.
|
|
64
|
+
html = re.sub(r"<\s*br\s*/?\s*>", "\n", html, flags=re.IGNORECASE)
|
|
65
|
+
|
|
66
|
+
parser = _HTMLTextExtractor()
|
|
67
|
+
try:
|
|
68
|
+
parser.feed(html)
|
|
69
|
+
parser.close()
|
|
70
|
+
text = parser.get_text()
|
|
71
|
+
except Exception:
|
|
72
|
+
text = re.sub(r"<[^>]+>", "\n", html)
|
|
73
|
+
|
|
74
|
+
text = unescape(text)
|
|
75
|
+
# Normalize whitespace but keep newlines as separators.
|
|
76
|
+
text = text.replace("\r", "\n")
|
|
77
|
+
text = re.sub(r"[ \t\f\v]+", " ", text)
|
|
78
|
+
text = re.sub(r"\n+", "\n", text)
|
|
79
|
+
return text.strip()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _fetch_rss_bytes(url: str) -> bytes:
|
|
83
|
+
req = urllib.request.Request(
|
|
84
|
+
url,
|
|
85
|
+
headers={
|
|
86
|
+
"User-Agent": "meshcore-rss-bot/1.0",
|
|
87
|
+
"Accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8",
|
|
88
|
+
},
|
|
89
|
+
method="GET",
|
|
90
|
+
)
|
|
91
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
92
|
+
return resp.read()
|
|
93
|
+
|
|
94
|
+
def extract_fields(raw_html: str) -> dict[str, str]:
|
|
95
|
+
"""
|
|
96
|
+
Extract desired fields from the HTML description.
|
|
97
|
+
We strip HTML to text with newlines, then parse lines like "Field: value".
|
|
98
|
+
"""
|
|
99
|
+
text = _strip_html(raw_html)
|
|
100
|
+
lines = [ln.strip() for ln in text.split("\n") if ln.strip()]
|
|
101
|
+
|
|
102
|
+
result: dict[str, str] = {}
|
|
103
|
+
for ln in lines:
|
|
104
|
+
m = re.match(r"^([^:]+):\s*(.*)$", ln)
|
|
105
|
+
if not m:
|
|
106
|
+
continue
|
|
107
|
+
key = m.group(1).strip()
|
|
108
|
+
val = m.group(2).strip()
|
|
109
|
+
|
|
110
|
+
# Match desired fields case-insensitively
|
|
111
|
+
for wanted in FIELDS_TO_SEND:
|
|
112
|
+
if key.lower() == wanted.lower():
|
|
113
|
+
result[wanted] = val
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
def _parse_rss_items(xml_bytes: bytes) -> list[dict[str, str]]:
|
|
119
|
+
"""Return list of items with keys: id, description, raw_description."""
|
|
120
|
+
items: list[dict[str, str]] = []
|
|
121
|
+
|
|
122
|
+
root = ET.fromstring(xml_bytes)
|
|
123
|
+
|
|
124
|
+
# RSS 2.0: <rss><channel><item>...
|
|
125
|
+
channel = root.find("channel") if root.tag.lower().endswith("rss") else root
|
|
126
|
+
if channel is None:
|
|
127
|
+
return items
|
|
128
|
+
|
|
129
|
+
for it in channel.findall("item"):
|
|
130
|
+
title = (it.findtext("title") or "").strip()
|
|
131
|
+
link = (it.findtext("link") or "").strip()
|
|
132
|
+
guid = (it.findtext("guid") or "").strip()
|
|
133
|
+
pub_date = (it.findtext("pubDate") or "").strip()
|
|
134
|
+
|
|
135
|
+
desc_html = it.findtext("description") or ""
|
|
136
|
+
desc_text = _strip_html(desc_html)
|
|
137
|
+
|
|
138
|
+
# stable_id = (guid or link or f"{title}|{pub_date}" or desc_text).strip()
|
|
139
|
+
|
|
140
|
+
base_id = (guid or link or f"{title}|{pub_date}").strip()
|
|
141
|
+
|
|
142
|
+
# fingerprint only the fields you output
|
|
143
|
+
fields = extract_fields(desc_html)
|
|
144
|
+
parts = []
|
|
145
|
+
for k in FIELDS_TO_SEND:
|
|
146
|
+
v = fields.get(k, "")
|
|
147
|
+
parts.append(f"{k}={v}")
|
|
148
|
+
fingerprint = hashlib.sha1("|".join(parts).encode("utf-8")).hexdigest()[:12]
|
|
149
|
+
|
|
150
|
+
stable_id = f"{base_id}|{fingerprint}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
items.append(
|
|
154
|
+
{
|
|
155
|
+
"id": stable_id,
|
|
156
|
+
"description": desc_text, # plain text for keyword filtering
|
|
157
|
+
"raw_description": desc_html, # original HTML-ish description
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return items
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_filtered_message(item: dict[str, str]) -> str | None:
|
|
168
|
+
# Keyword filter (case-insensitive) on plain text description
|
|
169
|
+
desc_text_lower = (item.get("description") or "").lower()
|
|
170
|
+
if not any(k.lower() in desc_text_lower for k in KEYWORDS_REQUIRED):
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
fields = extract_fields(item.get("raw_description") or "")
|
|
174
|
+
if not fields:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
status_norm = fields.get("Status", "").strip().lower()
|
|
178
|
+
blocked_norm = [b.lower() for b in BLOCKED_STATUSES]
|
|
179
|
+
|
|
180
|
+
if status_norm in blocked_norm:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# if any(b.lower() in status for b in BLOCKED_STATUSES):
|
|
185
|
+
# return None
|
|
186
|
+
|
|
187
|
+
# Keep order exactly as FIELDS_TO_SEND, skip missing
|
|
188
|
+
# parts = []
|
|
189
|
+
# for k in FIELDS_TO_SEND:
|
|
190
|
+
# v = fields.get(k)
|
|
191
|
+
# if v:
|
|
192
|
+
# parts.append(f"{v}")
|
|
193
|
+
|
|
194
|
+
parts = []
|
|
195
|
+
|
|
196
|
+
lat = fields.get("Latitude")
|
|
197
|
+
lon = fields.get("Longitude")
|
|
198
|
+
|
|
199
|
+
for k in FIELDS_TO_SEND:
|
|
200
|
+
if k in ("Latitude", "Longitude"):
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
v = fields.get(k)
|
|
204
|
+
if v:
|
|
205
|
+
parts.append(v)
|
|
206
|
+
|
|
207
|
+
# append formatted lat,lon at the end
|
|
208
|
+
if lat and lon:
|
|
209
|
+
parts.append(f"{lat},{lon}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if not parts:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
return " | ".join(parts)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def _fetch_items_async() -> list[dict[str, str]]:
|
|
219
|
+
xml_bytes = await asyncio.to_thread(_fetch_rss_bytes, RSS_URL)
|
|
220
|
+
return _parse_rss_items(xml_bytes)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def main() -> None:
|
|
224
|
+
meshcore = await MeshCore.create_serial(SERIAL_PORT, debug=True)
|
|
225
|
+
print(f"Connected on {SERIAL_PORT}")
|
|
226
|
+
|
|
227
|
+
# await meshcore.start_auto_message_fetching()
|
|
228
|
+
|
|
229
|
+
seen_ids: set[str] = set()
|
|
230
|
+
|
|
231
|
+
# Prime seen set so we do not spam old items on startup
|
|
232
|
+
try:
|
|
233
|
+
initial_items = await _fetch_items_async()
|
|
234
|
+
for it in initial_items:
|
|
235
|
+
if it.get("id"):
|
|
236
|
+
seen_ids.add(it["id"])
|
|
237
|
+
_LOGGER.info("Primed %d existing RSS items as seen.", len(seen_ids))
|
|
238
|
+
except Exception as ex:
|
|
239
|
+
_LOGGER.warning("Failed to prime RSS items: %s", ex)
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
while True:
|
|
243
|
+
try:
|
|
244
|
+
items = await _fetch_items_async()
|
|
245
|
+
|
|
246
|
+
# Feeds are usually newest-first. Send unseen in chronological order.
|
|
247
|
+
new_items = [it for it in items if it.get("id") and it["id"] not in seen_ids]
|
|
248
|
+
for it in reversed(new_items):
|
|
249
|
+
msg_text = build_filtered_message(it)
|
|
250
|
+
|
|
251
|
+
# Mark as seen even if it does not match filter, so we do not re-check forever
|
|
252
|
+
seen_ids.add(it["id"])
|
|
253
|
+
|
|
254
|
+
if not msg_text:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
# _LOGGER.info("Sending filtered RSS item to channel %s", CHANNEL_IDX)
|
|
258
|
+
# result = await meshcore.commands.send_chan_msg(CHANNEL_IDX, msg_text)
|
|
259
|
+
# if result.type == EventType.ERROR:
|
|
260
|
+
# _LOGGER.error("Error sending RSS message: %s", result.payload)
|
|
261
|
+
for ch in CHANNEL_IDXS:
|
|
262
|
+
_LOGGER.info("Sending filtered RSS item to channel %s", ch)
|
|
263
|
+
result = await meshcore.commands.send_chan_msg(ch, msg_text)
|
|
264
|
+
if result.type == EventType.ERROR:
|
|
265
|
+
_LOGGER.error("Error sending RSS message to channel %s: %s", ch, result.payload)
|
|
266
|
+
await asyncio.sleep(5) # seconds
|
|
267
|
+
await asyncio.sleep(5)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
except Exception as ex:
|
|
271
|
+
_LOGGER.warning("RSS poll failed: %s", ex)
|
|
272
|
+
|
|
273
|
+
await asyncio.sleep(POLL_INTERVAL_SEC)
|
|
274
|
+
|
|
275
|
+
except KeyboardInterrupt:
|
|
276
|
+
print("Stopping...")
|
|
277
|
+
finally:
|
|
278
|
+
await meshcore.stop_auto_message_fetching()
|
|
279
|
+
await meshcore.disconnect()
|
|
280
|
+
print("Disconnected")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
if __name__ == "__main__":
|
|
284
|
+
asyncio.run(main())
|
|
@@ -3,7 +3,7 @@ import logging
|
|
|
3
3
|
import random
|
|
4
4
|
from typing import Any, Callable, Coroutine, Dict, List, Optional, Union
|
|
5
5
|
|
|
6
|
-
from meshcore.packets import BinaryReqType
|
|
6
|
+
from meshcore.packets import BinaryReqType, AnonReqType
|
|
7
7
|
|
|
8
8
|
from ..events import Event, EventDispatcher, EventType
|
|
9
9
|
from ..reader import MessageReader
|
|
@@ -187,3 +187,24 @@ class CommandHandlerBase:
|
|
|
187
187
|
self._reader.register_binary_request(pubkey_prefix.hex(), exp_tag, request_type, actual_timeout, context=context)
|
|
188
188
|
|
|
189
189
|
return result
|
|
190
|
+
|
|
191
|
+
async def send_anon_req(self, dst: DestinationType, request_type: AnonReqType, data: Optional[bytes] = None, context={}, timeout=None, min_timeout=0) -> Event:
|
|
192
|
+
dst_bytes = _validate_destination(dst, prefix_length=32)
|
|
193
|
+
pubkey_prefix = _validate_destination(dst, prefix_length=6)
|
|
194
|
+
logger.debug(f"Anon Binary request to {dst_bytes.hex()}")
|
|
195
|
+
data = b"\x39" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + (data if data else b"")
|
|
196
|
+
|
|
197
|
+
result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
|
|
198
|
+
|
|
199
|
+
# Register the request with the reader if we have both reader and request_type
|
|
200
|
+
if (result.type == EventType.MSG_SENT and
|
|
201
|
+
self._reader is not None and
|
|
202
|
+
request_type is not None):
|
|
203
|
+
|
|
204
|
+
exp_tag = result.payload["expected_ack"].hex()
|
|
205
|
+
# Use provided timeout or fallback to suggested timeout (with 5s default)
|
|
206
|
+
actual_timeout = timeout if timeout is not None and timeout > 0 else result.payload.get("suggested_timeout", 4000) / 800.0
|
|
207
|
+
actual_timeout = min_timeout if actual_timeout < min_timeout else actual_timeout
|
|
208
|
+
self._reader.register_binary_request(pubkey_prefix.hex(), exp_tag, request_type, actual_timeout, context=context)
|
|
209
|
+
|
|
210
|
+
return result
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
3
|
import random
|
|
4
|
+
import io
|
|
4
5
|
|
|
5
6
|
from .base import CommandHandlerBase
|
|
6
7
|
from ..events import EventType
|
|
7
8
|
from ..packets import BinaryReqType
|
|
9
|
+
from ..packets import AnonReqType
|
|
8
10
|
|
|
9
11
|
logger = logging.getLogger("meshcore")
|
|
10
12
|
|
|
@@ -242,3 +244,109 @@ class BinaryCommandHandler(CommandHandlerBase):
|
|
|
242
244
|
res["neighbours"] += next_res["neighbours"]
|
|
243
245
|
|
|
244
246
|
return res
|
|
247
|
+
|
|
248
|
+
async def req_regions_async(self, contact, timeout=0, min_timeout=0):
|
|
249
|
+
req = b"\0" # The return path, I currently do nothing with, so direct only
|
|
250
|
+
return await self.send_anon_req(
|
|
251
|
+
contact,
|
|
252
|
+
AnonReqType.REGIONS,
|
|
253
|
+
data=req,
|
|
254
|
+
timeout=timeout
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def req_regions_sync(self, contact, timeout=0, min_timeout=0):
|
|
258
|
+
res = await self.req_regions_async(contact, timeout, min_timeout)
|
|
259
|
+
|
|
260
|
+
if res.type == EventType.ERROR:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout
|
|
264
|
+
timeout = timeout if timeout > min_timeout else min_timeout
|
|
265
|
+
|
|
266
|
+
if self.dispatcher is None:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
region_event = await self.dispatcher.wait_for_event(
|
|
270
|
+
EventType.BINARY_RESPONSE,
|
|
271
|
+
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
|
272
|
+
timeout=timeout,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if region_event is None:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
pkt = bytes().fromhex(region_event.payload["data"])
|
|
279
|
+
pbuf = io.BytesIO(pkt)
|
|
280
|
+
tag_again = pbuf.read(4)
|
|
281
|
+
return pbuf.read().decode("utf-8", "ignore").strip("\x00")
|
|
282
|
+
|
|
283
|
+
async def req_owner_async(self, contact, timeout=0, min_timeout=0):
|
|
284
|
+
req = b"\0"
|
|
285
|
+
return await self.send_anon_req(
|
|
286
|
+
contact,
|
|
287
|
+
AnonReqType.OWNER,
|
|
288
|
+
data=req,
|
|
289
|
+
timeout=timeout
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
async def req_owner_sync(self, contact, timeout=0, min_timeout=0):
|
|
293
|
+
|
|
294
|
+
res = await self.req_owner_async(contact, timeout, min_timeout)
|
|
295
|
+
|
|
296
|
+
if res.type == EventType.ERROR:
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout
|
|
300
|
+
timeout = timeout if timeout > min_timeout else min_timeout
|
|
301
|
+
|
|
302
|
+
if self.dispatcher is None:
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
owner_event = await self.dispatcher.wait_for_event(
|
|
306
|
+
EventType.BINARY_RESPONSE,
|
|
307
|
+
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
|
308
|
+
timeout=timeout,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if owner_event is None:
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
pkt = bytes().fromhex(owner_event.payload["data"])
|
|
315
|
+
pbuf = io.BytesIO(pkt)
|
|
316
|
+
tag_again = pbuf.read(4)
|
|
317
|
+
strings = pbuf.read().decode("utf-8", "ignore").split("\n", 1)
|
|
318
|
+
|
|
319
|
+
return dict(name=strings[0], owner=strings[1].strip("\x00"))
|
|
320
|
+
|
|
321
|
+
async def req_basic_async(self, contact, timeout=0, min_timeout=0):
|
|
322
|
+
req = b"\0"
|
|
323
|
+
return await self.send_anon_req(
|
|
324
|
+
contact,
|
|
325
|
+
AnonReqType.BASIC,
|
|
326
|
+
data=req,
|
|
327
|
+
timeout=timeout
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
async def req_basic_sync(self, contact, timeout=0, min_timeout=0):
|
|
331
|
+
|
|
332
|
+
res = await self.req_basic_async(contact, timeout, min_timeout)
|
|
333
|
+
|
|
334
|
+
if res.type == EventType.ERROR:
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
timeout = res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout
|
|
338
|
+
timeout = timeout if timeout > min_timeout else min_timeout
|
|
339
|
+
|
|
340
|
+
if self.dispatcher is None:
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
basic_event = await self.dispatcher.wait_for_event(
|
|
344
|
+
EventType.BINARY_RESPONSE,
|
|
345
|
+
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
|
346
|
+
timeout=timeout,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if basic_event is None:
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
return basic_event.payload
|
|
@@ -143,3 +143,12 @@ class ContactCommands(CommandHandlerBase):
|
|
|
143
143
|
|
|
144
144
|
async def change_contact_flags(self, contact, flags) -> Event:
|
|
145
145
|
return await self.update_contact(contact, flags=flags)
|
|
146
|
+
|
|
147
|
+
async def set_autoadd_config(self, flag : int) -> Event:
|
|
148
|
+
data = b"\x3A" + flag.to_bytes(1, "little", signed=False)
|
|
149
|
+
return await self.send(data, [EventType.OK, EventType.ERROR])
|
|
150
|
+
|
|
151
|
+
async def get_autoadd_config(self) -> Event:
|
|
152
|
+
data = b"\x3B"
|
|
153
|
+
return await self.send(data, [EventType.AUTOADD_CONFIG, EventType.ERROR])
|
|
154
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import random
|
|
3
|
+
|
|
4
|
+
from .base import CommandHandlerBase
|
|
5
|
+
from ..events import EventType, Event
|
|
6
|
+
from ..packets import ControlType, PacketType
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("meshcore")
|
|
9
|
+
|
|
10
|
+
# Command codes
|
|
11
|
+
CMD_REQUEST_ADVERT = 57 # 0x39
|
|
12
|
+
|
|
13
|
+
class ControlDataCommandHandler(CommandHandlerBase):
|
|
14
|
+
"""Helper functions to handle binary requests through binary commands"""
|
|
15
|
+
|
|
16
|
+
async def send_control_data (self, control_type: int, payload: bytes) -> Event:
|
|
17
|
+
data = bytearray([PacketType.SEND_CONTROL_DATA.value])
|
|
18
|
+
data.extend(control_type.to_bytes(1, "little", signed = False))
|
|
19
|
+
data.extend(payload)
|
|
20
|
+
|
|
21
|
+
result = await self.send(data, [EventType.OK, EventType.ERROR])
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
async def send_node_discover_req (
|
|
25
|
+
self,
|
|
26
|
+
filter: int,
|
|
27
|
+
prefix_only: bool=True,
|
|
28
|
+
tag: int=None,
|
|
29
|
+
since: int=None
|
|
30
|
+
) -> Event:
|
|
31
|
+
|
|
32
|
+
if tag is None:
|
|
33
|
+
tag = random.randint(1, 0xFFFFFFFF)
|
|
34
|
+
|
|
35
|
+
data = bytearray()
|
|
36
|
+
data.extend(filter.to_bytes(1, "little", signed=False))
|
|
37
|
+
data.extend(tag.to_bytes(4, "little"))
|
|
38
|
+
if not since is None:
|
|
39
|
+
data.extend(since.to_bytes(4, "little", signed=False))
|
|
40
|
+
|
|
41
|
+
logger.debug(f"sending node discover req {data.hex()}")
|
|
42
|
+
|
|
43
|
+
flags = 0
|
|
44
|
+
flags = flags | 1 if prefix_only else flags
|
|
45
|
+
|
|
46
|
+
res = await self.send_control_data(
|
|
47
|
+
ControlType.NODE_DISCOVER_REQ.value|flags, data)
|
|
48
|
+
|
|
49
|
+
if res is None:
|
|
50
|
+
return None
|
|
51
|
+
else:
|
|
52
|
+
res.payload["tag"] = tag
|
|
53
|
+
return res
|
|
54
|
+
|
|
55
|
+
async def request_advert(self, prefix: bytes, path: bytes) -> Event:
|
|
56
|
+
"""
|
|
57
|
+
Request advertisement from a node via pull-based system.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
prefix: First byte of target node's public key (PATH_HASH_SIZE = 1)
|
|
61
|
+
path: Path to reach the node (1-64 bytes)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Event with type OK on success, ERROR on failure.
|
|
65
|
+
The actual response arrives asynchronously as ADVERT_RESPONSE event.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If prefix is not 1 byte or path is empty/too long
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
# Get repeater from contacts
|
|
72
|
+
contacts = (await mc.commands.get_contacts()).payload
|
|
73
|
+
repeater = next(c for c in contacts.values() if c['adv_type'] == 2)
|
|
74
|
+
|
|
75
|
+
# Extract prefix and path
|
|
76
|
+
prefix = bytes.fromhex(repeater['public_key'])[:1]
|
|
77
|
+
path = bytes(repeater.get('out_path', [])) or prefix
|
|
78
|
+
|
|
79
|
+
# Send request
|
|
80
|
+
result = await mc.commands.request_advert(prefix, path)
|
|
81
|
+
if result.type == EventType.ERROR:
|
|
82
|
+
print(f"Failed: {result.payload}")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Wait for response
|
|
86
|
+
response = await mc.wait_for_event(EventType.ADVERT_RESPONSE, timeout=30)
|
|
87
|
+
if response:
|
|
88
|
+
print(f"Node: {response.payload['node_name']}")
|
|
89
|
+
"""
|
|
90
|
+
if len(prefix) != 1:
|
|
91
|
+
raise ValueError("Prefix must be exactly 1 byte (PATH_HASH_SIZE)")
|
|
92
|
+
if not path or len(path) > 64:
|
|
93
|
+
raise ValueError("Path must be 1-64 bytes")
|
|
94
|
+
|
|
95
|
+
cmd = bytes([CMD_REQUEST_ADVERT]) + prefix + bytes([len(path)]) + path
|
|
96
|
+
return await self.send(cmd, [EventType.OK, EventType.ERROR])
|
|
@@ -141,17 +141,27 @@ class MessagingCommands(CommandHandlerBase):
|
|
|
141
141
|
|
|
142
142
|
return None if res is None else result
|
|
143
143
|
|
|
144
|
-
async def send_chan_msg(self, chan, msg, timestamp=None) -> Event:
|
|
144
|
+
async def send_chan_msg(self, chan: int, msg: str, timestamp: Optional[int|bytes] = None) -> Event:
|
|
145
145
|
logger.debug(f"Sending channel message to channel {chan}: {msg}")
|
|
146
146
|
|
|
147
|
-
# Default to current time if timestamp not provided
|
|
148
147
|
if timestamp is None:
|
|
148
|
+
# Default to current time if timestamp not provided
|
|
149
149
|
import time
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
timestamp_bytes = int(time.time()).to_bytes(4, "little")
|
|
151
|
+
elif isinstance(timestamp, int):
|
|
152
|
+
timestamp_bytes = timestamp.to_bytes(4, "little")
|
|
153
|
+
elif isinstance(timestamp, bytes) and len(timestamp) == 4:
|
|
154
|
+
# expected bytes format
|
|
155
|
+
timestamp_bytes = timestamp
|
|
156
|
+
else:
|
|
157
|
+
if isinstance(timestamp, bytes):
|
|
158
|
+
logger.error(f"Invalid timestamp format: got bytes of length {len(timestamp)} but expected bytes of length 4")
|
|
159
|
+
else:
|
|
160
|
+
logger.error(f"Invalid timestamp format: got {type(timestamp)} but expected int or 4 bytes")
|
|
161
|
+
return Event(EventType.ERROR, {"reason": "invalid_timestamp_format"})
|
|
152
162
|
|
|
153
163
|
data = (
|
|
154
|
-
b"\x03\x00" + chan.to_bytes(1, "little") +
|
|
164
|
+
b"\x03\x00" + chan.to_bytes(1, "little") + timestamp_bytes + msg.encode("utf-8")
|
|
155
165
|
)
|
|
156
166
|
return await self.send(data, [EventType.OK, EventType.ERROR])
|
|
157
167
|
|
|
@@ -225,13 +235,20 @@ class MessagingCommands(CommandHandlerBase):
|
|
|
225
235
|
return await self.send(cmd_data, [EventType.MSG_SENT, EventType.ERROR])
|
|
226
236
|
|
|
227
237
|
async def set_flood_scope(self, scope):
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
elif scope == "0" or scope == "None" or scope == "*" or scope == "": # disable
|
|
238
|
+
|
|
239
|
+
if scope is None:
|
|
240
|
+
logger.debug(f"Resetting scope")
|
|
232
241
|
scope_key = b"\0"*16
|
|
233
|
-
|
|
234
|
-
|
|
242
|
+
elif isinstance (scope, str):
|
|
243
|
+
if scope == "0" or scope == "None" or scope == "*" or scope == "": # disable
|
|
244
|
+
logger.debug(f"Resetting scope")
|
|
245
|
+
scope_key = b"\0"*16
|
|
246
|
+
else:
|
|
247
|
+
logger.debug(f"Setting scope from string {scope}")
|
|
248
|
+
scope_key = sha256(scope.encode("utf-8")).digest()[0:16]
|
|
249
|
+
elif isinstance (scope, bytes): # scope has been sent directly as byte
|
|
250
|
+
logger.debug(f"Directly setting scope to {scope}")
|
|
251
|
+
scope_key = scope
|
|
235
252
|
|
|
236
253
|
logger.debug(f"Setting scope to {scope_key.hex()}")
|
|
237
254
|
|
|
@@ -22,6 +22,7 @@ class EventType(Enum):
|
|
|
22
22
|
MSG_SENT = "message_sent"
|
|
23
23
|
NEW_CONTACT = "new_contact"
|
|
24
24
|
NEXT_CONTACT = "next_contact"
|
|
25
|
+
AUTOADD_CONFIG = "autoadd_config"
|
|
25
26
|
|
|
26
27
|
# Push notifications
|
|
27
28
|
ADVERTISEMENT = "advertisement"
|
|
@@ -52,6 +53,7 @@ class EventType(Enum):
|
|
|
52
53
|
NEIGHBOURS_RESPONSE = "neighbours_response"
|
|
53
54
|
SIGN_START = "sign_start"
|
|
54
55
|
SIGNATURE = "signature"
|
|
56
|
+
ADVERT_RESPONSE = "advert_response"
|
|
55
57
|
|
|
56
58
|
# Command response types
|
|
57
59
|
OK = "command_ok"
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
from enum import Enum
|
|
2
2
|
|
|
3
|
+
class AnonReqType(Enum):
|
|
4
|
+
REGIONS = 0x01
|
|
5
|
+
OWNER = 0x02
|
|
6
|
+
BASIC = 0x03 # just remote clock
|
|
7
|
+
|
|
3
8
|
class BinaryReqType(Enum):
|
|
4
9
|
STATUS = 0x01
|
|
5
10
|
KEEP_ALIVE = 0x02
|
|
@@ -37,6 +42,7 @@ class PacketType(Enum):
|
|
|
37
42
|
SIGNATURE = 20
|
|
38
43
|
CUSTOM_VARS = 21
|
|
39
44
|
STATS = 24
|
|
45
|
+
AUTOADD_CONFIG = 25
|
|
40
46
|
BINARY_REQ = 50
|
|
41
47
|
FACTORY_RESET = 51
|
|
42
48
|
PATH_DISCOVERY = 52
|
|
@@ -59,3 +65,4 @@ class PacketType(Enum):
|
|
|
59
65
|
BINARY_RESPONSE = 0x8C
|
|
60
66
|
PATH_DISCOVERY_RESPONSE = 0x8D
|
|
61
67
|
CONTROL_DATA = 0x8E
|
|
68
|
+
ADVERT_RESPONSE = 0x8F
|
|
@@ -346,10 +346,11 @@ class MessageReader:
|
|
|
346
346
|
await self.dispatcher.dispatch(Event(EventType.ERROR, {"reason": f"binary_parse_error: {e}"}))
|
|
347
347
|
|
|
348
348
|
elif stats_type == 2: # STATS_TYPE_PACKETS
|
|
349
|
-
# RESP_CODE_STATS + STATS_TYPE_PACKETS: 26 bytes
|
|
350
|
-
# Format: <B B I I I I I I (response_code, stats_type, recv, sent, flood_tx, direct_tx, flood_rx, direct_rx)
|
|
349
|
+
# RESP_CODE_STATS + STATS_TYPE_PACKETS: 26 bytes (legacy) or 30 bytes (includes recv_errors)
|
|
350
|
+
# Format: <B B I I I I I I [I] (response_code, stats_type, recv, sent, flood_tx, direct_tx, flood_rx, direct_rx [, recv_errors])
|
|
351
|
+
logger.debug(f"stats packets payload len={len(data)} (expected 26 or 30)")
|
|
351
352
|
if len(data) < 26:
|
|
352
|
-
logger.error(f"Stats packets response too short: {len(data)} bytes, expected 26")
|
|
353
|
+
logger.error(f"Stats packets response too short: {len(data)} bytes, expected 26 or 30")
|
|
353
354
|
await self.dispatcher.dispatch(Event(EventType.ERROR, {"reason": "invalid_frame_length"}))
|
|
354
355
|
else:
|
|
355
356
|
try:
|
|
@@ -362,6 +363,11 @@ class MessageReader:
|
|
|
362
363
|
'flood_rx': flood_rx,
|
|
363
364
|
'direct_rx': direct_rx
|
|
364
365
|
}
|
|
366
|
+
if len(data) >= 30:
|
|
367
|
+
(recv_errors,) = struct.unpack('<I', data[26:30])
|
|
368
|
+
res['recv_errors'] = recv_errors
|
|
369
|
+
else:
|
|
370
|
+
res['recv_errors'] = None # legacy 26-byte frame
|
|
365
371
|
logger.debug(f"parsed stats packets: {res}")
|
|
366
372
|
await self.dispatcher.dispatch(Event(EventType.STATS_PACKETS, res))
|
|
367
373
|
except struct.error as e:
|
|
@@ -372,6 +378,13 @@ class MessageReader:
|
|
|
372
378
|
logger.error(f"Unknown stats type: {stats_type}, data: {data.hex()}")
|
|
373
379
|
await self.dispatcher.dispatch(Event(EventType.ERROR, {"reason": f"unknown_stats_type: {stats_type}"}))
|
|
374
380
|
|
|
381
|
+
elif packet_type_value == PacketType.AUTOADD_CONFIG.value:
|
|
382
|
+
logger.debug(f"received autoadd config response: {data.hex()}")
|
|
383
|
+
|
|
384
|
+
res = {}
|
|
385
|
+
res["config"] = dbuf.read(1)[0]
|
|
386
|
+
await self.dispatcher.dispatch(Event(EventType.AUTOADD_CONFIG, res, res))
|
|
387
|
+
|
|
375
388
|
elif packet_type_value == PacketType.CHANNEL_INFO.value:
|
|
376
389
|
logger.debug(f"received channel info response: {data.hex()}")
|
|
377
390
|
res = {}
|
|
@@ -591,10 +604,10 @@ class MessageReader:
|
|
|
591
604
|
)
|
|
592
605
|
|
|
593
606
|
elif packet_type_value == PacketType.BINARY_RESPONSE.value:
|
|
594
|
-
logger.debug(f"Received binary data: {data.hex()}")
|
|
595
607
|
dbuf.read(1)
|
|
596
608
|
tag = dbuf.read(4).hex()
|
|
597
609
|
response_data = dbuf.read()
|
|
610
|
+
logger.debug(f"Received binary data: {data.hex()}, tag {tag}, data {response_data.hex()}")
|
|
598
611
|
|
|
599
612
|
# Always dispatch generic BINARY_RESPONSE
|
|
600
613
|
binary_res = {"tag": tag, "data": response_data.hex()}
|
|
@@ -729,6 +742,69 @@ class MessageReader:
|
|
|
729
742
|
res = {"reason": "private_key_export_disabled"}
|
|
730
743
|
await self.dispatcher.dispatch(Event(EventType.DISABLED, res))
|
|
731
744
|
|
|
745
|
+
elif packet_type_value == PacketType.ADVERT_RESPONSE.value:
|
|
746
|
+
logger.debug(f"Received advert response: {data.hex()}")
|
|
747
|
+
# PUSH_CODE_ADVERT_RESPONSE (0x8F) format:
|
|
748
|
+
# Byte 0: 0x8F (push code)
|
|
749
|
+
# Bytes 1-4: tag (uint32)
|
|
750
|
+
# Bytes 5-36: pubkey (32 bytes)
|
|
751
|
+
# Byte 37: adv_type
|
|
752
|
+
# Bytes 38-69: node_name (32 bytes)
|
|
753
|
+
# Bytes 70-73: timestamp (uint32)
|
|
754
|
+
# Byte 74: flags
|
|
755
|
+
# [Optional fields based on flags]
|
|
756
|
+
|
|
757
|
+
if len(data) < 75:
|
|
758
|
+
logger.error(f"Advert response too short: {len(data)} bytes, need at least 75")
|
|
759
|
+
return
|
|
760
|
+
|
|
761
|
+
res = {}
|
|
762
|
+
offset = 1 # Skip push code
|
|
763
|
+
|
|
764
|
+
res["tag"] = struct.unpack('<I', data[offset:offset+4])[0]
|
|
765
|
+
offset += 4
|
|
766
|
+
|
|
767
|
+
res["pubkey"] = data[offset:offset+32].hex()
|
|
768
|
+
offset += 32
|
|
769
|
+
|
|
770
|
+
res["adv_type"] = data[offset]
|
|
771
|
+
offset += 1
|
|
772
|
+
|
|
773
|
+
res["node_name"] = data[offset:offset+32].decode('utf-8', errors='replace').rstrip('\x00')
|
|
774
|
+
offset += 32
|
|
775
|
+
|
|
776
|
+
res["timestamp"] = struct.unpack('<I', data[offset:offset+4])[0]
|
|
777
|
+
offset += 4
|
|
778
|
+
|
|
779
|
+
flags = data[offset]
|
|
780
|
+
res["flags"] = flags
|
|
781
|
+
offset += 1
|
|
782
|
+
|
|
783
|
+
# Parse optional fields based on flags
|
|
784
|
+
if flags & 0x01: # has latitude
|
|
785
|
+
if offset + 4 <= len(data):
|
|
786
|
+
lat_i32 = struct.unpack('<i', data[offset:offset+4])[0]
|
|
787
|
+
res["latitude"] = lat_i32 / 1e6
|
|
788
|
+
offset += 4
|
|
789
|
+
|
|
790
|
+
if flags & 0x02: # has longitude
|
|
791
|
+
if offset + 4 <= len(data):
|
|
792
|
+
lon_i32 = struct.unpack('<i', data[offset:offset+4])[0]
|
|
793
|
+
res["longitude"] = lon_i32 / 1e6
|
|
794
|
+
offset += 4
|
|
795
|
+
|
|
796
|
+
if flags & 0x04: # has description
|
|
797
|
+
if offset + 32 <= len(data):
|
|
798
|
+
res["node_desc"] = data[offset:offset+32].decode('utf-8', errors='replace').rstrip('\x00')
|
|
799
|
+
offset += 32
|
|
800
|
+
|
|
801
|
+
attributes = {
|
|
802
|
+
"tag": res["tag"],
|
|
803
|
+
"pubkey": res["pubkey"],
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
await self.dispatcher.dispatch(Event(EventType.ADVERT_RESPONSE, res, attributes))
|
|
807
|
+
|
|
732
808
|
elif packet_type_value == PacketType.CONTROL_DATA.value:
|
|
733
809
|
logger.debug("Received control data packet")
|
|
734
810
|
res={}
|
|
@@ -318,3 +318,26 @@ async def test_set_channel(command_handler, mock_connection):
|
|
|
318
318
|
async def test_set_channel_invalid_secret_length(command_handler):
|
|
319
319
|
with pytest.raises(ValueError, match="Channel secret must be exactly 16 bytes"):
|
|
320
320
|
await command_handler.set_channel(1, "Test", b"tooshort")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def test_send_chan_msg_with_str_timestamp(command_handler, mock_connection):
|
|
324
|
+
ts = 1620000000
|
|
325
|
+
await command_handler.send_chan_msg(3, "world", timestamp=ts)
|
|
326
|
+
data = mock_connection.send.call_args[0][0]
|
|
327
|
+
assert data.startswith(b"\x03\x00\x03")
|
|
328
|
+
assert b"world" in data
|
|
329
|
+
assert data[3:7] == ts.to_bytes(4, "little")
|
|
330
|
+
|
|
331
|
+
async def test_send_chan_msg_with_bytes_timestamp(command_handler, mock_connection):
|
|
332
|
+
ts = 1620000000
|
|
333
|
+
await command_handler.send_chan_msg(3, "world", timestamp=ts.to_bytes(4, "little"))
|
|
334
|
+
data = mock_connection.send.call_args[0][0]
|
|
335
|
+
assert data.startswith(b"\x03\x00\x03")
|
|
336
|
+
assert b"world" in data
|
|
337
|
+
assert data[3:7] == ts.to_bytes(4, "little")
|
|
338
|
+
|
|
339
|
+
async def test_send_chan_msg_with_invalid_timestamp(command_handler, mock_connection):
|
|
340
|
+
result = await command_handler.send_chan_msg(3, "world", timestamp=b"00")
|
|
341
|
+
|
|
342
|
+
assert result.type == EventType.ERROR
|
|
343
|
+
assert result.payload["reason"] == "invalid_timestamp_format"
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
import random
|
|
3
|
-
|
|
4
|
-
from .base import CommandHandlerBase
|
|
5
|
-
from ..events import EventType, Event
|
|
6
|
-
from ..packets import ControlType, PacketType
|
|
7
|
-
|
|
8
|
-
logger = logging.getLogger("meshcore")
|
|
9
|
-
|
|
10
|
-
class ControlDataCommandHandler(CommandHandlerBase):
|
|
11
|
-
"""Helper functions to handle binary requests through binary commands"""
|
|
12
|
-
|
|
13
|
-
async def send_control_data (self, control_type: int, payload: bytes) -> Event:
|
|
14
|
-
data = bytearray([PacketType.SEND_CONTROL_DATA.value])
|
|
15
|
-
data.extend(control_type.to_bytes(1, "little", signed = False))
|
|
16
|
-
data.extend(payload)
|
|
17
|
-
|
|
18
|
-
result = await self.send(data, [EventType.OK, EventType.ERROR])
|
|
19
|
-
return result
|
|
20
|
-
|
|
21
|
-
async def send_node_discover_req (
|
|
22
|
-
self,
|
|
23
|
-
filter: int,
|
|
24
|
-
prefix_only: bool=True,
|
|
25
|
-
tag: int=None,
|
|
26
|
-
since: int=None
|
|
27
|
-
) -> Event:
|
|
28
|
-
|
|
29
|
-
if tag is None:
|
|
30
|
-
tag = random.randint(1, 0xFFFFFFFF)
|
|
31
|
-
|
|
32
|
-
data = bytearray()
|
|
33
|
-
data.extend(filter.to_bytes(1, "little", signed=False))
|
|
34
|
-
data.extend(tag.to_bytes(4, "little"))
|
|
35
|
-
if not since is None:
|
|
36
|
-
data.extend(since.to_bytes(4, "little", signed=False))
|
|
37
|
-
|
|
38
|
-
logger.debug(f"sending node discover req {data.hex()}")
|
|
39
|
-
|
|
40
|
-
flags = 0
|
|
41
|
-
flags = flags | 1 if prefix_only else flags
|
|
42
|
-
|
|
43
|
-
res = await self.send_control_data(
|
|
44
|
-
ControlType.NODE_DISCOVER_REQ.value|flags, data)
|
|
45
|
-
|
|
46
|
-
if res is None:
|
|
47
|
-
return None
|
|
48
|
-
else:
|
|
49
|
-
res.payload["tag"] = tag
|
|
50
|
-
return res
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|