Преглед изворни кода

Add lock group aggregation

Groups of configured locks are defined via the options flow and publish
shared last_action and last_user sensors that reflect whichever member
lock fired most recently. Entry reloads on options change to pick up
new/removed groups.
ZiB пре 4 месеци
родитељ
комит
45284de2b1

+ 6 - 0
custom_components/zlock_slots/__init__.py

@@ -35,9 +35,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
   register_services(hass)
   await _async_register_frontend(hass)
 
+  entry.async_on_unload(entry.add_update_listener(_async_options_updated))
+
   return True
 
 
+async def _async_options_updated(hass: HomeAssistant, entry) -> None:
+  await hass.config_entries.async_reload(entry.entry_id)
+
+
 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
   unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 

+ 77 - 2
custom_components/zlock_slots/config_flow.py

@@ -2,10 +2,11 @@ from __future__ import annotations
 
 import voluptuous as vol
 
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
+from homeassistant.core import callback
 from homeassistant.helpers import selector
 
-from .const import DOMAIN, CONF_LOCK_ENTITY_IDS
+from .const import DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME
 
 
 class ZLockSlotsConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -40,9 +41,83 @@ class ZLockSlotsConfigFlow(ConfigFlow, domain=DOMAIN):
       errors=errors,
     )
 
+  @staticmethod
+  @callback
+  def async_get_options_flow(config_entry) -> "ZLockSlotsOptionsFlow":
+    return ZLockSlotsOptionsFlow(config_entry)
+
   def _make_title(self, lock_ids: list[str]) -> str:
     if len(lock_ids) == 1:
       state = self.hass.states.get(lock_ids[0])
       if state:
         return state.name
     return f"Z-Lock Slots ({len(lock_ids)} locks)"
+
+
+class ZLockSlotsOptionsFlow(OptionsFlow):
+  def __init__(self, config_entry) -> None:
+    self._entry = config_entry
+    self._groups: list[dict] = list(config_entry.options.get(CONF_GROUPS, []))
+
+  async def async_step_init(self, user_input=None) -> ConfigFlowResult:
+    return self.async_show_menu(
+      step_id="init",
+      menu_options=["add_group", "remove_group", "finish"],
+    )
+
+  async def async_step_add_group(self, user_input=None) -> ConfigFlowResult:
+    errors: dict[str, str] = {}
+    lock_ids: list[str] = self._entry.data[CONF_LOCK_ENTITY_IDS]
+
+    if user_input is not None:
+      name = user_input.get(CONF_GROUP_NAME, "").strip()
+      selected: list[str] = user_input.get(CONF_LOCK_ENTITY_IDS, [])
+      if not name:
+        errors[CONF_GROUP_NAME] = "group_name_required"
+      elif any(g[CONF_GROUP_NAME] == name for g in self._groups):
+        errors[CONF_GROUP_NAME] = "group_name_exists"
+      elif not selected:
+        errors[CONF_LOCK_ENTITY_IDS] = "no_locks_selected"
+      else:
+        self._groups.append({CONF_GROUP_NAME: name, CONF_LOCK_ENTITY_IDS: selected})
+        return await self.async_step_init()
+
+    lock_options = [
+      selector.SelectOptionDict(
+        value=lid,
+        label=(self.hass.states.get(lid).name if self.hass.states.get(lid) else lid),
+      )
+      for lid in lock_ids
+    ]
+    schema = vol.Schema({
+      vol.Required(CONF_GROUP_NAME): selector.TextSelector(),
+      vol.Required(CONF_LOCK_ENTITY_IDS): selector.SelectSelector(
+        selector.SelectSelectorConfig(options=lock_options, multiple=True)
+      ),
+    })
+    return self.async_show_form(step_id="add_group", data_schema=schema, errors=errors)
+
+  async def async_step_remove_group(self, user_input=None) -> ConfigFlowResult:
+    if not self._groups:
+      return await self.async_step_init()
+
+    if user_input is not None:
+      names_to_remove = set(user_input.get("groups", []))
+      self._groups = [g for g in self._groups if g[CONF_GROUP_NAME] not in names_to_remove]
+      return await self.async_step_init()
+
+    schema = vol.Schema({
+      vol.Required("groups"): selector.SelectSelector(
+        selector.SelectSelectorConfig(
+          options=[
+            selector.SelectOptionDict(value=g[CONF_GROUP_NAME], label=g[CONF_GROUP_NAME])
+            for g in self._groups
+          ],
+          multiple=True,
+        )
+      ),
+    })
+    return self.async_show_form(step_id="remove_group", data_schema=schema)
+
+  async def async_step_finish(self, user_input=None) -> ConfigFlowResult:
+    return self.async_create_entry(data={CONF_GROUPS: self._groups})

