python-mobius 0.4.3__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.
- {python_mobius-0.4.3 → python_mobius-0.4.4}/CHANGELOG.md +14 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/PKG-INFO +1 -1
- {python_mobius-0.4.3 → python_mobius-0.4.4}/pyproject.toml +1 -1
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/__init__.py +1 -1
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/constants.py +203 -70
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/device.py +91 -19
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/device_status.py +9 -6
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_device_time.py +59 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_light_intensity_branching.py +48 -0
- python_mobius-0.4.4/tests/test_manufacturer_lookup.py +59 -0
- python_mobius-0.4.3/tests/test_manufacturer_lookup.py +0 -26
- {python_mobius-0.4.3 → python_mobius-0.4.4}/.forgejo/scripts/extract_changelog_section.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/.forgejo/workflows/release.yml +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/.gitignore +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/LICENSE +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/README.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/00-overview.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/05-scenes.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/06-light-schedule.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/09-thread-coap-relay.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/10-known-gaps-and-open-questions.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/11-device-status-attributes.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/12-device-identity-and-address-stability.md +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/cli.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/coap.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/crc.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/discovery.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/frame.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/mesh_address.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/modifiers.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/power.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/pump_status.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/relay.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/schedule.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_coap.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_coap_indication_handling.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_connection_cleanup.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_device_status.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_device_summary.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_firmware_versions.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_frame.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_frame_sniffer.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_hyperdrive_device.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_insolation_detection.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_light_schedule.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_manufacturer.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_mesh_address.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_mesh_discovery.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_mesh_discovery_direct_connect.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_modifiers.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_networked_thread_devices.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_power.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_pump_schedule.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_pump_status.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_relay.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_schedule_intensity_scalar.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_serial_decoding.py +0 -0
- {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_serial_identity.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 0.4.3
|
|
4
18
|
|
|
5
19
|
- Fixed relayed devices (anything reached through a gateway over the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-mobius
|
|
3
|
-
Version: 0.4.
|
|
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.
|
|
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" }
|
|
@@ -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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
100
|
-
RadionXR15wG6Pro = 179
|
|
101
|
-
RadionXR15wG6Blue = 180
|
|
102
|
-
RadionXR30wG5Pro = 192
|
|
103
|
-
RadionXR30wG5Blue = 193
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
#
|
|
114
|
-
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
151
|
-
Model.
|
|
152
|
-
Model.
|
|
153
|
-
Model.
|
|
154
|
-
Model.
|
|
155
|
-
Model.
|
|
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.
|
|
159
|
-
Model.
|
|
160
|
-
Model.
|
|
161
|
-
Model.
|
|
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
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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 (
|
|
1468
|
-
to local system time
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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"),
|
|
1495
|
-
applied)
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
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:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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]
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/10-known-gaps-and-open-questions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|