python-jebao 0.1.2__py3-none-any.whl → 0.1.3__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 CHANGED
@@ -16,7 +16,7 @@ from .const import (
16
16
  MODEL_MD44,
17
17
  )
18
18
 
19
- __version__ = "0.1.2"
19
+ __version__ = "0.1.3"
20
20
 
21
21
  __all__ = [
22
22
  "JebaoDevice",
jebao/protocol.py CHANGED
@@ -55,7 +55,10 @@ class JebaoProtocol:
55
55
  self._sequence = 1
56
56
  self._keepalive_task: Optional[asyncio.Task] = None
57
57
  self._passcode: Optional[bytes] = None
58
- self._read_lock = asyncio.Lock()
58
+
59
+ # Locks for thread-safe operation
60
+ self._request_lock = asyncio.Lock() # Serialize entire request-response cycles
61
+ self._read_lock = asyncio.Lock() # Low-level read serialization
59
62
 
60
63
  # Message dispatcher
61
64
  self._message_queues: Dict[int, asyncio.Queue] = {}
@@ -360,39 +363,41 @@ class JebaoProtocol:
360
363
  JebaoConnectionError: Not connected
361
364
  JebaoTimeoutError: Request timed out
362
365
  """
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
366
+ # Serialize requests - only one operation at a time
367
+ async with self._request_lock:
368
+ if not self.is_connected:
369
+ raise JebaoConnectionError("Not connected")
370
+
371
+ # Drain any unexpected leftover bytes as safety net (very short timeout)
372
+ # Normally padding is read explicitly, but this catches edge cases
373
+ await self._drain_garbage_bytes(timeout=0.01)
374
+
375
+ # Send simple status request (0x90)
376
+ # Format: 00 00 00 03 04 00 00 90 02
377
+ # The last byte (02) requests values, and response will be message type 91 or 0x00
378
+ request = bytes.fromhex("0000000304000090 02")
379
+ _LOGGER.debug(f"Sending status request: {request.hex()}")
380
+ await self._send_raw(request)
381
+ _LOGGER.debug("Status request sent, waiting for response...")
382
+
383
+ # Read response directly (no dispatcher)
384
+ try:
385
+ response = await asyncio.wait_for(self._read_raw(), timeout=timeout)
386
+ except asyncio.TimeoutError as err:
387
+ raise JebaoTimeoutError("Status request timed out") from err
383
388
 
384
- _LOGGER.debug(f"Status response length: {len(response)}, data: {response.hex()[:100]}...")
389
+ _LOGGER.debug(f"Status response length: {len(response)}, data: {response.hex()[:100]}...")
385
390
 
386
- # Note: Don't drain here - pump may send unsolicited updates that we shouldn't consume
391
+ # Note: Don't drain here - pump may send unsolicited updates that we shouldn't consume
387
392
 
388
- if len(response) < 10:
389
- raise JebaoCommandError(f"Invalid status response: too short ({len(response)} bytes)")
393
+ if len(response) < 10:
394
+ raise JebaoCommandError(f"Invalid status response: too short ({len(response)} bytes)")
390
395
 
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'}")
396
+ # Log the message type and first few bytes for debugging
397
+ msg_type = response[7] if len(response) > 7 else 0
398
+ _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
399
 
395
- return response
400
+ return response
396
401
 
397
402
  async def send_control_command(
398
403
  self,
@@ -416,46 +421,48 @@ class JebaoProtocol:
416
421
  JebaoCommandError: Command failed
417
422
  JebaoTimeoutError: Command timed out
418
423
  """
419
- if not self.is_connected:
420
- raise JebaoConnectionError("Not connected")
424
+ # Serialize requests - only one operation at a time
425
+ async with self._request_lock:
426
+ if not self.is_connected:
427
+ raise JebaoConnectionError("Not connected")
421
428
 
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))
429
+ # Drain any unexpected leftover bytes as safety net (very short timeout)
430
+ # Normally padding is read explicitly, but this catches edge cases
431
+ await self._drain_garbage_bytes(timeout=0.01)
442
432
 
443
- # After ACK, pump may send unsolicited status update
444
- # Try to read it with short timeout
433
+ command = self._build_control_command(opcode1, opcode2, param1, param2)
434
+ _LOGGER.debug(
435
+ "Sending control command: opcode1=0x%02x, opcode2=0x%02x, param1=%d, param2=%d",
436
+ opcode1,
437
+ opcode2,
438
+ param1,
439
+ param2,
440
+ )
441
+ await self._send_raw(command)
442
+
443
+ # Read ACK (message type 0x94) directly
444
+ # The pump sends an ACK for control commands
445
445
  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")
