python-mobius 0.7.0__tar.gz → 0.7.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.
- {python_mobius-0.7.0 → python_mobius-0.7.1}/CHANGELOG.md +7 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/PKG-INFO +1 -1
- {python_mobius-0.7.0 → python_mobius-0.7.1}/pyproject.toml +1 -1
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/__init__.py +4 -1
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/cli.py +46 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/constants.py +1 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/device.py +30 -9
- python_mobius-0.7.1/src/mobius/scenes.py +97 -0
- python_mobius-0.7.1/tests/test_scenes.py +203 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/.forgejo/scripts/extract_changelog_section.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/.forgejo/workflows/release.yml +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/.gitignore +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/LICENSE +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/README.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/00-overview.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/05-scenes.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/06-light-schedule.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/09-thread-coap-relay.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/10-known-gaps-and-open-questions.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/11-device-status-attributes.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/12-device-identity-and-address-stability.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/13-attribute-dump.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/14-batched-attribute-reads.md +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/coap.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/crc.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/device_status.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/discovery.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/dump.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/frame.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/mesh_address.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/modifiers.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/power.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/pump_status.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/relay.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/schedule.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_attributes_batch.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_coap.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_coap_indication_handling.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_connection_cleanup.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_device_control.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_device_status.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_device_summary.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_device_time.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_discovery_company_ids.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_dump.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_firmware_versions.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_frame.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_frame_sniffer.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_full_poll_batch.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_hyperdrive_device.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_insolation_detection.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_light_intensity_branching.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_light_poll_batch.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_light_schedule.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_manufacturer.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_manufacturer_lookup.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_mesh_address.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_mesh_discovery.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_mesh_discovery_direct_connect.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_metadata_batch.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_modifiers.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_networked_thread_devices.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_new_diagnostic_attributes.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_power.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_pump_schedule.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_pump_status.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_pump_telemetry.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_relay.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_schedule_intensity_scalar.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_serial_decoding.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_serial_identity.py +0 -0
- {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_supported_attributes.py +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.1
|
|
4
|
+
|
|
5
|
+
- Added scene support: get_configured_scenes(), get_current_scene(),
|
|
6
|
+
start_scene(). Fixed get_current_scene() crashing on a custom
|
|
7
|
+
(non-preset) scene, and start_scene()'s duration being in seconds,
|
|
8
|
+
not minutes as previously labeled.
|
|
9
|
+
|
|
3
10
|
## 0.7.0
|
|
4
11
|
|
|
5
12
|
- Added get_full_poll_batch() -- one round-trip for device identity +
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-mobius
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.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.7.
|
|
7
|
+
version = "0.7.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" }
|
|
@@ -35,6 +35,7 @@ from .schedule import (
|
|
|
35
35
|
LightPrimitive, SchedulePoint, interpolate_light_schedule,
|
|
36
36
|
PumpPrimitiveValue, PumpSchedulePoint, get_active_pump_block,
|
|
37
37
|
)
|
|
38
|
+
from .scenes import Scene, ActiveScene, decode_configured_scenes, decode_active_scene
|
|
38
39
|
from .manufacturer import MOBIUS_COMPANY_ID_ECOTECH, MOBIUS_COMPANY_ID_AQUAILLUMINATION, MOBIUS_COMPANY_IDS, MobiusAdvertisement, parse_manufacturer_data
|
|
39
40
|
from .modifiers import (
|
|
40
41
|
AcclimationInfo, is_night_segment,
|
|
@@ -71,7 +72,7 @@ from .dump import (
|
|
|
71
72
|
enrich_attribute_dump,
|
|
72
73
|
)
|
|
73
74
|
|
|
74
|
-
__version__ = "0.7.
|
|
75
|
+
__version__ = "0.7.1"
|
|
75
76
|
|
|
76
77
|
__all__ = [
|
|
77
78
|
"__version__",
|
|
@@ -98,6 +99,8 @@ __all__ = [
|
|
|
98
99
|
# schedule
|
|
99
100
|
"LightPrimitive", "SchedulePoint", "interpolate_light_schedule",
|
|
100
101
|
"PumpPrimitiveValue", "PumpSchedulePoint", "get_active_pump_block",
|
|
102
|
+
# scenes
|
|
103
|
+
"Scene", "ActiveScene", "decode_configured_scenes", "decode_active_scene",
|
|
101
104
|
# manufacturer
|
|
102
105
|
"MOBIUS_COMPANY_ID_ECOTECH", "MOBIUS_COMPANY_ID_AQUAILLUMINATION", "MOBIUS_COMPANY_IDS", "MobiusAdvertisement", "parse_manufacturer_data",
|
|
103
106
|
# modifiers
|
|
@@ -18,6 +18,7 @@ from .device import MobiusDevice
|
|
|
18
18
|
from .relay import RelayedMobiusDevice
|
|
19
19
|
from .device_status import MeshPeer
|
|
20
20
|
from .mesh_address import extract_short_address
|
|
21
|
+
from .constants import PrimitiveType, SceneID
|
|
21
22
|
from .discovery import (
|
|
22
23
|
scan_for_mobius_devices_with_info, group_by_pan_id, dedupe_by_serial,
|
|
23
24
|
find_device_by_serial, discover_mesh_peers_via_direct_connect, discover_tank,
|
|
@@ -99,6 +100,37 @@ async def _dump_schedule(device: MobiusDevice, support: str) -> None:
|
|
|
99
100
|
print(f" (schedule dumping not supported for support={support!r})")
|
|
100
101
|
|
|
101
102
|
|
|
103
|
+
async def _dump_scenes(device: MobiusDevice, primitive: PrimitiveType | None) -> None:
|
|
104
|
+
"""Prints every configured scene, at whatever slot it lives in,
|
|
105
|
+
including empty/unused slots. The currently active scene is
|
|
106
|
+
already shown by the summary above (info["current_scene"])."""
|
|
107
|
+
print(" --- configured scenes ---")
|
|
108
|
+
scenes = await device.get_configured_scenes(primitive=primitive)
|
|
109
|
+
if not scenes:
|
|
110
|
+
print(" (none)")
|
|
111
|
+
return
|
|
112
|
+
for scene in scenes:
|
|
113
|
+
if scene.scene_type == SceneID.EmptyScene and not scene.name:
|
|
114
|
+
print(f" [{scene.index}] (empty)")
|
|
115
|
+
continue
|
|
116
|
+
type_label = scene.scene_type.name if scene.scene_type else "custom"
|
|
117
|
+
if scene.light is not None:
|
|
118
|
+
payload = ", ".join(
|
|
119
|
+
f"{vid.name}={val}"
|
|
120
|
+
for vid, val in sorted(scene.light.channels.items(), key=lambda x: x[0].name)
|
|
121
|
+
)
|
|
122
|
+
elif scene.pump is not None:
|
|
123
|
+
params = ", ".join(
|
|
124
|
+
f"{p.name}={v.hex() if isinstance(v, bytes) else (v.name if hasattr(v, 'name') else v)}"
|
|
125
|
+
for p, v in scene.pump.params.items()
|
|
126
|
+
)
|
|
127
|
+
payload = f"mode={scene.pump.mode.name} {params}"
|
|
128
|
+
else:
|
|
129
|
+
payload = "(no primitive data decoded)"
|
|
130
|
+
print(f" [{scene.index}] id={scene.id} ({type_label}) name={scene.name!r} "
|
|
131
|
+
f"timeout={scene.timeout}s: {payload}")
|
|
132
|
+
|
|
133
|
+
|
|
102
134
|
async def _debug_mesh_discovery(device) -> list:
|
|
103
135
|
"""
|
|
104
136
|
Verbose, step-by-step version of discover_mesh_peers_auto() for CLI
|
|
@@ -273,6 +305,10 @@ async def _run(args) -> None:
|
|
|
273
305
|
print(f" {k}: {v}")
|
|
274
306
|
if args.dump_schedule:
|
|
275
307
|
await _dump_schedule(mdevice, summary.get("support", ""))
|
|
308
|
+
if args.dump_scenes:
|
|
309
|
+
primitive_name = summary.get("primitive_type")
|
|
310
|
+
primitive = PrimitiveType[primitive_name] if primitive_name else None
|
|
311
|
+
await _dump_scenes(mdevice, primitive)
|
|
276
312
|
|
|
277
313
|
relayed = None # set below only if --relay-target resolves successfully
|
|
278
314
|
if args.dump_mesh_peers or args.relay_target:
|
|
@@ -620,6 +656,10 @@ async def _run(args) -> None:
|
|
|
620
656
|
print(f" {k}: {v}")
|
|
621
657
|
if args.dump_schedule:
|
|
622
658
|
await _dump_schedule(mdevice, summary.get("support", ""))
|
|
659
|
+
if args.dump_scenes:
|
|
660
|
+
primitive_name = summary.get("primitive_type")
|
|
661
|
+
primitive = PrimitiveType[primitive_name] if primitive_name else None
|
|
662
|
+
await _dump_scenes(mdevice, primitive)
|
|
623
663
|
except Exception as e:
|
|
624
664
|
print(" failed to connect/read:", e)
|
|
625
665
|
|
|
@@ -667,6 +707,12 @@ def main() -> None:
|
|
|
667
707
|
"hardware -- see LunarPhaseInfo's own docstring) -- compare against "
|
|
668
708
|
"\"current_intensities\"/\"light_diagnostics\" in the summary output "
|
|
669
709
|
"above for this library's own calculated value.")
|
|
710
|
+
parser.add_argument("--dump-scenes", action="store_true",
|
|
711
|
+
help="Also print every configured scene on each connected device -- "
|
|
712
|
+
"id, name, timeout, and its own light/pump payload -- at whatever "
|
|
713
|
+
"slot it lives in, including empty/unused slots. The currently "
|
|
714
|
+
"active scene is already shown by the summary above "
|
|
715
|
+
"(\"current_scene\").")
|
|
670
716
|
parser.add_argument("--dump-mesh-peers", action="store_true",
|
|
671
717
|
help="Only usable with --by-serial. After connecting, calls "
|
|
672
718
|
"discover_mesh_peers_auto() on it and prints every other device "
|
|
@@ -43,6 +43,7 @@ from .schedule import (
|
|
|
43
43
|
SchedulePoint, interpolate_light_schedule,
|
|
44
44
|
PumpSchedulePoint, get_active_pump_block,
|
|
45
45
|
)
|
|
46
|
+
from .scenes import Scene, ActiveScene, decode_configured_scenes, decode_active_scene
|
|
46
47
|
from .modifiers import (
|
|
47
48
|
AcclimationInfo, is_night_segment, lunar_percent_reduction,
|
|
48
49
|
)
|
|
@@ -1276,22 +1277,40 @@ class MobiusDevice:
|
|
|
1276
1277
|
|
|
1277
1278
|
# ---- scenes -----------------------------------------------------------
|
|
1278
1279
|
|
|
1279
|
-
async def start_scene(self, scene_id:
|
|
1280
|
-
"""
|
|
1281
|
-
|
|
1280
|
+
async def start_scene(self, scene_id: int, duration_seconds: int = 0) -> None:
|
|
1281
|
+
"""
|
|
1282
|
+
Activates a scene by id -- one of the ten SceneID presets, or a
|
|
1283
|
+
user-created scene's own device-assigned id (see get_configured_scenes()).
|
|
1284
|
+
duration_seconds=0 lets the device use the scene's own configured
|
|
1285
|
+
timeout instead.
|
|
1286
|
+
"""
|
|
1287
|
+
value = struct.pack("<HH", int(scene_id), duration_seconds)
|
|
1282
1288
|
await self.set_attribute(C2Attribute.CurrentScene, value)
|
|
1283
1289
|
|
|
1284
|
-
async def start_feed_mode(self,
|
|
1285
|
-
await self.start_scene(SceneID.FeedMode,
|
|
1290
|
+
async def start_feed_mode(self, duration_seconds: int = 0) -> None:
|
|
1291
|
+
await self.start_scene(SceneID.FeedMode, duration_seconds)
|
|
1286
1292
|
|
|
1287
1293
|
async def resume_schedule(self) -> None:
|
|
1288
1294
|
"""Cancel whatever scene is running and go back to the normal schedule."""
|
|
1289
1295
|
await self.set_attribute(C2Attribute.OperationState, bytes([OperationState.Schedule]))
|
|
1290
1296
|
|
|
1291
|
-
async def get_current_scene(self) ->
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1297
|
+
async def get_current_scene(self) -> Optional[ActiveScene]:
|
|
1298
|
+
try:
|
|
1299
|
+
raw = await self.get_attribute(C2Attribute.CurrentScene)
|
|
1300
|
+
except Exception:
|
|
1301
|
+
return None
|
|
1302
|
+
return decode_active_scene(raw[0]) if raw else None
|
|
1303
|
+
|
|
1304
|
+
async def get_configured_scenes(self, primitive: Optional[PrimitiveType] = None) -> list[Scene]:
|
|
1305
|
+
"""
|
|
1306
|
+
Every configured scene, at whatever slots this device actually
|
|
1307
|
+
has one in -- including slots holding a genuinely empty/unused
|
|
1308
|
+
scene. Pass primitive so each scene's own light/pump payload
|
|
1309
|
+
can be decoded; omit it (or leave as None) to get every other
|
|
1310
|
+
field with light/pump both left None.
|
|
1311
|
+
"""
|
|
1312
|
+
raw = await self.get_attribute(C2Attribute.ConfiguredScenes, index=0, count=0xFFFF)
|
|
1313
|
+
return decode_configured_scenes(raw, primitive)
|
|
1295
1314
|
|
|
1296
1315
|
async def get_operation_state(self) -> OperationState:
|
|
1297
1316
|
raw = await self.get_attribute(C2Attribute.OperationState)
|
|
@@ -2931,6 +2950,8 @@ class MobiusDevice:
|
|
|
2931
2950
|
info["firmware_versions"] = await self.get_firmware_versions(model)
|
|
2932
2951
|
info["hardware_info"] = await self.get_hardware_info()
|
|
2933
2952
|
info["device_time"] = await self.get_device_time_info()
|
|
2953
|
+
current_scene = await self.get_current_scene()
|
|
2954
|
+
info["current_scene"] = current_scene
|
|
2934
2955
|
|
|
2935
2956
|
return info
|
|
2936
2957
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scenes: a device-side preset that can be activated on demand, separate
|
|
3
|
+
from the normal time-based schedule (Schedule1/Schedule2). Each
|
|
4
|
+
configured scene stores exactly the same kind of payload a schedule
|
|
5
|
+
point would (channel intensities for a light, mode+parameters for a
|
|
6
|
+
pump), plus a name and a timeout, at a fixed slot within
|
|
7
|
+
ConfiguredScenes (400). Activating one writes CurrentScene (401).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import struct
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from .constants import SceneID, PrimitiveType, LIGHT_PRIMITIVES, PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL
|
|
17
|
+
from .schedule import LightPrimitive, PumpPrimitiveValue
|
|
18
|
+
|
|
19
|
+
SCENE_HEADER_SIZE = 20 # id(2) + timeout(2) + name(16), before primitiveData
|
|
20
|
+
SCENE_NAME_SIZE = 16
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Scene:
|
|
25
|
+
"""One configured scene, at a fixed slot (`index`) within
|
|
26
|
+
ConfiguredScenes. `scene_type` is one of the ten built-in presets
|
|
27
|
+
if `id` matches one, or None for a user-created scene (whose own
|
|
28
|
+
`id` is a device-assigned value outside that range). Exactly one
|
|
29
|
+
of `light`/`pump` is populated, matching the device's own
|
|
30
|
+
PrimitiveType -- the other stays None."""
|
|
31
|
+
index: int
|
|
32
|
+
id: int
|
|
33
|
+
scene_type: Optional[SceneID]
|
|
34
|
+
name: str
|
|
35
|
+
timeout: int
|
|
36
|
+
light: Optional[LightPrimitive]
|
|
37
|
+
pump: Optional[PumpPrimitiveValue]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ActiveScene:
|
|
42
|
+
"""The currently active scene (CurrentScene, 401) and how many
|
|
43
|
+
seconds it has left. `scene_type` is None if `id` doesn't match
|
|
44
|
+
one of the ten built-in presets (i.e. a user-created scene is
|
|
45
|
+
active) -- id itself is always present either way."""
|
|
46
|
+
id: int
|
|
47
|
+
scene_type: Optional[SceneID]
|
|
48
|
+
duration_seconds: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decode_configured_scenes(values: list[bytes], primitive: Optional[PrimitiveType]) -> list[Scene]:
|
|
52
|
+
"""
|
|
53
|
+
Pure decode for ConfiguredScenes (400) -- one call's worth of
|
|
54
|
+
values from a "get all elements" read (index=0, count=0xFFFF),
|
|
55
|
+
one element per slot, in index order. A slot with fewer than
|
|
56
|
+
SCENE_HEADER_SIZE bytes is skipped rather than raising.
|
|
57
|
+
"""
|
|
58
|
+
scenes = []
|
|
59
|
+
for index, raw in enumerate(values):
|
|
60
|
+
if len(raw) < SCENE_HEADER_SIZE:
|
|
61
|
+
continue
|
|
62
|
+
scene_id = struct.unpack_from("<H", raw, 0)[0]
|
|
63
|
+
timeout = struct.unpack_from("<H", raw, 2)[0]
|
|
64
|
+
name_bytes = raw[4:4 + SCENE_NAME_SIZE]
|
|
65
|
+
name = name_bytes.split(b"\x00", 1)[0].decode("utf-8", errors="replace")
|
|
66
|
+
primitive_data = raw[SCENE_HEADER_SIZE:]
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
scene_type = SceneID(scene_id)
|
|
70
|
+
except ValueError:
|
|
71
|
+
scene_type = None
|
|
72
|
+
|
|
73
|
+
light = None
|
|
74
|
+
pump = None
|
|
75
|
+
if primitive in LIGHT_PRIMITIVES:
|
|
76
|
+
light = LightPrimitive.parse(primitive_data)
|
|
77
|
+
elif primitive in PUMP_PRIMITIVES_VERIFIED or primitive in PUMP_PRIMITIVES_EXPERIMENTAL:
|
|
78
|
+
pump = PumpPrimitiveValue.parse(primitive_data)
|
|
79
|
+
|
|
80
|
+
scenes.append(Scene(
|
|
81
|
+
index=index, id=scene_id, scene_type=scene_type, name=name,
|
|
82
|
+
timeout=timeout, light=light, pump=pump,
|
|
83
|
+
))
|
|
84
|
+
return scenes
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def decode_active_scene(raw: bytes) -> Optional[ActiveScene]:
|
|
88
|
+
"""Pure decode for CurrentScene (401): id(u16 LE) + duration
|
|
89
|
+
seconds(u16 LE). None if raw is too short to contain both fields."""
|
|
90
|
+
if len(raw) < 4:
|
|
91
|
+
return None
|
|
92
|
+
scene_id, duration = struct.unpack_from("<HH", raw, 0)
|
|
93
|
+
try:
|
|
94
|
+
scene_type = SceneID(scene_id)
|
|
95
|
+
except ValueError:
|
|
96
|
+
scene_type = None
|
|
97
|
+
return ActiveScene(id=scene_id, scene_type=scene_type, duration_seconds=duration)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Tests for scenes.py's decode_configured_scenes()/decode_active_scene(),
|
|
2
|
+
and the device.py methods built on top of them."""
|
|
3
|
+
|
|
4
|
+
import struct
|
|
5
|
+
from unittest.mock import AsyncMock
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from bleak.backends.device import BLEDevice
|
|
9
|
+
|
|
10
|
+
from mobius import (
|
|
11
|
+
Scene, ActiveScene, SceneID, PrimitiveType, VisualID, PumpMode, PumpParam,
|
|
12
|
+
decode_configured_scenes, decode_active_scene,
|
|
13
|
+
)
|
|
14
|
+
from mobius.device import MobiusDevice
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _scene_bytes(scene_id: int, timeout: int, name: str, primitive_data: bytes) -> bytes:
|
|
18
|
+
name_bytes = name.encode("utf-8")[:16].ljust(16, b"\x00")
|
|
19
|
+
return struct.pack("<HH", scene_id, timeout) + name_bytes + primitive_data
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _light_primitive_bytes(channel: VisualID, intensity: int) -> bytes:
|
|
23
|
+
return struct.pack("<BH", int(channel), intensity)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _pump_primitive_bytes(mode: PumpMode, max_speed: int) -> bytes:
|
|
27
|
+
return bytes([int(mode)]) + struct.pack("<h", max_speed)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --------------------------------------------------------------------------
|
|
31
|
+
# decode_configured_scenes
|
|
32
|
+
# --------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def test_decode_light_scene():
|
|
35
|
+
raw = _scene_bytes(int(SceneID.AllOn), 60, "Bright", _light_primitive_bytes(VisualID.RoyalBlue, 1000))
|
|
36
|
+
scenes = decode_configured_scenes([raw], PrimitiveType.VisualV1)
|
|
37
|
+
|
|
38
|
+
assert len(scenes) == 1
|
|
39
|
+
scene = scenes[0]
|
|
40
|
+
assert scene.index == 0
|
|
41
|
+
assert scene.id == int(SceneID.AllOn)
|
|
42
|
+
assert scene.scene_type == SceneID.AllOn
|
|
43
|
+
assert scene.name == "Bright"
|
|
44
|
+
assert scene.timeout == 60
|
|
45
|
+
assert scene.pump is None
|
|
46
|
+
assert scene.light.get(VisualID.RoyalBlue) == 1000
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_decode_pump_scene():
|
|
50
|
+
raw = _scene_bytes(int(SceneID.FeedMode), 30, "Feed", _pump_primitive_bytes(PumpMode.Feed, 0))
|
|
51
|
+
scenes = decode_configured_scenes([raw], PrimitiveType.VorTechV1)
|
|
52
|
+
|
|
53
|
+
assert len(scenes) == 1
|
|
54
|
+
scene = scenes[0]
|
|
55
|
+
assert scene.scene_type == SceneID.FeedMode
|
|
56
|
+
assert scene.name == "Feed"
|
|
57
|
+
assert scene.light is None
|
|
58
|
+
assert scene.pump.mode == PumpMode.Feed
|
|
59
|
+
assert scene.pump.params[PumpParam.MaxSpeed] == 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_decode_custom_scene_has_no_scene_type():
|
|
63
|
+
"""A user-created scene's own id is a device-assigned value outside
|
|
64
|
+
the ten built-in presets -- confirms this doesn't raise, and
|
|
65
|
+
scene_type comes back None rather than a wrong/guessed preset."""
|
|
66
|
+
raw = _scene_bytes(65000, 60, "Sushi", _light_primitive_bytes(VisualID.RoyalBlue, 500))
|
|
67
|
+
scenes = decode_configured_scenes([raw], PrimitiveType.VisualV1)
|
|
68
|
+
|
|
69
|
+
assert scenes[0].id == 65000
|
|
70
|
+
assert scenes[0].scene_type is None
|
|
71
|
+
assert scenes[0].name == "Sushi"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_decode_multiple_slots_preserves_index_order():
|
|
75
|
+
raw1 = _scene_bytes(int(SceneID.EmptyScene), 0, "", bytes(3))
|
|
76
|
+
raw2 = _scene_bytes(int(SceneID.AllOn), 60, "Bright", _light_primitive_bytes(VisualID.RoyalBlue, 1000))
|
|
77
|
+
scenes = decode_configured_scenes([raw1, raw2], PrimitiveType.VisualV1)
|
|
78
|
+
|
|
79
|
+
assert len(scenes) == 2
|
|
80
|
+
assert scenes[0].index == 0 and scenes[0].name == ""
|
|
81
|
+
assert scenes[1].index == 1 and scenes[1].name == "Bright"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_decode_skips_elements_shorter_than_the_header():
|
|
85
|
+
raw_short = bytes(10) # shorter than the 20-byte header
|
|
86
|
+
raw_ok = _scene_bytes(int(SceneID.AllOn), 60, "Bright", _light_primitive_bytes(VisualID.RoyalBlue, 1000))
|
|
87
|
+
scenes = decode_configured_scenes([raw_short, raw_ok], PrimitiveType.VisualV1)
|
|
88
|
+
|
|
89
|
+
assert len(scenes) == 1
|
|
90
|
+
assert scenes[0].name == "Bright"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_decode_with_no_primitive_type_leaves_light_and_pump_none():
|
|
94
|
+
raw = _scene_bytes(int(SceneID.AllOn), 60, "Bright", _light_primitive_bytes(VisualID.RoyalBlue, 1000))
|
|
95
|
+
scenes = decode_configured_scenes([raw], None)
|
|
96
|
+
|
|
97
|
+
assert scenes[0].light is None
|
|
98
|
+
assert scenes[0].pump is None
|
|
99
|
+
assert scenes[0].name == "Bright"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
# decode_active_scene -- the actual bug fix (used to raise on a custom id)
|
|
104
|
+
# --------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def test_decode_active_scene_known_preset():
|
|
107
|
+
raw = struct.pack("<HH", int(SceneID.FeedMode), 45)
|
|
108
|
+
active = decode_active_scene(raw)
|
|
109
|
+
|
|
110
|
+
assert active.id == int(SceneID.FeedMode)
|
|
111
|
+
assert active.scene_type == SceneID.FeedMode
|
|
112
|
+
assert active.duration_seconds == 45
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_decode_active_scene_custom_id_does_not_raise():
|
|
116
|
+
"""The actual bug this fixes: SceneID(scene_id) used to raise
|
|
117
|
+
ValueError outright whenever a custom (non-preset) scene was the
|
|
118
|
+
one currently active."""
|
|
119
|
+
raw = struct.pack("<HH", 65000, 12)
|
|
120
|
+
active = decode_active_scene(raw)
|
|
121
|
+
|
|
122
|
+
assert active.id == 65000
|
|
123
|
+
assert active.scene_type is None
|
|
124
|
+
assert active.duration_seconds == 12
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_decode_active_scene_too_short_returns_none():
|
|
128
|
+
assert decode_active_scene(bytes(2)) is None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --------------------------------------------------------------------------
|
|
132
|
+
# Device methods
|
|
133
|
+
# --------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def _fake_ble_device():
|
|
136
|
+
return BLEDevice("AA:BB:CC:DD:EE:FF", "MOBIUS", {})
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
async def test_get_configured_scenes_reads_all_elements():
|
|
141
|
+
device = MobiusDevice(_fake_ble_device())
|
|
142
|
+
raw = _scene_bytes(int(SceneID.AllOn), 60, "Bright", _light_primitive_bytes(VisualID.RoyalBlue, 1000))
|
|
143
|
+
device.get_attribute = AsyncMock(return_value=[raw])
|
|
144
|
+
|
|
145
|
+
scenes = await device.get_configured_scenes(primitive=PrimitiveType.VisualV1)
|
|
146
|
+
|
|
147
|
+
device.get_attribute.assert_awaited_once()
|
|
148
|
+
call_args = device.get_attribute.call_args
|
|
149
|
+
assert call_args.kwargs.get("index") == 0
|
|
150
|
+
assert call_args.kwargs.get("count") == 0xFFFF
|
|
151
|
+
assert scenes[0].name == "Bright"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.mark.asyncio
|
|
155
|
+
async def test_get_current_scene_decodes_the_response():
|
|
156
|
+
device = MobiusDevice(_fake_ble_device())
|
|
157
|
+
raw = struct.pack("<HH", int(SceneID.FeedMode), 45)
|
|
158
|
+
device.get_attribute = AsyncMock(return_value=[raw])
|
|
159
|
+
|
|
160
|
+
active = await device.get_current_scene()
|
|
161
|
+
|
|
162
|
+
assert active.scene_type == SceneID.FeedMode
|
|
163
|
+
assert active.duration_seconds == 45
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.mark.asyncio
|
|
167
|
+
async def test_get_current_scene_fails_soft_when_unsupported():
|
|
168
|
+
device = MobiusDevice(_fake_ble_device())
|
|
169
|
+
device.get_attribute = AsyncMock(side_effect=IOError("not supported"))
|
|
170
|
+
|
|
171
|
+
assert await device.get_current_scene() is None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.mark.asyncio
|
|
175
|
+
async def test_start_scene_writes_id_and_seconds():
|
|
176
|
+
"""Confirms the actual unit is seconds, not minutes -- the
|
|
177
|
+
parameter used to be misleadingly named duration_minutes even
|
|
178
|
+
though the device treats it as seconds."""
|
|
179
|
+
device = MobiusDevice(_fake_ble_device())
|
|
180
|
+
device.set_attribute = AsyncMock()
|
|
181
|
+
|
|
182
|
+
await device.start_scene(int(SceneID.FeedMode), duration_seconds=90)
|
|
183
|
+
|
|
184
|
+
device.set_attribute.assert_awaited_once()
|
|
185
|
+
call_args = device.set_attribute.call_args
|
|
186
|
+
written = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("value")
|
|
187
|
+
scene_id, seconds = struct.unpack("<HH", written)
|
|
188
|
+
assert scene_id == int(SceneID.FeedMode)
|
|
189
|
+
assert seconds == 90
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@pytest.mark.asyncio
|
|
193
|
+
async def test_start_scene_accepts_a_custom_scene_id():
|
|
194
|
+
"""A user-created scene's own id isn't a SceneID enum member at
|
|
195
|
+
all -- confirms start_scene() accepts a plain int, not just SceneID."""
|
|
196
|
+
device = MobiusDevice(_fake_ble_device())
|
|
197
|
+
device.set_attribute = AsyncMock()
|
|
198
|
+
|
|
199
|
+
await device.start_scene(65000, duration_seconds=0)
|
|
200
|
+
|
|
201
|
+
written = device.set_attribute.call_args.args[1]
|
|
202
|
+
scene_id, _ = struct.unpack("<HH", written)
|
|
203
|
+
assert scene_id == 65000
|
|
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.7.0 → python_mobius-0.7.1}/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
|