python-mobius 0.8.0__tar.gz → 0.8.1__tar.gz

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.
Files changed (91) hide show
  1. {python_mobius-0.8.0 → python_mobius-0.8.1}/CHANGELOG.md +9 -0
  2. {python_mobius-0.8.0 → python_mobius-0.8.1}/PKG-INFO +1 -1
  3. {python_mobius-0.8.0 → python_mobius-0.8.1}/pyproject.toml +1 -1
  4. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/__init__.py +3 -1
  5. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/constants.py +29 -0
  6. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/device.py +22 -0
  7. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_device_control.py +49 -5
  8. python_mobius-0.8.1/tests/test_pump_reverse.py +52 -0
  9. {python_mobius-0.8.0 → python_mobius-0.8.1}/.forgejo/scripts/extract_changelog_section.py +0 -0
  10. {python_mobius-0.8.0 → python_mobius-0.8.1}/.forgejo/workflows/release.yml +0 -0
  11. {python_mobius-0.8.0 → python_mobius-0.8.1}/.gitignore +0 -0
  12. {python_mobius-0.8.0 → python_mobius-0.8.1}/LICENSE +0 -0
  13. {python_mobius-0.8.0 → python_mobius-0.8.1}/README.md +0 -0
  14. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/00-overview.md +0 -0
  15. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/01-ble-transport.md +0 -0
  16. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/02-framing-and-crc.md +0 -0
  17. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/03-attributes-and-opcodes.md +0 -0
  18. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/04-device-identity.md +0 -0
  19. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/05-scenes.md +0 -0
  20. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/06-light-schedule.md +0 -0
  21. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/07-pump-schedule.md +0 -0
  22. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/08-manufacturer-data.md +0 -0
  23. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/09-thread-coap-relay.md +0 -0
  24. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/10-known-gaps-and-open-questions.md +0 -0
  25. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/11-device-status-attributes.md +0 -0
  26. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/12-device-identity-and-address-stability.md +0 -0
  27. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/13-attribute-dump.md +0 -0
  28. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/14-batched-attribute-reads.md +0 -0
  29. {python_mobius-0.8.0 → python_mobius-0.8.1}/documentation/15-mob-template-format.md +0 -0
  30. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/cli.py +0 -0
  31. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/coap.py +0 -0
  32. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/crc.py +0 -0
  33. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/device_status.py +0 -0
  34. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/discovery.py +0 -0
  35. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/dump.py +0 -0
  36. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/frame.py +0 -0
  37. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/manufacturer.py +0 -0
  38. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/mesh_address.py +0 -0
  39. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/mob.py +0 -0
  40. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/modifiers.py +0 -0
  41. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/power.py +0 -0
  42. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/pump_status.py +0 -0
  43. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/relay.py +0 -0
  44. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/scenes.py +0 -0
  45. {python_mobius-0.8.0 → python_mobius-0.8.1}/src/mobius/schedule.py +0 -0
  46. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_attributes_batch.py +0 -0
  47. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_cli_group_targets.py +0 -0
  48. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_cli_mob_file.py +0 -0
  49. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_cli_schedule_file.py +0 -0
  50. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_coap.py +0 -0
  51. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_coap_indication_handling.py +0 -0
  52. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_connection_cleanup.py +0 -0
  53. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_device_status.py +0 -0
  54. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_device_summary.py +0 -0
  55. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_device_time.py +0 -0
  56. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_discovery_company_ids.py +0 -0
  57. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_dump.py +0 -0
  58. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_extra_physical_values.py +0 -0
  59. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_fan_thresholds.py +0 -0
  60. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_firmware_versions.py +0 -0
  61. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_frame.py +0 -0
  62. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_frame_sniffer.py +0 -0
  63. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_full_poll_batch.py +0 -0
  64. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_group_bitmap.py +0 -0
  65. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_hyperdrive_device.py +0 -0
  66. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_insolation_detection.py +0 -0
  67. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_light_intensity_branching.py +0 -0
  68. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_light_poll_batch.py +0 -0
  69. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_light_schedule.py +0 -0
  70. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_manufacturer.py +0 -0
  71. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_manufacturer_lookup.py +0 -0
  72. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_mesh_address.py +0 -0
  73. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_mesh_discovery.py +0 -0
  74. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_mesh_discovery_direct_connect.py +0 -0
  75. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_metadata_batch.py +0 -0
  76. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_mob.py +0 -0
  77. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_modifiers.py +0 -0
  78. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_networked_thread_devices.py +0 -0
  79. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_new_diagnostic_attributes.py +0 -0
  80. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_power.py +0 -0
  81. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_pump_schedule.py +0 -0
  82. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_pump_status.py +0 -0
  83. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_pump_supported_modes.py +0 -0
  84. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_pump_telemetry.py +0 -0
  85. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_relay.py +0 -0
  86. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_scenes.py +0 -0
  87. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_schedule_file_format.py +0 -0
  88. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_schedule_intensity_scalar.py +0 -0
  89. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_serial_decoding.py +0 -0
  90. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_serial_identity.py +0 -0
  91. {python_mobius-0.8.0 → python_mobius-0.8.1}/tests/test_supported_attributes.py +0 -0
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1
4
+
5
+ - Added `get_pump_reverse(primitive_type, params)`: whether a pump
6
+ schedule point's MaxSpeed/MinSpeed value currently encodes reverse
7
+ rotation. Only AlpacaV1 pumps support this -- every other primitive
8
+ type always returns None.
9
+ - Added `reboot_all()`: soft-reboots every device on the mesh at
10
+ once, matching the app's own "Restart all Devices" tank setting.
11
+
3
12
  ## 0.8.0