+ 2 - 0
custom_components/zlock_slots/const.py

@@ -9,6 +9,8 @@ STORAGE_KEY = "zlock_slots_names"
 STORAGE_VERSION = 1
 
 CONF_LOCK_ENTITY_IDS = "lock_entity_ids"
+CONF_GROUPS = "groups"
+CONF_GROUP_NAME = "name"
 
 
 class UserSource(Enum):

+ 23 - 0
custom_components/zlock_slots/coordinator.py

@@ -14,6 +14,8 @@ from .const import (
   STORAGE_KEY,
   STORAGE_VERSION,
   CONF_LOCK_ENTITY_IDS,
+  CONF_GROUPS,
+  CONF_GROUP_NAME,
   ALARM_MAP,
   UserSource,
 )
@@ -30,6 +32,12 @@ class SlotInfo:
   name: str | None = None
 
 
+@dataclass
+class GroupState:
+  last_action: str | None = None
+  last_user: str | None = None
+
+
 @dataclass
 class LockState:
   slots: dict[int, SlotInfo] = field(default_factory=dict)
@@ -62,6 +70,16 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
     self._unsub_event: callback | None = None
     self.data: dict[str, LockState] = {eid: LockState() for eid in self._lock_entity_ids}
 
+    groups: list[dict] = entry.options.get(CONF_GROUPS, [])
+    self.group_data: dict[str, GroupState] = {
+      g[CONF_GROUP_NAME]: GroupState() for g in groups
+    }
+    # lock_entity_id → list of group names it belongs to
+    self._lock_to_groups: dict[str, list[str]] = {}
+    for g in groups:
+      for lid in g.get(CONF_LOCK_ENTITY_IDS, []):
+        self._lock_to_groups.setdefault(lid, []).append(g[CONF_GROUP_NAME])
+
   async def async_setup(self) -> None:
     stored = await self._store.async_load()
     if stored:
@@ -144,6 +162,11 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
 
     self.data[lock_entity_id].last_action = mapping.action
     self.data[lock_entity_id].last_user = user_str
+
+    for group_name in self._lock_to_groups.get(lock_entity_id, []):
+      self.group_data[group_name].last_action = mapping.action
+      self.group_data[group_name].last_user = user_str
+
     self.async_update_listeners()
 
   def get_name(self, lock_entity_id: str, slot_num: int) -> str | None:

+ 57 - 1
custom_components/zlock_slots/sensor.py

@@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant, callback
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 
-from .const import DOMAIN, CONF_LOCK_ENTITY_IDS
+from .const import DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME
 from .coordinator import ZLockCoordinator
 
 
@@ -21,6 +21,10 @@ async def async_setup_entry(
     entities.append(ZLockLastActionSensor(coordinator, entry, lock_entity_id))
     entities.append(ZLockLastUserSensor(coordinator, entry, lock_entity_id))
     entities.append(ZLockSlotsSensor(coordinator, entry, lock_entity_id))
+  for group in entry.options.get(CONF_GROUPS, []):
+    group_name = group[CONF_GROUP_NAME]
+    entities.append(ZLockGroupLastActionSensor(coordinator, entry, group_name))
+    entities.append(ZLockGroupLastUserSensor(coordinator, entry, group_name))
   async_add_entities(entities)
 
 
@@ -115,3 +119,55 @@ class ZLockSlotsSensor(_ZLockBaseSensor):
         for num, info in sorted(state.slots.items())
       ]
     }
