asher-cli 1.5.0__tar.gz → 1.6.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.5.0 → asher_cli-1.6.0}/.claude/skills/pylitterbot-ref/SKILL.md +1 -0
  2. {asher_cli-1.5.0 → asher_cli-1.6.0}/.github/workflows/bandit.yml +2 -2
  3. {asher_cli-1.5.0 → asher_cli-1.6.0}/.github/workflows/ci.yml +3 -3
  4. {asher_cli-1.5.0 → asher_cli-1.6.0}/.github/workflows/coverage.yml +1 -1
  5. {asher_cli-1.5.0 → asher_cli-1.6.0}/.github/workflows/release.yml +4 -4
  6. {asher_cli-1.5.0 → asher_cli-1.6.0}/CHANGELOG.md +20 -9
  7. {asher_cli-1.5.0 → asher_cli-1.6.0}/CLAUDE.md +4 -0
  8. {asher_cli-1.5.0 → asher_cli-1.6.0}/PKG-INFO +6 -7
  9. {asher_cli-1.5.0 → asher_cli-1.6.0}/README.md +2 -2
  10. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/core/export.py +4 -4
  11. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/core/faults.py +3 -0
  12. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/core/helpers.py +5 -5
  13. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/commands/robot.py +1 -1
  14. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/history.py +3 -3
  15. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/CONTRIBUTING.md +1 -1
  16. {asher_cli-1.5.0 → asher_cli-1.6.0}/pyproject.toml +7 -8
  17. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/conftest.py +2 -2
  18. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_activity_labels.py +3 -3
  19. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_app_pilot.py +2 -2
  20. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_commands_pilot.py +2 -2
  21. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_completion.py +2 -2
  22. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_export.py +5 -5
  23. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_faults.py +5 -1
  24. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_headless.py +4 -4
  25. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_history_view.py +14 -16
  26. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_lr5_commands.py +2 -2
  27. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_missing_robot_commands.py +3 -3
  28. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_monitoring.py +3 -3
  29. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_new_commands_pilot.py +3 -3
  30. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/testhelpers.py +11 -11
  31. {asher_cli-1.5.0 → asher_cli-1.6.0}/uv.lock +675 -1076
  32. {asher_cli-1.5.0 → asher_cli-1.6.0}/.claude/hooks/block-env.py +0 -0
  33. {asher_cli-1.5.0 → asher_cli-1.6.0}/.claude/settings.json +0 -0
  34. {asher_cli-1.5.0 → asher_cli-1.6.0}/.claude/skills/mcp-bridge/SKILL.md +0 -0
  35. {asher_cli-1.5.0 → asher_cli-1.6.0}/.claude/skills/textual/SKILL.md +0 -0
  36. {asher_cli-1.5.0 → asher_cli-1.6.0}/.env.example +0 -0
  37. {asher_cli-1.5.0 → asher_cli-1.6.0}/.githooks/pre-push +0 -0
  38. {asher_cli-1.5.0 → asher_cli-1.6.0}/.github/FUNDING.yml +0 -0
  39. {asher_cli-1.5.0 → asher_cli-1.6.0}/.github/pull_request_template.md +0 -0
  40. {asher_cli-1.5.0 → asher_cli-1.6.0}/.github/workflows/dependency-automerge.yml +0 -0
  41. {asher_cli-1.5.0 → asher_cli-1.6.0}/.gitignore +0 -0
  42. {asher_cli-1.5.0 → asher_cli-1.6.0}/.vscode/launch.json +0 -0
  43. {asher_cli-1.5.0 → asher_cli-1.6.0}/.vscode/settings.json +0 -0
  44. {asher_cli-1.5.0 → asher_cli-1.6.0}/.vscode/tasks.json +0 -0
  45. {asher_cli-1.5.0 → asher_cli-1.6.0}/CODEOWNERS +0 -0
  46. {asher_cli-1.5.0 → asher_cli-1.6.0}/LICENSE +0 -0
  47. {asher_cli-1.5.0 → asher_cli-1.6.0}/app.py +0 -0
  48. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/__init__.py +0 -0
  49. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/__main__.py +0 -0
  50. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/core/__init__.py +0 -0
  51. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/core/activity.py +0 -0
  52. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/core/config.py +0 -0
  53. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/core/constants.py +0 -0
  54. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/core/credentials.py +0 -0
  55. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/core/theme.py +0 -0
  56. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/core/updates.py +0 -0
  57. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/desktop/__init__.py +0 -0
  58. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/desktop/autostart.py +0 -0
  59. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/desktop/daemon.py +0 -0
  60. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/desktop/desktoptheme.py +0 -0
  61. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/desktop/launcher.py +0 -0
  62. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/desktop/notifications.py +0 -0
  63. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/desktop/tray.py +0 -0
  64. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/desktop/watcher.py +0 -0
  65. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/headless.py +0 -0
  66. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/mcp/__init__.py +0 -0
  67. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/mcp/bridge.py +0 -0
  68. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/mcp/config.py +0 -0
  69. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/robot/__init__.py +0 -0
  70. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/robot/adapters.py +0 -0
  71. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/robot/protocol.py +0 -0
  72. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/__init__.py +0 -0
  73. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/app.py +0 -0
  74. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/cats.py +0 -0
  75. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/commands/__init__.py +0 -0
  76. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/commands/base.py +0 -0
  77. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/commands/builtin.py +0 -0
  78. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/commands/lr5.py +0 -0
  79. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/commands/registry.py +0 -0
  80. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/commands/slash.py +0 -0
  81. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/completion.py +0 -0
  82. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/connection.py +0 -0
  83. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/loginflow.py +0 -0
  84. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/loginscreen.py +0 -0
  85. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/monitoring.py +0 -0
  86. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/style.tcss +0 -0
  87. {asher_cli-1.5.0 → asher_cli-1.6.0}/asher/tui/ui.py +0 -0
  88. {asher_cli-1.5.0 → asher_cli-1.6.0}/cliff.toml +0 -0
  89. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/ROADMAP.md +0 -0
  90. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/README.md +0 -0
  91. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/cat-panel-badges.md +0 -0
  92. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/config-persistence.md +0 -0
  93. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/credentials.md +0 -0
  94. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/desktop-notifications.md +0 -0
  95. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/e2e-pilot.md +0 -0
  96. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/fault-monitoring.md +0 -0
  97. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/headless-export.md +0 -0
  98. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/history-export.md +0 -0
  99. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/lr5-features.md +0 -0
  100. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/multi-pet.md +0 -0
  101. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/robot-commands.md +0 -0
  102. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/runtime-slash-commands.md +0 -0
  103. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/slash-command-spec.md +0 -0
  104. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/sleep-schedule.md +0 -0
  105. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/tab-completion.md +0 -0
  106. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/ui-ux.md +0 -0
  107. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/version-display.md +0 -0
  108. {asher_cli-1.5.0 → asher_cli-1.6.0}/docs/roadmap-archive/websocket-updates.md +0 -0
  109. {asher_cli-1.5.0 → asher_cli-1.6.0}/renovate.json +0 -0
  110. {asher_cli-1.5.0 → asher_cli-1.6.0}/requirements.txt +0 -0
  111. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/__init__.py +0 -0
  112. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_auth.py +0 -0
  113. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_auth_pilot.py +0 -0
  114. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_autostart.py +0 -0
  115. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_cats.py +0 -0
  116. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_config.py +0 -0
  117. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_connection_mixin.py +0 -0
  118. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_credentials.py +0 -0
  119. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_daemon.py +0 -0
  120. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_desktoptheme.py +0 -0
  121. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_launcher.py +0 -0
  122. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_layering.py +0 -0
  123. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_mcp_bridge.py +0 -0
  124. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_mcp_command.py +0 -0
  125. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_mcp_config.py +0 -0
  126. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_notifications.py +0 -0
  127. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_robot_adapters.py +0 -0
  128. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_tray.py +0 -0
  129. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_ui.py +0 -0
  130. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_updates.py +0 -0
  131. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_version_command.py +0 -0
  132. {asher_cli-1.5.0 → asher_cli-1.6.0}/tests/test_watcher.py +0 -0
  133. {asher_cli-1.5.0 → asher_cli-1.6.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)
