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/config.py ADDED
@@ -0,0 +1,91 @@
1
+ """Configuration: TOML file overridden by environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from dataclasses import dataclass, field, fields
8
+ from pathlib import Path
9
+
10
+ DEFAULT_CONFIG = Path(
11
+ os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
12
+ ) / "matebot" / "config.toml"
13
+
14
+ # dataclass field -> environment variable
15
+ ENV_MAP = {
16
+ "machine_host": "MATEBOT_MACHINE_HOST",
17
+ "messenger": "MATEBOT_MESSENGER",
18
+ "telegram_token": "TELEGRAM_BOT_TOKEN",
19
+ "telegram_chat_id": "TELEGRAM_CHAT_ID",
20
+ "discord_token": "DISCORD_BOT_TOKEN",
21
+ "discord_channel_id": "DISCORD_CHANNEL_ID",
22
+ "data_repo": "MATEBOT_DATA_REPO",
23
+ "state_dir": "MATEBOT_STATE_DIR",
24
+ "min_shot_s": "MATEBOT_MIN_SHOT_S",
25
+ "ignore_profiles": "MATEBOT_IGNORE_PROFILES",
26
+ "sync_enabled": "MATEBOT_SYNC",
27
+ "site_title": "MATEBOT_SITE_TITLE",
28
+ "journal_url": "MATEBOT_JOURNAL_URL",
29
+ "hints_enabled": "MATEBOT_HINTS",
30
+ "digest_enabled": "MATEBOT_DIGEST",
31
+ "clean_every": "MATEBOT_CLEAN_EVERY",
32
+ "water_warn_pct": "MATEBOT_WATER_WARN",
33
+ "plots_enabled": "MATEBOT_PLOTS",
34
+ "wake_hook": "MATEBOT_WAKE_HOOK",
35
+ "sleep_hook": "MATEBOT_SLEEP_HOOK",
36
+ }
37
+
38
+
39
+ @dataclass
40
+ class Config:
41
+ machine_host: str = "gaggimate.local"
42
+ messenger: str = "telegram"
43
+ telegram_token: str = ""
44
+ telegram_chat_id: str = ""
45
+ discord_token: str = ""
46
+ discord_channel_id: str = ""
47
+ data_repo: str = "" # path to the git-backed shot journal; empty = sync off
48
+ state_dir: str = field(
49
+ default_factory=lambda: os.environ.get(
50
+ "XDG_STATE_HOME", os.path.expanduser("~/.local/state")
51
+ )
52
+ + "/matebot"
53
+ )
54
+ min_shot_s: float = 10.0
55
+ ignore_profiles: str = r"(?i)backflush|descale|flush|clean"
56
+ sync_enabled: bool = False
57
+ site_title: str = "Shot Journal"
58
+ journal_url: str = "" # public journal base URL, used for /last deep links
59
+ hints_enabled: bool = True # dial-in suggestions after sour/bitter/low-rated shots
60
+ digest_enabled: bool = True # weekly summary on Sunday evening
61
+ clean_every: int = 40 # backflush reminder every N espresso shots; 0 = off
62
+ water_warn_pct: int = 15 # warn when the tank drops below this percent; 0 = off
63
+ plots_enabled: bool = True # send the shot chart as a photo (needs matplotlib)
64
+ wake_hook: str = "" # shell command to power the machine's smart plug ON
65
+ sleep_hook: str = "" # shell command to power the smart plug OFF
66
+
67
+ @classmethod
68
+ def load(cls, path: str | Path | None = None) -> Config:
69
+ raw: dict = {}
70
+ cfg_path = Path(path) if path else DEFAULT_CONFIG
71
+ if cfg_path.exists():
72
+ raw = tomllib.loads(cfg_path.read_text())
73
+ kwargs = {}
74
+ for f in fields(cls):
75
+ value = raw.get(f.name)
76
+ env = os.environ.get(ENV_MAP.get(f.name, ""))
77
+ if env is not None:
78
+ value = env
79
+ if value is None:
80
+ continue
81
+ if f.name == "min_shot_s":
82
+ value = float(value)
83
+ elif f.name in ("clean_every", "water_warn_pct"):
84
+ value = int(value)
85
+ elif f.name in ("sync_enabled", "hints_enabled", "digest_enabled", "plots_enabled"):
86
+ value = str(value).lower() in ("1", "true", "yes", "on")
87
+ kwargs[f.name] = value
88
+ config = cls(**kwargs)
89
+ if config.data_repo and "sync_enabled" not in kwargs:
90
+ config.sync_enabled = True
91
+ return config
@@ -0,0 +1,263 @@
1
+ """The post-shot questionnaire — messenger-agnostic.
2
+
3
+ One questionnaire at a time (it's a home espresso machine, not a fleet).
4
+ A new shot supersedes a pending questionnaire: whatever was already answered
5
+ is saved (partial notes beat no notes), then the new one starts.
6
+
7
+ Flow: RATING → TASTE → BEAN → GRIND → DOSE_IN → DOSE_OUT → NOTES → save.
8
+ Answers land in the machine's own "Shot Notes" (via req:history:notes:save),
9
+ exactly like typing them into the web UI — just without opening the web UI.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from collections.abc import Awaitable, Callable
16
+ from dataclasses import dataclass
17
+
18
+ from .messengers.base import Event, Messenger, Option, OptionSelected, TextReply
19
+ from .state import State
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ SKIP = "–" # sentinel label value for skip options
24
+
25
+ # field key -> notes key
26
+ NOTE_KEYS = {
27
+ "r": "rating",
28
+ "bt": "balanceTaste",
29
+ "bean": "beanType",
30
+ "grind": "grindSetting",
31
+ "din": "doseIn",
32
+ "dout": "doseOut",
33
+ "txt": "notes",
34
+ }
35
+
36
+ STEPS = ["r", "bt", "bean", "grind", "din", "dout", "txt"]
37
+ TEXT_STEPS = {"bean", "grind", "din", "dout", "txt"}
38
+
39
+ PROMPTS = {
40
+ "r": "How was it?",
41
+ "bt": "Balance / taste?",
42
+ "bean": "Which beans?",
43
+ "grind": "Grind setting?",
44
+ "din": "Dose in (g)?",
45
+ "dout": "Dose out (g)?",
46
+ "txt": "Any notes for future-you?",
47
+ }
48
+
49
+
50
+ def _fmt_duration(ms: int) -> str:
51
+ return f"{ms / 1000:.0f}s"
52
+
53
+
54
+ def _signoff(hour: int) -> str:
55
+ """A closing line that knows what time it is (no 'sweet dreams' at 7 am)."""
56
+ if 5 <= hour < 11:
57
+ return "Enjoy the kickstart."
58
+ if 11 <= hour < 17:
59
+ return "Back to it."
60
+ if 17 <= hour < 22:
61
+ return "Enjoy the evening — bold choice, by the way."
62
+ return "Sweet dreams tonight. Eventually."
63
+
64
+
65
+ @dataclass
66
+ class PendingShot:
67
+ shot_id: int
68
+ profile: str
69
+ duration_ms: int
70
+ volume_g: float
71
+ step: str = "r"
72
+ answers: dict | None = None
73
+
74
+ def to_dict(self) -> dict:
75
+ return {
76
+ "shot_id": self.shot_id,
77
+ "profile": self.profile,
78
+ "duration_ms": self.duration_ms,
79
+ "volume_g": self.volume_g,
80
+ "step": self.step,
81
+ "answers": self.answers or {},
82
+ }
83
+
84
+ @classmethod
85
+ def from_dict(cls, d: dict) -> PendingShot:
86
+ return cls(
87
+ shot_id=d["shot_id"],
88
+ profile=d.get("profile", ""),
89
+ duration_ms=d.get("duration_ms", 0),
90
+ volume_g=d.get("volume_g", 0.0),
91
+ step=d.get("step", "r"),
92
+ answers=d.get("answers") or {},
93
+ )
94
+
95
+
96
+ class Conversation:
97
+ def __init__(
98
+ self,
99
+ messenger: Messenger,
100
+ state: State,
101
+ save_notes: Callable[[int, dict], Awaitable[bool]],
102
+ ) -> None:
103
+ self.messenger = messenger
104
+ self.state = state
105
+ self.save_notes = save_notes
106
+ self.pending: PendingShot | None = None
107
+ restored = state.get("pending")
108
+ if restored:
109
+ self.pending = PendingShot.from_dict(restored)
110
+ self._msg_ref: str | None = None
111
+
112
+ # ------------------------------------------------------------- shots
113
+
114
+ async def start_shot(
115
+ self, shot_id: int, profile: str, duration_ms: int, volume_g: float,
116
+ photo: bytes | None = None,
117
+ ) -> None:
118
+ if self.pending is not None:
119
+ await self._finish(superseded_by=shot_id)
120
+ self.pending = PendingShot(shot_id, profile, duration_ms, volume_g, answers={})
121
+ self._persist()
122
+ summary = (
123
+ f"☕ Shot #{shot_id} done!\n"
124
+ f"{profile} · {_fmt_duration(duration_ms)}"
125
+ + (f" · {volume_g:.1f} g in the cup" if volume_g else "")
126
+ + "\n\nLet's log it before you forget:"
127
+ )
128
+ if photo:
129
+ await self.messenger.send_photo(photo, summary)
130
+ else:
131
+ await self.messenger.send(summary)
132
+ await self._prompt()
133
+
134
+ async def resume_if_pending(self) -> None:
135
+ if self.pending is not None:
136
+ await self.messenger.send(
137
+ f"☕ Shot #{self.pending.shot_id} is still waiting for its log — "
138
+ "where were we?"
139
+ )
140
+ await self._prompt()
141
+
142
+ # ------------------------------------------------------------- events
143
+
144
+ async def handle_event(self, event: Event) -> None:
145
+ if self.pending is None:
146
+ return
147
+ if isinstance(event, OptionSelected):
148
+ await self._handle_option(event.option_id)
149
+ elif isinstance(event, TextReply):
150
+ await self._handle_text(event.text)
151
+
152
+ async def _handle_option(self, option_id: str) -> None:
153
+ try:
154
+ tag, shot, field, value = option_id.split("|", 3)
155
+ except ValueError:
156
+ return
157
+ if tag != "g" or self.pending is None:
158
+ return
159
+ if int(shot) != self.pending.shot_id or field != self.pending.step:
160
+ log.debug("stale option %s ignored", option_id)
161
+ return
162
+ if value == "skip":
163
+ await self._advance(None)
164
+ else:
165
+ await self._advance(value)
166
+
167
+ async def _handle_text(self, text: str) -> None:
168
+ if self.pending is None or self.pending.step not in TEXT_STEPS:
169
+ return
170
+ await self._advance(text.strip())
171
+
172
+ # ------------------------------------------------------------- steps
173
+
174
+ def _defaults(self) -> dict:
175
+ return self.state.get("last_notes", {})
176
+
177
+ def _options_for(self, step: str) -> list[Option]:
178
+ sid = self.pending.shot_id
179
+ mk = lambda field, value, label: Option(f"g|{sid}|{field}|{value}", label) # noqa: E731
180
+ if step == "r":
181
+ return [mk("r", str(n), "★" * n) for n in range(1, 6)]
182
+ if step == "bt":
183
+ return [
184
+ mk("bt", "sour", "🍋 sour"),
185
+ mk("bt", "balanced", "⚖️ balanced"),
186
+ mk("bt", "bitter", "🌰 bitter"),
187
+ mk("bt", "skip", "skip"),
188
+ ]
189
+ options = []
190
+ default = self._defaults().get(NOTE_KEYS[step])
191
+ if step == "dout" and self.pending.volume_g:
192
+ grams = f"{self.pending.volume_g:.1f}"
193
+ options.append(mk("dout", grams, f"Use {grams} g"))
194
+ elif default:
195
+ label = f"Same as last: {default}"
196
+ options.append(mk(step, default, label[:60]))
197
+ options.append(mk(step, "skip", "skip"))
198
+ return options
199
+
200
+ async def _prompt(self) -> None:
201
+ step = self.pending.step
202
+ text = PROMPTS[step]
203
+ if step in TEXT_STEPS and step != "txt":
204
+ text += " (tap or just type)"
205
+ self._msg_ref = await self.messenger.send(text, self._options_for(step))
206
+
207
+ async def _advance(self, value: str | None) -> None:
208
+ step = self.pending.step
209
+ if value:
210
+ self.pending.answers[NOTE_KEYS[step]] = value
211
+ idx = STEPS.index(step)
212
+ if idx + 1 < len(STEPS):
213
+ self.pending.step = STEPS[idx + 1]
214
+ self._persist()
215
+ await self._prompt()
216
+ else:
217
+ await self._finish()
218
+
219
+ # ------------------------------------------------------------- finish
220
+
221
+ async def _finish(self, superseded_by: int | None = None) -> None:
222
+ pending, self.pending = self.pending, None
223
+ self._persist()
224
+ answers = dict(pending.answers or {})
225
+ if "rating" in answers:
226
+ answers["rating"] = int(answers["rating"])
227
+ din, dout = answers.get("doseIn"), answers.get("doseOut")
228
+ try:
229
+ if din and dout and float(din) > 0:
230
+ answers["ratio"] = f"{float(dout) / float(din):.2f}"
231
+ except ValueError:
232
+ pass
233
+
234
+ if superseded_by is not None and not answers:
235
+ await self.messenger.send(
236
+ f"⏭ Shot #{pending.shot_id} skipped (new shot #{superseded_by})."
237
+ )
238
+ return
239
+
240
+ ok = await self.save_notes(pending.shot_id, answers) if answers else True
241
+ if ok:
242
+ # remember defaults for the next "same as last" buttons
243
+ last = self.state.get("last_notes", {})
244
+ remembered = ("beanType", "grindSetting", "doseIn")
245
+ last.update({k: v for k, v in answers.items() if k in remembered})
246
+ self.state.set("last_notes", last)
247
+ stars = "★" * int(answers.get("rating", 0))
248
+ suffix = f" (superseded by #{superseded_by})" if superseded_by else ""
249
+ ratio = f" · 1:{answers['ratio']}" if answers.get("ratio") else ""
250
+ from datetime import datetime
251
+
252
+ await self.messenger.send(
253
+ f"✅ Shot #{pending.shot_id} logged{suffix}. {stars}{ratio}\n"
254
+ f"Filed straight into your GaggiMate shot notes. {_signoff(datetime.now().hour)}"
255
+ )
256
+ else:
257
+ await self.messenger.send(
258
+ f"⚠️ Couldn't reach the machine to save notes for shot #{pending.shot_id}. "
259
+ "They'll appear once it's back online — or re-enter them in the web UI."
260
+ )
261
+
262
+ def _persist(self) -> None:
263
+ self.state.set("pending", self.pending.to_dict() if self.pending else None)
matebot/digest.py ADDED
@@ -0,0 +1,80 @@
1
+ """Weekly shot digest — a Sunday-evening summary, also available via /digest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import datetime, timedelta
8
+ from pathlib import Path
9
+
10
+ from .slog import ShotIndex
11
+
12
+ UTILITY_RE = re.compile(r"(?i)backflush|descale|flush|clean")
13
+
14
+
15
+ def rows_from_index(index: ShotIndex) -> list[dict]:
16
+ return [
17
+ {
18
+ "id": e.id,
19
+ "ts": e.timestamp,
20
+ "duration_ms": e.duration_ms,
21
+ "volume_g": e.volume_g,
22
+ "rating": e.rating,
23
+ "profile": e.profile_name,
24
+ }
25
+ for e in index.entries
26
+ if e.completed and not e.deleted
27
+ ]
28
+
29
+
30
+ def rows_from_site_index(path: str | Path) -> list[dict]:
31
+ data = json.loads(Path(path).read_text())
32
+ return [
33
+ {
34
+ "id": int(s["id"]),
35
+ "ts": s.get("ts", 0),
36
+ "duration_ms": int(s.get("duration_s", 0) * 1000),
37
+ "volume_g": s.get("final_g", 0),
38
+ "rating": s.get("rating", 0),
39
+ "profile": s.get("profile", ""),
40
+ }
41
+ for s in data.get("shots", [])
42
+ ]
43
+
44
+
45
+ def compute(rows: list[dict], *, now: datetime, journal_url: str = "") -> str | None:
46
+ """Digest text for the 7 days before *now*; None when there were no shots."""
47
+ since = (now - timedelta(days=7)).timestamp()
48
+ week = [
49
+ r for r in rows
50
+ if r["ts"] >= since and not UTILITY_RE.search(r["profile"] or "")
51
+ and r["duration_ms"] >= 10_000
52
+ ]
53
+ if not week:
54
+ return None
55
+
56
+ total_out = sum(r["volume_g"] or 0 for r in week)
57
+ rated = [r for r in week if r["rating"]]
58
+ lines = [f"📊 Your week in espresso: {len(week)} shots"]
59
+ if total_out:
60
+ lines[0] += f" · {total_out:.0f} g in the cup"
61
+ if rated:
62
+ avg = sum(r["rating"] for r in rated) / len(rated)
63
+ lines.append(f"Average rating {avg:.1f}★ ({len(rated)} rated)")
64
+ best = max(rated, key=lambda r: (r["rating"], r["id"]))
65
+ best_line = f"Best shot: #{best['id']} ({'★' * best['rating']})"
66
+ if journal_url:
67
+ best_line += f" {journal_url.rstrip('/')}/#{best['id']:06d}"
68
+ lines.append(best_line)
69
+ return "\n".join(lines)
70
+
71
+
72
+ def seconds_until_sunday_evening(now: datetime, hour: int = 18) -> float:
73
+ """Seconds until the next Sunday at *hour* local time (>= 60 s away)."""
74
+ days_ahead = (6 - now.weekday()) % 7
75
+ target = (now + timedelta(days=days_ahead)).replace(
76
+ hour=hour, minute=0, second=0, microsecond=0
77
+ )
78
+ if target <= now:
79
+ target += timedelta(days=7)
80
+ return max(60.0, (target - now).total_seconds())
matebot/hints.py ADDED
@@ -0,0 +1,53 @@
1
+ """Dial-in hints after a disappointing shot.
2
+
3
+ The suggestions follow the dial-in guide from modsmthng's Automatic Pro cheat
4
+ sheet (https://modsmthng.github.io/Automatic-Pro/v3/): adjust grind first,
5
+ then ratio, then temperature — one variable at a time; sour points to
6
+ under-extraction, bitter to over-extraction. Full credit to modsmthng.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ CREDIT = "Guide: modsmthng's Automatic Pro cheat sheet, modsmthng.github.io/Automatic-Pro"
12
+
13
+
14
+ def _ratio(notes: dict) -> float | None:
15
+ try:
16
+ value = float(notes.get("ratio", ""))
17
+ return value if value > 0 else None
18
+ except (TypeError, ValueError):
19
+ return None
20
+
21
+
22
+ def make_hint(notes: dict) -> str | None:
23
+ """A short suggestion for the next shot, or None when the shot was fine."""
24
+ taste = notes.get("balanceTaste")
25
+ rating = notes.get("rating") or 0
26
+ if taste not in ("sour", "bitter") and rating > 2:
27
+ return None
28
+ if taste not in ("sour", "bitter") and not rating:
29
+ return None
30
+
31
+ grind = notes.get("grindSetting")
32
+ ratio = _ratio(notes)
33
+ at_grind = f" (you're at {grind})" if grind else ""
34
+
35
+ if taste == "sour":
36
+ steps = [f"try a slightly finer grind{at_grind}"]
37
+ if ratio is not None and ratio < 2.0:
38
+ steps.append(f"or lengthen the ratio toward 1:2 (currently 1:{ratio:.2f})")
39
+ steps.append("or +1 °C if the grind is already dialed")
40
+ head = "Sour usually means under-extraction"
41
+ elif taste == "bitter":
42
+ steps = [f"try a slightly coarser grind{at_grind}"]
43
+ if ratio is not None and ratio > 2.0:
44
+ steps.append(f"or shorten the ratio toward 1:2 (currently 1:{ratio:.2f})")
45
+ steps.append("or −1 °C if the grind is already dialed")
46
+ head = "Bitter usually means over-extraction"
47
+ else: # low rating, no taste direction
48
+ return (
49
+ f"💡 For the next one: adjust the grind first{at_grind}, then ratio, "
50
+ f"then temperature — one variable at a time.\n({CREDIT})"
51
+ )
52
+
53
+ return f"💡 {head} — {steps[0]}, {'; '.join(steps[1:])}. One variable at a time.\n({CREDIT})"
matebot/machine.py ADDED
@@ -0,0 +1,195 @@
1
+ """Async client for the GaggiMate controller (WebSocket + HTTP).
2
+
3
+ Connection notes (learned from firmware source, do not "simplify" away):
4
+
5
+ * The ESP32's AsyncWebSocket closes clients whenever its send queue fills
6
+ (e.g. someone opens the web UI stats page), and status frames only flow
7
+ while a client is connected. We therefore treat 15 s of silence as a dead
8
+ socket and reconnect with capped exponential backoff.
9
+ * Missing files under ``/api/history/`` are served as HTTP 200 with the
10
+ gzipped SPA ``index.html`` (catch-all route) — every download must be
11
+ content-validated, never trusted by status code.
12
+ * ``req:history:notes:save`` id semantics: the request-level ``id`` is used
13
+ verbatim as the notes filename. Which name the web UI *reads back* differs
14
+ by firmware build (older builds use the 6-digit padded id, current nightlies
15
+ the unpadded one), so notes are saved under BOTH names. ``rating`` is a
16
+ number; all other note values must be strings or the firmware silently
17
+ ignores them.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import contextlib
24
+ import json
25
+ import logging
26
+ import random
27
+ import uuid
28
+ from collections.abc import AsyncIterator
29
+ from typing import Any
30
+
31
+ import aiohttp
32
+
33
+ from .slog import ShotIndex, is_slog, parse_index
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+ REDACTED_SETTINGS = ("wifiSsid", "wifiPassword", "apPassword", "haPassword")
38
+
39
+
40
+ class MachineError(RuntimeError):
41
+ pass
42
+
43
+
44
+ class GaggiMateClient:
45
+ def __init__(self, host: str, *, request_timeout: float = 15.0) -> None:
46
+ self.host = host
47
+ self.base = f"http://{host}"
48
+ self.ws_url = f"ws://{host}/ws"
49
+ self._timeout = aiohttp.ClientTimeout(total=request_timeout)
50
+ self._session: aiohttp.ClientSession | None = None
51
+ self._ws: aiohttp.ClientWebSocketResponse | None = None
52
+ self._pending: dict[str, asyncio.Future] = {}
53
+
54
+ async def __aenter__(self) -> GaggiMateClient:
55
+ self._session = aiohttp.ClientSession(timeout=self._timeout)
56
+ return self
57
+
58
+ async def __aexit__(self, *exc) -> None:
59
+ await self.close()
60
+
61
+ async def close(self) -> None:
62
+ if self._ws is not None and not self._ws.closed:
63
+ await self._ws.close()
64
+ if self._session is not None:
65
+ await self._session.close()
66
+ self._ws = None
67
+
68
+ @property
69
+ def session(self) -> aiohttp.ClientSession:
70
+ if self._session is None:
71
+ raise MachineError("client not started (use 'async with')")
72
+ return self._session
73
+
74
+ # ------------------------------------------------------------------ WS
75
+
76
+ async def status_stream(
77
+ self, *, liveness_timeout: float = 15.0, max_backoff: float = 60.0
78
+ ) -> AsyncIterator[dict]:
79
+ """Yield every WS message as a dict, reconnecting forever.
80
+
81
+ Also resolves rid-correlated ``request()`` futures as responses come in.
82
+ """
83
+ backoff = 1.0
84
+ while True:
85
+ try:
86
+ async with self.session.ws_connect(self.ws_url, heartbeat=None) as ws:
87
+ self._ws = ws
88
+ log.info("connected to %s", self.ws_url)
89
+ backoff = 1.0
90
+ while True:
91
+ msg = await ws.receive(timeout=liveness_timeout)
92
+ if msg.type != aiohttp.WSMsgType.TEXT:
93
+ raise MachineError(f"ws closed ({msg.type.name})")
94
+ try:
95
+ data = json.loads(msg.data)
96
+ except json.JSONDecodeError:
97
+ continue
98
+ rid = data.get("rid")
99
+ if rid and rid in self._pending:
100
+ self._pending.pop(rid).set_result(data)
101
+ yield data
102
+ except asyncio.CancelledError:
103
+ raise
104
+ except Exception as exc: # noqa: BLE001 - deliberate: never crash-loop
105
+ log.warning("ws connection lost (%s); retry in %.0fs", exc, backoff)
106
+ finally:
107
+ self._ws = None
108
+ for fut in self._pending.values():
109
+ if not fut.done():
110
+ fut.set_exception(MachineError("websocket dropped"))
111
+ self._pending.clear()
112
+ await asyncio.sleep(backoff + random.uniform(0, backoff / 4))
113
+ backoff = min(backoff * 2, max_backoff)
114
+
115
+ async def request(self, tp: str, *, timeout: float = 20.0, **fields: Any) -> dict:
116
+ """Send a rid-correlated request over the live WS connection."""
117
+ if self._ws is None or self._ws.closed:
118
+ raise MachineError("websocket not connected")
119
+ rid = f"gb-{uuid.uuid4().hex[:10]}"
120
+ fut: asyncio.Future = asyncio.get_running_loop().create_future()
121
+ self._pending[rid] = fut
122
+ try:
123
+ await self._ws.send_str(json.dumps({"tp": tp, "rid": rid, **fields}))
124
+ return await asyncio.wait_for(fut, timeout)
125
+ finally:
126
+ self._pending.pop(rid, None)
127
+
128
+ async def send_event(self, tp: str, **fields: Any) -> None:
129
+ """Fire-and-forget message for requests the firmware never answers
130
+ (e.g. ``req:change-mode`` — it acts but sends no response)."""
131
+ if self._ws is None or self._ws.closed:
132
+ raise MachineError("the machine looks powered off")
133
+ await self._ws.send_str(json.dumps({"tp": tp, **fields}))
134
+
135
+ async def notes_save(self, shot_id: int, notes: dict[str, Any]) -> dict:
136
+ """Write shot notes; lands in the web UI's "Shot Notes" panel."""
137
+ payload = {"id": str(shot_id)} # unpadded inside the payload
138
+ for key, value in notes.items():
139
+ if value is None:
140
+ continue
141
+ payload[key] = value if key == "rating" else str(value)
142
+ # unpadded first (what current nightlies read), padded as a fallback
143
+ # for older builds — the double index update is idempotent
144
+ resp = await self.request(
145
+ "req:history:notes:save", id=str(shot_id), notes=payload
146
+ )
147
+ with contextlib.suppress(Exception):
148
+ await self.request(
149
+ "req:history:notes:save", id=f"{shot_id:06d}", notes=payload
150
+ )
151
+ return resp
152
+
153
+ async def profiles_list(self) -> list[dict]:
154
+ resp = await self.request("req:profiles:list", timeout=30.0)
155
+ return resp.get("profiles", [])
156
+
157
+ # ---------------------------------------------------------------- HTTP
158
+
159
+ async def _get(self, path: str) -> bytes:
160
+ async with self.session.get(f"{self.base}{path}") as resp:
161
+ resp.raise_for_status()
162
+ return await resp.read()
163
+
164
+ async def fetch_index(self) -> ShotIndex:
165
+ return parse_index(await self._get("/api/history/index.bin"))
166
+
167
+ async def fetch_slog(self, shot_id: int) -> bytes:
168
+ data = await self._get(f"/api/history/{shot_id:06d}.slog")
169
+ if not is_slog(data):
170
+ raise MachineError(f"shot {shot_id:06d}.slog missing (got SPA fallback)")
171
+ return data
172
+
173
+ async def fetch_notes(self, shot_id: int) -> dict | None:
174
+ """Notes json, or None if absent. Tries padded then unpadded filename."""
175
+ for name in (f"{shot_id:06d}.json", f"{shot_id}.json"):
176
+ try:
177
+ data = await self._get(f"/api/history/{name}")
178
+ except aiohttp.ClientResponseError:
179
+ continue
180
+ text = data.lstrip()[:1]
181
+ if text == b"{":
182
+ with contextlib.suppress(json.JSONDecodeError):
183
+ return json.loads(data)
184
+ return None
185
+
186
+ async def fetch_status(self) -> dict:
187
+ return json.loads(await self._get("/api/status"))
188
+
189
+ async def fetch_settings(self, *, redact: bool = True) -> dict:
190
+ settings = json.loads(await self._get("/api/settings"))
191
+ if redact:
192
+ for key in REDACTED_SETTINGS:
193
+ if settings.get(key):
194
+ settings[key] = "<REDACTED>"
195
+ return settings