python-jebao 0.1.2__tar.gz → 0.1.5__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.2
3
+ Version: 0.1.5
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.2"
19
+ __version__ = "0.1.5"
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,21 +41,26 @@ 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
56
58
  self._keepalive_task: Optional[asyncio.Task] = None
57
59
  self._passcode: Optional[bytes] = None
58
- self._read_lock = asyncio.Lock()
60
+
61
+ # Locks for thread-safe operation
62
+ self._request_lock = asyncio.Lock() # Serialize entire request-response cycles
63
+ self._read_lock = asyncio.Lock() # Low-level read serialization
59
64
 
60
65
  # Message dispatcher
61
66
  self._message_queues: Dict[int, asyncio.Queue] = {}
@@ -65,6 +70,13 @@ class JebaoProtocol:
65
70
  # Read buffer for handling garbage bytes
66
71
  self._read_buffer = bytearray()
67
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
+
68
80
  @property
69
81
  def is_connected(self) -> bool:
70
82
  """Check if connection is established."""
@@ -82,7 +94,7 @@ class JebaoProtocol:
82
94
  JebaoTimeoutError: Operation timed out
83
95
  """
84
96
  try:
85
- _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)
86
98
  self._reader, self._writer = await asyncio.wait_for(
87
99
  asyncio.open_connection(self.host, self.port), timeout=timeout
88
100
  )
@@ -114,7 +126,7 @@ class JebaoProtocol:
114
126
  Raises:
115
127
  JebaoAuthenticationError: Authentication failed
116
128
  """
117
- _LOGGER.debug("Starting authentication")
129
+ _LOGGER.debug("%s Starting authentication", self.device_identifier)
118
130
 
119
131
  # Step 1: Request passcode
120
132
  request = self._build_passcode_request()
@@ -158,7 +170,7 @@ class JebaoProtocol:
158
170
  _LOGGER.error(f"Login failed - expected type 0x{MSG_LOGIN_SUCCESS:02x} at byte 7, got 0x{response[7]:02x}")
159
171
  raise JebaoAuthenticationError("Login failed")
160
172
 
161
- _LOGGER.info("Authentication successful for %s", self.host)
173
+ _LOGGER.info("%s Authentication successful", self.device_identifier)
162
174
 
163
175
  async def disconnect(self) -> None:
164
176
  """Disconnect from device."""
@@ -185,7 +197,7 @@ class JebaoProtocol:
185
197
  self._writer = None
186
198
  self._reader = None
187
199
 
188
- _LOGGER.debug("Disconnected from %s", self.host)
200
+ _LOGGER.debug("%s Disconnected", self.device_identifier)
189
201
 
190
202
  def _start_keepalive(self) -> None:
191
203
  """Start keepalive task."""
@@ -360,39 +372,41 @@ class JebaoProtocol:
360
372
  JebaoConnectionError: Not connected
361
373
  JebaoTimeoutError: Request timed out
