asher-cli 1.2.0__tar.gz → 1.3.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 (115) hide show
  1. {asher_cli-1.2.0 → asher_cli-1.3.0}/.github/workflows/bandit.yml +2 -2
  2. {asher_cli-1.2.0 → asher_cli-1.3.0}/.github/workflows/ci.yml +2 -2
  3. {asher_cli-1.2.0 → asher_cli-1.3.0}/.github/workflows/claude-code-review.yml +1 -1
  4. {asher_cli-1.2.0 → asher_cli-1.3.0}/.github/workflows/claude.yml +1 -1
  5. {asher_cli-1.2.0 → asher_cli-1.3.0}/.github/workflows/coverage.yml +1 -1
  6. {asher_cli-1.2.0 → asher_cli-1.3.0}/.github/workflows/release.yml +1 -1
  7. {asher_cli-1.2.0 → asher_cli-1.3.0}/CHANGELOG.md +15 -0
  8. {asher_cli-1.2.0 → asher_cli-1.3.0}/CLAUDE.md +2 -2
  9. {asher_cli-1.2.0 → asher_cli-1.3.0}/PKG-INFO +10 -5
  10. {asher_cli-1.2.0 → asher_cli-1.3.0}/README.md +9 -4
  11. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/__main__.py +18 -1
  12. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/activity_labels.py +7 -0
  13. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/commands/__init__.py +105 -34
  14. asher_cli-1.3.0/asher/constants.py +61 -0
  15. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/headless.py +78 -24
  16. asher_cli-1.3.0/asher/helpers.py +182 -0
  17. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/monitoring/__init__.py +5 -4
  18. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/robot_adapters.py +149 -6
  19. {asher_cli-1.2.0 → asher_cli-1.3.0}/docs/ROADMAP.md +51 -29
  20. {asher_cli-1.2.0 → asher_cli-1.3.0}/pyproject.toml +2 -2
  21. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_activity_labels.py +21 -0
  22. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_headless.py +169 -0
  23. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_lr5_commands.py +110 -0
  24. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_missing_robot_commands.py +139 -0
  25. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_monitoring.py +62 -0
  26. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_robot_adapters.py +192 -0
  27. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_version_command.py +12 -0
  28. asher_cli-1.3.0/tests/testhelpers.py +245 -0
  29. {asher_cli-1.2.0 → asher_cli-1.3.0}/uv.lock +265 -258
  30. asher_cli-1.2.0/asher/constants.py +0 -24
  31. asher_cli-1.2.0/asher/helpers.py +0 -71
  32. asher_cli-1.2.0/tests/testhelpers.py +0 -113
  33. {asher_cli-1.2.0 → asher_cli-1.3.0}/.claude/hooks/block-env.py +0 -0
  34. {asher_cli-1.2.0 → asher_cli-1.3.0}/.claude/settings.json +0 -0
  35. {asher_cli-1.2.0 → asher_cli-1.3.0}/.claude/skills/mcp-bridge/SKILL.md +0 -0
  36. {asher_cli-1.2.0 → asher_cli-1.3.0}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
  37. {asher_cli-1.2.0 → asher_cli-1.3.0}/.claude/skills/textual/SKILL.md +0 -0
  38. {asher_cli-1.2.0 → asher_cli-1.3.0}/.env.example +0 -0
  39. {asher_cli-1.2.0 → asher_cli-1.3.0}/.githooks/pre-push +0 -0
  40. {asher_cli-1.2.0 → asher_cli-1.3.0}/.github/FUNDING.yml +0 -0
  41. {asher_cli-1.2.0 → asher_cli-1.3.0}/.github/pull_request_template.md +0 -0
  42. {asher_cli-1.2.0 → asher_cli-1.3.0}/.gitignore +0 -0
  43. {asher_cli-1.2.0 → asher_cli-1.3.0}/.vscode/launch.json +0 -0
  44. {asher_cli-1.2.0 → asher_cli-1.3.0}/.vscode/settings.json +0 -0
  45. {asher_cli-1.2.0 → asher_cli-1.3.0}/.vscode/tasks.json +0 -0
  46. {asher_cli-1.2.0 → asher_cli-1.3.0}/CODEOWNERS +0 -0
  47. {asher_cli-1.2.0 → asher_cli-1.3.0}/LICENSE +0 -0
  48. {asher_cli-1.2.0 → asher_cli-1.3.0}/app.py +0 -0
  49. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/__init__.py +0 -0
  50. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/app.py +0 -0
  51. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/auth.py +0 -0
  52. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/autostart.py +0 -0
  53. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/cats.py +0 -0
  54. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/commands/base.py +0 -0
  55. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/completion.py +0 -0
  56. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/config.py +0 -0
  57. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/connection/__init__.py +0 -0
  58. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/daemon.py +0 -0
  59. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/desktoptheme.py +0 -0
  60. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/export.py +0 -0
  61. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/faults.py +0 -0
  62. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/history_view.py +0 -0
  63. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/launcher.py +0 -0
  64. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/login_flow.py +0 -0
  65. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/mcp_bridge.py +0 -0
  66. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/mcp_config.py +0 -0
  67. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/notifications.py +0 -0
  68. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/robot_protocol.py +0 -0
  69. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/slash-commands/__init__.py +0 -0
  70. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/theme.py +0 -0
  71. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/tray.py +0 -0
  72. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/ui/__init__.py +0 -0
  73. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/ui/style.tcss +0 -0
  74. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/updates.py +0 -0
  75. {asher_cli-1.2.0 → asher_cli-1.3.0}/asher/watcher.py +0 -0
  76. {asher_cli-1.2.0 → asher_cli-1.3.0}/cliff.toml +0 -0
  77. {asher_cli-1.2.0 → asher_cli-1.3.0}/docs/roadmap-archive/README.md +0 -0
  78. {asher_cli-1.2.0 → asher_cli-1.3.0}/docs/roadmap-archive/cat-panel-badges.md +0 -0
  79. {asher_cli-1.2.0 → asher_cli-1.3.0}/docs/roadmap-archive/config-persistence.md +0 -0
  80. {asher_cli-1.2.0 → asher_cli-1.3.0}/docs/roadmap-archive/desktop-notifications.md +0 -0
  81. {asher_cli-1.2.0 → asher_cli-1.3.0}/docs/roadmap-archive/fault-monitoring.md +0 -0
  82. {asher_cli-1.2.0 → asher_cli-1.3.0}/docs/roadmap-archive/headless-export.md +0 -0
  83. {asher_cli-1.2.0 → asher_cli-1.3.0}/docs/roadmap-archive/history-export.md +0 -0
  84. {asher_cli-1.2.0 → asher_cli-1.3.0}/docs/roadmap-archive/tab-completion.md +0 -0
  85. {asher_cli-1.2.0 → asher_cli-1.3.0}/renovate.json +0 -0
  86. {asher_cli-1.2.0 → asher_cli-1.3.0}/requirements.txt +0 -0
  87. {asher_cli-1.2.0 → asher_cli-1.3.0}/test.py +0 -0
  88. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/__init__.py +0 -0
  89. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/conftest.py +0 -0
  90. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_app_pilot.py +0 -0
  91. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_auth.py +0 -0
  92. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_auth_pilot.py +0 -0
  93. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_autostart.py +0 -0
  94. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_cats.py +0 -0
  95. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_commands_pilot.py +0 -0
  96. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_completion.py +0 -0
  97. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_config.py +0 -0
  98. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_connection.py +0 -0
  99. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_connection_mixin.py +0 -0
  100. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_daemon.py +0 -0
  101. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_desktoptheme.py +0 -0
  102. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_export.py +0 -0
  103. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_faults.py +0 -0
  104. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_history_view.py +0 -0
  105. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_launcher.py +0 -0
  106. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_mcp_bridge.py +0 -0
  107. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_mcp_command.py +0 -0
  108. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_mcp_config.py +0 -0
  109. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_new_commands_pilot.py +0 -0
  110. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_notifications.py +0 -0
  111. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_tray.py +0 -0
  112. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_ui.py +0 -0
  113. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_updates.py +0 -0
  114. {asher_cli-1.2.0 → asher_cli-1.3.0}/tests/test_watcher.py +0 -0
  115. {asher_cli-1.2.0 → asher_cli-1.3.0}/watchrun.py +0 -0
