Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[![Quality Scale: Platinum](https://img.shields.io/badge/Quality%20Scale-platinum-platinum.svg)](https://github.com/ccpk1/choreops)
[![Quality Gates](https://img.shields.io/github/actions/workflow/status/ccpk1/choreops/lint-validation.yaml?branch=main&label=Quality%20Gates)](https://github.com/ccpk1/choreops/actions/workflows/lint-validation.yaml)
[![Crowdin](https://badges.crowdin.net/choreops-translations/localized.svg)](https://crowdin.com/project/choreops-translations)
[![License](https://img.shields.io/static/v1?label=License&message=GPL-3.0&color=1E88E5&labelColor=555)](https://github.com/ccpk1/choreops/blob/main/LICENSE)
[![License](https://img.shields.io/static/v1?label=License&message=GPL-3.0&color=1E88E5&labelColor=555)](https://github.com/ccpk1/choreops/blob/main/LICENSE)
[![HACS Custom](https://img.shields.io/static/v1?label=HACS&message=custom&color=1E88E5&labelColor=555)](https://github.com/custom-components/hacs) <br>
[![Version](https://img.shields.io/github/v/release/ccpk1/choreops?include_prereleases&label=Version&color=1E88E5)](https://github.com/ccpk1/choreops/releases)
[![Stars](https://img.shields.io/github/stars/ccpk1/choreops)](https://github.com/ccpk1/choreops/stargazers)
Expand Down Expand Up @@ -98,9 +98,13 @@ ChoreOps ships with a functional dashboard starter experience, but it is designe

- **Rich sensor data**: granular attributes for dashboards and analytics
- **Service-level control**: automate create/claim/approve/redeem/adjust actions
- **Live chore CRUD updates**: chore create, edit, and delete update runtime sensors, workflow buttons, and dashboard helper payloads without a full integration reload
- **Automation-first architecture**: integrate with scripts, automations, dashboards, voice, and Node-RED
- **Multi-instance support**: run multiple ChoreOps entries in the same Home Assistant instance

> [!TIP]
> Chore create, edit, and delete now update runtime chore entities live. Sanctioned system settings changes still reload the integration when that remains the correct Home Assistant boundary.

---

### 📸 See ChoreOps in Action
Expand Down
1 change: 1 addition & 0 deletions custom_components/choreops/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ChoreOpsConfigEntry) ->
coordinator = entry.runtime_data
if coordinator:
coordinator._persist(immediate=True) # Unload must be immediate
coordinator.ui_manager.clear_runtime_state()
const.LOGGER.debug("Forced immediate persist before unload")

# Clear translation cache to prevent stale translations on reload
Expand Down
149 changes: 149 additions & 0 deletions custom_components/choreops/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_registry import async_get

from . import const
from .coordinator import ChoreOpsConfigEntry, ChoreOpsDataCoordinator
Expand Down Expand Up @@ -48,13 +49,161 @@
PARALLEL_UPDATES = 1


_async_add_entities_callback: AddEntitiesCallback | None = None


def register_chore_button_callback(async_add_entities: AddEntitiesCallback) -> None:
"""Register async_add_entities callback for dynamic chore button creation."""
global _async_add_entities_callback # noqa: PLW0603
_async_add_entities_callback = async_add_entities


def _button_entity_exists(
coordinator: ChoreOpsDataCoordinator,
unique_id: str,
) -> bool:
"""Return whether a button entity with the given unique ID already exists."""
entity_registry = async_get(coordinator.hass)
return (
entity_registry.async_get_entity_id("button", const.DOMAIN, unique_id)
is not None
)


def create_chore_button_entities(
coordinator: ChoreOpsDataCoordinator,
chore_id: str,
*,
assignee_ids: list[str] | None = None,
replace_existing: bool = False,
) -> int:
"""Create missing chore-linked workflow buttons for a chore.

Args:
coordinator: Runtime coordinator.
chore_id: Internal ID of the chore.
assignee_ids: Optional subset of assignee IDs to create buttons for.
When omitted, create buttons for all currently assigned assignees.
replace_existing: If True, do not filter existing registry entities. This is
intended for flows that have already removed the prior entity set.

Returns:
Count of entities handed to Home Assistant for creation.
"""
if _async_add_entities_callback is None:
const.LOGGER.warning("Cannot create chore buttons: callback not registered")
return 0

chore_info = coordinator.chores_data.get(chore_id)
if not chore_info:
const.LOGGER.warning(
"Cannot create chore buttons: chore %s not found", chore_id
)
return 0

chore_name = str(
chore_info.get(
const.DATA_CHORE_NAME, f"{const.TRANS_KEY_LABEL_CHORE} {chore_id}"
)
)
assigned_assignees_ids = chore_info.get(const.DATA_CHORE_ASSIGNED_USER_IDS, [])
target_assignee_ids = assigned_assignees_ids
if assignee_ids is not None:
target_assignee_ids = [
assignee_id
for assignee_id in assigned_assignees_ids
if assignee_id in assignee_ids
]

chore_claim_icon = chore_info.get(const.DATA_CHORE_ICON, const.SENTINEL_EMPTY)
chore_approve_icon = chore_info.get(const.DATA_CHORE_ICON, const.SENTINEL_EMPTY)
entry = coordinator.config_entry
entities: list[ButtonEntity] = []

for assignee_id in target_assignee_ids:
assignee_name = (
get_assignee_name_by_id(coordinator, assignee_id)
or f"{const.TRANS_KEY_LABEL_ASSIGNEE} {assignee_id}"
)

claim_unique_id = f"{entry.entry_id}_{assignee_id}_{chore_id}{const.BUTTON_KC_UID_SUFFIX_CLAIM}"
if should_create_entity_for_user_assignee(
const.BUTTON_KC_UID_SUFFIX_CLAIM,
coordinator,
assignee_id,
) and (
replace_existing or not _button_entity_exists(coordinator, claim_unique_id)
):
entities.append(
AssigneeChoreClaimButton(
coordinator=coordinator,
entry=entry,
assignee_id=assignee_id,
assignee_name=assignee_name,
chore_id=chore_id,
chore_name=chore_name,
icon=chore_claim_icon,
)
)

approve_unique_id = f"{entry.entry_id}_{assignee_id}_{chore_id}{const.BUTTON_KC_UID_SUFFIX_APPROVE}"
if should_create_entity_for_user_assignee(
const.BUTTON_KC_UID_SUFFIX_APPROVE,
coordinator,
assignee_id,
) and (
replace_existing
or not _button_entity_exists(coordinator, approve_unique_id)
):
entities.append(
ApproverChoreApproveButton(
coordinator=coordinator,
entry=entry,
assignee_id=assignee_id,
assignee_name=assignee_name,
chore_id=chore_id,
chore_name=chore_name,
icon=chore_approve_icon,
)
)

disapprove_unique_id = f"{entry.entry_id}_{assignee_id}_{chore_id}{const.BUTTON_KC_UID_SUFFIX_DISAPPROVE}"
if should_create_entity_for_user_assignee(
const.BUTTON_KC_UID_SUFFIX_DISAPPROVE,
coordinator,
assignee_id,
) and (
replace_existing
or not _button_entity_exists(coordinator, disapprove_unique_id)
):
entities.append(
ApproverChoreDisapproveButton(
coordinator=coordinator,
entry=entry,
assignee_id=assignee_id,
assignee_name=assignee_name,
chore_id=chore_id,
chore_name=chore_name,
)
)

if entities:
_async_add_entities_callback(entities)
const.LOGGER.debug(
"Created %d chore-linked buttons for chore: %s", len(entities), chore_name
)

return len(entities)


async def async_setup_entry(
hass: HomeAssistant,
entry: ChoreOpsConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up dynamic buttons."""
coordinator = entry.runtime_data
register_chore_button_callback(async_add_entities)

points_label = entry.options.get(
const.CONF_POINTS_LABEL, const.DEFAULT_POINTS_LABEL
Expand Down
22 changes: 22 additions & 0 deletions custom_components/choreops/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -2274,6 +2274,9 @@ def set_default_timezone(hass):
TRANS_KEY_PURPOSE_ACHIEVEMENT_PROGRESS: Final = "purpose_achievement_progress"
TRANS_KEY_PURPOSE_CHALLENGE_PROGRESS: Final = "purpose_challenge_progress"
TRANS_KEY_PURPOSE_DASHBOARD_HELPER: Final = "purpose_dashboard_helper"
TRANS_KEY_PURPOSE_DASHBOARD_CHORE_SHARD_HELPER: Final = (
"purpose_dashboard_chore_shard_helper"
)
TRANS_KEY_PURPOSE_DASHBOARD_TRANSLATION: Final = "purpose_dashboard_translation"
TRANS_KEY_PURPOSE_SYSTEM_DASHBOARD_HELPER: Final = "purpose_system_dashboard_helper"
# Legacy sensor purposes (sensor_legacy.py)
Expand Down Expand Up @@ -2517,12 +2520,17 @@ def set_default_timezone(hass):

# Dashboard Helper Sensor Attributes
ATTR_CHORES_BY_LABEL: Final = "chores_by_label"
ATTR_CHORE_HELPER_EIDS: Final = "chore_helper_eids"
ATTR_CHORE_CLAIMED_BY: Final = "claimed_by"
ATTR_CHORE_COMPLETED_BY: Final = "completed_by"
ATTR_CHORE_DUE_DATE: Final = "due_date"
ATTR_HELPER_CONTRACT_VERSION: Final = "helper_contract_version"
ATTR_CHORE_IS_TODAY_AM: Final = "is_today_am"
ATTR_CHORE_LABELS: Final = "labels"
ATTR_CHORE_PRIMARY_GROUP: Final = "primary_group"
ATTR_SHARD_COUNT: Final = "shard_count"
ATTR_SHARD_INDEX: Final = "shard_index"
ATTR_SHARD_RUNTIME: Final = "shard_runtime"
ATTR_UI_CONTROL: Final = "ui_control"
ATTR_UI_ROOT: Final = "ui_root"
ATTR_UI_ROOT_SHARED_ADMIN: Final = "shared_admin"
Expand Down Expand Up @@ -2552,6 +2560,13 @@ def set_default_timezone(hass):
PRIMARY_GROUP_THIS_WEEK = "this_week"
PRIMARY_GROUP_OTHER = "other"

HELPER_CONTRACT_VERSION_V1: Final = 1
HELPER_SHARD_FAMILY_CHORES: Final = "chores"
HELPER_SHARD_MODE_INLINE: Final = "inline"
HELPER_SHARD_MODE_SHARDED: Final = "sharded"
HELPER_SHARD_ENTER_BYTES: Final = 14 * 1024
HELPER_SHARD_EXIT_BYTES: Final = 12 * 1024


# ================================================================================================
# Entity UID Constants (SUFFIX patterns)
Expand Down Expand Up @@ -2595,6 +2610,9 @@ def set_default_timezone(hass):

# Sensor Entity ID constants still in active use (runtime)
SENSOR_KC_UID_SUFFIX_UI_DASHBOARD_HELPER: Final = "_dashboard_helper"
SENSOR_KC_UID_SUFFIX_UI_DASHBOARD_CHORE_SHARD_HELPER: Final = (
"_dashboard_chore_shard_helper"
)
SENSOR_KC_UID_SUFFIX_SYSTEM_DASHBOARD_HELPER: Final = "_system_dashboard_helper"

# System-level dashboard translation sensor (one per language in use)
Expand Down Expand Up @@ -3010,6 +3028,7 @@ class EntityRequirement(StrEnum):
SENSOR_KC_UID_SUFFIX_CHORE_STATUS_SENSOR: EntityRequirement.ALWAYS,
SENSOR_KC_UID_SUFFIX_CHORES_SENSOR: EntityRequirement.ALWAYS,
SENSOR_KC_UID_SUFFIX_UI_DASHBOARD_HELPER: EntityRequirement.ALWAYS,
SENSOR_KC_UID_SUFFIX_UI_DASHBOARD_CHORE_SHARD_HELPER: EntityRequirement.ALWAYS,
SENSOR_KC_UID_SUFFIX_SYSTEM_DASHBOARD_HELPER: EntityRequirement.ALWAYS,
# === SENSORS: Gamification ===
SENSOR_KC_UID_SUFFIX_ASSIGNEE_POINTS_SENSOR: EntityRequirement.GAMIFICATION,
Expand Down Expand Up @@ -3635,6 +3654,9 @@ class EntityRequirement(StrEnum):
TRANS_KEY_SENSOR_DASHBOARD_TRANSLATION: Final = "system_dashboard_translation_sensor"
TRANS_KEY_SENSOR_SYSTEM_DASHBOARD_HELPER: Final = "system_dashboard_helper_sensor"
TRANS_KEY_SENSOR_DASHBOARD_HELPER: Final = "assignee_dashboard_helper_sensor"
TRANS_KEY_SENSOR_DASHBOARD_CHORE_LIST_HELPER: Final = (
"assignee_dashboard_chore_list_helper_sensor"
)


# Sensor Attributes Translation Keys
Expand Down
Loading
Loading