meshcore 2.3.12__tar.gz → 2.3.13__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 (83) hide show
  1. {meshcore-2.3.12 → meshcore-2.3.13}/PKG-INFO +1 -1
  2. {meshcore-2.3.12 → meshcore-2.3.13}/pyproject.toml +1 -1
  3. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/commands/messaging.py +74 -45
  4. meshcore-2.3.13/tests/unit/test_send_msg_with_retry.py +194 -0
  5. {meshcore-2.3.12 → meshcore-2.3.13}/.github/funding.yml +0 -0
  6. {meshcore-2.3.12 → meshcore-2.3.13}/.github/python-test.yml +0 -0
  7. {meshcore-2.3.12 → meshcore-2.3.13}/.gitignore +0 -0
  8. {meshcore-2.3.12 → meshcore-2.3.13}/LICENSE +0 -0
  9. {meshcore-2.3.12 → meshcore-2.3.13}/README.md +0 -0
  10. {meshcore-2.3.12 → meshcore-2.3.13}/examples/.pubsub_example.py.swp +0 -0
  11. {meshcore-2.3.12 → meshcore-2.3.13}/examples/ble_chat.py +0 -0
  12. {meshcore-2.3.12 → meshcore-2.3.13}/examples/ble_pin_pairing_example.py +0 -0
  13. {meshcore-2.3.12 → meshcore-2.3.13}/examples/ble_private_key_export.py +0 -0
  14. {meshcore-2.3.12 → meshcore-2.3.13}/examples/ble_sign_example.py +0 -0
  15. {meshcore-2.3.12 → meshcore-2.3.13}/examples/ble_stats.py +0 -0
  16. {meshcore-2.3.12 → meshcore-2.3.13}/examples/ble_t1000_chan_msg.py +0 -0
  17. {meshcore-2.3.12 → meshcore-2.3.13}/examples/ble_t1000_custom_vars.py +0 -0
  18. {meshcore-2.3.12 → meshcore-2.3.13}/examples/ble_t1000_infos.py +0 -0
  19. {meshcore-2.3.12 → meshcore-2.3.13}/examples/ble_t1000_msg.py +0 -0
  20. {meshcore-2.3.12 → meshcore-2.3.13}/examples/ble_t1000_msg_retries.py +0 -0
  21. {meshcore-2.3.12 → meshcore-2.3.13}/examples/ble_t1000_set_cv.py +0 -0
  22. {meshcore-2.3.12 → meshcore-2.3.13}/examples/chan_recv_with_path.py +0 -0
  23. {meshcore-2.3.12 → meshcore-2.3.13}/examples/connection_events_example.py +0 -0
  24. {meshcore-2.3.12 → meshcore-2.3.13}/examples/mepo_mc_gps.py +0 -0
  25. {meshcore-2.3.12 → meshcore-2.3.13}/examples/pubsub_example.py +0 -0
  26. {meshcore-2.3.12 → meshcore-2.3.13}/examples/rf_packet_monitor.py +0 -0
  27. {meshcore-2.3.12 → meshcore-2.3.13}/examples/serial_battery_monitor.py +0 -0
  28. {meshcore-2.3.12 → meshcore-2.3.13}/examples/serial_channel_manager.py +0 -0
  29. {meshcore-2.3.12 → meshcore-2.3.13}/examples/serial_chat.py +0 -0
  30. {meshcore-2.3.12 → meshcore-2.3.13}/examples/serial_contacts.py +0 -0
  31. {meshcore-2.3.12 → meshcore-2.3.13}/examples/serial_infos.py +0 -0
  32. {meshcore-2.3.12 → meshcore-2.3.13}/examples/serial_meshcore_ollama.py +0 -0
  33. {meshcore-2.3.12 → meshcore-2.3.13}/examples/serial_msg.py +0 -0
  34. {meshcore-2.3.12 → meshcore-2.3.13}/examples/serial_pingbot.py +0 -0
  35. {meshcore-2.3.12 → meshcore-2.3.13}/examples/serial_repeater_status.py +0 -0
  36. {meshcore-2.3.12 → meshcore-2.3.13}/examples/serial_repeater_telemetry.py +0 -0
  37. {meshcore-2.3.12 → meshcore-2.3.13}/examples/serial_rss_bot.py +0 -0
  38. {meshcore-2.3.12 → meshcore-2.3.13}/examples/serial_trace.py +0 -0
  39. {meshcore-2.3.12 → meshcore-2.3.13}/examples/tcp_chat.py +0 -0
  40. {meshcore-2.3.12 → meshcore-2.3.13}/examples/tcp_login_status.py +0 -0
  41. {meshcore-2.3.12 → meshcore-2.3.13}/examples/tcp_mchome_contacts.py +0 -0
  42. {meshcore-2.3.12 → meshcore-2.3.13}/examples/tcp_mchome_infos.py +0 -0
  43. {meshcore-2.3.12 → meshcore-2.3.13}/examples/tcp_mchome_msg.py +0 -0
  44. {meshcore-2.3.12 → meshcore-2.3.13}/examples/tcp_mchome_readmsgs.py +0 -0
  45. {meshcore-2.3.12 → meshcore-2.3.13}/pytest.ini +0 -0
  46. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/__init__.py +0 -0
  47. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/ble_cx.py +0 -0
  48. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/commands/__init__.py +0 -0
  49. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/commands/base.py +0 -0
  50. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/commands/binary.py +0 -0
  51. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/commands/contact.py +0 -0
  52. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/commands/control_data.py +0 -0
  53. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/commands/device.py +0 -0
  54. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/connection_manager.py +0 -0
  55. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/events.py +0 -0
  56. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/lpp_json_encoder.py +0 -0
  57. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/meshcore.py +0 -0
  58. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/meshcore_parser.py +0 -0
  59. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/packets.py +0 -0
  60. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/parsing.py +0 -0
  61. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/reader.py +0 -0
  62. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/serial_cx.py +0 -0
  63. {meshcore-2.3.12 → meshcore-2.3.13}/src/meshcore/tcp_cx.py +0 -0
  64. {meshcore-2.3.12 → meshcore-2.3.13}/tests/README.md +0 -0
  65. {meshcore-2.3.12 → meshcore-2.3.13}/tests/test_ble_connection.py +0 -0
  66. {meshcore-2.3.12 → meshcore-2.3.13}/tests/test_ble_pin_pairing.py +0 -0
  67. {meshcore-2.3.12 → meshcore-2.3.13}/tests/test_meshcore_ble_pin.py +0 -0
  68. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_asyncio_lifecycle.py +0 -0
  69. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_ble_reconnect_cleanup.py +0 -0
  70. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_commands.py +0 -0
  71. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_connection_manager.py +0 -0
  72. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_error_handling.py +0 -0
  73. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_events.py +0 -0
  74. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_lpp_parsing.py +0 -0
  75. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_parse_status.py +0 -0
  76. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_path_discovery_response.py +0 -0
  77. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_private_key_export.py +0 -0
  78. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_protocol_surface_gaps.py +0 -0
  79. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_reader.py +0 -0
  80. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_rxlog_keyerror_regression.py +0 -0
  81. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_serial_connection.py +0 -0
  82. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_standalone_fixes.py +0 -0
  83. {meshcore-2.3.12 → meshcore-2.3.13}/tests/unit/test_transport_symmetry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: meshcore
