python-jebao 0.1.5__tar.gz → 0.1.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-jebao
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Python library for controlling Jebao aquarium pumps
5
5
  Home-page: https://github.com/jrigling/python-jebao
6
6
  Author: Justin Rigling
@@ -16,7 +16,7 @@ from .const import (
16
16
  MODEL_MD44,
17
17
  )
18
18
 
19
- __version__ = "0.1.5"
19
+ __version__ = "0.1.7"
20
20
 
21
21
  __all__ = [
22
22
  "JebaoDevice",
@@ -31,8 +31,12 @@ CONTROL_COMMAND_SIZE = 323
31
31
 
32
32
  # Product keys
33
33
  PRODUCT_KEY_MDP20000 = "04020B28"
34
+ PRODUCT_KEY_MDP20000_V2 = "04D30Q34" # Newer MDP-20000 hardware revision
34
35
  PRODUCT_KEY_MD44 = "04020B27" # Assumed, verify if needed
35
36
 
37
+ # All known product keys that identify an MDP-20000 (across hardware revisions)
38
+ PRODUCT_KEYS_MDP20000 = frozenset({PRODUCT_KEY_MDP20000, PRODUCT_KEY_MDP20000_V2})
39
+
36
40
  # Model identifiers
37
41
  MODEL_MDP20000 = "MDP-20000"
38
42
  MODEL_MD44 = "MD-4.4"
@@ -12,7 +12,7 @@ from .const import (
12
12
  MODEL_MD44,
13
13
  MODEL_MDP20000,
14
14
  PRODUCT_KEY_MD44,
15
- PRODUCT_KEY_MDP20000,
15
+ PRODUCT_KEYS_MDP20000,
16
16
  UDP_DISCOVERY_PORT,
17
17
  UDP_LISTEN_PORT,
18
18
  )
@@ -357,7 +357,7 @@ class JebaoDiscovery:
357
357
  product_key = data[product_key_start : product_key_start + 8].rstrip(b"\x00").decode("ascii")
358
358
 
359
359
  # Determine model from product key
360
- if product_key == PRODUCT_KEY_MDP20000:
360
+ if product_key in PRODUCT_KEYS_MDP20000:
361
361
  model = MODEL_MDP20000
362
362
  elif product_key == PRODUCT_KEY_MD44:
363
363
  model = MODEL_MD44
@@ -530,16 +530,22 @@ class JebaoProtocol:
530
530
  return bytes(buffer)
531
531
 
532
532
  async def _drain_garbage_bytes(self, timeout: float = 0.1) -> None:
533
- """Drain any garbage/padding bytes from the socket buffer.
533
+ """Drain leftover bytes and unsolicited messages from the socket buffer.
534
534
 
535
- The MDP-20000 pump appears to send extra padding bytes (0xee) after
536
- valid responses. This method drains those bytes to prevent them from
537
- corrupting subsequent reads.
535
+ The MDP-20000 pump emits two things we need to consume before a request:
536
+ - Padding bytes (0xee / 0x00) trailing some responses
537
+ - Unsolicited type 0x00 status broadcasts (header + body + 129-byte padding)
538
+
539
+ If an unsolicited type 0x00 frame is found and we only consume its body,
540
+ the trailing 129-byte padding remains in the buffer and corrupts the next
541
+ read (forcing a slow byte-by-byte resync that often races the pump's
542
+ connection close, surfacing as IncompleteReadError).
538
543
 
539
544
  Args:
540
- timeout: How long to wait for garbage bytes (default 0.1s)
545
+ timeout: How long to wait for bytes (default 0.1s)
541
546
  """
542
- if not self._reader:
547
+ reader = self._reader
548
+ if reader is None:
543
549
  return
544
550
 
545
551
  try:
@@ -548,7 +554,7 @@ class JebaoProtocol:
548
554
  try:
549
555
  # Try to read header of next message with short timeout
550
556
  header = await asyncio.wait_for(
551
- self._reader.read(5), timeout=timeout
557
+ reader.read(5), timeout=timeout
552
558
  )
553
559
  if not header:
554
560
  # No more data
@@ -559,20 +565,21 @@ class JebaoProtocol:
559
565
  continue
560
566
  # Check if it's a valid header
561
567
  if header[0:4] == b"\x00\x00\x00\x03":
562
- # Valid header found - this is a real message!
563
- # Put it back somehow... actually, we can't put it back easily
564
- # This is a problem - we've consumed a valid message header
565
- _LOGGER.warning(
566
- "Found valid header while draining - this shouldn't happen! "
567
- "Pump may have sent unsolicited message."
568
- )
569
- # Read the rest of this message to consume it
568
+ # Pump sent an unsolicited message (e.g. periodic status broadcast).
569
+ # Consume the full frame so we don't desync the stream.
570
570
  length = header[4]
571
- remaining = await self._reader.read(length)
572
- _LOGGER.warning(
573
- "Consumed unsolicited message: type=0x%02x, %d bytes total",
574
- header[7] if len(header) > 7 else 0,
575
- len(header) + len(remaining),
571
+ body = await reader.readexactly(length)
572
+ msg_type = body[2] if len(body) > 2 else 0
573
+ total = len(header) + len(body)
574
+ # Type 0x00 messages carry an extra 129-byte padding frame
575
+ # (same as _read_raw). Skip it or stale bytes corrupt the next read.
576
+ if msg_type == MSG_EXTENDED_DATA:
577
+ padding = await reader.readexactly(129)
578
+ total += len(padding)
579
+ _LOGGER.debug(
580
+ "Drained unsolicited message: type=0x%02x, %d bytes total",
581
+ msg_type,
582
+ total,
576
583
  )
577
584
  else:
578
585
  # Garbage header - log and continue draining
@@ -612,15 +619,21 @@ class JebaoProtocol:
612
619
  Raises:
613
620
  JebaoConnectionError: Read failed
614
621
  """
615
- if not self._reader:
616
- raise JebaoConnectionError("Not connected")
617
-
618
622
  async with self._read_lock:
623
+ # Bind the reader locally under the lock. disconnect() (called from
624
+ # our own IncompleteReadError handler, from _send_raw's error path,
625
+ # and from the coordinator) sets self._reader = None from another
626
+ # task; re-reading self._reader across the awaits below would raise
627
+ # AttributeError. Reading from a captured, closed StreamReader raises
628
+ # IncompleteReadError/ConnectionResetError instead, which we handle.
629
+ reader = self._reader
630
+ if reader is None:
631
+ raise JebaoConnectionError("Not connected")
619
632
  try:
620
633
  # Read header (4 bytes) + length byte (1 byte) = 5 bytes total
621
634
  # Format: 00 00 00 03 [LENGTH]
622
635
  _LOGGER.debug("Reading next message header...")
623
- header = await self._reader.readexactly(5)
636
+ header = await reader.readexactly(5)
624
637
 
625
638
  # Validate header - if invalid, try to re-sync
626
639
  max_resync_attempts = 50
@@ -644,7 +657,7 @@ class JebaoProtocol:
644
657
  max_resync_attempts,
645
658
  )
