matebot 0.2.2__tar.gz → 0.3.1__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 (65) hide show
  1. {matebot-0.2.2 → matebot-0.3.1}/AGENTS.md +6 -1
  2. {matebot-0.2.2 → matebot-0.3.1}/PKG-INFO +31 -1
  3. {matebot-0.2.2 → matebot-0.3.1}/README.md +30 -0
  4. {matebot-0.2.2 → matebot-0.3.1}/flake.nix +2 -2
  5. {matebot-0.2.2 → matebot-0.3.1}/pyproject.toml +1 -1
  6. matebot-0.3.1/scripts/discord_cmd_smoke.py +62 -0
  7. matebot-0.3.1/scripts/discord_smoke.py +75 -0
  8. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/__init__.py +1 -1
  9. matebot-0.3.1/src/matebot/calibrate.py +90 -0
  10. matebot-0.3.1/src/matebot/camera.py +141 -0
  11. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/cli.py +104 -7
  12. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/commands.py +174 -33
  13. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/config.py +17 -3
  14. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/machine.py +15 -2
  15. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/messengers/base.py +5 -0
  16. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/messengers/discord.py +9 -0
  17. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/messengers/telegram.py +15 -0
  18. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/plot.py +24 -1
  19. matebot-0.3.1/src/matebot/render.py +129 -0
  20. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/sitegen.py +35 -0
  21. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/sync.py +7 -3
  22. matebot-0.3.1/src/matebot/video.py +88 -0
  23. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/watcher.py +32 -5
  24. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/web/app.js +74 -1
  25. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/web/style.css +5 -0
  26. matebot-0.3.1/src/matebot/web_cam/index.html +107 -0
  27. matebot-0.3.1/tests/test_calibrate.py +65 -0
  28. matebot-0.3.1/tests/test_camera.py +73 -0
  29. {matebot-0.2.2 → matebot-0.3.1}/tests/test_commands.py +168 -8
  30. matebot-0.3.1/tests/test_render.py +89 -0
  31. {matebot-0.2.2 → matebot-0.3.1}/tests/test_sync_retry.py +2 -2
  32. matebot-0.3.1/tests/test_video.py +86 -0
  33. {matebot-0.2.2 → matebot-0.3.1}/tests/test_watcher.py +42 -0
  34. {matebot-0.2.2 → matebot-0.3.1}/.github/workflows/ci.yml +0 -0
  35. {matebot-0.2.2 → matebot-0.3.1}/.github/workflows/docker.yml +0 -0
  36. {matebot-0.2.2 → matebot-0.3.1}/.github/workflows/release.yml +0 -0
  37. {matebot-0.2.2 → matebot-0.3.1}/.gitignore +0 -0
  38. {matebot-0.2.2 → matebot-0.3.1}/CLAUDE.md +0 -0
  39. {matebot-0.2.2 → matebot-0.3.1}/Dockerfile +0 -0
  40. {matebot-0.2.2 → matebot-0.3.1}/LICENSE +0 -0
  41. {matebot-0.2.2 → matebot-0.3.1}/docker-compose.example.yml +0 -0
  42. {matebot-0.2.2 → matebot-0.3.1}/docs/screenshots/journal-detail.png +0 -0
  43. {matebot-0.2.2 → matebot-0.3.1}/docs/screenshots/journal-list.png +0 -0
  44. {matebot-0.2.2 → matebot-0.3.1}/docs/screenshots/telegram-chat.png +0 -0
  45. {matebot-0.2.2 → matebot-0.3.1}/flake.lock +0 -0
  46. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/bags.py +0 -0
  47. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/conversation.py +0 -0
  48. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/digest.py +0 -0
  49. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/hints.py +0 -0
  50. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/messengers/__init__.py +0 -0
  51. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/slog.py +0 -0
  52. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/state.py +0 -0
  53. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/web/index.html +0 -0
  54. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/web/vendor/chart.umd.js +0 -0
  55. {matebot-0.2.2 → matebot-0.3.1}/src/matebot/web/vendor/chartjs-plugin-annotation.min.js +0 -0
  56. {matebot-0.2.2 → matebot-0.3.1}/tests/fixtures/000004.slog +0 -0
  57. {matebot-0.2.2 → matebot-0.3.1}/tests/fixtures/status_frames.jsonl +0 -0
  58. {matebot-0.2.2 → matebot-0.3.1}/tests/test_bags.py +0 -0
  59. {matebot-0.2.2 → matebot-0.3.1}/tests/test_cleaning.py +0 -0
  60. {matebot-0.2.2 → matebot-0.3.1}/tests/test_conversation.py +0 -0
  61. {matebot-0.2.2 → matebot-0.3.1}/tests/test_digest.py +0 -0
  62. {matebot-0.2.2 → matebot-0.3.1}/tests/test_hints.py +0 -0
  63. {matebot-0.2.2 → matebot-0.3.1}/tests/test_plot.py +0 -0
  64. {matebot-0.2.2 → matebot-0.3.1}/tests/test_sitegen.py +0 -0
  65. {matebot-0.2.2 → matebot-0.3.1}/tests/test_slog.py +0 -0
