Coverage for custom_components/autoarm/frigate.py: 100%
54 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"""Keep the active Frigate profile in step with the alarm state
3See https://docs.frigate.video/configuration/profiles/
4"""
6import logging
7from collections.abc import Callable
9from homeassistant.components.select.const import ATTR_OPTIONS
10from homeassistant.components.select.const import DOMAIN as SELECT_DOMAIN
11from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION, STATE_UNAVAILABLE, STATE_UNKNOWN
12from homeassistant.core import Context, Event, EventStateChangedData, HomeAssistant
13from homeassistant.exceptions import HomeAssistantError
14from homeassistant.helpers import entity_registry as er
15from homeassistant.helpers.event import async_track_state_change_event
17from .const import EVENT_TRIGGERED, FRIGATE_NO_PROFILE, PUBLIC_ALARM_STATES
18from .helpers import child_context
20_LOGGER = logging.getLogger(__name__)
22# ends the unique id of the Frigate integration's profile select, after its config entry id
23FRIGATE_PROFILE_UNIQUE_ID_SUFFIX = ":select:profile"
26def match_profile(state: str, available: list[str]) -> str | None:
27 """The profile named for an alarm state, ignoring case, or else one named armed for any of the armed states"""
28 by_name = {p.lower(): p for p in available if p != FRIGATE_NO_PROFILE}
29 return by_name.get(state) or (by_name.get("armed") if state.startswith("armed_") else None)
32def frigate_profile_selects(hass: HomeAssistant) -> list[str]:
33 """Profile selects of the Frigate integration, one for each Frigate server with profiles"""
34 return [
35 entry.entity_id
36 for entry in er.async_get(hass).entities.values()
37 if entry.platform == "frigate" and entry.unique_id.endswith(FRIGATE_PROFILE_UNIQUE_ID_SUFFIX)
38 ]
41def frigate_profiles(hass: HomeAssistant, profile_select: str | None) -> list[str]:
42 """Profiles of the profile select, or of any Frigate profile select before one is chosen"""
43 selects = [profile_select] if profile_select else frigate_profile_selects(hass)
44 return list(
45 dict.fromkeys(
46 p
47 for entity_id in selects
48 if (state := hass.states.get(entity_id))
49 for p in state.attributes.get(ATTR_OPTIONS, [])
50 if p != FRIGATE_NO_PROFILE
51 )
52 )
55async def async_track_frigate_profile(
56 hass: HomeAssistant, alarm_panel: str, profile_select: str, profiles: dict[str, str]
57) -> Callable[[], None]:
58 """Switch Frigate to the profile given for each alarm state, and to no profile for the others
60 A state never given a profile is matched by name to one of the select's profiles,
61 while transitional states, like arming or triggered, leave the profile as it is
62 """
64 async def sync(context: Context | None = None) -> None:
65 panel = hass.states.get(alarm_panel)
66 select = hass.states.get(profile_select)
67 if (
68 panel is None
69 or panel.state not in PUBLIC_ALARM_STATES
70 or select is None
71 or select.state in (STATE_UNAVAILABLE, STATE_UNKNOWN)
72 ):
73 return
74 available: list[str] = select.attributes.get(ATTR_OPTIONS, [])
75 profile = (
76 profiles[panel.state] if panel.state in profiles else match_profile(panel.state, available) or FRIGATE_NO_PROFILE
77 )
78 if profile not in available:
79 # gone from Frigate, so back to the base config rather than staying on the profile of an earlier state
80 _LOGGER.warning("AUTOARM No Frigate profile %s on %s for alarm state %s", profile, profile_select, panel.state)
81 profile = FRIGATE_NO_PROFILE
82 if profile == select.state:
83 return
84 _LOGGER.info("AUTOARM Frigate profile %s-->%s for alarm state %s", select.state, profile, panel.state)
85 context = context or Context()
86 if context.origin_event is None:
87 # the logbook takes the first event of a context as its cause, otherwise that's the select action
88 hass.bus.async_fire(EVENT_TRIGGERED, {ATTR_ENTITY_ID: profile_select, "alarm_state": panel.state}, context=context)
89 try:
90 await hass.services.async_call(
91 SELECT_DOMAIN,
92 SERVICE_SELECT_OPTION,
93 {ATTR_ENTITY_ID: profile_select, ATTR_OPTION: profile},
94 blocking=True,
95 context=context,
96 )
97 except HomeAssistantError as e:
98 _LOGGER.warning("AUTOARM Failed to set Frigate profile %s on %s: %s", profile, profile_select, e)
100 async def on_panel_change(event: Event[EventStateChangedData]) -> None:
101 await sync(child_context(event.context))
103 async def on_select_change(event: Event[EventStateChangedData]) -> None:
104 # only catching up as Frigate comes back, so a profile chosen by hand is left alone
105 old = event.data["old_state"]
106 if old is None or old.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
107 await sync(child_context(event.context))
109 unsubscribes = [
110 async_track_state_change_event(hass, [alarm_panel], on_panel_change),
111 async_track_state_change_event(hass, [profile_select], on_select_change),
112 ]
113 await sync()
115 def unsubscribe() -> None:
116 for unsub in unsubscribes:
117 unsub()
119 return unsubscribe