diff --git a/CHANGELOG.md b/CHANGELOG.md index 968eb83..5a01af4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ All notable changes to FaceID. The Home Assistant app shows this file in the update dialog; standalone users can watch GitHub releases. +## 0.25.0 — 2026-10-06 + +- **Folder mode is available in the Home Assistant app.** The directory watcher has existed + in the standalone service since 0.23.0, but the app never offered it: no `folder` options, + `frigate_url` was mandatory, and `/media` and `/share` were not mounted at all — so even a + hand-written path could not be read. Requested in #31 by a user with five Reolink cameras + and no Frigate, uploading snapshots to `/media` over FTP. +- `frigate_url` may now be left empty. An empty URL means no Frigate: the service falls back + to a no-op client, and the gallery, unknown review and history stay usable. +- `/media` and `/share` are mounted **read-only**. FaceID never changes or deletes what it + watches, and no other host path is visible to the app. +- New options: `folder_enabled`, `folder_path`, `folder_camera`, `folder_recursive`, + `folder_process_existing`, `folder_extensions`, `folder_poll_interval`, + `folder_settle_seconds`, `folder_max_frames`, `folder_max_people_per_file`, + `folder_max_indexed_files`. +- The app now refuses to start with neither input configured, and warns on start when + `folder_path` does not exist inside the container — naming the two mounted roots, since a + path the app cannot see is the likeliest mistake. +- **In folder mode the camera sensor appears at startup**, not only after the first + recognition. The folder has no camera list to query, so its name is taken from the + configuration; without that a fresh install looked like it was doing nothing. +- **Fixed: three options were settable and inert.** `cross_risk_margin`, `self_outlier_ratio` + and `history_keep` were offered by the app and read by the service, but `run.sh` never + wrote them into the generated config — the same defect as #24, in three more fields. Found + by a new test that checks every option reaches the generated file. + ## 0.24.0 — 2026-09-15 - **The folder input no longer remembers every file forever.** `data/folder_ingest.json` diff --git a/README.md b/README.md index ddbd86d..38d1a78 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,9 @@ the whole gallery would need re-enrolling. (or manually: **Settings → Apps → App Store → ⋮ → Repositories** → add `https://github.com/SkyTechNerds/faceid`) 2. Install the **FaceID** app, set your Frigate URL in the options (MQTT is picked up - automatically from the Mosquitto app) and start it. + automatically from the Mosquitto app) and start it. **No Frigate?** Leave `frigate_url` + empty and switch on `folder_enabled` instead — see + [folder mode in the app](#folder-mode-in-the-home-assistant-app). 3. Open the **FaceID** panel in the sidebar. First start downloads the model (~300 MB). The app is built locally on your machine (amd64/aarch64). See @@ -238,7 +240,10 @@ Trade-offs, TLS, what FaceID actually requests, and which setup fits which netwo Contributed by [@thethereza](https://github.com/thethereza) — the idea and the implementation are his. FaceID began as a Frigate companion, but the recognition, gallery, unknown review and history were never Frigate-specific; only the source of the images was. -Set `frigate.enabled: false` and add a `folder` section to `config.yaml`: +Standalone: set `frigate.enabled: false` and add a `folder` section to `config.yaml`. +Running the Home Assistant app? The same input is available there — +[jump to the app options](#folder-mode-in-the-home-assistant-app). + ```yaml frigate: @@ -291,6 +296,42 @@ either raise the cap or set it to `0`. Replacing a file at the same path gives i processes it again. The Unknown-tab button becomes **Scan recording folder**, and the header shows the number of processed files. +## Folder mode in the Home Assistant app + +Available since v0.25.0 — before that the input existed only in the standalone service. + +The app mounts `/media` and `/share` **read-only**; FaceID never changes or deletes what it +watches. No other host path is visible to it, so put the images somewhere under those two. + +```yaml +frigate_url: "" # leave empty — no Frigate +folder_enabled: true +folder_path: /media/faceid +folder_camera: front_door # the MQTT sensor becomes sensor.faceid_front_door +folder_extensions: + - .jpg # the default list is video-only, see below + - .jpeg + - .png + - .webp +``` + +**Still images need their extension listed.** The default `folder_extensions` covers video +only. A folder of `.jpg` is skipped in silence until you add it — and the key *replaces* +the list rather than extending it, so keep the video formats you still want. + +**A camera that overwrites the same file works.** FaceID fingerprints by size and +modification time, not by name, so `front.jpg` replaced on every motion event is seen as a +new recording each time. + +⚠️ **One camera name per folder.** `folder_camera` applies to everything the watcher finds. +Several cameras writing into one directory all report under that single name. For separate +sensors, run them through separate paths — or use Frigate, which carries the camera name +with each event. + +Everything else is shared with the standalone service: the gallery, unknown review, +history, MQTT sensors and the `max_indexed_files` cap all behave the same. Full option +reference: [faceid-addon/DOCS.md](faceid-addon/DOCS.md). + ## Getting started 1. **Scan your camera history** (optional but recommended): in the Unknown tab, click diff --git a/app/mqtt_listener.py b/app/mqtt_listener.py index 563493f..4503d99 100644 --- a/app/mqtt_listener.py +++ b/app/mqtt_listener.py @@ -835,9 +835,22 @@ def _publish_discovery(self, only: list | None = None): if only is not None: cams = set(only) else: - cams = (self.cameras + # Der Ordner-Eingang hat keine Kameraliste, die man abfragen koennte — sein + # Kameraname steht nur in der Konfiguration. Ohne ihn entstuende im reinen + # Ordnerbetrieb gar kein Sensor, bis das erste Gesicht erkannt wird, und bis + # dahin saehe die Installation aus, als tue sie nichts. + fc = self.cfg.get("folder") or {} + folder_cams = set() + if fc.get("enabled"): + folder_cams = {str(fc.get("camera") or "").strip()} - {""} + # Vorrang wie bisher: ausdrueckliche Liste, sonst Discovery-Liste, sonst + # Frigate fragen. Die Ordner-Kamera kommt immer DAZU statt zu ersetzen — + # bei Frigate UND Ordner muessen beide Sensoren entstehen. Frigate wird nur + # gefragt, wenn es ueberhaupt an ist: der Aufruf geht ueber das Netz. + cams = (set(self.cameras) or set(self.cfg["faceid"].get("discovery_cameras") or []) - or self._frigate_cameras()) + or (self._frigate_cameras() if self.frigate_enabled else set())) + cams = cams | folder_cams self._announced |= cams log.info("MQTT discovery: announced %d sensor(s)%s", len(cams), "" if cams else " — cameras unknown, they follow on first recognition") diff --git a/faceid-addon/CHANGELOG.md b/faceid-addon/CHANGELOG.md index 968eb83..5a01af4 100644 --- a/faceid-addon/CHANGELOG.md +++ b/faceid-addon/CHANGELOG.md @@ -3,6 +3,32 @@ All notable changes to FaceID. The Home Assistant app shows this file in the update dialog; standalone users can watch GitHub releases. +## 0.25.0 — 2026-10-06 + +- **Folder mode is available in the Home Assistant app.** The directory watcher has existed + in the standalone service since 0.23.0, but the app never offered it: no `folder` options, + `frigate_url` was mandatory, and `/media` and `/share` were not mounted at all — so even a + hand-written path could not be read. Requested in #31 by a user with five Reolink cameras + and no Frigate, uploading snapshots to `/media` over FTP. +- `frigate_url` may now be left empty. An empty URL means no Frigate: the service falls back + to a no-op client, and the gallery, unknown review and history stay usable. +- `/media` and `/share` are mounted **read-only**. FaceID never changes or deletes what it + watches, and no other host path is visible to the app. +- New options: `folder_enabled`, `folder_path`, `folder_camera`, `folder_recursive`, + `folder_process_existing`, `folder_extensions`, `folder_poll_interval`, + `folder_settle_seconds`, `folder_max_frames`, `folder_max_people_per_file`, + `folder_max_indexed_files`. +- The app now refuses to start with neither input configured, and warns on start when + `folder_path` does not exist inside the container — naming the two mounted roots, since a + path the app cannot see is the likeliest mistake. +- **In folder mode the camera sensor appears at startup**, not only after the first + recognition. The folder has no camera list to query, so its name is taken from the + configuration; without that a fresh install looked like it was doing nothing. +- **Fixed: three options were settable and inert.** `cross_risk_margin`, `self_outlier_ratio` + and `history_keep` were offered by the app and read by the service, but `run.sh` never + wrote them into the generated config — the same defect as #24, in three more fields. Found + by a new test that checks every option reaches the generated file. + ## 0.24.0 — 2026-09-15 - **The folder input no longer remembers every file forever.** `data/folder_ingest.json` diff --git a/faceid-addon/DOCS.md b/faceid-addon/DOCS.md index 3e067b1..d018104 100644 --- a/faceid-addon/DOCS.md +++ b/faceid-addon/DOCS.md @@ -9,6 +9,7 @@ Full documentation: https://github.com/SkyTechNerds/faceid ## Setup 1. Set `frigate_url` to your Frigate instance (e.g. `http://192.168.1.10:5000`). + **No Frigate?** Leave it empty and use folder mode instead (below). 2. MQTT: leave `mqtt_host` empty to automatically use the Mosquitto broker app. Fill the `mqtt_*` options only for an external broker. 3. Optional: restrict processing to specific cameras (`cameras`), and list the cameras @@ -42,6 +43,49 @@ Full documentation: https://github.com/SkyTechNerds/faceid | `frigate_topic_prefix` | must match `mqtt.topic_prefix` in Frigate's own config (default `frigate`). Wrong value = FaceID hears nothing at all | | `poll_interval` | seconds; >0 also polls Frigate's event API for events MQTT never announces (e.g. events created by an automation from a camera's own detection). 0 = off | | `backup_enabled` / `backup_hour` / `backup_keep` | optional built-in daily gallery backup | +| `cross_risk_margin` | how close two people's references may get before one is set aside (relative to `match_threshold`; 0 or below = off) | +| `self_outlier_ratio` | sets aside a reference photo that fits its own person far worse than the rest (0 = off) | +| `history_keep` | how many recognitions the History tab keeps (0 = history off) | +| `folder_*` | watch a directory instead of (or alongside) Frigate — see below | + +## Folder mode — watch a directory instead of Frigate + +For setups without Frigate: point FaceID at a folder of finished recordings or snapshots. +Everything else stays the same — gallery, unknown review, history, MQTT sensors. + +```yaml +frigate_url: "" +folder_enabled: true +folder_path: /media/faceid +folder_camera: front_door +folder_extensions: [".jpg", ".jpeg", ".png", ".webp"] +``` + +⚠️ **Only `/media` and `/share` are visible to the app**, mounted read-only — FaceID never +changes or deletes the files it watches. A path under `/config` or on the host will not be +found, and the log says so on start. + +⚠️ **Still images need their extension listed.** The default `folder_extensions` is +video-only, and the option *replaces* that list rather than adding to it. A folder of +`.jpg` is skipped in silence until you list it. + +A camera that keeps overwriting the same file works: FaceID fingerprints by size and +modification time, not by name. One `folder_camera` applies to the whole folder, so +several cameras writing into one directory all report under that single sensor. + +| Option | Description | +|---|---| +| `folder_enabled` | switch the directory watcher on | +| `folder_path` | directory to watch, under `/media` or `/share` | +| `folder_camera` | name used for the MQTT sensor and in the history | +| `folder_recursive` | also watch subdirectories | +| `folder_process_existing` | process what is already there on first start (off = only new files) | +| `folder_extensions` | which file types to pick up — replaces the default video-only list | +| `folder_poll_interval` | seconds between directory scans | +| `folder_settle_seconds` | a file must be this old and unchanged across two scans before it is read | +| `folder_max_frames` | frames sampled per video | +| `folder_max_people_per_file` | cap on distinct faces kept from one file | +| `folder_max_indexed_files` | cap on the "already seen" index (0 = unlimited). Keep it above the number of files the folder holds, otherwise every scan re-reads the overflow | Thresholds and backup can also be changed live on the app's **Settings** tab; those edits are stored in the app's data volume and override these options. The Settings tab diff --git a/faceid-addon/app/mqtt_listener.py b/faceid-addon/app/mqtt_listener.py index 563493f..4503d99 100644 --- a/faceid-addon/app/mqtt_listener.py +++ b/faceid-addon/app/mqtt_listener.py @@ -835,9 +835,22 @@ def _publish_discovery(self, only: list | None = None): if only is not None: cams = set(only) else: - cams = (self.cameras + # Der Ordner-Eingang hat keine Kameraliste, die man abfragen koennte — sein + # Kameraname steht nur in der Konfiguration. Ohne ihn entstuende im reinen + # Ordnerbetrieb gar kein Sensor, bis das erste Gesicht erkannt wird, und bis + # dahin saehe die Installation aus, als tue sie nichts. + fc = self.cfg.get("folder") or {} + folder_cams = set() + if fc.get("enabled"): + folder_cams = {str(fc.get("camera") or "").strip()} - {""} + # Vorrang wie bisher: ausdrueckliche Liste, sonst Discovery-Liste, sonst + # Frigate fragen. Die Ordner-Kamera kommt immer DAZU statt zu ersetzen — + # bei Frigate UND Ordner muessen beide Sensoren entstehen. Frigate wird nur + # gefragt, wenn es ueberhaupt an ist: der Aufruf geht ueber das Netz. + cams = (set(self.cameras) or set(self.cfg["faceid"].get("discovery_cameras") or []) - or self._frigate_cameras()) + or (self._frigate_cameras() if self.frigate_enabled else set())) + cams = cams | folder_cams self._announced |= cams log.info("MQTT discovery: announced %d sensor(s)%s", len(cams), "" if cams else " — cameras unknown, they follow on first recognition") diff --git a/faceid-addon/config.yaml b/faceid-addon/config.yaml index 5bca309..f33c15f 100644 --- a/faceid-addon/config.yaml +++ b/faceid-addon/config.yaml @@ -1,5 +1,5 @@ name: FaceID -version: "0.24.0" +version: "0.25.0" slug: faceid description: Face recognition for Frigate — trainable gallery, clustered unknown review, HA sensors url: https://github.com/SkyTechNerds/faceid @@ -18,6 +18,12 @@ ports: 8600/tcp: null ports_description: 8600/tcp: Web UI without ingress (optional) +# Nur lesend: FaceID veraendert und loescht die beobachteten Aufnahmen nie. Ohne diese +# beiden Einhaengungen sieht die App /media und /share ueberhaupt nicht — genau daran +# scheitert der Ordner-Eingang in der App, nicht an fehlenden Optionen. +map: + - media:ro + - share:ro services: - mqtt:want options: @@ -58,9 +64,26 @@ options: set_sub_label: true cameras: [] discovery_cameras: [] + folder_enabled: false + folder_path: /media/faceid + folder_camera: front_door + folder_recursive: true + folder_process_existing: true + folder_extensions: + - .mp4 + - .mkv + - .mov + - .avi + - .webm + - .m4v + folder_poll_interval: 10 + folder_settle_seconds: 10 + folder_max_frames: 24 + folder_max_people_per_file: 6 + folder_max_indexed_files: 5000 schema: mqtt_prefix: str - frigate_url: url + frigate_url: url? mqtt_host: str? mqtt_port: port frigate_user: str? @@ -100,3 +123,15 @@ schema: - str discovery_cameras: - str + folder_enabled: bool + folder_path: str? + folder_camera: str? + folder_recursive: bool + folder_process_existing: bool + folder_extensions: + - str + folder_poll_interval: int(1,3600) + folder_settle_seconds: int(0,600) + folder_max_frames: int(1,120) + folder_max_people_per_file: int(1,20) + folder_max_indexed_files: int(0,200000) diff --git a/faceid-addon/run.sh b/faceid-addon/run.sh index 96c9c05..43d899b 100755 --- a/faceid-addon/run.sh +++ b/faceid-addon/run.sh @@ -86,9 +86,37 @@ CAMERAS=$(jq -c '.cameras // []' "${OPT}") DISCOVERY=$(jq -c '.discovery_cameras // []' "${OPT}") CLIPCAMS=$(jq -c '.clip_fallback_cameras // []' "${OPT}") LIVECAMS=$(jq -c '.live_hires_fallback_cameras // []' "${OPT}") +FOLDEREXT=$(jq -c '.folder_extensions // []' "${OPT}") + +# Eingaenge pruefen, bevor die Konfiguration geschrieben wird. Ohne das startet FaceID +# mit beiden Quellen aus und meldet erst beim ersten Scan, dass es nichts zu tun gibt. +FRIGATE_URL=$(cfg '.frigate_url') +FOLDER_ENABLED=$(cfg '.folder_enabled') +FOLDER_PATH=$(cfg '.folder_path') + +if [ -z "${FRIGATE_URL}" ] && [ "${FOLDER_ENABLED}" != "true" ]; then + bashio::log.fatal "No input configured: set frigate_url, or switch folder_enabled on and set folder_path." + exit 1 +fi +if [ "${FOLDER_ENABLED}" = "true" ] && [ -z "${FOLDER_PATH}" ]; then + bashio::log.fatal "folder_enabled is on but folder_path is empty." + exit 1 +fi +if [ "${FOLDER_ENABLED}" = "true" ] && [ ! -d "${FOLDER_PATH}" ]; then + # Keine Abbruchbedingung: der Ordner darf spaeter entstehen, und FaceID meldet den + # Fehlversuch bei jedem Scan. Aber der haeufigste Fehler ist ein Pfad, den der + # Container gar nicht sieht — deshalb hier einmal laut, mit den erlaubten Wurzeln. + bashio::log.warning "folder_path '${FOLDER_PATH}' does not exist inside the add-on." + bashio::log.warning "Only /media and /share are mounted; /config and host paths are not visible." +fi +if [ -z "${FRIGATE_URL}" ]; then + bashio::log.info "No frigate_url set — running on the folder input alone." +fi cat > /opt/faceid/config.yaml << EOF frigate: + # Leere URL heisst: kein Frigate. FrigateAPI leitet ``enabled`` daraus ab und liefert + # dann einen No-op-Client, damit Galerie und Review-Oberflaeche nutzbar bleiben. url: $(yml '.frigate_url') # Nur fuer Frigates authentifizierten Port 8971 noetig. Leer lassen heisst offene API: # FrigateAPI macht aus dem leeren Wert None, genau wie im Standalone-Betrieb. @@ -122,6 +150,9 @@ faceid: backup_hour: $(cfg '.backup_hour') backup_keep: $(cfg '.backup_keep') presence_window: $(cfg '.presence_window') + cross_risk_margin: $(cfg '.cross_risk_margin') + self_outlier_ratio: $(cfg '.self_outlier_ratio') + history_keep: $(cfg '.history_keep') set_sub_label: $(cfg '.set_sub_label') min_face_px: $(cfg '.min_face_px') det_size: $(cfg '.det_size') @@ -129,6 +160,18 @@ faceid: retry_seconds: 2.5 cameras: ${CAMERAS} discovery_cameras: ${DISCOVERY} +folder: + enabled: $(cfg '.folder_enabled') + path: $(yml '.folder_path') + camera: $(yml '.folder_camera') + recursive: $(cfg '.folder_recursive') + process_existing: $(cfg '.folder_process_existing') + extensions: ${FOLDEREXT} + poll_interval: $(cfg '.folder_poll_interval') + settle_seconds: $(cfg '.folder_settle_seconds') + max_frames: $(cfg '.folder_max_frames') + max_people_per_file: $(cfg '.folder_max_people_per_file') + max_indexed_files: $(cfg '.folder_max_indexed_files') EOF # Galerie + Modell-Cache im persistenten /data-Volume (überlebt Updates) diff --git a/tests/test_addon_options.py b/tests/test_addon_options.py new file mode 100644 index 0000000..b5bc198 --- /dev/null +++ b/tests/test_addon_options.py @@ -0,0 +1,92 @@ +"""Die App-Optionen gegen Schema und run.sh pruefen. + +Zwei Fehlerklassen, die beide schon aufgetreten sind und die kein Python-Test bisher +gefangen haette: + +* Issue #24 — eine Option stand unter ``options``, fehlte aber im ``schema``. Der + Supervisor warnte bei jedem Start, und der Wert war wirkungslos. +* Issue #31 — der Ordner-Eingang existierte im Dienst, aber die App bot ihn nicht an: + keine Optionen, kein ``map``, und ``run.sh`` schrieb keinen ``folder``-Block. +""" +import re +import unittest +from pathlib import Path + +import yaml + +ADDON = Path(__file__).resolve().parent.parent / "faceid-addon" +CONFIG = yaml.safe_load((ADDON / "config.yaml").read_text(encoding="utf-8")) +RUN_SH = (ADDON / "run.sh").read_text(encoding="utf-8") +# Nur die erzeugte Vorlage, nicht das ganze Skript: davor duerfen Werte roh gelesen +# werden (Vorab-Pruefungen, und die MQTT-Daten vor der Broker-Erkennung). Entscheidend +# ist, was in der Datei landet. +TEMPLATE = RUN_SH.split("cat > /opt/faceid/config.yaml << EOF\n", 1)[1].split("\nEOF", 1)[0] + + +class SchemaCoverageTests(unittest.TestCase): + def test_every_option_has_a_schema_entry(self): + missing = sorted(set(CONFIG["options"]) - set(CONFIG["schema"])) + self.assertEqual(missing, [], f"ohne Schema, Supervisor warnt beim Start: {missing}") + + def test_every_schema_entry_has_a_default(self): + missing = sorted(set(CONFIG["schema"]) - set(CONFIG["options"])) + self.assertEqual(missing, [], f"im Schema, aber ohne Vorgabe: {missing}") + + def test_every_option_reaches_the_generated_config(self): + # Sonst ist die Option sichtbar, einstellbar und wirkungslos — genau der + # Zustand, den Issue #24 beschrieb. + ignored = { + "mqtt_host", "mqtt_port", "mqtt_user", "mqtt_password", # via jstr nach der Broker-Erkennung + } + # Zwei Schreibweisen: Skalare ueber cfg/yml ('.x'), Listen ueber jq -c ('.x //'). + missing = [k for k in CONFIG["options"] + if k not in ignored + and f".{k}'" not in RUN_SH and f".{k} //" not in RUN_SH] + self.assertEqual(missing, [], f"nicht in run.sh verwendet: {missing}") + + +class FolderModeTests(unittest.TestCase): + def test_media_and_share_are_mapped_read_only(self): + # Ohne die Einhaengung sieht die App den Ordner nicht, egal wie er konfiguriert + # ist. Lesend genuegt: FaceID veraendert die Aufnahmen nie. + self.assertEqual(sorted(CONFIG.get("map") or []), ["media:ro", "share:ro"]) + + def test_the_generated_config_has_a_folder_block(self): + self.assertRegex(TEMPLATE, r"(?m)^folder:$") + + def test_frigate_url_is_optional(self): + # Pflichtfeld hiesse: ohne Frigate laesst sich die App gar nicht speichern. + self.assertEqual(CONFIG["schema"]["frigate_url"], "url?") + + def test_folder_options_are_complete(self): + expected = { + "folder_enabled", "folder_path", "folder_camera", "folder_recursive", + "folder_process_existing", "folder_extensions", "folder_poll_interval", + "folder_settle_seconds", "folder_max_frames", "folder_max_people_per_file", + "folder_max_indexed_files", + } + self.assertEqual(expected - set(CONFIG["options"]), set()) + + def test_the_folder_block_uses_the_unprefixed_keys(self): + # run.sh uebersetzt folder_path -> folder.path. Schreibt es versehentlich den + # App-Namen in die Dienst-Konfiguration, liest FolderIngest die Vorgabe. + block = TEMPLATE.split("\nfolder:\n", 1)[1] + keys = re.findall(r"(?m)^\s+([a-z_]+):", block) + expected = ["enabled", "path", "camera", "recursive", "process_existing", + "extensions", "poll_interval", "settle_seconds", "max_frames", + "max_people_per_file", "max_indexed_files"] + self.assertEqual(sorted(keys), sorted(expected)) + # Die jq-Ausdruecke rechts duerfen das App-Praefix tragen (.folder_path), die + # YAML-Schluessel links nicht — sonst liest FolderIngest seine Vorgaben. + self.assertEqual([k for k in keys if k.startswith("folder_")], []) + + +class FreeTextEscapingTests(unittest.TestCase): + """Freitext muss durch jq, sonst zerlegt ein Anfuehrungszeichen die Datei.""" + + def test_string_options_go_through_the_encoder(self): + raw = {k for k in CONFIG["options"] + if isinstance(CONFIG["schema"].get(k), str) + and CONFIG["schema"][k].rstrip("?") in {"str", "url", "password"} + and f"cfg '.{k}'" in TEMPLATE} + self.assertEqual(raw, set(), f"roh statt ueber yml(): {sorted(raw)}") diff --git a/tests/test_folder_ingest.py b/tests/test_folder_ingest.py index ce1c53f..cfaa9d3 100644 --- a/tests/test_folder_ingest.py +++ b/tests/test_folder_ingest.py @@ -648,3 +648,32 @@ def racing(): ing._enforce_index_cap = original ing._finish_scan({"found": 0}) self.assertEqual(ing.max_indexed_files, 7, "und wird beim naechsten Ende angewandt") + + +class OverwrittenFileTests(unittest.TestCase): + """Eine Kamera, die immer dieselbe Datei neu schreibt (Issue #31). + + Der Fingerabdruck haengt an Groesse und mtime, nicht am Namen — deshalb gilt + jedes Ueberschreiben als neue Aufnahme. Ohne das waere der haeufigste + Reolink/FTP-Aufbau still wirkungslos: eine Erkennung, danach nie wieder. + """ + + def test_every_overwrite_of_the_same_name_is_processed_again(self): + with tempfile.TemporaryDirectory() as tmp: + watch = Path(tmp) / "media" + watch.mkdir() + cfg = {"folder": {"enabled": True, "path": str(watch), "settle_seconds": 0, + "extensions": [".jpg"], "camera": "front"}, "faceid": {}} + proc = FakeProcessor() + ing = FolderIngest(cfg, Path(tmp) / "data", + FakeEngine([[FakeFace([1, 0, 0])]]), proc) + path = watch / "front.jpg" + processed = 0 + for i in range(3): + write_image(path, value=40 * (i + 1)) + now = time.time() + i * 1000 + ing.scan_once(now=now) + processed += ing.scan_once(now=now + 60)["processed"] + self.assertEqual(processed, 3, "jedes Ueberschreiben ist eine neue Aufnahme") + self.assertEqual(len(ing._state["files"]), 1, + "gleicher Pfad bleibt ein Indexeintrag") diff --git a/tests/test_local_event_processor.py b/tests/test_local_event_processor.py index 227bf29..c4c328a 100644 --- a/tests/test_local_event_processor.py +++ b/tests/test_local_event_processor.py @@ -106,3 +106,82 @@ def test_folder_ingest_has_a_class_level_default(self): # Damit kein Leser den Zeitraum zwischen start() und der Zuweisung abfangen muss. from app.mqtt_listener import EventProcessor self.assertIsNone(EventProcessor.folder_ingest) + + +class FolderDiscoveryTests(unittest.TestCase): + """Im reinen Ordnerbetrieb gibt es keine Kameraliste zum Abfragen. + + Gemeldet als Issue #31: ohne Frigate hat FaceID keine Quelle fuer Kameranamen, und + der Sensor entstuende erst beim ersten erkannten Gesicht. Bis dahin sieht die + Installation aus, als tue sie nichts. + """ + + def _processor(self, cfg): + return EventProcessor(cfg, engine=None, gallery=FakeGallery(), frigate=FakeFrigate()) + + def _announced(self, cfg): + proc = self._processor(cfg) + seen = [] + proc.client = SimpleNamespace(publish=lambda topic, payload=None, **kw: + seen.append(topic)) + proc._publish_discovery() + return proc._announced + + def test_the_folder_camera_is_announced_without_frigate(self): + cfg = config() + cfg["faceid"]["discovery_cameras"] = [] + cfg["folder"] = {"enabled": True, "camera": "driveway"} + self.assertIn("driveway", self._announced(cfg)) + + def test_the_folder_camera_joins_the_configured_ones(self): + cfg = config() + cfg["folder"] = {"enabled": True, "camera": "driveway"} + announced = self._announced(cfg) + self.assertIn("driveway", announced) + self.assertIn("front_door", announced) + + def test_a_disabled_folder_contributes_no_camera(self): + cfg = config() + cfg["folder"] = {"enabled": False, "camera": "driveway"} + self.assertNotIn("driveway", self._announced(cfg)) + + def test_an_empty_folder_camera_is_not_announced(self): + cfg = config() + cfg["faceid"]["discovery_cameras"] = [] + cfg["folder"] = {"enabled": True, "camera": " "} + self.assertEqual(self._announced(cfg), set()) + + def test_frigate_is_not_queried_when_the_folder_name_suffices(self): + # Der Aufruf geht ueber das Netz. Im Ordnerbetrieb steht der Name schon fest. + cfg = config() + cfg["faceid"]["discovery_cameras"] = [] + cfg["folder"] = {"enabled": True, "camera": "driveway"} + proc = self._processor(cfg) + proc.client = SimpleNamespace(publish=lambda *a, **k: None) + called = [] + proc.frigate_enabled = False + proc._frigate_cameras = lambda: called.append(1) or set() + proc._publish_discovery() + self.assertEqual(called, [], "ohne Frigate darf nicht gefragt werden") + + def test_frigate_is_still_queried_when_nothing_else_is_known(self): + cfg = config() + cfg["faceid"]["discovery_cameras"] = [] + proc = self._processor(cfg) + proc.frigate_enabled = True + proc.client = SimpleNamespace(publish=lambda *a, **k: None) + proc._frigate_cameras = lambda: {"from_frigate"} + proc._publish_discovery() + self.assertIn("from_frigate", proc._announced) + + def test_frigate_cameras_still_appear_alongside_a_folder(self): + # Gemischter Aufbau: beide Quellen muessen beim Start einen Sensor bekommen. + cfg = config() + cfg["faceid"]["discovery_cameras"] = [] + cfg["folder"] = {"enabled": True, "camera": "driveway"} + proc = self._processor(cfg) + proc.frigate_enabled = True + proc.client = SimpleNamespace(publish=lambda *a, **k: None) + proc._frigate_cameras = lambda: {"hof", "tor"} + proc._publish_discovery() + self.assertEqual(proc._announced, {"driveway", "hof", "tor"})