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