asher-cli 1.4.1__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.4.1 → asher_cli-1.6.0}/.claude/skills/pylitterbot-ref/SKILL.md +1 -0
  2. {asher_cli-1.4.1 → asher_cli-1.6.0}/.github/workflows/bandit.yml +3 -3
  3. {asher_cli-1.4.1 → asher_cli-1.6.0}/.github/workflows/ci.yml +3 -3
  4. {asher_cli-1.4.1 → asher_cli-1.6.0}/.github/workflows/coverage.yml +1 -1
  5. {asher_cli-1.4.1 → asher_cli-1.6.0}/.github/workflows/release.yml +9 -6
  6. {asher_cli-1.4.1 → asher_cli-1.6.0}/CHANGELOG.md +24 -4
  7. {asher_cli-1.4.1 → asher_cli-1.6.0}/CLAUDE.md +4 -0
  8. {asher_cli-1.4.1 → asher_cli-1.6.0}/PKG-INFO +6 -7
  9. {asher_cli-1.4.1 → asher_cli-1.6.0}/README.md +2 -2
  10. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/core/export.py +4 -4
  11. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/core/faults.py +3 -0
  12. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/core/helpers.py +5 -5
  13. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/mcp/config.py +18 -9
  14. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/commands/robot.py +1 -1
  15. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/commands/slash.py +13 -8
  16. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/history.py +3 -3
  17. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/CONTRIBUTING.md +1 -1
  18. {asher_cli-1.4.1 → asher_cli-1.6.0}/pyproject.toml +10 -10
  19. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/conftest.py +2 -2
  20. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_activity_labels.py +3 -3
  21. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_app_pilot.py +2 -2
  22. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_commands_pilot.py +2 -2
  23. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_completion.py +2 -2
  24. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_export.py +5 -5
  25. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_faults.py +5 -1
  26. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_headless.py +4 -4
  27. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_history_view.py +14 -16
  28. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_lr5_commands.py +2 -2
  29. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_mcp_command.py +9 -1
  30. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_mcp_config.py +36 -6
  31. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_missing_robot_commands.py +3 -3
  32. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_monitoring.py +3 -3
  33. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_new_commands_pilot.py +3 -3
  34. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/testhelpers.py +11 -11
  35. {asher_cli-1.4.1 → asher_cli-1.6.0}/uv.lock +675 -1076
  36. {asher_cli-1.4.1 → asher_cli-1.6.0}/.claude/hooks/block-env.py +0 -0
  37. {asher_cli-1.4.1 → asher_cli-1.6.0}/.claude/settings.json +0 -0
  38. {asher_cli-1.4.1 → asher_cli-1.6.0}/.claude/skills/mcp-bridge/SKILL.md +0 -0
  39. {asher_cli-1.4.1 → asher_cli-1.6.0}/.claude/skills/textual/SKILL.md +0 -0
  40. {asher_cli-1.4.1 → asher_cli-1.6.0}/.env.example +0 -0
  41. {asher_cli-1.4.1 → asher_cli-1.6.0}/.githooks/pre-push +0 -0
  42. {asher_cli-1.4.1 → asher_cli-1.6.0}/.github/FUNDING.yml +0 -0
  43. {asher_cli-1.4.1 → asher_cli-1.6.0}/.github/pull_request_template.md +0 -0
  44. {asher_cli-1.4.1 → asher_cli-1.6.0}/.github/workflows/dependency-automerge.yml +0 -0
  45. {asher_cli-1.4.1 → asher_cli-1.6.0}/.gitignore +0 -0
  46. {asher_cli-1.4.1 → asher_cli-1.6.0}/.vscode/launch.json +0 -0
  47. {asher_cli-1.4.1 → asher_cli-1.6.0}/.vscode/settings.json +0 -0
  48. {asher_cli-1.4.1 → asher_cli-1.6.0}/.vscode/tasks.json +0 -0
  49. {asher_cli-1.4.1 → asher_cli-1.6.0}/CODEOWNERS +0 -0
  50. {asher_cli-1.4.1 → asher_cli-1.6.0}/LICENSE +0 -0
  51. {asher_cli-1.4.1 → asher_cli-1.6.0}/app.py +0 -0
  52. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/__init__.py +0 -0
  53. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/__main__.py +0 -0
  54. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/core/__init__.py +0 -0
  55. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/core/activity.py +0 -0
  56. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/core/config.py +0 -0
  57. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/core/constants.py +0 -0
  58. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/core/credentials.py +0 -0
  59. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/core/theme.py +0 -0
  60. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/core/updates.py +0 -0
  61. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/desktop/__init__.py +0 -0
  62. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/desktop/autostart.py +0 -0
  63. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/desktop/daemon.py +0 -0
  64. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/desktop/desktoptheme.py +0 -0
  65. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/desktop/launcher.py +0 -0
  66. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/desktop/notifications.py +0 -0
  67. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/desktop/tray.py +0 -0
  68. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/desktop/watcher.py +0 -0
  69. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/headless.py +0 -0
  70. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/mcp/__init__.py +0 -0
  71. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/mcp/bridge.py +0 -0
  72. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/robot/__init__.py +0 -0
  73. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/robot/adapters.py +0 -0
  74. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/robot/protocol.py +0 -0
  75. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/__init__.py +0 -0
  76. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/app.py +0 -0
  77. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/cats.py +0 -0
  78. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/commands/__init__.py +0 -0
  79. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/commands/base.py +0 -0
  80. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/commands/builtin.py +0 -0
  81. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/commands/lr5.py +0 -0
  82. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/commands/registry.py +0 -0
  83. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/completion.py +0 -0
  84. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/connection.py +0 -0
  85. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/loginflow.py +0 -0
  86. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/loginscreen.py +0 -0
  87. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/monitoring.py +0 -0
  88. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/style.tcss +0 -0
  89. {asher_cli-1.4.1 → asher_cli-1.6.0}/asher/tui/ui.py +0 -0
  90. {asher_cli-1.4.1 → asher_cli-1.6.0}/cliff.toml +0 -0
  91. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/ROADMAP.md +0 -0
  92. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/README.md +0 -0
  93. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/cat-panel-badges.md +0 -0
  94. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/config-persistence.md +0 -0
  95. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/credentials.md +0 -0
  96. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/desktop-notifications.md +0 -0
  97. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/e2e-pilot.md +0 -0
  98. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/fault-monitoring.md +0 -0
  99. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/headless-export.md +0 -0
  100. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/history-export.md +0 -0
  101. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/lr5-features.md +0 -0
  102. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/multi-pet.md +0 -0
  103. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/robot-commands.md +0 -0
  104. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/runtime-slash-commands.md +0 -0
  105. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/slash-command-spec.md +0 -0
  106. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/sleep-schedule.md +0 -0
  107. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/tab-completion.md +0 -0
  108. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/ui-ux.md +0 -0
  109. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/version-display.md +0 -0
  110. {asher_cli-1.4.1 → asher_cli-1.6.0}/docs/roadmap-archive/websocket-updates.md +0 -0
  111. {asher_cli-1.4.1 → asher_cli-1.6.0}/renovate.json +0 -0
  112. {asher_cli-1.4.1 → asher_cli-1.6.0}/requirements.txt +0 -0
  113. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/__init__.py +0 -0
  114. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_auth.py +0 -0
  115. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_auth_pilot.py +0 -0
  116. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_autostart.py +0 -0
  117. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_cats.py +0 -0
  118. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_config.py +0 -0
  119. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_connection_mixin.py +0 -0
  120. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_credentials.py +0 -0
  121. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_daemon.py +0 -0
  122. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_desktoptheme.py +0 -0
  123. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_launcher.py +0 -0
  124. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_layering.py +0 -0
  125. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_mcp_bridge.py +0 -0
  126. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_notifications.py +0 -0
  127. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_robot_adapters.py +0 -0
  128. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_tray.py +0 -0
  129. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_ui.py +0 -0
  130. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_updates.py +0 -0
  131. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_version_command.py +0 -0
  132. {asher_cli-1.4.1 → asher_cli-1.6.0}/tests/test_watcher.py +0 -0
  133. {asher_cli-1.4.1 → 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,17 +31,17 @@ 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
38
38
  # is skipped and code scanning keeps showing the previous run's alerts.
39
39
  run: >-
40
- uvx --from 'bandit[toml,sarif]' bandit
40
+ uvx --from 'bandit[toml,sarif]==1.9.4' bandit
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
@@ -6,8 +6,7 @@ on:
6
6
  - "release/*"
7
7
 
8
8
  permissions:
9
- contents: write # needed to create GitHub Releases
10
- id-token: write # needed for PyPI OIDC trusted publishing
9
+ contents: read
11
10
 
12
11
  jobs:
13
12
  build:
@@ -20,9 +19,9 @@ jobs:
20
19
  - name: Extract version from branch name
21
20
  id: version
22
21
  run: echo "version=${GITHUB_REF_NAME#release/}" >> "$GITHUB_OUTPUT"
23
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
22
+ - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
24
23
  - run: uv build
25
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
24
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
26
25
  with:
27
26
  name: dist
28
27
  path: dist/
@@ -32,8 +31,10 @@ jobs:
32
31
  needs: build
33
32
  runs-on: ubuntu-latest
34
33
  environment: pypi
34
+ permissions:
35
+ id-token: write # needed for PyPI OIDC trusted publishing
35
36
  steps:
36
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
37
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
37
38
  with:
38
39
  name: dist
39
40
  path: dist/
@@ -43,9 +44,11 @@ jobs:
43
44
  name: Create GitHub Release
44
45
  needs: [build, publish]
45
46
  runs-on: ubuntu-latest
47
+ permissions:
48
+ contents: write # needed to create GitHub Releases
46
49
  steps:
47
50
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
48
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
51
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
49
52
  with:
50
53
  name: dist
51
54
  path: dist/
@@ -7,6 +7,28 @@ 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
+
20
+ ## [1.5.0] - 2026-10-03
21
+
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
28
+
29
+ - *(release)* Scope token permissions to the jobs that need them
30
+ - *(bandit)* Pin bandit to 1.9.4
31
+
10
32
  ## [1.4.1] - 2026-10-01
11
33
 
12
34
  ### 🐛 Bug Fixes
@@ -14,10 +36,6 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
14
36
  - *(export)* Reach the full 30 days on the LR4
15
37
  - *(tui)* Hang wrapped log lines under their message
16
38
 
17
- ### 🛡️ Security
18
-
19
- - *(deps)* Refresh `uv.lock` to patch pyjwt and urllib3 CVEs (#101)
20
-
21
39
  ### ♻️ Refactor
22
40
 
23
41
  - Group the asher package into core, robot, tui, desktop and mcp
@@ -32,6 +50,8 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
32
50
 
33
51
  ### 🔧 Miscellaneous
34
52
 
53
+ - *(release)* Cut v1.4.1
54
+ - *(release)* Cut v1.4.0
35
55
  - Remove claude.yml
36
56
  - Remove claude-code-review.yml
37
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.4.1
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.
@@ -11,6 +11,10 @@ from pathlib import Path
11
11
  _SERVER_NAME = "Asher CLI MCP Bridge"
12
12
 
13
13
 
14
+ class McpConfigError(Exception):
15
+ """A Claude Desktop config file exists but can't be read as a JSON object."""
16
+
17
+
14
18
  def mcp_extra_installed() -> bool:
15
19
  """Whether pylitterbot's mcp extra (the third-party `mcp` SDK) is importable."""
16
20
  return importlib.util.find_spec("mcp") is not None
@@ -60,10 +64,12 @@ def _load(path: Path) -> dict:
60
64
  if not path.exists():
61
65
  return {}
62
66
  try:
63
- data: dict = json.loads(path.read_text(encoding="utf-8"))
64
- return data
65
- except (json.JSONDecodeError, OSError):
66
- return {}
67
+ data = json.loads(path.read_text(encoding="utf-8"))
68
+ except (json.JSONDecodeError, OSError) as e:
69
+ raise McpConfigError(f"Can't read {path}: {e}") from e
70
+ if not isinstance(data, dict):
71
+ raise McpConfigError(f"Can't read {path}: not a JSON object")
72
+ return data
67
73
 
68
74
 
69
75
  def _save(path: Path, data: dict) -> None:
@@ -74,12 +80,13 @@ def _save(path: Path, data: dict) -> None:
74
80
  def set_mcp_enabled(enabled: bool) -> list[Path]:
75
81
  """Add or remove the MCP server entry in every known config location.
76
82
 
77
- Returns the list of config paths actually written.
83
+ Returns the list of config paths actually written. Raises McpConfigError,
84
+ before writing anything, if any existing config can't be read.
78
85
  """
79
86
  touched: list[Path] = []
87
+ configs = [(path, _load(path)) for path in config_paths()]
80
88
 
81
- for path in config_paths():
82
- data = _load(path)
89
+ for path, data in configs:
83
90
  servers = data.setdefault("mcpServers", {})
84
91
 
85
92
  if enabled:
@@ -101,7 +108,9 @@ def mcp_status() -> list[tuple[Path, bool]]:
101
108
  """Return (path, enabled) for every known Claude Desktop config location."""
102
109
  result = []
103
110
  for path in config_paths():
104
- data = _load(path)
105
- enabled = _SERVER_NAME in data.get("mcpServers", {})
111
+ try:
112
+ enabled = _SERVER_NAME in _load(path).get("mcpServers", {})
113
+ except McpConfigError:
114
+ enabled = False
106
115
  result.append((path, enabled))
107
116
  return result
@@ -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()
@@ -439,14 +439,15 @@ async def _ensure_mcp_extra(app: AsherApp) -> bool:
439
439
  if mcp_extra_installed():
440
440
  return True
441
441
 
442
- pin = f"pylitterbot[mcp]=={pkg_version('pylitterbot')}"
443
- app._log_info(f"Installing {pin}…")
442
+ # Same requirements as the `mcp` extra in pyproject.toml, cap included.
443
+ requirements = [f"pylitterbot[mcp]=={pkg_version('pylitterbot')}", "mcp<2"]
444
+ app._log_info(f"Installing {' '.join(requirements)}…")
444
445
  proc = await asyncio.create_subprocess_exec(
445
446
  sys.executable,
446
447
  "-m",
447
448
  "pip",
448
449
  "install",
449
- pin,
450
+ *requirements,
450
451
  stdout=asyncio.subprocess.PIPE,
451
452
  stderr=asyncio.subprocess.STDOUT,
452
453
  )
@@ -458,7 +459,8 @@ async def _ensure_mcp_extra(app: AsherApp) -> bool:
458
459
  app._log_err("Failed to install pylitterbot[mcp]:")
459
460
  for line in output.splitlines()[-10:]:
460
461
  app._log_err(f" {line}")
461
- app._log_info(f"Try manually: {sys.executable} -m pip install '{pin}'")
462
+ quoted = " ".join(f"'{r}'" for r in requirements)
463
+ app._log_info(f"Try manually: {sys.executable} -m pip install {quoted}")
462
464
  return False
463
465
 
464
466
 
@@ -467,7 +469,7 @@ class McpCommand(SlashCommand):
467
469
  description = "on|off|status Litter-Robot MCP server for Claude Desktop"
468
470
 
469
471
  async def run(self, app: AsherApp, args: list[str]) -> None:
470
- from ...mcp.config import mcp_status, set_mcp_enabled # noqa: PLC0415
472
+ from ...mcp.config import McpConfigError, mcp_status, set_mcp_enabled # noqa: PLC0415
471
473
 
472
474
  sub = args[0].lower() if args else "status"
473
475
  if sub not in ("on", "off", "status"):
@@ -507,9 +509,12 @@ class McpCommand(SlashCommand):
507
509
  return
508
510
  if not await _ensure_mcp_extra(app):
509
511
  return
510
- touched = set_mcp_enabled(True)
511
- else:
512
- touched = set_mcp_enabled(False)
512
+
513
+ try:
514
+ touched = set_mcp_enabled(sub == "on")
515
+ except McpConfigError as e:
516
+ app._log_err(f"{e}. Nothing was changed.")
517
+ return
513
518
 
514
519
  verb = "enabled" if sub == "on" else "disabled"
515
520
  if touched:
@@ -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.4.1"
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",
@@ -29,8 +28,9 @@ dependencies = [
29
28
  [project.optional-dependencies]
30
29
  # pylitterbot asks for mcp[cli]>=1.0.0 with no upper bound, but its server does
31
30
  # `from mcp.server.fastmcp import FastMCP`, and mcp 2.0 deleted that module.
32
- # Cap it here until pylitterbot ships a 2.x-compatible release.
33
- mcp = ["pylitterbot[mcp]==2025.6.6", "mcp<2"]
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"]
34
34
  # PyGObject is what puts pystray on its AppIndicator backend. Without it pystray
35
35
  # silently falls back to a legacy XEmbed icon, which KDE Plasma and GNOME no
36
36
  # longer host at all — the watcher runs, draws an icon, and nothing shows it.
@@ -81,7 +81,7 @@ lint = "ruff check ."
81
81
  fmt = "ruff format --check ."
82
82
  types = "mypy asher/ --ignore-missing-imports"
83
83
  test = "pytest tests/ -v --tb=short"
84
- security.cmd = "uvx --from bandit[toml] bandit -c pyproject.toml -r ."
84
+ security.cmd = "uvx --from bandit[toml]==1.9.4 bandit -c pyproject.toml -r ."
85
85
  security.help = "run the same Bandit scan as the Bandit workflow"
86
86
  fix.sequence = [{cmd = "ruff check --fix ."}, {cmd = "ruff format ."}]
87
87
  dev = "textual run --dev asher/__main__.py"
@@ -104,7 +104,7 @@ packages = ["asher"]
104
104
 
105
105
  [tool.ruff]
106
106
  line-length = 100
107
- target-version = "py310"
107
+ target-version = "py311"
108
108
  # Ruff 0.16 added "*.md" to the default include list, which pulls the Python
109
109
  # snippets in docs/ and .claude/skills/ into `ruff format`. Those are
110
110
  # illustrative, not compiled, so keep the formatter scoped to real source.
@@ -124,14 +124,14 @@ known-first-party = ["asher"]
124
124
  # ── Mypy ──────────────────────────────────────────────────────────────────────
125
125
 
126
126
  [tool.mypy]
127
- python_version = "3.10"
127
+ python_version = "3.11"
128
128
  warn_return_any = true
129
129
  ignore_missing_imports = true # pylitterbot and textual have incomplete stubs
130
130
 
131
131
  # ── bump-my-version ───────────────────────────────────────────────────────────
132
132
 
133
133
  [tool.bumpversion]
134
- current_version = "1.4.1"
134
+ current_version = "1.6.0"
135
135
  commit = true
136
136
  tag = true
137
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)