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.
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.gitignore +3 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/CLAUDE.md +11 -1
- {asher_cli-0.1.0 → asher_cli-0.1.1}/PKG-INFO +4 -1
- {asher_cli-0.1.0 → asher_cli-0.1.1}/README.md +3 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/ROADMAP.md +99 -57
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/app.py +5 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/commands/__init__.py +117 -1
- asher_cli-0.1.1/asher/connection/__init__.py +259 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/constants.py +3 -0
- asher_cli-0.1.1/asher/faults.py +146 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/monitoring/__init__.py +135 -3
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/ui/__init__.py +12 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/ui/style.tcss +23 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/pyproject.toml +2 -2
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/conftest.py +15 -2
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_app_pilot.py +3 -0
- asher_cli-0.1.1/tests/test_connection.py +194 -0
- asher_cli-0.1.1/tests/test_faults.py +186 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_missing_robot_commands.py +130 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_monitoring.py +120 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_ui.py +12 -0
- asher_cli-0.1.1/tests/test_version_command.py +87 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/uv.lock +1 -1
- asher_cli-0.1.0/asher/connection/__init__.py +0 -180
- asher_cli-0.1.0/tests/test_connection.py +0 -96
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.claude/hooks/block-env.ps1 +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.claude/settings.json +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.claude/skills/release/SKILL.md +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.claude/skills/textual/SKILL.md +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.env.example +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.githooks/pre-push +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.github/pull_request_template.md +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.github/workflows/ci.yml +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.github/workflows/claude-code-review.yml +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.github/workflows/claude.yml +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.github/workflows/coverage.yml +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.github/workflows/release.yml +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.vscode/launch.json +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.vscode/settings.json +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/.vscode/tasks.json +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/CODEOWNERS +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/LICENSE +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/app.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/__init__.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/__main__.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/activity_labels.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/auth.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/cats.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/commands/base.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/helpers.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/login_flow.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/mcp_bridge.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/mcp_config.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/robot_adapters.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/robot_protocol.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/asher/slash-commands/__init__.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/renovate.json +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/requirements.txt +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/test.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/__init__.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_activity_labels.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_auth.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_auth_pilot.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_cats.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_commands_pilot.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_connection_mixin.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_lr5_commands.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_mcp_bridge.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_mcp_command.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_mcp_config.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_new_commands_pilot.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/test_robot_adapters.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/tests/testhelpers.py +0 -0
- {asher_cli-0.1.0 → asher_cli-0.1.1}/watchrun.py +0 -0
|
@@ -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.
|
|
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
|
-
`
|
|
822
|
-
|
|
823
|
-
|
|
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
|
|
936
|
+
### Token persistence ✅ — avoid API re-auth on every run
|
|
893
937
|
|
|
894
|
-
`Account.
|
|
895
|
-
`token_update_callback
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
-
###
|
|
2083
|
+
### ~~`/version` slash command~~ ✅
|
|
2027
2084
|
|
|
2028
|
-
|
|
2029
|
-
|
|
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.
|
|
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.
|
|
2276
|
-
5.
|
|
2277
|
-
6.
|
|
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.
|
|
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.
|
|
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 ───────────────────────────────────────────────────────────────────
|