├── custom_components ├── __init__.py └── watts_vision │ ├── manifest.json │ ├── translations │ ├── en.json │ └── nl.json │ ├── const.py │ ├── __init__.py │ ├── central_unit.py │ ├── binary_sensor.py │ ├── config_flow.py │ ├── watts_api.py │ ├── sensor.py │ └── climate.py ├── hacs.json ├── requirements.test.txt ├── tests ├── bandit.yaml ├── test_init.py ├── __init__.py └── test_config_flow.py ├── .github └── workflows │ ├── hassfest.yml │ └── hacs_action.yml ├── README.md ├── .pre-commit-config.yaml ├── setup.cfg └── .gitignore /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Watts Vision", 3 | "render_readme": true 4 | } 5 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov==2.9.0 3 | pytest-homeassistant-custom-component 4 | pre-commit 5 | -------------------------------------------------------------------------------- /tests/bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B108 5 | - B306 6 | - B307 7 | - B313 8 | - B314 9 | - B315 10 | - B316 11 | - B317 12 | - B318 13 | - B319 14 | - B320 15 | - B325 16 | - B602 17 | - B604 18 | -------------------------------------------------------------------------------- /.github/workflows/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 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test component setup.""" 2 | # from homeassistant.core import HomeAssistant 3 | # from homeassistant.setup import async_setup_component 4 | 5 | # from custom_components.watts_vision.const import DOMAIN 6 | 7 | 8 | # async def test_async_setup(hass: HomeAssistant): 9 | # """Test the component gets setup.""" 10 | # assert await async_setup_component(hass, DOMAIN, {}) is True 11 | -------------------------------------------------------------------------------- /.github/workflows/hacs_action.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 | - uses: "actions/checkout@v2" 15 | - name: HACS Action 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | -------------------------------------------------------------------------------- /custom_components/watts_vision/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "watts_vision", 3 | "name": "Watts Vision", 4 | "codeowners": [ 5 | "@pwesters", 6 | "@nowarries", 7 | "@mirakels" 8 | ], 9 | "config_flow": true, 10 | "dependencies": [], 11 | "documentation": "https://github.com/pwesters/watts_vision", 12 | "integration_type": "hub", 13 | "iot_class": "cloud_polling", 14 | "issue_tracker": "https://github.com/pwesters/watts_vision/issues", 15 | "requirements": [], 16 | "version": "0.4.7" 17 | } 18 | -------------------------------------------------------------------------------- /custom_components/watts_vision/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Watts Vision", 3 | "config": { 4 | "error": { 5 | "invalid_auth": "Email and/or password invalid.", 6 | "unknown": "Unexpected exception occurred.", 7 | "username_exists": "An account with the provided email is already being tracked." 8 | }, 9 | "step": { 10 | "user": { 11 | "data": { 12 | "username": "Email", 13 | "password": "Password" 14 | }, 15 | "description": "Connect to the Watts Vision API" 16 | } 17 | } 18 | }, 19 | "options": { 20 | "error": { 21 | "invalid_auth": "Email and/or password invalid.", 22 | "unknown": "Unexpected exception occurred.", 23 | "username_exists": "An account with the provided email is already being tracked." 24 | }, 25 | "step": { 26 | "init": { 27 | "data": { 28 | "username": "Email", 29 | "password": "Password" 30 | }, 31 | "title": "Watts Vision - Account reconfiguration", 32 | "description": "Reconfigure account details and reconnect to the Watts Vision API" 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /custom_components/watts_vision/const.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from homeassistant.components.climate.const import ( 4 | PRESET_BOOST, 5 | PRESET_COMFORT, 6 | PRESET_ECO, 7 | ) 8 | 9 | API_CLIENT = "api" 10 | 11 | DOMAIN = "watts_vision" 12 | 13 | PRESET_DEFROST = "Frost Protection" 14 | PRESET_OFF = "Off" 15 | PRESET_PROGRAM_ON = "Program on" 16 | PRESET_PROGRAM_OFF = "Program off" 17 | 18 | PRESET_MODE_MAP = { 19 | "0": PRESET_COMFORT, 20 | "1": PRESET_OFF, 21 | "2": PRESET_DEFROST, 22 | "3": PRESET_ECO, 23 | "4": PRESET_BOOST, 24 | "8": PRESET_PROGRAM_ON, 25 | "11": PRESET_PROGRAM_OFF, 26 | } 27 | 28 | PRESET_MODE_REVERSE_MAP = { 29 | PRESET_COMFORT: "0", 30 | PRESET_OFF: "1", 31 | PRESET_DEFROST: "2", 32 | PRESET_ECO: "3", 33 | PRESET_BOOST: "4", 34 | PRESET_PROGRAM_ON: "8", 35 | PRESET_PROGRAM_OFF: "11", 36 | } 37 | 38 | SCAN_INTERVAL = timedelta(seconds=120) 39 | 40 | NO_ISSUES = "No issues" 41 | DEF_BAT_TH = "Battery failure" 42 | 43 | ERROR_MAP = { 44 | 0: NO_ISSUES, 45 | 1: DEF_BAT_TH 46 | } 47 | 48 | ERROR_REVERSE_MAP = { 49 | NO_ISSUES: 0, 50 | DEF_BAT_TH: 1 51 | } 52 | -------------------------------------------------------------------------------- /custom_components/watts_vision/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Watts Vision", 3 | "config": { 4 | "error": { 5 | "invalid_auth": "E-mail en/of wachtwoord ongeldig.", 6 | "unknown": "Er is een onverwachte uitzondering opgetreden.", 7 | "username_exists": "Een account met het opgegeven e-mailadres is al toegevoegd." 8 | }, 9 | "step": { 10 | "user": { 11 | "data": { 12 | "username": "Email", 13 | "password": "Password" 14 | }, 15 | "description": "Verbinding maken met de Watts Visie API" 16 | } 17 | } 18 | }, 19 | "options": { 20 | "error": { 21 | "invalid_auth": "E-mail en/of wachtwoord ongeldig.", 22 | "unknown": "Er is een onverwachte uitzondering opgetreden.", 23 | "username_exists": "Een account met het opgegeven e-mailadres is al toegevoegd." 24 | }, 25 | "step": { 26 | "init": { 27 | "data": { 28 | "username": "Email", 29 | "password": "Wachtwoord" 30 | }, 31 | "title": "Watts Vision - Account herconfiguratie", 32 | "description": "Accountgegevens opnieuw configureren en opnieuw verbinden met de Watts Visie API" 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Testing for Watts Vision Component.""" 2 | # import json 3 | # from unittest.mock import patch 4 | 5 | # from homeassistant.const import CONF_HOST, CONF_TYPE 6 | # from homeassistant.core import HomeAssistant 7 | # from pytest_homeassistant_custom_component.common import ( # , load_fixture 8 | # MockConfigEntry, 9 | # ) 10 | 11 | # from custom_components.watts_vision.const import DOMAIN 12 | 13 | 14 | # async def init_integration(hass: HomeAssistant, skip_setup=False) -> MockConfigEntry: 15 | # self.async_create_entry(title="Watts Vision", data=user_input) 16 | 17 | # """Set up the Brother integration in Home Assistant.""" 18 | # entry = MockConfigEntry( 19 | # domain=DOMAIN, 20 | # title="HL-L2340DW 0123456789", 21 | # unique_id="0123456789", 22 | # data={CONF_HOST: "localhost", CONF_TYPE: "laser"}, 23 | # ) 24 | 25 | # entry.add_to_hass(hass) 26 | 27 | # # if not skip_setup: 28 | # # with patch( 29 | # # "brother.Brother._get_data", 30 | # # return_value=json.loads(load_fixture("printer_data.json", "brother")), 31 | # # ): 32 | # # await hass.config_entries.async_setup(entry.entry_id) 33 | # # await hass.async_block_till_done() 34 | 35 | # return entry 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub release](https://img.shields.io/github/release/pwesters/watts_vision.svg) [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) 2 | 3 | # Watts Vision for Home Assistant 4 | 5 | These are my first steps in creating an add on for home assistant and learning python. There's a lot left to do, including: 6 | - All the things that aren't default options, like program, stop boost, etc. 7 | 8 | I'm learning by doing. Please be kind. 9 | 10 | ## Requirements 11 | A Watts Vision system Cental unit is required to be able to see the settings remotely. See [Watts Vision Smart Home](https://wattswater.eu/catalog/regulation-and-control/watts-vision-smart-home/) and watch the [guide on youtube (Dutch)](https://www.youtube.com/watch?v=BLNqxkH7Td8). 12 | 13 | ## HACS 14 | 15 | Add https://github.com/pwesters/watts_vision to the custom repositories in HACS. A new repository will be found. Click Download and restart Home Assistant. Go to Settings and then to Devices & Services. Click + Add Integration and search for Watts Vision. 16 | 17 | ## Manual Installation 18 | 19 | Copy the watts_vision folder from custom_components to your custom_components folder of your home assistant instance, go to devices & services and click on '+ add integration'. In the new window search for Watts Vision and click on it. Fill out the form with your credentials for the watts vision smart home system. 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v3.19.0 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py37-plus] 7 | - repo: https://github.com/psf/black 8 | rev: 23.7.0 9 | hooks: 10 | - id: black 11 | args: 12 | - --safe 13 | - --quiet 14 | files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ 15 | - repo: https://github.com/codespell-project/codespell 16 | rev: v2.2.5 17 | hooks: 18 | - id: codespell 19 | args: 20 | - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing 21 | - --skip="./.*,*.csv,*.json" 22 | - --quiet-level=2 23 | exclude_types: [csv, json] 24 | - repo: https://github.com/PyCQA/bandit 25 | rev: 1.7.5 26 | hooks: 27 | - id: bandit 28 | args: 29 | - --quiet 30 | - --format=custom 31 | - --configfile=tests/bandit.yaml 32 | files: ^(homeassistant|script|tests)/.+\.py$ 33 | - repo: https://github.com/pre-commit/mirrors-isort 34 | rev: v5.8.0 35 | hooks: 36 | - id: isort 37 | - repo: https://github.com/pre-commit/pre-commit-hooks 38 | rev: v2.4.0 39 | hooks: 40 | - id: check-executables-have-shebangs 41 | stages: [manual] 42 | - id: check-json 43 | - repo: https://github.com/pre-commit/mirrors-mypy 44 | rev: v0.931 45 | hooks: 46 | - id: mypy 47 | args: 48 | - --pretty 49 | - --show-error-codes 50 | - --show-error-context 51 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = 3 | custom_components 4 | 5 | [coverage:report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise NotImplemented() 9 | if __name__ == '__main__': 10 | main() 11 | show_missing = true 12 | 13 | [tool:pytest] 14 | testpaths = tests 15 | norecursedirs = .git 16 | addopts = 17 | --strict 18 | --cov=custom_components 19 | 20 | [flake8] 21 | # https://github.com/ambv/black#line-length 22 | max-line-length = 88 23 | # E501: line too long 24 | # W503: Line break occurred before a binary operator 25 | # E203: Whitespace before ':' 26 | # D202 No blank lines allowed after function docstring 27 | # W504 line break after binary operator 28 | ignore = 29 | E501, 30 | W503, 31 | E203, 32 | D202, 33 | W504 34 | 35 | [isort] 36 | # https://github.com/timothycrosley/isort 37 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 38 | # splits long import on multiple lines indented by 4 spaces 39 | multi_line_output = 3 40 | include_trailing_comma=True 41 | force_grid_wrap=0 42 | use_parentheses=True 43 | line_length=88 44 | indent = " " 45 | # by default isort don't check module indexes 46 | not_skip = __init__.py 47 | # will group `import x` and `from x import` of the same module. 48 | force_sort_within_sections = true 49 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 50 | default_section = THIRDPARTY 51 | known_first_party = custom_components,tests 52 | forced_separate = tests 53 | combine_as_imports = true 54 | 55 | [mypy] 56 | python_version = 3.13 57 | ignore_errors = true 58 | follow_imports = silent 59 | ignore_missing_imports = true 60 | warn_incomplete_stub = true 61 | warn_redundant_casts = true 62 | warn_unused_configs = true 63 | -------------------------------------------------------------------------------- /custom_components/watts_vision/__init__.py: -------------------------------------------------------------------------------- 1 | """Watts Vision Component.""" 2 | 3 | import logging 4 | 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.event import async_track_time_interval 9 | 10 | from .const import API_CLIENT, DOMAIN, SCAN_INTERVAL 11 | from .watts_api import WattsApi 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.CLIMATE] 16 | 17 | 18 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 19 | """Set up Watts Vision from a config entry.""" 20 | _LOGGER.debug("Set up Watts Vision") 21 | hass.data.setdefault(DOMAIN, {}) 22 | 23 | client = WattsApi(hass, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) 24 | 25 | try: 26 | await hass.async_add_executor_job(client.getLoginToken) 27 | except Exception as exception: # pylint: disable=broad-except 28 | _LOGGER.exception(exception) 29 | return False 30 | 31 | await hass.async_add_executor_job(client.loadData) 32 | 33 | hass.data[DOMAIN][API_CLIENT] = client 34 | 35 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 36 | 37 | async def refresh_devices(event_time): 38 | await hass.async_add_executor_job(client.reloadDevices) 39 | 40 | async_track_time_interval(hass, refresh_devices, SCAN_INTERVAL) 41 | 42 | return True 43 | 44 | 45 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 46 | """Unload a config entry.""" 47 | _LOGGER.debug("Unloading Watts Vision") 48 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 49 | hass.data[DOMAIN].pop(API_CLIENT) 50 | return unload_ok 51 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Tests for the Nest config flow.""" 2 | from homeassistant import data_entry_flow 3 | from homeassistant.core import HomeAssistant 4 | 5 | from custom_components.watts_vision import config_flow 6 | 7 | 8 | async def test_data_entry_flow_form(hass: HomeAssistant): 9 | """Test we abort if no implementation is registered.""" 10 | flow = config_flow.ConfigFlow() 11 | flow.hass = hass 12 | result = await flow.async_step_user() 13 | 14 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 15 | # assert result["reason"] == "no_flows" 16 | 17 | 18 | async def test_full_flow_implementation(hass: HomeAssistant): 19 | """Test registering an implementation and finishing flow works.""" 20 | # gen_authorize_url = AsyncMock(return_value="https://example.com") 21 | # convert_code = AsyncMock(return_value={"access_token": "yoo"}) 22 | 23 | # config_flow.register_flow_implementation( 24 | # hass, "test", "Test", gen_authorize_url, convert_code 25 | # ) 26 | # config_flow.register_flow_implementation( 27 | # hass, "test-other", "Test Other", None, None 28 | # ) 29 | 30 | flow = config_flow.ConfigFlow() 31 | flow.hass = hass 32 | result = await flow.async_step_user() 33 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 34 | assert result["step_id"] == "user" 35 | 36 | result = await flow.async_step_user({"username": "user", "password": "pass"}) 37 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 38 | assert result["errors"] == {"base": "unknown"} 39 | 40 | # { 41 | # 'type': 'form', 42 | # 'flow_id': None, 43 | # 'handler': None, 44 | # 'step_id': 'user', 45 | # 'data_schema': , 'password': }, extra=PREVENT_EXTRA, required=False) object at 0x10aa688e0>, 46 | # 'errors': {'base': 'unknown'}, 47 | # 'description_placeholders': None, 48 | # 'last_step': None 49 | # } 50 | -------------------------------------------------------------------------------- /custom_components/watts_vision/central_unit.py: -------------------------------------------------------------------------------- 1 | """Watts Vision sensor platform -- central unit.""" 2 | from typing import Optional 3 | 4 | from homeassistant.components.sensor import SensorEntity 5 | 6 | from .const import DOMAIN 7 | from .watts_api import WattsApi 8 | 9 | 10 | class WattsVisionLastCommunicationSensor(SensorEntity): 11 | def __init__(self, wattsClient: WattsApi, smartHome: str, label: str, mac_address: str): 12 | super().__init__() 13 | self.client = wattsClient 14 | self.smartHome = smartHome 15 | self._label = label 16 | self._name = "Last communication " + self._label 17 | self._state = None 18 | self._available = True 19 | self._mac_address = mac_address 20 | 21 | @property 22 | def unique_id(self) -> str: 23 | """Return the unique ID of the sensor.""" 24 | return "last_communication_" + self.smartHome 25 | 26 | @property 27 | def name(self) -> str: 28 | """Return the name of the entity.""" 29 | return self._name 30 | 31 | @property 32 | def state(self) -> Optional[str]: 33 | return self._state 34 | 35 | @property 36 | def device_info(self): 37 | return { 38 | "identifiers": { 39 | # Serial numbers are unique identifiers within a specific domain 40 | (DOMAIN, self.smartHome) 41 | }, 42 | "manufacturer": "Watts", 43 | "name": "Central Unit " + self._label, 44 | "model": "BT-CT02-RF", 45 | "connections": { 46 | ("mac", self._mac_address) 47 | } 48 | } 49 | 50 | async def async_update(self): 51 | data = await self.hass.async_add_executor_job( 52 | self.client.getLastCommunication, self.smartHome 53 | ) 54 | 55 | self._state = "{} days, {} hours, {} minutes and {} seconds.".format( 56 | data["diffObj"]["days"], 57 | data["diffObj"]["hours"], 58 | data["diffObj"]["minutes"], 59 | data["diffObj"]["seconds"], 60 | ) 61 | -------------------------------------------------------------------------------- /.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 | 131 | # MACOS files 132 | .DS_Store 133 | 134 | # intellij 135 | .idea 136 | -------------------------------------------------------------------------------- /custom_components/watts_vision/binary_sensor.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | import logging 3 | from typing import Callable 4 | 5 | from homeassistant.components.binary_sensor import BinarySensorEntity 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.core import HomeAssistant 8 | 9 | from .const import API_CLIENT, DOMAIN 10 | from .watts_api import WattsApi 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | SCAN_INTERVAL = timedelta(seconds=120) 15 | 16 | 17 | async def async_setup_entry( 18 | hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable 19 | ): 20 | """Set up the binary_sensor platform.""" 21 | wattsClient: WattsApi = hass.data[DOMAIN][API_CLIENT] 22 | 23 | smartHomes = wattsClient.getSmartHomes() 24 | 25 | sensors = [] 26 | 27 | if smartHomes is not None: 28 | for y in range(len(smartHomes)): 29 | if smartHomes[y]["zones"] is not None: 30 | for z in range(len(smartHomes[y]["zones"])): 31 | if smartHomes[y]["zones"][z]["devices"] is not None: 32 | for x in range(len(smartHomes[y]["zones"][z]["devices"])): 33 | sensors.append( 34 | WattsVisionHeatingBinarySensor( 35 | wattsClient, 36 | smartHomes[y]["smarthome_id"], 37 | smartHomes[y]["zones"][z]["devices"][x]["id"], 38 | smartHomes[y]["zones"][z]["zone_label"], 39 | ) 40 | ) 41 | 42 | async_add_entities(sensors, update_before_add=True) 43 | 44 | 45 | class WattsVisionHeatingBinarySensor(BinarySensorEntity): 46 | """Representation of a Watts Vision thermostat.""" 47 | 48 | def __init__(self, wattsClient: WattsApi, smartHome: str, id: str, zone: str): 49 | super().__init__() 50 | self.client = wattsClient 51 | self.smartHome = smartHome 52 | self.id = id 53 | self.zone = zone 54 | self._name = "Heating " + zone 55 | self._state: bool = False 56 | self._available = True 57 | 58 | @property 59 | def unique_id(self) -> str: 60 | """Return the unique ID of the sensor.""" 61 | return "thermostat_is_heating_" + self.id 62 | 63 | @property 64 | def name(self) -> str: 65 | """Return the name of the entity.""" 66 | return self._name 67 | 68 | @property 69 | def is_on(self): 70 | """Return the state of the sensor.""" 71 | return self._state 72 | 73 | @property 74 | def device_info(self): 75 | return { 76 | "identifiers": { 77 | # Serial numbers are unique identifiers within a specific domain 78 | (DOMAIN, self.id) 79 | }, 80 | "manufacturer": "Watts", 81 | "name": "Thermostat " + self.zone, 82 | "model": "BT-D03-RF", 83 | "via_device": (DOMAIN, self.smartHome), 84 | } 85 | 86 | async def async_update(self): 87 | # try: 88 | smartHomeDevice = self.client.getDevice(self.smartHome, self.id) 89 | if smartHomeDevice["heating_up"] == "0": 90 | self._state = False 91 | else: 92 | self._state = True 93 | # except: 94 | # self._available = False 95 | # _LOGGER.exception("Error retrieving data.") 96 | -------------------------------------------------------------------------------- /custom_components/watts_vision/config_flow.py: -------------------------------------------------------------------------------- 1 | """ 2 | Config flow for Watts Vision integration. 3 | """ 4 | import logging 5 | from typing import Any 6 | 7 | from homeassistant import config_entries 8 | from homeassistant.config_entries import CONN_CLASS_CLOUD_POLL, ConfigFlow 9 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 10 | from homeassistant.core import HomeAssistant, callback 11 | from homeassistant.exceptions import HomeAssistantError 12 | import voluptuous as vol 13 | 14 | from .const import DOMAIN 15 | from .watts_api import WattsApi 16 | 17 | CONFIG_SCHEMA = vol.Schema( 18 | {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} 19 | ) 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | async def validate_input( 25 | hass: HomeAssistant, data: dict[str, Any], current: dict[str, Any] = None 26 | ) -> dict[str, Any]: 27 | """Validate the user input allows us to connect.""" 28 | 29 | # Check if the username already exists as an entry 30 | existing_entries = hass.config_entries.async_entries(DOMAIN) 31 | for entry in existing_entries: 32 | if entry.data.get(CONF_USERNAME) == data[CONF_USERNAME] and ( 33 | current is None or entry.data.get(CONF_USERNAME) != current.get("username") 34 | ): 35 | raise UsernameExists 36 | 37 | api = WattsApi(hass, data[CONF_USERNAME], data[CONF_PASSWORD]) 38 | 39 | authenticated = await hass.async_add_executor_job(api.test_authentication) 40 | 41 | # If authentication fails, raise an exception. 42 | if not authenticated: 43 | raise InvalidAuth 44 | 45 | # Return info that you want to store in the config entry. 46 | return data 47 | 48 | 49 | class ConfigFlow(ConfigFlow, domain=DOMAIN): 50 | """Handle a config flow for Watts Vision.""" 51 | 52 | VERSION = 1 53 | CONNECTION_CLASS = CONN_CLASS_CLOUD_POLL 54 | 55 | async def async_step_user(self, user_input: dict[str, Any] = None): 56 | if user_input is None: 57 | return self.async_show_form(step_id="user", data_schema=CONFIG_SCHEMA) 58 | 59 | errors = {} 60 | 61 | try: 62 | _LOGGER.debug("Validate input") 63 | await validate_input(self.hass, user_input) 64 | except InvalidAuth: 65 | errors["base"] = "invalid_auth" 66 | except UsernameExists: 67 | errors["base"] = "username_exists" 68 | except Exception: # pylint: disable=broad-except 69 | _LOGGER.exception("Unexpected exception") 70 | errors["base"] = "unknown" 71 | else: 72 | return self.async_create_entry( 73 | title=str(user_input["username"]), data=user_input 74 | ) 75 | 76 | return self.async_show_form( 77 | step_id="user", data_schema=CONFIG_SCHEMA, errors=errors 78 | ) 79 | 80 | @staticmethod 81 | @callback 82 | def async_get_options_flow(config_entry): 83 | """Get the options flow for this handler.""" 84 | return OptionsFlowHandler(config_entry) 85 | 86 | 87 | class InvalidAuth(HomeAssistantError): 88 | """Error to indicate there is invalid auth.""" 89 | 90 | 91 | class UsernameExists(HomeAssistantError): 92 | """Error to indicate the username already exists.""" 93 | 94 | 95 | class OptionsFlowHandler(config_entries.OptionsFlow): 96 | """Handle options flow for the Watts Vision integration.""" 97 | 98 | def __init__(self, config_entry): 99 | """Initialize the options flow.""" 100 | self.config_entry = config_entry 101 | 102 | async def async_step_init(self, user_input=None): 103 | errors = {} 104 | updated = None 105 | if user_input is not None: 106 | try: 107 | _LOGGER.debug("Validate input") 108 | validated_data = await validate_input( 109 | self.hass, user_input, self.config_entry.data 110 | ) 111 | 112 | # Update entry 113 | _LOGGER.debug("Updating entry") 114 | updated = self.hass.config_entries.async_update_entry( 115 | self.config_entry, 116 | title=str(user_input["username"]), 117 | data=validated_data, 118 | ) 119 | if updated: 120 | # Reload entry 121 | _LOGGER.debug("Reloading entry") 122 | await self.hass.config_entries.async_reload( 123 | self.config_entry.entry_id 124 | ) 125 | 126 | except InvalidAuth: 127 | errors["base"] = "invalid_auth" 128 | except UsernameExists: 129 | errors["base"] = "username_exists" 130 | except Exception: # pylint: disable=broad-except 131 | _LOGGER.exception("Unexpected exception") 132 | errors["base"] = "unknown" 133 | else: 134 | # If updated, return to overview 135 | return self.async_create_entry(title="", data=None) 136 | 137 | return self.async_show_form( 138 | step_id="init", 139 | data_schema=vol.Schema( 140 | { 141 | vol.Required( 142 | CONF_USERNAME, 143 | default=str(self.config_entry.data[CONF_USERNAME]), 144 | ): str, 145 | vol.Required(CONF_PASSWORD): str, 146 | } 147 | ), 148 | errors=errors, 149 | ) 150 | -------------------------------------------------------------------------------- /custom_components/watts_vision/watts_api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | import logging 3 | 4 | from homeassistant.core import HomeAssistant 5 | import requests 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | 9 | 10 | class WattsApi: 11 | """Interface to the Watts API.""" 12 | 13 | def __init__(self, hass: HomeAssistant, username: str, password: str): 14 | """Init dummy hub.""" 15 | self._hass = hass 16 | self._username = username 17 | self._password = password 18 | self._token = None 19 | self._token_expires = None 20 | self._refresh_token = None 21 | self._refresh_expires_in = None 22 | self._smartHomeData = {} 23 | 24 | def test_authentication(self) -> bool: 25 | """Test if we can authenticate with the host.""" 26 | try: 27 | token = self.getLoginToken(True) 28 | return token is not None 29 | except Exception as exception: 30 | _LOGGER.exception(f"Authentication exception {exception}") 31 | return False 32 | 33 | def getLoginToken(self, forcelogin=False, firstTry=True): 34 | """Get the access token for the Watts Smarthome API through login or refresh""" 35 | 36 | now = datetime.now() 37 | 38 | if ( 39 | forcelogin 40 | or not self._refresh_expires_in 41 | or self._refresh_expires_in <= now 42 | ): 43 | _LOGGER.debug("Login to get an access token.") 44 | payload = { 45 | "grant_type": "password", 46 | "username": self._username, 47 | "password": self._password, 48 | "client_id": "app-front", 49 | } 50 | elif self._token_expires <= now: 51 | _LOGGER.debug("Refreshing access token") 52 | payload = { 53 | "grant_type": "refresh_token", 54 | "refresh_token": self._refresh_token, 55 | "client_id": "app-front", 56 | } 57 | else: 58 | _LOGGER.debug("Getting token called unneeded.") 59 | 60 | request_token_result = requests.post( 61 | url="https://auth.smarthome.wattselectronics.com/realms/watts/protocol/openid-connect/token", 62 | data=payload, 63 | ) 64 | 65 | if request_token_result.status_code == 200: 66 | token = request_token_result.json()["access_token"] 67 | self._token = token 68 | self._token_expires = now + timedelta( 69 | seconds=request_token_result.json()["expires_in"] 70 | ) 71 | self._refresh_token = request_token_result.json()["refresh_token"] 72 | self._refresh_expires_in = now + timedelta( 73 | seconds=request_token_result.json()["refresh_expires_in"] 74 | ) 75 | _LOGGER.debug( 76 | f"Received access token. New refresh_token needed on {self._refresh_expires_in}" 77 | ) 78 | return token 79 | else: 80 | if firstTry: 81 | self.getLoginToken(forcelogin=True, firstTry=False) 82 | else: 83 | _LOGGER.error( 84 | "Something went wrong fetching the token: {}".format( 85 | request_token_result.status_code 86 | ) 87 | ) 88 | raise None 89 | 90 | def loadData(self): 91 | """load data from api""" 92 | smarthomes = self.loadSmartHomes() 93 | self._smartHomeData = smarthomes 94 | 95 | return self.reloadDevices() 96 | 97 | def loadSmartHomes(self, firstTry: bool = True): 98 | """Load the user data""" 99 | self._refresh_token_if_expired() 100 | 101 | headers = {"Authorization": f"Bearer {self._token}"} 102 | payload = {"token": "true", "email": self._username, "lang": "nl_NL"} 103 | 104 | user_data_result = requests.post( 105 | url="https://smarthome.wattselectronics.com/api/v0.1/human/user/read/", 106 | headers=headers, 107 | data=payload, 108 | ) 109 | 110 | if self.check_response(user_data_result): 111 | return user_data_result.json()["data"]["smarthomes"] 112 | 113 | return None 114 | 115 | def loadDevices(self, smarthome: str, firstTry: bool = True): 116 | """Load devices for smart home""" 117 | self._refresh_token_if_expired() 118 | 119 | headers = {"Authorization": f"Bearer {self._token}"} 120 | payload = {"token": "true", "smarthome_id": smarthome, "lang": "nl_NL"} 121 | 122 | devices_result = requests.post( 123 | url="https://smarthome.wattselectronics.com/api/v0.1/human/smarthome/read/", 124 | headers=headers, 125 | data=payload, 126 | ) 127 | 128 | if self.check_response(devices_result): 129 | return devices_result.json()["data"]["zones"] 130 | 131 | return None 132 | 133 | def _refresh_token_if_expired(self) -> None: 134 | """Check if token is expired and request a new one.""" 135 | now = datetime.now() 136 | 137 | if ( 138 | self._token_expires 139 | and self._token_expires <= now 140 | or self._refresh_expires_in 141 | and self._refresh_expires_in <= now 142 | ): 143 | self.getLoginToken() 144 | 145 | def reloadDevices(self): 146 | """load devices for each smart home""" 147 | if self._smartHomeData is not None: 148 | for y in range(len(self._smartHomeData)): 149 | zones = self.loadDevices(self._smartHomeData[y]["smarthome_id"]) 150 | self._smartHomeData[y]["zones"] = zones 151 | 152 | return True 153 | 154 | def getSmartHomes(self): 155 | """Get smarthomes""" 156 | return self._smartHomeData 157 | 158 | def getDevice(self, smarthome: str, deviceId: str): 159 | """Get specific device""" 160 | for y in range(len(self._smartHomeData)): 161 | if self._smartHomeData[y]["smarthome_id"] == smarthome: 162 | for z in range(len(self._smartHomeData[y]["zones"])): 163 | for x in range(len(self._smartHomeData[y]["zones"][z]["devices"])): 164 | if ( 165 | self._smartHomeData[y]["zones"][z]["devices"][x]["id"] 166 | == deviceId 167 | ): 168 | return self._smartHomeData[y]["zones"][z]["devices"][x] 169 | 170 | return None 171 | 172 | def setDevice(self, smarthome: str, deviceId: str, newState: str): 173 | """Set specific device""" 174 | for y in range(len(self._smartHomeData)): 175 | if self._smartHomeData[y]["smarthome_id"] == smarthome: 176 | for z in range(len(self._smartHomeData[y]["zones"])): 177 | for x in range(len(self._smartHomeData[y]["zones"][z]["devices"])): 178 | if ( 179 | self._smartHomeData[y]["zones"][z]["devices"][x]["id"] 180 | == deviceId 181 | ): 182 | # If device is found, overwrite it with the new state 183 | self._smartHomeData[y]["zones"][z]["devices"][x] = newState 184 | return self._smartHomeData[y]["zones"][z]["devices"][x] 185 | 186 | return None 187 | 188 | # def setDevice(self, smarthome: str, deviceId: str, newState: str): 189 | # """Set specific device""" 190 | # for y in range(len(self._smartHomeData)): 191 | # if self._smartHomeData[y]["smarthome_id"] == smarthome: 192 | # for x in range(len(self._smartHomeData[y]["devices"])): 193 | # if self._smartHomeData[y]["devices"][x]["id"] == deviceId: 194 | # # If device is found, overwrite it with the new state 195 | # self._smartHomeData[y]["devices"][x] = newState 196 | # return self._smartHomeData[y]["devices"][x] 197 | 198 | return None 199 | 200 | def pushTemperature( 201 | self, 202 | smarthome: str, 203 | deviceID: str, 204 | value: str, 205 | gvMode: str, 206 | firstTry: bool = True, 207 | ): 208 | self._refresh_token_if_expired() 209 | 210 | headers = {"Authorization": f"Bearer {self._token}"} 211 | payload = { 212 | "token": "true", 213 | "context": "1", 214 | "smarthome_id": smarthome, 215 | "query[id_device]": deviceID, 216 | "query[time_boost]": "0", 217 | "query[gv_mode]": gvMode, 218 | "query[nv_mode]": gvMode, 219 | "peremption": "15000", 220 | "lang": "nl_NL", 221 | } 222 | extrapayload = {} 223 | if gvMode == "0": 224 | extrapayload = { 225 | "query[consigne_confort]": value, 226 | "query[consigne_manuel]": value, 227 | } 228 | elif gvMode == "1": 229 | extrapayload = { 230 | "query[consigne_manuel]": "0", 231 | } 232 | elif gvMode == "2": 233 | extrapayload = { 234 | "query[consigne_hg]": "446", 235 | "query[consigne_manuel]": "446", 236 | "peremption": "20000", 237 | } 238 | elif gvMode == "3": 239 | extrapayload = { 240 | "query[consigne_eco]": value, 241 | "query[consigne_manuel]": value, 242 | } 243 | elif gvMode == "4": 244 | extrapayload = { 245 | "query[time_boost]": "7200", 246 | "query[consigne_boost]": value, 247 | "query[consigne_manuel]": value, 248 | } 249 | elif gvMode == "11": 250 | extrapayload = { 251 | "query[consigne_manuel]": value, 252 | } 253 | payload.update(extrapayload) 254 | 255 | push_result = requests.post( 256 | url="https://smarthome.wattselectronics.com/api/v0.1/human/query/push/", 257 | headers=headers, 258 | data=payload, 259 | ) 260 | 261 | if self.check_response(push_result): 262 | return True 263 | return False 264 | 265 | def getLastCommunication(self, smarthome: str, firstTry: bool = True): 266 | self._refresh_token_if_expired() 267 | 268 | headers = {"Authorization": f"Bearer {self._token}"} 269 | payload = {"token": "true", "smarthome_id": smarthome, "lang": "nl_NL"} 270 | 271 | last_connection_result = requests.post( 272 | url="https://smarthome.wattselectronics.com/api/v0.1/human/sandbox/check_last_connexion/", 273 | headers=headers, 274 | data=payload, 275 | ) 276 | 277 | if self.check_response(last_connection_result): 278 | return last_connection_result.json()["data"] 279 | 280 | return None 281 | 282 | @staticmethod 283 | def check_response(response: requests.Response) -> bool: 284 | if response.status_code == 200: 285 | if "OK" in response.json()["code"]["key"]: 286 | return True 287 | else: 288 | # raise APIException("Code: {0}, key: {1}, value: {2}".format( 289 | # response.json()["code"]["code"], 290 | # response.json()["code"]["key"], 291 | # response.json()["code"]["value"] 292 | # )) 293 | _LOGGER.error( 294 | "Something went wrong fetching user data. Code: {}, Key: {}, Value: {}, Data: {}".format( 295 | response.json()["code"]["code"], 296 | response.json()["code"]["key"], 297 | response.json()["code"]["value"], 298 | response.json()["data"], 299 | ) 300 | ) 301 | return False 302 | if response.status_code == 401: 303 | # raise UnauthorizedException("Unauthorized") 304 | _LOGGER.error("Unauthorized") 305 | return False 306 | else: 307 | # raise UnHandledStatuException(response.status_code) 308 | _LOGGER.error(f"Unhandled status code {response.status_code}") 309 | return False 310 | -------------------------------------------------------------------------------- /custom_components/watts_vision/sensor.py: -------------------------------------------------------------------------------- 1 | """Watts Vision sensor platform.""" 2 | from datetime import timedelta 3 | import logging 4 | from typing import Callable, Optional 5 | 6 | from homeassistant.components.sensor import ( 7 | SensorDeviceClass, 8 | SensorEntity, 9 | SensorStateClass, 10 | ) 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.const import UnitOfTemperature 13 | from homeassistant.core import HomeAssistant 14 | from numpy import nan as NaN 15 | 16 | from .central_unit import WattsVisionLastCommunicationSensor 17 | from .const import API_CLIENT, DOMAIN, ERROR_MAP, PRESET_MODE_MAP 18 | from .watts_api import WattsApi 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | SCAN_INTERVAL = timedelta(seconds=120) 23 | 24 | 25 | async def async_setup_entry( 26 | hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable 27 | ): 28 | """Set up the sensor platform.""" 29 | 30 | wattsClient: WattsApi = hass.data[DOMAIN][API_CLIENT] 31 | 32 | smartHomes = wattsClient.getSmartHomes() 33 | 34 | sensors = [] 35 | 36 | if smartHomes is not None: 37 | for y in range(len(smartHomes)): 38 | if smartHomes[y]["zones"] is not None: 39 | for z in range(len(smartHomes[y]["zones"])): 40 | if smartHomes[y]["zones"][z]["devices"] is not None: 41 | for x in range(len(smartHomes[y]["zones"][z]["devices"])): 42 | sensors.append( 43 | WattsVisionThermostatSensor( 44 | wattsClient, 45 | smartHomes[y]["smarthome_id"], 46 | smartHomes[y]["zones"][z]["devices"][x]["id"], 47 | smartHomes[y]["zones"][z]["zone_label"], 48 | ) 49 | ) 50 | sensors.append( 51 | WattsVisionTemperatureSensor( 52 | wattsClient, 53 | smartHomes[y]["smarthome_id"], 54 | smartHomes[y]["zones"][z]["devices"][x]["id"], 55 | smartHomes[y]["zones"][z]["zone_label"], 56 | ) 57 | ) 58 | sensors.append( 59 | WattsVisionSetTemperatureSensor( 60 | wattsClient, 61 | smartHomes[y]["smarthome_id"], 62 | smartHomes[y]["zones"][z]["devices"][x]["id"], 63 | smartHomes[y]["zones"][z]["zone_label"], 64 | ) 65 | ) 66 | sensors.append( 67 | WattsVisionErrorSensor( 68 | wattsClient, 69 | smartHomes[y]["smarthome_id"], 70 | smartHomes[y]["zones"][z]["devices"][x]["id"], 71 | smartHomes[y]["zones"][z]["zone_label"], 72 | ) 73 | ) 74 | sensors.append( 75 | WattsVisionLastCommunicationSensor( 76 | wattsClient, 77 | smartHomes[y]["smarthome_id"], 78 | smartHomes[y]["label"], 79 | smartHomes[y]["mac_address"] 80 | ) 81 | ) 82 | 83 | async_add_entities(sensors, update_before_add=True) 84 | 85 | 86 | class WattsVisionThermostatSensor(SensorEntity): 87 | """Representation of a Watts Vision thermostat.""" 88 | 89 | def __init__(self, wattsClient: WattsApi, smartHome: str, id: str, zone: str): 90 | super().__init__() 91 | self.client = wattsClient 92 | self.smartHome = smartHome 93 | self.id = id 94 | self.zone = zone 95 | self._name = "Heating mode " + zone 96 | self._state = None 97 | self._available = True 98 | 99 | @property 100 | def unique_id(self) -> str: 101 | """Return the unique ID of the sensor.""" 102 | return "thermostat_mode_" + self.id 103 | 104 | @property 105 | def name(self) -> str: 106 | """Return the name of the entity.""" 107 | return self._name 108 | 109 | @property 110 | def state(self) -> Optional[str]: 111 | return self._state 112 | 113 | @property 114 | def device_class(self): 115 | return SensorDeviceClass.ENUM 116 | 117 | @property 118 | def options(self): 119 | return list(PRESET_MODE_MAP.values()) 120 | 121 | @property 122 | def device_info(self): 123 | return { 124 | "identifiers": { 125 | # Serial numbers are unique identifiers within a specific domain 126 | (DOMAIN, self.id) 127 | }, 128 | "manufacturer": "Watts", 129 | "name": "Thermostat " + self.zone, 130 | "model": "BT-D03-RF", 131 | "via_device": (DOMAIN, self.smartHome), 132 | } 133 | 134 | async def async_update(self): 135 | smartHomeDevice = self.client.getDevice(self.smartHome, self.id) 136 | 137 | self._state = PRESET_MODE_MAP[smartHomeDevice["gv_mode"]] 138 | 139 | 140 | class WattsVisionTemperatureSensor(SensorEntity): 141 | """Representation of a Watts Vision temperature sensor.""" 142 | 143 | def __init__(self, wattsClient: WattsApi, smartHome: str, id: str, zone: str): 144 | super().__init__() 145 | self.client = wattsClient 146 | self.smartHome = smartHome 147 | self.id = id 148 | self.zone = zone 149 | self._name = "Air temperature " + zone 150 | self._state = None 151 | self._available = True 152 | 153 | @property 154 | def unique_id(self) -> str: 155 | """Return the unique ID of the sensor.""" 156 | return "temperature_air_" + self.id 157 | 158 | @property 159 | def name(self) -> str: 160 | """Return the name of the entity.""" 161 | return self._name 162 | 163 | @property 164 | def state(self) -> Optional[str]: 165 | return self._state 166 | 167 | @property 168 | def state_class(self): 169 | return SensorStateClass.MEASUREMENT 170 | 171 | @property 172 | def device_class(self): 173 | return SensorDeviceClass.TEMPERATURE 174 | 175 | @property 176 | def native_unit_of_measurement(self): 177 | return UnitOfTemperature.FAHRENHEIT 178 | 179 | @property 180 | def device_info(self): 181 | return { 182 | "identifiers": { 183 | # Serial numbers are unique identifiers within a specific domain 184 | (DOMAIN, self.id) 185 | }, 186 | "manufacturer": "Watts", 187 | "name": "Thermostat " + self.zone, 188 | "model": "BT-D03-RF", 189 | "via_device": (DOMAIN, self.smartHome), 190 | "suggested_area": self.zone 191 | } 192 | 193 | async def async_update(self): 194 | smartHomeDevice = self.client.getDevice(self.smartHome, self.id) 195 | if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS: 196 | self._state = round( 197 | ((float(smartHomeDevice["temperature_air"]) / 10.0) - 32) * (5.0 / 9.0), 1 198 | ) 199 | # self._state = round( 200 | # (int(smartHomeDevice["temperature_air"]) - 320) * 5 / 9 / 10, 1 201 | # ) 202 | else: 203 | self._state = int(smartHomeDevice["temperature_air"]) / 10 204 | 205 | 206 | class WattsVisionSetTemperatureSensor(SensorEntity): 207 | """Representation of a Watts Vision temperature sensor.""" 208 | 209 | def __init__(self, wattsClient: WattsApi, smartHome: str, id: str, zone: str): 210 | super().__init__() 211 | self.client = wattsClient 212 | self.smartHome = smartHome 213 | self.id = id 214 | self.zone = zone 215 | self._name = "Target temperature " + zone 216 | self._state = None 217 | self._available = True 218 | 219 | @property 220 | def unique_id(self) -> str: 221 | """Return the unique ID of the sensor.""" 222 | return "target_temperature_" + self.id 223 | 224 | @property 225 | def name(self) -> str: 226 | """Return the name of the entity.""" 227 | return self._name 228 | 229 | @property 230 | def state(self) -> Optional[str]: 231 | return self._state 232 | 233 | @property 234 | def state_class(self): 235 | return SensorStateClass.MEASUREMENT 236 | 237 | @property 238 | def device_class(self): 239 | return SensorDeviceClass.TEMPERATURE 240 | 241 | @property 242 | def native_unit_of_measurement(self): 243 | return UnitOfTemperature.FAHRENHEIT 244 | 245 | @property 246 | def device_info(self): 247 | return { 248 | "identifiers": { 249 | # Serial numbers are unique identifiers within a specific domain 250 | (DOMAIN, self.id) 251 | }, 252 | "manufacturer": "Watts", 253 | "name": "Thermostat " + self.zone, 254 | "model": "BT-D03-RF", 255 | "via_device": (DOMAIN, self.smartHome), 256 | } 257 | 258 | async def async_update(self): 259 | smartHomeDevice = self.client.getDevice(self.smartHome, self.id) 260 | 261 | if smartHomeDevice["gv_mode"] == "0": 262 | self._state = smartHomeDevice["consigne_confort"] 263 | if smartHomeDevice["gv_mode"] == "1": 264 | self._state = NaN 265 | if smartHomeDevice["gv_mode"] == "2": 266 | self._state = smartHomeDevice["consigne_hg"] 267 | if smartHomeDevice["gv_mode"] == "3": 268 | self._state = smartHomeDevice["consigne_eco"] 269 | if smartHomeDevice["gv_mode"] == "4": 270 | self._state = smartHomeDevice["consigne_boost"] 271 | if smartHomeDevice["gv_mode"] == "11" or smartHomeDevice["gv_mode"] == "8": 272 | self._state = smartHomeDevice["consigne_manuel"] 273 | if self._state != NaN: 274 | if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS: 275 | # self._state = round((int(self._state) - 320) * 5 / 9 / 10, 1) 276 | self._state = round( 277 | ( 278 | ( 279 | float(self._state) / 10.0 280 | ) - 32 281 | ) * (5.0 / 9.0) * 2, 1 282 | ) / 2 283 | else: 284 | self._state = int(self._state) / 10 285 | 286 | 287 | class WattsVisionErrorSensor(SensorEntity): 288 | """Representation of a Watts Vision battery sensor.""" 289 | 290 | def __init__(self, wattsClient: WattsApi, smartHome: str, id: str, zone: str): 291 | super().__init__() 292 | self.client = wattsClient 293 | self.smartHome = smartHome 294 | self.id = id 295 | self.zone = zone 296 | self._name = "Error " + zone 297 | self._state = None 298 | self._available = True 299 | 300 | @property 301 | def unique_id(self) -> str: 302 | """Return the unique ID of the sensor.""" 303 | return "error_" + self.id 304 | 305 | @property 306 | def name(self) -> str: 307 | """Return the name of the entity.""" 308 | return self._name 309 | 310 | @property 311 | def state(self) -> Optional[str]: 312 | if self.client.getDevice(self.smartHome, self.id)['error_code'] > 1: 313 | _LOGGER.warning('Thermostat battery for device %s is (almost) empty.', self.id) 314 | return self._state 315 | 316 | @property 317 | def device_class(self): 318 | return SensorDeviceClass.ENUM 319 | 320 | @property 321 | def options(self): 322 | return list(ERROR_MAP.values()) 323 | 324 | @property 325 | def device_info(self): 326 | return { 327 | "identifiers": { 328 | # Serial numbers are unique identifiers within a specific domain 329 | (DOMAIN, self.id) 330 | }, 331 | "manufacturer": "Watts", 332 | "name": "Thermostat " + self.zone, 333 | "model": "BT-D03-RF", 334 | "via_device": (DOMAIN, self.smartHome) 335 | } 336 | 337 | async def async_update(self): 338 | smartHomeDevice = self.client.getDevice(self.smartHome, self.id) 339 | self._state = ERROR_MAP[smartHomeDevice["error_code"]] 340 | -------------------------------------------------------------------------------- /custom_components/watts_vision/climate.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import logging 3 | from typing import Callable 4 | 5 | from homeassistant.components.climate import ClimateEntity 6 | from homeassistant.components.climate.const import ( 7 | ClimateEntityFeature, 8 | HVACAction, 9 | HVACMode, 10 | ) 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.const import UnitOfTemperature 13 | from homeassistant.core import HomeAssistant 14 | 15 | from .const import ( 16 | API_CLIENT, 17 | DOMAIN, 18 | PRESET_BOOST, 19 | PRESET_DEFROST, 20 | PRESET_ECO, 21 | PRESET_MODE_MAP, 22 | PRESET_MODE_REVERSE_MAP, 23 | PRESET_OFF, 24 | PRESET_PROGRAM_OFF, 25 | PRESET_PROGRAM_ON, 26 | ) 27 | from .watts_api import WattsApi 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | 32 | async def async_setup_entry( 33 | hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable 34 | ): 35 | """Set up the climate platform.""" 36 | 37 | wattsClient: WattsApi = hass.data[DOMAIN][API_CLIENT] 38 | 39 | smartHomes = wattsClient.getSmartHomes() 40 | 41 | devices = [] 42 | 43 | if smartHomes is not None: 44 | for y in range(len(smartHomes)): 45 | if smartHomes[y]["zones"] is not None: 46 | for z in range(len(smartHomes[y]["zones"])): 47 | if smartHomes[y]["zones"][z]["devices"] is not None: 48 | for x in range(len(smartHomes[y]["zones"][z]["devices"])): 49 | devices.append( 50 | WattsThermostat( 51 | wattsClient, 52 | smartHomes[y]["smarthome_id"], 53 | smartHomes[y]["zones"][z]["devices"][x]["id"], 54 | smartHomes[y]["zones"][z]["devices"][x][ 55 | "id_device" 56 | ], 57 | smartHomes[y]["zones"][z]["zone_label"], 58 | ) 59 | ) 60 | 61 | async_add_entities(devices, update_before_add=True) 62 | 63 | 64 | class WattsThermostat(ClimateEntity): 65 | """""" 66 | 67 | def __init__( 68 | self, wattsClient: WattsApi, smartHome: str, id: str, deviceID: str, zone: str 69 | ): 70 | super().__init__() 71 | self.client = wattsClient 72 | self.smartHome = smartHome 73 | self.id = id 74 | self.zone = zone 75 | self.deviceID = deviceID 76 | self._name = "Thermostat " + zone 77 | self._available = True 78 | self._attr_extra_state_attributes = {"previous_gv_mode": "0"} 79 | 80 | @property 81 | def unique_id(self): 82 | """Return the unique ID for this device.""" 83 | return "watts_thermostat_" + self.id 84 | 85 | @property 86 | def name(self) -> str: 87 | """Return the name of the entity.""" 88 | return self._name 89 | 90 | @property 91 | def supported_features(self): 92 | return ( 93 | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE 94 | ) 95 | 96 | @property 97 | def temperature_unit(self) -> str: 98 | return UnitOfTemperature.FAHRENHEIT 99 | 100 | @property 101 | def hvac_modes(self) -> list[str]: 102 | return [HVACMode.HEAT] + [HVACMode.COOL] + [HVACMode.OFF] 103 | 104 | @property 105 | def hvac_mode(self) -> str: 106 | return self._attr_hvac_mode 107 | 108 | @property 109 | def hvac_action(self) -> str: 110 | return self._attr_hvac_action 111 | 112 | @property 113 | def preset_modes(self) -> list[str]: 114 | """Return the available presets.""" 115 | return list(PRESET_MODE_MAP.values()) 116 | 117 | @property 118 | def preset_mode(self) -> str: 119 | return self._attr_preset_mode 120 | 121 | @property 122 | def device_info(self): 123 | return { 124 | "identifiers": { 125 | # Serial numbers are unique identifiers within a specific domain 126 | (DOMAIN, self.id) 127 | }, 128 | "manufacturer": "Watts", 129 | "name": "Thermostat " + self.zone, 130 | "model": "BT-D03-RF", 131 | "via_device": (DOMAIN, self.smartHome), 132 | } 133 | 134 | async def async_update(self): 135 | smartHomeDevice = self.client.getDevice(self.smartHome, self.id) 136 | 137 | self._attr_current_temperature = float(smartHomeDevice["temperature_air"]) / 10 138 | if smartHomeDevice["gv_mode"] != "2": 139 | self._attr_min_temp = float(smartHomeDevice["min_set_point"]) / 10 140 | self._attr_max_temp = float(smartHomeDevice["max_set_point"]) / 10 141 | else: 142 | self._attr_min_temp = float(446 / 10) 143 | self._attr_max_temp = float(446 / 10) 144 | 145 | if smartHomeDevice["heating_up"] == "0": 146 | if smartHomeDevice["gv_mode"] == "1": 147 | self._attr_hvac_action = HVACAction.OFF 148 | else: 149 | self._attr_hvac_action = HVACAction.IDLE 150 | else: 151 | if smartHomeDevice["heat_cool"] == "1": 152 | self._attr_hvac_action = HVACAction.COOLING 153 | else: 154 | self._attr_hvac_action = HVACAction.HEATING 155 | 156 | if smartHomeDevice["heat_cool"] == "1": 157 | self._attr_hvac_mode = HVACMode.COOL 158 | else: 159 | self._attr_hvac_mode = HVACMode.HEAT 160 | self._attr_preset_mode = PRESET_MODE_MAP[smartHomeDevice["gv_mode"]] 161 | 162 | if smartHomeDevice["gv_mode"] == "0": 163 | self._attr_target_temperature = ( 164 | float(smartHomeDevice["consigne_confort"]) / 10 165 | ) 166 | elif smartHomeDevice["gv_mode"] == "1": 167 | self._attr_hvac_mode = HVACMode.OFF 168 | self._attr_target_temperature = None 169 | elif smartHomeDevice["gv_mode"] == "2": 170 | self._attr_target_temperature = float(smartHomeDevice["consigne_hg"]) / 10 171 | elif smartHomeDevice["gv_mode"] == "3": 172 | self._attr_target_temperature = float(smartHomeDevice["consigne_eco"]) / 10 173 | elif smartHomeDevice["gv_mode"] == "4": 174 | self._attr_target_temperature = ( 175 | float(smartHomeDevice["consigne_boost"]) / 10 176 | ) 177 | elif smartHomeDevice["gv_mode"] == "11": 178 | self._attr_target_temperature = ( 179 | float(smartHomeDevice["consigne_manuel"]) / 10 180 | ) 181 | 182 | self._attr_extra_state_attributes["consigne_confort"] = ( 183 | float(smartHomeDevice["consigne_confort"]) / 10 184 | ) 185 | self._attr_extra_state_attributes["consigne_hg"] = ( 186 | float(smartHomeDevice["consigne_hg"]) / 10 187 | ) 188 | self._attr_extra_state_attributes["consigne_eco"] = ( 189 | float(smartHomeDevice["consigne_eco"]) / 10 190 | ) 191 | self._attr_extra_state_attributes["consigne_boost"] = ( 192 | float(smartHomeDevice["consigne_boost"]) / 10 193 | ) 194 | self._attr_extra_state_attributes["consigne_manuel"] = ( 195 | float(smartHomeDevice["consigne_manuel"]) / 10 196 | ) 197 | self._attr_extra_state_attributes["gv_mode"] = smartHomeDevice["gv_mode"] 198 | 199 | async def async_set_hvac_mode(self, hvac_mode): 200 | """Set new target hvac mode.""" 201 | if hvac_mode == HVACMode.HEAT or hvac_mode == HVACMode.COOL: 202 | value = "0" 203 | if self._attr_extra_state_attributes["previous_gv_mode"] == "0": 204 | value = str( 205 | int(self._attr_extra_state_attributes["consigne_confort"] * 10) 206 | ) 207 | elif self._attr_extra_state_attributes["previous_gv_mode"] == "2": 208 | value = str(int(self._attr_extra_state_attributes["consigne_hg"] * 10)) 209 | elif self._attr_extra_state_attributes["previous_gv_mode"] == "3": 210 | value = str(int(self._attr_extra_state_attributes["consigne_eco"] * 10)) 211 | elif self._attr_extra_state_attributes["previous_gv_mode"] == "4": 212 | value = str( 213 | int(self._attr_extra_state_attributes["consigne_boost"] * 10) 214 | ) 215 | elif self._attr_extra_state_attributes["previous_gv_mode"] == "11": 216 | value = str( 217 | int(self._attr_extra_state_attributes["consigne_manuel"] * 10) 218 | ) 219 | 220 | # reloading the devices may take some time, meanwhile set the new values manually 221 | for y in range(len(self.client._smartHomeData)): 222 | if self.client._smartHomeData[y]["smarthome_id"] == self.smartHome: 223 | for z in range(len(self.client._smartHomeData[y]["zones"])): 224 | for x in range( 225 | len(self.client._smartHomeData[y]["zones"][z]["devices"]) 226 | ): 227 | if ( 228 | self.client._smartHomeData[y]["zones"][z]["devices"][x][ 229 | "id" 230 | ] 231 | == self.id 232 | ): 233 | self.client._smartHomeData[y]["zones"][z]["devices"][x][ 234 | "gv_mode" 235 | ] = self._attr_extra_state_attributes[ 236 | "previous_gv_mode" 237 | ] 238 | self.client._smartHomeData[y]["zones"][z]["devices"][x][ 239 | "consigne_manuel" 240 | ] = value 241 | if ( 242 | self._attr_extra_state_attributes[ 243 | "previous_gv_mode" 244 | ] 245 | == "0" 246 | ): 247 | self._attr_extra_state_attributes[ 248 | "consigne_confort" 249 | ] = value 250 | elif ( 251 | self._attr_extra_state_attributes[ 252 | "previous_gv_mode" 253 | ] 254 | == "2" 255 | ): 256 | self._attr_extra_state_attributes[ 257 | "consigne_hg" 258 | ] = value 259 | elif ( 260 | self._attr_extra_state_attributes[ 261 | "previous_gv_mode" 262 | ] 263 | == "3" 264 | ): 265 | self._attr_extra_state_attributes[ 266 | "consigne_eco" 267 | ] = value 268 | elif ( 269 | self._attr_extra_state_attributes[ 270 | "previous_gv_mode" 271 | ] 272 | == "4" 273 | ): 274 | self._attr_extra_state_attributes[ 275 | "consigne_boost" 276 | ] = value 277 | 278 | func = functools.partial( 279 | self.client.pushTemperature, 280 | self.smartHome, 281 | self.deviceID, 282 | value, 283 | self._attr_extra_state_attributes["previous_gv_mode"], 284 | ) 285 | await self.hass.async_add_executor_job(func) 286 | 287 | if hvac_mode == HVACMode.OFF: 288 | self._attr_extra_state_attributes[ 289 | "previous_gv_mode" 290 | ] = self._attr_extra_state_attributes["gv_mode"] 291 | 292 | # reloading the devices may take some time, meanwhile set the new values manually 293 | for y in range(len(self.client._smartHomeData)): 294 | if self.client._smartHomeData[y]["smarthome_id"] == self.smartHome: 295 | for z in range(len(self.client._smartHomeData[y]["zones"])): 296 | for x in range( 297 | len(self.client._smartHomeData[y]["zones"][z]["devices"]) 298 | ): 299 | if ( 300 | self.client._smartHomeData[y]["zones"][z]["devices"][x][ 301 | "id" 302 | ] 303 | == self.id 304 | ): 305 | self.client._smartHomeData[y]["zones"][z]["devices"][x][ 306 | "gv_mode" 307 | ] = PRESET_MODE_REVERSE_MAP[PRESET_OFF] 308 | self.client._smartHomeData[y]["zones"][z]["devices"][x][ 309 | "consigne_manuel" 310 | ] = "0" 311 | 312 | func = functools.partial( 313 | self.client.pushTemperature, 314 | self.smartHome, 315 | self.deviceID, 316 | "0", 317 | PRESET_MODE_REVERSE_MAP[PRESET_OFF], 318 | ) 319 | await self.hass.async_add_executor_job(func) 320 | 321 | async def async_set_preset_mode(self, preset_mode): 322 | """Set new target preset mode.""" 323 | value = 0 324 | if preset_mode != PRESET_OFF: 325 | if preset_mode == PRESET_DEFROST: 326 | value = str(int(self._attr_extra_state_attributes["consigne_hg"] * 10)) 327 | elif preset_mode == PRESET_ECO: 328 | value = str(int(self._attr_extra_state_attributes["consigne_eco"] * 10)) 329 | elif preset_mode == PRESET_BOOST: 330 | value = str( 331 | int(self._attr_extra_state_attributes["consigne_boost"] * 10) 332 | ) 333 | elif preset_mode == PRESET_PROGRAM_ON or preset_mode == PRESET_PROGRAM_OFF: 334 | value = str( 335 | int(self._attr_extra_state_attributes["consigne_manuel"] * 10) 336 | ) 337 | else: 338 | value = str( 339 | int(self._attr_extra_state_attributes["consigne_confort"] * 10) 340 | ) 341 | else: 342 | self._attr_extra_state_attributes[ 343 | "previous_gv_mode" 344 | ] = self._attr_extra_state_attributes["gv_mode"] 345 | 346 | # reloading the devices may take some time, meanwhile set the new values manually 347 | for y in range(len(self.client._smartHomeData)): 348 | if self.client._smartHomeData[y]["smarthome_id"] == self.smartHome: 349 | for z in range(len(self.client._smartHomeData[y]["zones"])): 350 | for x in range( 351 | len(self.client._smartHomeData[y]["zones"][z]["devices"]) 352 | ): 353 | if ( 354 | self.client._smartHomeData[y]["zones"][z]["devices"][x][ 355 | "id" 356 | ] 357 | == self.id 358 | ): 359 | self.client._smartHomeData[y]["zones"][z]["devices"][x][ 360 | "gv_mode" 361 | ] = PRESET_MODE_REVERSE_MAP[preset_mode] 362 | self.client._smartHomeData[y]["zones"][z]["devices"][x][ 363 | "consigne_manuel" 364 | ] = value 365 | 366 | func = functools.partial( 367 | self.client.pushTemperature, 368 | self.smartHome, 369 | self.deviceID, 370 | value, 371 | PRESET_MODE_REVERSE_MAP[preset_mode], 372 | ) 373 | await self.hass.async_add_executor_job(func) 374 | 375 | async def async_set_temperature(self, **kwargs): 376 | """Set new target temperature.""" 377 | value = str(int(kwargs["temperature"] * 10)) 378 | gvMode = PRESET_MODE_REVERSE_MAP[self._attr_preset_mode] 379 | 380 | # Get the smartHomeDevice 381 | smartHomeDevice = self.client.getDevice(self.smartHome, self.id) 382 | 383 | # update its temp settings 384 | smartHomeDevice["consigne_manuel"] = value 385 | smartHomeDevice["consigne_confort"] = value 386 | 387 | # Set the smartHomeDevice using the just altered SmartHomeDevice 388 | self.client.setDevice(self.smartHome, self.id, smartHomeDevice) 389 | 390 | func = functools.partial( 391 | self.client.pushTemperature, self.smartHome, self.deviceID, value, gvMode 392 | ) 393 | 394 | await self.hass.async_add_executor_job(func) 395 | --------------------------------------------------------------------------------