Initial Home Assistant Configuration

This commit is contained in:
root
2025-09-11 10:47:34 +03:00
commit ac8b542e1b
2360 changed files with 41412 additions and 0 deletions
+238
View File
@@ -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)
+355
View File
@@ -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")
+98
View File
@@ -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={})
+135
View File
@@ -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"
+96
View File
@@ -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)
+19
View File
@@ -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"
}
+140
View File
@@ -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
+488
View File
@@ -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()
+149
View File
@@ -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,
)
+60
View File
@@ -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"
}
}
}