python-mobius 0.10.2__py3-none-any.whl → 0.11.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 +11 -1
- mobius/device.py +86 -11
- mobius/device_status.py +3 -1
- mobius/firmware.py +242 -0
- {python_mobius-0.10.2.dist-info → python_mobius-0.11.0.dist-info}/METADATA +3 -1
- {python_mobius-0.10.2.dist-info → python_mobius-0.11.0.dist-info}/RECORD +9 -8
- {python_mobius-0.10.2.dist-info → python_mobius-0.11.0.dist-info}/WHEEL +1 -1
- {python_mobius-0.10.2.dist-info → python_mobius-0.11.0.dist-info}/entry_points.txt +0 -0
- {python_mobius-0.10.2.dist-info → python_mobius-0.11.0.dist-info}/licenses/LICENSE +0 -0
mobius/__init__.py
CHANGED
|
@@ -64,6 +64,12 @@ from .mesh_address import (
|
|
|
64
64
|
is_short_address_derived, format_mesh_address,
|
|
65
65
|
)
|
|
66
66
|
from .relay import RelayedMobiusDevice
|
|
67
|
+
from .device import firmware_label
|
|
68
|
+
from .firmware import (
|
|
69
|
+
FIRMWARE_CLOUD_HOST, ImageId, FirmwareImage, installable_images, firmware_images,
|
|
70
|
+
firmware_check_url, AvailableFirmware, FirmwareCheck, parse_firmware_check,
|
|
71
|
+
ChangelogEntry, Changelog, parse_changelog, version_tuple,
|
|
72
|
+
)
|
|
67
73
|
from .device import (
|
|
68
74
|
MobiusDevice, MobiusPump, LightIntensityResult, LightPollResult, FullPollResult,
|
|
69
75
|
schedule_capacity, min_schedule_capacity, tank_group,
|
|
@@ -87,7 +93,7 @@ from .mob import (
|
|
|
87
93
|
MAX_PUMP_PRIMITIVE_SIZE,
|
|
88
94
|
)
|
|
89
95
|
|
|
90
|
-
__version__ = "0.
|
|
96
|
+
__version__ = "0.11.0"
|
|
91
97
|
|
|
92
98
|
__all__ = [
|
|
93
99
|
"__version__",
|
|
@@ -113,6 +119,10 @@ __all__ = [
|
|
|
113
119
|
"supported_pump_modes",
|
|
114
120
|
"get_pump_reverse",
|
|
115
121
|
"FirmwareType", "FIRMWARE_TYPE_LABELS_ETM", "HardwareInfo",
|
|
122
|
+
# firmware
|
|
123
|
+
"firmware_label", "FIRMWARE_CLOUD_HOST", "ImageId", "FirmwareImage", "installable_images", "firmware_images",
|
|
124
|
+
"firmware_check_url", "AvailableFirmware", "FirmwareCheck", "parse_firmware_check",
|
|
125
|
+
"ChangelogEntry", "Changelog", "parse_changelog", "version_tuple",
|
|
116
126
|
"Color", "COLOR_LABELS", "RadioType", "RADIO_TYPE_LABELS",
|
|
117
127
|
"MotorType", "MOTOR_TYPE_LABELS", "ProductType", "PRODUCT_TYPE_LABELS",
|
|
118
128
|
# schedule
|
mobius/device.py
CHANGED
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import datetime
|
|
14
14
|
import struct
|
|
15
|
+
import logging
|
|
15
16
|
import time
|
|
16
17
|
from dataclasses import dataclass, field
|
|
17
18
|
from typing import Optional, Callable, Any
|
|
@@ -67,6 +68,7 @@ from .mesh_address import (
|
|
|
67
68
|
from .coap import decode_coap_response, CoapResponse, COAP_INDICATION_OPCODE
|
|
68
69
|
from .discovery import find_device_by_serial
|
|
69
70
|
from .timezone import encode_time_zone_field, posix_tz_string
|
|
71
|
+
from .firmware import firmware_images, installable_images
|
|
70
72
|
|
|
71
73
|
# --------------------------------------------------------------------------
|
|
72
74
|
# GATT UUIDs (service 01ff0100-...; four characteristics under it)
|
|
@@ -79,6 +81,8 @@ CHAR_TX_DATA = "01ff0103-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # write: app -> device,
|
|
|
79
81
|
CHAR_TX_FINAL = "01ff0104-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # write: app -> device, last fragment
|
|
80
82
|
|
|
81
83
|
# count=0xFFFF requests every element of a list attribute.
|
|
84
|
+
_LOGGER = logging.getLogger(__name__)
|
|
85
|
+
|
|
82
86
|
ALL_ELEMENTS = 0xFFFF
|
|
83
87
|
|
|
84
88
|
# Write fragment size when the ATT MTU is unknown or the minimum (23).
|
|
@@ -87,6 +91,23 @@ DEFAULT_WRITE_SIZE = 20
|
|
|
87
91
|
MTU_WRITE_OVERHEAD = 5
|
|
88
92
|
|
|
89
93
|
|
|
94
|
+
def _log_bad_frame(raw: bytes, frame, trace: list[tuple[str, int]]) -> None:
|
|
95
|
+
"""Debug details of a received frame that doesn't parse or fails its
|
|
96
|
+
CRC: what was received against what its header declares, and the
|
|
97
|
+
notifications it was assembled from (D = RX_DATA, F = RX_FINAL)."""
|
|
98
|
+
if not _LOGGER.isEnabledFor(logging.DEBUG):
|
|
99
|
+
return
|
|
100
|
+
declared = struct.unpack_from("<H", raw, 7)[0] + 11 if len(raw) >= 9 else None
|
|
101
|
+
pieces = " ".join(f"{kind}{length}" for kind, length in trace)
|
|
102
|
+
_LOGGER.debug(
|
|
103
|
+
"Received a frame that %s: %d bytes, header declares %s; op_group=%s op_code=%s msg_id=%s; "
|
|
104
|
+
"%d notifications [%s]; first bytes %s; last bytes %s",
|
|
105
|
+
"doesn't parse" if frame is None else "fails its CRC", len(raw), declared,
|
|
106
|
+
f"{frame.op_group:#04x}" if frame else "?", frame.op_code if frame else "?",
|
|
107
|
+
frame.message_id if frame else "?", len(trace), pieces, raw[:24].hex(), raw[-24:].hex(),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
90
111
|
def write_size_for_mtu(att_mtu: Optional[int]) -> int:
|
|
91
112
|
"""Write fragment size for a negotiated ATT MTU: `att_mtu` minus 5, as
|
|
92
113
|
the app does, or DEFAULT_WRITE_SIZE when it is unknown or not above the
|
|
@@ -482,14 +503,10 @@ def _indexed_values(blocks: list[AttributeValue]):
|
|
|
482
503
|
yield block.index + i, value
|
|
483
504
|
|
|
484
505
|
|
|
485
|
-
def
|
|
486
|
-
"""
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
from FIRMWARE_TYPE_LABELS_ETM for EcoTech Marine models and
|
|
490
|
-
FIRMWARE_TYPE_LABELS_NON_ETM for other known manufacturers, falling back
|
|
491
|
-
to the FirmwareType name.
|
|
492
|
-
"""
|
|
506
|
+
def firmware_label(firmware_type: FirmwareType, model: Optional[Model] = None) -> str:
|
|
507
|
+
"""Display label of a firmware component: FIRMWARE_TYPE_LABELS_ETM for
|
|
508
|
+
EcoTech Marine models, FIRMWARE_TYPE_LABELS_NON_ETM for other known
|
|
509
|
+
manufacturers, else (or when unlabelled) the FirmwareType name."""
|
|
493
510
|
manufacturer = manufacturer_for_model(model) if model is not None else None
|
|
494
511
|
if manufacturer == "EcoTech Marine":
|
|
495
512
|
labels = FIRMWARE_TYPE_LABELS_ETM
|
|
@@ -497,16 +514,49 @@ def decode_firmware_version_blocks(blocks: list[AttributeValue], model: Optional
|
|
|
497
514
|
labels = FIRMWARE_TYPE_LABELS_NON_ETM
|
|
498
515
|
else:
|
|
499
516
|
labels = {}
|
|
517
|
+
return labels.get(firmware_type, firmware_type.name)
|
|
500
518
|
|
|
519
|
+
|
|
520
|
+
def decode_firmware_versions_by_type(blocks: list[AttributeValue]) -> dict:
|
|
521
|
+
"""{FirmwareType: "a.b.c"} from FirmwareVersion (1) blocks. Each
|
|
522
|
+
element's index is its FirmwareType; each version is its bytes joined
|
|
523
|
+
with dots."""
|
|
501
524
|
result = {}
|
|
502
525
|
for index, value_bytes in _indexed_values(blocks):
|
|
503
526
|
fw_type = enum_or_none(FirmwareType, index)
|
|
504
|
-
if
|
|
505
|
-
|
|
506
|
-
result[labels.get(fw_type, fw_type.name)] = ".".join(str(b) for b in value_bytes)
|
|
527
|
+
if value_bytes and fw_type is not None:
|
|
528
|
+
result[fw_type] = ".".join(str(b) for b in value_bytes)
|
|
507
529
|
return result
|
|
508
530
|
|
|
509
531
|
|
|
532
|
+
def decode_firmware_version_blocks(blocks: list[AttributeValue], model: Optional[Model] = None) -> dict:
|
|
533
|
+
"""
|
|
534
|
+
{label: "a.b.c"} from FirmwareVersion (1) blocks, labelled with
|
|
535
|
+
firmware_label(). Components sharing a label keep the last one; see
|
|
536
|
+
decode_firmware_versions_by_type() for every component.
|
|
537
|
+
"""
|
|
538
|
+
return {
|
|
539
|
+
firmware_label(fw_type, model): version
|
|
540
|
+
for fw_type, version in decode_firmware_versions_by_type(blocks).items()
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def decode_radio_type(blocks: list[AttributeValue]) -> Optional[RadioType]:
|
|
545
|
+
"""HardwareInfo.RadioType from HardwareRevision (2) blocks, None if not
|
|
546
|
+
reported or unknown."""
|
|
547
|
+
for index, value_bytes in _indexed_values(blocks):
|
|
548
|
+
if index == HardwareInfo.RadioType and value_bytes:
|
|
549
|
+
return enum_or_none(RadioType, int.from_bytes(value_bytes, byteorder="little", signed=False))
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def decode_firmware_images(firmware_blocks: list[AttributeValue], hardware_blocks: list[AttributeValue],
|
|
554
|
+
model: Optional[Model]) -> list:
|
|
555
|
+
"""[FirmwareImage] the cloud can be asked about (see firmware.py)."""
|
|
556
|
+
return firmware_images(model, decode_firmware_versions_by_type(firmware_blocks),
|
|
557
|
+
decode_radio_type(hardware_blocks))
|
|
558
|
+
|
|
559
|
+
|
|
510
560
|
def decode_hardware_revision_blocks(blocks: list[AttributeValue]) -> dict:
|
|
511
561
|
"""
|
|
512
562
|
{HardwareInfo name: value} from HardwareRevision (2) blocks. Each
|
|
@@ -657,6 +707,9 @@ def decode_metadata_from_batch(by_attr_id: dict, model: Optional[Model]) -> Meta
|
|
|
657
707
|
),
|
|
658
708
|
hardware_info=decode_hardware_revision_blocks(by_attr_id.get(C2Attribute.HardwareRevision, [])),
|
|
659
709
|
firmware_versions=decode_firmware_version_blocks(by_attr_id.get(C2Attribute.FirmwareVersion, []), model),
|
|
710
|
+
firmware_images=decode_firmware_images(
|
|
711
|
+
by_attr_id.get(C2Attribute.FirmwareVersion, []), by_attr_id.get(C2Attribute.HardwareRevision, []), model,
|
|
712
|
+
),
|
|
660
713
|
supported_channels=decode_supported_channels(_values_for(by_attr_id, C2Attribute.SupportedColorChannels)),
|
|
661
714
|
error_state=decode_error_state(first(C2Attribute.ErrorState)),
|
|
662
715
|
epoch=_unpack(first(C2Attribute.Epoch), "<I"),
|
|
@@ -917,6 +970,9 @@ class MobiusDevice:
|
|
|
917
970
|
self.adapter = adapter
|
|
918
971
|
self._client: Optional[BleakClient] = None
|
|
919
972
|
self._rx_buffer = bytearray()
|
|
973
|
+
# ("D"/"F", length) of each notification of the frame being
|
|
974
|
+
# received, logged when it fails its CRC.
|
|
975
|
+
self._rx_trace: list[tuple[str, int]] = []
|
|
920
976
|
# Pending direct requests, keyed by FSCI message ID.
|
|
921
977
|
self._pending: dict[int, asyncio.Future] = {}
|
|
922
978
|
# Pending relayed requests, keyed by CoAP token (see mobius.relay).
|
|
@@ -1014,12 +1070,18 @@ class MobiusDevice:
|
|
|
1014
1070
|
|
|
1015
1071
|
def _on_rx_data(self, _handle, data: bytearray):
|
|
1016
1072
|
self._rx_buffer.extend(data)
|
|
1073
|
+
self._rx_trace.append(("D", len(data)))
|
|
1017
1074
|
|
|
1018
1075
|
def _on_rx_final(self, _handle, data: bytearray):
|
|
1019
1076
|
self._rx_buffer.extend(data)
|
|
1077
|
+
self._rx_trace.append(("F", len(data)))
|
|
1020
1078
|
raw = bytes(self._rx_buffer)
|
|
1079
|
+
trace = self._rx_trace
|
|
1021
1080
|
self._rx_buffer.clear()
|
|
1081
|
+
self._rx_trace = []
|
|
1022
1082
|
frame = parse_frame(raw)
|
|
1083
|
+
if frame is None or not frame.crc_ok:
|
|
1084
|
+
_log_bad_frame(raw, frame, trace)
|
|
1023
1085
|
if frame is None:
|
|
1024
1086
|
return
|
|
1025
1087
|
if self.on_any_frame is not None:
|
|
@@ -1729,6 +1791,7 @@ class MobiusDevice:
|
|
|
1729
1791
|
calibration=await soft(self.get_calibration_info(), None),
|
|
1730
1792
|
hardware_info=await soft(self.get_hardware_info(), {}),
|
|
1731
1793
|
firmware_versions=await soft(self.get_firmware_versions(model=model), {}),
|
|
1794
|
+
firmware_images=await soft(self.get_firmware_images(model=model), []),
|
|
1732
1795
|
supported_channels=await soft(self.get_supported_channels(), []),
|
|
1733
1796
|
error_state=decode_error_state(await self._try_first(C2Attribute.ErrorState)),
|
|
1734
1797
|
epoch=await self._try_unpack(C2Attribute.Epoch, "<I"),
|
|
@@ -1979,6 +2042,18 @@ class MobiusDevice:
|
|
|
1979
2042
|
return {}
|
|
1980
2043
|
return decode_firmware_version_blocks(blocks, model)
|
|
1981
2044
|
|
|
2045
|
+
async def get_firmware_images(self, model: Optional[Model] = None) -> list:
|
|
2046
|
+
"""[FirmwareImage] the cloud can be asked about for updates (see
|
|
2047
|
+
firmware.py). [] on failure or for models without updates."""
|
|
2048
|
+
if not installable_images(model):
|
|
2049
|
+
return []
|
|
2050
|
+
try:
|
|
2051
|
+
firmware_blocks = await self.get_attribute_raw_all(C2Attribute.FirmwareVersion, index=0, count=ALL_ELEMENTS)
|
|
2052
|
+
hardware_blocks = await self.get_attribute_raw_all(C2Attribute.HardwareRevision, index=0, count=ALL_ELEMENTS)
|
|
2053
|
+
except Exception:
|
|
2054
|
+
return []
|
|
2055
|
+
return decode_firmware_images(firmware_blocks, hardware_blocks, model)
|
|
2056
|
+
|
|
1982
2057
|
async def get_hardware_info(self) -> dict:
|
|
1983
2058
|
"""HardwareRevision sub-fields (all elements). See
|
|
1984
2059
|
decode_hardware_revision_blocks(). {} on failure."""
|
mobius/device_status.py
CHANGED
|
@@ -5,7 +5,7 @@ value has not been established.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
from dataclasses import dataclass
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
9
|
from typing import Optional
|
|
10
10
|
|
|
11
11
|
from .constants import Model
|
|
@@ -420,6 +420,8 @@ time zone strings.
|
|
|
420
420
|
# PosixTimeZoneString / OlsonTimeZoneLocation (205 / 206).
|
|
421
421
|
posix_tz: Optional[str] = None
|
|
422
422
|
olson_tz: Optional[str] = None
|
|
423
|
+
# [FirmwareImage] the cloud can be asked about for updates (firmware.py).
|
|
424
|
+
firmware_images: list = field(default_factory=list)
|
|
423
425
|
|
|
424
426
|
|
|
425
427
|
@dataclass
|
mobius/firmware.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Firmware update checks against the Mobius cloud, as the app does them.
|
|
3
|
+
|
|
4
|
+
The app checks each firmware part a model can install (its "image") with
|
|
5
|
+
|
|
6
|
+
GET https://cloud.mobius.app/firmware/mobius/check?version=<installed>&image=<ImageId>&format=json
|
|
7
|
+
|
|
8
|
+
No login is needed. The reply always has "interval" (minutes before the
|
|
9
|
+
app checks the same image and version again); when a newer firmware exists
|
|
10
|
+
it also has "url", "version", "md5", "changelog" (a URL to a JSON
|
|
11
|
+
changelog), "install", "reset" and "id".
|
|
12
|
+
|
|
13
|
+
This module only builds the URLs and parses the replies: fetching them is
|
|
14
|
+
left to the caller. Installing firmware is not supported.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import IntEnum
|
|
21
|
+
from typing import Optional
|
|
22
|
+
from urllib.parse import urlencode
|
|
23
|
+
|
|
24
|
+
from .constants import FirmwareType, Model, RadioType
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
FIRMWARE_CLOUD_HOST = "cloud.mobius.app"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ImageId(IntEnum):
|
|
31
|
+
"""Firmware image the cloud is asked about (the app's M.ImageId)."""
|
|
32
|
+
Unknown = 0
|
|
33
|
+
RfModuleKw41 = 1
|
|
34
|
+
QuietDriveDriver = 2
|
|
35
|
+
RadionDriverKl26 = 3
|
|
36
|
+
AiPumpKw41 = 4
|
|
37
|
+
DosingPumpKw41 = 5
|
|
38
|
+
AiLightQCA4020 = 6
|
|
39
|
+
DosingPumpBaseStation = 7
|
|
40
|
+
RadionQCA4020 = 8
|
|
41
|
+
Chalupa = 9
|
|
42
|
+
RfModuleK32w = 10
|
|
43
|
+
AiLightQCA4024 = 11
|
|
44
|
+
RadionQCA4024 = 12
|
|
45
|
+
Saddle = 13
|
|
46
|
+
Cowboy = 14
|
|
47
|
+
Muffler = 15
|
|
48
|
+
Coffee = 16
|
|
49
|
+
Alfalfa = 17
|
|
50
|
+
CrunchyTaco = 18
|
|
51
|
+
SoftTaco = 19
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_KL26_RADIONS = frozenset({
|
|
55
|
+
Model.RadionXR30w, Model.RadionXR30wG2, Model.RadionXR30wPro, Model.RadionXR30wG3,
|
|
56
|
+
Model.RadionXR30wG3Pro, Model.RadionXR30wG4, Model.RadionXR30wG4Pro, Model.RadionXR30wFWPro,
|
|
57
|
+
Model.RadionXR30wPrototype, Model.RadionXR15wG3Pro, Model.RadionXR15wFW, Model.RadionXR15wG4Pro,
|
|
58
|
+
Model.RadionXR15wFWPro,
|
|
59
|
+
})
|
|
60
|
+
_QUIETDRIVE_PUMPS = frozenset({
|
|
61
|
+
Model.VectraS1, Model.VectraM1, Model.VectraL1, Model.VectraS2, Model.VectraM2, Model.VectraL2,
|
|
62
|
+
Model.VorTechMP10wQD, Model.VorTechMP40wQD, Model.VorTechMP60wQD, Model.VorTechMP40wG3QD,
|
|
63
|
+
Model.VorTechMP10wES,
|
|
64
|
+
})
|
|
65
|
+
_QCA_HYDRAS = frozenset({
|
|
66
|
+
Model.Hydra32HDFreshwater, Model.PrimeSixteenHD, Model.HydraThirtyTwoHD, Model.HydraSixtyFourHD,
|
|
67
|
+
Model.PrimeSixteenHdFuge, Model.PrimeSixteenHdFw, Model.PrimeSixteenHdSol,
|
|
68
|
+
})
|
|
69
|
+
_QCA_RADIONS = frozenset({
|
|
70
|
+
Model.RadionXR15wG5Blue, Model.RadionXR15wG5FW, Model.RadionXR30wG5Blue, Model.RadionXR30wG5FW,
|
|
71
|
+
Model.RadionXR30wG5Pro, Model.RadionXR15wPrototype, Model.RadionXR15G5Pro, Model.RadionXR30wG6Pro,
|
|
72
|
+
Model.RadionXR30wG6Blue, Model.RadionXR15wG6Pro, Model.RadionXR15wG6Blue,
|
|
73
|
+
})
|
|
74
|
+
_AI_PUMPS = frozenset({
|
|
75
|
+
Model.Nero5, Model.Nero3, Model.Nero7, Model.Axis20, Model.Axis40, Model.Axis90, Model.Axis200,
|
|
76
|
+
Model.Orbit2, Model.Orbit4,
|
|
77
|
+
})
|
|
78
|
+
_SINGLE_IMAGE_MODELS: dict = {
|
|
79
|
+
Model.SproutSL1: (FirmwareType.QCA4020Firmware, ImageId.Chalupa),
|
|
80
|
+
**{m: (FirmwareType.Esp32C3Firmware, ImageId.Saddle) for m in (
|
|
81
|
+
Model.Blade, Model.BladeD, Model.BladeCoralGlow, Model.BladeCoralGrow, Model.BladeFreshwater,
|
|
82
|
+
Model.BladeRefugium, Model.BladeGrowPlus, Model.BladeGloFish,
|
|
83
|
+
)},
|
|
84
|
+
Model.DoseQD: (FirmwareType.Esp32H2Firmware, ImageId.Muffler),
|
|
85
|
+
Model.Quantum160: (FirmwareType.Esp32H2Firmware, ImageId.Coffee),
|
|
86
|
+
Model.Quantum220300: (FirmwareType.Esp32H2Firmware, ImageId.Coffee),
|
|
87
|
+
**{m: (FirmwareType.Esp32C6Firmware, ImageId.Alfalfa) for m in (
|
|
88
|
+
Model.HydraEdge44HD, Model.HydraEdge68HD, Model.HydraEdge44HDFreshwater, Model.HydraEdge68HDFreshwater,
|
|
89
|
+
)},
|
|
90
|
+
Model.Hera: (FirmwareType.Esp32S3Firmware, ImageId.CrunchyTaco),
|
|
91
|
+
Model.HeraBackplane: (FirmwareType.Esp32C6Firmware, ImageId.SoftTaco),
|
|
92
|
+
**{m: (FirmwareType.OS, ImageId.AiPumpKw41) for m in _AI_PUMPS},
|
|
93
|
+
Model.MXM: (FirmwareType.OS, ImageId.Cowboy),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def installable_images(model: Optional[Model], radio_type: Optional[RadioType] = None) -> list:
|
|
98
|
+
"""[(FirmwareType, ImageId)] the app can update on a model, in the
|
|
99
|
+
app's order. Some models depend on their radio (HardwareInfo
|
|
100
|
+
RadioType). [] for models the app has no updates for."""
|
|
101
|
+
if model in _KL26_RADIONS:
|
|
102
|
+
return [(FirmwareType.OS, ImageId.RfModuleKw41), (FirmwareType.MainMicroOS, ImageId.RadionDriverKl26)]
|
|
103
|
+
if model in _QUIETDRIVE_PUMPS:
|
|
104
|
+
radio = ImageId.RfModuleK32w if radio_type == RadioType.K32W else ImageId.RfModuleKw41
|
|
105
|
+
return [(FirmwareType.OS, radio), (FirmwareType.MainMicroOS, ImageId.QuietDriveDriver)]
|
|
106
|
+
if model in _QCA_HYDRAS:
|
|
107
|
+
image = ImageId.AiLightQCA4024 if radio_type == RadioType.QCA4024 else ImageId.AiLightQCA4020
|
|
108
|
+
return [(FirmwareType.QCA4020Firmware, image)]
|
|
109
|
+
if model in _QCA_RADIONS:
|
|
110
|
+
image = ImageId.RadionQCA4024 if radio_type == RadioType.QCA4024 else ImageId.RadionQCA4020
|
|
111
|
+
return [(FirmwareType.QCA4020Firmware, image)]
|
|
112
|
+
if model == Model.VersaVX1:
|
|
113
|
+
return [(FirmwareType.OS, ImageId.DosingPumpKw41),
|
|
114
|
+
(FirmwareType.XMC1402Firmware, ImageId.DosingPumpBaseStation)]
|
|
115
|
+
single = _SINGLE_IMAGE_MODELS.get(model)
|
|
116
|
+
return [single] if single else []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class FirmwareImage:
|
|
121
|
+
"""One updatable firmware part of a device and its installed version."""
|
|
122
|
+
firmware_type: FirmwareType
|
|
123
|
+
image_id: ImageId
|
|
124
|
+
version: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def firmware_images(model: Optional[Model], versions_by_type: dict,
|
|
128
|
+
radio_type: Optional[RadioType] = None) -> list:
|
|
129
|
+
"""[FirmwareImage] for the parts of installable_images() the device
|
|
130
|
+
reports a version for ({FirmwareType: "a.b.c"}, see
|
|
131
|
+
decode_firmware_versions_by_type())."""
|
|
132
|
+
return [
|
|
133
|
+
FirmwareImage(firmware_type, image_id, versions_by_type[firmware_type])
|
|
134
|
+
for firmware_type, image_id in installable_images(model, radio_type)
|
|
135
|
+
if versions_by_type.get(firmware_type)
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def firmware_check_url(image_id: ImageId, version: str, host: str = FIRMWARE_CLOUD_HOST) -> str:
|
|
140
|
+
"""URL the app checks for a newer firmware of `image_id`."""
|
|
141
|
+
query = urlencode({"version": version, "image": int(image_id), "format": "json"})
|
|
142
|
+
return f"https://{host}/firmware/mobius/check?{query}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class AvailableFirmware:
|
|
147
|
+
"""A newer firmware offered by the cloud."""
|
|
148
|
+
version: str
|
|
149
|
+
url: str
|
|
150
|
+
changelog_url: Optional[str] = None
|
|
151
|
+
md5: Optional[str] = None
|
|
152
|
+
install: bool = False
|
|
153
|
+
reset: bool = False
|
|
154
|
+
id: Optional[int] = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class FirmwareCheck:
|
|
159
|
+
"""A parsed check reply: `update` is None when the installed version is
|
|
160
|
+
the latest. `interval` is how long (seconds) the app waits before
|
|
161
|
+
checking the same image and version again, None if not given."""
|
|
162
|
+
update: Optional[AvailableFirmware]
|
|
163
|
+
interval: Optional[int]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_firmware_check(data: dict) -> FirmwareCheck:
|
|
167
|
+
"""Parses a check reply (the JSON object). Raises ValueError if it isn't
|
|
168
|
+
a JSON object."""
|
|
169
|
+
if not isinstance(data, dict):
|
|
170
|
+
raise ValueError(f"firmware check reply is not an object: {data!r}")
|
|
171
|
+
interval = data.get("interval")
|
|
172
|
+
interval = int(interval) * 60 if isinstance(interval, (int, float)) else None
|
|
173
|
+
url, version = data.get("url"), data.get("version")
|
|
174
|
+
if not url or not version:
|
|
175
|
+
return FirmwareCheck(None, interval)
|
|
176
|
+
firmware_id = data.get("id")
|
|
177
|
+
return FirmwareCheck(AvailableFirmware(
|
|
178
|
+
version=str(version),
|
|
179
|
+
url=str(url),
|
|
180
|
+
changelog_url=data.get("changelog") or None,
|
|
181
|
+
md5=data.get("md5") or None,
|
|
182
|
+
install=bool(data.get("install")),
|
|
183
|
+
reset=bool(data.get("reset")),
|
|
184
|
+
id=firmware_id if isinstance(firmware_id, int) else None,
|
|
185
|
+
), interval)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class ChangelogEntry:
|
|
190
|
+
"""One version of a firmware changelog."""
|
|
191
|
+
version: str
|
|
192
|
+
changes: list = field(default_factory=list) # list[str]
|
|
193
|
+
alerts: list = field(default_factory=list) # list[str]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class Changelog:
|
|
198
|
+
"""A firmware changelog (the reply's "changelog" URL), newest first as
|
|
199
|
+
published."""
|
|
200
|
+
current_version: Optional[str]
|
|
201
|
+
entries: list # list[ChangelogEntry]
|
|
202
|
+
alerts: list = field(default_factory=list) # list[str]
|
|
203
|
+
|
|
204
|
+
def newer_than(self, version: str) -> list:
|
|
205
|
+
"""Entries for versions newer than `version`. All entries if either
|
|
206
|
+
version can't be compared."""
|
|
207
|
+
installed = version_tuple(version)
|
|
208
|
+
if installed is None:
|
|
209
|
+
return list(self.entries)
|
|
210
|
+
newer = []
|
|
211
|
+
for entry in self.entries:
|
|
212
|
+
entry_version = version_tuple(entry.version)
|
|
213
|
+
if entry_version is None or entry_version > installed:
|
|
214
|
+
newer.append(entry)
|
|
215
|
+
return newer
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _texts(items) -> list:
|
|
219
|
+
return [str(item["text"]) for item in items or [] if isinstance(item, dict) and item.get("text")]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def parse_changelog(data: dict) -> Changelog:
|
|
223
|
+
"""Parses a changelog JSON object ({"current_version", "change_list":
|
|
224
|
+
[{"version", "changes": [{"text"}], "alerts": [{"text"}]}], "alerts"}).
|
|
225
|
+
Raises ValueError if it isn't a JSON object."""
|
|
226
|
+
if not isinstance(data, dict):
|
|
227
|
+
raise ValueError(f"changelog is not an object: {data!r}")
|
|
228
|
+
entries = [
|
|
229
|
+
ChangelogEntry(str(item.get("version", "")), _texts(item.get("changes")), _texts(item.get("alerts")))
|
|
230
|
+
for item in data.get("change_list") or [] if isinstance(item, dict)
|
|
231
|
+
]
|
|
232
|
+
current = data.get("current_version")
|
|
233
|
+
return Changelog(str(current) if current else None, entries, _texts(data.get("alerts")))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def version_tuple(version: str) -> Optional[tuple]:
|
|
237
|
+
"""(3, 0, 35) from "3.0.35" (the app also accepts "-" as a separator).
|
|
238
|
+
None if a part isn't a number."""
|
|
239
|
+
try:
|
|
240
|
+
return tuple(int(part) for part in str(version).replace("-", ".").split("."))
|
|
241
|
+
except ValueError:
|
|
242
|
+
return None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-mobius
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
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
|
|
@@ -65,6 +65,8 @@ implement it in another language.
|
|
|
65
65
|
- **Batched polling**: everything a poll needs in one request.
|
|
66
66
|
- **Diagnostics**: attribute dumps, firmware and hardware information,
|
|
67
67
|
Thread and clock diagnostics.
|
|
68
|
+
- **Firmware update checks**: which firmware images to ask the Mobius cloud
|
|
69
|
+
about and parsing its replies and changelogs (installing is not supported).
|
|
68
70
|
- **`mobius-scan`** command line tool.
|
|
69
71
|
|
|
70
72
|
## Supported devices
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
mobius/__init__.py,sha256=
|
|
1
|
+
mobius/__init__.py,sha256=HxhUUJCjvLh_q8Sz6RwOBI8_DXY8F6EZXlbM1Rlmaco,9133
|
|
2
2
|
mobius/checksums.py,sha256=PB9RqvaZu9AqfT6tU6NI2qbbZXrqaHUnow3Qi_6UFHs,5191
|
|
3
3
|
mobius/cli.py,sha256=tyft0SzbuLgbmzps-Ym1hCzcr6A9D5MsALkg6o4ynBs,57876
|
|
4
4
|
mobius/coap.py,sha256=-XV3kI4q-JoL8r0oLFNnHAdZAc_yUP-aICagKh-qbf4,4862
|
|
5
5
|
mobius/constants.py,sha256=pcG9Su_b81pmMA_GxR41lZz9UM9BjtV0T6nuslMICko,41138
|
|
6
6
|
mobius/crc.py,sha256=LOCHZe3a1UnR5thdpZ4S4SFP8BdmoMEnhDNiNMd0iJI,2508
|
|
7
|
-
mobius/device.py,sha256=
|
|
8
|
-
mobius/device_status.py,sha256=
|
|
7
|
+
mobius/device.py,sha256=3UmGI4txfp_gOCM3nonahXcYNwBLzicOzqFBtksumis,136823
|
|
8
|
+
mobius/device_status.py,sha256=JhJQreluRAP6Ow2M187RDrPyATEsQAAETpL_d-dLhQQ,15799
|
|
9
9
|
mobius/discovery.py,sha256=poNeXGekB44cpHOFbGyps9d4mwW_hJptmJODwSMTUSg,7176
|
|
10
10
|
mobius/dump.py,sha256=eQBYoi8J6CxRd2ojgWK5QoB8z416oywLRTBnnh0pLwo,9253
|
|
11
|
+
mobius/firmware.py,sha256=TuUcJxv2A6Lrpz5qfZvXDVC_-06GRPNCjG0MG1_UH-M,9693
|
|
11
12
|
mobius/frame.py,sha256=hO7nc1Rn0HqsIIZP6KZXj0n91J7LA5SfBoQcu_PNKvQ,7852
|
|
12
13
|
mobius/manufacturer.py,sha256=WNpU1f69rrroJQdWhEw-ROw1dtPH7Rbn7IBEtGKLxqE,2382
|
|
13
14
|
mobius/mesh_address.py,sha256=hNpijfuzJFmfpmndjeSAqefr6mY4oZ4hQEXNvYjy4mY,3435
|
|
@@ -19,8 +20,8 @@ mobius/relay.py,sha256=trGkWQSarwURF05gXHeb5jTJ9UvWbQGu8NarEP6sRqE,10107
|
|
|
19
20
|
mobius/scenes.py,sha256=VLfDdTKSv3qTsBt2bJGg8iPtUkfNEmNgu0TTtYT6jJE,4274
|
|
20
21
|
mobius/schedule.py,sha256=si9uJdYOj5vh6owuxxMyMyZRY0-tAmCPt3s6ROAeRww,13243
|
|
21
22
|
mobius/timezone.py,sha256=Q7oPKtCdEYcEHXLx4lt-VPq6KH-MNgQwVnDtIXJV-kQ,2267
|
|
22
|
-
python_mobius-0.
|
|
23
|
-
python_mobius-0.
|
|
24
|
-
python_mobius-0.
|
|
25
|
-
python_mobius-0.
|
|
26
|
-
python_mobius-0.
|
|
23
|
+
python_mobius-0.11.0.dist-info/METADATA,sha256=pruuXYoAVOF91RncSpReYkt_zTGXwf0-q2h_00McBvM,5983
|
|
24
|
+
python_mobius-0.11.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
25
|
+
python_mobius-0.11.0.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
|
|
26
|
+
python_mobius-0.11.0.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
|
|
27
|
+
python_mobius-0.11.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|