From 908c14d8e424aece22c444d57ea1b81ea2bae5b9 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Thu, 3 Sep 2026 06:02:51 -0500 Subject: [PATCH] feat(doorman): badge scan ingest + roster whitelist sensors [#345] Roster fetched live from KNEL/access-roster via gitea API (no git mechanics; access-roster stays the single gated source of truth). Known/disabled/unknown decision branches + last-scan entities. UNLOCK deliberately not wired - pending HA->Pi control path (#741). Validated: yamllint clean, ha core check passed on staged copy. https://projects.knownelement.com/issues/345 --- packages/doorman.yaml | 188 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 packages/doorman.yaml diff --git a/packages/doorman.yaml b/packages/doorman.yaml new file mode 100644 index 0000000..8661a23 --- /dev/null +++ b/packages/doorman.yaml @@ -0,0 +1,188 @@ +# ============================================================================ +# Doorman — server-room badge access (#345/#355/#356) +# ============================================================================ +# Ingests badge scans from the KNEL/doorman listeners (pfvsvrpi prod, +# ultix-field dev) and decides known/disabled/unknown against the +# badge-ID control file: KNEL/access-roster (private repo, two-human +# PR gate). The roster is fetched live from the gitea API — NO git +# mechanics, NO submodule (git_pull add-on lacks submodule support), +# NO local copy: access-roster remains the single source of truth. +# +# UNLOCK IS DELIBERATELY NOT WIRED. Known badges are logged and +# notified; nothing actuates. The HA->Pi relay control path is an open +# design decision (#345 sketch, GLPI #741). Fail-safe: this automation +# can never open the door. +# +# Change control (founder ruling 2026-09-02/03): this file deploys via +# the pfv-bms master pipeline (webhook fast path + sha-watch safety +# net); run `ha core check` before pushing — packages/** triggers a +# core restart. Badge ID changes NEVER happen here — only in +# KNEL/access-roster via approved PRs. +# ============================================================================ + +rest: + - resource: https://git.knownelement.com/api/v1/repos/KNEL/access-roster/raw/roster/badges.yaml?ref=main + headers: + Authorization: !secret gitea_auth_header + User-Agent: pfv-bms-doorman-roster + scan_interval: 300 + verify_ssl: true + sensor: + # Enabled badges only: CSV rows "badge_id|holder" (state limit + # 255 chars — revisit to attribute-based if roster grows past + # ~15 entries). + - name: Doorman roster enabled badges + unique_id: doorman_roster_enabled_badges + value_template: >- + {% set ns = namespace(id='', holder='', out=[]) %} + {% for raw in value.split('\n') %} + {% set line = raw | trim %} + {% if line.startswith('- badge_id:') %} + {% set ns.id = line.split(':', 1)[1].replace('"', '') | trim %} + {% elif line.startswith('holder:') %} + {% set ns.holder = line.split(':', 1)[1].replace('"', '') | trim %} + {% elif line.startswith('enabled: true') %} + {% set ns.out = ns.out + [(ns.id ~ '|' ~ ns.holder)] %} + {% endif %} + {% endfor %} + {{ ns.out | join(',') }} + # All roster badges (enabled or not) — lets the automation tell + # "disabled badge presented" apart from "unknown badge". + - name: Doorman roster all badges + unique_id: doorman_roster_all_badges + value_template: >- + {% set ns = namespace(id='', out=[]) %} + {% for raw in value.split('\n') %} + {% set line = raw | trim %} + {% if line.startswith('- badge_id:') %} + {% set ns.id = line.split(':', 1)[1].replace('"', '') | trim %} + {% set ns.out = ns.out + [ns.id] %} + {% endif %} + {% endfor %} + {{ ns.out | join(',') }} + +input_text: + doorman_last_badge: + name: Doorman last badge scanned + max: 255 + +input_datetime: + doorman_last_scan: + name: Doorman last scan time + has_date: true + has_time: true + +automation: + - id: doorman_badge_scan + alias: Doorman - badge scan ingest + decision + description: >- + Server-room badge scans from the KNEL/doorman listeners. Whitelist + = KNEL/access-roster via the doorman roster sensors. Logging + + alerts only — UNLOCK IS DELIBERATELY NOT WIRED (pending the + HA->Pi control path decision, #345/#741). This automation cannot + open the door. + mode: queued + max_exceeded: silent + triggers: + - trigger: webhook + webhook_id: !secret doorman_scan_webhook_id + allowed_methods: + - POST + local_only: false + actions: + - variables: + badge_id: "{{ trigger.json.badge_id | default('') | string }}" + reader: "{{ trigger.json.reader | default('unknown') }}" + scanned: "{{ trigger.json.ts | default(now().isoformat()) }}" + enabled_csv: "{{ states('sensor.doorman_roster_enabled_badges') }}" + all_csv: "{{ states('sensor.doorman_roster_all_badges') }}" + roster_ok: "{{ '|' in enabled_csv and ',' in (enabled_csv ~ ',') }}" + known_ids: >- + {{ all_csv.split(',') | map('trim') | select('match', '^\d+$') | list }} + enabled_ids: >- + {{ enabled_csv.split(',') | map('trim') + | select('match', '^\d+\|') | map('split', '|') | map('first') + | list }} + holder: >- + {% set hit = enabled_csv.split(',') | map('trim') + | select('search', '^' ~ badge_id ~ '\|') | list %} + {{ (hit[0].split('|')[1] if hit else 'UNKNOWN') }} + is_known: "{{ badge_id in known_ids }}" + is_enabled: "{{ badge_id in enabled_ids }}" + - action: input_text.set_value + target: + entity_id: input_text.doorman_last_badge + data: + value: "{{ badge_id }} at {{ reader }} ({{ 'enabled' if is_enabled else ('disabled' if is_known else 'UNKNOWN') }})" + - action: input_datetime.set_datetime + target: + entity_id: input_datetime.doorman_last_scan + data: + datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}" + - choose: + # Roster unavailable (gitea fetch failed): log, notify once, + # decide NOTHING. Fail-safe: no unlock path exists anyway. + - conditions: "{{ not roster_ok }}" + sequence: + - action: persistent_notification.create + data: + title: Doorman — roster unavailable + message: >- + Scan {{ badge_id }} at {{ reader }} could not be + checked: roster sensors are not populated. Logged + only; whitelist NOT evaluated. Check gitea + connectivity / KNEL/access-roster. + # Known + enabled badge: log + quiet notification. Unlock not + # wired by design (see header). + - conditions: "{{ is_known and is_enabled }}" + sequence: + - action: logbook.log + data: + name: Doorman + message: >- + Badge {{ badge_id }} ({{ holder }}) accepted at + {{ reader }} — logged, unlock not wired (#741). + - action: notify.persistent_notification + data: + title: Doorman — badge accepted + message: "{{ holder }} scanned at {{ reader }} ({{ scanned }}). Logged. Unlock not wired (#741)." + # In roster but disabled: flag it loudly (stolen/suspended + # badge use is exactly what we want to know about). + - conditions: "{{ is_known and not is_enabled }}" + sequence: + - action: logbook.log + data: + name: Doorman + message: "DISABLED badge {{ badge_id }} presented at {{ reader }} — refused." + - action: notify.send_message + target: + entity_id: + - notify.ultix_sidecar_new + - notify.allthes_ipad + data: + title: Doorman — DISABLED badge at server room + message: "Badge {{ badge_id }} is in the roster but disabled. Presented at {{ reader }} ({{ scanned }})." + # Unknown badge: instant alert (alert brain pattern). + - conditions: "{{ not is_known }}" + sequence: + - action: logbook.log + data: + name: Doorman + message: "UNKNOWN badge {{ badge_id }} at {{ reader }}." + - action: notify.send_message + target: + entity_id: + - notify.ultix_sidecar_new + - notify.allthes_ipad + data: + title: Doorman — UNKNOWN badge at server room + message: >- + Badge {{ badge_id }} is NOT in KNEL/access-roster. + Presented at {{ reader }} ({{ scanned }}). If this + was you, add it via an access-roster PR (two-human + gate); otherwise treat as recon. + default: + - action: logbook.log + data: + name: Doorman + message: "Scan {{ badge_id }} at {{ reader }} hit no decision branch."