asher-cli 1.6.0__tar.gz → 1.7.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 (133) hide show
  1. {asher_cli-1.6.0 → asher_cli-1.7.0}/.claude/skills/pylitterbot-ref/SKILL.md +3 -1
  2. {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/workflows/bandit.yml +2 -2
  3. {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/workflows/ci.yml +2 -2
  4. {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/workflows/coverage.yml +1 -1
  5. {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/workflows/release.yml +2 -2
  6. {asher_cli-1.6.0 → asher_cli-1.7.0}/CHANGELOG.md +6 -0
  7. {asher_cli-1.6.0 → asher_cli-1.7.0}/PKG-INFO +5 -6
  8. {asher_cli-1.6.0 → asher_cli-1.7.0}/README.md +1 -1
  9. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/faults.py +4 -0
  10. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/headless.py +4 -0
  11. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/mcp/config.py +9 -2
  12. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/robot/adapters.py +33 -43
  13. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/robot.py +3 -0
  14. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/slash.py +2 -2
  15. {asher_cli-1.6.0 → asher_cli-1.7.0}/pyproject.toml +7 -11
  16. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_faults.py +6 -0
  17. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_headless.py +26 -6
  18. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_mcp_command.py +1 -1
  19. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_missing_robot_commands.py +5 -3
  20. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_robot_adapters.py +11 -26
  21. {asher_cli-1.6.0 → asher_cli-1.7.0}/uv.lock +729 -717
  22. {asher_cli-1.6.0 → asher_cli-1.7.0}/.claude/hooks/block-env.py +0 -0
  23. {asher_cli-1.6.0 → asher_cli-1.7.0}/.claude/settings.json +0 -0
  24. {asher_cli-1.6.0 → asher_cli-1.7.0}/.claude/skills/mcp-bridge/SKILL.md +0 -0
  25. {asher_cli-1.6.0 → asher_cli-1.7.0}/.claude/skills/textual/SKILL.md +0 -0
  26. {asher_cli-1.6.0 → asher_cli-1.7.0}/.env.example +0 -0
  27. {asher_cli-1.6.0 → asher_cli-1.7.0}/.githooks/pre-push +0 -0
  28. {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/FUNDING.yml +0 -0
  29. {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/pull_request_template.md +0 -0
  30. {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/workflows/dependency-automerge.yml +0 -0
  31. {asher_cli-1.6.0 → asher_cli-1.7.0}/.gitignore +0 -0
  32. {asher_cli-1.6.0 → asher_cli-1.7.0}/.vscode/launch.json +0 -0
  33. {asher_cli-1.6.0 → asher_cli-1.7.0}/.vscode/settings.json +0 -0
  34. {asher_cli-1.6.0 → asher_cli-1.7.0}/.vscode/tasks.json +0 -0
  35. {asher_cli-1.6.0 → asher_cli-1.7.0}/CLAUDE.md +0 -0
  36. {asher_cli-1.6.0 → asher_cli-1.7.0}/CODEOWNERS +0 -0
  37. {asher_cli-1.6.0 → asher_cli-1.7.0}/LICENSE +0 -0
  38. {asher_cli-1.6.0 → asher_cli-1.7.0}/app.py +0 -0
  39. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/__init__.py +0 -0
  40. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/__main__.py +0 -0
  41. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/__init__.py +0 -0
  42. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/activity.py +0 -0
  43. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/config.py +0 -0
  44. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/constants.py +0 -0
  45. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/credentials.py +0 -0
  46. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/export.py +0 -0
  47. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/helpers.py +0 -0
  48. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/theme.py +0 -0
  49. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/updates.py +0 -0
  50. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/__init__.py +0 -0
  51. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/autostart.py +0 -0
  52. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/daemon.py +0 -0
  53. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/desktoptheme.py +0 -0
  54. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/launcher.py +0 -0
  55. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/notifications.py +0 -0
  56. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/tray.py +0 -0
  57. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/watcher.py +0 -0
  58. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/mcp/__init__.py +0 -0
  59. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/mcp/bridge.py +0 -0
  60. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/robot/__init__.py +0 -0
  61. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/robot/protocol.py +0 -0
  62. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/__init__.py +0 -0
  63. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/app.py +0 -0
  64. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/cats.py +0 -0
  65. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/__init__.py +0 -0
  66. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/base.py +0 -0
  67. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/builtin.py +0 -0
  68. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/lr5.py +0 -0
  69. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/registry.py +0 -0
  70. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/completion.py +0 -0
  71. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/connection.py +0 -0
  72. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/history.py +0 -0
  73. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/loginflow.py +0 -0
  74. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/loginscreen.py +0 -0
  75. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/monitoring.py +0 -0
  76. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/style.tcss +0 -0
  77. {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/ui.py +0 -0
  78. {asher_cli-1.6.0 → asher_cli-1.7.0}/cliff.toml +0 -0
  79. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/CONTRIBUTING.md +0 -0
  80. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/ROADMAP.md +0 -0
  81. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/README.md +0 -0
  82. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/cat-panel-badges.md +0 -0
  83. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/config-persistence.md +0 -0
  84. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/credentials.md +0 -0
  85. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/desktop-notifications.md +0 -0
  86. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/e2e-pilot.md +0 -0
  87. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/fault-monitoring.md +0 -0
  88. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/headless-export.md +0 -0
  89. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/history-export.md +0 -0
  90. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/lr5-features.md +0 -0
  91. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/multi-pet.md +0 -0
  92. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/robot-commands.md +0 -0
  93. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/runtime-slash-commands.md +0 -0
  94. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/slash-command-spec.md +0 -0
  95. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/sleep-schedule.md +0 -0
  96. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/tab-completion.md +0 -0
  97. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/ui-ux.md +0 -0
  98. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/version-display.md +0 -0
  99. {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/websocket-updates.md +0 -0
  100. {asher_cli-1.6.0 → asher_cli-1.7.0}/renovate.json +0 -0
  101. {asher_cli-1.6.0 → asher_cli-1.7.0}/requirements.txt +0 -0
  102. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/__init__.py +0 -0
  103. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/conftest.py +0 -0
  104. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_activity_labels.py +0 -0
  105. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_app_pilot.py +0 -0
  106. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_auth.py +0 -0
  107. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_auth_pilot.py +0 -0
  108. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_autostart.py +0 -0
  109. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_cats.py +0 -0
  110. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_commands_pilot.py +0 -0
  111. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_completion.py +0 -0
  112. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_config.py +0 -0
  113. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_connection_mixin.py +0 -0
  114. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_credentials.py +0 -0
  115. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_daemon.py +0 -0
  116. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_desktoptheme.py +0 -0
  117. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_export.py +0 -0
  118. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_history_view.py +0 -0
  119. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_launcher.py +0 -0
  120. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_layering.py +0 -0
  121. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_lr5_commands.py +0 -0
  122. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_mcp_bridge.py +0 -0
  123. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_mcp_config.py +0 -0
  124. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_monitoring.py +0 -0
  125. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_new_commands_pilot.py +0 -0
  126. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_notifications.py +0 -0
  127. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_tray.py +0 -0
  128. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_ui.py +0 -0
  129. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_updates.py +0 -0
  130. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_version_command.py +0 -0
  131. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_watcher.py +0 -0
  132. {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/testhelpers.py +0 -0
  133. {asher_cli-1.6.0 → asher_cli-1.7.0}/watchrun.py +0 -0
@@ -29,6 +29,8 @@ robot.is_laser_dirty # bool — LR4 since 2025.7.0, LR5 always
29
29
  await robot.refresh()
30
30
  await robot.start_cleaning()
31
31
  await robot.set_sleep_mode(enabled: bool)
32
+ # LR4 (2025.8.0+) and LR5: per-weekday window, minutes from midnight, DayOfWeek Sun=0
33
+ await robot.set_sleep_mode(True, 1320, wake_time=420, day_of_week=1)
32
34
  await robot.set_panel_lockout(enabled: bool)
33
35
  await robot.set_night_light_brightness(brightness: int) # or:
34
36
  await robot.set_night_light_mode(mode: NightLightMode)
@@ -54,7 +56,7 @@ model_name = type(robot).__name__ # "LitterRobot4", "LitterRobot3", etc.
54
56
 
55
57
  | Feature | LR3 | LR4 | LR5 |
56
58
  |---|---|---|---|
57
- | `set_sleep_mode()` | Simple on/off | Per-weekday schedule | TBD |
59
+ | `set_sleep_mode()` | On/off + start time, fixed 8h | Per-weekday schedule (2025.8.0+); sleep == wake sleeps all day | Per-weekday schedule |
58
60
  | Night light | `set_night_light_brightness(int)` | `set_night_light_mode(NightLightMode)` | TBD |
59
61
 
60
62
  Use `asher/robot/adapters.py` for all model-specific dispatch — do not branch on `type(robot).__name__` inline in command handlers.
@@ -31,7 +31,7 @@ jobs:
31
31
  runs-on: ubuntu-latest
32
32
  steps:
33
33
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
34
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
34
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
35
35
 
36
36
  - name: Bandit scan
37
37
  # --exit-zero: findings must not fail the job, or the upload step below
@@ -41,7 +41,7 @@ jobs:
41
41
  -c pyproject.toml -r . -f sarif -o bandit.sarif --exit-zero
42
42
 
43
43
  - name: Upload SARIF to code scanning
44
- uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
44
+ uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
45
45
  with:
46
46
  sarif_file: bandit.sarif
47
47
  category: bandit
@@ -19,7 +19,7 @@ jobs:
19
19
  runs-on: ubuntu-latest
20
20
  steps:
21
21
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
22
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
22
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
23
23
  - run: uv sync --no-default-groups --group dev
24
24
  - run: uv run ruff check .
25
25
  - run: uv run ruff format --check .
@@ -35,6 +35,6 @@ jobs:
35
35
  runs-on: ${{ matrix.os }}
36
36
  steps:
37
37
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
38
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
38
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
39
39
  - run: uv sync --no-default-groups --group dev
40
40
  - run: uv run pytest tests/ -v --tb=short
@@ -18,7 +18,7 @@ jobs:
18
18
  runs-on: ubuntu-latest
19
19
  steps:
20
20
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
21
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
21
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
22
22
  - run: uv sync --no-default-groups --group dev
23
23
  - name: Run tests with coverage
24
24
  run: uv run pytest tests/ --cov=asher --cov-report=lcov --cov-report=term-missing
@@ -19,7 +19,7 @@ jobs:
19
19
  - name: Extract version from branch name
20
20
  id: version
21
21
  run: echo "version=${GITHUB_REF_NAME#release/}" >> "$GITHUB_OUTPUT"
22
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
22
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
23
23
  - run: uv build
24
24
  - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
25
25
  with:
@@ -38,7 +38,7 @@ jobs:
38
38
  with:
39
39
  name: dist
40
40
  path: dist/
41
- - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
41
+ - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1
42
42
 
43
43
  github-release:
44
44
  name: Create GitHub Release
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  Generated by [git-cliff](https://git-cliff.org) from conventional commits.
9
9
 
10
+ ## [1.7.0] - 2026-11-07
11
+
12
+ ### 🚀 Features
13
+
14
+ - *(lr4)* Sleep schedules and panel faults from pylitterbot 2025.8.0
15
+
10
16
  ## [1.6.0] - 2026-10-15
11
17
 
12
18
  ### 🚀 Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: asher-cli
3
- Version: 1.6.0
3
+ Version: 1.7.0
4
4
  Summary: Terminal dashboard for Litter Robot (LR3/LR4/LR5) via the Whisker cloud API
5
5
  Project-URL: Homepage, https://github.com/karanshukla/asher-cli
6
6
  Project-URL: Repository, https://github.com/karanshukla/asher-cli
@@ -39,16 +39,15 @@ Classifier: Topic :: Home Automation
39
39
  Requires-Python: >=3.11
40
40
  Requires-Dist: keyring>=24.0.0
41
41
  Requires-Dist: plyer>=2.1.0
42
- Requires-Dist: pylitterbot==2025.7.0
42
+ Requires-Dist: pylitterbot==2025.8.0
43
43
  Requires-Dist: python-dotenv>=1.0.0
44
44
  Requires-Dist: rich>=13.0.0
45
45
  Requires-Dist: textual>=0.47.0
46
46
  Requires-Dist: tzlocal>=5.4
47
47
  Provides-Extra: mcp
48
- Requires-Dist: mcp<2; extra == 'mcp'
49
- Requires-Dist: pylitterbot[mcp]==2025.7.0; extra == 'mcp'
48
+ Requires-Dist: pylitterbot[mcp]==2025.8.0; extra == 'mcp'
50
49
  Provides-Extra: tray
51
- Requires-Dist: pillow>=10.0; extra == 'tray'
50
+ Requires-Dist: pillow>=13.0; extra == 'tray'
52
51
  Requires-Dist: pygobject>=3.42; (sys_platform == 'linux') and extra == 'tray'
53
52
  Requires-Dist: pystray>=0.19; extra == 'tray'
54
53
  Description-Content-Type: text/markdown
@@ -151,7 +150,7 @@ The keyring is the only place an installed copy reads credentials from. Working
151
150
  | `rename <new name>` | Rename the unit in the Whisker cloud |
152
151
  | `insight [days\|month]` | Show cycle-usage statistics (default: 30 days) |
153
152
  | `sleep-schedule` | Show the per-day sleep schedule |
154
- | `sleep-schedule set <day\|all> <HH:MM> <HH:MM>` | Set the sleep → wake window (LR3: one window for every day, wake time fixed; LR4: read-only, use the Whisker app) |
153
+ | `sleep-schedule set <day\|all> <HH:MM> <HH:MM>` | Set the sleep → wake window (LR3: one window for every day, wake time fixed; LR4: the same sleep and wake time sleeps all day) |
155
154
  | `sleep-schedule disable` | Turn every scheduled sleep window off |
156
155
  | `privacy on\|off` | Toggle LR5 privacy mode |
157
156
  | `volume <0-100>` | Set LR5 sound volume |
@@ -96,7 +96,7 @@ The keyring is the only place an installed copy reads credentials from. Working
96
96
  | `rename <new name>` | Rename the unit in the Whisker cloud |
97
97
  | `insight [days\|month]` | Show cycle-usage statistics (default: 30 days) |
98
98
  | `sleep-schedule` | Show the per-day sleep schedule |
99
- | `sleep-schedule set <day\|all> <HH:MM> <HH:MM>` | Set the sleep → wake window (LR3: one window for every day, wake time fixed; LR4: read-only, use the Whisker app) |
99
+ | `sleep-schedule set <day\|all> <HH:MM> <HH:MM>` | Set the sleep → wake window (LR3: one window for every day, wake time fixed; LR4: the same sleep and wake time sleeps all day) |
100
100
  | `sleep-schedule disable` | Turn every scheduled sleep window off |
101
101
  | `privacy on\|off` | Toggle LR5 privacy mode |
102
102
  | `volume <0-100>` | Set LR5 sound volume |
@@ -48,6 +48,10 @@ class Fault:
48
48
  _SAFETY_STATUSES: dict[LitterBoxStatus, tuple[str, str]] = {
49
49
  LitterBoxStatus.CAT_DETECTED: ("CAT DETECTED — cycle halted", SEVERITY_ERROR),
50
50
  LitterBoxStatus.CAT_SENSOR_INTERRUPTED: ("CAT SENSOR INTERRUPTED", SEVERITY_ERROR),
51
+ LitterBoxStatus.CAT_SENSOR_FAULT: (
52
+ "CAT SENSOR FAULT — check for a blocked sensor",
53
+ SEVERITY_ERROR,
54
+ ),
51
55
  LitterBoxStatus.PINCH_DETECT: ("PINCH DETECT — possible obstruction", SEVERITY_ERROR),
52
56
  LitterBoxStatus.STARTUP_PINCH_DETECT: ("PINCH DETECT — possible obstruction", SEVERITY_ERROR),
53
57
  LitterBoxStatus.OVER_TORQUE_FAULT: ("OVER-TORQUE — globe blocked or jammed", SEVERITY_WARN),
@@ -381,6 +381,8 @@ async def _sleep_schedule(session: Session, args: list[str]) -> Result:
381
381
  "enabled": bool(getattr(day, "is_enabled", False)),
382
382
  "sleep_time": _sleep_time(getattr(day, "sleep_time", None)),
383
383
  "wake_time": _sleep_time(getattr(day, "wake_time", None)),
384
+ # LR4 only: matching sleep and wake times mean asleep all day.
385
+ "full_day": bool(getattr(day, "is_full_day", False)),
384
386
  }
385
387
  )
386
388
  days.sort(key=lambda entry: DAY_NAMES.index(entry["day"]))
@@ -392,6 +394,8 @@ async def _sleep_schedule(session: Session, args: list[str]) -> Result:
392
394
  if entry["enabled"]
393
395
  else "off"
394
396
  )
397
+ if entry["enabled"] and entry["full_day"]:
398
+ window += " (all day)"
395
399
  lines.append(f" {entry['day']} {window}")
396
400
  return Result(data={"enabled": enabled, "days": days}, lines=lines)
397
401
 
@@ -16,8 +16,15 @@ class McpConfigError(Exception):
16
16
 
17
17
 
18
18
  def mcp_extra_installed() -> bool:
19
- """Whether pylitterbot's mcp extra (the third-party `mcp` SDK) is importable."""
20
- return importlib.util.find_spec("mcp") is not None
19
+ """Whether pylitterbot's mcp extra (the third-party `mcp` SDK, 2.x) is importable.
20
+
21
+ Probes the module pylitterbot's server imports rather than `mcp` itself, so an
22
+ mcp 1.x left over from an older `/mcp on` counts as missing and gets upgraded.
23
+ """
24
+ try:
25
+ return importlib.util.find_spec("mcp.server.mcpserver") is not None
26
+ except ModuleNotFoundError:
27
+ return False
21
28
 
22
29
 
23
30
  def config_paths() -> list[Path]:
@@ -18,13 +18,6 @@ if TYPE_CHECKING:
18
18
  # litterrobot3.SLEEP_DURATION_HOURS), so only the start time is settable.
19
19
  LR3_SLEEP_HOURS = 8
20
20
 
21
- # The LR4 exposes no schedule setter at all — the app is the only way to change
22
- # one, and saying so beats a cloud rejection the user can't act on.
23
- LR4_SCHEDULE_NOTE = (
24
- "LR4 sleep schedules can only be changed in the Whisker app — "
25
- "'sleep-schedule' shows the one it's running"
26
- )
27
-
28
21
  # Only the LR5's activity endpoint takes a type parameter; the LR3/LR4 history
29
22
  # call returns whatever the cloud feels like sending.
30
23
  HISTORY_FILTER_NOTE = "Filtering history by type is only available on the LR5"
@@ -77,7 +70,7 @@ class RobotAdapter(ABC):
77
70
 
78
71
  Disabling is the same cloud call as waking the robot on every model that
79
72
  has one, so this rides on ``set_sleep`` rather than repeating the
80
- per-model dispatch — including the LR4's "use the app" refusal.
73
+ per-model dispatch.
81
74
  """
82
75
  ok, msg = await self.set_sleep(False)
83
76
  return (True, "Sleep schedule disabled for every day") if ok else (False, msg)
@@ -165,15 +158,41 @@ class LR3Adapter(RobotAdapter):
165
158
  )
166
159
 
167
160
 
168
- class LR4Adapter(RobotAdapter):
161
+ class _WeekdaySleepMixin:
162
+ """Sleep commands for the LR4 and LR5, which share pylitterbot's per-weekday
163
+ ``set_sleep_mode(value, sleep, wake_time=, day_of_week=)``."""
164
+
165
+ robot: RobotProtocol
166
+
169
167
  async def set_sleep(self, enable: bool) -> tuple[bool, str]:
170
- return False, LR4_SCHEDULE_NOTE
168
+ try:
169
+ ok = await self.robot.set_sleep_mode(enable)
170
+ except Exception as exc:
171
+ return False, f"Sleep mode change failed: {exc}"
172
+ if not ok:
173
+ return False, "Sleep mode command rejected"
174
+ return True, "Sleep mode enabled" if enable else "Robot woken up"
171
175
 
172
176
  async def set_sleep_window(
173
177
  self, weekday: int | None, sleep: time, wake: time
174
178
  ) -> tuple[bool, str]:
175
- return False, LR4_SCHEDULE_NOTE
179
+ robot: Any = self.robot
180
+ try:
181
+ ok = await robot.set_sleep_mode(
182
+ True,
183
+ sleep.hour * 60 + sleep.minute,
184
+ wake_time=wake.hour * 60 + wake.minute,
185
+ day_of_week=_to_day_of_week(weekday),
186
+ )
187
+ except Exception as exc:
188
+ return False, f"Sleep schedule change failed: {exc}"
189
+ if not ok:
190
+ return False, "Sleep schedule command rejected"
191
+ when = "every day" if weekday is None else DAY_NAMES[weekday]
192
+ return True, f"Sleeping {sleep:%H:%M} → {wake:%H:%M} on {when}"
193
+
176
194
 
195
+ class LR4Adapter(_WeekdaySleepMixin, RobotAdapter):
177
196
  async def set_night_light(self, mode: str) -> tuple[bool, str]:
178
197
  from pylitterbot.enums import NightLightMode # noqa: PLC0415
179
198
 
@@ -224,18 +243,9 @@ class LR4Adapter(RobotAdapter):
224
243
  return True, f"Panel brightness set to {level}"
225
244
 
226
245
 
227
- class LR5Adapter(RobotAdapter):
246
+ class LR5Adapter(_WeekdaySleepMixin, RobotAdapter):
228
247
  supports_history_filter = True
229
248
 
230
- async def set_sleep(self, enable: bool) -> tuple[bool, str]:
231
- try:
232
- ok = await self.robot.set_sleep_mode(enable)
233
- except Exception as exc:
234
- return False, f"Sleep mode change failed: {exc}"
235
- if not ok:
236
- return False, "Sleep mode command rejected"
237
- return True, "Sleep mode enabled" if enable else "Robot woken up"
238
-
239
249
  async def set_night_light(self, mode: str) -> tuple[bool, str]:
240
250
  from pylitterbot.enums import NightLightMode # noqa: PLC0415
241
251
 
@@ -335,24 +345,6 @@ class LR5Adapter(RobotAdapter):
335
345
  return False, "Night light colour command rejected"
336
346
  return True, f"Night light colour set to {colour}"
337
347
 
338
- async def set_sleep_window(
339
- self, weekday: int | None, sleep: time, wake: time
340
- ) -> tuple[bool, str]:
341
- robot: Any = self.robot
342
- try:
343
- ok = await robot.set_sleep_mode(
344
- True,
345
- sleep.hour * 60 + sleep.minute,
346
- wake_time=wake.hour * 60 + wake.minute,
347
- day_of_week=_to_day_of_week(weekday),
348
- )
349
- except Exception as exc:
350
- return False, f"Sleep schedule change failed: {exc}"
351
- if not ok:
352
- return False, "Sleep schedule command rejected"
353
- when = "every day" if weekday is None else DAY_NAMES[weekday]
354
- return True, f"Sleeping {sleep:%H:%M} → {wake:%H:%M} on {when}"
355
-
356
348
  async def get_history(
357
349
  self, limit: int, activity_type: str | None = None
358
350
  ) -> tuple[list[Activity] | None, str]:
@@ -380,10 +372,8 @@ class LR5Adapter(RobotAdapter):
380
372
  def _to_day_of_week(weekday: int | None) -> int | None:
381
373
  """Convert a Python weekday (Mon=0…Sun=6) to pylitterbot's DayOfWeek (Sun=0…Sat=6).
382
374
 
383
- ``set_sleep_mode``'s own docstring claims 0=Monday, but it matches the value
384
- against the ``dayOfWeek`` field of the schedule the cloud sent back, and
385
- those follow the ``DayOfWeek`` enum — so the enum's numbering is the one
386
- that actually selects a day.
375
+ ``set_sleep_mode`` matches the value against the ``dayOfWeek`` field of the
376
+ schedule the cloud sent back, and those follow the ``DayOfWeek`` enum.
387
377
  """
388
378
  return None if weekday is None else (weekday + 1) % 7
389
379
 
@@ -754,6 +754,9 @@ class SleepScheduleCommand(Command):
754
754
  t.append(f" {name} ", style=theme.MUTED)
755
755
  if day_enabled:
756
756
  window_str = f"{_fmt_sleep_time(sleep_t)} → {_fmt_sleep_time(wake_t)}"
757
+ # LR4 only: matching sleep and wake times mean asleep all day.
758
+ if getattr(day, "is_full_day", False):
759
+ window_str += " (all day)"
757
760
  t.append(window_str, style=theme.FOREGROUND)
758
761
  if idx in active_day_indices:
759
762
  t.append(" ● now", style=f"bold {theme.WARN}")
@@ -439,8 +439,8 @@ async def _ensure_mcp_extra(app: AsherApp) -> bool:
439
439
  if mcp_extra_installed():
440
440
  return True
441
441
 
442
- # Same requirements as the `mcp` extra in pyproject.toml, cap included.
443
- requirements = [f"pylitterbot[mcp]=={pkg_version('pylitterbot')}", "mcp<2"]
442
+ # Same requirement as the `mcp` extra in pyproject.toml.
443
+ requirements = [f"pylitterbot[mcp]=={pkg_version('pylitterbot')}"]
444
444
  app._log_info(f"Installing {' '.join(requirements)}…")
445
445
  proc = await asyncio.create_subprocess_exec(
446
446
  sys.executable,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "asher-cli"
3
- version = "1.6.0"
3
+ version = "1.7.0"
4
4
  description = "Terminal dashboard for Litter Robot (LR3/LR4/LR5) via the Whisker cloud API"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -16,7 +16,7 @@ classifiers = [
16
16
  "Topic :: Home Automation",
17
17
  ]
18
18
  dependencies = [
19
- "pylitterbot==2025.7.0",
19
+ "pylitterbot==2025.8.0",
20
20
  "textual>=0.47.0",
21
21
  "rich>=13.0.0",
22
22
  "python-dotenv>=1.0.0",
@@ -26,15 +26,11 @@ dependencies = [
26
26
  ]
27
27
 
28
28
  [project.optional-dependencies]
29
- # pylitterbot asks for mcp[cli]>=1.0.0 with no upper bound, but its server does
30
- # `from mcp.server.fastmcp import FastMCP`, and mcp 2.0 deleted that module.
31
- # Cap it here, and in _ensure_mcp_extra (tui/commands/slash.py), until
32
- # pylitterbot ships a 2.x-compatible release.
33
- mcp = ["pylitterbot[mcp]==2025.7.0", "mcp<2"]
29
+ mcp = ["pylitterbot[mcp]==2025.8.0"]
34
30
  # PyGObject is what puts pystray on its AppIndicator backend. Without it pystray
35
31
  # silently falls back to a legacy XEmbed icon, which KDE Plasma and GNOME no
36
32
  # longer host at all — the watcher runs, draws an icon, and nothing shows it.
37
- tray = ["pystray>=0.19", "pillow>=10.0", "pygobject>=3.42; sys_platform == 'linux'"]
33
+ tray = ["pystray>=0.19", "pillow>=13.0", "pygobject>=3.42; sys_platform == 'linux'"]
38
34
 
39
35
  [project.scripts]
40
36
  asher = "asher.__main__:main"
@@ -58,7 +54,7 @@ dev = [
58
54
  "textual-dev>=1.0",
59
55
  "watchfiles>=0.21",
60
56
  "git-cliff>=2.0",
61
- "pillow>=10.0", # so the tray icon renderer is covered in CI
57
+ "pillow>=13.0", # so the tray icon renderer is covered in CI
62
58
  ]
63
59
  # Mirrors the `tray` extra, because a plain `uv run` re-syncs the environment to
64
60
  # the default groups and prunes everything else — which silently removed pystray
@@ -69,7 +65,7 @@ dev = [
69
65
  # from source and the runners have no GObject-introspection headers.
70
66
  tray = [
71
67
  "pystray>=0.19",
72
- "pillow>=10.0",
68
+ "pillow>=13.0",
73
69
  "pygobject>=3.42; sys_platform == 'linux'",
74
70
  ]
75
71
 
@@ -131,7 +127,7 @@ ignore_missing_imports = true # pylitterbot and textual have incomplete stubs
131
127
  # ── bump-my-version ───────────────────────────────────────────────────────────
132
128
 
133
129
  [tool.bumpversion]
134
- current_version = "1.6.0"
130
+ current_version = "1.7.0"
135
131
  commit = true
136
132
  tag = true
137
133
  tag_name = "v{new_version}"
@@ -89,6 +89,12 @@ class TestSafetyStatuses:
89
89
  assert len(faults) == 1
90
90
  assert faults[0].severity == SEVERITY_ERROR
91
91
 
92
+ def test_cat_sensor_fault_is_error(self):
93
+ # pylitterbot reports this for the LR4 panel's 30-minute cat-detect fault.
94
+ faults = check_faults(_lr4(status=LitterBoxStatus.CAT_SENSOR_FAULT))
95
+ assert len(faults) == 1
96
+ assert faults[0].severity == SEVERITY_ERROR
97
+
92
98
  def test_over_torque_fault_is_warn(self):
93
99
  faults = check_faults(_lr4(status=LitterBoxStatus.OVER_TORQUE_FAULT))
94
100
  assert len(faults) == 1
@@ -303,6 +303,25 @@ class TestSleepSchedule:
303
303
  assert result.data["days"][1]["sleep_time"] == "22:00"
304
304
  assert result.data["days"][0]["enabled"] is False
305
305
 
306
+ async def test_a_full_day_window_is_called_out(self):
307
+ robot = _robot(
308
+ sleep_schedule=SimpleNamespace(
309
+ is_enabled=True,
310
+ days=[
311
+ SimpleNamespace(
312
+ day=1,
313
+ is_enabled=True,
314
+ sleep_time=time(22, 0),
315
+ wake_time=time(22, 0),
316
+ is_full_day=True,
317
+ ),
318
+ ],
319
+ )
320
+ )
321
+ result = await COMMANDS_BY_NAME["sleep-schedule"].handler(_session(robot), [])
322
+ assert result.data["days"][0]["full_day"] is True
323
+ assert result.lines[0].endswith("22:00 → 22:00 (all day)")
324
+
306
325
 
307
326
  # ── action commands ──────────────────────────────────────────────────────────
308
327
 
@@ -677,12 +696,13 @@ class TestSleepScheduleWrites:
677
696
  await COMMANDS_BY_NAME["sleep-schedule"].handler(_session(), ["reticulate"])
678
697
  assert "Usage" in str(excinfo.value)
679
698
 
680
- async def test_writing_a_schedule_on_an_lr4_points_at_the_app(self):
681
- with pytest.raises(CommandError) as excinfo:
682
- await COMMANDS_BY_NAME["sleep-schedule"].handler(
683
- _session(), ["set", "all", "22:00", "07:00"]
684
- )
685
- assert "Whisker app" in str(excinfo.value)
699
+ async def test_writing_a_schedule_on_an_lr4_sends_the_window(self):
700
+ robot = _robot()
701
+ robot.set_sleep_mode = AsyncMock(return_value=True)
702
+ await COMMANDS_BY_NAME["sleep-schedule"].handler(
703
+ _session(robot), ["set", "all", "22:00", "07:00"]
704
+ )
705
+ robot.set_sleep_mode.assert_awaited_once_with(True, 1320, wake_time=420, day_of_week=None)
686
706
 
687
707
 
688
708
  class TestFilterReminder:
@@ -86,7 +86,7 @@ class TestMcpCommand:
86
86
  patch("asher.mcp.config.set_mcp_enabled", return_value=[Path("x")]) as mock_set,
87
87
  ):
88
88
  await McpCommand().run(app, ["on"])
89
- assert "mcp<2" in mock_exec.call_args.args
89
+ assert "pylitterbot[mcp]==" in " ".join(mock_exec.call_args.args)
90
90
  mock_set.assert_called_once_with(True)
91
91
 
92
92
  async def test_on_skips_enabling_when_install_fails(self, app):
@@ -622,7 +622,7 @@ async def test_sleep_schedule_disable(connected_app):
622
622
 
623
623
 
624
624
  @pytest.mark.asyncio
625
- async def test_sleep_schedule_write_on_lr4_points_at_the_app(connected_app):
625
+ async def test_sleep_schedule_write_on_lr4_sends_the_window(connected_app):
626
626
  from asher.robot.adapters import LR4Adapter
627
627
 
628
628
  connected_app._robot.set_sleep_mode = AsyncMock(return_value=True)
@@ -636,8 +636,10 @@ async def test_sleep_schedule_write_on_lr4_points_at_the_app(connected_app):
636
636
 
637
637
  log = _log_text(connected_app)
638
638
 
639
- connected_app._robot.set_sleep_mode.assert_not_called()
640
- assert "Whisker app" in log
639
+ connected_app._robot.set_sleep_mode.assert_called_once_with(
640
+ True, 1320, wake_time=420, day_of_week=None
641
+ )
642
+ assert "Sleeping" in log
641
643
 
642
644
 
643
645
  @pytest.mark.asyncio
@@ -175,19 +175,11 @@ async def test_lr3_night_light_brightness_unsupported(robot):
175
175
 
176
176
 
177
177
  @pytest.mark.asyncio
178
- async def test_lr4_sleep_unsupported(robot):
179
- ok, msg = await LR4Adapter(robot).set_sleep(True)
180
- assert not ok
181
- assert "LR4" in msg
182
- assert "sleep-schedule" in msg
183
-
184
-
185
- @pytest.mark.asyncio
186
- async def test_lr4_wake_unsupported(robot):
187
- ok, msg = await LR4Adapter(robot).set_sleep(False)
188
- assert not ok
189
- assert "LR4" in msg
190
- assert "sleep-schedule" in msg
178
+ @pytest.mark.parametrize("enable", [True, False])
179
+ async def test_lr4_sleep_toggles_the_schedule(robot, enable):
180
+ ok, _ = await LR4Adapter(robot).set_sleep(enable)
181
+ assert ok
182
+ robot.set_sleep_mode.assert_awaited_once_with(enable)
191
183
 
192
184
 
193
185
  @pytest.mark.asyncio
@@ -768,18 +760,18 @@ async def test_lr3_refuses_a_single_day(robot):
768
760
 
769
761
 
770
762
  @pytest.mark.asyncio
771
- async def test_lr4_sleep_window_points_at_the_app(robot):
772
- ok, msg = await LR4Adapter(robot).set_sleep_window(None, time(22, 0), time(7, 0))
773
- assert not ok
774
- assert "Whisker app" in msg
775
- robot.set_sleep_mode.assert_not_called()
763
+ async def test_lr4_sleep_window_sends_one_day(robot):
764
+ ok, msg = await LR4Adapter(robot).set_sleep_window(0, time(22, 0), time(7, 0))
765
+ assert ok
766
+ assert "Mon" in msg
767
+ robot.set_sleep_mode.assert_awaited_once_with(True, 1320, wake_time=420, day_of_week=1)
776
768
 
777
769
 
778
770
  # ── sleep schedule: disable ───────────────────────────────────────────────────
779
771
 
780
772
 
781
773
  @pytest.mark.asyncio
782
- @pytest.mark.parametrize("adapter_cls", [LR3Adapter, LR5Adapter])
774
+ @pytest.mark.parametrize("adapter_cls", [LR3Adapter, LR4Adapter, LR5Adapter])
783
775
  async def test_disable_schedule_turns_sleep_off(robot, adapter_cls):
784
776
  ok, msg = await adapter_cls(robot).disable_sleep_schedule()
785
777
  assert ok
@@ -787,13 +779,6 @@ async def test_disable_schedule_turns_sleep_off(robot, adapter_cls):
787
779
  robot.set_sleep_mode.assert_awaited_once_with(False)
788
780
 
789
781
 
790
- @pytest.mark.asyncio
791
- async def test_disable_schedule_on_lr4_explains_instead(robot):
792
- ok, msg = await LR4Adapter(robot).disable_sleep_schedule()
793
- assert not ok
794
- assert "Whisker app" in msg
795
-
796
-
797
782
  @pytest.mark.asyncio
798
783
  async def test_disable_schedule_surfaces_a_rejection(robot):
799
784
  robot.set_sleep_mode.return_value = False