python-mobius 0.4.2__py3-none-any.whl → 0.4.4__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.2"
68
+ __version__ = "0.4.4"
69
69
 
70
70
  __all__ = [
71
71
  "__version__",
mobius/constants.py CHANGED
@@ -61,71 +61,133 @@ PUMP_PRIMITIVES_EXPERIMENTAL = {PrimitiveType.CoffeeV1}
61
61
 
62
62
 
63
63
  class Model(IntEnum):
64
- """Device model identifiers. Confirmed literal values via reverse
65
- engineering the app's own model-identifier enum
66
- (a curated subset -- the full enum covers ~100 models across EcoTech
67
- Marine, AquaIllumination, Neptune Systems, NYOS, and Hera product lines;
68
- add entries here as needed)."""
64
+ """Device model identifiers. Confirmed literal values -- and, for
65
+ every entry, the real product name -- via reverse engineering the
66
+ app's own model-identifier enum and its own getName() method
67
+ directly (base64-decoded where the app itself stores the string
68
+ that way, confirmed real product names even for entries whose own
69
+ internal enum constant name is an unrelated food-themed placeholder
70
+ -- e.g. this enum's own Plumbus really is called "Plumbus" by the
71
+ real app itself, not a decompiler artifact). The full set, not a
72
+ curated subset -- see MODEL_MANUFACTURER below for the same
73
+ treatment of manufacturer. A few entries (the RadionXR#w G5/G6 FW/
74
+ Pro/Blue range specifically) are named here for the real product,
75
+ not the app's own internal enum constant name, to stay consistent
76
+ with how every other RadionXR#w entry in this same enum is already
77
+ named -- each renamed entry's own comment says what the app itself
78
+ actually calls it internally, for anyone cross-referencing against
79
+ the decompiled source directly."""
69
80
  Unknown = 0
70
- VorTechMP10wES = 10
71
- VorTechMP10wQD = 11
72
- RadionXR30w = 30
73
- RadionXR30wG2 = 31
74
- RadionXR30wPro = 32
75
- RadionXR30wG3 = 33
76
- RadionXR30wG3Pro = 34
77
- RadionXR30wG4 = 35
78
- RadionXR30wG4Pro = 36
79
- RadionXR30wFWPro = 37
80
- RadionXR30wPrototype = 39
81
- VorTechMP40wES = 40
82
- VorTechMP40wQD = 41
83
- VorTechMP40wG3QD = 42
84
- VorTechMP60wES = 60
85
- VorTechMP60wQD = 61
86
- VectraM1 = 144
87
- VectraL1 = 145
88
- VectraS1 = 146
89
- VectraS2 = 147
90
- VectraM2 = 148
91
- VectraL2 = 149
92
- RadionXR15wG3Pro = 160
93
- RadionXR15wFW = 161
94
- RadionXR15wG4Pro = 162
95
- RadionXR15wFWPro = 163
96
- RadionXR15wPrototype = 175
97
- RadionXR15G5Pro = 176
98
- RadionXR15wG5Blue = 177
99
- RadionXR15wG5FW = 178 # "Radion XR15w G5 FW" (base64-decoded from getName())
100
- RadionXR15wG6Pro = 179 # "Radion XR15w G6 Pro" (base64-decoded from getName())
101
- RadionXR15wG6Blue = 180 # "Radion XR15w G6 Blue" (inferred sequential id, base64-decoded name)
102
- RadionXR30wG5Pro = 192
103
- RadionXR30wG5Blue = 193
104
- Nero5 = 256
105
- Nero3 = 257
106
- DosingPump = 272
107
- Muffler = 275 # "Dose QD" -- another doser model
108
- Coffee1 = 396 # "Quantum 160" (NYOS) -- CoffeeV1 primitive
109
- Coffee2 = 397 # "Quantum 220/300" (NYOS)
110
-
111
-
112
- # Confirmed via reverse engineering the app's own manufacturer-lookup logic.
113
- # The decompiled version branches on
114
- # enum *ordinal* against a hardcoded position list (the full ~100-entry
115
- # Model enum's index 1-37 range, plus a few extras, all map to "EcoTech
116
- # Marine"); since this library only implements a curated subset of Model,
117
- # this is re-derived by name pattern against that same logic instead of by
118
- # ordinal position -- functionally equivalent for every Model value defined
119
- # above, but if you add more Model entries, add their manufacturer here too.
81
+ VorTechMP10wES = 10 # "VorTech MP10wES"
82
+ VorTechMP10wQD = 11 # "VorTech MP10mQD"
83
+ RadionXR30w = 30 # "Radion XR30w"
84
+ RadionXR30wG2 = 31 # "Radion XR30w G2"
85
+ RadionXR30wPro = 32 # "Radion XR30w Pro"
86
+ RadionXR30wG3 = 33 # "Radion XR30w G3"
87
+ RadionXR30wG3Pro = 34 # "Radion XR30wG3 Pro"
88
+ RadionXR30wG4 = 35 # "Radion XR30w G4"
89
+ RadionXR30wG4Pro = 36 # "Radion XR30w G4 Pro"
90
+ RadionXR30wFWPro = 37 # "Radion XR30wFW Pro"
91
+ RadionXR30wPrototype = 39 # "Radion XR30w Prototype"
92
+ VorTechMP40wES = 40 # "VorTech MP40wES"
93
+ VorTechMP40wQD = 41 # "VorTech MP40mQD"
94
+ VorTechMP40wG3QD = 42 # "VorTech MP40mQD"
95
+ VorTechMP60wES = 60 # "VorTech MP60wES"
96
+ VorTechMP60wQD = 61 # "VorTech MP60mQD"
97
+ VectraM1 = 144 # "Vectra M1"
98
+ VectraL1 = 145 # "Vectra L1"
99
+ VectraS1 = 146 # "Vectra S1"
100
+ VectraS2 = 147 # "Vectra S2"
101
+ VectraM2 = 148 # "Vectra M2"
102
+ VectraL2 = 149 # "Vectra L2"
103
+ RadionXR15wG3Pro = 160 # "Radion XR15wG3 Pro"
104
+ RadionXR15wFW = 161 # "Radion XR15w FW"
105
+ RadionXR15wG4Pro = 162 # "Radion XR15w G4 Pro"
106
+ RadionXR15wFWPro = 163 # "Radion XR15w FW Pro"
107
+ RadionXR15wPrototype = 175 # "Radion XR15w Prototype"
108
+ RadionXR15G5Pro = 176 # "Radion XR15w G5 Pro"
109
+ RadionXR15wG5Blue = 177 # "Radion XR15w G5 Blue"
110
+ RadionXR15wG5FW = 178 # "Radion XR15w G5 FW" (app's own internal enum name: BeefChalupa)
111
+ RadionXR15wG6Pro = 179 # "Radion XR15w G6 Pro" (app's own internal enum name: SteakQuesadilla)
112
+ RadionXR15wG6Blue = 180 # "Radion XR15w G6 Blue" (app's own internal enum name: SteakNachoFriesBurrito)
113
+ RadionXR30wG5Pro = 192 # "Radion XR30w G5 Pro"
114
+ RadionXR30wG5Blue = 193 # "Radion XR30w G5 Blue"
115
+ RadionXR30wG5FW = 194 # "Radion XR30w G5 FW" (app's own internal enum name: CrunchwrapSupreme)
116
+ RadionXR30wG6Pro = 195 # "Radion XR30w G6 Pro" (app's own internal enum name: SteakTaco)
117
+ RadionXR30wG6Blue = 196 # "Radion XR30w G6 Blue" (app's own internal enum name: BeefyMeltBurrito)
118
+ Nero5 = 256 # "Nero 5"
119
+ Nero3 = 257 # "Nero 3"
120
+ Nero7 = 258 # "Nero 7" (app's own internal enum name: Slack)
121
+ Orbit2 = 259 # "Orbit 2" (app's own internal enum name: Alpaca2)
122
+ Orbit4 = 260 # "Orbit 4" (app's own internal enum name: Alpaca4)
123
+ Axis20 = 261 # "Axis 20" (app's own internal enum name: Turtle1)
124
+ Axis40 = 262 # "Axis 40" (app's own internal enum name: Turtle2)
125
+ Axis90 = 263 # "Axis 90" (app's own internal enum name: Turtle3)
126
+ Axis200 = 264 # "Axis 200" (app's own internal enum name: Turtle4)
127
+ VersaVX1 = 272 # "Versa VX-1" (app's own internal enum name: DosingPump)
128
+ DoseQD = 275 # "Dose QD" (app's own internal enum name: Muffler)
129
+ SproutSL1 = 288 # "Sprout SL1" (app's own internal enum name: Chalupa)
130
+ EnvironmentalSensor = 304 # "Environmental Sensor" (app's own internal enum name: DoritosLocosTacos)
131
+ Prime = 320 # "Prime"
132
+ PrimeHD = 321 # "Prime HD"
133
+ PrimeFW = 322 # "Prime FW" (the app's own getName() genuinely returns an empty string for this one specifically -- not a parsing gap here)
134
+ HydraTwentySixHD = 323 # "Hydra TwentySix HD"
135
+ HydraFiftyTwoHD = 324 # "Hydra FiftyTwo HD"
136
+ RedSeaMAX13 = 325 # "RedSea MAX 13"
137
+ RedSeaMAX26 = 326 # "RedSea MAX 26"
138
+ PrimeSixteenHD = 333 # "Prime Sixteen HD"
139
+ HydraThirtyTwoHD = 334 # "Hydra ThirtyTwo HD"
140
+ HydraSixtyFourHD = 335 # "Hydra SixtyFour HD"
141
+ PrimeSixteenHdFw = 339 # "16 Freshwater"
142
+ PrimeSixteenHdFuge = 340 # "16 Fuge"
143
+ PrimeSixteenHdSol = 341 # "16 Sol"
144
+ PrimeFuge = 342 # "Prime Fuge"
145
+ Hydra32HDFreshwater = 344 # "Hydra 32HD Freshwater" (app's own internal enum name: Quesarito)
146
+ Thundervolt = 345 # "Thundervolt" (app's own internal enum name: NachosBellGrande)
147
+ Plumbus = 346 # "Plumbus"
148
+ Blade = 347 # "Blade" (app's own internal enum name: Saddle)
149
+ Leap = 349 # "Leap" (app's own internal enum name: BeanBurrito)
150
+ MXM = 350 # "MXM" (app's own internal enum name: Cowboy)
151
+ BladeD = 360 # "Blade" (app's own internal enum name: SaddleD)
152
+ BladeCoralGlow = 361 # "Blade Coral Glow" (app's own internal enum name: SaddleA)
153
+ BladeCoralGrow = 362 # "Blade Coral Grow" (app's own internal enum name: SaddleM)
154
+ BladeFreshwater = 363 # "Blade Freshwater" (app's own internal enum name: SaddleF)
155
+ BladeRefugium = 364 # "Blade Refugium" (app's own internal enum name: SaddleR)
156
+ BladeGrowPlus = 365 # "Blade Grow+" (app's own internal enum name: SaddleG)
157
+ BladeGloFish = 368 # "Blade GloFish" (app's own internal enum name: SaddleGF)
158
+ HydraEdge44HD = 371 # "Hydra Edge 44HD" (app's own internal enum name: AlfalfaRS)
159
+ HydraEdge68HD = 372 # "Hydra Edge 68HD" (app's own internal enum name: AlfalfaRL)
160
+ HydraEdge44HDFreshwater = 373 # "Hydra Edge 44HD Freshwater" (app's own internal enum name: AlfalfaFS)
161
+ HydraEdge68HDFreshwater = 374 # "Hydra Edge 68HD Freshwater" (app's own internal enum name: AlfalfaFL)
162
+ Quantum160 = 396 # "Quantum 160" (app's own internal enum name: Coffee1)
163
+ Quantum220300 = 397 # "Quantum 220/300" (app's own internal enum name: Coffee2)
164
+ Hera = 512 # "Hera" (app's own internal enum name: CrunchyTaco)
165
+ HeraBackplane = 513 # "Hera Backplane" (app's own internal enum name: SoftTaco)
166
+ HeraPHSensor = 514 # "Hera pH Sensor" (app's own internal enum name: LuxeBox)
167
+ HeraDissolvedOxygenSensor = 515 # "Hera Dissolved Oxygen Sensor" (app's own internal enum name: DiabloSauce)
168
+ HeraFluidMonitoringModule = 516 # "Hera Fluid Monitoring Module" (app's own internal enum name: FlaminHotRice)
169
+ HeraIOModule = 517 # "Hera I/O Module" (app's own internal enum name: CinnabonDelights)
170
+ HeraORPSensor = 518 # "Hera ORP sensor" (app's own internal enum name: BajaBlast)
171
+ HeraSalinitySensor = 519 # "Hera Salinity Sensor" (app's own internal enum name: CantinaChicken)
172
+ HeraSwitchedPowerModule = 520 # "Hera Switched Power Module" (app's own internal enum name: VeggieBowl)
173
+ RFModDev = 61441 # "RF Mod Dev"
174
+ WiFiModDev = 61442 # "WiFi Mod Dev"
175
+ ETMRFModProd = 61443 # "ETM RF Mod Prod"
176
+ WiFiModBreakout = 61444 # "WiFi Mod Breakout"
177
+ Passthrough = 61445 # "Devel Board"
178
+
179
+
180
+ # Confirmed directly against the app's own decompiled getManufacturer()/
181
+ # isEtm() logic for every single entry above -- not a curated subset,
182
+ # and not re-derived by name pattern (an earlier version of this
183
+ # comment described it that way; that approach turned out to have at
184
+ # least one confirmed mistake -- see the CHANGELOG for this release).
120
185
  MODEL_MANUFACTURER: dict[Model, str] = {
121
186
  Model.Unknown: "Unknown",
187
+
188
+ # EcoTech Marine
122
189
  Model.VorTechMP10wES: "EcoTech Marine",
123
190
  Model.VorTechMP10wQD: "EcoTech Marine",
124
- Model.VorTechMP40wES: "EcoTech Marine",
125
- Model.VorTechMP40wQD: "EcoTech Marine",
126
- Model.VorTechMP40wG3QD: "EcoTech Marine",
127
- Model.VorTechMP60wES: "EcoTech Marine",
128
- Model.VorTechMP60wQD: "EcoTech Marine",
129
191
  Model.RadionXR30w: "EcoTech Marine",
130
192
  Model.RadionXR30wG2: "EcoTech Marine",
131
193
  Model.RadionXR30wPro: "EcoTech Marine",
@@ -135,6 +197,17 @@ MODEL_MANUFACTURER: dict[Model, str] = {
135
197
  Model.RadionXR30wG4Pro: "EcoTech Marine",
136
198
  Model.RadionXR30wFWPro: "EcoTech Marine",
137
199
  Model.RadionXR30wPrototype: "EcoTech Marine",
200
+ Model.VorTechMP40wES: "EcoTech Marine",
201
+ Model.VorTechMP40wQD: "EcoTech Marine",
202
+ Model.VorTechMP40wG3QD: "EcoTech Marine",
203
+ Model.VorTechMP60wES: "EcoTech Marine",
204
+ Model.VorTechMP60wQD: "EcoTech Marine",
205
+ Model.VectraM1: "EcoTech Marine",
206
+ Model.VectraL1: "EcoTech Marine",
207
+ Model.VectraS1: "EcoTech Marine",
208
+ Model.VectraS2: "EcoTech Marine",
209
+ Model.VectraM2: "EcoTech Marine",
210
+ Model.VectraL2: "EcoTech Marine",
138
211
  Model.RadionXR15wG3Pro: "EcoTech Marine",
139
212
  Model.RadionXR15wFW: "EcoTech Marine",
140
213
  Model.RadionXR15wG4Pro: "EcoTech Marine",
@@ -147,18 +220,75 @@ MODEL_MANUFACTURER: dict[Model, str] = {
147
220
  Model.RadionXR15wG6Blue: "EcoTech Marine",
148
221
  Model.RadionXR30wG5Pro: "EcoTech Marine",
149
222
  Model.RadionXR30wG5Blue: "EcoTech Marine",
150
- Model.VectraM1: "EcoTech Marine",
151
- Model.VectraL1: "EcoTech Marine",
152
- Model.VectraS1: "EcoTech Marine",
153
- Model.VectraS2: "EcoTech Marine",
154
- Model.VectraM2: "EcoTech Marine",
155
- Model.VectraL2: "EcoTech Marine",
223
+ Model.RadionXR30wG5FW: "EcoTech Marine",
224
+ Model.RadionXR30wG6Pro: "EcoTech Marine",
225
+ Model.RadionXR30wG6Blue: "EcoTech Marine",
226
+ Model.VersaVX1: "EcoTech Marine",
227
+ Model.Thundervolt: "EcoTech Marine",
228
+ Model.Leap: "EcoTech Marine",
229
+ Model.ETMRFModProd: "EcoTech Marine",
230
+
231
+ # AquaIllumination
156
232
  Model.Nero5: "AquaIllumination",
157
233
  Model.Nero3: "AquaIllumination",
158
- Model.DosingPump: "AquaIllumination",
159
- Model.Muffler: "Neptune Systems", # confirmed explicitly in the decompile's manufacturer list
160
- Model.Coffee1: "NYOS", # confirmed explicitly (CoffeeV1 branch)
161
- Model.Coffee2: "NYOS",
234
+ Model.Nero7: "AquaIllumination",
235
+ Model.Orbit2: "AquaIllumination",
236
+ Model.Orbit4: "AquaIllumination",
237
+ Model.Axis20: "AquaIllumination",
238
+ Model.Axis40: "AquaIllumination",
239
+ Model.Axis90: "AquaIllumination",
240
+ Model.Axis200: "AquaIllumination",
241
+ Model.SproutSL1: "AquaIllumination",
242
+ Model.EnvironmentalSensor: "AquaIllumination",
243
+ Model.Prime: "AquaIllumination",
244
+ Model.PrimeHD: "AquaIllumination",
245
+ Model.PrimeFW: "AquaIllumination",
246
+ Model.HydraTwentySixHD: "AquaIllumination",
247
+ Model.HydraFiftyTwoHD: "AquaIllumination",
248
+ Model.RedSeaMAX13: "AquaIllumination",
249
+ Model.RedSeaMAX26: "AquaIllumination",
250
+ Model.PrimeSixteenHD: "AquaIllumination",
251
+ Model.HydraThirtyTwoHD: "AquaIllumination",
252
+ Model.HydraSixtyFourHD: "AquaIllumination",
253
+ Model.PrimeSixteenHdFw: "AquaIllumination",
254
+ Model.PrimeSixteenHdFuge: "AquaIllumination",
255
+ Model.PrimeSixteenHdSol: "AquaIllumination",
256
+ Model.PrimeFuge: "AquaIllumination",
257
+ Model.Hydra32HDFreshwater: "AquaIllumination",
258
+ Model.Plumbus: "AquaIllumination",
259
+ Model.Blade: "AquaIllumination",
260
+ Model.BladeD: "AquaIllumination",
261
+ Model.BladeCoralGlow: "AquaIllumination",
262
+ Model.BladeCoralGrow: "AquaIllumination",
263
+ Model.BladeFreshwater: "AquaIllumination",
264
+ Model.BladeRefugium: "AquaIllumination",
265
+ Model.BladeGrowPlus: "AquaIllumination",
266
+ Model.BladeGloFish: "AquaIllumination",
267
+ Model.HydraEdge44HD: "AquaIllumination",
268
+ Model.HydraEdge68HD: "AquaIllumination",
269
+ Model.HydraEdge44HDFreshwater: "AquaIllumination",
270
+ Model.HydraEdge68HDFreshwater: "AquaIllumination",
271
+ Model.RFModDev: "AquaIllumination",
272
+ Model.WiFiModDev: "AquaIllumination",
273
+ Model.WiFiModBreakout: "AquaIllumination",
274
+ Model.Passthrough: "AquaIllumination",
275
+
276
+ # Neptune Systems
277
+ Model.DoseQD: "Neptune Systems",
278
+ Model.MXM: "Neptune Systems",
279
+ Model.Hera: "Neptune Systems",
280
+ Model.HeraBackplane: "Neptune Systems",
281
+ Model.HeraPHSensor: "Neptune Systems",
282
+ Model.HeraDissolvedOxygenSensor: "Neptune Systems",
283
+ Model.HeraFluidMonitoringModule: "Neptune Systems",
284
+ Model.HeraIOModule: "Neptune Systems",
285
+ Model.HeraORPSensor: "Neptune Systems",
286
+ Model.HeraSalinitySensor: "Neptune Systems",
287
+ Model.HeraSwitchedPowerModule: "Neptune Systems",
288
+
289
+ # NYOS
290
+ Model.Quantum160: "NYOS",
291
+ Model.Quantum220300: "NYOS",
162
292
  }
163
293
 
164
294
 
@@ -276,6 +406,9 @@ class C2Attribute(IntEnum):
276
406
  LunarPhasesEnabled = 907
277
407
  Epoch = 201
278
408
  RTCTime = 219
409
+ LocalTime = 207 # already device-timezone-adjusted -- see
410
+ # get_device_local_date()'s own docstring in
411
+ # device.py for why this exists at all
279
412
  # Thread mesh peer discovery -- see documentation/09-thread-coap-relay.md
280
413
  # for the full protocol trace. MeshLocalAddresses confirmed via
281
414
  # reverse engineering the app's own connection-setup logic; the three
mobius/device.py CHANGED
@@ -637,10 +637,12 @@ class MobiusDevice:
637
637
  a real, known thing worth
638
638
  checking, using this exact same drift computation
639
639
  (local_time_seconds - device_reported_seconds). See
640
- DeviceTimeInfo's docstring for the important caveat that this
641
- doesn't directly explain schedule-interpolation mismatches (the
642
- app's own schedule lookup uses the phone/tank clock, not either
643
- of these), but is still worth ruling out as a general health check.
640
+ DeviceTimeInfo's docstring for the important caveat that these
641
+ two specific attributes don't directly explain a schedule-
642
+ interpolation mismatch by themselves (LocalTime, a different
643
+ attribute entirely, is what actually matters there -- see
644
+ get_device_local_date()), but real device clock drift is still
645
+ worth ruling out as a general health check.
644
646
  """
645
647
  import datetime as _datetime
646
648
  now = int(_datetime.datetime.now().timestamp())
@@ -668,6 +670,52 @@ class MobiusDevice:
668
670
  rtc_drift_seconds=(now - device_rtc) if device_rtc is not None else None,
669
671
  )
670
672
 
673
+ async def get_device_local_date(self) -> Optional["datetime.date"]:
674
+ """
675
+ The gateway device's own, ALREADY TIMEZONE-ADJUSTED idea of what
676
+ calendar date it currently is -- confirmed, not assumed, to be
677
+ the actual source get_current_light_intensities() needs for its
678
+ own lunar-phase calculation, by tracing the real app's own call
679
+ chain directly: VPointScheduleView's own lunar.percentReduction()
680
+ call passes vPointScheduleView2.time.date(), where that `time`
681
+ is set via Tank.getDateTime() -- which is populated by
682
+ DateTime.parseRequest(), which reads Epoch (201) AND LocalTime
683
+ (207) directly off the connected gateway device itself, NOT
684
+ computed from the phone's own system clock at all. An earlier
685
+ version of this library's own DeviceTimeInfo docstring claimed
686
+ the opposite ("the app's own schedule interpolation uses the
687
+ phone/tank's own clock, NOT either of these device-reported
688
+ values") -- that claim was wrong specifically for the lunar
689
+ calculation, confirmed by a real, reported production mismatch
690
+ (this library showing the wrong lunar phase for hours at a
691
+ stretch, spanning exactly the window between local calendar
692
+ midnight and whenever the device's own reported date actually
693
+ rolled over) that this method's own existence now fixes.
694
+
695
+ LocalTime specifically, not Epoch: the device itself already
696
+ applies its own configured timezone offset when reporting this
697
+ value (confirmed by the app's own DateTime constructor -- it
698
+ derives its own UTC offset as Epoch minus LocalTime, meaning
699
+ LocalTime is Epoch already shifted by that same offset), so it
700
+ can be treated directly as an offset-adjusted "local-looking"
701
+ Unix timestamp -- no separate timezone lookup or arithmetic
702
+ needed on this library's own side at all.
703
+
704
+ Returns None if this attribute isn't available for any reason
705
+ (unsupported, or a soft read failure) -- callers should fall
706
+ back to their own local system date in that case, same as
707
+ get_current_light_intensities() itself does.
708
+ """
709
+ try:
710
+ raw = await self.get_attribute(C2Attribute.LocalTime)
711
+ except Exception:
712
+ return None
713
+ if not raw:
714
+ return None
715
+ import datetime as _datetime
716
+ local_time_seconds = struct.unpack("<I", raw[0])[0]
717
+ return _datetime.datetime.fromtimestamp(local_time_seconds, tz=_datetime.timezone.utc).date()
718
+
671
719
  async def get_own_mesh_address(self) -> Optional[bytes]:
672
720
  """
673
721
  Fetches MeshLocalAddresses (1005) -- THIS device's own Thread
@@ -1464,16 +1512,27 @@ class MobiusDevice:
1464
1512
  now: Optional["datetime.datetime"] = None) -> LightIntensityResult:
1465
1513
  """
1466
1514
  Replicates what the app actually shows as "current intensity": fetch
1467
- the schedule, linearly interpolate it for the given time (defaults
1468
- to local system time -- NOT read from the device; the app itself
1469
- uses the tank's configured timezone against phone-local time, not a
1470
- device clock read, so this mirrors that behavior), then apply
1471
- whichever scalar the app itself would apply on top -- confirmed via
1472
- reverse engineering the app's own exact branching:
1515
+ the schedule, linearly interpolate it for the given time (minute_of_day
1516
+ defaults to local system time if not given -- pass it explicitly with
1517
+ a timezone-aware value if the caller has one, since this library has
1518
+ no way to know the correct timezone on its own), then apply whichever
1519
+ scalar the app itself would apply on top -- confirmed via reverse
1520
+ engineering the app's own exact branching:
1473
1521
 
1474
1522
  - dusk-to-night segment (see is_night_segment()) AND lunar phases
1475
1523
  enabled -> lunar_percent_reduction(date) in place of the normal
1476
- schedule intensity
1524
+ schedule intensity. The date used here specifically is
1525
+ get_device_local_date()'s own result (the connected gateway's
1526
+ own reported LocalTime attribute), falling back to `now`'s own
1527
+ date only if that read fails -- confirmed by tracing the real
1528
+ app's own call chain directly (see get_device_local_date()'s
1529
+ own docstring for the full trace) that this is genuinely a
1530
+ DEVICE-reported value, not phone/system-local time at all,
1531
+ unlike everything else this method computes. A real, reported
1532
+ production mismatch (this library showing the wrong lunar
1533
+ phase for hours at a stretch, spanning exactly the gap between
1534
+ local calendar midnight and whenever the device's own reported
1535
+ date actually rolled over) is what confirmed this.
1477
1536
  - dusk-to-night segment, lunar not enabled -> scalar = 1.0
1478
1537
  (full/unreduced)
1479
1538
  - otherwise (normal daytime) -> the schedule-level master
@@ -1491,13 +1550,17 @@ class MobiusDevice:
1491
1550
 
1492
1551
  `.diagnostics` also includes "is_night_segment", "lunar_enabled",
1493
1552
  "scalar_source" (one of "lunar"/"night_no_lunar"/
1494
- "schedule_intensity"), and "scalar" (the actual final multiplier
1495
- applied) -- added specifically to make it possible to tell which
1496
- branch was taken and why, since a mismatch against the app's own
1497
- displayed value could stem from either is_night_segment()
1498
- misjudging this schedule's specific point structure, or
1499
- get_lunar_enabled() reading incorrectly, and those need to be told
1500
- apart with real data rather than guessed at.
1553
+ "schedule_intensity"), "scalar" (the actual final multiplier
1554
+ applied), and, specifically for the "lunar" case, "lunar_date"
1555
+ (the actual date used for that calculation) and
1556
+ "lunar_date_source" (one of "device"/"local_fallback") -- added
1557
+ specifically to make it possible to tell which branch was taken
1558
+ and why, since a mismatch against the app's own displayed value
1559
+ could stem from is_night_segment() misjudging this schedule's
1560
+ specific point structure, get_lunar_enabled() reading
1561
+ incorrectly, or (confirmed now to be a real, genuine possibility)
1562
+ the wrong date being used for the lunar calculation itself, and
1563
+ those need to be told apart with real data rather than guessed at.
1501
1564
  """
1502
1565
  import datetime as _datetime
1503
1566
  if now is None:
@@ -1509,10 +1572,17 @@ class MobiusDevice:
1509
1572
  raw_intensities = interpolate_light_schedule(points, minute_of_day)
1510
1573
 
1511
1574
  night_segment = is_night_segment(points, minute_of_day)
1575
+ lunar_date = None
1576
+ lunar_date_source = None
1512
1577
  if night_segment:
1513
1578
  lunar_enabled = await self.get_lunar_enabled(which)
1514
1579
  if lunar_enabled:
1515
- scalar = lunar_percent_reduction(now.date())
1580
+ lunar_date = await self.get_device_local_date()
1581
+ lunar_date_source = "device"
1582
+ if lunar_date is None:
1583
+ lunar_date = now.date()
1584
+ lunar_date_source = "local_fallback"
1585
+ scalar = lunar_percent_reduction(lunar_date)
1516
1586
  scalar_source = "lunar"
1517
1587
  else:
1518
1588
  scalar = 1.0
@@ -1533,6 +1603,8 @@ class MobiusDevice:
1533
1603
  "lunar_enabled": lunar_enabled,
1534
1604
  "scalar_source": scalar_source,
1535
1605
  "scalar": scalar,
1606
+ "lunar_date": lunar_date,
1607
+ "lunar_date_source": lunar_date_source,
1536
1608
  })
