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.
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.claude/skills/pylitterbot-ref/SKILL.md +3 -1
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/workflows/bandit.yml +2 -2
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/workflows/ci.yml +2 -2
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/workflows/coverage.yml +1 -1
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/workflows/release.yml +2 -2
- {asher_cli-1.6.0 → asher_cli-1.7.0}/CHANGELOG.md +6 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/PKG-INFO +5 -6
- {asher_cli-1.6.0 → asher_cli-1.7.0}/README.md +1 -1
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/faults.py +4 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/headless.py +4 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/mcp/config.py +9 -2
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/robot/adapters.py +33 -43
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/robot.py +3 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/slash.py +2 -2
- {asher_cli-1.6.0 → asher_cli-1.7.0}/pyproject.toml +7 -11
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_faults.py +6 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_headless.py +26 -6
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_mcp_command.py +1 -1
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_missing_robot_commands.py +5 -3
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_robot_adapters.py +11 -26
- {asher_cli-1.6.0 → asher_cli-1.7.0}/uv.lock +729 -717
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.claude/hooks/block-env.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.claude/settings.json +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.claude/skills/mcp-bridge/SKILL.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.claude/skills/textual/SKILL.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.env.example +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.githooks/pre-push +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/FUNDING.yml +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/pull_request_template.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.github/workflows/dependency-automerge.yml +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.gitignore +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.vscode/launch.json +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.vscode/settings.json +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/.vscode/tasks.json +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/CLAUDE.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/CODEOWNERS +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/LICENSE +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/app.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/__init__.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/__main__.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/__init__.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/activity.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/config.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/constants.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/credentials.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/export.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/helpers.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/theme.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/core/updates.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/__init__.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/autostart.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/daemon.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/desktoptheme.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/launcher.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/notifications.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/tray.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/desktop/watcher.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/mcp/__init__.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/mcp/bridge.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/robot/__init__.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/robot/protocol.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/__init__.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/app.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/cats.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/__init__.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/base.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/builtin.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/lr5.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/commands/registry.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/completion.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/connection.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/history.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/loginflow.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/loginscreen.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/monitoring.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/style.tcss +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/asher/tui/ui.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/cliff.toml +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/CONTRIBUTING.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/ROADMAP.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/README.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/cat-panel-badges.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/config-persistence.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/credentials.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/desktop-notifications.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/e2e-pilot.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/fault-monitoring.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/headless-export.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/history-export.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/lr5-features.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/multi-pet.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/robot-commands.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/runtime-slash-commands.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/slash-command-spec.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/sleep-schedule.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/tab-completion.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/ui-ux.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/version-display.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/docs/roadmap-archive/websocket-updates.md +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/renovate.json +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/requirements.txt +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/__init__.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/conftest.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_activity_labels.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_app_pilot.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_auth.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_auth_pilot.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_autostart.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_cats.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_commands_pilot.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_completion.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_config.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_connection_mixin.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_credentials.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_daemon.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_desktoptheme.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_export.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_history_view.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_launcher.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_layering.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_lr5_commands.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_mcp_bridge.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_mcp_config.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_monitoring.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_new_commands_pilot.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_notifications.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_tray.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_ui.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_updates.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_version_command.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/test_watcher.py +0 -0
- {asher_cli-1.6.0 → asher_cli-1.7.0}/tests/testhelpers.py +0 -0
- {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()` |
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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.
|
|
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.
|
|
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
|
|
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>=
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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``
|
|
384
|
-
|
|
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
|
|
443
|
-
requirements = [f"pylitterbot[mcp]=={pkg_version('pylitterbot')}"
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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>=
|
|
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>=
|
|
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>=
|
|
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.
|
|
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
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
)
|
|
685
|
-
|
|
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
|
|
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
|
|
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.
|
|
640
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
assert
|
|
182
|
-
|
|
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
|
|
772
|
-
ok, msg = await LR4Adapter(robot).set_sleep_window(
|
|
773
|
-
assert
|
|
774
|
-
assert "
|
|
775
|
-
robot.set_sleep_mode.
|
|
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
|