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 +1 -0
- asher/__main__.py +11 -0
- asher/app.py +64 -0
- asher/auth.py +104 -0
- asher/cats.py +40 -0
- asher/commands/__init__.py +350 -0
- asher/connection/__init__.py +120 -0
- asher/helpers.py +48 -0
- asher/monitoring/__init__.py +111 -0
- asher/slash-commands/__init__.py +26 -0
- asher/ui/__init__.py +279 -0
- asher_cli-0.0.1.dist-info/METADATA +156 -0
- asher_cli-0.0.1.dist-info/RECORD +16 -0
- asher_cli-0.0.1.dist-info/WHEEL +4 -0
- asher_cli-0.0.1.dist-info/entry_points.txt +2 -0
- asher_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
asher/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Asher CLI — Litter Robot terminal dashboard."""
|
asher/__main__.py
ADDED
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("")
|