├── .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 | 
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 | \
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 | 
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 | \
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 |
--------------------------------------------------------------------------------