diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..bd429de --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,60 @@ +## Developing with Visual Studio Code + devcontainer + +The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. + +In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. + +**Prerequisites** + +- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- Docker + - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) + - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. +- [Visual Studio code](https://code.visualstudio.com/) +- [Remote - Containers (VSC Extension)][extension-link] + +[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) + +[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers + +**Getting started:** + +1. Fork the repository. +2. Clone the repository to your computer. +3. Open the repository using Visual Studio code. + +When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. + +_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ + +### Tasks + +The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. + +When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. + +The available tasks are: + +Task | Description +-- | -- +Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. +Run Home Assistant configuration against /config | Check the configuration. +Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. +Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. + +### Step by Step debugging + +With the development container, +you can test your custom component in Home Assistant with step by step debugging. + +You need to modify the `configuration.yaml` file in `.devcontainer` folder +by uncommenting the line: + +```yaml +# debugpy: +``` + +Then launch the task `Run Home Assistant on port 9123`, and launch the debbuger +with the existing debugging configuration `Python: Attach Local`. + +For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/). diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..02691ea --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,44 @@ +default_config: + +logger: + default: info + logs: + custom_components.versatile_thermostat: debug + +# If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) +debugpy: + +input_number: + fake_temperature_sensor1: + name: Temperature + min: 0 + max: 35 + step: 1 + icon: mdi:thermometer + fake_current_power: + name: Current power + min: 0 + max: 1000 + step: 10 + icon: mdi:flash + fake_current_power_max: + name: Current power max threshold + min: 0 + max: 1000 + step: 10 + icon: mdi:flash + + +input_boolean: + # input_boolean to simulate the windows entity. Only for development environment. + fake_window_sensor1: + name: Window 1 + icon: mdi:window-closed-variant + # input_boolean to simulate the heater entity switch. Only for development environment. + fake_heater_switch1: + name: Heater 1 + icon: mdi:radiator + # input_boolean to simulate the motion sensor entity. Only for development environment. + fake_motion_sensor1: + name: Motion Sensor 1 + icon: mdi:run diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..ba56015 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,37 @@ +// See https://aka.ms/vscode-remote/devcontainer.json for format details. +{ + "image": "ghcr.io/ludeeus/devcontainer/integration:latest", + "name": "Blueprint integration development", + "context": "..", + "appPort": [ + "9123:8123" + ], + "postCreateCommand": "container install", + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "terminal.integrated.profiles.linux": { + "Bash Profile": { + "path": "bash", + "args": [ ] + } + }, + "terminal.integrated.defaultProfile.linux": "Bash Profile", + // "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } +} \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..6bcce42 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 0000000..bbd0345 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,42 @@ +--- +name: Issue +about: Create a report to help us improve + +--- + + + +## Version of the custom_component + + +## Configuration + +```yaml + +Add your logs here. + +``` + +## Describe the bug +A clear and concise description of what the bug is. + + +## Debug log + + + +```text + +Add your logs here. + +``` \ No newline at end of file diff --git a/.github/workflows/cron.yaml b/.github/workflows/cron.yaml new file mode 100644 index 0000000..4b42416 --- /dev/null +++ b/.github/workflows/cron.yaml @@ -0,0 +1,22 @@ +name: Cron actions + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml new file mode 100644 index 0000000..d895c86 --- /dev/null +++ b/.github/workflows/pull.yml @@ -0,0 +1,55 @@ +name: Pull actions + +on: + pull_request: + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + style: + runs-on: "ubuntu-latest" + name: Check style formatting + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.x" + - run: python3 -m pip install black + - run: black . + + tests: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v2" + - name: Setup Python + uses: "actions/setup-python@v1" + with: + python-version: "3.8" + - name: Install requirements + run: python3 -m pip install -r requirements_test.txt + - name: Run tests + run: | + pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + --cov custom_components.integration_blueprint \ + -o console_output_style=count \ + -p no:sugar \ + tests diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..64c68fc --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,34 @@ +name: Push actions + +on: + push: + branches: + - master + - dev + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + style: + runs-on: "ubuntu-latest" + name: Check style formatting + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.x" + - run: python3 -m pip install black + - run: black . diff --git a/.gitignore b/.gitignore index 6704566..dd9d50a 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ dist # TernJS port file .tern-port + +__pycache__ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2489740 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // Example of attaching to local debug server + "name": "Python: Attach Local", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "justMyCode": false, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + }, + { + // Example of attaching to my production server + "name": "Python: Attach Remote", + "type": "python", + "request": "attach", + "port": 5678, + "host": "homeassistant.local", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/usr/src/homeassistant" + } + ] + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a3d535d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.pythonPath": "/usr/local/bin/python", + "files.associations": { + "*.yaml": "home-assistant" + } +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..7ab4ba8 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,29 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 9123", + "type": "shell", + "command": "container start", + "problemMatcher": [] + }, + { + "label": "Run Home Assistant configuration against /config", + "type": "shell", + "command": "container check", + "problemMatcher": [] + }, + { + "label": "Upgrade Home Assistant to latest dev", + "type": "shell", + "command": "container install", + "problemMatcher": [] + }, + { + "label": "Install a specific version of Home Assistant", + "type": "shell", + "command": "container set-version", + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/custom_components/versatile_thermostat/__init__.py b/custom_components/versatile_thermostat/__init__.py new file mode 100644 index 0000000..4aa2a1d --- /dev/null +++ b/custom_components/versatile_thermostat/__init__.py @@ -0,0 +1,91 @@ +"""The Versatile Thermostat integration.""" +from __future__ import annotations + +from typing import Dict + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .climate import VersatileThermostat + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Versatile Thermostat from a config entry.""" + + _LOGGER.debug( + "Calling async_setup_entry entry: entry_id='%s', value='%s'", + entry.entry_id, + entry.data, + ) + + # hass.data.setdefault(DOMAIN, {}) + + # TODO 1. Create API instance + api: VersatileThermostatAPI = hass.data.get(DOMAIN) + if api is None: + api = VersatileThermostatAPI(hass) + + # TODO 2. Validate the API connection (and authentication) + # TODO 3. Store an API object for your platforms to access + api.add_entry(entry) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + api: VersatileThermostatAPI = hass.data.get(DOMAIN) + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + if api: + api.remove_entry(entry) + + return unload_ok + + +class VersatileThermostatAPI(Dict): + """The VersatileThermostatAPI""" + + _hass: HomeAssistant + # _entries: Dict(str, ConfigEntry) + + def __init__(self, hass): + _LOGGER.debug("building a VersatileThermostatAPI") + super().__init__() + self._hass = hass + # self._entries = dict() + # Add the API in hass.data + self._hass.data[DOMAIN] = self + + def add_entry(self, entry: ConfigEntry): + """Add a new entry""" + _LOGGER.debug("Add the entry %s", entry.entry_id) + # self._entries[entry.entry_id] = entry + # Add the entry in hass.data + self._hass.data[DOMAIN][entry.entry_id] = entry + + def remove_entry(self, entry: ConfigEntry): + """Remove an entry""" + _LOGGER.debug("Remove the entry %s", entry.entry_id) + # self._entries.pop(entry.entry_id) + self._hass.data[DOMAIN].pop(entry.entry_id) + # If not more entries are preset, remove the API + if len(self) == 0: + _LOGGER.debug("No more entries-> Remove the API from DOMAIN") + self._hass.data.pop(DOMAIN) + + @property + def hass(self): + """Get the HomeAssistant object""" + return self._hass diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py new file mode 100644 index 0000000..4f4a4e3 --- /dev/null +++ b/custom_components/versatile_thermostat/climate.py @@ -0,0 +1,691 @@ +import math + +from homeassistant.core import HomeAssistant, callback, CoreState +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_track_time_interval, + async_call_later, +) + +import logging + +_LOGGER = logging.getLogger(__name__) + +from homeassistant.components.climate.const import ( + ATTR_PRESET_MODE, + ATTR_FAN_MODE, + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_ACTIVITY, + PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_HOME, + PRESET_NONE, + PRESET_SLEEP, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) + +from homeassistant.const import ( + UnitOfTemperature, + ATTR_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_OFF, + STATE_ON, + EVENT_HOMEASSISTANT_START, +) + +from .const import ( + DOMAIN, + CONF_HEATER, + CONF_POWER_SENSOR, + CONF_TEMP_SENSOR, + CONF_MAX_POWER_SENSOR, + CONF_MOTION_SENSOR, + CONF_WINDOW_SENSOR, + CONF_DEVICE_POWER, + CONF_PRESETS, + SUPPORT_FLAGS, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the VersatileThermostat thermostat with config flow.""" + _LOGGER.debug( + "Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data + ) + + unique_id = entry.entry_id + name = entry.data.get(CONF_NAME) + heater_entity_id = entry.data.get(CONF_HEATER) + temp_sensor_entity_id = entry.data.get(CONF_TEMP_SENSOR) + power_sensor_entity_id = entry.data.get(CONF_POWER_SENSOR) + max_power_sensor_entity_id = entry.data.get(CONF_MAX_POWER_SENSOR) + window_sensor_entity_id = entry.data.get(CONF_WINDOW_SENSOR) + motion_sensor_entity_id = entry.data.get(CONF_MOTION_SENSOR) + device_power = entry.data.get(CONF_DEVICE_POWER) + + presets = {} + for (key, value) in CONF_PRESETS.items(): + _LOGGER.debug("looking for key=%s, value=%s", key, value) + if value in entry.data: + presets[key] = entry.data.get(value) + else: + _LOGGER.debug("value %s not found in Entry", value) + + async_add_entities( + [ + VersatileThermostat( + unique_id, + name, + heater_entity_id, + temp_sensor_entity_id, + power_sensor_entity_id, + max_power_sensor_entity_id, + window_sensor_entity_id, + motion_sensor_entity_id, + presets, + device_power, + ) + ], + True, + ) + + +class VersatileThermostat(ClimateEntity, RestoreEntity): + """Representation of a Versatile Thermostat device.""" + + _name: str + _heater_entity_id: str + + def __init__( + self, + unique_id, + name, + heater_entity_id, + temp_sensor_entity_id, + power_sensor_entity_id, + max_power_sensor_entity_id, + window_sensor_entity_id, + motion_sensor_entity_id, + presets, + device_power, + ) -> None: + """Initialize the thermostat.""" + + super().__init__() + + self._unique_id = unique_id + self._name = name + self._heater_entity_id = heater_entity_id + self._temp_sensor_entity_id = temp_sensor_entity_id + self._power_sensor_entity_id = power_sensor_entity_id + self._max_power_sensor_entity_id = max_power_sensor_entity_id + self._window_sensor_entity_id = window_sensor_entity_id + self._motion_sensor_entity_id = motion_sensor_entity_id + + # if self.ac_mode: + # self.hvac_list = [HVAC_MODE_COOL, HVAC_MODE_OFF] + # else: + self._hvac_list = [HVAC_MODE_HEAT, HVAC_MODE_OFF] + self._unit = TEMP_CELSIUS + # Will be restored if possible + self._hvac_mode = None # HVAC_MODE_OFF + self._saved_hvac_mode = self._hvac_mode + + self._support_flags = SUPPORT_FLAGS + if len(presets): + self._support_flags = SUPPORT_FLAGS | SUPPORT_PRESET_MODE + self._attr_preset_modes = [PRESET_NONE] + list(presets.keys()) + _LOGGER.debug("Set preset_modes to %s", self._attr_preset_modes) + else: + _LOGGER.debug("No preset_modes") + self._attr_preset_modes = [PRESET_NONE] + self._presets = presets + _LOGGER.debug("%s - presets are set to: %s", self, self._presets) + # Will be restored if possible + self._attr_preset_mode = None # PRESET_NONE + + # Power management + self._device_power = device_power + if ( + self._max_power_sensor_entity_id + and self._power_sensor_entity_id + and self._device_power + ): + self._pmax_on = True + self._current_power = -1 + self._current_power_max = -1 + else: + self._pmax_on = False + + # will be restored if possible + self._target_temp = None + self._saved_target_temp = self._target_temp + self._humidity = None + self._ac_mode = False + self._fan_mode = None + self._swing_mode = None + self._cur_temp = None + + _LOGGER.debug( + "%s - Creation of a new VersatileThermostat entity: unique_id=%s heater_entity_id=%s", + self, + self.unique_id, + heater_entity_id, + ) + + def __str__(self): + return f"VersatileThermostat-{self.name}" + + @property + def unique_id(self): + return self._unique_id + + @property + def should_poll(self): + return False + + @property + def name(self): + return self._name + + @property + def hvac_modes(self): + """List of available operation modes.""" + return self._hvac_list + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit + + @property + def hvac_mode(self): + """Return current operation.""" + return self._hvac_mode + + @property + def hvac_action(self): + """Return the current running hvac operation if supported. + + Need to be one of CURRENT_HVAC_*. + """ + if self._hvac_mode == HVAC_MODE_OFF: + return CURRENT_HVAC_OFF + if not self._is_device_active: + return CURRENT_HVAC_IDLE + if self._ac_mode: + return CURRENT_HVAC_COOL + return CURRENT_HVAC_HEAT + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temp + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + @property + def _is_device_active(self): + """If the toggleable device is currently active.""" + if not self.hass.states.get(self._heater_entity_id): + return None + + return self.hass.states.is_state(self._heater_entity_id, STATE_ON) + + @property + def current_temperature(self): + """Return the sensor temperature.""" + return self._cur_temp + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + _LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode) + if hvac_mode == HVAC_MODE_HEAT: + self._hvac_mode = HVAC_MODE_HEAT + # TODO await self._async_control_heating(force=True) + elif hvac_mode == HVAC_MODE_COOL: + self._hvac_mode = HVAC_MODE_COOL + # TODO await self._async_control_heating(force=True) + elif hvac_mode == HVAC_MODE_OFF: + self._hvac_mode = HVAC_MODE_OFF + # TODO self.prop_current_phase = PROP_PHASE_NONE + # if self._is_device_active: + # await self._async_heater_turn_off() + else: + _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) + return + # Ensure we update the current operation after changing the mode + self.async_write_ha_state() + + async def async_set_preset_mode(self, preset_mode): + """Set new preset mode.""" + _LOGGER.info("%s - Set preset_mode: %s", self, preset_mode) + if preset_mode not in (self._attr_preset_modes or []): + raise ValueError( + f"Got unsupported preset_mode {preset_mode}. Must be one of {self._attr_preset_modes}" + ) + if preset_mode == self._attr_preset_mode: + # I don't think we need to call async_write_ha_state if we didn't change the state + return + if preset_mode == PRESET_NONE: + self._attr_preset_mode = PRESET_NONE + self._target_temp = self._saved_target_temp + # TODO await self._async_control_heating(force=True) + elif preset_mode == PRESET_ACTIVITY: + self._attr_preset_mode = PRESET_ACTIVITY + # TODO self._target_temp = self._presets[self.no_motion_mode] + # await self._async_control_heating(force=True) + else: + if self._attr_preset_mode == PRESET_NONE: + self._saved_target_temp = self._target_temp + self._attr_preset_mode = preset_mode + self._target_temp = self._presets[preset_mode] + # TODO await self._async_control_heating(force=True) + + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + _LOGGER.info("%s - Set fan mode: %s", self, fan_mode) + if fan_mode is None: + return + self._fan_mode = fan_mode + self.async_write_ha_state() + + async def async_set_humidity(self, humidity: int): + """Set new target humidity.""" + _LOGGER.info("%s - Set fan mode: %s", self, humidity) + if humidity is None: + return + self._humidity = humidity + + async def async_set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + _LOGGER.info("%s - Set fan mode: %s", self, swing_mode) + if swing_mode is None: + return + self._swing_mode = swing_mode + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + _LOGGER.info("%s - Set target temp: %s", self, temperature) + if temperature is None: + return + self._target_temp = temperature + self._attr_preset_mode = PRESET_NONE + # TODO await self._async_control_heating(force=True) + self.async_write_ha_state() + + @callback + async def entry_update_listener( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> None: + """Called when the entry have changed in ConfigFlow""" + _LOGGER.info("%s - Change entry with the values: %s", self, config_entry.data) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + # Add listener + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._heater_entity_id], self._async_switch_changed + ) + ) + + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._temp_sensor_entity_id], + self._async_temperature_changed, + ) + ) + if self._window_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._window_sensor_entity_id], + self._async_windows_changed, + ) + ) + if self._motion_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._motion_sensor_entity_id], + self._async_motion_changed, + ) + ) + # if self._keep_alive: + # self.async_on_remove( + # async_track_time_interval( + # self.hass, self._async_control_heating, self._keep_alive + # ) + # ) + if self._power_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._power_sensor_entity_id], + self._async_power_changed, + ) + ) + + if self._max_power_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._max_power_sensor_entity_id], + self._async_max_power_changed, + ) + ) + + await self.async_startup() + + async def async_startup(self): + """Triggered on startup, used to get old state and set internal states accordingly""" + _LOGGER.debug("%s - Calling async_startup", self) + + @callback + def _async_startup_internal(*_): + _LOGGER.debug("%s - Calling async_startup_internal", self) + need_write_state = False + temperature_state = self.hass.states.get(self._temp_sensor_entity_id) + if temperature_state and temperature_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + _LOGGER.debug( + "%s - temperature sensor have been retrieved: %f", + self, + float(temperature_state.state), + ) + # TODO self._async_update_temp(temperature_state) + need_write_state = True + + switch_state = self.hass.states.get(self._heater_entity_id) + if switch_state and switch_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self.hass.create_task(self._check_switch_initial_state()) + + if self._pmax_on: + # try to acquire current power and power max + current_power_state = self.hass.states.get(self._power_sensor_entity_id) + if current_power_state and current_power_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._current_power = float(current_power_state.state) + _LOGGER.debug( + "%s - Current power have been retrieved: %f", + self, + self._current_power, + ) + need_write_state = True + + # Try to acquire power max + current_power_max_state = self.hass.states.get( + self._max_power_sensor_entity_id + ) + if current_power_max_state and current_power_max_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._current_power_max = float(current_power_max_state.state) + _LOGGER.debug( + "%s - Current power max have been retrieved: %f", + self, + self._current_power_max, + ) + need_write_state = True + + if need_write_state: + self.async_write_ha_state() + # TODO self.hass.create_task(self._async_control_heating()) + + if self.hass.state == CoreState.running: + _async_startup_internal() + else: + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, _async_startup_internal + ) + + await self.get_my_previous_state() + + async def get_my_previous_state(self): + """Try to get my previou state""" + # Check If we have an old state + old_state = await self.async_get_last_state() + _LOGGER.debug( + "%s - Calling get_my_previous_state old_state is %s", self, old_state + ) + if old_state is not None: + # If we have no initial temperature, restore + if self._target_temp is None: + # If we have a previously saved temperature + if old_state.attributes.get(ATTR_TEMPERATURE) is None: + if self._ac_mode: + self._target_temp = self.max_temp + else: + self._target_temp = self.min_temp + _LOGGER.warning( + "%s - Undefined target temperature, falling back to %s", + self, + self._target_temp, + ) + else: + self._target_temp = float(old_state.attributes[ATTR_TEMPERATURE]) + + if old_state.attributes.get(ATTR_PRESET_MODE) in self._attr_preset_modes: + self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) + + if not self._hvac_mode and old_state.state: + self._hvac_mode = old_state.state + + else: + # No previous state, try and restore defaults + if self._target_temp is None: + if self._ac_mode: + self._target_temp = self.max_temp + else: + self._target_temp = self.min_temp + _LOGGER.warning( + "No previously saved temperature, setting to %s", self._target_temp + ) + + # Set default state to off + if not self._hvac_mode: + self._hvac_mode = HVAC_MODE_OFF + + _LOGGER.info( + "%s - restored state is target_temp=%f, preset_mode=%s, hvac_mode=%s", + self, + self._target_temp, + self._attr_preset_mode, + self._hvac_mode, + ) + + @callback + async def _async_temperature_changed(self, event): + """Handle temperature changes.""" + new_state = event.data.get("new_state") + _LOGGER.info( + "%s - Temperature changed. Event.new_state is %s", + self, + new_state, + ) + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return + + self._async_update_temp(new_state) + # TODO await self._async_control_heating() + self.async_write_ha_state() + + @callback + async def _async_windows_changed(self, event): + """Handle window changes.""" + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + _LOGGER.info( + "%s - Window changed. Event.new_state is %s, _hvac_mode=%s, _saved_hvac_mode=%s", + self, + new_state, + self._hvac_mode, + self._saved_hvac_mode, + ) + if new_state is None or old_state is None or new_state.state == old_state.state: + return + if not self._saved_hvac_mode: + self._saved_hvac_mode = self._hvac_mode + if new_state.state == STATE_OFF: + await self.async_set_hvac_mode(self._saved_hvac_mode) + elif new_state.state == STATE_ON: + self._saved_hvac_mode = self._hvac_mode + await self.async_set_hvac_mode(HVAC_MODE_OFF) + else: + return + + @callback + async def _async_motion_changed(self, event): + """Handle motion changes.""" + new_state = event.data.get("new_state") + _LOGGER.info( + "%s - Motion changed. Event.new_state is %s, _attr_preset_mode=%s, activity=%s", + self, + new_state, + self._attr_preset_mode, + PRESET_ACTIVITY, + ) + if self._attr_preset_mode != PRESET_ACTIVITY: + return + if new_state is None or new_state.state not in (STATE_OFF, STATE_ON): + return + + # if self.motion_delay: + # if new_state.state == STATE_ON: + # self._target_temp = self._presets[self.motion_mode] + # await self._async_control_heating() + # self.async_write_ha_state() + # else: + # async def try_no_motion_condition(_): + # if self._attr_preset_mode != PRESET_ACTIVITY: + # return + # try: + # long_enough = condition.state( + # self.hass, + # self.motion_entity_id, + # STATE_OFF, + # self.motion_delay, + # ) + # except ConditionError: + # long_enough = False + # if long_enough: + # self._target_temp = self._presets[self.no_motion_mode] + # await self._async_control_heating() + # self.async_write_ha_state() + # + # async_call_later(self.hass, self.motion_delay, try_no_motion_condition) + + @callback + async def _check_switch_initial_state(self): + """Prevent the device from keep running if HVAC_MODE_OFF.""" + _LOGGER.debug("%s - Calling _check_switch_initial_state", self) + if self._hvac_mode == HVAC_MODE_OFF and self._is_device_active: + _LOGGER.warning( + "The climate mode is OFF, but the switch device is ON. Turning off device %s", + self._heater_entity_id, + ) + # TODO await self._async_heater_turn_off() + + @callback + def _async_switch_changed(self, event): + """Handle heater switch state changes.""" + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + if new_state is None: + return + if old_state is None: + self.hass.create_task(self._check_switch_initial_state()) + self.async_write_ha_state() + + @callback + def _async_update_temp(self, state): + """Update thermostat with latest state from sensor.""" + try: + cur_temp = float(state.state) + if math.isnan(cur_temp) or math.isinf(cur_temp): + raise ValueError(f"Sensor has illegal state {state.state}") + self._cur_temp = cur_temp + except ValueError as ex: + _LOGGER.error("Unable to update temperature from sensor: %s", ex) + + @callback + async def _async_power_changed(self, event): + """Handle power changes.""" + _LOGGER.debug("Thermostat %s - Receive new Power event", self.name) + _LOGGER.debug(event) + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + if new_state is None or new_state.state == old_state.state: + return + + try: + current_power = float(new_state.state) + if math.isnan(current_power) or math.isinf(current_power): + raise ValueError(f"Sensor has illegal state {new_state.state}") + self._current_power = current_power + + except ValueError as ex: + _LOGGER.error("Unable to update current_power from sensor: %s", ex) + + @callback + async def _async_max_power_changed(self, event): + """Handle power max changes.""" + _LOGGER.debug("Thermostat %s - Receive new Power Max event", self.name) + _LOGGER.debug(event) + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + if new_state is None or new_state.state == old_state.state: + return + + try: + current_power_max = float(new_state.state) + if math.isnan(current_power_max) or math.isinf(current_power_max): + raise ValueError(f"Sensor has illegal state {new_state.state}") + self._current_power_max = current_power_max + + except ValueError as ex: + _LOGGER.error("Unable to update current_power from sensor: %s", ex) diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py new file mode 100644 index 0000000..ba864e4 --- /dev/null +++ b/custom_components/versatile_thermostat/config_flow.py @@ -0,0 +1,195 @@ +"""Config flow for Versatile Thermostat integration.""" +from __future__ import annotations +from homeassistant.core import callback + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow as HAConfigFlow, + OptionsFlow, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +import homeassistant.helpers.config_validation as cv + +from .const import ( + DOMAIN, + CONF_NAME, + CONF_HEATER, + CONF_TEMP_SENSOR, + CONF_POWER_SENSOR, + CONF_MAX_POWER_SENSOR, + CONF_WINDOW_SENSOR, + CONF_MOTION_SENSOR, + CONF_DEVICE_POWER, + ALL_CONF, + CONF_PRESETS, +) + +# from .climate import VersatileThermostat + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_HEATER): cv.string, + vol.Required(CONF_TEMP_SENSOR): cv.string, + vol.Optional(CONF_POWER_SENSOR): cv.string, + vol.Optional(CONF_MAX_POWER_SENSOR): cv.string, + vol.Optional(CONF_WINDOW_SENSOR): cv.string, + vol.Optional(CONF_MOTION_SENSOR): cv.string, + vol.Optional(CONF_DEVICE_POWER): vol.Coerce(float), + } +).extend( + {vol.Optional(v, default=17): vol.Coerce(float) for (k, v) in CONF_PRESETS.items()} +) + + +def schema_defaults(schema, **defaults): + """Create a new schema with default values filled in.""" + copy = schema.extend({}) + for field, field_type in copy.schema.items(): + if isinstance(field_type, vol.In): + value = None + # for dps in dps_list or []: + # if dps.startswith(f"{defaults.get(field)} "): + # value = dps + # break + + if value in field_type.container: + field.default = vol.default_factory(value) + continue + + if field.schema in defaults: + field.default = vol.default_factory(defaults[field]) + return copy + + +# class PlaceholderHub: +# """Placeholder class to make tests pass. +# +# TODO Remove this placeholder class and replace with things from your PyPI package. +# """ +# +# def __init__(self, name: str, heater_entity_id: str) -> None: +# """Initialize.""" +# self.name = name +# self.heater_entity_id = heater_entity_id +# +# # async def authenticate(self, username: str, password: str) -> bool: +# # """Test if we can authenticate with the host.""" +# # return True + + +async def validate_input( + hass: HomeAssistant, data: dict[str, str, str, Any] +) -> dict[str]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + + # If your PyPI package is not built with async, pass your methods + # to the executor: + # await hass.async_add_executor_job( + # your_validate_func, data["username"], data["password"] + # ) + + # hub = PlaceholderHub(data["name"], data["heater_id"]) + + # if not await hub.authenticate(data["username"], data["password"]): + # raise InvalidAuth + + # Return info that you want to store in the config entry. + return {"title": data["name"]} + + +class VersatileThermostatConfigFlow(HAConfigFlow, domain=DOMAIN): + """Handle a config flow for Versatile Thermostat.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry): + """Get options flow for this handler.""" + return VersatileThermostatOptionsFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class VersatileThermostatOptionsFlowHandler(OptionsFlow): + """Handle options flow for Versatile Thermostat integration.""" + + def __init__(self, config_entry: ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + _LOGGER.debug( + "CTOR VersatileThermostatOptionsFlowHandler config_entry.data: %s, entry_id: %s", + config_entry.data, + config_entry.entry_id, + ) + + async def async_step_init(self, user_input=None): + """Manage basic options.""" + _LOGGER.debug( + "Into VersatileThermostatOptionsFlowHandler.async_step_init user_input =%s, config_entry.data=%s", + user_input, + self.config_entry.data, + ) + if user_input is not None: + _LOGGER.debug("We receive the new values: %s", user_input) + data = dict(self.config_entry.data) + for conf in ALL_CONF: + data[conf] = user_input.get(conf) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) + return self.async_create_entry(title=None, data=None) + else: + defaults = self.config_entry.data.copy() + defaults.update(user_input or {}) + user_data_schema = schema_defaults(STEP_USER_DATA_SCHEMA, **defaults) + + return self.async_show_form( + step_id="init", + data_schema=user_data_schema, + ) diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py new file mode 100644 index 0000000..1b73094 --- /dev/null +++ b/custom_components/versatile_thermostat/const.py @@ -0,0 +1,47 @@ +"""Constants for the Versatile Thermostat integration.""" + +from homeassistant.const import CONF_NAME +from homeassistant.components.climate.const import ( + PRESET_ACTIVITY, + PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + SUPPORT_TARGET_TEMPERATURE, +) + +PRESET_POWER = "power" + +DOMAIN = "versatile_thermostat" + +CONF_HEATER = "heater_entity_id" +CONF_TEMP_SENSOR = "temperature_sensor_entity_id" +CONF_POWER_SENSOR = "power_sensor_entity_id" +CONF_MAX_POWER_SENSOR = "max_power_sensor_entity_id" +CONF_WINDOW_SENSOR = "window_sensor_entity_id" +CONF_MOTION_SENSOR = "motion_sensor_entity_id" +CONF_DEVICE_POWER = "device_power" + +ALL_CONF = [ + CONF_NAME, + CONF_HEATER, + CONF_TEMP_SENSOR, + CONF_POWER_SENSOR, + CONF_MAX_POWER_SENSOR, + CONF_WINDOW_SENSOR, + CONF_MOTION_SENSOR, + CONF_DEVICE_POWER, +] + +CONF_PRESETS = { + p: f"{p}_temp" + for p in ( + PRESET_ECO, + PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_POWER, + ) +} + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE diff --git a/custom_components/versatile_thermostat/manifest.json b/custom_components/versatile_thermostat/manifest.json new file mode 100644 index 0000000..3b4111a --- /dev/null +++ b/custom_components/versatile_thermostat/manifest.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "domain": "versatile_thermostat", + "name": "Versatile Thermostat", + "config_flow": true, + "documentation": "https://github.com/jmcollin78/versatile_thermostat", + "requirements": [], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@jmcollin78" + ], + "iot_class": "local_polling", + "integration_type": "device" +} diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json new file mode 100644 index 0000000..d55836b --- /dev/null +++ b/custom_components/versatile_thermostat/strings.json @@ -0,0 +1,28 @@ +{ + "title": "Versatile Thermostat configuration", + "config": { + "step": { + "user": { + "title": "Add new Versatile Thermostat", + "data": { + "name": "[%key:config::data::name%]", + "heater_entity_id": "[%key:config::data::heater_entity_id%]", + "temperature_sensor_entity_id": "[%key:config::data::temperature_sensor_entity_id%]", + "power_sensor_entity_id": "[%key:config::data::power_sensor_entity_id%]", + "max_power_sensor_entity_id": "[%key:config::data::max_power_sensor_entity_id%]", + "window_sensor_entity_id": "[%key:config::data::window_sensor_entity_id%]", + "motion_sensor_entity_id": "[%key:config::data::motion_sensor_entity_id%]", + "device_power": "[%key:config::data::device_power%]", + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json new file mode 100644 index 0000000..5611131 --- /dev/null +++ b/custom_components/versatile_thermostat/translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "name": "Name", + "heater_entity_id": "Heater entity id", + "temperature_sensor_entity_id": "Temperature sensor entity id", + "power_sensor_entity_id": "Power sensor entity id", + "max_power_sensor_entity_id": "Max power sensor entity id", + "window_sensor_entity_id": "Window sensor entity id", + "motion_sensor_entity_id": "Motion sensor entity id", + "device_power": "Device power (kW)" + } + } + } + } +} \ No newline at end of file