python-roborock 7.12.4__tar.gz → 7.14.0__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 (135) hide show
  1. {python_roborock-7.12.4 → python_roborock-7.14.0}/PKG-INFO +1 -1
  2. {python_roborock-7.12.4 → python_roborock-7.14.0}/pyproject.toml +1 -1
  3. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/cli.py +30 -2
  4. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/const.py +2 -0
  5. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/v1/v1_clean_modes.py +3 -0
  6. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/v1/v1_code_mappings.py +1 -0
  7. python_roborock-7.14.0/roborock/map/dynamic_blocks.py +220 -0
  8. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/testing/v1_simulator.py +95 -1
  9. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/web_api.py +25 -0
  10. {python_roborock-7.12.4 → python_roborock-7.14.0}/.gitignore +0 -0
  11. {python_roborock-7.12.4 → python_roborock-7.14.0}/LICENSE +0 -0
  12. {python_roborock-7.12.4 → python_roborock-7.14.0}/README.md +0 -0
  13. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/__init__.py +0 -0
  14. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/broadcast_protocol.py +0 -0
  15. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/callbacks.py +0 -0
  16. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/__init__.py +0 -0
  17. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/b01_q10/__init__.py +0 -0
  18. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  19. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  20. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/b01_q7/__init__.py +0 -0
  21. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  22. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  23. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/code_mappings.py +0 -0
  24. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/containers.py +0 -0
  25. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/dyad/__init__.py +0 -0
  26. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  27. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/dyad/dyad_containers.py +0 -0
  28. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/mower/__init__.py +0 -0
  29. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/mower/mower_code_mappings.py +0 -0
  30. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/mower/mower_containers.py +0 -0
  31. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/v1/__init__.py +0 -0
  32. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/v1/v1_containers.py +0 -0
  33. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/zeo/__init__.py +0 -0
  34. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  35. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/data/zeo/zeo_containers.py +0 -0
  36. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/device_features.py +0 -0
  37. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/README.md +0 -0
  38. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/__init__.py +0 -0
  39. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/cache.py +0 -0
  40. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/device.py +0 -0
  41. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/device_manager.py +0 -0
  42. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/file_cache.py +0 -0
  43. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/rpc/__init__.py +0 -0
  44. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/rpc/a01_channel.py +0 -0
  45. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  46. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  47. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/rpc/v1_channel.py +0 -0
  48. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/__init__.py +0 -0
  49. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/a01/__init__.py +0 -0
  50. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/a01/device_feature.py +0 -0
  51. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/__init__.py +0 -0
  52. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  53. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  54. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  55. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  56. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  57. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/common.py +0 -0
  58. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  59. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  60. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  61. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/goto.py +0 -0
  62. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/map.py +0 -0
  63. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/maps.py +0 -0
  64. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  65. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
  66. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  67. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  68. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q10/volume.py +0 -0
  69. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  70. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  71. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  72. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  73. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/common.py +0 -0
  74. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/traits_mixin.py +0 -0
  75. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/__init__.py +0 -0
  76. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  77. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  78. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/command.py +0 -0
  79. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/common.py +0 -0
  80. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  81. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/device_features.py +0 -0
  82. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  83. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  84. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  85. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/home.py +0 -0
  86. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/led_status.py +0 -0
  87. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/map_content.py +0 -0
  88. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/maps.py +0 -0
  89. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/mop_dryer.py +0 -0
  90. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/network_info.py +0 -0
  91. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/obstacle_photos.py +0 -0
  92. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/rooms.py +0 -0
  93. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/routines.py +0 -0
  94. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  95. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/status.py +0 -0
  96. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  97. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/volume.py +0 -0
  98. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  99. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/transport/__init__.py +0 -0
  100. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/transport/channel.py +0 -0
  101. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/transport/local_channel.py +0 -0
  102. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  103. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/diagnostics.py +0 -0
  104. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/exceptions.py +0 -0
  105. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/map/__init__.py +0 -0
  106. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/map/b01_grid_layers.py +0 -0
  107. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/map/b01_map_parser.py +0 -0
  108. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/map/b01_q10_map_parser.py +0 -0
  109. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/map/b01_q10_overlays.py +0 -0
  110. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/map/b01_q10_render.py +0 -0
  111. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/map/map_parser.py +0 -0
  112. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/map/proto/__init__.py +0 -0
  113. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/map/proto/b01_scmap.proto +0 -0
  114. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  115. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/map/room_colors.py +0 -0
  116. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/mqtt/__init__.py +0 -0
  117. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/mqtt/health_manager.py +0 -0
  118. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/mqtt/roborock_session.py +0 -0
  119. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/mqtt/session.py +0 -0
  120. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/protocol.py +0 -0
  121. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/protocols/__init__.py +0 -0
  122. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/protocols/a01_protocol.py +0 -0
  123. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  124. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  125. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/protocols/v1_protocol.py +0 -0
  126. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/py.typed +0 -0
  127. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/roborock_message.py +0 -0
  128. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/roborock_typing.py +0 -0
  129. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/testing/__init__.py +0 -0
  130. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/testing/a01_simulator.py +0 -0
  131. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/testing/b01_q10_simulator.py +0 -0
  132. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/testing/channel.py +0 -0
  133. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/testing/cloud.py +0 -0
  134. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/testing/simulator.py +0 -0
  135. {python_roborock-7.12.4 → python_roborock-7.14.0}/roborock/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-roborock