+
+
+class _ZLockGroupBaseSensor(CoordinatorEntity[ZLockCoordinator], SensorEntity):
+  _attr_has_entity_name = True
+
+  def __init__(
+    self,
+    coordinator: ZLockCoordinator,
+    entry: ConfigEntry,
+    group_name: str,
+  ) -> None:
+    super().__init__(coordinator)
+    self._group_name = group_name
+    self._entry = entry
+
+  @property
+  def _group_state(self):
+    return self.coordinator.group_data.get(self._group_name)
+
+
+class ZLockGroupLastActionSensor(_ZLockGroupBaseSensor):
+  _attr_translation_key = "last_action"
+
+  def __init__(self, coordinator, entry, group_name):
+    super().__init__(coordinator, entry, group_name)
+    self._attr_unique_id = f"{entry.entry_id}_group_{group_name}_last_action"
+
+  @property
+  def name(self) -> str:
+    return f"{self._group_name} last action"
+
+  @property
+  def native_value(self) -> str | None:
+    state = self._group_state
+    return state.last_action if state else None
+
+
+class ZLockGroupLastUserSensor(_ZLockGroupBaseSensor):
+  _attr_translation_key = "last_user"
+
+  def __init__(self, coordinator, entry, group_name):
+    super().__init__(coordinator, entry, group_name)
+    self._attr_unique_id = f"{entry.entry_id}_group_{group_name}_last_user"
+
+  @property
+  def name(self) -> str:
+    return f"{self._group_name} last user"
+
+  @property
+  def native_value(self) -> str | None:
+    state = self._group_state
+    return state.last_user if state else None

+ 31 - 0
custom_components/zlock_slots/strings.json

@@ -15,5 +15,36 @@
     "abort": {
       "already_configured": "This configuration already exists."
     }
+  },
+  "options": {
+    "step": {
+      "init": {
+        "title": "Manage Lock Groups",
+        "menu_options": {
+          "add_group": "Add group",
+          "remove_group": "Remove group",
+          "finish": "Save and close"
+        }
+      },
+      "add_group": {
+        "title": "Add Lock Group",
+        "description": "Create a group of locks that publishes a shared last-action and last-user sensor.",
+        "data": {
+          "name": "Group name",
+          "lock_entity_ids": "Locks in group"
+        }
+      },
+      "remove_group": {
+        "title": "Remove Lock Groups",
+        "data": {
+          "groups": "Groups to remove"
+        }
+      }
+    },
+    "error": {
+      "no_locks_selected": "Please select at least one lock.",
+      "group_name_exists": "A group with this name already exists.",
+      "group_name_required": "Group name is required."
+    }
   }
 }

+ 31 - 0
custom_components/zlock_slots/translations/en.json

@@ -15,5 +15,36 @@
     "abort": {
       "already_configured": "This configuration already exists."
     }
+  },
+  "options": {
+    "step": {
+      "init": {
+        "title": "Manage Lock Groups",
+        "menu_options": {
+          "add_group": "Add group",
+          "remove_group": "Remove group",
+          "finish": "Save and close"
+        }
+      },
+      "add_group": {
+        "title": "Add Lock Group",
+        "description": "Create a group of locks that publishes a shared last-action and last-user sensor.",
+        "data": {
+          "name": "Group name",
+          "lock_entity_ids": "Locks in group"
+        }
+      },
+      "remove_group": {
+        "title": "Remove Lock Groups",
+        "data": {
+          "groups": "Groups to remove"
+        }
+      }
+    },
+    "error": {
+      "no_locks_selected": "Please select at least one lock.",
+      "group_name_exists": "A group with this name already exists.",
+      "group_name_required": "Group name is required."
+    }
   }
 }