@@ -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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
34
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
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@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
44
+ uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
22
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
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.10", "3.11", "3.12"]
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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
38
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
21
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
22
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
23
23
  - run: uv build
24
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
24
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
25
25
  with:
26
26
  name: dist
27
27
  path: dist/
@@ -34,7 +34,7 @@ jobs:
34
34
  permissions:
35
35
  id-token: write # needed for PyPI OIDC trusted publishing
36
36
  steps:
37
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
37
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
38
38
  with:
39
39
  name: dist
40
40
  path: dist/
@@ -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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
51
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
52
52
  with:
53
53
  name: dist
54
54
  path: dist/
@@ -7,14 +7,27 @@ 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.6.0] - 2026-10-15
11
+
12
+ ### 🚀 Features
13
+
14
+ - *(faults)* Alert when LR4 laser sensors are dirty
15
+
16
+ ### 🔧 Miscellaneous
17
+
18
+ - [**breaking**] Drop Python 3.10 support
19
+
10
20
  ## [1.5.0] - 2026-10-03
11
21
 
12
- ### 🛡️ Security
22
+ ### 🐛 Bug Fixes
23
+
24
+ - *(mcp)* Don't overwrite a claude_desktop_config.json that can't be parsed
25
+ - *(mcp)* Cap mcp<2 when /mcp on installs the extra
26
+
27
+ ### 👷 CI/CD
13
28
 
