Initial Home Assistant Configuration
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
import logging
|
||||
import math
|
||||
from operator import itemgetter
|
||||
from statistics import mean, median
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
||||
from homeassistant.const import CONF_REGION
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.util import dt as dt_utils
|
||||
|
||||
# Import sensor entity and classes.
|
||||
from homeassistant.components.sensor.const import (
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from jinja2 import pass_context
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
EVENT_NEW_DAY,
|
||||
EVENT_NEW_PRICE,
|
||||
EVENT_NEW_HOUR,
|
||||
SENTINEL,
|
||||
RANDOM_MINUTE,
|
||||
RANDOM_SECOND,
|
||||
DEFAULT_TEMPLATE,
|
||||
DEFAULT_REGION,
|
||||
_PRICE_IN,
|
||||
_REGIONS,
|
||||
_CURRENTY_TO_CENTS,
|
||||
_CENT_MULTIPLIER,
|
||||
)
|
||||
from .misc import start_of, stock
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(
|
||||
list(_REGIONS.keys())
|
||||
),
|
||||
vol.Optional("friendly_name", default=""): cv.string,
|
||||
# This is only needed if you want the some area but want the prices in a non local currency
|
||||
vol.Optional("currency", default=""): cv.string,
|
||||
vol.Optional("VAT", default=True): cv.boolean,
|
||||
vol.Optional("precision", default=3): cv.positive_int,
|
||||
vol.Optional("low_price_cutoff", default=1.0): cv.small_float,
|
||||
vol.Optional("price_type", default="kWh"): vol.In(list(_PRICE_IN.keys())),
|
||||
vol.Optional("price_in_cents", default=False): cv.boolean,
|
||||
vol.Optional("additional_costs", default=DEFAULT_TEMPLATE): cv.template,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _dry_setup(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the damn platform using yaml."""
|
||||
_LOGGER.debug("Dumping config %r", config)
|
||||
_LOGGER.debug("timezone set in ha %r", hass.config.time_zone)
|
||||
region = config.get(CONF_REGION)
|
||||
friendly_name = config.get("friendly_name", "")
|
||||
price_type = config.get("price_type")
|
||||
precision = config.get("precision")
|
||||
low_price_cutoff = config.get("low_price_cutoff")
|
||||
currency = config.get("currency")
|
||||
vat = config.get("VAT")
|
||||
use_cents = config.get("price_in_cents")
|
||||
ad_template = config.get("additional_costs")
|
||||
api = hass.data[DOMAIN]
|
||||
sensor = NordpoolSensor(
|
||||
friendly_name,
|
||||
region,
|
||||
price_type,
|
||||
precision,
|
||||
low_price_cutoff,
|
||||
currency,
|
||||
vat,
|
||||
use_cents,
|
||||
api,
|
||||
ad_template,
|
||||
hass,
|
||||
)
|
||||
|
||||
add_devices([sensor])
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, add_devices, discovery_info=None) -> True:
|
||||
_dry_setup(hass, config, add_devices)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
"""Setup sensor platform for the ui"""
|
||||
config = config_entry.data
|
||||
_dry_setup(hass, config, async_add_devices)
|
||||
return True
|
||||
|
||||
|
||||
class NordpoolSensor(SensorEntity):
|
||||
"Sensors data"
|
||||
|
||||
_attr_device_class = SensorDeviceClass.MONETARY
|
||||
_attr_suggested_display_precision = None
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
friendly_name,
|
||||
area,
|
||||
price_type,
|
||||
precision,
|
||||
low_price_cutoff,
|
||||
currency,
|
||||
vat,
|
||||
use_cents,
|
||||
api,
|
||||
ad_template,
|
||||
hass,
|
||||
) -> None:
|
||||
self._area = area
|
||||
self._currency = currency or _REGIONS[area][0]
|
||||
self._price_type = price_type
|
||||
# Should be depricated in a future version
|
||||
self._precision = precision
|
||||
self._attr_suggested_display_precision = precision
|
||||
self._low_price_cutoff = low_price_cutoff
|
||||
self._use_cents = use_cents
|
||||
self._api = api
|
||||
self._ad_template = ad_template
|
||||
self._hass = hass
|
||||
self._attr_force_update = True
|
||||
|
||||
if vat is True:
|
||||
self._vat = _REGIONS[area][2]
|
||||
else:
|
||||
self._vat = 0
|
||||
|
||||
# Price by current hour.
|
||||
self._current_price = None
|
||||
|
||||
# Holds the data for today and morrow.
|
||||
self._data_today = SENTINEL
|
||||
self._data_tomorrow = SENTINEL
|
||||
|
||||
# Values for the day
|
||||
self._average = None
|
||||
self._max = None
|
||||
self._min = None
|
||||
self._mean = None
|
||||
self._off_peak_1 = None
|
||||
self._off_peak_2 = None
|
||||
self._peak = None
|
||||
self._additional_costs_value = None
|
||||
|
||||
_LOGGER.debug("Template %s", str(ad_template))
|
||||
# Check incase the sensor was setup using config flow.
|
||||
# This blow up if the template isnt valid.
|
||||
if not isinstance(self._ad_template, Template):
|
||||
if self._ad_template in (None, ""):
|
||||
self._ad_template = DEFAULT_TEMPLATE
|
||||
self._ad_template = cv.template(self._ad_template)
|
||||
# check for yaml setup.
|
||||
else:
|
||||
if self._ad_template.template in ("", None):
|
||||
self._ad_template = cv.template(DEFAULT_TEMPLATE)
|
||||
|
||||
# To control the updates.
|
||||
self._last_tick = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.unique_id
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No need to poll. Coordinator notifies entity of updates."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return "mdi:flash"
|
||||
|
||||
@property
|
||||
def unit(self) -> str:
|
||||
"""Unit"""
|
||||
return self._price_type
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> str: # FIXME
|
||||
"""Return the unit of measurement this sensor expresses itself in."""
|
||||
_currency = self._currency
|
||||
if self._use_cents is True:
|
||||
# Convert unit of measurement to cents based on chosen currency
|
||||
_currency = _CURRENTY_TO_CENTS[_currency]
|
||||
return "%s/%s" % (_currency, self._price_type)
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
name = "nordpool_%s_%s_%s_%s_%s_%s" % (
|
||||
self._price_type,
|
||||
self._area,
|
||||
self._currency,
|
||||
self._precision,
|
||||
self._low_price_cutoff,
|
||||
self._vat,
|
||||
)
|
||||
name = name.lower().replace(".", "")
|
||||
return name
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.unique_id)},
|
||||
"name": self.name,
|
||||
"manufacturer": DOMAIN,
|
||||
}
|
||||
|
||||
@property
|
||||
def additional_costs(self):
|
||||
"""Additional costs."""
|
||||
return self._additional_costs_value
|
||||
|
||||
@property
|
||||
def low_price(self) -> bool:
|
||||
"""Check if the price is lower then avg depending on settings"""
|
||||
return (
|
||||
self.current_price < self._average * self._low_price_cutoff
|
||||
if isinstance(self.current_price, (int, float))
|
||||
and isinstance(self._average, (float, int))
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def price_percent_to_average(self) -> float:
|
||||
"""Price in percent to average price"""
|
||||
return (
|
||||
self.current_price / self._average
|
||||
if isinstance(self.current_price, (int, float))
|
||||
and isinstance(self._average, (float, int))
|
||||
else None
|
||||
)
|
||||
|
||||
def _calc_price(self, value=None, fake_dt=None) -> float:
|
||||
"""Calculate price based on the users settings."""
|
||||
if value is None:
|
||||
value = self._current_price
|
||||
|
||||
if value is None or math.isinf(value):
|
||||
# _LOGGER.debug("api returned junk infinty %s", value)
|
||||
return None
|
||||
|
||||
def faker():
|
||||
def inner(*_, **__):
|
||||
return fake_dt or dt_utils.now()
|
||||
|
||||
return pass_context(inner)
|
||||
|
||||
price = value / _PRICE_IN[self._price_type] * (float(1 + self._vat))
|
||||
template_value = self._ad_template.async_render(
|
||||
now=faker(), current_price=price
|
||||
)
|
||||
|
||||
# Seems like the template is rendered as a string if the number is complex
|
||||
# Just force it to be a float.
|
||||
if not isinstance(template_value, (int, float)):
|
||||
try:
|
||||
template_value = float(template_value)
|
||||
except (TypeError, ValueError):
|
||||
_LOGGER.exception(
|
||||
"Failed to convert %s %s to float",
|
||||
template_value,
|
||||
type(template_value),
|
||||
)
|
||||
raise
|
||||
|
||||
self._additional_costs_value = template_value
|
||||
try:
|
||||
price += template_value
|
||||
except Exception:
|
||||
_LOGGER.debug(
|
||||
"price %s template value %s type %s dt %s current_price %s ",
|
||||
price,
|
||||
template_value,
|
||||
type(template_value),
|
||||
fake_dt,
|
||||
self._current_price,
|
||||
)
|
||||
raise
|
||||
|
||||
# Convert price to cents if specified by the user.
|
||||
if self._use_cents:
|
||||
price = price * _CENT_MULTIPLIER
|
||||
|
||||
return round(price, self._precision)
|
||||
|
||||
def _update(self):
|
||||
"""Set attrs"""
|
||||
today = self.today
|
||||
|
||||
if not today:
|
||||
_LOGGER.debug("No data for today, unable to set attrs")
|
||||
return
|
||||
|
||||
self._average = mean(today)
|
||||
self._min = min(today)
|
||||
self._max = max(today)
|
||||
self._off_peak_1 = mean(today[0:8])
|
||||
self._off_peak_2 = mean(today[20:])
|
||||
self._peak = mean(today[8:20])
|
||||
self._mean = median(today)
|
||||
|
||||
@property
|
||||
def current_price(self) -> float:
|
||||
"""This the current price for the hour we are in at any given time."""
|
||||
res = self._calc_price()
|
||||
# _LOGGER.debug("Current hours price for %s is %s", self.name, res)
|
||||
return res
|
||||
|
||||
def _someday(self, data) -> list:
|
||||
"""The data is already sorted in the xml,
|
||||
but I don't trust that to continue forever. That's why we sort it ourselves."""
|
||||
if data is None or data is SENTINEL:
|
||||
return []
|
||||
|
||||
local_times = []
|
||||
for item in data.get("values", []):
|
||||
i = {
|
||||
"start": dt_utils.as_local(item["start"]),
|
||||
"end": dt_utils.as_local(item["end"]),
|
||||
"value": item["value"],
|
||||
}
|
||||
|
||||
local_times.append(i)
|
||||
|
||||
data["values"] = local_times
|
||||
|
||||
return sorted(data.get("values", []), key=itemgetter("start"))
|
||||
|
||||
@property
|
||||
def today(self) -> list:
|
||||
"""Get todays prices
|
||||
|
||||
Returns:
|
||||
list: sorted list where today[0] is the price of hour 00.00 - 01.00
|
||||
"""
|
||||
return [
|
||||
self._calc_price(i["value"], fake_dt=i["start"])
|
||||
for i in self._someday(self._data_today)
|
||||
if i
|
||||
]
|
||||
|
||||
@property
|
||||
def tomorrow(self) -> list:
|
||||
"""Get tomorrows prices
|
||||
|
||||
Returns:
|
||||
list: sorted where tomorrow[0] is the price of hour 00.00 - 01.00 etc.
|
||||
"""
|
||||
return [
|
||||
self._calc_price(i["value"], fake_dt=i["start"])
|
||||
for i in self._someday(self._data_tomorrow)
|
||||
if i
|
||||
]
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
return {
|
||||
"average": self._average,
|
||||
"off_peak_1": self._off_peak_1,
|
||||
"off_peak_2": self._off_peak_2,
|
||||
"peak": self._peak,
|
||||
"min": self._min,
|
||||
"max": self._max,
|
||||
"mean": self._mean,
|
||||
"unit": self.unit,
|
||||
"currency": self._currency,
|
||||
"country": _REGIONS[self._area][1],
|
||||
"region": self._area,
|
||||
"low_price": self.low_price,
|
||||
"price_percent_to_average": self.price_percent_to_average,
|
||||
"today": self.today,
|
||||
"tomorrow": self.tomorrow,
|
||||
"tomorrow_valid": self.tomorrow_valid,
|
||||
"raw_today": self.raw_today,
|
||||
"raw_tomorrow": self.raw_tomorrow,
|
||||
"current_price": self.current_price,
|
||||
"additional_costs_current_hour": self.additional_costs,
|
||||
"price_in_cents": self._use_cents,
|
||||
}
|
||||
|
||||
def _add_raw(self, data) -> list:
|
||||
"""Helper"""
|
||||
result = []
|
||||
for res in self._someday(data):
|
||||
item = {
|
||||
"start": res["start"],
|
||||
"end": res["end"],
|
||||
"value": self._calc_price(res["value"], fake_dt=res["start"]),
|
||||
}
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@property
|
||||
def raw_today(self) -> list:
|
||||
"""Raw today"""
|
||||
return self._add_raw(self._data_today)
|
||||
|
||||
@property
|
||||
def raw_tomorrow(self) -> list:
|
||||
"""Raw tomorrow"""
|
||||
return self._add_raw(self._data_tomorrow)
|
||||
|
||||
@property
|
||||
def tomorrow_valid(self) -> bool:
|
||||
"""Verify that we have the values for tomorrow."""
|
||||
# this should be checked a better way
|
||||
return len([i for i in self.tomorrow if i not in (None, float("inf"))]) >= 23
|
||||
|
||||
async def _update_current_price(self) -> None:
|
||||
"""update the current price (price this hour)"""
|
||||
local_now = dt_utils.now()
|
||||
|
||||
data = await self._api.today(self._area, self._currency)
|
||||
if data:
|
||||
for item in self._someday(data):
|
||||
if item["start"] == start_of(local_now, "hour"):
|
||||
self._current_price = item["value"]
|
||||
_LOGGER.debug(
|
||||
"Updated %s _current_price %s", self.name, item["value"]
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("Cant update _update_current_price because it was no data")
|
||||
|
||||
async def handle_new_day(self):
|
||||
"""Update attrs for the new day"""
|
||||
_LOGGER.debug("handle_new_day")
|
||||
self._data_tomorrow = None
|
||||
# update attrs for the new day
|
||||
await self.handle_new_hr()
|
||||
|
||||
async def handle_new_hr(self):
|
||||
"""Update attrs for the new hour"""
|
||||
_LOGGER.debug("handle_new_hr")
|
||||
today = await self._api.today(self._area, self._currency)
|
||||
if today:
|
||||
self._data_today = today
|
||||
|
||||
now = dt_utils.now()
|
||||
if self._data_tomorrow is SENTINEL and stock(now) >= stock(now).replace(
|
||||
hour=13, minute=RANDOM_MINUTE, second=RANDOM_SECOND
|
||||
):
|
||||
tomorrow = await self._api.tomorrow(self._area, self._currency)
|
||||
if tomorrow:
|
||||
self._data_tomorrow = tomorrow
|
||||
|
||||
self._update()
|
||||
# Updates the current for this hour.
|
||||
await self._update_current_price()
|
||||
# This is not to make sure the correct template costs are set. Issue 258
|
||||
self._attr_native_value = self.current_price
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def handle_new_price(self):
|
||||
"""Update atts because of the new prices"""
|
||||
_LOGGER.debug("handle_new_price")
|
||||
tomorrow = await self._api.tomorrow(self._area, self._currency)
|
||||
if tomorrow:
|
||||
self._data_tomorrow = tomorrow
|
||||
|
||||
await self.handle_new_hr()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Connect to dispatcher listening for entity data notifications."""
|
||||
await super().async_added_to_hass()
|
||||
_LOGGER.debug("called async_added_to_hass %s", self.name)
|
||||
|
||||
async_dispatcher_connect(self._api._hass, EVENT_NEW_DAY, self.handle_new_day)
|
||||
async_dispatcher_connect(
|
||||
self._api._hass, EVENT_NEW_PRICE, self.handle_new_price
|
||||
)
|
||||
async_dispatcher_connect(self._api._hass, EVENT_NEW_HOUR, self.handle_new_hr)
|
||||
await self.handle_new_hr()
|
||||
Reference in New Issue
Block a user