python-mobius 0.4.2__tar.gz → 0.4.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 (65) hide show
  1. {python_mobius-0.4.2 → python_mobius-0.4.4}/CHANGELOG.md +24 -0
  2. {python_mobius-0.4.2 → python_mobius-0.4.4}/PKG-INFO +1 -1
  3. {python_mobius-0.4.2 → python_mobius-0.4.4}/pyproject.toml +1 -1
  4. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/__init__.py +1 -1
  5. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/constants.py +203 -70
  6. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/device.py +91 -19
  7. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/device_status.py +9 -6
  8. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/relay.py +43 -5
  9. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_device_time.py +59 -0
  10. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_light_intensity_branching.py +48 -0
  11. python_mobius-0.4.4/tests/test_manufacturer_lookup.py +59 -0
  12. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_relay.py +86 -0
  13. python_mobius-0.4.2/tests/test_manufacturer_lookup.py +0 -26
  14. {python_mobius-0.4.2 → python_mobius-0.4.4}/.forgejo/scripts/extract_changelog_section.py +0 -0
  15. {python_mobius-0.4.2 → python_mobius-0.4.4}/.forgejo/workflows/release.yml +0 -0
  16. {python_mobius-0.4.2 → python_mobius-0.4.4}/.gitignore +0 -0
  17. {python_mobius-0.4.2 → python_mobius-0.4.4}/LICENSE +0 -0
  18. {python_mobius-0.4.2 → python_mobius-0.4.4}/README.md +0 -0
  19. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/00-overview.md +0 -0
  20. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/01-ble-transport.md +0 -0
  21. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/02-framing-and-crc.md +0 -0
  22. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/03-attributes-and-opcodes.md +0 -0
  23. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/04-device-identity.md +0 -0
  24. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/05-scenes.md +0 -0
  25. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/06-light-schedule.md +0 -0
  26. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/07-pump-schedule.md +0 -0
  27. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/08-manufacturer-data.md +0 -0
  28. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/09-thread-coap-relay.md +0 -0
  29. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/10-known-gaps-and-open-questions.md +0 -0
  30. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/11-device-status-attributes.md +0 -0
  31. {python_mobius-0.4.2 → python_mobius-0.4.4}/documentation/12-device-identity-and-address-stability.md +0 -0
  32. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/cli.py +0 -0
  33. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/coap.py +0 -0
  34. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/crc.py +0 -0
  35. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/discovery.py +0 -0
  36. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/frame.py +0 -0
  37. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/manufacturer.py +0 -0
  38. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/mesh_address.py +0 -0
  39. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/modifiers.py +0 -0
  40. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/power.py +0 -0
  41. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/pump_status.py +0 -0
  42. {python_mobius-0.4.2 → python_mobius-0.4.4}/src/mobius/schedule.py +0 -0
  43. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_coap.py +0 -0
  44. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_coap_indication_handling.py +0 -0
  45. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_connection_cleanup.py +0 -0
  46. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_device_status.py +0 -0
  47. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_device_summary.py +0 -0
  48. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_firmware_versions.py +0 -0
  49. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_frame.py +0 -0
  50. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_frame_sniffer.py +0 -0
  51. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_hyperdrive_device.py +0 -0
  52. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_insolation_detection.py +0 -0
  53. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_light_schedule.py +0 -0
  54. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_manufacturer.py +0 -0
  55. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_mesh_address.py +0 -0
  56. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_mesh_discovery.py +0 -0
  57. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_mesh_discovery_direct_connect.py +0 -0
  58. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_modifiers.py +0 -0
  59. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_networked_thread_devices.py +0 -0
  60. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_power.py +0 -0
  61. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_pump_schedule.py +0 -0
  62. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_pump_status.py +0 -0
  63. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_schedule_intensity_scalar.py +0 -0
  64. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_serial_decoding.py +0 -0
  65. {python_mobius-0.4.2 → python_mobius-0.4.4}/tests/test_serial_identity.py +0 -0
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.4
4
+
5
+ - Filled in the rest of the device model list (EcoTech Marine,
6
+ AquaIllumination, Neptune Systems, NYOS), previously a curated
7
+ subset. Also corrects Model.VersaVX1, mismarked as AquaIllumination;
8
+ it's an EcoTech Marine product.
9
+ - Several Model entries are now named for their real product (e.g.
10
+ Model.Axis40, Model.Blade, Model.Quantum160) instead of the app's
11
+ own internal placeholder codename (Turtle2, Saddle, Coffee1).
12
+ - Fixed the lunar-phase-scaled light intensity sometimes disagreeing
13
+ with the app for hours at a stretch. The lunar calculation now uses
14
+ the gateway device's own reported date instead of the local
15
+ system's, matching what the app itself actually does.
16
+
17
+ ## 0.4.3
18
+
19
+ - Fixed relayed devices (anything reached through a gateway over the
20
+ Thread mesh, not directly over BLE) occasionally losing a read for
21
+ no apparent reason, even though the official app kept working fine.
22
+ A single relay round-trip crosses both BLE and the Thread mesh, and
23
+ one dropped or delayed packet somewhere in that longer path turned
24
+ out to be common enough that the app always retries once before
25
+ giving up -- this library now does the same.
26
+
3
27
  ## 0.4.2
4
28
 
5
29
  - Fixed `MeshPeer.age` being parsed as a signed number instead of
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.4.2
3
+ Version: 0.4.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.4.2"
7
+ version = "0.4.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" }
@@ -65,7 +65,7 @@ from .discovery import (
65
65
  discover_tank,
66
66
  )
67
67
 
68
- __version__ = "0.4.2"
68
+ __version__ = "0.4.4"
69
69
 