@@ -43,7 +43,12 @@ 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). Camera shots auto-calibrate
49
+ their chart-sync offset from the pump's audio onset (calibrate.py) and get
50
+ a rendered clip+chart "shot reel" sent to the chat (render.py,
51
+ MATEBOT_REEL=0 to disable; needs matplotlib).
47
52
 
48
53
  Common failure modes: token/chat id swapped or quoted wrong; bot and machine
49
54
  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.1
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,36 @@ 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 is calibrated automatically: the pump is loud, so MATEbot aligns its
182
+ audio onset in the clip with the pump command in the shot data and writes the
183
+ exact per-shot offset. If a clip has no usable audio the fallback is
184
+ `MATEBOT_CAMERA_OFFSET` (default `-1.0`), and `/vsync +0.5` still nudges any
185
+ shot manually. Videos rotate out after `MATEBOT_VIDEO_KEEP` shots (git
186
+ history keeps every clip recoverable).
187
+
188
+ After each recorded shot MATEbot also renders a **shot reel** — a portrait
189
+ video with the clip on top and the chart animating below it, playhead
190
+ tracking the x axis — and sends it to the chat (ready for sharing). Disable
191
+ with `MATEBOT_REEL=0`; it needs ffmpeg and the `plots` extra (matplotlib).
162
192
 
163
193
  ## Configuration
164
194
 
@@ -125,6 +125,36 @@ 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 is calibrated automatically: the pump is loud, so MATEbot aligns its
148
+ audio onset in the clip with the pump command in the shot data and writes the
149
+ exact per-shot offset. If a clip has no usable audio the fallback is
150
+ `MATEBOT_CAMERA_OFFSET` (default `-1.0`), and `/vsync +0.5` still nudges any
151
+ shot manually. Videos rotate out after `MATEBOT_VIDEO_KEEP` shots (git
152
+ history keeps every clip recoverable).
153
+
154
+ After each recorded shot MATEbot also renders a **shot reel** — a portrait
155
+ video with the clip on top and the chart animating below it, playhead
156
+ tracking the x axis — and sends it to the chat (ready for sharing). Disable
157
+ with `MATEBOT_REEL=0`; it needs ffmpeg and the `plots` extra (matplotlib).
128
158
 
129
159
  ## Configuration
130
160
 