@@ -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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
34
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
44
+ uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
22
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
23
23
  - run: uv sync --no-default-groups --group dev
24
24
  - run: uv run ruff check .
25
25
  - run: uv run ruff format --check .
@@ -35,6 +35,6 @@ jobs:
35
35
  runs-on: ${{ matrix.os }}
36
36
  steps:
37
37
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
38
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
38
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
39
39
  - run: uv sync --no-default-groups --group dev
40
40
  - run: uv run pytest tests/ -v --tb=short
@@ -33,7 +33,7 @@ jobs:
33
33
 
34
34
  - name: Run Claude Code Review
35
35
  id: claude-review
36
- uses: anthropics/claude-code-action@6b082c41935b4c8a3b8b0ef85ba4ba4d9eeb8975 # v1
36
+ uses: anthropics/claude-code-action@9d7150bc8a3dae8149739a88019d192b579ad90c # v1
37
37
  with:
38
38
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39
39
  plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
@@ -32,7 +32,7 @@ jobs:
32
32
 
33
33
  - name: Run Claude Code
34
34
  id: claude
35
- uses: anthropics/claude-code-action@6b082c41935b4c8a3b8b0ef85ba4ba4d9eeb8975 # v1
35
+ uses: anthropics/claude-code-action@9d7150bc8a3dae8149739a88019d192b579ad90c # v1
36
36
  with:
