├── custom_components └── smart_maic │ ├── strings.json │ ├── const.py │ ├── manifest.json │ ├── switch.py │ ├── entity.py │ ├── translations │ ├── en.json │ └── pt.json │ ├── number.py │ ├── smart_maic.py │ ├── __init__.py │ ├── coordinator.py │ ├── config_flow.py │ └── sensor.py ├── .pylintrc ├── hacs.json ├── .github ├── workflows │ ├── hacs.yml │ ├── ci.yml │ └── release.yml └── dependabot.yml ├── .editorconfig ├── LICENSE ├── README.md └── .gitignore /custom_components/smart_maic/strings.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | 3 | disable=E0401, W1203, R0903 4 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Smart MAIC", 3 | "homeassistant": "2023.10.0", 4 | "render_readme": true 5 | } -------------------------------------------------------------------------------- /.github/workflows/hacs.yml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | hacs: 8 | name: HACS Action 9 | runs-on: "ubuntu-latest" 10 | steps: 11 | - name: HACS Action 12 | uses: "hacs/action@main" 13 | with: 14 | category: "integration" 15 | -------------------------------------------------------------------------------- /custom_components/smart_maic/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Smart Maic integration.""" 2 | 3 | from homeassistant.const import CONF_IP_ADDRESS, CONF_PIN 4 | 5 | DOMAIN = "smart_maic" 6 | PREFIX = "smart-maic" 7 | HTTP_TIMEOUT = 5 8 | DEFAULT_EXPIRATION = 90 9 | 10 | IP_ADDRESS = CONF_IP_ADDRESS 11 | PIN = CONF_PIN 12 | DEVICE_NAME = "device_name" 13 | DEVICE_ID = "devid" 14 | DEVICE_TYPE = "devtype" 15 | EXPIRATION = "expiration" 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.{diff,md}] 16 | trim_trailing_whitespace = false 17 | 18 | [*.py] 19 | indent_size = 4 20 | -------------------------------------------------------------------------------- /custom_components/smart_maic/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "smart_maic", 3 | "name": "Smart MAIC", 4 | "codeowners": ["@krasnoukhov"], 5 | "config_flow": true, 6 | "dependencies": ["mqtt"], 7 | "documentation": "https://github.com/krasnoukhov/homeassistant-smart-maic", 8 | "integration_type": "device", 9 | "iot_class": "local_push", 10 | "issue_tracker": "https://github.com/krasnoukhov/homeassistant-smart-maic/issues", 11 | "requirements": [], 12 | "version": "1.5.3" 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | black: 8 | name: Python Code Format Check 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out code from GitHub 12 | uses: "actions/checkout@main" 13 | - name: Black Code Format Check 14 | uses: lgeiger/black-action@master 15 | with: 16 | args: ". --check --fast --diff" 17 | 18 | validate: 19 | name: Check hassfest 20 | runs-on: "ubuntu-latest" 21 | steps: 22 | - name: Check out code from GitHub 23 | uses: "actions/checkout@main" 24 | - name: Run hassfest 25 | uses: home-assistant/actions/hassfest@master 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dmitry Krasnoukhov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant Smart MAIC integration 2 | 3 | The Smart MAIC integration listens for energy data from the device via MQTT protocol and exposes sensors as well as controls. 4 | 5 | Tested with: 6 | * [Розумний лічильник електроенергії c WiFi D101, однофазний, стандартна версія](https://store.smart-maic.com/ua/p684214708-umnyj-schetchik-elektroenergii.html) 7 | * [Розумний лічильник електроенергії c WiFi D103, трифазний, розширена версія](https://store.smart-maic.com/ua/p679987290-umnyj-schetchik-elektroenergii.html) 8 | * [Розумний лічильник імпульсів з WiFi smart-MAIC D105](https://store.smart-maic.com/ua/p811449534-umnyj-schetchik-impulsov.html) 9 | 10 | ## Highlights 11 | 12 | ### Have your energy sensors at a glance 13 | 14 | sensors 15 | 16 | ### Sync consumed energy with an external meter from the UI 17 | 18 | number 19 | 20 | ### Control the dry switch 21 | 22 | switch 23 | 24 | ### Use the UI to set up integration 25 | 26 | setup 27 | 28 | ## Installation 29 | 30 | ### Via HACS 31 | 32 | [![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=krasnoukhov&repository=homeassistant-smart-maic&category=Integration) 33 | 34 | * Search for "Smart MAIC" on HACS tab in Home Assistant 35 | * Click on three dots and use the "Download" option 36 | * Follow the steps 37 | * Restart Home Assistant 38 | 39 | ### Manual Installation (not recommended) 40 | 41 | * Copy the entire `custom_components/smart-maic/` directory to your server's `/custom_components` directory 42 | * Restart Home Assistant 43 | -------------------------------------------------------------------------------- /custom_components/smart_maic/switch.py: -------------------------------------------------------------------------------- 1 | """Home Assistant component for accessing the Smart MAIC API. 2 | 3 | The switch component allows control of charging dry switch. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | 13 | from .const import ( 14 | DOMAIN, 15 | ) 16 | from .coordinator import SmartMaicCoordinator 17 | from .entity import SmartMaicEntity 18 | 19 | 20 | ENTITY_DESCRIPTIONS: dict[str, SwitchEntityDescription] = { 21 | "OUT": SwitchEntityDescription( 22 | key="OUT", translation_key="dry_switch", icon="mdi:home-switch" 23 | ), 24 | } 25 | 26 | 27 | async def async_setup_entry( 28 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 29 | ) -> None: 30 | """Create Smart MAIC switch entities in HASS.""" 31 | coordinator: SmartMaicCoordinator = hass.data[DOMAIN][entry.entry_id] 32 | 33 | async_add_entities( 34 | [ 35 | SmartMaicSwitch(hass, coordinator, entry, description) 36 | for ent in coordinator.data 37 | if (description := ENTITY_DESCRIPTIONS.get(ent)) 38 | ] 39 | ) 40 | 41 | 42 | class SmartMaicSwitch(SmartMaicEntity, SwitchEntity): 43 | """Representation of the Smart MAIC switch.""" 44 | 45 | entity_description: SwitchEntityDescription 46 | 47 | @property 48 | def is_on(self) -> bool: 49 | """Return the status of the switch.""" 50 | value = self.coordinator.data.get(self.entity_description.key) 51 | return None if value is None else value == 1 52 | 53 | async def async_turn_on(self) -> None: 54 | """Switch dry switch.""" 55 | await self._set_dry_swtich(1) 56 | 57 | async def async_turn_off(self) -> None: 58 | """Unswitch dry switch.""" 59 | await self._set_dry_swtich(0) 60 | 61 | async def _set_dry_swtich(self, value): 62 | await self.coordinator.async_set_dry_switch(value) 63 | data = {} | self.coordinator.data 64 | data[self.entity_description.key] = value 65 | self.coordinator.async_set_updated_data(data) 66 | -------------------------------------------------------------------------------- /custom_components/smart_maic/entity.py: -------------------------------------------------------------------------------- 1 | """Base entity for the Smart MAIC integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers.device_registry import DeviceInfo 8 | from homeassistant.helpers.entity import EntityDescription 9 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 10 | 11 | from .const import ( 12 | DEVICE_ID, 13 | DEVICE_NAME, 14 | DEVICE_TYPE, 15 | DOMAIN, 16 | IP_ADDRESS, 17 | ) 18 | from .coordinator import SmartMaicCoordinator 19 | 20 | 21 | class SmartMaicEntity(CoordinatorEntity[SmartMaicCoordinator]): 22 | """Defines a base Smart MAIC entity.""" 23 | 24 | _attr_has_entity_name = True 25 | _attr_should_poll = False 26 | 27 | def __init__( 28 | self, 29 | hass: HomeAssistant, 30 | coordinator: SmartMaicCoordinator, 31 | entry: ConfigEntry, 32 | description: EntityDescription, 33 | ) -> None: 34 | """Initialize a Smart MAIC entity.""" 35 | super().__init__(coordinator) 36 | 37 | self.entity_description = description 38 | self.hass = hass 39 | self._entry = entry 40 | 41 | self._attr_unique_id = "-".join( 42 | [ 43 | entry.data[DEVICE_ID], 44 | description.key, 45 | ] 46 | ) 47 | 48 | @property 49 | def device_info(self) -> DeviceInfo: 50 | """Return device information about this Smart MAIC device.""" 51 | return DeviceInfo( 52 | identifiers={ 53 | ( 54 | DOMAIN, 55 | self._entry.data[DEVICE_ID], 56 | ) 57 | }, 58 | name=self._entry.data[DEVICE_NAME], 59 | manufacturer="Smart MAIC", 60 | model=self._entry.data[DEVICE_TYPE], 61 | configuration_url=f"http://{self._entry.data[IP_ADDRESS]}", 62 | ) 63 | 64 | @property 65 | def name(self) -> str: 66 | """Return the name of the entity.""" 67 | original = super().name 68 | key = self.entity_description.key 69 | suffix = f" {key[-1]}" if key[-1] in ["1", "2", "3", "4", "5"] else "" 70 | return f"{original}{suffix}" 71 | -------------------------------------------------------------------------------- /custom_components/smart_maic/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "ip_address": "IP address", 7 | "pin": "PIN password", 8 | "device_name": "Name for the device in HA" 9 | }, 10 | "description": "Please set up MQTT on the device before adding this integration" 11 | } 12 | }, 13 | "error": { 14 | "already_configured": "Device is already configured", 15 | "already_in_progress": "Device configuration is in progress", 16 | "mqtt_unavailable": "Please set up Home Assistant MQTT integration", 17 | "mqtt_unconfigured": "Please configure MQTT host/port/credentials on the device and retry setup", 18 | "cannot_connect": "Failed to connect", 19 | "unknown": "Unknown error" 20 | } 21 | }, 22 | "options": { 23 | "step": { 24 | "init": { 25 | "data": { 26 | "expiration": "Expiration of sensor data in seconds" 27 | }, 28 | "data_description": { 29 | "expiration": "Depending on the device, it sends the data every 5 or 60 seconds. This value should be higher than this interval to avoid flip-flopping of the sensor values" 30 | } 31 | } 32 | } 33 | }, 34 | "entity": { 35 | "number": { 36 | "consumption": { 37 | "name": "Consumption" 38 | } 39 | }, 40 | "sensor": { 41 | "voltage": { 42 | "name": "Voltage" 43 | }, 44 | "current": { 45 | "name": "Current" 46 | }, 47 | "power": { 48 | "name": "Power" 49 | }, 50 | "return_power": { 51 | "name": "Return power" 52 | }, 53 | "consumption": { 54 | "name": "Consumption" 55 | }, 56 | "return": { 57 | "name": "Return" 58 | }, 59 | "power_factor": { 60 | "name": "Power factor" 61 | }, 62 | "device_temperature": { 63 | "name": "Device temperature" 64 | }, 65 | "total_current": { 66 | "name": "Total current" 67 | }, 68 | "total_power": { 69 | "name": "Total power" 70 | }, 71 | "total_return_power": { 72 | "name": "Total return power" 73 | }, 74 | "total_consumption": { 75 | "name": "Total consumption" 76 | }, 77 | "total_return": { 78 | "name": "Total return" 79 | }, 80 | "point": { 81 | "name": "Point" 82 | }, 83 | "channel": { 84 | "name": "Channel" 85 | }, 86 | "total_channel": { 87 | "name": "Total channel" 88 | }, 89 | "adc": { 90 | "name": "ADC" 91 | } 92 | }, 93 | "switch": { 94 | "dry_switch": { 95 | "name": "Dry switch" 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /custom_components/smart_maic/number.py: -------------------------------------------------------------------------------- 1 | """Home Assistant component for accessing the Smart MAIC API. 2 | 3 | The number component allows control of charging consumption. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import sys 9 | from typing import cast 10 | 11 | from homeassistant.components.number import ( 12 | NumberDeviceClass, 13 | NumberEntity, 14 | NumberEntityDescription, 15 | NumberMode, 16 | ) 17 | from homeassistant.config_entries import ConfigEntry 18 | from homeassistant.const import UnitOfEnergy 19 | from homeassistant.core import HomeAssistant 20 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 21 | 22 | from .const import ( 23 | DOMAIN, 24 | ) 25 | from .coordinator import SmartMaicCoordinator 26 | from .entity import SmartMaicEntity 27 | 28 | 29 | def phase_descriptions(index="") -> dict[str, NumberEntityDescription]: 30 | """Generate entity descriptions for a given phase""" 31 | return { 32 | f"Wh{index}": NumberEntityDescription( 33 | key=f"Wh{index}", 34 | translation_key="consumption", 35 | device_class=NumberDeviceClass.ENERGY, 36 | mode=NumberMode.BOX, 37 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 38 | native_min_value=0, 39 | native_max_value=sys.maxsize, 40 | entity_registry_enabled_default=False, 41 | ), 42 | } 43 | 44 | 45 | ENTITY_DESCRIPTIONS: dict[str, NumberEntityDescription] = { 46 | **phase_descriptions(""), 47 | **phase_descriptions("1"), 48 | **phase_descriptions("2"), 49 | **phase_descriptions("3"), 50 | } 51 | 52 | 53 | async def async_setup_entry( 54 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 55 | ) -> None: 56 | """Create Smart MAIC number entities in HASS.""" 57 | coordinator: SmartMaicCoordinator = hass.data[DOMAIN][entry.entry_id] 58 | 59 | async_add_entities( 60 | [ 61 | SmartMaicNumber(hass, coordinator, entry, description) 62 | for ent in coordinator.data 63 | if (description := ENTITY_DESCRIPTIONS.get(ent)) 64 | ] 65 | ) 66 | 67 | 68 | class SmartMaicNumber(SmartMaicEntity, NumberEntity): 69 | """Representation of the Smart MAIC number.""" 70 | 71 | entity_description: NumberEntityDescription 72 | _attr_entity_registry_enabled_default = False 73 | 74 | @property 75 | def native_value(self) -> int | None: 76 | """Return the value of the entity.""" 77 | value = self.coordinator.data.get(self.entity_description.key) 78 | return None if value is None else cast(int | None, value) 79 | 80 | async def async_set_native_value(self, value: int) -> None: 81 | """Set the value of the entity.""" 82 | print(self.entity_description.key) 83 | await self.coordinator.async_set_consumption(self.entity_description.key, value) 84 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Auto Release" 3 | 4 | on: 5 | push: 6 | paths: 7 | - custom_components/*/manifest.json 8 | 9 | jobs: 10 | auto-release: 11 | name: "Auto Release" 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: "✏️ Checkout code" 15 | uses: actions/checkout@v6 16 | with: 17 | path: './' 18 | 19 | - name: "🏷️ Get version tag" 20 | id: set_var 21 | run: echo "COMPONENT_VERSION=$(jq -r .version custom_components/*/manifest.json)" >> $GITHUB_ENV 22 | 23 | - name: "🏷️ Check if tag exists already" 24 | uses: mukunku/tag-exists-action@v1.7.0 25 | id: "check_tag" 26 | with: 27 | tag: "v${{ env.COMPONENT_VERSION }}" 28 | 29 | - name: "❌ Cancel if tag is already present" 30 | run: | 31 | echo "Tag already present: v${{ env.COMPONENT_VERSION }}. Not creating a new release" 32 | gh run cancel ${{ github.run_id }} 33 | gh run watch ${{ github.run_id }} 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | if: steps.check_tag.outputs.exists == 'true' 37 | 38 | - name: "🗝️ Get previous release version" 39 | id: last_release 40 | uses: InsonusK/get-latest-release@v1.1.0 41 | with: 42 | myToken: ${{ github.token }} 43 | exclude_types: "draft|prerelease" 44 | 45 | - name: "🏷️ Create new tag" 46 | uses: rickstaa/action-create-tag@v1 47 | id: "tag_create" 48 | with: 49 | tag: "v${{ env.COMPONENT_VERSION }}" 50 | tag_exists_error: false 51 | message: "Version ${{ env.COMPONENT_VERSION }}" 52 | # if: steps.check_tag.outputs.exists == 'false' 53 | 54 | - name: "🗒️ Generate release changelog" 55 | id: changelog 56 | uses: heinrichreimer/github-changelog-generator-action@v2.3 57 | with: 58 | token: ${{ secrets.GITHUB_TOKEN }} 59 | sinceTag: ${{ steps.last_release.outputs.tag_name }} 60 | headerLabel: "# Notable changes since ${{ steps.last_release.outputs.tag_name }}" 61 | stripGeneratorNotice: true 62 | 63 | - name: 👍 Create Stable release 64 | uses: softprops/action-gh-release@v2 65 | with: 66 | prerelease: false 67 | body: "${{ steps.changelog.outputs.changelog }}" 68 | name: "Version ${{ env.COMPONENT_VERSION }}" 69 | tag_name: "v${{ env.COMPONENT_VERSION }}" 70 | if: contains(env.COMPONENT_VERSION, 'beta') == false 71 | 72 | - name: 🤞 Create Beta release 73 | uses: softprops/action-gh-release@v2 74 | with: 75 | prerelease: true 76 | body: "${{ steps.changelog.outputs.changelog }}" 77 | name: "Version ${{ env.COMPONENT_VERSION }}" 78 | tag_name: "v${{ env.COMPONENT_VERSION }}" 79 | if: contains(env.COMPONENT_VERSION, 'beta') == true 80 | -------------------------------------------------------------------------------- /custom_components/smart_maic/translations/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "ip_address": "Endereço IP", 7 | "pin": "PIN de acesso", 8 | "device_name": "Nome para o dispositivo no HA" 9 | }, 10 | "description": "Por favor, configure o MQTT no dispositivo antes de adicionar esta integração" 11 | } 12 | }, 13 | "error": { 14 | "already_configured": "O dispositivo já está configurado", 15 | "already_in_progress": "A configuração do dispositivo está em progresso", 16 | "mqtt_unavailable": "Por favor, configure a integração MQTT no Home Assistant", 17 | "mqtt_unconfigured": "Configure o host/porta/credenciais MQTT no dispositivo e tente novamente", 18 | "cannot_connect": "Não foi possível ligar", 19 | "unknown": "Erro desconhecido" 20 | } 21 | }, 22 | "options": { 23 | "step": { 24 | "init": { 25 | "data": { 26 | "expiration": "Expiração dos dados do sensor em segundos" 27 | }, 28 | "data_description": { 29 | "expiration": "Dependendo do dispositivo, os dados são enviados a cada 5 ou 60 segundos. Este valor deve ser superior a este intervalo para evitar a oscilação dos valores do sensor" 30 | } 31 | } 32 | } 33 | }, 34 | "entity": { 35 | "number": { 36 | "consumption": { 37 | "name": "Consumo" 38 | } 39 | }, 40 | "sensor": { 41 | "voltage": { 42 | "name": "Voltagem" 43 | }, 44 | "current": { 45 | "name": "Corrente" 46 | }, 47 | "power": { 48 | "name": "Potência" 49 | }, 50 | "return_power": { 51 | "name": "Potência de retorno" 52 | }, 53 | "consumption": { 54 | "name": "Consumo" 55 | }, 56 | "return": { 57 | "name": "Retorno" 58 | }, 59 | "power_factor": { 60 | "name": "Fator de potência" 61 | }, 62 | "device_temperature": { 63 | "name": "Temperatura do dispositivo" 64 | }, 65 | "total_current": { 66 | "name": "Corrente total" 67 | }, 68 | "total_power": { 69 | "name": "Potência total" 70 | }, 71 | "total_return_power": { 72 | "name": "Potência de retorno total" 73 | }, 74 | "total_consumption": { 75 | "name": "Consumo total" 76 | }, 77 | "total_return": { 78 | "name": "Retorno total" 79 | }, 80 | "point": { 81 | "name": "Ponto" 82 | }, 83 | "channel": { 84 | "name": "Canal" 85 | }, 86 | "total_channel": { 87 | "name": "Canal total" 88 | }, 89 | "adc": { 90 | "name": "ADC" 91 | } 92 | }, 93 | "switch": { 94 | "dry_switch": { 95 | "name": "Interruptor seco" 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /custom_components/smart_maic/smart_maic.py: -------------------------------------------------------------------------------- 1 | """Smart MAIC integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from urllib.parse import urlparse, urlencode 9 | import requests 10 | 11 | from .const import ( 12 | DEVICE_ID, 13 | HTTP_TIMEOUT, 14 | IP_ADDRESS, 15 | PIN, 16 | PREFIX, 17 | ) 18 | 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | class SmartMaic: 24 | """Smart MAIC instance.""" 25 | 26 | def __init__(self, data: dict[str, Any]) -> None: 27 | """Init Smart MAIC.""" 28 | self._ip_address = data[IP_ADDRESS] 29 | self._pin = data[PIN] 30 | self._devid = data.get(DEVICE_ID) 31 | 32 | def get_wdata(self) -> dict[str, Any]: 33 | """Get "wdata" for Smart MAIC component.""" 34 | self._login_request() 35 | return self._get_request(page="getwdata").json() 36 | 37 | def get_config(self) -> dict[str, Any]: 38 | """Get config for Smart MAIC component.""" 39 | self._login_request() 40 | return self._get_request(page="webinit").json() 41 | 42 | def set_mqtt_config(self) -> dict[str, Any]: 43 | """Set Smart MAIC MQTT config.""" 44 | config = self.get_config() 45 | 46 | self._get_request( 47 | page="mqtt", 48 | serv=config["serv"], 49 | port=config["port"], 50 | uname=config["uname"], 51 | **{"pass": config["pass"]}, 52 | mqtt_on=1, 53 | mqttint=5, 54 | separat=2, 55 | prefix=f"{PREFIX}/", 56 | ) 57 | 58 | return self.get_config() 59 | 60 | def set_consumption(self, key: str, value: float) -> None: 61 | """Set Smart MAIC consumption value.""" 62 | self._login_request() 63 | self._get_request(page="initval", **{key: value}) 64 | 65 | def set_dry_switch(self, value: int) -> dict[str, Any]: 66 | """Set Smart MAIC dry switch.""" 67 | self._login_request() 68 | self._get_request(page="pout", state=value) 69 | 70 | def _login_request(self) -> None: 71 | self._get_request(page="devlogin", devpass=self._pin) 72 | 73 | def _get_request(self, **kwargs) -> requests.Response: 74 | """Make GET request to the Smart MAIC API.""" 75 | url = urlparse(f"http://{self._ip_address}/") 76 | url = url._replace(query=urlencode(kwargs)) 77 | 78 | _LOGGER.debug(f"Smart MAIC request: GET {url.geturl()}") 79 | try: 80 | r = requests.get(url.geturl(), timeout=HTTP_TIMEOUT) 81 | r.raise_for_status() 82 | _LOGGER.debug(f"Smart MAIC status: {r.status_code}") 83 | _LOGGER.debug(f"Smart MAIC response: {r.text}") 84 | 85 | return r 86 | except TimeoutError as timeout_error: 87 | raise ConnectionError from timeout_error 88 | except requests.exceptions.ConnectionError as connection_error: 89 | raise ConnectionError from connection_error 90 | except requests.exceptions.HTTPError as http_error: 91 | if http_error.response.status_code == 400: 92 | return r 93 | raise ConnectionError from http_error 94 | -------------------------------------------------------------------------------- /custom_components/smart_maic/__init__.py: -------------------------------------------------------------------------------- 1 | """The Smart MAIC integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import asyncio 7 | 8 | from homeassistant.components import mqtt 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import Platform 11 | from homeassistant.core import HomeAssistant, callback 12 | from homeassistant.exceptions import ConfigEntryNotReady 13 | from homeassistant.util.json import json_loads_object 14 | 15 | from .smart_maic import SmartMaic 16 | from .coordinator import SmartMaicCoordinator 17 | from .const import ( 18 | DEVICE_ID, 19 | DOMAIN, 20 | PREFIX, 21 | ) 22 | 23 | PLATFORMS = [Platform.SENSOR, Platform.NUMBER, Platform.SWITCH] 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | async def update_listener(hass, entry): 29 | """Handle options update.""" 30 | coordinator: SmartMaicCoordinator = hass.data[DOMAIN][entry.entry_id] 31 | coordinator.set_update_interval() 32 | 33 | 34 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 35 | """Set up Smart MAIC from a config entry.""" 36 | if not await mqtt.async_wait_for_mqtt_client(hass): 37 | raise ConfigEntryNotReady("MQTT is not available") 38 | 39 | smart_maic = SmartMaic(entry.data) 40 | coordinator = SmartMaicCoordinator(smart_maic, hass) 41 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator 42 | 43 | json_received = False 44 | 45 | async def wait_for_json() -> None: 46 | while not json_received: 47 | await asyncio.sleep(5) 48 | _LOGGER.debug("Has no JSON") 49 | 50 | @callback 51 | def async_json_received(msg: mqtt.ReceiveMessage) -> None: 52 | nonlocal json_received 53 | json_received = True 54 | 55 | data = json_loads_object(msg.payload) 56 | _LOGGER.debug(f"MQTT data: {data}") 57 | coordinator.async_set_updated_data(data) 58 | 59 | topic = "/".join([PREFIX, entry.data[DEVICE_ID], "JSON"]) 60 | _LOGGER.debug(f"Listening for MQTT topic: {topic}") 61 | entry.async_on_unload(await mqtt.async_subscribe(hass, topic, async_json_received)) 62 | non_prefixed_topic = "/".join([entry.data[DEVICE_ID], "JSON"]) 63 | _LOGGER.debug(f"Listening for non-prefixed MQTT topic: {non_prefixed_topic}") 64 | entry.async_on_unload( 65 | await mqtt.async_subscribe(hass, non_prefixed_topic, async_json_received) 66 | ) 67 | 68 | try: 69 | async with hass.timeout.async_timeout(90): 70 | await wait_for_json() 71 | except asyncio.TimeoutError as ex: 72 | raise ConfigEntryNotReady(f"Timeout waiting for MQTT topic {topic}") from ex 73 | 74 | _LOGGER.debug("Has JSON!") 75 | 76 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 77 | entry.async_on_unload(entry.add_update_listener(update_listener)) 78 | 79 | return True 80 | 81 | 82 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 83 | """Unload a config entry.""" 84 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 85 | if unload_ok: 86 | hass.data[DOMAIN].pop(entry.entry_id) 87 | 88 | return unload_ok 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /custom_components/smart_maic/coordinator.py: -------------------------------------------------------------------------------- 1 | """DataUpdateCoordinator for the Smart MAIC integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import timedelta, datetime 6 | import logging 7 | from typing import Any 8 | 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 11 | from homeassistant.util.dt import utcnow 12 | 13 | from .smart_maic import SmartMaic 14 | from .const import ( 15 | DEFAULT_EXPIRATION, 16 | DOMAIN, 17 | EXPIRATION, 18 | ) 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | class SmartMaicCoordinator(DataUpdateCoordinator[dict[str, Any]]): 24 | """Smart MAIC Coordinator class.""" 25 | 26 | _smart_maic: SmartMaic | None = None 27 | _last_update_at: datetime | None = None 28 | 29 | def __init__(self, smart_maic: SmartMaic, hass: HomeAssistant) -> None: 30 | """Initialize.""" 31 | self._smart_maic = smart_maic 32 | 33 | super().__init__( 34 | hass, 35 | _LOGGER, 36 | name=DOMAIN, 37 | ) 38 | 39 | if self.config_entry: 40 | self.set_update_interval() 41 | 42 | def set_update_interval(self): 43 | """Set update interval.""" 44 | self.update_interval = timedelta( 45 | seconds=self.config_entry.options.get(EXPIRATION) or DEFAULT_EXPIRATION 46 | ) 47 | 48 | def async_set_updated_data(self, data: dict[str, Any]): 49 | """Set updated data and note the time.""" 50 | super().async_set_updated_data(data) 51 | self._last_update_at = utcnow().replace(microsecond=0) 52 | 53 | async def _async_update_data(self) -> dict[str, Any]: 54 | """Check for stale data and reset it or return the latest data.""" 55 | return await self.hass.async_add_executor_job(self._update_data) 56 | 57 | def _update_data(self) -> dict[str, Any]: 58 | """Check for stale data and reset it or return the latest data.""" 59 | _LOGGER.debug(f"Last data update: {self._last_update_at}") 60 | 61 | if ( 62 | self._last_update_at 63 | and self.data 64 | and utcnow().replace(microsecond=0) - self._last_update_at 65 | >= self.update_interval 66 | ): 67 | _LOGGER.debug("Data expired") 68 | self.data = {} 69 | 70 | return self.data 71 | 72 | def _get_config(self) -> None: 73 | """Get Smart MAIC config.""" 74 | return self._smart_maic.set_mqtt_config() 75 | 76 | async def async_get_config(self) -> None: 77 | """Get Smart MAIC config.""" 78 | return await self.hass.async_add_executor_job(self._get_config) 79 | 80 | def _set_mqtt_config(self) -> None: 81 | """Set Smart MAIC MQTT config.""" 82 | return self._smart_maic.set_mqtt_config() 83 | 84 | async def async_set_mqtt_config(self) -> None: 85 | """Set Smart MAIC MQTT config.""" 86 | return await self.hass.async_add_executor_job(self._set_mqtt_config) 87 | 88 | def _set_consumption(self, key: str, value: float) -> None: 89 | """Set Smart MAIC consumption value.""" 90 | return self._smart_maic.set_consumption(key=key, value=value) 91 | 92 | async def async_set_consumption(self, key: str, value: float) -> None: 93 | """Set Smart MAIC consumption value.""" 94 | return await self.hass.async_add_executor_job(self._set_consumption, key, value) 95 | 96 | def _set_dry_switch(self, value: int) -> None: 97 | """Set Smart MAIC dry switch value.""" 98 | return self._smart_maic.set_dry_switch(value=value) 99 | 100 | async def async_set_dry_switch(self, value: int) -> None: 101 | """Set Smart MAIC dry switch value.""" 102 | return await self.hass.async_add_executor_job(self._set_dry_switch, value) 103 | -------------------------------------------------------------------------------- /custom_components/smart_maic/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Smart MAIC integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant import config_entries 11 | from homeassistant.components import mqtt 12 | from homeassistant.core import HomeAssistant, callback 13 | from homeassistant.data_entry_flow import AbortFlow 14 | import homeassistant.helpers.config_validation as cv 15 | 16 | from .const import ( 17 | DEFAULT_EXPIRATION, 18 | DEVICE_ID, 19 | DEVICE_NAME, 20 | DEVICE_TYPE, 21 | DOMAIN, 22 | EXPIRATION, 23 | IP_ADDRESS, 24 | PIN, 25 | ) 26 | from .smart_maic import SmartMaic 27 | from .coordinator import SmartMaicCoordinator 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | USER_SCHEMA = vol.Schema( 32 | { 33 | vol.Required(IP_ADDRESS): cv.string, 34 | vol.Required(PIN): cv.string, 35 | vol.Required(DEVICE_NAME, default="Energy"): cv.string, 36 | } 37 | ) 38 | 39 | OPTIONS_SCHEMA = vol.Schema( 40 | { 41 | vol.Optional(EXPIRATION, default=DEFAULT_EXPIRATION): vol.All( 42 | vol.Coerce(int), vol.Range(min=5) 43 | ), 44 | } 45 | ) 46 | 47 | 48 | async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: 49 | """Validate the user input allows us to connect. 50 | 51 | Data has the keys from USER_SCHEMA with values provided by the user. 52 | """ 53 | 54 | if not await mqtt.async_wait_for_mqtt_client(hass): 55 | raise AbortFlow("mqtt_unavailable") 56 | 57 | smart_maic = SmartMaic(data) 58 | coordinator = SmartMaicCoordinator(smart_maic, hass) 59 | config = await coordinator.async_get_config() 60 | if not config["serv"]: 61 | raise AbortFlow("mqtt_unconfigured") 62 | 63 | config = await coordinator.async_set_mqtt_config() 64 | additional = { 65 | DEVICE_ID: config["about"][DEVICE_ID]["value"], 66 | DEVICE_TYPE: config["about"][DEVICE_TYPE]["value"], 67 | } 68 | 69 | return {"title": data[DEVICE_NAME], "additional": additional} 70 | 71 | 72 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 73 | """Handle a config flow for Smart MAIC.""" 74 | 75 | VERSION = 1 76 | 77 | @staticmethod 78 | @callback 79 | def async_get_options_flow( 80 | config_entry: config_entries.ConfigEntry, 81 | ) -> config_entries.OptionsFlow: 82 | """Create the options flow.""" 83 | return OptionsFlowHandler() 84 | 85 | async def async_step_user(self, user_input=None): 86 | """Handle the initial step.""" 87 | if user_input is None: 88 | return self.async_show_form( 89 | step_id="user", data_schema=USER_SCHEMA, errors={} 90 | ) 91 | 92 | errors = {} 93 | 94 | try: 95 | info = await validate_input(self.hass, user_input) 96 | data = user_input | info["additional"] 97 | 98 | await self.async_set_unique_id(data[DEVICE_ID]) 99 | self._abort_if_unique_id_configured() 100 | 101 | return self.async_create_entry(title=info["title"], data=data) 102 | except ConnectionError: 103 | errors["base"] = "cannot_connect" 104 | except AbortFlow as abort_flow_error: 105 | errors["base"] = abort_flow_error.reason 106 | except Exception as exception_error: # pylint: disable=broad-except 107 | _LOGGER.exception(f"Unexpected exception {exception_error}") 108 | errors["base"] = "unknown" 109 | 110 | data_schema = self.add_suggested_values_to_schema(USER_SCHEMA, user_input) 111 | return self.async_show_form( 112 | step_id="user", data_schema=data_schema, errors=errors 113 | ) 114 | 115 | 116 | class OptionsFlowHandler(config_entries.OptionsFlow): 117 | """Handle options flow for Smart MAIC.""" 118 | 119 | async def async_step_init( 120 | self, user_input: dict[str, Any] | None = None 121 | ) -> config_entries.FlowResult: 122 | """Manage the options.""" 123 | data_schema = self.add_suggested_values_to_schema( 124 | OPTIONS_SCHEMA, self.config_entry.options 125 | ) 126 | 127 | if user_input is None: 128 | return self.async_show_form( 129 | step_id="init", 130 | data_schema=data_schema, 131 | ) 132 | 133 | return self.async_create_entry(title="", data=user_input) 134 | -------------------------------------------------------------------------------- /custom_components/smart_maic/sensor.py: -------------------------------------------------------------------------------- 1 | """Home Assistant component for accessing the Smart MAIC API. 2 | 3 | The sensor component creates multipe sensors regarding Smart MAIC status. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from typing import cast 9 | 10 | from homeassistant.components.sensor import ( 11 | SensorDeviceClass, 12 | SensorEntity, 13 | SensorEntityDescription, 14 | SensorStateClass, 15 | ) 16 | from homeassistant.config_entries import ConfigEntry 17 | from homeassistant.const import ( 18 | UnitOfTemperature, 19 | UnitOfElectricCurrent, 20 | UnitOfElectricPotential, 21 | UnitOfEnergy, 22 | UnitOfPower, 23 | ) 24 | from homeassistant.core import HomeAssistant 25 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 26 | from homeassistant.helpers.typing import StateType 27 | 28 | from .const import ( 29 | DOMAIN, 30 | ) 31 | from .coordinator import SmartMaicCoordinator 32 | from .entity import SmartMaicEntity 33 | 34 | 35 | def phase_descriptions(index="") -> dict[str, SensorEntityDescription]: 36 | """Generate entity descriptions for a given phase""" 37 | return { 38 | f"V{index}": SensorEntityDescription( 39 | key=f"V{index}", 40 | translation_key="voltage", 41 | device_class=SensorDeviceClass.VOLTAGE, 42 | state_class=SensorStateClass.MEASUREMENT, 43 | native_unit_of_measurement=UnitOfElectricPotential.VOLT, 44 | suggested_display_precision=2, 45 | ), 46 | f"A{index}": SensorEntityDescription( 47 | key=f"A{index}", 48 | translation_key="current", 49 | device_class=SensorDeviceClass.CURRENT, 50 | state_class=SensorStateClass.MEASUREMENT, 51 | native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, 52 | suggested_display_precision=2, 53 | ), 54 | f"W{index}": SensorEntityDescription( 55 | key=f"W{index}", 56 | translation_key="power", 57 | device_class=SensorDeviceClass.POWER, 58 | state_class=SensorStateClass.MEASUREMENT, 59 | native_unit_of_measurement=UnitOfPower.WATT, 60 | suggested_display_precision=0, 61 | ), 62 | f"rW{index}": SensorEntityDescription( 63 | key=f"rW{index}", 64 | translation_key="return_power", 65 | device_class=SensorDeviceClass.POWER, 66 | state_class=SensorStateClass.MEASUREMENT, 67 | native_unit_of_measurement=UnitOfPower.WATT, 68 | suggested_display_precision=0, 69 | entity_registry_enabled_default=False, 70 | ), 71 | f"Wh{index}": SensorEntityDescription( 72 | key=f"Wh{index}", 73 | translation_key="consumption", 74 | device_class=SensorDeviceClass.ENERGY, 75 | state_class=SensorStateClass.TOTAL_INCREASING, 76 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 77 | suggested_display_precision=0, 78 | ), 79 | f"rWh{index}": SensorEntityDescription( 80 | key=f"rWh{index}", 81 | translation_key="return", 82 | device_class=SensorDeviceClass.ENERGY, 83 | state_class=SensorStateClass.TOTAL, 84 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 85 | suggested_display_precision=0, 86 | entity_registry_enabled_default=False, 87 | ), 88 | f"PF{index}": SensorEntityDescription( 89 | key=f"PF{index}", 90 | translation_key="power_factor", 91 | device_class=SensorDeviceClass.POWER_FACTOR, 92 | state_class=SensorStateClass.MEASUREMENT, 93 | suggested_display_precision=2, 94 | ), 95 | } 96 | 97 | 98 | def point_description(index) -> dict[str, SensorEntityDescription]: 99 | """Generate entity description for a point""" 100 | return { 101 | f"T{index}": SensorEntityDescription( 102 | key=f"T{index}", 103 | translation_key="point", 104 | state_class=SensorStateClass.MEASUREMENT, 105 | ), 106 | } 107 | 108 | 109 | def channel_description(index) -> dict[str, SensorEntityDescription]: 110 | """Generate entity description for a channel""" 111 | return { 112 | f"Ch{index}": SensorEntityDescription( 113 | key=f"Ch{index}", 114 | translation_key="channel", 115 | state_class=SensorStateClass.MEASUREMENT, 116 | ), 117 | f"TCh{index}": SensorEntityDescription( 118 | key=f"TCh{index}", 119 | translation_key="total_channel", 120 | state_class=SensorStateClass.MEASUREMENT, 121 | ), 122 | } 123 | 124 | 125 | ENTITY_DESCRIPTIONS: dict[str, SensorEntityDescription] = { 126 | # D101 127 | **phase_descriptions(""), 128 | # D103 129 | **phase_descriptions("1"), 130 | **phase_descriptions("2"), 131 | **phase_descriptions("3"), 132 | # D105 133 | **point_description("1"), 134 | **point_description("2"), 135 | **point_description("3"), 136 | **point_description("4"), 137 | **point_description("5"), 138 | **channel_description("1"), 139 | **channel_description("2"), 140 | "ADC": SensorEntityDescription( 141 | key="ADC", 142 | translation_key="adc", 143 | state_class=SensorStateClass.MEASUREMENT, 144 | ), 145 | # Common 146 | "Temp": SensorEntityDescription( 147 | key="Temp", 148 | translation_key="device_temperature", 149 | device_class=SensorDeviceClass.TEMPERATURE, 150 | state_class=SensorStateClass.MEASUREMENT, 151 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 152 | suggested_display_precision=0, 153 | ), 154 | } 155 | 156 | # NOTE: dict keys here match API response 157 | # But we align "key" values with single phase for consistency 158 | PHASE_TOTAL_DESCRIPTIONS: dict[str, SensorEntityDescription] = { 159 | "A": SensorEntityDescription( 160 | key="A", 161 | translation_key="total_current", 162 | device_class=SensorDeviceClass.CURRENT, 163 | state_class=SensorStateClass.MEASUREMENT, 164 | native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, 165 | suggested_display_precision=2, 166 | ), 167 | "W": SensorEntityDescription( 168 | key="W", 169 | translation_key="total_power", 170 | device_class=SensorDeviceClass.POWER, 171 | state_class=SensorStateClass.MEASUREMENT, 172 | native_unit_of_measurement=UnitOfPower.WATT, 173 | suggested_display_precision=0, 174 | ), 175 | "rW": SensorEntityDescription( 176 | key="rW", 177 | translation_key="total_return_power", 178 | device_class=SensorDeviceClass.POWER, 179 | state_class=SensorStateClass.MEASUREMENT, 180 | native_unit_of_measurement=UnitOfPower.WATT, 181 | suggested_display_precision=0, 182 | entity_registry_enabled_default=False, 183 | ), 184 | "TWh": SensorEntityDescription( 185 | key="Wh", 186 | translation_key="total_consumption", 187 | device_class=SensorDeviceClass.ENERGY, 188 | state_class=SensorStateClass.TOTAL_INCREASING, 189 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 190 | suggested_display_precision=0, 191 | ), 192 | "rTWh": SensorEntityDescription( 193 | key="rWh", 194 | translation_key="total_return", 195 | device_class=SensorDeviceClass.ENERGY, 196 | state_class=SensorStateClass.TOTAL, 197 | native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, 198 | suggested_display_precision=0, 199 | entity_registry_enabled_default=False, 200 | ), 201 | } 202 | 203 | 204 | async def async_setup_entry( 205 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 206 | ) -> None: 207 | """Create Smart MAIC sensor entities in HASS.""" 208 | coordinator: SmartMaicCoordinator = hass.data[DOMAIN][entry.entry_id] 209 | 210 | async_add_entities( 211 | [ 212 | SmartMaicSensor(hass, coordinator, entry, description) 213 | for ent in coordinator.data 214 | if (description := ENTITY_DESCRIPTIONS.get(ent)) 215 | ] 216 | ) 217 | 218 | # NOTE: check if we're dealing with 3-phase device like D103 219 | if "A1" in coordinator.data: 220 | async_add_entities( 221 | [ 222 | SmartMaicPhaseTotalSensor(hass, coordinator, entry, description) 223 | for ent in PHASE_TOTAL_DESCRIPTIONS 224 | if (description := PHASE_TOTAL_DESCRIPTIONS.get(ent)) 225 | ] 226 | ) 227 | 228 | 229 | class SmartMaicSensor(SmartMaicEntity, SensorEntity): 230 | """Representation of the Smart MAIC sensor.""" 231 | 232 | @property 233 | def native_value(self) -> StateType: 234 | """Return the state of the sensor.""" 235 | value = self.coordinator.data.get(self.entity_description.key) 236 | return None if value is None else cast(StateType, value) 237 | 238 | 239 | class SmartMaicPhaseTotalSensor(SmartMaicEntity, SensorEntity): 240 | """Representation of the Smart MAIC total sensor.""" 241 | 242 | @property 243 | def native_value(self) -> StateType: 244 | """Return the state of the sensor.""" 245 | base_key = self.entity_description.key 246 | data = self.coordinator.data 247 | 248 | if data: 249 | return cast( 250 | StateType, 251 | data[f"{base_key}1"] + data[f"{base_key}2"] + data[f"{base_key}3"], 252 | ) 253 | 254 | return None 255 | --------------------------------------------------------------------------------