python-jebao 0.1.6__tar.gz → 0.2.0__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.6
3
+ Version: 0.2.0
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.6"
19
+ __version__ = "0.2.0"
20
20
 
21
21
  __all__ = [
22
22
  "JebaoDevice",
@@ -31,8 +31,12 @@ CONTROL_COMMAND_SIZE = 323
31
31
 
32
32
  # Product keys
33
33
  PRODUCT_KEY_MDP20000 = "04020B28"
34
+ PRODUCT_KEY_MDP20000_V2 = "04D30Q34" # Newer MDP-20000 hardware revision
34
35
  PRODUCT_KEY_MD44 = "04020B27" # Assumed, verify if needed
35
36
 
37
+ # All known product keys that identify an MDP-20000 (across hardware revisions)
38
+ PRODUCT_KEYS_MDP20000 = frozenset({PRODUCT_KEY_MDP20000, PRODUCT_KEY_MDP20000_V2})
39
+
36
40
  # Model identifiers
37
41
  MODEL_MDP20000 = "MDP-20000"
38
42
  MODEL_MD44 = "MD-4.4"
@@ -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 JebaoConnectionError
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
- """Connect to device.
98
+ """Verify the device is reachable and fetch initial status.
91
99
 
92
- Args:
93
- timeout: Connection timeout
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._protocol.connect(timeout)
100
- _LOGGER.info("%s Connected (%s)", self.device_identifier, self.model or "Unknown")
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.info("%s Disconnected", self.device_identifier)
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
- if status:
130
- self._state = DeviceState(status["state"])
131
- self._speed = status["speed"]
132
- self._last_update = time.time()
133
- _LOGGER.debug(
134
- "%s Status updated: state=%s, speed=%d%%",
135
- self.device_identifier,
136
- self._state.name,
137
- self._speed,
138
- )
139
- else:
140
- _LOGGER.warning("%s Failed to parse status response", self.device_identifier)
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."""
@@ -12,7 +12,7 @@ from .const import (
12
12
  MODEL_MD44,
13
13
  MODEL_MDP20000,
14
14
  PRODUCT_KEY_MD44,
15
- PRODUCT_KEY_MDP20000,
15
+ PRODUCT_KEYS_MDP20000,
16
16
  UDP_DISCOVERY_PORT,
17
17
  UDP_LISTEN_PORT,
18
18
  )
@@ -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
- sock.sendto(
177
- self.DISCOVERY_REQUEST, (broadcast_addr, UDP_DISCOVERY_PORT)
178
- )
179
- _LOGGER.debug("Discovery request sent: %s", self.DISCOVERY_REQUEST.hex())
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
 
@@ -357,7 +388,7 @@ class JebaoDiscovery:
357
388
  product_key = data[product_key_start : product_key_start + 8].rstrip(b"\x00").decode("ascii")
358
389
 
359
390
  # Determine model from product key
360
- if product_key == PRODUCT_KEY_MDP20000:
391
+ if product_key in PRODUCT_KEYS_MDP20000:
361
392
  model = MODEL_MDP20000
362
393
  elif product_key == PRODUCT_KEY_MD44:
363
394
  model = MODEL_MD44
@@ -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
- discovery = JebaoDiscovery()
399
- return await discovery.discover(timeout=timeout, interfaces=interfaces)
429
+ async with _discovery_lock():
430
+ discovery = JebaoDiscovery()
431
+ return await discovery.discover(timeout=timeout, interfaces=interfaces)
@@ -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
- This method automatically retries up to 3 times on transient
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
- This method automatically retries up to 3 times on transient
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
- This method automatically retries up to 3 times on transient
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
- This method automatically retries up to 3 times on transient
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
- This method automatically retries up to 3 times on transient
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()