14
- - *(mcp)* Cap `mcp<2` when `/mcp on` installs the extra
15
- - *(mcp)* Don't overwrite a `claude_desktop_config.json` that can't be parsed
16
- - *(ci)* Scope release token permissions to the jobs that need them
17
- - *(ci)* Pin bandit to 1.9.4
29
+ - *(release)* Scope token permissions to the jobs that need them
30
+ - *(bandit)* Pin bandit to 1.9.4
18
31
 
19
32
  ## [1.4.1] - 2026-10-01
20
33
 
@@ -23,10 +36,6 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
23
36
  - *(export)* Reach the full 30 days on the LR4
24
37
  - *(tui)* Hang wrapped log lines under their message
25
38
 
26
- ### 🛡️ Security
27
-
28
- - *(deps)* Refresh `uv.lock` to patch pyjwt and urllib3 CVEs (#101)
29
-
30
39
  ### ♻️ Refactor
31
40
 
32
41
  - Group the asher package into core, robot, tui, desktop and mcp
@@ -41,6 +50,8 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
41
50
 
42
51
  ### 🔧 Miscellaneous
43
52
 
53
+ - *(release)* Cut v1.4.1
54
+ - *(release)* Cut v1.4.0
44
55
  - Remove claude.yml
45
56
  - Remove claude-code-review.yml
46
57
 
@@ -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.5.0
3
+ Version: 1.6.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,21 +33,20 @@ 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.10
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.6.6
42
+ Requires-Dist: pylitterbot==2025.7.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
48
  Requires-Dist: mcp<2; extra == 'mcp'
50
- Requires-Dist: pylitterbot[mcp]==2025.6.6; extra == 'mcp'
49
+ Requires-Dist: pylitterbot[mcp]==2025.7.0; extra == 'mcp'
51
50
  Provides-Extra: tray
52
51
  Requires-Dist: pillow>=10.0; extra == 'tray'
53
52
  Requires-Dist: pygobject>=3.42; (sys_platform == 'linux') and extra == 'tray'
@@ -75,7 +74,7 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
75
74
  - Live status bar — unit name, online/offline, drawer fill level, last activity, cat weight
76
75
  - Human-readable robot status — translates raw API states into plain English (`Ready`, `Cleaning`, `Cat Detected`, `Drawer Full`, etc.)
77
76
  - 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: bonnet/laser/drawer); press `d` to dismiss
77
+ - 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
78
  - Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
80
79
  - 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
80
  - 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`
@@ -470,7 +469,7 @@ Scan settings live in `[tool.bandit]` in `pyproject.toml`, so local runs and the
470
469
  Bandit workflow report identical findings. Results from `main` and pull requests
471
470
  are published to the repository's **Security → Code scanning** tab.
472
471
 
473
- CI runs on Python 3.10 / 3.11 / 3.12 across Ubuntu, Windows, and macOS on every push.
472
+ CI runs on Python 3.11 / 3.12 across Ubuntu, Windows, and macOS on every push.
474
473
 
475
474
  ## Changelog
476
475
 
@@ -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: bonnet/laser/drawer); press `d` to dismiss
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`
@@ -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.10 / 3.11 / 3.12 across Ubuntu, Windows, and macOS on every push.
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, timezone, tzinfo
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=timezone.utc)
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=timezone.utc) - timedelta(days=days)
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 = timezone.utc
130
+ local_tz = UTC
131
131
 
132
132
  try:
133
133
  with dest.open("w", newline="", encoding="utf-8") as f:
