From 5ad73ea8fed37ed37d1a4a7d42e9de316e4b784b Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 10 Sep 2021 12:01:11 -0700 Subject: [PATCH 01/19] tests: add more tests (#36) * chore: add more tests * spelling --- .github/codecov.yaml | 31 +++ .github/release-drafter.yml | 2 +- custom_components/openei/__init__.py | 4 - tests/conftest.py | 4 +- tests/test_config_flow.py | 304 +++++++++++++++++++++++++++ tests/test_init.py | 6 +- 6 files changed, 342 insertions(+), 9 deletions(-) create mode 100644 .github/codecov.yaml create mode 100644 tests/test_config_flow.py diff --git a/.github/codecov.yaml b/.github/codecov.yaml new file mode 100644 index 0000000..08f0a2a --- /dev/null +++ b/.github/codecov.yaml @@ -0,0 +1,31 @@ +codecov: + require_ci_to_pass: yes + +coverage: + precision: 2 + round: down + range: "70...100" + status: + project: + default: + target: 90% + threshold: 1% + patch: + default: + target: 90% + +parsers: + gcov: + branch_detection: + conditional: yes + loop: yes + method: no + macro: no + +comment: + layout: "reach,diff,flags,tree" + behavior: default + require_changes: no + +ignore: + - "tests/" # no need to test the tests diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 7699afc..ecafea4 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -22,7 +22,7 @@ categories: - title: ":boom: Breaking Change :boom:" labels: - "breaking-change" - - title: ":zap: Enhancments :zap:" + - title: ":zap: Enhancements :zap:" labels: - "enhancement" - title: ":sparkles: New Features :sparkles:" diff --git a/custom_components/openei/__init__.py b/custom_components/openei/__init__.py index 0595ddb..89aaa56 100644 --- a/custom_components/openei/__init__.py +++ b/custom_components/openei/__init__.py @@ -167,10 +167,6 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non _LOGGER.debug("Attempting to reload entities from the %s integration", DOMAIN) - if config_entry.data == config_entry.options: - _LOGGER.debug("No changes detected not reloading entities.") - return - new_data = config_entry.options.copy() hass.config_entries.async_update_entry( diff --git a/tests/conftest.py b/tests/conftest.py index 890db2b..9408b68 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,9 @@ def auto_enable_custom_integrations(enable_custom_integrations): @pytest.fixture(name="mock_api") def mock_api(): """Mock the library calls.""" - with patch("custom_components.openei.openeihttp") as mock_api: + with patch("custom_components.openei.openeihttp"), patch( + "custom_components.openei.config_flow.openeihttp" + ): mock_conn = mock.Mock(spec=openeihttp.Rates) mock_conn.return_value.current_rate.return_value = 0.24477 mock_conn.return_value.distributed_generation.return_value = "Net Metering" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..af6f972 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,304 @@ +"""Test OpenEI config flow.""" +from unittest.mock import patch + +import pytest +from homeassistant import config_entries, setup +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.openei.const import DOMAIN + + +@pytest.mark.parametrize( + "input_1,step_id_2,input_2,step_id_3,input_3,title,data", + [ + ( + { + "api_key": "fakeAPIKey", + "radius": "", + }, + "user_2", + { + "utility": "Fake Utility Co", + }, + "user_3", + { + "rate_plan": "randomstring", + "sensor": "(none)", + }, + "Fake Utility Co", + { + "api_key": "fakeAPIKey", + "radius": "", + "utility": "Fake Utility Co", + "rate_plan": "randomstring", + "sensor": "(none)", + }, + ), + ], +) +async def test_form( + input_1, + step_id_2, + input_2, + step_id_3, + input_3, + title, + data, + hass, + mock_api, +): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + # assert result["title"] == title_1 + + with patch( + "custom_components.openei.async_setup", return_value=True + ) as mock_setup, patch( + "custom_components.openei.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "custom_components.openei.config_flow._lookup_plans", + return_value={ + "Fake Utility Co": [{"name": "Fake Plan Name", "label": "randomstring"}] + }, + ), patch( + "custom_components.openei.config_flow._get_entities", + return_value=["(none)"], + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], input_1 + ) + assert result2["type"] == "form" + assert result2["step_id"] == step_id_2 + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], input_2 + ) + + assert result3["type"] == "form" + assert result3["step_id"] == step_id_3 + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], input_3 + ) + + assert result4["type"] == "create_entry" + assert result4["title"] == title + assert result4["data"] == data + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "input_1,step_id_2,input_2,step_id_3,input_3,title,data", + [ + ( + { + "api_key": "fakeAPIKey_new", + "radius": "20", + }, + "user_2", + { + "utility": "Fake Utility Co", + }, + "user_3", + { + "rate_plan": "randomstring", + "sensor": "(none)", + }, + "Fake Utility Co", + { + "api_key": "fakeAPIKey_new", + "radius": "20", + "utility": "Fake Utility Co", + "rate_plan": "randomstring", + "sensor": "(none)", + }, + ), + ], +) +async def test_options_flow( + input_1, + step_id_2, + input_2, + step_id_3, + input_3, + title, + data, + hass, + mock_api, +): + """Test config flow options.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Fake Utility Co", + data={ + "api_key": "fakeAPIKey", + "radius": "", + "utility": "Fake Utility Co", + "rate_plan": "randomstring", + "sensor": "(none)", + }, + ) + + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["errors"] == {} + # assert result["title"] == title_1 + + with patch("custom_components.openei.async_setup", return_value=True), patch( + "custom_components.openei.async_setup_entry", + return_value=True, + ), patch( + "custom_components.openei.config_flow._lookup_plans", + return_value={ + "Fake Utility Co": [{"name": "Fake Plan Name", "label": "randomstring"}] + }, + ), patch( + "custom_components.openei.config_flow._get_entities", + return_value=["(none)"], + ): + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], input_1 + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["step_id"] == step_id_2 + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], input_2 + ) + await hass.async_block_till_done() + + assert result3["type"] == "form" + assert result3["step_id"] == step_id_3 + result4 = await hass.config_entries.options.async_configure( + result["flow_id"], input_3 + ) + await hass.async_block_till_done() + assert result4["type"] == "create_entry" + assert data == entry.data.copy() + + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + "input_1,step_id_2,input_2,step_id_3,input_3,title,data", + [ + ( + { + "api_key": "fakeAPIKey", + "radius": "", + }, + "user_2", + { + "utility": "Fake Utility Co", + }, + "user_3", + { + "rate_plan": "randomstring", + "sensor": "(none)", + }, + "Fake Utility Co", + { + "api_key": "fakeAPIKey", + "radius": "", + "utility": "Fake Utility Co", + "rate_plan": "randomstring", + "sensor": "(none)", + }, + ), + ], +) +async def test_options_flow_no_changes( + input_1, + step_id_2, + input_2, + step_id_3, + input_3, + title, + data, + hass, + mock_api, + caplog, +): + """Test config flow options.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Fake Utility Co", + data={ + "api_key": "fakeAPIKey", + "radius": "", + "utility": "Fake Utility Co", + "rate_plan": "randomstring", + "sensor": "(none)", + }, + ) + + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["errors"] == {} + # assert result["title"] == title_1 + + with patch("custom_components.openei.async_setup", return_value=True), patch( + "custom_components.openei.async_setup_entry", + return_value=True, + ), patch( + "custom_components.openei.config_flow._lookup_plans", + return_value={ + "Fake Utility Co": [{"name": "Fake Plan Name", "label": "randomstring"}] + }, + ), patch( + "custom_components.openei.config_flow._get_entities", + return_value=["(none)"], + ): + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], input_1 + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["step_id"] == step_id_2 + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], input_2 + ) + await hass.async_block_till_done() + + assert result3["type"] == "form" + assert result3["step_id"] == step_id_3 + result4 = await hass.config_entries.options.async_configure( + result["flow_id"], input_3 + ) + await hass.async_block_till_done() + assert result4["type"] == "create_entry" + assert data == entry.data.copy() + + await hass.async_block_till_done() + assert ( + "Attempting to reload entities from the openei integration" in caplog.text + ) diff --git a/tests/test_init.py b/tests/test_init.py index 3e72c7c..1c59b99 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -22,7 +22,7 @@ async def test_setup_entry(hass, mock_sensors, mock_api): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 5 assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -40,14 +40,14 @@ async def test_unload_entry(hass, mock_sensors, mock_api): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 5 assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert await hass.config_entries.async_unload(entries[0].entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 5 assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 assert len(hass.states.async_entity_ids(DOMAIN)) == 0 From 381473b868a95b89feebce023c597228ee276f3e Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 10 Sep 2021 12:55:32 -0700 Subject: [PATCH 02/19] feat: add address/zip code support (#37) * feat: add address/zip code support fixes #31 * add tests to workflow * Update config_flow.py --- .github/workflows/pull.yml | 27 ++++++++++++++ .github/workflows/push.yml | 29 ++++++++++++++- custom_components/openei/__init__.py | 35 ++++++++++--------- custom_components/openei/config_flow.py | 28 +++++++++++---- custom_components/openei/const.py | 1 + custom_components/openei/manifest.json | 4 +-- custom_components/openei/translations/en.json | 7 ++-- requirements_tests.txt | 3 ++ tests/test_config_flow.py | 8 +++++ 9 files changed, 113 insertions(+), 29 deletions(-) diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 36e1ef4..71cebb9 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -29,3 +29,30 @@ jobs: python-version: "3.x" - run: python3 -m pip install black - run: black . + + tests: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v2" + - name: Setup Python + uses: "actions/setup-python@v1" + with: + python-version: "3.8" + - name: Install requirements + run: python3 -m pip install -r requirements_tests.txt + - name: Run tests + run: | + pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + --cov custom_components.openei \ + -o console_output_style=count \ + -p no:sugar \ + --cov-report=xml \ + tests + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 061e0ad..88ca34c 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -17,7 +17,7 @@ jobs: python-version: "3.x" - run: python3 -m pip install black - run: black . - + validate: runs-on: "ubuntu-latest" name: Validate @@ -32,3 +32,30 @@ jobs: - name: Hassfest validation uses: "home-assistant/actions/hassfest@master" + + tests: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v2" + - name: Setup Python + uses: "actions/setup-python@v1" + with: + python-version: "3.8" + - name: Install requirements + run: python3 -m pip install -r requirements_tests.txt + - name: Run tests + run: | + pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + --cov custom_components.openei \ + -o console_output_style=count \ + -p no:sugar \ + --cov-report=xml \ + tests + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 diff --git a/custom_components/openei/__init__.py b/custom_components/openei/__init__.py index 89aaa56..2368869 100644 --- a/custom_components/openei/__init__.py +++ b/custom_components/openei/__init__.py @@ -13,6 +13,7 @@ from .const import ( BINARY_SENSORS, CONF_API_KEY, + CONF_LOCATION, CONF_PLAN, CONF_RADIUS, CONF_SENSOR, @@ -55,11 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): raise ConfigEntryNotReady hass.data[DOMAIN][entry.entry_id] = coordinator - - for platform in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, platform) - ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -120,12 +117,25 @@ def get_sensors(hass, config): plan = config.data.get(CONF_PLAN) radius = config.data.get(CONF_RADIUS) meter = config.data.get(CONF_SENSOR) + address = config.data.get(CONF_LOCATION) readings = None if meter: readings = hass.states.get(meter).state - rate = openeihttp.Rates(api, lat, lon, plan, radius, readings) + if address: + lat = None + lon = None + + rate = openeihttp.Rates( + api=api, + lat=lat, + lon=lon, + plan=plan, + radius=radius, + address=address, + readings=readings, + ) rate.update() data = {} @@ -145,18 +155,9 @@ def get_sensors(hass, config): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" - unloaded = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, platform) - for platform in PLATFORMS - ] - ) - ) - if unloaded: - hass.data[DOMAIN].pop(entry.entry_id) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - return unloaded + return unload_ok async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: diff --git a/custom_components/openei/config_flow.py b/custom_components/openei/config_flow.py index c8bb23b..8f21789 100644 --- a/custom_components/openei/config_flow.py +++ b/custom_components/openei/config_flow.py @@ -13,6 +13,7 @@ from .const import ( CONF_API_KEY, + CONF_LOCATION, CONF_PLAN, CONF_RADIUS, CONF_SENSOR, @@ -193,6 +194,9 @@ def _get_default(key: str, fallback_default: Any = None) -> None: CONF_API_KEY, default=_get_default(CONF_API_KEY, "") ): cv.string, vol.Optional(CONF_RADIUS, default=_get_default(CONF_RADIUS, "")): cv.string, + vol.Optional( + CONF_LOCATION, default=_get_default(CONF_LOCATION, "") + ): cv.string, }, ) @@ -251,12 +255,18 @@ def _get_default(key: str, fallback_default: Any = None) -> None: async def _get_utility_list(hass, user_input) -> list | None: """Return list of utilities by lat/lon.""" - lat = hass.config.latitude - lon = hass.config.longitude + lat = None + lon = None + + if user_input[CONF_LOCATION] in [None, ""]: + lat = hass.config.latitude + lon = hass.config.longitude + api = user_input[CONF_API_KEY] radius = user_input[CONF_RADIUS] + address = user_input[CONF_LOCATION] - plans = openeihttp.Rates(api, lat, lon, radius=radius) + plans = openeihttp.Rates(api=api, lat=lat, lon=lon, radius=radius, address=address) plans = await hass.async_add_executor_job(_lookup_plans, plans) utilities = [] @@ -270,13 +280,19 @@ async def _get_utility_list(hass, user_input) -> list | None: async def _get_plan_list(hass, user_input) -> list | None: """Return list of rate plans by lat/lon.""" - lat = hass.config.latitude - lon = hass.config.longitude + lat = None + lon = None + + if user_input[CONF_LOCATION] in [None, ""]: + lat = hass.config.latitude + lon = hass.config.longitude + api = user_input[CONF_API_KEY] radius = user_input[CONF_RADIUS] utility = user_input[CONF_UTILITY] + address = user_input[CONF_LOCATION] - plans = openeihttp.Rates(api, lat, lon, radius=radius) + plans = openeihttp.Rates(api=api, lat=lat, lon=lon, radius=radius, address=address) plans = await hass.async_add_executor_job(_lookup_plans, plans) value = {} diff --git a/custom_components/openei/const.py b/custom_components/openei/const.py index 908fa91..647b992 100644 --- a/custom_components/openei/const.py +++ b/custom_components/openei/const.py @@ -18,6 +18,7 @@ # Configuration and options CONF_API_KEY = "api_key" +CONF_LOCATION = "location" CONF_PLAN = "rate_plan" CONF_RADIUS = "radius" CONF_SENSOR = "sensor" diff --git a/custom_components/openei/manifest.json b/custom_components/openei/manifest.json index d9eea9a..64f7859 100644 --- a/custom_components/openei/manifest.json +++ b/custom_components/openei/manifest.json @@ -7,6 +7,6 @@ "iot_class": "cloud_polling", "config_flow": true, "codeowners": ["@firstof9"], - "requirements": ["python-openei==0.1.10"], - "version": "0.1.4" + "requirements": ["python-openei==0.1.11"], + "version": "0.1.5" } diff --git a/custom_components/openei/translations/en.json b/custom_components/openei/translations/en.json index 762c82e..9f611ed 100644 --- a/custom_components/openei/translations/en.json +++ b/custom_components/openei/translations/en.json @@ -3,10 +3,11 @@ "step": { "user": { "title": "OpenEI (Step 1)", - "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/", + "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nIf location information is omitted your latitude and longitude will be used.", "data": { "api_key": "API Key", - "radius": "Radius in miles (optional)" + "radius": "Radius in miles (optional)", + "location": "City,State or Zip Code (optional)" } }, "user_2": { @@ -36,7 +37,7 @@ "step": { "user": { "title": "OpenEI (Step 1)", - "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/", + "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nIf location information is omitted your latitude and longitude will be used.", "data": { "api_key": "API Key", "radius": "Radius in miles (optional)" diff --git a/requirements_tests.txt b/requirements_tests.txt index 2125db1..dcf3804 100644 --- a/requirements_tests.txt +++ b/requirements_tests.txt @@ -1,2 +1,5 @@ -r requirements_dev.txt python-openei +pytest +pytest-cov +pytest-homeassistant-custom-component \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index af6f972..d7f31a8 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -15,6 +15,7 @@ { "api_key": "fakeAPIKey", "radius": "", + "location": "", }, "user_2", { @@ -32,6 +33,7 @@ "utility": "Fake Utility Co", "rate_plan": "randomstring", "sensor": "(none)", + "location": "", }, ), ], @@ -103,6 +105,7 @@ async def test_form( { "api_key": "fakeAPIKey_new", "radius": "20", + "location": "", }, "user_2", { @@ -120,6 +123,7 @@ async def test_form( "utility": "Fake Utility Co", "rate_plan": "randomstring", "sensor": "(none)", + "location": "", }, ), ], @@ -142,6 +146,7 @@ async def test_options_flow( data={ "api_key": "fakeAPIKey", "radius": "", + "location": "", "utility": "Fake Utility Co", "rate_plan": "randomstring", "sensor": "(none)", @@ -205,6 +210,7 @@ async def test_options_flow( { "api_key": "fakeAPIKey", "radius": "", + "location": "", }, "user_2", { @@ -222,6 +228,7 @@ async def test_options_flow( "utility": "Fake Utility Co", "rate_plan": "randomstring", "sensor": "(none)", + "location": "", }, ), ], @@ -245,6 +252,7 @@ async def test_options_flow_no_changes( data={ "api_key": "fakeAPIKey", "radius": "", + "location": "", "utility": "Fake Utility Co", "rate_plan": "randomstring", "sensor": "(none)", From 2e9ab8ad16f1ea0582d16cbb1d9fca82dec1f865 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 10 Sep 2021 13:21:26 -0700 Subject: [PATCH 03/19] chore: add release workflow (#38) * feat: add address/zip code support fixes #31 * Update config_flow.py * chore: add release workflow --- .github/workflows/release.yaml | 35 +++++++++++++++++++++++++++++++ custom_components/openei/const.py | 2 +- hacs.json | 7 ++++--- 3 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..47f68f9 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,35 @@ +name: Release + +on: + release: + types: [published] + +jobs: + release_zip_file: + name: Prepare release asset + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v1 + + - name: Get Version + id: get_version + run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} + + - name: "Set version numbmer" + run: | + sed -i '/VERSION = /c\VERSION = "${{ steps.get_version.outputs.VERSION }}"' ${{ github.workspace }}/custom_components/openei/const.py + sed -i '/version/c\ \"version\": \"${{ steps.get_version.outputs.VERSION }}\"' ${{ github.workspace }}/custom_components/openei/manifest.json + # Pack the openei dir as a zip and upload to the release + - name: ZIP Dir + run: | + cd ${{ github.workspace }}/custom_components/openei + zip openei.zip -r ./ + - name: Upload zip to release + uses: svenstaro/upload-release-action@v1-release + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ${{ github.workspace }}/custom_components/openei/openei.zip + asset_name: openei.zip + tag: ${{ github.ref }} + overwrite: true diff --git a/custom_components/openei/const.py b/custom_components/openei/const.py index 647b992..3675c58 100644 --- a/custom_components/openei/const.py +++ b/custom_components/openei/const.py @@ -5,7 +5,7 @@ NAME = "OpenEI" DOMAIN = "openei" DOMAIN_DATA = f"{DOMAIN}_data" -VERSION = "0.0.1" +VERSION = "0.0.0-dev" ATTRIBUTION = "Data provided by OpenEI.org" ISSUE_URL = "https://github.com/firstof9/ha-openei/issues" PLATFORMS = ["binary_sensor", "sensor"] diff --git a/hacs.json b/hacs.json index eaa2519..62407bd 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,8 @@ { "name": "OpenEI", - "hacs": "1.6.0", - "domains": ["sensor"], + "domains": ["binary_sensor", "sensor"], "iot_class": "Cloud Polling", - "homeassistant": "0.118.0" + "homeassistant": "2021.8.0", + "zip_release": true, + "filename": "openei.zip" } From 24ba338bd08e82726c12a689f647720a95c8342e Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 11 Sep 2021 08:50:56 -0700 Subject: [PATCH 04/19] fix: config flow errors (#39) * fix: config flow errors * remove unused parameters from get_sensors --- custom_components/openei/__init__.py | 12 ------------ custom_components/openei/config_flow.py | 16 ++++++++-------- custom_components/openei/manifest.json | 2 +- tests/test_config_flow.py | 7 ++----- 4 files changed, 11 insertions(+), 26 deletions(-) diff --git a/custom_components/openei/__init__.py b/custom_components/openei/__init__.py index 2368869..74e9acc 100644 --- a/custom_components/openei/__init__.py +++ b/custom_components/openei/__init__.py @@ -112,28 +112,16 @@ async def _async_refresh_data(self, data=None) -> None: def get_sensors(hass, config): api = config.data.get(CONF_API_KEY) - lat = hass.config.latitude - lon = hass.config.longitude plan = config.data.get(CONF_PLAN) - radius = config.data.get(CONF_RADIUS) meter = config.data.get(CONF_SENSOR) - address = config.data.get(CONF_LOCATION) readings = None if meter: readings = hass.states.get(meter).state - if address: - lat = None - lon = None - rate = openeihttp.Rates( api=api, - lat=lat, - lon=lon, plan=plan, - radius=radius, - address=address, readings=readings, ) rate.update() diff --git a/custom_components/openei/config_flow.py b/custom_components/openei/config_flow.py index 8f21789..e116565 100644 --- a/custom_components/openei/config_flow.py +++ b/custom_components/openei/config_flow.py @@ -108,7 +108,7 @@ class OpenEIOptionsFlowHandler(config_entries.OptionsFlow): """Blueprint config flow options handler.""" def __init__(self, config_entry): - """Initialize HACS options flow.""" + """Initialize OpenEI options flow.""" self.config_entry = config_entry self._data = dict(config_entry.data) self._errors = {} @@ -209,8 +209,6 @@ def _get_schema_step_2( entry_id: str = None, ) -> vol.Schema: """Gets a schema using the default_dict as a backup.""" - if user_input is None: - user_input = {} def _get_default(key: str, fallback_default: Any = None) -> None: """Gets default value for key.""" @@ -233,10 +231,11 @@ def _get_schema_step_3( entry_id: str = None, ) -> vol.Schema: """Gets a schema using the default_dict as a backup.""" - if user_input is None: - user_input = {} - def _get_default(key: str, fallback_default: Any = None) -> None: + if CONF_SENSOR in default_dict.keys() and default_dict[CONF_SENSOR] is None: + default_dict.pop(CONF_SENSOR, None) + + def _get_default(key: str, fallback_default: Any = None) -> Any | None: """Gets default value for key.""" return user_input.get(key, default_dict.get(key, fallback_default)) @@ -245,7 +244,7 @@ def _get_default(key: str, fallback_default: Any = None) -> None: vol.Required(CONF_PLAN, default=_get_default(CONF_PLAN, "")): vol.In( plan_list ), - vol.Optional( + vol.Required( CONF_SENSOR, default=_get_default(CONF_SENSOR, "(none)") ): vol.In(_get_entities(hass, SENSORS_DOMAIN, "energy", ["(none)"])), }, @@ -327,7 +326,8 @@ def _get_entities( continue data.append(entity.entity_id) + data.sort if extra_entities: - data.extend(extra_entities) + data.insert(0, extra_entities) return data diff --git a/custom_components/openei/manifest.json b/custom_components/openei/manifest.json index 64f7859..726de82 100644 --- a/custom_components/openei/manifest.json +++ b/custom_components/openei/manifest.json @@ -7,6 +7,6 @@ "iot_class": "cloud_polling", "config_flow": true, "codeowners": ["@firstof9"], - "requirements": ["python-openei==0.1.11"], + "requirements": ["python-openei==0.1.12"], "version": "0.1.5" } diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index d7f31a8..319b06d 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -219,7 +219,7 @@ async def test_options_flow( "user_3", { "rate_plan": "randomstring", - "sensor": "(none)", + "sensor": ["(none)"], }, "Fake Utility Co", { @@ -227,7 +227,7 @@ async def test_options_flow( "radius": "", "utility": "Fake Utility Co", "rate_plan": "randomstring", - "sensor": "(none)", + "sensor": ["(none)"], "location": "", }, ), @@ -279,9 +279,6 @@ async def test_options_flow_no_changes( return_value={ "Fake Utility Co": [{"name": "Fake Plan Name", "label": "randomstring"}] }, - ), patch( - "custom_components.openei.config_flow._get_entities", - return_value=["(none)"], ): result2 = await hass.config_entries.options.async_configure( From 4bb4f62f01cc65518d01053a57cb76e4ac198211 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 11 Sep 2021 15:41:14 -0700 Subject: [PATCH 05/19] feat: add manual input for plan (#40) * feat: add manual input for plan * clean up unused imports * adjust tests * update test * fix test --- custom_components/openei/__init__.py | 7 ++++++- custom_components/openei/config_flow.py | 5 +++++ custom_components/openei/const.py | 3 ++- custom_components/openei/translations/en.json | 6 ++++-- tests/const.py | 9 ++++----- tests/test_config_flow.py | 8 ++++++++ 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/custom_components/openei/__init__.py b/custom_components/openei/__init__.py index 74e9acc..a0b65c4 100644 --- a/custom_components/openei/__init__.py +++ b/custom_components/openei/__init__.py @@ -14,6 +14,7 @@ BINARY_SENSORS, CONF_API_KEY, CONF_LOCATION, + CONF_MANUAL_PLAN, CONF_PLAN, CONF_RADIUS, CONF_SENSOR, @@ -41,9 +42,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): updated_config = entry.data.copy() - if entry.data.get(CONF_SENSOR) == "(none)": + if CONF_SENSOR in updated_config.keys() and updated_config[CONF_SENSOR] == "(none)": updated_config[CONF_SENSOR] = None + if CONF_MANUAL_PLAN in updated_config.keys() and entry.data.get(CONF_MANUAL_PLAN): + updated_config[CONF_PLAN] = updated_config[CONF_MANUAL_PLAN] + updated_config.pop(CONF_MANUAL_PLAN, None) + if updated_config != entry.data: hass.config_entries.async_update_entry(entry, data=updated_config) diff --git a/custom_components/openei/config_flow.py b/custom_components/openei/config_flow.py index e116565..5bcf9de 100644 --- a/custom_components/openei/config_flow.py +++ b/custom_components/openei/config_flow.py @@ -14,6 +14,7 @@ from .const import ( CONF_API_KEY, CONF_LOCATION, + CONF_MANUAL_PLAN, CONF_PLAN, CONF_RADIUS, CONF_SENSOR, @@ -244,6 +245,9 @@ def _get_default(key: str, fallback_default: Any = None) -> Any | None: vol.Required(CONF_PLAN, default=_get_default(CONF_PLAN, "")): vol.In( plan_list ), + vol.Optional( + CONF_MANUAL_PLAN, default=_get_default(CONF_PLAN, "") + ): cv.string, vol.Required( CONF_SENSOR, default=_get_default(CONF_SENSOR, "(none)") ): vol.In(_get_entities(hass, SENSORS_DOMAIN, "energy", ["(none)"])), @@ -305,6 +309,7 @@ async def _get_plan_list(hass, user_input) -> list | None: def _lookup_plans(handler) -> list: """Return list of utilities and plans.""" response = handler.lookup_plans() + response.insert(0, "Not Listed") _LOGGER.debug("lookup_plans: %s", response) return response diff --git a/custom_components/openei/const.py b/custom_components/openei/const.py index 3675c58..6e89164 100644 --- a/custom_components/openei/const.py +++ b/custom_components/openei/const.py @@ -1,5 +1,5 @@ """Constants for integration_blueprint.""" -from homeassistant.const import CURRENCY_DOLLAR, DEVICE_CLASS_MONETARY +from homeassistant.const import DEVICE_CLASS_MONETARY # Base component constants NAME = "OpenEI" @@ -19,6 +19,7 @@ # Configuration and options CONF_API_KEY = "api_key" CONF_LOCATION = "location" +CONF_MANUAL_PLAN = "manual_plan" CONF_PLAN = "rate_plan" CONF_RADIUS = "radius" CONF_SENSOR = "sensor" diff --git a/custom_components/openei/translations/en.json b/custom_components/openei/translations/en.json index 9f611ed..826c59d 100644 --- a/custom_components/openei/translations/en.json +++ b/custom_components/openei/translations/en.json @@ -22,7 +22,8 @@ "description": "Select your plan from the list. If you are unsure, check your utility bill.", "data": { "rate_plan": "Rate Plan", - "sensor": "Energy sensor for Tier plans (optional)" + "sensor": "Energy sensor for Tier plans (optional)", + "manual_plan": "Manually enter plan from OpenEI website (optional)" } } }, @@ -55,7 +56,8 @@ "description": "Select your plan from the list. If you are unsure, check your utility bill.", "data": { "rate_plan": "Rate Plan", - "sensor": "Energy sensor for Tier plans (optional)" + "sensor": "Energy sensor for Tier plans (optional)", + "manual_plan": "Manually enter plan from OpenEI website (optional)" } } } diff --git a/tests/const.py b/tests/const.py index 98032bb..2d6fce1 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,7 +1,6 @@ CONFIG_DATA = { - "data": { - "api_key": "fakeAPIKey", - "utility": "Fake Utility Co.", - "rate_plan": "totallyfakerateplan", - } + "api_key": "fakeAPIKey", + "utility": "Fake Utility Co.", + "rate_plan": "totallyfakerateplan", + "manual_plan": "manualfakerateplan", } diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 319b06d..76769f0 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -25,6 +25,7 @@ { "rate_plan": "randomstring", "sensor": "(none)", + "manual_plan": "", }, "Fake Utility Co", { @@ -34,6 +35,7 @@ "rate_plan": "randomstring", "sensor": "(none)", "location": "", + "manual_plan": "", }, ), ], @@ -115,6 +117,7 @@ async def test_form( { "rate_plan": "randomstring", "sensor": "(none)", + "manual_plan": "", }, "Fake Utility Co", { @@ -124,6 +127,7 @@ async def test_form( "rate_plan": "randomstring", "sensor": "(none)", "location": "", + "manual_plan": "", }, ), ], @@ -150,6 +154,7 @@ async def test_options_flow( "utility": "Fake Utility Co", "rate_plan": "randomstring", "sensor": "(none)", + "manual_plan": "", }, ) @@ -220,6 +225,7 @@ async def test_options_flow( { "rate_plan": "randomstring", "sensor": ["(none)"], + "manual_plan": "", }, "Fake Utility Co", { @@ -229,6 +235,7 @@ async def test_options_flow( "rate_plan": "randomstring", "sensor": ["(none)"], "location": "", + "manual_plan": "", }, ), ], @@ -256,6 +263,7 @@ async def test_options_flow_no_changes( "utility": "Fake Utility Co", "rate_plan": "randomstring", "sensor": "(none)", + "manual_plan": "", }, ) From 96100d85135ca80a40c73261eb07305c6ae27b0e Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 12 Sep 2021 18:46:05 -0700 Subject: [PATCH 06/19] fix: config flow errors related to the betas (#41) * fix: config flow errors related to the betas * Update en.json * clean up test code * last min tweaks --- custom_components/openei/__init__.py | 25 ++-- custom_components/openei/config_flow.py | 29 +++-- custom_components/openei/manifest.json | 2 +- custom_components/openei/translations/en.json | 7 +- tests/test_config_flow.py | 115 +++++++++++++++++- 5 files changed, 156 insertions(+), 22 deletions(-) diff --git a/custom_components/openei/__init__.py b/custom_components/openei/__init__.py index a0b65c4..e2644f9 100644 --- a/custom_components/openei/__init__.py +++ b/custom_components/openei/__init__.py @@ -16,7 +16,6 @@ CONF_LOCATION, CONF_MANUAL_PLAN, CONF_PLAN, - CONF_RADIUS, CONF_SENSOR, DOMAIN, PLATFORMS, @@ -42,13 +41,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): updated_config = entry.data.copy() + _LOGGER.debug("config_entry: %s", updated_config) + if CONF_SENSOR in updated_config.keys() and updated_config[CONF_SENSOR] == "(none)": - updated_config[CONF_SENSOR] = None + updated_config.pop(CONF_SENSOR, None) - if CONF_MANUAL_PLAN in updated_config.keys() and entry.data.get(CONF_MANUAL_PLAN): + if CONF_MANUAL_PLAN in updated_config.keys() and updated_config[CONF_MANUAL_PLAN]: updated_config[CONF_PLAN] = updated_config[CONF_MANUAL_PLAN] updated_config.pop(CONF_MANUAL_PLAN, None) + if CONF_LOCATION in updated_config.keys() and updated_config[CONF_LOCATION] == "": + updated_config.pop(CONF_LOCATION, None) + + _LOGGER.debug("updated_config: %s", updated_config) if updated_config != entry.data: hass.config_entries.async_update_entry(entry, data=updated_config) @@ -115,19 +120,25 @@ async def _async_refresh_data(self, data=None) -> None: raise UpdateFailed() from exception -def get_sensors(hass, config): +def get_sensors(hass, config) -> dict: api = config.data.get(CONF_API_KEY) plan = config.data.get(CONF_PLAN) meter = config.data.get(CONF_SENSOR) - readings = None + reading = None if meter: - readings = hass.states.get(meter).state + _LOGGER.debug("Using meter data from sensor: %s", meter) + reading = hass.states.get(meter) + if not reading: + reading = None + _LOGGER.warning("Sensor: %s is not valid.", meter) + else: + reading = reading.state rate = openeihttp.Rates( api=api, plan=plan, - readings=readings, + reading=reading, ) rate.update() data = {} diff --git a/custom_components/openei/config_flow.py b/custom_components/openei/config_flow.py index 5bcf9de..cd619cc 100644 --- a/custom_components/openei/config_flow.py +++ b/custom_components/openei/config_flow.py @@ -121,25 +121,34 @@ async def async_step_init(self, user_input=None): # pylint: disable=unused-argu async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" if user_input is not None: + if user_input[CONF_LOCATION] == '""': + user_input[CONF_LOCATION] = None + if user_input[CONF_RADIUS] == '""': + user_input[CONF_RADIUS] = None self._data.update(user_input) + _LOGGER.debug("Step 1: %s", user_input) return await self.async_step_user_2() return await self._show_config_form(user_input) async def async_step_user_2(self, user_input=None): """Handle a flow initialized by the user.""" - + _LOGGER.debug("data: %s", self._data) if user_input is not None: self._data.update(user_input) + _LOGGER.debug("Step 2: %s", user_input) return await self.async_step_user_3() return await self._show_config_form_2(user_input) async def async_step_user_3(self, user_input=None): """Handle a flow initialized by the user.""" - + _LOGGER.debug("data: %s", self._data) if user_input is not None: + if user_input[CONF_SENSOR] == "(none)": + user_input[CONF_SENSOR] = None self._data.update(user_input) + _LOGGER.debug("Step 3: %s", user_input) return self.async_create_entry(title="", data=self._data) return await self._show_config_form_3(user_input) @@ -148,7 +157,7 @@ async def _show_config_form(self, user_input): # pylint: disable=unused-argumen """Show the configuration form to edit location data.""" return self.async_show_form( step_id="user", - data_schema=_get_schema_step_1(self.hass, self._data, self._data), + data_schema=_get_schema_step_1(self.hass, user_input, self._data), errors=self._errors, ) @@ -158,7 +167,7 @@ async def _show_config_form_2(self, user_input): # pylint: disable=unused-argum return self.async_show_form( step_id="user_2", data_schema=_get_schema_step_2( - self.hass, self._data, self._data, utility_list + self.hass, user_input, self._data, utility_list ), errors=self._errors, ) @@ -169,7 +178,7 @@ async def _show_config_form_3(self, user_input): # pylint: disable=unused-argum return self.async_show_form( step_id="user_3", data_schema=_get_schema_step_3( - self.hass, self._data, self._data, plan_list + self.hass, user_input, self._data, plan_list ), errors=self._errors, ) @@ -210,6 +219,8 @@ def _get_schema_step_2( entry_id: str = None, ) -> vol.Schema: """Gets a schema using the default_dict as a backup.""" + if user_input is None: + user_input = {} def _get_default(key: str, fallback_default: Any = None) -> None: """Gets default value for key.""" @@ -232,8 +243,10 @@ def _get_schema_step_3( entry_id: str = None, ) -> vol.Schema: """Gets a schema using the default_dict as a backup.""" + if user_input is None: + user_input = {} - if CONF_SENSOR in default_dict.keys() and default_dict[CONF_SENSOR] is None: + if CONF_SENSOR in default_dict.keys() and default_dict[CONF_SENSOR] == "(none)": default_dict.pop(CONF_SENSOR, None) def _get_default(key: str, fallback_default: Any = None) -> Any | None: @@ -250,7 +263,7 @@ def _get_default(key: str, fallback_default: Any = None) -> Any | None: ): cv.string, vol.Required( CONF_SENSOR, default=_get_default(CONF_SENSOR, "(none)") - ): vol.In(_get_entities(hass, SENSORS_DOMAIN, "energy", ["(none)"])), + ): vol.In(_get_entities(hass, SENSORS_DOMAIN, "energy", "(none)")), }, ) @@ -309,7 +322,7 @@ async def _get_plan_list(hass, user_input) -> list | None: def _lookup_plans(handler) -> list: """Return list of utilities and plans.""" response = handler.lookup_plans() - response.insert(0, "Not Listed") + response["Not Listed"] = [{"name": "Not Listed", "label": "Not Listed"}] _LOGGER.debug("lookup_plans: %s", response) return response diff --git a/custom_components/openei/manifest.json b/custom_components/openei/manifest.json index 726de82..3611216 100644 --- a/custom_components/openei/manifest.json +++ b/custom_components/openei/manifest.json @@ -7,6 +7,6 @@ "iot_class": "cloud_polling", "config_flow": true, "codeowners": ["@firstof9"], - "requirements": ["python-openei==0.1.12"], + "requirements": ["python-openei==0.1.14"], "version": "0.1.5" } diff --git a/custom_components/openei/translations/en.json b/custom_components/openei/translations/en.json index 826c59d..29aaed0 100644 --- a/custom_components/openei/translations/en.json +++ b/custom_components/openei/translations/en.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "OpenEI (Step 1)", - "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nIf location information is omitted your latitude and longitude will be used.", + "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nIf location information is omitted your latitude and longitude will be used.\n\nEnter \"\" to clear optional fields.", "data": { "api_key": "API Key", "radius": "Radius in miles (optional)", @@ -38,10 +38,11 @@ "step": { "user": { "title": "OpenEI (Step 1)", - "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nIf location information is omitted your latitude and longitude will be used.", + "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nIf location information is omitted your latitude and longitude will be used.\n\nEnter \"\" to clear optional fields.", "data": { "api_key": "API Key", - "radius": "Radius in miles (optional)" + "radius": "Radius in miles (optional)", + "location": "City,State or Zip Code (optional)" } }, "user_2": { diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 76769f0..f939b26 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -125,7 +125,7 @@ async def test_form( "radius": "20", "utility": "Fake Utility Co", "rate_plan": "randomstring", - "sensor": "(none)", + "sensor": None, "location": "", "manual_plan": "", }, @@ -224,7 +224,7 @@ async def test_options_flow( "user_3", { "rate_plan": "randomstring", - "sensor": ["(none)"], + "sensor": "(none)", "manual_plan": "", }, "Fake Utility Co", @@ -233,7 +233,7 @@ async def test_options_flow( "radius": "", "utility": "Fake Utility Co", "rate_plan": "randomstring", - "sensor": ["(none)"], + "sensor": None, "location": "", "manual_plan": "", }, @@ -315,3 +315,112 @@ async def test_options_flow_no_changes( assert ( "Attempting to reload entities from the openei integration" in caplog.text ) + + +@pytest.mark.parametrize( + "input_1,step_id_2,input_2,step_id_3,input_3,title,data", + [ + ( + { + "api_key": "fakeAPIKey", + "radius": "", + "location": "", + }, + "user_2", + { + "utility": "Fake Utility Co", + }, + "user_3", + { + "rate_plan": "randomstring", + "sensor": "(none)", + "manual_plan": "", + }, + "Fake Utility Co", + { + "api_key": "fakeAPIKey", + "radius": "", + "utility": "Fake Utility Co", + "rate_plan": "randomstring", + "sensor": None, + "location": "", + "manual_plan": "", + }, + ), + ], +) +async def test_options_flow_some_changes( + input_1, + step_id_2, + input_2, + step_id_3, + input_3, + title, + data, + hass, + mock_api, + caplog, +): + """Test config flow options.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Fake Utility Co", + data={ + "api_key": "fakeAPIKey", + "radius": "", + "location": "12345", + "utility": "Fake Utility Co", + "rate_plan": "randomstring", + "sensor": "(none)", + "manual_plan": "", + }, + ) + + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["errors"] == {} + # assert result["title"] == title_1 + + with patch("custom_components.openei.async_setup", return_value=True), patch( + "custom_components.openei.async_setup_entry", + return_value=True, + ), patch( + "custom_components.openei.config_flow._lookup_plans", + return_value={ + "Fake Utility Co": [{"name": "Fake Plan Name", "label": "randomstring"}] + }, + ): + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], input_1 + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["step_id"] == step_id_2 + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], input_2 + ) + await hass.async_block_till_done() + + assert result3["type"] == "form" + assert result3["step_id"] == step_id_3 + result4 = await hass.config_entries.options.async_configure( + result["flow_id"], input_3 + ) + await hass.async_block_till_done() + assert result4["type"] == "create_entry" + assert data == entry.data.copy() + + await hass.async_block_till_done() + assert ( + "Attempting to reload entities from the openei integration" in caplog.text + ) \ No newline at end of file From f3c461298b35791f293bedda16149588e438c105 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 15 Sep 2021 08:33:27 -0700 Subject: [PATCH 07/19] fix: config flow empty strings convert to None (#42) * fix: config flow empty strings convert to None --- custom_components/openei/config_flow.py | 16 ++++++++++++---- tests/test_config_flow.py | 6 +++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/custom_components/openei/config_flow.py b/custom_components/openei/config_flow.py index cd619cc..4f1ec80 100644 --- a/custom_components/openei/config_flow.py +++ b/custom_components/openei/config_flow.py @@ -41,6 +41,9 @@ async def async_step_user(self, user_input=None): self._errors = {} if user_input is not None: + for key, value in user_input.items(): + if not bool(value): + user_input[key] = None self._data.update(user_input) return await self.async_step_user_2() @@ -51,6 +54,9 @@ async def async_step_user_2(self, user_input=None): self._errors = {} if user_input is not None: + for key, value in user_input.items(): + if not bool(value): + user_input[key] = None self._data.update(user_input) return await self.async_step_user_3() @@ -61,6 +67,9 @@ async def async_step_user_3(self, user_input=None): self._errors = {} if user_input is not None: + for key, value in user_input.items(): + if not bool(value): + user_input[key] = None self._data.update(user_input) return self.async_create_entry( title=self._data[CONF_UTILITY], data=self._data @@ -270,17 +279,16 @@ def _get_default(key: str, fallback_default: Any = None) -> Any | None: async def _get_utility_list(hass, user_input) -> list | None: """Return list of utilities by lat/lon.""" - lat = None lon = None + address = user_input[CONF_LOCATION] if bool(user_input[CONF_LOCATION]) else None + radius = user_input[CONF_RADIUS] if bool(user_input[CONF_RADIUS]) else None - if user_input[CONF_LOCATION] in [None, ""]: + if user_input.get(CONF_LOCATION) is None: lat = hass.config.latitude lon = hass.config.longitude api = user_input[CONF_API_KEY] - radius = user_input[CONF_RADIUS] - address = user_input[CONF_LOCATION] plans = openeihttp.Rates(api=api, lat=lat, lon=lon, radius=radius, address=address) plans = await hass.async_add_executor_job(_lookup_plans, plans) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index f939b26..e36a012 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -30,12 +30,12 @@ "Fake Utility Co", { "api_key": "fakeAPIKey", - "radius": "", + "radius": None, "utility": "Fake Utility Co", "rate_plan": "randomstring", "sensor": "(none)", - "location": "", - "manual_plan": "", + "location": None, + "manual_plan": None, }, ), ], From 9414d40f59a3ddcdd36053e35dcde900da2ab584 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 16 Sep 2021 15:42:05 -0700 Subject: [PATCH 08/19] fix: add default to radius (#43) * fix: properly fix config flow * code tweak * update tests --- custom_components/openei/config_flow.py | 51 +++++++------------ custom_components/openei/translations/en.json | 4 +- tests/test_config_flow.py | 40 +++++++-------- 3 files changed, 40 insertions(+), 55 deletions(-) diff --git a/custom_components/openei/config_flow.py b/custom_components/openei/config_flow.py index 4f1ec80..eae85af 100644 --- a/custom_components/openei/config_flow.py +++ b/custom_components/openei/config_flow.py @@ -41,9 +41,6 @@ async def async_step_user(self, user_input=None): self._errors = {} if user_input is not None: - for key, value in user_input.items(): - if not bool(value): - user_input[key] = None self._data.update(user_input) return await self.async_step_user_2() @@ -54,9 +51,6 @@ async def async_step_user_2(self, user_input=None): self._errors = {} if user_input is not None: - for key, value in user_input.items(): - if not bool(value): - user_input[key] = None self._data.update(user_input) return await self.async_step_user_3() @@ -67,9 +61,6 @@ async def async_step_user_3(self, user_input=None): self._errors = {} if user_input is not None: - for key, value in user_input.items(): - if not bool(value): - user_input[key] = None self._data.update(user_input) return self.async_create_entry( title=self._data[CONF_UTILITY], data=self._data @@ -130,10 +121,6 @@ async def async_step_init(self, user_input=None): # pylint: disable=unused-argu async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" if user_input is not None: - if user_input[CONF_LOCATION] == '""': - user_input[CONF_LOCATION] = None - if user_input[CONF_RADIUS] == '""': - user_input[CONF_RADIUS] = None self._data.update(user_input) _LOGGER.debug("Step 1: %s", user_input) return await self.async_step_user_2() @@ -154,15 +141,13 @@ async def async_step_user_3(self, user_input=None): """Handle a flow initialized by the user.""" _LOGGER.debug("data: %s", self._data) if user_input is not None: - if user_input[CONF_SENSOR] == "(none)": - user_input[CONF_SENSOR] = None self._data.update(user_input) _LOGGER.debug("Step 3: %s", user_input) return self.async_create_entry(title="", data=self._data) return await self._show_config_form_3(user_input) - async def _show_config_form(self, user_input): # pylint: disable=unused-argument + async def _show_config_form(self, user_input: Optional[Dict[str, Any]]): """Show the configuration form to edit location data.""" return self.async_show_form( step_id="user", @@ -170,7 +155,7 @@ async def _show_config_form(self, user_input): # pylint: disable=unused-argumen errors=self._errors, ) - async def _show_config_form_2(self, user_input): # pylint: disable=unused-argument + async def _show_config_form_2(self, user_input: Optional[Dict[str, Any]]): """Show the configuration form to edit location data.""" utility_list = await _get_utility_list(self.hass, self._data) return self.async_show_form( @@ -181,7 +166,7 @@ async def _show_config_form_2(self, user_input): # pylint: disable=unused-argum errors=self._errors, ) - async def _show_config_form_3(self, user_input): # pylint: disable=unused-argument + async def _show_config_form_3(self, user_input: Optional[Dict[str, Any]]): """Show the configuration form to edit location data.""" plan_list = await _get_plan_list(self.hass, self._data) return self.async_show_form( @@ -209,13 +194,13 @@ def _get_default(key: str, fallback_default: Any = None) -> None: return vol.Schema( { - vol.Required( - CONF_API_KEY, default=_get_default(CONF_API_KEY, "") - ): cv.string, - vol.Optional(CONF_RADIUS, default=_get_default(CONF_RADIUS, "")): cv.string, + vol.Required(CONF_API_KEY, default=_get_default(CONF_API_KEY)): cv.string, vol.Optional( CONF_LOCATION, default=_get_default(CONF_LOCATION, "") ): cv.string, + vol.Required(CONF_RADIUS, default=_get_default(CONF_RADIUS, 0)): vol.All( + vol.Coerce(int), vol.Range(min=0, max=200) + ), }, ) @@ -281,14 +266,14 @@ async def _get_utility_list(hass, user_input) -> list | None: """Return list of utilities by lat/lon.""" lat = None lon = None - address = user_input[CONF_LOCATION] if bool(user_input[CONF_LOCATION]) else None - radius = user_input[CONF_RADIUS] if bool(user_input[CONF_RADIUS]) else None + api = user_input[CONF_API_KEY] + address = user_input[CONF_LOCATION] + radius = user_input[CONF_RADIUS] - if user_input.get(CONF_LOCATION) is None: + if user_input[CONF_LOCATION] in [None, '""', "''"]: lat = hass.config.latitude lon = hass.config.longitude - - api = user_input[CONF_API_KEY] + address = None plans = openeihttp.Rates(api=api, lat=lat, lon=lon, radius=radius, address=address) plans = await hass.async_add_executor_job(_lookup_plans, plans) @@ -306,15 +291,15 @@ async def _get_plan_list(hass, user_input) -> list | None: lat = None lon = None - - if user_input[CONF_LOCATION] in [None, ""]: - lat = hass.config.latitude - lon = hass.config.longitude - + address = user_input[CONF_LOCATION] api = user_input[CONF_API_KEY] radius = user_input[CONF_RADIUS] utility = user_input[CONF_UTILITY] - address = user_input[CONF_LOCATION] + + if user_input[CONF_LOCATION] in [None, '""', "''"]: + lat = hass.config.latitude + lon = hass.config.longitude + address = None plans = openeihttp.Rates(api=api, lat=lat, lon=lon, radius=radius, address=address) plans = await hass.async_add_executor_job(_lookup_plans, plans) diff --git a/custom_components/openei/translations/en.json b/custom_components/openei/translations/en.json index 29aaed0..ce1ed58 100644 --- a/custom_components/openei/translations/en.json +++ b/custom_components/openei/translations/en.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "OpenEI (Step 1)", - "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nIf location information is omitted your latitude and longitude will be used.\n\nEnter \"\" to clear optional fields.", + "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nIf location information is omitted your latitude and longitude will be used.\n\nEnter \"\" in address to use latitude and longitude from Home Assistant.", "data": { "api_key": "API Key", "radius": "Radius in miles (optional)", @@ -38,7 +38,7 @@ "step": { "user": { "title": "OpenEI (Step 1)", - "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nIf location information is omitted your latitude and longitude will be used.\n\nEnter \"\" to clear optional fields.", + "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nIf location information is omitted your latitude and longitude will be used.\n\nEnter \"\" in address to use latitude and longitude from Home Assistant.", "data": { "api_key": "API Key", "radius": "Radius in miles (optional)", diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index e36a012..0ad3a4c 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -14,7 +14,7 @@ ( { "api_key": "fakeAPIKey", - "radius": "", + "radius": 0, "location": "", }, "user_2", @@ -30,12 +30,12 @@ "Fake Utility Co", { "api_key": "fakeAPIKey", - "radius": None, + "radius": 0, "utility": "Fake Utility Co", "rate_plan": "randomstring", "sensor": "(none)", - "location": None, - "manual_plan": None, + "location": "", + "manual_plan": "", }, ), ], @@ -106,7 +106,7 @@ async def test_form( ( { "api_key": "fakeAPIKey_new", - "radius": "20", + "radius": 20, "location": "", }, "user_2", @@ -122,10 +122,10 @@ async def test_form( "Fake Utility Co", { "api_key": "fakeAPIKey_new", - "radius": "20", + "radius": 20, "utility": "Fake Utility Co", "rate_plan": "randomstring", - "sensor": None, + "sensor": "(none)", "location": "", "manual_plan": "", }, @@ -149,12 +149,12 @@ async def test_options_flow( title="Fake Utility Co", data={ "api_key": "fakeAPIKey", - "radius": "", + "radius": None, "location": "", "utility": "Fake Utility Co", "rate_plan": "randomstring", "sensor": "(none)", - "manual_plan": "", + "manual_plan": None, }, ) @@ -214,7 +214,7 @@ async def test_options_flow( ( { "api_key": "fakeAPIKey", - "radius": "", + "radius": 0, "location": "", }, "user_2", @@ -230,10 +230,10 @@ async def test_options_flow( "Fake Utility Co", { "api_key": "fakeAPIKey", - "radius": "", + "radius": 0, "utility": "Fake Utility Co", "rate_plan": "randomstring", - "sensor": None, + "sensor": "(none)", "location": "", "manual_plan": "", }, @@ -258,7 +258,7 @@ async def test_options_flow_no_changes( title="Fake Utility Co", data={ "api_key": "fakeAPIKey", - "radius": "", + "radius": None, "location": "", "utility": "Fake Utility Co", "rate_plan": "randomstring", @@ -323,8 +323,8 @@ async def test_options_flow_no_changes( ( { "api_key": "fakeAPIKey", - "radius": "", - "location": "", + "radius": 0, + "location": "''", }, "user_2", { @@ -339,11 +339,11 @@ async def test_options_flow_no_changes( "Fake Utility Co", { "api_key": "fakeAPIKey", - "radius": "", + "radius": 0, "utility": "Fake Utility Co", "rate_plan": "randomstring", - "sensor": None, - "location": "", + "sensor": "(none)", + "location": "''", "manual_plan": "", }, ), @@ -367,7 +367,7 @@ async def test_options_flow_some_changes( title="Fake Utility Co", data={ "api_key": "fakeAPIKey", - "radius": "", + "radius": None, "location": "12345", "utility": "Fake Utility Co", "rate_plan": "randomstring", @@ -423,4 +423,4 @@ async def test_options_flow_some_changes( await hass.async_block_till_done() assert ( "Attempting to reload entities from the openei integration" in caplog.text - ) \ No newline at end of file + ) From 9307ecb78af8ff4daae6d5fe561057fcbbece91d Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 17 Sep 2021 08:21:48 -0700 Subject: [PATCH 09/19] fix: allow omitted entry for location on setup (#44) Updated config flow instructions --- custom_components/openei/config_flow.py | 21 +++++++++++-------- custom_components/openei/translations/en.json | 8 +++---- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/custom_components/openei/config_flow.py b/custom_components/openei/config_flow.py index eae85af..fef6884 100644 --- a/custom_components/openei/config_flow.py +++ b/custom_components/openei/config_flow.py @@ -41,6 +41,7 @@ async def async_step_user(self, user_input=None): self._errors = {} if user_input is not None: + _LOGGER.debug("Step 1: %s", user_input) self._data.update(user_input) return await self.async_step_user_2() @@ -188,16 +189,20 @@ def _get_schema_step_1( if user_input is None: user_input = {} + if CONF_LOCATION in user_input.keys() and user_input[CONF_LOCATION] == '""': + user_input[CONF_LOCATION] = "" + + if CONF_LOCATION in default_dict.keys() and default_dict[CONF_LOCATION] == '""': + default_dict[CONF_LOCATION] = "" + def _get_default(key: str, fallback_default: Any = None) -> None: """Gets default value for key.""" return user_input.get(key, default_dict.get(key, fallback_default)) return vol.Schema( { - vol.Required(CONF_API_KEY, default=_get_default(CONF_API_KEY)): cv.string, - vol.Optional( - CONF_LOCATION, default=_get_default(CONF_LOCATION, "") - ): cv.string, + vol.Required(CONF_API_KEY, default=_get_default(CONF_API_KEY)): str, + vol.Optional(CONF_LOCATION, default=_get_default(CONF_LOCATION, "")): str, vol.Required(CONF_RADIUS, default=_get_default(CONF_RADIUS, 0)): vol.All( vol.Coerce(int), vol.Range(min=0, max=200) ), @@ -252,9 +257,7 @@ def _get_default(key: str, fallback_default: Any = None) -> Any | None: vol.Required(CONF_PLAN, default=_get_default(CONF_PLAN, "")): vol.In( plan_list ), - vol.Optional( - CONF_MANUAL_PLAN, default=_get_default(CONF_PLAN, "") - ): cv.string, + vol.Optional(CONF_MANUAL_PLAN, default=_get_default(CONF_PLAN, "")): str, vol.Required( CONF_SENSOR, default=_get_default(CONF_SENSOR, "(none)") ): vol.In(_get_entities(hass, SENSORS_DOMAIN, "energy", "(none)")), @@ -270,7 +273,7 @@ async def _get_utility_list(hass, user_input) -> list | None: address = user_input[CONF_LOCATION] radius = user_input[CONF_RADIUS] - if user_input[CONF_LOCATION] in [None, '""', "''"]: + if not bool(user_input[CONF_LOCATION]): lat = hass.config.latitude lon = hass.config.longitude address = None @@ -296,7 +299,7 @@ async def _get_plan_list(hass, user_input) -> list | None: radius = user_input[CONF_RADIUS] utility = user_input[CONF_UTILITY] - if user_input[CONF_LOCATION] in [None, '""', "''"]: + if not bool(user_input[CONF_LOCATION]): lat = hass.config.latitude lon = hass.config.longitude address = None diff --git a/custom_components/openei/translations/en.json b/custom_components/openei/translations/en.json index ce1ed58..6082b81 100644 --- a/custom_components/openei/translations/en.json +++ b/custom_components/openei/translations/en.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "OpenEI (Step 1)", - "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nIf location information is omitted your latitude and longitude will be used.\n\nEnter \"\" in address to use latitude and longitude from Home Assistant.", + "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nOmit address to use latitude and longitude from Home Assistant.", "data": { "api_key": "API Key", "radius": "Radius in miles (optional)", @@ -19,7 +19,7 @@ }, "user_3": { "title": "OpenEI (Step 3)", - "description": "Select your plan from the list. If you are unsure, check your utility bill.", + "description": "Select your plan from the list. If you are unsure, check your utility bill.\n\nManual plan will override the selected rate plan.", "data": { "rate_plan": "Rate Plan", "sensor": "Energy sensor for Tier plans (optional)", @@ -38,7 +38,7 @@ "step": { "user": { "title": "OpenEI (Step 1)", - "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/\n\nIf location information is omitted your latitude and longitude will be used.\n\nEnter \"\" in address to use latitude and longitude from Home Assistant.", + "description": "If you do not have an API Key yet you can get one here: https://openei.org/services/api/signup/ \n\nEnter \"\" in address to clear it and use latitude and longitude from Home Assistant.", "data": { "api_key": "API Key", "radius": "Radius in miles (optional)", @@ -54,7 +54,7 @@ }, "user_3": { "title": "OpenEI (Step 3)", - "description": "Select your plan from the list. If you are unsure, check your utility bill.", + "description": "Select your plan from the list. If you are unsure, check your utility bill.\n\nManual plan will override the selected rate plan.", "data": { "rate_plan": "Rate Plan", "sensor": "Energy sensor for Tier plans (optional)", From 8efc2809aaa142c617d2ee00e680b6ec953e9fdc Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 17 Sep 2021 19:37:44 -0700 Subject: [PATCH 10/19] fix: config flow options and clearing location (#46) * fix: config flow options and clearing location * clean up unused import * clean up testing debug code * adjust tests --- custom_components/openei/__init__.py | 3 --- custom_components/openei/config_flow.py | 11 ++++------- tests/test_config_flow.py | 12 ++++++------ 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/custom_components/openei/__init__.py b/custom_components/openei/__init__.py index e2644f9..bf4a279 100644 --- a/custom_components/openei/__init__.py +++ b/custom_components/openei/__init__.py @@ -50,9 +50,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): updated_config[CONF_PLAN] = updated_config[CONF_MANUAL_PLAN] updated_config.pop(CONF_MANUAL_PLAN, None) - if CONF_LOCATION in updated_config.keys() and updated_config[CONF_LOCATION] == "": - updated_config.pop(CONF_LOCATION, None) - _LOGGER.debug("updated_config: %s", updated_config) if updated_config != entry.data: hass.config_entries.async_update_entry(entry, data=updated_config) diff --git a/custom_components/openei/config_flow.py b/custom_components/openei/config_flow.py index fef6884..784f9fb 100644 --- a/custom_components/openei/config_flow.py +++ b/custom_components/openei/config_flow.py @@ -7,7 +7,6 @@ from homeassistant import config_entries from homeassistant.components.sensor import DOMAIN as SENSORS_DOMAIN from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv import openeihttp import voluptuous as vol @@ -41,7 +40,6 @@ async def async_step_user(self, user_input=None): self._errors = {} if user_input is not None: - _LOGGER.debug("Step 1: %s", user_input) self._data.update(user_input) return await self.async_step_user_2() @@ -122,8 +120,9 @@ async def async_step_init(self, user_input=None): # pylint: disable=unused-argu async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" if user_input is not None: + if user_input[CONF_LOCATION] == '""': + user_input[CONF_LOCATION] = "" self._data.update(user_input) - _LOGGER.debug("Step 1: %s", user_input) return await self.async_step_user_2() return await self._show_config_form(user_input) @@ -133,7 +132,6 @@ async def async_step_user_2(self, user_input=None): _LOGGER.debug("data: %s", self._data) if user_input is not None: self._data.update(user_input) - _LOGGER.debug("Step 2: %s", user_input) return await self.async_step_user_3() return await self._show_config_form_2(user_input) @@ -143,7 +141,6 @@ async def async_step_user_3(self, user_input=None): _LOGGER.debug("data: %s", self._data) if user_input is not None: self._data.update(user_input) - _LOGGER.debug("Step 3: %s", user_input) return self.async_create_entry(title="", data=self._data) return await self._show_config_form_3(user_input) @@ -273,7 +270,7 @@ async def _get_utility_list(hass, user_input) -> list | None: address = user_input[CONF_LOCATION] radius = user_input[CONF_RADIUS] - if not bool(user_input[CONF_LOCATION]): + if not bool(address): lat = hass.config.latitude lon = hass.config.longitude address = None @@ -299,7 +296,7 @@ async def _get_plan_list(hass, user_input) -> list | None: radius = user_input[CONF_RADIUS] utility = user_input[CONF_UTILITY] - if not bool(user_input[CONF_LOCATION]): + if not bool(address): lat = hass.config.latitude lon = hass.config.longitude address = None diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 0ad3a4c..2ee0872 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -58,7 +58,7 @@ async def test_form( ) assert result["type"] == "form" assert result["errors"] == {} - # assert result["title"] == title_1 + # assert result['title'] == title_1 with patch( "custom_components.openei.async_setup", return_value=True @@ -168,7 +168,7 @@ async def test_options_flow( assert result["type"] == "form" assert result["errors"] == {} - # assert result["title"] == title_1 + # assert result['title'] == title_1 with patch("custom_components.openei.async_setup", return_value=True), patch( "custom_components.openei.async_setup_entry", @@ -277,7 +277,7 @@ async def test_options_flow_no_changes( assert result["type"] == "form" assert result["errors"] == {} - # assert result["title"] == title_1 + # assert result['title'] == title_1 with patch("custom_components.openei.async_setup", return_value=True), patch( "custom_components.openei.async_setup_entry", @@ -324,7 +324,7 @@ async def test_options_flow_no_changes( { "api_key": "fakeAPIKey", "radius": 0, - "location": "''", + "location": '""', }, "user_2", { @@ -343,7 +343,7 @@ async def test_options_flow_no_changes( "utility": "Fake Utility Co", "rate_plan": "randomstring", "sensor": "(none)", - "location": "''", + "location": "", "manual_plan": "", }, ), @@ -386,7 +386,7 @@ async def test_options_flow_some_changes( assert result["type"] == "form" assert result["errors"] == {} - # assert result["title"] == title_1 + # assert result['title'] == title_1 with patch("custom_components.openei.async_setup", return_value=True), patch( "custom_components.openei.async_setup_entry", From a42e177c12cfb7ac14b0aa495628c5c48923ecf8 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 17 Sep 2021 21:12:51 -0700 Subject: [PATCH 11/19] feat: add mincharge sensor (#47) * feat: add mincharge sensor * adjust tests --- custom_components/openei/__init__.py | 7 ++++- custom_components/openei/config_flow.py | 2 +- custom_components/openei/const.py | 5 ++-- custom_components/openei/manifest.json | 2 +- custom_components/openei/sensor.py | 15 ++++++----- tests/conftest.py | 34 ++++++++++++++++++------- tests/const.py | 10 +++++++- tests/test_config_flow.py | 7 +---- tests/test_init.py | 30 +++++++++++++++++----- tests/test_sensors.py | 26 +++++++++++++++++++ 10 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 tests/test_sensors.py diff --git a/custom_components/openei/__init__.py b/custom_components/openei/__init__.py index bf4a279..87f0eac 100644 --- a/custom_components/openei/__init__.py +++ b/custom_components/openei/__init__.py @@ -142,7 +142,12 @@ def get_sensors(hass, config) -> dict: for sensor in SENSOR_TYPES: _sensor = {} - _sensor[sensor] = getattr(rate, sensor) + value = getattr(rate, sensor) + if isinstance(value, tuple): + _sensor[sensor] = value[0] + _sensor[f"{sensor}_uom"] = value[1] + else: + _sensor[sensor] = getattr(rate, sensor) data.update(_sensor) for sensor in BINARY_SENSORS: diff --git a/custom_components/openei/config_flow.py b/custom_components/openei/config_flow.py index 784f9fb..02b600a 100644 --- a/custom_components/openei/config_flow.py +++ b/custom_components/openei/config_flow.py @@ -314,7 +314,7 @@ async def _get_plan_list(hass, user_input) -> list | None: def _lookup_plans(handler) -> list: """Return list of utilities and plans.""" - response = handler.lookup_plans() + response = handler.lookup_plans response["Not Listed"] = [{"name": "Not Listed", "label": "Not Listed"}] _LOGGER.debug("lookup_plans: %s", response) return response diff --git a/custom_components/openei/const.py b/custom_components/openei/const.py index 6e89164..41d56cd 100644 --- a/custom_components/openei/const.py +++ b/custom_components/openei/const.py @@ -42,7 +42,7 @@ SENSOR_TYPES = { "current_rate": [ "Current Energy Rate", - "mdi:currency-usd", + "mdi:cash-multiple", None, DEVICE_CLASS_MONETARY, ], @@ -51,10 +51,11 @@ "all_rates": ["All Listed Rates", "mdi:format-list-bulleted", None, None], "monthly_tier_rate": [ "Monthly Energy Rate", - "mdi:currency-usd", + "mdi:cash-multiple", None, DEVICE_CLASS_MONETARY, ], + "mincharge": ["Minimum Charge", "mdi:cash-multiple", None, DEVICE_CLASS_MONETARY], } BINARY_SENSORS = { diff --git a/custom_components/openei/manifest.json b/custom_components/openei/manifest.json index 3611216..2be5bab 100644 --- a/custom_components/openei/manifest.json +++ b/custom_components/openei/manifest.json @@ -7,6 +7,6 @@ "iot_class": "cloud_polling", "config_flow": true, "codeowners": ["@firstof9"], - "requirements": ["python-openei==0.1.14"], + "requirements": ["python-openei==0.1.15"], "version": "0.1.5" } diff --git a/custom_components/openei/sensor.py b/custom_components/openei/sensor.py index e3bc4bf..1d47dfc 100644 --- a/custom_components/openei/sensor.py +++ b/custom_components/openei/sensor.py @@ -32,13 +32,7 @@ def __init__(self, hass, sensor_type, entry, coordinator) -> None: self._unique_id = entry.entry_id self._config = entry self.coordinator = coordinator - self._attr_native_unit_of_measurement = ( - f"{self.hass.config.currency}/kWh" - if self._name in ["current_rate", "monthly_tier_rate"] - else None - ) self._device_class = SENSOR_TYPES[self._name][3] - self._attr_native_value = self.coordinator.data.get(self._name) @property def unique_id(self) -> str: @@ -60,6 +54,15 @@ def native_value(self) -> Any: """Return the value of the sensor.""" return self.coordinator.data.get(self._name) + @property + def native_unit_of_measurement(self) -> Any: + """Return the unit of measurement.""" + if self._name in ["current_rate", "monthly_tier_rate"]: + return f"{self.hass.config.currency}/kWh" + if f"{self._name}_uom" in self.coordinator.data: + return self.coordinator.data.get(f"{self._name}_uom") + return None + @property def available(self) -> bool: """Return if entity is available.""" diff --git a/tests/conftest.py b/tests/conftest.py index 9408b68..ec6c6f9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,16 +16,30 @@ def auto_enable_custom_integrations(enable_custom_integrations): @pytest.fixture(name="mock_api") def mock_api(): """Mock the library calls.""" - with patch("custom_components.openei.openeihttp"), patch( - "custom_components.openei.config_flow.openeihttp" - ): - mock_conn = mock.Mock(spec=openeihttp.Rates) - mock_conn.return_value.current_rate.return_value = 0.24477 - mock_conn.return_value.distributed_generation.return_value = "Net Metering" - mock_conn.return_value.approval.return_value = True - mock_conn.return_value.rate_name.return_value = 0.24477 + with patch("custom_components.openei.openeihttp.Rates") as mock_api: + # mock_api = mock.Mock(spec=openeihttp.Rates) + mock_api.return_value.current_rate = 0.24477 + mock_api.return_value.distributed_generation = "Net Metering" + mock_api.return_value.approval = True + mock_api.return_value.rate_name = 0.24477 + mock_api.return_value.mincharge = (10, "$/month") + mock_api.return_value.lookup_plans = ( + '"Fake Utility Co": [{"name": "Fake Plan Name", "label": "randomstring"}]' + ) + + yield mock_api + + +@pytest.fixture(name="mock_api_config") +def mock_api_config(): + """Mock the library calls.""" + with patch("custom_components.openei.config_flow.openeihttp.Rates") as mock_api: + # mock_api = mock.Mock() + mock_api.return_value.lookup_plans = { + "Fake Utility Co": [{"name": "Fake Plan Name", "label": "randomstring"}] + } - yield mock_conn + yield mock_api @pytest.fixture(name="mock_sensors") @@ -37,5 +51,7 @@ def mock_get_sensors(): "distributed_generation": "Net Metering", "approval": True, "rate_name": "Fake Test Rate", + "mincharge": 10, + "mincharge_uom": "$/month", } yield mock_sensors diff --git a/tests/const.py b/tests/const.py index 2d6fce1..d1b2392 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,6 +1,14 @@ CONFIG_DATA = { "api_key": "fakeAPIKey", - "utility": "Fake Utility Co.", + "utility": "Fake Utility Co", "rate_plan": "totallyfakerateplan", "manual_plan": "manualfakerateplan", } + +CONFIG_DATA_WITH_SENSOR = { + "api_key": "fakeAPIKey", + "utility": "Fake Utility Co", + "rate_plan": "totallyfakerateplan", + "manual_plan": "manualfakerateplan", + "sensor": "sensor.fakesensor", +} \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 2ee0872..c4c2d85 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -141,7 +141,7 @@ async def test_options_flow( title, data, hass, - mock_api, + mock_api_config, ): """Test config flow options.""" entry = MockConfigEntry( @@ -173,11 +173,6 @@ async def test_options_flow( with patch("custom_components.openei.async_setup", return_value=True), patch( "custom_components.openei.async_setup_entry", return_value=True, - ), patch( - "custom_components.openei.config_flow._lookup_plans", - return_value={ - "Fake Utility Co": [{"name": "Fake Plan Name", "label": "randomstring"}] - }, ), patch( "custom_components.openei.config_flow._get_entities", return_value=["(none)"], diff --git a/tests/test_init.py b/tests/test_init.py index 1c59b99..2bce092 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -7,14 +7,14 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.openei.const import DOMAIN -from tests.const import CONFIG_DATA +from tests.const import CONFIG_DATA, CONFIG_DATA_WITH_SENSOR async def test_setup_entry(hass, mock_sensors, mock_api): """Test settting up entities.""" entry = MockConfigEntry( domain=DOMAIN, - title="Fake Utility Co.", + title="Fake Utility Co", data=CONFIG_DATA, ) @@ -22,7 +22,7 @@ async def test_setup_entry(hass, mock_sensors, mock_api): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 5 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 6 assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -32,7 +32,7 @@ async def test_unload_entry(hass, mock_sensors, mock_api): """Test unloading entities.""" entry = MockConfigEntry( domain=DOMAIN, - title="Fake Utility Co.", + title="Fake Utility Co", data=CONFIG_DATA, ) @@ -40,14 +40,14 @@ async def test_unload_entry(hass, mock_sensors, mock_api): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 5 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 6 assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert await hass.config_entries.async_unload(entries[0].entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 5 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 6 assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 assert len(hass.states.async_entity_ids(DOMAIN)) == 0 @@ -60,7 +60,7 @@ async def test_setup_api_error(hass): """Test settting up entities.""" entry = MockConfigEntry( domain=DOMAIN, - title="Fake Utility Co.", + title="Fake Utility Co", data=CONFIG_DATA, ) @@ -71,3 +71,19 @@ async def test_setup_api_error(hass): assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert not hass.data.get(DOMAIN) + + +async def test_setup_entry_sensor_error(hass, mock_api, caplog): + """Test settting up entities.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Fake Utility Co", + data=CONFIG_DATA_WITH_SENSOR, + ) + + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert "Using meter data from sensor: sensor.fakesensor" in caplog.text + assert "Sensor: sensor.fakesensor is not valid." in caplog.text diff --git a/tests/test_sensors.py b/tests/test_sensors.py new file mode 100644 index 0000000..785521f --- /dev/null +++ b/tests/test_sensors.py @@ -0,0 +1,26 @@ +"""Tests for sensors.""" + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.openei.const import DOMAIN +from tests.const import CONFIG_DATA + +FAKE_MINCHARGE_SENSOR = "sensor.fake_utility_co_minimum_charge" + + +async def test_sensors(hass, mock_sensors, mock_api): + """Test settting up entities.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Fake Utility Co", + data=CONFIG_DATA, + ) + + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(FAKE_MINCHARGE_SENSOR) + assert state is not None + assert state.state == "10" + assert state.attributes["unit_of_measurement"] == "$/month" From fbccd007869eb7a73263a1c878959264ef0b9db0 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 18 Sep 2021 08:53:40 -0700 Subject: [PATCH 12/19] fix: remove unneeded code (#48) * fix: remove unneeded code --- custom_components/openei/config_flow.py | 1 - custom_components/openei/manifest.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/openei/config_flow.py b/custom_components/openei/config_flow.py index 02b600a..f6dd056 100644 --- a/custom_components/openei/config_flow.py +++ b/custom_components/openei/config_flow.py @@ -315,7 +315,6 @@ async def _get_plan_list(hass, user_input) -> list | None: def _lookup_plans(handler) -> list: """Return list of utilities and plans.""" response = handler.lookup_plans - response["Not Listed"] = [{"name": "Not Listed", "label": "Not Listed"}] _LOGGER.debug("lookup_plans: %s", response) return response diff --git a/custom_components/openei/manifest.json b/custom_components/openei/manifest.json index 2be5bab..45ba04f 100644 --- a/custom_components/openei/manifest.json +++ b/custom_components/openei/manifest.json @@ -7,6 +7,6 @@ "iot_class": "cloud_polling", "config_flow": true, "codeowners": ["@firstof9"], - "requirements": ["python-openei==0.1.15"], + "requirements": ["python-openei==0.1.16"], "version": "0.1.5" } From 31e85f6f2c3439b8fa7dfa12756c1f4e8f5caa0c Mon Sep 17 00:00:00 2001 From: firstof9 Date: Sat, 18 Sep 2021 18:06:25 -0700 Subject: [PATCH 13/19] fix: resolve incorrectly called method --- custom_components/openei/config_flow.py | 2 +- tests/conftest.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/custom_components/openei/config_flow.py b/custom_components/openei/config_flow.py index f6dd056..0202a1f 100644 --- a/custom_components/openei/config_flow.py +++ b/custom_components/openei/config_flow.py @@ -314,7 +314,7 @@ async def _get_plan_list(hass, user_input) -> list | None: def _lookup_plans(handler) -> list: """Return list of utilities and plans.""" - response = handler.lookup_plans + response = handler.lookup_plans() _LOGGER.debug("lookup_plans: %s", response) return response diff --git a/tests/conftest.py b/tests/conftest.py index ec6c6f9..4a0ea9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,6 @@ -from unittest import mock +"""Test configurations.""" from unittest.mock import patch -import openeihttp import pytest @@ -34,12 +33,12 @@ def mock_api(): def mock_api_config(): """Mock the library calls.""" with patch("custom_components.openei.config_flow.openeihttp.Rates") as mock_api: - # mock_api = mock.Mock() - mock_api.return_value.lookup_plans = { + mock_return = mock_api.return_value + mock_return.lookup_plans.return_value = { "Fake Utility Co": [{"name": "Fake Plan Name", "label": "randomstring"}] } - yield mock_api + yield mock_return @pytest.fixture(name="mock_sensors") From b2c54ea4a9998c3cebb2049d8e6a4d2cf9d5d0b7 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 23 Sep 2021 08:00:50 -0700 Subject: [PATCH 14/19] update dependency (#52) --- custom_components/openei/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/openei/manifest.json b/custom_components/openei/manifest.json index 45ba04f..c1623a9 100644 --- a/custom_components/openei/manifest.json +++ b/custom_components/openei/manifest.json @@ -7,6 +7,6 @@ "iot_class": "cloud_polling", "config_flow": true, "codeowners": ["@firstof9"], - "requirements": ["python-openei==0.1.16"], - "version": "0.1.5" + "requirements": ["python-openei==0.1.17"], + "version": "0.1.6" } From cee5a1a50c0aceeeae37fea7f7e1aa793c13125d Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 24 Sep 2021 08:25:34 -0700 Subject: [PATCH 15/19] fix: plan not updating on reconfigure (#54) --- custom_components/openei/__init__.py | 15 ++- custom_components/openei/config_flow.py | 10 +- custom_components/openei/translations/en.json | 6 +- tests/const.py | 6 + tests/test_config_flow.py | 125 +++++++++++++++++- tests/test_init.py | 17 ++- 6 files changed, 159 insertions(+), 20 deletions(-) diff --git a/custom_components/openei/__init__.py b/custom_components/openei/__init__.py index 87f0eac..527ee33 100644 --- a/custom_components/openei/__init__.py +++ b/custom_components/openei/__init__.py @@ -1,5 +1,4 @@ """Custom integration to integrate OpenEI with Home Assistant.""" -import asyncio from datetime import datetime, timedelta import logging @@ -13,7 +12,6 @@ from .const import ( BINARY_SENSORS, CONF_API_KEY, - CONF_LOCATION, CONF_MANUAL_PLAN, CONF_PLAN, CONF_SENSOR, @@ -46,9 +44,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if CONF_SENSOR in updated_config.keys() and updated_config[CONF_SENSOR] == "(none)": updated_config.pop(CONF_SENSOR, None) - if CONF_MANUAL_PLAN in updated_config.keys() and updated_config[CONF_MANUAL_PLAN]: - updated_config[CONF_PLAN] = updated_config[CONF_MANUAL_PLAN] - updated_config.pop(CONF_MANUAL_PLAN, None) + if ( + CONF_MANUAL_PLAN not in updated_config.keys() + or CONF_PLAN not in updated_config.keys() + or not any([updated_config[CONF_MANUAL_PLAN], updated_config[CONF_PLAN]]) + ): + _LOGGER.error("Plan configuration missing.") + raise ConfigEntryNotReady _LOGGER.debug("updated_config: %s", updated_config) if updated_config != entry.data: @@ -123,6 +125,9 @@ def get_sensors(hass, config) -> dict: meter = config.data.get(CONF_SENSOR) reading = None + if config.data.get(CONF_MANUAL_PLAN): + plan = config.data.get(CONF_MANUAL_PLAN) + if meter: _LOGGER.debug("Using meter data from sensor: %s", meter) reading = hass.states.get(meter) diff --git a/custom_components/openei/config_flow.py b/custom_components/openei/config_flow.py index 0202a1f..91de01b 100644 --- a/custom_components/openei/config_flow.py +++ b/custom_components/openei/config_flow.py @@ -140,6 +140,8 @@ async def async_step_user_3(self, user_input=None): """Handle a flow initialized by the user.""" _LOGGER.debug("data: %s", self._data) if user_input is not None: + if user_input[CONF_MANUAL_PLAN] == '""': + user_input[CONF_MANUAL_PLAN] = "" self._data.update(user_input) return self.async_create_entry(title="", data=self._data) @@ -251,10 +253,10 @@ def _get_default(key: str, fallback_default: Any = None) -> Any | None: return vol.Schema( { - vol.Required(CONF_PLAN, default=_get_default(CONF_PLAN, "")): vol.In( - plan_list - ), - vol.Optional(CONF_MANUAL_PLAN, default=_get_default(CONF_PLAN, "")): str, + vol.Optional(CONF_PLAN, default=_get_default(CONF_PLAN)): vol.In(plan_list), + vol.Optional( + CONF_MANUAL_PLAN, default=_get_default(CONF_MANUAL_PLAN, "") + ): str, vol.Required( CONF_SENSOR, default=_get_default(CONF_SENSOR, "(none)") ): vol.In(_get_entities(hass, SENSORS_DOMAIN, "energy", "(none)")), diff --git a/custom_components/openei/translations/en.json b/custom_components/openei/translations/en.json index 6082b81..613217e 100644 --- a/custom_components/openei/translations/en.json +++ b/custom_components/openei/translations/en.json @@ -12,7 +12,7 @@ }, "user_2": { "title": "OpenEI (Step 2)", - "description": "Select your utility company.", + "description": "Select your utility company.\n\nIf you are entering a plan manually please select 'Not Listed'", "data": { "utility": "Utility Company" } @@ -47,14 +47,14 @@ }, "user_2": { "title": "OpenEI (Step 2)", - "description": "Select your utility company.", + "description": "Select your utility company.\n\nIf you are entering a plan manually please select 'Not Listed'", "data": { "utility": "Utility Company" } }, "user_3": { "title": "OpenEI (Step 3)", - "description": "Select your plan from the list. If you are unsure, check your utility bill.\n\nManual plan will override the selected rate plan.", + "description": "Select your plan from the list. If you are unsure, check your utility bill.\n\nManual plan will override the selected rate plan.\n\nEnter \"\" to clear the manual plan.", "data": { "rate_plan": "Rate Plan", "sensor": "Energy sensor for Tier plans (optional)", diff --git a/tests/const.py b/tests/const.py index d1b2392..71e647d 100644 --- a/tests/const.py +++ b/tests/const.py @@ -11,4 +11,10 @@ "rate_plan": "totallyfakerateplan", "manual_plan": "manualfakerateplan", "sensor": "sensor.fakesensor", +} + +CONFIG_DATA_MISSING_PLAN = { + "api_key": "fakeAPIKey", + "utility": "Fake Utility Co", + "sensor": "sensor.fakesensor", } \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index c4c2d85..eb96c97 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -323,23 +323,23 @@ async def test_options_flow_no_changes( }, "user_2", { - "utility": "Fake Utility Co", + "utility": "Not Listed", }, "user_3", { - "rate_plan": "randomstring", + "rate_plan": "Not Listed", "sensor": "(none)", - "manual_plan": "", + "manual_plan": "randomstring", }, "Fake Utility Co", { "api_key": "fakeAPIKey", "radius": 0, - "utility": "Fake Utility Co", - "rate_plan": "randomstring", + "utility": "Not Listed", + "rate_plan": "Not Listed", "sensor": "(none)", "location": "", - "manual_plan": "", + "manual_plan": "randomstring", }, ), ], @@ -389,7 +389,8 @@ async def test_options_flow_some_changes( ), patch( "custom_components.openei.config_flow._lookup_plans", return_value={ - "Fake Utility Co": [{"name": "Fake Plan Name", "label": "randomstring"}] + "Fake Utility Co": [{"name": "Fake Plan Name", "label": "randomstring"}], + "Not Listed": [{"name": "Not Listed", "label": "Not Listed"}], }, ): @@ -419,3 +420,113 @@ async def test_options_flow_some_changes( assert ( "Attempting to reload entities from the openei integration" in caplog.text ) + + +@pytest.mark.parametrize( + "input_1,step_id_2,input_2,step_id_3,input_3,title,data", + [ + ( + { + "api_key": "fakeAPIKey", + "radius": 0, + "location": '""', + }, + "user_2", + { + "utility": "Fake Utility Co", + }, + "user_3", + { + "rate_plan": "randomstring", + "sensor": "(none)", + "manual_plan": '""', + }, + "Fake Utility Co", + { + "api_key": "fakeAPIKey", + "radius": 0, + "utility": "Fake Utility Co", + "rate_plan": "randomstring", + "sensor": "(none)", + "location": "", + "manual_plan": "", + }, + ), + ], +) +async def test_options_flow_some_changes_2( + input_1, + step_id_2, + input_2, + step_id_3, + input_3, + title, + data, + hass, + mock_api, + caplog, +): + """Test config flow options.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Fake Utility Co", + data={ + "api_key": "fakeAPIKey", + "radius": 0, + "location": "12345", + "utility": "Not Listed", + "rate_plan": "Not Listed", + "sensor": "(none)", + "manual_plan": "somerandomstring", + }, + ) + + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["errors"] == {} + # assert result['title'] == title_1 + + with patch("custom_components.openei.async_setup", return_value=True), patch( + "custom_components.openei.async_setup_entry", + return_value=True, + ), patch( + "custom_components.openei.config_flow._lookup_plans", + return_value={ + "Fake Utility Co": [{"name": "Fake Plan Name", "label": "randomstring"}], + "Not Listed": [{"name": "Not Listed", "label": "Not Listed"}], + }, + ): + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], input_1 + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["step_id"] == step_id_2 + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], input_2 + ) + await hass.async_block_till_done() + + assert result3["type"] == "form" + assert result3["step_id"] == step_id_3 + result4 = await hass.config_entries.options.async_configure( + result["flow_id"], input_3 + ) + await hass.async_block_till_done() + assert result4["type"] == "create_entry" + assert data == entry.data.copy() + + await hass.async_block_till_done() + assert ( + "Attempting to reload entities from the openei integration" in caplog.text + ) \ No newline at end of file diff --git a/tests/test_init.py b/tests/test_init.py index 2bce092..65a6a8d 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,4 +1,5 @@ """Tests for init.""" +import pytest from unittest.mock import patch from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -7,7 +8,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.openei.const import DOMAIN -from tests.const import CONFIG_DATA, CONFIG_DATA_WITH_SENSOR +from tests.const import CONFIG_DATA, CONFIG_DATA_MISSING_PLAN, CONFIG_DATA_WITH_SENSOR async def test_setup_entry(hass, mock_sensors, mock_api): @@ -87,3 +88,17 @@ async def test_setup_entry_sensor_error(hass, mock_api, caplog): assert "Using meter data from sensor: sensor.fakesensor" in caplog.text assert "Sensor: sensor.fakesensor is not valid." in caplog.text + + +async def test_setup_entry_sensor_plan_error(hass, mock_api, caplog): + """Test settting up entities.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Fake Utility Co", + data=CONFIG_DATA_MISSING_PLAN, + ) + + entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert "Plan configuration missing." in caplog.text From 73ba08a069168c207aea37c94e6a12e3c5edb195 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 25 Sep 2021 19:17:39 -0700 Subject: [PATCH 16/19] fix: adjust rate plan check (#55) --- custom_components/openei/__init__.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/custom_components/openei/__init__.py b/custom_components/openei/__init__.py index 527ee33..9bf47dd 100644 --- a/custom_components/openei/__init__.py +++ b/custom_components/openei/__init__.py @@ -44,11 +44,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if CONF_SENSOR in updated_config.keys() and updated_config[CONF_SENSOR] == "(none)": updated_config.pop(CONF_SENSOR, None) - if ( - CONF_MANUAL_PLAN not in updated_config.keys() - or CONF_PLAN not in updated_config.keys() - or not any([updated_config[CONF_MANUAL_PLAN], updated_config[CONF_PLAN]]) - ): + if CONF_MANUAL_PLAN not in updated_config.keys(): + updated_config[CONF_MANUAL_PLAN] = "" + + if CONF_PLAN not in updated_config.keys(): + updated_config[CONF_PLAN] = "" + + if not any([updated_config[CONF_MANUAL_PLAN], updated_config[CONF_PLAN]]): _LOGGER.error("Plan configuration missing.") raise ConfigEntryNotReady From c6c597a9b5256ee5cbd04b5e09df17ea40b04b3f Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 18 Oct 2021 13:51:25 -0700 Subject: [PATCH 17/19] refactor: sensor update for 2021.11.x (#56) * refactor: sensor update for 2021.11.x * update get_sensors function * formatting * fix native_value key * fix icons --- custom_components/openei/__init__.py | 4 +-- custom_components/openei/const.py | 53 ++++++++++++++++++---------- custom_components/openei/sensor.py | 49 ++++++++++++------------- 3 files changed, 60 insertions(+), 46 deletions(-) diff --git a/custom_components/openei/__init__.py b/custom_components/openei/__init__.py index 9bf47dd..637c3bb 100644 --- a/custom_components/openei/__init__.py +++ b/custom_components/openei/__init__.py @@ -149,12 +149,12 @@ def get_sensors(hass, config) -> dict: for sensor in SENSOR_TYPES: _sensor = {} - value = getattr(rate, sensor) + value = getattr(rate, SENSOR_TYPES[sensor].key) if isinstance(value, tuple): _sensor[sensor] = value[0] _sensor[f"{sensor}_uom"] = value[1] else: - _sensor[sensor] = getattr(rate, sensor) + _sensor[sensor] = getattr(rate, SENSOR_TYPES[sensor].key) data.update(_sensor) for sensor in BINARY_SENSORS: diff --git a/custom_components/openei/const.py b/custom_components/openei/const.py index 41d56cd..4e0e2b6 100644 --- a/custom_components/openei/const.py +++ b/custom_components/openei/const.py @@ -1,5 +1,8 @@ """Constants for integration_blueprint.""" -from homeassistant.const import DEVICE_CLASS_MONETARY +from __future__ import annotations +from homeassistant.components.sensor import SensorEntityDescription + +from typing import Final # Base component constants NAME = "OpenEI" @@ -39,23 +42,37 @@ ------------------------------------------------------------------- """ # property: name, icon, unit_of_measurement, device_class -SENSOR_TYPES = { - "current_rate": [ - "Current Energy Rate", - "mdi:cash-multiple", - None, - DEVICE_CLASS_MONETARY, - ], - "distributed_generation": ["Distributed Generation", "mdi:gauge", None, None], - "rate_name": ["Plan Name", "mdi:tag", None, None], - "all_rates": ["All Listed Rates", "mdi:format-list-bulleted", None, None], - "monthly_tier_rate": [ - "Monthly Energy Rate", - "mdi:cash-multiple", - None, - DEVICE_CLASS_MONETARY, - ], - "mincharge": ["Minimum Charge", "mdi:cash-multiple", None, DEVICE_CLASS_MONETARY], +SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { + "current_rate": SensorEntityDescription( + key="current_rate", + name="Current Energy Rate", + icon="mdi:cash-multiple", + ), + "distributed_generation": SensorEntityDescription( + key="distributed_generation", + name="Distributed Generation", + icon="mdi:gauge", + ), + "rate_name": SensorEntityDescription( + key="rate_name", + name="Plan Name", + icon="mdi:tag", + ), + "all_rates": SensorEntityDescription( + key="all_rates", + name="All Listed Rates", + icon="mdi:format-list-bulleted", + ), + "monthly_tier_rate": SensorEntityDescription( + key="monthly_tier_rate", + name="Monthly Energy Rate", + icon="mdi:cash-multiple", + ), + "mincharge": SensorEntityDescription( + key="mincharge", + name="Minimum Charge", + icon="mdi:cash-multiple", + ), } BINARY_SENSORS = { diff --git a/custom_components/openei/sensor.py b/custom_components/openei/sensor.py index 1d47dfc..dd29621 100644 --- a/custom_components/openei/sensor.py +++ b/custom_components/openei/sensor.py @@ -1,8 +1,10 @@ """Sensor platform for integration_blueprint.""" from typing import Any, Optional -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -16,7 +18,7 @@ async def async_setup_entry(hass, entry, async_add_devices): sensors = [] for sensor in SENSOR_TYPES: - sensors.append(OpenEISensor(hass, sensor, entry, coordinator)) + sensors.append(OpenEISensor(hass, SENSOR_TYPES[sensor], entry, coordinator)) async_add_devices(sensors, False) @@ -24,43 +26,38 @@ async def async_setup_entry(hass, entry, async_add_devices): class OpenEISensor(CoordinatorEntity, SensorEntity): """OpenEI Sensor class.""" - def __init__(self, hass, sensor_type, entry, coordinator) -> None: + def __init__( + self, + hass: HomeAssistant, + sensor_description: SensorEntityDescription, + entry: ConfigEntry, + coordinator: str, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.hass = hass - self._name = sensor_type + self._name = sensor_description.name + self._key = sensor_description.key self._unique_id = entry.entry_id self._config = entry + self._icon = sensor_description.icon self.coordinator = coordinator - self._device_class = SENSOR_TYPES[self._name][3] - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._name}_{self._unique_id}" - - @property - def name(self): - """Return the name of the sensor.""" - return f"{slugify(self._config.title)}_{SENSOR_TYPES[self._name][0]}" - - @property - def icon(self) -> str: - """Return the icon of the sensor.""" - return SENSOR_TYPES[self._name][1] + self._attr_name = f"{slugify(self._config.title)}_{self._name}" + self._attr_unique_id = f"{self._key}_{self._unique_id}" @property def native_value(self) -> Any: """Return the value of the sensor.""" - return self.coordinator.data.get(self._name) + return self.coordinator.data.get(self._key) @property def native_unit_of_measurement(self) -> Any: """Return the unit of measurement.""" - if self._name in ["current_rate", "monthly_tier_rate"]: + if self._key in ["current_rate", "monthly_tier_rate"]: return f"{self.hass.config.currency}/kWh" - if f"{self._name}_uom" in self.coordinator.data: - return self.coordinator.data.get(f"{self._name}_uom") + if f"{self._key}_uom" in self.coordinator.data: + return self.coordinator.data.get(f"{self._key}_uom") return None @property @@ -76,6 +73,6 @@ def device_state_attributes(self) -> Optional[dict]: return attrs @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class + def icon(self) -> str: + """Return the icon.""" + return self._icon \ No newline at end of file From c941615f25322f00e9d8f2eab156a0f030ee2256 Mon Sep 17 00:00:00 2001 From: firstof9 Date: Fri, 3 Dec 2021 15:22:40 -0700 Subject: [PATCH 18/19] fix: change to Home Assistant 2021.12.0+ core --- custom_components/openei/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/openei/sensor.py b/custom_components/openei/sensor.py index dd29621..ef2914d 100644 --- a/custom_components/openei/sensor.py +++ b/custom_components/openei/sensor.py @@ -66,7 +66,7 @@ def available(self) -> bool: return self.coordinator.last_update_success @property - def device_state_attributes(self) -> Optional[dict]: + def extra_state_attributes(self) -> Optional[dict]: """Return sesnsor attributes.""" attrs = {} attrs[ATTR_ATTRIBUTION] = ATTRIBUTION From ecf8c68a0a8f3a18d844cf1a0b65f77f0acf4bf5 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 4 Apr 2022 11:48:59 -0700 Subject: [PATCH 19/19] fix: add entities under service (#59) * fix: add entities under service * fix typo * update binary sensor properties --- custom_components/openei/binary_sensor.py | 48 +++++++++++++++++------ custom_components/openei/const.py | 22 +++++++++-- custom_components/openei/sensor.py | 14 ++++++- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/custom_components/openei/binary_sensor.py b/custom_components/openei/binary_sensor.py index aaf5638..729a5bf 100644 --- a/custom_components/openei/binary_sensor.py +++ b/custom_components/openei/binary_sensor.py @@ -1,5 +1,9 @@ """Binary sensor platform for OpenEI.""" -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -12,7 +16,7 @@ async def async_setup_entry(hass, entry, async_add_devices): binary_sensors = [] for binary_sensor in BINARY_SENSORS: - binary_sensors.append(OpenEIBinarySensor(coordinator, entry, binary_sensor)) + binary_sensors.append(OpenEIBinarySensor(hass, BINARY_SENSORS[binary_sensor], entry, coordinator)) async_add_devices(binary_sensors, False) @@ -20,20 +24,42 @@ async def async_setup_entry(hass, entry, async_add_devices): class OpenEIBinarySensor(CoordinatorEntity, BinarySensorEntity): """integration_blueprint binary_sensor class.""" - def __init__(self, coordinator, entry, sensor_type) -> None: + def __init__( + self, + hass: HomeAssistant, + sensor_description: BinarySensorEntityDescription, + entry: ConfigEntry, + coordinator: str, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._name = sensor_type + self._name = sensor_description.name + self._key = sensor_description.key + self._unique_id = entry.entry_id + self._icon = sensor_description.icon self._config = entry self.coordinator = coordinator - self._attr_is_on = coordinator.data.get(sensor_type) + self._attr_is_on = coordinator.data.get(self._key) + + self._attr_name = f"{slugify(self._config.title)}_{self._name}" + self._attr_unique_id = f"{self._key}_{self._unique_id}" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return f"{self._name}_{self._config.entry_id}" + def icon(self) -> str: + """Return the icon.""" + return self._icon @property - def name(self): - """Return the name of the binary_sensor.""" - return f"{slugify(self._config.title)}_{BINARY_SENSORS[self._name][0]}" + def device_info(self) -> DeviceInfo: + """Return device registry information.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._config.entry_id)}, + manufacturer="OpenEI", + name="OpenEI", + ) \ No newline at end of file diff --git a/custom_components/openei/const.py b/custom_components/openei/const.py index 4e0e2b6..4a00ab4 100644 --- a/custom_components/openei/const.py +++ b/custom_components/openei/const.py @@ -1,6 +1,12 @@ """Constants for integration_blueprint.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.components.binary_sensor import ( + BinarySensorEntityDescription, +) +from homeassistant.components.sensor import ( + SensorEntityDescription, +) +from homeassistant.helpers.entity import EntityCategory from typing import Final @@ -52,6 +58,7 @@ key="distributed_generation", name="Distributed Generation", icon="mdi:gauge", + entity_category=EntityCategory.DIAGNOSTIC, ), "rate_name": SensorEntityDescription( key="rate_name", @@ -62,6 +69,7 @@ key="all_rates", name="All Listed Rates", icon="mdi:format-list-bulleted", + entity_category=EntityCategory.DIAGNOSTIC, ), "monthly_tier_rate": SensorEntityDescription( key="monthly_tier_rate", @@ -72,9 +80,15 @@ key="mincharge", name="Minimum Charge", icon="mdi:cash-multiple", + entity_category=EntityCategory.DIAGNOSTIC, ), } -BINARY_SENSORS = { - "approval": ["Approval", "mdi:check", None, None], -} +BINARY_SENSORS: Final[dict[str, BinarySensorEntityDescription]] = { + "approval": BinarySensorEntityDescription( + name="Approval", + key="approval", + icon="mdi:check", + entity_category=EntityCategory.DIAGNOSTIC, + ), +} \ No newline at end of file diff --git a/custom_components/openei/sensor.py b/custom_components/openei/sensor.py index ef2914d..a6df85c 100644 --- a/custom_components/openei/sensor.py +++ b/custom_components/openei/sensor.py @@ -5,6 +5,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -75,4 +77,14 @@ def extra_state_attributes(self) -> Optional[dict]: @property def icon(self) -> str: """Return the icon.""" - return self._icon \ No newline at end of file + return self._icon + + @property + def device_info(self) -> DeviceInfo: + """Return device registry information.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._config.entry_id)}, + manufacturer="OpenEI", + name="OpenEI", + ) \ No newline at end of file