70
70
  __all__ = [
71
71
  "__version__",
@@ -61,71 +61,133 @@ PUMP_PRIMITIVES_EXPERIMENTAL = {PrimitiveType.CoffeeV1}
61
61
 
62
62
 
63
63
  class Model(IntEnum):
64
- """Device model identifiers. Confirmed literal values via reverse
65
- engineering the app's own model-identifier enum
66
- (a curated subset -- the full enum covers ~100 models across EcoTech
67
- Marine, AquaIllumination, Neptune Systems, NYOS, and Hera product lines;
68
- add entries here as needed)."""
64
+ """Device model identifiers. Confirmed literal values -- and, for
65
+ every entry, the real product name -- via reverse engineering the
66
+ app's own model-identifier enum and its own getName() method
67
+ directly (base64-decoded where the app itself stores the string
68
+ that way, confirmed real product names even for entries whose own
69
+ internal enum constant name is an unrelated food-themed placeholder
70
+ -- e.g. this enum's own Plumbus really is called "Plumbus" by the
71
+ real app itself, not a decompiler artifact). The full set, not a
72
+ curated subset -- see MODEL_MANUFACTURER below for the same
73
+ treatment of manufacturer. A few entries (the RadionXR#w G5/G6 FW/
74
+ Pro/Blue range specifically) are named here for the real product,
75
+ not the app's own internal enum constant name, to stay consistent
76
+ with how every other RadionXR#w entry in this same enum is already
77
+ named -- each renamed entry's own comment says what the app itself
78
+ actually calls it internally, for anyone cross-referencing against
79
+ the decompiled source directly."""
69
80
  Unknown = 0
70
- VorTechMP10wES = 10
71
- VorTechMP10wQD = 11
72
- RadionXR30w = 30
73
- RadionXR30wG2 = 31
74
- RadionXR30wPro = 32
75
- RadionXR30wG3 = 33
76
- RadionXR30wG3Pro = 34
77
- RadionXR30wG4 = 35
78
- RadionXR30wG4Pro = 36
79
- RadionXR30wFWPro = 37
80
- RadionXR30wPrototype = 39
81
- VorTechMP40wES = 40
82
- VorTechMP40wQD = 41
83
- VorTechMP40wG3QD = 42
84
- VorTechMP60wES = 60
85
- VorTechMP60wQD = 61
86
- VectraM1 = 144
87
- VectraL1 = 145
88
- VectraS1 = 146
89
- VectraS2 = 147
90
- VectraM2 = 148
91
- VectraL2 = 149
92
- RadionXR15wG3Pro = 160
93
- RadionXR15wFW = 161
94
- RadionXR15wG4Pro = 162
95
- RadionXR15wFWPro = 163
96
- RadionXR15wPrototype = 175
97
- RadionXR15G5Pro = 176
98
- RadionXR15wG5Blue = 177
99
- RadionXR15wG5FW = 178 # "Radion XR15w G5 FW" (base64-decoded from getName())
100
- RadionXR15wG6Pro = 179 # "Radion XR15w G6 Pro" (base64-decoded from getName())
101
- RadionXR15wG6Blue = 180 # "Radion XR15w G6 Blue" (inferred sequential id, base64-decoded name)
102
- RadionXR30wG5Pro = 192
103
- RadionXR30wG5Blue = 193
104
- Nero5 = 256
105
- Nero3 = 257
106
- DosingPump = 272
107
- Muffler = 275 # "Dose QD" -- another doser model
108
- Coffee1 = 396 # "Quantum 160" (NYOS) -- CoffeeV1 primitive
109
- Coffee2 = 397 # "Quantum 220/300" (NYOS)
110
-
111
-
112
- # Confirmed via reverse engineering the app's own manufacturer-lookup logic.
113
- # The decompiled version branches on
114
- # enum *ordinal* against a hardcoded position list (the full ~100-entry
115
- # Model enum's index 1-37 range, plus a few extras, all map to "EcoTech
116
- # Marine"); since this library only implements a curated subset of Model,
117
- # this is re-derived by name pattern against that same logic instead of by
118
- # ordinal position -- functionally equivalent for every Model value defined
119
- # above, but if you add more Model entries, add their manufacturer here too.
81
+ VorTechMP10wES = 10 # "VorTech MP10wES"
82
+ VorTechMP10wQD = 11 # "VorTech MP10mQD"
83
+ RadionXR30w = 30 # "Radion XR30w"
84
+ RadionXR30wG2 = 31 # "Radion XR30w G2"
85
+ RadionXR30wPro = 32 # "Radion XR30w Pro"
86
+ RadionXR30wG3 = 33 # "Radion XR30w G3"
87
+ RadionXR30wG3Pro = 34 # "Radion XR30wG3 Pro"
88
+ RadionXR30wG4 = 35 # "Radion XR30w G4"
89
+ RadionXR30wG4Pro = 36 # "Radion XR30w G4 Pro"
90
+ RadionXR30wFWPro = 37 # "Radion XR30wFW Pro"
91
+ RadionXR30wPrototype = 39 # "Radion XR30w Prototype"
92
+ VorTechMP40wES = 40 # "VorTech MP40wES"
93
+ VorTechMP40wQD = 41 # "VorTech MP40mQD"
94
+ VorTechMP40wG3QD = 42 # "VorTech MP40mQD"
95
+ VorTechMP60wES = 60 # "VorTech MP60wES"
96
+ VorTechMP60wQD = 61 # "VorTech MP60mQD"
97
+ VectraM1 = 144 # "Vectra M1"
98
+ VectraL1 = 145 # "Vectra L1"
99
+ VectraS1 = 146 # "Vectra S1"
100
+ VectraS2 = 147 # "Vectra S2"
101
+ VectraM2 = 148 # "Vectra M2"
102
+ VectraL2 = 149 # "Vectra L2"
103
+ RadionXR15wG3Pro = 160 # "Radion XR15wG3 Pro"
104
+ RadionXR15wFW = 161 # "Radion XR15w FW"
105
+ RadionXR15wG4Pro = 162 # "Radion XR15w G4 Pro"
106
+ RadionXR15wFWPro = 163 # "Radion XR15w FW Pro"
107
+ RadionXR15wPrototype = 175 # "Radion XR15w Prototype"
108
+ RadionXR15G5Pro = 176 # "Radion XR15w G5 Pro"
109
+ RadionXR15wG5Blue = 177 # "Radion XR15w G5 Blue"
110
+ RadionXR15wG5FW = 178 # "Radion XR15w G5 FW" (app's own internal enum name: BeefChalupa)
111
+ RadionXR15wG6Pro = 179 # "Radion XR15w G6 Pro" (app's own internal enum name: SteakQuesadilla)
112
+ RadionXR15wG6Blue = 180 # "Radion XR15w G6 Blue" (app's own internal enum name: SteakNachoFriesBurrito)
113
+ RadionXR30wG5Pro = 192 # "Radion XR30w G5 Pro"
114
+ RadionXR30wG5Blue = 193 # "Radion XR30w G5 Blue"
115
+ RadionXR30wG5FW = 194 # "Radion XR30w G5 FW" (app's own internal enum name: CrunchwrapSupreme)
116
+ RadionXR30wG6Pro = 195 # "Radion XR30w G6 Pro" (app's own internal enum name: SteakTaco)
117
+ RadionXR30wG6Blue = 196 # "Radion XR30w G6 Blue" (app's own internal enum name: BeefyMeltBurrito)
118
+ Nero5 = 256 # "Nero 5"
119
+ Nero3 = 257 # "Nero 3"
120
+ Nero7 = 258 # "Nero 7" (app's own internal enum name: Slack)
121
+ Orbit2 = 259 # "Orbit 2" (app's own internal enum name: Alpaca2)
122
+ Orbit4 = 260 # "Orbit 4" (app's own internal enum name: Alpaca4)
123
+ Axis20 = 261 # "Axis 20" (app's own internal enum name: Turtle1)
124
+ Axis40 = 262 # "Axis 40" (app's own internal enum name: Turtle2)
125
+ Axis90 = 263 # "Axis 90" (app's own internal enum name: Turtle3)
126
+ Axis200 = 264 # "Axis 200" (app's own internal enum name: Turtle4)
127
+ VersaVX1 = 272 # "Versa VX-1" (app's own internal enum name: DosingPump)
128
+ DoseQD = 275 # "Dose QD" (app's own internal enum name: Muffler)
129
+ SproutSL1 = 288 # "Sprout SL1" (app's own internal enum name: Chalupa)
130
+ EnvironmentalSensor = 304 # "Environmental Sensor" (app's own internal enum name: DoritosLocosTacos)
131
+ Prime = 320 # "Prime"
132
+ PrimeHD = 321 # "Prime HD"
133
+ PrimeFW = 322 # "Prime FW" (the app's own getName() genuinely returns an empty string for this one specifically -- not a parsing gap here)
134
+ HydraTwentySixHD = 323 # "Hydra TwentySix HD"
135
+ HydraFiftyTwoHD = 324 # "Hydra FiftyTwo HD"
136
+ RedSeaMAX13 = 325 # "RedSea MAX 13"
137
+ RedSeaMAX26 = 326 # "RedSea MAX 26"
138
+ PrimeSixteenHD = 333 # "Prime Sixteen HD"
139
+ HydraThirtyTwoHD = 334 # "Hydra ThirtyTwo HD"
140
+ HydraSixtyFourHD = 335 # "Hydra SixtyFour HD"
141
+ PrimeSixteenHdFw = 339 # "16 Freshwater"
142
+ PrimeSixteenHdFuge = 340 # "16 Fuge"
143
+ PrimeSixteenHdSol = 341 # "16 Sol"
144
+ PrimeFuge = 342 # "Prime Fuge"
145
+ Hydra32HDFreshwater = 344 # "Hydra 32HD Freshwater" (app's own internal enum name: Quesarito)
146
+ Thundervolt = 345 # "Thundervolt" (app's own internal enum name: NachosBellGrande)
147
+ Plumbus = 346 # "Plumbus"
148
+ Blade = 347 # "Blade" (app's own internal enum name: Saddle)
149
+ Leap = 349 # "Leap" (app's own internal enum name: BeanBurrito)
150
+ MXM = 350 # "MXM" (app's own internal enum name: Cowboy)
151
+ BladeD = 360 # "Blade" (app's own internal enum name: SaddleD)
152
+ BladeCoralGlow = 361 # "Blade Coral Glow" (app's own internal enum name: SaddleA)
153
+ BladeCoralGrow = 362 # "Blade Coral Grow" (app's own internal enum name: SaddleM)
154
+ BladeFreshwater = 363 # "Blade Freshwater" (app's own internal enum name: SaddleF)
155
+ BladeRefugium = 364 # "Blade Refugium" (app's own internal enum name: SaddleR)
156
+ BladeGrowPlus = 365 # "Blade Grow+" (app's own internal enum name: SaddleG)
157
+ BladeGloFish = 368 # "Blade GloFish" (app's own internal enum name: SaddleGF)
158
+ HydraEdge44HD = 371 # "Hydra Edge 44HD" (app's own internal enum name: AlfalfaRS)
159
+ HydraEdge68HD = 372 # "Hydra Edge 68HD" (app's own internal enum name: AlfalfaRL)
160
+ HydraEdge44HDFreshwater = 373 # "Hydra Edge 44HD Freshwater" (app's own internal enum name: AlfalfaFS)
161
+ HydraEdge68HDFreshwater = 374 # "Hydra Edge 68HD Freshwater" (app's own internal enum name: AlfalfaFL)
162
+ Quantum160 = 396 # "Quantum 160" (app's own internal enum name: Coffee1)
163
+ Quantum220300 = 397 # "Quantum 220/300" (app's own internal enum name: Coffee2)
164
+ Hera = 512 # "Hera" (app's own internal enum name: CrunchyTaco)
165
+ HeraBackplane = 513 # "Hera Backplane" (app's own internal enum name: SoftTaco)
166
+ HeraPHSensor = 514 # "Hera pH Sensor" (app's own internal enum name: LuxeBox)
167
+ HeraDissolvedOxygenSensor = 515 # "Hera Dissolved Oxygen Sensor" (app's own internal enum name: DiabloSauce)
168
+ HeraFluidMonitoringModule = 516 # "Hera Fluid Monitoring Module" (app's own internal enum name: FlaminHotRice)
169
+ HeraIOModule = 517 # "Hera I/O Module" (app's own internal enum name: CinnabonDelights)
170
+ HeraORPSensor = 518 # "Hera ORP sensor" (app's own internal enum name: BajaBlast)
171
+ HeraSalinitySensor = 519 # "Hera Salinity Sensor" (app's own internal enum name: CantinaChicken)
172
+ HeraSwitchedPowerModule = 520 # "Hera Switched Power Module" (app's own internal enum name: VeggieBowl)
173
+ RFModDev = 61441 # "RF Mod Dev"
174
+ WiFiModDev = 61442 # "WiFi Mod Dev"
175
+ ETMRFModProd = 61443 # "ETM RF Mod Prod"
176
+ WiFiModBreakout = 61444 # "WiFi Mod Breakout"
177
+ Passthrough = 61445 # "Devel Board"
178
+
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).
120
185
  MODEL_MANUFACTURER: dict[Model, str] = {
121
186
  Model.Unknown: "Unknown",
187
+
188
+ # EcoTech Marine
122
189
  Model.VorTechMP10wES: "EcoTech Marine",
123
190
  Model.VorTechMP10wQD: "EcoTech Marine",
124
- Model.VorTechMP40wES: "EcoTech Marine",
125
- Model.VorTechMP40wQD: "EcoTech Marine",
126
- Model.VorTechMP40wG3QD: "EcoTech Marine",
127
- Model.VorTechMP60wES: "EcoTech Marine",
128
- Model.VorTechMP60wQD: "EcoTech Marine",
129
191
  Model.RadionXR30w: "EcoTech Marine",
130
192
  Model.RadionXR30wG2: "EcoTech Marine",
131
193
  Model.RadionXR30wPro: "EcoTech Marine",
@@ -135,6 +197,17 @@ MODEL_MANUFACTURER: dict[Model, str] = {
135
197
  Model.RadionXR30wG4Pro: "EcoTech Marine",
136
198
  Model.RadionXR30wFWPro: "EcoTech Marine",
137
199
  Model.RadionXR30wPrototype: "EcoTech Marine",
200
+ Model.VorTechMP40wES: "EcoTech Marine",
201
+ Model.VorTechMP40wQD: "EcoTech Marine",
202
+ Model.VorTechMP40wG3QD: "EcoTech Marine",
203
+ Model.VorTechMP60wES: "EcoTech Marine",
204
+ Model.VorTechMP60wQD: "EcoTech Marine",
205
+ Model.VectraM1: "EcoTech Marine",
206
+ Model.VectraL1: "EcoTech Marine",
207
+ Model.VectraS1: "EcoTech Marine",
208
+ Model.VectraS2: "EcoTech Marine",
209
+ Model.VectraM2: "EcoTech Marine",
210
+ Model.VectraL2: "EcoTech Marine",
138
211
  Model.RadionXR15wG3Pro: "EcoTech Marine",
139
212
  Model.RadionXR15wFW: "EcoTech Marine",
140
213
  Model.RadionXR15wG4Pro: "EcoTech Marine",
@@ -147,18 +220,75 @@ MODEL_MANUFACTURER: dict[Model, str] = {
147
220
  Model.RadionXR15wG6Blue: "EcoTech Marine",
148
221
  Model.RadionXR30wG5Pro: "EcoTech Marine",
149
222
  Model.RadionXR30wG5Blue: "EcoTech Marine",
150
- Model.VectraM1: "EcoTech Marine",
151
- Model.VectraL1: "EcoTech Marine",
152
- Model.VectraS1: "EcoTech Marine",
153
- Model.VectraS2: "EcoTech Marine",
154
- Model.VectraM2: "EcoTech Marine",
155
- Model.VectraL2: "EcoTech Marine",
223
+ Model.RadionXR30wG5FW: "EcoTech Marine",
224
+ Model.RadionXR30wG6Pro: "EcoTech Marine",
225
+ Model.RadionXR30wG6Blue: "EcoTech Marine",
226
+ Model.VersaVX1: "EcoTech Marine",
227
+ Model.Thundervolt: "EcoTech Marine",
228
+ Model.Leap: "EcoTech Marine",
229
+ Model.ETMRFModProd: "EcoTech Marine",
230
+
231
+ # AquaIllumination
156
232
  Model.Nero5: "AquaIllumination",
157
233
  Model.Nero3: "AquaIllumination",
158
- Model.DosingPump: "AquaIllumination",
159
- Model.Muffler: "Neptune Systems", # confirmed explicitly in the decompile's manufacturer list
160
- Model.Coffee1: "NYOS", # confirmed explicitly (CoffeeV1 branch)
161
- Model.Coffee2: "NYOS",
234
+ Model.Nero7: "AquaIllumination",
235
+ Model.Orbit2: "AquaIllumination",
236
+ Model.Orbit4: "AquaIllumination",
237
+ Model.Axis20: "AquaIllumination",
238
+ Model.Axis40: "AquaIllumination",
239
+ Model.Axis90: "AquaIllumination",
240
+ Model.Axis200: "AquaIllumination",
241
+ Model.SproutSL1: "AquaIllumination",
242
+ Model.EnvironmentalSensor: "AquaIllumination",
243
+ Model.Prime: "AquaIllumination",
244
+ Model.PrimeHD: "AquaIllumination",
245
+ Model.PrimeFW: "AquaIllumination",
246
+ Model.HydraTwentySixHD: "AquaIllumination",
247
+ Model.HydraFiftyTwoHD: "AquaIllumination",
248
+ Model.RedSeaMAX13: "AquaIllumination",
249
+ Model.RedSeaMAX26: "AquaIllumination",
250
+ Model.PrimeSixteenHD: "AquaIllumination",
251
+ Model.HydraThirtyTwoHD: "AquaIllumination",
252
+ Model.HydraSixtyFourHD: "AquaIllumination",
253
+ Model.PrimeSixteenHdFw: "AquaIllumination",
254
+ Model.PrimeSixteenHdFuge: "AquaIllumination",
255
+ Model.PrimeSixteenHdSol: "AquaIllumination",
256
+ Model.PrimeFuge: "AquaIllumination",
257
+ Model.Hydra32HDFreshwater: "AquaIllumination",
258
+ Model.Plumbus: "AquaIllumination",
259
+ Model.Blade: "AquaIllumination",
260
+ Model.BladeD: "AquaIllumination",
261
+ Model.BladeCoralGlow: "AquaIllumination",
262
+ Model.BladeCoralGrow: "AquaIllumination",
263
+ Model.BladeFreshwater: "AquaIllumination",
264
+ Model.BladeRefugium: "AquaIllumination",
265
+ Model.BladeGrowPlus: "AquaIllumination",
266
+ Model.BladeGloFish: "AquaIllumination",
267
+ Model.HydraEdge44HD: "AquaIllumination",
268
+ Model.HydraEdge68HD: "AquaIllumination",
269
+ Model.HydraEdge44HDFreshwater: "AquaIllumination",
270
+ Model.HydraEdge68HDFreshwater: "AquaIllumination",
271
+ Model.RFModDev: "AquaIllumination",
272
+ Model.WiFiModDev: "AquaIllumination",
273
+ Model.WiFiModBreakout: "AquaIllumination",
274
+ Model.Passthrough: "AquaIllumination",
275
+
276
+ # Neptune Systems
277
+ Model.DoseQD: "Neptune Systems",
278
+ Model.MXM: "Neptune Systems",
279
+ Model.Hera: "Neptune Systems",
280
+ Model.HeraBackplane: "Neptune Systems",
281
+ Model.HeraPHSensor: "Neptune Systems",
282
+ Model.HeraDissolvedOxygenSensor: "Neptune Systems",
283
+ Model.HeraFluidMonitoringModule: "Neptune Systems",
284
+ Model.HeraIOModule: "Neptune Systems",
285
+ Model.HeraORPSensor: "Neptune Systems",
286
+ Model.HeraSalinitySensor: "Neptune Systems",
287
+ Model.HeraSwitchedPowerModule: "Neptune Systems",
288
+
289
+ # NYOS
290
+ Model.Quantum160: "NYOS",
291
+ Model.Quantum220300: "NYOS",
162
292
  }
163
293
 
164
294
 
@@ -276,6 +406,9 @@ class C2Attribute(IntEnum):
276
406
  LunarPhasesEnabled = 907
277
407
  Epoch = 201
278
408
  RTCTime = 219
409
+ LocalTime = 207 # already device-timezone-adjusted -- see
410
+ # get_device_local_date()'s own docstring in
411
+ # device.py for why this exists at all
279
412
  # Thread mesh peer discovery -- see documentation/09-thread-coap-relay.md
280
413
  # for the full protocol trace. MeshLocalAddresses confirmed via
281
414
  # reverse engineering the app's own connection-setup logic; the three
@@ -637,10 +637,12 @@ class MobiusDevice:
637
637
  a real, known thing worth
638
638
  checking, using this exact same drift computation
639
639
  (local_time_seconds - device_reported_seconds). See
640
- DeviceTimeInfo's docstring for the important caveat that this
641
- doesn't directly explain schedule-interpolation mismatches (the
642
- app's own schedule lookup uses the phone/tank clock, not either
643
- of these), but is still worth ruling out as a general health check.
640
+ DeviceTimeInfo's docstring for the important caveat that these
641
+ two specific attributes don't directly explain a schedule-
642
+ interpolation mismatch by themselves (LocalTime, a different
643
+ attribute entirely, is what actually matters there -- see
644
+ get_device_local_date()), but real device clock drift is still
645
+ worth ruling out as a general health check.
644
646
  """
645
647
  import datetime as _datetime
646
648
  now = int(_datetime.datetime.now().timestamp())
@@ -668,6 +670,52 @@ class MobiusDevice:
668
670
  rtc_drift_seconds=(now - device_rtc) if device_rtc is not None else None,
669
671
  )
670
672
 
673
+ async def get_device_local_date(self) -> Optional["datetime.date"]:
674
+ """
675
+ The gateway device's own, ALREADY TIMEZONE-ADJUSTED idea of what
676
+ calendar date it currently is -- confirmed, not assumed, to be
677
+ the actual source get_current_light_intensities() needs for its
678
+ own lunar-phase calculation, by tracing the real app's own call
679
+ chain directly: VPointScheduleView's own lunar.percentReduction()
680
+ call passes vPointScheduleView2.time.date(), where that `time`
681
+ is set via Tank.getDateTime() -- which is populated by
682
+ DateTime.parseRequest(), which reads Epoch (201) AND LocalTime
683
+ (207) directly off the connected gateway device itself, NOT
684
+ computed from the phone's own system clock at all. An earlier
685
+ version of this library's own DeviceTimeInfo docstring claimed
686
+ the opposite ("the app's own schedule interpolation uses the
687
+ phone/tank's own clock, NOT either of these device-reported
688
+ values") -- that claim was wrong specifically for the lunar
689
+ calculation, confirmed by a real, reported production mismatch
690
+ (this library showing the wrong lunar phase for hours at a
691
+ stretch, spanning exactly the window between local calendar
692
+ midnight and whenever the device's own reported date actually
693
+ rolled over) that this method's own existence now fixes.
694
+
695
+ LocalTime specifically, not Epoch: the device itself already
696
+ applies its own configured timezone offset when reporting this
697
+ value (confirmed by the app's own DateTime constructor -- it
698
+ derives its own UTC offset as Epoch minus LocalTime, meaning
699
+ LocalTime is Epoch already shifted by that same offset), so it
700
+ can be treated directly as an offset-adjusted "local-looking"
701
+ Unix timestamp -- no separate timezone lookup or arithmetic
702
+ needed on this library's own side at all.
703
+
704
+ Returns None if this attribute isn't available for any reason
705
+ (unsupported, or a soft read failure) -- callers should fall
706
+ back to their own local system date in that case, same as
707
+ get_current_light_intensities() itself does.
708
+ """
709
+ try:
710
+ raw = await self.get_attribute(C2Attribute.LocalTime)
711
+ except Exception:
712
+ return None
713
+ if not raw:
714
+ return None
715
+ import datetime as _datetime
716
+ local_time_seconds = struct.unpack("<I", raw[0])[0]
717
+ return _datetime.datetime.fromtimestamp(local_time_seconds, tz=_datetime.timezone.utc).date()
718
+
671
719
  async def get_own_mesh_address(self) -> Optional[bytes]:
672
720
  """
673
721
  Fetches MeshLocalAddresses (1005) -- THIS device's own Thread
@@ -1464,16 +1512,27 @@ class MobiusDevice:
1464
1512
  now: Optional["datetime.datetime"] = None) -> LightIntensityResult:
1465
1513
  """
1466
1514
  Replicates what the app actually shows as "current intensity": fetch
1467
- the schedule, linearly interpolate it for the given time (defaults
1468
- to local system time -- NOT read from the device; the app itself
1469
- uses the tank's configured timezone against phone-local time, not a
1470
- device clock read, so this mirrors that behavior), then apply
1471
- whichever scalar the app itself would apply on top -- confirmed via
1472
- reverse engineering the app's own exact branching:
1515
+ the schedule, linearly interpolate it for the given time (minute_of_day
1516
+ defaults to local system time if not given -- pass it explicitly with
1517
+ a timezone-aware value if the caller has one, since this library has
1518
+ no way to know the correct timezone on its own), then apply whichever
1519
+ scalar the app itself would apply on top -- confirmed via reverse
1520
+ engineering the app's own exact branching:
1473
1521
 
1474
1522
  - dusk-to-night segment (see is_night_segment()) AND lunar phases
1475
1523
  enabled -> lunar_percent_reduction(date) in place of the normal
1476
- schedule intensity
1524
+ schedule intensity. The date used here specifically is
1525
+ get_device_local_date()'s own result (the connected gateway's
1526
+ own reported LocalTime attribute), falling back to `now`'s own
1527
+ date only if that read fails -- confirmed by tracing the real
1528
+ app's own call chain directly (see get_device_local_date()'s
1529
+ own docstring for the full trace) that this is genuinely a
1530
+ DEVICE-reported value, not phone/system-local time at all,
1531
+ unlike everything else this method computes. A real, reported
1532
+ production mismatch (this library showing the wrong lunar
1533
+ phase for hours at a stretch, spanning exactly the gap between
1534
+ local calendar midnight and whenever the device's own reported
1535
+ date actually rolled over) is what confirmed this.
1477
1536
  - dusk-to-night segment, lunar not enabled -> scalar = 1.0
1478
1537
  (full/unreduced)
1479
1538
  - otherwise (normal daytime) -> the schedule-level master
@@ -1491,13 +1550,17 @@ class MobiusDevice:
1491
1550
 
1492
1551
  `.diagnostics` also includes "is_night_segment", "lunar_enabled",
1493
1552
  "scalar_source" (one of "lunar"/"night_no_lunar"/
1494
- "schedule_intensity"), and "scalar" (the actual final multiplier
1495
- applied) -- added specifically to make it possible to tell which
1496
- branch was taken and why, since a mismatch against the app's own
1497
- displayed value could stem from either is_night_segment()
1498
- misjudging this schedule's specific point structure, or
1499
- get_lunar_enabled() reading incorrectly, and those need to be told
1500
- apart with real data rather than guessed at.
1553
+ "schedule_intensity"), "scalar" (the actual final multiplier
1554
+ applied), and, specifically for the "lunar" case, "lunar_date"
1555
+ (the actual date used for that calculation) and
1556
+ "lunar_date_source" (one of "device"/"local_fallback") -- added
1557
+ specifically to make it possible to tell which branch was taken
1558
+ and why, since a mismatch against the app's own displayed value
1559
+ could stem from is_night_segment() misjudging this schedule's
1560
+ specific point structure, get_lunar_enabled() reading
1561
+ incorrectly, or (confirmed now to be a real, genuine possibility)
1562
+ the wrong date being used for the lunar calculation itself, and
1563
+ those need to be told apart with real data rather than guessed at.
1501
1564
  """
1502
1565
  import datetime as _datetime
1503
1566
  if now is None:
@@ -1509,10 +1572,17 @@ class MobiusDevice:
1509
1572
  raw_intensities = interpolate_light_schedule(points, minute_of_day)
1510
1573
 
1511
1574
  night_segment = is_night_segment(points, minute_of_day)
1575
+ lunar_date = None
1576
+ lunar_date_source = None
1512
1577
  if night_segment:
1513
1578
  lunar_enabled = await self.get_lunar_enabled(which)
1514
1579
  if lunar_enabled:
1515
- scalar = lunar_percent_reduction(now.date())
1580
+ lunar_date = await self.get_device_local_date()
1581
+ lunar_date_source = "device"
1582
+ if lunar_date is None:
1583
+ lunar_date = now.date()
1584
+ lunar_date_source = "local_fallback"
1585
+ scalar = lunar_percent_reduction(lunar_date)
1516
1586
  scalar_source = "lunar"
1517
1587
  else:
1518
1588
  scalar = 1.0
@@ -1533,6 +1603,8 @@ class MobiusDevice:
1533
1603
  "lunar_enabled": lunar_enabled,
1534
1604
  "scalar_source": scalar_source,
1535
1605
  "scalar": scalar,
1606
+ "lunar_date": lunar_date,
1607
+ "lunar_date_source": lunar_date_source,
1536
1608
  })