362
374
  """
363
- if not self.is_connected:
364
- raise JebaoConnectionError("Not connected")
365
-
366
- # Drain any unexpected leftover bytes as safety net (very short timeout)
367
- # Normally padding is read explicitly, but this catches edge cases
368
- await self._drain_garbage_bytes(timeout=0.01)
369
-
370
- # Send simple status request (0x90)
371
- # Format: 00 00 00 03 04 00 00 90 02
372
- # The last byte (02) requests values, and response will be message type 91 or 0x00
373
- request = bytes.fromhex("0000000304000090 02")
374
- _LOGGER.debug(f"Sending status request: {request.hex()}")
375
- await self._send_raw(request)
376
- _LOGGER.debug("Status request sent, waiting for response...")
377
-
378
- # Read response directly (no dispatcher)
379
- try:
380
- response = await asyncio.wait_for(self._read_raw(), timeout=timeout)
381
- except asyncio.TimeoutError as err:
382
- raise JebaoTimeoutError("Status request timed out") from err
375
+ # Serialize requests - only one operation at a time
376
+ async with self._request_lock:
377
+ if not self.is_connected:
378
+ raise JebaoConnectionError("Not connected")
379
+
380
+ # Drain any unexpected leftover bytes as safety net (very short timeout)
381
+ # Normally padding is read explicitly, but this catches edge cases
382
+ await self._drain_garbage_bytes(timeout=0.01)
383
+
384
+ # Send simple status request (0x90)
385
+ # Format: 00 00 00 03 04 00 00 90 02
386
+ # The last byte (02) requests values, and response will be message type 91 or 0x00
387
+ request = bytes.fromhex("0000000304000090 02")
388
+ _LOGGER.debug(f"Sending status request: {request.hex()}")
389
+ await self._send_raw(request)
390
+ _LOGGER.debug("Status request sent, waiting for response...")
391
+
392
+ # Read response directly (no dispatcher)
393
+ try:
394
+ response = await asyncio.wait_for(self._read_raw(), timeout=timeout)
395
+ except asyncio.TimeoutError as err:
396
+ raise JebaoTimeoutError("Status request timed out") from err
383
397
 
384
- _LOGGER.debug(f"Status response length: {len(response)}, data: {response.hex()[:100]}...")
398
+ _LOGGER.debug(f"Status response length: {len(response)}, data: {response.hex()[:100]}...")
385
399
 
386
- # Note: Don't drain here - pump may send unsolicited updates that we shouldn't consume
400
+ # Note: Don't drain here - pump may send unsolicited updates that we shouldn't consume
387
401
 
388
- if len(response) < 10:
389
- raise JebaoCommandError(f"Invalid status response: too short ({len(response)} bytes)")
402
+ if len(response) < 10:
403
+ raise JebaoCommandError(f"Invalid status response: too short ({len(response)} bytes)")
390
404
 
391
- # Log the message type and first few bytes for debugging
392
- msg_type = response[7] if len(response) > 7 else 0
393
- _LOGGER.debug(f"Status response type: 0x{msg_type:02x}, bytes 10-12: {response[10:12].hex() if len(response) > 11 else 'N/A'}")
405
+ # Log the message type and first few bytes for debugging
406
+ msg_type = response[7] if len(response) > 7 else 0
407
+ _LOGGER.debug(f"Status response type: 0x{msg_type:02x}, bytes 10-12: {response[10:12].hex() if len(response) > 11 else 'N/A'}")
394
408
 
395
- return response
409
+ return response
396
410
 
397
411
  async def send_control_command(
398
412
  self,
@@ -416,46 +430,49 @@ class JebaoProtocol:
416
430
  JebaoCommandError: Command failed
417
431
  JebaoTimeoutError: Command timed out
418
432
  """
419
- if not self.is_connected:
420
- raise JebaoConnectionError("Not connected")
433
+ # Serialize requests - only one operation at a time
434
+ async with self._request_lock:
435
+ if not self.is_connected:
436
+ raise JebaoConnectionError("Not connected")
421
437
 
422
- # Drain any unexpected leftover bytes as safety net (very short timeout)
423
- # Normally padding is read explicitly, but this catches edge cases
424
- await self._drain_garbage_bytes(timeout=0.01)
425
-
426
- command = self._build_control_command(opcode1, opcode2, param1, param2)
427
- _LOGGER.debug(
428
- "Sending control command: opcode1=0x%02x, opcode2=0x%02x, param1=%d, param2=%d",
429
- opcode1,
430
- opcode2,
431
- param1,
432
- param2,
433
- )
434
- await self._send_raw(command)
435
-
436
- # Read ACK (message type 0x94) directly
437
- # The pump sends an ACK for control commands
438
- try:
439
- ack = await asyncio.wait_for(self._read_raw(), timeout=timeout)
440
- ack_type = ack[7] if len(ack) > 7 else 0
441
- _LOGGER.debug("Control command ACK received: type=0x%02x, %d bytes", ack_type, len(ack))
438
+ # Drain any unexpected leftover bytes as safety net (very short timeout)
439
+ # Normally padding is read explicitly, but this catches edge cases
440
+ await self._drain_garbage_bytes(timeout=0.01)
441
+
442
+ command = self._build_control_command(opcode1, opcode2, param1, param2)
443
+ _LOGGER.debug(
444
+ "%s Sending control command: opcode1=0x%02x, opcode2=0x%02x, param1=%d, param2=%d",
445
+ self.device_identifier,
446
+ opcode1,
447
+ opcode2,
448
+ param1,
449
+ param2,
450
+ )
451
+ await self._send_raw(command)
442
452
 
