Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 13 additions & 0 deletions src/matebot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
4 changes: 3 additions & 1 deletion src/matebot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"hints_enabled": "MATEBOT_HINTS",
"digest_enabled": "MATEBOT_DIGEST",
"clean_every": "MATEBOT_CLEAN_EVERY",
"plots_enabled": "MATEBOT_PLOTS",
}


Expand All @@ -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:
Expand All @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions src/matebot/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions src/matebot/messengers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()."""
9 changes: 9 additions & 0 deletions src/matebot/messengers/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions src/matebot/messengers/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
91 changes: 91 additions & 0 deletions src/matebot/plot.py
Original file line number Diff line number Diff line change
@@ -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()
17 changes: 17 additions & 0 deletions tests/test_plot.py
Original file line number Diff line number Diff line change
@@ -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
Loading