From 866f2c9bde0ce0bf0c0065ea1f4c9518d32ba4c2 Mon Sep 17 00:00:00 2001 From: Joachim Wickman Date: Tue, 4 Jun 2024 11:49:41 +0300 Subject: [PATCH] First commit --- README.md | 1 + custom_components/solcast_solar/__init__.py | 332 ++++++++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 17080 bytes .../__pycache__/config_flow.cpython-312.pyc | Bin 0 -> 17071 bytes .../__pycache__/const.cpython-312.pyc | Bin 0 -> 1360 bytes .../__pycache__/coordinator.cpython-312.pyc | Bin 0 -> 9623 bytes .../__pycache__/diagnostics.cpython-312.pyc | Bin 0 -> 1780 bytes .../__pycache__/energy.cpython-312.pyc | Bin 0 -> 767 bytes .../__pycache__/recorder.cpython-312.pyc | Bin 0 -> 638 bytes .../__pycache__/select.cpython-312.pyc | Bin 0 -> 5269 bytes .../__pycache__/sensor.cpython-312.pyc | Bin 0 -> 15037 bytes .../__pycache__/solcastapi.cpython-312.pyc | Bin 0 -> 49151 bytes .../__pycache__/system_health.cpython-312.pyc | Bin 0 -> 1650 bytes .../solcast_solar/config_flow.py | 331 +++++++ custom_components/solcast_solar/const.py | 34 + .../solcast_solar/coordinator.py | 171 ++++ .../solcast_solar/diagnostics.py | 34 + custom_components/solcast_solar/energy.py | 20 + custom_components/solcast_solar/icons.json | 8 + custom_components/solcast_solar/manifest.json | 26 + custom_components/solcast_solar/recorder.py | 10 + custom_components/solcast_solar/select.py | 133 +++ custom_components/solcast_solar/sensor.py | 462 ++++++++++ custom_components/solcast_solar/services.yaml | 39 + custom_components/solcast_solar/solcastapi.py | 806 ++++++++++++++++++ custom_components/solcast_solar/strings.json | 164 ++++ .../solcast_solar/system_health.py | 30 + custom_components/solcast_solar/test.py | 36 + .../solcast_solar/translations/de.json | 77 ++ .../solcast_solar/translations/en.json | 164 ++++ .../solcast_solar/translations/fr.json | 137 +++ .../solcast_solar/translations/pl.json | 77 ++ .../solcast_solar/translations/sk.json | 146 ++++ .../solcast_solar/translations/ur.json | 137 +++ 34 files changed, 3375 insertions(+) create mode 100644 README.md create mode 100644 custom_components/solcast_solar/__init__.py create mode 100644 custom_components/solcast_solar/__pycache__/__init__.cpython-312.pyc create mode 100644 custom_components/solcast_solar/__pycache__/config_flow.cpython-312.pyc create mode 100644 custom_components/solcast_solar/__pycache__/const.cpython-312.pyc create mode 100644 custom_components/solcast_solar/__pycache__/coordinator.cpython-312.pyc create mode 100644 custom_components/solcast_solar/__pycache__/diagnostics.cpython-312.pyc create mode 100644 custom_components/solcast_solar/__pycache__/energy.cpython-312.pyc create mode 100644 custom_components/solcast_solar/__pycache__/recorder.cpython-312.pyc create mode 100644 custom_components/solcast_solar/__pycache__/select.cpython-312.pyc create mode 100644 custom_components/solcast_solar/__pycache__/sensor.cpython-312.pyc create mode 100644 custom_components/solcast_solar/__pycache__/solcastapi.cpython-312.pyc create mode 100644 custom_components/solcast_solar/__pycache__/system_health.cpython-312.pyc create mode 100644 custom_components/solcast_solar/config_flow.py create mode 100644 custom_components/solcast_solar/const.py create mode 100644 custom_components/solcast_solar/coordinator.py create mode 100644 custom_components/solcast_solar/diagnostics.py create mode 100644 custom_components/solcast_solar/energy.py create mode 100644 custom_components/solcast_solar/icons.json create mode 100644 custom_components/solcast_solar/manifest.json create mode 100644 custom_components/solcast_solar/recorder.py create mode 100644 custom_components/solcast_solar/select.py create mode 100644 custom_components/solcast_solar/sensor.py create mode 100644 custom_components/solcast_solar/services.yaml create mode 100644 custom_components/solcast_solar/solcastapi.py create mode 100644 custom_components/solcast_solar/strings.json create mode 100644 custom_components/solcast_solar/system_health.py create mode 100644 custom_components/solcast_solar/test.py create mode 100644 custom_components/solcast_solar/translations/de.json create mode 100644 custom_components/solcast_solar/translations/en.json create mode 100644 custom_components/solcast_solar/translations/fr.json create mode 100644 custom_components/solcast_solar/translations/pl.json create mode 100644 custom_components/solcast_solar/translations/sk.json create mode 100644 custom_components/solcast_solar/translations/ur.json 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 0000000000000000000000000000000000000000..52884e1aed35c149073390b7b5888bad624ecf5c GIT binary patch literal 17080 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 J?jK?O{tMjMp&0-G literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e002a4e301d1fb41b75d37787845c873eefa24c4 GIT binary patch literal 17071 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..21a0706dd9a3541f5006a5f8c0739c1db578d67b GIT binary patch literal 9623 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 b8TOtJY zCkXzABI3n^2mgVW1`p~&p&$i&3&ex=a&^bK5H{bi-d~aqxSF05S>(lG4?i3^R zLnY@+n*%(%3*Ze3k&i+gYB5eUU&9LPv7Q*d0oVvjaVarp#Wo^5TTcltBzYJ2X3NIHVWlshn3q--kSP$yt%x8HN3RPxMeZ!|lu zo*OU#%^-3TLhPG0j1i*QvIWg zKXfPF?T>8Yb;g@V*5ZM6?OSc>$X@HafnuanPt4nuLAoQD`U!x literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3df1401bb13b08603eced15a84b3a9b2120030df GIT binary patch literal 638 zcmYLGyKdA#6rJ&7ceTkzDEWa=M1h;#0x1;=p#UWXG;{_un)Qs=7UP-Kow3AjMZqT^ zYtE){pYY`Bt-&-M6TpL6_mG8q%BFCU+muPGrvL$j-ROX%hV z$t95_Cz8rY($xc27AW+tB4j9(Zi0_X zQlYF*^d-rn^~KY7g>I!{OY4>KHBPUX7O+rEcn|fw^~$jlY{TZNHjAAe$!f!VrI-|6 z%!O0a(~v|rt^`P~>!$YIfmB}9TFGycCbmGkf>8+Jp^r7bw`#+;We9xKkF_jg!f+$eY(_Z0t)>eoOT z7KYD4$QTX+>?7Qc9yM0BTAjiXw!x>nk8mARO23l_KgjVfa&kQ&(fB5c=o#I_NdNS- G-|`pUce6wQ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6d28d97d9ccd6b2a713e7abdf60eb9d6839cfeda GIT binary patch literal 5269 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 U>ww#d4G7nS_4Y3a*4$M811@ziegFUf literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e1e1c13ea4d889ef81cb3810b86e3387593d5e20 GIT binary patch literal 15037 zcmdTqTWlQHbu;^%eJ|h3_wXfYMQTZ!k|oQs>=j8#v_(=ODJ$MQCaayHxYR!A*`XzF zDOa|YRBoNdZXQzWq)pqjs2~+_ixx=J0FC0H4bUG>Wn-rk84!@PO@Mx^O&V6!pPqAP zXLgocI#QAX?In2k+`eL7fmk3Nj0Mv*v6^&ktTtU2t4r6%>eCIehIC`Bk>D|@rgSJ4N;k)vN!gWZ zNw>yY3G7a_rQ2ie1oosl(w(tR0((j;2_*%a1X8jFV zLrh<$`36F#fo}jhF|F2|3yq7PHkUP+Zv;BD;930a*0!5XHyEuJsD}vk5Fg^3`4$-A zb5-zLZ`cj|HiF-_?VQeR?a#OI?a0l|4%4hU2_1G@I@Q*O<-5r0nCWyAI^EmSsm4z? z-?N3EUP7n$q3HDTeOu`4Aar(YOQ+hrcJMp5%xf2+vkT}v_@3|L!&~^-P3Y|Ack}%Z zw7&xce}Et0_wW&+eUaa~pEn0h=V6H85Aj3%@B`sLLhv7fd3|mxW&RO<1h}j|>fRd; ztp}XieT2$BejooRzkeHiGnWSl{(*
    _`^TXy9jp>yz|=p5u9-$LgQp>yb==p5po z*i45|Is8ym4)ae|QXw>+dMFxC@zE+YjsT4ZKdVRh(QW0^m@X#>Z{z&3@tr_RIF?kAL0%XLM|YpAh!)PalHg=i2ng&+yM&w#DE)9;5gZmnnWy=n`BvY}!eU zJt{az%M`Rs3+@|Mt^S7gL&f%!)|1fsiHd#)R;=OK{I1#MrKPMWvGZAxoz12a@tnk- zd=`rWfpbD8mlbo7pCi36t$N~_Oje3Z$!sP!1!Wf>m*R<3JeLdG)S6j>Jtn-6ObDY` zsd}}l@r;y|R@5eQX-vo^#N-l^QiEE^jM^BTo15WAr>Bli z96L1=otv1R;!e$+P=lm?Vr+bBZsO>~_{^+YLuw|YQ>TtbN9Rt>jL)cUt!jE~{DkTv z@KkhiTn!L#_B8h_^aFyb_f#e+P0x>K1o7;O>d{Ikv*!gdY*Bs3vuPok%O!JCJR_-0 zBA!Z}i6_nhuK{h&gx9&1B>}4Z+MGvWMrX4y16r+%@;s?b3c1m8-n^m3Hpb66}UxBBzEV*sK(H z?)(C$Zz(5b`S?oSe^lS%IZ_U!`Q*V|HWe55Ea7~AZb2TLs`Z?(q6Q=}p2?-iwsTnF z<;fm%Warfu;63?*z%9e#aoKq;EyQ!nqL3C~m28=KT2O7t1nhEaZu#t4Atwnu$0u`3 zsrU-F1m`T7!z18b65{8$^CkA`PC|*TMoxqgHV%CLOH)cJU^&F?&8=WAvHJYE^1LU*u9nzH#&Bl@RN7L~S=p z6l={JQo{7amy%o}yPS~(G0#L#POztP0-yKcR-G5l>`5#L+ijN`fMW;3hD(%gp6Wtk zsbo4S! z0GEL+cM$=|PG!%3-08-HE$+m%@flsYk)d?Or7B+wat}vgF7D>dryVn~O|zr0PSchjz0O&1xdRmb2RtJYO!mA>iJZITC?ITBfB0^J))2UIhzo;Bu~U@oco|)uwS)dKM@yL zWk@A*bQp`il0Mc>s_KX@EeRQ3b%J_Dqe^vfIBrhun9*3-=0uBjB<2{O8lO4#oEXG{ z|Cxys)7)uL|J?EEQ!}csf+bfZQe}D5_p< zRXGVvmYiyZl4?WNRQg;Fk29;M`@@5vz`+_DEa{I#HocV10HNGqX;vID9tJgvk);*2 zMjJmTfRsfJ#1#=;)BlA3oC4X;sC$>FqMzC`{OyVB6Mr)=4?OW5;U6-8myri1FFm6K zYAzlBX{hr9%3^Q7;=SW!$Dg%3z*3NJGQtspiK$3Dxn^wlT~V)HeB|A`-&BqcGZ-}?t@X@ z-$mJ*Ucd0Jjs342iiVd*{i8uT0g#cb8bq&O0^qkm4hd9@h6t90r(;&Z7PIq~m}86e z&s?GAA*yxR8*>T_Z@Wwxmc0wgc2n66Wf$+bY%?%Cf|qymPE(EV3Uz^w`DdxHM?4N} z+}}MTfC&bhrrGptcAkwJcW##4IaagvB6A6|L_|phGoshefZY5leU*}+LQAjOSE(<; z*Zosm2H*_JNh}$%o0Lcd5{bJ|4>W1xnDgp%_1OYFLv~AOn35#z&>@iRFzd}} z-m*YTyY*V$E)5v4Ly8!%T^iD12`v$l7We8<`B!>WhhYu}bh!L0p_(O$cP5>{GfGdA zR%|_#)S{PqK?8%nYtze%XoGJ`Jjn4kLC<`{DxtI`X%UU{O-VnQH%ht|qMkPMYCQ+# zi?$bSuYFznJx`yfE?A$VKr1l!8z;cMNXApiyg)QVNz#aWV37NLv(qO=qqB4D9(Fks zKa&EKEDN?Yt6{M(#8b-xd*90T<)-e}mE<#ey1EUf+NbkLLAc+!AW2KPgM))oHk&$^ zlp-bZ76FMDi7yX^J5_sbc?qmKhU36;mpD!{6`uw+)EZ8ckLVs~BdZ-;T#`i2>_LI& zlMoiNDP9nB;u!SP8#&JZ366JGAJdH!4B+WOL_H99+KHZYfnrU zo|u)Nn7z%;A-(pEAmwZSmg6t%-}YbkuXXNU>o{=7+pyL&aL3zz?fe~Y<63CX9dGN` zxkCG1xqUBy*FIHfAC}vP?|8e{ddG_arrUkR_Ab+LZ^K6gn?ImzuD(0ox-U(>I;qs| z|BmG?={qB9!6$#%T!@~Mqo;01Pj6T)K0i#ltMAHK!P_o-+yA@hf?oG?XxZJ`!;bEv z-rnUJi&)OiH5L#xc;HA{MB8Xn+A3;5xL7aJFVZVk?YAskuG{iM zGhn8O8BMTY0D&bJjV)g>RE=nmYL_e`eg*~<@$F+O?9%RUtLYAE*Rj}IZNuUO{mVc4Vu6t4m))#_1<>1ajuwM@LuUjqk{vrj3=-!5-bmT%0aO42FK17aM=zb2x!po6i z>aSeUA?tSsqW#ue{dNE=ju{>~a?DVYZ0D2BQrGGHF4NX%Vq6q3W-0LOnHBSvT&KlF zAO=6UjyW8mw1VDJfQobn_d>J7OJc;__B3=_$J2zXMZwwxSUJG1G3+|jQ56%w3Nisa z@>i)9`no0GRbn-1J}PVuv_XnzmmsnbIeMKEUxW^3?mQX-hY0!RO6E*W)5sf|HX3gY z;!6Nmuw-0a7Eu4UuP*3qoHskT9ol2ci0kQ@cIp9Bh8>H-WTv z<3U077T6(~9V)O9}@m+qRu8Kj*HG@M`F8+B5*oUS82 z>#g9(5ytuh#ttxqaz}B&(5loH5f3);TSc_RRfkCAv1*$FFB2r=?_j0v4A^9)g7_NL zgac@WVT76ER5!<^v-~ow9tXw`_xy4^Rcdi^9G^{qp@u;V-6qjxGsd19G&2Q!XU$b! zAvB56p)yNi7R)_qMa)BQ5f@hcJZ5#6{WfH(uVl9*gj9;-K>jR4ExF-OY1YeeM8zzK zd-J;h+`CNut8MSyP|upJL1|;xY|TYmuibsOVNcNxzaV_t_ZFQ9F;uX%=t9U%x&0q{ z5SpSXhyQ(x+a6lC0#rn(7$S8RdsnFrAn5Gye_*lL8;rV!Qk}=%OX@Z(01;6_7)$_f zMgsAMiC)KTD*LPO-3558yhX6_)>zRR|Tr14oGu%w=qP2{U5tAu&Sn46YXniqBJb+q*73{kr=dTbG82IMuoR9<)^R zuQ&VFSmr&%zqWvX?GT*cXP5nJ2Ff;5*#+fFf7=P(b{XFT?M_qK3uQ0Qn7wZv{BQ8) zT`-zo2*6h$Zc}TJ_~td>oBPFST!0wP5Eg(Wl(lGs!=BLV3tN3|N6F{5=%y77zAH$= zlBb2fFqBF=3Z)7u2_C5-oweaG2YkcJCVnyM7m`TH@UkVt{Hi9Qfr>#Jj7dvXf10W~ zKCMf2pn->gq^cgZ?EyS$o5`cLn>=dUFX2&Jet}2rsPw3LC#*1r1~;WQsc+z}uzEGo zlV}i;MD(Qn7kJ+Es=Z_mcj^5e(DTLDSY{9FkImV+1jbRqW>?~#rn4!s_y2mD>$EK!d=O#vVF-*df zruUl$VppT)G&{J&*4tL&KB)UDE+HpqfQZjyhDTKtG3$X0bc*4PLtvSh=fs9D&k3DZ zVzO&OA1{R#G`(bYTr0w~5#4b;2bVk71lc`sTx%;F*SmF%S8T5_CRY?a&Q7IepwO~k zZrKlwfz9sdoxYJm-(k7$Frs#som15M-|zjW!CQlCv(MgUPyafd(|4GTVm;L!UJE~7 z2tOr;M~*5muan zHPj^I5CGeG6N`o{#4cKTlOjGwhsno4qd|A}N}*4%ImiLVGqKrxCkG{8Te-tXto2 z2=&*l=&<$g2ciSkTLX3gs|%Np4^@b(xHuOv+vMRBry<``xdi60!&@+M@e=lY39^d8 zpc@Y z=;+?|D{#+$18)7-ELW{xu-ifW!dXWen^N@~uxPxtl+gjxuoBC)x-F~!LL`CSPrddS zc_+5y+fOeD8CEkFSoi=3o;o2Y1c85i1dU9=M;vI1sCC9eD13M%Wx;cX57Po#Wj2Ff z+hieVJ&LHNH`5e+<|Tj|LtMOYDKVhWjooeQq3Z?~zx} zpZe=wJuCaexBdME2IZ>4cneIM%(N-qV8Poad)o@$LD@U_maQ-}Ee}oK_MZIEYH_)X zR?0PSKZo|pE49%Y`m1y_VEfuZfGQ4+AeZ1xto(;Upn$$DLYmUn7(JfL> z57&Uf`0EeX?29tgEyi`kZ@pDBa>Ql*fy)kX*i9A#z7UYaj}P3CKn;n_h+o9D`4VK| z+RZ_LFJlu1W5lmumcZ<5nDt0IlYk7>7%934=*F0fhk#xTy7&m_$EZtyfG4%Ei^V=nLMKHCtPGQI z%KHEf74HUJe{9xP<@A!CQ2kDtVHyhhvxAR@@gY{5%Ai8IF#X*rB0egcbV z&XxBi{FsQ#kKwZUWq9*|aG7Yu3TG;N_AGpfNM0mV4+cZY=biA`2;OeB6Th(m(W!m@ zitnMNFF+!i*+xEB5mEGrB$&rwr?$w9rM>-W)Of zp}A@pa1d)CQ)@SO76ok$FgmC9Rkkh&DTw6dBBkd@Q8){8fzML#!D2I~IUW(rLk;O$@KB6F7$Lyneir)Vo6@8C_eEdD?=zmcAWrTNI%Iag;@zQy9#I;06dLx(4SSTH!9verx#zIrYgc?7 zN^jprz}4j_TCqp5k+Rpx_5F&sMG5U7eFo%)0i`EW=oyiFMigJS)<Mwt4Z|BX zt_DZ3iFDpg*+X*kkmBuBLj8#63lS1Sa>LMu$Jyd2_7l8=l)YJQ8CJY(C0CEhJ&!4& z0mav)Gsd(!ijR{%CuqvvNoLbk*@;Xgv`6vnG&;d-CTY^^v(W1yhT^ndOVF!TZW~d& z-IZf$3`P`RuQ67;qxihm>xhN2cgsBo6>qq*m&V~f#m5@GdL6}43*m4MdNs;T5yjh1 zdQ~oWD6IJUj9wu}@s!qU9(w5rcM)&749z~PIlZN?sEe1 zS*^<%=u!h-yy6WhAy_?f^bAaoqs~!GXqZcY*#@urinpiQG7c)f7Gps=VDYtHM{Ez( zYt*JK{=@Y;MS8tIZ=*W)Js5lM*FFp?E>BanQ$Tj3x37HZ0!*y~XgBUryzr47e4Pio zP;qkZ!nO7}8y)Wl9{}ltv>lh@l5@?~zfO%>X-n~hqnqwh+)YI*{ND967VYp0>c4rQ z=tKzKN8IZ!fHscPCuzEOt-kM@s~Z%8MczVLJR268?q2IVv`)bWMgXNq zvIWXa1lmprKFd!~?5v(V<+FY-!?GS)zx)Z^}qgC{p@u>usmp}BuHQV6(v`x} z>ibg%QoAh7Z-76oD{UaXE1f+X`>g}EE*tZk`ZESHyX?$w?sp7ib!81?cV!Rcbmg!x zZGUc8F7i(4&l||^${#4`Dq!KM{e=TXT}8}q=`S8A=_-Lgt;?z5+=jjurIFFQyX=Q( zCtY3T8g2(Cq(8?AR=2)yryLsV{!lAFbyc#MHpI+8%ssJjWWO4-%ALaEW+JZrhnmRS zNP@0v_RfKKS<1Vvnh|Ynw$IWr)ZgRsc%9Ar+v-22__cgE)ipRcL%{!!FU|?pA8^u?}_bi8PqqBHhj3&BWtX&dqi4m}DA(e~#=_c=Vo#*sSaQk_^N?e_p0%f-V|-4d|zZnv(ypihmm^+H^RNfx1-FI zUQh3!$LkvGafda6H*D_p44oJf2VB$}I*+^mM3^7)PytToSF0O(h6Ydco^0qj>+uc^ zboUGmp!w)8PlHDuw{F~BqVnV#?yVml2^$6X3D?}zjz%LAqk3er&bTWP?KvkamG)~5~^UPP`w8s3C!lsn0f@_)$x34qHe*Jnt0jmKVo zRPzJ=_jn$|S*vLerw$$O!?f)_H8gY@Rnzr(h6crQM5He39qjdXcZ-#HtksIt3t^p0 zJn0D=PM@c{2eS)(2zDjyQJn(xMm3|_jfi)-d{n3UYjxbHrh|HNGZh@R47z(q9-`w5p(EW+6i%JAo{K7*50Hk4oVa_zO+?=Jb2)0s;z?Hb=YkrlFL`m?JfYt?)~QLtc% zRIp@vTcBXYgmz-6y=fw?^hO!DnW^6!=orcosD*jeH~NwbpP(6jJXSW($O`JZ1@T4#|N|fHWsyR|Kfn zhqWHB7&Zbvb<;TMP7Qeg`cCu$kPN%Lr$hn|VU5=p)(NyaV8Iy}b`SOro(yYy2fbmN z4F66Iodw7r^k9LoAe(Nt2a66scsS*hOBA~Mdk1>G_jY>N*xfA*^#Ed7ySrmnHhh~2 zgRl~{5Xo7JpQi!N*SY^)x9oPB?b$t{l(c8tFcF?@51HuEYJYZLC_68p%L`>@1$0>< zoBg?c@-R^j$%p9<-o%2oF0Gjpip`wzCzJrt>4EHM0q*pI4z5Aa!!-&9xF*2}*DRRe zrU+)ZsX_`|i{KPepVM`v38jJs_jI95NPCX&vI^xuGjv@xp+d0Yo`Jg!_e{jgz}=4L zOxztvZ^u0g;SSuhanHg%N2nCCanBWsg&gFUCsYY}xaSMic$1Hs76>&$0b&#ig{Vi7 zPz1MlB)7IUoD!K8&BMJuN9+s{hlWmghlcBWVj(bnv6#%5?0_#OPpL|pQB5DMW(gPK(+AEj#3)q!(K}*bC6ljSYN1VeV(nFH$DRniVV6KX zV^^NMwTiE=PVq(WN^6iJTM0#7>J@+Vju>hi5MxF3jp9QLLMYfJ0J`_BQJ$jr?{eRx zUQ^qOSWVHliVrae{;_LBUu*DLTHWV*d)!W1OY21%bzn&*57vd=!4pGa3vg0z_t{}W z0)_2i?eMwo_S%xL)zv$6%Ih8O_Q?2S*cbszcr)C))zxzf7@zBSzgq|!x+$~RHHPq5 zEIDgaLcPncafh>q07FyBcQc?jLdqZb2^4=<R#kBa z!bPG>#5On{`cR{*DJ@|>d-Us8@f0e<6Nd9Nw~)>`8^(2WrovE8@wo1?B~(%`KW69R zF*Q_H@ss1hHQS{%+pnMbWzAnLeQ#-C&7;9JE@_P`u%<`K5)dmTl$VcNdMLjDH)|-* z8O*Db^6FTaJ^yOcl_q#ip@JgHIyY2ODi={!LHCkSA$!i7FR6NY^xEimFUoHn1wlup zqkc?6gcm-7ga*0nR8Tz@3dlnS(lK^*rpt-93 zhF*{Iq5uzRR4^1RUC=5(Z8bN>dsV=b4E!Mn70^U;7zJhRRe{Xw`X5FBH|Sjr{VDk; zK0k|h@pT>1tmQ5rDd!qF4}Tss{^O`W&s{FOpnaS>&*Pn)-m!Ej;}5jd#gOffir+3InpJ2Ujd&j;#TbnyNoy(lj?}bGHY3ua3&H=fQlej$2 zUa#{MZuI83+db%%KR@S)+uI)I&u<6Ju>kEjE4sT~o}S*`u(qe)Q=1|lLMH*c*t2iP zj%^3Sdcl4C>`C!43e*h&eG&J=D^{^*;ViyRLNgg%RI8EESN=qV(qlA0I6c0mzPy;P zEdCW0;`u(DzvsSqn!9ba2d&OqR_DCM{^H(CdqbIzeWulIr1jEpU9=L4MjbZ*yEVUolTsyj*v!Zpt;|e!Ju&+kwxu z^!9TtrH;+xi_bOmLQeeE0(<|393eK^KY5-&J)c?GvWEX3`IZdBpKIa!X--S2_Dx$& zi(UWbIuraixKsq+Nab5fbT=#p^4s+-h2|T%MtWPqQ%I?yWu@^(HQ!Qaxlv;ze}fU> z$v?;{;FR(QX4lWjH2`zZcnMKpmlx0Sg<3=~_c8MWO16)h z<4Ybj3l178@FCXY>r1)+w>O$1WDznnn$ou$nl)T5f{B{J$nHo(R(_ z%P0JOELKO7SlLRf%X5)M*7zhXvg}JpTx2ibkz<#0UP67@l??OmTcx~>-YYmUH*Q`L zW}I*d1xnip2^cYY30r0tQai^|WBM&w>cY`fWgby_!8n$BF?9sr2Hnr|Uc!i#+-k=x z56G>^OPDLWMlDLc67siPw6OefNB)F2v#alsh$rUuQ0*^P%89gpxsm}=uXtdMOT1f^ z_7#A; zr7)_jo}zsG9bncU-=wnA<1^a+8bk_b#s07Xq5#)`2TUeU|7(2Mg5tekl6QMYz)(Df zl&|q3u@iioqLpO@Fk%R`72M8PdeI4n;#qOf4YmlwDUpq{*W>i9{dJRrj`W^I!iuB` z<4S3AhV`sYVFUVvm}KVup}~{A-m`)`Z0d(FhJ3!2Npp$}R6XPatRc=w!AzLq^f@sK zJHTcGs*Lf842F}H##&hK+Y`-pXJ_Yr`JLQ5ZW72esdGWj^5lJUJ0Hz0rkkS-Se8e9 zy|WW@jPoJKId~%CHlXw8 z+@h~0X^z`2^tc%x)#;WyztU4%E)(SR&0(Sp_xE~n^NPL0q68|6f^K4C zp!l#c;$ftGIF+$nWr3X7Luq94ga{r+_J_5Q+l3AC{1FApP{%$a!be3cykKe!4-JdE z$Y+EcgE48%YK=+u#6&Y;Yr=Sk^{g@B?Bv5C5@jmVdLkaCye+Za3PGdj>Tw@;f&Y;r zgO6_ag`V0pnT*nShVktW_lrZ6(x&iuwu2CCCtl1`l&hQGnMDW?y9um>b>t26LYN0V zhpL5nH{-h#r$fDDx=e;f>~rvC#nNQNI%2`pQJys*%o#nlU|4BB#)mSpt`=S?3}#eH z8I{3|B~r$c>8*i`<>M`(oV>eSb=p~eLVLTSZpQN$BR?MbvnQpztrLcMTh7(AD`|db z^Yw~%m%h0)*wP`jboiS){hd!Oa9l~V<};4VXwl3U)C3EbO9jhkG6MyxKIiNiCqUYf z8>(LEcT|LO3W7OHZsjcbNveP2p1I|F!QGfN%ommV%T@%6RxWUwyxN&F^EFF8WLv1RW^()Idc0t77fjrmrr_!}X?0t0^HZ_RKlU1bkdSnmdx-o)jv*= z%9&Ns-VJOB?j`neuwvyeLT&H1rt^K z%g;?12oURewO=im(5GLbODlIE+(7;D3G1z|7D^~Ci0)`hh&GF*srO}{u ziDZQ|i#H$AMEC=kNClmm|Hg4?2(nhvyT|y*JAeK`f5+jVi%v*CQ^jI{s_3hr^_AY6AS77@SLCaCe za`aauraZ>~lShX&{u#bKgMY3?x68@hF!60wrWLm#IfW`lS#s8qNrroUl zP)Bb+H0$y9!*nhDajXntEj&xSMIR9L-##k?>NBxVujkZQxbp5>!S2 zQ2SIRG#QgZ&?~4dV>=k)*$yC~l89ZBiCYBYs75foZdQn2kl}J(3hAyL)%K~v4y8t5 zs2E;PQEG5c8i=j?9MQOn4=f1-{V51D$l8Q@gRD(uWbLC-Vm?Y5J*87pf)#TAezc4l z63ALZ($=R%YXhQE<=T!WkDVSDTkYWygI8st2v$aH+Md#Z|SnxN^}qj z0`e)*=WyVjiG&1?E5SdEbF(gJi;n<<#Vil0%F>VKmJNCe*73Eh$shYH=Mh zQAk*Flhc<5VnNnw>%rq!e7)~N(n6dG==8q^`Q%JqGNWuz9tfqXx8eh3#{e{qs2$!JMU1&eG|F zft=-^bNaN(Psj-^YYZ-HmX&N`-Q17(|og_|eLp}eA(3$7JR7(P!$W-MFCgKgQ9_7}$H9l0+#CPxB}+6i7I zb+%o1UGMN0x6Rphh04lbt$d|&I&H=qDBC!p`;IkKTJ~xdYG1u+)*C2oozVS$`km|& z;QfBD&+mB>sO%`;ronBOrh{&6nohcPY7Wt@^sweG$K^aq{tUPUsL>Y-8dUxZ)V*lS z7Yk+zVU>(_z^^_lz6W_e9E&_3V#qUsWaPOeisNpa?Fd?$B}=n{JMW3UYk7O?P5!Oz zLCbE*vO6Ak9*D*|{nwWM2=06+8oJ?)$Ag;=NSh7>Hlct{$N0-Hi>+ zC3tu{pE7#8z)t=K9+BQ|WbR6yLRK5L8I5mm;9ECa-ri`0|L0mG!hfE!V&^*T&o^q2 z_UD^T@V{fo-d?JGr%;cOcS}-8ofiJ&`0#i19QVBW`4j~wj;eYBzl;bN&?)Mrtfm5gx|M_YPE7M62l+w@6&F#$ zp!5iOgsur*{8^lY{XTJ-d_7f8$8pJUV`vVmxY_Hf+#HIRnxr_O7Fc(rH4r04F)dX* zr<4V(H&!&Kigyw)O>GooC0rJ5+a?&|uuD?WQELd5P!bU)=}(AYz%qZq9K|lj%_n#$ zrd;E$%*1--O1{zis2+-}dTJ{u4oeJ`!ZC^`LZv{vp_~bhlHjRI9YLie zq&W$ZYP2Vh?nYmy#PoFv`Z^7Ly~x-o`6_Ke&zHrtTNTSi+ajc2N&!`1jTJgkMjh(CzYZRpeF= zL0XDZgJe`e-*V+`^d6xUqTl_>=sU%Sy&jS8i%|-kR~5)bD24l{c6e3sTAwO{Q}T|g zen&?I!ZvP(%I=QdR`AzF_#QP4nor4 zJUPUuEYMy|oF|hdP|WTn<$9e+ZPkLn3LAu>VJIWU^)xMz_vz^e_46f7vYrEnX&;=!f_%u>-kFvO%!Y#z&U zEJn%pkbbGaX0dN~bfgky291cwW;hCBQ=iCVHucZdeYNATJ842j~|VK0>XRW-WA21IGEqOcZPuS_N+ zj-raP;#&{-s>m55ht^P;_HvQ$I5|(l38!@vY1@5x+rf^ueeF!Cj%mtaZqjx!h(bN# zbS9pq%5~#&>K9K?LLJiqhPV{Ec0$;8{A_Q(0O1hn)l$r`SxuBk%RNhlN9ndyS&YQC zxiJmiu!)!qt(1v{eBBX17S^2hgn3t(?+jjUHr&iKc81LDZD&* zZ7`6xY=JAyYMRNL2Sv92mG#q)zp;C+eCMPoRNe6E=qsZ$ZL?`%{Lf9z*ZO|?v& zdG+ioXQz(_syE!K*tnplbf1|xXZh5MSJ%F>_WSF4+jPD0X4%aH?^XIAd%|D0DNuJbSaDRUI11?j;@z&Q3s$X{ zs@4apHUym;{LT%iZ)y2tbEtCJbRV>NKjpMV%OmwY=0A8ic<`un@aSCKv5%^c%~vdW zbU)1{xPB!S3FLVw#qI|sG{=Kov-YiKJZ7o7mUaVJAA5ZP{X24 zQ+T&@x=X59H=8M;eUMabf|RDbI#{+=Dq9;UTOTZ3?=M{c`Bokyv#_1#atnhwHMeqV zNFv09SwnM}LU#`DaKckzh59F^7I#x@L)pryzVEae5{R?SaZtX%TS9y&8BIMlg z#X>$;)bvFpR~q!M7TS3(Yt5IR8SWulF@I#8P=EfTe{FdzLRiLxZh1Q|*nCK8J``wv zG-!EDvOLBJ%f~@j&R{w|$L}`r-)Ppg<#2DNv{>8B+PCtXSL60`E8nIy{oIyE{#w3G zZ}@rLQuyD=X|~|@ZaLp(G`w4(BY(|iB)+&=kA*` z9azv9%trya^Cwrmy!qPZIcwd#Eit(g zS){A-i-UQ~q`YOHrJ|vCIin+efitG1fALu+XUhY6WX@GT(~ddIp}(=`0QWKH{*#Be zK+o~bS^PKZ8o3$r{J6L|Q~Rb>1Miy|Joz)V@W;(s)JonQnGerm`-`xRn)fvxWQvQ7 z5*!}t?@0KZo0~jX$E~)8W#X6fDjxb1ok4?S+ z3AAhlQW*}l8eeUa=E}lFwYjbyR`Yg1ekR^w^XDDp8Qat}rKv2jFE>dm*_&0=z;YzU z4mX_^bHkohU^{ZV8pF;^ z#%@gxJ7pjhg`4P)hDd*a`T+w4fW*m2Nl=*{x&J}-X{ zLxO9B@m2t0Kvj<5W1yupf+av%P~NKCE5zXnh14Cf8qM7-SGdT|SDnx!i$&Z@okE5( zx`P&YW=`Y+-q&jqODwpzGe1 z7AilK8@oo;HB``?UW<8)vY+wjFf)-<1CL+)#RTzRwjly#q)d|ex z0F-Y?%uK=}d0OIP zGBXN`#Eez*(Fx4V!XhzaReW?p`hDtYjVs+sG2_G~#HL8N6xu<7CSdAgt%2wxBI5WR z{<3*g*O!D!8I2id+R6#WzYbrZ%~uwIMTuV^@odQ24cNkQ5D~LlM$LeW#d3B*>BORD z#8ve0mo1|TLX`o8if0IwhA~ImnNf#0_Tq=gF$sHd{r$dF_XTJiGu7TC7*rd5yG6(~ z!otniHOWsmPY*~-H(oafmbQJTRLa=(!&yh5k#d zD!!AUf?*x)9&cw&37dLc!>*oQ??^aR@pTJ5;yL6io`(a28ou6vv))tC(eCZ{hAqsp zP=7yz&*9YmA&;l~gsaCpB-R?mwGI(Rfu;(F##6>qK_zM#l}Pww!j)w0(#E7t#k!|MT5Xbey67I+ z=#EuEe;O;K2qn)k4MeSaZ-!E!U`!g{q=lxB(J*=vOg{96pzdlLGbYKwk|YNfmNW-bk{r^KO`G=umyh#5&Ob^Ax zS+oyg;{1zS`8e&zG%5VB+Du=4WW|d>Jm+?moMp&2ktFTF!$&+$*D1G4=mfp5qsO>q zkO%|Dq3DMvhC{I=b{V`sw;>s0Q(53;v7D$=m(OL&^7)#E@gv%Czk#BuYF_tiF-!

    NgQ!mc^Jpw1<*^ctBw0Y+1n!$qjKYG@Wv1M=WrVD@iDJp>l(#HQw*;&WLF+2X zx+-8@Gh24cy74nTmzMsC7O^A3(Z*Hrq%y5dtosg9_eJHy#ngQd#k5hOR1DL`sdSR~ zpOLNjCY(3~gGA>^5R6~5Q%+N-pz>;`XdLOGJ$wDO9TSb@3{JVG)1|7l!KxOis^$8M zK-Kn-Y&$;DB3gvmQc=x+OiT23hDv-)WI#9-HgOa?Squpg3EG9zVuBl&$33oJ&SnC} z9+Qp}|ACzUi=6)(j!#qB3`=*mM58s~bR}dbq@?|1gU~fXs0^vqdR<$AWx!A#Hf#|` zN4Dsu#m+wQ4n5MS$@obJ`51np7hMp(hV>#C4}wUdh;T~Vpy0m1P!`w#@`@*j#I3bN zgm)*-V$Y@i5Uv+^e!U+S2$fA_H|S0D>A zt5ow|&GCds*ecI$Qhb=RB5pYFL?ByD_D zaz5(sIwF-E@gF@F$m^bHiN@X{IXBGqT(7*zzghj${+nB*%||5X5&yAnsifQQIv&XD z`E_H59L2JAo|&H6${+V#FP7Hq_kZoU-+OL>G&^@|KH<3PJ(_>yxU9XJg*0lX-&0Dq^G(R^q7i_Q8zf-}(jl<1Ux_&=q&hy;!dg6;kskUTX zAulZz?7E)+F`~I}MR6%$X)4p2aDo@~QG-F_dKu5UlynPE65LA@#jD0S zq}2mQ)7%>`X02CYFJVJac~r3vSn)9U&_@aec@NHzGNxmyNxF-=5wPXQaF41c)f#2B zBW??XHK~`6kC(s`;D@u9nf{bMquf;q? z9B@e!9Xr<37*)EcPZ{nRU4SbS1p+T2j%Y*zSl(rjF>a|LIF)3nX2}d-&NHYMu=_7QLHHN~xPi9q9 zAx>wkAmrEYgsbR;yM@RoLde_K&hQh>6M`&?`^d3GvagL`8hM5M$A6D9vEDFt>0P8^ z7UIM`@P&=EAq88M6V@_pOFlcm7BrD8xH9oYguV`^(a7`;_CQ<-fvNbPC>wqttZ`pp zs}ZBKzecN2M4DlbTY}_)jgwI(c|T60d<{8cAzHAcnX~3I>{qj|WWQv3*>cSipnb{9 zq>N>uoZP9586FD$ne|vyGSlza@}|<}Z1tb$HJJ_LEl@1T$i^~+DUw$dk$vd#grJP{ z@#fD`xC}=Cop)f)zBXW6`$qY!>t|Jx?UC{3JNE2}Gn1>XTwrE}6*hU+yxqan46?rR zkcQ%OEom*8+>K0gOQrUPQx7*ebo8S+&oh}p^!xhPg^l2nqD=Yv*DYyrC_JVGm8t{W zCDbJ00!3PhuSyR-9OKE3GDyTYWJ-vZAPz#Zz`;wRH!!Mh1qA=D8miTVAF@k@+E9g7 z2nYoNg9gUeEMK5$?}g5^?6C_JUCNk|S!tndcu*6Ux8bRjlgYO_LOE?^R<4~L%*3m6 zCq564DimZK`*tS-&@urfi%zu|9tJKMwi*Yzf4g8vOr?@wQ9c>`U9%YcjTr7;1PD`a ziZDvgiB>ote+H+$HZ>W55i=+jLzFwvtqdU+Gbtk1>#LI-~#v}v+;7{z(-8IRRh0@kVi#>V5K_2YpRJ424Ft2?jkoILRRyTK<*a{_CQ16m5KT6yLCw>SDNCCOkF$Vl$l z7TZ?9Du;>OqE&F;$~14S)V}4^!%YsiQqVik>wa4}T^v_UH5gO91I=Io1yNk{P4v=_Coo5b!h_?Sr* zR7XA==l-GdP_%6yIxh<VxA$A*n$GpD@MY z6HGt=Hpd?#ftW_~mhw=Hh5rD4U!rJWn5x$eGc&?jc1S_@U^nXGNrG1WIps%vCM$Q_ z8Yj+tCquF|%2>oRmh^Z`iEwzpQT}cfJii|&&l?4ImU{XU?@Nird|@q}fx22A3bMyN zu*bwl5C+x^86o=0w6Whca9nV0_N_|*O%HBMBqeJK;X3%F-?m>raQ&jR4c0!6NZXDC znxA-Dwq6e8Y&oKcHnh9_|A3EO(9TMNBr)(6)}rTRT@D7hjv#@I7DY_DTjf!XB1S6E zlF=d}l>oF9tcXb;7HQKx(uYOj{g?)A2>CMLWi;m`Vo9%?XB*~fc0sB$-ZF2q|BVf5 zvBARSQsMGBTVr7D6QIT!9%SGgIY4(DGKwVS)XzAioYevQ8oyvNsG8%AQP~`t{(zjUFtoXm7W8J0)%v|;=!!lhEpS*i|N1TIZ# zzMX%y~k8#X=M zIPgT!n1Ls!44!l{>uM43q<;W-Qc012A$W>Gf5vrSSpAE^6CJ=13z`_Xf{+o`uW6OV zB`~obV??Pal8X2kBZv>X$ABY~xRj>k|EIu9B3C6cg^BmR2#g^rU92bP9!No*$Ka?F zo`=WQM4DaqqG`W=)&83DNk`cXJ^R+hN!LsKm5X1Br3jA$X;e`=ExTp#5Mnk8L@e2o zODI|gh$`F|g$U)b3Tirq%QcoUGO-e&zU9ebb&}4EAYrV+gjoYMnK>}y4X)iS;eYj> zrz5AwR8e6|5TPCBCmsycL_#BC*-&C51u!s1gkg*zgrFfoFhvBy_)iI=e-HmD;r;u_ z(sKbBML=0po%S~YWfq`WS1wL9e&=Ff$(}j;-kXDx{ixq^R6((F=j}NR#ey0p_1)K- zS2Z_rZ#J1*(zG{Bdbn|8t)f_is4Y+|txT#d0>g9veUwu#WjB4vSajn5 ziCFDoH4@wlZW)V?=3NQp(*ZHMgvS85%v@ zIINPE9)?wtn)70+-(bzttYTL(6}6~Rb5xlb0?s&sc8shDCR0iV=16}KhtleRYtfic zZpL3s%uy{dY{qn8d&Q8;P%Tr{XTeq@931Z)3&1 zqm-(&fc0$ZXlgRWG$pNIX5wQDEVNL+sH$brvZz;}wzlXyVx>Y&FBP#Y#C-5!hmM0y5qeLY zV6tb=Vpf&&iRzUrbo%_Mc;%YP1Uxx;wEu^IUKbo_*{BLOmh>05U)nyg;+wl(%A2;# z<*k-1tH=3}CGEwwOKp>lQwOHc%(}+g0+vS*nv;8V}F}rE~#YK z__k0UwxlhU@|Fhji07EMdVE*NVx4GJ)-cI|;29x^==g8kEjf1IJR{ln`#X*Tb%rHg zG{=%VxlOW^UfQ#ehxRQL#;6gai`7e|_zA5iRwTY7^<~t<@W2S3%WFw~I4cVg#w0(Xig*;*9wRTIo9tR7mV6AW z1qRtvnLT2bDygD(rL=q7i1_8vc#3cF_#%G2L z+H%mpxx=5|iA}|~GqZ!4l~QKqlmNBOs`az0uA6?k`R1BH=E0!lpkz4+V6JlKrQcK;qL5N4y)=+s9yFCprt$?lm)605v0&rORbPZmfX-HAwMijuGhW2+(7=dJh|)1ZSRSNVZ<0=FmONM z)AP1}RVb)~Zp^%m zAx&Akup6XAc`2mFp9+S(sODra5j8X^CBa;Fv?eBRnUa_C-jfrPzcqSdTGZHg?Ar4T z{~L{DvN>iG*dNRiMrMD^m}bU|DG`+^IIApGX+iY^iY=WIS zNa^q2Ab%pv7HQ$@4yC_iT9@^Dwoh8^dorS>I>7e7v;sUS96 z0hsyAzENY}vcz0L+@+%JdDM0`0{L-!n7#uNFZt{*h|K#^MTc!&De{9~fgD zwcc+%9c6n%F|I9W&-zHVF}F+V?nP+JcE*ie+5^Te4Rv00)cT0olW;+VP!K(vun+W7 z;x($SlD&Md%GksK(k7e(slEI*T+QX3+!axW$!|TH- zvZ+bh9OnDGC`Hf?gJFu15>YvS4@OLx)tX1!_UwU)N*vxMABpCH0Zuo}WI}0>S>=?i zzR-bj!%LmLIQD74=Rzr~!62LC23vt|oIJQsT z2G1<>2KiPb%KCTIIaJBzPH-|; zE{DC-f1qOiE0thYXhDy!SoJ2YTP%}C=>m0deYxlY7#bBk&KeK6pz@i1jceA`M)w=a zdk~qB8*BL@QobgfXv^UmdglyF-VmQL8Dup`YC2gh1BeK&5hDBlGm5K3_Y_(B3 zEsg?YR!Nfx+=$D>ECxk3nB=&o95de8cF6E=p79^(miB!uu=Z;&>7*RjmmWs&*b4aX z7z_AX=R;GxcTjWDMb2?JI3LgL?K#ExU?TCp*`a;nGxW;b38TtUlMi7XS>6(#rbxu1 zk(tn8^39O*$8fMyYDo0D1(7PpXlPidAiE9!6MHEe5&}PXXm})S65Q@#^3`U@vabJu z(!5FzK?AV^b1rNmOT(bBJ#uCSR#EK1bh#sk^9j8@IDimF12OxVdM@6b2&bt0VI1?> z>%}%1pHCj43}g*0g}6&rxnjhBoSv0Rv9s|irNMat%ZNNUzY*O&Z_m2A=E@ozoeql< z0egASUL)DzseAqL+15FnreSX$H>oxPH_ZyOhyB^DbEa*#ZJFcxP=_hyRU*%8R@^xHb`K=^m~%Hhe@mv>&<8OW*(W-XJlmd!NIHeWyB@96Yr zEem8FntROUKY42Iv0f>wcYJ#&t00(FC1q9p-~!p34P>nv-wqk#r<^q{8;5FDH!XYRKV5{v^7b#rZ?98<;I_G4A^#Jn}aQb>_^frtqF6$w&Ev`Ul-?A z?~-i0@Xl&`@xrAGljkLC)qJ)yl-+W@@!fTAuDf36ckD$MW2I}@%8JCeWaqb6A(}RKXO>?%* zn6b3On2exh&~Y#M*Yc-!NqG%3I*1--PD^<$)%*2LzPuiV^Zbn3GKvQ$yP@7DUqtz<8hy4tB8HPWvf5~G75sWQpr|2 z(>QZXTG{IF#8mH*I)zZ%F6=*UJEGjNZ>{_&|Bq<)F@7P7>agJCG7E{VR*4SF$OPLf zqfFlXHZA<<)N7{#8EfCDz1cF?v`@;|hfffK@$;9?e{*DV9ZW8VZ25AzQ>7oF9^kB1 zg0YreIJxWfT{9O0OSjKu?HEtFZL%}E=!sl> z(_eMyBh%r9Ga4@MEdS-gMlS0#|JA}HJa5hjTWtU20piO&yEWrr75|olKbWg~D{Gq> z4>wCz!F|upA1u(l=h(Uq4tHeWVPQ)l9{lT@ow(g9;K?o494yz~avI?8 zA;dtrz(MEVJS zumBWA67hC2nFK`P1|j`*Ym7uh?oH)5Ma!rqnY03ef3sj=Dor~cP-7cvQ>gSz^p4q) zk^GI2Nh5~-Rn^&;WG6`;a?IGnX$E)2)JEkGMoSg!#3>XU$xD8SI;k@cb2K_q$x4bj ztPg#dM5#eAM)_Relg9UZDr%T)zAlTMWkQa^0Z5o<&S;AjAMw2~(z(h=^C&TX=ESsz zXJjH(n56B@uo=clp101iJ@FhQG77ExL%Z|x8Szi|_@BlQf5i_&s zp;8qmq>_*Fd{9{>3900-pf1Wy^H#<56L3ko#?oS1kgRu|qiOMdIhxi1A^M1s(dzy8 z93m4`4v_)YQ}ZRto9JCA z9ZgqeArs9T$I>sRV|O3B#~ynLVSnwI^#Qq+#qboZN>3-`Z@p+``QwiKmn(HicBm!F z%#Hg}leM5+DTnGmVoW$J=Rq~D_!k_OV;qZXgRyk+DSCdSdY+*~o|0JAD>8FIH`*Z&xRoJI23jQ05s$ zxjQU|S_@WAggGH|RESL!!vQLl&k{XG?e|doH$^Lw*x8K2^te5A>`{C})f0_;jSrbT z(k^PnpU}9kil*Ckp@->bITfY~^(%N(*rzI}a&^WO>z$yaz0stdXk46T+CVRA3zn5) zVsfby*0zaB<{5VP^qv?|Ul1n<+^=)Jm{jOnn3e49i4M5;EGci-#~yjZSPjlq%U9Jq z?QS8-DGRC z38$#m7RhIouAvF#B8M0^A|XTaF{N$f8zrZQ99m}NGjw*4?;<%*k<(5Nadt$3oTtel zTta5~JWsxFkVA^6BC(#u!{m&U^DH@+$aw)ytxewCrW( zze^4)-%Y;XBj-hOCdh$wF><`A4Zb+83?7)N0g=pO`LZG%M|`wsFTFrKnSeH;O0i(r zVD98Y&Z?O;*SASoyTE0D$?c6-HU{k#FjVh{_Cxnf=gjum(%IJ8HGW&m_*PY80w;0K zZu952%vrYHQ4c!p2;?+=&K0LU!GFTsQ2DYMtyB(OI;p&A_ED+4)o*jkN_@BM)vxdN zuiH7dv~6BFuBF4@`Ix`saerCYoa0ES$T_hc2k`id>jQZW3!FCVaek(8zM|&UwpZGw zUEkj`X%3ZEOd95u19$fO4;=FEKkTo1bT0R?&y5uS6P9Yh!mU^nY}_U_ZVNWHNsVox z!jfsj%r@9#S+R?UorD$HIB<1o_Cza4VDN@u7w{r@DIZJNkESWDV z^_Q)f*%~NXx4`jvJNen>`9kN*ZP(hSdS^1OXMI$-<8v*AlSP_b;$rxn@;OHRfnx*y zql5n9p*h>IdbBJ!H;?%bJnk>gZ*8{7b)cV9quO?gCyFDX)^9a=Ya^KkM-qZ=JKXLhW?I zg!4TzOGwMov2cc`ihcQc3QY-lB7L=B!GM3ckjs^B#Tx^)S?-aq7FJVioLL)fjXaru z_1Q5rstRWlncLKZLh`p>-x{=#m0alP*c`utH8fi#MR_C|Wz8Gf+2)@af)<#v--5|V z(NZ~EPS9E|S<5MMe>8I6N0!rMW7hE*4k*EC2Ryz|<)h1QdZR74alf>2e_$ijkUIR9 zj@wrIuNI6H>7P7h`0k(K+v%j*tlgWqH}m-2dc&LfTJje)n|9Y|Z&WVdZPLG$wFLgR zIexdp@HXGF0T1tN-pV{miHTsZ?w42QB>$JZ#WbY}}{<2VyoPSwrp^#-Ido#2jSSaL! zj3o&9V55fon@#Y4XsBslsr|5?-hQ}JPa*5H@W<_BBE}7%A%9l~z<8B|!F8UPHDfrV z1UnLvYTiP8`Wgrks5Hq2@$a`+iD|Vb6{V8U7W>dZB8VHn7}Am|g;)_4tb$#%csjOa zUr_hTLRp9zfCsaPAJuL|&ZBzN{ysU3X)gD$QmOpRm{MVSo;32~@@6^4#JNwtNm9$E z=^tDlXy-ui&NIDc6Pmy-kR2;w3W->1>`G=LU5GIkoXnic4(13<7 zM)TR&{_#KKk_BUIcR(Jk&zK%`rlE>>47g51@aPs#j*viwp&e$~>;}_@Jc8)rKa#@` z0T=lgt`J2DBKzG<(9NeU3ZAf@lq1i{x6@(%oV?(F8%aGx=|Io;7Y-&}EqqLL`t4t#;-_4gA>BRxx40#GvG;3_2PlM?=8TDBJw$lnrJ= zN-*7X%ib8uDV+3t>&f}5B{0k`1nXNQ{4d>ly+>NwcC$24wP(@DKbPBv)Ui0&Nc_?Hp}tvlvPjj(-pzG%@Y1= zwp=flYPMZ>1uP-sNnNp6nw6Gqc+&oE;Y3Wn)U}R z2mF=;x2-wjRt7*1@?Uvq=$>oYxK+!&r8RHeqJ7I!v$aY4R+Aq7IIxk9h!|M@ZVN`{ zd5gm3{f%_jXp9psW$Hq;9|I^~$@s*{L4`t%C*u?=IY;@!_iC>H8NjzIR){h!u_?WaE3&Q!ti!C)K0_%A1 zNPLS>W;;FdJ_)~D0i0}`mLaMcH*SHRlNowWG4|_(6eTw4Qi^{bt(~_(DH;6JRH_rk zLcTwsRz$c77A0S_;XZYwylDYkq+HoTyHSwuzuj`#=^~4i8?ogs$oJoFx$LYG@GV8? zSSQi+y<|yRr-$tOhwb-8%6O=L{te4`s6KtzGK4rw`_S$xf(}}A<{IMic@XTe(M&`h zUC2Y_wvXz(r3hvh3mnzSFs&1+hg0ImY)u~LzEmq1>+Qj(T zDPyS@Q%6z+c8?-lj92UM)wAP;nFbkSAIke9nQj;?34`n7g`OFm=q$ew5252^#X6>9 z2kcqK(Zna{#S%Q8lNz;F#V8uW zh&q!9sxV^cjxvw8nHy`PKJftMB>GJMOvqX>a*60Ol^>n%yuRvYy0rCC|B)vGTc7wr zt5kmUOYs(>w96V}G*M#ox&A9Xen1XW>N=0Q;aE8pZB;dNWS%X zz`@ZMu3ZS^)&z5xNx91cxeY(5z3!M>v0cjD4$Y$M+^Z+9oWRjLI4dWRT@}n;B4sZL zWH0-PdG^fQ@@6T!d3?w1yn^vvFi_8o1EfeU;kkJe`Z#S6UdmfBvq8$+ za+h;t9M()U-_FgOte0|YCz?Zf`7aky!P7K@2;QL-zr!ZDlGj$$8^T@s^F3> z68>u+xxP}W-G0;YSNZSdLp|iZBB}N;j*SjJc2aumWT5htRCsD~+nwUFK>5*`uDSA~ z{_dVYu`sFqn}Xu`##KK#G`n(kOxmy)yMX;2Uy}~F{KBcAFenLw{-I$>I3pc6BW)1< z9&cdvS%t&4L*}XN(A>qb(K|JhTc^^b!ut7@YkqQew&Qw>}Ty0nhFtzn5~P8**ILPB~g(Yw)+6W1w*P z?ZT>H;WDXk8HRU%$6V**_=x>S`=leM{R3x$1LviI^ZpAX(tuAo;`5J=1v)RHmd%=N zRLf?~eySziP|E}S0nPk@&bdQf{wI$5j}1sq4El$?!C{{??DIc4Dh-cGPmCe)BbsK4 zei$vqQ>$htW!0+bpseVIcL({--)2@&1!_2?`ENM1{IF)BlIG!OOSv^0W>;S?{j=@~ z-PM#UDU(pPs+u|@*=nb&pqZ&f;GG z#`@rfebR<~fei=b?Rv~!Q3sf0?fCSc<=ad6uea*~&j1jSsUw*-#F~s3 ziPsA;#*SdRNTaNmU@`rBT93TI+2t$Q83dP9y^dtNf|NBlYMSVt(+9LslNmT8i_8|$ zIZsJVT|{>>C>RUOgJdYRiyFDW*-yk$%74I)7sDmeYBI*|Z@KJys5GWzr6Mo$_f+F{ z*f}tzSfs5@kl7_03)m_|XUq8yj$qkdzXZdP#^2Vd7cn@spHMK9PtCEMa5&05uJA5BV^fMU6np zW;9$B?>HRAiq91aVC(tck#8Lw4}A+*!y{IF#ETfvxFtNXkFwDro*yHTeA;kGwP2A! zb{R9iolgh0?9@OpRJPe4Z}pLFDb85)AghmH=G!M4zjWoDIs?LL3; z{*P=2?mz&-1Su?d=Bc+mHyhtNcl{LL^FCQ@RI-DPT*=s}`6P#G^d&R$^jyn^mMwfO zza8fp?4V8{K7lOQ#Ej*{HaKD3@u8u9m?*}X?6j+aA#$*g_Z<2rtUu8|$DFVUqfY0!!%8wM zlLgLW)#rX%rqLIFYSQa-?`CWC8}FJl`lh?4Y`y*N@@jp`-GjVc zpLMq`6Hbj;pK~`m4Zh+u#4fhuv5rNg#OWFOin~=O`2XJ0^|Le#MDcg2F*dPM+Un2@ z(p0MfDUO}QL7|8!*ug~zrIZq=Mz1c4Lr2}j$Y}TboRsRuh>mfQyLM%CZJT?ASZkTp(-G`sYFVe6g8B?BD7j|$6wtKGcyiy zl%fdw6Oel0z!gPEaN^PfCxiqSD$J@IDjjhjlm)z15vH}Ac9AM;(I zkV62sKVGkXqapO0U=jdcfO7Z&thbPlY~(Ax8YrP^tBRntKnrzSm$V+FLc=yBoeI)n z#?FLUI}5zwr-NLWxAWncJtp}~PzZ~5Q9(GHMWfFj^RqYgJ?Rx8`-ETcb2oK+ypy+z z+tY85c&p)Kvk_Helf@?OP=-UZj@^LO%lE`7OW}Dpiel!nMjTOKwMx_hR2V2b17%C) zh1EF36-pbFxe?rn1ICf1b16955Qpz)lDiw}R^TEptUK~{$wFR$DSsXfE z9Jb;JM~p7e!7UxYE?GEcwA||O;&I<(0t^=5r>9`qMg1H~6?(ea)y*GfUieXe@estw zdpHLv5PT#u1U-P(kOEm^NBcXdd6XM*asW(Es7WB2>4Y2Mq;Zmb`ijuJp+NpKBdvsL z<`Zg!6AL#~ChjAlSxl%A-a%|U;r52H?x;*$U&8-*EG7FRd=#ttiMEM8*WOnwZTpGW zFdMXaH6|vj4|~4l)^M2!cb3M-JvV~tyI$R)m~3If3vG&h5WUyNluP6zq|5rI8atmiI;@LQaE(irFwtlbi%hL~7;onAnP7`4j!Ki+-P9_(K0G z`(^g_Tz9e3TU_lfuI?|szCXYAZKXTEvAcFqda74C+bx~#mCkod=l4s?yX*H2^yInT z)Jk`1h*JA|k50J$JnEH|2Y?DxaIdh!XkCaD!mhCK1;+V0}|@( zvnD)7?^q{!+Hw5Ya~z&?oN61+V#gsOG2CFCRwJqr(d+z79o{Gy-N74G9?j>V9utpl z6FdlBj0R7^aDz`#V_6znE1F G>FhuDP? 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": "ابھی پاور"} + } + } +}