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.
Files changed (67) hide show
  1. {meshcore-2.2.5 → meshcore-2.2.6}/PKG-INFO +13 -2
  2. {meshcore-2.2.5 → meshcore-2.2.6}/README.md +12 -1
  3. {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_stats.py +5 -0
  4. meshcore-2.2.6/examples/serial_rss_bot.py +284 -0
  5. {meshcore-2.2.5 → meshcore-2.2.6}/pyproject.toml +1 -1
  6. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/commands/base.py +22 -1
  7. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/commands/binary.py +108 -0
  8. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/commands/contact.py +9 -0
  9. meshcore-2.2.6/src/meshcore/commands/control_data.py +96 -0
  10. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/commands/messaging.py +28 -11
  11. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/events.py +2 -0
  12. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/packets.py +7 -0
  13. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/reader.py +80 -4
  14. {meshcore-2.2.5 → meshcore-2.2.6}/tests/unit/test_commands.py +23 -0
  15. meshcore-2.2.5/src/meshcore/commands/control_data.py +0 -50
  16. {meshcore-2.2.5 → meshcore-2.2.6}/.github/python-test.yml +0 -0
  17. {meshcore-2.2.5 → meshcore-2.2.6}/.gitignore +0 -0
  18. {meshcore-2.2.5 → meshcore-2.2.6}/LICENSE +0 -0
  19. {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_chat.py +0 -0
  20. {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_pin_pairing_example.py +0 -0
  21. {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_private_key_export.py +0 -0
  22. {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_sign_example.py +0 -0
  23. {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_t1000_chan_msg.py +0 -0
  24. {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_t1000_custom_vars.py +0 -0
  25. {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_t1000_infos.py +0 -0
  26. {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_t1000_msg.py +0 -0
  27. {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_t1000_msg_retries.py +0 -0
  28. {meshcore-2.2.5 → meshcore-2.2.6}/examples/ble_t1000_set_cv.py +0 -0
  29. {meshcore-2.2.5 → meshcore-2.2.6}/examples/connection_events_example.py +0 -0
  30. {meshcore-2.2.5 → meshcore-2.2.6}/examples/mepo_mc_gps.py +0 -0
  31. {meshcore-2.2.5 → meshcore-2.2.6}/examples/pubsub_example.py +0 -0
  32. {meshcore-2.2.5 → meshcore-2.2.6}/examples/rf_packet_monitor.py +0 -0
  33. {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_battery_monitor.py +0 -0
  34. {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_channel_manager.py +0 -0
  35. {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_chat.py +0 -0
  36. {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_contacts.py +0 -0
  37. {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_infos.py +0 -0
  38. {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_meshcore_ollama.py +0 -0
  39. {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_msg.py +0 -0
  40. {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_pingbot.py +0 -0
  41. {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_repeater_status.py +0 -0
  42. {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_repeater_telemetry.py +0 -0
  43. {meshcore-2.2.5 → meshcore-2.2.6}/examples/serial_trace.py +0 -0
  44. {meshcore-2.2.5 → meshcore-2.2.6}/examples/tcp_chat.py +0 -0
  45. {meshcore-2.2.5 → meshcore-2.2.6}/examples/tcp_login_status.py +0 -0
  46. {meshcore-2.2.5 → meshcore-2.2.6}/examples/tcp_mchome_contacts.py +0 -0
  47. {meshcore-2.2.5 → meshcore-2.2.6}/examples/tcp_mchome_infos.py +0 -0
  48. {meshcore-2.2.5 → meshcore-2.2.6}/examples/tcp_mchome_msg.py +0 -0
  49. {meshcore-2.2.5 → meshcore-2.2.6}/examples/tcp_mchome_readmsgs.py +0 -0
  50. {meshcore-2.2.5 → meshcore-2.2.6}/pytest.ini +0 -0
  51. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/__init__.py +0 -0
  52. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/ble_cx.py +0 -0
  53. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/commands/__init__.py +0 -0
  54. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/commands/device.py +0 -0
  55. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/connection_manager.py +0 -0
  56. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/lpp_json_encoder.py +0 -0
  57. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/meshcore.py +0 -0
  58. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/parsing.py +0 -0
  59. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/serial_cx.py +0 -0
  60. {meshcore-2.2.5 → meshcore-2.2.6}/src/meshcore/tcp_cx.py +0 -0
  61. {meshcore-2.2.5 → meshcore-2.2.6}/tests/README.md +0 -0
  62. {meshcore-2.2.5 → meshcore-2.2.6}/tests/test_ble_connection.py +0 -0
  63. {meshcore-2.2.5 → meshcore-2.2.6}/tests/test_ble_pin_pairing.py +0 -0
  64. {meshcore-2.2.5 → meshcore-2.2.6}/tests/test_meshcore_ble_pin.py +0 -0
  65. {meshcore-2.2.5 → meshcore-2.2.6}/tests/unit/test_events.py +0 -0
  66. {meshcore-2.2.5 → meshcore-2.2.6}/tests/unit/test_private_key_export.py +0 -0
  67. {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.5
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` | `MSG_SENT` | Send channel message |
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` | `MSG_SENT` | Send channel message |
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())
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meshcore"
7
- version = "2.2.5"
7
+ version = "2.2.6"
8
8
  authors = [
9
9
  { name="Florent de Lamotte", email="florent@frizoncorrea.fr" },
10
10
  { name="Alex Wolden", email="awolden@gmail.com" },
@@ -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
- timestamp = int(time.time()).to_bytes(4, "little")
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") + timestamp + msg.encode("utf-8")
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
- if scope.startswith("#"): # an hash
229
- logger.debug(f"Setting scope from hash {scope}")
230
- scope_key = sha256(scope.encode("utf-8")).digest()[0:16]
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
- else: # assume the key has been sent directly
234
- scope_key = scope.encode("utf-8")
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 total
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