matebot 0.2.2__tar.gz → 0.3.0__tar.gz

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.
Files changed (61) hide show
  1. {matebot-0.2.2 → matebot-0.3.0}/AGENTS.md +3 -1
  2. {matebot-0.2.2 → matebot-0.3.0}/PKG-INFO +25 -1
  3. {matebot-0.2.2 → matebot-0.3.0}/README.md +24 -0
  4. {matebot-0.2.2 → matebot-0.3.0}/flake.nix +2 -2
  5. {matebot-0.2.2 → matebot-0.3.0}/pyproject.toml +1 -1
  6. matebot-0.3.0/scripts/discord_cmd_smoke.py +62 -0
  7. matebot-0.3.0/scripts/discord_smoke.py +75 -0
  8. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/__init__.py +1 -1
  9. matebot-0.3.0/src/matebot/camera.py +141 -0
  10. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/cli.py +75 -7
  11. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/commands.py +162 -33
  12. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/config.py +15 -3
  13. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/machine.py +15 -2
  14. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/messengers/telegram.py +1 -0
  15. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/sitegen.py +19 -0
  16. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/sync.py +7 -3
  17. matebot-0.3.0/src/matebot/video.py +88 -0
  18. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/watcher.py +32 -5
  19. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/web/app.js +36 -0
  20. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/web/style.css +5 -0
  21. matebot-0.3.0/src/matebot/web_cam/index.html +107 -0
  22. matebot-0.3.0/tests/test_camera.py +73 -0
  23. {matebot-0.2.2 → matebot-0.3.0}/tests/test_commands.py +168 -8
  24. {matebot-0.2.2 → matebot-0.3.0}/tests/test_sync_retry.py +2 -2
  25. matebot-0.3.0/tests/test_video.py +86 -0
  26. {matebot-0.2.2 → matebot-0.3.0}/tests/test_watcher.py +42 -0
  27. {matebot-0.2.2 → matebot-0.3.0}/.github/workflows/ci.yml +0 -0
  28. {matebot-0.2.2 → matebot-0.3.0}/.github/workflows/docker.yml +0 -0
  29. {matebot-0.2.2 → matebot-0.3.0}/.github/workflows/release.yml +0 -0
  30. {matebot-0.2.2 → matebot-0.3.0}/.gitignore +0 -0
  31. {matebot-0.2.2 → matebot-0.3.0}/CLAUDE.md +0 -0
  32. {matebot-0.2.2 → matebot-0.3.0}/Dockerfile +0 -0
  33. {matebot-0.2.2 → matebot-0.3.0}/LICENSE +0 -0
  34. {matebot-0.2.2 → matebot-0.3.0}/docker-compose.example.yml +0 -0
  35. {matebot-0.2.2 → matebot-0.3.0}/docs/screenshots/journal-detail.png +0 -0
  36. {matebot-0.2.2 → matebot-0.3.0}/docs/screenshots/journal-list.png +0 -0
  37. {matebot-0.2.2 → matebot-0.3.0}/docs/screenshots/telegram-chat.png +0 -0
  38. {matebot-0.2.2 → matebot-0.3.0}/flake.lock +0 -0
  39. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/bags.py +0 -0
  40. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/conversation.py +0 -0
  41. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/digest.py +0 -0
  42. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/hints.py +0 -0
  43. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/messengers/__init__.py +0 -0
  44. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/messengers/base.py +0 -0
  45. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/messengers/discord.py +0 -0
  46. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/plot.py +0 -0
  47. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/slog.py +0 -0
  48. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/state.py +0 -0
  49. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/web/index.html +0 -0
  50. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/web/vendor/chart.umd.js +0 -0
  51. {matebot-0.2.2 → matebot-0.3.0}/src/matebot/web/vendor/chartjs-plugin-annotation.min.js +0 -0
  52. {matebot-0.2.2 → matebot-0.3.0}/tests/fixtures/000004.slog +0 -0
  53. {matebot-0.2.2 → matebot-0.3.0}/tests/fixtures/status_frames.jsonl +0 -0
  54. {matebot-0.2.2 → matebot-0.3.0}/tests/test_bags.py +0 -0
  55. {matebot-0.2.2 → matebot-0.3.0}/tests/test_cleaning.py +0 -0
  56. {matebot-0.2.2 → matebot-0.3.0}/tests/test_conversation.py +0 -0
  57. {matebot-0.2.2 → matebot-0.3.0}/tests/test_digest.py +0 -0
  58. {matebot-0.2.2 → matebot-0.3.0}/tests/test_hints.py +0 -0
  59. {matebot-0.2.2 → matebot-0.3.0}/tests/test_plot.py +0 -0
  60. {matebot-0.2.2 → matebot-0.3.0}/tests/test_sitegen.py +0 -0
  61. {matebot-0.2.2 → matebot-0.3.0}/tests/test_slog.py +0 -0
