1// Home Assistant → state change (rest_command)
2// Fires when a Home Assistant automation with a state trigger calls a rest_command that posts
3// {"entity_id", "name", "from_state", "to_state", "device_class", "unit", "priority"}.
4// Docs: https://www.home-assistant.io/docs/automation/trigger/#state-trigger
5//
6// Turns raw states into words ("on" on a door sensor becomes "open") and skips the noise
7// Home Assistant makes on restarts: changes to or from "unknown" and "unavailable".
8
9// What "on" and "off" mean for common binary_sensor device classes, plus an icon and how loud to be
10// when it turns on.
11const CLASSES = {
12 door: { on: "open", off: "closed", icon: "🚪", priority: 0 },
13 garage_door: { on: "open", off: "closed", icon: "🚗", priority: 0 },
14 window: { on: "open", off: "closed", icon: "🪟", priority: 0 },
15 opening: { on: "open", off: "closed", icon: "🚪", priority: 0 },
16 lock: { on: "unlocked", off: "locked", icon: "🔓", priority: 0 },
17 motion: { on: "motion detected", off: "clear", icon: "🏃", priority: 0 },
18 occupancy: { on: "occupied", off: "clear", icon: "🏃", priority: 0 },
19 presence: { on: "home", off: "away", icon: "🏠", priority: -1 },
20 moisture: { on: "wet", off: "dry", icon: "💧", priority: 2 }, // Highest: a leak
21 smoke: { on: "smoke detected", off: "clear", icon: "🚨", priority: 2 }, // Highest: fire
22 gas: { on: "gas detected", off: "clear", icon: "🚨", priority: 2 },
23 carbon_monoxide: { on: "CO detected", off: "clear", icon: "🚨", priority: 2 },
24 safety: { on: "unsafe", off: "safe", icon: "🚨", priority: 2 },
25 problem: { on: "problem", off: "OK", icon: "⚠️", priority: 1 }, // High: something needs fixing
26 battery: { on: "battery low", off: "battery normal", icon: "🔋", priority: 0 },
27 connectivity: { on: "connected", off: "disconnected", icon: "📶", priority: 0 },
28 plug: { on: "plugged in", off: "unplugged", icon: "🔌", priority: -1 },
29 power: { on: "power detected", off: "no power", icon: "⚡", priority: 0 },
30 running: { on: "running", off: "stopped", icon: "⚙️", priority: -1 },
31 tamper: { on: "tampered", off: "OK", icon: "🚨", priority: 1 },
32 vibration: { on: "vibration detected", off: "clear", icon: "📳", priority: 0 },
33}
34
35// Raw states that are the same for every device class.
36const STATES = {
37 on: "on",
38 off: "off",
39 open: "open",
40 closed: "closed",
41 locked: "locked",
42 unlocked: "unlocked",
43 home: "home",
44 not_home: "away",
45 armed_away: "armed (away)",
46 armed_home: "armed (home)",
47 armed_night: "armed (night)",
48 disarmed: "disarmed",
49 triggered: "triggered",
50}
51
52// What Home Assistant templates render when a value is missing. Treated as "not given".
53const MISSING = new Set(["", "none", "null", "unknown", "unavailable", "undefined", "nan"])
54
55// A trimmed, non-missing string (numbers are turned into text), or null.
56function clean(value, max = 250) {
57 if (typeof value === "number") return Number.isFinite(value) ? String(value) : null
58 if (typeof value === "boolean") return String(value)
59 if (typeof value !== "string") return null
60
61 const text = value.trim()
62 if (MISSING.has(text.toLowerCase())) return null
63 return text.length > max ? `${text.slice(0, max - 1)}…` : text
64}
65
66// Table lookup that ignores inherited names such as "toString".
67function pick(table, key, fallback) {
68 return typeof key === "string" && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : fallback
69}
70
71// Only link to real http(s) URLs.
72function link(url, cta) {
73 return typeof url === "string" && /^https?:\/\//.test(url) ? [{ cta, url }] : []
74}
75
76// Priority as a number (-2…2), a numeric string, or a word such as "high".
77const PRIORITY_WORDS = {
78 lowest: -2,
79 low: -1,
80 normal: 0,
81 default: 0,
82 high: 1,
83 highest: 2,
84 urgent: 2,
85 critical: 2,
86 emergency: 2,
87}
88
89function priority(value, fallback) {
90 const clamp = (n) => Math.max(-2, Math.min(2, Math.round(n)))
91 if (typeof value === "number") return Number.isFinite(value) ? clamp(value) : fallback
92
93 const text = clean(value)
94 if (!text) return fallback
95 if (/^[+-]?\d+(\.\d+)?$/.test(text)) return clamp(Number(text))
96 return pick(PRIORITY_WORDS, text.toLowerCase(), fallback)
97}
98
99// Entity IDs look like "binary_sensor.front_door".
100function entityId(value) {
101 const text = clean(value)
102 return text && /^[a-z0-9_]+\.[a-z0-9_]+$/.test(text) ? text : null
103}
104
105// The request body as an object. A rest_command without content_type: application/json
106// may arrive as a JSON string, so that's parsed too.
107function bodyOf(request) {
108 let body = request.body
109 if (typeof body === "string" && body.trim().startsWith("{")) {
110 try {
111 body = JSON.parse(body)
112 } catch {
113 return {}
114 }
115 }
116 return body && typeof body === "object" && !Array.isArray(body) ? body : {}
117}
118
119// ?ha=https://homeassistant.local:8123 adds a button to the entity's history.
120function historyButton(request, entity) {
121 const base = clean(request.query?.ha, 200)
122 if (!entity || !base || !/^https?:\/\/[^\s]+$/.test(base)) return []
123 return link(`${base.replace(/\/+$/, "")}/history?entity_id=${encodeURIComponent(entity)}`, "View history")
124}
125
126// A state in words, with the unit for numbers.
127function words(state, kind, unit) {
128 if (kind && (state === "on" || state === "off")) return kind[state]
129 if (unit && /^[+-]?\d+(\.\d+)?$/.test(state)) return `${state} ${unit}`
130 return pick(STATES, state, state)
131}
132
133function handleRequest(request) {
134 const body = bodyOf(request)
135
136 // Guard: only state-change bodies, so other requests on this integration stay quiet.
137 if (!Object.prototype.hasOwnProperty.call(body, "to_state")) {
138 console.log("Ignoring Home Assistant request without to_state")
139 return null
140 }
141
142 const entity = entityId(body.entity_id)
143 const name = clean(body.name, 80) ?? entity
144 const rawTo = typeof body.to_state === "string" ? body.to_state.trim().toLowerCase() : null
145 const rawFrom = typeof body.from_state === "string" ? body.from_state.trim().toLowerCase() : null
146 const to = clean(body.to_state, 100)
147 const from = clean(body.from_state, 100)
148
149 if (!name) {
150 console.log("Ignoring state change without entity_id or name")
151 return null
152 }
153
154 // An entity dropping off: only pushed with ?unavailable=1 (restarts make lots of these).
155 if (rawTo === "unavailable" || rawTo === "unknown") {
156 if (!["1", "true", "yes"].includes(String(request.query?.unavailable ?? "").toLowerCase())) {
157 console.log(`Skipping ${name} → ${rawTo}`)
158 return null
159 }
160 return {
161 title: `📴 ${name} unavailable`,
162 message: from ? `Was ${from}` : "Home Assistant lost contact with it",
163 topic: clean(body.topic, 50) ?? "Home Assistant",
164 priority: -1, // Low: often just a restart or a flaky battery sensor
165 buttons: historyButton(request, entity),
166 }
167 }
168
169 // Coming back after a restart, or an attribute-only change: not a real state change.
170 if (!to || rawFrom === "unavailable" || rawFrom === "unknown" || (from !== null && from === to)) {
171 console.log(`Skipping ${name}: ${from ?? "?"} → ${to ?? "?"}`)
172 return null
173 }
174
175 const kind = pick(CLASSES, clean(body.device_class, 40), null)
176 const unit = clean(body.unit, 20)
177 const toText = words(to.toLowerCase() === "on" || to.toLowerCase() === "off" ? to.toLowerCase() : to, kind, unit)
178 const fromText = from ? words(from.toLowerCase() === "on" || from.toLowerCase() === "off" ? from.toLowerCase() : from, kind, unit) : null
179 // Device classes are loud only when they turn on (a leak, smoke); turning off is Low.
180 const loud = kind && rawTo === "on"
181
182 return {
183 title: `${kind && loud ? `${kind.icon} ` : ""}${name}: ${toText}`,
184 message: fromText ? `Changed from ${fromText} to ${toText}` : `Now ${toText}`,
185 topic: clean(body.topic, 50) ?? "Home Assistant",
186 priority: priority(body.priority, loud ? kind.priority : kind ? -1 : 0),
187 buttons: historyButton(request, entity),
188 }
189}