4
13
 
5
14
  - Added `supported_pump_modes(primitive_type, closed_loop=None)`: the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.8.0
3
+ Version: 0.8.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.8.0"
7
+ version = "0.8.1"
8
8
  description = "Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)"
9
9
  readme = "README.md"
10
10
  license = { text = "GPL-2.0-only" }
@@ -28,6 +28,7 @@ from .constants import (
28
28
  SceneID, OperationState, FsciStatus,
29
29
  PumpMode, PumpOverrideMode, RampType, PumpParam, PUMP_PARAM_SIZE, PUMP_MODE_PARAMS,
30
30
  supported_pump_modes,
31
+ get_pump_reverse,
31
32
  FirmwareType, FIRMWARE_TYPE_LABELS_ETM, HardwareInfo,
32
33
  Color, COLOR_LABELS, RadioType, RADIO_TYPE_LABELS,
33
34
  MotorType, MOTOR_TYPE_LABELS, ProductType, PRODUCT_TYPE_LABELS,
@@ -81,7 +82,7 @@ from .mob import (
81
82
  MAX_PUMP_PRIMITIVE_SIZE,
82
83
  )
83
84
 
84
- __version__ = "0.8.0"
85
+ __version__ = "0.8.1"
85
86
 
86
87
  __all__ = [
87
88
  "__version__",
@@ -103,6 +104,7 @@ __all__ = [
103
104
  "SceneID", "OperationState", "FsciStatus",
104
105
  "PumpMode", "PumpOverrideMode", "RampType", "PumpParam", "PUMP_PARAM_SIZE", "PUMP_MODE_PARAMS",
105
106
  "supported_pump_modes",
107
+ "get_pump_reverse",
106
108
  "FirmwareType", "FIRMWARE_TYPE_LABELS_ETM", "HardwareInfo",
107
109
  "Color", "COLOR_LABELS", "RadioType", "RADIO_TYPE_LABELS",
108
110
  "MotorType", "MOTOR_TYPE_LABELS", "ProductType", "PRODUCT_TYPE_LABELS",
@@ -819,6 +819,35 @@ def supported_pump_modes(
819
819
  return _PUMP_SUPPORTED_MODES.get(primitive_type, [])
820
820
 
821
821
 
822
+ def get_pump_reverse(primitive_type: PrimitiveType, params: dict[PumpParam, int]) -> bool | None:
823
+ """Whether this point's own MaxSpeed/MinSpeed value currently
824
+ encodes reverse rotation -- ported from the decompiled app's own
825
+ PumpPrimitive.getReverse(), which checks each of a primitive's own
826
+ params for one whose slider settings say supportsReverse, then
827
+ reports whether that param's raw (unabs'd) value is negative.
828
+
829
+ Confirmed directly from the app's own PumpParameterEntry.sliderSettings():
830
+ MaxSpeed and MinSpeed both only ever set supportsReverse=true when
831
+ primitive_type is AlpacaV1 -- every other primitive type
832
+ (VorTechV1, PumpV1, TurtleV1, CoffeeV1, VectraV1) never supports
833
+ reverse at all, regardless of mode or the value's own sign. This
834
+ always returns None for those, same as the app's own getReverse()
835
+ falling through to null when no param on the primitive supports
836
+ it.
837
+
838
+ parse()/schedule_to_dict() already report MaxSpeed/MinSpeed as
839
+ signed ints as-is (see PumpPrimitiveValue.parse()'s own comment),
840
+ so this just checks their sign -- it doesn't change what those
841
+ functions themselves return.
842
+ """
843
+ if primitive_type != PrimitiveType.AlpacaV1:
844
+ return None
845
+ for param in (PumpParam.MaxSpeed, PumpParam.MinSpeed):
846
+ if param in params:
847
+ return params[param] < 0
848
+ return None
849
+
850
+
822
851
  class RampType(IntEnum):
823
852
  Sinusoidal = 1
824
853
  Logarithmic = 2
@@ -1296,6 +1296,28 @@ class MobiusDevice:
1296
1296
  """
1297
1297
  await self.set_attribute(C2Attribute.Reset, bytes([ResetType.Soft]))
1298
1298
 
1299
+ async def reboot_all(self) -> None:
1300
+ """
1301
+ Soft-reboots every device on this mesh at once -- what the
1302
+ app's own "Restart all Devices" (tank settings screen) does.
1303
+
1304
+ Confirmed from the app's own SettingsFragment.reboot(): this
1305
+ is NOT a loop of individual reboot() calls against every
1306
+ device. It's the exact same single Set as reboot() (Reset=Soft),
1307
+ just with the frame's own reserved-byte "group" field set to 1
1308
+ (see make_reserved()) instead of left at 0 -- one write to
1309
+ this device, propagated to the rest of the mesh the same way
1310
+ set_time_to_now()/start_scene(broadcast=True) already do.
1311
+ group=1 specifically (not the app's own client-side, per-tank
1312
+ group number, which isn't something this library has any way
1313
+ to discover) matches set_time_to_now()'s own confirmed-working
1314
+ value, and the same real-hardware caveats apply here: not
1315
+ guaranteed reliable across every mesh topology or under
1316
+ real-world conditions where a peer is briefly unreachable when
1317
+ the write's own propagation happens.
1318
+ """
1319
+ await self.set_attribute(C2Attribute.Reset, bytes([ResetType.Soft]), reserved=make_reserved(group=1))
1320
+
1299
1321
  # ---- scenes -----------------------------------------------------------
1300
1322
 
1301
1323
  async def start_scene(self, scene_id: int, duration_seconds: int = 0, broadcast: bool = True) -> None:
@@ -1,9 +1,10 @@
1
1
  """
2
- Tests for device control write operations -- currently just reboot().
3
- Confirmed wire format (attribute ID, single-byte value, no reserved-byte
4
- group involved) via the app's own source, which sends exactly this for
5
- its own "Restart" button. NOT confirmed: this specific write against real
6
- hardware, direct or relayed -- see reboot()'s own docstring.
2
+ Tests for device control write operations -- reboot() and
3
+ reboot_all(). Confirmed wire format via the app's own source, which
4
+ sends exactly this for its own "Restart" button and "Restart all
5
+ Devices" tank setting respectively. NOT confirmed: these specific
6
+ writes against real hardware, direct or relayed -- see each method's
7
+ own docstring.
7
8
  """
8
9
 
9
10
  import pytest
@@ -63,6 +64,49 @@ async def test_reboot_propagates_a_write_failure():
63
64
  await device.reboot()
64
65
 
65
66
 
67
+ @pytest.mark.asyncio
68
+ async def test_reboot_all_writes_reset_soft_with_group_1_reserved_byte():
69
+ """Confirmed via the app's own SettingsFragment.reboot() (the
70
+ "Restart all Devices" tank-settings option): the exact same Reset
71
+ Set as reboot(), but with the frame's own reserved-byte group
72
+ field set to 1 instead of left ungrouped -- one write to this
73
+ device propagates to the rest of the mesh, matching
74
+ set_time_to_now()'s own already-confirmed group=1 behavior."""
75
+ device = MobiusDevice(_fake_ble_device())
76
+ captured = {}
77
+
78
+ async def fake_set_attribute(attr_id, value, index=0, reserved=b"\x00\x00"):
79
+ captured["attr_id"] = attr_id
80
+ captured["value"] = value
81
+ captured["reserved"] = reserved
82
+
83
+ device.set_attribute = fake_set_attribute
84
+ await device.reboot_all()
85
+
86
+ assert captured["attr_id"] == C2Attribute.Reset
87
+ assert captured["value"] == bytes([ResetType.Soft])
88
+ assert captured["reserved"] == make_reserved(group=1)
89
+ # Distinct from reboot()'s own ungrouped reserved bytes -- this is
90
+ # the whole point of the two methods being separate.
91
+ assert captured["reserved"] != b"\x00\x00"
92
+
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_reboot_all_propagates_a_write_failure():
96
+ """Same reasoning as reboot()'s own equivalent test -- a caller
97
+ acting on a silently-swallowed failure here would wrongly believe
98
+ every device on the mesh actually rebooted."""
99
+ device = MobiusDevice(_fake_ble_device())
100
+
101
+ async def fake_set_attribute(attr_id, value, index=0, reserved=b"\x00\x00"):
102
+ raise IOError("device returned FSCI status Failed setting attribute 6")
103
+
104
+ device.set_attribute = fake_set_attribute
105
+
106
+ with pytest.raises(IOError, match="Failed setting attribute 6"):
107
+ await device.reboot_all()
108
+
109
+
66
110
  # --------------------------------------------------------------------------
67
111
  # set_attribute_multi() -- writing several elements of one attribute in
68
112
  # a single request (used by set_light_schedule()/set_pump_schedule()).
@@ -0,0 +1,52 @@
1
+ """
2
+ Tests for get_pump_reverse() -- ported from the decompiled app's own
3
+ PumpPrimitive.getReverse(). Confirmed against the app's own
4
+ PumpParameterEntry.sliderSettings(): MaxSpeed and MinSpeed only ever
5
+ set supportsReverse=true when primitive_type is AlpacaV1 -- every
6
+ other primitive type never supports reverse rotation at all,
7
+ regardless of mode or the value's own sign.
8
+ """
9
+
10
+ from mobius import PrimitiveType, PumpParam, get_pump_reverse
11
+
12
+
13
+ def test_alpacav1_negative_max_speed_is_reverse():
14
+ assert get_pump_reverse(PrimitiveType.AlpacaV1, {PumpParam.MaxSpeed: -420}) is True
15
+
16
+
17
+ def test_alpacav1_positive_max_speed_is_not_reverse():
18
+ assert get_pump_reverse(PrimitiveType.AlpacaV1, {PumpParam.MaxSpeed: 420}) is False
19
+
20
+
21
+ def test_alpacav1_negative_min_speed_is_reverse():
22
+ assert get_pump_reverse(PrimitiveType.AlpacaV1, {PumpParam.MinSpeed: -100}) is True
23
+
24
+
25
+ def test_alpacav1_checks_max_speed_before_min_speed_when_both_present():
26
+ # Matches the app's own getReverse() -- it stops at the first
27
+ # param on the primitive whose slider settings support reverse,
28
+ # which for AlpacaV1 is MaxSpeed before MinSpeed in params() order.
29
+ assert get_pump_reverse(PrimitiveType.AlpacaV1, {PumpParam.MaxSpeed: -420, PumpParam.MinSpeed: 100}) is True
30
+
31
+
32
+ def test_non_alpaca_primitive_types_never_support_reverse_even_with_a_negative_value():
33
+ """The genuinely important case -- confirmed straight from the
34
+ app's own sliderSettings(): a negative value on any other
35
+ primitive type isn't a real, meaningful reverse-rotation signal,
36
+ since the concept doesn't apply there at all."""
37
+ for primitive in (
38
+ PrimitiveType.VorTechV1,
39
+ PrimitiveType.PumpV1,
40
+ PrimitiveType.TurtleV1,
41
+ PrimitiveType.CoffeeV1,
42
+ PrimitiveType.VectraV1,
43
+ ):
44
+ assert get_pump_reverse(primitive, {PumpParam.MaxSpeed: -420}) is None
45
+
46
+
47
+ def test_returns_none_when_neither_speed_param_is_present():
48
+ assert get_pump_reverse(PrimitiveType.AlpacaV1, {PumpParam.PhaseShift: 180}) is None
49
+
50
+
51
+ def test_returns_none_for_an_empty_params_dict():
52
+ assert get_pump_reverse(PrimitiveType.AlpacaV1, {}) is None
File without changes
File without changes
File without changes