python-mobius 0.4.1__py3-none-any.whl → 0.4.3__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.
mobius/__init__.py CHANGED
@@ -65,7 +65,7 @@ from .discovery import (
65
65
  discover_tank,
66
66
  )
67
67
 
68
- __version__ = "0.4.1"
68
+ __version__ = "0.4.3"
69
69
 
70
70
  __all__ = [
71
71
  "__version__",
mobius/device.py CHANGED
@@ -843,9 +843,11 @@ class MobiusDevice:
843
843
  build_rloc_address(), which is for the different case of only
844
844
  having a 2-byte short address to encode into the prefix's last 2
845
845
  bytes -- here the full 8-byte suffix is already present in the
846
- entry, so it's used as-is); bytes 8-11 are a 4-byte "age" value
847
- (see MeshPeer's own docstring for the important caveat that its
848
- exact meaning isn't confirmed); bytes 12-13 are the 2-byte model
846
+ entry, so it's used as-is); bytes 8-11 are a 4-byte unsigned
847
+ "age" value in milliseconds -- confirmed to mean time since this
848
+ peer was last heard from over the mesh, a resetting value, not
849
+ time since first discovered (see MeshPeer's own docstring for
850
+ the full detail); bytes 12-13 are the 2-byte model
849
851
  ID; bytes 14-27 are the 14-byte ASCII serial number, matching the
850
852
  SerialNumberArray format used elsewhere in this library. All
851
853
  multi-byte numeric fields are read little-endian -- confirmed
@@ -888,7 +890,13 @@ class MobiusDevice:
888
890
  if len(entry) != 28:
889
891
  continue
890
892
  address = prefix + entry[0:8]
891
- age = struct.unpack("<i", entry[8:12])[0]
893
+ # Unsigned -- confirmed via reverse engineering the app's own
894
+ # network-troubleshooting screen, which explicitly treats this
895
+ # same 4-byte field as an unsigned value before ever using it
896
+ # for anything else. See MeshPeer's own docstring for the full,
897
+ # now-confirmed meaning (milliseconds since this peer was last
898
+ # heard from over the mesh).
899
+ age = struct.unpack("<I", entry[8:12])[0]
892
900
  model_raw = struct.unpack("<h", entry[12:14])[0]
893
901
  try:
894
902
  serial = entry[14:28].decode("ascii").rstrip("\x00")
mobius/device_status.py CHANGED
@@ -130,18 +130,27 @@ class MeshPeer:
130
130
  `age` is only ever set by discover_networked_thread_devices() --
131
131
  None from every other source of MeshPeer, including
132
132
  discover_mesh_peers() and discover_mesh_peers_via_direct_connect().
133
- Confirmed present as a raw 4-byte field in the wire format, but its
134
- exact unit/meaning (freshness of the gateway's own knowledge of this
135
- peer, most likely, given the name -- but not confirmed against real
136
- hardware) is not confirmed; treat it as an opaque, larger-is-probably-
137
- less-fresh value rather than a specific unit of time unless verified.
133
+ Confirmed via reverse engineering the app's own network-
134
+ troubleshooting screen: milliseconds since this specific peer was
135
+ last heard from over the Thread mesh, as far as the connected
136
+ gateway currently knows -- NOT time since it was first discovered
137
+ or added to the tank. A RESETTING value, not a monotonic one: it
138
+ legitimately drops back toward zero whenever the mesh genuinely
139
+ receives fresh activity from that peer, which happens on its own
140
+ schedule, independent of whenever this is read. Two checks in
141
+ quick succession can validly show a SMALLER age the second time --
142
+ that's expected, not a parsing error. The app's own troubleshooting
143
+ screen treats anything over roughly 5 minutes (300000) as long
144
+ enough to flag that peer as currently unreachable/failed, which is
145
+ a reasonable rule of thumb for interpreting this value generally,
146
+ though not enforced by this library itself.
138
147
  """
139
148
  serial: str
140
149
  model_raw: int
141
150
  model: Optional[Model] # None if model_raw doesn't match a known Model value
142
151
  short_address: int
143
152
  address: bytes # 16-byte Thread mesh-local IPv6, ready to pass to encode_coap_request()
144
- age: Optional[int] = None # see docstring -- only set by discover_networked_thread_devices()
153
+ age: Optional[int] = None # milliseconds since last heard from on the mesh -- see docstring above
145
154
 
146
155
 
147
156
  @dataclass
mobius/relay.py CHANGED
@@ -177,12 +177,52 @@ class RelayedMobiusDevice(MobiusDevice):
177
177
  embedded in its payload (confirmed via reverse engineering:
178
178
  coapRequest.getToken() == coapResponse.getToken()) -- never by
179
179
  this outer frame's own message ID.
180
+
181
+ Retries exactly once on a timeout before actually raising --
182
+ confirmed via reverse engineering the app's own request-timeout
183
+ handling: it never treats a single timed-out relay round-trip as
184
+ a real failure, always transparently retrying (fresh send, same
185
+ request) once first, only surfacing a failure if the retry ALSO
186
+ times out. A relayed round-trip crosses BLE (to the gateway) and
187
+ then the Thread mesh itself (gateway to target and back) -- a
188
+ single dropped/delayed packet somewhere in that longer path is
189
+ evidently common enough that the app was built assuming it will
190
+ happen regularly, not as an edge case. Each attempt gets its own
191
+ fresh CoAP token (this library generates one per call already,
192
+ not tied to a single request object the way the app's own retry
193
+ reuses one) -- deliberately, so a late, delayed response to the
194
+ first, abandoned attempt can never be mismatched against the
195
+ retry's own, separate wait.
180
196
  """
181
197
  if not self.gateway.is_connected:
182
198
  raise IOError(
183
199
  f"gateway device is not connected -- cannot relay to {self.peer.serial!r} through it"
184
200
  )
185
201
 
202
+ last_timeout_token: Optional[int] = None
203
+ for attempt in (1, 2):
204
+ try:
205
+ return await self._relay_attempt(inner_request_frame, is_retry=(attempt == 2))
206
+ except asyncio.TimeoutError as err:
207
+ last_timeout_token = err.args[0] if err.args else None
208
+ if attempt == 1 and self.debug:
209
+ print(f" [relay debug] timed out waiting {self.timeout}s for "
210
+ f"{self.peer.serial!r} -- retrying once before giving up")
211
+ continue
212
+
213
+ raise IOError(
214
+ f"timed out waiting {self.timeout}s for a relayed response from target "
215
+ f"{self.peer.serial!r} (no CoAP indication received for token {last_timeout_token}, "
216
+ f"even after one retry)"
217
+ )
218
+
219
+ async def _relay_attempt(self, inner_request_frame: bytes, is_retry: bool) -> ParsedFrame:
220
+ """One full send-and-await cycle for _relay() -- split out so
221
+ _relay() itself can retry it once on a timeout without
222
+ duplicating this whole body. Raises asyncio.TimeoutError itself
223
+ (not wrapped in IOError) specifically so _relay() can catch just
224
+ that case to decide whether to retry; every other exception here
225
+ propagates as-is, unwrapped, straight out of _relay() too."""
186
226
  token = next_coap_token()
187
227
  # Confirmed always GET regardless of the inner operation -- see
188
228
  # this module's docstring for why.
@@ -193,7 +233,8 @@ class RelayedMobiusDevice(MobiusDevice):
193
233
  outer_frame = build_frame(OPGROUP_C2CI_REQUEST, COAP_OPCODE, coap_payload, outer_message_id)
194
234
 
195
235
  if self.debug:
196
- print(f" [relay debug] inner request frame ({len(inner_request_frame)}B): "
236
+ retry_note = " (retry)" if is_retry else ""
237
+ print(f" [relay debug] inner request frame{retry_note} ({len(inner_request_frame)}B): "
197
238
  f"{inner_request_frame.hex()}")
198
239
  print(f" [relay debug] target address: {self.peer.address.hex()} token: {token}")
199
240
  print(f" [relay debug] outer request frame ({len(outer_frame)}B): {outer_frame.hex()}")
@@ -220,10 +261,7 @@ class RelayedMobiusDevice(MobiusDevice):
220
261
  coap_response = await asyncio.wait_for(coap_future, timeout=self.timeout)
221
262
  except asyncio.TimeoutError:
222
263
  self.gateway._pending_coap_tokens.pop(token, None)
223
- raise IOError(
224
- f"timed out waiting {self.timeout}s for a relayed response from target "
225
- f"{self.peer.serial!r} (no CoAP indication received for token {token})"
226
- )
264
+ raise asyncio.TimeoutError(token) from None
227
265
  finally:
228
266
  if self.debug:
229
267
  self.gateway.on_any_frame = previous_hook
mobius/schedule.py CHANGED
@@ -235,6 +235,16 @@ class PumpSchedulePoint:
235
235
  return None
236
236
  return cls(time_minutes, flags, PumpPrimitiveValue.parse(data[3:]))
237
237
 
238
+ def has(self, flag: int) -> bool:
239
+ # Same flags field, same bit meanings as SchedulePoint's own
240
+ # has() (see this class's own docstring: identical wire format,
241
+ # just different primitiveData) -- an earlier version of this
242
+ # class omitted this entirely, which crashed mobius.cli's own
243
+ # --dump-schedule for pumps specifically (its own _format_flags()
244
+ # helper is shared between the light and pump branches, calling
245
+ # point.has() either way).
246
+ return (self.flags & flag) == flag
247
+
238
248
 
239
249
  def get_active_pump_block(points: list[PumpSchedulePoint], minute_of_day: int) -> Optional[PumpSchedulePoint]:
240
250
  """
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
5
5
  Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
6
6
  Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
@@ -1,10 +1,10 @@
1
- mobius/__init__.py,sha256=JtPqSR-tQWJR9NA4fRAdqZiSvapwQyk6AI-cKu5tKSc,5722
1
+ mobius/__init__.py,sha256=7MOkrkQcDD7HLG5C7hKh5BTzUp0PmB5ZfIa3esYn2Nc,5722
2
2
  mobius/cli.py,sha256=8XH-pVTX4KObzd0IOGXicT7KrcNEjm5xYZS8JOWfHDA,30726
3
3
  mobius/coap.py,sha256=eXf_-9heQD1UjLyqZU9KA-c0jB50NGzAd8D7d6kFMb0,10750
4
4
  mobius/constants.py,sha256=z_KrFev5vGD9-9lKn4J0nGVtzTmelGQ13bYlVcCdjm0,25217
5
5
  mobius/crc.py,sha256=kLtAYWZvLbo5_p9ep08ZlLg5Yah8Q2qq0IwTvqbBGW0,2700
6
- mobius/device.py,sha256=Ln3LFWIMUQVhcCnAKXxZ3JiKuEDqmFJVHWQvikCQTyM,78538
7
- mobius/device_status.py,sha256=4mHpwr3v4TvMtNXsLCoz5uOVsmsERC-u6RIha8ss9v4,7797
6
+ mobius/device.py,sha256=H5JGFtV90xu69jEOydMPqpj53e-5ws6TUVej0xd7WSg,79078
7
+ mobius/device_status.py,sha256=p1l6RpCcg_G4p9nudhSo8MzBSgqkH8m5_0qrnjsFQ2k,8374
8
8
  mobius/discovery.py,sha256=LqHOEA_j1ZROuounn3PPmy8IEbpL6-N1TOW1MpoKg6A,11619
9
9
  mobius/frame.py,sha256=UNLTHuzVue5b6L4nv1hWJTSbn5k8lETWdsiL_52yI0Q,6141
10
10
  mobius/manufacturer.py,sha256=8-vBzckN8xmsPSMj5EWtOCXenbyQueoY5pnZHePgb_k,1937
@@ -12,10 +12,10 @@ mobius/mesh_address.py,sha256=PKQ-mm7Z4bY11ZnVmeWZO9EmOiAHp8hNtXceMGMs2tk,5940
12
12
  mobius/modifiers.py,sha256=zWJCsjaJ4TtwS1QgQoRFH-ve1mykuPWn5KX4YQpNqco,7215
13
13
  mobius/power.py,sha256=5l9ehjNCDrSsJjQkaU4M6pYnSMqw2FVF8Ogg6KIE5vQ,3844
14
14
  mobius/pump_status.py,sha256=RWTl8ZViCFDbiFU2mX-WAIH_cWhZOZ0M2B0oYXnPx3Y,1598
15
- mobius/relay.py,sha256=tMpV_67CYt9A1L_DPERzbqOgy3BOdG4NZpAG_IlefhY,16587
16
- mobius/schedule.py,sha256=3p7uZ8iYu_g7GUUy3S8X85qZHMNOMpwULhFw8SCnuls,10430
17
- python_mobius-0.4.1.dist-info/METADATA,sha256=VLOuzyUhBqful8QD76B2xjyAjIRRcGNVNGMtEmzJIfw,5952
18
- python_mobius-0.4.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
19
- python_mobius-0.4.1.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
20
- python_mobius-0.4.1.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
21
- python_mobius-0.4.1.dist-info/RECORD,,
15
+ mobius/relay.py,sha256=bo2uUV6HhHD_-LuH4CkuPJtnKGQGM6crmB6kLAU8i4E,18857
16
+ mobius/schedule.py,sha256=d5iAHvKuFDwfbrSptPx6viiA_lj2qGfWhsZwM_Bpr1k,10974
17
+ python_mobius-0.4.3.dist-info/METADATA,sha256=Iht60XXLlvjteO-wjyLMwUlzeKGlDKRL-mgIlv-sHx8,5952
18
+ python_mobius-0.4.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
19
+ python_mobius-0.4.3.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
20
+ python_mobius-0.4.3.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
21
+ python_mobius-0.4.3.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any