├── 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 |
15 |
16 | ### Sync consumed energy with an external meter from the UI
17 |
18 |
19 |
20 | ### Control the dry switch
21 |
22 |
23 |
24 | ### Use the UI to set up integration
25 |
26 |
27 |
28 | ## Installation
29 |
30 | ### Via HACS
31 |
32 | [](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 |
--------------------------------------------------------------------------------