Skip to content
Open
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
142 changes: 142 additions & 0 deletions custom_components/home_maintenance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from homeassistant.components.binary_sensor import DOMAIN as PLATFORM
from homeassistant.components.tag.const import EVENT_TAG_SCANNED
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_STATE_CHANGED
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
Expand Down Expand Up @@ -90,6 +91,12 @@ def handle_tag_scanned_event(event: Event) -> None:
unsub = hass.bus.async_listen(EVENT_TAG_SCANNED, handle_tag_scanned_event)
hass.data[const.DOMAIN]["unsub_tag_scanned"] = unsub

# Set up state change listeners for count-based tasks
setup_count_listeners(hass, task_store)

# Set up state change listeners for runtime-based tasks
setup_runtime_listeners(hass, task_store)

return True


Expand All @@ -102,6 +109,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if "unsub_tag_scanned" in hass.data[const.DOMAIN]:
hass.data[const.DOMAIN]["unsub_tag_scanned"]()

# Unsubscribe count listeners
for unsub in hass.data[const.DOMAIN].get("unsub_count_listeners", []):
unsub()

# Unsubscribe runtime listeners
for unsub in hass.data[const.DOMAIN].get("unsub_runtime_listeners", []):
unsub()

async_unregister_panel(hass)
hass.data.pop(const.DOMAIN, None)
return True
Expand Down Expand Up @@ -157,3 +172,130 @@ async def async_srv_reset(call: ServiceCall) -> None:
async_srv_reset,
schema=const.SERVICE_RESET_SCHEMA,
)

async def async_srv_increment_count(call: ServiceCall) -> None:
entity_id = call.data["entity_id"]
entity_registry = er.async_get(hass)
entry = cast("RegistryEntry", entity_registry.async_get(entity_id))
task_id = entry.unique_id
store = hass.data[const.DOMAIN].get("store")
store.increment_count(task_id)

hass.services.async_register(
const.DOMAIN,
const.SERVICE_INCREMENT_COUNT,
async_srv_increment_count,
schema=const.SERVICE_INCREMENT_COUNT_SCHEMA,
)

async def async_srv_reset_count(call: ServiceCall) -> None:
entity_id = call.data["entity_id"]
entity_registry = er.async_get(hass)
entry = cast("RegistryEntry", entity_registry.async_get(entity_id))
task_id = entry.unique_id
store = hass.data[const.DOMAIN].get("store")
store.reset_count(task_id)

hass.services.async_register(
const.DOMAIN,
const.SERVICE_RESET_COUNT,
async_srv_reset_count,
schema=const.SERVICE_RESET_COUNT_SCHEMA,
)


@callback
def setup_count_listeners(hass: HomeAssistant, task_store: TaskStore) -> None:
"""Set up state change listeners for count-based tasks.

Uses a single listener that dynamically reads the current task list,
so it handles tasks added/updated after setup.
"""
# Remove old listeners if any
for unsub in hass.data[const.DOMAIN].get("unsub_count_listeners", []):
unsub()

@callback
def handle_state_change(event: Event) -> None:
"""Handle state change for counted entities."""
entity_id = event.data.get("entity_id")
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")

if old_state is None or new_state is None:
return

# Only count transitions to "on" state
if new_state.state != "on" or old_state.state == "on":
return

store = hass.data[const.DOMAIN].get("store")
if not store:
return

for task_data in store.get_all():
if (
task_data.get("trigger_type") == "count"
and task_data.get("count_entity_id") == entity_id
):
_LOGGER.debug(
"Count increment for task %s (entity %s turned on)",
task_data["id"],
entity_id,
)
store.increment_count(task_data["id"])

unsub = hass.bus.async_listen(EVENT_STATE_CHANGED, handle_state_change)
hass.data[const.DOMAIN]["unsub_count_listeners"] = [unsub]


@callback
def setup_runtime_listeners(hass: HomeAssistant, task_store: TaskStore) -> None:
"""Set up state change listeners for runtime-based tasks."""
for unsub in hass.data[const.DOMAIN].get("unsub_runtime_listeners", []):
unsub()

@callback
def handle_runtime_state_change(event: Event) -> None:
"""Handle state change for runtime entities."""
entity_id = event.data.get("entity_id")
new_state = event.data.get("new_state")

if new_state is None:
return

store = hass.data[const.DOMAIN].get("store")
if not store:
return

for task_data in store.get_all():
if (
task_data.get("trigger_type") == "runtime"
and task_data.get("runtime_entity_id") == entity_id
):
try:
current_value = float(new_state.state)
except (ValueError, TypeError):
continue

runtime_baseline = task_data.get("runtime_baseline", 0)

# Detect external reset
if current_value < runtime_baseline:
_LOGGER.debug(
"Runtime reset detected for task %s (value %s < baseline %s)",
task_data["id"],
current_value,
runtime_baseline,
)
store.update_runtime_baseline(task_data["id"], 0)
else:
# Just refresh the entity state
entity = hass.data[const.DOMAIN]["entities"].get(task_data["id"])
if entity:
hass.async_create_task(
entity.async_update_ha_state(force_refresh=True)
)