@@ -43,7 +43,9 @@ The decision tree:
43
43
  6. **Optional extras**, in ascending effort: journal repo + GitHub Pages
44
44
  (README "Publishing your journal"), dial-in hints (on by default), bean
45
45
  bag tracking (`/newbag`, multiple bags, `/tossbag`), smart plug hooks for cold-start `/wake`
46
- (README "Smart plug cold start").
46
+ (README "Smart plug cold start"), shot-video camera module (README
47
+ "Camera module" — MATEBOT_CAMERA=1, needs ffmpeg + HTTPS in front of the
48
+ camera page; getUserMedia refuses plain HTTP).
47
49
 
48
50
  Common failure modes: token/chat id swapped or quoted wrong; bot and machine
49
51
  on different networks/VLANs; machine powered off at a dumb power strip;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matebot
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: The proactive companion for GaggiMate espresso machines: post-shot logging bot, .slog decoder, shot-journal site generator
5
5
  Project-URL: Homepage, https://github.com/AlexNly/matebot
6
6
  Project-URL: Issues, https://github.com/AlexNly/matebot/issues
@@ -159,6 +159,30 @@ MATEBOT_WAKE_HOOK='curl -sf "http://192.168.1.60/relay/0?turn=on"'
159
159
  ```
160
160
 
161
161
  Any shell command works (Home Assistant webhook, `tinytuya`, zigbee2mqtt…).
162
+ NixOS module users: write `%` as `%%` in the hook options — the values pass
163
+ through a systemd unit, which treats single `%` as a specifier.
164
+
165
+ ## Camera module (optional): shot videos synced with the charts
166
+
167
+ Opt in with `MATEBOT_CAMERA=1` and MATEbot serves a phone-camera page on
168
+ `MATEBOT_CAMERA_PORT` (default 8877). Open it on a phone near the machine,
169
+ allow camera access, and leave the page open — recording starts and stops
170
+ automatically with each shot. Clips are transcoded (720p, with audio),
171
+ committed to the journal repo, and the shot page plays them in sync with the
172
+ curves (scrub the chart, the video follows).
173
+
174
+ **HTTPS is required** — browsers only grant camera access on secure pages.
175
+ MATEbot itself serves plain HTTP; put it behind whatever TLS you have:
176
+
177
+ - a reverse proxy with certificates (nginx/caddy/traefik), e.g.
178
+ `matebot.example.com` → `127.0.0.1:8877` (WebSocket support needed)
179
+ - or zero-config on a tailnet: `tailscale serve https / http://127.0.0.1:8877`
180
+
181
+ Sync calibration: detection and stream latency mean the video usually starts
182
+ ~1 s after the true shot start. `MATEBOT_CAMERA_OFFSET` (default `-1.0`)
183
+ corrects this globally; watch one replay and nudge per shot with
184
+ `/vsync +0.5` if needed. Videos rotate out after `MATEBOT_VIDEO_KEEP` shots
185
+ (git history keeps every clip recoverable).
162
186
 
163
187
  ## Configuration
164
188
 
