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.
Files changed (79) hide show
  1. {python_mobius-0.7.0 → python_mobius-0.7.1}/CHANGELOG.md +7 -0
  2. {python_mobius-0.7.0 → python_mobius-0.7.1}/PKG-INFO +1 -1
  3. {python_mobius-0.7.0 → python_mobius-0.7.1}/pyproject.toml +1 -1
  4. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/__init__.py +4 -1
  5. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/cli.py +46 -0
  6. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/constants.py +1 -0
  7. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/device.py +30 -9
  8. python_mobius-0.7.1/src/mobius/scenes.py +97 -0
  9. python_mobius-0.7.1/tests/test_scenes.py +203 -0
  10. {python_mobius-0.7.0 → python_mobius-0.7.1}/.forgejo/scripts/extract_changelog_section.py +0 -0
  11. {python_mobius-0.7.0 → python_mobius-0.7.1}/.forgejo/workflows/release.yml +0 -0
  12. {python_mobius-0.7.0 → python_mobius-0.7.1}/.gitignore +0 -0
  13. {python_mobius-0.7.0 → python_mobius-0.7.1}/LICENSE +0 -0
  14. {python_mobius-0.7.0 → python_mobius-0.7.1}/README.md +0 -0
  15. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/00-overview.md +0 -0
  16. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/01-ble-transport.md +0 -0
  17. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/02-framing-and-crc.md +0 -0
  18. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/03-attributes-and-opcodes.md +0 -0
  19. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/04-device-identity.md +0 -0
  20. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/05-scenes.md +0 -0
  21. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/06-light-schedule.md +0 -0
  22. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/07-pump-schedule.md +0 -0
  23. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/08-manufacturer-data.md +0 -0
  24. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/09-thread-coap-relay.md +0 -0
  25. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/10-known-gaps-and-open-questions.md +0 -0
  26. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/11-device-status-attributes.md +0 -0
  27. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/12-device-identity-and-address-stability.md +0 -0
  28. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/13-attribute-dump.md +0 -0
  29. {python_mobius-0.7.0 → python_mobius-0.7.1}/documentation/14-batched-attribute-reads.md +0 -0
  30. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/coap.py +0 -0
  31. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/crc.py +0 -0
  32. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/device_status.py +0 -0
  33. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/discovery.py +0 -0
  34. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/dump.py +0 -0
  35. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/frame.py +0 -0
  36. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/manufacturer.py +0 -0
  37. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/mesh_address.py +0 -0
  38. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/modifiers.py +0 -0
  39. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/power.py +0 -0
  40. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/pump_status.py +0 -0
  41. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/relay.py +0 -0
  42. {python_mobius-0.7.0 → python_mobius-0.7.1}/src/mobius/schedule.py +0 -0
  43. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_attributes_batch.py +0 -0
  44. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_coap.py +0 -0
  45. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_coap_indication_handling.py +0 -0
  46. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_connection_cleanup.py +0 -0
  47. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_device_control.py +0 -0
  48. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_device_status.py +0 -0
  49. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_device_summary.py +0 -0
  50. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_device_time.py +0 -0
  51. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_discovery_company_ids.py +0 -0
  52. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_dump.py +0 -0
  53. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_firmware_versions.py +0 -0
  54. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_frame.py +0 -0
  55. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_frame_sniffer.py +0 -0
  56. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_full_poll_batch.py +0 -0
  57. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_hyperdrive_device.py +0 -0
  58. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_insolation_detection.py +0 -0
  59. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_light_intensity_branching.py +0 -0
  60. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_light_poll_batch.py +0 -0
  61. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_light_schedule.py +0 -0
  62. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_manufacturer.py +0 -0
  63. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_manufacturer_lookup.py +0 -0
  64. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_mesh_address.py +0 -0
  65. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_mesh_discovery.py +0 -0
  66. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_mesh_discovery_direct_connect.py +0 -0
  67. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_metadata_batch.py +0 -0
  68. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_modifiers.py +0 -0
  69. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_networked_thread_devices.py +0 -0
  70. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_new_diagnostic_attributes.py +0 -0
  71. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_power.py +0 -0
  72. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_pump_schedule.py +0 -0
  73. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_pump_status.py +0 -0
  74. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_pump_telemetry.py +0 -0
  75. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_relay.py +0 -0
  76. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_schedule_intensity_scalar.py +0 -0
  77. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_serial_decoding.py +0 -0
  78. {python_mobius-0.7.0 → python_mobius-0.7.1}/tests/test_serial_identity.py +0 -0
  79. {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.0
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.0"
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.0"
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 "
@@ -436,6 +436,7 @@ class C2Attribute(IntEnum):
436
436
  SceneTimeout = 402
437
437
  SceneTimer = 403
438
438
  # Scene diagnostics -- named, not yet exposed by a get_*() method.
439
+ LiveDemoSceneNero = 404
439
440
  LiveDemoTimeout = 405
440
441
  LiveDemoTimer = 406
441
442
  LiveDemoScene = 407
@@ -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: SceneID, duration_minutes: int = 0) -> None:
1280
- """duration_minutes=0 lets the device use the scene's own configured duration."""
1281
- value = struct.pack("<HH", int(scene_id), duration_minutes)
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, duration_minutes: int = 0) -> None:
1285
- await self.start_scene(SceneID.FeedMode, duration_minutes)
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) -> tuple[SceneID, int]:
1292
- raw = await self.get_attribute(C2Attribute.CurrentScene)
1293
- scene_id, duration = struct.unpack("<HH", raw[0])
1294
- return SceneID(scene_id), duration
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