@@ -104,6 +104,9 @@ _MODEL_ENUM_FAULTS: dict[str, tuple[_EnumCheck, ...]] = {
104
104
 
105
105
  _MODEL_BOOL_FAULTS: dict[str, tuple[_BoolCheck, ...]] = {
106
106
  # LR3 has no component-fault attributes exposed by pylitterbot.
107
+ "LitterRobot4": (
108
+ _BoolCheck("is_laser_dirty", "LASER SENSOR DIRTY — clean globe", SEVERITY_WARN),
109
+ ),
107
110
  "LitterRobot5": (
108
111
  _BoolCheck("is_bonnet_removed", "BONNET OPEN", SEVERITY_WARN),
109
112
  _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, timezone
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=timezone.utc)
46
- s = int((datetime.now(timezone.utc) - dt).total_seconds())
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=timezone.utc)
62
- seconds = (dt - datetime.now(timezone.utc)).total_seconds()
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.
@@ -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 asyncio.TimeoutError:
124
+ except TimeoutError:
125
125
  timed_out = True
126
126
  finally:
127
127
  unsubscribe()
@@ -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 datetime, timezone
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=timezone.utc)
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=timezone.utc).year:
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.10 / 3.11 / 3.12 × Ubuntu / Windows / macOS.
54
+ CI matrix: Python 3.11 / 3.12 × Ubuntu / Windows / macOS.
55
55
 
56
56
  ### Coverage
57
57
 
@@ -1,23 +1,22 @@
1
1
  [project]
2
2
  name = "asher-cli"
3
- version = "1.5.0"
3
+ version = "1.6.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" }
7
- requires-python = ">=3.10"
7
+ requires-python = ">=3.11"
8
8
  keywords = ["litter-robot", "whisker", "tui", "terminal", "smart-home"]
9
9
  classifiers = [
10
10
  "Development Status :: 4 - Beta",
11
11
  "Environment :: Console",
12
12
  "License :: OSI Approved :: MIT License",
13
13
  "Programming Language :: Python :: 3",
14
- "Programming Language :: Python :: 3.10",
15
14
  "Programming Language :: Python :: 3.11",
16
15
  "Programming Language :: Python :: 3.12",
17
16
  "Topic :: Home Automation",
18
17
  ]
