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:
parent
0227dbda3f
commit
55c282906e
3 changed files with 88 additions and 40 deletions
48
README.md
48
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_<sequentie><kamera>.MP4`
|
||||
Bestandsnaam: `YYYYMMDDHHMMSS_<sequentie><camera>.MP4`
|
||||
- `F` = front, `B` = back
|
||||
- ~381 MB per clip (~5 minuten)
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ Bestandsnaam: `YYYYMMDDHHMMSS_<sequentie><kamera>.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/<id>/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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
Loading…
Reference in a new issue