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 +73 -0
- mobius/cli.py +68 -0
- mobius/constants.py +393 -0
- mobius/crc.py +49 -0
- mobius/device.py +429 -0
- mobius/discovery.py +64 -0
- mobius/frame.py +170 -0
- mobius/manufacturer.py +53 -0
- mobius/schedule.py +212 -0
- python_mobius-0.1.0.dist-info/METADATA +149 -0
- python_mobius-0.1.0.dist-info/RECORD +14 -0
- python_mobius-0.1.0.dist-info/WHEEL +4 -0
- python_mobius-0.1.0.dist-info/entry_points.txt +2 -0
- python_mobius-0.1.0.dist-info/licenses/LICENSE +338 -0
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
|