443
- # After ACK, pump may send unsolicited status update
444
- # Try to read it with short timeout
453
+ # Read ACK (message type 0x94) directly
454
+ # The pump sends an ACK for control commands
445
455
  try:
446
- status_update = await asyncio.wait_for(self._read_raw(), timeout=0.5)
447
- status_type = status_update[7] if len(status_update) > 7 else 0
448
- _LOGGER.info(
449
- "Pump sent unsolicited status update after control command: type=0x%02x",
450
- status_type,
451
- )
452
- except asyncio.TimeoutError:
453
- # No status update sent, that's OK
454
- _LOGGER.debug("No unsolicited status update after control command")
456
+ ack = await asyncio.wait_for(self._read_raw(), timeout=timeout)
457
+ ack_type = ack[7] if len(ack) > 7 else 0
458
+ _LOGGER.debug("Control command ACK received: type=0x%02x, %d bytes", ack_type, len(ack))
455
459
 
456
- except asyncio.TimeoutError:
457
- _LOGGER.warning("Control command sent but no ACK received (pump may still process it)")
458
- # Don't raise - pump may still process the command even without ACK
460
+ # After ACK, pump may send unsolicited status update
461
+ # Try to read it with short timeout
462
+ try:
463
+ status_update = await asyncio.wait_for(self._read_raw(), timeout=0.5)
464
+ status_type = status_update[7] if len(status_update) > 7 else 0
465
+ _LOGGER.info(
466
+ "Pump sent unsolicited status update after control command: type=0x%02x",
467
+ status_type,
468
+ )
469
+ except asyncio.TimeoutError:
470
+ # No status update sent, that's OK
471
+ _LOGGER.debug("No unsolicited status update after control command")
472
+
473
+ except asyncio.TimeoutError:
474
+ _LOGGER.warning("Control command sent but no ACK received (pump may still process it)")
475
+ # Don't raise - pump may still process the command even without ACK
459
476
 
460
477
  def _build_control_command(
461
478
  self, opcode1: int, opcode2: int, param1: int, param2: int
@@ -582,6 +599,8 @@ class JebaoProtocol:
582
599
  self._writer.write(data)
583
600
  await self._writer.drain()
584
601
  except Exception as err:
602
+ _LOGGER.error("Send failed, closing connection: %s", err)
603
+ await self.disconnect()
585
604
  raise JebaoConnectionError(f"Send failed: {err}") from err
586
605
 
587
606
  async def _read_raw(self) -> bytes:
@@ -683,7 +702,9 @@ class JebaoProtocol:
683
702
  )
684
703
  return result
685
704
  except asyncio.IncompleteReadError as err:
686
- _LOGGER.error("IncompleteReadError - connection closed by peer")
705
+ _LOGGER.error("%s IncompleteReadError - connection closed by peer", self.device_identifier)
706
+ # Mark connection as closed so reconnection can happen
707
+ await self.disconnect()
687
708
  raise JebaoConnectionError("Connection closed") from err
688
709
  except JebaoConnectionError:
689
710
  raise
@@ -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.2"
7
+ version = "0.1.5"
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.2
3
+ Version: 0.1.5
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