646
659
  # Shift header left by 1 byte and read 1 new byte
647
- next_byte = await self._reader.readexactly(1)
660
+ next_byte = await reader.readexactly(1)
648
661
  header = header[1:] + next_byte
649
662
  else:
650
663
  # Failed to re-sync after max attempts
@@ -656,7 +669,7 @@ class JebaoProtocol:
656
669
  _LOGGER.debug(f"Valid header found, reading {length} more bytes")
657
670
 
658
671
  # Read the remaining bytes based on length field
659
- remaining = await self._reader.readexactly(length)
672
+ remaining = await reader.readexactly(length)
660
673
 
661
674
  result = header + remaining
662
675
  msg_type = result[7] if len(result) > 7 else 0
@@ -666,7 +679,7 @@ class JebaoProtocol:
666
679
  if msg_type == MSG_EXTENDED_DATA:
667
680
  # Read fixed padding (129 bytes) that pump sends for extended responses
668
681
  # The pump uses fixed-frame protocol with padding (0xee or 0x00)
669
- padding = await self._reader.readexactly(129)
682
+ padding = await reader.readexactly(129)
670
683
 
671
684
  # Validate padding - should be mostly 0xee or 0x00 patterns
672
685
  # Check first 4 bytes and last byte
@@ -701,8 +714,15 @@ class JebaoProtocol:
701
714
  f"Message complete: {len(result)} bytes, type=0x{msg_type:02x}"
702
715
  )
703
716
  return result
704
- except asyncio.IncompleteReadError as err:
705
- _LOGGER.error("%s IncompleteReadError - connection closed by peer", self.device_identifier)
717
+ except (asyncio.IncompleteReadError, ConnectionResetError) as err:
718
+ # Expected when the pump drops an idle connection. The reconnect
719
+ # is logged by the coordinator/retry layer, so keep this at debug
720
+ # to avoid flooding the log every poll.
721
+ _LOGGER.debug(
722
+ "%s Connection closed by peer (%s) - will reconnect",
723
+ self.device_identifier,
724
+ type(err).__name__,
725
+ )
706
726
  # Mark connection as closed so reconnection can happen
707
727
  await self.disconnect()
708
728
  raise JebaoConnectionError("Connection closed") from err
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-jebao"
7
- version = "0.1.5"
7
+ version = "0.1.7"
8
8
  description = "Python library for controlling Jebao aquarium pumps"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-jebao
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Python library for controlling Jebao aquarium pumps
5
5
  Home-page: https://github.com/jrigling/python-jebao
6
6
  Author: Justin Rigling
File without changes
File without changes
File without changes
File without changes