Skip to content

Commit 508bfec

Browse files
frogmosesclaude
andcommitted
Add OFF event for deterministic .alog linking via roastUUID
Sentinel now reads the newest .alog when Artisan sends an OFF event, extracting roastUUID and roastbatchnr for deterministic matching with coffee-roasting analysis. Falls back to date/time if OFF is not configured. - artisan_sync: add OFF to ROAST_EVENTS and ARTISAN_EVENT_MAP - sentinel: add _link_alog() method, triggered on OFF event - sentinel: include roast_uuid and batch_nr in saved JSON - Update CLAUDE.md and README.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0163db4 commit 508bfec

4 files changed

Lines changed: 107 additions & 7 deletions

File tree

CLAUDE.md

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ GoPro ← gopro_bridge.py (USB capture) ← sentinel._capture_and_analyze()
9494
5. Main loop: event-triggered + timed captures → `_capture_and_analyze()`
9595
6. Each capture: `gopro_bridge.quick_capture()``vision_client.assess_roast_color()`
9696
7. DROP or Ctrl+C: `end_camera_session()`, save log, rsync to dev machine
97+
8. OFF (after DROP): `_link_alog()` extracts `roastUUID` + `roastbatchnr` from newest `.alog`
9798

9899
## Key Parameters and Locations
99100

@@ -120,17 +121,23 @@ VISION_MODEL = "claude-sonnet-4-5-20250929"
120121
SERIAL = os.environ.get("GOPRO_SERIAL") # last 3 digits, from .env
121122
```
122123

123-
### WebSocket server — `artisan_sync.py:37-38`
124+
### WebSocket server — `artisan_sync.py:40-41`
124125
```python
125126
DEFAULT_HOST = "0.0.0.0"
126127
DEFAULT_PORT = 8765
127128
```
128129

129-
### Log push destination — `sentinel.py`
130+
### Log push destination — `sentinel.py:201`
130131
```python
131132
dest = os.environ.get("SENTINEL_RSYNC_DEST") # e.g. user@host:~/path/captures/
132133
```
133134

135+
### Artisan save directory — `sentinel.py:169-171`
136+
```python
137+
save_dir = os.environ.get("ARTISAN_SAVE_DIR", "") # default: ~/coffee-roasts
138+
```
139+
Used by `_link_alog()` to find the newest `.alog` after OFF event.
140+
134141
## GoPro Bridge Functions
135142

136143
### One-shot (for `ask`, `look` commands)
@@ -210,16 +217,25 @@ Sentinel runs a WebSocket **server** on port 8765. Artisan connects as a client.
210217
| FCe | `send({"event": "FCe"})` | |
211218
| DROP | N/A | Hottop Command uses this slot |
212219
| COOL END | `send({"event": "DROP"})` | Sends DROP, ends session |
220+
| OFF | `send({"event": "OFF"})` | Triggers .alog linking (extracts UUID + batch #) |
213221

214-
DROP's action slot is used by Hottop safety commands. COOL END is configured to send DROP instead.
222+
DROP's action slot is used by Hottop safety commands. COOL END is configured to send DROP instead. OFF triggers sentinel to scan for the newest .alog and extract roast identifiers for deterministic log matching.
215223

216224
### Message formats (`artisan_sync.py`)
217225

218226
- Button events: `{"event": "CHARGE"}`
219227
- Native events: `{"message": "chargeEvent"}` (mapped via `ARTISAN_EVENT_MAP`)
220228
- Data polling: `{"command": "getData", "id": N}` (responded with dummy BT/ET data)
221229

222-
### Phase mapping (`artisan_sync.py:70-78`)
230+
### Roast events (`artisan_sync.py:46`)
231+
232+
```python
233+
ROAST_EVENTS = ["START", "CHARGE", "DRY", "FCs", "FCe", "SCs", "SCe", "DROP", "OFF"]
234+
```
235+
236+
OFF is not a roast phase event — it signals that Artisan has saved the `.alog` file (autosave on OFF button). The sentinel uses this to link the session log to the `.alog` before pushing.
237+
238+
### Phase mapping (`artisan_sync.py:74-82`)
223239

224240
| Event | Phase |
225241
|-------|-------|
@@ -228,14 +244,45 @@ DROP's action slot is used by Hottop safety commands. COOL END is configured to
228244
| FCs/FCe/SCs/SCe | development |
229245
| DROP | cooling |
230246

247+
## Sentinel Session — Key Methods
248+
249+
### `SentinelSession` instance vars (`sentinel.py:47-61`)
250+
251+
| Var | Type | Description |
252+
|-----|------|-------------|
253+
| `bean_name` | str | Bean name for the session |
254+
| `session_id` | str | `YYYY-MM-DD_HHMM` timestamp |
255+
| `observations` | list | Vision observation dicts |
256+
| `roast_uuid` | str/None | From `.alog` `roastUUID`, set by `_link_alog()` |
257+
| `batch_nr` | int/None | From `.alog` `roastbatchnr`, set by `_link_alog()` |
258+
259+
### `_link_alog()` (`sentinel.py:163-193`)
260+
261+
Called when the OFF event is received. Scans `ARTISAN_SAVE_DIR` (env var, default `~/coffee-roasts`) for the newest `.alog` file, parses it with `ast.literal_eval()`, and extracts `roastUUID` and `roastbatchnr`. These are saved into the sentinel JSON for deterministic matching by `coffee-roasting/sentinel_loader.py`.
262+
263+
### `_on_artisan_event()` (`sentinel.py:75-90`)
264+
265+
Handles roast events from Artisan. Key behaviors:
266+
- Events in `CAPTURE_EVENTS` set → flag immediate capture
267+
- `DROP` → sets `self.running = False` to end session
268+
- `OFF` → calls `_link_alog()` to extract roast identifiers
269+
270+
### `_save_log()` (`sentinel.py:214-231`)
271+
272+
Writes session JSON including `roast_uuid` and `batch_nr` fields.
273+
274+
### `_push_log()` (`sentinel.py:195-212`)
275+
276+
Rsyncs the session log to the dev machine. Uses `SENTINEL_RSYNC_DEST` env var.
277+
231278
## Sentinel Capture Logic
232279

233280
Two triggers run simultaneously:
234281

235282
1. **Event-triggered** — immediate capture on CHARGE, DRY, FCs, FCe, SCs, SCe, DROP
236283
2. **Timed interval** — phase-adaptive (drying: 30s, maillard: 20s, development: 10s, cooling: none)
237284

238-
Camera opens on Artisan connect (ON/START), before CHARGE. Capture loop starts at CHARGE. Session ends on DROP or Ctrl+C. Partial logs are saved on interrupt.
285+
Camera opens on Artisan connect (ON/START), before CHARGE. Capture loop starts at CHARGE. Session ends on DROP or Ctrl+C. OFF (after DROP) triggers `.alog` linking but no capture. Partial logs are saved on interrupt.
239286

240287
## Session Log Schema
241288

@@ -245,6 +292,8 @@ Saved to `captures/sentinel_YYYY-MM-DD_HHMM.json`:
245292
{
246293
"session_id": "2026-02-28_1518",
247294
"bean_name": "Ethiopia Yirgacheffe",
295+
"roast_uuid": "a1b2c3d4-...",
296+
"batch_nr": 42,
248297
"artisan_events": {"charge": 0.0, "dry": 270.5, "fcs": 450.2, "drop": 570.8},
249298
"observations": [
250299
{
@@ -260,6 +309,8 @@ Saved to `captures/sentinel_YYYY-MM-DD_HHMM.json`:
260309
}
261310
```
262311

