Coverage for custom_components/autoarm/const.py: 99%

103 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-04-27 16:32 +0000

1"""The Auto Arm integration""" 

2 

3import logging 

4from dataclasses import dataclass 

5from enum import StrEnum, auto 

6from typing import Any 

7 

8import voluptuous as vol 

9from homeassistant.components.alarm_control_panel.const import AlarmControlPanelState 

10from homeassistant.components.calendar import CalendarEvent 

11from homeassistant.const import ( 

12 CONF_ACTION, 

13 CONF_ACTIONS, 

14 CONF_ALIAS, 

15 CONF_CONDITIONS, 

16 CONF_DELAY_TIME, 

17 CONF_ENTITY_ID, 

18 CONF_SERVICE, 

19 CONF_TARGET, 

20 STATE_HOME, 

21 STATE_NOT_HOME, 

22) 

23from homeassistant.helpers import config_validation as cv 

24from homeassistant.helpers.typing import ConfigType 

25from homeassistant.util.hass_dict import HassKey 

26 

27_LOGGER = logging.getLogger(__name__) 

28 

29DOMAIN = "autoarm" 

30YAML_DATA_KEY: HassKey[ConfigType] = HassKey(f"{DOMAIN}_yaml") 

31 

32ATTR_ACTION = "action" 

33ATTR_RESET = "reset" 

34CONF_DATA = "data" 

35CONF_NOTIFY = "notify" 

36CONF_ALARM_PANEL = "alarm_panel" 

37CONF_ALARM_STATES = "alarm_states" 

38 

39ALARM_STATES = [k.lower() for k in AlarmControlPanelState.__members__] 

40PUBLIC_ALARM_STATES = [s for s in ALARM_STATES if s not in ("pending", "triggered", "arming", "disarming")] 

41 

42NO_CAL_EVENT_MODE_AUTO = "auto" 

43NO_CAL_EVENT_MODE_MANUAL = "manual" 

44NO_CAL_EVENT_OPTIONS: list[str] = [NO_CAL_EVENT_MODE_AUTO, NO_CAL_EVENT_MODE_MANUAL, *ALARM_STATES] 

45 

46CONF_SUPERNOTIFY = "supernotify" 

47CONF_SCENARIO = "scenario" 

48CONF_SOURCE = "source" 

49CONF_STATE = "state" 

50NOTIFY_COMMON = "common" 

51NOTIFY_QUIET = "quiet" 

52NOTIFY_NORMAL = "normal" 

53NOTIFY_CATEGORIES = [NOTIFY_COMMON, NOTIFY_QUIET, NOTIFY_NORMAL] 

54 

55NOTIFY_DEF_SCHEMA = vol.Schema({ 

56 vol.Optional(CONF_SERVICE): cv.service, 

57 vol.Optional(CONF_SUPERNOTIFY): cv.boolean, 

58 vol.Required(CONF_SUPERNOTIFY, default=True): cv.boolean, 

59 vol.Optional(CONF_TARGET): vol.All(cv.ensure_list, [str]), 

60 vol.Optional(CONF_SOURCE): vol.All(cv.ensure_list, [str]), 

61 vol.Optional(CONF_STATE): vol.All(cv.ensure_list, [vol.In(ALARM_STATES)]), 

62 vol.Optional(CONF_SCENARIO, default=[]): vol.All(cv.ensure_list, [str]), 

63 vol.Optional(CONF_DATA): dict, 

64}) 

65 

66 

67def _apply_notify_defaults(config: dict[str, Any]) -> dict[str, Any]: 

68 """Apply defaults for known notify profiles.""" 

69 if not config: 

70 config = config or {} 

71 # backward compatible with old fixed pair profiles 

72 config.setdefault(NOTIFY_QUIET, {CONF_STATE: PUBLIC_ALARM_STATES}) 

73 config.setdefault(NOTIFY_NORMAL, {CONF_STATE: PUBLIC_ALARM_STATES}) 

74 sources: list[str] = [s for profile in config.values() for s in profile.get(CONF_SOURCE, []) if not profile.get(CONF_STATE)] 

75 

76 if NOTIFY_QUIET in config: 

77 if not config[NOTIFY_QUIET].get(CONF_SOURCE): 

