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.
- {python_jebao-0.1.2 → python_jebao-0.1.5}/PKG-INFO +1 -1
- {python_jebao-0.1.2 → python_jebao-0.1.5}/jebao/__init__.py +1 -1
- {python_jebao-0.1.2 → python_jebao-0.1.5}/jebao/device.py +13 -5
- {python_jebao-0.1.2 → python_jebao-0.1.5}/jebao/mdp20000.py +3 -3
- {python_jebao-0.1.2 → python_jebao-0.1.5}/jebao/protocol.py +92 -71
- {python_jebao-0.1.2 → python_jebao-0.1.5}/jebao/retry.py +34 -4
- {python_jebao-0.1.2 → python_jebao-0.1.5}/pyproject.toml +1 -1
- {python_jebao-0.1.2 → python_jebao-0.1.5}/python_jebao.egg-info/PKG-INFO +1 -1
- {python_jebao-0.1.2 → python_jebao-0.1.5}/LICENSE +0 -0
- {python_jebao-0.1.2 → python_jebao-0.1.5}/README.md +0 -0
- {python_jebao-0.1.2 → python_jebao-0.1.5}/jebao/const.py +0 -0
- {python_jebao-0.1.2 → python_jebao-0.1.5}/jebao/discovery.py +0 -0
- {python_jebao-0.1.2 → python_jebao-0.1.5}/jebao/exceptions.py +0 -0
- {python_jebao-0.1.2 → python_jebao-0.1.5}/python_jebao.egg-info/SOURCES.txt +0 -0
- {python_jebao-0.1.2 → python_jebao-0.1.5}/python_jebao.egg-info/dependency_links.txt +0 -0
- {python_jebao-0.1.2 → python_jebao-0.1.5}/python_jebao.egg-info/requires.txt +0 -0
- {python_jebao-0.1.2 → python_jebao-0.1.5}/python_jebao.egg-info/top_level.txt +0 -0
- {python_jebao-0.1.2 → python_jebao-0.1.5}/setup.cfg +0 -0
- {python_jebao-0.1.2 → python_jebao-0.1.5}/setup.py +0 -0
|
@@ -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("
|
|
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("
|
|
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
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
response
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
398
|
+
_LOGGER.debug(f"Status response length: {len(response)}, data: {response.hex()[:100]}...")
|
|
385
399
|
|
|
386
|
-
|
|
400
|
+
# Note: Don't drain here - pump may send unsolicited updates that we shouldn't consume
|
|
387
401
|
|
|
388
|
-
|
|
389
|
-
|
|
402
|
+
if len(response) < 10:
|
|
403
|
+
raise JebaoCommandError(f"Invalid status response: too short ({len(response)} bytes)")
|
|
390
404
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
#
|
|
444
|
-
#
|
|
453
|
+
# Read ACK (message type 0x94) directly
|
|
454
|
+
# The pump sends an ACK for control commands
|
|
445
455
|
try:
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
_LOGGER.
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|