Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
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.

36 changes: 36 additions & 0 deletions homeassistant/components/zendure_p1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""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

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True


async def async_unload_entry(hass: HomeAssistant, entry: ZendureP1ConfigEntry) -> bool:
"""Unload a config entry."""
if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.api.close()
return unloaded
62 changes: 62 additions & 0 deletions homeassistant/components/zendure_p1/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""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 homeassistant.helpers import config_validation as cv

from .const import DOMAIN, LOGGER

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
}
)


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.debug("Cannot connect to Zendure P1: %s", err)
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=5)
Comment thread
NextNebula marked this conversation as resolved.
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(
f"Error communicating with the Zendure P1 device: {ex}"
) from ex
except ZendureP1TimeoutError as ex:
raise UpdateFailed(
f"Timeout communicating with the Zendure P1 device: {ex}"
) from ex
except ZendureP1ResponseError as ex:
raise UpdateFailed(
f"Invalid response from the Zendure P1 device: {ex}"
) 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