python-mobius 0.8.0__tar.gz → 0.8.2__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.
- {python_mobius-0.8.0 → python_mobius-0.8.2}/CHANGELOG.md +18 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/PKG-INFO +1 -1
- {python_mobius-0.8.0 → python_mobius-0.8.2}/pyproject.toml +1 -1
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/__init__.py +3 -1
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/constants.py +29 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/device.py +48 -2
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_device_control.py +49 -5
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_light_poll_batch.py +21 -2
- python_mobius-0.8.2/tests/test_pump_reverse.py +52 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/.forgejo/scripts/extract_changelog_section.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/.forgejo/workflows/release.yml +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/.gitignore +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/LICENSE +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/README.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/00-overview.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/05-scenes.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/06-light-schedule.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/09-thread-coap-relay.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/10-known-gaps-and-open-questions.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/11-device-status-attributes.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/12-device-identity-and-address-stability.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/13-attribute-dump.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/14-batched-attribute-reads.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/15-mob-template-format.md +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/cli.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/coap.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/crc.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/device_status.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/discovery.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/dump.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/frame.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/mesh_address.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/mob.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/modifiers.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/power.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/pump_status.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/relay.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/scenes.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/src/mobius/schedule.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_attributes_batch.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_cli_group_targets.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_cli_mob_file.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_cli_schedule_file.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_coap.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_coap_indication_handling.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_connection_cleanup.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_device_status.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_device_summary.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_device_time.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_discovery_company_ids.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_dump.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_extra_physical_values.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_fan_thresholds.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_firmware_versions.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_frame.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_frame_sniffer.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_full_poll_batch.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_group_bitmap.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_hyperdrive_device.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_insolation_detection.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_light_intensity_branching.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_light_schedule.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_manufacturer.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_manufacturer_lookup.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_mesh_address.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_mesh_discovery.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_mesh_discovery_direct_connect.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_metadata_batch.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_mob.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_modifiers.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_networked_thread_devices.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_new_diagnostic_attributes.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_power.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_pump_schedule.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_pump_status.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_pump_supported_modes.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_pump_telemetry.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_relay.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_scenes.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_schedule_file_format.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_schedule_intensity_scalar.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_serial_decoding.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_serial_identity.py +0 -0
- {python_mobius-0.8.0 → python_mobius-0.8.2}/tests/test_supported_attributes.py +0 -0
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.2
|
|
4
|
+
|
|
5
|
+
- Added `LightPollResult.schedule_intensity`: the device's own raw
|
|
6
|
+
schedule-level master dimmer, matching `get_schedule_intensity()`'s
|
|
7
|
+
own return value. Not the same as `intensities.diagnostics["scalar"]`,
|
|
8
|
+
which reflects whichever value is currently in effect (the lunar
|
|
9
|
+
reduction factor during a night segment, not the schedule intensity
|
|
10
|
+
setting itself).
|
|
11
|
+
|
|
12
|
+
## 0.8.1
|
|
13
|
+
|
|
14
|
+
- Added `get_pump_reverse(primitive_type, params)`: whether a pump
|
|
15
|
+
schedule point's MaxSpeed/MinSpeed value currently encodes reverse
|
|
16
|
+
rotation. Only AlpacaV1 pumps support this -- every other primitive
|
|
17
|
+
type always returns None.
|
|
18
|
+
- Added `reboot_all()`: soft-reboots every device on the mesh at
|
|
19
|
+
once, matching the app's own "Restart all Devices" tank setting.
|
|
20
|
+
|
|
3
21
|
## 0.8.0
|
|
4
22
|
|
|
5
23
|
- 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.
|
|
3
|
+
Version: 0.8.2
|
|
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.
|
|
7
|
+
version = "0.8.2"
|
|
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.
|
|
85
|
+
__version__ = "0.8.2"
|
|
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
|
|
@@ -145,6 +145,24 @@ class LightPollResult:
|
|
|
145
145
|
schedule_points: list[SchedulePoint]
|
|
146
146
|
intensities: LightIntensityResult
|
|
147
147
|
used_batch: bool = True
|
|
148
|
+
# The same raw 0.0-1.0 schedule-level master dimmer
|
|
149
|
+
# get_schedule_intensity() itself returns -- already fetched and
|
|
150
|
+
# consumed internally by both this method and the individual-reads
|
|
151
|
+
# fallback below (it's one of process_light_intensities()'s own
|
|
152
|
+
# required inputs), but never actually propagated out to the
|
|
153
|
+
# caller until this field existed. NOT the same thing as
|
|
154
|
+
# intensities.diagnostics["scalar"]: that reflects whichever value
|
|
155
|
+
# is CURRENTLY driving the effective per-channel output at this
|
|
156
|
+
# exact moment (which, during a lunar-reduced night segment, is
|
|
157
|
+
# the lunar reduction factor instead) -- this field is always the
|
|
158
|
+
# device's own raw schedule-level intensity setting itself,
|
|
159
|
+
# regardless of whether it's the thing actually in effect right
|
|
160
|
+
# now. A caller wanting "what would the user's own intensity
|
|
161
|
+
# slider currently show" wants this field specifically, not
|
|
162
|
+
# diagnostics["scalar"]. None only if the underlying read itself
|
|
163
|
+
# failed and decode_schedule_intensity() had truly nothing to fall
|
|
164
|
+
# back to.
|
|
165
|
+
schedule_intensity: Optional[float] = None
|
|
148
166
|
|
|
149
167
|
|
|
150
168
|
@dataclass
|
|
@@ -545,7 +563,9 @@ def decode_light_poll_from_batch(
|
|
|
545
563
|
lunar_enabled, lunar_date, lunar_date_source,
|
|
546
564
|
schedule_intensity, acclimation_info, insolation_active,
|
|
547
565
|
)
|
|
548
|
-
return LightPollResult(
|
|
566
|
+
return LightPollResult(
|
|
567
|
+
schedule_points=points, intensities=intensities, used_batch=True, schedule_intensity=schedule_intensity,
|
|
568
|
+
)
|
|
549
569
|
|
|
550
570
|
|
|
551
571
|
def decode_pump_flow_range(min_raw: list[bytes], max_raw: list[bytes]) -> Optional[PumpFlowRange]:
|
|
@@ -1296,6 +1316,28 @@ class MobiusDevice:
|
|
|
1296
1316
|
"""
|
|
1297
1317
|
await self.set_attribute(C2Attribute.Reset, bytes([ResetType.Soft]))
|
|
1298
1318
|
|
|
1319
|
+
async def reboot_all(self) -> None:
|
|
1320
|
+
"""
|
|
1321
|
+
Soft-reboots every device on this mesh at once -- what the
|
|
1322
|
+
app's own "Restart all Devices" (tank settings screen) does.
|
|
1323
|
+
|
|
1324
|
+
Confirmed from the app's own SettingsFragment.reboot(): this
|
|
1325
|
+
is NOT a loop of individual reboot() calls against every
|
|
1326
|
+
device. It's the exact same single Set as reboot() (Reset=Soft),
|
|
1327
|
+
just with the frame's own reserved-byte "group" field set to 1
|
|
1328
|
+
(see make_reserved()) instead of left at 0 -- one write to
|
|
1329
|
+
this device, propagated to the rest of the mesh the same way
|
|
1330
|
+
set_time_to_now()/start_scene(broadcast=True) already do.
|
|
1331
|
+
group=1 specifically (not the app's own client-side, per-tank
|
|
1332
|
+
group number, which isn't something this library has any way
|
|
1333
|
+
to discover) matches set_time_to_now()'s own confirmed-working
|
|
1334
|
+
value, and the same real-hardware caveats apply here: not
|
|
1335
|
+
guaranteed reliable across every mesh topology or under
|
|
1336
|
+
real-world conditions where a peer is briefly unreachable when
|
|
1337
|
+
the write's own propagation happens.
|
|
1338
|
+
"""
|
|
1339
|
+
await self.set_attribute(C2Attribute.Reset, bytes([ResetType.Soft]), reserved=make_reserved(group=1))
|
|
1340
|
+
|
|
1299
1341
|
# ---- scenes -----------------------------------------------------------
|
|
1300
1342
|
|
|
1301
1343
|
async def start_scene(self, scene_id: int, duration_seconds: int = 0, broadcast: bool = True) -> None:
|
|
@@ -3768,7 +3810,11 @@ class MobiusDevice:
|
|
|
3768
3810
|
"""
|
|
3769
3811
|
points = await self.get_light_schedule(which)
|
|
3770
3812
|
intensities = await self.get_current_light_intensities(which, minute_of_day, now)
|
|
3771
|
-
|
|
3813
|
+
schedule_intensity = await self.get_schedule_intensity(which)
|
|
3814
|
+
return LightPollResult(
|
|
3815
|
+
schedule_points=points, intensities=intensities, used_batch=False,
|
|
3816
|
+
schedule_intensity=schedule_intensity,
|
|
3817
|
+
)
|
|
3772
3818
|
|
|
3773
3819
|
async def get_full_poll_batch(
|
|
3774
3820
|
self, primitive: PrimitiveType, model: Optional[Model] = None,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Tests for device control write operations --
|
|
3
|
-
Confirmed wire format
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
hardware, direct or relayed -- see
|
|
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()).
|
|
@@ -94,13 +94,23 @@ async def test_filters_request_to_only_supported_attributes():
|
|
|
94
94
|
async def test_night_segment_with_lunar_matches_individual_path_scenario():
|
|
95
95
|
"""Mirrors test_night_segment_with_lunar_enabled_uses_lunar_reduction()
|
|
96
96
|
in test_light_intensity_branching.py exactly, but via the batch
|
|
97
|
-
path -- same schedule, same time, same expected result.
|
|
97
|
+
path -- same schedule, same time, same expected result.
|
|
98
|
+
|
|
99
|
+
Also the exact regression scenario for a real, confirmed
|
|
100
|
+
production bug: a caller reading intensities.diagnostics["scalar"]
|
|
101
|
+
as if it were always schedule_intensity itself would get this
|
|
102
|
+
exactly backwards during a lunar-reduced night segment, since
|
|
103
|
+
"scalar" here reflects the lunar reduction factor currently in
|
|
104
|
+
effect, not the device's own raw schedule-level intensity setting
|
|
105
|
+
-- which is what result.schedule_intensity (this field) is
|
|
106
|
+
specifically for."""
|
|
98
107
|
device = MobiusDevice(_fake_ble_device())
|
|
99
108
|
|
|
100
109
|
async def fake_get_attributes_batch(requests):
|
|
101
110
|
return [
|
|
102
111
|
_av(C2Attribute.Schedule1, 0, *_night_schedule()),
|
|
103
112
|
_av(C2Attribute.LunarPhasesEnabled, 0, bytes([1])),
|
|
113
|
+
_av(C2Attribute.Schedule1Intensity, 0, bytes.fromhex("8103")), # 0x0381 = 897 -> 0.897
|
|
104
114
|
# Epoch/LocalTime deliberately absent -- same as that test's
|
|
105
115
|
# own LocalTime=None, forcing the local_fallback path.
|
|
106
116
|
]
|
|
@@ -108,7 +118,8 @@ async def test_night_segment_with_lunar_matches_individual_path_scenario():
|
|
|
108
118
|
device.get_attributes_batch = fake_get_attributes_batch
|
|
109
119
|
now = datetime.datetime(2026, 1, 18, 20, 0)
|
|
110
120
|
result = await device.get_light_poll_batch(
|
|
111
|
-
minute_of_day=1350, now=now,
|
|
121
|
+
minute_of_day=1350, now=now,
|
|
122
|
+
supported_attribute_ids={C2Attribute.Schedule1, C2Attribute.LunarPhasesEnabled, C2Attribute.Schedule1Intensity},
|
|
112
123
|
)
|
|
113
124
|
|
|
114
125
|
assert result.used_batch is True
|
|
@@ -120,6 +131,12 @@ async def test_night_segment_with_lunar_matches_individual_path_scenario():
|
|
|
120
131
|
assert intensities.diagnostics["scalar_source"] == "lunar"
|
|
121
132
|
assert intensities.diagnostics["lunar_date_source"] == "local_fallback"
|
|
122
133
|
assert intensities.diagnostics["lunar_date"] == now.date()
|
|
134
|
+
# The actual regression: schedule_intensity is the device's own raw
|
|
135
|
+
# 0.897 setting, genuinely different from whatever lunar-derived
|
|
136
|
+
# value diagnostics["scalar"] holds during this segment -- these
|
|
137
|
+
# must never be conflated.
|
|
138
|
+
assert result.schedule_intensity == pytest.approx(0.897)
|
|
139
|
+
assert result.schedule_intensity != intensities.diagnostics["scalar"]
|
|
123
140
|
|
|
124
141
|
|
|
125
142
|
@pytest.mark.asyncio
|
|
@@ -147,6 +164,7 @@ async def test_daytime_matches_individual_path_scenario():
|
|
|
147
164
|
assert intensities.diagnostics["is_night_segment"] is False
|
|
148
165
|
assert intensities.diagnostics["scalar_source"] == "schedule_intensity"
|
|
149
166
|
assert intensities.diagnostics["scalar"] == pytest.approx(0.808)
|
|
167
|
+
assert result.schedule_intensity == pytest.approx(0.808)
|
|
150
168
|
# RoyalBlue=500 raw * 0.808 scalar
|
|
151
169
|
assert intensities[VisualID.RoyalBlue] == pytest.approx(500 * 0.808)
|
|
152
170
|
|
|
@@ -180,6 +198,7 @@ async def test_batch_failure_falls_back_and_used_batch_is_false():
|
|
|
180
198
|
# get_schedule_intensity()'s own 0.5 default, since Schedule1Intensity
|
|
181
199
|
# itself returns None from the fake get_attribute() above.
|
|
182
200
|
assert result.intensities.diagnostics["scalar"] == pytest.approx(0.5)
|
|
201
|
+
assert result.schedule_intensity == pytest.approx(0.5)
|
|
183
202
|
|
|
184
203
|
|
|
185
204
|
@pytest.mark.asyncio
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_mobius-0.8.0 → python_mobius-0.8.2}/documentation/10-known-gaps-and-open-questions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|