Break-glass badges fire the relay locally on the reader host (no HA dependency); the flagged scan still reaches HA for the audit trail but skips re-dispatch. NOTE: deployed while pfv-bms is down — YAML lint clean, ha core check to run at Friday boot before arming. https://projects.knownelement.com/issues/345
232 lines
10 KiB
YAML
232 lines
10 KiB
YAML
# ============================================================================
|
|
# 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 PATH (founder-approved 2026-09-03): valid badge + the
|
|
# doorman_unlock_enabled input_boolean ON -> rest_command fires the
|
|
# doorctl endpoint on pfvsvrpi (Tailscale-bound, token+source
|
|
# allowlisted, self-securing relay — KNEL/doorman README). Disarm = the
|
|
# boolean OFF; unknown/disabled badges never reach this path.
|
|
#
|
|
# 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
|
|
|
|
input_boolean:
|
|
# Arming switch (founder-level): ON = a valid badge DISPATCHES the
|
|
# unlock to the pfvsvrpi relay. OFF = valid badges log + notify but
|
|
# the door stays shut.
|
|
doorman_unlock_enabled:
|
|
name: Doorman unlock armed
|
|
icon: mdi:lock-open-variant
|
|
|
|
rest_command:
|
|
# Fires the doorctl endpoint on pfvsvrpi (systemd socket-activated,
|
|
# Tailscale-bound, token+source allowlisted; self-securing relay).
|
|
# URL contains the token -> secret. KNEL/doorman README documents it.
|
|
doorman_unlock:
|
|
url: !secret doorman_unlock_url
|
|
method: get
|
|
timeout: 20
|
|
|
|
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: >-
|
|
{% set ns = namespace(ids=[]) %}
|
|
{% for row in enabled_csv.split(',') %}
|
|
{% set parts = row.split('|') %}
|
|
{% if parts | length == 2 %}
|
|
{% set ns.ids = ns.ids + [parts[0] | trim] %}
|
|
{% endif %}
|
|
{% endfor %}
|
|
{{ ns.ids }}
|
|
holder: >-
|
|
{% set hits = enabled_csv.split(',') | map('trim')
|
|
| select('search', '^' ~ badge_id ~ '\|') | list %}
|
|
{{ hits[0].split('|')[1] | trim if hits 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, then dispatch unlock IF armed AND
|
|
# the listener didn't already handle it locally (break-glass
|
|
# scans carry local_unlock:true — relay already fired on the
|
|
# reader host; HA logs without re-dispatching).
|
|
- conditions: "{{ is_known and is_enabled }}"
|
|
sequence:
|
|
- action: logbook.log
|
|
data:
|
|
name: Doorman
|
|
message: >-
|
|
Badge {{ badge_id }} ({{ holder }}) accepted at
|
|
{{ reader }}.
|
|
- if:
|
|
- condition: template
|
|
value_template: >-
|
|
{{ is_state('input_boolean.doorman_unlock_enabled', 'on')
|
|
and not (trigger.json.local_unlock | default(false) | bool(false)) }}
|
|
then:
|
|
- action: rest_command.doorman_unlock
|
|
continue_on_error: true
|
|
- action: logbook.log
|
|
data:
|
|
name: Doorman
|
|
message: "UNLOCK dispatched to the {{ reader }} relay (armed)."
|
|
- action: notify.persistent_notification
|
|
data:
|
|
title: Doorman — badge accepted, UNLOCK dispatched
|
|
message: "{{ holder }} scanned at {{ reader }} ({{ scanned }}). Door released for the hold window."
|
|
else:
|
|
- action: logbook.log
|
|
data:
|
|
name: Doorman
|
|
message: >-
|
|
No HA dispatch — {{ 'already released locally via break-glass' if trigger.json.local_unlock | default(false) | bool(false) else 'unlock disarmed' }}.
|
|
# 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."
|