3
- Version: 7.12.4
3
+ Version: 7.14.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Project-URL: Repository, https://github.com/python-roborock/python-roborock
6
6
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-roborock"
3
- version = "7.12.4"
3
+ version = "7.14.0"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
6
6
  requires-python = ">=3.11, <4"
@@ -34,7 +34,7 @@ import threading
34
34
  from collections.abc import Callable
35
35
  from dataclasses import asdict, dataclass
36
36
  from pathlib import Path
37
- from typing import Any, cast
37
+ from typing import Any, TextIO, cast
38
38
 
39
39
  try:
40
40
  import click
@@ -50,7 +50,7 @@ except ImportError as err:
50
50
  ) from err
51
51
 
52
52
  from roborock import RoborockCommand
53
- from roborock.data import RoborockBase, UserData
53
+ from roborock.data import HomeDataSchedule, RoborockBase, UserData
54
54
  from roborock.data.b01_q10.b01_q10_code_mappings import (
55
55
  B01_Q10_DP,
56
56
  YXCleanType,
@@ -428,6 +428,33 @@ async def execute_scene(ctx, scene_id):
428
428
  await client.execute_scene(connection_cache.user_data, scene_id)
429
429
 
430
430
 
431
+ @click.command()
432
+ @click.option("--device_id", required=True)
433
+ @click.option(
434
+ "--job-file",
435
+ required=True,
436
+ type=click.File("r", encoding="utf-8"),
437
+ help="JSON file containing the schedule to update.",
438
+ )
439
+ @click.pass_context
440
+ @async_command
441
+ async def update_job(ctx: click.Context, device_id: str, job_file: TextIO) -> None:
442
+ """Replace a cloud job using a complete schedule JSON file.
443
+
444
+ Include all existing settings in the file, including unchanged cleaning parameters.
445
+ """
446
+ context: RoborockContext = ctx.obj
447
+ try:
448
+ job = HomeDataSchedule.from_dict(json.load(job_file))
449
+ connection_cache = context.connection_cache()
450
+ client = RoborockApiClient(connection_cache.email)
451
+ await client.update_job(connection_cache.user_data, device_id, job)
452
+ except Exception as err:
453
+ click.echo(f"Error: {err}", err=True)
454
+ raise SystemExit(1) from err
455
+ click.echo(f"Updated job {job.id} for device {device_id}")
456
+
457
+
431
458
  async def _v1_trait(context: RoborockContext, device_id: str, display_func: Callable[[], V1TraitMixin]) -> Trait:
432
459
  device_manager = await context.get_device_manager()
433
460
  device = await device_manager.get_device(device_id)
@@ -1366,6 +1393,7 @@ cli.add_command(discover)
1366
1393
  cli.add_command(list_devices)
1367
1394
  cli.add_command(list_scenes)
1368
1395
  cli.add_command(execute_scene)
1396
+ cli.add_command(update_job)
1369
1397
  cli.add_command(status)
1370
1398
  cli.add_command(command)
1371
1399
  cli.add_command(parser)
@@ -47,6 +47,7 @@ ROBOROCK_C1 = "roborock.vacuum.c1"
47
47
  ROBOROCK_S8_PRO_ULTRA = "roborock.vacuum.a70"
48
48
  ROBOROCK_S8 = "roborock.vacuum.a51"
49
49
  ROBOROCK_P10 = "roborock.vacuum.a75" # also known as q_revo
50
+ ROBOROCK_P30_PRO = "roborock.vacuum.a306"
50
51
  ROBOROCK_S8_MAXV_ULTRA = "roborock.vacuum.a97"
51
52
  ROBOROCK_QREVO_S = "roborock.vacuum.a104"
52
53
  ROBOROCK_QREVO_PRO = "roborock.vacuum.a101"
@@ -77,6 +78,7 @@ SUPPORTED_VACUUMS = [
77
78
  ROBOROCK_S4_MAX,
78
79
  ROBOROCK_S7,
79
80
  ROBOROCK_P10,
81
+ ROBOROCK_P30_PRO,
80
82
  ROBOROCK_Q10,
81
83
  ROCKROBO_G10_SG,
82
84
  ]
@@ -55,6 +55,7 @@ class WaterModes(RoborockModeEnum):
55
55
  CUSTOM = ("custom_water_flow", 207)
56
56
  EXTREME = ("extreme", 208)
57
57
  SMART_MODE = ("smart_mode", 209)
58
+ SMART_WATER = ("smart_water", 210)
58
59
  PURE_WATER_FLOW_START = ("slight", 221)
59
60
  PURE_WATER_FLOW_SMALL = ("low", 225)
60
61
  PURE_WATER_FLOW_GENTLE = ("gentle", 230)
@@ -207,6 +208,8 @@ def _resolve_water_slide_mode(code: int) -> WaterModes | None:
207
208
  """Resolve a raw slider code to the water mode for its app range."""
208
209
  if code == 200:
209
210
  return WaterModes.OFF
211
+ if code == WaterModes.SMART_WATER.code:
212
+ return WaterModes.SMART_WATER
210
213
  position = code - 220
211
214
  if position < 1:
212
215
  return None
@@ -386,6 +386,7 @@ class RoborockDockTypeCode(RoborockEnum):
386
386
  f1s_dock = 44
387
387
  o7h_dock = 45
388
388
  f1c_dock = 46
389
+ type_49_dock = 49
389
390
 
390
391
 
391
392
  class RoborockDockDustCollectionModeCode(RoborockEnum):
@@ -0,0 +1,220 @@
1
+ """Byte-level container helpers for Roborock V1 map blocks.
2
+
3
+ A V1 map file and the ``data`` / ``robot_data`` values returned by the dynamic
4
+ map diff commands share one block framing. Every block begins with an
5
+ eight-byte common header::
6
+
7
+ offset 0, u16 block type
8
+ offset 2, u16 header length H (including these eight bytes)
9
+ offset 4, u32 payload length P
10
+ offset 8 ..H type-specific header fields
11
+ offset H ..H+P payload
12
+
13
+ The next block starts exactly ``H + P`` bytes after the current one. All
14
+ multibyte fields are little-endian and unsigned.
15
+
16
+ This module only understands that framing. It can split a map file or a
17
+ dynamic value into blocks, rebuild a map file from blocks, and decode the
18
+ block 34 nonce table that identifies the base map and its dynamic elements.
19
+ It deliberately knows nothing about the meaning of any block payload; that
20
+ stays with the map parser (full maps) and the dynamic element merge logic.
21
+ """
22
+
23
+ import struct
24
+ from collections.abc import Iterable, Iterator
25
+ from dataclasses import dataclass
26
+ from typing import Self
27
+
28
+ from roborock.exceptions import RoborockException
29
+
30
+ # Marker at the start of a full V1 map file.
31
+ MAP_FILE_MAGIC = b"rr"
32
+
33
+ # Size of the block header fields shared by every block type.
34
+ COMMON_HEADER_LEN = 8
35
+
36
+ # A dynamic ``data`` / ``robot_data`` value holds at most this many blocks.
37
+ MAX_DYNAMIC_VALUE_BLOCKS = 4
38
+
39
+ # Block type of the nonce table carried inside a full map.
40
+ NONCE_TABLE_BLOCK_TYPE = 34
41
+
42
+ # Entry identifier inside the nonce table that supplies the map nonce.
43
+ MAP_NONCE_ENTRY_ID = 35
44
+
45
+ # Trailing digest block of a full map. New blocks are inserted before it.
46
+ DIGEST_BLOCK_TYPE = 1024
47
+
48
+ _COMMON_HEADER = struct.Struct("<HHI")
49
+ _NONCE_TABLE_NUM = struct.Struct("<I")
50
+ _NONCE_ENTRY = struct.Struct("<BI")
51
+ _NONCE_TABLE_MIN_HEADER_LEN = COMMON_HEADER_LEN + _NONCE_TABLE_NUM.size
52
+ _MAP_FILE_HEADER_LEN = struct.Struct("<H")
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class MapBlock:
57
+ """One framed block: its type, type-specific header fields, and payload.
58
+
59
+ ``extra_header`` holds the bytes between the common eight-byte header and
60
+ the payload, so the common header is always regenerated from the lengths
61
+ and can never disagree with the data it describes.
62
+ """
63
+
64
+ block_type: int
65
+ payload: bytes
66
+ extra_header: bytes = b""
67
+
68
+ @property
69
+ def header_length(self) -> int:
70
+ """Total header length ``H`` written to the common header."""
71
+ return COMMON_HEADER_LEN + len(self.extra_header)
72
+
73
+ def with_payload(self, payload: bytes) -> Self:
74
+ """Return a copy carrying ``payload`` and the same type and header fields."""
75
+ return type(self)(self.block_type, payload, self.extra_header)
76
+
77
+ def encode(self) -> bytes:
78
+ """Serialize the block with a regenerated common header."""
79
+ common = _COMMON_HEADER.pack(self.block_type, self.header_length, len(self.payload))
80
+ return common + self.extra_header + self.payload
81
+
82
+
83
+ def iter_blocks(data: bytes, start: int = 0) -> Iterator[MapBlock]:
84
+ """Yield consecutive blocks from ``data`` beginning at byte ``start``.
85
+
86
+ The sequence must consume ``data`` exactly. A header shorter than eight
87
+ bytes, a header or payload that runs past the end of ``data``, or a block
88
+ that does not fit a complete common header is rejected.
89
+ """
90
+ position = start
91
+ end = len(data)
92
+ while position < end:
93
+ if end - position < COMMON_HEADER_LEN:
94
+ raise RoborockException(f"Map block at offset {position} is shorter than its common header")
95
+ block_type, header_length, payload_length = _COMMON_HEADER.unpack_from(data, position)
96
+ if header_length < COMMON_HEADER_LEN:
97
+ raise RoborockException(f"Map block at offset {position} declares header length {header_length} < 8")
98
+ block_end = position + header_length + payload_length
99
+ if block_end > end:
100
+ raise RoborockException(f"Map block at offset {position} extends past the end of the data")
101
+ header_end = position + header_length
102
+ yield MapBlock(
103
+ block_type=block_type,
104
+ payload=data[header_end:block_end],
105
+ extra_header=data[position + COMMON_HEADER_LEN : header_end],
106
+ )
107
+ position = block_end
108
+
109
+
110
+ def parse_dynamic_value(data: bytes) -> list[MapBlock]:
111
+ """Split a decoded dynamic ``data`` / ``robot_data`` value into blocks.
112
+
113
+ A dynamic value is a bare block sequence: there is no ``rr`` file header
114
+ and no digest. An empty value yields no blocks. A value that reaches a
115
+ fifth block is rejected, so a non-empty value holds one to four blocks.
116
+ """
117
+ blocks: list[MapBlock] = []
118
+ for block in iter_blocks(data):
119
+ if len(blocks) == MAX_DYNAMIC_VALUE_BLOCKS:
120
+ raise RoborockException(f"Dynamic map value holds more than {MAX_DYNAMIC_VALUE_BLOCKS} blocks")
121
+ blocks.append(block)
122
+ return blocks
123
+
124
+
125
+ def encode_blocks(blocks: Iterable[MapBlock]) -> bytes:
126
+ """Serialize a block sequence without any file header."""
127
+ return b"".join(block.encode() for block in blocks)
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class MapFile:
132
+ """A full V1 map file split into its file header and framed blocks."""
133
+
134
+ header: bytes
135
+ """The ``rr`` file header, kept verbatim so it round-trips unchanged."""
136
+
137
+ blocks: list[MapBlock]
138
+
139
+ def encode(self) -> bytes:
140
+ """Serialize the file header followed by every block."""
141
+ return self.header + encode_blocks(self.blocks)
142
+
143
+
144
+ def split_map_file(map_bytes: bytes) -> MapFile:
145
+ """Split a decompressed full map into its file header and blocks."""
146
+ if not map_bytes.startswith(MAP_FILE_MAGIC):
147
+ raise RoborockException("Map file does not start with the 'rr' marker")
148
+ if len(map_bytes) < _MAP_FILE_HEADER_LEN.size + 2:
149
+ raise RoborockException("Map file is shorter than its header length field")
150
+ (header_length,) = _MAP_FILE_HEADER_LEN.unpack_from(map_bytes, 2)
151
+ if header_length < 4 or header_length > len(map_bytes):
152
+ raise RoborockException(f"Map file declares an invalid header length {header_length}")
153
+ return MapFile(header=map_bytes[:header_length], blocks=list(iter_blocks(map_bytes, header_length)))
154
+
155
+
156
+ def find_block(blocks: Iterable[MapBlock], block_type: int) -> MapBlock | None:
157
+ """Return the first block of ``block_type``, or ``None`` when absent."""
158
+ for block in blocks:
159
+ if block.block_type == block_type:
160
+ return block
161
+ return None
162
+
163
+
164
+ def replace_block(blocks: list[MapBlock], new_block: MapBlock) -> list[MapBlock]:
165
+ """Return a new block list with ``new_block`` substituted for its type.
166
+
167
+ The first existing block of the same type is replaced in place, so the
168
+ relative order the map parser depends on (for example mop flags after
169
+ the path they describe) is preserved. When the type is not present the
170
+ block is inserted before the trailing digest, or appended when there is
171
+ no digest.
172
+ """
173
+ for index, block in enumerate(blocks):
174
+ if block.block_type == new_block.block_type:
175
+ return [*blocks[:index], new_block, *blocks[index + 1 :]]
176
+ for index, block in enumerate(blocks):
177
+ if block.block_type == DIGEST_BLOCK_TYPE:
178
+ return [*blocks[:index], new_block, *blocks[index:]]
179
+ return [*blocks, new_block]
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class NonceTable:
184
+ """Decoded block 34: identities of the base map and its dynamic elements.
185
+
186
+ Nonces are opaque 32-bit identities. Only equality is meaningful. They are
187
+ decoded unsigned to preserve all 32 bits. JSON values may use a signed
188
+ representation, as the app's binary reader does. Callers must normalize
189
+ JSON nonces with ``value & 0xFFFFFFFF`` before comparing them to this table.
190
+ """
191
+
192
+ map_nonce: int | None
193
+ """Nonce of the base map (entry 35), or ``None`` when the table lacks it."""
194
+
195
+ element_nonces: dict[int, int]
196
+ """Element nonce keyed by element id, excluding the map nonce entry."""
197
+
198
+
199
+ def decode_nonce_table(block: MapBlock) -> NonceTable:
200
+ """Decode a block 34 nonce table.
201
+
202
+ The header carries ``num`` as a u32 at offset 8. The payload holds ``num``
203
+ five-byte entries, each a one-byte identifier followed by a u32 nonce.
204
+ """
205
+ if block.block_type != NONCE_TABLE_BLOCK_TYPE:
206
+ raise RoborockException(f"Block type {block.block_type} is not a nonce table")
207
+ if block.header_length < _NONCE_TABLE_MIN_HEADER_LEN:
208
+ raise RoborockException("Nonce table header is shorter than 12 bytes")
209
+ (num,) = _NONCE_TABLE_NUM.unpack_from(block.extra_header, 0)
210
+ if len(block.payload) < num * _NONCE_ENTRY.size:
211
+ raise RoborockException(f"Nonce table declares {num} entries but the payload is too short")
212
+ map_nonce: int | None = None
213
+ element_nonces: dict[int, int] = {}
214
+ for index in range(num):
215
+ entry_id, nonce = _NONCE_ENTRY.unpack_from(block.payload, index * _NONCE_ENTRY.size)
216
+ if entry_id == MAP_NONCE_ENTRY_ID:
217
+ map_nonce = nonce
218
+ else:
219
+ element_nonces[entry_id] = nonce
220
+ return NonceTable(map_nonce=map_nonce, element_nonces=element_nonces)
@@ -4,8 +4,11 @@ This module provides `V1VacuumSimulator` which simulates the firmware state
4
4
  machine and JSON RPC commands for V1 vacuum cleaners.
5
5
  """
6
6
 
7
+ import gzip
8
+ import hashlib
7
9
  import json
8
10
  import logging
11
+ import struct
9
12
  import time
10
13
  from collections.abc import Callable
11
14
  from dataclasses import asdict, replace
@@ -37,6 +40,7 @@ from roborock.data.v1.v1_containers import (
37
40
  )
38
41
  from roborock.devices.cache import DeviceCache, InMemoryCache
39
42
  from roborock.devices.rpc.v1_channel import V1Channel
43
+ from roborock.protocol import Utils
40
44
  from roborock.protocols.v1_protocol import SecurityData
41
45
  from roborock.roborock_message import RoborockDataProtocol, RoborockMessage, RoborockMessageProtocol
42
46
  from roborock.testing.channel import FakeChannel
@@ -165,6 +169,33 @@ DEFAULT_LAST_CLEAN_RECORD = CleanRecord(
165
169
  )
166
170
 
167
171
 
172
+ # Eight character endpoint, matching the width of the map response header field.
173
+ MAP_ENDPOINT = "fakeendp"
174
+
175
+ # Minimum side length in cells of the simulated map image.
176
+ MAP_IMAGE_SIZE = 32
177
+
178
+ _MAP_FILE_HEADER_LEN = 20
179
+ _MAP_CELL_SIZE_MM = 50
180
+ _MAP_IMAGE_PADDING = 2
181
+ _IMAGE_BLOCK_TYPE = 2
182
+ _PATH_BLOCK_TYPE = 3
183
+ _POSE_BLOCK_TYPE = 8
184
+ _MOP_BLOCK_TYPE = 18
185
+ _DIGEST_BLOCK_TYPE = 1024
186
+ _MAP_INSIDE = 0xFF
187
+
188
+
189
+ def _path_point(index: int) -> bytes:
190
+ """A deterministic path point, so tests can assert on exact bytes."""
191
+ return struct.pack("<HH", 20000 + index * 10, 21000 + index * 7)
192
+
193
+
194
+ def _block(block_type: int, payload: bytes, extra_header: bytes = b"") -> bytes:
195
+ """Frame one map block: type, header length, payload length, header, payload."""
196
+ return struct.pack("<HHI", block_type, 8 + len(extra_header), len(payload)) + extra_header + payload
197
+
198
+
168
199
  class V1VacuumSimulator(RoborockDeviceSimulator):
169
200
  """Firmware simulator for a V1/L01 vacuum device.
170
201
 
@@ -219,10 +250,18 @@ class V1VacuumSimulator(RoborockDeviceSimulator):
219
250
  "reset_consumable": self._handle_reset_consumable,
220
251
  "app_get_init_status": self._handle_app_get_init_status,
221
252
  "get_network_info": self._handle_get_network_info,
253
+ "get_map_v1": lambda params: self.build_map_bytes(),
222
254
  }
