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 ADDED
@@ -0,0 +1,3 @@
1
+ """matebot — the proactive companion for GaggiMate espresso machines."""
2
+
3
+ __version__ = "0.2.0"
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