diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..62745884 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,14 @@ +name: Setup dependencies +description: Sets up required dependencies +runs: + using: composite + steps: + - name: Install dependencies + run: sudo apt install libffi-dev libncurses5-dev zlib1g zlib1g-dev libssl-dev libreadline-dev libbz2-dev libsqlite3-dev + shell: bash + - name: asdf_install + uses: asdf-vm/actions/install@v3 + - name: Install Python modules + run: | + pip install -r requirements.test.txt + shell: bash \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0a732632..d25e456e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,6 +6,10 @@ on: - 'mkdocs.yml' - '_docs/**' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -30,7 +34,7 @@ jobs: restore-keys: | mkdocs-material- - run: pip install -r requirements.txt - - run: mkdocs build --strict + - run: mkdocs build deploy_docs: if: ${{ github.repository_owner == 'BottlecapDave' && (github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main') }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 81702319..e0cdc102 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,6 +16,11 @@ on: paths-ignore: - 'mkdocs.yml' - '_docs/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: validate: if: ${{ github.event_name != 'schedule' || github.repository_owner == 'BottlecapDave' }} @@ -23,7 +28,7 @@ jobs: runs-on: "ubuntu-latest" steps: - uses: actions/checkout@v4 - # - uses: "home-assistant/actions/hassfest@master" # Disabled due to https://github.com/home-assistant/actions/issues/116 + - uses: "home-assistant/actions/hassfest@master" - name: HACS Action uses: "hacs/action@main" with: @@ -37,14 +42,8 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Install dependencies - run: sudo apt install libffi-dev libncurses5-dev zlib1g zlib1g-dev libssl-dev libreadline-dev libbz2-dev libsqlite3-dev - shell: bash - - name: asdf_install - uses: asdf-vm/actions/install@v3 - - name: Install Python modules - run: | - pip install -r requirements.test.txt + - name: Setup + uses: ./.github/actions/setup - name: Run unit tests run: | python -m pytest tests/unit @@ -58,14 +57,8 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Install dependencies - run: sudo apt install libffi-dev libncurses5-dev zlib1g zlib1g-dev libssl-dev libreadline-dev libbz2-dev libsqlite3-dev - shell: bash - - name: asdf_install - uses: asdf-vm/actions/install@v3 - - name: Install Python modules - run: | - pip install -r requirements.test.txt + - name: Setup + uses: ./.github/actions/setup - name: Run integration tests run: | python -m pytest tests/integration diff --git a/CHANGELOG.md b/CHANGELOG.md index ee84c811..b3647540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,29 +1,19 @@ -# [13.4.0-beta.1](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/compare/v13.3.0...v13.4.0-beta.1) (2024-12-21) +# [13.4.0](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/compare/v13.3.0...v13.4.0) (2024-12-26) ### Bug Fixes -* Fixed heat pump retrieved at parsing (15 minutes dev time) ([cd47109](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/cd471094811f120a56c6fa4a7498d532ad32a3bc)) -* Fixed heat pump retrieved at parsing (15 minutes dev time) ([#1118](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/issues/1118)) ([aeac537](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/aeac537b1624e1b6bbcb4e8d9089ae44d9ee3f09)) -* Fixed issue with setting heat pump zone temperature when in boost mode (30 minutes dev time) ([c0cfe69](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/c0cfe69c9e36067e8732bdb5305915f2acfefa02)) -* Fixed zone mode interpretations in zone climate control and exposed target temperature in boost service (30 minutes dev time) ([7eb6755](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/7eb67553119773616097b6cc9360e03ed793fb46)) +* Fixed state class for current total consumption sensors (5 minutes dev time) ([9af97a5](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/9af97a54b7a9dfb091f4e48f0cd66f758e7e2629)) +* Updated total consumption sensors to ignore zero based results reported by home pro (10 minute dev time) ([78748d1](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/78748d169887227de1a2f1f8bc73dfd1bf281190)) ### Features -* Added ability to change zone modes and target temperatures (1 hour dev time) ([56aef85](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/56aef85031be5e4b9a5bb5621b042a5616304fc7)) -* Added climate control for heat pump zone (3 hours dev time) ([5e72161](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/5e72161a180119110be950322dfafeab6b755b27)) -* Added endpoint to get heat pump configuration and status ([1826925](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/1826925513a288bc1b6a4772c305deb7cf3dc6d6)) -* Added heat pump humidity sensor (1 hour dev time) ([b479c89](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/b479c8999256a70273077c8c0b5f001decbdbf84)) -* Added heat pump information to device diagnostics (30 minutes dev time) ([b8468bc](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/b8468bce31a4dff419b3bfe21392911b88f05f0d)) -* Added temperature sensors from heat pumps (2.5 hours dev time) ([c79e5b8](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/c79e5b8bdb50ee974027e2f5d9a7a16360634621)) -* Created heat pump boosting action to support custom end time (40 minutes dev time) ([bc047f8](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/bc047f866a2047cd3d2cc059cce362d2f56a2ada)) -* Fixed heat pump service calls (30 minutes dev time) ([ff88cd4](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/ff88cd419b626daccb10d236a772b27931a4f08a)) -* Updated account to include heat pump ids (5 minutes dev time) ([5c411c8](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/5c411c8efafc41ca323ae4d8415f03cb213da282)) -* Updated heat pump zone min/max temperatures based on zone type (5 minutes dev time) ([2ce5fe6](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/2ce5fe64456434552d581aaaa311030fa0cec8f4)) -* Updated heat pump zones and sensors to not be included if not enabled (5 minutes dev time) ([f762c4d](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/f762c4d214ba635abc8447536a75e4e1402ffea1)) +* Added ability to apply weightings to rates from external sources for use with target rate and rolling target rate sensors (4 hours 30 minutes dev time) ([9350c3f](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/9350c3fbdb46514033a175e1db7e521b2fc07835)) +* Added support for INDRA intelligent provider (5 minutes dev time) ([7c3596c](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/7c3596cc920957dfe7ca23e42828aad826e44c43)) +* Updated Home Pro config to support custom API being optional if certain features are not required (see docs for more information) (45 minutes dev time) ([8b94c7d](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/8b94c7d15109476acb53f930762c7ee17a4e0ed6)) * Updated Home Pro to contact local API directly instead of via custom API (45 minutes dev time) ([0ace45e](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/0ace45e325bdbb104a24787bc26556e09e3d804e)) -* Updated manufacturer of heat pump and heat pump sensors (5 minutes dev time) ([d68e8b6](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/d68e8b6893f8391a20b5d787fb6c6f0dda1fba1a)) +* Updated target rates to support additional re-evaluation modes for target times. This is to assist with external weightings changing (30 minutes dev time) ([05db8c2](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/commit/05db8c2ffcbdc39341a6e419c77bf9113a6aebe6)) # [13.3.0](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/compare/v13.2.1...v13.3.0) (2024-12-16) diff --git a/README.md b/README.md index 0f6f8cc0..39800072 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Below are the main features of the integration * [Saving sessions support](https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/entities/octoplus/#saving-sessions) * [Wheel of fortune support](https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/entities/wheel_of_fortune/) * [Greener days support](https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/entities/greenness_forecast) +* [Heat Pump support](https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/entities/heat_pump) ## How to install diff --git a/_docs/assets/total_consumption_electricity.png b/_docs/assets/total_consumption_electricity.png new file mode 100644 index 00000000..acbc48e8 Binary files /dev/null and b/_docs/assets/total_consumption_electricity.png differ diff --git a/_docs/assets/total_consumption_gas.png b/_docs/assets/total_consumption_gas.png new file mode 100644 index 00000000..94bc00d2 Binary files /dev/null and b/_docs/assets/total_consumption_gas.png differ diff --git a/_docs/entities/heat_pump.md b/_docs/entities/heat_pump.md new file mode 100644 index 00000000..fe46f9ea --- /dev/null +++ b/_docs/entities/heat_pump.md @@ -0,0 +1,31 @@ +# Heat Pump + +The following entities are available if you have a heat pump registered against your account. The following heat pumps are known to be compatible + +* Cosy 6 + +## Humidity Sensor + +`sensor.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_{{SENSOR_CODE}}_humidity` + +This represents the humidity reported by a sensor (e.g. Cosy Pod) that is associated with a heat pump. + +## Temperature Sensor + +`sensor.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_{{SENSOR_CODE}}_temperature` + +This represents the temperature reported by a sensor (e.g. Cosy Pod) that is associated with a heat pump. + +## Zone + +`climate.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_{{ZONE_CODE}}` + +This can be used to control the target temperature and mode for a given zone (e.g. water or zone 1) linked to your heat pump. It will also display the current temperature linked to the primary sensor for the zone. + +The following operation modes are available + +* `Heat` - This represents as `on` in the app +* `Off` - This represents as `off` in the app +* `Auto` - This represents as `auto` in the app + +In addition, there is the preset of `boost`, which activates boost mode for the zone for 1 hour. If you require boost to be on for a different amount of time, then you can use the [available service](../services.md#octopus_energyboost_heat_pump_zone). \ No newline at end of file diff --git a/_docs/entities/home_pro.md b/_docs/entities/home_pro.md index 42161d86..03d38cb2 100644 --- a/_docs/entities/home_pro.md +++ b/_docs/entities/home_pro.md @@ -13,4 +13,8 @@ Once configured, the following entities will retrieve data locally from your Oct `text.octopus_energy_{{ACCOUNT_ID}}_home_pro_screen` -Allows you to set scrolling text for the home pro device. If the text is greater than 3 characters, then it will scroll on the device, otherwise it will be statically displayed. \ No newline at end of file +!!! info + + This is only available if you have setup the [Custom API](../setup/account.md#home-pro). + +Allows you to set scrolling text on the home pro device. If the text is greater than 3 characters, then it will scroll on the device, otherwise it will be statically displayed. \ No newline at end of file diff --git a/_docs/entities/intelligent.md b/_docs/entities/intelligent.md index 779c504b..68dc2a88 100644 --- a/_docs/entities/intelligent.md +++ b/_docs/entities/intelligent.md @@ -127,5 +127,5 @@ If you're moving to this integration from [megakid/ha_octopus_intelligent](https * `sensor.octopus_intelligent_offpeak_end` - The default off peak end date/time can be found as an attribute on the [off peak sensor](./electricity.md#off-peak). This can be extracted using a [template sensor](https://www.home-assistant.io/integrations/template/). * `switch.octopus_intelligent_bump_charge` - Use the [bump charge sensor](#bump-charge) * `switch.octopus_intelligent_smart_charging` - Use the [smart charge sensor](#smart-charge) -* `select.octopus_intelligent_target_time` - Use the [ready time sensor](#ready-time) -* `select.octopus_intelligent_target_soc` - Use the [charge limit sensor](#charge-limit) \ No newline at end of file +* `select.octopus_intelligent_target_time` - Use the [target time sensor](#target-time) +* `select.octopus_intelligent_target_soc` - Use the [charge target sensor](#charge-target) \ No newline at end of file diff --git a/_docs/events.md b/_docs/events.md index 77c3b9fe..0132367f 100644 --- a/_docs/events.md +++ b/_docs/events.md @@ -126,34 +126,6 @@ This is fired when the [previous consumption's](./entities/electricity.md#previo New rates available for {{ trigger.event.data.mpan }}. Starting value is {{ trigger.event.data.rates[0]["value_inc_vat"] }} ``` -## Electricity Previous Consumption Override Rates - -`octopus_energy_electricity_previous_consumption_override_rates` - -This is fired when the [previous consumption override's](./entities/electricity.md#tariff-overrides) rates are updated. - -| Attribute | Type | Description | -|-----------|------|-------------| -| `rates` | `array` | The list of rates applicable for the previous consumption override | -| `tariff_code` | `string` | The tariff code associated with previous consumption override's rates | -| `mpan` | `string` | The mpan of the meter associated with these rates | -| `serial_number` | `string` | The serial number of the meter associated with these rates | - -### Automation Example - -```yaml -- trigger: - - platform: event - event_type: octopus_energy_electricity_previous_consumption_override_rates - condition: [] - action: - - service: persistent_notification.create - data: - title: "Rates Updated" - message: > - New rates available for {{ trigger.event.data.mpan }}. Starting value is {{ trigger.event.data.rates[0]["value_inc_vat"] }} -``` - ## Gas Current Day Rates `octopus_energy_gas_current_day_rates` @@ -278,34 +250,6 @@ This is fired when the [previous consumption's](./entities/gas.md#previous-accum New rates available for {{ trigger.event.data.mprn }}. Starting value is {{ trigger.event.data.rates[0]["value_inc_vat"] }} ``` -## Gas Previous Consumption Override Rates - -`octopus_energy_gas_previous_consumption_override_rates` - -This is fired when the [previous consumption override's](./entities/gas.md#tariff-overrides) rates are updated. - -| Attribute | Type | Description | -|-----------|------|-------------| -| `rates` | `array` | The list of rates applicable for the previous consumption override | -| `tariff_code` | `string` | The tariff code associated with previous consumption override's rates | -| `mprn` | `string` | The mprn of the meter associated with these rates | -| `serial_number` | `string` | The serial number of the meter associated with these rates | - -### Automation Example - -```yaml -- trigger: - - platform: event - event_type: octopus_energy_gas_previous_consumption_override_rates - condition: [] - action: - - service: persistent_notification.create - data: - title: "Rates Updated" - message: > - New rates available for {{ trigger.event.data.mprn }}. Starting value is {{ trigger.event.data.rates[0]["value_inc_vat"] }} -``` - ## New Saving Session `octopus_energy_new_octoplus_saving_session` diff --git a/_docs/index.md b/_docs/index.md index 741c2da9..7f170787 100644 --- a/_docs/index.md +++ b/_docs/index.md @@ -14,6 +14,7 @@ Below are the main features of the integration * [Saving sessions support](#octoplus) * [Wheel of fortune support](#wheel-of-fortune) * [Greener days support](#greenness-forecast) +* [Heat Pump support](#heat-pumps) ## How to install @@ -92,7 +93,7 @@ To support the wheel of fortune that is awarded every month to customers. [Full ### Greenness Forecast -To support Octopus Energy's [greener days](https://octopus.energy/smart/greener-days/). [Full list of greenness forecast entites](./entities/greenness_forecast.md). +To support Octopus Energy's [greener days](https://octopus.energy/smart/greener-days/). [Full list of greenness forecast entities](./entities/greenness_forecast.md). ## Target Rate Sensors @@ -114,6 +115,10 @@ These sensors compare the cost of the previous consumption to another tariff to Please follow the [setup guide](./setup/tariff_comparison.md). +### Heat Pumps + +To support heat pumps connected to Octopus Energy, like the [Cosy 6](https://octopus.energy/cosy-heat-pump/). [Full list of heat pump entities](./entities/heat_pump.md). + ## Events This integration raises several events, which can be used for various tasks like automations. For more information, please see the [events docs](./events.md). diff --git a/_docs/services.md b/_docs/services.md index 25772394..6f5a8bde 100644 --- a/_docs/services.md +++ b/_docs/services.md @@ -235,4 +235,47 @@ Allows you to adjust the consumption for any given period recorded by a [cost tr | ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | | `target.entity_id` | `no` | The name of the cost tracker sensor(s) that should be updated (e.g. `sensor.octopus_energy_cost_tracker_{{COST_TRACKER_NAME}}`). | | `data.date` | `no` | The date of the data within the cost tracker to be adjusted. | -| `data.consumption` | `no` | The new consumption recorded against the specified date. | \ No newline at end of file +| `data.consumption` | `no` | The new consumption recorded against the specified date. | + +## octopus_energy.register_rate_weightings + +Allows you to configure weightings against rates at given times using factors external to the integration. These are applied when calculating [target rates](./setup/target_rate.md#external-rate-weightings) or [rolling target rates](./setup/rolling_target_rate.md#external-rate-weightings). + +Rate weightings are added to any existing rate weightings that have been previously configured. Any rate weightings that are more than 24 hours old are removed. Any rate weightings for periods that have been previously configured are overridden. + +| Attribute | Optional | Description | +| ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `target.entity_id` | `no` | The name of the electricity current rate sensor for the rates the weighting should be applied to (e.g. `sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_current_rate`). | +| `data.weightings` | `no` | The collection of weightings to add. Each item in the array should represent a given 30 minute period. Example array is `[{ "start": "2025-01-01T00:00:00Z", "end": "2025-01-01T00:30:00Z", "weighting": 0.1 }]` | + +### Automation Example + +This automation adds weightings based on the national grids carbon intensity, as provided by [Carbon Intensity](https://github.com/BottlecapDave/HomeAssistant-CarbonIntensity). + +```yaml +- alias: Carbon Intensity Rate Weightings + triggers: + - platform: state + entity_id: event.carbon_intensity_national_current_day_rates + actions: + - action: octopus_energy.register_rate_weightings + target: + entity_id: sensor.octopus_energy_electricity_xxx_xxx_current_rate + data: + weightings: > + {% set forecast = state_attr('event.carbon_intensity_national_current_day_rates', 'rates') + state_attr('event.carbon_intensity_national_next_day_rates', 'rates') %} + {% set ns = namespace(list = []) %} {%- for a in forecast -%} + {%- set ns.list = ns.list + [{ "start": a.from.strftime('%Y-%m-%dT%H:%M:%SZ'), "end": a.to.strftime('%Y-%m-%dT%H:%M:%SZ'), "weighting": a.intensity_forecast | float }] -%} + {%- endfor -%} {{ ns.list }} +``` + +## octopus_energy.boost_heat_pump_zone + +Allows you to boost a given heat pump zone for a set amount of time. + +| Attribute | Optional | Description | +| ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `target.entity_id` | `no` | The name of the heat pump zone boost mode should be applied to (e.g. `climate.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_{{ZONE_CODE}}`). | +| `data.hours` | `no` | The number of hours to turn boost mode on for. This can be between 0 and 12. | +| `data.minutes` | `no` | The number of minutes to turn boost mode on for. This can be 0, 15, or 45. | +| `data.target_temperature` | `yes` | The optional target temperature to boost to. If not supplied, then the current target temperature will be used. | \ No newline at end of file diff --git a/_docs/setup/account.md b/_docs/setup/account.md index 09e1ed05..cfd3707b 100644 --- a/_docs/setup/account.md +++ b/_docs/setup/account.md @@ -14,7 +14,7 @@ If you are lucky enough to own an [Octopus Home Mini](https://octopus.energy/blo Export sensors are not provided as the data is not available -See [electricity entities](../entities/electricity.md#home-mini-entities) and [gas entities](../entities/gas.md#home-mini-entities) for more information. +See [electricity entities](../entities/electricity.md#home-minipro-entities) and [gas entities](../entities/gas.md#home-minipro-entities) for more information. ### Refresh Rate In Minutes @@ -57,7 +57,13 @@ If you are lucky enough to own an [Octopus Home Pro](https://forum.octopus.energ ### Prerequisites -The Octopus Home Pro has an internal API which is not currently exposed. In order to make this data available for consumption by this integration you will need to expose a custom API on your device by following the instructions below +The Octopus Home Pro has a local API which is used to get consumption and demand data. If this is all you need, then you can jump straight to the [settings](./account.md#settings). + +However, there is also an internal API for setting the display which is not currently exposed. In order to make this available for consumption by this integration you will need to expose a custom API on your device by following the instructions below + +!!! warning + + This custom API can only be configured with the default Home Pro setup. If you set up Home Assistant on your Home Pro device, then it won't be possible to expose this custom API. 1. Follow [the instructions](https://github.com/OctopusSmartEnergy/Home-Pro-SDK-Public/blob/main/Home.md#sdk) to connect to your Octopus Home Pro via SSH 2. Run the command `wget -O setup_ha.sh https://raw.githubusercontent.com/BottlecapDave/HomeAssistant-OctopusEnergy/main/home_pro_server/setup.sh` to download the installation script @@ -90,8 +96,9 @@ export SERVER_AUTH_TOKEN=thisisasecrettoken # Replace with your own unique strin ### Settings -Once the API has been configured, you will need to set the address to the IP address of your Octopus Home Pro followed by the port 8000 (e.g. `http://192.168.1.2:8000`) and the api key to the value you set `SERVER_AUTH_TOKEN` to. +Once the API has been configured, you will need to set the address to the IP address of your Octopus Home Pro (e.g. `http://192.168.1.2`). +If you have setup the custom API, then you will need to set api key to the value you set `SERVER_AUTH_TOKEN` to. ### Entities diff --git a/_docs/setup/energy_dashboard.md b/_docs/setup/energy_dashboard.md index 14905efe..c9d739c6 100644 --- a/_docs/setup/energy_dashboard.md +++ b/_docs/setup/energy_dashboard.md @@ -21,6 +21,21 @@ If you have an Octopus Home Mini and a smart electricity meter you can obtain li Data will only appear in the energy dashboard from the point you configure the Home Mini within the integration. It doesn't backport any data. +#### Octopus Home Pro + +If you have an Octopus Home Pro and a smart electricity meter you can obtain live meter reading data into Home Assistant: + +1. Go to your [energy dashboard configuration](https://my.home-assistant.io/redirect/config_energy/) +2. Click `Add Consumption` under `Electricity grid` +3. For `Consumed energy` you want `sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_current_total_consumption` +4. Choose the `Use an entity with current price` option and the entity is `sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_current_rate` + +![HA modal electricity example](../assets/total_consumption_electricity.png){: style="height:500px"} + +!!! note + + Data will only appear in the energy dashboard from the point you configure the Home Pro within the integration. It doesn't backport any data. + #### Alternative methods to measure current Home Consumption If you don't have an Octopus Home mini you may have another way to get live or near-live daily consumption into Home Assistant such as a Hildebrand Glow In Home Display, an Energy CT Clamp such as the Shelly EM on the incoming supply cable, or your existing Solar/Battery inverter may have a sensor that provides Grid import information that you can use in the Energy dashboard. @@ -33,19 +48,36 @@ Do be aware that as you are not directly capturing the smart meter readings in H ### For Gas -![HA modal gas example](../assets/current_consumption_gas.png){: style="height:500px"} +#### Octopus Home Mini -This is only available if you have an Octopus Home Mini and a smart gas meter. +If you have an Octopus Home Mini and a smart electricity meter you can obtain live meter reading data into Home Assistant: 1. Go to your [energy dashboard configuration](https://my.home-assistant.io/redirect/config_energy/) 2. Click `Add Gas Source` under `Gas consumption` 3. For `Gas usage` you want `sensor.octopus_energy_gas_{{METER_SERIAL_NUMBER}}_{{MPRN_NUMBER}}_current_accumulative_consumption_kwh` 4. For `Use an entity tracking the total costs` option you want `sensor.octopus_energy_gas_{{METER_SERIAL_NUMBER}}_{{MPRN_NUMBER}}_current_accumulative_cost` +![HA modal gas example](../assets/current_consumption_gas.png){: style="height:500px"} + !!! note Data will only appear in the energy dashboard from the point you configure the Home Mini within the integration. It doesn't backport any data. +#### Octopus Home Pro + +If you have an Octopus Home Pro and a smart gas meter you can obtain live meter reading data into Home Assistant: + +1. Go to your [energy dashboard configuration](https://my.home-assistant.io/redirect/config_energy/) +2. Click `Add Consumption` under `Electricity grid` +3. For `Consumed energy` you want `sensor.octopus_energy_gas_{{METER_SERIAL_NUMBER}}_{{MPRN_NUMBER}}_current_total_consumption_kwh` +4. Choose the `Use an entity with current price` option and the entity is `sensor.octopus_energy_gas_{{METER_SERIAL_NUMBER}}_{{MPRN_NUMBER}}_current_rate` + +![HA modal electricity example](../assets/total_consumption_gas.png){: style="height:500px"} + +!!! note + + Data will only appear in the energy dashboard from the point you configure the Home Pro within the integration. It doesn't backport any data. + ## Previous Day Consumption If none of the methods above for feeding Current Day Consumption information into the Energy dashboard are suitable, you can add `previous consumption` information to the dashboard, using information retrieved via the Octopus API. Note that the consumption information is only available on the following day so "today's" Energy dashboard will show zero values, but "yesterday's", "day before", etc will show the correct consumption for each day. diff --git a/_docs/setup/rolling_target_rate.md b/_docs/setup/rolling_target_rate.md index 95056653..f38ccf32 100644 --- a/_docs/setup/rolling_target_rate.md +++ b/_docs/setup/rolling_target_rate.md @@ -136,6 +136,12 @@ If we had a target rate sensor of 1 hour, the following would occur with the fol | 0.2 | `2024-11-26 11:00:00`-`2024-11-26 12:00:00` | Cheapest period would be 0.1p, free electricity period would be 0.02p. | | 0 | `2024-11-26 11:00:00`-`2024-11-26 12:00:00` | Cheapest period would be 0.1p, free electricity period would be 0p. This will always go for free electricity sessions if available. | +## External Rate Weightings + +There may be times when you want to calculate the best times using factors that are external to data available via the integration, like grid carbon intensity or solar forecasts. This is where external rate weightings come in. Using the [Register Rate Weightings service](../services.md#octopus_energyregister_rate_weightings), you can configured weightings against given rates which are then multiplied against the associated rate. For example if you have a weighting of `2` set and a rate of `0.20`, then the rate will be interpreted as `0.40` during calculation. + +These weightings are used in addition to any [weightings](#weighting) configured against the sensor and [free electricity weightings](#free-electricity-weighting). For example if you have rate weight of `2`, a rate of `0.20`, a sensor weight of `3` and free electricity weight of `0.5`, then rate will be interpreted as `0.6` (2 * 0.20 * 3 * 0.5). + ## Attributes The following attributes are available on each sensor diff --git a/_docs/setup/target_rate.md b/_docs/setup/target_rate.md index 37453b1f..10498cfe 100644 --- a/_docs/setup/target_rate.md +++ b/_docs/setup/target_rate.md @@ -84,6 +84,24 @@ The target rate sensor will try to find the best times for the specified hours. For instance if the cheapest period is between `2023-01-01T00:30` and `2023-01-01T05:00` and your target rate is for 1 hour, then it will come on between `2023-01-01T00:30` and `2023-01-01T01:30`. If the available times are between `2023-01-01T00:30` and `2023-01-01T01:00`, then the sensor will come on between `2023-01-01T00:30` and `2023-01-01T01:00`. +### Evaluation mode + +Because the time frame that is being evaluated could have external factors change the underlying data (e.g. if you're using [external rate weightings](#external-rate-weightings)), you might want to set how/when the target times are evaluated in order to make the selected times more or less dynamic. + +#### All existing target rates are in the past + +This is the default way of evaluating target times. This will only evaluate new target times if no target times have been calculated or all existing target times are in the past. + +#### Existing target rates haven't started or finished + +This will only evaluate target times if no target times have been calculated or all existing target times are either in the future or all existing target times are in the past. + +For example, lets say we have a continuous target which looks between `00:00` and `08:00` has existing target times from `2023-01-02T01:00` to `2023-01-02T02:00`. + +* If the current time is `2023-01-02T00:59`, then the target times will be re-evaluated and might change if the target period (i.e. `2023-01-02T00:30` to `2023-01-02T08:30`) has better rates than the existing target times (e.g. the external weightings have changed). +* If the current time is `2023-01-02T01:00`, the the target times will not be re-evaluated because we've entered our current target times, even if the evaluation period has cheaper times. +* If the current time is `2023-01-02T02:01`, the the target times will be re-evaluated because our existing target times are in the past and will find the best times in the new rolling target period (i.e. `2023-01-02T02:00` to `2023-01-02T10:00`). + ### Offset You may want your target rate sensors to turn on a period of time before the optimum discovered period. For example, you may be turning on a robot vacuum cleaner for a 30 minute clean and want it to charge during the optimum period. For this, you'd use the `offset` field and set it to `-00:30:00`, which can be both positive and negative and go up to a maximum of 24 hours. This will shift when the sensor turns on relative to the optimum period. For example, if the optimum period is between `2023-01-18T10:00` and `2023-01-18T11:00` with an offset of `-00:30:00`, the sensor will turn on between `2023-01-18T09:30` and `2023-01-18T10:30`. @@ -162,6 +180,12 @@ If we had a target rate sensor of 1 hour, the following would occur with the fol | 0.2 | `2024-11-26 11:00:00`-`2024-11-26 12:00:00` | Cheapest period would be 0.1p, free electricity period would be 0.02p. | | 0 | `2024-11-26 11:00:00`-`2024-11-26 12:00:00` | Cheapest period would be 0.1p, free electricity period would be 0p. This will always go for free electricity sessions if available. | +## External Rate Weightings + +There may be times when you want to calculate the best times using factors that are external to data available via the integration, like grid carbon intensity or solar forecasts. This is where external rate weightings come in. Using the [Register Rate Weightings service](../services.md#octopus_energyregister_rate_weightings), you can configured weightings against given rates which are then multiplied against the associated rate. For example if you have a weighting of `2` set and a rate of `0.20`, then the rate will be interpreted as `0.40` during calculation. + +These weightings are used in addition to any [weightings](#weighting) configured against the sensor and [free electricity weightings](#free-electricity-weighting). For example if you have rate weight of `2`, a rate of `0.20`, a sensor weight of `3` and free electricity weight of `0.5`, then rate will be interpreted as `0.6` (2 * 0.20 * 3 * 0.5). + ## Attributes The following attributes are available on each sensor @@ -172,6 +196,7 @@ The following attributes are available on each sensor | `hours` | `string` | The total hours are being discovered. | | `type` | `string` | The type/mode for the target rate sensor. This will be either `continuous` or `intermittent`. | | `mpan` | `string` | The `mpan` of the meter being used to determine the rates. | +| `target_times_evaluation_mode` | `string` | The mode that determines when/how target times are picked | | `rolling_target` | `boolean` | Determines if `Re-evaluate multiple times a day` is turned on for the sensor. | | `last_rates` | `boolean` | Determines if `Find last applicable rates` is turned off for the sensor. | | `offset` | `string` | The offset configured for the sensor. | diff --git a/_docs/setup/tariff_comparison.md b/_docs/setup/tariff_comparison.md index a3d1932f..83f840d2 100644 --- a/_docs/setup/tariff_comparison.md +++ b/_docs/setup/tariff_comparison.md @@ -50,9 +50,7 @@ This will display the cost of your previous accumulative consumption against the !!! info - These sensors will compare the same time period as the [electricity previous accumulative consumption](../entities/electricity.md#previous-accumulative-consumption) or [gas previous accumulative consumption](../entities/gas.md#previous-accumulative-consumption-m3). - - If you have changed the [offset](./account.md#previous-consumption-days-offset), then this sensor will use the same offset. + These sensors will compare the same time period as the [electricity previous accumulative consumption](../entities/electricity.md#previous-accumulative-consumption) or [gas previous accumulative consumption](../entities/gas.md#previous-accumulative-consumption-m3). ### Previous Consumption Override Day Rates @@ -65,9 +63,7 @@ The state of this sensor states when the previous consumption tariff comparison !!! info - These sensors will provide rates for the same time period as the [electricity previous accumulative consumption](../entities/electricity.md#previous-accumulative-consumption) or [gas previous accumulative consumption](../entities/gas.md#previous-accumulative-consumption-m3). - - If you have changed the [offset](./account.md#previous-consumption-days-offset), then this sensor will use the same offset. + These sensors will provide rates for the same time period as the [electricity previous accumulative consumption](../entities/electricity.md#previous-accumulative-consumption) or [gas previous accumulative consumption](../entities/gas.md#previous-accumulative-consumption-m3). | Attribute | Type | Description | |-----------|------|-------------| diff --git a/custom_components/octopus_energy/__init__.py b/custom_components/octopus_energy/__init__.py index bb0d6beb..06433ae5 100644 --- a/custom_components/octopus_energy/__init__.py +++ b/custom_components/octopus_energy/__init__.py @@ -32,6 +32,7 @@ from .utils.error import api_exception_to_string from .storage.account import async_load_cached_account, async_save_cached_account from .storage.intelligent_device import async_load_cached_intelligent_device, async_save_cached_intelligent_device +from .storage.rate_weightings import async_load_cached_rate_weightings from .heat_pump import get_mock_heat_pump_id, mock_heat_pump_status_and_configuration @@ -50,6 +51,7 @@ CONFIG_MAIN_OLD_API_KEY, CONFIG_VERSION, DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY, + DATA_CUSTOM_RATE_WEIGHTINGS_KEY, DATA_HOME_PRO_CLIENT, DATA_INTELLIGENT_DEVICE, DATA_INTELLIGENT_MPAN, @@ -289,10 +291,8 @@ async def async_setup_dependencies(hass, config): hass.data[DOMAIN][account_id][DATA_CLIENT] = client if (CONFIG_MAIN_HOME_PRO_ADDRESS in config and - config[CONFIG_MAIN_HOME_PRO_ADDRESS] is not None and - CONFIG_MAIN_HOME_PRO_API_KEY in config and - config[CONFIG_MAIN_HOME_PRO_API_KEY] is not None): - home_pro_client = OctopusEnergyHomeProApiClient(config[CONFIG_MAIN_HOME_PRO_ADDRESS], config[CONFIG_MAIN_HOME_PRO_API_KEY]) + config[CONFIG_MAIN_HOME_PRO_ADDRESS] is not None): + home_pro_client = OctopusEnergyHomeProApiClient(config[CONFIG_MAIN_HOME_PRO_ADDRESS], config[CONFIG_MAIN_HOME_PRO_API_KEY] if CONFIG_MAIN_HOME_PRO_API_KEY in config else None) hass.data[DOMAIN][account_id][DATA_HOME_PRO_CLIENT] = home_pro_client # Delete any issues that may have been previously raised @@ -358,6 +358,11 @@ async def async_setup_dependencies(hass, config): mpan = point["mpan"] electricity_tariff = get_active_tariff(now, point["agreements"]) + rate_weightings = await async_load_cached_rate_weightings(hass, mpan) + if rate_weightings is not None: + key = DATA_CUSTOM_RATE_WEIGHTINGS_KEY.format(mpan) + hass.data[DOMAIN][account_id][key] = rate_weightings + for meter in point["meters"]: serial_number = meter["serial_number"] diff --git a/custom_components/octopus_energy/api_client/heat_pump.py b/custom_components/octopus_energy/api_client/heat_pump.py index 2cf99099..3f1c5859 100644 --- a/custom_components/octopus_energy/api_client/heat_pump.py +++ b/custom_components/octopus_energy/api_client/heat_pump.py @@ -12,7 +12,7 @@ class Connectivity(BaseModel): class Telemetry(BaseModel): temperatureInCelsius: float - humidityPercentage: Optional[int] + humidityPercentage: Optional[float] retrievedAt: str @@ -23,7 +23,7 @@ class Sensor(BaseModel): class ZoneTelemetry(BaseModel): - setpointInCelsius: int + setpointInCelsius: float mode: str relaySwitchedOn: bool heatDemand: bool @@ -85,7 +85,7 @@ class HeatPump(BaseModel): class CurrentOperation(BaseModel): mode: str - setpointInCelsius: Optional[int] + setpointInCelsius: Optional[float] action: Optional[str] end: str diff --git a/custom_components/octopus_energy/api_client_home_pro/__init__.py b/custom_components/octopus_energy/api_client_home_pro/__init__.py index 813fb42c..df5e11d9 100644 --- a/custom_components/octopus_energy/api_client_home_pro/__init__.py +++ b/custom_components/octopus_energy/api_client_home_pro/__init__.py @@ -12,9 +12,6 @@ class OctopusEnergyHomeProApiClient: _session_lock = RLock() def __init__(self, base_url: str, api_key: str, timeout_in_seconds = 20): - if (api_key is None): - raise Exception('API KEY is not set') - if (base_url is None): raise Exception('BaseUrl is not set') @@ -26,6 +23,9 @@ def __init__(self, base_url: str, api_key: str, timeout_in_seconds = 20): self._session = None + def has_api_key(self): + return self._api_key is not None + async def async_close(self): with self._session_lock: if self._session is not None: @@ -43,9 +43,8 @@ async def async_ping(self): try: client = self._create_client_session() url = f'{self._base_url}:3000/get_meter_consumption' - headers = { "Authorization": self._api_key } data = { "meter_type": "elec" } - async with client.post(url, headers=headers, json=data) as response: + async with client.post(url, json=data) as response: response_body = await self.__async_read_response__(response, url) if (response_body is not None and "Status" in response_body): status: str = response_body["Status"] @@ -64,9 +63,8 @@ async def async_get_consumption(self, is_electricity: bool) -> list | None: client = self._create_client_session() meter_type = 'elec' if is_electricity else 'gas' url = f'{self._base_url}:3000/get_meter_consumption' - headers = { "Authorization": self._api_key } data = { "meter_type": meter_type } - async with client.post(url, headers=headers, json=data) as response: + async with client.post(url, json=data) as response: response_body = await self.__async_read_response__(response, url) if (response_body is not None and "meter_consump"): meter_consump = json.loads(response_body["meter_consump"]) @@ -90,6 +88,9 @@ async def async_get_consumption(self, is_electricity: bool) -> list | None: async def async_set_screen(self, value: str, animation_type: str, type: str, brightness: int, animation_interval: int): """Get the latest consumption""" + if self._api_key is None: + raise Exception('API key is not set, so screen cannot be contacted') + try: client = self._create_client_session() url = f'{self._base_url}:8000/screen' diff --git a/custom_components/octopus_energy/config/main.py b/custom_components/octopus_energy/config/main.py index 51bd917b..78013a54 100644 --- a/custom_components/octopus_energy/config/main.py +++ b/custom_components/octopus_energy/config/main.py @@ -98,11 +98,7 @@ async def async_validate_main_config(data, account_ids = []): if data[CONFIG_MAIN_LIVE_GAS_CONSUMPTION_REFRESH_IN_MINUTES] < 1: errors[CONFIG_MAIN_LIVE_GAS_CONSUMPTION_REFRESH_IN_MINUTES] = "value_greater_than_zero" - if ((CONFIG_MAIN_HOME_PRO_ADDRESS in data and - data[CONFIG_MAIN_HOME_PRO_ADDRESS] is not None and - (CONFIG_MAIN_HOME_PRO_API_KEY not in data or data[CONFIG_MAIN_HOME_PRO_API_KEY] is None)) or - - (CONFIG_MAIN_HOME_PRO_API_KEY in data and + if ((CONFIG_MAIN_HOME_PRO_API_KEY in data and data[CONFIG_MAIN_HOME_PRO_API_KEY] is not None and (CONFIG_MAIN_HOME_PRO_ADDRESS not in data or data[CONFIG_MAIN_HOME_PRO_ADDRESS] is None))): errors[CONFIG_MAIN_HOME_PRO_ADDRESS] = "all_home_pro_values_not_set" @@ -111,7 +107,7 @@ async def async_validate_main_config(data, account_ids = []): data[CONFIG_MAIN_HOME_PRO_ADDRESS] is not None and CONFIG_MAIN_HOME_PRO_API_KEY in data and data[CONFIG_MAIN_HOME_PRO_API_KEY] is not None): - home_pro_client = OctopusEnergyHomeProApiClient(data[CONFIG_MAIN_HOME_PRO_ADDRESS], data[CONFIG_MAIN_HOME_PRO_API_KEY]) + home_pro_client = OctopusEnergyHomeProApiClient(data[CONFIG_MAIN_HOME_PRO_ADDRESS], data[CONFIG_MAIN_HOME_PRO_API_KEY] if CONFIG_MAIN_HOME_PRO_API_KEY in data else None) try: can_connect = await home_pro_client.async_ping() diff --git a/custom_components/octopus_energy/config_flow.py b/custom_components/octopus_energy/config_flow.py index 259c1295..bc2fa4ea 100644 --- a/custom_components/octopus_energy/config_flow.py +++ b/custom_components/octopus_energy/config_flow.py @@ -21,10 +21,10 @@ CONFIG_MAIN_HOME_PRO_ADDRESS, CONFIG_MAIN_HOME_PRO_API_KEY, CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD, - CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE, - CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, - CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, - CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING, CONFIG_TARGET_HOURS_MODE, CONFIG_TARGET_HOURS_MODE_EXACT, @@ -231,6 +231,15 @@ async def __async_setup_target_rate_schema__(self, account_id: str): vol.Optional(CONFIG_TARGET_START_TIME): str, vol.Optional(CONFIG_TARGET_END_TIME): str, vol.Optional(CONFIG_TARGET_OFFSET): str, + vol.Required(CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE, default=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, label="All existing target rates are in the past"), + selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, label="Existing target rates haven't started or finished"), + ], + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), vol.Optional(CONFIG_TARGET_ROLLING_TARGET, default=False): bool, vol.Optional(CONFIG_TARGET_LAST_RATES, default=False): bool, vol.Optional(CONFIG_TARGET_INVERT_TARGET_RATES, default=False): bool, @@ -278,22 +287,22 @@ async def __async_setup_rolling_target_rate_schema__(self, account_id: str): ), vol.Required(CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD): str, vol.Optional(CONFIG_TARGET_OFFSET): str, - vol.Optional(CONFIG_TARGET_LAST_RATES): bool, - vol.Optional(CONFIG_TARGET_INVERT_TARGET_RATES): bool, - vol.Optional(CONFIG_TARGET_MIN_RATE): str, - vol.Optional(CONFIG_TARGET_MAX_RATE): str, - vol.Optional(CONFIG_TARGET_WEIGHTING): str, - vol.Required(CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING, default=1): cv.positive_float, - vol.Required(CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE, default=CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST): selector.SelectSelector( + vol.Required(CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE, default=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST): selector.SelectSelector( selector.SelectSelectorConfig( options=[ - selector.SelectOptionDict(value=CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, label="All existing target rates are in the past"), - selector.SelectOptionDict(value=CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, label="Existing target rates haven't started or finished"), - selector.SelectOptionDict(value=CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, label="Always"), + selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, label="All existing target rates are in the past"), + selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, label="Existing target rates haven't started or finished"), + selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, label="Always"), ], mode=selector.SelectSelectorMode.DROPDOWN, ) ), + vol.Optional(CONFIG_TARGET_LAST_RATES): bool, + vol.Optional(CONFIG_TARGET_INVERT_TARGET_RATES): bool, + vol.Optional(CONFIG_TARGET_MIN_RATE): str, + vol.Optional(CONFIG_TARGET_MAX_RATE): str, + vol.Optional(CONFIG_TARGET_WEIGHTING): str, + vol.Required(CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING, default=1): cv.positive_float, }) async def __async_setup_cost_tracker_schema__(self, account_id: str): @@ -621,6 +630,15 @@ async def __async_setup_target_rate_schema__(self, config, errors): vol.Optional(CONFIG_TARGET_START_TIME): str, vol.Optional(CONFIG_TARGET_END_TIME): str, vol.Optional(CONFIG_TARGET_OFFSET): str, + vol.Required(CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, label="All existing target rates are in the past"), + selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, label="Existing target rates haven't started or finished"), + ], + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), vol.Optional(CONFIG_TARGET_ROLLING_TARGET): bool, vol.Optional(CONFIG_TARGET_LAST_RATES): bool, vol.Optional(CONFIG_TARGET_INVERT_TARGET_RATES): bool, @@ -644,7 +662,8 @@ async def __async_setup_target_rate_schema__(self, config, errors): CONFIG_TARGET_MIN_RATE: f'{config[CONFIG_TARGET_MIN_RATE]}' if CONFIG_TARGET_MIN_RATE in config and config[CONFIG_TARGET_MIN_RATE] is not None else None, CONFIG_TARGET_MAX_RATE: f'{config[CONFIG_TARGET_MAX_RATE]}' if CONFIG_TARGET_MAX_RATE in config and config[CONFIG_TARGET_MAX_RATE] is not None else None, CONFIG_TARGET_WEIGHTING: config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in config else None, - CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING: config[CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING] if CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING in config else 1 + CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING: config[CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING] if CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING in config else 1, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: config[CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE] if CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE in config else CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, } ), errors=errors @@ -709,22 +728,22 @@ async def __async_setup_rolling_target_rate_schema__(self, config, errors): ), vol.Required(CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD): str, vol.Optional(CONFIG_TARGET_OFFSET): str, - vol.Optional(CONFIG_TARGET_LAST_RATES): bool, - vol.Optional(CONFIG_TARGET_INVERT_TARGET_RATES): bool, - vol.Optional(CONFIG_TARGET_MIN_RATE): str, - vol.Optional(CONFIG_TARGET_MAX_RATE): str, - vol.Optional(CONFIG_TARGET_WEIGHTING): str, - vol.Required(CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING): cv.positive_float, - vol.Required(CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE, default=CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST): selector.SelectSelector( + vol.Required(CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE, default=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST): selector.SelectSelector( selector.SelectSelectorConfig( options=[ - selector.SelectOptionDict(value=CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, label="All existing target rates are in the past"), - selector.SelectOptionDict(value=CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, label="Existing target rates haven't started or finished"), - selector.SelectOptionDict(value=CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, label="Always"), + selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, label="All existing target rates are in the past"), + selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, label="Existing target rates haven't started or finished"), + selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, label="Always"), ], mode=selector.SelectSelectorMode.DROPDOWN, ) ), + vol.Optional(CONFIG_TARGET_LAST_RATES): bool, + vol.Optional(CONFIG_TARGET_INVERT_TARGET_RATES): bool, + vol.Optional(CONFIG_TARGET_MIN_RATE): str, + vol.Optional(CONFIG_TARGET_MAX_RATE): str, + vol.Optional(CONFIG_TARGET_WEIGHTING): str, + vol.Required(CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING): cv.positive_float, }), { CONFIG_TARGET_NAME: config[CONFIG_TARGET_NAME], @@ -740,7 +759,7 @@ async def __async_setup_rolling_target_rate_schema__(self, config, errors): CONFIG_TARGET_MIN_RATE: f'{config[CONFIG_TARGET_MIN_RATE]}' if CONFIG_TARGET_MIN_RATE in config and config[CONFIG_TARGET_MIN_RATE] is not None else None, CONFIG_TARGET_MAX_RATE: f'{config[CONFIG_TARGET_MAX_RATE]}' if CONFIG_TARGET_MAX_RATE in config and config[CONFIG_TARGET_MAX_RATE] is not None else None, CONFIG_TARGET_WEIGHTING: config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in config else None, - CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE: config[CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE] if CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE in config else CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: config[CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE] if CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE in config else CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING: config[CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING] if CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING in config else 1 } ), diff --git a/custom_components/octopus_energy/const.py b/custom_components/octopus_energy/const.py index 4b6d702f..b1c1b368 100644 --- a/custom_components/octopus_energy/const.py +++ b/custom_components/octopus_energy/const.py @@ -2,7 +2,7 @@ import homeassistant.helpers.config_validation as cv DOMAIN = "octopus_energy" -INTEGRATION_VERSION = "13.4.0-beta.1" +INTEGRATION_VERSION = "13.4.0" REFRESH_RATE_IN_MINUTES_ACCOUNT = 60 REFRESH_RATE_IN_MINUTES_INTELLIGENT = 3 @@ -72,12 +72,12 @@ CONFIG_TARGET_MAX_RATE = "maximum_rate" CONFIG_TARGET_WEIGHTING = "weighting" CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING = "free_electricity_weighting" +CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE = "target_times_evaluation_mode" +CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST = "all_target_times_in_past" +CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST = "all_target_times_in_future_or_past" +CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS = "always" CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD = "look_ahead_hours" -CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE = "target_times_evaluation_mode" -CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST = "all_target_times_in_past" -CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST = "all_target_times_in_future_or_past" -CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS = "always" CONFIG_TARGET_KEYS = [ CONFIG_TARGET_NAME, @@ -95,7 +95,7 @@ CONFIG_TARGET_WEIGHTING, CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING, CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD, - CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE ] CONFIG_COST_TRACKER_NAME = "name" @@ -140,6 +140,7 @@ DATA_HOME_PRO_CURRENT_CONSUMPTION_KEY = "HOME_PRO_CURRENT_CONSUMPTION_{}" DATA_FREE_ELECTRICITY_SESSIONS = "FREE_ELECTRICITY_SESSIONS" DATA_FREE_ELECTRICITY_SESSIONS_COORDINATOR = "FREE_ELECTRICITY_SESSIONS_COORDINATOR" +DATA_CUSTOM_RATE_WEIGHTINGS_KEY = "DATA_CUSTOM_RATE_WEIGHTINGS_{}" DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY = "HEAT_PUMP_CONFIGURATION_AND_STATUS_{}" DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR = "HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR_{}" @@ -191,14 +192,12 @@ EVENT_ELECTRICITY_CURRENT_DAY_RATES = "octopus_energy_electricity_current_day_rates" EVENT_ELECTRICITY_NEXT_DAY_RATES = "octopus_energy_electricity_next_day_rates" EVENT_ELECTRICITY_PREVIOUS_CONSUMPTION_RATES = "octopus_energy_electricity_previous_consumption_rates" -EVENT_ELECTRICITY_PREVIOUS_CONSUMPTION_OVERRIDE_RATES = "octopus_energy_electricity_previous_consumption_override_rates" EVENT_ELECTRICITY_PREVIOUS_CONSUMPTION_TARIFF_COMPARISON_RATES = "octopus_energy_elec_previous_consumption_tariff_comparison_rates" EVENT_GAS_PREVIOUS_DAY_RATES = "octopus_energy_gas_previous_day_rates" EVENT_GAS_CURRENT_DAY_RATES = "octopus_energy_gas_current_day_rates" EVENT_GAS_NEXT_DAY_RATES = "octopus_energy_gas_next_day_rates" EVENT_GAS_PREVIOUS_CONSUMPTION_RATES = "octopus_energy_gas_previous_consumption_rates" -EVENT_GAS_PREVIOUS_CONSUMPTION_OVERRIDE_RATES = "octopus_energy_gas_previous_consumption_override_rates" EVENT_GAS_PREVIOUS_CONSUMPTION_TARIFF_COMPARISON_RATES = "octopus_energy_gas_previous_consumption_tariff_comparison_rates" EVENT_NEW_SAVING_SESSION = "octopus_energy_new_octoplus_saving_session" diff --git a/custom_components/octopus_energy/electricity/base.py b/custom_components/octopus_energy/electricity/base.py index 78579521..7252e59b 100644 --- a/custom_components/octopus_energy/electricity/base.py +++ b/custom_components/octopus_energy/electricity/base.py @@ -13,6 +13,7 @@ def __init__(self, hass: HomeAssistant, meter, point, entity_domain = "sensor"): """Init sensor""" self._point = point self._meter = meter + self._hass = hass self._mpan = point["mpan"] self._serial_number = meter["serial_number"] diff --git a/custom_components/octopus_energy/electricity/current_rate.py b/custom_components/octopus_energy/electricity/current_rate.py index 16b706c0..cae19580 100644 --- a/custom_components/octopus_energy/electricity/current_rate.py +++ b/custom_components/octopus_energy/electricity/current_rate.py @@ -1,6 +1,8 @@ -from datetime import timedelta import logging +from custom_components.octopus_energy.storage.rate_weightings import async_save_cached_rate_weightings +from homeassistant.exceptions import ServiceValidationError + from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -20,6 +22,8 @@ from .base import (OctopusEnergyElectricitySensor) from ..utils.attributes import dict_to_typed_dict from ..coordinators.electricity_rates import ElectricityRatesCoordinatorResult +from ..utils.weightings import merge_weightings, validate_rate_weightings +from ..const import DATA_CUSTOM_RATE_WEIGHTINGS_KEY, DOMAIN from ..utils.rate_information import (get_current_rate_information) @@ -28,7 +32,7 @@ class OctopusEnergyElectricityCurrentRate(CoordinatorEntity, OctopusEnergyElectricitySensor, RestoreSensor): """Sensor for displaying the current rate.""" - def __init__(self, hass: HomeAssistant, coordinator, meter, point, electricity_price_cap): + def __init__(self, hass: HomeAssistant, coordinator, meter, point, electricity_price_cap, account_id: str): """Init sensor.""" # Pass coordinator to base class CoordinatorEntity.__init__(self, coordinator) @@ -37,6 +41,7 @@ def __init__(self, hass: HomeAssistant, coordinator, meter, point, electricity_p self._state = None self._last_updated = None self._electricity_price_cap = electricity_price_cap + self._account_id = account_id self._attributes = { "mpan": self._mpan, @@ -155,4 +160,31 @@ async def async_added_to_hass(self): if state is not None and self._state is None: self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state self._attributes = dict_to_typed_dict(state.attributes, ['all_rates', 'applicable_rates']) - _LOGGER.debug(f'Restored OctopusEnergyElectricityCurrentRate state: {self._state}') \ No newline at end of file + _LOGGER.debug(f'Restored OctopusEnergyElectricityCurrentRate state: {self._state}') + + @callback + async def async_register_rate_weightings(self, weightings): + """Apply rate weightings""" + result = validate_rate_weightings(weightings) + if result.success == False: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_rate_weightings", + translation_placeholders={ + "error": result.error_message, + }, + ) + + key = DATA_CUSTOM_RATE_WEIGHTINGS_KEY.format(self._mpan) + weightings = result.weightings + weightings = merge_weightings( + now(), + weightings, + self._hass.data[DOMAIN][self._account_id][key] + if key in self._hass.data[DOMAIN][self._account_id] + else [] + ) + + self._hass.data[DOMAIN][self._account_id][key] = weightings + + await async_save_cached_rate_weightings(self._hass, self._mpan, result.weightings) \ No newline at end of file diff --git a/custom_components/octopus_energy/electricity/current_total_consumption.py b/custom_components/octopus_energy/electricity/current_total_consumption.py index e1efcbcf..9e896b42 100644 --- a/custom_components/octopus_energy/electricity/current_total_consumption.py +++ b/custom_components/octopus_energy/electricity/current_total_consumption.py @@ -65,7 +65,7 @@ def device_class(self): @property def state_class(self): """The state class of sensor""" - return SensorStateClass.TOTAL + return SensorStateClass.TOTAL_INCREASING @property def native_unit_of_measurement(self): @@ -82,11 +82,6 @@ def extra_state_attributes(self): """Attributes of the sensor.""" return self._attributes - @property - def last_reset(self): - """Return the time when the sensor was last reset, if any.""" - return self._last_reset - @property def native_value(self): return self._state @@ -102,7 +97,7 @@ def _handle_coordinator_update(self) -> None: _LOGGER.debug(f"Calculated total electricity consumption for '{self._mpan}/{self._serial_number}'...") if consumption_data[-1]["total_consumption"] is not None: - self._state = consumption_data[-1]["total_consumption"] + self._state = consumption_data[-1]["total_consumption"] if consumption_data[-1]["total_consumption"] is not None and consumption_data[-1]["total_consumption"] != 0 else None self._last_reset = current self._attributes = { diff --git a/custom_components/octopus_energy/gas/current_total_consumption_cubic_meters.py b/custom_components/octopus_energy/gas/current_total_consumption_cubic_meters.py index 703b967a..9be19c9e 100644 --- a/custom_components/octopus_energy/gas/current_total_consumption_cubic_meters.py +++ b/custom_components/octopus_energy/gas/current_total_consumption_cubic_meters.py @@ -67,7 +67,7 @@ def device_class(self): @property def state_class(self): """The state class of sensor""" - return SensorStateClass.TOTAL + return SensorStateClass.TOTAL_INCREASING @property def native_unit_of_measurement(self): @@ -100,9 +100,9 @@ def _handle_coordinator_update(self) -> None: if consumption_data[-1]["total_consumption"] is not None: if "is_kwh" not in consumption_data[-1] or consumption_data[-1]["is_kwh"] == True: - self._state = convert_kwh_to_m3(consumption_data[-1]["total_consumption"], self._calorific_value) if consumption_data[-1]["total_consumption"] is not None else None + self._state = convert_kwh_to_m3(consumption_data[-1]["total_consumption"], self._calorific_value) if consumption_data[-1]["total_consumption"] is not None and consumption_data[-1]["total_consumption"] != 0 else None else: - self._state = consumption_data[-1]["total_consumption"] + self._state = consumption_data[-1]["total_consumption"] if consumption_data[-1]["total_consumption"] is not None and consumption_data[-1]["total_consumption"] != 0 else None self._attributes = { "mprn": self._mprn, diff --git a/custom_components/octopus_energy/gas/current_total_consumption_kwh.py b/custom_components/octopus_energy/gas/current_total_consumption_kwh.py index 66575d8c..6fad52db 100644 --- a/custom_components/octopus_energy/gas/current_total_consumption_kwh.py +++ b/custom_components/octopus_energy/gas/current_total_consumption_kwh.py @@ -67,7 +67,7 @@ def device_class(self): @property def state_class(self): """The state class of sensor""" - return SensorStateClass.TOTAL + return SensorStateClass.TOTAL_INCREASING @property def native_unit_of_measurement(self): @@ -100,9 +100,9 @@ def _handle_coordinator_update(self) -> None: if consumption_data[-1]["total_consumption"] is not None: if "is_kwh" not in consumption_data[-1] or consumption_data[-1]["is_kwh"] == True: - self._state = consumption_data[-1]["total_consumption"] + self._state = consumption_data[-1]["total_consumption"] if consumption_data[-1]["total_consumption"] is not None and consumption_data[-1]["total_consumption"] != 0 else None else: - self._state = convert_m3_to_kwh(consumption_data[-1]["total_consumption"], self._calorific_value) if consumption_data[-1]["total_consumption"] is not None else None + self._state = convert_m3_to_kwh(consumption_data[-1]["total_consumption"], self._calorific_value) if consumption_data[-1]["total_consumption"] is not None and consumption_data[-1]["total_consumption"] != 0 else None self._attributes = { "mprn": self._mprn, diff --git a/custom_components/octopus_energy/intelligent/__init__.py b/custom_components/octopus_energy/intelligent/__init__.py index ef0a753e..95f55a90 100644 --- a/custom_components/octopus_energy/intelligent/__init__.py +++ b/custom_components/octopus_energy/intelligent/__init__.py @@ -230,7 +230,8 @@ def __init__(self, "SMARTCAR", "TESLA", "SMART_PEAR", - "HYPERVOLT" + "HYPERVOLT", + "INDRA" ] def get_intelligent_features(provider: str) -> IntelligentFeatures: diff --git a/custom_components/octopus_energy/manifest.json b/custom_components/octopus_energy/manifest.json index 2cef7163..d35073bc 100644 --- a/custom_components/octopus_energy/manifest.json +++ b/custom_components/octopus_energy/manifest.json @@ -13,10 +13,10 @@ "homekit": {}, "iot_class": "cloud_polling", "issue_tracker": "https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/issues", - "ssdp": [], - "version": "13.4.0-beta.1", - "zeroconf": [], "requirements": [ "pydantic" - ] + ], + "ssdp": [], + "version": "13.4.0", + "zeroconf": [] } \ No newline at end of file diff --git a/custom_components/octopus_energy/sensor.py b/custom_components/octopus_energy/sensor.py index ac27fa0d..d28837bd 100644 --- a/custom_components/octopus_energy/sensor.py +++ b/custom_components/octopus_energy/sensor.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta import voluptuous as vol import logging @@ -59,6 +59,7 @@ from .heat_pump import get_mock_heat_pump_id from .heat_pump.sensor_temperature import OctopusEnergyHeatPumpSensorTemperature from .heat_pump.sensor_humidity import OctopusEnergyHeatPumpSensorHumidity +from .api_client.intelligent_device import IntelligentDevice from .utils.debug_overrides import async_get_account_debug_override, async_get_meter_debug_override @@ -196,6 +197,28 @@ async def async_setup_entry(hass, entry, async_add_entities): "async_redeem_points_into_account_credit", # supports_response=SupportsResponse.OPTIONAL ) + + platform.async_register_entity_service( + "register_rate_weightings", + vol.All( + cv.make_entity_service_schema( + { + vol.Required("weightings"): vol.All( + cv.ensure_list, + [ + { + vol.Required("start"): str, + vol.Required("end"): str, + vol.Required("weighting"): float + } + ], + ), + }, + extra=vol.ALLOW_EXTRA, + ), + ), + "async_register_rate_weightings", + ) elif config[CONFIG_KIND] == CONFIG_KIND_COST_TRACKER: await async_setup_cost_sensors(hass, entry, config, async_add_entities) @@ -323,7 +346,7 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent electricity_rate_coordinator = hass.data[DOMAIN][account_id][DATA_ELECTRICITY_RATES_COORDINATOR_KEY.format(mpan, serial_number)] electricity_standing_charges_coordinator = await async_setup_electricity_standing_charges_coordinator(hass, account_id, mpan, serial_number) - entities.append(OctopusEnergyElectricityCurrentRate(hass, electricity_rate_coordinator, meter, point, electricity_price_cap)) + entities.append(OctopusEnergyElectricityCurrentRate(hass, electricity_rate_coordinator, meter, point, electricity_price_cap, account_id)) entities.append(OctopusEnergyElectricityPreviousRate(hass, electricity_rate_coordinator, meter, point)) entities.append(OctopusEnergyElectricityNextRate(hass, electricity_rate_coordinator, meter, point)) entities.append(OctopusEnergyElectricityCurrentStandingCharge(hass, electricity_standing_charges_coordinator, meter, point)) diff --git a/custom_components/octopus_energy/services.yaml b/custom_components/octopus_energy/services.yaml index fc85d36a..93cbc527 100644 --- a/custom_components/octopus_energy/services.yaml +++ b/custom_components/octopus_energy/services.yaml @@ -232,6 +232,28 @@ adjust_cost_tracker: mode: box unit_of_measurement: kWh +register_rate_weightings: + name: Register rate weightings + description: Registers external weightings against rates, for use with target rate sensors when calculating target periods. + target: + entity: + integration: octopus_energy + domain: sensor + fields: + weightings: + name: Weightings + description: The collection of time periods and associated weightings to apply. + example: >- + [ + { + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-01T00:30:00Z", + "weighting": 0.1 + } + ] + selector: + object: + boost_heat_pump_zone: name: Boost heat pump zone description: Boost a given heat pump zone for a given time period diff --git a/custom_components/octopus_energy/storage/rate_weightings.py b/custom_components/octopus_energy/storage/rate_weightings.py new file mode 100644 index 00000000..2a7c3fe2 --- /dev/null +++ b/custom_components/octopus_energy/storage/rate_weightings.py @@ -0,0 +1,28 @@ +import logging +from homeassistant.helpers import storage + +from pydantic import BaseModel + +from ..utils.weightings import RateWeighting + +_LOGGER = logging.getLogger(__name__) + +class RateWeightings(BaseModel): + weightings: list[RateWeighting] + +async def async_load_cached_rate_weightings(hass, mpan: str) -> list[RateWeighting]: + store = storage.Store(hass, "1", f"octopus_energy.{mpan}_rate_weightings") + + try: + data = await store.async_load() + if data is not None: + _LOGGER.debug(f"Loaded cached rate weightings for {mpan}") + return RateWeightings.parse_obj(data).weightings + except: + return None + +async def async_save_cached_rate_weightings(hass, mpan: str, weightings: list[RateWeighting]): + if weightings is not None: + store = storage.Store(hass, "1", f"octopus_energy.{mpan}_rate_weightings") + await store.async_save(RateWeightings(weightings=weightings).dict()) + _LOGGER.debug(f"Saved rate weightings data for {mpan}") \ No newline at end of file diff --git a/custom_components/octopus_energy/target_rates/__init__.py b/custom_components/octopus_energy/target_rates/__init__.py index ec6a96e3..f0a85739 100644 --- a/custom_components/octopus_energy/target_rates/__init__.py +++ b/custom_components/octopus_energy/target_rates/__init__.py @@ -7,7 +7,7 @@ from homeassistant.util.dt import (as_utc, parse_datetime) from ..utils.conversions import value_inc_vat_to_pounds -from ..const import CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, CONFIG_TARGET_HOURS_MODE_EXACT, CONFIG_TARGET_HOURS_MODE_MAXIMUM, CONFIG_TARGET_HOURS_MODE_MINIMUM, CONFIG_TARGET_KEYS, REGEX_OFFSET_PARTS, REGEX_WEIGHTING +from ..const import CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, CONFIG_TARGET_HOURS_MODE_EXACT, CONFIG_TARGET_HOURS_MODE_MAXIMUM, CONFIG_TARGET_HOURS_MODE_MINIMUM, CONFIG_TARGET_KEYS, REGEX_OFFSET_PARTS, REGEX_WEIGHTING from ..api_client.free_electricity_sessions import FreeElectricitySession _LOGGER = logging.getLogger(__name__) @@ -419,9 +419,9 @@ def should_evaluate_target_rates(current_date: datetime, target_rates: list, eva if rate["start"] <= current_date: one_rate_in_past = True - return ((evaluation_mode == CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST and all_rates_in_past) or - (evaluation_mode == CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST and (one_rate_in_past == False or all_rates_in_past)) or - (evaluation_mode == CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS)) + return ((evaluation_mode == CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST and all_rates_in_past) or + (evaluation_mode == CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST and (one_rate_in_past == False or all_rates_in_past)) or + (evaluation_mode == CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS)) def apply_free_electricity_weighting(applicable_rates: list | None, free_electricity_sessions: list[FreeElectricitySession] | None, weighting: float): if applicable_rates is None: diff --git a/custom_components/octopus_energy/target_rates/rolling_target_rate.py b/custom_components/octopus_energy/target_rates/rolling_target_rate.py index 80d88315..3ae3d2bb 100644 --- a/custom_components/octopus_energy/target_rates/rolling_target_rate.py +++ b/custom_components/octopus_energy/target_rates/rolling_target_rate.py @@ -1,4 +1,3 @@ -from decimal import Decimal import logging from datetime import timedelta import math @@ -13,9 +12,6 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.util.dt import (utcnow, now) -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity -) from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) @@ -25,11 +21,12 @@ from ..const import ( CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD, - CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE, CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING, CONFIG_TARGET_HOURS_MODE, CONFIG_TARGET_MAX_RATE, CONFIG_TARGET_MIN_RATE, + CONFIG_TARGET_MPAN, CONFIG_TARGET_NAME, CONFIG_TARGET_HOURS, CONFIG_TARGET_TYPE, @@ -41,6 +38,7 @@ CONFIG_TARGET_TYPE_INTERMITTENT, CONFIG_TARGET_WEIGHTING, DATA_ACCOUNT, + DATA_CUSTOM_RATE_WEIGHTINGS_KEY, DOMAIN, ) @@ -59,6 +57,7 @@ from ..utils.attributes import dict_to_typed_dict from ..coordinators import MultiCoordinatorEntity from ..coordinators.free_electricity_sessions import FreeElectricitySessionsCoordinatorResult +from ..utils.weightings import apply_weighting from ..config.rolling_target_rates import validate_rolling_target_rate_config @@ -148,7 +147,7 @@ def _handle_coordinator_update(self) -> None: _LOGGER.debug(f'Updating OctopusEnergyTargetRate {self._config[CONFIG_TARGET_NAME]}') self._last_evaluated = current_date - should_evaluate = should_evaluate_target_rates(current_date, self._target_rates, self._config[CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE]) + should_evaluate = should_evaluate_target_rates(current_date, self._target_rates, self._config[CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE]) if should_evaluate: if self.coordinator is not None and self.coordinator.data is not None and self.coordinator.data.rates is not None: all_rates = self.coordinator.data.rates @@ -193,6 +192,13 @@ def _handle_coordinator_update(self) -> None: self._config[CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING] if CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING in self._config else 1 ) + weightings_key = DATA_CUSTOM_RATE_WEIGHTINGS_KEY.format(self._config[CONFIG_TARGET_MPAN]) + applicable_rates = apply_weighting( + applicable_rates, + self._hass.data[DOMAIN][self._account_id][weightings_key] + if weightings_key in self._hass.data[DOMAIN][self._account_id] + else [] + ) if applicable_rates is not None: number_of_slots = math.ceil(target_hours * 2) diff --git a/custom_components/octopus_energy/target_rates/target_rate.py b/custom_components/octopus_energy/target_rates/target_rate.py index d7ce8ec0..2408604f 100644 --- a/custom_components/octopus_energy/target_rates/target_rate.py +++ b/custom_components/octopus_energy/target_rates/target_rate.py @@ -1,4 +1,3 @@ -from decimal import Decimal import logging from datetime import timedelta import math @@ -13,9 +12,6 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.util.dt import (utcnow, now) -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity -) from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) @@ -24,11 +20,13 @@ from homeassistant.helpers import translation from ..const import ( - CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING, CONFIG_TARGET_HOURS_MODE, CONFIG_TARGET_MAX_RATE, CONFIG_TARGET_MIN_RATE, + CONFIG_TARGET_MPAN, CONFIG_TARGET_NAME, CONFIG_TARGET_HOURS, CONFIG_TARGET_OLD_END_TIME, @@ -48,6 +46,7 @@ CONFIG_TARGET_TYPE_INTERMITTENT, CONFIG_TARGET_WEIGHTING, DATA_ACCOUNT, + DATA_CUSTOM_RATE_WEIGHTINGS_KEY, DOMAIN, ) @@ -67,6 +66,7 @@ from ..utils.attributes import dict_to_typed_dict from ..coordinators import MultiCoordinatorEntity from ..coordinators.free_electricity_sessions import FreeElectricitySessionsCoordinatorResult +from ..utils.weightings import apply_weighting _LOGGER = logging.getLogger(__name__) @@ -154,7 +154,7 @@ def _handle_coordinator_update(self) -> None: _LOGGER.debug(f'Updating OctopusEnergyTargetRate {self._config[CONFIG_TARGET_NAME]}') self._last_evaluated = current_date - should_evaluate = should_evaluate_target_rates(current_date, self._target_rates, CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST) + should_evaluate = should_evaluate_target_rates(current_date, self._target_rates, self._config[CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE] if CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE in self._config else CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST) if should_evaluate: if self.coordinator is not None and self.coordinator.data is not None and self.coordinator.data.rates is not None: all_rates = self.coordinator.data.rates @@ -213,6 +213,14 @@ def _handle_coordinator_update(self) -> None: self._config[CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING] if CONFIG_TARGET_FREE_ELECTRICITY_WEIGHTING in self._config else 1 ) + weightings_key = DATA_CUSTOM_RATE_WEIGHTINGS_KEY.format(self._config[CONFIG_TARGET_MPAN]) + applicable_rates = apply_weighting( + applicable_rates, + self._hass.data[DOMAIN][self._account_id][weightings_key] + if weightings_key in self._hass.data[DOMAIN][self._account_id] + else [] + ) + if applicable_rates is not None: number_of_slots = math.ceil(target_hours * 2) weighting = create_weighting(self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None, number_of_slots) diff --git a/custom_components/octopus_energy/text.py b/custom_components/octopus_energy/text.py index c16f28b6..90834254 100644 --- a/custom_components/octopus_energy/text.py +++ b/custom_components/octopus_energy/text.py @@ -3,6 +3,7 @@ from homeassistant.core import HomeAssistant from .home_pro.screen_text import OctopusEnergyHomeProScreenText +from .api_client_home_pro import OctopusEnergyHomeProApiClient from .const import ( CONFIG_ACCOUNT_ID, @@ -27,11 +28,11 @@ async def async_setup_entry(hass, entry, async_add_entities): async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_entities): account_id = config[CONFIG_ACCOUNT_ID] - home_pro_client = hass.data[DOMAIN][account_id][DATA_HOME_PRO_CLIENT] if DATA_HOME_PRO_CLIENT in hass.data[DOMAIN][account_id] else None + home_pro_client: OctopusEnergyHomeProApiClient = hass.data[DOMAIN][account_id][DATA_HOME_PRO_CLIENT] if DATA_HOME_PRO_CLIENT in hass.data[DOMAIN][account_id] else None entities = [] - if home_pro_client is not None: + if home_pro_client is not None and home_pro_client.has_api_key(): entities.append(OctopusEnergyHomeProScreenText(hass, account_id, home_pro_client)) async_add_entities(entities) \ No newline at end of file diff --git a/custom_components/octopus_energy/translations/en.json b/custom_components/octopus_energy/translations/en.json index c84ca671..488000eb 100644 --- a/custom_components/octopus_energy/translations/en.json +++ b/custom_components/octopus_energy/translations/en.json @@ -13,7 +13,7 @@ "calorific_value": "Gas calorific value.", "electricity_price_cap": "Optional electricity price cap in pence", "gas_price_cap": "Optional gas price cap in pence", - "home_pro_address": "Home Pro address and port (e.g. http://localhost:8000)", + "home_pro_address": "Home Pro address (e.g. http://192.168.0.1)", "home_pro_api_key": "Home Pro API key", "favour_direct_debit_rates": "Favour direct debit rates where available" }, @@ -24,7 +24,7 @@ "electricity_price_cap": "This usually comes from the OE APIs and doesn't need to be set", "gas_price_cap": "This usually comes from the OE APIs and doesn't need to be set", "home_pro_address": "WARNING: This is experimental.", - "home_pro_api_key": "WARNING: This is experimental" + "home_pro_api_key": "WARNING: This is experimental. This is only required if you have setup the custom API." } }, "target_rate": { @@ -44,7 +44,8 @@ "minimum_rate": "The optional minimum rate for target hours", "maximum_rate": "The optional maximum rate for target hours", "weighting": "The optional weighting to apply to the discovered rates", - "free_electricity_weighting": "The weighting to apply to rates during free electricity sessions" + "free_electricity_weighting": "The weighting to apply to rates during free electricity sessions", + "target_times_evaluation_mode": "When should target times be selected" }, "data_description": { "hours": "This has to be a multiple of 0.5.", @@ -137,7 +138,7 @@ "weighting_not_supported_for_hour_mode": "Weighting is not supported for this hour mode", "invalid_product_or_tariff": "Product or tariff code does not exist", "minimum_or_maximum_rate_not_specified": "Either minimum and/or maximum rate must be specified for minimum hours mode", - "all_home_pro_values_not_set": "Either both Home Pro address and API key must be set, or neither must be set", + "all_home_pro_values_not_set": "Home Pro address must be set if API key is set", "home_pro_connection_failed": "Cannot connect to Home Pro device. Please check the specified address is correct and that you've installed the custom API as per the instructions.", "home_pro_authentication_failed": "Cannot authenticate with API on Home Pro device. Please check authentication token matches the value you configured.", "home_pro_not_responding": "Connected to Home Pro device, but responding with unsuccessful status. This implies the Home Pro failed to connect to your meter." @@ -160,7 +161,7 @@ "calorific_value": "Gas calorific value", "electricity_price_cap": "Optional electricity price cap in pence", "gas_price_cap": "Optional gas price cap in pence", - "home_pro_address": "Home Pro address and port (e.g. http://localhost:8000)", + "home_pro_address": "Home Pro address (e.g. http://192.168.0.1)", "home_pro_api_key": "Home Pro API key", "favour_direct_debit_rates": "Favour direct debit rates where available" }, @@ -170,7 +171,7 @@ "electricity_price_cap": "This usually comes from the OE APIs and doesn't need to be set", "gas_price_cap": "This usually comes from the OE APIs and doesn't need to be set", "home_pro_address": "WARNING: This is experimental", - "home_pro_api_key": "WARNING: This is experimental" + "home_pro_api_key": "WARNING: This is experimental. This is only required if you have setup the custom API." } }, "target_rate": { @@ -189,7 +190,8 @@ "maximum_rate": "The optional maximum rate for target hours", "rolling_target": "Re-evaluate multiple times a day", "weighting": "The optional weighting to apply to the discovered rates", - "free_electricity_weighting": "The weighting to apply to rates during free electricity sessions" + "free_electricity_weighting": "The weighting to apply to rates during free electricity sessions", + "target_times_evaluation_mode": "When should target times be selected" } }, "rolling_target_rate": { @@ -256,7 +258,7 @@ "weighting_not_supported_for_hour_mode": "Weighting is not supported for this hour mode", "invalid_product_or_tariff": "Product or tariff code does not exist", "minimum_or_maximum_rate_not_specified": "Either minimum and/or maximum rate must be specified for minimum hours mode", - "all_home_pro_values_not_set": "Either both Home Pro address and API key must be set, or neither must be set", + "all_home_pro_values_not_set": "Home Pro address must be set if API key is set", "home_pro_connection_failed": "Cannot connect to Home Pro device. Please check the specified address is correct and that you've installed the custom API as per the instructions.", "home_pro_authentication_failed": "Cannot authenticate with API on Home Pro device. Please check authentication token matches the value you configured.", "home_pro_not_responding": "Connected to Home Pro device, but responding with unsuccessful status. This implies the Home Pro failed to connect to your meter." @@ -278,6 +280,9 @@ }, "invalid_target_temperature": { "message": "Temperature must be equal or between {min_temperature} and {max_temperature}" + }, + "invalid_rate_weightings": { + "message": "{error}" } }, "issues": { diff --git a/custom_components/octopus_energy/utils/weightings.py b/custom_components/octopus_energy/utils/weightings.py new file mode 100644 index 00000000..4d65a857 --- /dev/null +++ b/custom_components/octopus_energy/utils/weightings.py @@ -0,0 +1,117 @@ +from datetime import datetime, timedelta + +from pydantic import BaseModel + +class RateWeighting(BaseModel): + start: datetime + end: datetime + weighting: float + +class ValidateRateWeightingsResult: + + def __init__(self, success: bool, weightings: list[RateWeighting] = [], error_message: str | None = None): + self.success = success + self.weightings = weightings + self.error_message = error_message + +def validate_rate_weightings(weightings: list[dict]): + if weightings is None or len(weightings) < 1: + return ValidateRateWeightingsResult(True, []) + + processed_weightings = [] + for index in range(len(weightings)): + weighting = weightings[index] + error = None + + start = None + try: + start = datetime.fromisoformat(weighting["start"]) + except: + error = f"start was not a valid ISO datetime in string format at index {index}" + break + + if start.tzinfo is None: + error = f"start must include timezone at index {index}" + break + + end = None + try: + end = datetime.fromisoformat(weighting["end"]) + except: + error = f"end was not a valid ISO datetime in string format at index {index}" + break + + if end.tzinfo is None: + error = f"end must include timezone at index {index}" + break + + + if start >= end: + error = f"start must be before end at index {index}" + break + + if (end - start).seconds != 1800: # 30 minutes + error = f"time period must be equal to 30 minutes at index {index}" + break + + error = _validate_time(start, "start", index) + if error is not None: + break + + error = _validate_time(end, "end", index) + if error is not None: + break + + processed_weightings.append(RateWeighting(start=start, end=end, weighting=weighting["weighting"])) + + if error is not None: + return ValidateRateWeightingsResult(False, [], error) + + return ValidateRateWeightingsResult(True, processed_weightings) + +def _validate_time(value: datetime, key: str, index: int): + if value.minute != 0 and value.minute != 30: + return f"{key} minute must equal 0 or 30 at index {index}" + + if value.second != 0 or value.microsecond != 0: + return f"{key} second and microsecond must equal 0 at index {index}" + + return None + +def merge_weightings(current_date: datetime, new_weightings: list[RateWeighting], current_weightings: list[RateWeighting]): + merged_weightings: list[RateWeighting] = [] + + if new_weightings is not None: + merged_weightings.extend(new_weightings) + + minimum_date = current_date - timedelta(hours=24) + + if current_weightings is not None: + for weighting in current_weightings: + if weighting.end >= minimum_date: + is_present = False + for existing_weighting in merged_weightings: + if existing_weighting.start == weighting.start and existing_weighting.end == weighting.end: + is_present = True + break + + if is_present == False: + merged_weightings.append(weighting) + + merged_weightings.sort(key=lambda x: x.start) + + return merged_weightings + +def apply_weighting(applicable_rates: list | None, rate_weightings: list[RateWeighting] | None): + if applicable_rates is None: + return None + + if rate_weightings is None: + return applicable_rates + + for rate in applicable_rates: + for session in rate_weightings: + if rate["start"] >= session.start and rate["end"] <= session.end: + rate["weighting"] = session.weighting + + return applicable_rates \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index d5d234a4..c86c22b6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,7 @@ nav: - Wheel Of Fortune: ./entities/wheel_of_fortune.md - Greenness Forecast: ./entities/greenness_forecast.md - Home Pro: ./entities/home_pro.md + - Heat Pump: ./entities/heat_pump.md - Diagnostics: ./entities/diagnostics.md - services.md - events.md @@ -71,4 +72,17 @@ theme: primary: light blue toggle: icon: material/brightness-4 - name: Switch to light mode \ No newline at end of file + name: Switch to light mode + +strict: true + +validation: + nav: + omitted_files: warn + not_found: warn + absolute_links: warn + links: + not_found: warn + anchors: warn + absolute_links: warn + unrecognized_links: warn \ No newline at end of file diff --git a/requirements.test.txt b/requirements.test.txt index b22f4af1..8f62ad23 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -5,6 +5,7 @@ mock homeassistant pydantic psutil-home-assistant +pydantic sqlalchemy fnvhash fnv_hash_fast \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f1a3e0c8..3297719f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -mkdocs-material==9.5.27 +mkdocs-material==9.5.49 mike==2.0.0 # mkdocs-git-committers-plugin-2==2.3.0 mkdocs-git-authors-plugin==0.9.0 \ No newline at end of file diff --git a/tests/unit/config/test_validate_main_config.py b/tests/unit/config/test_validate_main_config.py index 646c4d09..ed885a5e 100644 --- a/tests/unit/config/test_validate_main_config.py +++ b/tests/unit/config/test_validate_main_config.py @@ -292,36 +292,6 @@ async def async_mocked_get_account(*args, **kwargs): assert_errors_not_present(errors, config_keys, CONFIG_ACCOUNT_ID) -@pytest.mark.asyncio -async def test_when_home_pro_address_is_set_and_home_pro_api_key_is_not_set_then_error_returned(): - # Arrange - data = { - CONFIG_MAIN_API_KEY: "test-api-key", - CONFIG_ACCOUNT_ID: "A-123", - CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION: True, - CONFIG_MAIN_LIVE_ELECTRICITY_CONSUMPTION_REFRESH_IN_MINUTES: 1, - CONFIG_MAIN_LIVE_GAS_CONSUMPTION_REFRESH_IN_MINUTES: 1, - CONFIG_MAIN_CALORIFIC_VALUE: 40, - CONFIG_MAIN_ELECTRICITY_PRICE_CAP: 38.5, - CONFIG_MAIN_GAS_PRICE_CAP: 10.5, - CONFIG_MAIN_HOME_PRO_ADDRESS: "http://localhost:8000", - CONFIG_MAIN_HOME_PRO_API_KEY: None - } - - account_info = get_account_info() - async def async_mocked_get_account(*args, **kwargs): - return account_info - - # Act - with mock.patch.multiple(OctopusEnergyApiClient, async_get_account=async_mocked_get_account): - errors = await async_validate_main_config(data) - - # Assert - assert CONFIG_MAIN_HOME_PRO_ADDRESS in errors - assert errors[CONFIG_MAIN_HOME_PRO_ADDRESS] == "all_home_pro_values_not_set" - - assert_errors_not_present(errors, config_keys, CONFIG_MAIN_HOME_PRO_ADDRESS) - @pytest.mark.asyncio async def test_when_home_pro_address_is_not_set_and_home_pro_api_key_is_set_then_error_returned(): # Arrange diff --git a/tests/unit/intelligent/test_get_intelligent_features.py b/tests/unit/intelligent/test_get_intelligent_features.py index d8910e3f..6b2d5f4e 100644 --- a/tests/unit/intelligent/test_get_intelligent_features.py +++ b/tests/unit/intelligent/test_get_intelligent_features.py @@ -20,6 +20,7 @@ ("TESLA", True, True, True, True, True, False), ("SMART_PEAR", True, True, True, True, True, False), ("HYPERVOLT", True, True, True, True, True, False), + ("INDRA", True, True, True, True, True, False), ("OHME", False, False, False, False, False, False), ("DAIKIN".lower(), True, True, True, True, True, False), ("ECOBEE".lower(), True, True, True, True, True, False), @@ -37,6 +38,7 @@ ("TESLA".lower(), True, True, True, True, True, False), ("SMART_PEAR".lower(), True, True, True, True, True, False), ("HYPERVOLT".lower(), True, True, True, True, True, False), + ("INDRA".lower(), True, True, True, True, True, False), ("OHME".lower(), False, False, False, False, False, False), # Unexpected providers ("unexpected".lower(), False, False, False, False, False, True), diff --git a/tests/unit/target_rates/test_should_evaluate_target_rates.py b/tests/unit/target_rates/test_should_evaluate_target_rates.py index 8c66eb41..9431088e 100644 --- a/tests/unit/target_rates/test_should_evaluate_target_rates.py +++ b/tests/unit/target_rates/test_should_evaluate_target_rates.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta from custom_components.octopus_energy.const import ( - CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, - CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, - CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS ) import pytest @@ -11,9 +11,9 @@ @pytest.mark.asyncio @pytest.mark.parametrize("evaluation_mode",[ - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST), - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST), - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS), ]) async def test_when_target_rates_is_none_then_return_true(evaluation_mode: str): # Arrange @@ -28,9 +28,9 @@ async def test_when_target_rates_is_none_then_return_true(evaluation_mode: str): @pytest.mark.asyncio @pytest.mark.parametrize("evaluation_mode",[ - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST), - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST), - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS), ]) async def test_when_target_rates_is_empty_then_return_true(evaluation_mode: str): # Arrange @@ -45,9 +45,9 @@ async def test_when_target_rates_is_empty_then_return_true(evaluation_mode: str) @pytest.mark.asyncio @pytest.mark.parametrize("evaluation_mode,expected_result",[ - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, False), - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, True), - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, True), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, False), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, True), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, True), ]) async def test_when_target_rates_is_in_the_future_then_return_expected_result(evaluation_mode: str, expected_result: bool): # Arrange @@ -66,9 +66,9 @@ async def test_when_target_rates_is_in_the_future_then_return_expected_result(ev @pytest.mark.asyncio @pytest.mark.parametrize("evaluation_mode,expected_result",[ - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, False), - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, False), - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, True), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, False), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, False), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, True), ]) async def test_when_target_rates_started_then_return_expected_result(evaluation_mode: str, expected_result: bool): # Arrange @@ -87,9 +87,9 @@ async def test_when_target_rates_started_then_return_expected_result(evaluation_ @pytest.mark.asyncio @pytest.mark.parametrize("evaluation_mode,expected_result",[ - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, True), - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, True), - (CONFIG_ROLLING_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, True), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, True), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, True), + (CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, True), ]) async def test_when_target_rates_in_past_then_return_expected_result(evaluation_mode: str, expected_result: bool): # Arrange diff --git a/tests/unit/utils/test_apply_weighting.py b/tests/unit/utils/test_apply_weighting.py new file mode 100644 index 00000000..4de4517c --- /dev/null +++ b/tests/unit/utils/test_apply_weighting.py @@ -0,0 +1,51 @@ +from datetime import datetime, timedelta +from decimal import Decimal +import pytest + +from tests.unit import create_rate_data +from custom_components.octopus_energy.utils.weightings import RateWeighting, apply_weighting + +period_from = datetime.strptime("2024-11-26T00:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") +period_to = datetime.strptime("2024-11-27T00:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + +@pytest.mark.asyncio +async def test_when_applicable_rates_is_none_then_none_is_returned(): + applicable_rates = None + rate_weightings: list[RateWeighting] = [] + + new_applicable_rates = apply_weighting(applicable_rates, rate_weightings) + assert new_applicable_rates is None + +@pytest.mark.asyncio +async def test_when_rate_weightings_is_none_then_applicable_rates_is_returned(): + applicable_rates = create_rate_data(period_from, period_to, [1, 2]) + rate_weightings: list[RateWeighting] = None + + new_applicable_rates = apply_weighting(applicable_rates, rate_weightings) + + assert new_applicable_rates == applicable_rates + for rate in new_applicable_rates: + assert "weighting" not in rate + +@pytest.mark.asyncio +async def test_when_rate_weightings_is_available_then_weighting_is_added(): + applicable_rates = create_rate_data(period_from, period_to, [1, 2]) + + free_electricity_period_from = datetime.strptime("2024-11-26T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + free_electricity_period_to = datetime.strptime("2024-11-26T11:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + expected_weighting = Decimal(1.5) + rate_weightings: list[RateWeighting] = [ + RateWeighting(start=free_electricity_period_from, end=free_electricity_period_to, weighting=expected_weighting) + ] + + new_applicable_rates = apply_weighting(applicable_rates, rate_weightings) + + assert new_applicable_rates is not None + for rate in new_applicable_rates: + if (rate["start"] == datetime.strptime("2024-11-26T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") or + rate["start"] == datetime.strptime("2024-11-26T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + timedelta(minutes=30) or + rate["start"] == datetime.strptime("2024-11-26T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + timedelta(minutes=60)): + assert "weighting" in rate + assert rate["weighting"] == expected_weighting + else: + assert "weighting" not in rate \ No newline at end of file diff --git a/tests/unit/utils/test_merge_weightings.py b/tests/unit/utils/test_merge_weightings.py new file mode 100644 index 00000000..a25fb3c9 --- /dev/null +++ b/tests/unit/utils/test_merge_weightings.py @@ -0,0 +1,98 @@ +from datetime import datetime, timedelta +from decimal import Decimal +import pytest + +from custom_components.octopus_energy.utils.weightings import RateWeighting, merge_weightings + +def create_weightings(start: datetime, end: datetime, weighting: Decimal): + weightings = [] + current = start + while current < end: + weightings.append(RateWeighting(start=current, end=current + timedelta(minutes=30), weighting=weighting)) + current = current + timedelta(minutes=30) + + return weightings + +@pytest.mark.asyncio +async def test_when_new_weightings_is_none_then_current_weightings_returned(): + current_date = datetime.strptime("2024-12-24T10:16:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + new_weightings: list[RateWeighting] = None + current_weightings: list[RateWeighting] = [] + + merged_weightings = merge_weightings(current_date, new_weightings, current_weightings) + assert merged_weightings == [] + +@pytest.mark.asyncio +async def test_when_current_weightings_is_none_then_new_weightings_returned(): + current_date = datetime.strptime("2024-12-24T10:16:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + new_weightings: list[RateWeighting] = [] + current_weightings: list[RateWeighting] = None + + merged_weightings = merge_weightings(current_date, new_weightings, current_weightings) + assert merged_weightings == [] + +@pytest.mark.asyncio +async def test_when_new_weightings_in_past_then_weightings_returned(): + current_date = datetime.strptime("2024-12-24T10:16:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + expected_weighting = 1.5 + new_weightings: list[RateWeighting] = create_weightings( + datetime.strptime("2024-12-22T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), + datetime.strptime("2024-12-22T11:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), + expected_weighting + ) + current_weightings: list[RateWeighting] = None + + merged_weightings = merge_weightings(current_date, new_weightings, current_weightings) + assert merged_weightings == new_weightings + +@pytest.mark.asyncio +async def test_when_current_weightings_in_past_then_current_weightings_more_than_24_hours_removed(): + current_date = datetime.strptime("2024-12-24T10:16:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + expected_weighting = 1.5 + new_weightings: list[RateWeighting] = [] + current_weightings: list[RateWeighting] = create_weightings( + datetime.strptime("2024-12-23T09:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), + datetime.strptime("2024-12-23T11:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), + expected_weighting + ) + + merged_weightings = merge_weightings(current_date, new_weightings, current_weightings) + assert len(current_weightings) == 4 + assert len(merged_weightings) == 2 + + assert merged_weightings[0].start == datetime.strptime("2024-12-23T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + assert merged_weightings[0].end == datetime.strptime("2024-12-23T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + assert merged_weightings[0].weighting == expected_weighting + + assert merged_weightings[1].start == datetime.strptime("2024-12-23T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + assert merged_weightings[1].end == datetime.strptime("2024-12-23T11:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + assert merged_weightings[1].weighting == expected_weighting + +@pytest.mark.asyncio +async def test_when_new_weightings_and_current_weightings_exist_for_same_period_then_new_weighting_wins(): + current_date = datetime.strptime("2024-12-24T10:16:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + expected_weighting = 1.5 + new_weightings: list[RateWeighting] = create_weightings( + datetime.strptime("2024-12-24T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), + datetime.strptime("2024-12-24T11:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), + expected_weighting + ) + current_weightings: list[RateWeighting] = create_weightings( + datetime.strptime("2024-12-24T09:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), + datetime.strptime("2024-12-24T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), + expected_weighting + 0.5 + ) + + merged_weightings = merge_weightings(current_date, new_weightings, current_weightings) + assert len(merged_weightings) == 4 + + current = datetime.strptime("2024-12-24T09:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + for i in range(len(merged_weightings)): + assert merged_weightings[i].start == current + current += timedelta(minutes=30) + assert merged_weightings[i].end == current + + if merged_weightings[i].start >= datetime.strptime("2024-12-24T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"): + assert merged_weightings[i].weighting == expected_weighting + else: + assert merged_weightings[i].weighting != expected_weighting \ No newline at end of file diff --git a/tests/unit/utils/test_validate_rate_weightings.py b/tests/unit/utils/test_validate_rate_weightings.py new file mode 100644 index 00000000..deba5747 --- /dev/null +++ b/tests/unit/utils/test_validate_rate_weightings.py @@ -0,0 +1,184 @@ +from datetime import datetime, timedelta +import pytest + +from custom_components.octopus_energy.utils.weightings import validate_rate_weightings + +@pytest.mark.asyncio +async def test_when_weightings_is_none_then_empty_list_returned(): + weightings: list[dict] = None + + result = validate_rate_weightings(weightings) + assert result.success == True + assert result.weightings == [] + +@pytest.mark.asyncio +async def test_when_weightings_is_empty_list_then_empty_list_returned(): + weightings: list[dict] = [] + + result = validate_rate_weightings(weightings) + assert result.success == True + assert result.weightings == [] + +@pytest.mark.asyncio +@pytest.mark.parametrize("start",[ + ("A"), + ("2024-13-01T00:00:00Z"), + ("2024-12-32T00:00:00Z"), + ("2024-12-24T24:00:00Z"), + ("2024-12-24T23:60:00Z"), + ("2024-12-24T23:00:60Z"), +]) +async def test_when_start_is_not_valid_iso_datetime_then_error_is_returned(start: str): + weightings: list[dict] = [ + { + "start": start, + "end": "2024-12-24T00:30:00Z", + "weighting": 1.5 + } + ] + + result = validate_rate_weightings(weightings) + assert result.success == False + assert result.error_message == "start was not a valid ISO datetime in string format at index 0" + +@pytest.mark.asyncio +async def test_when_start_does_not_contain_timezone_then_error_is_returned(): + weightings: list[dict] = [ + { + "start": "2024-12-24T00:00:00", + "end": "2024-12-24T00:30:00Z", + "weighting": 1.5 + } + ] + + result = validate_rate_weightings(weightings) + assert result.success == False + assert result.error_message == "start must include timezone at index 0" + +@pytest.mark.asyncio +@pytest.mark.parametrize("end",[ + ("A"), + ("2024-13-01T00:00:00Z"), + ("2024-12-32T00:00:00Z"), + ("2024-12-24T24:00:00Z"), + ("2024-12-24T23:60:00Z"), + ("2024-12-24T23:00:60Z"), +]) +async def test_when_end_is_not_valid_iso_datetime_then_error_is_returned(end: str): + weightings: list[dict] = [ + { + "start": "2024-12-24T00:00:00Z", + "end": end, + "weighting": 1.5 + } + ] + + result = validate_rate_weightings(weightings) + assert result.success == False + assert result.error_message == "end was not a valid ISO datetime in string format at index 0" + +@pytest.mark.asyncio +async def test_when_end_does_not_contain_timezone_then_error_is_returned(): + weightings: list[dict] = [ + { + "start": "2024-12-24T00:00:00Z", + "end": "2024-12-24T00:30:00", + "weighting": 1.5 + } + ] + + result = validate_rate_weightings(weightings) + assert result.success == False + assert result.error_message == "end must include timezone at index 0" + +@pytest.mark.asyncio +async def test_when_end_is_before_start_then_error_is_returned(): + weightings: list[dict] = [ + { + "start": "2024-12-24T00:30:00Z", + "end": "2024-12-24T00:29:59Z", + "weighting": 1.5 + } + ] + + result = validate_rate_weightings(weightings) + assert result.success == False + assert result.error_message == "start must be before end at index 0" + +@pytest.mark.asyncio +@pytest.mark.parametrize("end",[ + ("2024-12-24T00:29:59Z"), + ("2024-12-24T00:30:01Z"), +]) +async def test_when_time_period_is_not_thirty_minutes_then_error_is_returned(end: str): + weightings: list[dict] = [ + { + "start": "2024-12-24T00:00:00Z", + "end": end, + "weighting": 1.5 + } + ] + + result = validate_rate_weightings(weightings) + assert result.success == False + assert result.error_message == "time period must be equal to 30 minutes at index 0" + +@pytest.mark.asyncio +async def test_when_start_minute_is_not_valid_then_error_is_returned(): + + for minute in range(60): + if minute == 0 or minute == 30: + continue + + weightings: list[dict] = [ + { + "start": f"2024-12-24T00:{minute:02}:00Z", + "end": (datetime.strptime("2024-12-24T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z") + timedelta(minutes=30 + minute)).isoformat(), + "weighting": 1.5 + } + ] + + print(weightings[0]["start"]) + result = validate_rate_weightings(weightings) + assert result.success == False + assert result.error_message == "start minute must equal 0 or 30 at index 0" + +@pytest.mark.asyncio +@pytest.mark.parametrize("start,end",[ + ("2024-12-24T00:00:01Z", "2024-12-24T00:30:01Z"), + ("2024-12-24T00:00:00.1Z", "2024-12-24T00:30:00.1Z"), +]) +async def test_when_time_period_is_not_thirty_minutes_then_error_is_returned(start: str, end: str): + weightings: list[dict] = [ + { + "start": start, + "end": end, + "weighting": 1.5 + } + ] + + result = validate_rate_weightings(weightings) + assert result.success == False + assert result.error_message == "start second and microsecond must equal 0 at index 0" + +@pytest.mark.asyncio +@pytest.mark.parametrize("start,end", [ + ("2024-12-24T00:00:00Z", "2024-12-24T00:30:00Z"), + ("2024-12-24T00:30:00Z", "2024-12-24T01:00:00Z"), +]) +async def test_when_data_is_valid_then_success_is_returned(start: str, end: str): + weightings: list[dict] = [ + { + "start": start, + "end": end, + "weighting": 1.5 + } + ] + + result = validate_rate_weightings(weightings) + assert result.success == True + assert len(result.weightings) == 1 + + assert result.weightings[0].start == datetime.strptime(start, "%Y-%m-%dT%H:%M:%S%z") + assert result.weightings[0].end == datetime.strptime(end, "%Y-%m-%dT%H:%M:%S%z") + assert result.weightings[0].weighting == weightings[0]["weighting"] \ No newline at end of file