@@ -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.1";
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.1"
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.1"
@@ -0,0 +1,90 @@
1
+ """Auto-calibrate the video/chart sync offset from the pump's audio onset.
2
+
3
+ A vibratory pump is loud. The moment it kicks in is visible in the shot data
4
+ (``tp`` — target pressure — leaves zero) and audible in the clip, so aligning
5
+ the two gives the exact offset with no manual ``/vsync`` needed:
6
+
7
+ offset = t_pump_in_video - t_pump_in_chart
8
+
9
+ Pure python on 8 kHz mono PCM — no numpy. Returns ``None`` whenever the
10
+ signal is unclear; callers then keep the configured default.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import struct
18
+ from pathlib import Path
19
+
20
+ from .slog import parse_slog
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ SAMPLE_RATE = 8000
25
+ WINDOW_S = 0.05
26
+ SUSTAIN_WINDOWS = 6 # onset must stay loud for 0.3 s (a clink won't)
27
+
28
+
29
+ async def audio_onset(clip: Path) -> float | None:
30
+ """Time (s) at which the clip's audio gets loud and stays loud."""
31
+ try:
32
+ proc = await asyncio.create_subprocess_exec(
33
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(clip),
34
+ "-vn", "-ac", "1", "-ar", str(SAMPLE_RATE), "-f", "s16le", "pipe:1",
35
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
36
+ )
37
+ except FileNotFoundError:
38
+ return None
39
+ pcm, _ = await proc.communicate()
40
+ if proc.returncode != 0 or len(pcm) < SAMPLE_RATE: # needs ≥0.5 s of audio
41
+ return None
42
+ n = len(pcm) // 2
43
+ samples = struct.unpack(f"<{n}h", pcm[: n * 2])
44
+ win = int(WINDOW_S * SAMPLE_RATE)
45
+ rms = [
46
+ (sum(x * x for x in samples[i : i + win]) / win) ** 0.5
47
+ for i in range(0, n - win, win)
48
+ ]
49
+ if len(rms) < 30:
50
+ return None
51
+ head = sorted(rms[:20])
52
+ base = head[len(head) // 2] # median of the first second
53
+ peak = max(rms)
54
+ if peak < max(base, 1.0) * 4: # no clear loud event (or mic muted)
55
+ return None
56
+ thresh = max(base * 5, peak * 0.25)
57
+ for i in range(len(rms) - SUSTAIN_WINDOWS):
58
+ if all(r > thresh for r in rms[i : i + SUSTAIN_WINDOWS]):
59
+ return round(i * WINDOW_S, 2)
60
+ return None
61
+
62
+
63
+ def pump_start(slog_bytes: bytes) -> float | None:
64
+ """Time (s) at which the shot data first commands the pump."""
65
+ shot = parse_slog(slog_bytes)
66
+ step = shot.sample_interval_ms / 1000
67
+ for key, thresh in (("tp", 0.1), ("fl", 0.3)):
68
+ for i, v in enumerate(shot.series.get(key, [])):
69
+ if v > thresh:
70
+ return round(i * step, 2)
71
+ return None
72
+
73
+
74
+ async def calibrate_offset(repo: str | Path, shot_id: int) -> float | None:
75
+ """Offset for the shot's sidecar (video t = chart t + offset), or None."""
76
+ repo = Path(repo)
77
+ sid = f"{shot_id:06d}"
78
+ clip = repo / "shots" / f"{sid}.mp4"
79
+ slog = repo / "shots" / f"{sid}.slog"
80
+ if not clip.exists() or not slog.exists():
81
+ return None
82
+ t_video = await audio_onset(clip)
83
+ t_chart = pump_start(slog.read_bytes())
84
+ if t_video is None or t_chart is None:
85
+ return None
86
+ offset = round(t_video - t_chart, 2)
87
+ if not -5.0 <= offset <= 5.0: # implausible match, don't trust it
88
+ log.info("calibration rejected (offset %+.2fs out of range)", offset)
89
+ return None
90
+ return offset
@@ -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,81 @@ 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
+ asyncio.create_task(post_video(sid))
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
+ async def post_video(sid: int) -> None:
185
+ """After a clip attaches: auto-calibrate its sync offset from the
186
+ pump's audio onset, then render + send the shot reel."""
187
+ try:
188
+ from .calibrate import calibrate_offset
189
+ from .video import set_offset
190
+
191
+ offset = await calibrate_offset(config.data_repo, sid)
192
+ if offset is not None:
193
+ set_offset(config.data_repo, sid, offset)
194
+ log.info("shot %06d video offset auto-calibrated to %+.2fs", sid, offset)
195
+ except Exception as exc: # noqa: BLE001 - calibration is best-effort
196
+ log.warning("offset calibration failed: %s", exc)
197
+ schedule_sync(quiet=True)
198
+ if not config.reel_enabled:
199
+ return
200
+ try:
201
+ from .render import RenderError, render_reel
202
+
203
+ reel = await render_reel(config.data_repo, sid, title=f"Shot #{sid}")
204
+ try:
205
+ await messenger.send_video(reel.read_bytes(), f"🎬 Shot #{sid}")
206
+ finally:
207
+ reel.unlink(missing_ok=True)
208
+ except RenderError as exc:
209
+ log.info("no reel for shot %06d: %s", sid, exc)
210
+ except Exception as exc: # noqa: BLE001 - never let the reel kill the bot
211
+ log.warning("reel for shot %06d failed: %s", sid, exc)
212
+
213
+ if config.camera_enabled and config.data_repo:
214
+ import shutil as _shutil
215
+ import time as _time
216
+
217
+ from .camera import CameraServer
218
+
219
+ async def on_clip(webm_path):
220
+ keep = pathlib.Path(config.state_dir) / "pending_clip.webm"
221
+ _shutil.copy(webm_path, keep)
222
+ pending_clip["path"] = str(keep)
223
+ pending_clip["ts"] = _time.monotonic()
224
+ await try_attach_clip()
225
+
226
+ camera = CameraServer(config.camera_port, on_clip)
227
+ await camera.start()
149
228
  # messenger APIs can be flaky at boot; retry instead of crash-looping
150
229
  for attempt in range(8):
151
230
  try:
@@ -175,16 +254,31 @@ async def _run(config: Config, *, replay: str | None, dry_run: bool) -> int:
175
254
 
176
255
  last_frame_at = 0.0
177
256
 
257
+ shot_active = False
258
+
178
259
  async def tee(source):
179
- nonlocal last_frame_at
260
+ nonlocal last_frame_at, shot_active
180
261
  async for frame in source:
181
262
  now = _time.monotonic()
182
263
  gap = now - last_frame_at if last_frame_at else None
183
264
  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)
