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

106 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-02-17 01:14 +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__] 

40 

41NO_CAL_EVENT_MODE_AUTO = "auto" 

42NO_CAL_EVENT_MODE_MANUAL = "manual" 

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

44 

45CONF_SUPERNOTIFY = "supernotify" 

46CONF_SCENARIO = "scenario" 

47CONF_SOURCE = "source" 

48CONF_STATE = "state" 

49NOTIFY_COMMON = "common" 

50NOTIFY_QUIET = "quiet" 

51NOTIFY_NORMAL = "normal" 

52NOTIFY_CATEGORIES = [NOTIFY_COMMON, NOTIFY_QUIET, NOTIFY_NORMAL] 

53 

54NOTIFY_DEF_SCHEMA = vol.Schema({ 

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

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

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

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

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

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

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

62 vol.Optional(CONF_DATA): dict, 

63}) 

64 

65 

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

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

68 if not config: 

69 config = config or {} 

70 # backward compatible with old fixed pair profiles 

71 config.setdefault(NOTIFY_QUIET, {}) 

72 config.setdefault(NOTIFY_NORMAL, {}) 

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

74 

75 if NOTIFY_QUIET in config: 

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

77 config[NOTIFY_QUIET][CONF_SOURCE] = [ 

78 v 

79 for v in [ 

80 ChangeSource.ALARM_PANEL, 

81 ChangeSource.BUTTON, 

82 ChangeSource.CALENDAR, 

83 ChangeSource.SUNRISE, 

84 ChangeSource.SUNSET, 

85 ] 

86 if v not in sources 

87 ] 

88 

89 config.setdefault(NOTIFY_COMMON, {}) 

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

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

92 

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

94 config[NOTIFY_COMMON][CONF_SUPERNOTIFY] = any( 

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

96 ) 

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

98 config[NOTIFY_COMMON][CONF_DATA][CONF_ACTIONS] = [ 

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

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

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

102 ] 

103 return config 

104 

105 

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

107 

108DEFAULT_CALENDAR_MAPPINGS = { 

109 AlarmControlPanelState.ARMED_AWAY: "Away", 

110 AlarmControlPanelState.DISARMED: "Disarmed", 

111 AlarmControlPanelState.ARMED_HOME: "Home", 

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

113 AlarmControlPanelState.ARMED_NIGHT: "Night", 

114} 

115 

116# ENTRY_NOTIFICATION_ALL = "ALL" 

117# ENTRY_NOTIFICATION_NONE = "NONE" 

118# ENTRY_NOTIFICATION_MATCHED = "MATCHED" 

119# ENTRY_NOTIFICATION_CHOICES = (ENTRY_NOTIFICATION_ALL, ENTRY_NOTIFICATION_MATCHED, ENTRY_NOTIFICATION_NONE) 

120 

121CONF_CALENDAR_CONTROL = "calendar_control" 

122CONF_CALENDARS = "calendars" 

123CONF_CALENDAR_POLL_INTERVAL = "poll_interval" 

124CONF_CALENDAR_EVENT_STATES = "state_patterns" 

125CONF_CALENDAR_NO_EVENT = "no_event_mode" 

126CONF_CALENDAR_ENTRY_NOTIFICATIONS = "entry_notifications" 

127CONF_CALENDAR_REMINDER_NOTIFICATIONS = "reminders" 

128 

129CALENDAR_SCHEMA = vol.Schema({ 

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

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

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

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

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

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

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

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

138 }, 

139}) 

140CALENDAR_CONTROL_SCHEMA = vol.Schema({ 

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

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

143}) 

144 

145CONF_TRANSITIONS = "transitions" 

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

147 

148CONF_BUTTONS = "buttons" 

149BUTTON_OPTIONS = [ATTR_RESET, *ALARM_STATES] 

150BUTTON_SCHEMA = vol.Schema({ 

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

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

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

154}) 

155 

156CONF_RATE_LIMIT = "rate_limit" 

157CONF_RATE_LIMIT_CALLS = "max_calls" 

158CONF_RATE_LIMIT_PERIOD = "period" 

159RATE_LIMIT_SCHEMA = vol.Schema({ 

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

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

162}) 

163 

164CONF_OCCUPANCY = "occupancy" 

165CONF_DAY = "day" 

166CONF_NIGHT = "night" 

167CONF_OCCUPANCY_DEFAULT = "default_state" 

168OCCUPANCY_SCHEMA = vol.Schema({ 

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

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

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

172 }, 

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

174}) 

175 

176CONF_DIURNAL = "diurnal" 

177CONF_SUNRISE = "sunrise" 

178CONF_SUNSET = "sunset" 

179CONF_EARLIEST = "earliest" 

180CONF_LATEST = "latest" 

181 

182DIURNAL_TIME_SCHEMA = vol.Schema({ 

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

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

185}) 

186 

187CONFIG_SCHEMA = vol.Schema( 

188 { 

189 DOMAIN: vol.Schema({ 

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

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

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

193 }), 

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

195 vol.Optional(CONF_SUNRISE): DIURNAL_TIME_SCHEMA, 

196 vol.Optional(CONF_SUNSET): DIURNAL_TIME_SCHEMA, 

197 }), 

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

199 vol.Optional(CONF_CALENDAR_CONTROL): CALENDAR_CONTROL_SCHEMA, 

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

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

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

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

204 }) 

205 }, 

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

207) 

208 

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

210 "disarmed": [ 

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

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

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

214 ], 

215 "armed_home": [ 

216 "{{ autoarm.computed }}", 

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

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

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

220 ], 

221 "armed_night": [ 

222 "{{ autoarm.computed }}", 

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

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

225 ], 

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

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

228} 

229 

230 

231@dataclass 

232class ConditionVariables: 

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

234 

235 occupied: bool | None 

236 unoccupied: bool | None 

237 night: bool 

238 state: AlarmControlPanelState 

239 occupied_defaults: dict[str, AlarmControlPanelState] 

240 calendar_event: CalendarEvent | None = None 

241 at_home: list[str] | None = None 

242 not_home: list[str] | None = None 

243 

244 def as_dict(self) -> ConfigType: 

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

246 return { 

247 "daytime": not self.night, 

248 "occupied": self.occupied, 

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

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

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

252 "night": self.night, 

253 "day": not self.night, 

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

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

256 "calendar_event": self.calendar_event, 

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

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

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

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

261 "computed": not self.calendar_event 

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

263 } 

264 

265 

266class ChangeSource(StrEnum): 

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

268 

269 CALENDAR = auto() 

270 MOBILE = auto() 

271 OCCUPANCY = auto() 

272 ALARM_PANEL = auto() 

273 BUTTON = auto() 

274 ACTION = auto() 

275 SUNRISE = auto() 

276 SUNSET = auto() 

277 ZOMBIFICATION = auto() 

278 STARTUP = auto()