python-jebao 0.1.7__py3-none-any.whl → 0.2.0__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 +1 -1
- jebao/device.py +36 -27
- jebao/discovery.py +38 -6
- jebao/mdp20000.py +5 -19
- jebao/protocol.py +289 -661
- jebao/retry.py +22 -74
- {python_jebao-0.1.7.dist-info → python_jebao-0.2.0.dist-info}/METADATA +1 -1
- python_jebao-0.2.0.dist-info/RECORD +13 -0
- {python_jebao-0.1.7.dist-info → python_jebao-0.2.0.dist-info}/WHEEL +1 -1
- python_jebao-0.1.7.dist-info/RECORD +0 -13
- {python_jebao-0.1.7.dist-info → python_jebao-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {python_jebao-0.1.7.dist-info → python_jebao-0.2.0.dist-info}/top_level.txt +0 -0
jebao/__init__.py
CHANGED
jebao/device.py
CHANGED
|
@@ -4,7 +4,7 @@ import logging
|
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
6
|
from .const import DEFAULT_TIMEOUT, DeviceState
|
|
7
|
-
from .exceptions import
|
|
7
|
+
from .exceptions import JebaoCommandError
|
|
8
8
|
from .protocol import JebaoProtocol
|
|
9
9
|
from .retry import async_retry
|
|
10
10
|
|
|
@@ -29,7 +29,6 @@ class JebaoDevice:
|
|
|
29
29
|
device_id: Device ID (from discovery)
|
|
30
30
|
model: Device model
|
|
31
31
|
"""
|
|
32
|
-
self.host = host
|
|
33
32
|
self.port = port
|
|
34
33
|
self.device_id = device_id
|
|
35
34
|
self.model = model
|
|
@@ -39,6 +38,15 @@ class JebaoDevice:
|
|
|
39
38
|
self._speed: Optional[int] = None
|
|
40
39
|
self._last_update: Optional[float] = None
|
|
41
40
|
|
|
41
|
+
@property
|
|
42
|
+
def host(self) -> str:
|
|
43
|
+
"""Device IP address. Setting it takes effect on the next operation."""
|
|
44
|
+
return self._protocol.host
|
|
45
|
+
|
|
46
|
+
@host.setter
|
|
47
|
+
def host(self, value: str) -> None:
|
|
48
|
+
self._protocol.host = value
|
|
49
|
+
|
|
42
50
|
@property
|
|
43
51
|
def device_identifier(self) -> str:
|
|
44
52
|
"""Get device identifier for logging."""
|
|
@@ -87,25 +95,23 @@ class JebaoDevice:
|
|
|
87
95
|
return self._state in (DeviceState.ON, DeviceState.OFF)
|
|
88
96
|
|
|
89
97
|
async def connect(self, timeout: float = DEFAULT_TIMEOUT) -> None:
|
|
90
|
-
"""
|
|
98
|
+
"""Verify the device is reachable and fetch initial status.
|
|
91
99
|
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
No connection is held open afterwards; every operation opens its own
|
|
101
|
+
short-lived session (the pump drops idle sessions after ~10 s).
|
|
94
102
|
|
|
95
103
|
Raises:
|
|
96
104
|
JebaoConnectionError: Connection failed
|
|
97
105
|
JebaoAuthenticationError: Authentication failed
|
|
106
|
+
JebaoTimeoutError: Operation timed out
|
|
98
107
|
"""
|
|
99
|
-
await self.
|
|
100
|
-
_LOGGER.
|
|
101
|
-
|
|
102
|
-
# Get initial status
|
|
103
|
-
await self.update()
|
|
108
|
+
await self.update(timeout)
|
|
109
|
+
_LOGGER.debug("%s Connected (%s)", self.device_identifier, self.model or "Unknown")
|
|
104
110
|
|
|
105
111
|
async def disconnect(self) -> None:
|
|
106
112
|
"""Disconnect from device."""
|
|
107
113
|
await self._protocol.disconnect()
|
|
108
|
-
_LOGGER.
|
|
114
|
+
_LOGGER.debug("%s Disconnected", self.device_identifier)
|
|
109
115
|
|
|
110
116
|
@async_retry(max_attempts=3, delay=0.5)
|
|
111
117
|
async def update(self, timeout: float = DEFAULT_TIMEOUT) -> None:
|
|
@@ -120,24 +126,27 @@ class JebaoDevice:
|
|
|
120
126
|
"""
|
|
121
127
|
import time
|
|
122
128
|
|
|
123
|
-
if not self.is_connected:
|
|
124
|
-
raise JebaoConnectionError("Not connected")
|
|
125
|
-
|
|
126
129
|
response = await self._protocol.request_status(timeout)
|
|
127
130
|
status = self._protocol.parse_status(response)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
131
|
+
if not status:
|
|
132
|
+
raise JebaoCommandError(f"Unparseable status response: {response[:24].hex()}")
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
state = DeviceState(status["state"])
|
|
136
|
+
except ValueError as err:
|
|
137
|
+
raise JebaoCommandError(
|
|
138
|
+
f"Unknown device state 0x{status['state']:02x}"
|
|
139
|
+
) from err
|
|
140
|
+
|
|
141
|
+
self._state = state
|
|
142
|
+
self._speed = status["speed"]
|
|
143
|
+
self._last_update = time.time()
|
|
144
|
+
_LOGGER.debug(
|
|
145
|
+
"%s Status updated: state=%s, speed=%d%%",
|
|
146
|
+
self.device_identifier,
|
|
147
|
+
self._state.name,
|
|
148
|
+
self._speed,
|
|
149
|
+
)
|
|
141
150
|
|
|
142
151
|
async def __aenter__(self):
|
|
143
152
|
"""Async context manager entry."""
|
jebao/discovery.py
CHANGED
|
@@ -20,6 +20,20 @@ from .exceptions import JebaoError
|
|
|
20
20
|
|
|
21
21
|
_LOGGER = logging.getLogger(__name__)
|
|
22
22
|
|
|
23
|
+
DISCOVERY_SEND_COUNT = 3
|
|
24
|
+
DISCOVERY_RESEND_INTERVAL = 0.7 # seconds
|
|
25
|
+
|
|
26
|
+
# Every scan binds UDP 37479 (the port the official app uses). Two concurrent
|
|
27
|
+
# scans in one process would split the replies between sockets, so serialise.
|
|
28
|
+
_DISCOVERY_LOCK: Optional[asyncio.Lock] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _discovery_lock() -> asyncio.Lock:
|
|
32
|
+
global _DISCOVERY_LOCK
|
|
33
|
+
if _DISCOVERY_LOCK is None:
|
|
34
|
+
_DISCOVERY_LOCK = asyncio.Lock()
|
|
35
|
+
return _DISCOVERY_LOCK
|
|
36
|
+
|
|
23
37
|
|
|
24
38
|
@dataclass
|
|
25
39
|
class DiscoveredDevice:
|
|
@@ -136,6 +150,7 @@ class JebaoDiscovery:
|
|
|
136
150
|
List of discovered devices on this interface
|
|
137
151
|
"""
|
|
138
152
|
devices: List[DiscoveredDevice] = []
|
|
153
|
+
sender: Optional[asyncio.Task] = None
|
|
139
154
|
|
|
140
155
|
# Get broadcast address for interface
|
|
141
156
|
broadcast_addr = self._get_broadcast_address(interface)
|
|
@@ -173,10 +188,20 @@ class JebaoDiscovery:
|
|
|
173
188
|
UDP_DISCOVERY_PORT,
|
|
174
189
|
local_ip or "any",
|
|
175
190
|
)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
)
|
|
179
|
-
|
|
191
|
+
# UDP is lossy (and pumps are on Wi-Fi): a single broadcast was
|
|
192
|
+
# regularly missed. Re-send a few times while listening.
|
|
193
|
+
async def _send_repeatedly() -> None:
|
|
194
|
+
for _ in range(DISCOVERY_SEND_COUNT):
|
|
195
|
+
try:
|
|
196
|
+
sock.sendto(
|
|
197
|
+
self.DISCOVERY_REQUEST, (broadcast_addr, UDP_DISCOVERY_PORT)
|
|
198
|
+
)
|
|
199
|
+
_LOGGER.debug("Discovery request sent: %s", self.DISCOVERY_REQUEST.hex())
|
|
200
|
+
except OSError as err:
|
|
201
|
+
_LOGGER.debug("Discovery send failed on %s: %s", interface, err)
|
|
202
|
+
await asyncio.sleep(min(DISCOVERY_RESEND_INTERVAL, timeout / DISCOVERY_SEND_COUNT))
|
|
203
|
+
|
|
204
|
+
sender = asyncio.create_task(_send_repeatedly())
|
|
180
205
|
|
|
181
206
|
# Listen for responses
|
|
182
207
|
end_time = asyncio.get_event_loop().time() + timeout
|
|
@@ -224,6 +249,12 @@ class JebaoDiscovery:
|
|
|
224
249
|
except Exception as err:
|
|
225
250
|
_LOGGER.error("Discovery failed on %s: %s", interface, err)
|
|
226
251
|
finally:
|
|
252
|
+
if sender is not None:
|
|
253
|
+
sender.cancel()
|
|
254
|
+
try:
|
|
255
|
+
await sender
|
|
256
|
+
except asyncio.CancelledError:
|
|
257
|
+
pass
|
|
227
258
|
if sock:
|
|
228
259
|
sock.close()
|
|
229
260
|
|
|
@@ -395,5 +426,6 @@ async def discover_devices(
|
|
|
395
426
|
>>> devices = await discover_devices()
|
|
396
427
|
>>> mdp20000_devices = [d for d in devices if d.is_mdp20000]
|
|
397
428
|
"""
|
|
398
|
-
|
|
399
|
-
|
|
429
|
+
async with _discovery_lock():
|
|
430
|
+
discovery = JebaoDiscovery()
|
|
431
|
+
return await discovery.discover(timeout=timeout, interfaces=interfaces)
|
jebao/mdp20000.py
CHANGED
|
@@ -14,7 +14,6 @@ from .const import (
|
|
|
14
14
|
)
|
|
15
15
|
from .device import JebaoDevice
|
|
16
16
|
from .exceptions import JebaoCommandError, JebaoInvalidStateError
|
|
17
|
-
from .retry import async_retry
|
|
18
17
|
|
|
19
18
|
_LOGGER = logging.getLogger(__name__)
|
|
20
19
|
|
|
@@ -40,7 +39,6 @@ class MDP20000Device(JebaoDevice):
|
|
|
40
39
|
super().__init__(host, port, device_id, MODEL_MDP20000)
|
|
41
40
|
self._feed_duration: int = 1 # Last configured feed duration
|
|
42
41
|
|
|
43
|
-
@async_retry(max_attempts=3, delay=0.5)
|
|
44
42
|
async def ensure_manual_mode(self, timeout: float = COMMAND_TIMEOUT) -> None:
|
|
45
43
|
"""Ensure device is in manual mode.
|
|
46
44
|
|
|
@@ -76,7 +74,6 @@ class MDP20000Device(JebaoDevice):
|
|
|
76
74
|
else:
|
|
77
75
|
_LOGGER.debug("Device already in manual mode")
|
|
78
76
|
|
|
79
|
-
@async_retry(max_attempts=3, delay=0.5)
|
|
80
77
|
async def turn_on(self, timeout: float = COMMAND_TIMEOUT) -> None:
|
|
81
78
|
"""Turn pump on.
|
|
82
79
|
|
|
@@ -87,8 +84,7 @@ class MDP20000Device(JebaoDevice):
|
|
|
87
84
|
JebaoCommandError: Command failed
|
|
88
85
|
|
|
89
86
|
Note:
|
|
90
|
-
|
|
91
|
-
failures (garbage bytes, connection issues).
|
|
87
|
+
Session setup is retried; the command itself is sent at most once.
|
|
92
88
|
"""
|
|
93
89
|
_LOGGER.info("%s Turning on pump", self.device_identifier)
|
|
94
90
|
|
|
@@ -101,7 +97,6 @@ class MDP20000Device(JebaoDevice):
|
|
|
101
97
|
# Note: Call update() after a delay to see new state
|
|
102
98
|
# The pump needs time to process the command
|
|
103
99
|
|
|
104
|
-
@async_retry(max_attempts=3, delay=0.5)
|
|
105
100
|
async def turn_off(self, timeout: float = COMMAND_TIMEOUT) -> None:
|
|
106
101
|
"""Turn pump off.
|
|
107
102
|
|
|
@@ -112,8 +107,7 @@ class MDP20000Device(JebaoDevice):
|
|
|
112
107
|
JebaoCommandError: Command failed
|
|
113
108
|
|
|
114
109
|
Note:
|
|
115
|
-
|
|
116
|
-
failures (garbage bytes, connection issues).
|
|
110
|
+
Session setup is retried; the command itself is sent at most once.
|
|
117
111
|
"""
|
|
118
112
|
_LOGGER.info("%s Turning off pump", self.device_identifier)
|
|
119
113
|
|
|
@@ -125,7 +119,6 @@ class MDP20000Device(JebaoDevice):
|
|
|
125
119
|
|
|
126
120
|
# Note: Call update() after a delay to see new state
|
|
127
121
|
|
|
128
|
-
@async_retry(max_attempts=3, delay=0.5)
|
|
129
122
|
async def set_speed(
|
|
130
123
|
self, percentage: int, timeout: float = COMMAND_TIMEOUT
|
|
131
124
|
) -> None:
|
|
@@ -140,8 +133,7 @@ class MDP20000Device(JebaoDevice):
|
|
|
140
133
|
JebaoCommandError: Command failed
|
|
141
134
|
|
|
142
135
|
Note:
|
|
143
|
-
|
|
144
|
-
failures (garbage bytes, connection issues).
|
|
136
|
+
Session setup is retried; the command itself is sent at most once.
|
|
145
137
|
"""
|
|
146
138
|
if not MIN_SPEED <= percentage <= MAX_SPEED:
|
|
147
139
|
raise ValueError(
|
|
@@ -194,7 +186,6 @@ class MDP20000Device(JebaoDevice):
|
|
|
194
186
|
|
|
195
187
|
self._feed_duration = minutes
|
|
196
188
|
|
|
197
|
-
@async_retry(max_attempts=3, delay=1.0, backoff=1.5)
|
|
198
189
|
async def start_feed(
|
|
199
190
|
self, minutes: Optional[int] = None, timeout: float = COMMAND_TIMEOUT
|
|
200
191
|
) -> None:
|
|
@@ -213,9 +204,7 @@ class MDP20000Device(JebaoDevice):
|
|
|
213
204
|
JebaoInvalidStateError: Device not in valid state for feed mode
|
|
214
205
|
|
|
215
206
|
Note:
|
|
216
|
-
|
|
217
|
-
failures (garbage bytes, connection issues) with longer delays
|
|
218
|
-
due to increased garbage accumulation risk.
|
|
207
|
+
Session setup is retried; the command itself is sent at most once.
|
|
219
208
|
"""
|
|
220
209
|
# Verify device is in manual mode
|
|
221
210
|
await self.update()
|
|
@@ -245,7 +234,6 @@ class MDP20000Device(JebaoDevice):
|
|
|
245
234
|
# Update status
|
|
246
235
|
await self.update()
|
|
247
236
|
|
|
248
|
-
@async_retry(max_attempts=3, delay=1.0, backoff=1.5)
|
|
249
237
|
async def cancel_feed(
|
|
250
238
|
self, resume_speed: Optional[int] = None, timeout: float = COMMAND_TIMEOUT
|
|
251
239
|
) -> None:
|
|
@@ -261,9 +249,7 @@ class MDP20000Device(JebaoDevice):
|
|
|
261
249
|
JebaoInvalidStateError: Device not in feed mode
|
|
262
250
|
|
|
263
251
|
Note:
|
|
264
|
-
|
|
265
|
-
failures (garbage bytes, connection issues) with longer delays
|
|
266
|
-
due to increased garbage accumulation risk.
|
|
252
|
+
Session setup is retried; the command itself is sent at most once.
|
|
267
253
|
"""
|
|
268
254
|
# Verify device is in feed mode
|
|
269
255
|
await self.update()
|