diff --git a/README.md b/README.md
index 97a21ae..c912b0b 100644
--- a/README.md
+++ b/README.md
@@ -1,45 +1,164 @@
-# pfv-bms — Home Assistant configuration (Turnsys PFV)
+# 🏠 pfv-bms — the house brain
-Production Home Assistant configuration for a private residence +
-server room: physical-plant environmental monitoring (temperatures,
-power, UPS), server-room badge access control, and alerting.
+> **Datacenter-grade monitoring and access control for a private residence + server room** —
+> 372 entities, ~260 sensors, and a badge-actuated door, all deployed by `git push`.
-Licensed under the [GNU AGPLv3](LICENSE) — see [AGPL compliance
-notes](#license) below.
+
+
+
+
+
-## What lives here
+Production Home Assistant configuration for the Turnsys PFV —
+environmental monitoring (temperatures, power, UPS) across a seven-node
+Proxmox fleet plus single-board computers, server-room badge access
+control, and tiered alerting to mobile devices.
-- `configuration.yaml` + `packages/` — the full running config,
- deployed to the HAOS box via a git-webhook pipeline
-- `packages/doorman.yaml` — badge-scan ingest + whitelist decisions
- (server-room door access)
-- `packages/plant_snmp.yaml` — fleet temperature/power sensors over
- SNMP (lm-sensors extends, iDRAC, UPS, PDU relay)
-- `automations.yaml` — tiered thermal alerts + sensor watchdogs
-- `dashboards/` — Lovelace dashboards
+Work tracking: [Redmine](https://projects.knownelement.com)
+([#344](https://projects.knownelement.com/issues/344) umbrella) ·
+Docs: [VP TechOps on Discourse](https://community.turnsys.com/c/vp-techops)
-## Security model (read this before forking)
+---
+
+## 🗺️ System architecture
+
+```mermaid
+flowchart LR
+ subgraph FIELD[📡 The field]
+ TS[🖥️ 7× Proxmox hosts
lm-sensors + iDRAC extends]
+ BOARDS[🥧 SBCs
pfvsvrpi · jetson · 3× subopi]
+ UPS[🔋 UPS on tsys1
NUT server :3493]
+ PDU[🔌 Garage PDU
SNMPv1 relay]
+ METER[⚡ Utility meter
Smart Meter Texas]
+ READER[🪪 Badge readers
pfvsvrpi · ultix-field]
+ end
+
+ subgraph HA[🏠 pfv-bms — this repo]
+ INGEST[📦 packages/plant_snmp.yaml
SNMP pollers → raw sensors]
+ TEMPLATE[🧮 template sensors
raw → °F/units + thresholds]
+ DOOR[🚪 packages/doorman.yaml
scan ingest + decision engine]
+ ROSTER[🔒 roster sensors
REST poll of private access-roster]
+ ENERGY[📊 utility_meter +
Riemann-sum energy]
+ AUTO[🚨 automations.yaml
tiered thermal + UPS alerts]
+ end
+
+ subgraph OUT[📲 Outcomes]
+ ALERT[📱 push alerts
companion apps]
+ LOG[📓 logbook + history
every scan, every degree]
+ DASH[🖥️ dashboards
server-room + plant]
+ RELAY[⚡ doorctl → usbrelay
→ door strike]
+ end
+
+ READER -->|webhook| DOOR
+ DOOR -->|whitelist| ROSTER
+ DOOR -->|armed + valid| RELAY
+ TS & BOARDS -->|SNMP| INGEST
+ UPS -->|NUT| INGEST
+ PDU -->|SNMP relay| INGEST
+ METER -->|REST| INGEST
+ INGEST --> TEMPLATE --> AUTO
+ INGEST --> ENERGY
+ AUTO --> ALERT
+ TEMPLATE & DOOR --> LOG & DASH
+```
+
+### 🛡️ Badge access lane (server-room door)
+
+| Component | Role | Lives |
+|-----------|------|-------|
+| USB HID readers | 13.56 MHz badge readers (keyboard-emulating) | door + dev bench |
+| [`KNEL/doorman`](https://git.knownelement.com/KNEL/doorman) | pure-bash listener, decode, webhook, doorctl | `pfvsvrpi` (prod), `ultix-field` (dev) |
+| `packages/doorman.yaml` | scan ingest, whitelist decision, alerts, unlock dispatch | **this repo** |
+| [`KNEL/access-roster`](https://git.knownelement.com/KNEL/access-roster) 🔒 | badge IDs + holders — **private**, two-human PR gate | gitea (private) |
+| `doorctl` + usbrelay | socket-activated, token + source-IP ACL, EXIT-trap secured | `pfvsvrpi` |
+
+Fail-closed end to end: HA unreachable → the door does not open.
+Break-glass badges fire the relay locally (works with HA down) and
+report `local_unlock` so HA never double-fires. Arm switch
+(`input_boolean.doorman_unlock_enabled`) is founder-level control.
+Verified end-to-end 2026-09-03: real armed scan → dispatch → relay.
+
+---
+
+## 🌡️ Environmental monitoring
+
+Raw SNMP pollers (`packages/plant_snmp.yaml`) → unit-normalized
+template sensors → tiered automations. Every host contributes
+lm-sensors extends; iDRAC hosts add inlet/exhaust/CPU; NUT feeds UPS
+electricals.
+
+| Family | Coverage |
+|--------|----------|
+| 🖥️ Host silicon | CPU/drive/NVMe/PCH temps on 7 Proxmox hosts |
+| 🌡️ Thermal zones | rack ambient, DIMM banks, GPU, tsys7 iDRAC inlet→exhaust ΔT |
+| 🥧 Boards | pfvsvrpi, jetson, 3× subopi |
+| 🔋 UPS | tsys1 battery charge/voltage, on-battery + runtime alerts |
+| ⚡ Power | whole-house kWh (utility), tsys6/7 node watts + kWh integrals, garage PDU amps |
+| 🏠 Rooms | temp/humidity/battery sensors (Govee, cloud-fed) |
+
+### 🚨 Alert tiers (automations.yaml)
+
+| Trigger | Threshold |
+|---------|-----------|
+| Drive / DIMM temps | > 140 °F |
+| NVMe | > 150 °F |
+| PCH | > 160 °F |
+| GPU | > 185 °F |
+| Ambient | > 104 °F |
+| UPS | on-battery transitions + runtime |
+| Watchdog | plant sensor freshness sweep every 30 min |
+
+### 📊 Snapshot (2026-09-03)
+
+372 entities · ~260 sensors · 45 temperature sensors · 21
+electrical/energy sensors · 8 todo lists · 2-way alerting to companion
+apps.
+
+---
+
+## 🔐 Security model (read this before forking)
- **No secrets in git, ever.** Everything sensitive is `!secret`
indirection into an on-box `secrets.yaml` (gitignored). Verified by
full-history scans.
-- **Badge IDs are NOT in this repo.** The badge roster lives in a
- separate *private* repository (`access-roster`, two-human PR gate)
- and is fetched live at runtime via the gitea API with a secret auth
- header. This repo contains only the decision logic.
-- Battery-powered room sensors and lock integrations are cloud-fed;
- their credentials also live in `secrets.yaml`.
+- **Badge IDs are NOT in this repo.** The roster lives in the *private*
+ [`KNEL/access-roster`](https://git.knownelement.com/KNEL/access-roster)
+ repo (two-human PR gate) and is fetched live at runtime.
+- **Fail-closed access control** — see the badge lane above.
+- Battery room sensors and locks are cloud-fed; credentials also live
+ in `secrets.yaml`.
-## Related
+## 🚀 Deployment pipeline
-- Work tracking: [Redmine project 55](https://projects.knownelement.com)
- ([#344](https://projects.knownelement.com/issues/344) HA integration,
- [#345](https://projects.knownelement.com/issues/345) badge access,
- [#762](https://projects.knownelement.com/issues/762) publication)
-- Documentation:
- [VP TechOps on Discourse](https://community.turnsys.com/c/vp-techops)
-- Badge listener/relay code: [KNEL/doorman](https://git.knownelement.com/KNEL/doorman)
-- Wider fleet (Proxmox, DNS, monitoring): [KNEL/PFVCluster](https://git.knownelement.com/KNEL/PFVCluster)
+```mermaid
+flowchart LR
+ PUSH[📝 git push] --> HOOK[gitea webhook
fast path]
+ PUSH --> SHA[sha-watch
safety net · 300 s poll]
+ HOOK & SHA --> CHECK{changed files?}
+ CHECK -->|packages/**| RESTART[♻️ core restart
webhook triggers need it]
+ CHECK -->|other YAML| RELOAD[⚡ reload only
~5 min to live]
+```
+
+Gate before pushing `packages/**`: `ha core check` must pass on-box.
+Measured end-to-end: 4 m 52 s commit → live.
+
+## 🗺️ Repo map
+
+| Path | What |
+|------|------|
+| `configuration.yaml` | entrypoint + package includes |
+| `packages/doorman.yaml` | 🚪 badge scan ingest + decisions |
+| `packages/plant_snmp.yaml` | 🌡️ SNMP pollers + templates |
+| `automations.yaml` | 🚨 tiered alerts + watchdogs |
+| `dashboards/` | Lovelace dashboards |
+| `tools/` | operator tooling (websocket CLI) |
+| `docs/reference-packs/` | historical YAML references — NOT loaded |
+
+## 🔗 Related
+
+- Badge listener + relay code: [`KNEL/doorman`](https://git.knownelement.com/KNEL/doorman)
+- Wider fleet (Proxmox, DNS, monitoring): [`KNEL/PFVCluster`](https://git.knownelement.com/KNEL/PFVCluster)
+- Badge roster (private): `KNEL/access-roster` 🔒
## License
diff --git a/docs/reference-packs/automations-ups-alerts.yaml b/docs/reference-packs/automations-ups-alerts.yaml
new file mode 100644
index 0000000..3e7dc64
--- /dev/null
+++ b/docs/reference-packs/automations-ups-alerts.yaml
@@ -0,0 +1,140 @@
+# REFERENCE ONLY — historical packs, NOT loaded by HA (superseded by packages/; kept for lineage). Statuses in the repo README.
+# PFV UPS + plant alert automations for Home Assistant [#439]
+#
+# Paste into automations.yaml (or via UI: paste YAML directly into the
+# automation editor). Delivery target: notify.pushover (add the Pushover
+# integration first; interim targets that exist today:
+# notify.mobile_app_ultix_sidecar / notify.mobile_app_ultix_mini /
+# notify.mobile_app_allthes_ipad).
+#
+# ENTITY IDs: created by the NUT integration when you add the two UPS config
+# entries (Settings > Devices & Services > Add Integration > Network UPS
+# Tools; hosts pfv-tsys1.knel.net and pfv-tsys9.knel.net, port 3493, user
+# homeassistant + the upsd.users password). Suggested device names:
+# "tsys1 ups" and "tsys9 ups" -> entities below. If HA names them
+# differently, check Developer Tools > States (search "_ups_status") and
+# edit the entity_id lines to match. Nothing else needs changing.
+
+- alias: "PFV plant - UPS on battery"
+ id: pfv_plant_ups_on_battery
+ mode: single
+ triggers:
+ - trigger: state
+ entity_id:
+ - sensor.tsys1_ups_status
+ - sensor.tsys9_ups_status
+ conditions:
+ - condition: template
+ value_template: >-
+ {{ 'On Battery' in states(trigger.entity_id) }}
+ actions:
+ - action: notify.send_message
+ target:
+ entity_id: notify.pushover
+ data:
+ title: "POWER OUTAGE"
+ message: >-
+ {{ state_attr(trigger.entity_id, 'friendly_name') }} lost utility
+ power and is ON BATTERY at {{ states('sensor.' ~ trigger.entity_id.split('.')[1] | replace('_status', '_battery_charge')) }}% charge.
+
+- alias: "PFV plant - UPS battery low"
+ id: pfv_plant_ups_battery_low
+ mode: single
+ triggers:
+ - trigger: numeric_state
+ entity_id:
+ - sensor.tsys1_ups_battery_charge
+ - sensor.tsys9_ups_battery_charge
+ below: 40
+ conditions:
+ - condition: template
+ value_template: >-
+ {{ 'On Battery' in states(trigger.entity_id | replace('_battery_charge', '_status')) }}
+ actions:
+ - action: notify.send_message
+ target:
+ entity_id: notify.pushover
+ data:
+ title: "UPS BATTERY LOW"
+ message: >-
+ {{ trigger.entity_id }} at {{ states(trigger.entity_id) }}% while on
+ battery. Start graceful shutdown of VMs before hypervisors (#455 ordering).
+ data:
+ priority: 1
+
+- alias: "PFV plant - UPS communication lost"
+ id: pfv_plant_ups_comm_lost
+ mode: single
+ triggers:
+ - trigger: state
+ entity_id:
+ - sensor.tsys1_ups_status
+ - sensor.tsys9_ups_status
+ to: unavailable
+ for: "00:05:00"
+ actions:
+ - action: notify.send_message
+ target:
+ entity_id: notify.pushover
+ data:
+ title: "UPS COMMS LOST"
+ message: >-
+ {{ trigger.entity_id }} unreachable for 5 minutes (nut-server or
+ network path down; tsys1 also hosts pfv-bms itself).
+ data:
+ priority: 1
+
+- alias: "PFV plant - utility power restored"
+ id: pfv_plant_ups_power_restored
+ mode: single
+ triggers:
+ - trigger: state
+ entity_id:
+ - sensor.tsys1_ups_status
+ - sensor.tsys9_ups_status
+ conditions:
+ - condition: template
+ value_template: >-
+ {{ trigger.from_state is not none and 'On Battery' in trigger.from_state.state
+ and 'On Battery' not in states(trigger.entity_id) }}
+ actions:
+ - action: notify.send_message
+ target:
+ entity_id: notify.pushover
+ data:
+ title: "Power restored"
+ message: >-
+ {{ state_attr(trigger.entity_id, 'friendly_name') }} is back ONLINE.
+
+- alias: "PFV plant - high temperature"
+ id: pfv_plant_high_temperature
+ mode: single
+ triggers:
+ - trigger: numeric_state
+ entity_id:
+ - sensor.pfv_artroom_temperature
+ - sensor.pfv_garage_temperature
+ - sensor.pfv_tsys1_rack_temperature
+ - sensor.pfv_tsys3_cpu_temperature
+ - sensor.pfv_tsys4_ambient_temperature
+ - sensor.pfv_tsys5_ambient_temperature
+ - sensor.pfv_tsys6_cpu_temperature
+ - sensor.pfv_tsys7_cpu_temperature
+ - sensor.pfv_tsys9_cpu_temperature
+ - sensor.pfv_jetson_temperature
+ - sensor.pfv_svrpi_temperature
+ - sensor.pfv_subopi3_temperature
+ - sensor.pfv_subopi_dev_3_temperature
+ - sensor.pfv_subopi_dev_4_temperature
+ above: 104
+ actions:
+ - action: notify.send_message
+ target:
+ entity_id: notify.pushover
+ data:
+ title: "HIGH TEMPERATURE"
+ message: >-
+ {{ trigger.entity_id }} is {{ states(trigger.entity_id) }}°F
+ (threshold 104°F). Check cooling/airflow.
+ data:
+ priority: 1
diff --git a/docs/reference-packs/energy-plant-kwh.yaml b/docs/reference-packs/energy-plant-kwh.yaml
new file mode 100644
index 0000000..3a607bf
--- /dev/null
+++ b/docs/reference-packs/energy-plant-kwh.yaml
@@ -0,0 +1,63 @@
+# REFERENCE ONLY — historical packs, NOT loaded by HA (superseded by packages/; kept for lineage). Statuses in the repo README.
+# PFV plant energy helpers (kWh) for Home Assistant [#439]
+#
+# Paste into configuration.yaml. Riemann-sum integral: W -> kWh with the
+# `integration` sensor platform (kept across restarts). Feeds the Energy
+# dashboard (Settings > Dashboards > Energy > add grid/device consumption
+# sources pointing at these kWh sensors).
+#
+# Node power comes from the ACPI power_meter read through the SNMP extend
+# (see sensors-snmp-plant.yaml): sensor.pfv_tsys6_node_power /
+# sensor.pfv_tsys7_node_power, in watts.
+
+sensor:
+ - platform: integration
+ source: sensor.pfv_tsys6_node_power
+ name: "PFV tsys6 node energy"
+ unique_id: pfv_tsys6_node_energy
+ unit_prefix: k
+ round: 2
+ method: left
+ max_sub_interval:
+ minutes: 5
+
+ - platform: integration
+ source: sensor.pfv_tsys7_node_power
+ name: "PFV tsys7 node energy"
+ unique_id: pfv_tsys7_node_energy
+ unit_prefix: k
+ round: 2
+ method: left
+ max_sub_interval:
+ minutes: 5
+
+# ---------------------------------------------------------------------------
+# UPS load -> energy (APC on tsys1, Tripp Lite on tsys9):
+# Preferred source is the NUT diagnostic sensor "Real power (W)" — enable it
+# on the NUT device page (diagnostics are disabled by default). If your UPS
+# does not expose real power, uncomment the template-watts fallback below and
+# fill in the nominal wattage from the UPS nameplate.
+#
+# - platform: integration
+# source: sensor.tsys1_ups_watts
+# name: "PFV tsys1 UPS energy"
+# unique_id: pfv_tsys1_ups_energy
+# unit_prefix: k
+# round: 2
+# method: left
+# max_sub_interval:
+# minutes: 5
+#
+# Fallback template (only if no realpower diagnostic entity exists):
+#
+# template:
+# - sensor:
+# - name: "PFV tsys1 UPS watts"
+# unique_id: pfv_tsys1_ups_watts
+# unit_of_measurement: "W"
+# device_class: power
+# state_class: measurement
+# state: >-
+# {{ (states('sensor.tsys1_ups_load') | float(0)
+# * NOMINAL_WATTS / 100) | round(0) }}
+# ---------------------------------------------------------------------------
diff --git a/docs/reference-packs/sensors-snmp-plant.yaml b/docs/reference-packs/sensors-snmp-plant.yaml
new file mode 100644
index 0000000..647e0b4
--- /dev/null
+++ b/docs/reference-packs/sensors-snmp-plant.yaml
@@ -0,0 +1,293 @@
+# REFERENCE ONLY — historical packs, NOT loaded by HA (superseded by packages/; kept for lineage). Statuses in the repo README.
+# PFV physical-plant SNMP sensors for Home Assistant [#439]
+#
+# Paste the sensor block below into pfv-bms configuration.yaml (or an
+# !include). All temperatures are converted C to F at the display layer per
+# founder ruling 2026-08-27 (transport stays lm-sensors format, °C).
+#
+# Source: lmsensors-extend snmpd extend, deployed on all 7 Proxmox hosts +
+# stlpc-artroom + stlpc-garage (see dcinfra/sensors/temper/).
+# Extend OID: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+#
+# Hosts resolve via knel.net DNS from pfv-bms (verify in Developer Tools >
+# States after reload; if a host shows 'unknown' check DNS + the snmpd
+# source ACL which already includes the pfv-bms LAN IP 192.168.3.12).
+#
+# Per founder ruling (#343): LibreNMS does NOT graph temperatures; these HA
+# sensors are the canonical environmental record for the plant.
+
+sensor:
+ # --- TEMPer USB probe hosts (External probe, Internal fallback) ---
+ - platform: snmp
+ name: "PFV artroom temperature"
+ host: stlpc-artroom.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_artroom_temperature
+ value_template: >-
+ {% set ext = value | regex_findall('External:\s*\+?(-?\d+\.?\d*)') %}
+ {% set int = value | regex_findall('Internal:\s*\+?(-?\d+\.?\d*)') %}
+ {% set c = (ext | first) if ext else ((int | first) if int else none) %}
+ {{ ((c | float * 9 / 5) + 32) | round(1) if c is not none else 'unknown' }}
+
+ - platform: snmp
+ name: "PFV garage temperature"
+ host: stlpc-garage.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_garage_temperature
+ value_template: >-
+ {% set ext = value | regex_findall('External:\s*\+?(-?\d+\.?\d*)') %}
+ {% set int = value | regex_findall('Internal:\s*\+?(-?\d+\.?\d*)') %}
+ {% set c = (ext | first) if ext else ((int | first) if int else none) %}
+ {{ ((c | float * 9 / 5) + 32) | round(1) if c is not none else 'unknown' }}
+
+ - platform: snmp
+ name: "PFV tsys1 rack temperature"
+ host: pfv-tsys1.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_tsys1_rack_temperature
+ value_template: >-
+ {% set ext = value | regex_findall('External:\s*\+?(-?\d+\.?\d*)') %}
+ {% set int = value | regex_findall('Internal:\s*\+?(-?\d+\.?\d*)') %}
+ {% set c = (ext | first) if ext else ((int | first) if int else none) %}
+ {{ ((c | float * 9 / 5) + 32) | round(1) if c is not none else 'unknown' }}
+
+ # --- Native lm-sensors hosts (label picked per host ground truth 2026-08-27) ---
+ # tsys3 Precision 7510: CPU package
+ - platform: snmp
+ name: "PFV tsys3 CPU temperature"
+ host: pfv-tsys3.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_tsys3_cpu_temperature
+ value_template: >-
+ {% set m = value | regex_findall('Package id 0:\s*\+?(-?\d+\.?\d*)') %}
+ {{ ((m | first | float * 9 / 5) + 32) | round(1) if m else 'unknown' }}
+
+ # tsys4 Precision T1700: Dell SMM ambient (board inlet)
+ - platform: snmp
+ name: "PFV tsys4 ambient temperature"
+ host: pfv-tsys4.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_tsys4_ambient_temperature
+ value_template: >-
+ {% set m = value | regex_findall('Ambient:\s*\+?(-?\d+\.?\d*)') %}
+ {{ ((m | first | float * 9 / 5) + 32) | round(1) if m else 'unknown' }}
+
+ # tsys5 Precision T7500: Dell SMM ambient (board inlet)
+ - platform: snmp
+ name: "PFV tsys5 ambient temperature"
+ host: pfv-tsys5.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_tsys5_ambient_temperature
+ value_template: >-
+ {% set m = value | regex_findall('Ambient:\s*\+?(-?\d+\.?\d*)') %}
+ {{ ((m | first | float * 9 / 5) + 32) | round(1) if m else 'unknown' }}
+
+ # tsys6 PowerEdge R610: socket-0 core temp (no Package label on Westmere)
+ - platform: snmp
+ name: "PFV tsys6 CPU temperature"
+ host: pfv-tsys6.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_tsys6_cpu_temperature
+ value_template: >-
+ {% set m = value | regex_findall('Core 0:\s*\+?(-?\d+\.?\d*)') %}
+ {{ ((m | first | float * 9 / 5) + 32) | round(1) if m else 'unknown' }}
+
+ # tsys6 ACPI power meter (node draw, watts)
+ - platform: snmp
+ name: "PFV tsys6 node power"
+ host: pfv-tsys6.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: power
+ state_class: measurement
+ unit_of_measurement: "W"
+ unique_id: pfv_tsys6_node_power
+ value_template: >-
+ {% set m = value | regex_findall('power1:\s*(\d+\.?\d*)\s*W') %}
+ {{ m | first | float if m else 'unknown' }}
+
+ # tsys7 PowerEdge R620: package temp
+ - platform: snmp
+ name: "PFV tsys7 CPU temperature"
+ host: pfv-tsys7.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_tsys7_cpu_temperature
+ value_template: >-
+ {% set m = value | regex_findall('Package id 0:\s*\+?(-?\d+\.?\d*)') %}
+ {{ ((m | first | float * 9 / 5) + 32) | round(1) if m else 'unknown' }}
+
+ # tsys7 ACPI power meter (node draw, watts)
+ - platform: snmp
+ name: "PFV tsys7 node power"
+ host: pfv-tsys7.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: power
+ state_class: measurement
+ unit_of_measurement: "W"
+ unique_id: pfv_tsys7_node_power
+ value_template: >-
+ {% set m = value | regex_findall('power1:\s*(\d+\.?\d*)\s*W') %}
+ {{ m | first | float if m else 'unknown' }}
+
+ # tsys9 OptiPlex 7080: package temp
+ - platform: snmp
+ name: "PFV tsys9 CPU temperature"
+ host: pfv-tsys9.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_tsys9_cpu_temperature
+ value_template: >-
+ {% set m = value | regex_findall('Package id 0:\s*\+?(-?\d+\.?\d*)') %}
+ {{ ((m | first | float * 9 / 5) + 32) | round(1) if m else 'unknown' }}
+
+ # --- SBC fleet (sysfs thermal fallback in lmsensors-extend v2; temp1 = zone0/SoC) ---
+ - platform: snmp
+ name: "PFV jetson temperature"
+ host: pfv-jetson-nano-1.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_jetson_temperature
+ value_template: >-
+ {% set m = value | regex_findall('temp1:\s*\+?(-?\d+\.?\d*)') %}
+ {{ ((m | first | float * 9 / 5) + 32) | round(1) if m else 'unknown' }}
+
+ - platform: snmp
+ name: "PFV svrpi temperature"
+ host: pfvsvrpi.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_svrpi_temperature
+ value_template: >-
+ {% set m = value | regex_findall('temp1:\s*\+?(-?\d+\.?\d*)') %}
+ {{ ((m | first | float * 9 / 5) + 32) | round(1) if m else 'unknown' }}
+
+ - platform: snmp
+ name: "PFV subopi3 temperature"
+ host: subopi3.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_subopi3_temperature
+ value_template: >-
+ {% set m = value | regex_findall('temp1:\s*\+?(-?\d+\.?\d*)') %}
+ {{ ((m | first | float * 9 / 5) + 32) | round(1) if m else 'unknown' }}
+
+ - platform: snmp
+ name: "PFV subopi-dev-3 temperature"
+ host: subopi-dev-3.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_subopi_dev_3_temperature
+ value_template: >-
+ {% set m = value | regex_findall('temp1:\s*\+?(-?\d+\.?\d*)') %}
+ {{ ((m | first | float * 9 / 5) + 32) | round(1) if m else 'unknown' }}
+
+ - platform: snmp
+ name: "PFV subopi-dev-4 temperature"
+ host: subopi-dev-4.knel.net
+ version: 2c
+ community: kn3lmgmt
+ baseoid: .1.3.6.1.4.1.8072.1.3.2.3.1.2.9.108.109.115.101.110.115.111.114.115
+ accept_errors: true
+ device_class: temperature
+ state_class: measurement
+ unit_of_measurement: "°F"
+ unique_id: pfv_subopi_dev_4_temperature
+ value_template: >-
+ {% set m = value | regex_findall('temp1:\s*\+?(-?\d+\.?\d*)') %}
+ {{ ((m | first | float * 9 / 5) + 32) | round(1) if m else 'unknown' }}
+
+# ---------------------------------------------------------------------------
+# PLACEHOLDERS — enable after dependencies land (do not invent OIDs live):
+#
+# Garage PDU (AP7830, SNMPv1, HA owns PDU power graphing per #343 ruling):
+# Needs the custom v1 community from LibreNMS (founder: supply value as a
+# HA secret) + OID discovery via snmpwalk of .1.3.6.1.4.1.318 subtree.
+# - platform: snmp
+# name: "PFV garage PDU current"
+# host:
+# version: 1
+# community: !secret pdu_community
+# baseoid:
+#
+# iDRAC6 feeds (pfv-tsys6-oob / pfv-tsys7-oob, community kn3lmgmt) once the
+# OOB ports are cabled (#460): inlet temp + system power via Dell PowerNet
+# MIB. OIDs to be walked and pinned post-cabling.
+# ---------------------------------------------------------------------------
diff --git a/tools/ha-ws-call.py b/tools/ha-ws-call.py
new file mode 100644
index 0000000..7836ae2
--- /dev/null
+++ b/tools/ha-ws-call.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+"""ha-ws-call: send websocket commands to Home Assistant, print JSON replies.
+
+Env: HASS_URL (http(s)://host[:port]), HASS_TOKEN (long-lived token).
+Args: one or more JSON command objects — argv literals or file paths.
+Replies printed one-per-command, JSON indented. Stdlib + websockets only.
+Usage example (docker, from repo root):
+ docker run --rm --network host --env-file ~/.creds/homeassistant.env \
+ -v "$PWD/HomeAssistant:/w" python:3.12-alpine \
+ sh -c 'pip install -q websockets && python /w/ha-ws-call.py "{...json...}"'
+"""
+
+import asyncio
+import json
+import os
+import sys
+
+try:
+ import websockets
+except ImportError:
+ sys.exit("missing dependency: pip install websockets")
+
+
+def load_cmd(arg):
+ if arg.startswith("{"):
+ return json.loads(arg)
+ with open(arg, encoding="latin1") as handle:
+ return json.load(handle)
+
+
+async def main():
+ base = os.environ["HASS_URL"]
+ url = base.replace("http", "ws", 1).rstrip("/") + "/api/websocket"
+ token = os.environ["HASS_TOKEN"]
+ cmds = [load_cmd(arg) for arg in sys.argv[1:]]
+ async with websockets.connect(url, max_size=16 * 1024 * 1024) as ws:
+ hello = json.loads(await ws.recv())
+ if hello.get("type") != "auth_required":
+ sys.exit(f"unexpected hello: {hello}")
+ await ws.send(json.dumps({"type": "auth", "access_token": token}))
+ authed = json.loads(await ws.recv())
+ if authed.get("type") != "auth_ok":
+ sys.exit(f"auth failed: {authed}")
+ for message_id, cmd in enumerate(cmds, start=1):
+ cmd = dict(cmd)
+ cmd["id"] = message_id
+ await ws.send(json.dumps(cmd))
+ reply = json.loads(await ws.recv())
+ print(json.dumps(reply, indent=1))
+
+
+if __name__ == "__main__":
+ asyncio.run(main())