python-mobius 0.8.1__tar.gz → 0.8.3__tar.gz

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.
Files changed (91) hide show
  1. {python_mobius-0.8.1 → python_mobius-0.8.3}/CHANGELOG.md +15 -0
  2. {python_mobius-0.8.1 → python_mobius-0.8.3}/PKG-INFO +1 -1
  3. {python_mobius-0.8.1 → python_mobius-0.8.3}/pyproject.toml +1 -1
  4. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/__init__.py +1 -1
  5. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/device.py +55 -2
  6. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_device_control.py +33 -0
  7. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_light_poll_batch.py +21 -2
  8. {python_mobius-0.8.1 → python_mobius-0.8.3}/.forgejo/scripts/extract_changelog_section.py +0 -0
  9. {python_mobius-0.8.1 → python_mobius-0.8.3}/.forgejo/workflows/release.yml +0 -0
  10. {python_mobius-0.8.1 → python_mobius-0.8.3}/.gitignore +0 -0
  11. {python_mobius-0.8.1 → python_mobius-0.8.3}/LICENSE +0 -0
  12. {python_mobius-0.8.1 → python_mobius-0.8.3}/README.md +0 -0
  13. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/00-overview.md +0 -0
  14. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/01-ble-transport.md +0 -0
  15. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/02-framing-and-crc.md +0 -0
  16. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/03-attributes-and-opcodes.md +0 -0
  17. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/04-device-identity.md +0 -0
  18. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/05-scenes.md +0 -0
  19. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/06-light-schedule.md +0 -0
  20. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/07-pump-schedule.md +0 -0
  21. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/08-manufacturer-data.md +0 -0
  22. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/09-thread-coap-relay.md +0 -0
  23. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/10-known-gaps-and-open-questions.md +0 -0
  24. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/11-device-status-attributes.md +0 -0
  25. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/12-device-identity-and-address-stability.md +0 -0
  26. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/13-attribute-dump.md +0 -0
  27. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/14-batched-attribute-reads.md +0 -0
  28. {python_mobius-0.8.1 → python_mobius-0.8.3}/documentation/15-mob-template-format.md +0 -0
  29. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/cli.py +0 -0
  30. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/coap.py +0 -0
  31. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/constants.py +0 -0
  32. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/crc.py +0 -0
  33. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/device_status.py +0 -0
  34. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/discovery.py +0 -0
  35. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/dump.py +0 -0
  36. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/frame.py +0 -0
  37. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/manufacturer.py +0 -0
  38. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/mesh_address.py +0 -0
  39. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/mob.py +0 -0
  40. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/modifiers.py +0 -0
  41. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/power.py +0 -0
  42. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/pump_status.py +0 -0
  43. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/relay.py +0 -0
  44. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/scenes.py +0 -0
  45. {python_mobius-0.8.1 → python_mobius-0.8.3}/src/mobius/schedule.py +0 -0
  46. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_attributes_batch.py +0 -0
  47. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_cli_group_targets.py +0 -0
  48. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_cli_mob_file.py +0 -0
  49. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_cli_schedule_file.py +0 -0
  50. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_coap.py +0 -0
  51. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_coap_indication_handling.py +0 -0
  52. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_connection_cleanup.py +0 -0
  53. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_device_status.py +0 -0
  54. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_device_summary.py +0 -0
  55. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_device_time.py +0 -0
  56. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_discovery_company_ids.py +0 -0
  57. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_dump.py +0 -0
  58. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_extra_physical_values.py +0 -0
  59. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_fan_thresholds.py +0 -0
  60. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_firmware_versions.py +0 -0
  61. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_frame.py +0 -0
  62. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_frame_sniffer.py +0 -0
  63. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_full_poll_batch.py +0 -0
  64. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_group_bitmap.py +0 -0
  65. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_hyperdrive_device.py +0 -0
  66. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_insolation_detection.py +0 -0
  67. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_light_intensity_branching.py +0 -0
  68. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_light_schedule.py +0 -0
  69. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_manufacturer.py +0 -0
  70. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_manufacturer_lookup.py +0 -0
  71. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_mesh_address.py +0 -0
  72. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_mesh_discovery.py +0 -0
  73. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_mesh_discovery_direct_connect.py +0 -0
  74. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_metadata_batch.py +0 -0
  75. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_mob.py +0 -0
  76. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_modifiers.py +0 -0
  77. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_networked_thread_devices.py +0 -0
  78. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_new_diagnostic_attributes.py +0 -0
  79. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_power.py +0 -0
  80. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_pump_reverse.py +0 -0
  81. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_pump_schedule.py +0 -0
  82. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_pump_status.py +0 -0
  83. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_pump_supported_modes.py +0 -0
  84. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_pump_telemetry.py +0 -0
  85. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_relay.py +0 -0
  86. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_scenes.py +0 -0
  87. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_schedule_file_format.py +0 -0
  88. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_schedule_intensity_scalar.py +0 -0
  89. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_serial_decoding.py +0 -0
  90. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_serial_identity.py +0 -0
  91. {python_mobius-0.8.1 → python_mobius-0.8.3}/tests/test_supported_attributes.py +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.3
