meshcore 2.3.11__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.11 → meshcore-2.3.13}/PKG-INFO +1 -1
  2. {meshcore-2.3.11 → meshcore-2.3.13}/pyproject.toml +1 -1
  3. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/commands/messaging.py +77 -49
  4. meshcore-2.3.13/tests/unit/test_send_msg_with_retry.py +194 -0
  5. {meshcore-2.3.11 → meshcore-2.3.13}/.github/funding.yml +0 -0
  6. {meshcore-2.3.11 → meshcore-2.3.13}/.github/python-test.yml +0 -0
  7. {meshcore-2.3.11 → meshcore-2.3.13}/.gitignore +0 -0
  8. {meshcore-2.3.11 → meshcore-2.3.13}/LICENSE +0 -0
  9. {meshcore-2.3.11 → meshcore-2.3.13}/README.md +0 -0
  10. {meshcore-2.3.11 → meshcore-2.3.13}/examples/.pubsub_example.py.swp +0 -0
  11. {meshcore-2.3.11 → meshcore-2.3.13}/examples/ble_chat.py +0 -0
  12. {meshcore-2.3.11 → meshcore-2.3.13}/examples/ble_pin_pairing_example.py +0 -0
  13. {meshcore-2.3.11 → meshcore-2.3.13}/examples/ble_private_key_export.py +0 -0
  14. {meshcore-2.3.11 → meshcore-2.3.13}/examples/ble_sign_example.py +0 -0
  15. {meshcore-2.3.11 → meshcore-2.3.13}/examples/ble_stats.py +0 -0
  16. {meshcore-2.3.11 → meshcore-2.3.13}/examples/ble_t1000_chan_msg.py +0 -0
  17. {meshcore-2.3.11 → meshcore-2.3.13}/examples/ble_t1000_custom_vars.py +0 -0
  18. {meshcore-2.3.11 → meshcore-2.3.13}/examples/ble_t1000_infos.py +0 -0
  19. {meshcore-2.3.11 → meshcore-2.3.13}/examples/ble_t1000_msg.py +0 -0
  20. {meshcore-2.3.11 → meshcore-2.3.13}/examples/ble_t1000_msg_retries.py +0 -0
  21. {meshcore-2.3.11 → meshcore-2.3.13}/examples/ble_t1000_set_cv.py +0 -0
  22. {meshcore-2.3.11 → meshcore-2.3.13}/examples/chan_recv_with_path.py +0 -0
  23. {meshcore-2.3.11 → meshcore-2.3.13}/examples/connection_events_example.py +0 -0
  24. {meshcore-2.3.11 → meshcore-2.3.13}/examples/mepo_mc_gps.py +0 -0
  25. {meshcore-2.3.11 → meshcore-2.3.13}/examples/pubsub_example.py +0 -0
  26. {meshcore-2.3.11 → meshcore-2.3.13}/examples/rf_packet_monitor.py +0 -0
  27. {meshcore-2.3.11 → meshcore-2.3.13}/examples/serial_battery_monitor.py +0 -0
  28. {meshcore-2.3.11 → meshcore-2.3.13}/examples/serial_channel_manager.py +0 -0
  29. {meshcore-2.3.11 → meshcore-2.3.13}/examples/serial_chat.py +0 -0
  30. {meshcore-2.3.11 → meshcore-2.3.13}/examples/serial_contacts.py +0 -0
  31. {meshcore-2.3.11 → meshcore-2.3.13}/examples/serial_infos.py +0 -0
  32. {meshcore-2.3.11 → meshcore-2.3.13}/examples/serial_meshcore_ollama.py +0 -0
  33. {meshcore-2.3.11 → meshcore-2.3.13}/examples/serial_msg.py +0 -0
  34. {meshcore-2.3.11 → meshcore-2.3.13}/examples/serial_pingbot.py +0 -0
  35. {meshcore-2.3.11 → meshcore-2.3.13}/examples/serial_repeater_status.py +0 -0
  36. {meshcore-2.3.11 → meshcore-2.3.13}/examples/serial_repeater_telemetry.py +0 -0
  37. {meshcore-2.3.11 → meshcore-2.3.13}/examples/serial_rss_bot.py +0 -0
  38. {meshcore-2.3.11 → meshcore-2.3.13}/examples/serial_trace.py +0 -0
  39. {meshcore-2.3.11 → meshcore-2.3.13}/examples/tcp_chat.py +0 -0
  40. {meshcore-2.3.11 → meshcore-2.3.13}/examples/tcp_login_status.py +0 -0
  41. {meshcore-2.3.11 → meshcore-2.3.13}/examples/tcp_mchome_contacts.py +0 -0
  42. {meshcore-2.3.11 → meshcore-2.3.13}/examples/tcp_mchome_infos.py +0 -0
  43. {meshcore-2.3.11 → meshcore-2.3.13}/examples/tcp_mchome_msg.py +0 -0
  44. {meshcore-2.3.11 → meshcore-2.3.13}/examples/tcp_mchome_readmsgs.py +0 -0
  45. {meshcore-2.3.11 → meshcore-2.3.13}/pytest.ini +0 -0
  46. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/__init__.py +0 -0
  47. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/ble_cx.py +0 -0
  48. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/commands/__init__.py +0 -0
  49. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/commands/base.py +0 -0
  50. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/commands/binary.py +0 -0
  51. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/commands/contact.py +0 -0
  52. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/commands/control_data.py +0 -0
  53. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/commands/device.py +0 -0
  54. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/connection_manager.py +0 -0
  55. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/events.py +0 -0
  56. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/lpp_json_encoder.py +0 -0
  57. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/meshcore.py +0 -0
  58. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/meshcore_parser.py +0 -0
  59. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/packets.py +0 -0
  60. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/parsing.py +0 -0
  61. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/reader.py +0 -0
  62. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/serial_cx.py +0 -0
  63. {meshcore-2.3.11 → meshcore-2.3.13}/src/meshcore/tcp_cx.py +0 -0
  64. {meshcore-2.3.11 → meshcore-2.3.13}/tests/README.md +0 -0
  65. {meshcore-2.3.11 → meshcore-2.3.13}/tests/test_ble_connection.py +0 -0
  66. {meshcore-2.3.11 → meshcore-2.3.13}/tests/test_ble_pin_pairing.py +0 -0
  67. {meshcore-2.3.11 → meshcore-2.3.13}/tests/test_meshcore_ble_pin.py +0 -0
  68. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_asyncio_lifecycle.py +0 -0
  69. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_ble_reconnect_cleanup.py +0 -0
  70. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_commands.py +0 -0
  71. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_connection_manager.py +0 -0
  72. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_error_handling.py +0 -0
  73. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_events.py +0 -0
  74. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_lpp_parsing.py +0 -0
  75. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_parse_status.py +0 -0
  76. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_path_discovery_response.py +0 -0
  77. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_private_key_export.py +0 -0
  78. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_protocol_surface_gaps.py +0 -0
  79. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_reader.py +0 -0
  80. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_rxlog_keyerror_regression.py +0 -0
  81. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_serial_connection.py +0 -0
  82. {meshcore-2.3.11 → meshcore-2.3.13}/tests/unit/test_standalone_fixes.py +0 -0
  83. {meshcore-2.3.11 → 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.11
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.11"
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,66 +113,93 @@ 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
 
118
+ # try to get a 32 bytes key (for flood reset), fallback to 6
117
119
  try:
118
120
  dst_bytes = _validate_destination(dst, prefix_length=32)
119
- # with 32 bytes we can reset to flood
120
121
  except ValueError:
121
- # but if we can't, we'll assume we're flood
122
122
  dst_bytes = _validate_destination(dst, prefix_length=6)
123
123
  contact = self._get_contact_by_prefix(dst_bytes.hex())
124
+ if not contact is None and len(dst_bytes) < 32 :
125
+ # if we have a contact then we can get full key
126
+ dst_bytes = _validate_destination(contact, prefix_length=32)
124
127
 
125
128
  attempts = 0
126
129
  flood_attempts = 0
127
- if not contact is None :
128
- flood = contact["out_path_len"] == -1
129
- if len(dst_bytes) < 32:
130
- # if we have a contact, then we can get a 32 bytes key !
131
- dst_bytes = _validate_destination(contact, prefix_length=32)
132
- else:
133
- # we can't know if we're flood without fetching all contacts
134
- # if we have a full key (meaning we can reset path) consider direct
135
- # else consider flood
136
- flood = len(dst_bytes) < 32
137
- logger.info(f"send_msg_with_retry: can't determine if flood, assume {flood}")
138
- res = None
139
- while attempts < max_attempts and res is None \
140
- and (not flood or flood_attempts < max_flood_attempts):
141
- if attempts == flood_after and not flood: # change path to flood
142
- logger.info("Resetting path")
143
- rp_res = await self.reset_path(dst_bytes)
144
- if rp_res.type == EventType.ERROR:
145
- logger.error(f"Couldn't reset path {rp_res} continuing ...")
146
- else:
147
- flood = True
148
- if not contact is None:
149
- contact["out_path"] = ""
150
- contact["out_path_len"] = -1
151
-
152
- if attempts > 0:
153
- logger.info(f"Retry sending msg: {attempts + 1}")
154
-
155
- result = await self.send_msg(dst, msg, timestamp, attempt=attempts)
156
- if result.is_error():
157
- logger.error(f"Failed to send message: {result.payload}")
158
- attempts += 1
159
- if flood:
160
- flood_attempts += 1
161
- continue
162
-
163
- exp_ack = result.payload["expected_ack"].hex()
164
- timeout = result.payload["suggested_timeout"] / 1000 * 1.2 if timeout==0 else timeout
165
- timeout = timeout if timeout > min_timeout else min_timeout
166
- res = await self.dispatcher.wait_for_event(EventType.ACK,
167
- attribute_filters={"code": exp_ack},
168
- timeout=timeout)
130
+ flood = False # by default consider we sent direct (will be overriden after send)
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())
169
136
 
170
- attempts = attempts + 1
171
- if flood :
172
- flood_attempts = flood_attempts + 1
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)
173
152
 
174
- return None if res is None else result
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
175
203
 
176
204
  async def send_chan_msg(self, chan: int, msg: str, timestamp: Optional[int|bytes] = None) -> Event:
177
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