Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dev #52

Merged
merged 11 commits into from
Mar 24, 2024
Merged

Dev #52

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
1 change: 1 addition & 0 deletions Manifest.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include habapp_rules/energy/monthly_report_template.html
12 changes: 10 additions & 2 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# Version 5.6.0 - 24.03.2024

# Features

- added ``habapp_rules.energy.montly_report.MonthlyReport`` for generating a monthly energy report mail
- bumped holidays to 0.45
- bumped multi-notifier to 0.4.0

# Version 5.5.1 - 07.03.2024

# Bugfix
Expand All @@ -19,9 +27,9 @@

# Bugfix

- fixed bug in ``habapp_rules.actors.state_observer.StateObserverNumber`` which triggered the manual-detected-callback if the received number deviates only a little bit because of data types. (e.g.: 1.000001 != 1.0)
- fixed bug in ``habapp_rules.actors.state_observer.StateObserverNumber`` which triggered the manual-detected-callback if the received number deviates only a little bit because of data types. (e.g.: 1.000001 != 1.0)
- fixed bug for dimmer lights in ``habapp_rules.actors.light`` which did not set the correct brightness if light was switched on.
- fixed bug in ``habapp_rules.common.hysteresis.HysteresisSwitch.get_output`` resulted in a wrong switch state if the value was 0.
- fixed bug in ``habapp_rules.common.hysteresis.HysteresisSwitch.get_output`` resulted in a wrong switch state if the value was 0.
- added missing state transition to ``habapp_rules.sensors.motion.Motion``. When state was ``PostSleepLocked`` and sleep started there was no change to ``SleepLocked``
- fixed strange behavior of ``habapp_rules.system.presence.Presence`` which did not abort leaving when the first phone appeared. This let to absence state if someone returned when leaving was active.

Expand Down
2 changes: 2 additions & 0 deletions custom_pylint_dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ autoupdate
bool
config
dataclass
donut
dwd
DWD
enums
ga
HCL
html
init
KNX
kwargs
Expand Down
2 changes: 1 addition & 1 deletion habapp_rules/__version__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""Set version for the package."""
__version__ = "5.5.1"
__version__ = "5.6.0"
Empty file added habapp_rules/energy/__init__.py
Empty file.
39 changes: 39 additions & 0 deletions habapp_rules/energy/donut_chart.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Module to create donut charts."""
import collections.abc
import pathlib

import matplotlib.pyplot


def _auto_percent_format(values: list[float]) -> collections.abc.Callable:
"""Get labels for representing the absolute value.

:param values: list of all values
:return: function which returns the formatted string if called
"""

def my_format(pct: float) -> str:
"""get formatted value.

:param pct: percent value
:return: formatted value
"""
total = sum(values)
return f"{(pct * total / 100.0):.1f} kWh"

return my_format


def create_chart(labels: list[str], values: list[float], chart_path: pathlib.Path) -> None:
"""Create the donut chart.

:param labels: labels for the donut chart
:param values: values of the donut chart
:param chart_path: target path for the chart
"""
_, ax = matplotlib.pyplot.subplots()
_, texts, _ = ax.pie(values, labels=labels, autopct=_auto_percent_format(values), pctdistance=0.7, textprops={"fontsize": 10})
for text in texts:
text.set_backgroundcolor("white")

matplotlib.pyplot.savefig(str(chart_path), bbox_inches="tight", transparent=True)
231 changes: 231 additions & 0 deletions habapp_rules/energy/monthly_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
"""Module for sending the monthly energy consumption."""
import dataclasses
import datetime
import logging
import pathlib
import tempfile

import HABApp
import HABApp.core.internals
import dateutil.relativedelta
import jinja2
import multi_notifier.connectors.connector_mail

import habapp_rules.__version__
import habapp_rules.core.exceptions
import habapp_rules.core.logger
import habapp_rules.energy.donut_chart

LOGGER = logging.getLogger(__name__)

