Repository: scottyphillips/echonetlite_homeassistant Branch: master Commit: 0995f2817b74 Files: 32 Total size: 287.1 KB Directory structure: gitextract_9lbvaute/ ├── .github/ │ └── workflows/ │ ├── black.yml │ └── python-app.yml ├── .gitignore ├── .vscode/ │ └── settings.json ├── LICENSE ├── README.ja.md ├── README.md ├── Services.ja.md ├── Services.md ├── custom_components/ │ └── echonetlite/ │ ├── __init__.py │ ├── binary_sensor.py │ ├── climate.py │ ├── config_flow.py │ ├── const.py │ ├── cover.py │ ├── fan.py │ ├── light.py │ ├── manifest.json │ ├── number.py │ ├── quirks/ │ │ ├── Nichicon/ │ │ │ └── all/ │ │ │ └── 02A5.py │ │ └── Panasonic/ │ │ └── all/ │ │ └── 0135.py │ ├── select.py │ ├── sensor.py │ ├── services.yaml │ ├── strings.json │ ├── switch.py │ ├── time.py │ └── translations/ │ ├── en.json │ ├── ja.json │ └── pt.json ├── hacs.json └── info.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/black.yml ================================================ name: Lint on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: psf/black@stable ================================================ FILE: .github/workflows/python-app.yml ================================================ # This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Check for Syntax Errors on: push: branches: [ master ] pull_request: branches: [ master ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python 3.9 uses: actions/setup-python@v2 with: python-version: 3.9 # - name: Install dependencies # run: | # python -m pip install --upgrade pip # pip install flake8 pytest # if [ -f requirements.txt ]; then pip install -r requirements.txt; fi # - name: Lint with flake8 # run: | # stop the build if there are Python syntax errors or undefined names # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics # - name: Test with pytest # run: | # pytest ================================================ FILE: .gitignore ================================================ *.pyc *.tar.gz build/* dist/* pychonet.egg-info/* .DS_Store __pycache__ pychonet ================================================ FILE: .vscode/settings.json ================================================ { "python.formatting.provider": "black" } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 Scott Phillips Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.ja.md ================================================ # ECHONETLite Platform Custom Component for Home Assistant [![GitHub Release][releases-shield]][releases] [![License][license-shield]](LICENSE) [![hacs][hacsbadge]][hacs] ![Project Maintenance][maintenance-shield] ECHONETLite互換機器で使用するためのHomeAssistantカスタムコンポーネント。 このカスタムコンポーネントは「pychonet」Python3ライブラリを利用しています。 また、そのコンポーネントもこの作者によって維持されています。 (https://github.com/scottyphillips/pychonet) **このコンポーネントは、エアコン・ファン・センサー・選択およびスイッチプラットフォームをセットアップします** # 現在動作確認されている機器: フィードバックに基づいて、このカスタムコンポーネントは以下の互換性のあるECHONETLite機器で動作します: * 三菱電機 MAC-568IF-E WiFi アダプターでコントロールされている以下の機器: * GE シリーズ * MSZ-GE42VAD * MSZ-GE24VAD * MSZ-GL71VGD * MSZ-GL50VGD * MSZ-GL35VGD * MSZ-GL25VGD * AP シリーズ * MSZ-AP22VGD * MSZ-AP25VGD * MSZ-AP50VGD * LN シリーズ * MSZ-LN25VG2 * MSZ-LN35VG2 * MSZ-LN50VG2 * 換気システム * PEA-M100GAA * PEA-RP140GAA * 三菱電機 HM-W002-AC WiFi アダプターでコントロールされている以下の機器: * JXV シリーズ * MSZ-JXV4018S * 三菱電機 MAC-578IF2-E WiFi アダプターでコントロールされている以下の機器: * AP シリーズ * MSZ-AP22VGD * MSZ-AP35VGD * MSZ-AP50VGD * 換気システム * PEAD-RP71 * 三菱電機 MAC-600IF WiFi アダプターでコントロールされている以下の機器: * Z シリーズ * MSZ-ZW4022S * 三菱電機 MAC-900IF WiFi アダプターでコントロールされている以下の機器: * Z シリーズ * MSZ-ZW4024S * XD シリーズ * MSZ-XD2225 * R シリーズ * MSZ-BKR2223 * 三菱電機 REF-WLAN001 アダプターでコントロールされている以下の機器: * 冷蔵庫 * MR-WZ55H * 富士通ゼネラル OP-J03DZ WiFi アダプターでコントロールされている以下の機器: * エアコン * 「ノクリア」Cシリーズ * AS-C224R * AS-C254R * 「ノクリア」Vシリーズ * AS-V173N2 * 'MoekadenRoom' ECHONETLite シミュレーター: https://github.com/SonyCSL/MoekadenRoom * エアコンオブジェクト * 照明オブジェクト * 電動ブラインドオブジェクト * 電子錠オブジェクト * 温度計オブジェクト * スマートメーターオブジェクト * シャープ * エアコン * AY-J22H * AY-L40P * 加湿空気清浄機 * KI-HS70 * 太陽光発電システム * JH-RWL8 * パナソニック * エアコン * CS-221DJ * IP/JEM-A 変換アダプター * HF-JA2-W * コイズミ照明 * スマートブリッジ AE50264E (https://www.koizumi-lt.co.jp/product/jyutaku/tree/ ) * リンナイ * 給湯器 (Wi-Fi機能(ECHONETLite)搭載のリモコン) * スイッチエンティティで、運転・ふろ自動・おいだき・タイマー予約をコントロール可能 * タイマー予約を設定するための入力エンティティは、テンプレートと[サービスコール]((Services.ja.md))を使用して設定できます。 * ダイキン * エアコン * ECHONET Lite 対応機器 * オムロン * 太陽光発電用ゲートウェイ * 太陽光発電とグリッド消費を含むホームアシスタントエネルギーダッシュボードの完全サポート。 * 各種センサーの負荷状態。 * 低圧スマートメーター(Bルートサービス) * Wi-SUN <-> Ethernet/Wifi ブリッジが必要 * [nao-pon/python-echonet-lite](https://github.com/nao-pon/python-echonet-lite) * など 上記の他にも[ECHONET Lite規格](https://echonet.jp/product/echonet-lite/)にリストアップされている多くの機器がコントロールできる可能性があります。 ## インストール - ECHONETプロトコルを有効にする このカスタムコンポーネントは、もともと三菱 MAC-568IF-E WiFi アダプター用に設計されました。 ECHONETliteを有効にするための基本ガイドを以下に示します。 公式の Mitsubishi AU/NZ Wifi アプリで「edit unit」設定の「ECHONETlite」プロトコル有効にする必要があります。 ※ 日本で発売されている ECHONET Lite 対応のアダプター(HM-W002-AC, HM-W002-ACB など)では設定の必要はないかも知れません。 ![echonet][echonetimg] 他の多くの製品はこのカスタムコンポーネントを使用して動作しますが、「ECHONETlite」プロトコルを正しくサポートしている必要があります。 作者は、他のベンダー製品で ECHONET Lite を有効にすることを支援することはできません。 ## インストール ### HACS利用 1. 統合でECHONETLiteを検索します 2. [インストール]をクリックします ### ダウンロードして配置 1. 選択したツールを使用して、HA構成( `configuration.yaml`がある場所)のディレクトリ(フォルダー)を開きます。 2. そこに`custom_components`ディレクトリ(フォルダ)がない場合は、それを作成する必要があります。 3. `custom_components`ディレクトリ(フォルダ)に`echonetlite`という新しいフォルダを作成します。 4. このリポジトリの`custom_components/echonetlite/`ディレクトリ(フォルダ)から_すべての_ファイルをダウンロードします。 5. ダウンロードしたファイルを作成した新しいディレクトリ(フォルダ)に配置します。 ## コンポーネントの有効化 1. ホームアシスタントを再起動し、ブラウザのキャッシュをクリアします 2. 「構成」->「統合」->「統合の追加」に移動します。 3. 「echonetlite」統合を選択します。 ホストフィールドにIPアドレスを入力し、プラットフォームに名前を付けます。 4. プラットフォームは、サポートされているプラットフォームを自動的に構成します。 気候、センサー、スイッチ、ファン、選択。 5. 構成する追加のデバイスがある場合は、手順2を繰り返します。 ## サポートされているエアコンおよび空気清浄機の風量および風向スイングモード設定のオプションの構成 統合を追加したら、構成->統合に戻ることができます。 ECHONETLiteデバイスの下で[構成]をクリックします。 必要な風量と風向スイングモードの設定を微調整します。 設定では、統合によりあなたのシステムでサポートされている機能を判別できます。 注:ECHONETLiteはこれらの設定に対して許可された値を返す手段を提供しないため、適切な特定のオプションを選択することは「試行錯誤」のプロセスです。 オプションを構成して保存するとすぐに、設定が有効になります。 ## 栄誉殿堂 スイッチエンティティを作成し、カスタムサービスコールフレームワークを作成してくれた Naoki Sawada (nao-pon) に感謝します。 コードベースとドキュメントの全体的な質の向上してくれた JasonNader に感謝します。 古いコードのリファクタリングとテストを手伝ってくれた khcnz (Karl Chaffey) と gvs に感謝します。 Dick Swart、Masaki Tagawa、Paul、khcnz、Kolodnerd、およびAlfie Gernerに、元の "mitsubishi_hass" へのコード更新とこのカスタムコンポーネントへの適用に貢献してくれたことに感謝します。 ホームアシスタント用に独自のネイティブ Python ECHONET ライブラリを作成するように促してくれた JeffroCarr に感謝します。 彼自身のリポジトリのいくつかのアイデアは、私自身のコードに実装されました。 (https://github.com/jethrocarr/echonetlite-hvac-mqtt-service.git) "pychonet"ライブラリの基礎を形成した Node JS の高品質で十分に文書化された ECHONET Lite ライブラリをオープンソーシングしてくれた Futomi Hatano に感謝します。 (https://github.com/futomi/node-echonet-lite) ## ライセンス このアプリケーションはMITライセンスの下でライセンスされています。詳細については、ライセンスを参照してください。 *** [echonetlite_homeassistant]: https://github.com/scottyphillips/echonetlite_homeassistant [hacs]: https://github.com/custom-components/hacs [hacsbadge]: https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge [releases-shield]: https://img.shields.io/github/release/scottyphillips/echonetlite_homeassistant.svg?style=for-the-badge [releases]: https://github.com/scottyphillips/echonetlite_homeassistant/releases [license-shield]:https://img.shields.io/github/license/scottyphillips/echonetlite_homeassistant?style=for-the-badge [buymecoffee]: https://www.buymeacoffee.com/RgKWqyt?style=for-the-badge [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge [maintenance-shield]: https://img.shields.io/badge/Maintainer-Scott%20Phillips-blue?style=for-the-badge [echonetimg]: ECHONET.jpeg ================================================ FILE: README.md ================================================ # ECHONETLite Platform Custom Component for Home Assistant [![GitHub Release][releases-shield]][releases] [![License][license-shield]](LICENSE) [![hacs][hacsbadge]][hacs] ![Project Maintenance][maintenance-shield] ([日本語](https://github.com/scottyphillips/echonetlite_homeassistant/blob/master/README.ja.md)) A Home Assistant custom component for use with ECHONETLite compatible devices. This custom component makes use of the 'pychonet' Python3 library also maintained by this author. (https://github.com/scottyphillips/pychonet) *Important note - this repo is no longer in active developement, and i will no longer be actively involved in troubleshooting any issues with the component. However i will respond and approve PRs. **This component will set up the climate, fan, sensor, select and switch platforms.** # Current working systems: Based upon feedback this custom component works with the following compatible ECHONETLite Devices: | **Manufacturer** | **Device** | **ECHONETLite Object Class** | **Home Assistant Entities** | **Notes** | |:--------------------|:-----------------------------------------------|:-----------------------------|:---------------------------------|:--------------------------------------------------------------------------------------------------| | Mitsubishi Electric | MAC-568IF-E | HomeAirConditioner | Climate, Sensor, Select | WiFi Adaptor connected to various HVAC ducted and split systems. See list below. | | Mitsubishi Electric | MAC-577IF-E | HomeAirConditioner | Climate, Sensor, Select | WiFi Adaptor connected to various HVAC ducted and split systems. See list below. | | Mitsubishi Electric | MAC-577IF2-E | HomeAirConditioner | Climate, Sensor, Select | WiFi Adaptor connected to various HVAC ducted and split systems. See list below. | | Mitsubishi Electric | MAC-578IF2-E | HomeAirConditioner | Climate, Sensor, Select | WiFi Adaptor connected to various HVAC ducted and split systems. See list below. | | Mitsubishi Electric | MAC-587IF-E | HomeAirConditioner | Climate, Sensor, Select | WiFi Adaptor connected to various HVAC ducted and split systems. See list below. | | Mitsubishi Electric | MAC-588IF-E | HomeAirConditioner | Climate, Sensor, Select | WiFi Adaptor connected to various HVAC ducted and split systems. See list below. | | Mitsubishi Electric | MAC-600IF | HomeAirConditioner | Climate, Sensor, Select | | | Mitsubishi Electric | MAC-900IF | HomeAirConditioner | Climate, Sensor, Select | WiFi Adaptor connected to various HVAC ducted and split systems. See list below. | | Mitsubishi Electric | HM-W002-AC | HomeAirConditioner | Climate, Sensor, Select | WiFi Adaptor connected to various HVAC ducted and split systems. See list below. | | Mitsubishi Electric | Eco-Cute SRT-S466A + RMCB-H6SE-T | ElectricWaterHeater | Sensor, Select, Switch | | | Mitsubishi Electric | REF-WLAN001 | Refrigerator | Sensor | | | Sharp | AY-J22H Air Conditioner | HomeAirConditioner | Climate, Sensor, Select | | | Sharp | AY-XP12YHE Air Conditioner | HomeAirConditioner | Climate, Sensor, Select | | | Sharp | AY-XP24YHE Air Conditioner | HomeAirConditioner | Climate, Sensor, Select | | | Sharp | AY-L40P Air Conditioner | HomeAirConditioner | Climate, Sensor, Select | | | Sharp | KI-HS70 Air Purifier | HomeAirCleaner | Fan, Sensor, Select | | | Sharp | JH-RWL8 Multi Energy Monitor | HomeSolarPower, StorageBattery | Sensor, Select | | | Panasonic | CS-221DJ Air Conditioner | HomeAirConditioner | Climate, Sensor, Select | | | Panasonic | CS-362DJ2 Air Conditioner | HomeAirConditioner | Climate, Sensor, Select | | | Panasonic | HF-JA2-W | | Sensor | IP/JEM-A conversion adapter. | | Panasonic | Link Plus WTY2001 | GeneralLighting, Lighting system | Light, Select | Lighting system is selector of preset scene. | | Panasonic | Smart Cosmo Type LAN | DistributionPanelMeter | Sensor | | | Rinnai | Hot water systems (ECHONETLite enabled models) | | Sensor, Switch, Input | Input entity to configure Hot Water Timers can be configured by using a template and a [Service Call](Services.md). | | Koizumi | Lighting system AE50264E bridge | LightingSystem | Light, Sensor | https://www.koizumi-lt.co.jp/product/jyutaku/tree/ | | Daikin | ECHONETLite enabled HVAC models. | HomeAirConditioner | Climate, Sensor, Select | | | OMRON | Home Solar Power Generation | | Switch, Sensor | Full support for Home Assistant Energy Dashboard including solar production and grid consumption. | | JDM Electric Meters | Low voltage smart meter (B route service) | | Sensor | Require Wi-SUN <-> Ethernet/Wifi bridge.
[nao-pon/python-echonet-lite](https://github.com/nao-pon/python-echonet-lite) | | Noritz | Bathtub and floor heating system | HotWaterGenerator | Sensor, Switch | | | KDK | ECHONETLite enabled Ceiling Fans | CeilingFan, GeneralLighting | Fan, Light, Sensor | Rebranded Panasonic Ceiling Fan. | | Sony | 'MoekadenRoom' ECHONETLite Simulator | | Climate, Select, Switch, Sensor | https://github.com/SonyCSL/MoekadenRoom. | * Mitsubishi MAC-568IF-E WiFi Adaptor connected to the following systems: * GE Series * MSZ-GE42VAD * MSZ-GE24VAD * MSZ-GL71VGD * MSZ-GL50VGD * MSZ-GL35VGD * MSZ-GL25VGD * AP Series * MSZ-AP22VGD * MSZ-AP25VGD * MSZ-AP50VGD * LN Series * MSZ-LN25VG2 * MSZ-LN35VG2 * MSZ-LN50VG2 * Ducted * PEA-M100GAA * PEA-M100HAA * PEA-RP140GAA * Bulkhead * SEZ-M71DA * Mitsubishi MAC-578IF2-E WiFi Adaptor connected to the following systems: * AP Series * MSZ-AP22VGD * MSZ-AP35VGD * MSZ-AP50VGD * Ducted * PEAD-RP71 * Mitsubishi MAC-587IF-E WiFi Adaptor connected to the following systems: * Ducted * PEAD-M50JA2 * Mitsubishi MAC-588IF-E WiFi Adaptor connected to the following systems: * Ducted * PEA-M200LAA * PEAD-M71JAA * Mitsubishi MAC-600IF WiFi Adaptor connected to the following systems: * Z Series * MSZ-ZW4022S * Mitsubishi MAC-900IF WiFi Adaptor connected to the following systems: * Z Series * MSZ-ZW4024S * XD Series * MSZ-XD2225 * R Series * MSZ-BKR2223 * Mitsubishi HM-W002-AC WiFi Adaptor connected to the following systems: * JXV Series * MSZ-JXV4018S * Mitsubishi REF-WLAN001 WiFi Adaptor connected to the following systems: * Refrigerator * MR-WZ55H * Fujitsu General OP-J03DZ WiFi Adaptor connected to the following systems: * Air Conditioner * "Nocria" C Series * AS-C224R * AS-C254R * "Nocria" V Series * AS-V173N2 ## Installation - Enable ECHONET protocol This Custom Component was originally designed for the Mitsubishi MAC-568IF-E WiFi Adaptor, a basic guide for enabling ECHONETlite is provided below. From the official Mitsubishi AU/NZ Wifi App, you will need to enable the 'ECHONET lite' protocol under the 'edit unit' settings. ![echonet][echonetimg] Note that the proprietary Mitsubishi app (MELCloud/MELView/Kumo Cloud) controls some models in single ˚F or half ˚C, but ECHONET works in whole ˚C. Many other products will work using this custom-component, but they must correctly support the 'ECHONET lite' protocol. The author cannot assist with enabling ECHONET Lite for other vendor products. ### Home Network If you have a firewall, ensure port 3610 is unblocked ([EchoNet Specifications](https://echonet.jp/spec_v113_lite_en/)) ## Installation ### Install using HACS 1. Click the link below or look up 'ECHONETLite Platform' in integrations\ [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=scottyphillips&repository=echonetlite_homeassistant&category=integration) 3. Click 'Download', leave the version be and click 'Download' again. 4. Restart Home Assistant ### Install manually 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 2. If you do not have a `custom_components` directory (folder) there, you need to create it. 3. In the `custom_components` directory (folder) create a new folder called `echonetlite`. 4. Download _all_ the files from the `custom_components/echonetlite/` directory (folder) in this repository. 5. Place the files you downloaded in the new directory (folder) you created. 6. Restart Home Assistant and clear your browser cache ## Setup 1. In Home Assistant, go to Settings -> Devices & Services -> ADD INTEGRATION. 2. Select the 'ECHONET Lite' integration. Enter the IP address of the HVAC unit in the host field, and give the platform a name. 3. Platform will automatically configure any supported platforms e.g. climate, sensor, switch, fan, select. 4. If you have additional devices to configure then repeat step 1. ## Enabling support for additional Mitsubishi interfaces Some Mitsubishi WiFi adaptors have hidden support for the ECHONET Lite protocol, which can be enabled by calling the `/smart` endpoint. For more information, please see [this issue](https://github.com/scottyphillips/echonetlite_homeassistant/issues/226). TLDR: Run the following command: ```bash curl -H 'Content-Type: text/xml' -d '7WVvmfhMYzGVi70nyFhmKEy9Jo3Hg3994vi9y1kEgDFWd/1ch9RWDUgY4HgsvMHFvP93fQ30AvEJCNcd0GTwPID0F8V5eyMVj/qAQCXFqYrRtJh8MIpm2/h7jZ2SsPj0' "http://${ip}/smart" ``` Replace `${ip}` with the IP of your adaptor. If successful, your should see a response like this: ```xml [encrypted content] ``` ## Configuring Options for Fan and swing mode settings for supported HVAC and Air Purifiers. Once you have added the integration, you can go back to configuration -> integrations. Under your ECHONETLite device click 'configure'. Fine tune your required fan and swing mode settings. The integration will be able to determine what settings are supported for your system in question. NOTE: Selecting which specific options are suitable is a 'trial and error' process as ECHONETLite does not provide a means of returning permittted values for these settings. As soon as you configure your options and save, the settings will take effect. ## How to find quirks of a manufacturer or specific device It is known that there are manufacturers or specific devices that have data points that are not in the Echonet Lite specification. If the device you are using has these quirks, it will be more useful to find the quirks and define the behavior. Here are the steps to find the quirks as a first step. 1. Enable the debug log on the ECHONETLite integration screen and restart HA. 2. Once startup is complete, disable the debug log and download the log. 3. Using the IP address of the target device, check the getmap and setmap data to find values ​​above 240 (0xF0). In the following sample log, 250 (0xFA) and 253 (0xFD) can be get/set. This device is from manufacturer "Rinnai" and has eojgc 2 (0x02) and eojcc 130 (0x82) ``` {'eojcc': 130, 'eojci': 1, 'eojgc': 2, 'getmap': [128, 224, 129, 241, 130, 131, 147, 243, 244, 245, 246, 247, 136, 248, 249, 138, 250, 251, 252, 157, 253, 158, 254, 159, 255], 'host': '192.168.0.49', 'host_product_code': None, 'manufacturer': 'Rinnai', 'name': None, 'ntfmap': [128, 129, 136, 250, 251, 253], 'setmap': [129, 147, 250, 253], 'uid': '0000590170500000000024cd8d4e84f4', 'uidi': '0000590170500000000024cd8d4e84f4-2-130-1'}]} ``` 4. Create a quirks file for debugging. - File name: quirks/Rinnai/all/0282.py (quirks/{manufacturer}/all/xxyy.py) ```python from homeassistant.const import CONF_NAME def _hex(edt): return edt.hex() QUIRKS = { 0xFA: { "EPC_FUNCTION": _hex, "ENL_OP_CODE": { CONF_NAME: "FA", }, }, 0xFD: { "EPC_FUNCTION": _hex, "ENL_OP_CODE": { CONF_NAME: "FD", }, }, } ``` 5. When you restart the HA, two new entities, FA and FD, will be configured in this example. Perform zone control and observe whether their values ​​change in a distinctive way. 6. If a characteristic change occurs, you are lucky! Please submit a new issue, or go a step further and submit your pull request. 👍 ## Hall of Fame Thanks Naoki Sawada for creating the switch entity, creating the custom service call framework, and a ton of other improvements. Most importantly of all he contributed the translation into 日本語. Thanks to scumbug, lordCONAN, and xen2 for contributing some very interesting devices. Thanks to Jason Nader for all the quality of life updates to the codebase and doco. Thanks to khcnz (Karl Chaffey) and gvs for helping refector the old code and contributing to testing. Thanks to Dick Swart, Masaki Tagawa, Paul, khcnz, Kolodnerd, and Alfie Gerner for each contributing code updates to to the original 'mitsubishi_hass' and therefore this custom component. Thanks to Jeffro Carr who inspired me to write my own native Python ECHONET library for Home Assistant. Some ideas in his own repo got implemented in my own code. (https://github.com/jethrocarr/echonetlite-hvac-mqtt-service.git) Thanks to Futomi Hatano for open sourcing a high quality and well documented ECHONET Lite library in Node JS that formed the basis of the 'Pychonet' library. (https://github.com/futomi/node-echonet-lite) Thanks to all other contributers who I might have missed for raising PRs and issues which has made this little weekend project into something useful for many people. ## License This application is licensed under an MIT license, refer to LICENSE for details. *** [echonetlite_homeassistant]: https://github.com/scottyphillips/echonetlite_homeassistant [hacs]: https://github.com/custom-components/hacs [hacsbadge]: https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge [releases-shield]: https://img.shields.io/github/release/scottyphillips/echonetlite_homeassistant.svg?style=for-the-badge [releases]: https://github.com/scottyphillips/echonetlite_homeassistant/releases [license-shield]:https://img.shields.io/github/license/scottyphillips/echonetlite_homeassistant?style=for-the-badge [buymecoffee]: https://www.buymeacoffee.com/RgKWqyt?style=for-the-badge [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge [maintenance-shield]: https://img.shields.io/badge/Maintainer-Scott%20Phillips-blue?style=for-the-badge [echonetimg]: ECHONET.jpeg ================================================ FILE: Services.ja.md ================================================ # サービスの構成 バージョン 3.5.3 以降、予備的なサポートとして高度なサービス呼び出しが構成されています。 現在、次のデバイスがホームアシスタントサービスをサポートしています。 ## ECHONET Lite 対応給湯器 (リンナイ給湯器で動作確認済) ECHONET Lite 対応給湯器は、`echonetlite.set_on_timer_time`サービスコールを使用して自動湯はりタイマーを設定できます。 これは、以下の例のように自動化で使用して、対応する入力エンティティを作成できます。 自動化のサンプル - センサーエンティティID: sensor.hot_water_set_value_of_on_timer_time - 時刻入力エンティティID: input_datetime.hot_water_value_of_on_timer_time ```yaml alias: Relay Hot Water On Timer time description: '' trigger: - platform: state entity_id: - input_datetime.hot_water_value_of_on_timer_time for: hours: 0 minutes: 0 seconds: 5 id: set - platform: state entity_id: - sensor.hot_water_set_value_of_on_timer_time id: get condition: - condition: template value_template: >- {{strptime(states('input_datetime.hot_water_value_of_on_timer_time'), '%H:%M:%S') != strptime(states('sensor.hot_water_set_value_of_on_timer_time')+':00', '%H:%M:%S')}} action: - if: - condition: trigger id: set then: - service: echonetlite.set_on_timer_time entity_id: sensor.hot_water_set_value_of_on_timer_time data_template: timer_time: '{{ states(''input_datetime.hot_water_value_of_on_timer_time'') }}' else: - service: input_datetime.set_datetime entity_id: input_datetime.hot_water_value_of_on_timer_time data_template: time: '{{ states(''sensor.hot_water_set_value_of_on_timer_time'') }}' mode: single ``` ================================================ FILE: Services.md ================================================ # Configuring Services Preliminary support for advanced service calls has been configured as of version 3.5.3. At the moment the follow devices support Home Assistant services: ## Hot Water Heater System ECHONET Lite compatible Hot Water Heaters can have their hot water heater timers configured using the `echonetlite.set_on_timer_time` service call. This in turn can be used in automation as per the below example to create a corresponding input entity: ### Automation example - Sensor entity id as: `sensor.hot_water_set_value_of_on_timer_time` - Input entity id as: `input_datetime.hot_water_value_of_on_timer_time` ```yaml alias: Relay Hot Water On Timer time description: '' trigger: - platform: state entity_id: - input_datetime.hot_water_value_of_on_timer_time for: hours: 0 minutes: 0 seconds: 5 id: set - platform: state entity_id: - sensor.hot_water_set_value_of_on_timer_time id: get condition: - condition: template value_template: >- {{strptime(states('input_datetime.hot_water_value_of_on_timer_time'), '%H:%M:%S') != strptime(states('sensor.hot_water_set_value_of_on_timer_time')+':00', '%H:%M:%S')}} action: - if: - condition: trigger id: set then: - service: echonetlite.set_on_timer_time entity_id: sensor.hot_water_set_value_of_on_timer_time data_template: timer_time: '{{ states(''input_datetime.hot_water_value_of_on_timer_time'') }}' else: - service: input_datetime.set_datetime entity_id: input_datetime.hot_water_value_of_on_timer_time data_template: time: '{{ states(''sensor.hot_water_set_value_of_on_timer_time'') }}' mode: single ``` ## Single-byte integer value setting services We can create a numeric setting entity with the following automation with the number input helper. Of course, it can also be used for various automations. With future expansion, parameters that can be set numerically on other devices can be added, so I think it can be used conveniently. (Thanks @nao-pon) - Sensor entity id as: `sensor.set_value_of_hot_water_temperature` - Input entity id as: `input_number.set_value_of_hot_water_temperature` ### Automation example ```yaml alias: Relay Set Value Of Hot Water Temperature description: '' trigger: - platform: state entity_id: - input_number.set_value_of_hot_water_temperature for: hours: 0 minutes: 0 seconds: 2 id: set - platform: state entity_id: - sensor.set_value_of_hot_water_temperature id: get condition: - condition: template value_template: >- {{int(states('input_number.set_value_of_hot_water_temperature'), 0) != int(states('sensor.set_value_of_hot_water_temperature'), 0)}} action: - if: - condition: trigger id: set then: - service: echonetlite.set_value_int_1b entity_id: sensor.set_value_of_hot_water_temperature data_template: value: '{{ states(''input_number.set_value_of_hot_water_temperature'') }}' else: - service: input_number.set_value entity_id: input_number.set_value_of_hot_water_temperature data_template: value: '{{ states(''sensor.set_value_of_hot_water_temperature'') }}' mode: queued max: 2 ``` ================================================ FILE: custom_components/echonetlite/__init__.py ================================================ """The echonetlite integration.""" from __future__ import annotations import asyncio import logging import os import time as pytime from datetime import timedelta from functools import partial from importlib import import_module from typing import Any import pychonet as echonet from homeassistant import config_entries from homeassistant.components.number.const import NumberDeviceClass from homeassistant.components.sensor.const import SensorDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, PERCENTAGE, Platform, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, UnitOfTemperature, UnitOfVolume, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.util import Throttle from pychonet import ECHONETAPIClient from pychonet.echonetapiclient import EchonetMaxOpcError from pychonet.EchonetInstance import ( ENL_CUMULATIVE_POWER, ENL_GETMAP, ENL_INSTANTANEOUS_POWER, ENL_SETMAP, ENL_STATUS, ENL_UID, ) from pychonet.HomeAirConditioner import ( ENL_AIR_HORZ, ENL_AIR_VERT, ENL_AUTO_DIRECTION, ENL_FANSPEED, ENL_SWING_MODE, ) from pychonet.lib.const import ENL_STATMAP, VERSION from pychonet.lib.epc import EPC_CODE, EPC_SUPER from pychonet.lib.epc_functions import ( DICT_30_ON_OFF, DICT_30_OPEN_CLOSED, DICT_30_TRUE_FALSE, DICT_41_ON_OFF, _hh_mm, ) from pychonet.lib.udpserver import UDPServer from .config_flow import ErrorConnect, async_discover_newhost, enumerate_instances from .const import ( CONF_BATCH_SIZE_MAX, CONF_ENABLE_SUPER_ENERGY, DOMAIN, ENABLE_SUPER_ENERGY_DEFAULT, ENL_OP_CODES, ENL_SUPER_CODES, ENL_SUPER_ENERGES, ENL_TIMER_SETTING, MISC_OPTIONS, TEMP_OPTIONS, USER_OPTIONS, ) _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.SENSOR, Platform.CLIMATE, Platform.SELECT, Platform.LIGHT, Platform.FAN, Platform.SWITCH, Platform.TIME, Platform.NUMBER, Platform.COVER, ] PARALLEL_UPDATES = 0 MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) MAX_UPDATE_BATCH_SIZE = 10 MIN_UPDATE_BATCH_SIZE = 3 SETUP_BUDGET = 45.0 DISCOVERY_MAX_BUDGET = 20.0 DISCOVERY_MIN_BUDGET = 8.0 INSTANCE_MAX_BUDGET = 8.0 INSTANCE_MIN_BUDGET = 4.0 INSTANCE_RETRY_DELAY = 0.3 def _remaining_setup_budget(started: float) -> float: """Return remaining setup budget in seconds.""" return SETUP_BUDGET - (pytime.monotonic() - started) async def _run_with_timeout(coro, timeout_s: float): """Run coroutine with timeout.""" async with asyncio.timeout(timeout_s): return await coro def get_device_name(connector, config) -> str: if connector._name: return connector._name if connector._instance._eojci > 1: return f"{config.title} {connector._instance._eojci}" return config.title def get_name_by_epc_code( jgc: int, jcc: int, code: int, unknown: str | None = None, given_name: str | None = None, ) -> str: if given_name != None: return given_name if code in EPC_SUPER: return EPC_SUPER[code] else: if unknown == None: unknown = f"({code})" name = EPC_CODE.get(jgc, {}).get(jcc, {}).get(code, None) if name == None: _code = f"[{hex(jgc)}({jgc})-{hex(jcc)}({jcc})-{hex(code)}({code})]" _LOGGER.warning( f"{_code} - Unable to resolve the item name. " + f"Please report the unknown code {_code} at the issue tracker on GitHub!" ) if unknown == None: name = f"({code})" else: name = unknown return name def polling_update_debug_log(values: dict[int, Any], conn_instance: ECHONETConnector): eojgc = conn_instance._eojgc eojcc = conn_instance._eojcc debug_log = "\nECHONETlite polling update data:\n" for value in list(values.keys()): name = conn_instance._enl_op_codes.get(value, {}).get(CONF_NAME) debug_log = ( debug_log + f" - {get_name_by_epc_code(eojgc, eojcc, value, None, name)} {value:#x}({value}): {values[value]}\n" ) return debug_log def get_unit_by_devise_class(device_class: str) -> str | None: if ( device_class == SensorDeviceClass.TEMPERATURE or device_class == NumberDeviceClass.TEMPERATURE ): unit = UnitOfTemperature.CELSIUS elif ( device_class == SensorDeviceClass.ENERGY or device_class == NumberDeviceClass.ENERGY ): unit = UnitOfEnergy.WATT_HOUR elif ( device_class == SensorDeviceClass.POWER or device_class == NumberDeviceClass.POWER ): unit = UnitOfPower.WATT elif ( device_class == SensorDeviceClass.CURRENT or device_class == NumberDeviceClass.CURRENT ): unit = UnitOfElectricCurrent.AMPERE elif ( device_class == SensorDeviceClass.VOLTAGE or device_class == NumberDeviceClass.VOLTAGE ): unit = UnitOfElectricPotential.VOLT elif ( device_class == SensorDeviceClass.HUMIDITY or device_class == SensorDeviceClass.BATTERY or device_class == NumberDeviceClass.HUMIDITY or device_class == NumberDeviceClass.BATTERY ): unit = PERCENTAGE elif device_class == SensorDeviceClass.GAS or device_class == NumberDeviceClass.GAS: unit = UnitOfVolume.CUBIC_METERS elif ( device_class == SensorDeviceClass.WATER or device_class == NumberDeviceClass.WATER ): unit = UnitOfVolume.CUBIC_METERS else: unit = None return unit def regist_as_inputs(epc_function_data): if epc_function_data: if type(epc_function_data) == list: if type(epc_function_data[1]) == dict and len(epc_function_data[1]) > 1: return True # Switch or Select if callable(epc_function_data[0]) and epc_function_data[0] == _hh_mm: return True # Time elif callable(epc_function_data) and epc_function_data == _hh_mm: return True # Time return False def regist_as_binary_sensor(epc_function_data): if epc_function_data: if type(epc_function_data) == list: if epc_function_data[1] in ( DICT_41_ON_OFF, DICT_30_TRUE_FALSE, DICT_30_ON_OFF, DICT_30_OPEN_CLOSED, ): return True return False async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(update_listener)) started = pytime.monotonic() host = None udp = None server = None async def discover_callback(host): await async_discover_newhost(hass, host) def unload_config_entry(): if server != None: _LOGGER.debug( f"Called unload_config_entry() try to remove {host} from server._state." ) if server._state.get(host): server._state.pop(host) # Remove update callback function for _key in list(server._update_callbacks.keys()): if _key.startswith(host): del server._update_callbacks[_key] entry.async_on_unload(unload_config_entry) if DOMAIN in hass.data: # maybe set up by config entry? _LOGGER.debug("ECHONETlite platform is already started.") server = hass.data[DOMAIN]["api"] hass.data[DOMAIN].update({entry.entry_id: []}) else: # setup API _LOGGER.debug("Starting up ECHONETlite platform..") _LOGGER.debug(f"pychonet version is {VERSION}") hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN].update({entry.entry_id: []}) udp = UDPServer() udp.run("0.0.0.0", 3610, loop=hass.loop) server = ECHONETAPIClient(udp) server._debug_flag = True server._logger = _LOGGER.debug server._message_timeout = 300 server._discover_callback = discover_callback hass.data[DOMAIN].update({"api": server}) if not entry.pref_disable_new_entities: host = ( entry.data["host"] if "host" in entry.data else entry.data["instances"][0]["host"] ) # make sure multicast is registered with the local IP used to reach this host server._server.register_multicast_from_host(host) try: remaining = _remaining_setup_budget(started) if remaining < DISCOVERY_MIN_BUDGET: raise ConfigEntryNotReady( f"Not enough setup time left for ECHONET Lite discovery on {host}" ) discovery_budget = min(remaining, DISCOVERY_MAX_BUDGET) instances = await _run_with_timeout( enumerate_instances(hass, host), discovery_budget, ) except ErrorConnect as ex: raise ConfigEntryNotReady( f"Connection error while connecting to {host}: {ex}" ) from ex except (TimeoutError, asyncio.TimeoutError) as ex: raise ConfigEntryNotReady( f"ECHONET Lite discovery timed out for {host}" ) from ex except asyncio.CancelledError: raise # Maintain old entity configuration types to avoid duplicate creation of new entities _registed_instances = {} for _instance in entry.data["instances"]: _uidi = f"{_instance['uid']}-{_instance['eojgc']}-{_instance['eojcc']}-{_instance['eojci']}" _registed_instances[_uidi] = _instance for _instance in instances: _uidi = _instance["uidi"] _registed = _registed_instances.get(_uidi) if _registed and _registed.get("uidi") == None: # keep old type config (echonetlite < 3.6.0) del _instance["uidi"] hass.config_entries.async_update_entry( entry, title=entry.title, data={"host": host, "instances": instances} ) for instance in entry.data["instances"]: # auto update to new style if "ntfmap" not in instance: instance["ntfmap"] = [] echonetlite = None host = instance["host"] eojgc = instance["eojgc"] eojcc = instance["eojcc"] eojci = instance["eojci"] ntfmap = instance["ntfmap"] getmap = instance["getmap"] setmap = instance["setmap"] uid = instance["uid"] # manually update API states using config entry data if host not in list(server._state): server._state[host] = { "instances": { eojgc: { eojcc: { eojci: { ENL_STATMAP: ntfmap, ENL_SETMAP: setmap, ENL_GETMAP: getmap, ENL_UID: uid, } } } } } if eojgc not in list(server._state[host]["instances"]): server._state[host]["instances"].update( { eojgc: { eojcc: { eojci: { ENL_STATMAP: ntfmap, ENL_SETMAP: setmap, ENL_GETMAP: getmap, ENL_UID: uid, } } } } ) if eojcc not in list(server._state[host]["instances"][eojgc]): server._state[host]["instances"][eojgc].update( { eojcc: { eojci: { ENL_STATMAP: ntfmap, ENL_SETMAP: setmap, ENL_GETMAP: getmap, ENL_UID: uid, } } } ) if eojci not in list(server._state[host]["instances"][eojgc][eojcc]): server._state[host]["instances"][eojgc][eojcc].update( { eojci: { ENL_STATMAP: ntfmap, ENL_SETMAP: setmap, ENL_GETMAP: getmap, ENL_UID: uid, } } ) echonetlite = ECHONETConnector(instance, hass, entry) await echonetlite.startup() try: # Since there is a small chance of failure, perform a few retries for each instance. for retry in range(1, 4): remaining = _remaining_setup_budget(started) if remaining < INSTANCE_MIN_BUDGET: raise ConfigEntryNotReady( f"Not enough setup time left to initialize ECHONET Lite instances for {host}" ) per_try_budget = min(remaining, INSTANCE_MAX_BUDGET) try: await _run_with_timeout( echonetlite.async_update(), per_try_budget, ) hass.data[DOMAIN][entry.entry_id].append( {"instance": instance, "echonetlite": echonetlite} ) break except (TimeoutError, asyncio.TimeoutError) as ex: _LOGGER.debug( "Setting up ECHONET instance %s-%s-%s on %s timed out " "(retry %s/3, remaining %.1fs)", eojgc, eojcc, eojci, host, retry, _remaining_setup_budget(started), ) if retry == 3: raise ConfigEntryNotReady( f"Initial update timed out for {host}" ) from ex await asyncio.sleep(INSTANCE_RETRY_DELAY) except asyncio.CancelledError: raise except KeyError as ex: raise ConfigEntryNotReady( f"IP address change was detected during setup of {host}" ) from ex _LOGGER.debug(f"Plaform entry data - {entry.data}") await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok # TODO update for Air Cleaner async def update_listener(hass, entry): for instance in hass.data[DOMAIN][entry.entry_id]: if instance["instance"]["eojgc"] == 1 and instance["instance"]["eojcc"] == 48: for option in USER_OPTIONS.keys(): if ( entry.options.get(USER_OPTIONS[option]["option"]) is not None ): # check if options has been created if isinstance( entry.options.get(USER_OPTIONS[option]["option"]), list ): if ( len(entry.options.get(USER_OPTIONS[option]["option"])) > 0 ): # if it has been created then check list length. instance["echonetlite"]._user_options.update( { option: entry.options.get( USER_OPTIONS[option]["option"] ) } ) else: instance["echonetlite"]._user_options.update( {option: False} ) else: instance["echonetlite"]._user_options.update( {option: entry.options.get(USER_OPTIONS[option]["option"])} ) for option in TEMP_OPTIONS.keys(): if entry.options.get(option) is not None: instance["echonetlite"]._user_options.update( {option: entry.options.get(option)} ) for key, option in MISC_OPTIONS.items(): if entry.options.get(key) is not None or option.get("default"): instance["echonetlite"]._user_options.update( {key: entry.options.get(key, option.get("default"))} ) _need_reload = False for func in instance["echonetlite"]._update_option_func: _need_reload |= bool(func()) if _need_reload: return await hass.config_entries.async_reload(entry.entry_id) else: return None async def get_echonet_connector(): return class ECHONETConnector: """EchonetAPIConnector is used to centralise API calls for Echonet devices. API calls are aggregated per instance (not per node!)""" def __init__(self, instance, hass, entry): self.hass = hass self._host = instance["host"] self._eojgc = instance["eojgc"] self._eojcc = instance["eojcc"] self._eojci = instance["eojci"] self._update_flag_batches = [] self._update_data = {} self._api = hass.data[DOMAIN]["api"] self._update_callbacks = [] self._update_option_func = [] self._update_flags_full_list = [] self._ntfPropertyMap = instance["ntfmap"] self._getPropertyMap = instance["getmap"] self._setPropertyMap = instance["setmap"] self._manufacturer = None self._host_product_code = None self._enl_op_codes = ENL_OP_CODES.get(self._eojgc, {}).get(self._eojcc, {}) if "manufacturer" in instance: self._manufacturer = instance["manufacturer"] if "host_product_code" in instance: self._host_product_code = instance["host_product_code"] self._uid = instance.get("uid") self._uidi = instance.get("uidi") self._name = instance.get("name") self._api.register_async_update_callbacks( self._host, self._eojgc, self._eojcc, self._eojci, self.async_update_callback, ) self._entry = entry self._instance = echonet.Factory( self._host, self._api, self._eojgc, self._eojcc, self._eojci ) async def startup(self): entry = self._entry _LOGGER.debug( f"Starting ECHONETLite {self._instance.__class__.__name__} instance for {self._eojgc}-{self._eojcc}-{self._eojci}, manufacturer: {self._manufacturer}, host_product_code: {self._host_product_code} at {self._host}" ) # Check Check the definition of quirk await self._load_quirk() # TODO this looks messy. self._user_options = { ENL_FANSPEED: False, ENL_AUTO_DIRECTION: False, ENL_SWING_MODE: False, ENL_AIR_VERT: False, ENL_AIR_HORZ: False, "min_temp_heat": 15, "max_temp_heat": 35, "min_temp_cool": 15, "max_temp_cool": 35, "min_temp_auto": 15, "max_temp_auto": 35, } # User selectable options for fan + swing modes for HVAC for option in USER_OPTIONS.keys(): if ( entry.options.get(USER_OPTIONS[option]["option"]) is not None ): # check if options has been created if ( len(entry.options.get(USER_OPTIONS[option]["option"])) > 0 ): # if it has been created then check list length. self._user_options[option] = entry.options.get( USER_OPTIONS[option]["option"] ) # Temperature range options for heat, cool and auto modes for option in TEMP_OPTIONS.keys(): if entry.options.get(option) is not None: self._user_options[option] = entry.options.get(option) # Misc options for key, option in MISC_OPTIONS.items(): if entry.options.get(key) is not None: self._user_options[key] = entry.options.get(key, option.get("default")) # Make _update_flags_full_list self._make_update_flags_full_list() self._update_option_func.append(self._make_update_flags_full_list) # Make batch request flags self._make_batch_request_flags() self._update_option_func.append(self._make_batch_request_flags) _LOGGER.debug(f"UID for ECHONETLite instance at {self._host} is {self._uid}.") if self._uid is None: self._uid = f"{self._host}-{self._eojgc}-{self._eojcc}-{self._eojci}" @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self, **kwargs): try: await self.async_update_data(kwargs) except EchonetMaxOpcError as ex: # Adjust the maximum number of properties for batch requests batch_size_max = self._user_options.get( CONF_BATCH_SIZE_MAX, MAX_UPDATE_BATCH_SIZE ) batch_data_len = max( ex.args[0], MIN_UPDATE_BATCH_SIZE, batch_size_max - 1, ) if batch_data_len >= batch_size_max: _LOGGER.error( f"The integration has adjusted the number of batch requests to {self._host} to {batch_size_max}, but no response is received. Please check and try restarting the device etc." ) return None self._user_options[CONF_BATCH_SIZE_MAX] = batch_data_len entry_options = dict(self._entry.options) entry_options.update({CONF_BATCH_SIZE_MAX: batch_data_len}) self.hass.config_entries.async_update_entry( self._entry, options=entry_options ) self._make_batch_request_flags() await self.async_update(**kwargs) async def async_update_data(self, kwargs): update_data = {} no_request = "no_request" in kwargs and kwargs["no_request"] for i, flags in enumerate(self._update_flag_batches): if i > 0: # Interval 100ms to next request await asyncio.sleep(0.1) batch_data = await self._instance.update(flags, no_request) if batch_data is not False: if len(flags) == 1: update_data[flags[0]] = batch_data elif isinstance(batch_data, dict): update_data.update(batch_data) _LOGGER.debug(polling_update_debug_log(update_data, self)) if len(update_data) > 0: self._update_data.update(update_data) async def async_update_callback(self, isPush: bool = False): await self.async_update_data(kwargs={"no_request": True}) for update_func in self._update_callbacks: await update_func(isPush) def _make_update_flags_full_list(self): _prev_update_flags_full_list = self._update_flags_full_list.copy() # Make EPC codes for update self._update_flags_full_list = [] # Is enabled CONF_ENABLE_SUPER_ENERGY _enabled_super_energy = self._user_options.get( CONF_ENABLE_SUPER_ENERGY, ENABLE_SUPER_ENERGY_DEFAULT.get(self._eojgc, {}).get(self._eojcc, True), ) # General purpose data items flags = [] # PR 246 if _enabled_super_energy: _enl_super_codes = ENL_SUPER_CODES else: _enl_super_codes = { k: v for k, v in ENL_SUPER_CODES.items() if k not in ENL_SUPER_ENERGES } flags += list(_enl_super_codes) # Get supported EPC_FUNCTIONS in pychonet object class _epc_keys = set(self._instance.EPC_FUNCTIONS.keys()) - set(EPC_SUPER.keys()) for item in self._getPropertyMap: if item in _epc_keys: flags.append(item) for value in flags: if value in self._getPropertyMap: self._update_flags_full_list.append(value) self._update_data[value] = None _LOGGER.debug( f"Echonet device {self._host}-{self._eojgc}-{self._eojcc}-{self._eojci} update_flags_full_list: {self._update_flags_full_list}" ) return _prev_update_flags_full_list != self._update_flags_full_list def _make_batch_request_flags(self): # Split list of codes into batches of 10 self._update_flag_batches = [] start_index = 0 full_list_length = len(self._update_flags_full_list) # Make batch request flags batch_size_max = self._user_options.get( CONF_BATCH_SIZE_MAX, MAX_UPDATE_BATCH_SIZE ) while start_index + batch_size_max < full_list_length: self._update_flag_batches.append( self._update_flags_full_list[start_index : start_index + batch_size_max] ) start_index += batch_size_max self._update_flag_batches.append( self._update_flags_full_list[start_index:full_list_length] ) _LOGGER.debug( f"Echonet device {self._host}-{self._eojgc}-{self._eojcc}-{self._eojci} batch request flags list: {self._update_flag_batches}" ) def register_async_update_callbacks(self, update_func): self._update_callbacks.append(update_func) def add_update_option_listener(self, update_func): self._update_option_func.append(update_func) async def _load_quirk(self): # self._manufacturer, self._host_product_code, self._eojgc, self._eojcc def update(extention): for epc in extention.QUIRKS: if func := extention.QUIRKS[epc].get("EPC_FUNCTION"): self._instance.EPC_FUNCTIONS.update({epc: func}) if op_code := extention.QUIRKS[epc].get("ENL_OP_CODE"): self._enl_op_codes.update({epc: op_code}) _LOGGER.debug(f"Echonet EPC_FUNCTIONS is: {self._instance.EPC_FUNCTIONS}") _LOGGER.debug(f"Echonet _enl_op_codes is: {self._enl_op_codes}") if self._manufacturer: check = [ "quirks", self._manufacturer, "all", "{:0>2X}".format(self._eojgc) + "{:0>2X}".format(self._eojcc), ] path = os.path.dirname(__file__) + "/" + "/".join(check) + ".py" _LOGGER.debug(f"Echonet _load_quirk check path is: {path}") if os.path.isfile(path): mod = "." + ".".join(check) _LOGGER.debug(f"Echonet import module is: {mod} of {__package__}") extention = await self.hass.async_add_executor_job( partial(import_module, mod, package=__package__) ) update(extention) if self._host_product_code: check = [ "quirks", self._manufacturer, self._host_product_code, "{:0>2X}".format(self._eojgc) + "{:0>2X}".format(self._eojcc), ] path = os.path.dirname(__file__) + "/" + "/".join(check) + ".py" _LOGGER.debug(f"Echonet _load_quirk check path is: {path}") if os.path.isfile(path): mod = "." + ".".join(check) _LOGGER.debug(f"Echonet import module is: {mod} of {__package__}") extention = await self.hass.async_add_executor_job( partial(import_module, mod, package=__package__) ) update(extention) ================================================ FILE: custom_components/echonetlite/binary_sensor.py ================================================ """Support for ECHONETLite sensors.""" import logging import voluptuous as vol from homeassistant.const import ( CONF_ICON, CONF_NAME, CONF_SERVICE, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.exceptions import InvalidStateError, NoEntitySpecifiedError from pychonet.lib.eojx import EOJX_CLASS from pychonet.lib.epc_functions import ( DATA_STATE_OFF, DATA_STATE_ON, DATA_STATE_CLOSE, DATA_STATE_OPEN, EPC_SUPER_FUNCTIONS, ) from . import ( get_name_by_epc_code, get_unit_by_devise_class, get_device_name, regist_as_inputs, regist_as_binary_sensor, ) from .const import ( DOMAIN, ENABLE_SUPER_ENERGY_DEFAULT, ENL_OP_CODES, CONF_STATE_CLASS, ENL_SUPER_CODES, ENL_SUPER_ENERGES, NON_SETUP_SINGLE_ENYITY, TYPE_SWITCH, TYPE_SELECT, TYPE_TIME, TYPE_NUMBER, SERVICE_SET_ON_TIMER_TIME, SERVICE_SET_INT_1B, CONF_FORCE_POLLING, CONF_ENABLE_SUPER_ENERGY, TYPE_DATA_DICT, TYPE_DATA_ARRAY_WITH_SIZE_OPCODE, CONF_DISABLED_DEFAULT, CONF_MULTIPLIER, CONF_MULTIPLIER_OPCODE, CONF_MULTIPLIER_OPTIONAL_OPCODE, CONF_ICON_POSITIVE, CONF_ICON_NEGATIVE, CONF_ICON_ZERO, ) _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config, async_add_entities, discovery_info=None): entities = [] platform = entity_platform.async_get_current_platform() for entity in hass.data[DOMAIN][config.entry_id]: _LOGGER.debug(f"Configuring ECHONETLite sensor {entity}") _LOGGER.debug( f"Update flags for this sensor are {entity['echonetlite']._update_flags_full_list}" ) eojgc = entity["instance"]["eojgc"] eojcc = entity["instance"]["eojcc"] if entity["echonetlite"]._user_options.get( CONF_ENABLE_SUPER_ENERGY, ENABLE_SUPER_ENERGY_DEFAULT.get(eojgc, {}).get(eojcc, True), ): _enl_super_codes = ENL_SUPER_CODES else: _enl_super_codes = { k: v for k, v in ENL_SUPER_CODES.items() if not k in ENL_SUPER_ENERGES } _enl_op_codes = entity["echonetlite"]._enl_op_codes | _enl_super_codes _epc_functions = ( entity["echonetlite"]._instance.EPC_FUNCTIONS | EPC_SUPER_FUNCTIONS ) # For all other devices, sensors will be configured but customise if applicable. for op_code in list( set(entity["echonetlite"]._update_flags_full_list) - NON_SETUP_SINGLE_ENYITY.get(eojgc, {}).get(eojcc, set()) ): # Check DeviceClass and regist_as_binary_sensor() if not isinstance( _enl_op_codes.get(op_code, {}).get(CONF_TYPE), BinarySensorDeviceClass ) and not regist_as_binary_sensor(_epc_functions.get(op_code, None)): continue # Is settable _is_settable = op_code in entity["instance"]["setmap"] # Conf Keys list _keys = _enl_op_codes.get(op_code, {}).keys() # For backward compatibility (Deprecated) _has_conf_service = CONF_SERVICE in _keys # Check this op_code will be configured as input(switch, select ot time) entity if ( _is_settable and not _has_conf_service and regist_as_inputs(_epc_functions.get(op_code, None)) ): continue # Configuration check with ENL_OP_CODE definition if len(_keys): if ( _is_settable and not _has_conf_service and ( TYPE_SWITCH in _keys or TYPE_SELECT in _keys or TYPE_TIME in _keys or TYPE_NUMBER in _keys ) ): continue # dont configure as sensor, it will be configured as switch, select or time instead. # For backward compatibility (Deprecated) if ( _is_settable and _has_conf_service ): # Some devices support advanced service calls. _enl_op_codes[op_code][CONF_DISABLED_DEFAULT] = True for service_name in _enl_op_codes.get(op_code, {}).get( CONF_SERVICE ): if service_name == SERVICE_SET_ON_TIMER_TIME: platform.async_register_entity_service( service_name, {vol.Required("timer_time"): cv.time_period}, "async_" + service_name, ) elif service_name == SERVICE_SET_INT_1B: platform.async_register_entity_service( service_name, { vol.Required("value"): cv.positive_int, vol.Optional( "epc", default=op_code ): cv.positive_int, }, "async_" + service_name, ) if TYPE_DATA_DICT in _keys: type_data = _enl_op_codes.get(op_code, {}).get(TYPE_DATA_DICT) if isinstance(type_data, list): for attr_key in type_data: entities.append( EchonetBinarySensor( entity["echonetlite"], config, op_code, _enl_op_codes.get(op_code) | {"dict_key": attr_key}, hass, ) ) continue else: continue if TYPE_DATA_ARRAY_WITH_SIZE_OPCODE in _keys: array_size_op_code = _enl_op_codes[op_code][ TYPE_DATA_ARRAY_WITH_SIZE_OPCODE ] array_max_size = await entity["echonetlite"]._instance.update( array_size_op_code ) for x in range(0, array_max_size): attr = _enl_op_codes[op_code].copy() attr["accessor_index"] = x attr["accessor_lambda"] = lambda value, index: ( value["values"][index] if index < value["range"] else None ) entities.append( EchonetBinarySensor( entity["echonetlite"], config, op_code, attr, ) ) continue else: entities.append( EchonetBinarySensor( entity["echonetlite"], config, op_code, _enl_op_codes.get( op_code, ENL_OP_CODES["default"] | {CONF_DISABLED_DEFAULT: True}, ), hass, ) ) continue entities.append( EchonetBinarySensor( entity["echonetlite"], config, op_code, ENL_OP_CODES["default"], hass, ) ) async_add_entities(entities, True) class EchonetBinarySensor(BinarySensorEntity): """Representation of an ECHONETLite Temperature Sensor.""" _attr_translation_key = DOMAIN def __init__(self, connector, config, op_code, attributes, hass=None) -> None: """Initialize the sensor.""" name = get_device_name(connector, config) self._connector = connector self._op_code = op_code self._sensor_attributes = attributes self._eojgc = self._connector._eojgc self._eojcc = self._connector._eojcc self._eojci = self._connector._eojci self._attr_unique_id = ( f"{self._connector._uidi}-{self._op_code}" if self._connector._uidi else f"{self._connector._uid}-{self._eojgc}-{self._eojcc}-{self._eojci}-{self._op_code}" ) self._device_name = name self._state_value = None self._server_state = self._connector._api._state[ self._connector._instance._host ] self._hass = hass _attr_keys = self._sensor_attributes.keys() self._attr_icon = self._sensor_attributes.get(CONF_ICON) self._attr_device_class = self._sensor_attributes.get(CONF_TYPE) self._attr_state_class = self._sensor_attributes.get(CONF_STATE_CLASS) # Create name based on sensor description from EPC codes, super class codes or fallback to using the sensor type self._attr_name = f"{name} {get_name_by_epc_code(self._eojgc, self._eojcc, self._op_code, self._attr_device_class, self._connector._enl_op_codes.get(self._op_code, {}).get(CONF_NAME))}" if "dict_key" in _attr_keys: self._attr_unique_id += f'-{self._sensor_attributes["dict_key"]}' if type(self._sensor_attributes[TYPE_DATA_DICT]) == int: # As of Version 3.8.0, no configuration is defined that uses this definition. self._attr_name += f' {str(self._sensor_attributes["accessor_index"] + 1).zfill(len(str(self._sensor_attributes[TYPE_DATA_DICT])))}' else: self._attr_name += f' {self._sensor_attributes["dict_key"]}' if "accessor_index" in _attr_keys: self._attr_unique_id += f'-{self._sensor_attributes["accessor_index"]}' self._attr_name += f' {str(self._sensor_attributes["accessor_index"] + 1).zfill(len(str(self._sensor_attributes[TYPE_DATA_ARRAY_WITH_SIZE_OPCODE])))}' self._attr_entity_registry_enabled_default = not bool( self._sensor_attributes.get(CONF_DISABLED_DEFAULT) ) self._attr_should_poll = True self._attr_available = True self.update_option_listener() @property def device_info(self): return { "identifiers": { ( DOMAIN, self._connector._uid, self._connector._eojgc, self._connector._eojcc, self._connector._eojci, ) }, "name": self._device_name, "manufacturer": self._connector._manufacturer + ( " " + self._connector._host_product_code if self._connector._host_product_code else "" ), "model": EOJX_CLASS[self._eojgc][self._eojcc], # "sw_version": "", } def get_attr_is_on(self): """Return the state of the sensor.""" if self._op_code in self._connector._update_data: new_val = self._connector._update_data[self._op_code] if "dict_key" in self._sensor_attributes: if hasattr(new_val, "get"): self._state_value = new_val.get(self._sensor_attributes["dict_key"]) else: self._state_value = None elif "accessor_lambda" in self._sensor_attributes: self._state_value = self._sensor_attributes["accessor_lambda"]( new_val, self._sensor_attributes["accessor_index"] ) else: self._state_value = new_val if self._state_value is None: return None _results = { True: True, "1": True, DATA_STATE_ON: True, DATA_STATE_OPEN: True, "yes": True, False: False, "0": False, DATA_STATE_OFF: False, DATA_STATE_CLOSE: False, "no": False, } return _results.get(self._state_value) async def async_update(self): """Retrieve latest state.""" try: await self._connector.async_update() self._attr_is_on = self.get_attr_is_on() except TimeoutError: pass async def async_added_to_hass(self): """Register callbacks.""" self._connector.add_update_option_listener(self.update_option_listener) self._connector.register_async_update_callbacks(self.async_update_callback) async def async_update_callback(self, isPush: bool = False): new_val = self._connector._update_data.get(self._op_code) if "dict_key" in self._sensor_attributes: if hasattr(new_val, "get"): new_val = new_val.get(self._sensor_attributes["dict_key"]) else: new_val = None if "accessor_lambda" in self._sensor_attributes: new_val = self._sensor_attributes["accessor_lambda"]( new_val, self._sensor_attributes["accessor_index"] ) changed = ( new_val is not None and self._state_value != new_val ) or self._attr_available != self._server_state["available"] if changed: _force = bool(not self._attr_available and self._server_state["available"]) self._state_value = new_val self._attr_is_on = self.get_attr_is_on() if self._attr_available != self._server_state["available"]: if self._server_state["available"]: self.update_option_listener() else: self._attr_should_poll = True self._attr_available = self._server_state["available"] self.async_schedule_update_ha_state(_force) def update_option_listener(self): _should_poll = self._op_code not in self._connector._ntfPropertyMap self._attr_should_poll = ( self._connector._user_options.get(CONF_FORCE_POLLING, False) or _should_poll ) self._attr_extra_state_attributes = {"notify": "No" if _should_poll else "Yes"} _LOGGER.debug( f"{self._attr_name}({self._op_code}): _should_poll is {_should_poll}" ) ================================================ FILE: custom_components/echonetlite/climate.py ================================================ import logging import math import voluptuous as vol from homeassistant.components.climate import ( ClimateEntity, ) from homeassistant.components.climate.const import ( ATTR_HVAC_MODE, ClimateEntityFeature, HVACAction, HVACMode, ) from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature, ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers import entity_platform from pychonet.HomeAirConditioner import ( AIRFLOW_VERT, ENL_AIR_VERT, ENL_AUTO_DIRECTION, ENL_FANSPEED, ENL_HVAC_MODE, ENL_HVAC_ROOM_TEMP, ENL_HVAC_SET_HUMIDITY, ENL_HVAC_SET_TEMP, ENL_HVAC_SILENT_MODE, ENL_STATUS, ENL_SWING_MODE, FAN_SPEED, SILENT_MODE, ) from pychonet.lib.eojx import EOJX_CLASS from . import get_device_name from .const import DATA_STATE_ON, DOMAIN, OPTION_HA_UI_SWING _LOGGER = logging.getLogger(__name__) DEFAULT_FAN_MODES = list( FAN_SPEED.keys() ) # ["auto","minimum","low","medium-low","medium","medium-high","high","very-high","max"] DEFAULT_HVAC_MODES = [ HVACMode.HEAT, HVACMode.COOL, HVACMode.DRY, HVACMode.FAN_ONLY, HVACMode.HEAT_COOL, HVACMode.OFF, ] DEFAULT_SWING_MODES = ["auto-vert"] + list( AIRFLOW_VERT.keys() ) # ["auto-vert","upper","upper-central","central","lower-central","lower"] DEFAULT_PRESET_MODES = list(SILENT_MODE.keys()) # ["normal", "high-speed", "silent"] SERVICE_SET_HUMIDIFER_DURING_HEATER = "set_humidifier_during_heater" ATTR_STATE = "state" ATTR_HUMIDITY = "humidity" async def async_setup_entry(hass, config_entry, async_add_devices): """Set up entry.""" entities = [] for entity in hass.data[DOMAIN][config_entry.entry_id]: if ( entity["instance"]["eojgc"] == 0x01 and entity["instance"]["eojcc"] == 0x30 ): # Home Air Conditioner entities.append(EchonetClimate(entity["echonetlite"], config_entry)) async_add_devices(entities, True) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_SET_HUMIDIFER_DURING_HEATER, { vol.Required(ATTR_STATE): cv.boolean, vol.Required(ATTR_HUMIDITY): cv.byte, }, "async_set_humidifier_during_heater", ) class EchonetClimate(ClimateEntity): """Representation of an ECHONETLite climate device.""" _attr_translation_key = DOMAIN def __init__(self, connector, config): """Initialize the climate device.""" name = get_device_name(connector, config) self._attr_name = name self._device_name = name self._connector = connector # new line self._attr_unique_id = ( self._connector._uidi if self._connector._uidi else self._connector._uid ) # The temperature unit of echonet lite is defined as Celsius. # Set temperature_unit setting to Celsius, # HA's automatic temperature unit conversion function works correctly. self._attr_temperature_unit = UnitOfTemperature.CELSIUS self._attr_precision = PRECISION_WHOLE self._attr_target_temperature_step = 1 if hasattr(ClimateEntityFeature, "TURN_ON"): self._attr_supported_features = ClimateEntityFeature( ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF ) else: self._attr_supported_features = ClimateEntityFeature(0) self._attr_supported_features = ( self._attr_supported_features | ClimateEntityFeature.TARGET_TEMPERATURE ) self._server_state = self._connector._api._state[ self._connector._instance._host ] self._opc_data = { ENL_AUTO_DIRECTION: list( self._connector._instance.EPC_FUNCTIONS[ENL_AUTO_DIRECTION][1].values() ), ENL_SWING_MODE: list( self._connector._instance.EPC_FUNCTIONS[ENL_SWING_MODE][1].values() ), } if ENL_FANSPEED in list(self._connector._setPropertyMap): self._attr_supported_features = ( self._attr_supported_features | ClimateEntityFeature.FAN_MODE ) if ENL_AIR_VERT in list( self._connector._setPropertyMap ) or ENL_SWING_MODE in list(self._connector._setPropertyMap): self._attr_supported_features = ( self._attr_supported_features | ClimateEntityFeature.SWING_MODE ) if ENL_HVAC_SILENT_MODE in list(self._connector._setPropertyMap): self._attr_supported_features = ( self._attr_supported_features | ClimateEntityFeature.PRESET_MODE ) self._attr_hvac_modes = DEFAULT_HVAC_MODES self._attr_preset_modes = DEFAULT_PRESET_MODES self._olddata = {} self._last_mode = HVACMode.OFF self._attr_should_poll = True self._attr_available = True self.update_option_listener() self._set_attrs() # see, https://developers.home-assistant.io/blog/2024/01/24/climate-climateentityfeatures-expanded self._enable_turn_on_off_backwards_compatibility = False async def async_update(self): """Get the latest state from the HVAC.""" try: await self._connector.async_update() except TimeoutError: pass @property def device_info(self): return { "identifiers": { ( DOMAIN, self._connector._uid, self._connector._instance._eojgc, self._connector._instance._eojcc, self._connector._instance._eojci, ) }, "name": self._device_name, "manufacturer": self._connector._manufacturer + ( " " + self._connector._host_product_code if self._connector._host_product_code else "" ), "model": EOJX_CLASS[self._connector._instance._eojgc][ self._connector._instance._eojcc ], # "sw_version": "", } def _set_min_max_temp(self): self._attr_min_temp = self._connector._user_options["min_temp_auto"] self._attr_max_temp = self._connector._user_options["max_temp_auto"] if hasattr(self, "_attr_hvac_mode"): """minimum/maximum temperature supported by the HVAC.""" if self._attr_hvac_mode == HVACMode.HEAT: self._attr_min_temp = self._connector._user_options["min_temp_heat"] self._attr_max_temp = self._connector._user_options["max_temp_heat"] elif self._attr_hvac_mode == HVACMode.COOL: self._attr_min_temp = self._connector._user_options["min_temp_cool"] self._attr_max_temp = self._connector._user_options["max_temp_cool"] def _set_attrs(self): """current temperature.""" _val = self._connector._update_data.get(ENL_HVAC_ROOM_TEMP) # 0x7F: Overflow, 0x80: Underflow, 0x7E:Value cannot be returned if _val in {0x7F, 0x80, 0x7E}: _val = None self._attr_current_temperature = _val """temperature we try to reach.""" _val = self._connector._update_data.get(ENL_HVAC_SET_TEMP) # -3: Rule of thumb, 0xFD: Temperature indeterminable if _val in {-3, 0xFD}: _val = None self._attr_target_temperature = _val """temperature we try to reach.""" self._attr_target_humidity = self._connector._update_data.get( ENL_HVAC_SET_HUMIDITY ) """current operation ie. heat, cool, idle.""" _val = self._connector._update_data.get(ENL_HVAC_MODE) self._attr_hvac_mode = HVACMode.OFF if self._connector._update_data[ENL_STATUS] == DATA_STATE_ON: if _val == "auto": self._attr_hvac_mode = HVACMode.HEAT_COOL elif _val == "other": if self._connector._user_options.get(ENL_HVAC_MODE) == "as_idle": self._attr_hvac_mode = self._last_mode else: self._attr_hvac_mode = HVACMode.OFF else: self._attr_hvac_mode = _val if self._attr_hvac_mode != HVACMode.OFF: self._last_mode = self._attr_hvac_mode """current operation ie. heat, cool, idle.""" self._attr_hvac_action = HVACAction.OFF if self._connector._update_data[ENL_STATUS] == DATA_STATE_ON: if self._connector._update_data[ENL_HVAC_MODE] == HVACMode.HEAT: self._attr_hvac_action = HVACAction.HEATING elif self._connector._update_data[ENL_HVAC_MODE] == HVACMode.COOL: self._attr_hvac_action = HVACAction.COOLING elif self._connector._update_data[ENL_HVAC_MODE] == HVACMode.DRY: self._attr_hvac_action = HVACAction.DRYING elif self._connector._update_data[ENL_HVAC_MODE] == HVACMode.FAN_ONLY: self._attr_hvac_action = HVACAction.FAN elif ( self._connector._update_data[ENL_HVAC_MODE] == HVACMode.HEAT_COOL or self._connector._update_data[ENL_HVAC_MODE] == "auto" ): _room_temp = self._connector._update_data.get(ENL_HVAC_ROOM_TEMP) if _room_temp := self._connector._update_data.get(ENL_HVAC_ROOM_TEMP): if self._connector._update_data[ENL_HVAC_SET_TEMP] < _room_temp: self._attr_hvac_action = HVACAction.COOLING elif self._connector._update_data[ENL_HVAC_SET_TEMP] > _room_temp: self._attr_hvac_action = HVACAction.HEATING else: self._attr_hvac_action = HVACAction.IDLE elif self._connector._update_data[ENL_HVAC_MODE] == "other": if self._connector._user_options.get(ENL_HVAC_MODE) == "as_idle": self._attr_hvac_action = HVACAction.IDLE else: self._attr_hvac_action = HVACAction.OFF else: _LOGGER.warning( f"Unknown HVAC mode {self._connector._update_data.get(ENL_HVAC_MODE)}" ) self._attr_hvac_action = HVACAction.IDLE """true if the device is on.""" self._attr_is_on = ( True if self._connector._update_data[ENL_STATUS] == DATA_STATE_ON else False ) """fan setting.""" self._attr_fan_mode = ( self._connector._update_data[ENL_FANSPEED] if ENL_FANSPEED in self._connector._update_data else None ) """preset setting.""" self._attr_preset_mode = ( self._connector._update_data[ENL_HVAC_SILENT_MODE] if ENL_HVAC_SILENT_MODE in self._connector._update_data else None ) """swing mode setting.""" if ( self._connector._update_data.get(ENL_AUTO_DIRECTION) in self._attr_swing_modes ): self._attr_swing_mode = self._connector._update_data.get(ENL_AUTO_DIRECTION) elif self._connector._update_data.get(ENL_SWING_MODE) in self._attr_swing_modes: self._attr_swing_mode = self._connector._update_data.get(ENL_SWING_MODE) else: self._attr_swing_mode = ( self._connector._update_data[ENL_AIR_VERT] if ENL_AIR_VERT in self._connector._update_data else None ) self._set_min_max_temp() async def async_set_fan_mode(self, fan_mode): """Set new fan mode.""" _LOGGER.debug(f"Updated fan mode is: {fan_mode}") await self._connector._instance.setFanSpeed(fan_mode) async def async_set_preset_mode(self, preset_mode): """Set new preset mode - This is normal/high-speed/silent""" await self._connector._instance.setSilentMode(preset_mode) async def async_set_swing_mode(self, swing_mode): """Set new swing mode.""" if swing_mode in self._opc_data[ENL_AUTO_DIRECTION]: await self._connector._instance.setAutoDirection(swing_mode) elif swing_mode in self._opc_data[ENL_SWING_MODE]: await self._connector._instance.setSwingMode(swing_mode) else: await self._connector._instance.setAirflowVert(swing_mode) async def async_set_temperature(self, **kwargs): """Set new target temperatures.""" # Check has HVAC Mode hvac_mode = kwargs.get(ATTR_HVAC_MODE) if hvac_mode is not None: await self.async_set_hvac_mode(hvac_mode) settemp = self._normalize_settemp(kwargs.get(ATTR_TEMPERATURE)) if kwargs.get(ATTR_TEMPERATURE) is not None: await self._connector._instance.setOperationalTemperature(settemp) async def async_set_humidity(self, humidity: int) -> None: await self._connector._instance.setOperationalTemperature(humidity) async def async_set_hvac_mode(self, hvac_mode): # _LOGGER.warning(self._connector._update_data) """Set new operation mode (including off)""" if hvac_mode == "heat_cool": await self._connector._instance.setMode("auto") else: await self._connector._instance.setMode(hvac_mode) async def async_turn_on(self): """Turn on.""" await self._connector._instance.on() async def async_turn_off(self): """Turn off.""" await self._connector._instance.off() async def async_set_humidifier_during_heater(self, state, humidity): """Handle boost heating service call.""" await self._connector._instance.setHeaterHumidifier(state, humidity) async def async_added_to_hass(self): """Register callbacks.""" self._connector.add_update_option_listener(self.update_option_listener) self._connector.register_async_update_callbacks(self.async_update_callback) async def async_update_callback(self, isPush: bool = False): changed = ( self._olddata != self._connector._update_data or self._attr_available != self._server_state["available"] ) _LOGGER.debug( f"Called async_update_callback on {self._device_name}.\nChanged: {changed}\nUpdate data: {self._connector._update_data}\nOld data: {self._olddata}" ) if changed: _force = bool(not self._attr_available and self._server_state["available"]) self._olddata = self._connector._update_data.copy() self._attr_available = self._server_state["available"] self._set_attrs() self.async_schedule_update_ha_state(_force | isPush) def update_option_listener(self): """list of available fan modes.""" _modes = self._connector._user_options.get(ENL_FANSPEED) if _modes: self._attr_fan_modes = _modes else: self._attr_fan_modes = DEFAULT_FAN_MODES """list of available swing modes.""" _modes = self._connector._user_options.get(OPTION_HA_UI_SWING) if _modes and len(_modes): self._attr_swing_modes = _modes elif _modes := self._connector._user_options.get(ENL_AIR_VERT): self._attr_swing_modes = _modes else: self._attr_swing_modes = DEFAULT_SWING_MODES self._set_min_max_temp() if self.hass: self.async_schedule_update_ha_state() def _normalize_settemp(self, req: float | int | None) -> int | None: """ Normalize a requested temperature to the 1°C resolution supported by ECHONET Lite HVAC devices. Matter controllers may send fractional values (e.g., 22.5°C). Since most ECHONET air conditioners accept only integer setpoints, this function converts the request to a valid value while preserving user intent: - Integer values are used as-is. - `.5` values are rounded directionally based on the previous target temperature (up when increasing, down when decreasing). - Other fractions are rounded to the nearest integer. """ if req is None: return None res = None if abs(req - round(req)) < 1e-9: res = int(round(req)) else: prev = self._attr_target_temperature frac = req - math.floor(req) if abs(frac - 0.5) < 1e-9 and prev is not None: if req >= prev: res = math.ceil(req) if req < prev: res = math.floor(req) else: res = int(math.floor(req + 0.5)) return res ================================================ FILE: custom_components/echonetlite/config_flow.py ================================================ """Config flow for echonetlite integration.""" from __future__ import annotations import logging import asyncio from typing import Any import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import selector from pychonet.lib.const import ( ENL_STATMAP, ENL_SETMAP, ENL_GETMAP, GET, ) # from aioudp import UDPServer from pychonet.lib.udpserver import UDPServer from pychonet.lib.epc_functions import _null_padded_optional_string # from pychonet import Factory from pychonet import ECHONETAPIClient from pychonet.HomeAirConditioner import ( ENL_AIR_VERT, ENL_AUTO_DIRECTION, ENL_SWING_MODE, ) from .const import ( DOMAIN, USER_OPTIONS, TEMP_OPTIONS, MISC_OPTIONS, ENL_HVAC_MODE, CONF_OTHER_MODE, OPTION_HA_UI_SWING, ) _LOGGER = logging.getLogger(__name__) WORD_OF_AUTO_DISCOVERY = "[Auto Discovery]" # TODO adjust the data schema to the data that you need STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required("host", default=WORD_OF_AUTO_DISCOVERY): str, vol.Required("title", default=WORD_OF_AUTO_DISCOVERY): str, } ) _detected_hosts = {} _init_server = None async def enumerate_instances( hass: HomeAssistant, host: str, newhost: bool = False ) -> list[dict[str, Any]]: """Validate the user input allows us to connect.""" _LOGGER.debug(f"IP address is {host}") server = None if DOMAIN in hass.data: # maybe set up by config entry? _LOGGER.debug("API listener has already been setup previously..") server = hass.data[DOMAIN]["api"] for key in hass.data[DOMAIN]: if key != "api": entries = hass.data[DOMAIN][key] if len(entries): inst = entries[0].get("instance") if inst: if inst.get("host") == host: raise ErrorConnect("already_configured") elif _init_server: _LOGGER.debug("API listener has already been setup in init_discover()") server = _init_server else: udp = UDPServer() udp.run("0.0.0.0", 3610, loop=hass.loop) server = ECHONETAPIClient(server=udp) server._debug_flag = True server._logger = _LOGGER.debug server._message_timeout = 300 # make sure multicast is registered with the local IP used to reach this host server._server.register_multicast_from_host(host) instance_list = [] _LOGGER.debug("Beginning ECHONET node discovery") await server.discover(host) # Timeout after 10 seconds for x in range(0, 1000): await asyncio.sleep(0.01) if "discovered" in list(server._state[host]): _LOGGER.debug("ECHONET Node Discovery Successful!") break if "discovered" not in list(server._state[host]): _LOGGER.debug("ECHONET Node Discovery Failed!") raise ErrorConnect("cannot_connect") state = server._state[host] uid = state["uid"] # check ip addr changed if newhost: config_entry = None old_host = None entries = hass.config_entries.async_entries(DOMAIN) entry = None instances = None for entry in entries: instances = [] _data = entry.data for _instance in _data.get("instances", []): instance = _instance.copy() if old_host or instance.get("uid") == uid: old_host = instance["host"] instance["host"] = host instances.append(instance) if old_host: config_entry = entry _LOGGER.debug( f"ECHONET registed node found uid is {uid}, conig entry id is {entry.entry_id}." ) break if old_host and entry and instances and config_entry: _LOGGER.debug( f"ECHONET registed node IP changed from {old_host} to {host}." ) _LOGGER.debug(f"New instances data is {instances}") if server._state.get(old_host): server._state[host] = server._state.pop(old_host) hass.config_entries.async_update_entry( config_entry, data={"host": host, "instances": instances} ) # Wait max 30 secs for entry loaded for x in range(0, 300): await asyncio.sleep(0.1) if entry.state == ConfigEntryState.LOADED: await hass.config_entries.async_reload(entry.entry_id) break raise ErrorIpChanged(host) manufacturer = state["manufacturer"] host_product_code = state.get("product_code") if type(host_product_code) == str: host_product_code = str.strip(host_product_code) if not isinstance(manufacturer, str): # If unable to resolve the manufacturer, # the raw identification number will be passed as int. _LOGGER.warn( f"{host} - Unable to resolve the manufacturer name - {manufacturer}. " + "Please report the manufacturer name of your device at the issue tracker on GitHub!" ) manufacturer = f"Unknown({manufacturer})" for eojgc in list(state["instances"].keys()): for eojcc in list(state["instances"][eojgc].keys()): for instance in list(state["instances"][eojgc][eojcc].keys()): _LOGGER.debug(f"instance is {instance}") cnt = 0 while ( await server.getAllPropertyMaps(host, eojgc, eojcc, instance) is False ): cnt += 1 if cnt > 2: raise ErrorConnect("cannot_get_property_maps") _LOGGER.debug( f"{host} - ECHONET Instance {eojgc}-{eojcc}-{instance} map attributes discovered!" ) ntfmap = state["instances"][eojgc][eojcc][instance].get(ENL_STATMAP, []) getmap = state["instances"][eojgc][eojcc][instance][ENL_GETMAP] setmap = state["instances"][eojgc][eojcc][instance][ENL_SETMAP] uidi = f"{uid}-{eojgc}-{eojcc}-{instance}" name = None if host_product_code == "WTY2001" and eojcc == 0x91: # Panasonic WTY2001 Advanced Series Link Plus Wireless Adapter await server.echonetMessage( host, eojgc, eojcc, instance, GET, [{"EPC": 0xFD}, {"EPC": 0xFE}], ) # Use Use HW ID because the instance number is uncertain # https://github.com/scottyphillips/echonetlite_homeassistant/issues/117#issuecomment-1929151918 uidi = _null_padded_optional_string( state["instances"][eojgc][eojcc][instance][0xFE] ) name = _null_padded_optional_string( state["instances"][eojgc][eojcc][instance][0xFD] ) instance_list.append( { "host": host, "name": name, "eojgc": eojgc, "eojcc": eojcc, "eojci": instance, "ntfmap": ntfmap, "getmap": getmap, "setmap": setmap, "uid": uid, # Deprecated, for backwards compatibility "uidi": uidi, "manufacturer": manufacturer, "host_product_code": host_product_code, } ) return instance_list async def async_discover_newhost(hass, host): _LOGGER.debug(f"received newip discovery: {host}") if host not in _detected_hosts.keys(): try: instance_list = await enumerate_instances(hass, host, newhost=True) _LOGGER.debug(f"ECHONET Node detected in {host}") except ErrorConnect as e: _LOGGER.debug(f"ECHONET Node Error Connect ({e})") except ErrorIpChanged as e: _LOGGER.debug(f"ECHONET Detected Node IP Changed to '{e}'") else: if len(instance_list): _detected_hosts.update({host: instance_list}) else: _LOGGER.debug(f"ECHONET Node not found in {host}") class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for echonetlite.""" host = None title = "" discover_task = None instance_list = None instances = None VERSION = 1 async def init_discover(self): async def discover_callback(host): await async_discover_newhost(self.hass, host) if DOMAIN in self.hass.data: # maybe set up by config entry? _LOGGER.debug("API listener has already been setup previously..") server = self.hass.data[DOMAIN]["api"] _init_server = None else: udp = UDPServer() udp.run("0.0.0.0", 3610, loop=self.hass.loop) server = ECHONETAPIClient(server=udp) server._debug_flag = True server._logger = _LOGGER.debug server._message_timeout = 300 server._discover_callback = discover_callback _init_server = server await server.discover() # Timeout after 30 seconds for x in range(0, 3000): await asyncio.sleep(0.01) if len(_detected_hosts): _LOGGER.debug("ECHONET Any Node Discovery Successful!") break if _init_server: _init_server._server._sock.close() del _init_server _init_server = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: errors = {} """Handle the initial step.""" scm = STEP_USER_DATA_SCHEMA if user_input is None or user_input.get("host") == WORD_OF_AUTO_DISCOVERY: step = "user_man" if ( user_input and user_input.get("host") == WORD_OF_AUTO_DISCOVERY and not len(_detected_hosts) ): await self.init_discover() if len(_detected_hosts): host = list(_detected_hosts.keys()).pop(0) title = _detected_hosts[host][0]["manufacturer"] if _detected_hosts[host][0]["host_product_code"]: title += " " + _detected_hosts[host][0]["host_product_code"] else: if user_input is None: host = title = WORD_OF_AUTO_DISCOVERY step = "user" else: host = "" title = "" errors["base"] = "not_found" scm = scm.extend( { vol.Required("host", default=host): str, vol.Required("title", default=title): str, } ) return self.async_show_form(step_id=step, data_schema=scm, errors=errors) try: self.instance_list = await enumerate_instances( self.hass, user_input["host"] ) _LOGGER.debug("Node detected") except ErrorConnect as e: errors["base"] = f"{e}" else: self.host = user_input["host"] self.title = user_input["title"] return await self.async_step_finish(user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) async def async_step_user_man( self, user_input: dict[str, Any] | None = None ) -> FlowResult: return await self.async_step_user(user_input) async def async_step_finish(self, user_input=None): if len(_detected_hosts) and self.host in _detected_hosts.keys(): _detected_hosts.pop(self.host) return self.async_create_entry( title=self.title, data={"host": self.host, "instances": self.instance_list}, options={"other_mode": "as_off"}, ) @staticmethod @callback def async_get_options_flow(config_entry): return OptionsFlowHandler(config_entry) class ErrorConnect(HomeAssistantError): """Error to indicate we cannot connect.""" class ErrorIpChanged(HomeAssistantError): """Error to indicate we cannot connect.""" class OptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config): self._config_entry = config self._data = {} async def async_step_init(self, user_input=None): """Manage the options.""" data_schema_structure = {} # Handle HVAC and Air Cleaner User configurable options for instance in self._config_entry.data["instances"]: if ( instance["eojgc"] == 0x01 and instance["eojcc"] == 0x30 ): # HomeAirConditioner ha_swing_list = [] for option in list(USER_OPTIONS.keys()): if option in instance["setmap"]: if option in [ENL_AIR_VERT, ENL_AUTO_DIRECTION, ENL_SWING_MODE]: ha_swing_list.append(option) if ( self._config_entry.options.get( USER_OPTIONS[option]["option"] ) is not None ): option_default = self._config_entry.options.get( USER_OPTIONS[option]["option"] ) else: if isinstance(USER_OPTIONS[option]["option_list"], list): # single select option_default = USER_OPTIONS[option]["option_list"][0][ "value" ] elif isinstance(USER_OPTIONS[option]["option_list"], dict): # multi selectable option_default = list( USER_OPTIONS[option]["option_list"].keys() ) else: option_default = [] data_schema_structure.update( { vol.Optional( USER_OPTIONS[option]["option"], default=option_default, ): cv.multi_select(USER_OPTIONS[option]["option_list"]) } ) # Handle setting Climate entity UI swing mode if len(ha_swing_list) > 0: option_list = {} for opt in ha_swing_list: option_list.update(USER_OPTIONS[opt]["option_list"]) if ENL_AIR_VERT in instance["setmap"]: for del_key in [ "auto", "non-auto", "auto-horiz", "not-used", "horiz", "vert-horiz", ]: option_list.pop(del_key, None) if self._config_entry.options.get(OPTION_HA_UI_SWING) is not None: option_default = self._config_entry.options.get( OPTION_HA_UI_SWING ) else: option_default = list(option_list.keys()) data_schema_structure.update( { vol.Optional( OPTION_HA_UI_SWING, default=option_default, ): cv.multi_select(option_list) } ) # Handle setting temperature ranges for various modes of operation for option in list(TEMP_OPTIONS.keys()): default_temp = TEMP_OPTIONS[option]["min"] if self._config_entry.options.get(option) is not None: default_temp = self._config_entry.options.get(option) else: default_temp = TEMP_OPTIONS[option]["default"] data_schema_structure.update( { vol.Required(option, default=default_temp): vol.All( vol.Coerce(int), vol.Range( min=TEMP_OPTIONS[option]["min"], max=TEMP_OPTIONS[option]["max"], ), ) } ) # Handle setting for the operation mode "Other" option_default = "as_off" if self._config_entry.options.get(CONF_OTHER_MODE) is not None: option_default = self._config_entry.options.get(CONF_OTHER_MODE) data_schema_structure.update( { vol.Optional( USER_OPTIONS[ENL_HVAC_MODE]["option"], default=option_default, ): selector( { "select": { "options": USER_OPTIONS[ENL_HVAC_MODE][ "option_list" ], "mode": "dropdown", } } ) } ) elif instance["eojgc"] == 0x01 and instance["eojcc"] == 0x35: # AirCleaner for option in list(USER_OPTIONS.keys()): if option in instance["setmap"]: option_default = [] if ( self._config_entry.options.get( USER_OPTIONS[option]["option"] ) is not None ): option_default = self._config_entry.options.get( USER_OPTIONS[option]["option"] ) data_schema_structure.update( { vol.Optional( USER_OPTIONS[option]["option"], default=option_default, ): cv.multi_select(USER_OPTIONS[option]["option_list"]) } ) for key, option in MISC_OPTIONS.items(): if "min" in option and "max" in option: _type = vol.All( vol.Coerce(option["type"]), vol.Range(min=option["min"], max=option["max"]), ) else: _type = option["type"] if type(option["default"]) == list and type(option["default"][0]) == dict: option_default = None for instance in self._config_entry.data["instances"]: option_default = ( option["default"][0] .get(instance["eojgc"], {}) .get(instance["eojcc"]) ) if option_default != None: break if option_default == None: option_default = option["default"][1] else: option_default = option["default"] data_schema_structure.update( { vol.Required( key, default=self._config_entry.options.get(key, option_default), ): _type } ) if user_input is not None or not any(data_schema_structure): if user_input is not None: self._data.update(user_input) return self.async_create_entry(title="", data=self._data) # return await self.async_step_misc() return self.async_show_form( step_id="init", data_schema=vol.Schema(data_schema_structure), ) async def async_step_misc(self, user_input=None): """Manage the options.""" data_schema_structure = {} # for key, option in MISC_OPTIONS.items(): # data_schema_structure.update({ # vol.Required( # CONF_FORCE_POLLING, # default=self._config_entry.options.get(key, option['default']) # ): option['type'] # }) if user_input is not None: self._data.update(user_input) return self.async_create_entry(title="", data=self._data) return self.async_show_form( step_id="misc", data_schema=vol.Schema(data_schema_structure), ) ================================================ FILE: custom_components/echonetlite/const.py ================================================ """Constants for the echonetlite integration.""" from homeassistant.const import ( CONF_ICON, CONF_SERVICE, CONF_TYPE, CONF_SERVICE_DATA, CONF_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_MINIMUM, CONF_MAXIMUM, PERCENTAGE, UnitOfEnergy, UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, ) from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor.const import ( CONF_STATE_CLASS, SensorStateClass, SensorDeviceClass, ) from homeassistant.components.number.const import ( NumberDeviceClass, ) from pychonet.ElectricBlind import ( ENL_OPENING_LEVEL, ENL_BLIND_ANGLE, ENL_OPENCLOSE_STATUS, ) from pychonet.GeneralLighting import ENL_BRIGHTNESS, ENL_COLOR_TEMP from pychonet.HomeAirConditioner import ( ENL_HVAC_MODE, ENL_FANSPEED, ENL_AIR_VERT, ENL_AIR_HORZ, ENL_AUTO_DIRECTION, ENL_HVAC_SET_TEMP, ENL_HVAC_SILENT_MODE, ENL_SWING_MODE, FAN_SPEED, AIRFLOW_VERT, AIRFLOW_HORIZ, AUTO_DIRECTION, SWING_MODE, ) from pychonet.EchonetInstance import ENL_STATUS, ENL_ON, ENL_OFF from pychonet.lib.const import ( ENL_CUMULATIVE_POWER, ENL_FAULT_DESCRIPTION, ENL_FAULT_STATUS, ENL_INSTANTANEOUS_POWER, ) from pychonet.lib.epc_functions import DATA_STATE_CLOSE, DATA_STATE_OPEN from pychonet.CeilingFan import ( ENL_BUZZER, ENL_FANSPEED_PERCENT, ENL_FAN_DIRECTION, ENL_FAN_LIGHT_BRIGHTNESS, ENL_FAN_LIGHT_COLOR_TEMP, ENL_FAN_LIGHT_MODE, ENL_FAN_LIGHT_NIGHT_BRIGHTNESS, ENL_FAN_LIGHT_STATUS, ENL_FAN_OSCILLATION, ) DOMAIN = "echonetlite" CONF_ENSURE_ON = "ensureon" CONF_OTHER_MODE = "other_mode" CONF_FORCE_POLLING = "force_polling" CONF_ENABLE_SUPER_ENERGY = "super_energy" CONF_BATCH_SIZE_MAX = "batch_size_max" CONF_ON_VALUE = "on_val" CONF_OFF_VALUE = "off_val" CONF_DISABLED_DEFAULT = "disabled_default" CONF_MULTIPLIER = "multiplier" CONF_MULTIPLIER_OPCODE = "multiplier_opcode" CONF_MULTIPLIER_OPTIONAL_OPCODE = "multiplier_optional_opcode" CONF_ICON_POSITIVE = "icon_positive" CONF_ICON_NEGATIVE = "icon_negative" CONF_ICON_ZERO = "icon_zero" CONF_ICONS = "icons" CONF_AS_ZERO = "as_zero" CONF_MAX_OPC = "max_opc" CONF_BYTE_LENGTH = "byte_len" DATA_STATE_ON = "on" DATA_STATE_OFF = "off" TYPE_SWITCH = "switch" TYPE_SELECT = "select" TYPE_TIME = "time" TYPE_NUMBER = "number" TYPE_DATA_DICT = "type_data_dict" TYPE_DATA_ARRAY_WITH_SIZE_OPCODE = "type_data_array_with_size_opcode" SERVICE_SET_ON_TIMER_TIME = "set_on_timer_time" SERVICE_SET_INT_1B = "set_value_int_1b" OPEN = "open" CLOSE = "close" STOP = "stop" DEVICE_CLASS_ECHONETLITE_LIGHT_SCENE = "echonetlite_light_scene" SWITCH_POWER = {DATA_STATE_ON: ENL_ON, DATA_STATE_OFF: ENL_OFF} SWITCH_BINARY = {DATA_STATE_ON: 0x41, DATA_STATE_OFF: 0x42} SWITCH_BINARY_INVERT = {DATA_STATE_ON: 0x42, DATA_STATE_OFF: 0x41} HVAC_SELECT_OP_CODES = { 0xA0: FAN_SPEED, 0xA1: AUTO_DIRECTION, 0xA3: SWING_MODE, 0xA5: AIRFLOW_HORIZ, 0xA4: AIRFLOW_VERT, } FAN_SELECT_OP_CODES = {0xA0: FAN_SPEED} COVER_SELECT_OP_CODES = {0xE0: {OPEN: 0x41, CLOSE: 0x42, STOP: 0x43}} ENL_TIMER_SETTING = 0x97 ENL_SUPER_CODES = { ENL_STATUS: {CONF_TYPE: BinarySensorDeviceClass.POWER}, ENL_INSTANTANEOUS_POWER: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, ENL_CUMULATIVE_POWER: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, CONF_MULTIPLIER: 0.001, }, ENL_TIMER_SETTING: { CONF_ICON: "mdi:clock-outline", TYPE_TIME: True, }, ENL_FAULT_STATUS: {CONF_TYPE: BinarySensorDeviceClass.PROBLEM}, ENL_FAULT_DESCRIPTION: { CONF_TYPE: SensorDeviceClass.ENUM, TYPE_DATA_DICT: ["fault classification", "error code"], }, } ENL_SUPER_ENERGES = {ENL_INSTANTANEOUS_POWER, ENL_CUMULATIVE_POWER} ENL_OP_CODES = { 0x00: { # Sensor-related Device 0x08: { # Visitor sensor class 0xB0: { CONF_ICON: "mdi:motion-sensor", }, # Detection threshold level 0xB1: { CONF_ICON: "mdi:motion-sensor", }, # Visitor detection status 0xBE: { TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.DURATION, CONF_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, CONF_MINIMUM: 0, CONF_MAXIMUM: 0xFFFD, CONF_MULTIPLIER: 10, }, }, # Visitor detection holding time }, 0x11: { # Temperature sensor 0xE0: { CONF_ICON: "mdi:thermometer", CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, }, }, 0x01: { # Air Conditioner-related Device 0x30: { # Home air conditioner # 0xB3: { # for develop test # CONF_TYPE: SensorDeviceClass.TEMPERATURE, # CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, # TYPE_NUMBER: { # Make Number input entity if settable value # CONF_TYPE: NumberDeviceClass.TEMPERATURE, # NumberDeviceClass.x # CONF_AS_ZERO: 0x1, # Value as zero # CONF_MINIMUM: 0x0, # Minimum value # CONF_MAXIMUM: 0x32, # Maximum value # CONF_MAX_OPC: None, # OPC of max value # CONF_BYTE_LENGTH: 0x1, # Data byte length # TYPE_SWITCH: { # Additional switch # CONF_NAME: "Auto", # Additionale name # CONF_ICON: "mdi:thermometer", # CONF_SERVICE_DATA: {DATA_STATE_ON: 23, DATA_STATE_OFF: 22}, # }, # }, # }, 0xB4: { # Humidity setting in dry mode CONF_TYPE: SensorDeviceClass.HUMIDITY, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.HUMIDITY, CONF_MINIMUM: 30, CONF_MAXIMUM: 90, }, CONF_SERVICE: [ SERVICE_SET_INT_1B ], # For backward compatibility (Deprecated) }, 0xBA: { CONF_TYPE: SensorDeviceClass.HUMIDITY, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xBE: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xBB: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xA0: { CONF_ICON: "mdi:fan", }, 0xA1: { CONF_ICON: "mdi:shuffle-variant", }, 0xA3: { CONF_ICON: "mdi:arrow-oscillating", }, 0xA5: { CONF_ICON: "mdi:tailwind", }, 0xA4: { CONF_ICON: "mdi:tailwind", }, }, 0x35: { # Air cleaner 0xE1: { CONF_ICON: "mdi:air-filter", }, 0xA0: { CONF_ICON: "mdi:fan", }, 0xC1: { CONF_ICON: "mdi:smoking", }, 0xC2: { CONF_ICON: "mdi:weather-sunny", }, 0xC0: { CONF_ICON: "mdi:flower-pollen", }, }, }, 0x02: { # Housing/Facilities-related Device 0x60: { # Electrically operated blind/shade 0xE0: { # Configured as Cover but left for backward compatibility CONF_ICON: "mdi:roller-shade", CONF_ICONS: { OPEN: "mdi:roller-shade", CLOSE: "mdi:roller-shade-closed", STOP: "mdi:roller-shade", }, CONF_DISABLED_DEFAULT: True, }, 0xD2: { CONF_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, TYPE_NUMBER: { CONF_MAXIMUM: 0x00, CONF_MAXIMUM: 0xFD, }, }, # Operation time }, 0x61: { # Electrically operated shutter 0xE0: { # Configured as Cover but left for backward compatibility CONF_ICON: "mdi:window-shutter-open", CONF_ICONS: { OPEN: "mdi:window-shutter-open", CLOSE: "mdi:window-shutter", STOP: "mdi:window-shutter-open", }, CONF_DISABLED_DEFAULT: True, }, 0xD2: { CONF_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, TYPE_NUMBER: { CONF_MAXIMUM: 0x00, CONF_MAXIMUM: 0xFD, }, }, # Operation time }, 0x62: { # Electrically operated curtain 0xE0: { # Configured as Cover but left for backward compatibility CONF_ICON: "mdi:curtains", CONF_ICONS: { OPEN: "mdi:curtains", CLOSE: "mdi:curtains-closed", STOP: "mdi:curtains", }, CONF_DISABLED_DEFAULT: True, }, 0xD2: { CONF_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, TYPE_NUMBER: { CONF_MAXIMUM: 0x00, CONF_MAXIMUM: 0xFD, }, }, # Operation time }, 0x63: { # Electrically operated rain sliding door/shutter 0xE0: { # Configured as Cover but left for backward compatibility CONF_ICON: "mdi:door-sliding-open", CONF_ICONS: { OPEN: "mdi:door-sliding-open", CLOSE: "mdi:door-sliding", STOP: "mdi:door-sliding-open", }, CONF_DISABLED_DEFAULT: True, }, 0xD2: { CONF_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, TYPE_NUMBER: { CONF_MAXIMUM: 0x00, CONF_MAXIMUM: 0xFD, }, }, # Operation time }, 0x64: { # Electrically operated gate 0xE0: { # Configured as Cover but left for backward compatibility CONF_ICON: "mdi:boom-gate-up-outline", CONF_ICONS: { OPEN: "mdi:boom-gate-up-outline", CLOSE: "mdi:boom-gate-outline", STOP: "mdi:boom-gate-up-outline", }, CONF_DISABLED_DEFAULT: True, }, 0xD2: { CONF_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, TYPE_NUMBER: { CONF_MAXIMUM: 0x00, CONF_MAXIMUM: 0xFD, }, }, # Operation time }, 0x65: { # Electrically operated window 0xE0: { # Configured as Cover but left for backward compatibility CONF_ICON: "mdi:window-open-variant", CONF_ICONS: { OPEN: "mdi:window-open-variant", CLOSE: "mdi:window-closed-variant", STOP: "mdi:window-open-variant", }, CONF_DISABLED_DEFAULT: True, }, 0xD2: { CONF_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, TYPE_NUMBER: { CONF_MAXIMUM: 0x00, CONF_MAXIMUM: 0xFD, }, }, # Operation time }, 0x66: { # Automatically operated entrance door/sliding door 0xE0: { # Configured as Cover but left for backward compatibility CONF_ICON: "mdi:door-sliding-open", CONF_ICONS: { OPEN: "mdi:door-sliding-open", CLOSE: "mdi:door-sliding", STOP: "mdi:door-sliding-open", }, CONF_DISABLED_DEFAULT: True, }, 0xD2: { CONF_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, TYPE_NUMBER: { CONF_MAXIMUM: 0x00, CONF_MAXIMUM: 0xFD, }, }, # Operation time }, 0x6B: { # Electric water heater # 0xB0: , # "Automatic water heating setting", # 0xB1: , # "Automatic water temperature control setting", # 0xB2: , # "Water heater status", 0xB3: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: 30, CONF_MAXIMUM: 90, }, }, # "Water heating temperature setting", 0xB4: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, # "Manual water heating stop days setting", # 0xB5: , # "Relative time setting value for manual water heating OFF", # 0xB6: , # Tank operation mode setting", # 0xC0: , # Daytime reheating permission setting", 0xC1: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, # Measured temperature of water in water heater", # 0xC2: , # Alarm status", # 0xC3: , # Hot water supply status", # 0xC4: , # Relative time setting for keeping bath temperature", 0xD1: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: 30, CONF_MAXIMUM: 90, }, }, # Temperature of supplied water setting", 0xD3: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: 30, CONF_MAXIMUM: 90, }, }, # Bath water temperature setting", 0xE0: { CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_MAXIMUM: 100, }, }, # Bath water volume setting", 0xE1: { CONF_TYPE: SensorDeviceClass.WATER, CONF_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, }, # Measured amount of water remaining in tank", 0xE2: { CONF_TYPE: SensorDeviceClass.WATER, CONF_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, }, # Tank capacity", # 0xE3: , # Automatic bath water heating mode setting", # 0xE9: , # Bathroom priority setting", # 0xEA: , # Bath operation status monitor", # 0xE4: , # Manual bath reheating operation setting", # 0xE5: , # Manual bath hot water addition function setting", # 0xE6: , # Manual slight bath water temperature lowering function setting", 0xE7: { CONF_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_MAXIMUM: 0xFD, }, }, # Bath water volume setting 1", 0xE8: { CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_AS_ZERO: 0x30, CONF_MINIMUM: 0x31, CONF_MAXIMUM: 0x38, }, }, # Bath water volume setting 2", 0xEE: { CONF_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_MAXIMUM: 0xFFFD, CONF_BYTE_LENGTH: 2, }, }, # Bath water volume setting 3", 0xD4: { CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_MINIMUM: 0x01, CONF_MAXIMUM: 0xFF, CONF_MAX_OPC: 0xD5, }, }, # Bath water volume setting 4", 0x90: { CONF_ICON: "mdi:timer", }, # ON timer reservation setting", 0x91: { CONF_ICON: "mdi:timer-outline", }, # ON timer setting", 0xD6: { CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_MAXIMUM: 0x64, }, }, # Volume setting", # 0xD7: , # Mute setting", 0xD8: { CONF_TYPE: SensorDeviceClass.WATER, CONF_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, }, # Remaining hot water volume", 0xDB: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, # Rated power consumption of H/P unit in wintertime", 0xDC: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, # Rated power consumption of H/P unit in in-between seasons", 0xDD: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, # Rated power consumption of H/P unit in summertime", 0xCB: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_DATA_DICT: ["10:00", "13:00", "15:00", "17:00"], }, 0xCC: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_DATA_DICT: ["10:00", "13:00", "15:00", "17:00"], }, 0xCE: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_DATA_DICT: ["13:00", "15:00", "17:00"], }, 0xCF: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_DATA_DICT: ["13:00", "15:00", "17:00"], }, }, 0x6F: { # Electric lock 0xE0: { CONF_ICON: "mdi:lock", CONF_ENSURE_ON: ENL_STATUS, }, 0xE1: { CONF_ICON: "mdi:lock", CONF_ENSURE_ON: ENL_STATUS, }, 0xE6: { CONF_ICON: None, CONF_ENSURE_ON: ENL_STATUS, }, }, 0x72: { # Hot water generator 0x90: { CONF_ICON: "mdi:timer", }, 0x91: { # Sensor with service CONF_ICON: "mdi:timer-outline", CONF_SERVICE: [ SERVICE_SET_ON_TIMER_TIME ], # For backward compatibility (Deprecated) }, 0xD1: { # Sensor CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: 30, CONF_MAXIMUM: 90, }, CONF_SERVICE: [ SERVICE_SET_INT_1B ], # For backward compatibility (Deprecated) }, 0xE1: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: 30, CONF_MAXIMUM: 90, }, CONF_SERVICE: [ SERVICE_SET_INT_1B ], # For backward compatibility (Deprecated) }, 0xE3: { CONF_ICON: "mdi:bathtub-outline", }, 0xE4: { CONF_ICON: "mdi:heat-wave", }, 0xE7: { CONF_TYPE: SensorDeviceClass.WATER, CONF_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, }, 0xEE: { CONF_TYPE: SensorDeviceClass.WATER, CONF_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, }, }, 0x73: { # Bathroom dryer 0xBA: { CONF_TYPE: SensorDeviceClass.HUMIDITY, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xBB: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xE0: { CONF_ICON: "mdi:motion-sensor", }, }, 0x79: { # Home solar power generation 0xA0: { CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, TYPE_NUMBER: { CONF_MAXIMUM: 0x64, }, }, 0xA1: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.POWER, CONF_MAXIMUM: 0xFFFD, CONF_BYTE_LENGTH: 0x02, }, }, 0xE0: { CONF_ICON: "mdi:solar-power-variant-outline", CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, CONF_ICON_POSITIVE: "mdi:solar-power-variant", CONF_ICON_NEGATIVE: "mdi:solar-power-variant-outline", CONF_ICON_ZERO: "mdi:solar-power-variant-outline", }, 0xE1: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, }, 0xE3: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, }, 0xE5: { CONF_ICON: "mdi:percent", CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_MAXIMUM: 0x64, }, }, 0xE6: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.POWER, CONF_MAXIMUM: 0xFFFD, CONF_BYTE_LENGTH: 0x02, }, }, 0xE7: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.POWER, CONF_MAXIMUM: 0xFFFD, CONF_BYTE_LENGTH: 0x02, }, }, 0xE8: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.POWER, CONF_MAXIMUM: 0xFFFD, CONF_BYTE_LENGTH: 0x02, }, }, 0xE9: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.POWER, CONF_MAXIMUM: 0xFFFD, CONF_BYTE_LENGTH: 0x02, }, }, }, 0x7B: { # Floor heater 0xE0: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: 16, CONF_MAXIMUM: 40, TYPE_SWITCH: { CONF_NAME: "Auto", CONF_SERVICE_DATA: {DATA_STATE_ON: 0x41, DATA_STATE_OFF: 16}, }, }, }, 0xE1: { CONF_ICON: "mdi:thermometer", CONF_TYPE: None, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_AS_ZERO: 0x30, CONF_MINIMUM: 0x31, CONF_MAXIMUM: 0x3F, CONF_MAX_OPC: 0xD1, TYPE_SWITCH: { CONF_NAME: "Auto", CONF_SERVICE_DATA: {DATA_STATE_ON: 0x41, DATA_STATE_OFF: 0x31}, }, }, }, 0xE2: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xE3: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0x90: { CONF_ICON: "mdi:timer", }, 0x91: { CONF_ICON: "mdi:timer-outline", }, 0x94: { CONF_ICON: "mdi:timer", }, 0x95: { CONF_ICON: "mdi:timer-outline", }, }, 0x7C: { # Fuel cell 0xC2: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xC4: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xC5: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, }, 0xCC: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xCD: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, }, 0xC7: { CONF_TYPE: SensorDeviceClass.GAS, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, CONF_UNIT_OF_MEASUREMENT: UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, }, 0xC8: { CONF_TYPE: SensorDeviceClass.GAS, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, }, }, 0x7D: { # Storage battery 0xA0: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, }, 0xA1: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, }, 0xA2: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, }, 0xA3: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, }, 0xA4: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, }, 0xA5: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, }, 0xA6: { CONF_TYPE: SensorDeviceClass.BATTERY, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.BATTERY, CONF_MAXIMUM: 0x64, }, }, 0xA7: { CONF_TYPE: SensorDeviceClass.BATTERY, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.BATTERY, CONF_MAXIMUM: 0x64, }, }, 0xA8: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, }, 0xA9: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, }, 0xAA: { CONF_ICON: "mdi:battery", CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.ENERGY, CONF_MAXIMUM: 0x3B9AC9FF, CONF_BYTE_LENGTH: 0x04, }, }, 0xAB: { CONF_ICON: "mdi:battery", CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.ENERGY, CONF_MAXIMUM: 0x3B9AC9FF, CONF_BYTE_LENGTH: 0x04, }, }, 0xD0: { CONF_ICON: "mdi:battery", CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, }, 0xD1: { CONF_MULTIPLIER: 0.1, CONF_UNIT_OF_MEASUREMENT: "Ah", CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xD2: { CONF_TYPE: SensorDeviceClass.VOLTAGE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xD3: { CONF_ICON_POSITIVE: "mdi:battery-arrow-up", CONF_ICON_NEGATIVE: "mdi:battery-arrow-down", CONF_ICON_ZERO: "mdi:battery", CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xD6: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, }, 0xD8: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, }, 0xE0: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, }, 0xE2: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, }, 0xE4: { CONF_TYPE: SensorDeviceClass.BATTERY, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xE5: { CONF_TYPE: SensorDeviceClass.BATTERY, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xE7: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.ENERGY, CONF_MAXIMUM: 0x3B9AC9FF, CONF_BYTE_LENGTH: 0x04, }, }, 0xE8: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.ENERGY, CONF_MAXIMUM: 0x3B9AC9FF, CONF_BYTE_LENGTH: 0x04, }, }, 0xEB: { CONF_ICON: "mdi:battery", CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.POWER, CONF_MAXIMUM: 0x3B9AC9FF, CONF_BYTE_LENGTH: 0x04, }, }, 0xEC: { CONF_ICON: "mdi:battery", CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.POWER, CONF_MAXIMUM: 0x3B9AC9FF, CONF_BYTE_LENGTH: 0x04, }, }, 0xEF: { CONF_TYPE: SensorDeviceClass.VOLTAGE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, }, 0x80: { # Electric energy meter 0xE0: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, CONF_MULTIPLIER_OPCODE: 0xE2, }, 0xE2: { CONF_DISABLED_DEFAULT: True, }, }, 0x81: { # Water flow meter 0xE0: { # CONF_ICON: "mdi:water", CONF_TYPE: SensorDeviceClass.WATER, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_MULTIPLIER_OPCODE: 0xE1, }, 0xE1: { CONF_DISABLED_DEFAULT: True, }, }, 0x82: { # Gas meter 0xE0: { # CONF_ICON: "mdi:gas-burner", CONF_TYPE: SensorDeviceClass.GAS, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_MULTIPLIER: 0.001, } }, 0x87: { # Distribution panel metering 0xC2: { CONF_DISABLED_DEFAULT: True, }, 0xB3: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, TYPE_DATA_ARRAY_WITH_SIZE_OPCODE: 0xB1, CONF_MULTIPLIER_OPCODE: 0xC2, }, 0xB7: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_DATA_ARRAY_WITH_SIZE_OPCODE: 0xB1, }, 0xC0: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, CONF_MULTIPLIER_OPCODE: 0xC2, }, 0xC1: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, CONF_MULTIPLIER_OPCODE: 0xC2, }, 0xC6: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xC7: { CONF_TYPE: SensorDeviceClass.CURRENT, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_DATA_DICT: ["r_phase_amperes", "t_phase_amperes"], CONF_DISABLED_DEFAULT: True, }, 0xC8: { CONF_TYPE: SensorDeviceClass.VOLTAGE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_DATA_DICT: ["r_sn_voltage", "sn_t_voltage"], CONF_DISABLED_DEFAULT: True, }, }, 0x88: { # Low voltage smart electric energy meter 0xD3: { CONF_DISABLED_DEFAULT: True, }, 0xE0: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, CONF_MULTIPLIER_OPCODE: 0xE1, CONF_MULTIPLIER_OPTIONAL_OPCODE: 0xD3, }, 0xE1: { CONF_DISABLED_DEFAULT: True, }, 0xE3: { CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, CONF_MULTIPLIER_OPCODE: 0xE1, CONF_MULTIPLIER_OPTIONAL_OPCODE: 0xD3, }, 0xE7: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, 0xE8: { CONF_TYPE: SensorDeviceClass.CURRENT, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_DATA_DICT: ["r_phase_amperes", "t_phase_amperes"], }, # 0xEA: { # TYPE_DATA_DICT: ["time", "culmative_value"], # }, # 0xEB: { # TYPE_DATA_DICT: ["time", "culmative_value"], # }, 0xD3: {CONF_DISABLED_DEFAULT: True}, 0xE1: {CONF_DISABLED_DEFAULT: True}, }, 0xA3: { # Lighting system 0xC0: { # Set scene CONF_ICON: "mdi:palette", CONF_TYPE: DEVICE_CLASS_ECHONETLITE_LIGHT_SCENE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_MAXIMUM: 0xFD, CONF_MAX_OPC: 0xC1, }, }, }, 0xA5: { # Multiple Input PCS 0xE0: { # Measured cumulative amount of electric energy (normal direction) CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, CONF_MULTIPLIER: 0.001, }, 0xE3: { # Measured cumulative amount of electric energy (reverse direction) CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, CONF_MULTIPLIER: 0.001, }, 0xE7: { # Measured instantaneous amount of electricity CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, }, 0xA6: { # Hybrid Water Heater 0xE1: { # Measured amount of hot water remaining in tank CONF_TYPE: SensorDeviceClass.WATER, CONF_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, }, 0xE2: { # Tank Capacity CONF_TYPE: SensorDeviceClass.WATER, CONF_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, }, }, }, 0x03: { # Cooking/housework-related device class group 0xB7: { # Refrigerator 0xB0: { CONF_ICON: "mdi:door", CONF_ICONS: { DATA_STATE_OPEN: "mdi:door-open", DATA_STATE_CLOSE: "mdi:door-closed", }, }, # "Door open/close status", 0xB1: { CONF_ICON: "mdi:door", CONF_ICONS: { DATA_STATE_OPEN: "mdi:door-open", DATA_STATE_CLOSE: "mdi:door-closed", }, }, # "Door open warning", 0xB2: { CONF_ICON: "mdi:door", CONF_ICONS: { DATA_STATE_OPEN: "mdi:door-open", DATA_STATE_CLOSE: "mdi:door-closed", }, }, # "Refrigerator compartment door status", 0xB3: { CONF_ICON: "mdi:door", CONF_ICONS: { DATA_STATE_OPEN: "mdi:door-open", DATA_STATE_CLOSE: "mdi:door-closed", }, }, # "Freezer compartment door status", 0xB4: { CONF_ICON: "mdi:door", CONF_ICONS: { DATA_STATE_OPEN: "mdi:door-open", DATA_STATE_CLOSE: "mdi:door-closed", }, }, # "Ice compartment door status", 0xB5: { CONF_ICON: "mdi:door", CONF_ICONS: { DATA_STATE_OPEN: "mdi:door-open", DATA_STATE_CLOSE: "mdi:door-closed", }, }, # "Vegetable compartment door status", 0xB6: { CONF_ICON: "mdi:door", CONF_ICONS: { DATA_STATE_OPEN: "mdi:door-open", DATA_STATE_CLOSE: "mdi:door-closed", }, }, # "Multi-refrigera-ting mode compartment door", 0xE0: { TYPE_DATA_DICT: [ "refrigerator", "freezer", "ice", "vegetable", "multi_refrigerating", ], CONF_DISABLED_DEFAULT: True, }, # "Maximum allowable temperature setting level", 0xE2: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: -127, CONF_MAXIMUM: 126, }, }, # "Refrigerator compartment temperature setting", 0xE3: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: -127, CONF_MAXIMUM: 126, }, }, # "Freezer compartment temperature setting", 0xE4: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: -127, CONF_MAXIMUM: 126, }, }, # "Ice temperature setting", 0xE5: { CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: -127, CONF_MAXIMUM: 126, }, CONF_TYPE: SensorDeviceClass.TEMPERATURE, }, # "Vegetable compartment temperature setting", 0xE6: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: -127, CONF_MAXIMUM: 126, }, }, # "Multi-refrigera-ting mode compartment temperature setting", 0xE9: { TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: 1, CONF_MAXIMUM: 0xFF, CONF_MAX_OPC: [0xE0, "refrigerator"], }, }, # "Refrigerator compartment temperature level setting", 0xEA: { TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: 1, CONF_MAXIMUM: 0xFF, CONF_MAX_OPC: [0xE0, "freezer"], }, }, # "Freezer compartment temperature level setting", 0xEB: { TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: 1, CONF_MAXIMUM: 0xFF, CONF_MAX_OPC: [0xE0, "ice"], }, }, # "ice compartment temperature level setting", 0xEC: { TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: 1, CONF_MAXIMUM: 0xFF, CONF_MAX_OPC: [0xE0, "vegetable"], }, }, # "Vegetable compartment temperature level setting", 0xED: { TYPE_NUMBER: { CONF_TYPE: NumberDeviceClass.TEMPERATURE, CONF_MINIMUM: 1, CONF_MAXIMUM: 0xFF, CONF_MAX_OPC: [0xE0, "multi_refrigerating"], }, }, # "Multi-refrigera-ting mode compartment temperature level setting", 0xD1: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, }, # "Measured refrigerator compartment temperature", 0xD2: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, }, # "Measured freezer compartment temperature", 0xD3: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, }, # "Measured subzero-fresh compartment temperature", 0xD4: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, }, # "Measured vegetable compartment temperature", 0xD5: { CONF_TYPE: SensorDeviceClass.TEMPERATURE, }, # "Measured multi-refrigeratin g mode compartment temperature", 0xD8: { TYPE_DATA_DICT: ["maximum_rotation_speed", "rotation_speed"] }, # "Compressor rotation speed", 0xDA: { CONF_TYPE: SensorDeviceClass.CURRENT, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, CONF_MULTIPLIER: 0.1, }, # "Measured electric current consumption", 0xDC: { CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, }, # "Rated power consumption", 0xA0: { CONF_ICON: "mdi:snowflake-check" }, # "Quick freeze function setting", 0xA1: { CONF_ICON: "mdi:fridge-bottom" }, # "Quick refrigeration function setting", 0xA4: {CONF_ICON: "mdi:dice-1-outline"}, # "Icemaker setting", 0xA5: {CONF_ICON: "mdi:dice-1-outline"}, # "Icemaker operation status", 0xA6: {CONF_ICON: "mdi:water-alert-outline"}, # "Icemaker tank status", 0xA8: { CONF_ICON: "mdi:water-thermometer" }, # "Refrigerator compartment humidification function setting", 0xA9: { CONF_ICON: "mdi:water-thermometer" }, # "Vegetable compartment humidification function setting", 0xAD: {CONF_ICON: "mdi:scent"}, # "Deodorization function setting", }, # Refrigerator }, "default": { CONF_ICON: None, CONF_TYPE: None, CONF_STATE_CLASS: None, }, } ENABLE_SUPER_ENERGY_DEFAULT = { # If False is not specified here, the default is True. # 0x01: { # 0x35: False, # }, } # Some entities that overlap with control entities are excluded from setup NON_SETUP_SINGLE_ENYITY = { 0x01: { # Home Air Conditioner 0x30: {ENL_HVAC_MODE, ENL_HVAC_SET_TEMP, ENL_HVAC_SILENT_MODE}, # Ceiling fan 0x3A: { ENL_FANSPEED_PERCENT, ENL_FAN_DIRECTION, ENL_FAN_OSCILLATION, ENL_FAN_LIGHT_STATUS, ENL_FAN_LIGHT_MODE, ENL_FAN_LIGHT_BRIGHTNESS, ENL_FAN_LIGHT_COLOR_TEMP, ENL_FAN_LIGHT_NIGHT_BRIGHTNESS, ENL_BUZZER, }, }, 0x02: { 0x60: {ENL_OPENING_LEVEL, ENL_BLIND_ANGLE, ENL_OPENCLOSE_STATUS}, 0x61: {ENL_OPENING_LEVEL, ENL_BLIND_ANGLE, ENL_OPENCLOSE_STATUS}, 0x62: {ENL_OPENING_LEVEL, ENL_BLIND_ANGLE, ENL_OPENCLOSE_STATUS}, 0x63: {ENL_OPENING_LEVEL, ENL_BLIND_ANGLE, ENL_OPENCLOSE_STATUS}, 0x64: {ENL_OPENING_LEVEL, ENL_BLIND_ANGLE, ENL_OPENCLOSE_STATUS}, 0x65: {ENL_OPENING_LEVEL, ENL_BLIND_ANGLE, ENL_OPENCLOSE_STATUS}, 0x66: {ENL_OPENING_LEVEL, ENL_BLIND_ANGLE, ENL_OPENCLOSE_STATUS}, # General Lighting 0x90: {ENL_BRIGHTNESS, ENL_COLOR_TEMP}, # Single Function Lighting 0x91: {ENL_BRIGHTNESS, ENL_COLOR_TEMP}, }, } ATTR_STATE_ON = "on" ATTR_STATE_OFF = "off" FAN_SPEED_OPTIONS = { "auto": "Auto", "minimum": "Minimum", "low": "Low", "medium-low": "Medium-Low", "medium": "Medium", "medium-high": "Medium-High", "high": "High", "very-high": "Very-High", "max": "Max", } AIRFLOW_HORIZ_OPTIONS = { "rc-right": "Right Center + Right", "left-lc": "Left + Left Center", "lc-center-rc": "Left + Center + Right Center", "left-lc-rc-right": "Left + Left Center + Right Center + Right", "right": "Right", "rc": "Right Center", "center": "Center", "center-right": "Center + Right", "center-rc": "Center + Right Center", "center-rc-right": "Center + Right Center + Right", "lc": "Left Center", "lc-right": "Left Center + Right", "lc-rc": "Left Center + Right Center", "lc-rc-right": "Left Center + Right Center + Right", "lc-center": "Left Center + Center", "lc-center-right": "Left Center + Center + Right", "lc-center-rc-right": "Left Center + Center + Right Center + Right", "left": "Left", "left-right": "Left + Right", "left-rc": "Left + Right Center", "left-rc-right": "Left + Right Center + Right", "left-center": "Left + Center", "left-center-right": "Left + Center + Right", "left-center-rc": "Left + Center + Right Center", "left-center-rc-right": "Left + Center + Right Center + Right", "left-lc-right": "Left + Left Center + Right", "left-lc-rc": "Left + Left Center + Right Center", "left-lc-center": "Left + Left Center + Center", "left-lc-center-right": "Left + Left Center + Center + Right", "left-lc-center-rc": "Left + Left Center + Center + Right Center", "left-lc-center-rc-right": "Left + Left Center + Center + Right Center + Right", } AIRFLOW_VERT_OPTIONS = { "upper": "Upper", "upper-central": "Upper Central", "central": "Central", "lower-central": "Lower Central", "lower": "Lower", } AUTO_DIRECTION_OPTIONS = { "auto": "Auto", "non-auto": "Non-Auto", "auto-vert": "Auto-vert", "auto-horiz": "Auto-horiz", } SWING_MODE_OPTIONS = { "not-used": "Not Used (Off)", "vert": "Vertical", "horiz": "Horizontal", "vert-horiz": "Vertical-Horizontal", } SILENT_MODE_OPTIONS = { "normal": "Normal", "high-speed": "High Speed", "silent": "Silent", } HVAC_MODE_OPTIONS = {"as_off": "As Off", "as_idle": "As Idle"} OPTION_HA_UI_SWING = "ha_ui_swing" USER_OPTIONS = { ENL_FANSPEED: {"option": "fan_settings", "option_list": FAN_SPEED_OPTIONS}, ENL_SWING_MODE: {"option": "swing_mode", "option_list": SWING_MODE_OPTIONS}, ENL_AUTO_DIRECTION: { "option": "auto_direction", "option_list": AUTO_DIRECTION_OPTIONS, }, ENL_AIR_VERT: {"option": "swing_vert", "option_list": AIRFLOW_VERT_OPTIONS}, ENL_AIR_HORZ: {"option": "swing_horiz", "option_list": AIRFLOW_HORIZ_OPTIONS}, ENL_HVAC_MODE: { "option": CONF_OTHER_MODE, "option_list": [ {"value": "as_off", "label": "As Off"}, {"value": "as_idle", "label": "As Idle"}, ], }, OPTION_HA_UI_SWING: {"option": OPTION_HA_UI_SWING, "option_list": []}, } TEMP_OPTIONS = { "min_temp_heat": {"min": 10, "max": 25, "default": 16}, "max_temp_heat": {"min": 18, "max": 30, "default": 30}, "min_temp_cool": {"min": 15, "max": 25, "default": 16}, "max_temp_cool": {"min": 18, "max": 30, "default": 30}, "min_temp_auto": {"min": 15, "max": 25, "default": 16}, "max_temp_auto": {"min": 18, "max": 30, "default": 30}, } MISC_OPTIONS = { CONF_FORCE_POLLING: {"type": bool, "default": False}, CONF_ENABLE_SUPER_ENERGY: { "type": bool, "default": [ENABLE_SUPER_ENERGY_DEFAULT, True], }, CONF_BATCH_SIZE_MAX: {"type": int, "default": 10, "min": 1, "max": 30}, } ================================================ FILE: custom_components/echonetlite/cover.py ================================================ import logging import math from typing import Any from pychonet.lib.epc_functions import ( DATA_STATE_CLOSE, DATA_STATE_OPEN, DATA_STATE_STOP, DATA_STATE_OPENING, DATA_STATE_CLOSING, ) from . import get_device_name from .const import DOMAIN from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, CoverEntity, CoverEntityFeature, ) from pychonet.lib.eojx import EOJX_CLASS from pychonet.ElectricBlind import ( ENL_BLIND_ANGLE, ENL_OPENCLOSE_STATUS, ENL_OPENING_LEVEL, ENL_OPENSTATE, ) from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, ) TILT_RANGE = (1, 180) _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_devices): """Set up entry.""" entities = [] for entity in hass.data[DOMAIN][config_entry.entry_id]: if entity["instance"]["eojgc"] == 0x02 and entity["instance"]["eojcc"] in ( 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, ): # 0x60: "Electrically operated blind/shade" # 0x61: "Electrically operated shutter" # 0x62: "Electrically operated curtain" # 0x63: "Electrically operated rain sliding door/shutter" # 0x64: "Electrically operated gate" # 0x65: "Electrically operated window" # 0x66: "Automatically operated entrance door/sliding door" entities.append(EchonetCover(entity["echonetlite"], config_entry)) async_add_devices(entities, True) class EchonetCover(CoverEntity): """Representation of an ECHONETLite climate device.""" def __init__(self, connector, config): """Initialize the cover device.""" name = get_device_name(connector, config) self._attr_name = name self._device_name = name self._connector = connector # new line self._attr_unique_id = ( self._connector._uidi if self._connector._uidi else self._connector._uid ) self._attr_is_closed = False self._server_state = self._connector._api._state[ self._connector._instance._host ] self._olddata = {} self._attr_should_poll = True self._support_flags = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP ) if ENL_OPENING_LEVEL in list(self._connector._setPropertyMap): self._support_flags |= CoverEntityFeature.SET_POSITION if ENL_BLIND_ANGLE in list(self._connector._setPropertyMap): self._support_flags |= ( CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT # not supported individually (just global STOP) # | CoverEntityFeature.STOP_TILT | CoverEntityFeature.SET_TILT_POSITION ) self._attr_current_cover_position = None self._attr_current_cover_tilt_position = None self._attr_is_opening = False self._attr_is_closing = False self.update_attr() self.update_option_listener() async def async_close_cover(self, **kwargs: Any) -> None: await self._connector._instance.setMessage(ENL_OPENSTATE, 0x42) self._connector._update_data[ENL_OPENSTATE] = DATA_STATE_CLOSE if ENL_OPENCLOSE_STATUS in self._connector._update_data: self._attr_is_opening = False self._attr_is_closing = True async def async_open_cover(self, **kwargs: Any) -> None: await self._connector._instance.setMessage(ENL_OPENSTATE, 0x41) self._connector._update_data[ENL_OPENSTATE] = DATA_STATE_OPEN if ENL_OPENCLOSE_STATUS in self._connector._update_data: self._attr_is_opening = True self._attr_is_closing = False async def async_stop_cover(self, **kwargs: Any) -> None: await self._connector._instance.setMessage(ENL_OPENSTATE, 0x43) self._connector._update_data[ENL_OPENSTATE] = DATA_STATE_STOP self._attr_is_opening = False self._attr_is_closing = False await self._connector.async_update() self.update_attr() async def async_set_cover_position(self, **kwargs: Any) -> None: desired_position = kwargs[ATTR_POSITION] current_position = self._attr_current_cover_position await self._connector._instance.setMessage(ENL_OPENING_LEVEL, desired_position) self._connector._update_data[ENL_OPENING_LEVEL] = int(desired_position) self._attr_is_opening = desired_position > current_position self._attr_is_closing = desired_position < current_position async def async_close_cover_tilt(self, **kwargs: Any) -> None: await self._connector._instance.setMessage(ENL_BLIND_ANGLE, 0) self._connector._update_data[ENL_BLIND_ANGLE] = 0 async def async_open_cover_tilt(self, **kwargs: Any) -> None: await self._connector._instance.setMessage(ENL_BLIND_ANGLE, 180) self._connector._update_data[ENL_BLIND_ANGLE] = 180 async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: tilt = math.ceil( percentage_to_ranged_value(TILT_RANGE, kwargs[ATTR_TILT_POSITION]) ) await self._connector._instance.setMessage(ENL_BLIND_ANGLE, tilt) self._connector._update_data[ENL_BLIND_ANGLE] = int(tilt) async def async_update(self): await self._connector.async_update() def update_attr(self): if ( ENL_OPENING_LEVEL in self._connector._update_data and self._connector._update_data[ENL_OPENING_LEVEL] != None ): self._attr_current_cover_position = int( self._connector._update_data[ENL_OPENING_LEVEL] ) self._attr_is_closed = self._attr_current_cover_position == 0 else: self._attr_is_closed = ( self._connector._update_data[ENL_OPENSTATE] == DATA_STATE_CLOSE ) if ENL_OPENCLOSE_STATUS in self._connector._update_data: self._attr_is_opening = ( self._connector._update_data[ENL_OPENCLOSE_STATUS] == DATA_STATE_OPENING ) self._attr_is_closing = ( self._connector._update_data[ENL_OPENCLOSE_STATUS] == DATA_STATE_CLOSING ) if ( ENL_BLIND_ANGLE in self._connector._update_data and self._connector._update_data[ENL_BLIND_ANGLE] != None ): self._attr_current_cover_tilt_position = ranged_value_to_percentage( TILT_RANGE, int(self._connector._update_data[ENL_BLIND_ANGLE]) ) @property def device_info(self): return { "identifiers": { ( DOMAIN, self._connector._uid, self._connector._instance._eojgc, self._connector._instance._eojcc, self._connector._instance._eojci, ) }, "name": self._device_name, "manufacturer": self._connector._manufacturer + ( " " + self._connector._host_product_code if self._connector._host_product_code else "" ), "model": EOJX_CLASS[self._connector._instance._eojgc][ self._connector._instance._eojcc ], # "sw_version": "", } async def async_added_to_hass(self): """Register callbacks.""" self._connector.add_update_option_listener(self.update_option_listener) self._connector.register_async_update_callbacks(self.async_update_callback) async def async_update_callback(self, isPush=False): changed = ( self._olddata != self._connector._update_data ) or self._attr_available != self._server_state["available"] if changed: self._olddata = self._connector._update_data.copy() self.update_attr() if self._attr_available != self._server_state["available"]: if self._server_state["available"]: self.update_option_listener() else: self._attr_should_poll = True self._attr_available = self._server_state["available"] self.async_schedule_update_ha_state() def update_option_listener(self): _LOGGER.info(f"{self._device_name}: _should_poll is {self._attr_should_poll}") ================================================ FILE: custom_components/echonetlite/fan.py ================================================ import logging from pychonet.HomeAirCleaner import FAN_SPEED from pychonet.lib.const import ENL_STATUS from pychonet.lib.eojx import EOJX_CLASS from pychonet.CeilingFan import ( ENL_FANSPEED_PERCENT, ENL_FAN_DIRECTION, ENL_FAN_OSCILLATION, ) from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.const import ( PRECISION_WHOLE, ) from . import get_device_name from .const import ( CONF_FORCE_POLLING, DATA_STATE_ON, DOMAIN, ENL_FANSPEED, ) _LOGGER = logging.getLogger(__name__) DEFAULT_FAN_MODES = list( FAN_SPEED.keys() ) # ["auto","minimum","low","medium-low","medium","medium-high","high","very-high","max"] async def async_setup_entry(hass, config_entry, async_add_devices): """Set up entry.""" entities = [] for entity in hass.data[DOMAIN][config_entry.entry_id]: if entity["instance"]["eojgc"] == 0x01 and ( entity["instance"]["eojcc"] == 0x35 or entity["instance"]["eojcc"] == 0x3A ): # Home Air Cleaner or Celing Fan entities.append(EchonetFan(entity["echonetlite"], config_entry)) async_add_devices(entities, True) class EchonetFan(FanEntity): """Representation of an ECHONETLite Fan device (eg Air purifier).""" def __init__(self, connector, config): """Initialize the climate device.""" name = get_device_name(connector, config) self._attr_name = name self._device_name = name self._connector = connector # new line self._attr_unique_id = ( self._connector._uidi if self._connector._uidi else self._connector._uid ) self._precision = 1.0 self._target_temperature_step = 1 self._server_state = self._connector._api._state[ self._connector._instance._host ] self._attr_supported_features = FanEntityFeature(0) if hasattr(FanEntityFeature, "TURN_ON"): # v2024.8 self._attr_supported_features |= FanEntityFeature.TURN_ON if hasattr(FanEntityFeature, "TURN_OFF"): self._attr_supported_features |= FanEntityFeature.TURN_OFF if ENL_FANSPEED in list(self._connector._setPropertyMap): self._attr_supported_features |= FanEntityFeature.PRESET_MODE if ENL_FANSPEED_PERCENT in list(self._connector._setPropertyMap): self._attr_supported_features |= FanEntityFeature.SET_SPEED if ENL_FAN_DIRECTION in list(self._connector._setPropertyMap): self._attr_supported_features |= FanEntityFeature.DIRECTION if ENL_FAN_OSCILLATION in list(self._connector._setPropertyMap): self._attr_supported_features |= FanEntityFeature.OSCILLATE self._olddata = {} self._attr_should_poll = True self._attr_available = True self._attr_speed_count = getattr(self._connector._instance, "SPEED_COUNT", 100) self._set_attrs() self.update_option_listener() # see, https://developers.home-assistant.io/blog/2024/07/19/fan-fanentityfeatures-turn-on_off/ self._enable_turn_on_off_backwards_compatibility = False async def async_update(self): try: await self._connector.async_update() except TimeoutError: pass @property def device_info(self): return { "identifiers": { ( DOMAIN, self._connector._uid, self._connector._instance._eojgc, self._connector._instance._eojcc, self._connector._instance._eojci, ) }, "name": self._device_name, "manufacturer": self._connector._manufacturer + ( " " + self._connector._host_product_code if self._connector._host_product_code else "" ), "model": EOJX_CLASS[self._connector._instance._eojgc][ self._connector._instance._eojcc ], # "sw_version": "", } @property def precision(self) -> float: return PRECISION_WHOLE @property def is_on(self): """Return true if the device is on.""" return ( True if self._connector._update_data[ENL_STATUS] == DATA_STATE_ON else False ) def _set_attrs(self): # @property # def preset_mode(self): """Return the fan setting.""" self._attr_preset_mode = ( self._connector._update_data[ENL_FANSPEED] if ENL_FANSPEED in self._connector._update_data else None ) # @property # def percentage(self): """Return the fan setting.""" self._attr_percentage = ( self._connector._update_data[ENL_FANSPEED_PERCENT] if ENL_FANSPEED_PERCENT in self._connector._update_data else None ) # @property # def current_direction(self): """Return the fan direction.""" self._attr_current_direction = ( self._connector._update_data[ENL_FAN_DIRECTION] if ENL_FAN_DIRECTION in self._connector._update_data else None ) # @property # def oscillating(self): """Return the fan oscillating.""" self._attr_oscillating = ( self._connector._update_data[ENL_FAN_OSCILLATION] if ENL_FAN_OSCILLATION in self._connector._update_data else None ) # @property # def preset_modes(self): """Return the list of available fan modes.""" if ( ENL_FANSPEED in list(self._connector._user_options.keys()) and self._connector._user_options[ENL_FANSPEED] is not False ): self._attr_preset_modes = self._connector._user_options[ENL_FANSPEED] else: self._attr_preset_modes = DEFAULT_FAN_MODES async def async_set_direction(self, direction: str) -> None: await self._connector._instance.setFanDirection(direction) async def async_turn_on( self, percentage: int | None = None, preset_mode: str | None = None, **kwargs, ) -> None: """Turn on.""" await self._connector._instance.on() async def async_turn_off(self, **kwargs): """Turn off.""" await self._connector._instance.off() async def async_oscillate(self, oscillating: bool) -> None: await self._connector._instance.setFanOscillation(oscillating) async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" await self._connector._instance.setFanSpeedPercent(percentage) async def async_set_preset_mode(self, preset_mode: str): """Set new fan mode.""" await self._connector._instance.setFanSpeed(preset_mode) async def async_added_to_hass(self): """Register callbacks.""" self._connector.register_async_update_callbacks(self.async_update_callback) self._connector.add_update_option_listener(self.update_option_listener) async def async_update_callback(self, isPush: bool = False): changed = ( self._olddata != self._connector._update_data or self._attr_available != self._server_state["available"] ) if changed: _force = bool(not self._attr_available and self._server_state["available"]) self._olddata = self._connector._update_data.copy() if self._attr_available != self._server_state["available"]: if self._server_state["available"]: self.update_option_listener() else: self._attr_should_poll = True self._attr_available = self._server_state["available"] self._set_attrs() self.async_schedule_update_ha_state(_force | isPush) def update_option_listener(self): _should_poll = ( ENL_STATUS not in self._connector._ntfPropertyMap or ( FanEntityFeature.PRESET_MODE in self._attr_supported_features and ENL_FANSPEED not in self._connector._ntfPropertyMap ) or ( FanEntityFeature.SET_SPEED in self._attr_supported_features and ENL_FANSPEED_PERCENT not in self._connector._ntfPropertyMap ) or ( FanEntityFeature.DIRECTION in self._attr_supported_features and ENL_FAN_DIRECTION not in self._connector._ntfPropertyMap ) or ( FanEntityFeature.OSCILLATE in self._attr_supported_features and ENL_FAN_OSCILLATION not in self._connector._ntfPropertyMap ) ) self._attr_should_poll = ( self._connector._user_options.get(CONF_FORCE_POLLING, False) or _should_poll ) self._attr_extra_state_attributes = {"notify": "No" if _should_poll else "Yes"} _LOGGER.debug(f"{self._attr_name}: _should_poll is {_should_poll}") ================================================ FILE: custom_components/echonetlite/light.py ================================================ import logging from pychonet.GeneralLighting import ENL_STATUS, ENL_BRIGHTNESS, ENL_COLOR_TEMP from pychonet.CeilingFan import ( ENL_FAN_LIGHT_STATUS, ENL_FAN_LIGHT_BRIGHTNESS, ENL_FAN_LIGHT_COLOR_TEMP, ) from pychonet.lib.const import ENL_ON from pychonet.lib.eojx import EOJX_CLASS from pychonet.lib.epc_functions import _swap_dict from homeassistant.components.light import ( ATTR_EFFECT, LightEntity, ColorMode, LightEntityFeature, ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ) from . import get_device_name from .const import DATA_STATE_ON, DOMAIN, CONF_FORCE_POLLING _LOGGER = logging.getLogger(__name__) DEFAULT_BRIGHTNESS_SCALE = 255 MIN_MIREDS = 153 # 6500k MAX_MIREDS = 500 # 2000k DEVICE_SCALE = 100 def _mireds_to_kelvin(mireds): """Convert mireds to kelvin.""" return round(1000000 / mireds) if mireds else None def _kelvin_to_mireds(kelvin): """Convert kelvin to mireds.""" return round(1000000 / kelvin) if kelvin else None async def async_setup_entry(hass, config_entry, async_add_devices): """Set up entry.""" entities = [] for entity in hass.data[DOMAIN][config_entry.entry_id]: eojgc = entity["instance"]["eojgc"] eojcc = entity["instance"]["eojcc"] if (eojgc == 0x02 and eojcc in (0x90, 0x91, 0xA3)) or ( eojgc == 0x01 and eojcc == 0x3A and ENL_FAN_LIGHT_STATUS in entity["echonetlite"]._setPropertyMap ): custom_options = {} # General Lighting (0x90), Mono Functional Lighting (0x91), Lighting System (0xA3) if eojgc == 0x02 and eojcc in (0x90, 0x91, 0xA3): custom_options = { ENL_STATUS: ENL_STATUS, ENL_BRIGHTNESS: ENL_BRIGHTNESS, ENL_COLOR_TEMP: ENL_COLOR_TEMP, "echonet_color": { 0x44: "daylight_color", 0x43: "daylight_white", 0x42: "white", 0x40: "other", 0x41: "incandescent_lamp_color", }, "echonet_mireds_int": { 0x44: 153, # 6500K 0x43: 200, # 5000K 0x42: 238, # 4200K 0x40: 285, # 3500K 0x41: 370, # 2700K }, # coolest to warmest value is mired "on": "on", "off": "off", } custom_options["echonet_int_color"] = _swap_dict( custom_options["echonet_color"] ) # Ceiling Fan (0x01-0x3A) elif eojgc == 0x01 and eojcc == 0x3A: custom_options = { ENL_STATUS: ENL_FAN_LIGHT_STATUS, ENL_BRIGHTNESS: ENL_FAN_LIGHT_BRIGHTNESS, ENL_COLOR_TEMP: ENL_FAN_LIGHT_COLOR_TEMP, "echonet_color": None, "echonet_mireds_int": None, "on": "light_on", "off": "light_off", } _LOGGER.debug("Configuring ECHONETlite Light entity") entities.append( EchonetLight( entity["echonetlite"], config_entry, custom_options, ) ) _LOGGER.debug(f"Number of light devices to be added: {len(entities)}") async_add_devices(entities, True) class EchonetLight(LightEntity): """Representation of a ECHONET light device.""" def __init__(self, connector, config, custom_options): """Initialize the climate device.""" name = get_device_name(connector, config) self._attr_name = name self._connector = connector # new line self._attr_unique_id = ( self._connector._uidi if self._connector._uidi else self._connector._uid ) self._attr_supported_features = LightEntityFeature(0) self._attr_supported_color_modes = set() self._server_state = self._connector._api._state[ self._connector._instance._host ] if mireds_int := custom_options.get("echonet_mireds_int"): mireds = mireds_int.values() self._attr_min_color_temp_kelvin = _mireds_to_kelvin(max(mireds)) self._attr_max_color_temp_kelvin = _mireds_to_kelvin(min(mireds)) else: self._attr_min_color_temp_kelvin = _mireds_to_kelvin(MAX_MIREDS) self._attr_max_color_temp_kelvin = _mireds_to_kelvin(MIN_MIREDS) # Keep mired limits for internal calculations if mireds_int := custom_options.get("echonet_mireds_int"): mireds = mireds_int.values() self._min_mireds = min(mireds) self._max_mireds = max(mireds) else: self._min_mireds = MIN_MIREDS self._max_mireds = MAX_MIREDS self._custom_options = custom_options if custom_options[ENL_COLOR_TEMP] in list(self._connector._setPropertyMap): self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) self._attr_color_mode = ColorMode.COLOR_TEMP if custom_options[ENL_BRIGHTNESS] in list(self._connector._setPropertyMap): if not self._attr_supported_color_modes: self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) self._attr_color_mode = ColorMode.BRIGHTNESS if not self._attr_supported_color_modes: self._attr_supported_color_modes.add(ColorMode.ONOFF) self._attr_color_mode = ColorMode.ONOFF self._olddata = {} self._attr_is_on = ( True if self._connector._update_data[custom_options[ENL_STATUS]] == DATA_STATE_ON else False ) if hasattr(self._connector._instance, "getEffectList"): self._attr_effect_list = self._connector._instance.getEffectList() if self._attr_effect_list: self._attr_supported_features |= LightEntityFeature.EFFECT if hasattr(self._connector._instance, "getLightColorLevelMax"): self._light_color_level_max = ( self._connector._instance.getLightColorLevelMax() ) else: self._light_color_level_max = 100 self._attr_should_poll = True self._attr_available = True self._set_attrs() self.update_option_listener() async def async_update(self): """Get the latest state from the Light.""" try: await self._connector.async_update() except TimeoutError: pass @property def device_info(self): return { "identifiers": { ( DOMAIN, self._connector._uid, self._connector._instance._eojgc, self._connector._instance._eojcc, self._connector._instance._eojci, ) }, "name": self._attr_name, "manufacturer": self._connector._manufacturer + ( " " + self._connector._host_product_code if self._connector._host_product_code else "" ), "model": EOJX_CLASS[self._connector._instance._eojgc][ self._connector._instance._eojcc ], # "sw_version": "", } async def async_turn_on(self, **kwargs): states = {"status": ENL_ON} if ( ATTR_BRIGHTNESS in kwargs and self._attr_supported_color_modes and self._attr_color_mode in {ColorMode.BRIGHTNESS, ColorMode.COLOR_TEMP} ): normalized_brightness = ( float(kwargs[ATTR_BRIGHTNESS]) / DEFAULT_BRIGHTNESS_SCALE ) device_brightness = round(normalized_brightness * DEVICE_SCALE) # Make sure the brightness is not rounded down to 0 device_brightness = max(device_brightness, 1) # send the message to the lamp states["brightness"] = device_brightness self._attr_brightness = kwargs[ATTR_BRIGHTNESS] if ( ATTR_COLOR_TEMP_KELVIN in kwargs and self._attr_supported_color_modes and self._attr_color_mode == ColorMode.COLOR_TEMP ): # Convert kelvin from HA to mireds for internal device logic attr_color_tmp = float(_kelvin_to_mireds(kwargs[ATTR_COLOR_TEMP_KELVIN])) if self._custom_options["echonet_color"]: color_temp_int = 0x41 for i, mired in self._custom_options["echonet_mireds_int"].items(): if attr_color_tmp <= mired + 15: color_temp_int = i break color_temp = self._custom_options["echonet_color"].get(color_temp_int) _LOGGER.debug( f"New color temp of light: {color_temp} - {color_temp_int}" ) self._attr_color_temp_kelvin = _mireds_to_kelvin( int(self._custom_options["echonet_mireds_int"].get(color_temp_int)) ) else: color_scale = (attr_color_tmp - float(self._min_mireds)) / float( self._max_mireds - self._min_mireds ) _LOGGER.debug(f"Set color to : {color_scale}") color_temp_int = min( self._light_color_level_max, max(1, (1 - color_scale) * self._light_color_level_max), ) _LOGGER.debug( f"New color temp of light: {attr_color_tmp} mireds - {color_temp_int}" ) self._attr_color_temp_kelvin = _mireds_to_kelvin(int(attr_color_tmp)) states["color_temperature"] = int(color_temp_int) if ATTR_EFFECT in kwargs and kwargs[ATTR_EFFECT] in self._attr_effect_list: states[ATTR_EFFECT] = kwargs[ATTR_EFFECT] if hasattr(self._connector._instance, "setLightStates"): return await self._connector._instance.setLightStates(states) else: """Turn on.""" result = await getattr( self._connector._instance, self._custom_options["on"] )() if result: if states.get("brightness"): result &= await self._connector._instance.setBrightness( states["brightness"] ) if states.get("color_temperature"): result &= await self._connector._instance.setColorTemperature( states["color_temperature"] ) async def async_turn_off(self, **kwargs): """Turn off.""" await getattr(self._connector._instance, self._custom_options["off"])() def _set_attrs(self): if self._attr_supported_color_modes and self._attr_color_mode in { ColorMode.BRIGHTNESS, ColorMode.COLOR_TEMP, }: """brightness of this light between 0..255.""" _LOGGER.debug( f"Current brightness of light: {self._connector._update_data[self._custom_options[ENL_BRIGHTNESS]]}" ) brightness = ( int(self._connector._update_data[self._custom_options[ENL_BRIGHTNESS]]) if self._custom_options[ENL_BRIGHTNESS] in self._connector._update_data else -1 ) if brightness >= 0: self._attr_brightness = min( round(float(brightness) / DEVICE_SCALE * DEFAULT_BRIGHTNESS_SCALE), 255, ) else: self._attr_brightness = 128 if ( self._attr_supported_color_modes and self._attr_color_mode == ColorMode.COLOR_TEMP ): """color temperature in kelvin.""" enl_color_temp = self._custom_options[ENL_COLOR_TEMP] _LOGGER.debug( f"Current color temp of light: {self._connector._update_data[enl_color_temp]}" ) if self._custom_options["echonet_color"]: # get the current echonet mireds and convert to kelvin color_temp = ( self._connector._update_data[enl_color_temp] if enl_color_temp in self._connector._update_data else "white" ) mired_val = self._custom_options["echonet_mireds_int"].get( self._custom_options["echonet_int_color"].get(color_temp), 153 ) self._attr_color_temp_kelvin = _mireds_to_kelvin(mired_val) else: mired_val = (self._max_mireds - self._min_mireds) * ( ( self._light_color_level_max - self._connector._update_data[enl_color_temp] ) / self._light_color_level_max ) + self._min_mireds self._attr_color_temp_kelvin = _mireds_to_kelvin(mired_val) if hasattr(self._connector._instance, "getEffect"): self._attr_effect = self._connector._instance.getEffect() async def async_added_to_hass(self): """Register callbacks.""" self._connector.add_update_option_listener(self.update_option_listener) self._connector.register_async_update_callbacks(self.async_update_callback) async def async_update_callback(self, isPush: bool = False): changed = ( self._olddata != self._connector._update_data or self._attr_available != self._server_state["available"] ) if changed: _force = bool(not self._attr_available and self._server_state["available"]) self._olddata = self._connector._update_data.copy() self._attr_is_on = ( True if self._connector._update_data[self._custom_options[ENL_STATUS]] == DATA_STATE_ON else False ) if self._attr_available != self._server_state["available"]: if self._server_state["available"]: self.update_option_listener() else: self._attr_should_poll = True self._attr_available = self._server_state["available"] self._set_attrs() self.async_schedule_update_ha_state(_force) if isPush and self._attr_should_poll: try: await self._connector.async_update() except TimeoutError: pass def update_option_listener(self): _should_poll = ( self._custom_options[ENL_STATUS] not in self._connector._ntfPropertyMap or ( self._attr_supported_color_modes and ColorMode.BRIGHTNESS in self._attr_supported_color_modes and self._custom_options[ENL_BRIGHTNESS] not in self._connector._ntfPropertyMap ) or ( self._attr_supported_color_modes and ColorMode.COLOR_TEMP in self._attr_supported_color_modes and ENL_COLOR_TEMP not in self._connector._ntfPropertyMap ) ) self._attr_should_poll = bool( self._connector._user_options.get(CONF_FORCE_POLLING, False) or _should_poll ) self._attr_extra_state_attributes = {"notify": "No" if _should_poll else "Yes"} _LOGGER.debug(f"{self._attr_name}: _should_poll is {_should_poll}") ================================================ FILE: custom_components/echonetlite/manifest.json ================================================ { "domain": "echonetlite", "name": "ECHONET Lite", "issue_tracker": "https://github.com/scottyphillips/echonetlite_homeassistant/issues", "config_flow": true, "documentation": "https://www.github.com/scottyphillips/echonetlite_homeassistant", "requirements": [ "pychonet==2.6.18" ], "dependencies": [], "codeowners": [ "@scottyphillips", "@nao-pon" ], "version": "3.9.0", "iot_class": "local_polling" } ================================================ FILE: custom_components/echonetlite/number.py ================================================ import logging from homeassistant.const import ( CONF_ICON, CONF_NAME, CONF_TYPE, CONF_MINIMUM, CONF_MAXIMUM, CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.exceptions import InvalidStateError from homeassistant.components.number import NumberEntity from pychonet.lib.eojx import EOJX_CLASS from . import get_name_by_epc_code, get_unit_by_devise_class, get_device_name from .const import ( CONF_DISABLED_DEFAULT, DOMAIN, CONF_FORCE_POLLING, CONF_AS_ZERO, CONF_MAX_OPC, CONF_BYTE_LENGTH, NON_SETUP_SINGLE_ENYITY, TYPE_NUMBER, ) _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config, async_add_entities, discovery_info=None): entities = [] for entity in hass.data[DOMAIN][config.entry_id]: eojgc = entity["instance"]["eojgc"] eojcc = entity["instance"]["eojcc"] _enl_op_codes = entity["echonetlite"]._enl_op_codes # configure select entities by looking up full ENL_OP_CODE dict for op_code in list( set(entity["instance"]["setmap"]) - NON_SETUP_SINGLE_ENYITY.get(eojgc, {}).get(eojcc, set()) ): if TYPE_NUMBER in _enl_op_codes.get(op_code, {}).keys(): entities.append( EchonetNumber( hass, entity["echonetlite"], config, op_code, _enl_op_codes[op_code], ) ) async_add_entities(entities, True) class EchonetNumber(NumberEntity): _attr_translation_key = DOMAIN def __init__(self, hass, connector, config, code, options): """Initialize the number.""" self._connector = connector self._config = config self._code = code self._server_state = self._connector._api._state[ self._connector._instance._host ] self._attr_icon = options.get(CONF_ICON, None) self._attr_name = f"{config.title} {get_name_by_epc_code(self._connector._eojgc, self._connector._eojcc, self._code, None, self._connector._enl_op_codes.get(self._code, {}).get(CONF_NAME))}" self._attr_unique_id = ( f"{self._connector._uidi}-{self._code}" if self._connector._uidi else f"{self._connector._uid}-{self._code}" ) self._options = options[TYPE_NUMBER] self._as_zero = int(options[TYPE_NUMBER].get(CONF_AS_ZERO, 0)) self._conf_max = int(options[TYPE_NUMBER][CONF_MAXIMUM]) self._byte_length = int(options[TYPE_NUMBER].get(CONF_BYTE_LENGTH, 1)) self._device_name = get_device_name(connector, config) self._attr_device_class = self._options.get( CONF_TYPE, options.get(CONF_TYPE, None) ) self._attr_native_value = self.get_value() self._attr_native_max_value = self.get_max_value() self._attr_native_min_value = self._options.get(CONF_MINIMUM, 0) - self._as_zero self._attr_native_unit_of_measurement = self._options.get( CONF_UNIT_OF_MEASUREMENT, options.get(CONF_UNIT_OF_MEASUREMENT, None) ) if not self._attr_native_unit_of_measurement: self._attr_native_unit_of_measurement = get_unit_by_devise_class( self._attr_device_class ) self._attr_should_poll = True self._attr_available = True self._attr_entity_registry_enabled_default = not bool( options.get(CONF_DISABLED_DEFAULT) ) self.update_option_listener() @property def device_info(self): return { "identifiers": { ( DOMAIN, self._connector._uid, self._connector._instance._eojgc, self._connector._instance._eojcc, self._connector._instance._eojci, ) }, "name": self._device_name, "manufacturer": self._connector._manufacturer + ( " " + self._connector._host_product_code if self._connector._host_product_code else "" ), "model": EOJX_CLASS[self._connector._instance._eojgc][ self._connector._instance._eojcc ], # "sw_version": "", } def get_value(self): value = self._connector._update_data.get(self._code) if value != None: return int(self._connector._update_data.get(self._code)) - self._as_zero else: return None def get_max_value(self): max_value = self.get_max_opc_value() if max_value == None: max_value = self._conf_max return max_value - self._as_zero def get_max_opc_value(self): max_opc_value = None max_opc = self._options.get(CONF_MAX_OPC) if max_opc: if isinstance(max_opc, list): max_opc_value = self._connector._update_data.get(max_opc[0]).get( max_opc[1] ) else: max_opc_value = self._connector._update_data.get(max_opc) if max_opc_value != None: max_opc_value = int(max_opc_value) return max_opc_value async def async_set_native_value(self, value: float) -> None: """Update the current value.""" if await self._connector._instance.setMessage( self._code, int(value + self._as_zero), self._byte_length ): pass else: raise InvalidStateError( "The state setting is not supported or is an invalid value." ) async def async_update(self): """Retrieve latest state.""" try: await self._connector.async_update() except TimeoutError: pass async def async_added_to_hass(self): """Register callbacks.""" self._connector.add_update_option_listener(self.update_option_listener) self._connector.register_async_update_callbacks(self.async_update_callback) async def async_update_callback(self, isPush: bool = False): new_val = self.get_value() changed = ( self._attr_native_value != new_val or self._attr_available != self._server_state["available"] or self._attr_native_max_value != self.get_max_value() ) if changed: _force = bool(not self._attr_available and self._server_state["available"]) self._attr_native_value = new_val self._attr_native_max_value = self.get_max_value() if self._attr_available != self._server_state["available"]: if self._server_state["available"]: self.update_option_listener() else: self._attr_should_poll = True self._attr_available = self._server_state["available"] self.async_schedule_update_ha_state(_force) def update_option_listener(self): _should_poll = self._code not in self._connector._ntfPropertyMap self._attr_should_poll = ( self._connector._user_options.get(CONF_FORCE_POLLING, False) or _should_poll ) self._attr_extra_state_attributes = {"notify": "No" if _should_poll else "Yes"} _LOGGER.debug( f"{self._device_name}({self._code}): _should_poll is {_should_poll}" ) ================================================ FILE: custom_components/echonetlite/quirks/Nichicon/all/02A5.py ================================================ from homeassistant.components.sensor.const import ( CONF_STATE_CLASS, SensorDeviceClass, SensorStateClass, ) from homeassistant.const import ( CONF_NAME, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, UnitOfEnergy, ) from pychonet.lib.epc_functions import _int, _signed_int from ....const import TYPE_DATA_DICT def _sint_4(edt): res = _signed_int(edt) return res if res >= -2147483647 and res <= 2147483645 else None def _02A5F5(edt): d1 = d2 = d3 = d4 = None try: d1 = _sint_4(edt[0:4]) d2 = _sint_4(edt[4:8]) d3 = _sint_4(edt[8:12]) d4 = _sint_4(edt[12:16]) except: pass finally: return { "From(-)/To(+) Grid": d1, "Household Consumption": d2, "Photovoltaic Origin": d3, "Other Origin": d4, } def _02A5F6(edt): d1 = d2 = None try: d1 = float(_int(edt[0:4])) / 1000 d2 = float(_int(edt[4:8])) / 1000 except: pass finally: return {"normal_direction": d1, "reverse_direction": d2} QUIRKS = { 0xF5: { "EPC_FUNCTION": _02A5F5, "ENL_OP_CODE": { CONF_NAME: "Instantaneous Power", CONF_TYPE: SensorDeviceClass.POWER, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, TYPE_DATA_DICT: [ "From(-)/To(+) Grid", "Household Consumption", "Photovoltaic Origin", "Other Origin", ], }, }, 0xF6: { "EPC_FUNCTION": _02A5F6, "ENL_OP_CODE": { CONF_NAME: "Cumulative energy", CONF_TYPE: SensorDeviceClass.ENERGY, CONF_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, CONF_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, TYPE_DATA_DICT: ["normal_direction", "reverse_direction"], }, }, } ================================================ FILE: custom_components/echonetlite/quirks/Panasonic/all/0135.py ================================================ from homeassistant.const import CONF_ICON, CONF_NAME from pychonet.lib.epc_functions import _int QUIRKS = { 0xF1: { "EPC_FUNCTION": [ _int, { 0x3C: "High", 0x32: "Medium", 0x28: "Low", 0x00: "Off", }, ], "ENL_OP_CODE": { CONF_NAME: "Humidity setting", CONF_ICON: "mdi:air-humidifier", }, }, } ================================================ FILE: custom_components/echonetlite/select.py ================================================ import logging from homeassistant.const import CONF_ICON, CONF_NAME from homeassistant.components.select import SelectEntity from pychonet.HomeAirConditioner import ( ENL_AIR_HORZ, ENL_AIR_VERT, ENL_AUTO_DIRECTION, ENL_FANSPEED, ENL_HVAC_MODE, ENL_SWING_MODE, ) from . import get_name_by_epc_code, get_device_name from .const import ( CONF_DISABLED_DEFAULT, DOMAIN, CONF_FORCE_POLLING, CONF_ICONS, TYPE_SELECT, NON_SETUP_SINGLE_ENYITY, ) from pychonet.lib.eojx import EOJX_CLASS from pychonet.lib.epc_functions import _swap_dict _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config, async_add_entities, discovery_info=None): entities = [] for entity in hass.data[DOMAIN][config.entry_id]: eojgc = entity["instance"]["eojgc"] eojcc = entity["instance"]["eojcc"] _enl_op_codes = entity["echonetlite"]._enl_op_codes _non_setup_single_entity = NON_SETUP_SINGLE_ENYITY.get(eojgc, {}).get( eojcc, set() ) # configure select entities by looking up full ENL_OP_CODE dict for op_code in list( set(entity["instance"]["setmap"]) - NON_SETUP_SINGLE_ENYITY.get(eojgc, {}).get(eojcc, set()) ): epc_function_data = entity["echonetlite"]._instance.EPC_FUNCTIONS.get( op_code, None ) if op_code in _non_setup_single_entity: continue _by_epc_func = ( type(epc_function_data) == list and type(epc_function_data[1]) == dict and len(epc_function_data[1]) > 2 ) _enl_op_code_dict = _enl_op_codes.get(op_code, {}) if _by_epc_func or TYPE_SELECT in _enl_op_code_dict.keys(): entities.append( EchonetSelect( hass, entity["echonetlite"], config, op_code, _enl_op_code_dict, ) ) async_add_entities(entities, True) class EchonetSelect(SelectEntity): _attr_translation_key = DOMAIN SELECT_USING_USER_OPTIONS = { "0x1-0x30": { ENL_FANSPEED, ENL_SWING_MODE, ENL_AUTO_DIRECTION, ENL_AIR_HORZ, ENL_AIR_VERT, ENL_HVAC_MODE, }, "0x1-0x35": { ENL_FANSPEED, ENL_SWING_MODE, ENL_AUTO_DIRECTION, ENL_AIR_HORZ, ENL_AIR_VERT, }, } def __init__(self, hass, connector, config, code, options): """Initialize the select.""" name = get_device_name(connector, config) self._connector = connector self._config = config self._code = code self._optimistic = False self._server_state = self._connector._api._state[ self._connector._instance._host ] self._sub_state = None if type(options.get(TYPE_SELECT)) == dict: self._options = options[TYPE_SELECT] else: # Read from _instance.EPC FUNCTIONS definition # Swap key, value of _instance.EPC_FUNCTIONS[opc][1] self._options = _swap_dict(connector._instance.EPC_FUNCTIONS[code][1]) self._icons = options.get(CONF_ICONS, {}) self._attr_icon = options.get(CONF_ICON, None) self._icon_default = self._attr_icon self._attr_options = list(self._options.keys()) self._user_option_epcs = self.SELECT_USING_USER_OPTIONS.get( hex(self._connector._instance._eojgc) + "-" + hex(self._connector._instance._eojcc), set(), ).intersection(set(self._connector._user_options.keys())) if self._code in self._user_option_epcs: if self._connector._user_options[code] is not False: self._attr_options = self._connector._user_options[code] self._attr_current_option = self._connector._update_data.get(self._code) self._attr_name = f"{config.title} {get_name_by_epc_code(self._connector._eojgc, self._connector._eojcc, self._code, None, self._connector._enl_op_codes.get(self._code, {}).get(CONF_NAME))}" self._attr_unique_id = ( f"{self._connector._uidi}-{self._code}" if self._connector._uidi else f"{self._connector._uid}-{self._code}" ) self._device_name = name self._attr_should_poll = True self._attr_available = True self._attr_force_update = False self._attr_entity_registry_enabled_default = not bool( options.get(CONF_DISABLED_DEFAULT) ) self.update_option_listener() @property def device_info(self): return { "identifiers": { ( DOMAIN, self._connector._uid, self._connector._instance._eojgc, self._connector._instance._eojcc, self._connector._instance._eojci, ) }, "name": self._device_name, "manufacturer": self._connector._manufacturer + ( " " + self._connector._host_product_code if self._connector._host_product_code else "" ), "model": EOJX_CLASS[self._connector._instance._eojgc][ self._connector._instance._eojcc ], # "sw_version": "", } async def async_select_option(self, option: str): self._attr_current_option = option self.async_schedule_update_ha_state() if not await self._connector._instance.setMessage( self._code, self._options[option] ): # Restore previous state self._attr_current_option = self._connector._update_data.get(self._code) self.async_schedule_update_ha_state() async def async_update(self): """Retrieve latest state.""" try: await self._connector.async_update() except TimeoutError: pass def update_attr(self): self._attr_options = list(self._options.keys()) if self._attr_current_option not in self._attr_options: # maybe data value is raw(int) keys = [ k for k, v in self._options.items() if v == self._attr_current_option ] if keys: self._attr_current_option = keys[0] self._attr_icon = self._icons.get(self._attr_current_option, self._icon_default) if self._code in self._user_option_epcs: if self._connector._user_options[self._code] is not False: self._attr_options = self._connector._user_options[self._code] async def async_added_to_hass(self): """Register callbacks.""" self._connector.add_update_option_listener(self.update_option_listener) self._connector.register_async_update_callbacks(self.async_update_callback) async def async_update_callback(self, isPush: bool = False): new_val = self._connector._update_data.get(self._code) changed = ( new_val is not None and self._attr_current_option != new_val ) or self._attr_available != self._server_state["available"] if changed: _force = bool(not self._attr_available and self._server_state["available"]) self._attr_current_option = new_val if self._attr_available != self._server_state["available"]: if self._server_state["available"]: self.update_option_listener() else: self._attr_should_poll = True self._attr_available = self._server_state["available"] self.update_attr() self.async_schedule_update_ha_state(_force) def update_option_listener(self): _should_poll = self._code not in self._connector._ntfPropertyMap self._attr_should_poll = ( self._connector._user_options.get(CONF_FORCE_POLLING, False) or _should_poll ) self._attr_extra_state_attributes = {"notify": "No" if _should_poll else "Yes"} _LOGGER.debug( f"{self._device_name}({self._code}): _should_poll is {_should_poll}" ) ================================================ FILE: custom_components/echonetlite/sensor.py ================================================ """Support for ECHONETLite sensors.""" import logging import voluptuous as vol from homeassistant.const import ( CONF_ICON, CONF_NAME, CONF_SERVICE, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.exceptions import InvalidStateError, NoEntitySpecifiedError from pychonet.lib.eojx import EOJX_CLASS from pychonet.lib.epc_functions import EPC_SUPER_FUNCTIONS from . import ( get_name_by_epc_code, get_unit_by_devise_class, get_device_name, regist_as_inputs, regist_as_binary_sensor, ) from .const import ( DOMAIN, ENABLE_SUPER_ENERGY_DEFAULT, ENL_OP_CODES, CONF_STATE_CLASS, ENL_SUPER_CODES, ENL_SUPER_ENERGES, NON_SETUP_SINGLE_ENYITY, TYPE_SWITCH, TYPE_SELECT, TYPE_TIME, TYPE_NUMBER, SERVICE_SET_ON_TIMER_TIME, SERVICE_SET_INT_1B, CONF_FORCE_POLLING, CONF_ENABLE_SUPER_ENERGY, TYPE_DATA_DICT, TYPE_DATA_ARRAY_WITH_SIZE_OPCODE, CONF_DISABLED_DEFAULT, CONF_MULTIPLIER, CONF_MULTIPLIER_OPCODE, CONF_MULTIPLIER_OPTIONAL_OPCODE, CONF_ICON_POSITIVE, CONF_ICON_NEGATIVE, CONF_ICON_ZERO, ) _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config, async_add_entities, discovery_info=None): entities = [] platform = entity_platform.async_get_current_platform() for entity in hass.data[DOMAIN][config.entry_id]: _LOGGER.debug(f"Configuring ECHONETLite sensor {entity}") _LOGGER.debug( f"Update flags for this sensor are {entity['echonetlite']._update_flags_full_list}" ) eojgc = entity["instance"]["eojgc"] eojcc = entity["instance"]["eojcc"] if entity["echonetlite"]._user_options.get( CONF_ENABLE_SUPER_ENERGY, ENABLE_SUPER_ENERGY_DEFAULT.get(eojgc, {}).get(eojcc, True), ): _enl_super_codes = ENL_SUPER_CODES else: _enl_super_codes = { k: v for k, v in ENL_SUPER_CODES.items() if not k in ENL_SUPER_ENERGES } _enl_op_codes = entity["echonetlite"]._enl_op_codes | _enl_super_codes _epc_functions = ( entity["echonetlite"]._instance.EPC_FUNCTIONS | EPC_SUPER_FUNCTIONS ) # For all other devices, sensors will be configured but customise if applicable. for op_code in list( set(entity["echonetlite"]._update_flags_full_list) - NON_SETUP_SINGLE_ENYITY.get(eojgc, {}).get(eojcc, set()) ): # Check DeviceClass or regist_as_binary_sensor() if isinstance( _enl_op_codes.get(op_code, {}).get(CONF_TYPE), BinarySensorDeviceClass ) or regist_as_binary_sensor(_epc_functions.get(op_code, None)): continue # Is settable _is_settable = op_code in entity["instance"]["setmap"] # Conf Keys list _keys = _enl_op_codes.get(op_code, {}).keys() # For backward compatibility (Deprecated) _has_conf_service = CONF_SERVICE in _keys # Check this op_code will be configured as input(switch, select ot time) entity if ( _is_settable and not _has_conf_service and regist_as_inputs(_epc_functions.get(op_code, None)) ): continue # Configuration check with ENL_OP_CODE definition if len(_keys): if ( _is_settable and not _has_conf_service and ( TYPE_SWITCH in _keys or TYPE_SELECT in _keys or TYPE_TIME in _keys or TYPE_NUMBER in _keys ) ): continue # dont configure as sensor, it will be configured as switch, select or time instead. # For backward compatibility (Deprecated) if ( _is_settable and _has_conf_service ): # Some devices support advanced service calls. _enl_op_codes[op_code][CONF_DISABLED_DEFAULT] = True for service_name in _enl_op_codes.get(op_code, {}).get( CONF_SERVICE ): if service_name == SERVICE_SET_ON_TIMER_TIME: platform.async_register_entity_service( service_name, {vol.Required("timer_time"): cv.time_period}, "async_" + service_name, ) elif service_name == SERVICE_SET_INT_1B: platform.async_register_entity_service( service_name, { vol.Required("value"): cv.positive_int, vol.Optional( "epc", default=op_code ): cv.positive_int, }, "async_" + service_name, ) if TYPE_DATA_DICT in _keys: type_data = _enl_op_codes.get(op_code, {}).get(TYPE_DATA_DICT) if isinstance(type_data, list): for attr_key in type_data: entities.append( EchonetSensor( entity["echonetlite"], config, op_code, _enl_op_codes.get(op_code) | {"dict_key": attr_key}, hass, ) ) continue else: continue if TYPE_DATA_ARRAY_WITH_SIZE_OPCODE in _keys: array_size_op_code = _enl_op_codes[op_code][ TYPE_DATA_ARRAY_WITH_SIZE_OPCODE ] array_max_size = await entity["echonetlite"]._instance.update( array_size_op_code ) for x in range(0, array_max_size): attr = _enl_op_codes[op_code].copy() attr["accessor_index"] = x attr["accessor_lambda"] = lambda value, index: ( value["values"][index] if index < value["range"] else None ) entities.append( EchonetSensor( entity["echonetlite"], config, op_code, attr, ) ) continue else: entities.append( EchonetSensor( entity["echonetlite"], config, op_code, _enl_op_codes.get( op_code, ENL_OP_CODES["default"] | {CONF_DISABLED_DEFAULT: True}, ), hass, ) ) continue entities.append( EchonetSensor( entity["echonetlite"], config, op_code, ENL_OP_CODES["default"], hass, ) ) async_add_entities(entities, True) class EchonetSensor(SensorEntity): """Representation of an ECHONETLite Temperature Sensor.""" _attr_translation_key = DOMAIN def __init__(self, connector, config, op_code, attributes, hass=None) -> None: """Initialize the sensor.""" name = get_device_name(connector, config) self._connector = connector self._op_code = op_code self._sensor_attributes = attributes self._eojgc = self._connector._eojgc self._eojcc = self._connector._eojcc self._eojci = self._connector._eojci self._attr_unique_id = ( f"{self._connector._uidi}-{self._op_code}" if self._connector._uidi else f"{self._connector._uid}-{self._eojgc}-{self._eojcc}-{self._eojci}-{self._op_code}" ) self._device_name = name self._state_value = None self._server_state = self._connector._api._state[ self._connector._instance._host ] self._hass = hass _attr_keys = self._sensor_attributes.keys() self._attr_icon = self._sensor_attributes.get(CONF_ICON) self._attr_device_class = self._sensor_attributes.get(CONF_TYPE) self._attr_state_class = self._sensor_attributes.get(CONF_STATE_CLASS) # Create name based on sensor description from EPC codes, super class codes or fallback to using the sensor type self._attr_name = f"{name} {get_name_by_epc_code(self._eojgc, self._eojcc, self._op_code, self._attr_device_class, self._connector._enl_op_codes.get(self._op_code, {}).get(CONF_NAME))}" if "dict_key" in _attr_keys: self._attr_unique_id += f'-{self._sensor_attributes["dict_key"]}' if type(self._sensor_attributes[TYPE_DATA_DICT]) == int: # As of Version 3.8.0, no configuration is defined that uses this definition. self._attr_name += f' {str(self._sensor_attributes["accessor_index"] + 1).zfill(len(str(self._sensor_attributes[TYPE_DATA_DICT])))}' else: self._attr_name += f' {self._sensor_attributes["dict_key"]}' if "accessor_index" in _attr_keys: self._attr_unique_id += f'-{self._sensor_attributes["accessor_index"]}' self._attr_name += f' {str(self._sensor_attributes["accessor_index"] + 1).zfill(len(str(self._sensor_attributes[TYPE_DATA_ARRAY_WITH_SIZE_OPCODE])))}' self._attr_native_unit_of_measurement = self._sensor_attributes.get( CONF_UNIT_OF_MEASUREMENT ) if not self._attr_native_unit_of_measurement: self._attr_native_unit_of_measurement = get_unit_by_devise_class( self._attr_device_class ) self._attr_entity_registry_enabled_default = not bool( self._sensor_attributes.get(CONF_DISABLED_DEFAULT) ) self._attr_should_poll = True self._attr_available = True self.update_option_listener() @property def device_info(self): return { "identifiers": { ( DOMAIN, self._connector._uid, self._connector._eojgc, self._connector._eojcc, self._connector._eojci, ) }, "name": self._device_name, "manufacturer": self._connector._manufacturer + ( " " + self._connector._host_product_code if self._connector._host_product_code else "" ), "model": EOJX_CLASS[self._eojgc][self._eojcc], # "sw_version": "", } def get_attr_native_value(self): """Return the state of the sensor.""" if self._op_code in self._connector._update_data: new_val = self._connector._update_data[self._op_code] if "dict_key" in self._sensor_attributes: if hasattr(new_val, "get"): self._state_value = new_val.get(self._sensor_attributes["dict_key"]) else: self._state_value = None elif "accessor_lambda" in self._sensor_attributes: self._state_value = self._sensor_attributes["accessor_lambda"]( new_val, self._sensor_attributes["accessor_index"] ) else: self._state_value = new_val if self._state_value is None: return None # interactive icon if CONF_ICON_POSITIVE in self._sensor_attributes: if self._state_value is None and self._state_value > 0: self._sensor_attributes[CONF_ICON] = self._sensor_attributes[ CONF_ICON_POSITIVE ] elif self._state_value is None and self._state_value < 0: self._sensor_attributes[CONF_ICON] = self._sensor_attributes[ CONF_ICON_NEGATIVE ] else: self._sensor_attributes[CONF_ICON] = self._sensor_attributes[ CONF_ICON_ZERO ] # apply coefficients if ( CONF_MULTIPLIER in self._sensor_attributes or CONF_MULTIPLIER_OPCODE in self._sensor_attributes or CONF_MULTIPLIER_OPTIONAL_OPCODE in self._sensor_attributes ): new_val = self._state_value if CONF_MULTIPLIER in self._sensor_attributes: new_val = new_val * self._sensor_attributes[CONF_MULTIPLIER] if CONF_MULTIPLIER_OPCODE in self._sensor_attributes: multiplier_opcode = self._sensor_attributes[CONF_MULTIPLIER_OPCODE] if ( multiplier_opcode in self._connector._update_data and self._connector._update_data[multiplier_opcode] is not None ): new_val = ( new_val * self._connector._update_data[multiplier_opcode] ) else: return None if CONF_MULTIPLIER_OPTIONAL_OPCODE in self._sensor_attributes: multiplier_opcode = self._sensor_attributes[ CONF_MULTIPLIER_OPTIONAL_OPCODE ] if ( multiplier_opcode in self._connector._update_data and self._connector._update_data[multiplier_opcode] is not None ): new_val = ( new_val * self._connector._update_data[multiplier_opcode] ) return new_val elif self._attr_device_class in [ SensorDeviceClass.TEMPERATURE, SensorDeviceClass.HUMIDITY, ]: if self._state_value in [126, 253]: return None else: return self._state_value elif self._attr_device_class == SensorDeviceClass.POWER: # Underflow (less than 1 W) if self._state_value == 65534: return 1 else: return self._state_value elif self._op_code in self._connector._update_data: if isinstance(self._state_value, (int, float)): return self._state_value if len(self._state_value) < 255: return self._state_value else: return None return None async def async_update(self): """Retrieve latest state.""" try: await self._connector.async_update() self._attr_native_value = self.get_attr_native_value() except TimeoutError: pass async def async_set_on_timer_time(self, timer_time): val = str(timer_time).split(":") mes = {"EPC": 0x91, "PDC": 0x02, "EDT": int(val[0]) * 256 + int(val[1])} if await self._connector._instance.setMessages([mes]): pass else: raise InvalidStateError( "The state setting is not supported or is an invalid value." ) async def async_set_value_int_1b(self, value, epc=None): if epc: value = int(value) if await self._connector._instance.setMessage(epc, value): pass else: raise InvalidStateError( "The state setting is not supported or is an invalid value." ) else: raise NoEntitySpecifiedError( "The required parameter EPC has not been specified." ) async def async_added_to_hass(self): """Register callbacks.""" self._connector.add_update_option_listener(self.update_option_listener) self._connector.register_async_update_callbacks(self.async_update_callback) async def async_update_callback(self, isPush: bool = False): new_val = self._connector._update_data.get(self._op_code) if "dict_key" in self._sensor_attributes: if hasattr(new_val, "get"): new_val = new_val.get(self._sensor_attributes["dict_key"]) else: new_val = None if "accessor_lambda" in self._sensor_attributes: new_val = self._sensor_attributes["accessor_lambda"]( new_val, self._sensor_attributes["accessor_index"] ) changed = ( new_val is not None and self._state_value != new_val ) or self._attr_available != self._server_state["available"] if changed: _force = bool(not self._attr_available and self._server_state["available"]) self._state_value = new_val self._attr_native_value = self.get_attr_native_value() if self._attr_available != self._server_state["available"]: if self._server_state["available"]: self.update_option_listener() else: self._attr_should_poll = True self._attr_available = self._server_state["available"] self.async_schedule_update_ha_state(_force) def update_option_listener(self): _should_poll = self._op_code not in self._connector._ntfPropertyMap self._attr_should_poll = ( self._connector._user_options.get(CONF_FORCE_POLLING, False) or _should_poll ) self._attr_extra_state_attributes = {"notify": "No" if _should_poll else "Yes"} _LOGGER.debug( f"{self._attr_name}({self._op_code}): _should_poll is {_should_poll}" ) ================================================ FILE: custom_components/echonetlite/services.yaml ================================================ set_on_timer_time: description: 'Set the time of the on-timer of the device.' target: entity: domain: sensor integration: echonetlite device: integration: echonetlite fields: timer_time: description: 'Time notation in "hour:minute" format.' example: '"21:00"' required: true entity_id: description: 'On-timer time sensor entity ID.' example: '"sensor.echonetlite_set_value_of_on_timer_time"' set_value_int_1b: description: 'Sets the specified sensor entity to an integer value.' target: entity: domain: sensor integration: echonetlite fields: value: description: 'Valid integer value for the specified entity.' example: '42' required: true entity_id: description: 'Integer sensor entity ID.' example: '"sensor.echonetlite_set_value_of_hot_water_temperature"' ================================================ FILE: custom_components/echonetlite/strings.json ================================================ { "config": { "step": { "user": { "title": "Device IP address and name setting (Auto Discovery)", "description": "For automatic detection, after pressing the Submit button, please perform operations such as turning the device on or off within 30 seconds. If you know the IP address, enter the IP address and name.", "data": { "host": "[%key:common::config_flow::data::host%]", "title": "[%key:common::config_flow::data::title%]" } }, "user_man": { "title": "Device IP address and name setting", "description": "", "data": { "host": "[%key:common::config_flow::data::host%]", "title": "[%key:common::config_flow::data::title%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", "not_found": "Device could not be detected.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, "progress": { "discover": "Discovering ECHONETLite devices.." } }, "options": { "error": { "invalid_path": "Invalid Setting" }, "step": { "init": { "title": "ECHONET Lite HVAC Options", "data": { "fan_settings": "Configure fan settings", "swing_horiz": "Configure Horizontal Swing Settings", "swing_vert": "Configure Vertical Swing Settings", "auto_direction": "Configure Auto Direction", "swing_mode": "Configure Swing Mode", "min_temp_heat": "Configure Minimum Temperature for Heating Operation", "max_temp_heat": "Configure Maximum Temperature for Heating Operation", "min_temp_cool": "Configure Minimum Temperature for Cooling Operation", "max_temp_cool": "Configure Maximum Temperature for Cooling Operation", "min_temp_auto": "Configure Minimum Temperature for Automatic Operation", "max_temp_auto": "Configure Maximum Temperature for Automatic Operation" }, "description": "Configure Fan Settings" } } }, "entity": { "select": { "echonetlite": { "state": { "auto": "Auto", "minimum": "Minimum", "low": "Low", "medium-low": "Medium-Low", "medium": "Medium", "medium-high": "Medium-High", "high": "High", "very-high": "Very-High", "max": "Max", "rc-right": "Right Center + Right", "left-lc": "Left + Left Center", "lc-center-rc": "Left + Center + Right Center", "left-lc-rc-right": "Left + Left Center + Right Center + Right", "right": "Right", "rc": "Right Center", "center": "Center", "center-right": "Center + Right", "center-rc": "Center + Right Center", "center-rc-right": "Center + Right Center + Right", "lc": "Left Center", "lc-right": "Left Center + Right", "lc-rc": "Left Center + Right Center", "lc-rc-right": "Left Center + Right Center + Right", "lc-center": "Left Center + Center", "lc-center-right": "Left Center + Center + Right", "lc-center-rc-right": "Left Center + Center + Right Center + Right", "left": "Left", "left-right": "Left + Right", "left-rc": "Left + Right Center", "left-rc-right": "Left + Right Center + Right", "left-center": "Left + Center", "left-center-right": "Left + Center + Right", "left-center-rc": "Left + Center + Right Center", "left-center-rc-right": "Left + Center + Right Center + Right", "left-lc-right": "Left + Left Center + Right", "left-lc-rc": "Left + Left Center + Right Center", "left-lc-center": "Left + Left Center + Center", "left-lc-center-right": "Left + Left Center + Center + Right", "left-lc-center-rc": "Left + Left Center + Center + Right Center", "left-lc-center-rc-right": "Left + Left Center + Center + Right Center + Right", "upper": "Upper", "upper-central": "Upper Central", "central": "Central", "lower-central": "Lower Central", "lower": "Lower", "not-used": "Not Used (Off)", "vert": "Vertical", "horiz": "Horizontal", "vert-horiz": "Vertical-Horizontal", "non-auto": "Non-Auto", "auto-vert": "Auto Vertical", "auto-horiz": "Auto Horizontal" } } }, "climate": { "echonetlite": { "state_attributes": { "fan_mode": { "state": { "auto": "Auto", "minimum": "Minimum", "low": "Low", "medium-low": "Medium-Low", "medium": "Medium", "medium-high": "Medium-High", "high": "High", "very-high": "Very-High", "max": "Max" } }, "swing_mode": { "name": "Air direction", "state": { "rc-right": "Right Center + Right", "left-lc": "Left + Left Center", "lc-center-rc": "Left + Center + Right Center", "left-lc-rc-right": "Left + Left Center + Right Center + Right", "right": "Right", "rc": "Right Center", "center": "Center", "center-right": "Center + Right", "center-rc": "Center + Right Center", "center-rc-right": "Center + Right Center + Right", "lc": "Left Center", "lc-right": "Left Center + Right", "lc-rc": "Left Center + Right Center", "lc-rc-right": "Left Center + Right Center + Right", "lc-center": "Left Center + Center", "lc-center-right": "Left Center + Center + Right", "lc-center-rc-right": "Left Center + Center + Right Center + Right", "left": "Left", "left-right": "Left + Right", "left-rc": "Left + Right Center", "left-rc-right": "Left + Right Center + Right", "left-center": "Left + Center", "left-center-right": "Left + Center + Right", "left-center-rc": "Left + Center + Right Center", "left-center-rc-right": "Left + Center + Right Center + Right", "left-lc-right": "Left + Left Center + Right", "left-lc-rc": "Left + Left Center + Right Center", "left-lc-center": "Left + Left Center + Center", "left-lc-center-right": "Left + Left Center + Center + Right", "left-lc-center-rc": "Left + Left Center + Center + Right Center", "left-lc-center-rc-right": "Left + Left Center + Center + Right Center + Right", "upper": "Upper", "upper-central": "Upper Central", "central": "Central", "lower-central": "Lower Central", "lower": "Lower", "not-used": "Not Used (Off)", "vert": "Vertical", "horiz": "Horizontal", "vert-horiz": "Vertical-Horizontal", "auto": "Auto", "non-auto": "Non-Auto", "auto-vert": "Auto Vertical", "auto-horiz": "Auto Horizontal" } } } } } } } ================================================ FILE: custom_components/echonetlite/switch.py ================================================ import asyncio import logging from homeassistant.const import CONF_ICON, CONF_SERVICE_DATA, CONF_NAME from homeassistant.components.switch import SwitchEntity from . import get_name_by_epc_code, get_device_name from .const import ( CONF_DISABLED_DEFAULT, DOMAIN, CONF_ON_VALUE, CONF_OFF_VALUE, DATA_STATE_ON, DATA_STATE_OFF, NON_SETUP_SINGLE_ENYITY, SWITCH_POWER, CONF_ENSURE_ON, TYPE_SWITCH, TYPE_NUMBER, ENL_STATUS, CONF_FORCE_POLLING, ) from pychonet.lib.eojx import EOJX_CLASS _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config, async_add_entities, discovery_info=None): entities = [] for entity in hass.data[DOMAIN][config.entry_id]: eojgc = entity["instance"]["eojgc"] eojcc = entity["instance"]["eojcc"] set_enl_status = False _enl_op_codes = entity["echonetlite"]._enl_op_codes # configure switch entities by looking up full ENL_OP_CODE dict for op_code in list( set(entity["instance"]["setmap"]) - NON_SETUP_SINGLE_ENYITY.get(eojgc, {}).get(eojcc, set()) ): epc_function_data = entity["echonetlite"]._instance.EPC_FUNCTIONS.get( op_code, None ) _by_epc_func = ( type(epc_function_data) == list and type(epc_function_data[1]) == dict and len(epc_function_data[1]) == 2 ) _enl_op_code_dict = _enl_op_codes.get(op_code, {}) if _by_epc_func or TYPE_SWITCH in _enl_op_code_dict.keys(): entities.append( EchonetSwitch( hass, entity["echonetlite"], config, op_code, _enl_op_code_dict, ) ) if op_code == ENL_STATUS: set_enl_status = True if ( switch_conf := _enl_op_codes.get(op_code, {}) .get(TYPE_NUMBER, {}) .get(TYPE_SWITCH) ): switch_conf.update(_enl_op_codes[op_code].copy()) entities.append( EchonetSwitch( hass, entity["echonetlite"], config, op_code, switch_conf, ) ) # Auto configure of the power switch if (eojgc == 0x01 and eojcc in (0x30, 0x35)) or ( eojgc == 0x02 and eojcc in (0x90, 0x91) ): # Home air conditioner, Air cleaner, General Lighting, Single Function Lighting continue if not set_enl_status and ENL_STATUS in entity["instance"]["setmap"]: entities.append( EchonetSwitch( hass, entity["echonetlite"], config, ENL_STATUS, { CONF_ICON: "mdi:power-settings", CONF_SERVICE_DATA: SWITCH_POWER, }, ) ) async_add_entities(entities, True) class EchonetSwitch(SwitchEntity): def __init__(self, hass, connector, config, code, options): """Initialize the switch.""" name = get_device_name(connector, config) self._connector = connector self._config = config self._code = code self._options = options epc_function_data = connector._instance.EPC_FUNCTIONS.get(code, None) if type(epc_function_data) == list: data_keys = list(epc_function_data[1].keys()) data_items = list(epc_function_data[1].values()) self._options.update( { CONF_SERVICE_DATA: { DATA_STATE_ON: data_keys[0], DATA_STATE_OFF: data_keys[1], }, CONF_ON_VALUE: data_items[0], CONF_OFF_VALUE: data_items[1], } ) self._on_value = self._options.get(CONF_ON_VALUE, DATA_STATE_ON) self._on_vals = [ self._on_value, self._options[CONF_SERVICE_DATA][DATA_STATE_ON], hex(self._options[CONF_SERVICE_DATA][DATA_STATE_ON])[2:], ] self._from_number = True if options.get(TYPE_NUMBER) else False self._attr_name = f"{config.title} {get_name_by_epc_code(self._connector._eojgc, self._connector._eojcc, self._code, None, self._connector._enl_op_codes.get(self._code, {}).get(CONF_NAME))}" self._attr_icon = options.get(CONF_ICON) self._attr_unique_id = ( f"{self._connector._uidi}-{self._code}" if self._connector._uidi else f"{self._connector._uid}-{self._connector._eojgc}-{self._connector._eojcc}-{self._connector._eojci}-{self._code}" ) if self._from_number: self._attr_unique_id += "-switch" self._attr_name += " " + options.get(CONF_NAME, "Switch") self._device_name = name self._server_state = self._connector._api._state[ self._connector._instance._host ] self._attr_is_on = self._connector._update_data[self._code] in self._on_vals self._attr_should_poll = True self._attr_available = True self._attr_entity_registry_enabled_default = not bool( options.get(CONF_DISABLED_DEFAULT) ) self.update_option_listener() @property def device_info(self): return { "identifiers": { ( DOMAIN, self._connector._uid, self._connector._instance._eojgc, self._connector._instance._eojcc, self._connector._instance._eojci, ) }, "name": self._device_name, "manufacturer": self._connector._manufacturer + ( " " + self._connector._host_product_code if self._connector._host_product_code else "" ), "model": EOJX_CLASS[self._connector._instance._eojgc][ self._connector._instance._eojcc ], } async def async_turn_on(self, **kwargs) -> None: """Turn switch on.""" main_sw_code = None # Check ensure turn on switch. # For some devices this ensures the main switch is switched on firs if CONF_ENSURE_ON in self._options: main_sw_code = self._options[CONF_ENSURE_ON] # Turn on the specified switch if ( main_sw_code is not None and self._connector._update_data[main_sw_code] != DATA_STATE_ON ): if not await self._connector._instance.setMessage( main_sw_code, SWITCH_POWER[DATA_STATE_ON] ): # Can't turn on main switch return # Wait about 2 seconds until the On state is stabilized on the device side await asyncio.sleep(2) if ( main_sw_code is None or self._connector._update_data[main_sw_code] == DATA_STATE_ON ): await self._connector._instance.setMessage( self._code, self._options[CONF_SERVICE_DATA][DATA_STATE_ON] ) async def async_turn_off(self, **kwargs) -> None: """Turn switch off.""" await self._connector._instance.setMessage( self._code, self._options[CONF_SERVICE_DATA][DATA_STATE_OFF] ) async def async_update(self): """Retrieve latest state.""" try: await self._connector.async_update() except TimeoutError: pass async def async_added_to_hass(self): """Register callbacks.""" self._connector.add_update_option_listener(self.update_option_listener) self._connector.register_async_update_callbacks(self.async_update_callback) async def async_update_callback(self, isPush: bool = False): new_val = self._connector._update_data[self._code] in self._on_vals changed = ( self._attr_is_on != new_val or self._attr_available != self._server_state["available"] ) if changed: _force = bool(not self._attr_available and self._server_state["available"]) self._attr_is_on = new_val if self._attr_available != self._server_state["available"]: if self._server_state["available"]: self.update_option_listener() else: self._attr_should_poll = True self._attr_available = self._server_state["available"] self.async_schedule_update_ha_state(_force) def update_option_listener(self): _should_poll = self._code not in self._connector._ntfPropertyMap self._attr_should_poll = ( self._connector._user_options.get(CONF_FORCE_POLLING, False) or _should_poll ) self._attr_extra_state_attributes = {"notify": "No" if _should_poll else "Yes"} _LOGGER.debug( f"{self._device_name}({self._code}): _should_poll is {_should_poll}" ) ================================================ FILE: custom_components/echonetlite/time.py ================================================ import logging import datetime from datetime import time from homeassistant.const import CONF_ICON, CONF_NAME from homeassistant.components.time import TimeEntity from homeassistant.exceptions import InvalidStateError from . import get_name_by_epc_code, get_device_name from .const import ( CONF_DISABLED_DEFAULT, DOMAIN, CONF_FORCE_POLLING, ENL_SUPER_CODES, NON_SETUP_SINGLE_ENYITY, TYPE_TIME, ) from pychonet.lib.eojx import EOJX_CLASS from pychonet.lib.epc_functions import _hh_mm _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config, async_add_entities, discovery_info=None): entities = [] for entity in hass.data[DOMAIN][config.entry_id]: eojgc = entity["instance"]["eojgc"] eojcc = entity["instance"]["eojcc"] _enl_op_codes = entity["echonetlite"]._enl_op_codes | ENL_SUPER_CODES # configure select entities by looking up full ENL_OP_CODE dict for op_code in list( set(entity["instance"]["setmap"]) - NON_SETUP_SINGLE_ENYITY.get(eojgc, {}).get(eojcc, set()) ): epc_function_data = entity["echonetlite"]._instance.EPC_FUNCTIONS.get( op_code, None ) _by_epc_func = ( type(epc_function_data) == list and epc_function_data[0] == _hh_mm ) or (callable(epc_function_data) and epc_function_data == _hh_mm) if _by_epc_func or TYPE_TIME in _enl_op_codes.get(op_code, {}).keys(): entities.append( EchonetTime( hass, entity["echonetlite"], config, op_code, _enl_op_codes.get(op_code, {}), ) ) async_add_entities(entities, True) class EchonetTime(TimeEntity): _attr_translation_key = DOMAIN def __init__(self, hass, connector, config, code, options, device_name=None): """Initialize the time.""" self._connector = connector self._config = config self._code = code self._server_state = self._connector._api._state[ self._connector._instance._host ] self._attr_icon = options.get(CONF_ICON, None) self._attr_name = f"{config.title} {get_name_by_epc_code(self._connector._eojgc, self._connector._eojcc, self._code, None, self._connector._enl_op_codes.get(self._code, {}).get(CONF_NAME))}" self._attr_unique_id = ( f"{self._connector._uidi}-{self._code}" if self._connector._uidi else f"{self._connector._uid}-{self._code}" ) self._device_name = get_device_name(connector, config) self._attr_native_value = self.get_time() self._attr_should_poll = True self._attr_available = True self._attr_entity_registry_enabled_default = not bool( options.get(CONF_DISABLED_DEFAULT) ) self.update_option_listener() @property def device_info(self): return { "identifiers": { ( DOMAIN, self._connector._uid, self._connector._instance._eojgc, self._connector._instance._eojcc, self._connector._instance._eojci, ) }, "name": self._device_name, "manufacturer": self._connector._manufacturer + ( " " + self._connector._host_product_code if self._connector._host_product_code else "" ), "model": EOJX_CLASS[self._connector._instance._eojgc][ self._connector._instance._eojcc ], # "sw_version": "", } def get_time(self): hh_mm = self._connector._update_data.get(self._code) if hh_mm != None: val = hh_mm.split(":") time_obj = datetime.time(int(val[0]), int(val[1])) else: time_obj = None return time_obj async def async_set_value(self, value: time) -> None: """Update the current value.""" h = int(value.hour) m = int(value.minute) mes = {"EPC": self._code, "PDC": 0x02, "EDT": h * 256 + m} if await self._connector._instance.setMessages([mes]): pass else: raise InvalidStateError( "The state setting is not supported or is an invalid value." ) async def async_update(self): """Retrieve latest state.""" try: await self._connector.async_update() except TimeoutError: pass async def async_added_to_hass(self): """Register callbacks.""" self._connector.add_update_option_listener(self.update_option_listener) self._connector.register_async_update_callbacks(self.async_update_callback) async def async_update_callback(self, isPush: bool = False): new_val = self.get_time() changed = ( self._attr_native_value != new_val or self._attr_available != self._server_state["available"] ) if changed: _force = bool(not self._attr_available and self._server_state["available"]) self._attr_native_value = new_val if self._attr_available != self._server_state["available"]: if self._server_state["available"]: self.update_option_listener() else: self._attr_should_poll = True self._attr_available = self._server_state["available"] self.async_schedule_update_ha_state(_force) def update_option_listener(self): _should_poll = self._code not in self._connector._ntfPropertyMap self._attr_should_poll = ( self._connector._user_options.get(CONF_FORCE_POLLING, False) or _should_poll ) self._attr_extra_state_attributes = {"notify": "No" if _should_poll else "Yes"} _LOGGER.debug( f"{self._device_name}({self._code}): _should_poll is {_should_poll}" ) ================================================ FILE: custom_components/echonetlite/translations/en.json ================================================ { "config": { "abort": { "already_configured": "Device is already configured" }, "error": { "cannot_connect": "Failed to connect.", "invalid_auth": "Invalid authentication.", "unknown": "Unexpected error.", "not_found": "Device could not be detected.", "already_configured": "That IP's Device is already configured.", "cannot_get_property_maps": "Could not get the property maps required for operation." }, "step": { "user": { "title": "Device IP address and name setting (Auto Discovery)", "description": "For automatic detection, after pressing the Submit button, please perform operations such as turning the device on or off within 30 seconds. If you know the IP address, enter the IP address and name.", "data": { "host": "Host (IP Address)", "title": "Device Name" } }, "user_man": { "title": "Device IP address and name setting", "description": "", "data": { "host": "Host (IP Address)", "title": "Device Name" } } }, "progress": { "discover": "Discovering ECHONETLite devices.." } }, "options": { "error": { "invalid_path": "The path provided is not valid. Should be in the format `user/repo-name` and should be a valid github repository." }, "step": { "init": { "title": "ECHONETLite Options", "data": { "other_mode": "Handling of operation mode \"Other\"", "fan_settings": "Configure Fan settings", "swing_horiz": "Configure Horizontal Swing Settings", "swing_vert": "Configure Vertical Swing Settings", "auto_direction": "Configure Auto Direction", "swing_mode": "Configure Swing Mode", "ha_ui_swing": "Configure Swing Mode for Climate entity UI", "min_temp_heat": "Configure Minimum Temperature for Heating Operation", "max_temp_heat": "Configure Maximum Temperature for Heating Operation", "min_temp_cool": "Configure Minimum Temperature for Cooling Operation", "max_temp_cool": "Configure Maximum Temperature for Cooling Operation", "min_temp_auto": "Configure Minimum Temperature for Automatic Operation", "max_temp_auto": "Configure Maximum Temperature for Automatic Operation", "force_polling": "Do not stop polling even if immediate notification is expected", "super_energy": "Enable energy-related sensors (if available)", "batch_size_max": "Maximum number of properties for batch requests" }, "description": "Configure optional settings" } } }, "entity": { "sensor": { "echonetlite": { "state": { "stopped": "Stopped", "supplying hot water": "Supplying Hot Water", "keeping bath temperature": "Keeping Bath Temperature", "heating": "Heating", "not heating": "Not Heating" } } }, "select": { "echonetlite": { "state": { "auto": "Auto", "minimum": "Minimum", "low": "Low", "medium-low": "Medium-Low", "medium": "Medium", "medium-high": "Medium-High", "high": "High", "very-high": "Very-High", "max": "Max", "rc-right": "> >>", "left-lc": "<< <", "lc-center-rc": "< || >", "left-lc-rc-right": "<< < > >>", "right": ">>", "rc": ">", "center": "||", "center-right": "|| >>", "center-rc": "|| >", "center-rc-right": "|| > >>", "lc": "<", "lc-right": "< >>", "lc-rc": "< >", "lc-rc-right": "< > >>", "lc-center": "< ||", "lc-center-right": "< || >>", "lc-center-rc-right": "< || > >>", "left": "<<", "left-right": "<< >>", "left-rc": "<< >", "left-rc-right": "<< > >>", "left-center": "<< ||", "left-center-right": "<< || >>", "left-center-rc": "<< || >", "left-center-rc-right": "<< || > >>", "left-lc-right": "<< < >>", "left-lc-rc": "<< < >", "left-lc-center": "<< < ||", "left-lc-center-right": "<< < || >>", "left-lc-center-rc": "<< < || >", "left-lc-center-rc-right": "<< < || > >>", "upper": "Upper", "upper-central": "Upper Central", "central": "Central", "lower-central": "Lower Central", "lower": "Lower", "not-used": "Not Used (Off)", "vert": "Vertical", "horiz": "Horizontal", "vert-horiz": "Vertical-Horizontal", "non-auto": "Fixed", "auto-vert": "Auto Vertical", "auto-horiz": "Auto Horizontal" } } }, "climate": { "echonetlite": { "state_attributes": { "fan_mode": { "state": { "auto": "Auto", "minimum": "Minimum", "low": "Low", "medium-low": "Medium-Low", "medium": "Medium", "medium-high": "Medium-High", "high": "High", "very-high": "Very-High", "max": "Max" } }, "swing_mode": { "name": "Air direction", "state": { "rc-right": "> >>", "left-lc": "<< <", "lc-center-rc": "< || >", "left-lc-rc-right": "<< < > >>", "right": ">>", "rc": ">", "center": "||", "center-right": "|| >>", "center-rc": "|| >", "center-rc-right": "|| > >>", "lc": "<", "lc-right": "< >>", "lc-rc": "< >", "lc-rc-right": "< > >>", "lc-center": "< ||", "lc-center-right": "< || >>", "lc-center-rc-right": "< || > >>", "left": "<<", "left-right": "<< >>", "left-rc": "<< >", "left-rc-right": "<< > >>", "left-center": "<< ||", "left-center-right": "<< || >>", "left-center-rc": "<< || >", "left-center-rc-right": "<< || > >>", "left-lc-right": "<< < >>", "left-lc-rc": "<< < >", "left-lc-center": "<< < ||", "left-lc-center-right": "<< < || >>", "left-lc-center-rc": "<< < || >", "left-lc-center-rc-right": "<< < || > >>", "upper": "Upper", "upper-central": "Upper Central", "central": "Central", "lower-central": "Lower Central", "lower": "Lower", "not-used": "Not Used (Off)", "vert": "Vertical", "horiz": "Horizontal", "vert-horiz": "Vertical-Horizontal", "auto": "Auto", "non-auto": "Non-Auto", "auto-vert": "Auto Vertical", "auto-horiz": "Auto Horizontal" } } } } } }, "title": "ECHONETLite" } ================================================ FILE: custom_components/echonetlite/translations/ja.json ================================================ { "config": { "abort": { "already_configured": "デバイスは構成済みです" }, "error": { "cannot_connect": "接続できません。", "invalid_auth": "認証が無効です。", "unknown": "予期しないエラーです。", "not_found": "機器を検出できませんでした。", "already_configured": "その IP のデバイスは既に構成されています。", "cannot_get_property_maps": "操作に必要なプロパティマップの取得ができませんでした。" }, "step": { "user": { "title": "機器IPアドレスと名称の設定 (自動検出)", "description": "自動検出のため、送信ボタンを押してから30秒以内に電源のON/OFFなどの操作を行ってください。IPアドレスが分かっている場合は、IPアドレスと名称を入力し直してください。", "data": { "host": "ホスト (IP アドレス)", "title": "機器名称" } }, "user_man": { "title": "機器IPアドレスと名称の設定", "description": "", "data": { "host": "ホスト (IP アドレス)", "title": "機器名称" } } }, "progress": { "discover": "ECHONETLite 機器を探しています.." } }, "options": { "error": { "invalid_path": "指定されたパスが無効です。`ユーザー名/リポジトリ名` の形式の有効な GitHub リポジトリである必要があります。" }, "step": { "init": { "title": "ECHONET Lite オプション", "data": { "other_mode": "運転モード「その他」の取り扱い", "fan_settings": "風量設定の構成", "swing_horiz": "風向左右設定の構成", "swing_vert": "風向上下設定の構成", "auto_direction": "自動風向の構成", "swing_mode": "スイングモードの構成", "ha_ui_swing": "エアコンエンティティUIでのスイングモードの構成", "min_temp_heat": "暖房時の最低温度設定", "max_temp_heat": "暖房時の最高温度設定", "min_temp_cool": "冷房時の最低温度設定", "max_temp_cool": "冷房時の最高温度設定", "min_temp_auto": "自動モード時の最低温度設定", "max_temp_auto": "自動モード時の最高温度設定", "force_polling": "即時通知が見込める場合でもポーリングを止めない", "super_energy": "エネルギー関連のセンサーを有効にする(取得可能な場合)", "batch_size_max": "バッチリクエストの最大プロパティ数" }, "description": "オプション設定を構成する" } } }, "entity": { "sensor": { "echonetlite": { "state": { "Stopped": "停止", "Supplying Hot Water": "湯はり中", "Keeping Bath Temperature": "保温中", "heating": "加温中", "not heating": "停止" } } }, "select": { "echonetlite": { "state": { "auto": "自動", "minimum": "微風", "low": "弱", "medium-low": "中弱", "medium": "中", "medium-high": "中強", "high": "強", "very-high": "強々", "max": "最大", "rc-right": "> >>", "left-lc": "<< <", "lc-center-rc": "< || >", "left-lc-rc-right": "<< < > >>", "right": ">>", "rc": ">", "center": "||", "center-right": "|| >>", "center-rc": "|| >", "center-rc-right": "|| > >>", "lc": "<", "lc-right": "< >>", "lc-rc": "< >", "lc-rc-right": "< > >>", "lc-center": "< ||", "lc-center-right": "< || >>", "lc-center-rc-right": "< || > >>", "left": "<<", "left-right": "<< >>", "left-rc": "<< >", "left-rc-right": "<< > >>", "left-center": "<< ||", "left-center-right": "<< || >>", "left-center-rc": "<< || >", "left-center-rc-right": "<< || > >>", "left-lc-right": "<< < >>", "left-lc-rc": "<< < >", "left-lc-center": "<< < ||", "left-lc-center-right": "<< < || >>", "left-lc-center-rc": "<< < || >", "left-lc-center-rc-right": "<< < || > >>", "upper": "上", "upper-central": "中上", "central": "中", "lower-central": "中下", "lower": "下", "not-used": "スイングなし", "vert": "上下スイング", "horiz": "左右スイング", "vert-horiz": "上下左右スイング", "non-auto": "固定", "auto-vert": "上下自動", "auto-horiz": "左右自動" } } }, "climate": { "echonetlite": { "state_attributes": { "fan_mode": { "state": { "auto": "自動", "minimum": "微風", "low": "弱", "medium-low": "中弱", "medium": "中", "medium-high": "中強", "high": "強", "very-high": "強々", "max": "最大" } }, "swing_mode": { "name": "風向設定", "state": { "rc-right": "> >>", "left-lc": "<< <", "lc-center-rc": "< || >", "left-lc-rc-right": "<< < > >>", "right": ">>", "rc": ">", "center": "||", "center-right": "|| >>", "center-rc": "|| >", "center-rc-right": "|| > >>", "lc": "<", "lc-right": "< >>", "lc-rc": "< >", "lc-rc-right": "< > >>", "lc-center": "< ||", "lc-center-right": "< || >>", "lc-center-rc-right": "< || > >>", "left": "<<", "left-right": "<< >>", "left-rc": "<< >", "left-rc-right": "<< > >>", "left-center": "<< ||", "left-center-right": "<< || >>", "left-center-rc": "<< || >", "left-center-rc-right": "<< || > >>", "left-lc-right": "<< < >>", "left-lc-rc": "<< < >", "left-lc-center": "<< < ||", "left-lc-center-right": "<< < || >>", "left-lc-center-rc": "<< < || >", "left-lc-center-rc-right": "<< < || > >>", "upper": "上", "upper-central": "中上", "central": "中", "lower-central": "中下", "lower": "下", "not-used": "スイングなし", "vert": "上下スイング", "horiz": "左右スイング", "vert-horiz": "上下左右スイング", "auto": "自動", "non-auto": "手動", "auto-vert": "上下自動", "auto-horiz": "左右自動" } } } } } }, "title": "ECHONETLite" } ================================================ FILE: custom_components/echonetlite/translations/pt.json ================================================ { "config": { "abort": { "already_configured": "Dispositivo já configurado" }, "error": { "cannot_connect": "Falha ao ligar.", "invalid_auth": "Autenticação inválida.", "unknown": "Erro inesperado.", "not_found": "Não foi possível detetar o dispositivo.", "already_configured": "O dispositivo com esse IP já está configurado.", "cannot_get_property_maps": "Não foi possível obter os mapas de propriedades necessários para a operação." }, "step": { "user": { "title": "Definição de endereço IP e nome do dispositivo (Descoberta Automática)", "description": "Para deteção automática, após pressionar o botão Enviar, efetue operações como ligar ou desligar o dispositivo dentro de 30 segundos. Se souber o endereço IP, introduza o endereço IP e o nome.", "data": { "host": "Anfitrião (Endereço IP)", "title": "Nome do Dispositivo" } }, "user_man": { "title": "Definição de endereço IP e nome do dispositivo", "description": "", "data": { "host": "Anfitrião (Endereço IP)", "title": "Nome do Dispositivo" } } }, "progress": { "discover": "A descobrir dispositivos ECHONETLite.." } }, "options": { "error": { "invalid_path": "O caminho fornecido não é válido. Deve estar no formato `utilizador/nome-repo` e deve ser um repositório github válido." }, "step": { "init": { "title": "Opções ECHONETLite", "data": { "other_mode": "Tratamento do modo de operação \"Outro\"", "fan_settings": "Configurar definições da ventoinha", "swing_horiz": "Configurar definições de Oscilação Horizontal", "swing_vert": "Configurar definições de Oscilação Vertical", "auto_direction": "Configurar Direção Automática", "swing_mode": "Configurar Modo de Oscilação", "ha_ui_swing": "Configurar Modo de Oscilação para a interface de utilizador da entidade Clima", "min_temp_heat": "Configurar Temperatura Mínima para Operação de Aquecimento", "max_temp_heat": "Configurar Temperatura Máxima para Operação de Aquecimento", "min_temp_cool": "Configurar Temperatura Mínima para Operação de Arrefecimento", "max_temp_cool": "Configurar Temperatura Máxima para Operação de Arrefecimento", "min_temp_auto": "Configurar Temperatura Mínima para Operação Automática", "max_temp_auto": "Configurar Temperatura Máxima para Operação Automática", "force_polling": "Não parar o polling mesmo que a notificação imediata seja esperada", "super_energy": "Ativar sensores relacionados com energia (se disponíveis)", "batch_size_max": "Número máximo de propriedades para pedidos em lote" }, "description": "Configurar definições opcionais" } } }, "entity": { "sensor": { "echonetlite": { "state": { "stopped": "Parado", "supplying hot water": "A fornecer água quente", "keeping bath temperature": "A manter a temperatura do banho", "heating": "A aquecer", "not heating": "Não está a aquecer" } } }, "select": { "echonetlite": { "state": { "auto": "Automático", "minimum": "Mínimo", "low": "Baixo", "medium-low": "Médio-Baixo", "medium": "Médio", "medium-high": "Médio-Alto", "high": "Alto", "very-high": "Muito Alto", "max": "Máximo", "rc-right": "> >>", "left-lc": "<< <", "lc-center-rc": "< || >", "left-lc-rc-right": "<< < > >>", "right": ">>", "rc": ">", "center": "||", "center-right": "|| >>", "center-rc": "|| >", "center-rc-right": "|| > >>", "lc": "<", "lc-right": "< >>", "lc-rc": "< >", "lc-rc-right": "< > >>", "lc-center": "< ||", "lc-center-right": "< || >>", "lc-center-rc-right": "< || > >>", "left": "<<", "left-right": "<< >>", "left-rc": "<< >", "left-rc-right": "<< > >>", "left-center": "<< ||", "left-center-right": "<< || >>", "left-center-rc": "<< || >", "left-center-rc-right": "<< || > >>", "left-lc-right": "<< < >>", "left-lc-rc": "<< < >", "left-lc-center": "<< < ||", "left-lc-center-right": "<< < || >>", "left-lc-center-rc": "<< < || >", "left-lc-center-rc-right": "<< < || > >>", "upper": "Superior", "upper-central": "Superior Central", "central": "Central", "lower-central": "Inferior Central", "lower": "Inferior", "not-used": "Não Usado (Desligado)", "vert": "Vertical", "horiz": "Horizontal", "vert-horiz": "Vertical-Horizontal", "non-auto": "Fixo", "auto-vert": "Automático Vertical", "auto-horiz": "Automático Horizontal" } } }, "climate": { "echonetlite": { "state_attributes": { "fan_mode": { "state": { "auto": "Automático", "minimum": "Mínimo", "low": "Baixo", "medium-low": "Médio-Baixo", "medium": "Médio", "medium-high": "Médio-Alto", "high": "Alto", "very-high": "Muito Alto", "max": "Máximo" } }, "swing_mode": { "name": "Direção do ar", "state": { "rc-right": "> >>", "left-lc": "<< <", "lc-center-rc": "< || >", "left-lc-rc-right": "<< < > >>", "right": ">>", "rc": ">", "center": "||", "center-right": "|| >>", "center-rc": "|| >", "center-rc-right": "|| > >>", "lc": "<", "lc-right": "< >>", "lc-rc": "< >", "lc-rc-right": "< > >>", "lc-center": "< ||", "lc-center-right": "< || >>", "lc-center-rc-right": "< || > >>", "left": "<<", "left-right": "<< >>", "left-rc": "<< >", "left-rc-right": "<< > >>", "left-center": "<< ||", "left-center-right": "<< || >>", "left-center-rc": "<< || >", "left-center-rc-right": "<< || > >>", "left-lc-right": "<< < >>", "left-lc-rc": "<< < >", "left-lc-center": "<< < ||", "left-lc-center-right": "<< < || >>", "left-lc-center-rc": "<< < || >", "left-lc-center-rc-right": "<< < || > >>", ================================================ FILE: hacs.json ================================================ { "name": "ECHONETLite Platform", "render_readme": true, "domains": ["climate", "sensor", "select", "light", "fan", "switch", "time"], "homeassistant": "2024.1.0", "iot_class": ["Local Push", "Local Polling"] } ================================================ FILE: info.md ================================================ [![GitHub Release][releases-shield]][releases] [![License][license-shield]](LICENSE) [![hacs][hacsbadge]][hacs] ![Project Maintenance][maintenance-shield] # ECHONETLite Platform for Home Assistant _Component to integrate ECHONETLite compatable HVAC systems using the [pychonet][pychonet] library._ **This component will set up the following platforms.** Platform | Description -- | -- `climate` | Interface to ECHONETLite API to control your ECHONETLite compatible HVAC (Commonly found in Asia-Pacific regions) `sensor` | Interface to ECHONETLite API to poll indoor and outdoor temperature sensors. `select` | Interface to ECHONETLite API to provide drop down menus for swing modes. `light` | Interface to ECHONETLite API to provide light functionality for supported devices. `light` | Interface to ECHONETLite API to provide fan functionality for supported devices. ![example][exampleimg] {% if not installed %} ## Pre-installation - if previously installed versions prior to 3.0.1 1. Delete 'mitsubishi' from your 'custom_components' directory 2. Remove references to 'mitsubishi' from 'configuration.yaml' ## Installation 1. Click install and then reload Home Assistant. 2. Platform 'echonetlite' should be added to 'custom_components' directory 3. You may also need to clear your browser cache. 4. Go to configuration -> integrations -> ADD INTEGRATION. 5. Select the 'echonetlite' integration. Enter your IP address in the host field, and give the platform a name. 6. Platform should automatically configure 'climate' and depending on your system will configure 'sensor' and 'select' 6. If you have additional HVACs then repeat step 4. {% endif %} # Current working systems: Based upon feedback this custom component works with the following compatible ECHONETLite Devices: * Mitsubishi MAC-568IF-E WiFi Adaptor connected to the following systems: * GE Series * MSZ-GE42VAD * MSZ-GE24VAD * MSZ-GL71VGD * MSZ-GL50VGD * MSZ-GL35VGD * MSZ-GL25VGD * AP Series * MSZ-AP22VGD * MSZ-AP25VGD * MSZ-AP50VGD * Ducted * PEA-M100GAA * PEA-RP140GAA * Mitsubishi HM-W002-AC WiFi Adaptor connected to the following systems: * JXV Series * MSZ-JXV4018S * 'MoekadenRoom' ECHONETLite Simulator: https://github.com/SonyCSL/MoekadenRoom * Generic HVAC Climate * Light Sensor * Lock Sensor * Temperature Sensor * Sharp * Air Conditioners * AY-J22H * AY-L40P * Air Purifier * KI-HS70 * Daikin (ECHONETLite enabled models) * Koizumi * Lighting system AE50264E bridge (https://www.koizumi-lt.co.jp/product/jyutaku/tree/ ) ## Mitsubishi MAC-568IF-E From the official Mitsubishi AU/NZ Wifi App, you will need to enable the 'ECHONET lite' protocol under the 'edit unit' settings. ## Support for Other ECHONETLite devices At present this platform is somewhat hard coded to HVACs but can be modified for other uses as needed. If you have ECHONETLite devices that are not HVACs and you would like to use them via this integration then please raise an issue or better yet a PR. ![echonet][echonetimg] *** [pychonet]: https://github.com/scottyphillips/pychonet [echonetlite_homeassistant]: https://github.com/scottyphillips/echonetlite_homeassistant [hacs]: https://github.com/custom-components/hacs [hacsbadge]: https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge [releases-shield]: https://img.shields.io/github/release/scottyphillips/echonetlite_homeassistant.svg?style=for-the-badge [releases]: https://github.com/scottyphillips/echonetlite_homeassistant/releases [license-shield]:https://img.shields.io/github/license/scottyphillips/echonetlite_homeassistant?style=for-the-badge [buymecoffee]: https://www.buymeacoffee.com/RgKWqyt?style=for-the-badge [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge [maintenance-shield]: https://img.shields.io/badge/Maintainer-Scott%20Phillips-blue?style=for-the-badge [exampleimg]: https://raw.githubusercontent.com/scottyphillips/echonetlite_homeassistant/master/Mitsubishi.jpg [echonetimg]: https://raw.githubusercontent.com/scottyphillips/echonetlite_homeassistant/master/ECHONET.jpeg