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.
Files changed (65) hide show
  1. {python_mobius-0.4.3 → python_mobius-0.4.4}/CHANGELOG.md +14 -0
  2. {python_mobius-0.4.3 → python_mobius-0.4.4}/PKG-INFO +1 -1
  3. {python_mobius-0.4.3 → python_mobius-0.4.4}/pyproject.toml +1 -1
  4. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/__init__.py +1 -1
  5. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/constants.py +203 -70
  6. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/device.py +91 -19
  7. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/device_status.py +9 -6
  8. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_device_time.py +59 -0
  9. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_light_intensity_branching.py +48 -0
  10. python_mobius-0.4.4/tests/test_manufacturer_lookup.py +59 -0
  11. python_mobius-0.4.3/tests/test_manufacturer_lookup.py +0 -26
  12. {python_mobius-0.4.3 → python_mobius-0.4.4}/.forgejo/scripts/extract_changelog_section.py +0 -0
  13. {python_mobius-0.4.3 → python_mobius-0.4.4}/.forgejo/workflows/release.yml +0 -0
  14. {python_mobius-0.4.3 → python_mobius-0.4.4}/.gitignore +0 -0
  15. {python_mobius-0.4.3 → python_mobius-0.4.4}/LICENSE +0 -0
  16. {python_mobius-0.4.3 → python_mobius-0.4.4}/README.md +0 -0
  17. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/00-overview.md +0 -0
  18. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/01-ble-transport.md +0 -0
  19. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/02-framing-and-crc.md +0 -0
  20. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/03-attributes-and-opcodes.md +0 -0
  21. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/04-device-identity.md +0 -0
  22. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/05-scenes.md +0 -0
  23. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/06-light-schedule.md +0 -0
  24. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/07-pump-schedule.md +0 -0
  25. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/08-manufacturer-data.md +0 -0
  26. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/09-thread-coap-relay.md +0 -0
  27. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/10-known-gaps-and-open-questions.md +0 -0
  28. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/11-device-status-attributes.md +0 -0
  29. {python_mobius-0.4.3 → python_mobius-0.4.4}/documentation/12-device-identity-and-address-stability.md +0 -0
  30. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/cli.py +0 -0
  31. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/coap.py +0 -0
  32. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/crc.py +0 -0
  33. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/discovery.py +0 -0
  34. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/frame.py +0 -0
  35. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/manufacturer.py +0 -0
  36. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/mesh_address.py +0 -0
  37. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/modifiers.py +0 -0
  38. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/power.py +0 -0
  39. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/pump_status.py +0 -0
  40. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/relay.py +0 -0
  41. {python_mobius-0.4.3 → python_mobius-0.4.4}/src/mobius/schedule.py +0 -0
  42. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_coap.py +0 -0
  43. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_coap_indication_handling.py +0 -0
  44. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_connection_cleanup.py +0 -0
  45. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_device_status.py +0 -0
  46. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_device_summary.py +0 -0
  47. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_firmware_versions.py +0 -0
  48. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_frame.py +0 -0
  49. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_frame_sniffer.py +0 -0
  50. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_hyperdrive_device.py +0 -0
  51. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_insolation_detection.py +0 -0
  52. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_light_schedule.py +0 -0
  53. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_manufacturer.py +0 -0
  54. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_mesh_address.py +0 -0
  55. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_mesh_discovery.py +0 -0
  56. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_mesh_discovery_direct_connect.py +0 -0
  57. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_modifiers.py +0 -0
  58. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_networked_thread_devices.py +0 -0
  59. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_power.py +0 -0
  60. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_pump_schedule.py +0 -0
  61. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_pump_status.py +0 -0
  62. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_relay.py +0 -0
  63. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_schedule_intensity_scalar.py +0 -0
  64. {python_mobius-0.4.3 → python_mobius-0.4.4}/tests/test_serial_decoding.py +0 -0
  65. {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
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.3"
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.3"
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]
@@ -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