Skip to content

Commit

Permalink
Merge pull request #312 from bruxy70/development
Browse files Browse the repository at this point in the history
4.0 Version
  • Loading branch information
bruxy70 authored Jan 9, 2022
2 parents bd509b8 + 3d70c8f commit 340795f
Show file tree
Hide file tree
Showing 18 changed files with 470 additions and 68 deletions.
6 changes: 5 additions & 1 deletion Garbage Collection.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@
"path": "."
}
],
"settings": {}
"settings": {
"files.associations": {
"*.yaml": "home-assistant"
}
}
}
168 changes: 164 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ Entity_id change is not possible using the YAML configuration. Changing other pa
|:----------|----------|------------
| `name` | Yes | Sensor friendly name
| `frequency` | Yes | `"weekly"`, `"even-weeks"`, `"odd-weeks"`, `"every-n-weeks"`, `"every-n-days"`, `"monthly"`, `"annual"` or `"group"`
| `manual_update` | No | (Advanced). Do not automatically update the status. Status is updated manualy by calling the service `garbage_collection.update_state` from an automation triggered by event `garbage_collection_loaded`, that could manually add or remove collection dates, and manually trigger the state update at the end. [See the example](#manual-update-example).</br>**Default**: `False`
| `offset` | No | Offset calculated date by `offset` days (makes most sense for monthly frequency). Examples of use:</br>for last Saurday each month, configure first Saturday each month with `offset: -7`</br>for 1<sup>st</sup> Wednesday in of full week, configure first Monday each month with `offset: 2`</br>(integer between -31 and 31) **Default**: 0
| `hidden` | No | Hide in calendar (useful for sensors that are used in groups)<br/>**Default**: `False`
| `icon_normal` | No | Default icon **Default**: `mdi:trash-can`
Expand All @@ -132,8 +133,8 @@ Entity_id change is not possible using the YAML configuration. Changing other pa
|:----------|----------|------------
| `first_month` | No | Month three letter abbreviation, e.g. `"jan"`, `"feb"`...<br/>**Default**: `"jan"`
| `last_month` | No | Month three letter abbreviation.<br/>**Default**: `"dec"`
| `exclude_dates` | No | List of dates with no collection (using international date format `'yyyy-mm-dd'`.
| `include_dates` | No | List of extra collection (using international date format `'yyyy-mm-dd'`.
| `exclude_dates` | No | List of dates with no collection (using international date format `'yyyy-mm-dd'`. Make sure to enter the date in quotes!
| `include_dates` | No | List of extra collection (using international date format `'yyyy-mm-dd'`. Make sure to enter the date in quotes!
| `move_country_holidays` | No | Country holidays - the country code (see [holidays](https://github.com/dr-prodigy/python-holidays) for the list of valid country codes).<br/>Automatically move garbage collection on public holidays to the following day.<br/>*Example:* `US`
| `holiday_in_week_move` | No | Move garbage collection to the following day if a holiday is in week.<br/>**Default**: `false`
| `holiday_move_offset` | No | Move the collection by the number of days (integer -7..7) **Default**: 1
Expand Down Expand Up @@ -215,7 +216,166 @@ It will set the `last_collection` attribute to the current date and time.

| Attribute | Description
|:----------|------------
| `entity_id` | The gatbage collection entity id (e.g. `sensor.general_waste`)
| `entity_id` | The garbage collection entity id (e.g. `sensor.general_waste`)


### garbage_collection.add_date
...see the manual update example below

### garbage_collection.remove_date
...see the manual update example below

### garbage_collection.offset_date
...see the manual update example below

### garbage_collection.update_state
...see the manual update example below


## Manual update example
(!!! Advanced - if you think this is too complicated, then this is not for you !!!)

For the example below, the entity should be configured with `manual_update` set to `true`.
Then, when the `garbage_collection` entity is updated (normally once a day at midnight, or restart, or when triggering entity update by script), it will calculate the collection schedule for previous, current and next year. But it will **NOT UPDATE** the entity state.
Instead, it will trigger an event `garbage_collection_loaded` with list of automatically calculated dates as a parameter.
You will **have to create an automation triggered by this event**. In this automation you will need to call the service `garbage_collection.update_state` to update the state. Before that, you can call the servics `garbage_collection.add_date` and/or `garbage_collection.remove_date` to programatically tweak the dates in whatever way you need (e.g. based on values from external API sensor, comparing the dates with list of holidays, calculating custom offsets based on the day of the week etc.). This is complicated, but gives you an ultimate flexibility.

### garbage_collection.add_date
Add a date to the list of dates calculated automatically. To add multiple dates, call this service multiple times with different dates.
Note that this date will be removed on the next sensor update when data is re-calculated and loaded. This is why this service should be called from the automation triggered be the event `garbage_collection_loaded`, that is called each time the sensor is updated. And at the end of this automation you need to call the `garbage_collection.update_state` service to update the sensor state based on automatically collected dates with the dates added, removed or offset by the automation.

| Attribute | Description
|:----------|------------
| `entity_id` | The garbage collection entity id (e.g. `sensor.general_waste`)
| `date` | The date to be added, in ISO format (`'yyyy-mm-dd'`). Make sure to enter the date in quotes!

### garbage_collection.remove_date
Remove a date to the list of dates calculated automatically. To remove multiple dates, call this service multiple times with different dates.
Note that this date will reappear on the next sensor update when data is re-calculated and loaded. This is why this service should be called from the automation triggered be the event `garbage_collection_loaded`, that is called each time the sensor is updated. And at the end of this automation you need to call the `garbage_collection.update_state` service to update the sensor state based on automatically collected dates with the dates added, removed or offset by the automation.

| Attribute | Description
|:----------|------------
| `entity_id` | The garbage collection entity id (e.g. `sensor.general_waste`)
| `date` | The date to be removed, in ISO format (`'yyyy-mm-dd'`). Make sure to enter the date in quotes!

### garbage_collection.offset_date
Offset the calculated collection day by the `offset` number of days.
Note that this offset will revert back on the next sensor update when data is re-calculated and loaded. This is why this service should be called from the automation triggered be the event `garbage_collection_loaded`, that is called each time the sensor is updated. And at the end of this automation you need to call the `garbage_collection.update_state` service to update the sensor state based on automatically collected dates with the dates added, removed or offset by the automation.

| Attribute | Description
|:----------|------------
| `entity_id` | The garbage collection entity id (e.g. `sensor.general_waste`)
| `date` | The date to be removed, in ISO format (`'yyyy-mm-dd'`). Make sure to enter the date in quotes!
| `offset` | By how many days to offset - integer between `-31` to `31` (e.g. `1`)

### garbage_collection.update_state
Choose the next collection date from the list of dates calculated automatically, added by service calls (and not removed), and update the entity state and attributes.

| Attribute | Description
|:----------|------------
| `entity_id` | The garbage collection entity id (e.g. `sensor.general_waste`)

## Events
### garbage_collection_loaded
This event is triggered each time a `garbage_collection` entity is being updated. You can create an automation to modify the collection schedule before the entity state update.

Event data:
| Attribute | Description
|:----------|------------
| `entity_id` | The garbage collection entity id (e.g. `sensor.general_waste`)
| `collection_dates` | List of collection dates calculated automatically.

## Simple example
Adding an extra collection date (a fixed date in this case) - for the entity `sensor.test`.


```yaml
alias: garbage_collection event
description: 'Manually add a collection date, then trigger entity state update.'
trigger:
- platform: event
event_type: garbage_collection_loaded
event_data:
entity_id: sensor.test
action:
- service: garbage_collection.add_date
data:
entity_id: "{{ trigger.event.data.entity_id }}"
date: '2022-01-07'
- service: garbage_collection.update_state
data:
entity_id: sensor.test
mode: single
```

## Moderate example
This will loop through the calculated dates, and add extra collection to a day after each calculated one. So if this is set for a collection each first Wednesday each month, it will result in a collection on first Wednesday, and the following day (kind of first Thursday, except if the week is starting on Thursday - just a random weird example :).

This example is for an entity `sensor.test`. If you want to use it for yours, replace it with the real entity name in the trigger.

```yaml
alias: test garbage_collection event
description: 'Loop through all calculated dates, add extra collection a day after the calculate one'
trigger:
- platform: event
event_type: garbage_collection_loaded
event_data:
entity_id: sensor.test
action:
- repeat:
count: '{{ trigger.event.data.collection_dates | count }}'
sequence:
- service: garbage_collection.add_date
data:
entity_id: "{{ trigger.event.data.entity_id }}"
date: >-
{{( as_datetime(trigger.event.data.collection_dates[repeat.index]) + timedelta( days = 1)) | as_timestamp | timestamp_custom("%Y-%m-%d") }}
- service: garbage_collection.update_state
data:
entity_id: "{{ trigger.event.data.entity_id }}"
mode: single
```

## Advanced example
This is an equivalent of "holiday in week" move - checking if there is a public holiday on the calculated collection day, or in the same day before, and if yes, moving the collection by one day. This is fully custom logic, so it could be further complicated by whatever rules anyone wants.
Note that this does not disable the current holiday exception handling in the integration - so if you have also configured that to move the collection, it will move it one more day. So if you want to use, configure the `offset` to `0` (so that the integration movves the holiday by "zero" days). Or you can configure the calendar on another entity - the automation is really using them to check the list of the dates - the list could be anywhere, does not even have to be a garbage_collection entity.

This example is for an entity `sensor.test`. If you want to use it for yours, replace it with the real entity name in the trigger.

```yaml
alias: test garbage_collection event
description: >-
Loop through all calculated dates, move the collection by 1 day if public holiday was in the week before or on the calculated collection date calculate one
trigger:
- platform: event
event_type: garbage_collection_loaded
event_data:
entity_id: sensor.test
action:
- repeat:
count: '{{ trigger.event.data.collection_dates | count }}'
sequence:
- condition: template
value_template: >-
{%- set collection_date = as_datetime(trigger.event.data.collection_dates[repeat.index]) %}
{%- set ns = namespace(found=false) %}
{%- for i in range(collection_date.weekday()+1) %}
{%- set d = ( collection_date + timedelta( days=-i) ) | as_timestamp | timestamp_custom("%Y-%m-%d") %}
{%- if d in state_attr(trigger.event.data.entity_id,'holidays') %}
{%- set ns.found = true %}
{%- endif %}
{%- endfor %}
{{ ns.found }}
- service: garbage_collection.offset_date
data:
entity_id: "{{ trigger.event.data.entity_id }}"
date: '{{ trigger.event.data.collection_dates[repeat.index] }}'
offset: 1
- service: garbage_collection.update_state
data:
entity_id: "{{ trigger.event.data.entity_id }}"
mode: single
```

# Lovelace config examples

Expand All @@ -238,7 +398,7 @@ This is the configuration
card:
type: picture-entity
name_template: >-
{{ states.sensor.bio.attributes.days }} days
{{ state_attr('sensor.bio','days') }} days
show_name: True
show_state: False
entity: sensor.bio
Expand Down
125 changes: 109 additions & 16 deletions custom_components/garbage_collection/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util
import voluptuous as vol
from dateutil.relativedelta import relativedelta
from homeassistant import config_entries
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME
from homeassistant.helpers import discovery

from .const import (
ATTR_LAST_COLLECTION,
CONF_DATE,
CONF_FREQUENCY,
CONF_OFFSET,
CONF_SENSORS,
DOMAIN,
SENSOR_PLATFORM,
Expand All @@ -38,36 +41,126 @@

COLLECT_NOW_SCHEMA = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): cv.string,
vol.Required(CONF_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_LAST_COLLECTION): cv.datetime,
}
)

UPDATE_STATE_SCHEMA = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
}
)

ADD_REMOVE_DATE_SCHEMA = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Required(CONF_DATE): cv.date,
}
)

OFFSET_DATE_SCHEMA = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Required(CONF_DATE): cv.date,
vol.Required(CONF_OFFSET): vol.All(vol.Coerce(int), vol.Range(min=-31, max=31)),
}
)


