Initial Home Assistant Configuration
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
import backoff
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_change
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_utils
|
||||
|
||||
from .aio_price import AioPrices, InvalidValueException
|
||||
from .events import async_track_time_change_in_tz
|
||||
from .services import async_setup_services
|
||||
|
||||
from .const import (
|
||||
NAME,
|
||||
VERSION,
|
||||
ISSUEURL,
|
||||
DOMAIN,
|
||||
EVENT_NEW_DAY,
|
||||
EVENT_NEW_HOUR,
|
||||
EVENT_NEW_PRICE,
|
||||
_CURRENCY_LIST,
|
||||
RANDOM_MINUTE,
|
||||
RANDOM_SECOND,
|
||||
)
|
||||
|
||||
|
||||
STARTUP = f"""
|
||||
-------------------------------------------------------------------
|
||||
{NAME}
|
||||
Version: {VERSION}
|
||||
This is a custom component
|
||||
If you have any issues with this you need to open an issue here:
|
||||
{ISSUEURL}
|
||||
-------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
class NordpoolData:
|
||||
"""Holds the data"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant):
|
||||
self._hass = hass
|
||||
self._last_tick = None
|
||||
self._data = defaultdict(dict)
|
||||
self.currency = []
|
||||
self.listeners = []
|
||||
self.areas = []
|
||||
|
||||
async def _update(self, type_="today", dt=None, areas=None):
|
||||
_LOGGER.debug("calling _update %s %s %s", type_, dt, areas)
|
||||
hass = self._hass
|
||||
client = async_get_clientsession(hass)
|
||||
|
||||
if dt is None:
|
||||
dt = dt_utils.now()
|
||||
|
||||
if areas is not None:
|
||||
self.areas += [area for area in areas if area not in self.areas]
|
||||
# We dont really need today and morrow
|
||||
# when the region is in another timezone
|
||||
# as we request data for 3 days anyway.
|
||||
# Keeping this for now, but this should be changed.
|
||||
for currency in self.currency:
|
||||
spot = AioPrices(currency, client)
|
||||
data = await spot.hourly(
|
||||
end_date=dt, areas=self.areas if len(self.areas) > 0 else None
|
||||
)
|
||||
if data:
|
||||
self._data[currency][type_] = data["areas"]
|
||||
|
||||
async def update_today(self, areas=None):
|
||||
"""Update today's prices"""
|
||||
_LOGGER.debug("Updating today's prices.")
|
||||
if areas is not None:
|
||||
self.areas += [area for area in areas if area not in self.areas]
|
||||
await self._update("today", areas=self.areas if len(self.areas) > 0 else None)
|
||||
|
||||
async def update_tomorrow(self, areas=None):
|
||||
"""Update tomorrows prices."""
|
||||
_LOGGER.debug("Updating tomorrows prices.")
|
||||
if areas is not None:
|
||||
self.areas += [area for area in areas if area not in self.areas]
|
||||
await self._update(
|
||||
type_="tomorrow",
|
||||
dt=dt_utils.now() + timedelta(hours=24),
|
||||
areas=self.areas if len(self.areas) > 0 else None,
|
||||
)
|
||||
|
||||
async def _someday(self, area: str, currency: str, day: str):
|
||||
"""Returns today's or tomorrow's prices in an area in the currency"""
|
||||
if currency not in _CURRENCY_LIST:
|
||||
raise ValueError(
|
||||
"%s is an invalid currency, possible values are %s"
|
||||
% (currency, ", ".join(_CURRENCY_LIST))
|
||||
)
|
||||
|
||||
if area not in self.areas:
|
||||
self.areas.append(area)
|
||||
# This is needed as the currency is
|
||||
# set in the sensor.
|
||||
if currency not in self.currency:
|
||||
self.currency.append(currency)
|
||||
try:
|
||||
await self.update_today(areas=self.areas)
|
||||
except InvalidValueException:
|
||||
_LOGGER.debug("No data available for today, retrying later")
|
||||
try:
|
||||
await self.update_tomorrow(areas=self.areas)
|
||||
except InvalidValueException:
|
||||
_LOGGER.debug("No data available for tomorrow, retrying later")
|
||||
|
||||
# Send a new data request after new data is updated for this first run
|
||||
# This way if the user has multiple sensors they will all update
|
||||
async_dispatcher_send(self._hass, EVENT_NEW_HOUR)
|
||||
|
||||
return self._data.get(currency, {}).get(day, {}).get(area)
|
||||
|
||||
async def today(self, area: str, currency: str) -> dict:
|
||||
"""Returns today's prices in an area in the requested currency"""
|
||||
return await self._someday(area, currency, "today")
|
||||
|
||||
async def tomorrow(self, area: str, currency: str):
|
||||
"""Returns tomorrow's prices in an area in the requested currency"""
|
||||
return await self._someday(area, currency, "tomorrow")
|
||||
|
||||
|
||||
async def _dry_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up using yaml config file."""
|
||||
if DOMAIN not in hass.data:
|
||||
api = NordpoolData(hass)
|
||||
hass.data[DOMAIN] = api
|
||||
_LOGGER.debug("Added %s to hass.data", DOMAIN)
|
||||
await async_setup_services(hass)
|
||||
|
||||
async def new_day_cb(_):
|
||||
"""Cb to handle some house keeping when it a new day."""
|
||||
_LOGGER.debug("Called new_day_cb callback")
|
||||
|
||||
for curr in api.currency:
|
||||
if not api._data.get(curr, {}).get("tomorrow"):
|
||||
api._data[curr]["today"] = await api.update_today()
|
||||
else:
|
||||
api._data[curr]["today"] = api._data[curr]["tomorrow"]
|
||||
api._data[curr]["tomorrow"] = {}
|
||||
|
||||
async_dispatcher_send(hass, EVENT_NEW_DAY)
|
||||
|
||||
async def new_hr(_):
|
||||
"""Callback to tell the sensors to update on a new hour."""
|
||||
_LOGGER.debug("Called new_hr callback")
|
||||
async_dispatcher_send(hass, EVENT_NEW_HOUR)
|
||||
|
||||
@backoff.on_exception(
|
||||
backoff.constant,
|
||||
(InvalidValueException),
|
||||
logger=_LOGGER,
|
||||
interval=600,
|
||||
max_time=7200,
|
||||
jitter=None,
|
||||
)
|
||||
async def new_data_cb(_):
|
||||
"""Callback to fetch new data for tomorrows prices at 1300ish CET
|
||||
and notify any sensors, about the new data
|
||||
"""
|
||||
# _LOGGER.debug("Called new_data_cb")
|
||||
await api.update_tomorrow()
|
||||
async_dispatcher_send(hass, EVENT_NEW_PRICE)
|
||||
|
||||
# Handles futures updates
|
||||
cb_update_tomorrow = async_track_time_change_in_tz(
|
||||
hass,
|
||||
new_data_cb,
|
||||
hour=13,
|
||||
minute=RANDOM_MINUTE,
|
||||
second=RANDOM_SECOND,
|
||||
tz=await dt_utils.async_get_time_zone("Europe/Stockholm"),
|
||||
)
|
||||
|
||||
cb_new_day = async_track_time_change(
|
||||
hass, new_day_cb, hour=0, minute=0, second=0
|
||||
)
|
||||
|
||||
cb_new_hr = async_track_time_change(hass, new_hr, minute=0, second=0)
|
||||
|
||||
api.listeners.append(cb_update_tomorrow)
|
||||
api.listeners.append(cb_new_hr)
|
||||
api.listeners.append(cb_new_day)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up using yaml config file."""
|
||||
return await _dry_setup(hass, config)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up nordpool as config entry."""
|
||||
res = await _dry_setup(hass, entry.data)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.add_update_listener(async_reload_entry)
|
||||
return res
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
# This is an issue if you have multiple sensors as everything related to DOMAIN
|
||||
# is removed, regardless if you have multiple sensors or not. Doesn't seem to
|
||||
# create a big issue for now #TODO
|
||||
if DOMAIN in hass.data:
|
||||
for unsub in hass.data[DOMAIN].listeners:
|
||||
unsub()
|
||||
hass.data.pop(DOMAIN)
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload config entry."""
|
||||
await async_unload_entry(hass, entry)
|
||||
await async_setup_entry(hass, entry)
|
||||
@@ -0,0 +1,355 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import timezone as ts
|
||||
|
||||
# import aiohttp
|
||||
# import backoff
|
||||
from dateutil.parser import parse as parse_dt
|
||||
from homeassistant.util import dt as dt_utils
|
||||
|
||||
# from nordpool.elspot import Prices
|
||||
from pytz import timezone, utc
|
||||
|
||||
from .misc import add_junk
|
||||
from .const import tzs, INVALID_VALUES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvalidValueException(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class CurrencyMismatch(ValueError): # pylint: disable=missing-class-docstring
|
||||
pass
|
||||
|
||||
|
||||
async def join_result_for_correct_time(results, dt):
|
||||
"""Parse a list of responses from the api
|
||||
to extract the correct hours in there timezone.
|
||||
"""
|
||||
# utc = datetime.utcnow()
|
||||
fin = defaultdict(dict)
|
||||
# _LOGGER.debug("join_result_for_correct_time %s", dt)
|
||||
if dt is None:
|
||||
utc = datetime.now(ts.utc)
|
||||
else:
|
||||
utc = dt
|
||||
|
||||
for day_ in results:
|
||||
for key, value in day_.get("areas", {}).items():
|
||||
zone = tzs.get(key)
|
||||
if zone is None:
|
||||
_LOGGER.debug("Skipping %s", key)
|
||||
continue
|
||||
else:
|
||||
zone = await dt_utils.async_get_time_zone(zone)
|
||||
|
||||
# We add junk here as the peak etc
|
||||
# from the api is based on cet, not the
|
||||
# hours in the we want so invalidate them
|
||||
# its later corrected in the sensor.
|
||||
value = add_junk(value)
|
||||
|
||||
values = day_["areas"][key].pop("values")
|
||||
|
||||
# We need to check this so we dont overwrite stuff.
|
||||
if key not in fin["areas"]:
|
||||
fin["areas"][key] = {}
|
||||
fin["areas"][key].update(value)
|
||||
if "values" not in fin["areas"][key]:
|
||||
fin["areas"][key]["values"] = []
|
||||
|
||||
start_of_day = utc.astimezone(zone).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
end_of_day = utc.astimezone(zone).replace(
|
||||
hour=23, minute=59, second=59, microsecond=999999
|
||||
)
|
||||
|
||||
for val in values:
|
||||
local = val["start"].astimezone(zone)
|
||||
local_end = val["end"].astimezone(zone)
|
||||
if start_of_day <= local <= end_of_day:
|
||||
if local == local_end:
|
||||
_LOGGER.info(
|
||||
"Hour has the same start and end, most likly due to dst change %s exluded this hour",
|
||||
val,
|
||||
)
|
||||
elif val["value"] in INVALID_VALUES:
|
||||
raise InvalidValueException(
|
||||
f"Invalid value in {val} for area '{key}'"
|
||||
)
|
||||
else:
|
||||
fin["areas"][key]["values"].append(val)
|
||||
|
||||
return fin
|
||||
|
||||
|
||||
class AioPrices:
|
||||
"""Interface"""
|
||||
|
||||
def __init__(self, currency, client, timeezone=None):
|
||||
# super().__init__(currency)
|
||||
self.client = client
|
||||
self.timeezone = timeezone
|
||||
(self.HOURLY, self.DAILY, self.WEEKLY, self.MONTHLY, self.YEARLY) = (
|
||||
"DayAheadPrices",
|
||||
"AggregatePrices",
|
||||
"AggregatePrices",
|
||||
"AggregatePrices",
|
||||
"AggregatePrices/GetAnnuals",
|
||||
)
|
||||
self.API_URL = "https://dataportal-api.nordpoolgroup.com/api/%s"
|
||||
self.currency = currency
|
||||
|
||||
async def _io(self, url, **kwargs):
|
||||
resp = await self.client.get(url, params=kwargs)
|
||||
_LOGGER.debug("requested %s %s", resp.url, kwargs)
|
||||
|
||||
if resp.status == 204:
|
||||
return None
|
||||
|
||||
return await resp.json()
|
||||
|
||||
def _parse_dt(self, time_str):
|
||||
"""Parse datetimes to UTC from Stockholm time, which Nord Pool uses."""
|
||||
time = parse_dt(time_str, tzinfos={"Z": timezone("Europe/Stockholm")})
|
||||
if time.tzinfo is None:
|
||||
return timezone("Europe/Stockholm").localize(time).astimezone(utc)
|
||||
return time.astimezone(utc)
|
||||
|
||||
def _parse_json(self, data, areas=None, data_type=None):
|
||||
"""
|
||||
Parse json response from fetcher.
|
||||
Returns dictionary with
|
||||
- start time
|
||||
- end time
|
||||
- update time
|
||||
- currency
|
||||
- dictionary of areas, based on selection
|
||||
- list of values (dictionary with start and endtime and value)
|
||||
- possible other values, such as min, max, average for hourly
|
||||
"""
|
||||
|
||||
if areas is None:
|
||||
areas = []
|
||||
|
||||
if not isinstance(areas, list) and areas is not None:
|
||||
areas = [i.strip() for i in areas.split(",")]
|
||||
|
||||
_LOGGER.debug("data type in _parser %s, areas %s", data_type, areas)
|
||||
|
||||
# Ripped from Kipe's nordpool
|
||||
if data_type == self.HOURLY:
|
||||
data_source = ("multiAreaEntries", "entryPerArea")
|
||||
elif data_type == self.DAILY:
|
||||
data_source = ("multiAreaDailyAggregates", "averagePerArea")
|
||||
elif data_type == self.WEEKLY:
|
||||
data_source = ("multiAreaWeeklyAggregates", "averagePerArea")
|
||||
elif data_type == self.MONTHLY:
|
||||
data_source = ("multiAreaMonthlyAggregates", "averagePerArea")
|
||||
elif data_type == self.YEARLY:
|
||||
data_source = ("prices", "averagePerArea")
|
||||
else:
|
||||
data_source = ("multiAreaEntries", "entryPerArea")
|
||||
|
||||
if data.get("status", 200) != 200 and "version" not in data:
|
||||
raise Exception(f"Invalid response from Nordpool API: {data}")
|
||||
|
||||
# Update currency from data
|
||||
# currency it not avaiable in yearly... We just have to trust that the one
|
||||
# we set in the class is correct.
|
||||
currency = data.get("currency", self.currency)
|
||||
|
||||
# Ensure that the provided currency match the requested one
|
||||
if currency != self.currency:
|
||||
raise CurrencyMismatch
|
||||
|
||||
start_time = None
|
||||
end_time = None
|
||||
# multiAreaDailyAggregates
|
||||
if len(data[data_source[0]]) > 0:
|
||||
start_time = self._parse_dt(data[data_source[0]][0]["deliveryStart"])
|
||||
end_time = self._parse_dt(data[data_source[0]][-1]["deliveryEnd"])
|
||||
updated = self._parse_dt(data["updatedAt"])
|
||||
|
||||
area_data = {}
|
||||
|
||||
# Loop through response rows
|
||||
for r in data[data_source[0]]:
|
||||
row_start_time = self._parse_dt(r["deliveryStart"])
|
||||
row_end_time = self._parse_dt(r["deliveryEnd"])
|
||||
|
||||
# Loop through columns
|
||||
for area_key in r[data_source[1]].keys():
|
||||
area_price = r[data_source[1]][area_key]
|
||||
# If areas is defined and name isn't in areas, skip column
|
||||
if area_key not in areas:
|
||||
continue
|
||||
|
||||
# If name isn't in area_data, initialize dictionary
|
||||
if area_key not in area_data:
|
||||
area_data[area_key] = {
|
||||
"values": [],
|
||||
}
|
||||
|
||||
# Append dictionary to value list
|
||||
area_data[area_key]["values"].append(
|
||||
{
|
||||
"start": row_start_time,
|
||||
"end": row_end_time,
|
||||
"value": self._conv_to_float(area_price),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"start": start_time,
|
||||
"end": end_time,
|
||||
"updated": updated,
|
||||
"currency": currency,
|
||||
"areas": area_data,
|
||||
}
|
||||
|
||||
async def _fetch_json(self, data_type, end_date=None, areas=None):
|
||||
"""Fetch JSON from API"""
|
||||
# If end_date isn't set, default to tomorrow
|
||||
if data_type is None:
|
||||
data_type = self.HOURLY
|
||||
|
||||
if areas is None or len(areas) == 0:
|
||||
raise Exception("Cannot query with empty areas")
|
||||
if end_date is None:
|
||||
end_date = date.today() + timedelta(days=1)
|
||||
# If end_date isn't a date or datetime object, try to parse a string
|
||||
if not isinstance(end_date, date) and not isinstance(end_date, datetime):
|
||||
end_date = parse_dt(end_date)
|
||||
|
||||
if not isinstance(areas, list) and areas is not None:
|
||||
areas = [i.strip() for i in areas.split(",")]
|
||||
|
||||
kws = {
|
||||
"currency": self.currency,
|
||||
"market": "DayAhead",
|
||||
"deliveryArea": ",".join(areas),
|
||||
# This one is default for hourly..
|
||||
"date": end_date.strftime("%Y-%m-%d"),
|
||||
}
|
||||
|
||||
if data_type != self.HOURLY:
|
||||
kws.pop("date")
|
||||
kws["year"] = end_date.strftime("%Y")
|
||||
|
||||
return await self._io(self.API_URL % data_type, **kws)
|
||||
|
||||
# Add more exceptions as we find them. KeyError is raised when the api return
|
||||
# junk due to currency not being available in the data.
|
||||
# @backoff.on_exception(
|
||||
# backoff.expo, (aiohttp.ClientError, KeyError), logger=_LOGGER, max_value=20
|
||||
# )
|
||||
async def fetch(self, data_type, end_date=None, areas=None, raw=False):
|
||||
"""
|
||||
Fetch data from API.
|
||||
Inputs:
|
||||
- data_type
|
||||
API page id, one of Prices.HOURLY, Prices.DAILY etc
|
||||
- end_date
|
||||
datetime to end the data fetching
|
||||
defaults to tomorrow
|
||||
- areas
|
||||
list of areas to fetch, such as ['SE1', 'SE2', 'FI']
|
||||
defaults to all areas
|
||||
Returns dictionary with
|
||||
- start time
|
||||
- end time
|
||||
- update time
|
||||
- currency
|
||||
- dictionary of areas, based on selection
|
||||
- list of values (dictionary with start and endtime and value)
|
||||
- possible other values, such as min, max, average for hourly
|
||||
"""
|
||||
if areas is None:
|
||||
areas = []
|
||||
|
||||
if end_date is None:
|
||||
end_date = datetime.now()
|
||||
|
||||
if isinstance(end_date, str):
|
||||
end_date = parse_dt(end_date)
|
||||
|
||||
today = end_date
|
||||
yesterday = today - timedelta(days=1)
|
||||
tomorrow = today + timedelta(days=1)
|
||||
|
||||
if data_type == self.HOURLY:
|
||||
if raw:
|
||||
return await self._fetch_json(data_type, today, areas)
|
||||
jobs = [
|
||||
self._fetch_json(data_type, yesterday, areas),
|
||||
self._fetch_json(data_type, today, areas),
|
||||
self._fetch_json(data_type, tomorrow, areas),
|
||||
]
|
||||
else:
|
||||
# This is really not today but a year..
|
||||
# All except from hourly returns the raw values
|
||||
return await self._fetch_json(data_type, today, areas)
|
||||
|
||||
res = await asyncio.gather(*jobs)
|
||||
raw = [
|
||||
await self._async_parse_json(i, areas, data_type=data_type)
|
||||
for i in res
|
||||
if i
|
||||
]
|
||||
|
||||
return await join_result_for_correct_time(raw, end_date)
|
||||
|
||||
async def _async_parse_json(self, data, areas, data_type):
|
||||
"""
|
||||
Async version of _parse_json to prevent blocking calls inside the event loop.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, self._parse_json, data, areas, data_type
|
||||
)
|
||||
|
||||
async def hourly(self, end_date=None, areas=None, raw=False):
|
||||
"""Helper to fetch hourly data, see Prices.fetch()"""
|
||||
if areas is None:
|
||||
areas = []
|
||||
return await self.fetch(self.HOURLY, end_date, areas, raw=raw)
|
||||
|
||||
async def daily(self, end_date=None, areas=None):
|
||||
"""Helper to fetch daily data, see Prices.fetch()"""
|
||||
if areas is None:
|
||||
areas = []
|
||||
return await self.fetch(self.DAILY, end_date, areas)
|
||||
|
||||
async def weekly(self, end_date=None, areas=None):
|
||||
"""Helper to fetch weekly data, see Prices.fetch()"""
|
||||
if areas is None:
|
||||
areas = []
|
||||
return await self.fetch(self.WEEKLY, end_date, areas)
|
||||
|
||||
async def monthly(self, end_date=None, areas=None):
|
||||
"""Helper to fetch monthly data, see Prices.fetch()"""
|
||||
if areas is None:
|
||||
areas = []
|
||||
return await self.fetch(self.MONTHLY, end_date, areas)
|
||||
|
||||
async def yearly(self, end_date=None, areas=None):
|
||||
"""Helper to fetch yearly data, see Prices.fetch()"""
|
||||
if areas is None:
|
||||
areas = []
|
||||
return await self.fetch(self.YEARLY, end_date, areas)
|
||||
|
||||
def _conv_to_float(self, s):
|
||||
"""Convert numbers to float. Return infinity, if conversion fails."""
|
||||
# Skip if already float
|
||||
if isinstance(s, float):
|
||||
return s
|
||||
try:
|
||||
return float(s.replace(",", ".").replace(" ", ""))
|
||||
except ValueError:
|
||||
return float("inf")
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Adds config flow for nordpool."""
|
||||
import logging
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.helpers.template import Template
|
||||
|
||||
from . import DOMAIN
|
||||
from .sensor import _PRICE_IN, _REGIONS, DEFAULT_TEMPLATE
|
||||
|
||||
regions = sorted(list(_REGIONS.keys()))
|
||||
currencys = sorted(list(set(v[0] for k, v in _REGIONS.items())))
|
||||
price_types = sorted(list(_PRICE_IN.keys()))
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NordpoolFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Nordpool."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize."""
|
||||
self._errors = {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input=None
|
||||
): # pylint: disable=dangerous-default-value
|
||||
"""Handle a flow initialized by the user."""
|
||||
self._errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
template_ok = False
|
||||
if user_input["additional_costs"] in (None, ""):
|
||||
user_input["additional_costs"] = DEFAULT_TEMPLATE
|
||||
else:
|
||||
# Lets try to remove the most common mistakes, this will still fail if the template
|
||||
# was writte in notepad or something like that..
|
||||
user_input["additional_costs"] = re.sub(
|
||||
r"\s{2,}", "", user_input["additional_costs"]
|
||||
)
|
||||
|
||||
template_ok = await self._valid_template(user_input["additional_costs"])
|
||||
if template_ok:
|
||||
return self.async_create_entry(title="Nordpool", data=user_input)
|
||||
else:
|
||||
self._errors["base"] = "invalid_template"
|
||||
|
||||
data_schema = {
|
||||
vol.Required("region", default=None): vol.In(regions),
|
||||
vol.Optional("currency", default=""): vol.In(currencys),
|
||||
vol.Optional("VAT", default=True): bool,
|
||||
vol.Optional("precision", default=3): vol.Coerce(int),
|
||||
vol.Optional("low_price_cutoff", default=1.0): vol.Coerce(float),
|
||||
vol.Optional("price_in_cents", default=False): bool,
|
||||
vol.Optional("price_type", default="kWh"): vol.In(price_types),
|
||||
vol.Optional("additional_costs", default=""): str,
|
||||
}
|
||||
|
||||
placeholders = {
|
||||
"region": regions,
|
||||
"currency": currencys,
|
||||
"price_type": price_types,
|
||||
"additional_costs": "{{0.0|float}}",
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(data_schema),
|
||||
description_placeholders=placeholders,
|
||||
errors=self._errors,
|
||||
)
|
||||
|
||||
async def _valid_template(self, user_template):
|
||||
try:
|
||||
#
|
||||
ut = Template(user_template, self.hass).async_render(
|
||||
current_price=0,
|
||||
) # Add current price as 0 as we dont know it yet..
|
||||
_LOGGER.debug("user_template %s value %s", user_template, ut)
|
||||
|
||||
if isinstance(ut, float):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except Exception as e:
|
||||
_LOGGER.error(e)
|
||||
pass
|
||||
return False
|
||||
|
||||
async def async_step_import(self, user_input): # pylint: disable=unused-argument
|
||||
"""Import a config entry.
|
||||
Special type of import, we're not actually going to store any data.
|
||||
Instead, we're going to rely on the values that are in config file.
|
||||
"""
|
||||
return self.async_create_entry(title="configuration.yaml", data={})
|
||||
@@ -0,0 +1,135 @@
|
||||
import voluptuous as vol
|
||||
from random import randint
|
||||
|
||||
DOMAIN = "nordpool"
|
||||
RANDOM_MINUTE = randint(10, 30)
|
||||
RANDOM_SECOND = randint(0, 59)
|
||||
EVENT_NEW_HOUR = "nordpool_update_hour"
|
||||
EVENT_NEW_DAY = "nordpool_update_day"
|
||||
EVENT_NEW_PRICE = "nordpool_update_new_price"
|
||||
SENTINEL = object()
|
||||
|
||||
_CURRENCY_LIST = ["DKK", "EUR", "NOK", "SEK"]
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
NAME = DOMAIN
|
||||
VERSION = "0.0.17"
|
||||
ISSUEURL = "https://github.com/custom-components/nordpool/issues"
|
||||
|
||||
|
||||
tzs = {
|
||||
"DK1": "Europe/Copenhagen",
|
||||
"DK2": "Europe/Copenhagen",
|
||||
"FI": "Europe/Helsinki",
|
||||
"EE": "Europe/Tallinn",
|
||||
"LT": "Europe/Vilnius",
|
||||
"LV": "Europe/Riga",
|
||||
"NO1": "Europe/Oslo",
|
||||
"NO2": "Europe/Oslo",
|
||||
"NO3": "Europe/Oslo",
|
||||
"NO4": "Europe/Oslo",
|
||||
"NO5": "Europe/Oslo",
|
||||
"SE1": "Europe/Stockholm",
|
||||
"SE2": "Europe/Stockholm",
|
||||
"SE3": "Europe/Stockholm",
|
||||
"SE4": "Europe/Stockholm",
|
||||
# What zone is this?
|
||||
"SYS": "Europe/Stockholm",
|
||||
"FR": "Europe/Paris",
|
||||
"NL": "Europe/Amsterdam",
|
||||
"BE": "Europe/Brussels",
|
||||
"AT": "Europe/Vienna",
|
||||
"GER": "Europe/Berlin",
|
||||
}
|
||||
|
||||
# List of page index for hourly data
|
||||
# Some are disabled as they don't contain the other currencies, NOK etc,
|
||||
# or there are some issues with data parsing for some ones' DataStartdate.
|
||||
# Lets come back and fix that later, just need to adjust the self._parser.
|
||||
# DataEnddate: "2021-02-11T00:00:00"
|
||||
# DataStartdate: "0001-01-01T00:00:00"
|
||||
COUNTRY_BASE_PAGE = {
|
||||
# "SYS": 17,
|
||||
"NO": 23,
|
||||
"SE": 29,
|
||||
"DK": 41,
|
||||
# "FI": 35,
|
||||
# "EE": 47,
|
||||
# "LT": 53,
|
||||
# "LV": 59,
|
||||
# "AT": 298578,
|
||||
# "BE": 298736,
|
||||
# "DE-LU": 299565,
|
||||
# "FR": 299568,
|
||||
# "NL": 299571,
|
||||
# "PL": 391921,
|
||||
}
|
||||
|
||||
AREA_TO_COUNTRY = {
|
||||
"SYS": "SYS",
|
||||
"SE1": "SE",
|
||||
"SE2": "SE",
|
||||
"SE3": "SE",
|
||||
"SE4": "SE",
|
||||
"FI": "FI",
|
||||
"DK1": "DK",
|
||||
"DK2": "DK",
|
||||
"OSLO": "NO",
|
||||
"KR.SAND": "NO",
|
||||
"BERGEN": "NO",
|
||||
"MOLDE": "NO",
|
||||
"TR.HEIM": "NO",
|
||||
"TROMSØ": "NO",
|
||||
"EE": "EE",
|
||||
"LV": "LV",
|
||||
"LT": "LT",
|
||||
"AT": "AT",
|
||||
"BE": "BE",
|
||||
"DE-LU": "DE-LU",
|
||||
"FR": "FR",
|
||||
"NL": "NL",
|
||||
"PL ": "PL",
|
||||
}
|
||||
|
||||
INVALID_VALUES = frozenset((None, float("inf")))
|
||||
|
||||
DEFAULT_TEMPLATE = "{{0.0|float}}"
|
||||
|
||||
|
||||
_CENT_MULTIPLIER = 100
|
||||
_PRICE_IN = {"kWh": 1000, "MWh": 1, "Wh": 1000 * 1000}
|
||||
_REGIONS = {
|
||||
"DK1": ["DKK", "Denmark", 0.25],
|
||||
"DK2": ["DKK", "Denmark", 0.25],
|
||||
"FI": ["EUR", "Finland", 0.255],
|
||||
"EE": ["EUR", "Estonia", 0.22],
|
||||
"LT": ["EUR", "Lithuania", 0.21],
|
||||
"LV": ["EUR", "Latvia", 0.21],
|
||||
"NO1": ["NOK", "Norway", 0.25],
|
||||
"NO2": ["NOK", "Norway", 0.25],
|
||||
"NO3": ["NOK", "Norway", 0.25],
|
||||
"NO4": ["NOK", "Norway", 0.25],
|
||||
"NO5": ["NOK", "Norway", 0.25],
|
||||
"SE1": ["SEK", "Sweden", 0.25],
|
||||
"SE2": ["SEK", "Sweden", 0.25],
|
||||
"SE3": ["SEK", "Sweden", 0.25],
|
||||
"SE4": ["SEK", "Sweden", 0.25],
|
||||
# What zone is this?
|
||||
"SYS": ["EUR", "System zone", 0.25],
|
||||
"FR": ["EUR", "France", 0.055],
|
||||
"NL": ["EUR", "Netherlands", 0.21],
|
||||
"BE": ["EUR", "Belgium", 0.06],
|
||||
"AT": ["EUR", "Austria", 0.20],
|
||||
# Unsure about tax rate, correct if wrong
|
||||
"GER": ["EUR", "Germany", 0.23],
|
||||
}
|
||||
|
||||
# Needed incase a user wants the prices in non local currency
|
||||
_CURRENCY_TO_LOCAL = {"DKK": "Kr", "NOK": "Kr", "SEK": "Kr", "EUR": "€"}
|
||||
_CURRENTY_TO_CENTS = {"DKK": "Øre", "NOK": "Øre", "SEK": "Öre", "EUR": "c"}
|
||||
|
||||
DEFAULT_CURRENCY = "NOK"
|
||||
DEFAULT_REGION = list(_REGIONS.keys())[0]
|
||||
DEFAULT_NAME = "Elspot"
|
||||
@@ -0,0 +1,96 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
#
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, HassJob
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_time_interval,
|
||||
async_track_point_in_utc_time,
|
||||
)
|
||||
|
||||
# For targeted patching in tests
|
||||
time_tracker_utcnow = dt_util.utcnow
|
||||
|
||||
|
||||
__ALL__ = ["async_track_time_change_in_tz"]
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def async_track_utc_time_change(
|
||||
hass: HomeAssistant,
|
||||
action: None,
|
||||
hour: Optional[Any] = None,
|
||||
minute: Optional[Any] = None,
|
||||
second: Optional[Any] = None,
|
||||
tz: Optional[Any] = None,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Add a listener that will fire if time matches a pattern."""
|
||||
# This is function is modifies to support timezones.
|
||||
|
||||
# We do not have to wrap the function with time pattern matching logic
|
||||
# if no pattern given
|
||||
if all(val is None for val in (hour, minute, second)):
|
||||
# Previously this relied on EVENT_TIME_FIRED
|
||||
# which meant it would not fire right away because
|
||||
# the caller would always be misaligned with the call
|
||||
# time vs the fire time by < 1s. To preserve this
|
||||
# misalignment we use async_track_time_interval here
|
||||
return async_track_time_interval(hass, action, timedelta(seconds=1))
|
||||
|
||||
job = HassJob(action)
|
||||
matching_seconds = dt_util.parse_time_expression(second, 0, 59)
|
||||
matching_minutes = dt_util.parse_time_expression(minute, 0, 59)
|
||||
matching_hours = dt_util.parse_time_expression(hour, 0, 23)
|
||||
|
||||
def calculate_next(now: datetime) -> datetime:
|
||||
"""Calculate and set the next time the trigger should fire."""
|
||||
ts_now = now.astimezone(tz) if tz else now
|
||||
return dt_util.find_next_time_expression_time(
|
||||
ts_now, matching_seconds, matching_minutes, matching_hours
|
||||
)
|
||||
|
||||
time_listener: CALLBACK_TYPE | None = None
|
||||
|
||||
@callback
|
||||
def pattern_time_change_listener(_: datetime) -> None:
|
||||
"""Listen for matching time_changed events."""
|
||||
nonlocal time_listener
|
||||
|
||||
now = time_tracker_utcnow()
|
||||
hass.async_run_hass_job(job, now.astimezone(tz) if tz else now)
|
||||
|
||||
time_listener = async_track_point_in_utc_time(
|
||||
hass,
|
||||
pattern_time_change_listener,
|
||||
calculate_next(now + timedelta(seconds=1)),
|
||||
)
|
||||
|
||||
time_listener = async_track_point_in_utc_time(
|
||||
hass, pattern_time_change_listener, calculate_next(dt_util.utcnow())
|
||||
)
|
||||
|
||||
@callback
|
||||
def unsub_pattern_time_change_listener() -> None:
|
||||
"""Cancel the time listener."""
|
||||
assert time_listener is not None
|
||||
time_listener()
|
||||
|
||||
return unsub_pattern_time_change_listener
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def async_track_time_change_in_tz(
|
||||
hass: HomeAssistant,
|
||||
action: None,
|
||||
# action: Callable[[datetime], Awaitable[None] | None],
|
||||
hour: Optional[Any] = None,
|
||||
minute: Optional[Any] = None,
|
||||
second: Optional[Any] = None,
|
||||
tz: Optional[Any] = None,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Add a listener that will fire if UTC time matches a pattern."""
|
||||
return async_track_utc_time_change(hass, action, hour, minute, second, tz)
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"domain": "nordpool",
|
||||
"name": "Nord Pool",
|
||||
"after_dependencies": [
|
||||
"http"
|
||||
],
|
||||
"codeowners": [
|
||||
"@hellowlol"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/custom-components/nordpool/",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/custom-components/nordpool/issues",
|
||||
"requirements": [
|
||||
"backoff"
|
||||
],
|
||||
"version": "0.0.17"
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from operator import itemgetter
|
||||
from statistics import mean
|
||||
from decimal import Decimal
|
||||
|
||||
import pytz
|
||||
from homeassistant.util import dt as dt_util
|
||||
from pytz import timezone
|
||||
|
||||
UTC = pytz.utc
|
||||
|
||||
__all__ = [
|
||||
"is_new",
|
||||
"has_junk",
|
||||
"extract_attrs",
|
||||
"start_of",
|
||||
"end_of",
|
||||
"stock",
|
||||
"add_junk",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
stockholm_tz = timezone("Europe/Stockholm")
|
||||
|
||||
|
||||
def exceptions_raiser():
|
||||
"""Utility to check that all exceptions are raised."""
|
||||
import aiohttp
|
||||
import random
|
||||
|
||||
exs = [KeyError, aiohttp.ClientError, None, None, None]
|
||||
got = random.choice(exs)
|
||||
if got is None:
|
||||
pass
|
||||
else:
|
||||
raise got
|
||||
|
||||
|
||||
def round_decimal(number, decimal_places=3):
|
||||
decimal_value = Decimal(number)
|
||||
return decimal_value.quantize(Decimal(10) ** -decimal_places)
|
||||
|
||||
|
||||
def add_junk(d):
|
||||
for key in ["Average", "Min", "Max", "Off-peak 1", "Off-peak 2", "Peak"]:
|
||||
d[key] = float("inf")
|
||||
|
||||
return d
|
||||
|
||||
|
||||
def stock(d):
|
||||
"""convert datetime to stocholm time."""
|
||||
return d.astimezone(stockholm_tz)
|
||||
|
||||
|
||||
def start_of(d, typ_="hour"):
|
||||
if typ_ == "hour":
|
||||
return d.replace(minute=0, second=0, microsecond=0)
|
||||
elif typ_ == "day":
|
||||
return d.replace(hour=0, minute=0, microsecond=0)
|
||||
|
||||
|
||||
def time_in_range(start, end, x):
|
||||
"""Return true if x is in the range [start, end]"""
|
||||
if start <= end:
|
||||
return start <= x <= end
|
||||
else:
|
||||
return start <= x or x <= end
|
||||
|
||||
|
||||
def end_of(d, typ_="hour"):
|
||||
"""Return end our hour"""
|
||||
if typ_ == "hour":
|
||||
return d.replace(minute=59, second=59, microsecond=999999)
|
||||
elif typ_ == "day":
|
||||
return d.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
|
||||
def is_new(date=None, typ="day") -> bool:
|
||||
"""Utility to check if its a new hour or day."""
|
||||
# current = pendulum.now()
|
||||
current = dt_util.now()
|
||||
if typ == "day":
|
||||
if date.date() != current.date():
|
||||
_LOGGER.debug("Its a new day!")
|
||||
return True
|
||||
return False
|
||||
|
||||
elif typ == "hour":
|
||||
if current.hour != date.hour:
|
||||
_LOGGER.debug("Its a new hour!")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_inf(d):
|
||||
if d == float("inf"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_junk(data) -> bool:
|
||||
"""Check if data has some infinity values.
|
||||
|
||||
Args:
|
||||
data (dict): Holds the data from the api.
|
||||
|
||||
Returns:
|
||||
TYPE: True if there is any infinity values else False
|
||||
"""
|
||||
cp = dict(data)
|
||||
cp.pop("values", None)
|
||||
if any(map(is_inf, cp.values())):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_attrs(data) -> dict:
|
||||
"""extract attrs"""
|
||||
d = defaultdict(list)
|
||||
items = [i.get("value") for i in data]
|
||||
|
||||
if len(data):
|
||||
data = sorted(data, key=itemgetter("start"))
|
||||
offpeak1 = [i.get("value") for i in data[0:8]]
|
||||
peak = [i.get("value") for i in data[8:20]]
|
||||
offpeak2 = [i.get("value") for i in data[20:]]
|
||||
|
||||
d["Peak"] = mean(peak)
|
||||
d["Off-peak 1"] = mean(offpeak1)
|
||||
d["Off-peak 2"] = mean(offpeak2)
|
||||
d["Average"] = mean(items)
|
||||
d["Min"] = min(items)
|
||||
d["Max"] = max(items)
|
||||
|
||||
return d
|
||||
|
||||
return data
|
||||
@@ -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()
|
||||
@@ -0,0 +1,149 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import _REGIONS
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_setting(value):
|
||||
def validator(value):
|
||||
c = any([i for i in value if i in list(_REGIONS.keys())])
|
||||
if c is not True:
|
||||
vol.Invalid(
|
||||
f"{value} in not in on of the supported areas {','.join(_REGIONS.keys())}"
|
||||
)
|
||||
return value
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
HOURLY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("currency"): str,
|
||||
vol.Required("date"): cv.date,
|
||||
vol.Required("area"): check_setting(cv.ensure_list),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
YEAR_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("currency"): str,
|
||||
vol.Required("year", default=dt_util.now().strftime("Y")): cv.matches_regex(
|
||||
r"^[1|2]\d{3}$"
|
||||
),
|
||||
vol.Required("area"): check_setting(cv.ensure_list),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_services(hass: HomeAssistant):
|
||||
_LOGGER.debug("Setting up services")
|
||||
from .aio_price import AioPrices
|
||||
|
||||
client = async_get_clientsession(hass)
|
||||
|
||||
async def hourly(service_call: ServiceCall) -> Any:
|
||||
sc = service_call.data
|
||||
_LOGGER.debug("called hourly with %r", sc)
|
||||
|
||||
# Convert the date to datetime as the rest of the code expects a datetime. We will want to keep date as it easier for ppl to use.
|
||||
end_date = datetime(
|
||||
year=sc["date"].year, month=sc["date"].month, day=sc["date"].day
|
||||
)
|
||||
|
||||
value = await AioPrices(sc["currency"], client).hourly(
|
||||
areas=sc["area"], end_date=end_date, raw=True
|
||||
)
|
||||
|
||||
_LOGGER.debug("Got value %r", value)
|
||||
return value
|
||||
|
||||
async def yearly(service_call: ServiceCall):
|
||||
sc = service_call.data
|
||||
_LOGGER.debug("called yearly with %r", sc)
|
||||
|
||||
value = await AioPrices(sc["currency"], client).yearly(
|
||||
areas=sc["area"], end_date=sc["year"]
|
||||
)
|
||||
|
||||
_LOGGER.debug("Got value %r", value)
|
||||
return value
|
||||
|
||||
async def weekly(service_call: ServiceCall):
|
||||
sc = service_call.data
|
||||
_LOGGER.debug("called weekly with %r", sc)
|
||||
|
||||
value = await AioPrices(sc["currency"], client).yearly(
|
||||
areas=sc["area"], end_date=sc["year"]
|
||||
)
|
||||
|
||||
_LOGGER.debug("Got value %r", value)
|
||||
return value
|
||||
|
||||
async def monthly(service_call: ServiceCall):
|
||||
sc = service_call.data
|
||||
_LOGGER.debug("called monthly with %r", sc)
|
||||
|
||||
value = await AioPrices(sc["currency"], client).monthly(
|
||||
areas=sc["area"], end_date=sc["year"]
|
||||
)
|
||||
_LOGGER.debug("Got value %r", value)
|
||||
return value
|
||||
|
||||
async def daily(service_call: ServiceCall):
|
||||
sc = service_call.data
|
||||
_LOGGER.debug("called daily with %r", sc)
|
||||
|
||||
value = await AioPrices(sc["currency"], client).daily(
|
||||
areas=sc["area"], end_date=sc["year"]
|
||||
)
|
||||
_LOGGER.debug("Got value %r", value)
|
||||
return value
|
||||
|
||||
hass.services.async_register(
|
||||
domain="nordpool",
|
||||
service="hourly",
|
||||
service_func=hourly,
|
||||
schema=HOURLY_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
hass.services.async_register(
|
||||
domain="nordpool",
|
||||
service="yearly",
|
||||
service_func=yearly,
|
||||
schema=YEAR_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
hass.services.async_register(
|
||||
domain="nordpool",
|
||||
service="monthly",
|
||||
service_func=monthly,
|
||||
schema=YEAR_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
hass.services.async_register(
|
||||
domain="nordpool",
|
||||
service="daily",
|
||||
service_func=daily,
|
||||
schema=YEAR_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
hass.services.async_register(
|
||||
domain="nordpool",
|
||||
service="weekly",
|
||||
service_func=weekly,
|
||||
schema=YEAR_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
yearly:
|
||||
name: yearly
|
||||
description: >-
|
||||
Action that gets the raw aggrigated yearly prices from Nordpool
|
||||
fields:
|
||||
currency:
|
||||
description: "What currecy should the prices be returned in"
|
||||
example: "NOK"
|
||||
year:
|
||||
description: "For what year, default to currect year"
|
||||
example: "2024"
|
||||
area:
|
||||
description: "Return the prices for what price area"
|
||||
example: "NO2"
|
||||
|
||||
daily:
|
||||
name: daily
|
||||
description: >-
|
||||
Action that gets the raw aggrigated daily prices from Nordpool
|
||||
fields:
|
||||
currency:
|
||||
description: "What currecy should the prices be returned in"
|
||||
example: "NOK"
|
||||
year:
|
||||
description: "For what year, default to currect year"
|
||||
example: "2024"
|
||||
area:
|
||||
description: "Return the prices for what price area"
|
||||
example: "NO2"
|
||||
|
||||
|
||||
monthly:
|
||||
name: monthly
|
||||
description: >-
|
||||
Action that gets the raw aggrigated monthly prices from Nordpool
|
||||
fields:
|
||||
currency:
|
||||
description: "What currecy should the prices be returned in"
|
||||
example: "NOK"
|
||||
year:
|
||||
description: "For what year, default to currect year"
|
||||
example: "2024"
|
||||
area:
|
||||
description: "Return the prices for what price area"
|
||||
example: "NO2"
|
||||
|
||||
hourly:
|
||||
name: hourly
|
||||
description: >-
|
||||
Action that gets the raw hourly price for spesific date from Nordpool
|
||||
fields:
|
||||
currency:
|
||||
description: "What currecy should the prices be returned in"
|
||||
example: "NOK"
|
||||
date:
|
||||
description: "For what year, default to currect year"
|
||||
example: "YYYY-MM-DD"
|
||||
area:
|
||||
description: "Return the prices for what price area"
|
||||
example: "NO2"
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Nordpool",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Nordpool-sensor",
|
||||
"description": "Opret en Nordpool-sensor",
|
||||
"data": {
|
||||
"region": "Region",
|
||||
"friendly_name": "Visningsnavn",
|
||||
"currency": "Valuta",
|
||||
"VAT": "Inkludér moms",
|
||||
"precision": "Antal decimaler som vises",
|
||||
"low_price_cutoff": "Grænse for lav-pris",
|
||||
"price_in_cents": "Pris i øre",
|
||||
"price_type": "Prisformat",
|
||||
"additional_costs": "Skabelon for yderligere omkostninger"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"name_exists": "Navnet eksisterer allerede",
|
||||
"invalid_template": "Skabelonen indeholder fejl, se https://github.com/custom-components/nordpool"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Nordpool",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Nordpool Sensor",
|
||||
"description": "Setup a Nordpool sensor",
|
||||
"data": {
|
||||
"region": "Region",
|
||||
"currency": "Currency",
|
||||
"VAT": "Include VAT",
|
||||
"precision": "Decimal rounding precision",
|
||||
"low_price_cutoff": "Low price percentage",
|
||||
"price_in_cents": "Price in cents",
|
||||
"price_type": "Energy scale",
|
||||
"additional_costs": "Template for additional costs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"name_exists": "Name already exists",
|
||||
"invalid_template": "The template is invalid, check https://github.com/custom-components/nordpool"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Elektri börsihind",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Andur",
|
||||
"description": "Seadista elektrihinna andur",
|
||||
"data": {
|
||||
"region": "Piirkond",
|
||||
"friendly_name": "Kuvatav nimi",
|
||||
"currency": "EUR",
|
||||
"VAT": "Koos käibemaksuga",
|
||||
"precision": "Mitu kohta peale koma",
|
||||
"low_price_cutoff": "Madala hinna tase",
|
||||
"price_in_cents": "Hind sentides",
|
||||
"price_type": "Hinna vorming",
|
||||
"additional_costs": "Lisanduvate hindade mall"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"name_exists": "See nimi on juba kasutusel",
|
||||
"invalid_template": "See mall on vigane, vaata https://github.com/custom-components/nordpool"
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Nordpool",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Nordpool sensori",
|
||||
"description": "Nordpool sensorin asetukset",
|
||||
"data": {
|
||||
"region": "Alue",
|
||||
"friendly_name": "Nimi käyttöliittymässä",
|
||||
"currency": "EUR",
|
||||
"VAT": "Sisällytä ALV",
|
||||
"precision": "Desimaalien lukumäärä",
|
||||
"low_price_cutoff": "Matalan hinnan raja-arvo",
|
||||
"price_in_cents": "Hinta senteissä",
|
||||
"price_type": "Hinnan yksikkö",
|
||||
"additional_costs": "Sivukulujen laskenta Template"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"name_exists": "Nimi on jo käytössä",
|
||||
"invalid_template": "Template on virheellinen, katso https://github.com/custom-components/nordpool"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Nordpool",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Nordpool Sensors",
|
||||
"description": "Iestatīt Nordpool biržas sensoru",
|
||||
"data": {
|
||||
"region": "Reģions",
|
||||
"currency": "Valūta",
|
||||
"VAT": "Iekļaut PVN",
|
||||
"precision": "Decimāldaļas noapaļošanas precizitāte",
|
||||
"low_price_cutoff": "Zemās cenas līmenis procentos",
|
||||
"price_in_cents": "Cena centos",
|
||||
"price_type": "Cenas formāts",
|
||||
"additional_costs": "Papildus izmaksu veidne"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"name_exists": "Šāds nosaukums jau eksistē",
|
||||
"invalid_template": "Veidne nav korekta, skatīt https://github.com/custom-components/nordpool"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Nordpool",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Nordpool Sensor",
|
||||
"description": "Opprett en Nordpool sensor",
|
||||
"data": {
|
||||
"region": "Region",
|
||||
"currency": "NOK",
|
||||
"VAT": "Inkluder MVA",
|
||||
"precision": "Antall desimaler som vises",
|
||||
"low_price_cutoff": "Grense for lav-pris",
|
||||
"price_in_cents": "Pris i øre",
|
||||
"price_type": "Pris i format",
|
||||
"additional_costs": "Mal for tilleggskostnader"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"name_exists": "Navnet eksisterer allerede",
|
||||
"invalid_template": "Malen inneholder feil, se https://github.com/custom-components/nordpool"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Nordpool",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Nordpool Sensor",
|
||||
"description": "Configureer een Nordpool sensor",
|
||||
"data": {
|
||||
"region": "Regio",
|
||||
"currency": "Munteenheid",
|
||||
"VAT": "Inclusief BTW",
|
||||
"precision": "Decimalen afronden",
|
||||
"low_price_cutoff": "Precentage lage prijs",
|
||||
"price_in_cents": "Prijs in centen",
|
||||
"price_type": "Energie schaal",
|
||||
"additional_costs": "Template voor extra kosten"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"name_exists": "Naam bestaat al",
|
||||
"invalid_template": "Het template is ongeldig, zie https://github.com/custom-components/nordpool"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Nordpool",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Nordpool Sensor",
|
||||
"description": "Opprett ein Nordpool-sensor",
|
||||
"data": {
|
||||
"region": "Region",
|
||||
"currency": "NOK",
|
||||
"VAT": "Inkluder MVA",
|
||||
"precision": "Talet på desimal som vert vist",
|
||||
"low_price_cutoff": "Grense for låg-pris",
|
||||
"price_in_cents": "Pris i øre",
|
||||
"price_type": "Pris i format",
|
||||
"additional_costs": "Mal for tilleggskostnadar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"name_exists": "Namnet eksisterer allereie",
|
||||
"invalid_template": "Malen inneheld ein feil, sjå https://github.com/custom-components/nordpool"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Nordpool",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Nordpool snímač",
|
||||
"description": "Nastavte snímač Nordpool",
|
||||
"data": {
|
||||
"region": "Región",
|
||||
"currency": "Mena",
|
||||
"VAT": "Vrátane DPH",
|
||||
"precision": "Presnosť desatinného zaokrúhľovania",
|
||||
"low_price_cutoff": "Nízke percento ceny",
|
||||
"price_in_cents": "Cena v centoch",
|
||||
"price_type": "Energetická stupnica",
|
||||
"additional_costs": "Šablóna pre dodatočné náklady"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"name_exists": "Názov už existuje",
|
||||
"invalid_template": "Šablóna je neplatná, skontrolujte https://github.com/custom-components/nordpool"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "Nord Pool",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Sensor",
|
||||
"description": "Konfigurera en Nord Pool-sensor",
|
||||
"data": {
|
||||
"region": "Region",
|
||||
"friendly_name": "Visningsnamn",
|
||||
"currency": "SEK",
|
||||
"VAT": "Inkludera moms",
|
||||
"precision": "Hur många decimaler ska visas",
|
||||
"low_price_cutoff": "Lägsta prisnivå",
|
||||
"price_in_cents": "Pris i ören",
|
||||
"price_type": "Prisformat",
|
||||
"additional_costs": "Mall för ytterligare kostnader"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"name_exists": "Namnet existerar redan",
|
||||
"invalid_template": "Mallen är ogiltig, se https://github.com/custom-components/nordpool"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user