223
255
 
256
+ # The simulated robot's own view of its map. Tests extend the path
257
+ # with advance_path() and move the robot via robot_position.
258
+ self.path_length = 0
259
+ self.robot_position = (25000, 24000, 90)
260
+
224
261
  self.device_cache = DeviceCache(self.duid, InMemoryCache())
225
- self.security_data = SecurityData(endpoint="fake_endpoint", nonce=b"fake_nonce_16bytes")
262
+ # The endpoint must fit the eight byte map response header field and the
263
+ # nonce must be a valid sixteen byte AES key for map responses to decode.
264
+ self.security_data = SecurityData(endpoint=MAP_ENDPOINT, nonce=b"fake_nonce_16byt")
226
265
  local_session = Mock(return_value=self.local_channel)
227
266
 
228
267
  self._v1_channel = V1Channel(
@@ -333,6 +372,56 @@ class V1VacuumSimulator(RoborockDeviceSimulator):
333
372
  def _handle_get_network_info(self, params: Any) -> dict[str, Any]:
334
373
  return _serialize_dataclass(self.network_info)
335
374
 
375
+ # --- Simulated map -------------------------------------------------
376
+
377
+ def advance_path(self, count: int) -> None:
378
+ """Extend the current path, as the robot does while it cleans."""
379
+ self.path_length += count
380
+
381
+ def path_payload(self, start: int, count: int) -> bytes:
382
+ """The point records for a range of the current path."""
383
+ return b"".join(_path_point(i) for i in range(start, start + count))
384
+
385
+ def mop_payload(self, start: int, count: int) -> bytes:
386
+ """The mop flags for a range of the current path, alternating per point."""
387
+ return bytes(i % 2 for i in range(start, start + count))
388
+
389
+ def _pose_block(self) -> bytes:
390
+ return _block(_POSE_BLOCK_TYPE, struct.pack("<iii", *self.robot_position))
391
+
392
+ def _path_blocks(self, start: int, count: int) -> bytes:
393
+ """Blocks 3 and 18 for a range of the path, as both a map and a page carry them."""
394
+ return _block(_PATH_BLOCK_TYPE, self.path_payload(start, count), struct.pack("<III", count, 4, 0)) + _block(
395
+ _MOP_BLOCK_TYPE, self.mop_payload(start, count)
396
+ )
397
+
398
+ def build_map_bytes(self) -> bytes:
399
+ """Build a decompressed map file reflecting the current robot state."""
400
+ # Full-map image offsets are cells; path and robot coordinates are millimetres.
401
+ points = [self.robot_position[:2], *struct.iter_unpack("<HH", self.path_payload(0, self.path_length))]
402
+ xs = [x // _MAP_CELL_SIZE_MM for x, _ in points]
403
+ ys = [y // _MAP_CELL_SIZE_MM for _, y in points]
404
+ left = max(0, min(xs) - _MAP_IMAGE_PADDING)
405
+ top = max(0, min(ys) - _MAP_IMAGE_PADDING)
406
+ width = max(MAP_IMAGE_SIZE, max(xs) - left + _MAP_IMAGE_PADDING + 1)
407
+ height = max(MAP_IMAGE_SIZE, max(ys) - top + _MAP_IMAGE_PADDING + 1)
408
+ image = _block(
409
+ _IMAGE_BLOCK_TYPE,
410
+ bytes([_MAP_INSIDE]) * (width * height),
411
+ struct.pack("<Iiiii", 0, top, left, height, width),
412
+ )
413
+ body = image + self._path_blocks(0, self.path_length) + self._pose_block()
414
+ digest_header = struct.pack("<HHI", _DIGEST_BLOCK_TYPE, 8, 20)
415
+ header = struct.pack("<2sHIHHII", b"rr", _MAP_FILE_HEADER_LEN, len(body) + len(digest_header) + 20, 1, 0, 0, 0)
416
+ data = header + body + digest_header
417
+ return data + hashlib.sha1(data, usedforsecurity=False).digest()
418
+
419
+ def _map_response_message(self, request_id: int, map_bytes: bytes) -> RoborockMessage:
420
+ """Frame the map as the encrypted binary response the device sends."""
421
+ body = Utils.encrypt_cbc(gzip.compress(map_bytes), self.security_data.nonce)
422
+ header = struct.pack("<8s8sH6s", self.security_data.endpoint.encode(), bytes(8), request_id & 0xFFFF, bytes(6))
423
+ return RoborockMessage(protocol=RoborockMessageProtocol.MAP_RESPONSE, payload=header + body)
424
+
336
425
  async def _handle_publish(self, message: RoborockMessage, channel: FakeChannel) -> None:
337
426
  if not message.payload:
338
427
  return
@@ -364,6 +453,11 @@ class V1VacuumSimulator(RoborockDeviceSimulator):
364
453
  else:
365
454
  result = "ok"
366
455
 
456
+ if method == "get_map_v1" and error is None and isinstance(result, bytes):
457
+ # Custom handlers return decompressed map bytes, just like the default.
458
+ channel.notify_subscribers(self._map_response_message(msg_id, result))
459
+ return
460
+
367
461
  response_data = {
368
462
  "dps": {"102": json.dumps({"id": msg_id, "result": result, "error": error})},
369
463
  "t": int(time.time()),
@@ -768,6 +768,31 @@ class RoborockApiClient:
768
768
  raise RoborockException(response)
769
769
  return response
770
770
 
771
+ async def update_job(self, user_data: UserData, device_id: str, job: HomeDataSchedule) -> None:
772
+ """Replace an existing cloud job with the full supplied schedule.
773
+
774
+ Pass a schedule returned by :meth:`get_schedules` with the desired changes.
775
+ The job ID identifies the URL; all other fields are sent as the replacement body.
776
+ """
777
+ rriot = user_data.rriot
778
+ if rriot is None:
779
+ raise RoborockException("rriot is none")
780
+ if rriot.r.a is None:
781
+ raise RoborockException("Missing field 'a' in rriot reference")
782
+ path = f"/user/devices/{device_id}/jobs/{job.id}"
783
+ body = job.as_dict(exclude={"id"})
784
+ job_request = PreparedRequest(
785
+ rriot.r.a,
786
+ self.session,
787
+ {
788
+ "Authorization": _get_hawk_authentication(rriot, path, body=body),
789
+ "Content-Type": "application/json",
790
+ },
791
+ )
792
+ response = await job_request.request("put", path, data=_compact_json(body).encode())
793
+ if not response or not response.get("success"):
794
+ raise RoborockException(response)
795
+
771
796
  async def get_products(self, user_data: UserData) -> ProductResponse:
772
797
  """Gets all products and their schemas, good for determining status codes and model numbers."""
773
798
  base_url = await self.base_url