MONTH_MAPPING = {
1: "Januar",
2: "Februar",
3: "März",
4: "April",
5: "Mai",
6: "Juni",
7: "Juli",
8: "August",
9: "September",
10: "Oktober",
11: "November",
12: "Dezember"
}


def _get_previous_month_name() -> str:
"""Get name of the previous month

:return: name of current month

if other languages are required, the global dict must be replaced
"""
today = datetime.date.today()
last_month = today.replace(day=1) - datetime.timedelta(days=1)

return MONTH_MAPPING[last_month.month]


def _get_next_trigger() -> datetime.datetime:
"""Get next trigger time (always first day of month at midnight)

:return: next trigger time
"""
return datetime.datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + dateutil.relativedelta.relativedelta(months=1)


@dataclasses.dataclass
class EnergyShare:
"""Dataclass for defining energy share objects."""
openhab_name: str
chart_name: str
monthly_power: float = 0

_openhab_item = None

def __post_init__(self) -> None:
"""This is triggered after init.

:raises habapp_rules.core.exceptions.HabAppRulesConfigurationException: if the number item could not be found
"""
try:
self._openhab_item = HABApp.openhab.items.NumberItem.get_item(self.openhab_name)
except AssertionError:
raise habapp_rules.core.exceptions.HabAppRulesConfigurationException(f"The given item name is not a number item. '{self.openhab_name}'")
except HABApp.core.errors.ItemNotFoundException:
raise habapp_rules.core.exceptions.HabAppRulesConfigurationException(f"Could not find any item for given name '{self.openhab_name}'")

@property
def openhab_item(self) -> HABApp.openhab.items.NumberItem:
"""Get OpenHAB item.

:return: OpenHAB item
"""
return self._openhab_item


class MonthlyReport(HABApp.Rule):
"""Rule for sending the monthly energy consumption.

Example:
known_energy_share = [
habapp_rules.energy.monthly_report.EnergyShare("Dishwasher_Energy", "Dishwasher"),
habapp_rules.energy.monthly_report.EnergyShare("Light", "Light")
]

config_mail = multi_notifier.connectors.connector_mail.MailConfig(
user="[email protected]",
password="fancy_password",
smtp_host="smtp.test.de",
smtp_port=587,
)

habapp_rules.energy.monthly_report.MonthlyReport("Total_Energy", known_energy_share, "Group_RRD4J", config_mail, "[email protected]")
"""

def __init__(
self,
name_energy_sum: str,
known_energy_share: list[EnergyShare],
persistence_group_name: str | None,
config_mail: multi_notifier.connectors.connector_mail.MailConfig | None,
recipients: str | list[str]) -> None:
"""Initialize the rule.

:param name_energy_sum: name of OpenHAB Number item, which holds the total energy consumption (NumberItem)
:param known_energy_share: list of EnergyShare objects
:param persistence_group_name: OpenHAB group name which holds all items which are persisted. If the group name is given it will be checked if all energy items are in the group
:param config_mail: config for sending mails
:param recipients: list of recipients who get the mail
:raises habapp_rules.core.exceptions.HabAppRulesConfigurationException: if config is not valid
"""
HABApp.Rule.__init__(self)
self._instance_logger = habapp_rules.core.logger.InstanceLogger(LOGGER, name_energy_sum)
self._recipients = recipients

self._item_energy_sum = HABApp.openhab.items.NumberItem.get_item(name_energy_sum)
self._known_energy_share = known_energy_share
self._mail = multi_notifier.connectors.connector_mail.Mail(config_mail)

if persistence_group_name is not None:
# check if all energy items are in the given persistence group
items_to_check = [self._item_energy_sum] + [share.openhab_item for share in self._known_energy_share]
not_in_persistence_group = [item.name for item in items_to_check if persistence_group_name not in item.groups]
if not_in_persistence_group:
raise habapp_rules.core.exceptions.HabAppRulesConfigurationException(f"The following OpenHAB items are not in the persistence group '{persistence_group_name}': {not_in_persistence_group}")