78 config[NOTIFY_QUIET][CONF_SOURCE] = [ 

79 v 

80 for v in [ 

81 ChangeSource.ALARM_PANEL, 

82 ChangeSource.BUTTON, 

83 ChangeSource.CALENDAR, 

84 ChangeSource.SUNRISE, 

85 ChangeSource.SUNSET, 

86 ] 

87 if v not in sources 

88 ] 

89 

90 config.setdefault(NOTIFY_COMMON, {}) 

91 config[NOTIFY_COMMON].setdefault(CONF_DATA, {}) 

92 config[NOTIFY_COMMON].setdefault(CONF_SERVICE, "notify.send_message") 

93 

94 if config[NOTIFY_COMMON].get(CONF_SUPERNOTIFY) is None: 

95 config[NOTIFY_COMMON][CONF_SUPERNOTIFY] = any( 

96 config[NOTIFY_COMMON][CONF_SERVICE].endswith(v) for v in ("supernotify", "supernotifier") 

97 ) 

98 if config[NOTIFY_COMMON].get(CONF_SUPERNOTIFY) and CONF_ACTIONS not in config[NOTIFY_COMMON][CONF_DATA]: 

99 config[NOTIFY_COMMON][CONF_DATA][CONF_ACTIONS] = [ 

100 {CONF_ACTION: "ALARM_PANEL_DISARM", "title": "Disarm Alarm Panel", "icon": "sfsymbols:bell.slash"}, 

101 {CONF_ACTION: "ALARM_PANEL_RESET", "title": "Reset Alarm Panel", "icon": "sfsymbols:bell"}, 

102 {CONF_ACTION: "ALARM_PANEL_AWAY", "title": "Arm Alarm Panel Away", "icon": "sfsymbols:airplane"}, 

103 ] 

104 return config 

105 

106 

107NOTIFY_SCHEMA = vol.All(vol.Schema({cv.string: NOTIFY_DEF_SCHEMA}), _apply_notify_defaults) 

108 

109DEFAULT_CALENDAR_MAPPINGS = { 

110 AlarmControlPanelState.ARMED_AWAY: "Away", 

111 AlarmControlPanelState.DISARMED: "Disarmed", 

112 AlarmControlPanelState.ARMED_HOME: "Home", 

113 AlarmControlPanelState.ARMED_VACATION: ["Vacation", "Holiday"], 

114 AlarmControlPanelState.ARMED_NIGHT: "Night", 

115} 

116 

117# ENTRY_NOTIFICATION_ALL = "ALL" 

118# ENTRY_NOTIFICATION_NONE = "NONE" 

119# ENTRY_NOTIFICATION_MATCHED = "MATCHED" 

120# ENTRY_NOTIFICATION_CHOICES = (ENTRY_NOTIFICATION_ALL, ENTRY_NOTIFICATION_MATCHED, ENTRY_NOTIFICATION_NONE) 

121 

122CONF_CALENDAR_CONTROL = "calendar_control" 

123CONF_CALENDARS = "calendars" 

124CONF_CALENDAR_POLL_INTERVAL = "poll_interval" 

125CONF_CALENDAR_EVENT_STATES = "state_patterns" 

126CONF_CALENDAR_NO_EVENT = "no_event_mode" 

127CONF_CALENDAR_ENTRY_NOTIFICATIONS = "entry_notifications" 

128CONF_CALENDAR_REMINDER_NOTIFICATIONS = "reminders" 

129 

130CALENDAR_SCHEMA = vol.Schema({ 

131 vol.Required(CONF_ENTITY_ID): cv.entity_id, 

132 vol.Optional(CONF_ALIAS): cv.string, 

133 vol.Optional(CONF_CALENDAR_POLL_INTERVAL, default=15): cv.positive_int, 

134 # vol.Optional(CONF_CALENDAR_ENTRY_NOTIFICATIONS): vol.In(ENTRY_NOTIFICATION_CHOICES), 

135 # vol.Optional(CONF_CALENDAR_REMINDER_NOTIFICATIONS, default={}): { 

136 # vol.In(ALARM_STATES): vol.All(cv.ensure_list, [cv.time_period])}, 

137 vol.Optional(CONF_CALENDAR_EVENT_STATES, default=DEFAULT_CALENDAR_MAPPINGS): { 

138 vol.In(ALARM_STATES): vol.All(cv.ensure_list, [cv.is_regex]) 

139 }, 

140}) 