265
+ if camera is not None and frame.get("tp") == "evt:status":
266
+ active = (
267
+ frame.get("m") == 1
268
+ and (frame.get("process") or {}).get("a") == 1
269
+ and not _re.search(config.ignore_profiles, frame.get("p") or "")
270
+ )
271
+ if active and not shot_active:
272
+ await camera.shot_started()
273
+ elif shot_active and not active:
274
+ asyncio.create_task(camera.shot_ended())
275
+ shot_active = active
276
+ if gap is None or gap > 60:
277
+ await router.on_machine_online(frame)
278
+ if state.get("sync_pending"):
279
+ log.info("machine is back; retrying pending journal sync")
280
+ state.set("sync_pending", False) # avoid re-trigger storms
281
+ schedule_sync(quiet=True)
188
282
  cache_frame(frame)
189
283
  await router.on_frame(frame)
190
284
  yield frame
@@ -247,6 +341,7 @@ async def _run(config: Config, *, replay: str | None, dry_run: bool) -> int:
247
341
  )
248
342
  except Exception: # noqa: BLE001 - keep watching even if messaging fails
249
343
  log.exception("questionnaire start failed (state kept for resume)")
344
+ await try_attach_clip(shot.entry.id)
250
345
  schedule_sync() # archive the .slog right away
251
346
 
252
347
  async def weekly_digest():
@@ -279,6 +374,8 @@ async def _run(config: Config, *, replay: str | None, dry_run: bool) -> int:
279
374
  if exc:
280
375
  raise exc
281
376
  finally:
377
+ if camera is not None:
378
+ await camera.stop()
282
379
  await messenger.stop()
283
380
  return 0
284
381