├── .devcontainer.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── config.yml │ └── feature_request.yml ├── dependabot.yml └── workflows │ ├── build-publish-dummy-server.yml │ ├── lint.yml │ ├── release.yml │ └── validate.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .ruff.toml ├── .vscode └── tasks.json ├── CONTRIBUTING.md ├── LICENSE ├── config.png ├── config └── configuration.yaml ├── custom_components ├── __init__.py └── argoclima │ ├── __init__.py │ ├── api.py │ ├── climate.py │ ├── config_flow.py │ ├── const.py │ ├── data.py │ ├── device_type.py │ ├── entity.py │ ├── manifest.json │ ├── number.py │ ├── select.py │ ├── service.py │ ├── services.yaml │ ├── switch.py │ ├── translations │ ├── climate.de.json │ ├── climate.en.json │ ├── de.json │ ├── en.json │ ├── select.de.json │ └── select.en.json │ ├── types.py │ └── update_coordinator.py ├── dummy-server ├── Dockerfile ├── go.mod ├── go.sum └── main.go ├── hacs.json ├── info.md ├── readme.md ├── requirements.txt └── scripts ├── develop ├── lint └── setup /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Argoclima integration development", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [ 6 | 8123 7 | ], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant", 11 | "onAutoForward": "notify" 12 | } 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "ms-python.python", 18 | "github.vscode-pull-request-github", 19 | "ryanluker.vscode-coverage-gutters", 20 | "ms-python.vscode-pylance" 21 | ], 22 | "settings": { 23 | "files.eol": "\n", 24 | "editor.tabSize": 4, 25 | "python.pythonPath": "/usr/bin/python3", 26 | "python.analysis.autoSearchPaths": false, 27 | "python.linting.pylintEnabled": true, 28 | "python.linting.enabled": true, 29 | "python.formatting.provider": "black", 30 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 31 | "editor.formatOnPaste": false, 32 | "editor.formatOnSave": true, 33 | "editor.formatOnType": true, 34 | "files.trimTrailingWhitespace": true 35 | } 36 | } 37 | }, 38 | "remoteUser": "vscode", 39 | "features": { 40 | "ghcr.io/devcontainers/features/rust:1": {} 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report" 3 | description: "Report a bug with the integration" 4 | labels: "Bug" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before you open a new issue, search through the existing issues to see if others have had the same problem. 9 | - type: textarea 10 | attributes: 11 | label: "System Health details" 12 | description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" 13 | validations: 14 | required: true 15 | - type: checkboxes 16 | attributes: 17 | label: Checklist 18 | options: 19 | - label: I have enabled debug logging for my installation. 20 | required: true 21 | - label: I have filled out the issue template to the best of my ability. 22 | required: true 23 | - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). 24 | required: true 25 | - label: This issue is not a duplicate issue of any [previous issues](https://github.com/nyffchanium/argoclima-integration/issues?q=is%3Aissue+label%3A%22Bug%22+).. 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: "Describe the issue" 30 | description: "A clear and concise description of what the issue is." 31 | validations: 32 | required: true 33 | - type: textarea 34 | attributes: 35 | label: Reproduction steps 36 | description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed." 37 | value: | 38 | 1. 39 | 2. 40 | 3. 41 | ... 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: "Debug logs" 47 | description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." 48 | render: text 49 | validations: 50 | required: true 51 | 52 | - type: textarea 53 | attributes: 54 | label: "Diagnostics dump" 55 | description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request" 3 | description: "Suggest an idea for this project" 4 | labels: "Feature+Request" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. 9 | - type: checkboxes 10 | attributes: 11 | label: Checklist 12 | options: 13 | - label: I have filled out the template to the best of my ability. 14 | required: true 15 | - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). 16 | required: true 17 | - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/nyffchanium/argoclima-integration/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). 18 | required: true 19 | 20 | - type: textarea 21 | attributes: 22 | label: "Is your feature request related to a problem? Please describe." 23 | description: "A clear and concise description of what the problem is." 24 | placeholder: "I'm always frustrated when [...]" 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | attributes: 30 | label: "Describe the solution you'd like" 31 | description: "A clear and concise description of what you want to happen." 32 | validations: 33 | required: true 34 | 35 | - type: textarea 36 | attributes: 37 | label: "Describe alternatives you've considered" 38 | description: "A clear and concise description of any alternative solutions or features you've considered." 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | attributes: 44 | label: "Additional context" 45 | description: "Add any other context or screenshots about the feature request here." 46 | validations: 47 | required: true 48 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | ignore: 14 | # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json 15 | - dependency-name: "homeassistant" 16 | -------------------------------------------------------------------------------- /.github/workflows/build-publish-dummy-server.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish dummy server Docker image 2 | 3 | on: workflow_dispatch 4 | 5 | env: 6 | IMAGE_NAME: argoclima-dummy-server 7 | 8 | jobs: 9 | build-and-push-image: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | packages: write 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Log in to the Container registry 20 | uses: docker/login-action@v3 21 | with: 22 | username: ${{ secrets.DOCKERHUB_USERNAME }} 23 | password: ${{ secrets.DOCKERHUB_TOKEN }} 24 | 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v3 27 | 28 | - name: Build and push Docker image 29 | uses: docker/build-push-action@v5 30 | with: 31 | context: dummy-server 32 | push: true 33 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest 34 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | 11 | jobs: 12 | ruff: 13 | name: "Ruff" 14 | runs-on: "ubuntu-latest" 15 | steps: 16 | - name: "Checkout the repository" 17 | uses: "actions/checkout@v4" 18 | 19 | - name: "Set up Python" 20 | uses: actions/setup-python@v5.0.0 21 | with: 22 | python-version: "3.11" 23 | cache: "pip" 24 | 25 | - name: "Install requirements" 26 | run: python3 -m pip install -r requirements.txt 27 | 28 | - name: "Run" 29 | run: python3 -m ruff check . 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | release: 5 | types: 6 | - "published" 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | release: 12 | name: "Release" 13 | runs-on: "ubuntu-latest" 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: "Checkout the repository" 18 | uses: "actions/checkout@v4" 19 | 20 | - name: "Adjust version number" 21 | shell: "bash" 22 | run: | 23 | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ 24 | "${{ github.workspace }}/custom_components/argoclima/manifest.json" 25 | 26 | - name: "ZIP the integration directory" 27 | shell: "bash" 28 | run: | 29 | cd "${{ github.workspace }}/custom_components/argoclima" 30 | zip argoclima_integration.zip -r ./ 31 | 32 | - name: "Upload the ZIP file to the release" 33 | uses: softprops/action-gh-release@v0.1.15 34 | with: 35 | files: ${{ github.workspace }}/custom_components/argoclima/argoclima_integration.zip 36 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: "Validate" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: 9 | - "main" 10 | pull_request: 11 | branches: 12 | - "main" 13 | 14 | jobs: 15 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 16 | name: "Hassfest Validation" 17 | runs-on: "ubuntu-latest" 18 | steps: 19 | - name: "Checkout the repository" 20 | uses: "actions/checkout@v4" 21 | 22 | - name: "Run hassfest validation" 23 | uses: "home-assistant/actions/hassfest@master" 24 | 25 | hacs: # https://github.com/hacs/action 26 | name: "HACS Validation" 27 | runs-on: "ubuntu-latest" 28 | steps: 29 | - name: "Checkout the repository" 30 | uses: "actions/checkout@v4" 31 | 32 | - name: "Run HACS validation" 33 | uses: "hacs/action@main" 34 | with: 35 | category: "integration" 36 | # Remove this 'ignore' key when you have added brand images for your integration to https://github.com/home-assistant/brands 37 | ignore: "brands" 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | 8 | 9 | # misc 10 | .coverage 11 | .vscode 12 | coverage.xml 13 | 14 | 15 | # Home Assistant configuration 16 | config/* 17 | !config/configuration.yaml 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | rev: v0.1.13 11 | hooks: 12 | - id: ruff 13 | args: [ --fix ] 14 | - id: ruff-format 15 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py310" 4 | 5 | select = [ 6 | "B007", # Loop control variable {name} not used within loop body 7 | "B014", # Exception handler with duplicate exception 8 | "C", # complexity 9 | "E", # pycodestyle 10 | "F", # pyflakes/autoflake 11 | "ICN001", # import concentions; {name} should be imported as {asname} 12 | "PGH004", # Use specific rule codes when using noqa 13 | "PLC0414", # Useless import alias. Import alias does not rename original package. 14 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 15 | "SIM117", # Merge with-statements that use the same scope 16 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 17 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 18 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 19 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 20 | "SIM401", # Use get from dict with default instead of an if block 21 | "T20", # flake8-print 22 | "TRY004", # Prefer TypeError exception for invalid type 23 | "RUF006", # Store a reference to the return value of asyncio.create_task 24 | "UP", # pyupgrade 25 | "W", # pycodestyle 26 | ] 27 | 28 | ignore = [ 29 | "E501", # line too long 30 | "E731", # do not assign a lambda expression, use a def 31 | ] 32 | 33 | [flake8-pytest-style] 34 | fixture-parentheses = false 35 | 36 | [pyupgrade] 37 | keep-runtime-typing = true 38 | 39 | [mccabe] 40 | max-complexity = 25 41 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 8123", 6 | "type": "shell", 7 | "command": "scripts/develop", 8 | "problemMatcher": [] 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using `scripts/lint`). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People *love* thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 48 | 49 | Or use the `pre-commit` settings implemented in this repository 50 | (see deicated section below). 51 | 52 | ## Test your code modification 53 | 54 | This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). 55 | 56 | It comes with development environment in a container, easy to launch 57 | if you use Visual Studio Code. With this container you will have a stand alone 58 | Home Assistant instance running and already configured with the included 59 | [`configuration.yaml`](./config/configuration.yaml) 60 | file. 61 | 62 | You can use the `pre-commit` settings implemented in this repository to have 63 | linting tool checking your contributions (see deicated section below). 64 | 65 | ## Pre-commit 66 | 67 | You can use the [pre-commit](https://pre-commit.com/) settings included in the 68 | repostory to have code style and linting checks. 69 | 70 | With `pre-commit` tool already installed, 71 | activate the settings of the repository: 72 | 73 | ```console 74 | $ pre-commit install 75 | ``` 76 | 77 | Now the pre-commit tests will be done every time you commit. 78 | 79 | You can run the tests on all repository file with the command: 80 | 81 | ```console 82 | $ pre-commit run --all-files 83 | ``` 84 | 85 | ## License 86 | 87 | By contributing, you agree that your contributions will be licensed under its MIT License. 88 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 - 2024 nyffchanium 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyffchanium/argoclima-integration/0d0dcfa562a47348f899e4481701a67b55bb5cf8/config.png -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # https://www.home-assistant.io/integrations/default_config/ 2 | default_config: 3 | 4 | # https://www.home-assistant.io/integrations/logger/ 5 | logger: 6 | default: info 7 | logs: 8 | custom_components.argoclima: debug 9 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom components module.""" 2 | -------------------------------------------------------------------------------- /custom_components/argoclima/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from custom_components.argoclima.api import ArgoApiClient 5 | from custom_components.argoclima.const import CONF_DEVICE_TYPE 6 | from custom_components.argoclima.const import CONF_HOST 7 | from custom_components.argoclima.const import DOMAIN 8 | from custom_components.argoclima.const import STARTUP_MESSAGE 9 | from custom_components.argoclima.device_type import ArgoDeviceType 10 | from custom_components.argoclima.service import setup_service 11 | from custom_components.argoclima.update_coordinator import ArgoDataUpdateCoordinator 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.core import Config 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.exceptions import ConfigEntryNotReady 16 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 17 | 18 | 19 | _LOGGER: logging.Logger = logging.getLogger(__package__) 20 | 21 | 22 | async def async_setup(hass: HomeAssistant, config: Config): 23 | await setup_service(hass) 24 | return True 25 | 26 | 27 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 28 | """Set up this integration using UI.""" 29 | if hass.data.get(DOMAIN) is None: 30 | hass.data.setdefault(DOMAIN, {}) 31 | _LOGGER.info(STARTUP_MESSAGE) 32 | 33 | type = ArgoDeviceType.from_name(entry.data.get(CONF_DEVICE_TYPE)) 34 | host: str = entry.data.get(CONF_HOST) 35 | 36 | session = async_get_clientsession(hass) 37 | client = ArgoApiClient(type, host, session) 38 | 39 | coordinator = ArgoDataUpdateCoordinator(hass, client, type) 40 | await coordinator.async_refresh() 41 | 42 | if not coordinator.last_update_success: 43 | raise ConfigEntryNotReady 44 | 45 | hass.data[DOMAIN][entry.entry_id] = coordinator 46 | 47 | for platform in type.platforms: 48 | coordinator.platforms.append(platform) 49 | hass.async_add_job( 50 | hass.config_entries.async_forward_entry_setup(entry, platform) 51 | ) 52 | 53 | entry.add_update_listener(async_reload_entry) 54 | 55 | return True 56 | 57 | 58 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 59 | """Handle removal of an entry.""" 60 | coordinator = hass.data[DOMAIN][entry.entry_id] 61 | type: ArgoDeviceType = ArgoDeviceType.from_name(entry.data.get(CONF_DEVICE_TYPE)) 62 | unloaded = all( 63 | await asyncio.gather( 64 | *[ 65 | hass.config_entries.async_forward_entry_unload(entry, platform) 66 | for platform in type.platforms 67 | if platform in coordinator.platforms 68 | ] 69 | ) 70 | ) 71 | if unloaded: 72 | hass.data[DOMAIN].pop(entry.entry_id) 73 | 74 | return unloaded 75 | 76 | 77 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 78 | """Reload config entry.""" 79 | await async_unload_entry(hass, entry) 80 | await async_setup_entry(hass, entry) 81 | -------------------------------------------------------------------------------- /custom_components/argoclima/api.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import async_timeout 3 | from custom_components.argoclima.data import ArgoData 4 | from custom_components.argoclima.device_type import ArgoDeviceType 5 | 6 | TIMEOUT = 10 7 | 8 | HEADERS = {"Content-type": "text/html"} 9 | 10 | 11 | class ArgoApiClient: 12 | def __init__( 13 | self, type: ArgoDeviceType, host: str, session: aiohttp.ClientSession 14 | ) -> None: 15 | self._host = host 16 | self._port = type.port 17 | self._type = type 18 | self._session = session 19 | 20 | async def async_sync_data(self, data: ArgoData) -> ArgoData: 21 | if data is None: 22 | data = ArgoData(self._type) 23 | 24 | url = f"http://{self._host}:{self._port}/?HMI={data.to_parameter_string()}&UPD={1 if data.is_update_pending() else 0}" 25 | 26 | async with async_timeout.timeout(TIMEOUT): 27 | response = await self._session.get(url, headers=HEADERS) 28 | data.parse_response_parameter_string(await response.text()) 29 | return data 30 | -------------------------------------------------------------------------------- /custom_components/argoclima/climate.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | from custom_components.argoclima.const import DOMAIN 4 | from custom_components.argoclima.data import ArgoFanSpeed 5 | from custom_components.argoclima.data import ArgoOperationMode 6 | from custom_components.argoclima.device_type import InvalidOperationError 7 | from custom_components.argoclima.entity import ArgoEntity 8 | from custom_components.argoclima.types import ArgoUnit 9 | from homeassistant.components.climate import ClimateEntity 10 | from homeassistant.components.climate.const import ClimateEntityFeature 11 | from homeassistant.components.climate.const import HVACMode 12 | from homeassistant.components.climate.const import PRESET_BOOST 13 | from homeassistant.components.climate.const import PRESET_ECO 14 | from homeassistant.components.climate.const import PRESET_NONE 15 | from homeassistant.components.climate.const import PRESET_SLEEP 16 | from homeassistant.config_entries import ConfigEntry 17 | from homeassistant.core import HomeAssistant 18 | 19 | 20 | async def async_setup_entry( 21 | hass: HomeAssistant, 22 | entry: ConfigEntry, 23 | async_add_devices: Callable[[list[ClimateEntity]], None], 24 | ): 25 | coordinator = hass.data[DOMAIN][entry.entry_id] 26 | async_add_devices([ArgoEntityClimate(coordinator, entry)]) 27 | 28 | 29 | class ArgoEntityClimate(ArgoEntity, ClimateEntity): 30 | def __init__(self, coordinator, entry: ConfigEntry): 31 | ArgoEntity.__init__(self, "Climate", coordinator, entry) 32 | ClimateEntity.__init__(self) 33 | 34 | @property 35 | def temperature_unit(self): 36 | if not self._type.current_temperature: 37 | raise InvalidOperationError 38 | return ArgoUnit.CELSIUS.to_ha_unit() 39 | 40 | @property 41 | def current_temperature(self): 42 | if not self._type.current_temperature: 43 | raise InvalidOperationError 44 | return self.coordinator.data.temp 45 | 46 | @property 47 | def target_temperature(self): 48 | if not self._type.target_temperature: 49 | raise InvalidOperationError 50 | return self.coordinator.data.target_temp 51 | 52 | @property 53 | def max_temp(self): 54 | if not self._type.target_temperature: 55 | raise InvalidOperationError 56 | return self._type.target_temperature_max 57 | 58 | @property 59 | def min_temp(self): 60 | if not self._type.target_temperature: 61 | raise InvalidOperationError 62 | return self._type.target_temperature_min 63 | 64 | @property 65 | def hvac_mode(self): 66 | if not self._type.operation_mode: 67 | raise InvalidOperationError 68 | if not self.coordinator.data.operating: 69 | return HVACMode.OFF 70 | return self.coordinator.data.mode.to_hvac_mode() 71 | 72 | @property 73 | def hvac_modes(self): 74 | if not self._type.operation_mode: 75 | raise InvalidOperationError 76 | modes = [HVACMode.OFF] 77 | for mode in self._type.operation_modes: 78 | modes.append(mode.to_hvac_mode()) 79 | return modes 80 | 81 | @property 82 | def preset_mode(self): 83 | if not self._type.preset: 84 | raise InvalidOperationError 85 | if self.coordinator.data.eco: 86 | return PRESET_ECO 87 | if self.coordinator.data.turbo: 88 | return PRESET_BOOST 89 | if self.coordinator.data.night: 90 | return PRESET_SLEEP 91 | return PRESET_NONE 92 | 93 | @property 94 | def preset_modes(self): 95 | if not self._type.preset: 96 | raise InvalidOperationError 97 | modes = [PRESET_NONE] 98 | if self._type.eco_mode: 99 | modes.append(PRESET_ECO) 100 | if self._type.turbo_mode: 101 | modes.append(PRESET_BOOST) 102 | if self._type.night_mode: 103 | modes.append(PRESET_SLEEP) 104 | return modes 105 | 106 | @property 107 | def fan_mode(self): 108 | if not self._type.fan_speed: 109 | raise InvalidOperationError 110 | return self.coordinator.data.fan.to_ha_string() 111 | 112 | @property 113 | def fan_modes(self): 114 | if not self._type.fan_speed: 115 | raise InvalidOperationError 116 | modes = [] 117 | for speed in self._type.fan_speeds: 118 | modes.append(speed.to_ha_string()) 119 | return modes 120 | 121 | @property 122 | def supported_features(self): 123 | features = 0 124 | if self._type.target_temperature: 125 | features |= ClimateEntityFeature.TARGET_TEMPERATURE 126 | if self._type.fan_speed: 127 | features |= ClimateEntityFeature.FAN_MODE 128 | if self._type.preset: 129 | features |= ClimateEntityFeature.PRESET_MODE 130 | return features 131 | 132 | async def async_set_hvac_mode(self, hvac_mode): 133 | """Set new target hvac mode.""" 134 | if not self._type.operation_mode: 135 | raise InvalidOperationError 136 | data = self.coordinator.data 137 | if hvac_mode == HVACMode.OFF: 138 | data.operating = False 139 | else: 140 | data.operating = True 141 | data.mode = ArgoOperationMode.from_hvac_mode(hvac_mode) 142 | await self.coordinator.async_request_refresh() 143 | 144 | async def async_set_preset_mode(self, preset_mode): 145 | """Set new target preset mode.""" 146 | if not self._type.preset: 147 | raise InvalidOperationError 148 | data = self.coordinator.data 149 | # TODO looks like all modes can be active simultaneously 150 | data.eco = preset_mode == PRESET_ECO 151 | data.turbo = preset_mode == PRESET_BOOST 152 | data.night = preset_mode == PRESET_SLEEP 153 | await self.coordinator.async_request_refresh() 154 | 155 | async def async_set_fan_mode(self, fan_mode): 156 | """Set new target fan mode.""" 157 | if not self._type.fan_speed: 158 | raise InvalidOperationError 159 | self.coordinator.data.fan = ArgoFanSpeed.from_ha_string(fan_mode) 160 | await self.coordinator.async_request_refresh() 161 | 162 | async def async_set_temperature(self, **kwargs): 163 | """Set new target temperature.""" 164 | if not self._type.target_temperature: 165 | raise InvalidOperationError 166 | if "temperature" in kwargs: 167 | self.coordinator.data.target_temp = kwargs["temperature"] 168 | await self.coordinator.async_request_refresh() 169 | -------------------------------------------------------------------------------- /custom_components/argoclima/config_flow.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import voluptuous as vol 4 | from custom_components.argoclima.api import ArgoApiClient 5 | from custom_components.argoclima.const import ARGO_DEVICE_ULISSE_ECO 6 | from custom_components.argoclima.const import ARGO_DEVICES 7 | from custom_components.argoclima.const import CONF_DEVICE_TYPE 8 | from custom_components.argoclima.const import CONF_HOST 9 | from custom_components.argoclima.const import CONF_NAME 10 | from custom_components.argoclima.const import DOMAIN 11 | from custom_components.argoclima.data import ArgoData 12 | from custom_components.argoclima.device_type import ArgoDeviceType 13 | from homeassistant import config_entries 14 | from homeassistant.core import callback 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.data_entry_flow import FlowResult 17 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 18 | 19 | 20 | async def async_test_host(hass: HomeAssistant, type: ArgoDeviceType, host: str): 21 | """Return true if host seems to be a supported device.""" 22 | try: 23 | session = async_create_clientsession(hass) 24 | client = ArgoApiClient(type, host, session) 25 | result = await client.async_sync_data(ArgoData(type)) 26 | return result is not None 27 | except Exception: # pylint: disable=broad-except 28 | pass 29 | return False 30 | 31 | 32 | class ArgoFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 33 | VERSION = 1 34 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 35 | 36 | def __init__(self): 37 | """Initialize.""" 38 | super().__init__() 39 | self._errors = {} 40 | 41 | @staticmethod 42 | @callback 43 | def async_get_options_flow(config_entry) -> "ArgoOptionsFlowHandler": 44 | return ArgoOptionsFlowHandler(config_entry) 45 | 46 | async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult: 47 | """Handle a flow initialized by the user.""" 48 | self._errors = {} 49 | 50 | if user_input is not None: 51 | type = ArgoDeviceType.from_name(user_input[CONF_DEVICE_TYPE]) 52 | if type is not None: 53 | hostOk = await async_test_host(self.hass, type, user_input[CONF_HOST]) 54 | if hostOk: 55 | return self.async_create_entry( 56 | title=user_input[CONF_NAME], 57 | data={ 58 | CONF_DEVICE_TYPE: user_input[CONF_DEVICE_TYPE], 59 | CONF_HOST: user_input[CONF_HOST], 60 | }, 61 | ) 62 | else: 63 | self._errors["base"] = "host" 64 | else: 65 | self._errors["base"] = "invalid_device_type" 66 | 67 | return self._show_config_form(user_input) 68 | 69 | def _show_config_form(self, user_input: dict[str, Any]) -> FlowResult: 70 | def default(key: str, default: str = None): 71 | if user_input is not None and user_input[key] is not None: 72 | return user_input[key] 73 | return default 74 | 75 | return self.async_show_form( 76 | step_id="user", 77 | data_schema=vol.Schema( 78 | { 79 | vol.Required( 80 | CONF_DEVICE_TYPE, 81 | default=default(CONF_DEVICE_TYPE, ARGO_DEVICE_ULISSE_ECO), 82 | ): vol.In(ARGO_DEVICES), 83 | vol.Required(CONF_NAME, default=default(CONF_NAME)): str, 84 | vol.Required(CONF_HOST, default=default(CONF_HOST)): str, 85 | } 86 | ), 87 | errors=self._errors, 88 | ) 89 | 90 | 91 | class ArgoOptionsFlowHandler(config_entries.OptionsFlow): 92 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None: 93 | """Initialize HACS options flow.""" 94 | super().__init__() 95 | self._errors = {} 96 | self.config_entry = config_entry 97 | self.data = dict(config_entry.data) 98 | 99 | async def async_step_init(self, user_input: dict[str, Any] = None) -> FlowResult: 100 | """Manage the options.""" 101 | return await self.async_step_user(user_input) 102 | 103 | async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult: 104 | """Handle a flow initialized by the user.""" 105 | if user_input is not None: 106 | type = ArgoDeviceType.from_name(self.data.get(CONF_DEVICE_TYPE)) 107 | hostOk = await async_test_host(self.hass, type, user_input[CONF_HOST]) 108 | if hostOk: 109 | self.data.update({CONF_HOST: user_input[CONF_HOST]}) 110 | return self.async_create_entry(title="", data=self.data) 111 | else: 112 | self._errors["base"] = "host" 113 | 114 | return self._async_show_option_form(user_input) 115 | 116 | def _async_show_option_form(self, user_input: dict[str, Any]) -> FlowResult: 117 | def default(key: str): 118 | if user_input is not None and ( 119 | user_input[key] is not None and len(user_input[key]) > 0 120 | ): 121 | return user_input[key] 122 | return self.data.get(key) 123 | 124 | return self.async_show_form( 125 | step_id="user", 126 | data_schema=vol.Schema( 127 | { 128 | vol.Required(CONF_HOST, default=default(CONF_HOST)): str, 129 | } 130 | ), 131 | errors=self._errors, 132 | ) 133 | -------------------------------------------------------------------------------- /custom_components/argoclima/const.py: -------------------------------------------------------------------------------- 1 | NAME = "Argoclima" 2 | VERSION = "1.0.1" 3 | ISSUE_URL = "https://github.com/nyffchanium/argoclima-integration/issues" 4 | 5 | DOMAIN = "argoclima" 6 | DOMAIN_DATA = f"{DOMAIN}_data" 7 | MANUFACTURER = "Argoclima S.p.A." 8 | 9 | # Configuration and options 10 | CONF_DEVICE_TYPE = "device" 11 | CONF_NAME = "name" 12 | CONF_HOST = "host" 13 | 14 | # Internal stuff 15 | ARGO_DEVICE_ULISSE_ECO = "Ulisse 13 DCI Eco WiFi" 16 | ARGO_DEVICES = [ARGO_DEVICE_ULISSE_ECO] 17 | API_UPDATE_ATTEMPTS = 3 18 | 19 | STARTUP_MESSAGE = f""" 20 | ------------------------------------------------------------------- 21 | {NAME} 22 | Version: {VERSION} 23 | This is a custom integration! 24 | If you have any issues with this you need to open an issue here: 25 | {ISSUE_URL} 26 | ------------------------------------------------------------------- 27 | """ 28 | -------------------------------------------------------------------------------- /custom_components/argoclima/data.py: -------------------------------------------------------------------------------- 1 | from custom_components.argoclima.const import API_UPDATE_ATTEMPTS 2 | from custom_components.argoclima.device_type import ArgoDeviceType 3 | from custom_components.argoclima.types import ArgoFanSpeed 4 | from custom_components.argoclima.types import ArgoOperationMode 5 | from custom_components.argoclima.types import ArgoTimerType 6 | from custom_components.argoclima.types import ArgoTimerWeekday 7 | from custom_components.argoclima.types import ArgoUnit 8 | from custom_components.argoclima.types import ArgoWeekday 9 | from custom_components.argoclima.types import ValueType 10 | 11 | 12 | class InvalidResponseFormatError(Exception): 13 | """The response does not have a known Argoclima format""" 14 | 15 | 16 | class ArgoDataValue: 17 | def __init__( 18 | self, 19 | update_index: int, 20 | response_index: int, 21 | type: ValueType = ValueType.READ_WRITE, 22 | ) -> None: 23 | self._type = type 24 | self._update_index = update_index 25 | self._response_index = response_index 26 | self._value: int = None 27 | self._pending_change = False 28 | self._requested_value: int = None 29 | self._change_try_counter_enabled = False 30 | self._change_try_count = 0 31 | 32 | @property 33 | def update_index(self) -> int: 34 | return self._update_index 35 | 36 | @property 37 | def response_index(self) -> int: 38 | return self._response_index 39 | 40 | @property 41 | def pending_change(self) -> bool: 42 | return self._pending_change 43 | 44 | @property 45 | def value(self) -> int: 46 | """The current value, or if a change was requested and 47 | not yet successful or cancelled, the requested value.""" 48 | if self._type == ValueType.WRITE_ONLY: 49 | raise Exception("can't get writeonly value") 50 | return self._requested_value if self._pending_change else self._value 51 | 52 | def request_value(self, value: int) -> None: 53 | """request a change""" 54 | if self._type == ValueType.READ_ONLY: 55 | raise Exception("can't set readonly value") 56 | if self._value != value: 57 | self._requested_value = value 58 | self._pending_change = True 59 | 60 | def requested_value_as_string(self) -> str: 61 | return str(int(self._requested_value)) 62 | 63 | def update(self, value: str) -> None: 64 | """update the actual value""" 65 | self._value = int(value) 66 | if self._pending_change and self._requested_value == self._value: 67 | self._pending_change = False 68 | self._change_try_counter_enabled = False 69 | 70 | def enable_change_watcher(self) -> None: 71 | """Start counting failed update attempts if not started already.""" 72 | if not self._change_try_counter_enabled: 73 | self._change_try_count = 0 74 | self._change_try_counter_enabled = True 75 | 76 | def assume_change_successful(self) -> None: 77 | if self._change_try_counter_enabled: 78 | self._pending_change = False 79 | self._change_try_counter_enabled = False 80 | 81 | def notify_unsuccessful_change(self) -> None: 82 | if self._change_try_counter_enabled: 83 | self._change_try_count += 1 84 | # Abort after x failed attempts. 85 | if self._change_try_count == API_UPDATE_ATTEMPTS: 86 | self._pending_change = False 87 | self._change_try_counter_enabled = False 88 | 89 | 90 | class ArgoRangedDataValue(ArgoDataValue): 91 | def __init__( 92 | self, 93 | update_index: int, 94 | response_index: int, 95 | min: int, 96 | max: int, 97 | type: ValueType = ValueType.READ_WRITE, 98 | ) -> None: 99 | super().__init__(update_index, response_index, type) 100 | self._min = min 101 | self._max = max 102 | 103 | def request_value(self, value: int) -> None: 104 | if value < self._min: 105 | raise Exception(f"value can't be less than {self._min}") 106 | elif value > self._max: 107 | raise Exception(f"value can't be greater than {self._max}") 108 | return super().request_value(value) 109 | 110 | 111 | class ArgoConstrainedDataValue(ArgoDataValue): 112 | def __init__( 113 | self, 114 | update_index: int, 115 | response_index: int, 116 | allowed_values: list[int], 117 | type: ValueType = ValueType.READ_WRITE, 118 | ) -> None: 119 | super().__init__(update_index, response_index, type) 120 | self._allowed_values = allowed_values 121 | 122 | def request_value(self, value: int) -> None: 123 | if value not in self._allowed_values: 124 | raise Exception("value not allowed") 125 | return super().request_value(value) 126 | 127 | 128 | class ArgoBooleanDataValue(ArgoDataValue): 129 | def __init__( 130 | self, 131 | update_index: int, 132 | response_index: int, 133 | type: ValueType = ValueType.READ_WRITE, 134 | ) -> None: 135 | super().__init__(update_index, response_index, type=type) 136 | 137 | @property 138 | def value(self) -> bool: 139 | return super().value == 1 140 | 141 | def request_value(self, value: int) -> None: 142 | return super().request_value(1 if value else 0) 143 | 144 | 145 | class ArgoData: 146 | def __init__(self, type: ArgoDeviceType) -> None: 147 | self._type = type 148 | self._target_temp = ArgoRangedDataValue( 149 | 0, 0, type.target_temperature_min * 10, type.target_temperature_max * 10 150 | ) 151 | self._temp = ArgoDataValue(None, 1, ValueType.READ_ONLY) 152 | self._operating = ArgoBooleanDataValue(2, 2) 153 | self._mode = ArgoConstrainedDataValue(3, 3, list(map(int, ArgoOperationMode))) 154 | self._fan = ArgoConstrainedDataValue(4, 4, list(map(int, ArgoFanSpeed))) 155 | # self._flap = RangedDataValue(5, 5, 0, 7) 156 | self._remote_temperature = ArgoBooleanDataValue(6, 6) 157 | # self._filter = BooleanDataValue(8, 8) 158 | self._eco = ArgoBooleanDataValue(8, 8) 159 | self._turbo = ArgoBooleanDataValue(9, 9) 160 | self._night = ArgoBooleanDataValue(10, 10) 161 | self._light = ArgoBooleanDataValue(11, 11) 162 | self._timer = ArgoConstrainedDataValue(12, 12, list(map(int, ArgoTimerType))) 163 | self._current_weekday = ArgoConstrainedDataValue( 164 | 18, None, list(map(int, ArgoWeekday)), ValueType.WRITE_ONLY 165 | ) 166 | self._timer_weekdays = ArgoConstrainedDataValue( 167 | 19, None, list(map(int, ArgoTimerWeekday)), ValueType.WRITE_ONLY 168 | ) 169 | self._time = ArgoRangedDataValue(20, None, 0, 1439, ValueType.WRITE_ONLY) 170 | self._delaytimer_duration = ArgoRangedDataValue( 171 | 21, None, 0, 1439, ValueType.WRITE_ONLY 172 | ) 173 | self._timer_on = ArgoRangedDataValue(22, None, 0, 1439, ValueType.WRITE_ONLY) 174 | self._timer_off = ArgoRangedDataValue(23, None, 0, 1439, ValueType.WRITE_ONLY) 175 | self._reset = ArgoRangedDataValue(24, None, 0, 3, ValueType.WRITE_ONLY) 176 | self._eco_limit = ArgoRangedDataValue( 177 | 25, 22, type.eco_limit_min, type.eco_limit_max 178 | ) 179 | self._unit = ArgoDataValue(26, 24) 180 | self._firmware_version = ArgoDataValue(None, 23, ValueType.READ_ONLY) 181 | self._values: list[ArgoDataValue] = [ 182 | self._target_temp, 183 | self._temp, 184 | self._operating, 185 | self._mode, 186 | self._fan, 187 | self._remote_temperature, 188 | self._eco, 189 | self._turbo, 190 | self._night, 191 | self._light, 192 | self._timer, 193 | self._current_weekday, 194 | self._timer_weekdays, 195 | self._time, 196 | self._delaytimer_duration, 197 | self._timer_on, 198 | self._timer_off, 199 | self._eco_limit, 200 | self._unit, 201 | self._firmware_version, 202 | ] 203 | 204 | def to_parameter_string(self) -> str: 205 | values = [] 206 | for i in range(36): 207 | out = "N" 208 | for val in self._values: 209 | if val.update_index == i and val.pending_change: 210 | out = val.requested_value_as_string() 211 | val.enable_change_watcher() 212 | break 213 | values.append(str(out)) 214 | return ",".join(values) 215 | 216 | def parse_response_parameter_string(self, query: str) -> None: 217 | values = query.split(",") 218 | 219 | if len(values) != 39: 220 | raise InvalidResponseFormatError() 221 | 222 | for val in self._values: 223 | if val.response_index is not None: 224 | value = values[val.response_index] 225 | 226 | if value == "N": 227 | continue 228 | 229 | if not value.isdecimal(): 230 | raise InvalidResponseFormatError() 231 | 232 | val.update(value) 233 | 234 | # If a requested change not (yet) accepted, we remember that. 235 | if val.pending_change: 236 | val.notify_unsuccessful_change() 237 | 238 | elif val.pending_change: 239 | val.assume_change_successful() 240 | 241 | def is_update_pending(self) -> bool: 242 | for val in self._values: 243 | if val.pending_change: 244 | return True 245 | return False 246 | 247 | @property 248 | def target_temp(self) -> float: 249 | return ( 250 | self._target_temp.value / 10 251 | if self._target_temp.value is not None 252 | else None 253 | ) 254 | 255 | @target_temp.setter 256 | def target_temp(self, value: int): 257 | self._target_temp.request_value((int)(value * 10)) 258 | 259 | @property 260 | def temp(self) -> float: 261 | return self._temp.value / 10 if self._temp.value is not None else None 262 | 263 | @property 264 | def operating(self) -> bool: 265 | return self._operating.value 266 | 267 | @operating.setter 268 | def operating(self, value: bool): 269 | self._operating.request_value(value) 270 | 271 | @property 272 | def mode(self) -> ArgoOperationMode: 273 | return ArgoOperationMode(self._mode.value) 274 | 275 | @mode.setter 276 | def mode(self, value: ArgoOperationMode): 277 | self._mode.request_value(value) 278 | 279 | @property 280 | def fan(self) -> ArgoFanSpeed: 281 | return ArgoFanSpeed(self._fan.value) 282 | 283 | @fan.setter 284 | def fan(self, value: ArgoFanSpeed): 285 | self._fan.request_value(value) 286 | 287 | @property 288 | def remote_temperature(self) -> bool: 289 | return self._remote_temperature.value 290 | 291 | @remote_temperature.setter 292 | def remote_temperature(self, value: bool): 293 | self._remote_temperature.request_value(value) 294 | 295 | @property 296 | def target_remote(self) -> bool: 297 | return self._remote_temperature.value 298 | 299 | @target_remote.setter 300 | def target_remote(self, value: bool): 301 | self._remote_temperature.request_value(value) 302 | 303 | @property 304 | def eco(self) -> bool: 305 | return self._eco.value 306 | 307 | @eco.setter 308 | def eco(self, value: bool): 309 | self._eco.request_value(value) 310 | 311 | @property 312 | def turbo(self) -> bool: 313 | return self._turbo.value 314 | 315 | @turbo.setter 316 | def turbo(self, value: bool): 317 | self._turbo.request_value(value) 318 | 319 | @property 320 | def night(self) -> bool: 321 | return self._night.value 322 | 323 | @night.setter 324 | def night(self, value: bool): 325 | self._night.request_value(value) 326 | 327 | @property 328 | def light(self) -> bool: 329 | return self._light.value 330 | 331 | @light.setter 332 | def light(self, value: bool): 333 | self._light.request_value(value) 334 | 335 | @property 336 | def timer(self) -> ArgoTimerType: 337 | return ArgoTimerType(self._timer.value) 338 | 339 | @timer.setter 340 | def timer(self, value: ArgoTimerType): 341 | self._timer.request_value(value) 342 | 343 | def set_current_weekday(self, value: ArgoWeekday): 344 | self._current_weekday.request_value(value) 345 | 346 | def set_timer_weekdays(self, value: ArgoTimerWeekday): 347 | self._timer_weekdays.request_value(value) 348 | 349 | def set_time(self, hours: int, minutes: int): 350 | self._time.request_value(hours * 60 + minutes) 351 | 352 | def set_delaytimer_duration(self, hours: int, minutes: int): 353 | self._delaytimer_duration.request_value(hours * 60 + minutes) 354 | 355 | def set_timer_on(self, hours: int, minutes: int): 356 | self._timer_on.request_value(hours * 60 + minutes) 357 | 358 | def set_timer_off(self, hours: int, minutes: int): 359 | self._timer_off.request_value(hours * 60 + minutes) 360 | 361 | @property 362 | def eco_limit(self) -> int: 363 | return self._eco_limit.value 364 | 365 | @eco_limit.setter 366 | def eco_limit(self, value: int): 367 | self._eco_limit.request_value(value) 368 | 369 | @property 370 | def unit(self) -> ArgoUnit: 371 | return ArgoUnit(self._unit.value) 372 | 373 | @unit.setter 374 | def unit(self, value: ArgoUnit): 375 | self._unit.request_value(value) 376 | 377 | @property 378 | def firmware_version(self) -> int: 379 | self._firmware_version.value 380 | -------------------------------------------------------------------------------- /custom_components/argoclima/device_type.py: -------------------------------------------------------------------------------- 1 | from custom_components.argoclima.const import ARGO_DEVICE_ULISSE_ECO 2 | from custom_components.argoclima.types import ArgoFanSpeed 3 | from custom_components.argoclima.types import ArgoFlapMode 4 | from custom_components.argoclima.types import ArgoOperationMode 5 | from custom_components.argoclima.types import ArgoTimerType 6 | from homeassistant.components.climate.const import DOMAIN as ENTITY_DOMAIN_CLIMATE 7 | from homeassistant.components.number import DOMAIN as ENTITY_DOMAIN_NUMBER 8 | from homeassistant.components.select.const import DOMAIN as ENTITY_DOMAIN_SELECT 9 | from homeassistant.components.switch import DOMAIN as ENTITY_DOMAIN_SWITCH 10 | 11 | 12 | class InvalidOperationError(Exception): 13 | """This operation is not available for this device type""" 14 | 15 | 16 | class ArgoDeviceType: 17 | def __init__(self, name: str, port: int, update_interval: int) -> None: 18 | self._name = name 19 | self._port = port 20 | self._update_interval = update_interval 21 | self._on_off = False 22 | self._operation_modes: list[ArgoOperationMode] = [] 23 | self._operation_mode = False 24 | self._eco_mode = False 25 | self._turbo_mode = False 26 | self._night_mode = False 27 | self._preset = False 28 | self._target_temperature = False 29 | self._target_temperature_min: float = None 30 | self._target_temperature_max: float = None 31 | self._current_temperature = False 32 | self._remote_temperature = False 33 | self._fan_speeds: list[ArgoFanSpeed] = [] 34 | self._fan_speed = False 35 | self._flap_modes: list[ArgoFlapMode] = [] 36 | self._flap_mode = False 37 | self._filter_mode = False 38 | self._timers: list[ArgoTimerType] = [] 39 | self._timer = False 40 | self._set_time_and_weekday = False 41 | self._device_lights = False 42 | self._unit = False 43 | self._eco_limit = False 44 | self._eco_limit_min: float = None 45 | self._eco_limit_max: float = None 46 | self._firmware = False 47 | self._reset = False 48 | 49 | @property 50 | def name(self) -> str: 51 | return self._name 52 | 53 | @property 54 | def port(self) -> int: 55 | return self._port 56 | 57 | @property 58 | def update_interval(self) -> int: 59 | return self._update_interval 60 | 61 | @property 62 | def on_off(self) -> bool: 63 | return self._on_off 64 | 65 | @property 66 | def operation_modes(self) -> list[ArgoOperationMode]: 67 | return self._operation_modes 68 | 69 | @property 70 | def operation_mode(self) -> bool: 71 | return self._operation_mode 72 | 73 | @property 74 | def eco_mode(self) -> bool: 75 | return self._eco_mode 76 | 77 | @property 78 | def turbo_mode(self) -> bool: 79 | return self._turbo_mode 80 | 81 | @property 82 | def night_mode(self) -> bool: 83 | return self._night_mode 84 | 85 | @property 86 | def preset(self) -> bool: 87 | return self._preset 88 | 89 | @property 90 | def target_temperature(self) -> bool: 91 | return self._target_temperature 92 | 93 | @property 94 | def target_temperature_min(self) -> float: 95 | return self._target_temperature_min 96 | 97 | @property 98 | def target_temperature_max(self) -> float: 99 | return self._target_temperature_max 100 | 101 | @property 102 | def current_temperature(self) -> bool: 103 | return self._current_temperature 104 | 105 | @property 106 | def remote_temperature(self) -> bool: 107 | return self._remote_temperature 108 | 109 | @property 110 | def fan_speeds(self) -> list[ArgoFanSpeed]: 111 | return self._fan_speeds 112 | 113 | @property 114 | def fan_speed(self) -> bool: 115 | return self._fan_speed 116 | 117 | @property 118 | def flap_modes(self) -> list[ArgoFlapMode]: 119 | return self._flap_modes 120 | 121 | @property 122 | def flap_mode(self) -> bool: 123 | return self._flap_mode 124 | 125 | @property 126 | def filter_mode(self) -> bool: 127 | return self._filter_mode 128 | 129 | @property 130 | def timers(self) -> list[ArgoTimerType]: 131 | return self._timers 132 | 133 | @property 134 | def timer(self) -> bool: 135 | return self._timer 136 | 137 | @property 138 | def set_time_and_weekday(self) -> bool: 139 | return self._set_time_and_weekday 140 | 141 | @property 142 | def device_lights(self) -> bool: 143 | return self._device_lights 144 | 145 | @property 146 | def unit(self) -> bool: 147 | return self._unit 148 | 149 | @property 150 | def eco_limit(self) -> bool: 151 | return self._eco_limit 152 | 153 | @property 154 | def eco_limit_min(self) -> float: 155 | return self._eco_limit_min 156 | 157 | @property 158 | def eco_limit_max(self) -> float: 159 | return self._eco_limit_max 160 | 161 | @property 162 | def firmware(self) -> bool: 163 | return self._firmware 164 | 165 | @property 166 | def reset(self) -> bool: 167 | return self._reset 168 | 169 | @property 170 | def platforms(self) -> list[str]: 171 | list = [] 172 | if self._on_off: 173 | list.append(ENTITY_DOMAIN_CLIMATE) 174 | if self._eco_limit: 175 | list.append(ENTITY_DOMAIN_NUMBER) 176 | if self._unit or self._timer: 177 | list.append(ENTITY_DOMAIN_SELECT) 178 | if self._device_lights or self.remote_temperature: 179 | list.append(ENTITY_DOMAIN_SWITCH) 180 | return list 181 | 182 | def __str__(self) -> str: 183 | return self.name 184 | 185 | @staticmethod 186 | def from_name(name: str) -> "ArgoDeviceType": 187 | map = { 188 | ARGO_DEVICE_ULISSE_ECO: ArgoDeviceTypeBuilder( 189 | ARGO_DEVICE_ULISSE_ECO, 1001, 10 190 | ) 191 | .on_off() 192 | .operation_modes( 193 | [ 194 | ArgoOperationMode.COOL, 195 | ArgoOperationMode.DRY, 196 | ArgoOperationMode.FAN, 197 | ArgoOperationMode.AUTO, 198 | ] 199 | ) 200 | .eco_mode() 201 | .turbo_mode() 202 | .night_mode() 203 | .unit() 204 | .current_temperature() 205 | .target_temperature(10, 32) 206 | .device_lights() 207 | .eco_limit(30, 99) 208 | .remote_temperature() 209 | .fan_speeds( 210 | [ 211 | ArgoFanSpeed.AUTO, 212 | ArgoFanSpeed.LOWEST, 213 | ArgoFanSpeed.LOW, 214 | ArgoFanSpeed.MEDIUM, 215 | ArgoFanSpeed.HIGH, 216 | ArgoFanSpeed.HIGHER, 217 | ArgoFanSpeed.HIGHEST, 218 | ] 219 | ) 220 | .timers( 221 | [ 222 | ArgoTimerType.NO_TIMER, 223 | ArgoTimerType.DELAY_ON_OFF, 224 | ArgoTimerType.PROFILE_1, 225 | ArgoTimerType.PROFILE_2, 226 | ArgoTimerType.PROFILE_3, 227 | ] 228 | ) 229 | .build() 230 | } 231 | return map[name] if name in map else None 232 | 233 | 234 | class ArgoDeviceTypeBuilder: 235 | def __init__(self, name: str, port: int, update_interval: int) -> None: 236 | self._deviceType = ArgoDeviceType(name, port, update_interval) 237 | 238 | def build(self) -> ArgoDeviceType: 239 | return self._deviceType 240 | 241 | def on_off(self) -> "ArgoDeviceTypeBuilder": 242 | self._deviceType._on_off = True 243 | return self 244 | 245 | def operation_modes( 246 | self, modes: list[ArgoOperationMode] 247 | ) -> "ArgoDeviceTypeBuilder": 248 | self._deviceType._operation_modes = modes 249 | self._deviceType._operation_mode = True 250 | return self 251 | 252 | def eco_mode(self) -> "ArgoDeviceTypeBuilder": 253 | self._deviceType._eco_mode = True 254 | self._deviceType._preset = True 255 | return self 256 | 257 | def turbo_mode(self) -> "ArgoDeviceTypeBuilder": 258 | self._deviceType._turbo_mode = True 259 | self._deviceType._preset = True 260 | return self 261 | 262 | def night_mode(self) -> "ArgoDeviceTypeBuilder": 263 | self._deviceType._night_mode = True 264 | self._deviceType._preset = True 265 | return self 266 | 267 | def target_temperature(self, min: float, max: float) -> "ArgoDeviceTypeBuilder": 268 | self._deviceType._target_temperature = True 269 | self._deviceType._target_temperature_min = min 270 | self._deviceType._target_temperature_max = max 271 | return self 272 | 273 | def current_temperature(self) -> "ArgoDeviceTypeBuilder": 274 | self._deviceType._current_temperature = True 275 | return self 276 | 277 | def remote_temperature(self) -> "ArgoDeviceTypeBuilder": 278 | self._deviceType._remote_temperature = True 279 | return self 280 | 281 | def fan_speeds(self, modes: list[ArgoFanSpeed]) -> "ArgoDeviceTypeBuilder": 282 | self._deviceType._fan_speeds = modes 283 | self._deviceType._fan_speed = True 284 | return self 285 | 286 | def flap_modes(self, modes: list[ArgoFlapMode]) -> "ArgoDeviceTypeBuilder": 287 | self._deviceType._flap_modes = modes 288 | self._deviceType._flap_mode = True 289 | return self 290 | 291 | def filter_mode(self) -> "ArgoDeviceTypeBuilder": 292 | self._deviceType._filter_mode = True 293 | return self 294 | 295 | def timers(self, modes: list[ArgoTimerType]) -> "ArgoDeviceTypeBuilder": 296 | self._deviceType._timers = modes 297 | self._deviceType._timer = True 298 | return self 299 | 300 | def set_time_and_weekday(self) -> "ArgoDeviceTypeBuilder": 301 | self._deviceType._set_time_and_weekday = True 302 | return self 303 | 304 | def device_lights(self) -> "ArgoDeviceTypeBuilder": 305 | self._deviceType._device_lights = True 306 | return self 307 | 308 | def unit(self) -> "ArgoDeviceTypeBuilder": 309 | self._deviceType._unit = True 310 | return self 311 | 312 | def eco_limit(self, min: float, max: float) -> "ArgoDeviceTypeBuilder": 313 | self._deviceType._eco_limit = True 314 | self._deviceType._eco_limit_min = min 315 | self._deviceType._eco_limit_max = max 316 | return self 317 | 318 | def firmware(self) -> "ArgoDeviceTypeBuilder": 319 | self._deviceType._firmware = True 320 | return self 321 | 322 | def reset(self) -> "ArgoDeviceTypeBuilder": 323 | self._deviceType._reset = True 324 | return self 325 | -------------------------------------------------------------------------------- /custom_components/argoclima/entity.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import uuid 3 | 4 | from custom_components.argoclima import ArgoDataUpdateCoordinator 5 | from custom_components.argoclima.const import CONF_DEVICE_TYPE 6 | from custom_components.argoclima.const import DOMAIN 7 | from custom_components.argoclima.const import MANUFACTURER 8 | from custom_components.argoclima.device_type import ArgoDeviceType 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.helpers.entity import EntityCategory 11 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 12 | 13 | 14 | class ArgoEntity(CoordinatorEntity): 15 | coordinator: ArgoDataUpdateCoordinator 16 | 17 | def __init__( 18 | self, 19 | entity_name: str, 20 | coordinator: ArgoDataUpdateCoordinator, 21 | entry: ConfigEntry, 22 | device_class: str = None, 23 | entity_category: EntityCategory = None, 24 | ): 25 | super().__init__(coordinator) 26 | self._type = ArgoDeviceType.from_name(entry.data[CONF_DEVICE_TYPE]) 27 | self._entity_name = entity_name 28 | self._entry = entry 29 | self._device_class = device_class 30 | self._entity_category = entity_category 31 | 32 | @property 33 | def unique_id(self) -> str: 34 | return uuid.UUID( 35 | hashlib.md5((self._entry.entry_id + self.name).encode("utf-8")).hexdigest() 36 | ).hex 37 | 38 | @property 39 | def available(self) -> bool: 40 | return self.coordinator.last_update_success 41 | 42 | @property 43 | def name(self): 44 | return f"{self._entry.title} {self._entity_name}" 45 | 46 | @property 47 | def device_class(self) -> str: 48 | return self._device_class 49 | 50 | @property 51 | def entity_category(self) -> str: 52 | return self._entity_category 53 | 54 | @property 55 | def device_info(self): 56 | return { 57 | "identifiers": {(DOMAIN, self._entry.entry_id)}, 58 | "name": self._entry.title, 59 | "model": self._type.name, 60 | "sw_version": self.coordinator.data.firmware_version 61 | if self.coordinator.data is not None 62 | else None, 63 | "manufacturer": MANUFACTURER, 64 | } 65 | -------------------------------------------------------------------------------- /custom_components/argoclima/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "argoclima", 3 | "name": "Argoclima", 4 | "documentation": "https://github.com/nyffchanium/argoclima-integration", 5 | "iot_class": "local_polling", 6 | "issue_tracker": "https://github.com/nyffchanium/argoclima-integration/issues", 7 | "dependencies": [], 8 | "config_flow": true, 9 | "codeowners": [ 10 | "@nyffchanium" 11 | ], 12 | "requirements": [], 13 | "version": "1.1.3" 14 | } 15 | -------------------------------------------------------------------------------- /custom_components/argoclima/number.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | from custom_components.argoclima.const import DOMAIN 4 | from custom_components.argoclima.device_type import InvalidOperationError 5 | from custom_components.argoclima.entity import ArgoEntity 6 | from homeassistant.components.number import NumberEntity 7 | from homeassistant.components.number import NumberMode 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import PERCENTAGE 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.entity import EntityCategory 12 | 13 | 14 | async def async_setup_entry( 15 | hass: HomeAssistant, 16 | entry: ConfigEntry, 17 | async_add_devices: Callable[[list[NumberEntity]], None], 18 | ): 19 | coordinator = hass.data[DOMAIN][entry.entry_id] 20 | 21 | async_add_devices([ArgoEcoLimitNumber(coordinator, entry)]) 22 | 23 | 24 | class ArgoEcoLimitNumber(ArgoEntity, NumberEntity): 25 | def __init__(self, coordinator, entry: ConfigEntry): 26 | ArgoEntity.__init__( 27 | self, 28 | "Eco Mode Power Limit", 29 | coordinator, 30 | entry, 31 | None, 32 | EntityCategory.CONFIG, 33 | ) 34 | NumberEntity.__init__(self) 35 | 36 | @property 37 | def icon(self) -> str: 38 | return "mdi:leaf" 39 | 40 | @property 41 | def native_value(self) -> float: 42 | if not self._type.eco_limit: 43 | raise InvalidOperationError 44 | return self.coordinator.data.eco_limit 45 | 46 | @property 47 | def native_min_value(self) -> int: 48 | if not self._type.eco_limit: 49 | raise InvalidOperationError 50 | return self._type.eco_limit_min 51 | 52 | @property 53 | def native_max_value(self) -> int: 54 | if not self._type.eco_limit: 55 | raise InvalidOperationError 56 | return self._type.eco_limit_max 57 | 58 | @property 59 | def native_step(self) -> int: 60 | if not self._type.eco_limit: 61 | raise InvalidOperationError 62 | return 1 63 | 64 | @property 65 | def native_unit_of_measurement(self) -> str: 66 | return PERCENTAGE 67 | 68 | @property 69 | def mode(self) -> NumberMode: 70 | return NumberMode.BOX 71 | 72 | async def async_set_native_value(self, value: float) -> None: 73 | if not self._type.eco_limit: 74 | raise InvalidOperationError 75 | self.coordinator.data.eco_limit = int(value) 76 | await self.coordinator.async_request_refresh() 77 | -------------------------------------------------------------------------------- /custom_components/argoclima/select.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | from custom_components.argoclima.const import CONF_DEVICE_TYPE 4 | from custom_components.argoclima.const import DOMAIN 5 | from custom_components.argoclima.device_type import ArgoDeviceType 6 | from custom_components.argoclima.device_type import InvalidOperationError 7 | from custom_components.argoclima.entity import ArgoEntity 8 | from custom_components.argoclima.types import ArgoTimerType 9 | from custom_components.argoclima.types import ArgoUnit 10 | from homeassistant.components.select import SelectEntity 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.entity import EntityCategory 14 | 15 | 16 | async def async_setup_entry( 17 | hass: HomeAssistant, 18 | entry: ConfigEntry, 19 | async_add_devices: Callable[[list[SelectEntity]], None], 20 | ): 21 | coordinator = hass.data[DOMAIN][entry.entry_id] 22 | 23 | entities = [] 24 | type = ArgoDeviceType.from_name(entry.data[CONF_DEVICE_TYPE]) 25 | 26 | if type.unit: 27 | entities.append(ArgoUnitSelect(coordinator, entry)) 28 | if type.timer: 29 | entities.append(ArgoTimerSelect(coordinator, entry)) 30 | 31 | async_add_devices(entities) 32 | 33 | 34 | class ArgoUnitSelect(ArgoEntity, SelectEntity): 35 | def __init__(self, coordinator, entry: ConfigEntry): 36 | ArgoEntity.__init__( 37 | self, "Display Unit", coordinator, entry, None, EntityCategory.CONFIG 38 | ) 39 | SelectEntity.__init__(self) 40 | 41 | @property 42 | def current_option(self) -> str: 43 | if not self._type.unit: 44 | raise InvalidOperationError 45 | return self.coordinator.data.unit.to_ha_unit() 46 | 47 | @property 48 | def options(self) -> list[str]: 49 | if not self._type.unit: 50 | raise InvalidOperationError 51 | list = [] 52 | for unit in ArgoUnit: 53 | list.append(unit.to_ha_unit()) 54 | return list 55 | 56 | async def async_select_option(self, option: str) -> None: 57 | if not self._type.unit: 58 | raise InvalidOperationError 59 | self.coordinator.data.unit = ArgoUnit.from_ha_unit(option) 60 | await self.coordinator.async_request_refresh() 61 | 62 | 63 | class ArgoTimerSelect(ArgoEntity, SelectEntity): 64 | def __init__(self, coordinator, entry: ConfigEntry): 65 | ArgoEntity.__init__( 66 | self, "Active Timer", coordinator, entry, None, EntityCategory.CONFIG 67 | ) 68 | SelectEntity.__init__(self) 69 | 70 | @property 71 | def current_option(self) -> str: 72 | if not self._type.timer: 73 | raise InvalidOperationError 74 | return self.coordinator.data.timer.__str__() 75 | 76 | @property 77 | def options(self) -> list[str]: 78 | if not self._type.timer: 79 | raise InvalidOperationError 80 | list = [] 81 | for type in self._type.timers: 82 | list.append(type.__str__()) 83 | return list 84 | 85 | async def async_select_option(self, option: str) -> None: 86 | if not self._type.timer: 87 | raise InvalidOperationError 88 | self.coordinator.data.timer = ArgoTimerType[option.upper()] 89 | await self.coordinator.async_request_refresh() 90 | -------------------------------------------------------------------------------- /custom_components/argoclima/service.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from datetime import time as dt_time 4 | from typing import Any 5 | from typing import cast 6 | 7 | import homeassistant.helpers.config_validation as cv 8 | import homeassistant.helpers.device_registry as dr 9 | import voluptuous as vol 10 | from custom_components.argoclima.const import DOMAIN 11 | from custom_components.argoclima.types import ArgoWeekday 12 | from custom_components.argoclima.update_coordinator import ArgoDataUpdateCoordinator 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers.service import verify_domain_control 15 | from homeassistant.util import dt as dt_util 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | ATTR_DEVICE = "device" 19 | ATTR_TIME = "time" 20 | ATTR_WEEKDAY = "weekday" 21 | 22 | 23 | async def setup_service(hass: HomeAssistant): 24 | async def _set_time(call, **kwargs) -> None: 25 | device: dr.DeviceEntry = call.data.get(ATTR_DEVICE) 26 | time: dt_time = call.data.get(ATTR_TIME) 27 | weekday: ArgoWeekday = call.data.get(ATTR_WEEKDAY) 28 | entry_id = next(iter(device.config_entries)) 29 | if entry_id not in hass.data[DOMAIN]: 30 | _LOGGER.warning( 31 | "Device %s is not loaded.", device.name_by_user or device.name 32 | ) 33 | return 34 | coordinator: ArgoDataUpdateCoordinator = hass.data[DOMAIN][entry_id] 35 | if not coordinator.last_update_success: 36 | _LOGGER.warning( 37 | "Device %s is not available.", device.name_by_user or device.name 38 | ) 39 | return 40 | if time is None or weekday is None: 41 | date = _get_current_datetime() 42 | if time is None: 43 | time = date.time() 44 | if weekday is None: 45 | weekday = ArgoWeekday.from_datetime(date) 46 | coordinator.data.set_current_weekday(weekday) 47 | coordinator.data.set_time(time.hour, time.minute) 48 | await coordinator.async_request_refresh() 49 | 50 | def _get_current_datetime() -> datetime: 51 | return dt_util.utcnow().astimezone(dt_util.get_time_zone(hass.config.time_zone)) 52 | 53 | def weekday(value: Any) -> ArgoWeekday: 54 | """Validate a weekday.""" 55 | value: str = str(value).lower() 56 | if value in ["0", "sunday"]: 57 | return ArgoWeekday(0) 58 | elif value in ["1", "monday"]: 59 | return ArgoWeekday(1) 60 | elif value in ["2", "tuesday"]: 61 | return ArgoWeekday(2) 62 | elif value in ["3", "wednesday"]: 63 | return ArgoWeekday(3) 64 | elif value in ["4", "thursday"]: 65 | return ArgoWeekday(4) 66 | elif value in ["5", "friday"]: 67 | return ArgoWeekday(5) 68 | elif value in ["6", "saturday"]: 69 | return ArgoWeekday(6) 70 | else: 71 | raise vol.Invalid("Invalid weekday") 72 | 73 | def device(value: Any) -> dr.DeviceEntry: 74 | """Validate that the device exists.""" 75 | device_registry = cast(dr.DeviceRegistry, hass.data[dr.DATA_REGISTRY]) 76 | try: 77 | return device_registry.devices[str(value)] 78 | except: # noqa: E722 pylint: disable=bare-except 79 | raise vol.Invalid(f"Could not find device with ID {value}") 80 | 81 | hass.services.async_register( 82 | DOMAIN, 83 | "set_time", 84 | verify_domain_control(hass, DOMAIN)(_set_time), 85 | schema=vol.Schema( 86 | { 87 | vol.Required(ATTR_DEVICE): device, 88 | vol.Optional(ATTR_TIME): cv.time, 89 | vol.Optional(ATTR_WEEKDAY): weekday, 90 | } 91 | ), 92 | ) 93 | -------------------------------------------------------------------------------- /custom_components/argoclima/services.yaml: -------------------------------------------------------------------------------- 1 | set_time: 2 | name: Set Time 3 | description: Set the internal time of an Arogclima device. 4 | fields: 5 | device: 6 | name: Device 7 | description: The targeted device. 8 | required: true 9 | selector: 10 | device: 11 | integration: "argoclima" 12 | time: 13 | name: Time 14 | description: Time to set the device to. Leave empty to use current local time. 15 | required: false 16 | selector: 17 | time: 18 | weekday: 19 | name: Weekday 20 | description: Weekday to set the device to. Leave empty to use current local weekday. 21 | required: false 22 | default: Monday 23 | selector: 24 | select: 25 | options: 26 | - Monday 27 | - Tuesday 28 | - Wednesday 29 | - Thursday 30 | - Friday 31 | - Saturday 32 | - Sunday 33 | -------------------------------------------------------------------------------- /custom_components/argoclima/switch.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | from custom_components.argoclima.const import CONF_DEVICE_TYPE 4 | from custom_components.argoclima.const import DOMAIN 5 | from custom_components.argoclima.device_type import ArgoDeviceType 6 | from custom_components.argoclima.device_type import InvalidOperationError 7 | from custom_components.argoclima.entity import ArgoEntity 8 | from homeassistant.components.switch import SwitchDeviceClass 9 | from homeassistant.components.switch import SwitchEntity 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.entity import EntityCategory 13 | 14 | 15 | async def async_setup_entry( 16 | hass: HomeAssistant, 17 | entry: ConfigEntry, 18 | async_add_devices: Callable[[list[SwitchEntity]], None], 19 | ): 20 | coordinator = hass.data[DOMAIN][entry.entry_id] 21 | 22 | entities = [] 23 | type = ArgoDeviceType.from_name(entry.data[CONF_DEVICE_TYPE]) 24 | 25 | if type.device_lights: 26 | entities.append(ArgoDeviceLightSwitch(coordinator, entry)) 27 | if type.remote_temperature: 28 | entities.append(ArgoRemoteTemperatureSwitch(coordinator, entry)) 29 | 30 | async_add_devices(entities) 31 | 32 | 33 | class ArgoDeviceLightSwitch(ArgoEntity, SwitchEntity): 34 | def __init__(self, coordinator, entry: ConfigEntry): 35 | ArgoEntity.__init__( 36 | self, 37 | "Device Light", 38 | coordinator, 39 | entry, 40 | SwitchDeviceClass.SWITCH, 41 | EntityCategory.CONFIG, 42 | ) 43 | SwitchEntity.__init__(self) 44 | 45 | @property 46 | def icon(self) -> str: 47 | return "mdi:lightbulb" 48 | 49 | @property 50 | def is_on(self) -> bool: 51 | if not self._type.device_lights: 52 | raise InvalidOperationError 53 | return self.coordinator.data.light 54 | 55 | async def async_turn_on(self, **kwargs) -> None: 56 | if not self._type.device_lights: 57 | raise InvalidOperationError 58 | self.coordinator.data.light = True 59 | await self.coordinator.async_request_refresh() 60 | 61 | async def async_turn_off(self, **kwargs) -> None: 62 | if not self._type.device_lights: 63 | raise InvalidOperationError 64 | self.coordinator.data.light = False 65 | await self.coordinator.async_request_refresh() 66 | 67 | 68 | class ArgoRemoteTemperatureSwitch(ArgoEntity, SwitchEntity): 69 | def __init__(self, coordinator, entry: ConfigEntry): 70 | ArgoEntity.__init__( 71 | self, 72 | "Use Remote Temperature", 73 | coordinator, 74 | entry, 75 | SwitchDeviceClass.SWITCH, 76 | EntityCategory.CONFIG, 77 | ) 78 | SwitchEntity.__init__(self) 79 | 80 | @property 81 | def icon(self) -> str: 82 | return "mdi:remote" 83 | 84 | @property 85 | def is_on(self) -> bool: 86 | if not self._type.remote_temperature: 87 | raise InvalidOperationError 88 | return self.coordinator.data.remote_temperature 89 | 90 | async def async_turn_on(self, **kwargs) -> None: 91 | if not self._type.remote_temperature: 92 | raise InvalidOperationError 93 | self.coordinator.data.remote_temperature = True 94 | await self.coordinator.async_request_refresh() 95 | 96 | async def async_turn_off(self, **kwargs) -> None: 97 | if not self._type.remote_temperature: 98 | raise InvalidOperationError 99 | self.coordinator.data.remote_temperature = False 100 | await self.coordinator.async_request_refresh() 101 | -------------------------------------------------------------------------------- /custom_components/argoclima/translations/climate.de.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "_": { 4 | "lowest": "niedrigste", 5 | "low": "niedrig", 6 | "medium": "mittel", 7 | "high": "hoch", 8 | "higher": "höher", 9 | "highest": "höchste" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/argoclima/translations/climate.en.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "_": { 4 | "lowest": "lowest", 5 | "low": "low", 6 | "medium": "medium", 7 | "high": "high", 8 | "higher": "higher", 9 | "highest": "highest" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/argoclima/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "host": "Host konnte nicht erreicht werden oder Host ist kein unterstütztes Gerät." 5 | }, 6 | "step": { 7 | "user": { 8 | "data": { 9 | "device": "Gerätetyp", 10 | "host": "IP oder Hostname des Geräts", 11 | "name": "Gib dem Gerät einen Namen" 12 | }, 13 | "description": "Wenn du Hilfe bei der Konfiguration benötigst, schau hier: https://github.com/nyffchanium/argoclima-integration", 14 | "title": "Argoclima" 15 | } 16 | } 17 | }, 18 | "options": { 19 | "error": { 20 | "host": "Host konnte nicht erreicht werden oder Host ist kein unterstütztes Gerät." 21 | }, 22 | "step": { 23 | "user": { 24 | "data": { 25 | "host": "IP oder Hostname des Geräts", 26 | "name": "Gib dem Gerät einen Namen" 27 | }, 28 | "description": "Wenn du Hilfe bei der Konfiguration benötigst, schau hier: https://github.com/nyffchanium/argoclima-integration" 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /custom_components/argoclima/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "host": "Could not reach host or host is not a supported device." 5 | }, 6 | "step": { 7 | "user": { 8 | "data": { 9 | "device": "Device type", 10 | "host": "IP or hostname of the device", 11 | "name": "Give the device a name" 12 | }, 13 | "description": "If you need help with the configuration have a look here: https://github.com/nyffchanium/argoclima-integration", 14 | "title": "Argoclima" 15 | } 16 | } 17 | }, 18 | "options": { 19 | "error": { 20 | "host": "Could not reach host or host is not a supported device." 21 | }, 22 | "step": { 23 | "user": { 24 | "data": { 25 | "host": "IP or hostname of the device", 26 | "name": "Give the device a name" 27 | }, 28 | "description": "If you need help with the configuration have a look here: https://github.com/nyffchanium/argoclima-integration" 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /custom_components/argoclima/translations/select.de.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "_": { 4 | "no_timer": "Keiner", 5 | "delay_on_off": "Verzögert Ein/Aus", 6 | "profile_1": "Profil 1", 7 | "profile_2": "Profil 2", 8 | "profile_3": "Profil 3" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /custom_components/argoclima/translations/select.en.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "_": { 4 | "no_timer": "None", 5 | "delay_on_off": "Delayed On/Off", 6 | "profile_1": "Profile 1", 7 | "profile_2": "Profile 2", 8 | "profile_3": "Profile 3" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /custom_components/argoclima/types.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import IntEnum 3 | from enum import IntFlag 4 | 5 | from homeassistant.components.climate.const import FAN_AUTO 6 | from homeassistant.components.climate.const import FAN_HIGH 7 | from homeassistant.components.climate.const import FAN_LOW 8 | from homeassistant.components.climate.const import FAN_MEDIUM 9 | from homeassistant.components.climate.const import HVACMode 10 | from homeassistant.const import UnitOfTemperature 11 | 12 | FAN_LOWEST = "lowest" 13 | FAN_HIGHER = "higher" 14 | FAN_HIGHEST = "highest" 15 | 16 | 17 | class UnknownConversionError(Exception): 18 | """Unknown type conversion""" 19 | 20 | 21 | class ValueType(IntEnum): 22 | READ_ONLY = 0 23 | WRITE_ONLY = 1 24 | READ_WRITE = 2 25 | 26 | 27 | class ArgoUnit(IntEnum): 28 | CELSIUS = 0 29 | FAHRENHEIT = 1 30 | 31 | def to_ha_unit(self) -> str: 32 | if self.value == ArgoUnit.CELSIUS: 33 | return UnitOfTemperature.CELSIUS 34 | if self.value == ArgoUnit.FAHRENHEIT: 35 | return UnitOfTemperature.FAHRENHEIT 36 | raise UnknownConversionError 37 | 38 | @staticmethod 39 | def from_ha_unit(mode: str) -> "ArgoOperationMode": 40 | if mode == UnitOfTemperature.CELSIUS: 41 | return ArgoUnit.CELSIUS 42 | if mode == UnitOfTemperature.FAHRENHEIT: 43 | return ArgoUnit.FAHRENHEIT 44 | raise UnknownConversionError 45 | 46 | 47 | class ArgoOperationMode(IntEnum): 48 | COOL = 1 49 | DRY = 2 50 | HEAT = 3 51 | FAN = 4 52 | AUTO = 5 53 | 54 | def to_hvac_mode(self) -> str: 55 | if self.value == ArgoOperationMode.COOL: 56 | return HVACMode.COOL 57 | if self.value == ArgoOperationMode.DRY: 58 | return HVACMode.DRY 59 | if self.value == ArgoOperationMode.HEAT: 60 | return HVACMode.HEAT 61 | if self.value == ArgoOperationMode.FAN: 62 | return HVACMode.FAN_ONLY 63 | if self.value == ArgoOperationMode.AUTO: 64 | return HVACMode.AUTO 65 | raise UnknownConversionError 66 | 67 | @staticmethod 68 | def from_hvac_mode(mode: str) -> "ArgoOperationMode": 69 | if mode == HVACMode.COOL: 70 | return ArgoOperationMode.COOL 71 | if mode == HVACMode.DRY: 72 | return ArgoOperationMode.DRY 73 | if mode == HVACMode.HEAT: 74 | return ArgoOperationMode.HEAT 75 | if mode == HVACMode.FAN_ONLY: 76 | return ArgoOperationMode.FAN 77 | if mode == HVACMode.AUTO: 78 | return ArgoOperationMode.AUTO 79 | raise UnknownConversionError 80 | 81 | 82 | class ArgoFanSpeed(IntEnum): 83 | AUTO = 0 84 | LOWEST = 1 85 | LOW = 2 86 | MEDIUM = 3 87 | HIGH = 4 88 | HIGHER = 5 89 | HIGHEST = 6 90 | 91 | def to_ha_string(self) -> str: 92 | if self.value == ArgoFanSpeed.AUTO: 93 | return FAN_AUTO 94 | if self.value == ArgoFanSpeed.LOWEST: 95 | return FAN_LOWEST 96 | if self.value == ArgoFanSpeed.LOW: 97 | return FAN_LOW 98 | if self.value == ArgoFanSpeed.MEDIUM: 99 | return FAN_MEDIUM 100 | if self.value == ArgoFanSpeed.HIGH: 101 | return FAN_HIGH 102 | if self.value == ArgoFanSpeed.HIGHER: 103 | return FAN_HIGHER 104 | if self.value == ArgoFanSpeed.HIGHEST: 105 | return FAN_HIGHEST 106 | raise UnknownConversionError 107 | 108 | @staticmethod 109 | def from_ha_string(string: str) -> "ArgoOperationMode": 110 | if string == FAN_AUTO: 111 | return ArgoFanSpeed.AUTO 112 | if string == FAN_LOWEST: 113 | return ArgoFanSpeed.LOWEST 114 | if string == FAN_LOW: 115 | return ArgoFanSpeed.LOW 116 | if string == FAN_MEDIUM: 117 | return ArgoFanSpeed.MEDIUM 118 | if string == FAN_HIGH: 119 | return ArgoFanSpeed.HIGH 120 | if string == FAN_HIGHER: 121 | return ArgoFanSpeed.HIGHER 122 | if string == FAN_HIGHEST: 123 | return ArgoFanSpeed.HIGHEST 124 | raise UnknownConversionError 125 | 126 | 127 | class ArgoFlapMode(IntEnum): 128 | pass 129 | 130 | 131 | class ArgoTimerType(IntEnum): 132 | NO_TIMER = 0 133 | DELAY_ON_OFF = 1 134 | PROFILE_1 = 2 135 | PROFILE_2 = 3 136 | PROFILE_3 = 4 137 | 138 | def __str__(self) -> str: 139 | return self.name.lower() 140 | 141 | 142 | class ArgoWeekday(IntEnum): 143 | SUNDAY = 0 144 | MONDAY = 1 145 | TUESDAY = 2 146 | WEDNESDAY = 3 147 | THURSDAY = 4 148 | FRIDAY = 5 149 | SATURDAY = 6 150 | 151 | def __str__(self) -> str: 152 | return self.name.lower() 153 | 154 | @classmethod 155 | def from_datetime(cls, date: datetime) -> "ArgoWeekday": 156 | val = date.weekday() + 1 157 | if val == 7: 158 | val = 0 159 | return cls(val) 160 | 161 | 162 | class ArgoTimerWeekday(IntFlag): 163 | SUNDAY = 1 164 | MONDAY = 2 165 | TUESDAY = 4 166 | WEDNESDAY = 8 167 | THURSDAY = 16 168 | FRIDAY = 32 169 | SATURDAY = 64 170 | 171 | def __str__(self) -> str: 172 | return self.name.lower() 173 | -------------------------------------------------------------------------------- /custom_components/argoclima/update_coordinator.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import timedelta 3 | 4 | from custom_components.argoclima.api import ArgoApiClient 5 | from custom_components.argoclima.const import DOMAIN 6 | from custom_components.argoclima.data import ArgoData 7 | from custom_components.argoclima.device_type import ArgoDeviceType 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 10 | 11 | 12 | _LOGGER: logging.Logger = logging.getLogger(__package__) 13 | 14 | 15 | class ArgoDataUpdateCoordinator(DataUpdateCoordinator[ArgoData]): 16 | def __init__( 17 | self, hass: HomeAssistant, client: ArgoApiClient, type: ArgoDeviceType 18 | ) -> None: 19 | """Initialize.""" 20 | super().__init__( 21 | hass, 22 | _LOGGER, 23 | name=DOMAIN, 24 | update_interval=timedelta(seconds=type.update_interval), 25 | update_method=self._async_update, 26 | ) 27 | 28 | self._api = client 29 | self.platforms = [] 30 | self.data = ArgoData(type) 31 | 32 | async def _async_update(self) -> ArgoData: 33 | """Update data via library.""" 34 | return await self._api.async_sync_data(self.data) 35 | -------------------------------------------------------------------------------- /dummy-server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang as builder 2 | 3 | WORKDIR /app 4 | COPY . . 5 | 6 | RUN go build . 7 | 8 | FROM ubuntu 9 | 10 | COPY --from=builder /app/ac-dummy . 11 | 12 | CMD ["./ac-dummy"] 13 | -------------------------------------------------------------------------------- /dummy-server/go.mod: -------------------------------------------------------------------------------- 1 | module ac-dummy 2 | 3 | go 1.17 4 | 5 | require github.com/sirupsen/logrus v1.8.1 6 | 7 | require golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 // indirect 8 | -------------------------------------------------------------------------------- /dummy-server/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= 6 | github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 7 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 8 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 9 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= 10 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 11 | -------------------------------------------------------------------------------- /dummy-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | log "github.com/sirupsen/logrus" 6 | "net" 7 | "os" 8 | "regexp" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | var ( 14 | port = os.Getenv("SERVER_PORT") 15 | ) 16 | 17 | func main() { 18 | if port=="" { 19 | port="8080" 20 | log.Info("using default port "+port) 21 | } 22 | 23 | l, err := net.Listen("tcp", ":"+port) 24 | if err != nil { 25 | fmt.Println("Error listening:", err.Error()) 26 | os.Exit(1) 27 | } 28 | 29 | defer l.Close() 30 | fmt.Println("Listening on port: "+port) 31 | for { 32 | conn, err := l.Accept() 33 | if err != nil { 34 | fmt.Println("Error accepting: ", err.Error()) 35 | os.Exit(1) 36 | } 37 | go handleRequest(conn) 38 | } 39 | } 40 | 41 | func handleRequest(conn net.Conn) { 42 | req:="" 43 | for { 44 | // read tcp connection bytewise 45 | buf := make([]byte, 1) 46 | _, err := conn.Read(buf) 47 | if err != nil { 48 | // just hackily assuming any error is an EOF 49 | break 50 | } 51 | req+=string(buf) 52 | if strings.Contains(req,"\r\n\r\n") { 53 | // break before reading body as we do not need it 54 | break 55 | } 56 | } 57 | 58 | // find URL in plain http request (seems AC is only sending GET or POST requests 59 | r,_:=regexp.Compile("(GET|POST) (.*?) .*") 60 | matches:=r.FindStringSubmatch(req) 61 | if len(matches)!=0 { 62 | // when we find an URL in the plain http request we fake the response 63 | url:=matches[2] 64 | fakeResponse(url,conn) 65 | 66 | // wait a second before trying to read a next request from the same TCP connection from the AC 67 | // this is the reason we cannot use a normal http server as it closes the TCP connection after responding 68 | // but the AC reuses the same TCP connection to send other http request after always sending an NTP request first 69 | time.Sleep(time.Second) 70 | handleRequest(conn) 71 | } 72 | 73 | conn.Close() 74 | } 75 | 76 | func fakeResponse(url string, conn net.Conn) { 77 | // plain http response template 78 | ok:="HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nServer: Microsoft-IIS/8.5\r\nX-Powered-By: PHP/5.4.11\r\nAccess-Control-Allow-Origin: *\r\nDate: %s GMT\\r\\nContent-Length: %d\r\n\r\n%s" 79 | resp:="" 80 | log.Infof("got request to %s",url) 81 | now:=time.Now() 82 | 83 | if strings.Contains(url, "CM=UI_NTP") { 84 | // wants time gets time 85 | // format: 'NTP 2022-02-01T13:55:14+00:00 UI SERVER (M.A.V. srl)' 86 | resp=now.Format("NTP 2006-01-02T15:04:05+00:00 UI SERVER (M.A.V. srl)") 87 | }else if strings.Contains(url, "CM=UI_FLG") { 88 | // i don't know what all these fields are for. i did not find any correlation to the rest of the HMI query part 89 | // but it seems this string as response does always work even though i found different responses from the public IP 90 | resp="{|1|0|1|0|0|0|N,N,N,N,1,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N,N|}[|0|||]ACN_FREE
\t\t" 91 | } else { 92 | // probably post, no fucking idea what it tries to do.. send captured response, always is the same 93 | resp="|}|}\t\t" 94 | } 95 | 96 | length:=len([]byte(resp)) 97 | // fill plain http response template, we always return a 200 98 | resp=fmt.Sprintf(ok,now.Format("Mon, 02 Jan 2006 15:04:05"),length,resp) 99 | log.Infof("responding with: %s",resp) 100 | conn.Write([]byte(resp)) 101 | } 102 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Argoclima", 3 | "homeassistant": "2022.7.0", 4 | "hacs": "1.6.0", 5 | "hide_default_branch": true, 6 | "render_readme": true, 7 | "zip_release": true, 8 | "filename": "argoclima_integration.zip" 9 | } 10 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | ![Project Status][project-status-shield] 2 | [![GitHub Release][releases-shield]][releases] 3 | [![GitHub Activity][commits-shield]][commits] 4 | [![License][license-shield]][license] 5 | 6 | [![pre-commit][pre-commit-shield]][pre-commit] 7 | [![Black][black-shield]][black] 8 | 9 | [![hacs][hacsbadge]][hacs] 10 | [![Project Maintenance][maintenance-shield]][user_profile] 11 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 12 | 13 | [![Discord][discord-shield]][discord] 14 | [![Community Forum][forum-shield]][forum] 15 | 16 | ## Supported devices and features 17 | 18 | At the moment, only the device I own is supported. There is a good chance that other wifi capable devices use the same API though. So if you own a different device, please feel free to get in touch or contribute. 19 | | Feature | Implementation / Supported for | Ulisse 13 DCI Eco WiFi | 20 | | ---------------------------- | ------------------------------ | ---------------------- | 21 | | on / off | `climate` operation | ✓ | 22 | | operation mode | `climate` operation | ✓ | 23 | | eco mode | `climate` preset | ✓ | 24 | | turbo mode | `climate` preset | ✓ | 25 | | night mode | `climate` preset | ✓ | 26 | | current temperature | `climate` | ✓ | 27 | | set target temperature | `climate` | ✓ | 28 | | set fan speed | `climate` fan mode | ✓ | 29 | | set flap mode | x | - | 30 | | set filter mode | x | - | 31 | | set active timer | `select` | ✓ | 32 | | use remote temperature | `switch` | ✓ | 33 | | timer configuration | x | x | 34 | | set current time and weekday | set_time service | ✓ | 35 | | device lights on / off | `switch` | ✓ | 36 | | display unit \* | `select` | ✓ | 37 | | eco mode power limit | `number` | ✓ | 38 | | firmware version \*\* | device registry | ✓ | 39 | | reset device | x | x | 40 | 41 | [`text`] _platform the feature is represented by in HA_\ 42 | [-] _not supported by the device_\ 43 | [x] _not implemented_ 44 | 45 | \* This only affects the value displayed on the device and the web interface. 46 | \*\* Not visible in the frontend. 47 | 48 | {% if not installed %} 49 | 50 | ## Installation 51 | 52 | 1. Click install. 53 | 1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Argoclima". 54 | 55 | {% endif %} 56 | 57 | ## Adding your device to Home Assistant 58 | 59 | At the moment, the integration will communicate with the device locally. Cloud based communication is not supported. 60 | 61 | ### Set up WiFi 62 | 63 | Follow the instructions provided with the device to connect it to your network. Once that is done, I highly recommend assigning it a static IP via router configuration. The integration is IP based and can not identify the device by any other means. 64 | 65 | ### Configuration 66 | 67 | Select your device type, give it a name and enter the IP. The IP can be changed later.\ 68 | ![configuration](https://raw.githubusercontent.com/nyffchanium/argoclima-integration/master/config.png) 69 | 70 | ## Using the Remote Sensor 71 | 72 | The temperature sensor integrated in the remote can still be used.\ 73 | To make this work: 74 | 75 | 1. To not overwrite the device's configuration, e.g. cover up the IR diode of the remote. 76 | 2. Set the state of the remote to On (indicated by e.g. the grid lines and the fan icon being visible). 77 | 3. Enable remote temperature mode (indicated by the user icon, toggled by holding the fan button for 2 seconds). _This might not be required. Not sure._ 78 | 79 | If my observations are correct, the remote will now send the temperature (no other settings): 80 | 81 | - every 6 minutes if no change is detected 82 | - whenever the temperature displayed on the remote changes 83 | 84 | Probably more often, but that's what I found. 85 | 86 | ## Dummy Server 87 | 88 | By default, your device periodically communicates with Argos's server (hardcoded IP `31.14.128.210`). Without this connection, the API this integration uses won't work. This repository provides a dummy server docker image, so you can keep the traffic in your local network. By doing this, you will lose the ability to use the original web UI. 89 | 90 | You can pull the docker image from https://hub.docker.com/r/nyffchanium/argoclima-dummy-server, or you can run the server without docker by using the Go script in the `dummy-server` folder. 91 | 92 | You can set the port the dummy server listens to via the env `SERVER_PORT`. It defaults to `8080`. 93 | 94 | ### Routing 95 | For the dummy server to be of any use, you need to redirect the traffic to it. As the original server is a hardcoded public IP you need to change the routing through your router. 96 | 97 | Example with an Asus router running Asuswrt-Merlin: 98 | 1. Enable custom scripts and SSH via the router UI (Administration -> System). 99 | 2. SSH into your router and create a file called `nat-start` in `/jffs/scripts` (replace `YOUR_SERVER` and `YOUR_PORT` with the address and port of your dummy server instance). 100 | ```sh 101 | #!/bin/sh 102 | iptables -t nat -I PREROUTING -s 0.0.0.0/0 -d 31.14.128.210 -p tcp -j DNAT --to-destination YOUR_SERVER:YOUR_PORT 103 | ``` 104 | 4. Make sure the file is executable. `chmod a+rx /jffs/scripts/*`. 105 | 5. Restart your router. 106 | 107 | ## Restrictions / Problems 108 | 109 | - With the remote / web interface, the _eco_, _turbo_ and _night_ modes can be activated all at the same time. It is possible to implement this, but I find it unnecessary. I don't know whether those mixed modes would actually do something "special" or if it's just ignored. If you need any of these combinations, open an issue. 110 | - If an API request is sent while another one is still in progress, the latter will be cancelled. It does not matter whether any of the requests actually changes anything. I.e. concerning parallel requests, only the most recent one is regarded by the device.\ 111 | Because of this, you should not use the official wep app in addition to this integration. 112 | - In case a value could not be changed (due to the problem mentioned above), it will be sent again until it is confirmed. 113 | - Because the response of an update request does not contain the updated information, updates will be sent twice in most cases. 114 | - There are however settings that can only be written and thus there is no way to check if they have been accepted. This affects current time and weekday, timer configuration and reset. Those values will only be sent once. 115 | 116 | ## Troubleshooting 117 | 118 | **Device can't be created / Device is unavailable, the IP is correct and the device is connected:**\ 119 | Turn off the device and unplug it, leave it for _an unknown amount of time (1min is enough for sure)_, then try again. 120 | 121 | **Home Assistant loses the connection to the device every few seconds:**\ 122 | ![image](https://github.com/nyffchanium/argoclima-integration/assets/55743116/9a19f95c-9685-4a49-a959-22d8ce2db0de)\ 123 | This seems to be caused by Argo's server being overloaded and not responding to the device's requests. Apparently, dropped / timed out requests result in the device resetting the WLAN connection.\ 124 | At the moment, the only known workaround is to use the [dummy server](#dummy-server). 125 | 126 | ## Contributions are welcome! 127 | 128 | If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) 129 | 130 | ## Credits 131 | 132 | The dummy server has been contributed by [@lallinger](https://github.com/lallinger). 133 | 134 | This project was initially generated from [@oncleben31](https://github.com/oncleben31)'s [Home Assistant Custom Component Cookiecutter][cookie_cutter] template. 135 | 136 | Code template was mainly taken from [@Ludeeus](https://github.com/ludeeus)'s [integration_blueprint][integration_blueprint] template. 137 | 138 | --- 139 | 140 | [argoclima]: https://github.com/nyffchanium/argoclima-integration 141 | [black]: https://github.com/psf/black 142 | [black-shield]: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge 143 | [project-status-shield]: https://img.shields.io/badge/project%20status-released-brightgreen.svg?style=for-the-badge 144 | [buymecoffee]: https://www.buymeacoffee.com/nyffchanium 145 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 146 | [commits-shield]: https://img.shields.io/github/commit-activity/y/nyffchanium/argoclima-integration.svg?style=for-the-badge 147 | [commits]: https://github.com/nyffchanium/argoclima-integration/commits/master 148 | [hacs]: https://hacs.xyz 149 | [hacsbadge]: https://img.shields.io/badge/HACS-Default-brightgreen.svg?style=for-the-badge 150 | [discord]: https://discord.gg/Qa5fW2R 151 | [discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge 152 | [exampleimg]: example.png 153 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 154 | [forum]: https://community.home-assistant.io/ 155 | [license]: https://github.com/nyffchanium/argoclima-integration/blob/master/LICENSE 156 | [license-shield]: https://img.shields.io/github/license/nyffchanium/argoclima-integration.svg?style=for-the-badge 157 | [pre-commit]: https://github.com/pre-commit/pre-commit 158 | [pre-commit-shield]: https://img.shields.io/badge/pre--commit-enabled-brightgreen?style=for-the-badge 159 | [maintenance-shield]: https://img.shields.io/badge/maintainer-%40nyffchanium-blue.svg?style=for-the-badge 160 | [releases-shield]: https://img.shields.io/github/release/nyffchanium/argoclima-integration.svg?style=for-the-badge 161 | [releases]: https://github.com/nyffchanium/argoclima-integration/releases 162 | [user_profile]: https://github.com/nyffchanium 163 | [cookie_cutter]: https://github.com/oncleben31/cookiecutter-homeassistant-custom-component 164 | [integration_blueprint]: https://github.com/custom-components/integration_blueprint 165 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![Project Status][project-status-shield] 2 | [![GitHub Release][releases-shield]][releases] 3 | [![GitHub Activity][commits-shield]][commits] 4 | [![License][license-shield]][license] 5 | 6 | [![pre-commit][pre-commit-shield]][pre-commit] 7 | [![Black][black-shield]][black] 8 | 9 | [![hacs][hacsbadge]][hacs] 10 | [![Project Maintenance][maintenance-shield]][user_profile] 11 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 12 | 13 | [![Discord][discord-shield]][discord] 14 | [![Community Forum][forum-shield]][forum] 15 | 16 | # Home Assistant Integration for Argoclima (Argo) climate control devices 17 | 18 | This is an unofficial Home Assistant integration I wrote for my Argo Ulisse Eco WiFi, using the undocumented API used by the webapp. 19 | 20 | ## Supported devices and features 21 | 22 | At the moment, only the device I own is supported. There is a good chance that other wifi capable devices use the same API though. So if you own a different device, please feel free to get in touch or contribute. 23 | | Feature | Implementation / Supported for | Ulisse 13 DCI Eco WiFi | 24 | | ---------------------------- | ------------------------------ | ---------------------- | 25 | | on / off | `climate` operation | ✓ | 26 | | operation mode | `climate` operation | ✓ | 27 | | eco mode | `climate` preset | ✓ | 28 | | turbo mode | `climate` preset | ✓ | 29 | | night mode | `climate` preset | ✓ | 30 | | current temperature | `climate` | ✓ | 31 | | set target temperature | `climate` | ✓ | 32 | | set fan speed | `climate` fan mode | ✓ | 33 | | set flap mode | x | - | 34 | | set filter mode | x | - | 35 | | set active timer | `select` | ✓ | 36 | | use remote temperature | `switch` | ✓ | 37 | | timer configuration | x | x | 38 | | set current time and weekday | set_time service | ✓ | 39 | | device lights on / off | `switch` | ✓ | 40 | | display unit \* | `select` | ✓ | 41 | | eco mode power limit | `number` | ✓ | 42 | | firmware version \*\* | device registry | ✓ | 43 | | reset device | x | x | 44 | 45 | [`text`] _platform the feature is represented by in HA_\ 46 | [-] _not supported by the device_\ 47 | [x] _not implemented_ 48 | 49 | \* This only affects the value displayed on the device and the web interface. 50 | \*\* Not visible in the frontend. 51 | 52 | ## Installation 53 | 54 | 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 55 | 2. If you do not have a `custom_components` directory (folder) there, you need to create it. 56 | 3. In the `custom_components` directory (folder) create a new folder called `argoclima`. 57 | 4. Download _all_ the files from the `custom_components/argoclima/` directory (folder) in this repository. 58 | 5. Place the files you downloaded in the new directory (folder) you created. 59 | 6. Restart Home Assistant 60 | 7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Argoclima" 61 | 62 | ## Adding your device to Home Assistant 63 | 64 | At the moment, the integration will communicate with the device locally. Cloud based communication is not supported. 65 | 66 | ### Set up WiFi 67 | 68 | Follow the instructions provided with the device to connect it to your network. Once that is done, I highly recommend assigning it a static IP via router configuration. The integration is IP based and can not identify the device by any other means. 69 | 70 | ### Configuration 71 | 72 | Select your device type, give it a name and enter the IP. The IP can be changed later.\ 73 | ![configuration](config.png) 74 | 75 | ## Using the Remote Sensor 76 | 77 | The temperature sensor integrated in the remote can still be used.\ 78 | To make this work: 79 | 80 | 1. To not overwrite the device's configuration, e.g. cover up the IR diode of the remote. 81 | 2. Set the state of the remote to On (indicated by e.g. the grid lines and the fan icon being visible). 82 | 3. Enable remote temperature mode (indicated by the user icon, toggled by holding the fan button for 2 seconds). _This might not be required. Not sure._ 83 | 84 | If my observations are correct, the remote will now send the temperature (no other settings): 85 | 86 | - every 6 minutes if no change is detected 87 | - whenever the temperature displayed on the remote changes 88 | 89 | Probably more often, but that's what I found. 90 | 91 | ## Dummy Server 92 | 93 | By default, your device periodically communicates with Argos's server (hardcoded IP `31.14.128.210`). Without this connection, the API this integration uses won't work. This repository provides a dummy server docker image, so you can keep the traffic in your local network. By doing this, you will lose the ability to use the original web UI. 94 | 95 | You can pull the docker image from https://hub.docker.com/r/nyffchanium/argoclima-dummy-server, or you can run the server without docker by using the Go script in the `dummy-server` folder. 96 | 97 | You can set the port the dummy server listens to via the env `SERVER_PORT`. It defaults to `8080`. 98 | 99 | ### Routing 100 | For the dummy server to be of any use, you need to redirect the traffic to it. As the original server is a hardcoded public IP you need to change the routing through your router. 101 | 102 | Example with an Asus router running Asuswrt-Merlin: 103 | 1. Enable custom scripts and SSH via the router UI (Administration -> System). 104 | 2. SSH into your router and create a file called `nat-start` in `/jffs/scripts` (replace `YOUR_SERVER` and `YOUR_PORT` with the address and port of your dummy server instance). 105 | ```sh 106 | #!/bin/sh 107 | iptables -t nat -I PREROUTING -s 0.0.0.0/0 -d 31.14.128.210 -p tcp -j DNAT --to-destination YOUR_SERVER:YOUR_PORT 108 | ``` 109 | 4. Make sure the file is executable. `chmod a+rx /jffs/scripts/*`. 110 | 5. Restart your router. 111 | 112 | ## Restrictions / Problems 113 | 114 | - With the remote / web interface, the _eco_, _turbo_ and _night_ modes can be activated all at the same time. It is possible to implement this, but I find it unnecessary. I don't know whether those mixed modes would actually do something "special" or if it's just ignored. If you need any of these combinations, open an issue. 115 | - If an API request is sent while another one is still in progress, the latter will be cancelled. It does not matter whether any of the requests actually changes anything. I.e. concerning parallel requests, only the most recent one is regarded by the device.\ 116 | Because of this, you should not use the official wep app in addition to this integration. 117 | - In case a value could not be changed (due to the problem mentioned above), it will be sent again until it is confirmed. 118 | - Because the response of an update request does not contain the updated information, updates will be sent twice in most cases. 119 | - There are however settings that can only be written and thus there is no way to check if they have been accepted. This affects current time and weekday, timer configuration and reset. Those values will only be sent once. 120 | 121 | ## Troubleshooting 122 | 123 | **Device can't be created / Device is unavailable, the IP is correct and the device is connected:**\ 124 | Turn off the device and unplug it, leave it for _an unknown amount of time (1min is enough for sure)_, then try again. 125 | 126 | **Home Assistant loses the connection to the device every few seconds:**\ 127 | ![image](https://github.com/nyffchanium/argoclima-integration/assets/55743116/9a19f95c-9685-4a49-a959-22d8ce2db0de)\ 128 | This seems to be caused by Argo's server being overloaded and not responding to the device's requests. Apparently, dropped / timed out requests result in the device resetting the WLAN connection.\ 129 | At the moment, the only known workaround is to use the [dummy server](#dummy-server). 130 | 131 | ## Contributions are welcome! 132 | 133 | If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) 134 | 135 | ## Credits 136 | 137 | The dummy server has been contributed by [@lallinger](https://github.com/lallinger). 138 | 139 | This project was initially generated from [@oncleben31](https://github.com/oncleben31)'s [Home Assistant Custom Component Cookiecutter][cookie_cutter] template. 140 | 141 | Code template was mainly taken from [@Ludeeus](https://github.com/ludeeus)'s [integration_blueprint][integration_blueprint] template. 142 | 143 | --- 144 | 145 | [argoclima]: https://github.com/nyffchanium/argoclima-integration 146 | [black]: https://github.com/psf/black 147 | [black-shield]: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge 148 | [project-status-shield]: https://img.shields.io/badge/project%20status-released-brightgreen.svg?style=for-the-badge 149 | [buymecoffee]: https://www.buymeacoffee.com/nyffchanium 150 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 151 | [commits-shield]: https://img.shields.io/github/commit-activity/y/nyffchanium/argoclima-integration.svg?style=for-the-badge 152 | [commits]: https://github.com/nyffchanium/argoclima-integration/commits/master 153 | [hacs]: https://hacs.xyz 154 | [hacsbadge]: https://img.shields.io/badge/HACS-Default-brightgreen.svg?style=for-the-badge 155 | [discord]: https://discord.gg/Qa5fW2R 156 | [discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge 157 | [exampleimg]: example.png 158 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 159 | [forum]: https://community.home-assistant.io/ 160 | [license]: https://github.com/nyffchanium/argoclima-integration/blob/master/LICENSE 161 | [license-shield]: https://img.shields.io/github/license/nyffchanium/argoclima-integration.svg?style=for-the-badge 162 | [pre-commit]: https://github.com/pre-commit/pre-commit 163 | [pre-commit-shield]: https://img.shields.io/badge/pre--commit-enabled-brightgreen?style=for-the-badge 164 | [maintenance-shield]: https://img.shields.io/badge/maintainer-%40nyffchanium-blue.svg?style=for-the-badge 165 | [releases-shield]: https://img.shields.io/github/release/nyffchanium/argoclima-integration.svg?style=for-the-badge 166 | [releases]: https://github.com/nyffchanium/argoclima-integration/releases 167 | [user_profile]: https://github.com/nyffchanium 168 | [cookie_cutter]: https://github.com/oncleben31/cookiecutter-homeassistant-custom-component 169 | [integration_blueprint]: https://github.com/custom-components/integration_blueprint 170 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog==6.8.0 2 | homeassistant==2024.1.3 3 | pip>=21.0,<23.4 4 | ruff==0.1.13 5 | -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/argoclima 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff check . --fix 8 | ruff format . 9 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt 8 | --------------------------------------------------------------------------------