1537
1609
 
1538
1610
  async def get_current_light_percentages(self, which: int = 1,
@@ -99,12 +99,15 @@ class DeviceTimeInfo:
99
99
  clock, treating a large discrepancy as a real, known failure mode --
100
100
  this isn't speculative, it's the exact same check the app runs.
101
101
 
102
- Note: the app's own schedule interpolation uses
103
- the phone/tank's own clock, NOT either of
104
- these device-reported values -- so this doesn't directly explain a
105
- schedule-interpolation mismatch by itself, but real device clock drift
106
- is still worth ruling out as a general health signal, exactly as the
107
- app's own troubleshooting tool treats it.
102
+ Note: this doesn't include LocalTime (207) -- see
103
+ get_device_local_date()'s own docstring for why that one specific
104
+ attribute is genuinely different (it's what the app's own lunar-
105
+ phase calculation actually uses, confirmed by tracing the app's own
106
+ call chain directly), unlike Epoch/RTCTime here, which the app's
107
+ own schedule interpolation itself does NOT use (an earlier version
108
+ of this docstring incorrectly extended that same "not used for
109
+ schedule purposes" claim to LocalTime too, before that call chain
110
+ had actually been traced).
108
111
  """
109
112
  device_epoch: Optional[int] # raw UNIX timestamp reported by the device
110
113
  device_rtc: Optional[int]
@@ -177,12 +177,52 @@ class RelayedMobiusDevice(MobiusDevice):
177
177
  embedded in its payload (confirmed via reverse engineering:
178
178
  coapRequest.getToken() == coapResponse.getToken()) -- never by
179
179
  this outer frame's own message ID.
180
+
181
+ Retries exactly once on a timeout before actually raising --
182
+ confirmed via reverse engineering the app's own request-timeout
183
+ handling: it never treats a single timed-out relay round-trip as
184
+ a real failure, always transparently retrying (fresh send, same
185
+ request) once first, only surfacing a failure if the retry ALSO
186
+ times out. A relayed round-trip crosses BLE (to the gateway) and
187
+ then the Thread mesh itself (gateway to target and back) -- a
188
+ single dropped/delayed packet somewhere in that longer path is
189
+ evidently common enough that the app was built assuming it will
190
+ happen regularly, not as an edge case. Each attempt gets its own
191
+ fresh CoAP token (this library generates one per call already,
192
+ not tied to a single request object the way the app's own retry
193
+ reuses one) -- deliberately, so a late, delayed response to the
194
+ first, abandoned attempt can never be mismatched against the
195
+ retry's own, separate wait.
180
196
  """
181
197
  if not self.gateway.is_connected:
182
198
  raise IOError(
183
199
  f"gateway device is not connected -- cannot relay to {self.peer.serial!r} through it"
184
200
  )
185
201
 
202
+ last_timeout_token: Optional[int] = None
203
+ for attempt in (1, 2):
204
+ try:
205
+ return await self._relay_attempt(inner_request_frame, is_retry=(attempt == 2))
206
+ except asyncio.TimeoutError as err:
207
+ last_timeout_token = err.args[0] if err.args else None
208
+ if attempt == 1 and self.debug:
209
+ print(f" [relay debug] timed out waiting {self.timeout}s for "
210
+ f"{self.peer.serial!r} -- retrying once before giving up")
211
+ continue
212
+
213
+ raise IOError(
214
+ f"timed out waiting {self.timeout}s for a relayed response from target "
215
+ f"{self.peer.serial!r} (no CoAP indication received for token {last_timeout_token}, "
216
+ f"even after one retry)"
217
+ )
218
+
219
+ async def _relay_attempt(self, inner_request_frame: bytes, is_retry: bool) -> ParsedFrame:
220
+ """One full send-and-await cycle for _relay() -- split out so
221
+ _relay() itself can retry it once on a timeout without
222
+ duplicating this whole body. Raises asyncio.TimeoutError itself
223
+ (not wrapped in IOError) specifically so _relay() can catch just
224
+ that case to decide whether to retry; every other exception here
225
+ propagates as-is, unwrapped, straight out of _relay() too."""
186
226
  token = next_coap_token()
187
227
  # Confirmed always GET regardless of the inner operation -- see
188
228
  # this module's docstring for why.
@@ -193,7 +233,8 @@ class RelayedMobiusDevice(MobiusDevice):
193
233
  outer_frame = build_frame(OPGROUP_C2CI_REQUEST, COAP_OPCODE, coap_payload, outer_message_id)
194
234
 
195
235
  if self.debug:
196
- print(f" [relay debug] inner request frame ({len(inner_request_frame)}B): "
236
+ retry_note = " (retry)" if is_retry else ""
237
+ print(f" [relay debug] inner request frame{retry_note} ({len(inner_request_frame)}B): "
197
238
  f"{inner_request_frame.hex()}")
198
239
  print(f" [relay debug] target address: {self.peer.address.hex()} token: {token}")
199
240
  print(f" [relay debug] outer request frame ({len(outer_frame)}B): {outer_frame.hex()}")
@@ -220,10 +261,7 @@ class RelayedMobiusDevice(MobiusDevice):
220
261
  coap_response = await asyncio.wait_for(coap_future, timeout=self.timeout)
221
262
  except asyncio.TimeoutError:
222
263
  self.gateway._pending_coap_tokens.pop(token, None)
223
- raise IOError(
224
- f"timed out waiting {self.timeout}s for a relayed response from target "
225
- f"{self.peer.serial!r} (no CoAP indication received for token {token})"
226
- )
264
+ raise asyncio.TimeoutError(token) from None
227
265
  finally:
228
266
  if self.debug:
229
267
  self.gateway.on_any_frame = previous_hook
@@ -5,6 +5,7 @@ device-clock-drift check confirmed real via the app's own
5
5
  under Troubleshoot.
6
6
  """
7
7
 
8
+ import datetime
8
9
  import struct
9
10
  import time
10
11
 
@@ -123,3 +124,61 @@ async def test_get_device_time_info_unsigned_wraparound():
123
124
 
124
125
  assert info.device_epoch == high_bit_set_value
125
126
  assert info.device_epoch > 0 # not decoded as a negative signed int
127
+
128
+
129
+ # --------------------------------------------------------------------------
130
+ # get_device_local_date() -- a genuinely different attribute (LocalTime,
131
+ # 207) from Epoch/RTCTime above. See its own docstring in device.py for
132
+ # the full trace confirming this is the actual date source the real app
133
+ # itself uses for its own lunar-phase calculation -- these tests cover
134
+ # just the raw LocalTime -> date conversion in isolation, separate from
135
+ # test_light_intensity_branching.py's own end-to-end coverage of how
136
+ # get_current_light_intensities() actually uses it.
137
+ # --------------------------------------------------------------------------
138
+
139
+ @pytest.mark.asyncio
140
+ async def test_get_device_local_date_converts_local_time_correctly():
141
+ device = MobiusDevice(_fake_ble_device())
142
+ # LocalTime is already device-timezone-adjusted -- treated as a
143
+ # "local-looking" UTC timestamp, per this method's own docstring.
144
+ local_time_value = int(datetime.datetime(2026, 3, 15, 23, 30, tzinfo=datetime.timezone.utc).timestamp())
145
+
146
+ async def fake_get_attribute(attr_id, index=0, count=1):
147
+ if attr_id == C2Attribute.LocalTime:
148
+ return [struct.pack("<I", local_time_value)]
149
+ raise AssertionError(f"unexpected attribute: {attr_id}")
150
+
151
+ device.get_attribute = fake_get_attribute
152
+ result = await device.get_device_local_date()
153
+
154
+ assert result == datetime.date(2026, 3, 15)
155
+
156
+
157
+ @pytest.mark.asyncio
158
+ async def test_get_device_local_date_fails_soft_when_unsupported():
159
+ """Callers (get_current_light_intensities()) depend on this
160
+ returning None cleanly, not raising, so they can fall back to their
161
+ own local date -- not every device necessarily supports this
162
+ attribute."""
163
+ device = MobiusDevice(_fake_ble_device())
164
+
165
+ async def fake_get_attribute(attr_id, index=0, count=1):
166
+ raise IOError("unsupported attribute")
167
+
168
+ device.get_attribute = fake_get_attribute
169
+ result = await device.get_device_local_date()
170
+
171
+ assert result is None
172
+
173
+
174
+ @pytest.mark.asyncio
175
+ async def test_get_device_local_date_fails_soft_on_empty_response():
176
+ device = MobiusDevice(_fake_ble_device())
177
+
178
+ async def fake_get_attribute(attr_id, index=0, count=1):
179
+ return []
180
+
181
+ device.get_attribute = fake_get_attribute
182
+ result = await device.get_device_local_date()
183
+
184
+ assert result is None
@@ -52,6 +52,8 @@ async def test_night_segment_with_lunar_enabled_uses_lunar_reduction():
52
52
  return _night_schedule()
53
53
  if attr_id == C2Attribute.LunarPhasesEnabled:
54
54
  return [bytes([1])]
55
+ if attr_id == C2Attribute.LocalTime:
56
+ return None # deliberately unavailable -- falls back to now's own date
55
57
  raise AssertionError(f"unexpected attribute during night+lunar test: {attr_id}")
56
58
 
57
59
  device.get_attribute = fake_get_attribute
@@ -72,6 +74,52 @@ async def test_night_segment_with_lunar_enabled_uses_lunar_reduction():
72
74
  assert result.diagnostics["lunar_enabled"] is True
73
75
  assert result.diagnostics["scalar_source"] == "lunar"
74
76
  assert result.diagnostics["scalar"] == pytest.approx(0.0)
77
+ assert result.diagnostics["lunar_date_source"] == "local_fallback"
78
+ assert result.diagnostics["lunar_date"] == now.date()
79
+
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_night_segment_with_lunar_uses_the_devices_own_reported_date():
83
+ """The actual, real, reported production bug this fixes: an earlier
84
+ version of this method always used `now`'s own date for the lunar
85
+ calculation, having incorrectly assumed (based on an earlier,
86
+ incomplete trace) that the real app does the same. It doesn't --
87
+ confirmed by tracing the real app's own call chain all the way
88
+ through to DateTime.parseRequest(), which reads Epoch AND LocalTime
89
+ directly off the connected gateway device itself. This test proves
90
+ the fix actually uses that device-reported date, not `now`'s own --
91
+ by making them deliberately, unambiguously disagree (device says
92
+ one date, `now` says a different one), and confirming the device's
93
+ own date is what the lunar reduction is actually computed from."""
94
+ device = MobiusDevice(_fake_ble_device())
95
+
96
+ # A real new-moon date the device itself reports, deliberately
97
+ # different from `now`'s own date below.
98
+ device_date = datetime.date(2026, 1, 18)
99
+ device_local_time = int(datetime.datetime(
100
+ 2026, 1, 18, 22, 30, tzinfo=datetime.timezone.utc,
101
+ ).timestamp())
102
+
103
+ async def fake_get_attribute(attr_id, index=0, count=1):
104
+ if attr_id == C2Attribute.Schedule1:
105
+ return _night_schedule()
106
+ if attr_id == C2Attribute.LunarPhasesEnabled:
107
+ return [bytes([1])]
108
+ if attr_id == C2Attribute.LocalTime:
109
+ return [device_local_time.to_bytes(4, "little")]
110
+ raise AssertionError(f"unexpected attribute: {attr_id}")
111
+
112
+ device.get_attribute = fake_get_attribute
113
+ # A DIFFERENT date than what the device itself reports above --
114
+ # if this were used instead, the lunar reduction would come out
115
+ # different (a real, non-zero value on this specific date, not the
116
+ # 0.0 a new moon gives).
117
+ now = datetime.datetime(2026, 3, 3, 20, 0)
118
+ result = await device.get_current_light_intensities(minute_of_day=1350, now=now)
119
+
120
+ assert result.diagnostics["lunar_date_source"] == "device"
121
+ assert result.diagnostics["lunar_date"] == device_date
122
+ assert result[VisualID.MoonlightWhite] == pytest.approx(0.0) # new moon on the DEVICE's own date
75
123
 
76
124
 
77
125
  @pytest.mark.asyncio
@@ -0,0 +1,59 @@
1
+ from mobius import Model, MODEL_MANUFACTURER, manufacturer_for_model
2
+
3
+
4
+ def test_vortech_is_ecotech_marine():
5
+ assert manufacturer_for_model(Model.VorTechMP40wG3QD) == "EcoTech Marine"
6
+
7
+
8
+ def test_radion_is_ecotech_marine():
9
+ assert manufacturer_for_model(Model.RadionXR15wG6Pro) == "EcoTech Marine"
10
+
11
+
12
+ def test_nero_is_aquaillumination():
13
+ assert manufacturer_for_model(Model.Nero5) == "AquaIllumination"
14
+
15
+
16
+ def test_ai_prime_and_axis_are_aquaillumination():
17
+ """Real, confirmed via actual hardware -- a real user's own AI Prime
18
+ 16HD and AI Axis 40 (both AquaIllumination-brand devices) previously
19
+ showed as "unknown(333)"/"unknown(262)" entirely, since neither model
20
+ value existed in this library's own Model enum yet."""
21
+ assert manufacturer_for_model(Model.PrimeSixteenHD) == "AquaIllumination"
22
+ assert manufacturer_for_model(Model.Axis40) == "AquaIllumination"
23
+
24
+
25
+ def test_coffee_is_nyos():
26
+ assert manufacturer_for_model(Model.Quantum160) == "NYOS"
27
+ assert manufacturer_for_model(Model.Quantum220300) == "NYOS"
28
+
29
+
30
+ def test_muffler_is_neptune_systems():
31
+ assert manufacturer_for_model(Model.DoseQD) == "Neptune Systems"
32
+
33
+
34
+ def test_dosing_pump_versa_is_ecotech_marine():
35
+ """A real, confirmed correction: an earlier version of this table
36
+ had Model.VersaVX1 ("Versa VX-1") as AquaIllumination, based on a
37
+ name-pattern guess rather than the app's own decompiled
38
+ getManufacturer()/isEtm() logic directly -- "Versa" dosing pumps
39
+ are a real, well-known EcoTech Marine product line, matching what
40
+ that logic actually says (ordinal 47, one of its own explicit,
41
+ hardcoded EcoTech Marine special cases)."""
42
+ assert manufacturer_for_model(Model.VersaVX1) == "EcoTech Marine"
43
+
44
+
45
+ def test_every_model_has_a_manufacturer_entry():
46
+ """MODEL_MANUFACTURER is meant to be complete, not a curated subset
47
+ -- confirmed directly against the app's own decompiled Model enum,
48
+ covering all ~98 real entries (not the food-themed internal
49
+ placeholder names some of them have -- those aren't fake, just
50
+ obfuscated; see this module's own Model docstring). This is the
51
+ backstop: if a future addition to Model itself is ever forgotten
52
+ here, this catches it immediately rather than silently returning
53
+ None the next time some real device happens to report that model."""
54
+ missing = [m for m in Model if m not in MODEL_MANUFACTURER]
55
+ assert missing == []
56
+
57
+
58
+ def test_none_model_returns_none():
59
+ assert manufacturer_for_model(None) is None
@@ -133,6 +133,92 @@ class TestRelayedGetAttribute:
133
133
  with pytest.raises(IOError, match="timed out"):
134
134
  await relayed.get_attribute(C2Attribute.Epoch)
135
135
 
136
+ @pytest.mark.asyncio
137
+ async def test_retries_once_on_timeout_then_succeeds(self, connected_gateway):
138
+ """A real, confirmed production issue this addresses directly: a
139
+ relayed round-trip crosses BLE AND the Thread mesh, and a single
140
+ dropped/delayed packet somewhere in that longer path turned out
141
+ to be common enough that two lights lost data within seconds of
142
+ each other from single, transient relay timeouts -- confirmed
143
+ via reverse engineering that the real app never treats one timed-
144
+ out relay attempt as an actual failure, always retrying once
145
+ first. Simulates exactly that: the first attempt's own token
146
+ never gets a response, the retry's own (different) token does."""
147
+ relayed = RelayedMobiusDevice(connected_gateway, PEER, timeout=0.2)
148
+ inner_frame = _build_inner_get_response(C2Attribute.Epoch, index=0, value=struct.pack("<I", 1700000000))
149
+
150
+ call_count = 0
151
+
152
+ async def fake_send_frame(frame, message_id, wait_response=True, timeout=5.0):
153
+ nonlocal call_count
154
+ call_count += 1
155
+ assert wait_response is False
156
+ if call_count == 1:
157
+ return None # first attempt: nothing ever arrives for its token
158
+ # second attempt (the retry): deliver the real response for
159
+ # ITS OWN token -- confirms each attempt truly gets a fresh
160
+ # one, not a reused/stale one from the first, abandoned try.
161
+ token = _extract_sent_token(frame)
162
+ indication_bytes = _build_indication_frame_bytes(inner_frame, token)
163
+ connected_gateway._on_rx_final(None, bytearray(indication_bytes))
164
+ return None
165
+
166
+ connected_gateway._send_frame = fake_send_frame
167
+
168
+ result = await relayed.get_attribute(C2Attribute.Epoch)
169
+
170
+ assert result == [struct.pack("<I", 1700000000)]
171
+ assert call_count == 2 # confirms a retry genuinely happened, not just luck
172
+
173
+ @pytest.mark.asyncio
174
+ async def test_raises_only_after_both_attempts_time_out(self, connected_gateway):
175
+ """The other half of the retry behavior: it's a single retry,
176
+ not unlimited -- if the retry ALSO times out, this must still
177
+ raise (not hang or retry forever), and the error message should
178
+ reflect that a retry was already attempted."""
179
+ relayed = RelayedMobiusDevice(connected_gateway, PEER, timeout=0.2)
180
+
181
+ call_count = 0
182
+
183
+ async def fake_send_frame(frame, message_id, wait_response=True, timeout=5.0):
184
+ nonlocal call_count
185
+ call_count += 1
186
+ return None # never arrives, on either attempt
187
+
188
+ connected_gateway._send_frame = fake_send_frame
189
+
190
+ with pytest.raises(IOError, match="even after one retry"):
191
+ await relayed.get_attribute(C2Attribute.Epoch)
192
+
193
+ assert call_count == 2 # confirms exactly one retry happened, not zero or unlimited
194
+
195
+ @pytest.mark.asyncio
196
+ async def test_does_not_retry_a_malformed_response(self, connected_gateway):
197
+ """Only a TIMEOUT gets retried -- a response that actually
198
+ arrived, just malformed, is a different failure mode a retry
199
+ wouldn't help with (the same bad data would just arrive again),
200
+ so this should raise immediately, not after a second attempt."""
201
+ relayed = RelayedMobiusDevice(connected_gateway, PEER, timeout=0.2)
202
+
203
+ call_count = 0
204
+
205
+ async def fake_send_frame(frame, message_id, wait_response=True, timeout=5.0):
206
+ nonlocal call_count
207
+ call_count += 1
208
+ token = _extract_sent_token(frame)
209
+ # A real indication frame, but with garbage as the inner
210
+ # FSCI payload -- parse_frame() will reject this (bad CRC).
211
+ indication_bytes = _build_indication_frame_bytes(b"\x00\x00\x00\x00", token)
212
+ connected_gateway._on_rx_final(None, bytearray(indication_bytes))
213
+ return None
214
+
215
+ connected_gateway._send_frame = fake_send_frame
216
+
217
+ with pytest.raises(IOError, match="malformed"):
218
+ await relayed.get_attribute(C2Attribute.Epoch)
219
+
220
+ assert call_count == 1 # confirms no retry was attempted for this failure mode
221
+
136
222
  @pytest.mark.asyncio
137
223
  async def test_ignores_the_meaningless_opcode_25_confirm(self, connected_gateway):
138
224
  """Confirms a bare opcode-25 confirm (matching the real, confirmed
@@ -1,26 +0,0 @@
1
- from mobius import Model, manufacturer_for_model
2
-
3
-
4
- def test_vortech_is_ecotech_marine():
5
- assert manufacturer_for_model(Model.VorTechMP40wG3QD) == "EcoTech Marine"
6
-
7
-
8
- def test_radion_is_ecotech_marine():
9
- assert manufacturer_for_model(Model.RadionXR15wG6Pro) == "EcoTech Marine"
10
-
11
-
12
- def test_nero_is_aquaillumination():
13
- assert manufacturer_for_model(Model.Nero5) == "AquaIllumination"
14
-
15
-
16
- def test_coffee_is_nyos():
17
- assert manufacturer_for_model(Model.Coffee1) == "NYOS"
18
- assert manufacturer_for_model(Model.Coffee2) == "NYOS"
19
-
20
-
21
- def test_muffler_is_neptune_systems():
22
- assert manufacturer_for_model(Model.Muffler) == "Neptune Systems"
23
-
24
-
25
- def test_none_model_returns_none():
26
- assert manufacturer_for_model(None) is None
File without changes
File without changes
File without changes