Skip to content
This repository has been archived by the owner on Jul 12, 2023. It is now read-only.

Commit

Permalink
Merge pull request #57 from mjmeli/0.1.0-beta
Browse files Browse the repository at this point in the history
0.1.0 Release
  • Loading branch information
mjmeli authored Dec 28, 2021
2 parents 232cdb0 + d2ca8ae commit 1b4cdd2
Show file tree
Hide file tree
Showing 11 changed files with 386 additions and 154 deletions.
2 changes: 2 additions & 0 deletions .devcontainer/configuration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ logger:
default: info
logs:
custom_components.duke_energy_gateway: debug
pyduke_energy.client: debug
pyduke_energy.realtime: debug
# If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/)
# debugpy:
57 changes: 48 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,27 @@
[![Project Maintenance][maintenance-shield]][user_profile]
[![BuyMeCoffee][buymecoffeebadge]][buymecoffee]

This is a custom integration for [Home Assistant](https://www.home-assistant.io/). It pulls near-real-time energy usage from Duke Energy via the Duke Energy Gateway pilot program.
This is a custom integration for [Home Assistant](https://www.home-assistant.io/). It pulls real-time energy usage from Duke Energy via the Duke Energy Gateway pilot program.

This component will set up the following entities.
This integration leverages the [`pyduke-energy`](https://github.com/mjmeli/pyduke-energy) library, also written by me, to pull data. This API is _very_ unofficial and may stop working at any time (see [Disclaimer](https://github.com/mjmeli/pyduke-energy#Disclaimer)). Also, you are required to have a Duke Energy Gateway connected to your smartmeter for this to work. This integration does not support any other method of retrieving data (see [Gateway Requirement](https://github.com/mjmeli/pyduke-energy#gateway-requirement)).

| Platform | Description |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sensor.duke_energy_usage_today_kwh` | Represents today's energy consumption (from 0:00 local time to 23:59 local time, then resetting). Additional attributes are available containing the meter ID, gateway ID, and the timestamp of the last measurement. |
## Sensors

This integration leverages the [`pyduke-energy`](https://github.com/mjmeli/pyduke-energy) library, also written by me, to pull data. This API is _very_ unofficial and may stop working at any time (see [Disclaimer](https://github.com/mjmeli/pyduke-energy#Disclaimer)). Also, you are required to have a Duke Energy Gateway connected to your smartmeter for this to work. This integration does not support any other method of retrieving data (see [Gateway Requirement](https://github.com/mjmeli/pyduke-energy#gateway-requirement)).
This component will set up the following entities:

### `sensor.duke_energy_current_usage_w`

- Represents the real-time _power_ usage in watts.
- This data is pushed from the gateway device every 1-3 seconds. _NOTE:_ This produces a lot of data. If this update interval is too frequent for you, you can configure a throttling interval in seconds (see [Configuration](#Configuration) below).
- Note that since this is power usage, it cannot be used as-is for the Home Assistant energy dashboard. Instead, you can use the `sensor.duke_energy_usage_today_kwh` sensor, or you need to feed this real-time sensor through the [Riemann sum integral integration](https://www.home-assistant.io/integrations/integration/).
- Additional attributes are available containing the meter ID and gateway ID.

Energy usage will be provided as _daily_ data, resetting at midnight local time. At the moment, the API appears to be limited to providing new records every 15 minutes, meaning readings could be delayed up to 15 minutes. For more information, see [limitations](https://github.com/mjmeli/pyduke-energy#limitations) in the `pyduke-energy` repo.
### `sensor.duke_energy_usage_today_kwh`

- Represents today's _energy_ consumption in kilowatt-hours (from 0:00 local time to 23:59 local time, then resetting).
- This data is polled every 60 seconds, but data may be delayed up to 15 minutes due to delays in Duke Energy reporting it (see [Limitations](https://github.com/mjmeli/pyduke-energy#Limitations) in the `pyduke-energy` repo.).
- This can be used as-is for the Home Assistant energy consumption dashboard.
- Additional attributes are available containing the meter ID, gateway ID, and the timestamp of the last measurement.

## Installation

Expand All @@ -42,19 +52,40 @@ Energy usage will be provided as _daily_ data, resetting at midnight local time.
6. Restart Home Assistant
7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Duke Energy Gateway"

## Configuration is done in the UI
## Configuration

Configuration will be done in the UI. You will need to provide the following data:
Configuration will be done in the UI. Initially, you will need to provide the following data:

| Data | Description |
| ---------- | ----------------------------------- |
| `email` | Your login email to Duke Energy. |
| `password` | Your login password to Duke Energy. |

After the integration is setup, you will be able to do further configuration by clicking "Configure" on the integration page. This will allow you to modify the following options:

| Data | Description |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Real-time Usage Update Interval (sec)` | By default, the real-time usage sensor will be updated any time a reading comes in. If this data is too frequent, you can configure this value to throttle the data. When set to a positive integer `X`, the sensor will only be updated once every `X` seconds. In other words, if set to 30, you will get a new real-time usage every ~30 seconds. |

### Meter Selection

The configuration flow will automatically attempt to identify your gateway and smartmeter. Right now, only one is supported per account. The first one identified will be used. If one cannot be found, the configuration process should fail.

If your meter selection fails, a first step should be to enable logging for the component (see [Logging](#Logging)). If this does not give insight into the problem, please open a GitHub issue.

### Logging

If you run into any issues and want to look into the logs, this integration provides verbose logging at the debug level. That can be enabled by adding the following to your `configuration.yaml` file.

```yaml
logger:
default: info
logs:
custom_components.duke_energy_gateway: debug
pyduke_energy.client: debug
pyduke_energy.realtime: debug
```
## Development
I suggest using the dev container for development by opening in Visual Studio Code with `code .` and clicking on the option to re-open with dev container. In VS Code, you can run the task "Run Home Assistant on the port 9123" and then access it via http://localhost:9123.
Expand All @@ -63,6 +94,14 @@ If you want to install manually, you can install dev dependencies with `pip inst

Before commiting, run `pre-commit run --all-files`.

### Working With In Development `pyduke-energy` Versions

If you are working on implementing new changes from `pyduke-energy` but do not want to release version of that library, you can set up your development environment to install from a remote working branch.

1. Update [`requirements_dev.txt`](requirements_dev.txt) to replace the `main` in `git+https://github.com/mjmeli/pyduke-energy@main` with your working branch and update the username if you have a fork (e.g. `git+https://github.com/notmjmeli/pyduke-energy@new-feature-dev-branch`)
2. Uninstall locally cached version of `pyduke-energy`: `pip uninstall -y pyduke-energy`
3. Re-run requirements installation: `pip install -r requirements_dev.txt`

## Contributions are welcome!

If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md)
Expand Down
122 changes: 29 additions & 93 deletions custom_components/duke_energy_gateway/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,22 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import UpdateFailed
from homeassistant.util import dt
from pyduke_energy.client import DukeEnergyClient
from pyduke_energy.realtime import DukeEnergyRealtime

from .const import CONF_EMAIL
from .const import CONF_PASSWORD
from .const import CONF_REALTIME_INTERVAL
from .const import CONF_REALTIME_INTERVAL_DEFAULT_SEC
from .const import DOMAIN
from .const import PLATFORMS
from .const import STARTUP_MESSAGE

SCAN_INTERVAL = timedelta(seconds=60)
from .coordinator import DukeEnergyGatewayUsageDataUpdateCoordinator

_LOGGER: logging.Logger = logging.getLogger(__package__)


async def async_setup(hass: HomeAssistant, config: Config):
async def async_setup(_hass: HomeAssistant, _config: Config):
"""Set up this integration using YAML is not supported."""
return True

Expand All @@ -42,22 +41,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):

email = entry.data.get(CONF_EMAIL)
password = entry.data.get(CONF_PASSWORD)
realtime_interval = entry.options.get(
CONF_REALTIME_INTERVAL, CONF_REALTIME_INTERVAL_DEFAULT_SEC
)

session = async_get_clientsession(hass)
client = DukeEnergyClient(email, password, session)
_LOGGER.debug("Setup Duke Energy API client")
realtime = DukeEnergyRealtime(client)
_LOGGER.debug("Set up Duke Energy API clients")

# Try to find the meter that is used for the gateway
selected_meter, selected_gateway = await find_meter_with_gateway(client)
# Find the meter that is used for the gateway
selected_meter, selected_gateway = await client.select_default_meter()

# If no meter was found, we raise an error
if not selected_meter or not selected_gateway:
if not selected_meter:
_LOGGER.error(
"Could not identify a smart meter on your account with gateway access."
)
return False

coordinator = DukeEnergyGatewayUsageDataUpdateCoordinator(hass, client=client)
coordinator = DukeEnergyGatewayUsageDataUpdateCoordinator(
hass,
client=client,
realtime=realtime,
realtime_interval=timedelta(seconds=realtime_interval),
)
await coordinator.async_refresh()

if not coordinator.last_update_success:
Expand All @@ -76,93 +84,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
hass.config_entries.async_forward_entry_setup(entry, platform)
)

entry.add_update_listener(async_reload_entry)
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
return True


async def find_meter_with_gateway(client: DukeEnergyClient):
"""Find the meter that is used for the gateway by iterating through the accounts and meters."""
account_list = await client.get_account_list()
account_numbers_text = ",".join([f"'{a.src_acct_id}'" for a in account_list])
_LOGGER.debug(
f"Accounts to check for gateway ({len(account_list)}): {account_numbers_text}"
)
for account in account_list:
try:
_LOGGER.debug(f"Checking account '{account.src_acct_id}' for gateway")
account_details = await client.get_account_details(account)
serial_numbers_text = ",".join(
[f"'{m.serial_num}'" for m in account_details.meter_infos]
)
_LOGGER.debug(
f"Meters to check for gateway ({len(account_details.meter_infos)}): {serial_numbers_text}"
)
for meter in account_details.meter_infos:
try:
_LOGGER.debug(
f"Checking meter '{meter.serial_num}' for gateway [meter_type={meter.meter_type}, is_certified_smart_meter={meter.is_certified_smart_meter}]"
)
if (
meter.serial_num
and meter.meter_type.upper() # sometimes blank meters show up
== "ELECTRIC"
and meter.is_certified_smart_meter
):
client.select_meter(meter)
gw_status = await client.get_gateway_status()
if gw_status is not None:
_LOGGER.debug(
f"Found meter '{meter.serial_num}' with gateway '{gw_status.id}'"
)
return meter, gw_status
else:
_LOGGER.debug(
f"No gateway status for meter '{meter.serial_num}'"
)
except Exception as e:
# Try the next meter if anything fails above
_LOGGER.debug(
f"Failed to check meter '{meter.serial_num}' on account '{account.src_acct_id}': {e}"
)
pass
except Exception as e:
# Try the next account if anything fails above
_LOGGER.debug(
f"Failed to find meter on account '{account.src_acct_id}': {e}"
)
pass
return None, None


class DukeEnergyGatewayUsageDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching usage data from the API."""

def __init__(
self,
hass: HomeAssistant,
client: DukeEnergyClient,
) -> None:
"""Initialize."""
self.api = client
self.platforms = []

super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)

async def _async_update_data(self):
"""Update data via library to get last day of minute-by-minute usage data."""
try:
today_start = dt.start_of_local_day()
today_end = today_start + timedelta(days=1)
return await self.api.get_gateway_usage(today_start, today_end)
except Exception as exception:
raise UpdateFailed(
f"Error communicating with Duke Energy Usage API: {exception}"
) from exception


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
coordinator: DukeEnergyGatewayUsageDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
]["coordinator"]

unloaded = all(
await asyncio.gather(
*[
Expand All @@ -175,6 +106,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unloaded:
hass.data[DOMAIN].pop(entry.entry_id)

# Cleanup real-time stream if it wasn't already done so (it should be done by the sensor entity)
_LOGGER.debug("Checking for clean-up of real-time stream in async_unload_entry")
coordinator.realtime_cancel()
coordinator.async_realtime_unsubscribe_all_from_dispatcher()

return unloaded


Expand Down
18 changes: 15 additions & 3 deletions custom_components/duke_energy_gateway/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@

from .const import CONF_EMAIL
from .const import CONF_PASSWORD
from .const import CONF_REALTIME_INTERVAL
from .const import CONF_REALTIME_INTERVAL_DEFAULT_SEC
from .const import DOMAIN
from .const import PLATFORMS


class DukeEnergyGatewayFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
Expand Down Expand Up @@ -89,18 +90,29 @@ async def async_step_user(self, user_input=None):
self.options.update(user_input)
return await self._update_options()

realtime_interval = self.options.get(
CONF_REALTIME_INTERVAL, CONF_REALTIME_INTERVAL_DEFAULT_SEC
)

return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(x, default=self.options.get(x, True)): bool
for x in sorted(PLATFORMS)
vol.Required(
CONF_REALTIME_INTERVAL,
default=realtime_interval,
): int,
}
),
)

async def _update_options(self):
"""Update config entry options."""
update_interval = self.options.get(
CONF_REALTIME_INTERVAL, CONF_REALTIME_INTERVAL_DEFAULT_SEC
)
if update_interval < 0:
return self.async_abort(reason="invalid_update_interval_value")
return self.async_create_entry(
title=self.config_entry.data.get(CONF_EMAIL), data=self.options
)
4 changes: 4 additions & 0 deletions custom_components/duke_energy_gateway/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@
CONF_ENABLED = "enabled"
CONF_EMAIL = "email"
CONF_PASSWORD = "password"
CONF_REALTIME_INTERVAL = "realtimeInterval"
CONF_REALTIME_INTERVAL_DEFAULT_SEC = 0 # no throttling

# Defaults
DEFAULT_NAME = DOMAIN

REALTIME_DISPATCH_SIGNAL = f"{DOMAIN}_realtime_dispatch_signal"

STARTUP_MESSAGE = f"""
-------------------------------------------------------------------
{NAME}
Expand Down
Loading

0 comments on commit 1b4cdd2

Please sign in to comment.