# ============================================================================ # 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."