429 files
This commit is contained in:
1 parent
472c19bceb
commit
963cce81a7
566 files changed
+50311
-47616
No files matched your search
@@ -1,6 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import re
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
@@ -27,6 +28,15 @@ from .const import (
|
||||
)
|
||||
from .errors import SensorConfigurationError
|
||||
|
||||
_HAS_CHILD_DEVICES = hasattr(dr, "ChildDeviceEntry")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.device_registry import AnyDeviceEntry as AnyDeviceEntry
|
||||
else:
|
||||
# AnyDeviceEntry was introduced together with child devices in HA 2026.9.
|
||||
# Keep imports working on older supported versions, where every entry is a DeviceEntry.
|
||||
AnyDeviceEntry = dr.DeviceEntry
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceEntity:
|
||||
@@ -38,7 +48,7 @@ class SourceEntity:
|
||||
unique_id: str | None = None
|
||||
name: str | None = None
|
||||
entity_entry: er.RegistryEntry | None = None
|
||||
device_entry: dr.DeviceEntry | None = None
|
||||
device_entry: AnyDeviceEntry | None = None
|
||||
config_entry_id: str | None = None
|
||||
|
||||
@property
|
||||
@@ -92,6 +102,13 @@ def is_number(value: str) -> bool:
|
||||
return math.isfinite(fvalue)
|
||||
|
||||
|
||||
def get_main_device_entry(device_registry: dr.DeviceRegistry, device_id: str) -> dr.DeviceEntry | None:
|
||||
"""Return a main device entry, excluding child devices on versions which support them."""
|
||||
if _HAS_CHILD_DEVICES:
|
||||
return device_registry.async_get(device_id, include_child_devices=False)
|
||||
return cast(dr.DeviceEntry | None, device_registry.async_get(device_id)) # pragma: no cover
|
||||
|
||||
|
||||
def create_source_entity(entity_id: str, hass: HomeAssistant) -> SourceEntity:
|
||||
"""Create object containing all information about the source entity."""
|
||||
|
||||
@@ -138,7 +155,7 @@ def get_wrapped_entity_name(
|
||||
entity_id: str,
|
||||
object_id: str,
|
||||
entity_entry: er.RegistryEntry | None,
|
||||
device_entry: dr.DeviceEntry | None,
|
||||
device_entry: AnyDeviceEntry | None,
|
||||
) -> str:
|
||||
"""Construct entity name based on the wrapped entity"""
|
||||
if entity_entry is None:
|
||||
@@ -156,7 +173,7 @@ def get_wrapped_entity_name(
|
||||
|
||||
def _get_device_entity_name(
|
||||
entity_entry: er.RegistryEntry,
|
||||
device_entry: dr.DeviceEntry | None,
|
||||
device_entry: AnyDeviceEntry | None,
|
||||
) -> str | None:
|
||||
if not entity_entry.has_entity_name or device_entry is None:
|
||||
return None
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from dataclasses import replace
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_DEVICE
|
||||
@@ -12,37 +13,45 @@ import homeassistant.helpers.entity_registry as er
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.common import AnyDeviceEntry, SourceEntity
|
||||
from custom_components.powercalc.const import CONF_AREA
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_HAS_SINGLE_CONFIG_ENTRY = hasattr(DeviceEntry, "config_entry_id")
|
||||
_HAS_CHILD_DEVICES = hasattr(device_registry, "ChildDeviceEntry")
|
||||
|
||||
|
||||
def is_composite_device_id(hass: HomeAssistant, device_id: str) -> bool:
|
||||
"""
|
||||
Return whether a device ID identifies a legacy composite device.
|
||||
Check for availability of async_is_composite_device_id, because this function is only available in HA >=2026.8
|
||||
"""
|
||||
"""Return whether a device ID identifies a legacy composite device."""
|
||||
device_reg = device_registry.async_get(hass)
|
||||
if _HAS_CHILD_DEVICES:
|
||||
return (
|
||||
device_reg.async_get(device_id) is not None
|
||||
and device_reg.async_get(
|
||||
device_id,
|
||||
include_composite_devices=False,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
is_composite = getattr(device_reg, "async_is_composite_device_id", None)
|
||||
if not callable(is_composite):
|
||||
return False
|
||||
return bool(is_composite(device_id))
|
||||
return False # pragma: no cover
|
||||
return bool(is_composite(device_id)) # pragma: no cover
|
||||
|
||||
|
||||
def get_non_composite_devices(hass: HomeAssistant) -> list[DeviceEntry]:
|
||||
"""Return all registered devices which are not legacy composite devices.
|
||||
|
||||
Resolves the registry and the composite device support once, instead of per device like
|
||||
a per-entry `is_composite_device_id` call would.
|
||||
Composite devices are synthesized on lookup and are not included in registry enumeration.
|
||||
"""
|
||||
device_reg = device_registry.async_get(hass)
|
||||
is_composite = getattr(device_reg, "async_is_composite_device_id", None)
|
||||
if not callable(is_composite):
|
||||
return list(device_reg.devices.values())
|
||||
return [device for device in device_reg.devices.values() if not is_composite(device.id)]
|
||||
return (
|
||||
list(device_reg.devices)
|
||||
if _HAS_CHILD_DEVICES
|
||||
else list(cast(dict[str, DeviceEntry], device_reg.devices).values()) # pragma: no cover
|
||||
)
|
||||
|
||||
|
||||
def get_related_device_ids(hass: HomeAssistant, device_id: str) -> set[str]:
|
||||
@@ -58,7 +67,7 @@ def get_related_device_ids(hass: HomeAssistant, device_id: str) -> set[str]:
|
||||
related.update(device.id for device in _get_composite_split_devices(device_reg, device_id))
|
||||
|
||||
device = device_reg.async_get(device_id)
|
||||
if device is None:
|
||||
if not isinstance(device, DeviceEntry):
|
||||
return related
|
||||
|
||||
sibling_devices = _get_composite_split_devices(device_reg, getattr(device, "composite_device_id", None))
|
||||
@@ -93,7 +102,7 @@ def _get_composite_split_devices(
|
||||
return list(get_split_devices(composite_device_id))
|
||||
|
||||
|
||||
def get_config_entry_ids(device: DeviceEntry) -> set[str]:
|
||||
def get_config_entry_ids(device: AnyDeviceEntry) -> set[str]:
|
||||
"""
|
||||
Return the config entry IDs a device belongs to.
|
||||
HA >=2026.8 splits composite devices, so a device belongs to exactly one config entry and
|
||||
@@ -111,7 +120,8 @@ def get_first_device_for_config_entry(hass: HomeAssistant, config_entry_id: str)
|
||||
|
||||
def get_devices_for_config_entry(hass: HomeAssistant, config_entry_id: str) -> list[DeviceEntry]:
|
||||
"""Return all non-composite devices belonging to a config entry."""
|
||||
return [device for device in get_non_composite_devices(hass) if config_entry_id in get_config_entry_ids(device)]
|
||||
device_reg = device_registry.async_get(hass)
|
||||
return list(device_registry.async_entries_for_config_entry(device_reg, config_entry_id))
|
||||
|
||||
|
||||
def get_related_devices(hass: HomeAssistant, device_id: str) -> list[DeviceEntry]:
|
||||
@@ -168,7 +178,7 @@ def get_device_entry(
|
||||
sensor_config: ConfigType | None = None,
|
||||
source_entity: SourceEntity | None = None,
|
||||
config_entry: ConfigEntry | None = None,
|
||||
) -> DeviceEntry | None:
|
||||
) -> AnyDeviceEntry | None:
|
||||
"""
|
||||
Get device entry for a given powercalc entity configuration.
|
||||
Prefer user configured device, when it is not set fallback to the same device as the source entity
|
||||
@@ -193,7 +203,7 @@ def get_device_entry(
|
||||
def bind_entity_to_registry_metadata(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str | None,
|
||||
device_entry: DeviceEntry | None,
|
||||
device_entry: AnyDeviceEntry | None,
|
||||
sensor_config: ConfigType | None,
|
||||
) -> None:
|
||||
"""Bind a Powercalc entity to configured registry metadata."""
|
||||
@@ -213,7 +223,7 @@ def bind_entity_to_registry_metadata(
|
||||
def bind_entity_to_device(
|
||||
entity_reg: er.EntityRegistry,
|
||||
entity_entry: RegistryEntry,
|
||||
device_entry: DeviceEntry | None,
|
||||
device_entry: AnyDeviceEntry | None,
|
||||
) -> None:
|
||||
"""Bind a Powercalc entity to the resolved device."""
|
||||
# Home Assistant only consumes entity.device_entry while creating registry
|
||||
|
||||
@@ -753,7 +753,7 @@ class DiscoveryManager:
|
||||
def extract_model_info(self, source_entity: SourceEntity) -> ModelInfo | None:
|
||||
"""Try to fetch manufacturer and model from the known device information."""
|
||||
device_entry = source_entity.device_entry
|
||||
if not device_entry:
|
||||
if not isinstance(device_entry, dr.DeviceEntry):
|
||||
return None
|
||||
|
||||
log_identifier = source_entity.log_identifier
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"requirements": [
|
||||
"numpy>=1.21.1"
|
||||
],
|
||||
"version": "v1.25.1"
|
||||
"version": "v1.25.2"
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import json
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Any, NotRequired, TypedDict, cast
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientError
|
||||
@@ -33,6 +36,106 @@ ENDPOINT_LIBRARY = f"{API_URL}/library"
|
||||
ENDPOINT_DOWNLOAD = f"{API_URL}/download"
|
||||
|
||||
TIMEOUT_SECONDS = 30
|
||||
MODEL_JSON_RETRY_LIMIT = 2
|
||||
|
||||
ALLOWED_RESOURCE_HOSTS = frozenset({"github.com", "raw.githubusercontent.com"})
|
||||
|
||||
|
||||
def _validate_resource_url(url: object) -> str:
|
||||
"""Validate that a resource URL points to an allowed HTTPS host."""
|
||||
if not isinstance(url, str):
|
||||
raise ProfileDownloadError("Remote profile resource has an invalid URL")
|
||||
|
||||
try:
|
||||
parsed_url = urlsplit(url)
|
||||
is_allowed = (
|
||||
parsed_url.scheme == "https"
|
||||
and parsed_url.hostname in ALLOWED_RESOURCE_HOSTS
|
||||
and parsed_url.username is None
|
||||
and parsed_url.password is None
|
||||
and parsed_url.port in (None, 443)
|
||||
)
|
||||
except ValueError as err:
|
||||
raise ProfileDownloadError(f"Remote profile resource has an invalid URL: {url}") from err
|
||||
|
||||
if not is_allowed:
|
||||
raise ProfileDownloadError(f"Remote profile resource URL is not allowed: {url}")
|
||||
return url
|
||||
|
||||
|
||||
def _resolve_resource_path(storage_path: str, resource_path: object) -> Path:
|
||||
"""Resolve and validate a resource path within the profile storage directory."""
|
||||
if not isinstance(resource_path, str) or not resource_path or "\0" in resource_path:
|
||||
raise ProfileDownloadError("Remote profile resource has an invalid path")
|
||||
|
||||
relative_path = Path(resource_path)
|
||||
if relative_path.is_absolute():
|
||||
raise ProfileDownloadError(f"Remote profile resource path is not allowed: {resource_path}")
|
||||
|
||||
try:
|
||||
storage_directory = Path(storage_path).resolve()
|
||||
destination = (storage_directory / relative_path).resolve()
|
||||
except (OSError, ValueError) as err:
|
||||
raise ProfileDownloadError(f"Remote profile resource has an invalid path: {resource_path}") from err
|
||||
if destination == storage_directory or not destination.is_relative_to(storage_directory):
|
||||
raise ProfileDownloadError(f"Remote profile resource path is not allowed: {resource_path}")
|
||||
return destination
|
||||
|
||||
|
||||
def _validate_resources(resources: object, storage_path: str) -> list[tuple[str, Path]]:
|
||||
"""Validate all resources in a remote profile response."""
|
||||
if not isinstance(resources, list) or not all(isinstance(resource, dict) for resource in resources):
|
||||
raise ProfileDownloadError("Remote profile response contains invalid resources")
|
||||
|
||||
return [
|
||||
(_validate_resource_url(resource.get("url")), _resolve_resource_path(storage_path, resource.get("path")))
|
||||
for resource in resources
|
||||
]
|
||||
|
||||
|
||||
def _sync_directory(directory: Path) -> None:
|
||||
"""Persist a directory entry, so a completed rename survives an unclean shutdown."""
|
||||
try:
|
||||
directory_descriptor = os.open(directory, os.O_RDONLY)
|
||||
except OSError: # pragma: no cover - directories cannot be opened on all platforms
|
||||
return
|
||||
try:
|
||||
os.fsync(directory_descriptor)
|
||||
except OSError: # pragma: no cover - directory fsync is not supported on all platforms
|
||||
pass
|
||||
finally:
|
||||
os.close(directory_descriptor)
|
||||
|
||||
|
||||
def _save_resource(data: bytes, path: Path) -> None:
|
||||
"""Atomically save a downloaded resource to the local profile storage directory.
|
||||
|
||||
The contents are flushed to disk before the rename, and the directory entry is flushed
|
||||
after it. Without both, a power loss shortly after an update can leave the new file name
|
||||
pointing at unwritten data, which is how a cached profile ends up as invalid JSON.
|
||||
"""
|
||||
os.makedirs(path.parent, exist_ok=True)
|
||||
file_descriptor, temporary_name = tempfile.mkstemp(
|
||||
dir=path.parent,
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
)
|
||||
temporary_path = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(file_descriptor, "wb") as file_handle:
|
||||
file_handle.write(data)
|
||||
file_handle.flush()
|
||||
os.fsync(file_handle.fileno())
|
||||
os.replace(temporary_path, path)
|
||||
_sync_directory(path.parent)
|
||||
finally:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _save_resources(resources: list[tuple[bytes, Path]]) -> None:
|
||||
"""Save all downloaded resources after every response has completed successfully."""
|
||||
for data, path in resources:
|
||||
_save_resource(data, path)
|
||||
|
||||
|
||||
class LibraryModel(TypedDict):
|
||||
@@ -64,6 +167,7 @@ class RemoteLoader(Loader):
|
||||
self.model_lookup: dict[str, dict[str, list[LibraryModel]]] = {}
|
||||
self.manufacturer_lookup: dict[str, set[str]] = {}
|
||||
self.profile_hashes: dict[str, str] = {}
|
||||
self._model_load_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||||
|
||||
async def initialize(self, prefer_cached: bool = False) -> None:
|
||||
"""Initialize the loader.
|
||||
@@ -188,18 +292,26 @@ class RemoteLoader(Loader):
|
||||
return str(self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, "library.json"))
|
||||
|
||||
def _read_local_library_json(self) -> dict[str, Any] | None:
|
||||
"""Read library.json from local storage, None when it has not been downloaded yet."""
|
||||
"""Read library.json from local storage, None when it is missing or unusable.
|
||||
|
||||
A truncated or unreadable copy is reported as absent rather than raised, so the caller
|
||||
falls through to a fresh download instead of failing setup on every restart.
|
||||
"""
|
||||
local_path = self._get_library_json_path()
|
||||
if not os.path.exists(local_path):
|
||||
return None
|
||||
with open(local_path) as f:
|
||||
return cast(dict[str, Any], json.load(f))
|
||||
try:
|
||||
with open(local_path) as f:
|
||||
return cast(dict[str, Any], json.load(f))
|
||||
except (JSONDecodeError, OSError) as err:
|
||||
_LOGGER.warning("Local library.json is unusable (%s), discarding it and downloading a fresh copy", err)
|
||||
return None
|
||||
|
||||
def _load_local_library_json(self) -> dict[str, Any]:
|
||||
"""Load library.json from local storage, raising when it is not there."""
|
||||
"""Load library.json from local storage, raising when it is not usable."""
|
||||
library_json = self._read_local_library_json()
|
||||
if library_json is None:
|
||||
raise ProfileDownloadError("Local library.json file not found")
|
||||
raise ProfileDownloadError("Local library.json file not found or unusable")
|
||||
return library_json
|
||||
|
||||
async def _download_remote_library_json(self) -> dict[str, Any] | None:
|
||||
@@ -224,12 +336,7 @@ class RemoteLoader(Loader):
|
||||
except (TimeoutError, ClientError) as err:
|
||||
raise ProfileDownloadError(f"Failed to download library.json: {err}") from err
|
||||
|
||||
def _save_to_local_storage(data: bytes) -> None:
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
await self.hass.async_add_executor_job(_save_to_local_storage, data)
|
||||
await self.hass.async_add_executor_job(_save_resource, data, Path(local_path))
|
||||
|
||||
return cast(dict[str, Any], json.loads(data))
|
||||
|
||||
@@ -347,19 +454,51 @@ class RemoteLoader(Loader):
|
||||
retry_count: int = 0,
|
||||
) -> tuple[dict[str, Any], str] | None:
|
||||
"""Load a model, downloading it if necessary, with retry logic."""
|
||||
lock = self._model_load_locks.setdefault((manufacturer, model), asyncio.Lock())
|
||||
async with lock:
|
||||
return await self._load_model_locked(manufacturer, model, force_update, retry_count)
|
||||
|
||||
async def _load_model_locked(
|
||||
self,
|
||||
manufacturer: str,
|
||||
model: str,
|
||||
force_update: bool,
|
||||
retry_count: int,
|
||||
) -> tuple[dict[str, Any], str] | None:
|
||||
"""Load a model while holding its per-profile lock."""
|
||||
model_info = self._get_library_model(manufacturer, model)
|
||||
storage_path = self.get_storage_path(manufacturer, model)
|
||||
model_path = os.path.join(storage_path, "model.json")
|
||||
|
||||
if await self._needs_update(model_info, manufacturer, model, model_path, force_update):
|
||||
await self._download_profile_with_retry(manufacturer, model, storage_path, model_path)
|
||||
while True:
|
||||
if await self._needs_update(model_info, manufacturer, model, model_path, force_update):
|
||||
await self._download_profile_with_retry(manufacturer, model, storage_path, model_path)
|
||||
|
||||
try:
|
||||
json_data = await self._load_model_json(model_path)
|
||||
except JSONDecodeError as e:
|
||||
return await self._handle_json_decode_error(e, manufacturer, model, retry_count)
|
||||
try:
|
||||
json_data = await self._load_model_json(model_path)
|
||||
except JSONDecodeError as error:
|
||||
if retry_count >= MODEL_JSON_RETRY_LIMIT:
|
||||
_LOGGER.error(
|
||||
"model.json remains invalid after %d redownload attempts for manufacturer: %s, model: %s",
|
||||
MODEL_JSON_RETRY_LIMIT,
|
||||
manufacturer,
|
||||
model,
|
||||
)
|
||||
raise LibraryLoadingError("Failed to load model.json file") from error
|
||||
|
||||
return json_data, storage_path
|
||||
retry_count += 1
|
||||
force_update = True
|
||||
_LOGGER.warning(
|
||||
"model.json is not valid JSON for manufacturer: %s, model: %s; redownloading profile "
|
||||
"(attempt %d of %d)",
|
||||
manufacturer,
|
||||
model,
|
||||
retry_count,
|
||||
MODEL_JSON_RETRY_LIMIT,
|
||||
)
|
||||
continue
|
||||
|
||||
return json_data, storage_path
|
||||
|
||||
def _get_library_model(self, manufacturer: str, model: str) -> LibraryModel:
|
||||
"""Retrieve model info, or raise an error if not found."""
|
||||
@@ -402,7 +541,7 @@ class RemoteLoader(Loader):
|
||||
callback = partial(self.download_profile, manufacturer, model, storage_path, model_hash)
|
||||
await self.download_with_retry(callback)
|
||||
self.profile_hashes[f"{manufacturer}/{model}"] = model_hash
|
||||
await self.hass.async_add_executor_job(self._write_profile_hashes, self.profile_hashes)
|
||||
await self.hass.async_add_executor_job(self._write_profile_hashes, dict(self.profile_hashes))
|
||||
except ProfileDownloadError as e:
|
||||
path_exists, storage_path_exists = await self.hass.async_add_executor_job(
|
||||
self._profile_paths_exist,
|
||||
@@ -429,20 +568,6 @@ class RemoteLoader(Loader):
|
||||
|
||||
return await self.hass.async_add_executor_job(_load_json)
|
||||
|
||||
async def _handle_json_decode_error(
|
||||
self,
|
||||
error: JSONDecodeError,
|
||||
manufacturer: str,
|
||||
model: str,
|
||||
retry_count: int,
|
||||
) -> tuple[dict[str, Any], str] | None:
|
||||
"""Handle JSON decode errors with retry logic."""
|
||||
_LOGGER.error("model.json file is not valid JSON for manufacturer: %s, model: %s", manufacturer, model)
|
||||
if retry_count < 2:
|
||||
_LOGGER.debug("Retrying to load model.json file")
|
||||
return await self.load_model(manufacturer, model, True, retry_count + 1)
|
||||
raise LibraryLoadingError("Failed to load model.json file") from error
|
||||
|
||||
def get_storage_path(self, manufacturer: str, model: str) -> str:
|
||||
"""Retrieve the storage path for a given manufacturer and model."""
|
||||
return str(self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, manufacturer, model))
|
||||
@@ -480,13 +605,6 @@ class RemoteLoader(Loader):
|
||||
|
||||
endpoint = f"{ENDPOINT_DOWNLOAD}/{manufacturer}/{model}"
|
||||
|
||||
def _save_file(data: bytes, directory: str) -> None:
|
||||
"""Save file from Github to local storage directory"""
|
||||
path = os.path.join(storage_path, directory)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
try:
|
||||
@@ -496,17 +614,25 @@ class RemoteLoader(Loader):
|
||||
raise ProfileDownloadError(f"Failed to download profile: {manufacturer}/{model}")
|
||||
resources = await resp.json()
|
||||
|
||||
validated_resources = await self.hass.async_add_executor_job(
|
||||
_validate_resources,
|
||||
resources,
|
||||
storage_path,
|
||||
)
|
||||
|
||||
await self.hass.async_add_executor_job(lambda: os.makedirs(storage_path, exist_ok=True))
|
||||
|
||||
# Download the files
|
||||
for resource in resources:
|
||||
url = resource.get("url")
|
||||
async with session.get(url) as resp:
|
||||
downloaded_resources: list[tuple[bytes, Path]] = []
|
||||
for url, destination in validated_resources:
|
||||
async with session.get(url, allow_redirects=False) as resp:
|
||||
if resp.status != 200:
|
||||
raise ProfileDownloadError(f"Failed to download github URL: {url}")
|
||||
|
||||
contents = await resp.read()
|
||||
await self.hass.async_add_executor_job(_save_file, contents, resource.get("path"))
|
||||
downloaded_resources.append((contents, destination))
|
||||
|
||||
await self.hass.async_add_executor_job(_save_resources, downloaded_resources)
|
||||
except (TimeoutError, aiohttp.ClientError) as e:
|
||||
raise ProfileDownloadError(f"Failed to download profile: {manufacturer}/{model}") from e
|
||||
|
||||
@@ -515,18 +641,25 @@ class RemoteLoader(Loader):
|
||||
return str(self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, ".profile_hashes"))
|
||||
|
||||
def _load_profile_hashes(self) -> dict[str, str]:
|
||||
"""Load profile hashes from local storage"""
|
||||
"""Load profile hashes from local storage.
|
||||
|
||||
An unusable file is treated as empty rather than raised: the hashes are only a cache
|
||||
validity marker, so the worst case is that every profile is downloaded once more.
|
||||
"""
|
||||
|
||||
path = self._get_profile_hashes_path()
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
|
||||
with open(path) as f:
|
||||
return json.load(f) # type: ignore[no-any-return]
|
||||
try:
|
||||
with open(path) as f:
|
||||
return cast(dict[str, str], json.load(f))
|
||||
except (JSONDecodeError, OSError) as err:
|
||||
_LOGGER.warning("Profile hashes file is unusable (%s), profiles will be downloaded again", err)
|
||||
return {}
|
||||
|
||||
def _write_profile_hashes(self, hashes: dict[str, str]) -> None:
|
||||
"""Write profile hashes to local storage"""
|
||||
"""Write profile hashes to local storage, atomically."""
|
||||
|
||||
path = self._get_profile_hashes_path()
|
||||
with open(path, "w") as json_file:
|
||||
json.dump(hashes, json_file, indent=4)
|
||||
_save_resource(json.dumps(hashes, indent=4).encode(), Path(path))
|
||||
@@ -69,6 +69,7 @@ class DiscoveryBy(StrEnum):
|
||||
CONFIG_ENTRY = "config_entry"
|
||||
DEVICE = "device"
|
||||
ENTITY = "entity"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from typing import Any, NamedTuple, Protocol
|
||||
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.errors import PowercalcSetupError
|
||||
@@ -197,7 +198,7 @@ class ModelIdMatcher(SubProfileMatcher):
|
||||
|
||||
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
|
||||
device_entry = source_entity.device_entry
|
||||
if not device_entry:
|
||||
if not isinstance(device_entry, DeviceEntry):
|
||||
return None
|
||||
|
||||
if device_entry.model_id == self._model_id:
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir, selector
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.powercalc.common import create_source_entity
|
||||
from custom_components.powercalc.common import create_source_entity, get_main_device_entry
|
||||
from custom_components.powercalc.const import CONF_MODEL, CONF_SUB_PROFILE, DOMAIN, ISSUE_COMPOSITE_DEVICE_ID
|
||||
from custom_components.powercalc.device_binding import is_composite_device_id
|
||||
from custom_components.powercalc.flow_helper.schema import build_sub_profile_schema
|
||||
@@ -32,7 +32,7 @@ class SubProfileRepairFlow(RepairsFlow):
|
||||
new_data = self._config_entry.data.copy()
|
||||
new_data[CONF_MODEL] = f"{new_data[CONF_MODEL]}/{user_input[CONF_SUB_PROFILE]}"
|
||||
self._hass.config_entries.async_update_entry(self._config_entry, data=new_data)
|
||||
return self.async_create_entry(title="", data={}) # type: ignore[no-any-return]
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
source_entity = create_source_entity(self._config_entry.data[CONF_ENTITY_ID], self._hass)
|
||||
profile = await get_power_profile(self.hass, dict(self._config_entry.data), source_entity)
|
||||
@@ -43,7 +43,7 @@ class SubProfileRepairFlow(RepairsFlow):
|
||||
if remarks:
|
||||
remarks = "\n\n" + remarks
|
||||
|
||||
return self.async_show_form( # type: ignore[no-any-return]
|
||||
return self.async_show_form(
|
||||
step_id="sub_profile",
|
||||
data_schema=sub_profile_schema,
|
||||
description_placeholders={
|
||||
@@ -73,14 +73,14 @@ class CompositeDeviceIdRepairFlow(RepairsFlow):
|
||||
if user_input is not None:
|
||||
entry = self.hass.config_entries.async_get_entry(self._entry_id)
|
||||
if entry is None:
|
||||
return self.async_abort(reason="entry_removed") # type: ignore[no-any-return]
|
||||
return self.async_abort(reason="entry_removed")
|
||||
|
||||
selected_device_id = user_input.get(CONF_DEVICE)
|
||||
if selected_device_id is not None:
|
||||
device_reg = dr.async_get(self.hass)
|
||||
if (
|
||||
not isinstance(selected_device_id, str)
|
||||
or device_reg.async_get(selected_device_id) is None
|
||||
or get_main_device_entry(device_reg, selected_device_id) is None
|
||||
or is_composite_device_id(self.hass, selected_device_id)
|
||||
):
|
||||
errors["base"] = "invalid_device"
|
||||
@@ -99,9 +99,9 @@ class CompositeDeviceIdRepairFlow(RepairsFlow):
|
||||
f"{ISSUE_COMPOSITE_DEVICE_ID}_{self._entry_id}",
|
||||
)
|
||||
await self.hass.config_entries.async_reload(self._entry_id)
|
||||
return self.async_create_entry(title="", data={}) # type: ignore[no-any-return]
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
return self.async_show_form( # type: ignore[no-any-return]
|
||||
return self.async_show_form(
|
||||
step_id="select_device",
|
||||
data_schema=vol.Schema({vol.Optional(CONF_DEVICE): selector.DeviceSelector()}),
|
||||
errors=errors,
|
||||
|
||||
@@ -239,7 +239,10 @@ class DailyEnergySensor(EnergySensor, RestoreEntity, SensorEntity):
|
||||
self._state,
|
||||
)
|
||||
self.async_schedule_update_ha_state()
|
||||
self._last_updated = dt_util.now().timestamp()
|
||||
# Reuse the timestamp captured by calculate_delta. Reading the clock again here can
|
||||
# cross a whole-second boundary and make the next interval one second too short.
|
||||
assert self._last_delta_calculate is not None
|
||||
self._last_updated = self._last_delta_calculate
|
||||
|
||||
self._update_timer_removal = async_track_time_interval(
|
||||
self.hass,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from homeassistant.components import lawn_mower, vacuum
|
||||
from homeassistant.core import State
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import TrackTemplate
|
||||
@@ -23,11 +22,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
},
|
||||
)
|
||||
|
||||
STATE_BASED_ENTITY_DOMAINS = [
|
||||
vacuum.DOMAIN,
|
||||
lawn_mower.DOMAIN,
|
||||
]
|
||||
|
||||
|
||||
class FixedStrategy(PowerCalculationStrategyInterface):
|
||||
def __init__(
|
||||
@@ -68,12 +62,6 @@ class FixedStrategy(PowerCalculationStrategyInterface):
|
||||
"fixed_mandatory",
|
||||
)
|
||||
|
||||
if self._source_entity.domain in STATE_BASED_ENTITY_DOMAINS and self._per_state_power is None:
|
||||
raise StrategyConfigurationError(
|
||||
"This entity can only work with 'states_power' not 'power'",
|
||||
"fixed_states_power_only",
|
||||
)
|
||||
|
||||
def get_entities_to_track(self) -> list[str | TrackTemplate]:
|
||||
"""Return entities that should be tracked."""
|
||||
track_templates: list[str | TrackTemplate] = []
|
||||
|
||||
@@ -69,6 +69,7 @@ class LinearStrategy(PowerCalculationStrategyInterface):
|
||||
self._attribute: str | None = None
|
||||
self._standby_power = standby_power
|
||||
self._initialized: bool = False
|
||||
self._missing_attribute_warned: bool = False
|
||||
self._calibration: list[tuple[int, float]] | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@@ -199,12 +200,15 @@ class LinearStrategy(PowerCalculationStrategyInterface):
|
||||
|
||||
value = entity_state.attributes.get(self._attribute)
|
||||
if value is None:
|
||||
_LOGGER.warning(
|
||||
"No %s attribute for entity: %s",
|
||||
self._attribute,
|
||||
entity_state.entity_id,
|
||||
)
|
||||
if not self._missing_attribute_warned:
|
||||
_LOGGER.warning(
|
||||
"No %s attribute for entity: %s",
|
||||
self._attribute,
|
||||
entity_state.entity_id,
|
||||
)
|
||||
self._missing_attribute_warned = True
|
||||
return None
|
||||
self._missing_attribute_warned = False
|
||||
# Convert volume level to 0-100 range
|
||||
if self._attribute == ATTR_MEDIA_VOLUME_LEVEL:
|
||||
if entity_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True:
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Musíte zadat alespoň jednu z možností „Hodnota“ nebo „Šablona hodnoty“",
|
||||
"entity_mandatory": "Pro všechny strategie kromě playbook je nutné vybrat entitu",
|
||||
"fixed_mandatory": "Musíte zadat alespoň jednu z následujících možností: pevnou hodnotu, šablonu nebo mapování stavů",
|
||||
"fixed_states_power_only": "Tato entita může pracovat pouze s 'states_power', ne s 'power'",
|
||||
"group_mandatory": "Musíte definovat alespoň podskupiny nebo entity výkonu a energie",
|
||||
"linear_mandatory": "Musíte zadat alespoň jednu z možností max_power nebo calibrate",
|
||||
"linear_min_higher_as_max": "Maximální spotřeba nebůže být nižší, nežli minimální spotřeba",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Musíte zadat buď cenu energie, nebo senzor ceny energie",
|
||||
"fixed_mandatory": "Musíte zadat alespoň jednu z možností Výkon, šablona výkonu nebo výkon podle stavů",
|
||||
"fixed_states_power_only": "Tato entita může pracovat pouze s 'states_power', ne s 'power'",
|
||||
"group_mandatory": "Musíte definovat alespoň podskupiny nebo entity výkonu a energie",
|
||||
"linear_mandatory": "Musíte zadat alespoň jednu z možností max_power nebo calibrate",
|
||||
"linear_min_higher_as_max": "Maximální spotřeba nebůže být nižší, nežli minimální spotřeba",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Du skal angive mindst én af Værdi eller Værdiskabelon",
|
||||
"entity_mandatory": "Der skal vælges en entitet for alle andre strategier end playbook",
|
||||
"fixed_mandatory": "Du skal angive mindst én af Effekt, Effektskabelon eller Effekt efter tilstand",
|
||||
"fixed_states_power_only": "Denne entitet kan kun bruges med 'states_power', ikke 'power'",
|
||||
"group_mandatory": "Du skal som minimum definere undergrupper eller effekt- og energientiteter",
|
||||
"linear_mandatory": "Du skal angive mindst én af max_power eller calibrate",
|
||||
"linear_min_higher_as_max": "Maksimal effekt kan ikke være lavere end minimal effekt",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Du skal angive enten en energipris eller en energiprissensor",
|
||||
"fixed_mandatory": "Du skal angive mindst én af Effekt, Effektskabelon eller Effekt efter tilstand",
|
||||
"fixed_states_power_only": "Denne entitet kan kun bruges med 'states_power', ikke 'power'",
|
||||
"group_mandatory": "Du skal som minimum definere undergrupper eller effekt- og energientiteter",
|
||||
"linear_mandatory": "Du skal angive mindst én af max_power eller calibrate",
|
||||
"linear_min_higher_as_max": "Maksimal effekt kan ikke være lavere end minimal effekt",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Du musst mindestens einen Wert oder ein Werte-Template angeben",
|
||||
"entity_mandatory": "Die Auswahl einer Entität ist für jede Strategie mit Ausnahme des Playbooks erforderlich",
|
||||
"fixed_mandatory": "Du musst mindestens Leistung, Leistungs-Template oder Leistung je Zustand angeben",
|
||||
"fixed_states_power_only": "Diese Entität kann nur mit Leistung je Zustand und nicht mit Leistung arbeiten",
|
||||
"group_mandatory": "Du musst mindestens Untergruppen oder Leistungs- und Energie-Entitäten definieren",
|
||||
"linear_mandatory": "Du musst mindestens die minimale Leistung oder den Kalibrierungswert angeben",
|
||||
"linear_min_higher_as_max": "Die maximale Leistung kann nicht niedriger als die minimale Leistung sein",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Du musst entweder einen Energiepreis oder einen Energiepreissensor angeben",
|
||||
"fixed_mandatory": "Sie müssen mindestens eines von Leistung, Leistung-Template oder Leistung je Zustand angeben",
|
||||
"fixed_states_power_only": "Diese Entität kann nur mit Leistung je Zustand und nicht mit Leistung arbeiten",
|
||||
"group_mandatory": "Sie müssen mindestens Untergruppen oder Leistung- und Energie-Entitäten definieren",
|
||||
"linear_mandatory": "Sie müssen mindestens Maximale Leistung oder Kalibrierung angeben",
|
||||
"linear_min_higher_as_max": "Maximale Leistung kann nicht niedriger sein als Minimale Leistung",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "You must supply at least one of Value or Value template",
|
||||
"entity_mandatory": "Selecting an entity is required for any strategy other than playbook",
|
||||
"fixed_mandatory": "You must supply at least one of Power, Power template or States power",
|
||||
"fixed_states_power_only": "This entity can only work with 'states_power' not 'power'",
|
||||
"group_mandatory": "You must define at least subgroups or power and energy-entities",
|
||||
"linear_mandatory": "You must supply at least one of max_power or calibrate",
|
||||
"linear_min_higher_as_max": "Max power cannot be lower than min power",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "You must supply either an energy price or an energy price sensor",
|
||||
"fixed_mandatory": "You must supply at least one of Power, Power template or States power",
|
||||
"fixed_states_power_only": "This entity can only work with 'states_power' not 'power'",
|
||||
"group_mandatory": "You must define at least subgroups or power and energy-entities",
|
||||
"linear_mandatory": "You must supply at least one of max_power or calibrate",
|
||||
"linear_min_higher_as_max": "Max power cannot be lower than min power",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Debes proveer al menos un valor o valor en plantilla",
|
||||
"entity_mandatory": "El sensor ya está configurado, especifique un identificador único",
|
||||
"fixed_mandatory": "Debes proveer al menos uno de los siguientes: potencia, potencia en plantilla, o potencia en un estado",
|
||||
"fixed_states_power_only": "Esta entidad solamente funciona con \"states_power\" y no con \"power\"",
|
||||
"group_mandatory": "Debes definir como mínimo subgrupos o potencia y entidades de energía",
|
||||
"linear_mandatory": "Debes proveer al menos potencia máxima (max_power) o calibrar",
|
||||
"linear_min_higher_as_max": "La potencia máxima no puede ser menos que la potencia mínima",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Debe proporcionar un precio de energía o un sensor de precio de energía.",
|
||||
"fixed_mandatory": "Debe suministrar al menos uno de: potencia, plantilla de potencia o estados de potencia",
|
||||
"fixed_states_power_only": "Esta entidad sólo puede funcionar con 'states_power' no 'power'",
|
||||
"group_mandatory": "Debes definir como mínimo subgrupos o potencia y entidades de energía",
|
||||
"linear_mandatory": "Debes proveer al menos potencia máxima (max_power) o calibrar",
|
||||
"linear_min_higher_as_max": "La potencia máxima no puede ser menos que la potencia mínima",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Anna vähintään value tai value_template",
|
||||
"entity_mandatory": "Entiteetti on valittava kaikille muille strategioille paitsi playbook-strategialle",
|
||||
"fixed_mandatory": "Anna vähintään power, power_template tai states_power",
|
||||
"fixed_states_power_only": "Tämä entiteetti toimii vain states_power-asetuksella, ei power-asetuksella",
|
||||
"group_mandatory": "Määritä vähintään aliryhmät tai teho- ja energiaentiteetit",
|
||||
"linear_mandatory": "Anna vähintään max_power tai calibrate",
|
||||
"linear_min_higher_as_max": "Enimmäisteho ei voi olla pienempi kuin vähimmäisteho",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Anna joko energian hinta tai energian hinta-anturi",
|
||||
"fixed_mandatory": "Anna vähintään power-, power_template- tai states_power-arvo",
|
||||
"fixed_states_power_only": "Tämä entiteetti voi toimia vain 'states_power', ei 'power':n kanssa",
|
||||
"group_mandatory": "Sinun on määritettävä vähintään alaryhmät tai teho- ja energiaentiteetit",
|
||||
"linear_mandatory": "Anna vähintään max_power tai calibrate",
|
||||
"linear_min_higher_as_max": "Enimmäisteho ei voi olla pienempi kuin vähimmäisteho",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Vous devez fournir au moins une valeur ou un modèle de valeur",
|
||||
"entity_mandatory": "La sélection d'une entité est requise pour toute stratégie autre que le playbook",
|
||||
"fixed_mandatory": "Vous devez fournir au moins une alimentation, un modèle d'alimentation ou un état d'alimentation",
|
||||
"fixed_states_power_only": "Cette entité ne peut fonctionner qu'avec 'states_power' et non 'power'",
|
||||
"group_mandatory": "Vous devez définir au moins des sous-groupes ou des entités de puissance et d'énergie",
|
||||
"linear_mandatory": "Vous devez spécifier au moins la puissance minimale ou la valeur d'étalonnage",
|
||||
"linear_min_higher_as_max": "La puissance maximale ne peut pas être inférieure à la puissance minimale",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Vous devez fournir soit un prix de l'énergie, soit un capteur de prix de l'énergie",
|
||||
"fixed_mandatory": "Vous devez fournir au moins une alimentation, un modèle d'alimentation ou une alimentation des états",
|
||||
"fixed_states_power_only": "Cette entité ne peut fonctionner qu'avec des 'Etat de puissance' et non 'Puissance'",
|
||||
"group_mandatory": "Vous devez définir au moins des sous-groupes ou des entités de puissance et d'énergie",
|
||||
"linear_mandatory": "Vous devez spécifier au moins la puissance minimale ou la valeur d'étalonnage",
|
||||
"linear_min_higher_as_max": "La puissance maximale ne peut pas être inférieure à la puissance minimale",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Adja meg legalább a value vagy value_template egyikét",
|
||||
"entity_mandatory": "A playbook stratégián kívül minden stratégiához entitást kell választani",
|
||||
"fixed_mandatory": "Adja meg legalább a power, power_template vagy states_power egyikét",
|
||||
"fixed_states_power_only": "Ez az entitás csak a „states_power” beállítással működik, a „power” beállítással nem",
|
||||
"group_mandatory": "Adjon meg legalább alcsoportokat, illetve energia- vagy teljesítményentitásokat",
|
||||
"linear_mandatory": "Adja meg legalább a max_power vagy a calibrate értéket",
|
||||
"linear_min_higher_as_max": "A maximális teljesítmény nem lehet kisebb a minimális teljesítménynél",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Adja meg az energiaárat vagy az energiaár-érzékelőt",
|
||||
"fixed_mandatory": "Adja meg legalább a power, power_template vagy states_power egyikét",
|
||||
"fixed_states_power_only": "Ez az entitás csak a 'states_power'-vel működik, nem a 'power'-val",
|
||||
"group_mandatory": "Meg kell határoznia legalább alcsoportokat vagy teljesítmény- és energia-entitásokat",
|
||||
"linear_mandatory": "A max_power vagy a calibrate közül legalább egyet meg kell adnia",
|
||||
"linear_min_higher_as_max": "A maximális teljesítmény nem lehet kisebb a minimális teljesítménynél",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "È necessario fornire almeno o un Valore o un Modello valore",
|
||||
"entity_mandatory": "La selezione di un'entità è richiesta per qualsiasi strategia diversa dal playbook",
|
||||
"fixed_mandatory": "È necessario fornire almeno uno tra Potenza, Modello di potenza o Stati di potenza",
|
||||
"fixed_states_power_only": "Questa entità può funzionare solo con 'states_power' non 'power'",
|
||||
"group_mandatory": "È necessario definire almeno sottogruppi o entità di potenza e di energia",
|
||||
"linear_mandatory": "È necessario fornire almeno valore di potenza massima o calibrare",
|
||||
"linear_min_higher_as_max": "La potenza massima non può essere inferiore a quella minima",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "È necessario fornire un prezzo dell'energia o un sensore del prezzo dell'energia",
|
||||
"fixed_mandatory": "È necessario fornire almeno uno tra Potenza, Modello di potenza o Stati di potenza",
|
||||
"fixed_states_power_only": "Questa entità può funzionare solo con 'states_power' non 'power'",
|
||||
"group_mandatory": "È necessario definire almeno sottogruppi o entità di potenza e entità di energia",
|
||||
"linear_mandatory": "È necessario fornire almeno picco di massima potenza o calibrare",
|
||||
"linear_min_higher_as_max": "La potenza massima non può essere inferiore a quella minima",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Du må oppgi minst én av value eller value_template",
|
||||
"entity_mandatory": "Du må velge en entitet for alle strategier unntatt playbook",
|
||||
"fixed_mandatory": "Du må oppgi minst én av power, power_template eller states_power",
|
||||
"fixed_states_power_only": "Denne entiteten kan bare bruke «states_power», ikke «power»",
|
||||
"group_mandatory": "Du må definere minst undergrupper eller effekt- og energientiteter",
|
||||
"linear_mandatory": "Du må oppgi minst én av max_power eller calibrate",
|
||||
"linear_min_higher_as_max": "Maksimal effekt kan ikke være lavere enn minimal effekt",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Du må oppgi enten en energipris eller en energiprissensor",
|
||||
"fixed_mandatory": "Du må oppgi minst én av power, power_template eller states_power",
|
||||
"fixed_states_power_only": "Denne entiteten kan bare bruke 'states_power', ikke 'power'",
|
||||
"group_mandatory": "Du må definere minst undergrupper eller effekt- og energientiteter",
|
||||
"linear_mandatory": "Du må oppgi minst én av max_power eller calibrate",
|
||||
"linear_min_higher_as_max": "Maks effekt kan ikke være lavere enn min effekt",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Je moet minimaal waarde of waarde template opgeven",
|
||||
"entity_mandatory": "Je moet verplicht een entiteit opgeven voor iedere andere strategie dan playbook",
|
||||
"fixed_mandatory": "Je dient minimaal een van de volgende velden te definiëren: Vermogen, Vermogen template of Vermogen per status",
|
||||
"fixed_states_power_only": "Deze entiteit kan alleen functioneren met 'states_power', niet 'power'",
|
||||
"group_mandatory": "Je dient minimaal een van de volgende velden te definiëren: sub groepen, vermogen entiteiten of energie entiteiten",
|
||||
"linear_mandatory": "Je moet tenminste één van Max vermogen of Kalibratie waardes opgeven",
|
||||
"linear_min_higher_as_max": "Maximale vermogen kan niet lager zijn dan minimale vermogen",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "U moet een energieprijs of een energieprijssensor aanleveren",
|
||||
"fixed_mandatory": "Je dient minimaal een van de volgende velden te definieren: vermogen, vermogen template of vermogen per status",
|
||||
"fixed_states_power_only": "Deze entiteit kan alleen functioneren met 'states_power', niet 'power'",
|
||||
"group_mandatory": "Je dient minimaal een van de volgende velden te definieren: sub groepen, vermogen entiteiten of energie entiteiten",
|
||||
"linear_mandatory": "Je moet mimimaal max vermogen of kalibratie waardes instellen",
|
||||
"linear_min_higher_as_max": "Maximale vermogen kan niet lager zijn dan mimimale vermogen",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Musisz podać co najmniej jedną Wartość lub Szablon wartości",
|
||||
"entity_mandatory": "Wybranie encji jest wymagane w przypadku każdej strategii innej niż playbook",
|
||||
"fixed_mandatory": "Musisz podać co najmniej jedno z: Moc, Szablon mocy lub Moce stanów",
|
||||
"fixed_states_power_only": "Ta encja może działać tylko ze 'states_power', a nie z 'power'",
|
||||
"group_mandatory": "Musisz zdefiniować przynajmniej podgrupy lub encje mocy i energii",
|
||||
"linear_mandatory": "Musisz dostarczyć co najmniej jedno z: Maksymalna moc lub Kalibracja",
|
||||
"linear_min_higher_as_max": "Maksymalna moc nie może być mniejsza niż Minimalna moc",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Należy podać cenę energii lub czujnik ceny energii",
|
||||
"fixed_mandatory": "Musisz podać co najmniej jedno z: Moc, Szablon mocy lub Moce stanów",
|
||||
"fixed_states_power_only": "Ta encja może działać tylko ze 'states_power', a nie z 'power'",
|
||||
"group_mandatory": "Musisz zdefiniować przynajmniej podgrupy lub encje mocy i energii",
|
||||
"linear_mandatory": "Musisz dostarczyć co najmniej jedno z: Maksymalna moc lub Kalibracja",
|
||||
"linear_min_higher_as_max": "Maksymalna moc nie może być mniejsza niż Minimalna moc",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Você deve fornecer pelo menos um modelo de Valor ou Valor",
|
||||
"entity_mandatory": "A seleção de uma entidade é necessária para qualquer estratégia que não seja roteiro",
|
||||
"fixed_mandatory": "Você deve fornecer pelo menos uma potência, um modelo de potência ou potências por estado",
|
||||
"fixed_states_power_only": "Esta entidade só pode trabalhar com 'states_power' e não 'power'",
|
||||
"group_mandatory": "Você deve definir pelo menos subgrupos ou entidades de potência e consumo",
|
||||
"linear_mandatory": "Você deve fornecer pelo menos um de max_power ou calibrar",
|
||||
"linear_min_higher_as_max": "A potência máxima não pode ser inferior à potência mínima",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Você deve fornecer um preço de energia ou um sensor de preço de energia",
|
||||
"fixed_mandatory": "Você deve fornecer pelo menos uma potência, um modelo de potência ou potências por estado",
|
||||
"fixed_states_power_only": "Esta entidade só pode trabalhar com 'states_power' e não 'power'",
|
||||
"group_mandatory": "Você deve definir pelo menos subgrupos ou entidades de potência e consumo",
|
||||
"linear_mandatory": "Você deve fornecer pelo menos um de max_power ou calibrar",
|
||||
"linear_min_higher_as_max": "A potência máxima não pode ser inferior à potência mínima",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Deve fornecer pelo menos um modelo de Valor ou Valor",
|
||||
"entity_mandatory": "A selecção de entidade é obrigatória para qualquer estratégia diferente de 'Sequência'",
|
||||
"fixed_mandatory": "Deve fornecer pelo menos uma potência, um modelo de potência ou potências por estado",
|
||||
"fixed_states_power_only": "Esta entidade só pode trabalhar com 'states_power' e não 'power'",
|
||||
"group_mandatory": "Deve definir pelo menos subgrupos ou entidades de poder e energia",
|
||||
"linear_mandatory": "Deve fornecer pelo menos um de max_power ou calibrar",
|
||||
"linear_min_higher_as_max": "A potência máxima não pode ser inferior à potência mínima",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Deve fornecer um preço de energia ou um sensor de preço da energia",
|
||||
"fixed_mandatory": "Deve fornecer pelo menos uma potência, um modelo de potência ou potências por estado",
|
||||
"fixed_states_power_only": "Esta entidade só pode trabalhar com 'states_power' e não 'power'",
|
||||
"group_mandatory": "Deve definir pelo menos subgrupos ou entidades de poder e energia",
|
||||
"linear_mandatory": "Deve fornecer pelo menos um de max_power ou calibrar",
|
||||
"linear_min_higher_as_max": "A potência máxima não pode ser inferior à potência mínima",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Trebuie să furnizați cel puțin o Valoare sau șablonul Valoare",
|
||||
"entity_mandatory": "Selectarea unei entități este necesară pentru orice altă strategie decât playbook",
|
||||
"fixed_mandatory": "Trebuie să furnizați cel puțin o putere, șablon putere sau State power",
|
||||
"fixed_states_power_only": "Această entitate poate funcționa numai cu 'states_power' nu cu 'power'",
|
||||
"group_mandatory": "Trebuie să definiți cel puțin subgrupuri sau entități de putere și energie",
|
||||
"linear_mandatory": "Trebuie să furnizați cel puțin unul dintre max_power sau să calibrați",
|
||||
"linear_min_higher_as_max": "Puterea maximă nu poate fi mai mică decât puterea minimă",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Trebuie să furnizați fie un preț al energiei, fie un senzor al prețului energiei",
|
||||
"fixed_mandatory": "Trebuie să furnizați cel puțin o putere, șablon putere sau State power",
|
||||
"fixed_states_power_only": "Această entitate poate funcționa numai cu 'states_power' nu cu 'power'",
|
||||
"group_mandatory": "Trebuie să definiți cel puțin subgrupuri sau entități de putere și energie",
|
||||
"linear_mandatory": "Trebuie să furnizați cel puțin unul dintre max_power sau să calibrați",
|
||||
"linear_min_higher_as_max": "Puterea maximă nu poate fi mai mică decât puterea minimă",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Необходимо указать хотя бы одно из: Значение или Шаблон значения",
|
||||
"entity_mandatory": "Выбор объекта обязателен для любой стратегии, кроме playbook",
|
||||
"fixed_mandatory": "Необходимо указать хотя бы одно из: Мощность, Шаблон мощности или Мощность по состояниям",
|
||||
"fixed_states_power_only": "Этот объект может работать только с 'states_power', а не 'power'",
|
||||
"group_mandatory": "Необходимо определить хотя бы подгруппы или объекты мощности и энергии",
|
||||
"linear_mandatory": "Необходимо указать хотя бы одно из: max_power или calibrate",
|
||||
"linear_min_higher_as_max": "Максимальная мощность не может быть меньше минимальной",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Вы должны предоставить либо цену на энергию, либо датчик цены на энергию.",
|
||||
"fixed_mandatory": "Необходимо указать хотя бы одно из: Мощность, Шаблон мощности или Мощность по состояниям",
|
||||
"fixed_states_power_only": "Этот объект может работать только с 'states_power', а не 'power'",
|
||||
"group_mandatory": "Необходимо определить хотя бы подгруппы или объекты мощности и энергии",
|
||||
"linear_mandatory": "Необходимо указать хотя бы одно из: max_power или calibrate",
|
||||
"linear_min_higher_as_max": "Максимальная мощность не может быть меньше минимальной",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Musíte dodať aspoň jednu šablónu hodnoty alebo hodnoty",
|
||||
"entity_mandatory": "Výber subjektu je potrebný pre akúkoľvek inú stratégiu, než je stratégia playbook",
|
||||
"fixed_mandatory": "Musíte zadať aspoň jednu z možností: fixnú hodnotu, šablónu alebo mapovanie stavov",
|
||||
"fixed_states_power_only": "Táto entita môže pracovať iba s 'states_power', nie 'power'",
|
||||
"group_mandatory": "Musíte definovať aspoň podskupiny alebo silové a energetické entity",
|
||||
"linear_mandatory": "Musíte dodať aspoň jeden z max_power alebo calibrate",
|
||||
"linear_min_higher_as_max": "Maximálny výkon nemôže byť nižší ako minimálny výkon",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Musíte dodať buď cenu energie, alebo snímač ceny energie",
|
||||
"fixed_mandatory": "Musíte zadať aspoň jednu z možností: fixnú hodnotu, šablónu alebo mapovanie stavov",
|
||||
"fixed_states_power_only": "Táto entita môže pracovať iba s 'states_power', nie 'power'",
|
||||
"group_mandatory": "Musíte definovať aspoň podskupiny alebo výkonové a energetické entity",
|
||||
"linear_mandatory": "Musíte dodať aspoň jeden z max_power alebo calibrate",
|
||||
"linear_min_higher_as_max": "Maximálny výkon nemôže byť nižší ako minimálny výkon",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "Du måste ange minst ett värde eller en värdemall",
|
||||
"entity_mandatory": "Att välja en entitet krävs för alla strategier utom playbook",
|
||||
"fixed_mandatory": "Du måste ange minst en av Effekt, Effektmall eller effekttillstånd",
|
||||
"fixed_states_power_only": "Den här entiteten kan bara jobba med 'state effekt' inte 'effekt'",
|
||||
"group_mandatory": "Du måste ange minst en subgrupp eller effekt och energi entititeter",
|
||||
"linear_mandatory": "Du måste ange minst en av max_effekt eller kalibrera",
|
||||
"linear_min_higher_as_max": "Max effekt kan inte vara lägre än min effekt",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "Du måste ange antingen ett energipris eller en energiprissensor",
|
||||
"fixed_mandatory": "Du måste ange minst en av Effekt, Effektmall eller effekttillstånd",
|
||||
"fixed_states_power_only": "Den här entiteten kan bara jobba med 'state effekt' inte 'effekt'",
|
||||
"group_mandatory": "Du måste ange minst en subgrupp eller effekt och energi entititeter",
|
||||
"linear_mandatory": "Du måste ange minst en av max_effekt eller kalibrera",
|
||||
"linear_min_higher_as_max": "Max effekt kan inte vara lägre än min effekt",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"daily_energy_mandatory": "必须至少提供“值”或“值模板”中的一项",
|
||||
"entity_mandatory": "除运行方案 (playbook) 外,所有策略都必须选择实体",
|
||||
"fixed_mandatory": "必须至少提供“功率”“功率模板”或“状态功率”中的一项",
|
||||
"fixed_states_power_only": "此实体只能使用 'states_power',不能使用 'power'",
|
||||
"group_mandatory": "必须定义至少一个子组,或功率和能源实体",
|
||||
"linear_mandatory": "必须至少提供 max_power 或 calibrate 其中一项",
|
||||
"linear_min_higher_as_max": "最大功率不能低于最小功率",
|
||||
@@ -680,7 +679,6 @@
|
||||
"error": {
|
||||
"cost_price_mandatory": "必须提供能源价格或能源价格传感器",
|
||||
"fixed_mandatory": "必须至少提供功率、功率模板或状态功率中的一项",
|
||||
"fixed_states_power_only": "此实体只能使用 'states_power',不能使用 'power'",
|
||||
"group_mandatory": "必须至少定义一个子组,或定义功率和能源实体",
|
||||
"linear_mandatory": "必须至少提供 max_power 或 calibrate 中的一项",
|
||||
"linear_min_higher_as_max": "最大功率不能低于最小功率",
|
||||
|
||||
Reference in new issue
Block a user