async def async_setup(hass, config):
"""Set up this component using YAML."""

def handle_collect_garbage(call):
"""Handle the service call."""
entity_id = call.data.get(CONF_ENTITY_ID)
last_collection = call.data.get(ATTR_LAST_COLLECTION)
_LOGGER.debug("called collect_garbage for %s", entity_id)
try:
entity = hass.data[DOMAIN][SENSOR_PLATFORM][entity_id]
if last_collection is None:
entity.last_collection = dt_util.now()
else:
entity.last_collection = dt_util.as_local(last_collection)
except Exception as err:
_LOGGER.error("Failed setting last collection for %s - %s", entity_id, err)
hass.services.call("homeassistant", "update_entity", {"entity_id": entity_id})
async def handle_add_date(call):
"""Handle the add_date service call."""
for entity_id in call.data.get(CONF_ENTITY_ID):
collection_date = call.data.get(CONF_DATE)
_LOGGER.debug("called add_date %s from %s", collection_date, entity_id)
try:
entity = hass.data[DOMAIN][SENSOR_PLATFORM][entity_id]
await entity.add_date(collection_date)
except Exception as err:
_LOGGER.error("Failed adding date for %s - %s", entity_id, err)

async def handle_remove_date(call):
"""Handle the remove_date service call."""
for entity_id in call.data.get(CONF_ENTITY_ID):
collection_date = call.data.get(CONF_DATE)
_LOGGER.debug("called remove_date %s from %s", collection_date, entity_id)
try:
entity = hass.data[DOMAIN][SENSOR_PLATFORM][entity_id]
await entity.remove_date(collection_date)
except Exception as err:
_LOGGER.error("Failed removing date for %s - %s", entity_id, err)

