Coverage for custom_components/autoarm/sentences.py: 99%
96 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-26 20:57 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-26 20:57 +0000
1"""Voice and chat commands for Home Assistant's built-in conversation agent - beta.
3The built-in agent doesn't use an AI model, it matches fixed sentences, so AutoArm registers a few
4of its own, the same way an automation with a conversation trigger does. These take priority over
5the built-in agent's own alarm sentences, so a change by voice counts as a manual intervention.
6English only for now. Arming, explaining and help are on by default, disarming is switched on separately in the options,
7since anyone who can talk to Assist could then disarm the alarm.
8"""
10import datetime as dt
11import logging
12from typing import TYPE_CHECKING, Any
14from homeassistant.components.alarm_control_panel.const import AlarmControlPanelState
15from homeassistant.core import CALLBACK_TYPE, HomeAssistant
16from homeassistant.core import Context as HAContext
17from homeassistant.helpers.script import ScriptRunResult
18from homeassistant.helpers.trigger import async_initialize_triggers, async_validate_trigger_config
19from homeassistant.util import dt as dt_util
21from .const import DOMAIN, ChangeSource
22from .helpers import alarm_state_as_enum
24if TYPE_CHECKING:
25 from .autoarming import AlarmArmer, StateChange
27_LOGGER = logging.getLogger(__name__)
29# hassil sentence templates - (a|b) is a choice, [a] is optional
30ALARM = "[the] (alarm|security system|burglar alarm)"
31AUTOARM = "(auto arm|autoarm)"
32SENTENCES: dict[str, list[str]] = {
33 AlarmControlPanelState.ARMED_AWAY: [f"arm {ALARM}", f"arm {ALARM} [in|to] away [mode]", f"set {ALARM} to away [mode]"],
34 AlarmControlPanelState.ARMED_HOME: [f"arm {ALARM} [in|to] home [mode]", f"set {ALARM} to home [mode]"],
35 AlarmControlPanelState.ARMED_NIGHT: [f"arm {ALARM} [in|to|for] [the] night [mode]", f"set {ALARM} to night [mode]"],
36 AlarmControlPanelState.ARMED_VACATION: [
37 f"arm {ALARM} [in|to|for] (vacation|holiday) [mode]",
38 f"set {ALARM} to (vacation|holiday) [mode]",
39 ],
40 AlarmControlPanelState.DISARMED: [f"disarm {ALARM}"],
41 "why": [
42 f"why is {ALARM} [armed|disarmed|on|off|set] [to] [away|home|night|vacation]",
43 f"why (was|has) {ALARM} [been] (armed|disarmed|changed|set)",
44 f"(what|who) (armed|disarmed|changed|set) {ALARM}",
45 ],
46 "help": [
47 f"how [do i|can i|to] (get help|find help|use) [on|with|for|using] {AUTOARM}",
48 f"i need help [on|with|for|using] {AUTOARM}",
49 f"help [me] [on|with|for|using] {AUTOARM}",
50 ],
51}
52DOCS_URL = "https://autoarm.rhizomatics.org.uk"
54SPOKEN_STATES: dict[AlarmControlPanelState, str] = {
55 AlarmControlPanelState.ARMED_AWAY: "armed away",
56 AlarmControlPanelState.ARMED_HOME: "armed home",
57 AlarmControlPanelState.ARMED_NIGHT: "armed for the night",
58 AlarmControlPanelState.ARMED_VACATION: "armed for vacation",
59 AlarmControlPanelState.ARMED_CUSTOM_BYPASS: "armed with custom bypass",
60}
62SPOKEN_SOURCES: dict[ChangeSource, str] = {
63 ChangeSource.CALENDAR: "from the calendar",
64 ChangeSource.TOD: "by a times of the day sensor",
65 ChangeSource.MOBILE: "from a mobile notification action",
66 ChangeSource.OCCUPANCY: "after a change in who's home",
67 ChangeSource.BUTTON: "when a button was pressed",
68 ChangeSource.ACTION: "from the reset action",
69 ChangeSource.SUNRISE: "at sunrise",
70 ChangeSource.SUNSET: "at sunset",
71 ChangeSource.ZOMBIFICATION: "because the panel had become unavailable",
72 ChangeSource.VOICE: "by a voice command",
73 ChangeSource.STARTUP: "when it started up",
74}
75# changes worked out from who's home and the time of day, so worth saying what those were
76COMPUTED_SOURCES = (ChangeSource.OCCUPANCY, ChangeSource.SUNRISE, ChangeSource.SUNSET, ChangeSource.STARTUP)
79async def async_register_sentences(
80 hass: HomeAssistant, armer: AlarmArmer, arm: bool = True, disarm: bool = False
81) -> CALLBACK_TYPE | None:
82 """Register the sentences with the built-in conversation agent, returning how to remove them"""
83 commands: dict[str, list[str]] = {
84 command: sentences
85 for command, sentences in SENTENCES.items()
86 if (disarm if command == AlarmControlPanelState.DISARMED else arm)
87 }
88 if not commands:
89 return None
91 async def action(run_variables: dict[str, Any], _context: HAContext | None = None) -> ScriptRunResult:
92 trigger: dict[str, Any] = run_variables["trigger"]
93 response = await async_respond(armer, trigger["id"], _requester(trigger))
94 return ScriptRunResult(conversation_response=response, service_response=None, variables={})
96 try:
97 # HA renamed platform to trigger in 2024.10
98 config = await async_validate_trigger_config(
99 hass,
100 [{"platform": "conversation", "id": str(command), "command": sentences} for command, sentences in commands.items()],
101 )
102 except Exception as e:
103 _LOGGER.warning("AUTOARM Unable to register sentences with the built-in conversation agent: %s", e)
104 return None
105 return await async_initialize_triggers(hass, config, action, DOMAIN, "AutoArm sentences", _log)
108def _log(level: int, msg: str, **kwargs: Any) -> None:
109 _LOGGER.log(level, "AUTOARM Sentences: %s", msg, **kwargs)
112def _requester(trigger: dict[str, Any]) -> HAContext:
113 """The context of the conversation, so a change can be linked to who asked"""
114 context: dict[str, Any] = (trigger.get("user_input") or {}).get("context") or {}
115 return HAContext(user_id=context.get("user_id"), parent_id=context.get("id"))
118async def async_respond(armer: AlarmArmer, command: str, context: HAContext) -> str:
119 """Carry out a command, returning what to say back"""
120 if command == "why":
121 return explain(armer)
122 if command == "help":
123 return f"Help on using Auto Arm is at {DOCS_URL}"
124 state: AlarmControlPanelState | None = alarm_state_as_enum(command)
125 if state is None:
126 return f"Auto Arm doesn't know the command {command}"
127 if armer.armed_state() == state:
128 return f"The alarm is already {_say(state)}"
129 armer.record_intervention(source=ChangeSource.VOICE, state=state, context=context)
130 new_state = await armer.arm(
131 state, source=ChangeSource.VOICE, change_context={"caller": "sentences", "user_id": context.user_id}, context=context
132 )
133 if new_state is None:
134 return f"Sorry, the alarm couldn't be {_say(state)}"
135 if armer.armed_state() == AlarmControlPanelState.ARMING:
136 return f"The alarm is arming, and will be {_say(state)} after the exit delay"
137 return f"The alarm is now {_say(state)}"
140def explain(armer: AlarmArmer) -> str:
141 """Why the alarm is in its current state, as far as AutoArm knows"""
142 state: AlarmControlPanelState = armer.armed_state()
143 change: StateChange | None = armer.last_change
144 intervention = armer.last_state_intervention()
145 reply = [f"The alarm is {_say(state)}."]
147 if (
148 intervention is not None
149 and intervention.source == ChangeSource.ALARM_PANEL
150 and intervention.state == state
151 and (change is None or intervention.created_at >= change.created_at)
152 ):
153 reply.append(f"It was changed on the alarm panel at {_when(intervention.created_at)}, not by AutoArm.")
154 elif change is not None and (change.to_state == state or state == AlarmControlPanelState.ARMING):
155 reason = _reason(change)
156 reply.append(
157 f"Auto Arm set it to {_say(change.to_state)} at {_when(change.created_at)}{' ' + reason if reason else ''}."
158 )
159 if change.source in COMPUTED_SOURCES:
160 reply.append(_situation(change))
161 elif change is not None:
162 reply.append(
163 f"It was changed outside AutoArm. AutoArm last set it to {_say(change.to_state)} at {_when(change.created_at)}."
164 )
165 else:
166 reply.append("AutoArm hasn't changed it since Home Assistant started.")
168 active = armer.active_calendar_event()
169 if active is not None:
170 reply.append(f"The calendar event {active.event.summary} is in control until {_when(active.event.end_datetime_local)}.")
171 return " ".join(reply)
174def _say(state: AlarmControlPanelState) -> str:
175 return SPOKEN_STATES.get(state, str(state).replace("_", " "))
178def _when(at: dt.datetime) -> str:
179 local = dt_util.as_local(at)
180 if local.date() == dt_util.now().date():
181 return local.strftime("%H:%M")
182 return local.strftime("%H:%M on %A %d %B")
185def _reason(change: StateChange) -> str:
186 if change.source == ChangeSource.CALENDAR:
187 if change.context.get("summary"):
188 return f"for the calendar event {change.context['summary']}"
189 if change.context.get("no_event_mode"):
190 return "because the calendar event ended"
191 if change.source == ChangeSource.TOD and change.context.get("summary"):
192 return f"by the times of the day sensor {change.context['summary']}"
193 return SPOKEN_SOURCES.get(change.source, "") if change.source else ""
196def _situation(change: StateChange) -> str:
197 time_of_day = "night" if change.night else "daytime"
198 if change.occupied is None:
199 return f"It was {time_of_day}."
200 who = "Someone was home" if change.occupied else "Everyone was out"
201 return f"{who}, and it was {time_of_day}."