3
- Version: 2.3.12
3
+ Version: 2.3.13
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meshcore"
7
- version = "2.3.12"
7
+ version = "2.3.13"
8
8
  authors = [
9
9
  { name="Florent de Lamotte", email="florent@frizoncorrea.fr" },
10
10
  { name="Alex Wolden", email="awolden@gmail.com" },
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import logging
2
3
  import random
3
4
  from typing import Optional, Union
@@ -112,7 +113,7 @@ class MessagingCommands(CommandHandlerBase):
112
113
  async def send_msg_with_retry (
113
114
  self, dst: DestinationType, msg: str, timestamp: Optional[int] = None,
114
115
  max_attempts=3, max_flood_attempts=2, flood_after=2, timeout=0, min_timeout=0
115
- ) -> Event:
116
+ ) -> Optional[Event]:
116
117
 
117
118
  # try to get a 32 bytes key (for flood reset), fallback to 6
118
119
  try:
@@ -127,50 +128,78 @@ class MessagingCommands(CommandHandlerBase):
127
128
  attempts = 0
128
129
  flood_attempts = 0
129
130
  flood = False # by default consider we sent direct (will be overriden after send)
130
- res = None
131
- while attempts < max_attempts and res is None \
132
- and (not flood or flood_attempts < max_flood_attempts):
133
- if attempts == flood_after and not flood : # change path to flood
134
- if len(dst_bytes) < 32: # can only reset with full key
135
- logger.info("Don't have full key, retreiving contacts so we can reset path")
136
- await self.get_contacts()
137
- contact = self._get_contact_by_prefix(dst_bytes.hex())
138
- if not contact is None:
139
- dst_bytes = _validate_destination(contact, prefix_length=32)
140
- logger.info("Resetting path")
141
- rp_res = await self.reset_path(dst_bytes)
142
- if rp_res.type == EventType.ERROR:
143
- logger.error(f"Couldn't reset path {rp_res} continuing ...")
144
- else:
145
- flood = True
146
- if not contact is None:
147
- contact["out_path"] = ""
148
- contact["out_path_len"] = -1
149
-
150
- if attempts > 0:
151
- logger.info(f"Retry sending msg: {attempts + 1}")
152
-
153
- result = await self.send_msg(dst, msg, timestamp, attempt=attempts)
154
- if result.is_error():
155
- logger.error(f"Failed to send message: {result.payload}")
156
- attempts += 1
157
- if flood:
158
- flood_attempts += 1
159
- continue
160
-
161
- flood = result.payload["type"] == 1 # we can sync flood flag from result ...
162
- exp_ack = result.payload["expected_ack"].hex()
163
- atimeout = result.payload["suggested_timeout"] / 1000 * 1.2 if timeout==0 else timeout
164
- atimeout = atimeout if atimeout > min_timeout else min_timeout
165
- res = await self.dispatcher.wait_for_event(EventType.ACK,
166
- attribute_filters={"code": exp_ack},
167
- timeout=atimeout)
168
-
169
- attempts = attempts + 1
170
- if flood :
171
- flood_attempts = flood_attempts + 1
172
-
173
- return None if res is None else result
131
+ if timestamp is None:
132
+ # same timestamp on every attempt, the attempt number is what
133
+ # makes each packet unique (and lets receivers spot retries)
134
+ import time
135
+ timestamp = int(time.time())
136
+
137
+ # expected ack code -> MSG_SENT of the attempt that expects it
138
+ # the device keeps codes of earlier attempts, so a late ack for
139
+ # any of them still means the message got through
140
+ sent = {}
141
+ early_acks = set()
142
+ acked = asyncio.get_running_loop().create_future()
143
+
144
+ def on_ack(event):
145
+ code = event.attributes.get("code")
146
+ if code in sent:
147
+ if not acked.done():
148
+ acked.set_result(code)
149
+ else:
150
+ # ack dispatched before send_msg returned its code
151
+ early_acks.add(code)
152
+
153
+ # subscribe before sending, the ack can be queued right behind MSG_SENT
154
+ ack_sub = self.dispatcher.subscribe(EventType.ACK, on_ack)
155
+ try:
156
+ while attempts < max_attempts and not acked.done() \
157
+ and (not flood or flood_attempts < max_flood_attempts):
158
+ if attempts == flood_after and not flood : # change path to flood
159
+ if len(dst_bytes) < 32: # can only reset with full key
160
+ logger.info("Don't have full key, retreiving contacts so we can reset path")
161
+ await self.get_contacts()
162
+ contact = self._get_contact_by_prefix(dst_bytes.hex())
163
+ if not contact is None:
164
+ dst_bytes = _validate_destination(contact, prefix_length=32)
165
+ logger.info("Resetting path")
166
+ rp_res = await self.reset_path(dst_bytes)
167
+ if rp_res.type == EventType.ERROR:
168
+ logger.error(f"Couldn't reset path {rp_res} continuing ...")
169
+ else:
170
+ flood = True
171
+ if not contact is None:
172
+ contact["out_path"] = ""
173
+ contact["out_path_len"] = -1
174
+
175
+ if attempts > 0:
176
+ logger.info(f"Retry sending msg: {attempts + 1}")
177
+
178
+ result = await self.send_msg(dst, msg, timestamp, attempt=attempts)
179
+ if result.is_error():
180
+ logger.error(f"Failed to send message: {result.payload}")
181
+ attempts += 1
182
+ if flood:
183
+ flood_attempts += 1
184
+ continue
185
+
186
+ flood = result.payload["type"] == 1 # we can sync flood flag from result ...
187
+ exp_ack = result.payload["expected_ack"].hex()
188
+ sent[exp_ack] = result
189
+ if exp_ack in early_acks and not acked.done():
190
+ acked.set_result(exp_ack)
191
+
192
+ atimeout = result.payload["suggested_timeout"] / 1000 * 1.2 if timeout==0 else timeout
193
+ atimeout = atimeout if atimeout > min_timeout else min_timeout
194
+ await asyncio.wait([acked], timeout=atimeout)
195
+
196
+ attempts = attempts + 1
197
+ if flood :
198
+ flood_attempts = flood_attempts + 1
199
+ finally:
200
+ ack_sub.unsubscribe()
201
+
202
+ return sent[acked.result()] if acked.done() else None
174
203
 
