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
This commit is contained in:
2026-09-03 06:02:51 -05:00
parent ec974c9061
commit 908c14d8e4
+188
View File
@@ -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."