141CALENDAR_CONTROL_SCHEMA = vol.Schema({ 

142 vol.Optional(CONF_CALENDAR_NO_EVENT, default=NO_CAL_EVENT_MODE_AUTO): vol.All(vol.Lower, vol.In(NO_CAL_EVENT_OPTIONS)), 

143 vol.Optional(CONF_CALENDARS, default=[]): vol.All(cv.ensure_list, [CALENDAR_SCHEMA]), 

144}) 

145 

146CONF_TRANSITIONS = "transitions" 

147TRANSITION_SCHEMA = vol.Schema({vol.Optional(CONF_ALIAS): cv.string, vol.Required(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA}) 

148 

149CONF_BUTTONS = "buttons" 

150BUTTON_OPTIONS = [ATTR_RESET, *ALARM_STATES] 

151BUTTON_SCHEMA = vol.Schema({ 

152 vol.Optional(CONF_ALIAS): cv.string, 

153 vol.Optional(CONF_DELAY_TIME): vol.All(cv.time_period, cv.positive_timedelta), 

154 vol.Required(CONF_ENTITY_ID): vol.All(cv.ensure_list, [cv.entity_id]), 

155}) 

156 

157CONF_RATE_LIMIT = "rate_limit" 

158CONF_RATE_LIMIT_CALLS = "max_calls" 

159CONF_RATE_LIMIT_PERIOD = "period" 

160RATE_LIMIT_SCHEMA = vol.Schema({ 

161 vol.Optional(CONF_RATE_LIMIT_PERIOD, default=60): vol.All(cv.time_period, cv.positive_timedelta), 

162 vol.Optional(CONF_RATE_LIMIT_CALLS, default=6): cv.positive_int, 

163}) 

164 

165CONF_OCCUPANCY = "occupancy" 

166CONF_DAY = "day" 

167CONF_NIGHT = "night" 

168CONF_OCCUPANCY_DEFAULT = "default_state" 

169OCCUPANCY_SCHEMA = vol.Schema({ 

170 vol.Required(CONF_ENTITY_ID, default=[]): vol.All(cv.ensure_list, [cv.entity_id]), 

171 vol.Optional(CONF_OCCUPANCY_DEFAULT, default={CONF_DAY: AlarmControlPanelState.ARMED_HOME}): { 

172 vol.In([CONF_DAY, CONF_NIGHT]): vol.In(ALARM_STATES) 

173 }, 

174 vol.Optional(CONF_DELAY_TIME): {vol.In([STATE_HOME, STATE_NOT_HOME]): vol.All(cv.time_period, cv.positive_timedelta)}, 

175}) 

176 

177CONF_DIURNAL = "diurnal" 

178CONF_SUNRISE = "sunrise" 

179CONF_SUNSET = "sunset" 

180CONF_EARLIEST = "earliest" 

181CONF_LATEST = "latest" 

182 

183DIURNAL_TIME_SCHEMA = vol.Schema({ 

184 vol.Optional(CONF_EARLIEST): cv.time, 

185 vol.Optional(CONF_LATEST): cv.time, 

186}) 

187 

188CONFIG_SCHEMA = vol.Schema( 

189 { 

190 DOMAIN: vol.Schema({ 

191 vol.Optional(CONF_ALARM_PANEL): vol.Schema({ 

192 vol.Optional(CONF_ALIAS): cv.string, 

193 vol.Required(CONF_ENTITY_ID): cv.entity_id, 

194 }), 

195 vol.Optional(CONF_DIURNAL): vol.Schema({ 

196 vol.Optional(CONF_SUNRISE): DIURNAL_TIME_SCHEMA, 

197 vol.Optional(CONF_SUNSET): DIURNAL_TIME_SCHEMA, 

198 }), 

199 vol.Optional(CONF_TRANSITIONS): {vol.In(ALARM_STATES): TRANSITION_SCHEMA}, 

200 vol.Optional(CONF_CALENDAR_CONTROL): CALENDAR_CONTROL_SCHEMA, 

201 vol.Optional(CONF_BUTTONS): {vol.In(BUTTON_OPTIONS): BUTTON_SCHEMA}, 

202 vol.Optional(CONF_OCCUPANCY, default={}): OCCUPANCY_SCHEMA, 

203 vol.Optional(CONF_NOTIFY, default={}): NOTIFY_SCHEMA, 

204 vol.Optional(CONF_RATE_LIMIT, default={}): RATE_LIMIT_SCHEMA, 

205 }) 

206 }, 

207 extra=vol.ALLOW_EXTRA, # validation fails without this by trying to include all of HASS config 

208) 

209 

210DEFAULT_TRANSITIONS: dict[str, str | list[str]] = { 

211 "disarmed": [ 

212 "{{ autoarm.computed and autoarm.occupied}}", 

213 "{{ (autoarm.day and autoarm.occupied_daytime_state == 'disarmed') or" 

214 " (autoarm.night and autoarm.occupied_nighttime_state == 'disarmed') }}", 

215 ], 

216 "armed_home": [ 

217 "{{ autoarm.computed }}", 

218 "{{ (autoarm.day and autoarm.occupied and autoarm.occupied_daytime_state == 'armed_home') or" 

219 " ( autoarm.night and autoarm.occupied and autoarm.occupied_nighttime_state == 'armed_home' ) or" 

220 " ( autoarm.day and autoarm.occupied is none ) }}", 

221 ], 

222 "armed_night": [ 

223 "{{ autoarm.computed }}", 

224 "{{ ( autoarm.night and autoarm.occupied and autoarm.occupied_nighttime_state == 'armed_night' ) or " 

225 " ( autoarm.night and autoarm.occupied is none )}}", 

226 ], 

227 "armed_away": "{{ autoarm.computed and not autoarm.occupied and autoarm.occupied is not none}}", 

228 "armed_vacation": "{{ autoarm.vacation }}", 

229} 

230 

231 

232@dataclass 

233class ConditionVariables: 

234 """Field with sub-fields added to the template context of Transition Conditions""" 

235 

236 occupied: bool | None 

237 unoccupied: bool | None 

238 night: bool 

239 state: AlarmControlPanelState 

240 occupied_defaults: dict[str, AlarmControlPanelState] 

241 calendar_event: CalendarEvent | None = None 

242 at_home: list[str] | None = None 

243 not_home: list[str] | None = None 

244 

245 def as_dict(self) -> ConfigType: 

246 """Generate the field to be exposed in the context, stringifying alarm states""" 

247 return { 

248 "daytime": not self.night, 

249 "occupied": self.occupied, 

250 "at_home": self.at_home or [], 

251 "not_home": self.at_home or [], 

252 "vacation": self.state == AlarmControlPanelState.ARMED_VACATION, 

253 "night": self.night, 

254 "day": not self.night, 

255 "bypass": self.state == AlarmControlPanelState.ARMED_CUSTOM_BYPASS, 

256 "manual": self.state in (AlarmControlPanelState.ARMED_VACATION, AlarmControlPanelState.ARMED_CUSTOM_BYPASS), 

257 "calendar_event": self.calendar_event, 

258 "state": str(self.state), 

259 "occupied_daytime_state": str(self.occupied_defaults.get(CONF_DAY, AlarmControlPanelState.DISARMED)), 

260 "occupied_nighttime_state": str(self.occupied_defaults.get(CONF_NIGHT, AlarmControlPanelState.ARMED_NIGHT)), 

261 "disarmed": self.state == AlarmControlPanelState.DISARMED, 

262 "computed": not self.calendar_event 

263 and self.state not in (AlarmControlPanelState.ARMED_VACATION, AlarmControlPanelState.ARMED_CUSTOM_BYPASS), 

264 } 

265 

266 

267class ChangeSource(StrEnum): 

268 """Enumeration of all the known ways to trigger a state change""" 

269 

270 CALENDAR = auto() 

271 MOBILE = auto() 

272 OCCUPANCY = auto() 

273 ALARM_PANEL = auto() 

274 BUTTON = auto() 

275 ACTION = auto() 

276 SUNRISE = auto() 

277 SUNSET = auto() 

278 ZOMBIFICATION = auto() 

279 STARTUP = auto() 

280 UNKNOWN = auto()