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:
@@ -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."
|
||||
Reference in New Issue
Block a user