diff --git a/flake.nix b/flake.nix index 5ef8d61..2e689b7 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,7 @@ build-system = [ pkgs.python3Packages.hatchling ]; dependencies = with pkgs.python3Packages; [ aiohttp + matplotlib python-telegram-bot # discord.py is optional at runtime; add it when using the discord messenger ] ++ pkgs.lib.optional (pkgs.python3Packages ? discordpy) pkgs.python3Packages.discordpy; diff --git a/pyproject.toml b/pyproject.toml index 6c34cf0..52295e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,8 @@ dependencies = ["aiohttp>=3.9"] telegram = ["python-telegram-bot>=21,<23"] discord = ["discord.py>=2.3"] matrix = ["matrix-nio>=0.24"] -all = ["matebot[telegram,discord,matrix]"] +plots = ["matplotlib>=3.8"] +all = ["matebot[telegram,discord,matrix,plots]"] dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.4"] [project.urls] diff --git a/src/matebot/cli.py b/src/matebot/cli.py index 9d6802f..ef2d9e5 100644 --- a/src/matebot/cli.py +++ b/src/matebot/cli.py @@ -214,12 +214,25 @@ async def on_utility(profile): "volume_g": shot.entry.volume_g, }, ) + photo = None + if config.plots_enabled: + try: + from .plot import render_shot_png + from .slog import parse_slog + + parsed = parse_slog(await client.fetch_slog(shot.entry.id)) + photo = render_shot_png( + parsed, title=f"Shot #{shot.entry.id} — {parsed.profile_name}" + ) + except Exception as exc: # noqa: BLE001 - photo is a nice-to-have + log.info("shot plot skipped: %s", exc) try: await convo.start_shot( shot.entry.id, shot.profile_label or shot.entry.profile_name, shot.duration_ms, shot.entry.volume_g, + photo=photo, ) except Exception: # noqa: BLE001 - keep watching even if messaging fails log.exception("questionnaire start failed (state kept for resume)") diff --git a/src/matebot/config.py b/src/matebot/config.py index b12dffe..a183ebf 100644 --- a/src/matebot/config.py +++ b/src/matebot/config.py @@ -29,6 +29,7 @@ "hints_enabled": "MATEBOT_HINTS", "digest_enabled": "MATEBOT_DIGEST", "clean_every": "MATEBOT_CLEAN_EVERY", + "plots_enabled": "MATEBOT_PLOTS", } @@ -55,6 +56,7 @@ class Config: hints_enabled: bool = True # dial-in suggestions after sour/bitter/low-rated shots digest_enabled: bool = True # weekly summary on Sunday evening clean_every: int = 40 # backflush reminder every N espresso shots; 0 = off + plots_enabled: bool = True # send the shot chart as a photo (needs matplotlib) @classmethod def load(cls, path: str | Path | None = None) -> Config: @@ -74,7 +76,7 @@ def load(cls, path: str | Path | None = None) -> Config: value = float(value) elif f.name == "clean_every": value = int(value) - elif f.name in ("sync_enabled", "hints_enabled", "digest_enabled"): + elif f.name in ("sync_enabled", "hints_enabled", "digest_enabled", "plots_enabled"): value = str(value).lower() in ("1", "true", "yes", "on") kwargs[f.name] = value config = cls(**kwargs) diff --git a/src/matebot/conversation.py b/src/matebot/conversation.py index 6a26835..0948d3a 100644 --- a/src/matebot/conversation.py +++ b/src/matebot/conversation.py @@ -112,7 +112,8 @@ def __init__( # ------------------------------------------------------------- shots async def start_shot( - self, shot_id: int, profile: str, duration_ms: int, volume_g: float + self, shot_id: int, profile: str, duration_ms: int, volume_g: float, + photo: bytes | None = None, ) -> None: if self.pending is not None: await self._finish(superseded_by=shot_id) @@ -124,7 +125,10 @@ async def start_shot( + (f" · {volume_g:.1f} g in the cup" if volume_g else "") + "\n\nLet's log it before you forget:" ) - await self.messenger.send(summary) + if photo: + await self.messenger.send_photo(photo, summary) + else: + await self.messenger.send(summary) await self._prompt() async def resume_if_pending(self) -> None: diff --git a/src/matebot/messengers/base.py b/src/matebot/messengers/base.py index 20e42b5..2b29756 100644 --- a/src/matebot/messengers/base.py +++ b/src/matebot/messengers/base.py @@ -48,6 +48,11 @@ async def send(self, text: str, options: list[Option] | None = None) -> str: async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None: """Replace a previously sent message's text/options (best effort).""" + async def send_photo(self, image: bytes, caption: str) -> str: + """Send an image with caption; backends without photo support fall + back to the caption text.""" + return await self.send(caption) + @abstractmethod def events(self) -> AsyncIterator[Event]: """User interactions, in order. Runs until stop().""" diff --git a/src/matebot/messengers/discord.py b/src/matebot/messengers/discord.py index 837f686..cd4b62b 100644 --- a/src/matebot/messengers/discord.py +++ b/src/matebot/messengers/discord.py @@ -66,6 +66,15 @@ async def send(self, text: str, options: list[Option] | None = None) -> str: msg = await channel.send(text, view=self._view(options)) return str(msg.id) + async def send_photo(self, image: bytes, caption: str) -> str: + import io as _io + + import discord + + channel = self.client.get_channel(self.channel_id) + msg = await channel.send(caption, file=discord.File(_io.BytesIO(image), "shot.png")) + return str(msg.id) + async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None: try: channel = self.client.get_channel(self.channel_id) diff --git a/src/matebot/messengers/telegram.py b/src/matebot/messengers/telegram.py index cd98b8e..162d10f 100644 --- a/src/matebot/messengers/telegram.py +++ b/src/matebot/messengers/telegram.py @@ -92,6 +92,18 @@ async def send(self, text: str, options: list[Option] | None = None) -> str: await asyncio.sleep(2 * (attempt + 1)) raise last_exc + async def send_photo(self, image: bytes, caption: str) -> str: + last_exc = None + for attempt in range(5): + try: + msg = await self.app.bot.send_photo(self.chat_id, photo=image, caption=caption) + return str(msg.message_id) + except Exception as exc: # noqa: BLE001 + last_exc = exc + log.warning("photo send attempt %d failed: %s", attempt + 1, exc) + await asyncio.sleep(2 * (attempt + 1)) + raise last_exc + async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None: try: await self.app.bot.edit_message_text( diff --git a/src/matebot/plot.py b/src/matebot/plot.py new file mode 100644 index 0000000..df022d3 --- /dev/null +++ b/src/matebot/plot.py @@ -0,0 +1,91 @@ +"""Render a shot as a PNG chart (sent as the post-shot photo). + +Same visual language as the journal and the GaggiMate web UI: temperature on +the left axis, pressure/flow on the right, weight on its own scale, dashed +targets, phase markers. matplotlib is an optional dependency (``[plots]`` +extra) — callers fall back to a text summary when it is missing. +""" + +from __future__ import annotations + +import io + +from .slog import Shot + +COLORS = { + "temp": "#e64a19", + "press": "#1e88e5", + "flow": "#43a047", + "puck": "#1b5e20", + "weight": "#8e24aa", +} + + +def render_shot_png(shot: Shot, *, title: str | None = None) -> bytes: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + t = shot.times_s + s = shot.series + have = lambda k: k in s and any(s[k]) # noqa: E731 + + fig, ax_temp = plt.subplots(figsize=(8, 4.5), dpi=110) + ax_bar = ax_temp.twinx() + + if have("ct"): + ax_temp.plot(t, s["ct"], color=COLORS["temp"], lw=1.8, label="Temp") + if have("tt"): + ax_temp.plot(t, s["tt"], color=COLORS["temp"], lw=1.1, ls="--", alpha=0.7) + if have("cp"): + ax_bar.plot(t, s["cp"], color=COLORS["press"], lw=2.0, label="Pressure") + if have("tp"): + ax_bar.plot(t, s["tp"], color=COLORS["press"], lw=1.1, ls="--", alpha=0.7) + if have("fl"): + ax_bar.plot(t, s["fl"], color=COLORS["flow"], lw=1.5, label="Pump flow") + if have("pf"): + ax_bar.plot(t, s["pf"], color=COLORS["puck"], lw=1.5, label="Puck flow") + + weight = s.get("v") if have("v") else s.get("ev") if have("ev") else None + if weight: + ax_g = ax_temp.twinx() + ax_g.spines.right.set_position(("axes", 1.12)) + ax_g.plot(t, weight, color=COLORS["weight"], lw=1.8, label="Weight") + ax_g.set_ylabel("g", color=COLORS["weight"]) + ax_g.set_ylim(bottom=0) + ax_g.tick_params(axis="y", colors=COLORS["weight"], labelsize=8) + + for phase in shot.phases: + px = phase.sample_index * shot.sample_interval_ms / 1000 + if 0.3 < px < (t[-1] if t else 0) - 0.3: + ax_temp.axvline(px, color="#999", lw=0.7, ls=":", alpha=0.7) + ax_temp.annotate( + phase.name[:18], (px, 1.0), xycoords=("data", "axes fraction"), + rotation=90, va="top", ha="right", fontsize=6.5, color="#777", + ) + + ax_temp.set_xlabel("s", fontsize=8) + ax_temp.set_ylabel("°C", color=COLORS["temp"], fontsize=9) + ax_temp.tick_params(labelsize=8) + ax_temp.tick_params(axis="y", colors=COLORS["temp"]) + ax_bar.set_ylabel("bar · g/s", color=COLORS["press"], fontsize=9) + ax_bar.tick_params(axis="y", colors=COLORS["press"], labelsize=8) + ax_bar.set_ylim(bottom=0) + ax_temp.grid(True, axis="y", lw=0.3, alpha=0.4) + if title: + ax_temp.set_title(title, fontsize=10) + + handles, labels = [], [] + for ax in fig.axes: + h, lab = ax.get_legend_handles_labels() + handles += h + labels += lab + fig.legend(handles, labels, loc="lower center", ncol=len(labels), + bbox_to_anchor=(0.5, 0.0), fontsize=7.5, frameon=False) + + fig.tight_layout(rect=(0, 0.08, 1, 1)) + buf = io.BytesIO() + fig.savefig(buf, format="png") + plt.close(fig) + return buf.getvalue() diff --git a/tests/test_plot.py b/tests/test_plot.py new file mode 100644 index 0000000..a32b7a9 --- /dev/null +++ b/tests/test_plot.py @@ -0,0 +1,17 @@ +import pathlib + +import pytest + +pytest.importorskip("matplotlib") + +from matebot.plot import render_shot_png # noqa: E402 +from matebot.slog import parse_slog # noqa: E402 + +GOLDEN = pathlib.Path(__file__).parent / "fixtures" / "000004.slog" + + +def test_render_png_from_golden_shot(): + shot = parse_slog(GOLDEN.read_bytes()) + png = render_shot_png(shot, title="Shot #4 — Adaptive v2") + assert png[:8] == b"\x89PNG\r\n\x1a\n" + assert len(png) > 20_000 # an actual chart, not an empty canvas