446
+ ack = await asyncio.wait_for(self._read_raw(), timeout=timeout)
447
+ ack_type = ack[7] if len(ack) > 7 else 0
448
+ _LOGGER.debug("Control command ACK received: type=0x%02x, %d bytes", ack_type, len(ack))
455
449
 
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
450
+ # After ACK, pump may send unsolicited status update
451
+ # Try to read it with short timeout
452
+ try:
453
+ status_update = await asyncio.wait_for(self._read_raw(), timeout=0.5)
454
+ status_type = status_update[7] if len(status_update) > 7 else 0
455
+ _LOGGER.info(
456
+ "Pump sent unsolicited status update after control command: type=0x%02x",
457
+ status_type,
458
+ )
459
+ except asyncio.TimeoutError:
460
+ # No status update sent, that's OK
461
+ _LOGGER.debug("No unsolicited status update after control command")
462
+
463
+ except asyncio.TimeoutError:
464
+ _LOGGER.warning("Control command sent but no ACK received (pump may still process it)")
465
+ # Don't raise - pump may still process the command even without ACK
459
466
 
460
467
  def _build_control_command(
461
468
  self, opcode1: int, opcode2: int, param1: int, param2: int
@@ -582,6 +589,8 @@ class JebaoProtocol:
582
589
  self._writer.write(data)
583
590
  await self._writer.drain()
584
591
  except Exception as err:
592
+ _LOGGER.error("Send failed, closing connection: %s", err)
593
+ await self.disconnect()
585
594
  raise JebaoConnectionError(f"Send failed: {err}") from err
586
595
 
587
596
  async def _read_raw(self) -> bytes:
@@ -684,6 +693,8 @@ class JebaoProtocol:
684
693
  return result
685
694
  except asyncio.IncompleteReadError as err:
686
695
  _LOGGER.error("IncompleteReadError - connection closed by peer")
696
+ # Mark connection as closed so reconnection can happen
697
+ await self.disconnect()
687
698
  raise JebaoConnectionError("Connection closed") from err
688
699
  except JebaoConnectionError:
689
700
  raise
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-jebao
3
- Version: 0.1.2
3
+ Version: 0.1.3
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
@@ -1,13 +1,13 @@
1
- jebao/__init__.py,sha256=B4uCc765FfQ5YpQQNos5ioIJXxSNSzNK9RbV4nbkA_c,761
1
+ jebao/__init__.py,sha256=9Mf_OC0RsIO7h1LM6Iq14TXPIcimyKei1E3jW11ES2A,761
2
2
  jebao/const.py,sha256=KasF1RntYAwC_2Gv4wmF4VE4OQgTzQ5UAYugdvk8oBg,1621
3
3
  jebao/device.py,sha256=MCCi3uOqFhGQgj3QzwXda6RyyjvJNY5OsDdNcXxQaKg,4098
4
4
  jebao/discovery.py,sha256=nyXhu_BPXKzjF5lBeK2W62R_RtVhNnpcuulVvH20X9k,13365
5
5
  jebao/exceptions.py,sha256=S8jO0kxaw9l_TMdITgZm2gx-olJdGB3746tLB8UahJ0,501
6
6
  jebao/mdp20000.py,sha256=0asRj8VQWlXhIB2RI2yIvEhehw917wxWpKABISz4fJw,9603
7
- jebao/protocol.py,sha256=wh87RKoGlbbDKWbto5JwTX0E3QUrpoDCaQGhyid2h0E,27412
7
+ jebao/protocol.py,sha256=uKtTulgkAGWQk6TTdjSImhKNNjMX4rudG0bhn_uQs2M,28233
8
8
  jebao/retry.py,sha256=N9biedjTigFRVmVrEAE_iPmuTW2GZJrv8B_zMQ8hC_A,2646
9
- python_jebao-0.1.2.dist-info/licenses/LICENSE,sha256=xz5RpUswwNDP6Agh5an2XKA3BJfABxtPaeqPTDn1y2k,1071
10
- python_jebao-0.1.2.dist-info/METADATA,sha256=hrJLoYEHwfLqx7D_uBlybwlkH3sLRmbVqp06EnAZCEA,10143
11
- python_jebao-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- python_jebao-0.1.2.dist-info/top_level.txt,sha256=Bj9cYMUNXSj-Q_ViTIEIYJyfKOmnoywBAspe6ZprUkY,6
13
- python_jebao-0.1.2.dist-info/RECORD,,
9
+ python_jebao-0.1.3.dist-info/licenses/LICENSE,sha256=xz5RpUswwNDP6Agh5an2XKA3BJfABxtPaeqPTDn1y2k,1071
10
+ python_jebao-0.1.3.dist-info/METADATA,sha256=boJT41KUsn8QaEcEtqZHX2N1zLRF3DGNR4BH-peDxuQ,10143
11
+ python_jebao-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ python_jebao-0.1.3.dist-info/top_level.txt,sha256=Bj9cYMUNXSj-Q_ViTIEIYJyfKOmnoywBAspe6ZprUkY,6
13
+ python_jebao-0.1.3.dist-info/RECORD,,