37
37
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
38
38
 
@@ -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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
21
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
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
@@ -20,7 +20,7 @@ jobs:
20
20
  - name: Extract version from branch name
21
21
  id: version
22
22
  run: echo "version=${GITHUB_REF_NAME#release/}" >> "$GITHUB_OUTPUT"
23
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
23
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
24
24
  - run: uv build
25
25
  - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
26
26
  with:
@@ -7,6 +7,21 @@ 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.3.0] - 2026-08-23
11
+
12
+ ### 🚀 Features
13
+
14
+ - *(commands)* Close the remaining robot-command gaps in the roadmap
15
+ - *(monitoring)* Shorten cat-panel status labels that overflow the panel width
16
+
17
+ ### 🐛 Bug Fixes
18
+
19
+ - *(monitoring)* Render readable status text in cat panel, not raw cloud code
20
+
21
+ ### 🧪 Tests
22
+
23
+ - *(version)* Keep the /version pilot tests off the network
24
+
10
25
  ## [1.2.0] - 2026-08-16
11
26
 
12
27
  ### 🚀 Features
@@ -23,8 +23,8 @@ asher/
23
23
  __init__.py
24
24
  app.py AsherApp class (thin orchestrator — composes mixins)
25
25
  auth.py LoginScreen modal (ModalScreen[tuple[str,str]]) — available, not primary flow
26
- helpers.py fmt_ago(), drawer_bar(), ts(), robot_model() (pure, testable)
27
- constants.py STATUS_COLORS, ROBOT_MODELS
26
+ helpers.py fmt_ago(), fmt_until(), drawer_bar(), ts(), robot_model(), status_text() + argument parsing shared by the TUI and headless surfaces: hex_colour(), parse_clock(), parse_day(), split_type_flag(), activity_type() (pure, testable)
27
+ constants.py STATUS_COLORS, CAT_PANEL_STATUS_LABELS, ACTIVITY_TYPES, ROBOT_MODELS
28
28
  theme.py Catppuccin Mocha palette + semantic roles (BACKGROUND, MUTED, DANGER, …) + CSS_VARIABLES/apply() — the only place a hex literal belongs
29
29
  config.py runtime settings persistence — load()/save()/update() over ~/.asher-cli/config.json; holds poll interval, cat-panel visibility/colour, active pet index, notification settings (non-secret UI prefs only; credentials stay in keyring)
30
30
  notifications.py desktop toast + audible alert façade — plyer first, then the platform's own tool (osascript/notify-send); fire/beep are always-safe no-ops on failure/headless
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: asher-cli
3
- Version: 1.2.0
3
+ Version: 1.3.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
@@ -75,8 +75,8 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
75
75
  - Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`)
76
76
  - 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
77
  - Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
78
- - Scrollable activity-history pager — `history [count|all]` 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
79
- - Commands: `clean`, `status`, `info`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto`, `night-light-brightness`, `panel-brightness <low|medium|high>` (LR4/LR5), `wait-time`, `power on|off`, `rename`, `insight`, `sleep-schedule`, plus LR5 extras (`privacy`, `volume`, `camera-audio`, `drawer-reset`), `history [count|all]`, `export [days|month]`, `help`, `quit`
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
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`
80
80
  - Slash commands for app management: `/login`, `/logout`, `/robots`, `/robot <index|name>`, `/pets`, `/pet <index|name>`, `/cat on|off|colour <hex>`, `/refresh [seconds|off]`, `/config`, `/notify on|off|sound on|off|test`, `/watch start|stop|status|enable|disable`, `/version`, `/mcp on|off|status`, `/exit`
81
81
  - Slash-command tab completion — type `/` and a Claude Code-style overlay lists matching commands; `↑`/`↓` to move, `Tab` or `Enter` to accept, `Esc` to dismiss
82
82
  - Inline ghost-text completion for bare commands — type a prefix (`cle`) and the rest (`an`) appears greyed; `Tab` or `→` to accept → `clean`
@@ -142,18 +142,21 @@ The keyring is the only place an installed copy reads credentials from. Working
142
142
  | `lock` / `unlock` | Toggle panel lockout |
143
143
  | `sleep` / `wake` | Toggle sleep mode |
144
144
  | `night-light on\|off\|auto` | Set night light mode |
145
+ | `night-light color <hex>` | Set the LR5 night light colour (`#RRGGBB`, or the 3-digit shorthand) |
145
146
  | `night-light-brightness <level>` | Set brightness (LR5: 0-100; LR4: 25/50/100) |
