python-jebao 0.1.1__tar.gz → 0.1.3__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.1
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
@@ -16,7 +16,7 @@ from .const import (
16
16
  MODEL_MD44,
17
17
  )
18
18
 
19
- __version__ = "0.1.1"
19
+ __version__ = "0.1.3"
20
20
 
21
21
  __all__ = [
22
22
  "JebaoDevice",
@@ -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,35 +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
- # 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
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
379
388
 
380
- _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]}...")
381
390
 
382
- # 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
383
392
 
384
- if len(response) < 10:
385
- 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)")
386
395
 
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'}")
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'}")
390
399
 
391
- return response
400
+ return response
392
401
 
393
402
  async def send_control_command(
394
403
  self,
@@ -412,48 +421,48 @@ class JebaoProtocol:
412
421
  JebaoCommandError: Command failed
413
422
  JebaoTimeoutError: Command timed out
414
423
  """
415
- if not self.is_connected:
416
- 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")
417
428
 
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))
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)
432
+
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)
438
442
 
439
- # After ACK, pump may send unsolicited status update
440
- # Try to read it with short timeout
443
+ # Read ACK (message type 0x94) directly
444
+ # The pump sends an ACK for control commands
441
445
  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")
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))
453
449
 
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
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
457
466
 
458
467
  def _build_control_command(
459
468
  self, opcode1: int, opcode2: int, param1: int, param2: int
@@ -580,6 +589,8 @@ class JebaoProtocol:
580
589
  self._writer.write(data)
581
590
  await self._writer.drain()
582
591
  except Exception as err:
592
+ _LOGGER.error("Send failed, closing connection: %s", err)
593
+ await self.disconnect()
583
594
  raise JebaoConnectionError(f"Send failed: {err}") from err
584
595
 
585
596
  async def _read_raw(self) -> bytes:
@@ -606,10 +617,17 @@ class JebaoProtocol:
606
617
  for attempt in range(max_resync_attempts):
607
618
  if header[0:4] == b"\x00\x00\x00\x03":
608
619
  # Valid header found
620
+ if attempt > 0:
621
+ # Log when we had to re-sync (shouldn't happen often now)
622
+ _LOGGER.warning(
623
+ "Had to re-sync message stream after %d attempt(s) - "
624
+ "garbage bytes not properly drained",
625
+ attempt
626
+ )
609
627
  break
610
628
 
611
629
  # Invalid header - try to re-sync by reading one more byte
612
- _LOGGER.warning(
630
+ _LOGGER.debug(
613
631
  "Invalid header %s, attempting re-sync (attempt %d/%d)",
614
632
  header[0:4].hex(),
615
633
  attempt + 1,
@@ -632,12 +650,51 @@ class JebaoProtocol:
632
650
 
633
651
  result = header + remaining
634
652
  msg_type = result[7] if len(result) > 7 else 0
653
+
654
+ # Only message type 0x00 (extended status responses) has 129-byte padding
655
+ # All other message types have no padding
656
+ if msg_type == MSG_EXTENDED_DATA:
657
+ # Read fixed padding (129 bytes) that pump sends for extended responses
658
+ # The pump uses fixed-frame protocol with padding (0xee or 0x00)
659
+ padding = await self._reader.readexactly(129)
660
+
661
+ # Validate padding - should be mostly 0xee or 0x00 patterns
662
+ # Check first 4 bytes and last byte
663
+ padding_start = padding[0:4]
664
+ padding_end = padding[-1]
665
+
666
+ # Valid patterns: eeeeeeee or 00000000 at start, 00 at end
667
+ is_valid_padding = (
668
+ (padding_start == b'\xee\xee\xee\xee' or padding_start == b'\x00\x00\x00\x00')
669
+ and padding_end == 0x00
670
+ )
671
+
672
+ if not is_valid_padding:
673
+ _LOGGER.error(
674
+ "Invalid padding detected! Start: %s, End: 0x%02x - possible protocol corruption",
675
+ padding_start.hex(),
676
+ padding_end
677
+ )
678
+ # Drain any additional garbage as fallback
679
+ await self._drain_garbage_bytes(timeout=0.05)
680
+ raise JebaoConnectionError(
681
+ f"Protocol error: invalid padding (start={padding_start.hex()}, end=0x{padding_end:02x})"
682
+ )
683
+
684
+ _LOGGER.debug(
685
+ "Read 129-byte padding frame for msg type 0x00 (start: %s, end: 0x%02x)",
686
+ padding_start.hex(),
687
+ padding_end
688
+ )
689
+
635
690
  _LOGGER.debug(
636
691
  f"Message complete: {len(result)} bytes, type=0x{msg_type:02x}"
637
692
  )
638
693
  return result
639
694
  except asyncio.IncompleteReadError as err:
640
695
  _LOGGER.error("IncompleteReadError - connection closed by peer")
696
+ # Mark connection as closed so reconnection can happen
697
+ await self.disconnect()
641
698
  raise JebaoConnectionError("Connection closed") from err
642
699
  except JebaoConnectionError:
643
700
  raise
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-jebao"
7
- version = "0.1.1"
7
+ version = "0.1.3"
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.1
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
File without changes
File without changes
File without changes
File without changes