feat: verstuur dashcam-notificatie via Matrix (sherpa) i.p.v. WhatsApp

wwebjs-api is vervangen door wuzapi; notificatiekanaal is nu Matrix:
- send_matrix_notification() POST -> sherpa compat-token uit
  ~/whatsed-new/secrets/sherpa-token naar eddy-report-room
- zelfde patronen als watchdog-report/WAN IP Guard
- tests: SendWhatsappTest -> SendMatrixTest (+2 asserts)
This commit is contained in:
Eddy de Vink 2026-10-13 23:49:18 +02:00
commit 55c282906e
3 changed files with 88 additions and 40 deletions

View file

@ -23,7 +23,7 @@ SD-kaart insteken
4. Check vrije ruimte — verwijder oudste datum-mappen indien nodig 4. Check vrije ruimte — verwijder oudste datum-mappen indien nodig
5. Copy + verify + delete per bestand, met retry 5. Copy + verify + delete per bestand, met retry
6. Early-abort bij aanhoudende I/O-fouten (SD-kaart defect) 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 SD-kaart uittrekken
→ udev REMOVE trigger (zelfde vendor:product) → udev REMOVE trigger (zelfde vendor:product)
@ -39,22 +39,22 @@ mount/umount-rechten op Fedora met SELinux in enforcing mode.
## Robuustheid ## 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`): SD-kaartlezer die midden in de transfer offline klapt, `I/O error`, `device offline`):
- **Echte fout zichtbaar** — kopieerfouten worden als `OSError` met reden gelogd - **Echte fout zichtbaar** — kopieerfouten worden als `OSError` met reden gelogd
(i.p.v. alleen "exit status 1"); de eerdere `cp`-stderr ging verloren. (i.p.v. alleen "exit status 1"); de eerdere `cp`-stderr ging verloren.
- **Pre-flight leesbaarheidscheck** — voordat oude datum-mappen op de SSD worden - **Pre-flight leesbaarheidscheck** — voordat oude datum-mappen op de SSD worden
verwijderd, wordt eerst een bestand van de kaart gelezen. Een onleesbare kaart 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. - **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 - **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. geverifieerde kopie.
- **Geen crash mid-loop** — alle bestandssysteem-calls zijn afgeschermd. - **Geen crash mid-loop** — alle bestandssysteem-calls zijn afgeschermd.
- **fsck.exfat** — na mount draait het mount-script een read-only health check; - **fsck.exfat** — na mount draait het mount-script een read-only health check;
een dirty/defecte kaart wordt daarmee zichtbaar in syslog. 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 - **Stale-mount opruiming** — als de kaartlezer wordt uitgetrokken (of de kaart
offline klapt) ruimt een udev REMOVE-trigger de mount op: de service wordt 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 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_<sequentie><kamera>.MP4` Bestandsnaam: `YYYYMMDDHHMMSS_<sequentie><camera>.MP4`
- `F` = front, `B` = back - `F` = front, `B` = back
- ~381 MB per clip (~5 minuten) - ~381 MB per clip (~5 minuten)
@ -99,7 +99,7 @@ Bestandsnaam: `YYYYMMDDHHMMSS_<sequentie><kamera>.MP4`
## Operations-handleiding ## Operations-handleiding
### WhatsApp berichten ### Matrix-berichten
Na elke transfer stuurt het script een notificatie naar de beheerder. Er zijn drie Na elke transfer stuurt het script een notificatie naar de beheerder. Er zijn drie
basissituaties: basissituaties:
@ -140,7 +140,7 @@ journalctl -t dashcam-transfer --since today
grep dashcam-transfer /var/log/messages grep dashcam-transfer /var/log/messages
# I/O- en USB-fouten van de lezer # 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 ### 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 | | `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 | | 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) | | 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 | | Abort: onvoldoende vrije ruimte | SSD vol, opschonen hielp niet | Oude datum-mappen handmatig opschonen of SSD uitbreiden |
### Herstelprocedures ### Herstelprocedures
@ -193,8 +193,9 @@ systemctl start dashcam-transfer
| `MAX_RETRIES` | `3` | Kopieerpogingen per bestand | | `MAX_RETRIES` | `3` | Kopieerpogingen per bestand |
| `RETRY_DELAY` | `2` | Pauze tussen pogingen (seconden) | | `RETRY_DELAY` | `2` | Pauze tussen pogingen (seconden) |
| `MAX_CONSECUTIVE_ERRORS` | `5` | Aantal opeenvolgende fouten vóór abort | | `MAX_CONSECUTIVE_ERRORS` | `5` | Aantal opeenvolgende fouten vóór abort |
| `WHATSAPP_API` | `http://localhost:3001/client/sendMessage/EdGpt` | wwebjs-api endpoint (localhost = nextcloud zelf) | | `MATRIX_API` | `https://whatsed.eddydevink.nl/_matrix/client/v3` | Matrix homeserver client API |
| `WHATSAPP_CHAT` | `31620248011@c.us` | Ontvanger van notificaties | | `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` ### `dashcam-transfer-mount.sh`
@ -214,25 +215,30 @@ systemctl start dashcam-transfer
| SD-kaart UUID | `00D3-7715` | | SD-kaart UUID | `00D3-7715` |
| SSD UUID | `3027-8ECA` | | 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 | | Config | Waarde |
|---|---| |---|---|
| API endpoint | `http://localhost:3001/client/sendMessage/EdGpt` | | API | `https://whatsed.eddydevink.nl/_matrix/client/v3` |
| Sessie | `EdGpt` | | Bot-account | `@sherpa:whatsed.eddydevink.nl` (compat-token `mct_…`) |
| Ontvanger | `31620248011@c.us` (privé chat Eddy) | | Tokenbestand | `/home/aiaiaiaiagent/whatsed-new/secrets/sherpa-token` (mode 600) |
| Roombestand | `/home/aiaiaiaiagent/whatsed-new/secrets/eddy-report-room` (mode 600) |
| Bericht | `PUT /rooms/<id>/send/m.room.message/…` met `m.text`-event |
De container: `docker-compose.yml` in `/home/aiaiaiaiagent/wwebjs-api/`. Token en room-ID blijven buiten Git; de bestanden worden bij elke notificatie gelezen.
De notificatie zelf is een JSON-payload naar het endpoint; een mislukte notificatie Een mislukte notificatie wordt alleen gelogd en breekt de transfer niet af. Verloopt
wordt alleen gelogd en breekt de transfer niet af. 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 ## Repo-structuur
``` ```
dashcam-transfer/ 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-mount.sh Udev add-trigger: wachten, mount, fsck, service start
├── dashcam-transfer-cleanup.sh Udev remove-trigger: stale mount opruimen ├── dashcam-transfer-cleanup.sh Udev remove-trigger: stale mount opruimen
├── udev/ ├── udev/
@ -295,7 +301,7 @@ trigger-scripts loggen naar syslog met tag `dashcam-transfer`.
| Suite | Commando | Aantal | Dekking | | 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 | | 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 | | Cleanup-script | `./tests/test_cleanup_script.sh` | 12 | Stale-detectie (device weg / naam-match), service stop, umount + lazy-fallback, no-op bij gezonde mount |

