python-mobius 0.8.2__tar.gz → 0.8.4__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.2 → python_mobius-0.8.4}/CHANGELOG.md +14 -0
  2. {python_mobius-0.8.2 → python_mobius-0.8.4}/PKG-INFO +1 -1
  3. {python_mobius-0.8.2 → python_mobius-0.8.4}/pyproject.toml +1 -1
  4. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/__init__.py +1 -1
  5. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/device.py +64 -2
  6. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_device_control.py +33 -0
  7. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_light_poll_batch.py +41 -3
  8. {python_mobius-0.8.2 → python_mobius-0.8.4}/.forgejo/scripts/extract_changelog_section.py +0 -0
  9. {python_mobius-0.8.2 → python_mobius-0.8.4}/.forgejo/workflows/release.yml +0 -0
  10. {python_mobius-0.8.2 → python_mobius-0.8.4}/.gitignore +0 -0
  11. {python_mobius-0.8.2 → python_mobius-0.8.4}/LICENSE +0 -0
  12. {python_mobius-0.8.2 → python_mobius-0.8.4}/README.md +0 -0
  13. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/00-overview.md +0 -0
  14. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/01-ble-transport.md +0 -0
  15. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/02-framing-and-crc.md +0 -0
  16. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/03-attributes-and-opcodes.md +0 -0
  17. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/04-device-identity.md +0 -0
  18. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/05-scenes.md +0 -0
  19. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/06-light-schedule.md +0 -0
  20. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/07-pump-schedule.md +0 -0
  21. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/08-manufacturer-data.md +0 -0
  22. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/09-thread-coap-relay.md +0 -0
  23. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/10-known-gaps-and-open-questions.md +0 -0
  24. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/11-device-status-attributes.md +0 -0
  25. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/12-device-identity-and-address-stability.md +0 -0
  26. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/13-attribute-dump.md +0 -0
  27. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/14-batched-attribute-reads.md +0 -0
  28. {python_mobius-0.8.2 → python_mobius-0.8.4}/documentation/15-mob-template-format.md +0 -0
  29. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/cli.py +0 -0
  30. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/coap.py +0 -0
  31. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/constants.py +0 -0
  32. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/crc.py +0 -0
  33. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/device_status.py +0 -0
  34. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/discovery.py +0 -0
  35. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/dump.py +0 -0
  36. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/frame.py +0 -0
  37. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/manufacturer.py +0 -0
  38. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/mesh_address.py +0 -0
  39. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/mob.py +0 -0
  40. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/modifiers.py +0 -0
  41. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/power.py +0 -0
  42. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/pump_status.py +0 -0
  43. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/relay.py +0 -0
  44. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/scenes.py +0 -0
  45. {python_mobius-0.8.2 → python_mobius-0.8.4}/src/mobius/schedule.py +0 -0
  46. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_attributes_batch.py +0 -0
  47. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_cli_group_targets.py +0 -0
  48. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_cli_mob_file.py +0 -0
  49. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_cli_schedule_file.py +0 -0
  50. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_coap.py +0 -0
  51. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_coap_indication_handling.py +0 -0
  52. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_connection_cleanup.py +0 -0
  53. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_device_status.py +0 -0
  54. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_device_summary.py +0 -0
  55. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_device_time.py +0 -0
  56. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_discovery_company_ids.py +0 -0
  57. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_dump.py +0 -0
  58. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_extra_physical_values.py +0 -0
  59. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_fan_thresholds.py +0 -0
  60. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_firmware_versions.py +0 -0
  61. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_frame.py +0 -0
  62. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_frame_sniffer.py +0 -0
  63. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_full_poll_batch.py +0 -0
  64. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_group_bitmap.py +0 -0
  65. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_hyperdrive_device.py +0 -0
  66. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_insolation_detection.py +0 -0
  67. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_light_intensity_branching.py +0 -0
  68. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_light_schedule.py +0 -0
  69. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_manufacturer.py +0 -0
  70. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_manufacturer_lookup.py +0 -0
  71. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_mesh_address.py +0 -0
  72. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_mesh_discovery.py +0 -0
  73. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_mesh_discovery_direct_connect.py +0 -0
  74. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_metadata_batch.py +0 -0
  75. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_mob.py +0 -0
  76. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_modifiers.py +0 -0
  77. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_networked_thread_devices.py +0 -0
  78. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_new_diagnostic_attributes.py +0 -0
  79. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_power.py +0 -0
  80. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_pump_reverse.py +0 -0
  81. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_pump_schedule.py +0 -0
  82. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_pump_status.py +0 -0
  83. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_pump_supported_modes.py +0 -0
  84. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_pump_telemetry.py +0 -0
  85. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_relay.py +0 -0
  86. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_scenes.py +0 -0
  87. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_schedule_file_format.py +0 -0
  88. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_schedule_intensity_scalar.py +0 -0
  89. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_serial_decoding.py +0 -0
  90. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_serial_identity.py +0 -0
  91. {python_mobius-0.8.2 → python_mobius-0.8.4}/tests/test_supported_attributes.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.4