312+
`roast_uuid` and `batch_nr` are populated by `_link_alog()` when the OFF event is received. They default to `""` and `0` respectively if OFF was not received or the `.alog` could not be parsed.
313+
263314
Development score scale (1-10): green → pale yellow → tan → cinnamon → city → full city → dark → Vienna → French → Italian. Defined in `vision_client.py:149-160`.
264315

265316
## Coding Conventions

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ In Artisan: **Config > Ports > WebSocket tab**
9595
| Port | `8765` |
9696
| Path | `WebSocket` |
9797

98-
Configure button WebSocket Command actions for ON, START, CHARGE, DRY, FCs, FCe, and COOL END (sends DROP). See [CLAUDE.md](CLAUDE.md) for the full button mapping.
98+
Configure button WebSocket Command actions for ON, START, CHARGE, DRY, FCs, FCe, COOL END (sends DROP), and OFF (triggers .alog linking). See [CLAUDE.md](CLAUDE.md) for the full button mapping.
9999

100100
## Deployment
101101

@@ -119,6 +119,8 @@ cd ~/CodeProjects/gopro
119119
{
120120
"session_id": "2026-02-28_1518",
121121
"bean_name": "Ethiopia Yirgacheffe",
122+
"roast_uuid": "a1b2c3d4-...",
123+
"batch_nr": 42,
122124
"artisan_events": {"charge": 0.0, "dry": 270.5, "fcs": 450.2, "drop": 570.8},
123125
"observations": [
124126
{
@@ -134,4 +136,6 @@ cd ~/CodeProjects/gopro
134136
}
135137
```
136138

139+
The `roast_uuid` and `batch_nr` fields are extracted from the `.alog` file when Artisan's OFF button is pressed. These enable deterministic matching between sentinel logs and roast logs in the analysis pipeline.
140+
137141
For code-level details, SDK API notes, and tuning parameters, see [CLAUDE.md](CLAUDE.md).

artisan_sync.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@
2222
FCe: send({"event": "FCe"})
2323
DROP: (Hottop Command — not available for WebSocket)
2424
COOL: send({"event": "DROP"}) ← COOL END button sends DROP
25+
OFF: send({"event": "OFF"}) ← triggers .alog linking
2526
2627
Note: DROP's action slot is used by Hottop safety commands.
2728
COOL END is configured to send DROP instead, ending the session.
29+
OFF is configured to send an event so sentinel can read the .alog
30+
that Artisan writes on OFF (autosave).
2831
"""
2932

3033
import asyncio
@@ -40,7 +43,7 @@
4043
# Roast events we track, in expected order
4144
# START is included because Artisan sends it — we log it but it doesn't
4245
# change the roast phase (phase tracking begins at CHARGE)
43-
ROAST_EVENTS = ["START", "CHARGE", "DRY", "FCs", "FCe", "SCs", "SCe", "DROP"]
46+
ROAST_EVENTS = ["START", "CHARGE", "DRY", "FCs", "FCe", "SCs", "SCe", "DROP", "OFF"]
4447

4548
# Artisan native event names → our standardized names.
4649
# Artisan sends these when WebSocket event tags are configured in the
@@ -64,6 +67,7 @@
6467
"SCs": "SCs",
6568
"SCe": "SCe",
6669
"DROP": "DROP",
70+
"OFF": "OFF",
6771
}
6872

6973
# Map events to roast phases

sentinel.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- Development: every 10 seconds (most critical phase)
1414
"""
1515

16+
import ast
1617
import asyncio
1718
import json
1819
import os
@@ -56,6 +57,8 @@ def __init__(self, bean_name=None, enable_crack=False, ws_port=8765, debug=False
5657
self.latest_observation = None
5758
self.crack_status = None
5859
self.running = False
60+
self.roast_uuid = None
61+
self.batch_nr = None
5962

6063
# Event-triggered capture flag — set by callback, consumed by async loop
6164
self._event_capture_pending = False
@@ -82,6 +85,10 @@ def _on_artisan_event(self, event_name, elapsed):
8285
if event_name == "DROP":
8386
self.running = False
8487

88+
# OFF means Artisan just saved the .alog — link to it
89+
if event_name == "OFF":
90+
self._link_alog()
91+
8592
def _on_artisan_connect(self):
8693
"""Handle Artisan WebSocket connection."""
8794
print(" Artisan connected")
@@ -153,6 +160,38 @@ async def _capture_and_analyze(self):
153160

154161
return result
155162

163+
def _link_alog(self):
164+
"""Find the newest .alog in Artisan's save directory and extract identifiers.
165+
166+
Called when OFF event is received, which means Artisan just wrote the .alog.
167+
Extracts roastUUID and roastbatchnr for deterministic sentinel-to-roast linking.
168+
"""
169+
save_dir = os.environ.get("ARTISAN_SAVE_DIR", "")
170+
if not save_dir:
171+
save_dir = os.path.expanduser("~/coffee-roasts")
172+
173+
save_path = Path(save_dir)
174+
if not save_path.exists():
175+
print(f" Warning: Artisan save dir not found: {save_path}")
176+
return
177+
178+
# Find the most recently modified .alog file
179+
alogs = list(save_path.glob("*.alog"))
180+
if not alogs:
181+
print(f" Warning: no .alog files in {save_path}")
182+
return
183+
184+
newest = max(alogs, key=lambda p: p.stat().st_mtime)
185+
186+
try:
187+
raw = ast.literal_eval(newest.read_text(encoding="utf-8"))
188+
self.roast_uuid = raw.get("roastUUID", "")
189+
self.batch_nr = raw.get("roastbatchnr", 0)
190+
title = raw.get("title", "")
191+
print(f" Linked to .alog: #{self.batch_nr} {title} (UUID: {self.roast_uuid[:8]}...)")
192+
except (ValueError, SyntaxError, OSError) as e:
193+
print(f" Warning: could not parse {newest.name}: {e}")
194+
156195
def _push_log(self, log_file):
157196
"""Push session log to dev machine via rsync (best-effort).
158197
@@ -180,6 +219,8 @@ def _save_log(self):
180219
log_data = {
181220
"session_id": self.session_id,
182221
"bean_name": self.bean_name,
222+
"roast_uuid": self.roast_uuid or "",
223+
"batch_nr": self.batch_nr or 0,
183224
"artisan_events": {
184225
k.lower(): v for k, v in self.artisan.events.items()
185226
},

0 commit comments

Comments
 (0)