self.run.at(next_trigger_time := _get_next_trigger(), self._cb_send_energy)
self._instance_logger.info(f"Successfully initiated monthly consumption rule for {name_energy_sum}. Triggered first execution to {next_trigger_time.isoformat()}")

def _get_historic_value(self, item: HABApp.openhab.items.NumberItem, start_time: datetime.datetime) -> float:
"""Get historic value of given Number item

:param item: item instance
:param start_time: start time to search for the interested value
:return: historic value of the item
"""
historic = item.get_persistence_data(start_time=start_time, end_time=start_time + datetime.timedelta(hours=1)).data
if not historic:
self._instance_logger.warning(f"Could not get value of item '{item.name}' of time = {start_time}")
return 0

return next(iter(historic.values()))

# pylint: disable=wrong-spelling-in-docstring
def _create_html(self, energy_sum_month: float) -> str:
"""Create html which will be sent by the mail

:param energy_sum_month: sum value for the current month
:return: html with replaced values

The template was created by https://app.bootstrapemail.com/editor/documents with the following input:

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style>
</style>
</head>
<body class="bg-light">
<div class="container">
<div class="card my-10">
<div class="card-body">
<h1 class="h3 mb-2">Strom Verbrauch</h1>
<h5 class="text-teal-700">von Februar</h5>
<hr>
<div class="space-y-3">
<p class="text-gray-700">Aktueller Zählerstand: <b>7000 kWh</b>.</p>
<p class="text-gray-700">Hier die Details:</p>
<p><img src="https://www.datylon.com/hubfs/Datylon%20Website2020/Datylon%20Chart%20library/Chart%20pages/Pie%20Chart/datylon-chart-library-pie-chart-intro-example.svg" alt="Italian Trulli" align="left">
</p>
</div>
<hr>
<p style="font-size: 0.6em">Generated with habapp_rules version = 20.0.3</p>
</div>
</div>
</div>
</body>
</html>
"""
html_template_path = pathlib.Path(__file__).parent / "monthly_report_template.html"

with html_template_path.open() as html_template_file:
html_template = html_template_file.read()

return jinja2.Template(html_template).render(
month=_get_previous_month_name(),
energy_now=f"{self._item_energy_sum.value:.1f}",
energy_last_month=f"{energy_sum_month:.1f}",
habapp_version=habapp_rules.__version__.__version__,
chart="{{ chart }}" # this is needed to not replace the chart from the mail-template
)

def _cb_send_energy(self) -> None:
"""Send the mail with the energy consumption of the last month"""
self._instance_logger.debug("Send energy consumption was triggered.")
# get values
now = datetime.datetime.now()
last_month = now - dateutil.relativedelta.relativedelta(months=1)

energy_sum_month = self._item_energy_sum.value - self._get_historic_value(self._item_energy_sum, last_month)
for share in self._known_energy_share:
share.monthly_power = share.openhab_item.value - self._get_historic_value(share.openhab_item, last_month)

energy_unknown = energy_sum_month - sum(share.monthly_power for share in self._known_energy_share)

with tempfile.TemporaryDirectory() as temp_dir_name:
# create plot
labels = [share.chart_name for share in self._known_energy_share] + ["Rest"]
values = [share.monthly_power for share in self._known_energy_share] + [energy_unknown]
chart_path = pathlib.Path(temp_dir_name) / "chart.png"
habapp_rules.energy.donut_chart.create_chart(labels, values, chart_path)

# get html
html = self._create_html(energy_sum_month)

# send mail
self._mail.send_message(self._recipients, html, f"Stromverbrauch {_get_previous_month_name()}", images={"chart": str(chart_path)})

self.run.at(next_trigger_time := _get_next_trigger(), self._cb_send_energy)
self._instance_logger.info(f"Successfully sent energy consumption mail to {self._recipients}. Scheduled the next trigger time to {next_trigger_time.isoformat()}")
Loading
Loading