├── testing ├── requirements.txt └── mixergy.py ├── hacs.json ├── .DS_Store ├── .github ├── FUNDING.yml └── workflows │ ├── validate_hassfest.yml │ └── hass.yml ├── info.md ├── custom_components └── mixergy │ ├── manifest.json │ ├── const.py │ ├── strings.json │ ├── translations │ └── en.json │ ├── mixergy_entity.py │ ├── services.yaml │ ├── switch.py │ ├── config_flow.py │ ├── number.py │ ├── __init__.py │ ├── sensor.py │ └── tank.py ├── LICENSE ├── .gitignore ├── www └── mixergy-card.js └── README.md /testing/requirements.txt: -------------------------------------------------------------------------------- 1 | requests -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mixergy" 3 | } -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasmcguinness/homeassistant-mixergy/HEAD/.DS_Store -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | ko_fi: tomasmcguinness 3 | github: tomasmcguinness 4 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # Mixergy Smart Hot Water Tank Integration 2 | 3 | This integration connects to your Mixergy tank via the cloud API. It adds various sensors and services to Home Assistant. 4 | 5 | [](https://ko-fi.com/G2G11TQK5) -------------------------------------------------------------------------------- /.github/workflows/validate_hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.github/workflows/hass.yml: -------------------------------------------------------------------------------- 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 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /custom_components/mixergy/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "mixergy", 3 | "name": "Mixergy", 4 | "codeowners": ["@tomasmcguinness"], 5 | "config_flow": true, 6 | "dependencies": ["integration"], 7 | "documentation": "https://github.com/tomasmcguinness/homeassistant-mixergy", 8 | "integration_type": "device", 9 | "iot_class": "cloud_polling", 10 | "issue_tracker": "https://github.com/tomasmcguinness/homeassistant-mixergy/issues", 11 | "requirements": [], 12 | "version": "0.9.0" 13 | } -------------------------------------------------------------------------------- /custom_components/mixergy/const.py: -------------------------------------------------------------------------------- 1 | # Consts for the Mixergy integration 2 | DOMAIN = "mixergy" 3 | 4 | SERVICE_SET_CHARGE = "mixergy_set_charge" 5 | SERVICE_SET_TARGET_TEMPERATURE = "mixergy_set_target_temperature" 6 | SERVICE_SET_HOLIDAY_DATES = "mixergy_set_holiday_dates" 7 | SERVICE_CLEAR_HOLIDAY_DATES = "mixergy_clear_holiday_dates" 8 | SERVICE_SET_DEFAULT_HEAT_SOURCE = "mixergy_set_default_heat_source" 9 | 10 | ATTR_CHARGE = "charge" 11 | ATTR_TEMPERATURE = "temperature" 12 | ATTR_START_DATE = "start_date" 13 | ATTR_END_DATE = "end_date" 14 | ATTR_HEAT_SOURCE = "heat_source" -------------------------------------------------------------------------------- /custom_components/mixergy/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "serial_number": "[%key:common::config_flow::data::serial_number%]", 7 | "username": "[%key:common::config_flow::data::username%]", 8 | "password": "[%key:common::config_flow::data::password%]" 9 | } 10 | } 11 | }, 12 | "error": { 13 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 14 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 15 | "unknown": "[%key:common::config_flow::error::unknown%]" 16 | }, 17 | "abort": { 18 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /custom_components/mixergy/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "invalidat_username": "Failed to connect to Mixergy API", 5 | "cannot_connect": "Failed to connect to Mixergy API", 6 | "tank_not_found": "Could not find a tank with the specified serial number", 7 | "invalid_auth": "Authentication failed.", 8 | "unknown": "Something went wrong. Please try again." 9 | }, 10 | "step": { 11 | "user": { 12 | "data": { 13 | "serial_number": "Serial Number", 14 | "password": "Password", 15 | "username": "Username" 16 | } 17 | } 18 | } 19 | }, 20 | "selector": { 21 | "heat_source": { 22 | "options": { 23 | "electric": "Electric", 24 | "indirect": "Indirect (e.g. gas)", 25 | "heat_pump": "Heat Pump" 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /custom_components/mixergy/mixergy_entity.py: -------------------------------------------------------------------------------- 1 | from .const import DOMAIN 2 | from .tank import Tank 3 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 4 | 5 | class MixergyEntityBase(CoordinatorEntity): 6 | 7 | should_poll = True 8 | 9 | def __init__(self, coordinator, tank:Tank): 10 | super().__init__(coordinator) 11 | self._tank = tank 12 | 13 | @property 14 | def device_info(self): 15 | return { 16 | "identifiers": {(DOMAIN, self._tank.serial_number)}, 17 | "manufacturer": "Mixergy Ltd", 18 | "name": "Mixergy Tank", 19 | "suggested_area": "garage", 20 | "model": self._tank.modelCode, 21 | "sw_version": self._tank.firmwareVersion 22 | } 23 | 24 | @property 25 | def available(self) -> bool: 26 | return self._tank.online 27 | 28 | async def async_added_to_hass(self): 29 | self._tank.register_callback(self.async_write_ha_state) 30 | 31 | async def async_will_remove_from_hass(self): 32 | self._tank.remove_callback(self.async_write_ha_state) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tomas McGuinness 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom_components/mixergy/services.yaml: -------------------------------------------------------------------------------- 1 | # Describes the format for available Mixergy services 2 | mixergy_set_charge: 3 | name: Set Charge 4 | description: Sets the desired charge of your Mixergy tank. 5 | fields: 6 | charge: 7 | name: Charge 8 | description: The charge you want the tank to reach. 9 | example: 25 10 | required: true 11 | selector: 12 | number: 13 | min: 0 14 | max: 100 15 | mixergy_set_target_temperature: 16 | name: Set Target Temperature 17 | description: Sets the target temperature of the water in Mixergy tank. 18 | fields: 19 | temperature: 20 | name: Temperature 21 | description: The target temperature of your Mixergy tank 22 | example: 55 23 | required: true 24 | selector: 25 | number: 26 | min: 45 27 | max: 70 28 | mixergy_set_holiday_dates: 29 | name: Set Holiday Dates 30 | description: Sets the holiday start and end dates of your Mixergy tank. 31 | fields: 32 | start_date: 33 | name: Start Date 34 | description: Timestamp of the start date of the holiday period 35 | example: "2024-03-15 16:00:00" 36 | required: true 37 | selector: 38 | datetime: 39 | end_date: 40 | name: End Date 41 | description: Timestamp of the end date of the holiday period 42 | example: "2024-03-17 12:00:00" 43 | required: true 44 | selector: 45 | datetime: 46 | mixergy_clear_holiday_dates: 47 | name: Clear Holiday Dates 48 | description: Clears the holiday dates of your Mixergy tank. 49 | mixergy_set_default_heat_source: 50 | name: Set Default Heat Source 51 | description: Sets the default heat source of the Mixergy tank. 52 | fields: 53 | heat_source: 54 | name: Heat Source 55 | description: The heat source of your Mixergy tank 56 | example: indirect 57 | required: true 58 | selector: 59 | select: 60 | translation_key: "heat_source" 61 | options: 62 | - "electric" 63 | - "indirect" 64 | - "heat_pump" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /testing/mixergy.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import requests 3 | import json 4 | 5 | if len(sys.argv) < 3: 6 | print("Mixergy username and password required") 7 | exit() 8 | 9 | username = sys.argv[1] 10 | password = sys.argv[2] 11 | serial_number = '' 12 | 13 | if len(sys.argv) == 4: 14 | print("Using Serial Number", sys.argv[3]) 15 | serial_number = sys.argv[3] 16 | 17 | # Get login URL 18 | 19 | result = requests.get("https://www.mixergy.io/api/v2") 20 | 21 | root_result = result.json() 22 | 23 | account_url = root_result["_links"]["account"]["href"] 24 | 25 | result = requests.get(account_url) 26 | 27 | account_result = result.json() 28 | 29 | login_url = account_result["_links"]["login"]["href"] 30 | 31 | result = requests.post(login_url, json = {'username': username, 'password': password}) 32 | 33 | if result.status_code != 201: 34 | print("Authentication failure. Check your credentials and try again!") 35 | exit() 36 | 37 | print("Authentication successful!") 38 | 39 | login_result = result.json() 40 | 41 | login_token = login_result["token"] 42 | 43 | headers = {'Authorization': f'Bearer {login_token}'} 44 | 45 | result = requests.get("https://www.mixergy.io/api/v2", headers=headers) 46 | 47 | root_result = result.json() 48 | 49 | tanks_url = root_result["_links"]["tanks"]["href"] 50 | 51 | result = requests.get(tanks_url, headers=headers) 52 | 53 | tanks_result = result.json() 54 | 55 | tanks = tanks_result['_embedded']['tankList'] 56 | 57 | if serial_number == '': 58 | for i, subjobj in enumerate(tanks): 59 | print("** Found a tank with serial number", subjobj['serialNumber']) 60 | exit() 61 | 62 | for i, subjobj in enumerate(tanks): 63 | if serial_number == subjobj['serialNumber']: 64 | print("Found tanks serial number", subjobj['serialNumber']) 65 | 66 | tank_url = subjobj["_links"]["self"]["href"] 67 | firmware_version = subjobj["firmwareVersion"] 68 | print("Tank Url:", tank_url) 69 | print("Firmware:",firmware_version) 70 | 71 | print("Fetching details...") 72 | 73 | result = requests.get(tank_url, headers=headers) 74 | 75 | tank_result = result.json() 76 | 77 | latest_measurement_url = tank_result["_links"]["latest_measurement"]["href"] 78 | control_url = tank_result["_links"]["control"]["href"] 79 | modelCode = tank_result["tankModelCode"] 80 | 81 | print("Measurement Url:", latest_measurement_url) 82 | print("Control Url:", control_url) 83 | print("Model:",modelCode) 84 | 85 | result = requests.get(latest_measurement_url, headers=headers) 86 | 87 | latest_measurement_result = result.json() 88 | 89 | hot_water_temperature = latest_measurement_result["topTemperature"] 90 | coldest_water_temperature = latest_measurement_result["bottomTemperature"] 91 | charge = latest_measurement_result["charge"] 92 | 93 | print("Top Temp:", hot_water_temperature) 94 | print("Bottom Temp:", coldest_water_temperature) 95 | print("Charge:",charge) 96 | 97 | state = json.loads(latest_measurement_result["state"]) 98 | 99 | current = state["current"] 100 | 101 | heat_source = current["heat_source"] 102 | heat_source_on = current["immersion"] == "On" 103 | 104 | print("Heat Source:", heat_source) 105 | print("Heat Source On:", heat_source_on) 106 | 107 | pv_energy = current["pvEnergy"] 108 | print("PV Energy:", pv_energy) 109 | -------------------------------------------------------------------------------- /www/mixergy-card.js: -------------------------------------------------------------------------------- 1 | import "https://unpkg.com/wired-card@0.8.1/wired-card.js?module"; 2 | import "https://unpkg.com/wired-toggle@0.8.0/wired-toggle.js?module"; 3 | import { 4 | LitElement, 5 | html, 6 | css 7 | } from "https://unpkg.com/lit-element@2.0.1/lit-element.js?module"; 8 | 9 | class MixergyCard extends LitElement { 10 | static get properties() { 11 | return { 12 | hass: {}, 13 | config: {} 14 | }; 15 | } 16 | 17 | render() { 18 | return html` 19 | 20 | 21 | 22 | ${html` 23 | 24 | 25 | 26 | ${this.getPercentage()} 27 | 28 | `} 29 | 30 | 31 | 32 | `; 33 | } 34 | 35 | getPercentage() { 36 | let entity = this.config.entity_current_charge; 37 | let state = this.hass.states[entity]; 38 | let percentage = parseFloat(state.state); 39 | return Math.floor(percentage) + "%"; 40 | } 41 | 42 | getHeight() { 43 | let entity = this.config.entity_current_charge; 44 | let state = this.hass.states[entity]; 45 | let percentage = parseFloat(state.state) / 100; 46 | let height = Math.floor(300 * percentage); 47 | return height + "px"; 48 | } 49 | 50 | getOffset() { 51 | let entity = this.config.entity_current_charge; 52 | let state = this.hass.states[entity]; 53 | let percentage = parseFloat(state.state) / 100; 54 | let size = Math.floor(300 - (300 * percentage)); 55 | return size + 1 + "px"; 56 | } 57 | 58 | setConfig(config) { 59 | if (!config.entity_current_charge) { 60 | throw new Error("You need to define entities"); 61 | } 62 | this.config = config; 63 | } 64 | 65 | // The height of your card. Home Assistant uses this to automatically 66 | // distribute all cards over the available columns. 67 | getCardSize() { 68 | return 5; 69 | } 70 | 71 | static get styles() { 72 | return css` 73 | ha-card { 74 | display: block; 75 | font-size: 18px; 76 | } 77 | 78 | .content { 79 | display: flex; 80 | flex-direction: column; 81 | align-items: center; 82 | } 83 | 84 | ha-card h1.card-header { 85 | align-self: start !important; 86 | } 87 | 88 | .tank { 89 | background-color: #039be5; 90 | height: 300px; 91 | width: 120px; 92 | border-radius: 5px; 93 | margin-bottom: 20px; 94 | display: grid; 95 | } 96 | 97 | .hot-water { 98 | align-self: flex-end; 99 | background-color: red; 100 | width: 120px; 101 | border-radius: 5px; 102 | align-items: center; 103 | justify-content: center; 104 | font-weight: bold; 105 | grid-row: 1; 106 | grid-column: 1; 107 | } 108 | 109 | .hot-water-percentage { 110 | align-self: center; 111 | justify-self: center; 112 | text-align: center; 113 | font-weight: bold; 114 | font-size: large; 115 | grid-row: 1; 116 | grid-column: 1; 117 | } 118 | `; 119 | } 120 | } 121 | customElements.define("mixergy-card", MixergyCard); 122 | Advertisement 123 | -------------------------------------------------------------------------------- /custom_components/mixergy/switch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity 3 | from .const import DOMAIN 4 | from .tank import Tank 5 | from .mixergy_entity import MixergyEntityBase 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | 9 | async def async_setup_entry(hass, config_entry, async_add_entities): 10 | _LOGGER.info("Setting up entry based on user config") 11 | 12 | entry = hass.data[DOMAIN][config_entry.entry_id] 13 | tank = entry["tank"] 14 | coordinator = entry["coordinator"] 15 | 16 | new_entities = [] 17 | 18 | new_entities.append(DSRSwitch(coordinator, tank)) 19 | new_entities.append(FrostProtectionSwitch(coordinator, tank)) 20 | new_entities.append(DistributedComputingSwitch(coordinator, tank)) 21 | new_entities.append(PVDivertSwitch(coordinator, tank)) 22 | 23 | async_add_entities(new_entities) 24 | 25 | class SwitchEntityBase(MixergyEntityBase, SwitchEntity): 26 | 27 | device_class = SwitchDeviceClass.SWITCH 28 | 29 | def __init__(self, coordinator, tank:Tank): 30 | super().__init__(coordinator, tank) 31 | 32 | class DSRSwitch(SwitchEntityBase): 33 | 34 | def __init__(self, coordinator, tank:Tank): 35 | super().__init__(coordinator, tank) 36 | 37 | @property 38 | def unique_id(self): 39 | return f"mixergy_{self._tank.tank_id}_dsr_enabled" 40 | 41 | @property 42 | def name(self): 43 | return f"Grid Assistance Enabled" 44 | 45 | @property 46 | def is_on(self): 47 | return self._tank.dsr_enabled 48 | 49 | async def async_turn_on(self, **kwargs): 50 | await self._tank.set_dsr_enabled(True) 51 | 52 | async def async_turn_off(self, **kwargs): 53 | await self._tank.set_dsr_enabled(False) 54 | 55 | class FrostProtectionSwitch(SwitchEntityBase): 56 | 57 | def __init__(self, coordinator, tank:Tank): 58 | super().__init__(coordinator, tank) 59 | 60 | @property 61 | def unique_id(self): 62 | return f"mixergy_{self._tank.tank_id}_frost_protection_enabled" 63 | 64 | @property 65 | def name(self): 66 | return f"Frost Protection Enabled" 67 | 68 | @property 69 | def is_on(self): 70 | return self._tank.frost_protection_enabled 71 | 72 | async def async_turn_on(self, **kwargs): 73 | await self._tank.set_frost_protection_enabled(True) 74 | 75 | async def async_turn_off(self, **kwargs): 76 | await self._tank.set_frost_protection_enabled(False) 77 | 78 | class DistributedComputingSwitch(SwitchEntityBase): 79 | 80 | def __init__(self, coordinator, tank:Tank): 81 | super().__init__(coordinator, tank) 82 | 83 | @property 84 | def unique_id(self): 85 | return f"mixergy_{self._tank.tank_id}_distributed_computng_enabled" 86 | 87 | @property 88 | def name(self): 89 | return f"Medical Research Donation Enabled" 90 | 91 | @property 92 | def is_on(self): 93 | return self._tank.distributed_computing_enabled 94 | 95 | async def async_turn_on(self, **kwargs): 96 | await self._tank.set_distributed_computing_enabled(True) 97 | 98 | async def async_turn_off(self, **kwargs): 99 | await self._tank.set_distributed_computing_enabled(False) 100 | 101 | class PVDivertSwitch(SwitchEntityBase): 102 | 103 | def __init__(self, coordinator, tank:Tank): 104 | super().__init__(coordinator, tank) 105 | 106 | @property 107 | def unique_id(self): 108 | return f"mixergy_{self._tank.tank_id}_pv_divert_enabled" 109 | 110 | @property 111 | def name(self): 112 | return f"PV Divert Enabled" 113 | 114 | @property 115 | def available(self): 116 | return super().available and self._tank.has_pv_diverter 117 | 118 | @property 119 | def is_on(self): 120 | return self._tank.divert_exported_enabled 121 | 122 | async def async_turn_on(self, **kwargs): 123 | await self._tank.set_divert_exported_enabled(True) 124 | 125 | async def async_turn_off(self, **kwargs): 126 | await self._tank.set_divert_exported_enabled(False) 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant Mixergy Smart Hot Water Tank Integration 2 | 3 | [](https://github.com/hacs/integration) 4 | 5 | Add support for Mixergy's smart water tank into Home Assistant. This integration will return the current temperatures at the the top and bottom of the tank, the tank's current charge, the state of the heating and the energy used by the direct heating element. It also has sensors to report low charge (< 5%) and no change (0%). 6 | 7 |  8 | 9 | ## Support 10 | 11 | If you want to support this project, please consider buying me a coffee! 12 | 13 | 14 | 15 | ## Installation 16 | 17 | ### HACS 18 | 19 | [](https://my.home-assistant.io/redirect/hacs_repository/?owner=tomasmcguinness&repository=homeassistant-mixergy&category=integration) 20 | 21 | To install via HACS, you must first add the repository and then add the integration. 22 | 23 | ### Manually 24 | 25 | Alternatively, you can copy the contents of the mixergy folder into your custom_components directory. 26 | 27 | ## Setup 28 | 29 | Once installed, the integration will then be available as an integration you can install. 30 | 31 |  32 | 33 | You then need to provide your Mixergy credentials and the serial number of your tank. You can find the serial number physically on the tank, or via [mixergy.io](https://www.mixergy.io/). 34 | 35 |  36 | 37 | ## Services 38 | 39 | This integration offers two services: 40 | 41 | `mixergy.mixergy_set_charge` - This boosts the hot water to the desired percentage. 42 | `mixergy.mixergy_set_target_temperature` - Sets the desired hot water temperature. 43 | `mixergy.mixergy_set_holiday_dates` - Set the holiday mode start and end dates. 44 | `mixergy.mixergy_clear_holiday_dates` - Clears the holiday dates, taking the tank out of holiday mode. 45 | `mixergy_set_default_heat_source` - Changes the tank's default heat source. 46 | 47 |  48 | 49 | ## Lovelace Card 50 | 51 | I has created a Love Lace card to give a visual representation of your Mixergy Tank. 52 | 53 |  54 | 55 | ### Installation 56 | 57 | To install this card, start by copying the `www/mixergy-card.js` file into your Home Assistant's `www` folder. 58 | 59 | In Home Assistant, go to Settings > Dashboards. Click on the three-dot menu, in the rop right, and choose Resources. 60 | 61 | Then click the "Add Resource" button. 62 | 63 | Enter the URL as `/local/mixergy-card.js` and select `Javascript Module` as the Resource Type. 64 | 65 | Click `Create`. You should then be able to add the Mixergy card into your dashboards. 66 | 67 | ``` 68 | type: custom:mixergy-card 69 | entity_current_charge: sensor.mixergy_current_charge 70 | ``` 71 | 72 | > [!TIP] 73 | > Watch the entity name in the snippet above. Also, you can only reference this card using YAML at this time. I want to find out how to deploy this card as a HACS package to make installation easier. 74 | 75 | ## Improvements 76 | 77 | This integration is useful as it provides the state of your Mixergy tank via the API, but there are numerous enhancements I would like to make. 78 | 79 | * ~~Add the component to HACS~~ 80 | * ~~Add to the HACS default repository list (There is a PR open for this)~~ 81 | * ~~Add a service to enable the charge to be set, so you can boost via HA~~ 82 | * ~~Put better icons into the status~~ 83 | * Ensure authentication token expiry is handled correctly. (Been told the token doesn't expire at present) 84 | * ~~Create a nice Lovelace card that provides a visual representation of the tank's state.~~ 85 | * Add the Card as a HACS package. 86 | * Get this component merged into the HomeAssistant core? 87 | * Update the Mixergy icon and support dark mode 88 | * ~~Get the Mixergy icon added, to improve the installation~~ 89 | * ~~Support *away* by controlling tank's holiday mode~~ 90 | 91 | 92 | -------------------------------------------------------------------------------- /custom_components/mixergy/config_flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import voluptuous as vol 4 | 5 | from homeassistant import config_entries, core, exceptions 6 | from .tank import Tank 7 | from .const import DOMAIN # pylint:disable=unused-import 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | # This is the schema that used to display the UI to the user. This simple 12 | # schema has a single required host field, but it could include a number of fields 13 | # such as username, password etc. See other components in the HA core code for 14 | # further examples. 15 | # Note the input displayed to the user will be translated. See the 16 | # translations/.json file and strings.json. See here for further information: 17 | # https://developers.home-assistant.io/docs/config_entries_config_flow_handler/#translations 18 | # At the time of writing I found the translations created by the scaffold didn't 19 | # quite work as documented and always gave me the "Lokalise key references" string 20 | # (in square brackets), rather than the actual translated value. I did not attempt to 21 | # figure this out or look further into it. 22 | DATA_SCHEMA = vol.Schema({("username"): str,("password"):str,("serial_number"):str}) 23 | 24 | async def validate_input(hass: core.HomeAssistant, data: dict): 25 | """Validate the user input allows us to connect. 26 | Data has the keys from DATA_SCHEMA with values provided by the user. 27 | """ 28 | # Validate the data can be used to set up a connection. 29 | 30 | # This is a simple example to show an error in the UI for a short hostname 31 | # The exceptions are defined at the end of this file, and are used in the 32 | # `async_step_user` method below. 33 | if len(data["username"]) <= 0: 34 | raise InvalidUserName 35 | 36 | if len(data["password"]) <= 0: 37 | raise InvalidPassword 38 | 39 | if len(data["serial_number"]) <= 0: 40 | raise InvalidSerialNumber 41 | 42 | tank = Tank(hass, data["username"],data["password"],data["serial_number"]) 43 | 44 | result = await tank.test_authentication() 45 | 46 | if not result: 47 | raise AuthenticationFailed 48 | 49 | result = await tank.test_connection() 50 | 51 | if not result: 52 | raise TankNotFound 53 | 54 | # Return info that you want to store in the config entry. 55 | # "Title" is what is displayed to the user for this hub device 56 | # It is stored internally in HA as part of the device config. 57 | # See `async_step_user` below for how this is used 58 | return {"title": data["serial_number"]} 59 | 60 | 61 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 62 | """Handle a config flow for Hello World.""" 63 | 64 | VERSION = 1 65 | # Pick one of the available connection classes in homeassistant/config_entries.py 66 | # This tells HA if it should be asking for updates, or it'll be notified of updates 67 | # automatically. This example uses PUSH, as the dummy hub will notify HA of 68 | # changes. 69 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH 70 | 71 | async def async_step_user(self, user_input=None): 72 | """Handle the initial step.""" 73 | # This goes through the steps to take the user through the setup process. 74 | # Using this it is possible to update the UI and prompt for additional 75 | # information. This example provides a single form (built from `DATA_SCHEMA`), 76 | # and when that has some validated input, it calls `async_create_entry` to 77 | # actually create the HA config entry. Note the "title" value is returned by 78 | # `validate_input` above. 79 | errors = {} 80 | if user_input is not None: 81 | try: 82 | info = await validate_input(self.hass, user_input) 83 | return self.async_create_entry(title=info["title"], data=user_input) 84 | except CannotConnect: 85 | errors["base"] = "cannot_connect" 86 | except AuthenticationFailed: 87 | errors["base"] = "invalid_auth" 88 | except TankNotFound: 89 | errors["base"] = "tank_not_found" 90 | except InvalidUserName: 91 | # The error string is set here, and should be translated. 92 | # This example does not currently cover translations, see the 93 | # comments on `DATA_SCHEMA` for further details. 94 | # Set the error on the `host` field, not the entire form. 95 | errors["username"] = "cannot_connect" 96 | except InvalidPassword: 97 | # The error string is set here, and should be translated. 98 | # This example does not currently cover translations, see the 99 | # comments on `DATA_SCHEMA` for further details. 100 | # Set the error on the `host` field, not the entire form. 101 | errors["password"] = "cannot_connect" 102 | except InvalidSerialNumber: 103 | # The error string is set here, and should be translated. 104 | # This example does not currently cover translations, see the 105 | # comments on `DATA_SCHEMA` for further details. 106 | # Set the error on the `host` field, not the entire form. 107 | errors["serial_number"] = "cannot_connect" 108 | except Exception: # pylint: disable=broad-except 109 | _LOGGER.exception("Unexpected exception") 110 | errors["base"] = "unknown" 111 | 112 | # If there is no user input or there were errors, show the form again, including any errors that were found with the input. 113 | return self.async_show_form( 114 | step_id="user", data_schema=DATA_SCHEMA, errors=errors 115 | ) 116 | 117 | class CannotConnect(exceptions.HomeAssistantError): 118 | """Error to indicate we could not reach the Mixergy API.""" 119 | 120 | class AuthenticationFailed(exceptions.HomeAssistantError): 121 | """Error to indicate we cannot authenciate.""" 122 | 123 | class TankNotFound(exceptions.HomeAssistantError): 124 | """Error to indicate we could not find a tank with the serial number.""" 125 | 126 | class InvalidUserName(exceptions.HomeAssistantError): 127 | """Error to indicate there is an invalid hostname.""" 128 | 129 | class InvalidPassword(exceptions.HomeAssistantError): 130 | """Error to indicate there is an invalid password.""" 131 | 132 | class InvalidSerialNumber(exceptions.HomeAssistantError): 133 | """Error to indicate there is an invalid serial number.""" -------------------------------------------------------------------------------- /custom_components/mixergy/number.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from homeassistant.const import UnitOfTemperature, PERCENTAGE 3 | from homeassistant.components.number import NumberEntity, NumberDeviceClass 4 | from .const import DOMAIN 5 | from .tank import Tank 6 | from .mixergy_entity import MixergyEntityBase 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | async def async_setup_entry(hass, config_entry, async_add_entities): 11 | _LOGGER.info("Setting up entry based on user config") 12 | 13 | entry = hass.data[DOMAIN][config_entry.entry_id] 14 | tank = entry["tank"] 15 | coordinator = entry["coordinator"] 16 | 17 | new_entities = [] 18 | 19 | new_entities.append(TargetTemperatureSensor(coordinator, tank)) 20 | new_entities.append(TargetChargeSensor(coordinator, tank)) 21 | new_entities.append(CleansingTemperatureSensor(coordinator, tank)) 22 | new_entities.append(PVCutInThreshold(coordinator, tank)) 23 | new_entities.append(PVChargeLimitSensor(coordinator, tank)) 24 | new_entities.append(PVTargetCurrent(coordinator, tank)) 25 | new_entities.append(PVOverTemperature(coordinator, tank)) 26 | 27 | async_add_entities(new_entities) 28 | 29 | class NumberEntityBase(MixergyEntityBase, NumberEntity): 30 | 31 | def __init__(self, coordinator, tank:Tank): 32 | super().__init__(coordinator, tank) 33 | 34 | class TargetTemperatureSensor(NumberEntityBase): 35 | 36 | native_max_value = 55 37 | native_min_value = 45 38 | native_step = 1 39 | device_class = NumberDeviceClass.TEMPERATURE 40 | native_unit_of_measurement = UnitOfTemperature.CELSIUS 41 | 42 | def __init__(self, coordinator, tank:Tank): 43 | super().__init__( coordinator, tank) 44 | 45 | @property 46 | def unique_id(self): 47 | return f"mixergy_{self._tank.serial_number}_target_temperature" 48 | 49 | @property 50 | def state(self): 51 | return self._tank.target_temperature 52 | 53 | async def async_set_native_value(self, value: float): 54 | await self._tank.set_target_temperature(int(value)) 55 | 56 | @property 57 | def name(self): 58 | return f"Target Temperature" 59 | 60 | class TargetChargeSensor(NumberEntityBase): 61 | 62 | native_max_value = 100 63 | native_min_value = 0 64 | native_step = 1 65 | native_unit_of_measurement = PERCENTAGE 66 | 67 | def __init__(self, coordinator, tank:Tank): 68 | super().__init__(coordinator, tank) 69 | 70 | @property 71 | def unique_id(self): 72 | return f"mixergy_{self._tank.serial_number}_target_charge" 73 | 74 | @property 75 | def state(self): 76 | return self._tank.target_charge 77 | 78 | @property 79 | def icon(self): 80 | return "hass:water-percent" 81 | 82 | async def async_set_native_value(self, value: float): 83 | await self._tank.set_target_charge(int(value)) 84 | 85 | @property 86 | def name(self): 87 | return f"Target Charge" 88 | 89 | class CleansingTemperatureSensor(NumberEntityBase): 90 | 91 | native_max_value = 55 92 | native_min_value = 51 93 | native_step = 1 94 | device_class = NumberDeviceClass.TEMPERATURE 95 | native_unit_of_measurement = UnitOfTemperature.CELSIUS 96 | 97 | def __init__(self, coordinator, tank:Tank): 98 | super().__init__( coordinator, tank) 99 | 100 | @property 101 | def unique_id(self): 102 | return f"mixergy_{self._tank.serial_number}_cleansing_temperature" 103 | 104 | @property 105 | def state(self): 106 | return self._tank.cleansing_temperature 107 | 108 | async def async_set_native_value(self, value: float): 109 | await self._tank.set_cleansing_temperature(int(value)) 110 | 111 | @property 112 | def name(self): 113 | return f"Cleansing Temperature" 114 | 115 | class PVCutInThreshold(NumberEntityBase): 116 | 117 | native_max_value = 500 118 | native_min_value = 0 119 | native_step = 50 120 | 121 | def __init__(self, coordinator, tank:Tank): 122 | super().__init__(coordinator, tank) 123 | 124 | @property 125 | def unique_id(self): 126 | return f"mixergy_{self._tank.tank_id}_pv_cut_in_threshold" 127 | 128 | @property 129 | def state(self): 130 | return self._tank.pv_cut_in_threshold 131 | 132 | @property 133 | def available(self): 134 | return super().available and self._tank.has_pv_diverter 135 | 136 | async def async_set_native_value(self, value: float): 137 | await self._tank.set_pv_cut_in_threshold(int(value)) 138 | 139 | @property 140 | def icon(self): 141 | return "mdi:lightning-bolt" 142 | 143 | @property 144 | def name(self): 145 | return f"PV Cut In Threshold" 146 | 147 | class PVChargeLimitSensor(NumberEntityBase): 148 | 149 | native_max_value = 100 150 | native_min_value = 0 151 | native_step = 10 152 | 153 | def __init__(self, coordinator, tank:Tank): 154 | super().__init__(coordinator, tank) 155 | 156 | @property 157 | def unique_id(self): 158 | return f"mixergy_{self._tank.tank_id}_pv_charge_limit" 159 | 160 | @property 161 | def state(self): 162 | return self._tank.pv_charge_limit 163 | 164 | @property 165 | def available(self): 166 | return super().available and self._tank.has_pv_diverter 167 | 168 | async def async_set_native_value(self, value: float): 169 | await self._tank.set_pv_charge_limit(int(value)) 170 | 171 | @property 172 | def icon(self): 173 | return "mdi:lightning-bolt" 174 | 175 | @property 176 | def name(self): 177 | return f"PV Charge Limit" 178 | 179 | class PVTargetCurrent(NumberEntityBase): 180 | 181 | native_max_value = 0 182 | native_min_value = -1 183 | native_step = 0.1 184 | 185 | def __init__(self, coordinator, tank:Tank): 186 | super().__init__(coordinator, tank) 187 | 188 | @property 189 | def unique_id(self): 190 | return f"mixergy_{self._tank.tank_id}_pv_target_current" 191 | 192 | @property 193 | def state(self): 194 | return self._tank.pv_target_current 195 | 196 | @property 197 | def available(self): 198 | return super().available and self._tank.has_pv_diverter 199 | 200 | async def async_set_native_value(self, value: float): 201 | await self._tank.set_pv_target_current(value) 202 | 203 | @property 204 | def icon(self): 205 | return "mdi:lightning-bolt" 206 | 207 | @property 208 | def name(self): 209 | return f"PV Target Current" 210 | 211 | class PVOverTemperature(NumberEntityBase): 212 | 213 | native_max_value = 60 214 | native_min_value = 45 215 | native_step = 1 216 | 217 | def __init__(self, coordinator, tank:Tank): 218 | super().__init__(coordinator, tank) 219 | 220 | @property 221 | def unique_id(self): 222 | return f"mixergy_{self._tank.tank_id}_pv_over_temperature" 223 | 224 | @property 225 | def state(self): 226 | return self._tank.pv_over_temperature 227 | 228 | @property 229 | def available(self): 230 | return super().available and self._tank.has_pv_diverter 231 | 232 | async def async_set_native_value(self, value: float): 233 | await self._tank.set_pv_over_temperature(int(value)) 234 | 235 | @property 236 | def icon(self): 237 | return "mdi:lightning-bolt" 238 | 239 | @property 240 | def name(self): 241 | return f"PV Target Temperature" 242 | -------------------------------------------------------------------------------- /custom_components/mixergy/__init__.py: -------------------------------------------------------------------------------- 1 | from .const import ATTR_CHARGE, SERVICE_SET_CHARGE, ATTR_TEMPERATURE, SERVICE_SET_TARGET_TEMPERATURE, ATTR_START_DATE, ATTR_END_DATE, SERVICE_SET_HOLIDAY_DATES, SERVICE_CLEAR_HOLIDAY_DATES, SERVICE_SET_DEFAULT_HEAT_SOURCE, ATTR_HEAT_SOURCE 2 | from datetime import timedelta 3 | import logging 4 | import asyncio 5 | import voluptuous as vol 6 | from homeassistant import core 7 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.helpers.config_validation import make_entity_service_schema 11 | from homeassistant.helpers.service import verify_domain_control 12 | from .tank import Tank 13 | from typing import Final, final 14 | import homeassistant.helpers.config_validation as cv 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 16 | 17 | CHARGE_SERVICE_SCHEMA: Final = make_entity_service_schema( 18 | {vol.Optional("target_percentage"): cv.positive_int} 19 | ) 20 | 21 | DOMAIN = "mixergy" 22 | PLATFORMS:list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.NUMBER] 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | async def async_setup(hass: HomeAssistant, config): 27 | _LOGGER.info("Setting up mixergy tank...") 28 | 29 | hass.data[DOMAIN] = {} 30 | 31 | return True 32 | 33 | async def async_setup_entry(hass: HomeAssistant, entry:ConfigEntry) -> bool: 34 | 35 | """Set up a tank from a config entry.""" 36 | 37 | tank = Tank(hass, entry.data[CONF_USERNAME],entry.data[CONF_PASSWORD],entry.data["serial_number"]) 38 | 39 | async def async_update_data(): 40 | _LOGGER.info("Fetching data from Mixergy...") 41 | await tank.fetch_data() 42 | 43 | # Create a coordinator to fetch data from the Mixergy API. 44 | coordinator = DataUpdateCoordinator(hass, _LOGGER, name="Mixergy", update_method = async_update_data, update_interval = timedelta(seconds=30)) 45 | await coordinator.async_config_entry_first_refresh() 46 | 47 | hass.data[DOMAIN][entry.entry_id] = { 48 | "tank": tank, 49 | "coordinator": coordinator, 50 | } 51 | 52 | _register_services(hass) 53 | 54 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 55 | 56 | return True 57 | 58 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): 59 | 60 | unload_ok = all( 61 | await asyncio.gather( 62 | *[ 63 | hass.config_entries.async_forward_entry_unload(entry, component) 64 | for component in PLATFORMS 65 | ] 66 | ) 67 | ) 68 | 69 | if unload_ok: 70 | hass.data[DOMAIN].pop(entry.entry_id) 71 | 72 | return unload_ok 73 | 74 | @core.callback 75 | def _register_services(hass): 76 | """Register Mixergy services.""" 77 | 78 | async def mixergy_set_charge(call): 79 | 80 | charge = call.data[ATTR_CHARGE] 81 | 82 | tasks = [ 83 | tank.set_target_charge(charge) 84 | for tank in [d["tank"] for d in hass.data[DOMAIN].values()] 85 | if isinstance(tank, Tank) 86 | ] 87 | 88 | results = await asyncio.gather(*tasks) 89 | 90 | # Note that we'll get a "None" value for a successful call 91 | if None not in results: 92 | _LOGGER.warning("The request to charge the tank did not succeed") 93 | 94 | async def mixergy_set_target_temperature(call): 95 | 96 | temperature = call.data[ATTR_TEMPERATURE] 97 | 98 | tasks = [ 99 | tank.set_target_temperature(temperature) 100 | for tank in [d["tank"] for d in hass.data[DOMAIN].values()] 101 | if isinstance(tank, Tank) 102 | ] 103 | 104 | results = await asyncio.gather(*tasks) 105 | 106 | # Note that we'll get a "None" value for a successful call 107 | if None not in results: 108 | _LOGGER.warning("The request to change the target temperature of the tank did not succeed") 109 | 110 | async def mixergy_set_holiday_dates(call): 111 | 112 | start_date = call.data[ATTR_START_DATE] 113 | end_date = call.data[ATTR_END_DATE] 114 | 115 | tasks = [ 116 | tank.set_holiday_dates(start_date, end_date) 117 | for tank in [d["tank"] for d in hass.data[DOMAIN].values()] 118 | if isinstance(tank, Tank) 119 | ] 120 | 121 | results = await asyncio.gather(*tasks) 122 | 123 | # Note that we'll get a "None" value for a successful call 124 | if None not in results: 125 | _LOGGER.warning("The request to change the holiday dates of the tank did not succeed") 126 | 127 | async def mixergy_clear_holiday_dates(call): 128 | 129 | tasks = [ 130 | tank.clear_holiday_dates() 131 | for tank in [d["tank"] for d in hass.data[DOMAIN].values()] 132 | if isinstance(tank, Tank) 133 | ] 134 | 135 | results = await asyncio.gather(*tasks) 136 | 137 | # Note that we'll get a "None" value for a successful call 138 | if None not in results: 139 | _LOGGER.warning("The request to clear the holiday dates of the tank did not succeed") 140 | 141 | async def mixergy_set_default_heat_source(call): 142 | 143 | heat_source = call.data[ATTR_HEAT_SOURCE] 144 | 145 | tasks = [ 146 | tank.set_default_heat_source(heat_source) 147 | for tank in [d["tank"] for d in hass.data[DOMAIN].values()] 148 | if isinstance(tank, Tank) 149 | ] 150 | 151 | results = await asyncio.gather(*tasks) 152 | 153 | # Note that we'll get a "None" value for a successful call 154 | if None not in results: 155 | _LOGGER.warning("The request to set the default heat source of the tank did not succeed") 156 | 157 | if not hass.services.has_service(DOMAIN, SERVICE_SET_CHARGE): 158 | # Register a local handler for scene activation 159 | hass.services.async_register( 160 | DOMAIN, 161 | SERVICE_SET_CHARGE, 162 | verify_domain_control(hass, DOMAIN)(mixergy_set_charge), 163 | schema=vol.Schema( 164 | { 165 | vol.Required(ATTR_CHARGE): cv.positive_int 166 | } 167 | ), 168 | ) 169 | 170 | if not hass.services.has_service(DOMAIN, SERVICE_SET_TARGET_TEMPERATURE): 171 | # Register a local handler for scene activation 172 | hass.services.async_register( 173 | DOMAIN, 174 | SERVICE_SET_TARGET_TEMPERATURE, 175 | verify_domain_control(hass, DOMAIN)(mixergy_set_target_temperature), 176 | schema=vol.Schema( 177 | { 178 | vol.Required(ATTR_TEMPERATURE): cv.positive_int 179 | } 180 | ), 181 | ) 182 | 183 | if not hass.services.has_service(DOMAIN, SERVICE_SET_HOLIDAY_DATES): 184 | # Register a local handler for scene activation 185 | hass.services.async_register( 186 | DOMAIN, 187 | SERVICE_SET_HOLIDAY_DATES, 188 | verify_domain_control(hass, DOMAIN)(mixergy_set_holiday_dates), 189 | schema=vol.Schema( 190 | { 191 | vol.Required(ATTR_START_DATE): cv.datetime, 192 | vol.Required(ATTR_END_DATE): cv.datetime 193 | } 194 | ), 195 | ) 196 | 197 | if not hass.services.has_service(DOMAIN, SERVICE_CLEAR_HOLIDAY_DATES): 198 | # Register a local handler for scene activation 199 | hass.services.async_register( 200 | DOMAIN, 201 | SERVICE_CLEAR_HOLIDAY_DATES, 202 | verify_domain_control(hass, DOMAIN)(mixergy_clear_holiday_dates), 203 | ) 204 | 205 | if not hass.services.has_service(DOMAIN, SERVICE_SET_DEFAULT_HEAT_SOURCE): 206 | hass.services.async_register( 207 | DOMAIN, 208 | SERVICE_SET_DEFAULT_HEAT_SOURCE, 209 | verify_domain_control(hass, DOMAIN)(mixergy_set_default_heat_source), 210 | schema=vol.Schema( 211 | { 212 | vol.Required(ATTR_HEAT_SOURCE): cv.string 213 | } 214 | ), 215 | ) 216 | -------------------------------------------------------------------------------- /custom_components/mixergy/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import timedelta 3 | from homeassistant.const import UnitOfPower, UnitOfTemperature, PERCENTAGE, STATE_OFF 4 | from homeassistant.core import HomeAssistant 5 | from homeassistant.components.sensor import SensorDeviceClass, SensorEntity 6 | from homeassistant.components.integration.sensor import IntegrationSensor 7 | from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity 8 | from .const import DOMAIN 9 | from .tank import Tank 10 | from .mixergy_entity import MixergyEntityBase 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | async def async_setup_entry(hass, config_entry, async_add_entities): 15 | _LOGGER.info("Setting up entry based on user config") 16 | 17 | entry = hass.data[DOMAIN][config_entry.entry_id] 18 | tank = entry["tank"] 19 | coordinator = entry["coordinator"] 20 | 21 | new_entities = [] 22 | 23 | new_entities.append(HotWaterTemperatureSensor(coordinator, tank)) 24 | new_entities.append(ColdestWaterTemperatureSensor(coordinator, tank)) 25 | new_entities.append(ChargeSensor(coordinator, tank)) 26 | new_entities.append(TargetChargeSensor(coordinator, tank)) 27 | new_entities.append(ElectricHeatSensor(coordinator, tank)) 28 | new_entities.append(IndirectHeatSensor(coordinator, tank)) 29 | new_entities.append(HeatPumpHeatSensor(coordinator,tank)) 30 | new_entities.append(LowChargeSensor(coordinator, tank)) 31 | new_entities.append(NoChargeSensor(coordinator, tank)) 32 | new_entities.append(PowerSensor(coordinator, tank)) 33 | new_entities.append(EnergySensor(hass, tank)) 34 | new_entities.append(TargetTemperatureSensor(coordinator, tank)) 35 | new_entities.append(HolidayModeSensor(coordinator, tank)) 36 | new_entities.append(PVPowerSensor(coordinator, tank)) 37 | new_entities.append(PVEnergySensor(hass, tank)) 38 | new_entities.append(ClampPowerSensor(coordinator, tank)) 39 | new_entities.append(IsChargingSensor(coordinator, tank)) 40 | new_entities.append(HolidayStartDateSensor(coordinator, tank)) 41 | new_entities.append(HolidayEndDateSensor(coordinator, tank)) 42 | new_entities.append(DefaultHeatSourceSensor(coordinator, tank)) 43 | 44 | async_add_entities(new_entities) 45 | 46 | class SensorBase(MixergyEntityBase, SensorEntity): 47 | 48 | def __init__(self, coordinator, tank:Tank): 49 | super().__init__(coordinator, tank) 50 | 51 | class BinarySensorBase(MixergyEntityBase, BinarySensorEntity): 52 | 53 | def __init__(self, coordinator, tank:Tank): 54 | super().__init__(coordinator, tank) 55 | 56 | class ChargeSensor(SensorBase): 57 | 58 | def __init__(self, coordinator, tank:Tank): 59 | super().__init__(coordinator, tank) 60 | 61 | @property 62 | def unique_id(self): 63 | return f"mixergy_{self._tank.serial_number}_charge" 64 | 65 | @property 66 | def unit_of_measurement(self): 67 | return PERCENTAGE 68 | 69 | @property 70 | def state(self): 71 | return self._tank.charge 72 | 73 | @property 74 | def icon(self): 75 | return "hass:water-percent" 76 | 77 | @property 78 | def name(self): 79 | return f"Current Charge" 80 | 81 | class TargetChargeSensor(SensorBase): 82 | 83 | def __init__(self, coordinator, tank:Tank): 84 | super().__init__(coordinator, tank) 85 | 86 | @property 87 | def unique_id(self): 88 | return f"mixergy_{self._tank.serial_number}_target_charge" 89 | 90 | @property 91 | def unit_of_measurement(self): 92 | return PERCENTAGE 93 | 94 | @property 95 | def state(self): 96 | return self._tank.target_charge 97 | 98 | @property 99 | def icon(self): 100 | return "hass:water-percent" 101 | 102 | @property 103 | def name(self): 104 | return f"Target Charge" 105 | 106 | class HotWaterTemperatureSensor(SensorBase): 107 | 108 | device_class = SensorDeviceClass.TEMPERATURE 109 | 110 | def __init__(self, coordinator, tank:Tank): 111 | super().__init__( coordinator, tank) 112 | 113 | @property 114 | def unique_id(self): 115 | return f"mixergy_{self._tank.serial_number}_hot_water_temperature" 116 | 117 | @property 118 | def state(self): 119 | return self._tank.hot_water_temperature 120 | 121 | @property 122 | def unit_of_measurement(self): 123 | return UnitOfTemperature.CELSIUS 124 | 125 | @property 126 | def name(self): 127 | return f"Hot Water Temperature" 128 | 129 | 130 | class ColdestWaterTemperatureSensor(SensorBase): 131 | 132 | device_class = SensorDeviceClass.TEMPERATURE 133 | 134 | def __init__(self, coordinator, tank:Tank): 135 | super().__init__(coordinator, tank) 136 | 137 | @property 138 | def unique_id(self): 139 | return f"mixergy_{self._tank.serial_number}_coldest_water_temperature" 140 | 141 | @property 142 | def state(self): 143 | return self._tank.coldest_water_temperature 144 | 145 | @property 146 | def unit_of_measurement(self): 147 | return UnitOfTemperature.CELSIUS 148 | 149 | @property 150 | def name(self): 151 | return f"Coldest Water Temperature" 152 | 153 | class TargetTemperatureSensor(SensorBase): 154 | 155 | device_class = SensorDeviceClass.TEMPERATURE 156 | 157 | def __init__(self, coordinator, tank:Tank): 158 | super().__init__(coordinator, tank) 159 | 160 | @property 161 | def unique_id(self): 162 | return f"mixergy_{self._tank.serial_number}_target_temperature" 163 | 164 | @property 165 | def state(self): 166 | return self._tank.target_temperature 167 | 168 | @property 169 | def unit_of_measurement(self): 170 | return UnitOfTemperature.CELSIUS 171 | 172 | @property 173 | def name(self): 174 | return f"Target Temperature" 175 | 176 | class IndirectHeatSensor(BinarySensorBase): 177 | 178 | device_class = BinarySensorDeviceClass.HEAT 179 | 180 | def __init__(self, coordinator, tank:Tank): 181 | super().__init__( coordinator, tank) 182 | 183 | @property 184 | def unique_id(self): 185 | return f"mixergy_{self._tank.serial_number}_indirect_heat" 186 | 187 | @property 188 | def is_on(self): 189 | return self._tank.indirect_heat_source 190 | 191 | @property 192 | def icon(self): 193 | return "mdi:fire" 194 | 195 | @property 196 | def name(self): 197 | return f"Indirect Heat" 198 | 199 | class ElectricHeatSensor(BinarySensorBase): 200 | 201 | device_class = SensorDeviceClass.ENERGY 202 | 203 | def __init__(self, coordinator, tank:Tank): 204 | super().__init__( coordinator, tank) 205 | self._state = STATE_OFF 206 | 207 | @property 208 | def unique_id(self): 209 | return f"mixergy_{self._tank.tank_id}_electic_heat" 210 | 211 | @property 212 | def is_on(self): 213 | return self._tank.electic_heat_source 214 | 215 | @property 216 | def name(self): 217 | return f"Electric Heat" 218 | 219 | class HeatPumpHeatSensor(BinarySensorBase): 220 | 221 | device_class = SensorDeviceClass.ENERGY 222 | 223 | def __init__(self, coordinator, tank:Tank): 224 | super().__init__( coordinator, tank) 225 | self._state = STATE_OFF 226 | 227 | @property 228 | def unique_id(self): 229 | return f"mixergy_{self._tank.tank_id}_heatpump_heat" 230 | 231 | @property 232 | def is_on(self): 233 | return self._tank.heatpump_heat_source 234 | 235 | @property 236 | def name(self): 237 | return f"HeatPump Heat" 238 | 239 | class NoChargeSensor(BinarySensorBase): 240 | 241 | def __init__(self, coordinator, tank:Tank): 242 | super().__init__( coordinator, tank) 243 | self._state = STATE_OFF 244 | 245 | @property 246 | def unique_id(self): 247 | return f"mixergy_{self._tank.tank_id}_no_charge" 248 | 249 | @property 250 | def is_on(self): 251 | return self._tank.charge < 0.5 252 | 253 | @property 254 | def icon(self): 255 | return "hass:water-remove-outline" 256 | 257 | @property 258 | def name(self): 259 | return f"No Hot Water" 260 | 261 | class LowChargeSensor(BinarySensorBase): 262 | 263 | def __init__(self, coordinator, tank:Tank): 264 | super().__init__( coordinator, tank) 265 | self._state = STATE_OFF 266 | 267 | @property 268 | def unique_id(self): 269 | return f"mixergy_{self._tank.tank_id}_low_charge" 270 | 271 | @property 272 | def is_on(self): 273 | return self._tank.charge < 5 274 | 275 | @property 276 | def icon(self): 277 | return "hass:water-percent-alert" 278 | 279 | @property 280 | def name(self): 281 | return f"Low Hot Water" 282 | 283 | class IsChargingSensor(BinarySensorBase): 284 | 285 | def __init__(self, coordinator, tank:Tank): 286 | super().__init__( coordinator, tank) 287 | self._state = STATE_OFF 288 | 289 | @property 290 | def unique_id(self): 291 | return f"mixergy_{self._tank.tank_id}_charging" 292 | 293 | @property 294 | def is_on(self): 295 | return self._tank.target_charge > 0 296 | 297 | @property 298 | def icon(self): 299 | return "hass:water-percent-alert" 300 | 301 | @property 302 | def name(self): 303 | return f"Is Charging" 304 | 305 | class PowerSensor(SensorBase): 306 | 307 | device_class = SensorDeviceClass.POWER 308 | state_class = "measurement" 309 | 310 | def __init__(self, coordinator, tank:Tank): 311 | super().__init__(coordinator,tank) 312 | self._state = 0 313 | 314 | @property 315 | def unique_id(self): 316 | return f"mixergy_{self._tank.tank_id}_power" 317 | 318 | @property 319 | def state(self): 320 | return 3300 if self._tank.electic_heat_source else 0 321 | 322 | @property 323 | def unit_of_measurement(self): 324 | return UnitOfPower.WATT 325 | 326 | @property 327 | def name(self): 328 | return f"Mixergy Electric Heat Power" 329 | 330 | class EnergySensor(IntegrationSensor): 331 | 332 | def __init__(self, hass: HomeAssistant, tank:Tank): 333 | super().__init__( 334 | hass = hass, 335 | name="Mixergy Electric Heat Energy", 336 | source_entity="sensor.mixergy_electric_heat_power", 337 | round_digits=2, 338 | unit_prefix="k", 339 | unit_time="h", 340 | integration_method="left", 341 | unique_id=f"mixergy_{tank.tank_id}_energy", 342 | max_sub_interval=None 343 | ) 344 | 345 | @property 346 | def icon(self): 347 | return "mdi:lightning-bolt" 348 | 349 | class PVPowerSensor(SensorBase): 350 | 351 | device_class = SensorDeviceClass.POWER 352 | state_class = "measurement" 353 | 354 | def __init__(self, coordinator, tank:Tank): 355 | super().__init__(coordinator,tank) 356 | self._state = 0 357 | 358 | @property 359 | def unique_id(self): 360 | return f"mixergy_{self._tank.tank_id}_pv_power" 361 | 362 | @property 363 | def state(self): 364 | return self._tank.pv_power 365 | 366 | @property 367 | def unit_of_measurement(self): 368 | return UnitOfPower.KILO_WATT 369 | 370 | @property 371 | def name(self): 372 | return f"Mixergy Electric PV Power" 373 | 374 | @property 375 | def available(self): 376 | return super().available and self._tank.has_pv_diverter 377 | 378 | class PVEnergySensor(IntegrationSensor): 379 | 380 | def __init__(self, hass: HomeAssistant, tank:Tank): 381 | super().__init__( 382 | hass = hass, 383 | name="Mixergy Electric PV Energy", 384 | source_entity="sensor.mixergy_electric_pv_power", 385 | round_digits=2, 386 | unit_prefix=None, # PVPowerSensor is already in kW 387 | unit_time="h", 388 | integration_method="left", 389 | unique_id=f"mixergy_{tank.tank_id}_pv_energy", 390 | max_sub_interval=None 391 | ) 392 | self._tank = tank 393 | 394 | @property 395 | def icon(self): 396 | return "mdi:lightning-bolt" 397 | 398 | @property 399 | def available(self): 400 | return self._tank.online and self._tank.has_pv_diverter 401 | 402 | class ClampPowerSensor(SensorBase): 403 | 404 | device_class = SensorDeviceClass.POWER 405 | state_class = "measurement" 406 | 407 | def __init__(self, coordinator, tank:Tank): 408 | super().__init__(coordinator,tank) 409 | self._state = 0 410 | 411 | @property 412 | def unique_id(self): 413 | return f"mixergy_{self._tank.tank_id}_clamp_power" 414 | 415 | @property 416 | def state(self): 417 | return self._tank.clamp_power 418 | 419 | @property 420 | def unit_of_measurement(self): 421 | return UnitOfPower.WATT 422 | 423 | @property 424 | def name(self): 425 | return f"Clamp Power" 426 | 427 | @property 428 | def available(self): 429 | return super().available and self._tank.has_pv_diverter 430 | 431 | class HolidayModeSensor(BinarySensorBase): 432 | 433 | def __init__(self, coordinator, tank:Tank): 434 | super().__init__( coordinator, tank) 435 | self._state = STATE_OFF 436 | 437 | @property 438 | def unique_id(self): 439 | return f"mixergy_{self._tank.tank_id}_holiday_mode" 440 | 441 | @property 442 | def is_on(self): 443 | return self._tank.in_holiday_mode 444 | 445 | @property 446 | def icon(self): 447 | return "mdi:airplane-takeoff" 448 | 449 | @property 450 | def name(self): 451 | return f"Holiday Mode" 452 | 453 | class HolidayStartDateSensor(SensorBase): 454 | 455 | device_class = SensorDeviceClass.TIMESTAMP 456 | 457 | def __init__(self, coordinator, tank:Tank): 458 | super().__init__(coordinator,tank) 459 | self._state = None 460 | 461 | @property 462 | def unique_id(self): 463 | return f"mixergy_{self._tank.tank_id}_holiday_date_start" 464 | 465 | @property 466 | def state(self): 467 | return self._tank.holiday_date_start 468 | 469 | @property 470 | def name(self): 471 | return f"Holiday Date Start" 472 | 473 | class HolidayEndDateSensor(SensorBase): 474 | 475 | device_class = SensorDeviceClass.TIMESTAMP 476 | 477 | def __init__(self, coordinator, tank:Tank): 478 | super().__init__(coordinator,tank) 479 | self._state = None 480 | 481 | @property 482 | def unique_id(self): 483 | return f"mixergy_{self._tank.tank_id}_holiday_date_end" 484 | 485 | @property 486 | def state(self): 487 | return self._tank.holiday_date_end 488 | 489 | @property 490 | def name(self): 491 | return f"Holiday Date End" 492 | 493 | class DefaultHeatSourceSensor(SensorBase): 494 | 495 | device_class = SensorDeviceClass.ENUM 496 | 497 | def __init__(self, coordinator, tank:Tank): 498 | super().__init__(coordinator,tank) 499 | self._state = None 500 | 501 | @property 502 | def unique_id(self): 503 | return f"mixergy_{self._tank.tank_id}_default_heat_source" 504 | 505 | @property 506 | def state(self): 507 | return self._tank.default_heat_source 508 | 509 | @property 510 | def name(self): 511 | return f"Default Heat Source" 512 | -------------------------------------------------------------------------------- /custom_components/mixergy/tank.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import json 4 | from datetime import datetime 5 | from typing import Optional 6 | from homeassistant.helpers import aiohttp_client 7 | from .const import ATTR_CHARGE 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | ROOT_ENDPOINT = "https://www.mixergy.io/api/v2" 12 | 13 | class TankUrls: 14 | def __init__(self, account_url): 15 | self.account_url = account_url 16 | 17 | class Tank: 18 | 19 | manufacturer = "Mixergy Ltd" 20 | 21 | def __init__(self, hass, username, password, serial_number): 22 | self._id = serial_number.lower() 23 | self.username = username 24 | self.password = password 25 | self.serial_number = serial_number.upper() 26 | self._hass = hass 27 | self._callbacks = set() 28 | self._loop = asyncio.get_event_loop() 29 | self._hot_water_temperature = -1 30 | self._coldest_water_temperature = -1 31 | self._charge = -1 32 | self._target_charge = 0 33 | self._indirect_heat_source = False 34 | self._electric_heat_source = False 35 | self._heatpump_heat_source = False 36 | self._hasFetched = False 37 | self._token = "" 38 | self._latest_measurement_url = "" 39 | self.model = "" 40 | self.firmware_version = "0.0.0" 41 | self._target_temperature = -1 42 | self._dsr_enabled = False 43 | self._frost_protection_enabled = False 44 | self._distributed_computing_enabled = False 45 | self._cleansing_temperature = 0 46 | self._in_holiday_mode = False 47 | self._pv_power = 0 48 | self._clamp_power = 0 49 | self._has_pv_diverter = False 50 | self._divert_exported_enabled = False 51 | self._pv_cut_in_threshold = 0 52 | self._pv_charge_limit = 0 53 | self._pv_target_current = 0 54 | self._pv_over_temperature = 0 55 | self._schedule = None 56 | 57 | @property 58 | def tank_id(self): 59 | return self._id 60 | 61 | async def test_authentication(self): 62 | return await self.authenticate() 63 | 64 | async def test_connection(self): 65 | return await self.fetch_tank_information() 66 | 67 | async def set_target_charge(self, charge): 68 | 69 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 70 | 71 | headers = {'Authorization': f'Bearer {self._token}'} 72 | 73 | async with session.put(self._control_url, headers=headers, json={'charge': charge }) as resp: 74 | 75 | if resp.status != 200: 76 | _LOGGER.error("Call to %s to set the desired charge failed with status %i", self._control_url, resp.status) 77 | return 78 | 79 | await self.fetch_last_measurement() 80 | 81 | async def set_target_temperature(self, temperature): 82 | 83 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 84 | 85 | headers = {'Authorization': f'Bearer {self._token}'} 86 | 87 | async with session.put(self._settings_url, headers=headers, json={'max_temp': temperature }) as resp: 88 | 89 | if resp.status != 200: 90 | _LOGGER.error("Call to %s to set the target temperature failed with status %i", self._settings_url, resp.status) 91 | return 92 | 93 | await self.fetch_settings() 94 | 95 | async def set_dsr_enabled(self, enabled): 96 | 97 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 98 | 99 | headers = {'Authorization': f'Bearer {self._token}'} 100 | 101 | async with session.put(self._settings_url, headers=headers, json={'dsr_enabled': enabled }) as resp: 102 | 103 | if resp.status != 200: 104 | _LOGGER.error("Call to %s to set dsr (grid assistance) enabled failed with status %i", self._settings_url, resp.status) 105 | return 106 | 107 | await self.fetch_settings() 108 | 109 | async def set_frost_protection_enabled(self, enabled): 110 | 111 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 112 | 113 | headers = {'Authorization': f'Bearer {self._token}'} 114 | 115 | async with session.put(self._settings_url, headers=headers, json={'frost_protection_enabled': enabled }) as resp: 116 | 117 | if resp.status != 200: 118 | _LOGGER.error("Call to %s to set frost protection enabled failed with status %i", self._settings_url, resp.status) 119 | return 120 | 121 | await self.fetch_settings() 122 | 123 | async def set_distributed_computing_enabled(self, enabled): 124 | 125 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 126 | 127 | headers = {'Authorization': f'Bearer {self._token}'} 128 | 129 | async with session.put(self._settings_url, headers=headers, json={'distributed_computing_enabled': enabled }) as resp: 130 | 131 | if resp.status != 200: 132 | _LOGGER.error("Call to %s to set distributed computing (medical research) enabled failed with status %i", self._settings_url, resp.status) 133 | return 134 | 135 | await self.fetch_settings() 136 | 137 | async def set_cleansing_temperature(self, value): 138 | 139 | # Ensure values are within correct range 140 | value = min(value, 55) 141 | value = max(value, 51) 142 | 143 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 144 | 145 | headers = {'Authorization': f'Bearer {self._token}'} 146 | 147 | async with session.put(self._settings_url, headers=headers, json={'cleansing_temperature': value }) as resp: 148 | 149 | if resp.status != 200: 150 | _LOGGER.error("Call to %s to set cleansing temperature failed with status %i", self._settings_url, resp.status) 151 | return 152 | 153 | await self.fetch_settings() 154 | 155 | async def set_divert_exported_enabled(self, enabled): 156 | 157 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 158 | 159 | headers = {'Authorization': f'Bearer {self._token}'} 160 | 161 | async with session.put(self._settings_url, headers=headers, json={'divert_exported_enabled': enabled }) as resp: 162 | 163 | if resp.status != 200: 164 | _LOGGER.error("Call to %s to set divert export enabled failed with status %i", self._settings_url, resp.status) 165 | return 166 | 167 | await self.fetch_settings() 168 | 169 | async def set_pv_cut_in_threshold(self, value): 170 | 171 | # Ensure values are within correct range 172 | value = min(value, 500) 173 | value = max(value, 0) 174 | 175 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 176 | 177 | headers = {'Authorization': f'Bearer {self._token}'} 178 | 179 | async with session.put(self._settings_url, headers=headers, json={'pv_cut_in_threshold': value }) as resp: 180 | 181 | if resp.status != 200: 182 | _LOGGER.error("Call to %s to set PV cut in threshold failed with status %i", self._control_url, resp.status) 183 | return 184 | 185 | await self.fetch_settings() 186 | 187 | async def set_pv_charge_limit(self, value): 188 | 189 | # Ensure values are within correct range 190 | value = min(value, 100) 191 | value = max(value, 0) 192 | 193 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 194 | 195 | headers = {'Authorization': f'Bearer {self._token}'} 196 | 197 | async with session.put(self._settings_url, headers=headers, json={'pv_charge_limit': value }) as resp: 198 | 199 | if resp.status != 200: 200 | _LOGGER.error("Call to %s to set PV charge limit failed with status %i", self._control_url, resp.status) 201 | return 202 | 203 | await self.fetch_settings() 204 | 205 | async def set_pv_target_current(self, value): 206 | 207 | # Ensure values are within correct range 208 | value = min(value, 0) 209 | value = max(value, -1) 210 | 211 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 212 | 213 | headers = {'Authorization': f'Bearer {self._token}'} 214 | 215 | async with session.put(self._settings_url, headers=headers, json={'pv_target_current': value }) as resp: 216 | 217 | if resp.status != 200: 218 | _LOGGER.error("Call to %s to set PV target current failed with status %i", self._control_url, resp.status) 219 | return 220 | 221 | await self.fetch_settings() 222 | 223 | async def set_pv_over_temperature(self, value): 224 | 225 | # Ensure values are within correct range 226 | value = min(value, 60) 227 | value = max(value, 45) 228 | 229 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 230 | 231 | headers = {'Authorization': f'Bearer {self._token}'} 232 | 233 | async with session.put(self._settings_url, headers=headers, json={'pv_over_temperature': value }) as resp: 234 | 235 | if resp.status != 200: 236 | _LOGGER.error("Call to %s to set PV over temperature failed with status %i", self._control_url, resp.status) 237 | return 238 | 239 | await self.fetch_settings() 240 | 241 | async def authenticate(self): 242 | 243 | if self._token: 244 | _LOGGER.info("Authentication token is valid") 245 | return 246 | 247 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 248 | 249 | async with session.get(ROOT_ENDPOINT) as resp: 250 | 251 | if resp.status != 200: 252 | _LOGGER.error("Fetch of root at %s failed with status code %i", ROOT_ENDPOINT, resp.status) 253 | return False 254 | 255 | root_result = await resp.json() 256 | 257 | self._account_url = root_result["_links"]["account"]["href"] 258 | 259 | _LOGGER.info("Account URL: %s", self._account_url) 260 | 261 | async with session.get(self._account_url) as resp: 262 | 263 | if resp.status != 200: 264 | _LOGGER.error("Fetch of account at %s failed with status code %i", self._account_url, resp.status) 265 | return False 266 | 267 | account_result = await resp.json() 268 | 269 | self._login_url = account_result["_links"]["login"]["href"] 270 | 271 | _LOGGER.info("Login URL: %s", self._login_url) 272 | 273 | async with session.post(self._login_url, json={'username': self.username, 'password': self.password}) as resp: 274 | 275 | if resp.status != 201: 276 | _LOGGER.error("Authentication failed with status code %i", resp.status) 277 | return False 278 | 279 | login_result = await resp.json() 280 | token = login_result['token'] 281 | self._token = token 282 | return True 283 | 284 | async def fetch_tank_information(self): 285 | 286 | if self._latest_measurement_url: 287 | _LOGGER.info("Tank information has already been fetched") 288 | return 289 | 290 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 291 | 292 | headers = {'Authorization': f'Bearer {self._token}'} 293 | 294 | async with session.get(ROOT_ENDPOINT, headers=headers) as resp: 295 | 296 | if resp.status != 200: 297 | _LOGGER.error("Fetch of root at %s failed with status code %i", ROOT_ENDPOINT, resp.status) 298 | return False 299 | 300 | root_result = await resp.json() 301 | 302 | self._tanks_url = root_result["_links"]["tanks"]["href"] 303 | 304 | async with session.get(self._tanks_url, headers=headers) as resp: 305 | 306 | if resp.status != 200: 307 | _LOGGER.error("Fetch of tanks at %s failed with status code %i", self._tanks_url, resp.status) 308 | return False 309 | 310 | tank_result = await resp.json() 311 | 312 | tanks = tank_result['_embedded']['tankList'] 313 | 314 | _LOGGER.debug(tanks) 315 | 316 | tank = None 317 | 318 | for i, subjobj in enumerate(tanks): 319 | if self.serial_number == subjobj['serialNumber']: 320 | _LOGGER.info("Found a tank with matching serial number %s!", self.serial_number) 321 | tank = subjobj 322 | break 323 | 324 | if not tank: 325 | _LOGGER.error("Could not find a tank with the serial number %s", self.serial_number) 326 | return False 327 | 328 | tank_url = tank["_links"]["self"]["href"] 329 | self.firmwareVersion = tank["firmwareVersion"] 330 | 331 | async with session.get(tank_url, headers=headers) as resp: 332 | 333 | if resp.status != 200: 334 | _LOGGER.error("Fetch of the tanks details at %s failed with status %i", tank_url, resp.status) 335 | return False 336 | 337 | tank_url_result = await resp.json() 338 | 339 | _LOGGER.debug(tank_url_result) 340 | 341 | self._latest_measurement_url = tank_url_result["_links"]["latest_measurement"]["href"] 342 | self._control_url = tank_url_result["_links"]["control"]["href"] 343 | self._settings_url = tank_url_result["_links"]["settings"]["href"] 344 | self._schedule_url = tank_url_result["_links"]["schedule"]["href"] 345 | 346 | self.modelCode = tank_url_result["tankModelCode"] 347 | 348 | tank_configuration_json = tank_url_result["configuration"] 349 | tank_configuration = json.loads(tank_configuration_json) 350 | 351 | # Some tanks do not return a mixergyPvType - so force to NO_INVERTER 352 | tank_configuration_pvtype = tank_configuration.get("mixergyPvType", "NO_INVERTER") 353 | self._has_pv_diverter = (tank_configuration_pvtype != "NO_INVERTER") 354 | 355 | _LOGGER.debug("Measurement URL is %s", self._latest_measurement_url) 356 | _LOGGER.debug("Control URL is %s", self._control_url) 357 | _LOGGER.debug("Settings URL is %s", self._settings_url) 358 | _LOGGER.debug("Schedule URL is %s", self._schedule_url) 359 | 360 | return True 361 | 362 | async def fetch_last_measurement(self): 363 | 364 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 365 | 366 | headers = {'Authorization': f'Bearer {self._token}'} 367 | 368 | async with session.get(self._latest_measurement_url, headers=headers) as resp: 369 | 370 | if resp.status != 200: 371 | _LOGGER.info("Fetch of the latest measurement at %s failed with status %i", self._latest_measurement_url, resp.status) 372 | return 373 | 374 | tank_result = await resp.json() 375 | _LOGGER.debug(tank_result) 376 | 377 | self._hot_water_temperature = tank_result["topTemperature"] 378 | self._coldest_water_temperature = tank_result["bottomTemperature"] 379 | 380 | if "pvEnergy" in tank_result: 381 | self._pv_power = tank_result["pvEnergy"] / 60000 382 | else: 383 | self._pv_power = 0 384 | 385 | if "clampPower" in tank_result: 386 | self._clamp_power = tank_result["clampPower"] 387 | else: 388 | self._clamp_power = 0 389 | 390 | new_charge = tank_result["charge"] 391 | 392 | _LOGGER.debug("Current: %f", self._charge) 393 | _LOGGER.debug("New: %f", new_charge) 394 | 395 | if new_charge != self._charge: 396 | _LOGGER.debug('Sending charge_changed event') 397 | 398 | event_data = { 399 | "device_id": self._id, 400 | "type": "charge_changed", 401 | "charge" : new_charge 402 | } 403 | 404 | self._hass.bus.async_fire("mixergy_event", event_data) 405 | 406 | self._charge = new_charge 407 | 408 | # Fetch information about the current state of the heating. 409 | 410 | state = json.loads(tank_result["state"]) 411 | 412 | current = state["current"] 413 | 414 | new_target_charge = 0 415 | 416 | if "target" in current: 417 | new_target_charge = current["target"] 418 | else: 419 | new_target_charge = 0 420 | 421 | self._target_charge = new_target_charge 422 | 423 | vacation = False 424 | 425 | # Source is only present when vacation is enabled it seems 426 | if "source" in current: 427 | source = current["source"] 428 | vacation = source == "Vacation" 429 | 430 | if vacation: 431 | self._in_holiday_mode = True 432 | 433 | # Assume it's all off as the tank is in holiday mode 434 | self._electric_heat_source = False 435 | self._heatpump_heat_source = False 436 | self._indirect_heat_source = False 437 | 438 | else: 439 | self._in_holiday_mode = False 440 | 441 | heat_source = current["heat_source"].lower() 442 | heat_source_on = current["immersion"].lower() == "on" 443 | 444 | if heat_source == "indirect": 445 | self._electric_heat_source = False 446 | self._heatpump_heat_source = False 447 | self._indirect_heat_source = heat_source_on 448 | 449 | elif heat_source == "electric": 450 | self._electric_heat_source = heat_source_on 451 | self._indirect_heat_source = False 452 | self._heatpump_heat_source = False 453 | 454 | elif heat_source == "heatpump": 455 | self._heatpump_heat_source = heat_source_on 456 | self._indirect_heat_source = False 457 | self._electric_heat_source = False 458 | 459 | else: 460 | self._indirect_heat_source = False 461 | self._electric_heat_source = False 462 | self._heatpump_heat_source = False 463 | 464 | async def fetch_settings(self): 465 | 466 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 467 | 468 | headers = {'Authorization': f'Bearer {self._token}'} 469 | 470 | async with session.get(self._settings_url, headers=headers) as resp: 471 | 472 | if resp.status != 200: 473 | _LOGGER.info("Fetch of the settings %s failed with status %i", self._settings_url, resp.status) 474 | return 475 | 476 | # The settings API returns text/plain as the content-type, so using the resp.json() fails. 477 | # Load it as a bit of JSON via the text. 478 | response_text = await resp.text() 479 | json_object = json.loads(response_text) 480 | _LOGGER.debug(json_object) 481 | 482 | self._target_temperature = json_object["max_temp"] 483 | self._dsr_enabled = json_object["dsr_enabled"] 484 | self._frost_protection_enabled = json_object["frost_protection_enabled"] 485 | self._distributed_computing_enabled = json_object["distributed_computing_enabled"] 486 | self._cleansing_temperature = json_object["cleansing_temperature"] 487 | 488 | try: 489 | self._divert_exported_enabled = json_object["divert_exported_enabled"] 490 | self._pv_charge_limit = json_object["pv_charge_limit"] 491 | self._pv_cut_in_threshold = json_object["pv_cut_in_threshold"] 492 | self._pv_target_current = json_object["pv_target_current"] 493 | self._pv_over_temperature = json_object["pv_over_temperature"] 494 | except KeyError: 495 | pass 496 | 497 | async def fetch_schedule(self): 498 | 499 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 500 | 501 | headers = {'Authorization': f'Bearer {self._token}'} 502 | 503 | async with session.get(self._schedule_url, headers=headers) as resp: 504 | 505 | if resp.status != 200: 506 | _LOGGER.info("Fetch of the schedule %s failed with status %i", self._schedule_url, resp.status) 507 | return 508 | 509 | # The schedule API returns text/plain as the content-type, so using the resp.json() fails. 510 | # Load it as a bit of JSON via the text. 511 | response_text = await resp.text() 512 | json_object = json.loads(response_text) 513 | _LOGGER.debug(json_object) 514 | 515 | self._schedule = json_object 516 | 517 | async def set_schedule(self, value): 518 | 519 | session = aiohttp_client.async_get_clientsession(self._hass, verify_ssl=False) 520 | 521 | headers = {'Authorization': f'Bearer {self._token}'} 522 | 523 | async with session.put(self._schedule_url, headers=headers, json=value) as resp: 524 | 525 | if resp.status != 200: 526 | _LOGGER.error("Call to %s to set schedule failed with status %i", self._schedule_url, resp.status) 527 | return 528 | 529 | await self.fetch_schedule() 530 | 531 | async def set_holiday_dates(self, start_date: datetime, end_date: datetime): 532 | 533 | await self.fetch_schedule() 534 | 535 | schedule = self._schedule 536 | 537 | if schedule == None: 538 | _LOGGER.error("Tried to set holiday dates but no schedule to set") 539 | return 540 | 541 | schedule["holiday"] = { 542 | "departDate": int(start_date.timestamp()) * 1000, 543 | "returnDate": int(end_date.timestamp()) * 1000 544 | } 545 | 546 | await self.set_schedule(schedule) 547 | 548 | await self.publish_updates() 549 | 550 | async def clear_holiday_dates(self): 551 | 552 | await self.fetch_schedule() 553 | 554 | schedule = self._schedule 555 | 556 | if schedule == None: 557 | _LOGGER.error("Tried to clear holiday dates but no schedule to set") 558 | return 559 | 560 | # Remove the holiday settings from the payload and send it back. 561 | schedule.pop("holiday", None) 562 | 563 | await self.set_schedule(schedule) 564 | 565 | await self.publish_updates() 566 | 567 | async def set_default_heat_source(self, heat_source): 568 | 569 | await self.fetch_schedule() 570 | 571 | schedule = self._schedule 572 | 573 | if schedule == None: 574 | _LOGGER.error("Tried to set the default heat source, but failed to fetch the schedule") 575 | return 576 | 577 | schedule["defaultHeatSource"] = heat_source 578 | 579 | await self.set_schedule(schedule) 580 | 581 | await self.publish_updates() 582 | 583 | async def fetch_data(self): 584 | 585 | _LOGGER.info('Fetching data....') 586 | 587 | await self.authenticate() 588 | 589 | await self.fetch_tank_information() 590 | 591 | await self.fetch_last_measurement() 592 | 593 | await self.fetch_settings() 594 | 595 | await self.fetch_schedule() 596 | 597 | await self.publish_updates() 598 | 599 | def register_callback(self, callback): 600 | self._callbacks.add(callback) 601 | 602 | def remove_callback(self, callback): 603 | self._callbacks.discard(callback) 604 | 605 | async def publish_updates(self): 606 | for callback in self._callbacks: 607 | callback() 608 | 609 | @property 610 | def online(self): 611 | return True 612 | 613 | @property 614 | def hot_water_temperature(self): 615 | return self._hot_water_temperature 616 | 617 | @property 618 | def coldest_water_temperature(self): 619 | return self._coldest_water_temperature 620 | 621 | @property 622 | def charge(self): 623 | return self._charge 624 | 625 | @property 626 | def target_charge(self): 627 | return self._target_charge 628 | 629 | @property 630 | def indirect_heat_source(self): 631 | return self._indirect_heat_source 632 | 633 | @property 634 | def electic_heat_source(self): 635 | return self._electric_heat_source 636 | 637 | @property 638 | def in_holiday_mode(self): 639 | return self._in_holiday_mode 640 | 641 | @property 642 | def heatpump_heat_source(self): 643 | return self._heatpump_heat_source 644 | 645 | @property 646 | def target_temperature(self): 647 | return self._target_temperature 648 | 649 | @property 650 | def dsr_enabled(self): 651 | return self._dsr_enabled 652 | 653 | @property 654 | def frost_protection_enabled(self): 655 | return self._frost_protection_enabled 656 | 657 | @property 658 | def distributed_computing_enabled(self): 659 | return self._distributed_computing_enabled 660 | 661 | @property 662 | def cleansing_temperature(self): 663 | return self._cleansing_temperature 664 | 665 | @property 666 | def pv_power(self): 667 | return self._pv_power 668 | 669 | @property 670 | def clamp_power(self): 671 | return self._clamp_power 672 | 673 | @property 674 | def has_pv_diverter(self): 675 | return self._has_pv_diverter 676 | 677 | @property 678 | def divert_exported_enabled(self): 679 | return self._divert_exported_enabled 680 | 681 | @property 682 | def pv_cut_in_threshold(self): 683 | return self._pv_cut_in_threshold 684 | 685 | @property 686 | def pv_charge_limit(self): 687 | return self._pv_charge_limit 688 | 689 | @property 690 | def pv_target_current(self): 691 | return self._pv_target_current 692 | 693 | @property 694 | def pv_over_temperature(self): 695 | return self._pv_over_temperature 696 | 697 | @property 698 | def holiday_date_start(self) -> Optional[datetime]: 699 | try: 700 | return datetime.fromtimestamp(self._schedule["holiday"]["departDate"] / 1000) 701 | except KeyError: 702 | return None 703 | except TypeError: 704 | return None 705 | 706 | @property 707 | def holiday_date_end(self) -> Optional[datetime]: 708 | try: 709 | return datetime.fromtimestamp(self._schedule["holiday"]["returnDate"] / 1000) 710 | except KeyError: 711 | return None 712 | except TypeError: 713 | return None 714 | 715 | @property 716 | def default_heat_source(self) -> str: 717 | try: 718 | return self._schedule["defaultHeatSource"] 719 | except KeyError: 720 | return None 721 | except TypeError: 722 | return None --------------------------------------------------------------------------------