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 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.6.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
- model_val = struct.unpack("<h", model_raw)[0] if model_raw and len(model_raw) >= 2 else None
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
- def _first_value(attr_id):
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
- if epoch_seconds is None or local_time_seconds is None:
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
- is_legacy_nero = model in (Model.Nero3, Model.Nero5, Model.Nero7)
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
- if not (min_raw and max_raw):
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
- points = []
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
- if not raw:
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
- if not raw:
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
- if not (enabled_raw and period_raw and start_intensity_raw and start_time_raw):
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
- if not raw:
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
- lunar_enabled = None # not checked/relevant outside the night segment
3059
- scalar = await self.get_schedule_intensity(which)
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
- result = {ch: value * scalar for ch, value in raw_intensities.items()}
3067
- return LightIntensityResult(result, diagnostics={
3068
- "insolation_active": insolation_active,
3069
- "is_night_segment": night_segment,
3070
- "lunar_enabled": lunar_enabled,
3071
- "scalar_source": scalar_source,
3072
- "scalar": scalar,
3073
- "lunar_date": lunar_date,
3074
- "lunar_date_source": lunar_date_source,
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
- points = []
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) -> Optional[PumpSchedulePoint]:
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 = await self.get_pump_schedule(which)
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.6.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=EU--56oVYOCdcWx7S_ap82Y0FgjvVsjlcX7kfiAGtXs,6540
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=xMd1g8mm_RQNjO3TYKfNo2AMz3srxw8LIYAikZFCQv8,156148
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.6.0.dist-info/METADATA,sha256=nPwPkfpT_K4tlVwBajfhTq94M9j1igyedPUDrpclUYs,8306
19
- python_mobius-0.6.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
20
- python_mobius-0.6.0.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
21
- python_mobius-0.6.0.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
22
- python_mobius-0.6.0.dist-info/RECORD,,
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,,