1537
1609
 
1538
1610
  async def get_current_light_percentages(self, which: int = 1,
mobius/device_status.py CHANGED
@@ -99,12 +99,15 @@ class DeviceTimeInfo:
99
99
  clock, treating a large discrepancy as a real, known failure mode --
100
100
  this isn't speculative, it's the exact same check the app runs.
101
101
 
102
- Note: the app's own schedule interpolation uses
103
- the phone/tank's own clock, NOT either of
104
- these device-reported values -- so this doesn't directly explain a
105
- schedule-interpolation mismatch by itself, but real device clock drift
106
- is still worth ruling out as a general health signal, exactly as the
107
- app's own troubleshooting tool treats it.
102
+ Note: this doesn't include LocalTime (207) -- see
103
+ get_device_local_date()'s own docstring for why that one specific
104
+ attribute is genuinely different (it's what the app's own lunar-
105
+ phase calculation actually uses, confirmed by tracing the app's own
106
+ call chain directly), unlike Epoch/RTCTime here, which the app's
107
+ own schedule interpolation itself does NOT use (an earlier version
108
+ of this docstring incorrectly extended that same "not used for
109
+ schedule purposes" claim to LocalTime too, before that call chain
110
+ had actually been traced).
108
111
  """
109
112
  device_epoch: Optional[int] # raw UNIX timestamp reported by the device
110
113
  device_rtc: Optional[int]
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.4.2
3
+ Version: 0.4.4
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=BpkSMm-tv7BC2F_vWHd-jym24Hsmwfd_OSMeJa2FEfQ,5722
1
+ mobius/__init__.py,sha256=3DdT6fMBcYJjlr_U1DMTJ3mHeoz_Ri-B9g8B8c9iAZo,5722
2
2
  mobius/cli.py,sha256=8XH-pVTX4KObzd0IOGXicT7KrcNEjm5xYZS8JOWfHDA,30726
3
3
  mobius/coap.py,sha256=eXf_-9heQD1UjLyqZU9KA-c0jB50NGzAd8D7d6kFMb0,10750
4
- mobius/constants.py,sha256=z_KrFev5vGD9-9lKn4J0nGVtzTmelGQ13bYlVcCdjm0,25217
4
+ mobius/constants.py,sha256=mIMc2Xk9io4jt2i6EKaEGs2nrkHFdFm7ovoJR4kRctI,33377
5
5
  mobius/crc.py,sha256=kLtAYWZvLbo5_p9ep08ZlLg5Yah8Q2qq0IwTvqbBGW0,2700
6
- mobius/device.py,sha256=H5JGFtV90xu69jEOydMPqpj53e-5ws6TUVej0xd7WSg,79078
7
- mobius/device_status.py,sha256=p1l6RpCcg_G4p9nudhSo8MzBSgqkH8m5_0qrnjsFQ2k,8374
6
+ mobius/device.py,sha256=TOT-hmlbrMjlJ7quZmm2plZC1ILxo67XtV7z9zUNoaA,83298
7
+ mobius/device_status.py,sha256=ZlhZt5499l2tbA1tRvVoBLqakrexYboNZTDLptK0a3Y,8584
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
15
+ mobius/relay.py,sha256=bo2uUV6HhHD_-LuH4CkuPJtnKGQGM6crmB6kLAU8i4E,18857
16
16
  mobius/schedule.py,sha256=d5iAHvKuFDwfbrSptPx6viiA_lj2qGfWhsZwM_Bpr1k,10974
17
- python_mobius-0.4.2.dist-info/METADATA,sha256=PBqNDTyqaF-wG4Q8YUZsMqsQ2TkBTFfOoUNKe0yYZG4,5952
18
- python_mobius-0.4.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
19
- python_mobius-0.4.2.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
20
- python_mobius-0.4.2.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
21
- python_mobius-0.4.2.dist-info/RECORD,,
17
+ python_mobius-0.4.4.dist-info/METADATA,sha256=izBMnM3A_kuwJ8hzrWdSt6p7X4Gpxy3R1k4lkhhTj0I,5952
18
+ python_mobius-0.4.4.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
19
+ python_mobius-0.4.4.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
20
+ python_mobius-0.4.4.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
21
+ python_mobius-0.4.4.dist-info/RECORD,,