async def handle_offset_date(call):
"""Handle the offset_date service call."""
for entity_id in call.data.get(CONF_ENTITY_ID):
offset = call.data.get(CONF_OFFSET)
collection_date = call.data.get(CONF_DATE)
_LOGGER.debug(
"called offset_date %s by %d days for %s",
collection_date,
offset,
entity_id,
)
try:
new_date = collection_date + relativedelta(days=offset)
except Exception as err:
_LOGGER.error("Failed to offset the date - %s", err)
break
try:
entity = hass.data[DOMAIN][SENSOR_PLATFORM][entity_id]
await entity.remove_date(collection_date)
await entity.add_date(new_date)
except Exception as err:
_LOGGER.error("Failed ofsetting date for %s - %s", entity_id, err)

async def handle_update_state(call):
"""Handle the update_state service call."""
for entity_id in call.data.get(CONF_ENTITY_ID):
_LOGGER.debug("called update_state for %s", entity_id)
try:
entity = hass.data[DOMAIN][SENSOR_PLATFORM][entity_id]
await entity.async_update_state()
except Exception as err:
_LOGGER.error("Failed updating state for %s - %s", entity_id, err)

async def handle_collect_garbage(call):
"""Handle the collect_garbage service call."""
for entity_id in call.data.get(CONF_ENTITY_ID):
last_collection = call.data.get(ATTR_LAST_COLLECTION)
_LOGGER.debug("called collect_garbage for %s", entity_id)
try:
entity = hass.data[DOMAIN][SENSOR_PLATFORM][entity_id]
if last_collection is None:
entity.last_collection = dt_util.now()
else:
entity.last_collection = dt_util.as_local(last_collection)
await entity.async_update_state()
except Exception as err:
_LOGGER.error(
"Failed setting last collection for %s - %s", entity_id, err
)

