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. +![license](https://img.shields.io/badge/license-AGPLv3-blue) +![HA](https://img.shields.io/badge/Home%20Assistant-2026.8.3-41bdf5?logo=homeassistant&logoColor=white) +![HAOS](https://img.shields.io/badge/HAOS-18.2-orange) +![deploy](https://img.shields.io/badge/deploy-git%20push%20%E2%86%92%20live-4EAA25) +![entities](https://img.shields.io/badge/entities-372-9c27b0) -## 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())