View file

@ -26,8 +26,9 @@ MAX_RETRIES = 3
RETRY_DELAY = 2 RETRY_DELAY = 2
MAX_CONSECUTIVE_ERRORS = 5 MAX_CONSECUTIVE_ERRORS = 5
WHATSAPP_API = "http://localhost:3001/client/sendMessage/EdGpt" # localhost = nextcloud server, niet Mac Mini! MATRIX_API = "https://whatsed.eddydevink.nl/_matrix/client/v3"
WHATSAPP_CHAT = "31620248011@c.us" MATRIX_TOKEN_FILE = "/home/aiaiaiaiagent/whatsed-new/secrets/sherpa-token"
MATRIX_ROOM_FILE = "/home/aiaiaiaiagent/whatsed-new/secrets/eddy-report-room"
UNREADABLE_REASON = ( UNREADABLE_REASON = (
"SD-kaart onleesbaar (I/O-fout): lezer/kaart defect of niet netjes afgemeld. " "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 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) gb = total_bytes / (1024 ** 3)
if aborted: if aborted:
body = ( 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"❌ Dashcam transfer voltooid: {ok} bestanden ({gb:.1f} GB) "
f"overgezet, {errors} fout(en)" f"overgezet, {errors} fout(en)"
) )
payload = {"chatId": WHATSAPP_CHAT, "content": body, "contentType": "string"}
try: 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() data = json.dumps(payload).encode()
req = urllib.request.Request( 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) urllib.request.urlopen(req, timeout=10)
logging.info("WhatsApp notificatie verstuurd") logging.info("Matrix notificatie verstuurd")
except Exception as e: except Exception as e:
logging.warning("WhatsApp notificatie mislukt: %s", e) logging.warning("Matrix notificatie mislukt: %s", e)
def parse_args(): def parse_args():
@ -285,7 +296,7 @@ def main():
files = get_mp4_files(SOURCE_DIR) files = get_mp4_files(SOURCE_DIR)
if files is None: 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 return
if not files: if not files:
logging.info("Geen MP4 bestanden gevonden in %s", SOURCE_DIR) logging.info("Geen MP4 bestanden gevonden in %s", SOURCE_DIR)
@ -295,7 +306,7 @@ def main():
files = files[:args.limit] files = files[:args.limit]
if not check_source_readable(SOURCE_DIR, files): 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 return
total_size = 0 total_size = 0
@ -304,7 +315,7 @@ def main():
total_size += os.path.getsize(os.path.join(SOURCE_DIR, fname)) total_size += os.path.getsize(os.path.join(SOURCE_DIR, fname))
except OSError as e: except OSError as e:
logging.error("Grootte van %s niet leesbaar: %s", fname, 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 return
logging.info("Start transfer: %d bestanden, %d bytes", len(files), total_size) logging.info("Start transfer: %d bestanden, %d bytes", len(files), total_size)
@ -312,7 +323,7 @@ def main():
try: try:
ensure_free_space(total_size, TARGET_BASE) ensure_free_space(total_size, TARGET_BASE)
except SystemExit: except SystemExit:
send_whatsapp_notification( send_matrix_notification(
0, 0, total_size, aborted=True, 0, 0, total_size, aborted=True,
reason="Onvoldoende vrije ruimte op de SSD, ook na opschonen.", 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) ok, errors = transfer_files(files, SOURCE_DIR, TARGET_BASE)
except TransferAborted as e: except TransferAborted as e:
logging.info("Transfer gestopt: %d ok, %d fouten", e.ok, e.errors) 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 return
logging.info("Transfer voltooid: %d ok, %d fouten", ok, errors) 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__": if __name__ == "__main__":

View file

@ -215,36 +215,67 @@ class EnsureFreeSpaceTest(unittest.TestCase):
dashcam.ensure_free_space(10_000, self.base) dashcam.ensure_free_space(10_000, self.base)
class SendWhatsappTest(unittest.TestCase): class SendMatrixTest(unittest.TestCase):
def setUp(self): def setUp(self):
patcher = mock.patch.object(dashcam.urllib.request, "urlopen") patcher = mock.patch.object(dashcam.urllib.request, "urlopen")
self.mock_urlopen = patcher.start() self.mock_urlopen = patcher.start()
self.mock_urlopen.return_value.__enter__.return_value = mock.Mock() self.mock_urlopen.return_value.__enter__.return_value = mock.Mock()
self.addCleanup(patcher.stop) 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): def _body(self):
req = self.mock_urlopen.call_args[0][0] 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): 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("5 bestanden", self._body())
self.assertIn("geen fouten", self._body()) self.assertIn("geen fouten", self._body())
def test_errors_message(self): 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()) self.assertIn("2 fout(en)", self._body())
def test_aborted_message(self): def test_aborted_message(self):
dashcam.send_whatsapp_notification( dashcam.send_matrix_notification(
1, 4, 100_000_000, aborted=True, reason="SD-kaart onleesbaar" 1, 4, 100_000_000, aborted=True, reason="SD-kaart onleesbaar"
) )
self.assertIn("GESTOPT", self._body()) self.assertIn("GESTOPT", self._body())
self.assertIn("SD-kaart onleesbaar", 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): def test_api_failure_is_swallowed(self):
self.mock_urlopen.side_effect = Exception("boom") self.mock_urlopen.side_effect = Exception("boom")
dashcam.send_whatsapp_notification(0, 0, 0) dashcam.send_matrix_notification(0, 0, 0)
if __name__ == "__main__": if __name__ == "__main__":