Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions homeassistant/components/zendure_p1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""The Zendure Smart Meter P1 integration."""

from zendure_p1 import ZendureP1Client

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady

from .const import PLATFORMS
from .coordinator import ZendureP1Coordinator

type ZendureP1ConfigEntry = ConfigEntry[ZendureP1Coordinator]


async def async_setup_entry(hass: HomeAssistant, entry: ZendureP1ConfigEntry) -> bool:
"""Set up Zendure Smart Meter P1 from a config entry."""
api = ZendureP1Client(entry.data[CONF_HOST])
coordinator = ZendureP1Coordinator(hass, entry, api)
entry.runtime_data = coordinator

try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
await api.close()
raise

entry.async_on_unload(api.close)
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
Comment thread
NextNebula marked this conversation as resolved.
Outdated
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True


async def async_reload_entry(hass: HomeAssistant, entry: ZendureP1ConfigEntry) -> None:
"""Reload the config entry when options change."""
await hass.config_entries.async_reload(entry.entry_id)


async def async_unload_entry(hass: HomeAssistant, entry: ZendureP1ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
61 changes: 61 additions & 0 deletions homeassistant/components/zendure_p1/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Config flow for the Zendure Smart Meter P1 integration."""

from typing import Any

import voluptuous as vol
from zendure_p1 import (
ZendureP1Client,
ZendureP1ConnectionError,
ZendureP1ResponseError,
ZendureP1TimeoutError,
)

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST

from .const import DOMAIN, LOGGER

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
Comment thread
NextNebula marked this conversation as resolved.
Outdated
}
)


class ZendureP1ConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Zendure Smart Meter P1."""

VERSION = 1

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
device_id = await self._async_validate_host(user_input[CONF_HOST])
except (
ZendureP1ConnectionError,
ZendureP1ResponseError,
ZendureP1TimeoutError,
) as err:
LOGGER.exception("Cannot connect to Zendure P1: %s", err)
Comment thread
NextNebula marked this conversation as resolved.
Outdated
Comment thread
NextNebula marked this conversation as resolved.
Outdated
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(device_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=device_id,
data=user_input,
)

return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

async def _async_validate_host(self, host: str) -> str:
"""Validate host by connecting and return the device ID."""
async with ZendureP1Client(host) as client:
report = await client.get_report()
return report.device_id
13 changes: 13 additions & 0 deletions homeassistant/components/zendure_p1/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Constants for the Zendure Smart Meter P1 integration."""

from datetime import timedelta
import logging

from homeassistant.const import Platform

DOMAIN = "zendure_p1"
PLATFORMS = [Platform.SENSOR]

LOGGER = logging.getLogger(__package__)

UPDATE_INTERVAL = timedelta(seconds=1)
Comment thread
NextNebula marked this conversation as resolved.
Outdated
56 changes: 56 additions & 0 deletions homeassistant/components/zendure_p1/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""DataUpdateCoordinator for the Zendure Smart Meter P1 integration."""

from typing import TYPE_CHECKING

from zendure_p1 import (
Report,
ZendureP1Client,
ZendureP1ConnectionError,
ZendureP1ResponseError,
ZendureP1TimeoutError,
)

from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DOMAIN, LOGGER, UPDATE_INTERVAL

if TYPE_CHECKING:
from . import ZendureP1ConfigEntry


class ZendureP1Coordinator(DataUpdateCoordinator[Report]):
"""Coordinator for the Zendure Smart Meter P1."""

def __init__(
self,
hass: HomeAssistant,
entry: ZendureP1ConfigEntry,
api: ZendureP1Client,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
LOGGER,
name=DOMAIN,
config_entry=entry,
update_interval=UPDATE_INTERVAL,
)
self.api = api

async def _async_update_data(self) -> Report:
"""Fetch data from api."""
try:
return await self.api.get_report()
except ZendureP1ConnectionError as ex:
raise UpdateFailed(
ex, translation_domain=DOMAIN, translation_key="connection_error"
) from ex
except ZendureP1TimeoutError as ex:
raise UpdateFailed(
ex, translation_domain=DOMAIN, translation_key="timeout_error"
) from ex
except ZendureP1ResponseError as ex:
raise UpdateFailed(
ex, translation_domain=DOMAIN, translation_key="response_error"
) from ex
Comment thread
NextNebula marked this conversation as resolved.
22 changes: 22 additions & 0 deletions homeassistant/components/zendure_p1/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Base entity for Zendure Smart Meter P1 integration."""

from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN
from .coordinator import ZendureP1Coordinator


class ZendureP1Entity(CoordinatorEntity[ZendureP1Coordinator]):
"""Defines a base Zendure Smart Meter P1 entity."""

_attr_has_entity_name = True

def __init__(self, coordinator: ZendureP1Coordinator) -> None:
"""Initialize the Zendure Smart Meter P1 entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.data.device_id)},
manufacturer="Zendure",
name="Smart Meter P1",
)
11 changes: 11 additions & 0 deletions homeassistant/components/zendure_p1/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"domain": "zendure_p1",
"name": "Zendure Smart Meter P1",
"codeowners": ["@NextNebula"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zendure_p1",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["zendure-p1==0.1.2"]
}
70 changes: 70 additions & 0 deletions homeassistant/components/zendure_p1/quality_scale.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
rules:
# Bronze
action-setup:
status: exempt
comment: The integration does not provide any actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: The integration does not provide any actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done

# Silver
action-exceptions:
status: exempt
comment: The integration does not provide any actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No additional configuration parameters beyond the host address.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: The integration does not use any authentication.
test-coverage: done

# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo

# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
99 changes: 99 additions & 0 deletions homeassistant/components/zendure_p1/sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Support for Zendure Smart Meter P1 sensors."""

from collections.abc import Callable
from dataclasses import dataclass

from zendure_p1 import Report

from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType

from . import ZendureP1ConfigEntry
from .coordinator import ZendureP1Coordinator
from .entity import ZendureP1Entity

PARALLEL_UPDATES = 0


@dataclass(frozen=True, kw_only=True)
class ZendureP1SensorEntityDescription(SensorEntityDescription):
"""Describes a Zendure Smart Meter P1 sensor."""

value_fn: Callable[[Report], StateType]


SENSORS: tuple[ZendureP1SensorEntityDescription, ...] = (
ZendureP1SensorEntityDescription(
key="a_apparent_power",
translation_key="a_apparent_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
Comment thread
NextNebula marked this conversation as resolved.
Outdated
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda report: report.a_apparent_power,
),
ZendureP1SensorEntityDescription(
key="b_apparent_power",
translation_key="b_apparent_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda report: report.b_apparent_power,
),
ZendureP1SensorEntityDescription(
key="c_apparent_power",
translation_key="c_apparent_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda report: report.c_apparent_power,
),
ZendureP1SensorEntityDescription(
key="total_power",
translation_key="total_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda report: report.total_power,
),
)


async def async_setup_entry(
hass: HomeAssistant,
entry: ZendureP1ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Zendure Smart Meter P1 sensor entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
ZendureP1SensorEntity(coordinator, description) for description in SENSORS
)


class ZendureP1SensorEntity(ZendureP1Entity, SensorEntity):
"""Defines a Zendure Smart Meter P1 sensor entity."""

entity_description: ZendureP1SensorEntityDescription

def __init__(
self,
coordinator: ZendureP1Coordinator,
description: ZendureP1SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.device_id}-{description.key}"

@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)
Loading
Loading