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 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.10.2"
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 decode_firmware_version_blocks(blocks: list[AttributeValue], model: Optional[Model] = None) -> dict:
486
- """
487
- {label: "a.b.c"} from FirmwareVersion (1) blocks. Each element's index is
488
- its FirmwareType; each version is its bytes joined with dots. Labels come
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 not value_bytes or fw_type is None:
505
- continue
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.10.2
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=ownwnGpQ6cybzgLY8bzjmBG4D7JOQ1bw_m9hMPMdvA8,8555
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=1awCx8TIzSFHHEnkGMbuItbpQtAt1DINdmSnLJqENWI,133014
8
- mobius/device_status.py,sha256=XclYijAknrgT8SdSW4XvyBemWHUQqw6UPE4tmwq6Yug,15658
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.10.2.dist-info/METADATA,sha256=O31L0MYu8ryhtqYYdQoXfTE9B23dqI4H2OJrWVH5EZI,5829
23
- python_mobius-0.10.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
24
- python_mobius-0.10.2.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
25
- python_mobius-0.10.2.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
26
- python_mobius-0.10.2.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.4
2
+ Generator: hatchling 1.33.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any