175
204
  async def send_chan_msg(self, chan: int, msg: str, timestamp: Optional[int|bytes] = None) -> Event:
176
205
  logger.debug(f"Sending channel message to channel {chan}: {msg}")
@@ -0,0 +1,194 @@
1
+ """send_msg_with_retry against a fake companion radio and a real dispatcher.
2
+
3
+ The fake radio mirrors the firmware: each attempt gets its own expected ack
4
+ (sha256 over timestamp, attempt & 3 and text) and a suggested timeout, and
5
+ the ack for an attempt is pushed after a per-attempt delay.
6
+ """
7
+ import asyncio
8
+ import time
9
+ from hashlib import sha256
10
+
11
+ import pytest
12
+ import pytest_asyncio
13
+
14
+ from meshcore.commands import CommandHandler
15
+ from meshcore.events import Event, EventDispatcher, EventType
16
+
17
+ pytestmark = pytest.mark.asyncio
18
+
19
+ PUBKEY = bytes(range(32))
20
+
21
+
22
+ class FakeRadio:
23
+ def __init__(self, dispatcher, *, direct_timeout_ms=100, flood_timeout_ms=100,
24
+ ack_delays=None, ack_with_msg_sent=False):
25
+ self.dispatcher = dispatcher
26
+ self.direct_timeout_ms = direct_timeout_ms
27
+ self.flood_timeout_ms = flood_timeout_ms
28
+ # attempt -> seconds until its ack, missing = never acked
29
+ self.ack_delays = ack_delays or {}
30
+ self.ack_with_msg_sent = ack_with_msg_sent
31
+ self.flood = False
32
+ self.frames = []
33
+ self.tasks = []
34
+
35
+ async def send(self, data):
36
+ data = bytes(data)
37
+ if data[0] == 0x0D: # reset_path
38
+ self.flood = True
39
+ await self.dispatcher.dispatch(Event(EventType.OK, {}))
40
+ return
41
+ assert data[:2] == b"\x02\x00"
42
+ self.frames.append(data)
43
+ attempt, ts, text = data[2], data[3:7], data[13:]
44
+ ack = sha256(ts + bytes([attempt & 3]) + text + PUBKEY).digest()[:4]
45
+ est = self.flood_timeout_ms if self.flood else self.direct_timeout_ms
46
+ await self.dispatcher.dispatch(Event(
47
+ EventType.MSG_SENT,
48
+ {"type": int(self.flood), "expected_ack": ack, "suggested_timeout": est},
49
+ ))
50
+ ack_event = Event(EventType.ACK, {"code": ack.hex()}, {"code": ack.hex()})
51
+ if self.ack_with_msg_sent:
52
+ # both frames already queued when the caller resumes
53
+ await self.dispatcher.dispatch(ack_event)
54
+ elif attempt in self.ack_delays:
55
+ async def push():
56
+ await asyncio.sleep(self.ack_delays[attempt])
57
+ await self.dispatcher.dispatch(ack_event)
58
+ self.tasks.append(asyncio.create_task(push()))
59
+
60
+ def attempts(self):
61
+ return [f[2] for f in self.frames]
62
+
63
+ def timestamps(self):
64
+ return {f[3:7] for f in self.frames}
65
+
66
+
67
+ @pytest_asyncio.fixture
68
+ async def dispatcher():
69
+ d = EventDispatcher()
70
+ await d.start()
71
+ yield d
72
+ await d.stop()
73
+
74
+
75
+ def make_handler(dispatcher, radio, out_path_len=0):
76
+ handler = CommandHandler()
77
+ handler.set_dispatcher(dispatcher)
78
+ handler._sender_func = radio.send
79
+ contact = {"public_key": PUBKEY.hex(), "out_path_len": out_path_len, "out_path": ""}
80
+ handler._get_contact_by_prefix = lambda prefix: contact
81
+ radio.flood = out_path_len == -1
82
+ return handler, contact
83
+
84
+
85
+ def ack_subscriptions(dispatcher):
86
+ return [s for s in dispatcher.subscriptions if s.event_type == EventType.ACK]
87
+
88
+
89
+ async def test_ack_queued_behind_msg_sent_is_not_lost(dispatcher):
90
+ radio = FakeRadio(dispatcher, ack_with_msg_sent=True)
91
+ handler, contact = make_handler(dispatcher, radio)
92
+
93
+ result = await handler.send_msg_with_retry(contact, "hello")
94
+
95
+ assert result is not None and result.type == EventType.MSG_SENT
96
+ assert radio.attempts() == [0]
97
+
98
+
99
+ async def test_late_ack_for_earlier_attempt_counts(dispatcher):
100
+ # window is 0.12s, attempt 0 is acked at 0.2s while attempt 1 is pending
101
+ radio = FakeRadio(dispatcher, ack_delays={0: 0.2})
102
+ handler, contact = make_handler(dispatcher, radio)
103
+
104
+ result = await handler.send_msg_with_retry(contact, "hello")
105
+
106
+ assert result is not None
107
+ assert radio.attempts() == [0, 1]
108
+ first_ack = sha256(radio.frames[0][3:7] + b"\x00" + b"hello" + PUBKEY).digest()[:4]
109
+ assert result.payload["expected_ack"] == first_ack
110
+
111
+
112
+ async def test_flood_attempt_uses_its_own_timeout(dispatcher):
113
+ # direct path is dead; the flood after the path reset is acked at 0.3s,
114
+ # past the direct window (0.12s) but inside the flood one (0.6s)
115
+ radio = FakeRadio(dispatcher, direct_timeout_ms=100, flood_timeout_ms=500,
116
+ ack_delays={2: 0.3})
117
+ handler, contact = make_handler(dispatcher, radio)
118
+
119
+ result = await handler.send_msg_with_retry(contact, "hello")
120
+
121
+ assert result is not None
122
+ assert radio.attempts() == [0, 1, 2]
123
+ assert radio.flood and contact["out_path_len"] == -1
124
+
125
+
126
+ async def test_explicit_timeout_applies_to_every_attempt(dispatcher):
127
+ radio = FakeRadio(dispatcher, direct_timeout_ms=100, flood_timeout_ms=5000)
128
+ handler, contact = make_handler(dispatcher, radio)
129
+
130
+ start = time.monotonic()
131
+ result = await handler.send_msg_with_retry(contact, "hello", timeout=0.05)
132
+
133
+ assert result is None
134
+ assert radio.attempts() == [0, 1, 2]
135
+ assert time.monotonic() - start < 1
136
+
137
+
138
+ async def test_attempts_share_one_timestamp(dispatcher, monkeypatch):
139
+ clock = iter(range(1_700_000_000, 1_700_000_100))
140
+ monkeypatch.setattr(time, "time", lambda: next(clock))
141
+ radio = FakeRadio(dispatcher)
142
+ handler, contact = make_handler(dispatcher, radio)
143
+
144
+ result = await handler.send_msg_with_retry(contact, "hello")
145
+
146
+ assert result is None
147
+ assert radio.attempts() == [0, 1, 2]
148
+ assert len(radio.timestamps()) == 1
149
+
150
+
151
+ async def test_caller_timestamp_is_kept(dispatcher):
152
+ radio = FakeRadio(dispatcher, ack_delays={0: 0.01})
153
+ handler, contact = make_handler(dispatcher, radio)
154
+
155
+ await handler.send_msg_with_retry(contact, "hello", timestamp=1234)
156
+
157
+ assert radio.timestamps() == {(1234).to_bytes(4, "little")}
158
+
159
+
160
+ async def test_flood_contact_stops_at_max_flood_attempts(dispatcher):
161
+ radio = FakeRadio(dispatcher)
162
+ handler, contact = make_handler(dispatcher, radio, out_path_len=-1)
163
+
164
+ result = await handler.send_msg_with_retry(contact, "hello")
165
+
166
+ assert result is None
167
+ assert radio.attempts() == [0, 1]
168
+
169
+
170
+ async def test_unrelated_ack_is_ignored(dispatcher):
171
+ radio = FakeRadio(dispatcher)
172
+ handler, contact = make_handler(dispatcher, radio)
173
+
174
+ async def stray_ack():
175
+ await asyncio.sleep(0.05)
176
+ await dispatcher.dispatch(Event(EventType.ACK, {"code": "deadbeef"}, {"code": "deadbeef"}))
177
+
178
+ stray = asyncio.create_task(stray_ack())
179
+ result = await handler.send_msg_with_retry(contact, "hello", max_attempts=1)
180
+ await stray
181
+
182
+ assert result is None
183
+
184
+
185
+ async def test_ack_subscription_is_removed(dispatcher):
186
+ radio = FakeRadio(dispatcher, ack_delays={0: 0.01})
187
+ handler, contact = make_handler(dispatcher, radio)
188
+
189
+ assert await handler.send_msg_with_retry(contact, "hello") is not None
190
+ assert ack_subscriptions(dispatcher) == []
191
+
192
+ radio.ack_delays = {}
193
+ assert await handler.send_msg_with_retry(contact, "hello") is None
194
+ assert ack_subscriptions(dispatcher) == []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes