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/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