python-mobius 0.4.4__py3-none-any.whl → 0.5.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
@@ -35,7 +35,7 @@ from .schedule import (
35
35
  LightPrimitive, SchedulePoint, interpolate_light_schedule,
36
36
  PumpPrimitiveValue, PumpSchedulePoint, get_active_pump_block,
37
37
  )
38
- from .manufacturer import MOBIUS_COMPANY_ID, MobiusAdvertisement, parse_manufacturer_data
38
+ from .manufacturer import MOBIUS_COMPANY_ID_ECOTECH, MOBIUS_COMPANY_ID_AQUAILLUMINATION, MOBIUS_COMPANY_IDS, MobiusAdvertisement, parse_manufacturer_data
39
39
  from .modifiers import (
40
40
  AcclimationInfo, is_night_segment,
41
41
  julian_day_from_date, lunar_days_into_phase, lunar_percent_reduction,
@@ -43,7 +43,7 @@ from .modifiers import (
43
43
  )
44
44
  from .power import ChannelPowerInfo, channel_percent_value
45
45
  from .pump_status import PumpFlowRange, BatteryBackupInfo, BoostedBatteryInfo
46
- from .device_status import GroupInfo, CalibrationInfo, MaintenanceInfo, DeviceTimeInfo, MeshPeer, Tank
46
+ from .device_status import GroupInfo, CalibrationInfo, AdvancedFeatures, VectraInfo, CoffeeInfo, MaintenanceInfo, DeviceTimeInfo, MeshPeer, Tank, LunarPhaseInfo
47
47
  from .coap import (
48
48
  CoapRequestType, CoapMethod, CoapResponseCode, CoapResponse,
49
49
  encode_coap_request, decode_coap_response, COAP_OPCODE, COAP_INDICATION_OPCODE,
@@ -65,7 +65,7 @@ from .discovery import (
65
65
  discover_tank,
66
66
  )
67
67
 
68
- __version__ = "0.4.4"
68
+ __version__ = "0.5.0"
69
69
 
70
70
  __all__ = [
71
71
  "__version__",
@@ -93,7 +93,7 @@ __all__ = [
93
93
  "LightPrimitive", "SchedulePoint", "interpolate_light_schedule",
94
94
  "PumpPrimitiveValue", "PumpSchedulePoint", "get_active_pump_block",
95
95
  # manufacturer
96
- "MOBIUS_COMPANY_ID", "MobiusAdvertisement", "parse_manufacturer_data",
96
+ "MOBIUS_COMPANY_ID_ECOTECH", "MOBIUS_COMPANY_ID_AQUAILLUMINATION", "MOBIUS_COMPANY_IDS", "MobiusAdvertisement", "parse_manufacturer_data",
97
97
  # modifiers
98
98
  "AcclimationInfo", "is_night_segment",
99
99
  "julian_day_from_date", "lunar_days_into_phase", "lunar_percent_reduction",
@@ -103,7 +103,7 @@ __all__ = [
103
103
  # pump_status
104
104
  "PumpFlowRange", "BatteryBackupInfo", "BoostedBatteryInfo",
105
105
  # device_status
106
- "GroupInfo", "CalibrationInfo", "MaintenanceInfo", "DeviceTimeInfo", "MeshPeer", "Tank",
106
+ "GroupInfo", "CalibrationInfo", "AdvancedFeatures", "VectraInfo", "CoffeeInfo", "MaintenanceInfo", "DeviceTimeInfo", "MeshPeer", "Tank", "LunarPhaseInfo",
107
107
  "CoapRequestType", "CoapMethod", "CoapResponseCode", "CoapResponse",
108
108
  "encode_coap_request", "decode_coap_response", "COAP_OPCODE", "COAP_INDICATION_OPCODE",
109
109
  "next_coap_token",
mobius/cli.py CHANGED
@@ -60,10 +60,27 @@ async def _dump_schedule(device: MobiusDevice, support: str) -> None:
60
60
  print(f" {_format_time(point.time_minutes)}{_format_flags(point)}: {channels}")
61
61
 
62
62
  lunar_enabled = await device.get_lunar_enabled()
63
+ print(f" lunar_enabled: {lunar_enabled}")
64
+
65
+ # The device's own firmware-computed lunar state -- see
66
+ # LunarPhaseInfo's own docstring in device_status.py for the
67
+ # confirmation and what this is useful for. Compare against
68
+ # "current_intensities"/"light_diagnostics" in the summary
69
+ # output above (NOT re-fetched here -- get_device_summary()
70
+ # already called get_current_light_intensities() once; a
71
+ # second call here would just be a redundant device read of
72
+ # the exact same thing). A real failure is shown directly
73
+ # (get_lunar_phase_info() no longer swallows it) rather than
74
+ # printing an indistinguishable "None".
75
+ try:
76
+ lunar_phase_info = await device.get_lunar_phase_info()
77
+ print(f" lunar_phase_info: {lunar_phase_info}")
78
+ except Exception as e:
79
+ print(f" lunar_phase_info: read failed: {e}")
80
+
63
81
  insolation_active = await device.get_insolation_enabled()
64
82
  schedule_intensity = await device.get_schedule_intensity()
65
83
  acclimation = await device.get_acclimation_info()
66
- print(f" lunar_enabled: {lunar_enabled}")
67
84
  print(f" insolation_active: {insolation_active}")
68
85
  print(f" schedule_intensity: {schedule_intensity}")
69
86
  print(f" acclimation: {acclimation}")
@@ -241,7 +258,14 @@ async def _run(args) -> None:
241
258
  device, info = found
242
259
  print(f" currently at {device.address} model={info.model.name if info.model else info.model_raw}")
243
260
  try:
244
- async with MobiusDevice(serial=args.by_serial, adapter=args.adapter,
261
+ # device= is the exact BLEDevice found_device_by_serial() just
262
+ # located above -- passing it avoids a second, independent scan
263
+ # inside connect() (see MobiusDevice.__init__()'s own docstring:
264
+ # re-scanning per device can cause BlueZ to drop it between
265
+ # "found" and "connect", the exact failure this fixes). serial=
266
+ # is still passed alongside it, for this same instance's own
267
+ # reconnect logic if the connection drops later in this run.
268
+ async with MobiusDevice(device=device, serial=args.by_serial, adapter=args.adapter,
245
269
  connect_timeout=args.connect_timeout) as mdevice:
246
270
  summary = await mdevice.get_device_summary()
247
271
  for k, v in summary.items():
@@ -249,6 +273,7 @@ async def _run(args) -> None:
249
273
  if args.dump_schedule:
250
274
  await _dump_schedule(mdevice, summary.get("support", ""))
251
275
 
276
+ relayed = None # set below only if --relay-target resolves successfully
252
277
  if args.dump_mesh_peers or args.relay_target:
253
278
  print("\n=== Thread mesh peer discovery ===")
254
279
  peers = await _debug_mesh_discovery(mdevice)
@@ -314,6 +339,47 @@ async def _run(args) -> None:
314
339
  f"`--by-serial {args.relay_target}` run to confirm they match.")
315
340
  except Exception as e:
316
341
  print(f" relay failed: {e}")
342
+
343
+ if args.reboot:
344
+ # If --relay-target was also given, reboot THAT device
345
+ # via relay through the connected one, instead of the
346
+ # directly-connected device itself -- lets both the
347
+ # direct and relayed write paths get tested with the
348
+ # same flag, without a separate one. If relay resolution
349
+ # above failed, this deliberately does NOT fall back to
350
+ # rebooting the directly-connected device instead --
351
+ # that would silently reboot a device the user didn't
352
+ # actually ask to reboot.
353
+ if args.relay_target:
354
+ if relayed is None:
355
+ print(f"\n=== reboot() on {args.relay_target!r} (via relay) -- "
356
+ f"SKIPPED: relay target could not be resolved above ===")
357
+ reboot_target = None
358
+ else:
359
+ reboot_target_label = f"{args.relay_target!r} (via relay through {args.by_serial!r})"
360
+ reboot_target = relayed
361
+ else:
362
+ reboot_target_label = f"{args.by_serial!r} (direct)"
363
+ reboot_target = mdevice
364
+
365
+ if reboot_target is not None:
366
+ print(f"\n=== reboot() on {reboot_target_label} (WRITE -- Reset, soft) ===")
367
+ try:
368
+ await reboot_target.reboot()
369
+ print(f" reboot() on {reboot_target_label} succeeded (device "
370
+ f"acknowledged the request -- it may take a few seconds to "
371
+ f"actually finish rebooting and reconnect).")
372
+ except Exception as e:
373
+ print(f" reboot() FAILED: {e}")
374
+
375
+ if args.set_time_to_now:
376
+ print(f"\n=== set_time_to_now() on {args.by_serial!r} (WRITE -- Epoch) ===")
377
+ try:
378
+ await mdevice.set_time_to_now()
379
+ print(f" set_time_to_now() on {args.by_serial!r} succeeded.")
380
+ except Exception as e:
381
+ print(f" set_time_to_now() FAILED: {e}")
382
+
317
383
  except Exception as e:
318
384
  print(" failed to connect/read:", e)
319
385
  return
@@ -365,7 +431,10 @@ async def _run(args) -> None:
365
431
  device, info = connectable[0]
366
432
  print(f"\n=== Network addresses (connecting to {info.serial} to ask) ===")
367
433
  try:
368
- async with MobiusDevice(serial=info.serial, adapter=args.adapter,
434
+ # device= already found above via the scan feeding `connectable`
435
+ # -- see the --by-serial path's own comment for why this avoids
436
+ # a redundant, race-prone second scan inside connect().
437
+ async with MobiusDevice(device=device, serial=info.serial, adapter=args.adapter,
369
438
  connect_timeout=args.connect_timeout) as mdevice:
370
439
  peers = await _debug_mesh_discovery(mdevice)
371
440
  except Exception as e:
@@ -398,7 +467,10 @@ async def _run(args) -> None:
398
467
  device, info = connectable[0]
399
468
  print(f" pan_id {label} (connecting through {info.serial})...")
400
469
  try:
401
- async with MobiusDevice(serial=info.serial, adapter=args.adapter,
470
+ # device= already found above -- see the --by-serial path's
471
+ # own comment for why this avoids a redundant, race-prone
472
+ # second scan inside connect().
473
+ async with MobiusDevice(device=device, serial=info.serial, adapter=args.adapter,
402
474
  connect_timeout=args.connect_timeout) as mdevice:
403
475
  tank = await discover_tank(mdevice)
404
476
  except Exception as e:
@@ -508,8 +580,11 @@ def main() -> None:
508
580
  "(every point's time, flags, and per-channel/mode values) -- not "
509
581
  "just the interpolated 'current' value get_device_summary() shows. "
510
582
  "For lights, also prints lunar/insolation/schedule-intensity/"
511
- "acclimation state. Useful for diagnosing a mismatch against the "
512
- "app's own displayed value.")
583
+ "acclimation state, PLUS the device's own firmware-computed lunar "
584
+ "state (LunarPhasesCurrentDay/CurrentScalar, confirmed against real "
585
+ "hardware -- see LunarPhaseInfo's own docstring) -- compare against "
586
+ "\"current_intensities\"/\"light_diagnostics\" in the summary output "
587
+ "above for this library's own calculated value.")
513
588
  parser.add_argument("--dump-mesh-peers", action="store_true",
514
589
  help="Only usable with --by-serial. After connecting, calls "
515
590
  "discover_mesh_peers_auto() on it and prints every other device "
@@ -542,6 +617,23 @@ def main() -> None:
542
617
  "confirmed-meaningless opcode-25 ack and the real opcode-26 "
543
618
  "indication, if it arrives). Useful for diagnosing a relay that "
544
619
  "times out or returns unexpected data.")
620
+ parser.add_argument("--set-time-to-now", action="store_true",
621
+ help="Only usable with --by-serial. A WRITE -- use on hardware you're "
622
+ "prepared to have its clock changed on. Calls set_time_to_now() "
623
+ "on the directly-connected device (Epoch, reserved-byte group=1 "
624
+ "-- required for the write to be accepted at all, confirmed "
625
+ "against real hardware). See that method's own docstring, and "
626
+ "documentation/09-thread-coap-relay.md, for what's confirmed "
627
+ "about this vs the rest of the mesh.")
628
+ parser.add_argument("--reboot", action="store_true",
629
+ help="Only usable with --by-serial. A WRITE -- use on hardware you're "
630
+ "prepared to have reboot. Soft-reboots the directly-connected "
631
+ "device (Reset, ResetType.Soft) -- confirmed via the app's own "
632
+ "source to be exactly its own \"Restart\" button. If "
633
+ "--relay-target is also given, reboots THAT device via relay "
634
+ "through the connected one instead, to test the relayed write "
635
+ "path specifically -- see reboot()'s own docstring for what's "
636
+ "confirmed about this vs a direct connection.")
545
637
  parser.add_argument("--build-peer-map", action="store_true",
546
638
  help="Instead of full device summaries, scan for devices then "
547
639
  "briefly connect to EACH one in turn to build a mesh peer map "
mobius/constants.py CHANGED
@@ -20,7 +20,7 @@ from typing import Optional
20
20
 
21
21
  class PrimitiveType(IntEnum):
22
22
  """What kind of device this is, at the protocol level. Confirmed literal
23
- values from M.PrimitiveType."""
23
+ values."""
24
24
  Undefined = 0
25
25
  VisualV1 = 1 # Radion / light-class devices (e.g. XR15, XR30, Prime, Hydra)
26
26
  PumpV1 = 2 # legacy Nero pumps
@@ -33,7 +33,7 @@ class PrimitiveType(IntEnum):
33
33
  CoffeeV1 = 9 # NYOS Quantum skimmers/dosers
34
34
 
35
35
 
36
- # Confirmed from M.PrimitiveType.size() -- the fixed byte-length (or, for
36
+ # Confirmed from the app's own per-primitive size lookup -- the fixed byte-length (or, for
37
37
  # VisualV1, the per-channel-triplet unit) of that primitive type's schedule
38
38
  # point payload.
39
39
  PRIMITIVE_SIZE: dict[PrimitiveType, int] = {
@@ -177,11 +177,9 @@ class Model(IntEnum):
177
177
  Passthrough = 61445 # "Devel Board"
178
178
 
179
179
 
180
- # Confirmed directly against the app's own decompiled getManufacturer()/
181
- # isEtm() logic for every single entry above -- not a curated subset,
182
- # and not re-derived by name pattern (an earlier version of this
183
- # comment described it that way; that approach turned out to have at
184
- # least one confirmed mistake -- see the CHANGELOG for this release).
180
+ # Confirmed directly against the app's own decompiled manufacturer-lookup
181
+ # logic for every single entry above -- not a curated subset, and not
182
+ # re-derived by name pattern.
185
183
  MODEL_MANUFACTURER: dict[Model, str] = {
186
184
  Model.Unknown: "Unknown",
187
185
 
@@ -300,8 +298,9 @@ def manufacturer_for_model(model: Optional[Model]) -> Optional[str]:
300
298
 
301
299
 
302
300
  class ErrorState(IntEnum):
303
- """Confirmed literal values from M.ErrorState (subset covering common
304
- pump/light fault codes -- the full enum also has dosing-pump-specific codes)."""
301
+ """Confirmed literal values for device error states (subset covering
302
+ common pump/light fault codes -- the full set also has dosing-pump-
303
+ specific codes)."""
305
304
  NoError = 0
306
305
  Disconnect = 1
307
306
  Temperature = 2
@@ -347,13 +346,19 @@ class C2Attribute(IntEnum):
347
346
  integer -- see documentation/03-attributes-and-opcodes.md), EXCEPT
348
347
  Schedule1 which required tracing through an extra hop via an unrelated
349
348
  library constant to resolve (also fully confirmed, just
350
- via that extra hop)."""
349
+ via that extra hop). PowerOnDelay (800) and CoffeeStatusIntensity
350
+ (4100) are a similar case, resolved from third-party Android library/
351
+ SDK constants this project couldn't confirm via web search on its
352
+ own -- confirmed directly instead. Both fit the surrounding
353
+ sequential numbering exactly (717/800/801/802, 4100/4101/4102),
354
+ consistent with, though not proof of, that direct confirmation."""
351
355
  AttributeTableVersion = 0
352
356
  FirmwareVersion = 1
353
357
  HardwareRevision = 2
354
358
  SerialNumber = 3
355
359
  Model = 4
356
360
  Name = 5
361
+ Reset = 6
357
362
  PrimitiveType = 8
358
363
  PhysicalValues = 101
359
364
  MACAddress = 103
@@ -404,11 +409,49 @@ class C2Attribute(IntEnum):
404
409
  AcclimationStartIntensity = 904
405
410
  AcclimationStartTime = 905
406
411
  LunarPhasesEnabled = 907
412
+ # Confirmed attribute IDs and wire format, both against real
413
+ # hardware -- see LunarPhaseInfo's own docstring in
414
+ # device_status.py. Notably, the app itself never requests or
415
+ # parses either of these anywhere (confirmed by a full-source
416
+ # search): its own equivalent setup/parse logic only ever touches
417
+ # LunarPhasesEnabled, computing the actual reduction value entirely
418
+ # client-side instead of reading it from the device. The device's
419
+ # own firmware computes and reports these independently regardless
420
+ # -- get_lunar_phase_info() in device.py reads them directly.
421
+ LunarPhasesCurrentDay = 908
422
+ LunarPhasesCurrentScalar = 909
407
423
  Epoch = 201
408
424
  RTCTime = 219
409
425
  LocalTime = 207 # already device-timezone-adjusted -- see
410
426
  # get_device_local_date()'s own docstring in
411
427
  # device.py for why this exists at all
428
+ # "Advanced Features" -- the app's own settings screen covering
429
+ # both VorTech (LocalControlEnabled/AutoDimTimeout) and Radion
430
+ # (MaxFanSpeed/FanShutdownEnabled) devices under one umbrella,
431
+ # gated purely per-attribute (not hardcoded per-device-type
432
+ # branching) -- see get_advanced_features()'s own docstring in
433
+ # device.py for why this library mirrors that same per-attribute
434
+ # approach.
435
+ LocalControlEnabled = 300
436
+ AutoDimTimeout = 301
437
+ MaxFanSpeed = 910
438
+ FanShutdownEnabled = 1203
439
+ # Vectra-specific settings. PowerOnDelay's own literal in the decompile
440
+ # resolves through a third-party Android animation library's own
441
+ # constant, not confirmable via web search or from the app's own
442
+ # decompile alone -- confirmed directly (not from this project's own
443
+ # sources) to be 800.
444
+ PowerOnDelay = 800
445
+ ClosedLoop = 801
446
+ FeedModeReturnDelay = 802
447
+ # NYOS Quantum-specific settings -- confirmed. CoffeeStatusIntensity's
448
+ # own literal resolves through an AndroidX SDK constant with the same
449
+ # confirmation gap as PowerOnDelay above -- also confirmed directly to
450
+ # be 4100.
451
+ CoffeeStatusIntensity = 4100
452
+ CoffeeLedOn = 717
453
+ CoffeeFeedModeReturnDelay = 4101
454
+ CoffeePowerOnDelay = 4102
412
455
  # Thread mesh peer discovery -- see documentation/09-thread-coap-relay.md
413
456
  # for the full protocol trace. MeshLocalAddresses confirmed via
414
457
  # reverse engineering the app's own connection-setup logic; the three
@@ -444,7 +487,7 @@ class C2Attribute(IntEnum):
444
487
 
445
488
  class PhysicalValueID(IntEnum):
446
489
  """Sub-index used with C2Attribute.PhysicalValues. Confirmed literal
447
- values from M.PhysicalValues."""
490
+ values."""
448
491
  Unknown = 0
449
492
  DriverTemperature = 1
450
493
  MotorTemperature = 2
@@ -514,7 +557,7 @@ class VisualID(IntEnum):
514
557
 
515
558
 
516
559
  class SceneID(IntEnum):
517
- """Confirmed literal values from M.SceneID."""
560
+ """Confirmed literal values."""
518
561
  EmptyScene = 0
519
562
  FeedMode = 1
520
563
  BatteryBackup = 2
@@ -528,13 +571,27 @@ class SceneID(IntEnum):
528
571
 
529
572
 
530
573
  class OperationState(IntEnum):
531
- """Confirmed literal values from M.OperationState."""
574
+ """Confirmed literal values."""
532
575
  OOB = 0
533
576
  LiveDemo = 1
534
577
  Scene = 2
535
578
  Schedule = 3
536
579
 
537
580
 
581
+ class ResetType(IntEnum):
582
+ """Confirmed literal values -- the single byte written to
583
+ C2Attribute.Reset (6). Only Soft is actually exposed by this
584
+ library's own reboot() (see that method's own docstring for why
585
+ the rest are deliberately not wired up to anything yet) -- kept
586
+ here in full since every value is a directly-confirmed literal in
587
+ the decompile, same as everywhere else in this file."""
588
+ Soft = 0
589
+ Factory = 1
590
+ PowerLoss = 2
591
+ PowerRecovery = 3
592
+ Calibration = 4
593
+
594
+
538
595
  class FsciStatus(IntEnum):
539
596
  """Confirmed status/error codes returned in the first byte of a
540
597
  Get/Set-attribute confirm response."""
@@ -558,7 +615,7 @@ class FsciStatus(IntEnum):
558
615
  # --------------------------------------------------------------------------
559
616
 
560
617
  class PumpMode(IntEnum):
561
- """Confirmed literal values from M.PumpMode."""
618
+ """Confirmed literal values."""
562
619
  Undefined = 0
563
620
  ConstantSpeed = 1
564
621
  Lagoon = 2
@@ -578,7 +635,7 @@ class PumpMode(IntEnum):
578
635
 
579
636
 
580
637
  class RampType(IntEnum):
581
- """Confirmed literal values from M.RampType."""
638
+ """Confirmed literal values."""
582
639
  Sinusoidal = 1
583
640
  Logarithmic = 2
584
641
  Linear = 3
@@ -586,7 +643,7 @@ class RampType(IntEnum):
586
643
 
587
644
  class FirmwareType(IntEnum):
588
645
  """
589
- Confirmed literal values from M.FirmwareType -- the sub-index used
646
+ Confirmed literal values -- the sub-index used
590
647
  with C2Attribute.FirmwareVersion (a Get with index=0, count=0xFFFF
591
648
  returns one entry per firmware component the device has, same "get
592
649
  all elements" pattern as MaxPower/NormalPower/SupportedColorChannels).
@@ -610,7 +667,7 @@ class FirmwareType(IntEnum):
610
667
  Esp32C6Firmware = 16
611
668
 
612
669
 
613
- # Ported directly from M.FirmwareType.name(Model) -- the EcoTech Marine
670
+ # Ported directly from the app's own firmware-label lookup -- the EcoTech Marine
614
671
  # (isEtm()) branch specifically, confirmed via real hardware to exactly
615
672
  # match the app's own displayed labels (a VorTech pump showed Radio/Radio
616
673
  # Bootloader/Product OS/Product Bootloader; a Radion light showed Product
@@ -636,16 +693,46 @@ FIRMWARE_TYPE_LABELS_ETM: dict = {
636
693
  FirmwareType.QCA4020WLAN: "WLAN",
637
694
  }
638
695
 
696
+ # The app's own non-ETM label branch (covers AquaIllumination -- Orbit/
697
+ # Axis pumps, Prime/Hydra lights -- plus any other non-EcoTech-Marine
698
+ # manufacturer) confirmed via the same name(Model) logic FIRMWARE_TYPE_
699
+ # LABELS_ETM above is ported from, just its other branch. Every label
700
+ # here is one of the SAME shared resource strings used somewhere in the
701
+ # ETM dict above (just mapped to different FirmwareType values), so each
702
+ # one is cross-referenced against an already hardware-confirmed value,
703
+ # not independently guessed. XMC1402Firmware/CoffeeMotorDriverFirmware
704
+ # are deliberately NOT included: their own labels ("driver"/
705
+ # "base_station") aren't shared with any ETM-confirmed entry, so there's
706
+ # no confirmed value available for them here -- they fall back to the
707
+ # raw FirmwareType name instead, same as any other unmapped entry.
708
+ FIRMWARE_TYPE_LABELS_NON_ETM: dict = {
709
+ FirmwareType.Unknown: "Radio Firmware",
710
+ FirmwareType.LEDClusterMicro: "Radio Firmware",
711
+ FirmwareType.Esp32C3Firmware: "Radio Firmware",
712
+ FirmwareType.Esp32H2Firmware: "Radio Firmware",
713
+ FirmwareType.Esp32S3Firmware: "Radio Firmware",
714
+ FirmwareType.Esp32C6Firmware: "Radio Firmware",
715
+ FirmwareType.OS: "Product OS",
716
+ FirmwareType.MainMicroOS: "Product OS",
717
+ FirmwareType.QCA4020M4F: "Product OS",
718
+ FirmwareType.Bootloader: "Product Bootloader",
719
+ FirmwareType.MainMicroBootloader: "Product Bootloader",
720
+ FirmwareType.QCA4020Firmware: "Firmware",
721
+ FirmwareType.QCA4020FileSystem: "Filesystem",
722
+ FirmwareType.QCA4020M0: "Radio",
723
+ FirmwareType.QCA4020WLAN: "WLAN",
724
+ }
725
+
639
726
 
640
727
  class HardwareInfo(IntEnum):
641
728
  """
642
- Confirmed literal values from M.HardwareInfo -- the sub-index used
729
+ Confirmed literal values -- the sub-index used
643
730
  with C2Attribute.HardwareRevision, same "get all elements" pattern as
644
731
  FirmwareVersion above. `Revision` and `Segments` have no confirmed
645
732
  display convention (read as plain small integers, not version
646
733
  numbers or enum codes) -- but `Color`/`ProductType`/`RadioType`/
647
- `MotorType` are each themselves confirmed enums (M.Color/M.ProductType/
648
- M.RadioType/M.MotorType) with confirmed display labels -- see
734
+ `MotorType` are each themselves confirmed enums with confirmed
735
+ display labels -- see
649
736
  Color/ProductType/RadioType/MotorType below and their *_LABELS dicts.
650
737
  """
651
738
  Unknown = 0
@@ -658,7 +745,7 @@ class HardwareInfo(IntEnum):
658
745
 
659
746
 
660
747
  class Color(IntEnum):
661
- """Confirmed literal values and display labels from M.Color --
748
+ """Confirmed literal values and display labels --
662
749
  the HardwareInfo.Color sub-value's own enum."""
663
750
  Unknown = 0
664
751
  Black = 1
@@ -672,7 +759,7 @@ COLOR_LABELS: dict = {
672
759
 
673
760
 
674
761
  class RadioType(IntEnum):
675
- """Confirmed literal values and display labels from M.RadioType --
762
+ """Confirmed literal values and display labels --
676
763
  the HardwareInfo.RadioType sub-value's own enum."""
677
764
  Unknown = 0
678
765
  KW41 = 1
@@ -698,7 +785,7 @@ RADIO_TYPE_LABELS: dict = {
698
785
 
699
786
 
700
787
  class MotorType(IntEnum):
701
- """Confirmed literal values and display labels from M.MotorType --
788
+ """Confirmed literal values and display labels --
702
789
  the HardwareInfo.MotorType sub-value's own enum. Several labels
703
790
  (Slack/Alpaca2/Alpaca4/Turtle1-4) are base64-string-obfuscated in the
704
791
  decompile rather than plain literals, apparently to avoid casual
@@ -762,7 +849,7 @@ MOTOR_TYPE_LABELS: dict = {
762
849
 
763
850
 
764
851
  class ProductType(IntEnum):
765
- """Confirmed literal values and display labels from M.ProductType --
852
+ """Confirmed literal values and display labels --
766
853
  the HardwareInfo.ProductType sub-value's own enum. Several labels
767
854
  are base64-string-obfuscated in the decompile the same way some
768
855
  MotorType labels are -- see MotorType's docstring for why that's
@@ -825,7 +912,7 @@ PRODUCT_TYPE_LABELS: dict = {
825
912
 
826
913
  class PumpOverrideMode(IntEnum):
827
914
  """
828
- Confirmed literal values from M.PumpOverrideMode. Confirmed only as a
915
+ Confirmed literal values. Confirmed only as a
829
916
  SET target in the decompile (writing None to exit override mode after
830
917
  the pump-side calibration flow) -- never actually read anywhere in the
831
918
  app. The wire format for reading it (plain byte enum) is a reasonable
@@ -863,7 +950,7 @@ PUMP_PARAM_SIZE: dict[PumpParam, int] = {
863
950
  PumpParam.PhaseShift: 2, PumpParam.OnTime: 4, PumpParam.OffTime: 4,
864
951
  }
865
952
 
866
- # Ported directly from M.PumpMode.parameters() -- which params are present
953
+ # Ported directly from the app's own pump-mode parameter logic -- which params are present
867
954
  # for each mode, and in what order (byte offset is cumulative from data[1:]).
868
955
  PUMP_MODE_PARAMS: dict[PumpMode, list[PumpParam]] = {
869
956
  PumpMode.Undefined: [],