@@ -125,6 +125,30 @@ MATEBOT_WAKE_HOOK='curl -sf "http://192.168.1.60/relay/0?turn=on"'
125
125
  ```
126
126
 
127
127
  Any shell command works (Home Assistant webhook, `tinytuya`, zigbee2mqtt…).
128
+ NixOS module users: write `%` as `%%` in the hook options — the values pass
129
+ through a systemd unit, which treats single `%` as a specifier.
130
+
131
+ ## Camera module (optional): shot videos synced with the charts
132
+
133
+ Opt in with `MATEBOT_CAMERA=1` and MATEbot serves a phone-camera page on
134
+ `MATEBOT_CAMERA_PORT` (default 8877). Open it on a phone near the machine,
135
+ allow camera access, and leave the page open — recording starts and stops
136
+ automatically with each shot. Clips are transcoded (720p, with audio),
137
+ committed to the journal repo, and the shot page plays them in sync with the
138
+ curves (scrub the chart, the video follows).
139
+
140
+ **HTTPS is required** — browsers only grant camera access on secure pages.
141
+ MATEbot itself serves plain HTTP; put it behind whatever TLS you have:
142
+
143
+ - a reverse proxy with certificates (nginx/caddy/traefik), e.g.
144
+ `matebot.example.com` → `127.0.0.1:8877` (WebSocket support needed)
145
+ - or zero-config on a tailnet: `tailscale serve https / http://127.0.0.1:8877`
146
+
147
+ Sync calibration: detection and stream latency mean the video usually starts
148
+ ~1 s after the true shot start. `MATEBOT_CAMERA_OFFSET` (default `-1.0`)
149
+ corrects this globally; watch one replay and nudge per shot with
150
+ `/vsync +0.5` if needed. Videos rotate out after `MATEBOT_VIDEO_KEEP` shots
151
+ (git history keeps every clip recoverable).
128
152
 
129
153
  ## Configuration
130
154
 
@@ -13,7 +13,7 @@
13
13
  default = matebot;
