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.
asher/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Asher CLI — Litter Robot terminal dashboard."""
asher/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Entry point — `asher` CLI command and `python -m asher`."""
2
+
3
+ from .app import AsherApp
4
+
5
+
6
+ def main() -> None:
7
+ AsherApp().run()
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
asher/app.py ADDED
@@ -0,0 +1,64 @@
1
+ """Asher CLI — Litter Robot terminal dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from typing import Any
7
+
8
+ from textual import events
9
+ from textual.app import App
10
+ from textual.binding import Binding
11
+ from textual.widgets import Input
12
+
13
+ from .commands import CommandsMixin
14
+ from .connection import ConnectionMixin
15
+ from .monitoring import MonitoringMixin
16
+ from .ui import UIMixin
17
+
18
+
19
+ class AsherApp(UIMixin, ConnectionMixin, MonitoringMixin, CommandsMixin, App): # type: ignore[type-arg]
20
+ CSS = UIMixin.CSS # type: ignore[misc] # must live in AsherApp.__dict__ so Textual gives it full user-CSS priority
21
+ BINDINGS = [
22
+ Binding("ctrl+c", "quit", "Quit", priority=True),
23
+ Binding("ctrl+l", "clear_log", "Clear log"),
24
+ Binding("escape", "blur_input", "Focus log", show=False),
25
+ ]
26
+
27
+ def __init__(self) -> None:
28
+ super().__init__()
29
+ self._account: Any = None
30
+ self._robot: Any = None
31
+ self._pets: list = []
32
+ self._cat_mode: str = "idle"
33
+ self._cat_frame: int = 0
34
+ self._cmd_history: list[str] = []
35
+ self._hist_idx: int = -1
36
+ self._login_state: str = "" # "" | "awaiting_email" | "awaiting_password"
37
+ self._login_email: str = ""
38
+ self._last_cat_seen: Any = None
39
+ self._is_loading: bool = True
40
+ self._spinner_idx: int = 0
41
+
42
+ _INPUT_STYLES = "border: none; background: #161b22; outline: none;"
43
+
44
+ def on_mount(self) -> None:
45
+ self._refresh_title()
46
+ self._show_welcome()
47
+ self._show_loading_state()
48
+ self._connect_worker()
49
+ self.set_interval(30, self._poll_status_interval)
50
+ self.set_interval(0.9, self._tick_cat)
51
+ # this doesn't work for some reason :(
52
+ inp = self.query_one("#cmd-input", Input)
53
+ inp.set_styles(self._INPUT_STYLES)
54
+ inp.focus()
55
+
56
+ def on_focus(self, _event: events.Focus) -> None:
57
+ focused = self.focused
58
+ if focused is not None and getattr(focused, "id", None) == "cmd-input":
59
+ focused.set_styles(self._INPUT_STYLES) # type: ignore[attr-defined]
60
+
61
+ async def on_unmount(self) -> None:
62
+ if self._account:
63
+ with contextlib.suppress(Exception):
64
+ await self._account.disconnect()
asher/auth.py ADDED
@@ -0,0 +1,104 @@
1
+ """Login screen for first-time credential setup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Container
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Button, Input, Label, Static
9
+
10
+
11
+ class LoginScreen(ModalScreen[tuple[str, str]]):
12
+ """Prompts for Whisker account credentials and returns (email, password)."""
13
+
14
+ CSS = """
15
+ LoginScreen {
16
+ align: center middle;
17
+ }
18
+
19
+ #login-box {
20
+ background: #161b22;
21
+ border: solid #30363d;
22
+ padding: 2 4;
23
+ width: 52;
24
+ height: auto;
25
+ }
26
+
27
+ #login-title {
28
+ color: #58a6ff;
29
+ text-style: bold;
30
+ text-align: center;
31
+ padding-bottom: 0;
32
+ width: 100%;
33
+ }
34
+
35
+ #login-note {
36
+ color: #484f58;
37
+ text-align: center;
38
+ width: 100%;
39
+ padding-bottom: 1;
40
+ }
41
+
42
+ .field-label {
43
+ color: #8b949e;
44
+ padding: 1 0 0 0;
45
+ height: 1;
46
+ }
47
+
48
+ #login-box Input {
49
+ border: solid #30363d;
50
+ background: #0d1117;
51
+ padding: 0 1;
52
+ margin-top: 0;
53
+ }
54
+
55
+ #login-box Input:focus {
56
+ border: solid #58a6ff;
57
+ }
58
+
59
+ #login-btn {
60
+ margin-top: 1;
61
+ width: 100%;
62
+ }
63
+
64
+ #login-error {
65
+ color: #f85149;
66
+ text-align: center;
67
+ height: 1;
68
+ padding-top: 1;
69
+ }
70
+ """
71
+
72
+ def compose(self) -> ComposeResult:
73
+ with Container(id="login-box"):
74
+ yield Static("◆ Sign in to Whisker", id="login-title")
75
+ yield Static("Stored securely in your OS keyring.", id="login-note")
76
+ yield Label("Email", classes="field-label")
77
+ yield Input(placeholder="you@example.com", id="email-input")
78
+ yield Label("Password", classes="field-label")
79
+ yield Input(placeholder="••••••••", password=True, id="password-input")
80
+ yield Button("Connect", id="login-btn", variant="success")
81
+ yield Static("", id="login-error")
82
+
83
+ def on_mount(self) -> None:
84
+ self.query_one("#email-input", Input).focus()
85
+
86
+ def on_button_pressed(self, event: Button.Pressed) -> None:
87
+ event.stop()
88
+ if event.button.id == "login-btn":
89
+ self._submit()
90
+
91
+ def on_input_submitted(self, event: Input.Submitted) -> None:
92
+ event.stop()
93
+ if event.input.id == "email-input":
94
+ self.query_one("#password-input", Input).focus()
95
+ elif event.input.id == "password-input":
96
+ self._submit()
97
+
98
+ def _submit(self) -> None:
99
+ email = self.query_one("#email-input", Input).value.strip()
100
+ password = self.query_one("#password-input", Input).value
101
+ if not email or not password:
102
+ self.query_one("#login-error", Static).update("Email and password are required.")
103
+ return
104
+ self.dismiss((email, password))
asher/cats.py ADDED
@@ -0,0 +1,40 @@
1
+ """ASCII cat art definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ CATS: dict[str, list[str] | str] = {
6
+ "idle": r"""_._ _,-'""`-._
7
+ (,-.`._,'( |\`-/|
8
+ `-.-' \ )-`( , o o)
9
+ `- \`_`"'-""",
10
+ "happy": r"""_._ _,-'""`-._
11
+ (,-.`._,'( |\`-/|
12
+ `-.-' \ )-`( , ^ ^)
13
+ `- \`_`"'-""",
14
+ "sleeping": r"""_._ _,-'""`-._
15
+ (,-.`._,'( |\`-/|
16
+ `-.-' \ )-`( , - -) z
17
+ `- \`_`"'- zZ""",
18
+ "cleaning": [
19
+ r"""_._ _,-'""`-._
20
+ (,-.`._,'( |\`-/|
21
+ `-.-' \ )-`( , @ o)
22
+ `- \`_`"'-""",
23
+ r"""_._ _,-'""`-._
24
+ (,-.`._,'( |\`-/|
25
+ `-.-' \ )-`( , o @)
26
+ `- \`_`"'-""",
27
+ r"""_._ _,-'""`-._
28
+ (,-.`._,'( |\`-/|
29
+ `-.-' \ )-`( , * *)
30
+ `- \`_`"'-""",
31
+ ],
32
+ "error": r"""_._ _,-'""`-._
33
+ (,-.`._,'( |\`-/|
34
+ `-.-' \ )-`( , x x)
35
+ `- \`_`"'-""",
36
+ "full": r"""_._ _,-'""`-._
37
+ (,-.`._,'( |\`-/|
38
+ `-.-' \ )-`( , ! !)
39
+ `- \`_`"'-""",
40
+ }
@@ -0,0 +1,350 @@
1
+ """Command dispatch and individual command handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ from typing import Any
8
+
9
+ from rich.text import Text
10
+ from textual import work
11
+ from textual.widgets import Input, RichLog, Static
12
+
13
+ from ..helpers import ts
14
+
15
+ _HINT_DEFAULT = "help · clean · status · history · /login · /logout · quit"
16
+ _HINT_SIGNIN = "/login to sign in"
17
+
18
+
19
+ class CommandsMixin:
20
+ # declared for type checkers; assigned in AsherApp.__init__
21
+ _robot: Any
22
+ _account: Any
23
+ _cmd_history: list[str]
24
+ _hist_idx: int
25
+ _login_state: str
26
+ _login_email: str
27
+
28
+ # ── input events ─────────────────────────────────────────────────────────
29
+
30
+ def on_input_submitted(self, event: Input.Submitted) -> None:
31
+ raw = event.value.strip()
32
+ self.query_one("#cmd-input", Input).value = "" # type: ignore[attr-defined]
33
+ if not raw:
34
+ return
35
+
36
+ log = self.query_one("#log", RichLog) # type: ignore[attr-defined]
37
+
38
+ # Login flow intercepts before history/echo
39
+ if self._login_state == "awaiting_email":
40
+ t = ts()
41
+ t.append(f" {raw}", style="#e6edf3")
42
+ log.write(t)
43
+ self._handle_login_email(raw)
44
+ return
45
+
46
+ if self._login_state == "awaiting_password":
47
+ t = ts()
48
+ t.append(" ••••••••", style="#484f58")
49
+ log.write(t)
50
+ self._handle_login_password(raw)
51
+ return
52
+
53
+ # Normal command — add to history and echo
54
+ self._cmd_history.insert(0, raw)
55
+ self._hist_idx = -1
56
+
57
+ t = ts()
58
+ t.append("> ", style="bold #3fb950")
59
+ t.append(raw, style="#e6edf3")
60
+ log.write(t)
61
+
62
+ cmd = raw.split()[0].lower()
63
+
64
+ if cmd in ("quit", "exit", "q"):
65
+ self.exit() # type: ignore[attr-defined]
66
+ return
67
+ if cmd == "help":
68
+ self._show_help()
69
+ return
70
+ if cmd == "clear":
71
+ log.clear()
72
+ return
73
+
74
+ if raw.startswith("/"):
75
+ self._run_slash_cmd(raw)
76
+ else:
77
+ self._run_cmd(raw)
78
+
79
+ def on_key(self, event) -> None: # type: ignore[override]
80
+ cmd_input = self.query_one("#cmd-input", Input) # type: ignore[attr-defined]
81
+ if not cmd_input.has_focus:
82
+ return
83
+ if self._login_state:
84
+ return # disable history nav during login
85
+
86
+ if event.key == "up":
87
+ event.prevent_default()
88
+ if self._cmd_history and self._hist_idx < len(self._cmd_history) - 1:
89
+ self._hist_idx += 1
90
+ cmd_input.value = self._cmd_history[self._hist_idx]
91
+ cmd_input.cursor_position = len(cmd_input.value)
92
+ elif event.key == "down":
93
+ event.prevent_default()
94
+ if self._hist_idx > 0:
95
+ self._hist_idx -= 1
96
+ cmd_input.value = self._cmd_history[self._hist_idx]
97
+ cmd_input.cursor_position = len(cmd_input.value)
98
+ elif self._hist_idx == 0:
99
+ self._hist_idx = -1
100
+ cmd_input.value = ""
101
+
102
+ # ── inline login flow ─────────────────────────────────────────────────────
103
+
104
+ def _start_login_flow(self) -> None:
105
+ """Enter interactive login mode — prompts for email then password in the command bar."""
106
+ self._login_state = "awaiting_email"
107
+ self._login_email = ""
108
+ self._set_cat("idle", "sign in") # type: ignore[attr-defined]
109
+ self.query_one("#prompt", Static).update("email ›") # type: ignore[attr-defined]
110
+ self.query_one("#hint-bar", Static).update("enter your Whisker account email") # type: ignore[attr-defined]
111
+ self.query_one("#cmd-input", Input).placeholder = "your@email.com" # type: ignore[attr-defined]
112
+ self.query_one("#cmd-input", Input).password = False # type: ignore[attr-defined]
113
+ self.query_one("#cmd-input", Input).focus() # type: ignore[attr-defined]
114
+ self._log_info("Enter your Whisker account email:") # type: ignore[attr-defined]
115
+
116
+ def _handle_login_email(self, email: str) -> None:
117
+ self._login_email = email
118
+ self._login_state = "awaiting_password"
119
+ self.query_one("#prompt", Static).update("password ›") # type: ignore[attr-defined]
120
+ self.query_one("#hint-bar", Static).update("password will not be shown") # type: ignore[attr-defined]
121
+ self.query_one("#cmd-input", Input).placeholder = "password" # type: ignore[attr-defined]
122
+ self.query_one("#cmd-input", Input).password = True # type: ignore[attr-defined]
123
+ self._log_info("Enter your password:") # type: ignore[attr-defined]
124
+
125
+ @work
126
+ async def _handle_login_password(self, password: str) -> None:
127
+ from ..connection import _keyring_save # noqa: PLC0415
128
+
129
+ email = self._login_email
130
+ self._login_state = ""
131
+ self._login_email = ""
132
+
133
+ # Restore prompt and input to normal
134
+ self.query_one("#prompt", Static).update(">") # type: ignore[attr-defined]
135
+ self.query_one("#hint-bar", Static).update(_HINT_DEFAULT) # type: ignore[attr-defined]
136
+ self.query_one("#cmd-input", Input).password = False # type: ignore[attr-defined]
137
+ self.query_one("#cmd-input", Input).placeholder = "type a command (help for list)…" # type: ignore[attr-defined]
138
+
139
+ if _keyring_save(email, password):
140
+ self._log_info("Credentials saved to keyring.") # type: ignore[attr-defined]
141
+ else:
142
+ self._log_warn("Could not save to keyring — you'll be prompted again next launch.") # type: ignore[attr-defined]
143
+
144
+ if self._account:
145
+ with contextlib.suppress(Exception):
146
+ await self._account.disconnect()
147
+ self._account = None
148
+ self._robot = None
149
+ self._set_cat("idle", "connecting…") # type: ignore[attr-defined]
150
+ self._connect_worker() # type: ignore[attr-defined]
151
+
152
+ # ── slash-command dispatch (app management) ───────────────────────────────
153
+
154
+ @work
155
+ async def _run_slash_cmd(self, raw: str) -> None:
156
+ parts = raw.strip().split()
157
+ cmd = parts[0].lstrip("/").lower() if parts else ""
158
+
159
+ if cmd in ("exit", "quit", "q"):
160
+ self.exit() # type: ignore[attr-defined]
161
+ elif cmd == "login":
162
+ self._start_login_flow()
163
+ elif cmd == "logout":
164
+ await self._cmd_logout()
165
+ elif cmd == "help":
166
+ self._show_help()
167
+ else:
168
+ self._log_warn( # type: ignore[attr-defined]
169
+ f"Unknown slash command: '{raw}' — try /login, /logout, /exit"
170
+ )
171
+
172
+ # ── robot-command dispatch ────────────────────────────────────────────────
173
+
174
+ @work
175
+ async def _run_cmd(self, raw: str) -> None:
176
+ parts = raw.strip().split()
177
+ cmd = parts[0].lower() if parts else ""
178
+ args = parts[1:] if len(parts) > 1 else []
179
+
180
+ if self._robot is None:
181
+ self._log_err("Not connected — type '/login' to sign in.") # type: ignore[attr-defined]
182
+ return
183
+
184
+ if cmd == "clean":
185
+ await self._cmd_clean()
186
+ elif cmd == "status":
187
+ await self._cmd_status()
188
+ elif cmd == "lock":
189
+ await self._cmd_lock(True)
190
+ elif cmd == "unlock":
191
+ await self._cmd_lock(False)
192
+ elif cmd == "sleep":
193
+ await self._cmd_sleep(True)
194
+ elif cmd == "wake":
195
+ await self._cmd_sleep(False)
196
+ elif cmd in ("night-light", "nightlight", "nl"):
197
+ await self._cmd_nightlight(args)
198
+ elif cmd in ("history", "hist"):
199
+ await self._cmd_history_list()
200
+ else:
201
+ self._log_warn(f"Unknown command: '{cmd}' — type 'help' for list") # type: ignore[attr-defined]
202
+
203
+ # ── slash-command handlers ────────────────────────────────────────────────
204
+
205
+ async def _cmd_logout(self) -> None:
206
+ from ..connection import _keyring_delete # noqa: PLC0415
207
+
208
+ if self._account:
209
+ with contextlib.suppress(Exception):
210
+ await self._account.disconnect()
211
+ self._account = None
212
+ self._robot = None
213
+ _keyring_delete()
214
+ self._log_ok("Signed out.") # type: ignore[attr-defined]
215
+ self._log_info("Type /login to sign in.") # type: ignore[attr-defined]
216
+ self._set_cat("idle", "not signed in") # type: ignore[attr-defined]
217
+ self.query_one("#hint-bar", Static).update(_HINT_SIGNIN) # type: ignore[attr-defined]
218
+
219
+ # ── robot-command handlers ────────────────────────────────────────────────
220
+
221
+ async def _cmd_clean(self) -> None:
222
+ self._set_cat("cleaning", "cleaning…") # type: ignore[attr-defined]
223
+ try:
224
+ await self._robot.start_cleaning()
225
+ self._log_ok("Clean cycle started") # type: ignore[attr-defined]
226
+ await asyncio.sleep(3)
227
+ await self._robot.refresh()
228
+ await self._refresh_status() # type: ignore[attr-defined]
229
+ self._set_cat("happy", "all done!") # type: ignore[attr-defined]
230
+ except Exception as exc:
231
+ self._log_err(f"Failed to start cleaning: {exc}") # type: ignore[attr-defined]
232
+ self._set_cat("error", "error") # type: ignore[attr-defined]
233
+
234
+ async def _cmd_status(self) -> None:
235
+ try:
236
+ await self._robot.refresh()
237
+ await self._refresh_status() # type: ignore[attr-defined]
238
+ r = self._robot
239
+ rows = [
240
+ ("Name", getattr(r, "name", "—")),
241
+ ("Status", str(getattr(r, "status", "—"))),
242
+ ("Drawer", f"{getattr(r, 'waste_drawer_level', 0):.0f}%"),
243
+ ("Sleeping", "yes" if getattr(r, "sleeping", False) else "no"),
244
+ ("Locked", "yes" if getattr(r, "panel_lockout", False) else "no"),
245
+ ("Night light", "on" if getattr(r, "night_light_mode_enabled", False) else "off"),
246
+ ("Online", "yes" if getattr(r, "is_online", False) else "no"),
247
+ ("Serial", getattr(r, "serial", "—")),
248
+ ]
249
+ log = self.query_one("#log", RichLog) # type: ignore[attr-defined]
250
+ for k, v in rows:
251
+ t = Text()
252
+ t.append(f" {k:<14}", style="#484f58")
253
+ t.append(str(v), style="#c9d1d9")
254
+ log.write(t)
255
+ except Exception as exc:
256
+ self._log_err(f"Status refresh failed: {exc}") # type: ignore[attr-defined]
257
+
258
+ async def _cmd_lock(self, lock: bool) -> None:
259
+ action = "locked" if lock else "unlocked"
260
+ try:
261
+ await self._robot.set_panel_lockout(lock)
262
+ self._log_ok(f"Panel {action}") # type: ignore[attr-defined]
263
+ except Exception as exc:
264
+ self._log_err(f"Failed: {exc}") # type: ignore[attr-defined]
265
+
266
+ async def _cmd_sleep(self, sleep: bool) -> None:
267
+ try:
268
+ await self._robot.set_sleep_mode(sleep)
269
+ if sleep:
270
+ self._log_ok("Sleep mode enabled") # type: ignore[attr-defined]
271
+ self._set_cat("sleeping", "sleeping…") # type: ignore[attr-defined]
272
+ else:
273
+ self._log_ok("Robot woken up") # type: ignore[attr-defined]
274
+ self._set_cat("happy", "awake!") # type: ignore[attr-defined]
275
+ except Exception as exc:
276
+ self._log_err(f"Failed: {exc}") # type: ignore[attr-defined]
277
+
278
+ async def _cmd_nightlight(self, args: list[str]) -> None:
279
+ arg = args[0].lower() if args else ""
280
+ if arg not in ("on", "off"):
281
+ self._log_warn("Usage: night-light on|off") # type: ignore[attr-defined]
282
+ return
283
+ try:
284
+ if hasattr(self._robot, "set_night_light_brightness"):
285
+ await self._robot.set_night_light_brightness(100 if arg == "on" else 0)
286
+ elif hasattr(self._robot, "set_night_light_mode"):
287
+ from pylitterbot.enums import NightLightMode # noqa: PLC0415
288
+
289
+ mode = NightLightMode.ON if arg == "on" else NightLightMode.OFF
290
+ await self._robot.set_night_light_mode(mode)
291
+ else:
292
+ self._log_warn("Night light control not supported by this robot version.") # type: ignore[attr-defined]
293
+ return
294
+ self._log_ok(f"Night light {arg}") # type: ignore[attr-defined]
295
+ except Exception as exc:
296
+ self._log_err(f"Failed: {exc}") # type: ignore[attr-defined]
297
+
298
+ async def _cmd_history_list(self) -> None:
299
+ try:
300
+ acts = await self._robot.get_activity_history(limit=25)
301
+ log = self.query_one("#log", RichLog) # type: ignore[attr-defined]
302
+ if not acts:
303
+ self._log_info("No activity history available.") # type: ignore[attr-defined]
304
+ return
305
+ self._log_info(f"Last {len(acts)} events:") # type: ignore[attr-defined]
306
+ for act in reversed(acts):
307
+ ts_dt = getattr(act, "timestamp", None)
308
+ ts_str = ts_dt.strftime("%m/%d %H:%M") if ts_dt else "?"
309
+ action = getattr(act, "action", "?")
310
+ action_str = action.text if hasattr(action, "text") else str(action)
311
+ t = Text()
312
+ t.append(f" {ts_str} ", style="#484f58")
313
+ t.append(action_str, style="#8b949e")
314
+ log.write(t)
315
+ except Exception as exc:
316
+ self._log_err(f"Failed to get history: {exc}") # type: ignore[attr-defined]
317
+
318
+ def _show_help(self) -> None:
319
+ log = self.query_one("#log", RichLog) # type: ignore[attr-defined]
320
+ log.write("")
321
+ log.write(Text.from_markup("[bold #58a6ff]Robot commands[/]"))
322
+ robot_cmds = [
323
+ ("clean", "start a clean cycle"),
324
+ ("status", "refresh and display full status"),
325
+ ("lock / unlock", "toggle panel lockout"),
326
+ ("sleep / wake", "toggle sleep mode"),
327
+ ("night-light on|off", "toggle night light"),
328
+ ("history", "show recent activity log"),
329
+ ("clear", "clear the log"),
330
+ ("help", "show this message"),
331
+ ("quit / exit", "exit Asher CLI"),
332
+ ]
333
+ for name, desc in robot_cmds:
334
+ t = Text()
335
+ t.append(f" {name:<22}", style="#3fb950")
336
+ t.append(desc, style="#8b949e")
337
+ log.write(t)
338
+ log.write("")
339
+ log.write(Text.from_markup("[bold #58a6ff]Slash commands[/] [#484f58](app management)[/]"))
340
+ slash_cmds = [
341
+ ("/login", "sign in or switch accounts"),
342
+ ("/logout", "sign out and re-enter credentials"),
343
+ ("/exit", "exit Asher CLI"),
344
+ ]
345
+ for name, desc in slash_cmds:
346
+ t = Text()
347
+ t.append(f" {name:<22}", style="#d29922")
348
+ t.append(desc, style="#8b949e")
349
+ log.write(t)
350
+ log.write("")