146
147
  | `panel-brightness <low\|medium\|high>` | Set control-panel brightness (LR4/LR5 only; shows current if omitted) |
147
148
  | `wait-time <minutes>` | Set clean-cycle wait time (shows valid values / current if omitted) |
148
149
  | `power on\|off` | Hard-power the unit on or off |
149
150
  | `rename <new name>` | Rename the unit in the Whisker cloud |
150
151
  | `insight [days\|month]` | Show cycle-usage statistics (default: 30 days) |
151
- | `sleep-schedule` | Show the per-day sleep schedule (read-only) |
152
+ | `sleep-schedule` | Show the per-day sleep schedule |
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: read-only, use the Whisker app) |
154
+ | `sleep-schedule disable` | Turn every scheduled sleep window off |
152
155
  | `privacy on\|off` | Toggle LR5 privacy mode |
153
156
  | `volume <0-100>` | Set LR5 sound volume |
154
157
  | `camera-audio on\|off` | Toggle LR5 camera audio |
155
158
  | `drawer-reset` | Reset the LR5 waste drawer level indicator |
156
- | `history [count\|all]` | Show recent activity in a scrollable pager (default: 50 events) |
159
+ | `history [count\|all] [--type <kind>]` | Show recent activity in a scrollable pager (default: 50 events); `--type cat\|clean\|litter\|offline\|…` filters on the LR5 |
157
160
  | `export [days\|month]` | Export activity history to CSV in `~/Downloads` (default: 30 days) |
158
161
  | `clear` | Clear the log |
159
162
  | `help` | Show command list |
@@ -193,6 +196,8 @@ asher clean start a clean cycle
193
196
  asher night-light auto set night light mode
194
197
  asher wait-time 7 set the clean-cycle wait time
195
198
  asher history 20 recent activity, newest first
199
+ asher history 20 --type cat only cat visits (LR5)
200
+ asher sleep-schedule set all 22:00 07:00 sleep 22:00 → 07:00 every day
196
201
  asher insight 7 cycle-usage statistics
197
202
  asher export 7 --output ~/hist.csv activity history to CSV
198
203
  ```
@@ -19,8 +19,8 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
19
19
  - Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`)
20
20
  - Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults (LR5: bonnet/laser/drawer); press `d` to dismiss
21
21
  - Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
22
- - Scrollable activity-history pager — `history [count|all]` 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
23
- - Commands: `clean`, `status`, `info`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto`, `night-light-brightness`, `panel-brightness <low|medium|high>` (LR4/LR5), `wait-time`, `power on|off`, `rename`, `insight`, `sleep-schedule`, plus LR5 extras (`privacy`, `volume`, `camera-audio`, `drawer-reset`), `history [count|all]`, `export [days|month]`, `help`, `quit`
22
+ - 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
23
+ - 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`
24
24
  - Slash commands for app management: `/login`, `/logout`, `/robots`, `/robot <index|name>`, `/pets`, `/pet <index|name>`, `/cat on|off|colour <hex>`, `/refresh [seconds|off]`, `/config`, `/notify on|off|sound on|off|test`, `/watch start|stop|status|enable|disable`, `/version`, `/mcp on|off|status`, `/exit`
25
25
  - Slash-command tab completion — type `/` and a Claude Code-style overlay lists matching commands; `↑`/`↓` to move, `Tab` or `Enter` to accept, `Esc` to dismiss
26
26
  - Inline ghost-text completion for bare commands — type a prefix (`cle`) and the rest (`an`) appears greyed; `Tab` or `→` to accept → `clean`
@@ -86,18 +86,21 @@ The keyring is the only place an installed copy reads credentials from. Working
86
86
  | `lock` / `unlock` | Toggle panel lockout |
87
87
  | `sleep` / `wake` | Toggle sleep mode |
88
88
  | `night-light on\|off\|auto` | Set night light mode |
89
+ | `night-light color <hex>` | Set the LR5 night light colour (`#RRGGBB`, or the 3-digit shorthand) |
89
90
  | `night-light-brightness <level>` | Set brightness (LR5: 0-100; LR4: 25/50/100) |
90
91
  | `panel-brightness <low\|medium\|high>` | Set control-panel brightness (LR4/LR5 only; shows current if omitted) |
