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/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
|
matebot/conversation.py
ADDED
|
@@ -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
|