4
+
5
+ - Added `set_lunar_enabled(enabled)`. Confirmed byte-for-byte against
6
+ the app's own compiled bytecode: exactly what its "Lunar" chip (top
7
+ of the light schedule editor) writes when toggled.
8
+
9
+ ## 0.8.2
10
+
11
+ - Added `LightPollResult.schedule_intensity`: the device's own raw
12
+ schedule-level master dimmer, matching `get_schedule_intensity()`'s
13
+ own return value. Not the same as `intensities.diagnostics["scalar"]`,
14
+ which reflects whichever value is currently in effect (the lunar
15
+ reduction factor during a night segment, not the schedule intensity
16
+ setting itself).
17
+
3
18
  ## 0.8.1
4
19
 
5
20
  - Added `get_pump_reverse(primitive_type, params)`: whether a pump
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.8.1
3
+ Version: 0.8.3
4
4
  Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
5
5
  Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
6
6
  Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.8.1"
7
+ version = "0.8.3"
8
8
  description = "Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)"
9
9
  readme = "README.md"
10
10
  license = { text = "GPL-2.0-only" }
@@ -82,7 +82,7 @@ from .mob import (
82
82
  MAX_PUMP_PRIMITIVE_SIZE,
83
83
  )
84
84
 
85
- __version__ = "0.8.1"
85
+ __version__ = "0.8.3"
86
86
 
