python-jebao 0.1.3__tar.gz → 0.1.6__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.3
3
+ Version: 0.1.6
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.3"
19
+ __version__ = "0.1.6"
20
20
 
21
21
  __all__ = [
22
22
  "JebaoDevice",
@@ -34,11 +34,18 @@ class JebaoDevice:
34
34
  self.device_id = device_id
35
35
  self.model = model
36
36
 
37
- self._protocol = JebaoProtocol(host, port)
37
+ self._protocol = JebaoProtocol(host, port, device_id=device_id)
38
38
  self._state: Optional[DeviceState] = None
39
39
  self._speed: Optional[int] = None
40
40
  self._last_update: Optional[float] = None
41
41
 
42
+ @property
43
+ def device_identifier(self) -> str:
44
+ """Get device identifier for logging."""
45
+ if self.device_id:
46
+ return f"[{self.device_id}]"
47
+ return f"[{self.host}]"
48
+
42
49
  @property
43
50
  def is_connected(self) -> bool:
44
51
  """Check if device is connected."""
@@ -90,7 +97,7 @@ class JebaoDevice:
90
97
  JebaoAuthenticationError: Authentication failed
91
98
  """
92
99
  await self._protocol.connect(timeout)
93
- _LOGGER.info("Connected to %s (%s)", self.host, self.model or "Unknown")
100
+ _LOGGER.info("%s Connected (%s)", self.device_identifier, self.model or "Unknown")
94
101
 
95
102
  # Get initial status
96
103
  await self.update()
@@ -98,7 +105,7 @@ class JebaoDevice:
98
105
  async def disconnect(self) -> None:
99
106
  """Disconnect from device."""
100
107
  await self._protocol.disconnect()
101
- _LOGGER.info("Disconnected from %s", self.host)
108
+ _LOGGER.info("%s Disconnected", self.device_identifier)
102
109
 
103
110
  @async_retry(max_attempts=3, delay=0.5)
104
111
  async def update(self, timeout: float = DEFAULT_TIMEOUT) -> None:
@@ -124,12 +131,13 @@ class JebaoDevice:
124
131
  self._speed = status["speed"]
125
132
  self._last_update = time.time()
126
133
  _LOGGER.debug(
127
- "Status updated: state=%s, speed=%d%%",
134
+ "%s Status updated: state=%s, speed=%d%%",
135
+ self.device_identifier,
128
136
  self._state.name,
129
137
  self._speed,
130
138
  )
131
139
  else:
132
- _LOGGER.warning("Failed to parse status response")
140
+ _LOGGER.warning("%s Failed to parse status response", self.device_identifier)
133
141
 
134
142
  async def __aenter__(self):
135
143
  """Async context manager entry."""
@@ -90,7 +90,7 @@ class MDP20000Device(JebaoDevice):
90
90
  This method automatically retries up to 3 times on transient
91
91
  failures (garbage bytes, connection issues).
92
92
  """
93
- _LOGGER.info("Turning on pump")
93
+ _LOGGER.info("%s Turning on pump", self.device_identifier)
94
94
 
95
95
  await self._protocol.send_control_command(
96
96
  CommandOpcode.TURN_ON_OFF,
@@ -115,7 +115,7 @@ class MDP20000Device(JebaoDevice):
115
115
  This method automatically retries up to 3 times on transient
116
116
  failures (garbage bytes, connection issues).
117
117
  """
118
- _LOGGER.info("Turning off pump")
118
+ _LOGGER.info("%s Turning off pump", self.device_identifier)
119
119
 
120
120
  await self._protocol.send_control_command(
121
121
  CommandOpcode.TURN_ON_OFF,
@@ -148,7 +148,7 @@ class MDP20000Device(JebaoDevice):
148
148
  f"Speed must be between {MIN_SPEED} and {MAX_SPEED}, got {percentage}"
149
149
  )
150
150
 
151
- _LOGGER.info("Setting speed to %d%%", percentage)
151
+ _LOGGER.info("%s Setting speed to %d%%", self.device_identifier, percentage)
152
152
 
153
153
  await self._protocol.send_control_command(
154
154
  CommandOpcode.SET_SPEED,
@@ -41,15 +41,17 @@ MSG_EXTENDED_DATA = 0x00
41
41
  class JebaoProtocol:
42
42
  """Low-level protocol implementation for Jebao devices."""
43
43
 
44
- def __init__(self, host: str, port: int = 12416):
44
+ def __init__(self, host: str, port: int = 12416, device_id: Optional[str] = None):
45
45
  """Initialize protocol handler.
46
46
 
47
47
  Args:
48
48
  host: Device IP address
49
49
  port: TCP port (default 12416)
50
+ device_id: Device ID for logging (optional)
50
51
  """
51
52
  self.host = host
52
53
  self.port = port
54
+ self.device_id = device_id
53
55
  self._reader: Optional[asyncio.StreamReader] = None
54
56
  self._writer: Optional[asyncio.StreamWriter] = None
55
57
  self._sequence = 1
@@ -68,6 +70,13 @@ class JebaoProtocol:
68
70
  # Read buffer for handling garbage bytes
69
71
  self._read_buffer = bytearray()
70
72
 
73
+ @property
74
+ def device_identifier(self) -> str:
75
+ """Get device identifier for logging."""
76
+ if self.device_id:
77
+ return f"[{self.device_id}]"
78
+ return f"[{self.host}]"
79
+
71
80
  @property
72
81
  def is_connected(self) -> bool:
73
82
  """Check if connection is established."""
@@ -85,7 +94,7 @@ class JebaoProtocol:
85
94
  JebaoTimeoutError: Operation timed out
86
95
  """
87
96
  try:
88
- _LOGGER.debug("Connecting to %s:%d", self.host, self.port)
97
+ _LOGGER.debug("%s Connecting to %s:%d", self.device_identifier, self.host, self.port)
89
98
  self._reader, self._writer = await asyncio.wait_for(
90
99
  asyncio.open_connection(self.host, self.port), timeout=timeout
91
100
  )
@@ -117,7 +126,7 @@ class JebaoProtocol:
117
126
  Raises:
118
127
  JebaoAuthenticationError: Authentication failed
119
128
  """
120
- _LOGGER.debug("Starting authentication")
129
+ _LOGGER.debug("%s Starting authentication", self.device_identifier)
121
130
 
122
131
  # Step 1: Request passcode
123
132
  request = self._build_passcode_request()
@@ -161,7 +170,7 @@ class JebaoProtocol:
161
170
  _LOGGER.error(f"Login failed - expected type 0x{MSG_LOGIN_SUCCESS:02x} at byte 7, got 0x{response[7]:02x}")
162
171
  raise JebaoAuthenticationError("Login failed")
163
172
 
164
- _LOGGER.info("Authentication successful for %s", self.host)
173
+ _LOGGER.info("%s Authentication successful", self.device_identifier)
165
174
 
166
175
  async def disconnect(self) -> None:
167
176
  """Disconnect from device."""
@@ -188,7 +197,7 @@ class JebaoProtocol:
188
197
  self._writer = None
189
198
  self._reader = None
190
199
 
191
- _LOGGER.debug("Disconnected from %s", self.host)
200
+ _LOGGER.debug("%s Disconnected", self.device_identifier)
192
201
 
193
202
  def _start_keepalive(self) -> None:
194
203
  """Start keepalive task."""
@@ -432,7 +441,8 @@ class JebaoProtocol:
432
441
 
433
442
  command = self._build_control_command(opcode1, opcode2, param1, param2)
434
443
  _LOGGER.debug(
435
- "Sending control command: opcode1=0x%02x, opcode2=0x%02x, param1=%d, param2=%d",
444
+ "%s Sending control command: opcode1=0x%02x, opcode2=0x%02x, param1=%d, param2=%d",
445
+ self.device_identifier,
436
446
  opcode1,
437
447
  opcode2,
438
448
  param1,
@@ -520,14 +530,19 @@ class JebaoProtocol:
520
530
  return bytes(buffer)
521
531
 
522
532
  async def _drain_garbage_bytes(self, timeout: float = 0.1) -> None:
523
- """Drain any garbage/padding bytes from the socket buffer.
533
+ """Drain leftover bytes and unsolicited messages from the socket buffer.
524
534
 
525
- The MDP-20000 pump appears to send extra padding bytes (0xee) after
526
- valid responses. This method drains those bytes to prevent them from
527
- 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).
528
543
 
529
544
  Args:
530
- timeout: How long to wait for garbage bytes (default 0.1s)
545
+ timeout: How long to wait for bytes (default 0.1s)
531
546
  """
532
547
  if not self._reader:
533
548
  return
@@ -549,20 +564,21 @@ class JebaoProtocol:
549
564
  continue
550
565
  # Check if it's a valid header
551
566
  if header[0:4] == b"\x00\x00\x00\x03":
552
- # Valid header found - this is a real message!
553
- # Put it back somehow... actually, we can't put it back easily
554
- # This is a problem - we've consumed a valid message header
555
- _LOGGER.warning(
556
- "Found valid header while draining - this shouldn't happen! "
557
- "Pump may have sent unsolicited message."
558
- )
559
- # Read the rest of this message to consume it
567
+ # Pump sent an unsolicited message (e.g. periodic status broadcast).
568
+ # Consume the full frame so we don't desync the stream.
560
569
  length = header[4]
561
- remaining = await self._reader.read(length)
562
- _LOGGER.warning(
563
- "Consumed unsolicited message: type=0x%02x, %d bytes total",
564
- header[7] if len(header) > 7 else 0,
565
- len(header) + len(remaining),
570
+ body = await self._reader.readexactly(length)
571
+ msg_type = body[2] if len(body) > 2 else 0
572
+ total = len(header) + len(body)
573
+ # Type 0x00 messages carry an extra 129-byte padding frame
574
+ # (same as _read_raw). Skip it or stale bytes corrupt the next read.
575
+ if msg_type == MSG_EXTENDED_DATA:
576
+ padding = await self._reader.readexactly(129)
577
+ total += len(padding)
578
+ _LOGGER.debug(
579
+ "Drained unsolicited message: type=0x%02x, %d bytes total",
580
+ msg_type,
581
+ total,
566
582
  )
567
583
  else:
568
584
  # Garbage header - log and continue draining
@@ -692,7 +708,7 @@ class JebaoProtocol:
692
708
  )
693
709
  return result
694
710
  except asyncio.IncompleteReadError as err:
695
- _LOGGER.error("IncompleteReadError - connection closed by peer")
711
+ _LOGGER.error("%s IncompleteReadError - connection closed by peer", self.device_identifier)
696
712
  # Mark connection as closed so reconnection can happen
697
713
  await self.disconnect()
698
714
  raise JebaoConnectionError("Connection closed") from err
@@ -41,27 +41,57 @@ def async_retry(
41
41
  return await func(*args, **kwargs)
42
42
  except exceptions as e:
43
43
  if attempt == max_attempts:
44
+ device_id = ""
45
+ if args and hasattr(args[0], 'device_identifier'):
46
+ device_id = args[0].device_identifier + " "
44
47
  _LOGGER.error(
45
- f"{func.__name__} failed after {max_attempts} attempts: {e}"
48
+ f"{device_id}{func.__name__} failed after {max_attempts} attempts: {e}"
46
49
  )
47
50
  raise
48
51
 
49
- # Check if error message indicates garbage issue
52
+ # Check if error message indicates specific issues
50
53
  error_msg = str(e).lower()
51
54
  is_garbage_issue = (
52
55
  "synchronization" in error_msg
53
56
  or "garbage" in error_msg
54
57
  or "invalid header" in error_msg
55
58
  )
59
+ is_not_connected = "not connected" in error_msg or "connection closed" in error_msg
60
+
61
+ # Attempt reconnection if disconnected
62
+ if is_not_connected and args and hasattr(args[0], 'connect'):
63
+ device = args[0]
64
+ # Check if device has is_connected property
65
+ if hasattr(device, 'is_connected') and not device.is_connected:
66
+ device_id = device.device_identifier if hasattr(device, 'device_identifier') else ""
67
+ _LOGGER.info(
68
+ f"{device_id} {func.__name__} attempt {attempt}/{max_attempts}: "
69
+ f"Connection lost, attempting to reconnect..."
70
+ )
71
+ try:
72
+ await device.connect()
73
+ _LOGGER.info(f"{device_id} Reconnected successfully, retrying operation")
74
+ # Don't sleep after successful reconnection - retry immediately
75
+ attempt += 1
76
+ continue
77
+ except Exception as reconnect_err:
78
+ _LOGGER.warning(
79
+ f"{device_id} Reconnection failed: {reconnect_err}, "
80
+ f"will retry in {current_delay}s"
81
+ )
82
+
83
+ device_id = ""
84
+ if args and hasattr(args[0], 'device_identifier'):
85
+ device_id = args[0].device_identifier + " "
56
86
 
57
87
  if is_garbage_issue:
58
88
  _LOGGER.warning(
59
- f"{func.__name__} attempt {attempt}/{max_attempts} failed "
89
+ f"{device_id}{func.__name__} attempt {attempt}/{max_attempts} failed "
60
90
  f"due to garbage bytes, retrying in {current_delay}s: {e}"
61
91
  )
62
92
  else:
63
93
  _LOGGER.warning(
64
- f"{func.__name__} attempt {attempt}/{max_attempts} failed, "
94
+ f"{device_id}{func.__name__} attempt {attempt}/{max_attempts} failed, "
65
95
  f"retrying in {current_delay}s: {e}"
66
96
  )
67
97
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-jebao"
7
- version = "0.1.3"
7
+ version = "0.1.6"
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.3
3
+ Version: 0.1.6
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