19
18
  dependencies = [
20
- "pylitterbot==2025.6.6",
19
+ "pylitterbot==2025.7.0",
21
20
  "textual>=0.47.0",
22
21
  "rich>=13.0.0",
23
22
  "python-dotenv>=1.0.0",
@@ -31,7 +30,7 @@ dependencies = [
31
30
  # `from mcp.server.fastmcp import FastMCP`, and mcp 2.0 deleted that module.
32
31
  # Cap it here, and in _ensure_mcp_extra (tui/commands/slash.py), until
33
32
  # pylitterbot ships a 2.x-compatible release.
34
- mcp = ["pylitterbot[mcp]==2025.6.6", "mcp<2"]
33
+ mcp = ["pylitterbot[mcp]==2025.7.0", "mcp<2"]
35
34
  # PyGObject is what puts pystray on its AppIndicator backend. Without it pystray
36
35
  # silently falls back to a legacy XEmbed icon, which KDE Plasma and GNOME no
37
36
  # longer host at all — the watcher runs, draws an icon, and nothing shows it.
@@ -105,7 +104,7 @@ packages = ["asher"]
105
104
 
106
105
  [tool.ruff]
107
106
  line-length = 100
108
- target-version = "py310"
107
+ target-version = "py311"
109
108
  # Ruff 0.16 added "*.md" to the default include list, which pulls the Python
110
109
  # snippets in docs/ and .claude/skills/ into `ruff format`. Those are
111
110
  # illustrative, not compiled, so keep the formatter scoped to real source.
@@ -125,14 +124,14 @@ known-first-party = ["asher"]
125
124
  # ── Mypy ──────────────────────────────────────────────────────────────────────
126
125
 
127
126
  [tool.mypy]
128
- python_version = "3.10"
127
+ python_version = "3.11"
129
128
  warn_return_any = true
130
129
  ignore_missing_imports = true # pylitterbot and textual have incomplete stubs
131
130
 
132
131
  # ── bump-my-version ───────────────────────────────────────────────────────────
133
132
 
134
133
  [tool.bumpversion]
135
- current_version = "1.5.0"
134
+ current_version = "1.6.0"
136
135
  commit = true
137
136
  tag = true
138
137
  tag_name = "v{new_version}"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from datetime import datetime, timezone
5
+ from datetime import UTC, datetime
6
6
  from unittest.mock import AsyncMock, MagicMock
7
7
 
8
8
  import pytest
@@ -25,7 +25,7 @@ def mock_robot():
25
25
  r.panel_lock_enabled = False
26
26
  r.night_light_mode_enabled = False
27
27
  r.serial = "ABC123"
28
- r.last_seen = datetime.now(timezone.utc)
28
+ r.last_seen = datetime.now(UTC)
29
29
  # Fault sources in their healthy/no-fault state (enums are truthy even
30
30
  # when healthy, so they must be set explicitly to avoid false positives).
31
31
  r.globe_motor_fault_status = GlobeMotorFaultStatus.NONE
@@ -7,7 +7,7 @@ about how ``action`` (``str | LitterBoxStatus``) is read.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from datetime import datetime, timezone
10
+ from datetime import UTC, datetime
11
11
  from types import SimpleNamespace
12
12
 
13
13
  from pylitterbot.enums import LitterBoxStatus
@@ -29,7 +29,7 @@ def _act(action, *, weight=None, pet_id=None, timestamp=None):
29
29
  Using ``SimpleNamespace`` lets us attach them without subclassing.
30
30
  """
31
31
  return SimpleNamespace(
32
- timestamp=timestamp or datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc),
32
+ timestamp=timestamp or datetime(2026, 6, 14, 12, 0, tzinfo=UTC),
33
33
  action=action,
34
34
  weight=weight,
35
35
  pet_id=pet_id,
@@ -52,7 +52,7 @@ class TestActivityRawText:
52
52
 
53
53
  def test_missing_action_attribute_falls_back_to_str_none(self):
54
54
  # Defensive: a malformed object with no .action attr should not raise.
55
- assert activity_raw_text(SimpleNamespace(timestamp=datetime.now(timezone.utc))) == "None"
55
+ assert activity_raw_text(SimpleNamespace(timestamp=datetime.now(UTC))) == "None"
56
56
 
57
57
 
58
58
  # ── format_activity ──────────────────────────────────────────────────────────
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from datetime import datetime, timezone
5
+ from datetime import UTC, datetime
6
6
  from unittest.mock import AsyncMock, MagicMock, patch
7
7
 
8
8
  import pytest
@@ -23,7 +23,7 @@ def mock_robot():
23
23
  robot.panel_lock_enabled = False
24
24
  robot.night_light_mode_enabled = False
25
25
  robot.serial = "LR12345"
26
- robot.last_seen = datetime.now(timezone.utc)
26
+ robot.last_seen = datetime.now(UTC)
27
27
  robot.pet_weight = 10.5
28
28
  robot.refresh = AsyncMock()
29
29
  robot.start_cleaning = AsyncMock(return_value=True)
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from datetime import datetime, timezone
5
+ from datetime import UTC, datetime
6
6
  from unittest.mock import AsyncMock, MagicMock, patch
7
7
 
8
8
  import pytest
@@ -24,7 +24,7 @@ def connected_app():
24
24
  robot.panel_lock_enabled = False
25
25
  robot.night_light_mode_enabled = False
26
26
  robot.serial = "LR12345"
27
- robot.last_seen = datetime.now(timezone.utc)
27
+ robot.last_seen = datetime.now(UTC)
28
28
  robot.pet_weight = 10.5
29
29
  robot.refresh = AsyncMock()
30
30
  robot.start_cleaning = AsyncMock(return_value=True)
@@ -19,7 +19,7 @@ so the command bar is live but no real cloud calls happen.
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
- from datetime import datetime, timezone
22
+ from datetime import UTC, datetime
23
23
  from types import SimpleNamespace
24
24
  from unittest.mock import AsyncMock, MagicMock
25
25
 
@@ -72,7 +72,7 @@ def connected_app():
72
72
  robot.panel_lock_enabled = False
73
73
  robot.night_light_mode_enabled = False
74
74
  robot.serial = "LR12345"
75
- robot.last_seen = datetime.now(timezone.utc)
75
+ robot.last_seen = datetime.now(UTC)
76
76
  robot.pet_weight = 10.5
77
77
  robot.refresh = AsyncMock()
78
78
  robot.start_cleaning = AsyncMock(return_value=True)
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import argparse
11
11
  import csv
12
- from datetime import datetime, timedelta, timezone
12
+ from datetime import UTC, datetime, timedelta
13
13
  from unittest.mock import AsyncMock, MagicMock, patch
14
14
 
15
15
  import pytest
@@ -155,7 +155,7 @@ def _act(
155
155
  pet_id: str | None = None,
156
156
  ) -> MagicMock:
157
157
  act = MagicMock()
158
- act.timestamp = datetime.now(timezone.utc) - timedelta(days=days_ago)
158
+ act.timestamp = datetime.now(UTC) - timedelta(days=days_ago)
159
159
  act.action = MagicMock(text=action_text)
160
160
  act.weight = weight
161
161
  act.pet_id = pet_id
@@ -214,8 +214,8 @@ class TestBuildHistoryCsv:
214
214
  robot = LitterRobot4(get_activity_history=AsyncMock(return_value=[]))
215
215
  await build_history_csv(robot, [], 30, tmp_path / "out.csv")
216
216
  start = robot.get_activity_history.await_args.kwargs["start"]
217
- assert start.tzinfo is timezone.utc
218
- age = datetime.now(timezone.utc) - start
217
+ assert start.tzinfo is UTC
218
+ age = datetime.now(UTC) - start
219
219
  assert timedelta(days=30) <= age < timedelta(days=30, seconds=5)
220
220
 
221
221
  async def test_non_lr4_fetch_sends_no_start(self, tmp_path):
@@ -272,7 +272,7 @@ class TestBuildHistoryCsv:
272
272
 
273
273
  async def test_naive_timestamp_treated_as_utc(self, tmp_path):
274
274
  act = MagicMock()
275
- act.timestamp = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=1)
275
+ act.timestamp = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=1)
276
276
  act.action = MagicMock(text="Ready")
277
277
  act.weight = None
278
278
  act.pet_id = None
@@ -139,8 +139,12 @@ class TestLR4ComponentFaults:
139
139
  assert check_faults(_lr4(usb_fault_status=UsbFaultStatus.NONE)) == []
140
140
  assert check_faults(_lr4(usb_fault_status=UsbFaultStatus.CLEAR)) == []
141
141
 
142
+ def test_laser_dirty_is_warn(self):
143
+ faults = check_faults(_lr4(is_laser_dirty=True))
144
+ assert faults == [Fault("LASER SENSOR DIRTY — clean globe", SEVERITY_WARN)]
145
+
142
146
  def test_lr4_ignores_lr5_only_bool_attrs(self):
143
- """LR4 allowlist must not check bonnet/laser/gas/drawer-removed."""
147
+ """LR4 allowlist must not check bonnet/gas/drawer-removed."""
144
148
  r = _lr4()
145
149
  r.is_bonnet_removed = True
146
150
  r.is_gas_sensor_fault_detected = True
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import csv
16
16
  import json
17
- from datetime import datetime, time, timedelta, timezone
17
+ from datetime import UTC, datetime, time, timedelta
18
18
  from types import SimpleNamespace
19
19
  from unittest.mock import AsyncMock, MagicMock, patch
20
20
 
@@ -49,7 +49,7 @@ def _robot(*, serial: str = "S1", name: str = "Asher", model: str = "LitterRobot
49
49
  robot.waste_drawer_level = 42.0
50
50
  robot.litter_level = 71.0
51
51
  robot.pet_weight = 9.1
52
- robot.last_seen = datetime.now(timezone.utc)
52
+ robot.last_seen = datetime.now(UTC)
53
53
  robot.night_light_mode = None
54
54
  robot.night_light_mode_enabled = False
55
55
  robot.panel_brightness = None
@@ -91,7 +91,7 @@ def _session(robot=None, pets=None, output=None) -> Session:
91
91
 
92
92
  def _act(*, days_ago: float = 1, action_text: str = "Clean Cycle Complete", weight=9.1):
93
93
  act = MagicMock()
94
- act.timestamp = datetime.now(timezone.utc) - timedelta(days=days_ago)
94
+ act.timestamp = datetime.now(UTC) - timedelta(days=days_ago)
95
95
  act.action = MagicMock(text=action_text)
96
96
  act.weight = weight
97
97
  act.pet_id = None
@@ -687,7 +687,7 @@ class TestSleepScheduleWrites:
687
687
 
688
688
  class TestFilterReminder:
689
689
  async def test_info_shows_the_next_filter_date_when_the_model_has_one(self):
690
- due = datetime.now(timezone.utc) + timedelta(days=23, hours=1)
690
+ due = datetime.now(UTC) + timedelta(days=23, hours=1)
691
691
  robot = _robot(model="LitterRobot5", next_filter_replacement_date=due)
692
692
  result = await COMMANDS_BY_NAME["info"].handler(_session(robot), [])
693
693
  assert result.data["filter_due"] == f"{due.date().isoformat()} (in 23d)"