87
87
  __all__ = [
88
88
  "__version__",
@@ -145,6 +145,24 @@ class LightPollResult:
145
145
  schedule_points: list[SchedulePoint]
146
146
  intensities: LightIntensityResult
147
147
  used_batch: bool = True
148
+ # The same raw 0.0-1.0 schedule-level master dimmer
149
+ # get_schedule_intensity() itself returns -- already fetched and
150
+ # consumed internally by both this method and the individual-reads
151
+ # fallback below (it's one of process_light_intensities()'s own
152
+ # required inputs), but never actually propagated out to the
153
+ # caller until this field existed. NOT the same thing as
154
+ # intensities.diagnostics["scalar"]: that reflects whichever value
155
+ # is CURRENTLY driving the effective per-channel output at this
156
+ # exact moment (which, during a lunar-reduced night segment, is
157
+ # the lunar reduction factor instead) -- this field is always the
158
+ # device's own raw schedule-level intensity setting itself,
159
+ # regardless of whether it's the thing actually in effect right
160
+ # now. A caller wanting "what would the user's own intensity
161
+ # slider currently show" wants this field specifically, not
162
+ # diagnostics["scalar"]. None only if the underlying read itself
163
+ # failed and decode_schedule_intensity() had truly nothing to fall
164
+ # back to.
165
+ schedule_intensity: Optional[float] = None
148
166
 
149
167
 
150
168
  @dataclass
@@ -545,7 +563,9 @@ def decode_light_poll_from_batch(
545
563
  lunar_enabled, lunar_date, lunar_date_source,
546
564
  schedule_intensity, acclimation_info, insolation_active,
547
565
  )
548
- return LightPollResult(schedule_points=points, intensities=intensities, used_batch=True)
566
+ return LightPollResult(
567
+ schedule_points=points, intensities=intensities, used_batch=True, schedule_intensity=schedule_intensity,
568
+ )
549
569
 
550
570
 
551
571
  def decode_pump_flow_range(min_raw: list[bytes], max_raw: list[bytes]) -> Optional[PumpFlowRange]:
@@ -3443,6 +3463,35 @@ class MobiusDevice:
3443
3463
  return False
3444
3464
  return decode_lunar_enabled(raw)
3445
3465
 
3466
+ async def set_lunar_enabled(self, enabled: bool) -> None:
3467
+ """Writes LunarPhasesEnabled (907) -- confirmed byte-for-byte
3468
+ against the app's own compiled bytecode (LunarInfo.smali's own
3469
+ save() method): a single byte, the boolean enabled field stored
3470
+ directly as 0 or 1, via the exact same SetC2AttrFsciRequest.
3471
+ addAttribute() mechanism this library's own set_attribute()
3472
+ uses. This is exactly what the app's own "Lunar" chip (top of
3473
+ the light schedule editor) toggles: clicking it turns
3474
+ lunar-phase night-time dimming on/off, and while enabled that
3475
+ same chip also displays the current moon phase.
3476
+
3477
+ No group/broadcast involved -- confirmed by tracing
3478
+ LightingFragment's own saveLunar(), which loops over every
3479
+ light device in the tank and calls this same per-device,
3480
+ ungrouped write on EACH one individually (not a single
3481
+ mesh-wide broadcast the way set_time_to_now() works). The
3482
+ app's own UI applies the same new value to every light in the
3483
+ tank with that one tap, but the underlying wire mechanism is
3484
+ still one ordinary, targeted write per device -- this method
3485
+ only ever writes to the single device it's called on, matching
3486
+ every other attribute write in this library.
3487
+
3488
+ No which/schedule parameter -- unlike Schedule1Intensity/
3489
+ Schedule2Intensity, there's only ever a single
3490
+ LunarPhasesEnabled attribute (confirmed: no LunarPhasesEnabled2
3491
+ exists), so this applies to the light as a whole rather than to
3492
+ either schedule individually."""
3493
+ await self.set_attribute(C2Attribute.LunarPhasesEnabled, bytes([1 if enabled else 0]))
3494
+
3446
3495
  async def get_acclimation_info(self) -> Optional[AcclimationInfo]:
3447
3496
  """
3448
3497
  Fetches all 4 acclimation attributes (AcclimationEnabled/Period/
@@ -3790,7 +3839,11 @@ class MobiusDevice:
3790
3839
  """
3791
3840
  points = await self.get_light_schedule(which)
3792
3841
  intensities = await self.get_current_light_intensities(which, minute_of_day, now)
3793
- return LightPollResult(schedule_points=points, intensities=intensities, used_batch=False)
3842
+ schedule_intensity = await self.get_schedule_intensity(which)
3843
+ return LightPollResult(
3844
+ schedule_points=points, intensities=intensities, used_batch=False,
3845
+ schedule_intensity=schedule_intensity,
3846
+ )
3794
3847
 
3795
3848
  async def get_full_poll_batch(
3796
3849
  self, primitive: PrimitiveType, model: Optional[Model] = None,
@@ -64,6 +64,39 @@ async def test_reboot_propagates_a_write_failure():
64
64
  await device.reboot()
65
65
 
66
66
 
67
+ async def test_set_lunar_enabled_writes_a_single_byte():
68
+ """Confirmed directly against the app's own behavior: LunarPhasesEnabled
69
+ (907) is exactly what the app's own "Lunar" chip (top of the light
70
+ schedule editor) toggles."""
71
+ device = MobiusDevice(_fake_ble_device())
72
+ captured = {}
73
+
74
+ async def fake_set_attribute(attr_id, value, index=0, reserved=b"\x00\x00"):
75
+ captured["attr_id"] = attr_id
76
+ captured["value"] = value
77
+
78
+ device.set_attribute = fake_set_attribute
79
+
80
+ await device.set_lunar_enabled(True)
81
+ assert captured["attr_id"] == C2Attribute.LunarPhasesEnabled
82
+ assert captured["value"] == bytes([1])
83
+
84
+ await device.set_lunar_enabled(False)
85
+ assert captured["value"] == bytes([0])
86
+
87
+
88
+ async def test_set_lunar_enabled_propagates_a_write_failure():
89
+ device = MobiusDevice(_fake_ble_device())
90
+
91
+ async def fake_set_attribute(attr_id, value, index=0, reserved=b"\x00\x00"):
92
+ raise IOError("device returned FSCI status Failed setting attribute 907")
93
+
94
+ device.set_attribute = fake_set_attribute
95
+
96
+ with pytest.raises(IOError, match="Failed setting attribute 907"):
97
+ await device.set_lunar_enabled(True)
98
+
99
+
67
100
  @pytest.mark.asyncio
68
101
  async def test_reboot_all_writes_reset_soft_with_group_1_reserved_byte():
69
102
  """Confirmed via the app's own SettingsFragment.reboot() (the
@@ -94,13 +94,23 @@ async def test_filters_request_to_only_supported_attributes():
94
94
  async def test_night_segment_with_lunar_matches_individual_path_scenario():
95
95
  """Mirrors test_night_segment_with_lunar_enabled_uses_lunar_reduction()
96
96
  in test_light_intensity_branching.py exactly, but via the batch
97
- path -- same schedule, same time, same expected result."""
97
+ path -- same schedule, same time, same expected result.
98
+
99
+ Also the exact regression scenario for a real, confirmed
100
+ production bug: a caller reading intensities.diagnostics["scalar"]
101
+ as if it were always schedule_intensity itself would get this
102
+ exactly backwards during a lunar-reduced night segment, since
103
+ "scalar" here reflects the lunar reduction factor currently in
104
+ effect, not the device's own raw schedule-level intensity setting
105
+ -- which is what result.schedule_intensity (this field) is
106
+ specifically for."""
98
107
  device = MobiusDevice(_fake_ble_device())
99
108
 
100
109
  async def fake_get_attributes_batch(requests):
101
110
  return [
102
111
  _av(C2Attribute.Schedule1, 0, *_night_schedule()),
103
112
  _av(C2Attribute.LunarPhasesEnabled, 0, bytes([1])),
113
+ _av(C2Attribute.Schedule1Intensity, 0, bytes.fromhex("8103")), # 0x0381 = 897 -> 0.897
104
114
  # Epoch/LocalTime deliberately absent -- same as that test's
105
115
  # own LocalTime=None, forcing the local_fallback path.
106
116
  ]
@@ -108,7 +118,8 @@ async def test_night_segment_with_lunar_matches_individual_path_scenario():
108
118
  device.get_attributes_batch = fake_get_attributes_batch
109
119
  now = datetime.datetime(2026, 1, 18, 20, 0)
110
120
  result = await device.get_light_poll_batch(
111
- minute_of_day=1350, now=now, supported_attribute_ids={C2Attribute.Schedule1, C2Attribute.LunarPhasesEnabled},
121
+ minute_of_day=1350, now=now,
122
+ supported_attribute_ids={C2Attribute.Schedule1, C2Attribute.LunarPhasesEnabled, C2Attribute.Schedule1Intensity},
112
123
  )
113
124
 
114
125
  assert result.used_batch is True
@@ -120,6 +131,12 @@ async def test_night_segment_with_lunar_matches_individual_path_scenario():
120
131
  assert intensities.diagnostics["scalar_source"] == "lunar"
121
132
  assert intensities.diagnostics["lunar_date_source"] == "local_fallback"
122
133
  assert intensities.diagnostics["lunar_date"] == now.date()
134
+ # The actual regression: schedule_intensity is the device's own raw
135
+ # 0.897 setting, genuinely different from whatever lunar-derived
136
+ # value diagnostics["scalar"] holds during this segment -- these
137
+ # must never be conflated.
138
+ assert result.schedule_intensity == pytest.approx(0.897)
139
+ assert result.schedule_intensity != intensities.diagnostics["scalar"]
123
140
 
124
141
 
125
142
  @pytest.mark.asyncio
@@ -147,6 +164,7 @@ async def test_daytime_matches_individual_path_scenario():
147
164
  assert intensities.diagnostics["is_night_segment"] is False
148
165
  assert intensities.diagnostics["scalar_source"] == "schedule_intensity"
149
166
  assert intensities.diagnostics["scalar"] == pytest.approx(0.808)
167
+ assert result.schedule_intensity == pytest.approx(0.808)
150
168
  # RoyalBlue=500 raw * 0.808 scalar
151
169
  assert intensities[VisualID.RoyalBlue] == pytest.approx(500 * 0.808)
152
170
 
@@ -180,6 +198,7 @@ async def test_batch_failure_falls_back_and_used_batch_is_false():
180
198
  # get_schedule_intensity()'s own 0.5 default, since Schedule1Intensity
181
199
  # itself returns None from the fake get_attribute() above.
182
200
  assert result.intensities.diagnostics["scalar"] == pytest.approx(0.5)
201
+ assert result.schedule_intensity == pytest.approx(0.5)
183
202
 
184
203
 
185
204
  @pytest.mark.asyncio
File without changes
File without changes
File without changes