91
92
  | `wait-time <minutes>` | Set clean-cycle wait time (shows valid values / current if omitted) |
92
93
  | `power on\|off` | Hard-power the unit on or off |
93
94
  | `rename <new name>` | Rename the unit in the Whisker cloud |
94
95
  | `insight [days\|month]` | Show cycle-usage statistics (default: 30 days) |
95
- | `sleep-schedule` | Show the per-day sleep schedule (read-only) |
96
+ | `sleep-schedule` | Show the per-day sleep schedule |
97
+ | `sleep-schedule set <day\|all> <HH:MM> <HH:MM>` | Set the sleep → wake window (LR3: one window for every day, wake time fixed; LR4: read-only, use the Whisker app) |
98
+ | `sleep-schedule disable` | Turn every scheduled sleep window off |
96
99
  | `privacy on\|off` | Toggle LR5 privacy mode |
97
100
  | `volume <0-100>` | Set LR5 sound volume |
98
101
  | `camera-audio on\|off` | Toggle LR5 camera audio |
99
102
  | `drawer-reset` | Reset the LR5 waste drawer level indicator |
100
- | `history [count\|all]` | Show recent activity in a scrollable pager (default: 50 events) |
103
+ | `history [count\|all] [--type <kind>]` | Show recent activity in a scrollable pager (default: 50 events); `--type cat\|clean\|litter\|offline\|…` filters on the LR5 |
101
104
  | `export [days\|month]` | Export activity history to CSV in `~/Downloads` (default: 30 days) |
102
105
  | `clear` | Clear the log |
103
106
  | `help` | Show command list |
@@ -137,6 +140,8 @@ asher clean start a clean cycle
137
140
  asher night-light auto set night light mode
138
141
  asher wait-time 7 set the clean-cycle wait time
139
142
  asher history 20 recent activity, newest first
143
+ asher history 20 --type cat only cat visits (LR5)
144
+ asher sleep-schedule set all 22:00 07:00 sleep 22:00 → 07:00 every day
140
145
  asher insight 7 cycle-usage statistics
141
146
  asher export 7 --output ~/hist.csv activity history to CSV
