python-mobius 0.8.4__py3-none-any.whl → 0.10.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 CHANGED
@@ -1,16 +1,18 @@
1
1
  """
2
2
  python-mobius: a reverse-engineered Python client for the BLE protocol used
3
3
  by EcoTech Marine (VorTech, Radion), AquaIllumination (Prime, Hydra),
4
- Neptune Systems, and NYOS "Mobius"-branded aquarium devices.
4
+ Neptune Systems and NYOS "Mobius" aquarium devices.
5
5
 
6
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.
7
+ independent implementation of the wire protocol for interoperability. See
8
+ documentation/ for the protocol description.
11
9
  """
12
10
 
13
11
  from .crc import crc16, CRC16_TABLE
12
+ from .checksums import (
13
+ ConfigurationCache, ConfigurationChecksums, configured_scenes_checksum, schedule_checksum,
14
+ supported_attributes_crc,
15
+ )
14
16
  from .frame import (
15
17
  build_frame, parse_frame, ParsedFrame,
16
18
  encode_get_attribute, encode_get_attributes_batch, encode_set_attribute, decode_attribute_response, AttributeValue,
@@ -22,9 +24,10 @@ from .frame import (
22
24
  )
23
25
  from .constants import (
24
26
  PrimitiveType, PRIMITIVE_SIZE, LIGHT_PRIMITIVES,
25
- PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL,
26
- Model, MODEL_MANUFACTURER, manufacturer_for_model,
27
- ErrorState, C2Attribute, PhysicalValueID, VisualID,
27
+ PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL, PUMP_PRIMITIVES, enum_or_none,
28
+ primitive_type_from_name, support_tier, PumpParamRange, pump_param_range, pump_param_errors,
29
+ Model, MODEL_MANUFACTURER, manufacturer_for_model, BLUETOOTH_ONLY_MODELS, is_bluetooth_only,
30
+ ErrorState, C2Attribute, OperationMode, PhysicalValueID, VisualID,
28
31
  SceneID, OperationState, FsciStatus,
29
32
  PumpMode, PumpOverrideMode, RampType, PumpParam, PUMP_PARAM_SIZE, PUMP_MODE_PARAMS,
30
33
  supported_pump_modes,
@@ -37,11 +40,11 @@ from .schedule import (
37
40
  LightPrimitive, SchedulePoint, interpolate_light_schedule,
38
41
  PumpPrimitiveValue, PumpSchedulePoint, get_active_pump_block,
39
42
  light_schedule_to_dict, light_schedule_from_dict,
40
- pump_schedule_to_dict, pump_schedule_from_dict,
43
+ pump_schedule_to_dict, pump_schedule_from_dict, pump_params_to_dict,
41
44
  MAX_PUMP_SCHEDULE_POINT_SIZE,
42
45
  )
43
46
  from .scenes import Scene, ActiveScene, decode_configured_scenes, decode_active_scene
44
- from .manufacturer import MOBIUS_COMPANY_ID_ECOTECH, MOBIUS_COMPANY_ID_AQUAILLUMINATION, MOBIUS_COMPANY_IDS, MobiusAdvertisement, parse_manufacturer_data
47
+ from .manufacturer import MOBIUS_COMPANY_ID_ECOTECH, MOBIUS_COMPANY_ID_AQUAILLUMINATION, MOBIUS_COMPANY_IDS, MobiusAdvertisement, parse_manufacturer_data, parse_advertisement
45
48
  from .modifiers import (
46
49
  AcclimationInfo, is_night_segment,
47
50
  julian_day_from_date, lunar_days_into_phase, lunar_percent_reduction,
@@ -58,19 +61,21 @@ from .coap import (
58
61
  from .mesh_address import (
59
62
  mesh_local_prefix_from_own_address, mesh_local_prefix_from_mlprefix,
60
63
  is_valid_mesh_address, build_rloc_address, extract_short_address,
61
- is_short_address_derived,
64
+ is_short_address_derived, format_mesh_address,
62
65
  )
63
66
  from .relay import RelayedMobiusDevice
64
67
  from .device import (
65
68
  MobiusDevice, MobiusPump, LightIntensityResult, LightPollResult, FullPollResult,
69
+ schedule_capacity, min_schedule_capacity, tank_group,
66
70
  SERVICE_GENERAL, CHAR_RX_DATA, CHAR_RX_FINAL, CHAR_TX_DATA, CHAR_TX_FINAL,
67
71
  )
68
72
  from .discovery import (
69
73
  scan_for_mobius_devices, scan_for_mobius_devices_with_info, group_by_pan_id,
70
74
  dedupe_by_serial, find_device_by_serial, discover_mesh_peers_via_direct_connect,
71
- discover_tank,
75
+ discover_tank, find_bluetooth_only_tank_members,
72
76
  )
73
77
  from .frame import SupportedAttribute
78
+ from .timezone import posix_tz_string, TIME_ZONE_FIELD_SIZE
74
79
  from .dump import (
75
80
  dump_attributes, format_attribute_dump_json, parse_attribute_dump_json,
76
81
  format_attribute_dump_text, parse_attribute_dump_text, parse_attribute_dump,
@@ -82,12 +87,13 @@ from .mob import (
82
87
  MAX_PUMP_PRIMITIVE_SIZE,
83
88
  )
84
89
 
85
- __version__ = "0.8.4"
90
+ __version__ = "0.10.0"
86
91
 
87
92
  __all__ = [
88
93
  "__version__",
89
94
  # crc
90
- "crc16", "CRC16_TABLE",
95
+ "crc16", "CRC16_TABLE", "supported_attributes_crc", "configured_scenes_checksum",
96
+ "schedule_checksum", "ConfigurationChecksums", "ConfigurationCache",
91
97
  # frame
92
98
  "build_frame", "parse_frame", "ParsedFrame",
93
99
  "encode_get_attribute", "encode_get_attributes_batch", "encode_set_attribute", "decode_attribute_response", "AttributeValue",
@@ -98,9 +104,10 @@ __all__ = [
98
104
  "OPCODE_GET_ATTR_ELEMENTS", "OPCODE_GET_ATTR_ELEMENTS_EXT", "OPCODE_GET_OPCODES",
99
105
  # constants
100
106
  "PrimitiveType", "PRIMITIVE_SIZE", "LIGHT_PRIMITIVES",
101
- "PUMP_PRIMITIVES_VERIFIED", "PUMP_PRIMITIVES_EXPERIMENTAL",
102
- "Model", "MODEL_MANUFACTURER", "manufacturer_for_model",
103
- "ErrorState", "C2Attribute", "PhysicalValueID", "VisualID",
107
+ "PUMP_PRIMITIVES_VERIFIED", "PUMP_PRIMITIVES_EXPERIMENTAL", "PUMP_PRIMITIVES", "enum_or_none",
108
+ "primitive_type_from_name", "support_tier", "PumpParamRange", "pump_param_range", "pump_param_errors",
109
+ "Model", "MODEL_MANUFACTURER", "BLUETOOTH_ONLY_MODELS", "is_bluetooth_only", "manufacturer_for_model",
110
+ "ErrorState", "C2Attribute", "OperationMode", "PhysicalValueID", "VisualID",
104
111
  "SceneID", "OperationState", "FsciStatus",
105
112
  "PumpMode", "PumpOverrideMode", "RampType", "PumpParam", "PUMP_PARAM_SIZE", "PUMP_MODE_PARAMS",
106
113
  "supported_pump_modes",
@@ -112,12 +119,12 @@ __all__ = [
112
119
  "LightPrimitive", "SchedulePoint", "interpolate_light_schedule",
113
120
  "PumpPrimitiveValue", "PumpSchedulePoint", "get_active_pump_block",
114
121
  "light_schedule_to_dict", "light_schedule_from_dict",
115
- "pump_schedule_to_dict", "pump_schedule_from_dict",
122
+ "pump_schedule_to_dict", "pump_schedule_from_dict", "pump_params_to_dict",
116
123
  "MAX_PUMP_SCHEDULE_POINT_SIZE",
117
124
  # scenes
118
125
  "Scene", "ActiveScene", "decode_configured_scenes", "decode_active_scene",
119
126
  # manufacturer
120
- "MOBIUS_COMPANY_ID_ECOTECH", "MOBIUS_COMPANY_ID_AQUAILLUMINATION", "MOBIUS_COMPANY_IDS", "MobiusAdvertisement", "parse_manufacturer_data",
127
+ "MOBIUS_COMPANY_ID_ECOTECH", "MOBIUS_COMPANY_ID_AQUAILLUMINATION", "MOBIUS_COMPANY_IDS", "MobiusAdvertisement", "parse_manufacturer_data", "parse_advertisement",
121
128
  # modifiers
122
129
  "AcclimationInfo", "is_night_segment",
123
130
  "julian_day_from_date", "lunar_days_into_phase", "lunar_percent_reduction",
@@ -133,14 +140,16 @@ __all__ = [
133
140
  "next_coap_token",
134
141
  "mesh_local_prefix_from_own_address", "mesh_local_prefix_from_mlprefix", "is_valid_mesh_address",
135
142
  "build_rloc_address", "extract_short_address", "is_short_address_derived",
143
+ "format_mesh_address", "posix_tz_string", "TIME_ZONE_FIELD_SIZE",
136
144
  "RelayedMobiusDevice",
137
145
  # device
138
146
  "MobiusDevice", "MobiusPump", "LightIntensityResult", "LightPollResult", "FullPollResult",
147
+ "schedule_capacity", "min_schedule_capacity", "tank_group",
139
148
  "SERVICE_GENERAL", "CHAR_RX_DATA", "CHAR_RX_FINAL", "CHAR_TX_DATA", "CHAR_TX_FINAL",
140
149
  # discovery
141
150
  "scan_for_mobius_devices", "scan_for_mobius_devices_with_info", "group_by_pan_id",
142
151
  "dedupe_by_serial", "find_device_by_serial", "discover_mesh_peers_via_direct_connect",
143
- "discover_tank",
152
+ "find_bluetooth_only_tank_members", "discover_tank",
144
153
  "SupportedAttribute", "dump_attributes", "enrich_attribute_dump", "generic_interpretations",
145
154
  "format_attribute_dump_json", "parse_attribute_dump_json",
146
155
  "format_attribute_dump_text", "parse_attribute_dump_text", "parse_attribute_dump",
mobius/checksums.py ADDED
@@ -0,0 +1,119 @@
1
+ """Checksums a device reports about its own configuration, and how to
2
+ compute them. Reading a 2-byte checksum instead of the data it covers lets
3
+ a client skip re-reading data that hasn't changed.
4
+
5
+ | Attribute | ID | Covers |
6
+ |---|---|---|
7
+ | `SupportedAttributesCRC` | 14 | The supported attribute table (see supported_attributes_crc()) |
8
+ | `ConfiguredScenesChecksum` | 408 | ConfiguredScenes (400) |
9
+ | `Schedule1Checksum` | 501 | Schedule1 (500) |
10
+ | `Schedule2Checksum` | 504 | Schedule2 (503) |
11
+
12
+ All four are CRC16 with seed 0xFFFF (see the functions below for what each
13
+ covers), confirmed on a VorTech MP40 and a Radion XR15 G6. The app
14
+ computes SupportedAttributesCRC itself and compares it with the device's
15
+ value to decide whether to read the attribute table again; the other
16
+ three it doesn't read.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import struct
22
+ from dataclasses import dataclass, field
23
+ from typing import Iterable, Optional
24
+
25
+ from .crc import crc16
26
+ from .frame import AttributeValue, SupportedAttribute
27
+
28
+
29
+ def supported_attributes_crc(supported: Iterable[SupportedAttribute]) -> int:
30
+ """
31
+ The app's checksum of a supported attribute table, for comparison with
32
+ SupportedAttributesCRC (14): crc16 over, for each attribute in the
33
+ order the device lists them, the id (u16 LE), the number of indexes
34
+ (u8) and each index (u8). Counts and indexes above 255 are truncated
35
+ to their low byte, as the app does.
36
+ """
37
+ data = bytearray()
38
+ for attribute in supported:
39
+ data += struct.pack("<H", attribute.attr_id)
40
+ data.append(len(attribute.indexes) & 0xFF)
41
+ data.extend(index & 0xFF for index in attribute.indexes)
42
+ return crc16(bytes(data))
43
+
44
+
45
+ def configured_scenes_checksum(slots: Iterable[bytes]) -> int:
46
+ """ConfiguredScenesChecksum (408) of ConfiguredScenes (400) slots as
47
+ read: crc16 over the used slots (scene id != 0) in slot order."""
48
+ return crc16(b"".join(slot for slot in slots if len(slot) >= 2 and struct.unpack_from("<H", slot)[0] != 0))
49
+
50
+
51
+ def schedule_checksum(points: Iterable[bytes]) -> int:
52
+ """Schedule1Checksum (501) / Schedule2Checksum (504) of Schedule1 (500) /
53
+ Schedule2 (503) points as read: crc16 over the active points (flags
54
+ bit 0 set) in order. Schedule1Intensity/Schedule2Intensity are not
55
+ included."""
56
+ return crc16(b"".join(point for point in points if len(point) >= 3 and point[2] & 0x01))
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ConfigurationChecksums:
61
+ """The checksums a device reports; None when not supported or not
62
+ readable."""
63
+ supported_attributes: Optional[int] = None
64
+ configured_scenes: Optional[int] = None
65
+ schedule1: Optional[int] = None
66
+ schedule2: Optional[int] = None
67
+
68
+
69
+ @dataclass
70
+ class ConfigurationCache:
71
+ """
72
+ Data of one device kept between get_full_poll_batch(cache=...) calls
73
+ so a poll reads the 2-byte checksums instead of the data they cover.
74
+
75
+ entries: {data attribute id: (checksum, response blocks)} for
76
+ ConfiguredScenes and the polled schedule, each with the checksum the
77
+ device reported in the same request. A poll whose checksum differs
78
+ (or can't be read) reads the data again. The device's own value is
79
+ the key, so no local computation is involved, and a change by any
80
+ client, this one included, shows up as a changed checksum.
81
+ supported_attributes_crc: SupportedAttributesCRC from the last poll.
82
+ When it changes (a firmware update), the poll result says so
83
+ (FullPollResult.attribute_table_changed) and the static attributes
84
+ are read again on the next poll. `entries` stay valid: each is still
85
+ checked against its own checksum.
86
+ static / static_read_at / static_refresh_interval: attributes that
87
+ rarely change (identity, firmware and hardware, color channels,
88
+ calibration, advanced settings, pump flow range and battery settings;
89
+ STATIC_POLL_ATTRIBUTES in device.py), read at most every
90
+ `static_refresh_interval` seconds and kept here in between. None (the
91
+ default) reads them on every poll. A client that writes one of them
92
+ calls invalidate_static() so the next poll reads them again.
93
+
94
+ One per device.
95
+ """
96
+ entries: dict[int, tuple[int, list[AttributeValue]]] = field(default_factory=dict)
97
+ supported_attributes_crc: Optional[int] = None
98
+ static: dict[int, list[AttributeValue]] = field(default_factory=dict)
99
+ static_read_at: Optional[float] = None
100
+ static_refresh_interval: Optional[float] = None
101
+
102
+ def static_due(self, now: float) -> bool:
103
+ """Whether the static attributes are to be read by a poll at
104
+ `now` (time.monotonic())."""
105
+ return (
106
+ self.static_refresh_interval is None or self.static_read_at is None
107
+ or now - self.static_read_at >= self.static_refresh_interval
108
+ )
109
+
110
+ def invalidate_static(self) -> None:
111
+ """Makes the next poll read the static attributes."""
112
+ self.static.clear()
113
+ self.static_read_at = None
114
+
115
+ def clear(self) -> None:
116
+ """Forgets everything: the next poll reads all data."""
117
+ self.entries.clear()
118
+ self.supported_attributes_crc = None
119
+ self.invalidate_static()