unsub = hass.bus.async_listen(EVENT_STATE_CHANGED, handle_runtime_state_change)
hass.data[const.DOMAIN]["unsub_runtime_listeners"] = [unsub]
72 changes: 70 additions & 2 deletions custom_components/home_maintenance/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,83 @@ def _calculate_next_due(

def _update_state(self) -> None:
"""Get the latest state of the sensor."""
trigger_type = self.task.get("trigger_type", "time")

if trigger_type == "count":
self._update_state_count()
return

if trigger_type == "runtime":
self._update_state_runtime()
return

self._update_state_time()

def _update_state_count(self) -> None:
"""Update state for count-based tasks."""
current_count = self.task.get("current_count", 0)
count_threshold = self.task.get("count_threshold", 0)
count_entity_id = self.task.get("count_entity_id")

self._attr_is_on = current_count >= count_threshold if count_threshold > 0 else False
self._attr_extra_state_attributes = {
"trigger_type": "count",
"current_count": current_count,
"count_threshold": count_threshold,
"count_entity_id": count_entity_id,
"last_performed": self.task.get("last_performed", ""),
}
if self.task.get("tag_id"):
self._attr_extra_state_attributes["tag_id"] = self.task["tag_id"]

def _update_state_runtime(self) -> None:
"""Update state for runtime-based tasks."""
runtime_entity_id = self.task.get("runtime_entity_id")
runtime_threshold = self.task.get("runtime_threshold", 0)
runtime_baseline = self.task.get("runtime_baseline", 0)

current_value = None
delta = 0

if runtime_entity_id:
state = self.hass.states.get(runtime_entity_id)
if state and state.state not in ("unknown", "unavailable"):
try:
current_value = float(state.state)
# Detect external reset (sensor went back below baseline)
if current_value < runtime_baseline:
runtime_baseline = 0
self.task["runtime_baseline"] = 0
delta = current_value - runtime_baseline
except (ValueError, TypeError):
current_value = None

self._attr_is_on = delta >= runtime_threshold if runtime_threshold > 0 and current_value is not None else False
self._attr_extra_state_attributes = {
"trigger_type": "runtime",
"runtime_entity_id": runtime_entity_id,
"runtime_threshold": runtime_threshold,
"runtime_baseline": runtime_baseline,
"runtime_current": current_value,
"runtime_delta": round(delta, 2),
"last_performed": self.task.get("last_performed", ""),
}
if self.task.get("tag_id"):
self._attr_extra_state_attributes["tag_id"] = self.task["tag_id"]

def _update_state_time(self) -> None:
"""Update state for time-based tasks."""
last = dt_util.parse_datetime(self.task["last_performed"])
if last is None:
self._attr_is_on = True
self._attr_extra_state_attributes = {
"trigger_type": "time",
"last_performed": self.task["last_performed"],
"interval_value": self.task["interval_value"],
"interval_type": self.task["interval_type"],
"next_due": "unknown",
}
if self.task["tag_id"]:
if self.task.get("tag_id"):
self._attr_extra_state_attributes["tag_id"] = self.task["tag_id"]
return

Expand All @@ -115,12 +182,13 @@ def _update_state(self) -> None:
dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) >= due_date
)
self._attr_extra_state_attributes = {
"trigger_type": "time",
"last_performed": self.task["last_performed"],
"interval_value": self.task["interval_value"],
"interval_type": self.task["interval_type"],
"next_due": due_date.isoformat(),
}
if self.task["tag_id"]:
if self.task.get("tag_id"):
self._attr_extra_state_attributes["tag_id"] = self.task["tag_id"]

async def async_update(self) -> None:
Expand Down
14 changes: 14 additions & 0 deletions custom_components/home_maintenance/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@
}
)

SERVICE_INCREMENT_COUNT = "increment_count"
SERVICE_INCREMENT_COUNT_SCHEMA = vol.Schema(
{
vol.Required("entity_id"): cv.entity_id,
}
)

SERVICE_RESET_COUNT = "reset_count"
SERVICE_RESET_COUNT_SCHEMA = vol.Schema(
{
vol.Required("entity_id"): cv.entity_id,
}
)

CONFIG_STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Optional("admin_only", default=True): cv.boolean,
Expand Down
34 changes: 20 additions & 14 deletions custom_components/home_maintenance/panel/dist/main.js

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions custom_components/home_maintenance/panel/src/data/websockets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ export const updateTask = (hass: HomeAssistant, payload: Record<string, any>): P
...payload,
})

export const incrementCount = (hass: HomeAssistant, id: string): Promise<void> =>
hass.callWS({
type: 'home_maintenance/increment_count',
task_id: id,
})

export const resetCount = (hass: HomeAssistant, id: string): Promise<void> =>
hass.callWS({
type: 'home_maintenance/reset_count',
task_id: id,
})

export const getConfig = (hass: HomeAssistant): Promise<IntegrationConfig> =>
hass.callWS({
type: 'home_maintenance/get_config',
Expand Down
Loading