python-mobius 0.1.0__py3-none-any.whl

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.
mobius/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ """
2
+ python-mobius: a reverse-engineered Python client for the BLE protocol used
3
+ by EcoTech Marine (VorTech, Radion), AquaIllumination (Prime, Hydra),
4
+ Neptune Systems, and NYOS "Mobius"-branded aquarium devices.
5
+
6
+ Not affiliated with or endorsed by any of those companies. This is an
7
+ independent reimplementation of the wire protocol for interoperability,
8
+ derived from public community reverse-engineering work and analysis of the
9
+ publicly-distributed Mobius Android app. See documentation/ for the full
10
+ protocol writeup and confirmation evidence for every field.
11
+ """
12
+
13
+ from .crc import crc16, CRC16_TABLE
14
+ from .frame import (
15
+ build_frame, parse_frame, ParsedFrame,
16
+ encode_get_attribute, encode_set_attribute, decode_attribute_response, AttributeValue,
17
+ OPGROUP_C2CI_REQUEST, OPGROUP_C2CI_CONFIRM,
18
+ OPGROUP_FSCI_REQUEST, OPGROUP_FSCI_CONFIRM,
19
+ OPGROUP_QOTAP_REQUEST, OPGROUP_QOTAP_CONFIRM,
20
+ OPCODE_GET_ATTR, OPCODE_GET_ATTR_EXT, OPCODE_SET_ATTR, OPCODE_SET_ATTR_EXT,
21
+ OPCODE_GET_ATTR_ELEMENTS, OPCODE_GET_ATTR_ELEMENTS_EXT, OPCODE_GET_OPCODES,
22
+ )
23
+ from .constants import (
24
+ PrimitiveType, PRIMITIVE_SIZE, LIGHT_PRIMITIVES,
25
+ PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL,
26
+ Model, ErrorState, C2Attribute, PhysicalValueID, VisualID,
27
+ SceneID, OperationState, FsciStatus,
28
+ PumpMode, RampType, PumpParam, PUMP_PARAM_SIZE, PUMP_MODE_PARAMS,
29
+ )
30
+ from .schedule import (
31
+ LightPrimitive, SchedulePoint, interpolate_light_schedule,
32
+ PumpPrimitiveValue, PumpSchedulePoint, get_active_pump_block,
33
+ )
34
+ from .manufacturer import MOBIUS_COMPANY_ID, MobiusAdvertisement, parse_manufacturer_data
35
+ from .device import (
36
+ MobiusDevice, MobiusPump,
37
+ SERVICE_GENERAL, CHAR_RX_DATA, CHAR_RX_FINAL, CHAR_TX_DATA, CHAR_TX_FINAL,
38
+ )
39
+ from .discovery import (
40
+ scan_for_mobius_devices, scan_for_mobius_devices_with_info, group_by_pan_id,
41
+ )
42
+
43
+ __version__ = "0.1.0"
44
+
45
+ __all__ = [
46
+ "__version__",
47
+ # crc
48
+ "crc16", "CRC16_TABLE",
49
+ # frame
50
+ "build_frame", "parse_frame", "ParsedFrame",
51
+ "encode_get_attribute", "encode_set_attribute", "decode_attribute_response", "AttributeValue",
52
+ "OPGROUP_C2CI_REQUEST", "OPGROUP_C2CI_CONFIRM",
53
+ "OPGROUP_FSCI_REQUEST", "OPGROUP_FSCI_CONFIRM",
54
+ "OPGROUP_QOTAP_REQUEST", "OPGROUP_QOTAP_CONFIRM",
55
+ "OPCODE_GET_ATTR", "OPCODE_GET_ATTR_EXT", "OPCODE_SET_ATTR", "OPCODE_SET_ATTR_EXT",
56
+ "OPCODE_GET_ATTR_ELEMENTS", "OPCODE_GET_ATTR_ELEMENTS_EXT", "OPCODE_GET_OPCODES",
57
+ # constants
58
+ "PrimitiveType", "PRIMITIVE_SIZE", "LIGHT_PRIMITIVES",
59
+ "PUMP_PRIMITIVES_VERIFIED", "PUMP_PRIMITIVES_EXPERIMENTAL",
60
+ "Model", "ErrorState", "C2Attribute", "PhysicalValueID", "VisualID",
61
+ "SceneID", "OperationState", "FsciStatus",
62
+ "PumpMode", "RampType", "PumpParam", "PUMP_PARAM_SIZE", "PUMP_MODE_PARAMS",
63
+ # schedule
64
+ "LightPrimitive", "SchedulePoint", "interpolate_light_schedule",
65
+ "PumpPrimitiveValue", "PumpSchedulePoint", "get_active_pump_block",
66
+ # manufacturer
67
+ "MOBIUS_COMPANY_ID", "MobiusAdvertisement", "parse_manufacturer_data",
68
+ # device
69
+ "MobiusDevice", "MobiusPump",
70
+ "SERVICE_GENERAL", "CHAR_RX_DATA", "CHAR_RX_FINAL", "CHAR_TX_DATA", "CHAR_TX_FINAL",
71
+ # discovery
72
+ "scan_for_mobius_devices", "scan_for_mobius_devices_with_info", "group_by_pan_id",
73
+ ]
mobius/cli.py ADDED
@@ -0,0 +1,68 @@
1
+ """
2
+ Command-line entrypoint: `mobius-scan`.
3
+
4
+ Scans for Mobius devices, groups them by pan_id (no connection required),
5
+ then connects to each and prints a full device summary.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import asyncio
12
+
13
+ from .device import MobiusDevice
14
+ from .discovery import scan_for_mobius_devices_with_info, group_by_pan_id
15
+
16
+
17
+ async def _run(args) -> None:
18
+ found = await scan_for_mobius_devices_with_info(timeout=args.timeout, adapter=args.adapter)
19
+ if not found:
20
+ print("No Mobius devices found. Make sure BLE is on and devices are nearby.")
21
+ return
22
+
23
+ print("=== Pan ID groups (from advertisements, no connection needed) ===")
24
+ for pan_id, members in group_by_pan_id(found).items():
25
+ label = f"{pan_id:#06x}" if pan_id is not None else "unknown"
26
+ print(f" pan_id {label}:")
27
+ for device, info in members:
28
+ if info:
29
+ print(f" {device.address} model={info.model.name if info.model else info.model_raw}"
30
+ f" serial={info.serial}")
31
+ else:
32
+ print(f" {device.address} (no parsed manufacturer data)")
33
+
34
+ if args.scan_only:
35
+ return
36
+
37
+ print("\n=== Full device summaries (connects to each) ===")
38
+ for device, _info in found:
39
+ print(f"--- {device.address} ({device.name}) ---")
40
+ try:
41
+ async with MobiusDevice(device, connect_timeout=args.connect_timeout) as mdevice:
42
+ summary = await mdevice.get_device_summary()
43
+ for k, v in summary.items():
44
+ print(f" {k}: {v}")
45
+ except Exception as e:
46
+ print(" failed to connect/read:", e)
47
+
48
+
49
+ def main() -> None:
50
+ parser = argparse.ArgumentParser(
51
+ prog="mobius-scan",
52
+ description="Scan for and identify Mobius-protocol BLE devices (VorTech, Radion, etc).",
53
+ )
54
+ parser.add_argument("--adapter", default=None,
55
+ help='BlueZ adapter to use, e.g. "hci0" or "hci1" (Linux only). '
56
+ 'Defaults to whatever bleak picks automatically.')
57
+ parser.add_argument("--timeout", type=float, default=15.0,
58
+ help="Scan duration in seconds (default: 15.0)")
59
+ parser.add_argument("--connect-timeout", type=float, default=30.0,
60
+ help="Per-device connect timeout in seconds (default: 30.0)")
61
+ parser.add_argument("--scan-only", action="store_true",
62
+ help="Only scan/group by pan_id; don't connect to any device.")
63
+ args = parser.parse_args()
64
+ asyncio.run(_run(args))
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
mobius/constants.py ADDED
@@ -0,0 +1,393 @@
1
+ """
2
+ Protocol enums and lookup tables shared across the library.
3
+
4
+ Every value here is either:
5
+ - a directly-read literal from the decompiled Mobius Android app, or
6
+ - explicitly noted as inferred (with the evidence for the inference).
7
+
8
+ See documentation/ for the full derivation of each table.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from enum import IntEnum
14
+
15
+
16
+ # --------------------------------------------------------------------------
17
+ # Device / primitive identity
18
+ # --------------------------------------------------------------------------
19
+
20
+ class PrimitiveType(IntEnum):
21
+ """What kind of device this is, at the protocol level. Confirmed literal
22
+ values from M.PrimitiveType."""
23
+ Undefined = 0
24
+ VisualV1 = 1 # Radion / light-class devices (e.g. XR15, XR30, Prime, Hydra)
25
+ PumpV1 = 2 # legacy Nero pumps
26
+ DoseV1 = 3 # dosing pumps (Versa VX-1, "Muffler")
27
+ VorTechV1 = 4 # VorTech pumps
28
+ VectraV1 = 5 # Vectra pumps
29
+ HotSauceV1 = 6 # environmental sensor devices
30
+ AlpacaV1 = 7 # newer-gen Nero-successor pumps ("Orbit")
31
+ TurtleV1 = 8 # newer-gen Nero-successor pumps ("Axis")
32
+ CoffeeV1 = 9 # NYOS Quantum skimmers/dosers
33
+
34
+
35
+ # Confirmed from M.PrimitiveType.size() -- the fixed byte-length (or, for
36
+ # VisualV1, the per-channel-triplet unit) of that primitive type's schedule
37
+ # point payload.
38
+ PRIMITIVE_SIZE: dict[PrimitiveType, int] = {
39
+ PrimitiveType.Undefined: 0,
40
+ PrimitiveType.VisualV1: 3, # per-channel unit (repeating), not a fixed total
41
+ PrimitiveType.PumpV1: 13,
42
+ PrimitiveType.DoseV1: 9, # DosePrimitive -- not implemented in this library
43
+ PrimitiveType.VorTechV1: 13,
44
+ PrimitiveType.VectraV1: 13,
45
+ PrimitiveType.HotSauceV1: 14, # not implemented in this library
46
+ PrimitiveType.AlpacaV1: 13,
47
+ PrimitiveType.TurtleV1: 13,
48
+ PrimitiveType.CoffeeV1: 13, # NYOS Quantum etc. -- same size as pump primitives,
49
+ # PumpPrimitive parser applies but is UNVERIFIED
50
+ # against real Coffee/Quantum hardware
51
+ }
52
+
53
+ # Support tiers used by MobiusDevice.get_device_summary().
54
+ LIGHT_PRIMITIVES = {PrimitiveType.VisualV1}
55
+ PUMP_PRIMITIVES_VERIFIED = {
56
+ PrimitiveType.VorTechV1, PrimitiveType.PumpV1, PrimitiveType.VectraV1,
57
+ PrimitiveType.AlpacaV1, PrimitiveType.TurtleV1,
58
+ }
59
+ PUMP_PRIMITIVES_EXPERIMENTAL = {PrimitiveType.CoffeeV1}
60
+
61
+
62
+ class Model(IntEnum):
63
+ """Device model identifiers. Confirmed literal values from M.Model
64
+ (a curated subset -- the full enum covers ~100 models across EcoTech
65
+ Marine, AquaIllumination, Neptune Systems, NYOS, and Hera product lines;
66
+ add entries here as needed)."""
67
+ Unknown = 0
68
+ VorTechMP10wES = 10
69
+ VorTechMP10wQD = 11
70
+ RadionXR30w = 30
71
+ RadionXR30wG2 = 31
72
+ RadionXR30wPro = 32
73
+ RadionXR30wG3 = 33
74
+ RadionXR30wG3Pro = 34
75
+ RadionXR30wG4 = 35
76
+ RadionXR30wG4Pro = 36
77
+ RadionXR30wFWPro = 37
78
+ RadionXR30wPrototype = 39
79
+ VorTechMP40wES = 40
80
+ VorTechMP40wQD = 41
81
+ VorTechMP40wG3QD = 42
82
+ VorTechMP60wES = 60
83
+ VorTechMP60wQD = 61
84
+ VectraM1 = 144
85
+ VectraL1 = 145
86
+ VectraS1 = 146
87
+ VectraS2 = 147
88
+ VectraM2 = 148
89
+ VectraL2 = 149
90
+ RadionXR15wG3Pro = 160
91
+ RadionXR15wFW = 161
92
+ RadionXR15wG4Pro = 162
93
+ RadionXR15wFWPro = 163
94
+ RadionXR15wPrototype = 175
95
+ RadionXR15G5Pro = 176
96
+ RadionXR15wG5Blue = 177
97
+ RadionXR15wG5FW = 178 # "Radion XR15w G5 FW" (base64-decoded from getName())
98
+ RadionXR15wG6Pro = 179 # "Radion XR15w G6 Pro" (base64-decoded from getName())
99
+ RadionXR15wG6Blue = 180 # "Radion XR15w G6 Blue" (inferred sequential id, base64-decoded name)
100
+ RadionXR30wG5Pro = 192
101
+ RadionXR30wG5Blue = 193
102
+ Nero5 = 256
103
+ Nero3 = 257
104
+ DosingPump = 272
105
+ Muffler = 275 # "Dose QD" -- another doser model
106
+ Coffee1 = 396 # "Quantum 160" (NYOS) -- CoffeeV1 primitive
107
+ Coffee2 = 397 # "Quantum 220/300" (NYOS)
108
+
109
+
110
+ class ErrorState(IntEnum):
111
+ """Confirmed literal values from M.ErrorState (subset covering common
112
+ pump/light fault codes -- the full enum also has dosing-pump-specific codes)."""
113
+ NoError = 0
114
+ Disconnect = 1
115
+ Temperature = 2
116
+ Stall = 3
117
+ Thermistor = 4
118
+ UnderVoltage = 5
119
+ PowerSupply = 6
120
+ SchedulePlayback = 7
121
+ OverVoltage = 8
122
+ OverCurrent = 9
123
+ LockDetectionCurrentLimit = 10
124
+ AbnormalSpeed = 11
125
+ AbnormalKt = 12
126
+ StuckInOpenLoop = 13
127
+ StuckInClosedLoop = 14
128
+ FanFault = 15
129
+ DriverTemp = 16
130
+ LEDChannelShortCircuit = 17
131
+ LEDChannelCurrentLeak = 18
132
+ LEDChannelOpenCircuit = 19
133
+ LEDClusterThermistor = 20
134
+ LEDClusterOverTemp = 21
135
+ DeviceBricked = 22
136
+ RTC = 23
137
+ MotorPowerDip = 64
138
+ MotorShort = 65
139
+ Wireless = 67
140
+ AppUpgrade = 68
141
+ ClogError = 69
142
+ DryRun = 79
143
+ PTCTrip = 128
144
+
145
+
146
+ # --------------------------------------------------------------------------
147
+ # Attribute IDs (Get/Set)
148
+ # --------------------------------------------------------------------------
149
+
150
+ class C2Attribute(IntEnum):
151
+ """Attribute IDs used with Get/Set requests. Every value here is a
152
+ directly-confirmed literal (either a plain literal in the decompile, or
153
+ resolved through a JADX symbolic-constant substitution back to the real
154
+ integer -- see documentation/03-attributes-and-opcodes.md), EXCEPT
155
+ Schedule1 which required tracing through Firebase's
156
+ ServiceStarter.ERROR_UNKNOWN=500 to resolve (also fully confirmed, just
157
+ via an extra hop)."""
158
+ AttributeTableVersion = 0
159
+ FirmwareVersion = 1
160
+ HardwareRevision = 2
161
+ SerialNumber = 3
162
+ Model = 4
163
+ Name = 5
164
+ PrimitiveType = 8
165
+ PhysicalValues = 101
166
+ MACAddress = 103
167
+ OperationState = 104
168
+ OperationMode = 105
169
+ GroupBitmap = 108
170
+ ErrorState = 107
171
+ ConfiguredScenes = 400
172
+ CurrentScene = 401
173
+ SceneTimeout = 402
174
+ SceneTimer = 403
175
+ Schedule1 = 500
176
+ Schedule1Checksum = 501
177
+ Schedule1StartTime = 502
178
+ Schedule2 = 503
179
+ Schedule2Checksum = 504
180
+ Schedule2StartTime = 505
181
+ Schedule2ActiveDays = 506
182
+ CurrentSchedule = 507
183
+ CurrentScheduleElement = 508
184
+ ActiveConfiguration = 509
185
+ SchedulePlayback = 510
186
+ Schedule1Intensity = 511
187
+ Schedule2Intensity = 512
188
+ MotorSpeed = 700
189
+ BatteryBackupMaxSpeed = 701
190
+ FeedModeMaxSpeed = 702
191
+ PumpOverrideMode = 705
192
+ BatteryBackupSpeed = 706
193
+ MinimumGallonsPerHour = 707
194
+ MaximumGallonsPerHour = 708
195
+ BoostedBatteryPower = 803
196
+ BoostedBatteryPowerOnTime = 804
197
+ BoostedBatteryPowerOffTime = 805
198
+ SupportedColorChannels = 901
199
+ MaxPower = 1504
200
+ Ramp = 1505
201
+ NormalPower = 1513
202
+
203
+
204
+ class PhysicalValueID(IntEnum):
205
+ """Sub-index used with C2Attribute.PhysicalValues. Confirmed literal
206
+ values from M.PhysicalValues."""
207
+ Unknown = 0
208
+ DriverTemperature = 1
209
+ MotorTemperature = 2
210
+ ClusterTemperature = 3
211
+ MotorPower = 4
212
+ MotorRPM = 5 # defined in the protocol; NEVER queried anywhere in
213
+ # the app -- unconfirmed whether firmware populates it
214
+ BatteryVoltage = 6
215
+ InputVoltage = 7
216
+ InternalTemperature = 8
217
+ FanSpeed = 9
218
+ MotorTemperatureFromStaterResistor = 10
219
+ GallonsPerHour = 11 # confirmed live-queried by the app for pump flow display
220
+ SupplyCurrent = 12
221
+ FanVoltage = 13
222
+ ModuleTemperature = 14
223
+ MotorSpinning = 15
224
+ Cluster2Temperature = 16
225
+
226
+
227
+ class VisualID(IntEnum):
228
+ """Light channel identifiers. Confirmed literal values from M.VisualID."""
229
+ Unknown = 0
230
+ Brightness = 1
231
+ CoolWhite = 16
232
+ Blue = 17
233
+ RoyalBlue = 18
234
+ Green = 19
235
+ Red = 20
236
+ UV = 21
237
+ WarmWhite = 22
238
+ Violet = 23
239
+ DeepBlue = 24
240
+ DeepRed = 25
241
+ NeutralWhite = 26
242
+ Yellow = 27
243
+ Amber = 28
244
+ FarRed = 29
245
+ Moonlight = 30
246
+ MoonlightWhite = 31
247
+ MoonlightBlue = 32
248
+ Cyan = 33
249
+ Lime = 34
250
+ BlueAndWhite = 35
251
+ RedAndWhite = 36
252
+ UV_PLUS = 37
253
+ White = 38
254
+ Status1 = 50
255
+ Status1Red = 51
256
+ Status1Green = 52
257
+ Status1Blue = 53
258
+ Status2 = 54
259
+ Status2Red = 55
260
+ Status2Green = 56
261
+ Status2Blue = 57
262
+ Status3 = 58
263
+ Status3Red = 59
264
+ Status3Green = 60
265
+ Status3Blue = 61
266
+ Status4 = 62
267
+ Status4Red = 63
268
+ Status4Green = 64
269
+ Status4Blue = 65
270
+ StormProbability = 100
271
+ CloudProbability = 101
272
+
273
+
274
+ class SceneID(IntEnum):
275
+ """Confirmed literal values from M.SceneID."""
276
+ EmptyScene = 0
277
+ FeedMode = 1
278
+ BatteryBackup = 2
279
+ AllOff = 3
280
+ ColorCycle = 4
281
+ Disco = 5
282
+ Thunderstorm = 6
283
+ CloudCover = 7
284
+ AllOn = 8
285
+ All50 = 9
286
+
287
+
288
+ class OperationState(IntEnum):
289
+ """Confirmed literal values from M.OperationState."""
290
+ OOB = 0
291
+ LiveDemo = 1
292
+ Scene = 2
293
+ Schedule = 3
294
+
295
+
296
+ class FsciStatus(IntEnum):
297
+ """Confirmed status/error codes returned in the first byte of a
298
+ Get/Set-attribute confirm response."""
299
+ Success = 0
300
+ Failed = 1
301
+ InvalidInstance = 2
302
+ InvalidElement = 3
303
+ NotPermitted = 4
304
+ InvalidMode = 5
305
+ NoMem = 6
306
+ UnsupportedAttribute = 7
307
+ EmptyEntry = 8
308
+ InvalidValue = 9
309
+ InvalidRange = 20
310
+ InvalidSize = 21
311
+ EntryNotFound = 255
312
+
313
+
314
+ # --------------------------------------------------------------------------
315
+ # Pump schedule / mode constants
316
+ # --------------------------------------------------------------------------
317
+
318
+ class PumpMode(IntEnum):
319
+ """Confirmed literal values from M.PumpMode."""
320
+ Undefined = 0
321
+ ConstantSpeed = 1
322
+ Lagoon = 2
323
+ ReefCrest = 3
324
+ NutrientTransport = 4
325
+ TidalSwell = 5
326
+ ShortPulse = 6
327
+ Gyre = 7
328
+ Transition = 8
329
+ ExpandingPulse = 9
330
+ Sync = 10
331
+ EcoSmartBack = 12 # 11 is genuinely unused in the source enum
332
+ Feed = 13
333
+ BatteryBackup = 14
334
+ Random = 15
335
+ Pulse = 16
336
+
337
+
338
+ class RampType(IntEnum):
339
+ """Confirmed literal values from M.RampType."""
340
+ Sinusoidal = 1
341
+ Logarithmic = 2
342
+ Linear = 3
343
+
344
+
345
+ class PumpParam(IntEnum):
346
+ """Named fields that can appear in a PumpPrimitive, per pump mode.
347
+ Not a wire-format enum itself -- just internal bookkeeping for
348
+ PUMP_MODE_PARAMS / PUMP_PARAM_SIZE below."""
349
+ MaxSpeed = 0
350
+ MinSpeed = 1
351
+ Time = 2
352
+ RampType = 3
353
+ StartTime = 4
354
+ EndTime = 5
355
+ Master = 6
356
+ BigTime = 7
357
+ Variance = 8
358
+ PhaseShift = 9
359
+ OnTime = 10
360
+ OffTime = 11
361
+
362
+
363
+ PUMP_PARAM_SIZE: dict[PumpParam, int] = {
364
+ PumpParam.MaxSpeed: 2, PumpParam.MinSpeed: 2, PumpParam.Time: 4,
365
+ PumpParam.RampType: 1, PumpParam.StartTime: 2, PumpParam.EndTime: 2,
366
+ PumpParam.Master: 8, PumpParam.BigTime: 4, PumpParam.Variance: 2,
367
+ PumpParam.PhaseShift: 2, PumpParam.OnTime: 4, PumpParam.OffTime: 4,
368
+ }
369
+
370
+ # Ported directly from M.PumpMode.parameters() -- which params are present
371
+ # for each mode, and in what order (byte offset is cumulative from data[1:]).
372
+ PUMP_MODE_PARAMS: dict[PumpMode, list[PumpParam]] = {
373
+ PumpMode.Undefined: [],
374
+ PumpMode.ConstantSpeed: [PumpParam.MaxSpeed],
375
+ PumpMode.Lagoon: [PumpParam.MaxSpeed],
376
+ PumpMode.ReefCrest: [PumpParam.MaxSpeed],
377
+ PumpMode.NutrientTransport: [PumpParam.MaxSpeed],
378
+ PumpMode.TidalSwell: [PumpParam.MaxSpeed],
379
+ PumpMode.Feed: [PumpParam.MaxSpeed],
380
+ PumpMode.ShortPulse: [PumpParam.MaxSpeed, PumpParam.Time],
381
+ PumpMode.Gyre: [PumpParam.MaxSpeed, PumpParam.BigTime],
382
+ PumpMode.Transition: [PumpParam.RampType],
383
+ PumpMode.ExpandingPulse: [PumpParam.MaxSpeed, PumpParam.StartTime, PumpParam.EndTime],
384
+ PumpMode.Sync: [PumpParam.MaxSpeed, PumpParam.PhaseShift, PumpParam.Master],
385
+ PumpMode.EcoSmartBack: [PumpParam.MaxSpeed, PumpParam.PhaseShift, PumpParam.Master],
386
+ PumpMode.BatteryBackup: [PumpParam.MaxSpeed],
387
+ PumpMode.Random: [PumpParam.MinSpeed, PumpParam.MaxSpeed, PumpParam.Variance],
388
+ PumpMode.Pulse: [PumpParam.MaxSpeed, PumpParam.OnTime, PumpParam.OffTime],
389
+ }
390
+
391
+ PUMP_TIME_OFFSET_PARAMS = {PumpParam.Time, PumpParam.StartTime, PumpParam.EndTime}
392
+ # Confirmed: androidx.recyclerview.widget.ItemTouchHelper.Callback.DEFAULT_SWIPE_ANIMATION_DURATION
393
+ PUMP_TIME_OFFSET = 250
mobius/crc.py ADDED
@@ -0,0 +1,49 @@
1
+ """
2
+ CRC16 (table-driven, seed 0xFFFF) used on every Mobius wire frame.
3
+
4
+ Ported from the decompiled com.c2.comm.utilities.Crc class. The table below
5
+ is Java's original signed-short constants converted to unsigned 16-bit for
6
+ Python arithmetic. Verified byte-for-byte against real captured packets --
7
+ see documentation/02-framing-and-crc.md.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ _CRC16_TABLE_SIGNED = [
13
+ 0, 4129, 8258, 12387, 16516, 20645, 24774, 28903, -32504, -28375, -24246,
14
+ -20117, -15988, -11859, -7730, -3601, 4657, 528, 12915, 8786, 21173, 17044,
15
+ 29431, 25302, -27847, -31976, -19589, -23718, -11331, -15460, -3073, -7202,
16
+ 9314, 13379, 1056, 5121, 25830, 29895, 17572, 21637, -23190, -19125, -31448,
17
+ -27383, -6674, -2609, -14932, -10867, 13907, 9842, 5649, 1584, 30423, 26358,
18
+ 22165, 18100, -18597, -22662, -26855, -30920, -2081, -6146, -10339, -14404,
19
+ 18628, 22757, 26758, 30887, 2112, 6241, 10242, 14371, -13876, -9747, -5746,
20
+ -1617, -30392, -26263, -22262, -18133, 23285, 19156, 31415, 27286, 6769,
21
+ 2640, 14899, 10770, -9219, -13348, -1089, -5218, -25735, -29864, -17605,
22
+ -21734, 27814, 31879, 19684, 23749, 11298, 15363, 3168, 7233, -4690, -625,
23
+ -12820, -8755, -21206, -17141, -29336, -25271, 32407, 28342, 24277, 20212,
24
+ 15891, 11826, 7761, 3696, -97, -4162, -8227, -12292, -16613, -20678, -24743,
25
+ -28808, -28280, -32343, -20022, -24085, -12020, -16083, -3762, -7825, 4224,
26
+ 161, 12482, 8419, 20484, 16421, 28742, 24679, -31815, -27752, -23557,
27
+ -19494, -15555, -11492, -7297, -3234, 689, 4752, 8947, 13010, 16949, 21012,
28
+ 25207, 29270, -18966, -23093, -27224, -31351, -2706, -6833, -10964, -15091,
29
+ 13538, 9411, 5280, 1153, 29798, 25671, 21540, 17413, -22565, -18438, -30823,
30
+ -26696, -6305, -2178, -14563, -10436, 9939, 14066, 1681, 5808, 26199, 30326,
31
+ 17941, 22068, -9908, -13971, -1778, -5841, -26168, -30231, -18038, -22101,
32
+ 22596, 18533, 30726, 26663, 6336, 2273, 14466, 10403, -13443, -9380, -5313,
33
+ -1250, -29703, -25640, -21573, -17510, 19061, 23124, 27191, 31254, 2801,
34
+ 6864, 10931, 14994, -722, -4849, -8852, -12979, -16982, -21109, -25112,
35
+ -29239, 31782, 27655, 23652, 19525, 15522, 11395, 7392, 3265, -4321, -194,
36
+ -12451, -8324, -20581, -16454, -28711, -24584, 28183, 32310, 20053, 24180,
37
+ 11923, 16050, 3793, 7920,
38
+ ]
39
+
40
+ CRC16_TABLE = [v & 0xFFFF for v in _CRC16_TABLE_SIGNED]
41
+
42
+
43
+ def crc16(data: bytes, seed: int = 0xFFFF) -> int:
44
+ """CRC16 over `data`, matching com.c2.comm.utilities.Crc.crc16()."""
45
+ crc = seed & 0xFFFF
46
+ for b in data:
47
+ idx = (b ^ (crc >> 8)) & 0xFF
48
+ crc = ((crc << 8) ^ CRC16_TABLE[idx]) & 0xFFFF
49
+ return crc