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.
- {python_jebao-0.1.3 → python_jebao-0.1.6}/PKG-INFO +1 -1
- {python_jebao-0.1.3 → python_jebao-0.1.6}/jebao/__init__.py +1 -1
- {python_jebao-0.1.3 → python_jebao-0.1.6}/jebao/device.py +13 -5
- {python_jebao-0.1.3 → python_jebao-0.1.6}/jebao/mdp20000.py +3 -3
- {python_jebao-0.1.3 → python_jebao-0.1.6}/jebao/protocol.py +41 -25
- {python_jebao-0.1.3 → python_jebao-0.1.6}/jebao/retry.py +34 -4
- {python_jebao-0.1.3 → python_jebao-0.1.6}/pyproject.toml +1 -1
- {python_jebao-0.1.3 → python_jebao-0.1.6}/python_jebao.egg-info/PKG-INFO +1 -1
- {python_jebao-0.1.3 → python_jebao-0.1.6}/LICENSE +0 -0
- {python_jebao-0.1.3 → python_jebao-0.1.6}/README.md +0 -0
- {python_jebao-0.1.3 → python_jebao-0.1.6}/jebao/const.py +0 -0
- {python_jebao-0.1.3 → python_jebao-0.1.6}/jebao/discovery.py +0 -0
- {python_jebao-0.1.3 → python_jebao-0.1.6}/jebao/exceptions.py +0 -0
- {python_jebao-0.1.3 → python_jebao-0.1.6}/python_jebao.egg-info/SOURCES.txt +0 -0
- {python_jebao-0.1.3 → python_jebao-0.1.6}/python_jebao.egg-info/dependency_links.txt +0 -0
- {python_jebao-0.1.3 → python_jebao-0.1.6}/python_jebao.egg-info/requires.txt +0 -0
- {python_jebao-0.1.3 → python_jebao-0.1.6}/python_jebao.egg-info/top_level.txt +0 -0
- {python_jebao-0.1.3 → python_jebao-0.1.6}/setup.cfg +0 -0
- {python_jebao-0.1.3 → python_jebao-0.1.6}/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,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
|
|
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("
|
|
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
|
|
533
|
+
"""Drain leftover bytes and unsolicited messages from the socket buffer.
|
|
524
534
|
|
|
525
|
-
The MDP-20000 pump
|
|
526
|
-
|
|
527
|
-
|
|
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
|
|
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
|
-
#
|
|
553
|
-
#
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
|
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
|