├── .gitignore ├── .gitattributes ├── Images ├── Options.png └── Integration.png ├── hacs.json ├── custom_components └── greenely │ ├── icons.json │ ├── services.yaml │ ├── manifest.json │ ├── const.py │ ├── __init__.py │ ├── strings.json │ ├── translations │ ├── sv.json │ └── en.json │ ├── services.py │ ├── config_flow.py │ ├── api.py │ └── sensor.py ├── .github └── workflows │ ├── hassfest.yaml │ └── hacs.yaml ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.swp 3 | *.swo 4 | .vscode/ 5 | .mypy_cache/ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto -------------------------------------------------------------------------------- /Images/Options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linsvensson/sensor.greenely/HEAD/Images/Options.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Greenely Sensors", 3 | "render_readme": true, 4 | "country": ["SE"] 5 | } -------------------------------------------------------------------------------- /Images/Integration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linsvensson/sensor.greenely/HEAD/Images/Integration.png -------------------------------------------------------------------------------- /custom_components/greenely/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "services":{ 3 | "fetch_facilities":"mdi:message-flash" 4 | } 5 | } -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | validate: 10 | runs-on: "ubuntu-latest" 11 | steps: 12 | - uses: "actions/checkout@v2" 13 | - uses: home-assistant/actions/hassfest@master 14 | -------------------------------------------------------------------------------- /custom_components/greenely/services.yaml: -------------------------------------------------------------------------------- 1 | fetch_facilities: 2 | fields: 3 | email: 4 | example: "user@example.com" 5 | default: "user@example.com" 6 | required: true 7 | password: 8 | default: "password" 9 | example: "password" 10 | required: true 11 | output_json: 12 | required: false 13 | example: false 14 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v2" 15 | - name: HACS Action 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" -------------------------------------------------------------------------------- /custom_components/greenely/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "greenely", 3 | "name": "Greenely Sensors", 4 | "codeowners": ["@linsvensson"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | 8 | "documentation": "https://github.com/linsvensson/sensor.greenely", 9 | "iot_class": "cloud_polling", 10 | "issue_tracker": "https://github.com/linsvensson/sensor.greenely/issues", 11 | "requirements": [], 12 | "translations": ["translations"], 13 | "version": "2.1.2" 14 | } 15 | -------------------------------------------------------------------------------- /custom_components/greenely/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Greenely integration.""" 2 | 3 | DOMAIN = "greenely" 4 | 5 | 6 | SENSOR_DAILY_USAGE_NAME = "Greenely Daily Usage" 7 | SENSOR_HOURLY_USAGE_NAME = "Greenely Hourly Usage" 8 | SENSOR_DAILY_PRODUCED_ELECTRICITY_NAME = "Greenely Daily Produced Electricity" 9 | SENSOR_SOLD_NAME = "Greenely Sold" 10 | SENSOR_PRICES_NAME = "Greenely Prices" 11 | 12 | GREENELY_PRICES = "prices" 13 | GREENELY_DAILY_USAGE = "daily_usage" 14 | GREENELY_HOURLY_USAGE = "hourly_usage" 15 | GREENELY_DAILY_PRODUCED_ELECTRICITY = "daily_produced_electricity" 16 | GREENELY_USAGE_DAYS = "usage_days" 17 | GREENELY_PRODUCED_ELECTRICITY_DAYS = "produced_electricity_days" 18 | GREENELY_DATE_FORMAT = "date_format" 19 | GREENELY_TIME_FORMAT = "time_format" 20 | GREENELY_HOURLY_OFFSET_DAYS = "hourly_offset_days" 21 | GREENELY_FACILITY_ID = "facility_id" 22 | GREENELY_HOMEKIT_COMPATIBLE = "homekit_compatible" 23 | 24 | 25 | GREENELY_SOLD = "sold" 26 | GREENELY_SOLD_MEASURE = "sold_measure" 27 | GREENELY_SOLD_DAILY = "sold_daily" 28 | -------------------------------------------------------------------------------- /custom_components/greenely/__init__.py: -------------------------------------------------------------------------------- 1 | """The Greenely integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform 9 | from homeassistant.core import HomeAssistant 10 | 11 | from .services import async_setup_services 12 | from .api import GreenelyApi 13 | from .const import GREENELY_FACILITY_ID 14 | 15 | PLATFORMS: list[Platform] = [Platform.SENSOR] 16 | 17 | 18 | type GreenelyConfigEntry = ConfigEntry[GreenelyData] 19 | 20 | 21 | @dataclass 22 | class GreenelyData: 23 | """Runtime data definition.""" 24 | 25 | api: GreenelyApi 26 | facilitiyId: int 27 | 28 | 29 | async def async_setup_entry(hass: HomeAssistant, entry: GreenelyConfigEntry) -> bool: 30 | """Set up Greenely from a config entry.""" 31 | 32 | email = entry.data[CONF_EMAIL] 33 | password = entry.data[CONF_PASSWORD] 34 | 35 | api = GreenelyApi(email, password) 36 | 37 | entry.async_on_unload(entry.add_update_listener(async_update_options)) 38 | 39 | if api.check_auth(): 40 | facilityId = ( 41 | api.get_facility_id() 42 | if entry.data.get(GREENELY_FACILITY_ID, "") == "" 43 | else entry.data[GREENELY_FACILITY_ID] 44 | ) 45 | entry.runtime_data = GreenelyData(api, facilityId) 46 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 47 | 48 | await async_setup_services(hass) 49 | return True 50 | 51 | 52 | async def async_update_options(hass: HomeAssistant, entry: GreenelyConfigEntry): 53 | await hass.config_entries.async_reload(entry.entry_id) 54 | 55 | 56 | async def async_unload_entry(hass: HomeAssistant, entry: GreenelyConfigEntry) -> bool: 57 | """Unload a config entry.""" 58 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 59 | -------------------------------------------------------------------------------- /custom_components/greenely/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Greenely", 6 | "description": "Setup your Greenely account.", 7 | "data": { 8 | "email": "[%key:common::config_flow::data::email%]", 9 | "password": "[%key:common::config_flow::data::password%]", 10 | "facility_id": "Facility ID" 11 | } 12 | } 13 | }, 14 | "error": { 15 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 16 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 17 | "unknown": "[%key:common::config_flow::error::unknown%]" 18 | }, 19 | "abort": { 20 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 21 | } 22 | }, 23 | "options": { 24 | "step": { 25 | "init": { 26 | "title": "Manage Sensors", 27 | "data": { 28 | "prices": "Price sensor", 29 | "daily_usage": "Daily usage sensor", 30 | "hourly_usage": "Hourly usage sensor", 31 | "daily_produced_electricity": "Daily produced electricity sensor", 32 | "usage_days": "Usage days", 33 | "produced_electricity_days": "Produced electricity days", 34 | "date_format": "Date format", 35 | "time_format": "Time format", 36 | "hourly_offset_days": "Hourly offset days", 37 | "facility_id": "Facility ID", 38 | "homekit_compatible": "HomeKit compatible" 39 | } 40 | } 41 | } 42 | }, 43 | "services": { 44 | "fetch_facilities": { 45 | "name": "Fetch Facilities", 46 | "description": "Fetches the facility IDs from Greenely and outputs the formated details in a notification. Optionally, the complete output can also be sent in JSON format.", 47 | "fields": { 48 | "email": { 49 | "name": "Email", 50 | "description": "The email address associated with your Greenely account" 51 | }, 52 | "password": { 53 | "name": "Password", 54 | "description": "The password associated with your Greenely account" 55 | }, 56 | "output_json": { 57 | "name": "Output JSON", 58 | "description": "Whether to output the facilities as JSON" 59 | } 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /custom_components/greenely/translations/sv.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Enheten är redan konfigurerad" 5 | }, 6 | "error": { 7 | "cannot_connect": "Misslyckades att ansluta", 8 | "invalid_auth": "Felaktig autentisering", 9 | "unknown": "Oväntat fel" 10 | }, 11 | "step": { 12 | "user": { 13 | "data": { 14 | "email": "Email", 15 | "password": "Password", 16 | "facility_id": "Anläggnings-ID" 17 | }, 18 | "description": "Konfigurera ditt Greenely-konto.", 19 | "title": "Greenely" 20 | } 21 | } 22 | }, 23 | "options": { 24 | "step": { 25 | "init": { 26 | "data": { 27 | "daily_produced_electricity": "Daglig producerad el sensor", 28 | "daily_usage": "Daglig förbrukning sensor", 29 | "date_format": "Datumformat", 30 | "facility_id": "Anläggnings-ID", 31 | "homekit_compatible": "HomeKit kompatibel", 32 | "hourly_offset_days": "Timvis förskjutning dagar", 33 | "hourly_usage": "Timvis förbrukning sensor", 34 | "prices": "Prissensor", 35 | "produced_electricity_days": "Producerad el dagar", 36 | "time_format": "Tidsformat", 37 | "usage_days": "Förbrukningsdagar" 38 | }, 39 | "title": "Hantera sensorer" 40 | } 41 | } 42 | }, 43 | "services": { 44 | "fetch_facilities": { 45 | "name": "Hämta anläggningar", 46 | "description": "Hämta anläggnings-ID från Greenely och skicka ut formaterade detaljer i en notis. Alternativt kan hela förfrågan skrivas i JSON-format.", 47 | "fields": { 48 | "email": { 49 | "description": "Emailadressen som är kopplad till ditt Greenely-konto." 50 | }, 51 | "password": { 52 | "description": "Lösenordet som är kopplat till ditt Greenely-konto." 53 | }, 54 | "output_json": { 55 | "description": "Skriv ut anläggningarna i json." 56 | } 57 | } 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /custom_components/greenely/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured" 5 | }, 6 | "error": { 7 | "cannot_connect": "Failed to connect", 8 | "invalid_auth": "Invalid authentication", 9 | "unknown": "Unexpected error" 10 | }, 11 | "step": { 12 | "user": { 13 | "data": { 14 | "email": "Email", 15 | "password": "Password", 16 | "facility_id": "Facility ID" 17 | }, 18 | "description": "Setup your Greenely account.", 19 | "title": "Greenely" 20 | } 21 | } 22 | }, 23 | "options": { 24 | "step": { 25 | "init": { 26 | "data": { 27 | "daily_produced_electricity": "Daily produced electricity sensor", 28 | "daily_usage": "Daily usage sensor", 29 | "date_format": "Date format", 30 | "facility_id": "Facility ID", 31 | "homekit_compatible": "HomeKit compatible", 32 | "hourly_offset_days": "Hourly offset days", 33 | "hourly_usage": "Hourly usage sensor", 34 | "prices": "Price sensor", 35 | "produced_electricity_days": "Produced electricity days", 36 | "time_format": "Time format", 37 | "usage_days": "Usage days" 38 | }, 39 | "title": "Manage Sensors" 40 | } 41 | } 42 | }, 43 | "services": { 44 | "fetch_facilities": { 45 | "name": "Fetch Facilities", 46 | "description": "Fetches the facility IDs from Greenely and outputs the formated details in a notification. Optionally, the complete output can also be sent in JSON format.", 47 | "fields": { 48 | "email": { 49 | "name": "Email", 50 | "description": "The email address associated with your Greenely account" 51 | }, 52 | "password": { 53 | "name": "Password", 54 | "description": "The password associated with your Greenely account" 55 | }, 56 | "output_json": { 57 | "name": "Output JSON", 58 | "description": "Whether to output the facilities as JSON" 59 | } 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /custom_components/greenely/services.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import voluptuous as vol 3 | import json 4 | import homeassistant.helpers.config_validation as cv 5 | from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN 6 | from homeassistant.const import CONF_PASSWORD, CONF_EMAIL 7 | from homeassistant.core import HomeAssistant, ServiceCall 8 | from .api import GreenelyApi 9 | from .const import DOMAIN 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | SERVICE_FETCH_FACILITIES = "fetch_facilities" 14 | 15 | SERVICE_FETCH_FACILITIES_SCHEMA = vol.Schema( 16 | { 17 | vol.Required(CONF_EMAIL): cv.string, 18 | vol.Required(CONF_PASSWORD): cv.string, 19 | vol.Optional("output_json", default=False): cv.boolean, 20 | } 21 | ) 22 | 23 | 24 | async def async_setup_services(hass: HomeAssistant) -> None: 25 | """Set up services for the Greenely integration.""" 26 | 27 | async def async_fetch_facilities(call: ServiceCall): 28 | """Service to fetch facility id.""" 29 | email = call.data[CONF_EMAIL] 30 | password = call.data[CONF_PASSWORD] 31 | 32 | api = GreenelyApi(email, password) 33 | if not api.check_auth(): 34 | await hass.services.async_call( 35 | NOTIFY_DOMAIN, 36 | "persistent_notification", 37 | {"message": "Invalid credentials", "title": "Greenely facility ids"}, 38 | blocking=True, 39 | ) 40 | 41 | else: 42 | facilityIds = api.get_facility_ids() 43 | _LOGGER.info("Facilities fetched successfully") 44 | 45 | facilityIdsOutput = [] 46 | 47 | for entity in facilityIds: 48 | facilityInfo = f"ID: {entity['id']}, Street: {entity['street']}, Zip Code: {entity['zip_code']}, City: {entity['city']}, Is Primary: {entity['is_primary']} " 49 | facilityIdsOutput.append(facilityInfo) 50 | 51 | facilityIdsMessage = "\n".join(facilityIdsOutput) 52 | await hass.services.async_call( 53 | NOTIFY_DOMAIN, 54 | "persistent_notification", 55 | {"message": facilityIdsMessage, "title": "Greenely facility ids"}, 56 | blocking=True, 57 | ) 58 | 59 | if call.data["output_json"]: 60 | message = json.dumps(facilityIds) 61 | await hass.services.async_call( 62 | NOTIFY_DOMAIN, 63 | "persistent_notification", 64 | {"message": message, "title": "Greenely facility ids json"}, 65 | blocking=True, 66 | ) 67 | 68 | hass.services.async_register( 69 | DOMAIN, 70 | SERVICE_FETCH_FACILITIES, 71 | async_fetch_facilities, 72 | schema=SERVICE_FETCH_FACILITIES_SCHEMA, 73 | ) 74 | -------------------------------------------------------------------------------- /custom_components/greenely/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Greenely integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant.config_entries import ( 11 | ConfigEntry, 12 | ConfigFlow, 13 | ConfigFlowResult, 14 | OptionsFlow, 15 | ) 16 | from homeassistant.const import CONF_EMAIL, CONF_PASSWORD 17 | from homeassistant.core import HomeAssistant, callback 18 | from homeassistant.exceptions import HomeAssistantError 19 | 20 | from .api import GreenelyApi 21 | 22 | from .const import ( 23 | DOMAIN, 24 | GREENELY_DAILY_PRODUCED_ELECTRICITY, 25 | GREENELY_DAILY_USAGE, 26 | GREENELY_DATE_FORMAT, 27 | GREENELY_FACILITY_ID, 28 | GREENELY_HOMEKIT_COMPATIBLE, 29 | GREENELY_HOURLY_OFFSET_DAYS, 30 | GREENELY_HOURLY_USAGE, 31 | GREENELY_PRICES, 32 | GREENELY_PRODUCED_ELECTRICITY_DAYS, 33 | GREENELY_TIME_FORMAT, 34 | GREENELY_USAGE_DAYS, 35 | ) 36 | 37 | _LOGGER = logging.getLogger(__name__) 38 | 39 | STEP_USER_DATA_SCHEMA = vol.Schema( 40 | { 41 | vol.Required(CONF_EMAIL): str, 42 | vol.Required(CONF_PASSWORD): str, 43 | vol.Optional(GREENELY_FACILITY_ID): int, 44 | } 45 | ) 46 | 47 | 48 | class Greenelyhub: 49 | """Class to authenticate with the host.""" 50 | 51 | def __init__(self, email: str, password: str): 52 | self.email = email 53 | self.password = password 54 | self.api = GreenelyApi(self.email, self.password) 55 | 56 | async def authenticate(self) -> bool: 57 | """Test if we can authenticate with the host.""" 58 | return self.api.check_auth() 59 | 60 | async def get_facility_id(self) -> int: 61 | return int(self.api.get_facility_id()) 62 | 63 | 64 | async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: 65 | """Validate the user input allows us to connect. 66 | 67 | Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. 68 | """ 69 | 70 | hub = Greenelyhub(data[CONF_EMAIL], data[CONF_PASSWORD]) 71 | 72 | if not await hub.authenticate(): 73 | raise InvalidAuth 74 | 75 | facilityId = data.get(GREENELY_FACILITY_ID, await hub.get_facility_id()) 76 | 77 | # Return info that you want to store in the config entry. 78 | return { 79 | "title": f"Greenely Facility {facilityId}", 80 | "facility_id": facilityId, 81 | } 82 | 83 | 84 | class ConfigFlow(ConfigFlow, domain=DOMAIN): 85 | """Handle a config flow for Greenely.""" 86 | 87 | VERSION = 1 88 | 89 | @staticmethod 90 | @callback 91 | def async_get_options_flow( 92 | config_entry: ConfigFlow, 93 | ) -> GreenelyOptionsFlow: 94 | """Create the options flow.""" 95 | return GreenelyOptionsFlow(config_entry) 96 | 97 | async def async_step_user( 98 | self, user_input: dict[str, Any] | None = None 99 | ) -> ConfigFlowResult: 100 | """Handle the initial step.""" 101 | errors: dict[str, str] = {} 102 | if user_input is not None: 103 | try: 104 | info = await validate_input(self.hass, user_input) 105 | except InvalidAuth: 106 | errors["base"] = "invalid_auth" 107 | except Exception: 108 | _LOGGER.exception("Unexpected exception") 109 | errors["base"] = "unknown" 110 | else: 111 | options = { 112 | GREENELY_DAILY_USAGE: True, 113 | GREENELY_FACILITY_ID: info["facility_id"], 114 | } 115 | return self.async_create_entry( 116 | title=info["title"], 117 | data=user_input, 118 | options=options, 119 | ) 120 | 121 | return self.async_show_form( 122 | step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors 123 | ) 124 | 125 | 126 | class GreenelyOptionsFlow(OptionsFlow): 127 | """Handle a option flow for Greenely.""" 128 | 129 | def __init__(self, config_entry: ConfigEntry) -> None: 130 | """Initialize Greenely options flow.""" 131 | self.config_entry = config_entry 132 | 133 | async def async_step_init( 134 | self, user_input: dict[str, Any] | None = None 135 | ) -> ConfigFlowResult: 136 | """Manage the Greenely options.""" 137 | if user_input is not None: 138 | return self.async_create_entry(title="Manage Sensors", data=user_input) 139 | 140 | return self.async_show_form( 141 | step_id="init", data_schema=self._get_options_schema() 142 | ) 143 | 144 | def _get_options_schema(self): 145 | return vol.Schema( 146 | { 147 | vol.Optional( 148 | GREENELY_PRICES, 149 | default=self.config_entry.options.get(GREENELY_PRICES, True), 150 | ): bool, 151 | vol.Optional( 152 | GREENELY_DAILY_USAGE, 153 | default=self.config_entry.options.get(GREENELY_DAILY_USAGE, True), 154 | ): bool, 155 | vol.Optional( 156 | GREENELY_HOURLY_USAGE, 157 | default=self.config_entry.options.get(GREENELY_HOURLY_USAGE, False), 158 | ): bool, 159 | vol.Optional( 160 | GREENELY_DAILY_PRODUCED_ELECTRICITY, 161 | default=self.config_entry.options.get( 162 | GREENELY_DAILY_PRODUCED_ELECTRICITY, False 163 | ), 164 | ): bool, 165 | vol.Optional( 166 | GREENELY_USAGE_DAYS, 167 | default=self.config_entry.options.get(GREENELY_USAGE_DAYS, 10), 168 | ): int, 169 | vol.Optional( 170 | GREENELY_PRODUCED_ELECTRICITY_DAYS, 171 | default=self.config_entry.options.get( 172 | GREENELY_PRODUCED_ELECTRICITY_DAYS, 10 173 | ), 174 | ): int, 175 | vol.Optional( 176 | GREENELY_DATE_FORMAT, 177 | default=self.config_entry.options.get( 178 | GREENELY_DATE_FORMAT, "%b %d %Y" 179 | ), 180 | ): str, 181 | vol.Optional( 182 | GREENELY_TIME_FORMAT, 183 | default=self.config_entry.options.get( 184 | GREENELY_TIME_FORMAT, "%H:%M" 185 | ), 186 | ): str, 187 | vol.Optional( 188 | GREENELY_HOURLY_OFFSET_DAYS, 189 | default=self.config_entry.options.get( 190 | GREENELY_HOURLY_OFFSET_DAYS, 1 191 | ), 192 | ): int, 193 | vol.Optional( 194 | GREENELY_FACILITY_ID, 195 | default=self.config_entry.options.get(GREENELY_FACILITY_ID), 196 | ): int, 197 | vol.Optional( 198 | GREENELY_HOMEKIT_COMPATIBLE, 199 | default=self.config_entry.options.get( 200 | GREENELY_HOMEKIT_COMPATIBLE, False 201 | ), 202 | ): bool, 203 | } 204 | ) 205 | 206 | class InvalidAuth(HomeAssistantError): 207 | """Error to indicate there is invalid auth.""" 208 | -------------------------------------------------------------------------------- /custom_components/greenely/api.py: -------------------------------------------------------------------------------- 1 | """Greenely API""" 2 | 3 | from datetime import datetime, timedelta 4 | import json 5 | import logging 6 | 7 | import httpx 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | class GreenelyApi: 13 | def __init__(self, email, password): 14 | self._jwt = "" 15 | self._url_check_auth = "https://api2.greenely.com/v1/checkauth" 16 | self._url_login = "https://api2.greenely.com/v1/login" 17 | self._url_data = "https://api2.greenely.com/v3/data/" 18 | self._url_facilities_base = "https://api2.greenely.com/v1/facilities/" 19 | self._headers = { 20 | "Accept-Language": "sv-SE", 21 | "User-Agent": "Android 2 111", 22 | "Content-Type": "application/json; charset=utf-8", 23 | "Authorization": self._jwt, 24 | } 25 | self._email = email 26 | self._password = password 27 | self._facility_id = "primary" 28 | 29 | def set_facility_id(self, facility_id) -> None: 30 | _LOGGER.debug("Setting facility id to %s", facility_id) 31 | self._facility_id = str(facility_id) 32 | 33 | def get_price_data(self): 34 | today = datetime.today() 35 | nextMonth = (today.replace(day=1) + timedelta(days=32)).replace(day=1) 36 | start = "?from=" + str(today.year) + "-" + today.strftime("%m") + "-01" 37 | end = "&to=" + str(nextMonth.year) + "-" + nextMonth.strftime("%m") + "-01" 38 | url = ( 39 | self._url_facilities_base 40 | + self._facility_id 41 | + "/consumption" 42 | + start 43 | + end 44 | + "&resolution=daily&unit=currency&operation=sum" 45 | ) 46 | response = httpx.get(url, headers=self._headers) 47 | data = {} 48 | if response.status_code == httpx.codes.ok: 49 | data = response.json() 50 | return data["data"] 51 | else: 52 | _LOGGER.error("Failed to get price data, %s", response.text) 53 | return data 54 | 55 | def get_spot_price(self): 56 | today = datetime.today() 57 | yesterday = today - timedelta(days=1) 58 | tomorrow = today + timedelta(days=2) 59 | start = ( 60 | "?from=" 61 | + str(yesterday.year) 62 | + "-" 63 | + yesterday.strftime("%m") 64 | + "-" 65 | + yesterday.strftime("%d") 66 | ) 67 | end = ( 68 | "&to=" 69 | + str(tomorrow.year) 70 | + "-" 71 | + tomorrow.strftime("%m") 72 | + "-" 73 | + tomorrow.strftime("%d") 74 | ) 75 | url = ( 76 | self._url_facilities_base 77 | + self._facility_id 78 | + "/spot-price" 79 | + start 80 | + end 81 | + "&resolution=hourly" 82 | ) 83 | response = httpx.get(url, headers=self._headers) 84 | data = {} 85 | if response.status_code == httpx.codes.ok: 86 | data = response.json() 87 | return data 88 | else: 89 | _LOGGER.error("Failed to get spot price data, %s", response.text) 90 | return data 91 | 92 | def get_usage(self, startDate, endDate, showHourly): 93 | start = ( 94 | "?from=" 95 | + str(startDate.year) 96 | + "-" 97 | + startDate.strftime("%m") 98 | + "-" 99 | + str(startDate.day) 100 | ) 101 | end = ( 102 | "&to=" 103 | + str(endDate.year) 104 | + "-" 105 | + endDate.strftime("%m") 106 | + "-" 107 | + str(endDate.day) 108 | ) 109 | resolution = "hourly" if showHourly else "daily" 110 | url = ( 111 | self._url_facilities_base 112 | + self._facility_id 113 | + "/consumption" 114 | + start 115 | + end 116 | + "&resolution=" 117 | + resolution 118 | ) 119 | response = httpx.get(url, headers=self._headers) 120 | data = {} 121 | if response.status_code == httpx.codes.ok: 122 | data = response.json() 123 | return data["data"] 124 | else: 125 | _LOGGER.error("Failed to fetch usage data, %s", response.text) 126 | return data 127 | 128 | def get_facility_id(self): 129 | result = httpx.get(self._url_facilities_base, headers=self._headers) 130 | if result.status_code == httpx.codes.ok: 131 | data = result.json()["data"] 132 | facility = next((f for f in data if f["is_primary"] == True), None) 133 | if facility == None: 134 | _LOGGER.debug( 135 | "Found no primary facility, using the first one in the list!" 136 | ) 137 | facility = data[0] 138 | self._facility_id = str(data[0]["id"]) 139 | _LOGGER.debug("Fetched facility id %s", self._facility_id) 140 | return self._facility_id 141 | else: 142 | _LOGGER.error("Failed to fetch facility id %s", result.reason) 143 | 144 | def get_facility_ids(self): 145 | result = httpx.get(self._url_facilities_base, headers=self._headers) 146 | if result.status_code == httpx.codes.ok: 147 | data = result.json()["data"] 148 | return data 149 | else: 150 | _LOGGER.error("Failed to fetch facility ids %s", result) 151 | 152 | def get_produced_electricity(self, startDate, endDate, showHourly): 153 | start = ( 154 | "?from=" 155 | + str(startDate.year) 156 | + "-" 157 | + startDate.strftime("%m") 158 | + "-" 159 | + startDate.strftime("%d") 160 | ) 161 | end = ( 162 | "&to=" 163 | + str(endDate.year) 164 | + "-" 165 | + endDate.strftime("%m") 166 | + "-" 167 | + endDate.strftime("%d") 168 | ) 169 | resolution = "hourly" if showHourly else "daily" 170 | url = ( 171 | self._url_facilities_base 172 | + self._facility_id 173 | + "/produced-electricity" 174 | + start 175 | + end 176 | + "&resolution=" 177 | + resolution 178 | ) 179 | _LOGGER.debug("Fetching produced electicity from url, %s", url) 180 | response = httpx.get(url, headers=self._headers) 181 | data = {} 182 | if response.status_code == httpx.codes.ok: 183 | data = response.json() 184 | _LOGGER.debug( 185 | "Fetched data for produced electricity endpoint, %s", data["data"] 186 | ) 187 | return data["data"] 188 | else: 189 | _LOGGER.error( 190 | "Failed to fetch produced electricity data, %s", response.text 191 | ) 192 | return data 193 | 194 | def check_auth(self): 195 | """Check to see if our jwt is valid.""" 196 | result = httpx.get(self._url_check_auth, headers=self._headers) 197 | if result.status_code == httpx.codes.ok: 198 | _LOGGER.debug("jwt is valid!") 199 | return True 200 | elif self.login() == False: 201 | _LOGGER.debug(result.text) 202 | return False 203 | return True 204 | 205 | def login(self): 206 | """Login to the Greenely API.""" 207 | result = False 208 | loginInfo = {"email": self._email, "password": self._password} 209 | loginResult = httpx.post( 210 | self._url_login, headers=self._headers, data=json.dumps(loginInfo) 211 | ) 212 | if loginResult.status_code == httpx.codes.ok: 213 | jsonResult = loginResult.json() 214 | self._jwt = "JWT " + jsonResult["jwt"] 215 | self._headers["Authorization"] = self._jwt 216 | _LOGGER.debug("Successfully logged in and updated jwt") 217 | if self._facility_id == "primary": 218 | self.get_facility_id() 219 | else: 220 | _LOGGER.debug("Facility id is %s", self._facility_id) 221 | result = True 222 | else: 223 | _LOGGER.error(loginResult.text) 224 | return result 225 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sensor.greenely 2 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/linsvensson/sensor.greenely?color=pink&style=for-the-badge) 3 | ![GitHub last commit](https://img.shields.io/github/last-commit/linsvensson/sensor.greenely?color=pink&style=for-the-badge) 4 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg?color=pink&style=for-the-badge)](https://github.com/hacs/integration) 5 | 6 | _Custom component to get usage data and prices from [Greenely](https://www.greenely.se/) for [Home Assistant](https://www.home-assistant.io/)._ 7 | 8 | Because Greenely doesn't have an open api yet, we are using the Android user-agent to access data. 9 | Data is fetched every hour. 10 | 11 | ## Installation 12 | ### HACS (recommended) 13 | - Have [HACS](https://hacs.xyz/docs/setup/download) installed, this will allow you to easily manage and track updates. 14 | - Search for 'Greenely'. 15 | - Click Install below the found integration. 16 | - Configure using the configuration instructions below. 17 | - Restart Home-Assistant. 18 | 19 | ### Manual 20 | - Copy directory `custom_components/greenely` to your `/custom_components` directory. 21 | - Configure with config below. 22 | - Restart Home-Assistant. 23 | 24 | ## Configuration 25 | Is done using the UI, click Add integration and search for Greenely. 26 | The initial setup needs your Email and Password for the Greenely account, optionally you could specify the Facility ID. 27 | 28 | For additional options go to integrations and select Greenely. 29 | Press configure 30 | ![alt text](Images/Integration.png) 31 | Options for additional sensors and settings are now displayed. 32 | ![alt text](Images/Options.png) 33 | 34 | ### Configuration variables 35 | key | type | description 36 | :--- | :--- | :--- 37 | **Email (Required)** | string | Your Greenely username. 38 | **Password (Required)** | string | Your Greenely password. 39 | **Facility ID (Optional)** | string | If you have more than one facility and know the facility ID you want data from, put it here. Note: The facility ids can be fetch using the service call greenely.fetch_factilites, this will output a notification displaying the facilities for your account. 40 | 41 | ### Options variables 42 | key | type | description 43 | :--- | :--- | :--- 44 | **Prices (Optional)** | boolean | Creates a sensor showing price data in kr/kWh. Default `true`. 45 | **Daily usage sensor (Optional)** | boolean | Creates a sensor showing daily usage data. The state of this sensor is yesterday's total usage. Default `true`. 46 | **Hourly usage sensor (Optional)** | boolean | Creates a sensor showing yesterday's hourly usage data. Default `false`. 47 | **Daily produced electricity sensor (Optional)** | boolean | Creates a sensor showing daily produced electricity data. The state of this sensor is the total value. Default `false`. 48 | **Usage days (Optional)** | number | How many days of usage data you want. Default `10`. 49 | **Produced electricity days (Optional)** | number | How many days of produced electricity data you want. Default `10`. 50 | **Date format (Optional)** | string | Default `%b %d %Y`, shows up as `Jan 18 2020`. [References](https://strftime.org/) 51 | **Time format (Optional)** | string | Default `%H:%M`, shows up as `10:00`. [References](https://strftime.org/) 52 | **Hourly offset days (Optional)** | number | How many days ago you want the hourly data from. Default `1` (yesterday's data). 53 | **Homekit compatible (Optional)** | boolean | If you're using Homekit and need the current price data in the format `x.x °C`, enable this. Default `false`. 54 | **Facility ID (Optional)** | string | If you have more than one facility and know the facility ID you want data from, put it here. Note: The facility ids can be fetch using the service call greenely.fetch_factilites, this will output a notification displaying the facilities for your account. 55 | 56 | ## Services 57 | **Fetch factilites** 58 | This service will fetch the facilites data and output it into a formated notification displaying the following. ID, Street, Zip code, City and Primary attributes for each of your facilites. 59 | 60 | Field | Type | Description 61 | :--- | :--- | :--- 62 | **Email (Required)** | string | Your Greenely username. 63 | **Password (Required)** | string | Your Greenely password. 64 | **Output json (Optional)** | boolean | Will output the complete payload from Greenely in json format into an additional notification. Default `false`. 65 | 66 | Example without json output: 67 | ```yaml 68 | service: greenely.fetch_facilities 69 | data: 70 | email: user@example.com 71 | password: password 72 | ``` 73 | 74 | Example with json output: 75 | ```yaml 76 | service: greenely.fetch_facilities 77 | data: 78 | email: user@example.com 79 | password: password 80 | output_json: true 81 | ``` 82 | 83 | ## Lovelace 84 | **Example chart with [ApexCharts Card](https://github.com/RomRider/apexcharts-card):** 85 | Use these configurations for the sensor 86 | ```yaml 87 | hourly_usage: true 88 | date_format: '%Y-%m-%d' 89 | ``` 90 | ```yaml 91 | - type: custom:apexcharts-card 92 | header: 93 | title: Förbrukning/timme & elpris 94 | show: true 95 | graph_span: 24h 96 | span: 97 | start: day 98 | offset: '-1d' 99 | yaxis: 100 | - id: first 101 | apex_config: 102 | tickAmount: 10 103 | min: 0 104 | max: 2 105 | - id: second 106 | opposite: true 107 | apex_config: 108 | tickAmount: 5 109 | min: 0 110 | decimals: 0 111 | apex_config: 112 | dataLabels: 113 | enabled: false 114 | stroke: 115 | width: 4 116 | series: 117 | - entity: sensor.greenely_hourly_usage 118 | name: Förbrukning 119 | yaxis_id: first 120 | type: column 121 | color: red 122 | show: 123 | legend_value: false 124 | data_generator: | 125 | return entity.attributes.data.map((entry) => { 126 | return [new Date(entry.localtime), entry.usage]; 127 | }); 128 | - entity: sensor.greenely_prices 129 | data_generator: | 130 | return entity.attributes.previous_day.map((entry) => { 131 | return [new Date(entry.date + 'T' + entry.time), entry.price]; 132 | }); 133 | yaxis_id: second 134 | type: line 135 | color: blue 136 | name: Elpris 137 | show: 138 | legend_value: false 139 | ``` 140 | 141 | ![image](https://user-images.githubusercontent.com/5594088/176283193-648e09cc-cd79-4807-a07b-0141071b9b64.PNG) 142 | 143 | **Example usage with [flex-table-card](https://github.com/custom-cards/flex-table-card):** 144 | ```yaml 145 | - type: 'custom:flex-table-card' 146 | title: Greenely Daily Usage 147 | sort_by: date 148 | entities: 149 | include: sensor.greenely_daily_usage 150 | columns: 151 | - name: date 152 | attr_as_list: data 153 | modify: x.localtime 154 | icon: mdi:calendar 155 | - name: kWh 156 | attr_as_list: data 157 | modify: x.usage 158 | icon: mdi:flash 159 | ``` 160 | 161 | ![image](https://user-images.githubusercontent.com/5594088/176284102-302906ca-5a23-4bcc-8700-415db6b30dd9.PNG) 162 | 163 | **Example prices with [flex-table-card](https://github.com/custom-cards/flex-table-card):** 164 | ```yaml 165 | - type: custom:vertical-stack-in-card 166 | cards: 167 | - type: horizontal-stack 168 | cards: 169 | - type: 'custom:flex-table-card' 170 | title: Today 171 | sort_by: date 172 | entities: 173 | include: sensor.greenely_prices 174 | columns: 175 | - name: time 176 | attr_as_list: current_day 177 | modify: x.time 178 | icon: mdi:clock 179 | - name: price(öre/kWh) 180 | attr_as_list: current_day 181 | modify: Math.round(x.price * 100) 182 | icon: mdi:cash 183 | - type: 'custom:flex-table-card' 184 | title: Tomorrow 185 | sort_by: date 186 | entities: 187 | include: sensor.greenely_prices 188 | columns: 189 | - name: time 190 | attr_as_list: next_day 191 | modify: x.time 192 | icon: mdi:clock 193 | - name: price(öre/kWh) 194 | attr_as_list: next_day 195 | modify: Math.round(x.price * 100) 196 | icon: mdi:cash 197 | ``` 198 | 199 | ![image](https://user-images.githubusercontent.com/5594088/176283756-3361f5ed-6003-40aa-b2f1-2891d286bb3b.PNG) 200 | 201 | **Example usage with [Node-Red Companion](https://github.com/zachowj/hass-node-red) and tts** 202 | ![image](https://user-images.githubusercontent.com/5594088/116883602-140c0980-ac26-11eb-843c-409604e4b93e.png) 203 |
204 | Clipboard 205 | 206 | ```[{"id":"3d82b277.52bb3e","type":"subflow","name":"volume adjustment 70%","info":"","category":"","in":[{"x":40,"y":200,"wires":[{"id":"ace99ba9.14ecf8"}]}],"out":[],"env":[]},{"id":"a6b88b50.4af618","type":"api-call-service","z":"3d82b277.52bb3e","name":"Set volume to 70%","server":"78ca140a.63476c","version":1,"debugenabled":false,"service_domain":"media_player","service":"volume_set","entityId":"media_player.google","data":"{\"volume_level\":0.7}","dataType":"json","mergecontext":"","output_location":"payload","output_location_type":"msg","mustacheAltTags":false,"x":530,"y":140,"wires":[[]]},{"id":"bd929e33.12a5a","type":"switch","z":"3d82b277.52bb3e","name":"70% ?","property":"data.attributes.volume_level","propertyType":"msg","rules":[{"t":"neq","v":"0.7","vt":"num"}],"checkall":"true","repair":false,"outputs":1,"x":330,"y":200,"wires":[["a6b88b50.4af618","c810b1a6.ad421"]]},{"id":"c810b1a6.ad421","type":"function","z":"3d82b277.52bb3e","name":"previous volume","func":"msg.payload =\n{\n \"data\":{\n \"entity_id\":\"media_player.google\",\n \"volume_level\": 0.4\n }\n}\nreturn msg;","outputs":1,"noerr":0,"x":500,"y":200,"wires":[["8876effc.617e4"]]},{"id":"8876effc.617e4","type":"delay","z":"3d82b277.52bb3e","name":"","pauseType":"delay","timeout":"8","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"x":700,"y":200,"wires":[["f0ffa361.42bab"]]},{"id":"f0ffa361.42bab","type":"api-call-service","z":"3d82b277.52bb3e","name":"set volume to previous","server":"78ca140a.63476c","version":1,"debugenabled":false,"service_domain":"media_player","service":"volume_set","entityId":"media_player.google","data":"{\"volume_level\":\"{{msg.payload.data.volume_level}}\"}","dataType":"json","mergecontext":"","output_location":"payload","output_location_type":"msg","mustacheAltTags":false,"x":920,"y":200,"wires":[[]]},{"id":"ace99ba9.14ecf8","type":"api-current-state","z":"3d82b277.52bb3e","name":"speaker","server":"78ca140a.63476c","version":1,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","override_topic":true,"entity_id":"media_player.google","state_type":"str","state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","blockInputOverrides":false,"x":180,"y":200,"wires":[["bd929e33.12a5a"]]},{"id":"bf9a6d11.435ea","type":"api-call-service","z":"816aff66.f144b","name":"tts","server":"78ca140a.63476c","version":1,"debugenabled":false,"service_domain":"tts","service":"google_say","entityId":"media_player.google","data":"","dataType":"jsonata","mergecontext":"","output_location":"payload","output_location_type":"msg","mustacheAltTags":false,"x":872,"y":2829,"wires":[["c692e642.c2ba58"]]},{"id":"584c5e33.b7901","type":"function","z":"816aff66.f144b","name":"get current price","func":"var price = msg.data.attributes.current_price;\nif (price !== undefined) {\n msg = {payload: {data: {message: \"The current price is \" + Math.round(price * 100) }}};\n} else {\n msg = {payload: {data: {message: \"Sorry, I was unable to fetch the current price\" }}};\n}\nreturn msg;","outputs":1,"noerr":0,"x":701,"y":2829,"wires":[["bf9a6d11.435ea"]]},{"id":"c692e642.c2ba58","type":"subflow:3d82b277.52bb3e","z":"816aff66.f144b","name":"","x":1170,"y":2840,"wires":[]},{"id":"fb18952.6043c68","type":"api-current-state","z":"816aff66.f144b","name":"","server":"78ca140a.63476c","version":1,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","override_topic":false,"entity_id":"sensor.greenely_prices","state_type":"str","state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","blockInputOverrides":false,"x":430,"y":2800,"wires":[["584c5e33.b7901"]]},{"id":"1d10e908.28fea7","type":"ha-entity","z":"816aff66.f144b","name":"","server":"78ca140a.63476c","version":1,"debugenabled":false,"outputs":2,"entityType":"switch","config":[{"property":"name","value":"scene_electricity_price"},{"property":"device_class","value":""},{"property":"icon","value":""},{"property":"unit_of_measurement","value":""}],"state":"payload","stateType":"msg","attributes":[],"resend":true,"outputLocation":"","outputLocationType":"none","inputOverride":"allow","outputOnStateChange":false,"outputPayload":"$entity().state ? \"on\": \"off\"","outputPayloadType":"jsonata","x":150,"y":2840,"wires":[["fb18952.6043c68"],[]]},{"id":"78ca140a.63476c","type":"server","z":"","name":"Home Assistant","legacy":false,"addon":false,"rejectUnauthorizedCerts":false,"ha_boolean":"y|yes|true|on|home|open","connectionDelay":true,"cacheJson":true}]\``` 207 | 208 |
209 | 210 | You can then for example make a script to trigger it through voice commands to Google Assistant! 211 | 212 | ## Debug logging 213 | Add this to your configuration.yaml to debug the component 214 | ```logger: 215 | default: warning 216 | logs: 217 | custom_components.greenely: debug 218 | ``` 219 | 220 | ## Data object structures 221 | **previous_day, current_day, next_day & current_month** 222 | ```json 223 | [{ "date": "Jan 18 2020", "time": "13:00", "price": "24.75" }] 224 | ``` 225 | **days** 226 | ```json 227 | [{ "localtime": "Jan 12 2020", "usage": "11.0" }] 228 | ``` 229 | **hourly** 230 | ```json 231 | [{ "localtime": "Jan 12 2020 10:00", "usage": "1.0" }] 232 | ``` 233 | **sold_data** 234 | ```json 235 | [{ "date": "Jan 12 2020", "usage": "11.0", "is_complete": true }] 236 | ``` 237 | -------------------------------------------------------------------------------- /custom_components/greenely/sensor.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | import logging 3 | 4 | from homeassistant.components.sensor import SensorDeviceClass 5 | from homeassistant.const import UnitOfEnergy 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType 8 | from homeassistant.helpers.entity import Entity 9 | 10 | from . import GreenelyData 11 | from .const import ( 12 | DOMAIN, 13 | GREENELY_DAILY_PRODUCED_ELECTRICITY, 14 | GREENELY_DAILY_USAGE, 15 | GREENELY_DATE_FORMAT, 16 | GREENELY_HOMEKIT_COMPATIBLE, 17 | GREENELY_HOURLY_OFFSET_DAYS, 18 | GREENELY_HOURLY_USAGE, 19 | GREENELY_PRICES, 20 | GREENELY_PRODUCED_ELECTRICITY_DAYS, 21 | GREENELY_TIME_FORMAT, 22 | GREENELY_USAGE_DAYS, 23 | GREENELY_FACILITY_ID, 24 | SENSOR_DAILY_PRODUCED_ELECTRICITY_NAME, 25 | SENSOR_DAILY_USAGE_NAME, 26 | SENSOR_HOURLY_USAGE_NAME, 27 | SENSOR_PRICES_NAME, 28 | ) 29 | 30 | SCAN_INTERVAL = timedelta(minutes=10) 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | 35 | async def async_setup_entry( 36 | hass: HomeAssistant, 37 | config_entry: GreenelyData, 38 | async_add_entities, 39 | ): 40 | """Setup sensors from a config entry created in the integrations UI.""" 41 | api = config_entry.runtime_data.api 42 | facility_id = str(config_entry.options.get(GREENELY_FACILITY_ID)) 43 | usage_days = config_entry.options.get(GREENELY_USAGE_DAYS, 10) 44 | production_days = config_entry.options.get(GREENELY_PRODUCED_ELECTRICITY_DAYS, 10) 45 | hourly_offset_days = config_entry.options.get(GREENELY_HOURLY_OFFSET_DAYS, 1) 46 | date_format = config_entry.options.get(GREENELY_DATE_FORMAT, "%b %d %Y") 47 | time_format = config_entry.options.get(GREENELY_TIME_FORMAT, "%H:%M") 48 | homekit_compatible = config_entry.options.get(GREENELY_HOMEKIT_COMPATIBLE, False) 49 | 50 | sensors = [] 51 | 52 | api.set_facility_id(facility_id) 53 | 54 | if config_entry.data.get(GREENELY_DAILY_USAGE, True): 55 | sensors.append( 56 | GreenelyDailyUsageSensor( 57 | SENSOR_DAILY_USAGE_NAME, 58 | api, 59 | facility_id, 60 | usage_days, 61 | date_format, 62 | time_format, 63 | ) 64 | ) 65 | if config_entry.data.get(GREENELY_PRICES, True): 66 | sensors.append( 67 | GreenelyPricesSensor( 68 | SENSOR_PRICES_NAME, 69 | api, 70 | facility_id, 71 | date_format, 72 | time_format, 73 | homekit_compatible, 74 | ) 75 | ) 76 | 77 | if config_entry.options.get(GREENELY_HOURLY_USAGE, False): 78 | sensors.append( 79 | GreenelyHourlyUsageSensor( 80 | SENSOR_HOURLY_USAGE_NAME, 81 | api, 82 | facility_id, 83 | hourly_offset_days, 84 | date_format, 85 | time_format, 86 | ) 87 | ) 88 | 89 | if config_entry.options.get(GREENELY_DAILY_PRODUCED_ELECTRICITY, False): 90 | sensors.append( 91 | GreenelyDailyProducedElecticitySensor( 92 | SENSOR_DAILY_PRODUCED_ELECTRICITY_NAME, 93 | api, 94 | facility_id, 95 | production_days, 96 | date_format, 97 | time_format, 98 | ) 99 | ) 100 | 101 | async_add_entities(sensors, True) 102 | 103 | 104 | class GreenelyDailyUsageSensor(Entity): 105 | def __init__(self, name, api, facility_id, usage_days, date_format, time_format): 106 | self._name = name 107 | self._icon = "mdi:lightning-bolt" 108 | self._state = 0 109 | self._state_attributes = { 110 | "state_class": "measurement", 111 | "last_reset": "1970-01-01T00:00:00+00:00", 112 | } 113 | self._unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR 114 | self._usage_days = usage_days 115 | self._date_format = date_format 116 | self._time_format = time_format 117 | self._api = api 118 | self._device_class = SensorDeviceClass.ENERGY 119 | self._facility_id = facility_id 120 | 121 | @property 122 | def name(self): 123 | """Return the name of the sensor.""" 124 | return self._name 125 | 126 | @property 127 | def icon(self): 128 | """Icon to use in the frontend, if any.""" 129 | return self._icon 130 | 131 | @property 132 | def state(self): 133 | """Return the state of the device.""" 134 | return self._state 135 | 136 | @property 137 | def extra_state_attributes(self): 138 | """Return the state attributes of the sensor.""" 139 | return self._state_attributes 140 | 141 | @property 142 | def unit_of_measurement(self): 143 | """Return the unit of measurement.""" 144 | return self._unit_of_measurement 145 | 146 | @property 147 | def unique_id(self): 148 | """Return a unique ID.""" 149 | return self._facility_id + "_daily_usage" 150 | 151 | @property 152 | def device_info(self) -> DeviceInfo: 153 | """Return the device info.""" 154 | _LOGGER.debug("device_info") 155 | return DeviceInfo( 156 | name="Greenely", 157 | identifiers={(DOMAIN, self._facility_id)}, 158 | manufacturer="Greenely", 159 | entry_type=DeviceEntryType.SERVICE, 160 | ) 161 | 162 | @property 163 | def device_class(self): 164 | """Return the class of the sensor.""" 165 | return self._device_class 166 | 167 | def update(self): 168 | _LOGGER.debug("Checking jwt validity...") 169 | if self._api.check_auth(): 170 | # Get todays date 171 | today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) 172 | _LOGGER.debug("Fetching daily usage data...") 173 | data = [] 174 | startDate = today - timedelta(days=self._usage_days) 175 | response = self._api.get_usage(startDate, today, False) 176 | if response: 177 | data = self.make_attributes(today, response) 178 | self._state_attributes["data"] = data 179 | else: 180 | _LOGGER.error("Unable to log in!") 181 | 182 | def make_attributes(self, today, response): 183 | yesterday = today - timedelta(days=1) 184 | data = [] 185 | keys = iter(response) 186 | if keys != None: 187 | for k in keys: 188 | daily_data = {} 189 | dateTime = datetime.strptime(response[k]["localtime"], "%Y-%m-%d %H:%M") 190 | daily_data["localtime"] = dateTime.strftime(self._date_format) 191 | usage = response[k]["usage"] 192 | if dateTime == yesterday: 193 | self._state = usage / 1000 if usage != None else 0 194 | daily_data["usage"] = (usage / 1000) if usage != None else 0 195 | data.append(daily_data) 196 | return data 197 | 198 | 199 | class GreenelyHourlyUsageSensor(Entity): 200 | def __init__( 201 | self, name, api, facility_id, hourly_offset_days, date_format, time_format 202 | ): 203 | self._name = name 204 | self._icon = "mdi:lightning-bolt" 205 | self._state = 0 206 | self._state_attributes = { 207 | "state_class": "measurement", 208 | "last_reset": "1970-01-01T00:00:00+00:00", 209 | } 210 | self._unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR 211 | self._date_format = date_format 212 | self._time_format = time_format 213 | self._hourly_offset_days = hourly_offset_days 214 | self._api = api 215 | self._device_class = SensorDeviceClass.ENERGY 216 | self._facility_id = facility_id 217 | 218 | @property 219 | def name(self): 220 | """Return the name of the sensor.""" 221 | return self._name 222 | 223 | @property 224 | def icon(self): 225 | """Icon to use in the frontend, if any.""" 226 | return self._icon 227 | 228 | @property 229 | def state(self): 230 | """Return the state of the device.""" 231 | return self._state 232 | 233 | @property 234 | def extra_state_attributes(self): 235 | """Return the state attributes of the sensor.""" 236 | return self._state_attributes 237 | 238 | @property 239 | def unit_of_measurement(self): 240 | """Return the unit of measurement.""" 241 | return self._unit_of_measurement 242 | 243 | @property 244 | def unique_id(self): 245 | """Return a unique ID.""" 246 | return self._facility_id + "_hourly_usage" 247 | 248 | @property 249 | def device_info(self) -> DeviceInfo: 250 | """Return the device info.""" 251 | _LOGGER.debug("device_info") 252 | return DeviceInfo( 253 | name="Greenely", 254 | identifiers={(DOMAIN, self._facility_id)}, 255 | manufacturer="Greenely", 256 | entry_type=DeviceEntryType.SERVICE, 257 | ) 258 | 259 | @property 260 | def device_class(self): 261 | """Return the class of the sensor.""" 262 | return self._device_class 263 | 264 | def update(self): 265 | _LOGGER.debug("Checking jwt validity...") 266 | if self._api.check_auth(): 267 | # Get todays date 268 | today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) 269 | _LOGGER.debug("Fetching hourly usage data...") 270 | data = [] 271 | startDate = today - timedelta(days=self._hourly_offset_days) 272 | response = self._api.get_usage(startDate, today, True) 273 | if response: 274 | data = self.make_attributes(datetime.now(), response) 275 | self._state_attributes["data"] = data 276 | else: 277 | _LOGGER.error("Unable to log in!") 278 | 279 | def make_attributes(self, today, response): 280 | yesterday = today - timedelta(days=1) 281 | data = [] 282 | keys = iter(response) 283 | if keys != None: 284 | for k in keys: 285 | hourly_data = {} 286 | dateTime = datetime.strptime(response[k]["localtime"], "%Y-%m-%d %H:%M") 287 | hourly_data["localtime"] = ( 288 | dateTime.strftime(self._date_format) 289 | + " " 290 | + dateTime.strftime(self._time_format) 291 | ) 292 | usage = response[k]["usage"] 293 | if ( 294 | dateTime.hour == yesterday.hour 295 | and dateTime.day == yesterday.day 296 | and dateTime.month == yesterday.month 297 | and dateTime.year == yesterday.year 298 | ): 299 | self._state = usage / 1000 if usage != None else 0 300 | hourly_data["usage"] = (usage / 1000) if usage != None else 0 301 | data.append(hourly_data) 302 | return data 303 | 304 | 305 | class GreenelyPricesSensor(Entity): 306 | def __init__( 307 | self, name, api, facility_id, date_format, time_format, homekit_compatible 308 | ): 309 | self._name = name 310 | self._icon = "mdi:account-cash" 311 | self._state = 0 312 | self._state_attributes = {} 313 | self._unit_of_measurement = "SEK/kWh" if homekit_compatible != True else "°C" 314 | self._date_format = date_format 315 | self._time_format = time_format 316 | self._homekit_compatible = homekit_compatible 317 | self._api = api 318 | self._facility_id = facility_id 319 | 320 | @property 321 | def name(self): 322 | """Return the name of the sensor.""" 323 | return self._name 324 | 325 | @property 326 | def icon(self): 327 | """Icon to use in the frontend, if any.""" 328 | return self._icon 329 | 330 | @property 331 | def state(self): 332 | """Return the state of the device.""" 333 | return self._state 334 | 335 | @property 336 | def extra_state_attributes(self): 337 | """Return the state attributes of the sensor.""" 338 | return self._state_attributes 339 | 340 | @property 341 | def unit_of_measurement(self): 342 | """Return the unit of measurement.""" 343 | return self._unit_of_measurement 344 | 345 | @property 346 | def unique_id(self): 347 | """Return a unique ID.""" 348 | return self._facility_id + "_prices" 349 | 350 | @property 351 | def device_info(self) -> DeviceInfo: 352 | """Return the device info.""" 353 | _LOGGER.debug("device_info") 354 | return DeviceInfo( 355 | name="Greenely", 356 | identifiers={(DOMAIN, self._facility_id)}, 357 | manufacturer="Greenely", 358 | entry_type=DeviceEntryType.SERVICE, 359 | ) 360 | 361 | def update(self): 362 | """Update state and attributes.""" 363 | _LOGGER.debug("Checking jwt validity...") 364 | if self._api.check_auth(): 365 | data = self._api.get_price_data() 366 | totalCost = 0 367 | if data: 368 | for d, value in data.items(): 369 | cost = value["cost"] 370 | if cost != None: 371 | totalCost += cost 372 | self._state_attributes["current_month"] = round(totalCost / 100000) 373 | spot_price_data = self._api.get_spot_price() 374 | if spot_price_data: 375 | _LOGGER.debug("Fetching daily prices...") 376 | today = datetime.now().replace( 377 | hour=0, minute=0, second=0, microsecond=0 378 | ) 379 | todaysData = [] 380 | tomorrowsData = [] 381 | yesterdaysData = [] 382 | for d in spot_price_data["data"]: 383 | timestamp = datetime.strptime( 384 | spot_price_data["data"][d]["localtime"], "%Y-%m-%d %H:%M" 385 | ) 386 | if timestamp.date() == today.date(): 387 | if spot_price_data["data"][d]["price"] != None: 388 | todaysData.append(self.make_attribute(spot_price_data, d)) 389 | elif timestamp.date() == (today.date() + timedelta(days=1)): 390 | if spot_price_data["data"][d]["price"] != None: 391 | tomorrowsData.append( 392 | self.make_attribute(spot_price_data, d) 393 | ) 394 | elif timestamp.date() == (today.date() - timedelta(days=1)): 395 | if spot_price_data["data"][d]["price"] != None: 396 | yesterdaysData.append( 397 | self.make_attribute(spot_price_data, d) 398 | ) 399 | self._state_attributes["current_day"] = todaysData 400 | self._state_attributes["next_day"] = tomorrowsData 401 | self._state_attributes["previous_day"] = yesterdaysData 402 | else: 403 | _LOGGER.error("Unable to log in!") 404 | 405 | def make_attribute(self, response, value): 406 | if response: 407 | newPoint = {} 408 | today = datetime.now() 409 | price = response["data"][value]["price"] 410 | dt_object = datetime.strptime( 411 | response["data"][value]["localtime"], "%Y-%m-%d %H:%M" 412 | ) 413 | newPoint["date"] = dt_object.strftime(self._date_format) 414 | newPoint["time"] = dt_object.strftime(self._time_format) 415 | if price != None: 416 | rounded = self.format_price(price) 417 | newPoint["price"] = rounded 418 | if dt_object.hour == today.hour and dt_object.day == today.day: 419 | self._state = rounded 420 | else: 421 | newPoint["price"] = 0 422 | return newPoint 423 | 424 | def format_price(self, price): 425 | if self._homekit_compatible == True: 426 | return round(price / 1000) 427 | else: 428 | return round(((price / 1000) / 100), 4) 429 | 430 | def make_data_attribute(self, name, response, nameOfPriceAttr): 431 | if response: 432 | points = response.get("points", None) 433 | data = [] 434 | for point in points: 435 | price = point[nameOfPriceAttr] 436 | if price != None: 437 | newPoint = {} 438 | dt_object = datetime.utcfromtimestamp(point["timestamp"]) 439 | newPoint["date"] = dt_object.strftime(self._date_format) 440 | newPoint["time"] = dt_object.strftime(self._time_format) 441 | newPoint["price"] = str(price / 100) 442 | data.append(newPoint) 443 | self._state_attributes[name] = data 444 | 445 | 446 | class GreenelyDailyProducedElecticitySensor(Entity): 447 | def __init__( 448 | self, 449 | name, 450 | api, 451 | facility_id, 452 | produced_electricity_days, 453 | date_format, 454 | time_format, 455 | ): 456 | self._name = name 457 | self._icon = "mdi:lightning-bolt" 458 | self._state = 0 459 | self._state_attributes = { 460 | "state_class": "measurement", 461 | "last_reset": "1970-01-01T00:00:00+00:00", 462 | } 463 | self._produced_electricity_days = produced_electricity_days 464 | self._unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR 465 | self._date_format = date_format 466 | self._time_format = time_format 467 | self._api = api 468 | self._device_class = SensorDeviceClass.ENERGY 469 | self._facility_id = facility_id 470 | 471 | @property 472 | def name(self): 473 | """Return the name of the sensor.""" 474 | return self._name 475 | 476 | @property 477 | def icon(self): 478 | """Icon to use in the frontend, if any.""" 479 | return self._icon 480 | 481 | @property 482 | def state(self): 483 | """Return the state of the device.""" 484 | return self._state 485 | 486 | @property 487 | def extra_state_attributes(self): 488 | """Return the state attributes of the sensor.""" 489 | return self._state_attributes 490 | 491 | @property 492 | def unit_of_measurement(self): 493 | """Return the unit of measurement.""" 494 | return self._unit_of_measurement 495 | 496 | @property 497 | def unique_id(self): 498 | """Return a unique ID.""" 499 | return self._facility_id + "_daily_produced_electricity" 500 | 501 | @property 502 | def device_info(self) -> DeviceInfo: 503 | """Return the device info.""" 504 | _LOGGER.debug("device_info") 505 | return DeviceInfo( 506 | name="Greenely", 507 | identifiers={(DOMAIN, self._facility_id)}, 508 | manufacturer="Greenely", 509 | entry_type=DeviceEntryType.SERVICE, 510 | ) 511 | 512 | def update(self): 513 | _LOGGER.debug("Checking jwt validity...") 514 | if self._api.check_auth(): 515 | # Get todays date 516 | today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) 517 | _LOGGER.debug("Fetching daily produced electricity data...") 518 | data = [] 519 | startDate = today - timedelta(days=(self._produced_electricity_days - 1)) 520 | endDate = today + timedelta(days=1) 521 | response = self._api.get_produced_electricity(startDate, endDate, False) 522 | if response: 523 | data = self.make_attributes(today, response) 524 | self._state_attributes["data"] = data 525 | else: 526 | _LOGGER.error("Unable to log in!") 527 | 528 | def make_attributes(self, today, response): 529 | data = [] 530 | keys = iter(response) 531 | if keys != None: 532 | for k in keys: 533 | daily_data = {} 534 | dateTime = datetime.strptime(response[k]["localtime"], "%Y-%m-%d %H:%M") 535 | daily_data["localtime"] = dateTime.strftime(self._date_format) 536 | produced_electricity = response[k]["value"] 537 | if dateTime == today: 538 | self._state = ( 539 | produced_electricity / 1000 540 | if produced_electricity != None 541 | else 0 542 | ) 543 | daily_data["produced_electricity"] = ( 544 | (produced_electricity / 1000) if produced_electricity != None else 0 545 | ) 546 | data.append(daily_data) 547 | return data 548 | --------------------------------------------------------------------------------