python-jebao 0.1.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.
- jebao/__init__.py +35 -0
- jebao/const.py +68 -0
- jebao/device.py +139 -0
- jebao/discovery.py +399 -0
- jebao/exceptions.py +25 -0
- jebao/mdp20000.py +302 -0
- jebao/protocol.py +670 -0
- jebao/retry.py +77 -0
- python_jebao-0.1.0.dist-info/METADATA +367 -0
- python_jebao-0.1.0.dist-info/RECORD +13 -0
- python_jebao-0.1.0.dist-info/WHEEL +5 -0
- python_jebao-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_jebao-0.1.0.dist-info/top_level.txt +1 -0
jebao/protocol.py
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""Protocol layer for Jebao devices.
|
|
2
|
+
|
|
3
|
+
This implementation is inspired by the original Node.js library:
|
|
4
|
+
https://github.com/tancou/jebao-dosing-pump-md-4.4
|
|
5
|
+
|
|
6
|
+
The GizWits protocol structure and authentication flow were adapted from
|
|
7
|
+
that work, with extensions for MDP-20000 support and asyncio implementation.
|
|
8
|
+
"""
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Dict, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from .const import (
|
|
14
|
+
COMMAND_TIMEOUT,
|
|
15
|
+
CONTROL_COMMAND_SIZE,
|
|
16
|
+
DEFAULT_TIMEOUT,
|
|
17
|
+
MSG_CONTROL_OR_EXTENDED_REQUEST,
|
|
18
|
+
MSG_CONTROL_RESPONSE_OR_EXTENDED_DATA,
|
|
19
|
+
MSG_DATA_REQUEST_SIMPLE,
|
|
20
|
+
MSG_DATA_RESPONSE_SIMPLE,
|
|
21
|
+
MSG_LOGIN_REQUEST,
|
|
22
|
+
MSG_LOGIN_SUCCESS,
|
|
23
|
+
MSG_PASSCODE_RESPONSE,
|
|
24
|
+
MSG_PING,
|
|
25
|
+
MSG_PONG,
|
|
26
|
+
MSG_REQUEST_PASSCODE,
|
|
27
|
+
)
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
JebaoAuthenticationError,
|
|
30
|
+
JebaoCommandError,
|
|
31
|
+
JebaoConnectionError,
|
|
32
|
+
JebaoTimeoutError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_LOGGER = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Extended data response message type (183-byte status responses)
|
|
38
|
+
MSG_EXTENDED_DATA = 0x00
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class JebaoProtocol:
|
|
42
|
+
"""Low-level protocol implementation for Jebao devices."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, host: str, port: int = 12416):
|
|
45
|
+
"""Initialize protocol handler.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
host: Device IP address
|
|
49
|
+
port: TCP port (default 12416)
|
|
50
|
+
"""
|
|
51
|
+
self.host = host
|
|
52
|
+
self.port = port
|
|
53
|
+
self._reader: Optional[asyncio.StreamReader] = None
|
|
54
|
+
self._writer: Optional[asyncio.StreamWriter] = None
|
|
55
|
+
self._sequence = 1
|
|
56
|
+
self._keepalive_task: Optional[asyncio.Task] = None
|
|
57
|
+
self._passcode: Optional[bytes] = None
|
|
58
|
+
self._read_lock = asyncio.Lock()
|
|
59
|
+
|
|
60
|
+
# Message dispatcher
|
|
61
|
+
self._message_queues: Dict[int, asyncio.Queue] = {}
|
|
62
|
+
self._message_reader_task: Optional[asyncio.Task] = None
|
|
63
|
+
self._dispatcher_running = False
|
|
64
|
+
|
|
65
|
+
# Read buffer for handling garbage bytes
|
|
66
|
+
self._read_buffer = bytearray()
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_connected(self) -> bool:
|
|
70
|
+
"""Check if connection is established."""
|
|
71
|
+
return self._writer is not None and not self._writer.is_closing()
|
|
72
|
+
|
|
73
|
+
async def connect(self, timeout: float = DEFAULT_TIMEOUT) -> None:
|
|
74
|
+
"""Connect to device and authenticate.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
timeout: Connection timeout in seconds
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
JebaoConnectionError: Connection failed
|
|
81
|
+
JebaoAuthenticationError: Authentication failed
|
|
82
|
+
JebaoTimeoutError: Operation timed out
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
_LOGGER.debug("Connecting to %s:%d", self.host, self.port)
|
|
86
|
+
self._reader, self._writer = await asyncio.wait_for(
|
|
87
|
+
asyncio.open_connection(self.host, self.port), timeout=timeout
|
|
88
|
+
)
|
|
89
|
+
except asyncio.TimeoutError as err:
|
|
90
|
+
raise JebaoTimeoutError(f"Connection to {self.host} timed out") from err
|
|
91
|
+
except OSError as err:
|
|
92
|
+
raise JebaoConnectionError(
|
|
93
|
+
f"Failed to connect to {self.host}: {err}"
|
|
94
|
+
) from err
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
await self._authenticate(timeout)
|
|
98
|
+
# Note: Message dispatcher disabled - causes issues with pump's padding bytes
|
|
99
|
+
# Using synchronous request-response pattern instead
|
|
100
|
+
# self._start_message_reader()
|
|
101
|
+
# Note: Keepalive disabled for now as it interferes with command responses
|
|
102
|
+
# The connection stays alive without explicit pings in most cases
|
|
103
|
+
# self._start_keepalive()
|
|
104
|
+
except Exception:
|
|
105
|
+
await self.disconnect()
|
|
106
|
+
raise
|
|
107
|
+
|
|
108
|
+
async def _authenticate(self, timeout: float) -> None:
|
|
109
|
+
"""Perform authentication handshake.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
timeout: Operation timeout
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
JebaoAuthenticationError: Authentication failed
|
|
116
|
+
"""
|
|
117
|
+
_LOGGER.debug("Starting authentication")
|
|
118
|
+
|
|
119
|
+
# Step 1: Request passcode
|
|
120
|
+
request = self._build_passcode_request()
|
|
121
|
+
await self._send_raw(request)
|
|
122
|
+
|
|
123
|
+
# Step 2: Receive passcode
|
|
124
|
+
try:
|
|
125
|
+
response = await asyncio.wait_for(self._read_raw(), timeout=timeout)
|
|
126
|
+
except asyncio.TimeoutError as err:
|
|
127
|
+
raise JebaoAuthenticationError("Passcode request timed out") from err
|
|
128
|
+
|
|
129
|
+
_LOGGER.debug(f"Passcode response length: {len(response)}, data: {response[:30].hex() if len(response) >= 30 else response.hex()}")
|
|
130
|
+
|
|
131
|
+
# Message type is at byte 7, not byte 8
|
|
132
|
+
if len(response) < 20 or response[7] != MSG_PASSCODE_RESPONSE:
|
|
133
|
+
_LOGGER.error(f"Invalid response - length: {len(response)}, type at [7]: {response[7] if len(response) > 7 else 'N/A'}")
|
|
134
|
+
raise JebaoAuthenticationError("Invalid passcode response")
|
|
135
|
+
|
|
136
|
+
self._passcode = response[10:20] # Extract 10-character passcode
|
|
137
|
+
_LOGGER.debug("Received passcode: %s", self._passcode.decode("ascii"))
|
|
138
|
+
|
|
139
|
+
# Step 3: Login with passcode
|
|
140
|
+
login = self._build_login_request(self._passcode)
|
|
141
|
+
_LOGGER.debug(f"Sending login request: {login.hex()}")
|
|
142
|
+
await self._send_raw(login)
|
|
143
|
+
|
|
144
|
+
# Step 4: Verify login success
|
|
145
|
+
try:
|
|
146
|
+
response = await asyncio.wait_for(self._read_raw(), timeout=timeout)
|
|
147
|
+
_LOGGER.debug(f"Login response: {response.hex()}")
|
|
148
|
+
except asyncio.TimeoutError as err:
|
|
149
|
+
_LOGGER.error("Login timed out - pump may be busy or rate-limited")
|
|
150
|
+
raise JebaoAuthenticationError("Login timed out") from err
|
|
151
|
+
|
|
152
|
+
# Message type is at byte 7
|
|
153
|
+
if len(response) < 8:
|
|
154
|
+
_LOGGER.error(f"Login response too short: {len(response)} bytes")
|
|
155
|
+
raise JebaoAuthenticationError("Login failed - response too short")
|
|
156
|
+
|
|
157
|
+
if response[7] != MSG_LOGIN_SUCCESS:
|
|
158
|
+
_LOGGER.error(f"Login failed - expected type 0x{MSG_LOGIN_SUCCESS:02x} at byte 7, got 0x{response[7]:02x}")
|
|
159
|
+
raise JebaoAuthenticationError("Login failed")
|
|
160
|
+
|
|
161
|
+
_LOGGER.info("Authentication successful for %s", self.host)
|
|
162
|
+
|
|
163
|
+
async def disconnect(self) -> None:
|
|
164
|
+
"""Disconnect from device."""
|
|
165
|
+
# Stop message reader
|
|
166
|
+
await self._stop_message_reader()
|
|
167
|
+
|
|
168
|
+
# Stop keepalive
|
|
169
|
+
if self._keepalive_task:
|
|
170
|
+
self._keepalive_task.cancel()
|
|
171
|
+
try:
|
|
172
|
+
await self._keepalive_task
|
|
173
|
+
except asyncio.CancelledError:
|
|
174
|
+
pass
|
|
175
|
+
self._keepalive_task = None
|
|
176
|
+
|
|
177
|
+
# Close connection
|
|
178
|
+
if self._writer:
|
|
179
|
+
try:
|
|
180
|
+
self._writer.close()
|
|
181
|
+
await self._writer.wait_closed()
|
|
182
|
+
except Exception as err:
|
|
183
|
+
_LOGGER.debug("Error closing connection: %s", err)
|
|
184
|
+
finally:
|
|
185
|
+
self._writer = None
|
|
186
|
+
self._reader = None
|
|
187
|
+
|
|
188
|
+
_LOGGER.debug("Disconnected from %s", self.host)
|
|
189
|
+
|
|
190
|
+
def _start_keepalive(self) -> None:
|
|
191
|
+
"""Start keepalive task."""
|
|
192
|
+
if self._keepalive_task is None or self._keepalive_task.done():
|
|
193
|
+
self._keepalive_task = asyncio.create_task(self._keepalive_loop())
|
|
194
|
+
|
|
195
|
+
async def _keepalive_loop(self) -> None:
|
|
196
|
+
"""Send periodic ping messages to keep connection alive."""
|
|
197
|
+
from .const import KEEPALIVE_INTERVAL
|
|
198
|
+
|
|
199
|
+
while self.is_connected:
|
|
200
|
+
try:
|
|
201
|
+
await asyncio.sleep(KEEPALIVE_INTERVAL)
|
|
202
|
+
if not self.is_connected:
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
# Send ping
|
|
206
|
+
ping = bytes.fromhex("0000000303000015")
|
|
207
|
+
await self._send_raw(ping)
|
|
208
|
+
|
|
209
|
+
# Wait for pong
|
|
210
|
+
try:
|
|
211
|
+
response = await asyncio.wait_for(
|
|
212
|
+
self._read_raw(), timeout=2.0
|
|
213
|
+
)
|
|
214
|
+
# Message type at byte 7
|
|
215
|
+
if len(response) >= 8 and response[7] == MSG_PONG:
|
|
216
|
+
_LOGGER.debug("Keepalive OK")
|
|
217
|
+
else:
|
|
218
|
+
_LOGGER.warning("Invalid pong response")
|
|
219
|
+
except asyncio.TimeoutError:
|
|
220
|
+
_LOGGER.warning("Keepalive pong timeout")
|
|
221
|
+
except asyncio.CancelledError:
|
|
222
|
+
break
|
|
223
|
+
except Exception as err:
|
|
224
|
+
_LOGGER.error("Keepalive error: %s", err)
|
|
225
|
+
break
|
|
226
|
+
|
|
227
|
+
def _start_message_reader(self) -> None:
|
|
228
|
+
"""Start background message reader task."""
|
|
229
|
+
if self._message_reader_task is None or self._message_reader_task.done():
|
|
230
|
+
self._dispatcher_running = True
|
|
231
|
+
self._message_reader_task = asyncio.create_task(self._message_reader_loop())
|
|
232
|
+
_LOGGER.debug("Message reader started for %s", self.host)
|
|
233
|
+
|
|
234
|
+
async def _stop_message_reader(self) -> None:
|
|
235
|
+
"""Stop background message reader task."""
|
|
236
|
+
self._dispatcher_running = False
|
|
237
|
+
|
|
238
|
+
if self._message_reader_task:
|
|
239
|
+
self._message_reader_task.cancel()
|
|
240
|
+
try:
|
|
241
|
+
await self._message_reader_task
|
|
242
|
+
except asyncio.CancelledError:
|
|
243
|
+
pass
|
|
244
|
+
self._message_reader_task = None
|
|
245
|
+
|
|
246
|
+
# Clear all queues
|
|
247
|
+
for queue in self._message_queues.values():
|
|
248
|
+
while not queue.empty():
|
|
249
|
+
try:
|
|
250
|
+
queue.get_nowait()
|
|
251
|
+
except asyncio.QueueEmpty:
|
|
252
|
+
break
|
|
253
|
+
self._message_queues.clear()
|
|
254
|
+
|
|
255
|
+
_LOGGER.debug("Message reader stopped for %s", self.host)
|
|
256
|
+
|
|
257
|
+
async def _message_reader_loop(self) -> None:
|
|
258
|
+
"""Background loop that reads messages and routes them to queues."""
|
|
259
|
+
_LOGGER.debug("Message reader loop started for %s", self.host)
|
|
260
|
+
|
|
261
|
+
while self._dispatcher_running and self.is_connected:
|
|
262
|
+
try:
|
|
263
|
+
# Read next message from socket
|
|
264
|
+
message = await self._read_raw()
|
|
265
|
+
msg_type = message[7] if len(message) > 7 else None
|
|
266
|
+
|
|
267
|
+
if msg_type is None:
|
|
268
|
+
_LOGGER.warning("Received message with no type from %s", self.host)
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
# Validate message type - skip obviously invalid messages
|
|
272
|
+
# Valid types: 0x00, 0x06-0x09, 0x15-0x16, 0x90-0x94
|
|
273
|
+
if not (
|
|
274
|
+
msg_type == 0x00
|
|
275
|
+
or (0x06 <= msg_type <= 0x09)
|
|
276
|
+
or (0x15 <= msg_type <= 0x16)
|
|
277
|
+
or (0x90 <= msg_type <= 0x94)
|
|
278
|
+
):
|
|
279
|
+
_LOGGER.warning(
|
|
280
|
+
"Ignoring invalid message type 0x%02x from %s",
|
|
281
|
+
msg_type,
|
|
282
|
+
self.host,
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
_LOGGER.debug(
|
|
287
|
+
"Dispatcher received message type 0x%02x (%d bytes) from %s",
|
|
288
|
+
msg_type,
|
|
289
|
+
len(message),
|
|
290
|
+
self.host,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Create queue for this message type if it doesn't exist
|
|
294
|
+
if msg_type not in self._message_queues:
|
|
295
|
+
self._message_queues[msg_type] = asyncio.Queue()
|
|
296
|
+
|
|
297
|
+
# Route message to appropriate queue
|
|
298
|
+
await self._message_queues[msg_type].put(message)
|
|
299
|
+
|
|
300
|
+
except asyncio.CancelledError:
|
|
301
|
+
_LOGGER.debug("Message reader cancelled for %s", self.host)
|
|
302
|
+
break
|
|
303
|
+
except JebaoConnectionError as err:
|
|
304
|
+
# Connection closed or read failed
|
|
305
|
+
_LOGGER.debug("Connection closed in message reader: %s", err)
|
|
306
|
+
break
|
|
307
|
+
except Exception as err:
|
|
308
|
+
if self._dispatcher_running:
|
|
309
|
+
_LOGGER.error(
|
|
310
|
+
"Message reader error for %s: %s", self.host, err
|
|
311
|
+
)
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
_LOGGER.debug("Message reader loop ended for %s", self.host)
|
|
315
|
+
|
|
316
|
+
async def _wait_for_message_type(
|
|
317
|
+
self, msg_type: int, timeout: float
|
|
318
|
+
) -> bytes:
|
|
319
|
+
"""Wait for a specific message type from the dispatcher.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
msg_type: Message type to wait for (byte 7 of message)
|
|
323
|
+
timeout: Timeout in seconds
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
The message bytes
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
JebaoTimeoutError: Timeout waiting for message
|
|
330
|
+
"""
|
|
331
|
+
# Create queue for this message type if it doesn't exist
|
|
332
|
+
if msg_type not in self._message_queues:
|
|
333
|
+
self._message_queues[msg_type] = asyncio.Queue()
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
message = await asyncio.wait_for(
|
|
337
|
+
self._message_queues[msg_type].get(), timeout=timeout
|
|
338
|
+
)
|
|
339
|
+
_LOGGER.debug(
|
|
340
|
+
"Got message type 0x%02x from queue for %s", msg_type, self.host
|
|
341
|
+
)
|
|
342
|
+
return message
|
|
343
|
+
except asyncio.TimeoutError as err:
|
|
344
|
+
raise JebaoTimeoutError(
|
|
345
|
+
f"Timeout waiting for message type 0x{msg_type:02x}"
|
|
346
|
+
) from err
|
|
347
|
+
|
|
348
|
+
async def request_status(
|
|
349
|
+
self, timeout: float = COMMAND_TIMEOUT
|
|
350
|
+
) -> bytes:
|
|
351
|
+
"""Request device status.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
timeout: Request timeout
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Raw status data
|
|
358
|
+
|
|
359
|
+
Raises:
|
|
360
|
+
JebaoConnectionError: Not connected
|
|
361
|
+
JebaoTimeoutError: Request timed out
|
|
362
|
+
"""
|
|
363
|
+
if not self.is_connected:
|
|
364
|
+
raise JebaoConnectionError("Not connected")
|
|
365
|
+
|
|
366
|
+
# Send simple status request (0x90)
|
|
367
|
+
# Format: 00 00 00 03 04 00 00 90 02
|
|
368
|
+
# The last byte (02) requests values, and response will be message type 91 or 0x00
|
|
369
|
+
request = bytes.fromhex("0000000304000090 02")
|
|
370
|
+
_LOGGER.debug(f"Sending status request: {request.hex()}")
|
|
371
|
+
await self._send_raw(request)
|
|
372
|
+
_LOGGER.debug("Status request sent, waiting for response...")
|
|
373
|
+
|
|
374
|
+
# Read response directly (no dispatcher)
|
|
375
|
+
try:
|
|
376
|
+
response = await asyncio.wait_for(self._read_raw(), timeout=timeout)
|
|
377
|
+
except asyncio.TimeoutError as err:
|
|
378
|
+
raise JebaoTimeoutError("Status request timed out") from err
|
|
379
|
+
|
|
380
|
+
_LOGGER.debug(f"Status response length: {len(response)}, data: {response.hex()[:100]}...")
|
|
381
|
+
|
|
382
|
+
# Note: Don't drain here - pump may send unsolicited updates that we shouldn't consume
|
|
383
|
+
|
|
384
|
+
if len(response) < 10:
|
|
385
|
+
raise JebaoCommandError(f"Invalid status response: too short ({len(response)} bytes)")
|
|
386
|
+
|
|
387
|
+
# Log the message type and first few bytes for debugging
|
|
388
|
+
msg_type = response[7] if len(response) > 7 else 0
|
|
389
|
+
_LOGGER.debug(f"Status response type: 0x{msg_type:02x}, bytes 10-12: {response[10:12].hex() if len(response) > 11 else 'N/A'}")
|
|
390
|
+
|
|
391
|
+
return response
|
|
392
|
+
|
|
393
|
+
async def send_control_command(
|
|
394
|
+
self,
|
|
395
|
+
opcode1: int,
|
|
396
|
+
opcode2: int = 0,
|
|
397
|
+
param1: int = 0,
|
|
398
|
+
param2: int = 0,
|
|
399
|
+
timeout: float = COMMAND_TIMEOUT,
|
|
400
|
+
) -> None:
|
|
401
|
+
"""Send control command to device.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
opcode1: Primary opcode (at byte 21)
|
|
405
|
+
opcode2: Secondary opcode (at byte 22)
|
|
406
|
+
param1: Parameter 1 (at byte 23)
|
|
407
|
+
param2: Parameter 2 (at byte 24)
|
|
408
|
+
timeout: Command timeout
|
|
409
|
+
|
|
410
|
+
Raises:
|
|
411
|
+
JebaoConnectionError: Not connected
|
|
412
|
+
JebaoCommandError: Command failed
|
|
413
|
+
JebaoTimeoutError: Command timed out
|
|
414
|
+
"""
|
|
415
|
+
if not self.is_connected:
|
|
416
|
+
raise JebaoConnectionError("Not connected")
|
|
417
|
+
|
|
418
|
+
# Drain any garbage bytes left from previous operations
|
|
419
|
+
# This prevents garbage from corrupting the ACK read
|
|
420
|
+
await self._drain_garbage_bytes(timeout=0.05)
|
|
421
|
+
|
|
422
|
+
command = self._build_control_command(opcode1, opcode2, param1, param2)
|
|
423
|
+
_LOGGER.debug(
|
|
424
|
+
"Sending control command: opcode1=0x%02x, opcode2=0x%02x, param1=%d, param2=%d",
|
|
425
|
+
opcode1,
|
|
426
|
+
opcode2,
|
|
427
|
+
param1,
|
|
428
|
+
param2,
|
|
429
|
+
)
|
|
430
|
+
await self._send_raw(command)
|
|
431
|
+
|
|
432
|
+
# Read ACK (message type 0x94) directly
|
|
433
|
+
# The pump sends an ACK for control commands
|
|
434
|
+
try:
|
|
435
|
+
ack = await asyncio.wait_for(self._read_raw(), timeout=timeout)
|
|
436
|
+
ack_type = ack[7] if len(ack) > 7 else 0
|
|
437
|
+
_LOGGER.debug("Control command ACK received: type=0x%02x, %d bytes", ack_type, len(ack))
|
|
438
|
+
|
|
439
|
+
# After ACK, pump may send unsolicited status update
|
|
440
|
+
# Try to read it with short timeout
|
|
441
|
+
try:
|
|
442
|
+
status_update = await asyncio.wait_for(self._read_raw(), timeout=0.5)
|
|
443
|
+
status_type = status_update[7] if len(status_update) > 7 else 0
|
|
444
|
+
_LOGGER.info(
|
|
445
|
+
"Pump sent unsolicited status update after control command: type=0x%02x",
|
|
446
|
+
status_type,
|
|
447
|
+
)
|
|
448
|
+
# Drain any remaining garbage after the status update
|
|
449
|
+
await self._drain_garbage_bytes()
|
|
450
|
+
except asyncio.TimeoutError:
|
|
451
|
+
# No status update sent, that's OK
|
|
452
|
+
_LOGGER.debug("No unsolicited status update after control command")
|
|
453
|
+
|
|
454
|
+
except asyncio.TimeoutError:
|
|
455
|
+
_LOGGER.warning("Control command sent but no ACK received (pump may still process it)")
|
|
456
|
+
# Don't raise - pump may still process the command even without ACK
|
|
457
|
+
|
|
458
|
+
def _build_control_command(
|
|
459
|
+
self, opcode1: int, opcode2: int, param1: int, param2: int
|
|
460
|
+
) -> bytes:
|
|
461
|
+
"""Build control command message.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
opcode1: Primary opcode
|
|
465
|
+
opcode2: Secondary opcode
|
|
466
|
+
param1: Parameter 1
|
|
467
|
+
param2: Parameter 2
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
323-byte command message
|
|
471
|
+
"""
|
|
472
|
+
buffer = bytearray(CONTROL_COMMAND_SIZE)
|
|
473
|
+
|
|
474
|
+
# Header
|
|
475
|
+
buffer[0:4] = b"\x00\x00\x00\x03"
|
|
476
|
+
buffer[4] = 0xBD
|
|
477
|
+
buffer[5] = 0x02
|
|
478
|
+
|
|
479
|
+
# Message type and sequence
|
|
480
|
+
buffer[8] = MSG_CONTROL_OR_EXTENDED_REQUEST
|
|
481
|
+
buffer[9:13] = self._sequence.to_bytes(4, "big")
|
|
482
|
+
self._sequence += 1
|
|
483
|
+
buffer[13] = 0x01
|
|
484
|
+
|
|
485
|
+
# Command payload
|
|
486
|
+
buffer[21] = opcode1
|
|
487
|
+
buffer[22] = opcode2
|
|
488
|
+
buffer[23] = param1
|
|
489
|
+
buffer[24] = param2
|
|
490
|
+
|
|
491
|
+
# Rest is zeros (already initialized)
|
|
492
|
+
return bytes(buffer)
|
|
493
|
+
|
|
494
|
+
@staticmethod
|
|
495
|
+
def _build_passcode_request() -> bytes:
|
|
496
|
+
"""Build passcode request message."""
|
|
497
|
+
return bytes.fromhex("00000003050000060100")
|
|
498
|
+
|
|
499
|
+
@staticmethod
|
|
500
|
+
def _build_login_request(passcode: bytes) -> bytes:
|
|
501
|
+
"""Build login request message.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
passcode: 10-byte passcode
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Login request message
|
|
508
|
+
"""
|
|
509
|
+
buffer = bytearray.fromhex("00000003 0f 000008 000a")
|
|
510
|
+
buffer.extend(passcode)
|
|
511
|
+
return bytes(buffer)
|
|
512
|
+
|
|
513
|
+
async def _drain_garbage_bytes(self, timeout: float = 0.1) -> None:
|
|
514
|
+
"""Drain any garbage/padding bytes from the socket buffer.
|
|
515
|
+
|
|
516
|
+
The MDP-20000 pump appears to send extra padding bytes (0xee) after
|
|
517
|
+
valid responses. This method drains those bytes to prevent them from
|
|
518
|
+
corrupting subsequent reads.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
timeout: How long to wait for garbage bytes (default 0.1s)
|
|
522
|
+
"""
|
|
523
|
+
if not self._reader:
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
try:
|
|
527
|
+
# Try to read with short timeout - if there's garbage, consume it
|
|
528
|
+
while True:
|
|
529
|
+
try:
|
|
530
|
+
# Try to read header of next message with short timeout
|
|
531
|
+
header = await asyncio.wait_for(
|
|
532
|
+
self._reader.read(5), timeout=timeout
|
|
533
|
+
)
|
|
534
|
+
if not header:
|
|
535
|
+
# No more data
|
|
536
|
+
break
|
|
537
|
+
if len(header) < 5:
|
|
538
|
+
# Partial header, probably garbage
|
|
539
|
+
_LOGGER.debug("Drained %d garbage bytes", len(header))
|
|
540
|
+
continue
|
|
541
|
+
# Check if it's a valid header
|
|
542
|
+
if header[0:4] == b"\x00\x00\x00\x03":
|
|
543
|
+
# Valid header found - this is a real message!
|
|
544
|
+
# Put it back somehow... actually, we can't put it back easily
|
|
545
|
+
# This is a problem - we've consumed a valid message header
|
|
546
|
+
_LOGGER.warning(
|
|
547
|
+
"Found valid header while draining - this shouldn't happen! "
|
|
548
|
+
"Pump may have sent unsolicited message."
|
|
549
|
+
)
|
|
550
|
+
# Read the rest of this message to consume it
|
|
551
|
+
length = header[4]
|
|
552
|
+
remaining = await self._reader.read(length)
|
|
553
|
+
_LOGGER.warning(
|
|
554
|
+
"Consumed unsolicited message: type=0x%02x, %d bytes total",
|
|
555
|
+
header[7] if len(header) > 7 else 0,
|
|
556
|
+
len(header) + len(remaining),
|
|
557
|
+
)
|
|
558
|
+
else:
|
|
559
|
+
# Garbage header - log and continue draining
|
|
560
|
+
_LOGGER.debug("Drained garbage header: %s", header.hex())
|
|
561
|
+
except asyncio.TimeoutError:
|
|
562
|
+
# Timeout means no more data - we're done
|
|
563
|
+
break
|
|
564
|
+
except Exception as err:
|
|
565
|
+
_LOGGER.debug("Error draining garbage: %s", err)
|
|
566
|
+
|
|
567
|
+
async def _send_raw(self, data: bytes) -> None:
|
|
568
|
+
"""Send raw data to device.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
data: Data to send
|
|
572
|
+
|
|
573
|
+
Raises:
|
|
574
|
+
JebaoConnectionError: Send failed
|
|
575
|
+
"""
|
|
576
|
+
if not self._writer:
|
|
577
|
+
raise JebaoConnectionError("Not connected")
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
self._writer.write(data)
|
|
581
|
+
await self._writer.drain()
|
|
582
|
+
except Exception as err:
|
|
583
|
+
raise JebaoConnectionError(f"Send failed: {err}") from err
|
|
584
|
+
|
|
585
|
+
async def _read_raw(self) -> bytes:
|
|
586
|
+
"""Read raw data from device.
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
Received data
|
|
590
|
+
|
|
591
|
+
Raises:
|
|
592
|
+
JebaoConnectionError: Read failed
|
|
593
|
+
"""
|
|
594
|
+
if not self._reader:
|
|
595
|
+
raise JebaoConnectionError("Not connected")
|
|
596
|
+
|
|
597
|
+
async with self._read_lock:
|
|
598
|
+
try:
|
|
599
|
+
# Read header (4 bytes) + length byte (1 byte) = 5 bytes total
|
|
600
|
+
# Format: 00 00 00 03 [LENGTH]
|
|
601
|
+
_LOGGER.debug("Reading next message header...")
|
|
602
|
+
header = await self._reader.readexactly(5)
|
|
603
|
+
|
|
604
|
+
# Validate header - if invalid, try to re-sync
|
|
605
|
+
max_resync_attempts = 50
|
|
606
|
+
for attempt in range(max_resync_attempts):
|
|
607
|
+
if header[0:4] == b"\x00\x00\x00\x03":
|
|
608
|
+
# Valid header found
|
|
609
|
+
break
|
|
610
|
+
|
|
611
|
+
# Invalid header - try to re-sync by reading one more byte
|
|
612
|
+
_LOGGER.warning(
|
|
613
|
+
"Invalid header %s, attempting re-sync (attempt %d/%d)",
|
|
614
|
+
header[0:4].hex(),
|
|
615
|
+
attempt + 1,
|
|
616
|
+
max_resync_attempts,
|
|
617
|
+
)
|
|
618
|
+
# Shift header left by 1 byte and read 1 new byte
|
|
619
|
+
next_byte = await self._reader.readexactly(1)
|
|
620
|
+
header = header[1:] + next_byte
|
|
621
|
+
else:
|
|
622
|
+
# Failed to re-sync after max attempts
|
|
623
|
+
_LOGGER.error("Failed to re-sync after %d attempts", max_resync_attempts)
|
|
624
|
+
raise JebaoConnectionError("Lost message synchronization")
|
|
625
|
+
|
|
626
|
+
# Extract length byte (byte 4) - indicates bytes that follow
|
|
627
|
+
length = header[4]
|
|
628
|
+
_LOGGER.debug(f"Valid header found, reading {length} more bytes")
|
|
629
|
+
|
|
630
|
+
# Read the remaining bytes based on length field
|
|
631
|
+
remaining = await self._reader.readexactly(length)
|
|
632
|
+
|
|
633
|
+
result = header + remaining
|
|
634
|
+
msg_type = result[7] if len(result) > 7 else 0
|
|
635
|
+
_LOGGER.debug(
|
|
636
|
+
f"Message complete: {len(result)} bytes, type=0x{msg_type:02x}"
|
|
637
|
+
)
|
|
638
|
+
return result
|
|
639
|
+
except asyncio.IncompleteReadError as err:
|
|
640
|
+
_LOGGER.error("IncompleteReadError - connection closed by peer")
|
|
641
|
+
raise JebaoConnectionError("Connection closed") from err
|
|
642
|
+
except JebaoConnectionError:
|
|
643
|
+
raise
|
|
644
|
+
except Exception as err:
|
|
645
|
+
_LOGGER.error(f"Read exception: {type(err).__name__}: {err}")
|
|
646
|
+
raise JebaoConnectionError(f"Read failed: {err}") from err
|
|
647
|
+
|
|
648
|
+
@staticmethod
|
|
649
|
+
def parse_status(data: bytes) -> Optional[dict]:
|
|
650
|
+
"""Parse status data from response.
|
|
651
|
+
|
|
652
|
+
Args:
|
|
653
|
+
data: Raw response data
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
Dictionary with state and speed, or None if parsing failed
|
|
657
|
+
"""
|
|
658
|
+
# For MDP-20000, state and speed are at fixed positions
|
|
659
|
+
# Byte 10: State (0x10=OFF, 0x11=ON, 0x15=FEED, 0x19=PROGRAM)
|
|
660
|
+
# Byte 11: Speed percentage (0-100)
|
|
661
|
+
if len(data) >= 12:
|
|
662
|
+
state_byte = data[10]
|
|
663
|
+
speed = data[11]
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
"state": state_byte,
|
|
667
|
+
"speed": speed,
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return None
|