142
147
  ```
@@ -132,9 +132,26 @@ def _build_parser() -> argparse.ArgumentParser:
132
132
  metavar="PATH",
133
133
  help="write to this path instead of ~/Downloads.",
134
134
  )
135
+ if command.name == "history":
136
+ sub.add_argument(
137
+ "--type",
138
+ metavar="KIND",
139
+ help="only events of this kind (LR5 only), e.g. cat, clean, litter.",
140
+ )
135
141
  return parser
136
142
 
137
143
 
144
+ def _handler_args(parsed: argparse.Namespace) -> list[str]:
145
+ """Rebuild the word list a headless handler parses.
146
+
147
+ The handlers take raw words so the TUI and the CLI read the same grammar;
148
+ argparse only declares the flags it needs in order to document them in
149
+ ``--help``, and they are folded back in here.
150
+ """
151
+ kind = getattr(parsed, "type", None)
152
+ return [*parsed.args, "--type", kind] if kind else list(parsed.args)
153
+
154
+
138
155
  def main() -> None:
139
156
  args = _build_parser().parse_args()
140
157
 
@@ -157,7 +174,7 @@ def main() -> None:
157
174
  asyncio.run(
158
175
  run(
159
176
  args.command,
160
- args.args,
177
+ _handler_args(args),
161
178
  robot=args.robot,
162
179
  output=args.output,
163
180
  as_json=getattr(args, "json", False),
@@ -35,6 +35,13 @@ ACTION_LABELS: dict[str, tuple[str, str]] = {
35
35
  "motor fault": ("Motor fault", theme.DANGER),
36
36
  "pinch detect": ("Pinch detected", theme.DANGER),
37
37
  "timing fault": ("Timing fault", theme.WARN),
38
+ # The LR5 activity endpoint reports SCREAMING_SNAKE type codes rather than
39
+ # the sentence-style text the LR3/LR4 history returns.
40
+ "pet_visit": ("Cat visit", theme.WARN),
41
+ "cat_detect": ("Cat detected", theme.WARN),
42
+ "cycle_completed": ("Clean cycle complete", theme.OK),
43
+ "cycle_interrupted": ("Cycle interrupted", theme.WARN),
44
+ "litter_low": ("Litter low", theme.WARN),
38
45
  }
39
46
 
40
47
  # Fallback colour for unknown event types — muted grey rather than crashing.
@@ -7,7 +7,7 @@ import contextlib
7
7
  import shutil
8
8
  import subprocess
9
9
  import sys
10
- from datetime import time
10
+ from datetime import datetime, time
11
11
  from importlib.metadata import PackageNotFoundError
12
12
  from importlib.metadata import version as pkg_version
13
13
  from pathlib import Path
@@ -26,8 +26,21 @@ from textual.widgets import Input, RichLog, Static
26
26
 
27
27
  from .. import theme
28
28
  from ..completion import enter_completes, render_completion, slash_matches
29
+ from ..constants import ACTIVITY_TYPES
29
30
  from ..export import ExportError, build_history_csv, resolve_dest
30
- from ..helpers import fmt_ago, robot_model, ts
31
+ from ..helpers import (
32
+ DAY_NAMES,
33
+ activity_type,
34
+ fmt_ago,
35
+ fmt_until,
36
+ hex_colour,
37
+ parse_clock,
38
+ parse_day,
39
+ robot_model,
40
+ split_type_flag,
41
+ status_text,
42
+ ts,
43
+ )
31
44
  from ..history_view import HistoryScreen
32
45
  from ..login_flow import LoginFlow, LoginState
33
46
  from .base import Command, CommandRegistry, SlashCommand
@@ -43,22 +56,6 @@ def _fmt_wait_time(minutes: object) -> str:
43
56
  return "—"
44
57
 
45
58
 
46
- def _status_text(status: object) -> str:
47
- """Render a robot status as human-readable text.
48
-
49
- ``LitterBoxStatus`` is a plain ``Enum`` (not ``StrEnum``), so ``str()`` yields
50
- the verbose ``"LitterBoxStatus.READY"``; the readable form ("Ready") lives in
51
- its ``text`` property. Falls back to ``str()`` for plain strings, and to an
52
- em dash when status is missing.
53
- """
54
- if status is None:
55
- return "—"
56
- text = getattr(status, "text", None)
57
- if isinstance(text, str):
58
- return text
59
- return str(status)
60
-
61
-
62
59
  _POWER_LABELS = {"AC": "AC (mains)", "DC": "Battery", "NC": "Off/unknown"}
63
60
 
64
61
 
@@ -188,7 +185,7 @@ class StatusCommand(Command):
188
185
  last_seen = getattr(app, "_last_cat_seen", None) or getattr(r, "last_seen", None)
189
186
  rows = [
190
187
  ("Online", "yes" if getattr(r, "is_online", False) else "no"),
191
- ("Status", _status_text(getattr(r, "status", None))),
188
+ ("Status", status_text(getattr(r, "status", None))),
192
189
  ("Drawer", f"{float(getattr(r, 'waste_drawer_level', 0) or 0):.0f}%"),
193
190
  ("Last seen", fmt_ago(last_seen)),
194
191
  ("Cat weight", weight),
@@ -251,6 +248,9 @@ class InfoCommand(Command):
251
248
  ("Online", _yn(getattr(r, "is_online", False))),
252
249
  ("Last seen", fmt_ago(last_seen)),
253
250
  ]
251
+ filter_due = getattr(r, "next_filter_replacement_date", None)
252
+ if isinstance(filter_due, datetime):
253
+ rows.insert(-2, ("Filter due", fmt_until(filter_due)))
254
254
  log = app.query_one("#log", RichLog)
255
255
  for k, v in rows:
256
256
  t = Text()
@@ -348,19 +348,22 @@ class WakeCommand(Command):
348
348
  class NightLightCommand(Command):
349
349
  name = "night-light"
350
350
  aliases = ("nightlight", "nl")
351
- description = "set night light mode"
351
+ description = "on|off|auto|color <hex> set night light mode"
352
352
  requires_robot = True
353
353
 
354
354
  @property
355
355
  def display_name(self) -> str:
356
- return "night-light on|off|auto"
356
+ return "night-light"
357
357
 
358
358
  async def run(self, app: AsherApp, args: list[str]) -> None:
359
359
  if app._adapter is None:
360
360
  return
361
361
  arg = args[0].lower() if args else ""
362
+ if arg in ("color", "colour"):
363
+ await self._set_colour(app, args[1:])
364
+ return
362
365
  if arg not in ("on", "off", "auto"):
363
- app._log_warn("Usage: night-light on|off|auto")
366
+ app._log_warn("Usage: night-light on|off|auto|color <hex>")
364
367
  return
365
368
  ok, msg = await app._adapter.set_night_light(arg)
366
369
  if ok:
@@ -375,6 +378,19 @@ class NightLightCommand(Command):
375
378
  else:
376
379
  app._log_warn(msg)
377
380
 
381
+ async def _set_colour(self, app: AsherApp, args: list[str]) -> None:
382
+ if app._adapter is None:
383
+ return
384
+ colour = hex_colour(args[0]) if args else None
385
+ if colour is None:
386
+ app._log_warn("Usage: night-light color <hex> e.g. night-light color #FF9E64")
387
+ return
388
+ ok, msg = await app._adapter.set_night_light_color(colour)
389
+ if ok:
390
+ app._log_ok(msg)
391
+ else:
392
+ app._log_warn(msg)
393
+
378
394
 
379
395
  class NightLightBrightnessCommand(Command):
380
396
  name = "night-light-brightness"
@@ -440,13 +456,14 @@ class PanelBrightnessCommand(Command):
440
456
  class HistoryCommand(Command):
441
457
  name = "history"
442
458
  aliases = ("hist",)
443
- description = "[count|all] show recent activity in a scrollable pager"
459
+ description = "[count|all] [--type <kind>] recent activity in a scrollable pager"
444
460
  requires_robot = True
445
461
 
446
462
  async def run(self, app: AsherApp, args: list[str]) -> None:
447
- if app._robot is None:
463
+ if app._robot is None or app._adapter is None:
448
464
  return
449
- raw = args[0].lower() if args else ""
465
+ counts, wanted_type = split_type_flag(args)
466
+ raw = counts[0].lower() if counts else ""
450
467
  if raw in ("all", "max"):
451
468
  limit = 500
452
469
  elif raw:
@@ -457,10 +474,15 @@ class HistoryCommand(Command):
457
474
  return
458
475
  else:
459
476
  limit = 50
460
- try:
461
- acts = await app._robot.get_activity_history(limit=limit)
462
- except Exception as exc:
463
- app._log_err(f"Failed to get history: {exc}")
477
+
478
+ wanted = activity_type(wanted_type)
479
+ if wanted_type is not None and wanted is None:
480
+ app._log_warn(f"Usage: history [count|all] --type <{'|'.join(ACTIVITY_TYPES)}>")
481
+ return
482
+
483
+ acts, problem = await app._adapter.get_history(limit, wanted)
484
+ if acts is None:
485
+ app._log_err(problem)
464
486
  return
465
487
  robot_name = getattr(app._robot, "name", "robot")
466
488
  app.push_screen(HistoryScreen(acts, app._pets, robot_name))
@@ -616,9 +638,6 @@ class InsightCommand(Command):
616
638
  log.write(t)
617
639
 
618
640
 
619
- _DAY_NAMES = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
620
-
621
-
622
641
  def _fmt_sleep_time(t: object) -> str:
623
642
  if t is None:
624
643
  return "—"
@@ -645,10 +664,16 @@ def _parse_sleep_day(day: Any) -> tuple[int, bool, object, object] | None:
645
664
  return None
646
665
 
647
666
 
667
+ _EVERY_DAY = ("all", "every", "daily", "everyday")
668
+ _SCHEDULE_USAGE = (
669
+ "Usage: sleep-schedule · sleep-schedule set <day|all> <HH:MM> <HH:MM> · sleep-schedule disable"
670
+ )
671
+
672
+
648
673
  class SleepScheduleCommand(Command):
649
674
  name = "sleep-schedule"
650
675
  aliases = ("sleepschedule",)
651
- description = "show the per-day sleep schedule (read-only)"
676
+ description = "[set <day|all> <HH:MM> <HH:MM>|disable] per-day sleep windows"
652
677
  requires_robot = True
653
678
 
654
679
  @property
@@ -656,6 +681,52 @@ class SleepScheduleCommand(Command):
656
681
  return "sleep-schedule"
657
682
 
658
683
  async def run(self, app: AsherApp, args: list[str]) -> None:
684
+ action = args[0].lower() if args else ""
685
+ if action == "set":
686
+ await self._set_window(app, args[1:])
687
+ return
688
+ if action in ("disable", "off"):
689
+ await self._disable(app)
690
+ return
691
+ if action:
692
+ app._log_warn(_SCHEDULE_USAGE)
693
+ return
694
+ await self._show(app)
695
+
696
+ async def _set_window(self, app: AsherApp, args: list[str]) -> None:
697
+ if app._adapter is None:
698
+ return
699
+ if len(args) < 3:
700
+ app._log_warn("Usage: sleep-schedule set <day|all> <HH:MM> <HH:MM>")
701
+ return
702
+ day_arg = args[0].lower()
703
+ weekday = None if day_arg in _EVERY_DAY else parse_day(day_arg)
704
+ if weekday is None and day_arg not in _EVERY_DAY:
705
+ app._log_warn(f"Unknown day '{args[0]}' — use {'/'.join(DAY_NAMES)} or 'all'")
706
+ return
707
+ sleep, wake = parse_clock(args[1]), parse_clock(args[2])
708
+ if sleep is None or wake is None:
709
+ app._log_warn("Times must be 24-hour HH:MM — e.g. sleep-schedule set all 22:00 07:00")
710
+ return
711
+
712
+ ok, msg = await app._adapter.set_sleep_window(weekday, sleep, wake)
713
+ if not ok:
714
+ app._log_warn(msg)
715
+ return
716
+ app._log_ok(msg)
717
+ await app._refresh_status()
718
+
719
+ async def _disable(self, app: AsherApp) -> None:
720
+ if app._adapter is None:
721
+ return
722
+ ok, msg = await app._adapter.disable_sleep_schedule()
723
+ if not ok:
724
+ app._log_warn(msg)
725
+ return
726
+ app._log_ok(msg)
727
+ await app._refresh_status()
728
+
729
+ async def _show(self, app: AsherApp) -> None:
659
730
  if app._robot is None:
660
731
  return
661
732
  try:
@@ -704,7 +775,7 @@ class SleepScheduleCommand(Command):
704
775
  continue
705
776
  idx, day_enabled, sleep_t, wake_t = parsed
706
777
 
707
- name = _DAY_NAMES[idx]
778
+ name = DAY_NAMES[idx]
708
779
  t = Text()
709
780
  t.append(f" {name} ", style=theme.MUTED)
710
781
  if day_enabled:
@@ -0,0 +1,61 @@
1
+ """Module-level constants shared across the app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import theme
6
+
7
+ STATUS_COLORS: dict[str, str] = {
8
+ "Ready": theme.OK,
9
+ "Clean Cycle In Progress": theme.ACCENT,
10
+ "Empty Cycle": theme.ACCENT,
11
+ "Cat Detected": theme.WARN,
12
+ "Clean Cycle Paused": theme.WARN,
13
+ "Drawer Almost Full - 2 Cycles Left": theme.WARN,
14
+ "Drawer Almost Full - 1 Cycle Left": theme.WARN,
15
+ "Drawer Full": theme.DANGER,
16
+ "Off": theme.DANGER,
17
+ "Offline": theme.DANGER,
18
+ "Clean Cycle Complete": theme.OK,
19
+ }
20
+
21
+ # Shortened LitterBoxStatus.text values for the cat panel only. #cat-status
22
+ # (asher/ui/style.tcss) is 26 columns wide with a 9-character key column,
23
+ # leaving 17 for the value — these .text values overflow that and wrap onto
24
+ # a second line. Only entries that would overflow are listed; everything
25
+ # else passes through status_text() unshortened. The status bar and the
26
+ # headless/`status` command still render the full text.
27
+ CAT_PANEL_STATUS_LABELS: dict[str, str] = {
28
+ "Clean Cycle In Progress": "Cycling",
29
+ "Clean Cycle Complete": "Cycle Complete",
30
+ "Clean Cycle Paused": "Cycle Paused",
31
+ "Cat Sensor Interrupted": "Sensor Interrupt",
32
+ "Drawer Almost Full - 2 Cycles Left": "Almost Full (2)",
33
+ "Drawer Almost Full - 1 Cycle Left": "Almost Full (1)",
34
+ "Dump + Home Position Fault": "Dump+Home Fault",
35
+ "Dump Position Fault": "Dump Pos Fault",
36
+ "Home Position Fault": "Home Pos Fault",
37
+ "Cat Sensor Fault At Startup": "Sensor (Startup)",
38
+ "Drawer Full At Startup": "Drawer (Startup)",
39
+ "Pinch Detect At Startup": "Pinch (Startup)",
40
+ }
41
+
42
+ # Friendly `history --type` argument → the LR5 activity type the cloud filters
43
+ # on. Anything not listed is passed through uppercased, so a type this table
44
+ # doesn't know yet is still reachable without a release.
45
+ ACTIVITY_TYPES: dict[str, str] = {
46
+ "cat": "PET_VISIT",
47
+ "visit": "PET_VISIT",
48
+ "detect": "CAT_DETECT",
49
+ "clean": "CYCLE_COMPLETED",
50
+ "cycle": "CYCLE_COMPLETED",
51
+ "interrupted": "CYCLE_INTERRUPTED",
52
+ "litter": "LITTER_LOW",
53
+ "offline": "OFFLINE",
54
+ }
55
+
56
+ ROBOT_MODELS: dict[str, str] = {
57
+ "LitterRobot3": "LR3",
58
+ "LitterRobot4": "LR4",
59
+ "LitterRobot5": "LR5",
60
+ "FeederRobot": "Feeder",
61
+ }