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.
- {matebot-0.2.2 → matebot-0.3.1}/AGENTS.md +6 -1
- {matebot-0.2.2 → matebot-0.3.1}/PKG-INFO +31 -1
- {matebot-0.2.2 → matebot-0.3.1}/README.md +30 -0
- {matebot-0.2.2 → matebot-0.3.1}/flake.nix +2 -2
- {matebot-0.2.2 → matebot-0.3.1}/pyproject.toml +1 -1
- matebot-0.3.1/scripts/discord_cmd_smoke.py +62 -0
- matebot-0.3.1/scripts/discord_smoke.py +75 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/__init__.py +1 -1
- matebot-0.3.1/src/matebot/calibrate.py +90 -0
- matebot-0.3.1/src/matebot/camera.py +141 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/cli.py +104 -7
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/commands.py +174 -33
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/config.py +17 -3
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/machine.py +15 -2
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/messengers/base.py +5 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/messengers/discord.py +9 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/messengers/telegram.py +15 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/plot.py +24 -1
- matebot-0.3.1/src/matebot/render.py +129 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/sitegen.py +35 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/sync.py +7 -3
- matebot-0.3.1/src/matebot/video.py +88 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/watcher.py +32 -5
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/web/app.js +74 -1
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/web/style.css +5 -0
- matebot-0.3.1/src/matebot/web_cam/index.html +107 -0
- matebot-0.3.1/tests/test_calibrate.py +65 -0
- matebot-0.3.1/tests/test_camera.py +73 -0
- {matebot-0.2.2 → matebot-0.3.1}/tests/test_commands.py +168 -8
- matebot-0.3.1/tests/test_render.py +89 -0
- {matebot-0.2.2 → matebot-0.3.1}/tests/test_sync_retry.py +2 -2
- matebot-0.3.1/tests/test_video.py +86 -0
- {matebot-0.2.2 → matebot-0.3.1}/tests/test_watcher.py +42 -0
- {matebot-0.2.2 → matebot-0.3.1}/.github/workflows/ci.yml +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/.github/workflows/docker.yml +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/.github/workflows/release.yml +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/.gitignore +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/CLAUDE.md +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/Dockerfile +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/LICENSE +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/docker-compose.example.yml +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/docs/screenshots/journal-detail.png +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/docs/screenshots/journal-list.png +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/docs/screenshots/telegram-chat.png +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/flake.lock +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/bags.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/conversation.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/digest.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/hints.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/messengers/__init__.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/slog.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/state.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/web/index.html +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/web/vendor/chart.umd.js +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/src/matebot/web/vendor/chartjs-plugin-annotation.min.js +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/tests/fixtures/000004.slog +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/tests/fixtures/status_frames.jsonl +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/tests/test_bags.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/tests/test_cleaning.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/tests/test_conversation.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/tests/test_digest.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/tests/test_hints.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/tests/test_plot.py +0 -0
- {matebot-0.2.2 → matebot-0.3.1}/tests/test_sitegen.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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())
|
|
@@ -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(
|
|
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,
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|