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

1"""Voice and chat commands for Home Assistant's built-in conversation agent - beta. 

2 

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""" 

9 

10import datetime as dt 

11import logging 

12from typing import TYPE_CHECKING, Any 

13 

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 

20 

21from .const import DOMAIN, ChangeSource 

22from .helpers import alarm_state_as_enum 

23 

24if TYPE_CHECKING: 

25 from .autoarming import AlarmArmer, StateChange 

26 

27_LOGGER = logging.getLogger(__name__) 

28 

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" 

53 

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} 

61 

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) 

77 

78 

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 

90 

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={}) 

95 

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) 

106 

107 

108def _log(level: int, msg: str, **kwargs: Any) -> None: 

109 _LOGGER.log(level, "AUTOARM Sentences: %s", msg, **kwargs) 

110 

111 

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")) 

116 

117 

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)}" 

138 

139 

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)}."] 

146 

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.") 

167 

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) 

172 

173 

174def _say(state: AlarmControlPanelState) -> str: 

175 return SPOKEN_STATES.get(state, str(state).replace("_", " ")) 

176 

177 

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") 

183 

184 

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 "" 

194 

195 

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}."