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/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)