14
14
  matebot = pkgs.python3Packages.buildPythonApplication {
15
15
  pname = "matebot";
16
- version = "0.2.2";
16
+ version = "0.3.0";
17
17
  pyproject = true;
18
18
  src = self;
19
19
  build-system = [ pkgs.python3Packages.hatchling ];
@@ -90,7 +90,7 @@
90
90
  wantedBy = [ "multi-user.target" ];
91
91
  after = [ "network-online.target" ];
92
92
  wants = [ "network-online.target" ];
93
- path = [ pkgs.git pkgs.openssh ];
93
+ path = [ pkgs.git pkgs.openssh pkgs.ffmpeg-headless ];
94
94
  environment = {
95
95
  MATEBOT_MACHINE_HOST = cfg.machineHost;
96
96
  MATEBOT_MESSENGER = cfg.messenger;
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "matebot"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "The proactive companion for GaggiMate espresso machines: post-shot logging bot, .slog decoder, shot-journal site generator"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,62 @@
1
+ """Command-routing smoke test for Discord: /help, /status, /bag as plain text."""
2
+ import asyncio
3
+ import logging
4
+ import os
5
+ import pathlib
6
+ import sys
7
+
8
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "src"))
9
+
10
+ from matebot.commands import CommandRouter, make_frame_cache # noqa: E402
11
+ from matebot.config import Config # noqa: E402
12
+ from matebot.messengers.base import TextReply # noqa: E402
13
+ from matebot.messengers.discord import DiscordMessenger # noqa: E402
14
+ from matebot.state import State # noqa: E402
15
+
16
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
17
+ log = logging.getLogger("cmdsmoke")
18
+
19
+
20
+ class FakeMachine:
21
+ async def request(self, tp, **f):
22
+ return {"msg": "Ok"}
23
+
24
+ async def send_event(self, tp, **f):
25
+ log.info("MACHINE would receive: %s %s", tp, f)
26
+
27
+
28
+ async def main():
29
+ messenger = DiscordMessenger(
30
+ os.environ["DISCORD_BOT_TOKEN"], os.environ["DISCORD_CHANNEL_ID"]
31
+ )
32
+ state = State("/tmp/discord-cmd-state.json")
33
+ state.set("last_shot", {"shot_id": 73, "profile": "Direct Lever v3",
34
+ "duration_ms": 42600, "volume_g": 36.4})
35
+ cache, latest = make_frame_cache()
36
+ cache({"tp": "evt:status", "m": 0, "ct": 91.2, "tt": 0, "wl": 55})
37
+ router = CommandRouter(FakeMachine(), state, None, messenger,
38
+ Config(journal_url="https://alexnly.github.io/GAGGIMATE-0614/"), latest)
39
+ await messenger.start()
40
+ await messenger.send("Command smoke test ready — send /help, /status, /last, /wake as messages.")
41
+
42
+ async def pump():
43
+ handled = 0
44
+ async for event in messenger.events():
45
+ log.info("EVENT: %r", event)
46
+ if isinstance(event, TextReply) and event.text.strip().startswith("/"):
47
+ consumed = await router.handle(event.text.strip())
48
+ log.info("router consumed=%s", consumed)
49
+ handled += 1
50
+ if handled >= 4:
51
+ log.info("✅ command routing works on Discord")
52
+ return
53
+
54
+ try:
55
+ await asyncio.wait_for(pump(), timeout=600)
56
+ except TimeoutError:
57
+ log.error("timed out")
58
+ finally:
59
+ await messenger.stop()
60
+
61
+
62
+ asyncio.run(main())
@@ -0,0 +1,75 @@
1
+ """Live smoke test for the Discord messenger backend.
2
+
3
+ Drives a complete fake-shot questionnaire through real Discord — buttons,
4
+ free-text answers, photo, commands — without needing the machine. Saved
5
+ notes land in a recorder and are printed instead of hitting hardware.
6
+
7
+ Usage:
8
+ DISCORD_BOT_TOKEN=... DISCORD_CHANNEL_ID=... python scripts/discord_smoke.py
9
+ """
10
+
11
+ import asyncio
12
+ import logging
13
+ import os
14
+ import pathlib
15
+ import sys
16
+
17
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "src"))
18
+
19
+ from matebot.conversation import Conversation # noqa: E402
20
+ from matebot.messengers.discord import DiscordMessenger # noqa: E402
21
+ from matebot.state import State # noqa: E402
22
+
23
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
24
+ log = logging.getLogger("smoke")
25
+
26
+ GOLDEN = pathlib.Path(__file__).parent.parent / "tests" / "fixtures" / "000004.slog"
27
+
28
+
29
+ async def main() -> None:
30
+ messenger = DiscordMessenger(
31
+ os.environ["DISCORD_BOT_TOKEN"], os.environ["DISCORD_CHANNEL_ID"]
32
+ )
33
+ state = State("/tmp/discord-smoke-state.json")
34
+ saved = {}
35
+
36
+ async def save_notes(shot_id, notes):
37
+ saved[shot_id] = notes
38
+ log.info("NOTES SAVED for #%s: %s", shot_id, notes)
39
+ return True
40
+
41
+ convo = Conversation(messenger, state, save_notes)
42
+ await messenger.start()
43
+ log.info("connected — starting fake questionnaire")
44
+
45
+ photo = None
46
+ try:
47
+ from matebot.plot import render_shot_png
48
+ from matebot.slog import parse_slog
49
+
50
+ photo = render_shot_png(parse_slog(GOLDEN.read_bytes()), title="Shot #999 — Smoke Test")
51
+ log.info("photo rendered (%d bytes)", len(photo))
52
+ except Exception as exc: # noqa: BLE001
53
+ log.warning("photo skipped: %s", exc)
54
+
55
+ await convo.start_shot(999, "Discord Smoke Test", 31000, 36.2, photo=photo)
56
+ log.info("questionnaire sent — play it through in Discord (buttons AND typed answers)")
57
+
58
+ async def pump():
59
+ async for event in messenger.events():
60
+ log.info("EVENT: %r", event)
61
+ await convo.handle_event(event)
62
+ if saved:
63
+ log.info("✅ questionnaire complete — smoke test PASSED")
64
+ return
65
+
66
+ try:
67
+ await asyncio.wait_for(pump(), timeout=600)
68
+ except TimeoutError:
69
+ log.error("timed out waiting for interactions")
70
+ finally:
71
+ await messenger.stop()
72
+
73
+
74
+ if __name__ == "__main__":
75
+ asyncio.run(main())
@@ -1,3 +1,3 @@
1
1
  """matebot — the proactive companion for GaggiMate espresso machines."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.3.0"
@@ -0,0 +1,141 @@
1
+ """Optional camera module: MATEbot serves a phone-camera page and records shots.
2
+
3
+ Strictly opt-in (``MATEBOT_CAMERA=1``). The phone opens the page (HTTPS via
4
+ the user's reverse proxy — getUserMedia requires a secure context), grants
5
+ camera access and keeps the page open near the machine. When a shot starts,
6
+ the server tells the page to record; MediaRecorder chunks stream back over
7
+ the WebSocket. When the shot ends (+ a short tail), the clip is transcoded
8
+ and attached to the shot via the video pipeline.
9
+
10
+ Best-effort by design: no page connected → no recording, no errors.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import importlib.resources
17
+ import json
18
+ import logging
19
+ import tempfile
20
+ from pathlib import Path
21
+
22
+ from aiohttp import WSMsgType, web
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ TAIL_SECONDS = 5.0
27
+ MAX_RECORD_SECONDS = 240
28
+
29
+
30
+ class CameraServer:
31
+ def __init__(self, port: int, on_clip) -> None:
32
+ """*on_clip* is ``async (webm_path: Path) -> None`` — called per finished clip."""
33
+ self.port = port
34
+ self.on_clip = on_clip
35
+ self._ws: web.WebSocketResponse | None = None
36
+ self._chunks: list[bytes] = []
37
+ self._recording = False
38
+ self._runner: web.AppRunner | None = None
39
+ self._stop_task: asyncio.Task | None = None
40
+
41
+ # ------------------------------------------------------------ lifecycle
42
+
43
+ async def start(self) -> None:
44
+ app = web.Application()
45
+ app.router.add_get("/", self._page)
46
+ app.router.add_get("/ws", self._websocket)
47
+ self._runner = web.AppRunner(app)
48
+ await self._runner.setup()
49
+ site = web.TCPSite(self._runner, "0.0.0.0", self.port)
50
+ await site.start()
51
+ log.info("camera server listening on :%d", self.port)
52
+
53
+ async def stop(self) -> None:
54
+ if self._runner:
55
+ await self._runner.cleanup()
56
+
57
+ @property
58
+ def page_connected(self) -> bool:
59
+ return self._ws is not None and not self._ws.closed
60
+
61
+ # ------------------------------------------------------------ recording
62
+
63
+ async def shot_started(self) -> None:
64
+ if not self.page_connected or self._recording:
65
+ return
66
+ self._chunks = []
67
+ self._recording = True
68
+ await self._send({"cmd": "start"})
69
+ log.info("camera: recording started")
70
+ self._stop_task = asyncio.create_task(self._safety_stop())
71
+
72
+ async def shot_ended(self) -> None:
73
+ if not self._recording:
74
+ return
75
+ await asyncio.sleep(TAIL_SECONDS)
76
+ await self._finish()
77
+
78
+ async def _safety_stop(self) -> None:
79
+ await asyncio.sleep(MAX_RECORD_SECONDS)
80
+ if self._recording:
81
+ log.warning("camera: safety stop after %ds", MAX_RECORD_SECONDS)
82
+ await self._finish()
83
+
84
+ async def _finish(self) -> None:
85
+ if not self._recording:
86
+ return
87
+ self._recording = False
88
+ if self._stop_task and not self._stop_task.done():
89
+ self._stop_task.cancel()
90
+ await self._send({"cmd": "stop"})
91
+ # give the page a moment to flush its final chunks
92
+ for _ in range(20):
93
+ await asyncio.sleep(0.25)
94
+ if not self.page_connected:
95
+ break
96
+ chunks, self._chunks = self._chunks, []
97
+ if not chunks:
98
+ log.info("camera: no video data received")
99
+ return
100
+ with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as tmp:
101
+ for chunk in chunks:
102
+ tmp.write(chunk)
103
+ path = Path(tmp.name)
104
+ log.info("camera: clip complete (%.1f MB)", path.stat().st_size / 1e6)
105
+ try:
106
+ await self.on_clip(path)
107
+ finally:
108
+ path.unlink(missing_ok=True)
109
+
110
+ # ------------------------------------------------------------ http
111
+
112
+ async def _page(self, request: web.Request) -> web.Response:
113
+ html = (importlib.resources.files("matebot") / "web_cam" / "index.html").read_text()
114
+ return web.Response(text=html, content_type="text/html")
115
+
116
+ async def _websocket(self, request: web.Request) -> web.WebSocketResponse:
117
+ ws = web.WebSocketResponse(max_msg_size=8 * 2**20)
118
+ await ws.prepare(request)
119
+ if self._ws and not self._ws.closed:
120
+ await self._ws.close() # newest page wins
121
+ self._ws = ws
122
+ log.info("camera page connected from %s", request.remote)
123
+ try:
124
+ async for msg in ws:
125
+ if msg.type == WSMsgType.BINARY:
126
+ if self._recording or self._chunks is not None:
127
+ self._chunks.append(msg.data)
128
+ elif msg.type == WSMsgType.TEXT:
129
+ log.debug("camera page: %s", msg.data[:100])
130
+ finally:
131
+ if self._ws is ws:
132
+ self._ws = None
133
+ log.info("camera page disconnected")
134
+ return ws
135
+
136
+ async def _send(self, payload: dict) -> None:
137
+ if self.page_connected:
138
+ try:
139
+ await self._ws.send_str(json.dumps(payload))
140
+ except Exception as exc: # noqa: BLE001
141
+ log.warning("camera send failed: %s", exc)
@@ -76,7 +76,10 @@ async def _sync(config: Config) -> int:
76
76
  return 1
77
77
  async with GaggiMateClient(config.machine_host) as client:
78
78
  # WS connection (for profiles) is optional here; HTTP does the rest.
79
- pushed = await sync(client, config.data_repo, site_title=config.site_title)
79
+ pushed = await sync(
80
+ client, config.data_repo,
81
+ site_title=config.site_title, video_keep=config.video_keep,
82
+ )
80
83
  print("synced" if pushed else "nothing new")
81
84
  return 0
82
85
 
@@ -117,7 +120,8 @@ async def _run(config: Config, *, replay: str | None, dry_run: bool) -> int:
117
120
  asyncio.create_task(
118
121
  sync_soon(
119
122
  client, config.data_repo, messenger.send,
120
- site_title=config.site_title, state=state, quiet=quiet,
123
+ site_title=config.site_title, video_keep=config.video_keep,
124
+ state=state, quiet=quiet,
121
125
  )
122
126
  )
123
127
 
@@ -146,6 +150,52 @@ async def _run(config: Config, *, replay: str | None, dry_run: bool) -> int:
146
150
  convo = Conversation(messenger, state, save_notes)
147
151
  cache_frame, latest_frame = make_frame_cache()
148
152
  router = CommandRouter(client, state, convo, messenger, config, latest_frame)
153
+
154
+ camera = None
155
+ pending_clip: dict = {"path": None, "ts": 0.0}
156
+ attach_lock = asyncio.Lock()
157
+
158
+ async def try_attach_clip(shot_id: int | None = None) -> None:
159
+ """Rendezvous: a finished clip and a resolved shot id arrive in
160
+ either order; whichever side is second completes the attach."""
161
+ import time as _time
162
+
163
+ from .video import VideoError, attach_video, get_offset
164
+
165
+ async with attach_lock:
166
+ clip = pending_clip["path"]
167
+ if clip is None or _time.monotonic() - pending_clip["ts"] > 300:
168
+ return
169
+ sid = shot_id if shot_id is not None else state.get("last_shot_id")
170
+ if not sid or get_offset(config.data_repo, sid) is not None:
171
+ return # no shot yet, or it already has a video
172
+ pending_clip["path"] = None
173
+ try:
174
+ await attach_video(config.data_repo, sid, clip,
175
+ offset=config.camera_offset)
176
+ log.info("camera clip attached to shot %06d", sid)
177
+ schedule_sync(quiet=True)
178
+ except VideoError as exc:
179
+ log.warning("clip attach failed: %s", exc)
180
+ await messenger.send(f"🎬 Couldn't process the shot video: {exc}")
181
+ finally:
182
+ pathlib.Path(clip).unlink(missing_ok=True)
183
+
184
+ if config.camera_enabled and config.data_repo:
185
+ import shutil as _shutil
186
+ import time as _time
187
+
188
+ from .camera import CameraServer
189
+
190
+ async def on_clip(webm_path):
191
+ keep = pathlib.Path(config.state_dir) / "pending_clip.webm"
192
+ _shutil.copy(webm_path, keep)
193
+ pending_clip["path"] = str(keep)
194
+ pending_clip["ts"] = _time.monotonic()
195
+ await try_attach_clip()
196
+
197
+ camera = CameraServer(config.camera_port, on_clip)
198
+ await camera.start()
149
199
  # messenger APIs can be flaky at boot; retry instead of crash-looping
150
200
  for attempt in range(8):
151
201
  try:
@@ -175,16 +225,31 @@ async def _run(config: Config, *, replay: str | None, dry_run: bool) -> int:
175
225
 
176
226
  last_frame_at = 0.0
177
227
 
228
+ shot_active = False
229
+
178
230
  async def tee(source):
179
- nonlocal last_frame_at
231
+ nonlocal last_frame_at, shot_active
180
232
  async for frame in source:
181
233
  now = _time.monotonic()
182
234
  gap = now - last_frame_at if last_frame_at else None
183
235
  last_frame_at = now
184
- if state.get("sync_pending") and (gap is None or gap > 60):
185
- log.info("machine is back; retrying pending journal sync")
186
- state.set("sync_pending", False) # avoid re-trigger storms
187
- schedule_sync(quiet=True)
236
+ if camera is not None and frame.get("tp") == "evt:status":
237
+ active = (
238
+ frame.get("m") == 1
239
+ and (frame.get("process") or {}).get("a") == 1
240
+ and not _re.search(config.ignore_profiles, frame.get("p") or "")
241
+ )
242
+ if active and not shot_active:
243
+ await camera.shot_started()
244
+ elif shot_active and not active:
245
+ asyncio.create_task(camera.shot_ended())
246
+ shot_active = active
247
+ if gap is None or gap > 60:
248
+ await router.on_machine_online(frame)
249
+ if state.get("sync_pending"):
250
+ log.info("machine is back; retrying pending journal sync")
251
+ state.set("sync_pending", False) # avoid re-trigger storms
252
+ schedule_sync(quiet=True)
188
253
  cache_frame(frame)
189
254
  await router.on_frame(frame)
190
255
  yield frame
@@ -247,6 +312,7 @@ async def _run(config: Config, *, replay: str | None, dry_run: bool) -> int:
247
312
  )
248
313
  except Exception: # noqa: BLE001 - keep watching even if messaging fails
249
314
  log.exception("questionnaire start failed (state kept for resume)")
315
+ await try_attach_clip(shot.entry.id)
250
316
  schedule_sync() # archive the .slog right away
251
317
 
252
318
  async def weekly_digest():
@@ -279,6 +345,8 @@ async def _run(config: Config, *, replay: str | None, dry_run: bool) -> int:
279
345
  if exc:
280
346
  raise exc
281
347
  finally:
348
+ if camera is not None:
349
+ await camera.stop()
282
350
  await messenger.stop()
283
351
  return 0
284
352