4
+
5
+ - Added `LightPollResult.lunar_enabled`/`lunar_phase_day`: the device's
6
+ own actual LunarPhasesEnabled setting and current lunar-cycle phase,
7
+ correct at any time of day. Not the same as
8
+ `intensities.diagnostics`'s own `lunar_enabled`/`lunar_date` keys,
9
+ which are only meaningful at night.
10
+
11
+ ## 0.8.3
12
+
13
+ - Added `set_lunar_enabled(enabled)`. Confirmed byte-for-byte against
14
+ the app's own compiled bytecode: exactly what its "Lunar" chip (top
15
+ of the light schedule editor) writes when toggled.
16
+
3
17
  ## 0.8.2
4
18
 
5
19
  - Added `LightPollResult.schedule_intensity`: the device's own raw
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.8.2
3
+ Version: 0.8.4
4
4
  Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
5
5
  Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
6
6
  Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.8.2"
7
+ version = "0.8.4"
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.2"
85
+ __version__ = "0.8.4"
86
86
 
87
87
  __all__ = [
88
88
  "__version__",
@@ -47,7 +47,7 @@ from .schedule import (
47
47
  )
48
48
  from .scenes import Scene, ActiveScene, decode_configured_scenes, decode_active_scene
49
49
  from .modifiers import (
50
- AcclimationInfo, is_night_segment, lunar_percent_reduction,
50
+ AcclimationInfo, is_night_segment, lunar_percent_reduction, lunar_days_into_phase,
51
51
  )
52
52
  from .power import ChannelPowerInfo, channel_percent_value
53
53
  from .pump_status import PumpFlowRange, BatteryBackupInfo, BoostedBatteryInfo
@@ -163,6 +163,35 @@ class LightPollResult:
163
163
  # failed and decode_schedule_intensity() had truly nothing to fall
164
164
  # back to.
165
165
  schedule_intensity: Optional[float] = None
166
+ # The device's own raw LunarPhasesEnabled (907) setting -- confirmed
167
+ # against the app's own "Lunar" chip, whose on/off state this
168
+ # matches exactly, at any time of day.
169
+ #
170
+ # A second, real, confirmed bug lived here before this field
171
+ # existed, same shape as schedule_intensity's own bug above:
172
+ # intensities.diagnostics["lunar_enabled"] is NOT this. Read
173
+ # process_light_intensities()'s own source: outside the night
174
+ # segment, it unconditionally sets lunar_enabled = None ("not
175
+ # checked/relevant outside the night segment" -- true for ITS OWN
176
+ # calculation, since the toggle only affects anything during the
177
+ # night segment, but not true for a caller that wants to know the
178
+ # device's own current setting regardless of time of day). A
179
+ # caller reading diagnostics["lunar_enabled"] during the day always
180
+ # gets None, even when the device's own toggle is genuinely on --
181
+ # this field is the one that's always correct, any time of day.
182
+ lunar_enabled: Optional[bool] = None
183
+ # The current lunar-cycle phase day (0-29, lunar_days_into_phase()'s
184
+ # own "days since new moon" convention), for a caller that wants to
185
+ # display the current moon phase -- not the same thing as the raw
186
+ # date this is computed from (which is only useful internally, for
187
+ # the schedule's own night-segment reduction calculation, and isn't
188
+ # exposed here at all). Falls back to today's local date when the
189
+ # device itself didn't provide one (Epoch/LocalTime unavailable),
190
+ # matching process_light_intensities()'s own "local_fallback"
191
+ # philosophy, so this is populated whenever `now` itself is valid --
192
+ # which it always is, since callers of get_light_poll_batch() are
193
+ # required to supply it.
194
+ lunar_phase_day: Optional[int] = None
166
195
 
167
196
 
168
197
  @dataclass
@@ -565,6 +594,7 @@ def decode_light_poll_from_batch(
565
594
  )
566
595
  return LightPollResult(
567
596
  schedule_points=points, intensities=intensities, used_batch=True, schedule_intensity=schedule_intensity,
597
+ lunar_enabled=lunar_enabled, lunar_phase_day=lunar_days_into_phase(lunar_date or now.date()),
568
598
  )
569
599
 
570
600
 
@@ -3463,6 +3493,35 @@ class MobiusDevice:
3463
3493
  return False
3464
3494
  return decode_lunar_enabled(raw)
3465
3495
 
3496
+ async def set_lunar_enabled(self, enabled: bool) -> None:
3497
+ """Writes LunarPhasesEnabled (907) -- confirmed byte-for-byte
3498
+ against the app's own compiled bytecode (LunarInfo.smali's own
3499
+ save() method): a single byte, the boolean enabled field stored
3500
+ directly as 0 or 1, via the exact same SetC2AttrFsciRequest.
3501
+ addAttribute() mechanism this library's own set_attribute()
3502
+ uses. This is exactly what the app's own "Lunar" chip (top of
3503
+ the light schedule editor) toggles: clicking it turns
3504
+ lunar-phase night-time dimming on/off, and while enabled that
3505
+ same chip also displays the current moon phase.
3506
+
3507
+ No group/broadcast involved -- confirmed by tracing
3508
+ LightingFragment's own saveLunar(), which loops over every
3509
+ light device in the tank and calls this same per-device,
3510
+ ungrouped write on EACH one individually (not a single
3511
+ mesh-wide broadcast the way set_time_to_now() works). The
3512
+ app's own UI applies the same new value to every light in the
3513
+ tank with that one tap, but the underlying wire mechanism is
3514
+ still one ordinary, targeted write per device -- this method
3515
+ only ever writes to the single device it's called on, matching
3516
+ every other attribute write in this library.
3517
+
3518
+ No which/schedule parameter -- unlike Schedule1Intensity/
3519
+ Schedule2Intensity, there's only ever a single
3520
+ LunarPhasesEnabled attribute (confirmed: no LunarPhasesEnabled2
3521
+ exists), so this applies to the light as a whole rather than to
3522
+ either schedule individually."""
3523
+ await self.set_attribute(C2Attribute.LunarPhasesEnabled, bytes([1 if enabled else 0]))
3524
+
3466
3525
  async def get_acclimation_info(self) -> Optional[AcclimationInfo]:
3467
3526
  """
3468
3527
  Fetches all 4 acclimation attributes (AcclimationEnabled/Period/
@@ -3811,9 +3870,12 @@ class MobiusDevice:
3811
3870
  points = await self.get_light_schedule(which)
3812
3871
  intensities = await self.get_current_light_intensities(which, minute_of_day, now)
3813
3872
  schedule_intensity = await self.get_schedule_intensity(which)
3873
+ lunar_enabled = await self.get_lunar_enabled(which)
3874
+ lunar_date = await self.get_app_lunar_date()
3814
3875
  return LightPollResult(
3815
3876
  schedule_points=points, intensities=intensities, used_batch=False,
3816
- schedule_intensity=schedule_intensity,
3877
+ schedule_intensity=schedule_intensity, lunar_enabled=lunar_enabled,
3878
+ lunar_phase_day=lunar_days_into_phase(lunar_date or now.date()),
3817
3879
  )
3818
3880
 
3819
3881
  async def get_full_poll_batch(
@@ -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
@@ -11,12 +11,14 @@ invented data.
11
11
  """
12
12
 
13
13
  import datetime
14
+ import struct
14
15
 
15
16
  import pytest
16
17
  from bleak.backends.device import BLEDevice
17
18
 
18
19
  from mobius import C2Attribute, AttributeValue, SupportedAttribute, VisualID, SchedulePoint
19
- from mobius.device import MobiusDevice
20
+ from mobius.device import MobiusDevice, compute_app_lunar_date
21
+ from mobius.modifiers import lunar_days_into_phase
20
22
 
21
23
 
22
24
  def _fake_ble_device():
@@ -137,26 +139,55 @@ async def test_night_segment_with_lunar_matches_individual_path_scenario():
137
139
  # must never be conflated.
138
140
  assert result.schedule_intensity == pytest.approx(0.897)
139
141
  assert result.schedule_intensity != intensities.diagnostics["scalar"]
142
+ # Same fields as the daytime test's own regression check -- here,
143
+ # where lunar reduction genuinely is in effect, they happen to
144
+ # agree with diagnostics, but they're still their own, independent
145
+ # fields (not simply forwarding diagnostics's own value).
146
+ assert result.lunar_enabled is True
147
+ # Epoch/LocalTime are deliberately absent in this scenario (see the
148
+ # comment above), so lunar_phase_day falls back to today's local
149
+ # date (matching process_light_intensities()'s own internal
150
+ # "local_fallback" philosophy) rather than being left unpopulated.
151
+ assert result.lunar_phase_day == lunar_days_into_phase(now.date())
140
152
 
141
153
 
142
154
  @pytest.mark.asyncio
143
155
  async def test_daytime_matches_individual_path_scenario():
144
156
  """Mirrors test_daytime_without_acclimation_uses_schedule_scalar_only
145
157
  in test_light_intensity_branching.py -- daytime, no acclimation
146
- data supported, schedule_intensity present."""
158
+ data supported, schedule_intensity present.
159
+
160
+ Also the exact regression scenario for a second, real, confirmed
161
+ production bug (same shape as schedule_intensity's own above):
162
+ the device's own LunarPhasesEnabled toggle is genuinely on here
163
+ (matching a real user's own report -- the app's "Lunar" chip shows
164
+ enabled at all times of day, not just at night), but
165
+ process_light_intensities() itself unconditionally wipes
166
+ diagnostics["lunar_enabled"]/["lunar_date"] to None outside the
167
+ night segment (true for its own reduction-factor calculation,
168
+ since the toggle only affects anything during the night segment --
169
+ not true for a caller wanting the device's own current setting
170
+ regardless of time of day). result.lunar_enabled/lunar_date are
171
+ the fields that stay correct at any time of day."""
147
172
  device = MobiusDevice(_fake_ble_device())
148
173
 
149
174
  async def fake_get_attributes_batch(requests):
150
175
  return [
151
176
  _av(C2Attribute.Schedule1, 0, *_day_schedule()),
152
177
  _av(C2Attribute.Schedule1Intensity, 0, bytes.fromhex("2803")), # 0x0328 = 808 -> 0.808
178
+ _av(C2Attribute.LunarPhasesEnabled, 0, bytes([1])),
179
+ _av(C2Attribute.Epoch, 0, struct.pack("<I", 1750000000)),
180
+ _av(C2Attribute.LocalTime, 0, struct.pack("<I", 1750000000)),
153
181
  ]
154
182
 
155
183
  device.get_attributes_batch = fake_get_attributes_batch
156
184
  now = datetime.datetime(2026, 6, 15, 12, 0)
157
185
  result = await device.get_light_poll_batch(
158
186
  minute_of_day=720, now=now,
159
- supported_attribute_ids={C2Attribute.Schedule1, C2Attribute.Schedule1Intensity},
187
+ supported_attribute_ids={
188
+ C2Attribute.Schedule1, C2Attribute.Schedule1Intensity, C2Attribute.LunarPhasesEnabled,
189
+ C2Attribute.Epoch, C2Attribute.LocalTime,
190
+ },
160
191
  )
161
192
 
162
193
  assert result.used_batch is True
@@ -167,6 +198,13 @@ async def test_daytime_matches_individual_path_scenario():
167
198
  assert result.schedule_intensity == pytest.approx(0.808)
168
199
  # RoyalBlue=500 raw * 0.808 scalar
169
200
  assert intensities[VisualID.RoyalBlue] == pytest.approx(500 * 0.808)
201
+ # The actual regression: the device's own toggle is genuinely on,
202
+ # but diagnostics unconditionally wipes it to None outside the
203
+ # night segment -- these must never be conflated.
204
+ assert intensities.diagnostics["lunar_enabled"] is None
205
+ assert result.lunar_enabled is True
206
+ assert intensities.diagnostics["lunar_date"] is None
207
+ assert result.lunar_phase_day == lunar_days_into_phase(compute_app_lunar_date(1750000000, 1750000000))
170
208
 
171
209
 
172
210
  # --------------------------------------------------------------------------
File without changes
File without changes
File without changes