Skip to content

Commit 087a938

Browse files
Add forecast service to amberelectric (home-assistant#144848)
Co-authored-by: G Johansson <[email protected]>
1 parent c058561 commit 087a938

File tree

16 files changed

+881
-219
lines changed

16 files changed

+881
-219
lines changed

homeassistant/components/amberelectric/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,22 @@
22

33
import amberelectric
44

5+
from homeassistant.components.sensor import ConfigType
56
from homeassistant.const import CONF_API_TOKEN
67
from homeassistant.core import HomeAssistant
8+
from homeassistant.helpers import config_validation as cv
79

8-
from .const import CONF_SITE_ID, PLATFORMS
10+
from .const import CONF_SITE_ID, DOMAIN, PLATFORMS
911
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator
12+
from .services import setup_services
13+
14+
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
15+
16+
17+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
18+
"""Set up the Amber component."""
19+
setup_services(hass)
20+
return True
1021

1122

1223
async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool:
Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
"""Amber Electric Constants."""
22

33
import logging
4+
from typing import Final
45

56
from homeassistant.const import Platform
67

7-
DOMAIN = "amberelectric"
8+
DOMAIN: Final = "amberelectric"
89
CONF_SITE_NAME = "site_name"
910
CONF_SITE_ID = "site_id"
1011

12+
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
13+
ATTR_CHANNEL_TYPE = "channel_type"
14+
1115
ATTRIBUTION = "Data provided by Amber Electric"
1216

1317
LOGGER = logging.getLogger(__package__)
1418
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
19+
20+
SERVICE_GET_FORECASTS = "get_forecasts"
21+
22+
GENERAL_CHANNEL = "general"
23+
CONTROLLED_LOAD_CHANNEL = "controlled_load"
24+
FEED_IN_CHANNEL = "feed_in"

homeassistant/components/amberelectric/coordinator.py

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@
1010
from amberelectric.models.channel import ChannelType
1111
from amberelectric.models.current_interval import CurrentInterval
1212
from amberelectric.models.forecast_interval import ForecastInterval
13-
from amberelectric.models.price_descriptor import PriceDescriptor
1413
from amberelectric.rest import ApiException
1514

1615
from homeassistant.config_entries import ConfigEntry
1716
from homeassistant.core import HomeAssistant
1817
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1918

2019
from .const import LOGGER
20+
from .helpers import normalize_descriptor
2121

2222
type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator]
2323

@@ -49,27 +49,6 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) ->
4949
return interval.channel_type == ChannelType.FEEDIN
5050

5151

52-
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
53-
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
54-
if descriptor is None:
55-
return None
56-
if descriptor.value == "spike":
57-
return "spike"
58-
if descriptor.value == "high":
59-
return "high"
60-
if descriptor.value == "neutral":
61-
return "neutral"
62-
if descriptor.value == "low":
63-
return "low"
64-
if descriptor.value == "veryLow":
65-
return "very_low"
66-
if descriptor.value == "extremelyLow":
67-
return "extremely_low"
68-
if descriptor.value == "negative":
69-
return "negative"
70-
return None
71-
72-
7352
class AmberUpdateCoordinator(DataUpdateCoordinator):
7453
"""AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read."""
7554

