python-mobius 0.6.0__py3-none-any.whl → 0.7.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.
- mobius/__init__.py +3 -3
- mobius/device.py +883 -217
- {python_mobius-0.6.0.dist-info → python_mobius-0.7.0.dist-info}/METADATA +1 -1
- {python_mobius-0.6.0.dist-info → python_mobius-0.7.0.dist-info}/RECORD +7 -7
- {python_mobius-0.6.0.dist-info → python_mobius-0.7.0.dist-info}/WHEEL +0 -0
- {python_mobius-0.6.0.dist-info → python_mobius-0.7.0.dist-info}/entry_points.txt +0 -0
- {python_mobius-0.6.0.dist-info → python_mobius-0.7.0.dist-info}/licenses/LICENSE +0 -0
mobius/__init__.py
CHANGED
|
@@ -56,7 +56,7 @@ from .mesh_address import (
|
|
|
56
56
|
)
|
|
57
57
|
from .relay import RelayedMobiusDevice
|
|
58
58
|
from .device import (
|
|
59
|
-
MobiusDevice, MobiusPump, LightIntensityResult,
|
|
59
|
+
MobiusDevice, MobiusPump, LightIntensityResult, LightPollResult, FullPollResult,
|
|
60
60
|
SERVICE_GENERAL, CHAR_RX_DATA, CHAR_RX_FINAL, CHAR_TX_DATA, CHAR_TX_FINAL,
|
|
61
61
|
)
|
|
62
62
|
from .discovery import (
|
|
@@ -71,7 +71,7 @@ from .dump import (
|
|
|
71
71
|
enrich_attribute_dump,
|
|
72
72
|
)
|
|
73
73
|
|
|
74
|
-
__version__ = "0.
|
|
74
|
+
__version__ = "0.7.0"
|
|
75
75
|
|
|
76
76
|
__all__ = [
|
|
77
77
|
"__version__",
|
|
@@ -117,7 +117,7 @@ __all__ = [
|
|
|
117
117
|
"build_rloc_address", "extract_short_address", "is_short_address_derived",
|
|
118
118
|
"RelayedMobiusDevice",
|
|
119
119
|
# device
|
|
120
|
-
"MobiusDevice", "MobiusPump", "LightIntensityResult",
|
|
120
|
+
"MobiusDevice", "MobiusPump", "LightIntensityResult", "LightPollResult", "FullPollResult",
|
|
121
121
|
"SERVICE_GENERAL", "CHAR_RX_DATA", "CHAR_RX_FINAL", "CHAR_TX_DATA", "CHAR_TX_FINAL",
|
|
122
122
|
# discovery
|
|
123
123
|
"scan_for_mobius_devices", "scan_for_mobius_devices_with_info", "group_by_pan_id",
|
mobius/device.py
CHANGED
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import struct
|
|
14
14
|
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
15
16
|
from typing import Optional, Callable
|
|
16
17
|
|
|
17
18
|
from bleak import BleakClient, BleakScanner
|
|
@@ -119,6 +120,86 @@ class LightIntensityResult(dict):
|
|
|
119
120
|
self.diagnostics = diagnostics or {}
|
|
120
121
|
|
|
121
122
|
|
|
123
|
+
@dataclass
|
|
124
|
+
class LightPollResult:
|
|
125
|
+
"""
|
|
126
|
+
Everything get_light_poll_batch() reads in ONE combined round-trip
|
|
127
|
+
-- the schedule itself (for a caller's own schedule_point_count,
|
|
128
|
+
same as get_light_schedule() alone would give) AND the fully
|
|
129
|
+
computed current intensities (LightIntensityResult, same as
|
|
130
|
+
get_current_light_intensities() alone would give), both derived
|
|
131
|
+
from the SAME underlying batch response. Avoids the redundant
|
|
132
|
+
double-fetch of the schedule that existed before this method: a
|
|
133
|
+
caller needing both previously called get_light_schedule() once
|
|
134
|
+
directly, then get_current_light_intensities() separately, which
|
|
135
|
+
re-fetched the exact same schedule internally all over again.
|
|
136
|
+
|
|
137
|
+
used_batch mirrors MetadataSnapshot's own field of the same name --
|
|
138
|
+
True if the batched request itself succeeded, False if this
|
|
139
|
+
method's own individual-reads fallback was used instead (for any
|
|
140
|
+
reason, including force_individual_reads=True). This library
|
|
141
|
+
doesn't log anything itself; used_batch is the signal a caller
|
|
142
|
+
uses to notice a fallback happened and log/react to it on its own
|
|
143
|
+
terms.
|
|
144
|
+
"""
|
|
145
|
+
schedule_points: list[SchedulePoint]
|
|
146
|
+
intensities: LightIntensityResult
|
|
147
|
+
used_batch: bool = True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class FullPollResult:
|
|
152
|
+
"""
|
|
153
|
+
Everything a SINGLE device poll needs, in ONE combined round-trip
|
|
154
|
+
-- device identity (Model/Name/SerialNumber/ErrorState/MACAddress
|
|
155
|
+
-- deliberately NOT PrimitiveType, see below), everything
|
|
156
|
+
MetadataSnapshot covers, and everything a light's own
|
|
157
|
+
LightPollResult (light_poll) or a pump's own telemetry
|
|
158
|
+
(pump_telemetry, matching get_pump_telemetry()'s own return shape
|
|
159
|
+
exactly: speed/speed_percent/gph/motor_power_watts/minimum_gph/
|
|
160
|
+
maximum_gph/gph_reliable) covers.
|
|
161
|
+
|
|
162
|
+
Why PrimitiveType is deliberately excluded, unlike every other
|
|
163
|
+
identity field: it's the one thing about a physical device that's
|
|
164
|
+
genuinely permanent for its whole lifetime -- a light is a light
|
|
165
|
+
forever, a pump is a pump forever. get_full_poll_batch() itself
|
|
166
|
+
REQUIRES the caller to already know it (see that method's own
|
|
167
|
+
`primitive` parameter) specifically so a caller who's determined
|
|
168
|
+
it ONCE (e.g. on a coordinator's very first successful poll) never
|
|
169
|
+
needs to spend a single further round-trip re-confirming it,
|
|
170
|
+
unlike Model/Name/ErrorState/MACAddress, which this DOES keep
|
|
171
|
+
re-fetching every poll since any of them could, in principle,
|
|
172
|
+
change (a firmware update changing the reported Model number,
|
|
173
|
+
a user renaming the device in the app, a real fault raising
|
|
174
|
+
ErrorState) even though none of them are truly volatile like
|
|
175
|
+
schedule/intensity data.
|
|
176
|
+
|
|
177
|
+
device_info matches get_device_info()'s own dict shape exactly,
|
|
178
|
+
except "primitive_type" is always None in it (this method never
|
|
179
|
+
fetches that attribute at all) -- fill it in yourself from
|
|
180
|
+
whatever you already know, if you need it in that same dict.
|
|
181
|
+
|
|
182
|
+
pump_schedule_points -- a pump's own Schedule1/Schedule2, decoded
|
|
183
|
+
the same way get_pump_schedule() itself does (populated alongside
|
|
184
|
+
pump_telemetry, both only for a pump; None for a light, which gets
|
|
185
|
+
its own schedule via light_poll.schedule_points instead). This
|
|
186
|
+
request already includes the schedule attribute regardless of
|
|
187
|
+
device type (get_light_poll_batch()'s own candidate list needs it
|
|
188
|
+
too) -- decoding it for a pump costs nothing further and avoids a
|
|
189
|
+
caller needing a separate get_pump_schedule() call just to also
|
|
190
|
+
get schedule_point_count alongside everything else here.
|
|
191
|
+
|
|
192
|
+
used_batch mirrors every other *Result/Snapshot's own field of the
|
|
193
|
+
same name and purpose.
|
|
194
|
+
"""
|
|
195
|
+
device_info: dict
|
|
196
|
+
metadata: MetadataSnapshot
|
|
197
|
+
light_poll: Optional[LightPollResult]
|
|
198
|
+
pump_telemetry: Optional[dict]
|
|
199
|
+
pump_schedule_points: Optional[list[PumpSchedulePoint]] = None
|
|
200
|
+
used_batch: bool = True
|
|
201
|
+
|
|
202
|
+
|
|
122
203
|
# Used by get_hardware_info() -- which HardwareInfo sub-fields are
|
|
123
204
|
# themselves confirmed enums with confirmed display labels, vs. plain
|
|
124
205
|
# integers with no confirmed meaning (Revision/Segments, deliberately not
|
|
@@ -196,6 +277,443 @@ def decode_hardware_revision_blocks(blocks: list[AttributeValue]) -> dict:
|
|
|
196
277
|
return result
|
|
197
278
|
|
|
198
279
|
|
|
280
|
+
def decode_lunar_enabled(raw: list[bytes]) -> bool:
|
|
281
|
+
"""
|
|
282
|
+
Pure decode for LunarPhasesEnabled (907) -- extracted from
|
|
283
|
+
get_lunar_enabled() itself so it's shared between that method
|
|
284
|
+
(individual fetch) and get_light_poll_batch() (batched fetch),
|
|
285
|
+
rather than each having its own copy of the decode logic. False
|
|
286
|
+
(matching this library's own fail-soft convention for optional
|
|
287
|
+
modifiers) if raw is empty.
|
|
288
|
+
"""
|
|
289
|
+
if not raw:
|
|
290
|
+
return False
|
|
291
|
+
return raw[0][0] > 0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def decode_schedule_intensity(raw: list[bytes]) -> float:
|
|
295
|
+
"""
|
|
296
|
+
Pure decode for Schedule1Intensity/Schedule2Intensity (511/512) --
|
|
297
|
+
extracted from get_schedule_intensity() itself, whose own
|
|
298
|
+
docstring covers the full confirmed meaning (a schedule-level
|
|
299
|
+
master dimmer, separate from any individual channel's own value).
|
|
300
|
+
0.5 (the app's own schedule-initialization default) if raw is
|
|
301
|
+
empty.
|
|
302
|
+
"""
|
|
303
|
+
if not raw:
|
|
304
|
+
return 0.5
|
|
305
|
+
value = struct.unpack("<h", raw[0])[0]
|
|
306
|
+
return value / 1000.0
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def decode_insolation_enabled(raw: list[bytes]) -> bool:
|
|
310
|
+
"""
|
|
311
|
+
Pure decode for InsolationEnabled (912) -- extracted from
|
|
312
|
+
get_insolation_enabled() itself. False if raw is empty. See that
|
|
313
|
+
method's own docstring for what this flag is actually used for
|
|
314
|
+
(Insolation itself is NOT implemented -- this is a detection read
|
|
315
|
+
only, to flag get_current_light_intensities()'s own
|
|
316
|
+
diagnostics["insolation_active"]).
|
|
317
|
+
"""
|
|
318
|
+
if not raw:
|
|
319
|
+
return False
|
|
320
|
+
return raw[0][0] > 0
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def decode_schedule_points(raw: list[bytes]) -> list[SchedulePoint]:
|
|
324
|
+
"""
|
|
325
|
+
Pure decode for a light's Schedule1/Schedule2 response -- extracted
|
|
326
|
+
from get_light_schedule() itself, whose own docstring covers the
|
|
327
|
+
full confirmed response-parsing logic (fixed count*length layout).
|
|
328
|
+
Silently drops any element SchedulePoint.parse() itself rejects,
|
|
329
|
+
matching that method's own existing behavior.
|
|
330
|
+
"""
|
|
331
|
+
points = []
|
|
332
|
+
for element in raw:
|
|
333
|
+
pt = SchedulePoint.parse(element)
|
|
334
|
+
if pt is not None:
|
|
335
|
+
points.append(pt)
|
|
336
|
+
return points
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def decode_pump_schedule_points(raw: list[bytes]) -> list[PumpSchedulePoint]:
|
|
340
|
+
"""Same as decode_schedule_points() above, but for a pump's own
|
|
341
|
+
Schedule1/Schedule2 response -- extracted from get_pump_schedule()
|
|
342
|
+
itself."""
|
|
343
|
+
points = []
|
|
344
|
+
for element in raw:
|
|
345
|
+
pt = PumpSchedulePoint.parse(element)
|
|
346
|
+
if pt is not None:
|
|
347
|
+
points.append(pt)
|
|
348
|
+
return points
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def decode_acclimation_info(
|
|
352
|
+
enabled_raw: list[bytes], period_raw: list[bytes],
|
|
353
|
+
start_intensity_raw: list[bytes], start_time_raw: list[bytes],
|
|
354
|
+
) -> Optional[AcclimationInfo]:
|
|
355
|
+
"""
|
|
356
|
+
Pure decode for the 4 acclimation attributes (AcclimationEnabled/
|
|
357
|
+
Period/StartIntensity/StartTime) -- extracted from
|
|
358
|
+
get_acclimation_info() itself, whose own docstring covers the
|
|
359
|
+
confirmed all-or-nothing behavior: None if ANY of the 4 raw
|
|
360
|
+
values is empty (matching the app's own behavior for a device
|
|
361
|
+
that doesn't support every one of them).
|
|
362
|
+
"""
|
|
363
|
+
if not (enabled_raw and period_raw and start_intensity_raw and start_time_raw):
|
|
364
|
+
return None
|
|
365
|
+
enabled = enabled_raw[0][0] > 0
|
|
366
|
+
period = period_raw[0][0]
|
|
367
|
+
start_intensity = struct.unpack("<h", start_intensity_raw[0])[0]
|
|
368
|
+
start_time = struct.unpack("<i", start_time_raw[0])[0]
|
|
369
|
+
return AcclimationInfo(enabled, start_time, period, start_intensity)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def decode_metadata_from_batch(by_attr_id: dict, model: Optional[Model]) -> "MetadataSnapshot":
|
|
373
|
+
"""
|
|
374
|
+
Pure decode, extracted from _get_metadata_via_batch() itself --
|
|
375
|
+
everything that method used to do AFTER its own get_attributes_batch()
|
|
376
|
+
call, now reusable by any caller that already has a by_attr_id dict
|
|
377
|
+
grouped the same way (attr_id -> list[AttributeValue]), regardless
|
|
378
|
+
of whether that dict came from get_metadata_batch()'s own, smaller
|
|
379
|
+
request or a larger, combined one (see get_full_poll_batch()) that
|
|
380
|
+
happens to also include every attribute this needs, among others.
|
|
381
|
+
Looking up only the keys this function itself cares about means it
|
|
382
|
+
works correctly either way -- extra, unrelated entries in by_attr_id
|
|
383
|
+
are simply ignored.
|
|
384
|
+
|
|
385
|
+
used_batch=True unconditionally -- this function IS the "batch
|
|
386
|
+
succeeded" decode path, by construction; a caller reusing it
|
|
387
|
+
outside that context is responsible for its own used_batch value.
|
|
388
|
+
"""
|
|
389
|
+
def _first_value(attr_id):
|
|
390
|
+
blocks = by_attr_id.get(attr_id)
|
|
391
|
+
if not blocks:
|
|
392
|
+
return None
|
|
393
|
+
for block in sorted(blocks, key=lambda b: b.index):
|
|
394
|
+
if block.values:
|
|
395
|
+
return block.values[0]
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
def _all_blocks(attr_id):
|
|
399
|
+
return by_attr_id.get(attr_id, [])
|
|
400
|
+
|
|
401
|
+
# -- AdvancedFeatures -- matching get_advanced_features()'s own decode exactly.
|
|
402
|
+
local_control_enabled = None
|
|
403
|
+
raw = _first_value(C2Attribute.LocalControlEnabled)
|
|
404
|
+
if raw:
|
|
405
|
+
local_control_enabled = raw[0] > 0
|
|
406
|
+
auto_dim_timeout = None
|
|
407
|
+
raw = _first_value(C2Attribute.AutoDimTimeout)
|
|
408
|
+
if raw:
|
|
409
|
+
auto_dim_timeout = struct.unpack("<h", raw)[0]
|
|
410
|
+
max_fan_speed = None
|
|
411
|
+
raw = _first_value(C2Attribute.MaxFanSpeed)
|
|
412
|
+
if raw:
|
|
413
|
+
raw_speed = struct.unpack("<h", raw)[0]
|
|
414
|
+
max_fan_speed = 100.0 if raw_speed == -1 else raw_speed / 10.0
|
|
415
|
+
fan_shutdown_enabled = None
|
|
416
|
+
raw = _first_value(C2Attribute.FanShutdownEnabled)
|
|
417
|
+
if raw:
|
|
418
|
+
fan_shutdown_enabled = raw[0] > 0
|
|
419
|
+
advanced_features = None
|
|
420
|
+
if any([
|
|
421
|
+
local_control_enabled is not None, auto_dim_timeout is not None,
|
|
422
|
+
max_fan_speed is not None, fan_shutdown_enabled is not None,
|
|
423
|
+
]):
|
|
424
|
+
advanced_features = AdvancedFeatures(
|
|
425
|
+
local_control_enabled=local_control_enabled, auto_dim_timeout=auto_dim_timeout,
|
|
426
|
+
max_fan_speed=max_fan_speed, fan_shutdown_enabled=fan_shutdown_enabled,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# -- CalibrationInfo -- matching get_calibration_info()'s own decode exactly.
|
|
430
|
+
calibration = None
|
|
431
|
+
completed_raw = _first_value(C2Attribute.IsCalibrated)
|
|
432
|
+
date_raw = _first_value(C2Attribute.LastCalibrationTime)
|
|
433
|
+
if completed_raw and date_raw:
|
|
434
|
+
lower_bound = None
|
|
435
|
+
raw = _first_value(C2Attribute.MinCalibratedSpeed)
|
|
436
|
+
if raw:
|
|
437
|
+
lower_bound = struct.unpack("<h", raw)[0]
|
|
438
|
+
upper_bound = None
|
|
439
|
+
raw = _first_value(C2Attribute.MaxCalibratedSpeed)
|
|
440
|
+
if raw:
|
|
441
|
+
upper_bound = struct.unpack("<h", raw)[0]
|
|
442
|
+
calibration = CalibrationInfo(
|
|
443
|
+
completed=completed_raw[0] > 0,
|
|
444
|
+
date_of_last=struct.unpack("<i", date_raw)[0],
|
|
445
|
+
lower_bound=lower_bound, upper_bound=upper_bound,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# -- hardware_info / firmware_versions -- shared decode helpers,
|
|
449
|
+
# matching get_hardware_info()/get_firmware_versions() exactly.
|
|
450
|
+
hardware_info = decode_hardware_revision_blocks(_all_blocks(C2Attribute.HardwareRevision))
|
|
451
|
+
firmware_versions = decode_firmware_version_blocks(_all_blocks(C2Attribute.FirmwareVersion), model)
|
|
452
|
+
|
|
453
|
+
# -- supported_channels -- matching get_supported_channels()'s
|
|
454
|
+
# own decode exactly (flattening every block's own values, in
|
|
455
|
+
# index order, same as get_attribute_all() does internally).
|
|
456
|
+
supported_channels = []
|
|
457
|
+
for block in sorted(_all_blocks(C2Attribute.SupportedColorChannels), key=lambda b: b.index):
|
|
458
|
+
for v in block.values:
|
|
459
|
+
if v:
|
|
460
|
+
try:
|
|
461
|
+
supported_channels.append(VisualID(v[0]))
|
|
462
|
+
except ValueError:
|
|
463
|
+
pass
|
|
464
|
+
|
|
465
|
+
# -- error_state -- matching get_device_info()'s own decode exactly.
|
|
466
|
+
error_state = None
|
|
467
|
+
raw = _first_value(C2Attribute.ErrorState)
|
|
468
|
+
if raw and len(raw) >= 2:
|
|
469
|
+
error_val = struct.unpack("<h", raw)[0]
|
|
470
|
+
try:
|
|
471
|
+
error_state = ErrorState(error_val).name
|
|
472
|
+
except ValueError:
|
|
473
|
+
error_state = f"unknown({error_val})"
|
|
474
|
+
|
|
475
|
+
# -- time fields -- raw ints, matching get_app_lunar_date()/
|
|
476
|
+
# get_device_local_date()'s own attribute reads (both attribute
|
|
477
|
+
# 201/207/202 decodes match exactly what those methods read).
|
|
478
|
+
epoch = None
|
|
479
|
+
raw = _first_value(C2Attribute.Epoch)
|
|
480
|
+
if raw and len(raw) >= 4:
|
|
481
|
+
epoch = struct.unpack("<I", raw)[0]
|
|
482
|
+
local_time = None
|
|
483
|
+
raw = _first_value(C2Attribute.LocalTime)
|
|
484
|
+
if raw and len(raw) >= 4:
|
|
485
|
+
local_time = struct.unpack("<I", raw)[0]
|
|
486
|
+
tz_offset = None
|
|
487
|
+
raw = _first_value(C2Attribute.TimeZoneOffset)
|
|
488
|
+
if raw and len(raw) >= 2:
|
|
489
|
+
tz_offset = struct.unpack("<h", raw)[0]
|
|
490
|
+
|
|
491
|
+
return MetadataSnapshot(
|
|
492
|
+
advanced_features=advanced_features, calibration=calibration,
|
|
493
|
+
hardware_info=hardware_info, firmware_versions=firmware_versions,
|
|
494
|
+
supported_channels=supported_channels, error_state=error_state,
|
|
495
|
+
epoch=epoch, local_time=local_time, tz_offset=tz_offset,
|
|
496
|
+
used_batch=True,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def decode_light_poll_from_batch(
|
|
501
|
+
by_attr_id: dict, which: int, minute_of_day: int, now: "datetime.datetime",
|
|
502
|
+
) -> "LightPollResult":
|
|
503
|
+
"""
|
|
504
|
+
Pure decode, extracted from _get_light_poll_via_batch() itself --
|
|
505
|
+
same reasoning as decode_metadata_from_batch() right above: reusable
|
|
506
|
+
by any caller with a by_attr_id dict grouped the same way, whether
|
|
507
|
+
it came from get_light_poll_batch()'s own smaller request or a
|
|
508
|
+
larger, combined one that happens to include everything this needs
|
|
509
|
+
too (see get_full_poll_batch()). used_batch=True unconditionally.
|
|
510
|
+
"""
|
|
511
|
+
schedule_attr = C2Attribute.Schedule1 if which == 1 else C2Attribute.Schedule2
|
|
512
|
+
intensity_attr = C2Attribute.Schedule1Intensity if which == 1 else C2Attribute.Schedule2Intensity
|
|
513
|
+
|
|
514
|
+
def _values_for(attr_id):
|
|
515
|
+
blocks = by_attr_id.get(attr_id, [])
|
|
516
|
+
values = []
|
|
517
|
+
for block in sorted(blocks, key=lambda b: b.index):
|
|
518
|
+
values.extend(block.values)
|
|
519
|
+
return values
|
|
520
|
+
|
|
521
|
+
points = decode_schedule_points(_values_for(schedule_attr))
|
|
522
|
+
lunar_enabled = decode_lunar_enabled(_values_for(C2Attribute.LunarPhasesEnabled))
|
|
523
|
+
|
|
524
|
+
epoch_values = _values_for(C2Attribute.Epoch)
|
|
525
|
+
epoch = struct.unpack("<I", epoch_values[0])[0] if epoch_values else None
|
|
526
|
+
local_time_values = _values_for(C2Attribute.LocalTime)
|
|
527
|
+
local_time = struct.unpack("<I", local_time_values[0])[0] if local_time_values else None
|
|
528
|
+
lunar_date = compute_app_lunar_date(epoch, local_time)
|
|
529
|
+
lunar_date_source = "app_lunar_date" if lunar_date is not None else None
|
|
530
|
+
|
|
531
|
+
schedule_intensity = decode_schedule_intensity(_values_for(intensity_attr))
|
|
532
|
+
acclimation_info = decode_acclimation_info(
|
|
533
|
+
_values_for(C2Attribute.AcclimationEnabled), _values_for(C2Attribute.AcclimationPeriod),
|
|
534
|
+
_values_for(C2Attribute.AcclimationStartIntensity), _values_for(C2Attribute.AcclimationStartTime),
|
|
535
|
+
)
|
|
536
|
+
insolation_active = decode_insolation_enabled(_values_for(C2Attribute.InsolationEnabled))
|
|
537
|
+
|
|
538
|
+
intensities = process_light_intensities(
|
|
539
|
+
points, minute_of_day, now,
|
|
540
|
+
lunar_enabled, lunar_date, lunar_date_source,
|
|
541
|
+
schedule_intensity, acclimation_info, insolation_active,
|
|
542
|
+
)
|
|
543
|
+
return LightPollResult(schedule_points=points, intensities=intensities, used_batch=True)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def decode_pump_flow_range(min_raw: list[bytes], max_raw: list[bytes]) -> Optional[PumpFlowRange]:
|
|
547
|
+
"""
|
|
548
|
+
Pure decode for MinimumGallonsPerHour/MaximumGallonsPerHour
|
|
549
|
+
(707/708), extracted from get_pump_flow_range() itself -- see that
|
|
550
|
+
method's own docstring for the confirmed meaning. None if either
|
|
551
|
+
input is empty.
|
|
552
|
+
"""
|
|
553
|
+
if not (min_raw and max_raw):
|
|
554
|
+
return None
|
|
555
|
+
return PumpFlowRange(
|
|
556
|
+
minimum_gph=struct.unpack("<h", min_raw[0])[0],
|
|
557
|
+
maximum_gph=struct.unpack("<h", max_raw[0])[0],
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def decode_pump_telemetry(
|
|
562
|
+
speed_raw: list[bytes], gph_raw: list[bytes], motor_power_raw: list[bytes],
|
|
563
|
+
flow_range: Optional[PumpFlowRange], model: Optional[Model], primitive: Optional[PrimitiveType],
|
|
564
|
+
) -> dict:
|
|
565
|
+
"""
|
|
566
|
+
Pure decode, extracted from get_pump_telemetry() itself -- see
|
|
567
|
+
that method's own docstring for the full confirmed field meanings
|
|
568
|
+
and the gph_reliable determination logic in particular (the actual
|
|
569
|
+
point of that method's own docstring). Takes an already-decoded
|
|
570
|
+
flow_range (from decode_pump_flow_range() above) rather than raw
|
|
571
|
+
bytes for it, since that's itself a composed value, not a single
|
|
572
|
+
attribute read.
|
|
573
|
+
"""
|
|
574
|
+
speed = struct.unpack("<h", speed_raw[0])[0] if speed_raw else None
|
|
575
|
+
gph = struct.unpack("<i", gph_raw[0])[0] if gph_raw else None
|
|
576
|
+
motor_power_watts = struct.unpack("<i", motor_power_raw[0])[0] if motor_power_raw else None
|
|
577
|
+
speed_percent = abs(speed) / 10 if speed is not None else None
|
|
578
|
+
|
|
579
|
+
is_legacy_nero = model in (Model.Nero3, Model.Nero5, Model.Nero7)
|
|
580
|
+
if is_legacy_nero:
|
|
581
|
+
gph_reliable = gph is not None
|
|
582
|
+
else:
|
|
583
|
+
gph_reliable = primitive != PrimitiveType.VectraV1 and flow_range is not None
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
"speed": speed, "speed_percent": speed_percent, "gph": gph,
|
|
587
|
+
"motor_power_watts": motor_power_watts,
|
|
588
|
+
"minimum_gph": flow_range.minimum_gph if flow_range else None,
|
|
589
|
+
"maximum_gph": flow_range.maximum_gph if flow_range else None,
|
|
590
|
+
"gph_reliable": gph_reliable,
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def decode_device_info(
|
|
595
|
+
model_raw: Optional[bytes], name_raw: Optional[bytes], serial_raw: Optional[bytes],
|
|
596
|
+
primitive_raw: Optional[bytes], error_raw: Optional[bytes], mac_raw: Optional[bytes],
|
|
597
|
+
) -> dict:
|
|
598
|
+
"""
|
|
599
|
+
Pure decode, extracted VERBATIM from get_device_info() itself --
|
|
600
|
+
see that method's own docstring for the confirmed field list.
|
|
601
|
+
Every input is the raw bytes get_attribute() itself would have
|
|
602
|
+
returned for that attribute (or None if unsupported/not fetched --
|
|
603
|
+
e.g. get_full_poll_batch() never fetches PrimitiveType at all,
|
|
604
|
+
since that's known once and cached by its own caller instead of
|
|
605
|
+
being re-fetched every poll; pass None for primitive_raw in that
|
|
606
|
+
case, same as this always did for any attribute that failed to
|
|
607
|
+
fetch, and "primitive_type" simply comes back None in the result).
|
|
608
|
+
"""
|
|
609
|
+
model_val = struct.unpack("<h", model_raw)[0] if model_raw and len(model_raw) >= 2 else None
|
|
610
|
+
try:
|
|
611
|
+
model = Model(model_val) if model_val is not None else None
|
|
612
|
+
except ValueError:
|
|
613
|
+
model = None
|
|
614
|
+
|
|
615
|
+
error_val = struct.unpack("<h", error_raw)[0] if error_raw and len(error_raw) >= 2 else None
|
|
616
|
+
try:
|
|
617
|
+
error = ErrorState(error_val) if error_val is not None else None
|
|
618
|
+
except ValueError:
|
|
619
|
+
error = None
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
"model_raw": model_val,
|
|
623
|
+
"model": model.name if model is not None else (f"unknown({model_val})" if model_val is not None else None),
|
|
624
|
+
"manufacturer": manufacturer_for_model(model),
|
|
625
|
+
"name": name_raw.decode("utf-8", errors="replace").rstrip("\x00") if name_raw else None,
|
|
626
|
+
"serial": _decode_serial(serial_raw) if serial_raw else None,
|
|
627
|
+
"primitive_type": PrimitiveType(primitive_raw[0]).name if primitive_raw else None,
|
|
628
|
+
"error_state": error.name if error is not None else (f"unknown({error_val})" if error_val is not None else None),
|
|
629
|
+
"mac_address": ":".join(f"{b:02X}" for b in mac_raw) if mac_raw else None,
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def compute_app_lunar_date(
|
|
634
|
+
epoch_seconds: Optional[int], local_time_seconds: Optional[int],
|
|
635
|
+
) -> Optional["datetime.date"]:
|
|
636
|
+
"""
|
|
637
|
+
Pure computation, extracted VERBATIM from get_app_lunar_date()
|
|
638
|
+
itself -- see that method's own docstring for the full, real-
|
|
639
|
+
hardware-confirmed derivation of this exact formula
|
|
640
|
+
(`2 * LocalTime - Epoch`, deliberately replicating a confirmed app
|
|
641
|
+
bug rather than computing the physically correct date). None if
|
|
642
|
+
either input is None, matching that method's own fallback
|
|
643
|
+
contract.
|
|
644
|
+
"""
|
|
645
|
+
if epoch_seconds is None or local_time_seconds is None:
|
|
646
|
+
return None
|
|
647
|
+
import datetime as _datetime
|
|
648
|
+
effective_seconds = 2 * local_time_seconds - epoch_seconds
|
|
649
|
+
return _datetime.datetime.fromtimestamp(effective_seconds, tz=_datetime.timezone.utc).date()
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def process_light_intensities(
|
|
653
|
+
points: list[SchedulePoint], minute_of_day: int, now: "datetime.datetime",
|
|
654
|
+
lunar_enabled: Optional[bool], lunar_date: Optional["datetime.date"], lunar_date_source: Optional[str],
|
|
655
|
+
schedule_intensity: Optional[float], acclimation_info: Optional[AcclimationInfo],
|
|
656
|
+
insolation_active: bool,
|
|
657
|
+
) -> LightIntensityResult:
|
|
658
|
+
"""
|
|
659
|
+
Pure computation, extracted VERBATIM (same branching, same
|
|
660
|
+
variable reassignments, in the same order) from
|
|
661
|
+
get_current_light_intensities() itself -- see that method's own
|
|
662
|
+
docstring for the full, real-hardware-confirmed branching logic
|
|
663
|
+
this replicates exactly. This is the ONLY place that logic lives;
|
|
664
|
+
get_current_light_intensities() itself is now a thin wrapper that
|
|
665
|
+
fetches its own inputs (lazily, only whichever branch's own inputs
|
|
666
|
+
are actually needed, exactly as it always has) and calls this, and
|
|
667
|
+
get_light_poll_batch() calls this too, with everything fetched
|
|
668
|
+
upfront in one round trip regardless of which branch ends up
|
|
669
|
+
mattering.
|
|
670
|
+
|
|
671
|
+
Every optional input here is used ONLY by the branch that actually
|
|
672
|
+
needs it, matching the original method's own conditional-fetch
|
|
673
|
+
pattern -- an input the taken branch doesn't need is simply
|
|
674
|
+
ignored, whatever value (or None) the caller happened to supply
|
|
675
|
+
for it. schedule_intensity defaults to 0.5 if given as None
|
|
676
|
+
(matching get_schedule_intensity()'s own fail-soft default) --
|
|
677
|
+
belt-and-suspenders only, since both current callers already
|
|
678
|
+
guarantee a real float via decode_schedule_intensity()'s own same
|
|
679
|
+
default; never actually exercised by either as of this writing.
|
|
680
|
+
"""
|
|
681
|
+
raw_intensities = interpolate_light_schedule(points, minute_of_day)
|
|
682
|
+
|
|
683
|
+
night_segment = is_night_segment(points, minute_of_day)
|
|
684
|
+
if night_segment:
|
|
685
|
+
if lunar_enabled:
|
|
686
|
+
if lunar_date is None:
|
|
687
|
+
lunar_date = now.date()
|
|
688
|
+
lunar_date_source = "local_fallback"
|
|
689
|
+
scalar = lunar_percent_reduction(lunar_date)
|
|
690
|
+
scalar_source = "lunar"
|
|
691
|
+
else:
|
|
692
|
+
lunar_date = None
|
|
693
|
+
lunar_date_source = None
|
|
694
|
+
scalar = 1.0
|
|
695
|
+
scalar_source = "night_no_lunar"
|
|
696
|
+
else:
|
|
697
|
+
lunar_enabled = None # not checked/relevant outside the night segment
|
|
698
|
+
lunar_date = None
|
|
699
|
+
lunar_date_source = None
|
|
700
|
+
scalar = schedule_intensity if schedule_intensity is not None else 0.5
|
|
701
|
+
scalar_source = "schedule_intensity"
|
|
702
|
+
if acclimation_info and acclimation_info.enabled and not acclimation_info.is_complete(now):
|
|
703
|
+
scalar *= acclimation_info.current_intensity(now) / 1000.0
|
|
704
|
+
|
|
705
|
+
result = {ch: value * scalar for ch, value in raw_intensities.items()}
|
|
706
|
+
return LightIntensityResult(result, diagnostics={
|
|
707
|
+
"insolation_active": insolation_active,
|
|
708
|
+
"is_night_segment": night_segment,
|
|
709
|
+
"lunar_enabled": lunar_enabled,
|
|
710
|
+
"scalar_source": scalar_source,
|
|
711
|
+
"scalar": scalar,
|
|
712
|
+
"lunar_date": lunar_date,
|
|
713
|
+
"lunar_date_source": lunar_date_source,
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
|
|
199
717
|
class MobiusDevice:
|
|
200
718
|
"""
|
|
201
719
|
High-level async client for a single Mobius-protocol BLE device.
|
|
@@ -813,28 +1331,7 @@ class MobiusDevice:
|
|
|
813
1331
|
error_raw = await _get(C2Attribute.ErrorState)
|
|
814
1332
|
mac_raw = await _get(C2Attribute.MACAddress)
|
|
815
1333
|
|
|
816
|
-
|
|
817
|
-
try:
|
|
818
|
-
model = Model(model_val) if model_val is not None else None
|
|
819
|
-
except ValueError:
|
|
820
|
-
model = None
|
|
821
|
-
|
|
822
|
-
error_val = struct.unpack("<h", error_raw)[0] if error_raw and len(error_raw) >= 2 else None
|
|
823
|
-
try:
|
|
824
|
-
error = ErrorState(error_val) if error_val is not None else None
|
|
825
|
-
except ValueError:
|
|
826
|
-
error = None
|
|
827
|
-
|
|
828
|
-
return {
|
|
829
|
-
"model_raw": model_val,
|
|
830
|
-
"model": model.name if model is not None else (f"unknown({model_val})" if model_val is not None else None),
|
|
831
|
-
"manufacturer": manufacturer_for_model(model),
|
|
832
|
-
"name": name_raw.decode("utf-8", errors="replace").rstrip("\x00") if name_raw else None,
|
|
833
|
-
"serial": _decode_serial(serial_raw) if serial_raw else None,
|
|
834
|
-
"primitive_type": PrimitiveType(primitive_raw[0]).name if primitive_raw else None,
|
|
835
|
-
"error_state": error.name if error is not None else (f"unknown({error_val})" if error_val is not None else None),
|
|
836
|
-
"mac_address": ":".join(f"{b:02X}" for b in mac_raw) if mac_raw else None,
|
|
837
|
-
}
|
|
1334
|
+
return decode_device_info(model_raw, name_raw, serial_raw, primitive_raw, error_raw, mac_raw)
|
|
838
1335
|
|
|
839
1336
|
async def get_group_info(self) -> GroupInfo:
|
|
840
1337
|
"""
|
|
@@ -1670,115 +2167,7 @@ class MobiusDevice:
|
|
|
1670
2167
|
for av in raw_results:
|
|
1671
2168
|
by_attr_id.setdefault(av.attr_id, []).append(av)
|
|
1672
2169
|
|
|
1673
|
-
|
|
1674
|
-
blocks = by_attr_id.get(attr_id)
|
|
1675
|
-
if not blocks:
|
|
1676
|
-
return None
|
|
1677
|
-
for block in sorted(blocks, key=lambda b: b.index):
|
|
1678
|
-
if block.values:
|
|
1679
|
-
return block.values[0]
|
|
1680
|
-
return None
|
|
1681
|
-
|
|
1682
|
-
def _all_blocks(attr_id):
|
|
1683
|
-
return by_attr_id.get(attr_id, [])
|
|
1684
|
-
|
|
1685
|
-
# -- AdvancedFeatures -- matching get_advanced_features()'s own decode exactly.
|
|
1686
|
-
local_control_enabled = None
|
|
1687
|
-
raw = _first_value(C2Attribute.LocalControlEnabled)
|
|
1688
|
-
if raw:
|
|
1689
|
-
local_control_enabled = raw[0] > 0
|
|
1690
|
-
auto_dim_timeout = None
|
|
1691
|
-
raw = _first_value(C2Attribute.AutoDimTimeout)
|
|
1692
|
-
if raw:
|
|
1693
|
-
auto_dim_timeout = struct.unpack("<h", raw)[0]
|
|
1694
|
-
max_fan_speed = None
|
|
1695
|
-
raw = _first_value(C2Attribute.MaxFanSpeed)
|
|
1696
|
-
if raw:
|
|
1697
|
-
raw_speed = struct.unpack("<h", raw)[0]
|
|
1698
|
-
max_fan_speed = 100.0 if raw_speed == -1 else raw_speed / 10.0
|
|
1699
|
-
fan_shutdown_enabled = None
|
|
1700
|
-
raw = _first_value(C2Attribute.FanShutdownEnabled)
|
|
1701
|
-
if raw:
|
|
1702
|
-
fan_shutdown_enabled = raw[0] > 0
|
|
1703
|
-
advanced_features = None
|
|
1704
|
-
if any([
|
|
1705
|
-
local_control_enabled is not None, auto_dim_timeout is not None,
|
|
1706
|
-
max_fan_speed is not None, fan_shutdown_enabled is not None,
|
|
1707
|
-
]):
|
|
1708
|
-
advanced_features = AdvancedFeatures(
|
|
1709
|
-
local_control_enabled=local_control_enabled, auto_dim_timeout=auto_dim_timeout,
|
|
1710
|
-
max_fan_speed=max_fan_speed, fan_shutdown_enabled=fan_shutdown_enabled,
|
|
1711
|
-
)
|
|
1712
|
-
|
|
1713
|
-
# -- CalibrationInfo -- matching get_calibration_info()'s own decode exactly.
|
|
1714
|
-
calibration = None
|
|
1715
|
-
completed_raw = _first_value(C2Attribute.IsCalibrated)
|
|
1716
|
-
date_raw = _first_value(C2Attribute.LastCalibrationTime)
|
|
1717
|
-
if completed_raw and date_raw:
|
|
1718
|
-
lower_bound = None
|
|
1719
|
-
raw = _first_value(C2Attribute.MinCalibratedSpeed)
|
|
1720
|
-
if raw:
|
|
1721
|
-
lower_bound = struct.unpack("<h", raw)[0]
|
|
1722
|
-
upper_bound = None
|
|
1723
|
-
raw = _first_value(C2Attribute.MaxCalibratedSpeed)
|
|
1724
|
-
if raw:
|
|
1725
|
-
upper_bound = struct.unpack("<h", raw)[0]
|
|
1726
|
-
calibration = CalibrationInfo(
|
|
1727
|
-
completed=completed_raw[0] > 0,
|
|
1728
|
-
date_of_last=struct.unpack("<i", date_raw)[0],
|
|
1729
|
-
lower_bound=lower_bound, upper_bound=upper_bound,
|
|
1730
|
-
)
|
|
1731
|
-
|
|
1732
|
-
# -- hardware_info / firmware_versions -- shared decode helpers,
|
|
1733
|
-
# matching get_hardware_info()/get_firmware_versions() exactly.
|
|
1734
|
-
hardware_info = decode_hardware_revision_blocks(_all_blocks(C2Attribute.HardwareRevision))
|
|
1735
|
-
firmware_versions = decode_firmware_version_blocks(_all_blocks(C2Attribute.FirmwareVersion), model)
|
|
1736
|
-
|
|
1737
|
-
# -- supported_channels -- matching get_supported_channels()'s
|
|
1738
|
-
# own decode exactly (flattening every block's own values, in
|
|
1739
|
-
# index order, same as get_attribute_all() does internally).
|
|
1740
|
-
supported_channels = []
|
|
1741
|
-
for block in sorted(_all_blocks(C2Attribute.SupportedColorChannels), key=lambda b: b.index):
|
|
1742
|
-
for v in block.values:
|
|
1743
|
-
if v:
|
|
1744
|
-
try:
|
|
1745
|
-
supported_channels.append(VisualID(v[0]))
|
|
1746
|
-
except ValueError:
|
|
1747
|
-
pass
|
|
1748
|
-
|
|
1749
|
-
# -- error_state -- matching get_device_info()'s own decode exactly.
|
|
1750
|
-
error_state = None
|
|
1751
|
-
raw = _first_value(C2Attribute.ErrorState)
|
|
1752
|
-
if raw and len(raw) >= 2:
|
|
1753
|
-
error_val = struct.unpack("<h", raw)[0]
|
|
1754
|
-
try:
|
|
1755
|
-
error_state = ErrorState(error_val).name
|
|
1756
|
-
except ValueError:
|
|
1757
|
-
error_state = f"unknown({error_val})"
|
|
1758
|
-
|
|
1759
|
-
# -- time fields -- raw ints, matching get_app_lunar_date()/
|
|
1760
|
-
# get_device_local_date()'s own attribute reads (both attribute
|
|
1761
|
-
# 201/207/202 decodes match exactly what those methods read).
|
|
1762
|
-
epoch = None
|
|
1763
|
-
raw = _first_value(C2Attribute.Epoch)
|
|
1764
|
-
if raw and len(raw) >= 4:
|
|
1765
|
-
epoch = struct.unpack("<I", raw)[0]
|
|
1766
|
-
local_time = None
|
|
1767
|
-
raw = _first_value(C2Attribute.LocalTime)
|
|
1768
|
-
if raw and len(raw) >= 4:
|
|
1769
|
-
local_time = struct.unpack("<I", raw)[0]
|
|
1770
|
-
tz_offset = None
|
|
1771
|
-
raw = _first_value(C2Attribute.TimeZoneOffset)
|
|
1772
|
-
if raw and len(raw) >= 2:
|
|
1773
|
-
tz_offset = struct.unpack("<h", raw)[0]
|
|
1774
|
-
|
|
1775
|
-
return MetadataSnapshot(
|
|
1776
|
-
advanced_features=advanced_features, calibration=calibration,
|
|
1777
|
-
hardware_info=hardware_info, firmware_versions=firmware_versions,
|
|
1778
|
-
supported_channels=supported_channels, error_state=error_state,
|
|
1779
|
-
epoch=epoch, local_time=local_time, tz_offset=tz_offset,
|
|
1780
|
-
used_batch=True,
|
|
1781
|
-
)
|
|
2170
|
+
return decode_metadata_from_batch(by_attr_id, model)
|
|
1782
2171
|
|
|
1783
2172
|
async def _get_metadata_via_individual_reads(self, model: Optional[Model]) -> MetadataSnapshot:
|
|
1784
2173
|
"""
|
|
@@ -2086,11 +2475,7 @@ class MobiusDevice:
|
|
|
2086
2475
|
local_time_seconds = struct.unpack("<I", raw[0])[0] if raw else None
|
|
2087
2476
|
except Exception:
|
|
2088
2477
|
return None
|
|
2089
|
-
|
|
2090
|
-
return None
|
|
2091
|
-
import datetime as _datetime
|
|
2092
|
-
effective_seconds = 2 * local_time_seconds - epoch_seconds
|
|
2093
|
-
return _datetime.datetime.fromtimestamp(effective_seconds, tz=_datetime.timezone.utc).date()
|
|
2478
|
+
return compute_app_lunar_date(epoch_seconds, local_time_seconds)
|
|
2094
2479
|
|
|
2095
2480
|
async def get_own_mesh_address(self) -> Optional[bytes]:
|
|
2096
2481
|
"""
|
|
@@ -2646,25 +3031,8 @@ class MobiusDevice:
|
|
|
2646
3031
|
motor_power_raw = await self.get_attribute(
|
|
2647
3032
|
C2Attribute.PhysicalValues, index=int(PhysicalValueID.MotorPower), count=1
|
|
2648
3033
|
)
|
|
2649
|
-
speed = struct.unpack("<h", speed_raw[0])[0] if speed_raw else None
|
|
2650
|
-
gph = struct.unpack("<i", gph_raw[0])[0] if gph_raw else None
|
|
2651
|
-
motor_power_watts = struct.unpack("<i", motor_power_raw[0])[0] if motor_power_raw else None
|
|
2652
|
-
speed_percent = abs(speed) / 10 if speed is not None else None
|
|
2653
|
-
|
|
2654
3034
|
flow_range = await self.get_pump_flow_range()
|
|
2655
|
-
|
|
2656
|
-
if is_legacy_nero:
|
|
2657
|
-
gph_reliable = gph is not None
|
|
2658
|
-
else:
|
|
2659
|
-
gph_reliable = primitive != PrimitiveType.VectraV1 and flow_range is not None
|
|
2660
|
-
|
|
2661
|
-
return {
|
|
2662
|
-
"speed": speed, "speed_percent": speed_percent, "gph": gph,
|
|
2663
|
-
"motor_power_watts": motor_power_watts,
|
|
2664
|
-
"minimum_gph": flow_range.minimum_gph if flow_range else None,
|
|
2665
|
-
"maximum_gph": flow_range.maximum_gph if flow_range else None,
|
|
2666
|
-
"gph_reliable": gph_reliable,
|
|
2667
|
-
}
|
|
3035
|
+
return decode_pump_telemetry(speed_raw, gph_raw, motor_power_raw, flow_range, model, primitive)
|
|
2668
3036
|
|
|
2669
3037
|
async def get_pump_flow_range(self) -> Optional[PumpFlowRange]:
|
|
2670
3038
|
"""
|
|
@@ -2679,12 +3047,7 @@ class MobiusDevice:
|
|
|
2679
3047
|
max_raw = await self.get_attribute(C2Attribute.MaximumGallonsPerHour)
|
|
2680
3048
|
except Exception:
|
|
2681
3049
|
return None
|
|
2682
|
-
|
|
2683
|
-
return None
|
|
2684
|
-
return PumpFlowRange(
|
|
2685
|
-
minimum_gph=struct.unpack("<h", min_raw[0])[0],
|
|
2686
|
-
maximum_gph=struct.unpack("<h", max_raw[0])[0],
|
|
2687
|
-
)
|
|
3050
|
+
return decode_pump_flow_range(min_raw, max_raw)
|
|
2688
3051
|
|
|
2689
3052
|
async def get_pump_override_mode(self) -> Optional[PumpOverrideMode]:
|
|
2690
3053
|
"""
|
|
@@ -2768,12 +3131,7 @@ class MobiusDevice:
|
|
|
2768
3131
|
"""
|
|
2769
3132
|
attr = C2Attribute.Schedule1 if which == 1 else C2Attribute.Schedule2
|
|
2770
3133
|
raw = await self.get_attribute(attr, index=0, count=0xFFFF)
|
|
2771
|
-
|
|
2772
|
-
for element in raw:
|
|
2773
|
-
pt = SchedulePoint.parse(element)
|
|
2774
|
-
if pt is not None:
|
|
2775
|
-
points.append(pt)
|
|
2776
|
-
return points
|
|
3134
|
+
return decode_schedule_points(raw)
|
|
2777
3135
|
|
|
2778
3136
|
async def get_schedule_intensity(self, which: int = 1) -> float:
|
|
2779
3137
|
"""
|
|
@@ -2809,10 +3167,7 @@ class MobiusDevice:
|
|
|
2809
3167
|
raw = await self.get_attribute(attr)
|
|
2810
3168
|
except Exception:
|
|
2811
3169
|
return 0.5
|
|
2812
|
-
|
|
2813
|
-
return 0.5
|
|
2814
|
-
value = struct.unpack("<h", raw[0])[0]
|
|
2815
|
-
return value / 1000.0
|
|
3170
|
+
return decode_schedule_intensity(raw)
|
|
2816
3171
|
|
|
2817
3172
|
async def get_lunar_enabled(self, which: int = 1) -> bool:
|
|
2818
3173
|
"""Fetches LunarPhasesEnabled (907). Returns False (not raises) on
|
|
@@ -2823,9 +3178,7 @@ class MobiusDevice:
|
|
|
2823
3178
|
raw = await self.get_attribute(C2Attribute.LunarPhasesEnabled)
|
|
2824
3179
|
except Exception:
|
|
2825
3180
|
return False
|
|
2826
|
-
|
|
2827
|
-
return False
|
|
2828
|
-
return raw[0][0] > 0
|
|
3181
|
+
return decode_lunar_enabled(raw)
|
|
2829
3182
|
|
|
2830
3183
|
async def get_acclimation_info(self) -> Optional[AcclimationInfo]:
|
|
2831
3184
|
"""
|
|
@@ -2842,13 +3195,7 @@ class MobiusDevice:
|
|
|
2842
3195
|
start_time_raw = await self.get_attribute(C2Attribute.AcclimationStartTime)
|
|
2843
3196
|
except Exception:
|
|
2844
3197
|
return None
|
|
2845
|
-
|
|
2846
|
-
return None
|
|
2847
|
-
enabled = enabled_raw[0][0] > 0
|
|
2848
|
-
period = period_raw[0][0]
|
|
2849
|
-
start_intensity = struct.unpack("<h", start_intensity_raw[0])[0]
|
|
2850
|
-
start_time = struct.unpack("<i", start_time_raw[0])[0]
|
|
2851
|
-
return AcclimationInfo(enabled, start_time, period, start_intensity)
|
|
3198
|
+
return decode_acclimation_info(enabled_raw, period_raw, start_intensity_raw, start_time_raw)
|
|
2852
3199
|
|
|
2853
3200
|
async def get_insolation_enabled(self, which: int = 1) -> bool:
|
|
2854
3201
|
"""
|
|
@@ -2867,9 +3214,7 @@ class MobiusDevice:
|
|
|
2867
3214
|
raw = await self.get_attribute(C2Attribute.InsolationEnabled)
|
|
2868
3215
|
except Exception:
|
|
2869
3216
|
return False
|
|
2870
|
-
|
|
2871
|
-
return False
|
|
2872
|
-
return raw[0][0] > 0
|
|
3217
|
+
return decode_insolation_enabled(raw)
|
|
2873
3218
|
|
|
2874
3219
|
async def get_channel_power_info(self) -> ChannelPowerInfo:
|
|
2875
3220
|
"""
|
|
@@ -3036,43 +3381,361 @@ class MobiusDevice:
|
|
|
3036
3381
|
minute_of_day = now.hour * 60 + now.minute
|
|
3037
3382
|
|
|
3038
3383
|
points = await self.get_light_schedule(which)
|
|
3039
|
-
raw_intensities = interpolate_light_schedule(points, minute_of_day)
|
|
3040
|
-
|
|
3041
3384
|
night_segment = is_night_segment(points, minute_of_day)
|
|
3385
|
+
|
|
3386
|
+
lunar_enabled = None
|
|
3042
3387
|
lunar_date = None
|
|
3043
3388
|
lunar_date_source = None
|
|
3389
|
+
schedule_intensity = None
|
|
3390
|
+
acclimation_info = None
|
|
3044
3391
|
if night_segment:
|
|
3045
3392
|
lunar_enabled = await self.get_lunar_enabled(which)
|
|
3046
3393
|
if lunar_enabled:
|
|
3047
3394
|
lunar_date = await self.get_app_lunar_date()
|
|
3048
3395
|
lunar_date_source = "app_lunar_date"
|
|
3049
|
-
if lunar_date is None:
|
|
3050
|
-
lunar_date = now.date()
|
|
3051
|
-
lunar_date_source = "local_fallback"
|
|
3052
|
-
scalar = lunar_percent_reduction(lunar_date)
|
|
3053
|
-
scalar_source = "lunar"
|
|
3054
|
-
else:
|
|
3055
|
-
scalar = 1.0
|
|
3056
|
-
scalar_source = "night_no_lunar"
|
|
3057
3396
|
else:
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
scalar_source = "schedule_intensity"
|
|
3061
|
-
acclimation = await self.get_acclimation_info()
|
|
3062
|
-
if acclimation and acclimation.enabled and not acclimation.is_complete(now):
|
|
3063
|
-
scalar *= acclimation.current_intensity(now) / 1000.0
|
|
3397
|
+
schedule_intensity = await self.get_schedule_intensity(which)
|
|
3398
|
+
acclimation_info = await self.get_acclimation_info()
|
|
3064
3399
|
|
|
3065
3400
|
insolation_active = await self.get_insolation_enabled(which)
|
|
3066
|
-
|
|
3067
|
-
return
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3401
|
+
|
|
3402
|
+
return process_light_intensities(
|
|
3403
|
+
points, minute_of_day, now,
|
|
3404
|
+
lunar_enabled, lunar_date, lunar_date_source,
|
|
3405
|
+
schedule_intensity, acclimation_info, insolation_active,
|
|
3406
|
+
)
|
|
3407
|
+
|
|
3408
|
+
async def get_light_poll_batch(
|
|
3409
|
+
self, which: int = 1, minute_of_day: Optional[int] = None,
|
|
3410
|
+
now: Optional["datetime.datetime"] = None,
|
|
3411
|
+
supported_attribute_ids: Optional[set] = None,
|
|
3412
|
+
force_individual_reads: bool = False,
|
|
3413
|
+
) -> LightPollResult:
|
|
3414
|
+
"""
|
|
3415
|
+
ONE combined round-trip covering everything a light device's
|
|
3416
|
+
own poll needs beyond get_metadata_batch(): the schedule
|
|
3417
|
+
itself (Schedule1/Schedule2), LunarPhasesEnabled, Epoch/
|
|
3418
|
+
LocalTime (for the app's own lunar-date calculation -- see
|
|
3419
|
+
compute_app_lunar_date()), Schedule1Intensity/
|
|
3420
|
+
Schedule2Intensity, the 4 Acclimation* attributes, and
|
|
3421
|
+
InsolationEnabled -- everything process_light_intensities()
|
|
3422
|
+
might need, regardless of which of its own branches
|
|
3423
|
+
(night-segment+lunar vs daytime+acclimation) ends up actually
|
|
3424
|
+
mattering, since that isn't knowable until AFTER the schedule
|
|
3425
|
+
itself is fetched and interpolated. Reuses that exact same,
|
|
3426
|
+
single-source-of-truth function for the actual computation --
|
|
3427
|
+
this method's own job is purely gathering the raw inputs
|
|
3428
|
+
differently (one batch instead of several individual reads),
|
|
3429
|
+
never re-implementing any of the branching logic itself.
|
|
3430
|
+
|
|
3431
|
+
FALLBACK: same pattern as get_metadata_batch() -- if the
|
|
3432
|
+
batched request itself fails for any reason (a device that
|
|
3433
|
+
doesn't support the batch-get mechanism at all), falls back to
|
|
3434
|
+
the original, lazy-fetch get_light_schedule()/
|
|
3435
|
+
get_current_light_intensities() pair, which only fetches
|
|
3436
|
+
whichever branch's own inputs are actually needed (unlike this
|
|
3437
|
+
method's own upfront batch). Never returns less than those two
|
|
3438
|
+
would have separately.
|
|
3439
|
+
|
|
3440
|
+
`used_batch` on the returned LightPollResult is exactly how a
|
|
3441
|
+
caller knows which path actually ran -- see MetadataSnapshot's
|
|
3442
|
+
own field of the same name and purpose. `force_individual_reads`
|
|
3443
|
+
-- same purpose as get_metadata_batch()'s own parameter: skip
|
|
3444
|
+
the batch attempt entirely for a caller that's already learned
|
|
3445
|
+
this device's batch mechanism doesn't work.
|
|
3446
|
+
|
|
3447
|
+
`supported_attribute_ids` -- same purpose as
|
|
3448
|
+
get_metadata_batch()'s own parameter: pass an already-known set
|
|
3449
|
+
to skip this method's own internal get_supported_attributes()
|
|
3450
|
+
call. A device's own attribute support essentially never
|
|
3451
|
+
changes across a session, so a caller polling repeatedly can
|
|
3452
|
+
fetch this once (e.g. alongside a get_metadata_batch() call
|
|
3453
|
+
already needing it) and reuse it here too.
|
|
3454
|
+
"""
|
|
3455
|
+
import datetime as _datetime
|
|
3456
|
+
if now is None:
|
|
3457
|
+
now = _datetime.datetime.now()
|
|
3458
|
+
if minute_of_day is None:
|
|
3459
|
+
minute_of_day = now.hour * 60 + now.minute
|
|
3460
|
+
|
|
3461
|
+
if supported_attribute_ids is None:
|
|
3462
|
+
try:
|
|
3463
|
+
supported = await self.get_supported_attributes()
|
|
3464
|
+
supported_attribute_ids = {s.attr_id for s in supported}
|
|
3465
|
+
except Exception:
|
|
3466
|
+
supported_attribute_ids = set()
|
|
3467
|
+
|
|
3468
|
+
if not force_individual_reads:
|
|
3469
|
+
try:
|
|
3470
|
+
return await self._get_light_poll_via_batch(which, minute_of_day, now, supported_attribute_ids)
|
|
3471
|
+
except Exception:
|
|
3472
|
+
pass # fall through to the individual-reads path below
|
|
3473
|
+
|
|
3474
|
+
return await self._get_light_poll_via_individual_reads(which, minute_of_day, now)
|
|
3475
|
+
|
|
3476
|
+
async def _get_light_poll_via_batch(
|
|
3477
|
+
self, which: int, minute_of_day: int, now: "datetime.datetime", supported_attribute_ids: set,
|
|
3478
|
+
) -> LightPollResult:
|
|
3479
|
+
"""
|
|
3480
|
+
The actual batched-request path -- see get_light_poll_batch()'s
|
|
3481
|
+
own docstring for the full confirmed rationale and the
|
|
3482
|
+
mandatory support-filtering this relies on (a single
|
|
3483
|
+
unsupported attribute anywhere in a batched request fails the
|
|
3484
|
+
WHOLE request over relay -- confirmed on real hardware for
|
|
3485
|
+
get_metadata_batch(), same underlying mechanism here).
|
|
3486
|
+
|
|
3487
|
+
Deliberately RAISES if the batch request itself fails (does
|
|
3488
|
+
NOT swallow the exception) -- get_light_poll_batch() is what
|
|
3489
|
+
decides to fall back to individual reads on that failure; this
|
|
3490
|
+
method's own job is just the batch attempt itself.
|
|
3491
|
+
"""
|
|
3492
|
+
schedule_attr = C2Attribute.Schedule1 if which == 1 else C2Attribute.Schedule2
|
|
3493
|
+
intensity_attr = C2Attribute.Schedule1Intensity if which == 1 else C2Attribute.Schedule2Intensity
|
|
3494
|
+
|
|
3495
|
+
candidate_requests = [
|
|
3496
|
+
(schedule_attr, 0, 0xFFFF),
|
|
3497
|
+
(C2Attribute.LunarPhasesEnabled, 0, 1),
|
|
3498
|
+
(C2Attribute.Epoch, 0, 1),
|
|
3499
|
+
(C2Attribute.LocalTime, 0, 1),
|
|
3500
|
+
(intensity_attr, 0, 1),
|
|
3501
|
+
(C2Attribute.AcclimationEnabled, 0, 1),
|
|
3502
|
+
(C2Attribute.AcclimationPeriod, 0, 1),
|
|
3503
|
+
(C2Attribute.AcclimationStartIntensity, 0, 1),
|
|
3504
|
+
(C2Attribute.AcclimationStartTime, 0, 1),
|
|
3505
|
+
(C2Attribute.InsolationEnabled, 0, 1),
|
|
3506
|
+
]
|
|
3507
|
+
requests = [r for r in candidate_requests if r[0] in supported_attribute_ids]
|
|
3508
|
+
|
|
3509
|
+
by_attr_id: dict = {}
|
|
3510
|
+
if requests:
|
|
3511
|
+
raw_results = await self.get_attributes_batch(requests)
|
|
3512
|
+
for av in raw_results:
|
|
3513
|
+
by_attr_id.setdefault(av.attr_id, []).append(av)
|
|
3514
|
+
|
|
3515
|
+
return decode_light_poll_from_batch(by_attr_id, which, minute_of_day, now)
|
|
3516
|
+
|
|
3517
|
+
async def _get_light_poll_via_individual_reads(
|
|
3518
|
+
self, which: int, minute_of_day: int, now: "datetime.datetime",
|
|
3519
|
+
) -> LightPollResult:
|
|
3520
|
+
"""
|
|
3521
|
+
The fallback path -- reuses the existing, already-lazy
|
|
3522
|
+
get_light_schedule()/get_current_light_intensities() pair
|
|
3523
|
+
directly rather than duplicating their own orchestration logic
|
|
3524
|
+
a second time. Accepts the minor cost of fetching the schedule
|
|
3525
|
+
twice (once here, once again inside
|
|
3526
|
+
get_current_light_intensities() itself) -- this is already the
|
|
3527
|
+
degraded-performance path by design (matching
|
|
3528
|
+
get_metadata_batch()'s own fallback philosophy: correctness
|
|
3529
|
+
over speed once the batch mechanism itself doesn't work), so
|
|
3530
|
+
avoiding that one extra round trip here isn't worth the added
|
|
3531
|
+
code duplication. used_batch=False unconditionally -- this
|
|
3532
|
+
method IS the "batch didn't work" path, by construction.
|
|
3533
|
+
"""
|
|
3534
|
+
points = await self.get_light_schedule(which)
|
|
3535
|
+
intensities = await self.get_current_light_intensities(which, minute_of_day, now)
|
|
3536
|
+
return LightPollResult(schedule_points=points, intensities=intensities, used_batch=False)
|
|
3537
|
+
|
|
3538
|
+
async def get_full_poll_batch(
|
|
3539
|
+
self, primitive: PrimitiveType, model: Optional[Model] = None,
|
|
3540
|
+
which: int = 1, minute_of_day: Optional[int] = None, now: Optional["datetime.datetime"] = None,
|
|
3541
|
+
supported_attribute_ids: Optional[set] = None, force_individual_reads: bool = False,
|
|
3542
|
+
) -> FullPollResult:
|
|
3543
|
+
"""
|
|
3544
|
+
ONE combined round-trip for EVERYTHING a single device poll
|
|
3545
|
+
needs: device identity (Model/Name/SerialNumber/ErrorState/
|
|
3546
|
+
MACAddress -- see FullPollResult's own docstring for why
|
|
3547
|
+
PrimitiveType is deliberately excluded), everything
|
|
3548
|
+
get_metadata_batch() covers, and everything get_light_poll_batch()
|
|
3549
|
+
(for a light) or get_pump_telemetry()/get_pump_flow_range() (for
|
|
3550
|
+
a pump) covers. Reuses the exact same, single-source-of-truth
|
|
3551
|
+
decode functions those methods themselves use
|
|
3552
|
+
(decode_metadata_from_batch/decode_light_poll_from_batch/
|
|
3553
|
+
decode_pump_telemetry) against ONE shared batch response,
|
|
3554
|
+
rather than re-implementing any decode logic here.
|
|
3555
|
+
|
|
3556
|
+
Confirmed on real hardware (a real light and a real pump, both
|
|
3557
|
+
via relay): combining device identity + metadata + light-poll
|
|
3558
|
+
(or pump-telemetry) into one 18-23 attribute request measures
|
|
3559
|
+
2-2.6x faster than the three separate calls this replaces
|
|
3560
|
+
(get_device_info() + get_metadata_batch() +
|
|
3561
|
+
get_light_poll_batch()-or-get_pump_telemetry()), with every
|
|
3562
|
+
decoded value confirmed matching exactly.
|
|
3563
|
+
|
|
3564
|
+
`primitive` -- REQUIRED, unlike every other parameter here. A
|
|
3565
|
+
device's own PrimitiveType is permanent for its whole lifetime
|
|
3566
|
+
(a light is a light forever, a pump is a pump forever) -- see
|
|
3567
|
+
FullPollResult's own docstring for the full rationale. This
|
|
3568
|
+
method itself never fetches it at all; the caller must already
|
|
3569
|
+
know it (e.g. a single PrimitiveType-only read, or a single
|
|
3570
|
+
get_device_info() call, done ONCE and cached forever after --
|
|
3571
|
+
exactly the same pattern already established for
|
|
3572
|
+
supported_attribute_ids below).
|
|
3573
|
+
|
|
3574
|
+
FALLBACK: same pattern as every other *_batch() method in this
|
|
3575
|
+
library -- if the combined request itself fails, falls back to
|
|
3576
|
+
get_device_info()/get_metadata_batch()/get_light_poll_batch()-
|
|
3577
|
+
or-get_pump_telemetry() run separately, so this never returns
|
|
3578
|
+
less than calling those would have.
|
|
3579
|
+
"""
|
|
3580
|
+
import datetime as _datetime
|
|
3581
|
+
if now is None:
|
|
3582
|
+
now = _datetime.datetime.now()
|
|
3583
|
+
if minute_of_day is None:
|
|
3584
|
+
minute_of_day = now.hour * 60 + now.minute
|
|
3585
|
+
|
|
3586
|
+
if supported_attribute_ids is None:
|
|
3587
|
+
try:
|
|
3588
|
+
supported = await self.get_supported_attributes()
|
|
3589
|
+
supported_attribute_ids = {s.attr_id for s in supported}
|
|
3590
|
+
except Exception:
|
|
3591
|
+
supported_attribute_ids = set()
|
|
3592
|
+
|
|
3593
|
+
if not force_individual_reads:
|
|
3594
|
+
try:
|
|
3595
|
+
return await self._get_full_poll_via_batch(
|
|
3596
|
+
primitive, model, which, minute_of_day, now, supported_attribute_ids,
|
|
3597
|
+
)
|
|
3598
|
+
except Exception:
|
|
3599
|
+
pass # fall through to the individual-reads path below
|
|
3600
|
+
|
|
3601
|
+
return await self._get_full_poll_via_individual_reads(primitive, model, which, minute_of_day, now)
|
|
3602
|
+
|
|
3603
|
+
async def _get_full_poll_via_batch(
|
|
3604
|
+
self, primitive: PrimitiveType, model: Optional[Model],
|
|
3605
|
+
which: int, minute_of_day: int, now: "datetime.datetime", supported_attribute_ids: set,
|
|
3606
|
+
) -> FullPollResult:
|
|
3607
|
+
"""
|
|
3608
|
+
The actual batched-request path -- see get_full_poll_batch()'s
|
|
3609
|
+
own docstring for the full confirmed rationale. Deliberately
|
|
3610
|
+
RAISES if the batch itself fails -- get_full_poll_batch() is
|
|
3611
|
+
what decides to fall back to individual reads on that failure.
|
|
3612
|
+
"""
|
|
3613
|
+
schedule_attr = C2Attribute.Schedule1 if which == 1 else C2Attribute.Schedule2
|
|
3614
|
+
candidate_requests = [
|
|
3615
|
+
# -- device identity (NOT PrimitiveType -- see FullPollResult's own docstring) --
|
|
3616
|
+
(C2Attribute.Model, 0, 1),
|
|
3617
|
+
(C2Attribute.Name, 0, 1),
|
|
3618
|
+
(C2Attribute.SerialNumber, 0, 1),
|
|
3619
|
+
(C2Attribute.ErrorState, 0, 1),
|
|
3620
|
+
(C2Attribute.MACAddress, 0, 1),
|
|
3621
|
+
# -- metadata (same candidate list as get_metadata_batch() itself) --
|
|
3622
|
+
(C2Attribute.LocalControlEnabled, 0, 1),
|
|
3623
|
+
(C2Attribute.AutoDimTimeout, 0, 1),
|
|
3624
|
+
(C2Attribute.MaxFanSpeed, 0, 1),
|
|
3625
|
+
(C2Attribute.FanShutdownEnabled, 0, 1),
|
|
3626
|
+
(C2Attribute.IsCalibrated, 0, 1),
|
|
3627
|
+
(C2Attribute.LastCalibrationTime, 0, 1),
|
|
3628
|
+
(C2Attribute.MinCalibratedSpeed, 0, 1),
|
|
3629
|
+
(C2Attribute.MaxCalibratedSpeed, 0, 1),
|
|
3630
|
+
(C2Attribute.Epoch, 0, 1),
|
|
3631
|
+
(C2Attribute.LocalTime, 0, 1),
|
|
3632
|
+
(C2Attribute.TimeZoneOffset, 0, 1),
|
|
3633
|
+
(C2Attribute.SupportedColorChannels, 0, 0xFFFF),
|
|
3634
|
+
(C2Attribute.FirmwareVersion, 0, 0xFFFF),
|
|
3635
|
+
(C2Attribute.HardwareRevision, 0, 0xFFFF),
|
|
3636
|
+
(schedule_attr, 0, 0xFFFF),
|
|
3637
|
+
]
|
|
3638
|
+
if primitive == PrimitiveType.VisualV1:
|
|
3639
|
+
intensity_attr = C2Attribute.Schedule1Intensity if which == 1 else C2Attribute.Schedule2Intensity
|
|
3640
|
+
candidate_requests += [
|
|
3641
|
+
(C2Attribute.LunarPhasesEnabled, 0, 1),
|
|
3642
|
+
(intensity_attr, 0, 1),
|
|
3643
|
+
(C2Attribute.AcclimationEnabled, 0, 1),
|
|
3644
|
+
(C2Attribute.AcclimationPeriod, 0, 1),
|
|
3645
|
+
(C2Attribute.AcclimationStartIntensity, 0, 1),
|
|
3646
|
+
(C2Attribute.AcclimationStartTime, 0, 1),
|
|
3647
|
+
(C2Attribute.InsolationEnabled, 0, 1),
|
|
3648
|
+
]
|
|
3649
|
+
else:
|
|
3650
|
+
candidate_requests += [
|
|
3651
|
+
(C2Attribute.MotorSpeed, 0, 1),
|
|
3652
|
+
(C2Attribute.PhysicalValues, int(PhysicalValueID.GallonsPerHour), 1),
|
|
3653
|
+
(C2Attribute.PhysicalValues, int(PhysicalValueID.MotorPower), 1),
|
|
3654
|
+
(C2Attribute.MinimumGallonsPerHour, 0, 1),
|
|
3655
|
+
(C2Attribute.MaximumGallonsPerHour, 0, 1),
|
|
3656
|
+
]
|
|
3657
|
+
requests = [r for r in candidate_requests if r[0] in supported_attribute_ids]
|
|
3658
|
+
|
|
3659
|
+
by_attr_id: dict = {}
|
|
3660
|
+
if requests:
|
|
3661
|
+
raw_results = await self.get_attributes_batch(requests)
|
|
3662
|
+
for av in raw_results:
|
|
3663
|
+
by_attr_id.setdefault(av.attr_id, []).append(av)
|
|
3664
|
+
|
|
3665
|
+
def _first_value(attr_id, index=0):
|
|
3666
|
+
blocks = by_attr_id.get(attr_id)
|
|
3667
|
+
if not blocks:
|
|
3668
|
+
return None
|
|
3669
|
+
for block in sorted(blocks, key=lambda b: b.index):
|
|
3670
|
+
if block.index == index and block.values:
|
|
3671
|
+
return block.values[0]
|
|
3672
|
+
return None
|
|
3673
|
+
|
|
3674
|
+
def _values_for(attr_id):
|
|
3675
|
+
blocks = by_attr_id.get(attr_id, [])
|
|
3676
|
+
values = []
|
|
3677
|
+
for block in sorted(blocks, key=lambda b: b.index):
|
|
3678
|
+
values.extend(block.values)
|
|
3679
|
+
return values
|
|
3680
|
+
|
|
3681
|
+
device_info = decode_device_info(
|
|
3682
|
+
_first_value(C2Attribute.Model), _first_value(C2Attribute.Name),
|
|
3683
|
+
_first_value(C2Attribute.SerialNumber), None, # PrimitiveType never fetched here
|
|
3684
|
+
_first_value(C2Attribute.ErrorState), _first_value(C2Attribute.MACAddress),
|
|
3685
|
+
)
|
|
3686
|
+
metadata = decode_metadata_from_batch(by_attr_id, model)
|
|
3687
|
+
|
|
3688
|
+
light_poll = None
|
|
3689
|
+
pump_telemetry = None
|
|
3690
|
+
pump_schedule_points = None
|
|
3691
|
+
if primitive == PrimitiveType.VisualV1:
|
|
3692
|
+
light_poll = decode_light_poll_from_batch(by_attr_id, which, minute_of_day, now)
|
|
3693
|
+
else:
|
|
3694
|
+
pump_schedule_points = decode_pump_schedule_points(_values_for(schedule_attr))
|
|
3695
|
+
speed_raw = _first_value(C2Attribute.MotorSpeed)
|
|
3696
|
+
gph_raw = _first_value(C2Attribute.PhysicalValues, index=int(PhysicalValueID.GallonsPerHour))
|
|
3697
|
+
motor_power_raw = _first_value(C2Attribute.PhysicalValues, index=int(PhysicalValueID.MotorPower))
|
|
3698
|
+
min_gph_raw = _first_value(C2Attribute.MinimumGallonsPerHour)
|
|
3699
|
+
max_gph_raw = _first_value(C2Attribute.MaximumGallonsPerHour)
|
|
3700
|
+
flow_range = decode_pump_flow_range(
|
|
3701
|
+
[min_gph_raw] if min_gph_raw else [], [max_gph_raw] if max_gph_raw else [],
|
|
3702
|
+
)
|
|
3703
|
+
pump_telemetry = decode_pump_telemetry(
|
|
3704
|
+
[speed_raw] if speed_raw else [], [gph_raw] if gph_raw else [],
|
|
3705
|
+
[motor_power_raw] if motor_power_raw else [], flow_range, model, primitive,
|
|
3706
|
+
)
|
|
3707
|
+
|
|
3708
|
+
return FullPollResult(
|
|
3709
|
+
device_info=device_info, metadata=metadata,
|
|
3710
|
+
light_poll=light_poll, pump_telemetry=pump_telemetry,
|
|
3711
|
+
pump_schedule_points=pump_schedule_points, used_batch=True,
|
|
3712
|
+
)
|
|
3713
|
+
|
|
3714
|
+
async def _get_full_poll_via_individual_reads(
|
|
3715
|
+
self, primitive: PrimitiveType, model: Optional[Model],
|
|
3716
|
+
which: int, minute_of_day: int, now: "datetime.datetime",
|
|
3717
|
+
) -> FullPollResult:
|
|
3718
|
+
"""
|
|
3719
|
+
The fallback path -- reuses the existing, already-proven
|
|
3720
|
+
get_device_info()/get_metadata_batch()/get_light_poll_batch()-
|
|
3721
|
+
or-get_pump_telemetry() directly rather than duplicating their
|
|
3722
|
+
own orchestration logic. used_batch=False unconditionally.
|
|
3723
|
+
"""
|
|
3724
|
+
device_info = await self.get_device_info()
|
|
3725
|
+
metadata = await self.get_metadata_batch(model=model)
|
|
3726
|
+
light_poll = None
|
|
3727
|
+
pump_telemetry = None
|
|
3728
|
+
pump_schedule_points = None
|
|
3729
|
+
if primitive == PrimitiveType.VisualV1:
|
|
3730
|
+
light_poll = await self.get_light_poll_batch(which=which, minute_of_day=minute_of_day, now=now)
|
|
3731
|
+
else:
|
|
3732
|
+
pump_schedule_points = await self.get_pump_schedule(which=which)
|
|
3733
|
+
pump_telemetry = await self.get_pump_telemetry(model=model, primitive=primitive)
|
|
3734
|
+
return FullPollResult(
|
|
3735
|
+
device_info=device_info, metadata=metadata,
|
|
3736
|
+
light_poll=light_poll, pump_telemetry=pump_telemetry,
|
|
3737
|
+
pump_schedule_points=pump_schedule_points, used_batch=False,
|
|
3738
|
+
)
|
|
3076
3739
|
|
|
3077
3740
|
async def get_current_light_percentages(self, which: int = 1,
|
|
3078
3741
|
minute_of_day: Optional[int] = None,
|
|
@@ -3117,25 +3780,28 @@ class MobiusDevice:
|
|
|
3117
3780
|
"""Same Schedule1/Schedule2 attribute as lights, decoded as PumpPrimitive."""
|
|
3118
3781
|
attr = C2Attribute.Schedule1 if which == 1 else C2Attribute.Schedule2
|
|
3119
3782
|
raw = await self.get_attribute(attr, index=0, count=0xFFFF)
|
|
3120
|
-
|
|
3121
|
-
for element in raw:
|
|
3122
|
-
pt = PumpSchedulePoint.parse(element)
|
|
3123
|
-
if pt is not None:
|
|
3124
|
-
points.append(pt)
|
|
3125
|
-
return points
|
|
3783
|
+
return decode_pump_schedule_points(raw)
|
|
3126
3784
|
|
|
3127
3785
|
async def get_current_pump_block(self, which: int = 1,
|
|
3128
|
-
minute_of_day: Optional[int] = None
|
|
3786
|
+
minute_of_day: Optional[int] = None,
|
|
3787
|
+
points: Optional[list[PumpSchedulePoint]] = None) -> Optional[PumpSchedulePoint]:
|
|
3129
3788
|
"""
|
|
3130
3789
|
Returns the PumpSchedulePoint currently active (block lookup, not
|
|
3131
3790
|
interpolation -- see get_active_pump_block()). Defaults to local
|
|
3132
3791
|
system time, same rationale as get_current_light_intensities().
|
|
3792
|
+
|
|
3793
|
+
`points` -- pass an already-fetched schedule (e.g. from a
|
|
3794
|
+
caller that already called get_pump_schedule() itself for its
|
|
3795
|
+
own purposes, such as a schedule_point_count) to skip this
|
|
3796
|
+
method's own redundant re-fetch of the exact same attribute.
|
|
3797
|
+
None (the default) fetches it fresh, same as always.
|
|
3133
3798
|
"""
|
|
3134
3799
|
if minute_of_day is None:
|
|
3135
3800
|
import datetime
|
|
3136
3801
|
now = datetime.datetime.now()
|
|
3137
3802
|
minute_of_day = now.hour * 60 + now.minute
|
|
3138
|
-
points
|
|
3803
|
+
if points is None:
|
|
3804
|
+
points = await self.get_pump_schedule(which)
|
|
3139
3805
|
return get_active_pump_block(points, minute_of_day)
|
|
3140
3806
|
|
|
3141
3807
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-mobius
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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,9 +1,9 @@
|
|
|
1
|
-
mobius/__init__.py,sha256
|
|
1
|
+
mobius/__init__.py,sha256=-jv9tL5vbjC5ZFfY1vAOZI6s1371TmIZPrkgjyVc7Yc,6610
|
|
2
2
|
mobius/cli.py,sha256=QFphEX10dpPu9bPzUjuUq6K0U9vHG7Kuk-d7BbFfqEs,47541
|
|
3
3
|
mobius/coap.py,sha256=dRE4yUp413etAMhDg2bLvFMLWusqjEWgUpC36i76mJs,10966
|
|
4
4
|
mobius/constants.py,sha256=LA6COD5QE_x97Ue7hKY0KVarcAQxhxWEARYBJtLIww8,42452
|
|
5
5
|
mobius/crc.py,sha256=kLtAYWZvLbo5_p9ep08ZlLg5Yah8Q2qq0IwTvqbBGW0,2700
|
|
6
|
-
mobius/device.py,sha256=
|
|
6
|
+
mobius/device.py,sha256=2C5Ry52eUFfSx-28DDesPFQuzkt6QDoUu8ETKBsu9ek,187683
|
|
7
7
|
mobius/device_status.py,sha256=xlLCMU4gLnp59Uqix679jRdVBY2Jt6oI4EUr8fraeRg,34089
|
|
8
8
|
mobius/discovery.py,sha256=Q_Z0riOBrEOwqpwAhk1wesh3340fiIYFi-8BWqhZ0JI,11949
|
|
9
9
|
mobius/dump.py,sha256=S7-KNeDi9TOETm4ty5DwMXTrthvuMau196UENDQoAb4,16785
|
|
@@ -15,8 +15,8 @@ mobius/power.py,sha256=2o91-Cl6tC3x9SKJ9gN0fP5HSTFeD31jtd2BwTyBn30,3913
|
|
|
15
15
|
mobius/pump_status.py,sha256=RWTl8ZViCFDbiFU2mX-WAIH_cWhZOZ0M2B0oYXnPx3Y,1598
|
|
16
16
|
mobius/relay.py,sha256=IG15TtKDjSkmmi_C2cJwPmucX7V4psfamI0hKgTb_v8,24197
|
|
17
17
|
mobius/schedule.py,sha256=kkQgwkFtM3pvik7UFzHKVhPVZrCGSH9AYvAlVA6kAck,10848
|
|
18
|
-
python_mobius-0.
|
|
19
|
-
python_mobius-0.
|
|
20
|
-
python_mobius-0.
|
|
21
|
-
python_mobius-0.
|
|
22
|
-
python_mobius-0.
|
|
18
|
+
python_mobius-0.7.0.dist-info/METADATA,sha256=LgBpHt0QN5xw5eKuoA6s3MPpPlZTaGSYVZtOFeUSHwg,8306
|
|
19
|
+
python_mobius-0.7.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
20
|
+
python_mobius-0.7.0.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
|
|
21
|
+
python_mobius-0.7.0.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
|
|
22
|
+
python_mobius-0.7.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|