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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .base import Event, Messenger, Option, OptionSelected, TextReply
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def create_messenger(config) -> Messenger:
|
|
5
|
+
"""Instantiate the configured backend (imports lazily: extras are optional)."""
|
|
6
|
+
kind = config.messenger
|
|
7
|
+
if kind == "telegram":
|
|
8
|
+
from .telegram import TelegramMessenger
|
|
9
|
+
|
|
10
|
+
return TelegramMessenger(config.telegram_token, config.telegram_chat_id)
|
|
11
|
+
if kind == "discord":
|
|
12
|
+
from .discord import DiscordMessenger
|
|
13
|
+
|
|
14
|
+
return DiscordMessenger(config.discord_token, config.discord_channel_id)
|
|
15
|
+
raise ValueError(
|
|
16
|
+
f"unknown messenger {kind!r} (supported: telegram, discord; "
|
|
17
|
+
"matrix is planned — see README for WhatsApp options)"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Event",
|
|
23
|
+
"Messenger",
|
|
24
|
+
"Option",
|
|
25
|
+
"OptionSelected",
|
|
26
|
+
"TextReply",
|
|
27
|
+
"create_messenger",
|
|
28
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Messenger abstraction: everything the conversation engine needs, nothing more.
|
|
2
|
+
|
|
3
|
+
A backend renders prompts (text + option buttons where the platform has them),
|
|
4
|
+
and yields user interactions as a single event stream. Option ids stay ≤64
|
|
5
|
+
bytes so they fit Telegram ``callback_data`` and Discord ``custom_id``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Option:
|
|
17
|
+
id: str # e.g. "g|59|r|4"
|
|
18
|
+
label: str # e.g. "★★★★"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class OptionSelected:
|
|
23
|
+
option_id: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TextReply:
|
|
28
|
+
text: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
Event = OptionSelected | TextReply
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Messenger(ABC):
|
|
35
|
+
"""One chat/channel with one user; the espresso machine's voice."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def start(self) -> None: ...
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def stop(self) -> None: ...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def send(self, text: str, options: list[Option] | None = None) -> str:
|
|
45
|
+
"""Send a message, return an opaque reference usable with edit()."""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None:
|
|
49
|
+
"""Replace a previously sent message's text/options (best effort)."""
|
|
50
|
+
|
|
51
|
+
async def send_photo(self, image: bytes, caption: str) -> str:
|
|
52
|
+
"""Send an image with caption; backends without photo support fall
|
|
53
|
+
back to the caption text."""
|
|
54
|
+
return await self.send(caption)
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def events(self) -> AsyncIterator[Event]:
|
|
58
|
+
"""User interactions, in order. Runs until stop()."""
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Discord backend (discord.py v2: gateway websocket + button Views)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
|
|
9
|
+
from .base import Event, Messenger, Option, OptionSelected, TextReply
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DiscordMessenger(Messenger):
|
|
15
|
+
def __init__(self, token: str, channel_id: int | str) -> None:
|
|
16
|
+
import discord
|
|
17
|
+
|
|
18
|
+
self.token = token
|
|
19
|
+
self.channel_id = int(channel_id)
|
|
20
|
+
self._queue: asyncio.Queue[Event] = asyncio.Queue()
|
|
21
|
+
self._ready = asyncio.Event()
|
|
22
|
+
self._task: asyncio.Task | None = None
|
|
23
|
+
|
|
24
|
+
intents = discord.Intents.default()
|
|
25
|
+
intents.message_content = True
|
|
26
|
+
self.client = discord.Client(intents=intents)
|
|
27
|
+
|
|
28
|
+
@self.client.event
|
|
29
|
+
async def on_ready():
|
|
30
|
+
self._ready.set()
|
|
31
|
+
log.info("discord messenger ready in channel %s", self.channel_id)
|
|
32
|
+
|
|
33
|
+
@self.client.event
|
|
34
|
+
async def on_message(message):
|
|
35
|
+
if message.author == self.client.user or message.channel.id != self.channel_id:
|
|
36
|
+
return
|
|
37
|
+
await self._queue.put(TextReply(message.content))
|
|
38
|
+
|
|
39
|
+
@self.client.event
|
|
40
|
+
async def on_interaction(interaction):
|
|
41
|
+
if interaction.type == discord.InteractionType.component:
|
|
42
|
+
await interaction.response.defer()
|
|
43
|
+
await self._queue.put(OptionSelected(interaction.data["custom_id"]))
|
|
44
|
+
|
|
45
|
+
async def start(self) -> None:
|
|
46
|
+
self._task = asyncio.create_task(self.client.start(self.token))
|
|
47
|
+
await asyncio.wait_for(self._ready.wait(), timeout=60)
|
|
48
|
+
|
|
49
|
+
async def stop(self) -> None:
|
|
50
|
+
await self.client.close()
|
|
51
|
+
if self._task:
|
|
52
|
+
self._task.cancel()
|
|
53
|
+
|
|
54
|
+
def _view(self, options: list[Option] | None):
|
|
55
|
+
if not options:
|
|
56
|
+
return None
|
|
57
|
+
import discord
|
|
58
|
+
|
|
59
|
+
view = discord.ui.View(timeout=None)
|
|
60
|
+
for o in options:
|
|
61
|
+
view.add_item(discord.ui.Button(label=o.label, custom_id=o.id))
|
|
62
|
+
return view
|
|
63
|
+
|
|
64
|
+
async def send(self, text: str, options: list[Option] | None = None) -> str:
|
|
65
|
+
channel = self.client.get_channel(self.channel_id)
|
|
66
|
+
msg = await channel.send(text, view=self._view(options))
|
|
67
|
+
return str(msg.id)
|
|
68
|
+
|
|
69
|
+
async def send_photo(self, image: bytes, caption: str) -> str:
|
|
70
|
+
import io as _io
|
|
71
|
+
|
|
72
|
+
import discord
|
|
73
|
+
|
|
74
|
+
channel = self.client.get_channel(self.channel_id)
|
|
75
|
+
msg = await channel.send(caption, file=discord.File(_io.BytesIO(image), "shot.png"))
|
|
76
|
+
return str(msg.id)
|
|
77
|
+
|
|
78
|
+
async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None:
|
|
79
|
+
try:
|
|
80
|
+
channel = self.client.get_channel(self.channel_id)
|
|
81
|
+
msg = await channel.fetch_message(int(ref))
|
|
82
|
+
await msg.edit(content=text, view=self._view(options))
|
|
83
|
+
except Exception as exc: # noqa: BLE001 - edits are cosmetic
|
|
84
|
+
log.debug("edit failed: %s", exc)
|
|
85
|
+
|
|
86
|
+
async def events(self) -> AsyncIterator[Event]:
|
|
87
|
+
while True:
|
|
88
|
+
yield await self._queue.get()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Telegram backend (python-telegram-bot v20+ async API, long-polling)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
|
|
10
|
+
from .base import Event, Messenger, Option, OptionSelected, TextReply
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TelegramMessenger(Messenger):
|
|
16
|
+
def __init__(self, token: str, chat_id: int | str) -> None:
|
|
17
|
+
from telegram.ext import (
|
|
18
|
+
Application,
|
|
19
|
+
CallbackQueryHandler,
|
|
20
|
+
MessageHandler,
|
|
21
|
+
filters,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
self.chat_id = int(chat_id)
|
|
25
|
+
self._queue: asyncio.Queue[Event] = asyncio.Queue()
|
|
26
|
+
self.app = Application.builder().token(token).build()
|
|
27
|
+
self.app.add_handler(CallbackQueryHandler(self._on_callback))
|
|
28
|
+
self.app.add_handler(
|
|
29
|
+
MessageHandler(filters.TEXT & filters.Chat(self.chat_id), self._on_text)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
async def _on_callback(self, update, context) -> None:
|
|
33
|
+
query = update.callback_query
|
|
34
|
+
await query.answer()
|
|
35
|
+
if query.message and query.message.chat_id != self.chat_id:
|
|
36
|
+
return
|
|
37
|
+
await self._queue.put(OptionSelected(query.data))
|
|
38
|
+
|
|
39
|
+
async def _on_text(self, update, context) -> None:
|
|
40
|
+
await self._queue.put(TextReply(update.message.text))
|
|
41
|
+
|
|
42
|
+
async def start(self) -> None:
|
|
43
|
+
from telegram import BotCommand
|
|
44
|
+
|
|
45
|
+
await self.app.initialize()
|
|
46
|
+
await self.app.start()
|
|
47
|
+
with contextlib.suppress(Exception): # menu is cosmetic
|
|
48
|
+
await self.app.bot.set_my_commands([
|
|
49
|
+
BotCommand("wake", "turn the machine on, ping when hot"),
|
|
50
|
+
BotCommand("sleep", "machine to standby"),
|
|
51
|
+
BotCommand("status", "mode, temperature, connectivity"),
|
|
52
|
+
BotCommand("last", "last logged shot"),
|
|
53
|
+
BotCommand("fix", "redo the last shot's log"),
|
|
54
|
+
BotCommand("bag", "how much is left in the bean bag"),
|
|
55
|
+
BotCommand("newbag", "start tracking a bean bag"),
|
|
56
|
+
BotCommand("help", "list commands"),
|
|
57
|
+
])
|
|
58
|
+
await self.app.updater.start_polling(drop_pending_updates=True)
|
|
59
|
+
log.info("telegram messenger polling as chat %s", self.chat_id)
|
|
60
|
+
|
|
61
|
+
async def stop(self) -> None:
|
|
62
|
+
await self.app.updater.stop()
|
|
63
|
+
await self.app.stop()
|
|
64
|
+
await self.app.shutdown()
|
|
65
|
+
|
|
66
|
+
def _keyboard(self, options: list[Option] | None):
|
|
67
|
+
if not options:
|
|
68
|
+
return None
|
|
69
|
+
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
|
70
|
+
|
|
71
|
+
buttons = [InlineKeyboardButton(o.label, callback_data=o.id) for o in options]
|
|
72
|
+
# ratings in rows of three (a single row of five truncates on phones),
|
|
73
|
+
# everything else stacked
|
|
74
|
+
if all(o.id.split("|")[2] == "r" for o in options):
|
|
75
|
+
rows = [buttons[i : i + 3] for i in range(0, len(buttons), 3)]
|
|
76
|
+
else:
|
|
77
|
+
rows = [[b] for b in buttons]
|
|
78
|
+
return InlineKeyboardMarkup(rows)
|
|
79
|
+
|
|
80
|
+
async def send(self, text: str, options: list[Option] | None = None) -> str:
|
|
81
|
+
# telegram API connectivity can flap; a lost send must not crash the bot
|
|
82
|
+
last_exc = None
|
|
83
|
+
for attempt in range(5):
|
|
84
|
+
try:
|
|
85
|
+
msg = await self.app.bot.send_message(
|
|
86
|
+
self.chat_id, text, reply_markup=self._keyboard(options)
|
|
87
|
+
)
|
|
88
|
+
return str(msg.message_id)
|
|
89
|
+
except Exception as exc: # noqa: BLE001 - NetworkError et al.
|
|
90
|
+
last_exc = exc
|
|
91
|
+
log.warning("send attempt %d failed: %s", attempt + 1, exc)
|
|
92
|
+
await asyncio.sleep(2 * (attempt + 1))
|
|
93
|
+
raise last_exc
|
|
94
|
+
|
|
95
|
+
async def send_photo(self, image: bytes, caption: str) -> str:
|
|
96
|
+
last_exc = None
|
|
97
|
+
for attempt in range(5):
|
|
98
|
+
try:
|
|
99
|
+
msg = await self.app.bot.send_photo(self.chat_id, photo=image, caption=caption)
|
|
100
|
+
return str(msg.message_id)
|
|
101
|
+
except Exception as exc: # noqa: BLE001
|
|
102
|
+
last_exc = exc
|
|
103
|
+
log.warning("photo send attempt %d failed: %s", attempt + 1, exc)
|
|
104
|
+
await asyncio.sleep(2 * (attempt + 1))
|
|
105
|
+
raise last_exc
|
|
106
|
+
|
|
107
|
+
async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None:
|
|
108
|
+
try:
|
|
109
|
+
await self.app.bot.edit_message_text(
|
|
110
|
+
text, chat_id=self.chat_id, message_id=int(ref),
|
|
111
|
+
reply_markup=self._keyboard(options),
|
|
112
|
+
)
|
|
113
|
+
except Exception as exc: # noqa: BLE001 - edits are cosmetic
|
|
114
|
+
log.debug("edit failed: %s", exc)
|
|
115
|
+
|
|
116
|
+
async def events(self) -> AsyncIterator[Event]:
|
|
117
|
+
while True:
|
|
118
|
+
yield await self._queue.get()
|
matebot/plot.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Render a shot as a PNG chart (sent as the post-shot photo).
|
|
2
|
+
|
|
3
|
+
Same visual language as the journal and the GaggiMate web UI: temperature on
|
|
4
|
+
the left axis, pressure/flow on the right, weight on its own scale, dashed
|
|
5
|
+
targets, phase markers. matplotlib is an optional dependency (``[plots]``
|
|
6
|
+
extra) — callers fall back to a text summary when it is missing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
|
|
13
|
+
from .slog import Shot
|
|
14
|
+
|
|
15
|
+
# Same palette as the GaggiMate web UI shot chart (design match, no code copied)
|
|
16
|
+
COLORS = {
|
|
17
|
+
"temp": "#F0561D",
|
|
18
|
+
"target_temp": "#731F00",
|
|
19
|
+
"press": "#0066CC",
|
|
20
|
+
"flow": "#63993D",
|
|
21
|
+
"puck": "#204D00",
|
|
22
|
+
"weight": "#8B5CF6",
|
|
23
|
+
"wflow": "#4b2e8d",
|
|
24
|
+
"phase": "#6B7280",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_shot_png(shot: Shot, *, title: str | None = None) -> bytes:
|
|
29
|
+
import matplotlib
|
|
30
|
+
|
|
31
|
+
matplotlib.use("Agg")
|
|
32
|
+
import matplotlib.pyplot as plt
|
|
33
|
+
|
|
34
|
+
t = shot.times_s
|
|
35
|
+
s = shot.series
|
|
36
|
+
have = lambda k: k in s and any(s[k]) # noqa: E731
|
|
37
|
+
|
|
38
|
+
fig, ax_temp = plt.subplots(figsize=(8, 4.5), dpi=110)
|
|
39
|
+
ax_bar = ax_temp.twinx()
|
|
40
|
+
|
|
41
|
+
dash = (0, (4, 4))
|
|
42
|
+
if have("ct"):
|
|
43
|
+
ax_temp.plot(t, s["ct"], color=COLORS["temp"], lw=2.2, label="Temp")
|
|
44
|
+
if have("tt"):
|
|
45
|
+
ax_temp.plot(t, s["tt"], color=COLORS["target_temp"], lw=1.4, ls=dash)
|
|
46
|
+
if have("cp"):
|
|
47
|
+
ax_bar.plot(t, s["cp"], color=COLORS["press"], lw=2.2, label="Pressure")
|
|
48
|
+
if have("tp"):
|
|
49
|
+
ax_bar.plot(t, s["tp"], color=COLORS["press"], lw=1.4, ls=dash, alpha=0.8)
|
|
50
|
+
if have("fl"):
|
|
51
|
+
ax_bar.plot(t, s["fl"], color=COLORS["flow"], lw=1.8, label="Pump flow")
|
|
52
|
+
if have("tf"):
|
|
53
|
+
ax_bar.plot(t, s["tf"], color=COLORS["flow"], lw=1.4, ls=dash, alpha=0.8)
|
|
54
|
+
if have("pf"):
|
|
55
|
+
ax_bar.plot(t, s["pf"], color=COLORS["puck"], lw=1.8, label="Puck flow")
|
|
56
|
+
|
|
57
|
+
weight = s.get("v") if have("v") else s.get("ev") if have("ev") else None
|
|
58
|
+
if weight:
|
|
59
|
+
ax_g = ax_temp.twinx()
|
|
60
|
+
ax_g.spines.right.set_position(("axes", 1.12))
|
|
61
|
+
ax_g.plot(t, weight, color=COLORS["weight"], lw=2.2, label="Weight")
|
|
62
|
+
ax_g.set_ylabel("g", color=COLORS["weight"])
|
|
63
|
+
ax_g.set_ylim(bottom=0)
|
|
64
|
+
ax_g.tick_params(axis="y", colors=COLORS["weight"], labelsize=8)
|
|
65
|
+
|
|
66
|
+
for phase in shot.phases:
|
|
67
|
+
px = phase.sample_index * shot.sample_interval_ms / 1000
|
|
68
|
+
if 0.3 < px < (t[-1] if t else 0) - 0.3:
|
|
69
|
+
ax_temp.axvline(px, color=COLORS["phase"], lw=1.0, alpha=0.8)
|
|
70
|
+
ax_temp.annotate(
|
|
71
|
+
phase.name[:18], (px, 0.99), xycoords=("data", "axes fraction"),
|
|
72
|
+
rotation=90, va="top", ha="right", fontsize=6.5, color="white",
|
|
73
|
+
bbox={"boxstyle": "round,pad=0.25", "facecolor": "#162132",
|
|
74
|
+
"alpha": 0.75, "edgecolor": "none"},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
ax_temp.set_xlabel("s", fontsize=8)
|
|
78
|
+
ax_temp.set_ylabel("°C", color=COLORS["temp"], fontsize=9)
|
|
79
|
+
ax_temp.tick_params(labelsize=8)
|
|
80
|
+
ax_temp.tick_params(axis="y", colors=COLORS["temp"])
|
|
81
|
+
ax_bar.set_ylabel("bar · g/s", color=COLORS["press"], fontsize=9)
|
|
82
|
+
ax_bar.tick_params(axis="y", colors=COLORS["press"], labelsize=8)
|
|
83
|
+
ax_bar.set_ylim(0, 16)
|
|
84
|
+
ax_temp.grid(True, axis="y", lw=0.3, alpha=0.4)
|
|
85
|
+
if title:
|
|
86
|
+
ax_temp.set_title(title, fontsize=10)
|
|
87
|
+
|
|
88
|
+
handles, labels = [], []
|
|
89
|
+
for ax in fig.axes:
|
|
90
|
+
h, lab = ax.get_legend_handles_labels()
|
|
91
|
+
handles += h
|
|
92
|
+
labels += lab
|
|
93
|
+
fig.legend(handles, labels, loc="lower center", ncol=len(labels),
|
|
94
|
+
bbox_to_anchor=(0.5, 0.0), fontsize=7.5, frameon=False)
|
|
95
|
+
|
|
96
|
+
fig.tight_layout(rect=(0, 0.08, 1, 1))
|
|
97
|
+
buf = io.BytesIO()
|
|
98
|
+
fig.savefig(buf, format="png")
|
|
99
|
+
plt.close(fig)
|
|
100
|
+
return buf.getvalue()
|
matebot/sitegen.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Generate the static shot-explorer site (GitHub Pages friendly).
|
|
2
|
+
|
|
3
|
+
Input: a folder of ``NNNNNN.slog`` + optional ``NNNNNN.json`` notes files
|
|
4
|
+
(the layout of a matebot/GaggiMate data repo's ``shots/`` dir).
|
|
5
|
+
Output: ``docs/`` with a self-contained viewer (no CDN):
|
|
6
|
+
index.html + app.js + style.css + index.json + shots/<id>.json
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.resources
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .slog import Shot, SlogError, parse_slog
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
WEB_ASSETS = (
|
|
21
|
+
"index.html",
|
|
22
|
+
"app.js",
|
|
23
|
+
"style.css",
|
|
24
|
+
"vendor/chart.umd.js",
|
|
25
|
+
"vendor/chartjs-plugin-annotation.min.js",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _round(values: list[float], digits: int) -> list[float]:
|
|
30
|
+
return [round(v, digits) for v in values]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _shot_payload(shot: Shot, notes: dict | None) -> dict:
|
|
34
|
+
s = shot.series
|
|
35
|
+
step = shot.sample_interval_ms / 1000.0
|
|
36
|
+
return {
|
|
37
|
+
"header": {
|
|
38
|
+
"profile": shot.profile_name,
|
|
39
|
+
"ts": shot.start_epoch,
|
|
40
|
+
"duration_s": round(shot.duration_ms / 1000, 1),
|
|
41
|
+
"final_g": shot.final_weight_g,
|
|
42
|
+
"phases": [
|
|
43
|
+
{"t": round(p.sample_index * step, 2), "name": p.name} for p in shot.phases
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
"series": {
|
|
47
|
+
"t": _round([t * step for t in s.get("t", [])], 2),
|
|
48
|
+
"ct": _round(s.get("ct", []), 1),
|
|
49
|
+
"tt": _round(s.get("tt", []), 1),
|
|
50
|
+
"cp": _round(s.get("cp", []), 2),
|
|
51
|
+
"tp": _round(s.get("tp", []), 2),
|
|
52
|
+
"fl": _round(s.get("fl", []), 2),
|
|
53
|
+
"tf": _round(s.get("tf", []), 2),
|
|
54
|
+
"pf": _round(s.get("pf", []), 2),
|
|
55
|
+
"vf": _round(s.get("vf", []), 2),
|
|
56
|
+
"v": _round(s.get("v", []), 1),
|
|
57
|
+
"ev": _round(s.get("ev", []), 1),
|
|
58
|
+
},
|
|
59
|
+
"notes": notes or {},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_notes(path: Path) -> dict | None:
|
|
64
|
+
try:
|
|
65
|
+
raw = path.read_bytes()
|
|
66
|
+
except OSError:
|
|
67
|
+
return None
|
|
68
|
+
if not raw.lstrip().startswith(b"{"): # gzip/HTML masquerade, see machine.py
|
|
69
|
+
log.warning("skipping non-JSON notes file %s", path.name)
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
return json.loads(raw)
|
|
73
|
+
except json.JSONDecodeError:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def generate(shots_dir: str | Path, out_dir: str | Path, *, title: str = "Shot Journal") -> int:
|
|
78
|
+
"""Build the site; returns the number of shots included."""
|
|
79
|
+
shots_dir, out_dir = Path(shots_dir), Path(out_dir)
|
|
80
|
+
(out_dir / "shots").mkdir(parents=True, exist_ok=True)
|
|
81
|
+
|
|
82
|
+
index = []
|
|
83
|
+
for slog_path in sorted(shots_dir.glob("*.slog")):
|
|
84
|
+
try:
|
|
85
|
+
shot = parse_slog(slog_path.read_bytes())
|
|
86
|
+
except SlogError as exc:
|
|
87
|
+
log.warning("skipping %s: %s", slog_path.name, exc)
|
|
88
|
+
continue
|
|
89
|
+
sid = slog_path.stem
|
|
90
|
+
notes = _load_notes(slog_path.with_suffix(".json"))
|
|
91
|
+
payload = _shot_payload(shot, notes)
|
|
92
|
+
(out_dir / "shots" / f"{sid}.json").write_text(
|
|
93
|
+
json.dumps(payload, separators=(",", ":"))
|
|
94
|
+
)
|
|
95
|
+
cp = payload["series"]["cp"]
|
|
96
|
+
index.append(
|
|
97
|
+
{
|
|
98
|
+
"id": sid,
|
|
99
|
+
"ts": shot.start_epoch,
|
|
100
|
+
"duration_s": payload["header"]["duration_s"],
|
|
101
|
+
"profile": shot.profile_name,
|
|
102
|
+
"final_g": shot.final_weight_g,
|
|
103
|
+
"peak_bar": max(cp) if cp else 0,
|
|
104
|
+
"rating": (notes or {}).get("rating") or 0,
|
|
105
|
+
"bean": (notes or {}).get("beanType", ""),
|
|
106
|
+
"ratio": (notes or {}).get("ratio", ""),
|
|
107
|
+
"taste": (notes or {}).get("balanceTaste", ""),
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
index.sort(key=lambda e: e["id"], reverse=True)
|
|
112
|
+
(out_dir / "index.json").write_text(json.dumps({"title": title, "shots": index}))
|
|
113
|
+
|
|
114
|
+
web = importlib.resources.files("matebot") / "web"
|
|
115
|
+
(out_dir / "vendor").mkdir(exist_ok=True)
|
|
116
|
+
for name in WEB_ASSETS:
|
|
117
|
+
(out_dir / name).write_text((web / name).read_text())
|
|
118
|
+
log.info("site generated: %d shots -> %s", len(index), out_dir)
|
|
119
|
+
return len(index)
|