@@ -103,7 +82,7 @@ def update_price_data(self) -> dict[str, dict[str, Any]]:
10382
"grid": {},
10483
}
10584
try:
106-
data = self._api.get_current_prices(self.site_id, next=48)
85+
data = self._api.get_current_prices(self.site_id, next=288)
10786
intervals = [interval.actual_instance for interval in data]
10887
except ApiException as api_exception:
10988
raise UpdateFailed("Missing price data, skipping update") from api_exception
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Formatting helpers used to convert things."""
2+
3+
from amberelectric.models.price_descriptor import PriceDescriptor
4+
5+
DESCRIPTOR_MAP: dict[str, str] = {
6+
PriceDescriptor.SPIKE: "spike",
7+
PriceDescriptor.HIGH: "high",
8+
PriceDescriptor.NEUTRAL: "neutral",
9+
PriceDescriptor.LOW: "low",
10+
PriceDescriptor.VERYLOW: "very_low",
11+
PriceDescriptor.EXTREMELYLOW: "extremely_low",
12+
PriceDescriptor.NEGATIVE: "negative",
13+
}
14+
15+
16+
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
17+
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
18+
if descriptor in DESCRIPTOR_MAP:
19+
return DESCRIPTOR_MAP[descriptor]
20+
return None
21+
22+
23+
def format_cents_to_dollars(cents: float) -> float:
24+
"""Return a formatted conversion from cents to dollars."""
25+
return round(cents / 100, 2)

homeassistant/components/amberelectric/icons.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,10 @@
2222
}
2323
}
2424
}
25+
},
26+
"services": {
27+
"get_forecasts": {
28+
"service": "mdi:transmission-tower"
29+
}
2530
}
2631
}

homeassistant/components/amberelectric/sensor.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,12 @@
2323
from homeassistant.helpers.update_coordinator import CoordinatorEntity
2424

2525
from .const import ATTRIBUTION
26-
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator, normalize_descriptor
26+
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator
27+
from .helpers import format_cents_to_dollars, normalize_descriptor
2728

2829
UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}"
2930

3031

31-
def format_cents_to_dollars(cents: float) -> float:
32-
"""Return a formatted conversion from cents to dollars."""
33-
return round(cents / 100, 2)
34-
35-
3632
def friendly_channel_type(channel_type: str) -> str:
3733
"""Return a human readable version of the channel type."""
3834
if channel_type == "controlled_load":
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Amber Electric Service class."""
2+
3+
from amberelectric.models.channel import ChannelType
4+
import voluptuous as vol
5+
6+
from homeassistant.config_entries import ConfigEntryState
7+
from homeassistant.core import (
8+
HomeAssistant,
9+
ServiceCall,
10+
ServiceResponse,
11+
SupportsResponse,
12+
)
13+
from homeassistant.exceptions import ServiceValidationError
14+
from homeassistant.helpers.selector import ConfigEntrySelector
15+
from homeassistant.util.json import JsonValueType
16+
17+
from .const import (
18+
ATTR_CHANNEL_TYPE,
19+
ATTR_CONFIG_ENTRY_ID,
20+
CONTROLLED_LOAD_CHANNEL,
21+
DOMAIN,
22+
FEED_IN_CHANNEL,
23+
GENERAL_CHANNEL,
24+
SERVICE_GET_FORECASTS,
25+
)
26+
from .coordinator import AmberConfigEntry
27+
from .helpers import format_cents_to_dollars, normalize_descriptor
28+
29+
GET_FORECASTS_SCHEMA = vol.Schema(
30+
{
31+
ATTR_CONFIG_ENTRY_ID: ConfigEntrySelector({"integration": DOMAIN}),
32+
ATTR_CHANNEL_TYPE: vol.In(
33+
[GENERAL_CHANNEL, CONTROLLED_LOAD_CHANNEL, FEED_IN_CHANNEL]
34+
),
35+
}
36+
)
37+
38+
39+
def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> AmberConfigEntry:
40+
"""Get the Amber config entry."""
41+
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
42+
raise ServiceValidationError(
43+
translation_domain=DOMAIN,
44+
translation_key="integration_not_found",
45+
translation_placeholders={"target": config_entry_id},
46+
)
47+
if entry.state is not ConfigEntryState.LOADED:
48+
raise ServiceValidationError(
49+
translation_domain=DOMAIN,
50+
translation_key="not_loaded",
51+
translation_placeholders={"target": entry.title},
52+
)
53+
return entry
54+
55+
56+
def get_forecasts(channel_type: str, data: dict) -> list[JsonValueType]:
57+
"""Return an array of forecasts."""
58+
results: list[JsonValueType] = []
59+
60+
if channel_type not in data["forecasts"]:
61+
raise ServiceValidationError(
62+
translation_domain=DOMAIN,
63+
translation_key="channel_not_found",
64+
translation_placeholders={"channel_type": channel_type},
65+
)
66+
67+
intervals = data["forecasts"][channel_type]
68+
69+
for interval in intervals:
70+
datum = {}
71+
datum["duration"] = interval.duration
72+
datum["date"] = interval.var_date.isoformat()
73+
datum["nem_date"] = interval.nem_time.isoformat()
74+
datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh)
75+
if interval.channel_type == ChannelType.FEEDIN:
76+
datum["per_kwh"] = datum["per_kwh"] * -1
77+
datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh)
78+
datum["start_time"] = interval.start_time.isoformat()
79+
datum["end_time"] = interval.end_time.isoformat()
80+
datum["renewables"] = round(interval.renewables)
81+
datum["spike_status"] = interval.spike_status.value
82+
datum["descriptor"] = normalize_descriptor(interval.descriptor)
83+
84+
if interval.range is not None:
85+
datum["range_min"] = format_cents_to_dollars(interval.range.min)
86+
datum["range_max"] = format_cents_to_dollars(interval.range.max)
87+
88+
if interval.advanced_price is not None:
89+
multiplier = -1 if interval.channel_type == ChannelType.FEEDIN else 1
90+
datum["advanced_price_low"] = multiplier * format_cents_to_dollars(
91+
interval.advanced_price.low
92+
)
93+
datum["advanced_price_predicted"] = multiplier * format_cents_to_dollars(
94+
interval.advanced_price.predicted
95+
)
96+
datum["advanced_price_high"] = multiplier * format_cents_to_dollars(
97+
interval.advanced_price.high
98+
)
99+
100+
results.append(datum)
101+
102+
return results
103+
104+
105+
def setup_services(hass: HomeAssistant) -> None:
106+
"""Set up the services for the Amber integration."""
107+
108+
async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse:
109+
channel_type = call.data[ATTR_CHANNEL_TYPE]
110+
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
111+
coordinator = entry.runtime_data
112+
forecasts = get_forecasts(channel_type, coordinator.data)
113+
return {"forecasts": forecasts}
114+
115+
hass.services.async_register(
116+
DOMAIN,
117+
SERVICE_GET_FORECASTS,
118+
handle_get_forecasts,
119+
GET_FORECASTS_SCHEMA,
120+
supports_response=SupportsResponse.ONLY,
121+
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
get_forecasts:
2+
fields:
3+
config_entry_id:
4+
required: true
5+
selector:
6+
config_entry:
7+
integration: amberelectric
8+
channel_type:
9+
required: true
10+
selector:
11+
select:
12+
options:
13+
- general
14+
- controlled_load
15+
- feed_in
16+
translation_key: channel_type
Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,61 @@
11
{
22
"config": {
3+
"error": {
4+
"invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]",
5+
"no_site": "No site provided",
6+
"unknown_error": "[%key:common::config_flow::error::unknown%]"
7+
},
38
"step": {
9+
"site": {
10+
"data": {
11+
"site_id": "Site NMI",
12+
"site_name": "Site name"
13+
},
14+
"description": "Select the NMI of the site you would like to add"
15+
},
416
"user": {
517
"data": {
618
"api_token": "[%key:common::config_flow::data::api_token%]",
719
"site_id": "Site ID"
820
},
921
"description": "Go to {api_url} to generate an API key"
10-
},
11-
"site": {
12-
"data": {
13-
"site_id": "Site NMI",
14-
"site_name": "Site Name"
22+
}
23+
}
24+
},
25+
"services": {
26+
"get_forecasts": {
27+
"name": "Get price forecasts",
28+
"description": "Retrieves price forecasts from Amber Electric for a site.",
29+
"fields": {
30+
"config_entry_id": {
31+
"description": "The config entry of the site to get forecasts for.",
32+
"name": "Config entry"
1533
},
16-
"description": "Select the NMI of the site you would like to add"
34+
"channel_type": {
35+
"name": "Channel type",
36+
"description": "The channel to get forecasts for."
37+
}
1738
}
39+
}
40+
},
41+
"exceptions": {
42+
"integration_not_found": {
43+
"message": "Config entry \"{target}\" not found in registry."
1844
},
19-
"error": {
20-
"invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]",
21-
"no_site": "No site provided",
22-
"unknown_error": "[%key:common::config_flow::error::unknown%]"
45+
"not_loaded": {
46+
"message": "{target} is not loaded."
47+
},
48+
"channel_not_found": {
49+
"message": "There is no {channel_type} channel at this site."
50+
}
51+
},
52+
"selector": {
53+
"channel_type": {
54+
"options": {
55+
"general": "General",
56+
"controlled_load": "Controlled load",
57+
"feed_in": "Feed-in"
58+
}
2359
}
2460
}
2561
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,13 @@
11
"""Tests for the amberelectric integration."""
2+
3+
from homeassistant.core import HomeAssistant
4+
5+
from tests.common import MockConfigEntry
6+
7+
8+
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
9+
"""Fixture for setting up the component."""
10+
config_entry.add_to_hass(hass)
11+
12+
await hass.config_entries.async_setup(config_entry.entry_id)
13+
await hass.async_block_till_done()

0 commit comments

Comments
 (0)