asher-cli 0.0.1__py3-none-any.whl

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.
@@ -0,0 +1,120 @@
1
+ """Whisker cloud API connection and authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ from typing import Any
8
+
9
+ import keyring
10
+ import keyring.errors
11
+ from dotenv import load_dotenv
12
+ from textual import work
13
+ from textual.widgets import RichLog
14
+
15
+ from ..helpers import ts
16
+
17
+ load_dotenv()
18
+
19
+ _SERVICE = "asher-cli"
20
+
21
+
22
+ def _keyring_load() -> tuple[str, str]:
23
+ try:
24
+ email = keyring.get_password(_SERVICE, "email") or ""
25
+ password = keyring.get_password(_SERVICE, "password") or ""
26
+ return email, password
27
+ except Exception:
28
+ return "", ""
29
+
30
+
31
+ def _keyring_save(email: str, password: str) -> bool:
32
+ try:
33
+ keyring.set_password(_SERVICE, "email", email)
34
+ keyring.set_password(_SERVICE, "password", password)
35
+ return True
36
+ except Exception:
37
+ return False
38
+
39
+
40
+ def _keyring_delete() -> None:
41
+ for key in ("email", "password"):
42
+ with contextlib.suppress(Exception):
43
+ keyring.delete_password(_SERVICE, key)
44
+
45
+
46
+ class ConnectionMixin:
47
+ # declared for type checkers; assigned in AsherApp.__init__
48
+ _account: Any
49
+ _robot: Any
50
+ _pets: list
51
+
52
+ @work(exclusive=True)
53
+ async def _connect_worker(self) -> None:
54
+ log = self.query_one("#log", RichLog) # type: ignore[attr-defined]
55
+
56
+ email, password = _keyring_load()
57
+
58
+ if not email or not password:
59
+ env_email = os.getenv("LITTER_ROBOT_USER") or os.getenv("LR4_EMAIL") or ""
60
+ env_pass = os.getenv("LITTER_ROBOT_PASSWORD") or os.getenv("LR4_PASSWORD") or ""
61
+ email = email or env_email
62
+ password = password or env_pass
63
+
64
+ if not email or not password:
65
+ from textual.widgets import Static # noqa: PLC0415
66
+
67
+ self._is_loading = False # type: ignore[attr-defined]
68
+ self._log_info("No saved credentials found.") # type: ignore[attr-defined]
69
+ self._log_info("Type /login to sign in.") # type: ignore[attr-defined]
70
+ self._set_cat("idle", "not signed in") # type: ignore[attr-defined]
71
+ self.query_one("#hint-bar", Static).update("/login to sign in") # type: ignore[attr-defined]
72
+ return
73
+
74
+ try:
75
+ from pylitterbot import Account # noqa: PLC0415
76
+
77
+ self._account = Account()
78
+ await self._account.connect(
79
+ username=email, password=password, load_robots=True, load_pets=True
80
+ )
81
+ self._pets = list(self._account.pets)
82
+ robots = list(self._account.robots)
83
+
84
+ if not robots:
85
+ self._log_err("No Litter Robots found on this account.") # type: ignore[attr-defined]
86
+ self._set_cat("error", "no robots") # type: ignore[attr-defined]
87
+ return
88
+
89
+ self._robot = robots[0]
90
+ if len(robots) > 1:
91
+ self._log_info( # type: ignore[attr-defined]
92
+ f"{len(robots)} robots found — using '{getattr(robots[0], 'name', 'robot #1')}'"
93
+ )
94
+ for i, rb in enumerate(robots):
95
+ model = type(rb).__name__
96
+ self._log_info( # type: ignore[attr-defined]
97
+ f" [{i}] {getattr(rb, 'name', '?')} ({model} serial={getattr(rb, 'serial', '?')})"
98
+ )
99
+
100
+ await self._update_last_cat_seen() # type: ignore[attr-defined]
101
+ await self._refresh_status() # type: ignore[attr-defined]
102
+
103
+ t = ts()
104
+ t.append("✓ Connected to ", style="#3fb950")
105
+ name = getattr(self._robot, "name", "robot")
106
+ model = type(self._robot).__name__
107
+ t.append(name, style="bold #e6edf3")
108
+ t.append(f" ({model})", style="#484f58")
109
+ log.write(t)
110
+ self._set_cat("happy", "connected!") # type: ignore[attr-defined]
111
+
112
+ except ImportError:
113
+ self._is_loading = False # type: ignore[attr-defined]
114
+ self._log_err("pylitterbot not installed. Run: pip install pylitterbot") # type: ignore[attr-defined]
115
+ self._set_cat("error", "missing dep") # type: ignore[attr-defined]
116
+ except Exception as exc:
117
+ self._is_loading = False # type: ignore[attr-defined]
118
+ self._log_err(f"Connection failed: {exc}") # type: ignore[attr-defined]
119
+ self._log_warn("Type '/logout' to clear saved credentials and try again.") # type: ignore[attr-defined]
120
+ self._set_cat("error", "auth error") # type: ignore[attr-defined]
asher/helpers.py ADDED
@@ -0,0 +1,48 @@
1
+ """Pure helper functions — no Textual or pylitterbot imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ from rich.text import Text
8
+
9
+ STATUS_COLORS: dict[str, str] = {
10
+ "Ready": "#3fb950", # green
11
+ "Cycling": "#58a6ff", # blue
12
+ "Cat Detected": "#d29922", # amber
13
+ "Drawer Full": "#f85149", # red
14
+ "Offline": "#f85149", # red
15
+ "Sleeping": "#484f58", # muted
16
+ }
17
+
18
+
19
+ def fmt_ago(dt: datetime | None) -> str:
20
+ if dt is None:
21
+ return "never"
22
+ if dt.tzinfo is None:
23
+ dt = dt.replace(tzinfo=timezone.utc)
24
+ s = int((datetime.now(timezone.utc) - dt).total_seconds())
25
+ if s < 60:
26
+ return f"{s}s ago"
27
+ if s < 3600:
28
+ return f"{s // 60}m ago"
29
+ if s < 86400:
30
+ return f"{s // 3600}h ago"
31
+ return f"{s // 86400}d ago"
32
+
33
+
34
+ def drawer_bar(pct: float, width: int = 14) -> Text:
35
+ filled = max(0, min(width, int(width * pct / 100)))
36
+ bar = "█" * filled + "░" * (width - filled)
37
+ color = "#f85149" if pct >= 85 else "#d29922" if pct >= 60 else "#3fb950"
38
+ t = Text()
39
+ t.append("[", style="#484f58")
40
+ t.append(bar, style=color)
41
+ t.append("]", style="#484f58")
42
+ return t
43
+
44
+
45
+ def ts() -> Text:
46
+ t = Text()
47
+ t.append(f"[{datetime.now().strftime('%H:%M:%S')}] ", style="#484f58")
48
+ return t
@@ -0,0 +1,111 @@
1
+ """Robot status polling and refresh."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.text import Text
8
+ from textual import work
9
+ from textual.widgets import Static
10
+
11
+ from ..helpers import drawer_bar, fmt_ago
12
+
13
+
14
+ class MonitoringMixin:
15
+ # declared for type checkers; assigned in AsherApp.__init__
16
+ _robot: Any
17
+ _pets: list
18
+ _cat_mode: str
19
+ _last_cat_seen: Any
20
+
21
+ async def _update_last_cat_seen(self) -> None:
22
+ """Cache the timestamp of the most recent cat-detection event from activity history."""
23
+ if self._robot is None:
24
+ return
25
+ try:
26
+ acts = await self._robot.get_activity_history(limit=10)
27
+ for act in acts:
28
+ w = getattr(act, "weight", None)
29
+ action: Any = getattr(act, "action", None)
30
+ action_text = (
31
+ action.text if hasattr(action, "text") else str(action or "")
32
+ ).lower()
33
+ if (w is not None and float(w) > 0) or "cat" in action_text:
34
+ ts_dt = getattr(act, "timestamp", None)
35
+ if ts_dt is not None:
36
+ self._last_cat_seen = ts_dt
37
+ return
38
+ except Exception:
39
+ pass
40
+
41
+ async def _refresh_status(self) -> None:
42
+ r = self._robot
43
+ if r is None:
44
+ return
45
+ self._is_loading = False
46
+
47
+ name = getattr(r, "name", "—")
48
+ online = getattr(r, "is_online", False)
49
+ drawer = float(getattr(r, "waste_drawer_level", 0) or 0)
50
+ status = getattr(r, "status", None)
51
+ sleeping = getattr(r, "sleeping", False)
52
+ last_seen = self._last_cat_seen or getattr(r, "last_seen", None)
53
+
54
+ status_str = status.value if status else ("Sleeping" if sleeping else "Ready")
55
+
56
+ weight_val = "—"
57
+ try:
58
+ w = getattr(r, "pet_weight", None)
59
+ if w is not None and float(w) > 0:
60
+ weight_val = f"{float(w):.1f} lb"
61
+ except Exception:
62
+ pass
63
+
64
+ pet_name = self._pets[0].name if self._pets else None
65
+
66
+ self.query_one("#robot-lbl", Static).update(Text(name, style="bold #e6edf3")) # type: ignore[attr-defined]
67
+
68
+ online_lbl = self.query_one("#online-lbl", Static) # type: ignore[attr-defined]
69
+ if online:
70
+ online_lbl.update(Text("● ONLINE", style="bold #3fb950"))
71
+ else:
72
+ online_lbl.update(Text("○ OFFLINE", style="bold #f85149"))
73
+
74
+ self.query_one("#status-lbl", Static).update( # type: ignore[attr-defined]
75
+ Text(f"[{status_str}]", style="#8b949e")
76
+ )
77
+
78
+ bar = drawer_bar(drawer)
79
+ dt = Text()
80
+ dt.append("Drawer ", style="#484f58")
81
+ dt.append_text(bar)
82
+ dt.append(f" {drawer:.0f}%", style="#8b949e")
83
+ self.query_one("#drawer-lbl", Static).update(dt) # type: ignore[attr-defined]
84
+
85
+ visit_label = "Last visit" if self._last_cat_seen else "Last seen"
86
+ self.query_one("#clean-lbl", Static).update( # type: ignore[attr-defined]
87
+ Text(f"{visit_label} {fmt_ago(last_seen)}", style="#484f58")
88
+ )
89
+
90
+ wt_text = Text()
91
+ if pet_name:
92
+ wt_text.append(pet_name, style="#8b949e")
93
+ wt_text.append(" 🐱 ", style="#484f58")
94
+ else:
95
+ wt_text.append("cat ", style="#484f58")
96
+ wt_text.append(weight_val, style="#8b949e")
97
+ self.query_one("#weight-lbl", Static).update(wt_text) # type: ignore[attr-defined]
98
+
99
+ if drawer >= 85 and self._cat_mode not in ("cleaning", "error"):
100
+ self._set_cat("full", "drawer full!") # type: ignore[attr-defined]
101
+
102
+ @work(exclusive=True)
103
+ async def _poll_status_interval(self) -> None:
104
+ if self._robot is None:
105
+ return
106
+ try:
107
+ await self._robot.refresh()
108
+ await self._update_last_cat_seen()
109
+ await self._refresh_status()
110
+ except Exception:
111
+ pass
@@ -0,0 +1,26 @@
1
+ """Slash-command registry — app management commands (prefixed with /).
2
+
3
+ Convention
4
+ ----------
5
+ - Normal commands (no prefix): robot actions — clean, status, lock, etc.
6
+ - Slash commands (/prefix): app management — /login, /logout, /exit, /reload, …
7
+ - Special cases (no prefix OK): exit, quit — accepted both ways.
8
+
9
+ Dispatch flow
10
+ -------------
11
+ on_input_submitted → raw.startswith("/") → _run_slash_cmd() (CommandsMixin)
12
+ → otherwise → _run_cmd() (CommandsMixin)
13
+
14
+ Current slash commands (implemented in asher/commands/__init__.py)
15
+ ------------------------------------------------------------------
16
+ /login — show login screen; save credentials to keyring; reconnect
17
+ /logout — delete credentials from keyring; exit
18
+ /exit — exit the app
19
+ /help — show help (also available without slash)
20
+
21
+ To add a new slash command
22
+ --------------------------
23
+ 1. Add a branch in _run_slash_cmd() in asher/commands/__init__.py.
24
+ 2. Implement _cmd_<name>() on CommandsMixin.
25
+ 3. Add an entry to the slash_cmds list in _show_help().
26
+ """
asher/ui/__init__.py ADDED
@@ -0,0 +1,279 @@
1
+ """UI layout, CSS, cat panel, and log helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError
6
+ from importlib.metadata import version as pkg_version
7
+
8
+ from rich.text import Text
9
+ from textual.app import ComposeResult
10
+ from textual.containers import Container
11
+ from textual.widgets import Input, RichLog, Static
12
+
13
+ from ..cats import CATS
14
+ from ..helpers import ts
15
+
16
+ try:
17
+ VERSION = pkg_version("asher-cli")
18
+ except PackageNotFoundError:
19
+ VERSION = "dev"
20
+
21
+ _CSS = """
22
+ Screen {
23
+ background: #0d1117;
24
+ color: #c9d1d9;
25
+ }
26
+
27
+ /* ── Status bar ── */
28
+ #status-bar {
29
+ background: #161b22;
30
+ height: 4;
31
+ border-bottom: solid #30363d;
32
+ dock: top;
33
+ padding: 0;
34
+ layout: vertical;
35
+ }
36
+
37
+ .srow {
38
+ height: 2;
39
+ layout: horizontal;
40
+ align: left middle;
41
+ padding: 0 2;
42
+ }
43
+
44
+ .srow:first-child {
45
+ border-bottom: solid #30363d;
46
+ }
47
+
48
+ .sep {
49
+ color: #30363d;
50
+ width: auto;
51
+ height: 1;
52
+ padding: 0 1;
53
+ }
54
+
55
+ .chunk {
56
+ width: auto;
57
+ padding: 0 2 0 0;
58
+ height: 1;
59
+ }
60
+
61
+ /* ── Main ── */
62
+ #main-area {
63
+ layout: horizontal;
64
+ height: 1fr;
65
+ }
66
+
67
+ #log {
68
+ width: 1fr;
69
+ height: 1fr;
70
+ background: #0d1117;
71
+ padding: 1 2;
72
+ overflow-x: hidden;
73
+ scrollbar-background: #161b22;
74
+ scrollbar-color: #30363d;
75
+ scrollbar-color-hover: #58a6ff;
76
+ }
77
+
78
+ /* ── Cat panel ── */
79
+ #cat-panel {
80
+ width: 30;
81
+ background: #0d1117;
82
+ border-left: solid #21262d;
83
+ align: center middle;
84
+ padding: 1 2;
85
+ height: 1fr;
86
+ layout: vertical;
87
+ }
88
+
89
+ #cat-art {
90
+ color: #58a6ff;
91
+ text-align: left;
92
+ width: 26;
93
+ content-align: left middle;
94
+ }
95
+
96
+ #cat-label {
97
+ color: #484f58;
98
+ text-style: italic;
99
+ text-align: left;
100
+ width: 26;
101
+ padding-top: 1;
102
+ height: 3;
103
+ }
104
+
105
+ /* ── Input area (outer dock + hint below the box) ── */
106
+ #bottom-dock {
107
+ dock: bottom;
108
+ height: 4;
109
+ background: #161b22;
110
+ layout: vertical;
111
+ padding: 0 0 0 0;
112
+ }
113
+
114
+ #input-bar {
115
+ background: #161b22;
116
+ height: 3;
117
+ border-top: solid #30363d;
118
+ border-bottom: solid #30363d;
119
+ layout: vertical;
120
+ padding: 0 2;
121
+ }
122
+
123
+ #input-row {
124
+ layout: horizontal;
125
+ height: 1;
126
+ align: left middle;
127
+ }
128
+
129
+ #prompt {
130
+ color: #3fb950;
131
+ width: auto;
132
+ text-style: bold;
133
+ padding: 0 1 0 0;
134
+ height: 1;
135
+ }
136
+
137
+ #hint-bar {
138
+ color: #484f58;
139
+ height: 1;
140
+ padding: 0 2;
141
+ }
142
+
143
+ #cmd-input {
144
+ width: 1fr;
145
+ background: #161b22;
146
+ color: #e6edf3;
147
+ border: none;
148
+ height: 1;
149
+ padding: 0;
150
+ }
151
+
152
+ Input {
153
+ border: none;
154
+ background: #161b22;
155
+ padding: 0;
156
+ }
157
+ """
158
+
159
+
160
+ _SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
161
+
162
+
163
+ class UIMixin:
164
+ CSS: str = _CSS
165
+
166
+ # declared for type checkers; assigned in AsherApp.__init__
167
+ _cat_mode: str
168
+ _cat_frame: int
169
+ _is_loading: bool
170
+ _spinner_idx: int
171
+
172
+ def compose(self) -> ComposeResult:
173
+ with Container(id="status-bar"):
174
+ with Container(classes="srow"):
175
+ yield Static("", id="title-lbl", classes="chunk")
176
+ yield Static("", id="robot-lbl", classes="chunk")
177
+ yield Static("", id="online-lbl", classes="chunk")
178
+ yield Static("", id="status-lbl", classes="chunk")
179
+ with Container(classes="srow"):
180
+ yield Static("", id="drawer-lbl", classes="chunk")
181
+ yield Static("│", classes="sep")
182
+ yield Static("", id="weight-lbl", classes="chunk")
183
+ yield Static("│", classes="sep")
184
+ yield Static("", id="clean-lbl", classes="chunk")
185
+
186
+ with Container(id="main-area"):
187
+ yield RichLog(id="log", highlight=True, markup=True, wrap=True, min_width=0)
188
+ with Container(id="cat-panel"):
189
+ yield Static(CATS["idle"], id="cat-art") # type: ignore[arg-type]
190
+ yield Static("idle", id="cat-label")
191
+
192
+ with Container(id="bottom-dock"):
193
+ with Container(id="input-bar"), Container(id="input-row"):
194
+ yield Static(">", id="prompt")
195
+ yield Input(placeholder="type a command (help for list)…", id="cmd-input")
196
+ yield Static(
197
+ "help · clean · status · history · /login · /logout · quit",
198
+ id="hint-bar",
199
+ )
200
+
201
+ def _refresh_title(self) -> None:
202
+ t = Text()
203
+ t.append("◆ ", style="bold #58a6ff")
204
+ t.append("Asher CLI", style="bold #e6edf3")
205
+ t.append(f" v{VERSION}", style="#484f58")
206
+ self.query_one("#title-lbl", Static).update(t) # type: ignore[attr-defined]
207
+
208
+ def _show_loading_state(self) -> None:
209
+ self.query_one("#online-lbl", Static).update( # type: ignore[attr-defined]
210
+ Text(f"{_SPINNER[0]} connecting…", style="#484f58")
211
+ )
212
+ dash = Text("—", style="#30363d")
213
+ for wid in ("#robot-lbl", "#status-lbl", "#drawer-lbl", "#weight-lbl", "#clean-lbl"):
214
+ self.query_one(wid, Static).update(dash) # type: ignore[attr-defined]
215
+
216
+ def _show_welcome(self) -> None:
217
+ log = self.query_one("#log", RichLog) # type: ignore[attr-defined]
218
+ log.write("")
219
+ log.write(
220
+ Text.from_markup(" [bold #58a6ff]◆ Asher CLI[/] [#484f58]— Litter Robot Dashboard[/]")
221
+ )
222
+ log.write(Text.from_markup(" [#484f58]Connecting to Whisker cloud API…[/]"))
223
+ log.write(
224
+ Text.from_markup(
225
+ " [#484f58]Type [/][#3fb950]help[/][#484f58] to see available commands.[/]"
226
+ )
227
+ )
228
+ log.write(Text.from_markup(" [#21262d]" + "─" * 52 + "[/]"))
229
+ log.write("")
230
+
231
+ def _set_cat(self, mode: str, label: str = "") -> None:
232
+ self._cat_mode = mode
233
+ self._cat_frame = 0
234
+ cats = CATS.get(mode, CATS["idle"])
235
+ frame = cats[0] if isinstance(cats, list) else cats
236
+ color = "#f85149" if mode == "error" else "#d29922" if mode == "full" else "#58a6ff"
237
+ self.query_one("#cat-art", Static).update(Text(frame, style=color)) # type: ignore[attr-defined]
238
+ self.query_one("#cat-label", Static).update( # type: ignore[attr-defined]
239
+ Text(label or mode, style="italic #484f58")
240
+ )
241
+
242
+ def _tick_cat(self) -> None:
243
+ if self._is_loading:
244
+ self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER)
245
+ self.query_one("#online-lbl", Static).update( # type: ignore[attr-defined]
246
+ Text(f"{_SPINNER[self._spinner_idx]} connecting…", style="#484f58")
247
+ )
248
+ cats = CATS.get(self._cat_mode, CATS["idle"])
249
+ if not isinstance(cats, list):
250
+ return
251
+ self._cat_frame = (self._cat_frame + 1) % len(cats)
252
+ frame = cats[self._cat_frame]
253
+ self.query_one("#cat-art", Static).update(Text(frame, style="#58a6ff")) # type: ignore[attr-defined]
254
+
255
+ def _log_ok(self, msg: str) -> None:
256
+ t = ts()
257
+ t.append(f"✓ {msg}", style="#3fb950")
258
+ self.query_one("#log", RichLog).write(t) # type: ignore[attr-defined]
259
+
260
+ def _log_err(self, msg: str) -> None:
261
+ t = ts()
262
+ t.append(f"✖ {msg}", style="#f85149")
263
+ self.query_one("#log", RichLog).write(t) # type: ignore[attr-defined]
264
+
265
+ def _log_warn(self, msg: str) -> None:
266
+ t = ts()
267
+ t.append(f"⚠ {msg}", style="#d29922")
268
+ self.query_one("#log", RichLog).write(t) # type: ignore[attr-defined]
269
+
270
+ def _log_info(self, msg: str) -> None:
271
+ t = ts()
272
+ t.append(f" {msg}", style="#8b949e")
273
+ self.query_one("#log", RichLog).write(t) # type: ignore[attr-defined]
274
+
275
+ def action_clear_log(self) -> None:
276
+ self.query_one("#log", RichLog).clear() # type: ignore[attr-defined]
277
+
278
+ def action_blur_input(self) -> None:
279
+ self.query_one("#cmd-input", Input).blur() # type: ignore[attr-defined]