diff --git a/README.md b/README.md new file mode 100644 index 0000000..b04311c --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# ha-solcast-solar diff --git a/custom_components/solcast_solar/__init__.py b/custom_components/solcast_solar/__init__.py new file mode 100644 index 0000000..4cc9af8 --- /dev/null +++ b/custom_components/solcast_solar/__init__.py @@ -0,0 +1,332 @@ +"""Support for Solcast PV forecast.""" + +import logging + +from homeassistant import loader +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import (HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse,) +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import aiohttp_client, intent +from homeassistant.helpers.device_registry import async_get as device_registry +from homeassistant.util import dt as dt_util + +from .const import ( + DOMAIN, + SERVICE_CLEAR_DATA, + SERVICE_UPDATE, + SERVICE_QUERY_FORECAST_DATA, + SERVICE_SET_DAMPENING, + SERVICE_SET_HARD_LIMIT, + SERVICE_REMOVE_HARD_LIMIT, + SOLCAST_URL, + CUSTOM_HOUR_SENSOR, + KEY_ESTIMATE +) + +from .coordinator import SolcastUpdateCoordinator +from .solcastapi import ConnectionOptions, SolcastApi + +from typing import Final + +import voluptuous as vol + +PLATFORMS = [Platform.SENSOR, Platform.SELECT,] + +_LOGGER = logging.getLogger(__name__) + +DAMP_FACTOR = "damp_factor" +SERVICE_DAMP_SCHEMA: Final = vol.All( + { + vol.Required(DAMP_FACTOR): cv.string, + } +) + +HARD_LIMIT = "hard_limit" +SERVICE_HARD_LIMIT_SCHEMA: Final = vol.All( + { + vol.Required(HARD_LIMIT): cv.Number, + } +) + +EVENT_START_DATETIME = "start_date_time" +EVENT_END_DATETIME = "end_date_time" +SERVICE_QUERY_SCHEMA: Final = vol.All( + { + vol.Required(EVENT_START_DATETIME): cv.datetime, + vol.Required(EVENT_END_DATETIME): cv.datetime, + } +) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up solcast parameters.""" + + #new in v4.0.16 for the selector of which field to use from the data + if entry.options.get(KEY_ESTIMATE,None) == None: + new = {**entry.options} + new[KEY_ESTIMATE] = "estimate" + entry.version = 7 + hass.config_entries.async_update_entry(entry, options=new) + + + optdamp = {} + try: + #if something goes wrong ever with the damp factors just create a blank 1.0 + for a in range(0,24): + optdamp[str(a)] = entry.options[f"damp{str(a).zfill(2)}"] + except Exception as ex: + new = {**entry.options} + for a in range(0,24): + new[f"damp{str(a).zfill(2)}"] = 1.0 + entry.options = {**new} + for a in range(0,24): + optdamp[str(a)] = 1.0 + + options = ConnectionOptions( + entry.options[CONF_API_KEY], + SOLCAST_URL, + hass.config.path('solcast.json'), + dt_util.get_time_zone(hass.config.time_zone), + optdamp, + entry.options[CUSTOM_HOUR_SENSOR], + entry.options.get(KEY_ESTIMATE,"estimate"), + (entry.options.get(HARD_LIMIT,100000)/1000), + ) + + solcast = SolcastApi(aiohttp_client.async_get_clientsession(hass), options) + + try: + await solcast.sites_data() + await solcast.sites_usage() + except Exception as ex: + raise ConfigEntryNotReady(f"Getting sites data failed: {ex}") from ex + + await solcast.load_saved_data() + + _VERSION = "" + try: + integration = await loader.async_get_integration(hass, DOMAIN) + _VERSION = str(integration.version) + _LOGGER.info(f"Solcast Integration version number: {_VERSION}") + except loader.IntegrationNotFound: + pass + + coordinator = SolcastUpdateCoordinator(hass, solcast, _VERSION) + + await coordinator.setup() + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_update_options)) + + _LOGGER.info(f"SOLCAST - Solcast API data UTC times are converted to {hass.config.time_zone}") + + if options.hard_limit < 100: + _LOGGER.info(f"SOLCAST - Inverter hard limit value has been set. If the forecasts and graphs are not as you expect, try running the service 'solcast_solar.remove_hard_limit' to remove this setting. This setting is really only for advanced quirky solar setups.") + + async def handle_service_update_forecast(call: ServiceCall): + """Handle service call""" + _LOGGER.info(f"SOLCAST - Service call: {SERVICE_UPDATE}") + await coordinator.service_event_update() + + async def handle_service_clear_solcast_data(call: ServiceCall): + """Handle service call""" + _LOGGER.info(f"SOLCAST - Service call: {SERVICE_CLEAR_DATA}") + await coordinator.service_event_delete_old_solcast_json_file() + + async def handle_service_get_solcast_data(call: ServiceCall) -> ServiceResponse: + """Handle service call""" + try: + _LOGGER.info(f"SOLCAST - Service call: {SERVICE_QUERY_FORECAST_DATA}") + + start = call.data.get(EVENT_START_DATETIME, dt_util.now()) + end = call.data.get(EVENT_END_DATETIME, dt_util.now()) + + d = await coordinator.service_query_forecast_data(dt_util.as_utc(start), dt_util.as_utc(end)) + except intent.IntentHandleError as err: + raise HomeAssistantError(f"Error processing {SERVICE_QUERY_FORECAST_DATA}: {err}") from err + + if call.return_response: + return {"data": d} + + return None + + async def handle_service_set_dampening(call: ServiceCall): + """Handle service call""" + try: + _LOGGER.info(f"SOLCAST - Service call: {SERVICE_SET_DAMPENING}") + + factors = call.data.get(DAMP_FACTOR, None) + + if factors == None: + raise HomeAssistantError(f"Error processing {SERVICE_SET_DAMPENING}: Empty factor string") + else: + factors = factors.strip().replace(" ","") + if len(factors) == 0: + raise HomeAssistantError(f"Error processing {SERVICE_SET_DAMPENING}: Empty factor string") + else: + sp = factors.split(",") + if (len(sp)) != 24: + raise HomeAssistantError(f"Error processing {SERVICE_SET_DAMPENING}: Not 24 hourly factor items") + else: + for i in sp: + #this will fail is its not a number + if float(i) < 0 or float(i) > 1: + raise HomeAssistantError(f"Error processing {SERVICE_SET_DAMPENING}: Factor value outside 0.0 to 1.0") + d= {} + opt = {**entry.options} + for i in range(0,24): + d.update({f"{i}": float(sp[i])}) + opt[f"damp{i:02}"] = float(sp[i]) + + solcast._damp = d + hass.config_entries.async_update_entry(entry, options=opt) + + #why is this here?? why did i make it delete the file when changing the damp factors?? + #await coordinator.service_event_delete_old_solcast_json_file() + except intent.IntentHandleError as err: + raise HomeAssistantError(f"Error processing {SERVICE_SET_DAMPENING}: {err}") from err + + async def handle_service_set_hard_limit(call: ServiceCall): + """Handle service call""" + try: + _LOGGER.info(f"SOLCAST - Service call: {SERVICE_SET_HARD_LIMIT}") + + hl = call.data.get(HARD_LIMIT, 100000) + + + if hl == None: + raise HomeAssistantError(f"Error processing {SERVICE_SET_HARD_LIMIT}: Empty hard limit value") + else: + val = int(hl) + if val < 0: # if not a positive int print message and ask for input again + raise HomeAssistantError(f"Error processing {SERVICE_SET_HARD_LIMIT}: Hard limit value not a positive number") + + + opt = {**entry.options} + opt[HARD_LIMIT] = val + # solcast._hardlimit = val + hass.config_entries.async_update_entry(entry, options=opt) + + except ValueError: + raise HomeAssistantError(f"Error processing {SERVICE_SET_HARD_LIMIT}: Hard limit value not a positive number") + except intent.IntentHandleError as err: + raise HomeAssistantError(f"Error processing {SERVICE_SET_DAMPENING}: {err}") from err + + async def handle_service_remove_hard_limit(call: ServiceCall): + """Handle service call""" + try: + _LOGGER.info(f"SOLCAST - Service call: {SERVICE_REMOVE_HARD_LIMIT}") + + opt = {**entry.options} + opt[HARD_LIMIT] = 100000 + hass.config_entries.async_update_entry(entry, options=opt) + + except intent.IntentHandleError as err: + raise HomeAssistantError(f"Error processing {SERVICE_REMOVE_HARD_LIMIT}: {err}") from err + + hass.services.async_register( + DOMAIN, SERVICE_UPDATE, handle_service_update_forecast + ) + + hass.services.async_register( + DOMAIN, SERVICE_CLEAR_DATA, handle_service_clear_solcast_data + ) + + hass.services.async_register( + DOMAIN, SERVICE_QUERY_FORECAST_DATA, handle_service_get_solcast_data, SERVICE_QUERY_SCHEMA, SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, SERVICE_SET_DAMPENING, handle_service_set_dampening, SERVICE_DAMP_SCHEMA + ) + + hass.services.async_register( + DOMAIN, SERVICE_SET_HARD_LIMIT, handle_service_set_hard_limit, SERVICE_HARD_LIMIT_SCHEMA + ) + + hass.services.async_register( + DOMAIN, SERVICE_REMOVE_HARD_LIMIT, handle_service_remove_hard_limit + ) + + return True + +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: + hass.data[DOMAIN].pop(entry.entry_id) + + hass.services.async_remove(DOMAIN, SERVICE_UPDATE) + hass.services.async_remove(DOMAIN, SERVICE_CLEAR_DATA) + hass.services.async_remove(DOMAIN, SERVICE_QUERY_FORECAST_DATA) + hass.services.async_remove(DOMAIN, SERVICE_SET_DAMPENING) + hass.services.async_remove(DOMAIN, SERVICE_SET_HARD_LIMIT) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE_HARD_LIMIT) + + + return unload_ok + +async def async_remove_config_entry_device(hass: HomeAssistant, entry: ConfigEntry, device) -> bool: + device_registry(hass).async_remove_device(device.id) + return True + +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry): + """Reload entry if options change.""" + await hass.config_entries.async_reload(entry.entry_id) + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Solcast Migrating from version %s", config_entry.version) + + if config_entry.version < 4: + new_options = {**config_entry.options} + new_options.pop("const_disableautopoll", None) + config_entry.version = 4 + + hass.config_entries.async_update_entry(config_entry, options=new_options) + + #new 4.0.8 + #power factor for each hour + if config_entry.version == 4: + new = {**config_entry.options} + for a in range(0,24): + new[f"damp{str(a).zfill(2)}"] = 1.0 + config_entry.version = 5 + + hass.config_entries.async_update_entry(config_entry, options=new) + + + + #new 4.0.15 + #custom sensor for 'next x hours' + if config_entry.version == 5: + new = {**config_entry.options} + new[CUSTOM_HOUR_SENSOR] = 1 + config_entry.version = 6 + + hass.config_entries.async_update_entry(config_entry, options=new) + + + + #new 4.0.16 + #which estimate value to use for data calcs est,est10,est90 + if config_entry.version == 6: + new = {**config_entry.options} + new[KEY_ESTIMATE] = "estimate" + config_entry.version = 7 + + hass.config_entries.async_update_entry(config_entry, options=new) + + _LOGGER.debug("Solcast Migration to version %s successful", config_entry.version) + + return True diff --git a/custom_components/solcast_solar/__pycache__/__init__.cpython-312.pyc b/custom_components/solcast_solar/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..52884e1 Binary files /dev/null and b/custom_components/solcast_solar/__pycache__/__init__.cpython-312.pyc differ diff --git a/custom_components/solcast_solar/__pycache__/config_flow.cpython-312.pyc b/custom_components/solcast_solar/__pycache__/config_flow.cpython-312.pyc new file mode 100644 index 0000000..e002a4e Binary files /dev/null and b/custom_components/solcast_solar/__pycache__/config_flow.cpython-312.pyc differ diff --git a/custom_components/solcast_solar/__pycache__/const.cpython-312.pyc b/custom_components/solcast_solar/__pycache__/const.cpython-312.pyc new file mode 100644 index 0000000..0433f43 Binary files /dev/null and b/custom_components/solcast_solar/__pycache__/const.cpython-312.pyc differ diff --git a/custom_components/solcast_solar/__pycache__/coordinator.cpython-312.pyc b/custom_components/solcast_solar/__pycache__/coordinator.cpython-312.pyc new file mode 100644 index 0000000..21a0706 Binary files /dev/null and b/custom_components/solcast_solar/__pycache__/coordinator.cpython-312.pyc differ diff --git a/custom_components/solcast_solar/__pycache__/diagnostics.cpython-312.pyc b/custom_components/solcast_solar/__pycache__/diagnostics.cpython-312.pyc new file mode 100644 index 0000000..9f915ac Binary files /dev/null and b/custom_components/solcast_solar/__pycache__/diagnostics.cpython-312.pyc differ diff --git a/custom_components/solcast_solar/__pycache__/energy.cpython-312.pyc b/custom_components/solcast_solar/__pycache__/energy.cpython-312.pyc new file mode 100644 index 0000000..239fcc8 Binary files /dev/null and b/custom_components/solcast_solar/__pycache__/energy.cpython-312.pyc differ diff --git a/custom_components/solcast_solar/__pycache__/recorder.cpython-312.pyc b/custom_components/solcast_solar/__pycache__/recorder.cpython-312.pyc new file mode 100644 index 0000000..3df1401 Binary files /dev/null and b/custom_components/solcast_solar/__pycache__/recorder.cpython-312.pyc differ diff --git a/custom_components/solcast_solar/__pycache__/select.cpython-312.pyc b/custom_components/solcast_solar/__pycache__/select.cpython-312.pyc new file mode 100644 index 0000000..6d28d97 Binary files /dev/null and b/custom_components/solcast_solar/__pycache__/select.cpython-312.pyc differ diff --git a/custom_components/solcast_solar/__pycache__/sensor.cpython-312.pyc b/custom_components/solcast_solar/__pycache__/sensor.cpython-312.pyc new file mode 100644 index 0000000..e1e1c13 Binary files /dev/null and b/custom_components/solcast_solar/__pycache__/sensor.cpython-312.pyc differ diff --git a/custom_components/solcast_solar/__pycache__/solcastapi.cpython-312.pyc b/custom_components/solcast_solar/__pycache__/solcastapi.cpython-312.pyc new file mode 100644 index 0000000..14e9d85 Binary files /dev/null and b/custom_components/solcast_solar/__pycache__/solcastapi.cpython-312.pyc differ diff --git a/custom_components/solcast_solar/__pycache__/system_health.cpython-312.pyc b/custom_components/solcast_solar/__pycache__/system_health.cpython-312.pyc new file mode 100644 index 0000000..dc2201d Binary files /dev/null and b/custom_components/solcast_solar/__pycache__/system_health.cpython-312.pyc differ diff --git a/custom_components/solcast_solar/config_flow.py b/custom_components/solcast_solar/config_flow.py new file mode 100644 index 0000000..66fd797 --- /dev/null +++ b/custom_components/solcast_solar/config_flow.py @@ -0,0 +1,331 @@ +"""Config flow for Solcast Solar integration.""" +from __future__ import annotations +from typing import Any + +import voluptuous as vol +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant import config_entries +from .const import DOMAIN, CONFIG_OPTIONS, CUSTOM_HOUR_SENSOR + +@config_entries.HANDLERS.register(DOMAIN) +class SolcastSolarFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Solcast Solar.""" + + VERSION = 6 #v5 started in 4.0.8, #6 started 4.0.15 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> SolcastSolarOptionFlowHandler: + """Get the options flow for this handler.""" + return SolcastSolarOptionFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + return self.async_create_entry( + title= "Solcast Solar", + data = {}, + options={ + CONF_API_KEY: user_input[CONF_API_KEY], + "damp00":1.0, + "damp01":1.0, + "damp02":1.0, + "damp03":1.0, + "damp04":1.0, + "damp05":1.0, + "damp06":1.0, + "damp07":1.0, + "damp08":1.0, + "damp09":1.0, + "damp10":1.0, + "damp11":1.0, + "damp12":1.0, + "damp13":1.0, + "damp14":1.0, + "damp15":1.0, + "damp16":1.0, + "damp17":1.0, + "damp18":1.0, + "damp19":1.0, + "damp20":1.0, + "damp21":1.0, + "damp22":1.0, + "damp23":1.0, + "customhoursensor":1, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY, default=""): str, + } + ), + ) + + +class SolcastSolarOptionFlowHandler(OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): + errors = {} + if user_input is not None: + if "solcast_config_action" in user_input: + nextAction = user_input["solcast_config_action"] + if nextAction == "configure_dampening": + return await self.async_step_dampen() + elif nextAction == "configure_api": + return await self.async_step_api() + elif nextAction == "configure_customsensor": + return await self.async_step_customsensor() + else: + errors["base"] = "incorrect_options_action" + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required("solcast_config_action"): SelectSelector( + SelectSelectorConfig( + options=CONFIG_OPTIONS, + mode=SelectSelectorMode.LIST, + translation_key="solcast_config_action", + ) + ) + } + ), + errors=errors + ) + + async def async_step_api(self, user_input: dict[str, Any] | None = None) -> FlowResult: + """Manage the options.""" + if user_input is not None: + allConfigData = {**self.config_entry.options} + k = user_input["api_key"].replace(" ","").strip() + k = ','.join([s for s in k.split(',') if s]) + allConfigData["api_key"] = k + + self.hass.config_entries.async_update_entry( + self.config_entry, + title="Solcast Solar", + options=allConfigData, + ) + return self.async_create_entry(title="Solcast Solar", data=None) + + return self.async_show_form( + step_id="api", + data_schema=vol.Schema( + { + vol.Required( + CONF_API_KEY, + default=self.config_entry.options.get(CONF_API_KEY), + ): str, + } + ), + ) + + async def async_step_dampen(self, user_input: dict[str, Any] | None = None) -> FlowResult: #user_input=None): + """Manage the hourly factor options.""" + + errors = {} + + damp00 = self.config_entry.options["damp00"] + damp01 = self.config_entry.options["damp01"] + damp02 = self.config_entry.options["damp02"] + damp03 = self.config_entry.options["damp03"] + damp04 = self.config_entry.options["damp04"] + damp05 = self.config_entry.options["damp05"] + damp06 = self.config_entry.options["damp06"] + damp07 = self.config_entry.options["damp07"] + damp08 = self.config_entry.options["damp08"] + damp09 = self.config_entry.options["damp09"] + damp10 = self.config_entry.options["damp10"] + damp11 = self.config_entry.options["damp11"] + damp12 = self.config_entry.options["damp12"] + damp13 = self.config_entry.options["damp13"] + damp14 = self.config_entry.options["damp14"] + damp15 = self.config_entry.options["damp15"] + damp16 = self.config_entry.options["damp16"] + damp17 = self.config_entry.options["damp17"] + damp18 = self.config_entry.options["damp18"] + damp19 = self.config_entry.options["damp19"] + damp20 = self.config_entry.options["damp20"] + damp21 = self.config_entry.options["damp21"] + damp22 = self.config_entry.options["damp22"] + damp23 = self.config_entry.options["damp23"] + + if user_input is not None: + try: + damp00 = user_input["damp00"] + damp01 = user_input["damp01"] + damp02 = user_input["damp02"] + damp03 = user_input["damp03"] + damp04 = user_input["damp04"] + damp05 = user_input["damp05"] + damp06 = user_input["damp06"] + damp07 = user_input["damp07"] + damp08 = user_input["damp08"] + damp09 = user_input["damp09"] + damp10 = user_input["damp10"] + damp11 = user_input["damp11"] + damp12 = user_input["damp12"] + damp13 = user_input["damp13"] + damp14 = user_input["damp14"] + damp15 = user_input["damp15"] + damp16 = user_input["damp16"] + damp17 = user_input["damp17"] + damp18 = user_input["damp18"] + damp19 = user_input["damp19"] + damp20 = user_input["damp20"] + damp21 = user_input["damp21"] + damp22 = user_input["damp22"] + damp23 = user_input["damp23"] + + allConfigData = {**self.config_entry.options} + allConfigData["damp00"] = damp00 + allConfigData["damp01"] = damp01 + allConfigData["damp02"] = damp02 + allConfigData["damp03"] = damp03 + allConfigData["damp04"] = damp04 + allConfigData["damp05"] = damp05 + allConfigData["damp06"] = damp06 + allConfigData["damp07"] = damp07 + allConfigData["damp08"] = damp08 + allConfigData["damp09"] = damp09 + allConfigData["damp10"] = damp10 + allConfigData["damp11"] = damp11 + allConfigData["damp12"] = damp12 + allConfigData["damp13"] = damp13 + allConfigData["damp14"] = damp14 + allConfigData["damp15"] = damp15 + allConfigData["damp16"] = damp16 + allConfigData["damp17"] = damp17 + allConfigData["damp18"] = damp18 + allConfigData["damp19"] = damp19 + allConfigData["damp20"] = damp20 + allConfigData["damp21"] = damp21 + allConfigData["damp22"] = damp22 + allConfigData["damp23"] = damp23 + + self.hass.config_entries.async_update_entry( + self.config_entry, + title="Solcast Solar", + options=allConfigData, + ) + + return self.async_create_entry(title="Solcast Solar", data=None) + except Exception as e: + errors["base"] = "unknown" + + return self.async_show_form( + step_id="dampen", + data_schema=vol.Schema( + { + vol.Required("damp00", description={"suggested_value": damp00}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp01", description={"suggested_value": damp01}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp02", description={"suggested_value": damp02}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp03", description={"suggested_value": damp03}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp04", description={"suggested_value": damp04}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp05", description={"suggested_value": damp05}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp06", description={"suggested_value": damp06}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp07", description={"suggested_value": damp07}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp08", description={"suggested_value": damp08}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp09", description={"suggested_value": damp09}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp10", description={"suggested_value": damp10}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp11", description={"suggested_value": damp11}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp12", description={"suggested_value": damp12}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp13", description={"suggested_value": damp13}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp14", description={"suggested_value": damp14}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp15", description={"suggested_value": damp15}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp16", description={"suggested_value": damp16}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp17", description={"suggested_value": damp17}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp18", description={"suggested_value": damp18}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp19", description={"suggested_value": damp19}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp20", description={"suggested_value": damp20}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp21", description={"suggested_value": damp21}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp22", description={"suggested_value": damp22}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + vol.Required("damp23", description={"suggested_value": damp23}): + vol.All(vol.Coerce(float), vol.Range(min=0.0,max=1.0)), + } + ), + errors=errors, + ) + + async def async_step_customsensor(self, user_input: dict[str, Any] | None = None) -> FlowResult: + """Manage the custom x hour sensor option.""" + + errors = {} + + customhoursensor = self.config_entry.options[CUSTOM_HOUR_SENSOR] + + if user_input is not None: + try: + customhoursensor = user_input[CUSTOM_HOUR_SENSOR] + + allConfigData = {**self.config_entry.options} + allConfigData[CUSTOM_HOUR_SENSOR] = customhoursensor + + self.hass.config_entries.async_update_entry( + self.config_entry, + title="Solcast Solar", + options=allConfigData, + ) + + return self.async_create_entry(title="Solcast Solar", data=None) + except Exception as e: + errors["base"] = "unknown" + + return self.async_show_form( + step_id="customsensor", + data_schema=vol.Schema( + { + vol.Required(CUSTOM_HOUR_SENSOR, description={"suggested_value": customhoursensor}): + vol.All(vol.Coerce(int), vol.Range(min=1,max=144)), + } + ), + errors=errors, + ) \ No newline at end of file diff --git a/custom_components/solcast_solar/const.py b/custom_components/solcast_solar/const.py new file mode 100644 index 0000000..a624abd --- /dev/null +++ b/custom_components/solcast_solar/const.py @@ -0,0 +1,34 @@ +"""Constants for the Solcast Solar integration.""" + +from __future__ import annotations + +from typing import Final + +from homeassistant.helpers import selector + +DOMAIN = "solcast_solar" +SOLCAST_URL = "https://api.solcast.com.au" + + +ATTR_ENTRY_TYPE: Final = "entry_type" +ENTRY_TYPE_SERVICE: Final = "service" + +ATTRIBUTION: Final = "Data retrieved from Solcast" + +CUSTOM_HOUR_SENSOR = "customhoursensor" +KEY_ESTIMATE = "key_estimate" + +SERVICE_UPDATE = "update_forecasts" +SERVICE_CLEAR_DATA = "clear_all_solcast_data" +SERVICE_QUERY_FORECAST_DATA = "query_forecast_data" +SERVICE_SET_DAMPENING = "set_dampening" +SERVICE_SET_HARD_LIMIT = "set_hard_limit" +SERVICE_REMOVE_HARD_LIMIT = "remove_hard_limit" + +#new 4.0.8 - integration config options menu +#new 4.0.15 - integration config options for custom hour (option 3) +CONFIG_OPTIONS = [ + selector.SelectOptionDict(value="configure_api", label="option1"), + selector.SelectOptionDict(value="configure_dampening", label="option2"), + selector.SelectOptionDict(value="configure_customsensor", label="option3"), +] \ No newline at end of file diff --git a/custom_components/solcast_solar/coordinator.py b/custom_components/solcast_solar/coordinator.py new file mode 100644 index 0000000..734232f --- /dev/null +++ b/custom_components/solcast_solar/coordinator.py @@ -0,0 +1,171 @@ +"""The Solcast Solar integration.""" +from __future__ import annotations + +import logging +import traceback + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_utc_time_change + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN +from .solcastapi import SolcastApi + +_LOGGER = logging.getLogger(__name__) + +class SolcastUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from Solcast Solar API.""" + + def __init__(self, hass: HomeAssistant, solcast: SolcastApi, version: str) -> None: + """Initialize.""" + self.solcast = solcast + self._hass = hass + self._previousenergy = None + self._version = version + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + ) + + + async def _async_update_data(self): + """Update data via library.""" + return self.solcast._data + + async def setup(self): + d={} + self._previousenergy = d + + try: + #4.0.18 - added reset usage call to reset usage sensors at UTC midnight + async_track_utc_time_change(self._hass, self.update_utcmidnight_usage_sensor_data, hour=0,minute=0,second=0) + async_track_utc_time_change(self._hass, self.update_integration_listeners, second=0) + except Exception as error: + _LOGGER.error("SOLCAST - Error coordinator setup: %s", traceback.format_exc()) + + + async def update_integration_listeners(self, *args): + try: + self.async_update_listeners() + except Exception: + #_LOGGER.error("SOLCAST - update_integration_listeners: %s", traceback.format_exc()) + pass + + async def update_utcmidnight_usage_sensor_data(self, *args): + try: + self.solcast._api_used = 0 + self.async_update_listeners() + except Exception: + #_LOGGER.error("SOLCAST - update_utcmidnight_usage_sensor_data: %s", traceback.format_exc()) + pass + + async def service_event_update(self, *args): + #await self.solcast.sites_weather() + await self.solcast.http_data(dopast=False) + await self.update_integration_listeners() + + async def service_event_delete_old_solcast_json_file(self, *args): + await self.solcast.delete_solcast_file() + + async def service_query_forecast_data(self, *args) -> tuple: + return await self.solcast.get_forecast_list(*args) + + def get_energy_tab_data(self): + return self.solcast.get_energy_data() + + def get_sensor_value(self, key=""): + if key == "total_kwh_forecast_today": + return self.solcast.get_total_kwh_forecast_day(0) + elif key == "peak_w_today": + return self.solcast.get_peak_w_day(0) + elif key == "peak_w_time_today": + return self.solcast.get_peak_w_time_day(0) + elif key == "forecast_this_hour": + return self.solcast.get_forecast_n_hour(0) + elif key == "forecast_next_hour": + return self.solcast.get_forecast_n_hour(1) + elif key == "forecast_custom_hour": + return self.solcast.get_forecast_custom_hour(self.solcast._customhoursensor) + elif key == "forecast_next_12hour": + return self.solcast.get_forecast_n_hour(12) + elif key == "forecast_next_24hour": + return self.solcast.get_forecast_n_hour(24) + elif key == "total_kwh_forecast_tomorrow": + return self.solcast.get_total_kwh_forecast_day(1) + elif key == "total_kwh_forecast_d3": + return self.solcast.get_total_kwh_forecast_day(2) + elif key == "total_kwh_forecast_d4": + return self.solcast.get_total_kwh_forecast_day(3) + elif key == "total_kwh_forecast_d5": + return self.solcast.get_total_kwh_forecast_day(4) + elif key == "total_kwh_forecast_d6": + return self.solcast.get_total_kwh_forecast_day(5) + elif key == "total_kwh_forecast_d7": + return self.solcast.get_total_kwh_forecast_day(6) + elif key == "power_now": + return self.solcast.get_power_production_n_mins(0) + elif key == "power_now_30m": + return self.solcast.get_power_production_n_mins(30) + elif key == "power_now_1hr": + return self.solcast.get_power_production_n_mins(60) + elif key == "power_now_12hr": + return self.solcast.get_power_production_n_mins(60*12) + elif key == "power_now_24hr": + return self.solcast.get_power_production_n_mins(60*24) + elif key == "peak_w_tomorrow": + return self.solcast.get_peak_w_day(1) + elif key == "peak_w_time_tomorrow": + return self.solcast.get_peak_w_time_day(1) + elif key == "get_remaining_today": + return self.solcast.get_remaining_today() + elif key == "api_counter": + return self.solcast.get_api_used_count() + elif key == "api_limit": + return self.solcast.get_api_limit() + elif key == "lastupdated": + return self.solcast.get_last_updated_datetime() + elif key == "hard_limit": + #return self.solcast._hardlimit < 100 + return False if self.solcast._hardlimit == 100 else f"{round(self.solcast._hardlimit * 1000)}w" + # elif key == "weather_description": + # return self.solcast.get_weather() + + + #just in case + return None + + def get_sensor_extra_attributes(self, key=""): + if key == "total_kwh_forecast_today": + return self.solcast.get_forecast_day(0) + elif key == "total_kwh_forecast_tomorrow": + return self.solcast.get_forecast_day(1) + elif key == "total_kwh_forecast_d3": + return self.solcast.get_forecast_day(2) + elif key == "total_kwh_forecast_d4": + return self.solcast.get_forecast_day(3) + elif key == "total_kwh_forecast_d5": + return self.solcast.get_forecast_day(4) + elif key == "total_kwh_forecast_d6": + return self.solcast.get_forecast_day(5) + elif key == "total_kwh_forecast_d7": + return self.solcast.get_forecast_day(6) + + #just in case + return None + + def get_site_sensor_value(self, roof_id, key): + match key: + case "site_data": + return self.solcast.get_rooftop_site_total_today(roof_id) + case _: + return None + + def get_site_sensor_extra_attributes(self, roof_id, key): + match key: + case "site_data": + return self.solcast.get_rooftop_site_extra_data(roof_id) + case _: + return None diff --git a/custom_components/solcast_solar/diagnostics.py b/custom_components/solcast_solar/diagnostics.py new file mode 100644 index 0000000..057a0be --- /dev/null +++ b/custom_components/solcast_solar/diagnostics.py @@ -0,0 +1,34 @@ +"""Support for the Solcast diagnostics.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import SolcastUpdateCoordinator + +TO_REDACT = [ + CONF_API_KEY, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: SolcastUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + return { + "tz_conversion": coordinator.solcast._tz, + "used_api_requests": coordinator.solcast.get_api_used_count(), + "api_request_limit": coordinator.solcast.get_api_limit(), + "rooftop_site_count": len(coordinator.solcast._sites), + "forecast_hard_limit_set": coordinator.solcast._hardlimit < 100, + "data": (coordinator.data, TO_REDACT), + "energy_history_graph": coordinator._previousenergy, + "energy_forecasts_graph": coordinator.solcast._dataenergy["wh_hours"], + } + diff --git a/custom_components/solcast_solar/energy.py b/custom_components/solcast_solar/energy.py new file mode 100644 index 0000000..57e7c0f --- /dev/null +++ b/custom_components/solcast_solar/energy.py @@ -0,0 +1,20 @@ +"""Energy platform.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from . import SolcastUpdateCoordinator +from .const import DOMAIN + + +async def async_get_solar_forecast(hass: HomeAssistant, config_entry_id: str): + """Get solar forecast for a config entry ID.""" + + coordinator: SolcastUpdateCoordinator = hass.data[DOMAIN][config_entry_id] + + if coordinator is None: + return None + + return coordinator.get_energy_tab_data() + + diff --git a/custom_components/solcast_solar/icons.json b/custom_components/solcast_solar/icons.json new file mode 100644 index 0000000..8e2aa2f --- /dev/null +++ b/custom_components/solcast_solar/icons.json @@ -0,0 +1,8 @@ +{ + "services": { + "update_forecasts": "mdi:sun-wireless-outline", + "clear_all_solcast_data": "mdi:database-off", + "query_forecast_data": "mdi:table-question", + "set_dampening": "mdi:format-align-middle" + } +} diff --git a/custom_components/solcast_solar/manifest.json b/custom_components/solcast_solar/manifest.json new file mode 100644 index 0000000..6a711b4 --- /dev/null +++ b/custom_components/solcast_solar/manifest.json @@ -0,0 +1,26 @@ +{ + "domain": "solcast_solar", + "name": "Solcast PV Forecast", + "after_dependencies": [ + "http" + ], + "codeowners": [ + "@oziee" + ], + "config_flow": true, + "dependencies": [ + "homeassistant", + "recorder", + "select" + ], + "documentation": "https://github.com/oziee/ha-solcast-solar", + "integration_type": "service", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/oziee/ha-solcast-solar/issues", + "requirements": [ + "aiohttp>=3.8.5", + "datetime>=4.3", + "isodate>=0.6.1" + ], + "version": "v4.0.22" +} diff --git a/custom_components/solcast_solar/recorder.py b/custom_components/solcast_solar/recorder.py new file mode 100644 index 0000000..8a8d2a3 --- /dev/null +++ b/custom_components/solcast_solar/recorder.py @@ -0,0 +1,10 @@ +"""Integration platform for recorder.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant, callback + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude potentially large attributes from being recorded in the database.""" + return {"detailedForecast", "detailedHourly", "hard_limit"} \ No newline at end of file diff --git a/custom_components/solcast_solar/select.py b/custom_components/solcast_solar/select.py new file mode 100644 index 0000000..3ebd9a1 --- /dev/null +++ b/custom_components/solcast_solar/select.py @@ -0,0 +1,133 @@ +"""Selector to allow users to select the pv_ data field to use for calcualtions.""" +import logging + +from enum import IntEnum + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.const import ( + EntityCategory, + ATTR_CONFIGURATION_URL, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTRIBUTION, DOMAIN, KEY_ESTIMATE, ATTR_ENTRY_TYPE +from .coordinator import SolcastUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class PVEstimateMode(IntEnum): + """ + Enumeration of pv forecasts kinds. + + Possible values are: + ESTIMATE - Default forecasts + ESTIMATE10 = Forecasts 10 - cloudier than expected scenario + ESTIMATE90 = Forecasts 90 - less cloudy than expected scenario + + """ + + ESTIMATE = 0 + ESTIMATE10 = 1 + ESTIMATE90 = 2 + + +_MODE_TO_OPTION: dict[PVEstimateMode, str] = { + PVEstimateMode.ESTIMATE: "estimate", + PVEstimateMode.ESTIMATE10: "estimate10", + PVEstimateMode.ESTIMATE90: "estimate90", +} + +# _OPTION_TO_MODE: dict[str, PVEstimateMode] = { +# value: key for key, value in _MODE_TO_OPTION.items() +# } + +ESTIMATE_MODE = SelectEntityDescription( + key="estimate_mode", + icon="mdi:sun-angle", + entity_category=EntityCategory.CONFIG, + translation_key="estimate_mode", +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + + coordinator: SolcastUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + try: + est_mode = coordinator.solcast.options.key_estimate + except (ValueError): + _LOGGER.debug("Could not read estimate mode", exc_info=True) + else: + entity = EstimateModeEntity( + coordinator, + ESTIMATE_MODE, + [v for k, v in _MODE_TO_OPTION.items()], + est_mode, + entry, + ) + async_add_entities([entity]) + + +class EstimateModeEntity(SelectEntity): + """Entity representing the solcast estimate field to use for calculations.""" + + _attr_attribution = ATTRIBUTION + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SolcastUpdateCoordinator, + entity_description: SelectEntityDescription, + supported_options: list[str], + current_option: str, + entry: ConfigEntry, + ) -> None: + """Initialize the sensor.""" + + self.coordinator = coordinator + self._entry = entry + + self.entity_description = entity_description + self._attr_unique_id = f"{entity_description.key}" + + self._attr_options = supported_options + self._attr_current_option = current_option + + self._attr_entity_category = EntityCategory.CONFIG + + self._attributes = {} + self._attr_extra_state_attributes = {} + + self._attr_device_info = { + ATTR_IDENTIFIERS: {(DOMAIN, entry.entry_id)}, + ATTR_NAME: "Solcast PV Forecast", + ATTR_MANUFACTURER: "Oziee", + ATTR_MODEL: "Solcast PV Forecast", + ATTR_ENTRY_TYPE: DeviceEntryType.SERVICE, + ATTR_SW_VERSION: coordinator._version, + ATTR_CONFIGURATION_URL: "https://toolkit.solcast.com.au/", + } + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self._attr_current_option = option + self.async_write_ha_state() + + new = {**self._entry.options} + new[KEY_ESTIMATE] = option + + self.coordinator._hass.config_entries.async_update_entry(self._entry, options=new) diff --git a/custom_components/solcast_solar/sensor.py b/custom_components/solcast_solar/sensor.py new file mode 100644 index 0000000..c281ce6 --- /dev/null +++ b/custom_components/solcast_solar/sensor.py @@ -0,0 +1,462 @@ +"""Support for Solcast PV forecast sensors.""" + +from __future__ import annotations + +import logging +import traceback +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_CONFIGURATION_URL, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, ATTR_ENTRY_TYPE, ATTRIBUTION +from .coordinator import SolcastUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +SENSORS: dict[str, SensorEntityDescription] = { + "total_kwh_forecast_today": SensorEntityDescription( + key="total_kwh_forecast_today", + translation_key="total_kwh_forecast_today", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + name="Forecast Today", + icon="mdi:solar-power", + suggested_display_precision=2, + #state_class= SensorStateClass.MEASUREMENT, + ), + "peak_w_today": SensorEntityDescription( + key="peak_w_today", + translation_key="peak_w_today", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + name="Peak Forecast Today", + icon="mdi:solar-power", + suggested_display_precision=0, + state_class= SensorStateClass.MEASUREMENT, + ), + "peak_w_time_today": SensorEntityDescription( + key="peak_w_time_today", + translation_key="peak_w_time_today", + name="Peak Time Today", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + #suggested_display_precision=0, + ), + "forecast_this_hour": SensorEntityDescription( + key="forecast_this_hour", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + translation_key="forecast_this_hour", + name="Forecast This Hour", + icon="mdi:solar-power", + suggested_display_precision=0, + ), + "forecast_remaining_today": SensorEntityDescription( + key="get_remaining_today", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + translation_key="get_remaining_today", + name="Forecast Remaining Today", + icon="mdi:solar-power", + suggested_display_precision=2, + ), + "forecast_next_hour": SensorEntityDescription( + key="forecast_next_hour", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + translation_key="forecast_next_hour", + name="Forecast Next Hour", + icon="mdi:solar-power", + suggested_display_precision=0, + ), + "forecast_custom_hour": SensorEntityDescription( + key="forecast_custom_hour", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + translation_key="forecast_custom_hour", + name="Forecast Custom Hours", + icon="mdi:solar-power", + suggested_display_precision=0, + ), + "total_kwh_forecast_tomorrow": SensorEntityDescription( + key="total_kwh_forecast_tomorrow", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + translation_key="total_kwh_forecast_tomorrow", + name="Forecast Tomorrow", + icon="mdi:solar-power", + suggested_display_precision=2, + ), + "peak_w_tomorrow": SensorEntityDescription( + key="peak_w_tomorrow", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + translation_key="peak_w_tomorrow", + name="Peak Forecast Tomorrow", + icon="mdi:solar-power", + suggested_display_precision=0, + ), + "peak_w_time_tomorrow": SensorEntityDescription( + key="peak_w_time_tomorrow", + translation_key="peak_w_time_tomorrow", + name="Peak Time Tomorrow", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + # suggested_display_precision=0, + ), + "api_counter": SensorEntityDescription( + key="api_counter", + translation_key="api_counter", + name="API Used", + icon="mdi:web-check", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "api_limit": SensorEntityDescription( + key="api_limit", + translation_key="api_limit", + name="API Limit", + icon="mdi:web-check", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "lastupdated": SensorEntityDescription( + key="lastupdated", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="lastupdated", + name="API Last Polled", + icon="mdi:clock", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "hard_limit": SensorEntityDescription( + key="hard_limit", + translation_key="hard_limit", + name="Hard Limit Set", + icon="mdi:speedometer", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "total_kwh_forecast_d3": SensorEntityDescription( + key="total_kwh_forecast_d3", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + translation_key="total_kwh_forecast_d3", + name="Forecast D3", + icon="mdi:solar-power", + suggested_display_precision=2, + ), + "total_kwh_forecast_d4": SensorEntityDescription( + key="total_kwh_forecast_d4", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + translation_key="total_kwh_forecast_d4", + name="Forecast D4", + icon="mdi:solar-power", + suggested_display_precision=2, + ), + "total_kwh_forecast_d5": SensorEntityDescription( + key="total_kwh_forecast_d5", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + translation_key="total_kwh_forecast_d5", + name="Forecast D5", + icon="mdi:solar-power", + suggested_display_precision=2, + ), + "total_kwh_forecast_d6": SensorEntityDescription( + key="total_kwh_forecast_d6", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + translation_key="total_kwh_forecast_d6", + name="Forecast D6", + icon="mdi:solar-power", + suggested_display_precision=2, + ), + "total_kwh_forecast_d7": SensorEntityDescription( + key="total_kwh_forecast_d7", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + translation_key="total_kwh_forecast_d7", + name="Forecast D7", + icon="mdi:solar-power", + suggested_display_precision=2, + ), + "power_now": SensorEntityDescription( + key="power_now", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + translation_key="power_now", + name="Power Now", + suggested_display_precision=0, + state_class= SensorStateClass.MEASUREMENT, + ), + "power_now_30m": SensorEntityDescription( + key="power_now_30m", + translation_key="power_now_30m", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + #name="Power Next 30 Mins", + suggested_display_precision=0, + ), + "power_now_1hr": SensorEntityDescription( + key="power_now_1hr", + translation_key="power_now_1hr", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + #name="Power Next Hour", + suggested_display_precision=0, + ), + #"weather_description": SensorEntityDescription( + #key="weather_description", + #translation_key="weather_description", + #icon="mdi:weather-partly-snowy-rainy", + #), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Solcast sensor.""" + + coordinator: SolcastUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + entities = [] + + for sensor_types in SENSORS: + sen = SolcastSensor(coordinator, SENSORS[sensor_types], entry) + entities.append(sen) + + for site in coordinator.solcast._sites: + k = RooftopSensorEntityDescription( + key=site["resource_id"], + name=site["name"], + icon="mdi:home", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + rooftop_id=site["resource_id"], + ) + + sen = RooftopSensor( + key="site_data", + coordinator=coordinator, + entity_description=k, + entry=entry, + ) + + entities.append(sen) + + async_add_entities(entities) + +class SolcastSensor(CoordinatorEntity, SensorEntity): + """Representation of a Solcast Sensor device.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SolcastUpdateCoordinator, + entity_description: SensorEntityDescription, + entry: ConfigEntry, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + #doesnt work :() + if entity_description.key == "forecast_custom_hour": + self._attr_translation_placeholders = {"forecast_custom_hour": f"{coordinator.solcast._customhoursensor}"} + + self.entity_description = entity_description + self.coordinator = coordinator + self._attr_unique_id = f"{entity_description.key}" + + self._attributes = {} + self._attr_extra_state_attributes = {} + + try: + self._sensor_data = coordinator.get_sensor_value(entity_description.key) + except Exception as ex: + _LOGGER.error( + f"SOLCAST - unable to get sensor value {ex} %s", traceback.format_exc() + ) + self._sensor_data = None + + self._attr_device_info = { + ATTR_IDENTIFIERS: {(DOMAIN, entry.entry_id)}, + ATTR_NAME: "Solcast PV Forecast", #entry.title, + ATTR_MANUFACTURER: "Oziee", + ATTR_MODEL: "Solcast PV Forecast", + ATTR_ENTRY_TYPE: DeviceEntryType.SERVICE, + ATTR_SW_VERSION: coordinator._version, + ATTR_CONFIGURATION_URL: "https://toolkit.solcast.com.au/", + } + + + @property + def extra_state_attributes(self): + """Return the state extra attributes of the sensor.""" + try: + return self.coordinator.get_sensor_extra_attributes( + self.entity_description.key + ) + except Exception as ex: + _LOGGER.error( + f"SOLCAST - unable to get sensor value {ex} %s", traceback.format_exc() + ) + return None + + @property + def native_value(self): + """Return the value reported by the sensor.""" + return self._sensor_data + + @property + def should_poll(self) -> bool: + """Return if the sensor should poll.""" + return False + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + try: + self._sensor_data = self.coordinator.get_sensor_value( + self.entity_description.key + ) + except Exception as ex: + _LOGGER.error( + f"SOLCAST - unable to get sensor value {ex} %s", traceback.format_exc() + ) + self._sensor_data = None + self.async_write_ha_state() + +@dataclass +class RooftopSensorEntityDescription(SensorEntityDescription): + rooftop_id: str | None = None + +class RooftopSensor(CoordinatorEntity, SensorEntity): + """Representation of a Solcast Sensor device.""" + + _attr_attribution = ATTRIBUTION + + def __init__( + self, + *, + key: str, + coordinator: SolcastUpdateCoordinator, + entity_description: SensorEntityDescription, + entry: ConfigEntry, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.key = key + self.coordinator = coordinator + self.entity_description = entity_description + self.rooftop_id = entity_description.rooftop_id + + self._attributes = {} + self._attr_extra_state_attributes = {} + self._attr_entity_category = EntityCategory.DIAGNOSTIC + + try: + self._sensor_data = coordinator.get_site_sensor_value(self.rooftop_id, key) + except Exception as ex: + _LOGGER.error( + f"SOLCAST - unable to get sensor value {ex} %s", traceback.format_exc() + ) + self._sensor_data = None + + self._attr_device_info = { + ATTR_IDENTIFIERS: {(DOMAIN, entry.entry_id)}, + ATTR_NAME: "Solcast PV Forecast", #entry.title, + ATTR_MANUFACTURER: "Oziee", + ATTR_MODEL: "Solcast PV Forecast", + ATTR_ENTRY_TYPE: DeviceEntryType.SERVICE, + ATTR_SW_VERSION: coordinator._version, + ATTR_CONFIGURATION_URL: "https://toolkit.solcast.com.au/", + } + + self._unique_id = f"solcast_api_{entity_description.name}" + + @property + def name(self): + """Return the name of the device.""" + return f"{self.entity_description.name}" + + @property + def friendly_name(self): + """Return the name of the device.""" + return self.entity_description.name + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"solcast_{self._unique_id}" + + @property + def extra_state_attributes(self): + """Return the state extra attributes of the sensor.""" + try: + return self.coordinator.get_site_sensor_extra_attributes( + self.rooftop_id, + self.key, + ) + except Exception as ex: + _LOGGER.error( + f"SOLCAST - unable to get sensor value {ex} %s", traceback.format_exc() + ) + return None + + @property + def native_value(self): + """Return the value reported by the sensor.""" + return self._sensor_data + + @property + def should_poll(self) -> bool: + """Return if the sensor should poll.""" + return False + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener(self._handle_coordinator_update) + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + try: + self._sensor_data = self.coordinator.get_site_sensor_value( + self.rooftop_id, + self.key, + ) + except Exception as ex: + _LOGGER.error( + f"SOLCAST - unable to get sensor value {ex} %s", traceback.format_exc() + ) + self._sensor_data = None + self.async_write_ha_state() diff --git a/custom_components/solcast_solar/services.yaml b/custom_components/solcast_solar/services.yaml new file mode 100644 index 0000000..219660c --- /dev/null +++ b/custom_components/solcast_solar/services.yaml @@ -0,0 +1,39 @@ +# Describes the format for available services for the Solcast integration +update_forecasts: + name: Update + description: Fetches the forecasts from Solcast. + +clear_all_solcast_data: + name: Clear saved Solcast site data + description: Deletes the solcast.json file to remove all current solcast site data + +query_forecast_data: + name: Query forecasts + description: List of forecasts between start datetime and end datetime + fields: + start_date_time: + example: "2023-09-09T00:00:00" + selector: + datetime: + end_date_time: + example: "2023-09-10T10:00:00" + selector: + datetime: + +set_dampening: + name: Set forecasts dampening + description: Set the hourly forecast dampening factor + fields: + damp_factor: + example: "1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1" + +set_hard_limit: + name: Set inverter forecast hard limit + description: Prevent forcast values being higher than the inverter can produce + fields: + hard_limit: + example: "5000" + +remove_hard_limit: + name: Remove inverter forecast hard limit + description: Remove set limit \ No newline at end of file diff --git a/custom_components/solcast_solar/solcastapi.py b/custom_components/solcast_solar/solcastapi.py new file mode 100644 index 0000000..643d506 --- /dev/null +++ b/custom_components/solcast_solar/solcastapi.py @@ -0,0 +1,806 @@ +"""Solcast API.""" +from __future__ import annotations + +import asyncio +import copy +import json +import logging +import os +import traceback +from dataclasses import dataclass +from datetime import datetime as dt +from datetime import timedelta, timezone +from operator import itemgetter +from os.path import exists as file_exists +from typing import Any, Dict, cast + +import async_timeout +from aiohttp import ClientConnectionError, ClientSession +from aiohttp.client_reqrep import ClientResponse +from isodate import parse_datetime + +_JSON_VERSION = 4 +_LOGGER = logging.getLogger(__name__) + +class DateTimeEncoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, dt): + return o.isoformat() + +class JSONDecoder(json.JSONDecoder): + def __init__(self, *args, **kwargs): + json.JSONDecoder.__init__( + self, object_hook=self.object_hook, *args, **kwargs) + + def object_hook(self, obj): + ret = {} + for key, value in obj.items(): + if key in {'period_start'}: + ret[key] = dt.fromisoformat(value) + else: + ret[key] = value + return ret + +@dataclass +class ConnectionOptions: + """Solcast API options for connection.""" + + api_key: str + host: str + file_path: str + tz: timezone + dampening: dict + customhoursensor: int + key_estimate: str + hard_limit: int + + +class SolcastApi: + """Solcast API rooftop.""" + + def __init__( + self, + aiohttp_session: ClientSession, + options: ConnectionOptions, + apiCacheEnabled: bool = False + ): + """Device init.""" + self.aiohttp_session = aiohttp_session + self.options = options + self.apiCacheEnabled = apiCacheEnabled + self._sites = [] + self._data = {'siteinfo': {}, 'last_updated': dt.fromtimestamp(0, timezone.utc).isoformat()} + self._api_used = 0 + self._api_limit = 0 + self._filename = options.file_path + self._tz = options.tz + self._dataenergy = {} + self._data_forecasts = [] + self._detailedForecasts = [] + self._loaded_data = False + self._serialize_lock = asyncio.Lock() + self._damp =options.dampening + self._customhoursensor = options.customhoursensor + self._use_data_field = f"pv_{options.key_estimate}" + self._hardlimit = options.hard_limit + #self._weather = "" + + async def serialize_data(self): + """Serialize data to file.""" + if not self._loaded_data: + _LOGGER.debug( + f"SOLCAST - serialize_data not saving data as it has not been loaded yet" + ) + return + + async with self._serialize_lock: + with open(self._filename, "w") as f: + json.dump(self._data, f, ensure_ascii=False, cls=DateTimeEncoder) + + async def sites_data(self): + """Request data via the Solcast API.""" + + try: + sp = self.options.api_key.split(",") + for spl in sp: + #params = {"format": "json", "api_key": self.options.api_key} + params = {"format": "json", "api_key": spl.strip()} + _LOGGER.debug(f"SOLCAST - trying to connect to - {self.options.host}/rooftop_sites?format=json&api_key=REDACTED") + async with async_timeout.timeout(60): + apiCacheFileName = "sites.json" + if self.apiCacheEnabled and file_exists(apiCacheFileName): + status = 404 + with open(apiCacheFileName) as f: + resp_json = json.load(f) + status = 200 + else: + resp: ClientResponse = await self.aiohttp_session.get( + url=f"{self.options.host}/rooftop_sites", params=params, ssl=False + ) + + resp_json = await resp.json(content_type=None) + status = resp.status + if self.apiCacheEnabled: + with open(apiCacheFileName, 'w') as f: + json.dump(resp_json, f, ensure_ascii=False) + + _LOGGER.debug(f"SOLCAST - sites_data code http_session returned data type is {type(resp_json)}") + _LOGGER.debug(f"SOLCAST - sites_data code http_session returned status {status}") + + if status == 200: + d = cast(dict, resp_json) + _LOGGER.debug(f"SOLCAST - sites_data returned data: {d}") + for i in d['sites']: + i['apikey'] = spl.strip() + #v4.0.14 to stop HA adding a pin to the map + i.pop('longitude', None) + i.pop('latitude', None) + + self._sites = self._sites + d['sites'] + else: + _LOGGER.warning( + f"SOLCAST - sites_data Solcast.com http status Error {status} - Gathering rooftop sites data." + ) + raise Exception(f"SOLCAST - HTTP sites_data error: Solcast Error gathering rooftop sites data.") + except json.decoder.JSONDecodeError: + _LOGGER.error("SOLCAST - sites_data JSONDecodeError.. The data returned from Solcast is unknown, Solcast site could be having problems") + except ConnectionRefusedError as err: + _LOGGER.error("SOLCAST - sites_data ConnectionRefusedError Error.. %s",err) + except ClientConnectionError as e: + _LOGGER.error('SOLCAST - sites_data Connection Error', str(e)) + except asyncio.TimeoutError: + _LOGGER.error("SOLCAST - sites_data TimeoutError Error - Timed out connection to solcast server") + except Exception as e: + _LOGGER.error("SOLCAST - sites_data Exception error: %s", traceback.format_exc()) + + async def sites_usage(self): + """Request api usage via the Solcast API.""" + + try: + sp = self.options.api_key.split(",") + + params = {"api_key": sp[0]} + _LOGGER.debug(f"SOLCAST - getting API limit and usage from solcast") + async with async_timeout.timeout(60): + resp: ClientResponse = await self.aiohttp_session.get( + url=f"https://api.solcast.com.au/json/reply/GetUserUsageAllowance", params=params, ssl=False + ) + resp_json = await resp.json(content_type=None) + status = resp.status + + if status == 200: + d = cast(dict, resp_json) + _LOGGER.debug(f"SOLCAST - sites_usage returned data: {d}") + self._api_limit = d.get("daily_limit", None) + self._api_used = d.get("daily_limit_consumed", None) + else: + raise Exception(f"SOLCAST - sites_usage: gathering site data failed. request returned Status code: {status} - Responce: {resp_json}.") + + except json.decoder.JSONDecodeError: + _LOGGER.error("SOLCAST - sites_usage JSONDecodeError.. The data returned from Solcast is unknown, Solcast site could be having problems") + except ConnectionRefusedError as err: + _LOGGER.error("SOLCAST - sites_usage Error.. %s",err) + except ClientConnectionError as e: + _LOGGER.error('SOLCAST - sites_usage Connection Error', str(e)) + except asyncio.TimeoutError: + _LOGGER.error("SOLCAST - sites_usage Connection Error - Timed out connection to solcast server") + except Exception as e: + _LOGGER.error("SOLCAST - sites_usage error: %s", traceback.format_exc()) + + # async def sites_weather(self): + # """Request rooftop site weather byline via the Solcast API.""" + + # try: + # if len(self._sites) > 0: + # sp = self.options.api_key.split(",") + # rid = self._sites[0].get("resource_id", None) + + # params = {"resourceId": rid, "api_key": sp[0]} + # _LOGGER.debug(f"SOLCAST - get rooftop weather byline from solcast") + # async with async_timeout.timeout(60): + # resp: ClientResponse = await self.aiohttp_session.get( + # url=f"https://api.solcast.com.au/json/reply/GetRooftopSiteSparklines", params=params, ssl=False + # ) + # resp_json = await resp.json(content_type=None) + # status = resp.status + + # if status == 200: + # d = cast(dict, resp_json) + # _LOGGER.debug(f"SOLCAST - sites_weather returned data: {d}") + # self._weather = d.get("forecast_descriptor", None).get("description", None) + # _LOGGER.debug(f"SOLCAST - rooftop weather description: {self._weather}") + # else: + # raise Exception(f"SOLCAST - sites_weather: gathering rooftop weather description failed. request returned Status code: {status} - Responce: {resp_json}.") + + # except json.decoder.JSONDecodeError: + # _LOGGER.error("SOLCAST - sites_weather JSONDecodeError.. The rooftop weather description from Solcast is unknown, Solcast site could be having problems") + # except ConnectionRefusedError as err: + # _LOGGER.error("SOLCAST - sites_weather Error.. %s",err) + # except ClientConnectionError as e: + # _LOGGER.error('SOLCAST - sites_weather Connection Error', str(e)) + # except asyncio.TimeoutError: + # _LOGGER.error("SOLCAST - sites_weather Connection Error - Timed out connection to solcast server") + # except Exception as e: + # _LOGGER.error("SOLCAST - sites_weather error: %s", traceback.format_exc()) + + async def load_saved_data(self): + try: + if len(self._sites) > 0: + if file_exists(self._filename): + with open(self._filename) as data_file: + jsonData = json.load(data_file, cls=JSONDecoder) + json_version = jsonData.get("version", 1) + #self._weather = jsonData.get("weather", "unknown") + _LOGGER.debug(f"SOLCAST - load_saved_data file exists.. file type is {type(jsonData)}") + if json_version == _JSON_VERSION: + self._loaded_data = True + self._data = jsonData + + #any new API keys so no sites data yet for those + ks = {} + for d in self._sites: + if not any(s == d.get('resource_id', '') for s in jsonData['siteinfo']): + ks[d.get('resource_id')] = d.get('apikey') + + if len(ks.keys()) > 0: + #some api keys rooftop data does not exist yet so go and get it + _LOGGER.debug("SOLCAST - Must be new API jey added so go and get the data for it") + for a in ks: + await self.http_data_call(r_id=a, api=ks[a], dopast=True) + await self.serialize_data() + + #any site changes that need to be removed + l = [] + for s in jsonData['siteinfo']: + if not any(d.get('resource_id', '') == s for d in self._sites): + _LOGGER.info(f"Solcast rooftop resource id {s} no longer part of your system.. removing saved data from cached file") + l.append(s) + + for ll in l: + del jsonData['siteinfo'][ll] + + #create an up to date forecast and make sure the TZ fits just in case its changed + await self.buildforcastdata() + + if not self._loaded_data: + #no file to load + _LOGGER.debug(f"SOLCAST - load_saved_data there is no existing file with saved data to load") + #could be a brand new install of the integation so this is poll once now automatically + await self.http_data(dopast=True) + else: + _LOGGER.debug(f"SOLCAST - load_saved_data site count is zero! ") + except json.decoder.JSONDecodeError: + _LOGGER.error("SOLCAST - load_saved_data error: The cached data is corrupt") + except Exception as e: + _LOGGER.error("SOLCAST - load_saved_data error: %s", traceback.format_exc()) + + async def delete_solcast_file(self, *args): + _LOGGER.debug(f"SOLCAST - service event to delete old solcast.json file") + try: + if file_exists(self._filename): + os.remove(self._filename) + await self.sites_data() + await self.load_saved_data() + except Exception: + _LOGGER.error(f"SOLCAST - service event to delete old solcast.json file failed") + + async def get_forecast_list(self, *args): + try: + tz = self._tz + + return tuple( + { + **d, + "period_start": d["period_start"].astimezone(tz), + } + for d in self._data_forecasts + if d["period_start"] >= args[0] and d["period_start"] < args[1] + ) + + except Exception: + _LOGGER.error(f"SOLCAST - service event to get list of forecasts failed") + return None + + def get_api_used_count(self): + """Return API polling count for this UTC 24hr period""" + return self._api_used + + def get_api_limit(self): + """Return API polling limit for this account""" + try: + return self._api_limit + except Exception: + return None + + # def get_weather(self): + # """Return weather description""" + # return self._weather + + def get_last_updated_datetime(self) -> dt: + """Return date time with the data was last updated""" + return dt.fromisoformat(self._data["last_updated"]) + + def get_rooftop_site_total_today(self, rooftopid) -> float: + """Return a rooftop sites total kw for today""" + return self._data["siteinfo"][rooftopid]["tally"] + + def get_rooftop_site_extra_data(self, rooftopid = ""): + """Return a rooftop sites information""" + g = tuple(d for d in self._sites if d["resource_id"] == rooftopid) + if len(g) != 1: + raise ValueError(f"Unable to find rooftop site {rooftopid}") + site: Dict[str, Any] = g[0] + ret = {} + + ret["name"] = site.get("name", None) + ret["resource_id"] = site.get("resource_id", None) + ret["capacity"] = site.get("capacity", None) + ret["capacity_dc"] = site.get("capacity_dc", None) + ret["longitude"] = site.get("longitude", None) + ret["latitude"] = site.get("latitude", None) + ret["azimuth"] = site.get("azimuth", None) + ret["tilt"] = site.get("tilt", None) + ret["install_date"] = site.get("install_date", None) + ret["loss_factor"] = site.get("loss_factor", None) + for key in tuple(ret.keys()): + if ret[key] is None: + ret.pop(key, None) + + return ret + + def get_forecast_day(self, futureday) -> Dict[str, Any]: + """Return Solcast Forecasts data for N days ahead""" + noDataError = True + + tz = self._tz + da = dt.now(tz).date() + timedelta(days=futureday) + h = tuple( + d + for d in self._data_forecasts + if d["period_start"].astimezone(tz).date() == da + ) + + tup = tuple( + {**d, "period_start": d["period_start"].astimezone(tz)} for d in h + ) + + if len(tup) < 48: + noDataError = False + + hourlyturp = [] + for index in range(0,len(tup),2): + if len(tup)>0: + try: + x1 = round((tup[index]["pv_estimate"] + tup[index+1]["pv_estimate"]) /2, 4) + x2 = round((tup[index]["pv_estimate10"] + tup[index+1]["pv_estimate10"]) /2, 4) + x3 = round((tup[index]["pv_estimate90"] + tup[index+1]["pv_estimate90"]) /2, 4) + hourlyturp.append({"period_start":tup[index]["period_start"], "pv_estimate":x1, "pv_estimate10":x2, "pv_estimate90":x3}) + except IndexError: + x1 = round((tup[index]["pv_estimate"]), 4) + x2 = round((tup[index]["pv_estimate10"]), 4) + x3 = round((tup[index]["pv_estimate90"]), 4) + hourlyturp.append({"period_start":tup[index]["period_start"], "pv_estimate":x1, "pv_estimate10":x2, "pv_estimate90":x3}) + + + return { + "detailedForecast": tup, + "detailedHourly": hourlyturp, + "dayname": da.strftime("%A"), + "dataCorrect": noDataError, + } + + def get_forecast_n_hour(self, hourincrement) -> int: + # This technically is for the given hour in UTC time, not local time; + # this is because the Solcast API doesn't provide the local time zone + # and returns 30min intervals that doesn't necessarily align with the + # local time zone. This is a limitation of the Solcast API and not + # this code, so we'll just have to live with it. + try: + da = dt.now(timezone.utc).replace( + minute=0, second=0, microsecond=0 + ) + timedelta(hours=hourincrement) + g = tuple( + d + for d in self._data_forecasts + if d["period_start"] >= da and d["period_start"] < da + timedelta(hours=1) + ) + m = sum(z[self._use_data_field] for z in g) / len(g) + + return int(m * 1000) + except Exception as ex: + return 0 + + def get_forecast_custom_hour(self, hourincrement) -> int: + """Return Custom Sensor Hours forecast for N hours ahead""" + try: + danow = dt.now(timezone.utc).replace( + minute=0, second=0, microsecond=0 + ) + da = dt.now(timezone.utc).replace( + minute=0, second=0, microsecond=0 + ) + timedelta(hours=hourincrement) + g=[] + for d in self._data_forecasts: + if d["period_start"] >= danow and d["period_start"] < da: + g.append(d) + + m = sum(z[self._use_data_field] for z in g) + + return int(m * 500) + except Exception as ex: + return 0 + + def get_power_production_n_mins(self, minuteincrement) -> float: + """Return Solcast Power Now data for N minutes ahead""" + try: + da = dt.now(timezone.utc) + timedelta(minutes=minuteincrement) + m = min( + (z for z in self._data_forecasts), key=lambda x: abs(x["period_start"] - da) + ) + return int(m[self._use_data_field] * 1000) + except Exception as ex: + return 0.0 + + def get_peak_w_day(self, dayincrement) -> int: + """Return hour of max kw for rooftop site N days ahead""" + try: + tz = self._tz + da = dt.now(tz).date() + timedelta(days=dayincrement) + g = tuple( + d + for d in self._data_forecasts + if d["period_start"].astimezone(tz).date() == da + ) + m = max(z[self._use_data_field] for z in g) + return int(m * 1000) + except Exception as ex: + return 0 + + def get_peak_w_time_day(self, dayincrement) -> dt: + """Return hour of max kw for rooftop site N days ahead""" + try: + tz = self._tz + da = dt.now(tz).date() + timedelta(days=dayincrement) + g = tuple( + d + for d in self._data_forecasts + if d["period_start"].astimezone(tz).date() == da + ) + #HA strips any TZ info set and forces UTC tz, so dont need to return with local tz info + return max((z for z in g), key=lambda x: x[self._use_data_field])["period_start"] + except Exception as ex: + return None + + def get_remaining_today(self) -> float: + """Return Remaining Forecasts data for today""" + try: + tz = self._tz + da = dt.now(tz).replace(second=0, microsecond=0) + + if da.minute < 30: + da = da.replace(minute=0) + else: + da = da.replace(minute=30) + + g = tuple( + d + for d in self._data_forecasts + if d["period_start"].astimezone(tz).date() == da.date() and d["period_start"].astimezone(tz) >= da + ) + + return sum(z[self._use_data_field] for z in g) / 2 + except Exception as ex: + return 0.0 + + def get_total_kwh_forecast_day(self, dayincrement) -> float: + """Return total kwh total for rooftop site N days ahead""" + tz = self._tz + d = dt.now(tz) + timedelta(days=dayincrement) + d = d.replace(hour=0, minute=0, second=0, microsecond=0) + needed_delta = d.replace(hour=23, minute=59, second=59, microsecond=0) - d + + ret = 0.0 + for idx in range(1, len(self._data_forecasts)): + prev = self._data_forecasts[idx - 1] + curr = self._data_forecasts[idx] + + prev_date = prev["period_start"].astimezone(tz).date() + cur_date = curr["period_start"].astimezone(tz).date() + if prev_date != cur_date or cur_date != d.date(): + continue + + delta: timedelta = curr["period_start"] - prev["period_start"] + diff_hours = delta.total_seconds() / 3600 + ret += (prev[self._use_data_field] + curr[self._use_data_field]) / 2 * diff_hours + needed_delta -= delta + + return ret + + def get_energy_data(self) -> dict[str, Any]: + try: + return self._dataenergy + except Exception as e: + _LOGGER.error(f"SOLCAST - get_energy_data: {e}") + return None + + async def http_data(self, dopast = False): + """Request forecast data via the Solcast API.""" + lastday = dt.now(self._tz) + timedelta(days=7) + lastday = lastday.replace(hour=23,minute=59).astimezone(timezone.utc) + + for site in self._sites: + _LOGGER.debug(f"SOLCAST - API polling for rooftop {site['resource_id']}") + #site=site['resource_id'], apikey=site['apikey'], + await self.http_data_call(site['resource_id'], site['apikey'], dopast) + + self._data["last_updated"] = dt.now(timezone.utc).isoformat() + #await self.sites_usage() + self._data["version"] = _JSON_VERSION + #self._data["weather"] = self._weather + self._loaded_data = True + + await self.buildforcastdata() + await self.serialize_data() + + async def http_data_call(self, r_id = None, api = None, dopast = False): + """Request forecast data via the Solcast API.""" + lastday = dt.now(self._tz) + timedelta(days=7) + lastday = lastday.replace(hour=23,minute=59).astimezone(timezone.utc) + pastdays = dt.now(self._tz).date() + timedelta(days=-730) + _LOGGER.debug(f"SOLCAST - Polling API for rooftop_id {r_id}") + + _data = [] + _data2 = [] + + #this is one run once, for a new install or if the solcasft.json file is deleted + #this does use up an api call count too + if dopast: + ae = None + resp_dict = await self.fetch_data("estimated_actuals", 168, site=r_id, apikey=api, cachedname="actuals") + if not isinstance(resp_dict, dict): + _LOGGER.warning("SOLCAST - No data was returned so this WILL cause errors.. either your limit is up, internet down.. what ever the case is it is NOT a problem with the integration, and all other problems of sensor values being wrong will be a seen") + raise TypeError(f"Solcast API did not return a json object. Returned {resp_dict}") + + ae = resp_dict.get("estimated_actuals", None) + + if not isinstance(ae, list): + raise TypeError(f"estimated actuals must be a list, not {type(ae)}") + + oldest = dt.now(self._tz).replace(hour=0,minute=0,second=0,microsecond=0) - timedelta(days=6) + oldest = oldest.astimezone(timezone.utc) + + for x in ae: + z = parse_datetime(x["period_end"]).astimezone(timezone.utc) + z = z.replace(second=0, microsecond=0) - timedelta(minutes=30) + if z.minute not in {0, 30}: + raise ValueError( + f"Solcast period_start minute is not 0 or 30. {z.minute}" + ) + if z > oldest: + _data2.append( + { + "period_start": z, + "pv_estimate": x["pv_estimate"], + "pv_estimate10": 0, + "pv_estimate90": 0, + } + ) + + resp_dict = await self.fetch_data("forecasts", 168, site=r_id, apikey=api, cachedname="forecasts") + if not isinstance(resp_dict, dict): + raise TypeError(f"Solcast API did not return a json object. Returned {resp_dict}") + + af = resp_dict.get("forecasts", None) + if not isinstance(af, list): + raise TypeError(f"forecasts must be a list, not {type(af)}") + + _LOGGER.debug(f"SOLCAST - Solcast returned {len(af)} records (should be 168)") + + for x in af: + z = parse_datetime(x["period_end"]).astimezone(timezone.utc) + z = z.replace(second=0, microsecond=0) - timedelta(minutes=30) + if z.minute not in {0, 30}: + raise ValueError( + f"Solcast period_start minute is not 0 or 30. {z.minute}" + ) + if z < lastday: + _data2.append( + { + "period_start": z, + "pv_estimate": x["pv_estimate"], + "pv_estimate10": x["pv_estimate10"], + "pv_estimate90": x["pv_estimate90"], + } + ) + + _data = sorted(_data2, key=itemgetter("period_start")) + _forecasts = [] + + try: + _forecasts = self._data['siteinfo'][r_id]['forecasts'] + except: + pass + + for x in _data: + #loop each rooftop site and its forecasts + + itm = next((item for item in _forecasts if item["period_start"] == x["period_start"]), None) + if itm: + itm["pv_estimate"] = x["pv_estimate"] + itm["pv_estimate10"] = x["pv_estimate10"] + itm["pv_estimate90"] = x["pv_estimate90"] + else: + # _LOGGER.debug("adding itm") + _forecasts.append({"period_start": x["period_start"],"pv_estimate": x["pv_estimate"], + "pv_estimate10": x["pv_estimate10"], + "pv_estimate90": x["pv_estimate90"]}) + + #_forecasts now contains all data for the rooftop site up to 730 days worth + #this deletes data that is older than 730 days (2 years) + for x in _forecasts: + zz = x['period_start'].astimezone(self._tz) - timedelta(minutes=30) + if zz.date() < pastdays: + _forecasts.remove(x) + + _forecasts = sorted(_forecasts, key=itemgetter("period_start")) + + self._data['siteinfo'].update({r_id:{'forecasts': copy.deepcopy(_forecasts)}}) + + + async def fetch_data(self, path= "error", hours=168, site="", apikey="", cachedname="forcasts") -> dict[str, Any]: + """fetch data via the Solcast API.""" + + try: + params = {"format": "json", "api_key": apikey, "hours": hours} + url=f"{self.options.host}/rooftop_sites/{site}/{path}" + _LOGGER.debug(f"SOLCAST - fetch_data code url - {url}") + + async with async_timeout.timeout(120): + apiCacheFileName = cachedname + "_" + site + ".json" + if self.apiCacheEnabled and file_exists(apiCacheFileName): + _LOGGER.debug(f"SOLCAST - Getting cached testing data for site {site}") + status = 404 + with open(apiCacheFileName) as f: + resp_json = json.load(f) + status = 200 + _LOGGER.debug(f"SOLCAST - Got cached file data for site {site}") + else: + #_LOGGER.debug(f"SOLCAST - OK REAL API CALL HAPPENING RIGHT NOW") + resp: ClientResponse = await self.aiohttp_session.get( + url=url, params=params, ssl=False + ) + status = resp.status + + if status == 200: + _LOGGER.debug(f"SOLCAST - API returned data. API Counter incremented from {self._api_used} to {self._api_used + 1}") + self._api_used = self._api_used + 1 + else: + _LOGGER.warning(f"SOLCAST - API returned status {status}. API data {self._api_used} to {self._api_used + 1}") + _LOGGER.warning("This is an error with the data returned from Solcast, not the integration!") + + resp_json = await resp.json(content_type=None) + + if self.apiCacheEnabled: + with open(apiCacheFileName, 'w') as f: + json.dump(resp_json, f, ensure_ascii=False) + + _LOGGER.debug(f"SOLCAST - fetch_data code http_session returned data type is {type(resp_json)}") + _LOGGER.debug(f"SOLCAST - fetch_data code http_session status is {status}") + + if status == 429: + _LOGGER.warning("SOLCAST - Exceeded Solcast API allowed polling limit") + elif status == 400: + _LOGGER.warning( + "SOLCAST - The rooftop site missing capacity, please specify capacity or provide historic data for tuning." + ) + #raise Exception(f"HTTP error: The rooftop site missing capacity, please specify capacity or provide historic data for tuning.") + elif status == 404: + _LOGGER.warning("SOLCAST - Error 404. The rooftop site cannot be found or is not accessible.") + #raise Exception(f"HTTP error: The rooftop site cannot be found or is not accessible.") + elif status == 200: + d = cast(dict, resp_json) + _LOGGER.debug(f"SOLCAST - fetch_data Returned: {d}") + return d + #await self.format_json_data(d) + except ConnectionRefusedError as err: + _LOGGER.error("SOLCAST - Error. Connection Refused. %s",err) + except ClientConnectionError as e: + _LOGGER.error('SOLCAST - Connection Error', str(e)) + except asyncio.TimeoutError: + _LOGGER.error("SOLCAST - Connection Timeout Error - Timed out connectng to Solcast API server") + except Exception as e: + _LOGGER.error("SOLCAST - fetch_data error: %s", traceback.format_exc()) + + return None + + def makeenergydict(self) -> dict: + wh_hours = {} + + try: + lastv = -1 + lastk = -1 + for v in self._data_forecasts: + d = v['period_start'].isoformat() + if v[self._use_data_field] == 0.0: + if lastv > 0.0: + wh_hours[d] = round(v[self._use_data_field] * 500,0) + wh_hours[lastk] = 0.0 + lastk = d + lastv = v[self._use_data_field] + else: + if lastv == 0.0: + #add the last one + wh_hours[lastk] = round(lastv * 500,0) + + wh_hours[d] = round(v[self._use_data_field] * 500,0) + + lastk = d + lastv = v[self._use_data_field] + except Exception as e: + _LOGGER.error("SOLCAST - makeenergydict: %s", traceback.format_exc()) + + return wh_hours + + async def buildforcastdata(self): + """build the data needed and convert where needed""" + try: + today = dt.now(self._tz).date() + yesterday = dt.now(self._tz).date() + timedelta(days=-730) + lastday = dt.now(self._tz).date() + timedelta(days=7) + + _forecasts = [] + + for s in self._data['siteinfo']: + tally = 0 + for x in self._data['siteinfo'][s]['forecasts']: + #loop each rooftop site and its forecasts + z = x["period_start"] + zz = z.astimezone(self._tz) #- timedelta(minutes=30) + + #v4.0.8 added code to dampen the forecast data.. (* self._damp[h]) + + if zz.date() < lastday and zz.date() > yesterday: + h = f"{zz.hour}" + if zz.date() == today: + tally += min(x[self._use_data_field] * 0.5 * self._damp[h], self._hardlimit) + + itm = next((item for item in _forecasts if item["period_start"] == z), None) + if itm: + itm["pv_estimate"] = min(round(itm["pv_estimate"] + (x["pv_estimate"] * self._damp[h]),4), self._hardlimit) + itm["pv_estimate10"] = min(round(itm["pv_estimate10"] + (x["pv_estimate10"] * self._damp[h]),4), self._hardlimit) + itm["pv_estimate90"] = min(round(itm["pv_estimate90"] + (x["pv_estimate90"] * self._damp[h]),4), self._hardlimit) + else: + _forecasts.append({"period_start": z,"pv_estimate": min(round((x["pv_estimate"]* self._damp[h]),4), self._hardlimit), + "pv_estimate10": min(round((x["pv_estimate10"]* self._damp[h]),4), self._hardlimit), + "pv_estimate90": min(round((x["pv_estimate90"]* self._damp[h]),4), self._hardlimit)}) + + self._data['siteinfo'][s]['tally'] = round(tally, 4) + + _forecasts = sorted(_forecasts, key=itemgetter("period_start")) + + self._data_forecasts = _forecasts + + await self.checkDataRecords() + + self._dataenergy = {"wh_hours": self.makeenergydict()} + + except Exception as e: + _LOGGER.error("SOLCAST - http_data error: %s", traceback.format_exc()) + + async def checkDataRecords(self): + tz = self._tz + for i in range(0,6): + da = dt.now(tz).date() + timedelta(days=i) + h = tuple( + d + for d in self._data_forecasts + if d["period_start"].astimezone(tz).date() == da + ) + + if len(h) == 48: + _LOGGER.debug(f"SOLCAST - Data for {da} contains all 48 records") + else: + _LOGGER.debug(f"SOLCAST - Data for {da} contains only {len(h)} of 48 records and may produce inaccurate forecast data") + + + \ No newline at end of file diff --git a/custom_components/solcast_solar/strings.json b/custom_components/solcast_solar/strings.json new file mode 100644 index 0000000..286c300 --- /dev/null +++ b/custom_components/solcast_solar/strings.json @@ -0,0 +1,164 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Only one Solcast instance allowed" + }, + "step": { + "user": { + "data": { + "api_key": "Solcast API key" + }, + "description": "Your Solcast API Account Key" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "solcast_config_action": "Action" + }, + "description": "Solcast configuration options" + }, + "api": { + "data": { + "api_key": "Solcast API key" + }, + "description": "Your Solcast API Account Key" + }, + "dampen": { + "data": { + "damp00": "00:00", + "damp01": "01:00", + "damp02": "02:00", + "damp03": "03:00", + "damp04": "04:00", + "damp05": "05:00", + "damp06": "06:00", + "damp07": "07:00", + "damp08": "08:00", + "damp09": "09:00", + "damp10": "10:00", + "damp11": "11:00", + "damp12": "12:00", + "damp13": "13:00", + "damp14": "14:00", + "damp15": "15:00", + "damp16": "16:00", + "damp17": "17:00", + "damp18": "18:00", + "damp19": "19:00", + "damp20": "20:00", + "damp21": "21:00", + "damp22": "22:00", + "damp23": "23:00" + }, + "description": "Modify the hourly dampening factor" + }, + "customsensor": { + "data": { + "customhoursensor": "Next X Hour Sensor" + }, + "description": "Custom sensor for total energy for the next X hours" + } + }, + "error": { + "unknown": "Unknown error", + "incorrect_options_action": "Incorrect action chosen" + } + }, + "selector": { + "solcast_config_action": { + "options": { + "configure_api": "Solcast API key", + "configure_dampening": "Configure Dampening", + "configure_customsensor": "Configure Custom Hour Sensor" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "Solcast server connection", + "used_requests": "API requests remaining", + "rooftop_site_count": "Rooftop site count" + } + }, + "services": { + "update_forecasts": { + "name": "Update", + "description": "Fetches the latest forecasts data from Solcast." + }, + "clear_all_solcast_data": { + "name": "Clear all saved Solcast data", + "description": "Deletes the solcast.json file to remove all current solcast site data." + }, + "query_forecast_data": { + "name": "Query forecast data", + "description": "Return a data set for a given query.", + "fields": { + "start_date_time": { + "name": "Start date time", + "description": "Query forecast data events from date time." + }, + "end_date_time": { + "name": "End date time", + "description": "Query forecast data events up to date time." + } + } + }, + "set_dampening": { + "name": "Set forecasts dampening", + "description": "Set forecast dampening hourly factor.", + "fields": { + "damp_factor": { + "name": "Dampening string", + "description": "String of hourly dampening factor values comma seperated." + } + } + }, + "set_hard_limit": { + "name": "Set inverter forecast hard limit", + "description": "Prevent forcast values being higher than the inverter can produce.", + "fields": { + "hard_limit": { + "name": "Limit value in Watts", + "description": "Set the max value in watts that the inverter can produce." + } + } + }, + "remove_hard_limit": { + "name": "Remove inverter forecast hard limit", + "description": "Remove set limit." + } + }, + "entity": { + "sensor": { + "power_now_30m": {"name": "Power Next 30 Mins"}, + "power_now_1hr": {"name": "Power Next Hour"}, + "total_kwh_forecast_today": {"name": "Forecast Today"}, + "peak_w_today": {"name": "Peak Forecast Today"}, + "peak_w_time_today": {"name": "Peak Time Today"}, + "forecast_this_hour": {"name": "Forecast This Hour"}, + "get_remaining_today": {"name": "Forecast Remaining Today"}, + "forecast_next_hour": {"name": "Forecast Next Hour"}, + "forecast_custom_hour": {"name": "Forecast Next {forecast_custom_hour} Hours"}, + "total_kwh_forecast_tomorrow": {"name": "Forecast Tomorrow"}, + "peak_w_tomorrow": {"name": "Peak Forecast Tomorrow"}, + "peak_w_time_tomorrow": {"name": "Peak Time Tomorrow"}, + "api_counter": {"name": "API Used"}, + "api_limit": {"name": "API Limit"}, + "lastupdated": {"name": "API Last Polled"}, + "total_kwh_forecast_d3": {"name": "Forecast Day 3"}, + "total_kwh_forecast_d4": {"name": "Forecast Day 4"}, + "total_kwh_forecast_d5": {"name": "Forecast Day 5"}, + "total_kwh_forecast_d6": {"name": "Forecast Day 6"}, + "total_kwh_forecast_d7": {"name": "Forecast Day 7"}, + "power_now": {"name": "Power Now"}, + "weather_description": {"name": "Weather"}, + "hard_limit": {"name": "Hard Limit Set"} + }, + "select": { + "estimate_mode" : {"name": "Use Forecast Field"} + } + } +} \ No newline at end of file diff --git a/custom_components/solcast_solar/system_health.py b/custom_components/solcast_solar/system_health.py new file mode 100644 index 0000000..b84fce1 --- /dev/null +++ b/custom_components/solcast_solar/system_health.py @@ -0,0 +1,30 @@ +"""Provide info to system health.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components import system_health +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN, SOLCAST_URL +from .coordinator import SolcastUpdateCoordinator + + +@callback +def async_register( + hass: HomeAssistant, register: system_health.SystemHealthRegistration +) -> None: + """Register system health callbacks.""" + register.async_register_info(system_health_info) + + +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: + """Get info for the info page.""" + coordinator: SolcastUpdateCoordinator =list(hass.data[DOMAIN].values())[0] + used_requests = coordinator.solcast.get_api_used_count() + + return { + "can_reach_server": system_health.async_check_can_reach_url(hass, SOLCAST_URL), + "used_requests": used_requests, + "rooftop_site_count": len(coordinator.solcast._sites), + } \ No newline at end of file diff --git a/custom_components/solcast_solar/test.py b/custom_components/solcast_solar/test.py new file mode 100644 index 0000000..2738498 --- /dev/null +++ b/custom_components/solcast_solar/test.py @@ -0,0 +1,36 @@ +#!/usr/bin/python3 + +import asyncio +import logging +import traceback + +from aiohttp import ClientConnectionError, ClientSession + +from .solcastapi import ConnectionOptions, SolcastApi + +#logging.basicConfig(level=logging.DEBUG) +_LOGGER = logging.getLogger(__name__) + + +async def test(): + try: + + options = ConnectionOptions( + "changetoyourapikey", + "https://api.solcast.com.au", + 'solcast.json' + ) + + async with ClientSession() as session: + solcast = SolcastApi(session, options, apiCacheEnabled=True) + await solcast.sites_data() + await solcast.load_saved_data() + print("Total today " + str(solcast.get_total_kwh_forecast_today())) + print("Peak today " + str(solcast.get_peak_w_today())) + print("Peak time today " + str(solcast.get_peak_w_time_today())) + except Exception as err: + _LOGGER.error("async_setup_entry: %s",traceback.format_exc()) + return False + + +asyncio.run(test()) \ No newline at end of file diff --git a/custom_components/solcast_solar/translations/de.json b/custom_components/solcast_solar/translations/de.json new file mode 100644 index 0000000..45cacb2 --- /dev/null +++ b/custom_components/solcast_solar/translations/de.json @@ -0,0 +1,77 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "Solcast-API-Schlüssel" + }, + "description": "Trage deinen Solcast-API-Kontoschlüssel ein." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Solcast-API-Schlüssel" + }, + "description": "Dein Solcast-API-Kontoschlüssel" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "Verbindung zum Solcast-Server", + "used_requests": "Verbrauchte API-Anfragen", + "rooftop_site_count": "Anzahl der Dachflächen" + } + }, + "services": { + "update_forecasts": { + "name": "Aktualisieren", + "description": "Lade die neusten Prognosedaten von Solcast herunter." + }, + "clear_all_solcast_data": { + "name": "Solcast-Daten zurücksetzen", + "description": "Alle gespeicherten Solcast-Daten werden entfernt. Die Datei solcast.json wird dadurch gelöscht." + }, + "query_forecast_data": { + "name": "Prognosedaten herunterladen", + "description": "Es werden die aktuellen Prognosedaten heruntergeladen.", + "fields": { + "start_date_time": { + "name": "Anfangsdatum und /-uhrzeit", + "description": "Startzeitpunkt der Prognosedaten." + }, + "end_date_time": { + "name": "Enddatum und /-uhrzeit", + "description": "Endzeitpunkt der Prognosedaten." + } + } + } + }, + "entity": { + "sensor": { + "power_now_30m": {"name": "Leistung kommende 30 Minuten"}, + "power_now_1hr": {"name": "Leistung kommende 60 Minuten"}, + "total_kwh_forecast_today": {"name": "Prognose heute"}, + "peak_w_today": {"name": "Prognose Spitzenleistung heute"}, + "peak_w_time_today": {"name": "Zeitpunkt Spitzenleistung heute"}, + "forecast_this_hour": {"name": "Prognose aktuelle Stunde"}, + "get_remaining_today": {"name": "Prognose verbleibende Leistung heute"}, + "forecast_next_hour": {"name": "Prognose nächste Stunde"}, + "total_kwh_forecast_tomorrow": {"name": "Prognose morgen"}, + "peak_w_tomorrow": {"name": "Prognose Spitzenleistung morgen"}, + "peak_w_time_tomorrow": {"name": "Zeitpunkt Spitzenleistung morgen"}, + "api_counter": {"name": "Verwendete API-Abrufe"}, + "api_limit": {"name": "max. API-Abrufe"}, + "lastupdated": {"name": "Zeitpunkt letzter API-Abruf"}, + "total_kwh_forecast_d3": {"name": "Prognose Tag 3"}, + "total_kwh_forecast_d4": {"name": "Prognose Tag 4"}, + "total_kwh_forecast_d5": {"name": "Prognose Tag 5"}, + "total_kwh_forecast_d6": {"name": "Prognose Tag 6"}, + "total_kwh_forecast_d7": {"name": "Prognose Tag 7"}, + "power_now": {"name": "Aktuelle Leistung"} + } + } +} \ No newline at end of file diff --git a/custom_components/solcast_solar/translations/en.json b/custom_components/solcast_solar/translations/en.json new file mode 100644 index 0000000..c7c152d --- /dev/null +++ b/custom_components/solcast_solar/translations/en.json @@ -0,0 +1,164 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Only one Solcast instance allowed" + }, + "step": { + "user": { + "data": { + "api_key": "Solcast API key" + }, + "description": "Your Solcast API Account Key" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "solcast_config_action": "Action" + }, + "description": "Solcast configuration options" + }, + "api": { + "data": { + "api_key": "Solcast API key" + }, + "description": "Your Solcast API Account Key" + }, + "dampen": { + "data": { + "damp00": "00:00", + "damp01": "01:00", + "damp02": "02:00", + "damp03": "03:00", + "damp04": "04:00", + "damp05": "05:00", + "damp06": "06:00", + "damp07": "07:00", + "damp08": "08:00", + "damp09": "09:00", + "damp10": "10:00", + "damp11": "11:00", + "damp12": "12:00", + "damp13": "13:00", + "damp14": "14:00", + "damp15": "15:00", + "damp16": "16:00", + "damp17": "17:00", + "damp18": "18:00", + "damp19": "19:00", + "damp20": "20:00", + "damp21": "21:00", + "damp22": "22:00", + "damp23": "23:00" + }, + "description": "Modify the hourly dampening factor" + }, + "customsensor": { + "data": { + "customhoursensor": "Next X Hour Sensor" + }, + "description": "Custom sensor for total energy for the next X hours" + } + }, + "error": { + "unknown": "Unknown error", + "incorrect_options_action": "Incorrect action chosen" + } + }, + "selector": { + "solcast_config_action": { + "options": { + "configure_api": "Solcast API key", + "configure_dampening": "Configure Dampening", + "configure_customsensor": "Configure Custom Hour Sensor" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "Solcast server connection", + "used_requests": "API requests remaining", + "rooftop_site_count": "Rooftop site count" + } + }, + "services": { + "update_forecasts": { + "name": "Update", + "description": "Fetches the latest forecasts data from Solcast." + }, + "clear_all_solcast_data": { + "name": "Clear all saved Solcast data", + "description": "Deletes the solcast.json file to remove all current solcast site data." + }, + "query_forecast_data": { + "name": "Query forecast data", + "description": "Return a data set or value for a given query.", + "fields": { + "start_date_time": { + "name": "Start date time", + "description": "Query forecast data events from date time." + }, + "end_date_time": { + "name": "End date time", + "description": "Query forecast data events up to date time." + } + } + }, + "set_dampening": { + "name": "Set forecasts dampening", + "description": "Set forecast dampening hourly factor.", + "fields": { + "damp_factor": { + "name": "Dampening string", + "description": "String of hourly dampening factor values comma seperated." + } + } + }, + "set_hard_limit": { + "name": "Set inverter forecast hard limit", + "description": "Prevent forcast values being higher than the inverter can produce.", + "fields": { + "hard_limit": { + "name": "Limit value in Watts", + "description": "Set the max value in watts that the inverter can produce." + } + } + }, + "remove_hard_limit": { + "name": "Remove inverter forecast hard limit", + "description": "Remove set limit." + } + }, + "entity": { + "sensor": { + "power_now_30m": {"name": "Power Next 30 Mins"}, + "power_now_1hr": {"name": "Power Next Hour"}, + "total_kwh_forecast_today": {"name": "Forecast Today"}, + "peak_w_today": {"name": "Peak Forecast Today"}, + "peak_w_time_today": {"name": "Peak Time Today"}, + "forecast_this_hour": {"name": "Forecast This Hour"}, + "get_remaining_today": {"name": "Forecast Remaining Today"}, + "forecast_next_hour": {"name": "Forecast Next Hour"}, + "forecast_custom_hour": {"name": "Forecast Next {forecast_custom_hour} Hours"}, + "total_kwh_forecast_tomorrow": {"name": "Forecast Tomorrow"}, + "peak_w_tomorrow": {"name": "Peak Forecast Tomorrow"}, + "peak_w_time_tomorrow": {"name": "Peak Time Tomorrow"}, + "api_counter": {"name": "API Used"}, + "api_limit": {"name": "API Limit"}, + "lastupdated": {"name": "API Last Polled"}, + "total_kwh_forecast_d3": {"name": "Forecast Day 3"}, + "total_kwh_forecast_d4": {"name": "Forecast Day 4"}, + "total_kwh_forecast_d5": {"name": "Forecast Day 5"}, + "total_kwh_forecast_d6": {"name": "Forecast Day 6"}, + "total_kwh_forecast_d7": {"name": "Forecast Day 7"}, + "power_now": {"name": "Power Now"}, + "weather_description": {"name": "Weather"}, + "hard_limit": {"name": "Hard Limit Set"} + }, + "select": { + "estimate_mode" : {"name": "Use Forecast Field"} + } + } +} \ No newline at end of file diff --git a/custom_components/solcast_solar/translations/fr.json b/custom_components/solcast_solar/translations/fr.json new file mode 100644 index 0000000..9c216fb --- /dev/null +++ b/custom_components/solcast_solar/translations/fr.json @@ -0,0 +1,137 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Une seule instance Solcast autorisée" + }, + "step": { + "user": { + "data": { + "api_key": "Clé API Solcast" + }, + "description": "Votre clé de compte API Solcast" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "solcast_config_action": "Action" + }, + "description": "Options de configuration Solcast" + }, + "api": { + "data": { + "api_key": "Clé API Solcast" + }, + "description": "Votre clé de compte API Solcast" + }, + "dampen": { + "data": { + "damp00": "00:00", + "damp01": "01:00", + "damp02": "02:00", + "damp03": "03:00", + "damp04": "04:00", + "damp05": "05:00", + "damp06": "06:00", + "damp07": "07:00", + "damp08": "08:00", + "damp09": "09:00", + "damp10": "10:00", + "damp11": "11:00", + "damp12": "12:00", + "damp13": "13:00", + "damp14": "14:00", + "damp15": "15:00", + "damp16": "16:00", + "damp17": "17:00", + "damp18": "18:00", + "damp19": "19:00", + "damp20": "20:00", + "damp21": "21:00", + "damp22": "22:00", + "damp23": "23:00" + }, + "description": "Modifier le coefficient d'amortissement horaire" + } + }, + "error": { + "unknown": "Erreur inconnue", + "incorrect_options_action": "Action incorrecte choisie" + } + }, + "selector": { + "solcast_config_action": { + "options": { + "configure_api": "Clé API Solcast", + "configure_dampening": "Configurer le coefficient" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "Connexion au serveur Solcast", + "used_requests": "Requêtes API restantes", + "rooftop_site_count": "Nombre d'emplacements" + } + }, + "services": { + "update_forecasts": { + "name": "Mise à jour", + "description": "Récupère les dernières données de prévisions de Solcast." + }, + "clear_all_solcast_data": { + "name": "Effacer toutes les données Solcast enregistrées", + "description": "Supprime le fichier solcast.json pour supprimer toutes les données actuelles du site solcast." + }, + "query_forecast_data": { + "name": "Interroger les données de prévision", + "description": "Renvoie un ensemble de données ou une valeur pour une requête donnée.", + "fields": { + "start_date_time": { + "name": "Date et heure de début", + "description": "Date et heure de début des données de prévision." + }, + "end_date_time": { + "name": "Date et heure de fin", + "description": "Date et heure de fin des données de prévision." + } + } + }, + "set_dampening": { + "name": "Définir le coefficient des prévisions", + "description": "Définissez le facteur horaire d’amortissement des prévisions.", + "fields": { + "damp_factor": { + "name": "Chaîne de coefficient d’amortissement", + "description": "Chaîne de valeurs horaires du facteur d’amortissement séparées par des virgules." + } + } + } + }, + "entity": { + "sensor": { + "power_now_30m": {"name": "Production des 30 prochaines minutes"}, + "power_now_1hr": {"name": "Production de la prochaines heure"}, + "total_kwh_forecast_today": {"name": "Prévisions pour aujourd'hui"}, + "peak_w_today": {"name": "Prévisions du pic aujourd'hui"}, + "peak_w_time_today": {"name": "Heure du pic aujourd'hui"}, + "forecast_this_hour": {"name": "Prévisions heure actuel"}, + "get_remaining_today": {"name": "Prévisions de production restantes aujourd'hui"}, + "forecast_next_hour": {"name": "Prévisions pour la prochaine heure"}, + "total_kwh_forecast_tomorrow": {"name": "Prévisions pour demain"}, + "peak_w_tomorrow": {"name": "Prévisions du pic pour demain"}, + "peak_w_time_tomorrow": {"name": "Heure du pic demain"}, + "api_counter": {"name": "API utilisée"}, + "api_limit": {"name": "Limite API"}, + "lastupdated": {"name": "Dernière interrogation de l'API"}, + "total_kwh_forecast_d3": {"name": "Prévision jour 3"}, + "total_kwh_forecast_d4": {"name": "Prévision jour 4"}, + "total_kwh_forecast_d5": {"name": "Prévision jour 5"}, + "total_kwh_forecast_d6": {"name": "Prévision jour 6"}, + "total_kwh_forecast_d7": {"name": "Prévision jour 7"}, + "power_now": {"name": "Production maintenant"} + } + } +} \ No newline at end of file diff --git a/custom_components/solcast_solar/translations/pl.json b/custom_components/solcast_solar/translations/pl.json new file mode 100644 index 0000000..535dd91 --- /dev/null +++ b/custom_components/solcast_solar/translations/pl.json @@ -0,0 +1,77 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "Klucz API Solcast" + }, + "description": "Wprowadź swój klucz konta API Solcast." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Klucz API Solcast" + }, + "description": "Twój klucz konta API Solcast" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "Połączenie z serwerem Solcast", + "used_requests": "Wykorzystane zapytania API", + "rooftop_site_count": "Liczba połaci" + } + }, + "services": { + "update_forecasts": { + "name": "Aktualizuj", + "description": "Pobierz najnowsze dane prognoz Solcast." + }, + "clear_all_solcast_data": { + "name": "Wyczyść dane Solcast", + "description": "Usunięte zostaną wszystkie przechowywane dane Solcast. Plik solcast.json zostanie usunięty." + }, + "query_forecast_data": { + "name": "Pobierz dane prognoz", + "description": "Pobierz aktualne dane prognoz.", + "fields": { + "start_date_time": { + "name": "Data i godzina rozpoczęcia", + "description": "Czas rozpoczęcia danych prognozowych." + }, + "end_date_time": { + "name": "Data i godzina zakończenia", + "description": "Czas zakończenia danych prognozowych." + } + } + } + }, + "entity": { + "sensor": { + "power_now_30m": {"name": "Moc - następne 30 minut"}, + "power_now_1hr": {"name": "Moc - następne 60 minut"}, + "total_kwh_forecast_today": {"name": "Prognoza na dzisiaj"}, + "peak_w_today": {"name": "Szczytowa moc dzisiaj"}, + "peak_w_time_today": {"name": "Czas szczytowej mocy dzisiaj"}, + "forecast_this_hour": {"name": "Prognoza na bieżącą godzinę"}, + "get_remaining_today": {"name": "Pozostała prognoza na dziś"}, + "forecast_next_hour": {"name": "Prognoza na następną godzinę"}, + "total_kwh_forecast_tomorrow": {"name": "Prognoza na jutro"}, + "peak_w_tomorrow": {"name": "Szczytowa moc jutro"}, + "peak_w_time_tomorrow": {"name": "Czas szczytowej mocy jutro"}, + "api_counter": {"name": "Liczba wykorzystanych zapytań API"}, + "api_limit": {"name": "Limit zapytań API"}, + "lastupdated": {"name": "Ostatnia aktualizacja API"}, + "total_kwh_forecast_d3": {"name": "Prognoza na dzień 3"}, + "total_kwh_forecast_d4": {"name": "Prognoza na dzień 4"}, + "total_kwh_forecast_d5": {"name": "Prognoza na dzień 5"}, + "total_kwh_forecast_d6": {"name": "Prognoza na dzień 6"}, + "total_kwh_forecast_d7": {"name": "Prognoza na dzień 7"}, + "power_now": {"name": "Aktualna moc"} + } + } +} \ No newline at end of file diff --git a/custom_components/solcast_solar/translations/sk.json b/custom_components/solcast_solar/translations/sk.json new file mode 100644 index 0000000..0e86e5e --- /dev/null +++ b/custom_components/solcast_solar/translations/sk.json @@ -0,0 +1,146 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Povolená je iba jedna inštancia Solcast" + }, + "step": { + "user": { + "data": { + "api_key": "Solcast API kľúč" + }, + "description": "Váš kľúč účtu Solcast API" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "solcast_config_action": "Akcia" + }, + "description": "Solcast možnosti konfigurácie" + }, + "api": { + "data": { + "api_key": "Solcast API kľúč" + }, + "description": "Váš kľúč účtu Solcast API" + }, + "dampen": { + "data": { + "damp00": "00:00", + "damp01": "01:00", + "damp02": "02:00", + "damp03": "03:00", + "damp04": "04:00", + "damp05": "05:00", + "damp06": "06:00", + "damp07": "07:00", + "damp08": "08:00", + "damp09": "09:00", + "damp10": "10:00", + "damp11": "11:00", + "damp12": "12:00", + "damp13": "13:00", + "damp14": "14:00", + "damp15": "15:00", + "damp16": "16:00", + "damp17": "17:00", + "damp18": "18:00", + "damp19": "19:00", + "damp20": "20:00", + "damp21": "21:00", + "damp22": "22:00", + "damp23": "23:00" + }, + "description": "Upravte hodinový faktor tlmenia" + }, + "customsensor": { + "data": { + "customhoursensor": "Ďalší X-hodinový senzor" + }, + "description": "Vlastný senzor pre celkovú energiu na ďalších X hodín" + } + }, + "error": { + "unknown": "Neznáma chyba", + "incorrect_options_action": "Zvolená nesprávna akcia" + } + }, + "selector": { + "solcast_config_action": { + "options": { + "configure_api": "Solcast API kľúč", + "configure_dampening": "Konfigurácia tlmenia" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "Solcast server pripojenie", + "used_requests": "Zostávajúce požiadavky API", + "rooftop_site_count": "Počet miest na streche" + } + }, + "services": { + "update_forecasts": { + "name": "Aktualizácia", + "description": "Načítava najnovšie predpovede zo Solcastu." + }, + "clear_all_solcast_data": { + "name": "Vymažte všetky uložené údaje Solcast", + "description": "Odstráni súbor solcast.json a odstráni všetky aktuálne údaje lokality solcast." + }, + "query_forecast_data": { + "name": "Dopytujte údaje predpovede", + "description": "Vráti množinu údajov alebo hodnotu pre daný dotaz.", + "fields": { + "start_date_time": { + "name": "Dátum začiatku a čas", + "description": "Dopyt na udalosti s údajmi prognózy od dátumu a času." + }, + "end_date_time": { + "name": "Dátum ukončenia čas", + "description": "Dopytujte udalosti predpovede údajov o aktuálnom čase." + } + } + }, + "set_dampening": { + "name": "Nastavte tlmenie predpovedí", + "description": "Nastavte hodinový faktor tlmenia predpovede.", + "fields": { + "damp_factor": { + "name": "Tlmiaci reťazec", + "description": "Reťazec hodnôt hodinového faktora tlmenia oddelený čiarkou." + } + } + } + }, + "entity": { + "sensor": { + "power_now_30m": {"name": "Výkon ďalších 30 min"}, + "power_now_1hr": {"name": "Výkon ďalšiu hodinu"}, + "total_kwh_forecast_today": {"name": "Predpoveď dnes"}, + "peak_w_today": {"name": "Predpoveď špičky dnes"}, + "peak_w_time_today": {"name": "Čas špičky dnes"}, + "forecast_this_hour": {"name": "Predpoveď túto hodinu"}, + "get_remaining_today": {"name": "Predpoveď zostávajúca dnes"}, + "forecast_next_hour": {"name": "Predpoveď ďalšie hodina"}, + "total_kwh_forecast_tomorrow": {"name": "Prepoveď zajtra"}, + "peak_w_tomorrow": {"name": "Predpoveď špička zajtra"}, + "peak_w_time_tomorrow": {"name": "Čas špičky zajtra"}, + "api_counter": {"name": "Použité API"}, + "api_limit": {"name": "API Limit"}, + "lastupdated": {"name": "API Last Polled"}, + "total_kwh_forecast_d3": {"name": "Predpoveď deň 3"}, + "total_kwh_forecast_d4": {"name": "Prepoveď deň 4"}, + "total_kwh_forecast_d5": {"name": "Predpoveď deň 5"}, + "total_kwh_forecast_d6": {"name": "Predpoveď deň 6"}, + "total_kwh_forecast_d7": {"name": "Predpoveď deň 7"}, + "power_now": {"name": "Výkon teraz"} + }, + "select": { + "estimate_mode" : {"name": "Použiť pole predpovede"} + } + } +} diff --git a/custom_components/solcast_solar/translations/ur.json b/custom_components/solcast_solar/translations/ur.json new file mode 100644 index 0000000..11fcf65 --- /dev/null +++ b/custom_components/solcast_solar/translations/ur.json @@ -0,0 +1,137 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "صرف ایک سولکاسٹ مثال کی اجازت ہے۔" + }, + "step": { + "user": { + "data": { + "api_key": "سولکاسٹ API کلید" + }, + "description": "آپ کی سولکاسٹ API اکاؤنٹ کی کلید" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "solcast_config_action": "عمل" + }, + "description": "سولکاسٹ کنفیگریشن کے اختیارات" + }, + "api": { + "data": { + "api_key": "سولکاسٹ API کلید" + }, + "description": "آپ کی سولکاسٹ API اکاؤنٹ کی کلیدy" + }, + "dampen": { + "data": { + "damp00": "00:00", + "damp01": "01:00", + "damp02": "02:00", + "damp03": "03:00", + "damp04": "04:00", + "damp05": "05:00", + "damp06": "06:00", + "damp07": "07:00", + "damp08": "08:00", + "damp09": "09:00", + "damp10": "10:00", + "damp11": "11:00", + "damp12": "12:00", + "damp13": "13:00", + "damp14": "14:00", + "damp15": "15:00", + "damp16": "16:00", + "damp17": "17:00", + "damp18": "18:00", + "damp19": "19:00", + "damp20": "20:00", + "damp21": "21:00", + "damp22": "22:00", + "damp23": "23:00" + }, + "description": "گھنٹہ وار ڈیمپنگ فیکٹر میں ترمیم کریں۔" + } + }, + "error": { + "unknown": "نامعلوم خامی", + "incorrect_options_action": "غلط عمل کا انتخاب کیا گیا ہے۔" + } + }, + "selector": { + "solcast_config_action": { + "options": { + "configure_api": "سولکاسٹ API کلید", + "configure_dampening": "ڈیمپننگ کو ترتیب دیں۔" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "سولکاسٹ سرور کنکشن", + "used_requests": "API کی درخواستیں باقی ہیں۔", + "rooftop_site_count": "چھت والی سائٹ کی گنتی" + } + }, + "services": { + "update_forecasts": { + "name": "اپڈیٹ کریں۔", + "description": "سولکاسٹ سے تازہ ترین پیشن گوئی کا ڈیٹا لاتا ہے۔" + }, + "clear_all_solcast_data": { + "name": "تمام محفوظ کردہ سولکاسٹ ڈیٹا کو صاف کریں۔", + "description": "تمام موجودہ سولکاسٹ سائٹ ڈیٹا کو ہٹانے کے لیے solcast.json فائل کو حذف کرتا ہے۔" + }, + "query_forecast_data": { + "name": "استفسار کی پیشن گوئی کے اعداد و شمار", + "description": "دیے گئے سوال کے لیے ڈیٹا سیٹ یا قدر واپس کریں۔", + "fields": { + "start_date_time": { + "name": "تاریخ کا وقت شروع کریں۔", + "description": "تاریخ کے وقت سے اعداد و شمار کے واقعات کی پیشن گوئی کریں۔" + }, + "end_date_time": { + "name": "اختتامی تاریخ کا وقت", + "description": "تازہ ترین وقت کے اعداد و شمار کے واقعات کی پیشن گوئی سے استفسار کریں۔" + } + } + }, + "set_dampening": { + "name": "نم ہونے والی پیشن گوئیاں مرتب کریں۔", + "description": "گھنٹہ وار فیکٹر کی پیشن گوئی کو نم کرنا سیٹ کریں۔", + "fields": { + "damp_factor": { + "name": "ڈمپننگ سٹرنگ", + "description": "فی گھنٹہ ڈیمپنگ فیکٹر ویلیوز کی اسٹرنگ کوما سے الگ کیا گیا۔" + } + } + } + }, + "entity": { + "sensor": { + "power_now_30m": {"name": "پاور اگلا 30 منٹ"}, + "power_now_1hr": {"name": "پاور اگلا گھنٹہ"}, + "total_kwh_forecast_today": {"name": "آج کی پیشن گوئی"}, + "peak_w_today": {"name": "آج کی بلند ترین پیش گوئی"}, + "peak_w_time_today": {"name": "آج عروج کا وقت"}, + "forecast_this_hour": {"name": "اس گھنٹے کی پیشن گوئی"}, + "get_remaining_today": {"name": "آج باقی رہنے والی پیشن گوئی"}, + "forecast_next_hour": {"name": "اگلے گھنٹے کی پیشن گوئی"}, + "total_kwh_forecast_tomorrow": {"name": "کل کی پیشن گوئی"}, + "peak_w_tomorrow": {"name": "کل عروج کی پیشن گوئی"}, + "peak_w_time_tomorrow": {"name": "کل عروج کا وقت"}, + "api_counter": {"name": "استعمال شدہ API"}, + "api_limit": {"name": "API کی حد"}, + "lastupdated": {"name": "API کی آخری رائے شماری"}, + "total_kwh_forecast_d3": {"name": "دن 3 کی پیشن گوئی"}, + "total_kwh_forecast_d4": {"name": "دن 4 کی پیشن گوئی"}, + "total_kwh_forecast_d5": {"name": "دن 5 کی پیشن گوئی"}, + "total_kwh_forecast_d6": {"name": "دن 6 کی پیشن گوئی"}, + "total_kwh_forecast_d7": {"name": "دن 7 کی پیشن گوئی"}, + "power_now": {"name": "ابھی پاور"} + } + } +}