mip-client-python 1.0.0__py3-none-any.whl
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.
mip_client/__init__.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MIP Client - Python client for the MIP (MSIP) protocol.
|
|
3
|
+
Handles connections, events, errors, and auto-reconnection.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import struct
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import IntEnum, IntFlag
|
|
11
|
+
from typing import Callable, Optional, Any
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.0"
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MIPClient",
|
|
17
|
+
"FrameType",
|
|
18
|
+
"Flags",
|
|
19
|
+
"FrameHeader",
|
|
20
|
+
"MIPMessage",
|
|
21
|
+
"MIPError",
|
|
22
|
+
"MIPClientOptions",
|
|
23
|
+
"create_client",
|
|
24
|
+
"get_frame_type_name",
|
|
25
|
+
"MAGIC",
|
|
26
|
+
"VERSION",
|
|
27
|
+
"HEADER_SIZE",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# ============================================================================
|
|
33
|
+
# Constants
|
|
34
|
+
# ============================================================================
|
|
35
|
+
|
|
36
|
+
MAGIC = 0x4D534950 # "MSIP"
|
|
37
|
+
VERSION = 1
|
|
38
|
+
HEADER_SIZE = 24
|
|
39
|
+
MSG_KIND_EVENT = 0x0001
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FrameType(IntEnum):
|
|
43
|
+
"""Frame types for the MIP protocol"""
|
|
44
|
+
HELLO = 0x0001
|
|
45
|
+
SUBSCRIBE = 0x0002
|
|
46
|
+
UNSUBSCRIBE = 0x0003
|
|
47
|
+
PUBLISH = 0x0004
|
|
48
|
+
EVENT = 0x0005
|
|
49
|
+
ACK = 0x0006
|
|
50
|
+
ERROR = 0x0007
|
|
51
|
+
PING = 0x0008
|
|
52
|
+
PONG = 0x0009
|
|
53
|
+
CLOSE = 0x000A
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Flags(IntFlag):
|
|
57
|
+
"""Flags for frame options"""
|
|
58
|
+
NONE = 0b0000_0000
|
|
59
|
+
ACK_REQUIRED = 0b0000_0001
|
|
60
|
+
COMPRESSED = 0b0000_0010
|
|
61
|
+
URGENT = 0b0000_0100
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ============================================================================
|
|
65
|
+
# Types & Interfaces
|
|
66
|
+
# ============================================================================
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class MIPClientOptions:
|
|
70
|
+
"""Configuration options for the MIP client"""
|
|
71
|
+
host: str = "127.0.0.1"
|
|
72
|
+
port: int = 9000
|
|
73
|
+
auto_reconnect: bool = True
|
|
74
|
+
reconnect_delay: float = 3.0
|
|
75
|
+
max_reconnect_attempts: int = 10
|
|
76
|
+
ping_interval: float = 0.0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class FrameHeader:
|
|
81
|
+
"""Parsed frame header"""
|
|
82
|
+
magic: int
|
|
83
|
+
version: int
|
|
84
|
+
flags: int
|
|
85
|
+
frame_type: FrameType
|
|
86
|
+
msg_kind: int
|
|
87
|
+
payload_length: int
|
|
88
|
+
msg_id: int
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class MIPMessage:
|
|
93
|
+
"""Received message event"""
|
|
94
|
+
header: FrameHeader
|
|
95
|
+
topic: str
|
|
96
|
+
message: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class MIPError(Exception):
|
|
101
|
+
"""Error details"""
|
|
102
|
+
message: str
|
|
103
|
+
code: Optional[int] = None
|
|
104
|
+
raw: Optional[bytes] = None
|
|
105
|
+
|
|
106
|
+
def __str__(self) -> str:
|
|
107
|
+
return self.message
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ============================================================================
|
|
111
|
+
# Event Callback Types
|
|
112
|
+
# ============================================================================
|
|
113
|
+
|
|
114
|
+
OnConnect = Callable[[], None]
|
|
115
|
+
OnDisconnect = Callable[[], None]
|
|
116
|
+
OnReconnecting = Callable[[int], None]
|
|
117
|
+
OnMessage = Callable[[MIPMessage], None]
|
|
118
|
+
OnEvent = Callable[[MIPMessage], None]
|
|
119
|
+
OnAck = Callable[[int], None]
|
|
120
|
+
OnPong = Callable[[], None]
|
|
121
|
+
OnError = Callable[[MIPError], None]
|
|
122
|
+
OnFrame = Callable[[FrameHeader, bytes], None]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ============================================================================
|
|
126
|
+
# MIP Client Class
|
|
127
|
+
# ============================================================================
|
|
128
|
+
|
|
129
|
+
class MIPClient:
|
|
130
|
+
"""Async MIP protocol client with auto-reconnection support"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, options: Optional[MIPClientOptions] = None, **kwargs: Any):
|
|
133
|
+
"""
|
|
134
|
+
Initialize MIP client.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
options: MIPClientOptions instance
|
|
138
|
+
**kwargs: Alternative way to pass options (host, port, auto_reconnect, etc...)
|
|
139
|
+
"""
|
|
140
|
+
if options is None:
|
|
141
|
+
options = MIPClientOptions(**kwargs)
|
|
142
|
+
|
|
143
|
+
self._options = options
|
|
144
|
+
self._reader: Optional[asyncio.StreamReader] = None
|
|
145
|
+
self._writer: Optional[asyncio.StreamWriter] = None
|
|
146
|
+
self._buffer: bytes = b""
|
|
147
|
+
self._connected: bool = False
|
|
148
|
+
self._reconnect_attempts: int = 0
|
|
149
|
+
self._reconnect_task: Optional[asyncio.Task] = None
|
|
150
|
+
self._ping_task: Optional[asyncio.Task] = None
|
|
151
|
+
self._read_task: Optional[asyncio.Task] = None
|
|
152
|
+
self._close_task: Optional[asyncio.Task] = None
|
|
153
|
+
self._msg_id_counter: int = 0
|
|
154
|
+
self._running: bool = False
|
|
155
|
+
|
|
156
|
+
# Event callbacks
|
|
157
|
+
self._on_connect: list[OnConnect] = []
|
|
158
|
+
self._on_disconnect: list[OnDisconnect] = []
|
|
159
|
+
self._on_reconnecting: list[OnReconnecting] = []
|
|
160
|
+
self._on_message: list[OnMessage] = []
|
|
161
|
+
self._on_event: list[OnEvent] = []
|
|
162
|
+
self._on_ack: list[OnAck] = []
|
|
163
|
+
self._on_pong: list[OnPong] = []
|
|
164
|
+
self._on_error: list[OnError] = []
|
|
165
|
+
self._on_frame: list[OnFrame] = []
|
|
166
|
+
|
|
167
|
+
# --------------------------------------------------------------------------
|
|
168
|
+
# Properties
|
|
169
|
+
# --------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def is_connected(self) -> bool:
|
|
173
|
+
"""Check if the client is connected"""
|
|
174
|
+
return self._connected
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def options(self) -> MIPClientOptions:
|
|
178
|
+
"""Get client options"""
|
|
179
|
+
return self._options
|
|
180
|
+
|
|
181
|
+
# --------------------------------------------------------------------------
|
|
182
|
+
# Event Registration
|
|
183
|
+
# --------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def on_connect(self, callback: OnConnect) -> "MIPClient":
|
|
186
|
+
"""Register connect event callback"""
|
|
187
|
+
self._on_connect.append(callback)
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
def on_disconnect(self, callback: OnDisconnect) -> "MIPClient":
|
|
191
|
+
"""Register disconnect event callback"""
|
|
192
|
+
self._on_disconnect.append(callback)
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
def on_reconnecting(self, callback: OnReconnecting) -> "MIPClient":
|
|
196
|
+
"""Register reconnecting event callback"""
|
|
197
|
+
self._on_reconnecting.append(callback)
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
def on_message(self, callback: OnMessage) -> "MIPClient":
|
|
201
|
+
"""Register message event callback"""
|
|
202
|
+
self._on_message.append(callback)
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
def on_event(self, callback: OnEvent) -> "MIPClient":
|
|
206
|
+
"""Register event callback"""
|
|
207
|
+
self._on_event.append(callback)
|
|
208
|
+
return self
|
|
209
|
+
|
|
210
|
+
def on_ack(self, callback: OnAck) -> "MIPClient":
|
|
211
|
+
"""Register ACK event callback"""
|
|
212
|
+
self._on_ack.append(callback)
|
|
213
|
+
return self
|
|
214
|
+
|
|
215
|
+
def on_pong(self, callback: OnPong) -> "MIPClient":
|
|
216
|
+
"""Register pong event callback"""
|
|
217
|
+
self._on_pong.append(callback)
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
def on_error(self, callback: OnError) -> "MIPClient":
|
|
221
|
+
"""Register error event callback"""
|
|
222
|
+
self._on_error.append(callback)
|
|
223
|
+
return self
|
|
224
|
+
|
|
225
|
+
def on_frame(self, callback: OnFrame) -> "MIPClient":
|
|
226
|
+
"""Register raw frame event callback"""
|
|
227
|
+
self._on_frame.append(callback)
|
|
228
|
+
return self
|
|
229
|
+
|
|
230
|
+
# --------------------------------------------------------------------------
|
|
231
|
+
# Public API
|
|
232
|
+
# --------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
async def connect(self) -> None:
|
|
235
|
+
"""Connect to the MIP server"""
|
|
236
|
+
if self._connected:
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
self._running = True
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
self._reader, self._writer = await asyncio.open_connection(
|
|
243
|
+
self._options.host, self._options.port
|
|
244
|
+
)
|
|
245
|
+
self._connected = True
|
|
246
|
+
self._reconnect_attempts = 0
|
|
247
|
+
self._buffer = b""
|
|
248
|
+
|
|
249
|
+
# Start background tasks
|
|
250
|
+
self._read_task = asyncio.create_task(self._read_loop())
|
|
251
|
+
self._setup_ping_interval()
|
|
252
|
+
|
|
253
|
+
# Emit connect event
|
|
254
|
+
for callback in self._on_connect:
|
|
255
|
+
callback()
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
self._emit_error(MIPError(message=str(e)))
|
|
259
|
+
raise
|
|
260
|
+
|
|
261
|
+
async def disconnect(self) -> None:
|
|
262
|
+
"""Disconnect from the server"""
|
|
263
|
+
self._options.auto_reconnect = False
|
|
264
|
+
self._running = False
|
|
265
|
+
await self._cleanup()
|
|
266
|
+
|
|
267
|
+
if self._writer:
|
|
268
|
+
await self._send_close()
|
|
269
|
+
self._writer.close()
|
|
270
|
+
try:
|
|
271
|
+
await self._writer.wait_closed()
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
self._writer = None
|
|
275
|
+
self._reader = None
|
|
276
|
+
|
|
277
|
+
self._connected = False
|
|
278
|
+
|
|
279
|
+
def subscribe(self, topic: str, require_ack: bool = True) -> int:
|
|
280
|
+
"""Subscribe to a topic"""
|
|
281
|
+
topic_bytes = topic.encode("utf-8")
|
|
282
|
+
flags = Flags.ACK_REQUIRED if require_ack else Flags.NONE
|
|
283
|
+
return self._send_frame(FrameType.SUBSCRIBE, topic_bytes, flags)
|
|
284
|
+
|
|
285
|
+
def unsubscribe(self, topic: str, require_ack: bool = True) -> int:
|
|
286
|
+
"""Unsubscribe from a topic"""
|
|
287
|
+
topic_bytes = topic.encode("utf-8")
|
|
288
|
+
flags = Flags.ACK_REQUIRED if require_ack else Flags.NONE
|
|
289
|
+
return self._send_frame(FrameType.UNSUBSCRIBE, topic_bytes, flags)
|
|
290
|
+
|
|
291
|
+
def publish(
|
|
292
|
+
self,
|
|
293
|
+
topic: str,
|
|
294
|
+
message: "str | bytes",
|
|
295
|
+
flags: Flags = Flags.NONE
|
|
296
|
+
) -> int:
|
|
297
|
+
"""Publish a message to a topic"""
|
|
298
|
+
topic_bytes = topic.encode("utf-8")
|
|
299
|
+
if isinstance(message, bytes):
|
|
300
|
+
message_bytes = message
|
|
301
|
+
else:
|
|
302
|
+
message_bytes = str(message).encode("utf-8")
|
|
303
|
+
|
|
304
|
+
# Build payload: [topic_length (2 bytes)] [topic] [message]
|
|
305
|
+
payload = struct.pack(">H", len(topic_bytes)) + topic_bytes + message_bytes
|
|
306
|
+
return self._send_frame(FrameType.PUBLISH, payload, flags)
|
|
307
|
+
|
|
308
|
+
def ping(self) -> int:
|
|
309
|
+
"""Send a ping to the server"""
|
|
310
|
+
return self._send_frame(FrameType.PING, b"")
|
|
311
|
+
|
|
312
|
+
def send_raw_frame(
|
|
313
|
+
self,
|
|
314
|
+
frame_type: FrameType,
|
|
315
|
+
payload: bytes,
|
|
316
|
+
flags: Flags = Flags.NONE
|
|
317
|
+
) -> int:
|
|
318
|
+
"""Send raw frame (advanced usage)"""
|
|
319
|
+
return self._send_frame(frame_type, payload, flags)
|
|
320
|
+
|
|
321
|
+
# --------------------------------------------------------------------------
|
|
322
|
+
# Private Methods
|
|
323
|
+
# --------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
def _generate_msg_id(self) -> int:
|
|
326
|
+
"""Generate unique message ID"""
|
|
327
|
+
self._msg_id_counter += 1
|
|
328
|
+
return int(time.time() * 1000000) + self._msg_id_counter
|
|
329
|
+
|
|
330
|
+
def _build_header(
|
|
331
|
+
self,
|
|
332
|
+
frame_type: FrameType,
|
|
333
|
+
payload_length: int,
|
|
334
|
+
flags: int = 0,
|
|
335
|
+
msg_id: Optional[int] = None
|
|
336
|
+
) -> bytes:
|
|
337
|
+
"""Build frame header"""
|
|
338
|
+
if msg_id is None:
|
|
339
|
+
msg_id = self._generate_msg_id()
|
|
340
|
+
|
|
341
|
+
# Header format: magic(4) + version(1) + flags(1) + frame_type(2) +
|
|
342
|
+
# msg_kind(2) + reserved(2) + payload_length(4) + msg_id(8)
|
|
343
|
+
return struct.pack(
|
|
344
|
+
">IBBHHHI Q",
|
|
345
|
+
MAGIC,
|
|
346
|
+
VERSION,
|
|
347
|
+
flags,
|
|
348
|
+
frame_type,
|
|
349
|
+
MSG_KIND_EVENT,
|
|
350
|
+
0, # reserved
|
|
351
|
+
payload_length,
|
|
352
|
+
msg_id
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def _send_frame(
|
|
356
|
+
self,
|
|
357
|
+
frame_type: FrameType,
|
|
358
|
+
payload: bytes,
|
|
359
|
+
flags: int = 0
|
|
360
|
+
) -> int:
|
|
361
|
+
"""Send a frame to the server"""
|
|
362
|
+
if not self._writer or not self._connected:
|
|
363
|
+
raise MIPError(message="Client is not connected")
|
|
364
|
+
|
|
365
|
+
msg_id = self._generate_msg_id()
|
|
366
|
+
header = self._build_header(frame_type, len(payload), flags, msg_id)
|
|
367
|
+
self._writer.write(header + payload)
|
|
368
|
+
return msg_id
|
|
369
|
+
|
|
370
|
+
async def _send_close(self) -> None:
|
|
371
|
+
"""Send close frame"""
|
|
372
|
+
if self._writer and self._connected:
|
|
373
|
+
try:
|
|
374
|
+
header = self._build_header(FrameType.CLOSE, 0)
|
|
375
|
+
self._writer.write(header)
|
|
376
|
+
await self._writer.drain()
|
|
377
|
+
except Exception:
|
|
378
|
+
pass
|
|
379
|
+
|
|
380
|
+
async def _read_loop(self) -> None:
|
|
381
|
+
"""Main read loop for incoming data"""
|
|
382
|
+
try:
|
|
383
|
+
while self._running and self._reader:
|
|
384
|
+
data = await self._reader.read(4096)
|
|
385
|
+
if not data:
|
|
386
|
+
break
|
|
387
|
+
self._handle_data(data)
|
|
388
|
+
except asyncio.CancelledError:
|
|
389
|
+
raise
|
|
390
|
+
except Exception as e:
|
|
391
|
+
if self._connected:
|
|
392
|
+
self._emit_error(MIPError(message=str(e)))
|
|
393
|
+
await self._handle_close()
|
|
394
|
+
|
|
395
|
+
def _handle_data(self, data: bytes) -> None:
|
|
396
|
+
"""Handle incoming data"""
|
|
397
|
+
self._buffer += data
|
|
398
|
+
|
|
399
|
+
while len(self._buffer) >= HEADER_SIZE:
|
|
400
|
+
magic = struct.unpack(">I", self._buffer[:4])[0]
|
|
401
|
+
|
|
402
|
+
if magic != MAGIC:
|
|
403
|
+
self._emit_error(MIPError(message="Invalid magic number", code=magic))
|
|
404
|
+
if self._writer:
|
|
405
|
+
self._writer.close()
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
payload_length = struct.unpack(">I", self._buffer[12:16])[0]
|
|
409
|
+
|
|
410
|
+
if len(self._buffer) < HEADER_SIZE + payload_length:
|
|
411
|
+
return # Wait for more data
|
|
412
|
+
|
|
413
|
+
header = self._parse_header(self._buffer[:HEADER_SIZE])
|
|
414
|
+
payload = self._buffer[HEADER_SIZE:HEADER_SIZE + payload_length]
|
|
415
|
+
|
|
416
|
+
self._process_frame(header, payload)
|
|
417
|
+
|
|
418
|
+
self._buffer = self._buffer[HEADER_SIZE + payload_length:]
|
|
419
|
+
|
|
420
|
+
def _parse_header(self, buffer: bytes) -> FrameHeader:
|
|
421
|
+
"""Parse frame header from bytes"""
|
|
422
|
+
magic, version, flags, frame_type, msg_kind, _, payload_length, msg_id = struct.unpack(
|
|
423
|
+
">IBBHHHI Q", buffer
|
|
424
|
+
)
|
|
425
|
+
return FrameHeader(
|
|
426
|
+
magic=magic,
|
|
427
|
+
version=version,
|
|
428
|
+
flags=flags,
|
|
429
|
+
frame_type=FrameType(frame_type),
|
|
430
|
+
msg_kind=msg_kind,
|
|
431
|
+
payload_length=payload_length,
|
|
432
|
+
msg_id=msg_id
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
def _process_frame(self, header: FrameHeader, payload: bytes) -> None:
|
|
436
|
+
"""Process received frame"""
|
|
437
|
+
# Emit raw frame event
|
|
438
|
+
for callback in self._on_frame:
|
|
439
|
+
callback(header, payload)
|
|
440
|
+
|
|
441
|
+
if header.frame_type in (FrameType.EVENT, FrameType.PUBLISH):
|
|
442
|
+
msg = self._parse_message(header, payload)
|
|
443
|
+
if msg:
|
|
444
|
+
if header.frame_type == FrameType.EVENT:
|
|
445
|
+
for callback in self._on_event:
|
|
446
|
+
callback(msg)
|
|
447
|
+
for callback in self._on_message:
|
|
448
|
+
callback(msg)
|
|
449
|
+
|
|
450
|
+
elif header.frame_type == FrameType.ACK:
|
|
451
|
+
for callback in self._on_ack:
|
|
452
|
+
callback(header.msg_id)
|
|
453
|
+
|
|
454
|
+
elif header.frame_type == FrameType.PONG:
|
|
455
|
+
for callback in self._on_pong:
|
|
456
|
+
callback()
|
|
457
|
+
|
|
458
|
+
elif header.frame_type == FrameType.ERROR:
|
|
459
|
+
error_msg = payload.decode("utf-8")
|
|
460
|
+
self._emit_error(MIPError(message=error_msg, raw=payload))
|
|
461
|
+
|
|
462
|
+
elif header.frame_type == FrameType.CLOSE:
|
|
463
|
+
self._close_task = asyncio.create_task(self._handle_close())
|
|
464
|
+
|
|
465
|
+
def _parse_message(self, header: FrameHeader, payload: bytes) -> Optional[MIPMessage]:
|
|
466
|
+
"""Parse message from payload"""
|
|
467
|
+
if len(payload) < 2:
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
topic_length = struct.unpack(">H", payload[:2])[0]
|
|
471
|
+
|
|
472
|
+
if len(payload) < 2 + topic_length:
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
topic = payload[2:2 + topic_length].decode("utf-8")
|
|
476
|
+
message = payload[2 + topic_length:].decode("utf-8")
|
|
477
|
+
|
|
478
|
+
return MIPMessage(header=header, topic=topic, message=message)
|
|
479
|
+
|
|
480
|
+
async def _handle_close(self) -> None:
|
|
481
|
+
"""Handle connection close"""
|
|
482
|
+
was_connected = self._connected
|
|
483
|
+
self._connected = False
|
|
484
|
+
await self._cleanup()
|
|
485
|
+
|
|
486
|
+
if was_connected:
|
|
487
|
+
for callback in self._on_disconnect:
|
|
488
|
+
callback()
|
|
489
|
+
|
|
490
|
+
if self._options.auto_reconnect and self._running:
|
|
491
|
+
self._schedule_reconnect()
|
|
492
|
+
|
|
493
|
+
def _schedule_reconnect(self) -> None:
|
|
494
|
+
"""Schedule reconnection attempt"""
|
|
495
|
+
if self._reconnect_task and not self._reconnect_task.done():
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
max_attempts = self._options.max_reconnect_attempts
|
|
499
|
+
if max_attempts > 0 and self._reconnect_attempts >= max_attempts:
|
|
500
|
+
self._emit_error(MIPError(
|
|
501
|
+
message=f"Max reconnection attempts ({max_attempts}) reached"
|
|
502
|
+
))
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
self._reconnect_attempts += 1
|
|
506
|
+
for callback in self._on_reconnecting:
|
|
507
|
+
callback(self._reconnect_attempts)
|
|
508
|
+
|
|
509
|
+
async def reconnect() -> None:
|
|
510
|
+
await asyncio.sleep(self._options.reconnect_delay)
|
|
511
|
+
try:
|
|
512
|
+
await self.connect()
|
|
513
|
+
except Exception:
|
|
514
|
+
if self._options.auto_reconnect and self._running:
|
|
515
|
+
self._schedule_reconnect()
|
|
516
|
+
|
|
517
|
+
self._reconnect_task = asyncio.create_task(reconnect())
|
|
518
|
+
|
|
519
|
+
def _setup_ping_interval(self) -> None:
|
|
520
|
+
"""Setup automatic ping interval"""
|
|
521
|
+
if self._options.ping_interval > 0:
|
|
522
|
+
async def ping_loop():
|
|
523
|
+
while self._connected and self._running:
|
|
524
|
+
await asyncio.sleep(self._options.ping_interval)
|
|
525
|
+
if self._connected:
|
|
526
|
+
self.ping()
|
|
527
|
+
|
|
528
|
+
self._ping_task = asyncio.create_task(ping_loop())
|
|
529
|
+
|
|
530
|
+
async def _cleanup(self) -> None:
|
|
531
|
+
"""Cleanup background tasks"""
|
|
532
|
+
if self._ping_task:
|
|
533
|
+
self._ping_task.cancel()
|
|
534
|
+
try:
|
|
535
|
+
await self._ping_task
|
|
536
|
+
except asyncio.CancelledError:
|
|
537
|
+
raise
|
|
538
|
+
self._ping_task = None
|
|
539
|
+
|
|
540
|
+
if self._read_task:
|
|
541
|
+
self._read_task.cancel()
|
|
542
|
+
try:
|
|
543
|
+
await self._read_task
|
|
544
|
+
except asyncio.CancelledError:
|
|
545
|
+
raise
|
|
546
|
+
self._read_task = None
|
|
547
|
+
|
|
548
|
+
if self._reconnect_task:
|
|
549
|
+
self._reconnect_task.cancel()
|
|
550
|
+
try:
|
|
551
|
+
await self._reconnect_task
|
|
552
|
+
except asyncio.CancelledError:
|
|
553
|
+
raise
|
|
554
|
+
self._reconnect_task = None
|
|
555
|
+
|
|
556
|
+
def _emit_error(self, error: MIPError) -> None:
|
|
557
|
+
"""Emit error to callbacks"""
|
|
558
|
+
for callback in self._on_error:
|
|
559
|
+
callback(error)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# ============================================================================
|
|
563
|
+
# Utility Functions
|
|
564
|
+
# ============================================================================
|
|
565
|
+
|
|
566
|
+
def get_frame_type_name(frame_type: FrameType) -> str:
|
|
567
|
+
"""Get the name of a frame type"""
|
|
568
|
+
return frame_type.name
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def create_client(
|
|
572
|
+
host: str = "127.0.0.1",
|
|
573
|
+
port: int = 9000,
|
|
574
|
+
**kwargs: Any
|
|
575
|
+
) -> MIPClient:
|
|
576
|
+
"""Create a client with default options"""
|
|
577
|
+
options = MIPClientOptions(host=host, port=port, **kwargs)
|
|
578
|
+
return MIPClient(options)
|
mip_client/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mip-client-python
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python client for the MIP (MSIP) protocol - handles connections, events, errors, and auto-reconnection
|
|
5
|
+
Project-URL: Homepage, https://github.com/DoctorPok42/MIP-Clients
|
|
6
|
+
Project-URL: Repository, https://github.com/DoctorPok42/MIP-Clients/tree/main/mip-client-python
|
|
7
|
+
Project-URL: Documentation, https://github.com/DoctorPok42/MIP-Clients/tree/main/mip-client-python#readme
|
|
8
|
+
Author-email: DoctorPok42 <pokdoctor@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: async,client,mip,msip,networking,protocol
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Networking
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# MIP-Client-python
|
|
31
|
+
|
|
32
|
+
Python async client for the MIP (MSIP) protocol - handles connections, events, errors, and auto-reconnection.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install mip-client-python
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
### Basic Connection
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import asyncio
|
|
46
|
+
from mip_client import MIPClient
|
|
47
|
+
|
|
48
|
+
async def main():
|
|
49
|
+
client = MIPClient(host="127.0.0.1", port=9000)
|
|
50
|
+
|
|
51
|
+
# Register event callbacks
|
|
52
|
+
client.on_connect(lambda: print("Connected to server"))
|
|
53
|
+
client.on_disconnect(lambda: print("Disconnected"))
|
|
54
|
+
client.on_error(lambda err: print(f"Error: {err.message}"))
|
|
55
|
+
client.on_message(lambda msg: print(f"[{msg.topic}] {msg.message}"))
|
|
56
|
+
|
|
57
|
+
# Connect
|
|
58
|
+
await client.connect()
|
|
59
|
+
|
|
60
|
+
# Keep running
|
|
61
|
+
await asyncio.sleep(60)
|
|
62
|
+
await client.disconnect()
|
|
63
|
+
|
|
64
|
+
asyncio.run(main())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Subscribe / Publish
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
# Subscribe to a topic
|
|
71
|
+
client.subscribe("my-topic")
|
|
72
|
+
|
|
73
|
+
# Listen for messages
|
|
74
|
+
def handle_message(msg):
|
|
75
|
+
print(f"Topic: {msg.topic}")
|
|
76
|
+
print(f"Message: {msg.message}")
|
|
77
|
+
|
|
78
|
+
client.on_message(handle_message)
|
|
79
|
+
|
|
80
|
+
# Publish a message
|
|
81
|
+
client.publish("my-topic", "Hello World!")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Advanced Options
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from mip_client import MIPClient, Flags
|
|
88
|
+
|
|
89
|
+
client = MIPClient(
|
|
90
|
+
host="127.0.0.1",
|
|
91
|
+
port=9000,
|
|
92
|
+
auto_reconnect=True, # Auto-reconnect (default: True)
|
|
93
|
+
reconnect_delay=3.0, # Delay between reconnections in seconds (default: 3.0)
|
|
94
|
+
max_reconnect_attempts=10, # Max attempts (0 = infinite)
|
|
95
|
+
ping_interval=5.0, # Ping interval in seconds (0 = disabled)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Publish with flags
|
|
99
|
+
client.publish("urgent-topic", "Important!", Flags.URGENT | Flags.ACK_REQUIRED)
|
|
100
|
+
|
|
101
|
+
# Listen for ACKs
|
|
102
|
+
client.on_ack(lambda msg_id: print(f"ACK received for: {msg_id}"))
|
|
103
|
+
|
|
104
|
+
# Manual ping
|
|
105
|
+
client.ping()
|
|
106
|
+
client.on_pong(lambda: print("Pong received!"))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Auto-Reconnection
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
client.on_reconnecting(lambda attempt: print(f"Reconnection attempt #{attempt}..."))
|
|
113
|
+
|
|
114
|
+
def on_connect():
|
|
115
|
+
# Re-subscribe after reconnection
|
|
116
|
+
client.subscribe("my-topic")
|
|
117
|
+
|
|
118
|
+
client.on_connect(on_connect)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Using create_client helper
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from mip_client import create_client
|
|
125
|
+
|
|
126
|
+
client = create_client("127.0.0.1", 9000, auto_reconnect=True)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## API Reference
|
|
130
|
+
|
|
131
|
+
### MIPClient
|
|
132
|
+
|
|
133
|
+
- `connect()` - Connect to the server (async)
|
|
134
|
+
- `disconnect()` - Disconnect from the server (async)
|
|
135
|
+
- `subscribe(topic, require_ack=True)` - Subscribe to a topic
|
|
136
|
+
- `unsubscribe(topic, require_ack=True)` - Unsubscribe from a topic
|
|
137
|
+
- `publish(topic, message, flags=Flags.NONE)` - Publish a message
|
|
138
|
+
- `ping()` - Send a ping to the server
|
|
139
|
+
|
|
140
|
+
### Events
|
|
141
|
+
|
|
142
|
+
- `on_connect(callback)` - Called when connected
|
|
143
|
+
- `on_disconnect(callback)` - Called when disconnected
|
|
144
|
+
- `on_reconnecting(callback)` - Called when reconnecting (receives attempt number)
|
|
145
|
+
- `on_message(callback)` - Called when a message is received
|
|
146
|
+
- `on_event(callback)` - Called when an event is received
|
|
147
|
+
- `on_ack(callback)` - Called when an ACK is received
|
|
148
|
+
- `on_pong(callback)` - Called when a pong is received
|
|
149
|
+
- `on_error(callback)` - Called when an error occurs
|
|
150
|
+
|
|
151
|
+
### Flags
|
|
152
|
+
|
|
153
|
+
- `Flags.NONE` - No flags
|
|
154
|
+
- `Flags.ACK_REQUIRED` - Request acknowledgment
|
|
155
|
+
- `Flags.COMPRESSED` - Compressed payload
|
|
156
|
+
- `Flags.URGENT` - Urgent message
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
mip_client/__init__.py,sha256=a_ET2iUamLWQ4WyJ2GdPoeTh58GCdpcXZWaHhTMnGM4,18776
|
|
2
|
+
mip_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
mip_client_python-1.0.0.dist-info/METADATA,sha256=go1fTZUUiqEoBSlFIIuCyd5sSpr9m3Xj188IodNBRBQ,4540
|
|
4
|
+
mip_client_python-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
5
|
+
mip_client_python-1.0.0.dist-info/licenses/LICENSE,sha256=tK6bDr9ftFIdQ6J3OnDj8-kLxbIgpig6wp4RpyEc9lA,1083
|
|
6
|
+
mip_client_python-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rémi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|