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/slog.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Decoder for GaggiMate binary shot logs (``.slog``) and the shot index (``index.bin``).
|
|
2
|
+
|
|
3
|
+
Format reference: ``src/display/models/shot_log_format.h`` in the GaggiMate
|
|
4
|
+
firmware (format v5, little-endian throughout). Older versions carry fewer
|
|
5
|
+
sample fields; ``fieldsMask`` says which are present, always in the fixed
|
|
6
|
+
order below.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import struct
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
SHOT_MAGIC = 0x544F4853 # "SHOT"
|
|
18
|
+
INDEX_MAGIC = 0x58444953 # "SIDX"
|
|
19
|
+
|
|
20
|
+
# (key, struct char, divisor) in fieldsMask bit order 0..12.
|
|
21
|
+
SAMPLE_FIELDS = [
|
|
22
|
+
("t", "H", None), # sample tick; seconds = t * sampleInterval / 1000
|
|
23
|
+
("tt", "H", 10), # target temp °C
|
|
24
|
+
("ct", "H", 10), # current temp °C
|
|
25
|
+
("tp", "H", 10), # target pressure bar
|
|
26
|
+
("cp", "H", 10), # current pressure bar
|
|
27
|
+
("fl", "h", 100), # pump flow ml/s
|
|
28
|
+
("tf", "h", 100), # target flow ml/s
|
|
29
|
+
("pf", "h", 100), # puck flow ml/s
|
|
30
|
+
("vf", "h", 100), # bluetooth-scale flow ml/s
|
|
31
|
+
("v", "H", 10), # bluetooth weight g
|
|
32
|
+
("ev", "H", 10), # estimated weight g
|
|
33
|
+
("pr", "H", 100), # puck resistance
|
|
34
|
+
("si", "H", None), # system info bitfield
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# ShotIndexEntry.flags
|
|
38
|
+
FLAG_COMPLETED = 0x01
|
|
39
|
+
FLAG_DELETED = 0x02
|
|
40
|
+
FLAG_HAS_NOTES = 0x04
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SlogError(ValueError):
|
|
44
|
+
"""Raised when input bytes are not a valid shot log / index."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _cstr(raw: bytes) -> str:
|
|
48
|
+
return raw.split(b"\0", 1)[0].decode("utf-8", errors="replace")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class PhaseTransition:
|
|
53
|
+
sample_index: int
|
|
54
|
+
phase_number: int
|
|
55
|
+
name: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Shot:
|
|
60
|
+
version: int
|
|
61
|
+
sample_interval_ms: int
|
|
62
|
+
fields_mask: int
|
|
63
|
+
sample_count: int
|
|
64
|
+
duration_ms: int
|
|
65
|
+
start_epoch: int
|
|
66
|
+
profile_id: str
|
|
67
|
+
profile_name: str
|
|
68
|
+
final_weight_g: float
|
|
69
|
+
phases: list[PhaseTransition] = field(default_factory=list)
|
|
70
|
+
# column-oriented: {"t": [...], "ct": [...], ...} only for fields present in fields_mask
|
|
71
|
+
series: dict[str, list[float]] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def times_s(self) -> list[float]:
|
|
75
|
+
step = self.sample_interval_ms / 1000.0
|
|
76
|
+
return [t * step for t in self.series.get("t", [])]
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict:
|
|
79
|
+
return {
|
|
80
|
+
"version": self.version,
|
|
81
|
+
"sample_interval_ms": self.sample_interval_ms,
|
|
82
|
+
"sample_count": self.sample_count,
|
|
83
|
+
"duration_ms": self.duration_ms,
|
|
84
|
+
"start_epoch": self.start_epoch,
|
|
85
|
+
"profile_id": self.profile_id,
|
|
86
|
+
"profile_name": self.profile_name,
|
|
87
|
+
"final_weight_g": self.final_weight_g,
|
|
88
|
+
"phases": [
|
|
89
|
+
{"sample_index": p.sample_index, "phase": p.phase_number, "name": p.name}
|
|
90
|
+
for p in self.phases
|
|
91
|
+
],
|
|
92
|
+
"series": self.series,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def to_json(self, **kwargs) -> str:
|
|
96
|
+
return json.dumps(self.to_dict(), **kwargs)
|
|
97
|
+
|
|
98
|
+
def to_csv(self) -> str:
|
|
99
|
+
keys = [k for k, _, _ in SAMPLE_FIELDS if k in self.series]
|
|
100
|
+
out = io.StringIO()
|
|
101
|
+
w = csv.writer(out)
|
|
102
|
+
w.writerow(["time_s", *keys])
|
|
103
|
+
step = self.sample_interval_ms / 1000.0
|
|
104
|
+
for i in range(len(self.series.get("t", []))):
|
|
105
|
+
w.writerow([self.series["t"][i] * step, *(self.series[k][i] for k in keys)])
|
|
106
|
+
return out.getvalue()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class IndexEntry:
|
|
111
|
+
id: int
|
|
112
|
+
timestamp: int
|
|
113
|
+
duration_ms: int
|
|
114
|
+
volume_g: float
|
|
115
|
+
rating: int
|
|
116
|
+
flags: int
|
|
117
|
+
profile_id: str
|
|
118
|
+
profile_name: str
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def completed(self) -> bool:
|
|
122
|
+
return bool(self.flags & FLAG_COMPLETED)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def deleted(self) -> bool:
|
|
126
|
+
return bool(self.flags & FLAG_DELETED)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def has_notes(self) -> bool:
|
|
130
|
+
return bool(self.flags & FLAG_HAS_NOTES)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def padded_id(self) -> str:
|
|
134
|
+
return f"{self.id:06d}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class ShotIndex:
|
|
139
|
+
version: int
|
|
140
|
+
next_id: int
|
|
141
|
+
entries: list[IndexEntry]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_slog(data: bytes) -> Shot:
|
|
145
|
+
"""Parse a .slog file. Raises SlogError on anything that isn't one.
|
|
146
|
+
|
|
147
|
+
Tolerates truncated sample sections (crash during recording): decodes as
|
|
148
|
+
many complete samples as are actually present.
|
|
149
|
+
"""
|
|
150
|
+
if len(data) < 24 or struct.unpack_from("<I", data, 0)[0] != SHOT_MAGIC:
|
|
151
|
+
raise SlogError("not a shot log (missing SHOT magic)")
|
|
152
|
+
|
|
153
|
+
version, _sample_size, header_size, interval, _res1 = struct.unpack_from("<BBHHH", data, 4)
|
|
154
|
+
fields_mask, sample_count, duration_ms, start_epoch = struct.unpack_from("<IIII", data, 12)
|
|
155
|
+
if header_size < 108 or header_size > len(data):
|
|
156
|
+
raise SlogError(f"implausible headerSize {header_size}")
|
|
157
|
+
profile_id = _cstr(data[28:60])
|
|
158
|
+
profile_name = _cstr(data[60:108])
|
|
159
|
+
(final_weight,) = struct.unpack_from("<H", data, 108)
|
|
160
|
+
|
|
161
|
+
phases: list[PhaseTransition] = []
|
|
162
|
+
if version >= 5 and header_size >= 459:
|
|
163
|
+
count = data[458]
|
|
164
|
+
for i in range(min(count, 12)):
|
|
165
|
+
off = 110 + i * 29
|
|
166
|
+
idx, num = struct.unpack_from("<HB", data, off)
|
|
167
|
+
phases.append(PhaseTransition(idx, num, _cstr(data[off + 4 : off + 29])))
|
|
168
|
+
|
|
169
|
+
present = [(k, c, d) for bit, (k, c, d) in enumerate(SAMPLE_FIELDS) if fields_mask & (1 << bit)]
|
|
170
|
+
fmt = "<" + "".join(c for _, c, _ in present)
|
|
171
|
+
size = struct.calcsize(fmt)
|
|
172
|
+
body = data[header_size:]
|
|
173
|
+
n_available = len(body) // size if size else 0
|
|
174
|
+
n = min(sample_count, n_available) if sample_count else n_available
|
|
175
|
+
|
|
176
|
+
series: dict[str, list[float]] = {k: [] for k, _, _ in present}
|
|
177
|
+
for values in struct.iter_unpack(fmt, body[: n * size]):
|
|
178
|
+
for (k, _, div), v in zip(present, values, strict=True):
|
|
179
|
+
series[k].append(v / div if div else v)
|
|
180
|
+
|
|
181
|
+
return Shot(
|
|
182
|
+
version=version,
|
|
183
|
+
sample_interval_ms=interval or 250,
|
|
184
|
+
fields_mask=fields_mask,
|
|
185
|
+
sample_count=n,
|
|
186
|
+
duration_ms=duration_ms,
|
|
187
|
+
start_epoch=start_epoch,
|
|
188
|
+
profile_id=profile_id,
|
|
189
|
+
profile_name=profile_name,
|
|
190
|
+
final_weight_g=final_weight / 10.0,
|
|
191
|
+
phases=phases,
|
|
192
|
+
series=series,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def parse_index(data: bytes) -> ShotIndex:
|
|
197
|
+
"""Parse /h/index.bin."""
|
|
198
|
+
if len(data) < 32 or struct.unpack_from("<I", data, 0)[0] != INDEX_MAGIC:
|
|
199
|
+
raise SlogError("not a shot index (missing SIDX magic)")
|
|
200
|
+
version, entry_size, entry_count, next_id = struct.unpack_from("<HHII", data, 4)
|
|
201
|
+
if entry_size < 96:
|
|
202
|
+
raise SlogError(f"implausible entrySize {entry_size}")
|
|
203
|
+
entries = []
|
|
204
|
+
for i in range(entry_count):
|
|
205
|
+
off = 32 + i * entry_size
|
|
206
|
+
if off + entry_size > len(data):
|
|
207
|
+
break # truncated index
|
|
208
|
+
sid, ts, dur, vol, rating, flags = struct.unpack_from("<IIIHBB", data, off)
|
|
209
|
+
entries.append(
|
|
210
|
+
IndexEntry(
|
|
211
|
+
id=sid,
|
|
212
|
+
timestamp=ts,
|
|
213
|
+
duration_ms=dur,
|
|
214
|
+
volume_g=vol / 10.0,
|
|
215
|
+
rating=rating,
|
|
216
|
+
flags=flags,
|
|
217
|
+
profile_id=_cstr(data[off + 16 : off + 48]),
|
|
218
|
+
profile_name=_cstr(data[off + 48 : off + 96]),
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
return ShotIndex(version=version, next_id=next_id, entries=entries)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def is_slog(data: bytes) -> bool:
|
|
225
|
+
return len(data) >= 4 and struct.unpack_from("<I", data, 0)[0] == SHOT_MAGIC
|
matebot/state.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Tiny atomic JSON state file (questionnaire defaults + resume data)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class State:
|
|
13
|
+
def __init__(self, path: str | Path) -> None:
|
|
14
|
+
self.path = Path(path)
|
|
15
|
+
self._data: dict[str, Any] = {}
|
|
16
|
+
if self.path.exists():
|
|
17
|
+
try:
|
|
18
|
+
self._data = json.loads(self.path.read_text())
|
|
19
|
+
except (json.JSONDecodeError, OSError):
|
|
20
|
+
self._data = {}
|
|
21
|
+
|
|
22
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
23
|
+
return self._data.get(key, default)
|
|
24
|
+
|
|
25
|
+
def set(self, key: str, value: Any) -> None:
|
|
26
|
+
self._data[key] = value
|
|
27
|
+
self._flush()
|
|
28
|
+
|
|
29
|
+
def update(self, **kwargs: Any) -> None:
|
|
30
|
+
self._data.update(kwargs)
|
|
31
|
+
self._flush()
|
|
32
|
+
|
|
33
|
+
def _flush(self) -> None:
|
|
34
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=".state-")
|
|
36
|
+
try:
|
|
37
|
+
with os.fdopen(fd, "w") as fh:
|
|
38
|
+
json.dump(self._data, fh, indent=1)
|
|
39
|
+
os.replace(tmp, self.path)
|
|
40
|
+
except BaseException:
|
|
41
|
+
os.unlink(tmp)
|
|
42
|
+
raise
|
matebot/sync.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Keep a git-backed shot journal in sync with the machine.
|
|
2
|
+
|
|
3
|
+
Layout of the data repo (created on first sync if missing):
|
|
4
|
+
shots/NNNNNN.slog + NNNNNN.json raw shot logs + notes
|
|
5
|
+
profiles/<label>.json brew profiles
|
|
6
|
+
settings.json machine settings (credentials redacted)
|
|
7
|
+
docs/ generated shot-explorer site (GitHub Pages)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import fcntl
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import aiohttp
|
|
20
|
+
|
|
21
|
+
from .machine import GaggiMateClient, MachineError
|
|
22
|
+
from .sitegen import generate
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SyncConflict(RuntimeError):
|
|
28
|
+
"""Manual edits conflict with the incoming sync; resolve by hand."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
|
|
32
|
+
return subprocess.run(
|
|
33
|
+
["git", "-C", str(repo), *args], capture_output=True, text=True, check=False
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _safe_name(label: str) -> str:
|
|
38
|
+
return re.sub(r"[^A-Za-z0-9._ -]", "_", label).strip() or "unnamed"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def sync(
|
|
42
|
+
client: GaggiMateClient, repo: str | Path, *, site_title: str = "Shot Journal"
|
|
43
|
+
) -> bool:
|
|
44
|
+
"""Pull, mirror machine state into the repo, regenerate site, commit, push.
|
|
45
|
+
|
|
46
|
+
Returns True if a commit was pushed. Raises SyncConflict on rebase conflict.
|
|
47
|
+
Needs a live client session; profile export additionally needs the WS
|
|
48
|
+
connection (skipped gracefully when the socket is down).
|
|
49
|
+
"""
|
|
50
|
+
repo = Path(repo)
|
|
51
|
+
shots_dir = repo / "shots"
|
|
52
|
+
shots_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
with open(repo / ".matebot.lock", "w") as lock:
|
|
55
|
+
try:
|
|
56
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
57
|
+
except BlockingIOError:
|
|
58
|
+
log.info("another sync is running; skipping")
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
if (repo / ".git").exists() and _git(repo, "remote").stdout.strip():
|
|
62
|
+
pull = _git(repo, "pull", "--rebase", "--autostash")
|
|
63
|
+
if pull.returncode != 0:
|
|
64
|
+
_git(repo, "rebase", "--abort")
|
|
65
|
+
raise SyncConflict(pull.stderr.strip()[-500:])
|
|
66
|
+
|
|
67
|
+
# quarantine legacy corrupted notes first (SPA-fallback downloads, see machine.py)
|
|
68
|
+
for p in shots_dir.glob("*.json"):
|
|
69
|
+
if not p.read_bytes().lstrip().startswith(b"{"):
|
|
70
|
+
log.warning("quarantining corrupt notes file %s", p.name)
|
|
71
|
+
p.rename(p.with_suffix(".json.corrupt"))
|
|
72
|
+
|
|
73
|
+
# --- shots ---
|
|
74
|
+
index = await client.fetch_index()
|
|
75
|
+
for entry in index.entries:
|
|
76
|
+
if entry.deleted or not entry.completed:
|
|
77
|
+
continue
|
|
78
|
+
slog_path = shots_dir / f"{entry.padded_id}.slog"
|
|
79
|
+
if not slog_path.exists():
|
|
80
|
+
try:
|
|
81
|
+
slog_path.write_bytes(await client.fetch_slog(entry.id))
|
|
82
|
+
log.info("downloaded shot %s", entry.padded_id)
|
|
83
|
+
except MachineError as exc:
|
|
84
|
+
log.warning("%s", exc)
|
|
85
|
+
continue
|
|
86
|
+
notes_path = shots_dir / f"{entry.padded_id}.json"
|
|
87
|
+
if entry.has_notes or not notes_path.exists():
|
|
88
|
+
notes = await client.fetch_notes(entry.id)
|
|
89
|
+
if notes is not None:
|
|
90
|
+
new = json.dumps(notes, indent=1).encode()
|
|
91
|
+
if not notes_path.exists() or notes_path.read_bytes() != new:
|
|
92
|
+
notes_path.write_bytes(new)
|
|
93
|
+
|
|
94
|
+
# --- profiles + settings (best effort) ---
|
|
95
|
+
try:
|
|
96
|
+
profiles = await client.profiles_list()
|
|
97
|
+
pdir = repo / "profiles"
|
|
98
|
+
pdir.mkdir(exist_ok=True)
|
|
99
|
+
for prof in profiles:
|
|
100
|
+
(pdir / f"{_safe_name(prof.get('label', 'unnamed'))}.json").write_text(
|
|
101
|
+
json.dumps(prof, indent=2, sort_keys=True)
|
|
102
|
+
)
|
|
103
|
+
except MachineError as exc:
|
|
104
|
+
log.info("profiles skipped (%s)", exc)
|
|
105
|
+
try:
|
|
106
|
+
settings = await client.fetch_settings(redact=True)
|
|
107
|
+
(repo / "settings.json").write_text(json.dumps(settings, indent=2, sort_keys=True))
|
|
108
|
+
except Exception as exc: # noqa: BLE001
|
|
109
|
+
log.info("settings skipped (%s)", exc)
|
|
110
|
+
|
|
111
|
+
# --- site ---
|
|
112
|
+
generate(shots_dir, repo / "docs", title=site_title)
|
|
113
|
+
|
|
114
|
+
# --- commit + push ---
|
|
115
|
+
if not (repo / ".git").exists():
|
|
116
|
+
_git(repo, "init", "-b", "main")
|
|
117
|
+
_git(repo, "add", "-A")
|
|
118
|
+
if not _git(repo, "status", "--porcelain").stdout.strip():
|
|
119
|
+
log.info("nothing to commit")
|
|
120
|
+
return False
|
|
121
|
+
latest = max((e.id for e in index.entries), default=0)
|
|
122
|
+
commit = _git(repo, "commit", "-m", f"sync: through shot {latest:06d}")
|
|
123
|
+
if commit.returncode != 0:
|
|
124
|
+
log.error("commit failed: %s", commit.stderr.strip())
|
|
125
|
+
return False
|
|
126
|
+
if _git(repo, "remote").stdout.strip():
|
|
127
|
+
push = _git(repo, "push")
|
|
128
|
+
if push.returncode != 0:
|
|
129
|
+
log.error("push failed: %s", push.stderr.strip()[-300:])
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def sync_soon(
|
|
134
|
+
client: GaggiMateClient, repo: str | Path, notify, *,
|
|
135
|
+
site_title: str = "Shot Journal", state=None, quiet: bool = False,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Post-shot sync wrapper: run, report problems, never raise.
|
|
138
|
+
|
|
139
|
+
A failed sync is remembered in *state* (``sync_pending``) so the caller
|
|
140
|
+
can retry when the machine comes back online.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
await sync(client, repo, site_title=site_title)
|
|
144
|
+
except SyncConflict as exc:
|
|
145
|
+
await notify(f"⚠️ Shot journal sync hit a git conflict — fix it manually:\n{exc}")
|
|
146
|
+
except (TimeoutError, aiohttp.ClientError, OSError) as exc:
|
|
147
|
+
log.warning("sync failed, machine unreachable: %r", exc)
|
|
148
|
+
if state is not None:
|
|
149
|
+
state.set("sync_pending", True)
|
|
150
|
+
if not quiet:
|
|
151
|
+
await notify(
|
|
152
|
+
"📡 The machine went offline before the journal could sync — "
|
|
153
|
+
"I'll catch up as soon as it's back on."
|
|
154
|
+
)
|
|
155
|
+
except Exception as exc: # noqa: BLE001
|
|
156
|
+
log.exception("sync failed")
|
|
157
|
+
if state is not None:
|
|
158
|
+
state.set("sync_pending", True)
|
|
159
|
+
if not quiet:
|
|
160
|
+
await notify(f"⚠️ Shot journal sync failed: {exc!r}")
|
|
161
|
+
else:
|
|
162
|
+
if state is not None:
|
|
163
|
+
state.set("sync_pending", False)
|
matebot/watcher.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Shot-end detection over the GaggiMate status stream.
|
|
2
|
+
|
|
3
|
+
A shot "ends" when ``process.a`` transitions 1 -> 0 while the machine was in
|
|
4
|
+
brew mode. The finished shot's id is then resolved by polling ``index.bin``
|
|
5
|
+
(the header/index are finalized only after extended recording ends, which can
|
|
6
|
+
take up to ~1 minute after the pump stops while a bluetooth scale settles).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .machine import GaggiMateClient
|
|
20
|
+
from .slog import IndexEntry
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
MODE_BREW = 1
|
|
25
|
+
BREW_PROCESSES = {"brew", "infusion"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class FinishedShot:
|
|
30
|
+
entry: IndexEntry
|
|
31
|
+
profile_label: str
|
|
32
|
+
duration_ms: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ShotWatcher:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
client: GaggiMateClient,
|
|
39
|
+
*,
|
|
40
|
+
min_duration_s: float = 10.0,
|
|
41
|
+
ignore_profiles: str = r"(?i)backflush|descale|flush|clean",
|
|
42
|
+
last_known_id: int = -1,
|
|
43
|
+
on_utility=None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.client = client
|
|
46
|
+
self.min_duration_ms = min_duration_s * 1000
|
|
47
|
+
self.ignore_re = re.compile(ignore_profiles) if ignore_profiles else None
|
|
48
|
+
self.last_known_id = last_known_id
|
|
49
|
+
self.on_utility = on_utility # async callback(profile) for ignored utility runs
|
|
50
|
+
|
|
51
|
+
def _frame_is_shot_end(self, prev: dict | None, frame: dict) -> bool:
|
|
52
|
+
if prev is None:
|
|
53
|
+
return False
|
|
54
|
+
p_prev = prev.get("process") or {}
|
|
55
|
+
p_now = frame.get("process") or {}
|
|
56
|
+
return (
|
|
57
|
+
p_prev.get("a") == 1
|
|
58
|
+
and p_now.get("a") == 0
|
|
59
|
+
and prev.get("m") == MODE_BREW
|
|
60
|
+
and p_prev.get("s", "brew") in BREW_PROCESSES
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _accept(self, profile: str, duration_ms: float) -> bool:
|
|
64
|
+
if duration_ms < self.min_duration_ms:
|
|
65
|
+
log.info("ignoring short shot (%.1fs)", duration_ms / 1000)
|
|
66
|
+
return False
|
|
67
|
+
if self.ignore_re and self.ignore_re.search(profile or ""):
|
|
68
|
+
log.info("ignoring utility profile %r", profile)
|
|
69
|
+
return False
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
async def _resolve_new_entry(self, *, budget_s: float = 120.0, poll_s: float = 3.0):
|
|
73
|
+
"""Poll index.bin until a new completed entry appears."""
|
|
74
|
+
waited = 0.0
|
|
75
|
+
while waited <= budget_s:
|
|
76
|
+
try:
|
|
77
|
+
index = await self.client.fetch_index()
|
|
78
|
+
except Exception as exc: # noqa: BLE001 - transient HTTP errors are fine
|
|
79
|
+
log.debug("index poll failed: %s", exc)
|
|
80
|
+
else:
|
|
81
|
+
fresh = [
|
|
82
|
+
e
|
|
83
|
+
for e in index.entries
|
|
84
|
+
if e.id > self.last_known_id and e.completed and not e.deleted
|
|
85
|
+
]
|
|
86
|
+
if fresh:
|
|
87
|
+
return max(fresh, key=lambda e: e.id)
|
|
88
|
+
await asyncio.sleep(poll_s)
|
|
89
|
+
waited += poll_s
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
async def shots(self, frames: AsyncIterator[dict]) -> AsyncIterator[FinishedShot]:
|
|
93
|
+
"""Consume status frames, yield finished (accepted) shots."""
|
|
94
|
+
prev: dict | None = None
|
|
95
|
+
# Initialize last_known_id from the machine so pre-existing shots
|
|
96
|
+
# never fire the questionnaire.
|
|
97
|
+
if self.last_known_id < 0:
|
|
98
|
+
try:
|
|
99
|
+
index = await self.client.fetch_index()
|
|
100
|
+
self.last_known_id = max((e.id for e in index.entries), default=0)
|
|
101
|
+
log.info("starting after shot id %d", self.last_known_id)
|
|
102
|
+
except Exception: # noqa: BLE001
|
|
103
|
+
self.last_known_id = 0
|
|
104
|
+
|
|
105
|
+
async for frame in frames:
|
|
106
|
+
if frame.get("tp") != "evt:status":
|
|
107
|
+
continue
|
|
108
|
+
if self._frame_is_shot_end(prev, frame):
|
|
109
|
+
profile = prev.get("p", "")
|
|
110
|
+
duration = (prev.get("process") or {}).get("e", 0)
|
|
111
|
+
log.info("shot ended: profile=%r duration=%.1fs", profile, duration / 1000)
|
|
112
|
+
is_utility = (
|
|
113
|
+
duration >= self.min_duration_ms
|
|
114
|
+
and self.ignore_re
|
|
115
|
+
and self.ignore_re.search(profile or "")
|
|
116
|
+
)
|
|
117
|
+
if is_utility and self.on_utility:
|
|
118
|
+
await self.on_utility(profile)
|
|
119
|
+
if self._accept(profile, duration):
|
|
120
|
+
entry = await self._resolve_new_entry()
|
|
121
|
+
if entry is None:
|
|
122
|
+
log.warning("shot ended but no new index entry appeared")
|
|
123
|
+
else:
|
|
124
|
+
self.last_known_id = entry.id
|
|
125
|
+
yield FinishedShot(entry, profile, entry.duration_ms or duration)
|
|
126
|
+
prev = frame
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def replay_frames(path: str | Path, *, delay_s: float = 0.0) -> AsyncIterator[dict]:
|
|
130
|
+
"""Replay a JSONL capture of WS frames (for tests and --replay dry-runs)."""
|
|
131
|
+
for line in Path(path).read_text().splitlines():
|
|
132
|
+
line = line.strip()
|
|
133
|
+
if not line:
|
|
134
|
+
continue
|
|
135
|
+
if delay_s:
|
|
136
|
+
await asyncio.sleep(delay_s)
|
|
137
|
+
yield json.loads(line)
|