if DOMAIN not in hass.services.async_services():
hass.services.async_register(
DOMAIN, "collect_garbage", handle_collect_garbage, schema=COLLECT_NOW_SCHEMA
)
hass.services.async_register(
DOMAIN, "update_state", handle_update_state, schema=UPDATE_STATE_SCHEMA
)
hass.services.async_register(
DOMAIN, "add_date", handle_add_date, schema=ADD_REMOVE_DATE_SCHEMA
)
hass.services.async_register(
DOMAIN, "remove_date", handle_remove_date, schema=ADD_REMOVE_DATE_SCHEMA
)
hass.services.async_register(
DOMAIN, "offset_date", handle_offset_date, schema=OFFSET_DATE_SCHEMA
)
else:
_LOGGER.debug("Service already registered")
_LOGGER.debug("Services already registered")

if config.get(DOMAIN) is None:
# We get here if the integration is set up using config flow
Expand Down
5 changes: 2 additions & 3 deletions custom_components/garbage_collection/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@ async def async_get_events(self, hass, start_datetime, end_datetime):
):
continue
garbage_collection = hass.data[DOMAIN][SENSOR_PLATFORM][entity]
await garbage_collection.async_load_holidays(start_date)
start = await garbage_collection.async_find_next_date(start_date, True)
start = await garbage_collection.async_next_date(start_date, True)
while start is not None and start >= start_date and start <= end_date:
try:
end = start + timedelta(days=1)
Expand Down Expand Up @@ -126,7 +125,7 @@ async def async_get_events(self, hass, start_datetime, end_datetime):
"allDay": False,
}
events.append(event)
start = await garbage_collection.async_find_next_date(
start = await garbage_collection.async_next_date(
start + timedelta(days=1), True
)
return events
Expand Down
Loading

0 comments on commit 340795f

Please sign in to comment.