matebot 0.2.0__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.
- matebot/__init__.py +3 -0
- matebot/bags.py +77 -0
- matebot/cli.py +293 -0
- matebot/commands.py +272 -0
- matebot/config.py +91 -0
- matebot/conversation.py +263 -0
- matebot/digest.py +80 -0
- matebot/hints.py +53 -0
- matebot/machine.py +195 -0
- matebot/messengers/__init__.py +28 -0
- matebot/messengers/base.py +58 -0
- matebot/messengers/discord.py +88 -0
- matebot/messengers/telegram.py +118 -0
- matebot/plot.py +100 -0
- matebot/sitegen.py +119 -0
- matebot/slog.py +225 -0
- matebot/state.py +42 -0
- matebot/sync.py +163 -0
- matebot/watcher.py +137 -0
- matebot/web/app.js +395 -0
- matebot/web/index.html +43 -0
- matebot/web/style.css +166 -0
- matebot/web/vendor/chart.umd.js +20 -0
- matebot/web/vendor/chartjs-plugin-annotation.min.js +7 -0
- matebot-0.2.0.dist-info/METADATA +232 -0
- matebot-0.2.0.dist-info/RECORD +29 -0
- matebot-0.2.0.dist-info/WHEEL +4 -0
- matebot-0.2.0.dist-info/entry_points.txt +2 -0
- matebot-0.2.0.dist-info/licenses/LICENSE +21 -0
matebot/__init__.py
ADDED
matebot/bags.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Bean bag tracking ā strictly opt-in.
|
|
2
|
+
|
|
3
|
+
Nothing happens until the user registers a bag with ``/newbag``; from then on
|
|
4
|
+
every logged dose-in is subtracted, and the bot warns once when roughly three
|
|
5
|
+
doses are left. ``/bag`` shows the current state at any time.
|
|
6
|
+
|
|
7
|
+
State shape (in the bot's state file):
|
|
8
|
+
bag = {"name": str, "total_g": float, "used_g": float,
|
|
9
|
+
"shots": int, "rating_sum": int, "warned": bool}
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
DEFAULT_DOSE_G = 18.0
|
|
15
|
+
WARN_DOSES = 3
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def open_bag(state, total_g: float, name: str) -> str:
|
|
19
|
+
state.set("bag", {
|
|
20
|
+
"name": name, "total_g": total_g, "used_g": 0.0,
|
|
21
|
+
"shots": 0, "rating_sum": 0, "warned": False,
|
|
22
|
+
})
|
|
23
|
+
return f"š« New bag registered: {name}, {total_g:.0f} g. I'll keep count."
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def bag_status(state) -> str:
|
|
27
|
+
bag = state.get("bag")
|
|
28
|
+
if not bag:
|
|
29
|
+
return "No bag registered. Start one with: /newbag <grams> [name]"
|
|
30
|
+
remaining = max(0.0, bag["total_g"] - bag["used_g"])
|
|
31
|
+
dose = _typical_dose(state)
|
|
32
|
+
doses_left = int(remaining // dose) if dose else 0
|
|
33
|
+
line = (
|
|
34
|
+
f"š« {bag['name']}: {remaining:.0f} g of {bag['total_g']:.0f} g left"
|
|
35
|
+
f" (ā{doses_left} doses) Ā· {bag['shots']} shots"
|
|
36
|
+
)
|
|
37
|
+
if bag["shots"] and bag["rating_sum"]:
|
|
38
|
+
line += f" Ā· avg {'ā
' * round(bag['rating_sum'] / bag['shots'])}"
|
|
39
|
+
return line
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def track_shot(state, notes: dict) -> str | None:
|
|
43
|
+
"""Subtract the logged dose; returns a warning message when running low."""
|
|
44
|
+
bag = state.get("bag")
|
|
45
|
+
if not bag:
|
|
46
|
+
return None
|
|
47
|
+
try:
|
|
48
|
+
dose = float(notes.get("doseIn", ""))
|
|
49
|
+
except (TypeError, ValueError):
|
|
50
|
+
dose = 0.0
|
|
51
|
+
if dose <= 0:
|
|
52
|
+
return None
|
|
53
|
+
bag["used_g"] += dose
|
|
54
|
+
bag["shots"] += 1
|
|
55
|
+
rating = notes.get("rating")
|
|
56
|
+
if isinstance(rating, int):
|
|
57
|
+
bag["rating_sum"] += rating
|
|
58
|
+
remaining = bag["total_g"] - bag["used_g"]
|
|
59
|
+
|
|
60
|
+
message = None
|
|
61
|
+
if remaining <= 0:
|
|
62
|
+
message = f"š« That was the last of '{bag['name']}' by my count ā bag empty."
|
|
63
|
+
elif remaining < WARN_DOSES * dose and not bag["warned"]:
|
|
64
|
+
bag["warned"] = True
|
|
65
|
+
message = (
|
|
66
|
+
f"š« Heads-up: '{bag['name']}' is down to ~{remaining:.0f} g "
|
|
67
|
+
f"(ā{int(remaining // dose)} doses). Time to restock?"
|
|
68
|
+
)
|
|
69
|
+
state.set("bag", bag)
|
|
70
|
+
return message
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _typical_dose(state) -> float:
|
|
74
|
+
try:
|
|
75
|
+
return float(state.get("last_notes", {}).get("doseIn", DEFAULT_DOSE_G))
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
return DEFAULT_DOSE_G
|
matebot/cli.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""matebot CLI: run / decode / sitegen / sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import pathlib
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .slog import SlogError, parse_slog
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> int:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="matebot",
|
|
19
|
+
description="The proactive companion for GaggiMate espresso machines.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--version", action="version", version=f"matebot {__version__}")
|
|
22
|
+
parser.add_argument("--config", help="path to config.toml")
|
|
23
|
+
parser.add_argument("-v", "--verbose", action="store_true")
|
|
24
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
25
|
+
|
|
26
|
+
p_run = sub.add_parser("run", help="watch the machine, message after every shot")
|
|
27
|
+
p_run.add_argument("--replay", help="JSONL frame capture instead of the live machine")
|
|
28
|
+
p_run.add_argument("--dry-run", action="store_true", help="log instead of messaging")
|
|
29
|
+
|
|
30
|
+
p_dec = sub.add_parser("decode", help="decode a .slog file to JSON or CSV")
|
|
31
|
+
p_dec.add_argument("file", type=pathlib.Path)
|
|
32
|
+
p_dec.add_argument("--csv", action="store_true")
|
|
33
|
+
|
|
34
|
+
p_site = sub.add_parser("sitegen", help="generate the static shot-explorer site")
|
|
35
|
+
p_site.add_argument("shots_dir", type=pathlib.Path)
|
|
36
|
+
p_site.add_argument("-o", "--out", type=pathlib.Path, required=True)
|
|
37
|
+
p_site.add_argument("--title", default="Shot Journal")
|
|
38
|
+
|
|
39
|
+
sub.add_parser("sync", help="one-off sync of the data repo (shots, profiles, site)")
|
|
40
|
+
|
|
41
|
+
args = parser.parse_args(argv)
|
|
42
|
+
logging.basicConfig(
|
|
43
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
44
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
45
|
+
)
|
|
46
|
+
config = Config.load(args.config)
|
|
47
|
+
|
|
48
|
+
if args.cmd == "decode":
|
|
49
|
+
try:
|
|
50
|
+
shot = parse_slog(args.file.read_bytes())
|
|
51
|
+
except SlogError as exc:
|
|
52
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
53
|
+
return 1
|
|
54
|
+
print(shot.to_csv() if args.csv else shot.to_json(indent=1))
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
if args.cmd == "sitegen":
|
|
58
|
+
from .sitegen import generate
|
|
59
|
+
|
|
60
|
+
count = generate(args.shots_dir, args.out, title=args.title)
|
|
61
|
+
print(f"{count} shots -> {args.out}")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
if args.cmd == "sync":
|
|
65
|
+
return asyncio.run(_sync(config))
|
|
66
|
+
|
|
67
|
+
return asyncio.run(_run(config, replay=args.replay, dry_run=args.dry_run))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _sync(config: Config) -> int:
|
|
71
|
+
from .machine import GaggiMateClient
|
|
72
|
+
from .sync import sync
|
|
73
|
+
|
|
74
|
+
if not config.data_repo:
|
|
75
|
+
print("error: MATEBOT_DATA_REPO / data_repo not configured", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
async with GaggiMateClient(config.machine_host) as client:
|
|
78
|
+
# WS connection (for profiles) is optional here; HTTP does the rest.
|
|
79
|
+
pushed = await sync(client, config.data_repo, site_title=config.site_title)
|
|
80
|
+
print("synced" if pushed else "nothing new")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _run(config: Config, *, replay: str | None, dry_run: bool) -> int:
|
|
85
|
+
from .commands import CommandRouter, make_frame_cache
|
|
86
|
+
from .conversation import Conversation
|
|
87
|
+
from .machine import GaggiMateClient
|
|
88
|
+
from .messengers.base import TextReply
|
|
89
|
+
from .state import State
|
|
90
|
+
from .sync import sync_soon
|
|
91
|
+
from .watcher import ShotWatcher, replay_frames
|
|
92
|
+
|
|
93
|
+
log = logging.getLogger("matebot")
|
|
94
|
+
state = State(pathlib.Path(config.state_dir) / "state.json")
|
|
95
|
+
|
|
96
|
+
async with GaggiMateClient(config.machine_host) as client:
|
|
97
|
+
if dry_run:
|
|
98
|
+
|
|
99
|
+
class _DryMessenger:
|
|
100
|
+
async def start(self): ...
|
|
101
|
+
async def stop(self): ...
|
|
102
|
+
async def send(self, text, options=None):
|
|
103
|
+
log.info("DRY SEND: %s %s", text, [o.label for o in options or []])
|
|
104
|
+
return "0"
|
|
105
|
+
async def edit(self, ref, text, options=None): ...
|
|
106
|
+
def events(self):
|
|
107
|
+
return _never()
|
|
108
|
+
|
|
109
|
+
messenger = _DryMessenger()
|
|
110
|
+
else:
|
|
111
|
+
from .messengers import create_messenger
|
|
112
|
+
|
|
113
|
+
messenger = create_messenger(config)
|
|
114
|
+
|
|
115
|
+
def schedule_sync(*, quiet: bool = False):
|
|
116
|
+
if config.sync_enabled and config.data_repo:
|
|
117
|
+
asyncio.create_task(
|
|
118
|
+
sync_soon(
|
|
119
|
+
client, config.data_repo, messenger.send,
|
|
120
|
+
site_title=config.site_title, state=state, quiet=quiet,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def save_notes(shot_id: int, notes: dict) -> bool:
|
|
125
|
+
from .bags import track_shot
|
|
126
|
+
from .hints import make_hint
|
|
127
|
+
|
|
128
|
+
for attempt in range(3):
|
|
129
|
+
try:
|
|
130
|
+
resp = await client.notes_save(shot_id, notes)
|
|
131
|
+
log.info("notes saved for %06d: %s", shot_id, resp.get("msg", "?"))
|
|
132
|
+
schedule_sync() # push notes + regenerated journal
|
|
133
|
+
if config.hints_enabled:
|
|
134
|
+
hint = make_hint(notes)
|
|
135
|
+
if hint:
|
|
136
|
+
await messenger.send(hint)
|
|
137
|
+
bag_msg = track_shot(state, notes) # no-op unless a bag is registered
|
|
138
|
+
if bag_msg:
|
|
139
|
+
await messenger.send(bag_msg)
|
|
140
|
+
return True
|
|
141
|
+
except Exception as exc: # noqa: BLE001
|
|
142
|
+
log.warning("notes save attempt %d failed: %s", attempt + 1, exc)
|
|
143
|
+
await asyncio.sleep(5 * (attempt + 1))
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
convo = Conversation(messenger, state, save_notes)
|
|
147
|
+
cache_frame, latest_frame = make_frame_cache()
|
|
148
|
+
router = CommandRouter(client, state, convo, messenger, config, latest_frame)
|
|
149
|
+
# messenger APIs can be flaky at boot; retry instead of crash-looping
|
|
150
|
+
for attempt in range(8):
|
|
151
|
+
try:
|
|
152
|
+
await messenger.start()
|
|
153
|
+
break
|
|
154
|
+
except Exception as exc: # noqa: BLE001
|
|
155
|
+
if attempt == 7:
|
|
156
|
+
raise
|
|
157
|
+
log.warning("messenger start failed (%s); retry in %ds", exc, 10 * (attempt + 1))
|
|
158
|
+
await asyncio.sleep(10 * (attempt + 1))
|
|
159
|
+
try:
|
|
160
|
+
await convo.resume_if_pending()
|
|
161
|
+
|
|
162
|
+
async def pump_events():
|
|
163
|
+
async for event in messenger.events():
|
|
164
|
+
try:
|
|
165
|
+
if isinstance(event, TextReply) and event.text.strip().startswith("/"):
|
|
166
|
+
if await router.handle(event.text):
|
|
167
|
+
continue
|
|
168
|
+
await convo.handle_event(event)
|
|
169
|
+
except Exception: # noqa: BLE001 - a flaky send must not kill the bot
|
|
170
|
+
log.exception("event handling failed")
|
|
171
|
+
|
|
172
|
+
async def pump_shots():
|
|
173
|
+
import re as _re
|
|
174
|
+
import time as _time
|
|
175
|
+
|
|
176
|
+
last_frame_at = 0.0
|
|
177
|
+
|
|
178
|
+
async def tee(source):
|
|
179
|
+
nonlocal last_frame_at
|
|
180
|
+
async for frame in source:
|
|
181
|
+
now = _time.monotonic()
|
|
182
|
+
gap = now - last_frame_at if last_frame_at else None
|
|
183
|
+
last_frame_at = now
|
|
184
|
+
if state.get("sync_pending") and (gap is None or gap > 60):
|
|
185
|
+
log.info("machine is back; retrying pending journal sync")
|
|
186
|
+
state.set("sync_pending", False) # avoid re-trigger storms
|
|
187
|
+
schedule_sync(quiet=True)
|
|
188
|
+
cache_frame(frame)
|
|
189
|
+
await router.on_frame(frame)
|
|
190
|
+
yield frame
|
|
191
|
+
|
|
192
|
+
async def on_utility(profile):
|
|
193
|
+
# only actual cleaning runs reset the counter, not water flushes
|
|
194
|
+
if _re.search(r"(?i)backflush|descale|clean", profile or ""):
|
|
195
|
+
state.set("shots_since_clean", 0)
|
|
196
|
+
log.info("cleaning run detected (%s); counter reset", profile)
|
|
197
|
+
|
|
198
|
+
frames = tee(
|
|
199
|
+
replay_frames(replay) if replay else client.status_stream()
|
|
200
|
+
)
|
|
201
|
+
watcher = ShotWatcher(
|
|
202
|
+
client,
|
|
203
|
+
min_duration_s=config.min_shot_s,
|
|
204
|
+
ignore_profiles=config.ignore_profiles,
|
|
205
|
+
last_known_id=state.get("last_shot_id", -1),
|
|
206
|
+
on_utility=on_utility,
|
|
207
|
+
)
|
|
208
|
+
async for shot in watcher.shots(frames):
|
|
209
|
+
since_clean = state.get("shots_since_clean", 0) + 1
|
|
210
|
+
state.set("shots_since_clean", since_clean)
|
|
211
|
+
if config.clean_every and since_clean % config.clean_every == 0:
|
|
212
|
+
try:
|
|
213
|
+
await messenger.send(
|
|
214
|
+
f"š§½ {since_clean} espresso shots since the last backflush ā "
|
|
215
|
+
"the group head would appreciate a round with the blind basket."
|
|
216
|
+
)
|
|
217
|
+
except Exception: # noqa: BLE001
|
|
218
|
+
log.exception("cleaning reminder failed")
|
|
219
|
+
state.update(
|
|
220
|
+
last_shot_id=shot.entry.id,
|
|
221
|
+
last_shot={
|
|
222
|
+
"shot_id": shot.entry.id,
|
|
223
|
+
"profile": shot.profile_label or shot.entry.profile_name,
|
|
224
|
+
"duration_ms": shot.duration_ms,
|
|
225
|
+
"volume_g": shot.entry.volume_g,
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
photo = None
|
|
229
|
+
if config.plots_enabled:
|
|
230
|
+
try:
|
|
231
|
+
from .plot import render_shot_png
|
|
232
|
+
from .slog import parse_slog
|
|
233
|
+
|
|
234
|
+
parsed = parse_slog(await client.fetch_slog(shot.entry.id))
|
|
235
|
+
photo = render_shot_png(
|
|
236
|
+
parsed, title=f"Shot #{shot.entry.id} ā {parsed.profile_name}"
|
|
237
|
+
)
|
|
238
|
+
except Exception as exc: # noqa: BLE001 - photo is a nice-to-have
|
|
239
|
+
log.info("shot plot skipped: %s", exc)
|
|
240
|
+
try:
|
|
241
|
+
await convo.start_shot(
|
|
242
|
+
shot.entry.id,
|
|
243
|
+
shot.profile_label or shot.entry.profile_name,
|
|
244
|
+
shot.duration_ms,
|
|
245
|
+
shot.entry.volume_g,
|
|
246
|
+
photo=photo,
|
|
247
|
+
)
|
|
248
|
+
except Exception: # noqa: BLE001 - keep watching even if messaging fails
|
|
249
|
+
log.exception("questionnaire start failed (state kept for resume)")
|
|
250
|
+
schedule_sync() # archive the .slog right away
|
|
251
|
+
|
|
252
|
+
async def weekly_digest():
|
|
253
|
+
from datetime import datetime
|
|
254
|
+
|
|
255
|
+
from .commands import build_digest
|
|
256
|
+
from .digest import seconds_until_sunday_evening
|
|
257
|
+
|
|
258
|
+
while True:
|
|
259
|
+
await asyncio.sleep(seconds_until_sunday_evening(datetime.now()))
|
|
260
|
+
stamp = datetime.now().strftime("%G-W%V")
|
|
261
|
+
if state.get("last_digest") == stamp:
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
text = await build_digest(client, config)
|
|
265
|
+
if text:
|
|
266
|
+
await messenger.send(text)
|
|
267
|
+
state.set("last_digest", stamp)
|
|
268
|
+
except Exception: # noqa: BLE001
|
|
269
|
+
log.exception("weekly digest failed")
|
|
270
|
+
|
|
271
|
+
tasks = [asyncio.create_task(pump_events()), asyncio.create_task(pump_shots())]
|
|
272
|
+
if config.digest_enabled:
|
|
273
|
+
tasks.append(asyncio.create_task(weekly_digest()))
|
|
274
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
275
|
+
for task in pending:
|
|
276
|
+
task.cancel()
|
|
277
|
+
for task in done:
|
|
278
|
+
exc = task.exception()
|
|
279
|
+
if exc:
|
|
280
|
+
raise exc
|
|
281
|
+
finally:
|
|
282
|
+
await messenger.stop()
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def _never():
|
|
287
|
+
if False: # pragma: no cover - typed empty async generator
|
|
288
|
+
yield
|
|
289
|
+
await asyncio.Event().wait()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
raise SystemExit(main())
|
matebot/commands.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Chat commands ā messenger-agnostic.
|
|
2
|
+
|
|
3
|
+
Commands arrive as plain text events starting with "/" (every backend already
|
|
4
|
+
delivers text), so this router works identically on Telegram, Discord and any
|
|
5
|
+
future messenger. It is consulted before the questionnaire so a command never
|
|
6
|
+
gets swallowed as a free-text answer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .machine import MachineError
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
MODE_NAMES = {0: "standby", 1: "brew", 2: "steam", 3: "water", 4: "grind"}
|
|
21
|
+
|
|
22
|
+
HELP = (
|
|
23
|
+
"/wake ā turn the machine on (brew mode); I'll ping you when it's at temperature\n"
|
|
24
|
+
"/sleep ā back to standby\n"
|
|
25
|
+
"/status ā mode, temperature, connectivity\n"
|
|
26
|
+
"/last ā the last logged shot\n"
|
|
27
|
+
"/fix ā redo the questionnaire for the last shot\n"
|
|
28
|
+
"/newbag <grams> [name] ā start tracking a bean bag (optional feature)\n"
|
|
29
|
+
"/bag ā how much is left in the bag\n"
|
|
30
|
+
"/digest ā your last 7 days in espresso\n"
|
|
31
|
+
"/help ā this list"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CommandRouter:
|
|
36
|
+
def __init__(self, client, state, convo, messenger, config, latest_frame) -> None:
|
|
37
|
+
self.client = client
|
|
38
|
+
self.state = state
|
|
39
|
+
self.convo = convo
|
|
40
|
+
self.messenger = messenger
|
|
41
|
+
self.config = config
|
|
42
|
+
self.latest_frame = latest_frame # () -> (frame dict | None, age seconds)
|
|
43
|
+
self._awaiting_ready = False
|
|
44
|
+
self._args: list[str] = []
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------- dispatch
|
|
47
|
+
|
|
48
|
+
async def handle(self, text: str) -> bool:
|
|
49
|
+
"""Handle a command; returns True when the text was consumed."""
|
|
50
|
+
parts = text.strip().split()
|
|
51
|
+
cmd = parts[0].lower().lstrip("/").split("@")[0]
|
|
52
|
+
self._args = parts[1:]
|
|
53
|
+
handler = getattr(self, f"_cmd_{cmd}", None)
|
|
54
|
+
if handler is None:
|
|
55
|
+
if cmd == "start":
|
|
56
|
+
await self.messenger.send("ā matebot at your service.\n\n" + HELP)
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
try:
|
|
60
|
+
await handler()
|
|
61
|
+
except MachineError as exc:
|
|
62
|
+
await self.messenger.send(f"ā ļø Can't reach the machine ({exc}). Is it plugged in?")
|
|
63
|
+
except Exception: # noqa: BLE001
|
|
64
|
+
log.exception("command %s failed", cmd)
|
|
65
|
+
await self.messenger.send("ā ļø That didn't work ā check the logs.")
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------- commands
|
|
69
|
+
|
|
70
|
+
async def _cmd_help(self) -> None:
|
|
71
|
+
await self.messenger.send(HELP)
|
|
72
|
+
|
|
73
|
+
async def _run_hook(self, hook: str, label: str) -> bool:
|
|
74
|
+
"""Run a smart-plug shell hook; reports failure to the user."""
|
|
75
|
+
try:
|
|
76
|
+
proc = await asyncio.create_subprocess_shell(
|
|
77
|
+
hook,
|
|
78
|
+
stdout=asyncio.subprocess.PIPE,
|
|
79
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
80
|
+
)
|
|
81
|
+
out, _ = await asyncio.wait_for(proc.communicate(), timeout=20)
|
|
82
|
+
if proc.returncode != 0:
|
|
83
|
+
log.warning("%s hook failed (%d): %s", label, proc.returncode, out.decode()[-200:])
|
|
84
|
+
await self.messenger.send(f"ā ļø The {label} hook failed ā check the plug.")
|
|
85
|
+
return False
|
|
86
|
+
return True
|
|
87
|
+
except Exception as exc: # noqa: BLE001
|
|
88
|
+
log.warning("%s hook error: %s", label, exc)
|
|
89
|
+
await self.messenger.send(f"ā ļø The {label} hook errored: {exc}")
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
async def _machine_online(self) -> bool:
|
|
93
|
+
_, age = self.latest_frame()
|
|
94
|
+
return age < 20
|
|
95
|
+
|
|
96
|
+
async def _cmd_wake(self) -> None:
|
|
97
|
+
cold_start = self.config.wake_hook and not await self._machine_online()
|
|
98
|
+
if self.config.wake_hook and not await self._run_hook(self.config.wake_hook, "wake"):
|
|
99
|
+
return
|
|
100
|
+
if cold_start:
|
|
101
|
+
await self.messenger.send(
|
|
102
|
+
"š Plug is on ā waiting for the machine to boot (can take a minute)ā¦"
|
|
103
|
+
)
|
|
104
|
+
for _ in range(90):
|
|
105
|
+
await asyncio.sleep(1)
|
|
106
|
+
if await self._machine_online():
|
|
107
|
+
break
|
|
108
|
+
else:
|
|
109
|
+
await self.messenger.send(
|
|
110
|
+
"ā ļø The machine didn't come online. Check the plug and the machine's switch."
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
|
+
await self.client.send_event("req:change-mode", mode=1)
|
|
114
|
+
self._awaiting_ready = True
|
|
115
|
+
await self.messenger.send("š„ Waking the machine ā I'll tell you when it's hot.")
|
|
116
|
+
|
|
117
|
+
async def _cmd_sleep(self) -> None:
|
|
118
|
+
try:
|
|
119
|
+
await self.client.send_event("req:change-mode", mode=0)
|
|
120
|
+
except MachineError:
|
|
121
|
+
if not self.config.sleep_hook:
|
|
122
|
+
raise
|
|
123
|
+
self._awaiting_ready = False
|
|
124
|
+
if self.config.sleep_hook:
|
|
125
|
+
await asyncio.sleep(3) # let the machine settle into standby first
|
|
126
|
+
if await self._run_hook(self.config.sleep_hook, "sleep"):
|
|
127
|
+
await self.messenger.send("š“ Standby, and the plug is off. Fully dark.")
|
|
128
|
+
return
|
|
129
|
+
await self.messenger.send("š“ Machine is going to standby.")
|
|
130
|
+
|
|
131
|
+
async def _cmd_status(self) -> None:
|
|
132
|
+
frame, age = self.latest_frame()
|
|
133
|
+
if frame is None or age > 20:
|
|
134
|
+
await self.messenger.send(
|
|
135
|
+
"š Machine is offline (no status for a while) ā probably powered off."
|
|
136
|
+
)
|
|
137
|
+
return
|
|
138
|
+
mode = MODE_NAMES.get(frame.get("m"), "?")
|
|
139
|
+
ct, tt = frame.get("ct", 0), frame.get("tt", 0)
|
|
140
|
+
line = f"Machine is in {mode} mode · boiler {ct:.1f}°C"
|
|
141
|
+
if tt:
|
|
142
|
+
line += f" ā target {tt:.0f}°C"
|
|
143
|
+
if frame.get("wl") is not None:
|
|
144
|
+
line += f" Ā· water {frame['wl']}%"
|
|
145
|
+
since_clean = self.state.get("shots_since_clean", 0)
|
|
146
|
+
if since_clean:
|
|
147
|
+
line += f"\nš§½ {since_clean} shots since the last backflush"
|
|
148
|
+
await self.messenger.send(line)
|
|
149
|
+
|
|
150
|
+
async def _cmd_last(self) -> None:
|
|
151
|
+
last = self.state.get("last_shot")
|
|
152
|
+
if not last:
|
|
153
|
+
await self.messenger.send("No shot logged yet.")
|
|
154
|
+
return
|
|
155
|
+
notes = self.state.get("last_notes", {})
|
|
156
|
+
bits = [f"Shot #{last['shot_id']} ā {last.get('profile', '?')}"]
|
|
157
|
+
bits.append(f"{last.get('duration_ms', 0) / 1000:.0f}s")
|
|
158
|
+
if last.get("volume_g"):
|
|
159
|
+
bits.append(f"{last['volume_g']:.1f} g")
|
|
160
|
+
if notes.get("beanType"):
|
|
161
|
+
bits.append(notes["beanType"])
|
|
162
|
+
text = " Ā· ".join(bits)
|
|
163
|
+
if self.config.journal_url:
|
|
164
|
+
text += f"\n{self.config.journal_url.rstrip('/')}/#{last['shot_id']:06d}"
|
|
165
|
+
await self.messenger.send(text)
|
|
166
|
+
|
|
167
|
+
async def _cmd_fix(self) -> None:
|
|
168
|
+
last = self.state.get("last_shot")
|
|
169
|
+
if not last:
|
|
170
|
+
await self.messenger.send("No shot to fix yet.")
|
|
171
|
+
return
|
|
172
|
+
await self.messenger.send(f"āļø Let's redo shot #{last['shot_id']}:")
|
|
173
|
+
await self.convo.start_shot(
|
|
174
|
+
last["shot_id"], last.get("profile", ""),
|
|
175
|
+
last.get("duration_ms", 0), last.get("volume_g", 0.0),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def _cmd_newbag(self) -> None:
|
|
179
|
+
from . import bags
|
|
180
|
+
|
|
181
|
+
usage = "Usage: /newbag <grams> [name] ā e.g. /newbag 250 Mondo Classico"
|
|
182
|
+
if not self._args:
|
|
183
|
+
await self.messenger.send(usage)
|
|
184
|
+
return
|
|
185
|
+
try:
|
|
186
|
+
grams = float(self._args[0].replace("g", ""))
|
|
187
|
+
except ValueError:
|
|
188
|
+
await self.messenger.send(usage)
|
|
189
|
+
return
|
|
190
|
+
name = (
|
|
191
|
+
" ".join(self._args[1:])
|
|
192
|
+
or self.state.get("last_notes", {}).get("beanType")
|
|
193
|
+
or "Unnamed beans"
|
|
194
|
+
)
|
|
195
|
+
await self.messenger.send(bags.open_bag(self.state, grams, name))
|
|
196
|
+
|
|
197
|
+
async def _cmd_bag(self) -> None:
|
|
198
|
+
from . import bags
|
|
199
|
+
|
|
200
|
+
await self.messenger.send(bags.bag_status(self.state))
|
|
201
|
+
|
|
202
|
+
async def _cmd_digest(self) -> None:
|
|
203
|
+
text = await build_digest(self.client, self.config)
|
|
204
|
+
await self.messenger.send(text or "No shots in the last 7 days. The machine misses you.")
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------- frames
|
|
207
|
+
|
|
208
|
+
async def on_frame(self, frame: dict[str, Any]) -> None:
|
|
209
|
+
"""Called for every status frame: ready ping after /wake, tank watch."""
|
|
210
|
+
if frame.get("tp") != "evt:status":
|
|
211
|
+
return
|
|
212
|
+
await self._check_water(frame)
|
|
213
|
+
if not self._awaiting_ready:
|
|
214
|
+
return
|
|
215
|
+
ct, tt = frame.get("ct", 0), frame.get("tt", 0)
|
|
216
|
+
if frame.get("m") == 1 and tt >= 60 and ct >= tt - 1.0:
|
|
217
|
+
self._awaiting_ready = False
|
|
218
|
+
text = f"ā {ct:.1f}°C ā the machine is ready when you are."
|
|
219
|
+
wl = frame.get("wl")
|
|
220
|
+
if wl is not None and self.config.water_warn_pct and wl < self.config.water_warn_pct:
|
|
221
|
+
text += f" The tank is at {wl}%, though."
|
|
222
|
+
await self.messenger.send(text)
|
|
223
|
+
|
|
224
|
+
async def _check_water(self, frame: dict[str, Any]) -> None:
|
|
225
|
+
"""Warn once when the tank runs low; re-arm after a refill."""
|
|
226
|
+
wl = frame.get("wl")
|
|
227
|
+
threshold = self.config.water_warn_pct
|
|
228
|
+
if wl is None or not threshold:
|
|
229
|
+
return
|
|
230
|
+
warned = self.state.get("water_warned", False)
|
|
231
|
+
if not warned and wl < threshold:
|
|
232
|
+
self.state.set("water_warned", True)
|
|
233
|
+
await self.messenger.send(
|
|
234
|
+
f"š§ Water tank at {wl}% ā maybe top it up before the next shot."
|
|
235
|
+
)
|
|
236
|
+
elif warned and wl >= threshold + 10:
|
|
237
|
+
self.state.set("water_warned", False)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def build_digest(client, config) -> str | None:
|
|
241
|
+
"""Digest from the machine index, falling back to the local journal data."""
|
|
242
|
+
from datetime import datetime
|
|
243
|
+
from pathlib import Path
|
|
244
|
+
|
|
245
|
+
from . import digest
|
|
246
|
+
|
|
247
|
+
rows = None
|
|
248
|
+
try:
|
|
249
|
+
rows = digest.rows_from_index(await client.fetch_index())
|
|
250
|
+
except Exception: # noqa: BLE001 - machine may be off
|
|
251
|
+
site_index = Path(config.data_repo or "") / "docs" / "index.json"
|
|
252
|
+
if config.data_repo and site_index.exists():
|
|
253
|
+
rows = digest.rows_from_site_index(site_index)
|
|
254
|
+
if rows is None:
|
|
255
|
+
return None
|
|
256
|
+
return digest.compute(rows, now=datetime.now(), journal_url=config.journal_url)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def make_frame_cache():
|
|
260
|
+
"""Returns (update, get): cache the newest status frame with a timestamp."""
|
|
261
|
+
box: dict[str, Any] = {"frame": None, "ts": 0.0}
|
|
262
|
+
|
|
263
|
+
def update(frame: dict) -> None:
|
|
264
|
+
if frame.get("tp") == "evt:status":
|
|
265
|
+
box["frame"] = frame
|
|
266
|
+
box["ts"] = time.monotonic()
|
|
267
|
+
|
|
268
|
+
def get():
|
|
269
|
+
age = time.monotonic() - box["ts"] if box["frame"] else 1e9
|
|
270
|
+
return box["frame"], age
|
|
271
|
+
|
|
272
|
+
return update, get
|