diff --git a/README.md b/README.md index a3399de..ad7f19d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ SD-kaart insteken 4. Check vrije ruimte — verwijder oudste datum-mappen indien nodig 5. Copy + verify + delete per bestand, met retry 6. Early-abort bij aanhoudende I/O-fouten (SD-kaart defect) - 7. Stuur WhatsApp notificatie via wwebjs-api + 7. Stuur Matrix notificatie via sherpa-bot SD-kaart uittrekken → udev REMOVE trigger (zelfde vendor:product) @@ -39,22 +39,22 @@ mount/umount-rechten op Fedora met SELinux in enforcing mode. ## Robuustheid -Het script is ontworpen tegen een geziene productie-fout (defecte/flakke +Het script is ontworpen tegen een bekende productie-fout (defecte/flakke SD-kaartlezer die midden in de transfer offline klapt, `I/O error`, `device offline`): - **Echte fout zichtbaar** — kopieerfouten worden als `OSError` met reden gelogd (i.p.v. alleen "exit status 1"); de eerdere `cp`-stderr ging verloren. - **Pre-flight leesbaarheidscheck** — voordat oude datum-mappen op de SSD worden verwijderd, wordt eerst een bestand van de kaart gelezen. Een onleesbare kaart - resulteert in een duidelijke WhatsApp-"GESTOPT" en **niets** wordt gewist. + resulteert in een duidelijke Matrix-"GESTOPT" en **niets** wordt gewist. - **Retry per bestand** — 3 pogingen met 2s pauze; vangt transiënte USB-glitches. - **Early-abort** — bij 5 opeenvolgende fouten stopt het script en stuurt het een - WhatsApp met de laatste fout. Bronbestanden worden nooit verwijderd zonder + Matrix-bericht met de laatste fout. Bronbestanden worden nooit verwijderd zonder geverifieerde kopie. - **Geen crash mid-loop** — alle bestandssysteem-calls zijn afgeschermd. - **fsck.exfat** — na mount draait het mount-script een read-only health check; een dirty/defecte kaart wordt daarmee zichtbaar in syslog. -- **Geen dubbele runs** — het mount-script skip als de transfer al actief is. +- **Geen dubbele runs** — het mount-script skipt als de transfer al actief is. - **Stale-mount opruiming** — als de kaartlezer wordt uitgetrokken (of de kaart offline klapt) ruimt een udev REMOVE-trigger de mount op: de service wordt gestopt en `/mnt/sdcard` netjes ontkoppeld (lazy als dat mislukt). Zo blijft er @@ -82,7 +82,7 @@ kernel-device (b.v. `sde1`) als argument — zo werkt het ongeacht het filesyste └── ... ``` -Bestandsnaam: `YYYYMMDDHHMMSS_.MP4` +Bestandsnaam: `YYYYMMDDHHMMSS_.MP4` - `F` = front, `B` = back - ~381 MB per clip (~5 minuten) @@ -99,7 +99,7 @@ Bestandsnaam: `YYYYMMDDHHMMSS_.MP4` ## Operations-handleiding -### WhatsApp berichten +### Matrix-berichten Na elke transfer stuurt het script een notificatie naar de beheerder. Er zijn drie basissituaties: @@ -140,7 +140,7 @@ journalctl -t dashcam-transfer --since today grep dashcam-transfer /var/log/messages # I/O- en USB-fouten van de lezer -dmesg | grep -Ei "usb|sd[a-z]|dided|device offline|I/O error" +dmesg | grep -Ei "usb|sd[a-z]|device offline|I/O error" ``` ### Troubleshooting @@ -152,7 +152,7 @@ dmesg | grep -Ei "usb|sd[a-z]|dided|device offline|I/O error" | `Geen MP4 bestanden gevonden` in log | Kaart leeg (transfer geslaagd) of verkeerde mapstructuur | Niks mis; controleer op de SSD of bestanden er zijn | | Transfer start niet na insteken | Service draait al (is-active gate) of device verscheen niet binnen 60s | Logs checken; als het device nooit komt, lezer opnieuw insteken | | Stale mount in `mount`-output | REMOVE-trigger miste (lezer trok in één keer beide devices weg) | Cleanup-script handmatig draaien (zie herstel) | -| `WhatsApp notificatie mislukt` in log | wwebjs-api container down | `docker compose` in `/home/aiaiaiaiagent/wwebjs-api/` controleren | +| `Matrix notificatie mislukt` in log | sherpa-token/room-file ontbreken of Matrix-homeserver onbereikbaar | `~/whatsed-new/secrets/sherpa-token` + `eddy-report-room` controleren (mode 600), `https://whatsed.eddydevink.nl` checken | | Abort: onvoldoende vrije ruimte | SSD vol, opschonen hielp niet | Oude datum-mappen handmatig opschonen of SSD uitbreiden | ### Herstelprocedures @@ -193,8 +193,9 @@ systemctl start dashcam-transfer | `MAX_RETRIES` | `3` | Kopieerpogingen per bestand | | `RETRY_DELAY` | `2` | Pauze tussen pogingen (seconden) | | `MAX_CONSECUTIVE_ERRORS` | `5` | Aantal opeenvolgende fouten vóór abort | -| `WHATSAPP_API` | `http://localhost:3001/client/sendMessage/EdGpt` | wwebjs-api endpoint (localhost = nextcloud zelf) | -| `WHATSAPP_CHAT` | `31620248011@c.us` | Ontvanger van notificaties | +| `MATRIX_API` | `https://whatsed.eddydevink.nl/_matrix/client/v3` | Matrix homeserver client API | +| `MATRIX_TOKEN_FILE` | `/home/aiaiaiaiagent/whatsed-new/secrets/sherpa-token` | Compat-token van `@sherpa:whatsed.eddydevink.nl` | +| `MATRIX_ROOM_FILE` | `/home/aiaiaiaiagent/whatsed-new/secrets/eddy-report-room` | DM sherpa → Eddy (Systeemrapportage) | ### `dashcam-transfer-mount.sh` @@ -214,25 +215,30 @@ systemctl start dashcam-transfer | SD-kaart UUID | `00D3-7715` | | SSD UUID | `3027-8ECA` | -## WhatsApp notificatie +## Matrix notificatie -De API (`wwebjs-api`) draait als Docker container op nextcloud zelf: +De notificatie wordt verstuurd als `@sherpa:whatsed.eddydevink.nl` naar de bestaande +DM "Systeemrapportage" met `@eddy:whatsed.eddydevink.nl` (zelfde kanaal als +watchdog-rapportage, Home Assistant en Uptime Kuma): | Config | Waarde | |---|---| -| API endpoint | `http://localhost:3001/client/sendMessage/EdGpt` | -| Sessie | `EdGpt` | -| Ontvanger | `31620248011@c.us` (privé chat Eddy) | +| API | `https://whatsed.eddydevink.nl/_matrix/client/v3` | +| Bot-account | `@sherpa:whatsed.eddydevink.nl` (compat-token `mct_…`) | +| Tokenbestand | `/home/aiaiaiaiagent/whatsed-new/secrets/sherpa-token` (mode 600) | +| Roombestand | `/home/aiaiaiaiagent/whatsed-new/secrets/eddy-report-room` (mode 600) | +| Bericht | `PUT /rooms//send/m.room.message/…` met `m.text`-event | -De container: `docker-compose.yml` in `/home/aiaiaiaiagent/wwebjs-api/`. -De notificatie zelf is een JSON-payload naar het endpoint; een mislukte notificatie -wordt alleen gelogd en breekt de transfer niet af. +Token en room-ID blijven buiten Git; de bestanden worden bij elke notificatie gelezen. +Een mislukte notificatie wordt alleen gelogd en breekt de transfer niet af. Verloopt +het token → nieuw compat-token uitgeven met `mas-cli manage issue-compatibility-token +sherpa` en het bestand bijwerken (zie [[Homelab/Software Infrastructuur/Matrix Bots]]). ## Repo-structuur ``` dashcam-transfer/ -├── dashcam-transfer.py Hoofdscript: transfer + WhatsApp-notificatie +├── dashcam-transfer.py Hoofdscript: transfer + Matrix-notificatie ├── dashcam-transfer-mount.sh Udev add-trigger: wachten, mount, fsck, service start ├── dashcam-transfer-cleanup.sh Udev remove-trigger: stale mount opruimen ├── udev/ @@ -295,7 +301,7 @@ trigger-scripts loggen naar syslog met tag `dashcam-transfer`. | Suite | Commando | Aantal | Dekking | |---|---|---|---| -| Python unit | `python3 -m unittest discover -s tests` | 20 | Naam-parsing, directory-scans, kopie/retry, bestandsvergelijking, ruimte-opschoning, WhatsApp-berichten | +| Python unit | `python3 -m unittest discover -s tests` | 22 | Naam-parsing, directory-scans, kopie/retry, bestandsvergelijking, ruimte-opschoning, Matrix-berichten | | Mount-script | `./tests/test_mount_script.sh` | 7 | Skip bij actieve service, mount + service start, fsck-uitvoering, logging | | Cleanup-script | `./tests/test_cleanup_script.sh` | 12 | Stale-detectie (device weg / naam-match), service stop, umount + lazy-fallback, no-op bij gezonde mount | diff --git a/dashcam-transfer.py b/dashcam-transfer.py index ac49b61..7c106d8 100644 --- a/dashcam-transfer.py +++ b/dashcam-transfer.py @@ -26,8 +26,9 @@ MAX_RETRIES = 3 RETRY_DELAY = 2 MAX_CONSECUTIVE_ERRORS = 5 -WHATSAPP_API = "http://localhost:3001/client/sendMessage/EdGpt" # localhost = nextcloud server, niet Mac Mini! -WHATSAPP_CHAT = "31620248011@c.us" +MATRIX_API = "https://whatsed.eddydevink.nl/_matrix/client/v3" +MATRIX_TOKEN_FILE = "/home/aiaiaiaiagent/whatsed-new/secrets/sherpa-token" +MATRIX_ROOM_FILE = "/home/aiaiaiaiagent/whatsed-new/secrets/eddy-report-room" UNREADABLE_REASON = ( "SD-kaart onleesbaar (I/O-fout): lezer/kaart defect of niet netjes afgemeld. " @@ -237,7 +238,7 @@ def transfer_files(files, source_dir, target_base): return ok, errors -def send_whatsapp_notification(ok, errors, total_bytes, aborted=False, reason=None): +def send_matrix_notification(ok, errors, total_bytes, aborted=False, reason=None): gb = total_bytes / (1024 ** 3) if aborted: body = ( @@ -254,16 +255,26 @@ def send_whatsapp_notification(ok, errors, total_bytes, aborted=False, reason=No f"❌ Dashcam transfer voltooid: {ok} bestanden ({gb:.1f} GB) " f"overgezet, {errors} fout(en)" ) - payload = {"chatId": WHATSAPP_CHAT, "content": body, "contentType": "string"} try: + with open(MATRIX_TOKEN_FILE) as f: + token = f.read().strip() + with open(MATRIX_ROOM_FILE) as f: + room = f.read().strip() + if not token or not room: + raise OSError("token of room-ID onbekend (lege secret-file)") + txn_id = "dashcam-%d" % int(time.time() * 1000) + url = "%s/rooms/%s/send/m.room.message/%s" % (MATRIX_API, room, txn_id) + payload = {"msgtype": "m.text", "body": body} data = json.dumps(payload).encode() req = urllib.request.Request( - WHATSAPP_API, data=data, headers={"Content-Type": "application/json"} + url, data=data, method="PUT", + headers={"Content-Type": "application/json", + "Authorization": "Bearer " + token}, ) urllib.request.urlopen(req, timeout=10) - logging.info("WhatsApp notificatie verstuurd") + logging.info("Matrix notificatie verstuurd") except Exception as e: - logging.warning("WhatsApp notificatie mislukt: %s", e) + logging.warning("Matrix notificatie mislukt: %s", e) def parse_args(): @@ -285,7 +296,7 @@ def main(): files = get_mp4_files(SOURCE_DIR) if files is None: - send_whatsapp_notification(0, 0, 0, aborted=True, reason=UNREADABLE_REASON) + send_matrix_notification(0, 0, 0, aborted=True, reason=UNREADABLE_REASON) return if not files: logging.info("Geen MP4 bestanden gevonden in %s", SOURCE_DIR) @@ -295,7 +306,7 @@ def main(): files = files[:args.limit] if not check_source_readable(SOURCE_DIR, files): - send_whatsapp_notification(0, 0, 0, aborted=True, reason=UNREADABLE_REASON) + send_matrix_notification(0, 0, 0, aborted=True, reason=UNREADABLE_REASON) return total_size = 0 @@ -304,7 +315,7 @@ def main(): total_size += os.path.getsize(os.path.join(SOURCE_DIR, fname)) except OSError as e: logging.error("Grootte van %s niet leesbaar: %s", fname, e) - send_whatsapp_notification(0, 0, 0, aborted=True, reason=UNREADABLE_REASON) + send_matrix_notification(0, 0, 0, aborted=True, reason=UNREADABLE_REASON) return logging.info("Start transfer: %d bestanden, %d bytes", len(files), total_size) @@ -312,7 +323,7 @@ def main(): try: ensure_free_space(total_size, TARGET_BASE) except SystemExit: - send_whatsapp_notification( + send_matrix_notification( 0, 0, total_size, aborted=True, reason="Onvoldoende vrije ruimte op de SSD, ook na opschonen.", ) @@ -322,11 +333,11 @@ def main(): ok, errors = transfer_files(files, SOURCE_DIR, TARGET_BASE) except TransferAborted as e: logging.info("Transfer gestopt: %d ok, %d fouten", e.ok, e.errors) - send_whatsapp_notification(e.ok, e.errors, total_size, aborted=True, reason=e.reason) + send_matrix_notification(e.ok, e.errors, total_size, aborted=True, reason=e.reason) return logging.info("Transfer voltooid: %d ok, %d fouten", ok, errors) - send_whatsapp_notification(ok, errors, total_size) + send_matrix_notification(ok, errors, total_size) if __name__ == "__main__": diff --git a/tests/test_dashcam_transfer.py b/tests/test_dashcam_transfer.py index bcf6f2f..c8c32d2 100644 --- a/tests/test_dashcam_transfer.py +++ b/tests/test_dashcam_transfer.py @@ -215,36 +215,67 @@ class EnsureFreeSpaceTest(unittest.TestCase): dashcam.ensure_free_space(10_000, self.base) -class SendWhatsappTest(unittest.TestCase): +class SendMatrixTest(unittest.TestCase): def setUp(self): patcher = mock.patch.object(dashcam.urllib.request, "urlopen") self.mock_urlopen = patcher.start() self.mock_urlopen.return_value.__enter__.return_value = mock.Mock() self.addCleanup(patcher.stop) + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + token_file = os.path.join(self.tmp.name, "sherpa-token") + room_file = os.path.join(self.tmp.name, "eddy-report-room") + with open(token_file, "w") as f: + f.write("mct_testtoken\n") + with open(room_file, "w") as f: + f.write("!testroom:whatsed.eddydevink.nl\n") + p1 = mock.patch.object(dashcam, "MATRIX_TOKEN_FILE", token_file) + p2 = mock.patch.object(dashcam, "MATRIX_ROOM_FILE", room_file) + p1.start() + p2.start() + self.addCleanup(p1.stop) + self.addCleanup(p2.stop) + def _body(self): req = self.mock_urlopen.call_args[0][0] - return json.loads(req.data)["content"] + return json.loads(req.data)["body"] + + def _request(self): + return self.mock_urlopen.call_args[0][0] def test_success_message(self): - dashcam.send_whatsapp_notification(5, 0, 381_000_000) + dashcam.send_matrix_notification(5, 0, 381_000_000) self.assertIn("5 bestanden", self._body()) self.assertIn("geen fouten", self._body()) def test_errors_message(self): - dashcam.send_whatsapp_notification(3, 2, 100_000_000) + dashcam.send_matrix_notification(3, 2, 100_000_000) self.assertIn("2 fout(en)", self._body()) def test_aborted_message(self): - dashcam.send_whatsapp_notification( + dashcam.send_matrix_notification( 1, 4, 100_000_000, aborted=True, reason="SD-kaart onleesbaar" ) self.assertIn("GESTOPT", self._body()) self.assertIn("SD-kaart onleesbaar", self._body()) + def test_sends_room_message_with_bearer_token(self): + dashcam.send_matrix_notification(1, 0, 10_000) + req = self._request() + self.assertEqual(req.get_method(), "PUT") + self.assertEqual(req.get_header("Authorization"), "Bearer mct_testtoken") + self.assertIn("/rooms/!testroom:whatsed.eddydevink.nl/send/m.room.message/", req.full_url) + self.assertEqual(json.loads(req.data)["msgtype"], "m.text") + + def test_missing_secrets_is_swallowed(self): + with mock.patch.object(dashcam, "MATRIX_TOKEN_FILE", "/nonexistent/token"): + dashcam.send_matrix_notification(0, 0, 0) + self.mock_urlopen.assert_not_called() + def test_api_failure_is_swallowed(self): self.mock_urlopen.side_effect = Exception("boom") - dashcam.send_whatsapp_notification(0, 0, 0) + dashcam.send_matrix_notification(0, 0, 0) if __name__ == "__main__":