asher-cli 0.1.0__tar.gz → 0.1.1__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 (75) hide show
  1. {asher_cli-0.1.0 → asher_cli-0.1.1}/.gitignore +3 -0
  2. {asher_cli-0.1.0 → asher_cli-0.1.1}/CLAUDE.md +11 -1
  3. {asher_cli-0.1.0 → asher_cli-0.1.1}/PKG-INFO +4 -1
  4. {asher_cli-0.1.0 → asher_cli-0.1.1}/README.md +3 -0
  5. {asher_cli-0.1.0 → asher_cli-0.1.1}/ROADMAP.md +99 -57
  6. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/app.py +5 -0
  7. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/commands/__init__.py +117 -1
  8. asher_cli-0.1.1/asher/connection/__init__.py +259 -0
  9. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/constants.py +3 -0
  10. asher_cli-0.1.1/asher/faults.py +146 -0
  11. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/monitoring/__init__.py +135 -3
  12. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/ui/__init__.py +12 -0
  13. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/ui/style.tcss +23 -0
  14. {asher_cli-0.1.0 → asher_cli-0.1.1}/pyproject.toml +2 -2
  15. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/conftest.py +15 -2
  16. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_app_pilot.py +3 -0
  17. asher_cli-0.1.1/tests/test_connection.py +194 -0
  18. asher_cli-0.1.1/tests/test_faults.py +186 -0
  19. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_missing_robot_commands.py +130 -0
  20. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_monitoring.py +120 -0
  21. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_ui.py +12 -0
  22. asher_cli-0.1.1/tests/test_version_command.py +87 -0
  23. {asher_cli-0.1.0 → asher_cli-0.1.1}/uv.lock +1 -1
  24. asher_cli-0.1.0/asher/connection/__init__.py +0 -180
  25. asher_cli-0.1.0/tests/test_connection.py +0 -96
  26. {asher_cli-0.1.0 → asher_cli-0.1.1}/.claude/hooks/block-env.ps1 +0 -0
  27. {asher_cli-0.1.0 → asher_cli-0.1.1}/.claude/settings.json +0 -0
  28. {asher_cli-0.1.0 → asher_cli-0.1.1}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
  29. {asher_cli-0.1.0 → asher_cli-0.1.1}/.claude/skills/release/SKILL.md +0 -0
  30. {asher_cli-0.1.0 → asher_cli-0.1.1}/.claude/skills/textual/SKILL.md +0 -0
  31. {asher_cli-0.1.0 → asher_cli-0.1.1}/.env.example +0 -0
  32. {asher_cli-0.1.0 → asher_cli-0.1.1}/.githooks/pre-push +0 -0
  33. {asher_cli-0.1.0 → asher_cli-0.1.1}/.github/pull_request_template.md +0 -0
  34. {asher_cli-0.1.0 → asher_cli-0.1.1}/.github/workflows/ci.yml +0 -0
  35. {asher_cli-0.1.0 → asher_cli-0.1.1}/.github/workflows/claude-code-review.yml +0 -0
  36. {asher_cli-0.1.0 → asher_cli-0.1.1}/.github/workflows/claude.yml +0 -0
  37. {asher_cli-0.1.0 → asher_cli-0.1.1}/.github/workflows/coverage.yml +0 -0
  38. {asher_cli-0.1.0 → asher_cli-0.1.1}/.github/workflows/release.yml +0 -0
  39. {asher_cli-0.1.0 → asher_cli-0.1.1}/.vscode/launch.json +0 -0
  40. {asher_cli-0.1.0 → asher_cli-0.1.1}/.vscode/settings.json +0 -0
  41. {asher_cli-0.1.0 → asher_cli-0.1.1}/.vscode/tasks.json +0 -0
  42. {asher_cli-0.1.0 → asher_cli-0.1.1}/CODEOWNERS +0 -0
  43. {asher_cli-0.1.0 → asher_cli-0.1.1}/LICENSE +0 -0
  44. {asher_cli-0.1.0 → asher_cli-0.1.1}/app.py +0 -0
  45. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/__init__.py +0 -0
  46. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/__main__.py +0 -0
  47. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/activity_labels.py +0 -0
  48. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/auth.py +0 -0
  49. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/cats.py +0 -0
  50. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/commands/base.py +0 -0
  51. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/helpers.py +0 -0
  52. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/login_flow.py +0 -0
  53. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/mcp_bridge.py +0 -0
  54. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/mcp_config.py +0 -0
  55. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/robot_adapters.py +0 -0
  56. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/robot_protocol.py +0 -0
  57. {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/slash-commands/__init__.py +0 -0
  58. {asher_cli-0.1.0 → asher_cli-0.1.1}/renovate.json +0 -0
  59. {asher_cli-0.1.0 → asher_cli-0.1.1}/requirements.txt +0 -0
  60. {asher_cli-0.1.0 → asher_cli-0.1.1}/test.py +0 -0
  61. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/__init__.py +0 -0
  62. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_activity_labels.py +0 -0
  63. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_auth.py +0 -0
  64. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_auth_pilot.py +0 -0
  65. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_cats.py +0 -0
  66. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_commands_pilot.py +0 -0
  67. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_connection_mixin.py +0 -0
  68. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_lr5_commands.py +0 -0
  69. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_mcp_bridge.py +0 -0
  70. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_mcp_command.py +0 -0
  71. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_mcp_config.py +0 -0
  72. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_new_commands_pilot.py +0 -0
  73. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_robot_adapters.py +0 -0
  74. {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/testhelpers.py +0 -0
  75. {asher_cli-0.1.0 → asher_cli-0.1.1}/watchrun.py +0 -0
@@ -216,3 +216,6 @@ __marimo__/
216
216
 
217
217
  # Streamlit
218
218
  .streamlit/secrets.toml
219
+
220
+ # ZCode editor state
221
+ .zcode/
@@ -43,6 +43,7 @@ asher/
43
43
  robot_adapters.py RobotAdapter ABC + LR3/LR4/LR5 subclasses + make_adapter() factory
44
44
  mcp_config.py Claude Desktop config read/write for the /mcp slash command
45
45
  mcp_bridge.py asher-mcp-launch console script — keyring-backed pylitterbot MCP launcher
46
+ faults.py check_faults(robot) — model-scoped safety/component fault detection (status enum + per-model attr allowlist; hopper never a fault)
46
47
  __main__.py main() entry point
47
48
  commands/
48
49
  base.py Command ABC, SlashCommand, CommandRegistry
@@ -66,6 +67,7 @@ tests/
66
67
  test_mcp_config.py Claude Desktop config read/write
67
68
  test_mcp_bridge.py mcp_bridge launcher credential/subprocess handling
68
69
  test_mcp_command.py /mcp slash command dispatch
70
+ test_faults.py check_faults() — safety statuses, attribute faults, graceful degradation
69
71
 
70
72
  .github/workflows/
71
73
  ci.yml ruff + mypy + pytest on every push/PR
@@ -123,6 +125,11 @@ AsherApp (textual.App)
123
125
  ├── #main-area
124
126
  │ ├── #log RichLog — scrollable event/command output
125
127
  │ └── #cat-panel animated ASCII cat sidebar
128
+ │ ├── #cat-fx animated FX strip
129
+ │ ├── #cat-art the ASCII cat
130
+ │ ├── #cat-label mode label (connected / cycling… / fault!)
131
+ │ ├── #cat-status complementary badges (status, power, cycles, wait time) — no lock/night-light (those are top-row only)
132
+ │ └── #fault-banner hidden unless check_faults() returns active faults; `d` dismisses
126
133
  └── #bottom-dock bottom dock
127
134
  ├── #input-bar / #input-row command prompt ("> " label + CmdInput)
128
135
  └── #hint-bar shortcut hints / login flow prompts
@@ -135,7 +142,10 @@ LoginScreen (ModalScreen) — available in auth.py but not the primary auth path
135
142
  | Method | Purpose |
136
143
  |---|---|
137
144
  | `_connect_worker()` | `@work` — resolve credentials (keyring → .env → inline login), authenticate |
138
- | `_refresh_status()` | update all header widgets from robot state |
145
+ | `_refresh_status()` | update all header widgets + cat panel + fault banner from robot state |
146
+ | `_update_cat_panel(robot)` | render `#cat-label` + `#cat-status` (complementary: status, power, cycles, wait); called from `_refresh_status` |
147
+ | `_refresh_faults(robot)` | run `check_faults()`, render `#fault-banner`, log transitions; sets cat mode to `error` while faults active |
148
+ | `_cycling_chip()` / `_start_cycle_timer()` / `_stop_cycle_timer()` / `_tick_cycle()` | `⟳ Cycling M:SS` chip + lazy 1s elapsed timer |
139
149
  | `_poll_status_interval()` | `@work` — poll fallback every 300s (5 min); WebSocket is primary |
140
150
  | `_tick_cat()` | advances multi-frame cat animation every 0.4s |
141
151
  | `_dispatch_command(command, args)` | `@work` — calls `command.run(app, args)` from the registry |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asher-cli
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Terminal dashboard for Litter Robot (LR3/LR4/LR5) via the Whisker cloud API
5
5
  License: MIT License
6
6
 
@@ -62,6 +62,9 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
62
62
 
63
63
  - Live status bar — unit name, online/offline, drawer fill level, last activity, cat weight
64
64
  - Human-readable robot status — translates raw API states into plain English (`Ready`, `Cleaning`, `Cat Detected`, `Drawer Full`, etc.)
65
+ - Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`)
66
+ - Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults (LR5: bonnet/laser/drawer); press `d` to dismiss
67
+ - Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
65
68
  - Scrollable activity log with timestamps
66
69
  - Commands: `clean`, `status`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto`, `night-light-brightness`, `history`, `export [days|month]`, `help`, `quit`
67
70
  - Slash commands for app management: `/login`, `/logout`, `/robots`, `/robot <index|name>`, `/pets`, `/pet <index|name>`, `/cat on|off|color <hex>`, `/refresh [seconds|off]`, `/config`, `/exit`
@@ -16,6 +16,9 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
16
16
 
17
17
  - Live status bar — unit name, online/offline, drawer fill level, last activity, cat weight
18
18
  - Human-readable robot status — translates raw API states into plain English (`Ready`, `Cleaning`, `Cat Detected`, `Drawer Full`, etc.)
19
+ - Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`)
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
+ - Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
19
22
  - Scrollable activity log with timestamps
20
23
  - Commands: `clean`, `status`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto`, `night-light-brightness`, `history`, `export [days|month]`, `help`, `quit`
21
24
  - Slash commands for app management: `/login`, `/logout`, `/robots`, `/robot <index|name>`, `/pets`, `/pet <index|name>`, `/cat on|off|color <hex>`, `/refresh [seconds|off]`, `/config`, `/exit`
@@ -21,6 +21,9 @@ Current state, missing functionality, and suggested additions — grounded in wh
21
21
  | `LoginScreen` modal (`auth.py`) — available for future use | ✅ |
22
22
  | Activity history (`get_activity_history`) | ✅ |
23
23
  | Cat animation panel with mode changes | ✅ |
24
+ | Cat panel — mode label + status badges (status chip, lock, night light, sleep, wait) under the art | ✅ |
25
+ | Fault & safety monitoring — `#fault-banner` (in cat panel) driven by `asher/faults.py`; transition-logged; `d` to dismiss | ✅ |
26
+ | Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`) | ✅ |
24
27
  | Command history (↑/↓) | ✅ |
25
28
  | WebSocket real-time updates (LR4 primary; poll fallback every 5 min for activity history) | ✅ |
26
29
  | LR4 / LR5 / LR3 polymorphic support via `RobotAdapter` pattern | ✅ |
@@ -474,11 +477,24 @@ If weight ID misidentifies a cat, this corrects the record.
474
477
  objects (day, sleep_time, wake_time, is_enabled). This is more granular than the
475
478
  current `sleep` / `wake` toggle.
476
479
 
480
+ ### ~~`sleep-schedule` — read-only viewer~~ ✅
481
+
482
+ The `sleep-schedule` (alias `sleepschedule`) command renders the per-day
483
+ sleep/wake window read-only. Days are sorted Mon→Sun (converted from pylitterbot's
484
+ Sun=0..Sat=6 `DayOfWeek`); enabled days show `22:00 → 07:00`, disabled days show
485
+ `off`. If `schedule.get_window()` returns an active window covering the current
486
+ time, the affected day(s) get a `● now` marker. When the whole schedule is
487
+ disabled it notes that and still lists the configured windows; when
488
+ `sleep_schedule is None` it warns that the unit is always awake and points at
489
+ `sleep`/`wake`. `_sleep_schedule` can raise on malformed data, so the whole read
490
+ is wrapped defensively and degrades to a `_log_err`. Works on LR3/LR4/LR5 — the
491
+ property exists on all three.
492
+
477
493
  ```
478
- sleep-schedule show current schedule
479
- sleep-schedule set interactive wizard (or flags)
480
- sleep-schedule Mon 22:00 07:00 set Monday sleep window
481
- sleep-schedule disable clear all days
494
+ sleep-schedule show current schedule
495
+ sleep-schedule set interactive wizard (or flags) — not yet
496
+ sleep-schedule Mon 22:00 07:00 set Monday sleep window — not yet
497
+ sleep-schedule disable clear all days — not yet
482
498
  ```
483
499
 
484
500
  ### Contextual sleep/wake toggle
@@ -496,7 +512,30 @@ The `sleep` / `wake` commands should detect the robot model and dispatch accordi
496
512
 
497
513
  ---
498
514
 
499
- ## 9. Fault monitoring & alerts
515
+ ## ~~9. Fault monitoring & alerts~~ ✅
516
+
517
+ Live fault detection lives in `asher/faults.py` (`check_faults(robot)`), called
518
+ from `MonitoringMixin._refresh_faults` on every status refresh (WebSocket push
519
+ + poll). Detection is **model-scoped** — each model only checks the component
520
+ attributes that are genuine fault indicators for it (mirrors the adapter
521
+ pattern in `robot_adapters.py`):
522
+
523
+ - **LR4** — globe motor + retract faults only
524
+ - **LR5** — globe motor + retract + bonnet + laser + gas sensor + drawer-removed
525
+ - **LR3** — no component checks (pylitterbot exposes none)
526
+
527
+ `is_hopper_removed` is **never** treated as a fault: on the LR4 it's `True`
528
+ when no hopper accessory is fitted (a hardware configuration, not a fault),
529
+ and on the LR5 it's standard hardware state. Universal safety statuses (cat
530
+ detected, pinch, over-torque, position faults) are checked on every model.
531
+ Enum-valued properties (`GlobeMotorFaultStatus`) are checked against their
532
+ healthy sentinels (`NONE` / `FAULT_CLEAR`) since the enums are truthy even
533
+ when healthy. Faults surface via a `#fault-banner` widget inside the cat panel,
534
+ beneath the status badges — hidden by default, red for `error`, amber for
535
+ `warn`. Transitions are logged (`_log_err` new / `_log_ok` cleared) but
536
+ steady-state faults don't flood the log. While a fault is active the cat
537
+ panel switches to `error` mode. Press `d` to dismiss the banner until the set
538
+ of active faults changes.
500
539
 
501
540
  ### 9a. Safety events (highest priority — surface immediately)
502
541
 
@@ -816,11 +855,16 @@ entire visit. With real-time push the badge appears the moment the sensor trips.
816
855
 
817
856
  ---
818
857
 
819
- ### Real-time cycling indicator (requires WebSockets)
858
+ ### ~~Real-time cycling indicator (requires WebSockets)~~ ✅
820
859
 
821
- `LitterBoxStatus.CLEAN_CYCLE` is already caught by the `[RDY]` status chip,
822
- but polling every 30 s means a full clean cycle (typically 2–4 min) can start
823
- and finish between polls, showing only `Ready` to the user the whole time.
860
+ The `#online-lbl` chip now shows `⟳ Cycling M:SS` with live elapsed time while
861
+ a `CLEAN_CYCLE`/`EMPTY_CYCLE` is active. `_cycle_start` is stamped on the
862
+ transition into a cycling status and a 1 s `_cycle_timer` (created lazily via
863
+ `set_interval`, stopped/null on any non-cycling status) re-renders the chip each
864
+ second via `_tick_cycle`. The `_cycling_chip()` helper is shared between the
865
+ timer and the `_refresh_status` cycling branch so they stay consistent. Because
866
+ `_refresh_status` fires on WebSocket push, the chip updates the moment the cycle
867
+ starts — no 30 s polling gap.
824
868
 
825
869
  **What's needed:**
826
870
  - WebSocket subscription (§6) — `robot.subscribe()` fires `EVENT_UPDATE`
@@ -889,27 +933,27 @@ Helper functions in `asher/connection/__init__.py`:
889
933
 
890
934
  Keyring service name: `asher-cli`, keys `email` and `password`.
891
935
 
892
- ### Token persistence (stretch — avoid API re-auth on every run)
936
+ ### Token persistence — avoid API re-auth on every run
893
937
 
894
- `Account.connect()` accepts a pre-existing `token` dict and exposes a
895
- `token_update_callback`. If we save the session token alongside credentials
896
- after first login, subsequent runs skip the username/password API call entirely
897
- faster startup and more resilient to rate-limiting.
898
-
899
- ```python
900
- def save_token(token: dict | None) -> None:
901
- if token:
902
- keyring.set_password("asher-cli", "token", json.dumps(token))
938
+ `Account.__init__()` accepts a pre-existing `token` dict and a
939
+ `token_update_callback`; `connect()` with no username/password reuses a valid
940
+ token or silently refreshes it via the refresh token. The cached session token
941
+ is stored as a JSON blob in the OS keyring (key `"token"` under service
942
+ `asher-cli`), so subsequent launches skip the OAuth password login entirely —
943
+ faster startup, less password exposure, more resilient to rate-limiting.
903
944
 
904
- account = Account(
905
- token=json.loads(keyring.get_password("asher-cli", "token") or "null"),
906
- token_update_callback=save_token,
907
- )
908
- ```
945
+ `_connect_worker` tries the token first (via `_try_token_connect`), and only
946
+ falls back to the email/password path if the token is absent, expired, or
947
+ rejected — in which case the stale token is wiped so a poisoned token can't
948
+ loop. `token_update_callback=_keyring_save_token` is also wired into the
949
+ password-login `Account()` construction, so refreshes during a session are
950
+ captured for the next launch. Users only re-enter their password when the
951
+ refresh token itself expires (typically months).
909
952
 
910
- The token is automatically refreshed by pylitterbot when it expires. This
911
- would mean users only re-enter their password when the refresh token itself
912
- expires (typically months).
953
+ Helpers in `asher/connection/__init__.py`:
954
+ - `_keyring_load_token() dict | None` returns the cached token or `None`
955
+ - `_keyring_save_token(token: dict | None)` — persists, or clears when `None`
956
+ - `_keyring_delete()` now also clears `"token"` (so `/logout` invalidates it)
913
957
 
914
958
  ### `subscribe_for_updates` — let pylitterbot manage WebSocket per robot
915
959
 
@@ -1314,11 +1358,24 @@ remaining item from the original suggestion that isn't yet in place.
1314
1358
 
1315
1359
  ---
1316
1360
 
1317
- ## 18. Cat panel — robot status badges underneath the art
1361
+ ## ~~18. Cat panel — robot status badges underneath the art~~ ✅
1318
1362
 
1319
- Currently the cat panel only shows the ASCII art and a single italic label
1320
- (`connected`, `cleaning…`, etc.). The panel has room to show a compact set of
1321
- status indicators beneath the art without touching the status bar or log.
1363
+ A `#cat-label` widget renders the mode label (`connected`, `cycling…`,
1364
+ `fault!`, ) and a `#cat-status` widget below it shows **complementary** info
1365
+ that the top status bar doesn't surface deliberately avoiding duplication of
1366
+ the top row's lock / night-light. The panel shows:
1367
+
1368
+ ```
1369
+ status Ready
1370
+ power mains
1371
+ cycles 1234
1372
+ wait 7m
1373
+ ```
1374
+
1375
+ Lines use a fixed-width ASCII key column (no emoji or ambiguous-width glyphs)
1376
+ so they stay vertically aligned in every terminal. Updated from `_refresh_status`
1377
+ via `MonitoringMixin._update_cat_panel`, so it refreshes in real time on
1378
+ WebSocket push. Status colour comes from the consolidated `STATUS_COLORS` map.
1322
1379
 
1323
1380
  ### Proposed layout
1324
1381
 
@@ -2023,10 +2080,12 @@ chip of the status bar:
2023
2080
  The `_refresh_title()` method in `asher/ui/__init__.py` builds this; version
2024
2081
  falls back to `"dev"` when running from source without `pip install -e .`.
2025
2082
 
2026
- ### `/version` slash command
2083
+ ### ~~`/version` slash command~~ ✅
2027
2084
 
2028
- A convenience command that prints version info to the log without requiring
2029
- the user to look at the status bar:
2085
+ `/version` (a `SlashCommand` in `asher/commands/__init__.py`) prints the
2086
+ runtime versions to the log via `importlib.metadata.version()` with a
2087
+ `PackageNotFoundError` → `"?"` fallback, so it degrades cleanly when run from
2088
+ source without `pip install -e .`:
2030
2089
 
2031
2090
  ```
2032
2091
  Asher CLI v1.0.0
@@ -2035,23 +2094,6 @@ the user to look at the status bar:
2035
2094
  textual 0.x.x
2036
2095
  ```
2037
2096
 
2038
- ```python
2039
- async def _slash_version(self) -> None:
2040
- import sys
2041
- from importlib.metadata import version as pkg_version, PackageNotFoundError
2042
-
2043
- def _v(pkg):
2044
- try:
2045
- return pkg_version(pkg)
2046
- except PackageNotFoundError:
2047
- return "?"
2048
-
2049
- self._log_info(f"Asher CLI v{_v('asher-cli')}")
2050
- self._log_info(f"Python {sys.version.split()[0]}")
2051
- self._log_info(f"pylitterbot {_v('pylitterbot')}")
2052
- self._log_info(f"textual {_v('textual')}")
2053
- ```
2054
-
2055
2097
  ### ~~Status bar title — model badge~~ ✅
2056
2098
 
2057
2099
  The `#robot-lbl` widget already shows the model type appended to the robot name:
@@ -2270,11 +2312,11 @@ Ranked by user-visible impact vs. implementation effort:
2270
2312
  ### High-value features (biggest user-visible wins)
2271
2313
 
2272
2314
  1. **History export to CSV** (§2) — `export [days|month]` command; writes to `~/Downloads`, opens folder in OS explorer
2273
- 2. **Cat panel status badges** (§18) — lock, sleep, night light, wait time under the art; high visibility, one-afternoon job
2315
+ 2. ~~**Cat panel status badges** (§18)~~ `#cat-label` (revived mode label) + `#cat-status` badges (status chip, lock, night light, sleep, wait) under the art; refreshed via `_update_cat_panel` on every status refresh
2274
2316
  3. ~~**WebSocket subscription**~~ ✅ — real-time push updates live; 5-min poll fallback for activity history
2275
- 4. **Real-time cycling indicator with elapsed time** (§11) — extend the existing `⟳ Cycling` label to show elapsed time: `⟳ Cycling 0:42`; needs a per-second tick timer active only during a cycle
2276
- 5. **Token persistence** (§13) — skip password re-entry on every run
2277
- 6. **Fault & safety monitoring** (§9) — cat detected, pinch, motor faults; banner + log transition + cat alert mode
2317
+ 4. ~~**Real-time cycling indicator with elapsed time** (§11)~~ — `⟳ Cycling M:SS` chip in the status bar; `_cycle_start` + a lazy 1 s `_cycle_timer` (created on cycle start, stopped on any other status)
2318
+ 5. ~~**Token persistence** (§13)~~ OAuth session token cached as JSON in the keyring; `_connect_worker` tries the token first and only falls back to email/password on failure (wiping the stale token). `token_update_callback` captures refreshes during a session, so launches skip the password login entirely until the refresh token itself expires
2319
+ 6. ~~**Fault & safety monitoring** (§9)~~ `asher/faults.py` + `_refresh_faults` drive an in-panel `#fault-banner` (red/amber); enum fault props checked against healthy sentinels; transitions logged, steady state quiet; cat panel flips to `error`; `d` dismisses
2278
2320
  7. ~~**Readable history events** (§11)~~ ✅ — `history` now renders translated, colour-coded labels via `asher/activity_labels.py` (`format_activity()`); cat-detection events append pet name + weight; shared with the `export` CSV path
2279
2321
  8. **History pager sub-view** (§11) — scrollable in-log display with pagination; `history 100` vs current hardcoded 25-event dump
2280
2322
 
@@ -2285,9 +2327,9 @@ Ranked by user-visible impact vs. implementation effort:
2285
2327
  3. ~~**`/pet` slash command**~~ ✅ (§1, §14) — `/pet` lists, `/pet <idx|name>` switches; `_active_pet_idx` persists for session
2286
2328
  4. ~~**`/cat`, `/refresh`, `/config` slash commands**~~ ✅ (§1) — cat panel toggle + colour, poll interval control, runtime config dump
2287
2329
  5. **Tab-completion for slash commands** (§23) — Claude Code-style overlay dropdown on `/` keypress; single-source registry drives both dispatch and completion
2288
- 6. **`/version` slash command** (§24) — print Python/package versions to log; model badge in status bar is already done
2330
+ 6. ~~**`/version` slash command** (§24)~~ prints asher-cli / Python / pylitterbot / textual versions to the log via `importlib.metadata`, with a `"?"` fallback when not installed; model badge in the status bar was already done
2289
2331
  7. ~~**`wait-time`, `power`, `rename`, `insight` commands** (§3)~~ ✅ — all four wired up; plus the `status`/`info` split (`status` trimmed to at-a-glance, `info` is the full property dump). `panel-brightness` skipped (not exposed by pylitterbot 2025.6.2); `reset`/`reset-settings`/`firmware`-update deliberately omitted as destructive
2290
- 8. **Sleep schedule viewer** (§8) — read-only first, config wizard later
2332
+ 8. ~~**Sleep schedule viewer** (§8)~~ `sleep-schedule` (alias `sleepschedule`) renders the per-day sleep/wake window read-only, sorted Mon→Sun, with an active-window `● now` marker; config wizard/set/disable still TODO
2291
2333
  9. **Headless CLI export** (§25) — `asher --export 7` for cron/Task Scheduler automation, no TUI or Claude Desktop needed
2292
2334
 
2293
2335
  ### Release pipeline
@@ -26,6 +26,7 @@ class AsherApp(UIMixin, ConnectionMixin, MonitoringMixin, CommandsMixin, App):
26
26
  Binding("ctrl+c", "quit", "Quit", priority=True),
27
27
  Binding("ctrl+l", "clear_log", "Clear log"),
28
28
  Binding("escape", "blur_input", "Focus log", show=False),
29
+ Binding("d", "dismiss_fault", "Dismiss fault banner", show=False),
29
30
  ]
30
31
 
31
32
  def __init__(self) -> None:
@@ -51,6 +52,10 @@ class AsherApp(UIMixin, ConnectionMixin, MonitoringMixin, CommandsMixin, App):
51
52
  self._cat_panel_visible: bool = True
52
53
  self._cat_color: str | None = None
53
54
  self._active_pet_idx: int = 0
55
+ self._prev_faults: set[str] = set()
56
+ self._fault_dismissed: set[str] = set()
57
+ self._cycle_start: Any = None
58
+ self._cycle_timer: Timer | None = None
54
59
 
55
60
  def on_mount(self) -> None:
56
61
  self._refresh_title()
@@ -8,7 +8,9 @@ import csv
8
8
  import os
9
9
  import subprocess
10
10
  import sys
11
- from datetime import datetime, timedelta, timezone
11
+ from datetime import datetime, time, timedelta, timezone
12
+ from importlib.metadata import PackageNotFoundError
13
+ from importlib.metadata import version as pkg_version
12
14
  from pathlib import Path
13
15
  from typing import TYPE_CHECKING, Any, cast
14
16
 
@@ -542,6 +544,101 @@ class InsightCommand(Command):
542
544
  log.write(t)
543
545
 
544
546
 
547
+ _DAY_NAMES = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
548
+
549
+
550
+ def _fmt_sleep_time(t: object) -> str:
551
+ if t is None:
552
+ return "—"
553
+ if isinstance(t, time):
554
+ return t.strftime("%H:%M")
555
+ return str(t)
556
+
557
+
558
+ def _dow_to_weekday(day: int) -> int:
559
+ """Convert a pylitterbot DayOfWeek (Sun=0..Sat=6) to Python weekday (Mon=0..Sun=6)."""
560
+ return (day - 1) % 7
561
+
562
+
563
+ class SleepScheduleCommand(Command):
564
+ name = "sleep-schedule"
565
+ aliases = ("sleepschedule",)
566
+ description = "show the per-day sleep schedule (read-only)"
567
+ requires_robot = True
568
+
569
+ @property
570
+ def display_name(self) -> str:
571
+ return "sleep-schedule"
572
+
573
+ async def run(self, app: AsherApp, args: list[str]) -> None:
574
+ assert app._robot is not None
575
+ try:
576
+ schedule = getattr(app._robot, "sleep_schedule", None)
577
+ except Exception as exc:
578
+ app._log_err(f"Failed to read sleep schedule: {exc}")
579
+ return
580
+
581
+ if schedule is None:
582
+ app._log_warn(
583
+ "No sleep schedule set — the unit is always awake "
584
+ "(or toggle sleep/wake for an immediate nap)."
585
+ )
586
+ return
587
+
588
+ try:
589
+ days = sorted(
590
+ getattr(schedule, "days", []),
591
+ key=lambda d: _dow_to_weekday(int(getattr(d, "day", 0))),
592
+ )
593
+ is_enabled = bool(getattr(schedule, "is_enabled", False))
594
+ # Window covers [sleep_start, wake_end] as datetimes; None if disabled/expired.
595
+ window = None
596
+ with contextlib.suppress(Exception):
597
+ window = schedule.get_window()
598
+ except Exception as exc:
599
+ app._log_err(f"Failed to parse sleep schedule: {exc}")
600
+ return
601
+
602
+ if not is_enabled:
603
+ app._log_info("Sleep schedule is disabled. Configured windows:")
604
+
605
+ log = app.query_one("#log", RichLog)
606
+ active_day_indices = set()
607
+ if window is not None:
608
+ try:
609
+ start_dt, end_dt = window
610
+ # Sleep windows can wrap past midnight, so flag both the start
611
+ # and end day as "active now" for the user-facing marker.
612
+ active_day_indices.add(start_dt.weekday())
613
+ active_day_indices.add(end_dt.weekday())
614
+ except Exception:
615
+ pass
616
+
617
+ for day in days:
618
+ try:
619
+ idx = _dow_to_weekday(int(getattr(day, "day", 0)))
620
+ day_enabled = bool(getattr(day, "is_enabled", False))
621
+ sleep_t = getattr(day, "sleep_time", None)
622
+ wake_t = getattr(day, "wake_time", None)
623
+ except Exception:
624
+ continue
625
+
626
+ name = _DAY_NAMES[idx]
627
+ t = Text()
628
+ t.append(f" {name} ", style="#484f58")
629
+ if day_enabled:
630
+ window_str = f"{_fmt_sleep_time(sleep_t)} → {_fmt_sleep_time(wake_t)}"
631
+ t.append(window_str, style="#c9d1d9")
632
+ if idx in active_day_indices:
633
+ t.append(" ● now", style="bold #d29922")
634
+ else:
635
+ t.append("off", style="#484f58")
636
+ log.write(t)
637
+
638
+ if is_enabled and not active_day_indices:
639
+ app._log_info("Outside the active sleep window right now.")
640
+
641
+
545
642
  # ── LR5-only commands ─────────────────────────────────────────────────────────
546
643
  # These route through the adapter, which returns a "not supported" message on
547
644
  # LR3/LR4 rather than crashing — so the commands are safe to type on any model.
@@ -1054,6 +1151,23 @@ class McpCommand(SlashCommand):
1054
1151
  app._log_info(f"MCP server was already {verb}")
1055
1152
 
1056
1153
 
1154
+ class VersionCommand(SlashCommand):
1155
+ name = "version"
1156
+ description = "show version info (asher-cli, Python, pylitterbot, textual)"
1157
+
1158
+ async def run(self, app: AsherApp, args: list[str]) -> None: # noqa: ARG002
1159
+ def _v(pkg: str) -> str:
1160
+ try:
1161
+ return pkg_version(pkg)
1162
+ except PackageNotFoundError:
1163
+ return "?"
1164
+
1165
+ app._log_info(f"Asher CLI v{_v('asher-cli')}")
1166
+ app._log_info(f"Python {sys.version.split()[0]}")
1167
+ app._log_info(f"pylitterbot {_v('pylitterbot')}")
1168
+ app._log_info(f"textual {_v('textual')}")
1169
+
1170
+
1057
1171
  def _open_folder(path: Path) -> None:
1058
1172
  if sys.platform == "win32":
1059
1173
  subprocess.Popen(["explorer", "/select,", str(path)])
@@ -1173,6 +1287,7 @@ _registry.register(WaitTimeCommand())
1173
1287
  _registry.register(PowerCommand())
1174
1288
  _registry.register(RenameCommand())
1175
1289
  _registry.register(InsightCommand())
1290
+ _registry.register(SleepScheduleCommand())
1176
1291
  _registry.register(PrivacyCommand())
1177
1292
  _registry.register(VolumeCommand())
1178
1293
  _registry.register(CameraAudioCommand())
@@ -1191,6 +1306,7 @@ _registry.register(CatCommand())
1191
1306
  _registry.register(RefreshCommand())
1192
1307
  _registry.register(ConfigCommand())
1193
1308
  _registry.register(McpCommand())
1309
+ _registry.register(VersionCommand())
1194
1310
 
1195
1311
 
1196
1312
  # ── mixin ───────────────────────────────────────────────────────────────────