asher-cli 1.5.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.5.0 → asher_cli-1.7.0}/.claude/skills/pylitterbot-ref/SKILL.md +4 -1
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/workflows/bandit.yml +2 -2
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/workflows/ci.yml +3 -3
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/workflows/coverage.yml +1 -1
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/workflows/release.yml +5 -5
- {asher_cli-1.5.0 → asher_cli-1.7.0}/CHANGELOG.md +26 -9
- {asher_cli-1.5.0 → asher_cli-1.7.0}/CLAUDE.md +4 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/PKG-INFO +8 -10
- {asher_cli-1.5.0 → asher_cli-1.7.0}/README.md +3 -3
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/export.py +4 -4
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/faults.py +7 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/helpers.py +5 -5
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/headless.py +4 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/mcp/config.py +9 -2
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/robot/adapters.py +33 -43
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/robot.py +4 -1
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/slash.py +2 -2
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/history.py +3 -3
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/CONTRIBUTING.md +1 -1
- {asher_cli-1.5.0 → asher_cli-1.7.0}/pyproject.toml +10 -15
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/conftest.py +2 -2
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_activity_labels.py +3 -3
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_app_pilot.py +2 -2
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_commands_pilot.py +2 -2
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_completion.py +2 -2
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_export.py +5 -5
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_faults.py +11 -1
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_headless.py +30 -10
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_history_view.py +14 -16
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_lr5_commands.py +2 -2
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_mcp_command.py +1 -1
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_missing_robot_commands.py +8 -6
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_monitoring.py +3 -3
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_new_commands_pilot.py +3 -3
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_robot_adapters.py +11 -26
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/testhelpers.py +11 -11
- {asher_cli-1.5.0 → asher_cli-1.7.0}/uv.lock +1118 -1507
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.claude/hooks/block-env.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.claude/settings.json +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.claude/skills/mcp-bridge/SKILL.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.claude/skills/textual/SKILL.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.env.example +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.githooks/pre-push +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/FUNDING.yml +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/pull_request_template.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/workflows/dependency-automerge.yml +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.gitignore +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.vscode/launch.json +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.vscode/settings.json +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/.vscode/tasks.json +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/CODEOWNERS +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/LICENSE +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/app.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/__init__.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/__main__.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/__init__.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/activity.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/config.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/constants.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/credentials.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/theme.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/updates.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/__init__.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/autostart.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/daemon.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/desktoptheme.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/launcher.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/notifications.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/tray.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/watcher.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/mcp/__init__.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/mcp/bridge.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/robot/__init__.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/robot/protocol.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/__init__.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/app.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/cats.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/__init__.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/base.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/builtin.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/lr5.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/registry.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/completion.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/connection.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/loginflow.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/loginscreen.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/monitoring.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/style.tcss +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/ui.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/cliff.toml +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/ROADMAP.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/README.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/cat-panel-badges.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/config-persistence.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/credentials.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/desktop-notifications.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/e2e-pilot.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/fault-monitoring.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/headless-export.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/history-export.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/lr5-features.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/multi-pet.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/robot-commands.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/runtime-slash-commands.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/slash-command-spec.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/sleep-schedule.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/tab-completion.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/ui-ux.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/version-display.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/websocket-updates.md +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/renovate.json +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/requirements.txt +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/__init__.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_auth.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_auth_pilot.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_autostart.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_cats.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_config.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_connection_mixin.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_credentials.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_daemon.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_desktoptheme.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_launcher.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_layering.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_mcp_bridge.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_mcp_config.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_notifications.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_tray.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_ui.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_updates.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_version_command.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_watcher.py +0 -0
- {asher_cli-1.5.0 → asher_cli-1.7.0}/watchrun.py +0 -0
|
@@ -20,6 +20,7 @@ robot.sleep_mode_enabled # bool — NOT "sleeping"
|
|
|
20
20
|
robot.panel_lock_enabled # bool — NOT "panel_lockout"
|
|
21
21
|
robot.night_light_mode_enabled # bool
|
|
22
22
|
robot.last_seen # datetime | None
|
|
23
|
+
robot.is_laser_dirty # bool — LR4 since 2025.7.0, LR5 always
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
## Robot methods (all async, await required)
|
|
@@ -28,6 +29,8 @@ robot.last_seen # datetime | None
|
|
|
28
29
|
await robot.refresh()
|
|
29
30
|
await robot.start_cleaning()
|
|
30
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)
|
|
31
34
|
await robot.set_panel_lockout(enabled: bool)
|
|
32
35
|
await robot.set_night_light_brightness(brightness: int) # or:
|
|
33
36
|
await robot.set_night_light_mode(mode: NightLightMode)
|
|
@@ -53,7 +56,7 @@ model_name = type(robot).__name__ # "LitterRobot4", "LitterRobot3", etc.
|
|
|
53
56
|
|
|
54
57
|
| Feature | LR3 | LR4 | LR5 |
|
|
55
58
|
|---|---|---|---|
|
|
56
|
-
| `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 |
|
|
57
60
|
| Night light | `set_night_light_brightness(int)` | `set_night_light_mode(NightLightMode)` | TBD |
|
|
58
61
|
|
|
59
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 .
|
|
@@ -30,11 +30,11 @@ jobs:
|
|
|
30
30
|
needs: lint
|
|
31
31
|
strategy:
|
|
32
32
|
matrix:
|
|
33
|
-
python-version: ["3.
|
|
33
|
+
python-version: ["3.11", "3.12"]
|
|
34
34
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
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,9 +19,9 @@ 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
|
-
- uses: actions/upload-artifact@
|
|
24
|
+
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
|
25
25
|
with:
|
|
26
26
|
name: dist
|
|
27
27
|
path: dist/
|
|
@@ -34,11 +34,11 @@ jobs:
|
|
|
34
34
|
permissions:
|
|
35
35
|
id-token: write # needed for PyPI OIDC trusted publishing
|
|
36
36
|
steps:
|
|
37
|
-
- uses: actions/download-artifact@
|
|
37
|
+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
|
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
|
|
@@ -48,7 +48,7 @@ jobs:
|
|
|
48
48
|
contents: write # needed to create GitHub Releases
|
|
49
49
|
steps:
|
|
50
50
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
51
|
-
- uses: actions/download-artifact@
|
|
51
|
+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
|
52
52
|
with:
|
|
53
53
|
name: dist
|
|
54
54
|
path: dist/
|
|
@@ -7,14 +7,33 @@ 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
|
+
|
|
16
|
+
## [1.6.0] - 2026-10-15
|
|
17
|
+
|
|
18
|
+
### 🚀 Features
|
|
19
|
+
|
|
20
|
+
- *(faults)* Alert when LR4 laser sensors are dirty
|
|
21
|
+
|
|
22
|
+
### 🔧 Miscellaneous
|
|
23
|
+
|
|
24
|
+
- [**breaking**] Drop Python 3.10 support
|
|
25
|
+
|
|
10
26
|
## [1.5.0] - 2026-10-03
|
|
11
27
|
|
|
12
|
-
###
|
|
28
|
+
### 🐛 Bug Fixes
|
|
29
|
+
|
|
30
|
+
- *(mcp)* Don't overwrite a claude_desktop_config.json that can't be parsed
|
|
31
|
+
- *(mcp)* Cap mcp<2 when /mcp on installs the extra
|
|
13
32
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- *(
|
|
17
|
-
- *(
|
|
33
|
+
### 👷 CI/CD
|
|
34
|
+
|
|
35
|
+
- *(release)* Scope token permissions to the jobs that need them
|
|
36
|
+
- *(bandit)* Pin bandit to 1.9.4
|
|
18
37
|
|
|
19
38
|
## [1.4.1] - 2026-10-01
|
|
20
39
|
|
|
@@ -23,10 +42,6 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
|
|
|
23
42
|
- *(export)* Reach the full 30 days on the LR4
|
|
24
43
|
- *(tui)* Hang wrapped log lines under their message
|
|
25
44
|
|
|
26
|
-
### 🛡️ Security
|
|
27
|
-
|
|
28
|
-
- *(deps)* Refresh `uv.lock` to patch pyjwt and urllib3 CVEs (#101)
|
|
29
|
-
|
|
30
45
|
### ♻️ Refactor
|
|
31
46
|
|
|
32
47
|
- Group the asher package into core, robot, tui, desktop and mcp
|
|
@@ -41,6 +56,8 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
|
|
|
41
56
|
|
|
42
57
|
### 🔧 Miscellaneous
|
|
43
58
|
|
|
59
|
+
- *(release)* Cut v1.4.1
|
|
60
|
+
- *(release)* Cut v1.4.0
|
|
44
61
|
- Remove claude.yml
|
|
45
62
|
- Remove claude-code-review.yml
|
|
46
63
|
|
|
@@ -277,3 +277,7 @@ Regenerate with `changelog-release X.Y.Z`, never plain `changelog` — the workf
|
|
|
277
277
|
- Mock external deps with `unittest.mock.AsyncMock` for async robot/account methods
|
|
278
278
|
- `from pylitterbot import Account` is a local import inside `_connect_worker` — patch it at `pylitterbot.Account`, not `asher.tui.connection.Account`
|
|
279
279
|
- Coverage: ~76% overall; main gaps are async exception paths and `_connect_worker` auth flow
|
|
280
|
+
|
|
281
|
+
## Temp files and large downloads
|
|
282
|
+
|
|
283
|
+
`/tmp` is tmpfs on this machine, and so is Claude Code's scratchpad (it lives under `/tmp`), so anything written there is held in RAM. Put venvs, model and runtime downloads, extracted archives and build output on disk under `~/.cache/claude-scratch/<task>/` instead, and keep only small throwaway files in the scratchpad. An in-repo `.venv` or `target/` is already on disk and fine.
|
|
@@ -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
|
|
@@ -33,23 +33,21 @@ Classifier: Development Status :: 4 - Beta
|
|
|
33
33
|
Classifier: Environment :: Console
|
|
34
34
|
Classifier: License :: OSI Approved :: MIT License
|
|
35
35
|
Classifier: Programming Language :: Python :: 3
|
|
36
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
37
36
|
Classifier: Programming Language :: Python :: 3.11
|
|
38
37
|
Classifier: Programming Language :: Python :: 3.12
|
|
39
38
|
Classifier: Topic :: Home Automation
|
|
40
|
-
Requires-Python: >=3.
|
|
39
|
+
Requires-Python: >=3.11
|
|
41
40
|
Requires-Dist: keyring>=24.0.0
|
|
42
41
|
Requires-Dist: plyer>=2.1.0
|
|
43
|
-
Requires-Dist: pylitterbot==2025.
|
|
42
|
+
Requires-Dist: pylitterbot==2025.8.0
|
|
44
43
|
Requires-Dist: python-dotenv>=1.0.0
|
|
45
44
|
Requires-Dist: rich>=13.0.0
|
|
46
45
|
Requires-Dist: textual>=0.47.0
|
|
47
46
|
Requires-Dist: tzlocal>=5.4
|
|
48
47
|
Provides-Extra: mcp
|
|
49
|
-
Requires-Dist: mcp
|
|
50
|
-
Requires-Dist: pylitterbot[mcp]==2025.6.6; extra == 'mcp'
|
|
48
|
+
Requires-Dist: pylitterbot[mcp]==2025.8.0; extra == 'mcp'
|
|
51
49
|
Provides-Extra: tray
|
|
52
|
-
Requires-Dist: pillow>=
|
|
50
|
+
Requires-Dist: pillow>=13.0; extra == 'tray'
|
|
53
51
|
Requires-Dist: pygobject>=3.42; (sys_platform == 'linux') and extra == 'tray'
|
|
54
52
|
Requires-Dist: pystray>=0.19; extra == 'tray'
|
|
55
53
|
Description-Content-Type: text/markdown
|
|
@@ -75,7 +73,7 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
|
|
|
75
73
|
- Live status bar — unit name, online/offline, drawer fill level, last activity, cat weight
|
|
76
74
|
- Human-readable robot status — translates raw API states into plain English (`Ready`, `Cleaning`, `Cat Detected`, `Drawer Full`, etc.)
|
|
77
75
|
- Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`)
|
|
78
|
-
- Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults (LR5
|
|
76
|
+
- Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults, dirty laser sensors (LR4/LR5), LR5 bonnet/drawer; press `d` to dismiss
|
|
79
77
|
- Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
|
|
80
78
|
- Scrollable activity-history pager — `history [count|all] [--type <kind>]` opens a full-screen, paginated view (arrow keys, `Page Up`/`Page Down`, `Home`/`End`); `c` copies the whole history to the clipboard; `q`/`Esc`/`Enter` to close
|
|
81
79
|
- Commands: `clean`, `status`, `info`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto|color <hex>`, `night-light-brightness`, `panel-brightness <low|medium|high>` (LR4/LR5), `wait-time`, `power on|off`, `rename`, `insight`, `sleep-schedule [set|disable]`, plus LR5 extras (`privacy`, `volume`, `camera-audio`, `drawer-reset`), `history [count|all] [--type <kind>]`, `export [days|month]`, `help`, `quit`
|
|
@@ -152,7 +150,7 @@ The keyring is the only place an installed copy reads credentials from. Working
|
|
|
152
150
|
| `rename <new name>` | Rename the unit in the Whisker cloud |
|
|
153
151
|
| `insight [days\|month]` | Show cycle-usage statistics (default: 30 days) |
|
|
154
152
|
| `sleep-schedule` | Show the per-day sleep schedule |
|
|
155
|
-
| `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) |
|
|
156
154
|
| `sleep-schedule disable` | Turn every scheduled sleep window off |
|
|
157
155
|
| `privacy on\|off` | Toggle LR5 privacy mode |
|
|
158
156
|
| `volume <0-100>` | Set LR5 sound volume |
|
|
@@ -470,7 +468,7 @@ Scan settings live in `[tool.bandit]` in `pyproject.toml`, so local runs and the
|
|
|
470
468
|
Bandit workflow report identical findings. Results from `main` and pull requests
|
|
471
469
|
are published to the repository's **Security → Code scanning** tab.
|
|
472
470
|
|
|
473
|
-
CI runs on Python 3.
|
|
471
|
+
CI runs on Python 3.11 / 3.12 across Ubuntu, Windows, and macOS on every push.
|
|
474
472
|
|
|
475
473
|
## Changelog
|
|
476
474
|
|
|
@@ -19,7 +19,7 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
|
|
|
19
19
|
- Live status bar — unit name, online/offline, drawer fill level, last activity, cat weight
|
|
20
20
|
- Human-readable robot status — translates raw API states into plain English (`Ready`, `Cleaning`, `Cat Detected`, `Drawer Full`, etc.)
|
|
21
21
|
- Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`)
|
|
22
|
-
- Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults (LR5
|
|
22
|
+
- Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults, dirty laser sensors (LR4/LR5), LR5 bonnet/drawer; press `d` to dismiss
|
|
23
23
|
- Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
|
|
24
24
|
- Scrollable activity-history pager — `history [count|all] [--type <kind>]` opens a full-screen, paginated view (arrow keys, `Page Up`/`Page Down`, `Home`/`End`); `c` copies the whole history to the clipboard; `q`/`Esc`/`Enter` to close
|
|
25
25
|
- Commands: `clean`, `status`, `info`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto|color <hex>`, `night-light-brightness`, `panel-brightness <low|medium|high>` (LR4/LR5), `wait-time`, `power on|off`, `rename`, `insight`, `sleep-schedule [set|disable]`, plus LR5 extras (`privacy`, `volume`, `camera-audio`, `drawer-reset`), `history [count|all] [--type <kind>]`, `export [days|month]`, `help`, `quit`
|
|
@@ -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 |
|
|
@@ -414,7 +414,7 @@ Scan settings live in `[tool.bandit]` in `pyproject.toml`, so local runs and the
|
|
|
414
414
|
Bandit workflow report identical findings. Results from `main` and pull requests
|
|
415
415
|
are published to the repository's **Security → Code scanning** tab.
|
|
416
416
|
|
|
417
|
-
CI runs on Python 3.
|
|
417
|
+
CI runs on Python 3.11 / 3.12 across Ubuntu, Windows, and macOS on every push.
|
|
418
418
|
|
|
419
419
|
## Changelog
|
|
420
420
|
|
|
@@ -10,7 +10,7 @@ identical regardless of how it was invoked.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import csv
|
|
13
|
-
from datetime import datetime, timedelta,
|
|
13
|
+
from datetime import UTC, datetime, timedelta, tzinfo
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import TYPE_CHECKING, Any
|
|
16
16
|
|
|
@@ -69,7 +69,7 @@ def _filter_recent(acts: list[Any], cutoff: datetime) -> list[tuple[Any, datetim
|
|
|
69
69
|
if ts_dt is None:
|
|
70
70
|
continue
|
|
71
71
|
if ts_dt.tzinfo is None:
|
|
72
|
-
ts_dt = ts_dt.replace(tzinfo=
|
|
72
|
+
ts_dt = ts_dt.replace(tzinfo=UTC)
|
|
73
73
|
if ts_dt >= cutoff:
|
|
74
74
|
filtered.append((act, ts_dt))
|
|
75
75
|
filtered.sort(key=lambda x: x[1])
|
|
@@ -106,7 +106,7 @@ async def build_history_csv(robot: RobotProtocol, pets: list[Any], days: int, de
|
|
|
106
106
|
``ACTION_LABELS`` / ``activity_raw_text`` so events render identically to
|
|
107
107
|
the ``history`` command output.
|
|
108
108
|
"""
|
|
109
|
-
cutoff = datetime.now(tz=
|
|
109
|
+
cutoff = datetime.now(tz=UTC) - timedelta(days=days)
|
|
110
110
|
# Only the LR4's get_activity_history() takes a start time.
|
|
111
111
|
fetch: Any = robot.get_activity_history
|
|
112
112
|
try:
|
|
@@ -127,7 +127,7 @@ async def build_history_csv(robot: RobotProtocol, pets: list[Any], days: int, de
|
|
|
127
127
|
|
|
128
128
|
local_tz: tzinfo = get_localzone()
|
|
129
129
|
except Exception: # noqa: BLE001 — tz discovery is best-effort; fall back to UTC
|
|
130
|
-
local_tz =
|
|
130
|
+
local_tz = UTC
|
|
131
131
|
|
|
132
132
|
try:
|
|
133
133
|
with dest.open("w", newline="", encoding="utf-8") as f:
|
|
@@ -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),
|
|
@@ -104,6 +108,9 @@ _MODEL_ENUM_FAULTS: dict[str, tuple[_EnumCheck, ...]] = {
|
|
|
104
108
|
|
|
105
109
|
_MODEL_BOOL_FAULTS: dict[str, tuple[_BoolCheck, ...]] = {
|
|
106
110
|
# LR3 has no component-fault attributes exposed by pylitterbot.
|
|
111
|
+
"LitterRobot4": (
|
|
112
|
+
_BoolCheck("is_laser_dirty", "LASER SENSOR DIRTY — clean globe", SEVERITY_WARN),
|
|
113
|
+
),
|
|
107
114
|
"LitterRobot5": (
|
|
108
115
|
_BoolCheck("is_bonnet_removed", "BONNET OPEN", SEVERITY_WARN),
|
|
109
116
|
_BoolCheck("is_laser_dirty", "LASER SENSOR DIRTY — clean globe", SEVERITY_WARN),
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
|
-
from datetime import datetime, time
|
|
7
|
+
from datetime import UTC, datetime, time
|
|
8
8
|
|
|
9
9
|
from rich.text import Text
|
|
10
10
|
|
|
@@ -42,8 +42,8 @@ def fmt_ago(dt: datetime | None) -> str:
|
|
|
42
42
|
if dt is None:
|
|
43
43
|
return "never"
|
|
44
44
|
if dt.tzinfo is None:
|
|
45
|
-
dt = dt.replace(tzinfo=
|
|
46
|
-
s = int((datetime.now(
|
|
45
|
+
dt = dt.replace(tzinfo=UTC)
|
|
46
|
+
s = int((datetime.now(UTC) - dt).total_seconds())
|
|
47
47
|
if s < 60:
|
|
48
48
|
return f"{s}s ago"
|
|
49
49
|
if s < 3600:
|
|
@@ -58,8 +58,8 @@ def fmt_until(dt: datetime | None) -> str:
|
|
|
58
58
|
if dt is None:
|
|
59
59
|
return "—"
|
|
60
60
|
if dt.tzinfo is None:
|
|
61
|
-
dt = dt.replace(tzinfo=
|
|
62
|
-
seconds = (dt - datetime.now(
|
|
61
|
+
dt = dt.replace(tzinfo=UTC)
|
|
62
|
+
seconds = (dt - datetime.now(UTC)).total_seconds()
|
|
63
63
|
stamp = dt.date().isoformat()
|
|
64
64
|
# Counted away from now in whole days, so 3 days and an hour ago reads as
|
|
65
65
|
# "3d" in either direction — timedelta.days alone floors that to 4.
|
|
@@ -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
|
|
|
@@ -121,7 +121,7 @@ class CleanCommand(Command):
|
|
|
121
121
|
timed_out = False
|
|
122
122
|
try:
|
|
123
123
|
await asyncio.wait_for(done.wait(), timeout=300)
|
|
124
|
-
except
|
|
124
|
+
except TimeoutError:
|
|
125
125
|
timed_out = True
|
|
126
126
|
finally:
|
|
127
127
|
unsubscribe()
|
|
@@ -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,
|
|
@@ -22,7 +22,7 @@ vertically regardless of an event's age — every event shows its date.
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
24
|
from collections import Counter
|
|
25
|
-
from datetime import
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
26
|
from typing import TYPE_CHECKING, Any
|
|
27
27
|
|
|
28
28
|
from rich.text import Text
|
|
@@ -61,7 +61,7 @@ def _localize(ts: object) -> datetime | None:
|
|
|
61
61
|
if not isinstance(ts, datetime):
|
|
62
62
|
return None
|
|
63
63
|
if ts.tzinfo is None:
|
|
64
|
-
ts = ts.replace(tzinfo=
|
|
64
|
+
ts = ts.replace(tzinfo=UTC)
|
|
65
65
|
return ts.astimezone(get_localzone())
|
|
66
66
|
|
|
67
67
|
|
|
@@ -69,7 +69,7 @@ def _ts_str(local_dt: datetime | None) -> str:
|
|
|
69
69
|
"""Fixed-width timestamp: ``MM/DD HH:MM`` this year, ``YYYY-MM-DD`` older."""
|
|
70
70
|
if local_dt is None:
|
|
71
71
|
return "?"
|
|
72
|
-
if local_dt.year == datetime.now(tz=
|
|
72
|
+
if local_dt.year == datetime.now(tz=UTC).year:
|
|
73
73
|
return local_dt.strftime("%m/%d %H:%M")
|
|
74
74
|
return local_dt.strftime("%Y-%m-%d")
|
|
75
75
|
|
|
@@ -51,7 +51,7 @@ uv run pytest --cov=asher --cov-report=term-missing
|
|
|
51
51
|
Per-file descriptions are in `CLAUDE.md` § Package structure. Pilot gotchas are
|
|
52
52
|
in `CLAUDE.md` § Testing notes.
|
|
53
53
|
|
|
54
|
-
CI matrix: Python 3.
|
|
54
|
+
CI matrix: Python 3.11 / 3.12 × Ubuntu / Windows / macOS.
|
|
55
55
|
|
|
56
56
|
### Coverage
|
|
57
57
|
|